#あらすじ
Google先生に教えてもらったmixinライブラリがどれもしっくりこなかったので、擬似多重継承ライブラリを自作したお話。
#プロローグ
ECMA6のClass構文を利用してHTML5 Canvasを便利に使うためのライブラリを作成しようとしていてふと手が止まった。
やりたいことは単純で、Canvas上にイメージライブラリを模したWindowを表示して、Window内のイメージをCanvas上にドラッグドロップするというだけのもの。で、早速Classを設計してみた。
コード共有と汎用性を考えて、CanvasとWindowはContainer、ImageはPlaceableのサブクラスとした。何の工夫もないが、極めて妥当な設計だ。またResponsible Web Designに対応できるよう、Containerには基本的なAnchorレイアウト(描画オブジェクト間の関係を定義してサイズ変更時に自動的に描画位置を調整するレイアウト方式)の機能を持たすことにした。
オブジェクトの内部ステータスを外部から変更するのはクラスのモジュール独立性を損なうので、Containerのサイズが変更されたらContainerからPlaceableにサイズ変更をイベントとして通知し、それに反応してPlaceableが自らの位置を調整するように設計しよう。
ん?ちょっと待てよ。ImageだけではなくWindowもCanvasに配置できるのだから、Plceableの機能を持たないとWindowだけサイズ変更イベントに反応しなくなってしまう。そうなると、こうだ。
「多重継承」…これは問題だ。ECMA6のClassでは多重継承やMixinといった複数親クラス対応は言語的にサポートされていない1。
この問題を解決する方法はいろいろ考えられるが、今回わざわざClassを利用したのはメンテナンス可能な実装、可読性の高いコード記述を求めてのことだ。なんとか楽にそれを実現できる方法が見つかれば良いのだが。
#教えて!Google先生!
「JavaScript 多重継承 Mixin」のキーワードで検索すると、それなりに多くの記事が引っかかってくる。Object.assignを利用する方法、片親のプロパティをまるっとコピーしてくる方法。どちらでも問題は解決しそうではあるが、いずれも重要な宣言であるべき継承元に関する記述が通常コードに紛れてしまうことがコード可読性の観点からは疑問に感じる。好みの問題かも知れないがextendsと同等の効果をもたらすのであればextendsに近しい形でそれは宣言されなければならないはずだ。
class Window extends Container, Placeable{
}
class Window extends Container with Placeable{
}
そうでなければ、機能継承元が一目で明らかではないため「なぜこのように動作するのか?」と言う素朴な疑問の答えを探す作業が大変になりすぎる。
もう少し調べてみると、理想形に近い書式を実現するライブラリが複数存在していることがわかった。mixwith.jsとes6-classes-mixin.jsだ。これらのライブラリはどちらも、Mixin対象クラス(この場合はWindowクラス)やそのインスタンスを機能拡張すると言う考え方ではなく、extends句にクラスを返却する関数を記述できることに目をつけて機能拡張した親クラスを動的に生成する方式を取っている。2
class Window extends mix(Container).with(Placeable){
}
class Window extends mix(Container, Placeable){
}
さて、このいずれかが役に立ってくれれば良いのだが、mixwithではMixin対象のクラスはSubclass factoryパターンを用いて宣言しなければならないらしい。つまりMixin対象のクラスは通常のクラス構文とは異なるmixwithの流儀に従って宣言しろと言うことで、既存のクラス、例えばオープンソースで提供されるクラスや過去に自分が作成したクラスはそのままMixinには利用できないことになる3。この制約は受け入れがたい。
一方es6-class-mixin.jsはというと、こちらはmix関数の第一引数には親クラスを宣言し、以降はMixin対象のオブジェクトを宣言する形となっている4。うーん、オブジェクトかあ。JSらしいと言えばそうだし、クラスよりもオブジェクトをMixinする方が汎用性が高いということなのかも知れないが、class宣言のextends句にオブジェクトを羅列するのはコード可読性に難があり(また統一美としても)どうかと思う。そこはクラスに統一することでコード可読性の維持を強要したいところでもある。
結局色々と調べてみたが、これぞというものは見つからなかった。
Google先生にはさようならを告げて、自作の旅に出ることにしよう。
#まずは要件
結局何が欲しいのかと言うとこうなる。
- 複数のクラス(オブジェクトではない)から機能継承可能
- 継承元クラスに特殊な制約は加えない。つまりthis, super, constructor, extends, instanceofは全てのクラスで制約なく記述可能でなければならない
- extends句内で継承元クラスの一覧が可能
「特殊な制約を課さない」とする中には次の条件も含まれている。
- 継承元クラスがステートレスであることを前提としない
Containerは描画可能領域としてのwidthやheightを持つことが自然であり、Placeableは描画座標位置としての(x, y)を持つのが当然だ。要件を並べてみるとMixinというよりは擬似多重継承5の実現に近いか。
#実現してみる
「extends句内で継承元クラスの一覧が可能」を満たす記述は、「es6-class-mixin.js」の記法を拝借する。
class Window extends mix(Container, Placeable){
}
mix関数はContainerとPlaceableの機能を併せ持つクラス(図中の「(Anonymous Class)」)を作成6して返却する関数で基本形はこうなる。
var mix = function(base, ...mixins){
let copyProperties =function (target = {}, source = {}) {
/* sourceからtargetにプロパティーをコピーする */
};
//引数のクラスを親クラスとする匿名クラスを準備
let retval = class extends base{
constructor(...args){
super(...args);
for(let i in mixins){
//匿名クラスにmixinクラスのプロパティをコピーする
copyProperties(retval.prototype, mixins[i].prototype);
}
}
};
return retval;
}
これだけなら簡単なのだが、どうしても解決しなければならない問題が2つある。
- (Mixinクラスを含む)継承元クラス間のプロパティ名衝突(同一名称のプロパティが宣言されている)に対応する必要がある
- (Mixinクラスを含む)継承元クラスのコンストラクタを全て呼び出す必要がある
最初の問題は決めの問題で、優先順位さえ明らかであれば後は開発者にお任せでいい。今回はextends句の左から右に向かって優先順位を下げていく考え方(重複プロパティ名がある場合、親クラスが最優先され、ついでmixinsの宣言順に優先とする)で良いだろう。後はプロパティ名衝突時には開発ログでも出力しておけば申し分ない。
ところがコンストラクタはそう簡単ではない。
#コンストラクタを攻略する
class Container {
constructor(width, height){
this._width = width;
this._height = height;
}
}
class Placeable {
constructor(x, y){
this._x = x;
this._y = y;
}
}
このようにコンストラクタが宣言されている場合、両方のコンストラクタがWindowの作成時に呼び出されてくれないと困る。でなければ初期化が片手落ちとなる。
class Window extends mix(Container, Placeable){
constructor(width, height, x, y){
//この呼び出しに呼応して、Container, Placeable両方のコンストラクタが実行される必要がある
super(width, height, x, y);
}
}
Containerは正当な継承元であり、super()で標準的に呼び出されるから問題ない。
let retval = class extends base{
constructor(...args){
super(...args);//このコードでContainerコンストラクタが呼び出される
//引き続きPlaceableコンストラクタを同じタイミングで呼び出したい
Placeableのコンストラクタも、当然同じタイミングで呼び出して、Windowクラスに正しく初期化されたwidth, height, x, yを設定したい。
ところがPlaceableは正当な親クラスではないのでsuper()で自動的に呼び出されるわけではない。その場合コンストラクタを実行させるにはnewを介して呼び出すしかなく、普通の関数として呼び出すことはできないのだ(StackOverFlowで同様のトピックが扱われているので興味があればこちらを参照)。
そうとわかればコンストラクタを直接呼び出すことにこだわる必要はない。
そもそもコンストラクタの役割はオブジェクトの初期状態、つまりオブジェクトプロパティの初期値を設定することにある。ならば一度オブジェクト化してしまって、コンストラクタそのものではなく、コンストラクタを呼び出した結果をコピーすれば良い。
var mix = function(base, ...mixins){
let copyProperties =function (target = {}, source = {}) {
/* sourceからtargetにプロパティーをコピーする */
};
//引数のクラスを親クラスとする匿名クラスを準備
let retval = class extends base{
constructor(...args){
super(...args);
//利用したパラメータは削除する
args = args.splice(base.length);
for(let i in mixins){
//匿名クラスにmixinクラスのプロパティをコピーする
copyProperties(retval.prototype, mixins[i].prototype);
//mixinクラスをオブジェクト化してコンストラクタの実行結果をコピーする
let mixinobj = new mixins[i](...args);
copyProperties(this, mixinobj);
args = args.splice(mixins[i].length);
}
//2017-01-12追記:
//Mixinクラスに対するinstanceof代替メソッド
this.isMixedWith = (cl) => mixins.reduce(
(p,c) => p || (cl === c || cl.isPrototypeOf(c)), false);
}
};
return retval;
}
これで要件を満たすmix関数は完成だ。本記事で作成したコードは(記事内ではコメント表記していた箇所も含めて)githubからダウンロードできるので興味がある方はダウンロードしていただきたい。
補足: mixin.jsコード内の「args = args.splice(base.length)」だが、クラス.lengthでコンストラクタのパラメータ数を取得できる。知っておけば役に立つこともあるかもしれない。
-
プロトタイプベースの構文に限ればMDNに疑似多重継承の手法が記載されているがClass構文内での再現は難しそうである ↩
-
JavaScriptのES2015クラスでmixinを実装する世界一美しい方法はes6-class-mixin.jsに近しい論理的アプローチで美しい記述を実現しており興味深い。ただストリングテンプレートを用いる方式については好みが分かれるか ↩
-
mixwithは「Mixinかくあるべし」と言うポリシーありきの実装のためこの形のようだ。ポリシー自体は興味深い内容なので一読の価値あり ↩
-
github上の使用例ではMixin対象もクラスを指定できるような記載となっているが、実際に試してみたところ、クラスを指定した場合うまく動作しなかった ↩
-
多重継承とMixinの違いに関する統一された説明は難しいが、個人的な見解を述べるとMixinとは「(単一継承言語において)親クラスとは別に ⑴コンストラクタを持たず かつ ⑵親クラスを持たない クラスに限定して機能継承を許可する」ことである。これは多重継承におけるダイヤモンド継承問題と複数継承元のコンストラクタを取り扱う煩雑さを避けることを目的とした「制約付き多重継承」に他ならない ↩
-
Containerクラスを直接機能拡張してはいけない。そうするとWindowだけではなくCanvasにまで副作用が及んでしまう。 ↩