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.

16 KiB

30面向对象编程第2步剖析一些技术细节

你好,我是宫文学。

在上一节课里,我们实现了基本的面向对象特性,包括声明类、创建对象、访问对象的属性和方法等等。

本来,我想马上进入对象的继承和多态的环节。但在准备示例程序的过程中,我发现有一些技术细节还是值得单独拿出来,和你剖析一下的,以免你在看代码的时候可能会抓不住关键点,不好消化。俗话说,魔鬼都在细节中。搞技术的时候,经常一个小细节就会成为拦路虎。

我想给你剖析的技术细节呢,主要是语义分析AST解释器方面的。通过研究这些技术细节,你会对面向对象的底层实现技术有更加细致的了解。

技术细节:语义分析

语义分析方面的技术细节包括如何设计和保存class的符号、如何设计class对应的类型、如何给This表达式做引用消解、如何消解点符号表达式中变量等等。

首先看看第一个问题就是如何在符号表里保存class的符号

我们知道符号表里存储的是我们自己在程序里声明出来的那些符号。在上一节课之前我们在符号表里主要保存了两类数据变量和函数。而class也是我们用程序声明出来的所以也可以被纳入到符号表里保存。

你应该还记得我们的符号表采用的是一种层次化的数据结构也就是Scope的层层嵌套。而且TypeScript只允许在顶层的作用域中声明class不允许在class内部或函数内部嵌套声明class所以class的符号总是被保存在顶层的Scope中。

其实在TypeScript中我们还可以在一个文件或模块里引用另一个文件里定义的类这样你就能在当前文件里使用这些外部的类了。但我们其实并不是把外部类的全部代码都导入进来而是只需要引入它们的符号就行了。在class符号里有这些类的描述信息这些信息叫做元数据。元数据里会包括它都有哪些属性、哪些方法分别都是什么类型的等等。这些保存在符号里的这些信息其实足够我们使用这个类了我们不用去管这个类的实现细节。

你也可以对比一下FunctionSymbol的设计。FunctionSymbol里会记录函数的名称、参数个数、参数类型和返回值类型。你通过这些信息就可以调用一个函数完全不用管这个函数的实现细节也不用区分它是内置函数还是你自己写的函数调用方式都是一样的。

说完了class的符号设计和保存我们再进入第二个技术点讨论一下class的类型问题

我们说过class给我们提供了一个自定义类型的能力。那这个自定义的类型如何表达呢

在前面的课程中我们已经形成了自己的一套类型体系用于进行类型计算。而在这个类型体系中有一种类型叫做NamedType。这些类型都有名称并且还有父类型。我们用NamedType首先表示了Number、String、Boolean这些TypeScript官方规定的类型还用它来表示了Integer和Decimal这两个Number类型的子类型这两个类型是我们自己设计的。

那其实NamedType就可以用来表示一个class的类型信息以便参与类型计算。

这里你可能会提出一个问题class本身不就是类型吗我们在ClassSymbol里已经保存了类的各种描述信息为什么还要用到NamedType呢

采用这样的设计有几个原因。首先并不是所有的类型都是用class定义出来的。比如系统里有一些内置的类型。再比如如果你用TypeScript调用其他语言编写的库比如一些AI库你可以把其他语言的类型映射成TypeScript语言的类型。所以说类型的来源并不只有自定义的class。

第二个原因是由类型计算的性质导致的。在我们目前的类型计算中,我们基本只用到了类型的名称和父子类型关系这两个信息,其他信息都没有用到,所以就不需要在类型体系中涉及。

不过使用NamedType这种设计其实有个潜台词就是我们类型系统是Norminal的类型系统。这是什么意思呢Norminal的意思是说我们在做类型比较的时候仅仅是通过类型的名称来确定两个类型是否相同或者是否存在父子关系。与之对应的另一种类型系统是structural的也就是只要两个类型拥有的方法是一致的那就认为它们是相同的类型。像Java、C++这些语言采用的是Nominal的类型系统而TypeScript和Go等语言采用的是Structural的类型系统。这个话题我们就不展开了有兴趣你可以多查阅这方面的资料。

不过为了简单我们目前的实现暂且采用Norminal的类型只通过名称来区分相互之间的关系。

在分析完了class的符号和类型之后我们再来看看它的用途。这就进入了第三个技术点也就是如何消解This表达式。

我们知道this表达式的使用场景是在类的方法中指代当前对象的数据。那么它的类型是什么呢在做引用消解的时候应该让它引用哪个符号呢

this的类型不用说肯定就是指当前的这个class对应的类型这个不会有疑问。

那它应该被关联到什么符号上呢我们知道当程序中出现某个变量名称或函数名称的时候我们会把这些AST节点关联到符号表里的VarSymbol和FunctionSymbol上this当然也不会例外。this在被用于程序中的时候其用法跟普通的一个对象类型的变量是没有区别的。那我们是否应该在每个用到this的方法里创建一个this变量呢

这样当然可以但其实也没有必要。因为每个函数都可能用到this关键字所以如果在每个方法里都创建一个this变量有点啰嗦。我们只需要简单地把this变量跟ClassSymbol关联起来就行了在使用的时候也没有什么不方便的。我们下面在讲AST解释器的实现机制里会进一步看看如何通过this来访问对象数据。

接下来,我们再看看第四个技术点:对点符号表达式的引用消解。

在上一节课的示例程序中我们可以通过“this.weight”、“mammal.color”、“mammal.speak()”这样的点符号表达式访问对象的属性和方法。

我们知道在做引用消解的时候需要把这里面的this、mammal、color、speak()都关联到相应的符号上,这样我们就知道这些标识符都是在哪里声明的了。

不过,之前我们不是已经都做过引用消解了吗?为什么这里又要把点符号的引用消解单独拎出来分析呢?

这是因为,之前我们做变量和函数的引用消解的时候,只需要利用变量和函数的名称信息就行了。但在点符号这边,只依赖名称是不行的,还必须依赖类型信息。

比如对于mammal.color这个表达式。我们在上下文里很容易找到mammal是在哪里声明的。但color就不一样了。这个color是在哪里声明的呢这个时候你就必须知道mammal的类型然后再找到mammal的定义。这样你才能知道mammal是否有一个叫做color的属性。

那你可能说,这很简单呀,我们只需要先计算出每个表达式的类型,然后再做引用消解就可以了呀。

没那么简单。为什么呢因为类型计算的时候也需要用到引用消解的结果。比如在mammal.color中如果你不知道mammal是在哪里声明的就不能知道它的类型那也就更没有办法去消解color属性了。

所以,在语义分析中,我们需要把类型计算和引用消解交叉着进行才行,不能分成单独的两个阶段。在《编译原理实战课》我曾经分析过Java的前端编译器的特点。这种多个分析工作穿插执行的情况是Java编译器代码中最难以阅读和跟踪的部分但你要知道这背后的原因。

我还给你提供了一个更复杂一点的例子,你可以先看一下:

class Human{
    swim(){
        console.log("swim");
    }
}

class Bird{
    fly(){
        console.log("fly");
    }
}

function foo(animal:Human|Bird){
    if (animal instanceof Human){
        animal.swim();
    }
    else{
        animal.fly();
    }
}

这个例子里有Human和Bird两个类Human有swim()方法而Bird有fly()方法。不过我们可以声明一个变量animal是Human和Bird的联合类型。那么你什么时候可以调用animal的swim()方法什么时候可以调用它的fly()方法呢这个时候你就要基于数据流分析方法先进行类型的窄化然后才能把swim()和fly()两个方法正确地消解。

好了关于语义分析部分的一些技术点我就先剖析到这里。接着我们看看AST解释器中的一些技术。

技术细节Ast解释器

实现Ast解释器的时候我们也涉及了不少的技术细节包括如何表示对象数据、对象数据在栈桢中的存储方式、如何以左值和右值的方式访问对象的属性等。

**首先我们看看如何表示对象的数据。**上一节课里我们提到用一个Map<Symbol, any>来存储对象数据就行了。我们在类中声明的每一个属性都对应着一个Symbol所以我们就可以用Symbol作为key来访问对象的数据。

其实我们的栈桢也是这样设计的。每个栈桢也是一个Map<Symbol, any>。你如果想访问哪个变量的数据就把变量的Symbol作为key到Map里去查找就好了。

不过如果只用一个Map来代表对象数据数据的接收方可能不知道该数据是属于哪个类的在实现一些功能的时候不方便。所以我们就专门设计了一个PlayObject对象在对象里包含了ClassSymbol和对象数据两方面的信息具体实现如下

class PlayObject{
    classSym:ClassSymbol;
    data:Map<Symbol,any> = new Map();
    constructor(classSym:ClassSymbol){
        this.classSym = classSym;
    }
}

那对象数据在栈桢里是如何保存的呢?

其实每个方法都跟函数一样会对应着一个栈桢。在方法里如果我们用到了this关键字那就可能会访问对象的属性或方法。

那我们就一定要在栈桢里放一个PlayObject对象。这个对象的key就是ClassSymbol。正好我们在前面让this表达式关联到了ClassSymbol上。所以我们使用this表达式就可以访问对象中的属性了。你看看下面的图里面显示了栈桢和对象数据之间的关系以及如何访问对象的属性。

那栈桢里的PlayObject对象是在什么时候被放到栈桢里的呢其实是在调用构造方法和普通方法的时候。

在调用构造方法之前我们首先要创建一个PlayObject对象把它放到构造方法使用的栈桢里。在构造方法里这样就可以用this来访问对象属性并给这些属性赋予初始值了。另外构造方法是没有返回值的。在调用构造方法之后我们就把这个新创建的PlayObject当做返回值就好了。

在调用普通的对象方法的时候比如用mammal.speak()对mammal求值的话会返回一个PlayObject对象。然后我们把这个对象放在speak()方法的栈桢里就行了。

把PlayObject放在栈桢里其实就相当于把PlayObject作为函数的第一个参数传到函数内部。总之这样就能够在speak方法里使用this表达式了。

最后,我们再看一下点符号表达式的左值和右值的使用场景。在下面的示例程序中第一句是给mammal.color赋值所以我们需要一个左值。而第二句是获取mammal.color当前的值所以是一个右值。

mammal.color="yellow"; //左值
println(mammal.color); //右值

对于这两种场景点符号表达式要分别返回左值和右值。在需要右值的时候mammal.color返回的是一个字符串。而在需要左值的时候我们应该返回什么呢

之前在处理本地变量的时候我们已经学过在需要左值的时候直接返回变量的Symbol就好了。这样后续的赋值程序就可以把这个Symbol作为Key来修改栈桢中变量的值。

28讲在实现数组特性的时候有时候我们需要修改某个数组元素的值。这个时候我们就不能简单地用数组变量的Symbol来表达一个左值了因为我们还需要知道数组元素的下标。所以那个时候我们专门设计了另一个左值对象叫做ArrayElementRef。它里面甚至可以存放多个下标值来引用多维数组中的某个元素。

class ArrayElementRef{
    varSym : VarSymbol;  //数组的基础变量对应的Symbol
    indices : number[];  //(多维)数组元素的下标。
    constructor(varSym:VarSymbol, indices:number[]){
        this.varSym = varSym;
        this.indices = indices;
    }
}

用于访问对象属性的左值其实也可以采用类似的设计。这个类的名字叫做ObjectPropertyRef意思是这是对一个对象的属性的引用里面有PlayObject对象还有被访问的属性的Symbol。基于这样一个左值我们就可以修改对象的属性了。

class ObjectPropertyRef{
    object: PlayObject;
    prop:Symbol;
    constructor(object:PlayObject, prop:Symbol){
        this.object = object;
        this.prop = prop;
    }
}

课程小结

今天的内容就是这些。通过这节课分享的一些技术实现细节,我希望你能记住几个关键点。

首先在语义分析方面我们需要对class建立符号并存到符号表里。class的符号应该包含足够的描述信息包括名称以及属性和方法的描述。为了进行类型计算我们还要把class符号关联到一个NamedType对象中。这种类型计算方式的设计思路是基于Nominal的类型系统而来的。

在支持点符号表达式以后,我们的引用消解和类型计算需要交错起来进行,这会导致语义分析程序变得复杂。你在查看各种编译器的源代码的时候,也可以多关注它们在这方面是如何实现的。

第二在AST解释器的实现机制上你的脑海里需要对栈桢有一个清晰的图像。在对象方法的栈桢里我们一定会放一个PlayObject对象数据这样就可以用this来访问对象的属性了。在访问对象属性时又要分为左值和右值的情况。对于左值我们要设计一种数据结构清晰地表达出如何访问对象的属性。

最后,关于课程代码的学习,我还要再叮嘱你几句。

在课程起步篇的后半段和我们现在的进阶篇里,课程的示例代码的体量明显加大。并且,由于每节课示例代码都在迭代,你在阅读代码的时候可能会感觉到有一定的负担。

这里我想强调的是,编译器针对词法分析和语法分析这样功能的代码,往往大家的实现都差不多。因为这两部分的理论化是最强的,基本上你理解了理论就能写出差不多的代码来了。

而语义分析、编译器的后端等的代码工程特点就比较强了各个编译器的实现差异很大。你需要把握其中的关键技术点就比如今天我们这节课分析的这些点。这样在具体实现上你可以不用拘泥于哪种具体的方式。就比如在这节课中关于如何消解this以及如何把对象数据提供给方法并通过this访问其实可以有多种技术方案。你可以活学活用只要把握住其中的关键点就可以了。

思考题

今天我们讲到了class的符号中包含的信息。那你能不能思考一下这些符号是否需要在虚拟机或者可执行程序中保存保存这些信息有什么用途你能不能结合你熟悉的语言来分享一下

另外今天我们提到的Norminal和Structural的类型系统你在使用它们的时候有什么体会如果我们想要实现Sturctural的类型系统那应该如何设计欢迎你在留言区分享观点。

欢迎把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。