こんにちは、ダイニーの ogino です。
この記事では、コードの読みやすさを比較判断するために役立つメンタルモデルを紹介します。
本記事を読むと、「このコードは良い / 悪い」という感覚が身につき、その理由を自信を持って説明できるようになるはずです。
コードを読む時には大抵、何か特定の目的があります。例えば、 API/foo にリクエストした時の動作を知りたい、ある画面で発生しているバグの原因を知りたい、などです。
この時、コードベースの隅から隅まで読み尽くすのではなく、特定のポイントから出発して関連する箇所を芋蔓式に辿りながら読むはずです。
人が一度に理解して覚えておける情報量には限界があるので、辿らなければいけないコード量が少ないほど当然読みやすくなります。
つまり、ある目的に関連するコードの箇所が局所的かつ明示的であるほどコードは読みやすいと言えます。
ここで突然ですが、以下 2 つのグラフを見比べてみてください。

整然としたグラフ

無秩序なグラフ
前者の方が、2 つのカタマリ状の構造があることを容易に見て取れるはずです。
コードの可能性を比較するためのメンタルモデルとして、コードをグラフと紐付けると様々なことが腑に落ちるようになります。そして、読みやすいコードは前者のグラフのように整然とした構造を持っています。
プログラムのコードとグラフに何の関係があるのかと思われる方も多いかもしれません。本記事では、下記のようにしてコードベースとその「依存グラフ」を対応付けます。
例えば次の TypeScript コードは
import{factorial}from"./math"constf=(x:number)=>{const y=factorial(5);const z=factorial(x);return y+ z;}console.log(f(4),f(9));// math.tsexportconst factorial=(n:number):number=>{if(n===0)return1;return n*factorial(n-1);}下図の依存グラフで表現することができます。

コードの各部分を頂点に書き込んだグラフ
この依存グラフを考えることで、プログラミングにおける諸々の設計思想やパターンを視覚的に理解することができます。次節以降で見ていきましょう。
!ここで定義した依存グラフは、コードの実行順を表すものではありません。
また、import や reference の関係と依存関係は必ずしも一致しません。
先ほどの例でコンソールに何が出力されるのか知りたい場合、
f(4),f(9) の値は何か?f の返り値y + z は何か?z の値は何か?x = 5 だとわかるfactorial の意味は何か?z = factorial(5) = 120 だとわかるという流れでコードを読んでいくことができます。これを下図の赤線のように依存グラフ上で表すと、バックトラックそのものです。

またここから、「依存グラフ上の頂点
コードを読み解く流れはバックトラックに似ています。とはいえ、実際には依存先のコードを末端に至るまで全て読むことは滅多に無いでしょう。
関数やモジュールなどによってコードを抽象化することで、細部の理解をスキップするからです。
コードの抽象化は、依存グラフ上で「クラスタ」を見つけ出すことに相当します。
クラスタとは、疎結合かつ高凝集となるように依存グラフの頂点たちを分割したものです。

波線がクラスタの境界
クラスタが与えられると、コードを読解する過程を以下のように分解することができます。

クラスタを 1 つの頂点かのように折り畳んだ図
疎結合なクラスタは、折り畳んだ時に矢印の数を大幅に減らし、全体の複雑度を低くします。
高凝集なクラスタは、関係無いものが混ざっていないので、内部を理解するための負荷が低くなります。
更に、クラスタに良い名前が付いていれば、その内部がどれほど複雑であっても理解のコストをスキップすることができます。
例えば、f という関数名は何の情報も与えない悪い命名なので、中を見なければ意味がわかりません。一方でfactorial という関数が何を返すのかは名前から明らかなので、内部実装を読む必要が無くなります。
関数ブロックは変数のスコープを作り、ローカル変数を隔離します。
先ほどの関数f の中の変数y,z に外からアクセスすることはできないので、依存グラフ上で境界の外から y, z へ矢印が伸びてくることはあり得ません。
constf=(x:number)=>{const y=factorial(5);const z=factorial(x);return y+ z;}
スコープの外から中の変数へ矢印は伸びない
そのため、変数のスコープはクラスタの疎結合を強制するために最も重要な概念だと言えます。
変数への書き込みは、依存グラフ上でループ状に張られた矢印を作り出します。 例えば次のコードを見てみましょう。
let count=0;// count はここで定義された後に値が書き換えられるので、mutable な変数constincrement=(n:number)=>{ count+= n;};increment(5);console.log(count);increment(3);console.log(count);変数count の意味を理解するには、最初の宣言だけを見ても当然不十分です。count の値を書き換えている、すなわちincrement を呼び出している箇所全てを調べないといけません。
一方でincrement を理解するにはcount の意味を理解する必要があるので、依存グラフは下図のようになります。

依存グラフにループがあると、「A を理解するには B が必要で、B には C が必要で、そのためには A が必要で...」という風に、どこから読み解けばいいのかわからなくなります。
そのため、ループの中に含まれる全ての頂点を同時に頭に入れて理解する必要があり、ループが大きくなればなるほど認知負荷が高くなります。
巨大なスコープを持つ mutable な変数は、依存グラフ上で広範囲を巻き込んだループを作り出します。すなわち、関連するコードの箇所が局所的ではなくなるので、読解が難しくなります。

頂点のどれか 1 つでも理解するためには、グラフ全体をいっぺんに理解しないといけない
グローバル変数はもちろんですが、巨大なクラスや関数の中のローカル変数、React の Context などでも全く同じ問題が起きます。
特にデータベースは、プロセスやコードベースの寿命すら越える特大のスコープを持つ変数のようなものですから、最大の警戒が必要です:

データベースが複数のバージョン、プロジェクトを依存グラフに巻き込む
一方で immutable な値や logger などはグローバルに公開されていても大して問題はありません。なぜならこれらは単方向の依存関係しか作らないからです。
immutable な値の意味が別の場所で書き変わることはありませんし、logger を呼ぶことで他の箇所に影響を与えることもありません。

immutable な値への矢印はループを作らない
宣言的なコードは依存グラフからループを取り除くか、もしくは非常に小さなクラスタの中に隔離します。それによってコードを局所的に理解しやすい構造にします。
ここで言う「宣言的なコード」とは、文 (statement) ではなく式 (expression) を中心に書かれたコードのことです。
expression とは大雑把に言うと、何らかの値を返すコードを指します。
JavaScript では関数呼び出しや演算子などが expression に該当し、それ以外のfor やif など大半の構文が statement に当たります。
次の例は、宣言的ではない(手続き的な)コードを示しています。
/** 注文に含まれる 1 種類の商品 */interfaceLineItem{ quantity:number; priceBeforeTax:number; isTaxRateReduced:boolean;// 軽減税率の対象なら true}/** 注文の合計金額を内訳と共に計算する手続き的な実装 */constcalculateTotalPrice=(lineItems: LineItem[])=>{let subtotal=0;let tax=0;for(const itemof lineItems){ subtotal+= item.quantity* item.priceBeforeTax;let taxRate=0.1;if(item.isTaxRateReduced){ taxRate=0.08;} tax+= taxRate* item.quantity* item.priceBeforeTax;} subtotal= Math.ceil(subtotal); tax= Math.ceil(tax);return{ subtotal, tax, total: subtotal+ tax};}これと同じ関数を宣言的に書くと次のようになります。
/** 宣言的な実装 */constcalculateTotalPriceDeclarative=(lineItems: LineItem[])=>{const subtotal= Math.ceil(sumBy(lineItems,({quantity, priceBeforeTax})=> quantity* priceBeforeTax));const tax= Math.ceil(sumBy(lineItems, calculateTax));return{subtotal, tax, total: subtotal+ tax};}constcalculateTax=(item: LineItem)=>(item.isTaxRateReduced?0.08:0.1)* item.priceBeforeTax* item.quantity;const sumBy=<T>(array:T[],f:(x:T)=>number)=> array.reduce((total, x)=> total+f(x),0);関係の薄いsubtotal とtax の計算を分離し、for ループは array.reduce で置き換えられるようになりました。また if 文を if 式 (三項演算子) に書き換え、それに伴って税率計算に mutable な変数を使う必要が無くなりました。
両者の依存グラフを下図で比較してみましょう。

手続き的なコードでは双方向の矢印が複雑に絡み合っていますが、宣言的なコードでは矢印が単方向に流れてツリーのよう[1]になっています。
そして宣言的なコードにおいては、ある変数について理解するための情報が変数宣言の初期化式だけに全て集まります。あとは式の各部分をエディタの定義ジャンプで深掘りしていくだけで、機械的に依存グラフを辿る事ができます。
つまり、宣言的なコードは関連箇所が局所的かつ明示的になるので、読みやすくなると言えます。
前節では宣言的なコードの利点を語りましたが、純粋に宣言的なロジックだけでプロダクトを作ることはまずありえません。
例えば Web アプリケーションであれば普通は何かしらデータベースを利用するでしょう。データベース操作は宣言的ではない処理の代表例です。
データベースは、ローカル変数のように内側へ隠蔽することができません(隠蔽できるならそもそもデータベースを使う必要がありません)。そこら中で好き勝手にデータベース操作をすると、コードの至る所が互いに強く依存して非常に読みづらくなります。
これに対する解決法は、データベース操作などの厄介な手続きを、エントリーポイントに近い端だけで行うことです。
コードを読みづらくするような「悪性」の頂点があると、それに依存する別の頂点も悪性に汚染されてしまいます。そのため、多くの箇所から依存される中核のコードに悪性の要素を混ぜると、広い範囲が連鎖的に汚染されます。
アプリケーションのエンドポイント付近は被依存が少ないので、悪性のコードをそこに追いやることでダメージを小さく抑えることができます。
型情報があると、関数の詳細な実装を読み解く手間を省くことができます。また、関数や変数などの参照箇所をエディタが教えてくれるので、依存するコードの位置が明示的になります。
プログラムのコードを読む流れは、依存グラフのバックトラックのようなものだと既に述べました。その例えに沿うと、型情報は次のような役割を果たしてくれます。
ここで、逆に型情報が無い場合に起きる問題を JavaScript のコード例で見てみましょう。
import{ getRows}from"./csv";constparse=(csv)=>{const rows=getRows(csv);return rows.map(arr=> arr.map(obj=>({...obj,orderedAt:newDate(obj.orderedAt),})));};まず、この関数の引数csv にはどんな値が求められているのでしょうか?生の文字列か、もしくはパース済みのオブジェクトの配列かもしれません。
そしてこの返り値はいったい何でしょう?rows.map の意味を理解するにはrows の型を知る必要があります。配列のmap 関数のようにも見えますが、このコードだけではわかりません。rows はResult 型もしくは全く別のオブジェクトかもしれません。
こうした疑問の答えを推論するためのアプローチは 2 種類あります。
parse を呼び出している箇所を全て余さず調べ上げ、実際に渡されている値を見るparse の詳細な実装を掘り下げるもし前者の方法での理解が必要だとしたら、その関数は抽象化が不十分だと言えるでしょう。関数定義の中で自己完結した意味を持っておらず、呼び出し元のコードと双方向の依存関係があるからです。
一方、詳細な実装を掘り下げて型を推論するのは、コードの読み手ではなく実装者やコンパイラがすべき仕事です。
あなたの使う言語が静的型付けでも動的型付けでも、型は確実に存在します。そして何か関数を定義する時には、実装者が意図している「暗黙の型」があるはずです。その暗黙の型を一番理解しているのは実装者ですから、自身で型を明示すべきです。
一度型を書いてしまえば、その後コードの読み手が推論する手間が無くなります。型を書かないのは、本来 1 のコストで済むものを 3 * 100 のコストとして読み手に押し付けるようなものです。
ダイニーではコード品質を大事にしており、PR レビューで積極的に議論する文化があります。筆者がレビュイー / レビュアーとして考えたことが、この記事を書くきっかけになりました。
そんなエンジニアチームにご興味のある方は、ぜひカジュアル面談にご応募ください。
https://hrmos.co/pages/dinii/jobs/9999
正確には、複数の頂点から 1 つの頂点へ矢印が伸びる事があるので、ツリーではなく DAG (Directed Acyclic Graph) です。↩︎
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。