Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Rustの手続きマクロに関する知見をまとめてみた

どうも。yoshiです。プロフィールにC++erと書いているけど最後に書いたC++記事は一昨年のでした。最近は業務ではC++もやるけどプライベートだとTSかRustみたいな感じになってます。業務の方もTS率高め。C++もつい先日C++20の規格がISOから正式発行されたところなので書こうと思えばネタはあるのですが、最近規格を読むモチベがあまりないのでやめておきます。

というわけで今回はRustの手続きマクロ(Procedural Macros)の話をします。

ところでProcedural Macrosの訳語には「手続き型マクロ」派と「手続きマクロ」派がいるようですが、どっちがいいんでしょうね? 「手続き型」の場合は「手続き型プログラミング(Procedural Programming)」などの既存の言葉に合わせてだと思いますが、この場合の「型」はTypeのことではないので、型の話が多いRustの中で使うとややこしいため、個人的には「手続きマクロ」派です。

閑話休題。

Rustのマクロとはどういうものか

RustのマクロはCのプリプロセッサマクロなどとおなじく、ビルド時にソースコードの一部を置き換える仕組みです。

ただ、Cプリプロセッサが単純な文字列置換しか行わないのに対して、Rustのマクロには複雑なルールが決められていて、危険性は相対的に低くなっています。

例えば、Cプリプロセッサマクロを使って、

#include <stdio.h>

#define a() 2 + 2

int main(void) {
    printf("%d\n", a() * 2);
}

としてみます。

定義を見ればa()は4に見えるし、例えばprintf("%d\n", a())とすると当然4が出力されるのですが、printf("%d\n", a() * 2)の結果は6になります。文字列置換を行っているだけのため、a() * 22 + 2 * 2となってしまうからです。

もちろん、これを防ぐには(2 + 2)のように括弧で囲めば良いのですが、人間が注意を払わなければいけないのは常にコストです。

一方、同じようなマクロをRustで書いてみます。

macro_rules! a{
    () => { 2 + 2 }
}

fn main() {
    println!("{}", a!() * 2);
}

こちらはちゃんと8が出力されます。Rustの場合は単純な文字列置換ではなく、マクロの評価結果が単一の構文要素になるためです。

さて、今使ったmacro_rules!が、Rustでただ「マクロを定義する」といった時に使う構文です(1)。
これだけでもそれなりに表現力はあるのですが、限界もあります。

例えば標準で用意されているprintln!マクロは第一引数に文字列リテラルを受け取りますが、その中にある{}の個数と残りの引数の個数が合っていないとコンパイルエラーになります。これをやるには文字列リテラルの中身をチェックするより他になく、そのための方法がmacro_rules!にないため、macro_rules!で実装することはできません。

単純な話、「aという小文字のトークンを受け取って大文字のAに変換するマクロ」を書こうとしてもできない訳です。

手続きマクロとは何か

一方、手続きマクロはそれこそ「なんでもできる(2)」機能です。

macro_rules!によるマクロが、マクロ以外の文法とは異なる「マクロのためのDSL」とでも言える方法で定義されるのに対して、手続きマクロはRustの関数として定義されます。

具体的に言うと、マクロの引数となるトークン列(proc_macro::TokenStream型の値)を受け取って、評価結果のコードも同じ形式のトークン列として返すのが手続きマクロの実体です。Rustの関数ですから何をすることも可能です。それこそ内部で別の言語からRustへのトランスパイラを動かすことだってできます。

ただ、手続きマクロを書くには、普通のライブラリを作るのと少し違う手順をたどる必要があります。そこらへんを説明していこうかと思います。

クレートを分ける

手続きマクロを使うには、Cargo.tomlの[lib]

[lib]
proc-macro = true

と書く必要があります。これによってそのクレートはマクロ用のクレートであると認識されるようになります。

一方、マクロ用クレートにはマクロ以外の用途のコードを含めることができません。もちろん関数を分けるなどは可能ですが、最終的にマクロ評価時に呼び出される以外のタイミングで実行されることはありませんし、クレートのトップレベルでpubにできるのもマクロ用の関数だけです。

したがって、通常の(マクロではない)クレートの中で自作の手続きマクロを使いたい場合は、マクロのためのクレートを作ってそれを呼び出す形になります。普通はクレートを分けてもリポジトリは一つで、ルートのCargo.tomlに[workspace]を書くことになります。

例えば有名なシリアライズ/デシリアライズライブラリであるSerdeではリポジトリを覗いてみるとリポジトリルートのCargo.toml

[workspace]
members = [
    "serde",
    "serde_derive",
    "serde_derive_internals",
    "serde_test",
    "test_suite",
]

となっていますが、このうち"serde_derive"がマクロ用クレート、"serde_derive_internals"がさらに"serde_derive"から呼び出されるように作られた通常のライブラリクレートです。

手続きマクロ定義に使う属性(Attribute)

手続きマクロを使うケースは3つあります。

  1. いわゆるマクロ、つまりvec!println!のようなものを定義する場合
  2. カスタム属性を定義する場合
  3. カスタム継承(Derive)を定義する場合

いずれもトークン列を受け取ってトークン列を生成するというやり方は同じですが、定義時に別の属性を使う必要があり、それぞれ、

  1. #[proc_macro]
  2. #[proc_macro_attribute]
  3. #[proc_macro_derive(...)]

となります。

例えばマクロ用クレート名をxとして、トップレベルで

use proc_macro::TokenStream;

#[proc_macro]
pub fn a(_item: TokenStream) -> TokenStream {
    "2 + 2".parse().unwrap()
}

と書くと、他のクレートからx::a!()が呼び出せて、2 + 2に置き換わるようになります。
なお、この例だと入力のトークン列は無視されているので、マクロ引数に何を与えても結果が変わることはありません。

また、

#[proc_macro_attribute]
pub fn a(_attr: TokenStream, _item: TokenStream) -> TokenStream {
    "fn a() { 2 + 2 }".parse().unwrap()
}

とした場合、他のクレートからは

use x::a;
#[a]
fn f() {}

と、属性として利用できます。

カスタム属性マクロが引数を2つ取るのは、属性そのものが引数を受け取ることが可能で(#[x::a(0)]のように書ける)、さらにその後ろの構文要素(上記の例だとfn f() {})を受け取るからです。

例ではまた引数を無視していますが、そうすると引数に渡された値はすべて最終的な出力結果から消えてしまいます。つまり例のような場合はf()という関数を定義したつもりがa()が定義されていたなんてことになります。もちろんできるからといって実際にそういうことはしないほうが良いでしょう。

proc_macro_derive属性は、引数を一つ取り、他のマクロと異なり属性の引数に渡した識別子がエクスポートされる名前になります。例えば、

#[proc_macro_derive(A)]
pub fn a(_item: TokenStream) -> TokenStream {
    "fn a() { 2 + 2 }".parse().unwrap()
}

とすると、

use x::A;
#[derive(A)]
struct S;

のようにして利用できます。引数に渡されるのはstruct, enum, unionのいずれかの定義(例だとstruct S;の部分)で、また引数が無視されていますが、カスタム属性マクロと違ってstruct S;の部分は消えません。カスタム継承マクロの目的はtraitに対するimplを定義することなので、型そのものの定義部分を上書きする必要はないからです。

また、例だとfn a() { 2 + 2 }という関数が新たに定義されることになってしまいますが、実際はimpl A for S {}を生成すべきです。

マクロ用クレートには実装を書かない

マクロ用クレートは手続きマクロ評価のエントリーポイントとして機能しますが、詳細な実装はここに書くべきではありません。

先程も言ったように、マクロ用クレート内部のコードはマクロ評価時にしか呼び出されないため、テストを書くことができないからです。

ではどうするかというと、マクロの詳細実装を行う普通のライブラリクレートを作って、マクロ用クレートからさらにそれを呼び出すという方法が考えられます。上記のSerdeで"serde_derive""serde_derive_internals"に分けられているのも同じ理由でしょう。

ところで、proc_macroクレートはマクロ用クレートの中からしか使えません。つまり、ライブラリクレートにproc_macro::TokenStreamを渡すことができません。したがって、proc_macro::TokenStreamを何か外部に渡せる形に変換する必要があります。そのため、proc_macroとほとんど同じインターフェイスが実装されたproc_macro2というクレートがあります。

proc_macroが言語機能のマクロシステムに組み込まれた特殊なクレートであるのに対してproc_macro2は普通のライブラリクレートです。

「ほとんど同じインターフェイス」というのは、例えばproc_macro::TokenStreamに対してproc_macro2::TokenStreamが定義されていて、相互変換が可能になっているし、proc_macro::TokenStreamに対して行うのと同じような操作をproc_macor2::TokenStreamに対して行うことができる、ということです。

したがって、例えばマクロ用クレートxに対してx_internalsというライブラリクレートを用意して、x_internalsクレートの中で

use proc_macro2::TokenStream;

pub fn a(items: TokenStream) -> TokenStream {
    "2 + 2".parse().unwrap()
}

という定義を行い、xクレートの中からは

use proc_macro::TokenStream;

#[proc_macro]
pub fn a(items: TokenStream) -> TokenStream {
    x_internals::a(items.into()).into()
}

のように引数と戻り値の変換のみを行うようにすれば、x_internals::aの方でいくらでも複雑な実装を書けますし、x::aのテストは不可能でもx_internals::aのテストは可能になります。

以下、特に明記しない限り、proc_macroではなくproc_macro2クレートの型について表記しているものと考えてください(もっとも、ほとんどの型の振る舞いは同じです)

quoteクレート

先程から例に出している2 + 2を、TokenStreamを直接構築する形で作るとどうなるでしょうか。答えはこうです。

{
    let token_tree: Vec<TokenTree> = vec![
        Literal::i32_unsuffixed(2).into(),
        Punct::new('+', proc_macro2::Spacing::Alone).into(),
        Literal::i32_unsuffixed(2).into(),
    ];
    token_tree.into_iter().collect::<TokenStream>()
}

まあ書き方は色々ですが、構文要素を一個ずつ作ってそれを変換する作業です。嫌ですね。やってらんないですね。

とはいえ、"2 + 2".parse()のように全部文字列にするのもこれはこれで辛い。というわけで、macro_rules!と似た感覚でTokenStreamを書くことができるquote::quote!マクロがあります。

quoteは現在proc_macro2とは別クレートですが、proc_macroのリファレンスには試験実装中のquote!マクロが載っています。proc_macro2proc_macroと同じ機能を搭載することを考えると、いずれproc_macro2の中に入れられるだろうと思います。

use quote::quote;
quote!(2 + 2)

とても楽ですね。

macro_rules!では$paramなどのように先頭に$を付けて受け取った構文要素を結果に埋め込むことができますが、 quote!マクロは代わりに#を使ってquote::ToTokensトレイトが実装された値を埋め込むことができます。

let item = /*何らかの方法で構築したquote::ToTokensの値*/;
quote! {
    fn a() {
        // #itemの位置に上記itemが展開される
        #item + 2
    }
}

macro_rules!$($args),*として複数の引数を展開するのと同じようにもできます。

let items = /*
    何らかの方法で構築したquote::ToTokensの値を列挙可能なコンテナ
    (ToTokensのイテレーターもしくは`IntoIterator<Item = T> where T: quote::ToTokens`など)
*/;
quote! {
    fn a() {
        // #(#item)+*の位置に上記itemsが+区切りで展開される
        #(#item)+* + 2
    }
}

synクレート

TokenStreamは、括弧が対応していないとエラーになるなどいくらかの制約はありますが、これ自体の構築フェーズではRustの文法チェックが行われるわけではありません。

しかし、実際のユースケースでは、Rustの構文を受け取ることがほとんどです(proc-macroを使ってオリジナルの構文を構築することもないわけではありませんが)

そこでsynクレートを使います。quoteクレートがTokenStream構築のためのクレートであるのに対して、synクレートはTokenStreamをRustの構文で解析するためのクレートです。

synクレートには、Rustの構文要素を表す型が用意されています。例えばstructならsyn::ItemStructがそれにあたり、名前、フィールドの配列、ジェネリクスの場合はその情報などが含まれています。

手続きマクロの情報を検索すると、synクレートに含まれるparse_macro_input!というマクロを使ってパースを行っている例が見られますが、parse_macro_input!proc_macro::TokenStreamに対しては使えますがproc_macro2::TokenStreamに対しては使えません。とはいえparse_macro_input!を使わなくてもそれほど難しいことはないです。

例えば、カスタム継承マクロで構造体をパースしたい時は、

let items: TokenStream;
let item_struct: ItemStruct = syn::parse2(items).unwrap();

と、これだけです。

後はパース結果の中身を覗いて色々いじくり回すと幸せになれます。

終わりに

ここまででざっと手続きマクロを作るための基礎的な話はできたと思います。

手続きマクロを作ることができるようになれば「Rustで出来ないことは大体ないな(何やるにも最終的に手続きマクロでどうにかすればいいから)」という気持ちになれます(なお増えるビルド時間)。

後はひたすら自分の出力したいコードができるようにやるだけです。頑張りましょう。

関連記事

Rustのドキュメント、どれを見るべきなのかという話



  1. ちなみにmacroキーワードを使ってマクロを定義するdeclarative macros 2.0というものも実装中で、nightlyで#![feature(decl_macro)]を使えば試すことができます。既存のマクロは、例えばスコープの問題など、他の言語機能と一致していない部分があったのですが、これによって大体似たような感じで扱えるようになるはずです。ただし、表現力は既存のマクロと変わりません。 
  2. なお、なんでもと言いましたが、もちろんマクロの出力結果はRustのソースコードである必要があり、それより先の翻訳フェイズに干渉することはできないので、例えばインラインアセンブラを手続きマクロで実装しようと思ってもできません。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。