あどけない話

インターネットに関する技術的な話など

prototype.js 1.6 のクラス定義

prototype.js は 1.6 からサブクラスの定義が簡単になりました。使い方はチュートリアルを読んで下さい。ここでは、ソースを読んでみることにします。

Classオブジェクト

Classというオブジェクトの定義は、以下のようになっています。

var Class = {
  create: function() {
    var parent = null, properties = $A(arguments);
    if (Object.isFunction(properties[0]))
      parent = properties.shift();

    function klass() {
      this.initialize.apply(this, arguments);
    }

    Object.extend(klass, Class.Methods);
    klass.superclass = parent;
    klass.subclasses = [];

    if (parent) {
      var subclass = function() { };
      subclass.prototype = parent.prototype;
      klass.prototype = new subclass;
      parent.subclasses.push(klass);
    }

    for (var i = 0; i < properties.length; i++)
      klass.addMethods(properties[i]);

    if (!klass.prototype.initialize)
      klass.prototype.initialize = Prototype.emptyFunction;

    klass.prototype.constructor = klass;

    return klass;
  }
};

単に create という関数を持ったオブジェクトです。Class.create の引数には任意の数のオブジェクトを渡します。

サブクラスの定義かを判断

    var parent = null, properties = $A(arguments);
    if (Object.isFunction(properties[0]))
      parent = properties.shift();

まず、第一引数のオブジェクトが関数かを判断しています。関数であれば、それは親クラスのコンストラクタだと思い、parent に入れます。そして、引数を shift して第二引数以降を properties に入れます。そうでなければ、単なるクラスの定義であり、parent は null で、元々の引数全体が properties に入ります。

コンストラクタの作成

    function klass() {
      this.initialize.apply(this, arguments);
    }

klass という名前でコンストラクタを定義しています。prototype に定義してある initialize というインスタンス関数を呼び出すことが分かります。

クラスメソッドの追加

    Object.extend(klass, Class.Methods);

Object.extend の定義は、以下の通りです。

Object.extend = function(destination, source) {
  for (var property in source)
    destination[property] = source[property];
  return destination;
};

つまり、プロパティ(変数や関数)をコピーするだけです。

コピー元となる Class.Methods の定義は、以下の通りです。

Class.Methods = {
  addMethods: function(source) {
    ...
  }
};

詳細は後で述べますが、addMethods はインスタンス関数をコピーする関数です。

つまりここでは、klass の「クラス関数」として「インスタンス関数をコピーする関数」Methods を定義していることになります。

親子関係

    klass.superclass = parent;
    klass.subclasses = [];

ここでは、親子情報を設定しています。

継承

    if (parent) {
      var subclass = function() { };
      subclass.prototype = parent.prototype;
      klass.prototype = new subclass;
      parent.subclasses.push(klass);
    }

親がいる場合、親の prototype を継承します。subclass というのは、親から見てのサブクラス、今定義しているクラスのことです。(名前が紛らわしいですね。)

自分である klass の prototype に、親の prototype を引き継いだオブジェクトを生成し代入しています。代入していますので、klass.prototype.constructor も上書きされていることに注意しましょう。

インスタンス関数の設定

    for (var i = 0; i < properties.length; i++)
      klass.addMethods(properties[i]);

properties に入っているオブジェクト群から、先ほど定義した addMethod を使い、インスタンス関数をコピーしています。

initializeの保証

    if (!klass.prototype.initialize)
      klass.prototype.initialize = Prototype.emptyFunction;

コンストラクタは必ずインスタンス関数 initialize を呼びます。initialize という関数が指定されてない場合、存在を保証するために、何もしない関数を代入します。

constructor の保証

    klass.prototype.constructor = klass;

klass.prototype.constructor は、親クラスがある場合は上書きされているので、自分自身を代入して constructor を保証します。

これまでの内容はあんまり難しくないでしょう。でも、さらに一歩踏み出すと、とんでもなく難しいコードが待っています。

$super

子のクラスのインスタンス関数から、親のクラスのインスタンス関数を呼ぶには、魔法の引数 $super を使います。チュートリアルの例と同じになってもしょうがないので、ここでは子のコンストラクタから親のコンストラクタを呼ぶ方法を考えてみましょう。

例として、「JavaScript 第5版」に載っている Rectangle と PositionedRectangle を使います。

var Rectangle = Class.create({
    initialize: function(w, h) {
	this._width = w;
	this._height = h;
    },
    area: function() {
	return this._width * this._height;
    }
});

var PositionedRectangle = Class.create(Rectangle, {
    initialize: function($super, x, y, w, h) {
	$super(w, h);
	this._x = x;
	this._y = y;
    },
    contains: function(x, y) {
	return (x > this.x && x < this.x + this.width &&
		y > this.y && y < this.y + this.height);
    }
});

このように、第一引数に $super を指定しておくと、$super() とするだけで、親クラスの中で同じ名前を持つインスタンス関数が呼べるのです。

魔法の関数 addMethods

この仕掛けは、先ほど内部を見なかった addMethods にあります。

  addMethods: function(source) {
    var ancestor = this.superclass && this.superclass.prototype;

    for (var property in source) {
      var value = source[property];
      if (ancestor && Object.isFunction(value) &&
          value.argumentNames().first() == "$super") {
        var method = value, value = Object.extend((function(m) {
          return function() { return ancestor[m].apply(this, arguments) };
        })(property).wrap(method), {
          valueOf:  function() { return method },
          toString: function() { return method.toString() }
        });
      }
      this.prototype[property] = value;
    }

    return this;
  }

$super とは関係ない部分を切り出すと、以下のようになります。

    for (var property in source) {
      var value = source[property];
      this.prototype[property] = value;
    }

これは、インスタンス関数をコピーしているだけですね。Object.extend と似ていますが、コピーするのがクラス関数なのかインスタンス関数なのか、というところが違います。

ifの条件式

さて問題は、for の中の if 文です。

      if (ancestor && Object.isFunction(value) &&
          value.argumentNames().first() == "$super") {

条件式を考えてみましょう。

一番目の ancestor は、

    var ancestor = this.superclass && this.superclass.prototype;

なので、親クラスがある場合は、親クラスの prototype が入ります。

二番目の Object.isFunction(value) は、「(名前が指している)値が関数なら」という意味です。isFunction の定義は、とても簡単です。

  isFunction: function(object) {
    return typeof object == "function";
  },

三番目に出てくる argumentNames() は、けなげにも関数を toString にかけてソーステキストに直し、正規表現を使って引数を取り出します。

  argumentNames: function() {
    var names = this.toString().match(/^[\s\(]*function[^(]*\((.*?)\)/)[1].split(",").invoke("strip");
    return names.length == 1 && !names[0] ? [] : names;
  },

つまり、条件式の意味は、「親クラスが存在し、今扱っているものが関数で、かつ、第一引数が '$super' という名前の場合」ということになります。

核心

さてさて、核心は、if の条件式が成立した場合のブロックです。

        var method = value, value = Object.extend((function(m) {
          return function() { return ancestor[m].apply(this, arguments) };
        })(property).wrap(method), {
          valueOf:  function() { return method },
          toString: function() { return method.toString() }
        });

さっぱり分からないでしょう? 気を取り直して、wrap の定義を見てみると、ますます分からなくなります。

  wrap: function(wrapper) {
    var __method = this;
    return function() {
      return wrapper.apply(this, [__method.bind(this)].concat($A(arguments)));
    }
  },

具体例

分からないときは、具体例を考えるのが常套手段です。ここでは、チュートリアルにある例をお借りしましょう。

var Person = Class.create({
  initialize: function(name) {
    this.name = name;
  },
  say: function(message) {
    return this.name + ': ' + message;
  }
});
var Pirate = Class.create(Person, {
  say: function($super, message) {
    return $super(message) + ', yarr!';
  }
});
var john = new Pirate('Long John');

以下のようにして関数を表示してみます。

alert(john.say.toString());

すると、以下のように定義そのままが返ってきます。

function ($super, message) {
    return $super(message) + ", yarr!"; 
}

これでは、$super がなんなのかさっぱり分かりません。また、wrap の中身はどこにいってしまったんでしょうか?

扉を開ける鍵

核心の部分をよく読むと、toString を再定義していることが分かります。なので、この再定義の部分を prototype.js から取り除いてみましょう。

        var method = value, value = (function(m) {
          return function() { return ancestor[m].apply(this, arguments) };
        })(property).wrap(method);

かなりすっきりしました。ここで、もう一度 say を表示させてみると以下のようになります。

function () { return wrapper.apply(this, [__method.bind(this)].concat($A(arguments))); }

やりました。say の化けの皮が剥がれて、wrap が出てきました。

apply の第二引数を考えてみましょう。bind(this) は、関数の中の this の値を保証するだけですから、取り除いて __method だけだと考えてもいいでしょう。arguments は、JavaScript で定義される変数で、関数に対する引数の配列のようなオブジェクトです。この配列のようなオブジェクトを $A で配列に直し、__method を先頭に追加しています。

つまりこれを分かりやすく書き換えてみると、こうなります。

function () { return wrapper.apply(this, [__method, arguments[0], arguments[1], ...]); }

wrapper と __method

さて、この wrapper と __method は一体何でしょう?クロージャーによって定義されている値なので、これだけ見たのでは分かりません。そこで、関数 wrap のソースを書き換えて、無理矢理表示してみました。

wrapper は、以下の通りです。

    function ($super, message) { return $super(message) + ", yarr!"; }

つまり、Pirate の say そのものです。

__method は、こう。

   function () { return ancestor[m].apply(this, arguments); }

ここで m は property であり、その値は 'say' ですから、ancestor[m] は親である Person の say です。つまりこれは、親の say を呼び出す関数です。

これで分かりました!

お元々の引数の先頭に「親のインスタンス関数を呼び出す関数」を付け足して、元々の関数を呼び出すのです。だから第一引数 $super に「親のインスタンス関数を呼び出す関数」が入る訳です。

あー、難しかった。