Subscribed unsubscribe Subscribe Subscribe

枕を欹てて聴く

香炉峰の雪は簾を撥げて看る

ES5, Property Descriptor解説

ECMAScript JavaScript

JavaScript Advent Calendar 2010 6日目のid:Constellationです.

ECMAScript5になって大きく変更されたといえば, strict mode, early error, Object extras, そしてProperty Descriptorの概念です. ということで今回はES5 PropertyDescriptorについてまるっと分かってしまおうということで.

Property Descriptorとは

JavaScriptのObjectは事実上Hash Tableです. しかし,

 key -> value

という対応でHash Tableに格納されているのかといえばそうではありません. なぜなら, EcmaScriptにはgetter / setterやattributeが存在するからです.

// getter / setter
var temp = null;
var obj = {
  get test() {
    return temp;
  },
  set test(val) {
    temp = val;
  }
};
obj.test = "value";
console.assert(obj.test === 'value');

// not enumerable
var proto = Object.prototype;
console.assert(proto.hasOwnProperty('toString'));
for (var i in proto) {
  console.log(i);  // toStringはenumerateされない -> enumerableでない
}

上の例では, objのtestは単なる値ではなくgetter / setterです, また, Object.prototypeのpropertyであるtoStringはfor inでiterateしても現れません(これをenumerableでないといいます)
このようにpropertyには特殊なものがあり, そのため実際には,

 key -> Property Descriptor
        | attributes
        | value

のようにattributesとvalueをセットにしたものを紐付けます. このセットをPropertyDescriptorといい, ES3まではこれは隠蔽されていましたが, ES5からProgrammableになりました.

Property Descriptorの種類

PropertyDescriptorの種類の説明を詳しくしました
Property Descriptorには次の3種類が存在します

  1. DataDescriptor
  2. AccessorDescriptor
  3. GenericDescriptor

そして, PropertyDescriptorには

  • Enumerable
  • Configurable
  • Writable
  • Value
  • Get
  • Set

6つのfieldがあり, 「あるDescriptorが3種類の中のどのPropertyDescriptorに分類されるのか」はそれぞれ, 「どのfieldに有効な値が入っているのか」で判断されます.



まずは, Enumerable / Configurable, これらは全てのPropertyDescriptorがもつことができる値です.

Enumerable for-inでiterateしたときにkeyが現れるかどうか
Configurable PropertyDescriptorのAttributesの変更や, 削除, 付け替えが許可されるかどうか

次にWritable / Value. これらはDataDescriptorしか持つことができません. というか, 逆説的に, Writable / Valueのどちらか一方でも持っているPropertyDescriptorはDataDescriptorであると定義されています.

Writable Dataに書き込み可能かどうか
Value Dataの実際の値

次にGet / Set, これらはAccessorDescriptorしかもつことができません. これも, 逆に, そもそもGet / Setのどちらか一方でも持っているPropertyDescriptorはAccessorDescriptorであると定義されています.

Get Getterの値
Set Setterの値

そして, GenericDescriptorとはDataDescriptorとしてのfieldもAccessorDescriptorとしてのfieldも存在しないものを言います. これは何に使うのかは後述します.



纏めると, 3種類のDescriptorは,

DataDescriptor Value / Writableのどちらかの値があるPropertyDescriptor
AccessorDescriptor Get / Setのどちらかの値があるPropertyDescriptor
GenericDescriptor DataDescriptorでもAccessorDescriptorでもないPropertyDescriptor(Enumerable / Configurableだけ)

注意として, 実はWritable AttributeはDataDescriptorの特徴なので, ConfigurableやEnumerableと一緒ではなく, DataDescriptorしか持つことができません. また, 全てのDescriptorは上記3種のどれかひとつに分類されなければいけないので, WritableとGetの値を持っている(これではDataDescriptorかAccessorDescriptorか区別がつかない)といったDescriptorは作成することができません(作成時にTypeError発令)

ちょっと練習

Object.definePropertyでPropertyDescriptorをobjectで表現してPropertyを追加することができます.

var obj = {};

//   Enumerable: absent
//   Configurable: absent
//   Writable: false,
//   Value: "data"
// の DataDescriptor (Value / Writableのどちらかが存在するので)
Object.defineProperty(obj, "data", {
  writable : false,
  value : "data"
});

//   Enumerable: false
//   Configurable: false
//   Get: get,
//   Set: absent
// の AccessorDescriptor (Get / Setのどちらかが存在するので)
Object.defineProperty(obj, "getter", {
  enumerable: false,
  configurable: false,
  get: function() {
    return "OK";
  }
});

// こういうのはエラー
//   Enumerable: false
//   Configurable: false
//   Writable: false,
//   Get: get,
//   Set: absent
// WritableとGetが同時に定義されていて, DataDescriptor/AccessorDescriptor/GenericDescriptorの3種の中に分類できない.
Object.defineProperty(obj, "getter", {
  enumerable: false,
  configurable: false,
  writable: false,
  get: function() {
    return "OK";
  }
});

Attribute

それぞれのAttributeについて.

Enumerable

propertyがEnumerableであるかどうかを表すattrです.

var obj = {};
Object.defineProperty(obj, "shown", {
  enumerable: true,
  value: "OK"
});

Object.defineProperty(obj, "hidden", {
  enumerable: false,
  value: "OK"
});

// "shown"しか現れない
for (var i in obj) {
  console.log(i);
}

これを従来のES3では制御できなかったため, 例えば,

// このように新規にObjectにpropertyを追加すると,
// そのAttributeはConfigurable: true, Enumerable: true, Writable: true, Value: val
// のDataDescriptorとして追加される.
// see in PutValue section
Array.prototype.uniq = function() {
  return this.reduce(function(e, r) {
    !~e.indexOf(r) && e.push(r);
    return e;
  }, []);
}
for (var i in []) {
  console.log(i);  // ここでuniqが現れてしまう. いわゆるprototype汚染
}

という風(いわゆるPrototype汚染)になっていましたが, これを制御できるようになったためbuiltinのmethodと同じattrを付けてやれば,

Object.defineProperty(Array.prototype, 'uniq', {
  enumerable: false,
  configurable: true,
  writable: true,
  value: function() {
    return this.reduce(function(e, r) {
      !~e.indexOf(r) && e.push(r);
      return e;
    }, []);
  }
});
for (var i in []) {
  console.log(i);  // uniqがでない. prototype汚染回避
}

という風にすることができるようになりました. まああんまり行儀いいとは言えませんが...

Configurable

PropertyDescriptorのAttributeの変更権限, またPropertyDescriptor自体を付け替える権限, PropertyDescriptorの種類の変更権限です.
Writableと混同しがちなので, 注意しないといけません. ConfigurableはDataDescriptorにおけるValueの値を変更するかどうかに関わる権限ではなく, PropertyDescriptor自体を変更するかどうかに関わる権限です.

var obj = {};
Object.defineProperty(obj, "test", {
  configurable: false,
  enumerable: true,
  value: "OK",
  writable: true
});
obj.test = "NG";
console.log(obj.test);  // writable: trueなため変更可能

// configurable: falseのためPropertyDescriptorを削除できない
// 結果はfalse
// ちなみにstrict modeではこのときTypeErrorがでます.
delete obj.test; 

try {
// PropertyDescriptorのenumerable attributeを変更しようとしているが,
// configurable: falseのためできない.
// TypeErrorがraiseされる
Object.defineProperty(obj, "test", {
  enumerable: false
});
} catch (e) {
  console.log(e);
}

try {
// PropertyDescriptorの種類をAccessorDescriptorに変更しようとしているが,
// configurable: falseのためできない.
// TypeErrorがraiseされる
Object.defineProperty(obj, "test", {
  get: function() {
    return "NG";
  }
});
} catch (e) {
  console.log(e);
}

という風にPropertyDescriptor自体の状態を変更することが出来なくなるのがconfigurableです.
ただし, ほんのちょっと融通が効いて, configurable: falseのとき, writableをfalse -> trueにはできませんが, true -> falseには出来るのです. 一方enumerableの値は変更できません. writableの場合はtrueのほうが権限として「緩い」のに対して, enumerableは権限ではなく状態なので「緩い」という方向性がないという意味論的なことがあります.
面白い話として, configurableがfalseになってしまうと, configurable自体の値ももちろんAttrの一つなので変更できません. つまりその「PropertyDescriptor」は(propertyではないのに注意. DataDescriptorであればwritable: trueであればもちろんValueは変更できます)どうやっても変更できなくなるのです.
これを便利に使えるのが, Object.seal / Object.freeze / Object.preventExtensionsです.

var obj = {
  test: "TEST"
};
// これでobjに新たなpropertyを追加できなくなる(これはPropertyDescriptorの話ではない. Objectのextensibleの値の変更)
Object.preventExtensions(obj);

// Object.sealはpreventExtensionsの機能も内包した上で, 既存のPropertyDescriptorのconfigurableをfalseにする.
// この時点で, objのPropertyDescriptorは変更できなくなった.
Object.seal(obj);

// Object.freezeはsealの機能も内包した上で, DataDescriptorに対してwritableをfalseにする
// この時点でもうDataPropertyのValueですら変更不可能が保証される. 完全にfreezeされた.
Object.freeze(obj);

休憩 余談 (GenericDescriptorとabsent値)

configurableなどの値にはtrue / false / absentの3つがあります.

var obj = {};
Object.defineProperty(obj, "test", {
  enumerable: false,
  configurable: true,
  writable: false,
  value: "OK"
});

このとき, enumerableの値だけ変更しようとしたらどうすればいいのでしょうか? 答えは簡単,

Object.defineProperty(obj, "test", {
  enumerable: true
});

これで大丈夫です. この時指定したdescriptorはDataでもAccessorでもありません. これがGenericDescriptorであり, GenericDescriptorはこのように既存のPropertyDescriptorの値を変更するときに使われます.
ちなみに, まだ未定義の物にGenericDescriptorを指定するとどうなるのか.

Object.defineProperty(obj, "undef", {
  enumerable: true
});

このときObject.definePropertyがundefはまだ未定義のpropertyであるとわかると, enumerable: trueであり, 残りのabsentになっている物はfalseに埋められて, かつ, writable: false, value: undefinedのDataDescriptorに変換されて登録されます. よって, 登録済みのPropertyDescriptorにはDataDescriptorかAccessorDescriptorしか存在しません. GenericDescriptorはこのように値変更のための指示を出すためだけのものです.


また, 先程からabsentという表現を使っています. これは何かというと,

var obj = {};
Object.defineProperty(obj, "test", {
  enumerable: true,
  writable: true,
  configurable: false
});

// このとき writableの値を変更しようとする
Object.defineProperty(obj, "test", {
  writable: false
});

これ, もしも2番目のもののenumerableが自動でfalseになんかなってしまうと, configurableに引っかかってTypeError発令のお知らせになってしまいます. このとき実際にはどのようなPropertyDescriptorになっているかというと,

writable: false,
enumeable: absent,
configurable: absent,

のDataDescriptorです(writableがあるので). absentにご注目ください. この値は非常に便利で, 既存のpropertyがある場合は現在設定済みの値に化け, ないときは自動でfalseに変更されるという特性があります. よってTypeErrorが起こらずにwritableの値だけ変更できるというわけです.

Writable / Value

writable, valueの説明です.

var obj = {};
Object.defineProperty(obj, "test", {
  writable: true,
  value: "OK"
});
console.assert(obj.test === "OK");  // obj.testに"OK"が入っている
obj.test = "NG";  // writable : trueのため書き込み可能
console.assert(obj.test === "NG");

// writableをfalseに変更
Object.defineProperty(obj, "test", {
  writable: false
});
// writable : falseのため書き込み不可能
// このとき, strict modeならTypeError
obj.test = "OK";
console.assert(obj.test === "NG");

writableは書き込み可能かどうかを管理するもので, valueは実際の値です. これらはDataDescriptorにしか存在しません.
writable値falseのときの書き込みは無視されますが, strict mode時にはTypeErrorがraiseされます.

Get / Set

get / setの説明です.

var obj = {};
var temp = null;
// getter / setterを定義
Object.defineProperty(obj, "test", {
  get: function() {
    return temp;
  },
  set: function(val) {
    temp = val;
  }
});

console.assert(obj.test === null);  // obj.testでgetが呼ばれ, tempの値が返る

obj.test = "OK";  // setが呼ばれ, temp = "OK"される.

console.assert(obj.test === "OK");  // tempは変更されたので

このようにESにgetter / setterを後からでも定義できます. 皆様に至りましてはこれまでは__defineGetter__とかを楽しく活用していらっしゃったと思いますが, Object.definePropertyとAccessorDescriptorを使えばES5標準でこのようなことが, しかもenumerable / configurableなどを制御しながら行うことができます.

例えば, こんな適当な例も,

Object.defineProperty(document, "head", {
  enumerable: true,
  get: function() {
    // thisはdocument
    return this.getElementsByTagName('head')[0];
  }
});
console.log(document.head);

まあ, これはdocument書き換えでちょっと危ないのと, WebKitならもうすでにあるのであまり不必要ではあります. ちなみにこのように片方だけ定義することも可能です. また, getter / setterはthisにそのpropertyをもつobjectが渡されてinvokeされるので, thisを使って操作もできます.

var obj = {};
Object.defineProperty(obj, "test", {
  get: function() {
    return "OK";
  }
});
console.log(obj.test === "OK");

// このときsetがないのでスルー. strict codeならTypeError
obj.test = "NG";

Descriptor周りのFunction

ES5にはDescriptor周りのfunctionがいくつか入りました.
Object.seal / Object.freeze / Object.preventExtensionsは説明したので, その他でも.

Object.defineProperty

さんざん使ってきてなんですが, DescriptorをつかってPropertyを定義できるdefinePropertyです. 使用例

var obj = {};
Object.defineProperty(obj, "test", {
  writable: false,
  value: "OK"
});

Object.defineProperties

propertyを一斉に定義したいことはよくあります. そのためのdefineProperties

var obj = {};
Object.defineProperties(obj, {
  "test": {
    writable: false,
    value: "OK"
  },
  "test2": {
    enumerable: false,
    get: function() { return "OK" }
  },
  "test3": {
    enumerable: false,
    set: function(val) { this.hidden = val; }
  }
});

このように一斉に定義できます. 見た目としても直感的.

Object.getOwnPropertyDescriptor

定義したPropertyDescriptorが知りたくなることがありますね! Object.getOwnPropertyDescriptorはその要望に応えてくれます.

var obj = {};
Object.defineProperty(obj, "test", {
  writable: false,
  value: "OK"
});

// ここから
console.log(Object.getOwnPropertyDescriptor(obj, "test"));
// {
//   configurable: false,
//   enumerable: false,
//   writable: false,
//   value: "OK"
// }

というふうに特定のkeyにひもづいているPropertyDescriptorを引っ張ってくることができます.

Object.create

これはちょっと離れますが, Object.createを使えば, 作成時にdefinePropertiesと同じことができます.
今回はPropertyDescriptorの説明が本題なのでvar obj = {}してからいじりましたが, 実際にはそんなのやめて, Object.createで作ってしまうのも素晴らしいことです.

// これでObject.prototypeをprototype chainの親に持つ, 第二引数がdefineProperitesに適用されたobjectが返る.
var obj = Object.create(Object.prototype, {
  "test": {
    writable: false,
    value: "OK"
  },
  "test2": {
    enumerable: false,
    get: function() { return "OK" }
  },
  "test3": {
    enumerable: false,
    set: function(val) { this.hidden = val; }
  }
});

結論

PropertyDescriptorはこれまでJSer側に公開されてこなかったconfigurable / enumerableなどの値を操作し, 好きなようにObjectのlayoutを構築できる, しかも「標準で定義されている」素晴らしい概念です.
さああなたもさっそくES5の世界へ.