イミュータブルにデータを扱うライブラリと Stage 2 Record & Tuple
変更情報
【2023/05/05 変更】
- ES2023 Change Array by Copy の議論によって
Array
に追加するメソッドが減り、同様にTuple
から取り除かれたpushed
やsorted
などの独自メソッドについての記述を削除 - Symbols as WeakMap keys が ES2023 となったため修正
-
0
,-0
,NaN
の等価性、同値性が決まったため修正 -
JSON.parseImmutable
が別提案としてスプリットされたため修正 - 支持されなかった Box についての記述を削除
JavaScript におけるイミュータブル、ミュータブル
JavaScript においてプリミティブはイミュータブル、つまり変更不可能です。
一方でオブジェクトは基本的にミュータブル、つまり変更可能です。Object.freeze
を使って凍結することは出来ますが、アクセサプロパティは問題なく動作し、[[Prototype]]
の変更に影響されます。つまり凍結されているからといって完全にイミュータブルであるとは言えないでしょう。
let a = 1;
const obj = Object.freeze({
get a() { return a; },
set a(val) { a = val; },
});
console.log(obj.a); // => 1
++obj.a;
console.log(obj.a); // => 2
const prototype = { a: 1 };
const obj = Object.freeze(Object.create(prototype));
console.log(obj.a); // => 1
++prototype.a;
console.log(obj.a); // => 2
イミュータブルにデータを扱うライブラリ
Immutable.js
完全にイミュータブルなデータを扱うライブラリに Immutable.js があります。これを使うことで意図しない変更によるバグを防ぐことが出来ます。
import * as Immutable from "immutable";
const map1 = Immutable.Map({ a: 1, b: 2 });
const map2 = map1.set("c", 3);
console.assert( Immutable.is(map1, Immutable.Map({ a: 1, b: 2 })) );
console.assert( Immutable.is(map2, Immutable.Map({ a: 1, b: 2, c: 3 })) );
const list1 = Immutable.List([1, 2]);
const list2 = list1.push(3);
console.assert( Immutable.is(list1, Immutable.List([1, 2])) );
console.assert( Immutable.is(list2, Immutable.List([1, 2, 3])) );
永続データ構造
Immutable.js は永続データ構造のテクニックを使っています。永続データ構造とはデータが更新される際に、更新前のデータを保持するデータ構造のことです。
データが更新される度に内部で差分を取って保持しているため、ヒープメモリの使用を抑えられます。また二つのイミュータブルなデータを比較する際に、持っているプロパティ全てを比較しなくてすむことがあります。
Directed Acyclic Graph (React.js Conf 2015 - Immutable Data and React)
一方でデータ構造が著しく変化するような場合には不向きです。また普通のオブジェクトよりも値の更新コストは少なくなりますが、値を取得するコストが増えてしまいます。
Immer
オブジェクトから新しいオブジェクトを手軽に作ることが出来るライブラリとして Immer があります。提供される produce
函数を使ってオブジェクトを作る場合に限って、元のオブジェクトが変更されないことが保証されます。ついでに新たに生成されたオブジェクトは凍結されます。
import produce from "immer";
const obj1 = { a: 1, b: 2 };
const obj2 = produce(obj1, (draft) => {
draft.c = 3;
});
console.log(obj1); // => { a: 1, b: 2 }
console.log(obj2); // => { a: 1, b: 2, c: 3 }
console.assert( !Object.isFrozen(obj1) );
console.assert( Object.isFrozen(obj2) );
const arr1 = [1, 2];
const arr2 = produce(arr1, (draft) => {
draft.push(3);
});
console.log(arr1); // => [1. 2]
console.log(arr2); // => [1, 2, 3]
console.assert( !Object.isFrozen(arr1) );
console.assert( Object.isFrozen(arr2) );
Immutable.js と Immer の比較
両者ともイミュータブルにデータを扱うライブラリとして有名ですが、特性はかなり異なっています。詳しくは uhyo さんの記事を御覧ください。
Stage 2 Record & Tuple
新たなプリミティブとして「複合プリミティブ(Compound primitive)」なデータ型である Record
と Tuple
が提案されています。ライブラリに頼らず完全にイミュータブルなデータを扱うことが出来るようになります。
const record1 = #{ a: 1, b: 2 };
const record2 = #{ ...record1, c: 3 };
console.assert( record1 === #{ a: 1, b: 2 } );
console.assert( record2 === #{ a: 1, b: 2, c: 3 } );
console.log(record1); // => Record { a: 1, b: 2 }
console.log(record2); // => Record { a: 1, b: 2, c: 3 }
const tuple1 = #[1, 2];
const tuple2 = #[...tuple1, 3];
console.assert( tuple1 === #[1, 2] );
console.assert( tuple2 === #[1, 2, 3] );
console.log(tuple1); // => Tuple [1, 2]
console.log(tuple2); // => Tuple [1, 2, 3]
この提案は Immutable.js と Immer の両者が持つ良い特徴を兼ね備えています。Immutable.js のように完全にイミュータブルなデータを構築し、実装エンジン側で永続データ構造を使うことで計算量のコストを減らすといった最適化出来ます[1]。また Immer のように扱いやすく、データの中身を簡単に表示出来ます。
プロパティの制限
Record
と Tuple
はプロパティとしてプリミティブ値しか保持できません。
const record = #{
id: 1234,
symbol: Symbol(),
// Tuple 自体はプリミティブなので保持できる
tags: #["foo", "bar", "baz"],
};
// throws TypeError: cannot use an object as a value in a record
const record = #{
foo: {},
};
また Record
のプロパティのキーは文字列のみとなっています。これは後述する値の比較を容易にするためです。
// throws TypeError: Record may only have string as keys
const record = #{
[Symbol()]: "foo",
};
そして Tuple
では Array
のような hole を持つことは許されません。
// SyntaxError: holes are disallowed by syntax
const tuple = #[1, 2, , 4];
どうしてもオブジェクトを持たせたい場合
数値や文字列などを使ってオブジェクトと対応するテーブルを作ることで擬似的に Record
や Tuple
にオブジェクトを持たせることが出来ます。特に何らかの処理をするメソッドを持たせることで便利になることがあるかもしれません。ただしそうやって実装した場合ガベージコレクションによるオブジェクトの開放が出来ない問題があります。
この問題を解決するのが ES2023 Symbols as WeakMap keys です。
WeakMap
のキーに Symbol
を使うことによって、その Symbol
への参照が無くなった際に一緒に対応するオブジェクトもガベージコレクタに回収させることが出来ます。
等価性、同値性
===
演算子や Object.is
を使うことでデータが等しいかどうか判定できます。
console.assert( #{ a: 1, b: #[1, 2] } === #{ a: 1, b: #[1, 2] } );
IEEE 754 浮動小数点数のエッジケースである 0
, -0
, NaN
の扱いについては以下のようになっています。
console.assert( #{ a: -0 } === #{ a: +0 } );
console.assert( #[-0] === #[+0] );
console.assert( #{ a: NaN } === #{ a: NaN } );
console.assert( #[NaN] === #[NaN] );
console.assert( !Object.is(#{ a: -0 }, #{ a: +0 }) );
console.assert( !Object.is(#[-0], #[+0]) );
console.assert( Object.is(#{ a: NaN }, #{ a: NaN }) );
console.assert( Object.is(#[NaN], #[NaN]) );
typeof
演算子
typeof
演算子の結果として新たに "record"
, "tuple"
が追加されます。
console.log(typeof #{ a: 1 }); // => "record"
console.log(typeof #[1, 2]); // => "tuple"
JSON
普通のオブジェクトと同じように JSON.stringify
で JSON 文字列を作ることが出来ます。
console.log(JSON.stringify(#{ a: #[1, 2, 3] })); // => '{"a":[1,2,3]}'
逆に JSON 文字列から Record
や Tuple
を作りたい場合は Stage 2 JSON.parseImmutable
を使います。
console.log(JSON.parseImmutable('{"a":[1,2,3]}')); // => Record { a: Tuple [1, 2, 3] }
深い階層構造を持つ場合のリテラル
深い階層構造を持っている場合、スプレッド構文のみで記述するのは厳しいものがあります。この問題を解決していたのが Immer なのでした。
const record1 = #{
a: #{
b: #{
foo: 1,
bar: 2,
},
baz: 3,
},
};
const record2 = #{
...record1,
a: #{
...record1.a,
b: #{
...record1.a.b,
bar: 5,
},
},
};
というわけで Record
のリテラルを拡張する提案が Stage 1 Deep Path Properties in Record Literals です。
const record1 = #{
a: #{
b: #{
foo: 1,
bar: 2,
},
baz: 3,
},
};
const record2 = #{
...record1,
a.b.bar: 5,
};
Array
や TypedArray
にも
非破壊的メソッドを Tuple
のメソッドを受けて、Array
や TypedArray
にも非破壊的なメソッドを追加したのが ES2023 Change Array by Copy です。
この提案は新たに Array
や TypedArray
を作ることから本来は @@species
の影響を受けるはずですがそうはなっていません。詳しくは以前書いた記事を御覧ください。
結び
今回はイミュータブルなデータを扱える新しいプリミティブとして提案されている Record
と Tuple
について紹介してみました。
この提案はかなり力が入っており、Stage 3 Temporal と同じようにドキュメントやクックブックが提供されています[2]。
また実際にコードが試せる Playground も用意されています。
特に React などでステート管理を扱うのに便利な機能になるため、使えるようになるのが待ち遠しいです。ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。
-
最適化については仕様には含まれておらず、特に制限もありません。実装エンジン側で自由に選ぶことが出来ます。 ↩︎
-
Temporal のドキュメント(日本語)、クックブック ↩︎
Discussion