展开

展开相对简单。编译器在生成AST之后,对程序进行语义理解之前的某个时间点,将会对所有宏进行展开。

这一过程包括,遍历AST,定位所有宏调用,并将它们用其展开进行替换。在非宏的语法扩展情境中,此过程具体如何发生根据具体情境各有不同。但所有语法扩展在展开完成之后所经历的历程都与宏所经历的相同。

每当编译器遇见一个语法扩展,都会根据上下文决定一个语法元素集。该语法扩展的展开结果应能被顺利解析为集合中的某个元素。举例来说,如果在模组作用域内调用了宏,那么编译器就会尝试将该宏的展开结果解析为一个表示某项条目(item)的AST节点。如果在需要表达式的位置调用了宏,那么编译器就会尝试将该宏的展开结果解析为一个表示表达式的AST节点。

事实上,语义扩展能够被转换成以下任意一种:

  • 一个表达式,
  • 一个模式,
  • 0或多个条目,
  • 0或多个impl条目,
  • 0或多个语句。

换句话讲,宏调用所在的位置,决定了该宏展开之后的结果被解读的方式。

编译器将把AST中表示宏调用的节点用其宏展开的输出节点完全替换。这一替换是结构性(structural)的,而非织构性(textural)的。

举例来说:

  1. let eight = 2 * four!();

我们可将这部分AST表示为:

  1. ┌─────────────┐
  2. Let
  3. name: eight ┌─────────┐
  4. init: │╶─╴│ BinOp
  5. └─────────────┘ op: Mul
  6. ┌╴│ lhs:
  7. ┌────────┐ rhs: │╶┐ ┌────────────┐
  8. LitInt │╶┘ └─────────┘ └╴│ Macro
  9. val: 2 name: four
  10. └────────┘ body: ()
  11. └────────────┘

根据上下文,four!()必须展开成一个表达式 (初始化语句只可能是表达式)。因此,无论实际展开结果如何,它都将被解读成一个完整的表达式。此处我们假设,four!的定义保证它被展开为表达式 1 + 3。故而,展开这一宏调用将使整个AST变为

  1. ┌─────────────┐
  2. Let
  3. name: eight ┌─────────┐
  4. init: │╶─╴│ BinOp
  5. └─────────────┘ op: Mul
  6. ┌╴│ lhs:
  7. ┌────────┐ rhs: │╶┐ ┌─────────┐
  8. LitInt │╶┘ └─────────┘ └╴│ BinOp
  9. val: 2 op: Add
  10. └────────┘ ┌╴│ lhs:
  11. ┌────────┐ rhs: │╶┐ ┌────────┐
  12. LitInt │╶┘ └─────────┘ └╴│ LitInt
  13. val: 1 val: 3
  14. └────────┘ └────────┘

这又能被重写成

  1. let eight = 2 * (1 + 3);

注意到虽然表达式本身不包含括号,我们仍加上了它们。这是因为,编译器总是将宏展开结果作为完整的AST节点对待,而不是仅仅作为一列标记。换句话说,即便不显式地把复杂的表达式用括号包起来,编译器也不可能“错意”宏替换的结果,或者改变求值顺序。

理解这一点——宏展开被当作AST节点看待——非常重要,它表明:

  • 宏调用不仅可用的位置有限,其展开结果也只可能跟语法分析器在该位置所预期的AST节点种类相符合。
  • 因此,宏必定无法展开成不完整或不合语法的架构。

有关展开还有一条值得注意:如果某个语法扩展的展开结果包含了另一次语法扩展调用,那会怎么样?例如,上述four!如果被展开成了1 + three!(),会发生什么?

  1. let x = four!();

展开成:

  1. let x = 1 + three!();

编译器将会检查扩展结果中是否包含更多的宏调用;如果有,它们将被进一步展开。因此,上述AST节点将被再次展开成:

  1. let x = 1 + 3;

此处我们了解到,展开是按“趟”发生的;要多少趟才能完全展开所有调用,那就会展开多少趟。

嗯,也不全是如此。事实上,编译器为此设置了一个上限。它被称作宏递归上限,默认值为32.如果第32次展开结果仍然包含宏调用,编译器将会终止并返回一个递归上限溢出的错误信息。

此上限可通过属性 #![recursion_limit="…"]被改写,但这种改写必须是crate级别的。 一般来讲,可能的话最好还是尽量让宏展开递归次数保持在默认值以下。