Распространённые заблуждения о наследовании в JavaScript

4rontender (Rinat Valiullov)
devSchacht
Published in
14 min readOct 18, 2019

Перевод статьи Eric Elliott: Common Misconceptions About Inheritance in JavaScript

WAT? — восклицание: Звук, издаваемый программистом, когда что-то нарушает правило (принцип) наименьшего удивления, поражая его своим нелогичным поведением.

> .1 + .2
0.30000000000000004
> WAT? О, МОЙ БОГ! ЗАТКНИСЬ! ГЛУПЫЙ JAVASCRIPT!!!
...

Также я могу воскликнуть WAT?, когда общаюсь с опытными JavaScript разработчиками, которые пренебрегают изучением основных механизмов прототипного наследования: одного из наиболее важных нововведений в истории CS (Computer Science), и одного из Двух столпов JavaScript.

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

Если ты не разбираешься в прототипах, ты не понимаешь JavaScript.

Разве классическое наследование и прототипное наследование не одно и то же, и какое из них предпочесть — всего лишь дело стилистических предпочтений?

Нет.

Классическое и прототипное наследования фундаментально и семантически различны.

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

В классическом наследовании экземпляры наследуются от образца (класса), и создают отношения между подклассами. Другими словами, вы не можете использовать класс, также как вы использовали бы экземпляр. Вы не можете вызывать методы экземпляра в самом определении класса. Сначала вы должны создать экземпляр, а уж затем вызвать методы из этого экземпляра.

В прототипном наследовании экземпляры наследуются от других экземпляров. Используя прототипы делегатов (устанавливая прототип одного экземпляра, чтобы он ссылался на объект экземпляра), буквально Объекты, Связывающиеся с Другими Объектами, или ОСДО, как их называет Кайл Симпсон. Используя конкатенативное наследование, вы просто копируете свойства из объекта экземпляра в новый экземпляр.

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

Прототипное наследование не обязательно создаёт аналогичные иерархии. Я рекомендую держать цепочки прототипов как можно более поверхностными. Легко объединить множество прототипов в один прототип делегатов.

Короче говоря:

  • Класс — это образец (модель).
  • Прототипэкземпляр объекта.

Разве классы не являются правильным способом создания объектов в JavaScript?

Нет.

Существует несколько верных способов создания объектов в JavaScript. Первый и наиболее распространённый — литерал объекта. Это выглядит следующим образом (ES6):

// ES6 / ES2015, потому что 2015.let mouse = {
furColor: 'brown',
legs: 4,
tail: 'long, skinny',
describe () {
return `A mouse with ${this.furColor} fur,
${this.legs} legs, and a ${this.tail} tail.`;
}
};

Конечно же, литерал объекта существуют гораздо дольше, чем ES6, но им не хватает сокращённого синтаксиса определения методов, показанного выше, и вы вынуждены использовать var вместо let. И да, шаблонные строки в методе .describe() также не будут работать в ES5.

Вы можете привязать прототипы делегатов с помощью Object.create() (ES5):

let animal = {
animalType: 'animal',
describe () {
return `An ${this.animalType}, with ${this.furColor} fur,
${this.legs} legs, and a ${this.tail} tail.`;
}
};
let mouse = Object.assign(Object.create(animal), {
animalType: 'mouse',
furColor: 'brown',
legs: 4,
tail: 'long, skinny'
});

Давайте немного разберёмся с этим. animal это прототип делегата. mouse - экземпляр. Когда вы пытаетесь получить доступ к свойству объекта mouse, которого в нём нет, среда выполнения JavaScript будет искать его в animal (делегат).

Object.assign() это ES6 особенность, появившаяся благодаря Рику Уолдрону, будучи ранее реализованной в нескольких десятках библиотек. Вы можете знать её как $.extend() из библиотеки jQuery или _.extend() из библиотеки Underscore. В Lodash есть своя версия assign(). Вы передаёте в целевой объект столько исходных объектов, сколько вам необходимо, разделяя их запятой. Этот метод скопирует все перечисляемые собственные свойства путём их присвоения из исходных объектов целевым объектам. Если есть какие-либо конфликты имён свойств, присвоится свойство объекта, переданного последним.

Object.create() - это ES5 особенность, появившаяся благодаря Дугласу Крокфорду, чтобы мы могли привязывать прототипы делегатов без использования конструкторов и ключевого слова new.

Я пропускаю пример с функцией конструктором, потому что не могу рекомендовать её. Я видел множество злоупотреблений этой функцией, и как она доставляет много проблем. Стоит отметить, что множество умных людей со мной не согласится. Умные люди могут делать всё, что они захотят.

Мудрые люди последуют совету Дугласа Крокфорда:

“Если особенность иногда опасна, и существует лучшее решение, то всегда используйте лучшее решение.”

Разве вам не нужна функция конструктора, чтобы определить поведение при создании экземпляра объекта и его инициализации?

Нет.

Любая функция может создавать и возвращать объекты. Когда это не функция конструктор (паттерн конструктор), она называется фабричной функцией (паттерн фабрика).

Лучшее Решение

let animal = {
animalType: 'animal',
describe () {
return `An ${this.animalType} with ${this.furColor} fur,
${this.legs} legs, and a ${this.tail} tail.`;
}
};
let mouseFactory = function mouseFactory () {
return Object.assign(Object.create(animal), {
animalType: 'mouse',
furColor: 'brown',
legs: 4,
tail: 'long, skinny'
});
};
let mickey = mouseFactory();

Как правило я не называю фабрики “фабриками” — это просто для иллюстрации. Я просто назвал бы её mouse().

Разве вам не нужны функции конструкторы для сохранения конфиденциальности в JavaScript?

Нет.

Всякий раз, когда вы создаёте функцию в JavaScript, эта функция имеет доступ к переменным внешней (окружающей её) функции. Когда вы их используете, движок JS создаёт замыкание. Замыкания — это распространённый паттерн в JavaScript, и они, как правило, используются для сохранения конфиденциальности данных.

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

let animal = {
animalType: 'animal',
describe () {
return `An ${this.animalType} with ${this.furColor} fur,
${this.legs} legs, and a ${this.tail} tail.`;
}
};
let mouseFactory = function mouseFactory () {
let secret = 'secret agent';
return Object.assign(Object.create(animal), {
animalType: 'mouse',
furColor: 'brown',
legs: 4,
tail: 'long, skinny',
profession () {
return secret;
}
});
};
let james = mouseFactory();

Означает ли использование ключевого слова new, что в коде используется классическое наследование?

Нет.

Ключевое слово new используется для вызова конструктора. Что он делает в действительности, так это:

  • Создаёт новый экземпляр
  • Связывает this с новым экземпляром
  • Создаёт ссылку нового делегата объекта [[Prototype]] на объект, на который ссылается свойство функции конструктора prototype.
  • Создаёт ссылку нового свойства .constructor объекта на конструктор, который был вызван
  • Именует тип объекта после конструктора, который вы сможете заметить в консоли отладки. Вы увидите, например, [Object Foo] вместо [Object object].
  • Позволяет instanceof проверить, является ли ссылка на прототип объекта тем же самым объектом, на который ссылается свойство .prootype конструктора.

instanceof лжёт

Давайте на секунду остановимся здесь и пересмотрим значение instanceof. Ваше мнение о его пользе может измениться.

Важно: в instanceof проверка типов происходит совсем не так как это происходит в строго типизированных языках. Вместо этого, он проверяет на идентичность прототипу, и его легко обмануть. Например, он не будет работать в разных контекстах выполнения (распространённый источник ошибок, разочарования и ненужных ограничений). Для справки, пример с библиотекой bacon.js.

Его также легко ввести в заблуждение с помощью false positives (и чаще всего) false negatives из другого источника. Поскольку это проверка идентичности свойства .prototype целевого объекта, это может приводить к странным вещам:

> function foo() {}
> var bar = { a: 'a'};
> foo.prototype = bar; // Object {a: "a"}
> baz = Object.create(bar); // Object {a: "a"}
> baz instanceof foo // true. oops.

Этот результат полностью соответствует спецификации JavaScript. Ничего не сломано — просто instanceof не может дать никаких гарантий относительно безопасности типов. Его легко ввести в заблуждение, как с помощью false positives, так и с помощью false negatives.

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

instanceof лжёт.

Используйте Утиную типизацию.

new странный

WAT? new также делает странности при возврате значений. Если вы попробуете вернуть примитивное значение, он не будет работать. Если возвращаете любой произвольный объект, тогда всё получится, но this будет отброшен, сломав все ссылки на него (включая .call() и .apply()), и разрывая ссылку на ссылку .prototypeконструктора.

Есть ли значительная разница в производительности между классическим и прототипным наследованием?

Нет.

Возможно вы слышали о скрытых классах, и полагаете что конструкторы значительно превосходят объекты, созданные с помощью метода Object.create(). Эти различия в производительности весьма завышены.

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

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

Не убедил? Для того, чтобы микрооптимизация имела хоть какое-либо заметное влияние на ваше приложение, вам необходимо повторить операцию сотни тысяч раз, и единственные различия в микрооптимизации, о которых вы должны когда-либо беспокоиться, — это те, которые различаются друг от друга на целый порядок.

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

Можете ли заметить разницу между 0.000000000001 и 0.00000000001 секундами? Я тоже не могу, но я совершенно точно могу увидеть разницу между загрузкой 10 маленьких иконок и загрузкой одного веб-шрифта!

Если после профилирования своего приложения вы обнаруживаете, что создание объекта действительно является узким местом, самый быстрый способ создать его — это не использовать ключевое слово new и классическое ОО. Самый быстрый способ - использовать литералы объектов. Вы можете сделать это с помощью циклов и добавить объекты в пул, чтобы избежать удаления сборщиком мусора. Если и стоит отказаться от прототипного OO в пользу производительности, то стоит вообще отказаться от цепочки прототипов и наследования, чтобы получить литералы объектов.

Но Google сказал, что классы — это быстро…

WAT? Google разрабатывает движок JavaScript. Вы же разрабатываете приложение. Очевидно, что то, что волнует их, и то, что волнует вас — совсем разные вещи. Оставьте Google разбираться с микрооптимизациями. Вы же беспокойтесь о реальных узких местах в вашем приложении. Я уверяю, вы получите гораздо больше пользы, сосредоточившись на чём-то ещё.

Есть ли большая разница в потреблении памяти между классическим и прототипным наследованием?

Нет.

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

На самом деле, если вы начинаете с фабричных функций, вам проще переключиться на пулы объектов, чтобы более тщательно управлять памятью и избегать периодических блокировок сборщиком мусора. Подробнее о том, почему так неудобно с конструкторами смотрите примечание WAT? в разделе означает ли использование ключевого слова new, что в коде используется классическое наследование?

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

“…если вам нужна максимальная гибкость в управлении памятью, используйте фабричные функции…”

Нативные API используют конструкторы. Разве они не более идиоматичны, чем фабричные функции?

Нет.

Фабричные функции чрезвычайно распространены в JavaScript. Например, самая популярная JavaScript библиотека, jQuery предоставляет эти функции пользователям. Джон Резиг писал о том, чтобы использовать фабричные функции и расширения прототипов вместо классов. По сути, дело сводится к тому, что он не хотел в вызовах набирать new каждый раз, чтобы сделать выборку элементов. Как бы это выглядело?

/**
классный jQuery — альтернативная реальность, где jQuery действительно бы проиграл и никогда бы не выиграл
ИЛИПочему никому не понравился бы jQuery, если бы он предоставлял класс вместо фабричной функции.
**/
// Это выглядит глупо. Мы создаём новый элемент DOM
// с id="foo"? Нет. Мы выбираем существующий элемент DOM
// с id="foo", и оборачиваем его в экземпляр объекта jQuery.
var $foo = new $('#foo');
// Кроме того, есть много дополнительного кода, который не дает никакого выигрыша.
var $bar = new $('.bar');
var $baz = new $('.baz');
// И это просто... хорошо. Я не знаю что.
var $bif = new $('.foo').on('click', function () {
var $this = new $(this);
$this.html('clicked!');
});

Где ещё есть фабричные функции?

  • React React.createClass() - это фабричная функция.
  • Angular использует классы и фабричные функции, но оборачивает их в фабричную функцию в Внедрения Зависимости (DI) ​​контейнере. Все провайдеры — это синтаксический сахар, которые используют фабричную функцию .provider(). Даже провайдер .factory() , и провайдер .service() оборачивают обычные конструкторы и предоставляют... как вы уже догадались: Фабричную функцию для DI потребителей.
  • Ember Ember.Application.create(); — это фабричная функция, которая производит приложение. Вместо того, чтобы создавать конструкторы с помощью ключевого слова new, методы .extend() дополняют приложение.
  • Node базовые функции вроде http.createServer() and net.createServer() — это фабричные функции.
  • Express — это фабричная функция, которая создаёт приложение.

Как видите, практически все самые популярные библиотеки и фреймворки для JavaScript широко используют фабричные функции. Только паттерн инстанцирования объекта, более распространённый, чем фабричные функции в JS, — это литерал объекта.

Встроенные в JavaScript модули начинали использовать конструкторы, потому что Брэндону Эйку было сказано сделать язык похожим на Java. JavaScript продолжает использовать конструкторы для самосогласованности. Было бы нелепо попытаться заменить всё на фабричные функции и запретить конструкторы.

Это не означает, что ваш API плох.

Разве классическое наследование не более идиоматично, чем прототипное наследование?

Нет.

Каждый раз, когда я слышу это заблуждение, мне хочется спросить: “А ты JavaScript-разработчик вообще?” и продолжить… но я сдержу себя в руках, и вместо этого расскажу как следует делать.

Не расстраивайся, если у тебя возник этот же вопрос. Это не твоя вина. Курсы по JavaScript — ерунда!

Ответ на этот вопрос большой, гигантский.

Нет… (но)

Прототипы являются идиоматической парадигмой наследования в JS, и class — захватнический вид.

Короткая история популярных JavaScript библиотек:

Сначала каждый писал свои собственные библиотеки, и открытое совместное использование не было большой проблемой. А потом появился Prototype. (Название — большой намёк). Prototype сделал своё дело, расширив встроенные прототипы делегатов, используя конкатенативное наследование.

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

Следующей по популярности библиотекой была jQuery. Большим претендентом на славу JQuery были JQuery плагины. Они работали, расширяя прототип делегата jQuery, используя конкатенативное наследование.

Ты начинаешь чувствовать здесь закономерность?

jQuery остаётся самой популярной библиотекой JavaScript, когда-либо созданной. С ОГРОМНЫМ запасом. ОГРОМНЫМ.

Это то, где вещи начинают путаться, и расширение класса начинает проникать в язык… Джон Резиг (автор jQuery) писал про Простое классическое наследование в JavaScript, и люди в действительности начинали пользоваться им, хотя сам Джон не думал, что это часть jQuery (потому что прототипное ОО делало ту же работу лучше).

Появились полупопулярные Java фреймворки, такие как ExtJS, первыми открывшие, вроде бы не совсем мейнстримное использование класса в JavaScript. Это был 2007. JavaScript было уже 12 лет, прежде чем довольно популярная библиотека начала предоставлять пользователям JS возможность классического наследования.

Три года спустя, на сцену ворвался Backbone и в нём был метод .extend(), который имитировал классическое наследование, включая все свои противные особенности такие как хрупкие объектные иерархии. Вот когда весь ад вырвался наружу.

Приложение размером примерно в 100 тысяч строк кода начинает использовать Backbone. Несколько месяцев я производил отладку шестиуровневой иерархии, пытаясь найти баг. Проходил через каждую строку кода вверх по цепочке `super`. Нашёл и исправил ошибку в базовом классе верхнего уровня. Затем пришлось исправить множество дочерних классов, потому что они зависели от некорректного поведения базового класса. В итоге часы разочарований вместо пятиминутного исправления бага.

Это не JavaScript. Я неожиданно снова оказался в Java-аду. Это одинокое, тёмное, страшное место, где любые быстрые движения могут привести к тому, что целые иерархии содрогнутся и рухнут в объединяющихся, жёстко связанных конвульсиях.

Это чудовища, из которых состоят исправления

Но, сквозь документацию Backbone, пробивается луч света:

// Луч свет в чреве у
// зверя...
var object = {};_.extend(object, Backbone.Events);object.on("alert", function(msg) {
alert("Triggered " + msg);
});
object.trigger("alert", "an event");

Наш старый друг, конкатенативное наследование, спасший ситуацию благодаря Backbone.Events примеси.

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

Наследование в JavaScript это так просто оно сбивает с толку людей, которые ожидают, что потребуются определённые усилия.

Чтобы усложнить ситуацию, мы добавили class.

И как мы сделали? Мы построили это поверх прототипного наследования, используя прототипы делегатов и конкатенацию объектов, конечно же!

Это всё равно что приехать на Tesla Model S в автосалон и обменять его на ржавый Ford Pinto 1983 года.

Разве выбор между классическим и прототипным наследованием не зависит от варианта использования?

Нет.

Прототипное OO проще, гибче и намного менее подвержено ошибкам. Я заявлял об этом и призывал людей найти убедительный пример использования классов много лет. Сотни тысяч людей услышали этот призыв. И те немногие ответы, которые я получил, зависели от одного или нескольких заблуждений, рассмотренных в этой статье.

Я когда-то был поклонником классового наследования. Я полностью купился на это. Я повсюду выстраивал иерархии объектов. Я разработал визуальные средства Быстрой Разработки Приложений, чтобы помочь архитекторам программного обеспечения проектировать иерархии объектов и отношения, которые имели бы смысл. Потребовался визуальный инструмент для реального построения и отображения связей между объектами в корпоративных приложениях с использованием классовой таксономии наследования.

Вскоре после перехода с C++ и Java на JavaScript, я перестал делать всё это. Не потому, что я разрабатывал менее сложные приложения (скорее наоборот), а из-за того, что JavaScript был намного проще, мне больше не нужны были все эти ОО инструменты для проектирования.

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

Я был не одинок. В те дни для новых версий программного обеспечения был характерен подход полного переписывания кода. В большинстве случаев это было вызвано устаревшим, застывшим кодом, вызванным застывшими, хрупкими иерархиями классов. Были написаны целые книги об ошибках проектирования ОО и о том, как избежать их или избавиться от них путём рефакторинга. Казалось, что у каждого разработчика была копия “Design Patterns” на столе.

Я рекомендую вам последовать совету “Банды четырех” по этому вопросу:

“Используйте композицию объектов, вместо наследования классов.”

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

В JavaScript у нас нет такого оправдания. На самом деле в JavaScript гораздо проще создать нужный вам объект путём сборки различных прототипов вместе, нежели управлять иерархиями объектов.

WAT? Серьёзно. Хотите объект jQuery, который может превратить любую введённую дату в megaCalendarWidget? Вам не нужно расширять класс. JavaScript имеет динамическое расширение объекта, а jQuery предоставляет свой собственный прототип, так что вы можете просто расширить его - без ключевого слова extend! WAT?:

/*
Как ркасширить jQuery прототип:
Так сложно.
Мозг взрывается.
охх.
*/
jQuery.fn.megaCalendarWidget = megaCalendarWidget;// АЛЛИЛУЙЯ! Я так рад, что всё кончено.

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

Точно так же вы можете использовать Object.assign() для объединения любого количества объектов вместе с последним по приоритетности:

import ninja from 'ninja'; // ES6 модули
import mouse from 'mouse';
let ninjamouse = Object.assign({}, mouse, ninja);

Нет, действительно — любое количество объектов:

// Я не уверен, что Object.assign() доступен в ES6
// так что на этот раз я буду использовать Lodash. Он похож на Underscore,
// но только на 200% круче. Вы также можете использовать
// jQuery.extend() или _.extend() из Underscore
var assign = require('lodash/object/assign');
var skydiving = require('skydiving');
var ninja = require('ninja');
var mouse = require('mouse');
var wingsuit = require('wingsuit');
// Количество крутого в этом куску кода может быть слишком много
// for seniors with heart conditions or young children.
var skydivingNinjaMouseWithWingsuit = assign({}, // создать новый объект
skydiving, ninja, mouse, wingsuit); // скопировать всё в него.

Эта техника называется конкатенативное наследование, а прототипы, от которых вы наследуете, иногда называют прототипами экземпляров, которые отличаются от прототипов делегатов тем, что вы копируете их, а не делегируете им.

В ES6 есть ключевое слово class. Не означает ли это то, что мы все должны использовать его?

Нет.

Есть много веских причин избегать ключевого слова class в ES6, не в последнюю очередь из-за того, что оно нелепо выглядит в JavaScript.

У нас уже есть удивительно мощная и выразительная система объектов в JavaScript. Концепция класса в том виде, в каком она реализована в JS сегодня, весьма ограниченна (в плохом смысле этого слова, а не наоборот), и скрывает очень крутую прототипную систему OO, которая была встроена в язык давным-давно.

Вы знаете, что действительно хорошо для JavaScript? Лучший синтаксический сахар и абстракции, построенные на основе прототипов, с точки зрения программиста, знакомого с прототипами ОО.

Это может быть действительно круто.

Я веду онлайн-уроки по прототипному OO в JavaScript.

Предварительно заказать сейчас для пожизненного доступа ко всем моим JavaScript курсам.

Eric Elliott автор книг “Programming JavaScript Applications” (O’Reilly), и ведёт документальную передачу, “Programming Literacy”. Он внёс свой вклад в опыт разработки программного обеспечения для Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, и ведущих артистов, в том числе Usher, Frank Ocean, Metallica,и множество других.

Большую часть своего времени он проводит в San Francisco Bay Area с самой красивой женщиной в мире.

Слушайте наш подкаст в iTunes и SoundCloud, читайте нас на Medium, контрибьютьте на GitHub, общайтесь в группе Telegram, следите в Twitter и канале Telegram, рекомендуйте в VK и Facebook.

--

--