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

274 lines
19 KiB
Markdown
Raw 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.

# 23增强编译器前端功能第2步增强类型体系
你好,我是宫文学。
你可能也注意到了我们在第二部分的主要任务是要让PlayScript扩展到支持更多的类型。在这个任务中对类型的处理能力就是一个很重要的功能。
其实在第一部分,我们已经实现了一定的类型处理功能,包括类型检查、类型自动推断等,但其实还有更多的类型处理能力需要支持。
对于一门语言来说类型系统是它的核心。语言之间的差别很多时候都体现在类型系统的设计上程序员们通常也会对类型处理的内部机制很感兴趣。而TypeScript比JavaScript语言增强的部分恰恰就是一个强大而又灵活的类型系统所以我们就更有必要讨论一下与类型有关的话题了。
那么通过今天这节课我们就来增强一下PlayScript的类型处理能力在这过程中我们也能学习到更多与类型系统有关的知识点特别是能对类型计算的数学实质有所认知。
首先我们来看看TypeScript的类型系统有什么特点。
## TypeScript的类型系统
从TypeScript的名字上你就可以看出来这门语言在类型系统的设计上一定是下了功夫的。也确实是这样TypeScript在设计之初就想弥补JavaScript弱类型、动态类型所带来的缺点。特别是当程序规模变大的时候弱类型、动态类型很容易不经意地引入一些错误而且还比较难以发现。
所以TypeScript的设计者希望通过提供一个强类型体系让编译器能够检查出程序中潜在的错误这也有助于IDE工具提供更友好的特性比如准确提示类的属性和方法从而帮助程序员编写更高质量的程序。
而TypeScript也确实实现了这个设计目标。它的类型系统功能很强大表达能力很强既有利于提高程序的正确性同时又没有削弱程序员自由表达各种设计思想的能力。
那么我们现在就来看一看TypeScript的类型系统到底有什么特点。
**首先TypeScript继承了JavaScript的几个预定义的类型比如number、string和boolean等。**
在JavaScript中我们不需要声明类型比如下面两句代码就是。在程序运行的时候系统会自动给age和name1分别关联一个number和string类型的值。
```plain
var age = 18;
var name1 = "richard";
```
而在TypeScript中呢你需要用let关键字来声明变量。在下面的示例程序中age和number被我们用let关键字分别赋予了number和string类型。
```plain
let age = 18;
let name1 = "richard";
```
这两行代码里的类型是被推导出来的,它们跟显式声明类型的方式是等价的。
```plain
let age:number = 18;
let name1:string = "richard";
```
**第二TypeScript禁止了变量类型的动态修改。**
在JavaScript中我们可以动态地修改变量的类型。比如在下面两行代码中age一开头是number型的后来被改成了string型也是允许的
```plain
var age = 18;
age = "eighteen";
```
但在TypeScript中如果你一开头给age赋一个number的值后面再赋一个string类型的值编译器就会报错
```plain
let age = 18;
age = "eighteen"; //错误!
```
这是因为上面的第一行代码等价于显式声明age为number类型因为TypeScript会根据变量初始化的部分来推断出age的类型。而这个类型一旦确定后面就不允许再修改了。
```plain
let age:number = 18;
age = "eighteen";
```
不过如果完全不允许类型动态变化可能会失去JavaScript灵活性这个优点会让某些程序员觉得用起来不舒服。所以TypeScript还留了一个口子就是any类型。
**第三只有any类型允许动态修改变量的类型。**
在TypeScript中如果你声明变量的时候不指定任何类型或者显式地指定变量类型为any那变量的类型都是any程序也就可以动态地修改变量的类型我们可以看看下面这个例子
```plain
let age; //等价于 let age:any;
age = 18;
console.log(typeof age);
age = "eighteen";
console.log(typeof age);
```
如果我们编译并运行这个示例程序,我们会得到这样的结果:
![图片](https://static001.geekbang.org/resource/image/54/7c/54735aefecfe35af1b01ae9eca70407c.png?wh=650x148)
你会看到在我们第二次给age赋值的时候age的类型真的被改变了。
**第四TypeScript支持联合类型。**
你在使用TypeScript编程的时候应该会很快注意到它的联合类型的特性。比如在下面这个例子中value的类型可以是number或string那你给value赋这两种类型的值都是可以的。当然如果你给value赋一个boolean值那仍然是错误的因为联合类型中不包含boolean类型。
```plain
let value:number|string;
value = 18; //OK
value = "richard"; //也OK
value = true; //错误!
```
**第五TypeScript支持把值作为类型。**
什么意思呢我们依然来看一个例子。你看在下面的语句中我们可以让name1取值为string或者null。这在编程中很方便特别是在声明对象属性的时候。因为我们可能一开始不知道名称是什么我们就可以先让它的值为null。之后等知道了名称以后我们再给name1赋予正式的值就好了。
```plain
var name1;
name1 = null;
console.log(typeof name1); //输出object
name1 = "richard";
console.log(typeof name1); //输出string
```
不过这个null并不是类型而是一个值它实际的类型是对象object你可以看看示例程序运行的结果来验证一下我这里也放了张截图
![图片](https://static001.geekbang.org/resource/image/0f/b3/0f537c2a5c252c5f897b46250c4fe9b3.png?wh=650x148)
除了在联合类型里使用值你甚至还可以用一个单独的值作为类型注解。比如在下面的示例程序中value2只能取0值。这可能在实际编程中没有太大的用因为value2相当于是一个常量而不是变量。我们更可能像value1那样规定合法的取值是多个值中的一个。
```plain
let value1 : 0|1;
let value2 : 0;
value1 = 0; //OK
value1 = 2; //错误!
value2 = 2; //错误!
```
其实TypeScript的类型系统还有更多丰富的特性比如数组类型、交集类型Intersection Type、通过class和interface实现自定义的类型、泛型等等非常强大。不过我们还是按照循序渐进的原则先从比较简单的特性入手然后逐步深化。
所以,我们就先聚焦在前面提到的几点特性上。特别是**联合类型**和**值类型**这都是我们之前没有实现过的我们就先来实现一下。在这个过程中我们就能先小小体会一下TypeScript类型系统到底有多强大。
那么现在,我们首先针对联合类型、值类型,来升级我们对类型的支持能力。
## 支持联合类型和值类型
在目前的语法解析器中我们对类型的解析很简单。比如像“let a : number;”这样一个简单的语句我们只要把number作为一个关键字提取出来然后再转换成一个代表number类型的内部对象就可以了。所以我们语法解析器中与变量声明有关的语法规则是很简单的我总结了下写在了下面
```plain
variableDecl : Identifier typeAnnotation ('=' expression)? ;
typeAnnotation : ':' typeName;
typename : 'number' | 'boolean' | 'string' | 'any' | 'void' ;
```
不过,我们在课程里已经很久没有写过语法规则了,不知道你还能不能重拾对它们的记忆呢?我帮你简单解释一下这三条规则。
* 变量声明(variableDecl):在变量声明里,标识符后面可以跟一个可选的类型注解;
* 类型注解(typeAnnotation):类型注解以“:”号开头,后面跟一个类型名称。
* 类型名称(typeName)类型名称可以是number、boolean、string、any或void这几个值之一这也是目前PlayScript所能识别的少量类型。
但是我们现在对我们的要求可不一样了。我们现在需要PlayScript支持联合类型和值类型并且后面还要支持更复杂的类型体系那我们现在就必须扩展一下我们语言中针对类型的语法规则了。
新的语法规则我放在了下面,你可以阅读一下,看看能不能读懂它们的含义:
```plain
typeAnnotation : ':' type_;
type_ : unionOrIntersectionOrPrimaryType ;
unionOrIntersectionOrPrimaryType : primaryType ('|' | '&' primaryType)* ;
primaryType : predefinedType | literal | typeReference | '(' type_ ')' | primaryType '[' ']' ;
predefinedType : 'number' | 'string' | 'boolean' | 'any' | 'void' ;
```
你可以看到,这个新的规则有一些不同。
首先,我们的类型注解(typeAnnotation)有了变化,现在改成了“:”号后面跟着类型type\_;
然后我们在看类型type\_类型可以有多种但目前我们的语法规则里只有unionOrIntersectionOrPrimaryType这一类以后还可以拓展。
第三个unionOrIntersectionOrPrimaryType它的字面意思是联合类型、交集类型或基础类型。从规则中你可以看到联合类型是由一个个基础类型用“|”号连接在一起的。交集类型与联合类型相似,差别在于它使用“&”号来连接基础类型的。当然,交集类型目前我们还用不到,但我们先在语法规则中预留下它的位置。
第四个不同点在于基础类型primaryType基础类型包括预定义的类型、字面量、类型引用、被括号括起来的类型以及数组类型。目前我们只会用到前两个预定义的类型就是之前的number、string这些。而字面量包括数字字面量、字符串字面量、布尔字面量等它们在解析后会形成我们前面提到的值类型。
**好了,现在我们已经把与类型有关的语法规则写好了。接下来,就要升级一下解析器,让它能够支持这些新的语法规则。**
根据我们之前学习过的语法分析的知识其实你只要写出来了语法规则照着规则来实现语法分析程序并不难。你可以看一下parseType、parseUnionOrIntersectionOrPrimaryType、parsePrimTypeExp这几个方法。
不过呢语法解析的结果是要形成AST所以我们这里还必须**增加一些AST节点**,来代表解析出来的这些类型信息。
与类型有关的AST节点我也画成了类图并放在了下面。这里面包括TypeExp、PrimTypeExp、PredefinedTypeExp、LiteralTypeExp和UnionOrIntersectionTypeExp等节点并且它们之间还有继承和引用的关系。
![](https://static001.geekbang.org/resource/image/52/54/52e99c1df620e450d6705dc9046d4654.jpg?wh=1920x1080)
不过课程上到这里在谈到AST节点设计的时候我觉得有必要插一个小知识点也就是关于AST和CST的区别。
这里出现了一个新的缩略词CST。CST是Concrete Syntax Tree的缩写意思是具体语法树。从这个名称你就能看出来它跟AST也就是抽象语法树Abstract Sytax Tree是相对的。那么它们有什么差别呢
你可能已经注意到了我在课程里所设计的AST节点并不是跟语法规则的名称完全一样的所形成的语法树也跟解析的过程不完全一致。以解析表达式2+(3+4)为例,如果忠实地按照解析的过程来形成语法树,跟现在的语法树会有很大的不同:
![图片](https://static001.geekbang.org/resource/image/e5/34/e5fe34f8bc337b7da2353528a6e38634.png?wh=904x1274)
你能看出来相比ASTCST更加忠实地体现了源代码的结构。比如它没有丢掉源代码中的任何一个Token包括+号和圆括号这样可以把AST和源代码精准地对应起来这也能更友好地显示一些错误信息。比如你可以在IDE里标出某个括号用错了。CST作为对解析过程和源代码的忠实体现它也被称作解析树Parse Tree
而在AST里更多体现的源代码的内在含义而不是去纠结有没有跟源代码一一对应上。如果我们把它们两种翻译比作英语翻译的话AST是意译而CST是直译。不过AST的特点也很明显。一方面它更简洁另一方面AST节点的对象设计更容易体现程序的内在含义。
在实际使用中呢有的编译器会先生成CST再基于CST生成AST。但这样显然会增加编译过程的计算量降低编译器的性能。所以大部分编译器都是直接生成AST。不过如果你写的编译器主要是用于支持IDE工具那么CST可能会发挥更大的作用因为它跟源代码的直接对应能力更强。
回到我们的主线上来。现在你应该明白了为什么我们关于类型的AST节点不是跟语法规则直接对应的了。这样的设计会更有利于对类型进行进一步的处理。
不过到这还没有完。我们刚才设计的只是有关类型的AST节点的对象结构。但是我们在程序内部保存类型信息的时候是不可能去保存一些AST节点的还需要**一套对象结构**来表示类型。我也画出了相应的类图,你可以看一下。
![](https://static001.geekbang.org/resource/image/02/b0/023053f35e4cc04233611a202af69bb0.jpg?wh=1920x1080)
在这里我们把Type作为各种类型的基类这下面有NamedType、ValueType、FunctionType、UnionType等等类型。另外我还设计了一个SysTypes类里面存放了预定义的一些类型的实例比如Boolean、Number、String、Any和Void都是NamedType的实例而Null、Undefined是值类型的实例。
设计完表达类型的对象结构以后,我们还要**基于AST解析出类型信息**来。针对这个功能我写了一个语义分析程序叫做TypeResolver。它能够把与类型有关的AST节点转化成类型对象。这个TypeResolver程序我们还会不断地演化让它能够处理更复杂的类型。特别是在我们后面实现了自定义类型的时候类型的消解算法还会变得比现在复杂一些。
好了,现在类型信息也能够被正确地消解了。换句话说,现在我们的程序,已经能够正确解析带有联合类型和值类型的程序了。
我们现在就动手试试看,我这里给了一个示例程序:
```plain
let age : string|number;
let name1 : string|null;
```
你可以用node play example.ts -v命令显示出解析后的AST。你会看到AST里面已经体现了与类型有关的AST节点信息以及类型消解后的结果。
![图片](https://static001.geekbang.org/resource/image/9a/d1/9aa7cb1e6335927733a009cc21159bd1.png?wh=782x516)
但是还差一点,如果我们要给示例程序中的变量赋值,那么我们还必须升级类型检查的算法,来支持新的联合类型和值类型。
## 升级类型检查功能
我们都知道,在给变量赋值的时候,我们必须要进行类型的检查。我们之前也介绍过类型检查功能。不过,这节课我想带你从不同的视角来认识类型检查,这会涉及我们之前提到过的类型计算技术。
在这节课当我们介绍TypeScript的类型体系的时候你可能或多或少会有一种感觉**怎么TypeScript的类型有点像集合呀**没错!你看,联合类型就是多个类型的合集。我们还隐约提到了交集类型,而交集显然也是对集合的一种运算。
是的集合这种数学方法可以非常好地用于表达类型的计算。你可以这么想什么是类型呢类型就是一组值的集合。比如number类型就是所有数字的集合string类型就是所有字符串的集合。当然我们也可以做个更大的集合同时包含number和string这就形成了一个新类型你也可以做一个更小的集合比如只包含几个数字这也是一个新类型。
那么从集合运算的角度看,什么是类型检查呢?
类型检查的规则就是给变量所赋的值一定要属于变量的类型所对应的集合。对于“let a : number = 1; ”这个语句来说显然1属于number的集合所以是合法的。而对于“let a : 0 | 1 = 1”来说1也属于集合{0,1},因此也是合法的。
所以,我们升级的类型检查算法,就用到了集合运算。了解了原理以后,你就可以再去看看[TypeChecker](https://gitee.com/richard-gong/craft-a-language/blob/master/23/semantic.ts#L521)和[Type](https://gitee.com/richard-gong/craft-a-language/blob/master/23/types.ts#L7)类中的实现,应该就比较容易看懂了。
不过类型检查只是类型计算技术的一个体现。在下一节课里我们还会结合前一节课的数据流分析技术和这节课的类型计算技术实现更多有趣的特性比如实现null安全性你也可以先简单预习一下相关的知识。
## 课程小结
这节课到这里就讲完了,今天我希望你能够记住下面这几个知识点:
首先我们学习了TypeScript类型体系的部分特征。TypeScript能够通过显式的方式来声明类型。除了any类型之外TypeScript变量的类型都是不能动态改变的。在基础类型之上TypeScript还支持值类型和联合类型。
第二在升级TypeScript与类型相关的语法规则的时候我们设计了一些新的代表AST节点的类。在这里我们穿插介绍了CST的概念。与AST相比CST更加忠实地反映源代码的结构、语法规则和解析过程能够对IDE工具有更好的支持。但它也有缺点就是树的结构更大在后序处理时会更加繁琐。
第三在升级类型检查功能的时候我们介绍了类型计算所采用的数学方法也就是集合计算。在程序里把变量a的值赋给变量b就是要求类型a的集合是类型b的集合的子集。你可以试着以集合的思维去重新解读你以前了解的与类型有关的知识比如类的继承关系相信会给你带来崭新的视角。
## 思考题
在你熟悉的其他语言中有类似TypeScript的联合类型和值类型的特性吗或者它们跟TypeScript的特性有什么差别呢欢迎你在留言区分享观点。
欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。
## 资源链接
1.[这节课示例代码目录](https://gitee.com/richard-gong/craft-a-language/tree/master/23)
2.与类型有关的[AST节点](https://gitee.com/richard-gong/craft-a-language/blob/master/23/ast.ts#L402)
3.[类型对象](https://gitee.com/richard-gong/craft-a-language/blob/master/23/types.ts)
4.这节课的测试程序[example\_type.ts](https://gitee.com/richard-gong/craft-a-language/blob/master/23/example_type.ts)