C++ OOP编程综述

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++对象的内存布局对于理解多继承/虚继承非常重要, 基本要关注的有三点

  1. 对于任意一个类型Foo,它的编译期布局都是已知的.
  2. 对于某个指针Foo * p, 在代码正确时, 它指向的对象可能是Foo的任意一个派生类, 编译器需要保证: 无论p指向哪个子类的实例, 代码都应该是正确执行的
  3. 保证正确的主要工具是VTable+VPTR

确定布局的核心规则: 对于某个确定的类型X, 其在内存中有两部分数据组成, 分别是静态部分和动态部分:

  1. 将X的直接基类中, 非虚基类的静态部分顺序拼接, 形成X的静态部分.
  2. 遍历X的整个继承树, 得到所有虚基类的类型名, 形成一个set, 将这些类型按字典序排序, 然后将这些类型的静态部分拼接起来,形成X的动态部分.
  3. 若X涉及virtual, 且X的静态部分的第一个类型不提供vptr, 则在X的静态部分头部插入一个新的vptr

第二条规则决定了动态部分, 它主要是为了支持虚继承而引入的, Virtual继承的逻辑功能是: 如果继承树中有多个环节virtual继承A, 那么所有这些virtual继承A的部分将通过指针共享同一个A

Virtual继承主要是用于解决菱形继承的is-a问题, 对于D: public B, public C,B: public AC: 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->AD->B->A是同一个A, 进一步才认为D也是一个A的实例, 编译器才会允许dynamic_cast<A*>(obj_d).

布局兼容性

首先, 我们需要初步了解涉及vtable时需要做的事情, 主要有两个场景, 假定给定了Base * p

  1. p->data: 如果data属于Base的动态部分, 那么data相对this的偏移在每个派生类中将是不同的, 只能通过vtable确定, 即 p + p->vptr[OFFSET]
  2. 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的布局都是编译期已知的, 所以编译器会直接计算出SrcTDestT之间的地址差值来进行修正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继承,privateprotect继承并非面向对象设计中的组件,而是为了实现其他功能的,在C++中从不应该使用.
  • 继承过程中基类成员的可见性可以通过using T::name手动控制.
    • 例如,基类中有public的function(),我们可以在派生类的private中写using Base::function,那么就不能通过派生类实例访问function了
  • 从实现上说,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的版本就可能访问未初始化的区域,这是十分危险的.

    • 所以一般禁止在构造和析构中调用虚函数.