💥

Rust の derive macro を書いてみよう

に公開

前回は cargo-expand を使って thiserror crate の derive macro の展開結果を見てみました 。今回は derive macro を書いて derive macro に慣れてみようと思います。

derive macro とは

derive macro とは、手続き的マクロ (procedural macro) の一種で、structenum などに derive 属性を指定したときに自動で生成されるコードを指定するマクロです。

標準で提供される derive macro の例としては Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd があります。

ユーザーが独自に custom derive macro を定義することもできます。今回はこれを扱います。

custom derive macro の定義方法

前述の通り derive macro は procedural macro です。 procedural macro は crate type を proc-macro にしておく必要があります。 Cargo.toml に次のような記述をすることで crate type が proc-macro になります。

[lib]
proc-macro = true

derive macro は proc_macro_derive 属性のついた関数で定義します。関数のシグネチャは (TokenStream) -> TokenStream です。入力として derive 属性のついたアイテムの TokenStream を受け取り、そこに追加する TokenStream を返します。

TokenStream はコンパイラが提供している proc_macro crate で定義されています。

公式のリファレンスにある例です。

extern crate proc_macro;
use proc_macro::TokenStream;

#[proc_macro_derive(AnswerFn)]
pub fn derive_answer_fn(_item: TokenStream) -> TokenStream {
    "fn answer() -> u32 { 42 }".parse().unwrap()
}
extern crate proc_macro_examples;
use proc_macro_examples::AnswerFn;

#[derive(AnswerFn)]
struct Struct;

fn main() {
    assert_eq!(42, answer());
}

今回の例

今回は enum に対して variant の名前の一覧を返す variants 関連関数を追加する derive macro を作ってみます。

こんな感じです。

#[derive(derive1::VariantsFn)]
enum E1 {
    A,
    B(i32),
    C { b: bool },
}

#[derive(derive1::VariantsFn)]
enum E2 {}

fn main() {
    assert_eq!(E1::variants(), &["A", "B", "C"]);
    assert_eq!(E2::variants(), &[] as &'static [&'static str]);
}

enum で定義されたデータ型に対して、 fn variants() -> &'static [&'static str] な関連関数が追加され、文字列の形で返してくれます。

コード例

コード例の全体は https://github.com/bouzuya/rust-examples/tree/7c3133ce43a61204f271dbe3366cca640598f5fa/derive_macro1 です。

proc-macro 用の crate とそれを利用する crate の 2 つが必要なので、まずは cargo workspace をつくります。

Cargo.toml

Cargo.toml を↓のように配置します。

[workspace]
members = ["crates/*"]
resolver = "3"

crates directory を作成して、その下で cargo new derive1 --libcargo new main として、ライブラリクレートとバイナリクレートを作成します。

crates/main/Cargo.toml

crates/main/Cargo.toml には derive1 crate を依存関係に追加します。

[package]
name = "main"
edition = "2024"
publish = false

[dependencies]
derive1 = { path = "../derive1" }

[lints.rust]
dead_code = "allow"

crates/main/src/main.rs

crates/main/src/main.rs は先ほども挙げたとおり、こんな感じです。 #[derive(...)] に今回定義した VariantsFn custom derive macro が使用されています。 variants 関数も提供されていますね。

#[derive(derive1::VariantsFn)]
enum E1 {
    A,
    B(i32),
    C { b: bool },
}

#[derive(derive1::VariantsFn)]
enum E2 {}

fn main() {
    assert_eq!(E1::variants(), &["A", "B", "C"]);
    assert_eq!(E2::variants(), &[] as &'static [&'static str]);
}

crates/derive1/Cargo.toml

crates/derive1/Cargo.toml には例の lib.proc-macro = true の記述を入れます。また proc-macro の定義に便利な crate として quote crate と syn crate も依存関係に追加します。

[package]
name = "derive1"
edition = "2024"
publish = false

[dependencies]
quote = "1.0.40"
syn = "2.0.106"

[lib]
proc-macro = true

crates/derive1/src/lib.rs

crates/derive1/src/lib.rs はこんな感じです。この記事の中心的な部分です。

use proc_macro::TokenStream;
use syn::spanned::Spanned;

#[proc_macro_derive(VariantsFn)]
pub fn derive_variants_fn(input: TokenStream) -> TokenStream {
    let input = syn::parse_macro_input!(input as syn::DeriveInput);

    let data_enum = if let syn::Data::Enum(data_enum) = &input.data {
        data_enum
    } else {
        return TokenStream::from(
            syn::Error::new(input.span(), "VariantsFn can only be derived for enums")
                .to_compile_error(),
        );
    };

    let enum_ident = input.ident;
    let enum_variant_names = data_enum
        .variants
        .iter()
        .map(|variant| variant.ident.to_string());
    let output = quote::quote! {
        impl #enum_ident {
            pub fn variants() -> &'static [&'static str] {
                &[#(#enum_variant_names,)*]
            }
        }
    };

    TokenStream::from(output)
}

#[proc_macro_derive(VariantsFn)]proc_macro_derive 属性です。これで VariantsFn という custom derive macro の名前を決めています。

proc_macro_derive のついている関数は説明どおりのシグネチャになっています。 pub fn derive_variants_fn(input: TokenStream) -> TokenStream 。 ファイルの先頭で use proc_macro::TokenStream しています。

syn crate を使って入力を解釈しています。 let input = syn::parse_macro_input!(input as syn::DeriveInput); 。単純な TokenStream で扱うよりも parse された syntax tree で扱えるほうが便利です。 derive macro 向けの DeriveInput という型も定義されています。

let data_enum = if let syn::Data::Enum(data_enum) = &input.data {
    data_enum
} else {
    return TokenStream::from(
        syn::Error::new(input.span(), "VariantsFn can only be derived for enums")
            .to_compile_error(),
    );
};

今回は enum 以外には生成できないので、エラーを返しています。エラー時は panic してもコンパイルエラーになり、問題ないのですが、 syn の提供する Error を使うとエラー箇所などがわかりやすい出力になります。

let enum_ident = input.ident;
let enum_variant_names = data_enum
    .variants
    .iter()
    .map(|variant| variant.ident.to_string());
let output = quote::quote! {
    impl #enum_ident {
        pub fn variants() -> &'static [&'static str] {
            &[#(#enum_variant_names,)*]
        }
    }
};

TokenStream::from(output)

quote crate の quote macro を使用しています。 quote macro はほとんど Rust コードなのですが #var の形で変数を追加した形の proc_macro2::TokenStream を返せます。 #(#enum_variant_names,)* あたりは quote macro における繰り返し表現や区切り文字などの機能です。

proc_macro2 crate は proc_macro を機能拡張したような crate なのですが、今回はほとんど触れません。 proc_macro::TokenStream に変換して返しておしまいです。

おそらく、思ったよりかんたんですよね。

cargo expand してみる

前回の記事の知識を活かして cargo expand してみましょう。

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2024::*;
#[macro_use]
extern crate std;
enum E1 {
    A,
    B(i32),
    C { b: bool },
}
impl E1 {
    pub fn variants() -> &'static [&'static str] {
        &["A", "B", "C"]
    }
}
enum E2 {}
impl E2 {
    pub fn variants() -> &'static [&'static str] {
        &[]
    }
}
fn main() {
    match (&E1::variants(), &&["A", "B", "C"]) {
        (left_val, right_val) => {
            if !(*left_val == *right_val) {
                let kind = ::core::panicking::AssertKind::Eq;
                ::core::panicking::assert_failed(
                    kind,
                    &*left_val,
                    &*right_val,
                    ::core::option::Option::None,
                );
            }
        }
    };
    match (&E2::variants(), &(&[] as &'static [&'static str])) {
        (left_val, right_val) => {
            if !(*left_val == *right_val) {
                let kind = ::core::panicking::AssertKind::Eq;
                ::core::panicking::assert_failed(
                    kind,
                    &*left_val,
                    &*right_val,
                    ::core::option::Option::None,
                );
            }
        }
    };
}

assert_eq! の展開結果でごちゃついていますが、 derive macro の部分は単純なものです。

enum E1 {
    A,
    B(i32),
    C { b: bool },
}
impl E1 {
    pub fn variants() -> &'static [&'static str] {
        &["A", "B", "C"]
    }
}
enum E2 {}
impl E2 {
    pub fn variants() -> &'static [&'static str] {
        &[]
    }
}

意図した形で展開されていそうです。

余談: セキュリティ上のリスク

余談ですが、 procedural macro はコンパイルタイミングで動き、コンパイラと同様のリソースにアクセスできます。これは一定のセキュリティ上のリスクがあるということです。自身で定義したものはともかく、外部のものは十分に注意して導入する必要がありそうです。

おわりに

derive macro を試しに書いてみました。やってみると crate なども整っており、業務でも活かしていけそうです。

次回は、今回触れられなかった derive macro helper attributes や trybuild crate などについて書きたいと思います。

参考

GitHubで編集を提案
ドクターメイト

Discussion