欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

程序员应知应会(22)C++继承中的内存布局

程序员文章站 2022-07-12 22:48:31
...

——谈VC++对象模型
(美)简.格雷
程化    译
译者前言    

       一个C++程序员,想要进一步提升技术水平的话,应该多了解一些语言的语意细节。对于使用VC++的程序员来说,还应该了解一些VC++对于C++的诠释。 Inside the C++ Object Model虽然是一本好书,然而,书的篇幅多一些,又和具体的VC++关系小一些。因此,从篇幅和内容来看,译者认为本文是深入理解C++对象模型比较好的一个出发点。

【1-前言】
        了解你所使用的编程语言究竟是如何实现的,对于C++程序员可能特别有意义。首先,它可以去除我们对于所使用语言的神秘感,使我们不至于对于编译器干的活感到完全不可思议;尤其重要的是,它使我们在Debug和使用语言高级特性的时候,有更多的把握。当需要提高代码效率的时候,这些知识也能够很好地帮助我们。
本文着重回答这样一些问题:
1* 类如何布局?
2* 成员变量如何访问?
3* 成员函数如何访问?
4* 所谓的“调整块”(adjuster thunk)是怎么回事?
5* 使用如下机制时,开销如何:
  * 单继承、多重继承、虚继承
  * 虚函数调用
  * 强制转换到基类,或者强制转换到虚基类
  * 异常处理
        首先,我们顺次考察C兼容的结构(struct)的布局,单继承,多重继承,以及虚继承;
        接着,我们讲成员变量和成员函数的访问,当然,这里面包含虚函数的情况;
        再接下来,我们考察构造函数,析构函数,以及特殊的赋值操作符成员函数是如何工作的,数组是如何动态构造和销毁的;
        最后,简单地介绍对异常处理的支持。
        对每个语言特性,我们将简要介绍该特性背后的动机,该特性自身的语意,以及该特性在微软的 VC++中是如何实现的。这里要注意区分抽象的C++语言语意与其特定实现。微软之外的其他C++厂商可能提供一个完全不同的实现,我们偶尔也会将 VC++的实现与其他实现进行比较。

【2-类布局 】本节讨论不同的继承方式造成的不同内存布局

2.1 C结构(struct)

       由于C++基于C,所以C++也“基本上”兼容C。特别地,C++规范在“结构”上使用了和C相同的,简单的内存布局原则:成员变量按其被声明的顺序排列,按具体实现所规定的对齐原则在内存地址上对齐所有的C/C++厂商都保证他们的C/C++编译器对于有效的C结构采用完全相同的布局。这里,A是一个简单的C结构,其成员布局和对齐方式都一目了然。

程序员应知应会(22)C++继承中的内存布局

struct A {  
   char c;  
   int i;  
};

       从上图可见,A在内存中占有8个字节,按照声明成员的顺序,前4个字节包含一个字符(实际占用1个字节,3个字节空着,补对齐),后4个字节包含一个整数。A的指针就指向字符开始字节处。 

2.2 有C++特征的C结构(类)

       当然了,C++不是复杂的C,C++本质上是面向对象的语言:包含 继承、封装,以及多态 。原始的C结构经过改造,成了面向对象世界的基石——类。除了成员变量外,C++类还可以封装成员函数和其他东西。然而,有趣的是,除非 为了实现虚函数和虚继承引入的隐藏成员变量外,C++类实例的大小完全取决于一个类及其基类的成员变量!成员函数基本上不影响类实例的大小。

       这里提供的B是一个C结构,然而该结构有一些C++特征:控制成员可见性的“public/protected/private”关键字、成员函数、静态成员,以及嵌套的类型声明。虽然看着琳琅满目,实际上,只有成员变量才占用类实例的空间。要注意的是,C++标准委员会不限制由“public/protected/private”关键字分开的各段在实现时的先后顺序,因此,不同的编译器实现的内存布局可能并不相同。( 在VC++中,成员变量总是按照声明时的顺序排列)。

程序员应知应会(22)C++继承中的内存布局

 

struct B {  
public:  
   int bm1;  
protected:  
   int bm2;  
private:  
   int bm3;  
   static int bsm;  
   void bf();  
   static void bsf();  
   typedef void* bpv;  
   struct N { };  
};  

注:B中static int bsm不占用内存空间?因为它是静态成员,该数据存放在程序的数据段中,不在类实例中!

2.3 单继承

        C++ 提供继承的目的是在不同的类型之间提取共性。比如,科学家对物种进行分类,从而有种、属、纲等说法。有了这种层次结构,我们才可能将某些具备特定性质的东西归入到最合适的分类层次上,如“怀孩子的是哺乳动物”。由于这些属性可以被子类继承,所以,我们只要知道“鲸鱼、人”是哺乳动物,就可以方便地指出“鲸鱼、人都可以怀孩子”。那些特例,如鸭嘴兽(生蛋的哺乳动物),则要求我们对缺省的属性或行为进行覆盖。
       C++中的继承语法很简单,在子类后加上“:base”就可以了。下面的D继承自基类C。

程序员应知应会(22)C++继承中的内存布局

程序员应知应会(22)C++继承中的内存布局

       既然派生类要保留基类的所有属性和行为,自然地,每个派生类的实例都包含了一份完整的基类实例数据。在D中,并不是说基类C的数据一定要放在D的数据之前,只不过这样放的话,能够保证D中的C对象地址,恰好是D对象地址的第一个字节。这种安排之下,有了派生类D的指针,要获得基类C的指针,就不必要计算偏移量 了。几乎所有知名的C++厂商都采用这种内存安排(基类成员在前)。在单继承类层次下,每一个新的派生类都简单地把自己的成员变量添加到基类的成员变量之后 。如上图,C对象指针和D对象指针指向同一地址。

2.4 多重继承
        大多数情况下,其实单继承就足够了。但是,C++为了我们的方便,还提供了多重继承。比如,我们有一个组织模型,其中有经理类(分任务),工人类(干活)。那么,对于一线经理类,即既要从上级经理那里领取任务干活,又要向下级工人分任务的角色来说,如何在类层次中表达呢?单继承在此就有点力不胜任。我们可以安排经理类先继承工人类,一线经理类再继承经理类,但这种层次结构错误地让经理类继承了工人类的属性和行为。反之亦然。当然,一线经理类也可以仅仅从一个类(经理类或工人类)继承,或者一个都不继承,重新声明一个或两个接口,但这样的实现弊处太多:多态不可能了;未能重用现有的接口;最严重的是,当接口变化时,必须多处维护。最合理的情况似乎是一线经理从两个地方继承属性和行为——经理类、工人类。
        C++允许用多重继承来解决这样的问题:

struct Manager ... { ... };  
struct Worker ... { ... };  
struct MiddleManager : Manager, Worker { ... };  

         这样的继承将造成怎样的类布局呢?下面我们还是用“字母类”来举例: 

程序员应知应会(22)C++继承中的内存布局

struct E {  
   int e1;  
   void ef();  
};  

程序员应知应会(22)C++继承中的内存布局 

struct F : C, E {  
   int f1;  
   void ff();  
};  

       结构F从C和E多重继承得来。与单继承相同的是,F实例拷贝了每个基类的所有数据与单继承不同的是,在多重继承下,内嵌的两个基类的对象指针不可能全都与派生类对象指针相同:

F f;
// (void*)&f == (void*)(C*)&f;  
// (void*)&f < (void*)(E*)&f;  
//上面那行说明C对象指针与F对象指针相同,下面那行说明E对象指针与F对象指针不同。

       观察类布局,可以看到F中内嵌的E对象,其指针与F指针并不相同。正如后文讨论强制转化和成员函数时指出的,这个偏移量会造成少量的调用开销。

       具体的编译器实现可以*地选择内嵌基类和派生类的布局。VC++ 按照基类的声明顺序先排列基类实例数据,最后才排列派生类数据。 当然,派生类数据本身也是按照声明顺序布局的本规则并非一成不变,我们会看到,当一些基类有虚函数而另一些基类没有时,内存布局并非如此)。

【7-异常处理】
       简单说来,异常处理是C++标准委员会工作文件提供的一种机制,通过该机制,一个函数可以通知其调用者“异常”情况的发生,调用者则能据此选择合适的代码来处理异常。该机制在传统的“函数调用返回,检查错误状态代码”方法之外,给程序提供了另一种处理错误的手段。
        因为C++是面向对象的语言,很自然地,C++中用对象来表达异常状态。并且,使用何种异常处理也是基于“抛出的”异常对象的静态或动态类型来决定的。不光如此,既然C++总是保证超出范围的对象能够被正确地销毁,异常实现也必须保证当控制从异常抛出点转换到异常“捕获”点时(栈展开),超出范围的对象能够被自动、正确地销毁。(栈解退)
考虑如下例子:

struct X { X(); }; // exception object class  
struct Z { Z(); ~Z(); }; // class with a destructor  
extern void recover(const X&);  
void f(int), g(int);  
int main() {  
   try {  
      f(0);  
   } catch (const X& rx) {  
      recover(rx);  
   }  
   return 0;  
}  
void f(int i) {  
   Z z1;  
   g(i);  
   Z z2;  
   g(i-1);  
}  
void g(int j) {  
   if (j < 0)  
      throw X();  
}  

       说明:X是异常类,Z是带析构函数的工作类,recover是错误处理函数,f和g一起产生异常条件,g实际抛出异常。这段程序会抛出异常。在main中,加入了处理异常的try & catch框架,当调用f(0)时,f构造z1,调用g(0)后,再构造z2,再调用g(-1),此时g发现参数为负,抛出X异常对象。我们希望在某个调用层次上,该异常能够得到处理。既然g和f都没有建立处理异常的框架,我们就只能希望main函数建立的异常处理框架能够处理X异常对象。实际上,确实如此。当控制被转移到main中异常捕获点时,从g中的异常抛出点到main中的异常捕获点之间,该范围内的对象都必须被销毁。在本例中,z2和z1应该被销毁。
        谈到异常处理的具体实现方式,一般情况下,在抛出点和捕获点都使用“表”来表述能够捕获异常对象的类型;并且,实现要保证能够在特定的捕获点真正捕获特定的异常对象;一般地,还要运用抛出的对象来初始化捕获语句的“实参”。通过合理地选择编码方案,可以保证这些表格不会占用过多的内存空间。
        异常处理的开销到底如何?让我们再考虑一下函数f。看起来f没有做异常处理。f确实没有包含try,catch,或者是throw关键字,因此,我们会猜异常处理应该对f没有什么影响。错!编译器必须保证一旦z1被构造,而后续调用的任何函数向f抛回了异常,异常又出了f的范围时,z1对象能被正确地销毁。同样,一旦z2被构造,编译器也必须保证后续抛出异常时,能够正确地销毁z2和z1。
        要实现这些“展开”语意,编译器必须在后台提供一种机制,该机制在调用者函数中,针对调用的函数抛出的异常动态决定异常环境(处理点)。这可能包括在每个函数的准备工作和善后工作中增加额外的代码,在最糟糕的情况下,要针对每一套对象初始化的情况更新状态变量。例如,上述例子中,z1应被销毁的异常环境当然与z2和z1都应该被销毁的异常环境不同,因此,不管是在构造z1后,还是继而在构造z2后,VC++都要分别在状态变量中更新(存储)新的值。
       所有这些表,函数调用的准备和善后工作,状态变量的更新,都会使异常处理功能造成可观的内存空间和运行速度开销。正如我们所见,即使在没有使用异常处理的函数中,该开销也会发生。幸运的是,一些编译器可以提供编译选项,关闭异常处理机制。那些不需要异常处理机制的代码,就可以避免这些额外的开销了。 

【8-小结】
       在本文中,我们讨论了许多重要的C++运行实现问题。我们发现,很多美妙的C++语言特性的开销很低,同时,其他一些美妙的特性(译者注:主要是和“虚”字相关的东西)将造成较大的开销。C++很多实现机制都是在后台默默地为你工作。一般说来,单独看一段代码时,很难衡量这段代码造成的运行时开销,必须把这段代码放到一个更大的环境中来考察,运行时开销问题才能得到比较明确的答案。

相关标签: 程序员应知应会