这是第一遍看《深度探索C++对象模型》的简单笔记。
整本书有些地方看着很通透,但有些地方看得迷迷糊糊不知所云。等以后重看时把一些不懂的地方再完善完善。
C++实现多态的方法
- 隐式转化
如Base *p = new Derived();
- 虚函数机制
- dynamic_cast和typeid
C++的内存模型
分为5个区域:
- 堆
动态分配的内存。 - 栈
存储局部变量、局部常量、参数等。 - 静态存储区
存储全局和静态变量。
在C中,未初始化的全局变量会放到.bss区域;而C++将所有全局变量都放到静态存储区中。
- 常量存储区
存储常量。 - 代码区
存储代码。
虚函数
当一个类有虚函数时就需要维护一个虚指针(vptr)指向一个虚表(vtbl),该表中每个slot存放一个函数指针,每个指针指向一个它的虚函数。
当派生类继承时,将自己的vptr指向自己新建的vtbl,并且vtbl里的函数指针和它的基类相同。
之后每覆写一个虚函数,就将其中对应的函数指针更改为指向覆写后的。
如果派生类添加了新的虚函数,就加到vtbl后面。
另外,纯虚函数也会占用一个slot,即使它在抽象类中没有实现。
既然一个子类的vprt指向的vtbl是覆写过的,那么使用Base b = d
直接将子类对象d赋值给b后,为什么b的vtpr不指向d的vtbl呢?调用的是父类(即未覆写)的函数呢?
原因是编译器在此时作出了仲裁,将b的vptr指向他自己的vtbl。
vptr在哪
vptr放在一个类的头部。这会有助于 多重继承下通过指向类成员的指针调用虚函数。而缺点则是丧失了和C语言struct的兼容性。
我的环境Microsoft Visual Studio 2017,64位
虚基类
考虑以下继承关系(即多继承):
class X {};
class Y : public virtual X {};
class Z : public virtual X {};
class A : public Y, public Z {};
其模型如下:
X作为Y和Z的虚基类。类A直接维护一个从类X继承而来的数据成员,而不从X、Y处继承。
多继承的复制构造函数
倘若要写类A的复制构造函数,应当如下:
A& A::operator=(const A &a) {
// 先调用其父类的复制构造函数
// 理论上这两个函数是不能调用虚基类X的构造函数的
this->Y::operator=(a);
this->Z::operator=(a);
// 但是不知道编译器会不会抑制其操作,因此需要再显式地调用该语句
this->X::operator=(a); // 虽然可能导致拷贝多次从X继承而来的成员,但能保证一定是从X继承来的
}
对象
影响对象内存大小的都有什么?
一般而言受以下3方面影响:
- 非静态数据成员
- 由于内存对齐而填补的空间
- 为支持virtual产生的额外开销(虚指针vptr)
书上说vptr指向的vtbl的第一个slot存放type_info信息。
但是我在调试后发现如果一个类没有虚函数那么就不会维护一个虚指针。且第一个slot(即vtbl[0])并非type_info信息。
通过调试数据成员偏移量发现,可能vptr是作为其第一个隐式数据成员,type_info作为第二个。
局部静态对象
一个局部静态对象保证其构造函数和析构函数只调用一次。并且只有它第一次被用到时才会调用其构造函数(而非在编译期)。方法是设置一个bool变量flag来确定是否是第一次使用。
由于在编译期无法确定这些对象的构造顺序,因此需要维护一个执行期链表保存构造的静态对象,以按顺序析构之。
new和delete
new的使用分两步:
- 使用适当的new配置内存。
- 将配置得到的内存初始化。
因此该语句SomeClass *p = new SomeClass
会被转化为:
SomeClass *p;
if (p = __new(sizeof(SomeClass)))
p = SomeClass::SomeClass(p);
对象数组
对没有虚基类的类声明数组,只需要使用new就够了,而对于有虚基类的类,编译器单独生成了一个函数vec_new()来创造其数组。该函数需要得到数组的起始地址、对象大小、对象数量,以及构造函数和析构函数
之所以创建数组还需要析构函数,是因为当捕获到异常时需要析构。
当delete一个数组时,即使没有指明数组大小,编译器也可以正确将其销毁,通常做法是在vec_new()中额外添加一个参数保存其元素个数,称为cookie。
因此如果用基类的指针去delete一个派生类对象数组就会出错(好吧,编译器对这种多态无能为力)。
临时性对象
考虑下面两种对象的初始化:SomeClass c = a + b
和c = a + b
。
前者的编译比后者更有效率,因为前者直接调用复制构造函数,而后者被转化为
c.SomeClass::~SomeClass(); // 先析构
c.SomeClass::SomeClass(a + b); // 再构造
所有临时性对象都应该在其所处的完整表达式求值之后再析构之,但有以下两个额外情况:
- 若该临时性对象持有表达式的结果,应该留存到对象的初始化完成。
如:
const char *str = str1 + str2;
// 不能被转化为
String temp;
operator+(temp, str1, str2);
str = temp.String::operator char*();
temp.String::~String(); // 此时str成了野指针
- 若该临时性对象被绑定于一个引用,应该被留存到引用的生命期结束。
如:
const String &str = " ";
// 将被转化为:
String temp;
temp.String::String(" ");
const String &str = temp;
// 此时被析构则引用就无用了
构造函数
之前一直有一个很深的误解:任何没有显示定义构造函数的类都会被生成一个默认构造函数。
这句话其实是错误的。真实情况是:只有当构造函数被需要时编译器才会生成默认构造函数。
只有下面两种情况才会生成默认构造函数。
- 类成员对象或类的父类有默认构造函数时
类首先会调用其基类和成员对象的所有构造函数,最后才调用自己的。 - 需要为对象初始化vptr或虚基类指针时
为了避免为多个文件生成多个默认构造函数,编译器将默认构造函数、析构函数、拷贝构造函数、赋值拷贝构造函数都以inline方式完成。如果函数太复杂不适合inline,就生成为非inline的静态函数。
复制构造函数
复制构造函数和构造函数一样只有在需要的时候才会被生成。
另外,返回一个类对象时会调用复制构造函数,例如:
X fun() {
X x0;
// 处理x0
return x0;
}
要把一个局部对象返回,需要添加一个额外参数来保存返回的对象。
因此上述函数会被改写为这样:
void fun(X& __result) {
X x0;
// 处理x0
__result.X::X(x0); // 调用X的复制构造函数
return
}
并且还可以进一步优化为:
void fun(X& __result) {
__result.X::X();
// 直接处理__result
return
}
这样就少调用了一次复制构造函数。(称为Named Return Value, NRV优化)。
书上说如果类生成了复制构造函数就可以实施NRV优化以提高效率。
但是我按照书上的示例代码运行发现竟然加了复制构造函数之后运行效率变低了。。。不知道是为什么。
另外,如果不允许一个类通过赋值运算符赋值,那么只需要将其赋值运算符重载并设为private,并且不提供定义即可。
初始化列表
对比以下不使用初始化列表和使用初始化列表的情况。
首先是不使用初始化列表,对如下代码:
class Word {
private:
String name;
int cnt;
public:
Word() {
name = "";
cnt = 0;
}
}
其构造函数会被改写为:
Word::Word(/*this指针*/) {
// 调用name的构造函数
name.String::String();
// 生成一个临时对象
String temp = String("");
// 将temp赋给name
name.String::operator=(temp);
// 析构temp
temp.String::~String();
cnt = 0;
}
作为对比,使用初始化列表如下:
Word() : name("") {
cnt = 0;
}
// 将被改写为以下形式:
Word::Word(/*this指针*/) {
// 直接调用name的有参构造函数
name.String::String("");
cnt = 0;
}
可以发现效率提高不少。并且注意到初始化列表转化的代码会放在构造函数中显式写出来的初始化代码之前。
另外,初始化列表的初始化顺序是成员变量的声明顺序而非初始化列表的排列顺序。
以下四种情况都必须使用初始化列表:
- 初始化引用成员时
- 初始化const成员时
- 调用基类的有参构造函数时
- 调用成员类的有参构造函数时
继承下构造函数的扩充
下面完整地记录一个构造函数可能被扩充的内容:
- 调用所有虚基类的构造函数,从左到右从深到浅。
- 调用所有基类的构造函数(以声明顺序)(忽略其对虚基类的构造函数的调用)。
- 若该对象有vptr,则将其初始化并指向vtbl。
- 将初始化列表的初始化放进来。
- 如果一个成员变量不在初始化列表中但它有缺省构造函数,则调用之。
其中3必须在1、2之后,原因在于如果调用基类构造函数时调用了其某虚函数,那么应该调用的是基类的该函数而非由虚指针指向的本类的。
析构函数
只有在类的基类或其成员变量有析构函数时,它才会生成析构函数
注意,否则即使它有虚函数也不生成析构函数。
关于空类的一个小问题
对上文提到过的多继承关系(其中X、Y、Z、A全是空类),这段代码
cout << sizeof(X) << " " << sizeof(Y) << " " << sizeof(Z) << " " << sizeof(A);
输出为1 8 8 16
。
原因如下:一个空类X,会有一个隐藏的1字节,用于让其不同的对象在内存中有不同的地址。
而根据空虚基类策略,一个空的虚基类被视为其派生类对象最开头的部分。因此Y和Z会维护一个指向X的指针,其隐藏的1字节被视为Y和Z的一部分。
最后A维护其两个父类的指针。
关于数据成员
非静态数据成员的排列顺序和其声明顺序一致。
使用&SomeClass::data
可以获得data在类中的偏移值(如果是静态成员则会得到真实的地址)。
书上说其实会给偏移值+1,以避免和NULL(即0)混淆。但是我尝试之后发现并没有,应该是做了优化。
用对象和用其指针或引用访问数据成员有什么区别呢?如:
SomeClass obj;
SomeClass *p = &obj;
obj.data = 0;
p->data = 0;
在data是结构体、类、单继承、多继承的情况下其效率都完全相同,只有当data是一个从虚基类继承而来的成员时,速度会稍慢一些。
因为不知道指针p指向到底指向哪个类,因此编译期就不知道data真正的偏移值,该操作也就只能延迟到执行期。
关于函数
非静态成员函数
会被改写为加上this
指针的非成员函数。
静态成员函数
其主要特性是其没有this
指针。其次它不能直接存取类中非静态成员,也不能被声明为const、volatile和virtual。
单一继承的虚函数
通过指针的虚函数调用ptr->fun()
会被改写为(*ptr->vptr[0])(ptr)
,其中fun()在虚表的第0个slot中,后面的(ptr)表示传参this指针。
多重继承的虚函数
因为派生类中有两个基类的虚指针指向各自的虚表,因此当通过指针调用函数时,如果调用的是第一个继承的基类的话就和单一继承的转化一样,而如果调用的是之后继承的基类的话就需要给this指针加上偏移值。
比较有效率的解决办法是利用thunk。所谓thunk就是一小段汇编代码1,用适当的偏移值来调整this指针。
例如Derived继承自类Base1和Base2,并用指针的调用Base2的析构函数时,其thunk实现下面的功能:
this += sizeof(Base1);
Derived::~Derived(this);
指向成员函数的指针
一个指向成员函数的指针可以这样声明并初始化:int (SomeClass::*pmf)() = &SomeClass::fun()
。使用(ptr->*pmf)()
调用它(ptr是指向一个对象的指针,pmf表示pointer member function)。
当fun()是普通成员函数时,&SomeClass::fun()
取得其在内存中的地址;
当fun()是虚函数时,则取得索引其虚表中的索引。此时调用语句将被转化为(* ptr->vptr[ (int)pmf ]) (ptr)
。
正是由于这种二义性(一个代表地址,一个代表索引值),编译器必须可以区分成员函数指针是哪种情况。
一个方法是用一个结构体mptr表示成员函数指针:
struct mptr {
int dleta; // this的偏移值
int index;
union {
ptrtofunc faddr;
// 虚基类或多重继承中第二个及以后基类的虚指针偏移值
// 当vptr被放在对象头部时可以不需要该字段
int vptr_offset;
}
}
其中index和faddr分别(不同时地)表示虚表索引和非虚函数地址。当指向非虚函数地址时设index为-1。
此时调用语句会被转化为:(pmf.index < 0) ? (*pmf.faddr)(ptr) : (*ptr->vptr[pmf.index](ptr));
inline函数
处理一个inline函数时,首先根据其定义确定是否可以成为inline,不能则被转为static函数。可以的话则在被调用处展开。例如对于内联函数:
inline int min(int i, int j) {
return i < j ? i : j;
}
它有三种转换情况:
int minVal;
int val1 = 1024, val2 = 2048;
// 情况1
minVal = min(val1, val2);
// 被转化为:
minVal = val1 < val2 ? val1 : val2;
// 情况2,参数是常量时
minVal = min(1024, 2048);
// 被转化为:
minVal = 2048; // 直接换成常量
// 情况3,参数有副作用时
minVal = min(func1(), func2());
// 被转化为:
int t1, t2; // 需要引入临时对象存储副作用
minVal = (t1 = func1()), (t2 = func2()), // 先完成副作用并存储
t1 < t2 ? t1 : t2;
另外,inline函数每次展开时都需要维护一组它自己的局部变量,再加上有副作用的参数,就会产生大量临时性对象。这就是inline导致代码膨胀的原因。
inline和宏的区别
- 宏在预处理时展开,做一个简单的文本替换。而内联函数在编译时展开(有以上几种特殊情况),被嵌入到目标代码中去。
- 内联函数可以进行类型安全检查。
- inline只是建议。
异常
在捕获到异常后,如果没有吻合的catch对象,那么会调用terminate()
,并将堆栈中每一个函数调用弹出,这一过程称为unwinding stack。对以下代码:
// 一段代码A
SomeClass obj;
// 相同的一段代码B
虽然代码A和B是两段一样的代码,但由于中间生成了一个对象obj,所以代码B抛出异常时需要unwinding,而代码A则不需要。要识别这种执行期不同的语义,编译器有两种做法:要么把这两块区域以个别的“将被摧毁的局部对象”链表联合起来(在编译器设置),要么让两块区域共享同一块链表,该链表会在执行期扩大或缩小。
为了标识代码的区域。一个策略是构造出PC range(PC在Intel中是RIP寄存器)表格,将PC的起始值和结束值存储其中。当throw时当前的PC值会被与PC range表中的进行对比。看是在try内还是try外,try内的话需不需要unwinding。
考虑以下情况:
catch (Derived ex) {
// do something
throw;
}
如果catch到一个Base类对象,就会将其截断,且重新抛出时会产生一个新的派生类对象。
但倘若catch一个引用:catch (Derived &ex)
。那么就会得到且抛出真正捕获到的对象。
另外,若对一个基类使用dynamic_cast
,当其向下转化是安全时会返回转化后真正的地址,否则返回0。倘若对一个引用使用,因为引用没有NULL,所以不安全时会抛出bad_cast
异常。
只有当其以汇编代码实现时才会高效。 ↩︎