Прототипное наследование и чем оно отличается от классической модели наследования

Обновлено: 07.07.2024

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

Так как я больше backend программист, JavaScript в своей практике я использую не так часто. Несколько месяцев назад у меня появилась задача реализации сложной бизнес-логики на JS, но знаний о наследовании в JS на тот момент не хватало. Эта статья мне настолько помогла, что я решил её скопировать сюда.

Представьте, вы получили заказ на создание небольшой онлайн игры. Всё что от вас требуется — создать понятный API для работы с персонажами. Техническое задание выглядит следующим образом:

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

Основа

Для создания подобной системы персонажей вам потребуется, как минимум, три функции конструктора: Human , Orc и Elf . Но, если вы внимательно прочитали присланное заказчиком задание, то уже знаете, что любой персонаж обладает одинаковым набором свойств и методов и лишь дополняется каким-то отдельным качеством в зависимости от принадлежности к определённому классу.

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

Теперь мы можем создавать базовую заготовку для любого персонажа, используя модуль Character и конструктор Character :

Заготовка для всех персонажей создана, и мы можем приступать к созданию отдельных классов. Начнём с людей и модуля Human . Мы знаем, что любой персонаж, принадлежащий к классу людей, умеет строить сооружения для защиты. Для этого отлично подойдёт метод build :

Человек, созданный с помощью конструктора Human теперь умеет строить здания определённой прочности и тем самым увеличивать свой запас здоровья. Отлично, мы уже на полпути! Или нет? Свойства health у человека пока что нет, поэтому и вся наша конструкция бесполезна. Разумеется, мы бы могли вручную создать все необходимые свойства для каждого класса персонажей:

Но, в таком случае, нам придется дублировать код с присваиванием свойств в каждом конструкторе, поддержка кода заметно усложнится, если, например, у нас будет не 3, а 20 классов героев. Именно для упрощения поддержки и уменьшения количества кода мы и создали конструктор-заготовку. Всё, что нам остаётся сделать — вызвать конструктор Character внутри конструктора Human :

Теперь любой объект, созданный с помощью конструктора Human , обладает свойствами health , name , exp и strength . В этом легко убедиться:

Разумеется, мы можем использовать и созданный нами ранее метод build :

apply и call

Методы run и walk , которые мы хотели унаследовать от конструктора Character всё ещё не доступны для использования. Чтобы разобраться, почему именно так, нужно понять принцип работы метода функций apply .

Итак, у нас есть конструктор Character , который при вызове с оператором new выдаст нам полностью рабочий объект со всеми свойствами и методами, как это было показано выше. Но не стоит забывать, что Character также является обычной функцией, то есть мы можем вызвать её и без использования оператора new :

Что произойдёт в таком случае? Если вы подобным образом используете функцию в глобальной области видимости, то глобальному объекту window будут записаны 4 свойства:

Другими словами, вы просто создадите глобальные переменные. Функция Character использует this для обращения к текущему объекту. А в глобальной области видимости this будет ссылаться на объект window . Таким образом, всё, что делает функция Character , — присваивает значения объекту, на который ссылается this при вызове функции. Именно это нам и нужно.

Неплохо было бы вызвать функцию Character в конструкторе Human , чтобы быстро записать все свойства в текущий объект. Если вы попробуете вызвать Character напрямую, то с удивлением обнаружите, что ни одно свойство не было записано в объект:

Метод apply работает схожим образом, но вместо списка аргументов принимаем массив (или любую другую массивоподобную структуру) и формирует из него тот же список аргументов. Подобное поведение бывает полезным, когда мы хотим передать вызываемой функции все аргументы из текущей. Сделать это можно с помощью псевдомассива аргументов arguments :

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

Чтобы окончательно понять принцип работы метода apply , попробуте использовать его с любой функцией, принимающей неограниченное число аргументов. Например, функция Math.max , которая находит максимальное переданное ей число:

С помощью apply функцию можно заставить работать с массивами:

Так как функция не использует this , то первым параметром передать можно всё, что угодно:

Наследование

Как я уже писал выше, мы хотим сделать так, чтобы конструктор Human не только имел все те же свойства, что и Character , но и мог использовать все методы из его прототипа: walk и run . Подобное наследование осуществить очень просто: всё, что нужно сделать — переназначить прототип конструктора Human :

Метод Object.create создаёт новый объект с указанным объектом прототипа. Таким образом мы можем использовать методы конструктора Human , когда они доступны, а в случае, если их нет, то будем обращаться уже к методам конструктора Character . Подробнее о том, как происходит определение того, какое именно свойство или метод будет использован, можно прочитать в статье о прототипах.

Таким образом для реализации наследования достаточно всего двух строчек кода:

Ещё больше наследования

Мы уже написали конструктор Human , осталось закончить работу и создать конструкторы для орков и эльфов.

В этом видео мы разберемся - что такое прототипы и как работает прототипное наследование в JS.

Давайте рассмотрим небольшой пример:

В JS есть нативный или встроенный объект под названием Date, который используется для работы с датами и временем.

Чтобы воспользоваться функционалом объекта Date - нам сначала нужно создать наш собственный экземпляр этого объекта. Для этого мы используем специально слово new:

В переменной myDate мы получаем новый экземпляр объекта Date, который содержит текущий момент времени. Далее, мы можем использовать различные встроенные методы объекта Date, обращаясь к нашему экземпляру.

Например, мы можем получить только текущий год:

или текущее время:

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

То же самое касается других нативных объектов, таких как Object, Number, Array и так далее.

Если мы напишем название любого из этих объектов в консоли и нажмемenter, мы увидим, что все они являются функциями.

Если запускать эти функции, используя слово new, то в ответ мы будем получать объекты (экземпляры головного объекта). Давайте это проверим:

Создаем собственный головной объект

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

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

Создаем экземпляры головного объекта

Теперь мы можем использовать эту функцию конструктор для создания отдельных экземпляров машин.

Таким образом, мы получили экземпляр нашего головного объекта Auto - пустой объект, присвоенный нашей переменной tesla. Давайте используем метод constructor, чтобы удостовериться, что это действительно так:

Добавляем свойства к экземплярам

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

Внутри функции Auto, значение this относится к каждому конкретному экземпляру, который мы будем создавать:

Мы создали 2 экземпляра нашего головного объекта Auto. Каждый экземпляр обладает своими собственными свойствами.

Добавляем методы к экземплярам

Теперь давайте попробуем добавить какие-то методы к нашим машинкам. Метод drive будет расходовать бензин каждой машины (свойство gas):

Все отлично работает - но есть небольшой ньюанс!

Каждый раз, cоздавая новый экземпляр машины, мы также создаем новую функцию drive (для каждого экземпляра)!

Как в этом убедиться?

Давайте сравним функцию drive у разных экземпляров машин:

Так что же в этом плохого?

В текущем примере, где у нас всего 2 экземпляра машин - проблем никаких нет. Но если количество экземпляров возрастет до 1,000 или десятков тысяч, это окажет негативное влияние на производительность.

Используем прототип (js prototype)

Чтобы не создавать каждый раз новый метод drive() для каждого экземпляра машины, мы можем поместить этот метод в, так называемый, прототип (prototype) нашего головного объекта.

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

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

Теперь давайте посмотрим на экземпляр нашего объекта в переменной nissan:

Мы видим, что метод drive пропал из списка свойств. Это происходит потому, что теперь метод drive "живет" в прототипе объекта.

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

Давайте убедимся в этом на примере:

Давайте добавим еще одно свойство в прототип нашего объекта:

Если мы в консоли напишем:

Теперь давайте дополнительно добавим свойство discount в наши экземпляры:

Если мы повторно в консоли проверим свойство discount конретного экземпляра, то получим значение "70%":

То есть, первым делом идет поиск в свойствах экземпляра. Если свойство отсутствует - JS делает поиск в прототипе!

Обновляем логику работы метода в прототипе для всех экземпляров

Часто требуется обновить логику работу какого-либо метода. Используя прототипное наследование, обновленный метод становится доступным для всех экземпляров!

Рассмотрим пример

Создаем метод info и ниже меняем его, в соответствии с новыми требованиями:

На этом этапе, для всех экземпляров нашего объекта Auto доступна обновленная версия метода info.

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

Класс (объект), от которого производится наследование, называется базовым, родительским или суперклассом (объектом).

Новый класс (объект) — потомком, наследником, дочерним или производным классом (объектом).

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

JavaScript вместо наследования использует прототипирование: если искомого свойства или вызванного метода в самом объекте нет, то запрос передаётся объекту-прототипу (свойство prototype всех объектов JavaScript). При этом объект, от которого произошло наследование называется прототипом, и унаследованные свойства могут быть найдены в объекте prototype конструктора. Поведение всех объектов класса можно поменять, заменив один из методов прототипа (например, добавив метода .toBASE64 для класса String ).

Процесс классического наследования должен создавать уровень абстракции, т.е. создается вертикальная иерархия.

При каждом наследовании, каждый дочерний класс должен снижать уровень абстракции, тем самым снижая уровень обобщения, например:

"ТУФЛИ" => "МУЖСКИЕ ТУФЛИ" => "МУЖСКИЕ ЛЕТНИЕ ТУФЛИ" => "МУЖСКИЕ ЛЕТНИЕ БЕЛЫЕ ТУФЛИ"

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

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

Задача программиста при использовании парадигмы классического наследования создать иерархию сущностей от максимальной общей к максимально конкретной.

При использование парадигмы прототипного наследования программист имеет дело только с объектами и при этом у него есть возможность создавать сущности в одном (горизонтальном) уровне абстракции .

В JavaScript выделяют:

  1. функциональное наследование (через функции-конструкторы и классы class - с особенностями);
  2. прототипное наследование.

Классовое наследование

Создание класса с использованием ключевого слова class фактически является формой функции-конструктора, поэтому классовое наследование может как использовать, так и не использовать ключевое слово class из ES6.

Класс похож на шаблон – описание объекта который будет создан. Экземпляры классов обычно создаются через функции-конструкторы с помощью ключевого слова new .

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

Эти таксономии практически невозможно спроектировать правильно для всех новых сценариев. Широкое распространение (использование) базового класса приводит к проблеме "хрупкого базового класса" (сложность внесения правок, когда он начинает работать неправильно). Классовое наследование служит причиной многих проблем в ОО проектировании:

  1. проблема сильной связанности (классовое наследование является самым сильным связанным доступным в ОО проектировании);
  2. проблема хрупкого базового класса (неэластичная иерархия, в конечном итоге, все развивающиеся иерархии не будут работать для новых сценариев);
  3. дублирование кода (в связи с неэластичными иерархиями новые сценарии часто вклинены в нескольких местах, дублируя код);
  4. проблема "гориллы и банана" (вы хотели банан, но получили гориллу, ждущую банан, и целые джунгли в дополнение).

Прототипное наследование

Прототип - это экземпляр некоторого объекта, от которого наследуются напрямую другие объекты. Экземпляры могут быть скомпонованы из большого количества разных объектов, позволяя легко проводить точечное наследование и строить простую [[Prototype]] иерархию.

Функциональное наследование в JS

Функциональное наследование использует вызов родительской функции-конструктора внутри функции-конструктора дочернего класса с одновременной передачей ей в качестве контекста this текущего объекта:

Чтение некоторых статей из Aadit M Shah , как Почему вопросы прототипного наследования или Прекратить использование функций конструктора в JavaScript от Eric Elliott Я думаю, что я понимаю все их аргументы, теоретически. Но на практике я не вижу реальных преимуществ этой модели.

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

  1. Первый использует augment.js , это скрипт от Aadit M Shah
  2. В этом примере мы собираемся использовать этот скрипт , Это сделано также Aadit M Shah.

Implementation 1:

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

Implementation 2:

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

var t = CreateParisLover.create.apply (CreateParisLover, ["Mary"]);

Это дает нам большую гибкость, это правда. Но мы можем сделать то же самое с это :

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

Точно так же мы с Эриком пытались популяризировать использование заводских функций вместо функций конструктора. Однако переход от фабрик к строителям не только по эстетическим соображениям. Мы вдвоем пытаемся изменить менталитет программистов JavaScript, потому что в некоторых аспектах мы оба считаем, что JavaScript в корне ошибочен. Оператором new в JavaScript является один из таких аспектов. Хотя он сломан, но он занимает центральное место в языке, и поэтому его нельзя избежать.

Суть заключается в следующем:

Если вы хотите создать цепочки прототипов в JavaScript, вы должны использовать new . Другого пути нет (кроме .__ proto __ ), который неодобрен).

В этом ответе я коснусь следующих тем:

  1. Почему мы застреваем с new ?
  2. Почему фабрики лучше конструкторов?
  3. Как мы получаем лучшее из обоих миров?

Ключевое слово new помещается на пьедестал в JavaScript. Невозможно создать цепочку прототипов в JavaScript без использования new . Да, вы можете изменить свойство .__ proto __ объекта, но только после его создания, и эта практика не одобряется. Даже Object.create использует new внутренне:

Как Дуглас Крокфорд упоминается :

Функция Object.create распутывает шаблон конструктора JavaScript, достигая истинного прототипального наследования. Он принимает старый объект как параметр и возвращает пустой новый объект, который наследуется от старого. Если мы попытаемся получить член от нового объекта, и ему не хватает этого ключа, тогда старый объект будет предоставлять член. Объекты наследуются от объектов. Что может быть более объектно-ориентированным?

Теперь вы можете подумать, действительно ли new настолько плохой. В конце концов, это действительно лучшее решение. На мой взгляд, это не должно быть так. Независимо от того, используете ли вы производительность new или Object.create , всегда должны быть одинаковыми. Здесь не хватает языковых реализаций. Они должны действительно стремиться к ускорению создания Object.create . Таким образом, помимо производительности, new имеет какие-либо другие искупительные качества? По моему скромному мнению это не так.

Часто вы не знаете, что не так с языком, пока вы не начнете использовать лучший язык. Итак, давайте посмотрим на некоторые другие языки:

a) Сорока

Magpie is a hobby language created by Bob Nystrom. It has a bunch of very interesting features which interact very nicely with each other, namely:

Однако классы в Magpie более похожи на прототипы в JavaScript или типы данных в Haskell.

В экземпляре экземпляров Magpie классы разбиваются на два этапа:

  1. Построение нового экземпляра.
  2. Инициализация вновь созданного экземпляра.

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

Рассмотрим следующий код Magpie:

Это эквивалентно следующему JavaScript-коду:

Как вы можете видеть здесь, мы разделили конструкцию и инициализацию экземпляров на две функции. Функция Point инициализирует экземпляр, а функция Point.new создает экземпляр. По сути, мы просто создали заводскую функцию.

Separating construction from initialization is such a useful pattern that the good people of the JavaScript room have even blogged about it, calling it the Initializer Pattern. You should read about the initializer pattern. It shows you that initialization in JavaScript is separate from construction.

  1. Заводы типа Object.create (+1): Конструкция отделена от инициализации.
  2. Оператор new (-1): Конструкция и инициализация неотделимы.

б) Хаскелл

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

Типы данных в Haskell похожи на прототипы в JavaScript, а конструкторы данных в Haskell похожи на заводские функции в JavaScript. Например, приведенный выше класс Point в Haskell будет записан следующим образом:

Короче, не так ли? Однако я не здесь, чтобы продать Haskell, поэтому давайте посмотрим на некоторые другие функции, которые предлагает Haskell:

Здесь rectangle и circle являются экземплярами типа Shape :

В этом случае Shape является наш прототип (тип данных в Haskell) и rectangle и circle являются экземплярами этого типа данных. Более интересно, однако прототип Shape имеет два конструктора (конструкторы данных в Haskell): Rectangle и Circle .

Конструктор данных Rectangle - это функция, которая принимает Point и еще один Point и возвращает Shape . Аналогично, конструктор данных Circle - это функция, которая принимает Point и Int и возвращает Shape . В JavaScript это будет написано следующим образом:

Как вы видите, прототип в JavaScript может иметь более одного конструктора, и это имеет смысл. Также возможно, что один конструктор имеет разные прототипы в разные моменты времени, но это не имеет никакого смысла. Это приведет к слою instanceof .

Как оказалось, наличие нескольких конструкторов является болью при использовании шаблона конструктора. Однако это совпадение на небесах при использовании прототипа:

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

  1. Заводы (+1): вы можете легко создать несколько заводов для каждого прототипа.
  2. Конструкторы (-1): Создание нескольких конструкторов для каждого прототипа является взломанным и неуклюжим.
  3. Прототипный шаблон (+1): все инкапсулировано в пределах одного литерала объекта. Похож на шаблон модуля.
  4. Конструкторский шаблон (-1): он неструктурирован и выглядит несовместимым. Трудно понять и поддерживать.

Кроме того, Haskell также учит нас о чисто функциональном программировании. Поскольку фабрики - это просто функции, мы можем call и применять фабрики, составлять фабрики, фабрики карри, фабрики memoize, делает фабрики ленивыми, поднимая их и многое другое. Потому что new - это оператор, а не функция, которую вы не можете сделать с помощью new . Да, вы можете сделать функциональный эквивалент new , но почему бы не просто использовать фабрики вместо этого? Использование оператора new в некоторых местах и ​​метод new в других местах является непоследовательным.

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

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

Это работает в большинстве случаев, но есть несколько проблем:

  1. Прототип o.constructor может отличаться от o .
  2. Мы хотим только создать новый экземпляр o , но o.constructor может также иметь логику инициализации, которую мы не можем отделить от конструкции.

Решение довольно просто:

Используя defclass , вы можете создавать классы следующим образом:

Как вы видите, мы разделили конструкцию и инициализацию, и инициализация может быть отложена до нескольких конструкторов. Его можно даже заковать следующим образом: (new Shape) .rectangle (). Circle () . Мы заменили Object.create на new , который намного быстрее, и у нас все еще есть возможность делать все, что захотим. Кроме того, все прекрасно инкапсулируется в одном объектном литерале.

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

new is for when I'm doing class-like things. I use new and constructor functions because it fits well with how the language is designed. (Yes, that design is unusual, but that's how it is.) So if I'm going to have objects that will represent people and have common behavior, I'll use a Person constructor, assign behaviors (functions) to Person.prototype , and use new Person to create them. (I use my Lineage script to make this more concise, and to handle some hierarchy stuff easily.) This is straightforward, familiar, clean, clear: If you see new Person you know I'm creating a new object. (If I'm not — yes, it's possible to violate that expectation with a constructor function — then to my mind I shouldn't be writing a constructor function in the first place.)

Now of course, you can define a builder function ( createPerson , buildPerson , whatever) that does the same thing using Object.create or similar. I don't have a problem with people doing that if that's what they prefer (as long as the function name is clear it creates something). I do have a problem with people saying "you shouldn't use new " as though it were objective advice; it's an opinion, it's style advice.

Object.create is for when I'm doing instance-level stuff. There's a project I work on that has a bunch of objects in a complex tree/graph. They're data-only, no behavior. Sometimes, we need to have data that's not yet verified, and so shouldn't overwrite the previous data. So the container has a reference to the verified data ( verified ) and to the unverified data ( current ). To avoid unnecessary branching in the code, the container always has both references, but in the normal case they refer to the same object ( container.verified = container.current = <>; ). Nearly all code uses that current object because nearly all code should be using the most up-to-date information. If we need to add pending data, we do container.current = Object.create(container.verified); and then add the data to container.current . Since current 's prototype is verified , there's no need to copy all the old data to it and have duplicated data all over the place. E.g., the classic use of facade. new would be the wrong tool for this job, it would only get in the way.

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

Читайте также: