展開

マクロの展開(expansion)は比較的単純な作業です。 抽象構文木を構築し終えてからコンパイラがプログラムの意味を理解しようとし始めるまでの間のどこかで、コンパイラはすべての構文拡張を展開します。

これは、抽象構文木を走査し、構文拡張の呼び出し箇所を見つけ、展開形で置き換えるという処理を伴います。

コンパイラが構文拡張を実行する際、コンパイラは呼び出し結果がその文脈に合致する構文要素のいずれかとしてパースできることを期待します。 例えば、構文拡張をモジュールスコープで呼び出したならば、コンパイラは呼び出し結果をアイテムを表す抽象構文木のノードとしてパースすることになります。 構文拡張を式が来るべき位置で呼び出したならば、コンパイラは結果を式の抽象構文木ノードとしてパースします。

実のところ、コンパイラは構文拡張の呼び出し結果を以下のいずれかに変換できます:

  • パターン
  • 0個以上のアイテム
  • 0個以上の文

言いかえれば、構文拡張をどこで呼び出したかによって、その結果がどう解釈されるかが決まるということです。

コンパイラは、構文拡張を展開した結果の抽象構文木ノードで構文拡張の呼び出しに対応するノードをそっくり置き換えます。 これは構造を考慮した操作であり、テキスト上の操作ではありません!

例えば、以下のコードを考えてみましょう:

let eight = 2 * four!();

これに対応する部分抽象構文木を図解すると次のようになります:

┌─────────────┐
│ Let         │
│ name: eight │   ┌─────────┐
│ init: ◌     │╶─╴│ BinOp   │
└─────────────┘   │ op: Mul │
                ┌╴│ lhs: ◌  │
     ┌────────┐ │ │ rhs: ◌  │╶┐ ┌────────────┐
     │ LitInt │╶┘ └─────────┘ └╴│ Macro      │
     │ val: 2 │                 │ name: four │
     └────────┘                 │ body: ()   │
                                └────────────┘

文脈より、four!()は式として展開されなければなりません(初期化子(initializer)1には式しか来ないため)。 よって、実際の展開形が何であれ、それは完全な式として解釈されることになります。 この場合、four!()1 + 3のような式に展開されるものとして定義されていると仮定できます。 結果として、この呼び出しを展開すると抽象構文木は次のように変化します:

┌─────────────┐
│ Let         │
│ name: eight │   ┌─────────┐
│ init: ◌     │╶─╴│ BinOp   │
└─────────────┘   │ op: Mul │
                ┌╴│ lhs: ◌  │
     ┌────────┐ │ │ rhs: ◌  │╶┐ ┌─────────┐
     │ LitInt │╶┘ └─────────┘ └╴│ BinOp   │
     │ val: 2 │                 │ op: Add │
     └────────┘               ┌╴│ lhs: ◌  │
                   ┌────────┐ │ │ rhs: ◌  │╶┐ ┌────────┐
                   │ LitInt │╶┘ └─────────┘ └╴│ LitInt │
                   │ val: 1 │                 │ val: 3 │
                   └────────┘                 └────────┘
1

訳注: 初期化子(initializer)とは、変数の初期化文の右辺のこと。

これは次のように書き下すことができます:

let eight = 2 * (1 + 3);

展開形に含まれていないにもかかわらず、括弧が付け足されていることに注意してください。 コンパイラは常に構文拡張の展開形を完全な抽象構文木のノードとして扱うのであって、ただのトークンの列として扱うのではないことを思い出してください。 別の言い方をすれば、複雑な式を明示的に括弧で囲まなくても、コンパイラが展開の結果を「誤解」したり、評価の順序を入れ替えたりすることはないということです。

構文拡張の展開形が抽象構文木のノードとして扱われるということをよく理解しておきましょう。この設計はさらに2つの意味を持ちます:

  • 構文拡張は、呼び出し位置の制約に加えて、その位置においてパーサが期待する種類の抽象構文木ノードにしか展開できないという制約を受ける。
  • 上記の制約の帰結として、構文拡張は不完全な、あるいは構文的に不正な構造には決して展開されない

構文拡張の展開について、さらにもう一つ注意すべきことがあります。ある構文拡張が別の構文拡張の呼び出しを含む何かに展開されたらどうなるのでしょうか。 例えば、four!の別定義を考えてみましょう。それが1 + three!()に展開されるとしたら、どうなるのでしょうか?

let x = four!();

これは次のように展開されます:

let x = 1 + three!();

これはコンパイラが追加の構文拡張呼び出しの展開結果を確認し、展開することで解決されます。 したがって、2段階めの展開ステップにより上記のコードは次のように変換されます:

let x = 1 + 3;

ここから得られる結論は、構文拡張の展開はすべての呼び出しが完全に展開されるのに必要なだけの「パス」にわたって行われるということです。

いや、これには語弊があります。 実際には、コンパイラは断念するまでに実行を試みる再帰的パスの数に上限を設けています。 これは構文拡張の再帰制限(recursion limit)として知られており、デフォルト値は128となっています。 もし128回めの展開が構文拡張呼び出しを含んでいたら、コンパイラは再帰制限を超過したことを示すエラーとともに実行を中断します。

この制限は#![recursion_limit="…"]属性を用いて引き上げることができるものの、クレート単位でしか設定できません。 上限の引き上げはコンパイル時間に影響を与える可能性があるため、基本的にはできる限り構文拡張が再帰制限を超えないように努めることをおすすめします。