Ich habe durchaus die Funktion, Klassen mit Mehrfachvererbung definieren zu können. Es ermöglicht Code wie den folgenden. Insgesamt werden Sie eine vollständige Abweichung von den nativen Klassifizierungstechniken in Javascript feststellen (z. B. werden Sie das class
Schlüsselwort nie sehen ):
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();
um eine Ausgabe wie diese zu erzeugen:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
So sehen die Klassendefinitionen aus:
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();
}
}));
Wir können sehen, dass jede Klassendefinition, die die makeClass
Funktion verwendet, einen Object
der übergeordneten Klassennamen akzeptiert , die übergeordneten Klassen zugeordnet sind. Es akzeptiert auch eine Funktion, die Object
enthaltende Eigenschaften für die zu definierende Klasse zurückgibt . Diese Funktion hat einen Parameterprotos
, der genügend Informationen enthält, um auf eine Eigenschaft zuzugreifen, die von einer der übergeordneten Klassen definiert wurde.
Das letzte Stück, das benötigt wird, ist die makeClass
Funktion selbst, die ziemlich viel Arbeit leistet. Hier ist es, zusammen mit dem Rest des Codes. Ich habe makeClass
ziemlich stark kommentiert :
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();
Die makeClass
Funktion unterstützt auch Klasseneigenschaften. Diese werden definiert, indem Eigenschaftsnamen mit dem $
Symbol versehen werden (beachten Sie, dass der endgültige Eigenschaftsname, der sich ergibt, $
entfernt wird). In diesem Sinne könnten wir eine spezielle Dragon
Klasse schreiben , die den "Typ" des Drachen modelliert, wobei die Liste der verfügbaren Drachentypen in der Klasse selbst gespeichert wird, im Gegensatz zu den Instanzen:
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 });
Die Herausforderungen der Mehrfachvererbung
Jeder, der den Code makeClass
genau befolgt hat, wird ein ziemlich bedeutendes unerwünschtes Phänomen bemerken, das stillschweigend auftritt, wenn der obige Code ausgeführt wird: Das Instanziieren von a RunningFlying
führt zu ZWEI Aufrufen des Named
Konstruktors!
Dies liegt daran, dass das Vererbungsdiagramm folgendermaßen aussieht:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Wenn in einem Vererbungsdiagramm einer Unterklasse mehrere Pfade zu derselben übergeordneten Klasse vorhanden sind Klasse vorhanden sind, rufen Instanziierungen der Unterklasse den Konstruktor dieser übergeordneten Klasse mehrmals auf.
Dies zu bekämpfen ist nicht trivial. Schauen wir uns einige Beispiele mit vereinfachten Klassennamen an. Wir betrachten die Klasse A
, die abstrakteste Elternklasse, die Klassen B
und C
, die beide erben A
, und die Klasse, BC
die von B
und erbt C
(und daher konzeptionell "doppelt erbt" von 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');
}
}));
Wenn wir verhindern möchten, dass BC
doppelt aufgerufen wird, müssen A.prototype.init
wir möglicherweise den Stil des direkten Aufrufs geerbter Konstruktoren aufgeben. Wir benötigen ein gewisses Maß an Indirektion, um zu überprüfen, ob doppelte Anrufe auftreten, und um sie kurzzuschließen, bevor sie auftreten.
Wir konnten betrachten die Parameter an die Funktion Eigenschaften geliefert zu ändern: neben protos
, eine Object
Rohdaten beschreiben vererbten Eigenschaften enthält, könnten wir auch eine Nutzenfunktion eine Instanz Methode so für den Aufruf , dass Eltern Methoden auch genannt werden, aber doppelte Anrufe werden erkannt und verhindert. Werfen wir einen Blick darauf, wo wir die Parameter für Folgendes festlegen 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 ... */
};
Der gesamte Zweck der obigen Änderung makeClass
besteht darin, dass wir ein zusätzliches Argument erhalten, propertiesFn
wenn wir uns aufrufen makeClass
. Wir sollten auch bewusst sein , dass jede Funktion in jeder Klasse definiert nun einen Parameter , nachdem alle seine anderen empfangen kann, mit dem Namen dup
, der eine ist , Set
die alle Funktionen enthält , die bereits als Folge genannt wurden , von der geerbten Methode aufrufen:
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');
}
}));
Mit diesem neuen Stil kann tatsächlich sichergestellt "Construct A"
werden, dass nur einmal protokolliert wird, wenn eine Instanz von BC
initialisiert wird. Es gibt jedoch drei Nachteile, von denen der dritte sehr kritisch ist :
- Dieser Code ist weniger lesbar und wartbar geworden. Hinter der
util.invokeNoDuplicates
Funktion verbirgt sich eine Menge Komplexität , und darüber nachzudenken, wie dieser Stil Mehrfachaufrufe vermeidet, ist nicht intuitiv und verursacht Kopfschmerzen. Wir haben auch diesen lästigen dups
Parameter, der wirklich für jede einzelne Funktion in der Klasse definiert werden muss . Autsch.
- Dieser Code ist langsamer - viel mehr Indirektion und Berechnung sind erforderlich, um wünschenswerte Ergebnisse bei Mehrfachvererbung zu erzielen. Leider ist dies wahrscheinlich bei jeder Lösung unseres Problems mit mehreren Aufrufen der Fall .
- Am wichtigsten ist, dass die Struktur von Funktionen, die auf Vererbung beruhen, sehr starr geworden ist . Wenn eine Unterklasse
NiftyClass
eine Funktion überschreibt niftyFunction
und util.invokeNoDuplicates(this, 'niftyFunction', ...)
sie ohne doppelten Aufruf ausführt, NiftyClass.prototype.niftyFunction
ruft sie die Funktion niftyFunction
jeder übergeordneten Klasse auf, die sie definiert, ignoriert alle Rückgabewerte dieser Klassen und führt schließlich die spezielle Logik von aus NiftyClass.prototype.niftyFunction
. Dies ist die einzig mögliche Struktur . Wenn NiftyClass
erbt CoolClass
und GoodClass
und beide übergeordneten Klassen niftyFunction
eigene Definitionen bereitstellen , NiftyClass.prototype.niftyFunction
wird es niemals (ohne das Risiko eines Mehrfachaufrufs) möglich sein:
- A. Führen Sie zuerst die spezialisierte Logik von
NiftyClass
und dann die spezialisierte Logik von Elternklassen aus
- B. Führen Sie die spezialisierte Logik
NiftyClass
an einem anderen Punkt aus, als nachdem alle spezialisierten übergeordneten Logik abgeschlossen wurde
- C. Verhalten Sie sich bedingt abhängig von den Rückgabewerten der speziellen Logik des übergeordneten Elements
- D. Vermeiden Sie eine bestimmte Mutter laufen ist spezialisiert
niftyFunction
insgesamt
Natürlich könnten wir jedes der oben genannten Probleme lösen, indem wir spezielle Funktionen definieren unter util
:
- A. definieren
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. define
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
(Wo parentName
ist der Name des Elternteils, auf dessen spezialisierte Logik unmittelbar die spezialisierte Logik der Kinderklassen folgt?)
- C. define
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(In diesem Fall testFn
würde das Ergebnis der speziellen Logik für das genannte übergeordnete Element empfangen parentName
und ein true/false
Wert zurückgegeben, der angibt, ob der Kurzschluss auftreten sollte.)
- D. define
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(In diesem Fall handelt blackList
es sich um einen Array
übergeordneten Namen, dessen spezielle Logik insgesamt übersprungen werden sollte.)
Diese Lösungen sind alle verfügbar, aber das ist totales Chaos ! Für jede eindeutige Struktur, die ein geerbter Funktionsaufruf annehmen kann, benötigen wir eine spezielle Methode, die unter definiert ist util
. Was für eine absolute Katastrophe.
Vor diesem Hintergrund können wir die Herausforderungen der Implementierung einer guten Mehrfachvererbung erkennen. Die vollständige Implementierung von makeClass
I in dieser Antwort berücksichtigt nicht einmal das Problem des Mehrfachaufrufs oder viele andere Probleme, die im Zusammenhang mit der Mehrfachvererbung auftreten.
Diese Antwort wird sehr lang. Ich hoffe, dass die makeClass
Implementierung, die ich aufgenommen habe, immer noch nützlich ist, auch wenn sie nicht perfekt ist. Ich hoffe auch, dass jeder, der sich für dieses Thema interessiert, mehr Kontext gewonnen hat, um sich beim weiteren Lesen daran zu erinnern!