Search
😨

Generic의 variance

간단소개
제네릭의 변성에 대해 간단히 알아보자
팔만코딩경 컨트리뷰터
ContributorNotionAccount
주제 / 분류
개발지식
TypeScript
Scrap
태그
변성
variance
9 more properties

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
복사
이렇게 변성을 확인할 수 있습니다. (물론 strictFunctionTypestrue라고 가정합니다.)
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를 추가로 부여한다고 볼 수 있겠습니다.