FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

「リーダブルコード」を読む (第13章「短いコードを書く」第14章「テストと読みやすさ」)

「リーダブルコード」読書会。名著と呼ばれており、私も一度さっくりと読んだ。 ただし忘れていることもあるし、体系的に学びたいので読み直す。

例によってJupyter Notebookに書いてまとめをブログに張っていくことにする。

今回は「第13章 短いコードを書く」「第14章 テストと読みやすさ」までをまとめた。

まとめるといっても、本書は言いたいことを非常に簡潔に要約しており、ほぼほぼそれを写経したようなものになっているが、まあ自分で手を動かさないと分からないということで。。。

リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック (Theory in practice)

リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック (Theory in practice)


第13章 短いコードを書く

ライブラリの再利用や機能の削除をすることで、時間を節約したり、コードを簡潔に維持したりできる。

鍵となる考え: 最も読みやすいコードは、何も書かれていないコードだ

13.1 その機能の実装について悩まないで~きっと必要ないから

プロジェクトの開始時に、不必要な機能を過剰に見積もってしまいがちである。

プロトタイプの実装にかかる時間を楽観的に見積もったり、将来的に必要となる保守や文書化などの「負担」時間を忘れたりする。

13.2 質問と要求の分割

例: 店舗検索システム

商用の「店舗検索システム」を作っているときに、「任意のユーザの緯度経度に対して、最も近い店舗を検索する」。

  • 日付変更線をまたいでいるときの処理
  • 北極や南極に近い時の処理
  • 「1マイル当たりの緯度」に対応した地球の曲率の調整

上記をまじめに実装すると非常に複雑なコードになるが、テキサス州の30件の店舗のみを考えるのであれば、上記のような複雑な処理は不要である。

キャッシュを追加する

ディスクから頻繁にオブジェクトを取得するアプリケーションについて、キャッシュを実装したいと思っている。

read Object A
read Object A
read Object A
read Object B
read Object B
read Object C
read Object D
read Object D

LRU方式を使おうと思っていたが、手持ちにライブラリが無いので自作する必要がある。 しかし実際にはアクセスが必ず順番に行われているので、単方向キャッシュ(one-item cache)を実装することにする。

DiskObject lastUsed;  // クラスのメンバ

DiskObject lookup(Stirng key) {
   if (lastUsed == nil || !lastUsed.key().equals(key)) {
       lastUsed = loadDiskObject(key);
   }
   return lastUsed;
}

LRU方式を使うときの90%の効果があった。これでも十分であると判断することもできる。

13.3 コードを小さく保つ

プロジェクトが成長しても、コードをできるだけ小さく軽量に維持することが大切である。

  • 汎用的な「ユーティリティ」コードを作って、重複コードを削除する。
  • 未使用のコードや無用の機能を削除する。
  • プロジェクトをサブプロジェクトに分割する。
  • コードの「重量」を意識する。軽量で機敏にしておく。

13.4 身近なライブラリに親しむ

たまには標準ライブラリのすべての関数・モジュール・方の名前を15分かけて読んでみよう。 新しいコードを書いているときに「ちょっと待てよ。これは、APIで見たような。。。」と思い出すことが出来る。

例: Pythonのリストとセット

Pythonのリストで、重複を許可しないようにするためには、unique()関数を実装しても良いが、set型を使うほうが便利である。

unique_elements = set([2,1,2])  # 重複の削除

ライブラリの再利用はなぜいいことなのか

平均的なソフトウェアエンジニアが1日に書く出荷用のコードは、10行なのだそうだ。 ダーウィンの進化を生き延びてきたコードには大きな価値がある。 これがライブラリの再利用が良しとされている理由の一つである。 時間の節約にもなるし、書くコードも少なくなる。

13.5 例: コーディングをするよりも、Unixツールボックスを使う

HTTPのレスポンスを処理するコードの中から、url-pathを見つけるプログラムはC++Javaのような言語で書くと軽く20行を超えてしまう。 一方で、Unixではコマンドで以下のように入力すればよい。

cat access.log | awk '{print $5 " " $7}' | egrep "[45]..$" \
| sort | uniq -c | sort -nr

コマンドラインが素晴らしいのは、「本物」のコードを書かなくてもいいということだ。 それから、ソース管理にチェックインしなくても済む。

第14章 テストと読みやすさ

14.1 テストを読みやすくて保守しやすいものにする

テストコードというのは「本物のコードの動作と使い方を示した非公式な文書」だと考えるプログラマもいるほどである。

鍵となる考え : 他のプログラマが安心してテストの追加や変更が出来るように、テストコードを読みやすくする。

テストコードが大きくて恐ろしいものだとしたら、以下のようなことが起きる。 - 本物のコードを修正するのを恐れる。 - 新しいコードを書いたときにテストを追加しなくなる。

14.2 このテストのどこがダメなの?

検索結果のスコアをソートしてフィルタする関数がある。

// 'docs'をスコアでソートする(降順)。マイナスのスコアは削除する
void SortAndFilterDocs(vector<ScoredDocument>* docs);

最初のテストは以下のようになっていた。このテストコードには少なくとも8つの問題がある。本章が終わるまでに全部見つけて修正する。

void Test1() {
  vector<ScoredDocument> docs;
  docs.resize(5);
  docs[0].url = "http://example.com";
  docs[0].score = -5.0;
  docs[1].url = "http://example.com";
  docs[1].score = 1;
  docs[2].url = "http://example.com";
  docs[2].score = 4;
  docs[3].url = "http://example.com";
  docs[3].score = -99998.7;
  docs[4].url = "http://example.com";
  docs[4].score = 3.0;

  SortAndFilterDocs(&docs);

  assert(docs.size() == 3);
  assert(docs[0].score == 4);
  assert(docs[1].score == 3.0);
  assert(docs[2].score == 1);
}

14.3 テストを読みやすくする

設計原則として、 大切ではない詳細はユーザから隠し、大切な詳細は目立つようにする 上記のコードでは、「vector<ScoredDocument>の設定」が一番目立っている。 これをきれいにするためには、設定用のヘルパー関数を作る。

void MakeScoredDoc(ScoredDocument* sd, double score, string url) {
    sd->score = score;
    sd->url   = url;
}
void Test1() {
    vector<ScoredDocument> docs;
    docs.resize(5);
    MakeScoredDoc(&docs[0], -5.0, "http://example.com");
    MakeScoredDoc(&docs[1], 1, "http://example.com");
    MakeScoredDoc(&docs[2], 4, "http://example.com");
    MakeScoredDoc(&docs[3], -99998.7, "http://example.com");
    ...
}

これでも"http://example.com" が目立ちすぎていて目障りである。 あとは、docs.resize(5)や、&docs[0]&docs[1]などが邪魔である。ヘルパー関数を修正して、AddScoredDoc()という名前に変える。

void AddScoredDoc(vector<ScoredDocument>&docs, double score) {
    ScoredDocument sd;
    sd.score = score;
    sd.url = "http://example.com";
    docs.push_back(sd);
}
void Test1() {
    vector<ScoredDocument> docs;
    docs.resize(5);
    AddScoredDoc(&docs, -5.0);
    AddScoredDoc(&docs, 1);
    AddScoredDoc(&docs, 4);
    AddScoredDoc(&docs, -99998.7);
    ...
}

最小のテストを作る

このテストコードを改善するためには、「第12章 コードに思いを込める」の技法を使う。 このテストが何をしようとしているのかを簡潔な言葉で説明する。

文書のスコアは[-5, 1, 4, -99998.7, 3]である。
SortAndFilterDocs()を呼び出した後のスコアは[4, 3, 1]である。
スコアはこの順番でなければならない。

テストコードは以下のようになっているとよいだろう。

CheckScoresBeforeAfter("-5, 1, 4, -9999.7, 3", "4, 3, 1");

独自の「ミニ言語」を設計する

最近のC++では、以下のように配列リテラルをそのまま引数として渡せるようになっている。

CheckScoresBeforeAfter({-5, 1, 4, -9999.7, 3}, {4, 3, 1});

カンマ区切りの数値をパースする関数を追加して、以下のようにCheckScoresBeforeAfter()を変更する。

void CheckScoresBeforeAfter(string input, string expected_output) {
    vector<ScoredDocument> docs = ScoredDocsFromString(input);
    SortAndFilterDocs(&docs);
    string output = ScoredDocsToString(docs);
    assert (output == expected_output);
}

14.4 エラーメッセージを読みやすくする

先程のコードにおいて、assert(output == expected_output) が失敗したら、そのコードを追いかけるのは大変になる。 Boost C++ライブラリを使って、

assert (output == expected_output);

を以下のように書き換える。

BOOST_REQUIRE_EQUAL(output, expected_output);

手作りのエラーメッセージ

BOOST_REQUIRE_EQUAL()をよりもっといい感じにしたい。エラーメッセージが欲しいなら、自分で書けばいい!

14.5 テストの適切な入力値を選択する

今テストに使っている値はでたらめだ。

CheckScoresBeforeAfter("-5, 1, 4, -9999.7, 3", "4, 3, 1");

鍵となる考え : コードを完全にテストするもっとも単純な入力値の組み合わせを選択しなければならない。

入力値を単純化する

テストには最も綺麗で単純な値を選ぶ

1つの機能に複数のテスト

小さなテストを複数作る方が、簡単で、効率的で、読みやすい。 複数のテストで別々の方向からバグを見つけ出すようにする。例えば、SortAndFilterDocs()には4つのテストがある。

CheckScoresBeforeAfter("2, 1, 3", "3, 2, 1");   // ソート
CheckScoresBeforeAfter("0, -0.1, -10", "0");    // マイナスは削除
CheckScoresBeforeAfter("1, -2, 1, -2", "1, 1"); // 重複は許可
CheckScoresBeforeAfter("", "");                 // 空の入力は許可

14.6 テストの機能に名前を付ける

SortAndFilterDocs()をテストするコードは、Test1()に入れていたが、名前はナンセンスである。 以下のことをすぐに理解できるものが良い。 - テストするクラス(もしあれば) - テストする関数 - テストする状況やバグ

void Test_SortAndFilterDocs() {
    ...
}

次に、状況に応じてテスト関数を分割するかどうかを考える。分割する場合にはTest_<関数名>_<状況>()という形式にすればよい。

void Test_SortAndFilterDocs_BasicSorting() {
    ...
}
void Test_SortAndFilterDocs_NegativeValues() {
    ...
}

14.7 このテストのどこがダメだったのか?

  1. テストが何をしているかを1つの文で記述した方がよい。
  2. テストが簡単に追加できない。
  3. 失敗メッセージが役に立たない。
  4. 一度にすべてのことをテストしようとしている。
  5. テストの入力値が単純ではない。
  6. テストの入力値が不完全である。
  7. 極端な入力値を使ってテストをしていない。
  8. Test1()という意味のない名前がついている。

14.8 テストに易しい開発

テストしやすいようにコードを設計するようになるのだ! テストに易しい設計をすれば、振る舞いごとにうまく分割されて、自然にコードが構成されていく。

テスト容易性の低いコードの特性とそこから生じる設計の問題

  • グローバル変数を使っている。 グローバルの状態をテストごとに初期化する必要がある。
  • 多くの外部コンポーネントに依存している。 最初に足場を設定しなければいけないので、テストを書くのが難しい。
  • コードが非決定的な動作をする。 テストがあてにならず、信頼できない。

テスト容易性の高いコードの特性とそこから生じる設計の利点

  • タスクが小さい。あるいは内部状態を持たない。 テストがしやすい。メソッドをテストするのにセットアップがあまり必要にならない。
  • クラスや関数が1つのことをしている。 完全にテストをするためのテストケースが少なくて済む。
  • クラスは他のクラスにあまり依存していない。高度に疎結合化されている。 各クラスは独立してテストできる。
  • 関数は単純でインタフェースが明確である。 明確な動作をテストできる。

14.9 やりすぎ

テストのために本物のコードの読みやすさを犠牲にしてしまう。

テストは読みやすくする。テストをしやすくするために、本物のコードにごみを入れてはならない。

テストのカバレッジを100%にしないと気が済まない

コードの90%をテストする方が、残り10%をテストするよりも楽である。

現実的に、カバレッジが100%になることはない。もしも100%になっているのだとしたら、バグを見逃しているか、機能を実装していないか、仕様が変更されていることに気づいていないのどれかである。

テストがプロダクト開発の邪魔になる

プロジェクトの一部に過ぎないテストが、プロジェクト全体を支配しているような状況は避けるべきである。

14.10 まとめ

  • テストのトップレベルはできるだけ簡潔にする。入出力のテストはコード1行で記述できるとよい。
  • テストが失敗したらバグの発見や習性がしやすいようなエラーメッセージを表示する。
  • テストに有効なもっとも単純な入力値を使う。
  • テスト関数に説明的な名前を付けて、何をテストしているのかを明らかにする。Test1()ではなく、Test_<関数名>_<状況>のような名前にする。