让这世界再多一份 GNU m4 教程 (2)

3

上一篇见:http://segmentfault.com/a/119...

自定义一个 m4 宏所用的基本格式如下:

define(宏名, 宏体)

上一节,我们定义的一个很简单的 say_hello_world 宏:

define(say_hello_world, Hello World!)

say_hello_world 是宏名,Hello World 是宏体。如果在宏定义之后的文本中出现了 say_hello_world,例如:

define(say_hello_world, Hello World!)

blab blab ... say_hello_world

假设上述文本均处于非负号缓存,那么当 m4 从输入流中读取到 say_hello_world 时,它能够检测出该文本片段是一个被定义了的宏,于是它就将这个宏展开为 Hello World,并使用这个展开结果替换文本片段 say_hello_world,所以,上述文本经过 m4 处理后发送到输出流,就变成:



blab blab ... Hello World!

上述输出结果中的空行,应该没什么玄机可言了,只是需要注意:宏定义语句本身也会被 m4 展开,因为 define 本身就是一个宏,只不过它的展开结果是一个空的字符串。

有参数的宏

宏可以有参数。遵循 POSIX 标准的 m4,允许一个宏最多有 9 个参数(似乎 Shell 脚本里的函数也最多支持 9 个参数),在宏体中可使用用 $1, ..., $9 来引用它们。GNU 的 m4 不限制宏的参数数量。

对于下面这段 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);

用 m4 的有参数的宏可给出等价表示:

divert(-1)
define(DEF_PAIR_OF,
`typedef struct pair_of_$1 {
        $1 first;
        $1 second;
} pair_of_$1')
divert(0)dnl

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

它们能够展开为同样的 C 代码(C 语言宏由 C 预处理器展开,m4 宏由 m4 展开):

typedef struct pair_of_int {
        int first;
        int second;
} pair_of_int;
typedef struct pair_of_double {
        double first;
        double second;
} pair_of_double;
typedef struct pair_of_MyStruct {
        MyStruct first;
        MyStruct second;
} pair_of_MyStruct;

注意,C 宏与 m4 宏的调用有点区别。在 C 中,调用一个宏,宏名与其后的 ( 可以有空格,而 m4 宏的调用不允许这样

m4 版本的 DEF_PAIR_OF 宏的宏体为:

`typedef struct pair_of_$1 {
        $1 first;
        $1 second;
} pair_of_$1'

这个宏体是一个带引号的字符串,形如:

`... ... ...'

注意左引号与右引号对应的符号。在大部分键盘上,左引号与 ~ 符号同键,右引号与 " 同键,它们是单引号。不要对引号掉以倾心,它在 m4 中的重要地位仅位列宏之下,如果没有它,宏的世界会异常的混乱。后面,我会在单独给引号的基本用法留出一节专门阐述。在此只需将引号理解为一段文本的封装。

事实上,对于 m4 版本的 DEF_PAIR_OF 宏体,不用引号也不会出问题(可以去掉引号试一下)。但是,在复杂一些的宏体内,可能会出现 , 符号,如果这样的宏体不用引号囊括起来,那么 , 会被 m4 误认为宏参数的分隔符。所以,一定要记住:, 会被 m4 捕获为宏参数分隔符,而引号可使之逃逸

小实践:reStructuredText 插图标记的简化

reStructuredText 是一种轻量级的文本标记语言,与 Markdown 属于同类,一般用于记笔记,然后以网页的形式发布。之所以要用轻量级文本标记,是因为直接手写 HTML 太繁琐了。我在我的机器上搭建的 Nikola 静态网站,默认用的就是 reStructuredText,我用它来整理我的一些笔记。

在使用 reSturcturedText 写文档时,我觉得它的插图标记过于繁琐。我常用的插图标记如下:

.. figure:: 图片文件路径
    :align: center
    :width: 宽度值

上述标记文本块前后必须要留出空行,否则 reStructuredText 的解析器就会抱怨。alignwidth 前面的缩进也是必须的,否则 reStructuredText 的解析器就会抱怨……

为了简化这个标记,我用 m4 定义了一个宏:

divert(-1)
define(place_figure, `
.. figure:: $1
    :align: center
    :width: $2
')
divert(0)dnl

然后我就可以愉快的像下面这样在 reStructuredText 文本中插入一幅图片了。

place_figure(`/images/amenta-needles/0001.png', 480)

用这种办法可以简化许多繁琐的文本标记,甚至可以实现 reStructuredText 不具备的功能,例如参考文献的管理。如果你不打算研究如何改造 reStructuredText 解析器来满足自己的需求,在这种前提下,用 m4 简单的 hack 一下,使得 reStructuredText 的易用性显著增强,这就是宏语言最大的用途。

不妨将宏语言视为生活中的方便袋。

宏的陷阱

m4 允许宏的重定义,结果是新的宏定义会覆盖旧的。例如:

define(LEFT, [)dnl
LEFT
define(LEFT, {)dnl
LEFT

如果你按照我说『新的宏定义会覆盖旧的』来判断,可能会认为上述文本流经 m4 会变为:

[
{

然而,事实上 m4 的处理结果是:

[
[

与理解这个诡异的结果是如何产生的,就需要认真的回顾一下 m4 的工作过程。

我将 m4 处理第一个 LEFT 宏定义的过程大致拆解为:

  1. 在输入流中,m4 遇到了 define,在它的记忆里,define 是一个宏;

  2. 接下来它遇到了一个 (,它会认为这是 define 宏参数列表的左定界符;

  3. 接下来,它遇到了 LEFT,,它会认为 , 之前的文本是 define 的第一个参数;

  4. 接下来,它遇到了 [),他会认为 [define 的第二个参数,而 )define 参数列表的右定界符;

  5. 它现在终于明白了,define(LEFT, [) 是在调用 define 宏;

  6. m4 对 define(LEFT, [) 进行展开,具体的展开过程,我们不得而知,因为 define 是 m4 内建的宏。我们只知道在 define(LEFT, [) 的展开过程中,m4 会为我们定义 LEFT 宏,并且 define(LEFT, [) 宏展开完成后,m4 会向输出流发送一个空字串。

当 m4 遇到第二个 LEFT 宏定义时,它的过程大致如下:

  1. 在输入流中,m4 遇到了 define,在它的记忆里,define 是一个宏;

  2. 接下来它遇到了一个 (,它会认为这是 define 宏参数列表的左定界符;

  3. 接下来,它遇到了 LEFT,,它会认为 , 之前的文本——LEFTdefine 的第一个参数。但是 m4 随即发现 LEFT 是一个宏,于是它就将这个宏展开,结果为 [,它认为 [ 才是真正的 define 的第一个参数;

  4. 接下来,它遇到了 {),他会认为 {define 的第二个参数,而 )define 参数列表的右定界符;

  5. 它现在终于明白了,define([, {) 是在调用 define 宏;

  6. m4 对 define([, {) 进行展开,具体的展开过程,我们不得而知,因为 define 是 m4 内建的宏。我们只知道在 define([, {) 的展开过程中,m4 会为我们定义 [ 宏,并且 define([, {) 宏展开完成后,m4 会向输出流发送一个空字串。

m4 处理输入流的过程,非常像人类,急功近利,目光短浅,一叶障目,不见泰山,管中窥豹,略见一斑……现在明白了吧!第二个 LEFT 宏定义,表面上看起来是重定义了 LEFT 宏,实际上定义的是 [ 宏。

由于 m4 允许用任何符号作为宏名,所以定义一个 [ 宏,这种行为是合法的,只不过 m4 不会真正的将它视为宏。我一直没有提 m4 的宏命名规则,现在是谈谈它的最好的时机,但是没什么好说的,在 m4 眼里,只有像 C 函数名的宏名才是真正的宏,也就是说,m4 的宏名名规则是:只允许使用字母、数字以及下划线构造宏名,并且宏名只能以字母或下划线开头只有符合宏名规则的宏,m4 才会将它视为真正的宏。不过,不符合宏名规则的宏,也是有办法调用的,以后再讲。

若真的想对已定义的宏的重新定义,需要借助引号。例如:

define(`LEFT', [)dnl
LEFT
define(`LEFT', {)dnl
LEFT

在 m4 语法中,单重引号具有逃逸的作用:当 m4 读到带单重引号的文本片段 S 时,它会将 S 的引号消除,然后继续处理 S 之后的文本。

现在可以这样来理解引号的作用:

  • m4 将一切没有引号的文本都视为宏。对于已定义的宏,m4 会将其展开;对于未定义的宏,m4 会按其字面将其输出。

  • 加了引号的文本,m4 不再检测它们是不是宏,而是将其作为普通文本按字面输出。

也就是说,加了引号的文本,可以让 m4 不需要判断它是不是宏。

记号

现在,我们继续探究 m4 究竟对于输入流都做了些什么。这件事,已经讨论了 3 次了,虽然每一次都比前一次更深入一些,但是迄今为止,真相依然未能堪破。现在应该到堪破真相的时候了。

m4 对输入流是以记号(Token)为单元进行读取的。一般情况下,m4 会将读取的每个记号直接发送到输出流,但是当 m4 发现某个单词是已定义的宏名时,它会将这个宏展开。在对宏进行展开的过程中,m4 可能会需要读入更多的文本以获取宏的参数。宏展开的结果会被插入到输入流剩余部分的前端,也就是说,宏展开后所得到的文本会被 m4 重新读取,解析为记号,继续处理

上面这段文字尤为重要。当 m4 不能如你预期的那样展开你定义的宏,都应该重新理解上面这段文字。

什么样的文本对于 m4 而言是一个记号?带引号的字符串、宏名、宏参数列表、空白字符(包括换行符)、数字以及其他符号(包括标点符号),像这些类别的文本,对于 m4 而言都是记号。对于每种记号,m4 都有相应的处理机制。数字与标点符号(西文的),它们本身是记号,同时也是某些记号的边界,除非它们出现于带引号的字符串或者宏的参数列表中

来看一个例子:

define(`definenum', `define(`num', `99')') num

若这行文本流经 m4,那么 m4 读到的第一个记号是 define。因为 define 后面尾随的是 (。由于 ( 即是记号,也是某些记号的边界。m4 读取 define 文本之后,就遇到了边界,因此 define 是 m4 遇到的一个记号。

然后,m4 开始对 define 这个记号进行处理,它发现这个记号是一个带参数的宏。所以它暂停对 define 的处理,继续读取并分析 define 之后的文本,看是否能获得 define 宏的参数列表。

接下来, m4 读取的是 (,这是个记号,而且是宏参数列表的左定界符。这对 m4 而言,已经开始经进入了一段可能是参数列表的文本。它期望接下来能遇到一个 , 或者 ),以得到完整的参数列表记号。

但是接下来,m4 读到的是一个左引号。这时,对 m4 而言,已经开始进入了一个可能是带引号的字符串文本,它期望接下来能遇到一些文本或右引号,以得到一个完整的字符串记号。

但是接下来,m4 读到是文本片段 definenum,再读下去,就读到了右引号。这时, m4 很高兴,它确定自己已经读取了一个带引号的字符串记号,然后它就将包围这个字符串的引号消除,继续读取后面的文本。m4 之所以不在这时将 definenum 发送到输出端,因为它没有忘记自己还有一个使命:为 define 宏搜寻完整的参数列表。

接下来,m4 读到了 ,——这是宏参数记号的边界。m4 很高兴,它终于得到了 define 宏的第一个参数,即 definenum。此时,m4 认为刚才读到的 , 就没什么用了,于是就将 , 消除了,然后它认为后面也许还会有第二个参数,决定继续前进。

接下来,m4 遇到了一个空格。在宏参数列表中,在 , 之后的空格是无意义的字符,m4 将这个空格扔掉,继续前进。然后它遇到了左引号……于是就像刚才处理 definenum 一样,m4 可以得到一个带引号的字符串:

 `define(`num', `99')'

m4 将这个字符串的引号消除,然后继续前进,结果碰到了 )。此时,m4 吁了口气,它终于为 define 宏获得了一个完整的参数列表,尽管这个参数列表只含有两个参数。

接下来,m4 对 define 宏进行展开。这个过程,我们无法得知,因为 define 是 m4 内建的宏,但是我们知道在 define 的展开过程中肯定发生了一系列计算,然后 definenum 变成了一个宏,最终

define(`definenum', `define(`num', `99')')

的展开结果是一个空的字符串。由于宏展开的结果会被插入到输入流剩余部分的前端,也就是说,宏展开后所得到的文本会被 m4 重新读取,解析为记号,继续处理,因此 m4 会将

define(`definenum', `define(`num', `99')')

的展开结果视为它下一步继续要读取并处理的文本。当 m4 继续前进时,它就会读到到一个空的字符串。空的字符串,虽然不具备被 m4 发送到输出流的资格,但是它可以作为其他记号的边界记号使用。

接下来,m4 遇到了一个空格字符。空格字符也是个记号,而且是其他记号的边界。m4 将空格记号直接发送到输出流,继续前进。

接下来,m4 一口气读到了输入流的末尾,得到了 num 记号。之所以说 num 是一个记号,是因为 num 的左侧与右侧都有边界,左侧是空格,右侧是输入流终止符。m4 将 num 这个记号视为宏,然后它确定这个宏没有被定义,因此无法对其进行展开,所以只好将它作为字符串发送到输出流。

挑战

对于以下 m4 文本

define(`definenum', define(`num', `99')) definenum num

推测一下 m4 的处理结果,然后执行 m4 命令检验所做的推测是否正确,然后再回顾一次 m4 的工作过程,最后用:

$ m4 -dV your-m4-file

查看一下输出,根据输出信息再回顾一次 m4 的工作过程。

下一篇见:http://segmentfault.com/a/119...


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

10 条评论
adam1q84 · 2015年12月29日

第二个LEFT的拆解有笔误吧,怎么不见花括号?

回复

garfileo 作者 · 2015年12月29日

是这个?

define(`LEFT', [)dnl
LEFT
define(`LEFT', {)dnl
LEFT

没错误……你用的是 GNU m4 么

回复

iamtzh · 2016年01月05日

似乎 Shell 脚本里的函数也最多支持 9 个参数

#!/bin/bash
#
# test args

print_args_star () {
  printf "%s\n" "$*"
}

print_args_star "$*"
echo '-------'
print_args_star zero one two three four five six seven eight nine

exit $?

输出如下:

# bash t.sh zero one two three four five six seven eight nine
zero one two three four five six seven eight nine
-------
zero one two three four five six seven eight nine

回复

garfileo 作者 · 2016年01月05日

你没法 $10

回复

iamtzh · 2016年01月05日

可以用 ${10}

回复

garfileo 作者 · 2016年01月05日

这样啊……不过这种表达上不一致性,感觉是早期的 Shell 只支持到 $9。

回复

happen23 · 2016年12月10日

“于是就像刚才处理 definenum 一样,m4 可以得到一个带引号的字符串”这段话下面的代码片段里,最右边那个闭引号应该是'而不是`吧?

回复

garfileo 作者 · 2016年12月10日

嗯。手误,已改。

回复

optor · 2018年09月03日

赞作者👍

回复

lpdonline · 2018年11月27日

mark

回复

载入中...