Day 012 — トレイト境界とwhere句 — 静的ディスパッチに条件を課す

2026-08-09 🔵 中級者 / Phase 2 実装 トレイト境界とwhere句

📚 背景知識(読んでから問題へ)

ジェネリクス(fn foo<T>(x: T))は「どんな型でも受け付ける」汎用性を与えてくれますが、それだけではTに対してできることがほぼ何もありません(比較も、表示も、クローンもできません)。そこでトレイト境界(trait bound)を使い、「Tはこのトレイトを実装している型に限る」という制約をコンパイラに伝えます。

// インライン境界(+ で複数トレイトを連結)
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { /* ... */ }

境界が1〜2個・型パラメータが1つならインライン記法で十分読めますが、型パラメータが増えたりクロージャ型(Fn/FnMut/FnOnce)が絡んだりすると、シグネチャの<>内が横に伸びきって可読性が落ちます。

// インライン記法だと関数名がどこにあるか探すのに苦労する例
fn process<T: Clone + std::fmt::Debug + PartialOrd, U: Into<String> + Clone>(t: T, u: U) -> T { /* ... */ }

where句はこの制約を関数シグネチャの外(戻り値の後ろ)に切り出す記法です。意味は完全に同じですが、「関数の入出力の形」と「型への要求」を視覚的に分離できます。

fn process<T, U>(t: T, u: U) -> T
where
    T: Clone + std::fmt::Debug + PartialOrd,
    U: Into<String> + Clone,
{
    /* ... */
}
📌
where句はimplブロックにも使えます。Tが特定のトレイトを実装している場合のみ、このメソッドを提供する」という条件付き実装(conditional implementation)は、Rustの型システムがコンパイル時に「この型にはこのメソッドが存在するか」を静的に確定させる仕組みの核心部分です。

📝 問題

以下の2つの要件を満たすコードを実装してください。

要件1: largest_by_key 関数

シグネチャは次の形にすること(where句を必ず使うこと)。

fn largest_by_key<T, K, F>(items: &[T], key_fn: F) -> Option<&T>
where
    F: Fn(&T) -> K,
    K: PartialOrd,
{
    // ここを実装する
}
  • itemsTのスライス)から、key_fnが返すKの値が最大になる要素への参照を返す
  • itemsが空ならNoneを返す
  • 型パラメータが3つ(T, K, F)ある状態で、それぞれに異なる境界(FFnトレイト、KPartialOrd)が付く。これがインライン記法だとどれだけ読みにくくなるかを体感するのも目的の一つ

要件2: Pair<T> と条件付き実装

struct Pair<T> {
    first: T,
    second: T,
}

impl<T> Pair<T> {
    fn new(first: T, second: T) -> Self {
        // 無条件で実装可能(T に境界不要)
    }
}

// ここに where 句を使った条件付き impl ブロックを書く
// T: std::fmt::Display + PartialOrd の場合のみ cmp_display を提供する
  • Pair::newTに何の制約もなく、どんな型でも呼べる状態にすること
  • cmp_display(&self)メソッドは「TDisplayPartialOrdの両方を実装している場合のみ」呼び出せるようにする(impl<T> Pair<T> where T: ... { ... }の形)
  • cmp_displayfirstsecondを比較し、大きい方を"The larger member is {値}"という形式で標準出力する

検証課題

main関数と#[cfg(test)]のテストで、以下をすべて検証してください。

  1. largest_by_keyに整数のスライスを渡し、key_fnとして恒等関数(|x| *x)を使った場合に最大値の参照が返る
  2. largest_by_keyに文字列のスライスを渡し、key_fnとして|s: &String| s.len()(長さで比較)を使った場合に、最も長い文字列への参照が返る
  3. largest_by_keyに空スライスを渡すとNoneが返る
  4. Pair::new(5, 10)のようなi32のペアでcmp_display()を呼び、正しい出力が出る
  5. (発展・任意)Pair::newだけはDisplayを実装していない型(例: 独自のstruct NoDisplay;)でも呼び出せることをコメントで示す

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • largest_by_keyIterator::max_by_keyと似た発想です。ループでitemsを1つずつ見て、これまでの最大値とkey_fnで得たKPartialOrd>演算子で比較していく方針で組み立てられます
  • Pair<T>は「impl<T> Pair<T> { ... }(無条件ブロック)」と「impl<T> Pair<T> where T: Display + PartialOrd { ... }(条件付きブロック)」を2つ書くことになります。1つのimplブロックにまとめようとするとnewにも境界がついてしまうので注意してください
ヒント2 — アプローチ
  • PartialOrd==ではなく</>が使える境界です。K: PartialOrdがあればif key_fn(item) > best_key { ... }のような比較が書けます
  • ループの中で「現在の最大値の参照」と「現在の最大キー」の両方を持ち回すか、最初の要素で初期化してから2番目以降を回すか、どちらの設計でも構いません
  • cmp_displayの中ではif self.first >= self.second { println!(...) } else { println!(...) }のような形になります。{}で表示するためにDisplay境界が必要です
ヒント3 — コード骨格
fn largest_by_key<T, K, F>(items: &[T], key_fn: F) -> Option<&T>
where
    F: Fn(&T) -> K,
    K: PartialOrd,
{
    let mut iter = items.iter();
    let first = iter.next()?;
    let mut best = first;
    let mut best_key = key_fn(first);
    for item in iter {
        let k = key_fn(item);
        if k > best_key {
            best = item;
            best_key = k;
        }
    }
    Some(best)
}

struct Pair<T> {
    first: T,
    second: T,
}

impl<T> Pair<T> {
    fn new(first: T, second: T) -> Self {
        Pair { first, second }
    }
}

impl<T> Pair<T>
where
    T: std::fmt::Display + PartialOrd,
{
    fn cmp_display(&self) {
        // ここに比較と println! を書く
    }
}

模範解答

use std::fmt::Display;

fn largest_by_key<T, K, F>(items: &[T], key_fn: F) -> Option<&T>
where
    F: Fn(&T) -> K,
    K: PartialOrd,
{
    let mut iter = items.iter();
    let first = iter.next()?;
    let mut best = first;
    let mut best_key = key_fn(first);

    for item in iter {
        let k = key_fn(item);
        if k > best_key {
            best = item;
            best_key = k;
        }
    }
    Some(best)
}

struct Pair<T> {
    first: T,
    second: T,
}

// 無条件ブロック: T に境界を課さないので、Display も PartialOrd も実装していない型でも new は呼べる
impl<T> Pair<T> {
    fn new(first: T, second: T) -> Self {
        Pair { first, second }
    }
}

// 条件付きブロック: T が Display + PartialOrd を実装している場合のみ cmp_display が「存在する」
impl<T> Pair<T>
where
    T: Display + PartialOrd,
{
    fn cmp_display(&self) {
        if self.first >= self.second {
            println!("The larger member is {}", self.first);
        } else {
            println!("The larger member is {}", self.second);
        }
    }
}

// Display も PartialOrd も実装していない型(発展課題の検証用)
struct NoDisplay;

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    let max = largest_by_key(&numbers, |x| *x);
    println!("最大値: {:?}", max); // Some(100)

    let words = vec![
        String::from("rust"),
        String::from("ownership"),
        String::from("lifetime"),
    ];
    let longest = largest_by_key(&words, |s: &String| s.len());
    println!("最長の単語: {:?}", longest); // Some("ownership")

    let pair = Pair::new(5, 10);
    pair.cmp_display(); // "The larger member is 10"

    // Pair::new は境界なしなので NoDisplay でも呼べる(=コンパイルが通る)。
    // ただし cmp_display() を呼ぼうとすると、NoDisplay が Display/PartialOrd を
    // 実装していないためコンパイルエラーになる(下記「コンパイルエラーが出た場合」参照)。
    let _no_display_pair = Pair::new(NoDisplay, NoDisplay);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn largest_by_key_returns_max_with_identity_key() {
        let numbers = vec![34, 50, 25, 100, 65];
        assert_eq!(largest_by_key(&numbers, |x| *x), Some(&100));
    }

    #[test]
    fn largest_by_key_returns_longest_string() {
        let words = vec![
            String::from("rust"),
            String::from("ownership"),
            String::from("lifetime"),
        ];
        let result = largest_by_key(&words, |s: &String| s.len());
        assert_eq!(result, Some(&String::from("ownership")));
    }

    #[test]
    fn largest_by_key_returns_none_for_empty_slice() {
        let empty: Vec<i32> = vec![];
        assert_eq!(largest_by_key(&empty, |x| *x), None);
    }

    #[test]
    fn pair_new_works_without_any_bound() {
        let _pair = Pair::new(NoDisplay, NoDisplay);
        // Display/PartialOrd が無くても構築できることの確認(コンパイルが通れば成功)
    }
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
最大値: Some(100)
最長の単語: Some("ownership")
The larger member is 10

$ cargo test
running 4 tests
test tests::largest_by_key_returns_max_with_identity_key ... ok
test tests::largest_by_key_returns_longest_string ... ok
test tests::largest_by_key_returns_none_for_empty_slice ... ok
test tests::pair_new_works_without_any_bound ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

🪜 Step-by-Step 解説

1
largest_by_keyのシグネチャをwhere句で組み立てる
型パラメータは3つ(T: 要素の型、K: 比較に使うキーの型、F: TからKを取り出すクロージャの型)です。インライン記法で書くとfn largest_by_key<T, K, F: Fn(&T) -> K, K: PartialOrd>(...)のようになり、Kの境界とFの境界が離れてしまい、依存関係(FKを生成する)が読み取りにくくなります。where句なら宣言順に整理して書けます。
fn largest_by_key<T, K, F>(items: &[T], key_fn: F) -> Option<&T>
where
    F: Fn(&T) -> K,
    K: PartialOrd,
2
最大値探索のロジック
items.iter()から先頭要素を取り出し、それを暫定の最大値・最大キーとして初期化します。空スライスならiter.next()Noneを返すので、?演算子で早期にNoneを返せます。残りの要素を1つずつ見て、キーをkey_fnで計算しPartialOrd>で比較、上回れば更新します。
3
Pair<T>を2つのimplブロックに分割する
Tに境界なしで呼べるメソッド」と「Tに境界がある場合のみ呼べるメソッド」を同じimplブロックに書くことはできません(書くと、境界を満たさない型でnewを呼ぶだけでもコンパイルエラーになってしまいます)。そのため、
impl<T> Pair<T> { fn new(...) -> Self { ... } }          // 境界なし
impl<T> Pair<T> where T: Display + PartialOrd { ... }     // 境界あり
の2ブロックに分離します。コンパイラはPair<T>という同じ型に対する複数のimplブロックを型ごとにマージして扱うため、これは正しいRustのイディオムです。
4
条件付きメソッドの呼び出し可否を確認する
NoDisplayのようなDisplayPartialOrdも実装しない型でもPair::new(NoDisplay, NoDisplay)はコンパイルが通ります(newには境界がないため)。しかし.cmp_display()を呼ぼうとすると、コンパイラは「NoDisplayに対するimpl<T> Pair<T> where T: Display + PartialOrdは条件を満たさないため、cmp_displayは存在しない」と判断し、メソッド解決に失敗します。

💡 設計思想・なぜこう書くのか

📌
「この型に何ができるか」をコンパイル時に確定させる: トレイト境界とwhere句は、Rustの一貫した設計哲学の表れです。C++のテンプレートは(Conceptsが導入される以前は)実際にインスタンス化されるまで型の要件が検査されず、エラーメッセージが「テンプレート展開の奥深く」を指す難解なものになりがちでした。Rustはジェネリクスを定義するその場でトレイト境界としてすべての要件を明示させ、要件を満たさない呼び出しはインスタンス化を待たずに即座にエラーになります。
⚠️
where句はセマンティクスに何も追加しない: インライン記法との違いは純粋に可読性です。それでもRust標準ライブラリの多くのメソッドが積極的にwhere句を使うのは、「型に対する要求」というプログラムの意図を、シグネチャの本体(引数と戻り値の形)から視覚的に分離することが、コードレビューや保守のコストを大きく下げるからです。条件付き実装(impl<T> Pair<T> where T: Display + PartialOrd)は、この思想をさらに一歩進めた能力ベースの設計で、後の章で学ぶトレイトオブジェクトやブランケット実装(標準ライブラリのimpl<T: Display> ToString for Tなど)の基礎になります。

🛑 コンパイルエラーが出た場合

条件を満たさない型(NoDisplay)で条件付きメソッドcmp_display()を呼ぼうとすると、次のようなエラーになります。

error[E0599]: the method `cmp_display` exists for struct `Pair<NoDisplay>`, but its trait bounds were not satisfied --> src/main.rs:66:22 | 23 | struct Pair<T> { | -------------- method `cmp_display` not found for this struct ... 47 | struct NoDisplay; | ---------------- doesn't satisfy `NoDisplay: PartialOrd` or `NoDisplay: std::fmt::Display` ... 66 | _no_display_pair.cmp_display(); | ^^^^^^^^^^^ method cannot be called on `Pair<NoDisplay>` due to unsatisfied trait bounds | note: the following trait bounds were not satisfied: `NoDisplay: PartialOrd` `NoDisplay: std::fmt::Display`

読み方:

  • the method 'cmp_display' exists for struct 'Pair<NoDisplay>', but its trait bounds were not satisfied: メソッドが「未定義」なのではなく、「Pair<T>にはcmp_displayという定義が確かに存在するが、T = NoDisplayの場合はその定義が要求する境界を満たさない」という判定です。Pair<i32>には問題なく存在します(i32DisplayPartialOrdを実装しているため)
  • doesn't satisfy 'NoDisplay: PartialOrd' or 'NoDisplay: std::fmt::Display': どのトレイト境界が満たされていないかを具体的に教えてくれます。ここを見れば、NoDisplayDisplayPartialOrdのどちらか(または両方)を実装すれば解決することがすぐわかります

修正方法: NoDisplay側にimpl Display for NoDisplay { ... }impl PartialOrd for NoDisplay { ... }(または#[derive(PartialEq, PartialOrd)])を実装するか、そもそもcmp_displayを呼ばない設計にするかのどちらかです。今回の課題では「呼ばない」が正解(境界のないメソッドと境界のあるメソッドが型ごとに使い分けられることを確認するのが目的)です。

🌐 他言語との比較

観点RustGoJavaC++TypeScript
ジェネリクスに制約を課す構文where T: TraitA + TraitB(またはインライン<T: TraitA + TraitB>[T Constraint](1.18+、interfaceで型集合を定義)<T extends Comparable<T>>(1境界のみ、複数は&で結合可)C++20のconcept/requires句、または旧来のSFINAE(std::enable_if<T extends SomeInterface>(構造的部分型なので「形」が合えば実質OK)
制約を満たさない場合に検出されるタイミングジェネリック関数・implブロックの定義時にコンパイラが要件を検査し、呼び出し時に不一致があれば即エラーコンパイル時(Goの型システムはシンプルなため、エラーメッセージも比較的明快)コンパイル時(ただし型消去のため、実行時には境界情報が残らない)Concepts導入前はテンプレートのインスタンス化時まで検査が遅延され、奥深いエラーメッセージになりがち。Concepts導入後はRustに近い早期検査が可能コンパイル時のみ(構造的型付けのため、名前ではなく「形」で判定される点がRust/Javaと異なる)
「型ごとに使えるメソッドを変える」条件付き実装impl<T> S<T> where T: Trait { ... }で自然に表現できる(標準ライブラリのToStringブランケット実装が代表例)該当する言語機能なし(インターフェースの組み合わせで型ごとに分岐するには別の設計が必要)該当なし(オーバーロードやインターフェース分離で近い効果を出すことは可能)Concepts + 部分特殊化で表現可能だが構文が複雑になりやすい該当なし(構造的型付けのため「この型は特定の形を持つ場合のみ」という条件分岐は型システムレベルでは表現しにくい)

Javaの<T extends Comparable<T>>はRustのwhere T: PartialOrdに感覚は近いですが、Javaは1つの型パラメータに1つのクラス境界+複数インターフェースという制約があり、Rustのように複数のトレイトを+で自由に組み合わせる設計(T: Display + PartialOrd + Cloneのような)はより柔軟です。C++のconcept(C++20)はRustのトレイト境界に最も近い思想を持ちますが、歴史的にはSFINAE(enable_ifによるテンプレートの部分的な無効化)で同等のことを実現してきた経緯があり、エラーメッセージの読みやすさで長年苦労してきた分野です。Rustは最初から「境界を明示させ、早期に検査する」設計を貫いています。

🏆 実務での使いどころ

  • ジェネリックなユーティリティ関数: largest_by_keyのようなパターンは、実務ではIterator::max_by_key/min_by_keyとしてすでに標準ライブラリに存在します。今回自前で実装した経験は、こうした標準メソッドの内部でどんな型制約が働いているかを理解する助けになります
  • リポジトリ・キャッシュ層の設計: 「保存はどんな型でもできるが、ログ出力やデバッグ表示はDebugを実装している型のみ」というような設計は、Pair<T>の条件付き実装パターンとまったく同じ形で実務コードに現れます(例: impl<T: Debug> Cache<T> { fn log_state(&self) { ... } }
  • APIライブラリ設計: 公開クレートのAPIでwhere句を使うと、docs.rs(Rustの自動生成ドキュメント)でトレイト境界が構造化されて表示され、利用者が「この関数を使うために型に何を実装すればいいか」を素早く把握できます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
where句とインライン境界は機能が違うと思い込む書き方が大きく異なるため、別の言語機能に見えてしまう意味的にはまったく同じもの。where句は複数の型パラメータ・複雑な境界を読みやすく書くための構文糖衣にすぎない
Pair::newcmp_displayを1つのimplブロックにまとめようとしてエラーに悩む「同じ構造体のメソッドは1箇所にまとめるべき」という他言語(Java/C#のクラス定義)の感覚を持ち込んでしまうRustでは同じ型に対して複数のimplブロックを書くのが自然なイディオム。境界の異なるメソッド群は別ブロックに分離するのが正しい設計
F: Fn(&T) -> Kのような関数トレイト境界を見て「これは特殊な構文」と身構えてしまうクロージャの型を直接書けないRustでは、Fn/FnMut/FnOnceという「呼び出し可能であること」を表すトレイトを境界として使う発想に慣れていないFn(&T) -> KFnトレイトに引数・戻り値の型情報を付与したトレイト境界の一種であり、通常のT: SomeTraitと全く同じ枠組みで扱われる

🚀 次のステップ

  • 発展: largest_by_keyIteratorアダプタとして書き直し(items.iter().max_by(|a, b| key_fn(a).partial_cmp(&key_fn(b)).unwrap())のような形)、自前ループ実装と比較してどちらが読みやすいか検討してみてください
  • 次回予告: Day 013ではデフォルト実装とトレイトオブジェクトを扱います。静的ディスパッチ(今回のジェネリクス)と動的ディスパッチ(dyn Trait)の違い、そしてトレイトにデフォルト実装を持たせる設計を学びます

🎯 自己評価

自分の回答

気づき・メモ