当你编译你的项目时,你可能会期望编译器会按照你编写的确切方式编译每个代码文件。但实际上并非如此。在编译之前,每个代码(.cpp)文件都会经历一个预处理阶段。在这个阶段,一个名为预处理器的程序会对代码文件的文本进行各种更改。预处理器不会以任何方式实际修改原始代码文件——相反,预处理器所做的所有更改都是临时在内存中进行的,或者使用临时文件。
顺便说一下… 从历史上看,预处理器是与编译器分开的程序,但在现代编译器中,预处理器可能直接内置于编译器本身。
预处理器所做的大部分事情都相当无趣。例如,它会删除注释,并确保每个代码文件以换行符结尾。然而,预处理器确实有一个非常重要的角色:它处理#include指令(我们稍后将更详细地讨论这一点)。 当预处理器完成对代码文件的处理后,结果被称为翻译单元。这个翻译单元随后由编译器进行编译。
翻译过程的阶段
相关内容 预处理、编译和链接的整个过程被称为翻译。 如果你好奇,这里有一份翻译阶段的列表。截至撰写本文时,预处理包括第1至第4阶段,编译是第5至第7阶段。
预处理器指令的基本概念
当预处理器运行时,它会扫描代码文件(从上到下),寻找预处理器指令。预处理器指令(通常简称为指令)是以#符号开头并以换行符结尾(不是分号)的指令。这些指令告诉预处理器执行某些文本操作任务。请注意,预处理器不理解C++语法——相反,指令有它们自己的语法(在某些情况下类似于C++语法,在其他情况下则不那么相似)。
关键见解 预处理器的最终输出不包含任何指令——只有处理过的指令的输出被传递给编译器。
#include指令详解
你已经在实践中看到了#include指令(通常是#include
#include <iostream>
int main()
{
std::cout << "Hello, world!\n";
return 0;
}
当预处理器运行在这个程序上时,预处理器会用名为"iostream"的文件的内容替换#include
关键见解 每个翻译单元通常由一个代码(.cpp)文件和它#include的所有头文件组成(递归应用,因为头文件可以#include其他头文件)。
宏定义的类型与用法
#define指令可以用来创建一个宏。在C++中,宏是一个规则,定义了输入文本如何被转换成替换输出文本。 有两种基本类型的宏:对象样式宏和函数样式宏。 函数样式宏像函数一样起作用,并提供类似的目的。它们的使用通常被认为是不安全的,几乎任何它们能做的事情都可以通过正常函数来完成。 对象样式宏可以以以下两种方式之一定义:
#define IDENTIFIER
#define IDENTIFIER substitution_text
上面的第一种定义没有替换文本,而下面的第二种定义有。由于这些是预处理器指令(不是语句),请注意两种形式都不以分号结尾。
宏标识符使用与普通标识符相同的命名规则:它们可以使用字母、数字和下划线,不能以数字开头,并且不应以下划线开头。按照惯例,宏名称通常全部大写,用下划线分隔。
最佳实践 宏名称应该用全部大写字母书写,单词之间用下划线分隔。
带有替换文本的对象样式宏
当预处理器遇到这个指令时,会在宏标识符和替换文本之间建立一个关联。宏标识符的所有进一步出现(在其他预处理器命令中使用除外)都被替换文本替换。 考虑以下程序:
#include <iostream>
#define MY_NAME "Alex"
int main()
{
std::cout << "My name is: " << MY_NAME << '\n';
return 0;
}
预处理器将上述内容转换为以下内容:
// 这里插入iostream的内容
int main()
{
std::cout << "My name is: " << "Alex" << '\n';
return 0;
}
运行时,输出为My name is: Alex。
在C中,带有替换文本的对象样式宏被用作给字面量分配名称的方式。这不再必要,因为C++中有更好方法(在多个文件中共享全局常量(使用内联变量))。现在,带有替换文本的对象样式宏主要出现在遗留代码中,我们建议尽可能避免使用它们。
最佳实践 除非没有可行的替代方案,否则避免使用带有替换文本的宏。
不带替换文本的对象样式宏
对象样式宏也可以在没有替换文本的情况下定义。 例如:
#define USE_YEN
这种形式的宏正如你可能预期的那样工作:标识符的大多数进一步出现被移除并替换为无! 这可能看起来相当无用,对于文本替换来说确实是无用的。然而,这并不是这种形式的指令通常被用于的目的。我们很快就会讨论这种形式的用途。
与带有替换文本的对象样式宏不同,这种形式的宏通常被认为是可以接受使用的。
条件编译的应用
条件编译预处理器指令允许你指定在什么条件下某些内容会或不会编译。有很多不同的条件编译指令,但我们只覆盖最常用的几个:#ifdef、#ifndef和#endif。 #ifdef预处理器指令允许预处理器检查是否已经通过#define定义了标识符。如果是,那么在#ifdef和匹配的#endif之间的代码被编译。如果不是,代码被忽略。 考虑以下程序:
#include <iostream>
#define PRINT_JOE
int main()
{
#ifdef PRINT_JOE
std::cout << "Joe\n"; // 因为PRINT_JOE已定义,所以将被编译
#endif
#ifdef PRINT_BOB
std::cout << "Bob\n"; // 因为PRINT_BOB未定义,所以将被排除
#endif
return 0;
}
因为已经定义了PRINT_JOE,所以std::cout « “Joe\n"这一行将被编译。因为PRINT_BOB没有被定义,所以std::cout « “Bob\n"这一行将被忽略。 #ifndef是#ifdef的相反,它允许你检查一个标识符是否尚未被#define定义。
#include <iostream>
int main()
{
#ifndef PRINT_BOB
std::cout << "Bob\n";
#endif
return 0;
}
这个程序打印"Bob”,因为PRINT_BOB从未被#define定义。
你会在#ifdef PRINT_BOB和#ifndef PRINT_BOB中看到#if defined(PRINT_BOB)和#if !defined(PRINT_BOB)。这些做同样的事情,但使用了稍微更C++风格的语法。
#if 0
条件编译的另一个常见用途是使用#if 0排除代码块不被编译(就好像它在注释块内):
#include <iostream>
int main()
{
std::cout << "Joe\n";
#if 0 // 从这里开始不要编译任何内容
std::cout << "Bob\n";
std::cout << "Steve\n";
#endif // 直到这一点
return 0;
}
上述代码只打印“Joe”,因为“Bob”和“Steve”被#if 0预处理器指令从编译中排除。
这提供了一种方便的方式来“注释掉”包含多行注释的代码(这些代码不能使用另一个多行注释来注释掉,因为多行注释是非嵌套的):
#include <iostream>
int main()
{
std::cout << "Joe\n";
#if 0 // 从这里开始不要编译任何内容
std::cout << "Bob\n";
/* 一些
* 多行
* 注释在这里
*/
std::cout << "Steve\n";
#endif // 直到这一点
return 0;
}
要暂时重新启用被#if 0包裹的代码,你可以将#if 0更改为#if 1:
#include <iostream>
int main()
{
std::cout << "Joe\n";
#if 1 // 总是真的,所以以下代码将被编译
std::cout << "Bob\n";
/* 一些
* 多行
* 注释在这里
*/
std::cout << "Steve\n";
#endif
return 0;
}
在其他预处理器命令中的宏替换 现在你可能想知道,考虑到以下代码:
#define PRINT_JOE
int main()
{
#ifdef PRINT_JOE
std::cout << "Joe\n"; // 因为PRINT_JOE已定义,所以将被编译
#endif
return 0;
}
由于我们定义PRINT_JOE为无,为什么预处理器没有将#ifdef PRINT_JOE中的PRINT_JOE替换为无,并从编译中排除输出语句呢? 在大多数情况下,当宏标识符在另一个预处理器命令中使用时,宏替换不会发生。
顺便说一下… 至少有一个例外:大多数形式的#if和#elif在预处理器命令中进行宏替换。
另一个例子:
#define FOO 9 // 这里是宏替换
#ifdef FOO // 这里的FOO不会被替换为9,因为它是另一个预处理器指令的一部分
std::cout << FOO << '\n'; // 这里的FOO被替换为9,因为它是正常代码的一部分
#endif
#define的作用域 指令在编译之前被解析,从上到下逐文件进行。 考虑以下程序:
#include <iostream>
void foo()
{
#define MY_NAME "Alex"
}
int main()
{
std::cout << "My name is: " << MY_NAME << '\n';
return 0;
}
尽管看起来#define MY_NAME “Alex”是在函数foo内定义的,但预处理器不理解C++概念,如函数。因此,这个程序的行为与在函数foo之前或紧接其后定义#define MY_NAME “Alex”的程序相同。为了避免混淆,你通常希望在函数外部#define标识符。
因为#include指令用包含文件的内容替换#include指令,所以#include可以将包含文件中的指令复制到当前文件中。然后这些指令将按顺序处理。 例如,以下内容也与前面的示例行为相同: Alex.h:
#define MY_NAME "Alex"
main.cpp:
#include "Alex.h" // 从Alex.h这里复制#define MY_NAME
#include <iostream>
int main()
{
std::cout << "My name is: " << MY_NAME << '\n'; // 预处理器将MY_NAME替换为"Alex"
return 0;
}
一旦预处理器完成,该文件中所有定义的标识符都被丢弃。这意味着指令仅在它们定义的文件中从定义点到文件末尾有效。在一个文件中定义的指令对其他文件没有任何影响(除非它们被#include到另一个文件中)。例如: function.cpp:
#include <iostream>
void doSomething()
{
#ifdef PRINT
std::cout << "Printing!\n";
#endif
#ifndef PRINT
std::cout << "Not printing!\n";
#endif
}
main.cpp:
void doSomething(); // 函数doSomething()的前置声明
#define PRINT
int main()
{
doSomething();
return 0;
}
上述程序将打印: Not printing!
尽管PRINT在main.cpp中被定义,但这对function.cpp中的任何代码都没有影响(PRINT只在main.cpp中从定义点到文件末尾被#define)。这将在我们未来讨论头文件保护时变得重要。