跳到主要內容
黯羽輕揚每天積累一點點

深入類型系統_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,而 number「兼容」null,因此推斷 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;

從類型安全角度來看,把 log 換成 writeToFile 不安全(缺 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,默認值使得參數值集少了 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 並沒有強制約束函數類型逆變,而是允許雙變。更進一步地,在比較兩個函數類型時,只要一方參數兼容另一方的參數即可,如上例中 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 函數有兩個重載版本,所以目標函數至少要兼容這兩個版本

四。枚舉

首先,來自不同枚舉類型的枚舉值不兼容,例如:

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. 雖然從實際類型上看,上例賦值是合法的,但在類型系統中認為二者不兼容,因此報錯

五。類

類與對象字面量類型和接口類似,區別在於,類同時具有實例類型和靜態類型,而比較兩個類實例時,僅比較實例成員

因此,靜態成員和構造函數並不影響兼容性:

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 子句也不例外

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論