快速理解 TypeScript 的逆變和協變
深入學習 TypeScript 類型系統的話,逆變、協變、雙向協變、不變是繞不過去的概念。
這些概念看起來挺高大上的,其實并不復雜,這篇文章我們就來學習下它們吧。
類型安全和型變
TypeScript 給 JavaScript 添加了一套靜態類型系統,是為了保證類型安全的,也就是保證變量只能賦同類型的值,對象只能訪問它有的屬性、方法。
比如 number 類型的值不能賦值給 boolean 類型的變量,Date 類型的對象就不能調用 exec 方法。
這是類型檢查做的事情,遇到類型安全問題會在編譯時報錯。
但是這種類型安全的限制也不能太死板,有的時候需要一些變通,比如子類型是可以賦值給父類型的變量的,可以完全當成父類型來使用,也就是“型變”(類型改變)。
這種“型變”分為兩種,一種是子類型可以賦值給父類型,叫做協變,一種是父類型可以賦值給子類型,叫做逆變。
先來看下協變:
協變
其中協變是很好理解的,比如我們有兩個 interface:
interface Person {
name: string;
age: number;
}
interface Guang {
name: string;
age: number;
hobbies: string[]
}
這里 Guang 是 Person 的子類型,更具體,那么 Guang 類型的變量就可以賦值給 Person 類型:
這并不會報錯,雖然這倆類型不一樣,但是依然是類型安全的。
這種子類型可以賦值給父類型的情況就叫做協變。
為什么要支持協變很容易理解:類型系統支持了父子類型,那如果子類型還不能賦值給父類型,還叫父子類型么?
所以型變是實現類型父子關系必須的,它在保證類型安全的基礎上,增加了類型系統的靈活性。
逆變相對難理解一些:
逆變
我們有這樣兩個函數:
let printHobbies: (guang: Guang) => void;
printHobbies = (guang) => {
console.log(guang.hobbies);
}
let printName: (person: Person) => void;
printName = (person) => {
console.log(person.name);
}
printHobbies 的參數是 printName 參數的子類型。
那么問題來了,printName 能賦值給 printHobbies 么?printHobbies 能賦值給 printName 么?
測試一下發現是這樣的:
printName 的參數不是 printHobbies 的父類型么,為啥能賦值給子類型?
因為這個函數調用的時候是按照 Guang 來約束的類型,但實際上函數只用到了父類型 Person 的屬性和方法,當然不會有問題,依然是類型安全的。
這就是逆變,函數的參數有逆變的性質(而返回值是協變的,也就是子類型可以賦值給父類型)。
那反過來呢,如果 printHoobies 賦值給 printName 會發生什么?
因為函數聲明的時候是按照 Person 來約束類型,但是調用的時候是按照 Guang 的類型來訪問的屬性和方法,那自然類型不安全了,所以就會報錯。
但是在 ts2.x 之前支持這種賦值,也就是父類型可以賦值給子類型,子類型可以賦值給父類型,既逆變又協變,叫做“雙向協變”。
但是這明顯是有問題的,不能保證類型安全,所以之后 ts 加了一個編譯選項 strictFunctionTypes,設置為 true 就只支持函數參數的逆變,設置為 false 則是雙向協變。
我們把 strictFunctionTypes 關掉之后,就會發現兩種賦值都可以了:
這樣就支持函數參數的雙向協變,類型檢查不會報錯,但不能嚴格保證類型安全。
開啟之后,函數參數就只支持逆變,子類型賦值給父類型就會報錯:
在類型編程中這種逆變性質有什么用呢?
還記得之前聯合轉交叉的實現么?
type UnionToIntersection<U> =
(U extends U ? (x: U) => unknown : never) extends (x: infer R) => unknown
? R
: never
類型參數 U 是要轉換的聯合類型。
U extends U 是為了觸發聯合類型的 distributive 的性質,讓每個類型單獨傳入做計算,最后合并。
利用 U 做為參數構造個函數,通過模式匹配取參數的類型。
結果就是交叉類型:
我們通過構造了多個函數類型,然后模式提取參數類型的方式,來實現了聯合轉交叉,這里就是因為函數參數是逆變的,會返回聯合類型的幾個類型的子類型,也就是更具體的交叉類型。
逆變和協變都是型變,是針對父子類型而言的,非父子類型自然就不會型變,也就是不變:
不變
非父子類型之間不會發生型變,只要類型不一樣就會報錯:
那類型之間的父子關系是怎么確定的呢,好像也沒有看到 extends 的繼承?
類型父子關系的判斷
像 java 里面的類型都是通過 extends 繼承的,如果 A extends B,那 A 就是 B 的子類型。這種叫做名義類型系統(nominal type)。
而 ts 里不看這個,只要結構上是一致的,那么就可以確定父子關系,這種叫做結構類型系統(structual type)。
還是拿上面那個例子來說:
Guang 和 Person 有 extends 的關系么?
沒有呀。
那是怎么確定父子關系的?
通過結構,更具體的那個是子類型。這里的 Guang 有 Person 的所有屬性,并且還多了一些屬性,所以 Guang 是 Person 的子類型。
注意,這里用的是更具體,而不是更多。
判斷聯合類型父子關系的時候, 'a' | 'b' 和 'a' | 'b' | 'c' 哪個更具體?
'a' | 'b' 更具體,所以 'a' | 'b' 是 'a' | 'b' | 'c' 的子類型。
測試下:
總結
ts 通過給 js 添加了靜態類型系統來保證了類型安全,大多數情況下不同類型之間是不能賦值的,但是為了增加類型系統靈活性,設計了父子類型的概念。父子類型之間自然應該能賦值,也就是會發生型變。
型變分為逆變和協變。協變很容易理解,就是子類型賦值給父類型。逆變主要是函數賦值的時候函數參數的性質,參數的父類型可以賦值給子類型,這是因為按照子類型來聲明的參數,訪問父類型的屬性和方法自然沒問題,依然是類型安全的。但反過來就不一定了。
不過 ts 2.x 之前反過來依然是可以賦值的,也就是既逆變又協變,叫做雙向協變。
為了更嚴格的保證類型安全,ts 添加了 strictFunctionTypes 的編譯選項,開啟以后函數參數就只支持逆變,否則支持雙向協變。
型變都是針對父子類型來說的,非父子類型自然就不會型變也就是不變。
ts 中父子類型的判定是按照結構來看的,更具體的那個是子類型。
理解了如何判斷父子類型(結構類型系統),父子類型的型變(逆變、協變、雙向協變),很多類型兼容問題就能得到解釋了。