Function语义学(二)
前言:这一小节还是将的有点抽象的,我在后面的多继承那里卡了挺久的,有一些涉及到内嵌汇编的小东西,哈哈哈。
Virtual Member Function 虚拟成员函数
virtual function 实现的一般模型:每一个 class 有一个 virtual table, 内含该 class 之中有作用的 virtual function 的地址,然后每一个 object 有一个 vptr,指向 virtual table 所在。
为了支持 virtual function 机制,必须首先能够对于多态对象有某种形式的 “ 执行期类型判断法(runtime type resolution)”。也就是说以下调用操作将需要 ptr
在执行期间的某些信息。
ptr->z()
这样一来,才能够找到并调用z()
的适当实例。
最直接的但是成本最高的解决方法就是把必要的信息加在ptr
身上。在这样的策略之下,一个指针(或一个引用)持有两项信息:
- 它所考虑的对象的地址,(也就是目前它所持有的东西)
- 对象类型的某种编码,或是某个结构(内含某些信息,用以正确决议出
z()
函数实例的地址。)就是说标识一个对象的类型,type_info。
这个方法带来了两个问题,第一是,它明显地增加了空间负担,即使程序并不适用多态(polymorphism);第二,它打断了与C程序间的链接兼容性。(为什么说打破了和C程序间的链接兼容性呢?我大胆地猜测一下:c编译器在进行编译的时候,会将对结构体成员属性的访问改写为该结构体对象的起始地址加上一个地址偏移量,并不需要额外的其他的信息,因为C语言并没有复杂的面向对象特性。)
所以,我们需要一个更好的规范。
在C++中,多态(polymorphism)表示 “以一个 public base class 的指针或引用,寻址出一个 derived class object ” 的意思。
**多态的技能主要扮演一个传输机制(transport mechanism)的角色,经由它,我们可以在程序的任何地方采用一组public derived
类型。这种多态形式被称为是消极的(passive),可以在编译时期完成–virtual base class 的情况除外。**我们也可以这样来理解这句话:首先,消极多态就是指一个基类指针只是指向了一个派生类对象,但是并没有使用这个指针;传输机制也很容易理解,我们可以把基类对象看成是一个池子(里面存放着字节数据),但是只有一种型号的管道可以适配这个池子,这时候,基类的指针或引用就相当于个很大的管道,它可以将那个指定的类型的管子放在自己的管子里,然后就能从池子中读到这个对象的字节数据了。
当被指出的对象真正被使用时,多态也就变成了积极的(active)的了。RTTI
是C++对于“积极多态”(active polymorphism)的唯一支持。我们在后面会讲到。
现在问题已经很明显了:想要鉴定那些 classes 展现多态特性,我们需要额外的执行期信息。由于 class 和 struct 并不能够帮助我们,而且也没有导入新的关键字,所以识别一个 class 是否支持多态,唯一适当的方法就是看看它是否有任何 virtual function。只要 class 拥有一个 virtual function,它就需要这份额外信息。
ptr->z();
还是这个例子,那需要什么样的额外信息呢?
- ptr 所指向的真实类型。这可使我们选择正确的 z() 实例。
- z() 实例的位置,以便我们能够调用它。
这时候,就能够想到我们之前了解到的 virtual function table 了。
在C++中,virtual functions(可经由其 class object 调用)可以在编译时期获知。此外,这一组地址是固定不变的,执行期间不可能新增或替换。由于程序执行时,virtual function table 的大小和内容都不会改变,所以其构建和存取都可以由编译器完全掌握,不需要执行期的任何介入。
一个 class 只会有一个 virtual table。每一个 table 内含其对应的 class object 中所有 active virtual function 函数实例的地址。这些 active virtual functions 包括:
- 这一 class 所定义的函数实例。它会改写(overriding)一个可能存在的 base virtual function 函数实例。
- 继承自 base class 的函数实例。这是在 derived class 决定不改写 virtual function 时才会出现的情况。
- 一个 pure_virtual_called() 函数实例,它既可以扮演 pure virtual function 的空间保卫角色,也可以当作执行期异常处理函数。(这个我没有用过)
现在我们再来看这个例子
ptr->z();
// 由于有virtual function table 的存在,我们可以直到z()实例对应的地址,即使我们不知道ptr具体的
// 指向是什么
// 所以编译器能够进行一下转换
(*ptr->vptr[4])(ptr);
// ptr的类型需要在运行是才能够知晓
看完了简单的,我们进入正题
//---------------------------------------------
// 多重继承下的 virtual funtion
//------------------------------------------
在多重继承中支持 virtual functions,其复杂度围绕在第二个及后续的 base classes 身上,以及 “必须在执行期调整this指针”这一点。
直接抛出我们的例子
class Base1 {
public:
Base1();
virtual ~Base1();
virtual void speckClearly();
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
身上。我们有三个问题需要解决
- virtual destructor
- 被继承下来的
Base2::mumble()
- 一组 clone() 实例。
// 先通过Base2指针获得一个Derived实例
Base2 *pbase2 = new Derived();
// 编译器内部帮我们做的事情
// 转移以支持第二个base Class
Derived *temp = new Derived();
Base2 *pbase2 = temp ? temp + sizeof(Base1) : 0;
// 当我们需要释放pbase2所指向的内存时
delete pbase2;
// 这个操作需要延迟至执行期才能确定
// 因为 pbase2 不知道他指向的具体类型是什么,
// 不能在编译期确定他指向的对象的起始地址
**一般规则是:经由指向 “第二或后继的 base class” 的指针或引用来调用 derived class virtual function,其所连带的必要的 this 指针的调整操作,必须在执行期完成。**也就是说,offset的大小,以及把 offset 的加到 this 指针上头的那一段程序代码,必须由编译器在某个地方插入。
在什么地方插入呢?书上介绍了两种方案:
-
将 virtual function table 增大,使它能够容纳此处所需的 this 指针,调整相关事物。这时候,每一个 virtual function table slot,不再只是一个指针,而是一个集合体,内含可能的 offset 以及地址。
delete pbase2; // 这时候将变成 (*pbase2->vprt[1].faddr)(pbase2 + pbase2->vprt[1].offset);
这个做法的缺点很明显,它改变了 virtual function table 的结构,所有的 virtual function 调用操作,不管它们是否需要 offset 调整。并且每次调用 virtual function 我们都需要进行一次对 offset 的存取和加法运算。
-
thunk技术(内嵌汇编)
所谓的thunk是一段小小的汇编代码(assemble),用来以适当的 offset 值调整 this 指针,跳到 virtual function 去。但是thunl技术只有通过汇编语言来实现才有效率可言。
Thunk技术允许 virtual table slot 继续内含一个简单的指针,因此多重继承不需要任何空间上的额外负担。Slot 中的地址可以直接指向 virtual function,也可以指向一个相关的 thunk(如果 this 指针需要调整的话)。
通过 thunk技术来调整 this 指针还有一个额外的负担:由于两种不同的可能:经由 derived class 或第一个 base class 调用,经由第二个(或后继)base class 调用,同一个函数在 virtual table 中可能需要多笔对应的 slots。
Base1 *pbase1 = new Derived; Base2 *pbase2 = new Derived; delete pbase1; delete pbase2;
上面的例子中,虽然两个操作将导致相同的 virtual function,但是它们需要两个不同的 virtual table slot:
pbase1
不需要调整this指针。其 virtual table slot 需放置真正的 destructor 地址。pbase2
需要调整 this 指针。其 virtual table slot 需要相关的 thunk 地址。
在多重继承之下,一个 derived class 内含 n-1 个额外的virtual tables,n 表示 其上一层 base class 的个数。
所以对于上面的继承体系,将会产生两个 virtual function table。一个主要实例,与
Base1
共享。一个次要实例,与Base2
有关。所以当你将一个Derived
对象指定给一个Base2
的指针时,被处理的是有关Base2
的virtual function table,而将Derived
对象指定给一个Base1
对应的指针时,被处理的 virtual table 时主要的virtual function table 实例,共享的那个。由于执行期链接器(runtime linkers)的降临,符号名称的链接可能变得非常缓慢。为了调节执行期链接器的效率,Sum编译器将多个 virtual function table 连锁为一个,指向次要表格的指针,可由指向主要表格的指针获得。
下面先看看在这个继承体系下,各个类的布局
现在,我们之前提到的三个问题,已经解决了两个了,还剩最后一个,Derived
对象是如何调用clone()
的。书上把这种情况总结为:第二个或后继的 base class 会影响对 virtual functions 的支持的第三种情况。
这种情况发生在一个语言扩充性质之下:允许一个 virtual function 的返回值类型有所变化:可能是 base type,也可能是 publicly derived type。从上面的图也能够看出:clone()函数的 Derived 版本传回了一个 Derived class 指针,默默改写了它的两个 base class 的函数实例。
当我们通过“指向第二个 base class” 的指针来调用 clone() 时,this指针的 offset 问题就产生了:
Base2 *pd1 = new Derived;
// 调用Derived *Derived::clone()
// 返回值必须被调整,以指向 Base2 suboject
Base2 *pb2 = pb1->clone();
当进行 pb1->clone()
时,pb1
会被调整指向Derived
对象的起始地址,于是clone()的Derived版会被调用;它会传回一个指针,指向一个新的Derived对象;该对象的地址在被指定之前,必须先经过调整,以指向Base2 subobject
。
这一个小结最后还介绍了Sum编译器的split functions
机制和Microsoft的address points
机制,感兴趣的兄弟们自行了解。
-
虚拟继承下的 virtual function
经过上面的洗礼,这一节相对比较简单,这里举个例字,很容易理解。
class Point2d { public: Point2d( float = 0.0, float = 0.0); virtual ~Point2d(); virtual void mumble(); virtual float z(); protected: float _x, _y; } class Point3d : public virtual Point2d { public: Point3d( float = 0.0, float = 0.0, float = 0.0 ); virtual ~Point3d(); float z(); protected: float _z; }
作者的建议:不要在一个 virtual base class 中声明 nonstatic data members。
ok。finish!