Generic
제네릭(generic)은 타입을 변수화해 동적으로 타입을 설정할 수 있도록 하는 기능입니다.
interface NodeWithId {
id: string;
}
function findNode<T extends NodeWithId>(array: Array<T>, id: string): T | undefined {
return array.find((node) => node.id === id);
}
// * * *
interface MyNode extends NodeWithId {
description: string;
}
const nodes: Array<MyNode> = [{ id: 'jmaing', description: '맹' }];
const jmaing = findNode(nodes, 'jmaing'); // findNode<MyNode>(...)
const description = jmaing?.description;
TypeScript
복사
제네릭을 사용하면, 특정 데이터 타입을 명시하지 않고도 여러 타입에 대해 재사용 가능한 코드를 작성할 수 있습니다.
Variance
변성(variance)은 제네릭 타입이 상속 관계에서 어떻게 처리되는지를 다루는 개념입니다.
타입 T의 서브타입 S가 있을 때,
•
공변성(Covariance): Covariance<S>는 Covariance<T>의 서브타입입니다. 가장 일반적이죠.
•
반공변성(Contravariance): Contravariance<T>가 Contravariance<S>의 서브타입입니다.
•
이변성(Bivariance): 공변이면서 반공변입니다. 서로가 서로의 서브타입인, 이상한 경우죠.
•
무공변(Invariant): 공변도 아니고 반공변도 아닙니다.
이렇게 네가지로 나눌 수 있습니다.
언어별 variance
C#
class Animal { }
class Dog : Animal { }
interface IFactory<out T> { // out: 공변 키워드
T Create(); // T가 출력 타입에 쓰일 때 공변
}
class DogFactory : IFactory<Dog> {
public Dog Create() {
return new Dog();
}
}
public class Program {
public static void Main() {
IFactory<Animal> animalFactory = new DogFactory(); // 공변
Animal animal = animalFactory.Create();
}
}
C#
복사
다른 언어도 마찬가지지만, 제네릭 파라미터가 어디에 쓰이는지에 따라 변성이 달라집니다.
•
출력에만 쓰이면 공변
•
입력에만 쓰이면 반공변
•
둘 다 쓰이면 무공변
class Animal { }
class Dog : Animal { }
interface IProcessor<in T> { // in: 반공변 키워드
void Process(T value); // T가 입력 타입에 쓰일 때 반공변
}
class AnimalProcessor : IProcessor<Animal> {
public void Process(Animal value) {
Console.WriteLine("Processing Animal");
}
}
public class Program {
public static void Main() {
IProcessor<Dog> dogProcessor = new AnimalProcessor(); // 반공변
dogProcessor.Process(new Dog());
}
}
C#
복사
in이나 out 키워드 없이 쓰이면 기본적으로는 무공변입니다.
Java
class Animal {}
class Dog extends Animal {}
public class CovariantExample {
// 리스트의 출력만 사용했으므로 공변, extends는 이를 위한 키워드
public static void printAnimals(List<? extends Animal> animals) {
for (Animal animal : animals) {
System.out.println(animal);
}
// animals가 무엇의 리스트일지 모르기 때문에 (null 외에는) 쓰기는 불가능함
}
public static void main(String[] args) {
List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog());
printAnimals(dogs); // 공변
}
}
Java
복사
자바에는 사용 방법이 다르지만, C#의 in이나 out 대신 ? extends와 ? super가 있습니다.
class Animal {}
class Dog extends Animal {}
public class ContravariantExample {
// 리스트의 입력만 사용했으므로 반공변, super는 이를 위한 키워드
public static void addAnimal(List<? super Dog> animals) {
animals.add(new Dog());
// 대신 여기서 animals를 읽으면 Object가 나옴
}
public static void main(String[] args) {
List<Animal> animals = new ArrayList<>();
addAnimal(animals); // 반공변
List<Object> objects = new ArrayList<>();
addAnimal(objects); // 심지어 Object도 됨
}
}
Java
복사
자바의 제네릭도 C#과 마찬가지로 기본적으로 무공변성을 따릅니다.
Typescript
타입스크립트는 변성을 지정하기 위한 별도의 키워드는 없지만, 타입 간의 연산으로 두 타입 간에 대입이 가능한지 확인할 수 있습니다.
type IsAssignableTo<A, B> = A extends B ? true : false;
TypeScript
복사
앞서 변성은 제네릭 파라미터를 어디에 쓰는지에 따라 결정된다고 했는데요,
type Covariant<T> = () => T;
type Contravariant<T> = (arg: T) => void;
type Invariant<T> = (arg: T) => T;
type Variance<A, B> =
A extends B
? B extends A
? "bivariant"
: "covariant"
: B extends A
? "contravariant"
: "invariant";
interface SuperType {
a: number;
}
interface SubType extends SuperType {
b: string;
}
type CovarianceTest = Variance<Covariant<SubType>, Covariant<SuperType>>;
type ContravarianceTest = Variance<Contravariant<SubType>, Contravariant<SuperType>>;
type InvarianceTest = Variance<Invariant<SubType>, Invariant<SuperType>>;
TypeScript
복사
이렇게 변성을 확인할 수 있습니다. (물론 strictFunctionTypes는 true라고 가정합니다.)
declare function magic<T>(): T;
const subMagic: Covariant<SubType> = magic();
const superMagic: Covariant<SuperType> = subMagic; // 공변
const contraSubMagic: Contravariant<SuperType> = magic();
const contraSuperMagic: Contravariant<SubType> = contraSubMagic; // 반공변
const subArray: Array<SubType> = [];
const superArray: Array<SuperType> = subArray; // 역시 공변
// ===== 위 코드는 타입 오류 없음 =====
const errorSubMagic: Contravariant<SuperType> = contraSuperMagic; // 오류
// Type 'Contravariant<SubType>' is not assignable to type 'Contravariant<SuperType>'.
TypeScript
복사
변성을 별도로 따지지는 않지만 structural subtyping에서 assignability를 따지기 때문에 잘못된 대입을 하는 경우 타입 오류가 발생하게 됩니다.
그런데 뭔가 이상하죠? 다른 C#이나 Java 등에서는 List도 기본적으로 무공변성이었는데, 타입스크립트에서는 공변성이네요.
interface SuperType {
a: number;
}
interface SubType extends SuperType {
b: string;
}
function troll(superArray: Array<SuperType>) {
superArray.push({ a: 1 });
}
const subArray: Array<SubType> = [];
troll(subArray);
// subArray에 과연 SubType만 들어있을까요?
for (const sub of subArray) {
console.log(sub.b.length); // 런타임 에러
}
TypeScript
복사
이는 타입스크립트가 변성을 다루는 방법이 다르기 때문에 발생하는 문제입니다.
function safe<T extends SuperType>(subArray: Array<T>) {
subArray.push({ a: 1 }); // 타입 오류
}
TypeScript
복사
타입스크립트에서는 extends를 이렇게 써서 제네릭 파라미터에 제약 조건을 걸 수 있습니다.
Bivariance
이변성은 제네릭 파라미터가 쓰였다면, 사실은 말이 안 됩니다.
출력에 쓰이면 공변, 입력에 쓰이면 반공변, 둘 다 쓰이면 무공변이니까요.
하지만 특히 타입스크립트에서는, 편의상 이변성을 가정하는 것이 도움이 될 때가 있습니다.
물론 편의상 가정하는 것이기 때문에, 실제로는 말이 안 되는, 안전하지 않은 타입이며 일반적으로 권장되지 않습니다.
type EventHandler<T extends SuperType> = (arg: T) => void;
const handlers: Array<EventHandler<SuperType>> = [];
const handler: EventHandler<SubType> = (sub: SubType) => console.log(sub.b.length);
handlers.push(handler);
// Argument of type 'EventHandler<SubType>' is not assignable to parameter of type 'EventHandler<SuperType>'.
// ===== 위 코드는 타입 오류, 아래 코드는 타입 오류는 없지만 위험함 =====
type EventHandler<T extends SuperType> = {
bivarianceHack(arg: T): void;
}["bivarianceHack"];
const handlers: Array<EventHandler<SuperType>> = [];
const handler: EventHandler<SubType> = (sub: SubType) => console.log(sub.b.length);
handlers.push(handler);
TypeScript
복사
타입스크립트variance 표
참고로 타입스크립트에서 함수가 아닌 그냥 값은 공변성으로 취급됩니다.
type ThisIsCovariant<T> = T;
type Result = Variance<ThisIsCovariant<SubType>, ThisIsCovariant<SuperType>>;
// covariant
TypeScript
복사
즉 (arg: T) => void는 (arg: Covariant<T>) => void와 변성이 반공변으로 같고, 마찬가지로 (arg: () => T) => void와도 변성이 반공변으로 같습니다.
여기서 입력(인자)이나 출력(리턴) 타입의 변성에 따라 variance의 표를 만들면 다음처럼 됩니다:
인자 \ 리턴 | bivariant | covariant | contravariant | invariant |
bivariant | bivariant | contravariant | covariant | invariant |
covariant | covariant | invariant | covariant | invariant |
contravariant | contravariant | contravariant | invariant | invariant |
invariant | invariant | invariant | invariant | invariant |
bivariant를 covariant + contravariant, invaraint를 0으로 본다면, covariant 여부, contravariant 여부를 각각 O/X로 나타내 표를 다음처럼 바꿀 수도 있겠습니다.
인자 \ 리턴 | O / O | O / X | X / O | X / X |
O / O | O / O | X / O | O / X | X / X |
O / X | O / X | X / X | O / X | X / X |
X / O | X / O | X / O | X / X | X / X |
X / X | X / X | X / X | X / X | X / X |
규칙이 보이시나요?
•
covariant 여부 = 인자의 contravariant 여부 AND 리턴의 covariant 여부
•
contravariant 여부 = 인자의 covariant 여부 AND 리턴의 contravariant 여부
그런데 여기서 (t: T) => T같은 형식이 아니라 이변성을 갖게 { _(t: T) => T }['_'] 처럼 쓰면 표가 이렇게 바뀝니다:
인자 \ 리턴 | bivariant | covariant | contravariant | invariant |
bivariant | bivariant | contravariant | covariant | invariant |
covariant | bivariant | contravariant | covariant | invariant |
contravariant | contravariant | contravariant | invariant | invariant |
invariant | contravariant | contravariant | invariant | invariant |
즉, { _(t: T) => T }['_']처럼 쓰면 covariant 또는 bivariant한 경우 contravariance를 추가로 부여한다고 볼 수 있겠습니다.