副作用・参照透過性・冪等性を整理し直したメモ
機能追加の議論で混ざりがちな副作用・参照透過性・冪等性について、言葉のスコープと具体例を並べ直して頭を揃えました。
TL;DR
- 副作用は式や関数を評価した結果、そのスコープ外から観測可能な変化が生じること全般を指し、状態更新だけでなく I/O・ログ・例外などの外部相互作用も含める立場を取る。
- 参照透過性は「式を評価した結果で置き換えても意味が変わらない」性質で、外部状態に依存・影響するなら破れる。
- 冪等性は「同じ操作を繰り返しても結果が変わらない」ことを保証する振る舞いで、参照透過性とは別軸の話。
- 3 つをセットで語るときは「どの対象(式・関数・API)にどの性質を期待するのか」を先に共有すると誤解が減る。
副作用という言葉の幅
一言で:副作用ってなに? 関数を呼んだ結果、返り値“以外”のところで世界が変わること。 副作用 (side effect) は、式や関数を評価した際にそのスコープ外から観測可能な変化が生じることを指す。ここでいう「変化」には状態の更新だけでなく、I/O(HTTP、DB、ファイル、ログ、メトリクス)、例外送出のような外部との相互作用、引数・クロージャ・モジュールスコープの更新、グローバルや共有ストアの変更も含める。本メモでは、例外の送出も「外部から観測される振る舞いの変化」として広義の副作用に含める立場を取る。
実務で整理しておくと便利なのは以下の 3 つの観点。
- 観測できる経路は何か:
console.logやメトリクス送信は、出力先の有無にかかわらず外部から観測可能な振る舞いとして扱う。I/O が走る時点で副作用。 - 再実行時に積み上がるか: 再試行でメールが二重送信されるなら副作用の重さは大きい。結果整合性が保たれるならリスクは下がる。
- ビジネス上の整合性に影響するか: ドメインイベントの重複送信やウォレット残高更新は重い副作用、監査ログの追記は比較的軽い。重み付けを明らかにしてルールを決める。
- スコープの境界: 「外向き」の定義は関数スコープを出た瞬間と捉える。引数・クロージャー・モジュール変数を壊せば呼び出し側から観測できるため副作用に該当する。
この観点を共有しておけば「今回気にすべき副作用はどれか?」を議論しやすくなる。
参照透過性は「置き換え可能性」
参照透過性 (referential transparency) は、式 e をその評価結果 v に置き換えてもプログラム全体の意味が変わらない性質を指す。副作用の有無が焦点になりがちだが、実際は「式が外部依存を持たない」ことも同じくらい重要だ。
const add = (a: number, b: number) => a + b;
const withLog = (x: number) => {
console.log('value', x);
return x * 2;
};
const withMutation = (person: { name: string }) => {
person.name = person.name.trim(); // 呼び出し側から観測できる変更
return person;
};
const main = () => {
const a = add(1, 2); // 3 で置き換えても意味は同じ
const b = withLog(a); // console.log の有無で意味が変わる
const c = withMutation({ name: ' Alice ' }); // 引数を直接変えるので透過性が崩れる
return b;
};
add は任意の箇所で結果に置き換えても行儀よく振る舞うが、withLog や withMutation は副作用を持つため参照透過的ではない。
外部の時刻や乱数を読む場合も同様で、式の呼び出し回数に応じて別の値が返るなら「置き換え可能性」は失われる。
参照透過性を保てれば、式の再順序やメモ化、部分式の評価結果のキャッシュなど最適化の余地が広がる。一方で、アプリケーションは IO を避けられないので、どこに「非透過性」を閉じ込めるかが設計のポイントになる。また、外部からの読取りのみ(例: Date.now(), Math.random(), process.env, localStorage.getItem) は状態を書き換えないため狭義の副作用ではないが、呼び出しごとに値が変わり得るので参照透過性を破る操作として扱う。
冪等性は作用の「回数」に関する性質
冪等性 (idempotence) は 操作を複数回適用しても結果が変化しない 性質だ。副作用や参照透過性との混乱が起きやすいのは、対象が「関数」「HTTP メソッド」「DB コマンド」など混在するためである。数学的には「すべての x について f(f(x)) = f(x) が成立する」ことで定義され、これは再帰(自分自身を呼び出す実装テクニック)とは無関係の、自己合成に対する不変性を問う性質である。HTTP では RFC 9110 の語義に従って GET/PUT/DELETE が冪等、POST は通常非冪等、PATCH は設計次第と押さえておくと迷わない。
PUT /users/123で同じリクエストボディを送り続けてもユーザーの状態が変わらないなら冪等。POST /usersで同じリクエストをリトライすると新しいユーザーが増えていくなら非冪等。- 関数レベルで言えば
clamp01(x)(値を 0〜1 に収める)やsortAsc(xs)(昇順ソート)のように自己合成しても結果が変わらないものは冪等。
冪等であっても副作用を持つケースは多い。たとえば PUT /users/123 は毎回監査ログを一行書き足すかもしれない。結果として監査テーブルは増えるが、業務的には許容できる副作用として扱うこともある。ここでも「どの状態が守られていれば OK か」を明確にしておくのが大切だ。
自分用の整理マップ
まとめると以下のようなマッピングになった。
- 副作用: 「外部から観測できる変化があるか?」という 広義の現象。
- 参照透過性: 「式を値に置き換えられるか?」という 式レベルの性質。
- 冪等性: 「同じ操作を繰り返しても結果が変わらないか?」という 操作レベルの性質。
つまり副作用は「起きた出来事」、参照透過性は「式の置換が安全か」、冪等性は「回数に関する約束事」。語る対象と観測者を揃えておけば、三者の関係を整理しやすくなる。
安全な副作用設計のヒント
- ピュアな計算をコアに寄せ、I/O や状態変化は外周の薄いレイヤーに押し出す。
- 「読むだけの外部」は
ClockやRandomなどの抽象を DI してテストで固定する。 - リトライ前提の操作は冪等性キーや事前割当 ID、アウトボックス・サーガで再入可能にしておく。
const stamp = (x: number, clock: { now: () => number }) => ({
v: x,
t: clock.now()
});
// f(f(x)) = f(x) が成立する冪等関数
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const sortAsc = (xs: number[]) => [...xs].sort((a, b) => a - b);
// HTTP 冪等化:POST を事前割当 ID で去勢
// PUT /users/123 { ...payload } // 冪等
// POST /users { id: "123", ...payload } // 事前 ID 付きで実質冪等化
チーム内での伝え方メモ
実務の議論では「今回守りたいのはどれか?」を冒頭で宣言するようにした。
- 式レベルでの推論しやすさ → 参照透過性を重視。ピュア関数で囲い込む。
- 再試行やリトライ耐性 → 冪等性を保証できるよう API/コマンドの設計を調整。
- 外部影響の芽を潰す → 副作用の対象範囲を洗い出し、逆に必要な副作用は列挙して明文化する。
この順番で話すと「何を守りたいのか」「どこなら崩しても良いのか」が共有しやすかった。特にリトライ処理では「冪等性さえ守れば副作用はあっても良いのか?」という問いが生まれるが、監査ログや通知など影響範囲を可視化しておけば判断しやすい。
まとめ
副作用・参照透過性・冪等性は似た文脈で語られるものの、対象と指している性質はそれぞれ異なる。
「どのレイヤーで」「誰にとっての変化を」「何回の操作について」語っているのかを最初に揃えるだけで、多くのすれ違いは解消できた。次に新しいチームへ概念を説明するときも、今回の整理をベースに進めてみるつもりだ。