LychieFan 2020-04-30
记录一下笔者遇到过的预处理和宏定义相关的内容。这里的总结主要来自于笔者阅读 CS106L 课程材料, C++ Primer 的内容以及官方文档。
(CS106L 是 Stanford 开设的一门关于 C++ 的课程,课程网址 CS106L.课程网站提供一份关于 C++ 编程的阅读材料( course reader )和配套的3个编程作业)
程序编译流程
总的来说,c/c++ 程序由源文件( 字符文本文件 )经历预处理、编译、汇编、链接这几个过程转换为二进制可执行文件,上述流程的示意图所下所示( csapp 原书第三版,P3 )。
以 Linux 平台下 gcc 编译程序的过程为例。其经历的主要过程如下:
1) 首先进行预处理过程,由预处理器处理源文件中以 "#"开始的预处理语句,如将 #include 语句对应的头文件包含进源文件,将 #define 定义的名字替换为实际的对象等,本文的后续即主要记录的是在这一部分中起作用的预处理指令。预处理过程结束后,源文件仍为文本文件,仍保持 c/c++ 的语言结构。
2) 编译器进行编译过程,源程序由对应的 c/c++ 语句转换为对应的汇编语句,该过程即会对源程序的语法使用进行检查,报告如行尾未包含分号‘;‘,引用了未定义的变量等错误。编译过程结束产生的是根据源文件逻辑生成的汇编文件,其内容为汇编语言;
3) 汇编器进行汇编过程,在这一过程中,将汇编阶段产生的汇编语句转换为对应的二进制数据,此阶段完成后,文件已经完成了二进制化。最后,通过链接器合并各个二进制文件,检查文件间的依赖调用关系,程序编译中常见的 "undefined reference to xxx" 错误即发生在这个阶段,最终生成可执行文件/库,完成编译过程。
gcc/g++ 使用 -E 参数指定编译过程到预处理完成后结束,-S 参数指定编译过程到编译过程后结束,-c 参数指定编译过程到汇编过程后结束。更多 gcc/g++ 的编译参数可以参考笔者的另外一篇博客Linux下编辑、编译、调试命令总结——gcc和gdb描述 。
预处理
在程序进行编译的过程中,首先由预处理程序( cpp )对源文件进行了处理,主要是对源文件中的预处理指令( directives )进行处理,生成经过预处理后的 .i 文件。预处理过程由独立的程序执行,与 c/c++ 语言无关,故而遵循与 c/c++ 不同的语法规则。预处理语句遵循以下几个语法规则:
1)预处理指令必须为所在行的第一个非空白字符;
2)一条完整的预处理指令必须处于同一行中;
3)预处理指令与 c/c++ 语句不同,在指令末尾不应该加入分号( ‘;‘ );
预处理程序依次扫描源文件,并对遇到的预处理指令进行处理,直到扫描完所有源文件内容,完成预处理过程,经过预处理过程的文件一般使用 .i 作为后缀。
预处理指令介绍
#include
#include 指令是 c/c++ 程序中最常见的预处理指令,其一般有两种形式,#include<stdio,h> 和 #include "stdio.h".当预处理器遇到 #include 指令时,会将该指令指定的头文件内容复制到源文件 #include 指令所在的位置,即使用指定头文件的内容替换 #include 指令所在行。上述两种不同的形式主要区别在于指定了不同的头文件查找位置和顺序。当使用 "stdio.h" 形式指定头文件时,会首先在当前目录下寻找对应的头文件,而使用 <stdlib.h> 形式指定头文件时,会在一个系统指定的位置寻找对应的头文件。
在 Windows 环境下,与 IDE Visual Studio 开发环境相关的编译程序和头文件等均位于 Visual Studio 的安装文件夹中,以笔者电脑为例,目录 Microsoft Visual Studio\2017\Enterprise\VC\Tools\MSVC\14.11.25503\ 为 Visual Studio 文件夹中与 VC++ 编译相关的内容,其中的 /bin 文件夹中存放的是编译所需的可执行文件,如编译器 cl.exe 和链接器 link.exe 等,/include 文件夹中存放对应的标准头文件,使用 #include <stdio.h> 指令时即在该文件夹中寻找指定的头文件。
在 Linux 环境下,系统按照文件分类将其分别放置在不同的文件夹中.与编译相关的可执行程序一般存放在 /usr/bin/ 目录下,与编译相关的标准头文件则存放在对应的 /usr/include/ 目录下。使用 #include<stdio.h> 指令时,预处理程序即在 /usr/include/ 下寻找对应的头文件。
#include "stdio.h" //首先在当前目录中查找指定的头文件 #include <stdio.h> //在系统指定的目录中寻找指定的头文件
g++/gcc 中头文件的搜索顺序可以参见 GNU Options for Directory Search. msvc 中头文件的搜索顺序参见 MSVC compilier options /I.
#define
#define 指令是另一个常用的预处理指令. #define 指令可以认为是给表达式"起"一个别名,在预处理器进行处理时,会将所有出现别名的地方替换为对应的表达式,表达式可以是数字,字符串乃至计算表达式.
#define phrase replacement //预处理在源程序中遇到 phrase 时,会将其替换为 replacement
在使用 #define 语句时,有两个地方需要注意:
1)预处理程序处理时仅进行字符对象的替换,即将"字符串" phrase 替换为"字符串" replacement ,并不会对替换的内容进行语义解析,故而在使用 #define 定义常量的别名时应该注意直接替换是否会造成潜在的语义改变.如下面的例子中, 预处理程序直接将 TEST 使用字符对象 "3+1" 替换,从而使得 x 的赋值语句变为 int x = 3 * 3+1,出现运算逻辑的改变.通过 #define 定义包含有运算的宏时,最好使用括号保证运算的逻辑不会因为外部环境改变.
#define TEST 3+1 // 定义宏 TEST,其值为 4 int x = 3 * TEST; // int x = 3 * 3+1,此时 x 的值为 10 #define TEST (3+1) int x = 3 * TEST; // int x = 3 *(3+1),此时 x 的值为 12
2) #define 语句将 phrase 后的第一个空白字符作为 phrase 与 replacement 的分界, replacement 部分对应为自 phrase 后第一个空白字符开始到行尾换行符的所有内容.比如在 #define 语句的末尾加入了分号 ‘;‘.则该分号 ‘;‘ 同样作为替换的一部分。
#define example //形式上,#define 仅定义了 phrase,没有定义 diaplacement,该写法是符合语法的,但无作用 #define test 3; //在 #define 末尾添加了‘;‘,则实际被用于替换的部分变为 "3;" int a = test + 3; //在预处理阶段被替换为 int a = 3; + 3;
#define 指令还可以定义接收参数的宏,用于定义某些重复使用但又比较简单的计算流程,比如进行两个数大小的比较。
#define Marco( arg1, arg2, ... ) macro-body //定义宏 Marco #define max(a,b) ( (a) > (b) ? (a) : (b) ) //定义宏 max,其接收两个参数 a 和 b,返回 a 和 b 中较大的值,加入括号保证运算顺序 int m = 3, n = 4; int c = max( m, n ); //预处理阶段 max 语句被替换为 int c = ( (m) > (n) ? (m) : (n) )
通常而言,由于#define 属于预处理语句,故而替换操作均在预处理阶段完成( 遵循预处理器的语法 ),而在实际编译程序时我们所能看到的大部分反馈信息都是由 c/c++ 编译器提供的 warning 和 error,所以上述预处理过程产生的错误在实际操作时可能会比较难以被 debug 发现。c++ 中也在逐渐提倡使用 const 变量和 inline 函数来尽可能的取代部分宏定义的功能,由于 const 和 inline 均为 c++ 语言所支持的语法,所以使用这些内容带来的错误可以通过编译器的报错信息反映出来,从而更容易被定位和发现。内联函数与 #define 宏的区别在于,#define 为预处理指令,其由预处理器处理,而内联函数为 c/c++ 语法规定,其由 c/c++ 编译器处理( 因而后者在出现错误时可能可以提供更多的调试信息 )。
#undef
#undef 指令后接一个名字,表示解除该名字的定义,从而不再使用或者重新定义该名字的用法。
#undef TEST //去除 TEST 的定义
更多 #undef 的使用可以参考 GNU - Undefining and Redefining Macros 和 MS - #undef directive (C/C++)。
#if / #elif / #else / #endif
预处理指令中包含有一套用于条件判断的指令。这些指令在 c/c++ 标准库头文件中常见,用于如平台、运行环境判断等方面。#if 指令的常用结构如下所示。当某个条件判断的值为真时,则预处理其会将对应的代码片段包含进源文件中,而其他部分则被直接忽略。值得注意的是,预处理指令均由预处理器进行处理,所以其支持的判断表达式与 c/c++ 本身支持的表达式有所区别。预处理指令中条件判断中的表达式仅可以包括 #define 定义的常量,整型,以及这些量构成的算数和逻辑表达式( 可以看到 c/c++ 程序中定义的变量是不被支持的 )。
#if exp1 code //若 exp1 为真,则对应的代码片段会被包含在源文件中 #elif exp2 code #else code #endif //使用 #endif 作为结尾
#ifdef / #ifndef / #defined
#defined 宏接收一个名字作为参数,返回值为 1 时表示该名字对应的宏已被定义,返回值为 0 表示值未被定义。
#if defined( TEST ) //如果宏 TEST 被定义,则将 code 包含进源文件中 code #endif
上述判断宏是否被定义的更简单的方法是使用 #ifdef 和 #ifndef 来进行条件判断,其在 c/c++ 标准库头文件中比较常见,同样用于平台、运行环境判断等方面,还可以用于避免头文件的重复包含问题。
#ifdef/#ifndef TEST //当定义/未定义宏 TEST 时( 与 #if defined( TEST ) / #if !defined( TEST ) 作用相同 ),将 code 对应的内容包含进源文件中( 供后续编译 ) code #endif
其他
字符串操作 —— # 以及 ## 操作符
在程序的预处理阶段,所有的源文件内容以字符串的方式被处理( 如进行替换 )。预处理指令可通过 # 和 # 运算符执行特定的字符串操作。
在预处理指令中,形如 #name 的操作方式代表的是 name 对应的内容的字符串表示(从而可以进行显示)。如下面的例子中,输出了 #n 的内容。这里有两点值得注意,一是在输出宏 PRINTOUT 的参数 n 时,该宏定义中使用了 ( n ),保证了 PRINTOUT 的参数 n 是一个表达式时( 如下例中 PRINTOUT 的参数为 x * 42,若不使用括号则预处理后的语句存在语法错误 )同样可以进行正确的编译;二是在正文代码中,通过 PRINTOUT( x * 42 ) 使用了宏 PRINTOUT,则字符串 "x * 42" 与宏定义的参数 n 对应,此时 #n 表示字符串常量 "x * 42"。
#define PRINTOUT(n) cout << #n << " is " << (n) << endl int x = 137; //源程序中片断 PRINTOUT(x * 42); // PRINTOUT 的参数为 x * 42,故而 PRINTOUT 定义中的参数 n 与 x * 42 对应 //预处理后片断 cout << "x * 42" << " is " << ( x * 42 ) << endl; //#n 表示对应的对象 x * 42 的字符串常量表示"x * 42",而 n 则是进行对象的替换,即将 n 替换为 x * 42,之后进行后续编译
形如 ##name 的操作方式表示字符串的拼接操作。如下例中,宏 Prefix 的作用是将参数 n 对应的内容加入前缀 "prefix_",注意使用 ##name 方式拼接后的结果并不是字符串常量,而是标志符( 供后续的编译过程处理 ),所以进行 ## 操作的结果应该满足 c/c++ 的命名规范,否则后续编译过程会产生错误。
#define Prefix(n) prefix_#n //将 prefix_ 和 n 对应的字符串内容拼接在一起 int Prefix(x); // int prefix_x; 拼接结果是标志符 prefix_x 而不是字符串常量 "prefix_x" int Prefix(3); // int prefix_3;
还有一些比较神奇的宏的用法如 X Macro trick 等,感兴趣的可以自行了解一下。