例如,如果用实部和虚部表示一个复数[1],我们可以写成由两个double型组成的结构体:
1 | struct complex_struct{ |
这一句定义了标识符complex_struct
(同样遵循标识符的命名规则),这种标识符在C语言中称为Tag,struct complex_struct { double x, y; }
整个可以看作一个类型名,就像int或double一样,只不过它是一个复合类型,如果用这个类型名来定义变量,可以这样写:
1 | struct complex_struct{ |
;
不能少。类型定义也是一种声明,声明都要以;
号结尾。
也可以在定义了complex_struct
后直接用普通的声明语句来声明 z1 和 z2:
1 | struct complex_struct z1, z2; |
每个复数变量都有两个成员(Member)x和y,可以用.
运算符(.
号,Period)来访问,这两个成员的存储空间是相邻的,合在一起组成复数变量的存储空间。看下面的例子:
1 |
|
注意上例中变量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 | double x = 3.0; |
注意,z1必须是局部变量才能用另一个变量x的值来初始化它的成员,如果是全局变量就只能用常量表达式来初始化。这也是C99的新特性,C89只允许在{}
中使用常量表达式来初始化,无论是初始化全局变量还是局部变量。
{}
这种语法不能用于结构体的赋值,例如这样是错误的 [2]:
1 | struct complex_struct z1; |
现在我们来实现一个完整的复数运算程序。在上一节我们已经定义了复数的结构体类型,现在需要围绕它定义一些函数。复数可以用直角坐标或极坐标表示,直角坐标做加减法比较方便,极坐标做乘除法比较方便。如果我们定义的复数结构体是直角坐标的,那么应该提供极坐标的转换函数,以便在需要的时候可以方便地取它的模和辐角。
1 |
|
可以看出,复数加减乘除运算的实现并没有直接访问结构体 complex_struct 的成员x和y,而是把它看成一个整体,通过调用相关函数来取它的直角坐标和极坐标。这样就可以非常方便地替换掉结构体 complex_struct 的存储表示,例如改为用极坐标来存储。
结构体也是一种递归定义:结构体的成员具有某种数据类型,而结构体本身也是一种数据类型。换句话说,结构体的成员可以是另一个结构体,即结构体可以嵌套定义。例如我们在复数的基础上定义复平面上的线段:
1 | struct Segment { |
嵌套结构体可以嵌套地初始化,例如:
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 | s.start.t = RECTANGULAR; |
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 | int main(void) |
枚举类型的对象的初始化或赋值,只能通过其枚举成员或同一枚举类型的其他对象来进行,例如:
1 | Points pt3d = point3d; //合法 |
注意把 3 赋给 Points 对象是非法的,即使 3 与一个 Points 枚举成员相关联。
可以对在上一节中的 complex_struct 进行改进,让其同时支持两种直角坐标和极坐标两种存储格式,方法是为 complex_struct 结构体添加一个枚举常量用作数据类型标志:
1 | enum coordinate_type {RECTANGULAR, POLAR}; |
如果数据类型标志为0,那么两个浮点数就表示直角坐标,如果数据类型标志为1,那么两个浮点数就表示极坐标。这样,直角坐标和极坐标的数据都可以适配(Adapt)到 complex_struct 结构体中,无需转换和损失精度。
数组(Array)也是一种复合数据类型,它由一系列相同类型的元素(Element)组成。
例如定义一个由4个int型元素组成的数组count:
1 | int count[4]; |
和结构体成员类似,数组count的4个元素的存储空间也是相邻的。
结构体成员可以是基本数据类型,也可以是复合数据类型,数组中的元素也是如此。根据组合规则,我们可以定义一个由4个结构体元素组成的数组:
1 | struct complex_struct { |
数组中的元素通过下标(或者叫索引,Index)来访问。例如前面定义的由4个int型元素组成的数组count图示如下:
1 | 0 1 2 3 |
整个数组占了4个int型的存储单元,存储单元用小方框表示,里面的数字是存储在这个单元中的数据(假设都是0),而框外面的数字是下标,这四个单元分别用count[0]、count[1]、count[2]、count[3]来访问。
这种数组下标的表达式不仅可以表示存储单元中的值,也可以表示存储单元本身,也就是说可以做左值,因此以下语句都是正确的:
1 | count[0] = 7; |
数组下标也可以是表达式,但表达式的值必须是整型的。例如:
1 | int i = 10; |
数组也可以像结构体一样初始化,未赋初值的元素也是用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 |
|
就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构成了多维数组(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 | 0 1 |
从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物理模型上看,这六个元素在存储器中仍然是连续存储的,就像一维数组一样,相当于把概念模型的表格一行一行接起来拼成一串,C语言的这种存储方式称为Row-major方式,而有些编程语言(例如FORTRAN)是把概念模型的表格一列一列接起来拼成一串存储的,称为Column-major方式。
多维数组也可以像嵌套结构体一样用嵌套Initializer初始化,例如上面的二维数组也可以这样初始化:
1 | int a[][2] = { { 1, 2 }, |
注意,除了第一维的长度可以由编译器自动计算而不需要指定,其余各维都必须明确指定长度。
利用C99的新特性也可以做 Memberwise Initialization,例如:
1 | int a[3][2] = { [0][1] = 9, [2][1] = 8 }; |
字符串可以看作一个数组,它的每个元素是字符型的,例如字符串"Hello, world.\n"图示如下:
1 | +----|----|----|----|----|---------|----|----|----|----|----|----|----|----+ |
注意每个字符串末尾都有一个字符\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 |
|
这个程序中定义了一个多维字符数组char days[8][10];,为了使1~7刚好映射到days[1]~days[7],我们把days[0]空出来不用,所以第一维的长度是8,为了使最长的字符串"Wednesday"能够保存到一行,末尾还能多出一个Null字符的位置,所以第二维的长度是10。
C语言的类型分为函数类型、对象类型和不完全类型三大类。对象类型又分为标量类型和非标量类型。指针类型属于标量类型,因此也可以做逻辑与、或、非运算的操作数和if、for、while的控制表达式,NULL指针表示假,非NULL指针表示真。不完全类型是暂时没有完全定义好的类型,编译器不知道这种类型该占几个字节的存储空间,例如:
1 | struct s; |
具有不完全类型的变量可以通过多次声明组合成一个完全类型,比如数组str声明两次:
1 | char str[]; |
当编译器碰到第一个声明时,认为str是一个不完全类型,碰到第二个声明时str就组合成完全类型了,如果编译器处理到程序文件的末尾仍然无法把str组合成一个完全类型,就会报错。
不完全的结构体类型有重要作用:
1 | struct s { |
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 | struct s { |
编译器看到struct s { struct t ot; };
时,认为struct t是一个不完全类型,无法定义成员ot,因为不知道它该占几个字节。所以结构体中可以递归地定义指针成员,但不能递归地定义变量成员,你可以设想一下,假如允许递归地定义变量成员,struct s中有一个struct t,struct t中又有一个struct s,struct s又中有一个struct t,这就成了一个无穷递归的定义。
以上是两个结构体构成的递归定义,一个结构体也可以递归定义:
1 | struct s { |
当编译器处理到第一行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 | typedef int (*T1(void *))[10]; |
1 | typedef int (*T2)[10]; |
1 | typedef int T3[10]; |
显然,T3是一个int数组,由10个元素组成。分解完毕。