编译器角度看面向对象(OOP)

编译器角度看面向对象(OOP)

目前Tiobe排行榜中一半以上都是面向对象的编程语言

同时面向对象(OOP),面向对象的设计(OOD)大家工作中用到、提到比比皆是。

本篇文章主要目的是从编译器以及面向对象语言设计的角度看"面向对象"是怎么样的,以及面向对象语言设计的复杂问题。相信这篇文章能让你从面向对象的底层设计上更加深刻的理解它。

概述

面向对象是编程语言提供给程序员的一种使"抽象"来描述"实际"的能力。很多语言都具备这样的特性,如:C++,JAVA,Python等。

大多数面向对象语言保持了类Algol语言面向过程作用域惯例(命名空间严格嵌套,名字的新实例将掩盖可见作用域内的所有旧实例)。从编译器的视角来看,面向对象语言重组了程序的命名空间,它们向经典类Algol的命名方案添加了另一组命名惯例(约定),围绕数据的布局(特别是对象的定义)展开。这种以数据为中心的命名规范产生了另一个作用域的层次结构,和另一种解析名字的机制,即将源语言名字映射到运行时地址,使得编译后代码可以访问与名字关联的数据。

为了使接下来的所有阐述不引起二义性,先对一些面向对象的术语进行准确说明:

  1. 对象:对象是一个抽象,具有一个或多个成员。这些成员可以是数据项、操纵数据项的方法或其他对象。
  2. 类:类是具有相同抽象结构和特征的对象的集合。类的数据成员分别定义在类的每个实例中,类的方法定义在类本身中。一些方法是从外部可见(公有的),另一些外部不可见(私有的)。
  3. 继承:继承指类之间的一种关系,在类的名字作用域上定义了一个偏序(偏序关系定义了一种方向,只能正着,反着就不行!)。类可以有一个超类,类从超类继承方法和数据成员。一些语言允许有多个超类。
  4. 接收器:方法是相对于某个对象调用的,该对象称为方法的接收器。在方法内部,接收器通过特指名字为人所知,如this或self。

面向对象语言的命名空间

继承在类上添加了一个祖先关系。按照声明,每个类都有一个或多个父类/超类。继承既改变了程序的命名空间,又改变了从方法名和实例的关系。

在运行对象的方法时,它可以引用多个作用域层次中定义的名字。方法是一个过程,有其自身的命名空间,由过程的嵌套词法作用域定义。方法可以使用类Algol语言定义的常用惯例,访问这些作用域中的名字。

此外方法是相对于某个接收器调用的,它可以访问该对象自身的成员。方法定义在接收器对应的类中,方法可以访问该类的成员,通过继承还可以访问其超类的成员。

为实现这样的特性,编译器必须跟踪方法内部和类内部的作用域规则建立的名字/作用域的层次结构,同样还需要跟踪通过扩展建立的子类和超类的继承层次结构所产生的名字/作用域的层次结构。在这种环境下,编译器对名字的解析不仅取决与代码定义的细节,也取决于数据定义所形成的类的结构。编译器必须对代码命名空间和与类层次结构相关联的命名空间进行建模。相应模型的复杂度取决于特定的面向对象语言的细节。

类层次结构定义了一组嵌套的名字作用域,为了找到一个名字的声明,编译器必须查找词法作用域层次、类层次结构和全局命名空间。概念上,编译器首先查找声明的类中,接下来查找该类的直接超类,接下类查找超类的直接超类,依次类推,直到找到名字或穷尽层次结构为止。如果层次结构中找不到该名字,编译器将查找全局命名空间。

面向对象语言的运行时结构

运行时对象记录

类Algol语言需要运行时结构(活动记录AR,Activation Record:过程调用分配的内存区,用于保存与单个过程的单次激活实例相关联的控制信息和数据存储)来支持其词法命名空间,典型的AR如下图所示:

面向对象语言也需要运行时结构支持其词法作用域层次和类层次结构。其中一些结构与类Algol语言使用的结构是相同的。例如:方法的控制信息以及局部名字对应的存储,都是存储在AR中。此外,一些对象信息需要存储在自身的对象记录(Object record,OR)中来保存其状态。类的OR描述了继承层次结构,它们在编译器转换代码以及执行中发挥了关键作用

使用以下一个例子来说明对象运行时的OR层次结构,代码如下:

class Point{
public:
    int x, y;
    void draw(){}
    void move(){}
}
class ColorPoint:public Point{
private:
    Color c;
public:
    void draw(){}
    void move(){}
    void test(){...;draw();}
}
Class C{
private:
    int x, y;
public:
    void m(){
        int y;
        Point p = ColorPoint();
        y = p.x;
        p.draw();
    }
}

OR层次结构如下。SimplePoint对象是Point的实例,而LeftCorner和RightCorner都是ColorPoint的实例。每个都有自己的OR,Point类和ColorPoint类也一样。

**注意:类方法向量对各个实例的方法向量来说,实例方法向量应该和应该是共享的向量**。

对象LeftCorner的OR,包含一个指针指向定义了LeftCorner的类、一个指针指向类的方法向量和用于保存x, y, c三个字段的空间。请注意,ColorPoint实例中继承而来的字段的偏移量,与这些字段在基类Point中的偏移量是相同的。ColorPoint的OR,首先是逐字复制了Point的OR,而后在基础上扩展。由此产生的一致性,使得超类方法(如Point.move)可以在子类对象上正确运行。

类的OR包含一个指向其类class的指针、一个指向class方法向量的指针以及本身的字段超类(superclass)和类方法(class methods)。类OR中class方法向量总是nullptr。

超类(superclass)字段记录了继承层次,类方法(class methods)字段指向类实例所使用的方法向量。

方法调用

编译器如何生成用于调用方法(如draw)的代码?方法总是相对于一个对象(如RightCorner)调用的,即接收器。要使该调用合法,RightCorner在调用处必须是可以见的,因此编译器可以返现如何利用符号表查找RightCorner。编译器首先查找方法的词法作用域层次,然后查找类层次结构,最后查找全局作用域。这种查找提供了足够的信息,使得编译器可以输出相应的代码,以获取指向RightCorner的OR的指针。

在编译器输出代码获取OR指针后,它将定位到OR中偏移量为4(假定在32bit系统下,指针的大小为4个字节)的方法向量处。它使用draw的偏移量,即相对于方法向量的偏移量0,以获取指向draw的目标实现的指针。编译器将利用该代码指针进行标准过程调用。当然,这其中编译器还有其他的动作需要完成:它将RightCorner的OR指针作为隐含的第一个参数传递给draw函数。因为编译器定位draw时,是基于RightCorner对象的OR,其中包含了一个指针指向ColorPoint类的OR的classmethods方法向量,因而上述代码序列可以定位到真确的draw实现。如果调用是SimplePoint.draw函数,利用同样的过程可以找到Point方法的向量指针并调用Point.draw。

上述例子嘉定每个类都有一个完整的方法向量。因而,ColorPoint方法向量中对应于move的槽位指向Point.move,而对应于draw的槽位指向ColorPoint.draw。这种方案产生了我们想要的结果,即类X中表示其本身定义的方法,而定位继承而来的方法时,则沿着超类的路径向上跟踪各个超类的OR,其方式类似于词法作用域和AR中使用的访问路径。

对象记录布局

例子中一个微妙的要点是,在超类-子类形成的类层次结构中,实现必须维护OR中名字到偏移量映射的一致性。要使方法(如move)在类实例和子类实例(Point实例和ColorPoint实例)上都能正确运行,那么相关的字段(如x和y)在类实例的OR和子类实例的OR中必须出现在同样的偏移量处。同理,在相关类的方法向量中,同一方法也必须出现在同样的偏移量处。

如果没有继承,编译器可以以类的字段和方法按任意顺序分配偏移量。它将这些偏移量直接编译到代码中。代码使用接收器的指针(如this)和偏移量,来定位OR中任意目标字段或方法向量中的任意方法。

在单继承的情况下,OR的布局简单且直接。因为每个类只有一个直接超类,编译器将新增字段追加到超类OR布局的末尾,对原有的OR布局进行扩展。这种方法成为前缀化(prefixing),确保了类层次结构上下各个偏移量的一致性。当对象转换为某个超类时,OR中的字段均处于预期位置。

虚方法与动态分派

在C++中编译器可以在编译时将任何方法确定到对应的具体实现上,除非方法声明为虚方法。虚方法在本质上意味着,程序员想要针对接收器所属的类来定位方法的实现。

编译器输出代码,用于在运行时使用对象的方法向量来定位方法的实现,这个过程称为动态分派(dynamic dispatch),C++中虚函数一般都是使用动态分派,但是,如果编译器可以证明某个虚方法调用有已知且不变的接收器,那么它可以生成直接调用,也称为静态分派(static dispatch)

动态分派中,如果类结构在运行时改变,那么编译器无法将方法名解析为实现;它必须将该过程推迟到运行时。解决此类问题,可以是在每次改变类层次结构时重新计算方法向量,也可能是在运行时进行名字解析和类层次结构中的搜索,等等。

面向对象语言的实现试图通过两种通用策略之一来降低动态分派的代价。

  1. 可以运行分析,以证明某个给定的方法调用使用的接收器是同一已知类,这种情况,可以将动态分派替换为静态分派。
  2. 对于无法确定接收器的调用以及类可能在运行时改变的情形,实现可以缓存搜索结果以提高性能。

多继承

多继承面临的问题:超类方法的编译代码使用的各个偏移量,都是基于该超类的OR布局。当然不同的直接超类可能为各个字段分配相互冲突的偏移量。为了调和这些相互竞争的偏移量,编译器必须采用一种稍微复杂的方案:对来自不同超类的方法,必须使用不同的OR指针。

考虑继承自多个超类 β \beta γ \gamma δ \delta 的类 α \alpha 。为设定类 α \alpha 对象的OR布局,实现首先必须为类 α \alpha 的超类 β \beta γ \gamma δ \delta 规定一种顺序。它首先针对类 β \beta 实例设定OR的布局,接下来追加 γ \gamma 类的实例的整个OR,然后在追加 δ \delta 类实例的整个OR。上述三个OR实例中,class指针均指向 α \alpha 类的OR。在上述布局之后,在追加 α \alpha 类本身声明的所有字段。而构建 α \alpha 类实例的方法的方法向量时,只需将 α \alpha 的各个方法指针追加到上述第一个超类实例的方法向量中。

下图中给出了类 α \alpha 实例的最终的OR布局,我们假定 α \alpha 本身定义了两个字段 α 1 \alpha_1 α 2 \alpha_2 β \beta γ \gamma δ \delta 类各自定义的字段名称类似。 α \alpha 类实例的OR划分为四个逻辑部分: β \beta 实例中的OR、 γ \gamma 实例中的OR、 δ \delta 实例中的OR、 α \alpha 中声明的各个字段。 α \alpha 类中声明的方法则附加到第一部分的方法向量中。

多继承涉及的其余复杂性在于下述事实:在使用class指针和methods方法向量调用超类方法时,必须调整类实例对应的OR指针。进行这种调用时,必须根据class指针相对于OR顶部的class指针的偏移量,相应地调整OR指针。用于完成这种调整的最简单的机制是,在方法向量和实际方法之间插入一个trampoline函数(蹦床函数)。trampoline会调整OR指针,用原来的参数调用对应的方法并在返回时逆向调整OR指针。

总结

在面向对象语言中,运行代码的活动记录(AR)可以捕获命名空间在词法作用域的一部分,以及执行状态;但实现还需要对象记录(OR)和类记录的层次结构,以模拟命名空间基于对象的另一部分。

Note类记录的层次结构是由类OR和类实例OR构成的,OR中superclass字段记录了类的层次。

猜你喜欢

转载自blog.csdn.net/weixin_46222091/article/details/105312980
今日推荐