読者です 読者をやめる 読者になる 読者になる

Rustマクロの衛生性はどのように実現されているか(2/2) 構文の優先度に関する衛生性

概要: Rustマクロは2つの意味で衛生的である。その衛生性の説明とともに、それを実現するためのコンパイラの仕組みを説明する。

Rustマクロの2つの衛生性

Rustマクロ (ja) は次の2つの意味で衛生的(hygienic; 健全ともいう)である。

  • マクロ内で導入される変数名と、マクロ呼び出し側の変数名が衝突しない。(Lispマクロの意味での衛生性)
  • 構文の優先順位の違いによる非直感的な挙動が発生しない。

この記事では、構文の優先度に関する衛生性を説明する。(識別子に関する衛生性については前記事を参照)

構文の優先度に関する衛生性とは

次のようなプログラムが直感的な動作をするのが、構文の優先度に関する衛生性である。Lispマクロの衛生性とは別だが、Rustではこの種類の性質も衛生性と呼んでいる。

macro_rules! prod {
    ($x: expr, $y: expr) => ($x * $y);
}
macro_rules! sum {
    ($x: expr, $y: expr) => ($x + $y);
}
fn main() {
    println!("{}", (4 + 5) * (6 + 7));
    println!("{}", sum!(4, 5) * sum!(6, 7));
    println!("{}", prod!(4 + 5, 6 + 7));
}

このプログラムを字句通りに展開すると構文の優先順位が変化してしまい、異なる結果が得られる。しかしRustのマクロではそのようなことは発生しない。

つまり、次の2点について構文の優先順位の影響を回避する設計になっていることになる。

  • マクロ展開後の内容を再結合から保護する。
  • マクロの実引数を再結合から保護する。

マクロの構文要素化

以前の記事で指摘したように、Rustは展開前のマクロ呼び出しをダミーの構文要素としてあらかじめ解釈してしまう。そのため、マクロ展開後の内容が構文の優先順位の影響を受けることはない。

補間トーク

それでは、マクロの実引数についてはどのように保護しているのだろうか。

Rustではこれを実現するために、補間トークというものを導入している。

補間トークンはsyntax::parse::tokenにて以下のように定義されている。

#[derive(Clone, RustcEncodable, RustcDecodable, PartialEq, Eq, Hash, Debug)]
pub enum Token {
    Eq,
    Lt,
    Le,
    ...

    /* For interpolation */
    Interpolated(Rc<Nonterminal>),

    ...
}

補完トークンは上のように Nonterminal (nonterminal = 非終端記号) という型の値を保持している。ではこの Nonterminal の定義はというと、次のようになっている。

#[derive(Clone, RustcEncodable, RustcDecodable, PartialEq, Eq, Hash)]
/// For interpolation during macro expansion.
pub enum Nonterminal {
    NtItem(P<ast::Item>),
    NtBlock(P<ast::Block>),
    NtStmt(ast::Stmt),
    ...
}

見てわかるように、 Nonterminal はASTの断片に他ならない。

Nonterminalmacro_rules! の節のマッチングの段階で発生する。この段階でマクロ呼び出しの実引数が構文解析されるからである。

こうして生成された非終端要素は、マクロ定義中の仮引数を置き換える。このときに Token::Interpolated を使って、非終端要素を1つのトークンと見なすのである。

このように、マクロ展開の時点で実引数を構文解析し、構文解析済みの部分は分解せずにそのままの形で保持することで、マクロの実引数を再結合から保護している。

例による説明

先ほどの例

prod!(4 + 5, 6 + 7)

をもとに説明する。

この例の場合、最初のパース時点では次のようなASTが生成される。この時点ではマクロ呼び出しの実引数は構文解析されていない生のトークンツリー列である。

Mac { path: "prod", tts: [Literal(4), BinOp(Plus), Literal(5), Comma, Literal(6), BinOp(Plus), Literal(7)] }

マクロ展開器により、 prod! マクロの定義が検索される。 prod!macro_rules! により定義されているから、中のトークンツリー列をマッチャーと照らし合わせる。この時点で実引数に対する構文解析が行われる。

[
    ("x", NtExpr(Binary(Plus, Lit(4), Lit(5)))),
    ("y", NtExpr(Binary(Plus, Lit(6), Lit(7)))),
]

マッチに成功したため、このマッチャーに対応する中身にこれが代入される。これにより以下のようなトークン列が生成される。

[NtExpr(Binary(Plus, Lit(4), Lit(5))), BinOp(Mult), NtExpr(Binary(Plus, Lit(6), Lit(7)))]

マクロ呼び出しは式の位置にあったため、これが再び式として構文解析される。このときsyntax::parse::parser 2025行目にある maybe_whole_expr! の処理

        if let token::Interpolated(nt) = $p.token.clone() {
            match *nt {
                token::NtExpr(ref e) => {
                    $p.bump();
                    return Ok((*e).clone());
                }
                ...
            };
        }

により、式が要求される部分にトークInterpolated(NtExpr(e)) が来たら、 e がそのまま式として使われる。

したがって、これを構文解析すると、式のAST

Binary(Mult, Binary(Plus, Lit(4), Lit(5)), Binary(Plus, Lit(6), Lit(7)))

が得られる。

まとめ

Rustマクロの2つの衛生性のうち、構文の優先度の違いによる再結合を防ぐ衛生性は、マクロ呼び出しやその実引数を比較的早い段階で構文解析してしまうことで、実現されている。