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

JavaScriptはいかにしてprototypeを捨てクラスベース継承を得るのか

きっかけは、prototypeconstructor__proto__ の関係を再確認していたときでした。JavaScriptはこうだけど、これって、AltJSな言語の継承はどうなってるんだろうと思って試したくなりました。

ちょっと気持ち的に、なんだか宗教に入ったみたいに俺は JavaScript がわかったって声高に言う人、だいたいみんな、プロトタイプチェーンによる移譲がクラスベースの継承に束縛されていた思考のブレイクスルーなんだぜ、みたいに言うんだけど、いったいそれがどれほど素晴らしいものなんだろうと考えてしまい...

もしプロトタイプチェーンがそんなに素晴らしいんなら、npm にあるほとんどのライブラリがチェーンを活かして作られてない理由が説明できない。もしかしたら、仕組みがいくら面白くても、実際のメンタルモデルにフィットせず、実は使い物にならないんじゃないか ---- と疑ってみるところから考えなおしてみました。

TypeScript で継承したらこうなりました。

class A {
    public p:number;
    public q:number;
    constructor(n:number) {
        this.p = n;
        this.q = n * 10;
    }
}

class B extends A {
    constructor(n:number) {
        super(n);
        this.p = n * 2;
        // this.q = n * 20;
    }
}

var b = new B(1);
console.log(b.p, b.q);

var __extends = this.__extends || function (d, b) {
    for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p];
    function __() { this.constructor = d; }
    __.prototype = b.prototype;
    d.prototype = new __();
};
var A = (function () {
    function A(n) {
        this.p = n;
        this.q = n * 10;
    }
    return A;
})();

var B = (function (_super) {
    __extends(B, _super);
    function B(n) {
        _super.call(this, n);
        this.p = n * 2;
        // this.q = n * 20;
    }
    return B;
})(A);

var b = new B(1);
console.log(b.p, b.q);

なんだこの __extends って。TypeScript は元のコードとわりと対応する JavaScript を吐くってことになってなかったっけ? なんで元のコードと対応しないこんな関数を作らなきゃいけないんだろう。

CoffeeScript ではどうかな?

class A
  constructor: (n)->
    @p = n
    @q = n * 10

class B extends A
  constructor: (n)->
    super(n)
    @p = n * 2
    # @q = n * 20

b = new B(1);
console.log b.p, b.q

var A, B, b,
  __hasProp = {}.hasOwnProperty,
  __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };

A = (function() {
  function A(n) {
    this.p = n;
    this.q = n * 10;
  }

  return A;

})();

B = (function(_super) {
  __extends(B, _super);

  function B(n) {
    B.__super__.constructor.call(this, n);
    this.p = n * 2;
  }

  return B;

})(A);

b = new B(1);

console.log(b.p, b.q);

やっぱり同じようなのができた。親のクラスを参照する方法が動的か静的か違うだけで、ほとんど同じことやってる。

ちょっとまって、JavaScript のプロトタイプチェーンの教科書でこの、n という初期化パラメータを持つオブジェクトの継承はどういうふうに実装したっけ??

function A(n) {
  this.p = n;
  this.q = n * 10;
}

function B(n) {
  this.p = n * 2;
  // this.q = n * 20;
}
B.prototype = new A(0);  // とりあえずオブジェクト要る、けどこれじゃダメだ

var b = new B(1);
console.log(b.p, b.q);
console.log(b.constructor);

おや? 伝統的な JavaScript の教科書的なプロトタイプチェーンだと、new A(0); のところがどうしてもうまく書けないぞ。あとで (new B(1) のときに) 初期化パラメータの値を決めたいのに、最初に何か仮に決めておかないといけない?? で、それだと b.q の値は new B(1) によって決定されるものと食い違う??

もっと不自然なのは、function A(n) { ... } がコールされるタイミング。B.prototype = ...; の時点、つまり何の実体も作られずただ定義だけしたときもうすでに、関数が呼ばれちゃってる。もし親になる A がタイムスタンプ取得やリソースのロックみたいな責務を持っていたら...

と、ここでプロトタイプ信者になった人がいうのは、「クラスベースとは違うのだよクラスベースとは」ってことなんですが、ちょっと落ち着いて、考え方が間違っているっていう前に、TypeScript と CoffeeScript がやっていることは何なのかを、なるべく平易な JavaScript に書き換えて考えてみますね。

function A(n) {
  this.p = n;
  this.q = n * 10;
}

function B(n) {
  A.call(this, n);  // ここで親の初期化プロセスを呼ぶ
  this.p = n * 2;
  // this.q = n * 20;
}
(function() {
  var tmp = function() {  // プロトタイプはパラメータを持ってはいけない
    this.constructor = B;  // __proto__の1段階目にconstructorプロパティがないJS謎仕様への対策
  };
  // hasOwnProperty はちょい割愛
  tmp.prototype = A.prototype;  // BのプロトタイプtmpはAに似た別のダミーオブジェクト
  B.prototype = new tmp();
})();

var b = new B(1);
console.log(b.p, b.q);
  • B のプロトタイプとして new されるのは A ではない
  • それは A に似た別のダミーオブジェクト
  • プロトタイプのコンストラクタはパラメータを持たない (new A(0); の件)
  • 親の初期化プロセスを初期化関数のように呼ぶ必要がある
  • __proto__ の1段階目にconstructorプロパティがないJS謎仕様への対策 ←ひどい

JavaScript でクラスベース継承で得られる恩恵を享受しようと思ったら、ユーザー側でこれだけのコードを書く必要があるんですかね? クラスベースのOOP言語でプロトタイプチェーンの真似をしたいときは、面倒だけどいちいちターゲットに移譲すれば何とかなるというシンプルさだというのに対して、あまりにフェアじゃないんじゃないですかこれ。

うーん。

そろそろプロトタイプチェーンの正義に準じて考えてみましょうか。プロトタイプチェーンは、例えていえばレストランで、無愛想な店長=プロトタイプとのやりとりしかできなかったところに、愛想のいい店員さんが入ってくれて、代わりにもっと愛嬌のある接客をしたり、お客さんのいろんな要望に答えたりしてくれる、みたいなやつですね。その実体は is-a 関係じゃなくてコンポジションによる拡張。なんでも継承で拡張しない、コンポジションを好むべきというプラクティスに合致している気がします。

気がします...

ところが JavaScript の初期設計には Java の圧力があったことを忘れちゃいけません。new Hoge(); だのコンストラクタだの、そもそもそれらは、本質的に必要なんでしょうか? なにか政治的な理由が、言語としての設計の完全性より優先したって可能性はないですか?

で、プロトタイプチェーンやるならこっちね、って最近 ECMA Script 5 で増えた、ナウなヤングに推奨な感じのやつがこれですよ。

Object.create - JavaScript | MDN

Object.create 概要 指定したプロトタイプオブジェクトおよびプロパティを持つ、新たなオブジェクトを生成します。

function Constructor(){}
o = new Constructor();
// これは以下と同じです:
o = Object.create(Constructor.prototype);
// もちろん、実際にコンストラクタ関数の初期化コードがある場合でも、Object.create はそれを反映できません

Polyfill

if (!Object.create) {
  Object.create = function (o) {
    if (arguments.length > 1) {
      throw new Error('Object.create implementation only accepts the first parameter.');
    }
    function F() {}
    F.prototype = o;
    return new F();
  };
}

この代替コードの中でやってることは、AltJS系がクラスベース継承を再現したものをJSに書き下したコードにずいぶん近いですね。コンストラクタは何でもいい、要するにターゲットオブジェクトに変更安全ラッパーをかぶせた何らかのオブジェクトが作れればいいんだ。

...やっぱりね。ユーザーコードに「コンストラクタ」なんて存在、そもそも必要なかったんじゃん。ECMA 曰く、形を定義してから実体を作っていく必要なんてない、と。いきなり実体を持つオブジェクトが存在して、ユーザーはその実体に皮をかぶせたオブジェクトを、好きなときに自由に作ってそれ(=皮)をデコレーションできるよ、コンストラクタ関数の定義なんて要らないよ、というやりかたでOKだそうですよ。Java に言わされてただけなんですっ newconstructor もなかったことにしてください、と...?

ここ、F.prototype = o;oConstructor.prototype ですね。クラスベース再現のほうでは、tmp.prototype = A.prototype; に対応しますね。オブジェクト直接ではなく、あくまで、型があって、その型にプロトタイプオブジェクトが存在するという考え。

プロトタイプチェーンの信奉者は、リアルな値(実際に構造がどうなってるか予測不能)が先にあって、さてこれをどうしよう、という発想なんだけど、これどうなんでしょ? 制限された型(構造は想定の範囲内)が先にあって、それらの関係という抽象概念に対して値を投入する、という発想のほうが、ソフトウェア設計として健全なアイデアなんじゃないかと、自分なら考えますけどね...

たしかに、チェーンの先の値や関数であっても、自分が持っているプロパティのように this.foo とするだけで読み取りアクセスできるのは楽ですが、さて、読み取りアクセスしたもを書き込みアクセス this.foo *= 2; などすると、別の実体ができてしまいますよね。で、またそれを読むと、こんどはプロトタイプのほうにアクセスしない。えーと、これって本当に移譲って言える?

いっぽう、明示的な移譲には何の問題もなかった。さほど面倒でもないし、意図しない操作はターゲットに届かない。

class Operator
  constructor: (@target)->
  exec: ->
    @target.foo *= 2

data = { foo: 1, bar: 10 }

op = new Operator data
op.exec()

console.log data.foo  # =>2

えーと、もしかして JavaScript のプロトタイプチェーンは、オブジェクト間の移譲もマトモに機能していない?

イエス!

ユーザーレベルで使えるプロトタイプチェーンとは、コピーコストをケチったクローンという用途を除いて、あらかじめ定義されたメソッドのセットを引き継ぐしかできない、単一選択のミックスインでしかないのであーる。

あれ...? んーっと、 _.extend(targetObject, myMethodSet) っと...

// Extend a given object with all the properties in passed-in object(s).
_.extend = function(obj) {
  if (!_.isObject(obj)) return obj;
  _.each(slice.call(arguments, 1), function(source) {
    for (var prop in source) {
      obj[prop] = source[prop];
    }
  });
  return obj;
};

どう考えてもこっちのほうがいいですね。何個でも、何回でもいけるもん。そりゃあ Underscore も lodash も大人気なわけだ。

ユーザーの本音「あんなややこしいもん使うぐらいなら、多段な継承なんかなくてもいいよ。他に使える道具は十分揃ってるからなくても我慢できる。使えたとしても、あんなもん手出したら逆に生産性が落ちる」

かくして、JavaScript はクソの役にも立たないプロトタイプチェーンを封印し、すべて関数と単一階層オブジェクトで語れる世界で予定調和を迎えましたとさ。

いっぽうで、この隠されたプロトタイプの秘術を利用してコンパイラを作り、あろうことか邪教であるクラスベースオブジェクト指向JavaScript 内に再現しようとするのが AltJS、という世界の流れなわけです。

いやー、そう考えるしかないでしょこれどう考えても。npm 見ても、AltJS 言語見ても。客観的に。(自分だってそんな結末は期待してなかったけど)

感想

プロトタイプチェーンという「しくみ」の魅力にハマることなく、現実世界でメンタルモデルにフィットし、かつ、実際に使える技術とは何か、ということを見失ってはいけないと思いました。あと、やっぱり JavaScriptJava の政治圧力を受けてだましだましリリースされた、ブラウザマクロのための中途半端なデザインの言語なのだというのは、いまだに重要な視点なんだなと思い出し直しました。

ウィーン ウィーン マサカリ防壁展開中...