C++ OOP编程综述
嵌套类和局部类
- 定义在类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的部分将通过指针共享同一个
AVirtual继承主要是用于解决菱形继承的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的版本就可能访问未初始化的区域,这是十分危险的.- 所以一般禁止在构造和析构中调用虚函数.