深度探索C++对象模型读书笔记
第一章 关于对象
影响C++继承体系对象的内存布局和存取时间的因素
- 虚函数
- 虚基类
class member 类型
- data member: static, nonstatic
- member function: static, nonstatic
C++对象模型
- 每个类对象拥有各自的 nonstatic data member
- static data member, static 和 nonstatic member function独立于类对象之外,只有一个实例
- 若有虚函数,则每个类产生指向每个虚函数的指针,并将指针放于虚表(virtual table)中,每个类的type_info 对象(用来支持RTTI,运行时类型鉴定)通常放在表格的第一个位置
- 每个类对象含有一个额外vptr指针,它指向该类相关的虚表(virtual table),vptr由该类的constructor,destruct和copy assignment运算符自动完成设置
继承
- 单一继承
- 多重继承
- 虚拟继承
虚拟继承时,基类只会存在一个实例(subobject)
access section
处于同一个access section的数据,以其声明的顺序出现在内存布局中,就是同为public,private或protect访问权限
支持多态
- 多态只存在于public继承体系中
- 隐式转化.把派生类指针或引用转化成public基类的指针或引用
- 虚函数机制
- dynamic_cast()和typeid运算符
- 在执行期根据对象的真正类型解析出正确被调用的函数
对象内存
- nostatic data members
- 为了边界对齐而添加的空间
- 支持虚函数的vptr
指针的类型
- 指针类型的差异体现在其寻址出来的对象的类型不同,指针类型告诉了编译器如何去解析该地址的内存内容和大小
- 转换(cast)就是一种编译器指令,告诉编译器该如何解析指针指向的内存区域
struct和class
- struct默认是public权限,class默认是private权限
- struct实现的是数据的集合,class实现了ADT观念,将数据和操作同时封装
- struct默认是public继承,class默认是private继承
第二章 构造函数语义学
关键字explicit
修饰构造函数,可以防止单一参数的构造函数被视为类型转换操作。
不加explicit时:
class Foo
{
public:
Foo(int a);
Foo(const Foo& f);
};
以下发生了隐式类型转化,将int转化为Foo类型
void f()
{
*// Foo tmp(1)*
*// Foo A(tmp);*
Foo A = 1;
}
加explicit时:
class Foo
{
public:
explicit Foo(int a);
explicit Foo(const Foo& f);
};
void f()
{
*// Foo A = 1,Foo C = B 会导致编译出错*
Foo A(1);
Foo B(2);
Foo C(A);
}
默认构造函数
对于类X,如果没声明任何构造函数,那么会有一个默认构造函数被编译器隐式生成,它可能是trivial(根本不会生成),也可能是notrivial(会生成)的。
以下分4种情况说明:
类中的成员对象含有默认构造函数
如果该类没有构造函数,但它含有的对象拥有默认构造函数,那么编译器会生成implicit inline notrivial的默认构造函数,如果函数太复杂,会合成explicit static实例。该默认构造函数会按顺序调用相应成员的默认构造函数。
如果该类有构造函数,编译器会在每个构造函数中首部安插代码来调用类成员相应的默认构造函数。
总的意思就是,类成员有默认构造函数,说明它需要被初始化,所以编译器需要做工作去初始化他们。
该类派生于带有默认构造函数的基类
基类有默认构造函数,说明基类需要被正确初始化,此时会通过生成默认构造函数或安插代码先调用基类的默认构造函数初始化基类部分,而后处理相应的类成员(如果有的话)。
存在虚函数的类
原理同上,编译器生成相关代码初始化vptr和vprtable。
带有虚基类
多重继承中的虚基类在子类对象中只保留一个,需要构造函数设置相应的虚基类对象指针。
拷贝构造函数
拷贝构造也分为trivial和nontrivial,并且nontrivial才会被编译器真正合成。
调用拷贝构造函数的情况:
class X{};
X x1 = X(); *// 默认构造*
X x2 = x1; *// 拷贝构造*
void foo(X x);
X f()
{
X tmp;
foo(tmp); *// 拷贝构造*
return tmp; *// 拷贝构造函数返回值*
}
按位逐次拷贝
像C中的POD类型的数据一个bit一个bit的拷贝。如果拷贝构造函数是trivial那么就会按位逐次拷贝,并不会生成它。
拷贝构造函数合成时机(nontrivial)
- 一个类包含的某个成员设置了默认的拷贝构造函数。(调用成员的默认拷贝构造函数)
- 一个类继承自一个含有默认拷贝构造函数的基类。(调用积累中的默认拷贝构造函数)
- 一个类含有虚函数。(正确拷贝vptr)
- 一个类继承自虚基类体系。(正确设置虚基对象指针)
第3种情况:
class ZooAnimal
{
public:
ZooAnimal();
virtual ~ZooAnimal();
virtual void animate();
virtual void draw();
*// ...*
};
class Bear: public ZooAnimal
{
public:
Bear();
void animate(); *// virtual*
void draw(); *// virtual*
virtual void dance();
*// ...*
};
Bear yogi;
*// 存在虚函数*
*// 编译器生成默认拷贝构造,正确设置w**innie**对象中的vptr*
Bear winnie = yogi;
当进行下面的拷贝初始化操作时,不会进行位逐次拷贝:
ZooAnimal z = b2;
这里由于z不是引用或者指针,b2会被切割,而z的vptr不会被设置成Bear类的vptr,z的类型编译期就确定了是ZooAnimal,它的vptr还是指向ZooAnimal类的vtbl。这是编译器合成的拷贝构造需要做的工作。
虚基类子对象
编译器安插代码正确设置虚基类子对象的指针/偏移量的初值。
程序转化语义学
尽管可以用不同方式初始化一个对象,但都会在编译阶段被转化某种形式,例如:
class X;
X x0(paras);
X x1(x0);
X x2 = x0;
X x3 = X(x0);
会被编译器转化为:
X x0;
X x1;
X x2;
X x3;
X0.X::X(paras); // 构造函数
x1.X::X(x0); // 拷贝构造函数
x2.X::X(x0); // 拷贝构造函数
X3.X::X(x0); // 拷贝构造函数
从C++17开始, 标准规定了必须进行copy elision的情况:
- 类似下面的情形:
T t = T(T(T())); // 只会调用一次默认构造函数, 要求类型相同(不考虑cv).
- 在返回类对象时, 如果直接在return语句中创建对象, 并且该对象与函数返回值类型一致(不考虑cv)时, 一般称这个优化为RVO(return value optimization)(注意, RVO在C++17之前都不是强制的, 从C++17开始才规定为mandatory的.), 如下例子:
T f() { ...... return T(); }
T t = f(); // 只会调用一次默认构造函数.
同样也规定了可以实施copy elision, 但不强制的情况, 比如NRVO(named return value optimization), 是指函数返回一个具名对象, 该对象是函数体内部定义的自动存储期变量, 并且是non-volatile的, 与函数返回值具有相同类型(不考虑cv). 具体可以参考copy elision
注意
只有当存在拷贝构造函数(不论是显式定义的还是编译器生成的)时, 编译器才有可能实施复制优化.
谨慎对待copy elision, 因为类设计者可能需要在拷贝/移动构造函数中进行某些特殊操作, 省略了之后可能带来难以调试的错误.
成员初始化列表
使用成员初始化列表的情况:
- 初始化一个引用成员
- 初始化一个const成员
- 调用一个含参的基类的构造函数
- 调用一个成员的含参的构造函数
类成员的初始化顺序和初始化列表的顺序无关,而是与类成员的声明顺序一致,在编译阶段会将初始化列表的初始化操作转化为代码安插在构造函数的显式代码之前。
注意
- 最好不要在初始化列表用一个成员初始化另一个成员,容易在顺序上发生罕见的错误。
- 在初始化列表可以调用成员函数来初始化成员,但要求该函数不依赖于这个类的数据成员,因为有可能在调用该函数时有某些成员还未初始化。
- 最好不要用成员函数来初始化基类成员。