m4 的咒语

2026 年 10 月 29 日

m4 语言及其处理器诞生于约半个世纪之前,那时是上古 Unix 的洪荒年代,后来在 GNU/Linux 系统中传续和发展。当你写出第一个能被 GNU m4 展开的宏时,也许它只能替换最为简单不过的文本,可是兴许你应该能感受到一些遥远的苍凉。我不知道,这种微型的宏编程语言在现代对于你而言是否还有学习的意义,我只知道,陶埙这种原始的乐器,在今天依然能吹奏动人的音乐。

初入法门

这世上有成百上千种编程语言,每种语言都可以从编写一个能在计算机屏幕上输出 Hello world! 文字的简单程序开始学起。用 m4 语言写这个程序,大概在所有语言中最为简单,只需新建一份文件,假设其名为 hello.m4,令其内容为

Hello world!

然后,在终端(命令行环境)里且在 hello.m4 文件所在目录执行 GNU m4 命令,

$ m4 hello.m4

便可在屏幕上输出 Hello world!。

在上述示例中,hello.m4 是 m4 处理器的输入文件。该文件的内容流入 m4 处理器,再流出,其内容不变,而 m4 处理器默认会将内容流出至计算机屏幕——确切而言,是计算机标准输出设备——,故而能在屏幕上显现 Hello world!。

若想让文本在流入和流出 m4 后发生变化,需要定义一些宏,并调用它们。例如,将 hello.m4 的内容修改为

define(Hello, 你好)
define(world, 世界)
Hello world!

define 是 m4 语言的内建宏,其调用语句的第 1 个参数是宏名,第 2 个参数是宏体。

对上述修改后的 hello.m4,再次像上述那样执行 m4 命令,得到的输出为



你好 世界!

在上述示例里,Hello 和 world 皆被定义为宏,而 Hello world! 语句调用它们。在文本流入 m4 处理器的过程中,遇到普通文本,m4 处理器会将其直接发送至输出流,遇到宏调用语句,m4 处理器会将其展开为宏体,然后将结果发送至输出流,于是便得到了上述中文输出。

需要注意,上述输出的中文文本之前存在两个空行,它们是 define 语句末尾的换行符形成的。define 语句本身也是宏调用语句,只是其展开结果为空文本,而其后的换行符是输入流中的普通文本,会被 m4 处理器发送到输出流,从而形成了两个空行。

使用 dnl 宏可以删除 define 语句之后的换行符,例如

define(Hello, 你好)dnl
define(world, 世界)dnl
Hello world!

流过 m4 处理器后,可得

你好 世界!

dnl 也是 m4 语言的内建宏,用于删除同一行中位于其后的所有文本,包括换行符。由于 dnl 具备删除文本的能力,故而在使用 m4 语言编写代码时,可以用它作为注释符使用,例如

define(Hello, 你好)dnl 定义 Hello 宏
define(world, 世界)dnl 定义 world 宏
dnl 调用 Hello 和 world 宏
Hello world!

上述文本流过 m4 处理器,所得结果依然是

你好 世界!

不过,没办法定义中文宏,例如

define(你好, Hello)dnl
define(世界, world)dnl
你好 世界!

流过 m4 处理器,得到的依然是

你好 世界!

原因很简单,m4 语言只允许使用英文字母、下划线和数字构造宏名,用其他字符构造的宏名是无效的。此事怨不得 m4 语言,在它诞生之后的很多年里,其处理器赖以生存的计算机世界一直没有出现汉字。

确切而言,m4 的宏名以字母或下划线开头,后跟字母、数字或下划线,亦即宏名不能以数字开头。这种命名规则与 C 语言一致,大概并非出于巧合,而是因为 m4 的发明者皆与 C 语言渊源颇深,其一是 Unix 名宿 Brian Kernighan,其代表著作是《C 程序设计语言》,另一位干脆是 C 语言之父 Dennis Ritchie。

野马也,尘埃也

从现在开始,我将 m4 处理器简称为 m4。有时,我也会将 m4 语言简称为 m4。有上下文的存在,二者应该不会出现混淆。

对于新手而言,m4 的宏,状如野马,不小心就会摔得七荤八素,兴许还能让 m4 失去意识。例如,当

define(hello, hello world!)dnl
hello

流过 m4 时,会致其僵死,需要以 Ctrl + c 键终止。

发生了什么呢?m4 在展开 hello 宏调用语句时,得到 hello world!,然后 m4 会对宏调用语句的展开结果再次扫描,若遇到宏调用语句则继续予以展开,于是 hello world! 中的 hello 会被展开,又得到 hello world!……这个过程无有穷尽,m4 陷入其中。

要避免 m4 对宏调用语句的展开结果再次展开,需要用引号对文本加以保护。m4 的引号,状若尘埃……左引号是 `,右引号是 ',不小心很容易看不到它们,或者很小心去看它们,又容易看不清有多少……将上述文本修改为

define(hello, `hello world!')dnl
hello

将其再度流过 m4,得到

hello world!

……那是不可能的!m4 依然会进入僵死状态,原因是,m4 处理器遇到带引号的一段文本,会将其最外层的引号脱去,将剩余文本发送至输出流。这意味着,在上述示例中,m4 在处理 define 语句时,便已经将 hello world! 外围的引号脱去了,所以 hello 的展开结果依然会导致 m4 陷入到无有穷尽的 hello 展开过程。

既然最外层的引号会被 m4 处理器脱除,若在 hello 的定义里再加一层引号,

define(hello, ``hello world!'')dnl
hello

第一层引号会在 m4 处理 define 语句时脱去,于是 hello 的展开结果是

`hello world!'

这个结果对于 m4 而言,是再次遇到带引号的文本,将引号脱去,将剩余结果发送至输出流,于是最终结果便是

hello world!

在定义宏时,宏的名字有时未必如你所见。例如

define(foo, `Foo')dnl
define(foo, `-Foo-')dnl
foo Foo

请问上述文本的展开结果是什么?foo 宏被定义了多次,以最后一次定义为准,于是 foo 的展开结果为 -Foo-。Foo 不是宏,会被 m4 原样发送至输出流。上述文本的展开结果为

-Foo- Foo

……倘若你也觉得应当是这个结果,需要恭喜你有机会更深入地理解 m4 宏了。这个答案是错的,正确的答案是无数个 - 形成的文本,无有穷尽,原因是,第 2 个 foo 的定义语句里,宏名 foo 未加引号抑制,m4 会将其视为宏调用语句,于是该定义语句在 m4 看来,实际上是

define(Foo, `-Foo-')

亦即第 2 个 foo 定义语句所定义的宏实际上 Foo。然后,m4 处理文本 foo Foo,其中 foo 被展开为 Foo。m4 发现 Foo 是宏调用语句,继续展开,结果得到 -Foo-,而该结果里又有 Foo,于是 m4 继续将其展开,结果便是 --Foo--……这个过程也是无有穷尽的,所以 m4 会陷入不断展开 Foo 的过程里,我们能看到的是 m4 疯狂输出 - 字符,但是永远都看不到 Foo,因为它一直在被展开着。

倘若将上述文本修改为

define(`foo', `Foo')dnl
define(`foo', `-Foo-')dnl
foo Foo

令其流过 m4,便如上文所想,第 1 个 foo 定义会被第 2 个 foo 定义取代,结果为

-Foo- Foo

上述示例定义的宏,皆无参数。m4 宏可以有参数,define 便是有参数的宏。在宏定义中,参数以 $1,$2,... 表示,例如

define(`foo', `![$1]($2)')dnl
foo(`插图 1', `a.png')

在 m4 语言的标准中,一个宏最多可以有 9 个参数,但 GNU m4 对此不作限制。

上述文本的展开结果是 markdown 的插图标记,

![插图 1](a.png)

在定义 m4 宏时,要有意识地用引号加以约束,使其不会被 m4 在不经意间展开了。一旦领悟了 m4 宏的定义和引号的用法,便可明白庄子所说的,野马也,尘埃也,生物之以息相吹也,而且野马就是野马,并非游气。

相吹之息

有时,你未必知道如何从自己所用的输入法打出直角引号「 和 」,可是在写一篇文章时,你需要使用这种引号。如果你不想为此事耗费心神研究自己的输入法如何使用,可以为直角引号作简单的编码,例如用 _lq_ 和 _rq_ 分别表示直角左引号和右引号,便可写出含有这种引号的文本,

这是含有_lq_直角引号_rq_的文本。

假设上述内容保存在文件 foo.txt 里,然后,创建含有 m4 宏定义的文件 foo.m4,其内容为

define(`_lq_', `「')dnl
define(`_rq_', `」')dnl

foo.m4 文件中出现的直角引号,可以从网络上的一些文档里复制过来。现在,使用 m4 命令,处理 foo.m4 和 foo.txt,

$ m4 foo.m4 foo.txt

m4 的输入流可以由多个文件构成。m4 会依序读取所有输入文件,将其拼接成一个输入流。基于该特性,上述命令营造的效果是,将 foo.m4 作用于 foo.txt,所得结果为

这是含有「直角引号」的文本。

由于 foo.m4 可以重复使用,这意味着你今后可以一直使用 _lq_ 和 _rq_ 代替直角引号。此时,m4 其实已经化身为一个简单的文本编辑器或者输入法了,所有你觉得输入起来颇为麻烦的内容,只要它们是重复的,便可将其定义为简单的宏,然后在文本里调用。

m4 语言提供的内建宏 incr 和 decr 可以对数字作增减运算。例如

decr(0), incr(`0')

展开结果为

-1, 1

利用 incr 宏,可以实现计数器宏,

define(`_N_', 0)dnl
define(`counter', `define(`_N_', incr(_N_))_N_')dnl
counter, counter

上述文本的展开结果为

1, 2

原因在于,含有 2 个 counter连续的文本会被展开为

define(`_N_', incr(_N_))_N_, define(`_N_', incr(_N_))_N_

亦即,_N_ 会被两次重定义,而且每次重定义后会立即被调用。现在,你应该认识到了 m4 对宏的展开结果再度展开的能力了。只要宏的展开结果里依然存在宏,m4 会继续将其展开,除非遇到引号文本。

在定义宏的时候,你很有可能会陷入引号取舍的困境。例如,你很有可能会将 counter 定义为

define(`counter', `define(`_N_', `incr(_N_)')_N_')dnl

该定义会导致 m4 在处理第 1 个 counter 时,就会陷入递归展开困境,因为 counter 的展开结果是

define(`_N_', `incr(_N_)')_N_

m4 对该结果再度展开,得到

incr(_N_)

可是 _N_ 是宏,于是 m4 继续展开,则上述文本应当变为

incr(incr(_N_))

但是结果里又出现了 _N_……

避免引号取舍困境之道在于,你要先想清楚自己想要的结果是什么,然后在此结果上加一层引号即可作为宏体。例如,我想要的结果是

define(`_N_', incr(_N_))_N_

那么,只需将 counter 定义为

define(`counter', `define(`_N_', incr(_N_))_N_')

不要去设计宏,要从想要的结果倒推出宏的定义。这是宏编程与常规编程最明显的区别。如果你用常规编程的思维使用宏语言,肯定会很累。常规编程,你所写的代码像是在构造一个命题,你需要用各种手段(编译器层面的形式论证和单元测试等)去证明这个命题一定能推导出你期望的结果。可是对于宏编程而言,结果已经是存在的,我们只需要为其设定一个简单的原因。

有了计数器,就能构造出一些能自动排版编号的宏了,例如,如果你想得到第 1 节 xxxx,第 2 节 yyyy 之类的内容,亦即

第 counter 节 $1

只需将上述内容加上引号,作为宏体,定义

define(`sec', `第 counter 节 $1')dnl

然后使用 sec 宏便可构造带序号的小节标题了,例如

sec(`你好,世界!')
... ... ...
sec(`野马也,尘埃也')
... ... ...

注意,sec 中的参数若皆为中文字符(包括标点),由于 m4 不会将英文字母、下划线和数字之外的字符视为宏名,即使不用引号,通常也是安全的。

展开结果为

第 1 节 你好,世界!
... ... ...
第 2 节 野马也,尘埃也
... ... ...

基于这种思路,甚至能为自己定义一种标记语言,其功能不会输给 markdown 之类的文本标记语言,只是这种标记语言在风格上会有些像 C 语言。

前文说,m4 语言的宏名只能由字母、下划线以及数字构成,且不能以数字作为开头,但是 GNU m4 作出了扩展,允许使用任意字符构造宏名,但是所定义的宏只能以内建宏 indir 的形式间接调用。例如

define(`你好,世界!', `第 1 节')dnl
indir(`你好,世界!')

倘若名字为非法的宏带有参数,例如

define(`你好,世界!', `第 $1 节')dnl

其间接调用可写为

indir(`你好,世界!', 1)

indir 宏的第 2 个及其之后的参数,皆会作为参数转发给其调用的宏,故而上述文本的展开结果为

第 1 节

基于 indir,可以令 sec 宏具有引用功能。由于宏的定义可由结果倒推而成,先写出期望得到的结果,即先以宏 _M_ 保存当前 _N_ 的值,然后排版小节标题,继而以小节的名字定义宏,

define(`_M_', counter)第 _M_ 节 $1`'define(`$1', 第 _M_ 节)

若引号内不包含文本,m4 会将其视为空文本,这种引号作为间隔符号使用,阻止与宏名相邻的字符污染宏名。上述示例中 $1 和 define 之间虽然无需间隔,但是加了间隔,有助于在视觉上区分二者。

先以宏 _M_ 保存当前 _N_ 的值,然后排版小节标题,继而以小节的名字定义宏,将小节序号作为其体。将上述文本加上一层引号,便可作为 sec 宏体,

define(`sec', `define(`_M_', counter)第 _M_ 节 $1`'define(`$1', 第 _M_ 节)')dnl

则以下文本

sec(`你好,世界!')
... ... ...
sec(`野马也,尘埃也')
... ... ...
如`'indir(`你好,世界!')所述,……
... ... ...

的展开结果为

第 1 节 你好,世界!
... ... ...
第 2 节 野马也,尘埃也
... ... ...
如第 1 节所述,……
... ... ...

这个结果意味着,重新定义的 sec 宏,除了依然能排版小节标题之外,还具备纯文字形式的交叉引用机制。

子在川上曰

m4 有一个很有趣而且在某些场景里颇为有用的功能,其输出流可以岔开为若干带有数字编号的支流,使用内建宏 divert 可将当前输出流切换至指定支流。编号为负值的支流,其中内容会被 m4 直接丢弃。0 号流是 m4 默认的输出流,其他支流会被 m4 以编号升序顺序依次合并至 0 号流。例如

... 0 号流 ...
divert(1)dnl 将后续内容导入 1 号流
... 1 号流 ...
divert(3)dnl 将后续内容导入 3 号流
... 3 号流 ...
divert(2)dnl 将后续内容导入 2 号流
... 2 号流 ...

展开结果为

... 0 号流 ...
... 1 号流 ...
... 2 号流 ...
... 3 号流 ...

注意,在 m4 的输入流中,3 号流的内容虽然在 2 号流之前,可是在 m4 的输出里,却出现在 2 号流之后。这意味着,在 m4 的输入流中,可以让一些内容提前出现,但是在 m4 的输出流中,可以令其延迟出现。

另一个内建宏 undivert 可将指定的支流内容输出至当前输出流。例如,

... 0 号流 ...
divert(1)dnl
... 1 号流 ...
divert(3)dnl
undivert(1)dnl 将 1 号流内容输出至当前流,即 3 号流
... 3 号流 ...
divert(2)dnl
... 2 号流 ...

展开结果为

... 0 号流 ...
... 2 号流 ...
... 1 号流 ...
... 3 号流 ...

上述示例意味着,使用 undivert 可将某些支流内容提前输出至指定流中,亦即通过 undivert 可以扰乱 m4 的支流合并顺序。

充分利用 divert 和 undivert,便可以像魔术师一样随意操纵文本输入和输出顺序。例如,

#include <stdio.h>
int main(void) {
    int *a = malloc(sizeof(int));divert(1)free(a);divert(0)
    *a = 42;
    double *b = malloc(sizeof(double));divert(1)free(b);divert(0)
    *b = 3.14;
    printf("a is %d, b is %lf\n", *a, *b);
    undivert(1)
    return 0;
}

展开结果为

#include <stdio.h>
int main(void) {
    int *a = malloc(sizeof(int));
    *a = 42;
    double *b = malloc(sizeof(double));
    *b = 3.14;
    printf("a is %d, b is %lf\n", *a, *b);
    free(a);free(b);
    return 0;
}

倘若你对 C 编程语言有所了解,大概能理解上述示例发生了什么。在 m4 的输入中,free(a); 和 free(b);提前出现,但它们会被输出至 1 号流,但是在 return 语句之前用 undivert 提前将 1 号流内容合并至 0 号流,亦即此时 1 号流被临时清空。这个示例意味着什么呢?意味着能够定义两个 m4 宏,让 C 语言具备一些自动释放内存的能力,例如

define(`_malloc_', `$2 *$1 = malloc(sizeof($2))divert(1)free($1);divert(0)')dnl
define(`_return_', `undivert(1)return $1')dnl

上述示例 C 代码可简化为

#include <stdio.h>
int main(void) {
    _malloc_(a, int);
    *a = 42;
    _malloc_(b, double);
    *b = 3.14;
    printf("a is %d, b is %lf\n", *a, *b);
    _return_(0);
}

展开结果为

#include <stdio.h>
int main(void) {
    int *a = malloc(sizeof(int));
    *a = 42;
    double *b = malloc(sizeof(double));
    *b = 3.14;
    printf("a is %d, b is %lf\n", *a, *b);
    free(a);free(b);return 0;
}

由于编号为负数的支流会被 m4 抛弃,亦即其内容不会被合并至 0 号流中。利用该特性,可以将宏的定义导入到这类支流中,从而无需再在每个宏定义之后添加用于消除多余换行符的 dnl。例如

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

注意,divert(0) 之后的 dnl 还是需要的,不然 0 号流的第一个字符是换行符。

展开结果为

HELLO WORLD!

山外青山楼外楼

有时,会遇到一些结构化文本。例如以下 ConTeXt 排版代码

\starttext
... ... ...
\startitemize
\item ... ... ...
\item ... ... ...
\stopitemize
... ... ...
\stoptext

如果你懒得在 \stop 语句再输入一次 text 和 itemize,也许会期望将上述内容简写为

_start(text)
... ... ...
_start(itemize)
\item ... ... ...
\item ... ... ...
_stop_
... ... ...
_stop_

若要达成该期望,只需将 _start 和 _stop_ 定义为 m4 宏,由 m4 将上述文本展开为 ConTeXt 排版代码。

注意,_start 宏需要有参数,为了与无参数的宏有所区分,我故意令其右侧不出现下划线。

_start 宏很好定义,

define(`_start', `\start$1')

可是 _stop_ 就很难了,因为它没有参数,\stop 该与什么衔接呢?

若在 _start 的定义中再定义一个宏,将 _start 的参数保存下来,然后在 _stop_ 的定义中使用,

divert(-1)
define(`_start', `\start$1`'define(`_cmd_', `$1')')
define(`_stop_', `\stop`'_cmd_')
divert(0)dnl

的确能将

_start(text)
... ... ...
_stop_

展开为

\starttext
... ... ...
\stoptext

可是,若遇到以下文本,

_start(text)
_start(itemize)
_stop_
_stop_

结果则是

\starttext
\startitemize
\stopitemize
\stopitemize

原因是,_start(text) 定义的 _cmd_,在调用 _start(itemize) 时被覆盖了,导致 _cmd_ 的展开结果是 itemize,而不再是 text。

由于你已经掌握了计数器宏的写法,也许你会想出下面这个巧妙的方案,

divert(-1)
define(`_N_', 0)
define(`_N_incr_', `define(`_N_', incr(_N_))')
define(`_N_decr_', `define(`_N_', decr(_N_))')
define(`_start', `\start$1`'_N_incr_`'define(`_cmd'_N_, `$1')')
define(`_stop_', `\stop`'indir(_cmd`'_N_)`'_N_decr_')
divert(0)dnl

这个巧妙的方案是可用的,不过也许你需要经过多次失败后,方能将其写得正确,而且一旦写出来,你就会发现它已经像天书了,过些时日也许你自己都看不懂了。

前文曾说过,宏不是设计出来的,而是基于结果倒推出来的。以上述最为复杂的 _start 宏为例,先不要去想如何去定义它,而是先写出自己想要什么。例如,我想要的是

\starttext`'计数器递增`'define(`_cmd'计数器当前的值, `text')

或

\startitemize`'计数器递增`'define(`_cmd'计数器当前的值, `itemize')

将可变部分表示为宏的参数,将计数器递增表示为 _N_incr_,将计数器的值表示为 _N_,于是可得

\start$1`'_N_incr_`'define(`_cmd'_N_, `$1')

将上述内容添加一层引号,便可作为 _start 宏体,

define(`_start', `\start$1`'_N_incr_`'define(`_cmd'_N_, `$1')')

这种倒推过程很容易理解,只是倒推的结果容易令人晕眩,而且甚至会产生误解,认为需要有一些超乎寻常的能力方能写出如此晦涩的代码。实际上,你需要掌握的只是这种倒推过程,无论何时都没有必要去理解倒推结果!即使以后你需要修改宏的定义,实际上你要修改的是上述倒推过程。

理解不了上述过程,也无妨,它不过是以一种简单粗暴的方式实现了一种脆弱的栈结构罢了。m4 为宏定义提供了栈空间,可以临时用 pushdef 将宏定义压入栈中以备调用,用毕之后,可使用 popdef 将其从栈中弹出。例如

\starttext`'pushdef(`_block_name_', `text')
\startitemize`'pushdef(`_block_name_', `itemize')
\stop`'_block_name_`'popdef(`_block_name_')
\stop`'_block_name_`'popdef(`_block_name_')

展开结果为

\starttext
\startitemize
\stopitemize
\stoptext

用上述倒推的方式很容易写出更好的 _start 和 _stop_ 宏,

divert(-1)
define(`_start', `\start$1`'pushdef(`_block_name_', `$1')')
define(`_stop_', `\stop`'_block_name_`'popdef(`_block_name_')')
divert(0)dnl

若你能正确写出 _start 和 _stop_ 这样的宏,意味着在 m4 语言的世界里,你已经可与写出本节内容时的我比肩而立了。

或跃在渊

上一节定义的 _N_incr_ 和 _N_decr_ 宏虽然被 m4 内建宏 pushdef 和 popdef 降维打击了,但它们的存在,意味着我们有能力定义更为通用的操作计数器的宏。m4 的 incr 和 decr 只能操作数字,它无法将运算结果保存起来备用。如果定义 _incr_ 宏,令其调用语句形如

_incr(`n')

希望其展开结果形如

如果 _counter_n_ 未定义,则 define(`_counter_n_', 1)
否则 define(`_counter_n_', incr(_counter_n_))

该如何实现呢?

m4 语言提供了条件表达式。内建宏 ifdef 可用于检测一个宏是否有定义,例如

ifdef(`_counter_n_', `a', `b')

其意为,若 _counter_n_ 宏有定义,则结果为 a,否则为 b,由于我们并未定义 _counter_n_ 宏,故而该表达式的展开结果为 b。若在条件表达式之前给出定义 _counter_n_,

define(`_counter_n_', 0)dnl
ifdef(`_counter_n_', `a', `b')

则展开结果为 a。

基于 ifdef 宏,上述我们假想的 _incr_ 宏的展开结果可表示为

ifdef(`_counter_n_', 
      `define(`_counter_n_', incr(_counter_n_))',
      `define(`_counter_n_', 1)')

由于 n 是 _incr_ 宏的参数,故而上述代码可改写为

ifdef(`_counter_$1_', 
      `define(`_counter_$1_', incr(_counter_$1_))',
      `define(`_counter_$1_', 1)')

用一层引号包含上述代码,便可作为 _incr 的宏体了,

define(`_incr',
       `ifdef(`_counter_$1_', 
             `define(`_counter_$1_', incr(_counter_$1_))',
             `define(`_counter_$1_', 1)')')

以下代码

_incr(`n') _counter_n_
_incr(`n') _counter_n_
_incr(`n') _counter_n_

展开结果为

 1
 2
 3

方便起见,再定义一个专用于取计数器值的宏,

define(`_get', _counter_$1_)

则上述示例可修改为

_incr(`n') _get(`n')
_incr(`n') _get(`n')
_incr(`n') _get(`n')

展开结果不变。同理,可以定义 _decr 宏,

define(`_decr',
       `ifdef(`_counter_$1_', 
             `define(`_counter_$1_', decr(_counter_$1_))',
             `define(`_counter_$1_', 0)')')

综合应用 _incr,_get 以及 _decr,

_incr(`n') _get(`n')
_incr(`n') _get(`n')
_decr(`n') _get(`n')
_incr(`n') _get(`n')

展开结果为

 1
 2
 1
 2

现在,我们可以尝试定义一个更通用的计数器操作宏 _op,期望 _op(n', ++') 的展开结果为

若 $2 是 `++' 则 _incr(`$1'); 若 $2 是 `--' 则 _decr(`$1')

m4 提供了内建宏 ifelse,基于它可以表示上述结果,

ifelse(`$2', `++', `_incr(`$1')', `ifelse(`$2', `--', `_decr(`$1')')')

上述语句单从逻辑上,可略写为

ifelse(`$2', `++`, `a', `b')

意思是,若 $2 是 ++,则结果为 a,亦即 $1 增 1,否则结果为 b,亦即再度判断 $2 是否为 -- 并做相应处理。

若将 ifelse 语句写成

ifelse(a, b, c)

意思是,若 a 和 b 相同,则结果为 c,否则结果为空。

若用引号包含上述最为复杂的那条 ifelse 语句,便可将其作为 _op 的宏体,

define(`_op', `ifelse(`$2', `++', 
                      `_incr(`$1')', 
                      `ifelse(`$2', `--', 
                              `_decr(`$1')')')')

验证 _op 是否可用,

_op(`n', `++') _get(`n')
_op(`n', `++') _get(`n')
_op(`n', `--') _get(`n')
_op(`n', `--') _get(`n')

结果应当为

 1
 2
 1
 0

将上述宏定义汇总起来,

divert(-1)
define(`_incr',
       `ifdef(`_counter_$1_', 
             `define(`_counter_$1_', incr(_counter_$1_))',
             `define(`_counter_$1_', 1)')')
define(`_decr',
       `ifdef(`_counter_$1_', 
             `define(`_counter_$1_', decr(_counter_$1_))',
             `define(`_counter_$1_', 0)')')
define(`_op', `ifelse(`$2', `++', 
                      `_incr(`$1')', 
                      `ifelse(`$2', `--', 
                              `_decr(`$1')')')')
define(`_get', _counter_$1_)
divert(0)dnl

你对 m4 语言的理解和应用,已是小有气象了。

反者道之动

利用 m4 会对宏的展开结果再度扫描和展开这一特性,可以构造出一些递归计算过程,虽然我并不推荐此道,也认为计算并非 m4 所长,但是对此有所了解,如同在玩一些简单的心智游戏,而最好的心智游戏,应该是递归的。

m4 的内建宏 eval 可以对整数运算表达式求值,例如

eval(`1 + 2')
eval(`1 - 2')
eval(`1 < 2')
eval(`2 >= 3')

结果为

3
-1
1
0

斐波那契数列的递推公式为

F(n) = F(n - 1) + F(n - 2), n >= 2
F(1) = 1
F(0) = 0

若以 Fib 宏表示函数 F,基于上述递推公式可以算出 F(n) 的值,

ifelse(`n', 0,
       0,
       `ifelse(`$1', 1,
               1,
               `eval(Fib(eval(`n - 1')) + Fib(eval(`n - 2')))')')

在数学中是用 n 表示参数,但是 m4 宏是以 $1 表示参数,故而将上述代码中的 n 皆替换为 $1,然后在代码外围添加一层引号,便可得到 Fib 的定义,

define(`Fib', `ifelse(`$1', 0, 
               0, 
               `ifelse(`$1', 1, 
                       1,
                       `eval(Fib(eval(`$1 - 1')) + Fib(eval(`$1 - 2')))')')')

Fib 宏的展开结果便是斐波那契数,例如

[Fib(0), Fib(1), Fib(2), Fib(3), Fib(4), ..., Fib(30)]

展开结果应当是

[0, 1, 1, 2, 3, ..., 832040]

当 $1 较大时,Fib 的展开过程很慢。在我这台颇为年长的机器里,展开上述文本大概要 12 秒。若试图展开 Fib(42),等待的时间足够让我屡次疑心代码写错了……之所以展开很慢,是因为 Fib 的递归过程中充满了大量的重复计算,例如,要计算 Fib(3),需要先算出 Fib(2) 和 Fib(1),而要计算 Fib(4),需要先算出 Fib(3) 和 Fib(2),于是 Fib(2) 被计算了两次。随着 $1 的增大,Fib 展开过程中的重复计算次数会急剧飙升。

若想改善 Fib 宏的计算性能,需要设法构造计算历史,亦即将一些计算结果保存起来,以备后续计算过程使用。假设用 _A_ 表示 F(1),用 _B_ 表示 F(0),

define(`_A_', 1)
define(`_B_', 0)

现在,若计算 F(2),只需

eval(_A_ + _B_)

不过,我希望这个计算结果能存储在 _A_ 里,而 _A 原有的值则转移给 _B_,

define(`_A_', eval(_A_ + _B_))
define(`_B_', _A_)

在 m4 语言里,这自然是不可能的,因为重新定义 _B_ 时,_A_ 原有的值被新值覆盖了。不过,我们可以再定义一个宏,保存 _A_ 的旧值,然后再更新 _A_ 和 _B_,

define(`_C_', _A_)
define(`_A_', eval(_A_ + _B_))
define(`_B_', _C_)

如果反复执行上述过程,例如执行 3 次,则 _A_ 的值就是 F(4),而 _B_ 的值是 F(3)。这意味着,要计算 F(n),当 n > 1 时,只需将上述过程执行 n - 1 次,则 _A_ 值便是 F(n)。可是,如何将一个计算过程重复执行指定的次数呢?m4 语言没有像主流编程语言那样提供了 for 循环语法,不过,利用 m4 的宏递归展开特性,可以模拟出 for,例如

divert(-1)
define(`_for', `ifelse(`$1', `$2',
                        `... end ...',
                        `... 第 eval(`$1 + 1') 次执行任务 ...
                        _for(eval(`$1 + 1'), `$2')')')
divert(0)dnl
_for(0, 3)

上述文本的展开结果为

... 第 1 次执行任务 ...
                        ... 第 2 次执行任务 ...
                        ... 第 3 次执行任务 ...
                        ... end ...

上述结果从第 2 行开始,每一行出现的缩进,都是 _for 定义中引入的,即 _for 递归调用语句之前的那段空白字符。若想消除这些缩进,则 _for 的定义会变得丑陋起来,

define(`_for', `ifelse(`$1', `$2',
                        `... end ...',
                        `... 第 eval(`$1 + 1') 次执行任务 ...
_for(eval(`$1 + 1'), `$2')')')

m4 宏定义中,参数间隔符之后的空白字符(包括换行符)会被 m4 忽略,但是参数内部的空白字符,会被保留下来。

使用修改后的 _for 宏,则上述示例的展开结果为

... 第 1 次执行任务 ...
... 第 2 次执行任务 ...
... 第 3 次执行任务 ...
... end ...

如果将 _for 定义中的

... 第 eval(`$1 + 1') 次执行任务 ...

替换为第 3 个参数,将循环结束时的输出为空文本,便有了一个能执行基本循环运算的 _for 宏了,

define(`_for', `ifelse(`$1', `$2',
                        `',
                        `$3`'_for(eval(`$1 + 1'), `$2', `$3')')')

例如

define(`_N_', 1)
_for(0, 3, `define(`_N_', incr(_N_))')
_N_

incr(_N_) 会被 _for 执行了 3 次,故而 _N_ 的展开结果是 4。

将计算斐波那契数字的过程带入 _for 宏,结果如何呢?

divert(-1)
define(`_A_', 1)
define(`_B_', 0)
_for(0, 3, `define(`_C_', _A_)
define(`_A_', eval(_A_ + _B_))
define(`_B_', _C_)')
divert(0)dnl
_A_

上述代码……的确丑陋,可是 _A_ 的展开结果会是 3,而这正是 F(4) 的值。若将 _for 语句中的 3 换成 29,则 _A_ 的展开结果是 832040,这正是 F(30) 的值。于是,将上述计算过程封装为宏,专用于计算斐波那契数字,

divert(-1)
define(`_for', `ifelse(`$1', `$2',
                        `',
                        `$3`'_for(eval(`$1 + 1'), `$2', `$3')')')
define(`_fib_init_', `define(`_A_', 1)define(`_B_', 0)')
define(`_do_fib_', `dnl
`'`'define(`_C_', _A_)dnl
`'`'define(`_A_', eval(_A_ + _B_))dnl
`'`'define(`_B_', _C_)'dnl
)
define(`Fib', `dnl
`'`'_fib_init_`'dnl
`'`'ifelse(`$1', 0,
           0,
           `_for(0, eval(`$1 - 1'), `_do_fib_')')dnl
`'`'_A_'dnl
)
divert(0)dnl

在宏定义里,可以用含有空文本的引号构造缩进,从而让代码的可读性略好一些。

我已经尽力不让代码变得像天书了,我将 _A_ 和 _B_ 的初始定义封装在 _fib_init_ 宏里,将计算斐波那契数字的过程封装在 _do_fib_ 宏里,则 Fib 宏的定义理解起来便容易许多。现在,若调用 Fib(30),瞬间便可算出结果,不会再像递归版本的 Fib 宏那样,需要经过十多秒。倘若上述代码你理解不了,也不必为此焦虑。在前文中我已强调多次,不要试图从宏代码中理解其设计意图,否则你很容易迷失在一团又一团的 m4 引号里……

其实,没有必要用 _A_,_B_ 和 _C_ 三个宏去保存 Fib 的中间计算结果。认真回想一下,前文之所以引入 _C_ 宏,本质上是因为无法在修改修改 _A_ 值的同时,将其原有的值转移给 _B_。然而,事实上有一种途径可以同时改变它们,那就是递归宏的参数,例如

define(`foo', `foo(eval(`$1 + $2'), `$1'))'

调用 foo 宏,

foo(1, 0)

则 foo 会陷入递归过程,无法结束,但是每一次递归,其参数都是在完成斐波那契数字计算,亦即上述 foo 调用语句的展开轨迹是

foo(1, 1)
foo(2, 1)
foo(3, 2)
foo(5, 3)
foo(8, 5)
foo(13, 8)
... ... ...

若 foo 被第 5 次展开,则展开结果中 foo 的第 1 个参数便是 F(6),第 2 个参数是 F(5)。这意味着,其实并没有必要创造 _for 循环宏,我们只要设法让 foo 在合适的时机停下来,它便可构成 Fib 的简洁且高效的计算过程,

define(`_do_fib',
        `ifelse(`$3', 0,
        `$2',
        `_do_fib(eval(`$2 + $1'), $1, eval(`$3 - 1'))')')
define(`Fib', `_do_fib(1, 0, `$1')')

知其雄,守其雌

m4 只能处理文本,即便 eval 能完成简单的整数运算,但其所用的数字,于本质上依然是文本。m4 有一些内建宏,可用于更为精细的文本处理任务,例如计算文本长度,

len(`hello world!')
dnl 结果为 12

len 只对 ASCII 编码的文本有效,对于中文文本,它得到的是字节数,而非字符数。

将字母从小写变为大写,从大写变为小写

translit(`GNUs not Unix', `a-z', `A-Z')
dnl 结果为 GNUS NOT UNIX
translit(`GNUs not Unix', `A-Z', `a-z')
dnl 结果为 gnus not unix

也能从文本中按指定范围截取一段,

substr(`0123456789', 5)
dnl 结果为 56789
substr(`0123456789', 5, 3)
dnl 结果为 567

也能基于正则表达式作文本替换,例如去除文本首尾空白字符,

|patsubst(patsubst(`   abc   ', `^[[:space:]]+', `'), `[[:space:]]+$', `')|
dnl 结果为 |abc|

还有一个有趣的内建宏 defn,它能够引用宏的定义,例如

defn(`foo')
define(`foo', `I am foo!')
defn(`foo')

第一个 defn 语句的展开结果为空,因为此时 foo 尚无定义。第二个 defn 语句的展开结果是 I am foo!,它是 foo 的定义。利用 defn,可以用 m4 语言模拟数组或散列表,例如

divert(-1)
define(`_set', `define(`$1[$2]', `$3')')
define(`_get', `defn(`$1[$2]')')
dnl 构造散列表
_set(`myarray', 1, `alpha')
_set(`myarray', `alpha', `omega')
divert(0)dnl
dnl 访问散列表
_get(`myarray', 1)
_get(`myarray', `alpha')
_get(`myarray', _get(`myarray',1))
defn(`myarray[alpha]')

展开结果为

alpha
omega
omega
omega

能使用 m4 语言定义散列表,意味着什么?在面向对象编程中,对象在本质上也是散列表啊……

基于上述内建宏以及宏递归展开机制,足以让 m4 像一叶轻舟,起伏穿梭在一团混沌的文字之间,完成更为复杂的文本处理任务。然而在前文中构造的一些示例里,如你所见,当宏的定义略显复杂时,便已难以阅读和理解了,故而我多次强调,要注重如何从你想要的结果中逐步倒推出宏的定义,而不要从宏定义本身去理解它是否能得到你想要的结果。

你从 C,C++,Java,C#,Go,Rust,Python……等主流编程语言中培养的思维模式,在宏编程的世界里,需要颠倒过来。你习惯的因果关系,需要倒果为因,亦即你习惯的调试和维护的对象不再是代码本身,而是一些隐性的推导过程。倘若你不能适应如此颠倒的宏世界,也不必担心,在理解其最为根本的运行原理后,复杂的计算逻辑依然可以用你熟悉的编程语言完成,只需将其结果传于 m4。

GNU m4 提供了内建宏 syscmd 和 esyscmd,前者是 m4 标准的一部分,后者则是 GNU m4 所作的扩展。这两个宏皆能获得外部程序的输出。例如,创建文本文件 foo.txt,其内容为

Hello world!

然后在 foo.txt 所在目录下创建 foo.m4 文件,其内容为

syscmd(`cat foo.txt')

foo.m4 的展开结果为

Hello world!

在上述示例中,cat 是 Linux 命令,可以输出文件内容,该命令被 m4 宏 syscmd 执行,其输出流与 m4 的输出流恰好重合,从而出现 cat 的输出流被 m4 捕获这样一种假象,而实际上 syscmd 的展开结果在 m4 看来,是空文本。由于这是假象,故而无法用 divert 改变外部程序的输出流向。例如

... 0 号流 ...
divert(1)dnl
... 1 号流 ...
syscmd(`cat foo.txt')

展开结果为

... 0 号流 ...
Hello world!
... 1 号流 ...

esyscmd 具有 syscmd 相同的功能,但是其展开结果的确是外部程序的输出,其流向可以改变,例如

... 0 号流 ...
divert(1)dnl
... 1 号流 ...
esyscmd(`cat foo.txt')

展开结果为

... 0 号流 ...
... 1 号流 ...
Hello world!

如果你的文本处理任务始终在 0 号流,用 syscmd 是安全的,否则会造成时空紊乱……例如

define(`foo', `*** syscmd(`cat foo.txt')')dnl
divert(1)dnl
foo

展开结果并非你所想的

*** Hello world!

而是

Hello world!
*** 

在上述示例中,用 esyscmd 是安全的。倘若你所用的 m4 是 GNU m4,我建议只用 esyscmd。

上一节颇费心思写出的高性能版本 Fib 宏,基于 esyscmd,可将其修改为 Lua 语言版本。首先,写出 Lua 脚本 fib.lua,

function fib(n)
    local a, b = 0, 1
    for i = 1, n do
        a, b = b, a + b
    end
    io.write(a)
end
fib(tonumber(arg[1]))

然后用 esyscmd 调用 Lua 解释器执行 fib.lua 脚本并获得其结果,

divert(-1)
define(`Fib', `esyscmd(`lua fib.lua $1')')
divert(0)dnl
Fib(30)

上述文本展开结果为

832040

从上述示例可以看到,复杂的计算逻辑交给更为专业的语言是合理的,m4 层面只需要驱动该语言的解释器去完成计算,用意不用力,四两拨千斤。

复归其根

本文档至此该结束了。它不可能覆盖 m4 语言的全部。我的任务是带你走上一段领略 m4 山水的路,至于如何画出 m4 世界的地图,那是 GNU m4 手册的任务。不过,最后我想谈谈一些不严肃的看法。

GNU m4 手册:https://www.gnu.org/software/m4/manual/index.html

用 m4 语言写出来的代码可能会无比复杂,宛若《尚书》,诘屈聱牙,可是其工作原理却异常简单。如果你熟悉计算机语言编译原理,也许能感受到 m4 像一个天然的词法分析器,只是宏调用语句在风格上有些像 C 语言函数。

m4 将输入文本视为一段记号流。记号是什么呢?引号、宏名、宏参数列表、空白字符、数字以及其他符号,皆为记号。不必将自身变成 m4,去分析它如何识别和处理当前所遇到的记号。m4 的所有记号实际上只是为两种对象服务,其一是普通文本,其二是宏调用语句。未定义的宏以及带引号的文本,会被 m4 视为普通文本,直接发送至输出流。宏调用语句会被 m4 展开,但是展开结果不会被立刻发送至输出流,而是作为输入流当前的内容,被 m4 继续处理,直至 m4 认为所得结果只是普通文本,便将其发送至输出流。

宏调用语句中的参数文本有其特殊性。m4 在对宏调用语句展开之前,先尽可能将其参数展开,此即为何新手经常遇到宏像野马一样不受控制的原因所在。例如

define(hello, hello world!)

m4 在处理这行文本时,hello 是宏名,hello world! 是宏体,但二者实际上都是 define 宏的参数。若 hello 是已有定义的宏,则 m4 会将其展开,这意味着这行语句所定义的宏不是你所看到的这个样子。为安全起见,通常需将其写成

define(`hello', `hello world!')

亦即用引号告诉 m4,将它们当成普通文本,无需作展开处理。

事实上,m4 语言在功能上是超越词法分析的。词法分析器的灵魂是正则表达式,而正则表达式作为语言,不具备图灵完备性。宏的递归展开机制使得 m4 语言超越了正则表达式边界。有许多基于正则表达式难以完成的文本替换任务,在 m4 语言面前,往往可以手到擒来。还记得在「山外青山楼外楼」一节里,我们基于宏定义栈所完成嵌套的结构化文本处理示例吗?基于正则表达式,很难处理文本中的一些嵌套结构。

m4 处理器像是一台织布机。普通文本像是经线,宏调用语句像是穿行不息的梭子,每穿行一次,便引出一根纬线。当文本流过 m4 处理器,所得结果就像是一匹织好的布。只要你足够用心,便可在布上织出精美的图案。很多人害怕甚至诟病宏语言,认为使用宏语言编程,是取乱之道。可是,这世上有什么衣服,不是用布料做的呢? 织布机要做好织布机的事,缝纫机要做好缝纫机的事。我未曾见过,服装厂的工人会抱怨织布是取乱之道。