浅谈预处理器指令 - #include & #define

· · 科技·工程

引言

预处理器指令,在代码中处处可见。无论是万物之伊始的 #include,还是不加就会爆零的 #define int long long,都是预处理器指令。但,你真的了解它吗?

观前说明:作者是个蒟蒻,对预处理器指令的实现没有深度探究,欢迎指出错误。由于曾制作过一个小游戏,所以所介绍的可能偏工程和实用,算法竞赛中可能不太能用上。至少作者本人没有。对于细节的介绍,通常是以与 OI 的关联程度和冷门程度排序。很多冷门的点都是作者查阅多篇资料的结果 ,这样你就可以找别人吹牛逼了

include

#include 可能是最常见的预处理器指令了,你可以用 typedef 避免 long long 满天飞,但你不能手搓 IO 函数。这时候,#include就发力了。本质上 #include 就是一个搬运工,把其他文件中的内容拷贝到当前文件中参与编译,这样程序员就可以偷懒了。发明 #include 的人真是一个天才!

但就是这么平常的 #include 也藏有不少细节。如下。

  1. 你有在意过 #include 后面跟的符号吗?尖括号代表从 C++系统库中寻找头文件,通常是查找编译系统 include\ 文件夹下的文件。双引号则代表从文件当前目录中查找文件。比方说你在目录下塞了个整蛊用的 iostream,用尖括号不会有事,但是用双引号就会有惊喜了。
  2. 如果 B 包含了 C,D 也包含 C,那么如果 E 包含了 D 和 B,就会拥有两份来自 C 的同样的文件。这可不妙,它会让你的编译器抛出一堆不明所以的重定义错误,然后你就要为了这一个预处理器指令查错一整天了。解决方法也很简单,用魔法打败魔法。既然编译器这么不通人性地复制了两次,那么我们就强制指定编译器只能复制一次,也就是 #pragam once 或传统的 include guard,本文只推荐 #pragam once。只需将 #pragma once 加在文件开头,就可以解决了。
  3. 作为一个复制粘贴的指令,如果被粘贴的另一个文件中粘贴了自己,那么该怎么办呢?这就是恶心的循环依赖。这种情况无论是在日常编程还是工业流程中都不该出现,也绝对不能允许它出现。这意味着两个模块耦合度极高,极度的不灵活。但是,作为一个蒟蒻,真的遇上了该怎么办呢?首要的就是修改结构,因为循环依赖就不该出现。但如果实在是太废物了,像我, 那么也可以用前向声明或声明实现分离的办法。前向声明就是告诉编译器另一个文件的内容我已经知道了,别管对不对,反正就是知道了。这样编译器就不会让另一个文件直接参与编译,而是使用其函数时提取出来。声明实现分离就是在头文件中声明,.cpp 文件中实现函数和类。好处就是只有实现的 .cpp 文件中包含了另一个头文件,还是声明,头文件只要知道有这个函数或类就行,甚至可以不包含任何其它文件,大大减少体积。不过就苦了编译器,要把实现和声明一个一个链接。

看来复制粘贴也是有门道的。以后不要笑别人键盘上就 ctrl、c 和 v 了,优秀的搬运工也是很重要的。但他可能并不那么优秀。

define

如果说 #include 可能偏工程一点,平时就用个尖括号,那么各位 OIer 对 #define 可就一点都不陌生呢。你打线段树时有用 #define lc p << 1 节省码量吗?你就没有被百调仍 WA 的题试过 #define int long long 吗?相比起 #include#define 才是适合 OIer 体质的预处理器粘贴指令。

粘贴指令?别急,等我慢慢向你介绍清楚。

  1. 我们说了编译器十分死板,不然 #include 后面两个细节和现在这项都讲不了,它只会机械的替换展开,所以它的风险也不少。如下:
    min(a++, b++)

    如果用函数实现,那么就相当于比较 a 和 b 的大小并把 a 和 b 都加上 1。但如果用宏实现,事情就变得有意思起来了。一般用宏实现会多次使用 a 或 b 来判断或返回,但如果将 a 和 b 带入,那么就会执行两次 a++ 或 b++。除非你非常清楚你在做什么并且知道这样做的结果,否则不要用宏替代 inline 函数了!它没有多少优化,反而会使你 Debug 一个小时。
    这是详细说明:

    
    min(a++, b++);

//函数实现

int min(a, b) { return a < b ? a : b; }

//它会返回+1后最小的一个数 //相当于 a++, b++; min(a, b); //----------------------------------------------------- //宏实现

define min(a, b) a < b ? a : b

//展开后

a++ < b++ ? a++ : b++

//这就相当于将两个数 +1 然后判断最小的那个数并将它 +1 并返回 //这会导致意外的 a+1 或 b+1 //非常危险,不要再用了

2. 你知道宏可以传入参数,那你知道宏可以拼接,转换参数吗?是的,使用 `##` 可以将参数拼接到另一个位置上,并且不会有空格。这样就可以灵活地实现各种功能。如果你想要输出参数名和参数值,`#` 是一个不错的选择,它可以方便的把参数转换成字符串。什么,你居然想拼接到字符串里。字符串内的内容不会被宏处理这种简单的东西自己试一下就知道了。
3. 你觉得 `#define` 指令是什么?一个替换名单?如果你知道预处理器指令是编译器编译前对文件的处理,那么你就会知道 `#define` 其实是把编译器当尼个使,让它代替你替换名称,展开宏。所以 `#define` 指令只会对编译时间有影响,但 OI 不记编译时间,你用 100 个宏指令也不会影响你 AC IOI。
4. 如果宏就这点用,那它还不值得我为它 Debug 一整天。它真正实用的地方在于可以获取编译器的信息!大部分编译器会支持 ANSI 标准的宏,如 \_\_FILE\_\_,代表源文件的路径。\_\_LINE\_\_ 是当前所在行的行号。\_\_DATE\_\_ 和 \_\_TIME\_\_ 是编译时的日期与时间。要注意的是编译运行在无修改的情况下不会重编译。利用好这些实用的宏,妈妈再也不怕我不会用调试工具了。

宏十分灵活,但因为只是简单的复制粘贴,我们还要加一些安全措施,防止宏出现问题,例如:

1. 如果你实在要用函数宏,那么你必须加上括号,除非你十分清楚你在做什么。举个栗子:
```cpp
#define AddWithoutBracket(a, b) a + b
//感谢来自 SClan_Offical 的补充,这里最好输入的参数也加上括号,不然可能因为参数优先级不如 + 导致炸掉
#define AddWithBracket(a, b) ((a) + (b))

cout << 57257 * AddWithoutBracket(1, 1) << endl;
cout << 57257 * AddWithBracket(1, 1) << endl;

看似都会输出 114514,但是实际上是输出 57528 和 114514。相信你也看懂了,没带括号会因为运算优先级等各种原因出现问题。

  1. 语句外要加 do-while。这也是因为粗糙的复制粘贴导致的,还是举个栗子:
    
    #define Action DoSomething(); DoSomething2()

define check(n) if(n) DoSomething3()

//...

//...

//...

if(false) Action if(tmp) check(1); else cout << "tmp is false\n";

聪明的你一定看出了,DoSomething2 一定会被执行,因为DoSomething2 看似和 DoSomething 在一起的,实则已经被分号分隔,不是同一个世界的人了。同理,else直接被check夺走了 ~~,无能的 if 是吧,什么 ntr 剧情~~,所以我们要禁止这种情况出现。  
解决方案:
```cpp
#define Action do{DoSomething();DoSomething2()}while(0)
#define check(n) do{if(n) DoSomething3();}while(0)

if(false) Action;
if(tmp) check(1);
else cout << "tmp is false\n";

这样 Action 和 check 都只在一个独立的块中,DoSomething2 不会与 DoSomething1 分开了,check 也无法夺走 if 的 else,皆大欢喜的结局。
并且编译器编译时会自动忽略 Do-While-0 这个没用的语句,直接去除它。重点:编译时,这意味着不会有粗糙的复制粘贴的问题出现。

为何不用{}
很简单,默认规则宏定义末尾不能加分号,加了就是大逆不道。因此如果用{},代入 check 你就可以高兴地 CE 了。

宏的内容真多,打了 2 个半小时,这可不同于 #include,编译器会帮你链接,遵循各种指令解决你的问题。到了这里编译器也开始偷懒,你只能靠自己的聪明才智与宏斗智斗勇了。

尾记

原本打算作预处理器指令全部的,但是由于作者实力有限,whk 压力又大,只能挤出一点时间做出 #define#include。后面可能会再做一篇 续 - 条件编译 & #pragma。这是蒟蒻的第一篇文章,觉得写的好就给个赞吧 QwQ。