深度探索c++对象模型

why

为什么要学习或者总结这本书?

  1. 了解c++对象模型底层实现
  2. 了解在语言之下,编译器做了什么事情
  3. 希望可以写出没有错误倾向而且比较有效率的C++代码
  4. 消除各种对c++的错误认识

what

包含哪些内容?

  1. 关于对象,提供以对象为基础的观念背景以及c++提供的面向对象程序设计范式
  2. 构造函数语意学,讨论constructor 如何工作
  3. Data 语意学,讨论data members的处理
  4. Function 语意学,讨论各种member function,特别是virtual function
  5. 构造、析构、拷贝语意学,讨论如何支持class模型和Object生命周期
  6. 执行期语意学,讨论执行期的某些对象模型行为,包括临时性对象生命周期,new,delete支持
  7. 对象模型的尖端:exception handling, template support, runtime type identification·

how

如何推进学习进度?计划是什么?如何实践书中理论知识?如何加强理解?

  1. 分章节学习,总结,计划是一天一章
  2. 对于书中的知识,如果需要验证的,可以写些demo验证
  3. 多思考c++对象模型为什么要这样设计或实现,有其他的实现方案吗?如果有想法可以记录下来

一、关于对象

c++对象模式

  1. 加上封装后的布局成本:

    c++ obj并没有增加成本,nonstatic data members直接内含在每一个obj中,就像c struct一样。而member function虽然在class内声明,却不出现在object中。每一个non-inline member func只会诞生一个函数实例。而每一个”拥有零个或一个定义“的inline function则会在其每一个使用者(模块)上产生一个函数实例

  2. c++ 在布局和存取时间上的主要额外负担是由virtual引起的

    1. virtual function 执行期绑定
    2. virtual base class
    3. 多重继承:一个derived class和他的第二或后继base class的转换
  3. c++ 对象模式,解决如何存储对象成员的问题

    2种class data member: static, non-static

    3种class member functions: static, non-static, virtual

    1. 简单对象模型

      object 内部是一系列的slots,每一个slot指向一个members,按其声明顺序填充member指针。

      对于继承:包含base class object的地址,或者有一个base table和bptr。

      缺点:间接性导致的空间和存取时间上的额外负担

    2. 表格驱动对象模型

      把所有与members相关的信息抽出来,放在一个 data member table和一个member function table中,class object本身内含这两个表格的指针。data table 直接包含member,func table则包含指针。

    3. c++ 对象模型

      non static data members 放在object内, static data member, static func member, non static func member 放在object之外。virtual function 以两个步骤支持:

      1. 每个class产生一堆指向virtual function的指针,放在virtual function table中
      2. 每个class object被安插一个指针,指向相关的virtual function table,即vptr。vptr的设定和重置由constructor, destructor, copy assignment 运算符自动完成。每个class关联的 type_info object(支持RTTI),也经由vitual table被指出来,通常放在表格的第一个slot
      3. 优点:空间和存取时间效率高,缺点:如果用到的class object的non static data member被修改,需要重新编译。
      4. 对于继承:base class subject 直接放置于derived class object,缺点:base class改变,derive class 需要重新编译

关键词所带来的的差异

  1. c程序员的技巧有时候却成为c++程序员的陷进

    struct mumble{
    	char pc[1];
    };
    struct mumble *pm = (struct mumble*) malloc(sizeof(struct mumble) + strlen(string) + 1);
    strcpy(&mumble.pc, string);
    

    如果改用class 来声明,而该class是:

    1. 指定多个access sections,内含数据(不同access section的顺序不一定)
    2. 从另一个class派生而来(父子顺序不一定)
    3. 定义了一个或多个virtual functions(vptr的顺序不一定)

    c++ 中凡是处于同一个access section的数据,必定保证以期声明顺序出现在内存布局中。然而被放置在多个access section中的成员,排列顺序就不一定了。

  2. 如果需要一个相当复杂的c++ class的某部分数据,使其拥有c声明的那种模样,那么这部分最好抽取出来成为一个独立的struct声明

    1. 从struct 中派生c++部分

      struct C_point {};
      class Point : public C_point{};
      

      这种方式不再被推荐,因为某些编译器(Microsoft C++)在支持virtual function的机制中对于class的继承布局做了一些变化

    2. 组合,而非继承,才是把c和c++结合在一起的唯一可行方法。当把c_point传递到C函数时,保证c_point 有与C兼容的空间布局。

      struct C_point{};
      class Point{
      public:
      	operator C_point(){return _c_point;}
      private:
      	C_point _c_point;
      };
      

对象的差异

  1. 三种程序设计范式

    1. procedural model 过程式设计
    2. Abstract data type model, ADT 拥有固定而单一的类型,在编译期就完全定义好了
    3. Object-oriented model(支持多态, 通过 pointer 或者 reference 来支持)
  2. c++多态只存在于一个个的public class继承体系中,并以下列方法支持多态:

    1. 经由一组隐式的转化操作 shape* ps = new circle();

    2. 经由virtual function 机制 ps->rotate();

    3. 经由dynamic_cast和typeid 运算符

      if (circle * pc = dynamic_cast<circle*> (ps) ) {}

  3. c++ class object 内存计算:
    1. non static data member 的总和大小
    2. alignment 内存对齐
    3. 为支持virtual产生的额外负担
  4. 指针的类型会教导编译器如何解释某个特定地址中的内存内容及其大小。一个pointer或者reference之所以支持多态,是因为他们并不引发内存中任何 ”与类型有关的内存委托操作“,会受到改变的,只有他们所指向的内存的大小和内容的解释方式而已。

二、 构造函数语意学

Default Constructor的构造操作

有4种情况,会造成”编译器必须为未声明constructor的class合成一个default constructor“。C++ standard把那些合成物称为implicit nontrivial default constructors. 被合成出来的constructor 只能满足编译器(而非程序)的需要。它之所以能够完成任务,是借着”调用member object 或者 base class 的default constructor“ ,或是”为每一个object初始化其virtual function机制或virtual base class 机制“ 而完成的。 至于没有存在那4种情况而又没有声明任何constructor 的class,我们说它拥有的是implicit trivial constructor, 他们实际上不会被合成出来。

在合成的default constructor中,只有base class subobject 和 member class objects会被初始化。所有其他nonstatic data member(如整数,指针,数组)都不会被初始化,这些初始化对程序而言或许有需要,但对编译器则非必要,这是程序员的责任。

注:关于初始化,Global Object的内存保证会在程序启动的时候被清0,Local Objects 配置于程序的堆栈中,heap object配置于自由空间中,都不一定会清0,他们的内容将是内存上次被使用的遗迹

Class会合成Nontrivial default constructor 的四种情况:

  1. member class object带有Default Constructor
    1. 合成只在Constructor真正需要被调用时才会发生。
    2. 为了避免在不同的编译模块中合成出多个,合成的default construct, copy constructor, destructor, assignment copy operator都以inline方式完成,一个inline函数有静态链接特性,不会被文件以外者看到。如果函数太复杂,不适合做成inline,就会合成一个explicit non-inline static 实例。
    3. 如果显示定义了constructor,编译器会扩张所有的constructor,在其中安插一些代码,以执行所有编译器需要的操作(在user code 之前),如果有多个member class object要求constructor 初始化操作,将以他们在class中的声明顺序调用各个constructors
  2. Base Class 带有 Default Constructor

    1. 与第一种情况类似,只是调用的是base class 的default Constructor,如果有多个base class,按声明顺序调用,如果还有member class object的default Constructor需要调用,会在base class之后调用。
  3. 带有一个Virtual Function

    1. 产生一个virtual function table
    2. 在每一个class object中,会放置一个vptr指向virtual funciton table。(构造函数需要做的事,类似第一,二种情况,如果没有显示定义,则合成一个执行该行为,否则在现有的Constructor中安插代码)
    3. 改写多态调用代码,通过vptr调用
  4. 带有一个Virtual Base Class

    1. 目的:必须使virtual base class在其每一个derived class object中的位置,能够于执行期准备妥当

      class x {public: int i;};
      class A : public virtual X {public: int j;};
      class B : public virtual X {public: double d;};
      class C : public A, public B {public: int k;};
            
      // 无法在编译期决定出(resolve) pa->X::i 的位置, 因为pa的真正类型可以改变
      void foo(const A* pa) {pa->i = 1024;}
      

      比如cfront的做法是: 在derived class object中为每一个virtual base class 安插一个指针,因此编译期可能修改 如下:

      // __vbcX 表示编译器所产生的指针,指向 virtual base class X
      void foo(const A* pa) {pa->__vbcX->i = 1024;}
      

      在合成的default constructor执行 或者 在现有constructor中 安插相关代码,以允许每一个virtual base class的执行期存取操作。

消除了2个误解:

  1. 任何class如果没有定义default constructor, 就会合成一个出来
  2. 编译器合成出来的default constructor 会显示设定”class 内每一个data member的默认值”

copy constructor 的构造操作

有3种情况,会以一个object的内容作为另一个class object的初值:

class X{};   
X x;   
// 1. 显示初始化
X xx = x

extern void foo(X x);
// 2. 作为参数,隐式初始化
foo(xx);

// 3. 当函数返回一个class object
X foo_bar() {
	X xx;
	return xx;
}

如果class 的设计者显示定义了一个copy constructor, 那么在上面3种情况下,该copy constructor 会被调用。否则,会以default memberwise initialization(逐成员初始化)来完成:每一个内建的或派生的data member(指针、数组),从某个object 拷贝一份到另一个object上,但不会拷贝member class object, 而是递归施行memberwise initialization.

// 其第二参数及后继参数以一个默认值供应
X::X(const X& x);
Y::Y(const Y& y, int = 0);

Copy constructor 与 defalut constructor一样,只在必要的时候由编译器合成出来。对于copy constructor,其必要是指当class不展现bitwise copy semantics时,此时为non trivial,会被真正合成出来,否则为trivial,不会真正合成。

有4种情况,class不展现bitwise copy semantics:

  1. member class object 声明有一个copy constructor(显示声明或是由编译器合成)
  2. base class 声明有一个copy constructor(显示声明或由编译器合成)
  3. 有一个或多个virtual function
  4. 有一个或多个virtual base class

1 和 2, 编译器必须将member 或base class 的 ”copy constructor 调用操作“ 安插到被合成的copy constructor.

重新设定vptr

对于3来说,合成出来的copy constructor 会重新设定object中指向virtual table的指针,即vptr。

举个例子,如果Bear 继承自 Animal,且包含virtual function, 那么:

  1. Bear b1; Bear b2 = b1;
  2. Animal a1; Animal a2 = a1;
  3. Animal a3 = b1; // 发生切割行为

上面1、2两种情况都可以靠 “bitwise copy semantics” 完成,因为他们的vptr指向同一个vtbl。a3的vptr不可以被设定指向Bear class的vtbl,但如果是bitwise copy的话,就会导致该结果。

处理virtual base class subobject

对于4来说,一个class object以另一个class object作为初值,而后者有一个virtual base class subobject时,会使bitwise copy semantics 失效。

每一个编译器对于虚拟继承的支持承若,都代表必须让”derived class object 中的 virtual base class subobject位置 “在执行期就准备妥当。维护位置的完整性是编译器的责任。”bitwise copy semantics“ 可能会破坏这个位置,所以编译器必须在它自己合成出来的copy constructor中做出仲裁。举例:

class Animal {};
class Raccoon : public virtual Animal{};
class RedPanda : public Raccoon {};
ReadPanda little_red;
// 问题发生在当一个class object以其derived class object初始化时.
// bitwise copy不够,编译器必须显示将little_critter 的virtual base class pointer/offset 初始化
// (在合成的copy constructor中执行,同时还要执行memberwise copy,以及其他内存相关操作)
Raccoon little_critter = little_red;

程序转化语意学

  1. 显示的初始化操作,转化分两个阶段:

    1. 重写每一个定义,其中的初始化操作会被剥除(c++中定义是指占用内存的行为)
    2. class 的copy constructor 调用操作会被安插进去,举个栗子:
    X x0;
    void foo_bar() {
    	X x1(x0);
    	X x2 = x0;
    	X X3 = X(x0);
    }
       
    // 转换为:
    viod foo_bar() {
    	X x1;
    	X x2;
    	X x3;
       	
    	x1.X::X(x0);
    	x2.X::X(x0);
    	x3.X::X(x0);
    }
    
  2. 参数的初始化,转化操作有两种策略:

    1. 导入临时性object,并调用copy constructor 将他初始化,然后将此临时性 object 交给函数。举个栗子:

      void foo(X x0)
      X xx;
      foo(xx);
            
      // 转化为:
      void foo(X& x0) // 修改函数声明
      X __temp0;      // 编译器产生的临时对象
      __temp0.X::X(xx);
      foo(__temp0);
      
    2. 以”拷贝构建“的方式把实际参数直接建构在其应该的位置上,此位置是函数活动范围的不同,记录于程序堆栈中。在函数返回前,局部对象的destructor被执行。

  3. 返回值的初始化,双阶段转化:

    1. 首先加上一个额外参数,类型是 class object 的一个reference,用来放置被”拷贝构建“而得的返回值。
    2. 在return指令之前安插一个copy constructor调用操作,以便将欲传回之object的内容当做上述新增参数的初值

    举个栗子(可以跟下文NRV优化转化的差别):

    x bar() {
    	X xx;
    	// 处理 xx ...
    	return xx;
    }
       
    // 转化为:
    void bar(X& __result) {
    	X xx;
    	xx.X::X(); // 编译器产生的 default constructor调用操作
    	// 处理 xx...
    	__result.X::X(xx);// 编译器产生的 copy constructor调用操作
    	return; 
    }
    
  4. 在使用者层面做优化(不推荐的做法)

    定义一个计算用的constructor,以提升效率,举例:

    // 原来的写法
    X bar(const T &y, const T &z) {
      X xx;
      return xx;
    }
       
    // 程序员改写为
    X bar(const T &y, cosnt T &z){
      return X(y, z);
    }
       
    // 被编译器转化为
    void bar(X &__result, const T &y, const T &z) {
      __result.X::X(y, z); // __result 被直接计算出来,而不是经由拷贝而来。
      return;
    }
    
  5. 在编译器层面做优化

    在一个像bar() 这样的函数中,所有的 return 指令都传回相同的具名数据,编译器有可能自己做优化,方法是以 result 参数取代named return value. 举个栗子:

    X bar() {
    	X xx;
      // ...处理 xx
      return xx;
    }
       
    // NRV 优化会将它转化为
    void bar(X &__result) {
      __result.X::X();
      // ...直接处理__result
      return;
    }
    

    注:copy constructor会激活NRV优化,如果没有copy constructor不会执行NRV。NRV的执行不通过另外独立的优化工具完成。

    NRV饱受批评的原因:

    1. 优化由编译器默默完成,而是否真的被完成,并不十分清楚
    2. 一旦函数变的比较复杂,优化也就难以施行
    3. 打破了copy constructor和destructor的对称性,即在”object 是经由copy而完成其初始化“的情况下,copy constructor不一定会被调用。

    讨论”剔除copy constructor调用操作“的合法性:

    1. NRV 优化非常重要,不应该被驳回
    2. 要消除static object的copy constructor几乎确定是不被允许的。对于local object,则不确定。
    3. 如果class需要大量的memberwise初始化操作,例如以传值方式传回object,那么在编译器提供NRV的情况下,提供一个copy constructor的explicit inline函数实例是非常合理的。需要注意的是,如果copy constructor 使用memcpy或memset拷贝对象,那么只有在class不包含任何由编译器产生的members时才能有效运行,例如vptr, virtual base class 的指针或offset。因为memcpy, memset 等操作会改写由编译安插的代码设置的这些值,这些代码在user code 之前运行。

成员初始化列表

必须使用 member initialization list的4种情况:

  1. 初始化一个reference member
  2. 初始化一个const member
  3. 调用一个base class的constructor,而他拥有一组参数时
  4. 调用一个member class的constructor,而他拥有一组参数时

编译器会对initialization list 一一处理并可能重新排序,以反映出members的声明顺序。他会安插一些代码到constructor体内,并置于任何explicit user code之前。

三、Data语意学

从虚拟继承引发的对象大小疑惑开始(开发环境 MacOS clang)

#include <gtest/gtest.h>
#include <iostream>

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

TEST(ModelTest, vbc) {
  std::cout << "sizeof * = " << sizeof(int*) << std::endl; // 8
  std::cout << "sizeof X = " << sizeof(X) << std::endl;    // 1
  std::cout << "sizeof Y = " << sizeof(Y) << std::endl; 	 // 8
  std::cout << "sizeof Z = " << sizeof(Z) << std::endl;    // 8
  std::cout << "sizeof A = " << sizeof(A) << std::endl;    // 16
}

下面几点知识有助于理解上述现象:

  1. 空类对象大小不为0,编译器会安插一个char,使得两个对象在内存中有独一无二的地址
  2. 如果空类含有其他 ”语言本身造成的额外负担(编译器植入的,如vptr)“,则1中那个char会被编译器优化掉
  3. 虚基类的成员需要额外的负担以支持运行期绑定(常见实现如:vbtr,或者在vptr中的slots中存储偏移,见下文)
  4. 如果没有2中的优化,额外插入的char导致需要考虑 alignment。
  5. 多重继承中为了维护父类对象的完整性,会存在多个vptr,因此 sizeof(A) = 16; 见下文

Data Member 的绑定

对 inline member function body的分析,会直到整个class的声明都出现了才开始评估求值。但对于inline member function的argument list(包含参数列表和返回值类型),却会在他们第一次出现时被适当的决议。举例如下(使用sizeof来验证,开发环境 MacOS clang):

#include <gtest/gtest.h>
#include <iostream>

typedef int length;

class Point3d {
 public:
  // lenght 为 int
  void mumble(length val) {
    std::cout << "sizeof(val) = " << sizeof(val) << std::endl;    // 4
    std::cout << "sizeof(_val) = " << sizeof(_val) << std::endl;  // 8
  }
	// length 为 int
  length mumble() { return _val; }

 private:
  // 会使前一个 typedef 失效
  typedef long lenght;
  // length 为 long
  lenght _val;
};

TEST(ModleTest, name_binding) {
  Point3d p3d;
  p3d.mumble(5);
  auto v = p3d.mumble();
  std::cout << "sizeof int = " << sizeof(int) << std::endl;         // 4
  std::cout << "sizeof long = " << sizeof(long) << std::endl;       // 8
  std::cout << "sizeof p3d.mumble() = " << sizeof(v) << std::endl;  // 4
}

所以需要有防御性编程风格:总是把”nested type声明“ 放在class的起始处。

Data Member 的布局

c++ 标准要求,在同一个access section(单个private,public,protected,而不是相同访问权限的section合在一起之后的)中,member的排序只需符合”较晚出现的members在class object中有较高的地址“这一条即可,即并不要求是连续排列。members之间可能会有因alignment而填补的bytes,或者是编译器插入的vptr等(注:vptr 一般放在object开头或者尾部)。而不同的access section 中的member顺序则没有要求(但目前所有编译器都是把一个以上的access section 连锁在一起,依照声明的顺序,成为一个连续区块)。

下面使用两种方式判断哪个access section在前面。这两个member要是不同的section中第一个声明的member。

#include <gtest/gtest.h>
#include <iostream>
#include <string>

class Point3d {
 public:
  float x;
 public:
  float y;
 public:
  float z;
};

template <class class_type, class data_type1, class data_type2>
void access_order(data_type1 class_type::*mem1, data_type2 class_type::*mem2) {
  union {
    data_type1 class_type::*mem;
    int val;
  } u1 = {mem1};

  union {
    data_type2 class_type::*mem;
    int val;
  } u2 = {mem2};
  
  assert(u1.val != u2.val);
  std::cout << "u1.val: " << u1.val << ", u2.val: " << u2.val << std::endl;

  // 方法一,指针转long比较
  std::string s1 = (long)(&(((class_type*)0)->*mem1)) < (long)(&(((class_type*)0)->*mem2))
                       ? "member 1 first"
                       : "member 2 first";

  // 方法二,使用union获取 数据成员指针 值
  std::string s2 = u1.val < u2.val ? "member 1 first" : "member 2 first";
  std::cout << s1 << std::endl << s2 << std::endl;
}

TEST(ModleTest, access_section) { access_order(&Point3d::z, &Point3d::y); }

Data Member 的存取

Static Data Members

static data members 只有一个实例,被放在程序的 data segment中,每次程序取用static member时,就会被内部转化为对该唯一extern实例的直接参考操作。其存取许可(privte,protected或public),以及与class的关联(比如,static data member是从复杂继承关系链继承而来,甚至从virtual base class继承而来),不会导致任何空间或时间上的额外负担,在内部都是直接存取。举个例子:

class Point3d {
	public:
		static const int chunkSize = 250;
  	float y;
};
Point3d origin, *pt = &origin;
origin.chunkSize == 250;
pt->chunkSize == 250;
// 都会被转化为
Point3d::chunkSize == 250;

若取一个static data member的地址,会得到一个指向其数据类型的指针,而不是指向class member的指针。

另外,static member 的内部名称会经过name-mangling修饰。

NonStatic data member

对一个nonStatic data member的存取,需要把class object的起始地址加上data member的偏移位置.比如

Point3d origin;  
&origin.y; 
// 等价于 
&origin + (&Point3d::y - 1);

这里为啥要-1呢?指向data member的指针,其offset值总是被加上1,这样可以使编译系统区分”一个指向data member的指针,用以指出class的第一个member“ 和 ”一个指向data member的指针,没有指向任何member“ 两种情况(注:MacOS clang拿到的指向成员的指针,并没有+1,原因可能是编译器转化过了,并且可以区分这两种情况,测试如下:)

class Point2d {
 public:
  Point2d(int x = 0, int y = 0) : _x(x), _y(y) {}
 public:
  int _x, _y;
};

int main() {
  int Point2d::*p1 = 0;
  int Point2d::*p2 = &Point2d::_x;
  if (p1 == p2) {
    cout << "p1 p2 contains the same value" << endl;
  } else {
    cout << "not same" << endl; // this
  }
}

因为non-static member的偏移位置在编译期就可获知,因此存取一个non-static class data member的效率和存取一个 c struct member、单一继承或多重继承而来的 base class data member 完全相同。唯一区别在于使用指针存取从virtual base class中继承而来的data member时,这种情况下速度会慢一点。比如:

Point3d origin, *pt = &origin;
origin.x = 0;
pt->x = 0;

当point3d 是一个derived class,x是从virtual base class中继承来的member,此时由于pt的类型不确定,因此无法知道x的真正偏移位置,所以存取操作必须延迟到执行期,经由一个额外的间接导引,才能解决。而origin没有这个问题,他的类型是确定的,所以x的偏移位置就确定了,不需要执行期存取。

###继承 与 Data Member

讨论各种继承场景下data member 布局,包括以下几种情况:

  1. 单一继承 + 非多态
  2. 单一继承 + 多态
  3. 多重继承
  4. 虚继承

单一继承非多态

这种情况与c struct一样,由derived class object 加上 base class subobject组成。需要注意的是,把一个class分解为两层或多层,有可能为了”表现class体系之抽象化“而膨胀所需的空间。因为c++保证“base class subobject 的完整原样性”。举例如下(32位机器):

class Concrete {
 public:
  Concrete(int v, char d1, char d2, char d3) : val(v), c1(d1), c2(d2), c3(d3) {}

 private:
  int val;
  char c1;
  char c2;
  char c3;
};

cout << sizeof(Concrete); // 8, 其中对齐需要1 byte

class Concrete1 {
 public:
  Concrete1(int v, char c) : val(v), bit1(c) {}

 private:
  int val;
  char bit1;
};

class Concrete2 : public Concrete1 {
 public:
  Concrete2(int v, char c1, char c2) : Concrete1(v, c1), bit2(c2) {}

 private:
  char bit2;
};

class Concrete3 : public Concrete2 {
 public:
  Concrete3(long val, char c1, char c2, char c3) : Concrete2(val, c1, c2), bit3(c3) {}

 private:
  char bit3;
};
cout << sizeof(Concrete3); // 16 = 8 + 4(1 + 3 byte alignment) + 4(1 + 3 byte alignment)

注:我在MacOS clang 12.0 和ubuntu g++9.3.0 环境上测试结果与上述理论违背,应该是编译器做了某些优化:

TEST(ModelTest, alignment) {
  std::cout << "sizeof(Concrete) = "  << sizeof(Concrete) << std::endl;   // 8
  std::cout << "sizeof(Concrete1) = " << sizeof(Concrete1) << std::endl;  // 8
  std::cout << "sizeof(Concrete2) = " << sizeof(Concrete2) << std::endl;  // 8
  std::cout << "sizeof(Concrete3) = " << sizeof(Concrete3) << std::endl;  // 8
  Concrete c{1, 'a', 'b', 'c'};
  Concrete3 c3{1, 'a', 'b', 'c'};
  char* pc = reinterpret_cast<char*>(&c);
  char* pc3 = reinterpret_cast<char*>(&c3);
  for (int i = 0; i < 8; i++, pc++) { // 1 0 0 0 97 98 99 0 
    cout << (int)(*pc) << " ";
  }
  cout << endl;
  for (int i = 0; i < 8; i++, pc3++) { // 1 0 0 0 97 98 99 0 
    cout << (int)*pc3 << " ";
  }
  cout << endl;
  
  // 测试Concrete2 subobject 赋值给 Concrete1 是否会覆盖 bit2 位置
  // 结果表明不会覆盖bit2的值,但是aligment bytes不再是全0
  Concrete1* pc1_1 = new Concrete1(2, 'd');
  Concrete2* pc2 = new Concrete2(3, 'e', 'f');
  Concrete1* pc1_2 = pc2;
  char* pp1 = reinterpret_cast<char*>(pc1_1);
  for (int i = 0; i < 8; i++, pp1++) { // 2 0 0 0 100 0 0 48
    cout << (int)(*pp1) << " ";
  }
  cout << endl;
  *pc1_1 = *pc1_2;
  pp1 = reinterpret_cast<char*>(pc1_1); 
  for (int i = 0; i < 8; i++, pp1++) { // 3 0 0 0 101 0 0 48
    cout << (int)(*pp1) << " ";
  }
  cout << endl;
}

单一继承多态

class Point2d {
 public:
  Point2d(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}
  float x() { return _x; }
  float y() { return _y; }
  virtual float z() { return 0.0; }
  virtual void operator+=(const Point2d& rhs) { _x += rhs.x() _y += rhs.y(); }

 protected:
  float _x, _y;
};

class Point3d : public Point2d {
 public:
  Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point2d(x, y), _z(z) {}
  float z() { return _z; }
  void operator+=(const Point2d& rhs) {
    Point2d::operator+=(rhs);
    _z += rhs.z();
  }

 protected:
  float _x, _y, _z;
};

额外负担:

  1. Point2d 和 Point3d的virtual table
  2. class object 中的vptr (空间负担)
  3. 加强constructor设置vptr的初值(时间负担),指向class对应的vtbl
  4. 加强destructor,执行与constructor相反的操作,即抹消 ”指向class相关vtbl“的vptr

vptr的位置:

  1. 放在尾端是早期的做法,可以保留base C struct的对象布局,与C兼容性较好
  2. 放在开头是后来的做法,对于 ”在多重继承之下,通过指向class members的指针调用virtual function“,会带来一些帮助,参考下文”Function 语意学“

单一继承单一继承

多重继承

多重继承的问题主要发生于derived class object和其第二或后继base class object之间的转换。将多重派生对象的地址指定给第一个base class 的指针,与单一继承相同,二者都有相同的起始地址。接下来分析以下继承关系链的对象布局:

#include <gtest/gtest.h>
#include <iostream>
#include <string>

using namespace std;

class Point2d {
 public:
  Point2d(int x = 0, int y = 0) : _x(x), _y(y) {}
  virtual int z() const { return 0; }

 protected:
  int _x, _y;
};

class Point3d : public Point2d {
 public:
  Point3d(int x = 0, int y = 0, int z = 0) : Point2d(x, y), _z(z) {}
  int z() { return _z; }

 protected:
  int _z;
};

class Vertex {
 public:
  Vertex(int v = 0) : _v(v) {}
  virtual void print() {}

 protected:
  int _v;
};

class Vertex3d : public Point3d, public Vertex {
 public:
  Vertex3d(int m = 0) : _m(m) {}
  Vertex3d(int x, int y, int z, int v, int m) : Point3d(x, y, z), Vertex(v), _m(m) {}

 protected:
  int _m;
};

// 单个vptr且放置在开始位置的内存布局打印
template <class T>
void print_layout_with_single_vptr_start(T t) {
  cout << "-------- type " << typeid(T).name() << " layout --------" << endl;
  union {
    T t;
    struct {
      void* vptr;
      int i[1];
    } layout;
  } ut{t};
  cout << "sizeof(" << typeid(T).name() << ") = " << sizeof(T) << ", sizeof(ut) = " << sizeof(ut)
       << endl;  // should be equal
  cout << "[0  ~  7] " << typeid(T).name() << " vptr: " << ut.layout.vptr << endl;
  int i = 0;
  for (int* p = ut.layout.i; reinterpret_cast<char*>(p) < reinterpret_cast<char*>(&ut) + sizeof(ut);
       p++, i++) {
    char str[256];
    snprintf(str,
             256,
             "[%02d ~ %02d] int[%d]: %08x",
             8 + sizeof(int) * i,
             8 + sizeof(int) * (i + 1) - 1,
             i,
             *p);
    cout << str << endl;
  }
  cout << endl;
}

// 验证多重派生对象的布局
TEST(ModelTest, Multiple_inheritance) {
  Point2d p2d{1, 2};
  Point3d p3d{1, 2, 3};
  Vertex v{1};
  Vertex3d v3d{1, 2, 3, 4, 5};
  print_layout_with_single_vptr_start(p2d);
  print_layout_with_single_vptr_start(p3d);
  print_layout_with_single_vptr_start(v);
  print_layout_with_single_vptr_start(v3d);
}

MacOS clang 12.0 环境输出如下:

-------- type 7Point2d layout --------
sizeof(7Point2d) = 16, sizeof(ut) = 16
[0  ~  7] 7Point2d vptr: 0x102cd8188
[08 ~ 11] int[0]: 00000001
[12 ~ 15] int[1]: 00000002

-------- type 7Point3d layout --------
sizeof(7Point3d) = 24, sizeof(ut) = 24
[0  ~  7] 7Point3d vptr: 0x102cd81b0
[08 ~ 11] int[0]: 00000001
[12 ~ 15] int[1]: 00000002
[16 ~ 19] int[2]: 00000003
[20 ~ 23] int[3]: 00007ffe

-------- type 6Vertex layout --------
sizeof(6Vertex) = 16, sizeof(ut) = 16
[0  ~  7] 6Vertex vptr: 0x102cd81e0
[08 ~ 11] int[0]: 00000001
[12 ~ 15] int[1]: 5d335b74

-------- type 8Vertex3d layout --------
sizeof(8Vertex3d) = 40, sizeof(ut) = 40
[0  ~  7] 8Vertex3d vptr: 0x102cd8208
[08 ~ 11] int[0]: 00000001
[12 ~ 15] int[1]: 00000002
[16 ~ 19] int[2]: 00000003
[20 ~ 23] int[3]: 00007ffe	# 这是alignment 的byte,按8字节对齐
[24 ~ 27] int[4]: 02cd8220  # 下面两行很明显是一个vptr指针 0x102cd8220
[28 ~ 31] int[5]: 00000001
[32 ~ 35] int[6]: 00000004
[36 ~ 39] int[7]: 00000005

Vertex3d中的 vptr 与 Point3d 和 Vertex 中的 vptr 均不一致,说明派生类中的vtbl与父类中的并不是同一个,且多重继承情况下,派生类中会产生多个vtbl)。

另外,这里编译器同样做了优化,没有保证base class subobject 的完整原样性。内存布局图参考如下:

多重继承继承

根据上面的布局图,当派生对象指针指向赋值给他的第二或后继base class指针时,编译器内部需要做转化:

Vertex3d v3d, *pv3d;
Vertex *pv;

pv = &v3d;
// 需要转化为
pv = (Vertex*)((char*)&v3d) + sizeof(Point3d);

pv = pv3d;
// 需要转换为
pv = pv3d ? (Vertex*)((char*)pv3d) + sizeof(Point3d) : 0;

存取第二或后继base class中的meber,并不会带来额外负担,因为member的位置在编译期就固定了,存取member只是一个简单的offset操作,就像单一继承一样简单—不管是经由指针、reference 还是object。

虚拟继承

虚拟继承是为了解决类似菱形继承中的virtual base class subobject的共享问题。如果class含有virutal base class, 那么他的object内存被分为两部分:不变区域可变区域。不变区域中的数据,不管后继如何演化,总是拥有固定的offset(从object的开头算起),这部分数据可以被直接存取。而共享区域就是指virtual base class,这部分的数据,其位置会因为每次的派生操作而有变化,所以他们只可以被间接存取。接下来,还是通过打印内存布局来验证(MacOS clang 12.0):

// layout.cc
class Point2d {
 public:
  Point2d(int x = 0, int y = 0) : _x(x), _y(y) {}
  virtual int z() const { return 0; }

 public:
  int _x, _y;
};

class Vertex : public virtual Point2d {
 public:
  Vertex(int v = 0) : _v(v) {}
  Vertex(int x, int y, int v) : Point2d(x, y), _v(v) {}
  virtual void print() {}

 public:
  int _v;
};

class Point3d : public virtual Point2d {
 public:
  Point3d(int x = 0, int y = 0, int z = 0) : Point2d(x, y), _z(z) {}
  int z() { return _z; }

 public:
  int _z;
};

class Vertex3d : public Point3d, public Vertex {
 public:
  Vertex3d(int m = 0) : _m(m) {}
  Vertex3d(int x, int y, int z, int v, int m)
      : Point3d(x, y, z), Vertex(x, y, v), Point2d(x, y), _m(m) {}

 public:
  int _m;
};

int main() { return sizeof(Vertex3d); }

使用自己实现的print_layout_with_single_vptr_start打印内存布局:

------- type 7Point2d layout --------
sizeof(7Point2d) = 16, sizeof(ut) = 16
[0  ~  7] 7Point2d vptr: 0x101ad1190 # vptr
[08 ~ 11] int[0]: 00000001
[12 ~ 15] int[1]: 00000002

-------- type 7Point3d layout --------
sizeof(7Point3d) = 32, sizeof(ut) = 32
[0  ~  7] 7Point3d vptr: 0x101ad11c0 # vptr
[08 ~ 11] int[0]: 00000003
[12 ~ 15] int[1]: 00007ffe  # alignment
[16 ~ 19] int[2]: 01ad11d8  # vptr 0x101ad11d8
[20 ~ 23] int[3]: 00000001
[24 ~ 27] int[4]: 00000001
[28 ~ 31] int[5]: 00000002

-------- type 6Vertex layout --------
sizeof(6Vertex) = 32, sizeof(ut) = 32
[0  ~  7] 6Vertex vptr: 0x101ad1230
[08 ~ 11] int[0]: 00000004
[12 ~ 15] int[1]: 00007ffe # alignment
[16 ~ 19] int[2]: 01ad1250 # vptr 0x101ad1250
[20 ~ 23] int[3]: 00000001
[24 ~ 27] int[4]: 00000001
[28 ~ 31] int[5]: 00000002

-------- type 8Vertex3d layout --------
sizeof(8Vertex3d) = 48, sizeof(ut) = 48
[0  ~  7] 8Vertex3d vptr: 0x101ad12a8 # vptr
[08 ~ 11] int[0]: 00000003
[12 ~ 15] int[1]: 00000006 # alignment
[16 ~ 19] int[2]: 01ad12c0 # vertex vptr 0x101ad12c0
[20 ~ 23] int[3]: 00000001
[24 ~ 27] int[4]: 00000004 # _v
[28 ~ 31] int[5]: 00000005 # _m
[32 ~ 35] int[6]: 01ad12e0 # p2d vptr 0x101ad12e0
[36 ~ 39] int[7]: 00000001
[40 ~ 43] int[8]: 00000001
[44 ~ 47] int[9]: 00000002

使用clang 打印内存布局如下,与上面结果一致:

clang -cc1 -fdump-record-layouts layout.cc

*** Dumping AST Record Layout
         0 | class Point2d
         0 |   (Point2d vtable pointer)
         8 |   int _x
        12 |   int _y
           | [sizeof=16, dsize=16, align=8,
           |  nvsize=16, nvalign=8]

*** Dumping AST Record Layout
         0 | class Point3d
         0 |   (Point3d vtable pointer)
         8 |   int _z
        16 |   class Point2d (virtual base)
        16 |     (Point2d vtable pointer)
        24 |     int _x
        28 |     int _y
           | [sizeof=32, dsize=32, align=8,
           |  nvsize=12, nvalign=8]

*** Dumping AST Record Layout
         0 | class Vertex
         0 |   (Vertex vtable pointer)
         8 |   int _v
        16 |   class Point2d (virtual base)
        16 |     (Point2d vtable pointer)
        24 |     int _x
        28 |     int _y
           | [sizeof=32, dsize=32, align=8,
           |  nvsize=12, nvalign=8]

*** Dumping AST Record Layout
         0 | class Vertex3d
         0 |   class Point3d (primary base)
         0 |     (Point3d vtable pointer)
         8 |     int _z
        16 |   class Vertex (base)
        16 |     (Vertex vtable pointer)
        24 |     int _v
        28 |   int _m
        32 |   class Point2d (virtual base)
        32 |     (Point2d vtable pointer)
        40 |     int _x
        44 |     int _y
           | [sizeof=48, dsize=48, align=8,
           |  nvsize=32, nvalign=8]

使用 g++ 打印内存布局(结果有点难看懂,但给出了virtual table的详细信息):

g++ -fdump-lang-class -c layout.cc

Vtable for Point2d
Point2d::_ZTV7Point2d: 3 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Point2d)
16    (int (*)(...))Point2d::z

Class Point2d
   size=16 align=8
   base size=16 base align=8
Point2d (0x0x7f6d75ceb420) 0
    vptr=((& Point2d::_ZTV7Point2d) + 16)

Vtable for Vertex
Vertex::_ZTV6Vertex: 8 entries
0     16                           # 共享vbc object偏移,虚拟继承才有
8     (int (*)(...))0              # 本subobject的偏移
16    (int (*)(...))(& _ZTI6Vertex)
24    (int (*)(...))Vertex::print
32    0                             # 0 表示共享对象的偏移
40    (int (*)(...))-16             # Point2d 在 vertex object 中的偏移 为16
48    (int (*)(...))(& _ZTI6Vertex) # type info
56    (int (*)(...))Point2d::z

VTT for Vertex
Vertex::_ZTT6Vertex: 2 entries
0     ((& Vertex::_ZTV6Vertex) + 24)
8     ((& Vertex::_ZTV6Vertex) + 56)

Class Vertex
   size=32 align=8
   base size=12 base align=8
Vertex (0x0x7f6d75b971a0) 0
    vptridx=0 vptr=((& Vertex::_ZTV6Vertex) + 24)
  Point2d (0x0x7f6d75ceb660) 16 virtual
      vptridx=8 vbaseoffset=-24 vptr=((& Vertex::_ZTV6Vertex) + 56) # vbaseoffset 应该跟共享对象有关

Vtable for Point3d      # 与 
Point3d::_ZTV7Point3d: 7 entries
0     16
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI7Point3d)
24    0
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI7Point3d)
48    (int (*)(...))Point2d::z

VTT for Point3d
Point3d::_ZTT7Point3d: 2 entries
0     ((& Point3d::_ZTV7Point3d) + 24)
8     ((& Point3d::_ZTV7Point3d) + 48)

Class Point3d
   size=32 align=8
   base size=12 base align=8
Point3d (0x0x7f6d75b97270) 0
    vptridx=0 vptr=((& Point3d::_ZTV7Point3d) + 24)
  Point2d (0x0x7f6d75cebb40) 16 virtual
      vptridx=8 vbaseoffset=-24 vptr=((& Point3d::_ZTV7Point3d) + 48)

Vtable for Vertex3d
Vertex3d::_ZTV8Vertex3d: 11 entries
0     32                 # 共享对象偏移
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI8Vertex3d)
24    16
32    (int (*)(...))-16
40    (int (*)(...))(& _ZTI8Vertex3d)
48    (int (*)(...))Vertex::print
56    0
64    (int (*)(...))-32
72    (int (*)(...))(& _ZTI8Vertex3d)
80    (int (*)(...))Point2d::z

Construction vtable for Point3d (0x0x7f6d75b97340 instance) in Vertex3d
Vertex3d::_ZTC8Vertex3d0_7Point3d: 7 entries
0     32
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI7Point3d)
24    0
32    (int (*)(...))-32
40    (int (*)(...))(& _ZTI7Point3d)
48    (int (*)(...))Point2d::z

Construction vtable for Vertex (0x0x7f6d75b973a8 instance) in Vertex3d
Vertex3d::_ZTC8Vertex3d16_6Vertex: 8 entries
0     16
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI6Vertex)
24    (int (*)(...))Vertex::print
32    0
40    (int (*)(...))-16
48    (int (*)(...))(& _ZTI6Vertex)
56    (int (*)(...))Point2d::z

VTT for Vertex3d
Vertex3d::_ZTT8Vertex3d: 7 entries
0     ((& Vertex3d::_ZTV8Vertex3d) + 24)
8     ((& Vertex3d::_ZTC8Vertex3d0_7Point3d) + 24)
16    ((& Vertex3d::_ZTC8Vertex3d0_7Point3d) + 48)
24    ((& Vertex3d::_ZTC8Vertex3d16_6Vertex) + 24)
32    ((& Vertex3d::_ZTC8Vertex3d16_6Vertex) + 56)
40    ((& Vertex3d::_ZTV8Vertex3d) + 80)
48    ((& Vertex3d::_ZTV8Vertex3d) + 48)

Class Vertex3d
   size=48 align=8
   base size=32 base align=8
Vertex3d (0x0x7f6d75d17000) 0
    vptridx=0 vptr=((& Vertex3d::_ZTV8Vertex3d) + 24)
  Point3d (0x0x7f6d75b97340) 0
      primary-for Vertex3d (0x0x7f6d75d17000)
      subvttidx=8
    Point2d (0x0x7f6d75cebde0) 32 virtual
        vptridx=40 vbaseoffset=-24 vptr=((& Vertex3d::_ZTV8Vertex3d) + 80)
  Vertex (0x0x7f6d75b973a8) 16
      subvttidx=24 vptridx=48 vptr=((& Vertex3d::_ZTV8Vertex3d) + 48)
    Point2d (0x0x7f6d75cebde0) alternative-path

布局图(MacOS clang 12.0, 元素类型 int 与 float不影响布局):

虚拟继承 内存布局

关于virtual base class subobject 的定位是使用Pointer Strategy(使用指针定位 vbc subobject) 还是在vtbl 中 “负偏移位置存储 vbc subobject的offset”,有待验证。Microsoft 使用 virtual base class table ? Sun 编译器 使用 vtbl offset? MacOS Clang呢?g++ 呢?

由于vbc subobject 的offset在编译器已确定,因此经由一个非多态的object来存取其member,并不会导致额外的负担,可以直接存取。

四、Funtion 语意学

member 的各种调用方式

NonStatic member function

c++的设计准则之一就是nonstatic member function 至少必须和一般的nonmember function有相同的效率,编译器会在内部将member function 转换为对等的 nonmember function,转换分3步:

  1. 改写函数签名,安插额外参数this指针(参数类型为 T * const this, 如果函数本身声明为const,则为 const T* const this)
  2. 将每一个“对nonstatic data member的存取操作” 改为经由this指针来存取
  3. 将member function重新写成一个外部函数,函数名经过mangling处理。(同时改写所有调用它的地方。)

virtual member function

通过指针调用虚函数,会被转化为通过vtbl调用,而通过对象或者class scope operator则会跟nonStatic member 一样被决议。

ptr->func() ==> (*ptr->vptr[1])(ptr)
Poin3d::func() 或者 obj.func() ==> func_7Point3dFv(&obj);

不走vtbl的优化有什么影响呢?如果virtual function 是inline,那么它可以被扩展从而提升效率

Static member function

static member function 的调用会被转换为nonmember function调用,他有一下几个特点:

  1. 不能直接存取class的nonstatic members
  2. 不能声明为const,volatile,virtual
  3. 不需要经由class object才被调用,经由object调用是语法上的便利,内部会被转化为直接调用
  4. 取其地址,是一个nonmember函数指针,而不是“指向 class member function的指针”

virtual Member function

c++多态表示“用一个public base class的指针寻址出一个derived class object“ 的意思。

是否需要多态看是否有virtual function。 多态机制需要额外的执行期信息以辅助决议,以ptr->z()举例,需要知道:

  1. ptr 所指对象的真实类型,以决议正确的z实例。(注意多重继承下的this指针调整就是为了定位到真实的对象)
  2. z的位置,即地址,以便能调用它。

编译期会准备好vtbl,其中包含了type info和virtual function的地址,为了在执行期找到正确的函数地址,还需要:

  1. 为了找到vtbl,每个class object被安插一个vptr,指向表格。
  2. 为了找到函数地址,每个virtual function被指派一个 表格索引。

这些信息使得编译器可以把ptr->z() 转化为 (*ptr->vptr[i])(ptr),唯一需要在执行期确定的是哪一个z()函数实例?

vtbl中的virtual function包括:

  1. class 定义的函数实例,他改写了base class virtual function实例
  2. 继承自base class的函数实例
  3. 一个pure_virtual_called() 函数实例,可以认为他是pure virtual function 在vtbl中的占位符,因为pure virtual func 没有实现。如果该函数被调用,通常的结果是结束程序。

单一继承

三种情况:

  1. 继承base class的virtual function,该函数的地址会被拷贝到derived class 的vtbl对应的slot中
  2. 使用自己的函数实例改写base class的virtual function,那么自己的函数地址会被放置在vtbl对应的slot中
  3. 加入一个新的virtual function,vtbl增加一个slot放置该函数地址。

下面使用ubuntu g++ 9.3来查看vtbl的slot中的内容,代码如下:

// test.cpp
class Point {
 public:
  Point(float x);
  virtual ~Point();
  virtual Point& mult(float) = 0 ;
  float x() const { return _x; }
  virtual float y() const;
  virtual float z() const {return 0;}

 public:
  float _x;
};

class Point2d : public Point {
 public:
  Point2d(float x, float y);
  ~Point2d();
  Point2d& mult(float);
  float y() const;
  float _y;
};

class Point3d : public Point2d {
 public:
  Point3d();
  ~Point3d();
  Point3d& mult(float);
  float z() const;
  float _z;
};

int main() { return sizeof(Point); }

使用 g++ -fdump-lang-class -c test.cpp 命令打印类层次及vtbl信息:

Vtable for Point
Point::_ZTV5Point: 7 entries
0     (int (*)(...))0									 # 偏移为0(参考多重继承)
8     (int (*)(...))(& _ZTI5Point)     # 这个应该是 type info
16    0                                # 存在纯虚函数的情况下,析构函数的slot是0
24    0																 # 同上,这里会有两个析构函数的slot,具体含义不太清楚
32    (int (*)(...))__cxa_pure_virtual # 纯虚函数占位符
40    (int (*)(...))Point::y
48    (int (*)(...))Point::z

Class Point
   size=16 align=8
   base size=12 base align=8
Point (0x0x7f3c97068420) 0
    vptr=((& Point::_ZTV5Point) + 16)  # 指定从 vtbl offset +16 开始?

Vtable for Point2d
Point2d::_ZTV7Point2d: 7 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Point2d)
16    (int (*)(...))Point2d::~Point2d  # 由于在Point2d中,mult不是纯虚函数了,所以这里是析构函数的地址
24    (int (*)(...))Point2d::~Point2d  # 同上,但为什么有两个?应该是为了区分堆和栈上的对象析构?联想到符号D0Ev,D1Ev
32    (int (*)(...))Point2d::mult      # 实现了纯虚函数
40    (int (*)(...))Point2d::y         # 改写了 Point::y
48    (int (*)(...))Point::z           # 继承   Point::z

Class Point2d
   size=16 align=8
   base size=16 base align=8
Point2d (0x0x7f3c96f141a0) 0
    vptr=((& Point2d::_ZTV7Point2d) + 16)
  Point (0x0x7f3c97068540) 0
      primary-for Point2d (0x0x7f3c96f141a0)

Vtable for Point3d
Point3d::_ZTV7Point3d: 7 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Point3d)
16    (int (*)(...))Point3d::~Point3d
24    (int (*)(...))Point3d::~Point3d
32    (int (*)(...))Point3d::mult
40    (int (*)(...))Point2d::y
48    (int (*)(...))Point3d::z

Class Point3d
   size=24 align=8
   base size=20 base align=8
Point3d (0x0x7f3c96f14208) 0
    vptr=((& Point3d::_ZTV7Point3d) + 16)
  Point2d (0x0x7f3c96f14270) 0
      primary-for Point3d (0x0x7f3c96f14208)
    Point (0x0x7f3c970685a0) 0
        primary-for Point2d (0x0x7f3c96f14270)

多重继承

以下面的代码示例讲解:

class Base1 {
 public:
  Base1();
  virtual ~Base1();
  virtual void speadClearly();
  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;
};

int main() { return sizeof(Derived); }

使用 g++ -fdump-lang-class -c test.cpp 命令打印类层次及vtbl信息:

几种析构函数符号:

D0Ev mangled: 堆对象析构时,调用该版本(即用 delete p 析构时)。 D1Ev mangled: 位于类层次的最底层,且为栈对象时,调用该版本。 D2Ev mangled: 位于类层次结构的非底层,即作为基类使用时,调用该版本。

Vtable for Base1
Base1::_ZTV5Base1: 6 entries
0     (int (*)(...))0               # 偏移
8     (int (*)(...))(& _ZTI5Base1)  # type info
16    (int (*)(...))Base1::~Base1
24    (int (*)(...))Base1::~Base1
32    (int (*)(...))Base1::speadClearly
40    (int (*)(...))Base1::Clone

Class Base1
   size=16 align=8
   base size=12 base align=8
Base1 (0x0x7f04706f5420) 0
    vptr=((& Base1::_ZTV5Base1) + 16) # 从 +16 offset开始

Vtable for Base2
Base2::_ZTV5Base2: 6 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI5Base2)
16    (int (*)(...))Base2::~Base2
24    (int (*)(...))Base2::~Base2
32    (int (*)(...))Base2::mumble
40    (int (*)(...))Base2::Clone

Class Base2
   size=16 align=8
   base size=12 base align=8
Base2 (0x0x7f04706f5480) 0
    vptr=((& Base2::_ZTV5Base2) + 16)

Vtable for Derived                    # 两个vptr指向同一个vtbl,使用不同的offset区分
Derived::_ZTV7Derived: 12 entries
0     (int (*)(...))0                 # 偏移1, 即base1的偏移
8     (int (*)(...))(& _ZTI7Derived)  # Derived type info
16    (int (*)(...))Derived::~Derived # Derived 的析构函数,有两个
24    (int (*)(...))Derived::~Derived
32    (int (*)(...))Base1::speadClearly
40    (int (*)(...))Derived::Clone		# Derived 改写了Clone
48    (int (*)(...))-16               # 偏移2,+16, 即base2的偏移
56    (int (*)(...))(& _ZTI7Derived)  # Derived type info
64    (int (*)(...))Derived::_ZThn16_N7DerivedD1Ev # this 指针需要调整,因此这里跟 ~Derived 不是同一个地址
72    (int (*)(...))Derived::_ZThn16_N7DerivedD0Ev
80    (int (*)(...))Base2::mumble
88    (int (*)(...))Derived::_ZTchn16_h16_NK7Derived5CloneEv # this需要调整,所以 跟Derived:Clone不是同一个地址

Class Derived
   size=32 align=8
   base size=32 base align=8
Derived (0x0x7f047070a000) 0
    vptr=((& Derived::_ZTV7Derived) + 16) # +16 offset
  Base1 (0x0x7f04706f54e0) 0
      primary-for Derived (0x0x7f047070a000)
  Base2 (0x0x7f04706f5540) 16
      vptr=((& Derived::_ZTV7Derived) + 64) # +64 offset

在多重继承中支持virtual function,其复杂度主要围绕在第二个及后继base class身上,以及”必须在执行期调整this指针“这一点。

举个栗子:

Base2 *pbase2 = new Derived; 
delete pbase2;
  1. new 返回的Derived对象的地址必须调整以指向其Base2 subobject, 编译期会产生以下代码:

    Derived *temp = new Derived;
    Base2 *pbase2 = temp ? temp + sizeof(Base1) : 0;
    

    如果没有这样的调整,指针的任何 ”非多态运用“ 都将失败,比如:

    pbase2->data_base2; 
    
  2. delete 时,指针再次被调整以指向对象的起始处

一般规则是,经由指向”第二或后继base class的指针或reference“ 调用Derived class virtual function ,其所连带的”this指针调整“操作必须在执行期完成, 因为指针所指的真正对象只有在执行期才能确定。也就是说offset的大小,以及上面的this指针调整的代码,必须由编译器在某个地方插入。比较有效率的做法是利用thunk,thunk是一小段assembly代码,用来 调整this指针 以及 跳到 virtual function。此时 vtbl 中 slot 可以直接指向 virtual function,也可以指向thunk,看是否需要调整this指针,更具体的说,看virtual function是经由Derived class 或者 第一个base class调用, 还是经由第二或后继base class调用,因此同一个virtual 函数在同一个vtbl 中需要多笔对应的slots(参考示例中的 ~Derived() 以及 Clone()函数)。

在多重继承之下,一个Derived class内含n-1个额外的vtbl, n表示其上一层base class的个数(编译器为了提升效率,会将多个vtbl合成一个,然后用offset来定位区分, 参考示例中Derived的vtbl, 以及两个vptr)。

经过this指针调整后,被处理的vtbl就确定了。具体的说,将一个derived 对象地址指定给第一base或者derived 指针时,使用的是”primary vbtl“,指定给第二或后继base class指针时,使用的是额外的vtbl。

在多重继承中,第二或后继base class会影响对virtual function的支持的三种情况:

  1. 通过指向第二或后继base class的指针调用derived class virtual function。已举例过,此时指针要调整指向derived对象起始处。

  2. 通过一个指向derived class的指针,调用第二或后继base class中一个继承而来的virtual function。这种情况下,derived class的指针要调整以指向第二或后继base class subobject

  3. c++允许一个virtual function的返回值类型有所变化,可能是base type,也可能是public derived type,参考上文的Clone函数。问题发生于”通过指向第二base class的指针调用clone,并返回给指向第二base class的指针“ 时, 如:

    Base2* pb1 = new Derived; // new 返回的指针需要调整以指向base2 subobject
    Base2* pb2 = pb1->Clone(); 
    

    当进行 pb1->clone 时,pb1会调整以指向derived 对象的起始地址,于是Clone的derived版本会被调用;他会传回一个指针,指向一个新的Derived 对象;该对象的地址在被指定pb2之前,也要经过调整,以指向Base2 subobject

虚拟继承

建议:不要在一个virtual base class中声明 nonstatic data member。

class Point2d {
 public:
  Point2d(float = 0.0, float = 0.0);
  virtual ~Point2d();
  virtual void mumble();
  virtual float z();

 protected:
  float _x, _y;
};

class Point3d : public virtual Point2d {
 public:
  Point3d(float = 0.0, float = 0.0, float = 0.0) ;
  ~Point3d();
  float z();
  float _z;
};

int main() {
  return sizeof(Point3d);
}

使用g++ -fdump-lang-class -c test.cpp:

Vtable for Point2d
Point2d::_ZTV7Point2d: 6 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Point2d)
16    (int (*)(...))Point2d::~Point2d
24    (int (*)(...))Point2d::~Point2d
32    (int (*)(...))Point2d::mumble
40    (int (*)(...))Point2d::z

Class Point2d
   size=16 align=8
   base size=16 base align=8
Point2d (0x0x7f4d6a97a420) 0
    vptr=((& Point2d::_ZTV7Point2d) + 16)

Vtable for Point3d
Point3d::_ZTV7Point3d: 15 entries
0     16                               # 貌似是共享对象的偏移,只有虚拟继承才有
8     (int (*)(...))0									 # subobject 偏移
16    (int (*)(...))(& _ZTI7Point3d)   # type info
24    (int (*)(...))Point3d::~Point3d
32    (int (*)(...))Point3d::~Point3d
40    (int (*)(...))Point3d::z
48    18446744073709551600            # 跟虚函数是否被重写有关,且与其在父类中声明顺序相反,比如这里表示z()被重写
56    0                               # 这里表示mumble没有被重写
64    18446744073709551600            # 这里表示析构函数被重写
72    (int (*)(...))-16               # Point2d subobject 偏移 +16
80    (int (*)(...))(& _ZTI7Point3d)  # type info
88    (int (*)(...))Point3d::_ZTv0_n24_N7Point3dD1Ev # 貌似子类的析构函数都带有 D1Ev 和 D0Ev 
96    (int (*)(...))Point3d::_ZTv0_n24_N7Point3dD0Ev
104   (int (*)(...))Point2d::mumble                  # 继承来的 mumble
112   (int (*)(...))Point3d::_ZTv0_n40_N7Point3d1zEv # z() 实例第二个版本

VTT for Point3d  # VTT virtual table table
Point3d::_ZTT7Point3d: 2 entries
0     ((& Point3d::_ZTV7Point3d) + 24)
8     ((& Point3d::_ZTV7Point3d) + 88)

Class Point3d
   size=32 align=8
   base size=12 base align=8
Point3d (0x0x7f4d6a8261a0) 0 # 偏移 0
    vptridx=0 vptr=((& Point3d::_ZTV7Point3d) + 24) # vptridx 表示在VTT中的偏移
  Point2d (0x0x7f4d6a97a480) 16 virtual # 偏移 +16, 说明前面有 4 byte alignment
      vptridx=8 vbaseoffset=-24 vptr=((& Point3d::_ZTV7Point3d) + 88)

函数的效能

对比了inline member,Nonmember friend,static member, nonstatic member,virtual member 在单一、多重、虚拟情况下的性能,结果显示:

  1. nonmember,static member, nonstatic member因为最终被转化为相同的形式,性能一致
  2. inline 未优化版本,性能提升25%,优化之后提升50倍,这是因为编译器将”被视为不变的表达式“提到了循环之外,因此只计算一次。说明inline函数不只能够节省一般函数调用的开销,也提供了程序优化的额外机会。
  3. virtual 函数的调用,性能降低 4%~11% 不等。

指向member function的指针

取一个nonstatic member function的地址,如果函数是non virtual,得到的是它在内存中的地址。但是这个地址不是完全的,它需要被绑定于某个class object 的地址上,才能调用。使用举例如下:

double (Point::*pmf) (); // 声明语法
double (Point::*coord)() = &Point::x; // 声明并初始化
coord = &Point::y; // 赋值
Point origin, &ptr = &origin;
(origin.*coord)(); // 通过 obj 调用,内部转化为 (coord)(&origin);
(ptr->*coord)();   // 通过 ptr 调用, 内部转化为 (coord)(ptr);

支持指向virtual member function的指针

多态机制仍然可以在使用 ”指向member function之指针“ 的情况下运行,举例如下:

float (Point::*pmf)() = &Point::z; // z 是virtual function
Point* ptr = new Point3d;
ptr->z(); // 多态生效,调用的是Point3d::z()
(ptr->*pmf)(); // 多态仍然生效,调用的还是Point3d::z()

由于对于virtual func来说,其地址在编译时期是未知的,所能知道的是其在vtbl中的索引值。所以对一个virtual func取地址,得到的是这个索引值。(ptr->*pmf)() 内部被转化为(*ptr->vptr[(int)pmf])(ptr)

因此对member func取地址,根据其是否为virtual,返回值的含义是不一样的,在编译器内部,pmf必须要能表示这两种值,即内存地址和vtbl中的索引。实现方式在下文描述(只是一种方案,具体实现依赖于特定编译器)。

在多重继承之下,指向member function的指针

接上文,为了让指向member func的指针,即pmf,能够同时表示地址和索引,且支持虚拟继承和多重继承,stroustrup设计了以下结构:

struct __mptr {
 int delta;            // this 指针的 offset 值
 int index;            // vtbl 中的索引,当index不指向vtbl时,为-1
 union {
 	ptrtofunc faddr;     // non-virtual member function 地址
 	int       v_offset;  // virtual base calss 或 第二或后继base class 的 vptr 的位置
 };
};

在此模型下:

(ptr->*pmf)();
// 被转换为
(pmf.index < 0) 
? // non-virtual invocation
(*pmf.faddr)(ptr + pmf.delta) 
: // virtual invocation
(*ptr->vptr[pmf.index].faddr)(ptr + ptr->vptr[pmf.index].delta); // vtbl 存储的是指向member function的地址,即 __mptr 结构。 

这种方式的缺点是,每一个调用操作都得付出上述转换的成本。MicroSoft 为了去掉转换成本,引入vcall thunk,此时faddr要么是virtual member func的地址,要么是vcall thunk的地址,而vcall thunk会选出并调用相关vtbl中的适当slot。

Inline Function

inline 只是一个请求,而不能强制。编译器会根据inline 函数中的 assignment,function calls,virtual function calls等操作的次数和权值,计算其复杂度,用来判断是否能合理的扩展,其执行成本是否比一般的函数调用及返回机制低。

处理inline 函数的两个阶段:

  1. 分析函数定义,以决定函数的”intrinsic inline ability“(本质的inline能力)。

    如果函数因其复杂度或构建问题,被判断为不可成为inline,它会被转化为一个static 函数,并在”被编译模块“内产生对应的函数定义。连接器会清理重复定义,但不会清理因此而产生的重复调试信息,unix 的strip可以清理。

  2. 在函数的调用点上扩展,这会带来参数的求值以及临时性对象的管理问题。

形式参数

在inline扩展中,形式参数会被实际参数取代,如果导致了实际参数被多次求值的副作用,则需要引入临时性对象。对于常量表达式,会在替换之前先求值,后续的替换使用这个值;其他情况,直接替换。举例说明:

inline int min(int i, int j) {
	return i < j ? i : j;
}

inline int bar () {
  int minval;
  int val1 = 1024, val2 = 2048;
  minval = min(val1, val2);  // (1) 
  minval = min(1024, 2048);  // (2)
  minval = min(foo(), bar() + 1); // (3)
  return minval;
}
//(1) 直接替换
minval = val1 < val2 ? val1 : val2;
// (2) 常量求值
minval = 1024;
// (3) 有副作用,导入临时对象避免重复计算
int t1;
int t2;
min val = (t1 = foo()), (t2 = bar() + 1), t1 < t2 ? t1 : t2;

局部变量

inline 函数中的局部变量扩展后都必须被放在函数调用的一个封闭区段中:

inline int main(int i, int j) {
	int minval = i <j ? i :j;
	return minval;
}

{
  int local_var;
  int minval;
  minval = min(val1, val2);
}

// 扩展后可能变为:
{
  int local_var;
  int minval;
  // inline 局部变量被mangling
  int __min_lv_minval;
  minval = (__min_lv_minval = val1 < val2?val1:val2, __min_lv_minval);
}

Inline 函数中的局部变量加上有副作用的参数,可能会导致大量临时性对象产生。需要注意程序大小暴涨的问题(编译器可能会优化掉这些临时对象)。

五、构造、析构、拷贝语意学