Last Updated on 2023年7月11日
嵌套类和局部类
- 定义在类X内的类Y称为嵌套类,它就是一个普通类,只不过使用的作用域被限制了.
- 定义在函数内的类Y称为局部类,这种类限制很多,主要用于在语言层面支持Lambda,实践中基本不用.
struct(class)的内存布局
- C++的struct和class实例大小不会为0,即便它内部没有任何数据变量.主要是因为编译器总是需要为实例分配内存,使得对象能获得有效的地址,且不同对象的地址总应该是不同的.
- 类型布局相关的名词有很多,如POD,Aggragate,naive等,具体我也不是很清楚,总之很麻烦.
- 如果需要把sturct或class对象通过C风格直接从内存层面管理(如memcpy),那么要求它的内存布局是平凡的.
- C++11之后,编译器只保证:
- 同一个权限声明符block内的对象是顺序存储的(可能因对齐而不连续)
- 同一个权限声明符level的不同block相对顺序是确定的
- 编译器允许在满足上面约束的前提下任意重排类的数据成员布局.
- 只要类型涉及到virtual, 那类型实例的首地址存储的一定是vptr, 涉及virtual只需满足任意一个条件:
- 直接或间接使用虚继承
- 直接或间接继承使用虚函数的基类
- 自身使用虚函数
- 对于类型Foo, 编译器将为它生成多个VTable, 首地址vptr指向的VTable是类型Foo的核心VTable, 只用它就能完成在Foo实例上的任意操作, 而其他的VTable则是为了和基类兼容而创建的.
- 对于每个确定的类型, 其内存布局都是编译期可以确定. 但是当只使用指针, 而不能确定指针指向的真实类型时, 对象的内存布局可能无法简单的根据指针类型来确定.
- 对于某个确定的类型
X
, 其在内存中有两部分数据组成, [static][dynamic], 类型X的内存布局为:- 将X的直接基类中, 非虚继承基类的静态部分顺序拼接, 形成X的静态部分.(这个逻辑可以直接用于确认任何类型的静态部分)
- 遍历X的整个继承树,得到所有作为virtual基类的类型名, 形成一个set, 将这些类型按字典序排序, 然后将这些类型的静态部分拼接起来,形成X的动态部分.
- 若X涉及virtual,且静态部分的第一个类型不提供vptr, 则在X的静态部分头部插入一个新的vptr
当最终layout确定后, 该layout将参与后续VTable的生成, 以用于更新相关的offset值. 编译器首先会为类型X生成核心VTableX, 如果是新添加的vptr, 那么从0开始创建即可. 如果是复用了Base0的vptr, 那么为了保证和Base0的兼容, 需要在Base0的核心VTableBase0的基础上扩展, 生成自身的核心VTableX.
进一步, 布局中的每一个有vptr的类型都需要生成对应的VTable, 以保证类型的兼容性, 例如, 假如动态部分中的类型Type1涉及virtual, 那么这个类型的静态数据部分头部就是vptr, 编译器同样需要基于Type1的核心VTableType1来生成与Type1兼容的VTableXForType1.如果能保证整个类型系统都没有virtual继承,那么对象的内存布局将会变得非常简化, 这也是大部分编程风格/指南禁用virtual继承的原因.
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)
.
Get address
这里举例说明vptr + vtable是如何实现virtual相关的功能的. 注意: 这里的描述仅仅是GCC的一种实现, 实际的实现可以是多种多样的, 只要逻辑上满足功能即可.
对于数据成员.
- 静态:
p->data
, data位于decltype(*p)
的静态部分, 则p + OFFSET0
: 这个很好理解 - 动态:
p->data
, data位于decltype(*p)
的动态部分,则首先需要获得data所属基类对象的地址p + vptr[OFFSET0]
, 再通过p + vptr[OFFSET1] + OFFSET2
获得最终的data成员地址.
对于函数调用p->fun()
,它涉及两次数据查找,分别用于定位函数func自身的地址 和 this 参数的地址, 它们的地址都可能需要通过动态的方式获得.
- 如果
fun
是一个普通函数- 那么
fun
的地址是常量,由链接器来解决. - this可能会被修正为
this = p + OFFSET2
, 也可能被修正为this = p + vptr[OFFSET3]
, 这取决于定义fun的类型到当前类型的路径上是否涉及virtual继承.
- 那么
- 如果
fun
是一个virtual函数,- 只能通过
func_ptr = vptr[OFFSET2]
来获取函数的地址, 因为函数可能由派生类提供, 编译期不可知其地址. - 只能通过
this = p + vptr[OFFSET3]
的形式获得, 因为实际的对象可能是派生类实例,它和p的相对偏移也是编译期不可知的.
- 只能通过
对于虚函数调用, 鉴于this和func的地址都需要查表, 一般而言, 这两个offset在虚函数表中存储在相邻的地址上
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而定.
类内基本控制函数
构造函数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的版本就可能访问未初始化的区域,这是十分危险的.- 所以一般禁止在构造和析构中调用虚函数.