在多重继承中支持 virtual function, 其复杂度围绕在第二个及后继的 base classes 上, 以及必须在执行期调整 this 指针这一点, 以以下的 class 体系为例:
class Base1 { public: Base1(); virtual ~Base1(); virtual void SpeakClearly(); virtual Base1* Clone() const; protected: float data_base1; }; class Base2 { public: Base2(); virtual ~Base2(); virtual void Mumble(); virtual Base2* Clone() const; protected: float data_base2; }; class Derived: public Base1, public Base2 { public: Derived(); virtual ~Derived(); virtual Derived* Clone() const; protected: float data_derived; };
在以上的体系中, Derived 支持 virtual functions 的困难度统统落在 Base2 subobject 身上. 共有三个问题需要解决:
1. virtual destructor
2. 被继承下来的 Base2::Mumble()
3. 一组 Clone() 实体.
咱们来一一解决!
首先, 把从 heap 中配置的 Derived 对象的地址, 指定给一个Base2 指针:
Base2 *pbase2 = new Derived;
新的 Derivec 对象地址必须调整, 以指向其 Base2 subobject, 编译时会产生以下代码:
//转移地址以支持第二个 base class Derived *temp = new Derived; Base2 *pbase2 = temp ? temp + sizeof(Base1) : 0;
如果没有这样的调整, 指针的任何非多态运用都将失败, 如下:
//pbase2 被指定一个Derved 对象, 应该没问题 pbase2->data_base2;
当程序员要删除 pbase2 所指的对象时:
//必须首先调用正确的 virtual destructor 函数实体 //然后施行 delete 运算符 //pbase2 可能需要被再一次调整, 以指出完整对象的起点 delete pbase2;
指针必须被再一次调整, 以求再一次指向 Derived 对象的起始处(推测它还指向 Derived 对象), 然而上述的 offset 加法却不能在编译时期直接确认, 因为 pbase2 所指的对象只有在执行期才能确定.
一般的规则是, 经由指向第二或后继的 base class 的指针(或 reference) 来调用 derived class virtual function:
Base2 *pbase2 = newDerived; ... delete pbase2;
该调用所连带的必要的 this 指针调整操作必须在执行期完成, 也就是说, offset 的大小, 以及把 offset 加到 this 指针上头的那一小段代码必须由编译器在某个地方插入, 问题是插哪个地方?
本贾尼原先实施于 cfront 编译器中的方法是将 virtual table 扩大, 使它容纳此处所需的 this 指针, 调整相关事物. 每一个 virtual table slot 不再只是一个指针, 而是一个聚合体, 内含可能的 offset 及地址. 于是 virtual function 的调用操作由:
(*pbase2->vptr[1])(pbase2); 改变为: (*pbase2->vptr[1].faddr) (pbase2 + pbase2->vptr[1].offset);
这个方法的缺点就是连带惩罚了所有的 virtual function 调用操作,不管他们是否需要 offset 的调整. 所说的处罚包括 offset 的额外存取及加法, 以及每个 virtual table slot 大小的改变.
比较有效率的方法是利用所谓的 thunk.Thunk 技术就是一小段 assembly 码(汇编语言), 用来:
1. 以适当的 offset 值调整 this 指针
2. 跳到 virtual function 去
例如, 经由一个Base2 指针调用 Derived destructor, 其相关的 tunk 可能看起来是这个样子:
//虚拟 C++ 码 pbase2_dtor_thunk: this += sizeof(basel); Derived::~Derived(this);
本贾尼并不是不知道 thunk 技术, 问题是 thunk 只有以 assembly 码完成才有效率可言, 由于 cfront 使用 C 作为其程序代码产生语言, 所以无法提供一个有效率的 tunk 编译器.
Thunk 技术允许 virtual table slot 继续内含一个简单的指针, 因此多重继承不需要任何空间上的额外负担, slot 中的地址可以直接指向 virtual function, 也可以指向一个相关的thunk(如果需要调整 this指针的话). 于是, 对于那些不需要调整 this 指针的virtual function 而言, 也就不需要承载效率上的额外负担.
调整 this 指针的第二个额外负担就是, 由于两种不同的可能:
1. 经由 derived class 或第一个 base class 调用
2. 经由第二个或后继的 base class 调用
同一函数在 virtual table 可能需要多笔对应的 slots, 例如:
Base1 *pbase1 = new Derived; Base2 *pbase2 = new Derived; delete pbase1; delete pbase2;
虽然两个 delete 操作导致相同的 Derived destructor, 但他们需要不同的 virtual table slots:
1. pbase1 不需要调整 this 指针(因为 Base1 是最左端的 base class 之故, 它已指向 Derived 对象的起始处, 可见先进门三天就是大). 其 virtual table slot 需放置真正的 destructor 地址.
2. pbase2 需要调整 this 指针. 其 virtual table slot 需要相关的 thunk 地址.
在多重继承下, 一个 derived class 内含 n - 1 个额外的 virtual tables, n 表示其上一层 base classes的数目(所以单一继承不会有额外的 virtual tables). 对于本例而言, 会有两个 virtual tables 被编译器产生出来:
1. 一个主要实体, 与 Base1(最左端 base class 正室) 共享.
2. 一个次要实体, 与 Bsae2(第二个 base class 侧室) 有关.
针对每一个 virtual tables, Derived 对象中有对应的 vptr. 下图说明了这一点, vptrs 将在 constructor 中被设立初值(经由编译器所产生的码).
用来支持一个class 拥有多个 virtual tables 的传统方法是, 将每一个 tables 以外部对象的形式产生出来并给与独一无二的名称, 例如 Derived 所关联的两个 tables 可能有这样的名称:
vtbl__Derived; //主要表格 vtbl__Base2__Derived; //次要表格
于是当你将一个Derived 对象地址指定给一个 Base1 指针给一个 Base1 指针或 Derived 指针时, 被处理的 virtual table 是主要表格, 而当你将一个 Derived 对象地址指定给一个 Base2 指针时, 被处理的 virtual table 是次要表格 vtbl__Base2_Derived.
之前的博客中曾说过, 有三种情况, 第二或后继的 base class 会影响对 virtual function 的支持.
1. 第一种情况是通过一个指向第二个 base class 的指针, 调用 derived class virtual function如:
Base2 *ptr = new Derived; //调用 Derived::~Derived //ptr 必须被向后调整sizeof(Base1) 个 bytes delete ptr;
从上图之中可以看到这个调用操作的重点: ptr 指向 Derived 对象中的 Base2 subobject; 为了能够正确执行, ptr 必须调整指向 Derived 对象的起始处.
2. 第二种情况是第一种情况的变化, 通过一个指向 derived class 的指针, 调用第二个 base class 中继承来的 virtual function. 在此情况之下, derived class 指针必须再次调整, 以指向第二个 base subobject, 如:
Derived *pder = new Derived; //调用 Base2::Mumble() //pder 必须被向前调整 sizeof(Base1) 个 bytes pder->Mumble();
3. 第三种情况发生在一个语言的扩充性质之下: 允许一个 virtual function 的返回值类型有所变化, 可能是 base type,也可能是 publicly derived type. 这一点可以通过 Derived::Clone()函数实体来说明. Clone 函数的 Derived 版本传回一个 Derived clas 指针, 默默地改写了它的两个 base class 函数实体. 当我们通过指向第二个 base class 的指针来调用 Clone() 时, this 指针的 offset 问题于是产生:
Base2 *pb2_1 = new Derived; //调用 Derived* Derived::Clone() //返回值必须被调整, 以指向 Base2 subobject Base2 *pb2_2 = pb2_1->Clone();
当进行 pb1->Clone() 时, pb2_1 会被调整指向 Derived 对象的起始地址,于是 Clone() 的 Derived 版会被调用: 它会传回一个指针, 指向新的 Derived 对象; 该对象在被指定给 pb2_2 之前, 必须先经过调整,以指向 Base2 subobject.
Microsoft 以所谓的 address points 来取代 thunk 策略: 将用来改写别人的那个函数(说人话就是 overriding function) 期待获得的是引入该 virtual function 的 class (而非 derived class) 的地址. 这就是该函数的 address point.