J'ai tout à fait la fonction de permettre aux classes d'être définies avec l'héritage multiple. Il permet un code comme celui-ci. Dans l'ensemble, vous noterez un départ complet des techniques de classement natives en javascript (par exemple, vous ne verrez jamais le class
mot - clé):
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();
pour produire une sortie comme celle-ci:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
Voici à quoi ressemblent les définitions de classe:
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();
}
}));
Nous pouvons voir que chaque définition de classe utilisant la makeClass
fonction accepte un Object
des noms de classe parente mappés à des classes parentes. Il accepte également une fonction qui retourne Object
des propriétés contenant pour la classe en cours de définition. Cette fonction a un paramètre protos
, qui contient suffisamment d'informations pour accéder à toute propriété définie par l'une des classes parentes.
Le dernier élément requis est la makeClass
fonction elle-même, qui fait pas mal de travail. Le voici, avec le reste du code. J'ai makeClass
beaucoup commenté :
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
fonction prend également en charge les propriétés de classe; ceux-ci sont définis en préfixant les noms de propriété avec le $
symbole (notez que le nom de propriété final qui en résulte sera $
supprimé). Dans cet esprit, nous pourrions écrire une Dragon
classe spécialisée qui modélise le "type" du Dragon, où la liste des types de Dragon disponibles est stockée sur la classe elle-même, par opposition aux instances:
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 });
Les défis de l'héritage multiple
Quiconque a suivi le code de makeClass
près notera un phénomène indésirable assez important se produisant silencieusement lorsque le code ci-dessus s'exécute: l' instanciation de a RunningFlying
entraînera DEUX appels au Named
constructeur!
C'est parce que le graphique d'héritage ressemble à ceci:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Lorsqu'il y a plusieurs chemins vers la même classe parente dans le graphe d'héritage d'une sous-classe , les instanciations de la sous-classe invoqueront le constructeur de cette classe parente plusieurs fois.
Combattre cela n'est pas trivial. Regardons quelques exemples avec des noms de classes simplifiés. Nous allons considérer la classe A
, la classe parente la plus abstraite, les classes B
et C
, qui héritent toutes deux de A
, et la classe BC
qui hérite de B
et C
(et donc conceptuellement "hérite en double" 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 nous voulons éviter BC
de double-invoquer, A.prototype.init
nous devrons peut-être abandonner le style d'appel direct des constructeurs hérités. Nous aurons besoin d'un certain niveau d'indirection pour vérifier si des appels en double se produisent, et court-circuiter avant qu'ils ne se produisent.
Nous pourrions envisager de changer les paramètres fournis à la fonction properties: à côté protos
, une Object
contenant des données brutes décrivant les propriétés héritées, nous pourrions également inclure une fonction utilitaire pour appeler une méthode d'instance de telle sorte que les méthodes parentes soient également appelées, mais que les appels en double soient détectés et empêché. Jetons un œil à l'endroit où nous établissons les paramètres pour 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 ... */
};
Le but de la modification ci-dessus makeClass
est de faire en sorte que nous ayons un argument supplémentaire fourni à notre propertiesFn
lorsque nous invoquons makeClass
. Nous devons également être conscients que chaque fonction définie dans une classe peut maintenant recevoir un paramètre après tous ses autres, nommé dup
, qui est un Set
qui contient toutes les fonctions qui ont déjà été appelées à la suite de l'appel de la méthode héritée:
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');
}
}));
Ce nouveau style réussit en fait à garantir qu'il "Construct A"
n'est journalisé qu'une seule fois lorsqu'une instance de BC
est initialisée. Mais il y a trois inconvénients, dont le troisième est très critique :
- Ce code est devenu moins lisible et maintenable. Une grande complexité se cache derrière la
util.invokeNoDuplicates
fonction, et réfléchir à la manière dont ce style évite les invocations multiples n'est pas intuitif et induit des maux de tête. Nous avons également ce dups
paramètre embêtant , qui doit vraiment être défini sur chaque fonction de la classe . Aie.
- Ce code est plus lent - un peu plus d'indirection et de calcul sont nécessaires pour obtenir des résultats souhaitables avec l'héritage multiple. Malheureusement, ce sera probablement le cas avec toute solution à notre problème d'invocation multiple.
- Plus important encore, la structure des fonctions qui reposent sur l'héritage est devenue très rigide . Si une sous-classe
NiftyClass
remplace une fonction niftyFunction
, et utilise util.invokeNoDuplicates(this, 'niftyFunction', ...)
pour l'exécuter sans duplicate-invocation, NiftyClass.prototype.niftyFunction
appellera la fonction nommée niftyFunction
de chaque classe parent qui la définit, ignorera toutes les valeurs de retour de ces classes et exécutera enfin la logique spécialisée de NiftyClass.prototype.niftyFunction
. C'est la seule structure possible . Si NiftyClass
hérite CoolClass
et GoodClass
, et que ces deux classes parentes fournissent niftyFunction
leurs propres définitions, NiftyClass.prototype.niftyFunction
il ne sera jamais (sans risquer de multiples invocations) de:
- A. Exécutez la logique spécialisée du
NiftyClass
premier, puis la logique spécialisée des classes-parents
- B. Exécutez la logique spécialisée de
NiftyClass
à tout moment autre que lorsque toute la logique parent spécialisée est terminée
- C. Se comporter de manière conditionnelle en fonction des valeurs de retour de la logique spécialisée de son parent
- D. Évitez l' exécution d' un parent particulier est spécialisé
niftyFunction
tout à fait
Bien sûr, nous pourrions résoudre chaque problème lettré ci-dessus en définissant des fonctions spécialisées sous util
:
- A. définir
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. define
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
(Où parentName
est le nom du parent dont la logique spécialisée sera immédiatement suivie par la logique spécialisée des classes enfants)
- C. define
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(dans ce cas testFn
recevrait le résultat de la logique spécialisée pour le parent nommé parentName
, et renverrait une true/false
valeur indiquant si le court-circuit devrait se produire)
- D. définir
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(Dans ce cas , blackList
serait un Array
des noms de parents dont la logique spécialisée devrait être tout à fait sautée)
Ces solutions sont toutes disponibles, mais c'est un chaos total ! Pour chaque structure unique qu'un appel de fonction hérité peut prendre, nous aurions besoin d'une méthode spécialisée définie sous util
. Quel désastre absolu.
Dans cet esprit, nous pouvons commencer à voir les défis de la mise en œuvre d'un bon héritage multiple. L'implémentation complète de makeClass
j'ai fournie dans cette réponse ne prend même pas en compte le problème d'invocation multiple, ou de nombreux autres problèmes qui se posent concernant l'héritage multiple.
Cette réponse devient très longue. J'espère que l' makeClass
implémentation que j'ai incluse est toujours utile, même si elle n'est pas parfaite. J'espère également que toute personne intéressée par ce sujet aura acquis plus de contexte à garder à l'esprit lors de la lecture plus approfondie!