ES6 классы
Нет, ничего не изменилось, прототипы никуда не ушли, а классы лишь приятная обёртка над прототипным наследованием. В классическом понимании классов в JavaScript никогда не существовало и не будет существовать никогда. Многие разработчики, особенно те, которые решили познать мир JavaScript после изучения другого языка программирования, не понимают (или не хотят понять) разницы между классическим и прототипным наследованием, в результате чего они буквально отказываются полностью использовать самый мощный инструмент, которым только может вооружиться JavaScript-разработчик. Поэтому данная статья, в первую очередь, призвана объяснить, почему классы, появившиеся в новом стандарте языка, не то, чем кажутся на первый взгляд.
JavaScript не такой, как все
Для начала, три концепции, которые должен включать в себя любой объектно-ориентированный язык программирования: инкапсуляция, наследование и полиморфизм. Знакомые слова, не так ли? Теперь простыми словами о каждой концепции:
Инкапсуляция
Инкапсуляция есть ни что иное, как реализация приватности. В JavaScript подобная концепция реализуется благодаря функциям и их областям видимости.
Что не так с инкапсуляцией в JavaScript? Всё просто. В примере приведённом выше отсутствует возможность явно обозначить приватность, как в других языках программирования с помощью ключевых слов private
, protected
и public
(пример с PHP). Естественно, подобная проблема достаточно просто решается с помощью тех же самых функций — создаётся ещё одна обёртка над кодом в виде самовызывающейся анонимной функции, например, при использовании паттерна “модуль”:
С релизом стандарта ES2015 подобная проблема приватности была частично решена добавлением блочных областей видимости для переменных, в результате чего чистые функции, работающие только для данного конкретного конструктора можно вынести в отдельный модуль и запрашивать по необходимости, не опасаясь того, что кто-то их перезапишет. Но чистые функции лишь вершина айсберга и про некоторые способы организации приватности при работе с классами в JavaScript можно прочитать в этой статье.
Наследование
С помощью наследования вы, буквально, говорите: “У меня есть один конструктор/класс и другой конструктор/класс, который точно такой же, как и первый, кроме вот этого и вот этого”. Чаще всего наследование в JavaScript реализуется с помощью функции Object.create()
, позволяющий создать новый объект с заданным прототипом.
И здесь с JavaScript “всё не так”. Тот же самый пример, написанный на PHP:
Да в чём вообще кроется разница? Мы же выполнили одни и те же действия, просто с разным синтаксисом! Чтобы понять, почему JavaScript другой представьте себе семейство птичек: дедушка попугай, отец попугай и сын попугай — все попугаи! Совершенно очевидно, что если у деда попугая вырастет ещё одна лапка, то это ни коем образом не повлияет на отца и сына. То есть попугай, родившийся с двумя лапами, так и останется до конца своей жизни с двумя лапами, в независимости от того, что случилось с любым из его предков. Подобным образом можно представить себе классическое наследование.
С прототипным наследование ситуация абсолютно противоположная. Дед-попугай отрастил себе третью лапку, и она автоматически появилась у отца и сына (прототипное наследование против природы). Сложившееся положение вещей крайне не понравилось отцу попугаю, и он решил, что и попугаем-то больше быть не хочет и стал орлом (с тремя лапами). Как вы уже, наверное, догадались сын попугай уже больше не попугай, а настоящий орёл (но опять же с тремя лапами). Три лапы слишком много для сына и он решает отказаться от одной (теперь у нас есть обычный орёл с двумя лапами).
Но и это ещё не всё! Единственный оставшийся попугай (дедушка) решил, что трёх лап мало и приобрёл себе ещё одну, а также решил стать ласточкой. В результаты из обычного семейства попугаев мы получили: деда ласточку с четырьмя лапами, отца орла с тремя лапами, сына орла с двумя лапами. Вот она вся суть прототипного наследования. Похоже на безумие? Да? Тогда перейдём к коду:
Вывод из всего выше перечисленного: в JavaScript прототипное наследование “динамическое”, можно изменять всё налету, классическое же наследование подобным похвастаться не может: всё, что вы объявили в одном классе останется там навсегда. Грубо говоря, классическое представление наследования предполагает наличие определённой статической схемы, по которой будет строиться каждый объект данного класса. В прототипном наследовании мы имеем дело не со схемой, а с живым, постоянно развивающимся организмом, который со временем изменяется и принимает ту форму, которая нам нужна (и это прекрасно).
Полиморфизм
Полиморфизм проще всего постичь на примере встроенных конструкторов (String
, Array
, Object
…). Вот если вас спросят: “Чем число 42
отличается от массива [4, 2]
и что у них общего?”, чтобы вы ответили? Наверняка, вы были начали рассказывать про примитивы и объекты, чем они отличаются, что можно делать с теми и другими, на вопрос про отличия. Но чем они похожи друг на друга? Абсолютно разные же типы данных! Но, очевидно, что они разделяют определённую часть методов, например, метод toString
, унаследованный от Object
. Это уже полиморфизм? Ещё нет, но мы уже близко. Метод toString
можно весьма успешно переназначить, во-первых, в прототипе функции конструктора, и, во-вторых, сразу же для данного конкретного объекта.
Что происходит? При использовании метода toString
на каждом из объектов происходит проверка того, какой метод нужно выбрать. Проверка ведётся следующим образом: изначально проверяется существует ли нужное свойство на самом объекте, если существует, то используется именно оно, если же нет, то проверка продолжается — на наличие свойства проверяется прототип, потом прототип прототипа, потом прототип прототипа прототипа… и так, пока не дойдём до самого конца — null
(null является прототипом для Object
). Таким образом, полиморфизм отвечает за то, чей метод вызвать. Подробнее о том, как это всё работает в других языках программирования можно узнать в этом вопросе на Тостере, а более подробный пример с JavaScript можно посмотреть в статье про прототипы.
Большой вывод, который я хочу, чтобы вы сделали из всего вышеперечисленного: “Javascript не такой, как остальные языки программирования с классическим пониманием ООП”. Не стоит жить в стране предрассудков и пытаться перенести своё понимание ООП из другого языка — вы только потеряете время.
Теперь, когда мы во всём разобрались и сделали нужный вывод (“JavaScript другой”) можно перейти к рассмотрению “классов” в JavaScript.
Классы
Итак, выше вы уже достаточно насмотрелись примеров конструкторов, свойств и методов, которыми мы пользовались до выхода в свет нового стандарта. Не буду вас больше томить ожиданием и сразу перейдём к коду:
Пример выше можно записать в стиле ES5 следующим образом:
Что нужно знать про классы:
- Создавая класс, вы пользуетесь блоком кода (всё, что находится между
{
и}
), внутри которого объявляете, всё, что хотите видеть в прототипе. - Запись
class Person
означает, что будет создана функция конструкторPerson
(всё точно так же, как и в ES5) - Свойство
constructor
используется для обозначение того, что будет происходить непосредственно в самом конструкторе. - Все методы для класса используют краткую запись, которую мы обсуждали ранее в статье про расширение литерала объектов.
- При перечислении методов не надо использовать запятые (на самом деле, они запрещены)
Важно понимать, что мы до сих пор работаем с обычными функциями. То есть если вы захотите проверить тип класса, то (с удивлением?) обнаружите function
:
Как и при работе с функциями конструкторами мы можем записать “класс” (на самом деле, только конструктор) в переменную. Иногда подобная запись может быть чрезвычайно полезной, например, когда нужно записать конструктор, как свойство объекта.
Но, как и во всех остальных вопросах в JavaScript, здесь есть свои подводные камни. Во-первых функция Person
обязана быть вызвана с оператором new
и, во-вторых, в то время, как функция function Person(name) { this.name = name; }
поднималась наверх (стандартный hoisting) и её можно было использовать в любой части кода, конструктор, созданный с помощью class
не испытывает на себе поднятия.
extends
ES6 классы также обладают синтаксическим сахаром для реализации прототипного наследования. Для подобных целей используется extends
:
super
В примере выше мы использовали super
для вызова конструктора-родителя. С помощью подобного вызова мы записали свойство name
для текущего объекта. Другуми словами, всё, что делает super
при вызове внутри конструктора (свойства constructor
) — вызывает конструктор родителя и записывает в текущий объект (то есть в this
) всё, что от него требуется. В ES5 для подобных действий приходилось напрямую обращаться к конструктору:
Но это ещё не всё, чем может порадовать super
! Если вы захотите обратиться к любому методу, записанному в прототип родителя внутри метода потомка, то super
и здесь вас сможет выручить.
Без super
нам бы пришлось напрямую обращаться к прототипу конструктора родителя, чтобы получить перезаписанный нами метод:
Таким образом, super
“оценивает ситуацию” и в зависимости от того, где вы его решили использовать будет работать по-разному, но при любом использовании он готов достаточно сильно сократить ваш код и избавить от не самого понятного способа вызова конструктора родителя.
Подводный камень super
: при реализации наследования с помощью extends
и работе с дочерним конструктором необходимо вызвать super()
перед добавлением любого нового свойства.
Классы без конструкторов
Как вы, наверное, заметили в примере выше не было использовано свойство constructor
для класса Speaker
, поскольку в нём просто нет необходимости в данном случае. Но свойство name
всё равно было записано. Магия? Магия! Когда вы не указываете явно, что нужно сделать в конструкторе, то всё решается без вашего участия. Можно представить себе данный процесс следующим образом:
static
При работе с конструкторами в ES5 многие привыкли использовать функции, как объекты (они же и есть объекты) и вешать на них служебные функции:
Если вы с подобной реализацией никогда не сталкивались, то обратите внимание на то, что подобные значения не имеют ничего общего с прототипами и доступны только при запросе непосредственно с функции (объекта):
При работе с классами запись подобных свойств доступна сразу внутри класса с помощью оператора static
. Причем все записанные подобным образом свойства при наследовании благополучно переносятся на конструктор потомка:
Комментарии