🎩

Rust の procedural macro を操って黒魔術師になろう〜proc-macro-workshop の紹介

2020/12/12に公開

この記事は Rust 3 Advent Calendar 2020 12日目の記事です。

11日目は -> rust-lang/rustへのcode contributionをはじめからていねいに
13日目は -> rust-lang/rustにコントリビュートした話

対象読者

  • Rust にある程度慣れている
  • proc macro を自分で作ってみたいが、作り方が分からない

procedural macro (手続きマクロ) とは

こんにちは、@magurotuna です。

Rust には大きく分けて2種類のマクロがあります。

  1. Declarative macro (宣言マクロ)
  2. Procedural macro (手続きマクロ) proc macro とも呼ばれます

宣言マクロは比較的馴染み深いもので、自分で書く機会も少なくないと思います。macro_rules! を使って作るマクロです。
拙著ですが、以下のエントリで chmin!chmax! という簡単な宣言マクロを作る例を紹介しています。

https://qiita.com/maguro_tuna/items/fab200fdc1efde1612e7

宣言マクロは比較的読み書きしやすく(比較的、というのは proc macro と比較して、です)、それでいて数多くのケースに対する十分な表現力を兼ね備えており、ボイラープレートを削減するのにかなり強力な手段となっています。

しかし宣言マクロではどうしても実現できないこともあります。その代表例が derive macro です。
以下のように serde クレート を使ってシリアライズ/デシリアライズができるデータ構造を作ることがあると思います。ここで使われている #[derive(Serialize, Deserialize)]derive macro です。

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct Point {
  x: i32,
  y: i32,
}

fn main() {
    let point = Point { x: 1, y: 2 };

    // JSON形式の文字列に変換
    let serialized = serde_json::to_string(&point).unwrap();

    // serialized = {"x":1,"y":2} と表示される
    println!("serialized = {}", serialized);
}

(この例は こちら から引用、一部改変 [1]

この例では、derive macro は serdeSerializeDeserialize というトレイト実装を自動導出する、という役割を果たしています。

また、表現力の点でも proc macro は非常に強力です。「手続きマクロ」の名前の通り、ASTからASTへの変換を手続き的に書くことができます。極めて強力な表現力と柔軟性で、ありとあらゆる変換が実現できます。
この強力な柔軟性により、以下のように「Cpu0 から Cpu511 までのバリアントをもつ enum を作りたい」といったときに使える seq! というマクロを作れたりします。
(多分これは宣言マクロだと作ることができない…と思うのですが、再帰をうまく駆使すればできるのかも。自信なし)

seq!(N in 0..512 {
    enum Processor {
        #(
            Cpu#N,
        )*
    }
});

// 以下のように展開される
// enum Processor {
//     Cpu0,
//     Cpu1,
//     Cpu2,
//     // ...中略...
//     Cpu510,
//     Cpu511,
// }

(この例は こちら から引用、一部改変 [2]

このように非常に強力な proc macro ですが、強力で柔軟な分、宣言マクロよりも書くのが難しいです。
そこで勉強用の教材としてかなりおすすめなのが今回紹介する dtolnay/proc-macro-workshop です。

proc-macro-workshop の紹介

https://github.com/dtolnay/proc-macro-workshop

proc-macro-workshop は、proc macro を使って実現できる様々な機能を、実際に作りながら習得できるように配慮された教材です。題材となるマクロとして以下の5つが用意されています。

  • deriveマクロ derive(Builder)
  • deriveマクロ derive(CustomDebug)
  • 関数っぽいマクロ seq!
  • attributeマクロ #[sorted]
  • attributeマクロ #[bitfield]

この中から、proc macroを作る上での基礎となる2つの要素:

  1. 構文木の走査
  2. その結果をもとにした出力コード構築

を習得するために最初にやるべきとされている Builder マクロ 作りについて、少し掘り下げて紹介します。

Builder マクロ作りに挑戦

Builder マクロは名前の通り、ビルダーを作るためのマクロです。
例えば構造体を作るときに、構造体のフィールド値を一度にまとめて渡して構造体を作るのではなく、「ビルダー」という構造体を作ることだけを責務にもつ補助的な構造体を用意します。その「ビルダー」を介してメソッドチェーンスタイルで目的の構造体を作りあげる、という有名なデザインパターンです。
Rust では関数のオプショナル引数や可変長引数のような機能がないため、ビルダーパターンはかなりよく使われています。

ある構造体に対して「ビルダー」を作るのはそれほど大変ではないものの、ほとんど機械的な作業になります。たくさんの構造体に対してそれぞれのビルダーを作ろうとすると、大量のボイラープレートが発生するでしょう。そのようなケースで使えるマクロを作ろう、というわけです。

最後にできあがる成果物

proc-macro-workshop の Builder マクロ作りでは、以下のように使うことのできるマクロを作ることが最終的な目標となっています。

// from https://github.com/dtolnay/proc-macro-workshop#derive-macro-derivebuilder

use derive_builder::Builder;

#[derive(Builder)]
pub struct Command {
    executable: String,
    #[builder(each = "arg")]
    args: Vec<String>,
    current_dir: Option<String>,
}

fn main() {
    let command = Command::builder()
        .executable("cargo".to_owned())
        .arg("build".to_owned())
        .arg("--release".to_owned())
        .build()
        .unwrap();

    assert_eq!(command.executable, "cargo");
}

Command::builder によってビルダーを生成し、メソッドチェーンの形で値を入れていって、最後に build() を呼び出すことで目的である Command 構造体を作っています。
Commandargs フィールドに #[builder(each = "arg") というアトリビュートが付与されており、これによってビルダーのメソッドに .arg が生えている(.args ではない)、というのもポイントです。

実装の進め方

まずはリポジトリを fork してからローカルに clone しましょう。
リポジトリルートに builder ディレクトリがあって、その中に今作ろうとしている Builder マクロの雛形があります。

builder ディレクトリの構造は以下のようになっています。

.
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── 01-parse.rs
    ├── 02-create-builder.rs
    ├── 03-call-setters.rs
    ├── 04-call-build.rs
    ├── 05-method-chaining.rs
    ├── 06-optional-field.rs
    ├── 07-repeated-field.rs
    ├── 08-unrecognized-attribute.rs
    ├── 08-unrecognized-attribute.stderr
    ├── 09-redefined-prelude-types.rs
    └── progress.rs

課題の進捗はテストケースで管理されます。
まず tests/01-parse.rs のテストが通るように src/lib.rs の実装を行い、実装ができたら tests/progress.rs01-parse.rs の行のコメントアウトを外します。

tests/progress.rs
#[test]
fn tests() {
    let t = trybuild::TestCases::new();
-    //t.pass("tests/01-parse.rs");
+    t.pass("tests/01-parse.rs");
    //t.pass("tests/02-create-builder.rs");
    //t.pass("tests/03-call-setters.rs");
    //t.pass("tests/04-call-build.rs");
    //t.pass("tests/05-method-chaining.rs");
    //t.pass("tests/06-optional-field.rs");
    //t.pass("tests/07-repeated-field.rs");
    //t.compile_fail("tests/08-unrecognized-attribute.rs");
    //t.pass("tests/09-redefined-prelude-types.rs");
}

そしていつも通り cargo test でテストを実行します。実装がうまくいっていればテストが PASS します!次は tests/02-create-builder.rs が通るようにして、progress.rs のコメントアウトを外して、テストを実行して……以下、この繰り返しです。徐々に最終目標のビルダーができあがっていきます。

各テストファイルの先頭には、実装の方針、このテストで何を確認したいのか、参考資料へのリンク等がコメントとして書かれています。
試しに tests/01-parse.rs の中身を見てみましょう:

// This test looks for a derive macro with the right name to exist. For now the
// test doesn't require any specific code to be generated by the macro, so
// returning an empty TokenStream should be sufficient.
//
// Before moving on, have your derive macro parse the macro input as a
// syn::DeriveInput syntax tree.
//
// Spend some time exploring the syn::DeriveInput struct on docs.rs by clicking
// through its fields in the documentation to see whether it matches your
// expectations for what information is available for the macro to work with.
//
//
// Resources:
//
//   - The Syn crate for parsing procedural macro input:
//     https://github.com/dtolnay/syn
//
//   - The DeriveInput syntax tree which represents input of a derive macro:
//     https://docs.rs/syn/1.0/syn/struct.DeriveInput.html
//
//   - An example of a derive macro implemented using Syn:
//     https://github.com/dtolnay/syn/tree/master/examples/heapsize

use derive_builder::Builder;

#[derive(Builder)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    env: Vec<String>,
    current_dir: String,
}

fn main() {}

コメント部分を意訳すると以下のようになるでしょうか。

このテストは正しい名前の derive マクロ(つまり今回の場合は Builder という名前のマクロ)が存在するかどうかを確認します。現時点ではマクロの生成結果は見ないので、空の TokenStream を返せば十分です。
マクロの入力を syn::DeriveInput という構文木としてパースするようにしてください。
ドキュメント上で syn::DeriveInput のフィールドをポチポチクリックして、マクロがどのような情報を使って動作するのかを調べてみてください。
【資料】
proc macroの入力をパースするためのsynクレート: https://github.com/dtolnay/syn
パース結果を表す構造体 DeriveInput: https://docs.rs/syn/1.0/syn/struct.DeriveInput.html
synクレートを使って derive macro を実装している実例: https://github.com/dtolnay/syn/tree/master/examples/heapsize

資料の中でも最後の「実例」はかなり参考になります。実例を見ることで derive macroを作る上での「流儀」が分かり、スムーズに実装に入れるようになると思います。

proc macro 3種の神器

上記のコメントで syn というクレートが出てきましたが、proc macro を作る上では

の3つのクレートにほぼ確実と言って差し支えないほどお世話になります。僕は勝手に「proc macro 3種の神器」と呼んでいます。

proc macro の実装は、大枠としては「proc_macro::TokenStream から proc_macro::TokenStream への変換」です。この変換の過程で「3種の神器」が活躍します。

  1. syn を使って、proc_macro::TokenStream をパースして構文木として扱えるようにする
  2. 構文木をもとにして、出力となる proc_macro::TokenStream を手続き的に作り上げていく。format! マクロが Display な値を展開しながら文字列を生成するのと同じように、quote! マクロを使うことによって、「quote::ToTokens トレイトを実装する値を展開しながら、proc_macro2::TokenStream を生成する」ことができる(なお、syn によってパースされた構文木のアイテムは quote::ToTokens を実装している)
  3. proc_macro2synquote の間でのインターフェースとなる proc_macro2::TokenStream を提供する

ヒントやドキュメントを読んでもどうしても分からない場合

workshop の進め方と proc macro の作り方の原理的なところは以上です。あとはテストファイルに書かれたヒントや資料、ドキュメント等を読みながら進めていけば OK です。

とはいえ、ヒントを読むだけでは分からない部分も出てくると思います(少なくとも自分はそうでした)。そういった場合は、そもそも全く知らないような概念が必要で、考えるだけではどうしようもないといったことも多いでしょう。以下のような「逃げ道」を利用するのがおすすめです。

他の人の答えを見る

シンプルにして最強の解決方法です。GitHub 内で検索する、あるいは元リポジトリの fork 先を見てみると、解答が見つかると思います。例えば:

https://github.com/DzenanJupic/proc_macros
https://github.com/jonhoo/proc-macro-workshop
https://github.com/magurotuna/proc-macro-workshop

などです。(最後のは僕の解答ですが、Builderマクロのみなので、ご注意ください)

ライブコーディング動画を見る

YouTubeにあるライブコーディング動画を見て Rust を学ぼう で紹介した @jonhoo さんがこの workshop に挑戦している動画があります。こちらも参考になると思います。

デバッグの方法

実装できたつもりだけどテストで落ちてしまう、ということも多々あります。テストのエラーログを見て修正箇所が分かれば良いのですが、皆目検討がつかないときには、cargo-expand というツールを使うのがおすすめです。

https://github.com/dtolnay/cargo-expand

以下のコマンドでインストールできます。

$ cargo install cargo-expand

このツールを使うことで、マクロが展開されたあとのコードを確認することができます。例として冒頭に出てきたコードの簡略版:

use serde::Serialize;

#[derive(Serialize)]
struct Point {
    x: i32,
}

に対して cargo expand してみます。以下のような結果が得られます。

#![feature(prelude_import)]
#[prelude_import]
use std::plude::v1::*;
#[macro_use]
extern crate std;
use serde::Serialize;
struct Point {
    x: i32,
}
#[doc(hidden)]
#[allow(non_upper_case_globals, unused_attributes, unused_qualifications)]
const _: () = {
    #[allow(rust_2018_idioms, clippy::useless_attribute)]
    extern crate serde as _serde;
    #[automatically_derived]
    impl _serde::Serialize for Point {
        fn serialize<__S>(&self, __serializer: __S) -> _serde::export::Result<__S::Ok, __S::Error>
        where
            __S: _serde::Serializer,
        {
            let mut __serde_state = match _serde::Serializer::serialize_struct(
                __serializer,
                "Point",
                false as usize + 1,
            ) {
                _serde::export::Ok(__val) => __val,
                _serde::export::Err(__err) => {
                    return _serde::export::Err(__err);
                }
            };
            match _serde::ser::SerializeStruct::serialize_field(&mut __serde_state, "x", &self.x) {
                _serde::export::Ok(__val) => __val,
                _serde::export::Err(__err) => {
                    return _serde::export::Err(__err);
                }
            };
            _serde::ser::SerializeStruct::end(__serde_state)
        }
    }
};

#[derive(Serialize)] がゴッソリと展開されていることが読み取れます。人間には読みにくい部分もありますが、デバッグをするには十分な情報が得られるはずです。

おわり

proc-macro-workshop の1つ目の題材であるBuilderマクロ作りを紹介しました。
これで proc macro 作りに興味が湧いた方は、ぜひ他の workshop の他の題材に挑戦してみたり、自分が求めるマクロを思いのままに作ってみたりしてみてください!

マクロを書くのは楽しいですが、あまりにも乱用しすぎると rust-analyzer が補完してくれなくなったり、メンテナンス性が落ちたり、といった事態も発生しうるので、用法・容量を守ってマクロライフを楽しんでいきましょう。

脚注
  1. MIT License https://github.com/serde-rs/serde/blob/master/LICENSE-MIT ↩︎

  2. MIT License https://github.com/dtolnay/proc-macro-workshop/blob/master/LICENSE-MIT ↩︎

Discussion