CoffeeScriptで継承出来るようなJavaScriptのクラスの書き方

意外と根が深すぎて、ビビりました。数時間を費やしてしまいました。

環境

結論

まず一番重要なのは、「constructorではオブジェクトをreturnすべきでない」ということです。

この記事を書いてからしばらく立ち、意図的であれば、オブジェクトを返すのが必ずしも悪ではないと思い直しました。

そして、紆余曲折を経て、JavaScript側の親クラスのprototypeにconstructorプロパティを追加することで解決することが判明しました。

問題のケース

まず、JavaScriptで非常にオーソドックスなprototypeベースのクラスを作成します。

// superclass.js
var SuperClass = function() {};
SuperClass.prototype = {
	test: function() {
		console.log("SuperClass");
	}
};

次にこれを継承するクラスをCoffeeScriptで実装します。

# subclass.coffee
class SubClass extends SuperClass
	test: () ->
		super.test()

this.SubClass = SubClass

subclass.coffeeをコンパイルしてsubclass.jsを生成したら、手抜きHTMLで読み込みます。

<!-- index.html -->
<script src="superclass.js"></script>
<script src="subclass.js"></script>

index.htmlをブラウザで開き、コンソールを出します。

> new SuperClass
SuperClass
> new SubClass
Object

なんとSubClassをnewしたらObjectのインスタンスが生成されてしまいました。

何故でしょう?

ポイント1:CoffeeScriptの継承

CoffeeScriptはextendsを書くと、以下の様な継承関数が自動生成されるようになっています*1

var __hasProp = {}.hasOwnProperty;
var __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;
};

ポイントはchild.__super__ = parent.prototype;の部分です。SuperClass.prototype.constructorが無いこと、SuperClass.prototype.__proto__.constructorがObjectであることを覚えておきましょう。

ポイント2:CoffeeScriptのデフォルトコンストラクタ

CoffeeScriptではclassにconstructorが無いと、自動でデフォルトコンストラクタを生成します。デフォルトコンストラクタは以下の様なコードになります。

function SubClass() {
  return SubClass.__super__.constructor.apply(this, arguments);
}

ポイントは、親クラスのconstructorの実行結果をreturnしている所です。

何が起こるか

ポイント2のSubClass.__super__.constructorは、ポイント1で述べたように、SuperClass.prototype.constructorが無いのでSuperClass.prototype.__proto__.constructorを参照することになります。この時、SuperClass.prototype.__proto__.constructorはObjectです。

つまり、ポイント2のコードは以下と等価です。

function SubClass() {
  return Object.apply(this, arguments);
}

このSubClassをnewすると一体何が起きるでしょうか。

言語仕様を見る前に…

Object()new Object()Object.apply(undefined)を実行してみましょう。

> Object()
Object
> new Object()
Object
> Object.apply(undefined)
Object

以上の3つでは、いずれも同じメソッドを持つObjectのインスタンスを生成しています。つまり、Object関数はthisの値に関わらずnew Object()を返すようです(要確認)。

そうすると、先のSubClassは、

function SubClass() {
  return new Object();
}

と等価になります。さて、SubClassをnewするとどうなるでしょうか?

newの罠

私はnewされるfunction*2で値を返すことをしたことが無かったので余計にはまりました。

> var Hoge = function() {};
undefined
> new Hoge()
Hoge
> var Huga = function() { return {}; }
undefined
> new Huga()
Object

上記の結果は、個人的にかなり意外でした。オブジェクトを返すコンストラクタHugaをnewすると、Hugaオブジェクトではなく、コンストラクタ返すオブジェクトになるのです。これは、ECMAScriptの仕様に起因します*3

13.2.2 Construct
When the Construct internal method for a Function object F is called with a possibly empty list of arguments, the following steps are taken:
1. Let obj be a newly created native ECMAScript object.
2. Set all the internal methods of obj as specified in 8.12.
3. Set the Class internal property of obj to "Object".
4. Set the Extensible internal property of obj to true.
5. Let proto be the value of calling the Get internal property of F with argument "prototype".
6. If Type(proto) is Object, set the Prototype internal property of obj to proto.
7. If Type(proto) is not Object, set the Prototype internal property of obj to the standard built-in Object prototype object as described in 15.2.4.
8. Let result be the result of calling the Call internal property of F, providing obj as the this value and providing the argument list passed into Construct as args.
9. If Type(result) is Object then return result.
10. Return obj.

http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf

コンストラクタFの実行結果resultがObjectの時は、resultが返ると書いてあります。

よって、SubClass

function SubClass() {
  return new Object();
}

をnewすると、Objectのインスタンスが生成されることになります。SubClassのprototypeなどは完全無視され、只々Objectのインスタンスが返るだけです。

CoffeeScriptの罠

class BadClass
	constructor: () -> return {}
	test: () -> console.log "BadClass"

こうすると、

function BadClass() {
  return {};
}

となり、new BadClassの結果は{}になります。

では、以下の時はどうでしょう。

class BadClass
	constructor: () -> {}
	test: () -> console.log "BadClass"

CoffeeScriptは、この問題を意識しているのか

function BadClass() {
  ({});
}

と変換されます。うーむ悩ましい。

どうするのが良いか

最初の結論で述べた通り、通常、コンストラクタでオブジェクトを返すことはすべきでないと考えます。

実際、多くの場合、コンストラクタにreturn文は書かないでしょうから、以下のようにしておけば問題ないでしょう。

var SuperClass = function() {
	// オブジェクトをreturnしない
};
SuperClass.prototype = {
	construct: SuperClass, // constructorプロパティを追加
	test: function() {
		console.log("SuperClass");
	}
};

このように、constructorプロパティを設定しておけば、CoffeeScript側で支障は出ません。

また別の方法としてCoffeeScript側でextendsする時、必要でなくても必ずconstructorを書くようにするという手もあるでしょう。

class SubClass extends SuperClass
	constructor: () -> # do nothing

とはいえ1番目の方法がより確実でしょう。

CoffeeScriptのコンストラクタは仕様なのかバグなのか、どうなのでしょうかね。

参考文献

*1:実際には整形されていません

*2:つまりクラス

*3:Google Chrome及びその他のモダンブラウザがECMAScriptのどのバージョンに準拠しているかは分からないのがアレですが…