- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — デバッグは推測ではない
- 科学的方法 — 仮説・予測・実験・観察
- 問題空間を二分探索せよ
- 最小再現例 — バグを檻に入れる
- git bisect — どのコミットが犯人か
- print vs デバッガ vs ロギング — 観察の道具
- エラーを実際に読め
- 「コンパイラのせいではない」
- ラバーダック — 声に出して説明する
- まとめ — バグ一つを最後まで
- おわりに
- 参考資料
はじめに — デバッグは推測ではない
バグが出ました。本番で決済がときどき失敗します。再現はできず、ログは曖昧で、締め切りは今日です。こういうとき、多くの開発者がやることはこうです。コードをにらみつけ、「ここが怪しいな」という行を変え、もう一度動かし、ダメならまた別の行を変え、さらに別の行を……。これを私はショットガンデバッグと呼んでいます。弾を四方八方に撒いて、いつか当たるだろうという方式です。
問題は、これがときどき効いてしまうことです。だから習慣になります。しかし運で直したバグは、なぜ直ったのか分からず、なぜ直ったのか分からなければ次にまた出会います。もっと悪いのは、「直す」過程でまったく無関係な正常なコードを三か所も触ってしまうことです。
この記事の主張はシンプルです。デバッグは科学だ。 科学者が自然を理解する方法——仮説を立て、それが真なら何が起きるかを予測し、実験で検証し、観察結果で仮説を修正する——をそのままコードに適用すればいい。運ではなく方法でバグを捕まえるのです。
科学的方法 — 仮説・予測・実験・観察
バグに出会ったときのループは、まさに科学的方法そのものです。
- 観察(observe):何が起きているか? 正確に何がおかしいか?
- 仮説(hypothesize):なぜそうなるのか、検証可能な説明を一つ立てる。
- 予測(predict):その仮説が真なら、XをすればYが観察されるはずだ。
- 実験(experiment):Xをする。
- 観察:実際にYが起きたか? 起きたなら仮説が強まり、起きなければ仮説を捨てるか修正する。
核心は3番、予測です。良いデバッグと悪いデバッグの分かれ道がここにあります。コードを変える前に、「これを変えると何が起きるはずだ」と声に出して言えなければなりません。予測なしにコードを変えるのは、実験ではなくただの賭けです。
具体的に見てみましょう。「決済がときどき失敗する」という観察から出発します。
- 悪いアプローチ:「タイムアウトかもしれない。タイムアウト値を伸ばそう」(予測なし、なぜ伸ばすのか不明)
- 良いアプローチ:「仮説:カード会社のAPIが3秒以上かかるとき、こちらのクライアントが2秒で切っている。予測:これが正しければ、失敗したリクエストのログには必ずタイムアウトエラーが記録されており、成功したリクエストはすべて2秒以内のはずだ。実験:失敗・成功リクエストの応答時間分布を取り出す」
二つ目のアプローチは、実験が失敗しても学びがあります。失敗したリクエストがタイムアウトではなく500エラーだったなら、仮説は外れましたが、原因がカード会社ではなくこちらのサーバーだと分かります。外れた仮説も情報をくれるというのが、この方法の力です。
仮説は必ず**反証可能(falsifiable)**でなければなりません。「ネットワークが変だから」は検証できないので仮説ではありません。「特定リージョンのリクエストだけ失敗する」は検証できるので仮説です。
問題空間を二分探索せよ
バグを捕まえる最強の単一技法は**二分探索(binary search)**です。ソート済み配列で値を探すときだけに使うのではありません。問題の原因を突き止めるのにそのまま使います。
原理はこうです。バグが起きる地点(症状)と、コードが正常な地点(入力)の間のどこかに原因があります。その間の中間点を一つ取って「ここまでは正常か?」を問えば、一度の実験で探索空間が半分になります。これを繰り返せば、数千行のコードも十回ほどの実験で犯人まで狭められます。2の10乗は1024ですから。
具体的に、二分探索をかけられる軸はいくつもあります。
- コードパス:リクエストがA → B → C → Dを通るなら、Cの時点で値を出力する。値が正常なら原因はC〜Dの間、おかしければA〜Cの間。
- 時間(コミット履歴):昨日は動いたが今日は動かないなら、その間のコミットを二分探索する。(これが次に出てくる
git bisectです。) - 入力データ:10万行の入力でクラッシュするなら、5万行に切って再現するか見る。再現すれば前半、しなければ後半に問題データがある。
- 設定・依存関係:フィーチャーフラグを半分ずつ切ってみる、依存を半分ずつ取り除いてみる。
二分探索が強力なのは、各実験が最大限の情報を与えるよう設計されるからです。情報理論的に言えば、半分に割る質問が一度に1ビットの情報を引き出します。適当な場所を突く実験は、これよりずっと少ない情報しか与えません。
最小再現例 — バグを檻に入れる
「再現できません」はデバッグで最もよくある壁です。ここで決定的な道具が**最小再現例(minimal reproducible example, MRE)**です。
MREはバグを引き起こす最小のコード片です。つくる過程そのものがデバッグです。アプリケーション全体から、バグと無関係な部分を一つずつ取り除きながら、なおバグが再現する最小の状態まで削り込みます。この過程で二つのうちどちらかが起きます。
- 何かを取り除いたらバグが消えた → いま取り除いたものが原因に関係している。 犯人を見つけたのです。
- すべて取り除いて20行だけ残ったのに再現する → もうこの20行だけをにらめばいい。無限に扱いやすくなりました。
MREをつくるときのルールはこうです。
- 外部依存を取り除け:DB、ネットワーク、ファイルシステムをハードコードした値に置き換える。バグがなお出るなら外部が原因ではない。
- データを最小化せよ:100件のレコードのうち1件で再現するなら、その1件だけ残す。
- 自己完結にせよ:他人がコピペしてそのまま動かせること。これができれば、同僚に聞くのも、イシューを立てるのも簡単だ。
MREをつくっている途中でバグが自然に消える経験をよくします。それは失敗ではありません。取り除く過程で原因を通り過ぎたということで、最後に取り除いたものを戻せば犯人が現れます。
git bisect — どのコミットが犯人か
「先週は動いたのに今は動かない」。この状況のための道具がgit bisectです。コミット履歴に対して二分探索を自動で回してくれる、gitの隠れた宝石です。
原理は先に見た二分探索を時間に適用したものです。正常だった古いコミット(good)と、バグのある今のコミット(bad)を教えると、gitがその中間のコミットにチェックアウトしてくれます。そこでバグがあるかテストして結果を伝えると、gitが残りの区間の中間へまた連れて行きます。コミットが1000個でも十回ほどで犯人コミット一つに狭まります。
# 二分探索を開始
git bisect start
# 今のコミットはバグがある
git bisect bad
# 3週間前のこのコミットは正常だった
git bisect good v1.4.0
# gitが中間のコミットにチェックアウトしてくれる。
# ここでバグをテストし、結果に応じて:
git bisect good # このコミットは正常
# または
git bisect bad # このコミットにもバグがある
# 繰り返すとgitが犯人コミットを指し示す:
# "abc1234 is the first bad commit"
# 終わったら元の位置に戻る
git bisect reset
本当の魔法は自動化です。バグを判定するスクリプト(例:失敗すると0以外の終了コードを返すテスト)さえあれば、git bisect runが人の介入なしに全工程を回します。
git bisect start
git bisect bad
git bisect good v1.4.0
# スクリプトがexit 0ならgood、それ以外ならbadと自動判定
git bisect run ./test-for-bug.sh
数秒後、犯人コミットが出ます。そのコミットのdiffを見れば、たいてい原因がすぐ見えます。git bisectがうまく回るにはコミットが小さく、それぞれビルド可能であるべきだという点が、コミットを細かく分けるべきもう一つの理由です。
git bisectのようなgitワークフローを手に馴染ませたいなら、Git実習場で安全にコミット・ブランチ・二分探索を練習できます。
print vs デバッガ vs ロギング — 観察の道具
仮説を検証するには、システムの内部を観察しなければなりません。観察の道具は大きく三つです。どれか一つが正解なのではなく、状況に応じて選びます。
printデバッグ
最も原始的で、最も過小評価されている道具です。print(またはconsole.log)を仕込んで値を自分の目で見ます。軽んじられがちですが、実際は強力です。
- 長所:どこでも使える。設定が要らない。値が時間とともにどう流れるかを一目で見られる(デバッガでステップを踏むより流れの把握が速いことが多い)。非同期・マルチスレッド・分散環境のようにデバッガで止めにくい場所で特に有用。
- 短所:コードを触る必要がある。消し忘れるとログが散らかる。再コンパイル・再デプロイが要ることがある。
printのコツ:何を出力するかラベルを付けよ。 print(x)よりprint("after validation, x =", x)のほうがずっと良い。値が複数あるなら一緒に出力して相関を見ます。
デバッガ
ブレークポイントを張って実行を止め、その瞬間の全状態(すべての変数、コールスタック)を覗き、一行ずつステップを踏む道具です。
- 長所:コードを変えなくてよい。止めた地点ですべてが見える。条件付きブレークポイント(「i == 4821のときだけ止める」)、ステップイン/オーバー、コールスタック確認、実行中の変数変更まで可能。複雑な状態や深いコールスタックの理解には圧倒的。
- 短所:設定が要る。非同期やタイミング依存のバグは、止めた瞬間に条件が変わって(ハイゼンバグ)再現しないことがある。本番ではたいてい使えない。
ロギング
printの大人版です。構造化されたログをレベル(DEBUG/INFO/WARN/ERROR)とともに永続的に残します。
- 長所:本番で常時動く。過ぎた事象を事後に調査できる(デバッガは今起きていることしか見えない)。レベルで騒音を調整し、構造化すれば検索・集計ができる。
- 短所:あらかじめ仕込んでおく必要がある。まさに必要なその地点にログがなければ役に立たない。量が多いとコストと騒音になる。
まとめると:再現するローカルバグの複雑な状態を掘るならデバッガ、流れを素早く見るか非同期・分散環境ならprint、本番で過ぎた事象を調査するならロギング。三つを状況に応じて行き来できる人が、本当にうまく捕まえます。
エラーを実際に読め
これは当たり前すぎて見えるのに、驚くほど守られていません。エラーメッセージを最初から最後まで実際に読んでください。 ざっと見ず、無視せず、「ああまたあれか」と流さず、本当に読んでください。
エラーメッセージとスタックトレースは、バグが自分でどこでなぜ死んだかを教えてくれる自白書です。ところが多くの開発者は、赤いテキストを見た瞬間に目を閉じて推測モードに入ります。そこに答えが書いてあるのに。
読むときに抜き出すもの:
- 正確な例外の型とメッセージ:
NullPointerExceptionかIndexOutOfBoundsExceptionかはまったく別の話。メッセージの一語一語が手がかりだ。 - ファイルと行番号:どこで壊れたかを正確に教える。
- スタックトレース:一番上が壊れた地点、下に行くほどそれを呼んだ呼び出しの連鎖だ。「自分のコード」が最初に現れる行を探せ(ライブラリの奥深くより、そこが本当の起点であることが多い)。
- 「Caused by」の連鎖:本当の根本原因は、一番下の「caused by」の後ろに隠れていることが多い。
メッセージの正確な文言をそのままコピーして検索するだけで、半分は解決します。ただしメッセージにファイルパスやIDのような自分の環境固有の値が混じっているなら、その部分は外して検索してください。
「コンパイラのせいではない」
デバッグの世界に古い格言があります。「It's never the compiler.(コンパイラのせいであることは決してない)」 コンパイラ(あるいはインタプリタ、ランタイム、標準ライブラリ、有名なフレームワーク)のせいであることは、ほとんどありません。
バグが捕まらず苛立つと、こんな考えがじわじわ湧いてきます。「これ言語のバグじゃない?」「コンパイラが最適化を間違えたんじゃ?」「このライブラリがおかしいんじゃ?」あり得ます。しかし確率的に、成熟した道具は何百万人もが毎日叩いて検証したものです。あなたの100行の新規コードと、10年間に数億回実行されたコンパイラ、どちらがバグである確率が高いでしょうか。
これが実用的に重要な理由は、「道具のせい」と結論した瞬間に探索を止めてしまうからです。本当の原因はまず間違いなく自分の仮定のどこかにあるのに、他人のせいにするとそこを見なくなります。だから基本姿勢は「犯人は自分のコード、自分の仮定、自分の理解にある」に置いてください。本当にごく稀に道具のバグのこともありますが、その結論は自分の側を残らず検証した一番最後に下すのです。
同じ流れでよくある罠を一つ。正規表現が「なぜマッチしないんだ?」というときも、たいていは正規表現エンジンではなく自分のパターンが間違っています。こういうときは正規表現テスターにパターンと入力を入れて、実際に何にマッチするかを観察するのが、頭の中で推測するより百倍速いです。
ラバーダック — 声に出して説明する
最後の技法は馬鹿げて聞こえますが、本当に効きます。ラバーダックデバッグです。机にゴムのアヒルを置いて、問題をそのアヒルに一行ずつ声に出して説明するのです。
なぜ効くのか。コードを目で読むとき、脳は「ここは当然正しいだろう」と自動的に飛ばします。その飛ばした仮定の中にバグが隠れています。ところが他人(アヒル)に説明しようとすると、その仮定を言葉にしなければならず、言葉にした瞬間に「あれ、これって本当にそうか?」と引っかかります。説明を強制されると、暗黙の仮定が明示的になります。
これが、同僚に助けを求めている途中で「あ、いや、今分かった」と言って切る、あの現象の正体です。同僚は何も言っていません。説明を準備する過程で自分で見つけたのです。アヒルはタダで、忍耐力は無限なので、人を呼ぶ前にまずアヒルに説明してみてください。
効果を高めるには、とても具体的に、とても基礎から説明してください。「この関数はユーザーを取得して……」ではなく「この関数は引数にuser_id整数を受け取り、DBにこのクエリを投げ、結果の最初の行をこのオブジェクトにマッピングして……」というレベルで。具体的なほど、隠れた仮定がよく飛び出します。
まとめ — バグ一つを最後まで
ここまでの断片を一つの流れに編んでみましょう。最初のバグ「決済がときどき失敗する」に戻ります。
- 読む:失敗したリクエストのエラーログを実際に最後まで読む。
PaymentGatewayTimeoutExceptionがスタックトレースの一番下、「caused by」の後ろにある。 - 仮説:カード会社のAPIが遅いとき、こちらが先に切っている。
- 予測:正しければ、失敗リクエストはすべてこちらのタイムアウト値の近くで死んでいるはずだ。
- 観察:失敗リクエストの応答時間を取り出す。すべてちょうど2000ms付近。仮説が強まる。
- 二分探索(時間):「いつから?」を問う。
git bisectを回す。タイムアウトを5秒から2秒に減らしたコミットが犯人として出る。 - 最小再現:カード会社を2.5秒遅延させるモックでローカルに再現。失敗が再現する。
- 修正と検証:タイムアウトを上げるか、リトライを入れる。予測:これならモック遅延を入れても成功するはずだ。実験。成功する。
各ステップに仮説と予測があり、各実験が探索空間を狭めました。運は一滴もありません。そして何より、なぜ直ったのかを正確に分かっています。これが科学者のようにデバッグするということの意味です。
おわりに
デバッグが得意な人とそうでない人の違いは、知識の量ではなく方法です。得意な人はコードをにらんで推測しません。観察し、反証可能な仮説を立て、予測し、問題空間を半分に割り、エラーが言っていることを実際に聞きます。
次にバグに出会ったら、コードを変える前に自分に問うてください。「私の仮説は何だ? これを変えると何が起きると予測する?」この二つの問いに答えられないなら、まだ実験する準備ができていません。答えられるようになった瞬間、あなたはギャンブラーではなく科学者になります。
参考資料
- アンドリュー・ハント & デイビッド・トーマス『達人プログラマー』(デバッグの章): https://pragprog.com/titles/tpp20/the-pragmatic-programmer-20th-anniversary-edition/
- デビッド・アガンス『Debugging: The 9 Indispensable Rules』: https://debuggingrules.com/
- git bisect 公式ドキュメント: https://git-scm.com/docs/git-bisect
- 「How to create a Minimal, Reproducible Example」(Stack Overflow): https://stackoverflow.com/help/minimal-reproducible-example
- ラバーダックデバッグの紹介: https://rubberduckdebugging.com/