Covariant, Contravariant, Invariant등 Type Variances 이해하기

Posted on Jan 3, 2020

거의 그대로 베껴온 원본 글

계약서로서의 타입

타입을 생각할 때, <계약서>로 생각하는 것이 편하다.

왜냐하면, 전달한 값이 그 타입의 범위 안에서 전달 받은 쪽에서 취급이 제한된다는 보장이 있어야 안전한 코드를 컴파일러는 생성해줄 수 있다. (혹은 그렇게 안전한지 체크를 해준다.)

Subtype, 일반화(Generalization), 특화(Specialization)

타입을 말할 때, 나는 보통 객체지향언어에서의 클래스의 상속 관계를 떠올린다. 꼭 그렇지는 않겠지만, 이 글에서도 그렇게 설명을 해보겠다.

이 글에서는 계속 다음 3개의 타입이 있고, 다음과 같은 관계라고 가정하겠다.

  class Animal(object): pass

  class Bird(Animal): pass

  class Human(Animal): pass

Animal 타입이 있고, 그를 직접 상속하는 Bird, Human 타입이 있다. (클래스와 타입을 섞어서 이야기하겠다.)

그리고 각각 클래스의 내용이 여기서는 아무 것도 없는 3개의 단지 그냥 이름이 다른 클래스지만, 상속한 클래스, 혹은 하위 클래스,는 상위 클래스보다 더 특정한 방식으로 기능이나 특성을 '특화(Specialize)'했다고 할 수 있다.

그리고 반대로, 부모 타입은 자식 타입보다 더 일반적(Generalized)이라고 이야기 할 수 있다.

그리고 이런 상속 관계를 단순하게, Subtype이라고 말하겠다. (이것도 역시 꼭 같은 것은 아니지만) 그리고 반대로 부모 타입은 Supertype이다.

표기는, Bird <: Animal, Human <: Animal 와 같이 표기하겠다.1

Covariant

Covariant은 너무 당연해보인다. 대부분의 상황에서 Bird, HumanAnimal 으로 치환되어도 상관이 없다. 즉, 이 둘은 Animal 의 Subtype이고, Animal 이 필요한 자리에 이들이 와도 상관이 없다면, 이를 "Animal 으로부터 Covariant"이라고 말하겠다.

예를 들면, 다음과 같은 리스트를 보자:

  animals: typing.List[Animal] = [Human(), Bird(), Animal()]

허용된다.

다시 말하자면, 이 리스트에 있는 모든 값/객체는 Animal 인 것처럼 취급 받아도 안전하고, 그렇게 취급할테니까 괜찮다. 모든 요소가 가장 낮은 수준의 특화 수준에 맞춰서 취급되면 안전하다.

또, 재밌는 점은 다음과 같이 어떤 타입 변수로 지정된 새로운 리스트 타입끼리 Covariant이 적용되어 Subtype이다.

  animals: typing.List[Animal] = [] #..일때,

  birds: typing.List[Bird] = [Bird()]

  animals = birds

항상은 아니지만 Covariant은 거의 기본으로 대부분에 적용되고2, 이해하기에도 직관적이다.

Invariant

Invariant은 더 단순하다. 지정한 타입만 가능할 때다.

Covariant이나 Contravariant(아직 소개 안했지만)와 같이 타입 상하관계에 따라서 유추하지 않고, 오직 그 타입만을 지정한다.

Contravariant

Covariant은 "그 타입과 하위 타입만 허용"이라면, Contravariant은 반대다. "그 타입과 그 상위 타입만 허용"이다.

Covariant에 비해서 Contravariant은 어떤 상황에 적용해야 하는지 직관적으로 알기 어렵다.사실 이 포스팅을 작성하는 이유다.

하지만, Covariant와 마찬가지로 계약으로서의 타입, 코드의 안정성을 담보해주는 타입으로서 생각하면 이해하기 수월하다.

  # 다음과 같은 타입의 함수들이 있다:

  def live(anAnimal: Animal) -> None: pass

  def sing(aBird: Bird) -> None: pass

  def love(aHuman: Human) -> None: pass

  # 그럴 때:
  f1: typing.Callable[[Bird], None] = sing    # OKAY
  f2: typing.Callable[[Bird], None] = live    # OKAY: Contravariant
  f3: typing.Callable[[Animal], None] = love  # FAIL

Covariant을 생각하고 보면, 완전히 반대다.

f1 은 당연히 이해가 쉽다. 하지만 f2 은 이상하다. 설명해보겠다.

f2 의 타입은 "Bird 값을 받는 함수"를 의미한다. 하지만, liveBird 의 상위타입인 Animal 을 허용하는 함수다. 그리고 이게 괜찮은가? 괜찮다.

왜냐하면, 말했듯이 타입을 계약으로 보면 이해가 쉽다. f2 값을 호출할 때, 전달하는 값은 아마 Bird 일 것이다. 그렇다면, 그렇게 전달 받은 값을 받는 함수는 Bird 타입이거나 그보다 더 일반화된 그 상위타입만을 기대하고, 그 범위만큼만 접근/사용하는게 안전하기 때문이다.

만약 Bird 타입보다 더 특화된, 예를 들어 Blackbird 같은 Bird 의 하위타입을 기대하고 그 함수가 동작한다면 안전하지 않을 것이다. 왜냐하면 f2 에 전달될 타입은 Blackbird 같은 특화된 타입이 아니라 그냥 Bird 일테니까.

Contravariant은 값을 받는 측을 고려해서 타입을 지정하기 때문에, 그 범위를 줄인다. Covariant와 반대이다.

f3 의 경우는, 만약 함수 타입의 인자에 대해서 Covariant으로 취급한다면 맞는 얘기겠지만, 여기서는 타입체커가 틀리다고 알려준다.3

함수 타입의 파라미터는 Contravariant, 되돌림 값은 Covariant

Contravariant의 적용 예시로, 함수 타입을 들었다. 하지만, 더 정확하게는 함수에 전달하는 파라미터 타입은 Contravariant이다.

(적어도 MyPy에서는) 함수의 되돌림 값에 대해서는 Covariant이다.

즉, 다음과 같다.

  def makeAnimal() -> Animal:
      return Animal()

  def makeBird() -> Bird:
      return Bird()

  anAnimal: Animal = makeBird()

간단하다.

Subtype의 관계

Covariant, Contravariant은 어떤 Generic type의 타입변수 위치에 어떤 타입이 올지도 결정하지만, 그 Generic type이 또 어떤 타입으로 지정되었을 때, 어떻게 Subtype 관계를 만드는지도 결정한다. (이미 Covariant 설명 때 말했듯이)

즉, 위의 예제들에서,

  1. typing.List[Bird] <: typing.List[Animal] 이고, (Covariant)
  2. typing.Callable[[Animal], None] <: typing.Callable[[Bird], None] 이다 (Contravariant)

언어마다 다를 수 있다

그런데, 꼭 함수 타입의 파라미터는 Contravariant이고, 되돌림 값의 타입은 Covariant이지는 않다.

언어나 타입체커마다 다르게 유추할 수도 있고, 가끔은 아예 내가 직접 지정해서 그렇게 동작하도록 해줘야 하는 경우도 있다. (Java, Scala등)

Footnotes


2

타입체커, 컴파일러가 기본적으로 이렇게 유추하는 경우가 많다.

3

MyPy 파이썬 타입체커.