「読みやすいコード」を依存グラフで考える
はじめに
こんにちは、ダイニーの ogino です。
この記事では、コードの読みやすさを比較判断するために役立つメンタルモデルを紹介します。
本記事を読むと、「このコードは良い / 悪い」という感覚が身につき、その理由を自信を持って説明できるようになるはずです。
コードの読みやすさとは何か
コードを読む時には大抵、何か特定の目的があります。例えば、 API /foo
にリクエストした時の動作を知りたい、ある画面で発生しているバグの原因を知りたい、などです。
この時、コードベースの隅から隅まで読み尽くすのではなく、特定のポイントから出発して関連する箇所を芋蔓式に辿りながら読むはずです。
人が一度に理解して覚えておける情報量には限界があるので、辿らなければいけないコード量が少ないほど当然読みやすくなります。
つまり、ある目的に関連するコードの箇所が局所的かつ明示的であるほどコードは読みやすいと言えます。
「依存グラフ」の本記事における定義
ここで突然ですが、以下 2 つのグラフを見比べてみてください。
整然としたグラフ
無秩序なグラフ
前者の方が、2 つのカタマリ状の構造があることを容易に見て取れるはずです。
コードの可能性を比較するためのメンタルモデルとして、コードをグラフと紐付けると様々なことが腑に落ちるようになります。そして、読みやすいコードは前者のグラフのように整然とした構造を持っています。
プログラムのコードとグラフに何の関係があるのかと思われる方も多いかもしれません。本記事では、下記のようにしてコードベースとその「依存グラフ」を対応付けます。
- グラフの頂点は、コードの一部分(コード 1 行、変数、関数、クラス、etc.)を表す
- あるコードの部分 A が、別のコード B に依存する時、頂点 A から頂点 B に矢印を伸ばす
- 「A が B に依存する」とは、「A の意味を理解するために B を先に理解する必要がある」ことを指す
例えば次の TypeScript コードは
import {factorial} from "./math"
const f = (x: number) => {
const y = factorial(5);
const z = factorial(x);
return y + z;
}
console.log(f(4), f(9));
// math.ts
export const factorial = (n: number): number => {
if (n === 0) return 1;
return n * factorial(n - 1);
}
下図の依存グラフで表現することができます。
コードの各部分を頂点に書き込んだグラフ
この依存グラフを考えることで、プログラミングにおける諸々の設計思想やパターンを視覚的に理解することができます。次節以降で見ていきましょう。
依存グラフの特徴
コードの読解 ≒ グラフのバックトラック
先ほどの例でコンソールに何が出力されるのか知りたい場合、
-
f(4)
,f(9)
の値は何か? -
f
の返り値y + z
は何か? -
z
の値は何か? -
x = 5
だとわかる -
factorial
の意味は何か? -
z = factorial(5) = 120
だとわかる
...
という流れでコードを読んでいくことができます。これを下図の赤線のように依存グラフ上で表すと、バックトラックそのものです。
またここから、「依存グラフ上の頂点
-
のコードを理解するコストV -
の矢印の先の頂点を理解するコストの合計V
コードの抽象化 ≒ グラフのクラスタを見つけ出すこと
コードを読み解く流れはバックトラックに似ています。とはいえ、実際には依存先のコードを末端に至るまで全て読むことは滅多に無いでしょう。
関数やモジュールなどによってコードを抽象化することで、細部の理解をスキップするからです。
コードの抽象化は、依存グラフ上で「クラスタ」を見つけ出すことに相当します。
クラスタとは、疎結合かつ高凝集となるように依存グラフの頂点たちを分割したものです。
- 疎結合:異なるクラスタの間を繋ぐ矢印が少ない
- 高凝集:矢印の繋がりが少ない頂点同士は、同じクラスタ内に含まれない
波線がクラスタの境界
クラスタが与えられると、コードを読解する過程を以下のように分解することができます。
- クラスタを折り畳んで全体像を把握する
- それぞれのクラスタを個別に深掘りして内部を理解する
クラスタを 1 つの頂点かのように折り畳んだ図
疎結合なクラスタは、折り畳んだ時に矢印の数を大幅に減らし、全体の複雑度を低くします。
高凝集なクラスタは、関係無いものが混ざっていないので、内部を理解するための負荷が低くなります。
更に、クラスタに良い名前が付いていれば、その内部がどれほど複雑であっても理解のコストをスキップすることができます。
例えば、f
という関数名は何の情報も与えない悪い命名なので、中を見なければ意味がわかりません。一方で factorial
という関数が何を返すのかは名前から明らかなので、内部実装を読む必要が無くなります。
変数のスコープは、内側への矢印の侵入を防ぐ
関数ブロックは変数のスコープを作り、ローカル変数を隔離します。
先ほどの関数 f
の中の変数 y
, z
に外からアクセスすることはできないので、依存グラフ上で境界の外から y, z へ矢印が伸びてくることはあり得ません。
const f = (x: number) => {
const y = factorial(5);
const z = factorial(x);
return y + z;
}
スコープの外から中の変数へ矢印は伸びない
そのため、変数のスコープはクラスタの疎結合を強制するために最も重要な概念だと言えます。
mutable な変数は、ループ状の矢印を作る
変数への書き込みは、依存グラフ上でループ状に張られた矢印を作り出します。 例えば次のコードを見てみましょう。
let count = 0; // count はここで定義された後に値が書き換えられるので、mutable な変数
const increment = (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 などでも全く同じ問題が起きます。
特にデータベースは、プロセスやコードベースの寿命すら越える特大のスコープを持つ変数のようなものですから、最大の警戒が必要です:
- データベースはコード内の非常に広い範囲で利用される
- アプリケーションを rolling update する際など、複数のバージョンのコードが共存することがある
- 時には複数のアプリケーションで共有される
データベースが複数のバージョン、プロジェクトを依存グラフに巻き込む
一方で immutable な値や logger などはグローバルに公開されていても大して問題はありません。なぜならこれらは単方向の依存関係しか作らないからです。
immutable な値の意味が別の場所で書き変わることはありませんし、logger を呼ぶことで他の箇所に影響を与えることもありません。
immutable な値への矢印はループを作らない
なぜ宣言的に書かれたコードは読みやすいのか
宣言的なコードは依存グラフからループを取り除くか、もしくは非常に小さなクラスタの中に隔離します。それによってコードを局所的に理解しやすい構造にします。
ここで言う「宣言的なコード」とは、文 (statement) ではなく式 (expression) を中心に書かれたコードのことです。
expression とは大雑把に言うと、何らかの値を返すコードを指します。
JavaScript では関数呼び出しや演算子などが expression に該当し、それ以外の for
や if
など大半の構文が statement に当たります。
次の例は、宣言的ではない(手続き的な)コードを示しています。
/** 注文に含まれる 1 種類の商品 */
interface LineItem {
quantity: number;
priceBeforeTax: number;
isTaxRateReduced: boolean; // 軽減税率の対象なら true
}
/** 注文の合計金額を内訳と共に計算する手続き的な実装 */
const calculateTotalPrice = (lineItems: LineItem[]) => {
let subtotal = 0;
let tax = 0;
for (const item of 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 };
}
これと同じ関数を宣言的に書くと次のようになります。
/** 宣言的な実装 */
const calculateTotalPriceDeclarative = (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};
}
const calculateTax = (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";
const parse = (csv) => {
const rows = getRows(csv);
return rows.map(arr => arr.map(obj => ({
...obj,
orderedAt: new Date(obj.orderedAt),
})));
};
まず、この関数の引数 csv
にはどんな値が求められているのでしょうか?生の文字列か、もしくはパース済みのオブジェクトの配列かもしれません。
そしてこの返り値はいったい何でしょう? rows.map
の意味を理解するには rows
の型を知る必要があります。配列の map
関数のようにも見えますが、このコードだけではわかりません。 rows
は Result
型もしくは全く別のオブジェクトかもしれません。
こうした疑問の答えを推論するためのアプローチは 2 種類あります。
- 関数
parse
を呼び出している箇所を全て余さず調べ上げ、実際に渡されている値を見る - 関数
parse
の詳細な実装を掘り下げる
もし前者の方法での理解が必要だとしたら、その関数は抽象化が不十分だと言えるでしょう。関数定義の中で自己完結した意味を持っておらず、呼び出し元のコードと双方向の依存関係があるからです。
一方、詳細な実装を掘り下げて型を推論するのは、コードの読み手ではなく実装者やコンパイラがすべき仕事です。
あなたの使う言語が静的型付けでも動的型付けでも、型は確実に存在します。そして何か関数を定義する時には、実装者が意図している「暗黙の型」があるはずです。その暗黙の型を一番理解しているのは実装者ですから、自身で型を明示すべきです。
一度型を書いてしまえば、その後コードの読み手が推論する手間が無くなります。型を書かないのは、本来 1 のコストで済むものを 3 * 100 のコストとして読み手に押し付けるようなものです。
まとめ
- 関連するコードの箇所が局所的かつ明示的であるほど読みやすい
- 依存関係がループを作ると、局所的な理解ができなくなる
- 宣言的なコードには依存関係のループが無い
- 宣言的でないコードは分離して端に掃き出すとよい
- 良い名前と型情報があると、詳細な内部実装の理解をスキップできる
We're hiring!
ダイニーではコード品質を大事にしており、PR レビューで積極的に議論する文化があります。筆者がレビュイー / レビュアーとして考えたことが、この記事を書くきっかけになりました。
そんなエンジニアチームにご興味のある方は、ぜひカジュアル面談にご応募ください。
-
正確には、複数の頂点から 1 つの頂点へ矢印が伸びる事があるので、ツリーではなく DAG (Directed Acyclic Graph) です。 ↩︎
Discussion
多くのことが書かれていて難しかったけど、コードの読みやすさが形式化されていてすごいと思いました!
コードとグラフの対応の正確な意味論があればこの記事は化けそう!
追記: 化けました。すごい!
Rustユーザですが、この視点、Rustにも転用できて、とても相性がいいです。
私の今の読解力では1/3も理解できませんでした(精読する時間が今はないというのもあります)が、
これってマイクロカーネルOSの理解/設計/デバッグ等にも応用できそうでとても有益な記事でした。
依存グラフをソースコードの形で定義でき、依存グラフにループがあるとコンパイルエラーになる超超高水準言語がほしい。設計上の意図を記述できない言語ばかり。
とても面白かったです!
書かれていた内容を読んで、じっさいのかいはつでもコードの良し悪しが判断できるようになって感動しました。
あまりにも嬉しい体験だったので、思いを記事にまとめました。
良い記事ありがとうございました