「読みやすい」とはどういうことか? コード品質の一指標
「良いコード」とは何でしょうか? コードの品質には色々な指標がありますが、「読みやすいコードは良いコードである」というのは一つの指標として多くの方が認めるところではないでしょうか。しかし、では読みやすいコードとはどのようなコードかというのもなかなか難しい問題です。
この記事では、品質の良いコードとしての「読みやすいコード」に対する筆者の考え方を共有します。もちろんこれが唯一解だと主張するつもりはありませんが、参考になった・共感したという方はぜひこの記事を周りに教えてあげてください。
なお、サンプルコードはTypeScriptを使って示しますが、必要に応じて説明するのでTypeScriptの経験が無い方でも読むことができます。
短いまとめ
- 読みやすいコードとは、書き手の意図が伝わりやすいコードです。
- 書き手の意図を読み手に伝えるには、読み手に意図を推論してもらうためのヒントを残します。
- 複数の書き方があるときは、もっともヒントになりやすい(言い換えれば、情報量が多い)選択肢を選びましょう。
- ヒントを与えるときは合理的な読者を優遇すべきです。
以下からは、いくつかの具体例を通じてこの考え方を解説していきます。
let
とconst
の問題
JavaScript/TypeScriptでは、変数を宣言する際にvar
を使う・let
を使う・const
を使うという3つの選択肢があります。var
はJavaScriptの最初から存在しており、let
とconst
はES2015で追加されました。var
と残りの2つの間には、スコープが関数スコープかブロックスコープかという違いや、同じ変数の再宣言が可能かどうかといった違いがあります。let
やconst
の方が後から追加されただけあって優れているので、var
は使わずにこの2つのどちらかを使えば問題ありません。
let
とconst
の違いは、後者(const
)で宣言された変数は再代入ができないという点にあります。
let foo = 123;
foo = 456; // これはOK
const bar = 0;
bar = 999; // これはエラー
JavaScript/TypeScriptにおいて、変数を宣言するたびに変数をlet
/const
のどちらで宣言するかという選択肢が発生します。変数に再代入される場合はlet
のみが選択肢となりますから、必然的にlet
が使われることになります。しかし、実は再代入されない(const
で宣言してもよい)変数というのはJavaScript/TypeScriptプログラミングにおいてかなり多く発生します。どんなプログラムかにも依りますが、ほとんどのケースでは再代入されない変数が9割以上といっても過言ではないでしょう。その場合にlet
とconst
のどちらを使えばいいのかというのが問題です。
読み手にヒントを与えるという観点からは、再代入されない変数にはconst
を使うべきです。なぜなら、const
で宣言された変数には「この変数には再代入されない」という情報が載っているのに対して、let
では「この変数は再代入されるかもしれないしされないかもしれない」ということになり、情報が無いからです。const
の方が読み手に多くの情報が与えられるので、const
を使える変数に対しては必然的にconst
を選択することになります。
実際、変数への再代入は「その変数に入っているものが状況によって異なる」という複雑性をロジックに与えます。そのため、プログラムを読解する際には変数の再代入には特に注意する必要があります。let
で宣言された「再代入されるかもしれない変数」は、const
に比べるとプログラムを読む人の負担が大きくなります。そのため、let
を使用するのは必要最小限にすべきです。
また、合理的に考える読み手なら、上の議論を前提として「わざわざconst
ではなくlet
を使うということはこの変数はあとで再代入されるに違いない」と思うでしょう。もし再代入されない変数にlet
を使うと、この読み手に誤った理解を与え混乱させてしまうことになります。特にこの記事の考え方に従うならば、プログラムとは合理性・必然性の塊であり、合理的な読解を裏切るのはそれ自体が非合理的なことですから、合理的な読み手は最大限優遇すべきです。
なお、let
とconst
の話題に関しては、「もともとconst
は定数のみに使用することを意図されており、普通の変数は全部let
で宣言されることが想定されていた」という言説があります。しかし、だからといってこれに律儀に従う必要はありません。歴史を重んじるのは大事なことですが、const
のほうが情報量が増えるという現実的なメリットに対しては釣り合わないからです。
readonly
を使う
似たような話題として、TypeScriptはreadonly
配列やreadonly
プロパティといった機能があります。例えば、number[]
が「数値の配列型」であるのに対して、reaadonly number[]
は「書き換え不可能な数値の配列型」を表します。
このreadonly
は関数の引数で使うと特に威力を発揮します。例えば、与えられた数値の配列の和を返すsum
関数を考えてみましょう。
function sum(arr: number[]): number {
let result = 0;
for (const num of arr) {
result += num;
}
return result;
}
この関数は与えられた配列arr
を読み取るだけで書き換えないので、次のようにreadonly
を使うことができます。こうすると、sum
の中でarr
を書き換えることはできなくなります(コンパイルエラーとなります)。
function sum(arr: readonly number[]): number {
let result = 0;
for (const num of arr) {
result += num;
}
return result;
}
筆者の考えでは、sum
の引数はこのようにreadonly number[]
型にすべきです。なぜなら、やはりreadonly
と書いてある方が情報が増えるからです。プログラムの読み手は、関数の型(引数の型)を見ただけで「この関数は与えられた引数の配列を書き換えない関数である」ということが分かります。一方で、readonly
と書いていない関数は与えられた配列(やオブジェクト)を書き換えるのか書き換えないのか分からないし、それどころか「readonly
と書いていないと言うことは書き換える関数だ」と考えるほうが合理的ですらあります。
惜しむらくは、readonly
が長くてそこかしこに書くのが面倒くさいということです。筆者の考えでは多少の面倒くささよりもプログラムの読みやすさのほうが優先されますが、ネストしたオブジェクトなどになるとさすがに面倒くさすぎると思わないでもありません。その点、書き換え不可をデフォルトとしたRustなどはデザインが上手ですね。
正確なインターフェースを付ける
型のある言語では[1]、引数の型や返り値の型によって関数のインターフェースを定義します。このインターフェースは、正確にすればするほど情報量が増えます。よって、なるべく正確なインターフェースを記述するべきです。
一つややTypeScript的な例を出しておきます。与えられた引数の文字列によって異なる数値を返す関数を作ってみましょう。
function getFontSize(size: string): number {
if (size === "small") {
return 10;
} else if (size === "medium") {
return 16;
} else {
return 24;
}
}
このgetFontSize
関数は、"small"
を渡されたら10を、"medium"
を渡されたら16を、それ以外の文字列を渡されたら24を返します。そうはいっても、この「それ以外の文字列」というのが微妙ですね。具体的には何でしょうか? "hogehoge"
とか"ピカチュウ"
とかを渡せばいいのでしょうか? 恐らく、そういった利用は想定されていないでしょう。
TypeScriptでは、リテラル型とユニオン型の組み合わせによって「文字列(string
)」よりもより具体的な型指定をすることができます。
type SizeString = "small" | "medium" | "large";
function getFontSize(size: SizeString): number {
if (size === "small") {
return 10;
} else if (size === "medium") {
return 16;
} else {
return 24;
}
}
上の例ではSizeString
型は「"small"
または"medium"
または"large"
という文字列の型」として定義されています。よって、SizeString
型を引数に取るgetFontSize
関数はこの3種類の文字列しか受け取ることができません。こうすれば、24
が欲しいときは"large"
を渡せばいい(それ以外は渡せない)ことが明らかになりました。
関数のインターフェースがstring
からSizeString
というより厳しいものになりました。一般に、より厳しいインターフェースのほうが情報量の多いインターフェースです。
リテラル型・ユニオン型というのはTypeScript以外の言語では珍しい概念ですが、いわゆるenumを使うと他の言語でも似たような表現ができる場面が多いでしょう(特にRustのようにADTを持っている言語は優秀ですね)。TypeScriptにもenum
はありますが古い機能なので使うべきではなく、上述のようなリテラル型・ユニオン型のほうが適しています。
ちなみに、やろうと思えばgetFontSize
の返り値の型を10 | 16 | 24
というユニオン型にすることもできるのですが、ここではnumber
と書かれています。これにも意味があり、「10、16、24といった具体的な数値には意味がない」ということを読み手に伝える役割を果たしています。この関数は何らかの数値を返す関数なのであり、個々の数値に依存すべきではないということを関数のインターフェースを通じて示しているのです。
publicとprivate
クラスを作ってフィールドを作るときは、そのフィールドをpublic(クラス外からも見える)にするかprivate(クラス内にのみ見える)にするかという選択肢が発生します。フィールドをpublicにするかprivateにするかによって、読み手に与える情報は大きく異なります。
次のHuman
クラスでは、name
とage
、presentCount
がpublicなフィールドです。
class Human {
name: string;
age: number;
presentCount: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
this.presentCount = 0;
}
grow() {
this.age++;
this.presentCount++;
}
getPresent() {
if (this.presentCount <= 0) {
throw new Error("You have no present!");
}
this.presentCount--;
return "present";
}
}
このHuman
は「1回grow()
するごとに1回getPresent()
する権利を得る」というロジックを持っています。それはpresentCount
によって実現されており、最初presentCount
は0ですが、1回grow()
するごとにpresentCount
が溜まって、それはgetPresent()
で使うことができます。
結論から言ってしまえば、このpresentCount
はprivateのほうがいいですよね。上のコードは設計など色々な観点から考察できそうですが、この記事ではやはり「読みやすさ」の観点から考えていきます。
TypeScriptでは、クラスのフィールドをpublicにするかprivateにするか[2]、そしてreadonlyにするか否かという選択肢があります。今回、presentCount
やその他のフィールドがpublicである(しかもreadonlyではない)ので、クラスの外部から書き換えられる余地があります。むしろ、privateもreadonlyも使わないということは、外部から書き換えられることを前提にしていると推論されるべきです。
つまり、読み手は常に「外部からフィールドが書き換えられるかもしれない」ことを念頭にコードを解釈しなければいけません。これはlet
とconst
の話と同じで、外部から書き換えられる可能性というのは読み手にとって負担になります。よって、本当にそれが必要でなければ外部から干渉できないようにすべきです。むしろ、外部から書き換えられないのにその可能性が残されているコードというのは読み手を裏切るコードであるとすら言えます。
上のコードについて言えば、presentCount
を外部から書き換えられると、上で説明した意図を逸脱することができますよね(uhyo.presentCount += 100
とか)。読み手からは、書き手の意図を読み取るのが困難です。つまり、それがやってはいけないことなのか、あるいはやってもいいこと(プレセント増量キャンペーンとか)なのか判断することは難しいでしょう。理解できないことがあると、コードの読解は当然難しくなります。
今回の場合はpresentCount
をprivateにして、プレゼント増量キャンペーンをやりたい場合はそれ用のいい感じの設計を考えるべきでしょう。presentCount
をprivateにするとこんな感じになります(JavaScript・TypeScriptでは#
を使ってprivateなフィールドを表現できます[3])。
class Human {
name: string;
age: number;
#presentCount: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
this.#presentCount = 0;
}
grow() {
this.age++;
this.#presentCount++;
}
getPresent() {
if (this.#presentCount <= 0) {
throw new Error("You have no present!");
}
this.#presentCount--;
return "present";
}
}
まとめ
この記事では、「読みやすさ」の観点からいくつかの例を考察し、コードの品質について議論しました。それぞれの話題は比較的メジャーな話題で色々な観点から考察できるものですが、この記事では一貫して「書き手の意図」「読者に与える情報」といった観点で考えました。
これらの考察を通じて、「読みやすさ」をベースとする考え方が色々な問題に対して広く適用できるものであることが理解いただけたかと思います。
また、この記事では読み手が“考える”・“推論する”立場にあることを強調しています。コードを読みやすく書くのは書き手の責務であると同時に、書き手の意図を正しく読み取るのは読み手の責務なのです。まるで推理ゲームのように、両者が合理的に推論すればするほど(この記事の観点での)コードの品質は高まると言えます。ですから、読みやすいプログラムを書く書き手を目指すのはもちろん、合理的な考え方が出来る読み手もまた目指すべき目標なのでしょう。
繰り返しになりますが、この記事で述べた考え方は「コードの品質」に対する考え方の一つでしかありません。いろいろな観点・指標が共存して然るべきでしょう。しかし、筆者はこの記事の考え方は広く通用しやすいと思っています。共感いただいた方はぜひシェアしてみてください。
Discussion