プロトタイプ=見本となるオブジェクト

JavaScriptを始めたころはprototypeオブジェクトが何なのかよく分からなかったけど、その名の通り「見本」あるいは「原型」なんだと分かれば、コンストラクタ関数とprototypeオブジェクトの関係は単純だと思える。


例として、人を表すオブジェクトを考えてみる。姓を表すlastNameプロパティと、名を表すfirstNameプロパティ、そしてそれらをつなげた文字列を返すgetFullNameメソッドを定義する。

var person = {
    firstName: '右京',
    lastName: '杉下',
    getFullName: function () {
        return this.lastName + ' ' + this.firstName;
    }
};

さらに違う人物を表すオブジェクトを作ることになったとする。

var another = {
    firstName: '享',
    lastName: '甲斐',
    getFullName: function () {
        return this.lastName + ' ' + this.firstName;
    }
};

さらに違う人物を……、いや、これだと何度も同じこと(getFullNameの関数)を書かなくてはいけない。こういうときは、適当な関数を用意し、そのprototypeプロパティに見本となるオブジェクトを代入するとよい。new演算子を使ってその関数を呼び出すことで、そのprototypeオブジェクトの複製とでもいうべきオブジェクトが作られる。

var person = {
    firstName: '右京',
    lastName: '杉下',
    getFullName: function () {
        return this.lastName + ' ' + this.firstName;
    }
};

// 適当な関数を用意して(このような関数は一般に大文字で始まる名前が付けられる)
function Person() {}
// 見本(prototype)となるオブジェクトを設定する
Person.prototype = person;

// personの複製のようなオブジェクトを作る
var another = new Person();
// 姓・名は上書きする
another.firstName = '享';
another.lastName = '甲斐';
// getFullName関数はそのままpersonと同じのを使えばよいので上書きしない
// 実行してみる
alert(another.getFullName()); // "甲斐 享"

// さらにもう一人
var another1 = new Person();
another1.firstName = '尊';
another1.lastName = '神戸';
alert(another1.getFullName()); // "神戸 尊"

getFullNameの関数を書くのは1回きりで済むようになった。でも、これだと少し困ることがある。それはnew Person()で作ったオブジェクトのプロパティを上書きするのを忘れたときに起こる。

var another2 = new Person();
another2.firstName = '薫';
// lastNameを上書きするのを忘れた!
alert(another2.getFullName()); // "杉下 薫" (ノ∀`)

lastNameプロパティを上書きするのを忘れた結果、prototypeオブジェクト(=== personオブジェクト)のlastNameプロパティがそのまま使われてしまった。

問題は2つある。

  1. 見本となるオブジェクトが、見本として相応しくない具体的な値(firstName = "右京", lastName = "杉下")を持っている
  2. 新しいオブジェクトに対してプロパティを設定する処理を抽象化できていない

まずは前者に対応する。見本として相応しい値は状況によって異なる(例えばPersonがチャットの参加者を表すのであれば「名無し」が良いかもしれない)けど、ここでは空文字列('')にしておく。これで少なくともちぐはぐな名前になってしまうことは防げる。

var person = {
    firstName: '',
    lastName: '',
    getFullName: function () {
        return this.lastName + ' ' + this.firstName;
    }
};

function Person() {}
Person.prototype = person;

var another2 = new Person();
another2.firstName = '薫';
// lastNameを上書きするのを忘れた!
alert(another2.getFullName()); // " 薫" ( ̄- ̄ )

次は後者だが、これはPerson関数内で行うようにすればよい。関数がnew演算子によって実行される場合、thisにその関数によって生成される新しいオブジェクト(prototypeオブジェクトの複製とでもいうべきオブジェクト)がセットされる。

var person = {
    firstName: '',
    lastName: '',
    getFullName: function () {
        return this.lastName + ' ' + this.firstName;
    }
};

function Person(firstName, lastName) {
    if (firstName != null) {
        this.firstName = firstName;
    }
    if (lastName != null) {
        this.lastName = lastName;
    }
}
Person.prototype = person;

var ukyo = new Person('右京', '杉下');
var toru = new Person('亨', '甲斐');
alert(toru.getFullName()); // "甲斐 亨"

これでオブジェクトを作るたびにプロパティを上書きする処理を書かなくて済むようになった。このように、新しいオブジェクトの初期化処理を行う関数はコンストラクタ関数と呼ばれる。

あとはpersonオブジェクトだ。personオブジェクトを直接触る機会はなさそうなので、Person.prototypeに直接代入する形に書き直してしまう。

function Person(firstName, lastName) {
    if (firstName != null) {
        this.firstName = firstName;
    }
    if (lastName != null) {
        this.lastName = lastName;
    }
}
Person.prototype = {
    firstName: '',
    lastName: '',
    getFullName: function () {
        return this.lastName + ' ' + this.firstName;
    }
};

これでよし。


巷のJavaScriptの解説では、コンストラクタとprototypeオブジェクトに関して次のような例を示す。

function Person(firstName, lastName) {
    if (firstName != null) {
        this.firstName = firstName;
    }
    if (lastName != null) {
        this.lastName = lastName;
    }
}

Person.prototype.getFullName = function () {
    return this.lastName + ' ' + this.firstName;
};

いきなりこのコードを見せられると、コンストラクタ関数がメインで、prototypeオブジェクトがそのおまけのように見えると思う。見本となるオブジェクト(prototypeオブジェクト)が先立つと考えた方が理解しやすいと思う。

また、prototypeは構文の何かに見えるかもしれない。実際は単なるオブジェクトで、そのプロパティへの代入でしかない。すべての関数には初めからprototypeオブジェクトが付いているので、そのオブジェクトを見本となるオブジェクトに仕立て上げてもよい。


prototypeオブジェクトのconstructorプロパティは、便宜的なものでしかない。constructorプロパティを使うような処理を書いたり、そのような処理が含まれたスクリプトを読み込む場合はともかく、言語仕様としてprototypeオブジェクトにconstructorプロパティを定義していないからと言って何か問題が起きるわけではない。

// ... 省略
Person.prototype = {
    constructor: Person, // new Person().constructor === Person となるようにする
// ... 省略

でもまあ、付けておくといいと思う。


prototypeオブジェクトの複製とでもいうべきオブジェクトは、実際には複製ではない。「プロトタイプチェーン」をキーワードに調べるとよい。

(ひどい)

IEで関数名を取得する

IE以外のブラウザであれば、関数.nameで関数名を取得することができる。

var func = function hoge() { }
func.name; // => "hoge"

事実上標準でES6にも取り入れられそう

しかしIEではバージョン11においても関数.nameは定義されていない。そこで、Function.prototype.toStringの結果を使って関数名を取得するコードを書くことにした。

今回は対象がIEだけだけど、複数のブラウザで確かめてみるとFunction.prototype.toStringの実装はまちまちだった(ES5仕様書にも実装依存と書いてある)。後述のテスト用ページを複数のブラウザで見比べると分かりやすい。

  • Firefoxでは、仮引数リストの前の「(」(開き括弧)より前の部分は正規化される(コメントは削除され、エスケープされたキーワードと識別子は変換される)。
  • Chromeでは、functionキーワードをエスケープするとエラーになる。その他はFirefoxと同じ。
  • IEでは、最初のfunctionキーワードから最後の「}」(閉じ波括弧)まで完全にソースコードの通りの文字列を得られる。

今回の目的において、コメントやエスケープを考慮しなければならないIEの実装が一番厄介だった。

考慮漏れがありそうけど、今回はこれで切り上げることにした。

文字列SetとしてのDOMTokenList

DOMTokenListを文字列専用のSetとして使えそうと思ったけど、空白を含む文字列を追加できないので使いづらかった(DOMTokenListを得られるのはclassListかrelListで、どちらもセパレータが空白)。

var set = document.createElement('div').classList;

set.add('foo');
set.add('bar');
set.add('bar'); // 同じトークンは追加されない
console.log(set.toString()); // "foo bar"

try {
    // 空白を含む文字列を追加できない
    set.add('hoge fuga');
} catch (e) {
    console.error(e); // InvalidCharacterError
}

set.remove('bar');
set.toggle('baz');

console.log(Array.prototype.map.call(set, function (token) {
    return token.toUpperCase();
}).join('\n'));
/*
FOO
BAZ
*/
// DOMTokenListを使ってuniqのようなこと

var strList = 'puyo piyo hoge fuga puyo fuga hoge piyo fuga'.split(' ');
var set = document.createElement('div').classList;

strList.forEach(function (str) {
    set.add(str);
});

// DOMTokenList→Array
var uniqStrList = Array.apply(null, set);

console.log(uniqStrList.join(', ')); // "puyo, piyo, hoge, fuga"

Setとは違い、Array.prototypeのメソッドを転用できる。


というか、文字列だけならObjectを使えばいいじゃん!

var strList = 'puyo piyo hoge fuga puyo fuga hoge piyo fuga'.split(' ');
var uniqStrList = Object.keys(strList.reduce(function (table, str) {
    table[str] = 1;
    return table;
}, {}));
console.log(uniqStrList.join(', ')); // 順序は実装次第

似非Set

Setを似非実装してみた。sizeではなくlengthならArray.prototypeのメソッドを転用できるのに。

delete予約語なので、関数名に同名を付けられない。Setを既に実装しているFirefoxでは、Set.prototype.delete.name === 'delete'となる。Function#namewritableにならない限り、真似できない。

同値性を判断する関数を渡せるようにしてもいいかもしれない。

JavaScriptの ++i は i += 1 ではない

JSLintでは ++ を使うとエラーになる。++i の代わりに i += 1 と書くようにということのようだ。でも、+= 1++ の正確な代替ではなかった。

加法演算子は被演算子の少なくとも一方が文字列なら、文字列連結処理になる。一方、前置増分演算子 (++) は初めに被演算子ToNumber 内部関数を適用してから 被演算子 = 被演算子 + 1 を行う。つまり、結果は必ず数値(NaNを含む)となる。

ということで、++i の代わりになるのは、正確には i = +i + 1 だった(単項 + 演算子は ToNumber 内部関数を適用する)。

FirefoxにおけるArray#concatは strict mode function 相当

Array.prototypeの大半のメソッドは、配列以外のオブジェクト(NodeListなどの擬似配列など)にも転用できるように汎用的に定義されている。Array.prototype.concatもその一つだけど、他のメソッドとは異なりthis値に配列っぽさは求められておらず、どんなオブジェクトでもよい。this値は引数と同じように処理される。

Array.prototype.concat.call(1, 2, 3, 4, 5); //=> [1, 2, 3, 4, 5] // ?
Array.prototype.concat.call("A", "B", "C"); //=> ["A", "B", "C"] // ?

ただし、通常の関数 (non-strict mode function) ではthis値は必ずオブジェクトになる。数値はNumberオブジェクトに、文字列はStringオブジェクトにラップされてしまう。? と書いたのはそのこと。Firefox以外のブラウザでは次のような結果になる。

var array = Array.prototype.concat.call(1, 2, 3, 4, 5);
typeof array[0]; //=> "object"
typeof array[1]; //=> "number"

Firefoxではtypeof array[0];の戻り値は"number"になる。this値はラップされない。strict mode function として定義されているのだろうか。

If this is evaluated within strict mode code, then the this value is not coerced to an object.

Annotated ES5
function nonStrictFn() {
    return this;
}
function strictFn() {
    "use strict";
    return this;
}
typeof nonStrictFn.call(9); //=> "object"
typeof strictFn.call(9);    //=> "number"

Generic method

FirefoxではString.prototypeArray.prototypeのメソッドを転用しやすいように Generic method が用意されている。例えば、Array.prototype.concat.callの代わりにArray.concatと書ける。

Array.concat(1, 2, 3, 4, 5); //=> [1, 2, 3, 4, 5]

このとき、戻り値の一つ目の要素がオブジェクトにラップされていたら混乱を招くと思う。これがArray.prototype.concatの実装に影響を与えているんだと思う。

以前書いたFirefox以外の実装にも Generic method を定義するためのスクリプトFirefoxの実装に合わせることにした。もちろん、Array.prototype.concatの方は弄らない。

転用の必要性

そもそも、Array.prototype.concatは転用する必要がないように思える。[].concat(item1, item2, ...)で事足りる。

var array;
array = [].concat(1, 2, 3, 4, 5); //=> [1, 2, 3, 4, 5]
typeof array[0]; //=> "number"

Array.prototype自体も配列 (Array.isArray(Array.prototype) === true) なので、Array.prototype.lengthが弄られていないという前提の下、次のようにも書ける。

// Function#callを使わない
Array.prototype.concat(1, 2, 3, 4, 5); //=> [1, 2, 3, 4, 5]

CSSセレクタ4の字句解析器を書いた

CSS4 セレクタエンジンの事始めとして。字句解析の範囲では、CSS3 とそれほど変わらない。違いは Reference combinator で使う「/」と、セレクタの subject を指定する「!」くらい。

ユニットテストは後で書きます……。

以下はメモ。

トークン種別は少なめに

  • Identifier
  • String
  • Invalid
  • Number
  • WhiteSpace
  • Punctuator

の6種類。CSS セレクタには Descendant combinator があるので Whitespace を捨てられない。

charCodeAt オンリー

セレクタはたいてい短いので正規表現を使ってもコストは気にならないだろうけど、いずれは CSS パーサを作るつもりなので、正規表現を使わずに書くことにした。

1文字1文字を比較していくわけだけど、その1文字を文字列として扱ったり数値として扱ったりするのは煩わしいので、全て数値として扱うことにした。つまり、charCodeAt のみを使い、charAt は使わない。文字コードは配列に格納していき、最後に String.fromCharCode(null, 文字コード配列) で文字列に戻す。パフォーマンスも問題なさそう

間接的な戻り値

文字列を読み進める関数は、次のように成功か失敗かを表す bool 値を戻り値で返し、実際に読み込んだ文字コードは第一引数の配列に格納するようにした。

function readNonascii(codes) {
    var code = text.charCodeAt(offset);
    if (code > 127) {
        codes.push(code);
        offset++;
        return true;
    }
    return false;
}

JavaC# ではこの手のメソッドがよくある気がする。一見ダサい。普通は次のように、単に読み込んだ文字コードを返すだけだと思う。

// 他の関数で複数の文字コードを読む場合もあるので、戻り値を配列 or null で統一
function readNonascii_another() {
    var code = text.charCodeAt(offset);
    if (code > 127) {
        offset++;
        return [code];
    }
    return null;
}

これなら読み取りに成功したかどうかは戻り値が truthy であることを確認すれば分かる。でも、使うときは前者の関数の方がコードがすっきりする。

var codes = [];

// 前者の場合
if (readNonascii(codes)) {
    // ...
}

// 後者の場合
var result; // 前者とは違い変数が必要
if ((result = readNonascii_another())) {
    codes = codes.concat(result);  // 前者と比べて1行増える
    // ...
}

ということで、前者のタイプの関数も場合によっては悪くないと思う。

NaN を扱う

Java では charAt(index) の index が範囲外だと例外が投げられる(CharSequence (Java Platform SE 7 ))けど、JavaScript では charAt なら空文字列、charCodeAt なら NaN が返されるだけ。

なので、現在のインデックス値が文字列の長さ未満かどうかの確認(if (offset < limit))を少し削れる。例えば次のように。

// before
if (offset < limit) {
    code = text.charCodeAt(offset);
    if (code === 10/*\n*/) {
        offset++;
        // ...
            codes.push(code);
            return true;
        // ...
        offset--;
    }
}

// after
code = text.charCodeAt(offset); // offset < limit で、戻り値が NaN かもしれない
if (code === 10/*\n*/) {        // NaN なら false になるだけなので問題ない
    offset++;
    // ...
        codes.push(code);
        return true;
    // ...
    offset--;
}

ただし、!== を使う場合や、ある数値間の範囲であることを確かめる場合はうまくいかない。初めに code && を付けなくてはいけない(NaN は falsy)。

うーん、やっぱりこれはよくない感じがする。