[TS] 공변성, 반공변성 코틀린 방식으로 이해하기

undefcat·2023년 3월 30일
1

Typescript

목록 보기
2/2
post-thumbnail

이미 타입스크립트의 공변성, 반공변성에 관해 설명한 글들은 너무 많다. 그리고 글의 내용이 대부분 비슷하기도 하다(loglogNumber 변수를 이용하는 예시코드들도).

그런데 개인적으로 공변성, 반공변성 개념은 코틀린의 제네릭 시스템을 공부했을 때의 내용이 가장 이해하기 쉬웠던 것 같아, 이를 소개하고자 한다.

왜 공변성, 반공변성을 고려해야 하는가?

TL;DR: 타입정보가 2개 이상이면 반공변성도 고려해야 한다.

우선, 공변성과 반공변성을 고려해야하는 상황이 발생하는 이유부터 살펴보자.

일반적인 타입계층을 가진 언어의 경우 공변성만 고려하면 된다. 이는 보통 직관적으로 다들 이해한다. 특히 클래스 기반의 타입시스템 언어를 살펴보면 타입을 클래스로 정의하고 확장할 수 있는데, 이를 보통 extends 키워드로 사용하며 상속한다는 표현을 쓰기도 한다.

확장이던 상속이던 결국 기존보다 더 많은 기능을 가지게 될 것이다. 따라서 보통 공변성은 직관적으로 이해가 된다.

class A {
  doA() { /** ... **/ }
}

class B extends A {
  doB() { /** ... **/ }
}

const a: A = new A();
const b: B = new B();

// ✅ 어차피 `A` 타입이 할 수 있는 일들은 `B`도 할 수 있다.
const aa: A = b;

aa의 변수 타입이 A인데, B 타입의 값을 할당했다. 이는 허용되는데, 어차피 A가 할 수 있는 일들은 B도 할 수 있기 때문이다. BA보다 더 많은 기능을 갖기 때문이다.

이렇게 타입 정보가 하나인 경우엔, 그냥 자연스럽게 이해하면 된다. 그런데 문제는 타입 정보가 2개 이상인 경우이다.

제네릭

다음과 같은 자바 코드를 보자.

String str = "Hello, World";
Object obj = str; // ✅

List<String> strs = new ArrayList<>();
List<Object> objects = strs; // ❌ 컴파일 오류

strsString이 담긴 리스트인데, 이를 List<Object> 타입에 할당할 수 없다. 언뜻 보면 이해되지 않을 수 있다. 하지만 다음과 같은 상황을 보자.

List<String> strs = new ArrayList<>();
List<Object> objects = strs; // ❌ 컴파일 오류인데, 만약 허용했다고 치자.

objects.add(1); // ✅ 타입시스템을 우회하여 `strs`에 `Integer` 값을 할당함.
String a = strs.get(0); // ❌ `Integer`를 `String`에 할당함. 프로그램에 문제가 있음.

List<T>타입정보가 2개 이상이다. List라는 타입과 T라는 타입 2개가 있는 것이다. 만약 자바에서 제네릭 시스템이 공변성을 허용했다면, 위와 같은 코드처럼 타입 시스템을 우회하여 런타임에 문제가 발생할 수 있는 코드를 작성할 수 있게 되는 것이다.
따라서 자바에서는 제네릭 타입의 경우 공변성을 허용하지 않아 애초에 할당 자체를 막아버린다(이를 해결하려면 wildcard type argument를 이용해서 변성을 부여해야만 한다).

함수

타입스크립트 공변성, 반공변성을 검색하면 많이 나오는 예제코드를 한 번 가져와보겠다.

type Logger<T> = (param: T) => void;

let log: Logger<string | number> = (param) => {
  console.log(param); // string | number
};

let logNumber: Logger<number> = (param) => {
  console.log(param); // number
};

log = logNumber; // ❌
logNumber = log; // ✅

하나씩 따져보면 이해하기가 쉬운데, log의 타입인 (param: string | number) => void 타입은 stringnumber 모두를 처리할 수 있지만, logNumber의 타입인 (param: number) => void 타입은 number만 처리할 수 있으므로 log에는 logNumber를 할당할 수 없다. 실제 동작하는 구현체는 오직 number만 처리할 수 있기 때문이다.

이 역시 함수의 매개변수 param변수의 관점에서 보면, 반대로 동작하는 것처럼 느껴진다. 하나씩 살펴보면 당연하다고 느껴지지만, 코드만 봤을 때에는 의아함이 먼저 떠오르는 것이다. 이에 관한 정리글들이 많은 것으로 보아, 다른 사람들 역시 직관적으로 느끼지 못하고 있는 것 같다. 물론 나조차도 예외가 아니다.

const s: string = 'string';
const sn: string | number = s; // ✅ 매개변수가 아닌 변수의 관점에서는 허용됨

함수 역시 타입 정보가 2개 이상이다.

  1. 함수 본연의 타입
  2. 함수 매개변수들의 타입
  3. 함수 리턴타입

따라서, 일반적인 공변성만으로는 해소될 수 없는 경우가 발생하는 것이다.

그렇다면 제네릭, 함수 등 타입 정보가 여러개 있는 경우, 어떻게 이해하는게 가장 쉬울까?

Producer, Consumer

사실 위에서 봤던 자바 코드가 문제가 되지 않는 경우가 있다.

List<String> strs = new ArrayList<>();
List<Object> objects = strs; // ❌ 컴파일러는 이를 허용하지 않지만

objects.add(1); // ✅
objects.add(2); // ✅

String str = strs.get(0); // ❌ 이렇게 코드를 작성하면 문제가 발생하지만
Object obj = strs.get(0); // ✅ 이렇게 코드를 작성한다면 아무런 문제가 없다!

만약 strs의 값을 꺼내올 때 Object 타입으로만 가져온다면, 컴파일 단계에서 위의 코드를 막을 이유가 있을까?

코틀린에서는 이를 PECS: PECS stands for Producer-Extends, Consumer-Super. 원칙으로 부르고, 이를 위한 키워드인 inout을 사용한다.

// `out`이다.
// 즉, `Producer`로 사용되는 인터페이스다.
interface Source<out T> {
	fun nextT(): T
}

fun demo(strs: Source<String>) {
	// `Source<String>` 타입을
    // `Source<Any>`에 할당할 수 있다.
    //
    // `Source<out T>` 타입은
    // 메서드의 리턴 타입에만 `T`가 올 수 있고
    // 따라서 구현체가 서브타입이지만
    // 리턴된 서브타입의 값을 슈퍼타입으로 업캐스팅해서 사용할 수 있다면
    // 아무런 문제가 없기 때문이다.
    val objects: Source<Any> = strs
    // ...
}
// `in`이다.
// 즉, `Consumer`로 사용되는 인터페이스다.
interface Comparable<in T> {
    operator fun compareTo(other: T): Int
}

fun demo(x: Comparable<Number>) {
	 // 1.0은 `Double` 타입이다.
     // 이는 `Number`의 하위 타입이다.
    x.compareTo(1.0)
    
    // `Double`이 `Number`의 서브타입이므로
    // 일반적인 변수 타입 관점에서는 다운캐스팅이라고 볼 수 있다.
    // 그러면 런타임에 문제가 발생할 수 있는데
    // `Comparable<in T>`는
    // 메서드의 매개변수에만 `T`가 올 수 있기 때문에
    // 실제 구현체가 슈퍼타입인 경우
    // 서브타입을 메서드 매개변수로 받아들여도
    // 오류가 발생하지 않는다.
    val y: Comparable<Double> = x // OK!
}

프로그래밍의 가장 근본으로 돌아가보자. 결국 우리가 작성하는 코드들은 값을 적절하게 사용하도록 코딩하고 있을 뿐이다. 왜냐하면 CPU가 하는 일을 생각해보면 된다. CPU는 그저 레지스터에 숫자를 넣고 계산하는 행위를 무한히 반복할 뿐이다.

따라서 우리는 값의 사용을 안전하게 하는 것을 목적으로 한다. 이에 따라서 바로 공변성과 반공변성의 차이가 생긴다.

Producer 인터페이스를 사용하는 경우 Producer우리에게 준 값을 항상 안전하게 사용할 수 있어야 하고(공변성), Consumer 인터페이스를 사용하는 경우 반대로 우리가 준 값Consumer 인터페이스가 안전하게 사용할 수 있어야 한다(반공변성).

즉, 값을 사용하는 주체의 관점에서 값을 만들어 주는 Producer와 값을 소비하는 Consumer로 구분해서 생각해보면 모든게 명확해진다.

값을 사용하는 주체가 우리(나)라면 해당 타입은 Producer 관점에서 봐야 하고, 값을 사용하는 주체가 우리(나)가 아니라면 해당 타입은 Consumer 관점에서 봐야 한다.

1. 변수는 Producer 이므로 공변성이 성립한다.

이 경우 변수의 값을 사용하는 주체는 바로 우리이므로, 변수가 값을 생산해준다고 볼 수 있다.

2. 함수의 리턴은 Producer 이므로 공변성이 성립한다.

이 경우 호출된 함수의 리턴값을 사용하는 주체는 바로 우리인데, 이는 변수에 값을 할당하는 행위와 같다고 볼 수 있다.

3. 함수의 매개변수는 Consumer 이므로 반공변성이 성립한다.

이 경우 매개변수로 넘겨진 변수의 값을 사용하는 주체는 우리가 아니므로, 함수가 매개변수를 안전하게 사용하려면 반공변성을 지켜줘야 한다.

정리하며

이제 정리해보자. 반공변성을 고려해야하는 전제 조건은 다음과 같다.

타입 정보가 2개 이상인 경우 반공변성을 고려해야 할 수도 있다.

그리고 공변성, 반공변성이 각각 어떤 상황에 적용되는지는 다음과 같은 기준으로 결정된다.

값의 사용주체가 우리(나)라면 공변성을, 우리(나)가 아니라면 반공변성이 성립한다.


⚠️ 참고할 점

타입스크립트의 타입시스템

타입스크립트의 타입 시스템은 일반적인 클래스 방식의 타입 시스템과 근본적으로 차이가 있다. 클래스 방식의 타입 시스템은 Nominal type system이기 때문에 서브타이핑이 명확하다. 하지만 타입스크립트는 Strutual type system이기 때문에, 서브타입이라는 개념이 클래스 기반의 언어와는 다를 수 있다. 이에 관한 설명은 이전에 올린 글인 좋은놈, 어려운놈, 이상한놈#Structual type system 및 하위 섹션에서 참고 바란다.

이변성

다음과 같은 타입스크립트 코드를 살펴보자.

const s: Array<string> = [];
const sn: Array<string | number> = [];

const sn2: Array<string | number> = s; // ✅
const s2: Array<string> = sn; // ❌

타입스크립트는 자바와 다르게 Array<T>에 공변성이 적용되는 듯하다. 그런데, push 메서드를 한 번 호출해보자.

const s: Array<string> = [];
const sn: Array<string | number> = [];

const sn2: Array<string | number> = s;

// `s`는 실제로 `Array<string>` 인데, 왜 허용하게 뒀지?
sn2.push(1); // ✅ ????

이는 위에서 자바 코드와 같은 이유로 오류가 발생할 수 있는 코드다. 하지만 타입스크립트에서는 이를 허용하는데, 타입스크립트가 탄생한 시점엔 이를 고려하지 않았기 때문이다.

추후에 Strict function types PR에 의해 --strictFunctionTypes가 추가되었으며, 하위호환을 위해 메서드를 정의할 때의 syntax에 따라 매개변수의 반공변성 체크가 이루어진다.

interface Comparer<T> {
  // 이런 형태의 경우 기존 메서드 시그니처로 선언되었으므로 이변성
  compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // ✅ because of bivariance
dogComparer = animalComparer;  // ✅
interface Comparer<T> {
  // 이는 함수 시그니처로 선언되었으므로 메서드이지만 `stricFunctionTypes` 모드에 따라 오류가 발생할 수 있음
  compare: (a: T, b: T) => number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // ❌
dogComparer = animalComparer;  // ✅

레퍼런스

profile
undefined cat

0개의 댓글