C++类对象内存模型是一个比较抓狂的问题,主要是C++特性太多了,所以必须建立一个清晰的分析层次。一般而言,讲到C++对象,都比较容易反应到以下这个图表:
这篇文章,就以这个表格作为分析和行文的策略的纵向指导;横向上,兼以考虑无继承、单继承、多重继承及虚拟继承四方面情况,这样一来,思维层次应该算是比较清晰了。
1、C++类数据成员的内存模型
1.1 无继承情况
实验最能说明问题了,首先考虑下面一个简单的程序1:
#include<iostream>
class memtest
{
public:
memtest(int _a, double _b) : a(_a), b(_b) {}
inline void print_addr(){
std::cout<<"Address of a and b is:/n/t/t"<<&a<<"/n/t/t" <<&b<<"/n";
}
inline void print_sta_mem(){
std::cout<<"Address of static member c is:/n/t/t"<<&c<<"/n";
}
private:
int a;
double b;
static int c;
};
int memtest::c = 8;
int main()
{
memtest m(1,1.0);
std::cout<<"Address of m is : /n/t/t"<< &m<<"/n";
m.print_addr();
m.print_sta_mem();
return 0;
}
在GCC4.4.5下编译,运行,结果如下:
可以发现以下几点:
1. 非静态数据成员a的存储地址就是从类的实例在内存中的地址中(本例中均为0xbfadfc64)开始的,之后的double b也紧随其后,在内存中连续存储;
2. 对于静态数据成员c,则出现在了一个很“莫名其妙”的地址0x804a028上,与类的实例的地址看上去那是八竿子打不着;
其实不做这个测试,关于C++数据成员存储的问题也都是C++ Programmer的常识,对于非静态数据成员,一般编译器都是按其在类中声明的顺序存储,而且数据成员的起始地址就是类得实例在内存中的起始地址,这个在上面的测试中已经很明显了。对非静态数据成员的读写,我们可以这样想,其实C++程序完全可以转换成对应的C程序来编写,有一些C++编译器编译C++程序时就是这样做的。对非静态数据成员的读写也可以借助这个等价的C程序来理解。考虑下面代码段2:
// C++ code
struct foo{
public:
int get_data() const{ return data; }
void set_data(int _data){ data = _data;}
private:
int data;
};
foo f();
int d = f.get_data();
如果要你用C你会怎么实现呢?
// C code
struct foo{
int data;
};
int get_foo_data(const foo* pFoo){ return pFoo->data;}
void set_foo_data(foo* pFoo, int _data){ pFoo->data = _data;}
foo f;
f.data = 8;
foo* pF = &f;
int d = get_foo_data(pF);
在C程序中,我们要实现同样的功能,必须是要往函数的参数列表中压入一个指针作为实参。实际上C++在处理非静态数据成员的时候也是这样的,C++必须借助一个直接的或暗喻的实例指针来读写这些数据,这个指针,就是大名鼎鼎的 this指针。有了this指针,当我们要读写某个数据时,就可以借助一个简单的指针运算,即this指针的地址加上该数据成员的偏移量,就可以实现读写了。这个偏移量由C++编译器为我们计算出来。
对于静态数据成员,如果在static_mem.cpp中加入下面一条语句:
std::cout<<”Size of class memtest is : ”<<sizeof(memtest)<<”/n”;
我们得到的输出是:12。也就是说,class的大小仅仅是一个int 和一个double所占用的内存之和。这很简单,也很明显,静态数据成员没有存储在类实例的地址空间中,它被C++编译器弄到外面去了也就是程序的data segment中,因为静态数据成员不在类的实例当中,所以也就不需要this指针的帮忙了。
1.2 单继承与多重继承的情况
由于我们还没有讨论类函数成员的情况,尤其,虚函数,在这一部分我们不考虑继承中的多态问题,也就是说,这里的父类没有虚函数——虽然这在实际中几乎就是禁手。如此,我们的讨论简洁很多了。
在C++继承模型中,一个子类的内存模型可以看成就是父类的各数据成员与自己新添加的数据成员的总和。请看下面的程序段3。
class father
{
public:
// constructors destructor
// access functions
// operations
protected:
int age;
char sex;
std::string phone_number;
};
class child : public father
{
public:
// ...
protected:
std::string twitter_url; // 儿子时髦,有推号
};
这里sizeof(father)和sizeof(child)分别是12和16(GCC 4.4.5)。先看sizeof(father)吧,int占4 bytes,char占1byte,std::string再占4 bytes,系统再将char圆整到4的倍数个字节,所以一共就是12 bytes了,对于child类,由于它仅仅引入了一个std::string,所以在12的基础上加上std::string的4字节就是16字节了。
在单继承不考虑多态的情况下,数据成员的布局是很简单的。用一个图来说明,如下。
假设有下面三个类,如下面的程序段4所示,继承结构关系如图:
class A{
public:
// ...
protected:
int a;
double b;
};
class B{
public:
// ...
protected:
char c;
};
class C : public A, public B
public:
// ...
protected:
float f;
};
那么,对应的内存布局就是图4所示。
1.3 虚继承
多重继承的一个语意上的副作用就是它必须支持某种形式的共享子对象继承,所谓共享,其实就是环形继承链问题。最经典的例子就是标准库本身的iostream继承族。
class ios{...};
class istream : public ios {...};
class ostream : public ios {...};
class iostream : public istream, public ostream {...};
无论是istream还是ostream都含有一个ios类型的子对象。然而在iostream的对象布局中,我们只需要一个这样的ios子对象就可以了,由此,新语法虚拟继承就引入了。
虚拟继承中,关于对象的数据成员内存布局问题有多种策略,在Inside the C++ Object Model中提出了三种流行的策略,而且Lippman写此书的时候距今天已经很遥远了,现代编译器到底如何实现我也讲不太清楚,等哪天去翻翻GCC的实现手册再论,今天先前一笔债在这。
2、C++类函数成员的内存模型
2.1 关于C++指针类型
要理解好C++类的函数成员的内存模型,尤其是虚函数的实现机制,一定要对指针的概念非常清晰,指针是绝对的利器,无论是编写代码还是研究内部各种机制的实现机理,这是由计算机体系结构决定的。先给一段代码,标记为代码段5:
class foo{
//...
};
int a(1);
double b(2.0);
foo f = foo();
int* pa = &a;
double* pb = &b;
foo* pf = &f;
我们知道,int指针的内容是一个 表征int数据结构 的地址,foo指针的内容就是一个 表征foo数据结构 的地址。那么,系统是如何分别对待这些看上去就是0101的地址的呢?同样是一个 1000110100...10100,我怎么知道这个地址就一个int 数据结构的地址呢?它NN的拼什么就不是一个foo 数据结构的地址呢?我只有知道了它是int,我才知道应该取出从1000110100...10100开始的4个byte,对不对?
所以我就想——强调一下,我也只是在猜想——一定是指针的数据类型(比如int*,还是foo*?)里面保存了相关的信息,这些信息告诉系统,我要的是一个int,你给我取连续的4个byte出来;我要的是一个foo结构,你给我取XX个连续的byte出来…
简单地说,指针类型中包含了一个类似于 sizeof 的信息,或者其他的辅助信息——至少我们先这么来理解,至于系统到底怎么实现的,那是《编译原理》上艰深的理论和GCC浩繁的代码里黑客们的神迹了。这个sizeof的信息就告诉了系统你应该拿几个(连续)地址上的字节返回给我。例如,int* pInt的值为0xbfadfc64,那么系统根据int*这个指针的类型,就知道应该把从0xbfadfc64到0xbfadfc68的这一段内存上的数据取出来返回。
回到C++的话题上,假设下面的代码段6,其实就是前面代码段3,为了阅读的方便copy过来一下。
class father
{
public:
// constructors destructor
// access functions
// operations
protected:
int age;
char sex;
std::string phone_number;
};
class child : public father
{
public:
// ...
protected:
std::string twitter_url; // 儿子时髦,有推号
};
现在我进行下面的调用:
child c();
father* pF = &c;
child* pC = &c;
std::string tu;
tu = pF->twitter_url;// 这个调用是非法的,原因我们后面说,暂且将这一行标记为(*)
tu = pC->twitter_url;
if(child* pC1 = dynamic_cast<child*>(pF))
tu = pC1->twitter_url;
对于(*)行,其实原因就是我们前面所说的,指针类型中包含了一个类似于sizeof 的信息,或者其他的辅助信息,对比图5,我们可以这样子想,一个father类型object嵌套在了一个child类型的object里面,因为指针类型有一个sizeof的信息,这个信息决定了一个pF类型的指针只能取到12个连续字节的内容,(*)试图访问到这个12个字节之外的内容,当然也就要报错了。
我得说明一句,这样子想只是一种理解上的自由(而且我认为这样理解,从结论和效果上讲是靠谱的),到底是不是这样子,我还并没有调查清楚。
这里,我们先调查了一下指针访问类的数据成员,还没有涉及到函数成员,但其实这才是本部分的核心内容。OK,马不停蹄趁热打铁,接下来我们就说这个故事。
2.2 静态函数成员
如果取一个静态函数成员的地址,获得的就是其在内存中的地址,由于它们没有this指针,所以其地址类型并不是一个指向类成员函数的特别的指针。
也由于没有了this指针这一本质特点,静态函数成员有了以下的语法特点:
l 它不能直接读写class内的非静态成员,无论是数据成员还是函数成员;
l 它不能声明为const或是virtual;
l 它不是由类的实例来调用的,而是类作用域界定符;
这里,我想起了《大学》上一段话:物有本末,事有终始,知所先后,则近道矣”,这话太TMD妙了,凡事入乎其内,外面的什么东西都是浮云,就像《越狱》里的Micheal看到一面墙就想得到里面的钢筋螺丝,这时候这面墙已经不是一面墙了。如果只是生硬地去记忆上面那些东西,那是何其痛苦的事情,也几乎不可能,但是一旦“入乎其内”了,这些东西就真的很简单了。
静态函数成员的特点赋予了它一些有趣的应用场合,比如它可以成为一个回调函数,MFC大量应用了这一点;它也可以成功地应用线程函数身上。
2.3 非静态函数成员
还是可以回到代码段3,其实这个代码段已经给出了非静态成员函数的实现机制。
1. 改写非静态成员函数的函数原型,压入一个额外的this指针到成员函数的参数列表中,目的就是提供一个访问类的实例的非静态数据/函数成员的渠道;
2. 将每一个对非静态数据/函数成员的读写操作改为经由this指针来读写;
3. 最惊讶的一步是,将成员函数改写为一个外部函数——Gotcha!这就是为什么sizeof(Class)的时候不会将非虚函数地址指针计算进去的原因,因为(非静态)成员函数都被搬到类的外面去了,并借助Name Mangling算法将函数名转化为一个全局唯一的名字。
对于第3点,有一个明显的好处就是,对类成员函数的调用就和一般的函数调用几乎没任何开销上的差异,几乎从C++投胎开始,效率就成为了C++的极致追求之一。