🛡️

AIが提案する「防御コード」は対症療法のサイン ── シャッフル再生バグ修正で学んだこと

に公開

はじめに

AIコーディングアシスタントにバグ修正を依頼すると、「フォールバック処理を追加する」「フラグで特殊ケースに対応する」といった提案が返ってくることがある。

こうした防御コードは手軽で、テストも通る。しかし 防御コードが必要になること自体が、処理対象のデータが間違っていることを示唆している 場合がある。

この記事では、Flutter アプリのシャッフル再生バグを通じて、「防御コードの提案が出てきたら、一歩引いて考え直す」ことの重要性について書く。


起きたこと ── シャッフルモードでフレーズが1つ消える

アプリの構造

言語学習アプリを開発している。レッスンには8つのフレーズがあり、そのうち3つが「学習対象」として選ばれている。

フレーズ1: ──── (対象外)
フレーズ2: ──── (学習対象) ★
フレーズ3: ──── (対象外)
フレーズ4: ──── (学習対象) ★
フレーズ5: ──── (対象外)
フレーズ6: ──── (対象外)
フレーズ7: ──── (学習対象) ★
フレーズ8: ──── (対象外)

シャッフルモードを有効にすると、学習対象の3フレーズをランダムな順番で再生する。ところが、2フレーズしか再生されない というバグが報告された。

バグの再現条件

ウェーブフォーム(波形表示)上でユーザーが再生位置を選んでから再生を開始する。このとき、カーソルが「対象外フレーズ」の位置にあると、バグが発生する。


原因の特定

処理の流れはこうなっている。

  1. ViewModel: カーソル位置から「最も近いフレーズ」を探す(_determinePlaybackStartPosition)
  2. 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