Last Updated on 2024年2月17日
嵌套类和局部类
- 定义在类X内的类Y称为嵌套类,它就是一个普通类,只不过使用的作用域被限制了.
- 定义在函数内的类Y称为局部类,这种类限制很多,主要用于在语言层面支持Lambda,实践中基本不用.
struct(class)的内存布局
- C++的struct和class实例大小不会为0,即便它内部没有任何数据变量.主要是因为编译器总是需要为实例分配内存,使得对象能获得有效的地址,且不同对象的地址总应该是不同的.
- 类型布局相关的名词有很多,如POD,Aggragate,naive等,具体我也不是很清楚,总之很麻烦.
- 如果需要把sturct或class对象通过C风格直接从内存层面管理(如memcpy),那么要求它的内存布局是平凡的.
- 只要类型涉及到virtual, 那类型实例的首地址存储的一定是vptr, 涉及virtual只需满足任意一个条件:
- 直接或间接使用虚继承
- 直接或间接使用虚函数
一般规则
C++对象的内存布局对于理解多继承/虚继承非常重要, 基本要关注的有三点
- 对于任意一个类型
Foo
,它的编译期布局都是已知的. - 对于某个指针
Foo * p
, 在代码正确时, 它指向的对象可能是Foo
的任意一个派生类, 编译器需要保证: 无论p
指向哪个子类的实例, 代码都应该是正确执行的 - 保证正确的主要工具是VTable+VPTR
确定布局的核心规则: 对于某个确定的类型X
, 其在内存中有两部分数据组成, 分别是静态部分和动态部分:
- 将X的直接基类中, 非虚基类的静态部分顺序拼接, 形成X的静态部分.
- 遍历X的整个继承树, 得到所有虚基类的类型名, 形成一个set, 将这些类型按字典序排序, 然后将这些类型的静态部分拼接起来,形成X的动态部分.
- 若X涉及virtual, 且X的静态部分的第一个类型不提供vptr, 则在X的静态部分头部插入一个新的vptr
第二条规则决定了动态部分, 它主要是为了支持虚继承而引入的, Virtual继承的逻辑功能是: 如果继承树中有多个环节virtual继承A, 那么所有这些virtual继承A的部分将通过指针共享同一个
A
Virtual继承主要是用于解决菱形继承的is-a问题, 对于
D: public B, public C
,B: public A
及C: public A
, 是否有D is a A
成立 ?
C++的原则是根据地址判断,只要Derive
派生类到达某个基类Base
的所有路径中, 最终获得的地址Base * p
都相同, 则认为Derive is a Base
, 例如, 上面的例子中, 只要能保证dynamic_cast<A *>(dynamic_cast<C *>(obj_d)) == dynamic_cast<A *>(dynamic_cast<B *>(obj_d))
那么才认为D->C->A
和D->B->A
是同一个A, 进一步才认为D
也是一个A
的实例, 编译器才会允许dynamic_cast<A*>(obj_d)
.布局兼容性
首先, 我们需要初步了解涉及vtable时需要做的事情, 主要有两个场景, 假定给定了Base * p
p->data
: 如果data属于Base
的动态部分, 那么data
相对this的偏移在每个派生类中将是不同的, 只能通过vtable确定, 即p + p->vptr[OFFSET]
p->fun()
: 它相当于调用OverrideT::fun((OverrideT*)(p))
: 我们一方面需要查找Override函数的地址, 另一方面需要在传参时修正p
, 获得正确的this
值. 函数地址可以用p->vptr[OFFSET1]
确定,this
的修正则一般存储在相邻的位置, 通过p + p->vptr[OFFSET1+1]
获得
为了保证上述功能正确, 我们只需要保证指针p
的vptr总是指向正确的vtable即可. 具体而言, 就是在各类cast的过程中, 保证vptr总是指向正确的vtable即可.
Static Cast 兼容性 : DestT *p_dst = static_cast<DestT *>(p_src)
- 在不涉及虚继承时, 由于Src和Dest的布局都是编译期已知的, 所以编译器会直接计算出
SrcT
和DestT
之间的地址差值来进行修正p_src
的二进制值. - 在涉及虚继承时, 一般是会在vtable中记录和原始对象的地址偏移,通过做两次修正进行转换. 第一次修正是将
p_src
修正得到原始对象的指针p_origin
, 第二次修正则是根据原始对象指向的vtable, 获得指向DestT
时需要修正的偏移量. - 无论是否涉及虚继承, 为了保证类型兼容, 编译器不但需要对指针值做修正, 还可能需要为每个类型准备多个VTable, 以保证修正后的指针指向正确的Vtable. 例如
C: public A, public B
, 在static_cast<A*>(c)
时,c
的二进制值不需要修正,但是需要保证c->vptr
指向的VTable和类型A兼容. 在static_cast<B*>(c)
时, c的二进制值需要修正为cf
, 且cf->vptr
指向的VTable需要和类型B兼容. 显然, 这里用于和A兼容的VTableA及用于和B兼容的VTableB结构大概率是不同的.
Dynamic Cast 兼容性: DestT *p_dst = dynamic_cast<DestT *>(p_src)
- 从实操上说, 其实就是static cast加额外的运行期检查, 编译器会额外的插入一段代码
DestT::IsInstance(p_dst)
编译期可以手动禁用RTTI, 这主要是为了避免在Vtable中生成类型信息, 因为
DestT::IsInstance
逻辑上需要对DestT
的所有派生类都成立, 它实际的实现开销是比较大的.如果能保证整个类型系统都没有virtual继承和多继承,那么对象的内存布局将会变得非常简化, 这也是大部分编程风格禁用virtual继承和多继承的原因.
OOP基础特性:
- 如果没有特别说明,就永远只使用
public继承
,private
及protect
继承并非面向对象设计
中的组件,而是为了实现其他功能的,在C++中从不应该使用. - 继承过程中基类成员的可见性可以通过
using T::name
手动控制.- 例如,基类中有public的
function()
,我们可以在派生类的private中写using Base::function
,那么就不能通过派生类实例访问function了
- 例如,基类中有public的
- 从实现上说,const成员函数只约束类实例内存区域不可写(bitwise-constness).例如,一个class有一个
int *p
成员,那么,在const成员函数中,修改p
是不可以的,修改*p
则是可以的.
friend
friend
是编译期的,其作用是将当前类的自定义成员向某个作用域开放访问(撤销private限制).- 这个作用域可以是一个函数,或一个类
- 只要该作用域能够访问到类实例,就能访问该实例的数据成员,或使用成员函数.
- 即便该作用域不能访问到类实例,也可以直接使用不含
this
的private成员函数,或者调用private构造函数创建对象.
friend
语句不是函数声明语句.
this
- 从逻辑上看,
this
在形参列表中为(T * const this, ...)
- 我们可以额外为
this
添加&
,&&
及const
const
要求this指向const
对象&
则要求this指向一个左值&&
要求this指向一个右值
- 我们可以额外为
this
的实现方式随编译器ABI而定, 不一定是形参列表中的第一个(例如有的编译器会固定使用某个寄存器传this, 而有的编译器则是将this入栈,和其他参数一样处理.)
类内基本控制函数
构造函数ClassName(args):{}
- 对于
const
类实例,仅当构造函数的代码区执行完后实例才获得const
属性. - 只有函数调用才能触发隐式类型转换.(重载运算符也是函数调用,也能触发)
- 添加
explicit ClassName(TYPE n)
限定符可以阻止编译器进行隐式转换.
拷贝控制
- 涉及拷贝构造(移动构造),拷贝赋值(移动赋值),析构这三类操作的控制函数被称为copy control,对于旧版本C++,有3个,对于C++11,则有五个
- 在一些支持隐式转换的场合,编译器可能优化掉拷贝/移动初始化操作. 例如
string str_obj="mystr";
,它实际是string str_obj(string("mystr"))
, 可能会被优化为string str("mystr")
,拷贝初始化(移动初始化)就被绕过了. - 编译器合成的拷贝移动/复制支持引用/指针, 逻辑上都是浅拷贝.(相比之下,手动定义的移动/复制则需要手动处理指针,且不支持引用)
= delete(C++11)
- 用于定义"不能被调用"的函数,
=delete
的函数会参与函数重载,当匹配到=delete
的函数时,编译器将报错.例如,对于一个mutex
,它显然是不具备拷贝意义的,我们就可以让class Mutex
的拷贝构造/拷贝移动为=delete
的
=default(C++11)
- 使用
=default
可以强制编译器按默认规则生成对应的函数- 如果编译器无法生成,则生成的版本是
=delete
的.
- 如果编译器无法生成,则生成的版本是
- 在声明处的
=default
将会使编译器生成inline
的版本
编译器的隐式合成行为
-
编译器会自动插入
xxx=default
这样的代码,称之为隐式合成.具体而言,只有默认构造函数T()
和五个拷贝控制函数是可能被隐式合成的.- 有了任意显式构造函数都不会合成
T();
- 有了任意显式拷贝控制都不会合成其余的拷贝控制;(有的编译器不严格执行)
- 注意:即便是
T(xxx) =default
这样的代码,仍被认为是"显式"的
- 有了任意显式构造函数都不会合成
-
如果T存在不能拷贝的成员,则编译器将合成
=delete
版本的拷贝赋值及拷贝初始化. -
只有当所有成员都可以移动时,编译器才会合成移动构造和移动赋值.换言之,只要编译器隐式合成了移动控制函数,就一定是可用的,不会是
=delete
的 -
"没有合成"与"=delete"是不同的. 没有合成意味着它不存在,不会参与重载,
=delete
的版本会参与函数匹配和重载.动态绑定与多态
-
返回类型放松:对于virtual函数
T::Foo
,如果返回类型是T&
或T*
, 我们可以在override的时将返回类型修改为派生类的T2&
或T2*
-
在构造函数和析构函数中调用虚函数仅会静态绑定, 也就是调用编译器当时能分析到的版本. 这是为了避免安全问题.例如,对于
class B: public A{virtual void fun();}
,在构造A时需要调用fun()
,若因动态绑定调用了B的版本,由于此时B仍未构造,此时调用B的版本就可能访问未初始化的区域,这是十分危险的.- 所以一般禁止在构造和析构中调用虚函数.