背景

在编程语言中,宏的概念可以追溯到 1970 年 C 的诞生. 当时宏命令的引入是为了弥补过于简单的 C 语法缺失的功能. 比如常量定义或者模块化开发等等。起初,C 中的宏运行逻辑非常简单, 只有#define 词例名 替换后的内容#include "文件名" 两个命令. 前者只能做最简单的词例 (token) 替换. 后者只是简单的把指定的文件内容复制进来,非常的 Unix. 这么简单的宏甚至不需要编译器出马,Richard 写了一个预处理器, 在编译前就把活干完了.

后来 Mike Lesk 对预处理器进行了扩展. 加入了条件编译和参数化预处理语法。此后为我们所熟悉的 C 宏基本成型。后来标准委员会和各家的编译器实现又加入了一些新东西,比如 pragma 之类的。但宏语法整体没有什么改变。等到 C++Ojbective-C 设计时,为了保持它对 C 的兼容, 实现了几乎完全一样的宏语法。宏也变成了 C 系语言的特色.

这种宏的机制过于简单,在实践中产生了很多问题. 因为它的执行发生在编译前,所以预处理器没有关于语言语法的知识, 只能简单的基于词例替换. 这就导致替换后的代码可能导致语法甚至出乎意料的逻辑错误. 而且由于错误是编译器报告的,在出错信息中不可能包含有关宏定义的任何信息. 调用者只能看到令人一头雾水的替换后的代码. 这就使得宏的定义者必须十分小心,考虑周全,否则就会制造难以发现的错误. 而考虑周全的代价就是,宏的编写变得非常累人,写出的代码也是 就算是没有错误,程序员也可以利用宏修改语法结构,创造属于自己的方言 , 并拿到 C 语言 混乱代码大赛冠军 , 给合作者制造大量认知成本.

除此之外,依靠宏的模块管理机制也非常麻烦和多坑. include 并没有对导入的文件做任何检查和处理。因此,哪怕你只想引用一个函数. 你也得导入整个巨大的模块,还有模块所依赖的所有文件。当然, 现在磁盘空间都很便宜,这个大概只算是小问题。但宏可不止这一个问题! 比如要是你无意间使用了和引用文件中名字相同的对象。编译器就会罢工报错, 逼得你得另想一个名字。更要命的是, 你引用的文件可能会不约而同地引用另外一个相同的文件. 然后你的编译器就又要抱怨重定义的问题。如果这样, 你只能手动给那个惹事文件加上 C 系程序员都熟知的防止重复引用的代码。然后给库作者发一个合并请求. 祈祷他能快快处理.

诚然这些问题很多都可以通过一定的编程技巧缓解,但是很多使用 C 系语言项目出于便于协作和项目发展的目的, 依然禁用或者限制了宏的使用 (尤其是不停添加语法取代宏用法的 C++). 虽然 C 系语言的预处理器被打入冷宫, 但是人们依然怀念宏在元编程方面的优点,希望能出现更加智能友好的宏实现, 以适应现代软件工程的特点.

现在轮到 Rust 了!作为 C++ 的挑战者,C 系语言的宏实现历经 30 多年,各种经验教训它肯定看在眼里. 那么它会怎么设计呢?

声明宏 (Macro By Example)

缺点

  1. 又傻又固执的编译器:
    • 进行模式匹配的时候只能认准一条道走到黑,如果匹配失败会直接报错, 不会回头试别的模式.
    • 如果匹配出现歧义,不能根据词例的语法意义推断词例的类型消歧义, 只会报错 (因为还没到词法分析这一步).
  2. 基础设施极度缺乏.
    • 没法知道多次匹配的元变量到底具体匹配了几次, 更没法访问某次匹配的具体结果.
    • 语法树类型的元变量也没法直接访问具体的内容.
  3. 写法递归太多容易爆栈.
  4. 传宏参数的时候不支持对参数求值, 因此想要对参数调用其它宏只能使用回调模式.
  5. 每次展开后强制语法检查. 因此必须保证嵌套展开宏时中间的展开结果也是合法的语法.