第 6 章 派生数据类型
本章涵盖:
- 把对象组合成数组
- 把指针作为不透明类型使用
- 把对象组合成结构体
- 使用
typedef为类型提供新名称
C 中所有其他数据类型,都从我们已经认识的基本类型派生而来。派生数据类型有四种策略,其中两种称为“聚合数据类型”,因为它们把同种或多种其他数据类型的多个实例组合在一起:
数组:组合基本类型全部相同的项目(第 6.1 节)。
结构体:组合基本类型可以不同的项目(第 6.3 节)。
另外两种派生数据类型的策略更加复杂:
指针:引用内存中对象的实体。
指针远比其他概念复杂,我们将完整讨论留到第 11 章。在第 6.2 节,我们只把它当作不透明数据类型来讨论,甚至不会提及它真正的用途。
联合体:在同一个内存位置叠置基本类型不同的项目。
联合体要求更深入地理解 C 的内存模型,在程序员日常工作中的用途也不太多,因此到第 12.2 节才会介绍。
还有第五种策略,它为类型引入新名称:typedef(第 6.4 节)。与前四种不同,它不会在 C 类型系统中创建新类型,只为现有类型创建新名称。从这一点看,它类似于用 #define 定义宏,这也正是该关键字得名的缘由。
6.1 数组
数组让我们能够把同一类型的对象组合成一个封装对象。稍后会介绍指针类型(第 11 章),但许多刚接触 C 的人都会对数组和指针感到困惑,这完全正常。C 中的数组与指针关系密切;要解释它们,我们面临一个先有鸡还是先有蛋的问题。数组在许多语境中看起来像指针,而指针又引用数组对象。这里选择的介绍顺序或许不同寻常:我们先从数组开始,并尽可能长时间地只讨论数组,之后才引入指针。有些读者可能觉得这“错了”,但请记住,这里的一切陈述都应当以如同规则为基础来理解(要点 5.1.3 #3)。我们先用一种符合 C 对抽象状态机假设的方式描述数组。
要点 6.1 #1
数组不是指针。
稍后会看到这两个概念如何关联;目前,重要的是不带成见地阅读本节。否则,只会推迟你真正理解 C 的那一天。
6.1.1 数组声明
我们已经看到怎样声明数组:在另一个声明的末尾附加类似 [N] 的部分。例如:
double a[4];
signed b[N];2
这里,a 由四个 double 类型的子对象组成,b 则由 N 个 signed 类型的子对象组成。我们用一排基本类型方框来表示数组:
[0] [1] [2] [3]
a double ?? double ?? double ?? double ??
[0] [N-1]
b signed ?? · · · · · · · · · signed ??2
3
4
5
这里的点号“· · ·”表示两个方框之间可能有数量未知的同类项目。
组成数组的类型本身也可以是数组,从而形成“多维数组”。这类声明稍难阅读,因为 [] 向左结合。下面两个声明所声明对象的类型完全相同:
double C[M][N];
double (D[M])[N];2
C 和 D 都是由 M 个 double[N] 数组类型对象组成的数组。这意味着,要描述嵌套数组声明的结构,必须由内向外阅读:
[0] [M-1]
[0][0] [0][N-1] [M-1][0] [M-1][N-1]
C double ?? ··· double ?? ··· double ?? ··· double ??2
3
我们也已经看到怎样访问和初始化数组元素:同样使用一对 []。在前面的示例中,a[0] 是一个 double 对象,可以用于任何使用普通对象的位置。如前所述,C[0] 本身是一个数组,所以 C[0][0](与 (C[0])[0] 相同)同样是 double 类型的对象。
初始化器可以使用指派初始化器(也采用 [] 记法)选择初始化所作用的具体位置。清单 5.1 的示例代码就包含这类初始化器。在开发期间,即使数组大小或位置发生小幅变化,指派初始化器也有助于保持代码稳健。
6.1.2 数组操作
数组其实只是另一类对象,与我们目前见过的类型有所不同。
要点 6.1.2 #1
数组用于条件时,求值结果为 true。
这是数组退化操作的结果,稍后会介绍。另一项重要性质是,我们不能像求取其他对象那样求取数组的值。
要点 6.1.2 #2
存在数组对象,但不存在数组值。
因此,数组不能作为表 4.1 中值运算符的操作数,数组自身也没有定义任何算术操作。
要点 6.1.2 #3
数组不能比较。
数组也不能出现在表 4.2 中对象运算符的值侧。大多数对象运算符同样不能以数组为操作数:要么因为它们假定操作数可以参与算术运算,要么因为它们还有第二个值操作数,而该操作数也必须是数组。
要点 6.1.2 #4
不能向数组赋值。
根据表 4.2,只剩四个对象运算符能够作用于数组。我们已经认识运算符 []。[1] 稍后还会引入数组退化操作、取地址运算符 & 和 sizeof 运算符。
6.1.3 数组长度
数组分为两类:定长数组(FLA)和变长数组(VLA)。前者从 C 诞生之初就已存在,许多其他编程语言也有这一特性。后者由 C99 引入,在很大程度上为 C 所独有,并且在使用上受到一些限制。
要点 6.1.3 #1
VLA 只能使用默认初始化器。
要点 6.1.3 #2
不能在函数外声明 VLA。
因此,我们从另一个方向入手,看看哪些数组实际上属于 FLA,因而不受这些限制。
要点 6.1.3 #3
FLA 的长度由整数常量表达式或初始化器确定。
对于第一种情形,数组长度在编译期通过 ICE 确定(第 5.6.2 节已介绍)。ICE 不受类型限制:任何整数类型都可以。
要点 6.1.3 #4
数组长度说明必须严格为正。
还有一种重要特例会产生 FLA:根本不写长度说明。如果把 [] 留空,数组长度就由初始化器确定:
double E[] = { [3] = 42.0, [2] = 37.0, };
double F[] = { 22.0, 17.0, 1, 0.5, };2
这里,E 和 F 的类型都是 double[4]。即使不知道各项的值,这种初始化器的结构也总能在编译期确定,因此数组仍是 FLA:
[0] [1] [2] [3]
E double 0.0 double 0.0 double 37.0 double 42.0
[0] [1] [2] [3]
F double 22.0 double 17.0 double 1.0 double 0.52
3
4
5
其他所有数组对象声明都会产生 VLA。
要点 6.1.3 #5
如果长度不是整数常量表达式,数组就是 VLA。
VLA 的地位在 C 的历史中几经变化:它在 C99 中作为强制特性引入,到 C11 又变成可选特性。对于 C23,自动对象所用的 VLA 本身仍为可选特性(可以用宏 STDC_NO_VLA 查询),但 VLA 类型、指向它们的指针,进而 VLA 形参,又重新成为强制特性。
可以用 sizeof 运算符计算数组长度。该运算符给出任意对象的大小,[2] 因此简单相除即可得到数组长度。[3]
要点 6.1.3 #6
数组 A 的长度是 (sizeof A)/(sizeof A[0])。
也就是说,用整个数组对象的大小除以任意数组元素的大小。
6.1.4 数组作为形参
数组充当函数形参时,还会出现另一种特殊情况。从 printf 的原型可以看到,这类形参可能带有 [],看起来像数组。由于我们无法产生数组值(要点 6.1.2 #2),数组形参不能按值传递;因此,数组形参本身没有多少意义。正因如此,这类形参会丢失信息,其行为方式也可能出乎意料。
要点 6.1.4 #1
函数的数组形参会丢失最内层维度。
要点 6.1.4 #2
不要对函数的数组形参使用 sizeof 运算符。
要点 6.1.4 #3
数组形参表现得如同数组按引用传递。
遗憾的是,在当前层次,你只能先接受这些事实;等完整介绍指针后,才有可能说明背后的机制。
来看清单 6.1。
清单 6.1 具有数组形参的函数
#include <stdio.h>
void swap_double(double a[static 2]) {
auto tmp = a[0];
a[0] = a[1];
a[1] = tmp;
}
int main(void) {
double A[2] = { 1.0, 2.0, };
swap_double(A);
printf("A[0] = %g, A[1] = %g\n", A[0], A[1]);
}2
3
4
5
6
7
8
9
10
11
这里,swap_double(A) 会直接作用于数组 A,而不是作用于它的副本。因此,程序会交换 A 的两个元素值。
挑战 6:线性代数
数组最重要的一些应用问题来自线性代数。
在当前阶段,你能否编写执行向量与向量乘积,或者矩阵与向量乘积的函数?高斯消元如何?矩阵求逆的迭代算法又如何?
6.1.5 字符串较为特殊
我们已经多次遇到一种特殊数组;与其他数组不同,它甚至拥有字面量:字符串。
要点 6.1.5 #1
字符串是以 0 结尾的 char 数组。
也就是说,类似 "hello" 的字符串总比可见内容多一个元素,用来存放值 0;因此,这里的数组长度为 6。
与所有数组一样,不能向字符串赋值,但可以用字符串字面量初始化它们:
char jay0[] = "jay";
char jay1[] = { "jay" };
char jay2[] = { 'j', 'a', 'y', 0, };
char jay3[4] = { 'j', 'a', 'y', };2
3
4
这些声明完全等价。请注意,并非所有 char 数组都是字符串,例如:
char jay4[3] = { 'j', 'a', 'y', };
char jay5[3] = "jay";2
这两个数组都在字符 'y' 之后截断,因而不以 0 结尾:
[0] [1] [2] [3]
jay0 char 'j' char 'a' char 'y' char '\0'
jay1 char 'j' char 'a' char 'y' char '\0'
jay2 char 'j' char 'a' char 'y' char '\0'
jay3 char 'j' char 'a' char 'y' char '\0'
[0] [1] [2]
jay4 char 'j' char 'a' char 'y'
jay5 char 'j' char 'a' char 'y'2
3
4
5
6
7
8
9
前面已经在整数类型中简要见过字符串的基本类型 char。它是一种窄整数类型,可以编码“基本字符集”中的所有字符。这个字符集包含拉丁字母、阿拉伯数字,以及我们编写 C 代码时使用的标点符号。它通常不包含特殊字符(例如 ä、á),也不包含完全不同书写系统中的字符。
如今,绝大多数平台都用美国信息交换标准代码(ASCII)对 char 类型的字符编码。只要停留在基本字符集内,就无须了解具体编码如何工作:C 及其标准库会透明地使用这种编码完成一切。
为了处理 char 数组和字符串,标准库在头文件 <string.h> 中提供了许多函数。仅要求数组实参的函数,其名称以 mem 开头;额外要求实参是字符串的函数,则以 str 开头。清单 6.2 使用了后面介绍的部分函数。
作用于 char 数组的函数如下:
memcpy(target, source, len)可以把一个数组复制到另一个数组。必须已知两者是不同数组。第三个实参len指定要复制的字符数。memcmp(s0, s1, len)按字典序比较两个数组。它先扫描两个数组中恰好相等的起始片段,再返回最先出现的两个不同字符之差。如果直到len都没有发现不同元素,就返回 0。memchr(s, c, len)在数组s中搜索字符c。
接下来是字符串函数。
清单 6.2 使用若干字符串函数
#include <string.h>
#include <stdio.h>
int main(int argc, char* argv[argc+1]) {
size_t const len = strlen(argv[0]); // 计算长度
// 初始化后的 VLA,C23
// 用 0 字符终止数组
char name[len+1] = { };
// 复制程序名称
memcpy(name, argv[0], len);
if (!strcmp(name, argv[0])) {
printf("program name \"%s\" successfully copied\n",
name);
} else {
printf("copying %s leads to different string %s\n",
argv[0], name);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
strlen(s)返回字符串s的长度。这个值只是第一个 0 字符的位置,并不是数组长度。你有责任确保s确实是字符串,即它以 0 结尾。strcpy(target, source)的工作方式与memcpy类似。它只复制到源字符串末尾,因此不需要len形参。source同样必须以 0 结尾,target也必须足够大,能够容纳副本。strdup(source)和strndup(source, len)(自 C23 起)与strcpy类似,但会先为副本分配存储。到第 13.1 节,我们会介绍这种已分配存储如何工作。对于strdup,实参source同样必须以 0 结尾;strndup的要求略为宽松,因为该函数绝不会读取source中第len个字符以后的内容。strcmp(s0, s1)与memcmp类似,按字典序比较两个数组;但它可能不会考虑语言环境中的某些特殊性质,例如字符“ä”在字母表中的位置。比较会在s0或s1中遇到第一个 0 字符时停止。两个形参同样都必须以 0 结尾。strcoll(s0, s1)按字典序比较两个数组,并尊重特定语言的环境设置。第 8.7 节将说明怎样正确设置环境。strchr(s, c)与memchr类似,但字符串s必须以 0 结尾。strspn(s0, s1)返回s0起始片段的长度,该片段中的字符也都出现在s1中。strcspn(s0, s1)返回s0起始片段的长度,该片段中的字符均不出现在s1中。
要点 6.1.5 #2
把非字符串传给字符串函数会导致程序失效。
在实际程序中,这种误用的常见症状包括:
strlen或类似扫描函数长时间运行,因为始终遇不到 0 字符;- 这类函数试图访问数组对象边界后的元素,从而发生段错误;
- 数据看似随机地损坏,因为函数把数据写到了本不该写入的位置。
换言之,务必确保所有字符串名副其实——它们确实以 0 结尾。如果知道字符数组的长度,却不知道它是否以 0 结尾,可以用 memchr 和指针算术(见第 11 章)安全地替代 strlen。类似地,如果不能确定字符数组是字符串,最好使用 memcpy 复制。
练习 47
使用 memchr 和 memcmp 实现一个检查边界的 strcmp 版本。
此前我一直没有展示一个重要细节:这些函数的原型。字符串函数的原型可以写成:
size_t strlen(char const s[static 1]);
char* strcpy(char target[static 1], char const source[static 1]);
char* strdup(char const s[static 1]);
char* strndup(char const s[static 1], size_t n);
signed strcmp(char const s0[static 1], char const s1[static 1]);
signed strcoll(char const s0[static 1], char const s1[static 1]);
char* strchr(const char s[static 1], int c);
size_t strspn(const char s1[static 1], const char s2[static 1]);
size_t strcspn(const char s1[static 1], const char s2[static 1]);2
3
4
5
6
7
8
9
除了 strcpy、strchr、strdup 和 strndup 那些古怪的返回类型以外,这些声明看起来合情合理。[4] 形参数组的长度未知,所以 [static 1] 对应至少含有一个 char 的数组。strlen、strspn 和 strcspn 返回大小;strcmp 则根据实参的排序次序返回负值、0 或正值。
看到数组函数的声明时,情况就没那么直观了:
void* memcpy(void* target, void const* source, size_t len);
signed memcmp(void const* s0, void const* s1, size_t len);
void* memchr(const void *s, int c, size_t n);2
3
你还不了解那些指定为 void* 的实体。它们是指向未知类型对象的指针。直到第 2 层的第 11 章,我们才会看到这些新的指针和 void 类型概念为何出现、怎样工作。
挑战 7:邻接矩阵
图 G 的邻接矩阵是一个矩阵 A:如果从结点 i 到结点 j 存在一条弧,元素 A[i][j] 就保存 true,否则保存 false。
在当前阶段,你能否使用邻接矩阵对图 G 执行广度优先搜索?能否找到连通分量?能否找到生成树?
挑战 8:最短路径
把图 G 的邻接矩阵思想扩展为距离矩阵 D,其中保存从点 i 到点 j 的距离。用一个非常大的值(例如 SIZE_MAX)标记不存在直接弧。
给定作为输入的两个结点 x 和 y,你能否找到它们之间的最短路径?
6.2 作为不透明类型的指针
现在,我们已经看到指针概念在许多地方出现,尤其是作为 void* 实参和返回类型出现,也作为 char const*const 出现,用来操纵字符串字面量的引用。指针的主要性质是:它不直接包含我们感兴趣的信息,而是引用或指向数据。C 的指针语法总带着那个独特的 *:
char const*const p2string = "some text";它可以表示为:
p2string char const*const
↓
"some text"2
3
请把它与前面的数组 jay0 比较;jay0 自身包含它所表示字符串的全部字符:
char jay0[] = "jay"; [0] [1] [2] [3]
jay0 char 'j' char 'a' char 'y' char '\0'2
在初步探索指针时,我们只需要知道它的几项简单性质。指针的二进制表示完全由平台决定,与我们无关。
要点 6.2 #1
指针是不透明对象。
这意味着,我们只能通过 C 语言允许的操作处理指针。如前所述,大多数操作以后才会引入;在初步使用中,只需要初始化、赋值和求值。
指针的状态是它区别于其他对象的一项特殊性质。
要点 6.2 #2
指针可能有效、为空,也可能无效。
例如,对象 p2string 始终有效,因为它指向字符串字面量 "some text"。由于第二个 const,这一关联永远不能改变。
要点 6.2 #3
以 nullptr 初始化指针或向指针赋予 nullptr,会使指针为空。
以下代码就是一个示例:
char const*const p2nothing = nullptr;我们把这种特殊情形表示为:
p2nothing char const*const
↓
✖2
3
请注意,这不同于指向空字符串:
char const*const p2empty = "";p2empty char const*const
↓
""2
3
通常,我们把处于空状态的指针称为”空指针”。令人惊讶的是,能够处理空指针本身就是一项有用的特性。
要点 6.2 #4
在逻辑表达式中,空指针求值为 false。
请注意,这种测试无法区分有效指针与无效指针。因此,指针真正“糟糕”的状态是无效,因为这种状态不可观察。
要点 6.2 #5
无效指针会导致程序失效。
无效指针的示例可能如下:
char const*const p2invalid;p2invalid char const*const
↓2
由于它未初始化,所以具有不确定表示。对它进行任何求值都会产生无效值,并使程序进入未定义状态(要点 5.7.5 #2)。因此,如果无法确保指针有效,至少必须确保将其设为空指针。
要点 6.2 #6
始终初始化指针。
6.3 结构体
如前所述,数组把若干基本类型相同的对象组合成更大的对象。当我们需要组合的信息适合采用第一项、第二项……这样的次序时,这十分合理。如果不适合,或者需要组合类型不同的对象,就应使用由关键字 struct 引入的结构体。
6.3.1 通过名称访问字段的简单结构体
第一个示例重新考察第 5.6.2 节的鸦科鸟类。那里,我们借助枚举类型这一技巧,跟踪 name 数组各元素的解释。C 结构体提供了一种更加系统的方式:给聚合中的成员(或字段)命名。
struct birdStruct {
char const* jay;
char const* magpie;
char const* raven;
char const* chough;
};
struct birdStruct const aName = {
.chough = "Henry",
.raven = "Lissy",
.magpie = "Frau",
.jay = "Joe",
};2
3
4
5
6
7
8
9
10
11
12
第 1 至 6 行声明了一个新类型,以 struct birdStruct 表示。这个结构体有四个成员,其声明看起来与普通对象声明完全相同。这里不再声明四个绑定于数组中的元素,而是为不同成员命名,并分别声明其类型。这样的结构体类型声明只说明类型;它尚未声明该类型的对象,更没有定义这种对象。
从第 7 行开始,我们声明并定义新类型的对象 aName。在初始化器和后续使用中,各成员都以带点号(.)的记法指代。对于数组,我们在第 5.6.1 节使用 bird[raven];对于结构体,则使用 aName.raven:
aName
struct birdStruct
.jay .magpie .raven .chough
char const* char const* char const* char const*
↓ ↓ ↓ ↓
"Joe" "Frau" "Lissy" "Henry"2
3
4
5
6
请注意,在这个示例中,各成员仍然只是引用字符串。例如,成员 aName.magpie 引用位于方框外部的实体 "Frau",它并不算作结构体自身的一部分。
第二个示例考察一种组织时间戳的方式。日历时间采用复杂的计数方式,涉及年、月、日、分、秒;月份和年份等不同时段可能长度不同,等等。要组织这种时间戳所需的九项不同数据(见后文),一种可能的方式是使用数组:
typedef int calArray[9]; [0] [1] [3] [4] [8]
calArray int ?? int ?? int ?? int ?? ··· int ??2
这种数组类型的用法会有歧义:年份究竟存入元素 [0],还是元素 [5]?为了避免歧义,可以再次使用枚举技巧。但 C 标准选择了另一种方式,在 <time.h> 中使用了类似下面的结构体:
struct tm {
int tm_sec; // 分钟后的秒数 [0, 60]
int tm_min; // 小时后的分钟数 [0, 59]
int tm_hour; // 午夜后的小时数 [0, 23]
int tm_mday; // 一月中的日期 [1, 31]
int tm_mon; // 一月后的月数 [0, 11]
int tm_year; // 1900 年后的年数
int tm_wday; // 星期日后的日数 [0, 6]
int tm_yday; // 一月一日后的日数 [0, 365]
int tm_isdst; // 夏令时标志
};2
3
4
5
6
7
8
9
10
11
这个结构体拥有具名成员,例如表示秒的 tm_sec 和表示年份的 tm_year。要编码一个日期——例如写作本文的日期:
终端
> LC_TIME=C date -u
Wed Apr 3 10:00:47 UTC 20192
相对简单:
struct tm today = {
.tm_year = 2019-1900,
.tm_mon = 4-1,
.tm_mday = 3,
.tm_hour = 10,
.tm_min = 0,
.tm_sec = 47,
};2
3
4
5
6
7
8
这会创建一个 struct tm 类型的对象,并用适当值初始化其成员。成员在结构体中的顺序或位置通常并不重要:只需使用前置点号 . 的成员名,就足以指定相应数据应当放到哪里。
.tm_sec .tm_min .tm_hour .tm_mday .tm_isdst
today struct tm int 47 int 0 int 10 int 3 ··· int 02
请注意,与 calArray 相比,today 的示意图多了一个”方框”。实际上,真正的 struct 类型会创建额外的抽象层。这个 struct tm 是 C 类型系统中的真正类型。
访问结构体成员同样简单,使用类似的点号语法:
printf("this year is %d, next year will be %d\n",
today.tm_year+1900, today.tm_year+1900+1);2
today.tm_year 这样的成员引用,可以像相同基本类型的任何对象一样出现在表达式中。
struct tm 还有三个成员没有在初始化器列表中提到:tm_wday、tm_yday 和 tm_isdst。由于没有提到它们,它们会自动置为 0。
要点 6.3.1 #1
结构体初始化器中省略的成员会被强制初始化为 0。
甚至可以走到极端,一个成员也不显式初始化。前面已经看到(要点 5.5 #3),有一种适用于所有数据类型的默认初始化器:{}。
因此,像这里这样初始化 struct tm 时,数据结构并不一致:成员 tm_wday 和 tm_yday 的值,并不与其余成员对应。可以用类似下面的函数,为该成员设置与其他成员一致的值:
struct tm time_set_yday(struct tm t) {
// tm_mdays 从 1 开始。
t.tm_yday += DAYS_BEFORE[t.tm_mon] + t.tm_mday - 1;
// 处理闰年
if ((t.tm_mon > 1) && leapyear(t.tm_year+1900))
++t.tm_yday;
return t;
}2
3
4
5
6
7
8
它使用当前月份之前各月的天数、成员 tm_mday,以及闰年可能需要的修正,计算一年中的第几天。这个函数有一项对当前层次十分重要的特征:它只修改函数形参 t 的成员,不修改原始对象。
要点 6.3.1 #2
struct 形参按值传递。
要保留这些改变,必须把函数结果重新赋给原对象:
today = time_set_yday(today);稍后介绍指针类型时,我们会看到函数怎样克服这一限制;目前还没有走到那里。这里可以看到,赋值运算符 = 对所有结构体类型都有良好定义。遗憾的是,它在比较方面的对应运算符却没有定义。
要点 6.3.1 #3
结构体可以赋值。
要点 6.3.1 #4
结构体不能使用 == 或 != 比较。
清单 6.3 给出使用 struct tm 的完整示例代码。其中没有声明历史悠久的 struct tm,因为标准头文件 <time.h> 已经提供了它。如今,人们大概会为各成员选择不同类型;但在 C 中,我们经常不得不沿用多年前作出的设计决定。
要点 6.3.1 #5
结构体布局是一项重要的设计决策。
数年以后,你可能会后悔当初的设计,因为所有使用该设计的现有代码,会让它几乎无法适应新的需求。
清单 6.3 操纵 struct tm 的示例程序
#include <time.h>
#include <stdbool.h>
#include <stdio.h>
bool leapyear(unsigned year) {
/* 所有能被 4 整除的年份都是闰年,
除非它们开启新世纪;
但能被 400 整除时仍是闰年。 */
return !(year % 4) && ((year % 100) || !(year % 400));
}
#define DAYS_BEFORE \
(int const[12]){ \
[0] = 0, [1] = 31, [2] = 59, [3] = 90, \
[4] = 120, [5] = 151, [6] = 181, [7] = 212, \
[8] = 243, [9] = 273, [10] = 304, [11] = 334, \
}
struct tm time_set_yday(struct tm t) {
// tm_mdays 从 1 开始。
t.tm_yday += DAYS_BEFORE[t.tm_mon] + t.tm_mday - 1;
// 处理闰年
if ((t.tm_mon > 1) && leapyear(t.tm_year+1900))
++t.tm_yday;
return t;
}
int main(void) {
struct tm today = {
.tm_year = 2019-1900,
.tm_mon = 4-1,
.tm_mday = 3,
.tm_hour = 10,
.tm_min = 0,
.tm_sec = 47,
};
printf("this year is %d, next year will be %d\n",
today.tm_year+1900, today.tm_year+1900+1);
today = time_set_yday(today);
printf("day of the year is %d\n", today.tm_yday);
}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
6.3.2 包含不同类型字段的结构体
struct 的另一种用途,是把类型不同的对象组合进一个更大的封装对象。C 标准已经为纳秒粒度的时间操作作出了这样的选择:
struct timespec {
time_t tv_sec; // 整秒数 ≥ 0
long tv_nsec; // 纳秒数 [0, 999999999]
};2
3
4
.tv_sec .tv_nsec
struct timespec time_t ?? long ??2
这里,秒数采用表 5.2 中见过的不透明类型 time_t,纳秒数则采用 long。[5] 这种选择同样出于历史原因;如今选取的类型也许会略有不同。要计算两个 struct timespec 时间之差,可以轻松定义一个函数。
difftime 函数是 C 标准的一部分,但这里所需的功能非常简单,也不依赖平台特有性质。因此,任何需要它的人都可以轻松实现。
练习 50
编写函数 timespec_diff,计算两个 timespec 值之差。
6.3.3 嵌套结构体
除 VLA 外,任何数据类型都可以作为结构体成员。因此,结构体也可以嵌套:某个 struct 的成员可以再次采用(另一个)struct 类型,较小的内层结构体甚至可以在较大的结构体内部声明:
struct person {
char name[256];
struct stardate {
struct tm date;
struct timespec precision;
} bdate;
};2
3
4
5
6
7
图 6.1 给出结构视图。其中灰色方框对应可能存在的填充;下一节将介绍这个概念。
图 6.1 结构体布局
struct person
├─ .name
│ └─ char[256]
│ ├─ [0] char ??
│ ├─ ···
│ └─ [255] char ??
├─ (可能的填充)
└─ .bdate:struct stardate
├─ .date:struct tm
│ ├─ .tm_sec int ??
│ ├─ .tm_min int ??
│ ├─ ···
│ └─ .tm_isdst int ??
├─ (可能的填充)
└─ .precision:struct timespec
├─ .tv_sec time_t ??
├─ .tv_nsec long ??
└─ (可能的填充)2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
与 C++ 等语言不同,struct stardate 声明的可见性与 struct person 相同。struct 自身(这里是 person)不会为最外层 struct 声明的 {} 内所定义的另一个 struct(这里是 stardate)创建新作用域。也就是说,如果这些嵌套的 struct 声明出现在全局位置,两个结构体随后都对整个 C 文件可见。如果它们出现在函数体内,可见性就受包含它们的 {} 复合语句约束。
要点 6.3.3 #1
嵌套声明中的所有 struct 声明具有相同的可见作用域。
因此,更合适的版本如下:
struct stardate {
struct tm date;
struct timespec precision;
};
struct person {
char name[256];
struct stardate bdate;
};2
3
4
5
6
7
8
这个版本把所有结构体放在同一层,因为它们最终无论如何都会处于同一层。不过,它并不会改变前面给出的结构视图:struct person 的布局和语义与先前完全相同。
6.3.4 合并结构体字段
我们已经看到,编译器按照结构体字段的定义次序把它们放入存储。如果字段大小不同,编译器可能希望把它们放在结构体的特定位置。这样做的主要原因是便于访问字段:存储被组织为包含若干字节的字,一个新字段从这样的字边界开始可能更好。这项特性称为“对齐”,我们会在第 12.7 节讨论。
对齐可能在任意字段后产生一些浪费的空间,称为“字节填充”,即图 6.1 中 person 类型示意图里的灰色区域。如果存在这种填充,它一定由一个或若干字节组成。
要点 6.3.4 #1
任意结构体成员之后都可能存在填充。
要点 6.3.4 #2
结构体开头没有填充。
减少填充字节浪费的一种方式,是选择特定的成员次序。
练习 51
为三个结构体内部字段的每一种排列次序分别创建结构体类型,共六种;三个字段分别为一个 unsigned char、一个 unsigned 和一个 unsigned long long。打印这六种结构体的大小,它们应当有显著差异。
把各成员大小相加,计算理论上的最小大小。是否有某个结构体恰好达到这一大小?哪个结构体最接近这个理想大小?
结构体中另一种位与字节浪费,可能源自低效使用。请回想,我们曾用 unsigned 值表示鸟的集合。实际上只需要这种 unsigned 中的 4 位,其他所有位都被浪费。这种现象称为“位填充”:与前面的字节填充不同,它通常不落在字节边界上,甚至可以按单个位计算。
预定义结构体 tm 中的这种浪费相当严重。例如,tm_sec 字段只有 61 个可能值,因此只需 6 位存储,而 int 成员至少占 16 位。C 传统上有一种称为“位域”的机制,可以减少结构体成员所占位数。
以下代码含有错误,请勿使用
struct tib {
int tib_sec :6; // 分钟后的秒数 [0, 60]
int tib_min :6; // 小时后的分钟数 [0, 59]
int tib_hour :5; // 午夜后的小时数 [0, 23]
int tib_mday :5; // 一月中的日期 [1, 31]
int tib_mon :4; // 一月后的月数 [0, 11]
int tib_year; // 1900 年后的年数
int tib_wday :3; // 星期日后的日数 [0, 6]
int tib_yday :9; // 一月一日后的日数 [0, 365]
int tib_isdst:1; // 夏令时标志
};2
3
4
5
6
7
8
9
10
11
也就是说,在成员声明末尾用 : 分隔,写出所需位数。这里表示:为所有位域至少需要 39 位(再加上 int 所需的 sizeof(int) * CHAR_BIT 位),才能表示我们感兴趣的全部值。随后,由编译器把连续位域合并进较大的单元,以组织结构体。
一种常见布局是:把前五个字段的 28 位组合进一个 int 大小的单元;把 tib_year 放在单独的 int 中;再用一个单元存放最后 13 位。与 9*sizeof(int) 相比,这种方案只使用 3*sizeof(int),空间缩小至三分之一。
所有这些内容用起来仍然很方便。字段的使用方式大体不变。例如,成员指示符 x.tib_year 可以像 struct tm 中对应成员一样用于表达式或赋值;形如 .tib_mon = 3 的指派初始化器也会按预期工作。
不过,前面给出的传统位域有一些缺点,因而示例代码可能是错误的。第一,与其他所有声明不同,为位域指定 int 时,实际对应的类型可能有符号,也可能无符号。在某些体系结构上,这里的 int 实际表示 unsigned,代码确实正确:所有标出的值都能存入相应字段。在(大多数)其他体系结构上,字段有符号,我们就缺少一位来表示大多数字段。
例如,如果字段 tib_mday 有 5 位且带符号,它只能容纳 −16 … 15。赋值 x.tib_mday = 31 的值超出该范围;编译器可能为存储操作选择位模式相同的对应值 −1。随后读取这个值时,它会解释为负数,所有日期计算都会出错。
可以重新审视说明,从而规避这项设计缺陷。一种方式是在所有说明中增加一个符号位。但这样一来,前五个字段已经需要 33 位,在大多数体系结构上无法存入同一个单元。另一种方式是让所有位域都使用 unsigned,这也是我推荐的方案。
要点 6.3.4 #3
指定一个位域时,不要单独使用 int。
C23 以前的位域还有第二项缺点:位域在表达式(例如 x.tib_mday)中最终采用什么类型,标准并未作出充分规定,目前不同编译器的处理也不一致。在当前层次上,我们还无法观察这一点;但很久以后,当第 18 章尝试为声明或类型泛型函数调用推导类型时,它可能突然带来麻烦。
随着 _BitInt 类型引入,如今有了一种新选择:
struct tbi {
unsigned _BitInt(6) tbi_sec :6; // 分钟后的秒数 [0, 60]
unsigned _BitInt(6) tbi_min :6; // 小时后的分钟数 [0, 59]
unsigned _BitInt(5) tbi_hour :5; // 午夜后的小时数 [0, 23]
unsigned _BitInt(5) tbi_mday :5; // 一月中的日期 [1, 31]
unsigned _BitInt(4) tbi_mon :4; // 一月后的月数 [0, 11]
signed tbi_year; // 1900 年后的年数
unsigned _BitInt(3) tbi_wday :3; // 星期日后的日数 [0, 6]
unsigned _BitInt(9) tbi_yday :9; // 一月一日后的日数 [0, 365]
bool tbi_isdst:1; // 夏令时标志
};2
3
4
5
6
7
8
9
10
11
这里,所有位域都精确采用指定类型,行为完全符合类型的语义。显式指定为无符号类型的那些字段,会依照无符号类型的规则表现。例如,测试:
x.tbi_min < 60足以判断该字段是否位于有效范围内。
要点 6.3.4 #4
宽度为 N 的数值位域应使用 _BitInt(N) 类型。
不过请注意,_BitInt 类型参与算术时的规则,可能与 signed int 略有不同。
字段 tbi_isdst 建模为 bool;任何充当标志的字段都应这样指定。
要点 6.3.4 #5
宽度为 1 的标志位域应使用 bool 类型。
6.4 类型的新名称:类型别名
上一节已经看到,结构体不仅把不同信息聚合进一个单元,还为这个聚合体引入了一个新类型名。又是出于历史原因,我们为结构体引入的名称始终必须带有前置关键字 struct,使用起来有些笨拙。许多 C 初学者还会忘记 struct 关键字,从而被编译器抛出的晦涩错误弄得一头雾水。
有一种通用工具可以提供帮助:typedef 为已经存在的类型提供一个符号名称。借助它,一个类型可以具有多个名称;我们甚至可以复用结构体声明中使用的标记名:
typedef struct birdStruct birdStructure;
typedef struct birdStruct birdStruct;2
于是,struct birdStruct、birdStruct 和 birdStructure 可以互换使用。我最喜欢的是下面这种惯用写法:
typedef struct birdStruct birdStruct;
struct birdStruct {
...
};2
3
4
也就是说,在正式的 struct 声明之前,使用完全相同的名称编写一个 typedef。这种写法之所以成立,是因为 struct 与后续名称组合时,标记始终有效,它构成结构体的前置声明。
要点 6.4 #1
在 typedef 中前置声明 struct 时,应使用与标记名相同的标识符。
C++ 默认采用类似做法,因此这项策略会让有 C++ 背景的读者更容易阅读你的代码。
typedef 机制也可用于结构体以外的类型。对于数组,可以写成:
typedef double vector[64];
typedef vector vecvec[16];
vecvec A;
typedef double matrix[16][64];
matrix B;
double C[16][64];2
3
4
5
6
这里,typedef 只为现有类型引入新名称,因此 A、B 和 C 的类型完全相同,都是 double[16][64]。
要点 6.4 #2
typedef 只为类型创建别名,绝不会创建新类型。
C 标准内部也大量使用 typedef。第 5.2 节所见的 size_t 等语义整数类型,就是用这一机制声明的。标准经常为 typedef 使用以 _t 结尾的名称。这项命名约定可以确保标准升级版引入此类名称时,不会与现有代码冲突。因此,你不应在自己的代码中引入这样的名称。
要点 6.4 #3
以 _t 结尾的标识符名称是保留名称。
小结
- 数组把若干基本类型相同的值组合进一个对象。
- 指针要么引用其他对象,要么为空,要么无效。
- 结构体把基本类型不同的值组合进一个对象。
typedef为现有类型提供新名称。