Tengo bastante la función de permitir que las clases se definan con herencia múltiple. Permite código como el siguiente. En general, notará una desviación completa de las técnicas de clasificación nativas en javascript (por ejemplo, nunca verá la class
palabra clave):
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();
para producir resultados como este:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
A continuación se muestran las definiciones de clase:
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();
}
}));
Podemos ver que cada definición de clase que usa la makeClass
función acepta uno Object
de los nombres de clase padre mapeados a clases padre. También acepta una función que devuelve Object
propiedades que contienen para la clase que se está definiendo. Esta función tiene un parámetroprotos
, que contiene suficiente información para acceder a cualquier propiedad definida por cualquiera de las clases principales.
La pieza final requerida es la makeClass
función en sí, que hace bastante trabajo. Aquí está, junto con el resto del código. He comentado makeClass
bastante:
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 makeClass
función también admite propiedades de clase; estos se definen prefijando los nombres de las propiedades con el $
símbolo (tenga en cuenta que el nombre de la propiedad final que resulte tendrá el $
eliminado). Con esto en mente, podríamos escribir una Dragon
clase especializada que modele el "tipo" del Dragón, donde la lista de tipos de Dragón disponibles se almacena en la Clase en lugar de en las instancias:
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 });
Los desafíos de la herencia múltiple
Cualquiera que haya seguido el código de makeClass
cerca notará un fenómeno indeseable bastante significativo que ocurre silenciosamente cuando se ejecuta el código anterior: ¡ crear una instancia RunningFlying
dará como resultado DOS llamadas al Named
constructor!
Esto se debe a que el gráfico de herencia se ve así:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Cuando hay múltiples rutas a la misma clase padre en un gráfico de herencia de la subclase, las instancias de la subclase invocarán ese constructor de la clase padre varias veces.
Combatir esto no es trivial. Veamos algunos ejemplos con nombres de clase simplificados. Consideraremos la clase A
, la clase padre más abstracta, las clases B
y C
, que ambas heredan de A
, y la clase BC
que hereda de B
y C
(y por lo tanto conceptualmente "doble hereda" de 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');
}
}));
Si queremos evitar la BC
doble invocación, A.prototype.init
es posible que debamos abandonar el estilo de llamar directamente a los constructores heredados. Necesitaremos cierto nivel de indirección para verificar si se producen llamadas duplicadas y cortocircuitos antes de que sucedan.
Podríamos considerar cambiar los parámetros suministrados a la función de propiedades: junto con protos
una Object
información sin procesar que describe las propiedades heredadas, también podríamos incluir una función de utilidad para llamar a un método de instancia de tal manera que también se invoquen métodos principales, pero se detectan llamadas duplicadas y prevenido. Echemos un vistazo a dónde establecemos los parámetros para 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 ... */
};
Todo el propósito del cambio anterior makeClass
es para que tengamos un argumento adicional suministrado propertiesFn
cuando invoquemos makeClass
. También debemos tener en cuenta que cada función definida en cualquier clase ahora puede recibir un parámetro después de todos los demás, llamado dup
, que es el Set
que contiene todas las funciones que ya se han llamado como resultado de llamar al método heredado:
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');
}
}));
Este nuevo estilo realmente garantiza "Construct A"
que solo se registre una vez cuando BC
se inicializa una instancia de . Pero hay tres desventajas, la tercera de las cuales es muy crítica :
- Este código se ha vuelto menos legible y mantenible. Una gran complejidad se esconde detrás de la
util.invokeNoDuplicates
función, y pensar en cómo este estilo evita la invocación múltiple no es intuitivo e induce dolor de cabeza. También tenemos ese dups
parámetro molesto , que realmente necesita ser definido en cada función de la clase . Ay.
- Este código es más lento: se requiere un poco más de indirección y cómputo para lograr resultados deseables con herencia múltiple. Desafortunadamente, es probable que este sea el caso con cualquier solución a nuestro problema de invocación múltiple.
- Más significativamente, la estructura de funciones que dependen de la herencia se ha vuelto muy rígida . Si una subclase
NiftyClass
anula una función niftyFunction
y la usa util.invokeNoDuplicates(this, 'niftyFunction', ...)
para ejecutarla sin invocación duplicada, NiftyClass.prototype.niftyFunction
llamará a la función nombrada niftyFunction
de cada clase padre que la defina, ignorará cualquier valor de retorno de esas clases y finalmente realizará la lógica especializada de NiftyClass.prototype.niftyFunction
. Esta es la única estructura posible . Si NiftyClass
hereda CoolClass
y GoodClass
, y ambas clases principales proporcionan niftyFunction
definiciones propias, NiftyClass.prototype.niftyFunction
nunca (sin arriesgarse a invocación múltiple) podrá:
- A. Ejecute la lógica especializada de
NiftyClass
primero, luego la lógica especializada de las clases padre
- B. Ejecute la lógica especializada
NiftyClass
en cualquier punto que no sea después de que se haya completado toda la lógica principal especializada
- C. Comportarse condicionalmente dependiendo de los valores de retorno de la lógica especializada de su padre
- D. Evite dirigir por
niftyFunction
completo a un padre en particular especializado
Por supuesto, podríamos resolver cada problema con letras arriba definiendo funciones especializadas en util
:
- A. definir
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. define
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
(dónde parentName
está el nombre del padre cuya lógica especializada será seguida inmediatamente por la lógica especializada de las clases secundarias)
- C. definir
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(en este caso testFn
recibiría el resultado de la lógica especializada para el padre nombrado parentName
y devolvería un true/false
valor que indica si el cortocircuito debería ocurrir)
- D. definir
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(en este caso blackList
sería uno Array
de los nombres principales cuya lógica especializada debería omitirse por completo)
Estas soluciones están disponibles, ¡ pero esto es un caos total ! Para cada estructura única que puede tomar una llamada de función heredada, necesitaríamos un método especializado definido en util
. Qué desastre absoluto.
Con esto en mente, podemos comenzar a ver los desafíos de implementar una buena herencia múltiple. La implementación completa de lo makeClass
que proporcioné en esta respuesta ni siquiera considera el problema de la invocación múltiple o muchos otros problemas que surgen con respecto a la herencia múltiple.
Esta respuesta se está haciendo muy larga. Espero que la makeClass
implementación que incluí siga siendo útil, incluso si no es perfecta. ¡También espero que cualquier persona interesada en este tema haya adquirido más contexto para tener en cuenta mientras leen más!