Frontender Magazine

Два столпа JavaScript

Часть 1: наследование через прототипы

Перед тем как мы начнем — позвольте представиться. Во время чтения вы, вероятно, будете задаваться вопросом «кто он такой и что о себе воображает».

Меня зовут Эрик Элиот, я автор книги «Programming JavaScript Applications» (O’Reilly), ведущий документального фильма «Programming Literacy» и создатель серии платных онлайн-курсов «Learn JavaScript with Eric Elliott».

Внес свой вклад в создание ПО для Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, топ-артистов таких как Usher, Frank Ocean, Metallica, и многих других.

Однажды…

Я блуждал во тьме. Я был слеп — топтался на месте, натыкался на предметы и ломал их, приводил в беспорядок всё к чему прикасался.

В 90-х я программировал на C++, Delphi и Java, создавал 3D-плагины для программы которая позже стала называться Maya (используется большинством крупных киностудий для создания блокбастеров).

И тут случилось это: пришествие Интернета. Все начали создавать веб-сайты, и, после практики создания и поддержки пары интернет-журналов, друг убедил меня, что будущее Интернета за SaaS (тогда этого термина не было и в помине). В то время я об этом не догадывался, однако этот общий настрой стал потихоньку влиять на мое общее представление о программировании, потому что если вы хотите делать хороший SaaS продукт — приходится использовать JavaScript.

И как только я начал его использовать — я уже не мог остановится. Внезапно, всё стало проще. Программы, которые я создавал, стали более гибкими. Код смог дольше существовать без необходимости рефакторинга. Вначале я думал о JavaScript как скриптовом языке для взаимодействия элементов страницы — однако, когда я узнал о cookies и AJAX, изначальное представление о нём изменилось.

JavaScript предлагает то чего не хватает в других языках:

Свободу!

JavaScript является одним из наиболее важных языков программирования всех времен не просто из-за своей популярности, а потому что он популяризует две чрезвычайно важные для развития всей науки программирования парадигмы:

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

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

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

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

И если вы всё ещё находитесь в ней — пришло время перейти на новый уровень.

Если вы создаете конструкторы и наследуете их — вы так и не изучили JavaScript. И не важно, что вы занимаетесь этим с 1995 года. Вы не в состоянии воспользоваться основными преимуществами языка.

Вы работаете с липовым JavaScript, который существует только как надстройка над Java.

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

Мы разводим бардак

«Не понимающие что идут в темноте никогда не выйдут на свет». ~ Брюс Ли

Конструкторы нарушают принцип открытости/закрытости. Создаете игру на HTML5? Хотите изменить стандартное поведение и использовать пул объектов вместо инстанцирования новых копий, чтобы сборщик мусора не влиял на FPS? Вы либо поломаете логику приложения, либо запутаетесь с костылями при создании фабрики методов.

Если вы вернете произвольный объект из конструктора — это сломает ссылки на прототип, и ключевое слово this в конструкторе больше не будет ссылаться на только что созданный экземпляр объекта. Кроме того, «фабрика» получается менее гибкой, за счет того, что в ней не получится использовать this; мы просто его выбрасываем.

А использовать конструкторы без строгого режима может быть просто опасно. Если вызывающий забывает использовать new, ваш код не использует строгий режим или не является ES6-классом [вздох], все что вы присваиваете через this будет засорять глобальное пространство имен. Не очень красиво, я считаю.

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

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

На самом деле, JavaScript вообще не нуждается в конструкторах, поскольку любая функция может вернуть новый объект. С помощью динамического расширения объектов, синтаксиса объектов и Object.create() у нас есть все что нужно. И наконец-то this ведет себя везде одинаково. Ура-ура!

Добро пожаловать на седьмой круг ада

«Чаще всего я не настолько печален насколько должен». ~ Т. Х. Уайт

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

Если поведение конструкторов это кастрюля с водой, то классическое наследование это не огонь, а пламя из седьмого круга ада Данте.

Проблема гориллы и банана

«Проблема объектно-ориентированных языков в том что они тащут за собой всю неявную среду. Вы хотите получить банан, но кроме банана в нагрузку получаете гориллу, и все чертовы джунгли!». ~ Джо Армстронг

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

Допустим, вы начинаете с двумя классами: Инструменты и Оружие. Вы уже облажались — у вас не получится создать игру «Cluedo» (прим. пер.: имеется ввиду, что от класса Оружие нельзя с легкостью получить инстансы Нож и Револьвер, необходимые для игры.)

Проблема сильной связанности

Связь между классом и его родителем является самой крепкой формой зависимости в объектно-ориентированном дизайне (ООД). Переиспользуемый модульный код, наоборот, имеет слабые связи.

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

Проблема необходимого дублирования

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

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

«Ой. Еще один». ~ Каждый классический ООП-программист рано или поздно

Это известная в ООП-кругах проблема необходимого дублирования.

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

В этом плане ключевое слово class, вероятно, самое вредное нововведение в JavaScript. Я безмерно уважаю блестящих и трудолюбивых людей, которые прилагали свои усилиях по стандартизации, но даже блестящие люди иногда делают неправильные вещи. К примеру, попробуйте в консоли браузера выполнить .1 + .2. (прим. пер.: тут автор явно троллит — вряд ли это архитектурная ошибка языка). Однако, это не мешает мне считать, что Брендан Айк внес большой вклад в веб, языки программирования и развитие ИТ в целом.

P.S.: Не используете super если не получаете удовольствие от пошаговой отладки каждого слоя из множества абстракций наследований.

Возрождение

Эти проблемы множатся по мере роста приложения и, в конце концов, остается только переписать всё с нуля или полностью доломать — иногда это единственный способ сократить финансовые потери.

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

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

Это — не вопрос вкуса или стиля. Ваш выбор может как спасти, так и убить продукт.

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

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

Шаг к свету

«Совершенство достигается не тогда, когда нечего больше добавить, а тогда когда больше нечего убавить». ~ Антуан де Сент-Экзюпери

Недавно, работая над библиотекой для демонстрации прототипного наследование для своей книги «Programming JavaScript Applications», я набрел на интересную идею: функция-фабрика, которая помогает создавать функции-фабрики, которые, в свою очередь, успешно наследуются и объединяются. Подобные фабрики я назвал «штампами», и библиотеку, соответственно, «Stampit». Она простая и очень маленькая. Я рассказывал о ней на конференции O'Reilly Fluent в 2013 году, и написал о статью в блоге.

Существует небольшое, но постепенно растущее, сообщество разработчиков, на чей стиль разработки повлияла эта библиотека. На текущий момент библиотека используется в продакшне множеством приложений с миллионами пользователей.

Конечно, Stampit не является единственной альтернативой. Например, Дуглас Крокфорд совсем не использует new или this, предлагая вместо этого полностью функциональный подход к повторному использованию кода.

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

Еще одна хорошая альтернатива наследованию — использование модулей (я рекомендую npm + ES6 модули через Browserify или WebPack), или просто клонирование объектов через копирование их свойств (Object.assign(), lodash.extend() и т.п.).

Механизм копирования это еще одна форма прототипного наследования под названием «наследование через объединение».

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

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

Когда я говорю людям, что конструкторы и классическое наследование это зло — они начинают защищаться. Я не нападаю на вас. Я пытаюсь вам помочь.

Люди привязываются к одному стилю программирования, как будто способ разработки является частью их личности. Чушь.

Не имеет значения как это сделано, если сделано плохо.

Единственная важная вещь в разработке ПО — это то, что пользователи любят ваше ПО.

Многие люди не прислушиваются к советам, и предпочитают наступать на грабли самостоятельно. Не делайте эту ошибку, её цена может быть непомерной. У вас есть возможность учиться не наступать на грабли, на которые наступали многие снова, и снова в течении десятилетий. Целые книги посвящены этим граблям.

Одна из таких книг (вероятно, самая известная) «Паттерны проектирования» от GoF строится вокруг двух основополагающих принципов:

«Создавайте интерфейсы вместо реализации» (фокусируйтесь на том что делает ваш код вместо как он это делает) и «композиция предпочтительнее наследования».

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

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

Погуглите «new considered harmful», «inheritance considered harmful» и «super is a code smell». Вы найдете дюжину статей в авторитетных блогах наподобии Dr. Dobb's Journal, написанных еще до изобретения JavaScript. Все они которых говорят одно и то же: new, сломанное классическое наследование и связывание потомок-родитель (в нашем случае через super) это дорога в один конец.

Даже Джеймс Гослинг, создатель Java, признает что Java неправильно реализует объекты.

Хотите ближе к JavaScript? Дуглас Крокфорд сообщил что Object.create() был добавлен в синтаксис для того чтобы ему не пришлось использовать new.

Кайл Симпсон (автор, «You don't know JS») написал захватывающую серию из трех постов под названием «JS Objects: Inherited a Mess».

Кайл противопоставляет прототипное наследование классическому через классы, утверждая что первое проще и удобнее. Он даже ввел термин OLOO (Objects Linked to Other Objects), чтобы прояснить различия между делегированием прототипа и наследованием через класс.

Хороший код — простой код.

«Упрощение это удаление очевидного и добавление смысла». ~ Джон Маэда

Итак, если вы выбросите конструкторы с классическим наследовании из JavaScript кода, все станет:

Вариант получше

«Если вещь теоретически может быть опасной, и есть вариант получше — всегда используйте этот вариант». ~ Дуглас Крокфорд

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

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

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

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

Хороший стиль программирования требует, чтобы, когда вы предстанете перед выбором: элегантный, простой гибкий код, или сложный, неудобный, с ограничениями — вы выберете первый вариант. Сейчас модно быть открытым новым особенностям языка, но есть два пути: правильный и не правильный.

Выберите правильный путь

~ Эрик Эллиот

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

П.С. Не пропустите продолжение статьи «Два столпа JavaScript. Часть 2: Функциональное программирование». Или «Как прекратить заниматься микроменеджментом».

Если вы заметили ошибку, вы всегда можете отредактировать статью, создать issue или просто написать об этом Антону Немцеву в skype ravencry.

Eric Elliott
Автор:
Eric Elliott
Сaйт:
https://ericelliottjs.com/
GitHub:
ericelliott
Twitter:
@_ericelliott
Андрей Афонинский
Переводчик:
Андрей Афонинский
GitHub:
vkfont
Twitter:
@vkfont
LinkedIn:
afoninsky

Комментарии (14 комментариев, если быть точным)

Автар пользователя
pavtaras

Странно он про классы, конечно. Наследование class Child extends Parent по сути неявный сахар прототипного наследования.

А в остальном более менее согласен.

Автар пользователя
zerkms

Наследование class Child extends Parent по сути неявный сахар прототипного наследования.

В идеальном мире люди отталкиваются от поведения, а не реализации.

Лично я не совсем понимаю, почему люди часто говорят, что class это сахар вокруг прототипов. Какая разница, реализовано оно на прототипах или механизм абсолютно уникальный? Что это меняет?

Автар пользователя
SilentImp

@zerkms в основном это влияет на особенности поведения. Протипное ооп, оборачивай его в class или нет, будет обладать рядом отличий в поведении от «классического».

Автар пользователя
zerkms

@SilentImp приведи пример, когда это не BC, а именно особенность, вызванная деталью реализации. Я с ES2015 вообще про прототипы забыл (от слова совсем).

Автар пользователя
subzey

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

С другой стороны, в JS от прототипов у нас остался this, который определяется при вызове метода, а не при его создании. Это разрывает мозг любителям классических языков, но вполне объясняется принципами прототипного наследования: вызывая [].valueOf(), который на самом деле Object.prototype.valueOf(), нам бы скорее хотелось в качестве this иметь массив, а не Object.prototype.

В Python это решено таким образом, что при обращении к методу инстанса отдаётся не сама функция по ссылке, а некий аналог .bind(this). Но в JS это вряд ли появится.

А ещё - как в ES2015 определить у класса свойство? Не присваивание this.foo = 42; в конструкторе, не метод-геттер, а именно свойство. Придётся снять классический пиджачок, надеть костюм химзащиты и лезть в прототипы: MyClass.prototype.foo = 42;

Автар пользователя
zerkms

А ещё - как в ES2015 определить у класса свойство?

Никак. Стандарт не даёт определения свойствам классов.MyClass.prototype.foo = 42; определяет свойство прототипа класса, но формально называть "свойством класса" его нельзя, ибо такой термин для нас не определён.

С другой стороны, в JS от прототипов у нас остался this, который определяется при вызове метода, а не при его создании.

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

Автар пользователя
subzey

@zerkms, но ведь методы - это тоже свойства прототипа класса. Будут ли они считаться свойствами класса?

Пруф: Спека, §14.5.14:

7. Let proto be ObjectCreate(protoParent).

16. Perform MakeConstructor(F, false, proto).

21. For each ClassElement m in order from methods If IsStatic of m is false, then Let status be the result of performing PropertyDefinitionEvaluation for m with arguments proto and false.

Да и вообще, невозможность тупо создать свойство класса - это лажа какая-то, не находите?

Автар пользователя
zerkms

Будут ли они считаться свойствами класса?

Я бы их всё-таки называл методами. Хотя, да, вероятно их можно называть и свойствами :-S

Ну и вы знаете же - что исправят это в https://github.com/jeffmo/es-class-fields-and-static-properties

Автар пользователя
eeeehaaaa

без практических примеров с объяснениями я слабо понял в чем проблемы в java и т.д...

Автар пользователя
bigkrp

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

Child.prototype = Object.create(Parrent.prototype);

В ES2015 через extends Parent и super();

А вот где "как надо" по прототипному? через Object.assign() ? Мне вот не очевидно из статьи как не наступать на грабли((

Я конечно понимаю что это перевод, но потребность в конкретных патернах присутствует..

Автар пользователя
6pm

Жаль что нет примеров кода...

Автар пользователя
rantiev

@eeeehaaaa да перестань ты уже javascript java называть.

Автар пользователя
iamstarkov

"Java is to JavaScript as ham is to hamster." — http://javascriptisnotjava.io/

Автар пользователя
eeeehaaaa

@rantiev

научись сначала читать... ....в чем проблемы в java(php, ruby, python,....) в отличии от javascript c его прототипированием....

для тебя как для маленького.