📚 背景知識(読んでから問題へ)
Day 016〜025ではIterator・IntoIterator・FromIterator・Extend・DoubleEndedIteratorなど、様々なトレイトを自作型に実装してきました。ここまでで得た教訓のひとつは「型が合ってコンパイルが通ること」と「意味的に正しく動くこと」は別問題だ、ということです。
借用チェッカーは「同時に複数の可変参照が存在しないか」「参照がダングリングしないか」を厳密にチェックしてくれますが、あなたが書いたnext()の中身が業務要件どおりの値を返しているかまでは検証してくれません。Iteratorトレイトが要求するのは「fn next(&mut self) -> Option<Self::Item>というシグネチャを満たすこと」だけであり、「何番目の要素を返すべきか」というロジックの正しさは、型システムの外側にあるあなた自身の責任です。
今日はその典型例として、標準ライブラリのIterator::step_by(n)(n個おきに要素を取り出す)を自作するMyStepBy<I>を題材にします。
// 標準ライブラリの動作(お手本)
let v: Vec<i32> = (0..10).step_by(2).collect();
assert_eq!(v, vec![0, 2, 4, 6, 8]);
step_by(2)は「2個おき」=「インデックス0, 2, 4, 6, 8」を返します。一見単純に見えるこのロジックですが、自分でnext()を書くとoff-by-one(1個分のズレ)バグに非常に陥りやすい実装です。今日はまずバグ入りの実装を提示するので、それを見抜いて修正してください。
📝 問題
以下のMyStepBy<I>は「step個おきに要素を返すイテレータ」を意図して書かれたコードです。コンパイルは通りますが、実行すると標準ライブラリのstep_by()とは異なる結果になります。
pub struct MyStepBy<I: Iterator> {
iter: I,
step: usize,
}
impl<I: Iterator> MyStepBy<I> {
pub fn new(iter: I, step: usize) -> Self {
MyStepBy { iter, step }
}
}
impl<I: Iterator> Iterator for MyStepBy<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
let item = self.iter.next()?;
for _ in 0..self.step {
self.iter.next();
}
Some(item)
}
}
fn main() {
let v: Vec<i32> = MyStepBy::new(0..10, 2).collect();
println!("{:?}", v);
}
課題
- まず手を動かさずに、上記コードを実行した場合の出力を予測してください(
0..10に対してstep=2を渡した場合) - 実際に
cargo runして予測と照合してください。標準ライブラリの(0..10).step_by(2).collect::<Vec<i32>>()(期待値:[0, 2, 4, 6, 8])と比較し、何が・なぜズレているかを言語化してください stepが「返した要素の後に何個スキップするか」ではなく「何個おきに返すか(=ストライド幅そのもの)」を意味するようにnext()を修正してください。修正後はMyStepBy::new(0..10, 2).collect::<Vec<i32>>()が[0, 2, 4, 6, 8]と一致すること- 標準ライブラリの
step_by()はstep == 0が渡されるとパニックする仕様です(assert!でガード)。MyStepBy::new()にも同様のガードを追加してください #[test]で以下を検証してくださいstep=1のとき元のイテレータと完全に同じ結果になる(MyStepBy::new(0..5, 1).collect::<Vec<_>>() == (0..5).collect::<Vec<_>>())step=2とstep=3それぞれについて、標準の.step_by()の結果と自作MyStepByの結果が一致するMyStepBy::new(0..10, 0)を呼ぶと#[should_panic]でパニックすることを確認する
🔍 ヒント(段階的開示)
ヒント1 — 方向性
- バグ入りコードを机上で1ステップずつ追ってみましょう。「
next()が呼ばれるたびに、self.iterから実際に何個の値を取り出しているか」を数えるのがポイントです。「返す1個」と「捨てるstep個」を合計すると、1回のnext()呼び出しで消費する要素数は何個になりますか? - 標準の
step_by(2)が意図する「ストライド幅」は2(0, 2, 4, ...の間隔)です。ストライド幅と「間に捨てる個数」は同じ数ではありません
ヒント2 — アプローチ
- ストライド幅が
stepのとき、1個返した後に捨てるべき個数はstep - 1個です(step個ではありません)。例えばstep=2なら、返した直後に1個だけ捨てれば次に返す値は2個先になります Iterator::nth(n)メソッド(n番目の要素を返しつつそこまでの要素を消費する)を使うと、ループを書かずに「step - 1個スキップして次の値を取る」を1行で表現できます。self.iter.nth(self.step - 1)は「0-indexedでstep-1番目」=「step-1個捨てた次の値」を返しますstep == 0のガードは、コンストラクタnew()の先頭でassert!(step != 0, ...)として入れるのが標準ライブラリの流儀に沿っています(step_byの実際のソースも同様にコンストラクタで検証します)
ヒント3 — コード骨格
impl<I: Iterator> MyStepBy<I> {
pub fn new(iter: I, step: usize) -> Self {
assert!(step != 0, "step must be non-zero");
MyStepBy { iter, step, first_take: true }
}
}
impl<I: Iterator> Iterator for MyStepBy<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
if self.first_take {
self.first_take = false;
self.iter.next()
} else {
self.iter.nth(self.step - 1)
}
}
}
(first_takeフィールドを追加する必要があることに注意。最初の1個は「スキップなしでそのまま返す」必要があるためです)
✅ 模範解答
// このコードは cargo new day026_stepby_bugfix && cd day026_stepby_bugfix の後、
// src/main.rs をこの内容で置き換えて `cargo run` / `cargo test` で検証できます。
pub struct MyStepBy<I: Iterator> {
iter: I,
step: usize,
first_take: bool,
}
impl<I: Iterator> MyStepBy<I> {
pub fn new(iter: I, step: usize) -> Self {
// 標準ライブラリの step_by() と同じ仕様: step が 0 だとパニックする
assert!(step != 0, "step must be non-zero");
MyStepBy {
iter,
// 「返した後に何個捨てるか」を先に引いておく(step-1)。
// これが今日のバグの本質的な修正ポイント。
step: step - 1,
first_take: true,
}
}
}
impl<I: Iterator> Iterator for MyStepBy<I> {
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
if self.first_take {
// 最初の1個は無条件にそのまま返す(スキップなし)
self.first_take = false;
self.iter.next()
} else {
// nth(n) は「n個消費してからn+1個目を返す」= 「n個捨てて次を返す」
self.iter.nth(self.step)
}
}
}
fn main() {
// --- バグ入り版で何が起きていたかの再現(コメントアウトで保存) ---
// struct BuggyStepBy<I: Iterator> { iter: I, step: usize }
// impl<I: Iterator> Iterator for BuggyStepBy<I> {
// type Item = I::Item;
// fn next(&mut self) -> Option<Self::Item> {
// let item = self.iter.next()?;
// for _ in 0..self.step { self.iter.next(); }
// Some(item)
// }
// }
// バグ版で step=2 を渡すと、1回のnext()で「返す1個+捨てる2個」=計3個消費するため
// 実質ストライド幅3になり、0, 3, 6, 9 が返ってしまう(期待値は0, 2, 4, 6, 8)。
let fixed: Vec<i32> = MyStepBy::new(0..10, 2).collect();
println!("修正後 step=2: {:?}", fixed);
let std_result: Vec<i32> = (0..10).step_by(2).collect();
println!("標準ライブラリ step_by(2): {:?}", std_result);
assert_eq!(fixed, std_result);
println!("一致を確認しました");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn step_one_matches_original_iterator() {
let mine: Vec<i32> = MyStepBy::new(0..5, 1).collect();
let original: Vec<i32> = (0..5).collect();
assert_eq!(mine, original);
}
#[test]
fn step_two_matches_std_step_by() {
let mine: Vec<i32> = MyStepBy::new(0..10, 2).collect();
let std_version: Vec<i32> = (0..10).step_by(2).collect();
assert_eq!(mine, std_version);
assert_eq!(mine, vec![0, 2, 4, 6, 8]);
}
#[test]
fn step_three_matches_std_step_by() {
let mine: Vec<i32> = MyStepBy::new(0..10, 3).collect();
let std_version: Vec<i32> = (0..10).step_by(3).collect();
assert_eq!(mine, std_version);
assert_eq!(mine, vec![0, 3, 6, 9]);
}
#[test]
#[should_panic(expected = "step must be non-zero")]
fn zero_step_panics() {
let _ = MyStepBy::new(0..10, 0);
}
}
▶ 実行結果を見る(cargo run / cargo test・実機で検証済み)
$ cargo run
修正後 step=2: [0, 2, 4, 6, 8]
標準ライブラリ step_by(2): [0, 2, 4, 6, 8]
一致を確認しました
$ cargo test
running 4 tests
test tests::step_one_matches_original_iterator ... ok
test tests::step_two_matches_std_step_by ... ok
test tests::step_three_matches_std_step_by ... ok
test tests::zero_step_panics ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
main()で動かした場合の実際の出力は[0, 3, 6, 9]でした。標準ライブラリの[0, 2, 4, 6, 8]とはズレているにもかかわらず、cargo buildはエラーもワーニングも出さずに通ります。🪜 Step-by-Step 解説
バグ入り版の
next()を1回呼ぶと、内部でself.iter.next()が何回呼ばれるか数えてみましょう。step=2のとき、1回のnext()呼び出しで消費するのは1 + 2 = 3個です。つまり実質のストライド幅はstep + 1になってしまい、step=2を渡したのに「3個おき」の動作(0, 3, 6, 9)になります。これが off-by-one バグの正体です。
ストライド幅を
stepにしたい場合、1回のnext()で消費すべき合計個数はstep個(返す1個 + 捨てるstep - 1個)です。バグ版は「捨てる個数」にstepをそのまま使ってしまい、+1個多く消費していました。修正版ではコンストラクタの時点でstep - 1を計算して保持し、next()側では単純に「返す or nth(捨てる個数)」の分岐だけにすることで、この計算ミスの余地を消しています。
first_takeフラグが必要な理由修正版がバグ版と構造的に違うのは
first_takeフィールドです。バグ版は「毎回1個返してstep個捨てる」という対称的なループでしたが、正しいstep_byの意味論は「最初の要素はスキップなしでそのまま返し、2回目以降だけstep-1個捨てる」という非対称な挙動です。この非対称性を表現するには、状態(「初回かどうか」)をどこかに持たせる必要があり、それがfirst_take: boolの役割です。バグ版の対称的なループ構造そのものが、そもそもstep_byの意味論と噛み合っていなかった、というのが根本原因でした。
💡 設計思想・なぜこう書くのか
Rustの型システムと借用チェッカーが保証するのは「メモリ安全性」(ダングリングポインタ・データ競合・二重解放が起きない)であり、「あなたのアルゴリズムが要件を満たしているか」ではありません。この区別はRust学習者が誤解しやすいポイントで、「コンパイルが通った=正しい」という思い込みは危険です。
今日のバグは、for _ in 0..self.step { self.iter.next(); }という一見自然に見えるループの中に、stepとstep - 1の混同という単純な意味論のズレが隠れていた例です。型チェックはこのズレを一切検出できません。だからこそ、Rustの標準ライブラリのソースコードでもIteratorの自前実装には必ずテスト(多くの場合#[test]に加えて#[should_panic]によるエッジケース検証)が付随します。「借用チェッカーが守ってくれる領域」と「テストで自分が守るべき領域」を明確に線引きする感覚は、Rustで実務コードを書くうえで不可欠です。
nth(n)を使った修正版は、forループで1個ずつnext()を呼ぶよりも意図が明確です。「n個スキップしてから値を取る」という操作を、ループという実装詳細ではなくnth()という意味のある語彙で表現することで、次にこのコードを読む人(未来の自分を含む)が「なぜstepではなくstep-1なのか」を考える回数を減らせます。🛑 コンパイルエラーが出た場合
今日の問題はコンパイルエラーではなく実行時の論理バグです。cargo buildはバグ入り版に対してもエラー・警告なしで成功します。これは今日の学びの核心でもあります。
読み方: ビルドが成功しても、それは「型が合っている」「借用規則に違反していない」ことしか保証しません。今日のように「返す個数と捨てる個数の計算式を1個間違える」ようなロジックバグは、テストを書いて期待値と突き合わせるまで気づけません。
もし修正の途中でself.stepをusizeの範囲外に落とし込むミス(例えばstep: 0のままnth(self.step - 1)を呼んでアンダーフローする)をすると、今度は次のような実行時パニックが出ます。
読み方: usizeは符号なし整数なので0 - 1は表現できず、デバッグビルドでは「オーバーフロー検出」によりパニックします。これは今日の課題4(step == 0をnew()の時点でassert!によって弾く)が必要な理由そのものです。コンストラクタでガードしておけば、next()側でself.step - 1(stepフィールドは既にstep - 1を保持済み)のような引き算を安全に行えます。
🌐 他言語との比較
| 言語 | 「n個おきに要素を取り出す」処理の書き方 |
|---|---|
| Python | itertools.islice(iterable, 0, None, step)を使うのが標準的。自作する場合はfor i, x in enumerate(iterable): if i % step == 0: yield xのようにenumerateとインデックス判定を組み合わせることが多く、今日と同種のoff-by-oneミス(i % step == 0をi % step == step - 1と書き間違える等)が起きうる |
| Java | Streamには直接的なstepBy相当のAPIがなく、IntStream.iterate(0, i -> i + step).limit(n)のように「初期値+加算関数」で組み立てるのが一般的。加算幅と個数の計算を自分で行うため、同様の計算ミスの余地がある |
| C++ | C++23のstd::views::stride(step)で標準サポートされた(それ以前は自作かBoost.Range必須)。内部実装は今日のnth()アプローチと同様、「最初の要素はそのまま・以降はstep個進める」という非対称なロジックになっている |
| Rust | Iterator::step_by(step)が標準に存在するため実務で自作する機会は少ないが、「既存のイテレータアダプタをラップする独自イテレータ」を書く場面(例: レート制限付きストリーム処理、サンプリング処理)では今日と同じ設計パターン(first_takeフラグ + nth())が頻出する |
どの言語でも「1個目は特別扱い、2個目以降は等間隔」という非対称なロジックを実装する際にoff-by-oneが起きやすい点は共通しています。Rustが他言語と違うのは、型システムがこのバグを検出できないことをコード上で自覚しやすい(unsafeを使っていないのに「安全なのに間違っている」という状態が明示的にテストの失敗として現れる)点です。
🏆 実務での使いどころ
- ログやメトリクスのサンプリング処理(大量のイベントストリームから一定間隔で1件だけ処理する)で、既存の
Iterator/Streamをラップする独自アダプタを書く場面は多く、今日のような「最初の要素の特別扱い」を含む非対称ロジックは頻出パターンです - ページネーションAPIのラッパーで「N件ごとに1件だけプレビュー表示する」といった間引き処理を独自実装する際、今日と同じ off-by-one の罠にはまりやすい箇所です
- 「コンパイルは通るがロジックが間違っている」バグは、コードレビューでも見逃されやすい種類のバグです。今日のように「標準ライブラリの正解実装と突き合わせるテストを書く」(
assert_eq!(mine, std_version))というアプローチは、独自実装が標準の挙動を模倣すべき場面(互換レイヤーの実装等)で実務的に非常に有効です
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
cargo buildが成功したのでロジックも正しいと思い込む | Rustの型安全性・借用チェッカーの評判から「コンパイルが通ればバグがない」という過信が生まれやすい | 型チェックと借用チェックは「メモリ安全性」しか保証しない。アルゴリズムの意味論的な正しさは、型システムの管轄外であり、必ずテストで別途検証する必要がある |
for _ in 0..self.step { self.iter.next(); }のようなループは「step個スキップする」という意図どおりに見えてしまう | コード自体は文法的に自然で、変数名stepも一見正しい意味に見える | 「捨てる個数」と「ストライド幅(step)」は別の量であり、ストライド幅 = 返す1個 + 捨てる個数という関係式を明示的に意識しないと、+1のズレに気づけない |
nth(n)を「n番目の要素を返す」とだけ理解し、「途中の要素を消費する副作用」を見落とす | メソッド名から「取得(get)」的な、副作用のない操作をイメージしてしまう | Iterator::nth(n)はn個の要素を実際に消費(next()をn回呼ぶのと等価)した上で、その次の要素を返す。副作用ありのメソッドであることを理解していないと、nth()を使った修正の正しさを検証できない |
step == 0のガードをnext()側に書いてしまい、MyStepBy::new(iter, 0)の生成自体は成功させてしまう | 「エラーは使われた瞬間に検出すればよい」という発想 | 標準ライブラリのstep_by()は不正なstepを生成時点でパニックさせる設計。不正な状態を持つ値をそもそも作らせない("make invalid states unrepresentable" の精神)ほうが、後続コードが不正な値を握ったまま処理を進めてしまうリスクを防げる |
🚀 次のステップ
- 発展: 今日の
MyStepBy<I>にDoubleEndedIterator(Day 023で実装済みのトレイト)を追加実装し、後ろからもstep個おきに取り出せるようにしてみましょう。前からと後ろからで「1個目の特別扱い」の非対称性がどう影響するか、考えてみると理解が深まります - 次回予告: 自己評価で「完全理解」または「おおむね理解」が3回連続で記録された時点で、次のテーマスマートポインタ①(Box<T>)(ヒープ確保・再帰的データ構造)に進みます