2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust deriveマクロを軽くハンズオン

Last updated at Posted at 2024-12-25

こちらの記事は Rustマクロ冬期講習アドベントカレンダー 21日目の記事です!

アドカレまとめ記事はこちら!: Rustマクロ作成チートシート!

前前回は関数風マクロについて、 前回 は属性風マクロについて軽いハンズオンを行いました。今回はderiveマクロに取り組みます!

deriveマクロの定義・構造

Cargo.toml に、 [lib] proc-macro = true という項目を追加した上で、 src/lib.rs に、

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 マクロを作ってみたいと思います!(あまり複雑にしたくないためフィールドを持つ列挙体や構造体は受け取らないものとします。)

Rust
#[derive(EnumDisplay)]
#[enum_display(rename_all = "snake_case")]
enum Hoge {
    Fuga,
    #[enum_display(rename = "barbar")]
    Bar,
}

こう書くと列挙体に std::fmt::Display が自動実装されることを目指します!

Rust
// ...省略...

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 = "...")] みたいな属性を、バリアント( FugaBar のこと )には #[enum_display(rename = "...")] みたいな属性を付けられるようにします。

属性 付与対象 設定可能値 効果
rename_all 列挙体名 snake_case, camelCase 全バリアントのデフォルト出力を指定したケースにします。
rename バリアント 任意の文字列 指定した名前で出力するようにします。

このような属性は、属性風マクロとは異なるものの、deriveマクロを補助するために使われます。この属性自体はマクロのような効果はないため 不活性属性 (inert attribute) と呼ばれます。(そしてそのため属性風マクロは活性属性 (active attribute) と呼ばれます。)

手順 1. 骨組みを作る

RTAと同様に準備します!

cargo new --lib enum_display
Cargo.toml
[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

ここまでは前回までとほぼ同じで、そして例によってマクロの宣言方法が異なります。

src/lib.rs
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. 入力をパースする

今回の入力パースは関数風マクロや属性風マクロとは打って変わって固定的です!

Rust
let input = parse_macro_input!(item as DeriveInput);

組み込むと次のような感じです。

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);

    TokenStream::new()
}

関数風マクロや属性風マクロでは欲しい入力を受け取れるようにこちらで色々指定したり用意したりしましたが、deriveマクロでは名前そのままの syn::DeriveInput を入力として利用します!

Rust
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プログラミングをしている場合は不要なので忘れてもらって大丈夫です。)

src/impls.rs
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 に変換する処理は軽く探した感じ提供されてなさそうだったので、ここだけ予め準備しておきます。

Rust
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_allCase で抜き出します!当然指定されていない場合もあるので返り値シグネチャは Option<Case> です。

Rust
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 式を組み立てて完成です。 DataEnumvariants フィールド でバリアントのイテレータを取得できますので、これで各バリアントの出力を作ります。

長いですが新しい話題は少ないので、一気に書きたいと思います!

src/impls.rs
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! マクロ を使用しています。

Rust
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 の微修正のみ)!今回はやたら属性のパースが長かっただけで処理内容はシンプルでした。

最終的な全体像は次の通りになります。

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()
}
src/impls.rs
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
}
src/main.rs
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
$ 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 クレートを取り扱うつもりです!こちらのクレートを使うと今回みたいなマクロはかなりスッキリ書けます!乞うご期待!

ここまでお読みいただきありがとうございました! :bow:

  1. deriveマクロの和訳には「導出マクロ」「派生マクロ」があります。「派生マクロ」の方だとオブジェクト指向の派生クラスと混同するというか似た概念と捉えられそうなので、避けたい和訳です。一方で「導出マクロ」だと入力となる構造体・列挙体から実装内容を「導出する」ため和訳としてあっていそうです!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?