第11章 结构体

11.1 自定义数据类型

C 语言是一种强类型语言,定义任何一个变量,都需要准确、唯一地指定这个变量的数据类型。为此,C 语言预先定义了整型,浮点型,字符型等几种数据类型。唯一一个泛型的类型就是void,不过它只能用于定义指针,而且你不能利用这种类型做什么实际的工作,它只是一个中间类型,最终要经过强制转换成一个具体的数据类型来使用。

void 的具体说明可以参考10.4.1 节。为了理解什么叫强类型语言,有必要先以一种弱类型的语言为例,例如Perl 语言。程序11-1 是一段很常见的Perl 程序,其中$var 变量可以作为一个数来与2.3 相加,相加后$var 等于3.3。同时还可以当成一个字符串与"abc"连接到一块。在Perl 语言中,这些都是可以的,但在C 语言中是绝对不允许的。这种弱类型语言的好处是比较灵活,在书写不超过50 行程序的时候,你完全可以hold 住这种灵活性,但是当你的程序已经上万行了,这种灵活性和不确定性无疑是一种灾难。

程序11-1 Perl 语言实例

!/usr/bin/perl

my $var = 1, $var1;

$var = $var + 2.3;

print $var,"\n";

$var1 = $var."abc";

print $var1,"\n";

C 语言是强类型语言,但是C 语言中预先定义的数据类型却远远不够。例如,有的时候我们想有没有一种数据类型能很好地定义学生,其中包括学号,姓名,性别等信息?有没有一种数据类型能很好地定义纸牌,包括大小和花色等信息?这样的要求在实际的应用中比比皆是。好在C 语言赋予你这种自定义的能力,它允许你用struct 来定义自己的数据类型。

struct 数据类型可以集不同数据类型于一体,抽象地说这是一种聚合数据类型,它能同时存储多个数据,有点类似于数组,但又和数组不同。数组保存的是同一种数据类型,结构体内一般保存的是不同的数据类型;数组一般用下标来访问,而结构体一般用成员名来访问。

再进一步,有的时候我们希望能有一个数据类型定义车,不仅包括车的一些特性,最好还能包括车的一些动作,包括启动,加速,刹车等。这个时候,我们就需要使用C++中的class。实际上,struct 就是所有成员都是public 的一个class。

从struct 过渡到class 是一件顺其自然的事,但是从C 过渡到C++却带来了处理问题角度的转变,把C++看成是带class 的C 是狭隘和片面的。

11.2 定义一个结构体变量的三种方法

定义一个结构体变量有三种方法。第一种方法是,用typedef 为已存在的结构类型struct student 定义新名字STUD,然后用新名字STUD 定义变量名,如程序11-2 所示。这种方法有很多优点,至少可以减少敲击键盘的次数,同时STUD 类型也比较醒目。

程序11-2 利用typedef 定义新类型名

typedef struct student{

int num;

char name[20];

} STUD;

STUD student1,student2; / 定义两个变量 /

第二种方法是,利用结构类型struct student 定义变量,如程序11-3 所示。这种方法在定义第三个变量student3 的时候,必须完整地写出结构体的类型定义struct student。你可以计算一下,比起STUD,你多敲了几个字母?如果你的结构体变量的应用范围比较少,或者生存期比较短,第二种声明结构体变量的方法也是可行的。这应该也是一种编程风格的取舍。

程序11-3 利用结构类型定义变量

struct student{

int num;

char name[20];

} student1,student2;

struct student student3; / 定义student3变量 /

第三种方法是,只定义变量不定义类型。这种方法有个缺点,如果还需要定义一个结构体变量student3,我们将无能为力。所以这种方法一般不太常用,如程序11-4所示。

程序11-4 不定义类型,只定义变量

struct

{

int num;

char name[20];

} student1,student2;

11.3 结构体中的“洞”

每个结构体的尺寸是不是其中每个成员的尺寸的和呢?让我们以程序11-5 为例进行说明。这个程序打印出一个整数,代表的就是整个结构体的尺寸。你会惊奇地发现,整个结构体的尺寸是8。在我的电脑上,int 占据4 字节,char 占据1 个字节,所以加到一起应该是5。但是,结构体变量s 在内存中却占了8 个字节。

程序11-5 结构体的尺寸

struct

{

char c;

int i ;

} s;

main(){

s.c = 'a';

s.i = 0x0a0b0c0d;

printf("%d",sizeof(s));

}

请注意,为了比较准确地定位出变量i 在内存中保存的位置,程序11-5 中变量i 我用十六进制进行了赋值,这样在程序11-5 所对应的图11-1 的内存分布上,你就可以比较清楚地看到变量c 和i 在内存中对应的位置了。从图11-1 中我们可以看出,i 变量被对齐到一个4 倍整数的地址上,在内存上,它并不是紧挨着变量c。这种现象叫做内存对齐,对齐以后的地址通常都是2 或4 的倍数。这种对齐最终会造成结构体s 内部char 和int 之间有一个“空洞(hole)”

图

图11-1 结构体内部的空洞

这种对齐的目的是使处理器能够更快速地进行寻址,以便执行的速度更快。这是一种以空间换时间的策略。就像是我们通常把不同的东西放到不同的抽屉里,虽然有点浪费空间,但是这样查找起来很快;如果把所有不同的东西都挤到一个抽屉里,虽然比较省空间,但是查找起来就很困难。

11.4 结构体的赋值和比较

两个相同类型的结构体变量是不能直接用等号来进行赋值的,同时也不能直接用“==”来判断两个结构体变量是否相等,否则会出现编译错误,如程序11-6 第6 行和第7 行所示。在C++中,你可以通过重载“=”和“==”两个运算符来直接赋值和比较,但是目前C 语言中不支持,所以无论是结构体间进行赋值还是判断相等,你只能通过对结构体中每个成员单独操作来完成。例如,如果想进行结构体变量的赋值操作,你可以利用程序11-6 中的第8 行和第9 行来完成。

程序11-6 结构体变量比较和赋值

1 struct {

2 char c;

3 int i ;

4 } s1, s2;

5 main(){

6 s1=s2 / 编译错误 /

7 if(s1==s2) / 编译错误 /

8 s1.c = s2.c / 单独对成员进行操作 /

9 s1.i = s2.i 10 }

不支持“==”来实现结构体变量的比较符合C 语言的低层特性。上面介绍过结构体中一般都有“空洞”,空洞中的数据是完全随机的。而简单地按位比较会由于结构体中的“空洞”中的随机数据而失败。如果结构体中的成员很多,而且经常进行结构体变量的比较,正确的方法是编写一个函数来做这个工作。关于如何向函数中传入结构体变量,我们会在后面介绍。

有的时候,结构体内部会包含指针成员变量。别忘了,结构体内部可以包含任何数据类型,甚至可以包含另外一个结构体(只要不是包含自身就可以了,因为这样会引起无限的嵌套定义),所以结构体内部包含一个指针无可厚非。数据结构中的链表结构就是通过在结构体内部包含一个指向自身的指针来完成的。

如果结构体内部包含指针成员变量,我们再进行赋值和比较相等的时候,就需要高度注意了。如何定义相等的含义?是两个指针保存的地址相等?还是指针指向的内容相等?一般情况下,我们认为指针指向的内容相等才是两个结构体相等。

程序11-7 第7 行中,在对两个结构体变量中的指针成员利用“=”进行直接赋值的时候,会使两个指针指向同一块内存地址,如图11-2 所示。这个时候,如果对s1变量中str 成员所指向的内容进行更改,那么s2 变量中str 成员也会被“潜在”修改,这是否是你希望的呢?

图

图11-2 两个指针指向同一地址

避免这种“潜在”修改的一个方法如程序11-7 第8 行和第9 行所示。这个时候,s1 变量中的str 成员和s2 变量中的str 成员指向不同的地址,修改其中一个内容不会影响到另外一个,如图11-3 所示。

这里需要注意的就是,在使用str 成员前,我们应该用malloc 函数分配一块空间,并且将str 指向这个空间的首地址。使用完毕结构体变量后,利用free 函数释放这块内存。

图

图11-3 两个指针指向不同的地址

程序11-7 结构体中包含指针的情况

1 struct {

2 char *str;

3 } s1, s2;

4 main(){

5 s1.str==s2.str / 比较指针保存的地址 /

6 strcmp(s1.str, s2.str) / 比较指针指向的内容 /

7 s1.str=s2.str / 两个指针指向同一地址 /

8 s1.str=(char)malloc(100,sizeof(char)); / 分配空间 */

9 strcpy(s1.str, s2.str) / 两个指针指向不同地址 /

10 ….

11 free(s1.str); / 使用完毕后释放空间 /

12 }

11.5 结构体的读写

从文件中读写结构体,一个方法就是利用fwrite 和fread 函数,如程序11-8所示。

程序11-8 读、写结构体的方法

struct{

char c;

int i ;

} s;

main(){

fwrite(&s, sizeof(s),1,fp);

}

这种方法的一个优点是把整块的结构体内存直接写到文件中,比起分别操作结构体中的每个成员,速度要快一些。但是这种方法隐藏了一个移植性的风险,如果你在电脑A 上运行你的程序,并把结构保存在data 这个文件中,当你把程序和data 文件移植到电脑B 的时候,结构体内部由于需要对齐地址而产生空洞,而不同的处理器的对齐策略是不同的。data 文件中保存的数据是根据电脑A 上的对齐策略分布的,按照这种格式读进电脑B 的内存也许并不符合电脑B 的内存对齐策略,这样就会带来一个移植性的问题。如果想避免移植性的问题,更好的办法就是单独对结构体的每一个成员进行操作,这样做的一个优点就是生成的文件由于没有空洞会比较小,同时也不会有移植的问题;缺点就是当结构体成员比较多的时候,分次读写要比一次读写的速度慢。

11.6 函数与结构体

无论是结构体还是数组,它们通常都是一大块数据,所以在传递它们的时候都要考虑效率的问题。传递数组的时候,我们通常传递的是数组的数组名,也就是它的首地址,所以效率比较高,同时函数不能返回一个数组。

与数组不同,向函数传递一个结构体,与向函数传递一个int 的变量是一样的,遵循的是单向值传递,并不是传递结构体的地址。同时,函数也支持返回结构体,与向函数返回一个int 的变量也是一样的。

我们以前讲解过,单向值传递都需要将传入的实参拷贝一次。所以,基于效率问题的考虑,应该尽量避免单向值传递结构体。传递结构体变量时,通常传递的是地址,而函数的形参声明为结构体类型的指针,如程序11-9 所示。这样就避免了结构体的复制。无论结构体变量有多大,传递一个指针只涉及到4 个字节,所以效率非常高。

但是,如果函数返回一个结构体,那么没有办法避免一次完整的复制过程,如程序11-9 第3 行中的stu2 变量。所以,当结构体的尺寸很大的时候,我们也要避免用函数直接返回一个结构体,用传入地址的方式效率更高一些。

程序11-9 函数和结构体

1 struct student foo(struct student pstu); / 函数定义 */

2 struct student stu1, stu2;

3 stu2 = foo(&stu1); / 函数调用 /

11.7 枚举

性别、月份、星期几、颜色等事物,本身有些特点。首先它们的值都有特定的范围,如性别只有男女,一个星期内只有7 天,一年只有12 个月等。其次它们的值本身并不是数值型数据。

在C 语言中,可以用一个数值型数据来定义上述事物中的一个值,如0 代表女,1 代表男。可以看出这种方法不直观,易读性差。如果能在程序中用自然语言中有相应含义的单词来代表某一个值,则程序就很容易阅读和理解。也就是说,事先考虑到某一变量可能取的值,尽量用自然语言中含义清楚的单词来表示它的每一个值。用这种方法定义的类型称枚举类型。枚举类型是一种基本数据类型,而不是一种构造类型,因为它不能再分解为任何基本类型。虽然它与int 类型在某些行为上很相似,但是它本身并不是int 类型。

关于枚举的一个经典的例子就是用它来描述一个星期,本书也不能免俗,把这个例子放到了程序11-10 中。

程序11-10 枚举描述一个星期的例子

1 enum DAY { MON=1, TUE, WED, THU, FRI, SAT, SUN };

2 void main()

3 {

4 enum DAY today;

5 today = MON;

6 today = 1;/ 错误 /

7 printf("%d \n", today);

8 printf("%s \n", today); / 错误 /

9 }

从这个简单的程序中可以看出枚举的一些优点。

• 枚举变量可以用枚举符,如“MON”来赋值,程序的易读性较好。

• 枚举变量只能用枚举符来赋值,这样就在编译阶段避免了today=8 这样严重的逻辑错误。

但是要注意,枚举符并不是字符串,所以程序11-10 中的第8 行是错误的。另外,today=(enum DAY)8;也会成功编译并运行,毕竟枚举没有人那样的智能,如果你霸王硬上弓,那它也只能委曲求全。避免这种逻辑错误的根本在于程序员本人。

11.8 本章小结

结构体部分主要介绍了定义结构体的三种方法。结构体内部遵循的对齐策略会让结构体内部有“空洞”。结构体的赋值和比较只能通过遍历其中的各个成员变量来完成。在C++中,当结构体上升到一个类的时候,我们可以通过重载类的赋值和比较运算符来完成。

对于结构体的读写,我们可以采用遍历其中各个成员变量的办法,也可以采用整块内存进行读写的办法,它们一个在空间上较小,一个在速度上较快。值得注意的是,当用整块内存进行读写的时候,“空洞”现象有的时候会带来一定的移植性问题。当想要一个函数传递一个结构体的时候,高效的办法是传递这个结构体的地址。