お疲れ様です。花粉症がひどくて、毎日花粉と闘っているバスクリンです。
今のところ全敗です( ゚Д゚)
前回はなんとなく計算量とオーダ記法の話をしてみましたが、今回はゆるーく計算量の概算をどう判定するか見てみようと思います。
コードを読む力
仕事柄、人のプログラムを見ることが多いバスクリンです。
綺麗なコード、汚いコード、見るのも嫌になるようなコード、コードにも色々あります。
綺麗なコードというのは読むだけで概要がすぐ把握できてストレスがたまりません。汚いコードは読むのに時間がかかり、本当に正しい処理ができているのか判断に迷います。見るのも嫌になるようなコードは文字の通りです(>_<)
実は人のコードを読むのは苦手なバスクリンです。だって美しくないから。もちろん綺麗なコードもありますが、仕事で読むコードの大半は美しくありません。そこでコードを読む力が必要になってきます。
問い合わせや不具合が発見された時も素早くコードを読むことができれば仕事でも役に立ちますし、リファクタをする際の指針を決める際にも役に立つので騙されたと思ってやってみてください。
Step.1 アウトラインを見る
計算量を見る前にコードを読むときのコツは、まずアウトラインから読んでいきます。
アウトラインの話の前に、まずはプログラムってなんぞやってとこのおさらいから。
どんなに複雑なプログラムでも、制御文は大きく分けて2つしかありません(関数から抜けるreturnとかは別にして)
・条件分岐
・繰り返し構文
色々なプログラムを見てきているのでたぶん合ってます。まずはこの制御文だけ見ていきます。
まず、if文などの条件分岐ですがgotoを使っていない限り、どんな複雑な式でも1度しか判定されません。条件はアウトラインを見るときには2の次なので、省略することが多いです(あとで見ますけど)
大事なのは繰り返し構文のforやwhileのブロックです。この数や構成をまず最初に見ます。ネストが深い繰り返しや、数が多いネストは頭の中で関数化してしまいます。
for (int i = 0; i < 10; ++i) { for (int j = 0; j < 20; ++j) { hoge(); moge(); } }
は、
for (int i = 0; i < 10; ++i) { loop_j(); }
void loop_j() { for (int j = 0; j < 20; ++j) { hoge(); moge(); } }
こんな感じにするといくらネストが深くてもブロック化できるので頭の中を整理するときは有効です。
エディタが使える場合は、まず関数をコピペしてインデントや空白、括弧の位置などを自分のスタイルで整形するのも有効です。自分のスタイルに整形することで、コードのアウトラインが今までに書いたコードに類似しているかどうかを判断することができます。整形が終わるとifやforのブロックのみに着目してざっと目を通します。変数名やコメントはこの時点ではあまり着目しないようにしましょう。大事なのはアウトラインです。
Step.2 命題を見つける
次にネストの一番奥を見てみます。上記のコードだとhoge()とmoge()がやりたいことです。
この「やりたいこと」を見つけるのがソースを見る上で重要になってきます。関数やクラスは、やりたいこと(命題)に対してソースを書いているので、この命題をいかに早く正確に読み解くことができるかが重要です。
だいたいのコードはこの命題は関数の最後、またはネストの一番奥に書かれています。
1つの関数に複数の繰り返しや、条件分岐が入り乱れている場合は、最後から見ていくとすっきりします(コードの大半は、命題を処理するための前処理だったり、実行条件だったりするので最後から見ていく方が早くコードを理解できます)
関数の命題が見えてくれば、次はその前後のブロック単位で命題を探していきます。通常、命題の前後にある処理は、命題を解決するための前処理とか後処理が書かれているので、そのブロック単位の命題を最後から見ることができれば、処理概要を早く理解できます。
Step.3 シルエットから処理概要を把握する
次に繰り返しの場合は、その種類とシルエットから処理を読みます。
まず種類です。単純な繰り返しなのか、集合に対する操作なのか判断します。
for (int i = 0; i < 10; ++i) { loop_j(); }
こんな感じで初期値と終了条件が固定の場合は単純な繰り返しです。
1+1でも複雑な式でも、指定回数実行したいだけです。
DataAdapter da = new DataAdapter(con, "SELECT * FROM XXX"); DataTable dt = new DataTable("XXX"); da.Fill(dt); foreach (DataRow r in dt.Rows) { x.Code = r["Code"]; : }
こんな感じのコードや
List<CHoge> hoges; : foreach (CHoge hoge in hoges) { : }
こんな感じのコードが出てきたら、集合に対する操作です。
集合に対する操作は結構パターンが限られていて、大体似たようなシルエットになります。
List<CHoge> hoges = new List<CHoge>(); int min_value; for (int i = 0; i < hoges.Count() - 1; ++i) { if (i == 0) { min_value = hoges[i].value; } else if (hoges[i].value < min_value) { min_value = hoges[i].value; } }
こんなコードだったらhogesに格納されているCHoge.valueの最小値を求めているだけです。
if文のtrueパートが先頭要素での初期化なので、やりたいことはfalseパートということになります。
falseパートでは、条件として大小関係を比較しているので上記の例で言えば最小値を求めるコードになります。
Step.4 集合処理はSQLに置き換えてみる
集合に対する処理は、検索であったり、合計であったり、平均値を求めたり、別の配列に値を入れたりと色々な処理がありますが、シルエットとパターンさえ覚えてしまえば簡単にコードを読むことができます。
パターンがある程度わかってくれば処理をSQLに置き換えたりして読むこともしばしばあります。
集合論に対しての記述言語なのですからSQL以上にわかりやすい言語はありません。メンバーへ説明するときなどもSQLで説明するとメンバー間の意識疎通が図れます。
2400行のコードをリファクタして18行にまとめた時などはこの手法で解析しました。だいたいやりたいことは似てるので、今の時代ならジェネリック、LINQやSQLでリファクタ可能な部分が多いです。
まとめ
コードのアウトラインの把握、処理概要の把握、繰り返し構文のパターンさえ見えてくれば再帰以外の大体の計算量を概算する力がついてきます。また、この切り口は業務システムでは特に有効で、リファクタリングの前作業になったりします。
計算量の算出は集合に対して、どのような繰り返しをするかを見ていくことになるので、再帰や複雑なデータ構造でない限りすぐ計算量がわかるようになります。次回は集合を格納するコンテナと計算量について書いてみたいと思います。
プログラムとはデータに対して処理をすることが基本なので、関数名や変数名などはほとんど見ません。処理のシルエットとデータの状態遷移に注目してコードを読むことができれば早くコードを理解することができます。