gitbook/手把手带你写一门编程语言/docs/424592.md
2022-09-03 22:05:03 +08:00

300 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 24增强编译器前端功能第3步全面的集合运算
你好,我是宫文学。
在上一节课我们扩展了我们语言的类型体系还测试了几个简单的例子。从中我们已经能体会出一些TypeScript类型体系的特点了。
不过TypeScript的类型体系其实比我们前面测试的还要强大得多能够在多种场景下进行复杂的类型处理。
今天这节课我们会通过多个实际的例子来探索TypeScript的类型处理能力。并且在这个过程中你还会进一步印证我们上一节课的一个知识点就是**类型计算实际上就是集合运算**。在我们今天的这些例子中,你会见到多种集合运算,包括子集判断、重叠判断,以及交集、并集和补集的计算。
首先,让我们看几个例子,来理解一下类型计算的使用场景。
## 类型计算的场景
我们先看第一个例子:
```plain
function foo1(age : number|null){
let age1 : string|number;
age1 = age; //编译器在这里会检查出错误。
console.log(age1);
}
```
在这个例子中我们用到了age和age1两个变量它们都采用了联合类型。一个是number|null一个是string|number。
如果你用strict选项来编译这个程序那么tsc会报错
![图片](https://static001.geekbang.org/resource/image/cc/24/cc57e718200cf6c9416fa2c89ca47424.png?wh=1382x368)
这个错误信息的意思是类型number|null不能赋给类型string|number。具体来说null是不能赋给string|number的。
这说明什么呢这说明对于赋值语句比如x = y来说它会有一个默认要求要求y的类型要么跟x一样要么是x的子集才可以。我们把这个关系记做y.type <= x.type。
那么,其他的二元运算,是不是也像赋值运算那样,需要一个类型是另一个类型的子集呢?
不是的。不同的运算,做类型检查的规则是不同的。比如,对于“==”和“!=”这两个运算符只需要两个类型有交集就可以。你可以用tsc编译一下这个例子
```plain
function foo2(age1 : number|null, age2:string|number){
if (age1 == age2){ //OK。只要两个类型有交集就可以。
console.log("same age!");
}
}
```
你会看到编译器并不会报错。这说明两个不同的类型只要它们有交集就可以进行等值和不等值比较。并且即使age1的值是nullage2的值是一个字符串等值比较仍然是有意义的比较的结果是不相等。
那如果两个类型没有交集会发生什么情况呢我们看看下面的例子参数x和y属于不同的类型它们之间没有交集。
```plain
function foo3(x : number|null, y:string|boolean){
if (x == y){ //编译器报错:两个类型没有交集
console.log("x and y is the same");
}
}
```
这次如果你用tsc去编译即使不加strict选项编译器也会报错
![图片](https://static001.geekbang.org/resource/image/c2/34/c2d10ddc726929d1c67ee389bd251f34.png?wh=1382x336)
编译器会说这个条件表达式会永远返回false因为这两个类型没有交集。
**到此为止,我们就了解清楚等值比较的规则了,也就是要求两个类型有交集才可以,或者说两个类型要存在重叠。**
那其他的比较运算符,比如>>=<<=,也遵循相同的规则吗?
我们把foo2中的==运算符改为>=运算符,得到一个新的示例程序:
```plain
function foo4(age1 : number|null, age2:string|number){
if (age1 >= age2){ //编译器报错
console.log("bigger age!");
}
}
```
我们再把这个示例程序用tsc --strict模式编译一下编译器也会报错
![图片](https://static001.geekbang.org/resource/image/d4/8a/d4f943a7f0731b4b0ddc984c7752b68a.png?wh=1382x302)
这次报错的原因是age1有可能取值为null而null是不能做大小的比较的。这说明有些类型是不能做大小比较的。
那什么类型之间可以做大小比较呢number、string、boolean类型之间都是可以的。但object类型、undefined类型就不可以做比较了。所以如果我们把foo4示例程序中age1的类型中去掉null值之后编译器就不会报错了。
```plain
function foo5(age1 : number, age2:string|number){
if (age1 >= age2){ //OK。
console.log("bigger age!");
}
}
```
刚才我们总结了等值比较和大小比较做类型检查的规则。你还可以进一步研究一下加减乘除这几个运算的类型检查的规则,我这里也整理了一下。
首先是+号运算符。+号运算符有两个语义:一个是作为字符串的连接符使用,这时候类型检查的规则是,只要+号运算符一边的类型是string型的那么另一边可以是任意的类型因为任意的类型都可以转化成字符串+号的另一个语义是做数字的算术运算在这种情况下运算符两边只能是数字类型包括number类型和枚举类型。
其次是-号、\*号和/号。它们做类型检查的规则,跟+号的第二个语义的规则是一样的,也就是运算符的两边只能是数字类型。
再进一步,你还可以研究一下逻辑运算符,也就是&&、||和!这几个运算符。这几个运算符要求操作数是boolean值的。不过在TypeScript中任意类型都可以转化为boolean值包括string、number、object和undefined类型。这些类型中有些值等价于true而其他值等价于false。等价于false的值包括数字0、对象null、空字符串、NaN以及undefined。
所以,看来类型检查中涉及的语义规则,还真是挺丰富的。而要实现上面这些类型检查功能,关键点就是实现类型的计算。在上面的例子中,我们看到需要实现两个运算:**LE运算和overlap运算我们又把它们叫做子类型判断和重叠判断**。
## 子类型和重叠的判断
我们这里提到了一个术语子类型。什么是子类型呢如果A是B的子类型那意味着A的每个成员也都是B的成员。如果采用集合的术语这意味着A集合是B集合子集。
在学习面向对象的时候,你肯定知道子类和父类的概念,它们之间的关系就是子类型关系。我们举一个例子:我们都知道“人”是“哺乳动物”的子类,那么我们可以说任何一个“人”,都肯定是一个“哺乳动物”。“人”的集合是“哺乳动物”集合的子集。
不过,除了面向对象的子类关系外,还有其他的子类型,**只要二者之间具有子集的关系就行**。比如:
* 类型“0|1”也就是只能取0和1两个值的类型它是number的子类型也是“0|1|2”的子类型
* 类型string是“string|number”的子类型也是“string|null”的子类型。
实现子类型判断你可以参考TypeUtil类的LE方法。它里面的运算规则比较多我挑重点的和你解释一下
**首先我们要如何判断一个NamedType是另一个NamedType的子类型呢**
对于string、number和boolean这些基础类型来说它们都是并列的相互之间没有子类型的关系。不过由于后面我们会讲到面向对象特性而面向对象中的类型之间是有子类型关系的所以我这里预先做了准备。
你看看NamedType类的设计会发现它有一个upperTypes属性这里就存了该类型的多个父类型。所以基于这个upperTypes属性父类和子类就被关联到了一起我们的编译器也就能够基于此来判断一个类型是否是另一个类型的子类型。
而且在PlayScript中我提供了Number的两个子类型分别是Integer和Decimal可以用来验证这个特性。在类型消解的时候编译器会把整型字面量的类型标注为Integer的而把浮点型字面量的类型标注为Decimal的。在赋值的时候Integer和Decimal类型的值都可以赋给number类型的变量。
**第二如何判断一个值类型是否是某个NamedType的子类型呢**
这个问题比较简单我们对每个值类型都记录了它所属的NamedType基础类型。比如整数值类型是Integer所以整数的值类型一定是Integer的子类型。而Integer又是number的子类型那么整数值类型也是number的子类型。
**第三如何判断一个NamedType或ValueType是UnionType的子类型呢**
你会看到UnionType中有一个types数组代表了多个类型的联合。如果一个NamedType或ValueType是UnionType中任何一个元素的子类型那么它就是该UnionType的子类型。
**最后我们如何判断一个UnionType是另一个UnionType的子类型呢**比如“0|1”是否是“0| 1|2”的子类型或者是否是“number|string”的子类型呢
这就要求第一个UnionType类型的每个成员都得是第二个UnionType的子类型才可以。
好了上面就是我们做子类型的检查的思路了。那第二个运算检查类型之间是否有交集或者说是否重叠也可以借鉴类似的思路算法上稍有区别。你参考下TypeUtil类的overlap方法就行。
在实现了子类型和overlap的检测以后我们就能完成前面那些场景中的类型检查了。
不过TypeScript做类型计算的能力不止于此它还有些更强大的能力这会用到其他的集合运算包括交集、并集和补集的计算。
让我们通过这些新的场景来探索一下。
## 交集、并集和补集的计算场景
我们还是回到这节课的第一个例子程序来分析分析。在这个例子中编译器不允许把age赋值给age1因为age可能取值为null不能赋值给“string|number”。
现在我们把这个例子改一下加一句“age = 18;”然后再给age1赋值看看会发生什么变化
```plain
function foo6(age : number|null){
let age1 : string|number;
age = 18; //age的值域现在变成了一个值类型18
age1 = age; //这里编译器不再报错。
console.log(age1);
}
```
你看看这个例子程序现在age的值是18。那么这时再把age的值赋给age2编译器还会不会报错呢
你肯定不希望编译器报错因为现在age的值是一个number不是null所以肯定是可以赋给age1的呀。
确实如你所愿tsc编译器这次没有再报错了。这个编译器真的是挺聪明的。
可是这个编译器是如何做到这一点的呢不是说age必须是age1的子类型吗
**原来TypeScript的编译器结合数据流分析技术随着程序的执行可以动态地改变变量的值域也就是取值范围。**比如在“age = 18”这句之后age的值域就变成了一个值类型18。如果用我们这个新的类型来做类型检查自然就不会出错。
那如果我们再给age赋一个新值让它等于null会怎样呢你可以参考下面的例子程序。
```plain
function foo7(age : number|null){
let age1 : string|number;
age = 18; //age的值域现在变成了一个值类型18
age1 = age; //OK。
age = null; //age的值域现在变成了null
age1 = age; //错误!
console.log(age1);
}
```
这个时候age的值域变成了null。如果我们这个时候把age赋给age1那么编译器就会报错。
除了赋值语句、变量初始化语句能够改变变量的值域以外if语句中的条件也会影响到变量的值域你再看看下面的例子。
```plain
function foo8(age : number|null){
let age1 : string|number;
if (age != null){ //age的值域现在是number
age1 = age; //OK!
console.log(age1);
}
else{ //age==null, 值域现在变成了null
console.log("age is empty!");
}
}
```
在这个例子中if条件是“age!=null”。这个条件跟age原来的类型“number|null”相结合就会求出if块中的age类型变成了“number”把null这个选项去掉了。
这个过程是怎么实现的呢?
在这里你可以把求age值域的过程看做是做集合运算的过程。在if条件中的表达式会生成一个age的值域这个值域是所有不等于null的值我记做!null。这个值域跟原来age的值域“number|null”做交集运算最后的结果就是number。我画了一张示意图表示交集运算的过程你可以看一下
![图片](https://static001.geekbang.org/resource/image/de/01/de6f8581e7db8b981f8f19ffcfbce701.png?wh=944x666)
在这个示意图里长方形的区域表示全集。全集里面有一个蓝色的圈表示number集合。还有一个小点表示null这个值这两个部分都是蓝色的代表了“number|null”。
而打斜线的区域是在全集中抠去null那个点后剩余的部分是{null}的补集,我记做!null。这两个集合的交集就是代表number的那个圆圈。所以这个圆圈既带有蓝色又打了斜线。
在这里,你会发现,我们的算法又需要支持两个集合运算。第一个运算,是**求交集运算**。在这里,我们可以先来简单总结一下交集运算的规则:
**规则一:**对于两个NamedType如果一个是另一个的子类型那么交集就是子类型。在下图中number和integer的交集是integer。
![图片](https://static001.geekbang.org/resource/image/18/40/18236214b10511881e77ca8a6640ee40.png?wh=944x666)
**规则二:**如果两个类型之间没有子类型关系那么它们的交集就是空集。就像下图中的number和string。
![图片](https://static001.geekbang.org/resource/image/1f/6d/1fa42c696233e4efcdd95018a5f4a46d.png?wh=944x666)
在算法中用什么来表示空集呢在PlayScript的代码中我用了一个特殊的NamedType叫做Never它对应了TypeScript中内置的类型never。关于never类型的介绍你可以参考[TypeScript手册中的内容](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#the-never-type)。
说完了NamedType之间求交集你还可以进一步思考一下如何在NamedType、ValueType和UnionType之间互相求交集。总体上遵循集合运算的规则就行了。
除了交集运算,还需要**求补集**。在前面的例子中我们用到了null的补集。那如何表达null的补集呢我用的方法是在ValueType对象里加了一个isComplement属性。如果这个属性为true就代表这个值对象其实是该值的补集。
除了值对象有补集其实NamedType也可以有补集。比如你在if条件中可以放“typeof age != string”这样的语句这时候age的值域就是!string也就是string的补集。
在上面的示例程序中我们还有一个地方用到了补集这就是计算else块中的值域的时候。在else块中要对if条件生成的值域取补集也就是把!null再做一次补集运算得到的结果就是null。也就是说在else块中age的取值肯定是null。
现在我们就说完了求交集和补集这两个集合运算。不过,你可能马上又会想到,**在集合运算里还有求并集的运算呀,那在我们类型计算里是不是也有这个场景呢?**
有的。如果我们把if条件复杂化一点用上逻辑运算“||”那么这个if条件形成的值域就是18|81。这里就做了一次求并集的运算把18和81两个值类型并在了一起。我们让这个并集再跟“number|null”做交集运算结果仍然是18|81。
```plain
function foo9(age : number|null){
if (age == 18 || age == 81){ //age的值域是 18|81
console.log("18 or 81");
}
else{ //age的值域是 (number | null) & !18 & !81
console.log("age is empty!")
}
}
```
我们把这个例子再往下深化分析一下。如果if块中age的值域是18|81那么else块中是什么呢那就是number|null中去掉18和81就可以了。
具体计算过程,是先对 18|81求补集也就是!( 18|81) = !18 & !81。其中&是交集的意思。然后再跟(number|null)求交集,得到的结果是(number|null) & !18 & !81。
到这里我们又必须引入另一个表示类型的对象叫做IntersectionType用于表示多个类型之间的交集。交集类型中多个成员之间使用&连接的。
并且运用集合运算的知识你还能意识到交集对象和联合对象之间是有转换关系的。UnionType的补集就是一个IntersectionType而IntersectionType的补集呢则是UnionType。
那到目前为止我们用于表示类型的对象体系就更加完善了。我们增加了一个新的类型是IntersectionType。并且我们还把ValueType和NamedType都加上了是否是补集的属性。
好了,求集合的补集、交集和并集等运算我们都讲过了。但对于刚才这个例子。我们还需要用数据流分析方法动态地求变量的值域。不过,今天的新知识点已经足够多了,我们还是把这个任务放到下一节课。在下一节课,我们会综合运用多种语义分析技术,来获得更强大的效果。
## 课程小结
在今天这节课,我们举了多个例子,示范了多个类型计算的场景。在这个过程中,我们接触到了个多种集合运算,包括子集判断、重叠判断、求补集、求交集和求并集。
第一子集判断的典型场景是赋值运算。比如x=y语句要求y的类型和x的类型相等或者y的类型是x的类型的子集。如果y的类型是x的类型的子集我们可以简单地说y是x的子类型。面向对象编程中的继承关系就是子类型的一种体现。
第二,重叠判断的典型场景是==和!=运算符。它们要求两边的类型有重叠的部分。如果没有重叠的部分,编译器就会报错。
第三并集的使用场景是if条件中带有逻辑运算||的情况,在下一节你还会看到另一个使用场景。
第四交集的使用场景一个是if条件中有逻辑运算&&的情况另一个是If条件中得到的值域与变量原来的值域求交集得到if块中变量的值域。
第五,补集有多个使用场景,一个是针对!=运算符第二个是if条件中使用!运算符还有一个是求else块中的变量值域时。如果对UnionType求补集我们会得到一个IntersectionType。
这节课的例子你都可以通过node play example\_type2.ts来运行。关于类型计算的实现可以参考TypeChecker和TypeUtil两个类。
我们这节课的内容全面地运用了集合计算这也是TypeScript具备强大的类型处理能力的原因。很多现代语言的类型处理能力现在也变得越来越强这背后的数学知识都是集合运算。所以你一定要重视这个知识点。你要学会像这节课这样分析在各种场景下编译器到底是如何使用集合运算来做类型处理的。
## 思考题
在这节课,我们涉猎了很多集合运算的知识点,让整个类型处理变成了一个自洽的体系。今天的思考题,我们继续把这种类型计算的方式跟其他语言做一下对比。你在其他语言做见过对类型做交集、并集、补集、判断子集和判断重叠的运算吗?欢迎在留言区分享你的发现。
欢迎你把这节课分享给更多对类型计算感兴趣的朋友。我是宫文学,我们下节课见。
## 资源链接
1.这节课的示例代码目录在[这里](https://gitee.com/richard-gong/craft-a-language/tree/master/24)
2.主要看语义分析的代码([semantic.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/24/semantic.ts#L505))和类型体系的代码([types.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/24/types.ts))。
3.例子代码:[example\_type2.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/24/example_type2.ts)