🔍

いまさらのデバッグプリントによる人力デバッグ備忘録

に公開

はじめに

ついに、AIにコードを書かせたりデバッグしてもらうことが当たり前になりつつある、そんな時代が来ました。バイブコーディングなるプログラミング手法の考え方も浸透してきているみたいです。


(出典: https://atmarkit.itmedia.co.jp/ait/articles/2509/26/news014.html)

とはいえ、今のところAIが常に理想のコードを生成したり、常に的確なデバッグができるわけでもないので、

  • 人間が、AIが生成したコードを疑って精査する・またはデバッグする

これが必要であることはしばらくは変わらなさそうです。

ただ今後LLMがさらに進化し、より品質のいいコードを生成したり、正確なデバッグができたりするようになれば、

  • 人間がコードを疑い修正する機会が減り、デバッグ能力が低下してしまう

ことも、起きうるのではないかと思っています。

AI台頭の時代であっても、人力デバッグは必要

人間がコードを疑い修正する機会が減ることついては、本来歓迎できる内容ではあります。

ですが、いかなる状況でもLLMやコーディングAIを使えるわけではないですし、
仮に使えたとしても、製品向けのコードや受託開発で納品するコードなどは、結局人間が確認・精査した証跡も含めないと、受け入れられないケースもあるのではないでしょうか。

AIが生成したコードでもそうでなくても、コードの内容を理解して精査することや、
不具合が発生したときに人力デバッグすることは、
今後も開発者にとって重要かつ必要な能力なのではないかと感じています。

本記事の目的

本記事では、古くから『デバッグプリント』と呼ばれてきた人力デバッグ手法について、
その進め方やTipsをまとめ、備忘録として残していきます。

デバッグにおける不具合原因の特定

背景として、まず開発におけるデバッグ、特に不具合の原因を特定するフェーズについて着目していきます。

不具合の原因を特定する作業は、バグやそれによる不具合を解消するためだけでなく、
設計の見直しや再発防止などの対策につなげるためにも、重要な作業になります。

デバッグにおける原因特定をうまくやるためには、コツや経験が必要な部分もあるとは思いますが、
過去の多くの猛者たちが積み重ねてきた知見やTipsも参考にできますし、デバッガを使えば簡単に進められます。

デバッグプリントによる人力デバッグ

一方で本記事の著者は、昔からデバッグは各言語の出力文 (printfとかconsole.logみたいなもの) をひたすら使い、力業で解決してきました。

この出力文を使ったデバッグ方法について、業界用語としては『デバッグプリント』という名前がついているようです。(なのでこの力業をしてきたのは著者だけではないと言い聞かせています。)

そしてこのデバッグプリントが役に立った体験が有るには有り、
取引先の環境を借りて開発をしたときや、特殊なハードウェア上で動作確認したときなどは、
デバッガが使えなかったので出力文を頼りにデバッグをしていました。

今は開発環境も進歩しているため話は変わってくると思いますが、個人的にはまだまだデバッグプリントにも一定の使いどころがあると思っています。

デバッグプリント備忘録

そこで本記事では、古から頼りにしてきたこのデバッグプリントについて、
なんとなくやっていたことを言語化する意味も込めて著者なりの進め方やTipsをまとめて備忘録として残したいと思います。

この備忘録をご覧いただいた方、そして未来の著者自身に向け、

  • AIにもデバッガにも頼れない、万が一そんな状況が訪れたとしても、人力デバッグして開発が進められるように
  • 苦労して身に着けたデバッグプリントの知見を、時代と共に失なうよりも何かしらの役に立てられるように

そんな想いを込めて、本記事を書いていきます。

注意事項

Q. デバッガ使えばいい
A. その通りなのですが、今回はご容赦ください

目次

以下を前・中・後編に分けて書いていきます。
本記事では、"(前編: 本記事)" の内容について扱います。

  • デバッグプリントの概要 (前編: 本記事)
  • デバッグプリントの詳細と手順 (前編: 本記事)
    • デバッグプリントの具体的手順
    • より最終出力に近い値から表示していく
  • デバッグプリントのTips
    • 処理内容別、バグの位置を絞り込むための変数の表示戦略 (中編 ※一番長いです)
    • 表示内容の工夫 (たぶん後編)
    • その他の工夫 (たぶん後編)

中編はこちら
後編はこちら

デバッグプリントの概要

まずデバッグプリントは、不具合発生に対する原因箇所の絞り込み・特定のフェーズにて実施します。
※ここでいう不具合発生とは、コードの実行結果が開発側が意図しない挙動となった場合とします。

開発中、書いたコードを実行してみて不具合が発生したときは、それを修復するためにまず不具合の原因を解明する必要があり、
それがコード内のバグが原因であればどの位置にバグがあるかを特定する必要があります。

ただコードが長く大規模になればなるほど、ある不具合に対する原因の候補、つまりバグを含む可能性があるコードの範囲が広くなってしまいます。
実際は設計によりこの範囲を狭める工夫はできるのですが、それでも不具合が出るような開発段階において、大量のコードからバグを探す作業が発生するケースは多いです。

デバッグプリントは、このコードからバグを探す作業において、バグの位置を一歩一歩確実に絞り込んでいくような作業になります。

デバッグの流れとデバッグプリント

デバッグプリントとは何かをざっくり説明すると、
コード上の変数の値を表示(プリント)することで、コードの範囲ごとにバグの有無を判断し、
それを繰り返すことで問題範囲を絞り込み、最終的にバグの位置を特定する方法です。

例えば、以下のような場合に役に立ちます。

  • 一通りコードを書いてコレでよし!と思っていざコードを実行したら、予想外に不具合が出た場合
  • 他者が書いたコードを引き継いだうえでデバッグをしなければいけない場合

このような、バグの位置の手がかりが薄い状態から着実に原因特定を進めたいとき、デバッグプリントは手札として役に立ちます。

デバッグプリントを含むデバッグの流れを簡単にまとめると、以下のようになります。

  1. コードを書く
  2. コンパイル・実行する
  3. 想定通り動かない (不具合が出る)
  4. 不具合原因の絞り込み・特定 (デバッグプリントによるバグ位置の絞り込み)
  5. 特定したバグを修正する
  6. 2に戻る

これを、想定通りコードが動くまで繰り返します。

次章からは、このデバッグプリントの具体的な手順やTipsを記載していきます。

デバッグプリントの詳細と手順

デバッグプリントは、コードにおけるバグの位置を少しずつ絞り込んでいく作業と述べました。
ここからその詳細と、著者の経験を参考にした具体的な手順を書いていきます。

まず、前提として以下のように考えます。

  • プログラムは基本的に、"入出力処理" と "変数の値を取り出し計算(加工)して変数に格納する処理" の連続で成り立っている。

非常に大雑把ですが、大体の場合に当てはまるのではないかなと思っています。
今回は後者の、"変数の値を取り出し計算して変数に格納する処理" における不具合の絞り込み方に着目します。

この前提をふまえたうえで、デバッグプリントでやることとしては、

  • A. ある時点の変数の値をを表示(プリント)し、それ以前までの計算処理のバグの有無を判断する
  • B. 計算前・計算後の変数の値を表示して照らし合わせることで、その計算処理のバグの有無を判断・あるいはバグを特定する

この二つが基本になってきます。
これを繰り返すことで、コードにおけるバグの位置を絞り込んでいくのが、デバッグプリントになります。

デバッグプリントの具体的手順

デバッグプリントの具体的な手順を見ていきます。

先ほど述べた通り、デバッグプリントでは変数の値を表示することで、その変数に関わった計算処理のバグの有無を判断します。

基本的に先述の、

  • A. ある時点の変数の値をを表示し、それ以前までの計算処理のバグの有無を判断する

を繰り返してバグを含む可能性がある処理の範囲を絞り込んでいき、
ある程度絞り込めたところで

  • B. 計算前・計算後の変数の値を表示して照らし合わせることで、その計算処理のバグの有無を判断・あるいはバグを特定する

に進み、ある計算処理におけるバグの内容を特定する。という流れになります。

コード例と共に見ていく

Pythonでのコード例を見ながら、具体的な手順を追っていきます。
表示のための出力命令があれば、他言語でも基本的にやり方は同じです。

A. ある時点の変数の値をを表示し、それ以前までの計算処理のバグの有無を判断する

まずは、先述した、

  • A. ある時点の変数の値をを表示し、それ以前までの計算処理のバグの有無を判断する

から見ていきます。

以下が例になります。

a = 1

b = test1(a)

c = test2(b)

print(c)    # (1)

d = test3(c)

e = test4(d)

print(e)    # (2)

スタートの a = 1 の状態から、4つの関数を経て値を加工していく例です。

  • test1 ~ test4 がそれぞれ変数を加工する計算処理になります。
  • (1), (2) の処理が、変数の表示(プリント)の処理になります。

このようにコードに変数表示の処理を追加した状態でコードを実行し、
ターミナル等で表示した結果を確認します。


そしてここから表示結果からバグの位置を絞り込んでいく考え方を見ていきます。

まず例の (1) で表示した変数 c の値を見ていきます。

  • (1) で表示した変数 c の値が、想定通りの値でなかったとする
    • ⇒ 変数 c の計算に関わる、test1(a), test2(b) の処理の少なくともどちらかが、想定通りの計算ができていない。つまりバグを含んでいる
a = 1

b = test1(a)  # この計算がおかしいかも

c = test2(b)  # この計算もおかしいかも

print(c)    # 想定通りの値ではなかった...

...
  • (1) で表示した変数 c の値が、想定通りの値であったとする
    • test1(a), test2(b) の処理は想定通りの計算ができている。つまりバグを含んでいない
a = 1

b = test1(a)  # この計算は想定通り

c = test2(b)  # この計算も想定通り

print(c)    # 想定通りの値だった

...

と考えることができます (※ここには要注意点があります。本節の最後で触れます。)

もちろん、このような判断をするには、
変数 c の値が本来どのような値であれば想定通りなのかを分かっている必要があります。
これはデバッグプリントをする上で必要な準備になります。

次に、(2) で表示した変数 e の値も見ていきます。

  • (2) で表示した変数 e の値が、想定通りの値でなかったとする
    • test1(a) ~ test4(d) のどこかの処理がバグを含んでいる
a = 1

b = test1(a)  # この計算がおかしいかも

c = test2(b)  # この計算もおかしいかも

print(c)

d = test3(c)  # この計算もおかしいかも

e = test4(d)  # この計算もおかしいかも

print(e)    # 想定通りの値ではなかった...
  • そのうえでさらに、変数 c の値が想定通りの値であったとする
    • test1(a), test2(b) はバグを含まず、test3(c), test4(d) がバグを含んでいる
a = 1

b = test1(a)  # この計算は想定通り

c = test2(b)  # この計算は想定通り

print(c)  # 想定通りの値だった

d = test3(c)  # この計算がおかしいかも

e = test4(d)  # この計算もおかしいかも

print(e)    # 想定通りの値ではなかった...

と考えることができます。また、

  • 変数 c も 変数 e も想定通りの値であったとする。
    • test1(a) ~ test4(d) はバグを含まないと考えることができ、バグは他のコード部分にある
a = 1

b = test1(a)  # この計算は想定通り

c = test2(b)  # この計算は想定通り

print(c)  # 想定通りの値だった

d = test3(c)  # この計算は想定通り

e = test4(d)  # この計算は想定通り

print(e)    # 想定通りの値だった

という風に原因を絞り込んでいくこともできます。

これが、

  • A. ある時点の変数の値をを表示し、それ以前までの処理のバグの有無を判断する。

による、バグの位置の絞り込みです。

今回の例のような関数を呼び出す処理においては、呼び出した関数の処理内容だけでなく、
関数への引数渡しや返り値の受け取りにもバグがある可能性があることにも注意が必要です。

B. 計算前・計算後の変数の値を表示して照らし合わせることで、その計算処理のバグの有無を判断・あるいはバグを特定する

続いて、

  • B. 計算前・計算後の変数の値を表示して照らし合わせることで、その計算処理のバグの有無を判断・あるいはバグを特定する

の方を実施します。

今、バグの位置を test1 もしくは test2 のどちらかまで絞り込んでいたとします。
かつ、test1, test2 の処理の内容が以下だとします。

# 値を2倍にする
def test1(x):
    y = x * 2
    return x

# 値の符合を反転させる
def test2(x):
    y = -1 * x
    return y

a = 1

b = test1(a)

print(b)    # (1)

c = test2(b)

print(c)    # (2)

(1), (2) のように変数を表示します。
b, c の値を確認することで、test1, test2 にバグがあるか判断します。
これは A. のときと同じ理屈です。

さらに、ab の値を比べる作業、bc の値を比べる作業をすることで、
それぞれ test1, test2 にどのようなバグがあるかの検討をある程度つけることもできます。

例えばここで、b の値が 1, c の値が -1 と表示されたとすると、
b の値は 2 であること想定されますがそうなっていない時点で、少なくとも test1 にバグがあることが確定します。

c の値は、 b の値が 1 であることから -1 となることが想定され、その通りになっていることから、test2 にはバグがないであろうことが判断できます。

そして、ab の値を比べると変化していないことから、test1 において値を倍にする処理が適用されないようなバグがあるかも、という仮説も立てることができます。

# 値を2倍にする
def test1(x):
    y = x * 2
    return x  # 怪しい?

# 値の符合を反転させる (問題なさそう)
def test2(x):
    y = -1 * x
    return y

a = 1

b = test1(a)  # バグ確定

print(b)    # 2 になるはずが 1 だった

c = test2(b)  # バグはなさそう

print(c)    # 想定通り -1 だった

このように、計算前・計算後の変数を表示して比べてみることで、
細かい処理単位までバグの位置を絞り込み、その処理のバグの具体的な内容のヒントも得ることができます。

ちなみに今回は、計算(加工)処理の例として関数を使いましたが、関数でない場合も同じ考え方が使えます。

以下の例のように、複数行にわたる意味のある計算処理を、ひとまとまりの処理単位として考えることができます。
この例の場合は、a,b,c の値と avg の値を、avg の値と var の値の比べることで、その間の計算処理のバグの有無を検査できます。

a = 3
b = 2
c = 4

total = (a + b + c)
avg = total / 3 

print(avg)

var = 0
for x in [a, b, c]:
    var += (x - avg) ** 2
var = var / 3

print(var)

より最終出力に近い値から表示していく

前節でデバッグプリントの具体的な進め方を説明しましたが、このようにデバッグプリントではいくつもの変数の値を表示します。

そしてデバッグプリントをするときは、より最終出力に近い値から表示していくのがオススメです。
※ここで最終出力とは、コード全体が一連の順序で実行されていくとして、その最後の最後に出力・算出される変数や値のことを指します。

理由は、あるコード実行時に不具合が出たケースにおいては、基本的にそのコードの最終出力が想定通りでないことから不具合が出たと判断するためです。

前節の "A. ある時点の変数の値をを表示し、それ以前までの計算処理のバグの有無を判断する" の説明の中で、
デバッグプリントをするには表示した変数の値が、本来どのような値であれば想定通りなのかを分かっている必要があると書きました。

しかし、細かく処理を区切ったとき、その処理の出力(変数)の想定通りの値を、すぐに定められない場合もあると思います。

ただ先ほど述べた通り、
コードの不具合の調査をしている時点で、そのコードの最終出力が想定通りでないという判断をしたはずであり、そのため少なくともコードの最終出力の想定通りの値は、分かっているケースが多いです。
なので、最終出力から見ていけば、確実にデバッグプリントを進めていくことができます。

具体的には、その最終出力の値を計算する処理(Pとする)の中身を調査すれば

  • その処理Pのバグが原因で、想定通りの値が算出されなかった
  • その処理Pバグはないが、処理Pの入力値(計算前の変数の値)Xが想定通りでなかったため、最終出力も想定通りでなかった

上記のどちらなのかを判断することができます。

前者の場合はまず、そのバグを取り除けばOKです。
後者の場合は、"その入力値Xがどんな値であれば最終出力が想定通りの値になるか = 入力値Xの想定通りの値" を処理Pの内容から定めることができます。そしてその入力値Xを計算するための処理Qが前段にあるはずなので、今度はその処理Qの中身の調査に移ります。

このようにさかのぼって繰り返すことで、コード上の各変数の想定通りの値を定めることができ、デバッグプリントを進めていくことが可能になります。

# (1) ~ (4) の順に調べていく
a = 1

b = test1(a)

c = test2(b)  # (4) test2 の計算内容に問題ないなら、`c` の値がおかしい理由は `b` がおかしいから

d = test3(c)  # (3) test3 の計算内容に問題ないなら、`d` の値がおかしい理由は `c` がおかしいから

e = test4(d)  # (2) test4 の計算内容に問題ないなら、`e` の値がおかしい理由は `d` がおかしいから

print(e)      # (1) 最終出力 (想定通りでない値)

別のケースの例として、
テストの失敗を検知し、その原因を探すためデバッグする場合においても、同じ方法が使えます。
テストにおいては、"出力がこの値であればテスト成功" という条件が定められているはずなので、
その出力を最終出力に見立てれば同じ考え方が通用するためです。

このように、デバッグプリントにおいては大抵の場合、最終出力に近い値から表示し確認していくことでバグに迫っていく のがやりやすいです。

おわりに (前編)

本記事 (前編) では、

  • デバッグプリントの概要 (前編: 本記事)
  • デバッグプリントの詳細と手順 (前編: 本記事)
    • デバッグプリントの具体的手順
    • より最終出力に近い値から表示していく

の内容を見ていきました。

続いて投稿する中編・後編は、以下の内容を扱う予定です。

  • デバッグプリントのTips
    • 処理内容別、バグの位置を絞り込むための変数の表示戦略 (中編 ※一番長いです)
    • 表示内容の工夫 (たぶん後編)
    • その他の工夫 (たぶん後編)

中編では、コードにおけるif文やfor文などの処理内容別に、デバッグプリントを進める際のTipsや考え方をいくつか紹介したいと思います。

長くなりましたがここまで読んでいただきありがとうございます。
よろしければ、引き続き中編・後編もご覧いただければ幸いです。

中編はこちら
後編はこちら

Discussion