C++对象模型
引言
C++对象模型描述了C++如何在内存中布局和操作对象,它是编译器将高级面向对象概念映射到底层机器实现的机制。
在C语言中,"数据"和"处理数据的操作"是分开来声明的,也就是说,语言本身并没有支持“数据和函数”之间的关联性。
我们把这种程序方法称为程序性的,由一组“分布在各个以功能为导向的函数中”的算法所驱动,它们处理的是共同的外部数据。而在 C++ 中,则有可能用独立的"抽象数据类型"来实现。
typedef struct point3d
{
float x;
float y;
float z;
} Point3d;
void Point3d_print(const Point3d *pd)
{
printf("(%g,%g,%g)",pd->x,pd->y,pd->z);
}
关于对象
很明显,不只在程序风格上有截然的不同,在程序的思考上也有明显的差异。细致些显示在C++中实现比在C中复杂,尤其后续可能是在使用template的情况下。
从软件工程的眼光来看,一个抽象数据类型或类层次结构的数据封装比在C程序中程序性地使用全局数据好。但是这往往被那些被要求快速让一个应用程序上马应战,并且执行起来又快又有效率的程序员所忽略。毕竟C的吸引力就在于它的精瘦和简易(相对于C++ 而言)。
class Point
{
public:
Point(float x = 0.0,float y = 0.0,floatz = 0.0):_x(x),_y(y),_z(z){}
float x(){return_x;}
floaty(){return_y;}
float z(){return_z;}
void x(float xval){_x = xval;}
private:
float_x;
float y;
float_z;
};
程序员看到Point
转换到C++之后,第一个可能会问的问题就是:加上了封装之后,布局成本增加了多少?
答案是Point
类并没有增加成本。三个数据成员直接内含在每一个类对象之中,就像C的struct的情况一样,而成员函数虽然含在类的声明之内,却不出现在对象之中。每一个非内联的成员函数只会诞生一个函数实体.至于每一个"拥有零个或一个定义"的内联函数则会在其每一个使用者(模块)身上产生一个函数实体。
Point3d
支持封装性质,这一点并未带给它任何空间或执行期的不良回应。即可看到,C++ 在布局以及存取时间上主要的额外负担是由virtual
引起,包括:
virtual function
机制用以支持一个有效率的"执行期绑定"。virtual base class
用以实现“多次出现在继承体系中的基类,有一个单一而被共享的实体”。此外,还有一些多重继承下的额外负担,发生在"一个派生类和其第二或后继之基类的转换"之间,然而,一般言之,并没有什么天生的理由说C++程序一定比其C兄弟庞大或迟缓。
概述
有两个概念可以解释C++对象模型:
- 语言中直接支持面向对象程序设计的部分:直接支持面向对象程序设计,包括了构造函数、析构函数、多态、虚函数等等,这些内容在很多书籍上都有讨论,也是C++最被人熟知的地方(特性)。
- 对于各种支持的底层实现机制:对象模型的底层实现机制却是很少有书籍讨论的。对象模型的底层实现机制并未标准化,不同的编译器有一定的自由来设计对象模型的实现细节。在我看来,对象模型研究的是对象在存储上的空间与时间上的优化,并对C++面向对象技术加以支持,如以虚指针、虚表机制支持多态特性。
class Point {
public:
Point(float xval);
virtual ~Point();
float x() const;
static int PointCount();
protected:
virtual ostream& print(ostream &os) const;
float _x;
static int _point_count;
};
// 静态成员初始化
int Point::_point_count = 0;
在 C++ 中,有两种类数据成员:静态数据成员和非静态数据成员,以及三种类成员函数:静态成员函数、非静态成员函数和虚函数。
则图示这个Point类在机器中将会被怎么样表现呢?也就是说,我们如何模塑出各种数据成员和函数成员呢?
对象模型类别
简单对象模型
简单对象模型十分简单粗暴,它可能是为了尽量减低C++编译器的设计复杂度而开发出来的,赔上的则是空间和执行期的效率。
在这个简单模型中,一个对象是一系列的slots,每一个slot指向一个成员。成员按其声明次序,各被指定一个slot,每一个数据成员或函数成员都有自己的一个slot。也即是说,每个数据成员和成员函数在类中所占的大小是相同的,都为一个指针的大小。图示可以说明这种模型。
也因此,在这个简单模型中,数据成员和成员函数本身不放在对象之中,只有指向他们的指针才放在对象内,可以避免成员因为存在不同的类型,需要不同的存储空间所招致的问题,对象中的成员可以靠slot的索引值来寻址。
想象一下,图示是我们的Point3d
类的这种模型,将会比C语言的struct
多了许多空间来存放指向函数的指针,而且每次读取类的数据成员,都需要通过再一次寻址——又是时间上的消耗。所以这种对象模型并没有被用于实际产品上,不过关于索引或者slot数目的观念倒是被应用到c++的指向成员的指针观念之中了。
表格驱动对象模型
为了对所有类的所有对象都有一致的表达方式,表格驱动对象对象模型是把所有与成员相关的信息抽出来,放在一个数据成员表和一个函数成员表里面,类对象本身则内含指向这两个表格的指针,成员函数表是一系列的slots,每一个slot指出一个成员函数;而数据成员表则直接含有数据本身。也就是,对象的大小是固定的两个指针的大小。
这样看来,这个模型在简单对象模型的基础上又添加一个间接层,也没有用于实际应用于真正的C++编译器上。
图示了C++对象模型的表格驱动对象模型如何应用于前面所说的Point
类。虽然这个模型也没有实际应用于真正的C++编译器身上,但成员函数表这个观念却成为支持虚函数的一个有效方案。
相比于其他的对象模型,表格驱动对象模型提供了较大的弹性,因为它多提供了一层间接性,如果应用程序代码本身未曾改变,但所用到的类对象的非静态数据成员有所修改(可能是增加、移除或者更改),那么那些应用程序代码无需得重新编译,不过也由于间接性导致了空间和存取时间上的额外负担,因此付出了空间和执行效率两方面的代价。
非继承下的C++对象模型
C++对象模型是从简单对象模型派生而来的,并对内存空间和存取时间做了优化。
在这个模型模型里面,非静态数据成员被放置于每一个类对象中,而静态数据成员则被存在所有的类对象之外。静态和非静态函数也都被放在所有的类对象之外;而对于virtual
函数,则通过虚函数表(vbtl
)+虚函数表指针(vptr
)来进行支持。
这个模型的主要优点在于它的空间和存取时间的效率;主要缺点则是,如果应用程序代码本身未曾改变,但所用到的类对象的非静态数据成员有所修改(可能是增加、移除或更改)那么那些应用程序代码便得重新编译,关于这点,可以类比前述的表格驱动对象模型。
在此模型下,我们的Point类的对象模型如图:
继承下的C++对象模型
一个派生类如何在本质上模塑其基类的实体呢?
在C++对象模型中,对于一般继承(这个一般是相对于虚拟继承而言),若子类重写了父类的虚函数,则子类虚函数将覆盖虚函数表中对应的父类虚函数位置(注意子类与父类拥有各自的一个虚函数表);若子类并无重写父类虚函数,而是声明了自己新的虚函数,则该虚函数地址将扩充到虚函数表最后。
而对于虚继承,编译器将为子类对象在固定位置增加一个新的基类表指针bptr,与基类的大小或数目无关,它会被初始化,指向其基类表,表格中的每一个 slot 内含一个相关的基类地址,其优点实现在每一个类对象中对于继承都有一致的表现方式,不需要改变类对象本身,就可以放大、缩小、或更改基类表;而缺点是由于间接性而导致的空间和存取时间上的额外负担,这与一般的继承不同。
class istream : virtual public ios {...};
class ostream : virtual public ios {...};
class iostream:public istream, public ostream
{/...};
在虚继承的情况下,基类不管在继承串链中被派生多少次,永远只会存在一个实体(称为 subobject).如图示 iostream 之中就只有 virtual ios base class 的一个实体。
虚函数表的实现原理
虚函数则会以两个步骤支持之:
- 虚函数表(vbtl):每一个类产生出一堆指向虚函数的指针,放在表格之中.这个表格被称为虚函数表(
vbtl
)。 - 虚函数表指针(vptr):每一个类对象被添加了一个指针,指向相关的虚函数表。通常这个指针被称为
vptr
。vptr
的设定和重置都由每一个类的构造函数、析构函数和拷贝复制运算符自动完成。每一个类所关联的type_info object
(用以支持运行时类型识别,RTTI)也经由虚函数表被指出来,通常是放在表格的第一个 slot处,RTTI
是为多态而生成的信息,包括对象继承关系,对象本身的描述等,只有具有虚函数的对象才会生成。
对象和虚表的内存布局
不同的编译器在内存布局的细节上可能有所不同,为了方便测试,统一使用vs2017的x64编译器。照应前面讲述的C++对象模型,依次介绍无继承有虚函数,单继承,多重继承,菱形继承,虚继承下的对象和虚表的内存布局。
无继承有虚函数
可以看到类Qiro_A
大小为16字节,虚表指针占8字节,变量a占8字节。虚表指针vfptr
指向虚表的第一个虚函数指针,也就是析构函数。通过vfptr
的偏移可以访问不同的虚函数指针。
- 虚表的第一项是
type_info
,即RTTI
指针,指向运行时类型信息,用于运行时类型识别,用于typeid
和dynamic_cast
。 - 虚表第二项是
offset_to_top
,表示该类虚表指针距离顶部地址的偏移量,这里是0,只有存在多重继承才不为0
class Qiro_A{
public:
Qiro_A(){ cout <<"Qiro_A::Qiro_A()"<<endl;}
virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<<< endl;}
virtual void f(){ cout <<"Qiro_A::f()"<<< endl;}
virtual void g(){cout <<"Qiro_A::g()"<<<endl;}
int a = 1;
};
对象和虚表的内存布局
单继承
- 对象布局:先是父类的成员,然后是子类的成员。
- 虚表布局:当子类重写虚函数时,父类的虚函数指针替换为子类的虚函数指针。
class Qiro A {
public:
Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<< endl;}
virtual void f(){ cout << "Qiro_A::f()"<<< endl;}
virtual void g(){cout <<"Qiro_A::g()"<<<endl;}
intptr_t a = 1;
};
class Qiro_B : public Qiro_A {
public:
Qiro_B(){cout<<"Qiro_B::Qiro_B()"<<endl;}
virtual mQiro_B(){cout <<"Qiro_B::~Qiro_B()"<<< endl;}
virtual void f() override { cout << "Qiro_B::f()"<< end1;}
virtual void h(){cout <<"Qiro_B::h()"<<< endl;}
intptr_tb=2;
};
可以看到Qiro_B的虚表中,f函数被覆盖,g函数继承下来,h是新的虚函数。
多重继承
- 对象布局:先是父类
Qiro_A
的成员,然后是Qiro_B
的成员,最后是子类Qiro_C
的成员。 - 虚表布局:继承2个父类所以子类有2个虚表,
Qiro_C
和Qiro_A
共享虚表,对象有2个虚表指针。
可以看到子类Qiro_C
的f()
和析构函数覆盖了Qiro_A
,&Qiro_A::g
和&Qiro_B::g
直接继承下来,Qiro_C
新增的 &Qiro_C::k
追加到Qiro_A
虚表的末尾。Qiro_C
和Qiro_A
共享第一个虚表。
多重继承注意事项:
this指针调整。
Qiro_B* qr2 = new Qiro_C;delete qr2;
Qiro_C
对象赋值给Qiro_B
指针时,this指针需要向后调整sizeof(Qiro_A)
,这样才能调用Qiro_B
的成员。
delete pb2
时this指针要向前调整sizeof(Qiro_A)
,这样才能调用Derived
的析构函数。
类似的可以留意到第二个虚表的offset_to_top
是-16,即虚表指针向后调整16是对象起始地址。
菱形继承
从右侧的对象布局可以发现:Qiro_A
的数据成员a有两份。因为二义性我们也不能直接通过d.a访问a。
菱形继承存在问题:
- 公共父类的数据存在两份拷贝造成浪费的同时公共基类会构造和析构两次。
- 存在二义性不能直接访问公共父类的数据和函数,需要通过类名::访问。
解决方法:使用虚继承
class Qiro_A{
public:
Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
virtual ~Qiro_A() { cout << "Qiro_A::~Qiro_A()" << endl;}
virtual void f(){ cout <<"Qiro_A::f()"<<< endl;}
intptr_t a = 1;
};
classQiro B : public Qiro_A {
public:
Qiro_B(){ cout<<"Qiro_B::Qiro_B()"<<endl;}
virtual ~Qiro_B(){cout <<"Qiro_B::~Qiro_B()"<<endl{;}
virtual void g(){cout <<"Qiro_B::g()"<<< endl;}
intptr_tb=2;
};
classQiro_C : public Qiro_A {
public:
Qiro_C(){cout<<"Qiro_C::Qiro_C()"<<endl;}
virtual ~Qiro_C(){cout <<"Qiro_C::~Qiro_C()"<< endl;}
virtual void h(){cout <<"Qiro_C::h()"<<<endl;}
intptr_t C = 3;
};
classQiro_D:publicQiro_B,publicQiro_C{
public:
Qiro_D(){cout<<"Qiro_D::Qiro_D()"<<endl;}
virtual ~Qiro_D(){cout << "Qiro_D::~Qiro_D()"<<< endl;}
void f() override{cout << "Qiro_D::f()"<<<endl;}
virtual void k(){cout <<"Qiro_D::k()"<<<endl;}
intptr_t d = 4;
};
int main(){
Qiro_D d;
d.a=2;//Qiro_D::a不明确
return
}
菱形继承+虚继承
虚继承可以解决菱形继承的问题,菱形继承改为虚继承后,a只有一份拷贝。
- 对象布局:两个直接基类多了
vbptr
,即虚基类指针。vbptr指向虚基类表vbtable
。接着是子类成员,然后是4字节的vtordisp
,为了保持8字节对齐,前面保留4字节。最后才是虚基类的成员。 - 虚表布局:
Qiro_D
和Qiro_B
共享第一个虚表虚基类表:虚基类表的第一项是类首地址相对vbptr的偏移量,这里都是-8。虚基类表的第二项是虚基类的虚表指针相对vbptr的偏移量,即64-32=32,64-8=56。
虚拟继承中派生类重写了基类的虚函数,并且在构造函数或者析构函数中使用指向基类的指针调用了该函数,编译器会为虚基类添加vtordisp
域。
class Qiro_A{
public:
Qiro_A(){cout<<"Qiro_A::Qiro_A()"<<endl;}
virtual ~Qiro_A(){cout <<"Qiro_A::~Qiro_A()"<<<endl;}
virtual void f(){ cout << "Qiro_A::f()"<<< endl;}
intptr_t a = 1;
};
class Qiro_B : virtual public Qiro_A{
public:
Qiro_B(){cout<<"Qiro_B::Qiro_B()"<<endl;}
virtual mQiro_B(){cout <<"Qiro_B::~Qiro_B()"<<<endl;}
virtual void g(){cout <<"Qiro_B::g()"<<< endl;}
intptr_tb=2;
};
class Qiro_C : virtual public Qiro_A{
public:
virtual public Qiro_A{
Qiro_C(){ cout <<"Qiro_C::Qiro_C()"<<endl;}
virtual mQiro_C(){cout <<"Qiro_C::~Qiro_C()"<<<endl;}
virtual void h(){cout <<"Qiro_C::h()"<<< endl;}
intptr_tc=3;
};
classQiro_D:publicQiro_B,publicQiro_C{
public:
Qiro_D(){ cout <<"Qiro_D::Qiro_D()"<<<endl;}
virtual ~Qiro_D(){cout <<"Qiro_D::~Qiro_D()"<<<endl;}
void f() override{cout <<"Qiro_D::f()"<<<endl;}
virtual void k(){cout << "Qiro_D::k()"<<< endl;}
intptr_t d = 4;
};
构造函数的建构过程
默认构造函数
C++Annorated Reference Manual
(ARM,C++参考手册)提出:"默认构造函数 ...在需要的时候被编译器产生出来",关键字眼是"在需要的时候"。被谁需要?做什么事情?看看下面这段程序代码:
在以下例子中,正确的程序语意是要求Foo类有一个默认构造函数,可以将它的两个成员初始化为0。下面这段代码可曾符合ARM所说的“在需要的时候"?答案是 no,其间的差别在于一个是程序的需要,一个是编译器的需要。程序如果有需要,那是程序员的责任;本例要承担责任的是设计Foo类的人,因此,上述程序片段并不会合成出一个默认构造函数。
提问,什么时候才会合成出一个默认构造函数呢?当编译器需要它的时候!此外,被合成出来的构造函数只执行编译器所需的行动,也就是说,即使有需要为Foo类合成一个默认构造函数,那个构造函数也不会将两个数据成员val和pnext初始化为0。为了让上一段码正确执行,Foo类的设计者必须提供一个明显的默认构造函数,将两个成员适当地初始化。
对于 class X
,如果没有任何用户声明的构造函数,那么会有一个默认构造函数被暗中(implicitly)声明出来......一个被暗中声明出来的默认构造函数将是一个 trivial(浅薄而无能,没啥用的)构造函数......
在C++中存在有四种情况会生成不平凡、有用的(nontrivial)默认构造函数
- 带有默认构造函数的类对象成员
- 带有默认构造函数的基类
- 带有虚函数的类
- 带有虚基类的类
带有默认构造函数的类对象成员
如果一个类没有任何构造函数,但它内含一个成员对象,而后者有默认构造函数,那么这个类的隐式默认构造函数(implicit default constructor) 就是不平凡的(nontrivial),编译器需要为此类合成出一个默认构造函数。不过这个合成操作只有在构造函数真正需要被调用时才会发生。
于是出现了一个有趣的问题:在C++各个不同的编译模块中,编译器如何避免合成出多个默认构造函数呢?解决方法是把合成的默认构造函数、拷贝构造函数、析构函数、赋值运算符操作都以内联(inline) 方式完成。一个 inline 函数有静态链接,不会被模块以外者看到。
如果函数太复杂,不适合做成 inline,就会合成出一个显示非内联的实体(explicit non-inline static)。被合成的Bar的默认构造函数内含必要的代码,能够调用Foo类的默认构造函数来处理成员对象Bar::foo
,但它并不产生任何码来初始化Bar:str
。是的,将Bar:foo
初始化是编译器的责任,将Bar:cstr
初始化则是程序员的责任。被合成的默认构造函数:inline Bar::Bar(){ foo.Foo::Foo();
/C++伪码/}。
假设程序员经由下面的默认构造函数提供了str的初始化操作:Bar::Bar(){str=0;}
则程序的需求满足了,但是编译器还得初始化成员对象foo。 由于默认构造函数已经被明确地定义出来,编译器没办法合成第二个.那编译器会采取什么行动呢?
编译器的行动是: "如果class A
内含一个或一个以上的成员类对象,那么class A的每一个 构造函数必须调用每一个成员类的默认构造函数"。编译器会扩展已存在的构造函数,在其中安插一些码,使得用户代码在被执行之前,先调用必要的默认构造函数。沿续前一个例子,扩张后的构造函数可能像这样:Bar::Bar(){foo.Foo::Foo(); str=0;}
。
C++ 语言要求以"成员对象在class中的声明次序"来调用各个构造函数,由编译器完成。
class Foo {
public:
Foo(){};
Foo(int){};
};
class Bar {
public:
Foofoo:
char* str: //注意,不是继承,是组合
};
void foo_bar(){
Bar bar://Bar::foo必须在此初始化
//Bar::foo是一个成员对象,而其class Foo
//有默认构造函数
if(bar.str){
std: cout << bar.str <<std::endl;
};
带有默认构造函数的基类
如果一个没有任何构造函数的类派生自一个“带有默认构造函数”的基类,那么这个派生类的默认构造函数将被视为非凡的(nontrivial),并因此需要被合成出来。
- 它将调用上一层基类(base classes)的默认构造函数(根据它们的声明次序)。
- 对一个后继派生的类而言,这个合成的构造函数和一个被明确提供的默认构造函数没有什么差异。如果设计者提供多个构造函数,但是其中没有默认构造函数呢?
- 编译器会扩张现有的每一个构造函数,将"用以调用所有必要的默认构造函数"的程序代码加进去。
- 它不会合成一个新的默认构造函数,这是因为其他由用户所提供的构造函数存在的缘故。
- 如果同时存在着带有默认构造函数的成员类对象,那么默认构造函数也会被调用–在所有基类构造函数都被调用之后。
带有虚函数的类
带有虚函数的类另外有两种情况,也需要合成出默认构造函数:
- 类声明(或者继承)一个虚函数
- 类派生自一个继承串链,其中有一个或者更多的虚基类。不管哪一种情况,由于缺乏有用户声明的构造函数,编译器会详细记录合成一个默认构造函数的必要信息。
class Widget{
public:
virtual void flip()=0:
}:
class Bell:public Widget{
public:
virtual void flip(){}
}:
class Whistle:public Vidget{
public:
virtual void flip(){}
}:
void flip(Widget&widget){widget.flip();};
void foo(){
Bell b:
Whistle w;
flip(b):
flip(w):
};
下面两个扩张操作会在编译期间发生:
- 一个虚函数表(vbtl)会被编译器产生出来,内放类的虚函数地址。
- 在每一个类对象中,一个额外的指针成员(也就是 vptr)会被编译器合成出来,内含相关的类vtbl的地址。此外,widget.flip()的虚拟引发操作(virtual invocation)会被重新改写,以使用 widget 的 vptr 和 vtbl 中的 flip() 条目:
//
widget.flip()
的虚拟引发操作(virtual invocation)的转变(\*widget.vptr\[1] )( \&widget )
其中:
- 1 表示 flip()在 virtual table 中的固定索引。
- &widget 代表要交给“被调用的某个 flip() 函数实体”的 this 指针。
为了让这个机制发挥功效,编译器必须为每一个widget(或其派生类)对象的vptr设定初值,放置适当的虚函数表地址。
对于类所定义的每一个构造函数,编译器会安插一些代码来做这样的事。
对于那些未声明任何构造的类,编译器会为它们合成一个默认构造函数,以便正确的初始化每一个类对象的vptr。
带有虚基类的类
虚基类的实现在不同的编译器之间有极大的差异,但是,每一个实现的共同点在于必须使虚基类在其每一个派生类对象中的位置,能够于执行期准备妥当。
编译器无法固定住foo()之中"经由pa而存取的X::i"的实际偏移位置,因为pa的真正类型可以改变。编译器必须改变"执行存取操作"的那些代码,使X:i 可以延迟至执行期才决定下来。
编译器的做法是靠“在 派生类对象的每一个虚基类中安插一个指针"完成。所有"经由引用或指针来存取一个虚基类"的操作都可以通过相关指针完成.在我的例子中,foo()可以被改写如下,以符合这样的实现策略:
void foo(const A \* pa) {pa->\_vbcX->x = 1024;};
其中_vbcX表示由编译器产生的指针,指向虚基类X。
_vbcX
(或编译器所做出的某个什么东西)是在类对象建构期间被完成的。对于class 所定义的每一个构造函数,编译器会安插那些“允许每一个 虚基类的执行期存取操作”的代码.如果class没有声明任何构造函数,编译器必须为它合成一个默认构造函数。
class X{public:inti;};
class A:public virtualX{public:intj:};
class B : public virtual X {public: double d;};
class C:public A,public B {public:intk;};
//无法在编译实际决定出pa->X:i的位置
void foo(const A* pa){pa->i = 1024;}
int main(){
foo(new A):
foo(new C):
return 0;
};
C++ 新手一般有两个常见的误解:
- 任何class如果没有定义默认构造函数,就会被合成出一个来。
- 编译器合成出来的默认构造函数会明确设定"class内每一个数据成员的默认值”。
总结没有存在上述四种情况而又没有声明任何构造函数的类当中,我们说它们拥有的是隐式无用默认构造函数 (implicit trivial default constructors),它们实际上并不会被合成出来。