📚 背景知識(読んでから問題へ)
Day 017〜020でCounter(ソースイテレータ)とWindows2<I>(アダプタイテレータ)を実装し、「動くコードを書く」段階は一通り経験しました。今日は一段視点を上げて、「自作イテレータに、Iterator本体以外のどのマーカートレイトまで実装すべきか」という設計判断を扱います。
標準ライブラリのイテレータ関連トレイトには、Iterator本体の他に主に3つの補助トレイトがあります。
| トレイト | 約束する内容 | 実装を誤った場合の影響 |
|---|---|---|
ExactSizeIterator | len()が残り要素数を正確に、かつO(1)で返す | Vec::with_capacity等の呼び出し元が誤ったキャパシティで確保する等、ロジックバグを誘発(メモリ安全性は壊れないが契約違反) |
DoubleEndedIterator | next_back()で末尾から逆順に要素を取り出せる | 実装が破綻していると「前から見た結果」と「後ろから見た結果」が整合しない不正なイテレータになる |
FusedIterator | 一度Noneを返したらそれ以降も永久にNoneを返す(.fuse()なしで安全に多重ループできる) | 実装を誤ると、一度終わったはずのイテレータが後でSomeを返す「ゾンビ復活」が起き、呼び出し側のロジックが混乱する |
これらはマーカートレイト(あるいは追加の性能保証を与えるトレイト)であり、「実装できるかどうか」はイテレータの内部ロジックによって決まります。無条件に全部実装すればよいというものではなく、「実装した場合に契約(コントラクト)を本当に守れるか」を先に検証し、守れない・守るコストが割に合わないものは実装しないという判断も、Rustのトレイト設計では重要なスキルです。これは「トレイトを実装できること」と「トレイトを実装すべきこと」が必ずしも一致しないという、API設計者に固有の意思決定です。
もう1つ今日扱うのが、公開API関数の戻り値の型設計です。イテレータを返すライブラリ関数を書くとき、-> Vec<T>(即座に全計算して積んだ結果を返す・eager)と-> impl Iterator<Item = T>(呼ばれるたびに1要素ずつ計算する・lazy)のどちらを選ぶかは、性能特性とAPIの柔軟性の両方に影響する設計判断です。
📝 問題
「連続する同じ値をまとめて(値, 連続回数)のペアにする」Run-Length Encoding(連長圧縮)を行うカスタムイテレータRunLengthEncode<I>を題材に、以下の設計判断を行い、それぞれ理由付きで結論を出し、結論を裏付けるコードを実装してください。
設計判断1: ExactSizeIteratorを実装すべきか
RunLengthEncode<I>に対してExactSizeIterator(len()をO(1)かつ正確に返す)を実装すべきかどうかを判断してください。「実装できるか(コンパイルが通るか)」ではなく「契約を正しく守れるか」の観点で結論と理由を述べてください。
設計判断2: DoubleEndedIteratorを実装すべきか
内側のIがDoubleEndedIteratorを実装している場合、RunLengthEncode<I>側にもDoubleEndedIterator(next_back()で末尾から連長圧縮を取り出す)を実装する価値があるかを検討してください。実装する場合の技術的な難所(前からのカーソルと後ろからのカーソルが同じ「連」の途中で衝突するケースをどう扱うか)にも触れ、実装するかしないか結論を出してください。
設計判断3: FusedIteratorを実装すべきか
RunLengthEncode<I>のnext()実装の書き方次第で、内側IがFusedIteratorかどうかに関係なく無条件にFusedIteratorを実装できるかどうかを判断してください(ヒント: next()の中で「一度Noneを観測した後にもう一度inner.next()を呼ぶコードパス」が存在するかどうかを確認してください)。
設計判断4: 公開関数の戻り値型 — Vec<(T, usize)> か impl Iterator<Item = (T, usize)> か
pub fn run_length_encode(...)という公開関数を設計するとして、戻り値を即座に計算済みのVec<(T, usize)>にするか、遅延評価されるimpl Iterator<Item = (T, usize)>にするかを判断してください。「呼び出し側が.take(2)のように結果の先頭だけを使いたい場合、入力全体を消費してしまうかどうか」を実際にコードで検証し、それを判断根拠に含めてください。
実装要件
RunLengthEncode<I>構造体とIterator実装(I::Item: PartialEq + Cloneという制約は課してよい)- 上記の設計判断の結論に基づいた
size_hint()・FusedIteratorの実装(実装すべきでないと判断したものは実装しない) .take(2)で先頭2グループだけを取り出した場合に、内側のソースが必要以上に消費されないことを検証するテスト(内側の消費回数をカウントする計測用ラッパーイテレータを自作してよい)
🔍 ヒント(段階的開示)
ヒント1 — 方向性
- Run-Length Encodingの出力グループ数は「入力データの中身」に依存します(例:
[1,1,1,1]→1グループ、[1,2,3,4]→4グループ)。これは「入力の要素数だけからは出力の要素数を確定できない」ことを意味します。ExactSizeIterator::len()は「中身を見ずにO(1)で正確な数を返す」契約なので、この時点で設計判断1の結論の方向性が見えてきます - 設計判断3は「
FusedIteratorの性質は、内側のイテレータの性質を継承するのではなく、自分のnext()の実装が持つ制御フロー自体から生まれる」という視点がポイントです
ヒント2 — アプローチ
size_hint()は「正確な値」ではなく「下限と上限の範囲」を返せばよい、というのがExactSizeIteratorとの違いです。内側のsize_hint()が(lo, hi)のとき、出力グループ数の下限は「lo == 0なら0、そうでなければ最低1グループは存在する」、上限は「最悪すべて別グループなのでhi(Option<usize>)のまま」という関係が成り立ちます.peekable()を使うと「次の要素を消費せずに覗き見る」ことができ、Run-Length Encodingの「同じ値が続く限り読み進める」ロジックを自然に書けます- 消費回数を数えるラッパーは
struct CountingIter<I> { inner: I, count: Rc<Cell<usize>> }のような形で、next()が呼ばれるたびにcountをインクリメントしてからself.inner.next()に委譲する、という薄いラッパーにするとテストで検証しやすくなります
ヒント3 — コード骨格
use std::iter::Peekable;
struct RunLengthEncode<I: Iterator> {
inner: Peekable<I>,
}
impl<I: Iterator> Iterator for RunLengthEncode<I>
where
I::Item: PartialEq + Clone,
{
type Item = (I::Item, usize);
fn next(&mut self) -> Option<Self::Item> {
let first = self.inner.next()?;
let mut count = 1usize;
while self.inner.peek() == Some(&first) {
self.inner.next();
count += 1;
}
Some((first, count))
}
fn size_hint(&self) -> (usize, Option<usize>) {
let (lo, hi) = self.inner.size_hint();
let lower = if lo == 0 { 0 } else { 1 };
(lower, hi)
}
}
// 設計判断3の結論に基づき、内側の性質に関係なく無条件に実装できる
impl<I: Iterator> std::iter::FusedIterator for RunLengthEncode<I> where I::Item: PartialEq + Clone {}
✅ 模範解答
use std::cell::Cell;
use std::iter::{FusedIterator, Peekable};
use std::rc::Rc;
// ---- 本体: RunLengthEncode<I> ----
struct RunLengthEncode<I: Iterator> {
inner: Peekable<I>,
}
impl<I: Iterator> Iterator for RunLengthEncode<I>
where
I::Item: PartialEq + Clone,
{
type Item = (I::Item, usize);
fn next(&mut self) -> Option<Self::Item> {
// 1つ目を取り出せなければ(内側が空)そこで終了。
// ここで None を観測したら、以降 inner.next() を二度と呼ばない
// (= FusedIterator を無条件で実装できる根拠)。
let first = self.inner.next()?;
let mut count = 1usize;
// peek() で消費せずに覗き見て、同じ値が続く限り実際に読み進める
while self.inner.peek() == Some(&first) {
self.inner.next();
count += 1;
}
Some((first, count))
}
fn size_hint(&self) -> (usize, Option<usize>) {
// 設計判断1の結論: 正確な len() は提供できないが、
// 下限・上限の「範囲」としての size_hint はO(1)で計算できる。
let (lo, hi) = self.inner.size_hint();
let lower = if lo == 0 { 0 } else { 1 };
(lower, hi) // 上限は「全要素が別グループ」のケースでも inner の上限を超えない
}
}
// 設計判断3の結論: next() の制御フローが「一度 None を見たら二度と inner に触れない」
// 形になっているため、内側 I が FusedIterator かどうかに関係なく無条件に実装できる。
impl<I: Iterator> FusedIterator for RunLengthEncode<I> where I::Item: PartialEq + Clone {}
// 注意: ExactSizeIterator と DoubleEndedIterator は意図的に実装しない
// (設計判断1・2の結論。理由は「Step-by-Step解説」参照)
trait RunLengthExt: Iterator + Sized {
fn run_length_encode(self) -> RunLengthEncode<Self>
where
Self::Item: PartialEq + Clone,
{
RunLengthEncode {
inner: self.peekable(),
}
}
}
impl<I: Iterator> RunLengthExt for I {}
// ---- 設計判断4の検証用: 消費回数を数える計測ラッパー ----
struct CountingIter<I> {
inner: I,
count: Rc<Cell<usize>>,
}
impl<I: Iterator> Iterator for CountingIter<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
self.count.set(self.count.get() + 1);
self.inner.next()
}
}
fn main() {
let data = vec![1, 1, 1, 2, 2, 3, 3, 3, 3, 4];
let groups: Vec<(i32, usize)> = data.into_iter().run_length_encode().collect();
println!("{groups:?}"); // [(1, 3), (2, 2), (3, 4), (4, 1)]
// 設計判断4の実証: .take(2) で先頭2グループだけ取れば、
// 内側の消費は "1,1,1,2,2" の5要素分で止まり、残りの "3,3,3,3,4" には触れない。
let count = Rc::new(Cell::new(0));
let source = CountingIter {
inner: vec![1, 1, 1, 2, 2, 3, 3, 3, 3, 4].into_iter(),
count: count.clone(),
};
let first_two: Vec<(i32, usize)> = source.run_length_encode().take(2).collect();
println!("{first_two:?}, inner next() 呼び出し回数 = {}", count.get());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn groups_consecutive_equal_elements() {
let data = vec![1, 1, 1, 2, 2, 3, 3, 3, 3, 4];
let groups: Vec<(i32, usize)> = data.into_iter().run_length_encode().collect();
assert_eq!(groups, vec![(1, 3), (2, 2), (3, 4), (4, 1)]);
}
#[test]
fn empty_input_yields_no_groups() {
let groups: Vec<(i32, usize)> = Vec::<i32>::new().into_iter().run_length_encode().collect();
assert_eq!(groups, Vec::<(i32, usize)>::new());
}
#[test]
fn size_hint_lower_bound_reflects_at_least_one_group() {
let iter = vec![1, 1, 2, 2].into_iter().run_length_encode();
// 中身は2グループだが、size_hint は「範囲」なので下限1・上限4(inner の要素数)で妥当
assert_eq!(iter.size_hint(), (1, Some(4)));
}
#[test]
fn take_two_does_not_consume_entire_source() {
let count = Rc::new(Cell::new(0));
let source = CountingIter {
inner: vec![1, 1, 1, 2, 2, 3, 3, 3, 3, 4].into_iter(),
count: count.clone(),
};
let first_two: Vec<(i32, usize)> = source.run_length_encode().take(2).collect();
assert_eq!(first_two, vec![(1, 3), (2, 2)]);
// 10要素すべてを舐めていれば10になるはずだが、
// 先頭2グループ(1,1,1,2,2)の直後、3番目のグループの先頭「3」を
// peek() するところまでで止まるため 6 回で済む。
assert_eq!(count.get(), 6);
}
#[test]
fn fused_after_exhaustion_never_returns_some_again() {
let mut iter = vec![1, 1].into_iter().run_length_encode();
assert_eq!(iter.next(), Some((1, 2)));
assert_eq!(iter.next(), None);
assert_eq!(iter.next(), None); // FusedIterator: 何度呼んでも None のまま
}
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
[(1, 3), (2, 2), (3, 4), (4, 1)]
[(1, 3), (2, 2)], inner next() 呼び出し回数 = 6
$ cargo test
running 5 tests
test tests::empty_input_yields_no_groups ... ok
test tests::fused_after_exhaustion_never_returns_some_again ... ok
test tests::groups_consecutive_equal_elements ... ok
test tests::size_hint_lower_bound_reflects_at_least_one_group ... ok
test tests::take_two_does_not_consume_entire_source ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
🪜 Step-by-Step 解説
ExactSizeIterator)— なぜ実装しないという結論になるかExactSizeIteratorの契約は「len()が残り要素数を正確に、かつO(1)で返す」ことです。Run-Length Encodingの出力グループ数は、入力データの中身(どこで値が変わるか)に依存するため、入力を実際に走査しない限り正確な数は分かりません。つまり「正確に」を満たすには「O(1)で」を諦めて全走査するしかなく、両方を同時に満たせません。ここで無理にlen()を実装する(例えば内側のsize_hint()の下限をそのまま返す等)と、コンパイルは通っても契約違反の嘘の値を返すことになります。ExactSizeIteratorはメモリ安全性に関わるunsafeなトレイトではないため嘘をついても即座にクラッシュはしませんが、Vec::with_capacity(iter.len())のような呼び出し元のロジックが誤ったキャパシティで動作するなど、静かなロジックバグを生みます。「実装できるかどうか」ではなく「契約を守れるかどうか」で判断すると、ここでは実装しないが正しい結論です。
DoubleEndedIterator)— 実装コストと需要のトレードオフ理論上は、内側
IがDoubleEndedIteratorであれば、末尾からnext_back()で連を検出することは不可能ではありません。しかし「前からのカーソル」と「後ろからのカーソル」が同じ連の途中で出会うケース(例: [1,1,1,1]を前から1個・後ろから1個ずつ取り出そうとすると、両方とも「1が何個連続しているか」を知るために相手側の状態を意識する必要が出てくる)を正しく扱うには、単純な転送では済まず、内部状態(両端の未確定カウント)を持つ複雑な実装が必要になります。この複雑さに見合うだけの実務的な需要(「連長圧縮の結果を後ろから読みたい」というユースケース)は限定的です。実装コストと保守コストが、得られる利便性に見合わないため、実装しないという判断も設計として正当です(無理に実装して複雑なバグの温床を作るより、まずは前方向のみをシンプルに提供する)。
FusedIterator)— 内側ではなく自分の制御フローで決まる模範解答の
next()を見ると、let first = self.inner.next()?;の?によって、inner.next()がNoneを返した瞬間にRunLengthEncode::next()自体も即座にNoneを返して終了します。このNoneが発生した後、同じnext()呼び出しの中でも次回の呼び出しでも、self.inner.next()が再度呼ばれることは構造上ありません(次回呼ばれても、また同じ?で即座にNoneが返るだけです)。したがって、内側IがFusedIteratorかどうかに一切関係なく、RunLengthEncode<I>は無条件に「一度Noneを返したら永久にNone」という性質を満たします。これがimpl<I: Iterator> FusedIterator for RunLengthEncode<I>を内側の制約なしで書ける理由です。
take(2)テストが示すものtake_two_does_not_consume_entire_sourceテストでは、内側のソースの消費回数を数えるCountingIterを挟み、.run_length_encode().take(2).collect()を呼んだ後の消費回数が6(10要素中)で止まることを確認しています。もしrun_length_encodeが-> Vec<(T, usize)>のようなeagerな戻り値型だった場合、関数の中で必ず入力全体(10要素)を舐め切ってからVecを返すため、呼び出し側が先頭2グループしか使わなくても常に全走査コストを払うことになります。-> impl Iterator<Item = (T, usize)>というlazyな戻り値型であれば、.take(2)のような下流の消費側の都合に応じて、必要な分だけ内側を消費して止まる、という構成可能性(composability)が得られます。この検証結果が「ライブラリの公開APIとしてはimpl Iteratorを返す設計の方が、呼び出し側の自由度と無駄な計算の回避の両面で優れている」という結論の裏付けになります。
💡 設計思想・なぜこう書くのか
ExactSizeIterator::len()に適当な固定値を返すコードを書いてもコンパイルは通ります)。しかしAPI設計者の責務は、トレイトのドキュメントに書かれた契約(意味論的な約束)を実際に守れるかどうかを検証することです。今回のExactSizeIteratorのように、コンパイラは守れないことを教えてくれない(型システムは「意味」までは検査しない)ため、設計者自身が「このトレイトの契約は本当に満たせるか」を毎回問い直す必要があります。pub fn run_length_encode(...) -> Vec<(T, usize)>として一度公開してしまうと、後から-> impl Iterator<Item = (T, usize)>に変更するのは破壊的変更(breaking change)になります(呼び出し側がVec固有のメソッド、例えば添字アクセスresult[0]やスライスメソッドに依存しているコードが壊れる可能性があるため)。逆にimpl IteratorからVecへの変更は多くの場合は互換性を保てます(VecもIntoIteratorを実装するため)。この非対称性から、「迷ったら遅延評価できるimpl Iterator側を先に選んでおき、必要になったら.collect()でVec化するのは呼び出し側の裁量に委ねる」というのが、Rustの公開API設計における実務上の定石です。🌐 他言語との比較
| 観点 | Rust | Java | Go | JavaScript/TypeScript | Python |
|---|---|---|---|---|---|
| 「正確な要素数」を約束するトレイト/機構 | ExactSizeIterator(トレイトとして明示・契約違反はコンパイルエラーにならず設計者の責任) | Collection.size()は「必ずO(1)で正確」という慣習はあるが、言語機構としての契約はない | スライス/マップのlen()組み込み関数のみ。カスタムイテレータ(iter.Seq等)にはこの種の契約は存在しない | Array.lengthはネイティブ配列にのみ存在。カスタムジェネレータに「正確な残数」を返す標準の仕組みはない | ジェネレータにlen()は存在しない(__length_hint__という「目安」の仕組みはあるが「正確」の保証はない) |
| 遅延評価 vs 即時評価の型による表現 | impl Iterator<Item = T>(遅延)とVec<T>(即時)が型として区別され、シグネチャを見ただけで挙動が分かる | Stream<T>(遅延)とList<T>(即時)で型としては区別されるが、Streamは一度しか消費できない等の制約に気付きにくい | チャネルやiter.Seq[T](Go 1.23+)で遅延評価を表現できるが歴史的には[]Tを返す設計が主流だった | ジェネレータ関数(function*、遅延)と配列を返す通常の関数(即時)は構文レベルで区別されるが、型注釈がないと呼び出し側は気付きにくい | ジェネレータ関数(yield、遅延)とリストを返す関数(即時)は構文で区別されるが、動的型付けのため型シグネチャからは判別できない |
| マーカートレイト実装の可否をコンパイラが検査するか | 検査しない(構文的に実装可能でも意味論的な契約違反はコンパイラが検出できない。設計者の判断に依存) | 該当なし(インターフェースの契約はJavadocの記述のみで強制力はない) | 該当なし(Goにはinterfaceの「意味的契約」を強制する仕組みがそもそもない) | 該当なし | 該当なし |
Rustが他言語と際立って異なるのは、「遅延か即時か」が関数シグネチャの型そのものに現れるという点です。JavaのStreamやPythonのジェネレータも遅延評価を表現できますが、いずれも「一度しか消費できない」「型注釈だけでは即時評価との区別がつきにくい」といった落とし穴があります。Rustではimpl Iterator<Item = T>という戻り値の型を見ただけで「これは遅延評価される」「複数回collect()はできない(所有権が一度しか渡せない)」という情報がコンパイル時に伝わるため、API利用者が実行してみるまで気付かない類の誤解が構造的に減ります。
🏆 実務での使いどころ
- ログ圧縮・監視データの前処理: 同じステータスコードが連続するアクセスログや、同じセンサー値が連続する時系列データを
.run_length_encode()でまとめれば、「変化点だけ」を効率よく抽出できます。ストレージへの保存前に生ログを圧縮する用途にも直結します - 公開クレートのAPI設計判断そのものが実務スキル: 「このイテレータに
ExactSizeIteratorを実装していいか」「戻り値をVecにすべきかimpl Iteratorにすべきか」という判断は、社内共通ライブラリやOSSクレートを設計する際に必ず直面する意思決定です。今回学んだ「契約を守れるかで判断する」「破壊的変更のリスクが低い方を先に選ぶ」という基準は、そのままプロダクションのAPI設計レビューに転用できます - 計測用ラッパーイテレータのテスト手法:
CountingIterのように「実際に何回next()が呼ばれたか」を数える薄いラッパーは、パフォーマンスクリティカルなイテレータチェーンで「本当に遅延評価されているか」「意図しない全走査が起きていないか」を検証する実務的なテスト手法として、そのまま流用できます
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
コンパイルが通るならExactSizeIteratorを実装しても問題ないと思い込む | Rustの型システムが厳格なため、「コンパイルが通る=正しい」という感覚に慣れている | ExactSizeIteratorのようなトレイトは意味論的な契約(ドキュメントに書かれた約束)を伴い、コンパイラはその契約の正しさまでは検査しない。契約を守れないなら実装自体を諦めるのが正しい判断 |
FusedIteratorは「内側がFusedなら自分もFusedになる」ものだと思い込む | 「性質は内側から伝播する」という直感がCloneやDebugのderiveの感覚から生まれやすい | FusedIteratorはnext()の自分自身の制御フローが「一度Noneを見たら二度と内側に触れない」形になっていれば、内側の性質と無関係に無条件で実装できる場合がある |
公開関数は常にVecを返すのが親切だと思い込む | 呼び出し側がすぐに.len()や添字アクセスをできるので使いやすく見える | Vecを返すと必ず全走査+ヒープ確保のコストを呼び出し側に強制する。impl Iteratorを返せば.take()等で早期終了できる柔軟性を呼び出し側に委ねられ、必要なら.collect()一発でVec化できる |
🚀 次のステップ
- 発展: 設計判断2で「実装しない」と判断した
DoubleEndedIteratorについて、実際に内部状態(front_pending・back_pendingのような両端の未確定カウント)を持たせて実装に挑戦してみると、Step 2で触れた「前後のカーソルが同じ連の中で出会う」問題の難しさを体感できます - 次回予告: 自己評価で「完全理解」または「おおむね理解」が3回連続で記録された時点で、次のテーマスマートポインタ①(Box<T>)(ヒープ確保・再帰的データ構造)に進みます