Eu tenho bastante a função de permitir que classes sejam definidas com herança múltipla. Ele permite códigos como os seguintes. No geral, você observará uma saída completa das técnicas nativas de classificação em javascript (por exemplo, você nunca verá a class
palavra - chave):
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 produzir uma saída como esta:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
Aqui estão as definições das classes:
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 definição de classe usando a makeClass
função aceita um Object
nome de classe pai mapeado para classe pai. Ele também aceita uma função que retorna Object
propriedades contendo a classe que está sendo definida. Esta função possui um parâmetroprotos
, que contém informações suficientes para acessar qualquer propriedade definida por qualquer uma das classes-pai.
A peça final necessária é a makeClass
própria função, que faz bastante trabalho. Aqui está, junto com o restante do código. Eu comentei 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();
A makeClass
função também suporta propriedades de classe; elas são definidas prefixando os nomes das propriedades com o $
símbolo (observe que o nome da propriedade final resultante será $
removido). Com isso em mente, poderíamos escrever uma Dragon
classe especializada que modela o "tipo" do dragão, onde a lista de tipos de dragão disponíveis é armazenada na própria classe, e não nas instâncias:
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 });
Os desafios da herança múltipla
Quem seguiu o código de makeClass
perto notará um fenômeno indesejável bastante significativo ocorrendo silenciosamente quando o código acima for executado: instanciar a RunningFlying
resultará em DUAS chamadas ao Named
construtor!
Isso ocorre porque o gráfico de herança se parece com isso:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Quando existem vários caminhos para a mesma classe pai no gráfico de herança de uma subclasse da subclasse, as instanciações da subclasse invocam o construtor dessa classe-pai várias vezes.
Combater isso não é trivial. Vejamos alguns exemplos com nomes de classe simplificados. Consideraremos classe A
, a classe pai mais abstrata, classes B
e C
, que herdam de A
, e classe BC
que herda de B
e C
(e, portanto, conceitualmente "herda duas vezes" 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');
}
}));
Se quisermos impedir BC
a chamada dupla A.prototype.init
, talvez seja necessário abandonar o estilo de chamar diretamente os construtores herdados. Precisamos de algum nível de indireção para verificar se chamadas duplicadas estão ocorrendo e entrar em curto-circuito antes que elas aconteçam.
Poderíamos considerar mudar os parâmetros fornecidos para as propriedades funcionar: ao lado protos
, uma Object
contendo dados brutos que descrevem propriedades herdadas, que poderia também incluir uma função de utilidade para chamar um método de exemplo, de tal forma que os métodos mãe também são chamados, mas chamadas duplicados são detectados e impedido. Vamos dar uma olhada em onde estabelecemos os 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 o objetivo da alteração acima makeClass
é para que tenhamos um argumento adicional fornecido propertiesFn
quando invocamos makeClass
. Também devemos estar cientes de que todas as funções definidas em qualquer classe agora podem receber um parâmetro depois de todas as outras, nomeadas dup
, Set
que contém todas as funções que já foram chamadas como resultado da chamada do método herdado:
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');
}
}));
Esse novo estilo realmente consegue garantir que "Construct A"
seja registrado apenas uma vez quando uma instância de BC
for inicializada. Mas há três desvantagens, a terceira das quais é muito crítica :
- Este código se tornou menos legível e de manutenção. Muita complexidade se esconde por trás da
util.invokeNoDuplicates
função, e pensar em como esse estilo evita a invocação múltipla não é intuitivo e causa dor de cabeça. Também temos esse dups
parâmetro irritante , que realmente precisa ser definido em todas as funções da classe . Ai.
- Esse código é mais lento - é necessário um pouco mais de indireção e computação para alcançar resultados desejáveis com herança múltipla. Infelizmente, é provável que isso aconteça com qualquer solução para nosso problema de invocação múltipla.
- Mais significativamente, a estrutura de funções que dependem de herança se tornou muito rígida . Se uma subclasse
NiftyClass
substituir uma função niftyFunction
e utilizá util.invokeNoDuplicates(this, 'niftyFunction', ...)
-la para executá-la sem invocação duplicada, NiftyClass.prototype.niftyFunction
chamará a função nomeada niftyFunction
de cada classe pai que a define, ignorará quaisquer valores de retorno dessas classes e, finalmente, executará a lógica especializada de NiftyClass.prototype.niftyFunction
. Essa é a única estrutura possível . Se NiftyClass
herda CoolClass
e GoodClass
, e ambas as classes-pai fornecem niftyFunction
definições próprias, NiftyClass.prototype.niftyFunction
nunca (sem arriscar invocação múltipla) será capaz de:
- A. Execute
NiftyClass
primeiro a lógica especializada , depois a lógica especializada das classes pai
- B. Execute a lógica especializada
NiftyClass
em qualquer ponto que não seja após a conclusão de toda lógica lógica especializada
- C. Comporte-se condicionalmente, dependendo dos valores de retorno da lógica especializada de seus pais
- D. Evitar a execução de um pai especial é especializada
niftyFunction
totalmente
Obviamente, poderíamos resolver cada problema com letras acima, definindo funções especializadas em util
:
- A. definir
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. define
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
(Onde parentName
é o nome do pai cuja lógica especializada será imediatamente seguida pela lógica especializada das classes filho)
- C. define
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(nesse caso testFn
, receberia o resultado da lógica especializada para o pai nomeado parentName
e retornaria um true/false
valor indicando se o curto-circuito deveria ocorrer)
- D. define
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(Nesse caso, blackList
seria um Array
dos nomes dos pais cuja lógica especializada deve ser ignorada por completo)
Essas soluções estão todas disponíveis, mas isso é total caos ! Para toda estrutura exclusiva que uma chamada de função herdada pode suportar, precisaríamos de um método especializado definido em util
. Que desastre absoluto.
Com isso em mente, podemos começar a ver os desafios da implementação de uma boa herança múltipla. A implementação completa de makeClass
I fornecida nesta resposta nem sequer considera o problema de invocação múltipla ou muitos outros problemas que surgem com relação à herança múltipla.
Esta resposta está ficando muito longa. Espero que a makeClass
implementação incluída ainda seja útil, mesmo que não seja perfeita. Eu também espero que qualquer pessoa interessada neste tópico tenha ganhado mais contexto para ter em mente enquanto lê mais!