«Настоящие» примеси на классах JavaScript

Примеси и 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-hierarchy

Теперь добавим примесь:

class B extends A with M {}

class-hierarchy-2

Как можно заметить, применение примеси A-with-M вставлено в иерархию между подклассом и суперклассом.

Примечание: Я использовал линию с длинными пунктирами для представления объявления примеси (B включает М), и линию с короткими пунктирами для представления применения определения примеси.

Множественные примеси

В Dart множественные примеси применяются в порядке слева направо, результирующие множественные добавляются в иерархию наследования:

class B extends A with M1, M2 {}

class C extends A with M1, M2 {}

class-hierarchy-3

Классические примеси 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 .

Давайте быстро пересчитаем список функций, которые мы б хотели иметь, и по ним сможем оценить нашу реализацию.:

  1. Примеси добавлены в прототип
  2. Примеси применяются без изменения существующих
  3. Примеси не имеют магии, и не определят новую семантику поверх ядра
  4. доступ к свойству super.foo работает и
  5. вызовы super() работают
  6. Примеси могут расширять другие
  7. работает instanceof
  8. Определение примесей не требует библиотек для своей работы, они могут быть написаны в универсальном стиле.

Фабрики подклассов со странным трюком

Выше я ссылался на примеси как "подкласс фабрик, параметризованные суперклассом", и это в буквальном смысле.

Мы полагаемся на две особенности JavaScript

  1. class может быть использован как выражение так и оператор. Выражение возвращает новый класс каждый раз при вызове (подобие фабрики!).
  2. 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).

Это значит, что передача аргументов особенно для конструкторов примесей - сложно. Один простой обходный путь - это просто иметь явный метод инициализации примеси, если он требует дополнительных аргументов.


Источник

Добавить комментарий