Хабрахабр

[recovery mode] Классическое наследование в JavaScript. Разбор реализации в Babel, BackboneJS и Ember

В этой статье мы поговорим о классическом наследовании в JavaScript, распространённых шаблонах его использования, особенностях и частых ошибках применения. Рассмотрим примеры наследования в Babel, Backbone JS и Ember JS и попытаемся вывести из них ключевые принципы объектно-ориентированного наследования для создания собственной реализации в EcmaScript 5.

Оказывается, простую функцию extend можно реализовать очень по-разному. Статья для тех, кто знаком с наследованием в других языках и сталкивался с попытками эмулировать подобное поведение в JavaScript, а также для тех, кому интересно заглядывать «под капот» различных библиотек и фреймворков, сравнивая их реализацию. пункт «Самая распространённая ошибка» ниже).
Нередко при этом допускаются ошибки (см.

О классическом наследовании

Под классическим понимается наследование в стиле ООП. Как известно, в чистом JavaScript классического наследования нет. Более того, в нём отсутствует понятие классов. И хотя современная спецификация EcmaScript добавляет синтаксические конструкции для работы с классами, это не меняет того факта, что на самом деле в нём используются функции-конструкторы и прототипирование. Поэтому данная техника нередко называется «псевдоклассическим» наследованием. Она преследует, пожалуй, единственную цель – представлять код в привычном ООП-стиле.

Сама концепция наследования, набравшая высокую популярность среди разработчиков, подвергается критике и во многих случаях противопоставляется разумной альтернативе – композиции. Существуют различные техники наследования, помимо классического: функциональное, прототипное (в чистом виде), фабричное, с использованием миксинов.

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

Критерии сравнения

Итак, мы решили применить ООП и классическое наследование в языке, изначально его не поддерживающем. Такое решение часто принимается в крупных проектах разработчиками, привыкшими к ООП в других языках. Оно, к тому же, используется многими крупными фреймворками: Backbone, Ember JS, и т.д, а также современной спецификацией EcmaScript.

Если у вас есть такая возможность, то можете не читать дальше то это лучший вариант с точки зрения читаемости кода и производительности. Лучшим советом по применению наследования будет использовать его в том виде, в каком оно описано в EcmaScript 6, с ключевыми словами class, extends, constructor, и т.д. Всё последующее описание будет полезным для случая использования старой спецификации, когда проект уже начат с использованием ES5 и переход на новую версию не представляется доступным.

Рассмотрим некоторые популярные примеры реализации классического наследования.

Проанализируем их в пяти аспектах:

  1. Эффективность использования памяти.
  2. Производительность.
  3. Статические свойства и методы.
  4. Ссылка на суперкласс.
  5. Косметические детали.

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

Так, ссылка на суперкласс (ключевое слово super) является опциональной, но её наличие желательно для полноценной эмуляции наследования. Более «удобными» будем считать те реализации, которые ближе по синтаксису и функциональности к классическому наследованию в других языках. Под косметическими деталями имеется в виду общее оформление кода, удобство отладки, использование с оператором instanceof и т.п.

Функция «_inherits» в Babel

Рассмотрим наследование в EcmaScript 6 и то, что мы получаем на выходе при компиляции кода в ES5 при помощи Babel.

Ниже приведён пример расширения класса в ES6.

class BasicClass constructor(x) { this.x = x; } someMethod() {}
} class DerivedClass extends BasicClass { static staticMethod() {} constructor(x) { super(x); } someMethod() { super.someMethod(); }
}

Как видно, синтаксис близок к другим ООП-языкам, за исключением, быть может, отсутствия типов и модификаторов доступа. И в этом уникальность использования ES6 с компилятором: мы можем позволить себе удобный синтаксис, и при этом получить на выходе работающий код на ES5. Ни один из последующих примеров не может похвастать такой синтаксической простотой, т.к. в них функция наследования реализуется сразу в готовом виде, без преобразований синтаксиса.

Компилятор Babel реализует наследование при помощи простой функции _inherits:

function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

Основную суть здесь можно свести к данной строке:

subClass.prototype = Object.create(superClass.prototype);

Данный вызов создаёт объект с указанным прототипом. Свойство prototype конструктора subClass указывает на новый объект, прототипом которого является prototype родительского класса superclass. Таким образом, это простое прототипное наследование, замаскированное под классическое в исходном коде.

При помощи следующей строки кода реализуется наследование статических полей класса:

Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;

Конструктор родительского класса (т.е. функция) становится прототипом конструктора нового класса (т.е. другой функции). Все статические свойства и методы родительского класса становятся таким образом доступными из класса-наследника. При отсутствии функции setPrototypeOf Babel предусматривает прямую запись прототипа в скрытое свойство __proto__ – техника не рекомендуемая, но подходящая на крайний случай при использовании старых браузеров.

При написании собственной реализации наследования можно использовать данный пример как основу и добавить в него объекты с динамическими и статическими полями в качестве дополнительных аргументов функции _inherits. Сама запись методов, как статических, так и динамических, происходит отдельно от вызова _inherits простым копированием ссылок в конструктор или его prototype.

Например, вызов родительского конструктора из примера выше заменяется следующей строкой: Ключевое слово «super» при компиляции просто заменяется прямым вызовом прототипа.

return _possibleConstructorReturn(this, (DerivedClass.__proto__ || Object.getPrototypeOf(DerivedClass)).call(this, x));

Babel использует много вспомогательных функций, которые мы не будем здесь освещать. Суть в том, что в данном вызове интерпретатор получает прототип конструктора текущего класса, которым как раз является конструктор базового класса (см. выше), и вызывает его в текущем контексте this.

В собственной реализации на чистом ES5 стадия компиляции нам недоступна, поэтому можно добавить поля _super в конструктор и его prototype, чтобы иметь удобную ссылку на родительский класс, например:

function extend(subClass, superClass) { // ... subClass._super = superClass; subClass.prototype._super = superClass.prototype;
}

Функция «extend» в Backbone JS

Backbone JS предоставляет функцию extend для расширения классов библиотеки: Model, View, Collection, и т.д. При желании её можно позаимствовать и для собственных целей. Ниже приведён код функции extend из версии Backbone 1.3.3.

var extend = function(protoProps, staticProps) { var parent = this; var child; // The constructor function for the new subclass is either defined by you // (the "constructor" property in your `extend` definition), or defaulted // by us to simply call the parent constructor. if (protoProps && _.has(protoProps, 'constructor')) { child = protoProps.constructor; } else { child = function(){ return parent.apply(this, arguments); }; } // Add static properties to the constructor function, if supplied. _.extend(child, parent, staticProps); // Set the prototype chain to inherit from `parent`, without calling // `parent`'s constructor function and add the prototype properties. child.prototype = _.create(parent.prototype, protoProps); child.prototype.constructor = child; // Set a convenience property in case the parent's prototype is needed // later. child.__super__ = parent.prototype; return child;
};

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

var MyModel = Backbone.Model.extend({ constructor: function() { // ваш конструктор класса; его использование опционально, // но если используете, нужно обязательно вызвать родительский конструктор Backbone.Model.apply(this, arguments); }, toJSON: function() { // метод переопределён, но можно вызвать родительский через «__super__» MyModel.__super__.toJSON.apply(this, arguments); }
}, { staticMethod: function() {}
});

Данная функция реализует расширение базового класса с поддержкой собственного конструктора и статических полей. Она возвращает функцию-конструктор класса. Собственно наследование реализуется следующей строкой, аналогичной примеру из Babel:

child.prototype = _.create(parent.prototype, protoProps);

Функция _.create() – аналог Object.create() из ES6, реализованный библиотекой Underscore JS. Её второй аргумент позволяет сразу записать в прототип свойства и методы protoProps, переданные при вызове функции extend.

Наследование статических полей класса реализуется простым копированием ссылок (или значений) из родительского класса и объекта со статическими полями, переданного в качестве второго аргумента функции extend, в создаваемый конструктор:

_.extend(child, parent, staticProps);

Указание конструктора является опциональным и производится внутри объявления класса в виде метода «constructor». При его использовании приходится обязательно вызывать конструктор родительского класса (как и в других языках), поэтому вместо этого разработчики чаще используют метод initialize, который вызывается автоматически изнутри родительского конструктора.

вызов родительского метода всё равно происходит с указанием имени конкретного метода и с передачей контекста this. Ключевое слово «__super__» является лишь удобным дополнением, т.к. Метод суперкласса, имя которого, как правило, известно в текущем контексте, может быть вызван и напрямую, так что данное ключевое слово является лишь сокращением для: Без этого такой вызов привёл бы к зацикливанию в случае многоуровневой цепочки наследования.

Backbone.Model.prototype.toJSON.apply(this, arguments);

С точки зрения кода, расширение классов в Backbone довольно лаконично. Не приходится вручную создавать конструктор класса и отдельно связывать его с родительским классом. Это удобство имеет свою цену – трудности отладки. В отладчике браузера все экземпляры унаследованных таким образом классов имеют одинаковое имя конструктора, объявленное внутри функции extend – «child». Этот недостаток может показаться несущественным, пока не столкнёшься с ним на практике при отладке цепочки классов, когда становится тяжело понять, экземпляром какого класса является данный объект и от какого класса он наследуется:

image

Гораздо удобнее эта цепочка отлаживается с использованием наследования из Babel:

image

перечисляемым при обходе экземпляра класса в цикле «for-in». Ещё одним недостатком является то, что свойство constructor является enumerable, т.е. Несущественно, однако Babel позаботился и об этом, объявляя конструктор с перечислением необходимых модификаторов.

Ссылка на суперкласс в Ember JS

Ember JS использует как функцию inherits, реализованную Babel, так и свою собственную реализацию extend – очень сложную и навороченную, с поддержкой миксинов и прочего. На приведение кода этой функции в данной статье просто не хватит места, что уже ставит под сомнение её производительность при использовании для собственных нужд вне фреймворка.

Она позволяет вызвать родительский метод без указания конкретного имени метода, например: Что представляет особый интерес, так это реализация ключевого слова «super» в Ember.

var MyClass = MySuperClass.extend({ myMethod: function (x) { this._super(x); }
});

Заметьте: при вызове метода супер-класса (this._super(x)) мы не указываем название метода. И никаких преобразований кода при компиляции не происходит.

Как Ember узнаёт, какой метод нужно вызвать при обращении к универсальному свойству _super без преобразования кода? Как это работает? Всё дело в сложной работе с классами и в хитрой функции _wrap, код которой приведён далее:

function _wrap(func, superFunc) { function superWrapper() { var orig = this._super; this._super = superFunc; // <--- магия здесь var ret = func.apply(this, arguments); this._super = orig; return ret; } // здесь опущена нерелевантная часть кода return superWrapper;
}

При наследовании класса Ember проходит по всем его методам и вызывает для каждого данную функцию-обёртку, заменяя каждую оригинальную функцию на superWrapper.

В свойство _super записывается указатель на родительский метод, соответствующий по названию вызываемому методу (работа по определению соответствий произошла ещё на этапе создания класса при вызове extend). Обратите внимание на строку, помеченную комментарием. Затем свойству _super присваивается исходное значение, что позволяет использовать его в глубоких цепочках вызовов. Далее вызывается оригинальная функция, изнутри которой можно обращаться к _super как к родительскому методу.

Но важно заметить, что всё это негативно сказывается на производительности. Идея, без сомнения, интересная, и её можно применять в своей реализации наследования. Поэтому при глубокой цепочке вызовов методов одного класса произойдёт разрастание стека. Каждый метод класса (по крайней мере, из тех, которые переопределяют родительский метод), независимо от факта использования свойства _super в нём, оборачивается в отдельную функцию. Поэтому можно сказать, что данная реализация является чересчур громоздкой и не оправдывает полученного преимущества в виде сокращённой формы записи. Особенно это критично для методов, вызываемых регулярно в цикле или при отрисовке пользовательского интерфейса.

Самая распространённая ошибка

Одной из самых распространённых и опасных ошибок на практике является создание экземпляра родительского класса при его расширении. Приведём пример подобного кода, использования которого следует всегда избегать:

function BaseClass() { this.x = this.initializeX(); this.runSomeBulkyCode();
}
// ...объявление методов BasicClass в прототипе... function SubClass() { BaseClass.apply(this, arguments); this.y = this.initializeY();
} // собственно наследование
SubClass.prototype = new BaseClass();
SubClass.prototype.constructor = SubClass; // ...объявление методов SubClass в прототипе... new SubClass(); // создание экземпдяра

Заметили ошибку?

Однако во время связывания классов через prototype создаётся экземпляр родительского класса, вызывается его конструктор, что приводит к лишним действиям, особенно если конструктор производит большую работу при создании объекта (runSomeBulkyCode). Этот код будет работать, он позволит классу SubClass унаследовать свойста и методы родительского класса. Так делать нельзя:

SubClass.prototype = new BaseClass();

Кроме того, тот же конструктор BaseClass вызывается затем повторно из конструктора подкласса. Это может привести и к тяжело обнаружимым ошибкам, когда свойства, инициализированные в родительском конструкторе (this.x), записываются не в новый экземпляр, а в прототип всех экземпляров класса SubClass. В случае, если родительский конструктор требует при вызове некоторые параметры, такую ошибку допустить тяжело, а вот при их отсутсвии – вполне возможно.

Вместо этого следует создавать пустой объект, прототипом которого является свойство prototype родительского класса:

SubClass.prototype = Object.create(BasicClass.prototype);

Итоги

Мы привели примеры реализации псевдоклассического наследования в компиляторе Babel (ES6-to-ES5) и во фреймворках Backbone JS, Ember JS. Ниже приведена сравнительная таблица всех трёх реализаций по описанным ранее критериям.

Babel

Backbone JS

Ember JS

Память

Равнозначно

Производительность

Высшая

Средняя

Низшая

Статические поля

+ (только в ES6)*

+

(за исключением внутреннего использования наследования из Babel)

Ссылка на суперкласс

super.methodName() (только в ES6)

Constructor.__super__.prototype
.methodName.apply(this)

this._super()

Косметические детали

Идеально с ES6;
требует доработки в собственной реализации под ES5

Удобство объявления; проблемы при отладке

Зависит от способа наследования; те же проблемы с отладкой, что и в Backbone

* — применение Babel идеально при использовании ES6; в случае написания своей реализации на его основе под ES5 статические поля и ссылку на суперкласс придётся дописывать самостоятельно.

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

Как упоминалось выше, по возможности стоит использовать наследование, специфицированное в EcmaScript 6 с компиляцией в ES5. Все приведённые примеры имеют свои положительные стороны и недостатки, но наиболее практичной можно считать реализацию Babel. Так наследование может быть реализовано наиболее гибким и подходящим под данный проект образом. При отсутствии такой возможности рекомендуется написать свою реализацию функции extend на базе примера из компилятора Babel с учётом приведённых замечаний и дополнений из других примеров.

Источники

  1. JavaScript.ru: Inheritance
  2. David Shariff. JavaScript Inheritance Patterns
  3. Eric Elliott. 3 Different Kinds of Prototypal Inheritance: ES6+ Edition
  4. Wikipedia: Composition over inheritance
  5. Mozilla Developer Network: Object.prototype
  6. Backbone JS
  7. Ember JS
  8. Babel
Показать больше

Похожие публикации

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»