聊聊TypeScript類型兼容,協變、逆變、雙向協變以及不變性

前言

學過集合論的同窗必定知道子集的概念,使用ES6 class寫過繼承的同窗必定知道子類的概念,而使用過TypeScript的同窗,也許知道子類型的概念。git

可是你知道協變 (Covariant)、逆變 (Contravariant)、雙向協變 (Bivariant) 和不變 (Invariant) 這些概念嗎?你知道像TypeScript這種強大的靜態類型檢查的編程語言,是怎麼作類型兼容的嗎?咱們今天來聊聊。github

關於Subtyping

子類型是編程語言中一個有趣的概念,源自於數學中子集的概念:typescript

若是集合A的任意一個元素都是集合B的元素,那麼集合A稱爲集合B的子集。編程

而子類型則是面向對象設計語言裏常提到的一個概念,是繼承機制的一個產物,如下概念來源百度:數組

在編程語言理論中,子類型是一種類型多態的形式。這種形式下,子類型能夠替換另外一種相關的數據類型(超類型,英語:supertype)。安全

子類型與面嚮對象語言中(類或對象)的繼承是兩個概念。子類型反映了類型(即面向對象中的接口)之間的關係;而繼承反映了一類對象能夠從另外一類對象創造出來,是語言特性的實現。所以,子類型也稱接口繼承;繼承稱做實現繼承。編程語言

咱們能夠理解子類就是實現繼承,子類型就是接口繼承,下面這幅圖更精確的定義了這個概念,不少同窗應該知道這個例子:ide

這幅圖中,貓是一種動物,因此咱們說貓是動物的子集,貓是動物的子類,或者說貓這種類型是動物這種類型的子類型。函數

Co..., Contra..., Bi..., Invariant?

一下提到四個陌生的單詞,不少同窗確定一下就懵了。React開發者應該對HOC (High Order Component) 不陌生,它就是使用一個基礎組件做爲參數,返回一個高階組件的函數。React的基礎是組件 (Component),在TypeScript裏是類型 (Type),所以咱們用HOT (High Order Type) 來表示一個複雜類型,這個複雜類型接收一個泛型參數,返回一個複合類型。post

下面我用一個例子來闡述這四個概念,你能夠將它使用TypeScript Playground運行,查看靜態錯誤提示,進行更深入理解:

interface SuperType {
    base: string;
}
interface SubType extends SuperType {
    addition: string;
};

// subtype compatibility
let superType: SuperType = { base: 'base' };
let subType: SubType = { base: 'myBase', addition: 'myAddition' };
superType = subType;

// Covariant
type Covariant<T> = T[];
let coSuperType: Covariant<SuperType> = [];
let coSubType: Covariant<SubType> = [];
coSuperType = coSubType;

// Contravariant --strictFunctionTypes true
type Contravariant<T> = (p: T) => void;
let contraSuperType: Contravariant<SuperType> = function(p) {}
let contraSubType: Contravariant<SubType> = function(p) {}
contraSubType = contraSuperType;

// Bivariant --strictFunctionTypes false
type Bivariant<T> = (p: T) => void;
let biSuperType: Bivariant<SuperType> = function(p) {}
let biSubType: Bivariant<SubType> = function(p) {}
// both are ok
biSubType = biSuperType;
biSuperType = biSubType;

// Invariant --strictFunctionTypes true
type Invariant<T> = { a: Covariant<T>, b: Contravariant<T> };
let inSuperType: Invariant<SuperType> = { a: coSuperType, b: contraSuperType }
let inSubType: Invariant<SubType> = { a: coSubType, b: contraSubType }
// both are not ok
inSubType = inSuperType;
inSuperType = inSubType;
複製代碼

咱們將基礎類型叫作T,複合類型叫作Comp<T>

  • 協變 (Covariant):協變表示Comp<T>類型兼容和T的一致。
  • 逆變 (Contravariant):逆變表示Comp<T>類型兼容和T相反。
  • 雙向協變 (Covariant):雙向協變表示Comp<T>類型雙向兼容。
  • 不變 (Bivariant):不變表示Comp<T>雙向都不兼容。

TS類型系統

在一些其餘編程語言裏面,使用的是名義類型 Nominal type,好比咱們在Java中定義了一個class Parent,在語言運行時就是有這個Parent的類型。所以若是有一個繼承自ParentChild類型,則Child類型和Parent就是類型兼容的。可是若是兩個不一樣的class,即便他們內部結構徹底同樣,他倆也是徹底不一樣的兩個類型。

可是咱們知道JavaScript的複雜數據類型Object,是一種結構化的類型。哪怕使用了ES6的class語法糖,建立的類型本質上仍是Object,所以TypeScript使用的也是一種結構化的類型檢查系統 structural typing

TypeScript uses structural typing. This system is different than the type system employed by some other popular languages you may have used (e.g. Java, C#, etc.)

The idea behind structural typing is that two types are compatible if their members are compatible.

所以在TypeScript中,判斷兩個類型是否兼容,只須要判斷他們的「結構」是否一致,也就是說結構屬性名和類型是否一致。而不須要關心他們的「名字」是否相同。

基於上面這點,咱們能夠來看看TypeScript中那些「奇怪」的疑問:

爲何TS中的函數類型是雙向協變的?

首先咱們須要知道,函數這一類型是逆變的。

對於協變,咱們很好理解,好比DogAnimal,那Array<Dog>天然也是Array<Animal>。可是對於某種複合類型,好比函數。(p: Dog) => void卻不是(p: Animal) => void,反過來卻成立。這該怎麼理解?我這裏提供兩種思路:

假設(p: Dog) => voidAction<Dog>(p: Animal) => voidAction<Animal>

  1. 基於函數的本質

    咱們知道,函數就是接收參數,而後作一些處理,最後返回結果。函數就是一系列操做的集合,而對於一個具體的類型Dog做爲參數,函數不只僅能夠把它當成Animal,來執行一些操做;還能夠訪問其做爲Dog獨有的一些屬性和方法,來執行另外一部分操做。所以Action<Dog>的操做確定比Action<Animal>要多,所以後者是前者的子集,兼容性是相反的,是逆變。

  2. 基於第三方函數對該函數調用

    假設有一個函數F,其參數爲Action<Animal>,也就是type F = (fp: Action<Animal>) => void。咱們假設Action<Dog>Action<Animal>兼容,此時咱們若是傳遞Action<Dog>來調用函數F,會不會有問題呢?

    答案是確定的,由於在函數F的內部,會對其參數fp也就是(p: Animal) => void進行調用,假設F使用Cat這一Animal對其進行調用,若是此時咱們傳遞的參數fp(p: Dog) => void,咱們自己是但願其被調用時傳遞的參數pDog,然而fp被調用時卻使用了Cat,這顯然會使程序崩潰!

    所以對於函數這一特殊類型,兼容性須要和其參數的兼容性相反,是逆變。

其次咱們再來看看爲何TS裏的函數還同時支持協變,也就是雙向協變的?

前面提到,TS使用的是結構化類型。所以若是Array<Dog>Array<Animal>兼容,咱們能夠推斷:

  • Array<Dog>.pushArray<Animal>.push兼容
    • 也就是(item: Dog) => number(item: Animal) => number兼容
      • ((item: Dog) => number).arguments((item: Animal) => number).arguments兼容
        • DogAnimal兼容

爲了維持結構化類型的兼容性,TypeScript團隊作了一個權衡 (trade-off)。保持了函數類型的雙向協變性。可是咱們能夠經過設置編譯選項--strictFunctionTypes true來保持函數的逆變性而關閉協變性。

爲何參數少的函數能夠和參數多的函數兼容?

這個問題其實和函數類型逆變兼容一個道理,也能夠用上述的兩種思路理解,Dog至關於多個參數,Animal至關於較少的參數。

爲何返回值不是void的函數能夠和返回值是void的函數兼容?

從第三方函數調用的角度,若是參數是一個非void的函數。則代表其不關心這個函數參數執行後的返回結果,所以哪怕給一個有返回值的函數參數,第三方的調用函數也不關係,是類型安全的,能夠兼容。

怎麼構造像Java那樣的名義類型?

一般狀況下,咱們不須要構造名義類型。可是必定要實現的話,也有一些trick:

名義字符串:

// Strings here are arbitrary, but must be distinct
type SomeUrl = string & {'this is a url': {}};
type FirstName = string & {'person name': {}};

// Add type assertions
let x = <SomeUrl>'';
let y = <FirstName>'bob';
x = y; // Error

// OK
let xs: string = x;
let ys: string = y;
xs = ys;
複製代碼

名義結構體:

interface ScreenCoordinate {
  _screenCoordBrand: any;
  x: number;
  y: number;
}
interface PrintCoordinate {
  _printCoordBrand: any;
  x: number;
  y: number;
}

function sendToPrinter(pt: PrintCoordinate) {
  // ...
}
function getCursorPos(): ScreenCoordinate {
  // Not a real implementation
  return { x: 0, y: 0 };
}

// Error
sendToPrinter(getCursorPos());
複製代碼

如何在運行時檢測變量的「名義」類型?

TypeScript的類型檢測只是一種編譯時的轉譯,編譯後類型是擦除的,沒法使用JavaScript的instanceof關鍵字實現類型檢驗:

interface SomeInterface {
  name: string;
  length: number;
}
interface SomeOtherInterface {
  questions: string[];
}

function f(x: SomeInterface|SomeOtherInterface) {
  // Can't use instanceof on interface, help?
  if (x instanceof SomeInterface) {
    // ...
  }
}
複製代碼

若是要實現檢測,須要咱們本身實現函數判斷類型內部的結構:

function isSomeInterface(x: any): x is SomeInterface {
  return typeof x.name === 'string' && typeof x.length === 'number';

function f(x: SomeInterface|SomeOtherInterface) {
  if (isSomeInterface(x)) {
    console.log(x.name); // Cool!
  }
}
複製代碼

還有更多「奇怪」的疑問,能夠參考TypeScript Wiki FAQs

類型安全和不變性

最後來聊一下不變性 (Invariant) 的應用。上面咱們提到Array<T>這一複合類型是協變。可是對於可變數組,協變並不安全。一樣,逆變也不安全(不過通常逆變不存在於數組)。

下面這個例子中運行便會報錯:

class Animal { }

class Cat extends Animal {
    meow() {
        console.log('cat meow');
    }
}

class Dog extends Animal {
    wow() {
        console.log('dog wow');
    }
}

let catList: Cat[] = [new Cat()];
let animalList: Animal[] = [new Animal()];
let dog = new Dog();

// covariance is not type safe
animalList = catList;
animalList.push(dog);
catList.forEach(cat => cat.meow()); // cat.meow is not a function

// contravariance is also not type safe, if it exist here
catList = animalList;
animalList.push(dog);
catList.forEach(cat => cat.meow());
複製代碼

所以,咱們使用可變數組時應該避免出現這樣的錯誤,在作類型兼容的時候儘可能保持數組的不可變性 (immutable)。而對於可變數組,類型本應該作到不變性。可是編程語言中很難實現,在Java中數組類型也都是可變並且協變的

參考

  1. What are covariance and contravariance?
  2. Covariance, contravariance and a little bit of TypeScript
  3. TypeScript Deep Dive
  4. Type System Behavior
相關文章
相關標籤/搜索