知其然,知其所以然:TypeScript 中的協變與逆變

林不渡 2022-01-07 12:17:08 阅读数:25

知其然 其所 所以然 typescript

前言

在前一篇文章《淘寶店鋪 TypeScript ESLint 規則集考量》中,我們提到了這一條規則:method-signature-style,它的作用是對 interface 中不同的函數聲明方式進行約束,這裏的聲明方式主要有兩種,methodproperty,區別如下:

// method
interface T1 {
func(arg: string): number;
}
// property
interface T2 {
func: (arg: string) => number;
}
複制代碼

首先,這兩種方式被稱為 method 與 property 的原因很明顯,method 方式就像是在 Class 中定義方法一樣,而 property 則是就像定義普通的接口屬性,只不過它的值是函數類型。

在 TypeScript ESLint 官方對此規則的解釋中,推薦使用 property 方式,而最重要的原因即是 property + 函數類型值的聲明方式使得函數的類型能享受到更嚴格的類型校驗(需要開啟 strictFunctionTypes,或只開啟 strict 即可)。

那麼這一條配置又是啥?函數的類型校驗為什麼還能有更嚴格的方式,默認情况下又是怎樣的?為什麼使用 method 聲明就享受不到了?

這篇文章我們就從這裏開始,聊一聊 TypeScript 中的協變與逆變。

基礎概念

直接上概念太勸退了,我們先從生活中隨處可見的例子開始:

class Animal {
asPet() {}
}
class Dog extends Animal {
bark() {}
}
class Corgi extends Dog {
cute() {}
}
複制代碼

在這裏,我們有三個依次派生的類,每個類在上一個類的基礎上添加了一個獨特的方法。我們使用 符號錶達子類型關系,A ≼ B 意為 A 是 B 的子類型,在這裏的例子中,易得 Corgi ≼ Dog ≼ Animal

現在我們多了一個函數:它接收一只狗作為參數,並嘗試讓它吠幾聲聽聽:

function makeDogBark(dog: Dog) {
dog.bark();
}
複制代碼

現在來試著調用一下:

makeDogBark(new Corgi());
makeDogBark(new Animal());
複制代碼

你很容易發現第一種是可以的,因為所有的柯基都是狗,都會吠,但第二種,並不是所有的動物都會吠,所以這裏會拋出一個錯誤。通過這個非常簡單的例子,你回憶了一下子類型和父類型,熱身結束,要開始看點認真的了。

使用函數作為入參

再看一個例子,假設現在我們有一個新的函數,它接收一個函數作為參數,其類型為 Dog -> Dog(即參數類型與返回值均為 Dog)。

type DogFactory = (args: Dog) => Dog;
function transformDogAndBark(dogFactory: DogFactory) {
const dog = dogFactory(new Dog());
dog.bark();
}
複制代碼

這種情况下,我們要和 Dog -> Dog 進行比較的也一定是 Corgi/Animal -> Corgi/Animal 這樣的類型,來排列組合一下幾種情况:

  • Corgi -> Corgi:我們在 transformDogAndBark 中,會傳入一只狗,並讓返回的狗狗叫兩聲聽聽,看起來好像沒問題,但是 Corgi -> Corgi 函數只能接受柯基,內部可能調用了柯基才有的邏輯,如果我們傳了個柴犬,那程序可能就崩潰了。但返回值沒問題,因為不管是柯基還是柴犬都能叫嘛。
  • Animal -> Animal:有了上一點的經驗我們一看就知道不行,因為它的返回值可能是任何動物,但不是任何動物都會狗叫。
  • Corgi -> Animal:第一點是參數類型有問題,第二點是返回值類有問題,這一點則是參數類型和返回值都有問題。
  • Animal -> Corgi:只剩下這一個正確答案了,如果還不行的話就離譜了。還是先來分析一波,首先我們會傳入一只狗,好的,沒問題,Animal 有的方法 Dog 都有。接著我們會讓返回的物種叫兩聲,這裏返回的是柯基!它可以叫!所以沒問題!

Dog 派生自 Animal,但不會修改其內部的原有功能如繁殖、進食,即裏氏替換原則:子類可以擴展父類的功能,但不能改變父類原有的功能,子類型(subtype)必須能够替換掉他們的基類型(base type)。

歸納一下上面的情况,我們會發現,作為參數的函數,它的入參允許是函數入參類型的父類型(實際入參 Animal,類型入參 Dog),不允許為子類型(實際入參 Corgi,類型入參 Dog),而它的返回值允許是函數返回類型的子類型(實際返回值 Corgi,類型返回值 Corgi),不允許是父類型(實際返回值 Animal,類型返回值 Dog)。

考慮到在前面最簡單的例子中我們知道可用的入參類型會是函數入參類型的子類型,也就說,以下這一等式成立:

(Animal → Corgi) ≼ (Dog → Dog)
複制代碼

協變與逆變

這個時候我們可以引入協變(covariance)與逆變(contravariance)的概念了,

看這兩個單詞,去掉意為變异性的 variance 後,還剩下 co 和 contra。如果說 contra 你不知道什麼意思,那 co 你一定知道,如 Collaboration、Cooperation 這兩個單詞都是加上 co 就都多了協作的意思,懂伐?

這兩個單詞實際上最初應該來自於幾何學領域中:隨著某一個量的變化,隨之變化一致的即稱為協變,而變化相反的即稱為逆變。 而在這裏,我們稱函數參數為逆變,函數返回值為協變,為什麼?

考慮 Corgi ≼ Dog,如果它遵循協變,則有 (T → Corgi) ≼ (T → Dog),即 A、B 在被作為函數返回值類型以後仍然遵循一致的子類型關系。而對於參數,由於其遵循逆變,則有 (Dog → T) ≼ (Corgi → T),即 A、B 被作為函數參數類型以後其子類型關系發生逆轉

這一段可能有點猝不及防,我們來講講人話。對於我個人來說,習慣將 “隨著某一個量的變化” 中的 變化,在 TypeScript 作為一個工具類型 Wrapper 理解,如:

type AsFuncArgType<T> = (arg: T) => void;
type AsFuncReturnType<T> = (arg: unknown) => T;
// 不成立:Corgi -> void ≼ Dog -> void
type CheckArgType = AsFuncArgType<Corgi> extends AsFuncArgType<Dog> ? 1 : 2;
// 成立:unknown -> Corgi ≼ unknown -> Dog
type CheckReturnType = AsFuncReturnType<Corgi> extends AsFuncReturnType<Dog>
? 1
: 2;
複制代碼

Wrapper<B> ≼ Wrapper<A>,這裏的 Wrapper 可能是函數這種隱式的 包裝,也可能是 Promise、Array、Record 等等這些顯式將其作為類型參數的高階類型。

A ≼ B 時,協變意味著 Wrapper<A> ≼ Wrapper<B>,而逆變意味著 Wrapper<B> ≼ Wrapper<A>

而按照正確的檢查邏輯(即我們上面的四種情况檢查),函數的參數類型應該使用逆變的方式來進行檢查,而返回值類型則是協變。

(Animal → Corgi) ≼ (Dog → Dog)
複制代碼

默認情况下與 strictFunctionTypes 下 的函數類型檢查

還記得在前面我們說到 method-signature-style 這條規則時提到它會對函數類型會啟用更嚴格的類型檢查(因而會包括使用 property 屬性聲明的函數類型),這個 “更嚴格” 指的就是,對於函數參數啟用逆變檢查。啥意思?先來看看默認情况下與啟用 strictFunctionTypes 的情况下分別是如何的,仍然使用 Animal、Dog 的例子,引入一個額外的 Cat,考慮以下函數:

declare let f1: (x: Animal) => void;
declare let f2: (x: Dog) => void;
declare let f3: (x: Cat) => void;
複制代碼

為了檢驗函數類型之間的兼容性,我們對 f1 f2 f3 之間進行賦值操作:

  • 設有 f1 = f2 成立,則 f2 ≼ f1
  • 接著設有 f1: A1 -> B1,f2: A2 -> B2,若 f1 = f2 成立,則 A2 -> B2 ≼ A1 -> B1
  • f1 = f2,即 A2 -> B2 ≼ A1 -> B1,即 Dog -> void ≼ Animal -> void
    • 上面說到如果“認真的”去檢查函數類型的可分配性,函數參數的部分需要滿足的是逆變的關系(Animal → Corgi) ≼ (Dog → Dog),即 Dog 是 Animal 的子類,則 Animal -> void 的左側函數的入參類型需要是 Animal 的父類如 Creature。因此在開啟 strictFunctionTypes 的情况下此分配不成立
    • 但在默認情况下,對於函數類型的檢查,在參數部分是雙向協變(bivariantly),即 Dog ≼ Animal 可以推導出 Dog -> void ≼ Animal -> voidAnimal -> void ≼ Dog -> void
  • f2 = f1,即 Animal -> void ≼ Dog -> void
    • 在嚴格檢查(逆變)與默認情况下(雙向協變)成立
  • f2 = f3,即 Cat -> void ≼ Dog -> void
    • Cat 與 Dog 均是 Animal 的子類,它們之間不存在派生關系,因此甚至不能滿足逆變、協變的發生條件,故在嚴格檢查與默認情况下均不成立。

那麼,為什麼默認情况下函數的參數是使用雙向協變檢查的?我們來看官方的說明(原文:Why are function parameters bivariant?

考慮如下代碼:

function trainDog(d: Dog) { ... }
function cloneAnimalAndDoSth(source: Animal, sth: (result: Animal) => void): void { ... }
let c = new Cat();
cloneAnimalAndDoSth(c, trainDog);
複制代碼

最後一行的 cloneAnimal 調用很明顯是會報錯的,因為此函數內部會將傳入的 source 交給第二個參數的函數進行處理,這裏我們傳了猫猫以及給狗狗使用的訓練函數,明顯是不可行的,但是在默認情况下這一段代碼不會報錯(因為默認雙向協變嘛)。

我們知道這裏實際是 Dog -> voidAnimal -> void 的比較,如果按照逆變的轉換, Dog ≼ Animal,則 Animal -> void ≼ Dog -> voi,那麼這裏 trainDog 是不能賦值給 sth 的!但是當然默認情况下由於執行函數參數的雙向協變,所以並不會報錯。

事實上,其實我們能非常神奇的從 Dog[] 與 Animal[] 的比較推導到 Dog -> void 和 Animal -> void 的比較,再回到 Dog 與 Animal 的比較:

  • Dog[] ≼ Animal[] 是否成立?
  • Dog[] 上的每一個成員(屬性、方法)是否都能對應的賦值給 Animal[]?
    • 你以為我要問 Dog 和 Animal 的比較?錯了,我要問的是,Dog[].push ≼ Animal[].push 是否成立?
    • 由 push 方法進一步推導,Dog -> void ≼ Animal -> void 是否成立?

這裏你就開始感覺不對勁了,如果我們要求 Dog[] ≼ Animal[] 成立,按照這個推導關系,就要求 Dog -> void ≼ Animal -> void ,在逆變的情况下意味著 Animal ≼ Dog,這很明顯是不對的。簡單的來說, Dog -> void ≼ Animal -> void 是否成立本身就為 Dog[] ≼ Animal[] 提供了一個前提答案。

這一節即是本文前言中最核心的一個部分,使用 property 方式聲明接口中的函數,其享受的更嚴格類型檢查指的是什麼。不妨來看一下使用 method 方式聲明:

interface Comparer<T> {
compare(a: T, b: T): number;
}
declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;
animalComparer = dogComparer;
dogComparer = animalComparer;
複制代碼

這裏的賦值操作其實和前面的大致類似,注意這裏兩個賦值是不會報錯的,因為使用 method 方式聲明時仍然保持默認的雙向協變,如果改為 property 聲明,則第一條賦值語句 animalComparer = dogComparer; 將不成立。

為什麼使用 method 聲明就享受不到了?這主要是為了仍然提供雙向協變的校驗給一些需要的場景,如 Array 的內部聲明:

interface Array<T> {
push(...items: T[]): number;
concat(...items: ConcatArray<T>[]): T[];
join(separator?: string): string;
}
複制代碼

其他場景

假設現在 Wrapper 不再是函數體了,直接一個簡單的籠子 Cage 呢?先不考慮 Cage 內部的實現,只知道它同時只能放一個物種的動物,Cage<Dog> 能被作為 Cage<Animal> 的子類型嗎?對於這一類型的比較,我們可以直接用實際場景來代入,即:

  • 假設我需要一籠動物,但並不會對它們進行除了讀以外的操作,那麼你給我一籠狗我也是沒問題的。也就意味著,此時 List 是 readonly 的,而 Cage<Dog> ≼ Cage<Animal> 成立。即在不可變的 Wrapper 中,我們允許其遵循協變。
  • 假設我需要一籠動物,並且會在其中新增其他物種,比如兔子啊王八,這個時候你給我一籠狗就不行了,因為這個籠子只能放狗,放兔子進行可能會變异。也就意味著,此時 List 是 writable 的,而 Cage<Dog> Cage<Rabit> Cage<Turtle> 彼此之前是互斥的,我們稱為 不變(invariant),用來放狗的籠子絕不能用來放兔子,即無法進行分配。
  • 如果我們再修改下規則,現在一個籠子可以放任意物種的動物,狗和兔子可以放一個籠子裏,這個時候任意的籠子都可以放任意的物種,放狗的可以放兔子,放兔子的也可以放狗,即可以互相分配,我們稱之為雙變(Bivariant)。

全文就到這裏結束了,實際上協變與逆變對我來說也是比較新鮮的概念,並且由於我只學習過 TypeScript 這一門類型語言,其中的敘述可能還有著不盡准確之處,歡迎指正與共同交流。下一篇文章中,我們會談到一個大部分人都比較感興趣的話題:TypeScript 中的工具類型與類型體操,這會是一個比較龐大的內容,我可以拍著胸脯保證看完你就會 95% 的工具類型與類型體操了!

版权声明:本文为[林不渡]所创,转载请带上原文链接,感谢。 https://gsmany.com/2022/01/202201071217076248.html