You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

03 | 预处理阶段能做什么:宏定义和条件编译

你好我是Chrono。

上一次我讲了在编码阶段要有好的code style尽量写出“人能够看懂的代码”。今天我就继续讲讲编码后的预处理阶段看看这个阶段我们能做哪些事情。

预处理编程

其实只要写C/C++程序,就会用到预处理,只是大多数时候,你只用到了它的一点点功能。比如,在文件开头写上“#include ”这样的语句,或者用“#define”定义一些常数。只是这些功能都太简单了没有真正发挥出预处理器的本领所以你可能几乎感觉不到它的存在。

预处理只能用很少的几个指令,也没有特别严谨的“语法”,但它仍然是一套完整自洽的语言体系,使用预处理也能够实现复杂的编程,解决一些特别的问题——虽然代码可能会显得有些“丑陋”“怪异”。

那么,“预处理编程”到底能干什么呢?

你一定要记住:预处理阶段编程的操作目标是“源码”,用各种指令控制预处理器,把源码改造成另一种形式,就像是捏橡皮泥一样。

把上面的这句话多读几遍,仔细揣摩体会一下,理解了之后,你再去用那些预处理指令就会有不一样的感觉了。

C++语言有近百个关键字,预处理指令只有十来个,实在是少得可怜。而且,常用的也就是#include、#define、#if所以很容易掌握。不过有几个小点我还是要特别说一下。

首先,预处理指令都以符号“#”开头这个你应该很熟悉了。但同时你也应该意识到虽然都在一个源文件里但它不属于C++语言它走的是预处理器不受C++语法规则的约束。

所以预处理编程也就不用太遵守C++代码的风格。一般来说预处理指令不应该受C++代码缩进层次的影响不管是在函数、类里还是在if、for等语句里永远是顶格写

另外,单独的一个“#”也是一个预处理指令,叫“空指令”,可以当作特别的预处理空行。而“#”与后面的指令之间也可以有空格,从而实现缩进,方便排版。

下面是一个示例,#号都在行首而且if里面的define有缩进看起来还是比较清楚的。以后你在写预处理代码的时候可以参考这个格式。

#                              // 预处理空行
#if __linux__                  // 预处理检查宏是否存在
#   define HAS_LINUX    1      // 宏定义,有缩进
#endif                         // 预处理条件语句结束
#                              // 预处理空行

预处理程序也有它的特殊性暂时没有办法调试不过可以让GCC使用“-E”选项略过后面的编译链接只输出预处理后的源码比如

g++ test03.cpp -E -o a.cxx    #输出预处理后的源码

多使用这种方式,对比一下源码前后的变化,你就可以更好地理解预处理的工作过程了。

这几个小点有些杂,不过你只要记住“#开头、顶格写”就行了。

包含文件(#include

先来说说最常用的预处理指令“#include”它的作用是“包含文件”。注意,不是“包含头文件”,而是可以包含任意的文件

也就是说,只要你愿意,使用“#include”可以把源码、普通文本甚至是图片、音频、视频都引进来。当然出现无法处理的错误就是另外一回事了。

#include "a.out"      // 完全合法的预处理包含指令,你可以试试

可以看到,“#include”其实是非常“弱”的不做什么检查就是“死脑筋”把数据合并进源文件。

所以,在写头文件的时候,为了防止代码被重复包含,通常要加上“Include Guard”,也就是用“#ifndef/#define/#endif”来保护整个头文件像下面这样

#ifndef _XXX_H_INCLUDED_
#define _XXX_H_INCLUDED_

...    // 头文件内容

#endif // _XXX_H_INCLUDED_

这个手法虽然比较“原始”但在目前来说C++11/14是唯一有效的方法而且也向下兼容C语言。所以我建议你在所有头文件里强制使用。

除了最常用的包含头文件,你还可以利用“#include”的特点玩些“小花样”编写一些代码片段存进“*.inc”文件里然后有选择地加载用得好的话可以实现“源码级别的抽象”。

比如说,有一个用于数值计算的大数组,里面有成百上千个数,放在文件里占了很多地方,特别“碍眼”:

static uint32_t  calc_table[] = {  // 非常大的一个数组,有几十行
    0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
    0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
    0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
    0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
    ...                          
};

这个时候,你就可以把它单独摘出来,另存为一个“*.inc”文件然后再用“#include”替换原来的大批数字。这样就节省了大量的空间让代码更加整洁。

static uint32_t  calc_table[] = {
#  include "calc_values.inc"        // 非常大的一个数组,细节被隐藏
};

宏定义(#define/#undef

接下来要说的是预处理编程里最重要、最核心的指令“#define”它用来定义一个源码级别的“文本替换”,也就是我们常说的“宏定义”。

“#define”可谓“无所不能”在预处理阶段可以无视C++语法限制,替换任何文字,定义常量/变量实现函数功能为类型起别名typedef减少重复代码……

不过也正是因为它太灵活如果过于随意地去使用宏来写程序就有可能把正常的C++代码搞得“千疮百孔”,替换来替换去,都不知道真正有效的代码是什么样子了。

所以,使用宏的时候一定要谨慎,时刻记着以简化代码、清晰易懂为目标,不要“滥用”,避免导致源码混乱不堪,降低可读性。

下面,我就说几个注意事项,帮你用好宏定义。

首先因为宏的展开、替换发生在预处理阶段不涉及函数调用、参数传递、指针寻址没有任何运行期的效率损失所以对于一些调用频繁的小代码片段来说用宏来封装的效果比inline关键字要更好因为它真的是源码级别的无条件内联。

下面有几个示例摘自Nginx你可以作为参考

#define ngx_tolower(c)      ((c >= 'A' && c <= 'Z') ? (c | 0x20) : c)
#define ngx_toupper(c)      ((c >= 'a' && c <= 'z') ? (c & ~0x20) : c)

#define ngx_memzero(buf, n)       (void) memset(buf, 0, n)

其次,你要知道,宏是没有作用域概念的,永远是全局生效。所以,对于一些用来简化代码、起临时作用的宏,最好是用完后尽快用“#undef”取消定义避免冲突的风险。像下面这样

#define CUBE(a) (a) * (a) * (a)  // 定义一个简单的求立方的宏

cout << CUBE(10) << endl;        // 使用宏简化代码
cout << CUBE(15) << endl;        // 使用宏简化代码

#undef CUBE                      // 使用完毕后立即取消定义

另一种做法是宏定义前先检查如果之前有定义就先undef然后再重新定义

#ifdef AUTH_PWD                  // 检查是否已经有宏定义
#  undef AUTH_PWD                // 取消宏定义
#endif                           // 宏定义检查结束
#define AUTH_PWD "xxx"           // 重新宏定义

再次你可以适当使用宏来定义代码中的常量消除“魔术数字”“魔术字符串”magic number

虽然不少人认为定义常量更应该使用enum或者const但我觉得宏定义毕竟用法简单也是源码级的真正常量而且还是从C继承下来的传统用在头文件里还是有些优势的。

这种用法非常普遍,你可能也经常用,我就简单举两个例子吧:

#define MAX_BUF_LEN    65535
#define VERSION        "1.0.18"

不过你要注意,关键是要“适当”,自己把握好分寸,不要把宏弄得“满天飞”。

除了上面说的三个如果你开动脑筋用好“文本替换”的功能也能发掘出许多新颖的用法。我有一个比较实际的例子用宏来代替直接定义名字空间namespace

#define BEGIN_NAMESPACE(x)  namespace x {
#define END_NAMESPACE(x)    }

BEGIN_NAMESPACE(my_own)

...      // functions and classes

END_NAMESPACE(my_own)

这里我定义了两个宏BEGIN_NAMESPACE和END_NAMESPACE虽然只是简单的文本替换但它全大写的形式非常醒目可以很容易地识别出名字空间开始和结束的位置。

条件编译(#if/#else/#endif

利用“#define”定义出的各种宏我们还可以在预处理阶段实现分支处理通过判断宏的数值来产生不同的源码改变源文件的形态这就是“条件编译”。

条件编译有两个要点,一个是条件指令“#if”另一个是后面的“判断依据”也就是定义好的各种宏这个“判断依据”是条件编译里最关键的部分

通常编译环境都会有一些预定义宏比如CPU支持的特殊指令集、操作系统/编译器/程序库的版本、语言特性等,使用它们就可以早于运行阶段,提前在预处理阶段做出各种优化,产生出最适合当前系统的源码。

你必须知道的一个宏是“__cplusplus它标记了C++语言的版本号使用它能够判断当前是C还是C++是C++98还是C++11。你可以看下面这个例子。

#ifdef __cplusplus                      // 定义了这个宏就是在用C++编译
    extern "C" {                        // 函数按照C的方式去处理
#endif
    void a_c_function(int a);
#ifdef __cplusplus                      // 检查是否是C++编译
    }                                   // extern "C" 结束
#endif

#if __cplusplus >= 201402                // 检查C++标准的版本号
    cout << "c++14 or later" << endl;    // 201402就是C++14
#elif __cplusplus >= 201103              // 检查C++标准的版本号
    cout << "c++11 or before" << endl;   // 201103是C++11
#else   // __cplusplus < 201103          // 199711是C++98
#   error "c++ is too old"               // 太低则预处理报错
#endif  // __cplusplus >= 201402         // 预处理语句结束

除了“__cplusplus”C++里还有很多其他预定义的宏,像源文件信息的“FILE”“ LINE”“ DATE以及一些语言特性测试宏比如“__cpp_decltype” “__cpp_decltype_auto” “__cpp_lib_make_unique”等。

不过与优化更密切相关的底层系统信息在C++语言标准里没有定义但编译器通常都会提供比如GCC可以使用一条简单的命令查看

g++ -E -dM - < /dev/null

#define __GNUC__ 5
#define __unix__ 1
#define __x86_64__ 1
#define __UINT64_MAX__ 0xffffffffffffffffUL
...


基于它们,你就可以更精细地根据具体的语言、编译器、系统特性来改变源码,有,就用新特性;没有,就采用变通实现:

#if defined(__cpp_decltype_auto)        //检查是否支持decltype(auto)
    cout << "decltype(auto) enable" << endl;
#else
    cout << "decltype(auto) disable" << endl;
#endif  //__cpp_decltype_auto

#if __GNUC__ <= 4
    cout << "gcc is too old" << endl;
#else   // __GNUC__ > 4
    cout << "gcc is good enough" << endl;
#endif  // __GNUC__ <= 4

#if defined(__SSE4_2__) && defined(__x86_64)
    cout << "we can do more optimization" << endl;
#endif  // defined(__SSE4_2__) && defined(__x86_64)


除了这些内置宏你也可以用其他手段自己定义更多的宏来实现条件编译。比如Nginx就使用Shell脚本检测外部环境生成一个包含若干宏的源码配置文件再条件编译包含不同的头文件实现操作系统定制化

#if (NGX_FREEBSD)
#  include <ngx_freebsd.h>

#elif (NGX_LINUX)
#  include <ngx_linux.h>

#elif (NGX_SOLARIS)
#  include <ngx_solaris.h>

#elif (NGX_DARWIN)
#  include <ngx_darwin.h>
#endif

条件编译还有一个特殊的用法,那就是,使用“#if 1”“#if 0”来显式启用或者禁用大段代码要比“/* … */”的注释方式安全得多,也清楚得多,这也是我的一个“不传之秘”。

#if 0          // 0即禁用下面的代码1则是启用
  ...          // 任意的代码
#endif         // 预处理结束

#if 1          // 1启用代码用来强调下面代码的必要性
  ...          // 任意的代码
#endif         // 预处理结束

小结

今天我讲了预处理阶段现在你是否对我们通常写的程序有了新的认识呢它实际上是混合了预处理编程和C++编程的两种代码。

预处理编程由预处理器执行,使用#include、#define、#if等指令来实现文件包含、文本替换、条件编译把编码阶段产生的源码改变为另外一种形式。适当使用的话可以简化代码、优化性能但如果是“炫技”式地过分使用就会导致导致代码混乱难以维护。

再简单小结一下今天的内容:

  1. 预处理不属于C++语言,过多的预处理语句会扰乱正常的代码,除非必要,应当少用慎用;
  2. “#include”可以包含任意文件所以可以写一些小的代码片段再引进程序里
  3. 头文件应该加上“Include Guard”防止重复包含
  4. “#define”用于宏定义非常灵活但滥用文本替换可能会降低代码的可读性
  5. “条件编译”其实就是预处理编程里的分支语句,可以改变源码的形态,针对系统生成最合适的代码。

课下作业

最后是课下作业时间,给你留两个思考题:

  1. 你认为宏的哪些用法可以用其他方式替代,哪些是不可替代的?
  2. 你用过条件编译吗?分析一下它的优点和缺点。

欢迎你在留言区写下你的思考和答案,如果觉得对你有所帮助,也欢迎分享给你的朋友,我们下节课见。