FPGA開発日記

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

「リーダブルコード」を読む (第7章「制御フローを読みやすくする」第8章「巨大な式を分割する」)

諸事情で数日はネットワークをあまり使えないので、RISC-Vに関連する調査は中止。 あまりやることが無いので例によって一人読書会をしている。 今回は「リーダブルコード」。名著と呼ばれており、私も一度さっくりと読んだ。 ただし忘れていることもあるし、体系的に学びたいので読み直す。

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

今回は第7章「制御フローを読みやすくする」第8章「巨大な式を分割する」までをまとめた。

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

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

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


第7章 制御フローを読みやすくする

鍵となる考え : 条件やループなどの制御フローはできるだけ「自然」にする。コードの読み手が立ち止まったり読み返したりしないように書く。

7.1 条件式の引数の並び順

例えば、下記のどちらがコードとして読みやすいだろうか。

while (bytes_received < bytes_expected)
while (bytes_expected > bytes_received)
  • 右側 : 調査対象の式。変化する。
  • 左側 : 比較対象の式。あまり変化しない。

「もし君が18歳以上ならば」というのは自然だが、「もし18年が君の年齢以下ならば」というのは不自然である。

7.2 if/else ブロックの並び順

if/else文の並び順には優劣がある。

  • 条件は否定形よりも高提携を使う。例えば、if(!debug) ではなく、if (debug) を使う。
  • 単純な条件を先に書く。 ifとelseが同じ画面に表示されるので見やすい。
  • 関心を引く条件や目立つ条件を先に書く。
if (!url.HasQueryParameter("expand_all")) {
    response.Render(items);
} else {
    for (int i = 0; i < items.size(); i++) {
        items[i].Expand();
    }
}

expand_allのことばかり気になってしまい、条件に集中できなくなる。否定形を削除したものの方が良い。

if (url.HasQueryParameter("expand_all")) {
    for (int i = 0; i < items.size(); i++) {
        items[i].Expand();
    }
} else {
    response.Render(items);
}

7.3 参考演算子

三項演算子は読みやすさの点からいうと議論の余地がある。

time_str += (hour >= 12) ? "pm" : "am";

三項演算子を使わないと以下のようになる。

if (hour >= 12) {
    time_str += "pm";
} else {
    time_str += "am";
}

ただし、さらに長い条件になると読みにくくなる場合が多い。「なんでも1行に収めよう」とすると、逆に難しくなることがある。

鍵となる考え : 行数を短くするよりも、ほかの人が理解するのにかかる時間を短くする。

アドバイス : 基本的には if/else を使おう。三項演算子はそれによって簡潔になる時だけ使おう。

7.4 do/whileループを避ける

do/whileループは必ずコードブロックを一度実行する。 whileループにすれば、コードブロックを読む前に繰り返しの条件があるので読みやすくなるが、コードを重複させてまでdo/whileを削除するのはばかばかしい。

// 疑似do/while (やってはダメ!)
本体
while(condition) {
    本体(2度目)
}

do/whileループは、whileループで書き直せることが多い。

public boolean ListHasNode (Node node, String name, int max_length) {
    do {
        if (node.name().equals(name))
            return true;
        node = node.next();
    } while (node != null && --max_length > 0);
    
    return false;
}

これを書き直すと、

public boolean ListHasNode (Node node, String name, int max_length) {
    while (node != null && max_length-- > 0) {
        if (node.name().equals(name)) return true;
        node = node.next();
    }
}

7.5 関数から早く返す

関数から早く返すのが望ましい時がある。以下のような「ガード節」を使わずに実装してしまうと、不自然な実装になってしまう。

public boolean Contains (String str, String substr) {
   if (str == null || substr == null) return false;
   if (substr.equals("")) return true;
   ...
}

7.6 悪名高きgoto

Linuxカーネルでもgotoは使われている。すごく評判が悪いが、gotoをはねのけるよりも、gotoを使うべき理由を分析する方が良い。

gotoの単純で害のないものは、関数の最下部に置いたexitと一緒に使うもの。

    if (p == NULL) goto exit;
    
    ...
exit:
    fclose(file1);
    fclose(file2);
    
    return;
}

7.7 ネストを浅くする

ネストを深くしてしまうと、読み手は「精神的スタック」に条件をプッシュしていく必要が生じる。

ネストが増える仕組み

ゼネストが増えてしまうのか。もともと単純なコードだったのが、条件を追加するたびに最も簡単な手法を選択していくため、このような複座ぬなネストになってしまう。

鍵となる考え : 変更するときはコードを新鮮な目で見る。一歩下がって全体を見る。

早めに返してネストを削除する

ネストを削除するためには、「失敗ケース」をできるだけ早めに関数から返せばよい。

ループ内部のネストを削除する

ループ内部で、早めに返すようなことを行うためには、continueを使う。

for (int i = 0; i < result.size(); i++) {
    if (results[i] == NULL) continue;
    non_null_count++;
    if (results[i]->name == "") continue;
    cout << "Considering candidate ... " << endl;
...
}

7.8 実行の流れを追えるかい?

プログラミング言語やライブラリには、コードを「舞台裏」で実行する構成要素がいくつかある。こうした構成要素を使っていると、コードを追うのが難しくなる。いくつか例を挙げる。

構成要素 高レベルの流れが不明瞭になる理由
スレッド どのコードがいつ実行されているのかよくわからない。
シグナル/割込みハンドラ ほかのコードが実行される可能性がある。
例外 いろんな関数呼び出しが終了しようとする。
関数ポインタと無名関数 コンパイル時に判別できないので、どのコードが実行されるのかわからない。
仮想メソッド object.virtualMethod()は未知のサブクラスのコードを呼び出す可能性がある。

7.9 まとめ

比較を書くときは、変化する値を左に、より安定した値を右に配置する。

if/else文のブロックは適切に並べ替える。一般的には、高提携・タンジュン・目立つものを先に処理する。

三項演算子・do/whileループ・gotoなどのプログラミング超す栄養素を使うと、コードが読みにくくなることが多い。

深いネストを避けるためには、直線的なコードを選択する。

早めに返してあげると、ネストを削除したりコードをクリーンにしたりできる。特に、「ガード節」(関数の丈夫で単純な条件を先に処理するもの)が便利だ。

第8章 巨大な式を分割する

人間は一度に3~4の「もの」しか考えられないそうだ。つまり、コードの指揮が大きくなれば、それだけ理解が難しくなるのである。

鍵となる考え : 巨大な式は飲み込みやすい大きさに分割する。

8.1 説明変数

式を簡単に分割するためには、四季を表す変数を使えばよい。これを「説明変数」と呼ぶ。

if line.split(':')[0].strip() == "root":
    ...

説明変数を使えば、以下のようになる。

username = line.split(':')[0].strip()
if username == "root":
    ...

8.2 要約変数

式を変数に代入しておくと、管理や把握を簡単にできる。これを要約変数と呼ぶ。

if (request.user.id == document.owner_id) {
    // ユーザはこの文書を編集できる。
}
...
if (request.user.id != documnet_owner_id) {
    // 文書は読み取り専用
}

ユーザは文書を所持しているか?という変数なので、要約してこの概念をもっと明確に表現できる。

final boolean user_owns_document = (request.user.id == documnet.owner_id);
if (user_owns_document) {
    // ユーザはこの文書を編集できる。
}
...
if (!user_owns_document) {
    // 文書は読み取り専用
}

8.3 ド・モルガンの法則を使う

論理式を等価な式に置き換える方法は2つある。

  1. not (a or b or c) <--> (not a) and (not b) and (not c)
  2. not (a and b and c) <--> (not a) or (not b) or (not c)

この法則を使えば、論理式を読みやすくできる。例えば、以下のようなコードは、

if (!(file_exists && !is_protected)) Error ("Sorry, could not read file.");

以下のように書き換えることができる。

if (!file_exists || !is_protected) Error ("Sorry, could not read file.");

8.4 短絡評価の悪用

例えば、 if (a || b)a がTrueならば、bは評価されない。この動作はすごく便利だけど、悪用すると複雑なロジックになってしまう。

assert ((!(bucket = FindBucket(key))) || !bucket->IsOccupied());

ほかのコードと比較してみる。

bucket = FindBucket(key);
if (bucket != NULL) assert (!bucket->IsOccupied());

鍵となる考え : 「頭がいい」コードに気を付ける。後でほかの人がコードを読むときに分かりにくくなる。

8.5 例 : 複雑なロジックと格闘する

Rangeクラスを実装しているとする。

struct Range {
    int begin;
    int end;
    // 例えば、[0,5)は[3,8)と重なっている。
    bool OverlapWith(Range other);
};

これを実装してみる。

bool Range::OverlapWith(Range other) {
    // 'begin'または'end'が'other'の中にあるかを確認する。
    return (begin >= other.begin && begin <= other.end) ||
           (end >= other.begin && end <= other.end);
}

場合分けや条件が多すぎて、バグを見逃しやすい。上記のコードでは、 Range[0,2)Range[2,4)が重なってしまう。

この問題を修正すると、

begin (begin >= other.begin && begin < other.end) ||
      (end > other.begin && end <= other.end);

まだバグがある。begin/endがほかの範囲を取り囲んでいるケースが抜けている。

begin (begin >= other.begin && begin < other.end) ||
      (end > other.begin && end <= other.end);
      (begin <= other.begin && end >= other.end);

より優雅な方法を見つける

もっと簡単な方法がある。OverlapWith()の反対を考えると、「重ならない」になる。2つの範囲が重ならないのは簡単だ。2つの場合しかない。

  1. 一方の範囲の終点が、ある範囲の始点よりも前にある場合。
  2. 一方の範囲の始点が、ある範囲の終点よりも後にある場合。

これをコードに置き換えるのは簡単だ。

bool Range::OverlapWith(Range other) {
    if (other.end <= begin) return false;
    if (other.begin >= end) return false;
    
    return true;
}

8.6 巨大な文を分割する

同じようなものが何度も登場する場合は、それを削除する。

  • タイプミスを減らすために役に立つ。
  • 横幅が縮まるのでコードが読みやすくなる。
  • クラス名を変更することになれば、一か所を変更すればよい。

8.7 式を簡潔にするもう1つの創造的な方法

フィールド名が違うだけで、どの式も同じようなことをしてることに気が付いた場合、C++のマクロを定義すればよい。

void AddStats(const Stats& add_from, Stats* add_to) {
    add_to->set_total_memory(add_from.total_memory() + add_to->total_memory());
    add_to->set_free_memory(add_from.total_memory() + add_to->free_memory());
    add_to->set_swap_memory(add_from.total_memory() + add_to->swap_memory());
    add_to->set_status_string(add_from.total_memory() + add_to->status_string());
    add_to->set_num_processes(add_from.total_memory() + add_to->num_processes());
    ...
}

C++マクロを定義する。

void AddStats(const Stats& add_from, Stats* add_to) {
    #define ADD_FIELD(field) add_to->set##field(add_from.field() + add_to->field())
    ADD_FIELD(total_memory);
    ADD_FIELD(free_memory);
    ADD_FIELD(swap_memory);
    ADD_FIELD(status_string);
    ADD_FIELD(num_processes);
    ...
    #undef ADD_FIELD
}

8.8 まとめ

説明変数を導入する。これにより、3つの利点がある。

  • 巨大な式を分割できる。
  • 簡潔な名前で指揮を説明することで、コードを文書化できる。
  • コードの主要な「概念」を読み手が認識しやすくなる。

ド・モルガンの法則を使ってロジックを操作する方法がある。

複雑な論理条件は小さな分を分割する。