📚 背景知識(読んでから問題へ)
Day 024〜027でIntoIterator・FromIterator/Extend・自作StepByのバグ修正・DoubleEndedIteratorと、イテレータ「型そのもの」を自作する側を練習してきました。今日は視点を変えて、「イテレータを返す関数」をどう設計するかを考えます。
Rustでは関数の戻り値の型は具体的に書かなければならないのが原則ですが、.map()や.filter()を連鎖させた戻り値の型はMap<Filter<std::slice::Iter<'_, i32>, {closure}>, {closure}>のように、書くのが非現実的なほど複雑になります。この問題を解決する方法が主に2つあります。
1. impl Trait(Return Position Impl Trait, RPIT)
fn evens(v: &[i32]) -> impl Iterator<Item = i32> + '_ {
v.iter().filter(|&&x| x % 2 == 0).copied()
}
呼び出し側には「Iterator<Item = i32>を実装した何らかの型」としか見えませんが、コンパイラは内部的に具体的な型を知っています。関数呼び出しごとに単相化(monomorphization)されるため、静的ディスパッチでありヒープ確保もvtable経由の間接呼び出しも発生しません——ゼロコスト抽象化の恩恵をそのまま受けられます。
2. Box<dyn Iterator<Item = T>>(トレイトオブジェクト)
fn evens(v: &[i32]) -> Box<dyn Iterator<Item = i32> + '_> {
Box::new(v.iter().filter(|&&x| x % 2 == 0).copied())
}
戻り値は「ヒープに確保された、Iterator<Item = i32>を実装する何か」という動的ディスパッチの型になります。Boxによるヒープ確保1回と、メソッド呼び出しごとのvtable経由の間接呼び出しというコストを払う代わりに、関数の中で型が変わっても(例えば条件分岐で全く異なる具体型を返しても)同じ戻り値型で扱えます。
impl Traitが使えないのか」というコンパイルエラーに直面します。今日はその境界を実際に手を動かして体感します。📝 問題
以下の3つの関数について、それぞれimpl Iterator<Item = T>で書けるか、Box<dyn Iterator<Item = T>>が必要かを判断し、理由を述べた上で実際にコンパイルが通る完全な実装を書いてください。
関数A: Vec<i32>を受け取り、偶数だけを2倍にして返すイテレータを返す関数(戻り値の型は分岐によらず常に同じ処理チェーン)
関数B: Vec<i32>とboolフラグascendingを受け取り、ascendingがtrueなら昇順のまま、falseなら逆順にして返す関数。以下のコードは意図的にコンパイルエラーを起こすので、まずこのエラーメッセージを読み、次に正しい実装に直してください。
// このコードはコンパイルエラーになる
fn order(v: Vec<i32>, ascending: bool) -> impl Iterator<Item = i32> {
if ascending {
v.into_iter()
} else {
v.into_iter().rev()
}
}
関数C: 将来的に「内部の実装(フィルタ条件やソート方式)を頻繁に変更する可能性がある」公開ライブラリのAPIとして、イテレータを返す関数を設計する場合、impl TraitとBox<dyn Iterator>のどちらを選ぶべきでしょうか。トレードオフを説明してください(コード実装は関数A・Bのみでよい)。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
- 関数の戻り値の型が、関数の実行パス(if/elseや分岐)によらず単一の具体型に定まるかどうかが、
impl Traitが使えるかどうかの分かれ目です v.into_iter()とv.into_iter().rev()は、見た目は似ていてもIteratorトレイトを実装した別々の具体型(std::vec::IntoIter<i32>とstd::iter::Rev<std::vec::IntoIter<i32>>)である点に注意してください
ヒント2 — アプローチ
- 関数Bのコンパイルエラーは「
ifブロックとelseブロックの戻り値の型が一致しない」という趣旨のメッセージになります impl Traitは「呼び出し側から見えない具体的な1つの型」を表すシンタックスシュガーであって、「Iteratorを実装する型なら何でもよい」という動的な意味ではないことを思い出してください- 解決策は、分岐の両方を同じ具体型に揃えるか(
Either型を自作/クレート利用)、素直にBox<dyn Iterator<Item = i32>>にすることです
ヒント3 — コード骨格
// 関数A: impl Trait で書ける(単一の処理チェーン)
fn double_evens(v: Vec<i32>) -> impl Iterator<Item = i32> {
v.into_iter().filter(/* ??? */).map(/* ??? */)
}
// 関数B: Box<dyn Iterator> が必要(分岐で具体型が変わる)
fn order(v: Vec<i32>, ascending: bool) -> Box<dyn Iterator<Item = i32>> {
if ascending {
Box::new(/* ??? */)
} else {
Box::new(/* ??? */)
}
}
✅ 模範解答
// このコードは cargo new day028_iter_return の後、
// src/main.rs をこの内容で置き換えて `cargo run` で検証できます。
// 関数A: impl Trait で書ける。
// filter と map を連鎖させても、実行パスによらず戻り値の具体型は
// Map<Filter<std::vec::IntoIter<i32>, {closure}>, {closure}> の1種類に定まる。
fn double_evens(v: Vec<i32>) -> impl Iterator<Item = i32> {
v.into_iter().filter(|x| x % 2 == 0).map(|x| x * 2)
}
// 関数B: Box<dyn Iterator> が必要。
// if 節と else 節でそれぞれ異なる具体型
// (std::vec::IntoIter<i32> と std::iter::Rev<std::vec::IntoIter<i32>>)
// を返すため、単一の具体型を要求する impl Trait では表現できない。
fn order(v: Vec<i32>, ascending: bool) -> Box<dyn Iterator<Item = i32>> {
if ascending {
Box::new(v.into_iter())
} else {
Box::new(v.into_iter().rev())
}
}
fn main() {
let a: Vec<i32> = double_evens(vec![1, 2, 3, 4, 5, 6]).collect();
assert_eq!(a, vec![4, 8, 12]);
println!("関数A: {:?}", a);
let b_asc: Vec<i32> = order(vec![1, 2, 3], true).collect();
assert_eq!(b_asc, vec![1, 2, 3]);
println!("関数B(昇順): {:?}", b_asc);
let b_desc: Vec<i32> = order(vec![1, 2, 3], false).collect();
assert_eq!(b_desc, vec![3, 2, 1]);
println!("関数B(降順): {:?}", b_desc);
println!("OK");
}
▶ 実行結果を見る(rustc / cargo run・実機で検証済み)
$ cargo run
関数A: [4, 8, 12]
関数B(昇順): [1, 2, 3]
関数B(降順): [3, 2, 1]
OK
rustcで単体コンパイル・実行し、上記の出力とOKが得られることを確認済みです。cargo newしたプロジェクトのsrc/main.rsにそのまま貼り付けてもcargo runで同様に動作します。impl Traitを選ぶべきです。impl Traitは「呼び出し側には型が見えない」という不透明性(opacity)を最初から持っているため、関数の内部実装(フィルタ条件・処理チェーンの中身)をどれだけ変更しても、戻り値の型シグネチャ自体はimpl Iterator<Item = T>のまま変わりません——これはセマンティックバージョニング上の破壊的変更になりません。逆にBox<dyn Iterator>は最初から「型を隠す」設計なので実装変更への耐性という点では同等ですが、全呼び出しで不要なヒープ確保コストを払い続けるという欠点があります。したがって「戻り値の型が単一の具体型に定まる限りはimpl Traitを使い、実行パスによって型が変わる場合にのみBox<dyn Iterator>に切り替える」が実務上の指針になります。🪜 Step-by-Step 解説
double_evensは分岐を持たず、v.into_iter().filter(...).map(...)という単一の式が戻り値になります。これは実行時にどんな引数が渡されても、戻り値の型が常に同じ具体型(Map<Filter<IntoIter<i32>, _>, _>)になることを意味します。だからimpl Traitで表現できます。
元のコード(
impl Iterator<Item = i32>を戻り値にしたままif/elseでinto_iter()とinto_iter().rev()を返す版)をコンパイルすると、「ifアームとelseアームの型が違う」という趣旨のエラーが出ます。これはimpl Traitが単相化を前提にした単一の具体型を要求している証拠です。
Box<dyn Iterator>に切り替えるBox::new(...)で両方のアームをヒープに確保されたtraitオブジェクトに変換すれば、if/else双方が同じ型Box<dyn Iterator<Item = i32>>になり、コンパイルが通ります。これは「型消去(type erasure)」と呼ばれる操作で、異なる具体型を同じインターフェースの向こう側に隠します。
API設計の文脈では「実装を変えても型シグネチャが変わらない」という性質は
impl TraitとBox<dyn Iterator>の両方が持ちます。差はパフォーマンスにあります。実行パスに応じて型が変わる必要がない限り、impl Traitの方が呼び出し側に不要なアロケーションコストを負わせません。
💡 設計思想・なぜこう書くのか
Rustが「戻り値の型は具体的でなければならない」という原則を貫くのは、コンパイラが関数呼び出しごとに具体的な型情報を持っていることで、実行時のディスパッチコストなしに(インライン化さえ効く形で)コードを生成できるからです。impl Traitはこの原則を壊さずに「具体型を書き下すのが非現実的な場合の省略記法」として導入されました——あくまでコンパイル時に1つの具体型に確定することが大前提になっています。
Box<dyn Trait>はこれとは全く別の解決策で、「実行時にどの具体型が来るか分からない/複数ありうる」というケースのために、意図的に間接参照(vtable)とヒープ確保という実行時コストを受け入れる設計です。C++でいう仮想関数テーブル、Javaのインターフェース参照とほぼ同じ仕組みですが、Rustでは「ゼロコストで済ませられる場合はそちらを使う」という選択肢(impl Trait/ジェネリクス)が明確に用意されており、どちらのコストモデルで書くかをプログラマが型シグネチャのレベルで意識的に選ぶようになっている点がRust特有の設計思想です。
🛑 コンパイルエラーが出た場合
問題文に示した元のコードをそのままコンパイルすると、次のようなエラーになります。
読み方: E0308は「型の不一致」を示す代表的なエラーコードです。impl Iterator<Item = i32>は呼び出し側からは不透明ですが、コンパイラの内部では「関数本体から推論される唯一の具体型」に固定されます。ifアームが最初に評価されてIntoIter<i32>に固定された後、elseアームで別の具体型Rev<IntoIter<i32>>が出てくると矛盾が生じる、という意味です。ありがたいことに、このエラーメッセージ自体がhelp:として「Box<dyn Iterator>に変更し、両アームをBox::new(...)で包む」という修正方針をそのまま提示してくれています。修正方針は、両アームを同じ具体型に揃えるか(今回は不可能に近い)、Box<dyn Iterator<Item = i32>>に変更してヒープ上で型消去することです。
🌐 他言語との比較
| 言語 | 同種の使い分け |
|---|---|
| Rust | impl Trait(静的ディスパッチ・単相化)とBox<dyn Trait>(動的ディスパッチ・型消去)を型シグネチャのレベルで明示的に選択する |
| Go | インターフェース型を返すのが基本で、常に動的ディスパッチ相当(内部的にitableを使う)。ゼロコストな静的ディスパッチの選択肢は言語機能としては薄い |
| Java | Stream<T>やIterator<T>を返すメソッドは常にインターフェース経由(実質的にJavaではBox<dyn Trait>寄りの世界のみ)。JITのインライン化に最適化を委ねる |
| C++ | auto戻り値型推論(C++14〜)がimpl Traitに近い(静的・単相化)。std::functionや仮想関数がBox<dyn Trait>に相当する |
| TypeScript | 構造的型付けなのでインターフェース(Iterable<T>)を返せば型は常に統一的に扱われる。ランタイムはJITに委ねられ、Rustのような明示的なコスト選択の概念自体が薄い |
RustとC++のauto戻り値型推論は「単相化前提の静的ディスパッチをデフォルトにしつつ、動的な場合だけ明示的にコストを払う」という設計思想が近い部分です。Go/Java/TypeScriptは総じてインターフェース(動的ディスパッチ)がデフォルトで、そもそも「静的ディスパッチかどうかをプログラマが型シグネチャで選ぶ」という発想自体がRustほど前面に出てきません。
🏆 実務での使いどころ
- 内部実装用のヘルパー関数(クレート内・モジュール内で完結し、戻り値の型が呼び出し元コード生成に強く影響してよい場合)は基本的に
impl Traitを優先し、モノモーフィック化によるインライン化の恩恵を受けます - トレイトオブジェクトのコレクション(例えば
Vec<Box<dyn Iterator<Item = Event>>>のように「異なる種類のイベントソースをまとめて扱いたい」場合)や、実行時の条件分岐で本質的に異なるアルゴリズム・データソースを切り替える必要がある場合はBox<dyn Iterator>が必須になります - パブリックAPIの設計では、「将来の実装変更が型シグネチャに影響しない」という不透明性の恩恵を
impl TraitだけでなくBox<dyn Iterator>も持っている点を理解した上で、パフォーマンス要件(ホットパスかどうか)で選択します
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
impl Traitは「そのトレイトを実装していれば何でもよい」動的な型だと思い込む | ジェネリクスの型パラメータ制約(<T: Iterator>)と混同してしまう | impl Traitは戻り値位置では「コンパイラが推論する唯一の具体型」を指す不透明型(opaque type)であり、関数本体の中で複数の具体型を返し分けることはできない |
分岐で型が変わる関数に無理やりimpl Traitを使おうとしてエラーの意味を誤読する | エラーメッセージの「incompatible types」だけを見て、値そのものの型ミスマッチだと思ってしまう | 実際には「Iteratorトレイトの実装先の具体型」がifとelseで異なるという、ジェネリクス/トレイトレベルの不一致である |
Box<dyn Iterator>を使うと必ず性能が悪化すると思い込み、無理にimpl Traitで押し通そうとする | 「ゼロコスト抽象化」を「常にBoxを避けるべき」と拡大解釈してしまう | 実行パスによって具体型が変わる設計要件がある場合、Box<dyn Iterator>はヒープ確保1回+vtable呼び出し程度のコストで正しさを保証できる妥当な選択肢であり、無理な設計変更でコードを複雑化させる方が実務上は損である |
🚀 次のステップ
- 発展:
Either<L, R>(itertoolsクレートなどが提供する、2つの異なるイテレータ型を1つの型に統合するenum)を使うと、関数BのケースでBoxを使わずに(=ヒープ確保なしで)静的ディスパッチのまま分岐を表現できます。実際にitertools::Eitherを使って関数Bを書き直してみましょう - 次回予告: 自己評価で「完全理解」または「おおむね理解」が3回連続で記録された時点で、次のテーマスマートポインタ①(Box<T>)(ヒープ確保・再帰的データ構造)に進みます