编程随想 · 2022 年 07 月 19 日 0

Why are function parameters bivariant?

背景

为什么函数参数是双变?

这个问题实际上在 TS2.6 之后已经不完全成立了:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types

最近在工作中碰到一个双变带来的坑,借此好好捋一捋类型编程中的协变(covariant)、逆变(contravariant)、双变(bivariant)及不变(invariant)。

在此之前有过两篇不成熟的理解文章:
* Typescript逆变与协变
* TypeScript类型兼容

现在回过头去看,有种隔靴搔痒没理解到关键点上的感觉。

父与子

先定义两个接口:

interface Animal {
    eat: () => void;
}
interface Dog extends Animal {
    bark: () => void;
}

这里 DogAnimal 的子类型,在类型编程的里,使用父类型的场景可以“安全”替换为子类型。这句话怎么理解呢?且看例子:

let animal: Animal = xxx;
let dog: Dog = yyy;

function animalEat(animal: Animal) {
    animal.eat();
}
// work
animalEat(animal);
// also work
animalEat(doc);

虽然我们定义的 animalEat 入参类型为 Animal,但实际使用时,可以传入其子类 Dog 的实例,为什么可以这样呢?

因为在 animalEat 内所调用的接口一定遵循 Animal 的定义,而 Dog 作为其子类型也一定符合其接口定义,故而在运行时也是安全的,反之则未必:

let animal: Animal = xxx;
let dog: Dog = yyy;

function dogBark(dog: Dog) {
    dog.bark();
}
// Error:并非所有的 animal 都有 bark 接口
dogBark(animal);

为了后面能更简单表示父子类型关系,我们约定 A ≤ B 表示AB的子类型,也即在使用B的场景可用A替代。

协变

有如下函数:

function fn1(cb: () => Animal) {}
function fn2(cb: () => Dog) {}
// ①
fn1(() => dog);
// ②
fn2(() => animal);

①、② 哪个成立?

这个问题实际是:() => Dog ≤ () => Animal() => Animal ≤ () => Dog 哪个成立。

如果 fn2 是如下实现方式(注意,未改定义,仅增加了实现),我们很简单就能推断出() => Dog ≤ () => Animal是成立的:

function fn2(cb: () => Dog) {
    const dog = cb();
    // 若 cb 实际是 () => Animal,则下面这行运行时便会出错
    dog.bark();
}

那么我们就有了结论,因为 Dog ≤ Animal() => Dog ≤ () => Animal,我们称后者为协变,换通用的表述:函数的返回值是协变的,意味着其父子类型关系是跟着实际返回的类型走的。

逆变

与协变反之,且看下面的例子:

function fn1(cb: (animal: Animal) => void) {}
function fn2(cb: (dog: Dog) => void) {}

// ①
fn1((dog: Dog) => {});
// ②
fn2((animal: Animal) => {});

①、② 哪个成立?

没有协变的例子直观,加入没有类型错误,我们完善下 ① 的 callback:

fn1((dog: Dog) => {
    dog.bark();
});

运行代码会出现什么情况?

Runtime Error

为什么会这样呢?看看 fn1 的定义,人家可只承诺给你 callback 传入 Animal,你一厢情愿当 Dog 使用,必然不安全。反观 ②:

fn2((animal: Animal) => {
    animal.eat();
});

fn2 承诺会给 callback 传入 Dog,而我们在 callback 内将其当 Animal 使用则没有任何问题,所以我们有结论:
Animal => void ≤ Dog => void,其与 Dog ≤ Animal 的方向反过来了,称之为逆变,也就是说函数的参数是逆变的。

那为什么一开始又问“Why are function parameters bivariant” ?

双变

即便在 TS2.6 以后,开启了严格模式,下面的写法并不会报错(online example):

interface Father {
    playWith(animal: Animal): void;
}

interface Child extends Father {
    playWith(animal: Dog): void;
}
let f:Father = xxx;
let c:Child = yyy;
// ①
f.playWith = c.playWith;
// ②
c.playWith = f.playWith;

不是说好函数的参数是逆变的嘛?为什么 ① 不报错?

且看下面的逐步推导:
1. Child ≤ Father 这是毫无疑问的
2. 既然上述成立,那 Child[playWith] ≤ Father[playWith] 也应该成立,因为 TS 是结构化类型系统
3. 也就意味着 Dog => void ≤ Animal => void 成立

更多可以参考 https://github.com/Microsoft/TypeScript/wiki/FAQ?spm=ata.21736010.0.0.6696252c5Tm6NX#why-are-function-parameters-bivariant

通过上面的步骤我们又推导出了函数的参数是协变的,既然前面我们也推导出了函数的参数是逆变的,那既逆变又协变,那就叫双变吧。

但理性告诉我们,函数参数的协变是不安全的,是不得已而为之,所以 TS2.6 在严格模式下禁用了函数参数的的协变,但依旧允许上面的写法以保证在方法(method)类函数参数的双变。

在大多数情况下,我们应该使用如下方式定义接口中的函数:

interface Father {
    // playWith(animal: Animal): void;
    playWith: (animal: Animal) => void;
}

interface Child extends Father {
    // playWith(animal: Dog): void;
    playWith: (animal: Dog)=> void; // <<<<<=======  Type Error
}

不变

有如下接口:

interface State<T> {
    get: () => T;
    set: (value: T) => void;
}

那么请问State<T> 是协变的还是逆变的?

为了不费脑子,我们直接上例子:

let a:State<Animal> = xxx;
let b:State<Dog> = yyy;
// ①
a = b;
// ②
b = a;

打开 online example 发现①、②均报错了,其实也容易理解,我们仔细看看就会发现 State<T>[get] 是协变,State<T>[set] 是逆变的,那State<T>只能既不是逆变也不是协变了,称之为不变

为了在复杂场景,能一眼看出某个泛型是逆变、协变、双变还是不变,TS4.7 引入了两个关键字用于手动标注,上述的例子可以改成:

interface State<in out T> {
    get: () => T;
    set: (value: T) => void;
}

细节不再阐述,可自行查阅文档。

结尾

前面我们说道:

使用父类型的场景可以“安全”替换为子类型
安全二字我打上了引号,为啥呢?感受下下面这个例子:

function test(animals: Animal[]) {
    animals.push(animal);
}

const dogs: Dog[] = [];
test(dogs);

// runtime error
dogs.forEach((doc) => dog.bark());

啊哈哈,,,mutable 是万恶之源 :satisfied: