背景
为什么函数参数是双变?
这个问题实际上在 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;
}
这里 Dog
是 Animal
的子类型,在类型编程的里,使用父类型
的场景可以“安全”替换为子类型
。这句话怎么理解呢?且看例子:
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
表示A
是B
的子类型,也即在使用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: