本章将总结之前
#include、#define等语句的使用方法,并正式介绍#开头的预处理指令
12.1 我将…点燃大海 #
一个重复定义…还有…循环引用。
就此终止,没人会受伤。
否则……你们都会死(指段错误)。
侦探游戏结束了,你们不该出现在这里(指静态分析clangd)。
进程结束之后,记得告诉所有人——是星核猎手送了你们最后一程。——星核猎手·萨姆段错误:核心已转储 MISSION ACCOMPLIE.
编译是一个复杂的流程,包括 预处理、编译、汇编、链接 四个主要阶段。在正式编译之前,需要先进行 预处理——将注释替换为空格、处理 #include 包含文件、展开 #define 宏定义、执行条件编译指令(#ifdef,#if 等),最终生成纯净的 C++代码供后续编译阶段使用。
12.2 加入战场。(老朋友 #include 包含语句)
#
#include 用于引入一个(头)文件。基本语法我们已经很熟悉了:
#include 文件
其中 文件 可以是 <文件> 也可以是 "文件" ,这取决于文件是位于编译器的目录(使用 <文件>)还是你的工程目录(使用 "文件" )。不过有的 IDE 不会区分这两种差异。
#include 的本质是将文件 复制-粘贴 了一份,方便编译器处理。
所以顺带一提如果你的文件结构是这样的:
根目录
├ main.cpp
└ a.h
main.cpp:
#include "a.h"
#include "a.h"
int main() {
return 0;
}
a.h:
int x = 9;
尝试编译文件 main.cpp,你会得到类似的信息:
In file included from ./main.cpp:2:
./a.h:1:5: error: redefinition of 'a'
1 | int a = 1;
| ^
./main.cpp:1:10: note: './a.h' included multiple times, additional include site here
1 | #include "a.h"
| ^
./main.cpp:2:10: note: './a.h' included multiple times, additional include site here
2 | #include "a.h"
| ^
./a.h:1:5: note: unguarded header; consider using #ifdef guards or #pragma once
1 | int a = 1;
| ^
1 error generated.
redefinition of 'a',因为 a 被 #include 了两次,所以实际上你的 main.cpp 在编译器看来是这样的:
int x = 9;
int x = 9;
int main() {
return 0;
}
编译器虽然报了错,但是也傲娇
才不是!
地告诉你“绝对不告诉你 可以使用 #ifdef 保护或者 #pragma once 喵”(consider using #ifdef guards or #pragma once)这两种方法分别在 12.4 和 12.5 介绍。
12.3 行动四,点火(带坑的 #define 宏定义)
#
#define 可以定义常量宏:
// #define 别名 一行语句
#define ll long long
#define MAX_LEN 100000
在预处理时,编译器就会直接将别名 替换 为对应的语句。比如 int arr[MAX_LEN]; 会被直接替换为 int arr[MAX_LEN];。
碎碎念:在编译参数中定义宏
可以在编译时使用参数
-D来定义宏,比如:gcc -DNDEBUG program.c -o program.out就相当于
#define NDEBUG
#define 还可以带参数的宏:
#define abs(x) x > 0 ? x : -x
这样就能直接使用 abs(var) 计算计算绝对值了……吗?
宏定义的天坑 #
还记得之前说的宏定义是“查找-替换”模式吗?考虑这个语句,看看输出是 10 吗:
int var = -9;
std::cout << 1 + abs(var);
输出:
9
Why?!Lookin’ my eyes! 其实答案就藏在“查找-替换”这个字眼里面,经过替换,刚才的语句会变成
int var = -9;
std::cout << 1 + var > 0 ? var : -var;
我们期望的顺序是 std::cout << (1 + (var > 0 ? var : -var));,但是由于运算符优先级,实际得到的顺序是 std::cout << (1 + var) > 0 ? var : -var; 这也是为什么建议多打括号表明意图,比如这样会好一些:
#define abs(x) (((x) > 0) ? (x) : (-(x)))
但是更严重的问题不止于此,如果在宏定义里面输入了能修改变量的语句的话……
std::cout << abs(var++);
宏展开后:
std::cout << var++ > 0 ? var++ : -var++;
var 递增了三次,这显然不是我们想要的。这就是宏定义 简单替换最大的缺陷,且无法修补——这是宏定义内在的缺陷。另外,缺乏类型检查(比如 int 数据对应 int 接口)也是一大缺陷,不再举例。
因此在现代 C++中,应该避免使用宏,改用:
// 方法 1:内联函数
inline int abs(int x) {
return x > 0 ? x : -x;
}
// 方法 2:使用标准库
#include <cmath>
std::abs(var);
// constexpr if(C++ 17),以及 consteval
// 具体可以自行搜索,不展开
#include <type_traits> // --> std:: is_unsigned_v
template<typename T>
constexpr T abs(T x) {
if constexpr (std::is_unsigned_v<T>) {
return x;
} else {
return x < 0 ? -x : x;
}
}
碎碎念:inline 内联函数 (虽然与预处理关系不大)
#
内联函数是一种更高级的替换,用来减少函数调用开销,常用于优化计算量小且没有复杂逻辑的函数,内联函数 通常需要立即定义,如果先声明再定义,编译器可能忽略内联请求。
在 C 中,内联函数默认是外部、全局的,因此一般在头文件或其他外部文件中定义(不在头文件中定义会产生链接错误),否则必须加上 static 关键字表示内部链接(仅当前文件可以访问,参见 2.4.3.2),或者 extern 来恢复可被外部引用的效果。C++ 没有这样的限制,会自动处理好关系。
下面是一个例子(内联函数位于 line 13~20),其余是测试逻辑:
#include <iostream>
#include <chrono>
#include <cmath>
// 明确内联的函数
__attribute__((always_inline))
inline double inline_distance(double x1, double y1, double x2, double y2) {
// 故意写成多个步骤,防止编译器自动优化成内联
double dx = x1 - x2;
double dy = y1 - y2;
double dx_sq = dx * dx;
double dy_sq = dy * dy;
return std::sqrt(dx_sq + dy_sq);
}
// 明确不内联的版本
__attribute__((noinline))
double noinline_distance(double x1, double y1, double x2, double y2) {
double dx = x1 - x2;
double dy = y1 - y2;
double dx_sq = dx * dx;
double dy_sq = dy * dy;
return std::sqrt(dx_sq + dy_sq);
}
int main() {
const int ITERATIONS = 10000000;
double total = 0.0;
// 测试内联版本
std::cout << "--- Testing INLINE version ---" << std::endl;
auto start1 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < ITERATIONS; ++i) {
// 轻微修改参数防止编译时预计算
total += inline_distance(1.0 + i * 0.0000001, 2.0, 3.0, 4.0);
}
auto end1 = std::chrono::high_resolution_clock::now();
auto duration1 = std::chrono::duration_cast<std::chrono::microseconds>(end1 - start1);
std::cout << "Inline time: " << duration1.count() << " microseconds" << std::endl;
// 测试非内联版本
std::cout << "--- Testing NOINLINE version ---" << std::endl;
auto start2 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < ITERATIONS; ++i) {
total += noinline_distance(1.0 + i * 0.0000001, 2.0, 3.0, 4.0);
}
auto end2 = std::chrono::high_resolution_clock::now();
auto duration2 = std::chrono::duration_cast<std::chrono::microseconds>(end2 - start2);
std::cout << "Noinline time: " << duration2.count() << " microseconds" << std::endl;
double ratio = static_cast<double>(duration1.count()) / duration2.count();
std::cout << "Performance ratio (inline/noninline): " << ratio << std::endl;
return 0;
}
在笔者电脑上(编译选项:-O0)输出如下:
--- Testing INLINE version ---
Inline time: 34874 microseconds
--- Testing NOINLINE version ---
Noinline time: 43778 microseconds
Performance ratio (inline/noninline): 0.79661x
(使用 -O0 禁用优化是为了确保能看到内联的效果,在实际开发中,我们通常使用 -O2 或 -O3,那时编译器会更积极地自动内联)
原理是编译器将函数体代码直接“复制粘贴”(也就是 “内联”)到函数调用的部分,于是减少了函数调用压栈-出栈的调用开销,某些时候 可以加快运行速度。但是代价是代码膨胀(编译出来的程序变大)。代码膨胀带来的 CPU 缓存命中率下降甚至反而会降低程序运行效率;由于代码是“复制粘贴”的,在使用调试器时也无法进入函数体内部进行调试;同样的原因内联函数在 被内联的调用点 没有独立的函数地址,但如果编译器决定不内联某些调用(比如通过函数指针调用),它仍然会生成一个独立的函数实体。
总之,内联函数不是“万金油”,其适用/不适用的场景如下:
| 类别 | 适用情况 ✅ | 不适用情况 ❌ |
|---|---|---|
| 函数规模 | 非常小的函数 (1-3 行) inline int getX() const { return x; } |
较大的函数 (> 10 行) inline void bigFunction() { /* ... 大量代码 ... */ } ➤ 导致代码膨胀,降低缓存命中率。 |
| 调用频率 | 在性能关键循环中频繁调用 inline bool isReady() { return status == READY; } |
很少被调用的函数 ➤ 优化收益极小,白费编译时间。 |
| 函数类型 | 模板函数 (通常必须在头文件定义) template<typename T> T min(T a, T b) { ... } |
递归函数 inline int factorial(int n) { return n <= 1 ? 1 : n * factorial(n-1); }➤ 编译器通常无法内联,除非尾递归优化。 |
| 特殊成员 | 构造函数/析构函数(如果编译器判断有益) inline MyClass() : data(0) {} |
虚函数 virtual inline void draw() { ... }➤ 调用在运行时决定,通常无法内联。 |
| 其他场景 | 作为库接口,防止链接冲突 在头文件中定义函数。 | 通过函数指针调用的函数 void (*ptr)() = &myInlineFunc; ➤ 通过指针的调用无法被内联。 |
有趣的是,笔者测试了
int abs(x) { x>0 ? x : -x; }发现内联版本甚至开销更大,推测是内敛后将x反复计算了三次造成的结果。所以“短小”只是必要条件,而不是充分条件。换言之,内联不仅关乎函数大小,还关乎调用上下文。
对于完全无法内联的函数,即使你使用了 inline,编译器也会拒绝内联;而对于非常简单的函数,除非你明确不需要内联(__attribute__((noinline)) 或者编译时加上 -O0),编译器也会事实上对其内联。因此,在大多数情况下,将内联决策交给编译器是更好的选择:你只需要关注代码结构,将小函数自然地写在头文件或类定义中即可。
12.4 切换预案。(带条件的 #if 判断语句)
#
类似 if-else 语句,预处理也有 #if、#ifdef、ifndef、#elif、#else、#endif 支持条件编译,一个比较常用的就是根据平台编译同一函数的不同实现
#include <iostream>
// 检测操作系统
#ifdef _WIN32
#include <windows.h>
#define PLATFORM_WINDOWS 1
#elif defined(__unix__) || defined(__linux__)
#include <ncurses.h>
#include <unistd.h>
#define PLATFORM_LINUX 1
#elif defined(__APPLE__)
#include <curses.h>
#include <unistd.h>
#define PLATFORM_MACOS 1
#else
#define PLATFORM_UNKNOWN 1
#endif
// 平台特定的函数实现
// 这里为了简化直接执行系统调用
// 而不使用 api
// 现在较新的终端支持“ANSI 转义序列”,这是跨平台的:
// 相当于 <ESC>+3,也就是清屏
// printf("\033c")
void clearScreenSimple() {
#ifdef _WIN32
// 或者 #if PLATFORM_WINDOWS
system("cls");
#else
system("clear");
#endif
}
Qt 等开发框架也常用宏定义来区分版本号,比如:
#if (QT_VERSION <= QT_VERSION_CHECK(5,0,0))
// 兼容代码
#endif
此外,如果你打开标准库文件,你有可能看见这样的开头:
// 这是 llvm-mingw 的 stdio.h
#ifndef _INC_STDIO
#define _INC_STDIO
#include <corecrt_stdio_config.h>
#pragma pack(push,_CRT_PACKING)
#pragma push_macro("snprintf")
#undef snprintf
#pragma push_macro("vsnprintf")
// .....
#endif
思考如果没有 2、3、14 行,重复 #include<stdio.h> 会发生什么,加上以后又为什么能起保护作用。
| 想好了就点开吧 |
|---|
12.5 执行,焦土作战。(强大的 #pragma 语句)
#
pragma,来自希腊语 \(πρᾶγμα\)(拉丁转写就是 pragma),意思是“事务”,现代英语中相应的词是 pragmatic(务实的)。#pragma 的意思大概就是 实用指令 的意思。#pragma 的具体实现取决于编译器,对于不支持的指令,编译器会选择性眼瞎。下面简单介绍一些大部分编译器支持的操作。
12.5.1 β 模组-自限装甲(存护的 #once 声明)
#
#pragma once
这个操作基本等效于前面的 #ifndef 保护,但是没那么繁琐。在头文件开头加上这句话以后,编译器同样只会看一眼这个文件
因为多看一眼就会……
。
12.5.2 DHGDR-超新星过载(激进的 optimize("O3") 优化)
#
#pragma optimize("O3")
optimize,顾名思义就是优化的意思,除了 O3 之外还有各种优化级别,列于下表(效果 一列指的是 内联函数 的例子,内联/未内联):
| 级别 | 含义 | 效果 |
|---|---|---|
O0 |
几乎没有任何优化 代码和汇编严格对应(适合调试)。编译最快,运行(一般)最慢 |
\(34606μs / 43528μs\) \(= 0.795028\) |
O1 O |
少量优化:删除未使用的函数和变量、变量的寄存器优化、少量调度优化,但仍然适合调试 编译稍慢于 O0,运行有明显提速 |
\(1936μs / 1942μs\) \(= 0.99691\) |
O2 |
开启了几乎所有优化功能,以在不显著增加代码大小的前提下最大限度地提升代码性能。 包括 指令重排、内联函数、循环优化、尾部调用消除 等等(如需要参见编译原理), 由于目标代码相比源代码已经面目全非, O2 优化 不适合调试编译时间变长,运行速度显著加快。 |
\(0μs / 0μs\) \(= NaN\) |
O3 |
最激进的优化:不惜一切代价追求最高性能,可能会牺牲代码大小和标准符合性,甚至可能产生非预期的行为 会更广泛搜索代码寻找关联、引入更多代码内联和循环展开,因此代码体积可能显著增大,编译时间也会更明显增加 运行速度最快,甚至可以达到 O0 的上千倍 |
\(0μs / 0μs\) \(= NaN\) |
Ofast |
比 O3 还激进。会引入更多不符合标准的优化 |
\(0μs / 0μs\) \(= NaN\) |
Os Oz |
在 O2 适用于嵌入式系统等需要严格控制程序体积的场景 由于对代码体积的限制,可能会导致程序比 O0 还慢 Oz 对代码体积的优化更激进,有可能大大降低程序速度 |
\(70636μs / 72340μs\) \(= 0.976445\) 71485μs / 72873μs) \(= 0.980953\) |
Og |
在 O1 的基础上,进一步调整优化方式,减少优化后程序结构的改变对调试的干扰 | \(19677μs / 27712μs\) \(= 0.710053\) |
一般程序发布使用的是 O2,测试使用 O0,而 O3 的优化需要反复测试才可以投入使用(以防出现非预期行为)。
毕竟 超新星过载 不是所有人都受得了的(笑)
12.5.3 外接协议(外部库 comment(lib, "xxx.lib") 语句)
#
这是 MSVC 编译器的主场(其他编译器支持程度不高)。这时出于保密的需要,头文件里面可能全部是外部引用(比如,简化地表示:extern WINSOCK_API_LINKAGE int WSAAPI WSAStartup(WORD wVersionRequested,LPWSADATA lpWSAData);),没有具体实现,而外部的具体实现封装在 lib 里面。我们需要手动指示链接:
#include <winsock2.h>
#pragma comment(lib, "ws2_32.lib")
// ... 你的网络代码 ...
int result = WSAStartup(MAKEWORD(2, 2), &wsaData);
这个指令几乎是 MSVC 特有的,如果是 WinAPI 编程极有可能用得上。
碎碎念:数学库的链接
在 Linux 上编译下面的 C 代码(WSL,GNU/Linux 6.6.87.2-microsoft-standard-WSL2 x86_64)
// 注意是 C 代码 #include <math.h> int main(){ sqrt(1); return 0; }你会得到:
/usr/bin/ld: /tmp/ccYAjON2.o: in function `__gnu_cxx::__enable_if<std::__is_integer<int>::__value, double>::__type std::sqrt<int>(int)': test.cpp:(.text._ZSt4sqrtIiEN9__gnu_cxx11__enable_ifIXsrSt12__is_integerIT_E7__valueEdE6__typeES3_[_ZSt4sqrtIiEN9__gnu_cxx11__enable_ifIXsrSt12__is_integerIT_E7__valueEdE6__typeES3_]+0x23): undefined reference to `sqrt' collect2: error: ld returned 1 exit status注意到
undefined reference to `sqrt这一句。但是我们明明#include <math.h>了啊?!这是因为部分编译器的数学库是分开实现的,编译后不会自动链接。这时候#pragma不适合处理这种情况(特别是 gcc,对#pragma支持不太好)我们可以使用-lm参数:gcc ./test.c -lm -o test.out。其他库的链接错误可以查阅文档。
12.5.4 战斗…没有结束…(其他 #pragma 指令)
#
还有很多指令,编译器对它们的支持参差不齐,下面再介绍一组常用的
| 指令 | 用途 | 实例(MSVC 格式) |
|---|---|---|
#pragma warning(push) |
保存 当前所有警告的设置状态。可以把它理解为一个“存档点”。 | #pragma warning(push) |
#pragma warning(pop) |
恢复 到最近一次 push 保存的状态。可以把它理解为“读档”。 |
#pragma warning(pop) |
#pragma warning(disable: num) |
在 push 和 pop 之间使用,用于 禁用 指定编号的警告。 |
#pragma warning(disable: 4996) |
#pragma warning(default: num) |
在 push 和 pop 之间使用,将指定警告的设置 恢复为编译器默认 状态。 |
#pragma warning(default: 4996) |
12.6 任务…终止…(终止编译/运行的 assert 和 #error 语句)
#
#error 用于手动产生一个错误终止编译。常常和断言 assert 一起使用(虽然后者其实不是预处理指令)。
#error 在编译期起作用。当预处理器遇到 #error 指令时,会立即停止编译,并显示该指令后面指定的错误信息。这通常用于条件编译中,检查代码是否在不符合要求的环境下被编译,例如检查操作系统、编译器版本或某个必需的宏是否已定义。
#ifndef _WIN32
#error "This lib is for Windows ONLY!"
#endif
部分编译器还支持 #warning 发出警告,用法类似于 #error,不再赘述。
而 assert 是一个 调试宏,在运行阶段起作用,用于检查运行时逻辑是否正确,我们看一个经典的检查下标越界的例子:
#include <stdio.h>
#include <assert.h>
#define ARRAY_SIZE 5
int getValue(int array[], int index) {
// 断言:检查下标是否在有效范围内
assert(index >= 0 && index < ARRAY_SIZE);
return array[index];
}
int main() {
int numbers[ARRAY_SIZE] = {10, 20, 30, 40, 50};
printf("numbers[2] = %d\n", getValue(numbers, 2));
// 测试越界情况 - 这会触发断言失败
printf("numbers[5] = %d\n", getValue(numbers, 5));
return 0;
}
如果断言失败,assert 会让程序立刻终止(崩溃),并产生这样的报错:
test.out: ./test.cpp:8: int getValue(int*, int): Assertion `index >= 0 && index < ARRAY_SIZE' failed.
这行报错可以让你更快找到问题。
assert 是一个调试宏,其定义如下(\ 结尾表示将一行代码延续到下一行):
#ifdef NDEBUG
#define assert(expression) ((void)0)
#else
// ...
#define assert(expression) ((void)( \
(!!(expression)) || \
(_wassert(_CRT_WIDE(#expression), _CRT_WIDE(__FILE__), (unsigned)(__LINE__)), 0)) \
)
#endif
换言之可以通过 #define NDEBUG 在发行版本中禁用断言。
还有很多预处理指令,这里不可能一一列举讲解,如有需要,你完全可以自行查阅文档和网页——经过十二章的修炼,你应该已经养成了这样的习惯,因此我们不再赘述。
课后习题:
-
给定下面两个文件,请在不改动
main.c的情况下修复编译错误// pork_intestine.h const int chitterling_turns = 9; void cook(){}; // main.cpp #include "pork_intestine.h" #include "pork_intestine.h" // ~~故意~~ 不小心包含了两次 int main() { return 0; } -
下面这个宏有
亿点点问题,如何初步修复?如何彻底修复?#define SQUARE(x) x * x int main() { int a = 5; std::cout << SQUARE(a + 1) << std::endl; // 输出什么?为什么? return 0; } -
设计一个调试日志
LOG宏,在未定义NDEBUG时输出调试信息,定义了之后什么也不做DEBUG_LOG("var x = %d", x); DEBUG_LOG("on entering function foo: %p", foo);输出:
[DEBUG] var x = 1 [DEBUG] on entering function foo: 0x11451419提示:使用
...作为宏的最后“一个”参数以达到像printf那样的可变参数,使用__VA_ARGS__指代...。
"123" "456"会拼接为"123456"。 -
设计一个只能在 Windows 下编译通过的程序,其他操作系统编译会产生错误
(编译器的操作系统宏不一定一致,请查阅自己的编译器的文档) -
安全地 使用 2) 中的
SQUARE,并定义一个函数square,仿照 内联函数 的例子比较性能差异