AIが提案する「防御コード」は対症療法のサイン ── シャッフル再生バグ修正で学んだこと
はじめに
AIコーディングアシスタントにバグ修正を依頼すると、「フォールバック処理を追加する」「フラグで特殊ケースに対応する」といった提案が返ってくることがある。
こうした防御コードは手軽で、テストも通る。しかし 防御コードが必要になること自体が、処理対象のデータが間違っていることを示唆している 場合がある。
この記事では、Flutter アプリのシャッフル再生バグを通じて、「防御コードの提案が出てきたら、一歩引いて考え直す」ことの重要性について書く。
起きたこと ── シャッフルモードでフレーズが1つ消える
アプリの構造
言語学習アプリを開発している。レッスンには8つのフレーズがあり、そのうち3つが「学習対象」として選ばれている。
フレーズ1: ──── (対象外)
フレーズ2: ──── (学習対象) ★
フレーズ3: ──── (対象外)
フレーズ4: ──── (学習対象) ★
フレーズ5: ──── (対象外)
フレーズ6: ──── (対象外)
フレーズ7: ──── (学習対象) ★
フレーズ8: ──── (対象外)
シャッフルモードを有効にすると、学習対象の3フレーズをランダムな順番で再生する。ところが、2フレーズしか再生されない というバグが報告された。
バグの再現条件
ウェーブフォーム(波形表示)上でユーザーが再生位置を選んでから再生を開始する。このとき、カーソルが「対象外フレーズ」の位置にあると、バグが発生する。

原因の特定
処理の流れはこうなっている。
-
ViewModel: カーソル位置から「最も近いフレーズ」を探す(
_determinePlaybackStartPosition) - UseCase: 見つかったフレーズを起点にシャッフル順を決定し、再生を開始する
問題は ステップ1 にあった。
// ViewModel: カーソル位置から最も近いフレーズを探す
for (final phrase in lesson.phrases) { // ← 全8フレーズから検索
if (phrase.startMs <= centerPositionMs) {
targetPhrase = phrase;
}
}
// → P3(対象外フレーズ)が startWithPhrase として返される
ViewModel は全8フレーズから検索するため、カーソルが P3 の位置にあると P3 を返す。しかし UseCase は学習対象の3フレーズだけを管理しているため、P3 を起点としたシャッフルインデックスの計算が狂い、1フレーズが消えていた。
AIの最初の提案 ── フォールバックで特殊対応
AIにバグを報告したところ、最初の修正案はこうだった。
// AIの提案: UseCase側にフォールバック処理を追加
int startIndex = targetPhrases.indexOf(startWithPhrase);
if (startIndex == -1) {
// startWithPhraseが学習対象に含まれない場合のフォールバック
startIndex = _findNearestTargetIndex(startWithPhrase, targetPhrases);
_logger.w('startWithPhrase not in target list, falling back to nearest');
}
「受け取り側で、想定外の値が来たときにフォールバックする」。一見、堅牢な防御的プログラミングに見える。
もちろん、防御的プログラミングそのものが悪いわけではない。外部APIとの境界やユーザー入力の検証 など、信頼できないデータが入ってくる場所ではフォールバックは必要だ。しかし、内部ロジックのメソッド間呼び出し でフォールバックを多用するのは、責任の所在を曖昧にし、バグの根本原因を隠蔽してしまう。
この提案には2つの問題がある。
問題1: 受け取り側の責任が増える
UseCase は「学習対象フレーズのリスト」と「その中の開始フレーズ」を受け取る。渡されたフレーズがリストに含まれていることは、呼び出し側の責任 だ。
受け取り側でフォールバックするのは、誰かが壊した窓を、通行人が毎回テープで塞いでいるようなものだ。
問題2: 「最も近い学習対象」の判定が曖昧
フォールバックで「最も近い学習対象フレーズ」を探す処理を追加すると、「近い」の定義(時間的に前?後?)や、先頭・末尾のエッジケースなど、本来不要な判断ロジック が増える。
立ち止まって考える ── なぜ対象外フレーズが渡されるのか?
防御コードを追加する前に、そもそもの疑問に立ち返った。
なぜ UseCase に「対象外フレーズ」が渡されるのか?
答えは単純だった。ViewModel の _determinePlaybackStartPosition が 全フレーズ を検索対象にしていたからだ。
そして同じ ViewModel 内の他のメソッドを見ると、すでに解決パターンが存在していた。
// jumpToNextPhraseStart(): 学習対象のみを検索
for (final phrase in lesson.phrases) {
if (phrase.phraseType != PhraseType.target) continue; // ← フィルタ済み
// ...
}
// jumpToPreviousPhraseStart(): 同様にフィルタ済み
for (final phrase in lesson.phrases) {
if (phrase.phraseType != PhraseType.target) continue;
// ...
}
「次のフレーズへジャンプ」「前のフレーズへジャンプ」は学習対象のみを対象にしているのに、「開始位置の決定」だけが全フレーズを対象にしていた。同じクラス内で、同じデータに対するフィルタリングが一貫していなかった。
根本修正 ── 検索対象を正しく絞る
修正は、メソッドの冒頭でフィルタリングするだけだった。
// Before: 全フレーズから検索
if (lesson.phrases.isEmpty) return null;
// After: 学習対象フレーズのみから検索
final targetPhrases = lesson.phrases.where((p) => p.isTarget).toList();
if (targetPhrases.isEmpty) return null;
以降のループをすべて lesson.phrases → targetPhrases に置き換えるだけ。防御コードは一切不要。
ビフォー・アフター
Before(フォールバック対応案):
ViewModel → 全フレーズから検索 → P3(対象外)を返す
UseCase → P3 が見つからない → フォールバック処理 → 近似値で対応
リスク: エッジケース、曖昧な「近い」の定義、追加テスト
After(根本修正):
ViewModel → 学習対象のみから検索 → P2(学習対象)を返す
UseCase → P2 で正常にシャッフル開始
リスク: なし
この事例から見えるパターン
防御コードが出てきたら疑うべきこと
今回のケースを一般化すると、こうなる。
1. 関数Aが「間違ったデータ」を関数Bに渡す
2. 関数Bで「間違ったデータが来たときの分岐」を追加する(防御コード)
3. → 本来やるべきこと: 関数Aが「正しいデータ」を渡すようにする
前回の事例(冪等性)との共通点
前回の記事では、AIが _isPreparing フラグで二重呼び出しを抑え込んだ事例を書いた。前回は「フラグ」、今回は「フォールバック」── 手段は違うが、どちらも受け取り側で症状を抑える防御コードだ。
| 前回(冪等性) | 今回(シャッフル) | |
|---|---|---|
| 症状 | DB競合エラー | フレーズが1つ消える |
| AIの防御コード |
_isPreparing フラグで二重実行を防止 |
フォールバックで最近接フレーズを探す |
| 提案の本質 | 「2回呼ばれても大丈夫にする」 | 「間違った値が来ても大丈夫にする」 |
| 根本原因 | 呼び出し元が2箇所あった | 検索対象に不要なデータが含まれていた |
| 根本修正 | 呼び出し元を1箇所に集約 | 検索対象を学習対象のみにフィルタ |
どちらも 「防御コードを足す」のではなく「問題が起きない構造にする」 ことで解決している。
既存コードにヒントがある
今回の修正で特に重要だったのは、同じクラス内に正解パターンがすでに存在していた ことだ。
jumpToNextPhraseStart と jumpToPreviousPhraseStart は学習対象のみをフィルタしていた。_determinePlaybackStartPosition だけがフィルタを忘れていた。
AIに「他のメソッドではどうしている?」と聞くだけで、防御コードではなく根本修正にたどり着ける。コードベース内の 一貫性のチェック は、AIの対症療法を防ぐ有効な手段だ。
教訓
1. 「防御コード」は黄色信号
AIが提案するフォールバックやフラグ追加は、多くの場合「症状を抑える」対応だ。防御コードが必要になる理由を掘り下げると、データの入口や処理対象の選び方に問題が見つかることが多い。
2. 受け取り側ではなく、渡す側を直す
関数に「想定外の値が来たときの分岐」を追加するより、そもそも想定外の値を渡さない ほうが安全でシンプルだ。
防御的プログラミングには明確な 使い所の区別 がある。
| 場所 | フォールバック | 理由 |
|---|---|---|
| 外部APIとの境界 | 必要 | 相手の挙動を制御できない |
| ユーザー入力の検証 | 必要 | 何が来るか予測できない |
| 内部ロジックのメソッド間 | 不要(原則) | 呼び出し側が正しいデータを保証すべき |
内部ロジックでフォールバックを書くと、「誰が正しいデータを保証する責任を持つのか」が曖昧になり、バグの発見が遅れる。
3. 同じクラス内のパターンを確認する
バグ修正の前に、同じクラスや同じモジュール内の類似メソッドを確認しよう。すでに正しいパターンが存在しているなら、それに合わせるだけで済む。AIに修正を依頼するときも、「他のメソッドではどう処理している?」と聞くことで、一貫性のある修正に導ける。
4. AIへの問いかけを変える
| 問いかけ | AIの反応 |
|---|---|
| 「このバグを直して」 | 防御コード(フォールバックやフラグ)で症状を抑える |
| 「なぜこの値が渡されるのか?」 | データの流れを追い、入口の問題を見つける |
| 「他のメソッドではどうしている?」 | 既存パターンとの一貫性を確認する |
まとめ
AIコーディングアシスタントは、目の前のエラーを素早く解消する能力に優れている。しかしその「素早さ」が、対症療法的な修正を生みやすい。
フォールバック、フラグ、特殊ケース分岐 ── こうした防御コードが提案に含まれていたら、立ち止まって「なぜそれが必要なのか?」を問い直そう。多くの場合、データの入口を正せば、防御コードは不要になる。
入口が正しければ、出口は自然と正しくなる。
Discussion