💎

イミュータブルにデータを扱うライブラリと Stage 2 Record & Tuple

2021/09/23に公開
変更情報

【2023/05/05 変更】

  • ES2023 Change Array by Copy の議論によって Array に追加するメソッドが減り、同様に Tuple から取り除かれた pushedsorted などの独自メソッドについての記述を削除
  • Symbols as WeakMap keys が ES2023 となったため修正
  • 0, -0, NaN の等価性、同値性が決まったため修正
  • JSON.parseImmutable が別提案としてスプリットされたため修正
  • 支持されなかった Box についての記述を削除

JavaScript におけるイミュータブル、ミュータブル

JavaScript においてプリミティブはイミュータブル、つまり変更不可能です。

https://developer.mozilla.org/ja/docs/Glossary/Primitive

一方でオブジェクトは基本的にミュータブル、つまり変更可能です。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])) );

https://immutable-js.com/

永続データ構造

Immutable.js は永続データ構造のテクニックを使っています。永続データ構造とはデータが更新される際に、更新前のデータを保持するデータ構造のことです。

https://ja.wikipedia.org/wiki/永続データ構造

データが更新される度に内部で差分を取って保持しているため、ヒープメモリの使用を抑えられます。また二つのイミュータブルなデータを比較する際に、持っているプロパティ全てを比較しなくてすむことがあります。

Directed Acyclic Graph
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) );

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

Immutable.js と Immer の比較

両者ともイミュータブルにデータを扱うライブラリとして有名ですが、特性はかなり異なっています。詳しくは uhyo さんの記事を御覧ください。

https://zenn.dev/uhyo/articles/immutable-immer

Stage 2 Record & Tuple

新たなプリミティブとして「複合プリミティブ(Compound primitive)」なデータ型である RecordTuple が提案されています。ライブラリに頼らず完全にイミュータブルなデータを扱うことが出来るようになります。

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]

https://github.com/tc39/proposal-record-tuple

この提案は Immutable.js と Immer の両者が持つ良い特徴を兼ね備えています。Immutable.js のように完全にイミュータブルなデータを構築し、実装エンジン側で永続データ構造を使うことで計算量のコストを減らすといった最適化出来ます[1]。また Immer のように扱いやすく、データの中身を簡単に表示出来ます。

プロパティの制限

RecordTuple はプロパティとしてプリミティブ値しか保持できません。

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",
};

https://github.com/tc39/proposal-record-tuple/issues/15

そして Tuple では Array のような hole を持つことは許されません。

// SyntaxError: holes are disallowed by syntax
const tuple = #[1, 2, , 4];

https://github.com/tc39/proposal-record-tuple/issues/84

どうしてもオブジェクトを持たせたい場合

数値や文字列などを使ってオブジェクトと対応するテーブルを作ることで擬似的に RecordTuple にオブジェクトを持たせることが出来ます。特に何らかの処理をするメソッドを持たせることで便利になることがあるかもしれません。ただしそうやって実装した場合ガベージコレクションによるオブジェクトの開放が出来ない問題があります。

この問題を解決するのが ES2023 Symbols as WeakMap keys です。

https://github.com/tc39/proposal-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]) );

https://github.com/tc39/proposal-record-tuple/issues/65

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 文字列から RecordTuple を作りたい場合は Stage 2 JSON.parseImmutable を使います。

https://github.com/tc39/proposal-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 です。

https://github.com/tc39/proposal-deep-path-properties-for-record

const record1 = #{
  a: #{
    b: #{
      foo: 1,
      bar: 2,
    },
    baz: 3,
  },
};
const record2 = #{
  ...record1,
  a.b.bar: 5,
};

非破壊的メソッドを ArrayTypedArray にも

Tuple のメソッドを受けて、ArrayTypedArray にも非破壊的なメソッドを追加したのが ES2023 Change Array by Copy です。

https://github.com/tc39/proposal-change-array-by-copy

この提案は新たに ArrayTypedArray を作ることから本来は @@species の影響を受けるはずですがそうはなっていません。詳しくは以前書いた記事を御覧ください。

https://zenn.dev/petamoriken/articles/d413ce090e40bb

結び

今回はイミュータブルなデータを扱える新しいプリミティブとして提案されている RecordTuple について紹介してみました。

この提案はかなり力が入っており、Stage 3 Temporal と同じようにドキュメントやクックブックが提供されています[2]

https://tc39.es/proposal-record-tuple/tutorial/

https://tc39.es/proposal-record-tuple/cookbook/

また実際にコードが試せる Playground も用意されています。

https://rickbutton.github.io/record-tuple-playground/

特に React などでステート管理を扱うのに便利な機能になるため、使えるようになるのが待ち遠しいです。ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。

脚注
  1. 最適化については仕様には含まれておらず、特に制限もありません。実装エンジン側で自由に選ぶことが出来ます。 ↩︎

  2. Temporal のドキュメント(日本語)クックブック ↩︎

Discussion