C++对象模型——Virtual Member Functions (虚拟成员函数)(第四章)

来源:互联网 发布:淘宝助理和千牛 编辑:程序博客网 时间:2024/06/06 04:35

4.2 Virtual Member Functions (虚拟成员函数)

     已经看过了 virtual function的一般实现模型:每一个 class 有一个 virtual table,内含该 class 中有作用的 virtual function的地址,然后每个object有一个vptr,指向 virtual table的所在.
    为了支持 virtual function机制,必须首先能够对多态对象有某种形式的"执行期类型判断法(runtime type resolution)".也就是说,以下的函数调用操作将需要ptr在执行期的某些相关信息:
ptr->z();
    如此一来才能够找到并调用z()的适当实体.
    或许最直接了当但是成本最高的解决办法就是把必要的信息加在ptr上.在这样的策略下,一个指针(或者是一个reference)含有两项信息:
    1. 它所参考到的对象的地址(也就是当前它所含有的东西);
    2. 对象类型的某种编码,或是某种结构(内含某些信息,用以正确决议出z()函数实例)的地址
     这个方法带来两个问题,第一,它明显增加了空间负担,即使程序并不使用多态(polymorphism);第二,它打断了与C程序间的链接兼容性.
     如果这份额外信息不能够和指针放在一起,下一个可以考虑的地方就是把它放在对象本身.但是哪一个对象真正需要这些信息呢?应该把这些信息放进可能被继承的每一个聚合体上吗?或许把!但考虑以下这样的C struct 声明:
struct date {int m, d, y;};
严格地说,这符合上述规范,然而事实上它并不需要那些信息,加上那些信息将使C struct 膨胀并且打破链接兼容性,却没有带来任何明显的补偿利益.
     或许只有面对那些明确使用了 class 关键字的声明,才应该加上额外的执行期信息,这么做就可以保留语言的兼容性了.不过仍然不是一个聪明的策略.举个例子,下面的 class 符合新规范:
class date {public:int m, d, y;};
但实际上它并不需要那份信息,下面的 class 声明虽然不符合新规范,却需要那份信息:
struct geom {public:virtual ~geom();};
因此,需要一个更好的规范,一个"以class的使用为基础,而不在乎关键词是class或struct(1.2节)"的规范.如果 class 真正需要那份信息,它就会存在;如果不需要,它就不存在.那么,到底何时才需要这份信息?很明显是在必须支持某种形式的"执行期多态(runtime polymorphism)"的时候.
     在C++中,多态(polymorphism)表示"以一个public base class的指针(或reference),寻址出一个derived object"的意思.例如下面的声明:
Point *ptr;
    可以指定ptr以寻址出一个Point2d对象:
ptr = new Point2d;
    或是一个Point3d对象:
ptr = new Point3d;
    ptr的多态性能主要扮演一个输送机制(transport mechanism)的角色,经由它,可以在程序的任何地方采用一组 public derived类型.这种多态形式被称为是消极的(passive),可以在编译时期完成——virtual base class 的情况除外.
    当被指出的对象真正被使用时,多态就变成积极的(active).下面对于 virtual function的调用,就是一例子:
// "积极多态(active polymorphism)"的常见例子ptr->z();
在runtime type identification(RTTI)性质于1993年被引入C++语言之前,C++对"积极多态(active polymorphism)"的唯一支持,就是对于 virtual function call的决议(resolution)操作.有了RTTI,就能够在执行期间查询一个多态的pointer或多态的reference.
// "积极多态(active polymorphism)"的第二个例子if (Point3d *p3d = dynamic_cast<Point3d *>(ptr))return p3d->_z;
所以,问题已经被区分出来,那就是:欲鉴定哪些 class 展现多态特性,需要额外的执行期信息.关键词 class 和 struct 并不能够帮助这点.由于没有导入如polymorphism之类的新关键词,因此识别一个 class 是否支持多态,唯一适当的方法就是看看它是否有任何 virtual function.只要 class 拥有一个 virtual function,它就需要这份额外的执行期信息.
     下一个明显的问题是,什么样的额外信息是需要存储的?也就是说,如果有这样的调用:
ptr->z();
    其中z()是一个 virtual function,那么什么信息才能使得在执行期调用正确的z()实体?需要知道:
    ptr所指对象的真实类型,这可使的选择正确的z()实体
    z()实体位置,以便能够调用它


    在实现上,首先在每一个多态的 class object上增加两个members:
    1.一个字符串或数字,表示 class 的类型.
    2. 一个指针,指向某表格,表格中带有程序的 virtual functions的执行期地址.


    表格中的 virtual functions地址如何被建构起来?在C++中, virtual functions(可经由其 class object被调用)可以在编译时期获知,此外,这一组地址是固定不变的,执行期不可能新增或替换.由于程序执行时,表格的大小和内容都不会改变,所以其建构和存取皆可以由编译器完全掌控,不需要执行期的任何介入.
    然而,执行期备妥那些函数地址,只是解答的一半而已,另一半解答是找到那些地址,以下两个步骤可以完成这项任务:
    1.为了找到表格,每一个 class object被插入一个由编译器内部产生的指针,指向该表格.
    2. 为了找到函数地址,每一个 virtual function被指派一个表格索引值.


    这些工作都由编译器完成,执行期要做的,只是在特定的 virtual table slot(记录着 virtual function的地址)中激活 virtual function.
    一个 class 只会有一个 virtual table.每一个table内含其对应的 class object中所有的active virtual functions函数实体的地址,这些active virtual functions包括:
    这个 class 所定义的函数实体,它会改写(overriding)一个可能存在的base class virtual function函数实体.
    继承自base class 的函数实体,这是在derived class 决定不改写 virtual function时才会出现的情况
    一个pure_virtual_called()函数实体,它既可以扮演pure virtual function的空间保卫者角色,也可以当作执行期异常处理函数


    每一个 virtual function都被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的 virtual function的关联.例如在Point class 体系中:
class Point {public:virtual ~Point();virtual Point &mult(float) = 0;float x() const { return _x; }virtual float y() const { return 0; }virtual float z() const { return 0; }protected:Point(float x = 0.0);float _x;};
virtual destructor被赋值slot 1,而mult()被赋值slot 2,此例并没有mult()的函数定义(因为它是一个pure virtual function),所以pure_virtual_called()的函数地址会被放在slot 2中.如果该函数意外地被调用,通常的操作是结束掉这个程序,y()被赋值slot 3而z()被赋值slot 4,x()的slot是多少?答案是没有,因为x()并非 virtual function.

    当一个 class 派生自Point时,会发生什么事情?例如 class Point2d:
class Point2d : public Point {public:Point2d(float x = 0.0, float y = 0.0) : Point(x), _y(y) {}~Point2d();// 改写base class virtual functionsPoint2d& mult(float);float y() const { return _y; }protected:float _y;};
一共有三种可能性:
    1.它可以继承base class 所声明的 virtual functions的函数实体,正确地说,是该函数实体的地址会被拷贝到derived class 的 virtual table相对应的slot中.
    2.它可以使用自己的函数实体,这表示它自己的函数实体地址必须放在对应的slot中
    3.它可以加入一个新的 virtual function.这时候 virtual table的尺寸会增大一个slot,新的函数实体地址会放进该slot中.

    Point2d的 virtual table在slot 1中指出的destructor,而在slot 2中指出mult()(取代pure virtual function).它自己的y()函数实体地址放在slot 3,继承自Point的z()函数实体地址则放在slot 4.
    类似的情况,Point3d派生自Point2d,如下:
class Point3d : public Point2d {public:Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), _z(z) {}~Point3d();// 改写的base class virtual functionsPoint3d& mult(float);float z() const { return _z; }protected:float _z;};
其中 virtual table中的slot 1放置Point3d的destructor,slot 2放置Point3d::mult()函数地址,slot 3放置继承自Point2的y()函数地址,slot 4放置它自己的z()函数地址.

     如下图4.1所示(取自Inside The C++ Object Model的原图):


图4.1 virtual table的布局:单一继承情况
现在,有这样的式子:
ptr->z();
    那么,如何有足够的知识在编译时期设定 virtual function的调用呢?
    一般而言,并不知道ptr所指对象的真正类型,然而知道,经由ptr可以存取到该对象的 virtual table.
    虽然不知道哪一个z()函数实体会被调用,但知道每一个z()函数地址都被放在slot 4.
    这些信息使得编译器可以将该调用转化为:
(*ptr->vptr[4])(ptr);
    在这个转化中,vptr表示编译器所插入的指针,指向 virtual table;4表示z()被赋值的slot编号(关联到Point体系的 virtual tabl).唯一一个在执行期才能直到的东西是:slot 4所指的到底是哪一个z()函数实体?
    在一个单一继承体系中,virtual function机制的行为十分良好,不但有效率而且很容易被塑造出模型,但在多重继承和虚拟继承中,对 virtual functions的支持就没有那么美好了.

多重继承下的Virtual Functions

    在多重继承中支持的 virtual functions,其复杂度围绕在第二个以及后继的base classes上,以及"必须在执行期调整this指针"这一点,以下面的 class 体系为例:
// class体系,用来描述多重继承(MI)情况下支持 virtual function时间的复杂度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;
    新的Derived对象的地址必须调整,以指向其Base2 subobject,编译时期会产生以下的代码:
// 转移以支持第二个base classDerived *temp = new Derived;Base2 *pbase = temp ? temp + sizeof(Base1) : 0;
如果没有这样的调整,指针的任何"非多态运用"都将失败:
// 即使pbase2被指定一个Derived对象,这也应该没有问题pbase2->data_Base2;
当程序员要删除pbase2所指的对象时:
// 必须首先调用正确的virtual destructor函数实体// 然后施行delete运算符// pbase2可能需要调整,以指出完整对象的起始点delete pbase2;
指针必须被再一次调整,以求再一次指向Derived对象的起始处(推测它还指出Derived对象).然而上述的offset加法却不能够在编译时期直接设定,因为pbase2所指向的真正对象只有在执行期才能确定.
    一般规则是,经由指向"第二或后继的base class"的指针(或reference)来调用derived class virtual function.
Base2 *pbase2 = new Derived;// ...delete pbase2;
该调用操作所连带的"必要的this指针调整"操作,必须在执行期完成.也就是说,offset的大小,以及把offset加到 this 指针上头的那一小段代码,必须由编译器在某个地方插入.问题是,在哪个地方?
    Bjarne原先实施于cfront编译器中的方法是将 virtual table加大,使它容纳此处所需的 this 指针,调整相关事物.每一个 virtual table slot,不再只是一个指针,而是一个聚合体,内含可能的offset以及地址,于是 virtual function的调用操作由:
(*pbase2->vptr[1])(pbase2);
改变为:
(*pbase2->vptr[1].faddr)(pbase2 + pbase2->vptr[1].offset);
    其中faddr内含 virtual function地址,offset内含 this 指针调整值.
    这样做法的缺点是,它相当于连带处罚了所有的 virtual function调用操作,不管它们是否需要offset的调整.所谓的处罚,包括offset的额外存取以及其加法,以及每一个 virtual table slot的大小改变.
    调整 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).对于本例的Dervied而言,会有两个 virtual tables被编译器产生出来:
   1. 一个主要实体,与Base1(最左端base class)共享.
    2. 一个次要实体,与Base2(第二个base class)有关.
针对每一个 virtual tables,Derived对象中有对应的vptr,图4.2(书上的原图4.2)说明了这一点.如下所示,vptrs将在constructor中被设置初值.
 
图4.2 virtual table的布局:多重继承情况
    用以支持"一个class拥有多个virtual table"的传统方法是,将每一个tables以外部对象的形式产生出来,并给及独一无二的名称.例如,Derived所关联的两个tables可能有这样的名称:
vtbl_Derived;// 主要表格vtbl_Base2_Derived// 次要表格
于是将一个Derived对象地址指定给一个Base1指针或Derived指针时,被处理的 virtual table是主要表格vtbl_Derived,而当将一个Derived对象地址指定给一个Base2指针时,被处理的 virtual table是次要表格vtbl_Base2_Derived.
    由于执行期链接器(runtime linkers)的降临(可以支持动态共享函数库),符号名称的链接可能变得非常缓慢.为了调节执行期链接器的效率,Sun编译器将多个 virtual tabls连锁为一个:指向次要表格的指针,可由主要表格名称加上一个offset获得.在这样的策略下,每一个 class 只有一个具名的 virtual table.
    有三种情况,第二个或后继的base class 会影响到 virtual functions的支持,第一种情况是,通过一个"指向第二个base class"的指针,调用derived class virtual function.例如:
Base2 *ptr = new Derived;// 调用Derived::~Derived// ptr必须被向后调整sizeof(Base1)个bytesdelete ptr;
这个调用操作的重点:ptr指向Derived对象中的Base2 subobject:为了能够正确执行,ptr必须调整指向Derived对象的起始处.
    第二种情况是第一种情况的变化,通过一个"指向derived class"的指针,调用第二个base class 中一个继承而来的 virtual function.在此情况下,derived class 指针必须再次调整,以指向第二个base subobject.例如:
Derived *pder = new Derived;// 调用Base2::mumble()// pder必须被向前调整sizeof(Base1)个bytespder->mumble();
第三种情况发生于一个语言扩充性质下:允许一个 virtual function的返回类型有所变化,可能是base type,也可能是publicly derived type,这一点可以通过Derived clone()函数实体来说明.clone()函数的Derived版本传回一个Derived class 指针,默默地改写了它的两个base class 函数实体.当通过"指向第二个base class"的指针来调用clone()时,this 指针的offset问题于是诞生:
Base2 *pb1 = new Derived;// 调用Derived *Derived::clone()// 返回值必须被调整,以指向Base2 subobjectBase2 *pb2 = pb1->clone();
当进行pb1->clone()时,pb1会被调整指向Derived对象的起始地址,于是clone()的Derived版会被调用,它会传回一个指针,指向一个新的Derived对象;该对象的地址在被指定给pb2之前,必须先经过调整,以指向Base2 subobject.
    当函数被认为"足够小"的时候,Sun编译器会提供一个所谓的"split functions"技术:以相同算法产生出两个函数,其中第二个返回之前,为指针加上必要的offset,于是不论通过Base1指针或Derived指针调用函数,都不需要调整返回值;而通过Base2指针所调用的,是另一个函数.
    如果函数并不小,"split function"策略会给予此函数中的多个进入点(entry points)中的一个.每一个进入点需要三个指令,但Mike Ball想办法去除了这项成本,对于OO没有经验的程序员,可能会怀疑这种"split function"的应用性,然而OO程序员都会尽量使用小规模的 virtual function将操作"局部化",通常 virtual function的平均大小是8行.
    函数如果支持多重进入点,就可以不必有许多"thunks".如IBM就是把thunk搂抱在真正被调用的 virtual function中.函数一开始先(1)调整 this 指针,然后才(2)执行程序员所写的函数码;至于不需要调整的函数调用操作,就直接进入(2)的部分.
    Microsoft以所谓的"address points"来取代thunk策略,即将用来改写别人的那个函数(也就是overriding function)期待获得的是"引入该virtual function的class"(而非derived class)的地址,这就是该函数的"address point".

虚拟继承下的Virtual Functions

    考虑下面的 virtual base class 派生体系,从Point2d派生出Point3d:
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);~Point3d();float z();protected:float _z;};
虽然Point3d有唯一一个(同时也是最左边)的base class,也就是Point2d,但Point3d和Point2d的起始部分并不像"非虚拟的单一继承"情况那样一致.这种情况显示于图4.3(如下图所示).由于Point2d和Point3d的对象不再相符,两者之间的转换也需要调整 this 指针.至于在虚拟继承的情况下要清除thunks,一般而言已经被证明是一项高难度技术.

图4.3 virtual table布局:虚拟继承情况
    当一个 virtual base class 从另一个 virtual base class 派生而来,并且两者都支持 virtual functions和nonstatic data members时,编译器对于 virtual base class 的支持非常复杂.最好,不要在一个 virtual base class 中声明nonstatic data members.


0 0