Day 018 — カスタムイテレータの実装(DoubleEndedIterator/ExactSizeIteratorの境界バグ)

2026-08-15 🔵 中級者 / Phase 2 デバッグ・コードレビュー カスタムイテレータの実装

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

Day 017ではIteratornext()のみ)を自作しました。今日はその発展として、DoubleEndedIteratorExactSizeIteratorという2つの追加トレイトを扱います。

pub trait DoubleEndedIterator: Iterator {
    fn next_back(&mut self) -> Option<Self::Item>; // 末尾側から1つ取り出す
}

pub trait ExactSizeIterator: Iterator {
    fn len(&self) -> usize {
        // デフォルト実装は size_hint() から算出されるが、正確な残数が
        // 分かっている場合は独自にオーバーライドできる
        self.size_hint().0
    }
}
  • DoubleEndedIterator: next()(先頭から)に加えnext_back()(末尾から)を実装すると、.rev()で逆順走査ができるようになります。Veciter()Range0..10)は標準でこれを実装しています
  • ExactSizeIterator: 「残りの要素数が正確に分かる」ことを型で表明するマーカー的トレイトです。Vec::with_capacityのような事前確保の最適化に使われ、collect()ExactSizeIteratorを実装したイテレータに対して再アロケーションなしでちょうどいいサイズのVecを1回で確保できます

この2つを同じ型に実装するとき、最も起こりやすいバグが「先頭カーソルと末尾カーソルが衝突したかどうか」の境界判定の誤りです。next()next_back()はそれぞれ独立したメソッドですが、両者は同じ内部状態(currentendなど)を共有しながら「互いに反対方向から侵食していく」関係にあります。片方の終了条件だけ演算子を1文字間違えると、コンパイルは通ってしまうのに実行時に整数アンダーフローパニック終わらないイテレータという深刻なバグを生みます。

以下のコードは、0..endの範囲を先頭・末尾どちらからでも取り出せるCountdownイテレータの実装です。一見正しく動きそうですが、next_back()に1箇所バグがあります。

struct Countdown {
    current: u32,
    end: u32,
}

impl Countdown {
    fn new(start: u32, end: u32) -> Self {
        Countdown { current: start, end }
    }
}

impl Iterator for Countdown {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current == self.end {
            return None;
        }
        let value = self.current;
        self.current += 1;
        Some(value)
    }
}

impl DoubleEndedIterator for Countdown {
    fn next_back(&mut self) -> Option<Self::Item> {
        if self.current > self.end {
            // BUG: ここに問題がある
            return None;
        }
        self.end -= 1;
        Some(self.end)
    }
}

impl ExactSizeIterator for Countdown {
    fn len(&self) -> usize {
        (self.end - self.current) as usize
    }
}

📝 問題

上記のCountdownのコードにはnext_back()に1つ、境界判定の演算子の誤りによるバグがあります。以下の3つの要求に順番に答えてください。

要求1(バグの特定と原因説明)

next_back()内のif self.current > self.endという条件が誤りである理由を説明してください。Countdown::new(0, 3)に対してnext_back()4回連続で呼んだとき、実際に何が起こるか(何回目の呼び出しでどんな問題が発生するか)をステップごとに追ってください。

要求2(修正版の実装)

next_back()の境界判定を正しい条件に修正した完全なCountdownの実装を書いてください。next()next_back()を交互に呼んでも、.rev().collect()を呼んでも、必ずcurrent == endに到達した時点で正しくNoneを返し、要素の重複・欠落・パニックが起きないようにしてください。

要求3(回帰防止のテスト実装)

次の3つを検証するテストを書いてください。

  1. Countdown::new(0, 5)に対してnext_back()だけを5回呼ぶと[4, 3, 2, 1, 0]が順に返り、6回目はNoneになる(修正前のバグでは6回目でパニックしていた箇所)
  2. next()next_back()を交互に呼び、合計で元の要素数ぶんだけ値が返り、値の重複がない(Countdown::new(0, 5)なら{0,1,2,3,4}がちょうど1回ずつ)
  3. Countdown::new(0, 5).rev().collect::<Vec<u32>>()vec![4, 3, 2, 1, 0]になり、かつExactSizeIterator::len()が走査前に正しく5を返す

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • next()の終了条件はself.current == self.endです。next_back()も本質的には「先頭カーソルと末尾カーソルが衝突したか」という同じ判定をしているはずですが、演算子が違います。2つの条件式を並べて見比べてください
  • currentは常にend以下(current <= end)という不変条件が保たれている限り、current > endという条件は理論上決して真にならないはずです。つまりこの条件は「衝突を検知するガード」として機能していません
  • バグの影響はCountdown::new(0, 3)のようにcurrentendが近い(要素数が少ない)場合ほど早く表面化します。手を動かしてcurrentendの値をステップごとに紙に書き出してみてください
ヒント2 — アプローチ
  • 正しい終了条件は「currentendが一致したら終わり」です。next()の条件self.current == self.endまったく同じ式next_back()にも使うのが正解です(>=でも意味的には同じですが、currentendを超えることは不変条件上ありえないため==が最も意図を表します)
  • self.current > self.endという条件は、currentendが一致した瞬間(本来なら止まるべきタイミング)にまだfalseのままです。そのためself.end -= 1が実行されてしまい、endu32の下限(0)を割り込むと整数アンダーフローが発生します。デバッグビルドではattempt to subtract with overflowというパニックになり、リリースビルドではラップアラウンド(0 - 1 = 4294967295)してしまい、current > endが永久に成立しないまま巨大な値を返し続ける「実質無限ループ」になります
  • 修正はnext_back()の1行、if self.current > self.endif self.current == self.endに変えるだけです。len()の実装(self.end - self.current)は、この修正によりendが常にcurrent以上に保たれるため、アンダーフローの心配がなくなります
ヒント3 — コード骨格
impl DoubleEndedIterator for Countdown {
    fn next_back(&mut self) -> Option<Self::Item> {
        if self.current == self.end {
            return None;
        }
        self.end -= 1;
        Some(self.end)
    }
}

模範解答

struct Countdown {
    current: u32,
    end: u32,
}

impl Countdown {
    fn new(start: u32, end: u32) -> Self {
        Countdown { current: start, end }
    }
}

impl Iterator for Countdown {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        if self.current == self.end {
            return None;
        }
        let value = self.current;
        self.current += 1;
        Some(value)
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        let remaining = (self.end - self.current) as usize;
        (remaining, Some(remaining))
    }
}

// 修正版: 境界判定を `self.current == self.end` に統一する
impl DoubleEndedIterator for Countdown {
    fn next_back(&mut self) -> Option<Self::Item> {
        if self.current == self.end {
            return None;
        }
        self.end -= 1;
        Some(self.end)
    }
}

impl ExactSizeIterator for Countdown {
    fn len(&self) -> usize {
        (self.end - self.current) as usize
    }
}

fn main() {
    // 要求2の実行例: next_back() だけで末尾から辿る
    let backward: Vec<u32> = Countdown::new(0, 5).rev().collect();
    println!("backward = {:?}", backward);

    // 交互に next() / next_back() を呼ぶ例
    let mut it = Countdown::new(0, 5);
    let mut mixed = Vec::new();
    mixed.push(it.next().unwrap());      // 0
    mixed.push(it.next_back().unwrap()); // 4
    mixed.push(it.next().unwrap());      // 1
    mixed.push(it.next_back().unwrap()); // 3
    mixed.push(it.next().unwrap());      // 2
    println!("mixed = {:?}", mixed);
    println!("exhausted next() = {:?}", it.next());           // None
    println!("exhausted next_back() = {:?}", it.next_back()); // None

    // ExactSizeIterator の確認
    println!("len before consume = {}", Countdown::new(0, 5).len());
}

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

    // 要求3-1: next_back() のみを5回呼ぶ回帰テスト
    // 修正前のバグでは6回目の呼び出しで `attempt to subtract with overflow` パニックしていた
    #[test]
    fn next_back_only_yields_reverse_order_then_none() {
        let mut it = Countdown::new(0, 5);
        assert_eq!(it.next_back(), Some(4));
        assert_eq!(it.next_back(), Some(3));
        assert_eq!(it.next_back(), Some(2));
        assert_eq!(it.next_back(), Some(1));
        assert_eq!(it.next_back(), Some(0));
        assert_eq!(it.next_back(), None); // ここでパニックせず None が返る
        assert_eq!(it.next_back(), None); // 何度呼んでも None のまま(FusedIterator相当の安定性)
    }

    // 要求3-2: next() / next_back() を交互に呼んでも重複・欠落がない
    #[test]
    fn interleaved_next_and_next_back_cover_all_elements_exactly_once() {
        let mut it = Countdown::new(0, 5);
        let mut collected = Vec::new();

        collected.push(it.next().unwrap());
        collected.push(it.next_back().unwrap());
        collected.push(it.next().unwrap());
        collected.push(it.next_back().unwrap());
        collected.push(it.next().unwrap());

        // 5要素すべて出尽くしたので、両方向とも None になる
        assert_eq!(it.next(), None);
        assert_eq!(it.next_back(), None);

        collected.sort();
        assert_eq!(collected, vec![0, 1, 2, 3, 4]);
    }

    // 要求3-3: rev().collect() と ExactSizeIterator::len() の整合性
    #[test]
    fn rev_collect_matches_expected_order_and_len_is_exact() {
        let it = Countdown::new(0, 5);
        assert_eq!(it.len(), 5);

        let reversed: Vec<u32> = Countdown::new(0, 5).rev().collect();
        assert_eq!(reversed, vec![4, 3, 2, 1, 0]);
    }

    #[test]
    fn empty_range_returns_none_immediately_from_both_ends() {
        let mut it = Countdown::new(3, 3);
        assert_eq!(it.next(), None);
        assert_eq!(it.next_back(), None);
        assert_eq!(it.len(), 0);
    }
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
backward = [4, 3, 2, 1, 0]
mixed = [0, 4, 1, 3, 2]
exhausted next() = None
exhausted next_back() = None
len before consume = 5

$ cargo test
running 4 tests
test tests::empty_range_returns_none_immediately_from_both_ends ... ok
test tests::interleaved_next_and_next_back_cover_all_elements_exactly_once ... ok
test tests::next_back_only_yields_reverse_order_then_none ... ok
test tests::rev_collect_matches_expected_order_and_len_is_exact ... ok

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

🪜 Step-by-Step 解説

1
(要求1)バグの追跡 — Countdown::new(0, 3)next_back()を4回呼ぶ
修正前のコード(if self.current > self.end)で追跡します。初期状態はcurrent = 0, end = 3です。
呼び出し回数呼び出し前の状態条件 current > end結果
1回目current=0, end=30 > 3falseend=2, Some(2)を返す
2回目current=0, end=20 > 2falseend=1, Some(1)を返す
3回目current=0, end=10 > 1falseend=0, Some(0)を返す
4回目current=0, end=00 > 0false(本来止まるべきタイミング)self.end -= 1を実行 → u320 - 1整数アンダーフロー

3回目の時点ですでに[2, 1, 0]という全要素(0..3の3個)を返し終えており、current == end == 0という「両カーソルが衝突した」状態になっています。しかし条件式はcurrent > end(真の意味は「衝突を超えて逆転したか」)を見ているため、衝突しただけではまだfalseのままです。デバッグビルドではattempt to subtract with overflowというパニックになり、リリースビルドではend4294967295u32::MAX)にラップアラウンドし、以降current > endは永久にfalseのままになるため、next_back()Noneを返せず実質無限に値を返し続ける壊れたイテレータになります。

2
(要求2)修正の本質 — 「衝突」と「逆転」の違い
if self.current == self.end {  // 修正後: 衝突した瞬間に止める
    return None;
}
current <= endという不変条件が常に成り立っている前提では、「カーソルが衝突したか」を判定する唯一正しいタイミングはcurrent == endです。current > end(逆転)は、この不変条件が破られて初めて成立する状態であり、本来到達してはいけない状態です。バグ元のコードは、正常なら絶対に真にならない条件を「終了条件」として書いてしまっていたため、実質的に「終了条件が存在しない」のと同じでした。next()側の条件(self.current == self.end)とまったく同じ式をnext_back()にも使うのが、この不変条件を守る唯一の正しい書き方です。
3
(要求3)ExactSizeIterator::len()との連動
fn len(&self) -> usize {
    (self.end - self.current) as usize
}
修正前のバグはnext_back()だけの問題に見えますが、実はlen()も巻き添えを食います。バグによりendcurrentを下回ってしまうと、self.end - self.currentというu32同士の引き算がここでもアンダーフローパニックを起こします。DoubleEndedIteratorExactSizeIterator同じ内部状態を共有する複数のトレイト実装であり、1箇所の不変条件違反が他のメソッドにまで波及することを示す典型例です。修正後はcurrent <= endが常に保たれるため、len()の引き算は常に安全です。

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

📌
2段構えのオーバーフロー挙動: Rustの整数型はデフォルトでデバッグビルドではオーバーフロー時にパニックし、リリースビルドではラップアラウンドするという2段構えの挙動を持ちます。これは「バグを握りつぶして原因不明の異常値のまま実行を続けさせるより、開発中に派手に落として気づかせる」という設計判断です。C/C++の符号なし整数アンダーフローが未定義動作(何が起きるか保証されない)であるのに対し、Rustは常にどちらかの決まった挙動を保証します。今回のバグはまさにこの仕組みが機能した例で、current > endという誤った条件が、デバッグビルドでのcargo test実行時に明確なパニックとして表面化しました。
⚠️
不変条件はコンパイラの管轄外: IteratorDoubleEndedIteratorExactSizeIteratorはそれぞれ独立したトレイトでありながら、実装者には同じ不変条件(ここではcurrent <= end)を全メソッドにわたって一貫して守る責任があります。トレイトシステムはメソッドのシグネチャ(型)は検査してくれますが、「境界判定の演算子が意味的に正しいか」というロジックレベルの不変条件はコンパイラの管轄外です。これは所有権・借用チェックがコンパイル時に保証してくれる領域とは別のレイヤーであり、テスト(特に境界値テスト)でしか検出できません。本問の要求3で「片方向だけの走査」「交互の走査」「rev().collect()」という3種類のテストを書かせたのは、複数のメソッドが同じ状態を共有するときはその組み合わせパターンごとにテストする必要があることを体で覚えるためです。

🛑 パニックが出た場合(このバグはコンパイルは通る)

このバグはコンパイルは通ります。境界判定の演算子(> vs ==)はどちらもboolを返す正しい式であり、型システムはロジックの意味的な正しさまでは検査しないためです。問題はcargo test実行時、またはcargo run実行時にランタイムパニックとして表面化します。

panic: attempt to subtract with overflow --> src/main.rs:24:9 | 24 | self.end -= 1; | = note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

読み方: attempt to subtract with overflowは、符号なし整数(u32など)の引き算・複合代入減算(-=)が、表現できる範囲の下限(0)を下回ろうとしたときにデバッグビルドで発生するパニックです。エラーメッセージ自体は「どの変数が」「なぜ」負になろうとしたかまでは教えてくれないため、行番号(self.end -= 1の行)からどの減算がアンダーフローしたかを特定し、その値がなぜ0未満になり得たのか(=終了条件が正しく機能していなかったのはなぜか)を境界条件から逆算して調べるのが定石です。RUST_BACKTRACE=1 cargo testのように実行すると、パニックに至るまでの呼び出しスタックが表示され、どのテストのどの行が引き金になったかをより素早く特定できます。

🌐 他言語との比較

観点RustJavaGoJavaScript/TypeScriptC++
符号なし整数のアンダーフローデバッグビルドでパニック、リリースビルドは2の補数でラップアラウンド(常に定義された挙動)プリミティブに符号なし整数型が存在しない(intは常に符号あり)符号なし整数(uint)はラップアラウンドするのみ。パニックなし・警告なし数値型はすべて浮動小数点(Number)で整数専用型がなく、アンダーフローの概念自体が薄い(BigIntは任意精度)符号なし整数のアンダーフローは常にラップアラウンド(未定義動作ではなく規格で定義済み)。デバッグ時の自動チェックは標準では無い
両端走査イテレータの標準サポートDoubleEndedIteratorが言語標準で定義され、.rev()が全コレクションで一貫利用可能ListIterator(双方向)はあるがIteratorとは別インターフェース。コレクション実装依存標準のiter.Seqに双方向の概念はなく、逆順走査は呼び出し側でスライスを反転するのが一般的配列は.reverse()で破壊的に反転するか、.toReversed()(ES2023)で新規配列を作る。遅延的な両端イテレータの概念はないstd::reverse_iteratorがイテレータをラップして逆順にする汎用アダプタとして提供される
境界バグの検出タイミング型システムでは検出不可。デバッグビルドの実行時パニック、または境界値テストで検出該当する符号なし整数がないため同種のバグは起きにくいが、ArrayIndexOutOfBoundsException等で同様に実行時に検出実行時に静かにラップアラウンドし、テストで見つけない限りエラーにすらならず気づけないことが多い実行時にNaNや予期しない値になるが、明示的な例外は出ないため発見が遅れがち未定義動作ではないためクラッシュはしないが、境界チェックがない限りテストでしか発見できない

Goの符号なし整数はRustと同様にラップアラウンドしますが、デバッグビルドでの自動パニックという安全網が存在しないため、本問のようなバグはcargo testのような形で早期に検出されず、本番環境で初めて異常値として発覚するリスクがRustより高くなります。Rustの「デバッグ時はパニック・リリース時は定義済みラップアラウンド」という2段構えは、開発中に境界バグを見つけやすくしつつ、本番では未定義動作を出さないという、C++の未定義動作ともGoの沈黙したラップアラウンドとも異なる中間的な設計です。

🏆 実務での使いどころ

  • ページャ/カーソルベースのAPIクライアント: 「先頭から順に取得」と「末尾から遡って取得」の両方が必要なUI(無限スクロール+末尾ジャンプなど)でDoubleEndedIteratorを実装すると、片方向の走査ロジックを2重に書かずに済みます
  • collect()のパフォーマンス最適化: ExactSizeIteratorを正しく実装すると、Vec::with_capacity相当の事前確保がcollect()内部で自動的に行われ、再アロケーションのコストを削減できます。逆にlen()が不正確な値を返すと、Vecが正しくないキャパシティで確保され、余計なメモリ確保やパニックにつながることがあります
  • 境界値のプロパティベーステスト: 本問のようなカーソル共有型のバグは、0要素・1要素・偶数個・奇数個など要素数のパターンを網羅したテストでしか検出できません。実務でも「両端から消費する」データ構造(デック、スライディングウィンドウ、リングバッファ)を実装する際は、境界値テストを最初から用意するのが定石です

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
next_back()の終了条件をnext()とは別物として考えてしまい、独自の(誤った)条件式を書いてしまう「前から」と「後ろから」で処理の向きが違うため、条件式も違って当然だと思い込む両者が守るべき不変条件(current <= end、衝突したら止まる)は同一。処理の向き(currentを増やすかendを減らすか)が違うだけで、終了判定のロジックは対称的であるべき
符号なし整数の引き算は「0未満にならない」と無意識に信じてコードを書いてしまう他言語(JavaScriptのNumber、Pythonの任意精度整数)に慣れていると、整数のアンダーフローという概念自体が意識に上らないRustのu8/u16/u32/u64/usizeは0未満を表現できない型であり、0 - 1は必ずアンダーフローする。境界に近い減算を書くときは、常に「この値は本当に0以上を維持できるか」を先に確認する習慣が必要
ロジックバグはコンパイラが必ず教えてくれると思い込む借用チェッカーやライフタイムエラーなど、Rustのコンパイル時チェックの強力さに慣れすぎている所有権・借用・型の整合性はコンパイル時に検証されるが、「境界条件の演算子が意味的に正しいか」はロジックの領域でありコンパイラの管轄外。テスト(特に境界値・組み合わせのテスト)でしか検出できない

🚀 次のステップ

  • 発展: Day 017のSlidingWindowにも同じ要領でDoubleEndedIteratorを実装し、.rev()で末尾側からウィンドウを取り出せるようにしてみてください。front/backという2つのカーソルを使う場合、本問のcurrent/endよりも境界判定が複雑になる点に注意してください
  • 次回予告: 自己評価で「完全理解」または「おおむね理解」が3回連続で記録された時点で、次のテーマスマートポインタ①(Box<T>)(ヒープ確保・再帰的データ構造)に進みます

🎯 自己評価

自分の回答

気づき・メモ