Rust の derive macro を書いてみよう
前回は cargo-expand を使って thiserror crate の derive macro の展開結果を見てみました 。今回は derive macro を書いて derive macro に慣れてみようと思います。
derive macro とは
derive macro とは、手続き的マクロ (procedural macro) の一種で、struct や enum などに 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 --lib と cargo 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 などについて書きたいと思います。
参考
- Procedural Macros - The Rust Reference https://doc.rust-lang.org/reference/procedural-macros.html
- Derive - The Rust Reference https://doc.rust-lang.org/reference/attributes/derive.html
- proc_macro - Rust https://doc.rust-lang.org/proc_macro/index.html
- quote - crates.io https://crates.io/crates/quote
- syn - crates.io https://crates.io/crates/syn
Discussion