基礎●●●○○

イミュータビリティ

Immutability ・ いみゅーたびりてぃ

一度作った値を書き換えず、変更を新しい値の生成で表す方針。共有と並行処理に強いコードの土台になる。

✦ 30秒で試す — 航海書き換え禁止のデータで、どうやって値を変える?新しい版のために、新しく作る箱はいくつ?予想してみる

概要

イミュータビリティ(不変性)とは、一度作った値を後から書き換えず、「変更したければ新しい値を作る」ことで変化を表す方針です。たとえば多くの言語で文字列は不変で、upper() のようなメソッドは元の文字列を書き換えるのではなく、大文字化された新しい文字列を返します。この「元は残したまま、変わった版を新しく作る」という発想を、文字列だけでなくリストやオブジェクトなどあらゆるデータに広げたものがイミュータビリティです。

この方針は関数型プログラミングの中心的な考え方として発展しましたが、いまでは特定のパラダイムに限らない設計の常識になりつつあります。React の状態更新、Redux などの状態管理、一度記録したコミットを決して書き換えない Git の履歴、起きた出来事を追記するだけで状態を表すイベントソーシングまで、「書き換えない」ことを土台にした仕組みは至るところにあります。

直感的には「書き換えたほうが速くて簡単なのに、なぜわざわざ作り直すのか」と感じるかもしれません。その答えは、書き換え(ミューテーション)が引き起こす事故の種類と、「作り直す」ことが実はそれほど高くつかない仕掛け(構造共有)の両方を知ると見えてきます。

なぜ生まれたか

多くの言語では、オブジェクトを変数に代入したり関数に渡したりするとき、実体はコピーされず参照だけが渡ります(値渡しと参照渡し)。つまり1つのオブジェクトを複数の場所が同時に「持って」いるのが普通の状態です。ここで誰か1人がそのオブジェクトを書き換えると、参照を持つ全員の見ている値が一斉に変わります。書き換えた本人には自分のコードしか見えていないのに、影響はプログラムの遠く離れた場所に波及します。「渡したはずの設定オブジェクトがいつの間にか壊れている」「関数を呼んだだけで引数が変わっていた」という、原因箇所と症状箇所が離れた追いにくいバグの典型です。

並行と並列の世界になると、問題はさらに深刻になります。複数のスレッドが同じ可変データを同時に読み書きすると、タイミング次第で結果が変わる競合状態(レースコンディション)が生じ、ロックによる排他制御が必要になります。ロックは正しく書くのが難しく、取る順番を誤ればデッドロックを招き、取りすぎれば性能が落ちます。突き詰めると、これらの問題の根はすべて「共有された可変状態」にあります。共有をやめるか、可変をやめるかのどちらかで問題は消えます。イミュータビリティは後者、つまり「そもそも書き換え不可能にすれば、書き換え事故も読み書きの競合も原理的に起こらない」という解決です。

ただし「毎回まるごとコピーする」のでは遅すぎて実用になりません。この壁を越えたのが、1980〜90年代に研究された永続データ構造(persistent data structure)です。Chris Okasaki の『Purely Functional Data Structures』(1998年)などで体系化され、2007年に登場した Clojure が「標準のコレクションはすべて不変で、しかも速い」ことを実用言語で示したことで、不変のデータは関数型言語の外へ広がっていきました。

詳細

変更とは新しい値を作ること

イミュータブルな世界では、「リストに要素を追加する」は「要素が1つ多い新しいリストを作る」ことを意味します。元のリストはそのまま残るため、参照を持っていた人たちの見ている値は絶対に変わりません。変化はすべて「古い値 → 新しい値」という置き換えとして表れるので、「いつ・どこで値が変わったか」が代入箇所として明示的になります。変更履歴(アンドゥ、タイムトラベルデバッグ)も、過去の値を捨てずに持っておくだけで実現できます。

破壊的更新イミュータブル参照者A参照者B書き換え役共有オブジェクト中身が書き換わる1人の書き換えが全員の見る値を変えてしまう参照者A参照者B変更する役旧バージョン不変のまま残る新バージョン新しく生成旧版を見ている人には何も起きない
破壊的更新は共有者全員に波及する。イミュータブルなら旧版を見ている人は影響を受けない

構造共有 — 毎回まるごとコピーするわけではない

「変更のたびに作り直すなんて、巨大なデータでは遅すぎるのでは」というのが当然の疑問です。ここで効くのが構造共有(structural sharing)という技法です。不変なデータは絶対に変わらないと保証されているからこそ、新しいバージョンは変わっていない部分を旧バージョンと安全に共有できます。木構造で1つの葉を変更するなら、根からその葉までの経路上の節点だけを作り直し(経路複製、path copying)、残りの枝はすべて旧版のものをそのまま指せばよいのです。

旧バージョン新バージョン根 v1根 v2節点 B部分木 A節点 B′葉 3葉 B1葉 7共有(コピーしない)旧版はそのまま残る破線 = 新版から旧版の枝を指す経路上の3つだけ新しく作る不変だからこそ共有しても安全 — 誰かに書き換えられる心配がない
構造共有 — 根から変更した葉までの経路だけを作り直し、変わらない枝は新旧の両方から指す

こうして作られたデータ構造が永続データ構造です。深さは要素数 n に対しておおむね log n なので、1回の変更で作り直す節点も log n 個で済みます。Clojure の標準コレクションや Scala の不変コレクション、JavaScript の Immutable.js は、1つの節点に32本前後の枝を持たせた幅の広い木(Hash Array Mapped Trie など)を使い、100万要素でも深さ4〜5、つまり数個の節点の複製で「変更された新しい版」を作ります。

変更検知が参照比較で済む

イミュータビリティのもう1つの大きな恩恵は、「変わったかどうか」を一瞬で判定できることです。可変なオブジェクトでは、変わったかを知るには中身を隅々まで比較する(深い比較)しかありません。しかし「変更=新しいオブジェクトの生成」という約束が守られていれば、参照が同じなら中身も同じと言い切れるため、=== の参照比較1回で判定が終わります。しかも構造共有のおかげで、比較は根から始めて「参照が違う枝」だけを下りればよく、参照が同じ枝は中身を見ずに丸ごと飛ばせます。

React が仮想DOMの再描画を省く判断(メモ化)、Redux などの状態管理ライブラリが状態の変化を購読者に通知する仕組み、シグナル系のリアクティビティの依存追跡は、いずれもこの「参照が変わったら中身が変わった」という約束に支えられています。逆に、状態オブジェクトを直接書き換えてしまうと参照が変わらないため変更が検知されません。React の入門者が最初に踏む「setState したのに再描画されない」バグは、まさにこの約束違反です。

下の部品で確かめてください。木の葉を押して値を変えると、根からその葉までの経路だけが金色の新しい節点として複製され、残りの枝は旧版と同じ箱を指し続けます。「比較する」を押すと、旧 === 新 が経路上でだけ false になり、それ以外の枝は1回の比較で丸ごと飛ばされる様子が見えます。書き換え方を「破壊的」に切り替えると何が起きるかも試してみてください。

⚡ 体験: 葉を1つ変える — 経路だけを複製し、残りは共有する
書き換え方
prev旧版cur新版prev === cur319224514831415926prev → [3, 1, 4, 1, 5, 9, 2, 6]cur → [3, 1, 4, 1, 5, 9, 2, 6]新しく作った節点共有している節点= / ≠ … 旧 === 新 の比較結果
新しく作った節点—共有している節点—比較の回数—

いちばん下の葉(数字)を1つ押して、値を +1 してみてください。上の節点は、子の合計を持っています。

言語への広がりと、よくある誤解

イミュータビリティは Haskell や Erlang のような関数型プログラミングの言語では標準(むしろ唯一の選択肢)でしたが、その利点が広く認められ、主流の言語にも取り入れられてきました。Java の record や String の不変性、C# の record と init 専用プロパティ、Python の tuple や frozen=True の dataclass、そして Rust は「変数は既定で不変、書き換えたければ mut を明示する」という逆転の設計です。API の面でも、元を書き換えずに新しい配列を返す toSorted や with のようなメソッドが、破壊的なメソッドの代わりとして JavaScript に追加されています。

ただし、言語の機能がどこまで守ってくれるかには注意が要ります。JavaScript の const は「変数の再代入」を禁止するだけで、指している中身の不変までは保証しません(const user = {...} でも user.name = "x" は通ります)。Object.freeze は中身の書き換えを禁止しますが、凍るのはそのオブジェクト自身のプロパティだけで、入れ子のオブジェクトは凍りません(浅い凍結)。Object.freeze(config) のあとでも config.db.host = "x" は通ってしまうので、深く凍らせたければ再帰的に freeze するか、型(TypeScript の readonly / Readonly<T>)や不変コレクションのライブラリで守ることになります。

トレードオフ — 生成のコストと使いどころ

万能ではありません。構造共有があっても、新しいオブジェクトの生成は書き換えよりメモリ確保が多く、短命なオブジェクトを大量に作るためガベージコレクションへの負荷も増えます。数値計算のホットループや巨大な配列の頻繁な更新など、性能が最優先の箇所では、可変なデータを局所的に使うのが現実解です。

実践的な指針は「共有される境界では不変、関数の内部に閉じた一時変数は可変でもよい」という使い分けです。外に見える状態を不変に保てば事故の波及は防げますし、Clojure の transient や JavaScript の Immer のように「内部では効率よく書き換え、完成品を不変として公開する」折衷の仕組みも整っています。不変を既定にし、可変を例外として意識的に選ぶ。それが現代的なバランスです。

試問

8枚の葉を持つ木で、上の節点は子の合計を持ちます。変数 prev(旧版)と cur(新版)が根を指し、葉を押すと値が +1 されます。いまはデータを「破壊的(その場で書き換え)」に更新する設定で、上の切り替えで「不変(新しい版を作る)」にもできます。画面の再描画は、prev === cur の参照比較が false のときにだけ起きるとします。

✦ 命題挑戦中

葉の値を変え、その変化を prev === cur の比較1回で検知できる状態にせよ(旧版 prev の値は変えずに)

  • 葉の値を1つ以上変える
  • prev === cur が false(比較1回で「変わった」と分かる)
  • 旧版 prev から見える値は、変える前のまま
⚡ 体験: 変えたことを、比較1回で気づかせる
書き換え方
prev旧版cur新版prev === cur319224514831415926prev → [3, 1, 4, 1, 5, 9, 2, 6]cur → [3, 1, 4, 1, 5, 9, 2, 6]新しく作った節点共有している節点= / ≠ … 旧 === 新 の比較結果
新しく作った節点—共有している節点—比較の回数—

いちばん下の葉(数字)を1つ押して、値を +1 してみてください。上の節点は、子の合計を持っています。