More Effective C++ 条款24 了解virtual function,multiple inheritance,virtual base classes,runtime type id

1. 要实现C++的每一个语言特性,不同的编译器可能采取不同的方法,其中某些特性(如标题所列)的实现可能会对对象的大小和其member functions的执行速度带来冲击.

2. 虚函数.

    当通过对象指针或引用调用虚函数时,具体调用哪一个虚函数由指针或引用的动态类型决定,大部分编译器使用vtbls(virtual tables,虚函数表)和vptrs(virtual table pointers,虚函数表指针)来实现这一点.

(1)对象的静态类型:对象在声明时采用的类型。是在编译期确定的。
(2)对象的动态类型:目前所指对象的类型。是在运行期决定的。

    vtbl通常是一个函数指针的数组或链表(vtbl是一个数组,元素是指向虚函数的函数指针),每一个声明或继承虚函数的类都有自己的vtbl,其中的每一个元素就是该类的各个虚函数的指针.假如以下类:

class C1{
public:
    C1();
    virtual ~C();
    virtual void f1();
    virtual int f2(char) const;
    virtual void f3(const string&);
    void f4() const;
    ...
}

  C1的vtbl看起来像这样:

(注意,非虚函数f4和C1 constructor并不在表格中!)

constructor必定不是虚函数:原因见

 如果C2继承C1,并重新定义部分虚函数,即:

class C2: public C1 {
public:
    C2();
    virtual ~C2(); //重新定义的虚函数
    virtual void f1();//重新定义的虚函数
    virtual void f5(char *str); //新的虚函数
    ...
};

 那么C2的vtbl看起来像这样:

从以上示例可以看出 虚函数的第一个成本 : 必须为每一个拥有虚函数的类消耗一块vtbl空间,用于存储虚函数指针.

 (虚表是属于类的,不是属于类的某个对象) 由于每个类只有一个vtbl,这就产生了第二个问题:由于C++支持多文件编译,而每一个目标文件都是独立生成的,因而类的vtbl放在哪一个目标文件就需要选择.编译器厂商倾向于两种阵营:一种暴力式做法就是在每一个需要vtbl的目标文件内都产生一个vtbl副本,最后由链接器剥除重复副本,使最终的可执行文件或程序库内,只留下每个vtbl的单一实体;另一种更常见的勘探式做法将vtbl产生于"内含其第一个non-inline,non-pure虚函数定义式"的目标文件中.因此,先前class C1,C2的vtbl应该分别放在内含C1::~C1和C2::~C2定义式的目标文件中.这中勘探式做法要求类必须有一个non-inline的虚函数,如果所有函数都声明为inline,这种做法会失败,那么大部分编译器会在每一个"使用了class's vtbl"的目标文件中,在大型系统中,由此造成的代码和内存膨胀是十分可观的.因此,要尽量避免将虚函数声明为inline,事实上,编译器通常会忽略虚函数的inline指示.

        由于每个对象都需要对应其所属类型的vtbl,以便在通过指针或引用调用虚函数时实现动态绑定,这就导致了虚函数的第二个成本:每个对象都内含一vptr,用于指向该类的vtbl(在每一个拥有虚函数的对象内付出“一个额外指针的代价”).

    凡声明有虚函数的类,其对象内都含有一个隐藏的data member:vptr,指向类的vtbl,被编译器加入对象内某个唯编译器才知道的位置,不同编译器对此实现不同,如果vptr加入到对象尾端(以下都假设vptr加入到尾端),那么对象构造看起来像这样:

为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

    4字节的vptr导致的对象大小膨胀所产生的影响可大可小(与对象大小和运行平台等相关),但较大的对象往往意味着较难塞入一个缓存分页(cache page)或虚内存分页(virtual memory page),也就意味着换页(paging)活动可能会增加.

    综合上述,object和vtbl的关系可能像这样

 现考虑这样的程序片段:

void makeCall(C1 *pC1){
    pC1->f1();
}

    由于指针pC1所指对象的动态类型不确定,因而f1的实体是哪一个也就不确定,但编译器仍然要为f1的调用操作产生可执行代码,完成以下动作:

  1). 根据对象的vptr找到其vtbl.编译器知道到对象的哪里找vptr,此动作成本只有一个偏移调整(offset adjustment,以便获得vptr)和一个指针间接动作(以便获得vtbl).

    2). 找出被调用函数(本例为f1)在vtbl内的对应指针.其成本只是一个偏移(offset)以求进入vtbl数组.

    3). 调用步骤2所得指针所指向的函数.

    因此以上对于f1的调用操作,编译器产生的代码可能像这样:

(*(pC1->vptr)[i])(pC1); //i为f1在vtbl中的下标。调用pc1->vptr所指的vtbl中的第i个条目所指的函数。pc1被传给该函数作为“this”指针使用

 这几乎与一个非虚函数的效率相当.因此虚函数的调用成本基本上和"通过一个函数指针来调用函数"相同,虚函数本身并不构成性能上的瓶颈.

虚函数真正的运行成本在于和inlining发生互动的时候,原则上不应该对虚函数实行inline:inline意味着"在编译期,将调用端的调用动作被被调用函数的函数本体取代",而virtual则意味着"等待,直到运行时期才知道哪个函数被调用".当编译器面对某个调用动作却无法提前得知哪一个函数该被调用时,就没有能力对该函数实现inline了.因此通过指针或引用的虚函数调用是无法被inline的,由于此等调用行为是常态,所以虚函数事实上等于无法被inline.这是虚函数的第三个成本。

         多重继承下,"找出对象内的vptrs"会变得比较复杂:此时一个对象内会有多个vptrs(每个base class各对应一个);除了之前所讨论的vtbl,针对base classes而形成的特殊vtbl也会被产生出来.结果虚函数对每一个object和每一个class造成的空间负担又增加了一些,运行期调用成本也有所成长.

        多重继承往往导致virtual base classes(虚拟基类)的需求.在non-virtual base的情况下,如果派生类对于基类有多条继承路径,那么派生类会有不止一个基类部分,让基类为virtual可以消除这样的复制现象.然而虚基类也可能导致另一成本:其实现做法常常利用指针,指向"virtual base class"部分,因此对象内可能出现一个(或多个)这样的指针.

例如以下多重继承"菱形"结构:

class A{...};
class B:virtual public A{...};
class C:virtual public A{...};
class D:pulic B,public C{...};

A是个虚基类,B和C都采用虚继承,在某些编译器下,D对象的内存布局可能如下:

这是在没有虚函数参与的情况下,如果A类有虚函数,那么D的布局类似这样:

上图一个奇怪之处在于明明有4个类,却只有三个vptr,原因在于B和D可共享同一个vptr,大多数编译器会采取此策略,降低编译器所带来的额外开销。

4. RTTI(runtime type identification,运行时类型识别)

      RTTI即运行时类型识别,c++通过RTTI实现对多态的支持。
  c++是静态类型语言,其数据类型是在编译期就确定的,不能在运行时更改。

  为了支持RTTI,C++提供了一个type_info类和两个关键字typeiddynamic_cast

        typeid表达式形如:typeid(expr);
    typeid返回type_info类型,expr可以是各种类型名,对象和内置基本数据类型的实例、指针或者引用,当作用于指针和引用将返回它实际指向对象的类型信息。如果表达式的类型是类类型且至少包含有一个虚函数,则typeid操作符返回表达式的动态类型,需要在运行时计算;否则,typeid操作符返回表达式的静态类型,在编译时就可以计算。

    RTTI使得可以在运行时获得objects和classes的相关信息,因此其实现必须需要一些内存来存储那些信息:类型信息用type_info类型的对象存放,可以用typeid操作符取得class对应的type_info对象.

    一个类只需要一份RTTI信息,但必须要使得属于这个类的每个对象都能够取得该信息(只有当某种类型拥有至少一个虚函数,才能保证能够检验该类型对象的动态类型),这和vtbl的要求相同,因此RTTI的的设计理念便是根据class的vtbl来实现.通常在vtbl索引为0的元素存放一指针,用来指向"该vtbl所对应的class"的相应的type_info对象,因此2中的C1的vtbl实际上可能像这样:

    运用这种实现方法,RTTI的运行成本就只需要在每一个class vtbl内增加一个条目,再加上每个class所需的一个type_info对象空间.

    注意,由于多数编译器利用这种方法实现RTTI,因此要某个类要使用RTTI,就必须有vtbl,而要有vtbl,就必须有虚函数,也就是说,没有虚函数的类是无法使用RTTI的,也就无法进行dynamic_cast.

class A{};//类A中有虚函数

class B:public A{};

 A *pA = new B();
     cout<<typeid(pA).name()<<endl; // class A *

     cout<<typeid(*pA).name()<<endl; // class B,输出动态类型

当类中不存在虚函数时,typeid是编译时期的事情,也就是静态类型;当类中存在虚函数时,typeid是运行时期的事情,也就是动态类型。若A中不存在虚函数,则cout<<typeid(*pA).name()<<endl; // class A,输出静态类型

tyoeid(*PA)返回的是type_info对象,调用type_info类的成员函数name()。返回对象的类型名称

5. 以下表格是虚函数,多重继承,虚拟基类和RTTI的主要成本摘要

性质

对象大小增加

Class 数据量增加

Inlining几率降低

虚函数(Virtual Functions)

多重继承(Multiple Inheritance)

虚拟基类(Virtual Base Classes)

常常

有时候

运行时类型识别(RTTI)

     从以上可见,C++要支持面向对象,付出了一定的时间和空间成本,但是也因此实现了更强大易用的功能.如果坚持使用C语言,那么以上功能都必须自己打造.相对于编译器产生的代码,自己打造的东西可能比较没效率,也不够鲁棒性.从效率上来说,自己动手打造未必会比编译器做的更好.当然,有时确实要避免编译器在背后所做的这些工作,比如隐藏的vptr以及"指向virtual base classes"的指针,可能会造成"将C++对象存储于数据库"或"在进程边界间搬移C++对象"时的困难度提高,这时可能就需要手动模拟这些性质.

    对于C++面向对象模型的深入了解可参照《深入探索C++对象模型》.


另外一个例子:

class A {
public:
    virtual void vfunc1();
    virtual void vfunc2();
    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

class B : public A {
public:
    virtual void vfunc1();
    void func1();
private:
    int m_data3;
};

class C: public B {
public:
    virtual void vfunc2();
    void func2();
private:
    int m_data1, m_data4;
};

其对象模型如下图3所示。

这里写图片描述

int main() 
{
    B bObject;
    A *p = & bObject;
    p->vfunc1();
}

程序在执行p->vfunc1()时,会发现p是个指针,且调用的函数是虚函数,接下来便会进行以下的步骤。 

首先,使用typtid返回动态类型即type_info对象,根据虚表指针p->vptr来访问对象bObject对应的虚表。
然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于 p->vfunc1()的调用,B vtbl的第一项即是vfunc1对应的条目。 
最后,根据虚表中找到的函数指针,调用函数。从图3可以看到,B vtbl的第一项指向B::vfunc1(),所以 p->vfunc1()实质会调用B::vfunc1()函数。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值