《深度探索C++对象模型》学习笔记(1-4章)

今年静下心来重读了侯捷老师译本的Lippman大神作品《Inside the C++ Object Model》,记录下学习过程中的疑问和心得有助于以后……面试。不是开玩笑,这本书里面随便找一段出来都可以当作C++面试题,学透彻了的话,无论是面试别人或者被人面试都是相当犀利的武器。

第1章 对象

对象

OOP的成本

  • 实际上相对于struct来讲,class只是程序员友好的手段
  • 单纯使用class以及继承,内存成本并不会增加,成员函数也会由编译器进行转换(4.1函数成员:调用)
  • 真正需要额外成本的是virtual特性。

对象布局方式:简单对象模型

  • 思想:尽量降低实现的复杂度
  • 每个对象中的项目都是一个slot,按照顺序排列
  • 数据成员本身并不放置在对象中,只保存指针,避免变长对象出现
  • 没有用于实际产品中(但成为指向成员的指针的灵感来源)

对象布局方式:表格驱动的对象模型

  • 对象保存两个指针,一个指向拥有所有数据成员指针的表格,一个指向拥有所有成员函数指针的表格
  • 也没用于实际产品中(但指向函数指针表格的指针,演化为vptr)

对象布局方式:C++对象模型

  • 演化自简单对象模型
  • 非静态成员存储于类内。静态的则都位于之外。
  • vptr和vtbl的概念。用于支持RTTI的type_info位于vtble第一个slot内

继承的情况

  • 简单模型:子类描述基类的slot保存基类指针,优点是父类的修改不会影响子类
  • 另一模型:保存一个基类表格,子类拥有一个bptr指向之。表中的slot再指向具体的基类

差异

关键字差异

  • class和struct的思考:为了兼容C而多做了很多工作
  • struct表现数据集合体,而class为OOP而生
  • class的多个section不能保证成员在内存中的顺序和声明的一样
  • struct目前的作用,抽取class的一部分作为参数传给C函数

对象差异

  • 三种programming paradigms
    1. procedural model,C语言的函数调用
    2. ADT model,隐含的调用,例如等号运算符
    3. object-oriented model,面向对象特性
  • 多态的实现需要由指针或引用来实现,但C++中的指针和引用并非多态的必然结果
  • 支持多态的方法
  • 指针的转换(基类子类)
  • 虚函数调用
  • dynamic_cast或者typeid运算符

指针和多态

  • void*虽然可以指向某地址,但并不清楚具体类型,也不能通过它操作对象
  • D继承自B,D的大小为B的子对象加上D特有的部分
  • D*和B*的指针对象虽然都指向同一个开始地址(B的开头),但它们涵盖的范围不同
  • 指针在编译期会决定固定的可用接口(public)以及接口对应的访问范围,这类信息都在链接(link)中,它位于vptr和其指向的vtbl之间
  • 编译器在构造和赋值时候会决定vptr的值。如果对象含有多于1个的vptr,那么就不用基类指针进行覆盖(5.4构析拷贝:拷贝赋值)

第2章 构造函数

构造函数

程序员应承担默认构造器的工作。

  • 只有在需要的时候构造器才被创建。
  • 但编译器创建的默认构造器是trivial的。如果需要初始化类成员,必须手动编写构造器
  • 如果程序员提供了多个构造器,但都不是默认的话,编译器代码会安插到所有构造器中

对于一个没有默认构造器,但是拥有包含默认构造器的成员的类来说:

  • 编译器会生成non-trivial的构造器,但是:这个构造器只进行那个成员的初始化。其他成员一概不管。
  • 如果程序员编写了默认构造器,但没有进行类成员的初始化,那么编译器会在其中安插代码就完成此工作。
  • 对于多个类成员来讲,按照声明的顺序进行初始化代码的安插。

对于继承了拥有默认构造器的类的类来说,亦是如此。

  • 如果既继承了默认构造器类、又拥有默认构造器类成员,那么先调用基类的构造器。

如果继承链中存在虚函数呢?

  • 那么按照惯例,首先合成虚函数表(vtbl)放置虚函数地址信息,以及虚指针存放于类对象中,用于指向vtbl
  • 改变virtual invocation,例如 ( *b.vptr[1] )(&b),意思为找到所调用函数的位置,传入当前调用此函数的对象this指针(可能为子类)
  • 所以结论为:在每一个拥有virtual机能的类对象中,都必须初始化vptr以指向vtbl,这些代码会安插在所有构造器中。如果没有构造器,自然会生成一个non-trivial的默认构造器来做这些事情。

如果是virtual继承呢?(例如 D、E继承自B,X继承自D和E,B中有一个int变量)

  • 通过父类指针取值的时候,例如传入E*作为D*为形参的函数。因为最底层的X无法知道基类中成员的位置偏移量,所以编译器这些事情做不了,转而由执行期的情况决定。
  • 所以需要一个指向具体类对象的指针存在,用于在执行期选用。这个过程也是在默认构造器中完成的。

结论:除了以上四种情况外,编译器不会合成默认的构造器。就算在这四种情况下,合成的默认构造器也不会负责初始化成员变量。

拷贝构造函数

  1. 没有提供显式拷贝构造函数时候
    • 内部以default memberwise initialization完成,这即是对于所有的member都拷贝一份到新的object上,但不拷贝其具体内容。例如只拷贝指针地址,不拷贝一份新的指针指向的对象,这也就是浅拷贝。
    • 类的成员变量会以递归的方式(memberwise)处理所有成员变量,即所有成员变量也会应用同样的规则对于其内部的值进行拷贝,而非一股脑地将所有类成员对象进行内存级别的直接复制(bitwisely copy)
    • 是否会生成默认的拷贝构造函数,要视类是否有bitwise copy semantics而定。如果bitwise copy足够的话,那么就没有默认的拷贝构造函数生成
  2. 如果某个类成员拥有拷贝构造函数,但自己却没有
    • 传说中的非bitwise copy semantics情况
    • 那么这个类在进行拷贝操作的时候,编译器会帮着生成一个拷贝构造函数。之后对于某成员使用拷贝构造函数进行copy,其他的成员则按照member wise initialization规则拷贝。如上所述,这个过程是递归的。
  3. 这也就是说,拷贝构造函数和默认构造器一样,需要的时候会进行构建,而并非程序员不写编译器就帮着构建。
  4. 对于继承中有虚函数的情况来说,例如D继承于B,利用初始化好的d进行拷贝构造b
    • 如果是bitwise copy的话,那么vptr也会原封不动地从d传到了b。
    • 但这个时候b的vptr绝对不能指向D的vtbl地址,因为在拷贝构造的时候,对于b来说,D对象部分被slice掉了!
    • 所以编译器会生成一定的代码对于vptr进行调整
  5. 虚继承中以子类的对象作为父类对象初始化依据的时候,也并非bitwise copy
    • 编译器需要生成拷贝构造函数(或者在其中安插代码)设置虚基类的指针以及对象的位置偏移量
  6. 总结一下,类似于拷贝构造函数的是下面这些情况中不表现出默认的bitwise copy现象:
    • 所继承的类中有拷贝构造函数(2)
    • 继承了拥有拷贝构造函数的基类的子类(4)
    • 虚函数(4)
    • 继承链中的虚基类(5)

数据成员变量初始化队列

何时需要?

  • 类成员包含引用和const时
  • 积累构造器拥有参数时
  • 类对象成员构造器拥有参数时

性能问题!

  • 对于类对象成员直接在构造器初始化的话,会产生临时对象,并memberwise地复制所赋值的对象到成员对象上。
  • 而初始化队列(intialization list)的话,直接调用成员对象的拷贝构造函数,以传入的值作为赋值依据
  • 所安插的拷贝构造函数代码,在构造函数中会位于程序员所书写的代码前,大可放心!

初始化队列并非是一组函数调用 初始化时候的顺序和声明顺序相同,不管以何种顺序在队列中书写。 初始化时候可以使用类的成员函数(因为this指针已经OK),但最好不用,因为使用构造器之外的成员变量会有危险(在所调用的函数中,不知道使用的成员变量何时真正初始化了!)

  • 举例来说,子类在初始化自己的成员时,调用自己的成员函数返回变量作为base构造的参数如何?显然有问题。因为使用的变量初始化代码,位于被安插的(基类的)拷贝构造函数之后!

程序转化

下面两种有关函数X foo() { X x; return x; }调用的说法是否成立要视编译器类型而定:

  • 函数调用后会传回本地声明的x
  • 若X定义了拷贝构造器,那么foo()调用的时候该构造器也会被调用

不管是哪种初始化方式:X x1(x), X x1 = x, X x1 = X(x)

  • 在编译阶段,定义的初始化操作都会被剥离而变成x1.X::X(x),即对于拷贝构造函数的调用
  • 不管是哪种初始化方式:X x1(x), X x1 = x, X x1 = X(x)

参数传入函数前,会进行tmp化操作,利用memberwise复制一份后传入。函数调用完毕后,析构函数被调用,保证临时变量空间的释放。 返回值。

  • 增加一个额外参数,为返回值类型的引用
  • return前用本地的对象进行拷贝构造,修改引用值

程序员/编译器优化(todo)

  • 直接用返回值的方式(NRV,named return value)

拷贝构造函数的取舍问题

  • 是否永远需要显式地编写拷贝构造函数?答案肯定是否定的。在那些会自动生成拷贝构造函数、又不造成内存泄漏和别名地址(address aliasing)的情况下,没有必要自己写,因为编译器的方法已经足够高效(最高效!)
  • 那么何时需要?大量的传值操作,并且编译器支持NRV。直接memcpy作为初始化参照的对象到this指针。但对于含有虚函数或者虚基类的情况,万不可使用memcpy,因为这样基类指针会被改写!

不管是哪种初始化方式:X x1(x), X x1 = x, X x1 = X(x)

  • 在编译阶段,定义的初始化操作都会被剥离而变成x1.X::X(x),即对于拷贝构造函数的调用

第3章 数据

基础

数据成员的排列考虑:空间优化、速度优化、和C的结构器的兼容性 所有的非static成员都排列在class object中,包括继承而来的 所需要的空间可能超过所想象的

  • 支持额外的语言特性,例如虚函数
  • 特殊情况下的优化处理
  • 数据对齐(alignment),再小的数据也需要占用一个字长,以保证总线的运输效率

例如Y和Z继承自X,A继承自Y和Z。那么sizeof()的结果分别是1、8、8、12。

  • 对于空类来说,编译器会自动添加一个char作为标识符。
  • 8 bytes的来历:1个字长的虚基类指针,1个byte的char,再加上相应的alignment需求3 bytes。
  • 12 bytes的来历同理,但有一点需要注意,即虚基类子对象只会存在一次,所以A类只有Y和Z的基类指针,每个4 bytes。
  • 但对于某些现代编译器来说(例如VC),对于虚继承出来的子类,并不会添加额外的一个char,所以每个类节省了4 bytes的长度

静态成员单独存储在静态数据段(static data segment)内。就算没有class object被实例化,也同样存在 静态数据成员

  • 只有一份,位于程序DS
  • 访问通过类名,但其实并非类的一部分。取址后拿到的是个const long*,即指向数据区的指针。
  • 两个类声明了相同的静态成员的话,编译器会进行name mangling,对名称进行编码以防混淆

成员方法内对于数据成员的访问,其实是增加了一个此类的指针作为第一个参数

  • 例如Foo( Bar b )会变为Foo(Foo* this, Bar b),以便使用类的成员
  • 访问数据成员的时候会以偏移量为准寻找具体位置。这个偏移量在编译期即可得知,不影响效率

从虚基类继承下来的类的指针在访问数据成员的时候和普通情况有很大不同,因为在编译器无法得知具体的类型,需要在执行期通过额外工作得知

表现

早期的编译器会出现的问题:类inline方法中返回类成员变量,但如果之前有extern的全局变量,那么就不会使用类自己的(inline声明时类成员还没出现)

  • 解决方法1:将成员声明放置在方法声明之前
  • 解决方法2:inline函数在类外定义

现代C++规则没有这个问题:inline方法也会到类声明整个完成之后才开始进行绑定 但随之而来同样的问题出现在类方法的参数列表中 类成员在内存的类对象中的排列顺序和声明的顺序相同

  • 注意!并不是连续排列的,只是晚出现的类成员位于较高的地址而已,因为其间还可能有alignment bytes存在

vptr的处理

  • 一般来说放置于所有类成员最后
  • 也有一些编译器放置在前面。C++标准并未对此作出严格规定

不同访问级别(不同的access section)

  • 可以不按照声明的顺序进行排列,但没有编译器会这么做
  • 多少个access section无所谓,每个对象单列一个section和都放在一个section内在内存的开销上一样

继承

有继承无多态

  • 和struct没什么区别,不增加空间和时间上的负担
  • 但需要注意的是对齐问题:例如类B有一个char成员,D继承自它同样也有一个char成员。如果将一个基类对象memberwise地拷贝到子类对象上,会出现意外的数值问题:
  • [char][padding3]拷贝到[char][char][padding2]时,子类的第二个char数值不可预测!

继承+多态

  • 为了支持多态,必然会带来控件上的额外负担
  • 也就是vptr、vtble、vptr初始化和销毁的过程带来的负担
  • 回到vptr的位置上来。从MS VC开始,vptr被放置在类开端。这样做的好处就是不用在编译器决定所有成员的offset。丧失的则是基本没用的C struct兼容性(有人会从struct继承出一个class来用吗?)

多继承

  • 最大问题:基类无虚拟特性,但派生类有(从其他基类继承来的或者本身就有),怎么办?
  • 例如,Point3d继承自Point2d,Vertex3d继承自Point3d和Vertex,Point类都有虚拟特性,Vertex没有。
    • 现在有一个Vertex3d的实例,以及Point2d、Point3d和Vertex的指针各一个。
    • 如果直接进行pVertex = &vertex3d的操作,编译器会将其变为:pVertext = (Vertex*)(((char*)&vertex3d) + sizeof(Point3d))
    • 而如果要将另外两个Point类的指针指向实例的话,直接拷贝地址就完了
  • 再例如,同样的类条件下,有Vertex3d和Vertex的指针各一个,直接pVertex = pVertex3d的话会如何?
    • 比上例还复杂!因为如果pVertex3d是null的话,pVertex拿到了sizeof(Point3d)的大小,错!
    • 所以还需要加一个判断,如果右侧为null则直接左侧=null

虚继承

  • 子类和基类进行转换时,直接拿到子类的vbc指针即可,而对于虚函数(包括运算符重载),需要编译器进行转换。子类没有定义的成员,利用vbc拿到后进行修改。
  • 这么做的问题?第一是每增加一个虚继承基类,vbc指针就多一个。二是继承链越长所要(通过vbc)转发的次数就越多
  • MS的编译器解法:只保存一个虚基类表(vbctbl),子类则只有一个vbcptr指向这个表头。这也是MS的专利
  • Bjarne的解法:在vtbl中不保存vbc地址,而是offset。offset为正值是取函数,负值则是取vbc

指针

重提vptr的位置,可以放置在头部(MS)也可以放置在尾部,甚至是数据成员中间(但估计没人这么做)

对于类成员取址的操作,相当于拿到该成员在class object中的offset

指向成员的指针真是奇葩语法……

void (B::*pF)();  //成员函数指针
void (B::*pF2)(char*);  //成员函数指针

int B::*pDM = &B::Value; //定义指针
B b;
B* pB = new B();
b.*pDM = 5;  //实例访问
pB->Value = b.*pDM * 10;
cout<<b.*pDM<<endl<<pB->*pDM<<endl; //指针访问

指向了成员的指针和未指向的指针如何区分?

  • 指向了成员的,返回的offset会+1,所以真正使用的时候需要减掉这个1
  • 例如int Foo::* p1 = null和int Foo::* p2 = &Foo::Value

所以,直接取得class object的成员地址的操作,和取得实例中成员地址的操作有很大不同。后者返回的是内存实际地址。 构造和析构函数是取不到地址的

  • 还是多继承有关的问题。如果一个函数中,期望传入子类的成员指针,结果传了基类的会如何?
  • D继承自B1和B2
  • 定义:func( D::* aP, D* d ) { d->*aP; }
  • 使用:int B2::*p = &B::Value; D* d = new D(); func( p, d );
  • 问题:期望修改的是B1中继承而来的第一个成员,结果传进来的是B2,它的成员指针值偏移量(offset)和B1的第一个成员相同!(成员地址相对于类开始的地址)

第4章 函数

调用

静态函数不会是const的

  • 静态函数可以访问本身参数、静态对象和全局变量。
  • 而这些都不是class object的一部分。const限制的是访问内部对象时候不允许修改。两者毫无交集。

C++设计思想:成员函数的调用效率应该和非成员函数一样

  • 编译器内部会进行改写,将成员函数改为拥有一个const this指针作为参数的非成员函数
  • 大概的过程
    • void Foo::Bar()
    • void Foo::Bar( Foo* const this ),如果函数是const则是void Foo::Bar( const Foo* const this )
    • 将函数内部的变量调用全部加上this->
    • name mangling: extern Bar_1FooFv( Foo* const this );
    • 调用foo.Bar()则变为 Bar_1FooFv( &foo );
    • 调用foo->Bar()则变为Bar_1FooFv( foo );

name mangling的作用?

  • 显而易见,对于继承情况下的子类重写父类的方法或者数据成员有用,保证不会重名
  • 重载呢?根据参数表修改后缀,例如void Bar(float aA)和void Bar()会得到不同的Bar_1FooFf和Bar_1FooFv
  • 目前没有统一的标准

静态函数

静态函数除了不会是const的外:

  • 还不会是volatile或者virtual的
  • private的有可能。直接初始化或者不带类名限制地定义。但有啥用呢。

名称也同样需要被mangling 静态成员函数的地址是内存地址。因为没有this指针的限制,所以取到的就是一个unsigned int (*)(),而非unsigned int ( Foo*)()

虚函数

虚函数的调用?之前遇到过(构造函数:构造函数)复习一下

  • 查vtbl,将调用者指针当作参数传入 foo->Bar() => ( *foo->vptr[1] )( foo );

class object拥有虚特性,并非意味着所有函数调用都会去虚表

  • 性能问题。对于没有虚拟特性的部分,直接按照非静态成员函数的变化进行即可

多态的代码层次解释:以基类对象的指针寻找到子类对象地址,表现出子类对象的行为

  • 消极多态和积极多态:前者为指针指向的解释,在编译期即可完成;后者则是函数调用,需要到运行期才表现出来

继承,需要额外存储的信息

  • 对象的地址和对象类型的地址
  • 例如这样的调用 foo->Bar(),需要清楚foo的地址(class object上的标识符)和Bar()地址(vptr指向的vtbl地址)才能调用到所需要的。
  • 每个class object从父类继承下来的函数占用单独的slot。
  • 对于纯虚函数,使用一个内置的函数地址,例如pure_virtual_called()
  • 这样一来调用的时候就可以知道具体的信息
    • foo->Bar()。我不知道foo具体是子类还是基类,但我知道它的vptr指向vtbl
    • 我也不知道Bar()的地址,但我知道它在vtbl中的索引

多重继承

  • 中心思想:运行期动态调整指针
  • (第二个继承的)基类指针指向子类对象,需要进行调整,指针位置向高位移动sizeof(Base1);删除的时候亦需要进行调整
  • 这些工作需要在执行期完成,代码由编译器插入,何处?
    • Bjarne的解法是加大vtbl,存入地址和offset。于是 ( *foo->vptr[1] )( foo )变为 ( *foo->vptr[1].faddr )( foo + foo->vptr[1].offset );
    • Thunk。《深入探索COM》翻译的比原文还晦涩……即用一段汇编调整this指针,需要调整的slot在vtbl中指向Thunk程序。
    • SUN编译器:对于小型虚函数,采用split function,产生两个相同的函数,其中一个对this进行调整
    • MS编译器:address points,将引用虚函数的对象class地址改写为实际的对象地址,而非子类地址
  • 对于指向子类对象的不同基类指针,this指针的调整主要是找到对应的vtbl

虚继承

  • 作者说太复杂不适合介绍(……我觉得上面的已经足够复杂了)

内联函数

定义在类声明内的函数。inline可加可不加。声明外的必加。 类似于宏的替换操作提高函数操作效率。但并非所有inline声明都会变成inline函数。代码过多的话编译器会拒绝,转而将其作为普通函数定义。

  • 例如用了循环、数组和switch的代码(旧的编译器不接受,但新型编译器可以),问题在于代码体积膨胀,还起不到提高效率的作用。
  • 另外一个例子是静态内联函数。这是允许的,但问题一旦内联失败,链接出来static函数就不是一份了。出错也会有多个。

形参

  • 传入参数,直接替换
  • 传入常量,连替换都省了,直接变成常量
  • 传入函数运行结果,则需要导入临时变量

局部变量

  • 局部变量会被mangling,以便inline函数被替换后名字唯一
  • 也就是说一次性调用N次,就会出现N个临时变量……程序的体积会暴增

简而言之:inline要小心使用

指针

对于非静态成员函数来说,如果不是virtual的,取到的地址就是实际的内存地址

B b;
    B* pB = &b;

   void (B::*pF)() = &B::ToString; // virtual的
   void (B::*pF2)(char*) = &B::Say; //非virtual的
    ( b.*pF )();
    ( pB->*pF2 )( "hi" );
    cout<<pF<<endl;  // 1?
    cout<<pF2<<endl;  // 1?
    printf( "%p\n", &B::Dummy ); // 00410B9C
    printf( "%p\n", &B::ToString ); //00000001
    printf( "%p\n", &B::Say ); //00410B70

但还是需要this指针来补全其真实地址。 编译器的优化使得利用成员函数指针调用的效率和普通的对象调用基本一样 虚函数指针

  • 还是看上面的例子,对于虚函数来说,地址拿到后是函数在vtbl的索引值(1)
  • 调用( foo->*pF )(); 会变成 ( *foo->vptr[ (int)pF ] )( foo );
  • 既然pF可能有两种情况,怎么区分呢?如下,依据是目前的继承体系最多支持128个虚函数。
(((int)pF & ~127)
? //非virtual
(*pF)(foo)
: //virtual
( *foo->vptr[ (int)pF ])( foo ) );

多重继承中的虚函数指针

  • 很麻烦……任何情况碰到多重继承都很麻烦……
  • Bjarne大爷设计了一个结构体解决这个问题,里面有index(虚表索引)和一个union保存faddr和v_offset

于是( foo->*pF )()就变成

( pF.index < 0 )
? //非virtual
( *pF.faddr )(foo)
: //virtual
( *foo->vptr[ pF.index ] )( foo );
  • 微软的做法是不进行判断。faddr要么指向真正函数地址,要么去做thunk call,再由thunk负责具体的调用vtbl slot的操作

Leave a Reply