题记:对于一种事物的认知,我们似乎习惯于首先了解它的大小、形状,也许这能给予我们对这种事物最为直观的感知。
接触计算机、接触一门编程语言,似乎我们首先接触的会是数据类型,而其中的基本数据类型由于其简单易学的特性以及其独特的规划数据内存分配的功能,成为编程初学者所学习的重中之重(之后的博客会讲解一下数据类型的魅力,这里不是本文的重点)。比如我们熟知的int、float、double...在学习它们的时候不同的书籍、不同的老师几乎都会提及一个概念:数据类型所占的内存大小,也会不约而同的使用一个关键字sizeof。
何为sizeof?sizeof关键字有何作用?微软的著名技术文档MSDN有云:sizeof关键字提供了与变量或类型(包括复合类型)有关的在字节上的存储量(内存分配量)。使用sizeof操作符可以得到一个对象或是一种类型的大小。我们常用的也都是:
struct Test
{
int i;
int j;
}
void main()
{
int i;
Test test;
sizeof(i); sizeof(int); //32位操作系统中值为4
sizeof(test);sizeof(Test); //32位操作系统中值为8
}
你也会说这很正常,int类型占4字节,两个int型当然就是8字节。那么看下面的例子:
struct Test
{
int i;
char c;
}
那么此时Test会占多大的内存呢?你也许会回答一个int型占4字节,一个char占1字节,那Test占5个字节。但不幸的是编译器向操作系统申请给Test分配了8字节的内存,这是为什么呢?
事实上,不同架构的CPU为了提高访问内存的速度要求不同的数据类型只能按照相应的规则在内存空间中存放,而不能一个接一个的顺序排列(典型的空间换时间的例子)。这就引出了结构体对齐问题,而要想了解结构体对齐问题首先要知道两个概念:基本数据类型的自身对齐值与结构体自身对齐值,基本数据类型的自身对齐值为它自身对象的大小,比如说char型数据自身对齐值为1,int型自身对齐值为4;结构体自身对齐值是其成员中对齐值最大的那个。而结构体对齐相应的对齐规则是:
1、结构体中各成员的起始地址%成员的自身对齐值 = 0,不足位置填零补位。
2、结构体最终的长度大小%结构体自身对齐值 = 0,不足位置填零补位。
此处的起始地址可以认为是结构体各成员相对于结构体起始位置的偏移地址。上面的例子int i分配4字节,由于i的相对地址0x00000 % 4 = 0满足条件不用填零补位,char c分配1字节,由于c的相对地址 0x00004% 1 = 0满足条件不用填零补位,此时共分配5字节。又由于结构体Test的自身对齐值为4,而5 % 4!= 0所以需要填零补位,即给结构体Test分配8字节内存。到此问题的答案变得似乎触手可及,sizeof(T)给出的是T这个对象或是数据类型所占的内存空间,但答案真的如此吗?看下面的例子:
class C
{
static int i; //不算做sizeof里面
int j;
int read() //不算做sizeof里面
{
return i;
}
};
C obj;
cout << sizeof(obj)<< endl; //值为4
cout << sizeof(C) << endl; //值为4
那么静态变量为什么不算做sizeof里面呢?也许对于sizeof(obj)我们很好理解,类中的静态变量不属于类的任何对象而属于类本身,当然不能算作sizeof(obj)里面去,那么sizeof(C)呢?在此我说一下我的个人观点:
对于编译器的设计者可能会有两种处理sizeof(C)的方法
1、直接读取C这个类的定义,遇到static变量不做动作,遇到普通函数也不动作,遇到int加4、char加1...还要考虑对齐问题等等,但是这样会有一个致命的问题,在32位系统里可以在64位系统里int可是占8个字节,这样的做法使得程序的可移植性大大降低。
2、编译器在处理sizeof(C)时可以先创建一个C的对象后再进行计算。
再者说数据类型只是个“模子”,而数据类型所实例化的对象才是实体。又由于上述sizeof(obj)==sizeof(C),编译器在处理sizeof(C)的时候创建C的对象再计算的可能性较大。
那么对于sizeof似乎可以给出如下的定义:sizeof记录的是类型对象的大小。但这样的定义准确么?
class C
{
static int i;
int j;
int read()
{
return i;
}
virtual int inc() //虚函数
{
return j+1;
}
};
C obj;
cout << sizeof(obj)<< endl; //结果是8
cout << sizeof(C) << endl; //结果是8
那么为什么普通函数不算做sizeof里面,而虚函数要算到sizeof里面呢?也许你会说这很好理解:普通函数在编译阶段就已经确认调用了,而虚函数需要在运行时才确认其具体调用当然要存储一个4字节的虚表指针。说法当然没有错误,但我想说的是这种说法的潜台词是sizeof需要在运行阶段之前就已经确定调用了。如果sizeof在运行时调用,虚函数调用已经确定,对象就没必要保留虚表指针,sizeof也就不会多算4个字节了(存储对象的空间都有可能被释放掉)。所以说sizeof的调用在程序的编译阶段执行完成。我们可以使用未完成的模板错误信息来证明这一点:
template<int i>
struct Test;
class foo
{
int a;
};
Test<sizeof(foo)> test; //ERROR
//'test' uses undefinedstruct 'Test<4>'
//编译时已经发生了sizeof(foo)=4的调用
所以对于sizeof我更愿意称之为在程序编译阶段记录了类型对象的大小。
到此为止我们似乎学习了一套sizeof的规则,拿着sizeof就可以准确求出类型对象的大小,但不幸的是我们使用sizeof有时是针对一些我们不知道具体结构的数据类型,将sizeof的返回值作为判断的一个依据。例如下面的例子:
class C
{
static int i;
… //一些静态变量
int read()
{
return i;
}
};
class Chr
{
char c;
};
class Empty
{
//空类不空,为了区分空类的不同对象
//编译器给空类一个字节的空间作为标识
};
cout << sizeof(C) << endl; //结果为1
cout << sizeof(Chr)<< endl; //结果为1
cout << sizeof(Empty)<< endl; //结果为1
在此sizeof尴尬了,当遇到了结果为1的类型对象时,它究竟属于上述三类中哪类的呀,还是另有其结构,sizeof茫然了。
当然了,对于编程而言sizeof有着其独特的优点:
1、sizeof可以避免一些所谓的“魔术字”带来的问题。比如当我们想把4个整形变量的长度作为一个变量时,4*sizeof(int)要比16来的更直观一些并且易于理解。
2、随着计算机软硬件体系的不断发展,相同的数据类型在不同的软硬件体系下所表现出来的长度大小是有差别的。为此程序设计者总希望可以将由类型带来的影响降到最低,这样也可以增强程序的可移植性(sizeof(int)可以较好的应用在32与64位操作系统中,作为整型对象长度的4就不能应用在64位操作系统中)。
但是对于一些复杂问题的分析,单纯的从sizeof的结果去分析往往会将你引入歧途,虽然它的结果是正确的。例如下面的例子:
class A
{
public:
virtual void f1(){}
};
class B
{
public:
virtual void f2(){}
};
class C: public A, public B
{
public:
virtual void f3(){}
};
class D: public C
{
public:
virtual void f4(){}
};
cout << sizeof(D)<< endl; //结果为8
显然D中含有了2个虚表指针,那么究竟是哪两个虚表指针呢?这将会给予编程者极大的困惑与苦恼。事实上在对这段代码进行拆解时你就会发现其中的奥妙:
vtable_A{..., &f1(),&f3(), &f4()}
vtable_B{..., &f2()}
D
{
void ** vtable_A;
void ** vtable_B; //结果一目了然
}
所以说,对于一个聪明的程序设计者而言要在适当的情况下使用sizeof,而在复杂问题的判断上慎用sizeof或者将其作为分析问题的一个辅助条件也不失为一种明智的选择。
参考文献:
《The C++ Programming Language》Bjarne Stroustrup
《Bjarne Stroustrup'sC++ Style and Technique FAQ》BjarneStroustrup
《Data Structure Alignment》Wikipedia
《How to determine sizeof class with virtual function》StackOverflow
《Is it possible to print out the size of a C++ class atcompile-time》StackOverflow