赞
踩
参考资料:《深度探索C++对象模型》
C++实现了类和对象,带来了语法上繁琐的规则和陷阱。但如果了解了它的原理,你会发现所谓类和对象其实就是在C语言上的一层包装,其内部实现并没有什么神秘,很多语法规则结合实现原理就会显得清楚明白。
实际上C++的语法复杂度主要是来源于各种实体之间的复杂关系,包括:
在编程语言中,对两个实体建立关系,概括来说有两种方法:
在C++中,子类及成员类的关联使用了第一种方法,而带“虚”的(虚函数、虚继承)使用了第二种方法。
下面我们就由浅入深的具体看一下.
最简单的类的内存排布实际和C语言的结构体没有什么不一样:
class Naive
{
public:
int member_1;
int member_2;
};
int main()
{
Naive a; // 实例化,在内存中构造一个对象
a.member_2 = 1; // 访问对象成员
return 0;
}
当我们实例化Naive
类的对象时,其在内存中存储如下:
只是在内存中依次放置两个成员而已,成员int member_1
占据了前4个字节,int member_2
占据了接下来的4个字节。对象的地址就是所有成员最开始的地址,也即member_1
的地址。当使用a.member_2 = 1
访问对象成员时,编译器自动的将此改写为对一个偏移地址的访问,相当于*(&a + 4) = 1
总之,这里和C语言的struct
完全相同。为了简单起见, 本文不考虑为对齐插入的内存,对齐规则和C语言也是完全相同的。
如下例,当包含了成员函数时,对象在内存的排布又如何呢?
class Base
{
public:
int member_1;
int member_2;
void func1(); // 成员函数
};
实际上内存排布和没有函数时没有任何区别。非虚成员函数如func1
并不会存在对象的内存中,其和C语言中普通的函数一样,都存在代码区,类的所有对象都共用这一个函数。
为体现出函数属于某个类,编译器会对该函数进行改名(name mangling),将类名信息加到函数名中。比如func1就可以改为func1_4BaseFv
,其中4代表后面的部分有4个字符也即类名Base
。然后是一个字符F
做分隔,再后面是函数参数类型,如v
代表void,f
代表float。需注意这种改名方式只是一个示例,各编译器的实现会有不同(比如gcc里就是_ZN4Base5func1Ev
)。
当调用成员函数时,编译器会根据类型信息改写调用方式:
Base b;
b.func1(); // 编译器将其转为 func1_4BaseFv(&b);
这里看到,编译器除了会转换函数名外,还会将对象的地址传入函数中。这是因为编译器会为每个成员函数都加一个参数用于传this指针。这也是为什么在成员函数中可以使用this->xx
的格式访问成员。当然this->
也可以省略,编译器会自动补上。
本例中this
指针指向对象b
,故使用this指针访问的也就是对象b
的成员了。
总结来说,非虚成员函数实际与普通的全局函数没有本质不同,仅仅是通过改名做了一定区分。编译器根据对象类型来找到对应函数的实际名称并调用,反正类型信息在编译时就明确了。
注意到除了对象类型外,函数参数类型也体现在了函数名中,实际上这就是C++实现函数重载的方式,根据参数类型不同调用不同名字的函数。所以实际所有的C++函数都经过了改名,若要兼容调用C的函数必须使用extern "C"
告诉编译器调用未改名的版本。
对于非虚函数,编译器直接根据静态的对象类型信息就可以确定实际调用的是哪个函数,而对于虚函数则只能在运行时根据指针指向的实际类型才能知道调用哪个函数。为此,可以将函数以指针的形式存储在对象中,见下例:
class Base
{
public:
int member_1;
int member_2;
void func1();
virtual void func2(float arg1); // 增加了一个虚函数
};
如图,当类中有虚函数时,编译时会为该类生成一个虚函数表vtable
存储在常量区(只读数据区),其中的表项为函数指针。编译器会在类对象中增加一个虚表指针vptr
成员,并在对象构造时令其指向本类的虚函数表。该类各对象共用同一个虚函数表。调用与虚函数时只需要从虚函数表中取出函数地址调用即可。
使用这种机制,编译器不会再根据类名Base
来确定具体函数,只要vptr
指针指向了正确的虚函数表,不管是使用基类指针还是继承类指针访问,都会调用到正确的虚函数。在后面讲继承时,我们会再进一步分析。
注意到虚函数表的第一项为RTTI(Run-Time Type Identification),它显式的存储了该类的类型信息,可以调用typeid获取相关信息,在使用dynamic_cast及异常机制时可以用其做类型判断。此处不做展开。
在调用虚函数时,编译器会做如下自动转换:
Base *p_b = new Base();
p_b->func2(1.0); // 编译器将其转为 (*p_b->vptr[1])(p_b, 1.0);
其中由于vptr[0]存了RTTI,故func2的指针存在vptr[1]的位置,从这里取出函数指针后对其解引用并调用。和非虚函数一样,会隐式加一个this指针参数并将对象地址传进去。
下例增加了继承和成员对象:
class Base { public: int member_1; int member_2; void func1(); virtual void func2(float arg1); }; class Memb { int member_4; int member_5; }; class Derive: public Base // 继承 { public: int member_3; Memb member_obj; // 成员对象 virtual void func2(float arg1) override; virtual void func3(float arg1); };
其内存排布如下:
成员类Memb
只是把其内存放置到外部类Derive
的内存中作为一部分而已。
子类Derive
的内存实际就是在其父类的后面再添加上自己的内容,需要注意的是编译器在构造Derive
对象时会将vptr
指向Derive
类的vtable
而不是基类Base
的,该vtable
中存储的是Derive
的虚函数,包括重写父类的虚函数,也包括此类新添加的虚函数(如func3
)
继承并不会添加新的vptr项,而是复用父类的vptr。但如果父类没有虚函数(即没有vptr)则会在该有虚函数的子类后面加vptr项。
注意到的基类Base
和子类Derive
的初始地址相同,这也是为什么可以使用基类指针指向子类。
Derive d;
Base *p_b = &d;
p_b->func2(1.0); // 编译器将其转为 (*p_b->vptr[1])(p_b);
此例中将Derive对象d
赋值给Base
类指针p_b
,编译器使用该指针时就当作其是一个Base
对象,只能访问Base的成员。这没关系,因为Derive
对象的内存中前面本来就是一个完整Base
对象,各种偏移也和真正的Base
对象保持一样,因此这样访问是没问题的。只是无法访问Derive特有的成员而已。(注意到这里只适用于单继承,多继承时编译器还需要调整指针位置来保证此特性)
虽然编译器是很无脑的看到Base
指针就认为是Base
对象,但这里却可以实现多态特性,即调用虚函数时会调用Derive
类的版本。前面说到在构造Derive
对象时,会将其vptr
指向Derive
的vtable
,现在虽然改为使用Base指针来访问了,但是其vptr
依然存的时Derive的vtable的地址。当调用func2时,编译器还是无脑的转换成(*p_b->vptr[1])(p_b)
,这里从vptr便会取出Derive的虚函数版本。
这就是C++多态的实现原理。归根结底是虽然改变了指针的类型为基类指针,改变了对这块内存的解释方式,但是并没有改变这块内存的内容,而因此vptr的得以保留其子类的vtable地址进而调用子类的函数。
C++子类可以选择重写或者不重写父类的虚函数,这在vtable中很容易实现,如果重写,就让vtable中的表项指向子类的函数,否则就依然指向父类。
多重继承时,编译器会在内存依次放置各个基类。如下例:
class Derive: public Base1, public Base2
在内存中的排布即:
这里带来的问题是Derive
的起始地址和其父类Base2
的起始地址不同了,因此将Derive
对象地址赋值给Base2
指针时编译器会进行地址的调整,否则就会用Base2
的方式去访问Base1
的内存造成错误了。
Derive d;
Base2 *p_b = &d; // 此处编译器会偏移地址跳过Base1
cout << &d; // 输出:0xffffcbc0
cout << p_b; // 输出:0xffffcbd0
注意父类Base1
和Base2
的内存谁在前谁在后没有统一的规范,不同编译器可能有不一样的实现。
在出现菱形继承关系时,会导致存储多余的父类:
class Base1 : public CommonBase
class Base2 : public CommonBase
class Derive: public Base1, public Base2
Derive的内存中包含Base1和Base2,而这两者都包含CommonBase,使得存储了两份CommonBase,不仅浪费空间,还会造成歧义。
C++对此的解决方案就是虚继承,其和虚函数基本是相同的思想,引入一个指针来指向目标。
具体的实现方式各编译器不同,包括:
总之是引入指针间接的访问基类,代替原来的根据固定的偏移访问基类。这样就允许虚基类改变和其子类的相对位置,只要把虚基类地址信息写到指针中即可。也可以允许多个子类的该指针指向同一个虚基类内存实现共享。
在上一节我们了解了一个类的对象需要安置其父类成员、虚函数指针、成员对象等关联实体。但是在我们编程时并没有考虑这些。实际上这些相关工作都被编译器在构造函数和析构函数中隐式的替我们完成了。
下例解释了对象构造时编译器为我们做的事情:
class Derive: public Base, virtual public VBase { public: int member_5{1}; int member_6; Memb member_obj; virtual void func2(float arg1) override{}; virtual void func3(float arg1){}; Derive(/*编译器插入 Derive* this */) { // 在用户代码前,编译器会插入如下内容: // 调用 VBase::VBase() 构建虚基类部分 // 调用 Base::Base() 构建基类部分 // 设置 vptr 指向本类的vtable // 初始化 member_5; // 初始化 member_6; (由于int不需要初始化,会被省略) // 调用 Memb::Memb() 构建member_obj cout << member_5 << endl; // 用户代码,编译器将member_5扩展为this->member_5 // 编译器插入 return this; } };
在构造函数中,编译器会在用户代码之前依次插入:
以上工作做完之后才开始真正执行用户写的构造函数代码。
需要注意的是vptr的设置位置,其在成员的构造之前,因为这样才能保证成员构造时以及用户代码中调用的虚函数是本类的版本。同时其在各种基类的构造之后,这样才能保证基类构造时使用基类自己的虚函数版本,基类是在其自己的构造函数中设置的vptr的。所以说这个vptr在构造过程中经过了多次的改变,从指向基类的vtable一直沿继承链向下到最终派生类的vtable。
不过编译器也会进行一定的优化,当基类的构造函数没有调用虚函数时,会省略vptr的设置。
下面画一个单继承的简单例子:
另外即使用户不定义构造函数,编译器也会合成一个构造函数做上述事情。
注意到上述代码只有在需要时才插入,比如int
成员member_6
没有默认的构造函数此处就是没有操作。如果一个类所有这些代码都不需要插入,且用户没有显示定义构造函数,就说该类构造函数时trivial的,编译器一般不会真正的生成和调用该构造函数。这些代码都不需要插入的情况即:
拷贝构造函数与此类似,除了:
C++编译器通过插入代码节省了用户的工作量,但是也带来了如下问题:
关于问题1,一个例子就是用户在构造函数调memset将this指向的整个对象清零,这会导致vptr也被清零造成虚函数访问错误。
关于问题2,由于代码都是自动插入的,用户无法看到自然无法修改。对此只能再加一些语法补丁来弥补了。对于构造函数,这个补丁就是初始化列表。
如果不用初始化列表,直接在构造函数的函数体内进行相关成员的初始化工作,比如member_obj= Memb(10);
,实际上会造成重复工作,一次是编译器自己插入的无参数初始化,一次是函数体内写的赋值操作。而初始化列表就是允许我们调整编译器插入的初始化过程以避免重复。
下面是一个例子
class Derive: public Base // 假设Base包含一个单参数构造函数 { public: int member_5; int member_6; Memb member_obj; // 假设此成员类包含一个单参数构造函数 virtual void func2(float arg1) override{}; virtual void func3(float arg1){}; Derive() :member_6(1), member_5(member_6), // 陷阱出现,未定义值 Base(10), member_obj(20) { // 此时编译器根据初始化列表插入的代码如下 // 调用 Base::Base(10) 构建基类部分 // 设置 vptr 指向本类的vtable // 初始化 member_5为member_6的值,此时member_6未初始化即内存随机值 // 初始化 member_6为1; // 调用 Memb::Memb(20) 构建member_obj member_obj = Memb(10); // 用户代码,此处相当于构造之后又进行赋值 }
从例子中可以看到,初始化的顺序并不会因为加入了初始化列表而改变,初始化列表的顺序并不是真正的初始化顺序。
这里便造成了一个陷阱,如本例中如果按照初始化列表来看,好像是member_6初始化为1之后再用来初始化member_5,但实际上是member_5先初始化被赋予了未定义的值。
像这种因为操作被隐藏而不得不再增加语法补丁调整的例子还有const成员函数,实际上它就是给被隐藏的this指针加了个const:
class Base
{
void func(float arg1);
// 相当于 void func(Base *this, float arg1);
void func(float arg1) const;
// 相当于 void func(const Base *this, float arg1);
};
当你知道了这个原理自然知道了为什么const函数不能修改成员(this是常量指针),也自然知道了什么时候调用const版本的成员函数(使用const对象调用时)。否则又要背一条奇怪的语法。
编译器也会在析构函数中插入代码以调用相关类的析构函数,如下例:
class Derive: public Base, public virtual VBase { public: int member_5; int member_6; Memb member_obj; virtual void func2(float arg1) override{}; virtual void func3(float arg1){}; Derive(/*编译器插入 Derive* this */) { // 在用户代码前,编译器会插入如下内容: // 调用 VBase::VBase() 构建虚基类部分 // 调用 Base::Base() 构建基类部分 // 设置 vptr 指向本类的vtable // 初始化 member_5; // 初始化 member_6; // 调用 Memb::Memb() 构建member_obj cout << member_5 << endl; // 用户代码,编译器将member_5扩展为this->member_5 // 编译器插入 return this; } ~Derive(/*编译器插入 Derive* this */) { // 在用户代码前,编译器会插入如下内容: // 设置vptr使其指向本类vtable (此操作主要对于基类有用) member_5 = 0; // 在用户代码后,编译器会插入如下内容: // 调用Memb::~Memb() 析构member_obj // 析构 member_6 (因为是int所以省略) // 析构 member_5 (因为是int所以省略) // 调用Base::~Base() 析构基类 // 调用VBase::~VBase() 析构虚基类 } };
对比来说,析构函数基本和就是在做构造函数的反向操作,且执行顺序也基本相反,除了vptr的设置此时放在函数最开始。具体罗列如下:
通过在析构函数的开始设置vptr,确保了继承链上的每个类的析构函数中都使用自己的虚函数。和构造函数一样,当没必要设置vptr时编译器会省略该步骤。
class A
{
public:
int n1;
int n2;
A() : n2(1), n1(n2)
{
cout << n1 << "," << n2 << endl;
}
};
class Base { public: Base() {func();} virtual void func() {cout << "base" << endl;} }; class Derive :public Base { public: Derive() {func();} virtual void func() {cout << "derive" << endl;} }; int main() { Derive derived; return 0; }
class Empty { };
Empty a, b;
if (&a == &b) cerr << " should not be equal" << endl;
从这些面试题也可以看出,如果不懂C++对象原理,C++的语法有很多陷阱。
经过本文的剖析,是否觉得C++也不再那么神秘了?实际上很多事情都是C语言的套壳,也因此实际上C语言也可以实现类似的的机制,像linux源码里就是这样。借助C语言的struct
和函数指针可以实现类似对象的数据结构。通过修改函数指针指向就可以重写成员函数。通过把Base结构体作为Derive结构体的第一个成员也可以模仿继承关系。C++通过编译器来完成了类似的事情,减少了程序员的重复工作。
C++实际并未对语言底层实现做详细的规范,因此各编译器会有差异,比如vptr的位置、元素存储顺序、name mangling方式、RTTI内容、多重继承时基类排布顺序等。不过大同小异,思想类似。
通过了解底层原理,也可以看到C++相比于传统C语言存在性能代价的地方,包括:
回到我们开篇提到的,编程语言中要建立两个实体之间的联系,可以通过固定的地址偏移(非虚继承、成员对象),也可以通过指针(虚函数、虚继承)。这之中地址偏移没什么神奇的,C语言中的结构体也是这样的。而通过指针这种方法则是C++多态机制的基石,尤其是虚函数机制。
更扩展来说,这种引入指针实际上是增加了间接性。而正是间接性带来了灵活度。原本是直接访问函数,现在是先访问指针(及虚函数表)然后再找到函数。当然间接性的引入也要付出性能下降的代价,因为必须要先经过间接的部分。原本直接调用函数即可,现在需要额外访问指针。
实际上引用间接性这种手段在计算机技术中屡见不鲜。比如:
域名系统(DNS):原本通过IP就可以直接访问服务器,现在引入间接性先访问域名,再获得IP访问。这样带来的灵活性包括:可以一个域名对应多个服务器IP实现负载均衡、可以多个域名对应一个服务器IP实现复用、可以起一个容易记忆的域名或者修改域名而不影响IP。
编译器中间表示(IR):原本直接将编程语言编译成特定硬件的机器码,现在引入间接性先编译成IR,再转成机器码。这样带来的灵活性包括:可以通过复用IR大幅减少开发工作量(见下图)、可以选择更好的数据格式便于编译处理。
虚拟地址:原本计算机可以直接访问内存物理地址,现在引入间接性先访问虚拟地址再翻译成物理地址。这样带来的灵活性包括:可以一个多个虚拟地址对应同一个物理地址实现内存复用。可以隔离具体的物理内存安排,保证程序每次处理固定的虚拟地址。
可以看到间接性的好处主要就是实现灵活的对应关系(一对多、多对一等)以及隔离变化。
实际上,我认为增加间接性这种技术可以算作是计算机领域的“根技术”之一,可以和模块化(分解)、抽象(分层)、缓存、通用-专用平衡等技术齐名了。
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
赞
踩
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。