Skip to main content

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 など)からコード生成

使い分けの判断基準​

迷ったら上から順に検討する。下に行くほど強力だが、コストも上がる。

  1. 関数で済むか? → 済むなら関数
  2. 型違いだけが問題か? → ジェネリクス + トレイト境界
  3. 共通の振る舞いがあって差分だけ書きたい? → トレイトのデフォルト実装
  4. コンパイル時に値が欲しい? → const fn
  5. 関数定義・impl ブロックの形まで生成したい? → 宣言マクロ
  6. 構造体定義を解析して自動実装したい? → 手続き型マクロ
  7. 外部データやファイルからコードを生成したい? → build.rs

逆向きの判断軸(生成したいものは何か)で整理すると:

生成したいもの第一候補
計算した値const fn
似た形の関数定義/impl宣言マクロ
トレイト実装を構造体から自動派生手続き型マクロ (#[derive])
外部スキーマ/大量データからの型・定数build.rs
共通の振る舞いトレイトのデフォルト実装
型違いだけの共通処理ジェネリクス

よくある混同​

「マクロと const fn どっちを使えばいい?」​

  • 値が欲しい → const fn: 計算結果を const や static に入れたい、配列長に使いたい
  • コードが欲しい → マクロ: 関数や impl ブロックそのものを量産したい
  • 両方使うことも多い: マクロで const fn を生成する、など

「ジェネリクスとマクロどっちを使えばいい?」​

  • 共通化したいのが型だけならジェネリクス(型安全、IDE フレンドリー)
  • 型 + 関数名 + フィールド名まで違うならマクロしかない
  • 関数定義自体の重複を消したいならマクロ

「宣言マクロと手続き型マクロどっちを使えばいい?」​

  • パターンマッチで足りる → 宣言マクロ
  • 構造体のフィールドを読みたい、属性を解析したい → 手続き型マクロ
  • 1プロジェクト内の補助 → 宣言マクロ、ライブラリとして公開 → どちらも有力

まとめ​

「重複を消す/コンパイル時に何かする」手段は複数あり、それぞれ役割が違う。関数 → ジェネリクス → const fn → 宣言マクロ → 手続き型マクロ → build.rs の順に検討し、足りなくなったら一段降りる。「値が欲しいか、コードが欲しいか」で大きく分かれることだけ覚えておくと迷いにくい。

参考​