コードリーディングのモデル¶
コードリーディングとはどのような行為なのか、本演習で想定したモデルを説明します。モデルを導入することで、以降に紹介するコードリーディングの技法の位置付けが明確になり、また応用が効くようになると期待されます。
本演習ではコードリーディングとは次の5つのステップからなる作業ととらえます。
- 何かソフトウェアについて疑問を持つ。
- 既に知っていること(基底知識)に対応するソースコード箇所(コードポイント)を特定する。
- ベースポイントを選択する。- いくつもあるコードポイントからリーディングの起点(ベースポイント)を選択する。- 基底知識を駆使してどのコードポイントが適当であるか見定める。
- ベースポイントと制御フローおよびデータフローでつながるコードポイントを探す(フロー追跡)。- ここで新しい知識を獲得する。- ここでみつけたコードポイントは次のベースポイントの候補となる。
- 得た知識で疑問が解決すればリーディングを終了する。解決しなければ、2へ。
2, 3, 4は繰り返します。3で獲得した知識は次の繰り返しの基底知識として使えます。追跡した制御フロー、データフローの軌跡をコードパスと呼ぶことにします。
制御フローとは、関数の呼び出し順序関係を指します。データフローとは変数への値の代入、変数の値の参照の順序関係を指します。
モデルの一部として以下の(筆者独自の)用語を導入します。
- 基底知識
- コードポイント
- ベースポイント
- フロー
- コードパス
- プルーフポイント
- プルーフパス
- リーディング資産
- コード仮説
- チェックポイント
制御フロー追跡¶
int func1()
{
func2();
}
int func2()
{
...
}
func1はfunc2を呼び出している:
func1->func2
- 順方向の制御フロー追跡
func2はfunc1から呼び出されている:
func2->func1
- 逆方向の制御フロー追跡
データフロー追跡¶
int func3 (int * r)
{
*r += 3;
}
int func4(int *r)
{
if (r > 10)
...
}
func3で更新したrの値はfunc4で参照されている:
func3->func4
- 順方向のデータフロー追跡
func4で参照したrはfunc3で更新されている:
func4->func3
- 逆方向のデータフローの追跡
知識獲得の例¶
次のコードを題材として知識獲得について説明します。
int no_pipe;
FILE* file;
int main(int argc, char** argv)
{
...
no_pipe = 0
for (i = 0; i < argc; i++)
{
/* 例1のベースポイント */
if (!strcmp(argv[i], "--no-pipe"))
no_pipe = 1;
...
}
...
}
...
if (no_pipe)
file = make_temporary_file();
...
FILE* make_temporary_file(void)
{
/* 例2のベースポイント */
int f = mkstemp("myappXXXXXX");
if (f >= 0)
return fdopen(f, "rw");
...
}
コードリーディングでできること¶
モデルを前提とすると、コードリーディングできることは次の2つです。
- ある実行条件の元での実行結果と副作用の説明
- 実行結果や副作用に対する実行条件の説明
この2つを組合せて、ソフトウェアに対する疑問を解決します。
コードリーディングでできることは「ある実行条件の元での実行結果と副作用の説明」の例にもなっています。結論として得られた知識に注目します: 「オプション–no-pipeを与える」という条件の元で「テンポラリファイルを作成する」という副作用があることを、コードリーディングによって確定したコードパスが説明しています。
一方逆方向にフローを追跡して「実行結果や副作用に対する実行条件」を知る例は 「実行結果や副作用に対する実行条件の説明」の例にもなっています。結論として得られた知識に注目します: 「テンポラリファイルが作成された」という副作用に対して「オプション–no-pipeが与えられた」という実行条件を、コードリーディングによって確定したコードパスが説明しています。
ベースポイントからは様々なフローが延びているので、コードリーディングの過程で知りたかったこととは直接関係の無い知識を獲得することがあり得ます。すなわちハズレのコードパスも確定していきます。しかし、もしソースコードを調べてわかることであれば、ベースポイントが適切に選ぶことで最終的知りたかったことを説明するコードパスが判明するはずです。このコードパスを特にプルーフパスと呼ぶことにします。プルーフパスの終点をプルーフポイントと呼ぶことにします。
フロー追跡の方向¶
「ある実行条件の元での実行結果と副作用」を説明するには、フローを順方向に追います。すなわち実際の処理の順序とコードリーディングの読み進める順序が同じとなります。ベースポイントから開始して、関数呼び出しに遭遇すればそこで呼び出されている関数(callee)の定義を読みます。変数への代入に遭遇すれば、代入箇所以降でその変数を参照(あるいは消費)している箇所を追います。
「実行結果や副作用に対する実行条件」を説明するには、フローを逆方向に追います。すなわち実際の処理の順序とコードリーディングの読み進める順序が逆になります。ベースポイントを含む関数の呼び出し元関数(caller)の定義やベースポイントにて参照している変数へ値を代入(あるいは供給)している箇所を追います。
ソフトウェアに対する疑問の内容によっては、複数の説明を組合せて解決する必要があります。そのためコードリーディングは順方向、逆方向のフロー追跡を組合せた作業となります。
順方向にフローを追跡して「ある実行条件の元での実行結果と副作用」を知る例¶
マニュアルなどを読んで 「–no-pipeというオプションがある」こと、「文字列の比較にはstrcmpというglibc Cライブラリに含まれる関数が利用できる」ことを知っているとします。
これを基底知識とします。
ソースコード中から–no-pipeという文字列を検索して基底知識に対するコードポイントを探します。するとコマンドライン引数と“–no-pipe”という文字列を比較している箇所が見付かります。そこをベースポイントとします。
if (strcmp(argv[i], "--no-pipe") == 0)
このベースポイントから呼び出している関数の定義および変数の参照箇所を辿って行きます。コマンドライン引数に–no-pipeが指定されている場合、変数no_pipeに1が代入されます。そこで次にno_pipeの値がどこで参照されているかを追います。すると次のコードがみつかります。
if (no_pipe)
file = make_temporary_file();
no_pipeが1の場合、make_temporary_file関数を呼んでいます。そこで今度はmake_temporary_file関数の定義を見てみます。
int f = mkstemp("myappXXXXXX");
するとその定義中でmkstemp関数を呼び出しています。ここでマニュアルを調べてみると、mkstempはglibc Cライブラリに含まれていて、テンポラリファイルを作るための関数であることがわかります[1]。
ここで「オプション–no-pipeを与えるとテンポラリファイルを作成する」、という知識を獲得できます。
逆方向にフローを追跡して「実行結果や副作用に対する実行条件」を知る例¶
コードリーディングでできることの例とは逆に 「基底知識として目的のコマンドがテンポラリファイルを作成することがある」こと、「テンポラリファイルの作成にはmkstempというglibcCライブラリに含まれる関数を利用できる」こと、を持っているとします。–no-pipeについては知らない、とします。
ソースコード中でmkstempを文字列検索すると、その関数が使われていることがわかります。その呼び出し箇所をベースポイントとします。
int f = mkstemp("myappXXXXXX");
このベースポイントから関数の呼び出し元および変数の代入箇所を辿っていきます。mkstempはmake_temporary_file関数から呼び出されています。
if (no_pipe)
file = make_temporary_file();
make_temporary_file関数が呼び出されるのはno_pipeが0でない場合です。そこで no_pipe へ代入している箇所を探します。
if (!strcmp(argv[i], "--no-pipe"))
no_pipe = 1;
するとコマンド呼び出し引数の配列argvのi番目の要素 と“–no-pipe”を 文字列としてstrcmpで比較している箇所に到達します。ここでマニュアルを調べてみると、strcmpはglibc Cライブラリに含まれていて、文字列を比較するための関数であることがわかります[1]。
ここで「テンポラリファイルが作成されたのは、オプション–no-pipeが与えられたからだった」という知識を獲得できます。
ベースポイントと痕跡文字列¶
コードリーディングを開始するにはベースポイントが必要です。対象ソフトウェアについてベースポイントとして使えそうなコードポイントのストックが無い場合、ソフトウェアに対する疑問から検索文字列(痕跡文字列)を捻出できないか検討して下さい。ソースコード中から痕跡文字列を検索することで基底知識に対応するコードポイントを特定し、それをベースポイントとしてコードリーディングを開始できます。
「マニュアルに記載されていないコマンドラインオプション “–using-shm” の効用を知りたい」とか、あるいは「 “Error: Failed in memory allocation” というエラーメッセージの意味をより詳細に知りたい」、といったように、疑問が文字列とのかかわりの元で表現されている場合、その疑問が基底知識となります。そして疑問中の文字列が痕跡文字列となります。ソースコード中から起点文字列を検索することで基底知識に対応するコードポイントを特定し、それをベースポイントとしてコードリーディングを開始できます。
「マニュアルに記載されていないコマンドラインオプション “–using-shm” の効用を知りたい」、であれば 「マニュアルに記載されていないコマンドラインオプション “–using-shm” 」の存在が基底知識となります。 “–using-shm”を痕跡文字列としてコードポイントを特定します。それをベースポイントとして、フローを順方向に辿れば良いでしょう。
「 “Error: Failed in memory allocation” というエラーメッセージの意味をより詳細に知りたい」、であれば、「対象ソフトウェアは “Error: Failed in memory allocation” というエラーメッセージを出力する」という基底知識となります。 “Error: Failed in memory allocation” を痕跡文字列としてコードポイントを特定します。それをベースポイントとして、フローを順方向に辿れば良いでしょう。
痕跡文字列の捻出¶
疑問から直接に検索文字列(痕跡文字列)を捻出できない場合でも、間接的に捻出できる可能性はあります。
例として「お絵描きソフト」について表示に使っている画像のデータ構造定義を知りたいとします。痕跡文字列として使えそうな文字列がありません。しかし、例えばそのお絵描きソフトに “印刷” や “保存” といったメニュー項目があったとします。”印刷” や “保存”の処理には、その「お絵描きソフト」の表示画像のデータが必要となるはずです。
そこで「 “印刷” や “保存” といったメニュー項目がある」ということを基底知識とします。そして “印刷” や “保存” を痕跡文字列として、ソースコードを検索することでベースポイントを探します。ベースポイントがみつかれば、そこから印刷処理や保存処理を担当する関数を探します。これらの関数はメニュー項目とともにまとめてGUIライブラリに登録されていると想像されます。そこで痕跡文字列である “印刷” や “保存” がデータとしてどのように消費されているかデータフローを追う過程で関数が特定されるはずです。
保存関数あるいは印刷関数は呼び出されるときに引数として、画像のデータが渡されると期待されます。そこでそれらの関数の引数の型を確認します。この型が画像のデータ構造に対応するはずです。ここで画像を表現するのに使っている型の名前を基底知識に追加できます。
最後に ”...” を痕跡文字列としてソースコードを検索すると、データ構造定義がみつかるはずです。
この例では、 “印刷” や “保存” を痕跡文字列に選出するのに、暗黙にGUIライブラリに関する基底知識を使っています。そういった基底知識が無い場合であれば、 “image” や “pixelmap” など適当な単語を使ってみるしか無いでしょう。本当にどうしょうも無い場合は、C言語で記述されたプログラムのエントリーポイントであるmain関数の “main” を探して、そこから読むことになります。
痕跡文字列の検索に失敗する原因¶
これぞという痕跡文字列をソースコードツリー中で検索しても、どういうわけか検索がヒットしない、すなわちコードポイントがみつからない場合があります。良くあるケースを3つあげます。
コンパイル時文字列合成¶
文字列リテラルを隣接させたり、Cのプリプロセッサ(cpp)のマクロ定義で#や##を使うと、コンパイル時に文字列を合成できます。結果として痕跡文字列が “字面”として直接にはソースコードにあらわれません。文字列定数の連結とプリプロセッサ(cpp)にて詳細を説明します。
実行時文字列合成¶
fprintf関数やprintf関数などが提供する書式付き出力を使っているプログラムでは、実行時に文字列を合成します。例えば次のようなログメッセージを考えてみます:
Error: Failed in memory allocation
もし次のような関数呼び出しでこのログメッセージが出力されている場合、痕跡文字列にコードポイントは検索によってみつかるはずです。
fputs("Error: Failed in memory allocation", logfile);
ところが次のような書式付き出力関数を使っているとどうでしょう?
const char* level = "Error";
const char* resource = "memory";
fprintf(logfile, "%s: Failed in %s allocation", level, resource);
痕跡文字列を単純に検索してもコードポイントを特定できないでしょう。正規表現検索を用いるとうまく特定できるかもしれません。正規表現検索については最低限必要となるツールにて説明します。
リーディング資産とコード仮説¶
痕跡文字列を通してベースポイントを特定することを説明しました。次にフロー追跡を通したコードパスを確定する作業について考えてみます。
フロー追跡の過程¶
ベースポイントからは様々なフローが延びています。プルーフパスをみつけるのに様々なフローを試してみる必要が出てきます。フロー追跡の過程で新しい知見を示唆するいくつものコードポイントに遭遇します。遭遇したコードポイントの多くは、プルーフポイントへとつながるコードパスの上には無いかもしれません。あるコードポイントについて、プルーフポイントとかかわりがありそうだと思ったら、そこをベースポイントとして(ベースポイントの選択と移動)、再びプルーフポイントへ向けたフロー追跡を開始します。これを繰り返して結果的にプルーフポイントへ到達します。
たとえば制御フローを順方向に追跡していると、if/else文、すなわち条件分岐を目にするでしょう。各条件について異なる関数を呼んでいるとします。それぞれの関数定義を読んで新しい知見を得たら、それぞれの関数定義がコードポイントとなります。いくつもの関数定義のうち、その内容から一番プルーフポイントへ関連のありそうな関数定義を選択してそれを次のコードリーディングのベースポイントとします。
プルーフポイントがどういったものなのかはっきりと分っているわけではないので、不適当なコードポイントを次のベースポイントとして選択してしまう怖れがあります。新しいベースポイントから読み進めて手応えが無い場合には、誤ったベースポイントを選択したとも考えられます。このときは遡ってベースポイントを選択し直すところから始めなければいけません。
フロー追跡の過程でベースポイントの選択と移動を繰り返しているうちに、同じところを繰り返し読んでいることに気付いたり、あるいはプルーフポイントに迫っている感じがしない、といった理由で絶望的な気分になるかもしれません。
そのソフトウェアについてコードリーディングを開始して日の浅いうちは、プルーフポイントへ近付いているかどうかということに神経質にならずコードポイントを増やしていけていれば良いでしょう。コードポイントを増やす、ということはそのソフトウェアのソースコードに慣れ親しむということです。プルーフポイントに近付いている気がしなくとも、新しいコードポイントを獲得できていればそれは無駄とはなりません。
ある程度の数のコードポイントを稼いだら、元々何を説明しようとしてソースコードを読み始めたのかを思い出して、初期ベースポイントを選択し直して読み始めるとうまく行くかもしれません。「絶望的な気分」になるまでソースコード中をうろついたことで、多くのコードポイントとそれに対応する基底知識を獲得しているはずです。あらためて初期ベースポイントを決めるヒントとなるでしょう。
リーディング資産¶
読んでいるソフトウェアとのかかわりが一時的なものでなく、今後も繰り返し読解していくのであれば、読解の途中で得られるコードポイントをより大切に扱って下さい。いままで「ハズレ」と書いたプルーフパスに上にないコードポイントは、今抱いている疑問の解決の直接的な補助とならない場合であっても、他の疑問の解決する場合や、そのソフトウェアの包括的な理解への重要な資産(リーディング資産)となる可能性があるからです。
あらたにコードポイントを得たら、次の作業を通してコードポイントに対する理解を強化できます。
- コードパスの反芻
- どのベースポイントから読み始めてそのコードポイントに至ったか反芻します。
- 周辺の探索
- そのコードポイントから延びるフローを探索します。
- 過去の到達事例の回想
- 別の目的でソースコードを読んだときに、そのコードポイントに至ったことがないか思い出します。
- より抽象的な言葉を用いた説明
- ソースコードのレベルから離れていより抽象的な言葉でそのソフトウェアにおけるコードポイントの役割を説明できないか試します。
周辺を探索するとは、旅行において目的地に向う途中で道草を食うことと良く似ています。具体的には、知りたいこととは直接関係無くとも、既知のコードポイントに至るコードパスが無いか、新しく発見したコードポイントを起点に順方向、逆方向にいくつかのフローを追ってみます。既知のコードポイントに至るようなコードパスが発見できれば、新しく発見したコードポイントのソフトウェア全体に対する位置付けがより明確になり、そのコードパスもリーディング資産となります。
繰り返し読み、疑問の解決を繰り返していくと、いよいよ多くのコードポイントとコードパスが蓄積されます。
コード仮説とチェックポイント¶
この章の冒頭で、ベースポイントの選択について次のように書きました。
新しいベースポイントから読み進めて手応えが無い場合には、誤ったベースポイントを選択したとも考えられます。このときは遡ってベースポイントを選択し直すところから始めなければいけません。
ここでは「手応え」について説明します。
ここまで次のベースポイントとして使えそうなコードポイントを現ベースポイントからどのように目指すのか説明してきませんでした。現ベースポイントからのびている複数のフローのうちどれを読むべきでしょうか。コードポイントが増えれば、いずれ見通しが立ってくるのでどのフローを読み進めても無駄にはならないのですが、できれば大きな遠回りを避けて速くプルーフポイントに到達したいものです。適切なフローを選択して読み進めているかどうかの指標として「手応え」があります。
ここで「手応え」とは、ソースコードに関する仮説を設定し、読解を進めることでその仮説の正しさを裏付けることができたことを指します。
さらに具体的に説明するために「コード仮説」と「チェックポイント」という言葉を導入します。現ベースポイントからこの先読み進めるとどういったコードが出現するはずかを想像し仮説を立てます。これをコード仮説と呼ぶことにします。コード仮説が正しいとして、仮説中のコードが出現するコードポイントを特にチェックポイントと呼びます。仮説が十分に具体的であれば、文字列検索でチェックポイントを発見できるはずです。
チェックポイントを次のベースポイント候補と位置付けます。フローをたどるにあたり、現ベースポイントとチェックポイントの間のコードパスを確定することを目指します。確定できたときそれが「手応え」となります。逆に時間をかけて読んでも確定できないようであれば、ベースポイント、フローの選択、あるいはコード仮説の設定に誤りの可能性があります。
現ベースポイントからチェックポイントへ至るコードパスが確定したところで、コード仮説に由来する「現ベースポイントからチェックポイントへ至るコードパス」を基底知識に追加できます。
一度設定したコード仮説は固持するものではありません。リーディングの進行によってコードポイントとそれに伴う基底知識を獲得した時々で、コード仮説を更新できないか検討します。コード仮説の設定、更新とチェックポイントまでのコードパスの探索を交互に繰り返します。
はさみうち¶
フローの追跡はベースポイントから始める必要はありません。チェックポイントからベースポイントへのフローを探しても良いです。その組合せでも良いです。ベースポイントからの読むのとチェックポイントから読むのではフローを追う方向が逆となります。つながりを発見したところで、そのつながりをベースポイントからチェックポイントへのコードパスとして見直します。
コード仮説の例¶
たとえば今、counterという変数の値を条件とするif文を読んでいるとします(ベースポイント)。名前からこの変数は何かを数えた結果を保持する変数であると想像されます。もし逆方向にデータフローを追っているのであれば、どこかでその変数の値をインクリメントあるいはデクリメントをしているはずです(コード仮説)。多くのケースで次のように記述されているでしょう(インクリメントの場合)。
counter++;
あるいは
++counter;
インクリメントしているコードを検索して該当するコード行をソースコードツリー中で発見できたら、そこがチェックポイントとなります。このは例は小さすぎて、チェックポイントを見付けることと、データフローを辿ることが同じ作業になってしまっています。ただしベースポイントにあったcounterと チェックポイントとして発見したつもりのcounterが本当に同じ変数を指すのか確認する必要があります。C言語に変数名のスコープがあります。同じ変数名であってもそれぞれのスコープが異なれば、変数としては同じではありません。スコープについてはスコープにて説明します。
もしチェックポイントに相当するコードを発見できない場合、仮説を設定し直します。例えば「counter変数の値は別の変数からの代入により与えられる」というコード仮説を立て直します。
資産家のリーディング¶
対象ソフトウェアのソースコードに良く慣れ親しみリーディング資産が豊富になってくると、雪崩れ式に読むのが速くなります。その仕掛けは次のようなものです。
コードポイントを豊富に持っていると現ベースポイントと、そこからフロー的に近そうなコードポイントA(あるいはBやC)をみつけてくることができます。そのコードポイントを選んでチェックポイントとし、ベースポイントからチェックポイントの間につながりがあるというコード仮説を立てて読むことになります。元々ベースポイントから近い位置にありそうなコードポイントを選んでいるので、そのコード仮説を裏付けるコードパスを確定するのに必要となるリーディングの量も少く済むでしょう。
一度既知のコードポイントとのつながりが判明すれば、既知のコードパスを活用して、プルーフポイントに近そうなコードポイントZ(あるいはXやY)へベースポイントを移動できます。新しいベースポイントからプルーフポイントまで読み切れば、リーディング完了となります。ここで読解にあたり最初に選んだベースポイント(1)そのベースポイントと既知のコードポイントとのコードパス(2)、ベースポイントの移動先となった既知のコードポイントとプルーフポイントとのコードパス(3)、プルーフポイント(4)がリーディング資産に追加されます。
渋滞の無い高速道を使った車の運転に例えることができます。自宅から一般道を経てインターチェンジまで辿りつけば、あとはあとは目的地付近のインターチェンジまでは、速く移動できます。インターチェンジをおりてから目的地までもう一頑張り必要です。
高速道(既知のコードパス群)が国土(ソースコードツリー)全体を良くカバーしていて、しかもインターチェンジ(コードポイント)の数が豊富であれば、どこへでも素早く移動できます。
[1] | (1, 2) マニュアルの見方についてはソースコード以外の情報源にて説明します。もちろん マニュアル の内容が疑わしい場合、glibc中のmkstempやstrcmpの定義を調べたくなるかもしれません。演習の課題としています。 |