변성이란 제네릭을 사용하는 언어에서 상속과 관련되어 생기는 개념이다.
공변성과 반공변성을 이해하기기 위해서는, 먼저 서브타입과 슈퍼타입을 이해해야 한다. 제네릭만 나오면 일단 긴장하는 나는 이부분에서 시간이 아주 매우 많이 오래 걸렸다. (머리가 나쁜건지 이부분이 이해가 너무너무너무 어렵더라)
내 스스로 정리하기 위해서 적어봅니당
구글링을 열심히 해본 결과, 서브타입과 슈퍼타입에 대한 설명은 아주 어렵게도 쉽게도 할 수 있다.
일단 쉬운 설명을 먼저 보자.
다른 한 타입을 포함하는 타입을 슈퍼타입(supertype)이라고 하고, 슈퍼타입에 포함되는 타입을 서브타입(subtype)이라고 합니다. ‘타입을 포함한다’의 기준은 타입 시스템 별로 다르지만 구조적 타입 시스템(structural type system)을 가진 타입스크립트의 경우, 한 타입이 다른 한 타입의 값을 모두 포함하고 있으면 그 타입을 포함한다고 합니다.
(출처: 안전한 any 타입 만들기)
그러니까 A라는 타입이 B라는 타입에 포함되면 A는 B의 서브타입, B는 A의 슈퍼타입이다.
이 경우 A(서브타입> <: B(슈퍼타입)
으로도 쓸 수 있다.
number <: (number | string)
// 'number'는 'number이거나 string'인 조건에 포함된다.
let A: 1 = 1;
let B: number = A;
A <: B
// 1만 받을 수 있는 A에 비해, B는 number를 다 받을 수 있다.
// B는 A의 값을 포함하고 있기 때문에 B는 A의 슈퍼 타입이다.
let value: string | number = 'number';
let stringValue: string = 'number2';
stringValue <: value
// value 값의 타입으로는 string과 number가 올 수 있고, stringValue의 값으로는 string만 올 수 있다.
// value는 stringValue의 값을 포함하고 있기 때문에 value는 stringValue의 슈퍼 타입이다.
여기까지는 '더 큰 범위'로 해석해서 이해가 되었다.
그런데 서브타입과 슈퍼타입에서 가장 헷갈리는 부분이 객체 형태를 비교하는 케이스였다.
type Supertype = { x: boolean }
type Subtype = { x: boolean, y: number }
위의 케이스를 소개한 블로그에서는 아래와 같이 설명하고 있다.
...(생략)
모든 Subtype의 값은 Supertype의 값이기도 합니다. 이렇게 Supertype이 Subtype을 포함하기 때문에, Supertype은 Subtype의 슈퍼타입이고 Subtype은 Supertype의 서브타입이라고 할 수 있습니다.
(출처: 안전한 any 타입 만들기)
그런데 여기서 Subtype에 있는 값 y
가 Supertype에는 없기 때문에, "Supertype이 Subtype을 포함한다"가 왜 성립하는지 이해할 수가 없었다. 아까 위에서 '한 타입이 다른 한 타입의 값을 모두 포함하고 있으면 그 타입을 포함한다' 라고 했는데, 그러면 { x: boolean }
이 { x: boolean, y: number }
에 포함되니까 서브와 슈퍼가 반대로 되어야 하는 거 아닌가???
완전히 동일한 케이스를 또 다른 블로그에서는 아래처럼 설명하고 있다.
type A = { a : string }
type B = { a : string, b : string }
// B <: A
A = B // OK
B = A // Error
일반적인 공변적 Sub Type 관계는 위와 같다. B는 A를 포함하고 있기 때문에 B는 A의 Sub Type이 된다.
(출처: TypeScript 에서의 공변성과 반공변성 (strictFunctionTypes))
이번에는 b
라는 또 다른 값을 가진 타입 B가 A를 포함한다고 했다. (위에 나온 블로그와는 상반되는데, 나는 여기까지는 이 설명에 동의함!)
그리고 B가 A를 포함하고 있기 때문에 B가 A의 서브타입이라고 했다. 아까전에 맨 위에서 본 정의에는 포함하는 쪽이 슈퍼타입이었는데... 여기서 멘붕이 시작됐다.
일단 두 글의 결론은 동일하므로, 설명을 좀 더 찾아보고자 구글링을 하다가
이번엔 아래와 같은 글을 발견했다.
<리스코프 치환 원칙>
바바라 리스코프는 올바른 상속 관계의 특징을 정의하기 위해 리스코프 치환 원칙(Liskov Substitution Principle, LSP) 을 발표했다.
상속 관계로 연결한 두 클래스가 서브타이핑 관계를 만족시키기 위해서는 다음의 조건을 만족해야 한다.
S형의 각 객체 o1에 대해 T형의 객체 o2가 하나 있고,
T에 의해 정의된 모든 프로그램 P에서 T가 S로 치환될 때 P의 동작이 변하지 않으면 S는 T의 서브타입이다.
리스코프 치환 원칙을 정리하면 서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다.
클라이언트가 차이점을 인식하지 못한 채 파생 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다는 것이다.
(출처: Objects Study - Chapter13. 서브클래싱과 서브타이핑 )
서브타입은 슈퍼타입에 의해 대체 가능해야 한다.
즉, 슈퍼타입으로 대체하더라도 원래의 기능에 문제가 없어야 한다.
아까의 헷갈리는 케이스에 위 원칙을 적용해 보았다.
type A = { a : string }
type B = { a : string, b : string }
/* 가정 1) */
// S형 : type A
const AA: A = {
a: 'a입니다',
};
// T형 : type B
const BB:B = {
a: 'a입니다',
b: 'b입니다',
}
// T에 의해 정의된 모든 프로그램 P에서 T가 S로 치환될 때 P의 동작이 변하지 않으면
BB.b = '안녕';
AA.b // Error; 동작이 변함. 따라서 S는 T의 서브타입이 아니다.
/* 가정 2) */
// T형 : type A
const AA: A = {
a: 'a입니다'
};
// S형 : type B
const BB:B = {
a: 'a입니다',
b: 'b입니다',
}
// T에 의해 정의된 모든 프로그램 P에서 T가 S로 치환될 때 P의 동작이 변하지 않으면
AA.a = '안녕';
BB.a = '안녕' // OK
// S는 T의 서브 타입이다.
이렇게 하니까 이해가 되었다...!!
스스로 이해를 돕기 위해서, '서브타입은 슈퍼타입을 상속받아 만들어질 수 있다' 라고 생각해도 될 것 같다.
여기까지 이해하니 기분이 아주 좋아서 글을 마치고 싶었다.
그렇지만 원래 이 글의 목적은 변성
을 설명하고 싶었던 거니까..
공변성 : A가 B의 서브타입이면, C<A>는 C<B>의 서브타입이다.
let stringArray: Array<string> = [];
let array: Array<string | number> = [];
array = stringArray; // OK
stringArray = array; // Error
stringArray <: array
let subObj: { a: string; b: number } = { a: '', b: 1 };
let superObj: { a: string | number; b: number } = subObj;
subObj <: superObj
// 각각의 프로퍼티가 대응하는 프로퍼티와 같거나 서브타입이어야 한다.
위의 예제에서 보면, string
은 string | number
의 서브 타입이다.
따라서 Array<string | number>
타입을 가지고 있는 stringArrary는 Array<string>
타입을 가지고 있는 array의 서브 타입이다.
또, { a: string; b: number }
는 { a: string | number; b: number }
의 서브 타입이다. 그렇기 때문에 subObj는 superObj의 서브 타입이다.
이렇게 A가 B의 서브타입일 때, T<A>
가 T<B>
의 서브타입이 된다면, T를 공변적이라고 부를 수 있다.
반공변성 : A가 B의 서브타입이면, C<B>는 C<A>의 서브타입이다.
그런데 함수의 인자로 전달된 경우, 이것이 반대로 동작한다.
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; // Error
logNumber = log; // OK
number <: (string | number)
log <: logNumber
인자로 string | number
를 받는 log 함수와, 인자로 number
만을 받는 logNumber 함수가 있다. logNumber에 log를 할당하는 것은 문제가 없지만, log에 logNumber를 할당하려고 하면 에러가 발생한다. logNumber는 log를 모두 커버할 수 없기 때문이다. 이 경우 반대로 logNumber가 log의 서브타입이 된다.
또 다른 예를 보자.
type FA = (p: { a: string }) => void
type FB = (p: { a: string, b: string }) => void
{ a: string, b: string } <: { a: string }
FA <: FB
(p: { a: string }) => void <: (p: { a: string, b: string }) => void
FA에다 FB를 할당하려고 하면 에러가 난다. FA는 인자로 {a}
만을 받기 때문에 {a, b}
를 받는 FB를 할당할 수 없다. 반대로 FB에다 FA를 할당하는 것은 가능하다. 따라서 FA는 FB의 서브타입이다.
타입스크립트는 함수의 인자를 다루는 과정에서 이변성(Bivariance)을 가지고 있다. 이변성이란 공변성과 반공변성을 모두 가지는 것을 말한다.
type F<T> = (p: T) => T
type A = {a: string};
type B = {a: string, b: string};
const a: A = {a: 'a'};
const b: B = {a: 'a', b: 'b'};
const fa: F<A> = (p: A) => a;
const fb: F<B> = (p: B) => b;
위 케이스의 경우, B <: A
에는 공변성이 적용되지만 인자로 받은 함수에서는 반공변성에 의해 F<A> <: F<B>
이고, return 된 값은 다시 b <: a
로 공변적 흐름을 가진다. 이렇게 이변적인 문제 때문에 타입스크립트에서는 파라미터에 서브타입과 슈퍼타입을 모두 사용하더라도 타입 에러가 나지 않는다.
앞에서 보았듯이 함수의 파라미터는 반공변적으로 동작하는 것이 타당하기 때문에, 이를 확실하게 명시하고 함수가 반공변적으로만 동작하도록 맞춰주기 위해서 --strictFunctionTypes
옵션을 사용한다.
참고 :
안전한 any 타입 만들기(https://overcurried.com/%EC%95%88%EC%A0%84%ED%95%9C%20any%20%ED%83%80%EC%9E%85%20%EB%A7%8C%EB%93%A4%EA%B8%B0/)
TypeScript 에서의 공변성과 반공변성 (strictFunctionTypes)
(https://iamssen.medium.com/typescript-%EC%97%90%EC%84%9C%EC%9D%98-%EA%B3%B5%EB%B3%80%EC%84%B1%EA%B3%BC-%EB%B0%98%EA%B3%B5%EB%B3%80%EC%84%B1-strictfunctiontypes-a82400e67f2)
Objects Study - Chapter13. 서브클래싱과 서브타이핑
(https://jaehun2841.github.io/2020/07/18/object-chapter13/#%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-vs-%EA%B0%9D%EC%B2%B4%EA%B8%B0%EB%B0%98-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D)
공변성이란 무엇인가 (https://seob.dev/posts/%EA%B3%B5%EB%B3%80%EC%84%B1%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80/)
반공변 설명에서 관계가 반대인 부분이 있는 거 같아요.
확인 좀 부탁드립니다!
from: logNumber <: log
to: log <: logNumber