Typescript協變與逆變簡單理解

1. 協變和逆變簡單理解

先簡單說下協變和逆變的理解。

首先,無論協變還是逆變,必然是存在於有繼承關系的類當中,這個應該好理解吧。如果你隻有一個類,那沒有什麼好變的。

其次,無論協變還是逆變,既然是變,那必然是存在不同類之間的對象的賦值,比如子類對象賦值給父類對象,父類對象賦值給子類對象,這樣才叫做變。

結合上面兩條,我覺得協變和逆變在我的字典中就能定義成:支持子類對象賦值給父類對象的情況稱之為協變;反之,支持父類對象賦值給子類對象的情況稱之為逆變。

舉個栗子,我們先假定我們有這麼幾個類

class Animal {}
class Dog extends Animal {}
class Greyhound extends Dog {}

那麼按照上面的理解,要整出一個示例的話,首先我們這裡類的繼承關系這個條件有瞭,其次我們要整出的就是這幾個類賦值的情況,那麼用實參和形參的方式來demo應該是很不錯的選擇。

2. 協變舉例

那麼協變的情況我們可以用代碼表示為

class Animal {}
class Dog extends Animal {
    bark(): void {
        console.log("Bark")
    }
}
class Greyhound extends Dog {}
function makeDogBark(dog:Dog) : void {
    dog.bark()
}
let dog: Dog = new Dog();
let greyhound: Greyhound = new Greyhound();
let animal: Animal = new Animal();
makeDogBark(greyhound) // OK。 子類賦值給父類
makeDogBark(animal) // Error。編譯器會報錯,父類不能賦值給子類

我們如果有面向對象基礎的話,相信對上面這段代碼不難理解, 子類賦值給父類,即協變的情況,在面向對象編程中是非常常見的,且這是實現語言多態特性的基礎。而多態,卻又是實現眾多設計模式的基礎。

3. 逆變舉例

當我們將函數作為參數進行傳遞時,就需要註意逆變的情況。比如下面的makeAnimalAction這個函數,就嘗試錯誤的讓一隻貓去做出狗吠的動作。

class Animal {
    doAnimalThing(): void {
        console.log("I am a Animal!")
    }
}
class Dog extends Animal {
    doDogThing(): void {
        console.log("I am a Dog!")
    }
}
class Cat extends Animal {
    doCatThing(): void {
        console.log("I am a Cat!")
    }
}
function makeAnimalAction(animalAction: (animal: Animal) => void) : void {
    let cat: Cat = new Cat()
    animalAction(cat)
}
function dogAction(dog: Dog) {
    dog.doDogThing()
}
makeAnimalAction(dogAction) // TS Error at compilation, since we are trying to use `doDogThing()` to a `Cat`

這裡作為實參的dogAction函數接受一個Dog類型的參數,而makeAnimalAction的形參animalAction接受一個Dog的父類Animal類型的參數,返回值都是void,那麼按照正常的思路,這時應該可以像上面協變的例子一樣進行正常的賦值的。

但事實上編譯是不能通過的,因為最終makeAnimalAction中的代碼會嘗試以cat為參數去調用dogAction,然後讓一個cat去執行doDogThing。

所以這裡我們把函數作為參數傳遞時,如果該函數裡面的參數牽涉到有繼承關系的類,就要特別註意下逆變情況的發生。

不過有vscode等代碼編輯工具的錯誤提示支持的話,應該也很容易排除這種錯誤。

4. 更簡單點的理解

我覺得將上面的例子稍微改動下,將makeAnimalAction的形參的類型抽出來定義成一個type,應該會有助於我們理解上面的代碼。

class Animal {
    doAnimalThing(): void {
        console.log("I am a Animal!")
    }
}
class Dog extends Animal {
    doDogThing(): void {
        console.log("I am a Dog!")
    }
}
class Cat extends Animal {
    doCatThing(): void {
        console.log("I am a Cat!")
    }
}
function makeAnimalAction(animalAction: AnimalAction) : void {
    let cat: Cat = new Cat()
    animalAction(cat)
}
type AnimalAction =  (animal: Animal) => void
type DogAction =  (dog: Dog) => void
let dogAction: DogAction = (dog: Dog) => {
    dog.doDogThing()
}
const animalAction: AnimalAction = dogAction // Error: 和上面一樣的逆變導致的錯誤
makeAnimalAction(animalAction)
  • animalAction(animal: Animal)函數,我們可以將其理解成一個可以讓動物做動物都有的動作的函數。因此我們可以傳dog、cat或者animal進去作為參數,因為它們都是動物,然後animalAction內部可以調用animal.doAnimalThing方法,但不能調用doCatThing或者doDogThing這些方法,因為這些不是所有動物共有的方法。
  • dogAction(dog: Dog)函數, 同上,我們可以將其理解成一個可以讓狗狗做狗狗都有的動作的函數。因此可傳dog,greyHound這些狗狗對象作為參數,因為對他們都是狗狗,然後dogAction內部可以調用dog.doDogThing和dog.doAnimalThing, 因為這些都是狗狗共有的動作。但是不能調用dog.doGrenHoundThing,因為這不是狗狗共有的動作,隻有狗狗的子類灰狗用歐這樣的函數。

以上兩個都是協變的情況。下面我們看下逆變所導致的錯誤那一行。

animalAction = dogAction,如果有C/C++經驗的,就可以理解成一個函數指,指向另外一個函數,否則理解成一個函數復制給另外一個函數也可以。

假如這個語句可以執行,那麼執行之前,dogAction(dog: Dog)隻能接受Dog和GreyHound類型的對象,然後去做狗狗都有的動作。

執行之後,因為現在animalAction指向瞭dogAction,但是animalAction自身的參數是(animal: Animal),即可以接受所有動物類型的對象。

所以最終這裡animalAction就變成瞭這幅模樣(隱隱約約覺得這是理解的關鍵):

function animalAction(animal: Animal) {
 animal.doDogThing()
}

這很明顯就是不合理的嘛!所有狗狗都是動物,但這裡反過來就不行,不是所有動物都能做狗狗能做的事情,比如這裡傳個Cat對象進來,那豈不就是讓貓去做狗狗的事情瞭嗎。

而反過來,這裡假如我們先定義瞭animalAction, 然後我們讓dogAction = animalAction,這種做法卻是可行的。我們看最終dogAction變成

function dogAction(dog: Dog) {
 dog.doAnimalThing()
}

即dogAction(dog:Dog)指向瞭animalAction(animal: Animal), 也就是一個以父類型的對象為參數的函數賦予給瞭一個以子類型的對象為參數的函數,這和我們協變時候的對象之間的賦值時,隻能子對象賦值給父對象的做法是相反的。我想,這應該也是為什麼叫做逆變的原因吧。

本來這裡在我頭腦過的時候感覺應該很容易說清楚的,沒有想到寫下來的時候還是得寫這麼一大堆,希望能有幫助吧。

5. 參考

https://dev.to/codeozz/how-i-understand-covariance-contravariance-in-typescript-2766

到此這篇關於Typescript協變與逆變簡單理解的文章就介紹到這瞭,更多相關Typescript協變與逆變內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: