Ho abbastanza la funzione di consentire alle classi di essere definite con ereditarietà multipla. Permette codice come il seguente. Complessivamente noterai una completa deviazione dalle tecniche di classificazione native in javascript (ad esempio non vedrai mai la classparola chiave):
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();
per produrre output in questo modo:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
Ecco come appaiono le definizioni delle classi:
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();
}
}));
Possiamo vedere che ogni definizione di classe usando la makeClassfunzione accetta un nome Objectdi classe genitore associato a classi genitore. Accetta anche una funzione che restituisce una Objectproprietà contenente per la classe che viene definita. Questa funzione ha un parametro protos, che contiene informazioni sufficienti per accedere a qualsiasi proprietà definita da una delle classi principali.
L'ultimo pezzo richiesto è la makeClassfunzione stessa, che fa un bel po 'di lavoro. Eccolo, insieme al resto del codice. Ho commentato makeClassabbastanza pesantemente:
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();
La makeClassfunzione supporta anche le proprietà di classe; questi sono definiti prefissando i nomi delle proprietà con il $simbolo (si noti che il nome della proprietà finale risultante verrà $rimosso). Con questo in mente, potremmo scrivere una Dragonclasse specializzata che modella il "tipo" del Drago, dove l'elenco dei tipi di Drago disponibili è memorizzato nella Classe stessa, al contrario delle istanze:
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 });
Le sfide dell'ereditarietà multipla
Chiunque abbia seguito da makeClassvicino il codice noterà un fenomeno indesiderabile piuttosto significativo che si verifica silenziosamente quando viene eseguito il codice sopra: la creazione di un'istanza RunningFlyingcomporterà DUE chiamate al Namedcostruttore!
Questo perché il grafico dell'ereditarietà è simile al seguente:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Quando ci sono più percorsi per la stessa classe genitore in un grafico dell'ereditarietà della sottoclasse, le istanze della sottoclasse richiameranno più volte il costruttore di quella classe genitore.
Combattere questo non è banale. Diamo un'occhiata ad alcuni esempi con nomi di classe semplificati. Considereremo la classe A, la classe genitore più astratta, le classi Be C, entrambe ereditate da A, e la classe BCche eredita da Be C(e quindi concettualmente "doppia eredità" da 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');
}
}));
Se vogliamo impedire il BCdoppio A.prototype.initrichiamo, potremmo aver bisogno di abbandonare lo stile di chiamare direttamente i costruttori ereditati. Avremo bisogno di un certo livello di riferimento indiretto per verificare se si verificano chiamate duplicate e cortocircuitare prima che si verifichino.
Potremmo prendere in considerazione la modifica dei parametri forniti alla funzione proprietà: a fianco protos, Objectcontenente un dato non elaborato che descrive le proprietà ereditate, potremmo anche includere una funzione di utilità per chiamare un metodo di istanza in modo tale che vengano chiamati anche metodi parent, ma vengono rilevate chiamate duplicate e prevenuto. Diamo un'occhiata a dove stabiliamo i parametri per 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 ... */
};
L'intero scopo della modifica di cui sopra makeClassè in modo che abbiamo un argomento aggiuntivo fornito al nostro propertiesFnquando invochiamo makeClass. Dobbiamo anche essere consapevoli del fatto che ogni funzione definita in qualsiasi classe ora può ricevere un parametro dopo tutti gli altri, denominati dup, che è uno Setche contiene tutte le funzioni che sono già state chiamate come risultato della chiamata al metodo ereditato:
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');
}
}));
Questo nuovo stile riesce effettivamente a garantire che "Construct A"venga registrato solo una volta quando BCviene inizializzata un'istanza di . Ma ci sono tre aspetti negativi, il terzo dei quali è molto critico :
- Questo codice è diventato meno leggibile e gestibile. Molta complessità si nasconde dietro la
util.invokeNoDuplicatesfunzione e pensare a come questo stile eviti la multi-invocazione non è intuitivo e induce mal di testa. Abbiamo anche quel dupsparametro fastidioso , che deve davvero essere definito su ogni singola funzione della classe . Ahia.
- Questo codice è più lento - è necessario un po 'più di indiretta e di calcolo per ottenere risultati desiderabili con eredità multipla. Sfortunatamente, questo è probabilmente il caso di qualsiasi soluzione al nostro problema di invocazione multipla.
- Più significativamente, la struttura delle funzioni che si basano sull'eredità è diventata molto rigida . Se una sottoclasse
NiftyClasssovrascrive una funzione niftyFunctione la utilizza util.invokeNoDuplicates(this, 'niftyFunction', ...)per eseguirla senza invocazione duplicata, NiftyClass.prototype.niftyFunctionchiamerà la funzione denominata niftyFunctiondi ogni classe genitrice che la definisce, ignora qualsiasi valore restituito da tali classi e infine esegue la logica specializzata di NiftyClass.prototype.niftyFunction. Questa è l' unica struttura possibile . Se NiftyClasseredita CoolClasse GoodClass, ed entrambe queste classi principali forniscono niftyFunctiondefinizioni proprie, NiftyClass.prototype.niftyFunctionnon potranno mai (senza rischiare l'invocazione multipla):
- A. Esegui prima la logica specializzata
NiftyClass, quindi la logica specializzata delle classi genitore
- B. Esegui la logica specializzata
NiftyClassin qualsiasi momento diverso dal completamento di tutta la logica padre specializzata
- C. Comportarsi condizionatamente in base ai valori di ritorno della logica specializzata dei suoi genitori
- D. Evitare l'esecuzione di un genitore particolare è specializzata
niftyFunctiondel tutto
Ovviamente, potremmo risolvere ogni problema segnalato sopra definendo funzioni specializzate sotto util:
- A. definire
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. define
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)(Dov'è parentNameil nome del genitore la cui logica specializzata sarà immediatamente seguita dalla logica specializzata delle classi figlio)
- C. define
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)(In questo caso testFnriceverebbe il risultato della logica specializzata per il genitore nominato parentNamee restituirebbe un true/falsevalore che indica se il cortocircuito dovrebbe avvenire)
- D. define
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)(In questo caso blackListsarebbe uno Arraydei nomi parent la cui logica specializzata dovrebbe essere saltata del tutto)
Queste soluzioni sono tutte disponibili, ma questo è un caos totale ! Per ogni struttura unica che può assumere una chiamata di funzione ereditata, avremmo bisogno di un metodo specializzato definito in util. Che disastro assoluto.
Con questo in mente possiamo iniziare a vedere le sfide legate all'implementazione di una buona eredità multipla. La piena implementazione di makeClassI fornita in questa risposta non considera nemmeno il problema di invocazione multipla o molti altri problemi che sorgono riguardo l'ereditarietà multipla.
Questa risposta sta diventando molto lunga. Spero che l' makeClassimplementazione che ho incluso sia ancora utile, anche se non è perfetta. Spero anche che chiunque sia interessato a questo argomento abbia guadagnato più contesto da tenere a mente mentre leggono ulteriormente!