预处理器(The Preprocessor)
章节大纲
-
预处理器(The Preprocessor)
根据最初为 C89 制定的《C 语言标准合理性说明》文档中的描述:“现有 C 实现中最令人头疼的多样性,也许正出现在预处理阶段。作为一种独立、原始的语言,预处理命令是在几乎没有统一指引、文档也极不严谨的背景下逐渐积累起来的。”
基本信息
预处理器 vs 编译器
许多 C 编译器都采用多阶段处理,预处理通常作为第一阶段。基于这一点,一些编译器在预处理器与后续阶段之间共享信息以优化效率。然而,这种方式只是特定实现的优化手段,你应当始终将“预处理”与“编译”视为两个独立的阶段。
建议:务必在思维上区分预处理与编译。如果混淆这两者,某些操作(例如稍后将讨论的
sizeof
)可能会引发问题。虽然 C 是一种自由格式的语言,但预处理器严格来说并不是 C 语言的一部分,因此不一定遵守自由格式的规则。C 语言和预处理器各自拥有独立的语法、约束和语义,二者均由标准 C 定义。
指令名称格式(Directive Name Format)
预处理指令必须以
#
开头。然而,并非所有预处理器都要求#
与指令名是一个不可分的记号。一些实现允许#
和指令名称之间存在空格或制表符。K&R 显示
#
是指令名称的一部分,未明确说明是否允许中间有空白。标准 C 允许在
#
和指令名称之间插入任意数量的水平空白(空格和水平制表符),这些被视为独立的预处理记号。
指令的起始位置(Start Position of Directives)
许多预处理器允许在指令前加空白字符(如缩进嵌套指令)。而某些更严格的预处理器要求
#
必须是源行的第一个字符。K&R 指出:“以
#
开头的行会与预处理器交互。”但没有明确什么是“开头”。标准 C 允许
#
前有任意空白字符(不仅限于空格和制表符),任何类型的空白都被接受。
指令内部的空白字符(White Space Within Directives)
标准 C 规定,指令名称与终止换行符之间的所有空白必须是空格或水平制表符。
K&R 并未对指令内的空白做出说明。
在指令中只要使用至少一个空白字符来分隔记号,具体使用几个空格或混合制表/空格对大多数预处理器都无影响。例外情况是宏的良性重定义(稍后详述)。
指令内的宏展开(Macro Expansion Within a Directive)
标准 C 规定:“除非特别说明,预处理指令内的预处理记号不会进行宏展开。”例如:
#define EMPTY EMPTY # include <file.h>
上述第二行不会被识别为预处理指令,因为在翻译阶段 4 开始时,它并非以
#
开头(尽管宏替换后变成了#include
)。
指令续行(Directive Continuation Lines)
K&R 指出,带或不带参数的宏定义可使用反斜线
\
续行——反斜线需位于行末、紧邻换行。标准 C 推广了这一概念,允许任何记号(不仅仅是预处理器能看到的)都可以通过反斜线/换行对来续行。
例如:
#define NAME … \ #define …
此时第二行不被视为宏定义,因为它是续行,且其中的
#
被前方非空格字符(反斜线)“阻隔”。
行尾多余记号(Trailing Tokens)
严格来说,预处理器应报告指令中多余的记号。然而某些实现只处理其所期待的记号,其余忽略。例如:
#include <header.h> #define MAX 23
这种写法常见于某些移植中换行被丢失的情况,会成功包含头文件,但宏定义将被忽略。
再比如:
#ifdef DEBUG fp = fopen(name, "r");
无论
DEBUG
是否定义,文件都不会被打开。K&R 未说明这类情况应如何处理。
标准 C 要求:若出现多余记号,必须发出诊断信息。
指令中的注释(Comments in Directives)
块注释(
/* … */
)在预处理阶段会被视为一个空格,因此可以出现在指令中任何允许空白的地方。例如:#define NUM_ELEMS 50 /* … */ #include "global.h" /* … */ /* … */ #define FALSE 0 /* … */ #ifdef SMALL /* … */ #define TRUE 1 /* … */
这些注释在预处理阶段均会被替换为一个空格。尽管前两行能很好地移植,但后三行含有前置空白字符,可能会被某些实现拒绝(如前述)。
当然,注释也可以出现在指令记号之间。
需要注意的是:块注释可跨多行,不需要使用反斜线/换行来续行。
而行注释(
//
)也可用于指令中。
翻译阶段(Phases of Translation)
标准 C 对源代码转换为记号并由编译器处理的过程做了详细说明。C89 之前并无统一规则,导致以下代码在不同预处理器中产生不同解释:
#ifdef DEBUG #define T #else #define T /\ * #endif T printf(...); /* … */
意图可能是:在未定义
DEBUG
时,宏T
被展开为/*
,从而注释掉printf
。如一位程序员所说:“为了让
T
成为/*
,我们需要欺骗预处理器,因为它在处理任何记号前就会识别注释。我们将星号放在续行中,预处理器就不会直接识别出/*
,一切如愿。这种写法在 UNIX 环境下的编译器能正常工作。”但问题是:预处理器是否真的在所有处理前识别注释?
根据标准 C 的翻译阶段,流程如下:
-
移除反斜线/换行对,将续行拼接为一行。
-
将源码拆分为预处理记号和空白序列(包括注释)。
-
用一个空格替换每个注释(是否压缩多个空白为一个由实现定义)。
-
执行预处理指令、展开宏,每个包含的头文件也重新走一遍上述流程。
由此,标准 C 规定前述代码是非法的,因为
#endif
会被包含在宏定义形成的注释中,导致指令丢失或结构错乱。有些实现先展开宏,再识别预处理指令,因此会接受以下写法:
#define d define #d MAX 43
但标准 C 明确禁止这种用法。
检查预处理器输出(Inspecting Preprocessor Output)
有些实现将预处理器与编译器分离,这种情况下会生成一个中间文本文件;而其他实现将预处理器和编译器集成在一起,并通过列表选项在编译输出中显示所有预处理指令的最终效果。有些实现还支持列出包含嵌套宏的中间宏展开结果。
需要注意的是,由于在预处理指令处理前,注释可能已被替换为空格,因此某些实现无法在中间代码中保留注释或空白字符。这符合标准 C 所定义的翻译阶段行为。
建议:查看你的编译器是否支持保存预处理器输出文件。一个非常有用的质量保证步骤是:比较不同预处理器生成的输出文件,检查它们是否正确地展开宏以及正确包含条件代码。迁移代码到新环境时,也建议一同迁移其预处理后的版本。
源文件包含(Source File Inclusion)
#include
指令用于将指定头文件的内容当作当前源文件的一部分进行处理。虽然头文件通常是文件本身,但并不要求其完全对应某个具体文本文件或名称。标准 C 要求头文件必须包含完整的记号。也就是说,不能在头文件中只放注释、字符串字面量或字符常量的开始或结束部分。头文件也必须以换行符结束。这意味着不能在多个
#include
指令之间拼接记号。为避免标准头文件与程序员自定义代码之间的命名冲突,标准 C 要求实现方使用双下划线或下划线加大写字母作为其标识符的前缀。K&R 索引中只提到了三个宏:
EOF
、FILE
和NULL
,以及二三十个库函数。而标准 C 拥有数百个保留标识符,大部分是宏或函数名。再加上系统相关、第三方库中的标识符,命名冲突的风险相当高。建议:为每个目标环境生成按字母顺序排列的标识符清单(按头文件分类和跨头文件分类)。这份清单可用于两个目的:
-
作为命名时应避免使用的参考。
-
找出所有环境中的公共标识符,便于在跨平台代码中合理使用。
此外,不同环境中同名宏并不一定含义相同。为了减少冲突,自定义标识符时建议使用独特前缀(避免使用下划线)、后缀或专属命名风格。
#include
指令格式(#include Directive Format)K&R 与标准 C 定义了两种
#include
形式:#include "header-name" #include <header-name>
-
K&R 规定:
"header-name"
形式首先在源文件所在目录中查找,然后再在标准路径中查找。 -
标准 C 则规定查找路径为实现定义。
K&R 和标准 C 都规定:
<header-name>
形式只能在实现定义的标准路径中查找。C89 添加了第三种形式:
#include identifier
只要该标识符最终被展开成
"..."
或<...>
的形式即可。由于宏名属于标识符,因此可以用宏构造头文件名,例如通过##
运算符或命令行#define
实现。许多编译器支持如下形式的命令行参数:
-Didentifier 或 /define=identifier
这相当于在源代码中加入
#define identifier 1
。如果你的目标编译器支持
-D
选项和#include identifier
形式,你就可以在编译时指定完整路径而不是硬编码到#include
中。示例:将硬编码的头文件路径抽象化处理:
/* hnames.h - fully qualified header names */ #define MASTER "DBA0:[data]master.h" #define STRUCTURES "DUA2:[templates]struct.h"
引用方式:
#include "hnames.h" #include MASTER #include STRUCTURES
如果头文件被移动或系统更换,只需修改
hnames.h
并重新编译即可。
头文件名称(Header Names)
"..."
和<...>
中头文件名的格式和拼写取决于实现。一些文件系统使用反斜线
\
作为路径分隔符,如 DOS:\dir1\dir2\...\filename.ext
此时可能出现如下路径:
\totals\accum.h \summary\files.h \volume\backup.h
但如
\t
或\v
可能被解释为转义字符,这就会导致语义错误。标准 C 规定:尽管
"..."
看起来像字符串字面量,但它不是,因此其内容必须原样处理,不进行转义。建议:尽量避免在头文件名中嵌入设备名、目录名和子目录信息。
若头文件名与文件系统文件名直接对应,还应注意跨平台命名限制。例如有的系统区分大小写,
STDIO.H
、stdio.h
、Stdio.h
被认为是不同文件。C89 规定:实现可以忽略大小写,并将文件名映射限制为扩展名前最多 6 个有效字符。C99 将此限制提升到 8 个有效字符。
嵌套头文件(Nested Headers)
头文件可以包含其他
#include
指令。嵌套深度为实现定义。-
K&R 表示支持头文件嵌套,但未规定最小深度。
-
标准 C 要求支持至少 8 层嵌套。
建议:设计良好的头文件应支持多次、任意顺序包含。
每个头文件应具备自包含性(自己引入所需头文件)。
每个头文件只包含相关内容,并将嵌套层数限制在 3~4 层。
使用#ifdef
包裹头文件内容,避免重复包含。
#include
路径指定(#include Path Specifiers)K&R 和标准 C 只支持
"..."
和<...>
两种路径机制。但在测试或临时替换时可能需要更多灵活性。多数实现允许在编译时通过命令行指定多个搜索路径。例如:
cc -I"path1" -I"path2" -I"path3" source.c
预处理器会按顺序在 path1、path2、path3 中查找
"..."
格式的头文件,最后在系统默认位置查找。路径支持数量不足或命令行长度受限,可能影响跨平台移植。
建议:确认你所有编译器支持多少路径和命令行参数长度限制。
修改标准头文件(Modification of Standard Headers)
建议:不要修改标准头文件以添加定义、声明或
#include
。
应创建你自己的通用或本地头文件,并在需要的地方引用它。这样在升级编译器或更换平台时只需维持原来的本地头文件即可。
宏替换(Macro Replacement)
使用
#define
将宏名与一段字符串定义关联。宏名是标识符,受命名限制。-
K&R 有效长度为 8 个字符
-
标准 C 要求至少支持 31 个字符
建议:采用最小公约数的长度(例如 31 个字符以内)作为宏名长度限制。
命名风格建议:宏名通常使用大写字母、数字、下划线。
标准 C 要求宏定义中的记号必须是完整合法的,即不能只包含注释、字符串或字符常量的一部分。
有些编译器允许定义“部分记号”的宏,在展开时自动拼接。
建议:避免使用部分记号的宏定义。
宏定义可能包含表达式:
#define MIN 5 #define MAX MIN + 30
预处理器并不计算表达式,只是文本替换。因此这不等价于:
#define MAX 35
只有在
#if
条件包含中,预处理器才执行宏表达式求值:#if MAX → #if MIN + 30 → #if 35
不同实现可能对宏定义长度有限制。
建议:若宏定义超过 80 个字符,请测试所有目标环境是否支持。
带参数的宏(Macros with Arguments)
通用格式:
#define name(arg1, arg2, ..., argn) definition
K&R 没有规定最大参数个数。
标准 C 要求支持至少 31 个参数。
建议:如果打算使用超过 4~5 个参数的宏,需检查目标实现是否支持。
宏定义中,宏名与左括号之间不能有空白字符,但调用宏时没有此限制。
宏定义参数列表中的参数不要求全部出现在定义体中。
C99 增加对可变参数宏的支持,使用省略号(
...
)与__VA_ARGS__
实现。以下是你提供的英文内容的中文翻译:
宏名的重新扫描(Rescanning Macro Names)
一个宏的定义中可以引用另一个宏,在这种情况下,该定义会在必要时被重新扫描。
标准 C 要求在宏展开过程中临时禁用该宏自身的定义,以防止“递归死亡”。也就是说,宏在自身定义中出现时不会被再次展开。这允许将宏名作为参数传递给另一个宏。
字符串字面量与字符常量中的替换(Replacement Within String Literals and Character Constants)
某些实现允许在字符串字面量和字符常量中替换宏参数。例如:
#define PR(a) printf("a = %d\n", a)
调用:
PR(total);
有的实现会展开为:
printf("total = %d\n", total);
但不支持的实现则展开为:
printf("a = %d\n", total);
K&R 说明“字符串或字符常量内部的文本不会被替换”。
标准 C 不支持在字符串或字符常量中替换宏参数。但它提供了
#
字符串化运算符(C89 引入),可以达到类似效果。例如:#define PR(a) printf(#a " = %d\n", a)
调用:
PR(total);
展开为:
printf("total" " = %d\n", total);
由于标准 C 支持字符串常量拼接,最终结果为:
printf("total = %d\n", total);
命令行宏定义(Command-Line Macro Definition)
许多编译器允许通过命令行参数(如
-Didentifier
或/define=identifier
)来定义宏,这相当于在源文件中使用#define identifier 1
。有些编译器还允许用这种方式定义带参数的宏。命令行缓冲区的大小或参数个数可能导致无法指定所有所需的宏定义,特别是在你用此方式为多个
#include
提供宏时。建议:确认所有目标编译器是否支持此功能。只要标识符名足够短,通常应支持 5~6 个宏定义。注意:如果使用 31 字符长的标识符,可能会超出命令行缓冲区限制。
宏重定义(Macro Redefinition)
多数实现允许宏在未先
#undef
的情况下重新定义。这通常是为了允许多个头文件在同一作用域中重复定义相同宏。但若定义内容不同,就可能出现严重问题。例如:
#define NULL 0 … #define NULL 0L
前面部分代码中
NULL
是整数 0,后面则为长整型 0L。若调用f(NULL)
,传入参数大小可能与f
所预期不符。标准 C 允许宏重复定义,只要定义完全一致,称为“良性重定义”(benign redefinition)。那么“完全一致”是什么意思?基本上就是拼写必须一模一样,包括空白字符的处理。比如:
-
#define MACRO a macro
-
#define MACRO a macro
-
#define MACRO a<tab>macro
-
#define MACRO a macro
-
#define MACRO example
前两者一致,第 3、4 是否一致取决于实现如何处理空格,第 5 肯定会报错。
这并不能解决同一宏在不同作用域下定义不一致的问题。
建议:可以在多个位置重复定义相同宏(常见于头文件中),并鼓励这样做。但要避免对同一宏有不同定义。
使用单个空格分隔记号,避免多个连续空格,建议统一使用空格而非制表符,因为 tab 可能被转为多个空格。宏重定义指的是:无参宏重定义为相同名称的无参宏,或带参宏重定义为参数名和数量都相同的带参宏。
建议:即使实现允许,也不要将无参宏重定义为带参宏,或反之。标准 C 不支持这种行为。
预定义标准宏(Predefined Standard Macros)
标准 C 定义了以下预定义宏:
-
__DATE__
(C89):编译日期 -
__TIME__
(C89):编译时间 -
__FILE__
(C89):正在编译的源文件名(未说明是否为完整路径) -
__LINE__
(C89):当前源文件的行号 -
__STDC__
(C89):若编译器符合某版标准 C,则值为 1。注意:不要仅根据是否定义了该宏来判断是否符合标准,而应检查其值为 1。某些实现可能设为 0(不完全符合)或 2(有扩展)。要判断是否符合 C89,需
__STDC__
为 1,且未定义__STDC_VERSION__
。 -
__STDC_VERSION__
(C95):所遵循的标准 C 版本号-
C95:
199409L
-
C99:
199901L
-
C11:
201112L
-
C17:
201710L
-
-
__STDC_HOSTED__
(C99):指示实现是“宿主环境”还是“独立环境”
试图
#define
或#undef
这些预定义宏会导致未定义行为。宏名以
__STDC_
开头的是保留供将来标准使用的。K&R 中没有预定义宏。但在 C89 之前,一些实现已提供
__LINE__
、__FILE__
、__DATE__
和__TIME__
,只不过日期格式不统一。标准 C 还要求:其他预定义宏名必须以下划线加大写字母或第二个下划线开头。
同时,禁止在标准头文件或代码中定义__cplusplus
。C++ 注意事项:标准 C++ 预定义了
__cplusplus
,类似__STDC_VERSION__
,也是版本号编码。
是否预定义__STDC__
或__STDC_VERSION__
由 C++ 实现自行决定。通过命令行定义的宏不属于“预定义宏”,尽管它们在源码处理之前被定义。
除标准 C 规定的宏之外,其他预定义宏的命名都是实现定义的。
虽然没有固定集合,但 GNU C 编译器提供了丰富的预定义宏,其他实现可能效仿。符合标准的实现还可以条件性地定义其他宏(见下节)。
条件定义的标准宏(Conditionally Defined Standard Macros)
标准 C 允许但不强制预定义以下宏:
-
__STDC_ANALYZABLE__
(C11) -
__STDC_IEC_559__
(C99) — 表示符合 IEEE 754 浮点标准 -
__STDC_IEC_559_COMPLEX__
(C99)如果定义了
__STDC_NO_COMPLEX__
,就不得再定义此宏。 -
__STDC_ISO_10646__
(C99)C++ 也定义了此宏,表示 wchar_t 编码遵循 ISO/IEC 10646
-
__STDC_LIB_EXT1__
(C11) -
__STDC_MB_MIGHT_NEQ_WC__
(C11) -
__STDC_NO_ATOMICS__
(C11) — 不支持原子类型 -
__STDC_NO_COMPLEX__
(C11) — 不支持复数类型 -
__STDC_NO_THREADS__
(C11) — 不支持多线程 -
__STDC_NO_VLA__
(C11) — 不支持变长数组 -
__STDC_UTF_16__
(C11) -
__STDC_UTF_32__
(C11)
以下是你提供内容的完整中文翻译:
宏定义限制(Macro Definition Limit)
不同实现中,预处理器符号表可容纳的宏定义条目数及字符串空间大小可能差异很大。
C89 要求:至少支持 1024 个宏标识符同时在一个源文件中定义(包括所有
#include
的头文件)。C99 及以后版本将该限制提高到 4095。
尽管符合标准的实现需要满足这个数量要求,但宏的定义长度可能会被限制,标准并不保证你可以拥有数量众多、长度和复杂度无限制的宏定义。K&R 没有对宏定义数量或大小的限制做出说明。
建议:如果你预计将会有数百个以上的宏定义,请编写一个程序,生成包含任意数量和复杂度宏的测试头文件,测试各个编译器是否支持。
此外,应避免不必要的头文件包含,并使每个头文件模块化,只包含相关内容。多个头文件中出现同一个宏定义是可以接受的。
例如:有些实现会在多个头文件中重复定义NULL
,以避免仅为了使用一个宏就引入整个stdio.h
。
宏定义叠加(Stacking Macro Definitions)
某些实现支持“宏叠加”——即,如果一个宏已在作用域中,又定义了同名宏,后者会隐藏前者;当后者被
#undef
后,前者会恢复生效。例如:#define MAX 30 // ... MAX 为 30 #define MAX 35 // ... MAX 为 35 #undef MAX // ... MAX 恢复为 30
标准 C 不支持宏定义叠加。
K&R 表示
#undef
会使“该标识符的宏定义被遗忘”,通常意味着完全移除定义。
#
字符串化运算符(The#
Stringize Operator)这是 C89 引入的特性。
C99 增加了对空宏参数的支持,字符串化时会变成
""
。#
和##
运算符的求值顺序未定义(implementation-defined)。
##
记号粘贴运算符(The##
Token-Pasting Operator)同样由 C89 引入。它允许宏展开过程中构造新记号,并被重新扫描。例如:
#define PRN(x) printf("%d", value ## x) PRN(3);
展开为:
printf("%d", value3);
标准 C 之前的替代方法是:
#define M(a, b) a/* */b
虽然注释会被替换为一个空格,但某些实现会将
a/* */b
拼接为ab
,形成新记号。但这种做法不被 K&R 和标准 C 支持。标准 C 提供了更规范的方法:
#define M(a, b) a ## b
其中
##
运算符两侧可以有空格,也可以没有。标准 C 规定:在
A ## B ## C
的表达式中,运算顺序为实现定义。
有趣的宏替换情况(特殊粘贴情形)
#define INT_MIN -32767 int i = 1000 - INT_MIN;
宏展开后变成:
int i = 1000 --32767;
看起来像是语法错误,因为
--
是自减运算符,左侧不是 lvalue。但标准 C 的“翻译阶段”规则要求,-
和32767
保持其独立记号,不合并为--
,因此语义仍然正确。但某些非标准实现可能错误地将两个
-
合并为--
,造成不符合预期的行为。建议:为了防止这种误解析,应将宏值用括号包裹,例如:
#define INT_MIN (-32767)
重定义关键字(Redefining Keywords)
某些实现(包括标准 C)允许对 C 语言关键字进行宏重定义。例如:
#if __STDC__ != 1 #define void int #endif
建议:不要随意重定义语言关键字。
#undef
指令(The#undef
Directive)#undef
可用于移除库中的宏定义,以访问同名的真正函数。如果宏不存在,标准 C 规定
#undef
应被忽略 —— 即你可以安全地对不存在的宏执行#undef
,不会出错。详见“宏定义叠加”部分中关于
#undef
的进一步讨论。标准 C 不允许对预定义标准宏(如
__FILE__
、__LINE__
等)执行#undef
。
条件包含(Conditional Inclusion)
条件包含是 C 环境中最强大的功能之一,用于编写可在多个目标系统运行的代码。
建议:尽可能多地使用条件包含指令。前提是你维护一套有意义的宏集合,用于区分不同的目标平台。
可以参考<limits.h>
和<float.h>
来了解宿主平台特性。
#if
算术表达式(#if Arithmetic)#if
指令后的表达式是常量表达式,用来与 0 做比较。某些实现允许在表达式中使用
sizeof
,例如:#if sizeof(int) == 4 int total; #else long total; #endif
严格来说,预处理器是一个宏处理器与字符串替换工具,它无需了解 C 的类型系统。
sizeof
是编译时操作符,而此时是预处理阶段,还未进入编译阶段。K&R 使用与 C 语言相同的常量表达式定义,因此似乎允许使用
sizeof
,但没有说明是否允许类型转换。标准 C 规定:常量表达式中不得包含类型转换或枚举常量。
是否支持sizeof
表达式是实现定义的。
如果表达式中包含枚举常量,它将被视为未知宏,并默认值为 0。建议:不要在条件常量表达式中使用
sizeof
、类型转换或枚举常量。
若需要判断类型大小,可借助<limits.h>
提供的宏间接实现。C89 规定:“… 控制常量表达式的求值应遵循 C 的算术规则,并且其范围至少应覆盖《数值限制》一节中所规定的范围,且
int
和unsigned int
视为分别与long
和unsigned long
等价。”C99 改为:“… 控制常量表达式的求值应遵循 6.6 节的规则,所有有符号和无符号整数类型视为分别与
<stdint.h>
中定义的intmax_t
和uintmax_t
等价。”以下是你提供内容的完整中文翻译:
不允许使用浮点常量(Floating-point constants are not permitted)
建议:不要依赖下溢或上溢的行为,因为补码、一补码或压缩十进制机器上的算术特性差异很大。如果操作数为有符号类型,不要使用右移运算符,因为当符号位被设置时,其结果是实现定义的。
字符常量
字符常量可以作为常量表达式的一部分(此时它们被当作整数处理)。字符常量可以包含任意位模式,例如使用
'\nnn'
或'\xhhh'
。有些实现允许字符常量取负值(例如
'\377'
或'\xFA'
,其高位为1)。标准 C 规定:单字符常量是否允许为负值是实现定义的。K&R 未作说明。
标准 C 和某些实现也支持多字符常量。
建议:不要使用可能为负值的字符常量。
另外,由于多字符常量的顺序与含义是实现定义的,不要在#if
条件表达式中使用它们。
未定义的宏在表达式中默认值为 0
在标准 C 中,如果常量表达式中包含一个未定义的宏名,该宏会被视为其值为
0
(但不会真正定义为0
)。K&R 并未规定此行为。
建议:不要依赖未定义宏等于 0 的规则。如果遗漏了某个宏定义(无论是头文件还是编译命令行中),那么这个默认规则会让你误以为该宏已经被定义了。
虽然有时不实际检查是否定义,但对于那些依赖命令行定义的宏,建议始终加以检查。
为避免此类错误,建议使用构建脚本或编译批处理命令,特别是在命令行包含大量宏定义与路径时。
表达式中的错误
某些表达式可能导致错误,例如:除以 0(可能由于分母使用的宏未定义而默认为 0)。
有些实现会报告错误,有些则不会,有些甚至直接将整个表达式视为0
。建议:不要假定你的实现会在
#if
表达式中发生数学错误时报告诊断信息。
K&R 未包含逻辑非运算符
!
K&R 没有将一元运算符
!
列为常量表达式中允许使用的运算符,这通常被认为是疏忽或排版错误。
defined
运算符(Thedefined
Operator)有时需要嵌套使用条件包含指令,例如:
#ifdef VAX #ifdef VMS #ifdef DEBUG ... #endif #endif #endif
K&R 与标准 C 都支持这种嵌套结构。
标准 C(及某些 C89 之前的实现)提供了defined
一元运算符,让写法更简洁,例如:#if defined(VAX) && defined(VMS) && defined(DEBUG) ... #endif
标准 C 还保留了
defined
作为关键字,不允许在其他地方用作宏名。建议:除非所有目标环境都支持
defined
运算符,否则不要使用它。
#elif
指令在可移植代码中经常使用如下冗长的结构,K&R 和标准 C 都支持:
#if MAX >= TOTAL1 ... #else #if MAX >= TOTAL2 ... #else #if MAX >= TOTAL3 ... #else ... #endif #endif #endif
#elif
指令可以极大地简化这类嵌套结构:#if MAX >= TOTAL1 ... #elif MAX >= TOTAL2 ... #elif MAX >= TOTAL3 ... #else ... #endif
建议:除非所有目标实现都支持
#elif
,否则不要使用该指令。
嵌套条件编译指令(Nested Conditional Directives)
标准 C 保证支持至少 8 层嵌套。
K&R 也允许嵌套,但未规定最小嵌套深度。
建议:除非你确信所有目标实现都支持较深的嵌套层数,最多使用 2~3 层嵌套。
行控制(Line Control)
#line
指令的语法如下:#line 行号 #line 行号 文件名
其中行号和文件名分别用于更新
__LINE__
和__FILE__
这两个预定义宏。标准 C 允许使用宏名或字符串字面量代替文件名。也允许使用宏替代行号,只要宏展开后是十进制数字序列(前导 0 不表示八进制)。
实际上,
#line
后可以接任意预处理记号,只要在宏展开后符合上述两种形式即可。不同实现中,如果
__LINE__
用于跨多行的指令或宏调用,其取值可能不同。
空指令(The Null Directive)
标准 C 允许使用空指令:
#
该指令无任何作用,通常只出现在自动生成的代码中。虽然已存在多年,但 K&R 中并未正式定义。
#pragma
指令#pragma
是 C89 引入的。其设计目的是为实现提供扩展预处理器语法的机制。预处理器会忽略它无法识别的
#pragma
指令。因此其语法与语义为实现定义。一般格式为:#pragma token-sequence
典型用途包括控制分页、优化开关、启用/禁用静态分析等。
实现者可以自行设计各种
#pragma
。格式如下的
#pragma
:#pragma STDC token-sequence
是专门保留给标准 C 使用的,例如:
-
FP_CONTRACT
-
FENV_ACCESS
-
CX_LIMITED_RANGE
(均由 C99 引入)
_Pragma
运算符C99 引入的仅限预处理器使用的一元运算符,格式如下:
_Pragma("string-literal")
等价于使用
#pragma
指令,适用于宏中嵌入#pragma
。
#error
指令C89 引入的特性。语法如下:
#error token-sequence
其作用是让编译器在此处生成包含指定信息的诊断信息。
典型用途是在期望某些宏已定义时,发现未定义而中止编译。例如你正在移植使用变长数组(VLA)或多线程的代码,而宏:
-
__STDC_NO_VLA__
-
__STDC_NO_THREADS__
被定义了,说明当前环境不支持这些功能。
非标准指令(Non-Standard Directives)
有些实现支持其他非标准的预处理指令。这些扩展通常只对特定平台有意义,无法在其他环境中通用。
因此,在移植代码时,应识别这些指令并以其他方式重写或替代。
-