Edited at

phina.jsとclass構文について考察する


はじめに

phina.js Advent Calendar 2018の4日目にES6のclass構文でphina.jsのクラスを継承するという記事が書かれているのを拝見しました。

実は似たような内容の記事を考えていたのですが、setPrototypeOfを使った実装がとても興味深かったので、もう一度class構文周りについて調べなおしてみました。

せっかくなので、phina.jsのクラスをclass構文で継承する方法についてもう少し掘り下げて書いてみようと思います。

元記事ではsetPrototypeOfを使って解決していましたが、この記事ではなぜこの方法で解決できるのかを考察して、別の方法がないかどうかも考えてみます。


phina.jsのクラスをclass構文で継承する問題点

setPrototypeOfを用いた解決を考える前に、なぜclass構文で継承する操作がうまく行かないかについて見ていきます。

記事中ではいくつかコード例を示しますが、毎回'use strict'やglobalizeするのは面倒なので省略します。


とりあえず継承してみよう

タイトルの通り、まずはphina.jsのクラスをES2015のclass構文で継承してみます。

はじめにphina.defineでクラスを定義します。

phina.define('ClassA', {

init(){},
log(){
console.log('ClassA')
}
})

ClassA().log(); // => ClassA

このクラスをclass構文で継承します。

class ClassB extends ClassA {}

new ClassB().log() // => ClassA

まずは問題なく継承とインスタンス化ができました(できたように見えます)。

では次にオーバーライドを試してみます。ClassBがClassAを名乗るのはおかしいので、logメソッドを再定義するように書き換えてみます。

class ClassB extends ClassA {

log(){
console.log('ClassB')
}
}

new ClassB().log(); // => ClassA

おっと、先ほどと同じものが表示されてしまいました。ここではClassBと表示されてほしいので期待している動作とは異なります。

logはこのClassBのメソッドでオーバーライドされるわけですから、表示にはClassBのメソッドが使われるはずです。

…もしかしてこのインスタンスはこのクラスのインスタンスではないのでしょうか?

instanceofを使ってこのインスタンスのクラスを確認してみます。

class ClassB extends ClassA {

log(){
console.log('ClassB')
}
}

console.log(new ClassB() instanceof ClassB) // => false

これは一体どういうことでしょうか。ClassBをインスタンス化したはずなのに、できたのはClassBのインスタンスではないようです。ではこのオブジェクトは一体何者なんでしょう。


戻り値は何者なのか

なんの脈絡もないオブジェクトが生成されるのはちょっと考えにくいです。ClassBのインスタンスではないとしたら、もしかするとClassAのインスタンスではないでしょうか。確認してみます。

class ClassB extends ClassA {

log(){
console.log('ClassB')
}
}

console.log(new ClassB() instanceof ClassA) // => true

上2つの結果から、このインスタンスはどうやらClassAのインスタンスのようです。一体なぜClassBのコンストラクタからClassAのインスタンスが生成されてしまうのでしょうか。

実のところ、このような挙動はclass構文の仕様とphina.jsのクラスの仕様によります。


コンストラクタが返す値とthis

ここからネタばらしに入りますが、class構文で定義したクラスでは、親クラスのconstructorが値を返す場合、constructor内のthisは親クラスのconstructorが返した値になります。言葉で説明すると難しいのでコードで示します。

class TestA {

constructor(){
return {
value: 0
}
}
}

class TestB extends TestA {
constructor(){
super()
console.log(this) // => { value: 0 }
}
}

このあたりはちょっと直感に反した挙動になるのですが、class構文のコンストラクタ内において、superを呼び出すまでthisへのアクセスが禁止されていることを考えると、この挙動もうなずけます。

この挙動と先程のClassAやBの挙動の何が関係あるのかと思うかもしれませんが、これはphina.jsのクラスの定義方法を見ればわかります。


src/phina.js

// createClass関数の一部分

var _class = function() {
var instance = new _class.prototype._creator();
_class.prototype.init.apply(instance, arguments);
return instance;
};

phina.jsのクラスは関数呼び出しと同じ方法でインスタンス化する事ができました。これはどういう仕組かというと、詳細は省略しますが、関数呼び出しだろうがnew付きでの呼び出しだろうが関係なくクラスのインスタンスを返すようにできているためです(もっと詳しく知りたい方は直接phina.createClass関数を見てみてください)。

先ほどの挙動を踏まえると、このコンストラクタでのthisはClassAのインスタンスになります。コンストラクタはthisを戻り値として返すので、ClassB のコンストラクターは結果的にClassBのインスタンスを生成できず、ClassAのインスタンスを返してしまうわけです。

class ClassB extends ClassA {

constructor(){
super()
// ここのthisはもうClassA
}
log(){
console.log('ClassB')
}
}


ここで元記事の内容を振り返ってみます。

この場合、SubRectangleのコンストラクタ内のthisはRectangleShapeのインスタンスそのものです。ここで、明示的にprototypeにSubRectangleをセットすることで正しくSubRectangleのインスタンスを生成できたわけです。

class SubRectangle extends phina.display.RectangleShape {

constructor (option) {
super(option)
Object.setPrototypeOf(this, SubRectangle.prototype) // ←これ
}
render (canvas) {
console.log('ex: renderをオーバーライドして何かする')
super.render(canvas)
}
}

(元記事)ES6のclass構文でphina.jsのクラスを継承するより

しかし元記事でも言及されているsetPrototypeOfのパフォーマンスについて、MDNには次のように記述されています。


警告: 最近の JavaScript エンジンがプロパティへのアクセスを最適化する方法の特質上、オブジェクトの [[Prototype]] を変更すると、すべてのブラウザーや JavaScript エンジンで、操作がとても低速になります。プロトタイプを変更することの性能への影響は細かく広範囲にわたり、 Object.setPrototypeOf(...) 文に費やされる時間だけではなく、 [[Prototype]] が変更されたすべてのオブジェクトへのアクセスを持つすべてのコードに影響する可能性があります。性能が気になる場合は、オブジェクトの [[Prototype]] を変更することは避けるべきです。かわりに、 Object.create() を使用して必要な [[Prototype]] をもつオブジェクトを生成してください。


Object.setPrototypeOf() - JavaScript | MDN

(2018年12月時点)

いきなり警告されてしまいました。babelでもsetPrototypeOfは使われているようですが、これはstaticメソッドを継承するためだけに使われているようです。それを考えると、インスタンスが生成されるたびにprototypeをセットするのはやはりパフォーマンス的に心配です。


class構文で継承するために

ここまででphina.jsのクラスをclass構文で継承する際の問題点は理解できました。ここからはsetPrototypeOfを使わずに、class構文でも問題なく継承するにはどうしたらよいかを考えていきます。


古典的な継承の場合

いきなりclass構文での継承を考えるのはちょっと難しいので、まずはprototypeを直接扱う古典的なクラス継承から挑戦してみます。

古典的なクラス継承では、class構文のsuper()に当たるところを、コンストラクタ内でthisに対して親クラスのコンストラクタを適用することで親クラスの分の初期化をしてやります。

ここで補足情報ですが、前述の通りphina.jsのクラスでは(実際の)コンストラクタはクラス生成の機能のみを持っていて、初期化処理はinitメソッドに詰め込まれています(phina.defineでinit関数に初期化処理を書くのはそういうことです)。

ここでは初期化処理のみがほしいので、phina.jsのクラスから継承を行うには次のようにします。

phina.define('ClassA', {

init(){},
log(){
console.log('ClassA')
}
})

const ClassB = function(){
ClassA.prototype.init.apply(this)
}
ClassB.prototype = Object.create(ClassA.prototype)
ClassB.prototype.constructor = ClassB

ClassBのコンストラクタ内でClassAのinitメソッドを適用することでClassAの分の初期化ができます。

あとはprototypeにClassAのプロトタイプを流し込んでやると継承は完了です。

オーバーライドも試してみます。

phina.define('ClassA', {

init(){},
log(){
console.log('ClassA')
}
})

const ClassB = function(){
ClassA.prototype.init.apply(this)
}
ClassB.prototype = Object.create(ClassA.prototype)
ClassB.prototype.log = function(){
console.log('ClassB')
}
ClassB.prototype.constructor = ClassB

new ClassB().log() // => ClassB


このクラスはclass構文で継承できる

さて、だんだんゴールに近づいてきましたが、実はこのClassBはclass構文で継承することができます。

class構文はこの古典的なクラス定義のシンタックスシュガーなので、古典的なクラスのほとんどは普通に継承できます。

const ClassB = function(){

ClassA.prototype.init.apply(this)
}
ClassB.prototype = Object.create(ClassA.prototype)
ClassB.prototype.log = function(){
console.log('ClassB')
}
ClassB.prototype.constructor = ClassB

class ClassC extends ClassB {
log(){
console.log('ClassC')
}
}

new ClassC().log() // => 'ClassC'

例外としては、phina.jsのようにコンストラクタが明示的に値を返してしまう場合です。

つまりコンストラクタが明示的に値を返さないようなクラスを一度経由してしまえば、class構文での継承はうまくいくはずです!


そして継承へ...

上のような古典的なクラスでの継承を間に挟めばclass構文での継承が可能になることが分かりました。

ただイチイチ手動でこんなことをやっていてはclass構文での継承は全くの蛇足です。これを解決するために補助関数を定義します。

const convert = function(_class){

const creator = function(){
_class.prototype.init.apply(this, arguments)
}
creator.prototype = Object.create(_class.prototype)
creator.prototype.constructor = creator
return creator
}

この関数は渡されたphina.jsクラスを継承したクラスを返します。このクラスのコンストラクタは、単純にinitをthisに適用するだけです。その際、コンストラクタに渡された引数はinitにそのまま横流しします。

つまりこの関数を通してしまえばめでたくclass構文での継承が実現できます!

phina.define('ClassA', {

init(){},
log(){
console.log('ClassA')
}
})

class ClassB extends convert(ClassA) {
log(){
console.log('ClassB')
}
}

new ClassB().log() // => ClassB

RectangleShapeのようなビルトインのクラスでも問題なく継承できます。

class SubRectangle extends convert(RectangleShape) {

constructor(option){
super(option) // 引数にもちゃんと対応する
}

render(canvas){
console.log('It Works!')
super.render(canvas)
}
}

やっとclass構文での継承にたどり着くことができました!


おわりに

この方法で継承を行うことはできましたが、いくつか気になる点が無いわけではないです。


  • prototypeが余計に一つ増えてしまう


  • extends convert()の部分が面倒/嫌 など

2つ目はともかく、prototypeが1つ増えてしまうのはsetPrototypeOf程ではないにしろ、ちょっと気にはなります。

また、できたクラスの使い勝手としては


  • newが必須

  • _static, _accessor,_definedなどが使えない

  • Scene, Element#fromJSONなどで直接扱えない

などがあります。

1つ目に関しては今時そこまで気にはならないと思います。

2つ目に関しては、staticはstaticキーワード、_accessorはget/setキーワード、definedはデコレータなどで代用可能です。

3つ目に関してはちょっと工夫が必要で、phina.defineと同じような動きをさせるには手動でphina.registerしてやる必要があります。

また、phina.js内部では基本的に関数呼び出し形式でインスタンス化するので、それに対応させる必要があります。

class ClassB extends convert(ClassA) {

// ...
}

// 名前(path)と生成用の関数を渡す
phina.register('ClassB', function(...args){
return new ClassB(...args)
})

class構文を使いたい場合はこれらを踏まえた上で使うのがいいと思います。どうしても公式で対応している機能ではありませんから、何か問題が起きた時には自分で調べて解決する必要があります。

それを考えると、このやり方はあまり初心者にはお勧めできませんし、そこまでスマートでは無いかもしれません。ただ最近はclass構文がエディタの恩恵を受けられるようになっているので、class構文を使うメリットは少なからずあるのではないでしょうか。

記事の内容はここでおわりです。考えられる範囲でclass構文を使う方法を書いてみましたが、もしもっと良い方法があれば教えてもらえると幸いです。

今回記事中に挙げたconvert関数などは自由に使ってもらって大丈夫です(保証はしかねますが...)。長くなってしまいましたが、最後まで読んでいただきありがとうございました。


おまけ

class構文では、staticなメソッドも継承先に引き継がれます。この挙動も実現したい場合には、babelを真似することで同じことができます。

const convert = function(_class){

const creator = function(){
_class.prototype.init.apply(this, arguments)
}
creator.prototype = Object.create(_class.prototype)
creator.prototype.constructor = creator
Object.setPrototypeOf(creator, _class) // <= ここを追加
return creator
}

やっていることは単純です。creator(つまりコンストラクタ)のプロトタイプに親クラスのコンストラクタをセットしてやることで、staticなメソッドも参照できるようになるというわけです。