この記事で伝えたいこと
- Rustマクロの入出力単位は「トークン木」であること
- マクロの置換はASTレベルで解析されるので、不完全な処理結果になることはないこと
こちらの記事は Rustマクロ冬期講習アドベントカレンダー 2日目の記事です!
2日目は、Rustのマクロはそもそもどういう処理を行うものであるか?を軽く解説したいと思います。
Rustマクロの入出力は「トークン木」
C/C++のマクロと同じく、直感的にはRustのマクロも「コンパイル時にソースコードの一部を書き換えるもの」です。
ただし決定的に違う部分があります。C/C++のマクロは純粋にソースコードを書き換えてしまう一方で、Rustのマクロはソースコードを解析することで得られる 抽象構文木 (Abstract Syntax Tree, AST) を置き換える 処理をします。この違いについては後ほど例を出します。
絵に表すと次のような流れでASTが置換されます。( ※画像はイメージであり実際のコンパイルの流れを描き表したものではありません。 )
ここでASTの他に「 トークン木 (TokenTree) 」というものが出てきました。これこそがRustマクロの入出力でやりとりされるキホンの木です!
トークン木は、ソースコードを切り分けて得られるトークンと、ASTの中間に位置するもので、次の要素(葉と枝)からなります。
- 葉: 「識別子」「句読点」「リテラル」などのトークン
- 枝: 葉を括弧(
()
または[]
または{}
)で囲んだグループ
a + b + (c + d[0]) + e
↓ «» で囲まれたものがトークン木の要素
«a» «+» «b» «+» «( )» «+» «e»
╭────────┴──────────╮
«c» «+» «d» «[ ]»
╭─┴─╮
«0»
こういった話は話のみでは「あくまでも概念的なもので実際にマクロを書くときには関係ないんでしょ...?」ってなりがちです。わかります。そこで軽く検証してみたいと思います。
実際にRustの手続きマクロを作成する際に使う proc-macro2
( と syn
& quote
) クレートを活用して、Rustのソースコードをトークン木に変換しデバッグ出力してみます。
use quote::ToTokens;
fn main() {
let stmt = r#"let a = 40 + (30 * (20 - 10));"#;
let stmt: syn::Stmt = syn::parse_str(stmt).unwrap(); // stmt をRustの「文」として解釈
let token_stream: proc_macro2::TokenStream = stmt.to_token_stream(); // トークンストリームにする
for token_tree in token_stream { // トークン木として葉やグループを一つ一つ取り出す
dbg!(token_tree);
}
}
[src/main.rs:10:9] token_tree = Ident {
sym: let,
span: bytes(1..4),
}
[src/main.rs:10:9] token_tree = Ident {
sym: a,
span: bytes(5..6),
}
[src/main.rs:10:9] token_tree = Punct {
char: '=',
spacing: Alone,
span: bytes(7..8),
}
[src/main.rs:10:9] token_tree = Literal {
lit: 40,
span: bytes(9..11),
}
[src/main.rs:10:9] token_tree = Punct {
char: '+',
spacing: Alone,
span: bytes(12..13),
}
[src/main.rs:10:9] token_tree = Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: 30,
span: bytes(15..17),
},
Punct {
char: '*',
spacing: Alone,
span: bytes(18..19),
},
Group {
delimiter: Parenthesis,
stream: TokenStream [
Literal {
lit: 20,
span: bytes(21..23),
},
Punct {
char: '-',
spacing: Alone,
span: bytes(24..25),
},
Literal {
lit: 10,
span: bytes(26..28),
},
],
span: bytes(20..29),
},
],
span: bytes(14..30),
}
[src/main.rs:10:9] token_tree = Punct {
char: ';',
spacing: Alone,
span: bytes(30..31),
}
Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=cc0fb3777327c7e91197c6e5cb646f87
Ident
は識別子、 Punct
は句読点、 Literal
はリテラルであることを表し、 Group
はトークン木の枝であるグループを表しています。 Group
によりネスト構造が生まれています。
proc_macro2::TokenStream
は、トークンを吐き出すストリームであり、その吐き出す一つ一つの値の型(IntoIterator::Item)は proc_macro2::TokenTree
つまりトークン木になっています。proc_macro2::TokenStream
はまさしくRustの手続きマクロの入出力型1になっていますから、「Rustマクロの入出力はトークン木」と言えることがコードから確認できたかと思います!
出力トークン木が抽象構文木として不完全だとエラー
トークン木自体は、葉とグループからなるというルールを除くと、 Rustの文法とは無縁 であり、言い換えると文法に従っているとは限らなくてよいものになっています。ただしRustマクロにより 出力されるトークン木は設置先のASTに組み込まれて有効なものである 必要があります。
C/C++のマクロはソースコードの置き換えでありそのような制限がないため、次のように書くことができます。
#include <stdio.h>
#define MYMACRO(i) + i +
int main(void) {
printf("%d", 1 MYMACRO(2) 3); // printf("%d", 1 + 2 + 3);
return 0;
}
一方でRustのマクロが出力するトークン木にはASTとして有効という縛りがあるため、トークン木としては成立していても次のようなマクロは書けません。
macro_rules! mymacro {
($i:expr) => {
+ $i +
};
}
fn main() {
println!("{}", 1 mymacro(2) 3);
}
Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=da61dd353cea66e7205ed34f06c83710
あるいは、同じマクロ呼び出しでも書ける部分と書けない部分があります。
macro_rules! double_expr {
($e:expr) => {
$e;
$e
};
}
fn main() {
double_expr!(10); // OK
let hoge = double_expr!(10); // NG
}
Playground: https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=149316d7a048b696f2c978f4b23b6978
let hoge = 10; 10;
みたいに展開され、文法的には一応問題ない感じになりそうですが、ここでは式(expr)が要求されているためエラーになってしまいます。
C/C++のマクロと異なり、抽象構文木まで考慮されたマクロ置き換えなので、C/C++のマクロと比べると ユーザーが意図しないマクロ置換結果になりにくい という特徴があり、安心して使いやすい黒魔術になっているのです!
まとめ
色々書きましたが、
- Rustマクロの入出力は「トークン木」である
- C/C++のマクロのような不完全で下手なマクロは書けない/下手な使い方はできない
この2点を押さえていただけたなら幸いです!
-
正確には
proc_macro::TokenStream
ですがほぼ同一です。このことについては実際に手続きマクロを書く回で触れたいと思います。 ↩