核心简记
面向对象的核心思想是数据抽象、继承、动态绑定。
数据抽象:接口与实现分离
继承:定义相似的类型与关系建模
动态绑定:忽略类型区别、统一使用对象
c++中将要由子适应自己而改变的函数记为虚函数,子不应该改变的函数则为普通函数。
派生类列表指明从哪些类继承而来。形式: 冒号+访问说明符+类名 逗号分隔
虚函数必须定义,派生类不是必须定义父的全部虚函数,没定义的会名字找到父的实现。后加override表示编译器检查,必须这个成员改写了基类虚函数。
基类的引用指针可以绑定到派生类,反之不可,当使用基类的引用或指针调用虚函数时发生动态绑定,实际调用的函数由运行时对象决定。不加虚的由指针类型静态决定。不用指针的是拷贝赋值函数调用,会砍掉子的部分。
一个类控制自己可被访问的部分,继承只是在黑盒上再进行黑盒封装。不能控制父的私有部分。
定义基类与派生类
|
|
基类应该定义虚析构函数,为了按动态类型释放对象。
希望派生类实现的函数定义为虚函数-运行时解析,不希望改变直接继承的为普通函数-编译时解析。
虚函数通过声明前加virtual关键字。可以是构造函数与静态函数之外的函数,因为这俩个函数都不是对象绑定的。
关键字virtual只能出现在类内部的声明语句之前,不能用于外部函数定义,因为其是调用方的事-使用声明查找接口,不是实现方的事。
基类的虚函数在子类中隐式的也是虚函数。
|
|
派生类可以访问基类的公有、受保护的成员。基类的访问修饰符同样是三个,用于控制派生类的用户对派生类继承自基类的成员的访问权。
不是必须覆盖基类的虚函数,没覆盖的行为类似普通函数。默认继承virtual,可用override。
一个派生类对象含有自己的成员以及基类的对象,c++标准没有规定对象在内存的分布。
派生类到基类的转化式隐式的。
每个类控制自己的成员初始化过程,派生使用基类的构造函数初始化基类部分。都是在初始化阶段执行的。
执行顺序是先基类的构造函数、再自己的,顺序按类内声明的,不写就是默认初始化。
派生类可以访问基类公有与保护成员,为的是不改变原封装性。每个类负责定义各自的接口。
如果类定义了一个静态成员,则整个继承体系中只存在该成员的唯一定义,静态成员初始化必在外,也是作为全局只能有一份,声明是供人连接的。访问使用基类、派生类均可。
声明语句目的是令程序知道某个名字的存在和表示什么实体,声明派生类直接写 class A;即可,不用加基类。
|
|
B是C的直接基类、A是C的间接基类。
防止继承的发生:类名后加final
B不能当基类。
智能指针也支持派生向基类转化。
表达式有俩种类型:静态类型与动态类型。
静态类型编译时已知,是声明或表达式生成的类型。动态类型则是内存中对象的类型。
如果表达式既不是引用也不是指针,则他的动态类型与静态类型一致。
不可将基类转化为派生类,即使一个基类的指针绑定在派生类对象上,也不能将基类化为派生类。编译器无法确定运行时安全,只能检查静态类型。
初始化或赋值对象时是在调用函数,此时子赋给父是执行父的赋值,会切掉子的部分。
类型规则:
- 从派生到基只对指针与引用有效。
- 基到派生不存在隐式
- 转化可能受限不可执行-由于访问受限
虚函数
当且仅当使用指针/引用调用虚函数时执行动态绑定,所有虚函数都必须有定义。也只有这时静态与动态类型可能不同。
对非虚函数的调用在编译时绑定,对象进行函数调用(无论是否虚)也是编译时定。
一旦声明某个函数为虚函数,则所有派生类中都是虚函数。覆盖时形参、返回类型必须完全一致,有一个列外:当返回类型是类本身的指针或引用时,返回类型可以不一致。
override标记的函数没有覆盖虚函数将报错:
该关键字是接口的部分,放于声明。出现在形参列表(引用与const修饰符是形参表的一部分)与尾置返回之后。
final标记函数不可被覆盖
出现在形参列表(引用与const修饰符是形参表的一部分)与尾置返回之后。
虚函数可以有默认实参,但是函数调用使用默认实参由静态类型决定-因为实参是调用时定。因此基类派生类的默认实参最好一致。
可以回避虚函数机制使用特定的版本:类似super
不管对象类型,在编译时完成解析,使用类的实现。
通常在成员函数、友元中使用作用域运算符回避虚函数机制,为了在子的实现中调用父的实现。直接调用分析的是this的类型,子的一般是子会无限递归,此时可以执行父的。
抽象基类
定义纯虚函数,该函数无需定义,在函数体的位置写=0,只能出现在类内的虚函数声明语句处。
不可定义有纯虚函数的类的对象,但是派生可使用其构造来构建基类部分。
可为纯虚函数提供定义,不过函数体必须在类的外部。
含有纯虚函数的类是抽象基类,不能创建抽象基类的对象。负责定义接口。子类不定义父的纯虚函数则仍是抽象基类。
派生类构造函数只能初始化他的直接基类。向上调用后向下完成。
访问控制与继承
每个类控制自己的成员初始化、成员的可访问性。
protected关键字:类用户不可见、派生类可访问。
注意:派生类的成员只能通过派生类的对象访问基类受保护的成员,而不能通过基类对象访问。即-可访问的是派生中含的基类,单独的不可。为了防止间接访问保护成员。
继承的成员可访问性受俩个因素影响:基类中的可访问性、派生列表中的访问说明符。我接下来称其为1与2
2对派生类访问直接基类没有影响,其只有1控制。
外部对基类的访问只看1,2是决定继承后的1是什么样的。
2对1不会造成权限提升,public的2使1不变,protect的2使1的pub变为pro,pre的2使1的pub与pro变为pre。
派生向基类转化的可访问性:
- 只有D公有的继承B时,用户代码才可使用派生向基类的转化。
- 不论D怎么继承B,D的成员函数与友元都能使用转化。
- 继承方式是公有或保护,则D的派生类可以使用转化。
对于某个节点,基类的公有成员可访问则转化可访问。
友元:
友元的关系不可传递,同样不可继承。友元只能访问指定友元关系的类,与单独声明的成员函数范围类似。
当类A将类B声明为友元时,这种友元关系只对B与A有效,A的子类不能访问B,B的子类不能访问A。
每个类控制自己的成员的被访问权限。
改变个别的访问权限:使用using。
使用using将直接、间接基类中的可访问成员标记出来,继承后的访问权限由所在位置访问说明符决定。
注意这个是可以提升访问权的。
class默认派生访问是private,struct是public
这俩个关键字的唯一区别就是默认成员访问说明符与默认派生访问说明符。
继承中的作用域
派生类的作用域嵌套在基类作用域之内,内访问外,访问符是单独控制。
解析名字:a.b
- 先在A类中查找名字b
- 在A的直接基类中查找名字b
- 继续向上。
作用域:编译时处理用,主要供编译器查找名称。
在编译时进行名字查找,对象、指针的静态类型决定对象的那些成员是可见的。只有可见的编译时才可访问使用,动态只是决定实际执行那个。
子的成员名将隐藏外层基类相同的名字。可以使用::访问
成员都不会被消除,只是各种形式的覆盖了。
重要!!!
函数调用的解析过程:p->mem();
- 确定p的静态类型,因为是调用成员,所以必须是类类型
- 在p的静态类型对应的类中查找mem,如果找不到就从直接基类中找,直至继承链的顶端。找到就下一步
- 找到men后进行类型检查,参数是否正确等,确定调用是否合法。
- 合法时,将根据是否是虚函数生成不同代码:虚函数且通过指针/引用使用,将运行时决定。不是时将产生常规函数调用。
注意名字优先于类型检查,此时内不会重载外部,在内中找到名字后经过类型检查不合适也不会再找了。
虚函数有重载时,可选择覆盖0或多个。使用using可以将同名的所有重载添加到作用域中。
构造函数与拷贝控制
析构函数的执行需要看对象的动态类型,因此应该是virtual的。因此总应该写上。子析构会继承父析构的虚属性
合成的构造、赋值、析构,会直接使用基类中对应的操作对基类部分进行初始化、赋值、析构。
自定义时:
默认下都是使用默认的构造函数初始化基类部分,因此必须显示使用拷贝/移动构造。
派生类的赋值也必须显示:
析构会隐式执行父的析构。顺序是派生类的析构先执行、之后是基类的。
当作用于构造函数时,using将产生代码:
子类名(参数):父类名(参数){}
这样来调用父构造。且不会改变访问级别。
容器与继承
容器中的类型不能是对象类型,因为会被切掉。通常放指针、智能指针。
注意 new A(b) 时会切掉b的部分,这是在运行拷贝构造。
注意:
这里要使用move的原因是,尽管a是右值引用,但是a本身是变量,是个左值,因此要使用move来传递。
这也是我当时反汇编出没有调用移动方法的原因。
反编译
|
|
ARM-64
简单对比:
特性 | ARM V8 | ARM V7 |
---|---|---|
指令集 | 64位指令集 AArch64, 并且兼容32位指令集 AArch32 | 32位指令集 A32 和16位指令集 T16 |
支持地址长度 | 64位 | 32位 |
通用寄存器 | 31个 x0-x30(64位)或者 w0-w30(32位) | 15个, r0-r14 (32位) |
异常模式 | 4层结构 EL0-EL3 | 2层结构vector table |
NEON | 默认支持 | 可选支持 |
LAPE | 默认支持 | 可选支持 |
Virtualization | 默认支持 | 可选支持 |
big.LITTLE | 支持 | 支持 |
TrustZone | 默认支持 | 默认支持 |
SIMD寄存器 | 32个 X 128位 | 32个 X 64位 |
ARM-v7A中常使用32位ARM指令集并且支持thumb指令集与arm的切换。
而在ARMV8中使用的是64位ARM指令集且不再有thumb指令集状态的切换了!
因此64位下的ARM是:
B:直接跳转
BL:连接返回地址并跳转
BR:直接跳转到寄存器指定
BLR:连接并跳转寄存器指定
32位下的ARM:
B:直接跳转
BL:连接跳转
BX:指令切换跳转
BLX:连接且指令切换跳转
详细的ARM64指令将再开一篇分析。
分析
main:
B的构造
A的构造
虚表在需要重定位的data只读段
俩个虚表:
子的f3
子析构
父析构:
对象内存模型:
单继承时由低到高依次:虚表指针、父成员、子成员。直接连续存的
虚表排列:
虚表内函数的顺序按类继承层次中首次声明的顺序,先父的。因此只要继承了父的虚,虚表前面就会和父的虚顺序一样,这样保证多态调用的肯定是对应的。
子类新定义的虚会在父的后面,纯虚函数使用出错函数占位置,即使有定义也不会写进去。
构造:
构造为先执行父的构造,再执行子的虚表指针赋值、初始化、函数体。在构造中都会赋予虚表指针各自类的虚表位置。
虚表指针为类的隐藏成员,位于对象的一开始。
注意ndk中调用纯虚函数的坑,即使定义了也不能使用。使用作用域静态使用可以。
析构:
先执行子的虚表指针覆盖、函数体、成员析构,最后执行父的析构。
函数调用:与p5所述函数查找过程一致。
指针调用虚函数时都被翻译为查找虚表,寄存器跳转。
指针使用普通函数、对象使用函数都是直接调用。
作用域强制调用虚与普都使用直接调用。
但是父中使用定义了的纯虚函数会有错误。在ndk下
一些解释:
对象通过虚表指针访问虚表(类的虚函数、一个类一张虚表)。
构造中和析构中都为虚表指针赋值,为的是在父的构造与析构中执行虚函数时执行的是自己的版本。
虚表在.data.rel.ro段。
ida使用一些方面:
引用的from是数据来源,表示修改这个位置的数据的代码 标记为w
to是数据去向,表示读取这个位置数据的代码 标记为r 或者引用地址o
这里ndk编译出的arm代码引用处和实际处差了一截,不过一样可以使用交叉引用查看获取虚表地址的代码,就是构造函数与析构函数了。
全局的析构还是在代理构造中用atexit注册的。
x86-64
…太多了,就看arm的吧