楼上说了背景知识
我补充个具体的例子吧
class Troop {};
class AirTroop {
attackFromTheSky(){}
}
class LavaHound extends AirTroop {
aggro(){}
}
class Balloon extends AirTroop{
avalanche() {}
}
type AttackStrategy = (arg:AirTroop) => AirTroop;
var f = (g:AttackStrategy) => {
var ball = new Balloon();
ball.avalanche();
var n = g(ball);
n.attackFromTheSky();
}
var g1 = (n:LavaHound) => n;
f(g1); //类型不安全,因为f会以ballon为参数调用g
var g2 = (n:Troop) => n;
f(g2); //类型不安全,因为f会使用g的返回值,不是所有的部队都能从空中进攻
var g3 = (n: Troop) => new LavaHound();
f(g3); // 类型安全
所以:F(Troop) => LavaHound 对于 F(AirTroop) => AirTroop是类型安全的
可以看到对于返回类型 LavaHound是AirTroop的子类,而参数类型则相反,Troop是AirTroop的父类。也就是所谓的返回类型是协变,而参数类型是逆变
TypeScript的Bivariance
就是指函数参数两样都行,这明显是不安全的,比如对应代码f(g1)的情况。
是
interface Person {
name: string;
}
interface Student extends Person {
grade: number;
}
function doIt(
provider: () => Person,
customer: (v: Person) => void,
): void {
const p = provider();
customer(p);
}
const personProvider = () => ({ name: "James" } as Person);
const studentProvider = () => ({ name: "Jack", grade: 7 } as Student);
const personCustomer = (p: Person) => console.log(p.name);
const studentCustomer = (p: Student) => console.log(`${p.name} in ${p.grade}`);
完成上述定义之后,如此使用 doIt()
doIt(personProvider, personCustomer);
doIt(personProvider, studentCustomer);
// ^^^^^^^^^^^^^^^ ①
doIt(studentProvider, personCustomer);
// ^^^^^^^^^^^^^^^ ②
doIt(studentProvider, studentCustomer);
// ^^^^^^^^^^^^^^^ ③
doIt()
的定义,第 2 个参数应该消费一个 Person
所以 doIt()
内部可能会给它一个不是 Student
的 Person
。但 studentCustomer
需要消费一个 Student
,很显然 doIt()
给它消费的东西不符合要求 —— 如果给的只是一个 Person
,那 studentCustomer
中使用 p.grade
时就会不找到 grade
属性。② doIt()
需要提供者提供一个 Person
,然后它内部会在 Person
声明的有限属性/方法中进行操作。所以只要提供者提供的是一个 Person
(其子类对象也是 Person
)就行,而 Student
确实是 Person
,所以 studentProvider
可用。
子类对象一定是父类类型,但父类对象不一定是子类类型。想像一下:学生是人,但人不一定是学生。
Student
,但是 doIt()
是把它当作 Person
看待的(根据声明),它并不清楚这是一个可以给 studnetCustomer
消费的对象。当然也不需要知道,按 ① 的原理,只需要直接判 studentCustomer
不匹配类型即可。这里 doIt()
的第一个参数是“产出”型,只要它输出的类型符合 doIt()
需要的类型(或其子类)即可,这就是协变。doIt()
的第二个参数是“输入”型,doIt()
需要提供一个符合其要求类型的对象,也就是说 doIt()
提供的对象是其要求的类型或者子类型,反过来说,它需要的输入的类型只能是 doIt()
声明可提供类型的父类型,不能是子类型,这就是逆变。
简单的理解:
协变就是我声明需要一个类型,你提供了这个类型或其子类型,就很 OK。这符合 Parent p(声明的) = s as Sub (提供的)
的常识,称为“协”。
逆变就是,我声明提供一个类型,你可以消费这种类型,就
没问题。因为传入参数(比如回调)中声明的类型必须是我声明可提供的类型的父类型,看起来像是 Sub s (声明的) = p as Parent (提供的)
,所以称为“逆”。
但其实不管是协还是逆,都可以抛开声明方和提供方,直接从数据流向来考虑。从数据流向来说,都是 子 ⇒ 父
,符合类型约束常识。
至于 TypeScript 的“双向协变”,看下图,事实证明它不允许(也许是 4.x 对这方面的类型安全性进行了增强,会与 3.x 有所不同)!
9 回答9.4k 阅读
6 回答5.1k 阅读✓ 已解决
5 回答3.7k 阅读✓ 已解决
3 回答10.5k 阅读✓ 已解决
4 回答8k 阅读✓ 已解决
7 回答10.1k 阅读
4 回答7.4k 阅读
首先“协变”是啥这个题主清楚吗?
如果你不清楚,我先简单介绍一下,想详细了解可以自己搜索一下。
这个名词虽然不是 OOP 里才有的,但一般现在我们讲它都是在 OOP 的语境下。因为 OOP 里有继承这种东西存在。
那么假设有
class Student extends Person
存在,也就是Person p = new Student()
成立(我们把这个成立条件称作条件 V,用于下面代称)。那么如何决定Student
和Person
二者的更复杂类型之间的关系,叫做“变型”。一般有以下三种情况(以数组为例):
Student[]
同时也是Person[]
,即Person[] arr = new Student[]
成立,与条件 V 的兼容性一致。Person[]
同时也是Student[]
,即Student[] arr = new Person[]
成立,与条件 V 的兼容性相反。Student[]
和Person[]
没有关系。除非你强转,否则你不能直接把一个类型的变量赋值给另一个类型的变量。简记就是:协变多变少,逆变少变多(子类型是父类型的扩展,所以理论上子类型的属性比父类型要多)。
上面是以数组为例,还有很多变体的存在,比如泛型(TS 里数组就是泛型的,但大部分 OOP 语言不是这样)
Foo<Student>
和Foo<Parent>
是否可以互转;再比如函数(e: Student) => void
和(e: Parent) => void
是否可以互转;等等等等。那什么叫“双向协变(Bivariant)”呢?其实就是协变 + 逆变都成立。
回到问题里,为什么说 TS 的函数是双向协变的。
因为 TS 是鸭子类型系统,只要两个对象结构一致,就认为是同一种类型,而不需要两者的实际类型有显式的继承关系。
因为这个特点,所以 TS 里有很多一般 OOP 语言中没有的特性,函数的双向协变就是其中之一。
图中的代码已经给了一个例子:
MouseEvent extends Event
,在函数中type f1 = (e: MouseEvent) => void
可以与type f2 = (e: Event) => void
互换。P.S. 可以通过编译选项
--strictFunctionTypes true
关闭函数的双向协变,只保留逆变。