声明、定义、头文件与预处理器
本节目标
查询 C23 声明与定义、翻译单元、头文件契约、宏展开、条件编译和可变参数宏。
多文件 C 程序有两条相互衔接的边界:声明告诉当前代码“某个名字和类型可用”,定义为对象或函数承担实体责任;预处理器则先处理包含、条件指令和宏替换,随后编译器才分析形成的翻译单元。本章以 ISO C23 为基线,从这两条边界判断头文件应该放什么、定义应该留在哪个源文件,以及宏是否会重复求值。
声明与定义
声明(declaration),把标识符、类型及其他属性引入当前语境;定义(definition),它也是声明,但还会定义函数体、为对象建立定义责任,或完整定义某类实体。判断时不要只看有没有分号。
| 写法 | 是声明 | 是本处定义 | 判断依据 |
|---|---|---|---|
extern int total; | 是 | 否 | 只有无初始化器的 extern 对象声明 |
int total = 1; | 是 | 是 | 带初始化器的对象定义 |
int sum(const int values[], size_t count); | 是 | 否 | 函数原型,没有函数体 |
int sum(const int values[], size_t count) { /* ... */ } | 是 | 是 | 函数定义,提供函数体 |
对象定义与函数定义承担的责任不同,但二者都先是声明。一个接口可以在多处重复相容声明,不能因此在多个翻译单元复制普通对象定义或非 inline 外部函数定义。具有外部链接的标识符若在表达式中被使用(标准列出的不求值例外除外),整个程序必须恰有一个外部定义;否则最多有一个外部定义。这里的例外包括结果为整数常量表达式的 sizeof 或 alignof 操作数,以及结果不是变长修改类型的 typeof 操作数。声明可以重复,但类型与链接必须兼容。
审查顺序是:先识别实体类别,再看声明位置、存储类说明符、初始化器或函数体,最后结合程序中的其他声明判断定义责任。名字是否可见、是否具有外部链接和本条是否为定义,是三个不同问题。
外部定义与暂定定义
外部定义(external definition),是位于函数外、同时构成定义的外部声明;术语中的“external”描述文件作用域语境,不等于该名字一定具有外部链接。文件作用域的 static int retries = 1; 也定义实体,但名字具有内部链接。
没有初始化器且既没有 extern 也没有 thread_local 存储类说明符的文件作用域对象声明,构成暂定定义(tentative definition):
int total; // 暂定定义,外部链接。
int total; // 同一翻译单元中再次暂定定义。
static int retries; // 暂定定义,内部链接。
extern int total; // 声明,不是暂定定义。
文件作用域的 thread_local int per_thread; 即使没有初始化器也是外部定义,不是暂定定义。
如果一个翻译单元包含一个或多个某对象的暂定定义,却没有该对象的外部定义,行为就像该翻译单元包含一个文件作用域声明:采用翻译单元末尾的复合类型,并带空初始化器 {}。该对象随后按 6.7.10 的默认初始化规则初始化。暂定定义只在翻译单元结束时按规则补成这个声明,不能用常见链接器的 common-symbol 行为替代语言规则;也不要把默认初始化的结果和“任意声明都是定义”混成一句话。
头文件若写普通的 int total;,每个包含它的翻译单元都会得到一个暂定定义;这不是发布共享对象接口的正确方法。共享接口通常在头文件写 extern int total;,并在一个源文件写唯一的 int total = 0;。
翻译单元与多文件程序
一个 .c 源文件连同它通过 #include 纳入的头文件内容,经过条件包含、宏展开和其他预处理后形成编译器分析的翻译单元。头文件文本被分别纳入每个包含它的源文件;它不会因为后缀是 .h 就自动成为一个共享的已编译实体。
代表程序由两个翻译单元组成:
| 编译输入 | 预处理后包含的项目代码 | 负责内容 |
|---|---|---|
main.c | main.c 与展开进来的 report.h | 使用公共声明与宏,定义 main |
report.c | report.c 与展开进来的 report.h | 使用相同函数声明,并定义 report_sum |
report.h 的函数原型让两个翻译单元独立检查同一个接口;最终程序还要把各翻译单元产生的结果组合起来,才能解析 main.c 对 report_sum 的引用。预处理先组织并替换预处理记号,编译再检查所得翻译单元的 C 语义。预处理成功不代表声明相容、类型正确或整个多文件程序已满足定义要求。
自包含头文件与接口契约
自包含头文件应当能在一个空白源文件中直接作为首次包含项,而不依赖调用方碰巧先包含了别的头。它公开接口所需的声明、类型和宏,并自行包含这些接口所依赖的标准头或项目头。
report.h 的函数声明使用 size_t,因此自行包含 <stddef.h>;REPORT 宏会形成 printf 调用,因此也自行包含 <stdio.h>。若公共接口按值传递结构体或访问其成员,使用处通常需要完整类型;只声明并传递某结构体指针时,可以在契约允许的位置使用不完整类型。
头文件应提供接口所需的声明和类型,不在其中放普通外部对象定义或非 inline 外部函数定义。对应实现文件也应先包含自己的公共头,这样函数定义若与公开原型不相容,会在实现翻译单元中尽早被诊断。自包含头文件必须自行包含其公开声明所依赖的标准头或项目头。
#include 与 include guard
#include "report.h" 把找到的源文件内容纳入当前位置。引号形式先以实现定义的方式搜索指定源文件;若该搜索不受支持或失败,指令会按相同字符序列的尖括号形式重新处理。尖括号形式搜索一组实现定义的位置,位置的指定方式和头的识别方式也由实现定义。具体目录顺序必须查询实现文档;两种形式都不是运行期“导入模块”。
include guard 用当前预处理翻译单元内的条件宏阻止同一头文件正文被重复处理:
#ifndef PROJECT_REPORT_H
#define PROJECT_REPORT_H
/* declarations */
#endif
guard 名应在项目范围内唯一,并避开实现保留的标识符。它不能让不同翻译单元共享一次处理结果,也不能修复头文件中本来就不应复制的外部定义。ISO C 示例使用 include guard;#pragma once 只标注为实现广泛支持的非标准机制。
对象式宏与函数式宏
对象式宏在名字后没有形参列表,函数式宏的定义则让宏名紧接 ( 和形参列表:
#define REPORT_MODE "c23"
#define ARRAY_COUNT(array) (sizeof(array) / sizeof((array)[0]))
REPORT_MODE 是对象式宏,每次出现都会由替换列表中的字符串字面量记号替换。ARRAY_COUNT 是函数式宏,调用形态中的实参记号会代入替换列表。给每次参数使用和整个算式加括号,可以避免许多优先级与结合问题。sizeof 的操作数类型不是 VLA 时不求值;若操作数类型是 VLA,操作数会求值。本例传入的是非 VLA 数组对象,因此两个替换位置都不会求值 values。
但 ARRAY_COUNT 只适用于当前作用域中真正的数组对象。把已退化为指针的函数形参传入时,sizeof 得到的是指针大小而非数组元素总大小。这个契约来自替换后的表达式,不是宏能够检查的“数组类型参数”。宏是预处理阶段的记号替换,不是只求值一次且受类型检查的函数调用。有关表达式分组可回查运算符、表达式与类型转换。
宏展开、括号与副作用
函数式宏的实参不是像普通函数实参那样绑定到一个只求值一次的形参对象。某个形参在替换列表出现两次,替换后的表达式就可能求值该实参两次:
#define BAD_MAX(left, right) \
(((left) > (right)) ? (left) : (right))
static void unsafe_example(void) {
int index = 3;
int limit = 2;
int selected = BAD_MAX(index++, limit); // 条件为真时,index++ 被替换到两处。
(void)selected;
}
这个反例只用于审查,不执行,也没有固定输出。外层与参数括号解决的是分组问题,不能把多次出现的参数合并成一次求值。函数式宏的参数可能被替换多次;完整括号只能处理分组问题,不能消除重复求值。需要一次求值、类型检查或可取地址的行为时,优先使用合适的函数;代表程序只把无副作用的数组名、常量和普通值传给宏。
条件编译与特性检测
#if、#ifdef 和 defined 在预处理阶段选择哪些记号进入后续翻译。检测对象应当是语言实现预定义、标准头提供,或构建系统明确约定的宏:
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
#define PROJECT_C23 1
#else
#error "This source requires C23"
#endif
#ifdef PROJECT_TRACE
#define TRACE_ENABLED 1
#else
#define TRACE_ENABLED 0
#endif
__STDC_VERSION__ 由符合相应规则的实现提供;PROJECT_TRACE 则必须来自项目构建契约,而不是在检测前无条件把它定义成想要的结果。#ifdef FEATURE 只判断宏是否已定义,不判断值是否为真;若契约使用 0 和 1,应改用与该契约一致的 #if FEATURE。条件编译必须检测真正由实现或构建配置定义的宏,不凭猜测制造伪特性宏。
特性检测只决定选择哪段源码,不会自动证明所选分支的接口相容或行为可移植。让每个受支持分支都接受同一组严格诊断检查,并把实现差异限制在清晰接口后。
字符串化与记号拼接
# 在函数式宏的替换列表中把对应宏参数的拼写字符串化;## 则可在对象式或函数式宏的替换列表中把左右预处理记号拼接成一个记号:
#define STRINGIZE_RAW(token) #token
#define STRINGIZE(token) STRINGIZE_RAW(token)
#define JOIN_RAW(left, right) left ## right
#define JOIN(left, right) JOIN_RAW(left, right)
#define REPORT_BUFFER report ## _buffer
#define BUFFER_SIZE 64
static int REPORT_BUFFER;
static int JOIN(buffer_, BUFFER_SIZE);
static const char *size_text = STRINGIZE(BUFFER_SIZE);
直接参与 # 或 ## 的参数不会先按普通参数规则完成宏展开,因此常用上面的两层宏:外层先展开 BUFFER_SIZE,内层再得到标识符 buffer_64 和字符串字面量 "64"。对象式宏 REPORT_BUFFER 则直接把两个记号拼成标识符 report_buffer。# 只能用于函数式宏的替换列表,并且下一个预处理记号必须是该宏的参数。## 可用于对象式或函数式宏的替换列表,但不能出现在替换列表开头或末尾;拼接结果还必须形成合法预处理记号,不能把任意字符序列当字符串连接器。
这段代码只说明预处理结果,不读取对象,也不声明运行输出。字符串化不会求值表达式;记号拼接也不会绕过后续编译阶段的声明与类型检查。
可变参数宏与 C23 __VA_OPT__
可变参数宏用 ... 接收可变部分,并在替换列表中以 __VA_ARGS__ 引用它。C23 的 __VA_OPT__(tokens) 在可变实参完成宏替换后的替换结果非空时保留 tokens,替换结果为空时不产生这些记号,因此可以让分隔逗号随参数一起出现:
#define REPORT(format, ...) \
printf((format) __VA_OPT__(,) __VA_ARGS__)
REPORT("tag=core\n") 的可变部分为空,逗号随 __VA_OPT__ 一起消失;REPORT("mode=%s\n", REPORT_MODE) 则保留逗号并把 REPORT_MODE 放进调用。若另有空的对象式宏 #define EMPTY,REPORT("tag=core\n", EMPTY) 的可变部分在调用处含有 EMPTY 记号,但宏替换后为空,逗号仍被抑制。C23 __VA_OPT__ 只在可变参数宏替换列表中使用,也不能在参数列表、普通函数体或非可变参数宏中当作一般运算符。
下面三个真实文件组成一个程序。report.h 给出自包含接口、guard、对象式宏、函数式宏、条件特性门和可空的可变参数宏;report.c 提供外部函数定义;main.c 使用同一公共契约。代表程序中的宏参数均无副作用。
#ifndef XSONNET_C_DECLARATIONS_REPORT_H
#define XSONNET_C_DECLARATIONS_REPORT_H
#include <stddef.h>
#include <stdio.h>
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
#define REPORT_MODE "c23"
#else
#error "This example requires C23"
#endif
#define ARRAY_COUNT(array) (sizeof(array) / sizeof((array)[0]))
#define REPORT(format, ...) printf((format) __VA_OPT__(,) __VA_ARGS__)
int report_sum(const int values[], size_t count);
#endif
#include "report.h"
int report_sum(const int values[], size_t count) {
int sum = 0;
for (size_t index = 0; index < count; ++index) {
sum += values[index];
}
return sum;
}
#include "report.h"
int main(void) {
const int values[] = {1, 2, 3, 4};
const size_t count = ARRAY_COUNT(values);
REPORT("mode=%s\n", REPORT_MODE);
REPORT("values-count=%zu\n", count);
REPORT("values-sum=%d\n", report_sum(values, count));
REPORT("tag=core\n");
return 0;
}
cc -std=c23 -Wall -Wextra -Wpedantic -Werror main.c report.c -o header_macro_report
输出:
mode=c23
values-count=4
values-sum=10
tag=core
重复定义、宏污染与保留标识符
下面片段集中展示拒绝或不安全边界,只用于审查,不执行,也没有固定输出:
/* bad_report.h */
int shared_total = 0; // 多个翻译单元包含时会复制外部定义。
#define min(a, b) ((a) < (b) ? (a) : (b))
// 常见短名字会污染包含方。
#ifndef _REPORT_H
#define _REPORT_H // 下划线加大写字母属于实现保留形式。
#endif
static int misuse(int value) {
return min(value++, 10); // 宏参数可能重复求值。
}
双下划线、下划线加大写字母等保留名字不得用于项目 include guard 或公共宏;项目可以使用带组织前缀且不进入保留形式的名字,例如代表程序的 XSONNET_C_DECLARATIONS_REPORT_H。避免宏污染还应做到:公共宏带稳定前缀,不用普通标识符常见短名,内部辅助宏在使用后按契约 #undef,并避免让替换列表隐式依赖包含方的局部名字。
| 症状 | 常见原因 | 修正方向 |
|---|---|---|
| 组合多文件时出现重复定义 | 在头文件放了普通外部对象或非 inline 外部函数定义 | 头文件保留声明,一个实现文件承担定义 |
| 单独包含头文件时报未知类型 | 依赖调用方提前包含另一个头 | 让头文件自行包含公开声明所需依赖 |
| 宏调用改变对象的次数超出预期 | 参数在替换列表出现多次 | 改用一次求值的函数,或只传无副作用表达式 |
| 不同构建走入错误分支 | 检测了猜测的宏或混淆“已定义”与“值为真” | 为语言、实现和构建特性分别定义真实契约 |
| 项目名与实现声明冲突 | 使用双下划线或下划线加大写字母等保留形式 | 采用项目命名空间前缀并审查公共宏表面 |
最终排查时先看预处理后的翻译单元,再核对每个接口声明的类型与链接,随后定位整个程序中的定义责任;遇到宏则展开到实际表达式,逐项检查分组、求值次数、特性来源和保留名字。预处理器可以组织源码,但不会替代 C 的声明、类型和程序行为规则。