医療DXを加速させる愉快な技術集団ispec inc.で医療システムのオンプレとクラウドを安全に繋ぐプラットフォームのCloudSailのエンジニアをしております。
この投稿はispec-inc Advent Calendar 2025への投稿です。
愉快ですごい方々の記事が投稿されるのでぜひ見て楽しんでください!
さて、今回は自称Rustaceanとして、技術集団の一員としてRustの普及に努めるためにRustの最も不思議なプロシージャルマクロ(手続マクロ)に触れてみる、というものです。
プロシージャルマクロはRustの魔法のようなものでライブラリ作成者はユーザフレンドリーなAPIを提供できるようになります。
プロシージャルマクロって?
Rustを触ったことがある人なら一度は次のようなコードを見たことがあるのではないかと思います。
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct AwesomeStructure {
name: String,
age: i32,
}
そして、
fn main() {
let obj = AwesomeStructure {
name: "SHIMA",
age: 26
};
let json_obj = serde_json::to_string(&obj);
}
とするとjson_objとして
{
"name": "SHIMA",
"age": 26
}
を得られます。
この処理の中で実は#[derive(Serialize,...)]が大きな仕事をしていて、これをプロシージャルマクロ (proc-macro)と呼びます。
そして、今回の話はこれを作ってみましょう!というものです。
プロシージャルマクロの流れ
Rust公式にはプロシージャルマクロは以下のように書かれています。
Procedural macros allow you to run code at compile time that operates over Rust syntax, both consuming and producing Rust syntax. You can sort of think of procedural macros as functions from an AST to another AST.
要するにコンパイル時に実行されて、Rustの構文を消費して生成するよ、ということです。また、AST(抽象木構造)を別のASTに変換する関数を書くこととも言える、とも書かれていますね。
より平坦に僕の理解で言ってしまえば手続き型マクロが行うのは
RustのDeriveを始めとするマクロに関する糖衣構文を見つけたらあらかじめ定義されているマクロを用いて、
その部分を新たなRustのソースコードを生成してそれをコンパイルするよ
ということです。
もっと具体的には例えばHelloというStructure内のhello_targetに対してHello, {hello_target}を行うプロシージャルマクロが存在するとします。
すると、
#[derive(Hello)]
struct Structure {
#[hello_target]
name: String,
}
とかいて、コンパイルすると#[derive(Hello)]部分をコンパイラが見つけた際に、Helloというマクロを呼び出します。
そしてこのマクロはコンパイラからこの構造体に関する情報(TokenStream)を受け取ります。マクロはこの構文を解析してnameにhello_targetがついているな、と見た上で最終的に以下のようなRust構文をコンパイラに返します。
impl Hello for Structure {
fn hello(&self) -> String {
format!("Hello, {name}")
}
}
そして、この構文をコンパイラがコンパイルすることで実行時にはユーザーは単にこの構造体のhelloメソッドを呼び出すだけでHello, {name}を得られる、というわけです。
つまり、以下の流れがプロシージャルマクロの全容です。
- ユーザが自身の作成した構造体にプロシージャルマクロを糖衣実装する
- コンパイルする
- コンパイラがユーザの「プロシージャルマクロを実行してね」という糖衣構文を見つけて、マクロを呼び出す
- マクロは対象の構造体の情報を受け取って解析してユーザのお望みの実装をマクロの定義に従って施す
- マクロから返ってきたデータを元のコンパイル中のデータに追加してコンパイルを進める
- ユーザがコンパイル結果を実行すると、想定通りの結果が出力される
この内容における利用ユーザは1,6だけをするだけで利用できるわけです。
構造体に対して毎回helloを実装することなく、結果が得られるため、まるで魔法のようだ!と感じるわけです。(僕はそう感じました。)
さてさて、では次に実際にこのHelloプロシージャルマクロを実装してみましょう。
Helloプロシージャルマクロの実装
ではでは、まずはいつも通りにRustの開発環境を整えてください。
今回はWorkspacesを使ってhelloとhello_deriveというライブラリクレートを作成します。
ここの方法がわからないよ〜という人は公式のドキュメント(日本語版)を確認して整えてください。
以下のようなディレクトリ構造になれば良いです。
.
├── Cargo.lock
├── Cargo.toml
├── hello
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── src
│ │ └── lib.rs
│ └── target
│ └── ...
├── hello_derive
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── src
│ │ └── lib.rs
│ └── target
│ └── ...
└── target
└── ...
※ target配下は自動生成のものなので省略しています。
また、hello_deriveライブラリ側はproc-macroを有効にします。
Cargo.tomlは以下のようにしておきます。
[package]
name = "hello_derive"
version = "0.1.0"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1.0"
ここでsynとquoteという二つの依存関係がありますが、これらはプロシージャルマクロを書く上での神器の二つです。
とりあえず、どんなライブラリかに関係なくプロシージャルマクロを書く際には基本的に脳死で入れてしまっていいです。
一応、簡単にいうと
- syn: コンパイラが渡してきた入力をわかりやすく意味単位で使いやすくまとめてくれる
- quote: 分かりやすいRustライクな構文をTokenStreamというコンパイラが扱う形に変換してくれる
というもので、これらがあるからこそ、Rustのプロシージャルマクロは「やってみよう!」と言ってやってみれるくらいの難易度になっているのです。
今は一旦「プロシージャルマクロを簡単に作るための神器」と認識してもらえれば大丈夫です。
プロシージャルマクロのライブラリ作成の前準備
今回はプロシージャルマクロの中でもよく見かけるderive-macroと簡単なattribute-macroを作成します。
deriveマクロは上記でも書いたようにある構造体に対して特定の機能を追加するイメージです。そのため、まずは実装するtraitを定義します。
今回はHelloというtraitをhelloクレート側に以下のようにおきましょう。
(以下はhello/src/lib.rsに書く)
pub trait Hello {
fn hello(&self) -> String;
}
単体テストを書いてもいいよ
もしテストを書きたいなら簡単に単体テストを以下のようにlib.rsに追加するのも良いですね。#[cfg(test)]
mod test {
use super::Hello;
struct HelloUser {
name: String,
}
impl Hello for HelloUser {
fn hello(&self) -> String {
format!("Hello, {}!", self.name)
}
}
#[test]
fn hello_test() {
let hello_manual = HelloUser {
name: "Bob".to_string(),
};
assert_eq!(hello_manual.hello(), "Hello, Bob!");
}
}
これで準備は完了です。
ここも魔法のように感じてる所以ですが、実はメインであるはずのhelloライブラリは非常に単純な型の宣言のみで終わってしまうことがほとんどです。
proc-macroライブラリの実装
ここから私たちは魔法を書いていきます。
上記のテストコードのように実装するのではなく、スマートに
#[derive(Hello)]
struct HelloUser {
#[hello_target]
name: String
}
とかけるようにしていきます。
ではhello_deriveクレートを開きましょう。
書き始める前に思い出して欲しいことがあります。
proc-macroはどのようなものだったでしょうか?
AST(抽象木構造)を別のASTに変換する関数を書くこととも言える
というのを覚えていますか?ASTとか小難しいことは一旦端に追いやって大切なのは関数であるということです。
じゃあ、関数の定義をしましょう。
(以下はhello_derive/src/lib.rsに書く)
use proc_macro::TokenStream;
pub fn hello_derive(input: TokenStream) -> TokenStream {}
これがマクロになる関数です。
おそらく、これを書くと以下のようなエラーがrust-analyzerなどからエラーがでます。
proc-macrocrate types currently cannot export any items other than functions tagged with#[proc_macro],#[proc_macro_derive], or#[proc_macro_attribute]
要するに「このクレートはproc-macroだからexportできる関数はこの関数はproc-macroだよとマークしたものだけですよ」ってことですね。
では、先ほどの関数にマークしてあげましょう。
今回はderiveマクロなので...
use proc_macro::TokenStream;
#[proc_macro_derive(Hello)]
pub fn hello_derive(input: TokenStream) -> TokenStream {}
とすることでエラーが消えます。今このタイミングでhello_derive関数はHelloというderiveマクロとしてexportされるようになりました。
では実際の関数の処理を書いていきましょう。と、その前に最終的に作りたい形を今一度確認しておきましょう。
コードを書くうえでゴールを理解した上で進めるのが大切なのはproc-macroも同じですからね。
今回、私たちはhelloクレートで作成したHelloトレイトを実装したいのでした。
出力はHello, 〇〇!となるようにします。
つまり
impl Hello for XXX {
fn hello(&self) -> String {
format!("Hello, {}!", self.XXXXX)
}
}
という形を作ることがゴールになりますね。
このなかで定型分ではない部分は
- XXX: 構造体名
- self.XXXXX: 表示するターゲットのフィールド名
の二つということがわかりました。
では、この二つを取得していきましょう。最初は構造体から取得してstruct_nameとして保持しましょう!
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Hello)]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// inputのIdentはDeriveマクロでは構造体の名前(Identity)を保持する
let struct_name = &input.ident;
}
ここではsynを使って入力のTokenStreamをパースしています。
- syn: コンパイラが渡してきた入力をわかりやすく意味単位で使いやすくまとめてくれる
ということでした。このパースでTokenStreamをRust構文の意味でまとめて簡単に扱えるようになります。
その下の
let struct_name = &input.ident;
では入力の名前を取得するのに.identというフィールドを呼んでいます。
synが「お、この構造体の名前はこれだな」と判断してidentとして保持してくれたわけですね。
(※ ここでidentというものが出ていますが、proc-macroではある"もの"(構造体であれ、フィールドであれ、他も)の名前はIdentというもので扱われます。ここは難しく考えずにその"もの"のIdentityだと考えると良いです。)
さて、どんどんいきますよ。
次に欲しいのは「表示するターゲットのフィールド名」でしたね。
これにはhello_targetという属性を利用するのでしたね。
これを探していきたいところですが、その前にproc-macroに対して「このマクロはhello_targetという属性を利用しまっせ」と教えてあげる必要があります。
そうでないと、利用時に「hello_targetなんて属性聞いてない!間違いだ!」とコンパイルエラーになってしまいます。
そのため、proc_macro_deriveにattributesを追加してあげましょう。
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
// 利用する属性をattributesとして追加する
#[proc_macro_derive(Hello, attributes(hello_target))]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// inputのIdentはDeriveマクロでは構造体の名前(Identity)を保持する
let struct_name = &input.ident;
}
さて、舞台は整いましたね。ターゲットのフィールドも探していきます。
なお、今回は構造体の名前付きフィールドのみを対象として作成していきます。なので、他のパスはコンパイルエラーを返してあげるようにもしてしまいましょう。
use proc_macro::TokenStream;
use syn::{parse_macro_input, DeriveInput};
// 利用する属性をattributesとして追加する
#[proc_macro_derive(Hello, attributes(hello_target))]
pub fn hello_derive(input: TokenStream) -> TokenStream {
...既存のコード...
let target_field_ident = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
found_target.push(&field.ident);
}
}
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
match candidate {
Some(ident) => {
ident
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
let names = found_target
.iter()
.map(|x|
x.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
};
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct with named fields";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
};
}
一気にバーっと出てきて「は?」という声が聞こえてきそうですが、ここはRustの一般的な構文で理解できます。
安心してください。メインの処理を抜き出すとFields::Named部分で以下になります。
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
found_target.push(&field.ident);
}
}
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
match candidate {
Some(ident) => {
ident
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
let names = found_target
.iter()
.map(|x|
x.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
};
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
まだ長くてよくわからない人もいると思います。(僕も他人の書いた長いコードを見るのは苦手なのでわかります)
では分解してみていきましょう。
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
found_target.push(&field.ident);
}
}
...
}
ここではまさにコアな機能ですが、hello_targetの属性を持つフィールドを探しています。
field情報をループして、attrsにhello_targetという属性があるか、ということを確認しています。
fieldは複数の属性を持てるので.any()を使っていますが、この辺はRustの構文なので割愛しましょう。
属性のidentに対して「あなたは"hello_target"という属性のIdentですか?」と全属性に確認して一人でも「そうです!」となればhas_target_attrにtrueが入るわけですね。
そして、found_targetにターゲット候補としてpushしてあげるわけです。
つまり、この部分でやってることは
- 構造体のフィールドをクロールして
hello_targetという属性を見つけたらそのフィールドをfound_targetに保持する
です。
そして残りの部分はまぁ、いってしまうとバリデーションしているわけですね。
今回の要件としてターゲットは必ず1つであるべきです。
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
match candidate {
Some(ident) => {
ident
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
ここではfound_target.len()が1である場合にそのターゲット候補が名前付きであるかどうかをチェックしています。
名前付きでない場合にはここでコンパイルエラーを発生させます。
このコンパイルエラーを書いてあげると、rust-analyzerなどによってコード上にエラーが表示されるため、コーディングの中で問題に気づけます。
例えば
#[derive(Hello)]
struct User {
name: String,
}
とすると、Userに以下のようなエラーが表示されます。
You must specify a field with the
hello_targetattribute on the target
一般にproc-macroでコンパイル時、つまりコーディング時にわかるエラーはコンパイルエラーとして返してあげるのがユーザに優しいと思います。
もちろん実行時にしかわからないエラーは別途エラーにするしかないですが、使い方が誤ってるようなケースではわざわざユーザが実行してエラーに逢うよりもコンパイル時やrust-analyzerなどでエラーが表示される方がはるかに修正コストも低くすみますからね。
そして、found_target.len()が1以外のケースもカバーしておきましょう。
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
let names = found_target
.iter()
.map(|x|
x.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
};
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
ここではメッセージをfound_target.len()が0のケースと2つ以上のケースで分けて書いています。
if文は式?
Rustでは多くの構文が式でできており、if文をはじめとしてほとんどの構文が値を返します。
そのため、今回のようにif文で条件分岐して適切なメッセージのStringを返す、という実装が行えます。
一般にif文が式ではない言語が多いのでこれはRustらしい書き方かもしれません。
意外と便利ですよ![]()
2つ以上の場合には「何と何のフィールドについていて、これがダメだよ」と伝えてあげたほうがどこが間違えているか、というデバッグが高速化できるので、まぁ「コンパイルエラーのメッセージは自分が利用者だったら欲しい内容」であるべきと僕は考えています。
さて2つ以上のケースで変な処理をしていますね。
let names = found_target
.iter()
.map(|x|
x.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
今回は発生し得ないので.unwrapでもいいのですが、わかりやすくするために少々回りくどくIdentを対処しています。
欲しいのは複数のターゲットがあったときに<target1>, <target2>, ...という文字列です。
そのため、文字列に変換して無名フィールドがあった場合には<unnamed>として表示してあげるようにします。
他の場合はto_string()をすることでそのフィールド名の文字列を取得できます。
そして
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
とすると、晴れて
You must specify only 1 field with the `hello_target` attribute but you specified on [name, age]
のようなコンパイルエラーを表示させられるようになりました!
Fields::Named(_)以外のケース、Data::Struct(_)以外のケースについては現状対応していないのでその旨を記載したコンパイルエラーで対処しましょう。
すると、ここまでの全体像は以下のようになります。
use proc_macro::TokenStream;
use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident};
#[proc_macro_derive(Hello, attributes(hello_target))]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// inputのIdentはDeriveマクロでは構造体の名前(Identity)を保持する
let struct_name = &input.ident;
let target_field_ident = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
found_target.push(&field.ident);
}
}
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
match candidate {
Some(ident) => {
ident
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
let names = found_target
.iter()
.map(|x|
x.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
};
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct with named fields";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
};
}
ついにこれで欲しい二つの情報が手に入りましたね!
- 構造体名:
struct_name - 表示するターゲットのフィールド名:
target_field_ident
では最後にトレイト実装のTokenStreamを生成してコンパイラに返してあげましょう。
とはいってもここでも
- quote: 分かりやすいRustライクな構文をTokenStreamというコンパイラが扱う形に変換してくれる
という話があったようにquoteを利用して以下のように記載します。
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(Hello, attributes(hello_target))]
pub fn hello_derive(input: TokenStream) -> TokenStream {
...既存のコード...
// 生成する実装 (Hello traitは利用者側クレートにある想定)
let expanded = quote! {
impl Hello for #struct_name {
fn hello(&self) -> String {
format!("Hello, {}!", self.#target_field_ident)
}
}
};
expanded.into()
}
ここはもう非常にRustライクなのでみたままではあるのですが、quote特有の点として変数の埋め込みは#<変数名>として記載します。
self.#target_field_ident
この書き方は非常に紛らわしくて、最初見た時はよくわからないと思った記憶があるので補足すると、TokenStreamとして出力される時、この部分は
self.<target_field_name>
という形でIdentの情報で埋め込みが行われて出力されるようになっています。
今回のようにシンプルなケースでは「そんなこともよくわからないとか大丈夫かよ」と思われるかも知れませんが、複雑になるとこの埋め込みが何を指しているかわからなくなっていく、という状態が発生してきます。
もしそんな時はこの話に立ち返ってください。
intoの必要性
もしかしたら型が表示されるIDEを使われている方ではlet expanded = ...
のexpandedがTokenStreamと表示されるのでそのまま返せばいいのでは?と思うかも知れません。
しかし実際にはこれは不可能です。
実はquoteの返すTokenStreamはproc_macro::TokenStreamではなく、proc_macro2::TokenStreamです。
proc_macro2::TokenStreamは簡単にproc_macro::TokenStream変換できるとはいえ別の構造体であるため、返り値にするためにはinto()が必要となります。
なぜproc_macro2が利用されるか、という点についてはproc_macroには無い、さまざまな便利な点があるので詳しく知りたい方は調べてみてください。
さぁこれでプロシージャルマクロライブラリが完成しました。改めて全体像を見てみましょう。
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(Hello, attributes(hello_target))]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// inputのIdentはDeriveマクロでは構造体の名前(Identity)を保持する
let struct_name = &input.ident;
let target_field_ident = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
found_target.push(&field.ident);
}
}
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
match candidate {
Some(ident) => {
ident
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
let names = found_target
.iter()
.map(|x|
x.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
};
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct with named fields";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
};
// 生成する実装 (Hello traitは利用者側クレートにある想定)
let expanded = quote! {
impl Hello for #struct_name {
fn hello(&self) -> String {
format!("Hello, {}!", self.#target_field_ident)
}
}
};
expanded.into()
}
利用のための準備
ライブラリはできました。
しかし、Cargo.tomlで毎回
[package]
name = "hello_bin"
description = "A hello bin crate"
version = "0.1.0"
edition = "2024"
[dependencies]
hello = "..."
hello_derive = "..."
と書かせるのは非常に面倒ですし、バージョンが増えた際にderiveライブラリとの互換性がとれたものを探すのも大変ですよね。
そこで、メインのhelloの依存関係としてhello_deriveを一緒にダウンロードさせて利用できるようにしましょう。
(以下はhello/Cargo.tomlに書く)
[package]
name = "hello"
description = "A hello created for the proc-macro practice"
version = { workspace = true }
authors = { workspace = true }
edition = "2024"
[dependencies]
hello_derive = { path = "../hello_derive", optional = true }
[features]
derive = ["hello_derive"]
またhello/src/lib.rsでこのライブラリをre-exportしてあげます。
pub use hello_derive::Hello;
pub trait Hello {
fn hello(&self) -> String;
}
実はこれでエラーになるケースがあります。
それはfeatures = ["derive"]をしていない環境です。本来はderiveするのがメインとはいえ、deriveを利用したくないケースも想定されます。
そのケースでは以下のようなエラーが発生します。
error[E0432]: unresolved import
hello_derive
--> hello/src/lib.rs:1:9
|
1 | pub use hello_derive::Hello;
| ^^^^^^^^^^^^ use of unresolved module or unlinked cratehello_derive
|
= help: if you wanted to use a crate namedhello_derive, usecargo add hello_deriveto add it to yourCargo.toml
For more information about this error, tryrustc --explain E0432.
error: could not compilehello(lib) due to 1 previous error
deriveフィーチャーを利用しない環境ではhello_deriveをインポートしないため、存在しないライブラリを呼び出そうとしてエラーになったわけですね。
そこで、この
pub use hello_derive::Hello;
をderiveフィーチャー有効な時だけに限定して実行されるようにします。
以下のように修正してください。
#[cfg(feature = "derive")]
pub use hello_derive::Hello;
pub trait Hello {
fn hello(&self) -> String;
}
これでderiveフィーチャーがついていない場合には
pub trait Hello {
fn hello(&self) -> String;
}
で、deriveフィーチャーがある場合には
pub use hello_derive::Hello;
pub trait Hello {
fn hello(&self) -> String;
}
としてコンパイルされるようになりました。
デフォルトに含める
Rustを知ってる人ならいや、最初からデフォルトに
hello_deriveをインポートすればいいじゃん
と思った人も多いでしょう。
確かに
[package]
name = "hello"
description = "A hello created for the proc-macro practice"
version = { workspace = true }
authors = { workspace = true }
edition = "2024"
[dependencies]
hello_derive = { path = "../hello_derive" }
としてしまえば、わざわざ条件分岐もいらないですよね。
ただ、これではderiveを利用しないユーザでもhello_deriveのバイナリが含まれてしまいます。
Rustとしては「使わないものにはコストを支払わない」という原則があるため、一般的にはユーザがこの選択を行えるようにderiveフィーチャーとして実装する方が良い、と慣習的にされています。
Helloの衝突
pub use hello_derive::Hello;
pub trait Hello {
fn hello(&self) -> String;
}
と見ると同じHelloという名前が存在しており、一見すると名前が衝突してエラーになるように見えますよね。
実はRustでは「マクロ」と「型(トレイト)」は異なる名前空間で管理されています。
そのため、同名であっても衝突せずに動作を行うことができます。
しかも同じ名前にしておくことで、利用ユーザは
use hello::Hello;
と一回のuse宣言で両方を指すことができるようになるわけです。
よりわかりやすく考えるなら、人間の言語と同じで文脈でどの意味かわかるという形をイメージすれば良いです。
マクロとtraitは構文的に使われる場所が明確に異なります。
そのため、Rustのコンパイラは使われている場所を見て「deriveにあるからマクロのHelloをくれ」と「実装につかっているからTraitのHelloをくれ」と判断に迷うことがないわけです。
使ってみよう
ここまでで作ったプロシージャルマクロを使ってみましょう!
workspacesにバイナリクレートを一つ追加しましょう。名前はhello_binとでもします。
.
├── Cargo.lock
├── Cargo.toml
├── hello
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── src
│ └── target
├── hello_bin
│ ├── Cargo.toml
│ └── src
├── hello_derive
│ ├── Cargo.lock
│ ├── Cargo.toml
│ ├── src
│ └── target
└── target
のようになっていれば良いです。
ではhello_binのCargo.tomlの[dependencies]を以下のようにしましょう。
[dependencies]
hello = { path = "../hello", features = ["derive"] }
そして、main.rsで以下のように書いてみます。
use hello::Hello;
#[derive(Hello)]
struct Member {
#[hello_target]
name: String,
}
fn main() {
let bob = Member {
name: "Bob".to_string(),
};
println!("{}", bob.hello());
}
これで以下のコマンドで実行してみます。
cargo run --bin hello_bin
すると
Hello, Bob!
と表示されたかと思います。
これで最初に目標にしたスマートな実装が行えるようになりました!
これで全て完了...と言いたいところですが、実は今の実装、まだ問題が残っています。
nameをVec<String>に変更した以下のコードにしてみましょう。
#[derive(Hello)]
struct Member {
#[hello_target]
name: Vec<String>,
}
これでおそらく、
Vec<String>doesn't implementstd::fmt::Display[E0277]
Help: the traitstd::fmt::Displayis not implemented forVec<String>
Note: in format strings you may be able to use{:?}(or {:#?} for pretty-print) instead
のようなエラーが発生すると思います。
しかし、無駄な情報を表示したくないですよね。
Note: in format strings you may be able to use
{:?}(or {:#?} for pretty-print) instead
これはhello_deriveの実装上の部分でユーザにはどうしようもありません。
そこであらかじめ、hello_targetをつけるフィールドにはDisplayが実装されていることをトレイト境界で指定しておきましょう。
考慮漏れのデバッグ
このような考慮漏れでユーザに優しくない情報が表示されてしまうことは良くあります。
しかし、常に「ユーザにとって必要な情報」を純度高く与えることが良いと僕は個人的な美学として考えています。
このライブラリでは入力が文字列出力されることからderiveのライブラリの生成されるコートにDisplayのトレイト境界を追加します。
まずはfieldの型を取得します。書いてきたhello_deriveを開いて、以下の修正をします。
-
target_field_identを取得していた部分を(target_field_ident, target_field_ty)に変更する -
found_targetに型情報も合わせてpushする - 返り値を渡す際に型情報も合わせて一緒に返す
// target_field_tyとしてターゲットのフィールドの型を取得する
let (target_field_ident, target_field_ty) = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
// field.tyで型情報を取得できる
found_target.push((&field.ident, &field.ty));
}
}
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
let ty = candidate.1;
match candidate.0 {
Some(ident) => {
(ident, ty)
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
...既存コード...
そして、実装のTokenStreamを生成する部分にトレイト境界を追加します。
...既存コード...
let expanded = quote! {
impl Hello for #struct_name
// トレイト境界を追加
where
#target_field_ty: ::std::fmt::Display,
{
fn hello(&self) -> String {
format!("Hello, {}!", self.#target_field_ident)
}
}
};
...既存コード...
これで修正は完了です。最終的なhello_derive/src/lib.rsの全体コードは以下のようになります。
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, Data, DeriveInput, Fields};
#[proc_macro_derive(Hello, attributes(hello_target))]
pub fn hello_derive(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
// inputのIdentはDeriveマクロでは構造体の名前(Identity)を保持する
let struct_name = &input.ident;
let (target_field_ident, target_field_ty) = match &input.data {
Data::Struct(s) => match &s.fields {
Fields::Named(named) => {
let mut found_target = Vec::with_capacity(named.named.len());
for field in &named.named {
let has_target_attr = field
.attrs
.iter()
.any(|a| a.path().is_ident("hello_target"));
if has_target_attr {
found_target.push((&field.ident, &field.ty));
}
}
if found_target.len() == 1 {
// この時点でfound_targetは必ず長さ1なのでfound_target[0]は必ず存在する
let candidate = found_target[0];
let ty = candidate.1;
match candidate.0 {
Some(ident) => {
(ident, ty)
}
None => {
let msg = "You must specify a field with the `hello_target` attribute on the NAMED field";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
}
else {
let msg = if found_target.is_empty() {
"You must specify a field with the `hello_target` attribute on the target".to_string()
} else {
let names = found_target
.iter()
.map(|x|
x.0.as_ref().map(|i| i.to_string()).unwrap_or("<unnamed>".to_string()))
.collect::<Vec<_>>();
format!(
"You must specify only 1 field with the `hello_target` attribute but you specified on [{}]",
names.join(", ")
)
};
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct with named fields";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
}
_ => {
let msg = "this macro only supports struct";
return syn::Error::new(struct_name.span(), msg).to_compile_error().into();
}
};
// 生成する実装 (Hello traitは利用者側クレートにある想定)
let expanded = quote! {
impl Hello for #struct_name
where
#target_field_ty: ::std::fmt::Display,
{
fn hello(&self) -> String {
format!("Hello, {}!", self.#target_field_ident)
}
}
};
expanded.into()
}
この状態で先ほどのhello_binを見てみましょう。
すると
Vec<String>doesn't implementstd::fmt::Display[E0277]
とだけ表示されていい感じだと思います。
実装のまとめ的なSomething
この実装パートでは実際にHelloというプロシージャルマクロを実装しました。
今回はかなりシンプルなマクロだったのでさほど混乱もなかったかと思いますが、複雑な処理を隠蔽しようとするとTokenStreamとRustを行き来してものすごく頭の中での切り替えが大変になっていきます。
だからこそ、たくさんのパターンを書いて慣れていくのがプロシージャルマクロの最も良い学習な気もしています。
ともいう僕もいまだにめちゃくちゃ間違えるので人のこと言えないんですけどね。
特に所有権システムのIDEの評価がquote内では働かないのでデバッグも困難になりやすいです。
quote外で確認してから入れる、というようないろんなテクニックがあるので使いやすい自分だけのデバッグ方法も見つけてみてください。
全体のまとめ
今日はここまで長くお付き合いありがとうございました!
この記事があなたの役に少しでも立ってくれたら、Rustっておもしれぇ!と思ってもらえたら僕はとても嬉しいです。
最後に、今日話したプロシージャルマクロを利用したテンプレート変換ライブラリのtemplatiaを現在開発しております。(ここ2ヶ月ほどコミットできてないけど、0.0.4を完成させたい。)
このライブラリはserdeなどと異なり、構造化されていない自然言語など非構造だけどパターンは決まってるよね、というケースをRustの構造体にマッピングするパーサーライブラリです。
今日説明したものの他に
- darling
- chumsky
といったライブラリを利用したものです。
他の大手のOSSにコミットするのは怖くても僕はどんなものでもお待ちしておりますので、ぜひ協力していただけたら嬉しいです!
このライブラリはRegexって超強力だけど、管理が大変だよね、という個人的な問題から作ったものです。皆様のContributionをお待ちしております(笑)
それではよきRustライフを〜!