프로필 사진
Sojin Park
Frontend Dev

공변과 반공변
제네릭 타입을 더욱 잘 사용할 수 있는 공변과 반공변 개념에 대해 알아보자
2019. 3. 9.

최근 사용되고 있는 프로그래밍 언어 대부분에서는 제네릭 타입Generic type을 지원하고 있다. 제네릭 타입이란 Array<T>와 같은 형식으로 타입 인자Type argument를 받아 새로운 타입을 만드는 타입을 말한다. 예를 들어, Array<T> 제네릭 타입의 T 타입 인자에 number를 제공함으로써 숫자의 배열 Array<number>를 만들 수 있고, string을 제공함으로써 문자열의 배열 Array<string>을 만들 수 있다.

또 프로그래밍 언어에서 대부분 기본적으로 제공되는 개념으로 파생 타입Subtype이 있다. 예를 들어, 프로그래머는 Animal 타입을 정의하면서 그 파생 타입으로 Dog 타입도 정의할 수 있다. 이때 Dog은 파생 타입이므로 그 부모 타입 Animal 타입에 할당할 수 있지만 그 반대로 AnimalDog에 할당할 수 없다.

이 두 가지 개념을 합치면 다음과 같은 질문을 던질 수 있다. Animal 타입과 그 파생 타입 Dog을 제네릭 타입 Array<T>에 제공하였을 때, 두 타입 Array<Animal>Array<Dog>의 관계는 어떻게 되는가? Array<Animal>Array<Dog>의 부모 타입인가, 아니면 파생 타입인가?

이 질문에 대한 답은 제네릭 타입 Array<T>에서 타입 인자 T의 성질에 따라 결정된다. 이때 필요한 개념이 공변共變, Covariance과 반공변反共變, Contravariance이다.

공변 타입

공변의 정의를 살펴보면 다음과 같다.

제네릭 타입 Generic<T>에서 타입 인자 T에 부모 타입 Super와 파생 타입 Sub이 주어졌을 때, 마찬가지로 Generic<Super>가 부모 타입, Generic<Sub>이 파생 타입이 되는 경우, 타입 인자 T는 공변Covariant이라고 한다.

TypeScript에서 배열 타입 Array<T>, 프로미스 타입 Promise<T>, Rx의 감시 가능 타입 Observable<T>, fp-tsMaybe<T> 등은 대표적으로 타입 인자가 공변인 예이다. Array<T>에서 T가 공변인지 아닌지 확인하기 위해 다음과 같이 생각할 수 있다. 일반적으로 어떤 개의 리스트 Array<Dog>가 주어져도 그것을 동물의 리스트 Array<Animal>에 할당할 수 있다. 이로부터 보면 자연스럽게 Array<Animal>은 부모 타입, Array<Dog>은 파생 타입이 된다. 이는 AnimalDog의 부모 타입-파생 타입 관계를 따르고 있으므로, T는 공변이다.

일반적으로 제네릭 타입 T가 값에 대한 타입을 나타낼 때, T는 공변 타입이 된다. 예를 들어, Array<T>에서 T는 배열 안의 각 원소의 타입을 나타낸다. 각 원소는 값이 되므로, T는 공변이다. 유사하게 Promise<T>에서 T도 비동기 작업의 결과 타입을 나타내므로 공변이다. 비슷한 사고방식을 Observable<T>Maybe<T> 등에 적용할 수 있다.

TypeScript에서 공변성

TypeScript에서는 제네릭 타입 인자 T가 타입 안에서 값 타입으로 사용되었을 경우 자동으로 T를 공변으로 추론한다.

interface Generic<T> {
  value: T;
}

let numberGeneric: Generic<number> = {
  value: 320,
};

type ZeroOrOne = 0 | 1;

let zeroOrOneGeneric: Generic<ZeroOrOne> = {
  value: 0,
};

numberGeneric = zeroOrOneGeneric; // OK
zeroOrOneGeneric = numberGeneric; // TypeError

위에서 Generic<T>에서 T는 제네릭 타입이 들고 있는 값으로서 사용되었다. 따라서 여기에서 T는 공변으로 추론된다.

ZeroOrOne의 모든 원소가 number에 할당될 수 있으므로, number는 부모 타입, ZeroOrOne은 파생 타입이 된다. Generic<T>에서 T는 공변이므로 마찬가지로 Generic<number>는 부모 타입, Generic<ZeroOrOne>은 그 파생 타입이다. 따라서 numberGenericzeroOrOneGeneric을 할당하는 것은 타입 시스템 상 문제가 없지만, 그 반대를 시도하면 컴파일러에서 타입 에러가 발생한다.

다른 언어에서 공변성

TypeScript에서는 공변 타입을 해당 타입이 값으로서 사용되었는지를 바탕으로 자동으로 추론하지만 많은 언어에서는 이러한 추론 방식을 사용하지 않는다. 대신 프로그래머가 수동으로 넣어주는 방식을 사용한다.

예를 들어, Scala에서 List[+A] 타입에서는 A를 공변 타입으로 명시하기 위해 앞에 +를 붙인다. 유사하게 C#에서는 List<out T>와 같이 앞에 out을 붙임으로써 T가 공변임을 표시한다.

반공변 타입

반공변 타입은 공변 타입과 정확히 반대를 나타내는데, 그 정의를 살펴보면 다음과 같다.

제네릭 타입 Generic<T>에서 부모 타입 Super, 파생 타입 Sub의 관계에 대해 Generic<Sub>이 부모 타입, Generic<Super>이 파생 타입이 되는 관계가 성립하는 경우 제네릭 타입 인자 T를 반공변Contravariant이라고 한다.

처음에 보기에 굉장히 낯선 개념이지만 의외로 프로그래밍을 하다 보면 가끔 만나볼 수 있는 타입이다. 예를 들어 다음과 같은 StringMaker<Input> 타입을 생각해 보자.

type StringMaker<Input> = (input: Input) => string;

이 타입의 인스턴스를 구현해보자면 다음과 같다.

let numberStringMaker: StringMaker<number> = (input: number) => {
  return `Got number input: ${input.toString()}`;
};

let zeroOrOneStringMaker: StringMaker<ZeroOrOne> = (input: ZeroOrOne) => {
  return `Got zero or one: ${input.toString()}`;
};

이제 StringMaker<number>StringMaker<ZeroOrOne>의 관계에 대해 생각해 보자. SubSuper에 할당 가능하다고 하는 것은 Super가 하는 모든 일을 Sub이 할 수 있음을 의미한다. StringMaker<number>는 어떤 숫자가 주어져도 문자열을 만들 수 있다. 그러나 StringMaker<ZeroOrOne>은 0 또는 1이 주어진 경우에만 문자열을 만들 수 있다. 이로부터 StringMaker<number>StringMaker<ZeroOrOne>이 하는 모든 작업을 수행할 수 있고, 그보다 더 넓은 범위의 일까지 수행함을 확인할 수 있다. 그러나 그 반대의 경우는 성립하지 않는다. 즉, 이 경우 StringMaker<ZeroOrOne>이 부모 타입이고 StringMaker<number>이 파생 타입이 되는 것이다.

number가 부모 타입이고 ZeroOrOne이 파생 타입인 상황에서 StringMaker<ZeroOrOne>이 부모 타입, StringMaker<number>가 파생 타입이 되어버린 상황을 보면 제네릭 타입 StringMaker<Input>의 타입 인자 Input은 반공변임을 확인할 수 있다.

일반적으로 제네릭 타입 Generic<T>의 타입 인자 T가 함수의 입력 타입으로 사용될 때 T는 반공변이 된다. 예를 들어 다음과 같은 제네릭 타입에서 타입 인자는 반공변이다.

interface NumberReader<T> {
  from: (input: T) => number;
}

TypeScript에서 반공변성

TypeScript에서는 위와 같이 타입 인자 T가 함수의 입력 타입으로 사용되는 것을 확인했을 때 T를 자동으로 반공변으로 추론한다. 따라서 StringMaker<ZeroOrOne>StringMaker<number>에 할당하려고 하면 타입 오류가 발생한다.

다른 언어에서 반공변성

Scala에서는 반공변 타입을 키워드 -을 이용하여 Generic[-A]와 같이 표시한다. C#에서는 in 키워드를 이용하여 Generic<in A>처럼 표시한다.

정리

  1. 공변과 반공변은 제네릭 타입 Generic<T>에서 타입 인자 T가 가지는 성질이다.
  2. 부모 타입 Super와 파생 타입 Sub에 대해 Generic<Super>가 부모 타입, Generic<Sub>이 파생 타입이 되는 경우, T는 공변이라고 한다.
  3. 반대로 Generic<Sub>이 부모 타입, Generic<Super>가 파생 타입이 되는 경우, T는 반공변이라고 한다.