结构体

定义结构体

例如,如果用实部和虚部表示一个复数[1],我们可以写成由两个double型组成的结构体:

1
2
3
struct complex_struct{
double x, y;
};

这一句定义了标识符complex_struct(同样遵循标识符的命名规则),这种标识符在C语言中称为Tag,struct complex_struct { double x, y; }整个可以看作一个类型名,就像int或double一样,只不过它是一个复合类型,如果用这个类型名来定义变量,可以这样写:

1
2
3
struct complex_struct{
double x, y;
} z1, z2;

注意后面的;不能少。类型定义也是一种声明,声明都要以;号结尾。

也可以在定义了complex_struct后直接用普通的声明语句来声明 z1 和 z2:

1
struct complex_struct z1, z2;

访问结构体成员

每个复数变量都有两个成员(Member)x和y,可以用.运算符(.号,Period)来访问,这两个成员的存储空间是相邻的,合在一起组成复数变量的存储空间。看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
int main(void){
struct complex_struct { double x, y; } z;
double x = 3.0;
z.x = x;
z.y = 4.0;
if (z.y < 0)
printf("z=%f%fi\n", z.x, z.y);
else
printf("z=%f+%fi\n", z.x, z.y);
return 0;
}

注意上例中变量x和变量z的成员x的名字并不冲突,因为变量z的成员x只能通过表达式z.x来访问,编译器可以从语法上区分哪个x是变量x,哪个x是变量z的成员x。

初始化和赋值

结构体变量也可以在定义时初始化,例如:

1
struct complex_struct z = { 3.0, 4.0 };

Initializer中的数据依次赋给结构体的各成员。如果Initializer中的数据比结构体的成员多,编译器会报错,但如果只是末尾多个逗号则不算错。如果Initializer中的数据比结构体的成员少,未指定的成员将用0来初始化,就像未初始化的全局变量一样。例如以下几种形式的初始化都是合法的:

1
2
3
4
double x = 3.0;
struct complex_struct z1 = { x, 4.0, }; /* z1.x=3.0, z1.y=4.0 */
struct complex_struct z2 = { 3.0, }; /* z2.x=3.0, z2.y=0.0 */
struct complex_struct z3 = { 0 }; /* z3.x=0.0, z3.y=0.0 */

注意,z1必须是局部变量才能用另一个变量x的值来初始化它的成员,如果是全局变量就只能用常量表达式来初始化。这也是C99的新特性,C89只允许在{}中使用常量表达式来初始化,无论是初始化全局变量还是局部变量。

{}这种语法不能用于结构体的赋值,例如这样是错误的 [2]

1
2
struct complex_struct z1;
z1 = { 3.0, 4.0 };

实例:构造复数

现在我们来实现一个完整的复数运算程序。在上一节我们已经定义了复数的结构体类型,现在需要围绕它定义一些函数。复数可以用直角坐标或极坐标表示,直角坐标做加减法比较方便,极坐标做乘除法比较方便。如果我们定义的复数结构体是直角坐标的,那么应该提供极坐标的转换函数,以便在需要的时候可以方便地取它的模和辐角。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <math.h>
struct complex_struct {
// 定义复数的结构体
double x, y;
};
double real_part(struct complex_struct z){
// 返回复数的实部
return z.x;
}
double img_part(struct complex_struct z){
// 返回复数的虚部
return z.y;
}
double magnitude(struct complex_struct z){
// 返回复数的模
return sqrt(z.x * z.x + z.y * z.y);
}
double angle(struct complex_struct z){
// 返回复数的辐角
return atan2(z.y, z.x);
}
struct complex_struct make_from_real_img(double x, double y){
// 用实部和虚部构造复数结构体
struct complex_struct z;
z.x = x;
z.y = y;
return z;
}
struct complex_struct make_from_mag_ang(double r, double A){
// 用辐角和模构造复数结构体
struct complex_struct z;
z.x = r * cos(A);
z.y = r * sin(A);
return z;
}
// 定义复数的加减乘除运算
struct complex_struct add_complex(struct complex_struct z1, struct complex_struct z2){
return make_from_real_img(
real_part(z1) + real_part(z2),
img_part(z1) + img_part(z2)
);
}
struct complex_struct sub_complex(struct complex_struct z1, struct complex_struct z2){
return make_from_real_img(
real_part(z1) - real_part(z2),
img_part(z1) - img_part(z2)
);
}
struct complex_struct mul_complex(struct complex_struct z1, struct complex_struct z2){
return make_from_mag_ang(
magnitude(z1) * magnitude(z2),
angle(z1) + angle(z2)
);
}
struct complex_struct div_complex(struct complex_struct z1, struct complex_struct z2){
return make_from_mag_ang(
magnitude(z1) / magnitude(z2),
angle(z1) - angle(z2)
);
}

可以看出,复数加减乘除运算的实现并没有直接访问结构体 complex_struct 的成员x和y,而是把它看成一个整体,通过调用相关函数来取它的直角坐标和极坐标。这样就可以非常方便地替换掉结构体 complex_struct 的存储表示,例如改为用极坐标来存储。

嵌套结构体

结构体也是一种递归定义:结构体的成员具有某种数据类型,而结构体本身也是一种数据类型。换句话说,结构体的成员可以是另一个结构体,即结构体可以嵌套定义。例如我们在复数的基础上定义复平面上的线段:

1
2
3
4
struct Segment {
struct complex_struct start;
struct complex_struct end;
};

初始化

嵌套结构体可以嵌套地初始化,例如:

1
struct Segment s = { {1.0, 2.0 }, { 4.0, 6.0 } };

也可以平坦(Flat)地初始化。例如:

1
struct Segment s = { 1.0, 2.0, 4.0, 6.0 };

甚至可以把两种方式混合使用(这样可读性很差,应该避免):

1
struct Segment s = { { 1.0, 2.0 }, 4.0, 6.0 };

利用C99的新特性也可以做 Memberwise Initialization,例如:

1
struct Segment s = { .start.x = 1.0, .end.x = 2.0 };

访问结构体成员

访问嵌套结构体的成员要用到多个.运算符,例如:

1
2
3
s.start.t = RECTANGULAR;
s.start.a = 1.0;
s.start.b = 2.0;

枚举

定义

enum关键字的作用和struct关键字类似,例如:

1
enum coordinate_type { RECTANGULAR, POLAR };

enum coordinate_type 表示一个枚举(Enumeration)类型。枚举类型的成员是常量,它们的值由编译器自动分配,例如定义了上面的枚举类型之后,RECTANGULAR就表示常量0,POLAR表示常量1。如果不希望从0开始分配,可以这样定义:

1
enum coordinate_type { RECTANGULAR = 1, POLAR };

这样,RECTANGULAR就表示常量1,而POLAR表示常量2。枚举常量也是一种整型,其值在编译时确定,因此也可以出现在常量表达式中,可以用于初始化全局变量或者作为case分支的判断条件。

有一点需要注意,虽然结构体的成员名和变量名不在同一命名空间中,但枚举的成员名却和变量名在同一命名空间中,所以会出现命名冲突。例如这样是不合法的:

1
2
3
4
5
6
7
int main(void)
{
enum coordinate_type { RECTANGULAR = 1, POLAR };
int RECTANGULAR;
printf("%d %d\n", RECTANGULAR, POLAR);
return 0;
}

赋值

枚举类型的对象的初始化或赋值,只能通过其枚举成员或同一枚举类型的其他对象来进行,例如:

1
2
3
4
Points pt3d = point3d; //合法
Points pt2w = 3; //不合法:pt2w不能再初始化
pt2w = polygon; //不合法:polygon不是Points的枚举成员
pt2w = pt3d; //合法

注意把 3 赋给 Points 对象是非法的,即使 3 与一个 Points 枚举成员相关联。

实例

可以对在上一节中的 complex_struct 进行改进,让其同时支持两种直角坐标和极坐标两种存储格式,方法是为 complex_struct 结构体添加一个枚举常量用作数据类型标志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum coordinate_type {RECTANGULAR, POLAR};
struct complex_struct {
enum coordinate_type t;
double a, b;
};
struct complex_struct make_from_real_img(double x, double y)
{
struct complex_struct z;
z.t = RECTANGULAR;
z.a = x;
z.b = y;
return z;
}
struct complex_struct make_from_mag_ang(double r, double A)
{
struct complex_struct z;
z.t = POLAR;
z.a = r;
z.b = A;
return z;
}
// 其他的加减乘除运算...

如果数据类型标志为0,那么两个浮点数就表示直角坐标,如果数据类型标志为1,那么两个浮点数就表示极坐标。这样,直角坐标和极坐标的数据都可以适配(Adapt)到 complex_struct 结构体中,无需转换和损失精度。

数组

数组(Array)也是一种复合数据类型,它由一系列相同类型的元素(Element)组成。

定义数组

简单数组

例如定义一个由4个int型元素组成的数组count:

1
int count[4];

结构体成员类似,数组count的4个元素的存储空间也是相邻的。

定义复合类型数组

结构体成员可以是基本数据类型,也可以是复合数据类型,数组中的元素也是如此。根据组合规则,我们可以定义一个由4个结构体元素组成的数组:

1
2
3
struct complex_struct {
double x, y;
} a[4];

访问数组元素

数组中的元素通过下标(或者叫索引,Index)来访问。例如前面定义的由4个int型元素组成的数组count图示如下:

1
2
3
4
5
0 1 2 3
+------|------|------|------+
| | | | |
count | 0 | 0 | 0 | 0 |
+------|------|------|------+

整个数组占了4个int型的存储单元,存储单元用小方框表示,里面的数字是存储在这个单元中的数据(假设都是0),而框外面的数字是下标,这四个单元分别用count[0]、count[1]、count[2]、count[3]来访问。

和我们平常数数的习惯不同,数组元素是从“0”开始数的。大多数编程语言都是这么规定的,所以计算机术语中有 Zeroth 这个词。

这种数组下标的表达式不仅可以表示存储单元中的值,也可以表示存储单元本身,也就是说可以做左值,因此以下语句都是正确的:

1
2
3
count[0] = 7;
count[1] = count[0] * 2;
++count[2];

数组下标也可以是表达式,但表达式的值必须是整型的。例如:

1
2
int i = 10;
count[i] = count[i+1];

使用数组下标不能超出数组的长度范围,这一点在使用变量做数组下标时尤其要注意。C编译器并不检查count[-1]或是count[100]这样的访问越界错误,编译时能顺利通过,所以属于运行时错误。但有时候这种错误很隐蔽,发生访问越界时程序可能并不会立即崩溃,而执行到后面某个正确的语句时却有可能突然崩溃。所以从一开始写代码时就要小心避免出问题,事后依靠调试来解决问题的成本是很高的。

初始化

数组也可以像结构体一样初始化,未赋初值的元素也是用0来初始化,例如:

1
int count[4] = { 3, 2, };

则count[0]等于3, count[1]等于2,后面两个元素等于0。如果定义数组的同时初始化它,也可以不指定数组的长度,例如:

1
int count[] = { 3, 2, 1, };

编译器会根据Initializer有三个元素确定数组的长度为3。利用C99的新特性也可以做 Memberwise Initialization:

1
int count[4] = { [2] = 3 };

示例

下面举一个完整的例子:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
int main(void)
{
int count[4] = { 3, 2, }, i;
for (i = 0; i < 4; i++)
printf("count[%d]=%d\n", i, count[i]);
return 0;
}

多维数组

就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构成了多维数组(Multi-dimensional Array)。例如定义并初始化一个二维数组:

1
int a[3][2] = { 1, 2, 3, 4, 5 };

数组a有3个元素,a[0]、a[1]、a[2]。每个元素也是一个数组,例如a[0]是一个数组,它有两个元素a[0][0]、a[0][1],这两个元素的类型是int,值分别是1、2,同理,数组a[1]的两个元素是3、4,数组a[2]的两个元素是5、0。如下图所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0 1
+----|----+
概 0 | 1 | 2 |
念 +----|----+
模 1 | 3 | 4 |
型 +----|----+
3 | 5 | 0 |
+----|----+
物理模型
a[0][0] a[1][0] a[2][0]
+----|----|----|----|----|----+
| 1 | 2 | 3 | 4 | 5 | 0 |
+----|----|----|----|----|----+
a[0][1] a[1][1] a[2][1]

从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物理模型上看,这六个元素在存储器中仍然是连续存储的,就像一维数组一样,相当于把概念模型的表格一行一行接起来拼成一串,C语言的这种存储方式称为Row-major方式,而有些编程语言(例如FORTRAN)是把概念模型的表格一列一列接起来拼成一串存储的,称为Column-major方式。

多维数组也可以像嵌套结构体一样用嵌套Initializer初始化,例如上面的二维数组也可以这样初始化:

1
2
3
int a[][2] = { { 1, 2 },
{ 3, 4 },
{ 5, } };

注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长度

利用C99的新特性也可以做 Memberwise Initialization,例如:

1
int a[3][2] = { [0][1] = 9, [2][1] = 8 };

字符串

字符串可以看作一个数组,它的每个元素是字符型的,例如字符串"Hello, world.\n"图示如下:

1
2
3
+----|----|----|----|----|---------|----|----|----|----|----|----|----|----+
| h | e | l | l | o | , | | w | o | r | l | d | . | \n | \0 |
+----|----|----|----|----|----|----|----|----|----|----|----|----|----|----+

注意每个字符串末尾都有一个字符\0做结束符,这里的\0是ASCII码的八进制表示,也就是ASCII码为0的Null字符,在C语言中这种字符串也称为以零结尾的字符串(Null-terminated String)。数组元素可以通过数组名加下标的方式访问,而字符串字面值也可以像数组名一样使用,可以加下标访问其中的字符:

1
char c = "Hello, world.\n"[0];

初始化

字符数组也可以用一个字符串字面值来初始化:

1
char str[10] = "Hello";

相当于

1
char str[10] = { 'H', 'e', 'l', 'l', 'o', '\0' };

str的后四个元素没有指定,自动初始化为0,即Null字符。注意,虽然字符串字面值"Hello"是只读的,但用它初始化的数组str却是可读可写的。数组str中保存了一串字符,以’\0’结尾,也可以叫字符串。在本书中只要是以Null字符结尾的一串字符都叫字符串,不管是像str这样的数组,还是像"Hello"这样的字符串字面值。

如果用于初始化的字符串字面值比数组还长,比如:

1
char str[10] = "Hello, world.\n";

则数组str只包含字符串的前10个字符,不包含Null字符,这种情况编译器会给出警告。如果要用一个字符串字面值准确地初始化一个字符数组,最好的办法是不指定数组的长度,让编译器自己计算:

1
char str[] = "Hello, world.\n";

字符串字面值的长度包括Null字符在内一共15个字符,编译器会确定数组str的长度为15。

有一种情况需要特别注意,如果用于初始化的字符串字面值比数组刚好长出一个Null字符的长度,比如:

1
char str[14] = "Hello, world.\n";

则数组str不包含Null字符,并且编译器不会给出警告。

多维字符数组

多维字符数组也可以嵌套使用字符串字面值做Initializer,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
void print_day(int day)
{
char days[8][10] = { "", "Monday", "Tuesday",
"Wednesday", "Thursday", "Friday",
"Saturday", "Sunday" };
if (day < 1 || day > 7)
printf("Illegal day number!\n");
printf("%s\n", days[day]);
}
int main(void)
{
print_day(2);
return 0;
}

这个程序中定义了一个多维字符数组char days[8][10];,为了使1~7刚好映射到days[1]~days[7],我们把days[0]空出来不用,所以第一维的长度是8,为了使最长的字符串"Wednesday"能够保存到一行,末尾还能多出一个Null字符的位置,所以第二维的长度是10。

类型总结

C语言类型总结

C语言的类型分为函数类型、对象类型和不完全类型三大类。对象类型又分为标量类型和非标量类型。指针类型属于标量类型,因此也可以做逻辑与、或、非运算的操作数和if、for、while的控制表达式,NULL指针表示假,非NULL指针表示真。不完全类型是暂时没有完全定义好的类型,编译器不知道这种类型该占几个字节的存储空间,例如:

1
2
3
struct s;
union u;
char str[];

具有不完全类型的变量可以通过多次声明组合成一个完全类型,比如数组str声明两次:

1
2
char str[];
char str[10];

当编译器碰到第一个声明时,认为str是一个不完全类型,碰到第二个声明时str就组合成完全类型了,如果编译器处理到程序文件的末尾仍然无法把str组合成一个完全类型,就会报错。

不完全的结构体类型

不完全的结构体类型有重要作用:

1
2
3
4
5
6
7
struct s {
struct t *pt;
};
struct t {
struct s *ps;
};

struct s和struct t各有一个指针成员指向另一种类型。编译器从前到后依次处理,当看到struct s { struct t* pt; };时,认为struct t是一个不完全类型,pt是一个指向不完全类型的指针,尽管如此,这个指针却是完全类型,因为不管什么指针都占4个字节存储空间,这一点很明确。然后编译器又看到struct t { struct s *ps; };,这时struct t有了完整的定义,就组合成一个完全类型了,pt的类型就组合成一个指向完全类型的指针。由于struct s在前面有完整的定义,所以struct s *ps;也定义了一个指向完全类型的指针。

这样的类型定义是错误的:

1
2
3
4
5
6
7
struct s {
struct t ot;
};
struct t {
struct s os;
};

编译器看到struct s { struct t ot; };时,认为struct t是一个不完全类型,无法定义成员ot,因为不知道它该占几个字节。所以结构体中可以递归地定义指针成员,但不能递归地定义变量成员,你可以设想一下,假如允许递归地定义变量成员,struct s中有一个struct t,struct t中又有一个struct s,struct s又中有一个struct t,这就成了一个无穷递归的定义。

一个结构体的递归定义

以上是两个结构体构成的递归定义,一个结构体也可以递归定义:

1
2
3
4
struct s {
char data[6];
struct s* next;
};

当编译器处理到第一行struct s {时,认为struct s是一个不完全类型,当处理到第三行struct s *next;时,认为next是一个指向不完全类型的指针,当处理到第四行};时,struct s成了一个完全类型,next也成了一个指向完全类型的指针。类似这样的结构体是很多种数据结构的基本组成单元,如链表、二叉树等。

分析复杂的类型

可以想像得到,如果把指针和数组、函数、结构体层层组合起来可以构成非常复杂的类型。在分析复杂声明时,要借助typedef把复杂声明分解成几种基本形式

  • T *p;,p是指向T类型的指针。
  • T a[];,a是由T类型的元素组成的数组,但有一个例外,如果a是函数的形参,则相当于T *a;
  • T1 f(T2, T3...);,f是一个函数,参数类型是T2、T3等等,返回值类型是T1。

我们分解一下这个复杂声明:

1
int (*(*fp)(void *))[10];

  1. fp和*号括在一起,说明fp是一个指针,指向T1类型:

1
2
typedef int (*T1(void *))[10];
T1 *fp;

  1. T1应该是一个函数类型,参数是void *,返回值是T2类型:

1
2
3
typedef int (*T2)[10];
typedef T2 T1(void *);
T1 *fp;

  1. T2和*号括在一起,应该也是个指针,指向T3类型:

1
2
3
4
typedef int T3[10];
typedef T3 *T2;
typedef T2 T1(void *);
T1 *fp;

显然,T3是一个int数组,由10个元素组成。分解完毕。


  1. C99已经定义了复数类型Complex,位于C标准库的头文件complex.h

  2. 在 C99 后支持使用一种新的语法语法 Compound Literal,例如:z1 = (struct complex_struct){3.0, 4.0};