宣言的マクロと手続き的マクロ――Rustのマクロ機能を使ってみる:基本からしっかり学ぶRust入門(18)
メタプログラミングの手法に、C/C++言語で普通に使われているマクロ機能があります。最終回である今回は、それらより安全な実装となっているRustのマクロ機能について。
マクロとは?
マクロとは、ソースコード中の特定の文字列を別の文字列に置き換える機能です。プログラムの一部から別のプログラムを生成することから、メタプログラミングの手法の一つと言われています。C/C++言語などではおなじみの機能で、定数または関数のように使えるマクロを定義するために利用できます。
C/C++言語のマクロの抱える問題
C/C++言語のマクロは、特に関数の代わりに使われるときに期待しない動作となり、しばしばバグの原因となったり、マクロの危険性の理由となったりしてきました。例えば「#define ADD2(a, b) a + b」のように定義されたマクロADD2を、「int x = ADD2(2, 3) * 4;」のように使用すると、計算結果は「14」と期待外になってしまいます(期待される結果は20です)。
これは、マクロが単なる文字列として置き換えられるためで、C言語のプリプロセッサ(ソースコードをテキストレベルで前処理するプログラム)は変数xの宣言文を「int x = 2 + 3 * 4;」のように置き換えます。マクロの呼び出し結果がそのまま代入されていれば問題ないのですが、加算より優先順位の高い乗算が式に含まれていたため、そちらの演算が優先されて結果が期待外になったわけです。
これを解決するには、マクロの定義を「#define ADD2(a, b) (a + b)」のように変更します。マクロ全体がカッコで囲まれるので、常に優先して演算が実行されるようになり、期待される結果を得られます。しかし、カッコで囲むかどうかはプログラマーに任せられることになるので、ルールとして強制しても漏れが発生する可能性があります。
Rustでは、このような問題が起きないように、言語仕様で安全なマクロの記述を可能にしています。例えば、マクロで展開された部分で変数を宣言しても、名前が衝突したり、外部から見えたりといったことは発生しません。これは衛生的マクロ(Hygienic Macro)と呼ばれ、プログラマーの配慮に依存しない安全なマクロの利用が可能です。
Rustのマクロは大きく分けて2種類
本連載では、ほぼ毎回、Rust標準のマクロを使用してきました。それがprintln!マクロです。このマクロは、引数に指定する書式に従って値を標準出力に書き出す、というものでした。関数で実装してもよい(C言語のprintf関数などは関数で実装されている)ように思えますが、なぜマクロなのでしょうか?
マクロにする理由の一つは、引数の数を可変にするためです。C言語などと異なり、Rustでは引数の数が可変である関数を定義できません。そのため、マクロを使って、このような関数を疑似的に定義するのです。また、構造体や関数の定義のブロックを受け取り、それに基づき別のコードを展開する機能もRustのマクロにはあります。
Rustではマクロ定義の方法が大きく分けて2つ用意されています。それが宣言的マクロと手続き的マクロです。以降、2つのマクロについて紹介していきます。
宣言的マクロ
まずは、宣言的マクロ(Declarative Macro)です。宣言的マクロは、Rustの初期段階の実装から使うことができた基本的なマクロで、macro_rules!構文で定義できます(これ自体もマクロです)。宣言的マクロでは、match式に似た形式で処理内容を定義します。宣言的マクロは、利用者が参照できる場所であれば、どこでも定義可能です。
実装するマクロの内容
以下は、引数の値を順番に表示するマクロprintargsの定義例です。ここからのサンプルは、macrosパッケージに作成していきます。
macro_rules! printargs { (1) () => { (2) print!("No argument.\n"); (3) }; ($($arg:expr),*) => { (4) { $(println!("Argument: {}", $arg);)* (5) } }; } fn main() { printargs!("One", "Two", "Three"); (6) printargs!["Four", "Five"]; printargs!{}; }
実行結果は、下記です。
Argument: One 1回目のprintargs! Argument: Two Argument: Three Argument: Four 2回目のprintargs! Argument: Five No argument. 3回目のprintargs!
(1)ではマクロの定義を開始しています。macro_rules!マクロを使うことは触れましたが、マクロの名称をそれに続け、マクロの内容をブロック内({〜})に記述します。このとき、(2)(4)のように「引数のパターン => 処理内容」の形式でマクロの引数のパターン別に処理内容(マクロの展開内容)を記述していきます。この例では、パターンを2個記述していますが、その意味はのちほど触れます。
(5)からの3行は、マクロの呼び出しです。マクロの呼び出しには、マクロの名前に感嘆符「!」を続けるのはprintln!マクロの紹介で触れた通りですが、引数を囲むかっこには、パーレンの他にブラケット([ ]、大かっこ)、ブレース({ }、中かっこ)も使えます。どれを使っても動作に変わりはなく、関数的に使うときにはパーレン、vec!のように配列を渡すときはブラケット、詳細は省きますがtry!のようにブロックを渡すときはブレース、というように意味的に使い分けます。
パターンの指定方法
パターンは以下の形式で記述します。
($name:fragment-specifier)
$nameは、パターンで一致した部分を参照する名前であり、Rustの変数名と同様のルールで指定できます。fragment-specifierは、フラグメント(ソースコードの一部を表す指定)のいずれかとその引数です。src/bin/macro_args.rsの(2)のようにパターンを省略する場合は空となります。主なフラグメントを以下の表1に挙げておきます(他のフラグメントについてはhttps://doc.rust-lang.org/reference/macros-by-example.htmlを参照)。
種類 | マッチするもの |
---|---|
expr | 式(expression) |
block | ブロック({〜}で囲まれているもの) |
ident | 識別子(identifier)。予約語でもマッチする |
item | 項目(item)。関数、構造体、列挙子、定数、モジュール、トレイト、use宣言など |
literal | リテラル |
ty | 型(type)。未定義でも文法上許されるものであればよい(予約語は不許可) |
表1 主なフラグメント |
以上の理解に基づいて、src/bin/macro_args.rsの(4)に注目してみましょう。これが、フラグメントを使ったパターンの指定です。「$」で始まっていることに注意してください。$argは変数名、exprは式を表すフラグメントです。式に相当するものが引数にあれば、それは$argに入ります。また、カンマ「,」に続けてアスタリスク(*)が記述されていますが、これは正規表現の「*」と同じように、直前の項目の0回以上の繰り返しを意味します。同様に、「+」は1回以上の繰り返し、「?」は0回または1回の繰り返しとなります。この場合は、「カンマが続くかもしれない式」となります。
処理のコードを記述する
src/bin/macro_args.rsの(3)では引数が空のとき、print!マクロをさらに呼び出して「No argument.」を出力します。(5)では、「$(…)*」によってマッチする限り「println!("{}", $arg);」を展開します。このとき、println!マクロにはパターン中の変数$argがそのまま渡されていて、マッチした内容に置き換えられることに注目です。(6)の1行目であれば、3個のprintln!の引数がそれぞれ"One"、"Two"、"Three"となります。
宣言的マクロは、想定される引数のパターンに応じて処理内容をそれぞれ記述していくので、直感的で分かりやすいといえます。しかし、パターンが複雑になりがちですし、macro_rules!内部の独特な構文の拘束もあるので、複雑な処理は記述しにくいといった問題があります。
【補足】宣言的マクロはdeprecate予定
Rustのドキュメントによれば、宣言的マクロはdeprecate(廃止)予定となっています。代わりに後述する手続き的マクロの使用が推奨されていますが、最新のRustでも依然として使えること、今回は2つのマクロ定義について比較するためにあえて取り上げています。
手続き的マクロ
手続き的マクロ(Procedural Macro)は、宣言的マクロよりもコードが複雑になりがちですが、その分、自由度は高くなります。手続き的マクロは、さらに表2に挙げる3種類に分けられます。目的や適用場所は異なりますが、ほぼ共通のルールで記述できます。
マクロ | 概要 |
---|---|
関数マクロ | 宣言的マクロのように関数呼び出しと似た形で使用できるマクロ |
カスタム派生マクロ(derive) | 構造体や列挙体の定義を拡張でき、主にこれらへのトレイトの実装に利用 |
カスタム属性マクロ(attribute) | 構造体や列挙体に加えて関数などの定義の拡張が可能 |
表2 手続き的マクロの種類 |
その性質上、宣言的マクロを置き換えるとすれば、まずはこの関数マクロが対象となるでしょう。以降、関数マクロを例に、手続き的マクロを実装してみます。
手続き的マクロの実装の準備
手続き的マクロを記述する際には、以下の前提となる決まりごとがあります。これらは、宣言的マクロにはなかったことです。
- 手続き的マクロは専用のライブラリクレートに記述する
- そのクレートには手続き的マクロが書かれていることをパッケージで明示する
マクロを専用のクレートに記述する意味は、プログラム実行のためのクレートとは別にする必要があるということです。つまり、手続き的マクロを実装するには、ワークスペースを導入して、複数のパッケージをまとめる必要があるのです。ワークスペースについては連載第13回で紹介しているので、具体的な手順はそちらを参照していただくことにして、ここでは実行すべき手順を示すのみにします。本連載のためのatmarkit_rustフォルダを起点に操作してください。
ワークスペースのためのフォルダproc_macrosフォルダを作成します。そしてproc_macrosフォルダにCargo.tomlファイルを以下の内容で作成します。
[workspace] members = [ "pmacro", "libs" ]
membersエントリには2つのクレートがあります。それぞれ、これから作成する実行バイナリとライブラリのクレートです。続けて、proc_macrosフォルダ下で、実行バイナリクレートpmacroとライブラリクレートlibsを作成します。作成時に「warning: compiling this new package may not work due to invalid workspace configuration」のような警告が出ますが、無視しても大丈夫です。作成後に念のため、proc_macrosフォルダにpmacroフォルダとlibsフォルダがあることを確認しておきましょう。
% cargo new --bin pmacro % cargo new --lib libs
実行バイナリクレートにライブラリクレートへの依存を設定します。実行バイナリクレートのCargo.tomlファイルに、ライブラリクレートへの依存があることを設定します。これで、実行バイナリクレートpmacroからlibsへの参照が自動化されます。
…略… [dependencies] libs = { path = "../libs" }
ライブラリクレートにマクロが記述されていることを明示します。ライブラリクレートのCargo.tomlファイルに、以下のセクションおよびエントリを追加します。これで、このライブラリクレートは手続き的マクロのためのものであるとマークされます。
…略… [lib] proc-macro = true
最後に、ライブラリクレートのソースファイルlibs.rsの中身を全て削除します(マクロに関係のない、サンプルコードが含まれているためです)。
これで、基本的な実装は終了です。ここまでの作業と設定に問題がないか、ビルド、実行してみます。実行バイナリの既定である「Hello, world!」が表示されればOKです。
実装するマクロの内容
ここから、作成したライブラリクレートにマクロを実装していきます。宣言的マクロでは、macro_rules!マクロを使って処理内容を記述していきますが、マクロ内でしか使えない構文もあって、それが理解の妨げとなることもありました。手続き的マクロでは、基本的にRustの関数としてマクロを記述します。もちろん、マクロ記述のためのルールはありますが、Rustの文法の範囲内なので、すでに関数を書いたことがあればマクロも問題なく実装できるはずです。
実装するマクロは、引数のべき乗を計算する式を返すというものです。このような展開になります。
my_power(1 + 2) ⇒ (1 + 2) * (1 + 2)
このマクロを、ライブラリクレートに記述していきますが、関数の動作を理解しやすくするために段階を踏んで実装していくことにします。
マクロの枠組みを記述する
まずは、以下のようにマクロの枠組みを記述します。
use proc_macro::TokenStream; (1) #[proc_macro] (2) pub fn my_power(_item: TokenStream) -> TokenStream { (3) let item = _item.clone(); (4) dbg!(&item); item (5) }
ここには、手続き的マクロの基本形が詰まっています。(1)は、手続き的マクロの引数と戻り値の型であるTokenStreamを使うための宣言です。そして(2)ではproc_macro注釈により、続く関数定義がマクロの定義であることを示しています。ちなみに、カスタム属性マクロではproc_macro_attribute、カスタム派生マクロではproc_macro_deriveが注釈となります。
(3)では、TokenStream型を引数と戻り値に持つ関数my_powerを定義しています。TokenStreamとは、その名の通りトークン(字句)のストリーム(流れ、列)のことで、Rustのコードを字句解析した結果を表す型です。入力の字句解析結果をもとに何かの処理を実行し、出力を字句解析結果として返すというわけです。上記のmy_powerの展開例ですと、「1 + 2」の解析結果が入力であり、「(1 + 2) * (1 + 2)」が出力となります。入力のTokenStreamに対し、どのようなTokenStreamを返すかは完全に関数の実装に任されているので、自由度の高いマクロを記述できるというわけです。
(4)からの2行で、引数の内容をデバッグ表示し、最後に(5)で引数(正確には複製)をそのまま返しています。つまり、このマクロは引数をそのまま展開するだけです。
さて、上記はライブラリクレートなので単独では実行できません。そのために、実行バイナリの方にマクロ呼び出しのコードを記述しておきます。
fn main() { println!("Power: {}", libs::my_power!(1 + 3)); }
ここでビルド、実行してみると、以下のように長めのデバッグ出力が現れます。
% cargo run …略… [libs/src/lib.rs:8] &item = TokenStream [ Literal { …略… }, Punct { …略… }, Literal { …略… }, ] …略…
この出力を見ると、TokenStreamは構文を構成する要素(Literal=リテラル、Punct=記号)の配列であることが分かります。なお、これは実行によってではなく、ビルド(コンパイル)によって出力されていることに注意してください。
引数を解析するコードを記述する
ここで作ろうとしているマクロは引数のべき乗の式を返すものなので、受け取ったTokenStreamを解析し、べき乗の式に再構築する必要があります。このとき使うのが、synクレートとquoteクレートです。それぞれ、解析と構築に対応します。これらのクレートを使うために、ライブラリクレートのCargo.tomlファイルに依存関係を追加しておきます。なお、synクレートのオプションにfeaturesを指定していますが、これは後ほど利用するデバッグ表示のサポートのためです。
…略… [dependencies] syn = { version = "1.0.103", features = ["full", "extra-traits"] } quote = "1.0.21" …略…
そして、ライブラリクレートも以下のように修正します。
…略… use syn::{parse_macro_input, Expr}; (1) …略… pub fn my_power(_item: TokenStream) -> TokenStream { let item = _item.clone(); let ast = parse_macro_input!(_item as Expr); (2) dbg!(&ast); (3) item }
先ほどと異なるのは、synクレートを参照するための(1)のuse文が追加されたこと、(2)の文が追加されたこと、(3)のデバッグ表示の引数が(2)の実行結果になっている点です。特に(2)は、手続き的マクロでは定型的な文で、TokenStream型の引数を指定される形式で解析して、抽象構文木(AST)の形で取得するものです。ASTに変換することで、個別の要素を取り出したりすることが容易になります。解析はsynクレートのparse_macro_input!マクロに構造体あるいは列挙体(この場合は式を表すExpr列挙体)を渡します。なお、ここで使われているasはキャストの演算子ではなく、parse_macro_input!マクロの構文の一部であることに注意してください。
【補足】抽象構文木(AST)
抽象構文木(Abstract Syntax Tree)とは、プログラミング言語などの構文を木構造で表現したものです。木構造であるので階層関係を分かりやすく表現でき、演算も木構造の末端から処理すればよいといったプログラミング上のメリットがあります。
実行してみます。新たに2つのクレートを依存関係に追加したので、そのダウンロードとビルドが実行されるため、多少の時間がかかります。
% cargo run …略… [libs/src/lib.rs:15] &ast = Binary( ExprBinary { attrs: [], left: Lit( …略… ), op: Add( Add, ), right: Lit( …略… ), }, ) …略…
長いので途中は省略しますが、ExprBinaryという二項演算の中に左辺(left)、演算子(op)、右辺(right)が含まれているのが分かります。これは、ASTにおけるExprBinaryというノードの構成となっています。演算の内容を変えたり、左辺と右辺を入れ替えるとかといった操作のためにASTを利用できます。
べき乗の式を返すコードを記述する
最後に、べき乗の式を返すためにライブラリクレートを以下のように修正します。
…略… use quote::quote; (1) …略… pub fn my_power(_item: TokenStream) -> TokenStream { let ast = parse_macro_input!(_item as Expr); let res = quote! { (2) (#ast) * (#ast) (3) }; res.into() (4) }
(1)はquoteクレートのマクロを使うためのuse文です。そして(2)がquoteクレートのquote!マクロの呼び出しです。quote!は、Rustの構文を渡すだけでASTを構築してくれるという便利なマクロです。(3)で、作成済みのASTを使ったべき乗の式を記述するだけで、それに基づくASTを構築できます。なお変数astの前のハッシュ(#)はquote!マクロ内でのみ使えるAST展開のための記号です。(4)ではintoメソッドの結果を関数の戻り値としています。このメソッドは、ASTからTokenStream型の値を取得するためのもので、parse_macro_input!マクロと逆の位置付けといえます。
これを実行すると、期待する「Power: 16」が得られることが分かります。このとき、演算しているのはマクロ内部ではなく、あくまでもマクロの展開結果です。
【補足】マクロのライブラリクレートには実装を記述しない
手続き的マクロとマークされているライブラリクレートにマクロの処理内容(実装)を記述してきましたが、本来は実装は別のクレートに切り離すべきです。マクロ専用のクレートにはマクロ以外を記述できないので、例えばテストコードを記述できないからです。
まとめ
最終回である今回は、Rustのマクロ機能を紹介しました。シンプルで分かりやすい宣言的マクロと、実装は複雑だが制約が少なく構造体や関数の拡張もしやすい手続き的マクロの違いについて、理解していただけたのではないかと思います。
今回をもって、本連載は終了となります。Rustの言語仕様は膨大で、限られたスペースの中では、その全てを語り尽くすことはとてもできませんが、そのエッセンスだけでもお伝えできたならば幸いです。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.