JavaScript
lodash

ES6のconstを使い倒すレシピ2 - Object.freeze編 〜 JSおくのほそ道 #035

More than 1 year has passed since last update.

こんにちは、ほそ道です。

前回のconst前提共有編に続いて、今回から「どうやって予期しない値の状態変化を抑止するか」をやっていきます。
前回、constで宣言した変数は再代入ができないが、オブジェクトや配列などの子属性/要素が不意に変更されてしまう可能性をはらんでいる、という話をいれました。
今回はその対策の一つとして値を一切変更させない(凍結)手段についてやっていきます。

全体の目次はこちら

Object.freeze

ES5から追加となる、freezeを使うことで不変オブジェクトを生成できます。

Object.freeze
'use strict';

const o = Object.freeze({x: 1, y: 2});
o.x = 3;  // TypeError: Cannot assign to read only property 'x' of #<Object>

オブジェクト生成後にfreezeすることも可能です。が、constすることで値に状態を持たせたくないという狙いを考えると、「freezeした」という状態が生まれてしまうので、代入時にfreezeしてしまう方が良いと思います。

宣言後にfreeze
'use strict';

const o =  {x: 1, y: 2};
o.x = 3;

Object.freeze(o);

console.log(o);  // { x: 3, y: 2 }

o.x = 4;  // TypeError: Cannot assign to read only property 'x' of #<Object>

strictモード実行のススメ

このfreezeはstrictモードであれば、きっちりエラーが発生しますので、是非ともstrictモードで。
strictモードでない場合は代入が無視されるので、逆に想定外の状況を誘発するようになって危険だと思います。。

非strictモードでのfreeze
const o =  Object.freeze({x: 1, y: 2});
o.x = 3;
console.log(o.x);  // 1(上の代入はなかったことにされる)

この後のサンプルはstrict宣言を省きますが、'use strict'している体で続けていきます。

子属性がオブジェクトを抱えている場合

Object.freezeはトップレベルの属性を凍結してくれるのですが、
子属性にオブジェクトがあると、そこまでは凍結してくれません。

freezeは子属性には届かない
const o = Object.freeze({a: 1, b: {x: 2}});
o.b.x = 3;
console.log(o);  // { a: 1, b: { x: 3 } }

ではどうするか、子属性自体をfreezeすれば凍結することができます。

子属性も凍結
const o = Object.freeze({a: 1, b: Object.freeze({x: 2})});
o.b.x = 3;
console.log(o);  // TypeError: Cannot assign to read only property 'x' of #<Object>

MDNのObject.freezeページにもこのこのことに言及しており、子属性の凍結を汎用的に行えるdeepFreezeなる関数を提示してくれています。
ところがこのdeepFreezeは引数となるオブジェクト自体をfreezeして書き換えてしまい、新しいオブジェクトを返してくれるでもなかったので、ちょっと自分が使う用に書き換えてみました。一部lodashを使っています。

deepFreeze(カスタマイズ版)
const _ = require('lodash');

function deepFreeze(o) {
  const oFrz = _.clone(o);
  _.keys(oFrz).forEach(key => {
    if (oFrz.hasOwnProperty(key) && (typeof oFrz[key] === "object") && !Object.isFrozen(oFrz[key])) {
      oFrz[key] = deepFreeze(oFrz[key]);
    }
  });
  return Object.freeze(oFrz);
}

これだとオブジェクトリテラルで生成したオブジェクトに関しては子属性まで含んだfreezeができます。
コピーを生成して返すので、元のオブジェクトには影響なく、constに一発で入れることができます。

deepFreeze(カスタマイズ版)を使用
const o = {a: 1, b: {x: 2}};
const odf = deepFreeze(o);  // 一発で代入

o.b.y = 5;

console.log(o);   // { a: 1, b: { x: 2, y: 5 } }
console.log(odf)  // { a: 1, b: { x: 2 } }

odf.b.y = 5;      // TypeError: Can't add property y, object is not extensible

ちなみにこのdeepFreezeカスタマイズ版、注意点としてコンストラクタで生成するオブジェクトに関しては、prototypeのコピーができていませんのでご注意をば。__proto__を使えば一応実現できるのですが、外法扱いということで今回は却下です。。

コレクションオブジェクトのfreeze

配列

配列もfreezeすることができます。

配列をfreeze
const a = Object.freeze([1,2,3,4,5]);
a.push(6);  // TypeError: Can't add property 5, object is not extensible

オブジェクトの場合と似ていますが、子要素に配列があるとそれは凍結されません。

子要素
const a = Object.freeze([1,2,3,4,5,[6]]);
a[5].push(7);
console.log(a);  // [ 1, 2, 3, 4, 5, [ 6, 7 ] ]

上で作ったdeepFreezeカスタマイズ版をかますと凍結は成功します。

配列をdeepFreeze
const a = deepFreeze([1,2,3,4,5,[6]]);
a[5].push(7);    // TypeError: Can't add property 1, object is not extensible

Map/Set

ES6で追加されたMapSetに関してはfreezeできません
現行のChrome46.0, FF42.0, Node5.0などいずれも同様の結果となります。

Mapがfreezeできない
const m = Object.freeze(new Map([[1, 'a'], [2, 'b']]));
m.set(3, 'c');
console.log(m);  // Map { 1 => 'a', 2 => 'b', 3 => 'c' }
Setもfreezeできない
const s = Object.freeze(new Set([1, 2, 3]));
s.add(4);
console.log(s);  // Set { 1, 2, 3, 4 }

と、いった感じで今回はObject.freezeを使ってゴリッゴリの不変オブジェクトを作ってみました。
いくらかの課題はあるもののconstで作った値の子属性が不意に変更されてしまう可能性を潰す一つの手法にはなりうるんではないかと思います。
ただ、アプリケーションを実装していくうえで、子属性を追加したり、変更したりという必要性にかられるケースは多々あると思いますので次回はその辺をやっていきたいと思います。
今回は以上です。