跳过正文

丙加·第12章·协议通过,执行作战(编译控制)

·1472 字·7 分钟·
目录

本章将总结之前 #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#ifdefifndef#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) pushpop 之间使用,用于 禁用 指定编号的警告。 #pragma warning(disable: 4996)
#pragma warning(default: num) pushpop 之间使用,将指定警告的设置 恢复为编译器默认 状态。 #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 在发行版本中禁用断言。

还有很多预处理指令,这里不可能一一列举讲解,如有需要,你完全可以自行查阅文档和网页——经过十二章的修炼,你应该已经养成了这样的习惯,因此我们不再赘述。


课后习题:

  1. 给定下面两个文件,请在不改动 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;
    }
    
  2. 下面这个宏有 亿点点 问题,如何初步修复?如何彻底修复?

    #define SQUARE(x) x * x
    
    int main() {
        int a = 5;
        std::cout << SQUARE(a + 1) << std::endl; // 输出什么?为什么?
        return 0;
    }
    
  3. 设计一个调试日志 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"

  4. 设计一个只能在 Windows 下编译通过的程序,其他操作系统编译会产生错误
    (编译器的操作系统宏不一定一致,请查阅自己的编译器的文档)

  5. 安全地 使用 2) 中的 SQUARE,并定义一个函数 square,仿照 内联函数 的例子比较性能差异

命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang