読者です 読者をやめる 読者になる 読者になる

『Effective JavaScript』を読んで(項目43~52)

久しぶりの更新。一気に第5章全部まとめた。

第5章は「配列とディクショナリ」。知らないこと多かったけど、正直そこまでおもしろいネタはなかった気がする。

項目43:軽量ディクショナリは Object の直接インスタンスから構築しよう

Object は文字列であるプロパティを各値にマップする機能を持っている。そのため Object を利用するとディクショナリを簡単に実装できる。

しかし、Object を継承してディクショナリ(下記例では NaiveDict)を実装した場合、for in ループではただしく要素を列挙できないので注意しよう。for in ループでは Object インスタンスのプロパティだけでなく、継承されたプロパティすべてを列挙するためである。

function NaiveDict() {}

NaiveDict.prototype.count = function() {
    var i = 0;
    for (var name in this) {
        i++;
    }
    return i;
}

NaiveDict.prototype.toString = function() {
    return "[object Naive Dict]";
}

var dict = new NaiveDict();

dict.alice = 34;
dict.bob = 24;
dict.chris = 62;

dict.count(); // => 5, count と toString プロパティをカウントしてしまう。

プロパティ汚染の可能性を考慮して、Object を継承するのではなく単に Object インスタンスを利用しよう。そのためには各種リテラルのみを利用するのが最適である。

var dict = {}
dict.alice = 34;
dict.bob = 24;
dict.chris = 62;

var names = [];
for (var name in dict) { 
    names.push(name);
}
names; // => ["alice", "bob", "chris"];

項目44:プロトタイプ汚染を予防するために、null プロトタイプを使う

プロトタイプ汚染を予防するためには空のプロトタイプを持つオブジェクトを生成すればよい。しかし、ES5 になるまで、標準で空のプロトタイプでオブジェクトを生成する方法がなかった。

コンストラクタの prototype プロパティを null か undefined に設定する方法では空のプロトタイプを持つオブジェクトは生成できない。そこで、ES5 では空のプロトタイプを持つオブジェクトを生成する方法が導入された。Object.create() を用いる方法である。

function PrototypeNotNull() { }
PrototypeNotNull.prototype = null;

var prototypeNotNull = new PrototypeNotNull();
Object.getPrototypeOf(prototypeNotNull) === null; // => false
Object.getPrototypeOf(prototypeNotNull) === Object.prototype; // => true

var prototypeNull = Object.create(null);
Object.getPrototypeOf(prototypeNull) === null; // => true

この他に、非標準のプロパティの __proto__ 経由で空のプロトタイプを持つオブジェクトを生成することができる。ただし、一部の環境ではプロパティキーの __proto__ そのものがオブジェクトを汚染することがある。

var x = { __proto__: null };
x instanceOf Object; // => false

項目45:プロトタイプ汚染を防御するために hasOwnProperty を使う

軽量ディクショナリを利用する時、JavaScript が提供するネイティブな構文を利用したい場合がある。 例えば、あるキーがディクショナリに含まれるかどうかを調べるために in キーワードを利用したくなるかもしれない。 しかし、in キーワードによるテストでは Object.prototype から継承したプロパティを拾ってしまうのでうまくいかない。

// in を用いたキーの存在テスト
var dict = {};
"alice" in dict; // => false
"bob" in dict; // => false
"chris" in dict; // => false
"toString" in dict; // => true
"valueOf" in dict; // => true

in キーワードではなく、hasOwnProperty を用いるとキーのみを正しく取得することが可能である。ただし、"hasOwnProperty" というキーを利用すると hasOwnProperty メソッドを利用できない。 そのため、hasOwnProperty メソッドを取り出しておいて、項目20で述べた call メソッドと合わせて利用する。

var dict = {}
dict.hasOwnProperty = 10; // 上書き
dict.hasOwnProperty("alice"); // => undefined error

var hasOwnProp = Object.prototype.hasOwnProperty; // hasOwnProperty を取り出す
hawOwnProp.call(dict, "alice"); // または {}.hasOwnProperty.call(...); でも OK

このアプローチを用いると、hasOwnProperty が上書きされていることを心配する必要がない。

なお、毎回このアプローチを用いるのはめんどうなので、この動作を行うラッパークラスを用意するとよい。

function Dict(elements) {
    this.elements = elements || {}; 
}

Dict.prototype.has = function(key) {
    return {}.hasOwnProperty.call(this.elements, key);
};

Dict.prototype.get = function(key) {
    return this.has(key) ? this.elements[key] : undefined;
};

Dict.prototype.set = function(key, val) { 
    this.elements[key] = val;
};

Dict.prototype.remove = function(key) {
    delete this.elements[key];
}

項目46:順序を持つコレクションには、ディクショナリではなく配列を使おう

JavaScript の Object は順序を持たないプロパティのコレクションである。 ECMAScript の標準規格ではプロパティの格納順序についての規定はない。 しかし、for in ループを用いるため、実装上は何らかの順序を規定しなければならない。 実装上の都合で規定された順序に依存した処理にならないように気をつけよう。

例えば Number 型は不動小数点数なので、順序に依存して丸め処理が発生したり、しなかったりする。 これを防ぐため、明示的に配列にいれるなどして順序が必ず一定になるようにするべきである。

項目47:Object.prototype には列挙されるプロパティを決して追加しない

Object.prototype にプロパティを追加するとプログラム中の全ての for in ループに影響を与えるので決して追加してはいけない。

Object.prototype.allKeys = function() {
    var result = [];
    for (var key in this) {
        result.push(key);
    }
    return result;
}

({a:1, b:2, c:3}).allKeys(); // => ["allKeys", "a", "b", "c"]

ES5 で提供されている Object.defineProperty を利用すれば、for in ループを汚染することなくプロパティを定義できる。列挙属性を示す enumerable を false に設定して、allKeys を定義する。

Object.defineProperty(Object.prototype, "allKeys", {
    value: function() {
        var result = [];
        for (var key in this) {
            result.push(key);
        }
        return result;
    },
    writable: true,
    enumerable: false, // 列挙属性を false にする
    configurable: true
});

項目48:列挙の実行中にオブジェクトを変更しない

for in ループ内で列挙中のオブジェクトはループ内では変更しないほうがよい。ECMAScript の仕様上はこの点に関して明記しておらず、「ループ内でその変更が適用されるかもしれないし、その変更が適用されないかもしれない」という曖昧性を残している。

内容がループの間にオブジェクトが変化するような場合は while ループか for ループを使うべきである。

項目49:配列の反復処理には、for in ループではなく、for ループを使おう

配列の反復処理に for in ループを用いると、キーがループされるので意図しない結果になる。

var scores = [100, 101, 102, 103, 104];
var total = 0;
for (var score in scores) {
    total += score;
}
var mean = total/score.length;
mean; // => ?

上記の例では、scores 要素をループして平均値を出すように見えるが、実際にはキーを取り出してループしているので意図した結果と異なることになる。 このような場合は、以下のような古典的な for ループを利用しよう。

for (var i = 0, n = scores.length; i < n; i++) {
    total += scores[i];
}

なお、古典的な最適化だが(もしかしたら実行時に最適化されているかもしれないが)、上記のように n は一度ループの外で計算するようにしておこう。

項目50:複数のループより反復メソッドが好ましい

for 文を使うとよく終了条件を間違える。

for (var i = 0; i <= n; i++) { ... } // 一回余計に繰り返している
for (var i = 1; i < n; i++) { ... } // 最初の回が抜けている
for (var i = n; i >= 0; i--) { ... } // 一回余計に繰り返している
for (var i = n - 1; i > 0; i--) { ... } // 最後の回が抜けている

ES5 ではこのような間違いを防ぐために Array.prototype.forEach を提供している。

players = [1, 2, 3, 4, 5];
players.forEach(function(elem) {
    elem++;
});

このほかにもリスト操作のできるプログラミング言語でありがちなメソッドが提供されているのでかしこく利用しよう。

  • map 関数
  • filter 関数
  • some 関数
  • every 関数

some 関数と every 関数はそれぞれ「渡された述語が真となる要素が一つでもあるかどうか」、「渡された述語がすべての要素で真となるかどうか」を調べるメソッドである。 この性質を利用して「ある条件を満たすまで配列に特定の操作を行う」という関数を定義できる。たとえば「要素に対する述語が偽になるまで先頭から切り取る」という関数は以下のように実装できる

// every による takeWhile 実装
function takeWhile(a, pred) {
    var result = [];
    a.every(function(x, i) {
        if (!pred(x)) {
            return false; // break する
        }       
        result[i] = x;
        return true;
    });
    return result;
}

// some による takeWhile 実装
function takeWhile(a, pred) {
    var result = [];
    a.some(function(x, i) {
        if (!pred(x)) {
            return true; // break する
        }
        result[i] = x;
        return false;
    });
    return result;
}

こういう実装は、&& や || を用いて処理をチェーンするのと同じ感覚。

項目51:「配列のようなオブジェクト」にも、総称的な配列メソッドを再利用できる

配列ではない(Array クラスではない)オブジェクトを、配列的に扱うことができる。 たとえば、配列ではない関数の arguments に対して Array.prototype.forEach を使いたい場合、には [] から forEach メソッドを取り出して call を利用する。

function highlight() {
    [].forEach.call(arguments, function(widgest) {
        widget.setBackground("yellow");
    });
}

配列ではないオブジェクトに対して配列のメソッドを利用するのではなく、実際に配列的なオブジェクトとして扱いたい場合には、以下二つの Array クラスの性質を守ればよい。

  • 整数 length プロパティを保持し、その値が 0 ~ 2^(32-1) であること
  • length プロパティがそのオブジェクトの最大インデックスよりも大きいこと。インデックスは 0 ~ 2^(32 - 2) の範囲まで整数で、それを文字列で表現したものがキーであること

以上の性質を満たせば、普通のオブジェクトも配列のようなオブジェクトとして利用できる。 また、文字列は上記の条件を満たすため、配列のようなオブジェクトとして利用することができる。

var arrayLike = { 0: "a", 1: "b", 2: "c", lenght: 3}
var result = Array.prototype.map.call(arrayLike, function(s) {
   return s.toUpperCase(); 
}); // => ["A", "B", C"]

var string = "abc"
var result = Array.prototype.map.call(string, function(s) {
   return s.toUpperCase(); 
}); // => ["A", "B", C"]

しかし、本当に Array クラスのすべての振る舞いをシミュレートするのは至難の技である。それは JavaScript の Array には以下の特殊性があるためである。

  • length プロパティに現在の値よりも小さい値を設定すると、その値以上のインデックスのプロパティは自動で削除される。
  • length プロパティの現在の値よりも大きな値をもつインデックスにプロパティを追加すると length プロパティは自動的にその値+1の値になる。

また、concat メソッドClass タグをチェックするので Array クラスのすべての特長を持っていても、Array クラスと同様の動作をすることがないので注意する。

項目52:配列コンストラクタよりも配列リテラルのほうが好ましい

配列を生成するため JavaScript には二つの方法が用意されている。配列コンストラクタを利用する方法と配列リテラルを利用する方法である。

var a = [1, 2, 3, 4, 5];
var b = new Array(1, 2, 3, 4, 5);

美的な問題は抜きにしても、以下の理由から配列リテラルを用いる方が望ましい。

  • Array コンストラクタを一つの引数で呼び出すと、要素ではなく length プロパティを初期化することになる。
  • Array が本来の配列コンストラクタ以外に結合されていないかに注意しなければならない。
// Array コンストラクタが一つの引数で呼び出された場合
new Array(3); // => 要素が 3 個の配列になる。[3] ではない。

// Array 変数に String コンストラクタが代入されている場合
Array = String
new Array(1, 2, 3, 4, 5) // => new String(1);