はじめに
本記事は
『リーダブルコード ―― より良いコードを書くためのシンプルで実践的なテクニック』
Dustin Boswell、Trevor Foucher 著、角征典 訳
の各コンセプトを簡単にまとめた、↓ リポジトリの内容を転載したものです。
1章 理解しやすいコード Code Should Be Easy to Understand
コードは「他の人」が最短時間で理解できる(=変更を加えたりバグを見つけたりできる)ように書かなければいけない。
「他の人」というのは、自分のコードに見覚えのない6カ月後の「君自身」かもしれない。
2章 名前に情報を詰め込む Packing Information into Words
情報を詰め込んだ名前の付け方
- 明確な単語を選ぶ
- 汎用的な名前を避ける
- 抽象的な名前よりも具体的な名前を使う
- 接尾辞や接頭辞を使って情報を追加する
- 名前の長さを決める
- 名前のフォーマットで情報を伝える
2.1 明確な単語を選ぶ Choose Specific Words
get → インターネットから取ってくるなら fetch や download
size → 目的に合わせて height や numNodes や memoryBytes
stop → 動作に合わせて kill や Resume や Pause
単語 | 代替案 |
---|---|
send | delivery, dispatch, announce, distribute, route |
find | search, extract, locate, recover |
start | launch, create, begin, open |
make | create, set up, build, generate, compose, add, new |
2.2 汎用的な名前を避ける Avoid Generic Names
バグを早期に発見するため、少しでも時間を使っていい名前を考える習慣をつけよう。
いい名前というのは、変数の目的や値を表すものだ。
2.3 抽象的な名前よりも具体的な名前を使う Prefer Concrete Names over Abstract Names
メソッドの動作を想定しやすくなり。意図しない使われ方を防ぐ。
2.4 名前に情報を追加する Attaching Extra Information to a Name
16進数の文字列を持つ id があった場合には、 hexId のようにする。
時間やバイト数のように計測できる者であれば、変数名に単位を入れるといいだろう。
関数の仮引数 | 単位を追加した仮引数 |
---|---|
Start(delay: Int) | delay → delaySecs |
CreateCache(size: Int) | size → sizeMb |
ThrottleDownload(limit: Float) | limit → maxKbps |
Rotate(angle: Float) | angle → degreesCw |
危険や注意を喚起する情報も追加した方がいい。
状況 | 変数名 | 改善後 |
---|---|---|
password はプレーンテキストなので、処理をする前に暗号化すべきである。 | password | plaintextPassword |
ユーザが入力した comment は表示する前にエスケープする必要がある。 | comment | unescapedComment |
html の文字コードを UTF-8 に変えた。 | html | htmlUtf8 |
入力された data をURLエンコードした。 | data | urlencodedData |
2.5 名前の長さを決める How Long Should a Name Be?
長い名前は覚えにくいし、画面を大きく占領してしまう。折返しが必要になれば、コード行が無駄に増えてしまう。
ただし、スコープが小さければ短い名前でもいい。
プロジェクト固有の省略形は控えた方がよい。新しくプロジェクトに参加した人は、暗号のように見えて怖いと思うだろう。
「新しいチームメイトはその名前の意味を理解できるだろうか?」
プログラマは、 document の代わりに doc を使う。 string の代わりに str を使う。
新しいチームメンバーでも FormatStr() の意味は理解できる。
名前に含まれる単語を削除しても情報がまったく損なわれないこともある。 ConvertToString() を短くして ToString() にしても、必要な情報は損なわれない。
2.6 名前のフォーマットで情報を伝える Use Name Formatting to Covey Meaning
キャメルケースやスネークケースの統一、メンバ変数はアンダースコアの接尾辞を加える、jQueryのライブラリ関数を呼び出したときには変数名の頭に $ をつけるなどエンティティごとに異なるフォーマットを使うことで、一種のシンタックスハイライトを実現することができる。
3章 誤解をされない名前 Names That Can't Be Misconstrued
名前が他の意味と間違えられることはないだろうか?と何度も自問自答する。
3.1 例:filter() Example: filter()
選択するのか、除外するのかが曖昧な場合がある。選択する場合は select() 除外する場合は exclude() にしたほうがよい。
3.2 例:clip(text, length) Example: clip(text, length)
段落の内容を切り抜く関数 clip() があるとしよう。 clip() の動作は2つ考えられる。
- 最後から length 文字を削除する(remove)
- 最大 length 文字まで切り詰める(truncate)
それから maxLength にした方が明確になる。これで終わりじゃない。 maxLength もいろんな解釈ができる。
- バイト数
- 文字数
- 単語数
この場合は文字数を意味しているので、 maxLength ではなく maxChars にするといいだろう。
3.3 限界値含めるときは min と max を使う Prefer min and max for (Inclusive) Limits
上下限値を表すのに limit を使った場合、未満(限界値を含まない)のか以下(限界値を含む)なのかが分からず、古典的な「off-by-oneエラー」を引き起こす可能性がある。
3.4 範囲を指定するときは first と last を使う Prefer first and last for Inclusive Ranges
範囲を指定するのに start と stop を使った場合、 stop が複数の意味に解釈できるため誤解を招く可能性がある。包括的な範囲を表すのであれば first と last を使うのがいい。
3.5 包含/排他的範囲には begin と end を使う Prefer begin and end for Inclusive/Exclusive Ranges
10月16日の範囲を指定する場合のような包含/排他的範囲には begin と end を使うことが多い。
3.6 ブール値の名前 Naming Booleans
ブール値の変数やブール値を返す関数の名前を選ぶときには、true と false の意味を明確にしなければいけない。ブール値の変数名は、頭に is、has、can、should などをつけて分かりやすくすることが多い。
それから、名前を否定形にするのは避けた方がいい。
3.7 ユーザの期待に合わせる Matching Expectations of Users
例:get()
多くのプログラマは、get で始まるメソッドはメンバの値を返すだけの軽量アクセサであるという規約に慣れ親しんでいる。計算量が多い場合には、呼出しコストの高さが事前に分かるように compute() などの名前に変えるべきだろう。
例:list::size() (旧C++標準ライブラリ)
list.size() を他のコンテナと同じ高速な操作と誤認識したため、計算量がべき乗になった。(Kotlinにおける size メソッドの計算量は O(1) である。)
3.8 例:複数の名前を検討する Example: Evaluating Multiple Name Candidates
名前を決めるときには、複数の候補を検討すると思う。最終的に決める前に、それぞれ長所について話し合うのが普通だ。
4章 美しさ Aesthetics
優れたソースコードは目に優しいものでなければいけない。コードを読みやすくするための余白・配置・順序について説明しよう。
- 読み手が慣れているパターンと一貫性のあるレイアウトを使う
- 似ているコードは似ているように見せる
- 関連するコードをまとめてブロックにする
4.1 なぜ美しさが大切なのか? Why Do Aesthetics Matter?
見た目が美しいコードの方が使いやすいのは明らかだ。さっと流し読みできれば、誰にとっても使いやすいコードと言えるだろう。
4.2 一貫性のある簡潔な改行位置 Rearrange Line Breaks to Be Consistent and Compact
コードの見た目を一貫性のあるものにするには、適切な改行を入れるようにしよう(それからコメントも整列させよう)。
4.3 メソッドを使った整列 Use Methods to Clean Up Irregularity
コードの見た目を美しくすることにより、うれしい副作用がもたらされることがある。
- 重複を排除したことでコードが簡潔になる
- テストケースの大切な部分(名前やエラー文字列)が見やすくなった
- テストの追加が簡単になった
4.4 縦の線をまっすぐにする Use Column Alignment When Helpful
縦の線が視覚的な手すりになれば、流し読みができるようになる。また、タイプミスといった問題を見つけやすくなる。
でも、これが好きではないプログラマもいる。整列やその維持に手間がかかるというのだ。1行だけ変更したいのに、他の行も変更しなければいけないので差分が増えるという人もいる。
でも、試しにやってみてはどうだろうか。ぼくたちの経験では、プログラマが心配するほどの手間にはならない。もし手間になるようだったら、そのときは止めればいい。
4.5 一貫性と意味のある並び Pick a Meaningful Order, and Use It Consistently
コードはランダムに並べるのではなく、意味のある順番に並べるといい。どのような並び順を選ぶにしても、一連のコードでは同じ順を使うべきだ。
- 対応する HTML フォームの フィールドと同じ順にする
- 最重要なものから重要度順に並べる
- アルファベット順に並べる
4.6 宣言をブロックにまとめる Organize Declarations into Blocks
人間の脳はグループや階層を1つの単位として考える。コードの概要をすばやく把握してもらうには、このような単位を作ればいい。
4.7 コードを段落に分割する Break Code into Paragraphs
文章は次のように複数の段落に分割されている。
- 似ている考えをグループにまとめて他の考えと分けるため
- 視覚的な踏み石を提供できるため
- 段落単位で移動できるようになるため
4.8 個人的な好みと一貫性 Personal Style versus Consistency
クラスの開き括弧 {} の位置などがそうだ。どちらを選んだとしても、コードの読みやすさに大きな影響はない。
一貫性のあるスタイルは正しいスタイルよりも大切だ。
5章 コメントすべきことを知る Knowing What to Comment
コメントの目的は、書き手の意図を読み手に知らせることである。
- コメントするべきではないことを知る
- コードを書いているときの自分の考えを記録する
- 読み手の立場になって何が必要になるかを考える
5.1 コメントするべきではないこと What NOT to Comment
コメントを読むとその分だけコードを読む時間がなくなる。コメントは画面を占領してしまう。言い換えれば、コメントにはそれだけの価値を持たせるべきなんだ。
コードからすぐにわかることをコメントに書かない。
逆に言えば、コードを理解するよりも、コメントを読んだ方が早く理解できる場合には価値がある。
コメントはひどい名前の埋め合わせに使うものではない。優れたコメントよりも関数・変数の名前の方が大切だ。
プログラマはこのことを「優れたコード > ひどいコード + 優れたコメント」と言っている。
5.2 自分の考えを記録する Recording Your Thoughts
優れたコメントというのは考えを記録するためのものである。コードを書いているときに持っている大切な考えを記録しなければいけない。
次の場合、コメントから情報を得られるので、下手に最適化しようとして無駄に時間を使う必要がなくなる。
// このデータだとハッシュテーブルよりもバイナリツリーの方が 40% 速かった。
// 左右の比較よりもハッシュの計算コストの方が高いようだ。
このコメントがなければ、失敗するテストケースに無駄な時間をかえることになるかもしれない。あるいは、バグだと思って修正したくなるだろう。
// ヒューリスティックだと単語が漏れることがあるが仕方ない。100% は難しい。
このコメントはコードが汚いことを認めている。そして、誰かに修正を促している。コメントがなければ、コードが汚くて誰も近づかなかっただろう。
// このクラスは汚くなってきている。
// サブクラス ResourceNode を使って整理した方がいいかもしれない。
記法 | 典型的な意味 |
---|---|
TODO: | あとで手をつける |
FIXME: | 既知の不具合があるコード |
HACK: | あまりキレイじゃない解決策 |
XXX: | 危険!大きな問題がある |
5.3 読み手の立場になって考える Anticipating Likely Questions
本書で使っている技法は、他の人にコードがどのように見えるかを想像するものだ。他の人というのは、プロジェクトのことを君のように熟知していない人のことである。
次のような C++ コードがあったとする。このコードを見たプログラマは「どうして単純に data.clear() せずに空のベクタをスワップするんだ?」と疑問に思うだろう。
このようにしているのは、ベクタのメモリを開放してメモリアロケータに戻す方法がこれしかないからだ。これはあまり知られていないことである。つまり、ここにコメントをつけるべきなのだ。
struct Recorder {
vector<float> data;
void Clear() {
// ベクタのメモリを開放する(「STL swap 技法」で検索してみよう)
vector<float>().swap(data);
}
}
関数やクラスを文書化するときには、「このコードを見てビックリすることは何だろう?どんなふうに間違えて使う可能性があるのだろう?」と自分に問いかけるといい。
また、新しいチームメンバーにとって最も難しいのは全体像の理解である。高レベル・低レベルコードの全体像についてコメントするのはいい考えだ。
5.4 ライターズブロックを乗り越える Final Thoughts - Getting Writer's Block
プログラマの多くはコメントを書きたがらない。コメントをうまく書くのは大変だと思っているからだ。こうしたライターズロックを乗り越えるには、とにかく書き始めるしかない。
コメントを書く作業は、3つの簡単な手順に分解できる。
- 頭の中にあるコメントをとにかく書き出す
- コメントを読んで(どちらかと言えば)改善が必要なものを見つける
- 改善する
6章 コメントは正確で簡潔に Making Comments Precise and Compact
コメントを書くのであれば、正確に書くべきだ(できるだけ明確で詳細に)。また、コメントは簡潔なものでなければならない。
6.1 コメントを簡潔にしておく Keep Comments Compact
コメントを正確にすることと簡潔にすることは両立することが多い。
6.2 あいまいな代名詞を避ける Avoid Ambiguous Pronouns
読み手は代名詞を還元しなければいけない。場合によっては、「それ」や「これ」がなにを指しているのか分からないこともある。
// データをキャッシュに入れる。ただし、先にそのサイズをチェックする。
「その」がさしているのは、データかもしれないし、キャッシュかもしれない。紛らわしいようであれば、名詞を代名詞に代入してみるといい。
// データをキャッシュに入れる。ただし、先にデータのサイズをチェックする。
6.3 歯切れの悪い文章を磨く Polish Sloppy Sentences
次のコメントは一見問題なさそうに見える。
// これまでにクロールした URL かどうかによって優先度を変える
こちらの方が単純だし短いし直接的だ。さらには、クロールしていない URL の優先度が高いという、先のコメントでは言及されていない情報も含まれている。
// これまでクロールしていない URL の優先度を高くする
6.4 関数の動作を正確に記述する Describe Function Behavior Precisely
コメントを正確にすることと簡潔にすることは両立することが多い。正確にコメントすることで、伝わる情報は格段に増える。
6.5 入出力のコーナーケースに実例を使う Use Input/Output Examples That Illustrate Corner Cases
慎重に選んだ入出力の実例をコメントに書いておけば、それは千の言葉に等しいと言える。
6.6 コードの意図を書く State the Intent of Your Code
コメントというのはコードを書いているときに考えていたことを読み手に伝えるためのものだ。
場合によって、コメントは冗長検査の役割を果たす場合もある。
6.7 名前付き引数コメント Named Function Parameter Comments
(Kotlinでも)関数呼び出しの引数を名前付きで渡せる。
connect(timeout = 10, useEncryption = false)
6.8 情報密度の高い言葉を使う Use Information-Dense Words
プログラミングの経験が何年かあれば、同じ問題や解決策が何度も繰り返し登場することに気付いていると思う。こうしたパターンやイディオムを説明するための言葉や表現がある。このような言葉を使えば、コメントをもっと簡潔にできる。
// このクラスには大量のメンバがある。同じ情報はデータベースにも保管されている。
// ただし、速度の面からここにも保管しておく。
// このクラスを読み込むときには、メンバが存在しているかどうかを策に確認する。
// もし存在していれば、そのまま返す。
// もし存在しなければ、データベースから読み込んで、次回のためにデータをフィールドに保管する。
次のように書けばいい。
// このクラスの役割は、データベースのキャッシュ層である
7章 制御フローを読みやすくする Making Control Flow Easy to Read
条件やループなどの制御フローはできるだけ自然にする。コードの読み手が立ち止まったり読み返したりしないように書く。
7.1 条件式の引数の並び方 The Order of Arguments in Conditionals
- 左辺:調査対象の式。変化する。
- 右辺:比較対象の式。あまり変化しない。
この指針は英語の用法と合っている。英語で「もし君が18歳以上ならば」と言うのは自然だ。「もし18年が金の年齢以下ならば」と言うのは不自然だ。
今もヨーダ記法は便利なのか?上の指針で言えば、順序を逆にするとコードが不自然で読みにくくなる。「ヨーダ記法」は過去のものになりつつあると言えるだろう。
7.2 if/else ブロックの並び順 The Order of if/else Blocks
if/else 文のブロックには優劣がある。
- 条件は否定形よりも肯定系を使う
- 単純な条件を先に書く
- 感心を引く条件や目立つ条件を先に書く
7.3 三項演算子 The ?: Conditional Expression (a.k.a. "Ternary Operator")
読みやすさの点から言うと、これには議論の余地がある。支持者は複数行が1行でまとまるのでいいと言う。反対者は、読みにくいしデバッガでステップ実行するのが難しいと言う。
行数を短くするよりも、他の人が理解するのにかかる時間を短くする。
※ Kotlin に三項演算子はない。 if/else 文が式として扱われるため、 if/else の結果を変数に代入するようなことは可能。
7.4. do/while ループを避ける Avoid do/while Loops
do/while ループが変わっているのは、コードブロックを再実行する条件が下にあることだ。if文・while文・for文などの条件は、コードブロックの上にある。コードは上から下に読んでいくので、do/while 文は少し不自然だ。コードを2回読むことになってしまう。
C++の作者であるビャーネ・ストロヴストルップは、著書「C++ Programming Language」でこう言っている。
私の経験では、do-statementは、エラーや混乱の原因になることが多い。(中略)私は条件が「前もって」書かれている方が好きだ。そのため、私は do-statement を割けることが多い。
7.5 関数から早く返す Returning Early from a Function
関数から早く返すことはいいことだ。
関数の出口を1つにしたいというのは、何らかのクリーンアップコードを確実に実行したいからだろう。現代の言語では、こうした仕組みがより洗練された形で提供されている。
言語 | クリーンアップコードのイディオム |
---|---|
C++ | デストラクタ |
Java・Python・Kotlin | try ... finally |
Python | with |
C# | using |
7.6 悪名高きgoto The Infamous goto
goto を使うとすぐに手に負えなくなったり、子^度についていくのが難しくなったリスので、すごく評判が悪い。
神への冒涜だと goto をはねつけるよりも、goto を使うべき理由を分析する方がいいだろう。最も単純で害のない goto というのは、関数の最下部に置いた exit と一緒に使うものだ。
if(p == NULL) goto exit;
...
exit:
fclose(file);
...
return;
goto が唯一許されるのがこれだ。これなら goto は大した問題にならない。でも、goto の飛び先きが複数になると問題だ。経路が交差していたらなおさらである。
7.7 ネストを浅くする How Nesting Accumulates
ネストの深いコードは理解しにくい。ネストが深くなると、読み手は精神的スタックに条件をプッシュしなければいけない。閉じ括弧 } を見てスタックからポップしようと思っても、その条件が何だったのかうまく思い出せない。
7.8 実行の流れを追えるかい? Can You Follow the Flow of Execution?
できることならプログラムのすべての実行パスを簡単に追えるようになるといい。main() から出発して、心の中でコードを追っていく。関数を次々に呼び出していく。それをプログラムが終了するまで続けるのだ。
ただし、プログラミング言語やライブラリには、コードを舞台裏で実行する構成要素がある。
構成要素 | 高レベルの流れが不明瞭になる理由 |
---|---|
スレッド | どのコードがいつ実行されるのかよく分からない |
シグナル/割り込みハンドラ | 他のコードが実行される可能性がある |
例外 | いろんな関数呼び出しが終了しようとする |
関数ポインタと無名関数 | コンパイル時に判別できないので、どのコードが実行されるのか分からない |
仮想メソッド | object.virtualMethod() は未知のサブクラスのコードを呼び出す可能性がある |
これらの構成要素を使うことで、コードが読みやすくなったり、冗長性が低くなったりすることもある。コード全体に締める割合を大きくしすぎないことが大切だ。こうした構成要素はうまく使わないと、コードの行方を見失ってしまう。
8章 巨大な式を分割する Breaking Down Giant Expressions
巨大な式は飲み込みやすい大きさに分割する。最近の研究では、人間は一度に3~4のものしか考えられないそうだ。つまり、コードの式が大きくなれば、それだけ理解が難しくなるのである。
8.1 説明変数 Explaining Variables
式を簡単に分割するには、式を表す変数を使えばいい。この変数を説明変数と呼ぶこともある。
8.2 要約変数 Summary Variables
式を説明する必要がない場合でも式を変数に代入しておくと便利だ。大きなコードの塊を小さな名前に置き換えて、監理や把握を簡単にする変数のことをようやく変数と呼ぶ。
8.3 ド・モルガンの法則を使う Use De Morgan's Laws
- not (a or b or c) ⇔ (not a) and (not b) and (not c)
- not (a and b and c ) ⇔ (not a) or (not b) or (not c)
この式が覚えにくいようであれば、「not を分配して and/or を反転する」と覚えればいい。
8.4 短絡評価の悪用 Abusing Short-Circuit Logic
ブール演算子は短絡評価を行うものが多い。すごく便利だけど、悪用すると複雑なロジックになってしまう。
以下は、著者のひとりが過去に書いた(C++の)コードの例だ。
assert((!(bucket = FindBucket(key))) || !bucket->IsOccupied());
これは「このキーのバケツを取得する。もしバケツが null じゃなかったら、中身が入っていないかを確認する」という意味だ。まったく同じものだけど、以下のほうがずっと理解しやすくなった。
bucket = FindBucket(key);
if (bucket != NULL) assert(!bucket->IsOccupied());
どうして1行で書こうとしたのだろう? そのときは「オレは頭がいい」と思っていたのだ。ロジックを簡潔なコードに落とし込むことに一種の喜びを感じていた。この気持ちがみんなにも理解してもらえると思う。問題は、これがコードのスピードバンプになっていたことだ。
他にも言っておきたいイディオムがある。Python・JavaScript・Ruby・Kotlin などの言語では、複数の引数のなかから1つを返すOR演算子が使える。
8.5 例:複雑なロジックと格闘する Example: Wrestling with Complicated Logic
(左閉右開区間を示す)Range クラスを実装しているとしよう。
区間内の条件分けを実装していくと、場合分けや条件が多すぎてバグを見逃しやすい。解決策を見つけるには創造性が必要だ。でも、どうすればいいのだろう? 反対から問題を解決してみるという手法がある。 今回の場合、反対を考えると重ならない部分になる。2つの範囲が重ならないのは簡単だ。2つの場合しかない。
- 一方の範囲の終端が、ある範囲の始点よりも前にある場合
- 一方の範囲の始点が、ある範囲の終点よりも後にある場合
このような視点の切り替えにより、コードがずっと単純になる。
8.6 巨大な式を分割する Breaking Down Giant Statements
巨大な分があった場合、同じ式を抽出して要約変数として関数の最上部に抽出すればいい(これは DRY 原則の実例でもある)。
これにより、以下のようなことが副産物的に得られることがある。
- タイプミスを減らすのに役立つ
- 横幅が縮まることでコードが読みやすくなる
8.7 式を簡潔にするもう1つの創造的な方法 Another Creative Way to Simplify Expressions
同じような式が複数ある場合、関数として定義すればいい。何も関数を頻繁に使えと言ってるわけじゃない。コードがわかりにくくなるし、見つけにくいバグがもぐりこんでしまうこともある。でも、簡潔で読みやすくなるという明確な利点がもたらされる場合もある。
9章 変数と読みやすさ Variables and Readability
- 変数が多いと変数を追跡するのが難しくなる
- 変数のスコープが大きいとスコープを把握する時間が長くなる
- 変数が頻繁に変更されると現在の値を把握するのが難しくなる
9.1 変数を削除する Eliminating Variables
役に立たない一時変数は削除する。役に立たない変数には次のような特徴がある。
- 複雑な式を分割していない
- より明確になっていない
- 一度しか使っていないので、重複コードの削除になっていない
中間結果を削除する。タスクはできるだけ早く完了する方がいい。
制御フロー変数を削除する。制御フロー変数はプログラムの実行を制御するためだけの変数であり、うまくプログラミングすれば削除することができる。
9.2 変数のスコープを縮める Shrink the Scope of Your Variables
「グローバル変数は避ける」というアドバイスは、誰もが一度は耳にしたことがあるはずだ。グローバル変数というのは、どこでどのように使われるのかを追跡するのが難しい。グローバル変数に限らず、すべての変数のスコープを縮めることはいい考えだ。
では、なぜ変数のスコープを縮めることがいいこととされているのだろう? それは、一度に考えなければいけない変数を減らせるからだ。すべての変数のスコープを 1/2 に縮めることができれば、スコープに存在する変数の数は平均して 1/2 になる。
クラスのメンバへのアクセスを制限するもうひとつの方法は、メソッドをできるだけ static にすることだ。 static メソッドを使えば、メンバ変数とは関係ないことがよくわかる。
もうひとつの方法は、大きなクラスを小さなクラスに分割することだ。ただし、分割後のクラスが独立していなければ問題ないけど、クラスで相互にメンバを参照しあうようならやっても意味がない。分割したいのはデータなのである。
9.3 変数は一度だけ書き込む Prefer Write-Once Variables
生きている変数が多いとコードが理解しにくくなることを説明した。でも、もっと理解しにくいのは変数が絶えず変更され続けることだ。
この問題と戦うために、ちょっと変わったものを提案したい。それは、変数は一度だけ書き込むというものだ。
Kotlin でいうならばイミュータブル、 value で扱うということになる。
9.4 最後の例 A Final Example
9.5 まとめ Summary
変数を減らして、できるだけ軽量にすれば、コードは読みやすくなる。
- 邪魔な変数を削除する
- 変数のスコープをできるだけ小さくする
- 一度だけ書き込む変数を使う
10章 無関係な下位問題を抽出する Extracting Unrelated Subproblems
エンジニアリングとは、大きな問題を小さな問題に分割して、それぞれの解決策を組み立てることに他ならない。
本章のアドバイスは、無関係の下位問題を積極的に見つけて抽出することだ。
- 関数やコードブロックを見て「このコードの高レベルの目標は何か?」と自問する
- コードの各行に対して「高レベルの目標に直接的に効果があるか? あるいは、無関係の下位問題を解決しているか?」と自問する
- 無関係の下位問題を解決しているコードが相当量あれば、それらを抽出して別の関数にする
10.1 入門的な例 Introductory Example
下位問題を抽出することで、難しい計算に心を奪われることなく高レベルの目標に集中できるようになる。
さらに言うと、再利用できるようになりユニットテストができるようになる。
完全に自己完結しているので、自分がアプリケーションにどのように利用されているのかを知らないのだ。
10.2 純粋なユーティリティコード Pure Utility Code
プログラムの核となる基本的なタスクというものがある。例えば、文字列の操作・ハッシュテーブルの使用、ファイルの読み書きなどがそうだ。
たまに自分でこの溝を埋めなきゃいけないことがある。これは「無関係の下位問題」の古典的な例だ。「このライブラリに XYZ() 関数があればいいなあ」と思ったら、その関数を自分で書けばいいのだ! そうしたコードは複数のプロジェクトで使えるユーティリティコードになっていくだろう。
10.3 その他の汎用コード Other General-Purpose Code
コードが独立していれば改善が楽になる。関数というのは、小さくて独立したものになっていれば、機能追加・読みやすさの向上・エッジケースの処理などが楽にできる。
10.4 汎用コードをたくさん作る Create a Lot of General-Purpose Code
汎用コードは素晴らしい。プロジェクトから完全に切り離されているからだ。このようなコードは開発もテストも理解も楽だ。
君のプロジェクトもできるだけ独立したライブラリに独立した方がいい。そうすれば、残りのコードは小さくて考えやすいものになる。
10.5 プロジェクトに特化した機能 Project-Specific Functionality
抽出する下位問題というのは、プロジェクトから完全に独立したものである方がいい。ただし、完全に独立していなくても、それはそれで問題ない。下位問題を取り除くだけでも効果がある。
10.6 既存のインタフェースを簡潔にする Simplifying an Existing Interface
誰もがキレイなインタフェースを提供するライブラリが好きだ。引数は少なくて、事前設定も必要なくて、面倒なことをしなくても使えるライブラリ。インタフェースがキレイだとコードが優雅に見える。簡潔で、しかも強力だ。
インタフェースがキレイじゃなくても、自分でラップ関数を作ることができる。
10.7 必要に応じてインタフェースを整える Reshaping an Interface to Your Needs
多くのコードは、その他のコードを支援するためだけに存在する。こうした「グルー」コードは、プログラムの本質的なロジックとは関係ないことが多い。
10.8 やりすぎ Taking Things Too Far
小さな関数を作りすぎると、逆に読みにくくなってしまう。あちこちに飛び回る実行パスを追いかけることになるからだ。
新しい関数をコードに追加すると、ごくわずかに(でも確実に)読みにくさのコストが発生する。
プロジェクトの他の部分から再利用できるのであれば、小さな関数を追加するのも意味のあることかもしれない。でも、それまでは必要ない。
10.9 まとめ Summary
ほとんどのコードは汎用化できる。一般的な問題を解決するライブラリやヘルパー関数を作っていけば、プログラムに固有の小さな核だけが残る。
この技法が役に立つのは、プロジェクトの他の部分から分離された小さな問題に集中できるからだ。それに、あとでコードを再利用できるかもしれない。
11章 一度に 1 つのことを One Task at a Time
コードは 1 つずつタスクを行うようにしなければいけない。 別の言い方をすれば、本章はコードのデフラグについて説明している。
「一度に 1 つのタスクをする」ためにぼくたちが使っている手順
- コードが行っている「タスク」をすべて列挙する。この「タスク」という言葉はユル句使っている。オブジェクトが妥当かどうかを確認するように小さなこともあれば、ツリーのすべてのノードをいてレートするのように曖昧なこともある。
- タスクをできるだけ異なる関数に分割する。少なくとも異なる領域に分割する。
11.1 タスクは小さくできる Tasks Can Be Small
一度に複数のことをするコードは理解しにくい。いろいろなタスクをやることで、エラーやタイポやバグがあっても一目見ただけでは分からない。
一度に 1 つのタスクをするようになれば楽に理解ができ、一目見ただけで正しく動きそうと分かる。
11.2 オブジェクトから値を抽出する Extracting Values from an Object
ユーザの所在地を読みやすい文字列(「都市、国」)に整形するコードを考える。
Locality Name | Sub Administrative Area Name | Administrative Area Name | Country Name | OUTPUT |
---|---|---|---|---|
"Santa Monica" | "Los Angeles" | "California" | "USA" | "Santa Monica, USA" |
ここまでは簡単だ。ややこしいのは、この 4 つの値のいずれかが(あるいはすべてが)存在しない可能性があることだ。いかに対応策を示す。
- 「都市」を選ぶときには、「Locality Name」(市や町)→「Sub Administrative Area Name」(都市や郡)、「Administrative Area Name」(州や地域)の順番で使用可能なものを使う。
- 以上の 3 つすべてが使えない場合は、「都市」に「Middle-of-Nowhere」(何でもない場所)というデフォルト値を設定する。
- 「国」に「Country Name」(国名)が使えない場合は、「Planet Earth」(地球)というデフォルト値を設定する。
Locality Name | Sub Administrative Area Name | Administrative Area Name | Country Name | OUTPUT |
---|---|---|---|---|
(undefined) | (undefined) | (undefined) | "Canada" | "Middle-of-Nowhere, Canada" |
(undefined) | "Washington, DC" | (undefined) | "USA" | "Washington, DC, USA" |
「一度に 1 つのことを」を適用しないと、ひどいコードになるだろう。
コードをリファクタリングするときには、複数の手法が使えることが多い。
今回も例外ではない。 タスクを分割すると、コードのことを考えやすくなる。その結果、もっとうまくリファクタリングできる方法を思いつく可能性がある。
11.3 もっと大きな例 A Larger Example
ウェブページをダウンロードしたあとに毎回呼ばれ、さまざまな統計値を更新するウェブクローリングする関数を考える。
- キーのデフォルト値に「unknown」を使う
- 必要なメンバがあるかどうかを確認する
- 値を抽出して文字列に変換する
- カウンタを更新する
「一度に 1 つのことを」を適用しないと、大量のコードになる。ロジックもいっぱい。重複コードだってある。読んでいて楽しくない。
このコードを改善するには、タスクを目的を持った別々の領域に分割すればよい。
- 3 つのキーのデフォルト値を定義する
- 可能であれば、キーの値を抽出して文字列に変換する
- キーのカウンタを更新する
領域を分けておくといいのは、それぞれが分離されているからだ。ある領域にいるときは、他の領域のことは考えなくて済む。
11.4 まとめ Summary
コードを構成する簡単な技法「一度に 1 つのタスクを行う」を紹介した。
読みにくいコードがあれば、そこで行われているタスクをすべて列挙する。そこには別の関数やクラスに分割できるタスクがあるだろうか。それ以外は、関数の論理的な「段落」になる。
タスクをどのように分割するかよりも、運渇するということが大切なのだ。
12章 コードに思いを込める Turning Thoughts int Code
おばあちゃんがわかるように説明できなければ、本当に理解したとは言えない。
―― アルバート・アインシュタイン
誰かに複雑な考えを伝えるときには、細かいところまで話すぎると相手を混乱させてしまう。自分よりも知識が少ない人が理解できるような「簡単な言葉」で説明する能力が大切だ。これは誰かに理解してもらうだけでなく、自分の考えをより明確にすることにもなる。
コードを読み手に「プレゼント」するときにも、これと同じ能力を使うべきだ。ソースコードというのは、プログラムの動作を説明する最も大切な手段だとぼくたちは考えている。つまり、コードも「簡単な言葉で」書くべきなのだ。
本章では、コードをより明確にする簡単な手順を使う。
- コードの動作を簡単な言葉で同僚にも分かるように説明する。
- その説明のなかで使っているキーワードやフレーズに注目する。
- その説明に合わせてコードを書く。
12.1 ロジックを明確に説明する Describing Logic Clearly
大きなロジックは理解が難しい。単純化することを考えよう。でも、どうやって?
簡単な言葉でロジックを説明してみよう。
権限があるのは、以下の 2 つ。その他は、権限がない。
- 管理者
- 文書の所有者(文書がある場合)
この説明から新しい解決策を思いついた。
12.2 ライブラリを知る Knowing Your Libraries Helps
簡潔なコードを書くのに欠かせないのは、ライブラリが何を提供してくれるかを知ることだ。
12.3 この手法を大きな問題に適用する Applying This Method to Larger Problems
これからやろうとしていることを簡単な言葉で説明する手法を使えば、コードを分割する単位を見つけることができる。
12.4 まとめ Summary
プログラムのことを簡単な言葉で説明することで、コードがより自然になっていく。この技法は思っているよりも簡単だが、非常に強力だ。説明に使っている単語やフレーズをよく見れば、分割する下位問題がどこにあるかがわかる。
ある大学の計算機センターにはこんな方針があった。プログラムのデバッグに悩む学生は、部屋の隅に置かれたテディベアに向かって最初に説明しなければいけないのである。驚くべきことに、問題を声に出して説明するだけで、学生は解決策が見つかるのだ。この技法は「ラバーダッキング」とも呼ばれる。
問題や設計をうまく言葉で説明できないのであれば、何かを見落としているか、詳細が明確になっていないということだ。プログラム(あるいは自分の考え)を言葉にすることで明確な形になるのである。
13章 短いコードを書く Writing Less Code
最も読みやすいコードは、何も書かれていないコードだ。
13.1 その機能の実装について悩まないで ―― きっと必要ないから Don't Bother Implementing That Feature ―― You Won't Need It
プロジェクトを開始するときには、これから実装するカッコいい機能のことを考えて興奮するものだ。そして、プロジェクトに欠かせない機能を過剰に見積もってしまう。その結果、多くの機能が、完成しないか、全く使われないか、アプリケーションを複雑にするものになってしまう。
プログラマというのは、実装にかかる労力を過小評価するものである。プロトタイプの実装にかかる時間を楽観的に見積ったり、将来的に必要となる保守や文書化などの「負担」時間を忘れたりする。
13.2 質問と要求の分割 Question and Break Down Your Requirements
すべてのプログラムが、高速で、100% 正しくて、あらゆる入力をうまく処理する必要はない。
要求を詳しく調べれば、問題をもっと簡単にできることもある。そうすれば、必要なコードも少なくて済む。このような例をいくつか見ていこう。
例: 店舗検索システム Example: A Store Locator
「任意のユーザの緯度経度に対して、最も近い店舗を検索する。」
これを 100% ただしく実装するには、以下のことも考慮しなければいけない。
- 日付変更線をまたいでいるときの処理
- 北極や南極に近いときの処理
- 「 1 マイル当たりの」経度に対応した地球の曲率の調整
これらを真面目に処理すれば、相当な量のコードになる。でも、君のアプリケーションで扱うのは、テキサス州にある 30 軒の店舗だけだ。こんな小さな範囲で上記の 3 つの処理を考える必要はない。
「テキサス州のユーザのために、テキサスで最も近くにある店舗を検索する。」
この問題を解決するのは簡単だ。すべての店舗との距離を計算すればいい。
例: キャッシュを追加する Example: Adding a Cache
ディスクから頻繁にオブジェクトを取得するアプリケーションを考える。アプリケーションの速度は、ディスクの読み取り速度によって制限されていた。ぼくたちは何らかのキャッシュを実装したいと思っていた。
read ObjectA
read ObjectA
read ObjectA
read ObjectB
read ObjectB
read ObjectC
read ObjectD
read ObjectD
同じオブジェクトでも何度もアクセスしているのがわかると思う。キャッシュは確実に有効だ。
この問題に直面した時には、LRU 方式のキャッシュを使おうと思っていた。でも、手持ちのライブラリがなかったので、自分たちで実装することにした。そのときはハッシュテーブルとたん方向リストの両方を使っていて、コードは全部で 100 行になった。
でも、アクセスが必ず順番に行われていることに気づいたので、LRU 方式のキャッシュではなく、単項目キャッシュ(one-item cache)を実装することにした。LRU 方式を使った時の 90% の効果があった。それにメモリ使用量も少なくなった。
「要求の削除」と「より単純な問題の解決」に利点があるというのは、何も大げさに言ってるわけじゃない。要求というのは、お互いに微妙に干渉してしまうものだ。問題を半分にすることで、1/4 のコーディング時間で済むこともあるくらいだ。
1.3 コードを小さく保つ Keeping Your Codebase Small
プロジェクトを始めるときには、ソースファイルは 1 つか 2 つしかない。素晴らしい。コードをコンパイルして実行するなんて簡単だ。変更もしやすい。関数やクラスをどこで定義しているかもすぐに思い出せる。
プロジェクトが進んでいくと、ファイルが増えていく。ディレクトリを分けてファイルを整理しなきゃいけなくなってくる。どの関数がどの関数を呼び出しているかよくわからなくなってくる。バグを見つけるのもだんだん面倒になっていく。
最終的には、いろんなディレクトリにファイルが散らばることになる。プロジェクトは巨大になって、すべてを把握できる人はだれもいなくなる。新しい機能を追加するのが苦痛になってくる。コードを扱うのが厄介になって楽しくなくなる。
あらゆる協調システムは成長する。それらを結び付ける複雑さはもっと速い速度で成長する。これは宇宙の自然法則だ。
つまり、プロジェクトが成長しても、コードをできるだけ小さく軽量に維持するしかない。
- 汎用的な「ユーティリティ」コードを作って、重複コードを削除する(「10章 無関係の会問題を抽出する」参照)。
- 未使用のコードや無用の機能を削除する。
- プロジェクトをサブプロジェクトに分割する。
- コードの「重量」を意識する。軽量で機敏にしておく。
未使用のコードを削除する Removing Unused Code
園芸家は、植物が生き生きと成長し続けられるように枝を刈り込む。これと同じで、邪魔になっている未使用のコードを刈り込むのはいい考えだ。
コードを書いたら、削除したくはないものだ。コードというのは「実際の仕事」をあらわしたものだからだ。つまり、コードをさくじょすれば、そこに費やした時間を無駄にすることんいなる。そんなのどうでもいいんだよ! ここはクリエイティブな分野だ。写真家・作家・映像制作者が、自分の作ったものすべて残しているだろうか。
独立した関数を削除するのは簡単だ。でも、「未使用のコード」は、気づかないうちにプロジェクト全体に深く絡み合っていることがある。
- 国際的なファイル名を扱うシステムをせっけいしたとする。そのコードは今でも変換コードに含まれている。でも、そのコードは一度も使われていない。アプリケーションが国際的なファイルを扱うことがないからだ。どうしてこの機能を削除しないのだろうか?
- メモリを使い果たしても動くようなシステムにしたかったので、メモリ不足からうまく回復するロジックをたくさんプログラムに組み込んだ。考え方は悪くなかったけど、実際にメモリ不足になると単なる不安定なゾンビになってしまう。必要な機能も使えない。ワンクリックで死亡だ。どうして「システムがメモリ不足です」と言ってプログラムを終了しないのだろうか? どうしてメモリ不足の原因となっているコードを削除しないのだろうか?
13.4 身近なライブラリに親しむ Be Familiar with the Libraries Around You
プログラマというのは、既存のライブラリで問題を解決できることを知らないことが多い。あるいは、ライブラリで可能なことを忘れていることが多い。ライブラリの機能を熟知して、実際に活用することが大切だ。
ここでささやかな提案だ。たまには標準ライブラリのすべての関数・モジュール・型の名前を 15 分かけて読んでみよう。
ライブラリを全部覚えろと言っているわけじゃない。どんなことができそうか感じ取るだけでいい。そうすれば、新しいコードをかいているときに、「ちょっと待てよ。これは、API でみたような……」と思い出すことができる。
例: リストとセット Lists and Sets
リストの要素から重複を取り除きたいとする。
でも、あまり知られていない Set 型を使った方がいい。
ライブラリの再利用はなぜいいことなのか Why Reusing Libraries Is Such a Win
よく使われる統計だけど、平均的なソフトウェアエンジニアが 1 日に書く出荷用のコードは、10 行なのだそうだ。
大切なのは、出荷用という言葉だ。成熟したライブラリのコードの裏側には、膨大な統計・デバッグ・修正・文書・最適化・テストが存在する。このダーウィンの進化を生き延びてきたコードには大きな価値がある。これがライブラリの再利用が良しとされている理由の 1 つだ。時間の節約になるし、書くコードも少なくなる。
13.5 例: コーディングするよりも Unix ツールボックスを使う Example: Using Unix Tools Instead of Coding
ウェブサーバが頻繁に 4xx や 5xx の HTTP レスポンスコードを返していいたら、何かもんだいがあるという兆候だ。ウェブサーバのアクセスログをパースシテ、どの URL でエラーが発生しているかを調べたい。
4xx や 5xx のレスポンスコードをかえしている url-path を見つけるプログラムは、軽く 20 行を超えてしまう。一方 Unix では以下のコマンドを入力すればいい。
cat acces.log | awk '{ print $5 " " $7 }' | egrep "[45]..$" \ | sort | uniq -c | sort -nr
コマンドラインが素晴らしいのは、「本物」のコードを書かなくてもいいことだ。それから、ソース管理にチェックインしなくても済む。
13.6 まとめ Summary
本章では、できるだけコードを書かないことについて説明した。新しいコードは、テストや文書や保守が必要になる。また、コードが増えると「重く」なるし、開発も難しくなる。
新しいコードを書かないようにするには
- 不必要な機能をプロダクトから削除する。過剰な機能は持たせない。
- もっとも簡単に問題を解決できるような要求を考える。
- 定期的にすべての API を読んで、標準ライブラリに慣れ親しんでおく。
14章 テストと読みやすさ Testing and Readability
本章では、スッキリと効果的なテストを書くための簡単な技法を教えよう。
テストというのは人によって意味が違う。本章における「テスト」とは、他のコードの振る舞いを確認するためのすべてのコードのことだ。
14.1 テストを読みやすくて保守しやすいものにする Make Tests Easy to Read and Maintain
テストコードを読みやすくするのは、テスト以外のコードを読みやすくするのと同じくらい大切なことだ。テストコードというのは「本物のコードの動作と使い方を示した非公式的な文書」だと考えるプログラマもいるほどである。テストが読みやすければ、本物のコードの動作が理解しやすくなる。
テストコードが大きくて恐ろしいものだとしたら、以下のようなことが起きる。
- 本物のコードを修正するのを恐れる。 ―― 「うへえ。このコードには手を出したくないなあ。テストを変更するなんて悪夢だよ。」
- 新しいコードを書いたときにテストを追加しなくなる。 ―― テストのあるモジュールが減っていく。そして、コードが正しく動いているのか自身が持てなくなる。
コードのユーザ(特に自分!)には、テストコードを安心して使ってもらいたい。テストコードを変更したことで既存のテストが壊れたとしても、簡単に原因を突き止められるようにしておきたい。そうすれば、安心してテストを追加できるようになる。
14.2 このテストのどこがダメなの? What's Wrong with This Test?
// × 少なくとも 8 つ問題がある
@Test
fun test1() {
var docs = listOf<ScoredDocument>()
docs[0].url = "http://example.com"
docs[0].score = -5.0
docs[1].url = "http://example.com"
docs[1].score = 1.0
docs[2].url = "http://example.com"
docs[2].score = 4.0
docs[3].url = "http://example.com"
docs[3].score = -99998.7
docs[4].url = "http://example.com"
docs[4].score = 3.0
docs = sortAndFilterDocs(docs = docs)
assert(docs.size == 3)
assert(docs[0].score == 4.0)
assert(docs[1].score == 3.0)
assert(docs[2].score == 1.0)
}
14.3 テストを読みやすくする Making This Test More Readable
一般的な設計原則として「大切ではない詳細はユーザから隠し、大切な詳細は目立つようにする」べきだ。
前節のテストコードは、明らかにこの原則を破っている。これをキレイにするには、最初にヘルパー関数を作る。
最小のテストを作る Creating the Minimal Test Statement
さらに改善するには、「12章 コードに思いを込める」の技法を使おう。このテストが何をしようとしているのかを簡単な言葉で説明するのだ。
文書のスコアは [-5, 1, 4, -99998.7, 3] である。
sortAndFilterDocs() を呼び出したあとのスコアは [4, 3, 1] である。
スコアはこの順番でなければいけない。
いちばん大切なのはスコアの配列なのだ。テストの本質というのは、「こういう状況と入力から、こういう振る舞いと出力を期待する。」のレベルまで要約できる。コードを簡潔に読みやすくするだけでなく、テストステートメントを短くすることで、テストケースの追加が簡単になる。
独自の「ミニ言語」を実装する Implementing Custom "Minilanguages"
独自の見に言語を定義すれば、小さな領域で多くの情報を表現できる。
14.4 エラーメッセージを読みやすくする Making Error Messages Readable
もっといい assert() を使う Using Better Versions of assert()
多くの言語やライブラリには、洗練された assert() が用意されている。もし使えるのであれば。便利なアサーションメソッドを使うべきだ。テストが失敗するたびに役に立つ。
手作りのエラーメッセージ Hand-Crafted Error Messages
既存でいいエラーメッセージが得られなければ、自分で書けばいい! この話の教訓は、「エラーメッセージはできるだけ役に立つようにする」だ。もしかすると、自分好みのエラーメッセージを印字する「手作りアサート」を用意するのが最善の道かもしれない。
14.5 テストの適切な入力値を選択する Choosing Good Test Inputs
テストの適切な入力値を選択するには優れた技能が必要だ。 どうすれば適切な入力値を選択できるのだろう? 適切な入力値というのは、コードを完全にテストするものでなければいけない。それに簡単に読めるような単純なものでなければいけない。
入力値を単純化する Simplifying the Input Values
テストには最もキレイで単純な値を選ぶ。
1 つの機能に複数のテスト Multiple Tests of Functionality
コードを検証する「完璧」な入力値を 1 つ作るのではなく、小さなテストを複数作る方が、簡単で、効果的で、読みやすい。
丁寧にやりたければ、テストを増やすこともできる。テストケースが分割されていれば、次の人がコードを扱いやすくなる。意図せずにバグを発生させたとしても、失敗したテストによってその場所がわかる。
14.6 テストの機能に名前をつける Naming Test Functions
テストコードは関数になっていることが多い。関数はテストするメソッドや状況でひとまとめにする。
テスト関数に名前をつけるのは、退屈で無駄なことだと思うかもしれない。でも、 test1() や test2() のような意味のない名前をつけてはいけない。テストの内容を表した名前をつけるべきだ。
- テストするクラス(もしあれば)
- テストする関数
- テストする状況やバグ
長くて変な名前にならないかと怖がることはない。他のコードから呼び出されるものではないので、長くなっても構わない。テスト関数の名前はコメントだと思えばいい。ほとんどのテスティングフレームワークでは、テストが失敗したらその関数の名前が印字されるようになっている。だから、名前は説明的なほうが役に立つのである。
14.7 このテストのどこがダメだったのか? What Was Wrong with That Test?
// × 少なくとも 8 つ問題がある
@Test
fun test1() {
var docs = listOf<ScoredDocument>()
docs[0].url = "http://example.com"
docs[0].score = -5.0
docs[1].url = "http://example.com"
docs[1].score = 1.0
docs[2].url = "http://example.com"
docs[2].score = 4.0
docs[3].url = "http://example.com"
docs[3].score = -99998.7
docs[4].url = "http://example.com"
docs[4].score = 3.0
docs = sortAndFilterDocs(docs = docs)
assert(docs.size == 3)
assert(docs[0].score == 4.0)
assert(docs[1].score == 3.0)
assert(docs[2].score == 1.0)
}
- このテストには、どうでもいいことがたくさん書かれている。テストが何をしているかは 1 つの文で記述できる。テストステートメントはあまり長くしてはいけない。
- テストが簡単に追加できない。思わずコピペしてしまいそうになる。そうなれば、長くて重複の多いコードになってしまう。
- 失敗メッセージが役に立たない。このテストが失敗すると、「Assertion failed」と表示されるだけだ。デバッグに使える情報が足りない。
- 一度にすべてのことをテストしようとしている。ここではマイナスのフィルタリングとソートの機能のテストだ。テストは分割した方が読みやすい。
- テストの入力値が単純ではない。 -99998.7 は「大音量」で目立つけど、これといって意味はない。もっと単純なマイナス値で十分である。
- テストの入力値が不完全である。例えば、スコアが 0 の文書をテストしていない(この文書はフィルタリングされるのか? されないのか?)。
- 極端な入力値を使ってテストしていない。例えば、空の入力値・巨大な入力値・重複した入力値など。
- test1() といういみのない名前がついている。テスト関数の名前は、テストする関数や状況を表したものにすべきだ。
14.8 テストに優しい開発 Test-Friendly Development
コードにはテストしやすいものとそうでないものがある。テストしやすいこーどには、明確なインタフェースがある。状態の「セットアップ」がない。検証するデータが隠されていない。
プログラムをクラスやメソッドに分割するということは、疎結合にした方がテストしやすいからである。プログラムが密結合していて、クラス間でメソッド呼び出しがたくさん行われていて、メソッド呼び出しに多くの引数が必要だったらどうだろう。プログラムがりかいしにくいだけでなく、テストコードも汚くて読み書きしにくいものになっているはずだ。
テスト容易性の低いコードの特性とそこから生じる設計の問題
特性 | テスト容易性の問題 | 設計の問題 |
---|---|---|
グローバル変数を使っている。 | グローバルの状態をテストごとに変数化する必要がある。 | どの関数にどんな副作用があるのかわかりにくい。関数を個別に考えることができない。すべてが動くことを理解するにはプログラム全体を把握しなければいけない。 |
多くの外部コンポーネントに依存している。 | 最初に足場を設定しなければいけないので、テストを書くのが難しい。テストを書くのが楽しくないので、みんなテストを書こうとしなくなる。 | 依存しているものが落ちるとシステムが使えなくなる。任意の変更にどんな影響があるのかを理解するのが難しい。クラスのリファクタリングが難しい。システムが考えなければいけない故障状態や回復経路が増える。 |
コードが非決定的な動作をする。 | テストは当てにならず、信頼できない。テストが正常に動かないことがあるので、最終的に無視されるようになる。 | プログラムが競合状態になったり、再現不可能なバグが発生したりする。プラグラムを論理的に判断できなくなる。バグを追跡したり修正したりするのが非常に難しい。 |
テスト容易性の高いコードの特性とそこから生じる設計の利点
特性 | テスト容易性の問題 | 設計の問題 |
---|---|---|
クラスが小さい。あるいは内部状態を持たない。 | テストしやすい。メソッドをテストするのにセットアップがあまり必要にならない。検査する状態が隠されていない。 | 状態の少ないクラスは単純で理解しやすい。 |
クラスや関数が 1 つのことをしている。 | 完全にテストするためにテストケースが少なくて済む。 | 小さくて単純なコンポーネントシステムがモジュール化されている。システムは疎結合である。 |
クラスは他のクラスにあまり依存していない。高度に疎結合かされている。 | 各クラスは独立してテストできる。 | システムは並列的に開発できる。クラスは他の部分を気にすることなく簡単に修正や削除ができる。 |
関数は単純でインタフェースが明確である。 | 明確な動作をテストできる。単純なインタフェースなのでテストが楽。 | インタフェースがわかりやすくて再利用しやすい。 |
14.9 やるすぎ Going Too Far
場合によってはテストに集中しすぎてしまう可能性もある。
- テストのための本物のコードの読みやすさを犠牲にしてしまう。―― 本物のコードをテストしやすいように設計するには、両社に利点がなければいけない。本物のコードは単純で疎結合なものにする。テストは読み書きしやすくする。テストをしやすくするために、本物のコードにゴミを入れてはいけない。
- テストのカバレッジを 100% にしないと気が済まない。―― コードの 90% をテストする方が、残りの 10% をテストするよりも楽である。最後の 10% にはユーザインタフェースやどうでもいいエラーケースが含まれている。その部分はバグのコストが高くないので、テストが割に合わない。現実的には、カバレッジが 100% になることはない。もしも 100% になっているのだとしたら、バグを見逃しているか、機能を実装していないか、仕様が変更されたことに気づいていないかのどれかだ。バグのコストによって、テストコードにかける最適な時間は違ってくる。ウェブサイトのプロトタイプを作っているのであれば、テストは価格なくも問題ない。でも、宇宙船の制御装置や医療機器を作っているのであれば、テストに集中しなければいけないだろう。
- テストがプロダクト開発の邪魔になる。―― プロジェクトの一部にすぎないテストが、プロジェクト全体を支配している状況を目にしたことがある。テストが触れてはいけない神のようになっているのだ。プラグラマたちは、貴重なエンジニアリングの時間を犠牲にしていると知りながら、ある種の儀式としてテストを行っているのである。
14.10 まとめ Summary
テストコードでも読みやすさが大切だ。テストが読みやすければ、テストが書きやすくなり、みんながテストを追加しやすくなる。また、本物のコードをテストしやすく設計すれば、コードの設計が全体的に改善できる。
テストを改善する点をまとめよう。特に、新しいテストの追加や修正を簡単にすることが大切だ。
- テストのトップレベルはできるだけ簡潔にする。入出力のテストはコード 1 行で記述できるといい。
- テストが失敗したらバグの発見や修正がしやすいようなエラーメッセージを表示する。
- テストに有効な最も単純な入力値を使う。
- テスト関数に説明的な名前を付けて、何をテストしているのかを明らかにする。
15章 「分/時間カウンタ」を設計・実装する Designing and Implementing a "Minute/Hour Counter"
本物のプロダクトコードで使われているデータ構造「分/時間カウンタ」を見ていこう。問題を解決して、パフォーマンスを改善して、機能を追加する。このようなエンジニアの自然な思考プロセスをたどることにしよう。でも、一番大切なのは、本書の原則を使ってコードを読みやすくすることだ。
15.1 問題点 The Problem
ウェブサーバの直近 1 分間と直近 1 時間の転生バイト数を把握したい。
素朴な問題だ。でも、これを効率的に解決するのは簡単ではなさそうだ。
15.2 クラスのインタフェースを定義する Defining the Class Interface
abstract class MinutesHourCounter {
// カウンタを追加する
abstract fun count(numBytes: Int)
// 直近 1 分間のカウンタを返す
abstract fun minuteCount(): Int
// 直近 1 時間のカウントを返す
abstract fun hourCount(): Int
}
名前を改善する Improving the Name
count() というメソッド名には問題がある。
ぼくたちは同僚に「count() は何をすると思う?」と聞いてみた。すると「全期間のカウントを返す」と答えた人が何人もいた。つまり、この名前は直観的ではないのだ。count に名詞と動詞があるのが問題なのだろう。
count() の代わりになりそうなものを挙げよう。
- increment() 値が増加する一方だと思われてしまう。
- observe() 問題はない。でも少し曖昧だ。
- record() 名詞と動詞の問題があるのでダメだ。
- add() 興味深い。
また、MinuteHourCounter クラスが仮引数の「バイト数」を知る必要はない。
abstract class MinutesHourCounter {
// カウンタを追加する
abstract fun add(count: Int)
// 直近 1 分間のカウンタを返す
abstract fun minuteCount(): Int
// 直近 1 時間のカウントを返す
abstract fun hourCount(): Int
}
コメントを改善する Improving the Comments
add() のコメントは冗長だ。削除するか改善すべきだ。
minuteCount() のコメントの意味を同僚に聞いてみたところ、2 種類の答えが返ってきた。
- 「午後 12:13 のような現在時刻の分数のカウントを返す。
- 時刻の分数に関係なく「直近 60 秒間のカウント」を返す。
2 つ目の解釈が実際の動作である。より正確で詳細な言葉を使って、誤解のない明確なものにしよう。
// 直近 1 分間および直近 1 時間の累積カウントを記録する。
// 例えば、帯域幅の使用状況を確認するのに使える。
abstract class MinutesHourCounter3 {
// 新しいデータ点を追加する(count >= 0)
// それから 1 分間は、minuteCount() の返す値が +count だけ増える。
// それから 1 時間は、minuteHour() の返す値が +count だけ増える。
abstract fun add(count: Int)
// 直近 60 秒間の累積カウントを返す
abstract fun minuteCount(): Int
// 直近 3660 秒間の累積カウントを返す
abstract fun hourCount(): Int
}
15.3 試案1: 素朴な解決策 Attempt 1: A Native Solution
このコードは理解しやすいか? Is the Code Easy to Understand?
この解決策は「正しい」けれど、読みにくい点がいくつかある。
- while ループが少しうるさい。―― この部分を読むときに速度が著しく落ちる。(もちろんバグがないことを確かめるには速度を落とす必要がある。)
- minuteCount() と hourCount() がほぼ同じ。―― 重複コードを共通化すればコードをちいさくできる。ここは比較的複雑なところなので、特に共有化した方がいい(複雑なコードは一か所にまとめるべきだ)。
読みやすいバージョン An Easier-to-Read Version
minuteCount() と hourCount() のコードは数値が違うだけだ。両方を処理するヘルパーメソッドを導入して、リファクタリングするのがいいだろう。
class MinutesHourCounter5 {
private val events = arrayListOf<Event>()
// 新しいデータ点を追加する(count >= 0)
// それから 1 分間は、minuteCount() の返す値が +count だけ増える。
// それから 1 時間は、minuteHour() の返す値が +count だけ増える。
fun add(count: Int) {
events.add(
Event(
count = 0,
time = Instant.now()
)
)
}
// 直近 60 秒間の累積カウントを返す
fun minuteCount(): Int {
return countSince(Duration.ofSeconds(Instant.now().epochSecond - 60))
}
// 直近 3660 秒間の累積カウントを返す
fun hourCount(): Int {
return countSince(Duration.ofSeconds(Instant.now().epochSecond - 3600))
}
private fun countSince(cutoff: Duration): Int {
var count = 0
val rit = events.reversed().iterator()
while (rit.hasNext()) {
if (rit.next().time.epochSecond <= cutoff.seconds) {
break
}
count += rit.next().count
}
return count
}
}
この新しいコードには、注目すべき点がいくつかある。
まず、countSince() の仮引数の名前を secsAgo という相対値ではなく、 cutoff という絶対値にしている。動作がかわるわけではないけど、名前を変えた方が countSince() が扱いやすくなる。
次に、イテレータの名前を i から rit に変えている。i はインデックスによく使われる名前なので、ぼくたちも最初はこの名前を使おうと思っていた。でも、ここで使っているのは逆向きのイテレータだ。このコードを正しく動かすには欠かせない部分である。
最後に、while ループから rit.next().time.epochSecond <= cutoff.seconds という条件を抽出して、新しく if 文を作っている。これはなぜだろうか? こうすれば「すべての要素を調べる」ことがすぐにわかるし、それ以上深く考える必要がなくなる。
パフォーマンスの問題 Performance Problems
これまでのコードの見た目を中心に改善してきたけど、この設計には深刻なパフォーマンスの問題がある。
- これからも大きくなっていく。このクラスはすべてのイベントを保持している。つまり、メモリを無限に使用してしまうのだ! MinuteHourCounter は、1 時間よりも古い不要なイベントを自動的に削除するべきだ。
- minuteCount() と hourCount() が遅すぎる。countSince() メソッドの処理時間は O(n) である。n は任意の時間帯のデータ点の数だ。高性能のサーバが、add() を 1 秒間に何百回呼び出したらどうなるだろうか。hourCount() を呼び出すたびに、数百万個のデータ点をカウントしなければいけなくなるのだ! MinuteHourCounter は、add() の呼出しに対応する値を minuteCount と hourCount とで別々に保持すべきだ。
15.4 試案2: ベルトコンベヤー設計 Attempt 2: Conveyor Belt Design
さきほどの問題を両方解決する設計が必要だ。
- 不要なデータを削除する。
- 事前に minuteCount と hourCount の値を最新のものにしておく。
これからやることを説明しておこう。List をベルトコンベヤーのように使うのだ。一方の端に新しいデータが到着したら合計に加算する。データが古すぎたらもう一方の端から「落下」させて合計から減算する。
ベルトコンベヤー設計の実装は 2 種類ある。
1 つ目は、2 つの List を管理する方法だ。1 つは直近 1 分間のイベントに、もう 1 つは直近 1 時間のイベントに使う。新しいイベントが到着したら両方にコピーを追加する。この方法はすごく単純だけど、すべてのイベントを 2 つずつ作るので非効率だ。
2 つ目も、2 つの List を管理する方法だ。ただし、1 番目の List にイベントが到着してから、2 番目の List に流れ込むようになっている。2 つ目の「二段階」ベルトコンベヤー設計の方が効率的なので、こちらを実装することにしよう。
二段階ベルトコンベヤーの実装 Implementing the Two-Stage Conveyor Belt Design
これで終わり? Are We Done?
これで 2 つのパフォーマンス問題が解決できた。この解決策で問題ない。多くのアプリケーションではこれで十分だろう。ただし、欠点がたくさんある。
まず、この設計には柔軟性がない。例えば、直近 24 時間のカウントを保存したいとする。すると、多くのコードに修正が必要になる。
次に、メモリの使用量が多い。高トラフィックのサーバが 1 秒間に 100 回も add() を呼び出したとしよう。直近 1 時間のデータをすべて保持しているので、約 5MB のメモリが必要になる。
add() が頻繁に呼び出されると、それだけメモリを多く消費してしまう。プロダクション環境では、予測不能な量のメモリを使うライブラリはよしとされていない。add() が呼び出される頻度に関係なく、MinuteHourCounter の使用するメモリは一定である方がいい。
15.5 試案3: 時間バケツの設計 Attempt 3: A Time-Bucketed Design
気づいていないかもしれないけど、先ほどの 2 つの実装にはどちらも小さなバグがあった。タイムスタンプを保持するのに使ったクラスは秒数を保持しているので、ここで数値が丸められてしまう。その結果、minuteCount() は呼び出された時刻によって、59 秒か 60 秒のデータを返すことになる。
ミリ秒の粒度を使えばこの問題はすべて解決できる。でも、MinuteHourCounter を使っているアプリケーションでは、このレベルの精度を必要としない。というわけで、このことを踏まえて、高速でメモリ使用量も少ない新しい MinuteHourCounter を設計した。パフォーマンスと精度はトレードオフなのだ。
イベントを小さな時間帯に分けてバケツに入れ、バケツ単位でイベントの合計値を出すというのが鍵となる考えだ。例えば、直近 1 分間のイベントは、1 秒ごとに 60 個のバケツに入れる。直近 1 時間のイベントは、1 分ごとに 60 個のバケツに入れる。
このようなバケツを使えば、minuteCount() や hourCount() のメソッドは 1/60 の精度になる。
もっと正確なものが必要であれば、メモリ使用量と引き換えにバケツの数を増やすことができる。でも、ここで一番大切なのは、個の設計にすればメモリ使用量を固定化できて予測可能になるということだ。
時間バケツの実装 Implementing the Time-Bucketed Design
この設計をクラス 1 つで実装すると、理解できない複雑なコードになる。「11 章 一度に 1 つのことを」のアドバイスにしたがって、複数のクラスで異なる部分を処理していきたい。これにより、コードは読みやすくなり、柔軟性も高くなった。
TrailingBucketCounter を実現する Implementing TrailingBucketCounter
ConveyorQueue の実装 Implementing ConveyorQueue
15.6 3 つの解決策を比較する Comparing the Three Solutions
本章で取り上げた解決策を比較しよう。
解決策 | コードの行数 | HourCount() の計算量 | メモリ使用量 | HourCount() の誤差 |
---|---|---|---|---|
素朴な解決策 | 33 | O(1時間のイベント数)(~360万) | 制限なし | 1/3600 |
ベルトコンベヤー設計 | 55 | O(1) | O(1時間のイベント数)(~5MB) | 1/3600 |
時間バケツ設計(バケツ 60 個) | 98 | O(1) | O(バケツの数)(~500バイト) | 1/60 |
3 つのクラスを使うことになった時間バケツのコード行数は、他 2 つのコード行数よりもずっと多い。でも、パフォーマンスは高石、設計に柔軟性がある。それに、クラスに分割しているので読みやすい。50 行の読みにくいコードよりも、100 行の読みやすいコードの方が優れているのだ。
問題を複数のクラスに分割すると、複数のクラスになったことが原因で複雑になることもある。今回はクラスが「線形」につながっていて、ユーザに公開されているクラスは 1 つだけになっている。したがって、問題を分割することで、利点だけが得られるようになっている。
15.7 まとめ Summary
最終的に MinuteHourCounter になるまでの手順をおさらいしよう。コードの進化の様子がうまく表されている。
まずは、素朴な解決策から始めた。ここから設計上の課題が 2 つあることが分かった。速度とメモリ使用量である。
次に、「ベルトコンベヤー」設計を試した。個の設計は速度とメモリ使用量の問題は改善できたけど、高パフォーマンスのアプリケーションには適していなかった。また、柔軟性が乏しく、その他の時間帯を扱うにはかなり手を入れる必要があった。
最終的な設計では、複数の下位問題に分割することで、これらの問題を解決した。ボトムアップで 3 つのクラスを作り、それぞれのクラスで各下位問題を解決するようにした。
- ConveyorQueue 最大長であるキュー。「シフト」可能で合計値を保持する。
- TrailingBucketCounter 時間経過に伴って ConveyorQueue を移動する。また、1 つの時間帯のカウンタを任意の精度で保持する。
- MinuteHourCounter 2 つの TrailingBucketCounter を保持する。1 つは 1 分間のカウントで、もう 1 つは 1 時間のカウントだ。