你好,我是宫文学。
在上一节课,我们比较全面地分析了怎么用集合运算的算法思路实现类型计算。不过,在实际的语义分析过程中,我们往往需要综合运用多种技术。
不知道你还记不记得,我们上一节课举了一个例子,里面涉及了数据流分析和类型计算技术。不过这还不够,今天这节课,我们还要多举几个例子,来看看如何综合运用各种技术来达到语义分析的目的。在这个过程中,你还会加深对类型计算的理解、了解常量折叠和常量传播技术,以及实现更精准的类型推导。
好,我们首先接着上一节课的思路,看一看怎么把数据流分析与类型计算结合起来。
我们再用一下上节课的示例程序foo7。在这个程序中,age的类型是number|null,age1的类型是string|number。我们先让age=18,这时候把age赋给age1是合法的。之后又给age赋值为null,然后再把age赋给age1,这时编译器就会报错。
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的值域是动态变化的。在这里,我用了“值域”这个词。它其实跟类型是同一个意思。我这里用值域这个词,是强调动态变化的特征。毕竟,如果说到类型,你通常会觉得变量的类型是不变的。如果你愿意,也可以直接把它叫做类型。
你马上就会想到,数据流分析技术很擅长处理这种情况。具体来说,就是在扫描程序代码的过程中,某个值会不断地变化。
提到数据流分析,那自然我们就要先来识别它的5大关键要素了。我们来分析一下。
首先是分析方向。这个场景中,分析方向显然是自上而下的。
第二,是数据流分析针对的变量。在这个场景中,我们需要分析的是变量的值域。所以,我用了一个varRanges变量,来保存每个变量的值域。varRanges是一个map,每个变量在里面有一个key。
varRanges:Map<VarSymbol, Type> = new Map();
第三,我们要确定varRanges的初始值。在这个例子中,每个变量的值域的初始值就是它原来的类型。比如age一开始的值域就是number|null。
第四,我们要确定转换函数,也就是在什么情况下,变量的值域会发生变化。在当前的例子中,我们只需要搞清楚变量赋值的情况就可以了。如果我们要在变量声明中进行初始化,那也可以看做是变量赋值。
在变量赋值时,如果=号右边的值是一个常量,那么变量的值域都会变成一个值对象,这种情况我们已经在前一节课分析过了。
那如果=号右边的值不是常量,而是另一个变量呢?比如下面一个例子foo10,x的类型是number|string,y的类型是string。然后把y赋给x。我相信你也看出来,现在x的值域就应该跟y的一样了,都是string。
function foo10(x : number|string, y : string){
x = y; //x的值域变成了string
if (typeof x == 'string'){ //其实这个条件一定为true
println("x is string");
}
}
研究一下这个例子,你会发现通过赋值操作,我们把x的值域收窄了。在TypeScript的文档中,这被叫做"Narrowing"。翻译成汉语的话,我们姑且称之为“窄化”吧。
不过,除了赋值语句,还有其他情况可以让变量的值域窄化,包括使用typeof运算符、真值判断、等值判断、instanceof运算符,以及使用类型断言等等。其中最后两种方法,涉及到对象,我们目前还没有支持对象特性,所以先不讨论了。我们就讨论一下typeof运算符、真值判断和等值判断这三种情况。
首先讨论一下typeof运算符。其实在前面的例子foo10中,我们就使用了typeof运算符。typeof是一个类型运算符,它能返回代表变量类型的字符串。不过它的结果只有少量几个值,包括number、string、boolean、object、undefined、symbol和bigint。
我们再举一个例子foo11,看看typeof是如何影响变量的值域的。
function foo11(x : number|string){
let y: string;
if (typeof x === 'string'){ //x的值域变为string
y = x; //OK。
}
}
你可以看到,在示例程序foo11中,x原来的类型是number|string。但在if条件中,我们用typeof进行了类型的检测,只有当x的类型是string的时候,才会进入if块。所以,在if块中,我们用x给string类型的变量y赋值是没有错的。
在使用typeof的表达式中,你可以用四个运算符:===、!==、==和!=。其中===和==的效果是一样的,只不过前者的性能更高。同样,!==和!=也是等价的。
接着,我们看看真值判断。什么是真值判断呢?我们还是举一个例子,这样理解起来更直观一些。
function foo12(x : string|null){
let y: string;
if (x){ //x的值域变为string & !""
y = x; //OK。
}
}
在这个例子中,x的类型是string|null。但在if语句中,通过判断x是否为真,把x=null这个选项去掉了,这样就可以把x赋给string类型的y了。
这里,我还要给你补充点背景知识。在TypeScript/JavaScript中,我们其实可以把其他类型的值放入需要boolean值的地方,比如string、number、object等,它们会被自动转化成boolean值。不过,其中有一些值会被转化成false,它们是:0、NaN、“” (空字符串)、0n (bigint类型中的0)、null,以及undefined。
除此之外的值,转化为boolean值以后都是true。所以,在上面foo12示例程序的if条件中,x是true,那它就不可能是null值了,也不可能是空字符串。这样,最后的形成的值域就是string & !“”。
最后,我们再看看等值判断。其实我们在上一节就见过等值判断的例子,我们把那个例子程序再拿过来看一下。
function foo9(age : number|null){
if (age == 18 || age == 81){ //age的值域现在是 18|81
console.log("18 or 81");
}
else{ //age的值域是 !18 & !81 & (number | null)
console.log("age is empty!")
}
}
在这个例子中有一个if语句,其中的条件表达式会生成一个值域,“18|81”。而对于else块,则需要先把“18|81”取补集,然后再跟age原来的值域求交集。
好了,现在我们就分析完了数据流分析中的第四个要素,也就是转换函数。接下来我们看看最后一个要素,就是汇聚函数。
什么时候需要用到汇聚函数呢?对于if语句来说,如果程序在if块和else块中都修改了某个变量的值域,那在if语句后面,变量的值域就需要做汇聚。我们还是通过一个例子来说明一下。
在下面的例子foo13中有一个if语句。在这个if语句中,if块和else块分别都有一个对y赋值的语句。在if块中赋值语句是y=x。在这里,x的值域是number,所以y的值域也是number。在else块中,赋值语句是y = 18,那y的值域就是18。
function foo13(x : number|null){
let y:number|string;
let z:number;
if (x != null){ //x的值域是number
y = x; //y的值域是number
}
else{
y = 18; //y的值域是18
} //if语句之后,y的值域是number
z = y; //OK
return z;
}
那么,在退出if语句的时候,y的值域应该是什么呢?你稍微分析一下就能看出来,这里应该取两个分支的并集,也就是number。所以把y赋给z是可以的。
你可以把这个例子稍微修改一下,把else块中的赋值语句改为y = “eighteen”。那当退出if语句以后,y的值域是number|“eighteen”。这样你再把y赋给z,编译器就会报错。
function foo14(x : number|null){
let y:number|string;
let z:number;
if (x != null){ //x的值域是number
y = x; //y的值域是number
}
else{
y = "eighteen"; //y的值域是18
} //if语句之后,y的值域是number|"eighteen"
z = y; //编译器报错!
return z;
}
好了,到目前为止,我已经把数据流分析的5个要素都识别清楚了。思路清楚以后,你就可以去实现了。至于我的参考实现,你可以看一下TypeChecker。
在这里,我要再分享一点心得。你会发现,即使我们已经多次使用数据流分析技术了,每次我还要把5个要素都过一遍。这是因为,我们做研发的时候,有个思维框架很重要,它可以引导你的思路,避免一些思维盲区。
比如,我在类型计算中使用数据流分析的时候,一开始注意力被其他技术点吸引了,忘记了用整个分析框架检查一遍,结果就忘记了实现汇聚函数,这就会导致一些功能缺失。后来用框架一检查,马上就补上了这个功能。
好了,到这里,我们已经基本介绍清楚了如何使用数据流分析技术来做类型计算。不过,类型计算还可能受到其他技术的影响。接下来我就介绍一下常量折叠(Constant Folding)和常量传播(Constant Propagation)。常量折叠和常量传播的结果,会进一步影响到类型计算的结果。
我们还是先看一个例子来理解一下这两个概念。这个例子中有x1,x2和x3三个变量。我们首先给x2赋予常量10。接着,我们把x2+8赋给x3。从这里你能计算出,其实x3的值也是一个常量,它的值是18。
function foo15(x1:number|null):number{
let x2 = 10; //x2是常量10
let x3 = x2 + 8; //x3是常量18
if (x1 == x3 ){ //x1的值域是18
return x1; //OK!
}
return x2;
}
你看,执行到这里,我们其实在编译期就把x2+8的值计算出来。这样,在生成汇编代码的时候,我们就不需要进行相应的计算了,直接给x3赋值为18就行了。这个技术就叫做常量折叠。它能让一些常量的计算在编译期完成,这样就能提高程序在运行期的性能。
同时,在x3 = x2+8这行程序中,还有一个现象,叫做常量传播。什么意思呢?在这行中,x2的值已经是一个常量10了,它的常量值被传播到了x2+8这个表达式中,从而计算出了一个新的常量x3。
再接下来是一个if语句。这个时候,x3的值传播到了if条件中。这就影响到了x1的值域。现在x1的值域就变成18了。所以,当我们在if块中执行return x1的时候,代码是正确的,满足返回值必须是number的要求。
那常量传播具体怎么实现呢?
在PlayScript的实现中,我们给每个表达式都添加了一个constValue属性。通过遍历树的方式,就可以求出每个表达式的常量值,并记录到constValue属性。在生成目标代码的时候,就可以直接使用这个常量值,不需要在运行期做计算。
好了,现在我们已经了解了常量折叠和常量传播技术,也分析了它对类型计算的影响。
不过,到目前为止,对于类型计算的结果,我们都是用在类型检查的场景里。其实,类型计算的结果也能用于类型推导,能够提高类型推导的准确程度。而常量折叠和传播,也会在其中起到作用。
在之前PlayScript版本中,我们也实现了基本的类型推导功能。但那个时候,类型推导都是基于变量声明时的类型,而不是基于数据流分析来获得变量动态的值域,再根据这个值域做类型推导。基于变量声明进行推导的结果肯定是不够精准的。
同样,我们举个例子看一下。在这个例子中,变量a的类型是number|string,我们再给a赋值为“hello”,现在a的值域是“hello”。再然后呢,我们声明了一个变量b,并把变量a作为它的初始化值。
function foo16(a:number|string){
a = "hello"; //a的值域是"hello"
let b = a; //推导出b的类型是string
console.log(typeof b);
}
那么问题来了,现在b应该是什么类型呢?我给你两个候选答案,让你选一下:
选项1:b的类型a原来的类型是一样的,都是number|string。
选项2:b的类型是string,因为采用常量传播技术,我们已经知道a的值是“hello”了。
我估计你应该会选出正确的答案,就是选项2。其实,上面的“let b = a”这个语句,就等价于“let b = “hello””,所以你应该能够推导出b的类型是string。
不过,这里要注意,我们不能因为a当前的值域是“hello”,就推导出变量b的类型也是值类型“hello”,这就把变量b限制得太死了。TypeScript会采用“hello”的基础类型string。
类型推导还有更复杂一点的场景。比如,在下面的例子中,我们仍然用a来初始化变量b。不过,现在a的值域是10|null。
function foo17(a:number|string|null){
if(a == 10 || a == null){ //a的值域是10|null
let b = a; //推导出b的类型是number|null
if (b == "hello"){ //编译器报错!
console.log("whoops");
}
}
}
基于a的值域,编译器会把b的类型推导为number|null。所以,这个时候如果我们用b=="hello"让b跟字符串做比较,编译器就会报错,指出类型number|null和string之间没有重叠,所以不能进行==运算。
好了,通过刚才的分析,相信你对类型计算的在类型推导中的作用,也有了一些直观的了解。
今天的内容就是这些。在今天这节课,我希望你能在以下几个方面有所收获。
首先,我们采用数据流分析的框架,可以动态地计算变量在每行代码处的值域,或者叫做类型。通过变量赋值、typeof运算符、真值判断和等值判断等操作,变量的值域会不停地被窄化。不过,在多个条件分支汇聚的地方,又会通过求并集而把值域变宽。
第二,常量折叠技术能够在编译期提前计算出常量,这样我们就不需要在运行期再计算了,从而提高程序性能。而常数传播技术,能够把常数随着代码传播到其他地方,从而计算出更多的常量。这些传播出去的常量,还会让类型计算的结果更加准确。
第三,类型计算的结果不仅可以用于类型检查,还可以用于类型推导,让类型推导的结果更加准确。
今天这节课实现的功能,你仍然可以参考TypeUtil和TypeChecker的实现,并且运行example_type2.ts示例程序。
为了更好地支持类型计算的功能,我还给编译器增加了对typeof语法的支持。增加的新语法规则叫做typeOfExp。
primary: literal | functionCall | '(' expression ')' | typeOfExp ;
typeOfExp : 'typeof' primary;
另外,我还增加了对于===和!==的支持。现在你对于支持新的语法规则应该已经驾轻就熟了,所以我在这里就不多展开了。你可以去看看示例程序的源代码。
那么,对TypeScript的类型系统和其他编译器前端功能的实现,我们到此就告一个段落了。这些功能将会给我们后面实现编译器后端特性提供很好的支撑!
今天的思考题是关于类型推导的。如果b的值域是0 | 1 | true | false,那么在“let a = b”这样一个变量声明语句中,编译器推导出的a的类型应该是什么呢?
欢迎你把这节课分享给更多对编译器前端感兴趣的朋友。我是宫文学,我们下节课见。
1.这节课示例代码的目录;
2.这节课你仍然需要关注TypeChecker和TypeUtil的代码;
3.Parser中解析TypeOfExp的代码,非常简单;
4.测试程序仍然是放在example_types.ts中,不过例子更多了。你每次可以注释掉其他的例子,只运行其中的一个,测试编译器的行为。