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.

14 KiB

29面向对象编程第1步先把基础搭好

你好,我是宫文学。

到目前为止我们的语言已经简单支持了number类型、string类型和数组。现在我们终于要来实现期待已久的面向对象功能了。

在我们的课程中为了实现编译器的功能我们使用了大量自定义的类。最典型的就是各种AST节点它们都有共同的基类然后各自又有自己属性或方法。这就是TypeScript面向对象特性最直观的体现。

面向对象特性是一个比较大的体系,涉及了很多知识点。我们会花两节课的时间,实现其中最关键的那些技术点,比如声明自定义类、创建对象、访问对象的属性和方法,以及对象的继承和多态,等等,让你理解面向对象的基础原理。

首先,我们仍然从编译器的前端部分改起,让它支持面向对象特性的语法和语义处理工作。

修改编译器前端

首先是对语法的增强。我们还是先来看一个例子,通过这个例子看看,我们到底需要增加哪些语法特性:

class Mammal{
    weight:number;
    color:string;
    constructor(weight:number, color:string){
        this.weight = weight;
        this.color = color;
    }
    speak(){
        println("Hello!");
    }
}

let mammal = new Mammal(20,"white");
println(mammal.color);
println(mammal.weight);
println(mammal.speak);

在这个例子中我们声明了一个classMammal。这个类描述了哺乳动物的一些基础属性包括它的体重weight、颜色color。它还提供了哺乳动物的一些行为特征比如提供了一个speak方法。

Mammal类还有一个特殊的方法叫做构造方法。通过调用构造方法可以创建类的实例也就是对象。然后我们可以访问对象的属性和方法。

其实TypeScript的类还有很多特性包括私有成员、静态成员等等。这里我们还是先考虑一个最小的特性集合先让语言支持最基础的类和对象特性。

看看这个示例程序我们能总结出多个需要增强的语法点包括类的声明、调用类的构造方法this关键字以及通过点符号来引用对象的属性和方法。

我们首先看看类的声明。我们提供了下面这些语法规则,来支持类的声明:

classDecl : Class Identifier classTail ;
classTail :  '{' classElement* '}' ;
classElement : constructorDecl| propertyMemberDecl;
constructorDecl : Constructor '(' parameterList? ')' '{' functionBody '}' ;
propertyMemberDecl : Identifier typeAnnotation? ('=' expression)? ';'                  
                   | Identifier callSignature  '{' functionBody '}' ;

这些规则看似很多但其实解析起来并不复杂。并且很多基础成分在我们之前的函数声明的语法结构中都有了比如参数列表parameterList、函数签名callSignature、函数体functionBody等等所以这会给我们节省很多工作量。

第二我们再看看如何调用类的构造方法来创建对象比如“new Mammal(20,“white”)”这个表达式。

相关的语法规则如下:

primaryLeft: Identifier | functionCall | constructorCall |其他表达式

第三在构造方法里我们可以使用一个特殊的this关键字。这个关键字被解析以后也会形成一个表达式就叫做This表达式。

primaryLeft: Identifier | functionCall | constructorCall | This | 其他表达式

最后我们需要用点符号来引用对象的属性和方法比如用mammal.weight代表mammal对象的weight属性这些都是我们平时很习惯使用的语法。

点号表达式跟上一节课的下标表达式一样,都是在别的表达式后面加个后缀,所以语法规则可以写成这样:

primary:  primaryLeft ('[' expression ']') | '.' expression)* ;

好了,语法规则就是这些。

对应着这些语法规则我们也需要增加一些AST节点包括ClassDecl、ConstructorDecl、PropertyDecl、MethodDecl、ConstructorCall、ThisExp和DotExp等。

语法分析和AST节点的参考实现你可以看看parser.tsast.ts

接下来,你肯定会猜到,我们还需要做一些语义分析的工作,包括再一次增强类型体系,以及符号表、引用消解等方面的工作,还要做一些必要的语义检查

在示例程序中我们每次使用class声明一个新的类实际上就是创建了一个自定义的类型。接下来我们就可以用这个类型来声明变量并进行类型检查。

这就给我们的语义分析增加了一些工作量这个工作叫做类型消解。也就是说当我们每次使用一个自定类型的时候要知道这个类型是在哪里声明的。这就不像number和string这样的内置的类型我们每次见到它们都可以自动识别出来。所以我们要多做一点工作建立类型的声明和使用之间的关系。这种消解工作跟变量的消解、函数的消解是差不多的可以尽量复用之前的算法。

在类型消解之后我们就可以利用类的声明信息来进行类型检查了。比如你可以在程序里用点符号访问weight属性、color属性和speak()方法。但如果访问了没有定义过的属性或者方法,那就要报语义分析的错误。

除了这些针对类的语义分析我们还有一些其他工作。比如当我们创建对象的时候对象的数据成员必须被正确地初始化。当然那些可以取值为undefined的属性除外。所以你需要使用我们第22节课学过的赋值分析技术,来完成这项工作。

好了完成了语法分析和语义分析以后编译器前端部分的工作就基本完成了。你可以运行我们的解析器输出示例程序的AST看看。我把截屏放在了文稿里并做了一些标注方便你熟悉类的声明和类成员访问等语法对应的AST结构。

完成了编译器前端的工作以后我们接下来看看运行时方面需要做一些什么工作。首先我们还是要升级一下AST解释器。

升级AST解释器

为了让AST解释器支持面向对象特性我们需要做4个方面的工作包括创建对象、在栈桢里保存对象数据、通过点符号来引用对象的属性和方法,以及执行对象的方法

首先看创建对象的过程。你可以通过new关键字调用构造方法来创建对象。构造方法最重要的工作是初始化对象的属性。这里的具体实现你可以参见visitConstructorCall方法。

不过,对象的属性不仅仅是在构造函数里初始化的。其实,你在声明类的时候,就可以给属性带上初始化表达式。所以,实际对象的初始化过程,是首先使用这些初始化表达式,来给对象属性赋值,之后才会执行构造方法。

我们再看看第二个工作在栈桢里保存对象数据。这里我用了一个简单的Map对象来建立对象的属性和数据的映射关系。

接着是第三项任务就是访问对象的属性。访问对象的属性需要借助点符号表达式。点符号左边的表达式能够返回一个对象引用。在AST解释器里这个对象引用和其他变量一样都是一个VarSymbol。基于这个VarSymbol程序就可以从栈桢里找到对象的数据也就是我们前面说到的Map对象。你可以在这个Map里查找对象属性的值。

最后,是执行对象的方法。执行对象的方法跟执行普通函数其实差不多。主要的区别,就是你必须给方法传递一个特殊的参数也就是对象引用具体来说就是一个VarSymbol。这样的话你才可以在方法里用this.weight这样的表达式来访问对象的属性。这里的this就是传到方法里的对象引用。

好了实现完这些机制以后AST解释器就顺利升级了。你可以用这个解释器运行一下前面的示例程序输出结果如下图所示

那么升级完AST解释器以后我们再进一步尝试把我们这节课的示例程序编译成可执行程序。首先我们仍然要设计一下对象的内存布局并实现几个必要的内置函数。

内存布局和内置函数

在前面两节课,我们已经实现过了字符串和数组的内存结构。这些知识点我们今天仍然可以借鉴,降低我们这节课在设计和实现上的工作量。

我们用PlayObject来保存对象的数据。每个PlayObject跟PlayString、PlayArray等一样也具备公共的对象头。在对象头之后就是对象的属性数据。

目前我们可以存储4种类型的属性包括number型、string型、数组型和其他的自定义对象。它们都有一个共同的特点就是都需要在PlayObject中占据8个字节空间用来保存数据或对象引用。这是一个好消息,因为我们可以先简化对象的设计,不用考虑太多字节对齐等话题。

不过如果你想了解字节对齐你可以去参考一下C语言的结构体是如何安排每个字段在内存中的位置的。这些字段并不是一个挨着一个来存放的相反每个字段的其实地址往往可以被4字节、8字节等整除这就导致字段之间可能存在空隙。这样做的原因是让CPU在读取字节对齐的数据的时候速度更快只需要在内部做一次读取操作就可以完成了。而读取不对齐的数据CPU在内部需要的读取操作可就不止一次了。

这里我先不展开你只需要知道内存布局设计上要考虑这个因素就行了。目前我们大可以不必担忧因为我们存放的各种数据都是8字节大小可以紧挨着排列一点都不浪费空间而且都是字节对齐的。

设计完内存布局之后,我们再实现一下内置函数。其中最主要的,当然就是在内存里创建对象的函数。

PlayObject* object_create_by_length(size_t length);

我也用C语言写了一个class.c的测试程序来测试创建对象、访问对象属性等功能。你可以用make class命令编译并运行一下看看。

好了,关于内存布局和内置函数,我们就讨论完毕了。接下来又到了最后一个环节:修改编译器后端。

修改编译器后端

在修改编译器后端的时候,我们需要把注意力放在两个方面:访问对象的属性调用对象的方法

我们先看看如何访问对象的属性。在上一节课,我们曾经实现过访问数组元素的功能。访问对象的属性和访问数组元素的原理,其实是一样的,关键点就是要正确地计算出内存地址。根据我们的内存布局设计,这其实就是在对象地址的基础上,加上一定的偏移量就可以了,实现起来很简单。

那我们再看看如何调用对象的方法。这倒是一个新的知识点不过这跟调用函数有很多相似之处。我们对调用函数已经很熟悉了而调用对象方法和它只有一个地方不同就是方法的第一个参数其实是对象引用。而原来方法声明中的第一个和第二个参数等等则依次被往后移了一个位置成为了第二个、第三个参数。其实C++和Java等面向对象的语言基本上也是用这样的方法传递对象引用的让方法中的代码可以访问对象中的数据。

修改编译器后端的这些示例代码,我仍然放在了asm_x86-64.ts中,你可以参考一下。这里,你特别要注意看一下我是如何计算对象属性的内存地址,以及如何用参数机制给方法传递对象引用的。

课程小结

今天的主要内容就是这些了,我们再一起回顾一下这节课的重点:

在编译器前端方面,我们最近这几节课一直在迭代、增加一些语法规则,这一节课一下子又增加了不少。在我们课程的第一阶段,可能你需要花很多时间才能实现一小点语法规则。而现在,你可以大刀阔斧地快速实现很多语法规则了。是不是已经感受到了自己的技能提升了很多?

在这里,我也分享一点我的心得。在实现这些语法功能的时候,最重要的其实就是设计出正确的语法规则。一旦你能够清晰地写出语法规则,那么照着规则去实现语法分析程序就不是什么难事了。你可能不能一次写出完全正确的语法规则,这也没有关系,多尝试几次就好了。你会不断积累经验,直到对各种形式的语法都得心应手。

在内存布局方面我们基本上沿袭了前几节课的设计。但关于字节对齐这个知识点虽然我们当前的简化设计不会遇到字节对齐问题但你仍然要了解它以便以后升级我们的对象设计支持更多的数据类型特别是小于8个字节的基础数据类型比如boolean和32位的整型等。

最后,在编译器后端的实现上,重点是对象方法的调用机制。我们需要把对象引用作为第一个参数传递给方法。

好了,今天我们已经实现了基础的自定义对象功能。下一节课,我们会在这个基础上,增加面向对象编程最核心的一个特性,继承和多态功能,这会有助于加深你对面向对象的底层机制的理解。

思考题

TypeScript中的几乎任何类型的数据都可以用点符号来访问其内部的属性和方法。比如我们可以访问字符串和数组的length属性甚至也可以用一些方法调用number类型的数据。那么基于今天课程中的知识点你能不能思考一下我们具体能如何实现上述这些功能呢欢迎在留言区分享你的观点。

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

资源链接

这节课的示例代码都在这里!