💎

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

11 min read

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

まだこの提案は Stage 2 です。仕様が変更される可能性があります。

新たなプリミティブとして「複合プリミティブ(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.pushed(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 にオブジェクトを持たせることが出来ます。特に何らかの処理をするメソッドを持たせることで便利になることがあるかもしれません。ただしそうやって実装した場合 GC によってオブジェクトの開放が出来ない問題があります。

この問題を解決する提案が Stage 2 Symbols as WeakMap keys です。

https://github.com/tc39/proposal-symbols-as-weakmap-keys

WeakMap のキーに Symbol を使うことによって、その Symbol への参照が無くなった際に一緒に対応するオブジェクトも CG に回収させることが出来るようになります。

また WeakMap を使ったアプローチとは別に、もっと直接的にオブジェクトを持たせられるようにする Box を導入する議論もあります。

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

等価性、同値性

=== 演算子や Object.is を使うことでデータが等しいかどうか判定できます。

console.assert( #{ a: 1, b: #[1, 2] } === #{ a: 1, b: #[1, 2] } );

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 を作りたい場合は 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,
};

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

Tuple の特徴的なメソッド

Tuple はプロトタイプに Array と同じようなメソッドを持っていますが、非破壊的なため一部 Array とは異なります。

Tuple#pushed

Tuple の末尾に一つ以上の要素を追加し、新たな Tuple を返します。Array#push のように要素数は取得できません。

const tuple1 = #[1, 2];
const tuple2 = tuple1.pushed(3, 4);

console.assert( tuple1 === #[1, 2] );
console.assert( tuple2 === #[1, 2, 3, 4] );

Tuple#popped

Tuple の末尾から一つ要素を取り除いた新たな Tuple を返します。Array#pop のように取り除いた要素は取得できません。

const tuple1 = #[1, 2, 3];
const tuple2 = tuple1.popped();

console.assert( tuple1 === #[1, 2, 3] );
console.assert( tuple2 === #[1, 2] );

Tuple#unshifted

Tuple の先頭に一つ以上の要素を追加し、新たな Tuple を返します。Array#unshift のように要素数は取得できません。

const tuple1 = #[1, 2];
const tuple2 = tuple1.unshifted(3, 4);

console.assert( tuple1 === #[1, 2] );
console.assert( tuple2 === #[3, 4, 1, 2] );

Tuple#shifted

Tuple の先頭から一つ要素を取り除いた新たな Tuple を返します。Array#shift のように取り除いた要素は取得できません。

const tuple1 = #[1, 2, 3];
const tuple2 = tuple1.shifted();

console.assert( tuple1 === #[1, 2, 3] );
console.assert( tuple2 === #[2, 3] );

Tuple#with

指定したインデックスの値を更新した新しい Tuple を返します。インデックスに負数や Tuple#length 以上の値を入れた場合 RangeError を投げます。

const tuple1 = #[1, 2, 3];
const tuple2 = tuple1.with(1, 5);

console.assert( tuple1 === #[1, 2, 3] );
console.assert( tuple2 === #[1, 5, 3] );

Tuple#spliced

指定したインデックスから任意の数の要素を取り除き、要素を追加した新しい Tuple を返します。Array#splice のように取り除いた要素は取得できません。

const tuple1 = #[1, 2, 3, 4];
const tuple2 = tuple1.spliced(1, 2);
const tuple3 = tuple1.spliced(1, 2, 5, 6);

console.assert( tuple1 === #[1, 2, 3, 4] );
console.assert( tuple2 === #[1, 4] );
console.assert( tuple3 === #[1, 5, 6, 4] );

Tuple#sorted

Tuple をソートして新しい Tuple を返します。引数を省略した場合は Array と同じように文字列による比較が行われます。

const tuple1 = #[10, 20, 1, 2];
const tuple2 = tuple1.sorted();
const tuple3 = tuple1.sorted((a, b) => a - b);

console.assert( tuple1 === #[10, 20, 1, 2] );
console.assert( tuple2 === #[1, 10, 2, 20] );
console.assert( tuple3 === #[1, 2, 10, 20] );

Tuple#reversed

Tuple の要素を反転させた新しい Tuple を返します。

const tuple1 = #[1, 2, 3];
const tuple2 = tuple1.reversed();

console.assert( tuple1 === #[1, 2, 3] );
console.assert( tuple2 === #[3, 2, 1] );

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

Tuple のメソッドを受けて、ArrayTypedArray にも非破壊的なメソッドを追加する提案が Stage 2 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

ログインするとコメントできます