React と Redux における immutability の重要性、あるいは JavaScript を何も理解してなかった話


JavaScript は奥が深い。以前このブログに「JavaScript はそれほど難しくないと思う」と書いたが、全くそんなことはなかった。

発端

React と Redux のドキュメントや関連するブログを読んでいると、しつこいくらい immutableimmutability という単語が登場する。

immutablemutable の対義語。 mutablemutate + -able で、 mutate は「ミュータント - mutant」という言葉に使われるように「変異する」という意味。ちなみにこの単語はニューヨークの下水道で暮らす亀が忍者に変異して悪と戦うアニメで覚えた。

immutable はその対義語なので「不変性」を意味しており、要は「あるオブジェクトの中身を直接書き換える(mutate)のではなくて、そのコピーを作ってそれを書き換えなさい」ということを指している。一般的に、 JavaScript において object を immutable update することにはいくつかのメリットがある。

https://developer.mozilla.org/en-US/docs/Glossary/Immutable

実は React のチュートリアルでも immutability の重要性について説明されているのだが、私はなぜそれが必要なのかを深く理解しないままやり過ごしてきた。

しかし、どうも気になる…なんでわざわざこんな事をしなければならんのか。ということで、調べてブログに書くことにした。

JavaScript における immutability

React や Redux における immutability の重要性について調べる前に、まずは JavaScript の基本を学ぶ。

JavaScript において、値は必ず primitiveobject のいずれかになる。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures

primitive は以下の 7 種類のデータ型で、これらに該当しない値が object となる。

  • string
  • number
  • bigint
  • boolean
  • undefined
  • symbol
  • null

https://developer.mozilla.org/en-US/docs/Glossary/Primitive

primitive は「immutable」で object は「mutable」という互いに異なる性質を持つ。

primitive の immutability

primitive である文字列 Hello に別の文字列 , World! を連結すると、新しくできた文字列 Hello, World! は元々の 2 つの文字列とは全く別の新しい文字列として作られる。文字列 Hello が直接 mutate されるわけではない。これは C 言語とは異なる。

実際の例を見てみる。

let immutableString = "Hello";

immutableString = immutableString + ", World!"; // update

上記のコードで変数 immutableString の update 時には

  1. 変数 immutableString の値 Hello を取得し
  2. , World! という値を 1. の値に追加し
  3. 2. の結果を新しく確保されたメモリに割当て
  4. 変数 immutableString が 3. のメモリを参照し
  5. ガベージコレクション(以下 GC と呼ぶ)が update 前の参照先である 1. のメモリを解放して利用可能にする

という処理が行われる。

https://developer.mozilla.org/en-US/docs/Glossary/Mutable

object の mutability

object は mutable であり、そのプロパティ(や Array の要素)は変更することができる。

const book1 = { title: "The Road of the Rings" };
book1.title = "The Lord of the Rings";

console.log(book1); // { title: 'The Lord of the Rings' }

object は値への参照とみなすこともできる。下記のように、ある object がそのプロパティとして持つ値が等しくても、 object の参照先が等しくなければそれは異なる object となる。

const book1 = { title: "The Lord of the Rings" };
const book2 = { title: "Harry Potter" };

book2.title = "The Lord of the Rings";

console.log(book1 === book2); // false
console.log(book1.title === book2.title); // true

下記の例では book1 と book3 は同じ参照先を持つ object なので、 book3.title を mutate すると book1.title も同時に mutate される。

const book1 = { title: "The Lord of the Rings" };
const book3 = book1;

console.log(book1 === book3); // true

book3.title = "The Chronicles of Narnia";

console.log(book1 === book3); // true

console.log(book1); // { title: 'The Chronicles of Narnia' }

このように、「object は mutable である」とは「object の参照先を変えないまま参照先の値を書き換えることができる」と言い換えることができる。

React における immutability の重要性

本記事の冒頭で挙げたように、 React において「なぜ immutability が重要なのか」という理由はチュートリアルに書いてある。それによると、 object を immutable update することで

  1. 変更履歴を活用するような機能を簡単に実装できる
  2. (React が)簡単にオブジェクトの変更を検出できる
  3. 2. によって(React が)コンポーネントをいつ再描画するかを決定しやすくなる

というメリットがあるそうだ。

https://ja.reactjs.org/tutorial/tutorial.html#why-immutability-is-important

上記のうち 1. は自明だが 2. と 3. については React 内部の動作を知らないと納得がいかないと思う。自分はここを深く理解せず思考停止で immutable update をしていた。

React.Component では shouldComponentUpdate() という API を通じて、いつ再描画すべきかをコンポーネントに伝えている。この shouldComponentUpdate() はコンポーネントの引数(props)か状態(state)が更新される度に render() の前に呼び出されて真偽値(デフォルトは true)を返し、これが false を返す場合は render() が実行されず再描画されない。

https://ja.reactjs.org/docs/react-component.html#shouldcomponentupdate

component-lifecycle-diagram

上の図は ↓ から引用した。

https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

shouldComponentUpdate() の詳細な動作についてはドキュメントで説明されている。これによると、ツリー状に連なったコンポーネントにおいて shouldComponentUpdate() の結果が true かつ描画される React 要素が等しいかどうかで Reconciliation (差分を検出する処理)を実行するかを判定している。

https://ja.reactjs.org/docs/optimizing-performance.html#shouldcomponentupdate-in-action

Reconciliation の詳細な仕組みについてもドキュメントに記載されている。ツリー構造の全体で差分比較をすることは計算コストが高すぎる(ドキュメントによるとツリーの要素数を n として O(n^3) らしい)ので、 React ではツリーの差分検出を O(n) 程度の計算量で行うためにいくつかの前提を置いているそうだ。

https://ja.reactjs.org/docs/reconciliation.html

Reconciliation の結果として「差分がある」ということになれば、 React は render() が返す JSX 1を使って生成した DOM で実 DOM を更新する。

このようなコンポーネントのライフサイクルを見ると、 React において immutability が重要である詳細な理由が見えてくる。それは、「props や state の更新が行われるたびに」 shouldComponentUpdate() が呼び出されることにある。shouldComponentUpdate() が呼び出されれば、それはデフォルトで true を返す。すると React は render() を呼び出し JSX を得る。React はその結果を使って Reconciliation を行い、差分があれば実 DOM を更新する。ここで、ライフサイクルの最初で props や state が変わったことをどのように検出するかという問題がある。JavaScript の object は mutable で、その参照先を書き換えた上で書き換え前の object と比較しても等しいままなのであった。

論理的には object のプロパティを辿ってその一つ一つを比較することで object が更新されたかどうかを検出することは可能ではあるものの、現実的には計算コストが高そうで、要素数が n なら O(n) の計算量になるはずだ。この点、 immutable な更新を行えば参照が異なるというだけで中身を見なくても変更されていることが分かるので、この前提を守るだけで非常に少ない計算コスト O(1) で変更を検出できることが分かる。

具体例としては、 React では state object の中身を(setState 関数を使わずに)直接 mutate しても DOM は再描画されないという仕様がある。これは、 setState が state object の immutable な更新を行っているが、この state object を直接書き換えるとその参照先は同じなので(React からすると)変わったように見えないからだ。

https://ja.reactjs.org/docs/state-and-lifecycle.html#do-not-modify-state-directly

Redux における immutability の重要性

実世界で React コンポーネントを作るときはクラスで状態を持つことは少なく、多くのコンポーネントは props を受け取り JSX を返す関数として構成され、少数の閉じたコンポーネントで使う状態は React Hooks で、コンポーネントツリー内のあちこちで共通の状態を使い回すのであれば Redux 等のライブラリで状態を管理することになると思う。

Redux で管理する状態は Store と呼ばれるグローバルな単一の object に統一されている。この Store は Reducer と呼ばれる、 Action と現在の状態を引数に取り新しい状態を返す関数によってのみ更新される。 Action は Reducer が識別に使うための type 要素を持つ単なる JavaScript object で、 UI 等から呼び出される Dispatch と呼ばれる関数によって Store に送られる。

https://redux.js.org/tutorials/essentials/part-1-overview-concepts

redux-dataflow-diagram

Redux において immutability が重要になるのは、 object を直接 mutate するようなコードが React-Redux のような Redux の Store に依存するライブラリにおいて様々な問題を引き起こすためとドキュメントには書かれている。 React-Redux では root state object を shallow check することで state をラップするコンポーネントの再描画が必要かどうかを検出するため、直接 mutate していると再描画がトリガーされない。

https://redux.js.org/faq/immutable-data#does-shallow-equality-checking-with-a-mutable-object-cause-problems-with-redux

Redux-Toolkit を使う

上記のように、 Redux においても object を immutable update することが重要だ。

しかし、実世界で Redux アプリケーションを作る際に使うであろう Redux Toolkit の createSlice や createReducer は内部で Immer を使っていて、その中では object を mutate するようなコードを書いたとしても Immer が immutability を担保してくれるので開発者側からすると意識しないかもしれない…これを知らずに自力で state の immutable update を試みるとかえって余計なバグを生み出しそうだ。つまり、 immutability は重要だが、必ずしもそれを担保するコードを自分で書く必要はない。

https://redux.js.org/tutorials/fundamentals/part-8-modern-redux#writing-slices

Immer は現在の状態と次の状態の間を取り持つ proxy を用意し、開発者が試みた全ての変更を追跡し安全に immutable update をしてくれる。

https://hackernoon.com/introducing-immer-immutability-the-easy-way-9d73d8f71cb3

ちなみに Immer の名前は immutable から取ってるのかなと思ったら、(それもあるだろうけど)ドイツ語で「いつも」という意味でもあるらしい。名前の付け方がおしゃれだ…

https://immerjs.github.io/immer/docs/introduction

おまけ: JavaScript のメモリ管理

プログラミングを始めたばかりの頃に C 言語を使って libc の再実装をやっていた時期があって、そのときは malloc や calloc でメモリを確保して free で解放するプログラムを書いていた。普段 JavaScript や TypeScript を書いているとメモリ管理について意識することが少ないのだが、この機会にどうなっているのか調べてみた。

JavaScript では object の生成時に自動的にメモリを割り当て、それが “不要” になると GC によって自動的にメモリが解放される。 GC が既に割り当てられたメモリを “不要” とするアルゴリズムはいろいろある。

例えば、 Reference-counting と呼ばれるやり方では、「ある object が他の object から参照されていない(つまり reference-count が 0 である)」ことを以てその object を “不要” とみなす。これは一見うまく行くように見えるが、 object 間に循環参照がある場合 GC の対象にならないという欠点がある。

Mark-and-sweep algorithm と呼ばれるやり方では、 root と呼ばれる global object からスタートして参照を辿り、そこから到達不可能な object を “不要” とみなしてメモリを解放する。 root から到達可能かどうかによって GC の必要性を判断するので、お互いにお互いを参照する object が存在してもそれが root から到達不可能であれば GC の対象となる。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Memory_Management

GC があってもメモリ管理は必要

何を今更という話かもしれないが、上記の通り JavaScript はメモリの割当や解放を勝手にやってくれるだけで、それはメモリ管理についての配慮が不要であることを意味しない。

例えば GC のアルゴリズムでは “不要” とみなされないが開発者や利用者の視点からは “不要” なメモリをたくさん確保しそれらを解放しないまま動作し続けるようなプログラムを実行したらクラッシュするだろう。

家にゴミ箱があっても、ちゃんとゴミ箱に捨てて収集日にゴミ出しする習慣が無いと家がゴミだらけになってしまう。

GC 以外のメモリ管理方法: Rust

最近ちょっとだけ Rust の勉強をした。といっても The Rust Programming Language 日本語版を一通り読んだだけだが…

Rust には JavaScript のような GC が存在しない。ではどうやって “不要” なメモリを OS に返却し再割当可能にしているかというと、「所有権 - Ownership」という独特な仕組みが用意されている。 Rust の値は「所有者 - Owner」と呼ばれる変数と対応している。所有者は必ず 1 つの変数で、その変数がスコープを抜けたらメモリは自動的に解放される。

https://doc.rust-jp.rs/book-ja/ch04-01-what-is-ownership.html

Rust は所有権を中心に様々な特殊な文法を持っていて、解放されたメモリにアクセスしたり確保されたメモリを二重に解放してしまうようなメモリ関連のバグを強制的に防ぐことができる。つまり言語仕様のレベルでメモリの安全性が担保されている。コンパイルの段階つまりプログラムの実行前にそれを知ることができるので、とても便利だ。

仕事で使うことは無さそうだが、面白かったので引き続き勉強して趣味で使おうと思う。

プログラミングは奥が深い

プログラミング、たぶん一生理解できないまま寿命かやる気のどっちかが尽きるんだろうな〜という思いがある。

今回はドキュメントベースで勉強していったけど、直接いじりながら動かした方がドキュメント化されていない挙動を確認したり詳細を理解できるので勉強になりそうだ。 preact という小さな React があるので、時間があるときに触ってみたい。

あと私は初心者なのでこの記事に間違いあったらすみません。各自の責任で調べて下さい。

Footnotes

  1. 実は JSX を使わずに React を使うこともできる