Статьи

Шаблоны для наследования объектов в JavaScript ES2015

Ребенок читает в постели с факелом, со страшными силуэтами, брошенными на стену игрушками

С долгожданным появлением ES2015 (ранее известный как ES6), JavaScript оснащен синтаксисом специально для определения классов. В этой статье я собираюсь исследовать, можем ли мы использовать синтаксис классов для составления классов из более мелких частей.

Поддержание глубины иерархии до минимума важно для поддержания чистоты вашего кода. Умение разбираться в уроках помогает. Для большой кодовой базы одним из вариантов является создание классов из меньших частей; составление классов. Это также общая стратегия, чтобы избежать дублирования кода.

Представьте, что мы создаем игру, в которой игрок живет в мире животных. Некоторые из них друзья, другие враждебны (собачий человек, как я, может сказать, что все кошки — враждебные существа). Мы могли бы создать класс HostileAnimal , который расширяет Animal , чтобы служить базовым классом для Cat . В какой-то момент мы решили добавить роботов, предназначенных для нанесения вреда людям. Первое, что мы делаем, это создаем класс Robot . Теперь у нас есть два класса, которые имеют похожие свойства. HostileAnimal и Robot могут attack() .

Если бы мы могли как-то определить враждебность в отдельном классе или объекте, скажем, Hostile , мы могли бы использовать это для обоих Cat качестве Robot . Мы можем сделать это различными способами.

Множественное наследование — это функция, поддерживаемая некоторыми классическими языками ООП. Как следует из названия, оно дает нам возможность создать класс, который наследуется от нескольких базовых классов. Посмотрите, как класс Cat расширяет несколько базовых классов в следующем коде Python:

 class Animal(object): def walk(self): # ... class Hostile(object): def attack(self, target): # ... class Dog(Animal): # ... class Cat(Animal, Hostile): # ... dave = Cat(); dave.walk(); dave.attack(target); 

Интерфейс является общей чертой (типизированных) классических языков ООП. Это позволяет нам определить, какие методы (а иногда и свойства) должен содержать класс. Если этого класса нет, компилятор выдаст ошибку. Следующий код TypeScript вызовет ошибку, если у Cat не будет методов attack() или walk() :

 interface Hostile { attack(); } class Animal { walk(); } class Dog extends Animal { // ... } class Cat extends Animal implements Hostile { attack() { // ... } } 

Множественное наследование страдает от проблемы алмаза (где два родительских класса определяют один и тот же метод). Некоторые языки решают эту проблему, внедряя другие стратегии, такие как миксины . Миксины — это крошечные классы, которые содержат только методы. Вместо расширения этих классов, миксины включены в другой класс. Например, в PHP миксины реализованы с использованием черт.

 class Animal { // ... } trait Hostile { // ... } class Dog extends Animal { // ... } class Cat extends Animal { use Hostile; // ... } class Robot { use Hostile; // ... } 

Напомним: синтаксис класса ES2015

Если у вас не было возможности погрузиться в классы ES2015 или вы чувствуете, что недостаточно знаете о них, обязательно прочитайте Джеффа Мотта « Объектно-ориентированный JavaScript — глубокое погружение в классы ES6», прежде чем продолжить.

В двух словах:

  • class Foo { ... } описывает класс с именем Foo
  • class Foo extends Bar { ... } описывает класс Foo , расширяющий другой класс, Bar

Внутри блока класса мы можем определить свойства этого класса. Для этой статьи нам нужно только понять конструкторы и методы:

  • constructor() { ... } — зарезервированная функция, которая выполняется при создании ( new Foo() )
  • foo() { ... } создает метод с именем foo

Синтаксис класса в основном является синтаксическим сахаром над моделью-прототипом JavaScript. Вместо создания класса он создает конструктор функции:

 class Foo {} console.log(typeof Foo); // "function" 

Отсюда следует, что JavaScript не является языком ООП на основе классов. Можно даже утверждать, что синтаксис обманчив, создавая впечатление, что это так.

Составление классов ES2015

Интерфейсы можно имитировать, создавая фиктивный метод, который выдает ошибку. После наследования функция должна быть переопределена, чтобы избежать ошибки:

 class IAnimal { walk() { throw new Error('Not implemented'); } } class Dog extends IAnimal { // ... } const robbie = new Dog(); robbie.walk(); // Throws an error 

Как предлагалось ранее, этот подход основан на наследовании. Для наследования нескольких классов нам понадобится либо множественное наследование, либо миксины.

Другой подход заключается в написании вспомогательной функции, которая проверяет класс после его определения. Пример этого можно найти в « Подождите мгновение», JavaScript поддерживает множественное наследование! Андреа Джаммарки. Смотрите раздел «Базовая проверка функций Object.implement».

Время исследовать различные способы применения множественного наследования и миксины. Все рассмотренные ниже стратегии доступны на GitHub .

Object.assign(ChildClass.prototype, Mixin...)

До ES2015 мы использовали прототипы для наследования. Все функции имеют свойство prototype . При создании экземпляра с использованием new MyFunction() prototype копируется в свойство в экземпляре. Когда вы пытаетесь получить доступ к свойству, которого нет в экземпляре, движок JavaScript попытается найти его в объекте-прототипе.

Чтобы продемонстрировать, взгляните на следующий код:

 function MyFunction () { this.myOwnProperty = 1; } MyFunction.prototype.myProtoProperty = 2; const myInstance = new MyFunction(); // logs "1" console.log(myInstance.myOwnProperty); // logs "2" console.log(myInstance.myProtoProperty); // logs "true", because "myOwnProperty" is a property of "myInstance" console.log(myInstance.hasOwnProperty('myOwnProperty')); // logs "false", because "myProtoProperty" isn't a property of "myInstance", but "myInstance.__proto__" console.log(myInstance.hasOwnProperty('myProtoProperty')); 

Эти объекты-прототипы могут быть созданы и изменены во время выполнения. Изначально я пытался использовать классы для Animal и Hostile :

 class Animal { walk() { // ... } } class Dog { // ... } Object.assign(Dog.prototype, Animal.prototype); 

Выше не работает, потому что методы класса не перечисляются . Практически это означает, что Object.assign(...) не копирует методы из классов. Это также затрудняет создание функции, которая копирует методы из одного класса в другой. Однако мы можем скопировать каждый метод вручную:

 Object.assign(Cat.prototype, { attack: Hostile.prototype.attack, walk: Animal.prototype.walk, }); 

Другой способ состоит в том, чтобы отбросить классы и использовать объекты в качестве миксинов Положительным побочным эффектом является то, что смешанные объекты не могут быть использованы для создания экземпляров, предотвращая неправильное использование.

 const Animal = { walk() { // ... }, }; const Hostile = { attack(target) { // ... }, }; class Cat { // ... } Object.assign(Cat.prototype, Animal, Hostile); 

Pros

  • Mixins не может быть инициализирован

Cons

  • Требуется дополнительная строка кода
  • Object.assign () немного неясен
  • Изобретая прототип наследования для работы с классами ES2015

Составление объектов в конструкторах

С классами ES2015 вы можете переопределить экземпляр, вернув объект в конструкторе:

 class Answer { constructor(question) { return { answer: 42, }; } } // { answer: 42 } new Answer("Life, the universe, and everything"); 

Мы можем использовать эту функцию для создания объекта из нескольких классов внутри подкласса. Обратите внимание, что Object.assign(...) прежнему плохо работает с классами mixin, поэтому я также использовал здесь объекты:

 const Animal = { walk() { // ... }, }; const Hostile = { attack(target) { // ... }, }; class Cat { constructor() { // Cat-specific properties and methods go here // ... return Object.assign( {}, Animal, Hostile, this ); } } 

Поскольку this относится к классу (с не перечисляемыми методами) в вышеприведенном контексте, Object.assign(..., this) не копирует методы Cat . Вместо этого вам нужно будет явно указать поля и методы для того, чтобы Object.assign() мог применять их, например, так:

 class Cat { constructor() { this.purr = () => { // ... }; return Object.assign( {}, Animal, Hostile, this ); } } 

Этот подход не практичен. Поскольку вы возвращаете новый объект вместо экземпляра, он по сути эквивалентен:

 const createCat = () => Object.assign({}, Animal, Hostile, { purr() { // ... } }); const thunder = createCat(); thunder.walk(); thunder.attack(); 

Я думаю, что мы можем согласиться, что последнее более читабельно.

Pros

  • Это работает, я думаю?

Cons

  • Очень неясный
  • Нулевая выгода от синтаксиса класса ES2015
  • Неправильное использование классов ES2015

Функция фабрики классов

Этот подход использует способность JavaScript определять класс во время выполнения.

Сначала нам понадобятся базовые классы. В нашем примере Animal и Robot служат базовыми классами. Если вы хотите начать с нуля, пустой класс тоже работает.

 class Animal { // ... } class Robot { // ... } 

Затем мы должны создать фабричную функцию, которая возвращает новый класс, расширяющий класс Base , который передается в качестве параметра. Это миксины:

 const Hostile = (Base) => class Hostile extends Base { // ... }; 

Теперь мы можем передать любой класс функции Hostile которая будет возвращать новый класс, объединяющий Hostile и любой класс, который мы передали функции:

 class Dog extends Animal { // ... } class Cat extends Hostile(Animal) { // ... } class HostileRobot extends Hostile(Robot) { // ... } 

Мы можем передать несколько классов, чтобы применить несколько миксинов:

 class Cat extends Demonic(Hostile(Mammal(Animal))) { // ... } 

Вы также можете использовать Object в качестве базового класса:

 class Robot extends Hostile(Object) { // ... } 

Pros

  • Проще понять, потому что вся информация находится в заголовке объявления класса

Cons

  • Создание классов во время выполнения может повлиять на производительность запуска и / или использование памяти

Вывод

Когда я решил исследовать эту тему и написать статью о ней, я ожидал, что прототипная модель JavaScript будет полезна для генерации классов. Поскольку синтаксис класса делает методы не перечисляемыми, манипулирование объектами становится намного сложнее, практически нецелесообразно.

Синтаксис класса может создать иллюзию, что JavaScript является языком ООП на основе классов, но это не так. В большинстве подходов вам придется модифицировать прототип объекта, чтобы имитировать множественное наследование. Последний подход, использующий функции фабрики классов, является приемлемой стратегией использования миксинов для составления классов.

Если вы находите программирование на основе прототипов ограничительным, вы, возможно, захотите взглянуть на свое мышление. Прототипы обеспечивают беспрецедентную гибкость, которой вы можете воспользоваться.

Если по какой-либо причине вы по-прежнему предпочитаете классическое программирование, вы можете обратиться к языкам, которые компилируются в JavaScript. Например, TypeScript — это расширенный набор JavaScript, который добавляет (необязательно) статическую типизацию и шаблоны, которые вы узнаете из других классических языков ООП.

Собираетесь ли вы использовать любой из вышеперечисленных подходов в своих проектах? Вы нашли лучшие подходы? Дай мне знать в комментариях!

Эта статья была рецензирована Джеффом Моттом , Скоттом Молинари , Вилданом Софтиком и Джоан Инь . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!