深入探索C++对象模型

0.325字数 7715阅读 1542

一个博客,这个博客记录了他读这本书的笔记,总结得不错。
《深度探索C++对象模型》笔记汇总

1. C++对象模型与内存布局

参考资料

在C++中有两种类的数据成员:static和nonstatic,以及三种类的成员函数:static、nonstatic和virtual。在C++对象模型中,非静态数据成员被配置于每一个类的对象之中,静态数据成员则被存放在所有的类对象之外;静态及非静态成员函数也呗放在类对象之外,虚函数则通过以下两个步骤支持:

  1. 每一个类产生出一堆指向虚函数的指针,放在表格之中,这个表格被称为虚函数表(virtual table, vtbl)。
  2. 每一个类对象被添加了一个指针,指向相关的虚函数表,通常这个指针被称为vptr。vptr的设定和重置都由每一个类的构造函数、析构函数和拷贝赋值运算符自动完成。另外,虚函数表地址的前面设置了一个指向type_info的指针,RTTI(Run Time Type Identification)运行时类型识别是由编译器在编译器生成的特殊类型信息,包括对象继承关系,对象本身的描述,RTTI是为多态而生成的信息,所以只有具有虚函数的对象在会生成。
class Point
{
    public:
        Point(float xval);
        virtual ~Point();
        
        float x() const;
        static int PointCount();
        
    protected:
        virtual ostream& printf(ostream& os) const;
        
        float _x;
        static int _point_count;
}
C++对象模型

这个模型的主要优点在于它的空间和存取时间的效率;主要缺点则是,如果应用程序代码本身未曾改变,但所用到的类对象的非静态数据成员有所增加、移除或修改,那么这些应用程序的代码同样得重新编译。

  • 非static成员变量被放置于每一个类对象中,非static成员函数放在类的对象之外,且非static成员变量在内存中的存放顺序与其在类内的声明顺序一致。
  • static成员变量存放在类的对象之外,static成员函数也放在类的对象之外。
  • C++中的虚函数是通过虚函数表(vtbl)来实现,每一个类为每一个virtual函数产生一个指针,放在表格中,这个表格就是虚函数表。每一个类对象会被安插一个指针(vptr),指向该类的虚函数表。vptr的设定和重置都由每一个类的构造函数、析构函数和复制赋值运算符自动完成。在本人的环境中,类的对象的安插的vptr放在该对象所占内存的最前面。
  • 虚函数表中函数指针存放的顺序与虚函数在类中声明的顺序一致。

加上继承

C++支持单一继承和多重继承,甚至继承关系也可以指定为virtual(也就是共享的意思)。在虚拟继承的情况下,基类不管在继承串链中被派生多少次,永远只会存在一个实体。

  1. 单继承(只有一个父类)
    类的继承关系为:class Derived : public Base


    Derived类的对象的内存布局为:虚函数表指针、Base类的非static成员变量、Derived类的非static成员变量。

  2. 多重继承(多个父类)
    类的继承关系如下:class Derived : public Base1, public Base2


    Derived类的对象的内存布局为:基类Base1子对象和基类Base2子对象及Derived类的非static成员变量组成。基类子对象包括其虚函数表指针和其非static的成员变量。

  3. 重复继承(继承的多个父类中其父类有相同的超类)
    类的继承关系如下:
    class Base1 : public Base
    class Base2: public Base
    class Derived : public Base1, public Base2


    Derived类的对象的内存布局与多继承相似,但是可以看到基类Base的子对象在Derived类的对象的内存中存在一份拷贝。这样直接使用Derived中基类Base的相关成员时,就会引发歧义,可使用多重虚拟继承消除之。

  4. 多重虚拟继承(使用virtual方式继承,为了保证继承后父类的内存布局只会存在一份)
    类的继承关系如下:
    class Base1 : virtual public Base
    class Base2: virtual public Base
    class Derived : public Base1, public Base2


    Derived类的对象的内存布局与重复继承的类的对象的内存分布类似,但是基类Base的子对象没有拷贝一份,在对象的内存中仅存在在一个Base类的子对象。但是它的非static成员变量放置在对象的末尾处。


2. 构造函数语意学

默认构造函数的建构操作

通常很多C++程序员存在两种误解:

  • 没有定义默认构造函数的类都会被编译器生成一个默认构造函数。
  • 编译器生成的默认构造函数会明确初始化类中每一个数据成员。

C++标准规定:如果类的设计者并未为类定义任何构造函数,那么会有一个默认 构造函数被暗中生成,而这个暗中生成的默认构造函数通常是不做什么事的(无用的),下面四种情况除外。

换句话说,有以下四种情况编译器必须为未声明构造函数的类生成一个会做点事 的默认构造函数。我们会看到这些默认构造函数仅“忠于编译器”,而可能不会按 照程序员的意愿程效命。

  1. 包含有带默认构造函数的对象成员的类
    若一个类X没有定义任何构造函数,但却包含一个或以上定义有默认构造函数的 对象成员,此时编译器会为X合成默认构造函数,该默认函数会调用对象成员的 默认构造函数为之初始化。如果对象的成员没有定义默认构造函数,那么编译器 合成的默认构造函数将不会为之提供初始化。例如类A包含两个数据成员对象, 分别为:string strchar *Cstr,那么编译器生成的默认构造函数将只提 供对string类型成员的初始化,而不会提供对char*类型的初始化。
    假如类X的设计者为X定义了默认的构造函数来完成对str的初始化,形如:A::A({Cstr=”hello”};因为默认构造函数已经定义,编译器将不能再生成一 个默认构造函数。但是编译器将会扩充程序员定义的默认构造函数——在最前面插 入对初始化str的代码。若有多个定义有默认构造函数的成员对象,那么这些成员 对象的默认构造函数的调用将依据声明顺序排列。

  2. 继承自带有默认构造函数的基类的类
    如果一个没有定义任何构造函数的类派生自带有默认构造函数的基类,那么编译 器为它定义的默认构造函数,将按照声明顺序为之依次调用其基类的默认构造函 数。若该类没有定义默认构造函数而定义了多个其他构造函数,那么编译器扩充它的所有构造函数——加入必要的基类默认构造函数。另外,编译器会将基类的默认构造函数代码加在对象成员的默认构造函数代码之前。

  3. 带有虚函数的类
    带有虚函数的类,与其它类不太一样,因为它多了一个vptr,而vptr的设置是由 编译器完成的,因此编译器会为类的每个构造函数添加代码来完成对vptr的初始化。

  4. 带有一个虚基类的类
    在这种情况下,编译器要将虚基类在类中的位置准备妥当,提供支持虚基类的机 制。也就是说要在所有构造函数中加入实现前述功能的的代码。没有构造函数将 合成以完成上述工作。

总的来说,编译器将对构造函数动这些手脚:

  • 如果类虚继承自基类,编译器将在所有构造函数中插入准备虚基类位置的代 码和提供支持虚基类机制的代码。
  • 如果类声明有虚函数,那么编译器将为之生成虚函数表以存储虚函数地址, 并将虚函数指针(vptr)的初始化代码插入到类的所有构造函数中。
  • 如果类的父类有默认构造函数,编译将会对所有的默认构造函数插入调用其 父类必要的默认构造函数。必要是指设计者没有显示初始化其父类,调用顺 序,依照其继承时声明顺序。
  • 如果类包含带有默认构造函数的对象成员,那么编译器将会为所有的构造函 数插入对这些对象成员的默认构造函数进行必要的调用代码,所谓必要是指 类设计者设计的构造函数没有对对象成员进行显式初始化。成员对象默认构 造函数的调用顺序,依照其声明顺序。
  • 若类没有定义任何构造函数,编译器会为其合成默认构造函数,再执行上述 四点。

拷贝构造函数

拷贝构造函数的定义:有一个参数类型是其类类型的构造函数。

X::X( const X& x);
Y::Y( const Y& y, int =0 );//可以是多参数形式,但其第二个即后继参数都有一个默认值

当一个类对象以另一个同类实体作为初值时,大部分情况下会调用拷贝构造函数。 一般是这三种具体情况:显式地以一个类对象作为另一个类对象的初值,形如X xx=x;当类对象被作为参数交给函数时;当函数返回一个类对象时。

编译器何时合成拷贝构造函数

并不是所有未定义有拷贝构造函数的类编译器都会为其合成拷贝构造函数,只有在编译器在普通手段无法解决“一个类对象以另一个同类实体作为初值”这一问题时,编译器才会合成 拷贝构造函数。如果一个类没有定义拷贝构造函数,通常按照“成员逐一初始化(Default Memberwise Initialization)”的手法来解决这一问题——即把内建或派生的数据成员从某一个对象拷贝到另一个对象身上,如果数据成员是一个对象,则递归使用“成员逐一初始化”的手法。

成员逐一初始化具体的实现方式则是位逐次拷贝(Bitwise copy semantics)。也就是在能使用这种常规方式来解决“一个类对象以另一个同类实体作为初值”的时候,编译器是不需要合成拷贝构造函数的。但有些时候常规武器不那么管用,我们就得祭出非常规武器了 ——拷贝构造函数。有以下几种情况,位逐次拷贝将不能胜任或者不适合来完成 “一个类对象以另一个同类实体作为初值”的工作。此时,如果类没有定义拷贝构造函数,那么编译器将必须为类合成一个拷贝构造函数。

  • 当类内含一个成员对象,而后者的类声明有一个拷贝构造函数时(不论是设 计者定义的还是编译器合成的)。
  • 当类继承自一个声明有拷贝构造函数的类时(不论这个拷贝构造函数 是被显示声明还是由编译器合成的)。
  • 类中声明有虚函数。
  • 当类的派生串链中包含有一个或多个虚基类。

对于前两种情况,不论是基类还是对象成员,既然后者声明有拷贝构造函数时, 就表明其类的设计者或者编译器希望以其声明的拷贝构造函数来完成“一个类对象以另一个同类实体作为初值”的工作,而设计者或编译器这样做——声明拷贝构造函 数,总有它们的理由,而通常最直接的原因是他们想要做一些额外的工作或“位逐次拷贝”无法胜任。

对于有虚函数的类,如果两个对象的类型相同那么位逐次拷贝其实是可以胜任的。 但问题将出现在,如果基类由其继承类进行初始化时,此时若按照位逐次拷贝来 完成这个工作,那么基类的vptr将指向其继承类的虚函数表,这将导致无法预料的后果——调用一个错误的虚函数实体是无法避免的,轻则带来程序崩溃,更糟糕的问题可能是这个错误被隐藏了。所以对于有虚函数的类编译器将会明确的使被初始化的对象的vptr指向正确的虚函数表。因此有虚函数的类没有声明拷贝构造 函数,编译将为之合成一个,来完成上述工作,以及初始化各数据成员,声明有拷贝构造函数的话也会被插入完成上述工作的代码。

对于继承串链中有虚基类的情况,问题同样出现在继承类向基类提供初值的情况, 此时位逐次拷贝有可能破坏对象中虚基类子对象的位置。

成员初始化列表

对于初始化列表,有一个概念是非常重要的:在构造函数中对于对象成员的初始化发生在初始化列表中——或者我们可以把初始化列表直接看做是对成员的定义,而构造函数体中进行的则是赋值操作。所以不难理解有四种情况必须用到初始化列表:

  • 有const成员
  • 有引用类型成员
  • 成员对象没有默认构造函数
  • 基类对象没有默认构造函数

前两者因为要求定义时初始化,所以必须明确的在初始化队列中给它们提供初值。后两者因为不提供默认构造函数,所以必须显式调用它们的带参构造函数来定义即初始化它们。显而易见的是当类中含有对象成员或者继承自基类的时候,在初始化队列中初始化成员对象和基类子对象会在效率上得到提升——省去了一些赋值操作嘛。


3. Data语意学

C++类对象的大小

猜猜下面几个类sizeof的大小。

class X{};
class Y:virtual public X{};
class Z:virtual public X{};
class A:public Y, public Z{};

32位系统下VS2010的运行结果为

sizeof X yielded 1 
sizeof Y yielded 4 
sizeof Z yielded 4 
sizeof Z yielded 8

事实上,对于像X这样的一个的空类,编译器会对其动点手脚——隐晦的插入一个字节。为 什么要这样做呢?插入了这一个字节,那么X的每一个对象都将有一个独一无二的地址。
再看看Y和Z,由于要实现虚继承,需要额外添加一个虚指针指向虚函数表。到目前为止,对于一个32位的机器来说Y、Z的大小应该为5,而不 是8或者4。我们需要再考虑两点因素:内存对齐(memory alignment)和编译器的优化。

对齐会将数值调整到某数的整数倍,32位计算机上为4bytes。内存对齐可以使得总线的运输量达到最高效率。所以Y、Z的大小被补齐到8。那么在vs2010中为什么Y、Z的大小是4而不是8呢?我们先思考一个问题,X之所以被插入 1字节是因为本身为空,需要这一个字节为其在内存中给它占领一个独一无二的地址。但 是当这一字节被继承到Y、Z后呢?它已经完全失去了它存在的意义,为什么?因为Y、Z各 自拥有一个虚基类指针,它们的大小不是0。既然这一字节在Y、Z中毫无意义,那么就没 必要留着。也就是说vs2010对它们进行了优化,优化的结果是去掉了那一个字节。

影响C++类的大小的三个因素:

  • 支持特殊功能所带来的额外负担(对各种virtual的支持)。
  • 编译器对特殊情况的优化处理。
  • 内存对齐操作。

数据成员的布局与存取

参考第一章中的两篇参考文献。

在VC中数据成员的布局顺序为:

  • vptr部分(如果基类有,则继承基类的)
  • vbptr (如果需要)
  • 基类成员(按声明顺序)
  • 自身数据成员
  • 虚基类数据成员(按声明顺序)

4. 函数语意学

成员函数的调用

c++支持三种类型的成员函数,分别为static,nostatic,virtual。每一种调用方式都不尽相同。

非静态成员函数
为保证非静态成员函数具有与普通函数调用相同的效率,编译器内部会将成员函数等价转换为非成员函数。具体是这样实现的:

  1. 改写成员函数的签名,使得其可以接受一个额外参数,this指针。如果成员函数是const的,插入的参数类型将为 const Point* 类型。
float Point::X();
//成员函数X被插入额外参数this
float Point:: X(Point* this );
  1. 将每一个对非静态数据成员的操作都改写为经过this操作。
  2. 将成员函数写成一个外部函数,对函数名进行“mangling”处理,使之成为独一无二的名称。

关键在于为函数提供一个可直接读写成员数据的通道(this),和对函数名进行修饰,以免名字冲突。

虚成员函数
如果function()是一个虚拟函数,那么用指针或引用进行的调用将发生一点特别的转换——一个中间层被引入进来。

例如:p->function()将转化为(*p->vptr[1])(p)。其中vptr为指向虚函数表的指针,它由编译器产生。vptr也要进行名字处理,因为一个继承体系可能有多个vptr。1是虚函数在虚函数表中的索引,通过它关联到虚函数function()。

当通过指针调用的时候,要调用的函数实体无法在编译期决定,必需待到执行期才能获得,所以上面引入一个间接层的转换必不可少。但是当我们通过对象(不是引用,也不是指针)来调用的时候, 进行上面的转换就显得多余了,因为在编译器要调用的函数实体已经被决定。此时调用发生的转换,与一个非静态成员函数调用发生的转换一致。

静态成员函数
静态成员函数不能直接读写其类中的非静态成员和调用非静态成员函数,不能申明为const、voliatile或virtual,不需经由对象调用(允许通过对象调用)。除了缺乏一个this指针他与非静态成员函数没有太大的差别。在这里通过对象调用和通过指针或引用调用,将被转化为同样的调用代码。

虚成员函数

单继承下的虚函数
虚函数的实现原理:编译器为每个有虚函数的类配一张虚函数表,用来存储该类类型信息和所有虚函数执行期的地址;并为每个有虚函数的类插入一个指针(vptr),这个指针指向该类的虚函数表,最后 给每一个虚函数指派一个在表中的索引。

一个类的虚函数表中存储有类型信息(VC2010中存储在索引为-1的位置)和所有虚函数地址,这些虚函数地址包括三种:

  • 这个类定义的虚函数,会改写(overriding)一个可能存在的基类的虚函数实体——假如基类也定义有这个虚函数。
  • 继承自基类的虚函数实体,——基类定义有,而这个类却没有定义。直接继承之。
  • 一个纯虚函数实体。用来在虚函数表中占座,有时候也可以当做执行期异常处理函数。

当一个类单继承自有虚函数的基类的时候,将按如下步骤构建虚函数表:

  1. 继承基类中声明的虚函数——这些虚函数的实体地址被拷贝到继承类中的虚函数表中对于的slot中。
  2. 如果有改写(override)基类的虚函数,那么在1中应将改写(override)的函数实体的地址放入对应的slot中而不是拷贝基类的。
  3. 如果有定义新的虚函数,那么将虚函数表扩大一个slot以存放新的函数实体地址。

每一个虚函数都被指派一个固定的索引值,这个索引值在整个继承体系中保持前后关联,例如,假如z()在Point虚函数表中的索引值为2,那么在Point3d虚函数表中的索引值也为2。

多重继承下的虚函数
在多重继承下,继承类需要为每一条继承线路维护一个虚函数表(也有可能这些表被合成为一个,但本质意义并没有变化)。当然这一切都发生在需要的情况下。

当使用第一继承的基类指针来调用继承类的虚函数的时候,与单继承的情况没有什么异样,问题出生在当以第二或后继的基类指针(或引用)的使用上。例如:

//假设有这样的继承关系:class Derived:public base1,public base2;
//base1,base2都定义有虚析构函数。
base2 *ptr = new derived;
//需要被转换为,这个转换在编译期完成
base2 *ptr = temp ? temp + sizeof(base1) : 0 ;

如果不做出上面的转换,那么 ptr 指向的并不是 derived 的 base2 subobject 。后果是,ptr 将一个derived类型当做base2类型来用。

当要delete ptr时又面临了一次转换,因为在delete ptr的时候,需要对整个对象而不是其子对象施行delete运算符,这期间需要调整ptr指向完整的对象起点,因为不论是调用正确的析构函数还是delete运算符都需要一个指向对象起点的指针,想一想给予一个derived类的成员函数指向base2 subobjuect 的this指针会发生什么吧。因为ptr的具体类型并不知道,所以必须要等到执行期来完成。

多继承下的虚函数,影响到虚函数的调用的实际质上为this的调整。而this调整一般为两种:

  • 调整指针指向对应的subobject,一般发生在继承类类型指针向基类类型指针赋值的情况下。
  • 将指向subobject的指针调整回继承类对象的起始点,一般发生在基类指针对继承类虚函数进行调用的时候。

第一点,使得该基类指针指向一个与其指针类型匹配的子对象,唯有如此才能保证使得该指针在执行与其指针类型相匹配的特定行为的正确性。比方调用基类的成员,获得正确的虚函数地址。可以想象如果不调整,用ptr存取base2 subobject的数据成员时,会发生什么?调用base2的成员函数的时候,其成员函数接受的this指针指向derived 类型对象,这又会发生什么?结果是整个对象的内存结构有可能都被破坏。还有别忘了,vptr也可以看做一个数据成员,要找到虚函数,前提是获取正确的vptr偏移量。

而第二点,显然是让一个继承类的虚函数获取一个正确的this指针,因为一个继承类虚函数要的是一个指向继承类对象的this指针,而不是指向其子对象。

第一顺序继承类之所以不需要进行调整的关键在于,其subobject的起点与继承类对象的起点一致。

5. 构造、解构、拷贝 语意学

几点类的设计原则
1.即使是一个抽象基类,如果它有非静态数据成员,也应该给它提供一 个带参数的构造函数,来初始化它的数据成员。或许你可以通过其派生 类来初始化它的数据成员(假如nostatic data member为publish或 protected),但这样做的后果则是破坏了数据的封装性,使类的维护和修 改更加困难。由此引申,类的data member应当被初始化,且只在其构造函 数或其member function中初始化。

2.不要将析构函数设计为纯虚的,这不是一个好的设计。将析构函数 设计为纯虚函数意味着,即使纯虚函数在语法上允许我们只声明而不定义 纯虚函数,但还是必须实现该纯虚析构函数,否则它所有的继承类都将遇 到链接错误。一个不能派生继承类的抽象类有什么存在的意义?必须定义 纯虚析构函数,而不能仅仅声明它的原因在于:每一个继承类的析构函数 会被编译器加以扩展,以静态调用方式其每一个基类的析构函数(假如有 的话,不论是显示的还是编译器合成的),所以只要任何一个基类的析构 函数缺乏定义,就会导致链接失败。矛盾就在这里,纯虚函数的语法,允 许只声明而不定义纯虚析构函数,而编译器则死脑筋的看到一个其基类的 析构函数声明,则去调用它的实体,而不管它有没有被定义。

3.真的必要的时候才使用虚函数,不要滥用虚函数。虚函数意味着不 小的成本,编译很可能给你的类带来膨胀效应:
每一个对象要多负担一个word的vptr。
给每一个构造函数(不论是显示的还是编译器合成的),插入一些代 码来初始化vptr,这些代码必须被放在所有基类构造函数的调用之后, 但需在任意用户代码之前。没有构造函数则需要合成,并插入代码。
合成一个拷贝构造函数和一个复制操作符(如果没有的话),并插入 对vptr的初始化代码,有的话也需要插入vptr的初始化代码。
意味着,如果具有bitwise语意,将不再具有,然后是变大的对象、没 有那么高效的构造函数,没有那么高效的复制控制。

4.不能决定一个虚函数是否需要 const ,那么就不要它。

5.决不在构造函数或析构函数中使用虚函数机制。在构造函数中,每次 调用虚函数会被决议为当前构造函数所对应类的虚函数实体,虚函数机制并 不起作用。当一个base类的构造函数含有对虚函数vf()的调用,当其派生类 derived的构造函数调用基类base的构造函数的时候,其中调用的虚函数vf() 是base中的实体,而不是derived中的实体。这是由vptr初始化的位置决定的 ——在所有基类构造函数调用之后,在程序员供应的代码或是成员初始化队列 之前。因构造函数的调用顺序是:有根源到末端,由内而外,所以对象的构 造过程可以看成是,从构建一个最基础的对象开始,一步步构建成一个目标 对象。析构函数则有着与构造相反的顺序,因此在构造或析构函数中使用虚 函数机制,往往不是程序员的意图。若要在构造函数或析构函数中调用虚函 数,应当直接以静态方式调用,而不要通过虚函数机制。

推荐阅读更多精彩内容