こんにちは、やまもとです。
最近、YouTubeを見ていて、エンジニアの技術力は、問題解決の時にはっきりと差が出るよなぁと考えていました。
実は、エンジニアなら当たり前のように使っている考え方も、世間一般から見ると実はすごいことだったりするのかも?と思い、1つ思いついたので記録として残しておこうと思います。
エンジニアリングだけに絞っても、問題の種類は対象となる領域で様々です。例えば、インフラ・エンジニアならハードの故障や配線ミスなどが考えられますし、ネットワーク・エンジニアならケーブルの断線からプロトコルの設定も間違い、ソフトウェア・エンジニアならソフトの動作不良(いわゆるバグ)といった問題が考えられます。
ここでは、自分がソフトウェア・エンジニアなので、ソフトウェアのデバッグの場合を考えることにします。ソフトウェア開発ではインターネット上のコードをコピペして簡単に作成したりもできますが、デバッグは完全に同じ状況になることが少ないため、インターネットを検索しても簡単に解決できないことが多いです。
このような簡単に解決できない問題に対して、エンジニアはどのように考えているかを整理してみました。
大まかな問題箇所を想定する
何か問題が起こると、優秀なエンジニアは、「こういう場合は、だいたいXが怪しい」と大まかに問題の場所と原因を想定することができます。そのおかげで、手当たり次第に原因調査することがなく、問題解決までのスピードが格段に向上します。このような想定は、次のような知識や経験に基づいて、直観的に判断されます。(参考:直観)
- 昔、同じような問題に遭遇した経験
- この問題が起き得る間違った使い方の知識
前者は、過去の経験によって、同様の問題と解決方法を含む知識ネットワークが構築できているため、類推を使って直観的に判断できていると考えられます。後者は、深い学習や経験によって1段階深い理解があるため、結果から因果を推定し原因を想定するアブダクション思考が働くためと考えられます。(参考:知識ネットワーク、類推、因果メカニズム説、アブダクション)
<ソフトウェア・エンジニアリングの例>
ソフトウェア・エンジニアの場合、大抵の問題はプログラムのバグです。ただし、下の図のように、バグと言っても種類があります。
静的バグは、コンパイルの時など、プログラムを実行しないでも判明する不具合です。動的バグは、実行段階で判明する不具合で、不具合が想定される場合には、エラー処理(あるいは例外処理)が記述されています。当然、想定外のバグにはエラー処理はありません。エラー処理がなくても、割り付けられたメモリ領域外への書き込みなど、システムで検知できる不具合があります。しかし、これら全てで問題がないバグもあります。例えば、型変換ミスとか、浮動小数点数の数値誤差とかは、人間でなければまず検知できません。
そして、上図のバグの種類の違いは、エンジニアに求められる力量の違いに相当します。
静的バグは、大抵の場合、プログラムの文法上の間違いなので、プログラムの書き方を知っているだけの初級者でも対応できます。
エラー処理されたバグは、問題が発生した時、トレースバックと言われる呼び出された関数群の一覧が表示され、問題の発生箇所はおおよそ特定することができます。そのため、例外の種類や対処方法の知識を持つエンジニアであれば対応できます。
システム的に検知されたバグは、大抵の場合、その発生箇所が分かりません。そのため、問題の発生箇所を特定することから始めなければなりません。また、システム上の間違いが、どのようなプログラムで発生するのかも知っておく必要があります。
システム的にも検知できないバグは、そもそも正常と異常を人間が判別しなければなりません。その判別を行うためには、そのプログラムの背景知識が必要になります。例えば、銀行システムであれば銀行業務を知っている必要がありますし、科学シミュレーションであればベースとなる科学理論を、数値計算プログラムであれば数学的背景を知っている必要があります。
こうして見ると、デバッグで技術力の差が出る理由が分かりますね。初級者が静的バグを想定するのが精一杯なのに対し、上級者は検知不能なバグの想定もできるため、初級者に比べて上級者は大抵の問題に直観が働くのでしょう。
問題箇所を絞り込む
エラー処理がないバグだと、エラーの発生場所がわかりませんが、技術力のあるエンジニアだと大まかに問題箇所を想定することができます。そのようなエンジニアは、次に、さらに問題箇所を絞り込むことを考えます。なぜなら、問題の発生箇所を1カ所に特定できれば、原因も数個(うまくいけば一個)に絞り込むことができるからです。
絞り込みの方法には、いくつかの戦略が考えられます。手当たり次第に探索する方法(ランダム探索法)もありますが、問題発生箇所を運良く探し当てる必要があり、戦略としては良いものと言えません。よく採用される方法としては、次のような2種類があるのではないでしょうか?
- 先頭か最後尾から順番にテストする(線形探索法, linear search)
- 半分に分けてテストし、問題のある方で同じことを繰り返す(二分探索法, binary search)
ただし、エンジニアとして技術力を上げたいのであれば、後者の二分探索法を使わなければなりません。アルゴリズムに詳しい方ならご存知の通り、上記の2つの探索法は処理速度が異なります。線形探索法の計算量がO(N)なのに対し、二分探索法の計算量はO(logN)で、Nが大きいほど二分探索法の方が圧倒的に速くなります。
図2の例の場合、線形探索法は4回目で問題の場所が分かるのに対し、二分探索法では3回目で問題箇所が判明します。
<ソフトウェア・エンジニアリングの例>
例えば、次のプログラムにはバグがあります。最後の表示した結果が、想定とは違いました。あなたは、どうやって問題箇所を見つけますか?
#include <stdio.h>
int main( int argc, char** args ){
double a, b, c, d, x, y;
int e, f, g, h;
a=1.11111e0;
b=2.22222e0;
c=a+b;
d=a+c;
e = c + a * b;
f = d + a + b;
x = e * f - 2e0 * e;
y = 2e0 * f;
printf("x= %G, y= %G\n", x, y );
}
最も基本的なデバッグ技法は、計算途中の変数の値を標準出力に出力してみることです。ただ、最も基本的ですが、プログラミング関連書籍でデバッグ技法の説明を見かけたことがないので、意外と知られていないかも知れません。
基本的なデバッグ技法を知っていたとして、次の問題は標準出力に出力するprintf文をどこに仕込むかです。上記の短いコードなら手当たり次第に仕込んでも、原因箇所特定までのスピードに大差はないでしょう。しかし、もっと長大なプログラムの場合、次にprintf文を仕込む場所によって、スピードは大きく変わってきます。
あなたなら、下記のdebug code (A) ~ (C) のどのコードから挿入しますか?
#include <stdio.h>
int main( int argc, char** args ){
double a, b, c, d, x, y;
int e, f, g, h;
a=1.11111e0;
b=2.22222e0;
printf("%G, %G \n",a,b); /* debug code (A) */
c=a+b;
d=a+c;
printf("%G, %G \n",c,d); /* debug code (B) */
e = c + a * b;
f = d + a + b;
printf("%G, %G \n",e,f); /* debug code (C) */
x = e * f - 2e0 * e;
y = 2e0 * f;
printf("x= %G, y= %G\n", x, y );
}
初級者の場合、(A)プログラムの先頭から、あるいは、(C)バグ発生箇所から遡って、を選ぶことが多いのではないでしょうか?しかし、解決スピードを早くしたいのであれば、二分探索法を使用する(B)が正解になります。
二分探索法では、バグがあるコードのおよそ半分の位置にデバッグ用printf文を挿入し、プログラムを前半と後半に分け、次のように判断します。
- もし、出力した数値がおかしければ、前半にバグがある
- もし、出力した数値に問題がなければ、後半にバグがある
上記のサンプルコードでは、(B)の出力には問題がありませんので、(B)よりも後半にバグがあることが分かります。
次に、(B)からサンプルコードの最後尾までの中間に位置する(C)にprintf文を挿入して、(B)~(C)と(C)〜最後の2つに分け、(C)の前後のどちらにバグがあるのかを確かめます。上記のコードでは(C)の出力には問題があるので、判別ルールに従うとバグは(B)〜(C)の間にあることが特定できます。
このようにして、二分探索法を使うと、(B)→(C)と2ステップの確認でバグの箇所を特定できました。もし仮に、線形探索法を使って、先頭から確認して行ったとすると、(A)→(B)→(C)と3ステップの確認が必要になります。
原因を修正する
問題箇所を絞り込んだことで、原因が数個程度に絞り込まれます。しかし、その原因自体が分からなければ修正できません。そのため、最終的に、原因を特定するレベルの知識が必要になります。
<ソフトウェア・エンジニアリングの例>
前述のコードの場合、13行目と14行目にバグがあるのですが、何が原因だか分かりますか?
#include <stdio.h>
int main( int argc, char** args ){
double a, b, c, d, x, y;
double e, f, g, h;
a=1.11111e0;
b=2.22222e0;
c=a+b;
d=a+c;
e = c + a * b;
f = d + a + b;
x = e * f - 2e0 * e;
y = 2e0 * f;
printf("x= %G, y= %G\n", x, y );
}
これは、C言語の言語仕様をある程度知らないと、原因は分からないでしょう。プログラムの書き方には問題がないように見えると思います。実際、上記のプログラムは、結果はおかしいものの、きちんと動作します。
このプログラムのバグの原因は、13行目と14行目でdouble型の値をint型の変数に代入することで、自動的に型変換が起きて、小数点以下の数値が切り捨てられてしまうことです。そのため、計算結果が意図しない結果になってしまっています。ただし、このような変換を意図的に行うプログラムもあるので、実際にはプログラムの背景となる理論を知っておく必要があります。また、当然ながら、自動的な型変換を知っている必要があります。
このように原因が分かると、次のようにコードを修正すれば良いことに気がつくでしょう。もちろん、背景理論からこの型変換が意図しないものであることを確かめる必要はあります。
#include <stdio.h>
int main( int argc, char** args ){
double a, b, c, d, x, y;
double e, f, g, h;
a=1.11111e0;
b=2.22222e0;
c=a+b;
d=a+c;
e = c + a * b;
f = d +a + b;
x = e * f - 2e0 * e;
y = 2e0 * f;
printf("x= %G, y= %G\n", x, y );
}
この例の難しいところは、問題箇所(13-14行目)と修正箇所(5行目)が異なることかも知れませんね。
まとめ
この記事では、デバッグの基本的な考え方を紹介しました。
- 大まかに問題箇所を想定する
- エラー遭遇経験や間違った使い方の知識がある必要がある
- 問題箇所を絞り込む
- 二分探索法を知っている必要がある
- 原因を修正する
- 書き方だけでなく、言語仕様に踏み込んだ知識が必要になる
エンジニアにとっては普通の考え方かも知れませんが、プログラミング技術だけではデバッグは難しそうですね。
実は、デバッグの経験を積ませることが、ハイレベルな技術者を育成するポイントになるのかも知れません。