こちらの記事は Rustマクロ冬期講習アドベントカレンダー 21日目の記事です!
アドカレまとめ記事はこちら!: Rustマクロ作成チートシート!
前前回は関数風マクロについて、 前回 は属性風マクロについて軽いハンズオンを行いました。今回はderiveマクロに取り組みます!
deriveマクロの定義・構造
Cargo.toml
に、 [lib] proc-macro = true
という項目を追加した上で、 src/lib.rs
に、
use proc_macro::TokenStream;
#[proc_macro_derive(Deriveマクロの名前, attributes(関連する不活性属性))]
pub fn deriveマクロの実装_名前は呼び出しに関係なし(item: TokenStream) -> TokenStream {
// ...
}
というように #[proc_macro_derive(Deriveマクロの名前)]
を付けた関数を定義すると、 #[derive(Deriveマクロの名前)]
みたいに使用できるderiveマクロが定義されます。
deriveマクロは構造体・列挙体にのみ使用できます。
入力の item:
TokenStream
を元にして三種の神器クレート syn quote proc-macro2 を使い出力を作るところまでは他のマクロと同様です。しかし、属性風マクロは入力部分が置換されるのに対してderiveマクロは 追記 が行われます1。
また、deriveマクロでは構造体・列挙体全体や各フィールド・バリアントに属性風マクロ的な何か(本記事の例だと #[enum_display(rename_all = "snake_case")]
など)を併用することがあります。こちらはマクロ定義においては #[proc_macro_derive(..., attributes(enum_display))]
の attributes
で指定され、この属性自体がマクロとして動作するわけではない(deriveマクロによって初めて拾われる)ので 不活性属性 (inert attribute)と呼ばれます。
deriveマクロ「 EnumDisplay
」
ド定番な車輪ですが、列挙体に std::fmt::Display
を実装する EnumDisplay
マクロを作ってみたいと思います!(あまり複雑にしたくないためフィールドを持つ列挙体や構造体は受け取らないものとします。)
#[derive(EnumDisplay)]
#[enum_display(rename_all = "snake_case")]
enum Hoge {
Fuga,
#[enum_display(rename = "barbar")]
Bar,
}
こう書くと列挙体に std::fmt::Display
が自動実装されることを目指します!
// ...省略...
impl ::std::fmt::Display for Hoge {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
match self {
Self::Fuga => write!(f, "fuga"),
Self::Bar => write!(f, "barbar"),
}
}
}
fn main() {
println!("{} {}", Hoge::Fuga, Hoge::Bar);
// hoge barbar
}
列挙体名の上には #[enum_display(rename_all = "...")]
みたいな属性を、バリアント( Fuga
や Bar
のこと )には #[enum_display(rename = "...")]
みたいな属性を付けられるようにします。
属性 | 付与対象 | 設定可能値 | 効果 |
---|---|---|---|
rename_all |
列挙体名 |
snake_case , camelCase 等 |
全バリアントのデフォルト出力を指定したケースにします。 |
rename |
バリアント | 任意の文字列 | 指定した名前で出力するようにします。 |
このような属性は、属性風マクロとは異なるものの、deriveマクロを補助するために使われます。この属性自体はマクロのような効果はないため 不活性属性 (inert attribute) と呼ばれます。(そしてそのため属性風マクロは活性属性 (active attribute) と呼ばれます。)
手順 1. 骨組みを作る
RTAと同様に準備します!
cargo new --lib enum_display
[package]
name = "enum_display"
version = "0.1.0"
edition = "2021"
+[lib]
+proc-macro = true
[dependencies]
ここまでのマクロたち(関数風マクロ、属性風マクロ)同様扱いにくい proc-macro
クレートと縁を切るため、 src/lib.rs
はシンプルに保ち実装は src/impls.rs
で行うディレクトリ構造にします。
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── impls.rs
│ ├── lib.rs
│ └── main.rs
└── target
ここまでは前回までとほぼ同じで、そして例によってマクロの宣言方法が異なります。
mod impls;
use proc_macro::TokenStream;
#[proc_macro_derive(EnumDisplay, attributes(enum_display))]
pub fn enum_display_impl(item: TokenStream) -> TokenStream {
todo!()
}
deriveマクロの入力は属性風マクロとは異なり一つです!属性風マクロでは属性部分を第一引数 attr
として、アイテム部分を第二引数 item
として受け取っていましたが、deriveマクロでは item
しか受け取りません。では不活性属性部分はどうやって受け取ればよいか?は後程わかります!
続きです。三種の神器を cargo add
しておきます。
cargo add proc-macro2 quote
cargo add syn --features full --features extra-traits
手順 2. 入力をパースする
今回の入力パースは関数風マクロや属性風マクロとは打って変わって固定的です!
let input = parse_macro_input!(item as DeriveInput);
組み込むと次のような感じです。
mod impls;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(EnumDisplay, attributes(enum_display))]
pub fn enum_display_impl(item: TokenStream) -> TokenStream {
let _input = parse_macro_input!(item as DeriveInput);
TokenStream::new()
}
関数風マクロや属性風マクロでは欲しい入力を受け取れるようにこちらで色々指定したり用意したりしましたが、deriveマクロでは名前そのままの syn::DeriveInput を入力として利用します!
pub struct DeriveInput {
pub attrs: Vec<Attribute>, // 構造体・列挙体の上に付与されている属性
pub vis: Visibility, // 構造体・列挙体の可視性
pub ident: Ident, // 構造体・列挙体の名前
pub generics: Generics, // 構造体・列挙体に存在するジェネリクス
pub data: Data, // フィールドやバリアントの情報
}
今回は、列挙体名を得るために ident
を、列挙体に付与された不活性属性の取得目的で attrs
を、そしてバリアントを扱うために data
フィールドを主に利用します。
つまり不活性属性は DeriveInput
に含まれているので、使いたい時に勝手に使う感覚でおkです!
手順 3. パースをしつつ出力を考える
バリアントと付与されている属性情報があれば入力を決定できます。ただ、入力された属性は引き続き適切にパースして中身を取り出す必要があります。
とりあえず impls.rs
側の関数で DeriveInput
を受け取るようにして続行していきましょう!
まずは DeriveInput
から使用するフィールドのみ抜き出し、また対象が列挙体であることを確認します。
付与対象が構造体か列挙体か共用体かは、 data
フィールドにてsyn::Data
列挙体により表されているので、 let-else
文のパターンマッチで調べています。(共用体は普通にRustプログラミングをしている場合は不要なので忘れてもらって大丈夫です。)
use proc_macro2::TokenStream;
use syn::{spanned::Spanned, Data, DeriveInput};
pub fn enum_display_impl(input: DeriveInput) -> syn::Result<TokenStream> {
let span = input.span(); // コンパイルエラー時に使う情報
let DeriveInput { // let文で分割
attrs,
data,
ident: enum_ident,
..
} = input;
let Data::Enum(enm) = data else {
// 構造体や共用体の時はコンパイルエラーにする
return Err(syn::Error::new(span, "expected enum"));
};
todo!()
}
ここから各バリアントごとに match
式のアームを作っていくのですが、先に列挙体に付与された属性の情報を持つ attrs: Vec<Attributes>
から rename_all
の情報がないかを抜き出しておきます。この抜き出しは愚直に行います。
今回 rename_all
に指定できるケース一覧は次の通りにしたいと考えています。
ケース | 説明 |
---|---|
UPPERCASE | 全て大文字に |
lowercase | 全て小文字に |
camelCase | キャメルケース |
PascalCase | パスカルケース |
snake_case | スネークケース |
kebab-case | ケバブケース |
文字列のケース変換を自前実装するのは面倒なので今回はクレートに頼りましょう。
cargo add convert_case
ケース名から convert_case::Case
に変換する処理は軽く探した感じ提供されてなさそうだったので、ここだけ予め準備しておきます。
use convert_case::Case;
fn str2case(s: &str) -> Result<Case, ()> {
match s.to_ascii_lowercase().as_str() {
"upper" | "uppercase" | "upper_case" => Ok(Case::Upper),
"lower" | "lowercase" | "lower_case" => Ok(Case::Lower),
"camel" | "camelcase" | "camel_case" => Ok(Case::Camel),
"pascal" | "pascalcase" | "pascal_case" => Ok(Case::Pascal),
"snake" | "snakecase" | "snake_case" => Ok(Case::Snake),
"kebab" | "kebabcase" | "kebab_case" => Ok(Case::Kebab),
_ => Err(()),
}
}
下準備は整ったので、 attrs: Vec<Attribute>
から全バリアントのデフォルトケースを決める rename_all
を Case
で抜き出します!当然指定されていない場合もあるので返り値シグネチャは Option<Case>
です。
use convert_case::Case;
use syn::{Attribute, Expr, ExprLit, Lit, Meta, MetaList, MetaNameValue};
fn get_case(attrs: Vec<Attribute>) -> Option<Case> {
let mut res = None;
// 属性は複数付いている可能性があるのでVecで渡されている
// #[hoge(...)] みたいなやつごとに繰り返し
// meta 以外のフィールドは不要
for Attribute { meta, .. } in attrs {
// Path: meta
// List: meta( ... )
// NameValue: meta = ...
// のうち MetaList 以外の時は対象外としてcontinue
let Meta::List(MetaList { path, tokens, .. }) = meta else {
continue;
};
// enum_display(rename_all = "snake_case") のうち
// path には enum_display が
// tokens には rename_all = "snake_case" が入る
// enum_display 以外の属性は対象外として continue (1)
if !path.is_ident("enum_display") {
continue;
}
// rename_all = "snake_case" を
// path: rename_all
// value: "snake_case"
// に分解
//
// もし enum_display(aaa) みたいに異なるものがある場合 continue (2)
let Ok(MetaNameValue { path, value, .. }) = syn::parse2(tokens) else {
continue;
};
// path が rename_all ではない場合 continue
if !path.is_ident("rename_all") {
continue;
}
// rename_all = 10 みたいになっている時 continue (2)
let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = value
else {
continue;
};
// "snake_case" -> Case::Snake
res = str2case(&s.value()).ok();
}
res
}
- (1):
enum_display
以外の属性もこの繰り返しに入ってくるため、path
の中身の確認は必要です。実際に#[allow(unused)]
等の適当な属性を付けてdbg!(&path)
で確認してみるとattrs: Vec<Attribute>
に含まれていることがわかります - (2):
continue
にするよりもエラーに倒した方が利用者側には便利かもしれません。ただResult<Option<Case>>
は煩雑だと考えたことと、他の別なマクロがたまたま同じ名前の不活性属性を利用している時にもエラーになってしまうことを鑑みて(杞憂)、continue
する方に倒しました
let-else文大活躍!
こういう Result
型や Option
型以外の型でかつ match
式だと多少煩雑になってしまいそうな場合には let-else
文 が便利です!
別に match
式でも浅くはできますが、特にネストを深めないことで可読性に貢献しています。
ケースが無事取得できたら後は各バリアントごとにアームを作り、最後に match
式を組み立てて完成です。 DataEnum
の variants
フィールド でバリアントのイテレータを取得できますので、これで各バリアントの出力を作ります。
長いですが新しい話題は少ないので、一気に書きたいと思います!
use convert_case::{Case, Casing};
use proc_macro2::TokenStream;
use quote::{quote, ToTokens};
use syn::{
spanned::Spanned, Attribute, Data, DeriveInput, Expr, ExprLit, Fields, Lit, Meta, MetaList,
MetaNameValue, Variant,
};
pub fn enum_display_impl(input: DeriveInput) -> syn::Result<TokenStream> {
// ...省略...
// rename_allが設定されていない場合は列挙体のバリアント表現に等しいパスカルケースにする
let rename_all = get_case(attrs).unwrap_or(Case::Pascal);
// 出力
let tokens = enm
.variants
.into_iter()
.map(|variant| { // 各バリアント( Hoge::Fuga, Hoge::Bar )ごと
let span = variant.span(); // バリアント部分のSpan
// 必要なフィールドのみ抜き取る
let Variant {
attrs, // 各バリアントの属性
ident, // 各バリアントの名前
fields: Fields::Unit, // Hogeのみ受け取り、Hoge(...) や Hoge {...} といったバリアントは許さない
..
} = variant
else {
return Err(syn::Error::new(span, "expected unit variant only"));
};
// #[enum_display(rename = "...")] が指定されているかで分岐
let name = match get_name(attrs) {
// 指定されている場合はそちらで確定
Some(name) => name.to_token_stream(),
// 指定されていない場合
None => {
// 名前を rename_all で指定されたケースに変換
let ident = ident.to_string().to_case(rename_all);
ident.to_token_stream()
}
};
// アームの出力 (1)
Ok(quote! {
Self::#ident => write!(f, #name),
})
})
.collect::<syn::Result<Vec<TokenStream>>>()?;
// この時点で
// tokens = vec![
// Self::Fuga => write!(f, "fuga"),
// Self::Bar => write!(f, "barbar"),
// ];
// のようになっている
// 全体の出力 (1)
Ok(quote! {
// 衛生性のためフルパス表示 (2)
impl ::std::fmt::Display for #enum_ident {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
match self {
// アームが展開される
#(
#tokens
)*
}
}
}
})
}
// 各バリアントの rename 指定を抽出
// 最後以外は get_case とほぼ同じ
fn get_name(attrs: Vec<Attribute>) -> Option<String> {
let mut res = None;
for Attribute { meta, .. } in attrs {
let Meta::List(MetaList { path, tokens, .. }) = meta else {
continue;
};
if !path.is_ident("enum_display") {
continue;
}
let Ok(MetaNameValue { path, value, .. }) = syn::parse2(tokens) else {
continue;
};
if !path.is_ident("rename") {
continue;
}
let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = value
else {
continue;
};
res = Some(s.value());
}
res
}
- (1):
quote::quote!
マクロ を使って、希望通りの出力を構成 - (2): マクロの出力で登場するインポートパスは、外部の影響を極力受けないようにフルパスで記述する
前回・前々回では何故か運よく?使用しませんでしたが、手続きマクロ実装において出力を作るために必須な quote::quote!
マクロ を使用しています。
Ok(quote! {
impl ::std::fmt::Display for #enum_ident {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
match self {
#(
#tokens
)*
}
}
}
})
quote!
マクロを使うと、あたかも宣言マクロと同じような記法で proc_macro2::TokenStream
を構成できます。違いは $
が #
に変わったぐらいでしょうか? #name
みたいに #変数
でアクセスできるかはその値が ToTokens
トレイトを実装した型かどうかで決まります。 syn
クレート以下の構文要素には軒並み実装されています。
::std::fmt::Display
の部分を ::
から始める丁寧な書き方で表現しているのは、パスの曖昧さを排除して衛生性を保つためです。下手に fmt::Display
等途中から書く書き方になっていると、マクロ前後の use
の状況に左右され、実装される Display
が意図したものと異なってしまう可能性があることより、フルパスで書いています。
衛生性について詳しくは別な記事にまとめたのでそちらを読んでほしいです!
Rustマクロの事前知識③「マクロのマナー 衛生性(健全性)」 #Rust - Qiita
まとめ・所感
これでマクロはほぼ完成です(あとは src/lib.rs
の微修正のみ)!今回はやたら属性のパースが長かっただけで処理内容はシンプルでした。
最終的な全体像は次の通りになります。
mod impls;
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(EnumDisplay, attributes(enum_display))]
pub fn enum_display_impl(item: TokenStream) -> TokenStream {
let input = parse_macro_input!(item as DeriveInput);
impls::enum_display_impl(input)
.unwrap_or_else(syn::Error::into_compile_error)
.into()
}
use convert_case::{Case, Casing};
use proc_macro2::TokenStream;
use quote::{quote, ToTokens};
use syn::{
spanned::Spanned, Attribute, Data, DeriveInput, Expr, ExprLit, Fields, Lit, Meta, MetaList,
MetaNameValue, Variant,
};
pub fn enum_display_impl(input: DeriveInput) -> syn::Result<TokenStream> {
let span = input.span();
let DeriveInput {
attrs,
data,
ident: enum_ident,
..
} = input;
let Data::Enum(enm) = data else {
return Err(syn::Error::new(span, "expected enum"));
};
let rename_all = get_case(attrs).unwrap_or(Case::Pascal);
// 出力
let tokens = enm
.variants
.into_iter()
.map(|variant| {
let span = variant.span();
let Variant {
attrs,
ident,
fields: Fields::Unit,
..
} = variant
else {
return Err(syn::Error::new(span, "expected unit variant only"));
};
let name = match get_name(attrs) {
Some(name) => name.to_token_stream(),
None => {
let ident = ident.to_string().to_case(rename_all);
ident.to_token_stream()
}
};
Ok(quote! {
Self::#ident => write!(f, #name),
})
})
.collect::<syn::Result<Vec<TokenStream>>>()?;
Ok(quote! {
impl ::std::fmt::Display for #enum_ident {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
match self {
#(
#tokens
)*
}
}
}
})
}
fn get_case(attrs: Vec<Attribute>) -> Option<Case> {
let mut res = None;
for Attribute { meta, .. } in attrs {
let Meta::List(MetaList { path, tokens, .. }) = meta else {
continue;
};
if !path.is_ident("enum_display") {
continue;
}
let Ok(MetaNameValue { path, value, .. }) = syn::parse2(tokens) else {
continue;
};
if !path.is_ident("rename_all") {
continue;
}
let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = value
else {
continue;
};
res = str2case(&s.value()).ok();
}
res
}
fn str2case(s: &str) -> Result<Case, ()> {
match s.to_ascii_lowercase().as_str() {
"upper" | "uppercase" | "upper_case" => Ok(Case::Upper),
"lower" | "lowercase" | "lower_case" => Ok(Case::Lower),
"camel" | "camelcase" | "camel_case" => Ok(Case::Camel),
"pascal" | "pascalcase" | "pascal_case" => Ok(Case::Pascal),
"snake" | "snakecase" | "snake_case" => Ok(Case::Snake),
"kebab" | "kebabcase" | "kebab_case" => Ok(Case::Kebab),
_ => Err(()),
}
}
fn get_name(attrs: Vec<Attribute>) -> Option<String> {
let mut res = None;
for Attribute { meta, .. } in attrs {
let Meta::List(MetaList { path, tokens, .. }) = meta else {
continue;
};
if !path.is_ident("enum_display") {
continue;
}
let Ok(MetaNameValue { path, value, .. }) = syn::parse2(tokens) else {
continue;
};
if !path.is_ident("rename") {
continue;
}
let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = value
else {
continue;
};
res = Some(s.value());
}
res
}
use enum_display::EnumDisplay;
#[derive(EnumDisplay)]
#[enum_display(rename_all = "snake_case")]
enum Hoge {
Fuga,
#[enum_display(rename = "barbar")]
Bar,
}
fn main() {
println!("{} {}", Hoge::Fuga, Hoge::Bar);
}
cargo expand
の結果は次のようになり、
$ cargo expand --bin enum_display
Checking enum_display v0.1.0 (/path/to/enum_display)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use enum_display::EnumDisplay;
#[enum_display(rename_all = "snake_case")]
enum Hoge {
Fuga,
#[enum_display(rename = "barbar")]
Bar,
}
impl ::std::fmt::Display for Hoge {
fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result {
match self {
Self::Fuga => f.write_fmt(format_args!("fuga")),
Self::Bar => f.write_fmt(format_args!("barbar")),
}
}
}
fn main() {
{
::std::io::_print(format_args!("{0} {1}\n", Hoge::Fuga, Hoge::Bar));
};
}
実行結果は次の通りになりました!
$ cargo run -q
fuga barbar
deriveマクロは入力が DeriveInput
である点が特徴的でした!そこから属性をパースしつつquote::quote!
マクロを使って出力を作っていきました。
deriveマクロはなんとなく実装が大変そうなイメージがありますが、実装したい処理次第であり結局は他のマクロと大差ありません。おそらく属性のパースが面倒だったのでそのような印象がある気がします。
そこで今後の回では daring クレートを取り扱うつもりです!こちらのクレートを使うと今回みたいなマクロはかなりスッキリ書けます!乞うご期待!
ここまでお読みいただきありがとうございました!
-
deriveマクロの和訳には「導出マクロ」「派生マクロ」があります。「派生マクロ」の方だとオブジェクト指向の派生クラスと混同するというか似た概念と捉えられそうなので、避けたい和訳です。一方で「導出マクロ」だと入力となる構造体・列挙体から実装内容を「導出する」ため和訳としてあっていそうです! ↩