You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

17 KiB

25增强编译器前端功能第4步综合运用多种语义分析技术

你好,我是宫文学。

在上一节课,我们比较全面地分析了怎么用集合运算的算法思路实现类型计算。不过,在实际的语义分析过程中,我们往往需要综合运用多种技术。

不知道你还记不记得,我们上一节课举了一个例子,里面涉及了数据流分析和类型计算技术。不过这还不够,今天这节课,我们还要多举几个例子,来看看如何综合运用各种技术来达到语义分析的目的。在这个过程中,你还会加深对类型计算的理解、了解常量折叠和常量传播技术,以及实现更精准的类型推导。

好,我们首先接着上一节课的思路,看一看怎么把数据流分析与类型计算结合起来。

在类型计算中使用数据流分析技术

我们再用一下上节课的示例程序foo7。在这个程序中age的类型是number|nullage1的类型是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。

**第四,我们要确定转换函数,也就是在什么情况下,变量的值域会发生变化。**在当前的例子中,我们只需要搞清楚变量赋值的情况就可以了。如果我们要在变量声明中进行初始化,那也可以看做是变量赋值。

在变量赋值时,如果=号右边的值是一个常量,那么变量的值域都会变成一个值对象,这种情况我们已经在前一节课分析过了。

那如果=号右边的值不是常量而是另一个变量呢比如下面一个例子foo10x的类型是number|stringy的类型是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。常量折叠和常量传播的结果会进一步影响到类型计算的结果。

常量折叠和常量传播

我们还是先看一个例子来理解一下这两个概念。这个例子中有x1x2和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应该是什么类型呢我给你两个候选答案让你选一下

选项1b的类型a原来的类型是一样的都是number|string。

选项2b的类型是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运算符、真值判断和等值判断等操作变量的值域会不停地被窄化。不过在多个条件分支汇聚的地方又会通过求并集而把值域变宽。

第二,常量折叠技术能够在编译期提前计算出常量,这样我们就不需要在运行期再计算了,从而提高程序性能。而常数传播技术,能够把常数随着代码传播到其他地方,从而计算出更多的常量。这些传播出去的常量,还会让类型计算的结果更加准确。

第三,类型计算的结果不仅可以用于类型检查,还可以用于类型推导,让类型推导的结果更加准确。

今天这节课实现的功能,你仍然可以参考TypeUtilTypeChecker的实现,并且运行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.这节课你仍然需要关注TypeCheckerTypeUtil的代码;
3.Parser中解析TypeOfExp的代码,非常简单;
4.测试程序仍然是放在example_types.ts中,不过例子更多了。你每次可以注释掉其他的例子,只运行其中的一个,测试编译器的行为。