深度探索C++对象模型 笔记
https://book.douban.com/annotation/121895489/
深度探索C++对象模型 笔记
封装
-
对象持有自己的数据,等价于C语言的结构体,保持了访问速度。就是说,C++中,class对象就是struct,而不是像Java中的对象那样均是引用;C++的对象就是对象本身,不存在间接性,而引用另有语法表示。之所以能提升效率,在于struct能被分配在栈上,而栈帧的扩张要比动态申请内存要快得多。甚至,这也是in place new存在的原因:存在先于本质,不论对象空间被分配在堆上还是栈上,之后,均可以通过该重载的new操作符来刷新其中的数据。只不过,栈上的临时对象生命周期要短一些,频繁的构造与析构可能是一个问题;但本质上,单纯就对象封装特性来看,C++的Plain Old Data在效率上与C结构体是等价的,而因封装特性引入C++的仅是一些类似成员可访问性的微小差异。
-
对象还持有方法表指针,以提供一种基于方法表下标的形式统一的虚方法的获取方式。而且,如果一个类没有虚方法,其对象也不会有方法表指针,而类也没有方法表,是完全兼容C结构体的。所以,虚方法触发类的方法表及其对象的方法表指针的产生,为的必须是继承体系下函数调用的动态绑定能力,否则是完全没有意义的。
-
其他数据均是全局的。包括静态数据与方法、构造器,其中的函数对应Java中的static或special调用,均是编译期就被决议定的,而通过方法表里的方法调用对应Java的dynamic调用,是动态决议的。
继承
- 扩展类对象完全包含了基类对象的数据(大小);而扩展类的方法表包含了基类的方法表,且按顺序将基类的方法(指针)排在前面,使得虚方法调用有一个统一的按下标获取的方式,实现动态绑定的效果。万恶之源是多继承的出现,而虚拟继承这个大魔王则是为了解决多继承共基类的数据冗余而出现的。在介绍它们导致的麻烦之前,可以认为基类数据排在扩展对象的前面,而在扩展类的方法表上的虚方法中,对基类成员数据的引用也依然可以不加调整地像它在基类方法中那样基于一致的this指针的偏移量来实现。
p.s. 传递指针,即一个地址(类型的)值,不会改变对象,就像C中引入void类型的指针那样,指针类型的语义是静态的;但是,直接传递对象被保留为C语言那种结构体值拷贝的语义。所以,虚方法调用这种动态绑定的语义就只能用指针或引用(已判空,或者是说也不能判空,而是从诞生源头上就不为空的指针)来表达了。
-
在兼容C的情况下,C++的拷贝保持了结构体数据在函数栈上非间接引用的效率,但它既是直接寻址的,也是静态的;即,先是数据类型(大小)被静态地指定了,然后才有将一个扩展类对象直接拷贝到基类上的情况,与付出了间接访问代价的动态绑定特性的区别是,静态拷贝的代价是它仅仅保留了基类对象的大小,表现为扩展类对象看上去被截断了。而截断除了是数据上的,还是语义上的;即,对象包括数据和方法表指针两部分,类型静态大小的一致性是需要保持的,也不可能再截去方法表指针,而基类可能还有自己的基类,也会存在动态绑定的可能性,但显然无法再访问扩展类的数据成员,于是,在这种拷贝出现的时候,除了拷贝数据部分,还需要将方法表指针覆盖为基类方法表的地址;与数据部分可以自定义拷贝语义(默认浅拷贝,无移动语义)不同,方法表指针的填写是编译器的事情。p.s. 在一块能容纳扩展对象的基类对象空间上是可以执行扩展对象的in place构造的,但同时,方法表在大多数编译器实现中都被重写为了基类的方法表指针。
-
引入多继承与虚拟继承后,因为多继承中的第二继承对象的起始位置不再与单一继承相一致,所以,所涉及到的相关指针就需要调整了。首先是虚方法中涉及数据成员位置的this指针需要调整,由于扩展对象仍然可以与第一继承对象共享方法表指针,所以,可以仅将扩展对象中的第二及后续其他继承对象的this指针往后调整到对应基类对象的位置,而继承自第二即后续对象的虚方法将成为扩展类自己的方法;至于,第二及后续继承对象的虚拟方法表中,涉及到所有数据的方法,包括虚析构函数,将需要调整到扩展类对象的起始处。
-
同时,在动态地在多个继承对象类型之间切换时,可能需要用到方法表第0槽中的type_info信息,以将多继承产生的多个方法表区分开来。b.t.w. type_info原本是为了支持try catch中对动态抛出的异常类型的决议的,却也新引入了一个在动态绑定之外的动态决议的能力,就是凡是调用dynamic_cast的地方就会生成对应类型的type_info以支持动态类型切换;具体做的事情,关键就包括方法表指针的覆盖。p.s. 为了解决dynamic_cast转换引用类型失败会抛出异常的效率问题,可以用typeid替换它,本质上,它的判断也是基于type_info的。
-
最后,为了支持多继承中虚拟基类只有一份数据,可以为子类增加一个虚基类的指针,也可以扩展一下方法表,在反向的方法表槽中填充虚基类对象在扩展类对象中的偏移量,从而维持对象整体性。同时,为了使虚基类对象仅被构造一次,需要为所有扩展类的构造函数扩展一个参数以区别是否需要构造基类,并仅在最终扩展类中对虚基类对象进行构造。
构造
-
对象的数据成员是按照声明顺序初始化(区别于赋值,e.g. 初始化列表属于构造,但是构造函数体内就认为对象已经构造完成,能做的只有赋值了;其间的效率之差大概在于前者是在初始化函数栈的时候就顺便完成的)的;如果未初始化,但成员定义有默认构造函数(i.e. 无参数构造,不论是编译器合成,还是人为定义的),则数据成员的默认构造函数会被调用,该安插行为是编译器的工作。加上继承与方法表指针后,构造的顺序变成:基类(包括虚基类)构造,方法表指针赋值,然后扩展类中的成员按照声明的顺序进行初始化。
p.s. 概括起来,构造中,有四种需要编译器安插某些行为的场景,除了安插基类对象的默认构造、成员数据的默认构造,方法表指针的重填,以及虚基类对象指针的安排也需要编译器安插初始化操作,这就是C++因为引入继承、虚方法和虚继承所产生的一些关键成本。 -
对象传递的方式有两种:引用(包括指针)与拷贝。引用是拷贝构造函数的基础,即,拷贝构造函数只能以引用为入参类型,否则,就是一场死循环。拷贝构造又是函数的基石,即,拷贝构造是函数调用中非拷贝的参数与返回值的实现方式;且,注意是构造而非拷贝复制运算符。拷贝赋值运算符,虽然也只能返回this对象的引用,但它的入参,如果是引用类型的,就类似于拷贝构造,如果是非引用类型的,就相当于在入参处先执行了一次入参对象的拷贝构造,后者可以搭配swap函数实现原对象持有的资源以自动类型的形式在析构中释放,毕竟,拷贝构造的入参本质就是临时对象,而前者则需要先手动释放自己原来持有的资源,即先手动调用析构再加上拷贝构造的动作。
-
与const类似,在现代C++定义的移动语境中,右值引用是一个用于重载的类型标志,暗示函数可以由自己一方来持有入参对象的指向资源的指针,并将入参对象的资源指针置空,表现为将这样的入参对象所持有的资源窃取了;而move仅仅做了一次强制类型转换,即,加上了一个暗示可以窃取资源的标志,以触发移动语义优先于拷贝被匹配。有移动语义后,具名返回值NRV优化就没有必要了,后者本来就是为了解决拷贝构造在普通函数调用过程中所造成的效率问题的,而移动语义替代了拷贝。与作为基本能力在任何时候都是要合成的拷贝不同,在未自定义任何拷贝操作的情况下,且所有数据成员都是可以移动的,即,是基本类型,或者本身定义了移动行为,编译器才会合成移动操作;移动操作不可用时,对应的拷贝操作就会代替移动操作被调用。而只有自定义了拷贝构造(非拷贝赋值运算符),NRV优化才会被触发,因为,与合成拷贝中的memset相比,一个真实存在的拷贝构造函数调用显然会有不可避免地有损于执行效率,而NRV优化也可能导致具名局部对象的析构函数调用机制被连带抹掉倒是其次,本来反复构造析构临时对象就是要被避免的;可见,二者均是为了解决在函数调用过程中拷贝构造可能造成的重大效率问题,而移动的语义也的确更加清晰了。
-
应该完全避免在构造析构中调用虚方法,虽然,基本可以肯定先于成员数据初始化被赋值的方法表指针不会造成问题,但由于虚方法有可能访问未初始化的数据,就仍然存在不一致的可能,这与Java单例构造中,可能打破原子性和有序性的双重判空检查的失败是类似的。
其他
- 编译器会为代码的执行插入很多初始化与清理操作。除了临时对象外,全局对象在main函数的入口和出口的exit处进行构造与析构,且,C++中未初始化的全局对象不会像C那样放入BSS段,而仍然是当作初始化过的全局对象看待的。而在new失败的地方,在catch和re-throw的地方,均会插入一些析构临时对象的操作。
- 在Template中的名称模板决议法一节中,有一个有趣的例子,从中可以看出,模板中的方法是按照方法级别分模板定义与模板实例化两个阶段进行决议的,如果方法不涉及模板类型,决议就是在定义阶段,如果涉及到模板参数,就是在实例化的时候决议。并且,为了保证我们想要的方法被实例化,可以显式地指出全部按某个类型实例化或选择个别方法实例化。
- 关于成员指针:数据成员指针相当于基于this的偏移量,部分编译器为了与nullptr相区别而将偏移量加一。方法成员指针本身是有地址的,但必须提供this对象作为隐式绑定的第一个入参才能调用,方法指针与普通函数指针的差异仅此而已;因而,类作用域运算符可能会压抑动态语义,所以并不会出现link解答中所说的问题。b.t.w. 作用域运算符提供一种类似类这样的命名空间之外,最开始被引入的原因是为了让类外定义的构造函数与普通函数相区别,这是为了兼容C中函数可以不带返回值类型就默认为int的情况。
Ref: Lippman 1996
示例
例子:(空虚基类优化)字节对齐
#include <iostream>
using namespace std;
class Foo {
public:
Foo() {
cout << "Foo()" << endl;
}
Foo(int i) {
cout << "Foo(i)" << endl;
}
};
class Bar {
public:
Foo foo;
int i;
Bar() // default construction of foo
{
}
Bar(int ii): foo(ii) { i = ii; }
Bar(long l): foo() {}
Bar(double d) // default construction of foo
{
i = (int) d;
foo = Foo(i);
}
};
class X {
public:
X() {
cout << "X()" << endl;
}
};
class Y: public virtual X {
};
class Z: public virtual X {
};
class XYZ: public Y, public Z {
// default construction of base class X
};
class A {
public:
A() {
cout << "A()" << endl;
}
A(int ii, int jj): i(ii), j(jj) {}
// default copy constructor, bitwise
int getI() { return i; }
private:
int i;
int j;
};
int main() {
Bar bar;
cout << "----" << endl;
Bar barI(2);
cout << "----" << endl;
Bar barL(3L);
cout << "----" << endl;
Bar barD(4.0);
cout << "----" << endl;
cout << sizeof(X) << endl;
cout << sizeof(Y) << endl;
cout << sizeof(Z) << endl;
cout << sizeof(XYZ) << endl;
XYZ xyz;
cout << "----" << endl;
A a(1, 2);
A aa = a;
cout << aa.getI() << endl;
}
输出1:某些在线编译器
Foo()
----
Foo(i)
----
Foo()
----
Foo()
Foo(i)
----
1
8
8
16
X()
----
1
输出2:https://cpp.sh/
...
----
1
4
4
8
X()
----
1
例子:方法表指针的重写
#include <iostream>
class Base {
public:
int j;
virtual void f() { std::cout << "Base.f, j=" << j << std::endl; }
virtual ~Base() { std::cout << "~Base" << std::endl; }
};
class Devided: public Base {
public:
// int i;
Devided() { j = 2; }
virtual void f() { std::cout << "Devided.f, j=" << j << std::endl; }
virtual ~Devided() { std::cout << "~Devided" << std::endl; }
};
int main() {
Base b;
b.f();
b.~Base();
new (&b) Devided;
b.f();
}
输出:
Base.f, j=72736
~Base
Base.f, j=2
~Base
例子:virtual与type_info
#include <iostream>
#include <typeinfo>
class St {
char c; // 0
short s; // 2
public:
virtual ~St() {};
};
class StE : public St {};
int main()
{
std::cout << sizeof(St) << std::endl;
std::cout << sizeof(StE) << std::endl;
StE stE;
St *pSt = &stE;
std::cout << std::hex << pSt << std::endl;
// std::cout << std::hex << dynamic_cast<StE *>(pSt) << std::endl;
std::cout << std::endl;
St st;
const std::type_info &ti_St = typeid(St);
std::cout << ti_St.name() << (typeid(st) == ti_St) << std::endl;
const std::type_info &ti_StE = typeid(StE);
std::cout << ti_StE.name() << (typeid(stE) == ti_StE) << std::endl;
std::cout << std::endl;
std::cout << typeid(*pSt).name() << std::endl;
std::cout << std::endl;
std::cout << typeid(pSt).name() << std::endl;
std::cout << typeid(&stE).name() << std::endl;
std::cout << typeid(&st).name() << std::endl;
// std::cout << typeid(st) << std::endl;
// std::cout << typeid(StE) << std::endl;
// std::cout << typeid(st) << std::endl;
// std::cout << typeid(stE) << std::endl;
}
8
8
0x505458
2St1
3StE1
3StE
P2St
P3StE
P2St
图1 封装 https://img2023.cnblogs.com/blog/1382440/202303/1382440-20230318144339278-1721819877.jpg
图2 多继承&虚拟继承
多继承&虚拟继承 https://img2023.cnblogs.com/blog/1382440/202303/1382440-20230318143231142-99387741.jpg
多继承&虚拟继承type_info https://img2023.cnblogs.com/blog/1382440/202303/1382440-20230318143302516-1309577535.jpg