본문으로 건너뛰기

타입 시스템 깊이 탐구_TypeScript 노트 8

무료2019-02-23#TypeScript#TypeScript bivariance#Function Parameter Bivariance#双向协变#TypeScript类型兼容性#TypeScript类型继承关系

타입 추론 메커니즘에서 타입 호환성까지

一.타입 추론

대입 추론

타입 추론 메커니즘은 강타입으로 인한 구문 부담을 경감합니다. 예를 들어:

let x = 3;
// 等价于
let x: number = 3;

컴파일러는 변수의 초기값 3 에 기반하여 변수 타입이 number 임을 추론할 수 있으므로, 많은 시나리오에서 명시적으로 타입을 선언할 필요가 없습니다. 它猜得到

P.S.모든 것을 사전에 확정해야 하는 [Haskell](/articles/类型-haskell 笔记 3/) 에서조차, 도처에 타입 선언이 넘쳐나는 것이 아니라, 매우 간결합니다. 이는 컴파일러가 강력한 타입 추론 지원을 제공하기 때문입니다

대입과 유사한 시나리오에서는 타겟 값에 기반하여 타입을 확정할 수 있습니다. 구체적으로는:

  • 변수 또는 (클래스) 멤버의 초기값

  • 파라미터의 디폴트 값

  • 함수의 리턴값

이들 3 종류의 값은 직접적인 타입 정보를 제공할 수 있으며, 그에 의해 타겟 타입을 확정합니다. 이 외에도, 그다지 직접적이지 않은 시나리오도 있습니다. 예를 들어 배열 타입

배열 타입

let x = [0, 1, null];

배열 중의 요소는 number 또는 null 뿐이며, numbernull 을「호환」하므로, x 의 타입은 number[] 로 추론됩니다

Null, Undefined 및 Never 는 다른 타입의 서브타입이므로, 다른 임의의 타입 변수에 대입할 수 있습니다

([기본 타입_TypeScript 노트 2](/articles/基本类型-typescript 笔记 2/#articleHeader3) 에서 인용)

즉, 배열 타입을 확정하려면, 먼저 각 요소의 타입을 확정하고,その後 그 호환 관계를 고려하여, 최종적으로 가장「넓은」타입 (배열 중의 다른 모든 타입을 포용하며, best common type 이라 함) 을 배열 타입으로 확정합니다

배열 요소 중에 다른 모든 타입을 호환할 수 있는 타입이 없는 경우 (즉 best common type 을 찾을 수 없는 경우), 유니온 타입을 사용합니다. 예를 들어:

// 推断 mixin: (string | number | boolean)[]
let mixin = [1, '2', true];

class Animal {}
class Elephant extends Animal {}
class Snake extends Animal {}
// 推断 zoo: (Elephant | Snake)[]
let zoo: Animal[] = [new Elephant(), new Snake()];

컨텍스트 추론

대입 추론과 비교하여, 컨텍스트 추론은 다른 사고방식입니다:

    推断
值 ------> 变量类型
       查找             匹配(推断)
上下文 -----> 上下文类型 -----------> 变量类型

전자는 값에서 타입으로, 후자는타입에서 타입으로입니다. 컨텍스트에 기반하여 타입 정보를 얻고, 後에 위치에 기반하여 변수에 매핑합니다. 예를 들어:

// 推断 mouseEvent: MouseEvent
window.onmousedown = function(mouseEvent) {
  // ...
};

우측의 익명 함수는 mousedown 이벤트 핸들러로서, DOM API 의 타입 제약에 따르며, 그에 의해 파라미터 타입이 얻어집니다:

interface MouseEvent extends UIEvent {
  readonly clientX: number;
  readonly clientY: number;
  //...等等很多属性
}
interface GlobalEventHandlers {
  onmousedown: ((this: GlobalEventHandlers, ev: MouseEvent) => any) | null;
}
interface Window extends GlobalEventHandlers {/*...*/}

declare var window: Window;

TypeScript/lib/lib.dom.d.ts 에서 인용)

만약 mousedown 이벤트 핸들러라는 컨텍스트에서 이탈하면, 파라미터 타입을 추론할 수 없습니다:

// 推断 mouseEvent: any
function handler(mouseEvent) {
  console.log(mouseEvent.clickTime);
}

많은 시나리오에서 컨텍스트에 기반하여 타입을 추론합니다. 예를 들어:

  • 함수 호출 중의 파라미터

  • 대입문의 우측

  • 타입 어설션

  • 오브젝트 멤버와 배열 리터럴

  • return

二.서브타입 호환성

[TypeScript 의 13 가지 기본 타입](/articles/基本类型-typescript 笔记 2/) 중, 타입 계층 관계는 다음과 같습니다:

[caption id="attachment_1899" align="alignnone" width="625"]TypeScript 타입 관계 TypeScript 타입 관계[/caption]

간단히 요약하면:

  • Any 는 가장「넓다」. 다른 모든 타입을 호환 (다시 말해, 다른 타입은 모두 Any 의 서브타입)

  • Never 는 가장「좁다」. 다른 임의의 타입을 호환하지 않음

  • Void 는 Undefined 와 Null 을 호환

  • 다른 타입은 모두 Never 와 Void 를 호환

P.S.호환은 간단히 대입 가능한지 여부로 이해할 수 있습니다 (문말에 엄밀한 기술 있음). 예를 들어:

let x: any;
let y: number;
let z: null;

// Any 兼容 Number
x = y;
// Number 兼容 Null
y = z;
// Null 不兼容 Number
// 错误 Type 'number' is not assignable to type 'null'.
z = y;

기본 타입뿐만 아니라, 함수, 클래스, 제네릭 등의 복잡한 타입 간에도 이러한 호환 관계가 있습니다

三.함수

호환성 판정

타입 시스템에게, 어떤 함수 타입이 다른 함수 타입을 호환하는지 여부를 정확히 판단할 필요가 있습니다. 예를 들어 대입의 시나리오:

let log: (msg: string) => void
let writeToFile: (msg: any, encode: string) => void

// 类型兼容吗?该不该报错
log = writeToFile;
writeToFile = log;

타입 안전의 각도에서 보면, logwriteToFile 로 바꾸는 것은 안전하지 않습니다 (encode 파라미터가 부족하여, writeToFile 이 정상적으로 작동하지 못할 수 있음). 반대의 경우는 안전합니다. 리턴값 타입이 같고, 파라미터가 충분히有余하며, msg 의 타입도 호환 (stringany 의 서브타입) 하기 때문입니다

구체적으로는, TypeScript 타입 시스템의 함수 타입 호환성 판정 룰은 다음과 같습니다:

  • 파라미터: 대응 파라미터의 타입 호환을 요구하며, 수량은 잉여를 허용

      let x = (a: number) => 0;
      let y = (b: number, s: string) => 0;
    
      y = x; // OK
      x = y; // Error
    
  • 리턴값: 리턴값 타입의 호환을 요구

      let x = () => ({name: "Alice"});
      let y = () => ({name: "Alice", location: "Seattle"});
    
      x = y; // OK
      y = x; // Error, because x() lacks a location property
    
  • 함수 타입: 쌍변성 제약을 만족할 것을 요구

함수 타입의 쌍변성 (bivariance)

쌍변이란 동시에 공변과 역변을 만족하는 것으로, 간단히 말하면:

  • 공변 (covariant): 부모 타입이 나타나는 장소를 허용하면, 서브타입도 나타나는 것을 허용. 즉 리스코프의 치환 원칙

  • 역변 (contravariant): 공변의 역. 즉 서브타입이 나타나는 장소를 허용하면, 부모 타입도 나타나는 것을 허용

  • 쌍변 (bivariant): 동시에 공변과 역변을 만족

  • 불변 (invariant 또는 nonvariant): 공변도 역변도 만족하지 않음

공변은 이해하기 쉽고, 서브타입은 부모 타입을 호환하며, 더욱이 몇 가지 (부모 타입이 갖지 않는) 확장 속성 또는 메서드를 가지므로, 서브타입으로 부모 타입을 치환한 후에도, 정상적으로 작동할 수 있습니다 (타입 안전)

한편, 역변은 그다지 직관적이지 않습니다. 어떠한 시나리오에서, 부모 타입으로 서브타입을 치환한 후에도, 타입 안전을 보증할 수 있을까요?

상속 관계 중의 멤버 함수 오버라이드는, 역변의 전형적인 예입니다:

class Example {
  foo(maybe: number | undefined) { }
  str(str: string) { }
  compare(ex: Example) { }
}

class Override extends Example {
  foo(maybe: number) { } // Bad: should have error.
  str(str: 'override') { } // Bad: should have error.
  compare(ex: Override) { } // Bad: should have error.
}

Overridden method parameters are not checked for parameter contravariance 에서 인용)

오버라이드 전후의 함수 타입을 비교:

// foo
(maybe: number | undefined) => any
(maybe: number) => any
// str
(str: string) => any
(str: 'override') => any
// compare
(ex: Example) => any
(ex: Override) => any

P.S.str(str: 'override')str(str: string) 보다 undefined 가 1 개「좁고」, 디폴트 값으로 인해 파라미터 값 세트에서 undefined 가 감소

파라미터는 모두「넓은」타입에서 보다「좁은」타입이 되며, 즉 부모 타입에서 서브타입이 됩니다. 명백히, 이렇게 하는 것은 안전하지 않습니다. 예를 들어:

function callFoo(example: Example) {
  return example.foo(undefined);
}

callFoo(new Example());   // 没问题
callFoo(new Override());  // 可能会出错,因为子类的 foo 不接受 undefined

반대로, 서브클래스가 오버라이드 후의 파라미터 타입이 보다「넓은」경우, 안전합니다. 예를 들어:

class Example {
  foo(maybe: number | undefined) { }
}

class Override extends Example {
  foo(maybe: number) { }  // Sound
}

이것이 소위 역변으로, 멤버 함수의 파라미터에게, 부모 타입으로 서브타입을 치환하는 것은 안전합니다. 즉:

允许出现子类型的地方,也允许出现父类型

타입의 각도에서 보면, 서브타입은 타입 간에 계층 (상속) 관계를 허용하며, 광범위한 타입에서 특수한 타입으로, 그리고 공변, 역변 등의 관계는 이 타입 계층 위에 구축됩니다:

  • 공변: 단순한 타입의 계층 관계가 복잡한 타입에 보류되며, 이 복잡한 타입은 공변입니다. 예를 들어 AnimalCat 의 부모 타입이고, 배열 Animal[]Cat[] 의 부모 타입이므로, 배열 타입은 공변입니다

  • 역변: 단순한 타입의 계층 관계가 복잡한 타입 중에서 역이 되며, 이 복잡한 타입은 역변입니다. 예를 들어 함수 타입 Animal => stringCat => string 의 서브타입 (후자가 받아들이는 파라미터가 보다「좁기」때문), 단순한 타입 AnimalCat 의 부모 타입이므로, 함수 타입은 역변입니다

P.S.우리가 보는 바와 같이, 역변은 직관적이지 않으므로, 타입 시스템을 단순하게 유지하기 위해, 일부 언어는 타입 컨스트럭터가 불변이라고 생각하기도 합니다. 다만, 이는 타입 안전에 위반될 수 있습니다

특별히, TypeScript 중의 함수 타입은 쌍변입니다. 예를 들어:

interface Comparer<T> {
  compare(a: T, b: T): number;
}

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

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok

Strict function types 에서 인용)

이론적으로는 함수 파라미터의 역변을 요구하여, 타입 안전을 확보할 필요가 있습니다. 따라서:

// 把父类型赋值给子类型,在逆变的场景中是安全的
dogComparer = animalComparer;  // Ok
// 把子类型赋值给父类型,在逆变的场景(函数类型)中是不安全的
animalComparer = dogComparer;  // Ok because of bivariance

후자는 안전하지 않지만, JavaScript 세계에서는 매우 일반적입니다:

This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns.

따라서 TypeScript 는 함수 타입의 역변을 강제 제약하지 않고, 쌍변을 허용합니다. 더욱이, 2 개의 함수 타입을 비교할 때, 일방의 파라미터가 타방의 파라미터를 호환하면 됩니다. 상기 예에서 dogCompareranimalComparer 가 상호 대입할 수 있는 것과 같습니다

옵션 파라미터와 나머지 파라미터

파라미터 호환성을 비교할 때, 옵션 파라미터의 매칭을 요구하지 않습니다. 예를 들어 원타입이 추가의 옵션 파라미터를 갖는 것은 합법이고, 타겟 타입이 대응하는 옵션 파라미터를 결여하는 것도 합법입니다

나머지 파라미터에 대해서는, 무한 개의 옵션 파라미터로 취급하며, 엄밀한 매칭도 요구하지 않습니다. 타입 시스템의 각도에서 보면 안전하지 않지만, 실제 응용에서는 매우 일반적인「패턴」입니다. 예를 들어 불확정의 파라미터로 콜백 함수를 호출:

function invokeLater(args: any[], callback: (...args: any[]) => void) {
  /* ... Invoke callback with 'args' ... */
}

// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

함수 오버로드

복수의 오버로드가 존재하는 함수의 경우, 소스 함수의 각 오버로드 버전이 타겟 함수 상에 대응하는 버전을 가질 것을 요구하여, 타겟 함수가 모든 소스 함수가 호출할 수 있는 장소에서 호출할 수 있음을 보증합니다. 예를 들어:

interface sum {
  (a: number, b: number): number;
  (a: number[]): number;
}

// Sum 要求的两个版本
function add(a: any, b: any);
function add(a: any[], b?: any): any;
// 额外的版本
function add(a: any[], b: any, c: number): any;
function add(a, b) { return a; }
let sum: sum = add;

sum 함수에는 2 개의 오버로드 버전이 있으므로, 타겟 함수는 적어도 이들 2 개의 버전을 호환할 필요가 있습니다

四.열거

먼저, 다른 열거 타입으로부터의 열거값은 호환하지 않습니다. 예를 들어:

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let s = Status.Ready;
// Type 'Color.Green' is not assignable to type 'Status'.
s = Color.Green;  // Error

특별히, 수치 열거 는 수치와 상호 타입 호환합니다. 예를 들어:

enum Status { Ready, Waiting };
// 数值兼容枚举值
let ready: number = Status.Ready;
// 枚举值兼容数值
let waiting: Status = 1;

그러나 문자열 열거 는 문자열 타입과 상호 호환하지 않습니다

enum Status { Ready = '1', Waiting = '0' };
let ready: string = Status.Ready;
// 报错 Type '"0"' is not assignable to type 'Status'.
let waiting: Status = '0';

P.S.실제 타입에서 보면, 상기 대입은 합법이지만, 타입 시스템 중에서는 이자는 호환하지 않는다고 간주되므로, 에러가 보고됩니다

五.클래스

클래스는 오브젝트 리터럴 타입과 인터페이스와 유사하지만, 차이는, 클래스는 동시에 인스턴스 타입과 스태틱 타입을 갖는 것입니다. 2 개의 클래스 인스턴스를 비교할 때, 인스턴스 멤버만 비교합니다

따라서, 스태틱 멤버와 컨스트럭터는 호환성에 영향을 주지 않습니다:

class Animal {
  static id: string = 'Kitty';
  feet: number;
  constructor(name: string, numFeet: number) { }
}

class Size {
  feet: number;
  constructor(numFeet: number) { }
}

let a: Animal;
let s: Size;

a = s;  // OK
s = a;  // OK

프라이빗 멤버와 프로텍티드 멤버

멤버 수식자 privateprotected 도 타입 호환성에 영향을 줍니다. 구체적으로는, 이들 멤버가같은 클래스로부터 유래할 것을 요구하여, 이에 의해 부모 클래스가 서브클래스를 호환함을 보증합니다:

class Animal {
  private feet: number;
  constructor() { }
}

class Cat extends Animal { }

// 和 Animal 长得完全一样的 Tree
class Tree {
  private feet: number;
  constructor() { }
}

// 正确 父类兼容子类
let animal: Animal = new Cat();
// 错误 Type 'Tree' is not assignable to type 'Animal'.
animal = new Tree();
// 正确 二者“形状”完全一样
let kitty: Cat = new Animal();

Tree 인스턴스를 Animal 타입에 대입하면 에러가 보고됩니다. 이자는 완전히 같아 보이지만, 프라이빗 속성 feet 이 다른 클래스로부터 유래하기 때문입니다:

Types have separate declarations of a private property 'feet'.

마찬가지로, 상기 예에서 Animal 인스턴스를 Cat 타입에 대입해도 에러가 보고되지 않는 것은, 이자의 멤버 리스트가 같고, 프라이빗 속성 feet 도 같은 Animal 클래스로부터 유래하기 때문입니다

六.제네릭

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // OK, because y matches structure of x
y = x;  // OK, because x matches structure of y

Empty<number>Empty<string> 은 크게 다르지만, 제네릭 정의 중에서는 타입 파라미터 T 를 사용하지 않았으므로 (unused variable 에 유사하여, 그다지 의미가 없음), 상호 호환합니다

interface NotEmpty<T> {
    data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

// 错误 Type 'Empty<string>' is not assignable to type 'Empty<number>'.
x = y;

이 때, 타입 파라미터를 지정한 제네릭은 일반적인 구체 타입과 같이 엄밀히 비교하고, 타입 파라미터를 지정하지 않은 제네릭에 대해서는, 타입 파라미터를 any 로 간주하고, 後에 비교합니다. 예를 들어:

let identity = function<T>(x: T): T {
  //...
  return x;
}
let reverse = function<U>(y: U): U {
  //...
  return y;
}

// 正确 等价于把 (y: any) => any 赋值给 (x: any) => any
identity = reverse;

七.타입 호환성

실제로는, TypeScript 규범 중에서는 2 종류의 호환성만 정의하고 있습니다. 서브타입 호환성과 대입 호환성으로, 이자에는 미묘한 차이가 있습니다:

Assignment extends subtype compatibility with rules to allow assignment to and from any, and to and from enum with corresponding numeric values.

대입 호환성은 서브타입 호환성을 확장하여, any 의 상호 대입, 및 enum 과 대응하는 수치의 상호 대입을 허용합니다

타입 호환성에 대해서는, 규범 중에서는 이 개념을 정의하지 않았습니다. 많은 문맥에서는, 소위타입 호환성은 대입 호환성을 따르며, implementsextends 구도 예외는 아닙니다

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성