服务器之家:专注于服务器技术及软件下载分享
分类导航

PHP教程|ASP.NET教程|Java教程|ASP教程|编程技术|正则表达式|C/C++|IOS|C#|Swift|Android|VB|R语言|JavaScript|易语言|vb.net|

服务器之家 - 编程语言 - C/C++ - C++ 多重继承和虚拟继承对象模型、效率分析

C++ 多重继承和虚拟继承对象模型、效率分析

2021-01-28 15:12C++教程网 C/C++

本文简单介绍多态和多重继承、虚拟继承的基本概念。随后重点分析了C++中对象模型之间的差异和运行效率

一、多态

C++多态通过继承和动态绑定实现。继承是一种代码或者功能的传承共享,从语言的角度它是外在的、形式上的,极易理解。而动态绑定则是从语言的底层实现保证了多态的发生——在运行期根据基类指针或者引用指向的真实对象类型确定调用的虚函数功能!通过带有虚函数的单一继承我们可以清楚的理解继承的概念、对象模型的分布机制以及动态绑定的发生,即可以完全彻底地理解多态的思想。为了支持多态,语言实现必须在时间和空间上付出额外的代价(毕竟没有免费的晚餐,更何况编译器是毫无感情):

1、类实现时增加了virtual table,用来存放虚函数地址;
2、类对象中增加了指向虚函数表的指针vptr,以提供runtime的链接;
3、在类继承层次的构造函数中重复设定vptr的初值,以期待指针指向对应类的virtual table;
4、在类继承层次的析构函数中重复还原vptr的初值;
5、多态发生时(base class指针调用虚函数)需要通过vptr和virtual table表调用对应函数实体,增加了 一层间接性。
第1、2两点是多态带来的空间代价,后面三点则是时间效率上的代价。

二、多重继承虚拟继承

多重继承具有多个base class,有别于单一继承(提供了一种“自然多态”形式)。单一继承中,基类和派生类具有相同的内存地址,它们之间的转换十分自然不需要编译器的介入。但如果基类中没有虚函数而派生类中有,单一继承的自然多态被打破。这种情况下,派生类转换为基类需要编译器的介入,用以调整this指针地址。多重继承的对象模型较单一继承复杂,根源在于derived class objects和其第二或后继的base class objects之间的“非自然”关系 ,这一点可以从下面的对象模型中看到。派生类和基类之间的非自然多态引起了一个严重的问题(在虚拟继承中也存在):derived class和第二或后继base class之间的转换(不论是对象间的直接转换或者经由其所支持的virtual function机制做转换)需要调整this指针的地址,以使其指向完整正确的class object 。
虚拟继承是一种机制,类通过虚继承指出它所希望共享虚基类的状态,虚基类在派生层次中只有一份实体。相比多重继承,虚拟继承的难点在于既要识别出相同的对象部分又要维持基类和派生类之间的多态关系 。通常情况下,实现虚拟继承时编译器将对象分割为一个不变局部和一个共享局部 。不变局部中的数据,不管后继如何衍化,总是拥有固定的offset,所以这一部分数据可以被直接存取。至于共享局部,所表现的就是virtual base class subobject。这一部分的数据,其位置会因为每次的派生操作而有变化,所以它们只能被间接存取 。各家编译器实现技术之间的差异在于间接存取方法不同。一般的策略就是先安排好派生类的不变部分,然后建立共享部分。虚拟继承base class和derived class之间非自然的多态关系,它们之间相互转换时需要对this指针地址进行调整。由于对virtual base class的支持,虚拟继承带来了额外的负担和模型复杂性。

三、多重继承和虚拟继承对象模型

造成多重继承和虚拟继承较普通单一继承复杂、效率低的本质在于 对象模型内存分布的差异, 这一点从第二部分分析也可以看到。下面示例对比列出了普通单一继承、多重继承以及虚拟继承的对象模型。需要说明的是:C++标准中并没有强制规定base class members和derived class members之间的次序关系,理论上可以自由安排之,但实际上大多数编译器都会基类成员放在前面,但虚拟继承除外。下面也是这种策略,同时把vptr作为类的第一个成员。

基类Base1、Base2以及派生类DerivedSingle、DerivedMulti类定义如下:

?
1
2
3
4
5
6
7
8
9
class Base1
{
public:
  Base1(void);
  ~Base1(void);
  virtual Base1* clone()const;
protected:
  float data_Base1;
};
?
1
2
3
4
5
6
7
8
9
10
class Base2
{
public:
  Base2(void);
  ~Base2(void);
  virtual void mumble();
  virtual Base2* clone()const;
protected:
  float data_Base2;
};
?
1
2
3
4
5
6
7
8
9
class DerivedSingle: public Base1
{
public:
  DerivedSingle(void);
  virtual ~DerivedSingle(void);
  virtual DerivedSingle* clone() const;
protectd:
  float data_DerivedSingle;
};
?
1
2
3
4
5
6
7
8
9
class DerivedMulti :public Base1, public Base2
{
public:
  DerivedMulti(void);
  virtual ~DerivedMulti(void);
  virtual DerivedMulti* clone() const;
protected:
  float data_DerivedMulti;
};

对象模型如下,虚拟继承和单一继承类结构相同,只是继承改成了虚拟继承。

 

单一继承C++ 多重继承和虚拟继承对象模型、效率分析

C++ 多重继承和虚拟继承对象模型、效率分析

多重继承:

C++ 多重继承和虚拟继承对象模型、效率分析

虚拟继承:

为了保证memberwise复制的正确性(否则基类子对象复制给派生类时会发生错误),C++中保证“基类子对象在派生类中的原样性 ”。

单一继承的对象模型呈现了一种“自然多态”的形式,基类和派生类之间的转换十分自然简单。然而多重继承有多个基类,对象有多个vptr指针,对于第二个或后继基类和派生类之间的转换需要地址调整,以指向完整的基类子对象。

虚拟继承中,为了记住和共享虚拟基类,需要在类中添加指向该基类的指针。从上面的虚拟继承对象模型中可以看到,虽然和单一继承有相同的类层次结构,但虚拟继承打破了单一继承的“自然多态”形式,基类和派生类之间的转换需要调整this指针的地址。如果是虚拟多重继承,则虚拟基类/后继基类和派生类之间的转换需要this指针地址调整 。

一般规则,多重继承经由指向“第二个或者后继base class”的指针(引用)来调用derived class virtual function,该操作所连带的“必要的this指针调整”操作,必须在执行期完成,也就是说offset的大小、以及吧offset加到this指针上头的那一小段程序代码,必须有编译器在某个地方插入。为了实现this指针调整引入thunk技术,所谓thunk是一小段assembly代码,用来以适当的offset值调整this指针,并跳到virtual函数去。Thunk技术允许virtual table slot继续内含一个简单的指针,因此多重继承不需要额外任何空间上的额外负担。Slots中的地址可以直接指向virtual function,也可以指向一个相关的thunk(如果需要调整this指针)。调整this指针的第二个额外负担就是,由于两中不同的可能:(1)经由derived class(或者第一个base class)调用,(2)经由第二个(或者后继)base class调用,同一个函数在virtual table中可能需要多笔对应的slots。并且在第二个或者后继base class中的虚函数表保存的是thunk代码地址。

四、 效率

通过上面第三部分的分析,多重继承和虚拟继承对象模型的较单一继承复杂的对象模型 ,造成了成员访问低效率, 表现在两个方面:对象构建时vptr的多次设定,以及this指针的调整。对于多种继承情况的效率比较如下:

 

情形 Vptr 设定 Data member 访问 virtual Function member 访问 效率分析
单一继承 no vptr 指针/引用/对象访问效率相同 直接访问 效率较高
单一继承 一次 指针/引用/对象访问效率相同 通过vptr和vtable访问 多态的引入,带来了设定vptr和间接访问虚函数等效率的降低
多重继承 多次 指针/引用/对象访问效率相同 通过vptr和vtable访问,通过第二或者后继base类指针访问需要调整this指针 除了单一继承效率降低的情形,调整this指针也带来了效率的降低
虚拟继承 多次 对象/指针/应用访问效率较低 通过vptr和vtable访问,访问虚基类需要调整this指针 除了单一继承效率降低的情形,调整this指针也带来了效率的降低

 

多态中的data member访问

    考察多态中几种继承情形的data member成员访问效率的关键是:members的offset位置在编译期是否能够确定。 如果访问的成员在编译期就可以确定下offset位置,不会带来额外的负担。

    理论上针对上面的继承类型,通过类对象访问,效率完全一样,因为成员在类中的位置在编译期是可以确定的。通过引用或者指针访问,除了一种情形,上面的继承类型效率也完全相同 。例外情形是:通过指针和引用访问虚拟基类的数据成员。因为虚拟基类在不同的继承层次中,其offset位置是变化的,并且无法通过指针或者引用类型确定指针指向对象的真实类型,所以编译期无法确定offset位置,只能在运行期通过类型信息确定。

    实际上具体继承(非virtual继承)并不会增加空间或者存取时间上的额外负担,但是虚拟继承的“间接性”压抑了“把所有运算都移往缓存器执行”的优化能力,即使通过类对象访问编译器也会像对待指针一样(目前是,编译器都没能识别出对“继承而来的data member”的存取是通过一个非多态对象,因而不需要执行期的间接存取), 效率令人担心。但间接性并不会严重影响非优化程序的执行效率,各类型继承效率差别不大。一般来说,virtual base class最有效的运用形式:一个抽象的virtual base class,没有任何data   members。

多态中的function member访问

     在C++中,nonmember/static member/nonstatic member函数都被转化为完全相同的形式(通过managling命名处理),所以它们的效率完全相同。

     如果是通过引用和指针调用虚函数,效率将会降低,这是由C++多态性质决定的。而多重继承和虚拟继承中虚函数的调用比单一继承的效率更低。这个从上面表格可以清楚的看出来:this指针调(比如通过thunk技术调整)和多次初始化vptr。当然,请记住:通过对象访问虚函数和访问非虚成员函数效率是一样的。在调用虚函数而又不需要多态的情况下,可以明确地调用该函数实体:类名::函数名,压制由于虚拟机制而产生的不必要的重复调用操作。

this指针地址调整
       多重继承和虚拟继承中this指针调整使得这两种继承效率降低,实际编程时应该有所警惕。下面列出常见的需要调整this指针的情形:

      1、new 派生类给第二(后继)个基类指针或通过第二(后继)base class调用派生类虚析构函数

      必须调整Derived对象的地址,以使其指向Base2 subobject对象。当删除基类指向的对象时必须再一次调整,使其指向Derived对象的起始地址,然而这个调整只能在执行期完成,在编译时无法确定指针指向的对象类类型。

      下次你看到这种情况不要好奇:pBase2不等于pDerived。      

?
1
2
3
Derived* pDerived = new Derived;
Base2* pBase2 = pDerived; // Base2为Derived的第二个基类
pBase2 != pDerived;    // 两者不等

 2、通过派生类指针调用第二或后继base class拥有的虚函数

      如果想正确调用必须在编译时调整派生类指针,以指向后继base subobject调用正确的虚函数。由上面的模型图可以看到:如果通过派生类指针调用mumble函数,而mumble函数只存在于后继类的虚函数表中,故必须调整之。

      3、后继base class指针调用返回derived class type的虚函数并且赋值给另一后继base class指针时

      示例如下:

?
1
2
Base2* pb1 = new Derived;  // 调整指针指向base2 clss子对象
Base2* pb2 = pb1->clone(); // pb1被调整至Derived对象的地址,产生新的对象,再次调整对象指针指向base2基类子对象,赋值给pb2。

 记住:Base class指针一定得指向一个完整的与自身类型相同的对象或者子对象地址,不满足这个条件的情形都需要this指针的调整。

     详细知识请参考:《Inside The C++ Object Model》。

延伸 · 阅读

精彩推荐