第16章:C 预处理器和 C 库
预处理器负责在编译前组织源码,标准库提供已经定义好接口的常用能力。两者经常一起使用,但工作层次不同:宏操作预处理记号,函数接收运行时的参数值,编译器根据 C 类型与语义检查最终代码。
本章可以沿着三条线整理:用预处理控制代码,用类型与函数表达操作,用标准库完成通用任务。 以下示例以 C99/C11,尤其是 C11 为背景。
1. 从源码到程序:先分清处理阶段
1.1 宏展开之前已经发生一些文本处理
理解常见行为时,可以使用下面的简化流程:
源字符处理
↓
删除反斜杠紧接换行形成的续行
↓
识别预处理记号,注释替换为空格
↓
处理 #include、宏展开与条件编译
↓
后续翻译、类型检查、代码生成与链接
完整标准规定了更细的翻译阶段;这里的重点是,注释、续行和宏不是运行时才执行的功能。
int/* comment */count;
注释被替换为空格后,相当于 int count;,不会把两边直接黏成一个新名字。
1.2 预处理指令占据一条逻辑行
#define MESSAGE "first " \
"second"
反斜杠必须直接接换行,才能把物理行接成逻辑行。不要在续行反斜杠后放普通空格或注释。
字符串较长时,也可以利用相邻字符串字面量在后续翻译阶段拼接的规则,而不把代码都挤进一行。
指令前可以有空白,# 与指令名之间也可以有适当空白。普通代码风格通常让指令从行首开始,便于辨认。
1.3 宏按记号工作,不是任意子串替换
#define SIZE 8
普通代码中的 SIZE 可以展开,但 BUFFER_SIZE 不会因为含有这个字母片段而局部展开,"SIZE" 内的字符也不会直接变成 "8"。
2*3 与 2 * 3 都可以被识别为数字、运算符、数字三个预处理记号;不需要靠空格才能识别 *。宏定义的相同性还涉及记号之间的空白分隔规则,不宜仅靠改变空格来重定义宏。
2. 对象式宏:给替换列表命名
#define CAPACITY 32
#define PROMPT "name: "
#define FEATURE_ENABLED
宏可以代表数值、字符串、代码片段,也可以拥有空替换列表。定义本身通常不带结尾分号:
int values[CAPACITY];
如果把宏写成 #define CAPACITY 32;,分号也会一起展开,可能破坏声明或表达式。
2.1 展开不等于提前算出数值
#define BASE 4
#define LIMIT (BASE * 2)
使用 LIMIT 时,会展开其中的宏,形成相应记号。普通表达式的类型判断与常量求值由后续翻译完成。
预处理器也有自己的 #if 表达式求值功能,但这不意味着它会替普通宏定义自动做所有算术。
2.2 宏、枚举常量与 const 各有用途
| 形式 | 特点 | 常见用途 |
|---|---|---|
#define COUNT 8 |
预处理替换,不创建数据对象 | 条件编译、配置、替换记号 |
enum { COUNT = 8 }; |
整型常量表达式中的有名常量 | 数组固定长度、状态与数量 |
const int count = 8; |
有类型的只读对象 | 按作用域组织只读数据 |
在 C99/C11 中,普通 const int 对象一般不能代替整型常量表达式用于所有固定数组或预处理条件。它与 C++ 的常见规则不同。
2.3 宏不遵循普通花括号作用域
宏从定义处开始影响后面的预处理,直到被 #undef 取消或相应翻译单元处理结束;写在函数花括号里面,也不会因此自动变成局部宏。
#define LIMIT 8
#undef LIMIT
#define LIMIT 16
需要改变定义时,先 #undef,避免依赖实现如何处理不相同的重复定义。取消一个尚未定义的宏是允许的。
3. 函数式宏:语法像调用,行为是替换
3.1 定义时名字与左括号之间不能插空格
#define SQUARE(x) ((x) * (x))
这定义函数式宏。若写成 #define SQUARE (x) ...,则是名为 SQUARE 的对象式宏,其替换列表从 (x) 开始。
宏的参数是记号序列,不是已经计算好的数值。
3.2 参数和整个表达式都要保护优先级
#define BAD_SQUARE(x) x * x
#define SQUARE(x) ((x) * (x))
| 使用方式 | 不完整保护造成的展开问题 |
|---|---|
BAD_SQUARE(2 + 3) |
变成 2 + 3 * 2 + 3,不是 25 |
100 / BAD_SQUARE(2) |
变成 100 / 2 * 2,不是 25 |
给参数加括号避免传入表达式被拆开;给整个替换表达式加括号避免外部运算改变组合方式。
3.3 括号不能消除重复求值
#define SQUARE(x) ((x) * (x))
// SQUARE(++value) 会把递增操作展开两次,不能这样调用。
同一对象的两次修改在这里没有适当排序,产生未定义行为,不能把某次运行得到的 42、49 或某个最终值当作语言保证。
#define MAX_VALUE(a, b) ((a) > (b) ? (a) : (b))
这个宏也会在比较后再次求值被选中的参数。即使没有未定义行为,也可能造成额外输入、额外函数调用或错误更新。
表达式宏的参数应避免包含递增、赋值、输入以及依赖调用次数的函数等副作用。需要普通运算时,函数或 static inline 通常更容易保证参数只求值一次。
3.4 宏参数的逗号也有语法边界
宏调用中,括号层次之外的逗号用来分隔参数。函数调用内部有配对圆括号,因此 MACRO(function(a, b)) 可以是单个参数。
花括号本身不会替预处理器保护逗号。传入带逗号的复合字面量等表达式时,需要合适的额外圆括号,不能只按后续 C 语法推断宏参数数量。
4. # 字符串化与 ## 记号拼接
4.1 字符串化把参数写法变成文本
#define PRINT_INT(expr) (printf(#expr " = %d\n", (expr)))
PRINT_INT(2 + 3) 会打印表达式文本和计算值。#expr 与普通字符串中的 "expr" 不同,后者只是固定单词。
字符串化会按相应规则处理空白和转义,并不会对原表达式求值。上面的宏只在实际 printf 参数位置求值一次,并约定该表达式结果适合 %d。
4.2 拼接必须产生合法预处理记号
#define JOIN_RAW(a, b) a ## b
JOIN_RAW(item_, 4) 得到 item_4。## 不是连接运行时字符串,不能拿来把两个字符串变量的内容拼起来。
4.3 两层宏控制是否先展开参数
直接参与 # 或 ## 的参数,不会先按普通参数方式展开。需要先展开再字符串化或拼接时,增加一层:
#include <stdio.h>
#define PROJECT_COUNT 4
#define STRING_RAW(x) #x
#define STRING(x) STRING_RAW(x)
#define JOIN_RAW(a, b) a ## b
#define JOIN(a, b) JOIN_RAW(a, b)
#define PRINT_INT(expr) (printf(#expr " = %d\n", (expr)))
int main(void)
{
int JOIN(item_, PROJECT_COUNT) = 12;
puts(STRING_RAW(PROJECT_COUNT));
puts(STRING(PROJECT_COUNT));
printf("item_4 = %d\n", item_4);
PRINT_INT(2 + 3);
return 0;
}
输出:
PROJECT_COUNT
4
item_4 = 12
2 + 3 = 5
外层先让 PROJECT_COUNT 展开为 4,内层再处理 # 或 ##。理解这两步,比死记一个复杂宏更可靠。
5. 变参宏与多语句宏
5.1 ... 与 __VA_ARGS__
#define PRINT(...) printf(__VA_ARGS__)
省略号必须位于宏参数列表末尾,__VA_ARGS__ 表示对应的可变部分。
PRINT("ready\n");
PRINT("count = %d\n", count);
变参宏只是在预处理阶段转交参数记号,不会替最终调用验证格式与实参类型是否匹配。
5.2 多语句宏包装成一条语句
#define LOG(...) do { \
fprintf(stderr, "%s:%d: ", __FILE__, __LINE__); \
fprintf(stderr, __VA_ARGS__); \
fputc('\n', stderr); \
} while (0)
调用时写 LOG("ready"); 或 LOG("count = %d", count);。固定格式字符串仍由调用者正确提供,不能把不可信外部文本直接当作格式。
do { ... } while (0) 让宏在 if ... else 等位置表现为一条语句:
if (failed)
LOG("operation failed");
else
continue_work();
不要在宏定义末尾额外附带调用者的分号。这个包装也不让宏变成真正函数,内部名字、break、continue 等仍可能影响使用方式。
5.3 省略参数可能留下多余逗号
#define LOG_FORMAT(format, ...) fprintf(stderr, format, __VA_ARGS__)
在本章的标准背景下,不能假定只传一个 format 就会自动移除后面的逗号。上面的纯变参 LOG(...) 形式让格式串本身属于可变部分,可以处理只有一个格式参数的常见用法。
6. 头文件:共享接口,不是自动提供链接结果
6.1 两种包含形式
#include <stdio.h>
#include "project_types.h"
尖括号形式通常用于实现提供的头文件,双引号形式通常先按实现规定的本地规则查找。具体搜索目录和顺序取决于编译器配置,不能把某个 IDE 的行为当成标准目录模型。
#include 在预处理层面引入相应内容,不等于运行时打开那个文件,也不等于把某个 .c 的函数实现自动链接进程序。
6.2 头文件通常放什么
| 内容 | 目的 |
|---|---|
| 类型定义、结构定义 | 让多个翻译单元理解相同接口 |
| 函数原型 | 让编译器检查调用 |
| 必要的宏与配置 | 提供编译期信息 |
extern 对象声明 |
引用一处定义的共享对象 |
合适的 static inline 定义 |
提供小型内部函数实现 |
普通外部函数定义、需要唯一存储的全局对象定义,通常放在 .c 中。不能因为有包含保护,就在头文件里随意定义同一个外部对象供多个源文件包含。
6.3 包含保护限制同一翻译单元中的重复处理
#ifndef PROJECT_TYPES_H
#define PROJECT_TYPES_H
#include <stddef.h>
struct sample {
const double *values;
size_t count;
};
double sample_total(const struct sample *sample);
#endif
第一次包含时定义保护宏并处理内容;再次包含时跳过正文。保护名字应在项目中足够独特,避免占用标准或实现保留的命名空间。
它不会阻止不同翻译单元分别处理这个头文件,也不会替代“一处定义,其他地方声明”的链接规则。
头文件应包含其接口自身需要的类型声明来源,例如这里的 size_t,不要要求调用者碰巧先包含某个其他头文件。
7. 条件编译:决定哪些代码交给编译器
7.1 检查存在与检查值不同
#define PROJECT_TRACE 0
此时 #ifdef PROJECT_TRACE 为真,因为宏已定义;#if PROJECT_TRACE 为假,因为展开后的值是零。
| 指令 | 含义 |
|---|---|
#ifdef NAME |
宏是否已经定义 |
#ifndef NAME |
宏是否尚未定义 |
#if expression |
预处理条件表达式是否非零 |
#elif expression |
前面的分支未选中时继续判断 |
#else |
其他情况 |
#endif |
结束相应条件组 |
defined(NAME) 或 defined NAME 可以用于 #if,并结合逻辑运算形成条件。
7.2 预处理器不能读取普通 C 对象的值
const int enabled = 1;
// #if enabled 不能据此取得这个 const 对象的值。
#if 经过适当展开后,剩余普通标识符按相关规则视为 0。它不知道局部变量值,也不能像普通 C 表达式那样执行函数调用或使用 sizeof 取得类型大小。
不参与的分支不进入后续普通编译,但仍需遵守必要的预处理词法规则。不要用条件编译来包住未闭合注释等任意损坏文本。
7.3 完整示例:构建时打开追踪输出
#include <stdio.h>
#ifndef PROJECT_TRACE
#define PROJECT_TRACE 0
#endif
#if PROJECT_TRACE != 0 && PROJECT_TRACE != 1
#error PROJECT_TRACE must be 0 or 1
#endif
int main(void)
{
int total = 0;
for (int i = 1; i <= 4; ++i) {
total += i;
#if PROJECT_TRACE
printf("step %d: %d\n", i, total);
#endif
}
printf("total: %d\n", total);
return 0;
}
默认输出:
total: 10
使用支持相应命令行选项的编译器,可以在构建时指定:
cc -std=c11 -DPROJECT_TRACE=1 trace.c -o trace
这时重新编译得到的程序会额外输出每一步的累积值。运行时的 if 与这里的 #if 不同:前者在程序执行中作选择,后者决定哪些源码参与构建。
8. 诊断信息、预定义宏与编译指示
8.1 常用预定义信息
| 名称 | 含义 |
|---|---|
__FILE__ |
当前逻辑源码文件名字符串 |
__LINE__ |
当前逻辑源码行号 |
__DATE__、__TIME__ |
翻译日期与时间,通常不是程序运行时间 |
__STDC__ |
相应标准符合性信息 |
__STDC_VERSION__ |
语言标准版本信息,例如 C11 为 201112L |
__STDC_HOSTED__ |
区分宿主环境与独立环境的相关信息 |
__func__ 是函数体内可用的预定义标识符,表示当前函数名,不是可以用 #ifdef 当作普通宏检查的预定义宏。
日志可以组合文件、行号和函数名,但这些信息不会自动证明运行时数据有效,也不应代替必要的错误处理。
8.2 #line 改变后续报告的位置
#line 100 "generated_part.c"
这影响后续逻辑行号和文件名报告,常用于生成代码映射。它不会在磁盘上改名文件,也不会把程序跳转到某个行号。
8.3 #error 拒绝不满足要求的构建
#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 201112L
#error This module requires C11 or later
#endif
需要“至少 C11”时,比较版本下限比只判断是否恰好等于 C11 更符合意图。语言版本信息也不必然代表所有可选特性可用,使用可选能力还应检查相应实现支持。
8.4 #pragma 与 _Pragma
#pragma 向实现表达特定编译要求;不少内容依赖编译器,不能照搬某个实现的指令就认为普遍有效。
_Pragma 接收字符串字面量形式的内容,可以出现在宏展开中:
#define PROJECT_PRAGMA_RAW(text) _Pragma(#text)
它先对参数进行字符串化,再由 _Pragma 形成编译指示。普通宏展开不能随意制造一条新的 #include 或 #if 指令,_Pragma 提供的是专门机制。
9. _Generic:由类型决定采用哪个表达式
9.1 它是 C 表达式,不是预处理条件
#define TYPE_NAME(value) _Generic((value), \
int: "int", \
float: "float", \
double: "double", \
default: "other")
TYPE_NAME(3) 得到 "int",TYPE_NAME(3.0) 得到 "double"。类型判断由编译器完成,不是预处理器通过数值判断。
一般形式为:
_Generic(control_expression,
type_a: expression_a,
type_b: expression_b,
default: fallback_expression)
控制表达式不被求值,只用于确定匹配类型;最终只求值被选中的结果表达式。匹配根据相容类型规则进行,同一选择列表不能重复列出彼此相容的类型。
表达式参与运算后可能改变类型,所以应检查传入表达式本身,而不只是某个变量最初的声明。例如 float 变量加上 double 常量后,结果可能按 double 参与选择。
9.2 未选中的表达式也需要是合法代码
不能把 _Generic 当作任意隐藏类型错误的机制。即使一个关联最终没有被选择,其中的代码仍需要满足相应语法与语义要求。
一个实用方式是先选择函数,再在外面调用:
#define SQRT_ANY(value) _Generic((value), \
float: sqrtf, \
long double: sqrtl, \
default: sqrt)(value)
关联值都是已声明函数;控制位置不求值,实际调用位置只求值一次。默认选择 sqrt,让普通整数参数通过函数原型转换为 double。
10. static inline:保留函数规则的小型实现
10.1 inline 是实现建议,不是展开保证
内联函数仍然是函数,拥有参数类型、局部对象和普通调用语义。编译器是否用函数体替代调用,取决于实现与优化,不能仅凭 inline 断言最终机器码没有调用。
普通函数也可能被优化成内联。选择函数与宏时,应优先考虑语义和可维护性,再根据实际测量判断性能。
static inline int square_int(int value)
{
return value * value;
}
形参已经接收到值,函数内部重复使用 value 不会重复执行调用方的递增表达式。数值结果仍需要处于 int 可表示范围,内联不会消除算术溢出。
10.2 头文件中常用 static inline
它为各翻译单元提供内部链接的小函数定义,通常能避开普通外部函数在多个文件中重复定义的问题。
只有 inline、没有 static 的外部链接写法,涉及 C 的内联定义与外部定义规则;不能简单认为“头文件加一个 inline,就不再需要考虑链接”。需要采用这种方式时,应把声明和外部定义配套设计,并使用一致的语言模式。
内联函数可以取得地址并通过相容的函数指针调用。不能把“编译器可能内联”解释成“函数必定没有可用地址”。
10.3 完整示例:类型选择与正常函数求值
#include <math.h>
#include <stdio.h>
#define SQRT_ANY(value) _Generic((value), \
float: sqrtf, \
long double: sqrtl, \
default: sqrt)(value)
static inline int square_int(int value)
{
return value * value;
}
int main(void)
{
int input = 2;
int result = square_int(++input);
float first = SQRT_ANY(9.0f);
long double second = SQRT_ANY(16.0L);
printf("square: %d, input: %d\n", result, input);
printf("roots: %.1f %.1Lf\n", (double)first, second);
return 0;
}
输出:
square: 9, input: 3
roots: 3.0 4.0
11. 使用标准库:声明、实现与参数约定一起看
11.1 头文件与链接库解决不同问题
#include <math.h>
头文件让编译器看到声明、类型和宏。函数实现可能由工具链自动链接,也可能需要额外库选项,不能把“已包含头文件”当作“所有链接依赖都已满足”。
在需要显式链接数学库的工具链中,命令可以写成:
cc -std=c11 coordinates.c -lm -o coordinates
具体是否需要 -lm 取决于实现。库通常放在使用它的目标文件之后,有助于符合一些链接器的搜索规则。
11.2 阅读函数接口时确认哪些信息
| 信息 | 需要明确的含义 |
|---|---|
| 头文件 | 正确声明从哪里取得 |
| 参数类型 | 接收数值、地址、字节数还是元素数 |
const、restrict 等 |
修改与重叠访问的约定 |
| 返回值 | 正常结果、状态还是实际完成数量 |
| 失败约定 | 是否需要检查错误标记、范围或特殊结果 |
| 生存期与所有权 | 谁保存对象,谁负责释放 |
例如 fread 接收通用对象指针,不代表它能推断目标数组容量。类型通用时,调用者提供的大小与对象有效性信息反而更重要。
12. 数学库:类型、单位和定义域
12.1 常用函数按用途分组
| 用途 | 函数示例 |
|---|---|
| 平方根与立方根 | sqrt、cbrt |
| 三角与反三角 | sin、cos、tan、asin、acos、atan、atan2 |
| 指数与对数 | exp、log、log10 |
| 幂运算 | pow |
| 取整 | ceil、floor |
| 浮点绝对值 | fabs |
| 二维长度 | hypot |
普通版本通常接收并返回 double。相应的单精度和扩展精度版本在名称末尾加 f、l,例如 sqrtf、sqrtl,不是在名字前加前缀。
float 传给普通 sqrt 会转换为 double;long double 传给它也会收窄为 double,因此选择相应版本可以保留接口所需精度。
12.2 三角函数使用弧度
弧度 = 角度 × π / 180
角度 = 弧度 × 180 / π
不能把角度 90 直接当作 sin(90) 的“90 度”。标准 C99/C11 也不要求通过名为 M_PI 的宏提供 π,可按需求使用 acos(-1.0) 等方法取得相应值。
12.3 atan2(y, x) 保留象限信息
atan(y / x) 只接收到比值,丢失两个分量的符号组合,还可能遇到除以零。atan2(y, x) 分别取得两个分量,能正确处理相应象限,注意参数顺序是先 y、后 x。
#include <math.h>
#include <stdio.h>
struct polar {
double radius;
double degrees;
};
struct polar to_polar(double x, double y)
{
struct polar result;
const double pi = acos(-1.0);
result.radius = hypot(x, y);
result.degrees = x == 0.0 && y == 0.0
? 0.0 : atan2(y, x) * 180.0 / pi;
return result;
}
int main(void)
{
struct polar result = to_polar(-3.0, 4.0);
printf("radius: %.2f\n", result.radius);
printf("degrees: %.2f\n", result.degrees);
return 0;
}
输出:
radius: 5.00
degrees: 126.87
这里约定原点的角度为零。hypot 直接表达二维长度,也避免简单计算 x*x + y*y 可能引入的不必要中间溢出等问题。
真实数值输入还需要明确允许的有限值范围、定义域与错误报告策略。sqrt 的实数输入应非负,log 的普通实数输入应为正;不要假定所有数学错误都会用同一种方式设置 errno,应结合实现和 math_errhandling 约定。
12.4 <tgmath.h> 提供标准泛型数学宏
#include <tgmath.h>
它可根据参数类型选择对应数学函数版本;在支持相应复数能力时,还涉及对应的复数版本。
某些库名既有函数也有同名函数式宏。需要调用其真正函数形式时,(sqrt)(value) 可以避开名为 sqrt 的函数式宏展开,因为宏名后面没有直接跟着调用左括号。
这不表示给任意宏名加括号都能得到一个函数,前提是确实存在可调用的相应函数声明。
12.5 clock 衡量处理器时间
<time.h> 的 clock 返回处理器时间计量值,失败时返回 (clock_t)-1;差值除以 CLOCKS_PER_SEC 可以换算为秒。
需要在计算前检查失败、差值表示范围以及实现的计时分辨率。它不是通常意义上的日历时间,也不能在所有环境中当作准确的墙上经过时间;用忙循环拖延程序还会持续消耗处理器资源。
13. 正常退出与退出回调
13.1 _Noreturn 不等于 void
#include <stdio.h>
#include <stdlib.h>
_Noreturn void fatal_error(const char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(EXIT_FAILURE);
}
void 表示不返回一个值;_Noreturn 表示不会以正常返回方式把控制交回调用者。函数可能终止程序,也可能以其他符合约定的方式不再返回。
它是承诺,不会自动在函数末尾插入退出逻辑。若函数最终正常返回,就违反了这个约定。
13.2 atexit 注册正常终止时执行的函数
#include <stdio.h>
#include <stdlib.h>
void first_cleanup(void)
{
puts("first cleanup");
}
void second_cleanup(void)
{
puts("second cleanup");
}
int main(void)
{
if (atexit(first_cleanup) != 0 || atexit(second_cleanup) != 0) {
return EXIT_FAILURE;
}
puts("main finished");
return EXIT_SUCCESS;
}
正常输出:
main finished
second cleanup
first cleanup
回调类型是 void function(void),按注册顺序的逆序执行,注册是否成功也要检查。
正常程序终止还涉及流清理等工作;exit 在辅助函数中调用也会终止整个程序,而普通 return 只结束当前调用。
退出回调不能继续使用已经结束生存期的普通局部对象。需要在当前作用域内检查文件关闭或资源释放结果时,显式清理通常更清楚。
atexit 针对正常终止,不保证在崩溃、abort 或其他异常终止情形下执行,不能把它当成任何情况下都必定触发的清理机制。
14. qsort:通用排序依靠比较函数理解数据
14.1 数组地址、元素数量、元素大小与比较规则
void qsort(void *base, size_t count, size_t size,
int (*compare)(const void *, const void *));
base 指向数组首元素,count 是元素个数,size 是单个元素字节数。通用对象指针不会告诉库这些元素的实际类型,因此比较函数必须恢复正确访问类型。
比较器接收两个元素的地址,不是整个数组的地址,也不是两个值本身。
| 比较结果 | 顺序含义 |
|---|---|
| 负数 | 第一个元素排在前面 |
| 零 | 在当前排序规则下等价 |
| 正数 | 第一个元素排在后面 |
比较规则必须前后一致,不能在比较过程中随意修改数据或改变先后含义。
14.2 整数比较不要直接相减
return first - second; 可能发生有符号溢出,使比较器本身出现未定义行为。用关系比较构造结果更稳妥:
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
int compare_ints(const void *left, const void *right)
{
int first = *(const int *)left;
int second = *(const int *)right;
return (first > second) - (first < second);
}
int main(void)
{
int values[] = {5, -2, 0, 5, 1};
size_t count = sizeof values / sizeof values[0];
qsort(values, count, sizeof values[0], compare_ints);
for (size_t i = 0; i < count; ++i) {
printf("%s%d", i == 0 ? "" : " ", values[i]);
}
putchar('\n');
return 0;
}
输出:
-2 0 1 5 5
若要降序,调整关系比较方向。不能仅根据 qsort 的名字推断标准保证它使用某一种快速排序算法,也不能假定相等元素一定保持原先次序。
14.3 结构按多个字段依次比较
struct name {
char first[24];
char last[24];
};
int compare_names(const void *left, const void *right)
{
const struct name *first = left;
const struct name *second = right;
int order = strcmp(first->last, second->last);
return order != 0 ? order : strcmp(first->first, second->first);
}
这里先比姓,再比名;各字段必须是有效字符串。C 允许把相应 const void * 转为对象指针,不必为每次转换额外书写强制转换。
14.4 字符串指针数组要多解引用一层
数组元素若是 const char *,比较器接收的就是那个指针元素的地址:
int compare_texts(const void *left, const void *right)
{
const char *const *first = left;
const char *const *second = right;
return strcmp(*first, *second);
}
这与直接排序二维字符数组不同。先确认数组元素类型,再决定比较器的访问方式,不能把所有字符串相关排序都写成同一种强制转换。
若排序浮点数据,还要为 NaN 等特殊值明确规定一致的顺序,不能机械套用只适合普通有限数值的比较规则。
15. 运行时断言与编译时断言
15.1 assert 检查程序内部应成立的条件
#include <assert.h>
assert(index < count);
条件为零才表示断言失败。启用断言时,失败会输出诊断信息,并通过异常终止机制结束程序;不能把“非零为真”记反。
它适合检查内部不变量、开发阶段的假设。用户输入是否合法、文件是否打开成功等需要在正式程序中处理的问题,仍应使用常规检查与错误报告。
15.2 NDEBUG 可以让断言表达式完全不执行
在包含 <assert.h> 前定义 NDEBUG,会使相应的 assert 不执行其条件表达式。值是否为 0 不重要,关键在于该宏是否已定义。
// 不要依赖这种写法推进程序状态:
// assert(++index < count);
断言被禁用后,递增也会消失。断言表达式应避免承载程序必须执行的副作用。
15.3 _Static_assert 在编译时验证常量条件
C11 的 _Static_assert 是声明,第一个参数必须是整型常量表达式,第二个参数是说明失败原因的字符串。它不依赖 NDEBUG。
#include <assert.h>
#include <stdio.h>
#ifndef SLOT_COUNT
#define SLOT_COUNT 16u
#endif
_Static_assert(SLOT_COUNT > 0u &&
(SLOT_COUNT & (SLOT_COUNT - 1u)) == 0u,
"SLOT_COUNT must be a nonzero power of two");
static unsigned int next_slot(unsigned int index)
{
assert(index < SLOT_COUNT);
return (index + 1u) & (SLOT_COUNT - 1u);
}
int main(void)
{
unsigned int last = SLOT_COUNT - 1u;
printf("next after %u: %u\n", last, next_slot(last));
return 0;
}
默认输出:
next after 15: 0
掩码方式依赖槽位数为二的幂,编译时断言保护这个配置;运行时断言检查传入索引。主函数用当前配置的最后一个合法下标演示回绕,而不是把下标写死。
16. memcpy 与 memmove:复制字节,不转换数值
16.1 两个函数的关键区别是重叠
void *memcpy(void *restrict target,
const void *restrict source, size_t bytes);
void *memmove(void *target, const void *source, size_t bytes);
两者都定义在 <string.h> 中,从源位置复制指定字节数,返回目标地址。
| 项目 | memcpy |
memmove |
|---|---|---|
| 单位 | 字节数 | 字节数 |
| 源与目标重叠 | 不允许 | 允许 |
| 遇到空字符是否停止 | 不停止 | 不停止 |
| 是否自动数值转换 | 否 | 否 |
memmove 的结果好像经过中间缓冲保存源数据,但实现不一定真的分配临时数组。
16.2 完整示例:独立复制与原地移动
#include <stddef.h>
#include <stdio.h>
#include <string.h>
void show_values(const char *label, const int values[], size_t count)
{
printf("%s", label);
for (size_t i = 0; i < count; ++i) {
printf(" %d", values[i]);
}
putchar('\n');
}
int main(void)
{
int values[6] = {1, 2, 3, 4, 5, 6};
int copy[6];
memcpy(copy, values, sizeof values);
memmove(values + 2, values, 4 * sizeof values[0]);
show_values("copy:", copy, 6);
show_values("move:", values, 6);
return 0;
}
输出:
copy: 1 2 3 4 5 6
move: 1 2 1 2 3 4
第二次操作的范围重叠,必须使用允许重叠的接口。两边都必须有足够的可访问空间,memmove 不会替调用者验证数组边界。
16.3 元素个数、字节数与对象表示分别计算
复制 10 个 double,需要的是 10 * sizeof(double) 字节,而不是 10 字节;若数量来自外部,还要检查大小计算是否溢出。
把 double 的字节拷贝进 int 数组,不会得到逐元素的数值转换。把这些字节当作另一种类型使用,可能涉及无效表示和类型访问规则,不能作为一般转换技巧。
含指针的结构即使按字节复制,也只复制指针值,不会复制所指资源。字节复制工具不会自动完成深复制、分配空间或转交所有权。
17. 可变参数函数:参数数量和类型由接口约定
17.1 省略号不会携带可自动查询的类型列表
double sum_doubles(int count, ...);
在本章 C99/C11 规则下,省略号位于参数列表末尾,前面至少有一个固定形参。
函数需要某种办法知道额外参数的数量和类型:可以使用数量、格式字符串、标签或适当的结束标记。最后一个固定参数不一定必须表示数量,例如 printf 使用的是格式约定。
编译器不会因为看见 ... 就自动给自定义函数保存一份可查询的类型描述。调用者与函数必须遵守同一协议。
17.2 四组基本操作
| 工具 | 用途 |
|---|---|
va_list |
表示遍历可变参数所需状态的类型 |
va_start(list, last) |
以最后一个固定形参初始化遍历状态 |
va_arg(list, type) |
取出下一项,并推进遍历状态 |
va_end(list) |
完成与该遍历状态对应的清理 |
va_copy(copy, original) |
建立可以独立遍历的状态副本 |
这些工具由 <stdarg.h> 提供。不要假定 va_list 就是一个普通指针,也不要用赋值代替可移植的 va_copy。
每个成功初始化的列表,都要在适当路径上匹配 va_end,包括副本。va_end 处理的是实现所需的状态清理,不表示调用者在自己手动释放一块已知的动态内存。
17.3 按默认实参提升后的类型读取
省略号对应的实参没有普通固定形参类型来替它完成目标类型转换,而是应用默认实参提升等规则:
float提升为double,读取时使用va_arg(list, double)。char、short等会按整数提升规则处理,通常读取为相应的int等提升后类型。- 整数常量
1仍不是double,不能因为函数想取浮点数就自动按1.0读取。 - 指针也需要遵守约定,不能假定整数形式的空指针常量会在省略号位置自动变成需要的指针类型。
// 若函数约定读取两个 double:
sum_doubles(2, 1.0, 2.0); // 类型符合约定。
// sum_doubles(2, 1, 2); // 不能按 double 读取这两个 int。
va_arg 的类型参数不是“要求进行转换的目标类型”,而是说明这里应该按什么实际参数类型取值。读错类型或读取超过实际参数数量,可能导致未定义行为。
17.4 完整示例:复制遍历状态,分两遍统计
下面用一次遍历求平均值,再用独立副本求总体方差。接口约定:count 为正,后面恰好提供相应数量的 double,数据与中间运算均在可处理范围内。
#include <stdarg.h>
#include <stdio.h>
struct statistics {
int valid;
double mean;
double variance;
};
struct statistics summarize(int count, ...)
{
struct statistics result = {0};
if (count <= 0) {
return result;
}
va_list values;
va_list second_pass;
va_start(values, count);
va_copy(second_pass, values);
double total = 0.0;
for (int i = 0; i < count; ++i) {
total += va_arg(values, double);
}
va_end(values);
result.mean = total / count;
double squared = 0.0;
for (int i = 0; i < count; ++i) {
double difference = va_arg(second_pass, double) - result.mean;
squared += difference * difference;
}
va_end(second_pass);
result.variance = squared / count;
result.valid = 1;
return result;
}
int main(void)
{
struct statistics result = summarize(3, 2.0, 4.0, 6.0);
if (!result.valid) {
return 1;
}
printf("mean: %.2f\n", result.mean);
printf("variance: %.3f\n", result.variance);
return 0;
}
输出:
mean: 4.00
variance: 2.667
副本在第一次遍历开始前创建,因此可以从相同起点独立读取;结束原列表不妨碍已经正确初始化的副本继续遍历。非正数量返回 valid == 0,不读取额外参数。
若要把变参内容存入动态数组,还需单独检查元素数量与分配大小,处理分配失败,并把返回内存的释放责任交给调用方。
参数本来已经储存在同类型数组中时,“数组地址 + 元素数量”通常比省略号接口更简单,也更容易进行边界与类型检查。
18. 根据问题所在层次选择工具
| 需求 | 合适的方式 |
|---|---|
| 替换记号、生成名字或表达式文本 | 宏、#、## |
| 按构建配置选择源码 | 条件编译 |
| 多文件共享类型与接口 | 有包含保护的头文件和配套源文件 |
| 类型明确、参数只求值一次的小操作 | 普通函数或合适的 static inline |
| 按表达式类型选择实现 | _Generic 或适当泛型库接口 |
| 数学计算 | 合适类型、单位与定义域的数学函数 |
| 通用数组排序 | qsort 与类型正确、一致的比较函数 |
| 开发阶段检查内部条件 | 无必要副作用的 assert |
| 拒绝不成立的编译期假设 | _Static_assert、合适的 #error |
| 复制独立内存范围 | memcpy |
| 允许重叠的内存移动 | memmove |
| 数量和类型协议确有必要变化的接口 | 配套使用 stdarg.h 的可变参数函数 |
预处理器擅长组织代码,却不理解对象所有权和运行时边界;标准库提供通用操作,却仍依赖正确的类型、数量与生存期约定。把这些责任写清楚,代码才更容易复用和验证。

