私は、クラスを多重継承で定義できるようにするかなりの機能を持っています。次のようなコードが可能です。全体的に、JavaScriptのネイティブの分類手法からの完全な逸脱に気付くでしょう(たとえば、class
キーワードが表示されることはありません)。
let human = new Running({ name: 'human', numLegs: 2 });
human.run();
let airplane = new Flying({ name: 'airplane', numWings: 2 });
airplane.fly();
let dragon = new RunningFlying({ name: 'dragon', numLegs: 4, numWings: 6 });
dragon.takeFlight();
このような出力を生成するには:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
クラス定義は次のようになります。
let Named = makeClass('Named', {}, () => ({
init: function({ name }) {
this.name = name;
}
}));
let Running = makeClass('Running', { Named }, protos => ({
init: function({ name, numLegs }) {
protos.Named.init.call(this, { name });
this.numLegs = numLegs;
},
run: function() {
console.log(`${this.name} runs with ${this.numLegs} legs.`);
}
}));
let Flying = makeClass('Flying', { Named }, protos => ({
init: function({ name, numWings }) {
protos.Named.init.call(this, { name });
this.numWings = numWings;
},
fly: function( ){
console.log(`${this.name} flies away with ${this.numWings} wings!`);
}
}));
let RunningFlying = makeClass('RunningFlying', { Running, Flying }, protos => ({
init: function({ name, numLegs, numWings }) {
protos.Running.init.call(this, { name, numLegs });
protos.Flying.init.call(this, { name, numWings });
},
takeFlight: function() {
this.run();
this.fly();
}
}));
makeClass
関数を使用する各クラス定義は、Object
親クラスにマップされた親クラスの名前を受け入れることがわかります。またObject
、定義されているクラスの包含プロパティを返す関数も受け入れます。この関数にはパラメータがありますprotos
、親クラスのいずれかによって定義されたプロパティにアクセスするための十分な情報を含むがあります。
最後に必要なのは、makeClass
かなりの作業を行う関数自体です。ここに、残りのコードがあります。私はmakeClass
かなりたくさんコメントしました:
let makeClass = (name, parents={}, propertiesFn=()=>({})) => {
// The constructor just curries to a Function named "init"
let Class = function(...args) { this.init(...args); };
// This allows instances to be named properly in the terminal
Object.defineProperty(Class, 'name', { value: name });
// Tracking parents of `Class` allows for inheritance queries later
Class.parents = parents;
// Initialize prototype
Class.prototype = Object.create(null);
// Collect all parent-class prototypes. `Object.getOwnPropertyNames`
// will get us the best results. Finally, we'll be able to reference
// a property like "usefulMethod" of Class "ParentClass3" with:
// `parProtos.ParentClass3.usefulMethod`
let parProtos = {};
for (let parName in parents) {
let proto = parents[parName].prototype;
parProtos[parName] = {};
for (let k of Object.getOwnPropertyNames(proto)) {
parProtos[parName][k] = proto[k];
}
}
// Resolve `properties` as the result of calling `propertiesFn`. Pass
// `parProtos`, so a child-class can access parent-class methods, and
// pass `Class` so methods of the child-class have a reference to it
let properties = propertiesFn(parProtos, Class);
properties.constructor = Class; // Ensure "constructor" prop exists
// If two parent-classes define a property under the same name, we
// have a "collision". In cases of collisions, the child-class *must*
// define a method (and within that method it can decide how to call
// the parent-class methods of the same name). For every named
// property of every parent-class, we'll track a `Set` containing all
// the methods that fall under that name. Any `Set` of size greater
// than one indicates a collision.
let propsByName = {}; // Will map property names to `Set`s
for (let parName in parProtos) {
for (let propName in parProtos[parName]) {
// Now track the property `parProtos[parName][propName]` under the
// label of `propName`
if (!propsByName.hasOwnProperty(propName))
propsByName[propName] = new Set();
propsByName[propName].add(parProtos[parName][propName]);
}
}
// For all methods defined by the child-class, create or replace the
// entry in `propsByName` with a Set containing a single item; the
// child-class' property at that property name (this also guarantees
// there is no collision at this property name). Note property names
// prefixed with "$" will be considered class properties (and the "$"
// will be removed).
for (let propName in properties) {
if (propName[0] === '$') {
// The "$" indicates a class property; attach to `Class`:
Class[propName.slice(1)] = properties[propName];
} else {
// No "$" indicates an instance property; attach to `propsByName`:
propsByName[propName] = new Set([ properties[propName] ]);
}
}
// Ensure that "init" is defined by a parent-class or by the child:
if (!propsByName.hasOwnProperty('init'))
throw Error(`Class "${name}" is missing an "init" method`);
// For each property name in `propsByName`, ensure that there is no
// collision at that property name, and if there isn't, attach it to
// the prototype! `Object.defineProperty` can ensure that prototype
// properties won't appear during iteration with `in` keyword:
for (let propName in propsByName) {
let propsAtName = propsByName[propName];
if (propsAtName.size > 1)
throw new Error(`Class "${name}" has conflict at "${propName}"`);
Object.defineProperty(Class.prototype, propName, {
enumerable: false,
writable: true,
value: propsAtName.values().next().value // Get 1st item in Set
});
}
return Class;
};
let Named = makeClass('Named', {}, () => ({
init: function({ name }) {
this.name = name;
}
}));
let Running = makeClass('Running', { Named }, protos => ({
init: function({ name, numLegs }) {
protos.Named.init.call(this, { name });
this.numLegs = numLegs;
},
run: function() {
console.log(`${this.name} runs with ${this.numLegs} legs.`);
}
}));
let Flying = makeClass('Flying', { Named }, protos => ({
init: function({ name, numWings }) {
protos.Named.init.call(this, { name });
this.numWings = numWings;
},
fly: function( ){
console.log(`${this.name} flies away with ${this.numWings} wings!`);
}
}));
let RunningFlying = makeClass('RunningFlying', { Running, Flying }, protos => ({
init: function({ name, numLegs, numWings }) {
protos.Running.init.call(this, { name, numLegs });
protos.Flying.init.call(this, { name, numWings });
},
takeFlight: function() {
this.run();
this.fly();
}
}));
let human = new Running({ name: 'human', numLegs: 2 });
human.run();
let airplane = new Flying({ name: 'airplane', numWings: 2 });
airplane.fly();
let dragon = new RunningFlying({ name: 'dragon', numLegs: 4, numWings: 6 });
dragon.takeFlight();
makeClass
この関数は、クラスのプロパティをサポートしています。これらは、プロパティ名の前に$
記号を付けることによって定義されます(結果の最終的なプロパティ名は$
削除されることに注意してください)。これを念頭に置いて、Dragon
ドラゴンの「タイプ」をモデル化する特殊なクラスを作成できます。使用可能なドラゴンタイプのリストは、インスタンスではなくクラス自体に格納されます。
let Dragon = makeClass('Dragon', { RunningFlying }, protos => ({
$types: {
wyvern: 'wyvern',
drake: 'drake',
hydra: 'hydra'
},
init: function({ name, numLegs, numWings, type }) {
protos.RunningFlying.init.call(this, { name, numLegs, numWings });
this.type = type;
},
description: function() {
return `A ${this.type}-type dragon with ${this.numLegs} legs and ${this.numWings} wings`;
}
}));
let dragon1 = new Dragon({ name: 'dragon1', numLegs: 2, numWings: 4, type: Dragon.types.drake });
let dragon2 = new Dragon({ name: 'dragon2', numLegs: 4, numWings: 2, type: Dragon.types.hydra });
多重継承の課題
makeClass
上記のコードを実行すると、コードを注意深くフォローした人なら誰でも、サイレントに発生するかなり重大な望ましくない現象に気付くでしょう。インスタンス化するRunningFlying
と、Named
コンストラクターが2回呼び出されます。
これは、継承グラフが次のようになるためです。
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
サブクラスの継承グラフに同じ親クラスへの複数のパスがある場合、サブクラスのインスタンス化により、その親クラスのコンストラクターが複数回呼び出されます。
これと戦うことは簡単ではありません。簡略化されたクラス名を使用した例をいくつか見てみましょう。私たちは、クラスを検討しましょうA
、最も抽象親クラス、クラスB
とC
、両方から継承A
し、クラスをBC
どの継承からB
とC
(したがって、概念的に「ダブル・継承」からA
):
let A = makeClass('A', {}, () => ({
init: function() {
console.log('Construct A');
}
}));
let B = makeClass('B', { A }, protos => ({
init: function() {
protos.A.init.call(this);
console.log('Construct B');
}
}));
let C = makeClass('C', { A }, protos => ({
init: function() {
protos.A.init.call(this);
console.log('Construct C');
}
}));
let BC = makeClass('BC', { B, C }, protos => ({
init: function() {
// Overall "Construct A" is logged twice:
protos.B.init.call(this); // -> console.log('Construct A'); console.log('Construct B');
protos.C.init.call(this); // -> console.log('Construct A'); console.log('Construct C');
console.log('Construct BC');
}
}));
BC
二重呼び出しA.prototype.init
を防ぐには、継承されたコンストラクターを直接呼び出すスタイルを放棄する必要がある場合があります。重複した呼び出しが発生しているかどうかを確認し、発生する前に短絡するために、ある程度の間接参照が必要になります。
プロパティ関数に提供されるパラメーターを変更することを検討することができます:継承されたプロパティを説明する生データを含むとともにprotos
、Object
親メソッドも呼び出されるようにインスタンスメソッドを呼び出すためのユーティリティ関数を含めることもできますが、重複した呼び出しが検出されますと防止。のパラメーターを設定する場所を見てみましょうpropertiesFn
Function
。
let makeClass = (name, parents, propertiesFn) => {
/* ... a bunch of makeClass logic ... */
// Allows referencing inherited functions; e.g. `parProtos.ParentClass3.usefulMethod`
let parProtos = {};
/* ... collect all parent methods in `parProtos` ... */
// Utility functions for calling inherited methods:
let util = {};
util.invokeNoDuplicates = (instance, fnName, args, dups=new Set()) => {
// Invoke every parent method of name `fnName` first...
for (let parName of parProtos) {
if (parProtos[parName].hasOwnProperty(fnName)) {
// Our parent named `parName` defines the function named `fnName`
let fn = parProtos[parName][fnName];
// Check if this function has already been encountered.
// This solves our duplicate-invocation problem!!
if (dups.has(fn)) continue;
dups.add(fn);
// This is the first time this Function has been encountered.
// Call it on `instance`, with the desired args. Make sure we
// include `dups`, so that if the parent method invokes further
// inherited methods we don't lose track of what functions have
// have already been called.
fn.call(instance, ...args, dups);
}
}
};
// Now we can call `propertiesFn` with an additional `util` param:
// Resolve `properties` as the result of calling `propertiesFn`:
let properties = propertiesFn(parProtos, util, Class);
/* ... a bunch more makeClass logic ... */
};
上記の変更の目的は、を呼び出すときにmakeClass
追加の引数が提供されるようpropertiesFn
にすることmakeClass
です。また、任意のクラスで定義されたすべての関数dup
は、Set
と名付けられた他のすべての後にパラメータを受け取る可能性があることにも注意してください。
let A = makeClass('A', {}, () => ({
init: function() {
console.log('Construct A');
}
}));
let B = makeClass('B', { A }, (protos, util) => ({
init: function(dups) {
util.invokeNoDuplicates(this, 'init', [ /* no args */ ], dups);
console.log('Construct B');
}
}));
let C = makeClass('C', { A }, (protos, util) => ({
init: function(dups) {
util.invokeNoDuplicates(this, 'init', [ /* no args */ ], dups);
console.log('Construct C');
}
}));
let BC = makeClass('BC', { B, C }, (protos, util) => ({
init: function(dups) {
util.invokeNoDuplicates(this, 'init', [ /* no args */ ], dups);
console.log('Construct BC');
}
}));
この新しいスタイルは"Construct A"
、のインスタンスBC
が初期化されるときに一度だけログに記録されることを確実に成功させます。しかし、3つの欠点があります。3番目の欠点は非常に重要です。
- このコードは、可読性と保守性が低下しています。多くの複雑さが
util.invokeNoDuplicates
関数の背後に隠れており、このスタイルがマルチ呼び出しを回避する方法について考えることは非直感的で頭痛の種になります。また、その厄介なdups
パラメータもあります。これは、クラス内のすべての単一の関数で実際に定義する必要があります。痛い。
- このコードは低速です。多重継承で望ましい結果を得るには、かなり多くの間接参照と計算が必要です。残念ながら、これはの場合である可能性が高い任意の私たちの複数の呼び出しの問題を解決。
- 最も重要なのは、継承に依存する関数の構造が非常に厳格になったことです。サブクラス
NiftyClass
が関数をオーバーライドする場合niftyFunction
util.invokeNoDuplicates(this, 'niftyFunction', ...)
重複呼び出しなしでそれを実行するために使用する場合、はそれを定義するすべての親クラスのNiftyClass.prototype.niftyFunction
名前付き関数を呼び出し、niftyFunction
それらのクラスからの戻り値を無視して、最後にの特殊なロジックを実行しNiftyClass.prototype.niftyFunction
ます。これが唯一可能な構造です。とをNiftyClass
継承CoolClass
しGoodClass
、これらの親クラスの両方niftyFunction
が独自の定義を提供する場合、NiftyClass.prototype.niftyFunction
(複数呼び出しのリスクがない限り)次のことはできません。
- A.
NiftyClass
最初に専用ロジックを実行し、次に親クラスの専用ロジックを実行する
- B.すべての特殊化された親ロジックが完了した後
NiftyClass
以外の任意の時点で特殊化されたロジックを実行する
- C.親の専用ロジックの戻り値に応じて条件付きで動作する
- D.特定の親に特化したものを
niftyFunction
完全に実行しない
もちろん、以下の特殊な関数を定義することにより、上記の各文字の問題を解決できますutil
。
- A.定義する
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B.定義
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
(ここparentName
で、特殊化されたロジックの直後に子クラスの特殊化されたロジックが続く親の名前)
- C. 定義する
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(この場合testFn
、という名前の親の特殊なロジックの結果を受け取り、短絡が発生するかどうかを示す値をparentName
返しtrue/false
ます)
- D.定義
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(この場合blackList
はArray
、特別なロジックを完全にスキップする必要がある親の名前になります)
これらのソリューションはすべて利用可能です。 が、これは完全な騒動です!継承された関数呼び出しが取ることができるすべての一意の構造について、で定義された特殊なメソッドが必要になりますutil
。なんと絶対的な災害でしょう。
これを念頭に置いて、適切な多重継承を実装する際の課題を見始めることができます。makeClass
この回答で提供した完全な実装では、複数呼び出しの問題や、多重継承に関して発生する他の多くの問題も考慮されていません。
この答えは非常に長くなっています。私がmakeClass
含めた実装は、完全ではない場合でも、まだ有用であることを願っています。また、このトピックに関心のある方は、本を読んでいる間、心に留めておくためのより多くのコンテキストを得ていただければ幸いです。