第9章 结构体和类

在C++中,结构体和类都具有构造函数、析构函数和成员函数,两者只有一个区别:结构体的访问控制默认为public,而类的默认访问控制是private。对于C++中的结构体而言,public、private、protected的访问控制都是在编译期进行检查,当越权访问时,编译过程中会检查出此类错误并给予提示。编译成功后,程序在执行的过程中不会在访问控制方面做任何检查和限制。因此,在反汇编中,C++中的结构体与类没有分别,两者的原理相同,只是类型名称不同,本章使用的示例多为类。

9.1 对象的内存布局

结构体和类都是抽象的,在真实世界中它们只可以表示某个群体,无法确定这个群体中的某个独立个体,而对象则是群体中独立存在的个体。例如,地球上最智慧的群体生物是人,人便是抽象事物,可以看做是一个类。“人”只能描述这个类型的事物具有哪些特征,而无法得知具体是哪一个人。而在“人”这个类中,如关羽、张飞等都是独立存在的实体,可被看做是“人”这个类中的实体对象。

由于类是抽象概念,当两个类的特征相同时,它们之间应该是相等的关系。而对象是实际存在的,即使它们之间所包含的数据相同,也不能视为同一个对象,这就如同人类中的两个实体对象,即使他们是一对双胞胎,也不能因为他们的相貌等各方面的特征都相同就将他们描述成同一个人。下面我们将通过一个简单的示例(见代码清单9-1)来加深理解类与对象之间的关系。

代码清单9-1 类与对象的关系—C++源码


class CNumber{//CNumber为抽象类名称,如同"人"这个名称

public:

CNumber(){

m_nOne=1;

m_nTwo=2;

}

int GetNumberOne(){//类成员函数,如人类的行为,吃、喝、睡等

return m_nOne;

}

int GetNumberTwo(){

return m_nTwo;

}

private:

int m_nOne;//类数据成员,如人类的耳、鼻等外部器官

int m_nTwo;

};

void main(){

CNumber Number;

}


代码清单9-1中定义了自定义类型CNumber类,以及该类的实例对象Number。CNumber类型与C++中提供的int都属于数据类型。在32位下,整型变量的数据大小为4字节。使用class关键字的自定义类型如何分配各数据成员呢?我们下面来调试运行代码清单9-1,以分析对象Number的各成员在内存中的布局,如图9-1所示。

图 9-1 对象内存布局

在图9-1中,对象Number在内存中的地址为0x0012FF78,该地址处定义了对象Number的各个数据成员,它们分别存放在地址0x0012FF78与0x0012FF7C处。对象Number中先定义的数据成员在低地址处,后定义的数据成员在高地址处,依次排列。对象的大小只包含数据成员,类成员函数属于执行代码,不属于类对象的数据。

根据图9-1可知,凡是属于CNumber类型的变量,在内存中都会占据8字节的空间。这8字节由类中的两个数据成员组成,它们都是int类型,各自的数据长度为4字节。从内存布局上看,类与数组非常相似,都是由多个数据元素构成,但类的能力要远远大于数组。类成员的数据类型定义非常广,除本身的对象外,任何已知数据类型都可以在类中定义。

为什么在类中不能定义自身的对象呢?因为类需要在申请内存的过程中计算出自身的实际大小,以用于实例化。如果在类中定义了自身的对象,在计算各数据成员的长度时,又会回到自身,这样就形成了递归定义,而这个递归并没有出口,是一个无限的循环递归定义,所以不能定义自身对象作为类成员。但是,自身类型的指针除外,因为任何类型的指针在32位下所占用的内存大小始终为4字节,等同于一个常量值,因此将其作为类的数据成员不会影响长度的计算。根据以上知识,可以总结出如下的对象长度计算公式:


对象长度=sizeof(数据成员1)+sizeof(数据成员2)+……+sizeof(数据成员n)


这个公式是否正确呢?

从表面上看,这个公式没有问题,但对象的大小计算远远没有这么简单。即使类中没有继承和虚函数的定义,仍有三种特殊情况能推翻此公式:空类、内存对齐、静态数据成员。当出现这三种情况时,使用此公式得到的对象长度与实际情况不相符。下面我们就来详细介绍一下为何该公式不适用这三种情况。

空类。空类中没有任何数据成员,按照该公式计算得出的对象长度为0字节。类型长度为0,则此类的对象不占据内存空间。而实际情况是,空类的长度为1字节。如果对象完全不占用内存空间,那么空类就无法取得实例对象的地址,this指针失效,因此不能被实例化。而类的定义是由成员数据和成员函数组成,在没有成员数据的情况下,还可以有成员函数,因此仍然需要实例化,分配了1字节的空间用于类的实例化,这1字节的数据并没有被使用。

内存对齐。在VC++6.0中,类和结构体中的数据成员是根据它们在类或结构体中出现的顺序来依次申请内存空间的,由于内存对齐的原因,它们并不一定会像数组那样连续地排列。由于数据类型不同,因此占用的内存空间大小也会不同,在申请内存时,会遵守一定的规则。

在为结构体和类中的数据成员分配内存时,结构体中的当前数据成员类型长度为M,指定的对齐值为N,那么实际对齐值为q=min(M, N),其成员的地址安排在q的倍数上。如以下代码所示:


Struct tagTEST{

short sShort;//应占2字节内存空间,假设所在地址为0x0012FF74

int nInt;//应占4字节内存空间

};


数据成员sShort的地址为0x0012FF74,类型为short,占2字节内存空间。VC++6.0指定的对齐值默认为8,short的长度为2,于是实际的对齐值取较小者2。所以,short被分配在地址0x0012FF74处,此地址是2的倍数,可分配。此时,轮到为第二个数据成员分配内存了,如果分配在sShort后,应在地址0x0012FF76处,但第二个数据成员为int类型,占4字节内存空间,与指定的对齐值比较后,实际对齐值取int类型的长度4,而地址0x0012FF76不是4的倍数,需要插入两个字节填充,以满足对齐条件,因此第二个数据成员被定义在地址0x0012FF78处,如图9-2所示。

图 9-2 内存对齐说明

在图9-2中,内存监视窗口中显示了test对象所在地址中的数据。在short类型变量所占用的地址0x0012FF74处,数据成员sShort被赋值为3。在其后插入了两个0xCC数据,它们便是编译器用于对齐而插入的,实际运行中并没有使用到这两个字节中的数据。

上述示例讲到了结构体成员对齐值的问题,现在讨论一下对齐值对结构体整体大小的影响。如果按VC++6.0默认的8字节对齐,那么结构体的整体大小要能被8整除,如以下代码所示:


struct{

double dDouble;//所在地址:0x0012FF00~0x0012FF08之间,占8字节

int nInt;//所在地址:0x0012FF08~0x0012FF0C之间,占4字节

short sShort;//所在地址:0x0012FF0C~0x0012FF10之间,占2字节

};


上例中结构体成员的总长度为8+4+2=14,按默认的对齐值设置要求,结构体的整体大小要能被8整除,于是编译器在最后一个成员sShort所占内存之后加入2字节空间填补到整个结构体中,使总大小为8+4+2+2=16,这样就满足了对齐的要求。

但是,并非设定了默认对齐值就将结构体的对齐值锁定。如果结构体中的数据成员类型最大值为M,指定的对齐值为N,那么实际对齐值为min(M, N),如以下代码所示:


struct{

char cChar;//应占1字节内存空间,如所在地址为0x0012FF00

int nInt;//应占4字节内存空间

short sShort;//应占2字节内存空间

};


以上结构如果还是按照8字节的方式对齐,其布局格式如下所示:


cChar所在地址:0x0012FF00~0x0012FF04之间,占4字节,对齐nInt

nInt所在地址:0x0012FF04~0x0012FF08之间,占4字节

sShort所在地址:0x0012FF08~0x0012FF0C之间,占2字节,另外填充2字节


随后定义的数据成员sShort应该使用6字节的空数据对齐。VC++6.0通过检查发现,结构中最大的类型为nInt数据,占4字节空间,于是将对齐值由8调整为4,重新调整后,sShort只需要填充2字节的空白数据就可以实现对齐。

既然有默认的对齐值,就可以在定义结构体时进行调整,VC++6.0中可使用预编译指令#pragma pack(N)来调整对齐大小。修改以上示例,调整对齐值为1,如以下代码所示:


#pragma pack(1)

struct{

char cChar;//应占1字节内存空间

int nInt;//应占4字节内存空间

short sShort;//应占2字节内存空间

};


调整对齐值后,根据对齐规则,在分配nInt时无需插入空白数据。对齐值为1,nInt占4字节大小,很明显,使用pack设定的对齐值更小,因此采用对齐值1的倍数来计算分配内存空间的首地址,nInt只需紧靠在cChar之后即可。这样cChar只占用1字节内存空间。由于设定的对齐值小于等于结构体中所有数据成员的类型长度,因此结构总长度只要是1的倍数即可。在这个例子中,结构总长度为7。

使用pack修改对齐值也并非一定会生效,与默认对齐值一样,都需要参考结构体中的数据成员类型。当设定的对齐值大于结构体中的数据成员类型大小时,此对齐值同样是无效的。对齐值的计算流程换个说法是:将设定的对齐值与结构体中最大的基本类型数据成员的长度进行比较,取两者之间的较小者。

当结构体中以数组作为成员时,将根据数组元素的长度计算对齐值,而不是按数组的整体大小去计算,如以下代码所示:


struct{

char cChar;//应占1字节内存空间,如所在地址为0x0012FF00

char cArray[4];//应占4字节内存空间

short sShort;//应占2字节内存空间

};


按照对齐规定,cChar与cArray的对齐没有缝隙,无需插入空白数据,当cArray与sShort进行对齐时,cChar与cArray在内存中将会占5字节,此时按照结构中当前的数据类型short进行对齐,插入1字节的数据即可,其结构布局如下所示:


cChar 所在地址:0x0012FF00~0x0012FF01之间,占1字节

cArray[4]所在地址:0x0012FF01~0x0012FF06之间,占5字节

sShort 所在地址:0x0012FF06~0x0012FF08之间,占2字节


根据结构体中的各数据成员类型得到,最大类型的数据成员sShort占2字节大小,其余成员类型各为1字节大小。在默认的编译选项下,对齐值为8,而sShort长度为2,因此会按照short类型的长度(2字节)来对齐,此时结构的总大小为8字节,无需填入即可满足。

当结构体中出现结构体类型的数据成员时,不会将嵌套的结构体类型的整体长度参与到对齐值计算中,而是以嵌套定义的结构体所使用的对齐值进行对齐,如以下代码所示:


struct tagOne{

char cChar;//占1字节内存空间

char cArray[4];//占5字节内存空间

short sShort;//占2字节内存空间

};

struct tagTwo{

int nInt;//占4字节内存空间

tagOne one;

//占8字节内存空间

};


在以上结构中,虽然tagOne结构占8字节大小,但由于其对齐值为2,因此tagTwo结构体中的最大类型便是int,以4作为对齐值。所以,结构tagTwo的总大小并非以8字节对齐的16字节,而是以4字节对齐的12字节。

由于存在内存对齐,数据的布局变化多端,因此在分析结构体和类的数据成员布局时,不能单纯地参考各数据成员的类型长度,按顺序进行排列,而应该按上述方法仔细观察和分析。另外,各编译器厂商的实现也有所不同,应详细阅读相关文档。

静态数据成员。当类中的数据成员被修饰为静态时,对象的长度计算又会发生变化。虽然静态数据成员在类中被定义,但它与静态局部变量类似,存放的位置和全局变量一致。只是编译器增加了作用域的检查,在作用域之外不可见,同类对象将共同享有静态数据成员的空间,详细内容请参见9.3节。

通过以上的讲解我们发现,对象的内存布局并不简单。在类中定义了虚函数和类为派生类等情况下,对象的内存布局中将含有虚函数表和父类数据成员等数据信息,这将使长度计算更为复杂。我们要从简单的情况入手,先掌握最基本的类对象的内存结构分析方法,然后再深入学习。

当对象为全局对象时,其内存布局与局部对象相同,只是所在内存地址,以及构造函数和析构函数的触发时机不同。全局对象所在的内存地址空间为全局数据区,而局部对象的内存地址空间在栈中。第10章将会详细讲解全局对象的构造函数的初始化以及析构函数释放的全过程。

了解了类中数据成员的内存布局后,如何访问它们呢?在类方法中,又是如何知道数据成员在内存中的地址以及其中的数据信息的呢?带着这些疑问,我们进入9.2节的学习,了解神秘的this指针。