行同士の依存関係を考える
tl;dr
コードを書くとき、自分には矢印が見えています。
それは、ある行が他の行に依存していることを意味する矢印で、変数の定義、関数の呼び出し、副作用など、すべてがどこかに矢印を伸ばしています。
この矢印の数や長さ、複雑さをなるべく減らすようにすると、コードは格段に読みやすく、壊れにくくなります。
行と行のあいだの矢印
たとえば以下のようなコードを見てみましょう。
const a = 3;
const b = a + 1;
const c = b * 2;
console.log(c);
このコードの行や変数には、明確な依存関係があります。
-
b
はa
に依存しています -
c
はb
に依存しています -
console.log
はc
に依存しています
つまり、次のような依存の矢印が見えてきます。
これは簡単な例ですが、現実のコードではもっと複雑な矢印が錯綜します。モジュールやクラス同士などの依存関係は一般的にも良く語られていますが、行や変数単位で観察したことは少ない人も多いのではないでしょうか。
矢印が多く、複雑だと、コードは読みにくく壊れやすくなる
依存の矢印が多い、あるいは遠くまで飛んでいると、コードは以下のような問題を抱えるようになります。
- 読みづらい:一行を理解するのに、前後の10行を読まなければならない
- 修正しづらい:一箇所の修正が思わぬ箇所に影響を与える
結果として、スパゲッティコードが生まれます。
グローバル変数は「矢印の出所が不明」になる
グローバル変数は、矢印の観点では最悪に近いものです。
let sharedState = 0;
function increment() {
sharedState += 1;
}
この increment()
関数は、外部にある sharedState
に依存しています。
しかし、その依存は関数の中からは見えません。sharedState
がどこで定義され、どこで変更されているかは、別の場所を探さなければ分かりません。
これは「謎の矢印をエスパーして探す」ような状態です。また、これはグローバル変数の再代入に限らず、一般の副作用で起きていることです。
再代入は依存の矢印をねじらせる
コードの中で同じ変数に再代入すると、依存関係はさらに複雑になります。
let score = 0;
if (user.isPremium) {
score += 100;
}
if (user.hasCoupon) {
score += 50;
}
この score
は、複数の条件に応じて変化します。
依存グラフで描くと、次のように複数の経路から再代入されている状態になります。
┌────────────┐
user →│ if-premium │
└────┬───────┘
▼
score
▲
┌────┴───────┐
user │ if-coupon │
└────────────┘
この構造は、値の由来を理解するのが難しく、後から読んだときに「どうしてこの値になったのか?」を追いかける必要があります。
再代入を避けると、矢印は単純になる
const base = 0;
const premiumBonus = user.isPremium ? 100 : 0;
const couponBonus = user.hasCoupon ? 50 : 0;
const score = base + premiumBonus + couponBonus;
このように再代入を避けて、各要素を独立した変数に分解することで、矢印は直線的になります。
user <- premiumBonus
user <- couponBonus
base, premiumBonus, couponBonus <- score
再代入は「時間軸に沿った隠れた依存」を作り出すため、可能であれば避けた方がよいでしょう。
依存の矢印を整理するには?
依存の矢印を整理・減らすには、例えば次のような方法があります。
(これ以外にも、一般に言われている可読性向上のテクニックは、ほとんど依存関係の整理になっているはずです)
1. 小さな純粋関数を利用する
- 関数の出力は、引数のみに依存させます
- 外部状態(グローバル変数など)に依存しません
- 副作用を避けます
これにより、関数の入出力だけが明示的な依存元・先になります。
また、小さな関数に切り出すことで、内部の矢印は外からは見えなくなります。
これは抽象化であり、複雑な依存を「ひとつの名前」に包んで隠す行為です。
const calculateFinalScore = (user) =>
user.score + (user.premium ? 100 : 0);
2. 高階関数を使う
以下のような map/filter/reduce
を使った構文は、直線的な依存だけを持ちます。
const result = data
.filter(x => x.active)
.map(x => x.value)
.reduce((sum, x) => sum + x, 0);
各行は「ひとつ前の行」にだけ依存していて、それ以上には依存しません。
矢印が一直線に並ぶ構造は、読みやすく・壊れにくいです。
矢印をイメージできるようになると、以下のような for
文は途端に最悪に思えてくるのではないでしょうか。
let result = 0;
for (let i = 0; i < data.length; i++) {
if (data[i].active) {
result += data[i].value
}
}
まとめ
- コードには「依存の矢印」がある
- 矢印が多い・長い・隠れている・複雑なほど、コードは読みにくく壊れやすくなる
- グローバル変数や再代入は、矢印をねじれさせ・隠してしまう
- 純粋関数、高階関数、関数分割で、矢印を制御するべき
実装レベルのリファクタリングはつまるところ、矢印のネットワークを構築・最適化することだと思っています。
あなたにも、矢印は見えているでしょうか?
Discussion