Примеси и JavaScript: Хорошие, плохие и уродливые
Примеси и JavaScript как классическое кино с Клинтом Иствудом.
Они хороши тем, что позволяют создать объект из настолько маленьких кусков, насколько это позволяет гибкая природа JavaScript, поэтому примеси довольно популярны в определенных кругах.
Список плохих качеств немного больше:
- нет общей идеи за концептом примесей;
- они требуют для использования дополнительные библиотеки;
- более усложненное создание (например, координацию между примесями и прототипами) и не вписывается в природу шаблонов проектирования;
- сложнее проводить статический анализ;
- и наконец, большая часть библиотек мутирует объекты или их прототипы, что приводит к проблемам с оптимизацией, поэтому некоторые программисты их избегают.
Уродливость их в том, что в результате раздробленности экосистем примесей, часто конструкции несовместимы и отличаются семантикой. Насколько я могу судить, на данный момент нет достаточно популярной библиотеки, которую можно считать общепринятой.
Примеси в JavaScript опустились намного ниже их возможного потенциала, в сравнении с примесями, изложенными в академической литературе, и даже в реализации нескольких хороших языков программирования.
Для тех, кому нравятся примеси, и думают, что они должны использоваться везде, где возможно - это ужасно. Примеси решают множество проблем, которые есть у языков с одиночным наследованием, и, как по мне, большинство жалуются на переполненность прототипов, в сравнении с классами в JavaScript. Я бы сказал, что все наследование должно иметь интерфейс примесей: подклассы - это просто вырожденная форма применения примесей.
К счастью, есть свет в конце тоннеля, с JavaScript классами.
Их наличие, наконец, дает простой синтаксис для наследования. Классы JavaScript имеют бо?льшую мощность, нежели многие люди могут понять, и являются хорошим фундаментом для создания реальных примесей.
В этой статье, мы изучим, что должны делать примеси, что не так с текущими, и как просто создать могучей систему примесей в JavaScript, которая очень хорошо вписывается в систему классов JavaScript.
Что такое примеси на самом деле?
Перед тем, как разобраться в том, что должна делать реализация примесей, давайте сначала рассмотрим, чем являются примеси:
Примесь это абстрактный подкласс, в том смысле, что определение подкласса может быть применено к разным суперклассам для создания семейства связаных классов.
Это наилучшее определение примесей, которое я смог найти. Оно явно показывает отличие между примесью и обычным классом, и основательно подсказывает, как примеси могут быть реализованы на JavaScript.
Для копания в глубину этого определения, давайте добавим два новых термина в наш словарь:
- Определение примеси: Определение класса который может быть применен к разным суперклассам.
- Применение примеси: Применение определения примеси к суперклассу порождает новый подкласс.
На самом деле, определение примеси - это фабрика подклассов, параметризованная суперклассом, который производит применение примесей. Применение примеси лежит в иерархии наследования между подклассом и суперклассом.
Единственное отличие между примесью и обычным подклассом в том, что класс имеет фиксированный суперкласс, в то же время примесь на момент определения его не имеет.
Только применение примеси имеет свой суперкласс. Вы можете рассматривать нормальное наследование подкласса как вырожденный случай, где суперкласс известен на момент определения и имеет только одно применение.
Примеры
Вот пример примесей на Dart, который имеет красивый синтаксис, похожий на JavaScript:
class B extends A with M {}
Где A
- базовый класс, B
- подкласс, и M
- примесь. Применение примеси - это специфическая комбинация M
добавленная в A
, часто называемая A-with-M
. Суперкласом A-with-M
является A
, и реальным суперклассом B
является не A
, как вы могли ожидать, а A-with-M
.
Полезными могут быть объявления классов и диаграммы для объяснения происходящего.
Начнем с простой иерархии классов, где класс B
наследуется от класса A
:
class B extends A {}
Теперь добавим примесь:
class B extends A with M {}
Как можно заметить, применение примеси A-with-M
вставлено в иерархию между подклассом и суперклассом.
Примечание: Я использовал линию с длинными пунктирами для представления объявления примеси (B
включает М
), и линию с короткими пунктирами для представления применения определения примеси.
Множественные примеси
В Dart множественные примеси применяются в порядке слева направо, результирующие множественные добавляются в иерархию наследования:
class B extends A with M1, M2 {}
class C extends A with M1, M2 {}
Классические примеси JavaScript
Возможность свободной модификации объектов в JavaScript значит, что легко копировать функции для повторного использования кода без необходимости полагаться на наследование.
Например: библиотеки примесей, Cocktail, traits.js, и описанные шаблоны, которые можно найти в многих статьях (как эту в последнем Hacker News: Использование декораторов ES7 как примеси), в основном работают модифицируя прототип, копируя свойства из объектов примесей и переписывая существующие свойства.
Очень часто реализованы функцией, похожей на эту:
function mixin(source, target) {
for (var prop in source) {
if (source.hasOwnProperty(prop)) {
target[prop] = source[prop];
}
}
}
Ее вариант даже существует в JavaScript, известный как Object.assign.
mixin()
обычно вызывается на прототипе:
mixin(MyMixin, MyClass.prototype);
в результате чего, MyClass
имеет все свойства определенные в MyMixin
.
Чем же это плохо?
Простое копирование свойств в целевой объект имеет несколько проблем. Некоторые из этих проблем можно обойти используя "умные" функции примесей, но все же большинство имеют эти проблемы:
Модификация прототипа
Когда библиотеки примесей используются с прототипами, то они собственно модифицируются. Это является проблемой, если прототип используется в каком-нибудь другом месте, где примеси не являются желанными. Примеси, которые изменяют прототип, могут форсировать необходимость использовать виртуальной машиной медленные объекты во время распределения. А также, это противоречит идее о том, что применение примесей должно создавать новый класс при комбинировании с существующим.
Не работает super
Так как JavaScript, наконец-то, поддерживает super
, то примеси должны позволять своим методам иметь доступ к делегированию переопределенным методам в цепочке прототипов. А так как super
лексически связан с классом, то это не сработает при копировании функций.
Неправильный приоритет
Это не всегда так, но часто показано в примерах, при переопределении свойств, методы примеси имеют преимущество перед теми, что в подклассе. Они должны иметь приоритет только над методами определенными в суперклассе, позволяя подклассу переопределить методы определенные в примеси.
Нарушенная композиция
Примесям часто необходимо делегировать управление другим примесям или объектам в цепочке прототипов, но нет естественного способа сделать это с примесями. Так как функции скопированы в объект, т. е. наивные реализации переписывают существующие методы. Более утонченные библиотеки запоминают существующие методы, и вызывают несколько методов с одинаковым именем, но в этом случае библиотеки должны изобретать свои правила композиции: в каком порядке методы будут вызваны, какие аргументы переданы, и т.п.
Ссылки на функции дублируются на все применение примесей, где в большинстве случаев они могут быть добавлены в общий прототип. Переписывая свойства, структуры прототипов их динамическая природа сокращается: у вас не получиться просто разобраться с примесями, удалить или изменить порядок примесей, потому что примеси были добавлены прямо в целевой объект.
Если бы на самом деле использовать цепочку прототипов, все эти нюансы уходят после небольшой переделки.
Лучшие примеси с классами
Теперь давайте перейдем к хорошим штукам: Потрясающие примеси™ 2015 Edition .
Давайте быстро пересчитаем список функций, которые мы б хотели иметь, и по ним сможем оценить нашу реализацию.:
- Примеси добавлены в прототип
- Примеси применяются без изменения существующих
- Примеси не имеют магии, и не определят новую семантику поверх ядра
- доступ к свойству
super.foo
работает и - вызовы
super()
работают - Примеси могут расширять другие
- работает
instanceof
- Определение примесей не требует библиотек для своей работы, они могут быть написаны в универсальном стиле.
Фабрики подклассов со странным трюком
Выше я ссылался на примеси как "подкласс фабрик, параметризованные суперклассом", и это в буквальном смысле.
Мы полагаемся на две особенности JavaScript
class
может быть использован как выражение так и оператор. Выражение возвращает новый класс каждый раз при вызове (подобие фабрики!).extends
принимает произвольное выражение, которое возвращает класс или конструктор.
Ключевым значением есть то, что классы в JavaScript являются "полноправными гражданами": являются значениями и могут быть переданы и возвращенными из функций.
Все, что нам надо определить примесь как функцию, которая получает суперкласс и создает новый подкласс из него, например так:
let MyMixin = (superclass) => class extends superclass {
foo() {
console.log('foo from MyMixin');
}
};
После, мы можем использовать его вот так:
class MyClass extends MyMixin(MyBaseClass) {
/* ... */
}
Теперь MyClass
имеет метод foo
с помощью наследования примеси:
let c = new MyClass();
c.foo(); // prints "foo from MyMixin"
Невероятно простой и невероятно мощный! Просто комбинируя применение функции и выражения класса, мы получили целое решения для примесей, которые вполне обобщенные.
Применение нескольких примесей так же работает:
class MyClass extends Mixin1(Mixin2(MyBaseClass)) {
/* ... */
}
Еще примеси могут легко наследоваться от других примесей, просто передавая суперкласс:
let Mixin2 = (superclass) => class extends Mixin1(superclass) {
/* добавить или переопределить методы здесь */
}
И вы можете использовать обычную композицию для композиции примесей:
let CompoundMixin = (superclass) => Mixin2(Mixin3(superclass));
Польза примесей как фабрики подклассов
Этот подход дает нам очень хорошую реализацию миксин.
Подклассы могут переопределить методы примеси
Как я говорил ранее, многие примеры примесей неправильно понимают и примеси переписывают методы подкласса. С этим подходом подклассы правильно переопределяют методы примесей, которые в свою очередь переопределяют методы суперкласса.
Работает super
Одно из наибольших преимуществ это то, что super
работает внутри методов подклассов и примесей. Так как мы никогда не затираем методы классов или примесей, то они всегда доступны для адресации через super
.
Вызовы super
могут быть немного не очевидными для новичков в примесях, потому что суперкласс не известен на момент определения примеси, и иногда разработчики ожидают, что super
указывает на определенный суперкласс (параметр на миксин), а не применение примеси. Может помочь мысль об конечной цепочке прототипов.
Сохраняется композиция
На самом деле это только следствие других преимуществ, но две примеси могут определить одинаковые методы, и до тех пор, пока они вызывают super
, оба метода будут вызваны и применены.
Иногда примесь может не знать, существует ли в суперкласса необходимое свойство или метод, поэтому лучше для начала убедиться в этом:
let Mixin1 = (superclass) => class extends superclass {
foo() {
console.log('foo from Mixin1');
if (super.foo) super.foo();
}
};
let Mixin2 = (superclass) => class extends superclass {
foo() {
console.log('foo from Mixin2');
if (super.foo) super.foo();
}
};
class S {
foo() {
console.log('foo from S');
}
}
class C extends Mixin1(Mixin2(S)) {
foo() {
console.log('foo from C');
super.foo();
}
}
new C().foo();
Выведет:
foo from C
foo from Mixin1
foo from Mixin2
foo from S
Улучшение синтаксиса
Я нашел примеси как функции одновременно элегантно простыми (можно понять, что собственно происходит), и в то же время немного уродливыми. Меня больше всего беспокоит то, что конструкция не оптимальная для читателей, которые не знакомы с этим подходом.
Я бы хотел, чтобы синтаксис был проще для глаз и как минимум дал новым читателям возможность понять, что происходит, к примеру, как синтаксис в Dart. Так же, я бы хотел добавить дополнительные функции, например, меморизацию применения примеси и автоматическую реализацию поддержки instanceof
.
Для этого я написал простой помощник, который применяет список примесей к суперклассу:
class MyClass extends mix(MyBaseClass).with(Mixin1, Mixin2) {
/* ... */
}
Вот его реализация:
let mix = (superclass) => new MixinBuilder(superclass);
class MixinBuilder {
constructor(superclass) {
this.superclass = superclass;
}
with(...mixins) {
return mixins.reduce((c, mixin) => mixin(c), this.superclass);
}
}
Конструкторы и инициализация
Конструктор - источник для потенциальной путаницы с примесями. Они специально ведут себя как методы, за исключением того, что переопределенные методы склонны иметь одинаковую сигнатуру, в то время как конструкторы и иерархии наследования часто имеют разные сигнатуры.
Так как примеси не знают к какому классу будут применены, и как в следствие сигнатуру супер-конструктора, вызов super()
может быть довольно мудреный. Поэтому, лучше всего это всегда передавать все параметры конструктора в super()
, или не определять конструктор вообще, или использовать оператор расширения: super(...arguments)
.
Это значит, что передача аргументов особенно для конструкторов примесей - сложно. Один простой обходный путь - это просто иметь явный метод инициализации примеси, если он требует дополнительных аргументов.
Источник