深度探索C++对象模型

深度探索C++对象模型

Posted by zhaostu4 on November 28, 2019

T:2019/11/28 W:四 17:0:11 [HMTL]:@TOC

参考链接:


  • 深度探索C++对象模型 笔记汇总

    第1章 关于对象(Object Lessons)

    C++的额外成本

  • C++较之C的最大区别,无疑在于面向对象。类相较于C的struct不仅只包含了数据,同时还包括了对于数据的操作。在语言层面上C++带来了很多面向对象的新特性,类、继承、多态等等。新特性使得C++更加强大,但却伴随着空间布局和存取时间的额外成本。作为一个以效率为目标的语言,C++对于面向对象的实现,其实不大优雅.
  • 额外成本主要由virtual引起,包括:
    • virtual function 机制,用来支持“执行期绑定”。
    • virtual base class ——虚基类机制,以实现共享虚基类的 subobject。
  • 除此之外C++没有太多理由比C迟缓

    三种对象模型

  • C++类包含两种数据成员和三种成员函数:
    • 静态数据成员, 非静态数据成员
    • 成员函数, 静态函数, 虚函数
  • 在C++对象中表现为中有三种模型(C++对象模型是在前两种对象模型上发展而来的,甚至于局部上直接用到前两种对象模型):
    • 简单对象模型、
    • 表格驱动对象模型
    • C++对象模型。
  • 假定有一个 Point类,我们将用三种对象模型来表现它。Point类定义如下: ```cpp class Point
    {
    public:
    Point( float xval ); virtual ~Point();
    float x() const;
    static int PointCount();
protected:  
    virtual ostream&  print( ostream &os ) const;
    float _x;  

    static int _point_count;  
};
``` ### 简单对象模型 - 简单对象模型的**概念**:
- 一个C++对象存储了所有指向成员的指针,而成员本身不存储在对象中。
- 也就是说不论数据成员还是成员函数,也不论这个是普通成员函数还是虚函数,它们都存储在对象本身之外,同时对象保存指向它们的指针。 --- - 简单对象模型对于编译器来说虽然极尽简单,但同时付出的代价是**空间和执行期的效率**.
- 对于每一个成员都要额外搭上一个指针大小的空间
- 对于每成员的操作都增加了一个间接层。 - 因此C++并没有采用这样一种对象模型,但是被用到了C++中 **“指向成员的指针”** 的概念当中。 ![简单对象模型](https://img-blog.csdnimg.cn/20191012211235385.png#pic_center =500x400) ### 表格驱动对象模型 - 表格驱动模型则更绝,
- 它将对象中所有的成员都抽离出来在外建表,
- 而对象本身只存储指向这个表的指针。 - 下图可以看到,
- 它将所有的数据成员抽离出来建成一张 **数据成员表**,
- 将所有的函数抽取出来建成一张 **函数成员表**,
- 而对象本身只保持一个 **指向数据成员表的指针**。
![表格驱动对象模型](https://img-blog.csdnimg.cn/2019101221200329.png#pic_center =500x400) - ==侯大大认为,在对象与成员函数表中间应当加一个虚箭头,他认为这是Lippman的疏漏之处,应当在对象中保存指向函数成员表的指针。== - 然而我在这儿还是保留原书(而非译本)的截图,因为以我之拙见,不保存指向成员函数表的指针也没有妨碍。因为形如float Point::x()的成员函数实际上相当于float x(Point*)类型的普通函数,因此保存指向成员函数表的指针当属多此一举。 - 当然C++也没有采用这一种对象模型,但C++却**以此模型作为支持虚函数的方案。** ### C++对象模型 - C++对象模型的组成:
- 所有的**非静态数据成员**存储在**对象本身中**。
- 所有的**静态数据成员**、**成员函数(包括静态与非静态)**都置于**对象之外**。
- 另用一张**虚函数表(virtual table) 存储所有指向虚函数的指针**,并在**表头附加上一个该类的type_info对象**,在**对象中则保存一个指向虚函数表的指针**。 - 如下图:
	![C++对象模型](https://img-blog.csdnimg.cn/20191012213609981.png#pic_center =500x400) ## class和struct关键字的差异 - 按照lippman的意思是,struct仅仅是给想学习C++的C程序员攀上高峰少一点折磨。但遗憾的是当我开始学C++的时候这个问题给我带来更多的疑惑。以我的认识class与struct仅限一个默认的权限(后者为public前者为private)的不同。有时我甚至觉得只有一点畸形,他们不应当如此的相像,我甚至认为struct不应该被扩充,仅仅保存它在C中的原意就好了。[^1] [^1]:  实际上struct还要复杂一点,它有时表现的会和C struct完全一样,有时则会成为class的胞兄弟。 --- -  一个有意思的C技巧(但别在C++中使用)
- 在C中将一个一个元素的数组放在struct的末尾,可以令每个struct的对象拥有可变数组。 - 这是一个很有意思的小技巧,但是**别在C++中使用**。因为C++的内存布局相对复杂。例如被继承,有虚函数… 问题将不可避免的发生。 - 看代码:
```c
struct mumble {  
    /* stuff */  
    char pc[ 1 ];  
};  
// grab a string from file or standard input  
// allocate memory both for struct & string  
struct mumble *pmumb1 = ( struct mumble* )  
    malloc(sizeof(struct mumble)+strlen(string)+1);  
strcpy( &mumble.pc, string );
``` ## 三种编程典范 - 程序模型:数据和函数分开。 - 抽象数据类型模型:数据和函数一起封装以来提供。 - 面向对象模型:可通过一个抽象的base class封装起来,用以提供共同接口,需要付出的就是额外的间接性。
  • 纯粹使用一种典范编程,有莫大的好处,如果混杂多种典范编程有可能带来意想不到的后果,例如将继承类的对象赋值给基类对象,而妄想实现多态,便是一种ADT模型和面向对象模型混合编程带来严重后果的例子。

    一个类的对象的内存大小

  • 一个类对象的内存:
    • 所有非静态数据成员的大小。
    • 由内存对齐而填补的内存大小。
    • 为了支持virtual有内部产生的额外负担。(只增加虚表指针,虚表在对象之外[^2]) [^2]:Ref: C++虚函数表,虚表指针,内存分布
  • 以下类:
      class ZooAnimal {  
      public:  
          ZooAnimal();  
          virtual ~ZooAnimal();  
          virtual void rotate();  
      protected:  
          int loc;  
          String name;  
      };
    
  • 在32位计算机上所占内存为16字节:int四字节,String8字节(一个表示长度的整形,一个指向字符串的指针),以及一个指向虚函数表的指针vptr。对于继承类则为基类的内存大小加上本身数据成员的大小。
  • 其内存布局如下图: 对象的内存布局

    一些结论1

  • C++在加入封装后(只含有数据成员和普通成员函数)的布局成本增加了多少?
    • 答案是并没有增加布局成本。就像C struct一样,memeber functions虽然含在class的声明之内,却不出现在object中。每一个non-inline member function只会诞生一个函数实体。至于每一个“拥有零个或一个定义的” inline function则会在其每一个使用者(模块)身上产生一个函数实体。


</br>

  • C++在布局以及存取时间上主要的额外负担是哪来的?
    • virtual funciton机制,用以支持一个有效率的“执行期绑定”
    • virtual base class,用以实现“多次出现在继承体系中的base class,有一个单一而被共享的实体”


</br>

  • 继承关系指定为虚拟继承,意味着什么?
    • 在虚拟继承的情况下,base class不管在继承链中被派生(derived)多少次,永远只会存在一个实例(称为subobject)。


</br>

  • 什么时候应该在c++程序中以struct取代class?
    • 答案之一是当他让人感觉比较好的时候。单独来看,关键词本身并不提供任何差异,c++编译器对二者都提供了相同支持,我们可以认为支持struct只是为了方便将c程序迁移到c++中。


</br>

  • 那为什么我们要引入class关键词?
    • 这是因为引入的不只是class这个关键词,更多的是它所支持的封装和继承的哲学。


</br>

  • 怎么在c++中用好struct?
    • 将struct和class组合起来,组合,而非继承,才是把c和c++结合在一起的唯一可行的方法。另外,当你要传递“一个复杂的class object的全部或部分”到某个c函数去时,struct声明可以将数据封装起来,并保证拥有与c兼容的空间布局。


</br>

  • C++支持多态的方式?
    • 经由一组隐式的转化操作。例如把一个derived class指针转化为一个指向其public base type的指针
      • shape *ps=new circle();
    • 经由virtual function机制
      • ps->rotate();
    • 经由dynamic_cast和typeid运算符
      • if(circle *pc=dynamic_cast<circle *>(ps))...
    • 多态的主要用途是经由一个共同的接口来影响类型的封装,这个接口通常被定义在一个抽象的base class中。这个共享接口是以virtual function机制引发的,它可以在执行期根据object的真正类型解析出到底是哪一个函数实体被调用。


</br>

  • 需要多少内存才能表现一个class object? [^4] [^4]:详情请参考: C++中的类所占内存空间总结
    • 其nonstatic data members的总和大小
    • 加上任何由于aliginment的需求而填补上去的空间(可能存在于members之间,也可能存在于集合体边界),aliginement就是将数值调整到某数的倍数,如在32位的计算机上为4。
    • 加上为了支持virtual而由内部产生的任何额外负担(对象内的新增需求仅为一个指针)


</br>

  • 转型(cast)其实是一种编译器指令。
    • 大部分情况下它并不改变一个指针所含的真正地址,它只影响“被指出之内存大大小和其内容”的解释方式
    • 如一个类型为void *的指针只能够持有一个地址,但不能 通过它操作所指object。


</br>

  • 一个基类指针和其派生类指针有什么不同?(单一一层继承,且其都指向派生类对象)
    • 二者都指向基类对象的第一个byte,其间的差别是,派生类指针涵盖的地址包含整个派生类对象,而一个基类指针所涵盖的地址只包含派生类对象的基类子对象部分。
    • 但基类指针可以通过virtual机制访问派生类对象的函数。
    • 如果基类存在虚函数, 即使派生类被强转为基类, 虚表内依旧保存有原派生的类型,可以通过typeid 获取原派生类类型[^5] [^5]:参考一下实现: 二重调度问题:解决方案之虚函数+RTTI

  • 以下结论貌似有问题:
    </br>
  • 当一个base class object被直接初始化为(或被指定为)一个derived class object时,会发生什么?
    • derived object就会被切割(sliced)以塞入较小的base type内存中,derived type将没有留下任何蛛丝马迹。
    • 多态于是不再呈现,而一个严格的编译器可以在编译器解析一个“通过此object而触发的virtual function调用操作”,因而回避virtual机制。
    • 如果virtual function被定义为inline,则更有效率上的大收获。

      第2章 构造函数语意学(The Semantics of constructors)

      2.1 构造函数

  • 通常很多C++程序员存在两种误解:
    • 没有定义默认构造函数的类都会被编译器生成一个默认构造函数。
    • 编译器生成的默认构造函数会明确初始化类中每一个数据成员。
  • C++标准规定:
    • 如果类的设计者并未为类定义任何构造函数,那么会有一个默认构造函数被暗中生成,而这个暗中生成的默认构造函数通常是不做什么事的(无用的). 只有下面四种情况除外.
    • 换句话说,只有以下四种情况编译器必须为未声明构造函数的类生成一个会做点事的默认构造函数。我们会看到这些默认构造函数仅“忠于编译器”,而可能不会按照程序员的意愿程效命。

      包含有带默认构造函数的对象成员的类

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

        继承自带有默认构造函数的基类的类

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

      带有虚函数的类

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

      带有一个虚基类的类

  • 带有一个虚基类的类
    • 在这种情况下,编译器要将虚基类在类中的位置准备妥当,提供支持虚基类的机制。也就是说要在所有构造函数中加入实现前述功能的的代码。没有构造函数将合成默认构造函数。
    • 编译器所做的工作包括:
      • 合成默认构造函数(如果不存在任何构造函数)
      • 在对象中插入一个指向虚基类对象的指针
      • 将原来的执行存取操作改变为执行期间决定(原来是在编译时候通过偏移获取不同的值, 现改为通过指向虚基类对象的指针进行访问)

        总结

  • 从概念来上来讲,每一个没有定义构造函数的类都会由编译器来合成一个默认构造函数,以使得可以定义一个该类的对象,但是默认构造函数是否真的会被合成,将视是否有需要而定。C++ standard 将合成的默认构造函数分为 trivial(不重要) 和 notrivial(重要) 两种,前文所述的四种情况对应于notrivial默认构造函数,其它情况都属于trivial。对于一个trivial默认构造函数,编译器的态度是,既然它全无用处,干脆就不合成它。在这儿要厘清的是概念与实现的差别,概念上追求缜密完善,在实现上则追求效率,可以不要的东西就不要。

    2.2 拷贝构造函数(copy constuctor)

    • 通常C++初级程序员会认为当一个类为没有定义拷贝构造函数的时候,编译器会为其合成一个,答案是否定的。
    • 编译器只有在必要的时候在合成拷贝构造函数。
    • 这是的重点是探索: 那么编译器什么时候合成,什么时候不合成,合成的拷贝构造函数在不同情况下分别如何工作呢?
  • 拷贝构造函数的定义有一个参数的类型是其类类型的构造函数是为拷贝构造函数。如下:
      X::X( const X& x);
      Y::Y( const Y& y, int =0 ); //可以是多参数形式,但其第二个即后继参数都有一个默认值
    

    拷贝构造函数的应用:

  • 当一个类对象以另一个同类实体作为初值时,大部分情况下会调用拷贝构造函数。
    • 一般为以下三种具体情况:
      • 显式地以一个类对象作为另一个类对象的初值,形如X xx=x;
      • 类对象被作为参数交给函数时(会产生一个临时对象)
      • 函数返回一个类对象时(会产生一个临时对象)
  • 编译器何时合成拷贝构造函数
    • 并不是所有未定义有拷贝构造函数的类编译器都会为其合成拷贝构造函数,编译器只有在必要的时候才会为其合成拷贝构造函数。
    • 必要的时刻是指编译器在普通手段无法完成解决“当一个类对象以另一个同类实体作为初值”时,才会合成拷贝构造函数。也就是说,当常规武器能解决问题的时候,就没必要动用非常规武器。
    • 如果一个类没有定义拷贝构造函数,通常按照“成员逐一初始化(Default Memberwise Initialization)”的手法来解决“一个类对象以另一个同类实体作为初值”.
      • 也就是说把内建或派生的数据成员从某一个对象拷贝到另一个对象身上,如果数据成员是一个对象,则递归使用“成员逐一初始化(Default Memberwise Initialization)”的手法。
      • 成员逐一初始化(Default Memberwise Initialization)具体的实现方式则是位逐次拷贝(Bitwise copy semantics)[^6]。 [^6]: Bitwise copy semantics 是Default Memberwise Intializiation的具体实现方式。[别人的解释]
    • 以下几种情况,如果类没有定义拷贝构造函数,那么编译器将必须为类合成一个拷贝构造函数
      • 内含一个声明有拷贝构造函数成员对象(不论是设计者定义的还是编译器合成的)。
      • 继承自一个声明有拷贝构造函数的类(不论拷贝构造函数是被显示声明还是由编译器合成的)。
      • 类中声明有虚函数
      • 当类的派生串链中包含有一个或多个虚基类

        继承带拷贝构造函数的基类以及内含带拷贝构造函数的对象成员

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

    有虚函数的类

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

    继承串链中有虚基类

  • 对于继承串链中有虚基类的情况,问题同样出现在继承类向基类提供初值的情况,此时位逐次拷贝有可能破坏对象中虚基类子对象的位置。
  • 当一个相同派生类以另一个同类对象为初值时,使用位逐次拷贝是OK的,问题在于,一个class Object以派生类对象作为初值时.

    总结

  • 编译器需要合成拷贝构造函数的主要原因有三个:
    • 用户反应使用“位逐次拷贝”无法胜任,或有额外操作(通过声明拷贝构造函数)
    • 类中指向虚表的指针(估计是无法隐式的复制)
    • 类中有指向虚基类的指针(问题发生于 一个class Object以派生类对象作为初值时)

      2.3 命名返回值优化

  • 对于一个如foo()这样的函数,它的每一个返回分支都返回相同的对象,编译器有可能对其做Named return Value优化(下文都简称NRV优化),方法是以一个参数result取代返回对象
  • foo()的原型:
      X foo() 
      { 
          X xx; 
          if(...)
              return xx; 
          else 
              return xx; 
      }
    
  • 优化后的foo()以result取代xx:
      void  foo(X &result)
      {
          result.X::X();
          if(...)
          {//直接处理result
              return;
          }
          else
          {//直接处理result
              return;
          }
      }
    
  • 对比优化前与优化后的代码可以看出,对于一句类似于X a = foo()这样的代码,NRV优化后的代码相较于原代码节省了一个临时对象的空间(省略了xx),同时减少了两次函数调用(减少xx对象的默认构造函数和析构函数,以及一次拷贝构造函数的调用,增加了一次对a的默认构造函数的调用)。

  • 附加[^7]: [^7]:命名返回值优化和成员初始化队列
    • Lippman在《深度探索C++》书中指出NRV的开启与关闭取决于是否有显式定义一个拷贝构造函数,我 实在想不出有什么理由必须要有显示拷贝构造函数才能开启NRV优化,于是在vs2010中进行了测试,
    • 测试结果表明
      • 在release版本中,不论是否定义了一个显式拷贝构造函数,NRV都会开启。由此可见vs2010并不以是否有一个显式拷贝构造函数来决定NRV优化的开启与否。
      • 但同时,立足于这一点,可以得出Lippman所说的以是否有一个显式定义的拷贝构造函数来决定是否开启NRV优化,应该指的是他自己领导实现的cfront编译器,而非泛指所有编译器。
      • 那么cfront又为什么要以是否定义有显示的拷贝构造函数来决定是否开启NRV优化呢?我猜测,他大概这样以为,当显式定义有拷贝构造函数的时候一般代表着要进行深拷贝,也就是说此时的拷贝构造函数将费时较长,在这样的情况下NRV优化才会有明显的效果。反之,不开启NRV优化也不是什么大的效率损失。
    • 另外,有一点要注意的是,NRV优化,有可能带来程序员并不想要的结果,最明显的一个就是——当你的类依赖于构造函数或拷贝构造函数,甚至析构函数的调用次数的时候,想想那会发生什么。由此可见、Lippman的cfront对NRV优化抱有更谨慎的态度,而MS显然是更大胆。

      2.4 成员初始化队列(Member Initialization List)

      • 对于初始化队列,厘清一个概念是非常重要的:(大概可以如下定义)
        • 把初始化队列直接看做是对成员的定义,
        • 构造函数体中进行的则是赋值操作。
  • 有四种情况必须用到初始化列表:
    • 有const成员
    • 有引用类型成员
    • 成员对象没有默认构造函数
    • 基类对象没有默认构造函数
  • 前两者因为要求定义时初始化,所以必须明确的在初始化队列中给它们提供初值。
  • 后两者因为不提供默认构造函数,所有必须显示的调用它们的带参构造函数来定义即初始化它们。

  • 显而易见的是当类中含有对象成员或者继承自基类的时候,在初始化队列中初始化成员对象和基类子对象会在效率上得到提升——省去了一些赋值操作嘛。

  • 最后,一个关于初始化队列众所周知的陷阱,初始化队列的顺序
    • 无论在初始化列表中是什么顺序总是会按照定义的顺序进行初始化

      第3章 Data语意学(The Semantics of Data)

      3.1 C++对象的大小

  • 一个实例引出的思考
      class X{};
      class Y:virtual public X{};
      class Z:virtual public X{};
      class A:public Y, public Z{};
    
  • 猜猜sizeof上面各个类都为多少?
      // Lippman的一个法国读者的结果是
      sizeof X yielded 1
      sizeof Y yielded 8
      sizeof Z yielded 8
      sizeof A yielded 12
      // vs2010上的结果是
      sizeof X yielded 1
      sizeof Y yielded 4
      sizeof Z yielded 4
      sizeof Z yielded 8
      //gcc
      sizeof X yielded 1
      sizeof Y yielded 8
      sizeof Z yielded 8
      sizeof A yielded 16
    
  • 事实上,对于像X这样的一个的空类,编译器会对其动点手脚——隐晦的插入一个字节。为什么要这样做呢?插入了这一个字节,那么X的每一个对象都将有一个独一无二的地址。如果不插入这一个字节呢?哼哼,那对X的对象取地址的结果是什么?两个不同的X对象间地址的比较怎么办?

  • 我们再来看Y和Z。首先我们要明白的是实现虚继承,将要带来一些额外的负担——额外需要一个某种形式的指针。到目前为止,对于一个32位的机器来说Y、Z的大小应该为5,而不是8或者4。我们需要再考虑两点因素:内存对齐(alignment—)和编译器的优化。
    • alignment[^8]会将数值调整到某数的整数倍,32位计算机上位4bytes。内存对齐可以使得总线的运输量达到最高效率。所以Y、Z的大小被补齐到8就不足为奇了。 [^8]: 关于更多的memory alignment(内存对齐)的知识见VC内存对齐准则(Memory alignment), VC对齐
    • 那么在vs2010中为什么Y、Z的大小是4而不是8呢?我们先思考一个问题,X之所以被插入1字节是因为本身为空,需要这一个字节为其在内存中给它占领一个独一无二的地址。但是当这一字节被继承到Y、Z后呢?它已经完全失去了它存在的意义,为什么?因为Y、Z各自拥有一个虚基类指针,它们的大小不是0。既然这一字节在Y、Z中毫无意义,那么就没必要留着。也就是说vs2010对它们进行了优化,优化的结果是去掉了那一个字节,而Lippman的法国读者的编译器显然没有做到这一点。

  • 当我们现在再来看A的时候,一切就不是问题了。对于那位Lippman的法国读者来说,A的大小是共享的X实体1字节,X和Y的大小分别减去虚基类带来的内存空间,都是4。A的总计大小为9,alignment以后就是12了。而对于vs2010来说,那个一字节被优化后,A的大小为8,也不需再进行alignment操作。

总结

  • 影响C++类的大小的三个因素[^8]:
    • 支持特殊功能所带来的额外负担(对各种virtual的支持)。
    • 编译器对特殊情况的优化处理。
    • alignment操作,即内存对齐。

      3.2 DataMember 的绑定(略,狗啊!)

      3.3 VC内存对齐准则(Memory alignment)

      • 本文所有内容在建立在一个前提下:使用VC编译器
      • 着重点在于:
        • VC的内存对齐准则;
        • 同样的数据,不同的排列有不同的大小;
        • 在有虚函数或虚拟继承情况下又有如何影响?
      • 内存对齐?!What?Why?
        • 对于一台32位的机器来说如何才能发挥它的最佳存取效率呢?当然是每次都读4字节(32bit),这样才可以让它的bus处于最高效率。实际上它也是这么做的,即使你只需要一个字节,它也是读一个机器字长(这儿是32bit)。更重要的是,有的机器在存取或存储数据的时候它要求数据必须是对齐的,何谓对齐?它要求数据的地址从4的倍数开始,如若不然,它就报错。还有的机器它虽然不报错,但对于一个类似int变量,假如它横跨一个边界的两端,那么它将要进行两次读取才能获得这个int值。比方它存储在地址为2-5的四个字节中,那么要读取这个int,将要进行两次读取,第一次读取0-3四个字节,第二次读取4~7四个字节。但是如果我们把这个整形的起始地址调整到0,4,8…呢?一次存取就够了!这种调整就是内存对齐了。我们也可以依次类推到16位或64位的机器上。

结论

  • Ref:
  • 假设规定对齐量为4,
    • char(1byte)变量应该存储在偏移量为1的倍数的地方
    • int(4byte)则是从偏移量为4的倍数的地方
    • double(8 byte)也同样应存储在偏移量为4的倍数的地方
  • 结构体整体的大小也应该对齐,对齐依照规定对齐量与最大数据成员两者中较小的进行。
  • Vptr影响对齐而VbcPoint(Virtual base class pointer)不影响。

    3.4 C++对象的数据成员

  • 数据成员的布局
  • 对于一个类来说它的对象中只存放非静态的数据成员,但是除此之外,编译器为了实现virtual功能还会合成一些其它成员插入到对象中。我们来看看这些成员的布局。

  • C++ 标准的规定:
    • 在同一个Access Section(也就是private,public,protected片段)中,要求较晚出现的数据成员处在较大的内存中。这意味着同一个片段中的数据成员并不需要紧密相连,编译器所做的成员对齐就是一个例子。
    • 允许编译器将多个Acess Section的顺序自由排列,而不必在乎它们的声明次序。但似乎没有编译器这样做。
    • 对于继承类,C++标准并未指定是其基类成员在前还是自己的成员在前。
    • 对于虚基类成员也是同样的未予规定。
  • 一般的编译器怎么做?
    • 同一个Access Section中的数据成员按期声明顺序,依次排列。
    • 但成员与成员之间因为内存对齐的原因可能存在空当。
    • 多个Access Section按其声明顺序排放。
    • 基类的数据成员总放在自己的数据成员之前,但虚基类除外。
  • 编译器合成的成员放在哪?
    • 为了实现虚函数和虚拟继承两个功能,编译器一般会合成Vptr和Vbptr两个指针。那么这两个指针应该放在什么位置?C++标准肯定是不曾规定的,因为它甚至并没有规定如何来实现这两个功能,因此就语言层面来看是不存在这两个指针的。
    • 对于Vptr来说有的编译器将它放在末尾,如Lippman领导开发的Cfront。有的则将其放在最前面,如MS的VC,但似乎没人将它放在中间。为什么不放在中间?没有理由可以让人这么做,放在末尾,可以保持C++类对C的struct的良好兼容性,放在最前可以给多重继承下的指针或引用调用虚函数带来好处。
      • 在VS2010和VC6.0中运行的结果都是地址值&x.a比&x大4,可见说vc的vptr放在对象的最前面此言非虚
      • 实验如下:
          class X{
          public:
              int a;
              virtual void vfc(){};
          };
          int main()
          {
              using namespace std;
              X x;
              cout<<&x.a<<" "<<&x<<endl;
              system("pause");
          }
        
    • 对于Vbptr而言一般的看法为:在每一个虚派生对象中安插一个指向虚基对象的指针
      • 缺点: - 多占用了一个指针的空间 - 随着虚继承链增长, 可能会存在多次间接存取问题, 不能得到固定的存取时间 有好几种方法:
      • cfront 对第二个问题的解决方案: 在虚继承链上取nest virtual base class ptr
      • VC方案:对第二个问题的解决方案: 在虚继承体系下新增加一个vitual base class table(虚基类表),虚基类表中则存放有指向虚基类的指针,而对象内增加一个指向虚基类表的指针.
      • 另一种解决方案: 在虚函数表中放置 虚基类对象 的偏移量(相对于对象起始位置的偏移量)
        • 如虚基类对象指针 = rhs + rhs._vptr[-1][^9] [^9]:Sun公司实现的编译器 - 虚函数表取负值,表示取回虚基类对象的偏移量,rhs 表示一个存在虚基类的对象.


</br>

  • 对象成员或基类对象成员后面的填充空白不能为其它成员所用
    • 看一段代码:
        class X{
        public:
            int x;
            char c;
        };
        class X2:public X
        {
        public:char  c2;
        };
      
      • X2的布局应当是x(4),c(1),c2(1),这么说来sizeof(X2)的值应该是8?错了,实际上是12。原因在于X后面的三个字节的填充空白不能为c2所用。也就是说X2的大小实际上为:X(8)+c2(1)+填补(3)=12。
  • Vptr与Vbptr
    • 在多继承情况下,即使是多虚拟继承,继承而得的类只需维护一个Vbptr
    • 而多继承情况下Vptr则可能要维护多个Vptr,看其基类有几个虚函数。
    • 一条继承线路只有一个Vptr,但可能有多个Vbptr,视有几次虚拟继承而定。换句话说:
      • 对于继承类对象来说,不需要新合成vptr,而是使用其基类子对象的vptr。
      • 而虚拟继承类对象,必须新合成一个自己的Vbptr。
    • 实例:
        class X{
            virtual void vf(){};
        };
        class X2:virtual public X
        {
            virtual void vf(){};
        };
        class X3:virtual public  X2
        {
             virtual void vf(){};
        }
      
    • X3将包含有一个Vptr,两个Vbptr。确切的说这两个Vbptr一个属于X3,一个属于X3的子对象X2,X3通过其Vbptr找到子对象X2,而X2通过其Vbptr找到X。
    • 其中差别在于vptr通过一个虚函数表可以确切地知道要调用的函数,而Vbptr通过虚基类表只能够知道其虚基类子对象的偏移量。这两条规则是由虚函数与虚拟继承的实现方式,以及受它们的存取方式和复制控制的要求决定的。

数据成员的存取

  • 静态数据成员相当于一个仅对该类可见的全局变量,因为程序中只存在一个静态数据成员的实例,所以其地址在编译时就已经被决定。不论如何静态数据成员的存取不会带来任何额外负担。
  • 非静态数据成员的存取,相当于对象起始地址加上偏移量。效率上与C struct成员的效率等同。因为它的偏移量在编译阶段已经确定。但有一种情况例外:pt->x=0.0。当通过指针或引用来存取——x而x又是虚基类的成员的时候。因为必须要等到执行期才能知道pt指向的确切类型,所以必须通过一个间接导引才能完成

  • 附加:
    • 类中对静态成员变量取址: 对静态成员取地址,将会得到一个指向数据类型的指针,而不是一个指向class member的指针,因为静态成员并不内含与class object中.

      小结

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

      第4章 Function语意学(The Semantics of Function)

      4.1 C++之成员函数调用

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

非静态成员函数(Nonstatic Member Functions)

  • 保证nostatic member function至少必须和一般的nonmember function有相同的效率是C++的设计准则之一。 事实上在c++中非静态成员函数(nostatic member function)与普通函数的调用也确实具有相同的效率,因为本质上非静态成员函数就如同一个普通函数.
    • 编译器内部会将成员函数等价转换为非成员函数,具体是这样做的: 改写成员函数的签名,使得其可以接受一个额外参数,这个额外参数即是this指针, 当然如果成员函数是const的,插入的this 参数类型将为 const xxx 类型。
    • 例:
        float Point::X();
        //成员函数X被插入额外参数this
        float Point:: X(Point* this );
      
      - 非静态成员函数X float Point::X();就相当于一个普通函数float X(Point* this);
      • 将每一个对非静态数据成员的操作都改写为经过this操作。
      • 将成员函数写成一个外部函数,对函数名进行“mangling”处理,使之成为独一无二的名称。
  • 将一个成员函数改写成一个外部函数的关键在于两点,
    • 一是给函数提供一个可以直接读写成员数据的通道,给函数提供一个额外的指针参数
    • 二是解决好有可能带来的名字冲突,通过一定的规则将名字转换,使之独一无二。

  • T:2019/10/16 19:13
  • 起始对于一个类中的成员函数(包括虚函数),提供一个可以直接读写成员数据的通道, 至关重要!!!, 看面关于多继承下的多态问题,对这个概念尤为深刻。

  • 由此可以做出一点总结:
    • 一个成员函数实际上就是一个被插入了一个接受其类的指针类型的额外参数的非成员函数,还要额外对函数的名称进行处理。额外插入的参数用来访问数据成员,而名称的特殊处理用来避免名字冲突。
  • 对于名称的特殊处理并没有统一的标准,各大编译器厂商可能有不同的处理规则。
    • 在VC下上述的成员函数X()的名称X处理后就 成了?X@Point@@QAEMXZ
    • 更多信息可以参见维基百科的Visual C++名字修饰。
  • VC中对于上面的例子中的成员函数的调用将发生如下的转换:
      //p->X();被转化为
      ?X@Point@@QAEMXZ(p);
      //obj.X();被转化为
      ?X@Point@@QAEMXZ(&obj);
    

    虚拟成员函数(Virtual Member Functions)

  • 如果function()是一个虚拟函数,
    • 那么用指针或引用进行的调用将发生一点特别的转换 —— 一个中间层被引入进来。
  • 例如:
      // p->function()
      // 将转化为
      (*p->vptr[1])(p);
    
    • 其中vptr为指向虚函数表的指针,它由编译器产生。vptr也要进行名字处理,因为一个继承体系可能有多个vptr。
    • 1是虚函数在虚函数表中的索引,通过它关联到虚函数function().
  • 何时发生这种转换?答案是在必需的时候——一个再熟悉不过的答案。
  • 当通过指针调用的时候,要调用的函数实体无法在编译期决定,必需待到执行期才能获得,所以上面引入一个间接层的转换必不可少。
  • 但是==当我们通过对象(不是引用,也不是指针)来调用的时候,进行上面的转换就显得多余了,因为在编译器要调用的函数实体已经被决定。此时调用发生的转换,与一个非静态成员函数(Nonstatic Member Functions)调用发生的转换一致。==

    静态成员函数(Static Member Functions)

  • 静态成员函数的一些特性:
    • 不能够直接存取其类中的非静态成员(nostatic members),包括不能调用非静态成员函数(Nonstatic Member Functions)。
    • 不能够声明为 const、voliatile或virtual
    • 它不需经由对象调用,当然,通过对象调用也被允许。
  • 除了缺乏一个this指针他与非静态成员函数没有太大的差别。 在这里通过对象调用和通过指针或引用调用,将被转化为同样的调用代码。
  • 需要注意的是通过一个表达式或函数对静态成员函数进行调用,C++ Standard要求对表达式进行求值。
    • 如:(a+=b).static_fuc();
  • 虽然省去对a+b求值对于static_fuc()的调用并没有影响,但是程序员肯定会认为表达式a+=b已经执行,一旦编译器为了效率省去了这一步,很难说会浪费多少程序员多少时间。这无疑是一个明智的规定。

4.2 C++之虚函数(Virtual Member Functions)

  • 深度探索C++对象模型》是这样来说多态的:
    • 在C++中,多态表示“以一个public base class的指针(或引用),寻址出一个derived class object”的意思。

静态多态性与动态多态性2

  • 多态:以一个 “public base class” 的指针寻址出一个 “derived class object”(深入探索C++对象模型定义)
    • 静态多态性: 通常称为编译时多态,到底模板是不是多态???我个人认为不是
    • 动态多态性: 通常称为运行时多态,通过虚函数来实现
  • 动态多态性的两个条件:
    • 在基类中必须使用虚函数或纯虚函数
    • 调用函数时使用基类的指针或引用

      消极多态与积极多态

  • 消极多态(没有进行虚函数的调用)
    • 用基类指针来寻址继承类的对象,我们可以这样:
      • Point ptr=new Point3d; //Point3d继承自Point
    • 在这种情况下,多态可以在编译期完成(虚基类情况除外),因此被称作消极多态(没有进行虚函数的调用, 指针的多态机能主要扮演一个输送机制的角色)。
  • 积极多态(对象类型需要在执行期才能决定)
    • 积极多态的例子如虚函数和RTTI
    • 如下例关于虚函数的调用, 虚函数的实现机制,将保证调用的z()函数实现,为Point3d:: z()而不是调用了Point:: z()。
    • 例子:
        //例1,虚函数的调用
        ptr->z();
        //例2,RTTI 的应用
        if(Point3d *p=dynamic_cast<Point3d*>(ptr) )
            return p->z();
      

      虚函数的实现

  • 虚函数的实现:
    • 为每个有虚函数的类配一张虚函数表,它存储该类类型信息和所有虚函数执行期的地址。
    • 为每个有虚函数的类插入一个指针(vptr),这个指针指向该类的虚函数表。
    • 给每一个虚函数指派一个在表中的索引。
    • 用这种模型来实现虚函数得益于在C++中,虚函数的地址在编译期是可知的,而且这一地址是固定不变的。而且表的大小不会在执行期增大或减小。

  • 类的虚函数表中存储有类型信息:
    • 有类型信息(存储在索引为0的位置)
    • 所有虚函数地址
    • 部分编译器还把虚基类的指针放到了虚函数表里面,如早期的Sum编译器
  • 虚函数地址包括三种:
    • 这个类定义的虚函数,会改写(overriding)一个可能存在的基类的虚函数实体——假如基类也定义有这个虚函数。
    • 继承自基类的虚函数实体,——基类定义有,而这个类却没有定义。直接继承之。
    • 一个纯虚函数实体。用来在虚函数表中占座,有时候也可以当做执行期异常处理函数。
  • 每一个虚函数都被指派一个固定的索引值,这个索引值在整个继承体系中保持前后关联,例如,假如z()在Point虚函数表中的索引值为2,那么在Point3d虚函数表中的索引值也为2。

    单继承下的虚函数

  • 类单继承自有虚函数的基类时,将按如下步骤构建虚函数表
    • 继承基类中声明的虚函数——这些虚函数的实体地址被拷贝到继承类中的虚函数表中对于的slot中。
    • 如果有改写(override)基类的虚函数,那么在1中应将改写(override)的函数实体的地址放入对应的slot中而不是拷贝基类的。
    • 如果有定义新的虚函数,那么将虚函数表扩大一个slot以存放新的函数实体地址。
  • 例子:
      ptr->z();
      //被编译器转化为:
      (*ptr->vptr[4])(ptr);
    
  • 我们假设z()函数在Point虚函数表中的索引为4,回到最初的问题——要如何来保证在执行期调用的是正确的z()实体?其中微妙在于,编译将做一个小小的转换:
  • 这个转换保证了调用到正确的实体,因为:
    • 虽然我们不知道ptr所指的真正类型,但它可以通过vptr找到正确类型的虚函数表。在整个继承体系中z()的地址总是被放在slot 4。

多重继承下的虚函数

  • 多重继承中支持virtual functions,其复杂度围绕在第二个及后继的base classes身上,以及“必须在执行期调整this指针”这一点。如下是多重继承体系:
      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 suboject身上,有三个问题要解决:
    • 1.virtual destructor;(进行删除对象的时候,需要把指针调整至于派生类上)
    • 2.被继承下来的Base2::mumble();(需要把指针调整至Base2上)
    • 3.一组clone()函数实例。
  • 分析: (注意编译器需要调整Base2的指针)
      Base2* pbase2 = new Derived;
    	 
      //编译器转化,新的Derived对象必须调整
      //以指向Base2 subobject
      Derived* temp = new Derived;
      Base2* pbase2 = temp?temp + sizeof(Base1):0;
    	 
      //调整后,调用虚函数正确
      pbase2->data_Base2; // 指向base2
    	 
      //删除pbase2时候,必须正确调用virtual destructor实例
      //然后delete
      delete pbase2; // 指向derived
    
  • 实现过程(Thunk技术):
    • 在多重继承之下,一个derived class内含n-1个额外的virtual tables,n表示其上一次base classes的个数(因此单一继承将不会产生额外的virtual tables)。对于本例而言的Derived,会有两个virtual tables被编译器产生:
      • 一个主要实例,与Base1(最左端base clss)共享, vtbl_Derived; //主要表格
      • 一个次要实例,与Base2(第二个base class)有关,vtbl_Base2_Derived; //次要表格
    • Thunk允许 虚函数表中的slot包括的地址包括两个类型:
      • 不需要调整地址, 指向虚函数实体地址
      • 需要调整地址,指向一个相关的Thunk(估计是一个偏移量之类的)
    • 当将一个Derived对象地址指定给一个Base1指针或Derived指针,被处理的virtual table主要表格vtbl_Derived;
    • 当将一个Derived对象地址指定给一个Base2指针时,被处理的virtual tables是次要表格vtbl_Base2_Derived;
    • 分析
      • 通过“指向第二个base class”的指针,调用derived class virtual function。ptr必须调整指向Baes2 subobject。
      • 通过“指向derived class”的指针,调用第二个base class中一个继承而来的virtual function。derived class 指针必须再次调整指向第二Base2 subobject
  • 图示 多重基类虚表配置
  • 做如下小结:
  • 多继承下的虚函数,影响到虚函数的调用的实际质上为this的调整。而this调整一般为两种:
    • 调整指针指向对应的sub object,一般发生在继承类类型指针向基类类型指针赋值的情况下。
    • 将指向sub object的指针调整回继承类对象的起始点,一般发生在基类指针对继承类虚函数进行调用的时候。
  • 第一点,使得该基类指针指向一个与其指针类型匹配的子对象,唯有如此才能保证使得该指针在执行与其指针类型相匹配的特定行为的正确性。比方调用基类的成员,获得正确的虚函数地址。
  • 第二点,显然是让一个继承类的虚函数获取一个正确的this指针,因为一个继承类虚函数要的是一个指向继承类对象的this指针,而不是指向其子对象。
  • 第一顺序继承类之所以不需要进行调整的关键在于,其sub object的起点与继承类对象的起点一致。

    虚拟继承下的虚函数

  • Lippman说,如果一个虚基类派生自另一虚基类,而且它们都支持虚函数和非静态数据成员的时候,编译器对虚基类的支持就像迷宫一样复杂。
  • 虚基类虚表配置

    4.3 指向成员函数的指针

    “指向Nonstatic Member Functions”的指针

    • 取一个nonstatic data member的地址,得到的结果是该member在 class 布局中的byte位置(再加1),它是一个不完整的值,须要被绑定于某个 class object的地址上,才可以被存取.
    • 取一个nonstatic member function的地址,假设该函数是nonvirtual,则得到的结果是它在内存中真正的地址.然而这个值也是不全然的,它也须要被绑定与某个 class object的地址上,才可以通过它调用该函数,全部的nonstatic member functions都须要对象的地址(以參数 this 指出).
  • 关于指向成员函数指针的 声明 赋值 调用:
    • 声明member function的指针:
        double		// return type
        (Point::*	// class the function is member
         pmf)		// name of the pointer to member
        ();			// argument list
      
    • 定义并初始化该指针:double (Point::*coord)() = &Point::x;
    • 指针赋值: coord = &Point::y;
    • 调用它:(origin.*coord)();(ptr->*coord)();
      • 这些操作会被编译器转化为: (coord)(&origin);(coord)(ptr);
  • 关于指向成员函数指针的分析
    • 指向member function的指针的声明语法 中加入 Point::* 的作用是是作为 this 指针的空间保留者.这这也就是为什么 static member function(没有 this 指针)的类型是”函数指针”,而不是”指向member function的指针”的原因.
  • 效率的讨论:
    • 使用一个”member function指针”,(不用于 virtual function,多重继承,virtual base class 等情况的话),并不会比使用一个”nonmember function指针”的成本更高.
    • virtual function,多重继承,virtual base class 三种情况的话对于”member function指针”的类型以及调用都太过复杂

      “指向Virtual Member Functions”的指针

  • 注意以下的程序片段:
      float (Point::*pmf)() = &Point::z;
      Point *ptr = new Point3d;
    
  • pmf,一个指向member function的指针,被设值为Point::z()(一个 virtual function)的地址,ptr则被指定以一个Point3d对象,
    • 直接经由ptr调用z(): ptr->z(); 则被调用的是point3d:: z(),
    • 从pmf间接调用z() (ptr->pmf)(); 仍然是Point3d:: z()被调用
  • 也就是说,虚拟机制仍然可以在使用”指向member function的指针”的情况下运行 !

  • 对一个nonstatic member function取其地址,将获得该函数在内存中的地址,然而面对一个 virtual function,其地址在编译时期是未知的,所能直到的仅是 virtual function在其相关的 virtual table中的索引值.也就是说,对一个 virtual member function取其地址,所能获得的仅仅是一个索引值.
  • 具体实现过程如下:
  • 例子:
      class Point {
      public:
          virtual ~Point();
          float x();
          float y();
          virtual float z();
      };
    
  • 对nonstatic函数取地址:
    • 取x()或y()的地址: &Point::x();&Point::y(); 得到的则是函数在内存中的地址,由于它们不是 virtual
  • 对virtual函数取地址:
    • 取得destructor的地址:&Point::~Point; 得到的结果是1(索引值.)
    • 取z()的地址:&Point:: z(); 得到的结果是2(索引值.)
  • 对指向虚函数的函数指针调用:
    • 通过pmf来调用z(),会被内部转化为一个编译时期的式子,一般形式例如以下: (*ptr->vptr[(int)pmf])(ptr);
    • 对一个”指向member function的指针”评估求值(evaluted),会由于该值有两种意义而复杂化;其调用操作也将有别于常规调用操作.
    • pmf的内部定义,为float (Point::*pmf)();
      • 这里该定义可以指向nonvirtual x()和 virtual z()两个member functions,因为其有着同样的原型:
        • 只是当中一个代表内存地址
        • 还有一个代表 virtual table中的索引值
            // 二者都能够被指定给pmf
            float Point::x() { return _x; }
            float Point::z() { return 0; }
          
    • 因此,编译器必须定义pmf使它能够
      • (1)还有两种数值,
      • (2)更重要的是其数值能够被差别代表内存地址还是 virtual table中的索引值.

        第5章 构造、解构、拷贝 语意学(Semantics of Construction,Destruction,and Copy)

        几点类设计原则

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

  • 不要将析构函数设计为纯虚的,这不是一个好的设计。 将析构函数设计为纯虚函数意味着,即使纯虚函数在语法上允许我们只声明而不定义纯虚函数,但还是必须实现该纯虚析构函数,否则它所有的继承类都将遇到链接错误。
    • 必须定义纯虚析构函数,而不能仅仅声明它的原因在于:
      • 每一个继承类的析构函数会被编译器加以扩展,以静态调用方式其每一个基类的析构函数(假如有的话,不论是显示的还是编译器合成的),所以只要任何一个基类的析构函数缺乏定义,就会导致链接失败。
      • 矛盾就在这里,纯虚函数的语法,允许只声明而不定义纯虚析构函数,而编译器则死脑筋的看到一个其基类的析构函数声明,则去调用它的实体,而不管它有没有被定义。
  • 真的必要的时候才使用虚函数,不要滥用虚函数。 虚函数意味着不小的成本,编译很可能给你的类带来膨胀效应:
    • 每一个对象要多负担一个word的vptr。给每一个构造函数(不论是显示的还是编译器合成的),插入一些代码来初始化vptr,这些代码必须被放在所有基类构造函数的调用之后,但需在任意用户代码之前。
    • 没有构造函数则需要合成,并插入代码。
    • 合成一个拷贝构造函数和一个复制操作符(如果没有的话),并插入对vptr的初始化代码,有的话也需要插入vptr的初始化代码。
    • 意味着,如果具有bitwise语意,将不再具有,然后是变大的对象、没有那么高效的构造函数,没有那么高效的复制控制。
  • 不能决定一个虚函数是否需要 const ,那么就不要它
  • 决不在构造函数或析构函数中使用虚函数机制(并不是说不要把构造函数和析构函数设置为虚函数)
    • 在构造函数中,每次调用虚函数会被决议为当前构造函数所对应类的虚函数实体,虚函数机制并不起作用。
    • 当一个base类的构造函数含有对虚函数vf()的调用,当其派生类derived的构造函数调用基类base的构造函数的时候,其中调用的虚函数vf()是base中的实体,而不是derived中的实体。
      • 这是由vptr初始化的位置决定的——在所有基类构造函数调用之后,在程序员供应的代码或是成员初始化队列之前
      • 因构造函数的调用顺序是:有根源到末端,由内而外,所以对象的构造过程可以看成是,从构建一个最基础的对象开始,一步步构建成一个目标对象。析构函数则有着与构造相反的顺序,因此在构造或析构函数中使用虚函数机制,往往不是程序员的意图。若要在构造函数或析构函数中调用虚函数,应当直接以静态方式调用,而不要通过虚函数机制。

        构造、复制、析构语意学

  • 一种所谓的Plain OI’Data声明形式:
      struct Point {
          float x,y,z;
      };
    
  • 概念上来讲,对于一段这样的C++代码,编译器会为之合成一个默认构造函数、复制构造函数、析构函数、赋值操作符。
  • 然而实际上编译器会分析这段代码,并给Point贴上Plain OI’Data标签。编译器在此后对于Point的处理与在C中完全一样,也就是说上述的函数都不会被合成。可见概念上应当由编译器合成的函数,并不一定会合成,编译器只有在必要的时候才会合成它们。由此一来,原本在观念上应该调用这些函数的地方实质上不会调用,而是用其它的方法来完成上面的功能,比方复制控制会用bitwise copy。
  • 对象构造语意学
  • 单继承体系下的对象构造 对照一下
    • 对于简单定义的一个对象T object;,很明显它的默认构造函数会被调用(被编译器合成的或用户提供的)。但是一个构造函数究竟做了什么,就显得比较复杂了——编译器给了它很多的隐藏代码。编译器一般会做如下扩充操作:
    • 调用所有虚基类的构造函数,从左到右,从最深到最浅:
      • 如果该类被列于成员初始化列表中,任何明确指定的参数,都应该被传递过来。若没有列入成员初始化列表中,虚基类的一个默认构造函数被调用(有的话)。
      • 此外,要保证虚基类的偏移量在执行期可存取,对于使用vbptr来实现虚基类的编译器来说,满足这点要求就是对vbptr的初始化。
      • 然而,只有在类对象代表着“most-derived class”时,这些构造函数才可能会被调用。一些支持这个行为的代码会被放进去(直观点说就是,虚基类的构造由最外层类控制)。
    • 调用所有基类构造函数,依声明顺序:
      • 如果该基类被列入了成员初始化队列,那么所有明确指定的参数,应该被传递过来。
      • 没有列入的话,那么调用其默认构造函数,如果有的话。
      • 如果该基类是第二顺位或之后的基类,this 指针必须被调整。
    • 正确初始化vptr,如果有的话。
    • 调用没有出现在初始化成员列表中的member object的默认构造函数,如果有的话。
    • 记录在成员初始化队列中的数据成员初始化操作以声明的顺序被放进构造函数中。

  • 多重继承的构造函数流程:
    • 在派生类构造函数中,所有虚基类以及上一层的基类的构造函数都被调用
    • 对象的vptr(s)被初始化指向相关的virtual table(s)
    • 执行构造函数的成员初始化列表
    • 执行程序猿 提供的初始化代码段;
  • 对象复制语意学
    • 设计一个类,并考虑到要以一个对象指定给另一个对象时,有三种选择:
      • 什么都不做,采用编译器提供默认行为(bitwise copy或者由编译器合成一个)。
      • 自己提供一个赋值运算符操作。
      • 明确拒绝将一个对象指定给另一个对象。
    • 对于第三点,只要将赋值操作符声明为private,且不定义它就可以了。
    • 对于第二点,只有在第一点的行为不安全或不正确,或你特别想往其中插入点东西的时候。
    • 以下四种情况 copy assignment operator(还是用它的英文名,感觉顺畅点),不具有bitwise copy语意,也就是说这些情况下,编译器要合成copy assignment operator而不能依靠bitwise copy来完成赋值操作,这四种情况与构造函数、拷贝构造函数的情况类似,原因可以参考它们的。四种情况如下:
      • 类包含有定义了copy assignment operator的class object成员。
      • 类的基类有copy assignment operator。
      • 类声明有任何虚函数的时候(问题同样会出现在由继承类对象向基类对象拷贝的时候)。
      • 当class继承体系中有虚基类时。
    • 在虚拟继承情况下,copy assignment opertator会遇到一个不可避免的问题,virtual base class sub object的复制行为会发生多次,与前面说到的在虚拟继承情况下虚基类被构造多次是一个意思,不同的是在这里不能抑制非most-derived class 对virtual base class 的赋值行为。
      • 安全的做法是把虚基类的赋值放在最后,避免被覆盖。

  • 对象析构语意学
    • 只有在基类拥有析构函数,或者object member拥有析构函数的时候,编译器才为类合成析构函数,否则都被视为不需要。
    • 析构的顺序正好与构造相反:
      • 本身的析构函数被执行。
      • 以声明的相反顺序调用member object 的析构函数,如果有的话。
      • 重设vptr 指向适当的基类的虚函数表,如果有的话。
      • 以声明相反的顺序调用上一层的析构函数,如果有的话。
      • 如果当前类是 most-derived class,那么以构造的相反顺序调用虚基类的析构函数。

        第6章 执行期语意学(Runting Semantics)

  1. 实际上struct还要复杂一点,它有时表现的会和C struct完全一样,有时则会成为class的胞兄弟。 ↩︎

  2. Ref:C++虚函数表,虚表指针,内存分布

  3. Ref: 深度探索c++对象模型(一)

  4. 详情请参考: C++中的类所占内存空间总结

  5. 参考一下实现: 二重调度问题:解决方案之虚函数+RTTI

  6. Bitwise copy semantics 是Default Memberwise Intializiation的具体实现方式。[别人的解释]

  7. 命名返回值优化和成员初始化队列

  8. 关于更多的memory alignment(内存对齐)的知识见VC内存对齐准则(Memory alignment),VC对齐

  9. Sun公司实现的编译器 - 虚函数表取负值,表示取回虚基类对象的偏移量,rhs 表示一个存在虚基类的对象. ↩︎

  10. 静态多态性与动态多态性