(一)

什么是宏

书名是『宏』,它被作者展开为这本书的全部内容。药瓶上的标签是『宏』,将药片从瓶中倾倒出来,就是这个宏的展开结果。被用的最多的『宏』,应该是 Internet 的超级链接。每当你点击一个超级链接,就相当于将这个宏展开为网页中的内容。生活中,类似的例子还有很多,只要你给某种具体的事物贴上了一个标签,那么这个标签就相当于宏。

人类非常喜欢给事物贴标签,尽管无论他们贴与不贴,那些事物本身依然是存在的。在编程中,如果你想给一段代码贴标签,最简单最直接的办法就是使用宏。那些还在用汇编语言编程的人,他们是离不开宏的,因为汇编语言本身就是将一大堆标签贴在了更大的一堆机器代码上。如果所用的编程语言不提供宏功能,可以用这种编程语言为一段代码制作一个标签——函数,不过这种标签就不是宏了,而且要付出一些性能上的代价,因为标签的展开过程被推迟到程序的运行过程。

C 语言自诞生后,只用了 5 年就让汇编语言归隐山林了,这可能要归功于 Unix 的成功以及 Dennis Ritchie 的忽悠。Steve Johnson——yacc, lint, spell 以及 PCC(Portable C Compiler)的作者说:『Dennis Ritchie 告诉所有人,C 函数的调用开销真的很小很小。于是人人都开始编写小函数,搞模块化。然而几年后,我们发现在 PDF-11 中函数的调用开销依然非常大,而 VAX 机器上的代码往往在 CALL 指令上花费掉 50% 的运行时间。Dennis 对我们撒了谎!但为时已晚,我们已经欲罢不能……』

现代的编程语言,几乎都赞同用函数来取代宏。拥护者们往往会给出一些冠冕堂皇的理由是,诸如不必额外实现一个宏处理器,函数比宏更安全并且更容易调试。事实上,他们的理由仅仅是迎合现实而已。如果将这些人扔进时空裂缝让他们穿越到 Ken Thompson 编写 Unix 系统的时代,让他们也在一台废弃的 PDP-7 型号的计算机上写程序。在这种内存只有 8KB 的计算机上,那些冠冕堂皇的理由近乎与科幻小说等价。函数之所以能够取代宏,仅仅是因为 CPU 的计算速度比过去更快了,内存比以前更大了,牺牲一些程序性能,让编程工作更容易一些,这样比较合算而已。编程语言的性能与机器的性能似乎总是成反比的。

宏被很多人主观的弃用了,得益于现代编程语言的表达能力,他们似乎几乎不需要用宏,于是他们作出结论:宏过时了。事实上,宏会永远居于众编程语言之上的,因为前者总是能够生成后者。编程专家总是会告诉我们,要慎用宏。胆子小的程序猿看到宏就躲得远远的,以至于他们总觉得那些使用宏的代码是糟糕的,是不安全的。事实上,在编程中,若能恰如其分的使用宏,可以让代码更加简洁易读,特别是对 C 语言这种表现力不足的语言。

例如下面 C 代码中的宏:

#define DEF_PAIR_OF(dtype) 
typedef struct pair_of_##dtype { 
        dtype first; 
        dtype second; 
} pair_of_##dtype##_t 

DEF_PAIR_OF(int);
DEF_PAIR_OF(double);
DEF_PAIR_OF(MyStruct);

是不是有点 C++ 模板的意味?像 C 标准库提供的 qsort 函数所接受的回调函数,也可以用类似的方法半自动生成。有关 C 语言宏的基本规则与技巧,可参考『宏定义的黑魔法 - 宏菜鸟起飞手册』。即使是表达能力很强的现代编程语言,在处理复杂问题上,也无法避免代码自身的频繁重复,妥善的使用宏总是可以消除这种重复,甚至可以创造一些 DSL(领域专用语言)。

在代码中适当的运用宏,创造优雅易读的代码,这样或许更能体现编程是一种艺术。虽然有些编程语言未提供宏功能,但是我们总是会有 GNU m4 这种通用的宏处理器可用。

GNU m4 简介

m4 是一种宏处理器,它扫描用户输入的文本并将其输出,期间如果遇到宏就将其展开后输出。宏有两种,一种是内建的,另一种是用户定义的,它们能接受任意数量的参数。除了做展开宏的工作之外,m4 内建的宏能够加载文件,执行 Shell 命令,做整数运算,操纵文本,形成递归等等。m4 可用作编译器的前端,或者单纯作为宏处理器来用。

所有的 Unix 系统都会提供 m4 宏处理器,因为它是 POSIX 标准的一部分。通常只有很少一部分人知道它的存在,这些发现了 m4 的人往往会在某个方面成为专家。这不是我说的,这是 m4 手册说的。

有些人对 m4 非常着迷,他们先是用 m4 解决一些简单的问题,然后解决了一个比一个更大的问题,直至掌握如何编写一个复杂的 m4 宏集。若痴迷于此,往往会对一些简单的问题写出复杂的 m4 脚本,然后耗费很多时间去调试,反而不如直接手动解决问题更有效。所以,对于程序猿中的强迫症患者,要对 m4 有所警惕,它可能会危及你的健康。这也不是我说的,是 m4 手册说的。

m4 基本工作过程

上文提到『m4 是一种宏处理器,它扫描用户输入的文本并将其输出,期间如果遇到宏就将其展开后输出』,其实更正式的说,应该是:m4 从文本输入流中获取文本并将其发送到文本输出流,期间如果遇到宏就将其展开后发送到文本输出流。

在 Brian Kernighan 与 Dennis Ritchie 合著的《C Programming Language》中将流(Stream)定义为『与磁盘或其它外围设备关联的数据的源或目的地』。基于这个定义,m4 的输入流就是与磁盘或其它外围设备关联的数据的源,其输出流就是与磁盘或其它外围设备关联的数据的源或目的地,只不过 m4 希望它的输入流与输出流的内容是文本。如果你不那么较真,可以将流理解为文件,对于 m4 而言,就是文本文件,但是下文会坚持使用流的概念。

m4 使用流的概念并非巧合,如果说巧合,也只是因为它的作者恰好也是 Brian Kernighan 与 Dennis Ritchie。

m4 是如何从输入流中获取文本并将其发送到输出流的?肯定不是简单的读取文本就了事,因为 m4 有一个任务是『遇到宏就将其展开』。这意味着 m4 在从输入流中读取文本的过程中至少需要检测所读取的某段文本是不是宏。也就是说,从 m4 的角度来看,它首先要将输入流所提供的文本分为两类:宏与非宏。如果 m4 读取的是一段文本是非宏,它基本上会将它们直接发送到输出流。之所以说是『基本上』,是因为非宏的文本会被进一步分类处理,其中细节后文会讲。如果 m4 读取的文本片段是宏,m4 就会将它展开,然后将展开结果发送到输出流。

m4 的工作过程具有一定程度的即时性,它不需要将输入流中全部信息都读取出来,然后再进行处理,而是扮演了一种过滤器的角色。从用户的角度来看,文本流入 m4,然后又流出。

从图灵的角度来看 m4,输入流与输出流可以衔接起来构成一条无限延伸的纸带,m4 是这条纸带的读写头,所以 m4 是一种图灵机。事实上,m4 的确是一种图灵机。因此 m4 的计算能力与任何一种编程语言等同,区别只体现在编程效率以及所编写的程序的运行效率方面。感觉基于 m4 来讲解计算机原理还是挺不错的。

m4 的工作空间

m4 既然是图灵机,它至少需要有一个『状态寄存器』,否则它无法判断当前从输入流中读取的文本是宏还是非宏。为了提高文本处理效率,还应该有一个缓存空间,使得 m4 在这一空间中高效工作。现代的 CPU,没有缓存的应该很罕见。

m4 缓存的容量为 512KB。当它满了的时候,m4 会将自动将其中的内容妥善的保存到一份临时文件中备用。所以,只要你的磁盘或其它外围设备的容量足够,就不要担心 m4 无法处理大文件。

注意,m4 缓存,这个概念是我瞎杜撰的。GNU m4 官方文档没这个概念,官方的概念是转移(Diversion)。

类似 CPU 的多级缓存,m4 的缓存空间也是划分了级别的。符合 POSIX 标准的 m4,可将缓存空间划分为 10 种级别,编号依次为 0, 1, 2, ..., 9。GNU m4 对缓存空间的级别数量不作限制。

m4 默认在 0 号缓存中工作,它在这个缓存对文本进行处理,然后将其发送到输出流。使用 m4 内建的宏 divert,可以从当前缓存切换到其他缓存。例如:

divert(3)

就从当前的缓存切换到 3 号缓存了,然后 m4 就在 3 号缓存中对输入流中的文本进行处理。如果不继续使用 divert 进行缓存切换,m4 会一直在 3 号缓存中工作,直到输入流终结。最后,m4 会将各个缓存中的文本汇总到 0 号缓存中。

缓存的汇总过程是按照缓存级别进行的。m4 会根据缓存级别的编号的增序进行汇总。例如,它总是先将 1 号缓存的内容汇总到 0 号缓存中,然后将 2 号缓存的内容汇总到 0 号缓存中,以此类推,最后将 0 号缓存中的内容依序发送到输出流中。

划分了级别的缓存,像是一道一道分水岭,使得文本流像河流一样拥有支流,不同的支流最终又汇集到一起,奔流到海……是不是有些气势恢宏的感觉,然而你也应该考虑到这样的现实:百川东到海,何时复西归?也就是说,文本流经 m4 的过程也像河流入海一样的不可逆。这是宏最大的弱点。在程序中滥用宏,形同过度开采水资源。

软件领域有一门学科,叫逆向工程,研究如何借助反汇编技术重现某个程序的原有逻辑。具体技术我不是很了解,但是幸好有这门学科,否则我的显卡很难在新版本的 Linux 内核上工作。因为 Nvidia 官方的 Linux 驱动自某个版本之后就宣布不再支持我这种型号的显卡了,而 Nvidia 官方驱动已经被大神实施逆向工程产生了 Nouveau 驱动,而后者又被集成到了 Linux 内核中。

似乎跑题了,我想表达的是,逆向工程固然能够在一定程度上复原某个程序的源码,但它却永远无法基于宏的展开结果重现宏的定义,只有宏的作者才知道当初究竟发生了什么。

这时,你应该有一个问题。如果你真的想学习 m4,那就必须要有这个问题——m4 为什么要对缓存划分级别?回顾一下上文,各个缓存的汇总过程是遵循特定次序的。有了这种分级的缓存汇总机制,你就有能力借助缓存来控制文本的支流,决定哪条支流先汇入 0 号缓存。你可以说这样你有机会扮演大禹,但是我觉得这更像铁路调度员所做的事。对于铁路调度员而言,文本流是他要调度的一组列车。

暗黑缓存

更有趣的是,m4 也提供了暗黑缓存,它的编号是 -1。GNU m4 对暗黑缓存也不限制数量,只要它们的编号是负数就可以。

暗黑缓存,似乎有点恐怖,实际上你可以将它们理解为地下河。也就是流过暗黑缓存的文本,m4 会将它们汇总到 0 号缓存,汇总过程按照暗黑缓存编号的递减次序进行的,但是 m4 不会将暗黑缓存汇总的内容发送到输出流。这没什么不好理解的,现实中没有什么东西是负数的。

在 m4 的应用中,暗黑缓存的主要作用就是作为宏定义的空间。如果在 0 号缓存定义一个宏,例如:

divert(0)
define(say_hello_world, Hello World!)

定义了一个名为 say_hello_world 的 m4 宏。宏定义语句『展开』为一个长度为 0 的字符串,然后发送到输出流。长度为 0 的字符串,就是空文本,即使它被发送到输出流,对输出流不会产生任何影响,但是 say_hello_world 宏之前,也就是divert(0) 之后存在一个换行符,m4 会将这个换行符发送到输出流。除非你原本就希望输出流中需要这个换行符,否则你就在输出流中引入了一个额外的换行符,通常情况下,它不是你想要的结果。为了更好的说明这一点,可以看下面的示例:

divert(0)
define(say_hello_world, Hello World!)
say_hello_world

这个示例就是在上述代码中又增加了一行文本,它表示调用了上一行所定义的 say_hello_world 宏。假设示例代码保存在 hello.m4 文件中,然后执行以下命令:

$ m4 hello.m4

此时,hello.m4 就是 m4 的输入流。m4 从输入流中读取文本,处理文本,然后将处理结果发送到输出流。此时,输出流是系统的标准输出设备(stdout),也就是当前的终端屏幕。

执行上述命令后,我们期望的结果通常是:

$ m4 hello.m4
say_hello_world

然而,m4 输出的却是:

$ m4 hello.m4 

Hello World!

Hello World! 前面出现了两处空行,一处是 divert 语句后面的换行符导致的,另处是 say_hello_world 宏定义语句后面的换行符导致的。

如果将 say_hello_world 宏定义语句放在暗黑缓存中,可以解决一半问题。例如:

divert(-1)
define(say_hello_world, Hello World!)
divert(0)
say_hello_world

再次执行 m4 命令,可得:

$ m4 hello.m4 

Hello World!

现在 Hello World! 前面只有 1 处空行了,它是 divert(0) 后面的换行符导致的。要消除它,有两种方法。第一种方法就是 divert(0) 后面不换行,例如:

divert(-1)
define(say_hello_world, Hello World!)
divert(0)say_hello_world

另一种方法是使用 m4 内建的 dnl 宏,它会从将它被调用的位置到后面的第一个换行符之间的文本(包括换行符本身)一并删除,例如:

divert(-1)
define(say_hello_world, Hello World!)
divert(0)dnl
say_hello_world

这两种方法输出的结果是相同的。为了让文本具有更好的可读性,通常用 dnl 来做这样的事。

挑战

(1) 对于以下 m4 代码

divert(-1)
define(say, )
define(hello, HELLO)
define(world, WORLD!)
divert(0)dnl
say hello world

推测一下 m4 的处理结果,然后执行 m4 命令检验所做的推测是否正确。

(2) 对于以下 m4 代码

divert(2)
define(say, )
define(hello, HELLO)
divert(1)
define(world, WORLD!)
divert(0)dnl
say hello world

推测一下 m4 的处理结果,然后执行 m4 命令检验所做的推测是否正确。

文章导航