八叶一刀·无仞剑

万物流转,无中生有,有归于无

导航

C++对象模型简析

Posted on 2020-11-11 17:32  闪之剑圣  阅读(163)  评论(0编辑  收藏  举报

最近在看林锐的《高质量程序设计指南——C/C++语言》,在阅读的过程中重温了C++对象的内存分布,在这篇文章里予以总结。

非多态类的内存映像

先看看最基本的非多态类的内存分布,我们这里展示了一个Rectangle类:

class Rectangle {
public:
	Rectangle(): m_length(1), m_width(1) {}
	~Rectangle() {}
	float GetLength()const {return m_length;}
	void SetLength(float length) {m_length = length;}
	float GetWidth()const {return m_width;}
	void SetWidth(float width) { m_width = width;}
	void Draw() {...}
	static unsigned int GetCount() {return m_count;}
protected:
	Rectangle(const Rectangle& copy) {...}
	Rectangle& operator=(const Rectangle& assign) {...}
private:
	float m_length;
	float m_width;
	static unsigned int m_count;
};

我们知道,一段程序的内存大致分为代码段、堆栈和静态数据段等区域。这个Rectangle类的所有函数均存储在代码段中。m_count是一个静态变量,因此存储在静态数据段中。用户创建的Rectangle对象,包括它们的m_length,m_width等成员则存储在堆栈段中(书中称之为用户内存区)。
Rectangle的内存映像如下所示:

多态类的内存映像

我们假设Rectangle派生自抽象基类Shape,Shape有一个属性m_color,并且把Draw()移植到Shape中作为纯虚函数,代码如下所示:

class Shape {
public:
	Shape():m_color(0) {}
	virtual ~Shape() {}
	float GetColor()const {return m_color;}
	void SetColor(float color) {m_color = color;}
	virtual void Draw() = 0;
private:
	float m_color;
};

class Rectangle: public Shape {
	...
};

Rectangle作为派生类,它会继承基类的非静态成员,并作为自己对象的专用数据成员。同时,编译器会为每一个多态类创建一个虚函数指针数组vtable,该类的所有虚函数地址都保存在这张表里。
多态类的每一个对象中会有一个指针成员vptr,它是一个指向函数指针的指针,指向当前类型所属的vtable。一般来说,vptr会放置在所有数据成员的最前面。
为了支持RTTI,编译器会为每一个多态类创建一个type_info对象,记录该类的类型信息,并把其地址保存在vtable的固定位置(一般是第一个位置)。
按照上面的论述,Rectangle的内存映像如下图所示:

从上图可以看到,派生类的内存构造顺序是按照基类的继承顺序,统一新增在基类子对象的前面的,因此从一个派生对象入手,就可以直接访问到基类的数据成员(因为基类的数据成员被直接嵌入到了派生类对象中)。
访问虚函数的时候,因为要通过vptr间接寻址,因此增加了一层间接性,由此带来了一些额外的运行开销。

虚函数表的运行时访问实现

不同于调用普通的函数,如果一个虚函数被调用,那么我们要到运行时才能知道运行的到底是哪一个类的虚函数。具体是怎么实现这样的效果的呢?书中给出了一种猜测。
因为C++中的数组只能存储一种类型,而虚函数指针的类型是各种各样的,因此编译器定义了一个统一的函数指针类型作为vtable的元素类型:

typedef void (*PVFN) (void);
typedef struct{
type_info * _pTypeInfo;
PVFN        _arrayOfPvfn[];
};

所有的虚函数指针都会被强制转换成PVFN后存储在vtable中。
同时,记录下vtable中每一个元素中虚函数指针的实际类型:

typedef void (*PVFN_Draw)(void);
typedef void (*PVFN_~Shape)(void);

真正在调用虚函数时,从vtable中取出的类型只是PVFN,还需要做一个反向的强制转换:

(*(PVFN_Draw)(pShape->_vptr[2]))(pShape);   //pShape->Draw();
(*(PVFN_~Shape)(pShape->_vptr[1]))(pShape);   //delete pShape;

在这个过程中并没有直接在代码中调用Rectangle::Draw,因为pShape的静态类型虽然是Shape*,但是它实际上指向一个Rectangle对象,而该对象的vptr成员指向Rectangle::_vtable,这个vtable中存储的是Rectangle改写过的虚函数或新加的虚函数的地址,而不是Shape的虚函数的地址,因此实现了对Rectangle::Draw的调用。

对于vtable中虚函数指针的排列顺序,大致遵循的规律是:
1.一个虚函数如果再当前类中是第一次出现,则将其地址插入到该类的每一个vtable的底部
2.如果派生类改写了基类的虚函数,则这个函数的地址在派生类vtable中的位置与它在基类vtable中的位置一致,与它在派生类中声明的顺序无关
3.派生类没有改写的基类虚函数被继承下来并插入派生类的vtable中,因此派生类的vtable布局是和基类的兼容的

比如下面的代码,我们这么定义Shape和Rectangle类:

class Shape {
public:
	Shape():m_color(0) {}
	virtual ~Shape() {}
	float GetColor()const {return m_color;}
	void SetColor(float color) {m_color = color;}
	virtual void Draw() = 0;
private:
	float m_color;
};

class Rectangle {
public:
	Rectangle(): m_length(1), m_width(1) {}
	~Rectangle() {}
	virtual int ObliqueAngle() {return 90;}
	virtual void Draw() {...}
	virtual ~Rectangle() {}
	static unsigned int GetCount() {return m_count;}
protected:
	Rectangle(const Rectangle& copy) {...}
	Rectangle& operator=(const Rectangle& assign) {...}
private:
	float m_length;
	float m_width;
	static unsigned int m_count;
};

那么它们的虚函数表如下所示: