Rustで重複コードを消す手段の比較と使い分け(関数/ジェネリクス/トレイト/マクロ/const fn/build.rs)
TL;DR
- 重複を消す/コンパイル時に何かする手段は Rust に複数ある。役割が違うので「どれが最強」ではなく「どれが今の問題に合うか」で選ぶ
- 基本順序: 関数 → ジェネリクス/トレイト → 宣言マクロ → 手続き型マクロ → build.rs。下に行くほど強力だが、複雑さ・ビルド時間・読みにくさのコストも上がる
- 「値が欲しい」なら
const fn、「コードが欲しい」ならマクロ、と分けると迷わない - 困ったら上から順に試して、足りなくなったら一段降りるのが基本戦略
このドキュメントの射程
「同じようなコードが並ぶ」「コンパイル時に何かしておきたい」という場面で取れる Rust の手段を、メリデメと使い分けの観点で並べて比較する。詳細な書き方は各手段の専用記事に譲り、ここでは「どれを選ぶか」の地図に絞る。
並べる手段の一覧
| 手段 | 何をするもの | 強さ | コスト |
|---|---|---|---|
通常の関数 (fn) | ロジックを切り出して再利用 | 弱 | 最小 |
| ジェネリクス + トレイト境界 | 型をパラメータにして共通化 | 中 | 小 |
| トレイトのデフォルト実装 | trait 側に共通実装、impl 側で差分だけ | 中 | 小 |
const fn | コンパイル時にも呼べる関数。値を生成 | 中 | 小 |
宣言マクロ (macro_rules!) | パターンマッチでコードを生成。コードを生成 | 強 | 中(読みにくさ) |
| 手続き型マクロ (proc macro) | 任意のコードでコードを生成。#[derive] など | 最強 | 大(別クレート・ビルド時間) |
build script (build.rs) | ビルド時に Rust 外でコード/データ生成 | 最強 | 大(ビルドが複雑化) |
各手段のメリット・デメリット
通常の関数 (fn)
- メリット: シンプル、デバッグ容易、IDE 補完が完全に効く、テストしやすい
- デメリット: 「型そのもの」「関数名」「フィールド名」を引数にできない。重複の根本原因が型違いだと吸収できない
- 使う場面: 中身のロジックが共通なとき。まずはこれで考える
ジェネリクス + トレイト境界
- メリット: 型安全。モノモルフィゼーションで実行時オーバーヘッドなし。型違いの共通処理を1つの関数にまとめられる
- デメリット: バイナリサイズ増、コンパイル時間増、トレイト境界が複雑化しがち、フィールド名は依然として固定
- 使う場面: 「型は違うが API(メソッド・トレイト)が同じ」処理を共通化したいとき
fn process(item: T) { /* ... */ }
トレイトのデフォルト実装
- メリット: 共通実装を trait 側に書き、impl 側は差分だけ。継承的に書ける割に Rust の所有権モデルとも相性が良い
- デメリット: trait を変更すると全 impl に波及。複数 trait の合成が複雑になりやすい
- 使う場面: 「振る舞いの基本形があり、一部だけオーバーライドさせたい」とき
trait Greeter {
fn name(&self) -> &str;
fn greet(&self) { // デフォルト実装
println!("Hello, {}!", self.name());
}
}
const fn
- メリット: コンパイル時にも呼べる関数。後方互換に追加可能(既存の呼び出しは壊れない)。型安全でデバッグも普通の関数と同じ
- デメリット: 書ける処理に制限あり(トレイトメソッド呼び出し、ヒープ確保、
?などが不可)。バージョンで制約が変わる - 使う場面: 純粋計算をコンパイル時に済ませたい、配列長や
const値の初期化に使いたい、ライブラリのコンストラクタ - 「値が欲しい」ときの第一候補
const fn square(x: i32) -> i32 { x * x }
const TABLE: [i32; 4] = [square(0), square(1), square(2), square(3)];
宣言マクロ (macro_rules!)
- メリット: 型・識別子・可変長引数を受け取れる。関数定義や impl ブロックそのものを生成できる。同じファイルに書けて proc macro より軽量
- デメリット: IDE の go-to-definition が効きにくい、エラーが分かりにくい、複雑なパターンは書きづらい、マクロ衛生(hygiene)の理解が要る
- 使う場面: 「似た形の関数・impl を量産したい」「型やフィールド名まで引数にしたい」とき
- 「コードが欲しい」ときの第一候補
macro_rules! impl_getter {
($name:ident, $field:ident, $ty:ty) => {
pub fn $name(&self) -> $ty { self.$field }
};
}
手続き型マクロ (proc macro)
- メリット: 構造体や enum を AST レベルで解析できる。
#[derive(Serialize)]のような強力な自動実装が可能。DSL も作れる - デメリット: 別クレート必須、
syn/quoteへの依存、ビルド時間が大幅に増える、書く・デバッグするのが難しい - 使う場面:
#[derive(MyTrait)]のような自動実装、フィールド属性を読んで処理を変える、構造体定義から派生コードを生成する、宣言マクロでは届かないとき
build script (build.rs)
- メリット: 任意の Rust コードや外部ファイルからコードを生成できる。コンパイル時に環境を見て分岐可能。マクロや
const fnでは届かない領域に手が出る - デメリット: ビルドが複雑化、生成ファイルの管理が必要、IDE 連携が落ちる、CI で問題が起きやすい
- 使う場面: 大量の定数テーブル生成、FFI バインディング生成、ビルド時の環境情報埋め込み、外部スキーマ(OpenAPI など)からコード生成
使い分けの判断基準
迷ったら上から順に検討する。下に行くほど強力だが、コストも上がる。
- 関数で済むか? → 済むなら関数
- 型違いだけが問題か? → ジェネリクス + トレイト境界
- 共通の振る舞いがあって差分だけ書きたい? → トレイトのデフォルト実装
- コンパイル時に値が欲しい? →
const fn - 関数定義・impl ブロックの形まで生成したい? → 宣言マクロ
- 構造体定義を解析して自動実装したい? → 手続き型マクロ
- 外部データやファイルからコードを生成したい? → build.rs
逆向きの判断軸(生成したいものは何か)で整理すると:
| 生成したいもの | 第一候補 |
|---|---|
| 計算した値 | const fn |
| 似た形の関数定義/impl | 宣言マクロ |
| トレイト実装を構造体から自動派生 | 手続き型マクロ (#[derive]) |
| 外部スキーマ/大量データからの型・定数 | build.rs |
| 共通の振る舞い | トレイトのデフォルト実装 |
| 型違いだけの共通処理 | ジェネリクス |
よくある混同
「マクロと const fn どっちを使えばいい?」
- 値が欲しい →
const fn: 計算結果をconstやstaticに入れたい、配列長に使いたい - コードが欲しい → マクロ: 関数や impl ブロックそのものを量産したい
- 両方使うことも多い: マクロで
const fnを生成する、など
「ジェネリクスとマクロどっちを使えばいい?」
- 共通化したいのが型だけならジェネリクス(型安全、IDE フレンドリー)
- 型 + 関数名 + フィールド名まで違うならマクロしかない
- 関数定義自体の重複を消したいならマクロ
「宣言マクロと手続き型マクロどっちを使えばいい?」
- パターンマッチで足りる → 宣言マクロ
- 構造体のフィールドを読みたい、属性を解析したい → 手続き型マクロ
- 1プロジェクト内の補助 → 宣言マクロ、ライブラリとして公開 → どちらも有力
まとめ
「重複を消す/コンパイル時に何かする」手段は複数あり、それぞれ役割が違う。関数 → ジェネリクス → const fn → 宣言マクロ → 手続き型マクロ → build.rs の順に検討し、足りなくなったら一段降りる。「値が欲しいか、コードが欲しいか」で大きく分かれることだけ覚えておくと迷いにくい。