C++ OOP编程综述

嵌套类和局部类

  • 定义在类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的版本就可能访问未初始化的区域,这是十分危险的.

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

Read more

Coroutine

从一般概念上说, 协程是特殊的函数调用: 被调用的函数可以在可控的位置被中断,然后在下一次调用时,继续从上次中断的位置继续执行。 本文主要通过Python的协程来介绍协程, 这是我唯一熟悉的一种协程实现. Classic Coroutine 下面的python代码很好的说明了协程的核心功能 def co_routine(): recv0 = yield 996 # hangs here after first coro.send assert recv0 == "Second" yield 711 # hangs here after second coro.send return def main(): coro = co_routine() # Create a new coroutine object value = coro.send(None)

By Edimetia3D

GDB with Python

这篇文章的主要应用场景是调试Python的C/C++ Extension 1. 同时使用pdb / gdb 进行调试. 通俗点说, 既可以break在 .py 文件中,也可以break在 .cc 文件中 2. 在gdb中不但可以获得常规的调试信息, 还可以获得python VM 的调试信息, 例如获得python的调用栈, 访问Python局部变量等. 这将会在调试exception时(如Segmentfalut)非常有用, 这种场景下, 定位 Python VM 正运行到哪一行代码往往可以提供一些直观的重要信息. 第一步: 编译源码以获得一些辅助数据. 我们并不真的需要使用从源码编译的Python, 但是一些调试相关的辅助文件需要从源码中获得, 包括 python-gdb.py及debug symbol等. 在 https://www.python.org/ftp/python/ 或 https://github.com/python/cpython

By Edimetia3D

Bazel Notes

这是一篇2019年左右的记录, 内容可能过时, 也不太全面 杂谈 Bazel是Google为Monorepo服务而开发的构建工具. 首先是巨大,当问题的规模变大,事情总是会变得更复杂. 而Google面对的"巨大Monorepo",应该是世间罕有的. 然后是Monorepo,这极大的影响了代码的组织风格.例如,你要写一个操作系统内核ProjectOS,还要写一个游戏ProjectGame.在传统的开发习惯中,这两个项目会组织到两个不同的Repo里,PorjectOS和ProjectGame之间无法直接相互引用,例如,你在ProjectOS里写了一个高级的数据结构,想要在Game里也使用,要么直接复制粘贴,要么是创建一个新的CommonRepo,把可公用的代码都放在Common里,然后两个项目各自引入Common作为依赖. 使用MonoRepo则不存在这个问题,Game可以直接依赖OS内的组件,按照Bazel的语法描述,就是在Game中可以直接使用@ProjectOS//path/to/package:AdvancedStruct.当然,你仍然可以选择重构一

By Edimetia3D

Unix related things

这是一篇2017年左右的记录, 仅用作分享 杂 * 在shell内能干的事,我们都可以比较简单地通过系统调用实现. * `称为反引号,^称为脱字符,常用来表示CTRL * windows的系统调用是不开放的,windows下只能直接使用windows.h里的windows API. * /dev目录下的设备是供用于程序直接使用的,主要由block,char,pipe,socket类型 * 并不是所有设备都能映射为这种形式 * /sys/device/目录称为sysfs,他下面存放了所有设备的信息.(不能直接从/dev获得任何设备信息) * udevadm info --query=all --name="/dev/sda1"可以用于查询/dev下某个设备对应的sysfs路径 权限系统 * 权限系统由两部分组成 * 文件属性:用于标注文件owner,所属组,以及权限的设定(默认只有owner和root可以修改权限设置) *

By Edimetia3D