读书笔记(十四) 《深度探索C++对象模型》

看此书的起因是自己想更加深刻的了解底层,最底层的莫不过于汇编了,但只有汇编还不够,因为它需要上层工具语言编译器的支持,编译器怎么去编译程序的,决定了汇编的执行方向,理论上说汇编只是执行编译器编译后的程序内容,因此我认为编译器是关键所在。

我们大多数人自认为自己已经把面向对象语言已经烂熟于心了,其实台面下的机制,如编译器合成的默认构造函数、对象内存布局等都不是很了解。我也是一样,我对编译器一块知识一直不是清晰,所以想通过这本书来了解我们平时编程时的底层的工作原理是什么。

作者说第一句话就打动了我,让我认真看完了全书,他说“我的经验告诉我,如果一个程序员了解底层实现模型,他就能够写出效率较高的代码,自信心也比较高。一个人不应该用猜的方式,或者等待某位大师的宣判,才确定何时提供一个copy constructor 而何时不需要。这类问题的解答应该来自于我们自身对对象模型的了解。“

不管我们是在使用C#、Java、C++,它们都是面相对象的编程语言,因此底层的原理都会有些相似性,特别是内存布局上。我也是抱着这种用C++内存布局去理解其他语言的心态去学习和研究这部分内容的。

对象的内存布局

一个普通的class,有成员变量、成员函数,静态变量,静态函数,关于它们我们可以话一张图,拿一个简单的Point来举例:

	class Point

	成员变量 float x

	成员函数 int PointCount()

	静态变量 static int sMaxCount

	静态函数 static int GetMaxCount()

假如我们实例化一个Point,即 Point * pt = new Point,Point的内存中为:

	float x

没错,内存中只有一个浮点数变量的空间大小,那么其他的包括成员函数、静态变量、静态函数都不在Point实例内存中,它们在哪呢?

它们被编译器编写在了代码段和数据段中,可以被所有Point实例共享的内存使用。

我们知道一个可执行程序的内存分布,分为数据段、代码段、栈段,这三个基本都是静态不会被改变的空间段,其他还有中断表、堆内存空间等。

class的成员函数、静态函数无论是否public还是private修饰,都会被放入代码段中,静态变量则被放入数据段中无论是否public还是private(放入数据段中的内容,每次取值都会做远距离寻址,相对近距离寻址会费一些,因为隔断了内存连续操作),而成员变量例如Point中的x,无论它是public还是private都会被放入动态内存分配的内存块中。

实际上计算机内存中、以及机器码中没有public和private之分,我们可以任意的取得任何内存中的内容没有限制,限制我们程序访问的,只是语言和编译器的语法检查器这两者为我们提供的语言方法和规则检查。

当class有继承和多态后则有所不同

当class有了继承后,通常都会有多态出现,即虚函数。让我们来举个例子说明:

class Point
{
public:
	float x;
	virtual int PointCount();
}

class Point2D : public Point
{
public:
	float y;
	virtual int PointCount();
	virtual void PrintPoint();
}

Point pt = new Point2D();

加上继承和多态后,内存布局就有所变化,由于基类和子类都有虚函数,子类重载了函数后,我们在内存中就需要有一张表来存放所有虚函数,以便正确调用。

如上Point和Point2D的关系,可以将一个Point2D实例内存看作如下结构:

	- virtual table 虚表地址 -------->   Point2D的虚表空间
										- PointCount()地址
										- PrintPoint()地址
	- x
	- y

和普通class不同的是,当我们有了多态后,我们的一个实例内存中除了数据外,还多了一个变量指向这个类的虚表空间,这个虚表空间已经被编译器编写在了数据段中,是一个静态的并且专门为Point2D制定的空间,这个空间中存放着指向类中所有虚函数地址,空间不会随着多个实例化而增多因为它只有一个,但每个Point2D实例的虚表指针指向着它(Point实例也是类似的内存布局)。

(这里不得不讲一下struct和class的区别,其实它在不同语言中的语义和用途不太一样,在C++中可能大部分struct都是用来兼容c或当纯数据的内存结构的,很多class有的功能struct也同样能实现,但在其他语言则不同,比如在C#中的用途就是期望struct能更多的做些内存连续优化,因为它是复制类型数据结构,每次赋值和传递参数都会复制一份内存,除非使用引用关键字。)

此时如果是Point实例则是

	- virtual table 虚表地址 -------->   Point的虚表空间
										- PointCount()地址
	- x

与Point2D相比,少了一个y变量,以及虚表空间中少了一个函数地址。

(编译器编译中很多时候都会将许多我们看来是动态的访问变为静态,例如子类的强制转换(其实内存上没有做任何操作,只是通知编译器后面的操作是基于某个类开始的,使得编译器在编译时偏移地址较前面有所不同)、前面Point中的静态函数(程序在执行时就会直接跳到代码段的GetMaxCount这个函数的地址上而不会通过实例内存去找)、Templete会在编译期就将代码和指令生成完毕,以及一些编译器对代码的优化会直接将某个公式计算好以数字的形式呈现在机器码里,还有很多排错机制,其实编译器帮我们干了很多本该我们需要检查和手动指定的工作。)

多重继承的内存布局更为复杂些,会带有好几个虚表地址在内存中,效率也更差,因为每一层的虚表都会间接性降低把处理搬到寄存器执行的优化,我们也不鼓励多重继承的写法,因此这里不做详细讲解。

实例化后的类大小真的只有变量大小的总和,加一个虚标指针吗?还有内存对齐的规则。

在32位计算机上,由于寄存器是32位、总线有32条、一次取内容为32位,因此每次取值时都会取得一个4字节的内存内容,编译器也会遵循32位字节对齐的方式去编译。

例如:

class A
{
	int a; //4字节
	bool b; //1字节
	float c; //4字节
	double d; //8字节
	char e; //1字节
}
	[a 4字节]
	[b 1字节][填充3字节]
	[c 4字节]
	[d 8字节]
	[e 1字节][填充3字节]

32位计算机中,此对象的内存空间为 4 +(1+3) + 4 + 8 + (1+3) = 24个字节。

而在64位计算机中,以64位内存对齐的规则时:

	[a 4字节][b 1字节][填充3字节]
	[c 4字节][填充4字节]
	[d 8字节]
	[e 1字节][填充7字节]

此对象实例的内存占用空间为 (4+1+3) + (4+4) + 8 + (1+7) = 32字节,在取a、b变量时可以一次取得,节省一次内存调度。

对象在内存上对齐的越紧凑,能节省的内存调度次数就会越少,程序运行的性能也会因此提高。

inline 内联

除了对齐内存,inline也能让编译器优化函数,让函数执行更快。那么它是怎么优化的呢?

一般而言,处理一个inline函数有两个阶段:

1.分析函数,以决定函数是否具备inline能力。

如果函数因其复杂度,或因其建构问题,被判断不可成为inline,它会被转为一个static函数,并在“被编译模块”内产生对应的函数定义。

2.真正的inline函数在调用时展开操作,省去函数调用导致的推栈和入栈寄存器的操作,也一并优化了函数中的计算内容(更少的内存存取次数和更快更少的计算次数)。

这也导致我们通常给予inline后并不清楚编译器是否真正将其视为inline去优化,只有我们进入汇编中才能看到是否真的实现了inline。

inline具体会优化哪些方面呢?我们来举几个例子

minval = min(val1, val2); //1
minval = min(1024, 2048); //2
minval = min(foo(), bar() +1); //3

如果min是一个inline内联函数:

第一行会被改为 minval = val1 < val2 ? val1 : val2; 省去了函数调用。

第二行会被改为 minval = 1024; 编译器直接离线计算好结果用常数代替函数调用。

第三行会被改为

int t1 = foo();
int t2 = bar() + 1;
minval = t1 < t2 ? t1 : t2;

增加了临时性的变量,从而替代内联的函数调用。inline函数中的局部变量加上inline自己增加的局部变量,在展开后可能会导致大量临时性的变量产生。

inline函数对于封装提供了一种必要的支持,可以有效存取装于class中的nonpublic数据。它同时也是C程序中大量使用#define宏处理的一个安全代替品,但如果inline函数被调用太多次的话,会产生大量的扩展代码,使得程序集本身的大小暴涨。

Template 模板

C++ 中的Template 模板,在许多语言里也称为泛型。自从1991年加入到cfront 3.0之后深深改变了C++编程习惯。它被使用在编译期做些评估和生成代码的工作,也因而带来了重大的效率提升,同时也成为了程序员一个噩梦以及最挫败的主题。

那么当我们声明了一个 template class、或者 template function时究竟会发生什么呢?

其实什么都不会发生,如果我们不使用它的话,编译器就会忽略它什么都不干。有也只有当我们使用定义的template做事时,编译器才开始工作,我说的使用是指在我们在代码中使用了前面声明的 template class 或者 template function,而不光是定义。

如果我们使用了一个指针,指向特定的实例,像这样:

template<class T>
class Point
{
public:
	void Print();
private:
	T x,y,z;
}

Point<float> * ptr = 0;

编译器依然什么都不回做,因为一个指向class object的指针本身并不是一个class object,编译器不需要知道与该class有关的任何成员数据或对象布局数据,它只是一个指针,至于指向什么并不重要。

然而如果带template的class被实例化时,则编译器才真正开始为template产生代码,例如我们定义了一个实体而非指针:

Point<float> origin;

此时origin会被实例化,编译器也检查到了这种情况,则会启动template代码生成器为Point生成一个类,如下面代码生成格式:

class Point_float
{
private:
	float x,y,z;
}

然而在编译器生成template代码类时,也并不会将所有template定义的类中的代码都生成出来,编译器只会在代码中生成被使用的函数或者可能使用的函数。例如上述中的Point_float就没有生成Print函数,因为我们并没有使用它。

这样做的主要原因是,为空间和时间上的效率考虑,如果我们的template class中有100个函数,使用中的类型有10个,我们只使用了其中5个函数,那么原本会生成是100 * 10 = 1000个函数,现在编译器只会生成 5 * 10 = 50 个函数,其余的950个函数代码讲会被忽略。这大大节省了空间提升了编译效率。

那么编译器是如何生成template代码的呢?

首先要发现template使用情况,在.h和.cpp文件中寻找template使用情况,如果有使用则继续生成,否则忽略。

其次编译器尝试模拟链接操作,检查看看哪一个函数真正需要,将真正需要的生成的函数提取出来,位它们生成具体的函数代码。

最后编译器要阻止template function在多个.o文件中被生成出来,它会从链接器提供的支持中获取信息,只留下一份代码,其余的都将忽略。

RTTI 执行期类型识别

执行期类型识别(Runtime Type Identification)最初是由于支持异常处理(Exception Handling)而产生的,可以说它是异常处理的副产品,后来被大量的使用在执行期代码中。

C++被吹毛求疵的一点就是,它缺乏一个保证安全的向下转换操作,只有在类型真的可以被转换的情况下,你才能够执行转换。想要实现安全的转换,则需要额外的类信息支持,于是就有了类信息(type information)。

type_info是C++标准所定义的类型描述器的class名称,该class中放置着待索求的类型信息。虚表空间中的第一个空格就是指向type_info信息的地址。

因此想要有RTTI功能,则class必须继承type_info类,即

class fct: public type_info {...};
class gen: public type_info {...};

class Point: public type_info {...};
class Point2D: public Point {...};

- Point 继承 type_info后 Point2D 虚表变化情况
- virtual table 虚表地址 -------->   Point2D的虚表空间
									- type_info ptr 类信息地址
									- PointCount()地址
									- PrintPoint()地址
- x
- y

Point2D和Point的​虚表头,都是type_info的信息地址。这样看来,每次我们使用指针取得class object类型描述器时,其实就是通过虚表指针去取得类信息地址,即如下:

((type_info)(pt->vptr[0]))->name(); //从虚表指针中的第一个槽位中取得类信息

type_info object在C++标准下的定义为:

class type_info
{
public:
	virtual ~type_info();
	bool operator==(const type_info&) const;
	bool operator!=(const type_info&) const;
	bool before(const type_info&) consnt;
	const char* name() const;
	const char* raw_name() const;//返回类名称的编码字符串
private:
    void *_m_data;
    char _m_d_name[1];
	type_info(const type_info&);
	type_info& operator=(const type_info&);
}

有了类信息,我们在做向下转换时,就可以用根据类信息来判断是否可以转换,即如下代码:

pfct pf = dynamic_cast<pfct>(pt);

相当于:

pfct pf = NULL;
type_info type_ptcf = typeid(ptcf);
type_info type_pt = typeid(pt);
if(type_ptcf == type_pt || type_pt.before(&type_ptcf))
{
	pf = pt;
}

总结,class内存分布由成员变量+虚表指针组成,布局大小会根据计算机的内存对齐方式不同而不同。inline内联函数并不一样会真的内联,其优化会节省函数调用开始和计算次数开销。Template模板会根据template class使用情况生成代码,生成步骤是找到使用情况、生成代码、去除重复。想要有RTTI执行期类型识别,则必须有额外的信息空间支持,在C++中我们必须继承type_info才能获得RTTI的功能,或者我们自己写一个执行期类型识别功能来替代标准C++,有了执行期类型识别功能,我们在向下转换时才更加安全。

· 读书笔记

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    读书笔记(十四) 《深度探索C++对象模型》

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)