CoffeeScriptで継承出来るようなJavaScriptのクラスの書き方
意外と根が深すぎて、ビビりました。数時間を費やしてしまいました。
環境
- Google Chrome 20.0.1132.57
- CoffeeScript 1.3.3
結論
まず一番重要なのは、「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
http://www.ecma-international.org/publications/files/ECMA-ST/Ecma-262.pdf
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.
コンストラクタ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のコンストラクタは仕様なのかバグなのか、どうなのでしょうかね。