У меня есть функция, позволяющая определять классы с множественным наследованием. Это позволяет использовать следующий код. В целом вы заметите полный отход от собственных методов классификации в javascript (например, вы никогда не увидите class
ключевое слово):
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();
чтобы произвести такой вывод:
human runs with 2 legs.
airplane flies away with 2 wings!
dragon runs with 4 legs.
dragon flies away with 6 wings!
Вот как выглядят определения классов:
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();
}
}));
Мы видим, что каждое определение класса, использующее makeClass
функцию, принимает Object
имена родительских классов, сопоставленные с родительскими классами. Он также принимает функцию, которая возвращает Object
содержащие свойства для определяемого класса. Эта функция имеет параметр protos
, который содержит достаточно информации для доступа к любому свойству, определенному любым из родительских классов.
Последняя необходимая часть - это makeClass
сама функция, которая выполняет довольно много работы. Вот он вместе с остальным кодом. Я makeClass
довольно много комментировал :
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();
makeClass
Функция также поддерживает свойство класса; они определяются путем добавления к именам свойств $
символа (обратите внимание, что окончательное имя свойства будет $
удалено). Имея это в виду, мы могли бы написать специализированный Dragon
класс, который моделирует «тип» Дракона, где список доступных типов Дракона хранится в самом Классе, а не в экземплярах:
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 });
Проблемы множественного наследования
Любой, кто makeClass
внимательно следил за кодом для, заметит довольно существенное нежелательное явление, незаметно происходящее при выполнении приведенного выше кода: создание экземпляра a RunningFlying
приведет к ДВА вызовам Named
конструктора!
Это потому, что граф наследования выглядит так:
(^^ More Specialized ^^)
RunningFlying
/ \
/ \
Running Flying
\ /
\ /
Named
(vv More Abstract vv)
Когда существует несколько путей к одному и тому же родительскому классу в графе наследования подкласса , экземпляры подкласса будут вызывать конструктор этого родительского класса несколько раз.
Бороться с этим нетривиально. Давайте посмотрим на несколько примеров с упрощенными именами классов. Мы рассмотрим класс A
, наиболее абстрактный родительский класс, классы B
и C
, которые наследуются от и A
, и класс, BC
который наследуется от B
и C
(и, следовательно, концептуально "наследует двойное" от 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');
}
}));
Если мы хотим предотвратить BC
двойной A.prototype.init
вызов, нам может потребоваться отказаться от стиля прямого вызова унаследованных конструкторов. Нам понадобится некоторый уровень косвенного обращения, чтобы проверить, происходят ли повторяющиеся вызовы, и короткое замыкание, прежде чем они произойдут.
Мы могли бы рассмотреть вопрос об изменении параметров , подаваемых на свойства функционируют: наряду protos
, Object
содержащие исходные данные , описывающие наследственные свойства, мы могли бы также включать в себя функцию полезности для вызова метода экземпляра таким образом , что методы родительских также называют, но повторяющиеся вызовы обнаружены и предотвратил. Давайте посмотрим, где мы устанавливаем параметры для 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 ... */
};
Вся цель вышеупомянутого изменения в makeClass
том, чтобы у нас был дополнительный аргумент, предоставляемый нашему propertiesFn
при вызове makeClass
. Мы также должны знать, что каждая функция, определенная в любом классе, теперь может получать параметр после всех своих других с именем named dup
, который Set
содержит все функции, которые уже были вызваны в результате вызова унаследованного метода:
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');
}
}));
Этот новый стиль фактически обеспечивает "Construct A"
регистрацию только один раз при BC
инициализации экземпляра . Но есть три минуса, третий из которых очень критичный :
- Этот код стал менее читабельным и удобным в обслуживании. За
util.invokeNoDuplicates
функцией скрывается много сложностей , и размышления о том, как этот стиль позволяет избежать множественных вызовов, не интуитивно понятны и вызывают головную боль. У нас также есть этот надоедливый dups
параметр, который действительно нужно определять для каждой отдельной функции в классе . Уч.
- Этот код медленнее - для достижения желаемых результатов с множественным наследованием требуется немного больше косвенных обращений и вычислений. К сожалению, это может случиться с любым решением нашей проблемы с множественными вызовами.
- Что наиболее важно, структура функций, которые полагаются на наследование, стала очень жесткой . Если подкласс
NiftyClass
переопределяет функцию niftyFunction
и использует ее util.invokeNoDuplicates(this, 'niftyFunction', ...)
для запуска без повторного вызова, NiftyClass.prototype.niftyFunction
вызовет функцию с именем niftyFunction
каждого родительского класса, который ее определяет, проигнорирует любые возвращаемые значения из этих классов и, наконец, выполнит специализированную логику NiftyClass.prototype.niftyFunction
. Это единственно возможная структура . Если NiftyClass
наследует CoolClass
и GoodClass
, и оба этих родительских класса предоставляют niftyFunction
собственные определения, NiftyClass.prototype.niftyFunction
никогда (без риска многократного вызова) не смогут:
- A. Запустите
NiftyClass
сначала специализированную логику , затем специализированную логику родительских классов
- Б. Запускать специализированную логику
NiftyClass
в любой момент, кроме как после завершения всей специализированной родительской логики.
- C. Вести себя условно в зависимости от возвращаемых значений специализированной логики его родителя
- D. Избегайте работы конкретного родителя специализировалась в
niftyFunction
целом
Конечно, мы могли бы решить каждую указанную выше проблему, указав специализированные функции в util
:
- А. определить
util.invokeNoDuplicatesSubClassLogicFirst(instance, fnName, ...)
- B. define
util.invokeNoDuplicatesSubClassAfterParent(parentName, instance, fnName, ...)
(где parentName
имя родителя, чья специализированная логика будет сразу следовать специализированной логике дочерних классов)
- C. define
util.invokeNoDuplicatesCanShortCircuitOnParent(parentName, testFn, instance, fnName, ...)
(в этом случае testFn
будет получен результат специализированной логики для указанного родительского объекта parentName
и будет возвращено true/false
значение, указывающее, должно ли произойти короткое замыкание)
- D. define
util.invokeNoDuplicatesBlackListedParents(blackList, instance, fnName, ...)
(в этом случае blackList
будет Array
родительским именем, специализированная логика которого должна быть полностью опущена)
Все эти решения доступны, но это полный хаос ! Для каждой уникальной структуры, которую может принимать вызов унаследованной функции, нам потребуется специальный метод, определенный в разделе util
. Какая абсолютная катастрофа.
Имея это в виду, мы можем начать видеть проблемы реализации хорошего множественного наследования. Полная реализация того, что makeClass
я предоставил в этом ответе, даже не рассматривает проблему множественного вызова или многие другие проблемы, которые возникают в отношении множественного наследования.
Этот ответ становится очень длинным. Я надеюсь, что makeClass
включенная мной реализация по-прежнему полезна, даже если она не идеальна. Я также надеюсь, что любой, кто интересуется этой темой, получил больше контекста, о котором нужно помнить при дальнейшем чтении!