# 子类型(subtype)
面向对象编程中,我们可以简单地认为对象就是键值对。
比如直角坐标系中的点(Point)对象:
{ x: 1, y: 2 }
其对应的类型是:
type Point = { x: number, y: number }
假设现在有这样一个对象:
{ x: 1, y: 2, color: 'red' }
其对应的类型是:
type ColoredPoint = { x: number, y: number, color: string }
可以看到,ColoredPoint
包含了Point
的全部信息,此外还多了一个color信息,信息越多越具体(特化),所以我们就说ColoredPoint
是Point
的子类型(subtype)。
这个其实是比较容易理解的,比如:
植物 -> 水果 -> 苹果 -> 黄元帅苹果
越往右,信息越多越具体,右边都是左边的子类型。
因而在面向对象编程中,子类型的对象可以赋值给父类型的对象,即:
const apple:Apple = new Apple();
const fruit:Fruit = apple;
这个过程无需强制类型转换,因为上述子类型的定义符合里氏替换原则 (opens new window),这样写一定不会出错。
里氏替换原则通俗版:子类对象可以在程序中替换父类对象
关于里氏替换原则,也可以这样理解:父类型是大人,子类型是小孩。现在一群人满满当当挤在电梯里,你可以把一个大人替换成一个小孩,但是反之不行。也就是,更小的类型可以替换更大的类型。
# 协变(Covariance)、逆变(Contravariance)、不可变(Invariance)
函数是否也有子类型呢?因为函数有参数和返回值,那我们先用控制变量法,只观察参数类型或返回值类型不同的函数。
首先来看参数类型相同而返回值类型不同的函数:
function drawPoint(x: number, y: number): Point {
return { x, y }
}
function drawRedPoint(x: number, y: number): RedPoint {
return { x, y, color: 'red' }
}
想象一下,如果我们把代码中所有的drawPoint
都替换成drawRedPoint
,这应该是没有问题的(返回的对象多了一个color字段,没有什么影响),而反之是有问题的。那么根据里氏替换原则,drawRedPoint
是drawPoint
的子类型。
抽象一下总结出来就是,对于参数类型相同的两个函数,返回值类型越小,函数类型越小。emmmm其实类型可没有大小之分,这里是借用之前电梯的那个例子,总之理解我的意思就好。
再来看看返回值类型相同而参数类型不同的函数:
function logPoint(point: Point): string {
return JSON.stringify(point)
}
function logRedPoint(redPoint: RedPoint): string {
return JSON.stringify(redPoint)
}
同样地,如果把代码中所有的logRedPoint
替换成logPoint
,这应该是不会报错的(原来的参数一定有color字段,现在无所谓了),而反之是有问题的。那么根据里氏替换原则,logPoint
是logRedPoint
的子类型。
抽象一下总结就是,对于返回值类型相同的参数,参数类型越大,函数类型越小。同理理解我意思就好。
最后,如果函数的参数和返回值类型都不相同,那么我们可以定义:参数类型更大且返回值类型更小的函数类型更小,参数类型更小且返回值类型更大的函数类型更大,其他情况无法比较大小(也就是没有不算子类型)。
用数学符号表示即:
if s' <= s and t <= t' then s->t <= s'->t'
这里还有一个与之对偶的有趣结论,如果我们把函数固定下来,尝试改变参数和返回值的赋值类型。比如下面这个函数,正常情况下调用该函数,参数是Point
类型,而返回值的赋值对象是RedPoint
类型:
function foo(point: Point): RedPoint
实际上,如果我们在调用该函数的时候,把参数换成RedPoint
类型,或者把返回值的赋值对象换成Red
类型,这都是没有问题的。即:
- 有些地方,我们可以使用比原本设定的类型更小的类型(比如函数的参数)
- 有些地方,我们可以使用比原本设定的类型更大的类型(比如函数的返回值的赋值变量类型)。
第一种情况就叫做协变(Covariance),这是最符合直觉的一种场景,第二种情况叫逆变(Contravariance),这个乍一看不太符合直觉。当然除了协变和逆变之外,如果既不能换成大的也不能换成小的,那就叫不可变(Invariance),这种场景也是存在的。
简单的记忆结论就是:参数可协变,返回值可逆变
# 限定泛型(Bounded Polymorphism)
前文啰嗦了一大堆,总算讲到了正文了,我们先看一看什么叫限定泛型。当然,这里假设你已经知道什么叫泛型了(Polymorphism/Quantification/Generic等)。
限定泛型,的意思就是,限定了的泛型。
比如下面的函数,判断一个东西美不美:
function isBeautiful<T>(something: T): boolean
本来泛型T可以是任意类型,比如可以是植物、动物、建筑等,没有任何限制。现在我们像限制一下T只能是动物的子类型,即:
function isBeautiful<T extends Animal>(something: T): boolean
这就叫做限定泛型(Bounded Polymorphism),可以看到,其实就是对参数增加了一个协变。
# F-bounded Polymorphism
抱歉不知道中文该怎么翻译,有人叫“有界限定泛型”,我觉得不是很贴切。至于为什么叫F-bounded Polymorphism,可以看最初的论文 (opens new window),当时的作者也不知道该起什么名字,索性就拿举例子的函数字母F加个前缀吧,于是就叫做F-bounded Polymorphism。
为什么有这么个玩意呢?
回到之前泛型的例子,假如现在的函数是从两个东西里挑出最美的那一个:
function pickMostBeautiful<T>(a: T, b: T): T
还是一样,现在想限定一下是从动物中挑选,那么我们会下意识地这样写:
function pickMostBeautiful<T extends Animal>(a: T, b: T): T
因为我们期望达到这样的效果:
const mostBeautiful: Cat = pickMostBeautiful<Cat>(a, b)
上面的代码,我们会注意到Cat
类型作为了函数返回值赋值的类型,相比于最初的泛型函数,相当于是类型缩小了,也就是发生了协变,而我们知道,函数的返回值赋值变量只能逆变,这这这这这,不是矛盾了吗?!
当当当当,F-bounded Polymorphism登场了:
const mostBeautiful: F[Cat] = pickMostBeautiful<Cat>(a, b)
这里,把原来的Cat
换了个记号F[Cat]
,其中的F是一个函数,将一个类型转换成另一个类型。
F的定义是:F[t] = σ
,且t
是σ
的子类型。即:σ是来源于t(用F作用于t得到的),而t呢又是来源于σ(子类型的定义),于是σ和t就产生了递归了,这就叫做F-bounded Polymorphism。可见F-bounded Polymorphism其实也是一种Bounded Polymorphism,只是带有递归的Bounded Polymorphism。
回到上面的代码,因为Cat
是F[Cat]
的子类型,所以是逆变,圆上了。
就好比先有鸡还是先有蛋,这个问题争论起来没有答案,但是在实际情况中不管是先有鸡还是先有蛋,世界都能正常运转,于其纠结于此(compile time),不如引入一个递归的概念把理论说通了,反正现实中(runtime)都很明确了,不打紧。
另一个比较实际的例子是class的this的类型,这里就不啰嗦了,可以看TS的官方文档:点击这里 (opens new window)
如果到这里你是一头雾水,那就对了,多琢磨琢磨,找找感觉吧。
# 参考资料
- F-Bounded Polymorphism for Object-Oriented Programming (opens new window)
- Very Rough Notes on F-Bounded Polymorphism (opens new window)
- Covariance and contravariance (opens new window)
- 泛型中的协变和逆变 (opens new window)
- Liskov substitution principle (opens new window)
- F-Bounded Polymorphism in Java (opens new window)
- WTF is F-Bounded Polymorphism (opens new window)
- Bounded quantification (opens new window)