Back
Featured image of post C++对象

C++对象

C++

读书笔记:深度探索c++对象模型

C++ 对象模型

可用的对象模型有很多种,C++对象模型派生于简单对象模型,这里直接说C++对象模型:

  1. Non-static data member 存在于每一个object内
  2. Static data member 存在于每一个object外
  3. Static 与 Non-static function members 也被存放在所有object外
  4. virtual function members:object内存在指向虚表vtb的指针(一般用于支持RTTI的type_info object的指针置于vtb第一个slot)

继承

虚继承

base class不管被继承多少次,永远只存在一个实体,虚继承产生的派生类只是有路径指向对应的base class实体。

这里的“路径”可以是类似虚表的指针(bptr)也可以是一系列指针,视存取效率而定。

然而,这样的话object的布局和大小会随着继承体系的改变而改变,所以这一部分的内容应被存放于对象模型的特殊部分。这个特殊部分就是“共享局部”,其他的固定不变的部分就是“不变局部”。所以为了实现虚继承,引入了两个局部的概念,虽然实现方式各编译器有所不同,但两个局部的概念一致。

构造

默认构造函数

如果没有user-declared constructor, 一个trivial 的constructor可能被生成, 但他啥都不干。

  1. 只有编译器需要时,non-trivial 的constructor才会被编译器合成,但他也只干编译器需要的事情,比如A内有一个B类的成员(组合),B有一个构造函数,那么A也就需要一个non-trivial 的constructor,这个构造函数只会构造B,而不会管A内可能存在的其他成员。

  2. 如果A内有B,C,D三个类的成员,这三个类都有自己的构造函数,显然编译器会为A生成一个构造函数,他会依次调用B,C,D的构造函数,顺序取决于三成员在A内的排列顺序。

  3. 回到1,如果A内其他成员比如一个int值在user-declared constructor里初始化但是user-declared constructor内未初始化B,那编译器怎么办呢,编译器会扩写user-declared constructor,给里面加上个B的构造函数的调用。

  4. 类内有虚函数存在,编译器需要扩张已有构造函数或生成一个构造函数来完成虚表的初始化操作

  5. 虚继承的使用也会给构造函数增加工作量,编译器需要让构造函数给类添加执行期判断的能力,比如在派生类中添加一个bptr提供指向唯一基类实体的路径。

默认拷贝构造函数

三种调用情况:

  1. X x1 = x2 变量赋值
  2. f(x1) 函数传参
  3. return x1 返回值

不需要默认拷贝构造函数:

  1. 类内以及其子类(无限递归下去)内部全是内建类型(即全是int啥的),那么只需要简单的依次(对于子类递归的执行)将这些成员赋值。这叫做default memberwise initialization,这里编译器没有产生default 拷贝构造函数,这是一种bitwise copy semantic即bitwise的copy每一个变量值

需要默认拷贝构造函数:

  1. 类内及其子类内存在一个explicit(即显式声明)的拷贝构造函数,那类及其子类本身也需要生成一个拷贝构造函数,其中会调用那个explicit的拷贝构造函数。
  2. 类继承的base class有一个explicit的拷贝构造函数
  3. 类存在一个或多个virtual function
  4. 继承串联下存在虚继承

情况三的类存在一个或多个virtual function

很容易想到的多态情况,以一个derive class A作为值赋给一个base class B指针或引用,发生修改扩张操作(多态),并且需要使用Base class的vptr而非A的vptr,这都需要编译器在生成的拷贝构造函数中纠正,使base class的vptr指向对应A的虚函数。

情况四的继承串联下存在虚继承

同样将derive class A作为值赋给一个base class B,同时这个base class B虚继承自一个virtual base class C。那么A的拷贝构造函数需要被编译器扩张或生成来完成bptr的指定。

传值三种情况构造

  1. X x1 = x2 变量赋值

转化为:

X x1;
x1 = X::X(x2);
  1. f(x1) 函数传参

    1. 构造临时对象,通过引用传给函数
    2. 因为使用引用传参,所以函数也需要重写参数列表接收一个引用
  2. return x1 返回值

    1. 给函数参数加一个引用参数,类型为返回值类型
    2. 将函数内的局部变量通过拷贝构造给这个参数,返回值体现在添加的参数里,外部可以感知到

尝试优化

对于返回值,可以从使用者层面优化:

函数内的局部变量不需要通过拷贝构造给这个参数,直接在函数内部不定义这个局部变量,将对于他的操作全部写在他的一个构造函数内,return X(y,z); 这样当转化为参数时也只是调用参数的构造函数,即可省去拷贝构造。

member initialization list

即:


class a{
    int a1,a2;
    a(): a1(0), a2(0) {};
}

这种初始化方式。

以下几种成员必须如此初始化:

  1. reference member
  2. const member
  3. call case class 的 constructor
  4. call member class 的 constructor

需要注意的是,变量的初始化顺序取决于成员在类内声明的顺序,而非member initialization list的顺序

虚继承下构造

主要说钻石继承,见图:

在构造Vertex3d或Pvertex时,Point的构造函数只应构造一次,且由最底的Vertex3d或Pvertex调用,而非递归的调用到。

构造函数中调用虚函数

使用构造函数来调用虚函数,参考图:

假如所有类都有一个虚函数size(), 这个虚函数在每个构造函数中被调用,那么在Pvertex构造函数调用时应当根据目前递归调用到那一层来决定size()的调用,也就是Pvertex构造中Point3d构造时就该调Point3d的虚函数size,返回Point3d的大小。 这就对vptr的初始化有了一定要求,维持虚函数调用的规则不变(也就是编译器不插手这种情况,编译器当然可以将size使用对应的静态调用方式来调用,但不好,不够优雅),对vptr的初始化顺序做一定限制,就可以完成。

vptr在基类的构造函数调用之后但在程序员的code或member initialization初始化操作之前才初始化,这就确保了基类中调用的size依据的是基类的vptr,因为vptr要在程序员的code之前就初始化好。当然member initialization初始化操作其实还是在程序员的code之前的,在vptr初始化之后的。所以顺序是:

基类的构造函数调用 -> vptr初始化 -> member initialization初始化操作 -> 程序员的code

数据

数据布局

  1. static成员不在对象布局内,在静态存储区域
  2. 其他的同一access section内相对次序与声明次序相关,不同之间编译器可以做自己的实现,不保证顺序
  3. 编译器自己生成的vptr等成员自己放置位置,目前多防止开头或结尾。

数据存取

static data member

static data member存在于data segment内,编译器将其转化为一个指针,直接去存取。且static data member并不会冲突,得益于name-mangling机制,每一个类的static data member都有其唯一的id,通过id访问

nonstatic data member

通过implicit class object

对于函数的成员方法,其可以直接操作类内成员,编译器在参数列表添加一个this指针,方法是通过this指针来操作的

存取操作

通过计算成员变量的offset来存取,但是一旦存在virtual base class object,就多了一层间接性,因为虚基类的对象可能需要通过一个bptr来访问到。

显然,以下两种情况当使用指针存取,成员存在于虚基类中,其效率会低,这是因为编译器实际上并不能确定aptr指向什么type,也就对于x的offset无从得知,一切判断都需要延迟到执行期。但是a.x的方式对于编译器来说是明显的,编译器可以确定的在生成的汇编代码里加上offset寻址到那个x。

A a;
A *aptr = &a;
a.x;
aptr->x;

继承下数据布局

无虚函数和虚基类

各基类需要保持其“原样性”:

见图可知:

有虚函数无虚基类

见图:

单一继承

多重继承,derive class添加新的virtual func

有虚基类(虚继承)

经典的钻石继承

使用pointer 策略即bptr

看到pPoint2d就是那个bptr

使用table策略即通过在vptr内添加虚基类偏移找到

Data member 的地址

对一个Data member 取地址,取到的是data member在object内的偏移量

通过指向data member的指针来存取目标会更加耗时

函数

nonstatic member function

为了获得和普通函数相同的调用效率,

进行三步处理:

  1. 传一个对象this指针进去
  2. 内部对成员调用改为this->
  3. 对函数名做mangling,起一个别的名字,一般来讲有class名称在内

nonstatic member function函数指针

这也是一个需要绑定于object地址上才能调用的函数指针,也就是他本身是一种不完整的offset。

virtual member function

将虚函数调用转化为虚函数表的一项:

(* ptr->vptr[1])(this);

单一继承下虚函数表布局

直接看图

多继承下虚函数表布局

看到base1的虚函数表里居然有base2的虚函数,这是因为base1里的的虚函数表充当了derive class的主要虚函数表,base1 class就是主要实体,所以在他这个subobject内的虚函数表就需要一个完整的derive class能掉用到的虚函数表。

那么如果将derive class object传给一个base2指针,因为是一个derive class,那么虚函数指针还是需要指向开头的主要实体的虚函数表这里,因为它可以调用所有的虚函数,这种设计给类似上面的行为提供了一种统一的做法,那就是:要访问虚函数,那就将调用指针移到开头去,从主要实体里找虚函数表。

虚继承下虚函数表布局

明显虚继承下derive class的虚函数表也包含全部虚函数,虚基类的虚函数表也包含他自己有的全部虚函数,只是虚函数版本向derive class内的定义看齐

virtual member function函数指针

  1. 单一继承体系下

虚函数的地址只是一个它在虚函数表内的索引值。

  1. 多继承体系下:

cfront的实现使用一个结构体与这个虚函数绑定,记录了他的表内索引和他所在的表的vptr,通过这俩找到这个虚函数

static member function

static member function直接被转换为非成员函数并调用

不能存取class中的nonstatic members

所以一般用于操作static member,因为static member一般不会被定义为public的,所以用一个function来操作很好,而普通成员函数又需要创建object才能用,static member function就不需要。

inline function

inline 函数会经历以下两步:

  1. 分析函数,决定是否能成为inline,如果不能就转换为static函数
  2. 在调用点上触发函数拓展,拓展时存在参数求值和临时对象管理问题

析构

首先最重要的一点:

每一个derive class的析构函数会被编译器扩展,以静态调用的方式调用各层base class的析构函数(如果有的话),base class没有析构函数那就不调用了

默认destructor

只有member 有一个destructor, 默认destructor才被合成,合成出来就是调用成员的destructor

析构过程

还是这个图,Pvertex被析构时,他这个对象会依次“变成”各个基类,直到最后变成Point然后彻底消亡,vptr,object都会发生这种“蜕变”,很神奇。

对象

全局对象

程序之始创建于data segment中,exit时销毁

局部静态对象

local static object在c++ std要求下必须在这一局部被调用到才创建和赋值。且该对象只创建一次(就在被调用时),只销毁一次(整个程序退出时)。具体怎么实现被调用才创建就略了。

对象数组

有一个循环的方法来依次创建和销毁数组中的每一个对象。

new和delete

new: 配置内存malloc -> 构造

placement new: 在指定位置配置内存并构造

delete: 析构 -> 释放内存free

RTTI

使用vtable的第一个slot装type_info这个object的地址

以上就是使用dynamic_cast时所做的操作,他相比于static_cast更为耗时但安全,因为一旦类型不匹配那就转换失败。而static_cast就是强转。

而对于非多态类或内建类型,这个type_info可能就需要单独定义,取得的时候就需要单独链接起来。

Template

声明 + “具现”

模板函数只声明,不使用编译器并不会“具现”他,也就是text 段中不会有这些函数。需要注意的是模板类中所有的member functions都会被具现出来,即使它们中的一些并未使用。在每个可以具现的地方具现,并在最后链接的时候只用一份,丢弃其他的,就可以缩小最终可执行文件的大小。

一旦使用,那么type就确定,这个模板函数就可以转为普通函数来进行处理。

但是模板类内部如果存在一个方法完全不依赖传入的type,也就是type不指定不影响方法的定义。那么这个方法内部很可能与当前程序文件的其他内容相关。比如那个方法调用了一个extern 函数其参数是double,虽然模板类内有个member是int(确定为int)的,方法内调用函数传的参数的也是这个int的member,但编译器还是会提前将这个extern函数链接上去,即使后面调用处有更合适的函数(一个extern 函数其参数是int),这里也不会再改变了。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy