🌈

타입 시스템에서의 변성(Variance) — 공변성(Covariance)과 반공변성(Contravariance)

Date
2021/10/20
Tags
Comp. Sci
Type System
Created by
타입 시스템의 Covariance와 Contravariance라는 개념을 풀어 쓰려고 노력했습니다.
Table of Contents

그런 용어가 있었어? 왜?

타입 계층 관계가 존재하는 타입 시스템에는 CovarianceContravariance라는 개념(+ Invariance, Bivariance)이 존재한다. 이러한 개념들이 대체 왜 존재하는가?
많은 프로그래밍 언어들의 타입 시스템은 subtyping 개념을 지원한다. 당장 리스코프 치환 원칙을 떠올려 보자. 하위 타입  상위 타입 관계가 실제로 의미하는 것은 상위 타입의 객체를 하위 타입의 객체로 치환해도 동작에 문제가 없어야 한다는 것이다. CatAnimalsubtype이라면, Animal expression이 쓰이는 곳을 Cat expression으로 모두 치환할 수 있어야 한다.
여기서 Variance(변성)이라는 것은, 더 복잡한 타입들끼리의 subtyping이 등장했을 때 어떻게 동작해야 하는 것에 관한 것이다. 예를 들어, List<Cat>List<Animal> 간의 관계는 어떻게 되는가? Cat을 return하는 함수와 Animal을 return하는 함수 간의 관계는 어떻게 되는가?
이러한 상황에서 프로그래밍 언어를 설계하는 측에서는 배열, 상속, 제네릭 등과 같은 언어 기능에 대한 Typing rule을 정할 때 Variance를 반드시 고려해야 하며, 이를 사용하는 프로그래머도 타입 안정성을 지키기 위해 (+ 대체 왜 이건 되고 이건 안 되는지 모르겠는 상황을 피하기 위해) Variance에 대한 개념을 숙지해야 한다.
C#의 예를 들자면, 상속 관계에 있는 클래스(객체)끼리는 서로 형변환이 가능한데, 형변환으로 발생할 수 있는 모든 예외를 컴파일 타임에 검증하는 것이 거의 불가능에 가까웠다. 그래서 형변환을 Covariant와 Contravariant로 분류하게 되었고, 형변환을 분류하여 처리함으로써 컴파일 타임에 더 많은 에러를 잡을 수 있게 되었다. 이를 어떻게 언어에서 구현하는지는 이 글을 끝까지 읽어보도록 하자.

정의

타입 시스템의 Typing rule 또는 Type constructor(제네릭 등)는 다음 중 하나의 성질을 가진다.
Covariant(공변): 서브타입의 순서가 보존되는 경우. 즉, 좁은(more specific) 타입 ≤ 넓은(more generic) 타입 순서를 보존하는 경우.
즉, Type constructor가 타입 AB에 대해 T<A>T<B>인 경우.
Contravariant(반공변): 서브타임의 순서가 반대로 보존되는 경우. 즉, 타입의 순서가 좁은(more specific) 타입 ≥ 넓은(more generic) 인 경우.
즉, Type constructor가 타입 AB에 대해 T<A>T<B>인 경우.
Bivariant: 넓은 타입으로도, 좁은 타입으로도 변환 가능한 것.
즉, Covariant하면서 Contravariant한 것.
즉, Type constructor가 타입 AB에 대해 T<A>T<B>인 경우.
Invariant(무공변): 타입을 변환할 수 없는 것.
즉, Covariant하지도, Contravariant하지도, Bivariant하지도 않은 것.
솔직히 정의만 봐서는 이 개념이 왜 필요한지, 무엇을 설명하고자 하는 것인지 잘 와닿지 않는다.

예시를 통해 정의를 익혀보자

언어에 종속되지 않는 클래스 개념으로 예시를 들어보자. 세 개의 클래스가 있다.
class Animal {} class Dog extends Animal {} class Greyhound extends Dog {}
TypeScript
복사
위 코드에서 크게 두 가지 사실을 파악할 수 있다.
DogAnimalsubtype이고, AnimalDogsupertype이다.
GreyhoundDogsubtype이고, DogGreyhoundsupertype이다.
세 개의 타입의 순서를 나열하자면 다음과 같다.
Greyhound ≤ Dog ≤ Animal
Plain Text
복사

Covariance(공변성)

어떤 Typing rule 또는 Type constructor가 Covariant(공변)이라는 것은 서브타입의 순서가 보존되는 것을 말한다. 즉, subtype은 받지만 supertype은 받지 않는 것을 말한다.
다음과 같은 예시가 있을 때, Covariant<T>, 나아가 acceptDogCovariance 함수는 공변성을 띈다. subtype은 받지만 supertype은 받지 않기 때문이다.
const acceptDogCovariance = function (value: Covariant<Dog>) { ... } acceptDogCovariance(new Animal()) // Error (Dog ≤ Animal) acceptDogCovariance(new Dog()) // Ok (Dog ≡ Dog) acceptDogCovariance(new Greyhound()) // Ok (Greyhound ≤ Dog)
TypeScript
복사

Contravariance(반공변성)

반공변성은 공변성과 정반대로, supertype은 받지만 subtype은 받지 않는 것을 말한다.
다음 예시에서, Contravariance<T>acceptDogContravariance 함수는 반공변성을 띈다. supertype은 받지만 subtype은 받지 않기 때문이다.
const acceptDogContravariance = function (value: Contravariance<Dog>) { ... } acceptDogContravariance(new Animal()) // Ok (Dog ≤ Animal) acceptDogContravariance(new Dog()) // Ok (Dog ≡ Dog) acceptDogContravariance(new Greyhound()) // Error (Greyhound ≤ Dog)
TypeScript
복사

Bivariance

Bivariant하다는 것은 “넓은 타입으로도, 좁은 타입으로도 변환 가능한 것”이라고 설명했다. 즉, subtype도 받고, supertype도 받는 것을 말한다.
const acceptDogBivariance = function (value: Bivariant<Dog>) { ... } acceptDogBivariance(new Animal()) // Ok (Dog ≤ Animal) acceptDogBivariance(new Dog()) // Ok (Dog ≡ Dog) acceptDogBivariance(new Greyhound()) // Ok (Greyhound ≤ Dog)
TypeScript
복사

Invariance(무공변성)

Invariant하다는 것은 “타입을 변환할 수 없는 것”이라고 설명했다. 즉, subtype이고 supertype이고 나발이고 해당 타입이 아니면 받지 않는 것을 말한다.
const acceptDogInvariance = function (value: Invariant<Dog>) { ... } acceptDogInvariance(new Animal()) // Error (Dog ≤ Animal) acceptDogInvariance(new Dog()) // Ok (Dog ≡ Dog) acceptDogInvariance(new Greyhound()) // Error (Greyhound ≤ Dog)
TypeScript
복사

예시를 통해 자세히 알아보자

위에서 추상적으로 설명한 Variance는 타입 언어마다 각기 다르게 동작한다. 심지어 일부 함수형 언어의 경우 Type variance를 직접 설정할 수도 있다.

TypeScript

TypeScript에서의 타입 시스템 동작은 다음과 같이 정리할 수 있다.
기본적으로 Covariant하다.
함수 인수는 Contravariant하다. (정확히는 TSConfig의 strict 및 strictFunctionTypes가 true이면 Contravariant, false이면 Bivariant하다. 그리고 TSConfig의 strict 옵션은 기본적으로 true이다.)

TypeScript의 Covariance

let arrayStringOrNumber: Array<string | number> = []; let arrayString: Array<string> = []; arrayStringOrNumber = arrayString; // Ok arrayString = arrayStringOrNumber; // Error
TypeScript
복사
Array<string | number> 타입의 변수에 Array<string> 타입의 변수를 대입하는 것은 문제가 없지만, 반대 동작은 에러가 발생한다.
TypeScript의 타입 시스템에서 stringstring | number보다 더 좁은 타입인 것은 자명하다(stringstring | number). 이러한 상황에서 Array<string | number> 타입의 변수에 Array<string> 타입의 변수를 대입하는 것이 가능한 것이 확인되었으니(Array<string>Array<string | number>), 이는 TypeScript의 Covariance를 보여주는 좋은 예시가 된다.
예시를 하나 더 들어보자.
function func1(x: string): number { return 0; } type Func2 = (x: string) => number | string; let func2: Func2 = func1; // Ok
TypeScript
복사
func1(x: string) => number 타입이고, func2(x: string) => number | string 타입이다. 당연히 (x: string) => number(x: string) => number | string 이다.
이러한 상황에서 Func2 타입의 변수에 func1을 대입하는 것은 문제가 되지 않는다.
function func1(x: string): number | string { return 0; } type Func2 = (x: string) => number; let func2: Func2 = func1; // Error
TypeScript
복사
(x: string) => number 타입의 변수에 (x: string) => number | string 타입의 변수를 대입하려 하는 상황인데, 좁은 타입에 넓은 타입을 대입하려 하는 경우이므로 당연히 에러가 발생한다.

TypeScript의 Contravariance

예시를 보자.
function func1(x: string): number { return 0; } type Func2 = (x: string | number) => number; let func2: Func2 = func1; // Error
TypeScript
복사
function func1(x: string | number): number { return 0; } type Func2 = (x: string) => number; let func2: Func2 = func1; // Ok
TypeScript
복사
첫 번째 예시에서 func1(x: string) => number 타입이고, func2(x: string | number) => number 타입이다. 그러나 func2 타입에 func1을 대입하는 것은 불가능하다.
두 번째 예시에서 func1(x: string | number) => number 타입이고, func2(x: string) => number 타입이다. 그리고 func2 타입에 func1를 대입하는 것은 에러가 발생하지 않는다.
stringstring | number보다 더 좁은 타입인 것(stringstring | number)은 위에서도 예제를 통해 보았듯 자명한 사실이다. 하지만 결과를 통해 (x: string) => number(x: string | number) => number 임이 확인되었다. 바로 이 부분이 TypeScript가 Contravariance를 띄는 부분이다.

C#

C#은 언어 특성상 형변환, 제네릭, Delegate pattern 등 다양한 부분에서 Variance가 나타날 수 있다.

C#의 Covariance

IEnumerable<string> strings = new List<string>(); IEnumerable<object> objects = strings; // Ok
C#
복사
C#에서 stringobject를 상속받으므로 stringobject이고, IEnumerable<object> 타입의 변수에 IEnumerable<string>을 대입할 수 있으므로 IEnumerable<string>IEnumerable<object> 임을 알 수 있다. 이로써 IEnumerable<T>은 Covariance를 띄는 것을 확인할 수 있다.
Covariance를 띄는 다른 제네릭으로는 IEnumerator<T>, IQueryable<T>, IGrouping<TKey,TElement> 등이 있다.
또한, 제네릭 Delegator의 Generic type argument를 Covariant로 선언하고 싶다면 out 키워드를 사용하면 된다.
public delegate R DCovariant<out R>();
C#
복사

C#의 Contravariance

// static void SetObject(object o) { } Action<object> actObject = SetObject; Action<string> actString = actObject;
C#
복사
stringobject 이지만, Action<string> 타입의 변수에 Action<object>을 대입할 수 있으므로 Action<string>Action<object> 이 확인된다. 이로써 Action<T>는 Contravariance를 띄는 것을 확인할 수 있다.
Contravariant를 띄는 다른 제네릭으로는 IComparer<T>, IComparable<T>, IEqualityComparer<T> 등이 있다.
또한 마찬가지로, 제네릭 Delegator의 Generic type argument를 Contravariant로 선언하고 싶다면 in 키워드를 사용하면 된다.
public delegate void DContravariant<in A>(A a);
C#
복사
하나의 Delegator 안에서 서로 다른 Generic type argument들에 대해 Variance를 각각 설정해줄 수도 있다.
public delegate R DVariant<in A, out R>(A a);
C#
복사

Scala

스칼라는 T'T의 하위 타입일 때 (T’T) 다음과 같은 표기법을 이용해 Covariant, Contravariant, Invariant를 나타낸다.
의미
스칼라 표기
공변성(Covariant)
C[T’]C[T]
C[+T]
반공변성(Contravariant)
C[T’]C[T]
C[-T]
무공변성(Invariant)
C[T’]C[T]는 아무 관계가 없다
C[T]
다음은 스칼라의 공변성을 보여주는 예시이다.
scala> class Covariant[+A] defined class Covariant scala> val cv: Covariant[AnyRef] = new Covariant[String] cv: Covariant[AnyRef] = Covariant@4035acf6 scala> val cv: Covariant[String] = new Covariant[AnyRef] <console>:6: error: type mismatch; found : Covariant[AnyRef] required: Covariant[String] val cv: Covariant[String] = new Covariant[AnyRef] ^
Scala
복사
다음은 스칼라의 반공변성을 보여주는 예시이다.
scala> class Contravariant[-A] defined class Contravariant scala> val cv: Contravariant[String] = new Contravariant[AnyRef] cv: Contravariant[AnyRef] = Contravariant@49fa7ba scala> val fail: Contravariant[AnyRef] = new Contravariant[String] <console>:6: error: type mismatch; found : Contravariant[String] required: Contravariant[AnyRef] val fail: Contravariant[AnyRef] = new Contravariant[String]
Scala
복사

References