🐞

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

に公開

はじめに

本記事は "前編・中編・後編" 構成の "中編" になります。

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

前編はこちら
後編はこちら

デバッグプリントのTips

前編では、デバッグプリントの詳細や手順について説明しました。

本記事では、実際の手順に沿ってデバッグプリントを行う際の、Tipsや考え方をいくつか紹介したいと思います。

内容は大きく、以下に分けて説明していきます。

  • 処理内容別、バグの位置を絞り込むための変数の表示戦略
    • 逐次処理
    • 複数の変数の集約
    • 分岐処理
    • 繰り返し処理
  • 表示内容の工夫 (後編で)
  • その他の工夫 (後編で)

処理内容別、バグの位置を絞り込むための変数の表示戦略

ここでは、コードの変数の値を表示するデバッグプリントにおいて、
どのような考え・手順で変数の値を表示し、効果的にバグの特定を進めるか、
その戦略について一部紹介します。

今回、プログラムにおける典型的な処理である、以下のケースでの進め方をそれぞれ考えていきます。

  • 逐次処理
  • 複数の変数の集約
  • 分岐処理
  • 繰り返し処理

それぞれの処理に対し、どの変数の値を表示しどのようにバグの位置を絞り込んでいくか、
簡単な例を見ていきます。


逐次処理

ここでいう逐次処理は、複数の処理を単に順に実行していくものを考えます。
前編でも取り上げた以下の例は逐次処理のシンプルなパターンになります。

a = 1

b = test1(a)

c = test2(b)

d = test3(c)

e = test4(d)

このような処理に対してバグの位置を絞り込む場合は、前編の例でも実施したように、
処理を塊ごとに分け、その節目の変数の値を表示することで、どの処理の塊にバグがありそうかを調べていきます。
バグ有無の判断の考え方は、前編で紹介したものと同様なので割愛します。

a = 1

b = test1(a)

c = test2(b)

print(c)

d = test3(c)

e = test4(d)

print(e)

変数の値を表示する際、いくつかの進め方が考えられます。

  • 1.2分探索のように、少しずつ変数の値を表示して絞り込んでいく
    • 上記の例なら、 c, e を表示して test1, test2 または test3, test4 どちらかの塊に絞り込んみ、そのあと1つに絞り込む
  • 2.最終出力に近い変数から、さかのぼるように順に表示していく
    • 上記の例なら、e を表示し、d を表示し、... とさかのぼっていく。正しい値の変数があった時点で、それ以降の処理にバグがあると考えられる
  • 3.一度に多くの変数を表示し、複数の処理について一気にバグの有無を確かめる
    • 上記の例なら、b, c, d, e すべて表示して、test1, test2, test3, test4 それぞれのバグの有無を絞り込む

1.はどんな場面でも安定して使える方法で、バグの位置を絞り込むという目的に対して手間と効果のバランスがとれている方法だと思います。
特に、逐次処理が長く続くような大規模な処理の場合に使うとよさそうです。
一方大規模な処理の場合に3.のような方法で一気に絞り込もうとしても、コード量と表示した値とで情報が多いために結局整理に手間がかかりがちです。

2.は、コードの内容をあまり把握していない場合に使うとよいでしょう。
前編の『より最終出力に近い値から表示していく』で述べた通りで、
表示した変数の値が想定通りかを判断しやすいやり方で、確実性があります。

3.は逆に、コードを明確に把握しており、各変数の値が想定通りかをすぐに判断できる場合にオススメです。
1.の説明で述べた通り情報の整理は大変ですが、各変数の値から関連する処理にバグがあるかをすぐに判断できます。
また、関係性が明確な変数の値を一覧で表示して俯瞰することで、
『この値とその値がおかしいということは、この処理が怪しい。』
『その値に対してこの値になるのはおかしいので、この処理が怪しい。』
というような分析ができる場合もあります。


複数の変数の集約

続いて、複数の変数の集約の例について見ていきます。

ここでいう複数の変数の集約とは、
一つの式や処理の中で複数の変数を入力として用いており、
特にその各変数が別々の処理の出力結果である場合
としています。

とてもシンプルな例であれば以下のような場合です。

a = func1(x);
b = func2(y);
c = func3(z);
d = func4(a, b, c);

d という出力結果は、func4 の処理によって計算されますが、
その結果は、a,b,c の三つの値を使って計算されます。
また a,b,c はそれぞれ func1, func2, func3 の処理によって計算されます。
つまり、a,b,c という三つの計算結果の変数を集約・加工し、d という変数の値を計算・出力している例です。

このような場合はまず、最終結果に近いdの値が想定通りかどうかを確認します。

いま、dの値が想定通りの値でなかったとします。
次のステップでは、この d が想定通りの値でなかった不具合の原因を調べます。可能性があるのは、

  • a の値が想定通りではなかった
  • b の値が想定通りではなかった
  • c の値が想定通りではなかった
  • d を計算する func4 にバグがある

であり、上記のうち1つ以上が原因ということになります。

逐次処理で挙げた例との違いは、入力に相当する変数が複数 (今回の場合 a, b, c) 存在すること、
そしてその複数の変数が、それぞれ違う方法で計算されていることです。

なので、複数の変数の集約している場合のデバッグプリントおいては、
入力となる各変数をすべて出力して値が想定通りかを確認することで、バグの位置を絞り込みます。

a = func1(x)
b = func2(y)
c = func3(z)
print("a: ", a)
print("b: ", b)
print("c: ", c)
d = func4(a, b, c)
print("d: ", d) # 想定外の値

変数 a,b,c いずれの値にも問題がないと分かれば、d を計算する func4 の内容にバグがあると判断できます。
一方で変数 a,b,c の値に想定通りでないものがあれば、その変数を計算する処理にバグがあるかの調査へと移ります。

ここで重要なのが、a,b,c のうち想定通りの値であった変数とそれを計算する処理については、いったん調査する必要がなくなるという点です。

a = func1(x) # d の値が想定外であった原因ではない
b = func2(y) # d の値が想定外であった原因 => func2 の処理にバグがあるか、yの値が想定外の可能性がある
c = func3(z) # d の値が想定外であった原因ではない
print("a: ", a) # 想定通りの値
print("b: ", b) # 想定外の値
print("c: ", c) # 想定通りの値
d = func4(a, b, c)

例のような複数の変数を使って計算する処理は、関係する変数や処理が多くなります。
そのため、いざその処理結果が想定通りでなかったときは、バグがあるかもと疑うべき範囲が広くなってしまいます。

そこで、このように計算の入力となる各変数が一つ一つ想定通りの値かを確認することから始めれば、
その疑う範囲を絞ること、つまりバグの位置を絞り込むことができます。

以下の例ように、複数の変数を集約し計算する処理が、関数ではなく一つの式の場合でも考え方は同じです。

a = func1(x)
b = func2(y)
c = func3(z)
d = (-b + (b ** 2 - 4 * a * c) ** 0.5) / (2 * a)

また、式の中で直接関数を呼んでいる場合も、変数を経由していないだけで同じ考え方が適用できます。
その呼んだ関数の値を出力して想定通りかを確認することで、その関数や引数に問題があるかどうかを判断します。

a = func1(x)
b = func2(y)
c = func3(z)
print("a: ", a)
print("b: ", b)
print("c: ", c)
print("disc: ", disc(a, b, c))
d = (-b + disc(a, b, c)) / (2 * a)

分岐処理

次に、分岐処理におけるバグ位置の絞り込み、およびバグ有無の判断をする場合について見ていきます。

まず分岐処理に対するデバッグの観点は、

  • 想定通りの分岐方向に進んでいるかどうか
  • 分岐条件の値が想定通りかどうか

の二つがある、ということがポイントです。

例を見てみます。


# 中型免許取得可否の判定
age = 20
carrer = 2

if (age > 20 and carrer >= 2):
    # 処理ルート1
    # ...
elif (age > 18):
    # 処理ルート2
    # ...
else:
    # 処理ルート3
    # ...

この分岐処理のどこかに不具合の原因があることが判明している前提とします。
かつ、ここでは処理ルート1に進むことが想定通りであるとします。

まず、想定通りのルートを通ったのかを判断します。確実なのは各ルートの先頭で適当なテキストを出力する表示処理を配置することです。

# 中型免許取得可否の判定
if (age > 20 and carrer >= 2):
    print("ルート1通過")
    # 処理ルート1
    # ...
elif (age > 18):
    print("ルート2通過")
    # 処理ルート2
    # ...
else:
    print("ルート3通過")
    # 処理ルート3
    # ...

これにより、どのテキストが出力されたかを見ることで、どのルートを通ったのかを判断することができます。

その後の進め方を見ていきます。

まずパターン1、想定通りのルートを通っていた場合。
想定通りのルートを通っているのに不具合が発生しているなら、その想定通りのルート内の処理にバグがあると判断できます。

分岐処理の条件式やそこで使われた値に実は問題があり、たまたま想定通りのルートを通った可能性もありますが、
少なくとも分岐後のルート内の処理にバグがあってそれが不具合につながっているのはまず間違いないので、先にそこを潰すべきでしょう。

一方パターン2として、もし想定通りのルートを通っていなかった場合。
この場合は、想定通りのルートを通っていないことに不具合の原因があると考えてよいため、

  • 1.分岐処理の条件式にバグがあるので、想定通りのルートを通らなかった
  • 2.分岐処理の条件式の計算に使われた変数等の値が想定通りでないので、想定通りのルートを通らなかった

これらの可能性を考えることができます。
そうと分かれば、

  • 条件式の値
  • 条件式に使われたすべての変数等の値

これらを表示することで、1,2 のどちらか、または両方に問題があるかを判断できるでしょう。
以下がその例になります。

# 中型免許取得可否の判定
print("age:" age, "carrer:", carrer) # 条件式に使われたすべての値
print("condition: ", age > 20 and carrer >= 2) # 条件式の値
if (age > 20 and carrer >= 2):  # 怪しい?
    print("ルート1通過")
    # 処理ルート1
    # ...
elif (age > 18):  # 怪しい?
    print("ルート2通過")
    # 処理ルート2
    # ...
else:
    print("ルート3通過")
    # 処理ルート3
    # ...
# 出力結果
age: 20 carrer: 2
condition:  False
ルート2通過

例えばここで、変数 agecarrer の値は想定通りだったとします。
一方で変数 condition の値が想定外の False になっており、そのために想定していたルート1に入っていないことが分かります。
この場合、condition を計算する条件式の内容が疑わしくなるので、不具合の原因は条件式のバグである、というところまで絞り込むことができます。

**

今回は段階を踏んで説明しましたが、分岐処理のデバッグプリントにおいては、
一手目から以下の内容をとりあえず出力しておけば、スムーズに検証を進められるかと思います。

  • 各ルートの先頭で適当なテキストを表示(ルートごとに別のテキスト)
  • 条件式の値
  • 条件式で使われたすべての変数等の値

繰り返し処理

最後は繰り返し処理のパターンを見ていきます。

繰り返し処理のデバッグは他と比べると難易度が上がります。
また、プログラミング言語によって様々な繰り返し処理の記法があったり、内部仕様が異なったりしていて、それも難しい要因になっています。

ここでは一般的な繰り返し処理として、for文とwhile文について考えます。


for文

まず for 文の例です。
以下はよくある、List型のようなコレクションの各要素ごとに処理をするパターンです。

# 同時確率の計算
y = 1.0

for x in x_list:
    # パーセント形式から変換
    prob = percent_to_prob(x)
    y *= prob

まずは、for文による処理の、出力に相当する変数の値が想定通りかを確認し、for文の処理に何かしらのバグがあるかどうかを確認します。
上記の例の場合は、y の値がfor文の処理の出力と考えられるため、y の値を確認します。

# 同時確率の計算
y = 1.0

for x in x_list:
    # パーセント形式から変換
    prob = percent_to_prob(x)
    y *= prob

print(y)

ここで、yの値が想定外の値になっていたら、for文による処理のどこかにバグがあると判断し、さらに絞り込んでいきます。

ただ、for文のような繰り返し処理は、ある変数の値が繰り返しごとに刻々と変わっていったり、
コレクションの全ての値が計算に関わったりと、変数一つ追うだけでも大変になりがちです。

そのためfor文に対しバグの位置を特定する場合は、まず入力となる変数の値を表示し、問題がないかを確認するのがよいでしょう。
入力の変数に問題があればその原因を先に追えばよく、問題がなければfor文の処理のどこかにバグがあると判断できます。
この例の場合は、以下のように x_list の各要素の値が想定通りであるかを確認します。

# 同時確率の計算
y = 1.0

for x in x_list:
    print(x) # x_list の要素を一つ一つ表示
    # パーセント形式から変換
    # prob = percent_to_prob(x)
    # y *= prob

上記は、x_list の各要素の値を確認する一例です。
for文の中に表示のための命令を配置し、それ以外の処理をコメントアウトしています。

もちろん、x_list の中身を簡単に確認する方法が別に用意されていれば、それを用いても構いません。
例えば、numpy配列にはファイル出力用のメソッドが用意されており、それを呼び出せば配列の中身をファイルに出力して確認することができます。

大量要素のコレクションの中身確認ついて

コレクションの要素が非常に多く、いちいち確認していられない場合もあるでしょう。
そのような場合、デバッグプリントによる対応は基本的に難しくなってきます。

それでもデバッグプリントで進めたい場合の一つの方法は、出力の値をヒントにすることです。

例えば、上記の例で y の値が 0 になっていると分かり、かつそれが想定外の結果だとします。
その場合、入力の要素のいずれかに予期せぬ 0 が混ざっている可能性があり、そのため相乗処理の結果として値が 0になったという仮説を立てることができます。

y = 1.0

for x in x_list: # x_list の要素のどれかが 0 である可能性
    prob = percent_to_prob(x)
    y *= prob    # prob が一回でも 0 になれば、y も 0 になる

print(y) # 0 だった

すると、x_list0 が混ざっているかどうかを確かめる、その通りであった場合は混ざった原因を探す、という形で状況を進めることができます。

それも難しければ、次は要素数の少ないテスト用の入力を自前で用意することが考えられます。
このテスト用の入力に対して出力の値が想定通りの値にならなければ、少なくとも for文の処理にバグがあると判断できます。

y = 1.0

x_list = [10, 20, 100, 1]
for x in x_list_test: # テスト用の入力を使う
    prob = percent_to_prob(x)
    y *= prob

print(y) # 0.0002 になっていてほしい

注意として、出力が想定通りの値になったとしても、for文の処理にバグがないと断定することはできません。

テスト用の入力では問題が出ないけども、不具合が起きたときの入力では問題が出てしまう、というようなバグである可能性も考えなくてはなりません。
(テスト用の入力を用意してのデバッグについては、逐次処理や条件分岐など他の処理の場合でも同様のことが言えますが、繰り返し処理の場合はより顕著です。)

このようなケースは、for文の中身、つまり繰り返し実行される処理が複雑な場合に発生しがちなので、
いったんデバッグプリントを離れてfor文の中身の処理を抜き出し、その処理に対する単体テストを書くなどの対策をするのがよいでしょう。

先に入力を確認する場合の説明をしましたが、入力に問題がないと分かれば、
次はfor文の中身の処理にバグがないかの判断をする必要があります。

# 同時確率の計算
y = 1.0

def percent_to_prob(x):
    return x ** 0.01    # 怪しい?

for x in x_list:
    # パーセント形式から変換
    prob = percent_to_prob(x)
    y *= prob

print(y)

このときの対応としては、
まずfor文の中身の処理を抜き出して、その処理に対して入力を与えてみて想定通りの出力になるかを確認するのが堅実です。
このときの考え方は、逐次処理の例を適用することができます。

例えば以下の例では、percent_to_prob(x) に適当な入力を与えて繰り返し処理の外で呼んでみて、
そのうえで各変数の値を表示して想定通りの値になっているかを調べています。

# 同時確率の計算
y = 1.0

def percent_to_prob(x):
    return x ** 0.01    # 怪しい?

# for x in x_list:
#     # パーセント形式から変換
#     prob = percent_to_prob(x)
#     y *= prob

x = 50
prob = percent_to_prob(x)
print(prob) # 想定外の値だった。percent_to_prob の処理が怪しい?
y *= prob
print(y)

**

繰り返し処理の中身について調べるにあたって注意しなければならないのは、
繰り返し処理の場合、同じ処理を何度も繰り返したときはじめて、不具合となって現れる厄介なケースがあることです。

このような不具合が発生するケースとしては、

  • 1.繰り返しの各回で共通して扱う変数があるパターン
  • 2.繰り返し回数によって処理内容が変わるパターン
  • 3.入力の変数の値を書き換えてしまうパターン
    などが考えられます。

1.のケースは、for文を使う際によく見られるパターンなため仕方がない部分がありますが、
2,3は予期せぬバグの原因になりがちなので、そもそも設計の段階からこのような処理にならないよう避けるのが無難でしょう。

とはいえ、2,3のようなコードであることを受け入れたうえで、バグの原因を絞り込みたい場合もあると思います。
その場合は、少し力業なデバッグプリントの方法として、

  • 各繰り返し回数における変数の値を表示する
  • 特定の繰り返し回数における変数の値を表示する

これをやることでバグの原因を探る手があります。

# 同時確率の計算
y = 1.0

for i, x in enumerate(x_list):
    print(i, "回目")
    print(x)
    # パーセント形式から変換
    prob = percent_to_prob(x)
    print(prob)
    y *= prob
    print(y)
print(y)

上記は、各繰り返し回数における変数の値を表示する例です。
何回目の繰り返しかを示す値 i の変数を用意しつつ、各繰り返しにおける変数の値 x を出力しています。

これにより、何回目の繰り返しで不具合が発生したかを特定し、かつそのときの各変数の値が想定通りかを見ることで、
それをヒントにどの処理にバグがありそうかの検討をつけていきます。

一方で入力確認の例と同様で、繰り返し回数が非常に多い場合、各繰り返しごとにいちいち変数の値を確認していられない場合もあると思います。
その場合に使える方法が、特定の繰り返し回数における変数の値を表示することです。

# 同時確率の計算
y = 1.0

for i, x in enumerate(x_list):
    # パーセント形式から変換
    if i == 10:  # i = 10 のときを特別に確認
        print(i, "回目")
        print(x)
        prob = percent_to_prob(x)
        print(prob)
        y *= prob
        print(y)
print(y)

上記のようにfor文の中にif文を挿入し、特定の繰り返し回数における変数の値だけを表示します。
breakで抜けてもokです。
例えば繰り返し回数によって分岐するような処理がある場合は、その回数付近の変数の値を見てバグがありそうかを判断します。

また、このような特定の回数の変数の値の表示を何回か試し、想定通りでない値になるケースの再現を目指すのもよいでしょう。
例えば力業ですが、1万回の繰り返しに対して 5000 回目、2500 回目、1250回目、と2分探索的に変数の値の表示を試し、不具合が発生する回数の境目を見つけることも有効です。

うまく境目を見つけられれば、変数がまさに想定外の値になり始めたときの処理の流れを追うことができるので、バグの特定にグッと近づきます。

以上長くなりましたが、for文における例を見ていきました。


While文

続いて while 文の例ですが、実際はほとんどのテクニックはfor文と変わりません。
例えばfor文で紹介した例は、そもそもwhile文を使って以下のように書き換えられます。

# 同時確率の計算
y = 1.0
size = len(x_list)
i = 0
while(True):
    x = x_list[i]
    # パーセント形式から変換
    prob = percent_to_prob(x)
    y *= prob

    i = i + 1
    if i >= size:
        break

for文におけるデバッグプリントでやることとして説明した、

  • 繰り返し処理の出力に相当する変数の値を確認すること
  • 繰り返し処理で扱う入力が正常かを確認すること
  • 繰り返し処理の中身を抜き出して実行してみて、バグがないかを調査すること
  • 各繰り返し回数における変数の値を表示すること

上記の方法は、while文であっても同じように適用できるため、while文でのデバッグプリントも同じような進め方ができます。

ただ実は、for文よりもwhile文で発生しがちなバグも存在します。
ここではその例を見ていきます。

while文はfor文と比べて、以下の処理を書くことが非常に多いです。

  • 1,繰り返しを抜けるため(または継続するため)の条件文
  • 2.繰り返し回数や、配列のインデックスに使う変数の更新処理

1.は、先ほどのコード例では if i >= size: の処理が該当し、
2.は、 i = i + 1 の処理が該当します。

経験上、この二つの処理におけるバグが while文ではよく見られ、かつ予期せぬ不具合につながるパターンが多いです。

例えば 1.に関しては、条件式のバグや、条件式で使われる変数が不意に書き換えられてしまって想定外の処理になる場合などがあります。
2.に関しては、本来必要な更新処理を書き忘れてしまっていたり、更新タイミングが不適切だったりするパターンがあります。

# 同時確率の計算
y = 1.0
size = len(x_list)
i = 0
while(True):
    x = x_list[i]
    # パーセント形式から変換
    prob = percent_to_prob(x)
    i *= prob # 1の例。 条件式で使われる変数を不正に書き換えてしまうパターン

    if i >= size:
        break
    i = i + 1 # 2の例。更新のタイミングが間違っているパターン

よってwhile文におけるバグの特定に関しては、for文で説明した内容に加え、以下も追加で調査するとよいでしょう。

  • 繰り返しを継続するかを判定する処理の調査
    • while文を抜けたタイミングでの、各変数の値の表示すること
    • while文の終了・継続条件の判定に使われる式の確認、式で使われる変数の値の表示をすること
  • 繰り返し回数を示す変数やインデックスの更新処理の調査
    • 更新処理を抜き出して個別に確認すること
    • 各繰り返し回数における、繰り返し回数やインデックスを示す変数の値を表示すること

おわりに (中編)

本記事では、デバッグプリントを行う際の、Tipsや考え方の一部を紹介しました。
特に、if文やfor文など典型的な処理において、どの変数の値を表示しどのようにバグの位置を絞り込んでいくかを、例と共に見ていきました。

続く後編では、残るデバッグプリントにおける簡単な工夫について紹介します。

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

ここまでご覧いただきありがとうございました。
よければ前編、続く後編の記事もご覧いただけると幸いです。

前編はこちら
後編はこちら

Discussion