プロトタイプによる継承

 ES6からclass構文が投入されて、普通のオブジェクト指向言語っぽく書けるようになったJavaScriptだが、クラスの継承の面では他の言語と結構毛色が違う。 JavaScriptの継承の基本はプロトタイプ継承で、class構文はこれをうまいこと隠してくれるが、見えていた方が幸せなんじゃないかと思うこともある。 なお、今日日のclass構文はシンタックシュガーの域を超えていて、class構文じゃないとできないこともある。 いつしかあきらめてclass構文を使わなきゃいけない日が来るんだろうなぁ。

コンストラクタ関数によるクラス

 JavaScriptのクラスの基本は、クラス名を冠したコンストラクタ関数を作り、そのprototypeプロパティにインスタンスメソッドを詰め込む、である。 インスタンスプロパティはすべてコンストラクタの中で初期化する。 これは慣れるまでは「すごく変な」感じがする。 まず、関数にプロパティがある。 C()という関数を作ると、C.xみたいなプロパティを作って代入できてしまう。 もちろん、関数としてのCの定義は残ったままである。

function C() { }
C.x = 1;
C(); // I can call it!
クラスの静的プロパティ・静的メソッドはすべてこの形で定義する。

 逆にインスタンスにくっつく方のプロパティはすべてコンストラクタの中で定義する。 コンストラクタの中で定義するので、この時に必ず初期値を設定することになる。 メソッドはコンストラクタで同様に設定してもいいが、普通はprotytpoeプロパティに設定する。 両者の違いは継承した時に出る。 前者は派生クラスから基底クラスの関数を呼び出せない。

function C () {
	this.x = 1;
	this.y = 2;
	this.foo = function () { console.log("this.foo"); }
}
C.prototype.bar = function () { console.log("this.bar"); }

 prototypeプロパティの中身は、newを実行したときにそのままそっくり新しく生成したオブジェクトのプロトタイプに設定される。 ややこしいが、前者は普通のプロパティでC.xなんかと同じ扱いである。 後者は言語が定める特別な属性であり、オブジェクトならば必ず持っている。 前者はprototypeプロパティ、後者はオブジェクトのプロトタイプと呼んで区別することにする。 まとめると、「関数を定義するとprototypeプロパティが自動生成され、newでオプジェクトを生成するとコンストラクタ関数のprototypeプロパティがオブジェクトのプロパティに自動的に設定される」ようになっている。

const c = new C();
c.foo(); // this.foo
c.bar(); // this.bar
console.log(Object.getPrototypeOf(c)); // {bar: f, ...

 JavaScriptはオブジェクトに対して何かを要求すると、まず自身のプロパティ一覧から探す。 なければオブジェクトのプロトタイプから探す。 それでもない場合はどうするか。 オブジェクトのプロトタイプもオブジェクトの一種なので、プロトタイプオブジェクトにさらにプロトタイプをくっつけることができる。 そこで、さらにプロトタイプオブジェクトのプロトタイプを検索する。 これをプロトタイプオブジェクトのプロトタイプがnullになるまで続ける。 通常、すべてのオブジェクトはObjectクラスまでプロトタイプをたどることができるが、Objectオブジェクトのプロトタイプがnullになっており、ここで検索は終了する。 これをプロトタイプチェーンと呼び、JavaScriptではプロトタイプチェーンを使って継承を実現する。

 正直、文字だけで説明するのはプロトタイプが氾濫してしまって無理がある。 「現代のJavaScriptチュートリアル」のプロトタイプ継承あたりとか、「JavaScript入門」のプロトタイプでオブジェクトの継承を実装するあたりを見ると図入りで分かりやすい。

 オブジェクトのプロトタイプはObject.setPrototypeOf静的メソッドを使って明示的に設定できる。

function D() {
	this.y = 3;
	this.z = 4;
}
Object.setPrototypeOf(D.prototype, C.prototype);

ここではD.prototypeには何も設定していないが、関数を定義した時点でprototypeプロパティは自動的に作成されるので、Object.setPrototypeOfは何事もなかったように成功する。

const d = new D();
console.log(d); // D {y: 2, z: 3 }
// d.foo(); → TypeError: d.foo is not a function
d.bar(); // prototype.bar

 ここでプロトタイプに設定した関数と、そうではない関数の差が出る。 barCprototypeプロパティにあり、それをDpropertyプロパティのオブジェクトプロパティに設定している。 newD.prototypeを生成したオブジェクトのプロトタイプに設定するので、プロトタイプチェーンの中にC.prototypeが入っている。 このため、Dbarを定義していなくてもプロトタイプチェーン経由でC.barが見つかる。 一方、fooCのプロパティとしては登録されているが、プロトタイプには登録されていないので、プロトタイプチェーン経由では見つからずエラーになる。

 あらためて実行結果を見てみると、基底クラスCのインスタンスプロパティxが出力されていない。 これは基底クラスのコンストラクタC()を呼んでいないからである。 明示的に呼び出す必要がある。 当然、派生クラスの初期化の前に呼び出す必要がある。

 このとき単純にC()と呼び出してもダメである。 thisが思った値にならない。 C()はグローバル関数なのでそのまま呼び出すとthisはグローバルなthisになる。 つまりWebブラウザの環境ならwindowだし、厳格モードならば未定義である。 それに対し、D()newが呼び出しており、thisをきちんと設定してくれている。 一方、this.C()と書いてもプロトタイプチェーンからC()を見つけられないのでエラーになる。 thisからではなく直接Cを探しつつ、DthisC()に渡す必要がある。 そのためにはcallを使って以下のように書く。

function D() {
	C.call(this)
	this.y = 3;
	this.z = 4;
}
const d = new D();
console.log(d); // D {x: 1, y: 2, z: 3, foo: f}
d.foo(); // this.foo
d.bar(); // prototype.bar

 これで基底クラスのインスタンスプロパティも初期化され、同時にfooもプロパティとして見えるようになっている。 まとめれば以下のようになる。 これをシンタックスシュガーとしてまとめたのがclassキーワードによるクラス定義である。

C(...) { // constructor
	B.call(this, ...); // base class constructor, if you needed
	this.x = 10; // instnace property
}
Object.assign(C, {
	foo() { /* class (static) method */ },
	a: 20 // class (static) property
});
Object.assign(C.prototype, {
	bar() { /* instance method */ }
});
// set base class prototype, if you needed
Object.setPrototypeOf(C.prototype, B.prototype);

ここでObject.assignはオプジェクトにプロパティを上書きしてくれるメソッドである。 もちろん、ちまちまと代入していっても、スプレッド構文を使ってもいい。

Object.createによるクラス

 通常、JavaScriptでオブジェクトを生成する場合はnewを使うが、もうひとつオブジェクトを作る方法がある。 それがObject.createである。 この関数は第1引数に生成するオブジェクトのプロトタイプとなるオブジェクトを指定する。 第2引数もあるのだが、とりあえず使わないのでここでは解説しない。 Object.createを使うと先ほどのクラスCは以下のように書ける。

const C = {
	create() {
		const me = Object.create(C.prototype);
		me.x = 1;
		me.y = 2;
		me.foo = function () { console.log("C.foo"); };
		return me;
	},
	prototype: {
		bar() { console.log("C.bar"); }
}
};

 プロトタイプはprototypeという名前である必要はなく、また、プロトタイプの内容を直接CのメンバとしてC.barのように書いても動かせるが、コンストラクタ関数型クラスとの相互運用を考えるとprototypeに収めておいた方がよい。

 継承にはやはりObject.setPrototypeOfを使う。 ややこしいので実例を先に見た方がいいだろう。

const D = {
	create() {
		const me = Object.create(D.prototype);
		me.y = 3;
		me.z = 4;
		return me;
	},
	prototype: { }
}
Object.setPrototypeOf(D.prototype, C.prototype);

D.prototpyeプロパティのオブジェクトプロトタイプに、C.prototypeそのものを設定している。 C.prototypeはたぶんプロトタイプの体を為したオブジェクトであろうから、これでプロトタイプチェーンがつながる。

 ただし、まだ完全ではない。 基底クラスのコンストラクタを呼んでいない。 今、基底クラスの初期化はC.createで行っているが、これをそのまま呼び出すともう一度オブジェクトが作られてしまうので、オブジェクトを作る部分と初期化する部分を分ける。 初期化する部分がコンストラクタに相当するが、規格とバッティングするのを避けてinitという名前にしてみる。

const C = {
	create() {
		const me = Object.create(C.prototype);
		me.init();
		return me;
	},
	prototype: {
		init() {
			this.x = 1;
			this.y = 2;
			this.foo = function () { console.log("C.foo"); };
		},
		bar() { console.log("C.bar"); }
	}
};
const D = {
	create() {
		const me = Object.create(D.prototype);
		this.init();
		return me;
	},
	prototype: {
		init() {
			super.init();
			// means C.init.call(this);
			this.y = 3;
			this.z = 4;
		}
	}
};
Object.setPrototypeOf(D.prototype, C.prototype);

コード中に書いたが、C.init.call(this)とすることで、クラスCの初期化関数を呼び出せるが、代わりにsuperが使える。 ただし、superが使えるのはこの書き方だけで、コンストラクタ呼び出しとしての使用はできず、また、以下の方法では使えない。

const D = {
	prototype: {
		// CANNOT do this.
		foo: function () { super.foo(); }
	}
};
// CANNOT do this.
D.prototype.bar = function () { super.bar(); }

 さて、CDcreateがほとんど同じである。 何度も書くのは面倒なのでどうにかしたい。 ざっくり、プロトタイプをもらって、上のCDのようなオブジェクトを返す関数を作ればよさそうだ。

を持つオブジェクトを返せばよいので、こんな風に書ける。
function buildClass(base, proto) {
	if (base)
		Object.setPrototypeOf(proto, base.prototype);
	return {
		prototype: proto,
		create(...args) {
			const me = Object.create(proto);
			me.init(...args);
			return me;
		}
	};
}
const C = buildClass(null, {
	init() {
		this.x = 1;
		this.y = 2;
		this.foo = function () { console.log("C.foo"); };
	},
	bar() { console.log("C.bar"); }
});
const D = buildClass(C, {
	init() {
		super.init();
		this.y = 3;
		this.z = 4;
	}
});

クラスのスタティックメンバは返ってきたCDに直接書き足してしまえばよい。

 ただ、このままだと親クラスのスタティックメンバは見えないので、クラスそれ自身のオブジェクトプロトタイプに、親クラスそのものを設定しておく。 これで親クラスのスタティックメンバがプロトタイプ経由で見えるようになる、とりあえず。09 Aug 2024

 まとめるとこんな感じになる。

const C = buildClass(base, {
	init(...arg) {
		super.init(...); // base class constructor, if you needed
		// or if base is ordinal constructor function, do:
		// base.call(this, ...)
		this.x = 10; // instnace property
	},
	bar() { } // instance method
});
Object.assign(C, {
	foo() { }, // class (static) method
	a: 20 // class (static) property
});
Object.setPrototypeOf(C, base);

 これでだいたい使えるようになっているが、コンストラクタ関数型では関数のprototypeプロパティにconstructorというプロパティがあり、自分自身を指している。 現状、コンストラクタ関数がないので、より厳密には定義してやる必要がある。

function buildClass(base, proto) {
	if (base)
		Object.setPrototypeOf(proto, base.prototype);
	proto.constructor = function (...args) {
		this.init(...args);
	};
	proto.constructor.prototype = proto;
	return {
		prototype: proto,
		create(...args) {
			const me = Object.create(proto);
			me.init(...args);
			return me;
		}
	};
}

proto.constructorはこの形で書く必要がある。 functionを使って関数を定義しないとprototypeプロパティが生成されず、コンストラクタとして使えない。 この関数のprototype.constructorはデフォルトでこの関数自身となる。 このコンストラクタの呼び出しでインスタンスを生成できなければならないから、コンストラクタ関数のprototypeプロパティには、今作っているクラスのプロトタイプオブジェクトを設定しておけばよい。

 ただ、コンストラクタを使わないでオブジェクトを作れるようにしたのに、今更コンストラクタなんて、という気もする。 new obj.constructor()を使っているコードのことは忘れて、C.create(...args)をプロトタイプに入れておけばそれでいい気もする。

function buildClass(base, proto) {
	if (base)
		Object.setPrototypeOf(proto, base.prototype);
	proto.create = function (...args) {
		const me = Object.create(proto);
		me.init(...args);
		return me;
	};
	return {
		prototype: proto,
		create: proto.create
	};
}

 もうひとつ問題があり、instanceof演算子が動かない。

const d = D.create();
console.log(d instanceof D); // TypeError: Right-hand side of 'instanceof' is not callable

instanceofの右辺は関数じゃないといけないらしい。 D.prototype.isPrototypeOf(d)で代用できるのだが、やはり不便である。 これはSymbol.hasInstanceというクラスメソッドを定義することで解決できる。 ただ、この名前は普通の関数名ではないので、JSON読んでたらJavaScriptの識別子として使えないキーに遭遇しちゃった場合に使う、[ ]記法を使う。 つまり、この場合はD[Symbol.hasInstance] = ...という形で定義すればよい。

 この関数の引数にはinstanceofの左辺が入ってくるので、instanceofの結果として返したい値を返せばよい。 判断にはObject.isPrototypeOfが使える。 分かりやすく書けば以下のようになる。 なお、先ほどのprototype.createは削除している。

function buildClass(base, proto) {
	if (base)
		Object.setPrototypeOf(proto, base.prototype);
	const cls = {
		prototype: proto
	};
	cls.create = function (...args) {
		const me = Object.create(proto);
		me.init(...args);
		return me;
	};
	cls[Symbol.hasInstance] = function (obj) {
		return proto.isPrototypeOf(obj);
	};
	return cls;
}

アロー関数等使ってえいやっとまとめてしまえば、以下のようになる。

function buildClass(base, proto) {
	if (base)
		Object.setPrototypeOf(proto, base.prototype);
	return {
		prototype: proto,
		create(...args) {
			const me = Object.create(proto);
			me.init(...args);
			return me;
		},
		[Symbol.hasInstance]: obj => proto.isPrototypeOf(obj)
	};
}

このくらいがバランスがいいかな、と思う。

混ぜて使う

 これで実は相互運用が可能になっている。 肝はObject.create型のクラス定義にprototypeを持たせたことで、関数型も見た目がほぼ一緒になるため、基底クラス・派生クラスとも混ぜて使える。

function C() { }
Object.assign(C.prototype, { });

const D = buildClass(C, {
	init() { }
});

function F() { }
Object.assign(F.prototype, { });
Object.setPrototypeOf(F.prototype, D.prototype);

const f = new F();
console.log(f instanceof F); // true
console.log(f instanceof C); // true
console.log(f instanceof D); // true

組み込み型と使う

 組み込み型を基底クラスとすることもできるが、一部のクラスは特殊な動きをするので気をつける必要がある。

Arrayなど

 Arrayはコンストラクタを呼び出すと必ずオブジェクトを作ってしまう。

const A = buildClass(Array, {
	init() { }
});
A.create(...args) = {
	const me = Array(...args);
	Object.setPrototypeOf(me, A.prototype);
	me.init();
	return me;
};

仕方がないのでObject.createの呼び出しはあきらめて、buildClassが作ってくれたコンストラクタを置き換え、Arrayオブジェクトを作ってからObject.setPrototypeOfでプロトタイプを入れ替える。 buildClassArrayのプロトタイプをA.prototypeにぶら下げているはずなので、これでAArrayとしても動く。

 Stringなんかも同類。

WebGLRenderingContextなど

 WebGLRenderingContextCanvasRenderingContext2Dなどはcanvas.getContextを呼び出すことで得られ、コンストラクタは使わない。 これらもArrayと同様に実装できる。

const G = buildClass(WebGLRenderingContext, {
	init() { }
});
G.create(canvas, ...args) = {
	const me = canvas.getContext("webgl", ...args);
	Object.setPrototypeOf(me, G.prototype);
	me.init(...args);
	return me;
};

 オリジナルのオブジェクトを複製せず、そのままプロトタイプを入れ替えるパターンは意外と有用なことが多いので、buildClassでクラスメソッドを定義してもいいだろう。関数名はtakeoverにしてある。09 Aug 2024

function buildClass(base, proto) {
	...
	return {
		...
		takeover(me, ...args) {
			Object.setPrototypeOf(me, proto).init(...args);
			return me;
		},
		...
	};
}
G.create(canvas, ...args) = {
	return G.takeover(canvas.getContext("webgl", ...args));;
};

new・create兼用 11 Aug 2024

 コンストラクタ関数には普通にプロパティがあり、関数も設定できる。また、newが生成した「生」のオブジェクトを受け取るが、このオブエクトのプロトタイプには既にコンストラクタ関数のprototypeプロパティが設定されている。 ということは、以下のような半ばデタラメのようなことができる。

function F(...args) {
	this.init(...args);
}
F.prototype = {
	init(...args) { ... }
};
F.create = function (...args) {
	const me = Object.create(F.prototype);
	me.init(...args);
	return me;
}

これでnew F()でもF.create()でも動いてしまう。 createはもっと簡単に、

F.create = function (...args) {
	return new this(...args);
}

でいい。 ここまでするなら普通にコンストラクタ関数型でいいじゃないか、という気もする。 create型のクラスの何がメリットだったかを考えると、buildClassが全部やってくれることだった。 ならば、コンストラクタ関数型でも全部やってくれる関数を用意すればよいのではないか?

function functionClass(base, proto) {
	function F(...args) { this.init(...args); }
	if (base) {
		Object.setPrototypeOf(F, base);
		Object.setPrototypeOf(proto, base.prototype);
	}
	Object.assign(F, {
		prototype: proto,
		...proto.staticMembers
	});
	F.prototype.constructor = F;
	return F;
}

const C = functionClass(null, { ... });

これで一応動くのだが、コンストラクタ関数の内容が固定になってしまうのと、コンストラクタ関数の場合、関数名、つまりクラス名をnameプロパティで得られるが、関数名が必ずFになってしまうので生成したクラスのクラス名がすべてFになってしまう。

 そこで、コンストラクタ関数だけは外で定義することにすると、

function functionClass(func, base, proto) {
	if (base) {
		Object.setPrototypeOf(func, base);
		Object.setPrototypeOf(proto, base.prototype);
	}
	Object.assign(func, {
		prototype: proto,
		...proto.staticMembers
	});
	func.prototype.constructor = func;
}

function C(...args) { this.init(...args); }
functionClass(C, null, { ... });

こんな感じに使える。 create型と比べると、クラス作成が関数定義とクラス化の2段階になってしまうが、クラス名を得られるのがコンストラクタ型クラス宣言のメリットである。当然、instanceofも小細工なしで普通に動く。

 実はクラスの中から例外を投げるときにクラス名をメッセージに入れようと思ったのがこの記事を書くことになった発端。 create型だとbuildClassはクラス名を決定する情報を一切受け取っていないので、そのままではどうやってもユーザーが決めた名前が分からない。 ユーザーが明示的にクラス名を渡す必要があるが、buildClassの結果を保持する変数名と二重に書く必要があるし、どちらか一方だけを修正して他方の修正を忘れると、要らぬ混乱を招く元になる。

 で、今のところの成果をまとめるとこうなる。

function funclass(func, base, proto) {
	func.prototype = proto;
	proto.constructor = func;
	proto.factory = {
		create(...args) {
			return new func(...args);
		},
		takeover(me, ...args) {
			Object.setPrototypeOf(me, proto).init(...args);
			return me;
		},
		...proto.factory
	};
	Object.assign(func, proto.factory);
	if (base) {
		Object.setPrototypeOf(func, base);
		Object.setPrototypeOf(proto, base.prototype);
	}
}

function buildClass(base, proto) {
	if (proto.constructor == null) {
		proto.constructor = function (...args) {
			this.init(...args);
		};
		proto.constructor.prototype = proto;
	}
	proto.factory = {
		prototype: proto,
		create(...args) {
			const me = Object.create(proto);
			me.init(...args);
			return me;
		},
		takeover(me, ...args) {
			Object.setPrototypeOf(me, proto).init(...args);
			return me;
		},
		...proto.factory
	};
	Object.defineProperty(proto.factory, Symbol.hasInstance, {
		value: obj => proto.isPrototypeOf(obj)
	});
	if (base) {
		Object.setPrototypeOf(proto.factory, base);
		Object.setPrototypeOf(proto, base.prototype);
	}
	return proto.factory;
}

 functionClassは動詞・目的語ルールから外れてるし、classの頭文字cがfunctionのいいところに出てくるので、funclassという名前にしてみた。 静的メンバはproto.factoryの中に収めている。 これは静的メンバには必ず createが含まれるからで、本来はproto.staticみたいな名前の方がいいのだろうが、JavaScriptの構文要素として使われている単語だし、メンバー名として用いることに問題はないが避けることにした。

 funclassbuildClassとも、引数protofactoryはシャローコピーされる。 createなどの関数はユーザーが定義した内容で上書きされるようになっている。 つまり、protoの書き方次第でcreateconstructortakeoverはカスタマイズ可能である。

 ちょっと前の更新ではprotoそれ自体もシャローコピーしていたのだが、そうするとsuperキーワードが効かなくなる。 もしかするとちゃんとsuperが効くコピーの方法もあるのかもしれないが、とりあえず今は与えられたオブジェクトをそのまま使うしかない。 呼び出し側でプロトタイプを使いまわさないように注意する必要がある。

 Symbol.hasInstanceメンバーがdefinePropertyによる設定になっているが、これはこの記事を作成中にcreate型の静的メンバをコンストラクタ関数にごっそりコピーしようとしたら、Symbol.hasInstanceメンバーが上書きできない、と怒られたためである。 definePropertyを使うとデフォルトで列挙不可(かつ設定不可、変更不可)になる。

 このふたつのクラスでできることをまとめると以下のようになる。

コンストラクタ関数型Object.create型
クラス定義funclassbuildClass
new×
new this.constructor
create
this.factory.create
ワンライナー×

つまり、コンストラクタ関数型の方ができることが多いのだが、create型にもひとつだけメリットがあり、それは「newによるオブジェクト生成を避けられる」ことである。 配列を継承したい場合、どうしてもArray()を呼んだ時に配列ができてしまうので、それをtakeoverするとnewが作ったオブジェクトは捨てるしかない。 静的解析で「newは不要だ」と判断することはある程度は可能なので、JavaScriptエンジンで最適化される可能性はあるが、動的にObject.createする場合とtakeoverする場合が分かれる場合はエンジン側の最適化はできない。 この手のオブジェクトが大量に生成されると、その倍のオブジェクトが生成されて、半分は生まれてすぐにガベージコレクションの対象になるだけである。 create型はこれを避けることができる。

const C = buildClass(Array, {
factory: {
create(...args) {
	if (args[0] instanceof D)
		return args[0].clone();
	return this.takeover(Array(...args));
}
},
init() { ... },
clone() { ... }
});

C++言語と比べて

 C++言語からJavaScriptのクラス、特に継承まわりを見たときに、大きく違うのは以下の4点だと思う。

 例えば、「石橋で叩いて渡るGL」では、まずWebGLRenderingContextを便利に使うコード集としてglUtilを作り、さらにそれをオブジェクト指向っぽくしてVanillaGLクラスを作っている。 glUtilはクラスというより名前空間に近い。 この中にlinkProgramという関数がある。 この関数は3つのクラス全部に実装されているが、それぞれ若干仕様が違っていて、そのために引数の型が違う。 WebGLRenderingContextでは最初の引数はGLProgramである。 glUtilではWebGLRenderingContextである。 VanillaGLではWebGLShaerである。

void             WebGLRenderingContext.linkProgram (WebGLProgram prog);
Object           glUtil.linkProgram                (WebGLRenderingContext gl, WebGLShader vs, WebGLShader, fs);
VanillaGLProgram VanillaGL.linkProgram             (WebGLShader vs, WebGLShader, fs);

 glUtilには基底クラスはなく、そのためメソッドを呼び出す際は多くの場合WebGLRenderingContextを指定してやる必要がある。 linkProgramの場合は戻り値もその場で適当に作ったオブジェクトで、その中にWebGLProgramが入っている。 一方、VanillaGLWebGLRenderingContextを基底クラスとしている。

 先に作ったglUtil.linkProgramWebGLRenderingContext型の引数glを使って、gl.linkProgramを呼び出す。 後から作ったVanillaGL.linkProgramはこれは便利とばかりにglUtil.linkProgramを呼び出す。 glUtil.linkProgramの第1引数glWebGLRenderingContextだが、VanillaGLWebGLRenderingContextを継承しているのでthisをそのまま渡せる。 ここまではC++もJavaScriptも似たように進む。

 違うのはこの先である。 glUtilは後からVanillaGLができたことを知らないから、何も考えずにgl.linkProgramを呼び出す。 ところがgl引数は実はVanillaGLなので、JavaScriptの場合はVanillaGL.linkProgramを呼び出してしまう。 無限再帰のできあがりである。

 C++の場合、glUtil.linkProgram呼び出し時にVanillaGLのインスタンスはWebGLRenderingContextにキャストされ、linkProgramが仮想関数でなければglUtilは必ず引数に書かれたクラスであるWebGLRenderingContext.linkProgramを呼び出す。 仮想関数であった場合でも、引数型が違えば違う関数として認識されるので、オーバーロードで解決することが可能である。

 一方、JavaScriptの場合はそもそもキャストの概念がない。 キャストできたところで常に仮想関数のように振る舞うので、必ずVanillaGL.linkProgramを呼び出してしまう。 しかも、引数型が宣言になく、言語としてオーバーロードの仕様がないので、クラス内に同じ名前の関数はひとつしか存在しない。派生クラス側の関数が呼ばれると決まった時点で再帰呼び出し確定である。

 引数型がWebGLRenderingContextVanillaGLで違うのならば、受け取った側で型を調べることができるので、VanillaGL側で引数の数と型を調べて分岐し、第1引数がWebGLProgramであればWebGLRenderingContextの関数を呼び出せばよい。 そしてこれがJavaScriptでのオーバーロードの実現方法である。 VanillaGL.linkProgramもこの方法で実装している。

 呼び出し方はsuper.linkProgram(vs);のようになる。 先に書いたように、オブジェクトリテラル形式でも書き方によってはsuperが使える。 もしsuperを使わないのなら、プロトタイプから引っ張ってくればいいので、 WebGLRenderingContext.prototype.linkProgram.call(this, vs) のようになる。 WebGLRenderingContext自体はコンストラクタ関数であり、静的メンバしか持っていない。 インスタンスメソッドはコンストラクタ関数のprototypeプロパティを見る必要がある。 そしてcallthisを強制的に置き換えて呼び出す。

 JavaScriptのthisはかなりいい加減で、関数呼び出しの前に何かオブジェクトがくっついている場合は、原則としてそのオブジェクトがthisである(例外はアロー関数)。 この場合は放っておくと WebGLRenderingContext.prototypethisになる。 これはほとんどの場合で意図したものではないため、 WebGLRenderingContext.linkProgram を呼び出すときに、callで強制的にVanillaGLオブジェクトをthisに設定して呼び出す。 VanillaGLクラスはWebGLRenderingContextクラスを継承しているため、これで問題なく動作する。

 callFunctionクラスのメソッドで、関数ならば必ずこのメソッドを持っている。 この関数を使えばどんなオブジェクトでもthisに設定できてしまう。 ある意味これがC++のキャストに相当するが、キャストもへったくれもなく、好きなように呼び出し先のthisを設定できる。 いい具合にいい加減である。

 言い方を変えると、super.f(...args)は、基底クラスがBならば、B.prototype.f.call(this, ...args)のシンタックスシュガーであるとも言えそうだ。

 C++でも、linkProgramが仮想関数で、派生クラス・基底クラスで引数型が同じだった場合は、基底クラスにキャストしても派生クラス側のlinkProgramが再帰呼び出しされる(仮想関数だからね)。 glUtil側に修正を加えていいのならば、gl.WebGLRenderingContext::linkProgramと書けば、linkProgramが仮想関数であったとしてもWebGLRenderingContextlinkProgramが呼び出される。 glUtil側を修正できない場合は、JavaScript同様、派生クラスであるVanillaGL側で対策をする必要がある。

 考えられるのは、VanillaGLに「linkProgram実行中」というフラグを設けておいて、フラグが立っていなければフラグを立てた上でglUtillinkProgramを呼ぶ。立っていればWebGLRenderingContextlinkProgramを呼び出してフラグを落とす、だろう。 これはJavaScriptでもC++でも同様に実装できる。


15 Jul 2024: 初稿

Copyright (C) 2024 akamoz.jp