结构体和类

在C++中类与结构体并没有太大的区别,只是默认的成员访问权限不同,类默认权限为私有,而结构体为公有,所以在这将它们统一处理,在例子中采用类的方式。

类对象在内存中的分布

在类中只有数据成员占内存空间,而类的函数成员主要分布在代码段中,不占内存空间,一般对象所占的内存空间大小为sizeof(成员1) + sizeof(成员2) + … + sizeof(成员n)但是有几种情况不符合这个公式,比如虚函数和继承,空类,内存对齐,静态数据成员。只要出现虚函数就会多出4个字节的空间,作为虚函数表,继承时需要考虑基类的大小,另外出现静态成员时静态成员由于存在于数据段中,并不在类对象的空间中,所以静态成员不计算在类对象的大小中这些不在此处讨论,主要说明其余的三种情况:

空类

按照上述公式,空类应该不占内存,但是实际情况却不是这样,下面来看一个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
class Test
{
public:
int Print(){printf("Hello world!\n");}
};

int main()
{
Test test;
printf("%d\n", sizeof(test));
return 0;
}

运行程序发现,输出结果为1,这个结果与我们预想的可能有点不一样,按理来说,空类中没有数据成员,应该不占内存空间才对,但是我们知道每个类都有一个this指针指向具体的内存,以便成员函数的调用,即使定义一个类什么都不写,编译器也会提供默认的构造函数用来初始化类,但是如果类的实例不占内存空间,那么该如何初始化?所以编译器为它分配一个1字节的空间以便初始化this指针。所以空类占一个字节。

内存对齐

下面看这样一个类

1
2
3
4
5
6
class Test
{
public:
short s;
int n;
};

当在程序中定义这样一个类,通过sizeof来输出大小得到的是8,上面的公式又不满足了,我们知道为了程序的运行效率,编译器并不会依次申请内存用于存储变量,而会采用内存对齐的方式,以牺牲一定内存空间的代价来换取程序的效率,这个类的大小为8,也是内存对齐的结果,查看类工各个成员的地址我们发现 n的地址为0x0012ff44,而s的地址为0x0012ff40,s本来是占2个字节,但是n并没有出现在其后的42的位置,我所用的VC++6.0默认采用的是8个字节的对齐方式,假设编译器采用的是n个字节的对齐方式,而类中某成员实际所占内存空间的大小为m,那么该成员所在的内存地址必须为p的整数倍,而p = min(m, n),所以对于s来说,采用的是2个字节的对齐方式,分配到的首地址为40是2的倍数,而其后的整型成员n占4个字节,采用上述公式,得到它的内存地址应该是4的倍数,所以取其后的44作为它的地址,中间有两个字节没有使用,所以这个类占8个字节。
下面再来看一个例子:

1
2
3
4
5
6
7
class Test
{
public:
short s; //8
double d; //8
char c;
};

通过程序得出当前结果体的大小为24,根据上面的分析,首先在为s分配空间的时候采用的是2个字节的对齐方式,假设分配到的地址为0x0012ff40,那么d采用的是8个字节的对齐方式,它的地址应该为0x0012ff48,最后为c分配内存的时候,应该是用1个字节的对齐方式,总共应该占的空间为8 + 8 + 1 = 17但是结果却并不是这样。在内存对齐时编译器实际采用对齐方式是:假设结构体成员的最大成员占n个字节,编译器默认采用m个字节的对齐方式,那么实际对齐大小应该为min(m, n)的整数倍,所以实际采用的是8个字节的对齐方式,而结构体的大小应该是实际对齐方式的整数倍,所以占24个字节。在编写程序时可以使用#pragma pack(n)的方式来改变编译器的默认对齐方式。另外对于嵌套定义的结构体,对齐情况也有少许不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
class One
{
public:
short s;
double d;
char c;
};

class Two
{
One one;
int n;
};

输出class two的大小为32个字节,嵌套定义的结构体仍然能够满足上述两个法则,首先其中的成员结构体one大小为24,然后另外一个成员n占4个字节,得到总共占28个字节,然后根据第二个对齐的规则在24和8之间取最小值8,可以得到结构体的大小应该为8的整数倍32个字节。

类的成员函数

类的成员函数在调用时直接利用对象打点调用,在函数中直接使用类中的成员,函数操作的是不同对象的数据成员,能够达到这个目的实际上类的对象在调用类的成员函数时默认传入的第一个参数是一个指向这个对象地址的指针叫做this指针,具体this指针的原理看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class test
{
private:
int i;
public:
test(){i = 0;}
int GetNum()
{
i = 10;
return i;
};
};
int main(int argc, char* argv[])
{
test t;
t.GetNum();
return 0;
}

下面对应的反汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
;主函数
24: test t;
00401278 lea ecx,[ebp-4]
0040127B call @ILT+20(test::test) (00401019)
25: t.GetNum();
00401280 lea ecx,[ebp-4]
00401283 call @ILT+0(test::GetNum) (00401005)
26: return 0;
00401288 xor eax,eax

;GetNum()函数
18: i = 10;
0040130D mov eax,dword ptr [ebp-4]
00401310 mov dword ptr [eax],0Ah
19: return i;
00401316 mov ecx,dword ptr [ebp-4]
00401319 mov eax,dword ptr [ecx]

在主函数中定义类的对象时首先会调用其构造函数,在调用函数之前首先通过lea指令获取到对象的首地址并将它保存到了ecx寄存器中,在函数GetNum中,首先是在函数栈中定义了一个局部变量,将这个局部变量的值赋值为10,然后将这个局部变量的值赋值到ecx所在地址的内存中,最后再将这块内存中的值放到eax中作为参数返回。通过这部分代码可以看到,this指针并不是通过参数栈的方式传递给成员函数的,而是通过一个寄存器来传递,但是成员函数中若有参数,则仍然通过参数栈的方式传递参数。通过寄存器传递给成员方法作为this指针,然后根据数据成员定义的顺序和类型进行指针偏移找到对应的内存地址,对其进行操作。

类的静态成员

静态数据成员

类的静态成员与之前所说的函数中的局部静态变量相似,它们都存储在数据段中,它们的生命周期与它们所在的位置无关,都是全局的生命周期,它们的可见性被封装到了它们所在的位置,对于函数中的局部静态变量来说,只在函数中可见,对于在文件中的全局静态变量来说,它们只在当前文件中可见,类中的局部静态变量可见性只在类中可见。
类的静态数据成员的生命周期与类对象的无关,这样我们可以通过类名::变量名的方式来直接访问这块内存,而不需要通过对象访问,由于静态数据成员所在的内存不在具体的类对象中,所以在C++中所有类的对象中的局部静态变量都是使用同一块内存区域,随便一个修改了静态变量的值,其他的对象中,这个静态变量的值都会发生变化。

静态函数成员

类中的函数成员也可以是静态的,下面看一个静态函数成员的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class test
{
public:
static void print()
{
cout<<"hello world";
}
};

int main(int argc, char* argv[])
{
test t;
t.print();
return 0;
}

下面是对应的汇编代码:

1
2
3
21:       test t;
22: t.print();
00401388 call @ILT+80(test::print) (00401055)

我们可以看到,在调用类的静态函数时并没有取对象的地址到ecx的操作,也就说,静态成员函数并不会传递this指针,由于静态成员的生命周期与对象无关,可以通过类名直接访问,那么如果静态成员函数也需要传递this指针的话,那么对于这种通过类名访问的时候,它要怎么传递this指针呢。
另外由于静态成员函数不传递this指针,这样会造成另外一个问题,如果需要在这个静态函数中操作类的数据成员,那么通过对象调用时,它怎么能找到这个数据成员所在的地址,另外在还没有对象,通过类直接调用时,这个数据成员还没有分配内存地址,所以说在C++中为了避免这些问题直接规定静态函数不能调用类的非静态成员,但是静态数据成员虽然说由所有类共享,但是能够找到对应的内存地址,所以非静态成员函数是可以访问静态数据成员的。

类作为函数参数

前面在写函数原理的那篇博文时说过结构体是如何参数传递的,其实类也是一样的,当类作为参数时,会调用拷贝构造,拷贝到函数的参数栈中,下面通过一个简单的例子来说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class test
{
private:
char szBuf[255];
public:
static void print()
{
cout<<"hello world";
}
};
void printhello(test t)
{
t.print();
}
int main(int argc, char* argv[])
{
test t;
printhello(t);
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
26:       test t;
27: printhello(t);
0040141E sub esp,100h
00401424 mov ecx,3Fh
00401429 lea esi,[ebp-100h]
0040142F mov edi,esp
00401431 rep movs dword ptr [edi],dword ptr [esi]
00401433 movs word ptr [edi],word ptr [esi]
00401435 movs byte ptr [edi],byte ptr [esi]
00401436 call @ILT+130(printhello) (00401087)
0040143B add esp,100h

从上面的汇编代码上可以看出,在进行参数传递时通过rep mov这个指令来将对象所在内存中的内容拷贝到函数栈中。在函数参数需要对象时,直接传递对象会进行一次拷贝,这样不仅浪费内存空间,而且在效率上不高,可以通过传递指针或者引用的方式来实现,这样只消耗4个字节的空间,而且不用拷贝,如果希望函数中不修改对象的内容,可以加上const限定。

类作为函数返回值

类作为函数的返回值时也与之前所说的结构体作为函数的返回值类似,都是需要先将类拷贝到对应函数栈外部的内存中,然后在随着函数栈由系统统一回收,在这就不做特别的说明了。但是与作为参数不同,为了安全起见一般不要返回局部变量的指针或者引用,在某些需要返回类对象的场合一般只能返回类对象。