В этой статье мы поговорим о классическом наследовании в JavaScript, распространённых шаблонах его использования, особенностях и частых ошибках применения. Рассмотрим примеры наследования в Babel, Backbone JS и Ember JS и попытаемся вывести из них ключевые принципы объектно-ориентированного наследования для создания собственной реализации в EcmaScript 5.
Статья для тех, кто знаком с наследованием в других языках и сталкивался с попытками эмулировать подобное поведение в JavaScript, а также для тех, кому интересно заглядывать «под капот» различных библиотек и фреймворков, сравнивая их реализацию. Оказывается, простую функцию extend можно реализовать очень по-разному. Нередко при этом допускаются ошибки (см. пункт «Самая распространённая ошибка» ниже).
О классическом наследовании
Под классическим понимается наследование в стиле ООП. Как известно, в чистом JavaScript классического наследования нет. Более того, в нём отсутствует понятие классов. И хотя современная спецификация EcmaScript добавляет синтаксические конструкции для работы с классами, это не меняет того факта, что на самом деле в нём используются функции-конструкторы и прототипирование. Поэтому данная техника нередко называется «псевдоклассическим» наследованием. Она преследует, пожалуй, единственную цель – представлять код в привычном ООП-стиле.
Существуют различные техники наследования, помимо классического: функциональное, прототипное (в чистом виде), фабричное, с использованием миксинов. Сама концепция наследования, набравшая высокую популярность среди разработчиков, подвергается критике и во многих случаях противопоставляется разумной альтернативе – композиции.
Наследование, к тому же именно в классическом стиле, не является панацеей. Его целесообразность зависит от конкретной ситуации в конкретном проекте. Тем не менее, в данной статье не будем углубляться в вопрос о преимуществах и недостатках данного подхода, а сосредоточимся на способах его правильного применения.
Критерии сравнения
Итак, мы решили применить ООП и классическое наследование в языке, изначально его не поддерживающем. Такое решение часто принимается в крупных проектах разработчиками, привыкшими к ООП в других языках. Оно, к тому же, используется многими крупными фреймворками: Backbone, Ember JS, и т.д, а также современной спецификацией EcmaScript.
Лучшим советом по применению наследования будет использовать его в том виде, в каком оно описано в EcmaScript 6, с ключевыми словами class, extends, constructor, и т.д. Если у вас есть такая возможность,
то можете не читать дальшето это лучший вариант с точки зрения читаемости кода и производительности. Всё последующее описание будет полезным для случая использования старой спецификации, когда проект уже начат с использованием ES5 и переход на новую версию не представляется доступным.
Рассмотрим некоторые популярные примеры реализации классического наследования.
Проанализируем их в пяти аспектах:
- Эффективность использования памяти.
- Производительность.
- Статические свойства и методы.
- Ссылка на суперкласс.
- Косметические детали.
Конечно, в первую очередь следует убедиться в эффективности используемого шаблона с точки зрения памяти и производительности. К рассматриваемым примерам из популярных фреймворков в этом плане особых претензий нет, однако на практике нередко встречаются ошибочные примеры, ведущие к утечке памяти и разрастанию стека, о чём мы поговорим ниже.
Остальные перечисленные критерии относятся к удобству использования и читаемости кода.
Более «удобными» будем считать те реализации, которые ближе по синтаксису и функциональности к классическому наследованию в других языках. Так, ссылка на суперкласс (ключевое слово super) является опциональной, но её наличие желательно для полноценной эмуляции наследования. Под косметическими деталями имеется в виду общее оформление кода, удобство отладки, использование с оператором instanceof
и т.п.
Функция «_inherits» в Babel
Рассмотрим наследование в EcmaScript 6 и то, что мы получаем на выходе при компиляции кода в ES5 при помощи Babel.
Ниже приведён пример расширения класса в ES6.
class BasicClass {
static staticMethod() {}
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
простым копированием ссылок в конструктор или его prototype
. При написании собственной реализации наследования можно использовать данный пример как основу и добавить в него объекты с динамическими и статическими полями в качестве дополнительных аргументов функции _inherits
.
Ключевое слово «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
, который вызывается автоматически изнутри родительского конструктора.
Ключевое слово «__super__» является лишь удобным дополнением, т.к. вызов родительского метода всё равно происходит с указанием имени конкретного метода и с передачей контекста this
. Без этого такой вызов привёл бы к зацикливанию в случае многоуровневой цепочки наследования. Метод суперкласса, имя которого, как правило, известно в текущем контексте, может быть вызван и напрямую, так что данное ключевое слово является лишь сокращением для:
Backbone.Model.prototype.toJSON.apply(this, arguments);
С точки зрения кода, расширение классов в Backbone довольно лаконично. Не приходится вручную создавать конструктор класса и отдельно связывать его с родительским классом. Это удобство имеет свою цену – трудности отладки. В отладчике браузера все экземпляры унаследованных таким образом классов имеют одинаковое имя конструктора, объявленное внутри функции extend
– «child». Этот недостаток может показаться несущественным, пока не столкнёшься с ним на практике при отладке цепочки классов, когда становится тяжело понять, экземпляром какого класса является данный объект и от какого класса он наследуется:
Гораздо удобнее эта цепочка отлаживается с использованием наследования из Babel:
Ещё одним недостатком является то, что свойство constructor
является enumerable, т.е. перечисляемым при обходе экземпляра класса в цикле «for-in». Несущественно, однако 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(); // создание экземпдяра
Заметили ошибку?
Этот код будет работать, он позволит классу SubClass унаследовать свойста и методы родительского класса. Однако во время связывания классов через prototype
создаётся экземпляр родительского класса, вызывается его конструктор, что приводит к лишним действиям, особенно если конструктор производит большую работу при создании объекта (runSomeBulkyCode). Так делать нельзя:
SubClass.prototype = new BaseClass();
Это может привести и к тяжело обнаружимым ошибкам, когда свойства, инициализированные в родительском конструкторе (this.x
), записываются не в новый экземпляр, а в прототип всех экземпляров класса SubClass. Кроме того, тот же конструктор BaseClass вызывается затем повторно из конструктора подкласса. В случае, если родительский конструктор требует при вызове некоторые параметры, такую ошибку допустить тяжело, а вот при их отсутсвии – вполне возможно.
Вместо этого следует создавать пустой объект, прототипом которого является свойство 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 |
this._super() |
Косметические детали | Идеально с ES6; требует доработки в собственной реализации под ES5 |
Удобство объявления; проблемы при отладке | Зависит от способа наследования; те же проблемы с отладкой, что и в Backbone |
* — применение Babel идеально при использовании ES6; в случае написания своей реализации на его основе под ES5 статические поля и ссылку на суперкласс придётся дописывать самостоятельно.
Критерий производительности оценивался не в абсолютных значениях, а относительно остальных реализаций, исходя из количества операций и циклов в каждом варианте. В целом, различия в производительности несущественны, т.к. расширение классов обычно происходит однократно на начальном этапе работы приложения и не вызывается повторно.
Все приведённые примеры имеют свои положительные стороны и недостатки, но наиболее практичной можно считать реализацию Babel. Как упоминалось выше, по возможности стоит использовать наследование, специфицированное в EcmaScript 6 с компиляцией в ES5. При отсутствии такой возможности рекомендуется написать свою реализацию функции extend
на базе примера из компилятора Babel с учётом приведённых замечаний и дополнений из других примеров. Так наследование может быть реализовано наиболее гибким и подходящим под данный проект образом.
Источники
- JavaScript.ru: Inheritance
- David Shariff. JavaScript Inheritance Patterns
- Eric Elliott. 3 Different Kinds of Prototypal Inheritance: ES6+ Edition
- Wikipedia: Composition over inheritance
- Mozilla Developer Network: Object.prototype
- Backbone JS
- Ember JS
- Babel
Автор: AGrinko