Наследование в JavaScript: основные правила
В статье раскрываются ключевые моменты наследования в JavaScript с особым вниманием к прототипной архитектуре.
25К открытий26К показов
Изучая наследование в JavaScript, автор (не специалист в JS) нашёл целесообразным зафиксировать как можно более системно, а значит строго и последовательно, основные правила этой «дисциплины».
Как известно, тема наследования в JS тесно связана с таким механизмом, как прототипы, сведения о которых, будучи незамысловаты, в то же время могут вызвать трудности с понимаем общей картины. Нередко на постижение материала уходит какое-то время, а ответы приходится искать на форумах.
После возвращения к конспектам решил выбрать публичный формат изложения, из-за чего были добавлены некоторые связующие пункты. В итоге получилась небольшая, не претендующая на академический характер статья, которая может быть полезна в качестве дополнительного материала для изучающих JavaScript.
Подразумевается, что читатель знаком с основами языка и вообще ООП.
Содержание:
- Объект
- Типы и классы
- Класс Object
- Свойства функций и статические свойства класса
- Флаги свойств объектов и некоторые статические методы функции Object
- Прототипы
- Прототипная связь функций
- Полезные свойства Function.prototype
- Наследование с помощью прототипов
- Class вместо function
- Наследование статических свойств
1. Объект
Под объектом понимается тип данных, реализованных в виде набора свойств (полей и методов), имеющих имя и значение, а также экземпляр этого типа. Например, машина — это объект, и конкретный экземпляр, выпущенный на заводе, — тоже объект.
Ключевое слово this
в методе объекта указывает на сам же объект и используется для обращения к его свойствам. Заметим, что свойства в js могут назначаться объекту не только при его создании, но и после. Также заметим, что возможно обращение к несуществующим полям, и они будут равны undefined
, но обращение к несуществующим методам, конечно, невозможно.
2. Типы и классы
В js все объекты имеют тип object
. Тем не менее, они могут иметь различную структуру, иначе говоря — состав свойств. Чтобы отличать «тип js» от «типа объекта» будем использовать для последнего обозначение «класс». Класс описывается через функцию, и такая функция называется функцией-конструктором, иногда просто конструктором. Имя функции-конструктора выступает именем класса, и соглашением предусмотрено, что оно начинается с прописной буквы. Экземпляры класса создаются через вызов new <ИмяФункции>()
.
Вывод консоли показывает, что тип объекта object
. Когда же мы выводим сам экземпляр класса, то обозначен он будет как Person {}
. Также есть способ проверить, является ли объект экземпляром класса через оператор instanceof
.
3. Класс Object
Для создания простого объекта, помимо литерального способа (let o = {}
), использованного ранее, можно использовать функцию Object
. Не следует путать тип object и класс Object
. Последний выполняет роль фундаментального базового класса в генеалогии классов js. Если вывести Object
в консоль, будет видно, что это функция.
4. Свойства функций и статические свойства класса
Говорят, что в js функция — это объект. Действительно, функции можно назначить произвольное свойство и вообще обращаться с ней как с обычным объектом, неважно, с маленькой она или большой буквы, является функцией-конструктором или возвращает результат вычисления.
Скажем, что функция в js имеет дуалистическую природу: с одной стороны, это функция, и её можно вызывать через круглые скобки, с другой, это объект, и у неё могут быть свои свойства, будь то поля или даже методы. Их объявление и обращение к ним выполняется через точку от имени функции. Нетрудно догадаться, что свойства функции-конструктора выступают статическими свойствами реализуемого ею класса. В этом примере мы объявляем у класса Person
статическое поле count
и статический метод getCount
.
5. Флаги свойств объектов и некоторые статические методы функции Object
У свойств объекта есть значение и флаги. Их совокупность называется дескриптором свойства. Получить дескриптор можно через метод Object.getOwnPropertyDescriptor
. Дескриптор в свою очередь представляет собой объект с полями с говорящими названиями value
, writable
, enumerable
, configurable
.
Метод Object.defineProperty
позволяет назначить или модифицировать свойство объекта через дескриптор. В качестве дескриптора передаётся объект с любыми значимыми полями. В примере мы назначаем объекту свойство age
со значением 18
и флагом enumerable
, равным false
. Этот флаг отвечает за видимость свойства при перечислении свойств объекта в некоторых конструкциях языка. Например, метод Object.keys
проигнорирует свойство age
. Однако есть метод Object.getOwnPropertyNames
, который воспринимает и «неперечислимые» свойства.
Флаг writable
отвечает за возможность перезаписи свойства, а configurable
— его удаления (с помощью оператора delete
).
6. Прототипы
6.1. Связь объекта с прототипом
У объектов есть дефолтное свойство __proto__
, которое указывает на его «прототип». Это обычный объект: в том смысле, что это не функция. При создании объекта ему явно или неявно назначается прототип. При этом объект получает возможность обращаться к тем свойствам прототипа, которых у него нет.
При программировании на js для манипуляции с прототипом объекта используются предназначенные для этого функции, хотя и прямое обращение к прототипу объекта через свойство __proto__
возможно.
6.2. Указание прототипа и обращение к свойствам прототипа
Рассмотрим существенно искусственный пример прототипной связи между двумя объектами.
Здесь мы создаём объект person
, который далее используется в качестве прототипа при создании объекта с тривиальным именем person1
. Используется функция Object.create
(это статический метод класса Object
), которая создаёт объект с указанным прототипом (справочно, по другой схеме также создаются объекты person2-4
).
Несмотря на то, что у объекта person1
нет заданных свойств (мы намеренно их не указывали), он может использовать свойства своего прототипа. Когда мы вызываем метод speak
, то сам метод и поле name
заимствуются у прототипа. Затем мы назначаем объекту person1
свойство name
, и теперь у него своё собственное значение этого свойства.
Когда мы вновь вызываем метод speak
, сам метод будет взят у прототипа, а свойство name
— уже у объекта person1
. Затем мы назначаем ему и свой собственный метод speak
. В конце мы обращаемся к person.speak()
, чтобы показать, что значение свойств name
и speak
у прототипа остались прежними.
6.3. Цепочка прототипов
Каждый объект имеет прототип. У прототипа объекта есть свой прототип, у того — свой и т.д. Цепочка заканчивается, если прототип становится равным null
.
Когда происходит обращение к свойству объекта и такого свойства не обнаруживается, то поиск свойства происходит по цепочке прототипов.
В следующем листинге мы создаём цепочку объектов person
, user
, account
. Чтобы назначить прототип уже созданному объекту, можно использовать функцию Object.setPrototypeOf
, а чтобы получить прототип объекта — getPrototypeOf
. Код выводит объект account
с цепочкой прототипов.
6.4. Автоматические прототипы
В предыдущих примерах мы произвольно назначали одни объекты в качестве прототипов другим объектам. А что является прототипом объекта по умолчанию? Вообще есть ли он? Да, при создании объекта ему обязательно будет назначен прототип. До того было сказано, что прототип может быть назначен неявно.
После описания функции машина сама («под капотом») назначит ему особенное свойство prototype (то есть статическое свойство класса), заполнит его, а при создании объектов с помощью этой функции присвоит значение этого свойства prototype
их (создаваемых объектов) свойству __proto__
.
Например, мы описали функцию-конструктор Person
(неважно, что внутри тела функции). Тогда, незаметно, произойдёт вот что.
A. Функции будет назначено свойство prototype
типа object
.
Условно это можно записать так: Person.prototype = {}
;
B. Объекту prototype
будет назначено свойство constructor
со значением ссылки на саму функцию. Условно это можно записать так:
Person.prototype = {constructor: Person};
C. Объекту prototype
будет назначено свойство __proto__
со значением ссылки на Object.prototype
.
Условно это можно записать так:
Person.prototype = {constructor: Person, __proto__: Object.prototype};
Разумеется, это условный код, призванный раскрыть немного смысла внутренней реализации.
Объект Object.prototype
также имеет свойства constructor
, равный функции Object
, и __proto__
, равный null
.
Благодаря свойству constructor
свойства prototype
функции (в данном случае Person.prototype.constructor
) можно создавать объекты через обращение к прототипу другого объекта. У person1
нет свойства constructor
, поэтому при вызове этого метода машина начнёт поиск в person1.__proto
__. Свойство __proto__
, как мы уже знаем, указывает на Person.prototype
, а у последнего как раз есть свойство constructor
, которое, в свою очередь, ссылается на саму функцию Person
, и, таким образом, person1.constructor
есть Person
. Витиевато, но правда.
Заканчивает листинг создание объекта с помощью constructor’а строкового литерала (в данном случае пустой строки). Обратите внимание, что во всех случаях используется оператор new
.
6.5. Расширение прототипов
Вследствие того, что при создании нового объекта ему назначается прототип равный свойству prototype
функции-конструктора, все прототипы новых объектов равны друг другу и ссылаются на один и тот же объект. Отсюда следует, что если добавить в prototype
новое свойство, то оно будет доступно всем объектам класса.
Здесь у каждого объекта наличествуют собственные свойства name
и speak
, а вот свойство talk
принадлежит одному лишь прототипу. Объекты могут беспрепятственно обращаться к свойству прототипа. При этом указатель this
не теряет свой контекст и ссылается на сам объект. Отсюда следует, что при проектировании классов через функции-конструкторы целесообразно размещать методы класса как свойства prototype
.
6.6. Как работает оператор instanceof
Оператор имеет синтаксис obj instanceof <ИмяФункции>
, где слева какой-то объект, а справа какая-то функция-конструктор. Это выражение возвращает истину, если объект является экземпляром функции.
Объект считается экземпляром функции, если свойство prototype
функции, т.е. <ИмяФункции>.prototype
, является звеном цепочки прототипов объекта. Т.е., если:
obj.__proto__ === <ИмяФункции>.prototype
или
obj.__proto__.__proto__ === <ИмяФункции>.prototype
и т.д.
6.7. О прочтении слов «прототип» и «prototype»
Говоря о прототипе объекта, мы имеем в виду некий иной объект, на который указывает свойство __proto__
этого первого объекта. В то же время, как правило, все объекты одного класса имеют один прототип, на который указывает свойство prototype
функции-конструктора. Поскольку функция сама является объектом, выражение «прототип функции» может быть воспринято двояко: то ли это <ИмяФункции>.__proto__
, то ли <ИмяФункции>.prototype
. В целях этой статьи под прототипом функции мы понимаем <ИмяФункции>.__proto__
, а свойство prototype
, равное свойству __proto__
экземпляров этой функции, так и называем — prototype
.
7. Прототипная связь функций
Если функция является объектом, то, стало быть, у неё может/должен быть прототип. Если мы возьмём любую функцию, то вывод в консоль выражения типа <ИмяФункции>.__proto__
покажет результат. Более того, они все ссылаются на один и тот же объект.
Этот загадочный объект находится по адресу Function.prototype
. Здесь Function
тоже функция. То есть получается, что при описании/создании какой-нибудь функции она как будто бы создаётся через вызов new Function()
. В принципе, мы даже можем создать функцию подобным образом.
Из этого, конечно, не следует, что именно так и создаются функции. Просто их свойство __proto__
указывает на Function.prototype
.
Возникает резонный вопрос: если прототип всякой функции ссылается на Function.prototype
, то что является прототипом самой функции Function
? Ответ: Function.__proto__
также ссылается на Function.prototype
. В принципе это логично, ибо Function
и сама является функцией.
А вот Function.prototype.__proto__
ссылается на Object.prototype
, что тоже логично, ибо свойство prototype
любой функции является объектом, а не функцией. Вспомним как работает оператор instanceof
и изобразим прототипные отношения наших функций следующим образом.
8. Полезные свойства Function.prototype
Если вывести в консоль объект Function.prototype
, можно заметить, что у него есть какие-то свойства. Нас интересует метод call
.
Рассмотрим следующий пример. Пусть у нас есть функция-конструктор Person
. Мы создали объект этого класса (person1)
стандартным образом через оператор new
. Теперь предположим, мы хотим превратить произвольный объект в экземпляр класса Person
. Что для этого нужно сделать?
Во-первых, связать объект через прототип. В случае объекта person2
мы назначаем свойству __proto__
значение Person.prototype
через метод Object.setPrototypeOf
, а в случае person3
создаём его сразу с указанным прототипом.
Во-вторых, нам нужно вызвать функцию Person
применительно к объекту так, чтобы объект (person2
и person3
) стал this
. Это возможно сделать с помощью метода call
Function.prototype
.
Поскольку Person.__proto__
= Function.prototype
, мы можем писать просто Person.call
. Первым аргументом мы передаём объект, который внутри Person
выступит в качестве this
(иначе говоря, мы передаём контекст), остальные аргументы call
это аргументы функции Person
(в данном случае один аргумент name
). Выводим объекты person1-3
в консоль, и убеждаемся, что они однотипны.
Таким хитрым образом мы смогли имитировать создание экземпляра функции Person
без оператора new
. Подобный приём нам пригодится при реализации наследования.
9. Наследование с помощью прототипов
Рассмотрим класс TestItem
, представляющий собой вопрос проверочного теста. У него есть поля question
(текст вопроса), points
(ответы) и answer
(номер правильного ответа) и метод check
, проверяющий ответ. Предположим, параметр answer
— это номер выбранного ответа, метод check
просто сравнивает его с правильным. В листинге мы создаём вопрос и имитируем его проверку.
Всё хорошо, но в один момент выяснилось, что правильных ответов может быть несколько, и к тому же возникла необходимость добавить подсказку. Принято решение расширить класс в новый: MultipleChoiseTestItem
. Порядок действий при расширении класса следующий.
В теле функции-конструктора производного класса при необходимости вызывается конструктор базового класса с помощью метода call
и передачей текущего this
с возможными прочими аргументами. В нашем случае вызов необходим, ибо конструктор базового класса «что-то делает», а именно заполняет поля this
аргументами question
, points
и answer
.
Поле hint
мы заполняем уже в теле конструктора производного класса.
В обязательном порядке устанавливается прототипная связь между prototype
производного и базового класса. То есть свойство __proto__
свойства prototype
производного класса должно ссылаться на prototype
базового класса. В нашем случае MultipleChoiseTestItem.prototype.__proto__
устанавливается равным TestItem.prototype
.
Также устанавливается прототипная связь между самим производным и базовым классом. В нашем случае MultipleChoiseTestItem.__proto__
устанавливается равным TestItem
.
В листинге мы используем для связи метод Object.setPrototypeOf
.
После этих действий можно приступать к переопределению методов базового класса и добавлению методов производного класса. В нашем примере мы полностью переопределяем один метод и частично — другой. Статический метод оставляем без изменений.
На практике некоторые приёмы обращения со свойством __proto__
считаются нерекомендованными. Например, при наследовании вместо метода Object.setPrototypeOf
используется Object.create
. В этом случае prototype
функции-конструктора создаётся с нуля (якобы быстрее создать prototype
с нуля, чем модифицировать существующий).
После такого действия объекту prototype
необходимо назначить свойство constructor
, равным самой функции-конструктору. В следующем примере для этого используется метод Object.defineProperty
с передачей дескриптора свойства. Если мы напишем просто User.prototype.constructor = User
(в принципе можно и так), то флаги свойства могут быть отличны от дефолтных значений флагов свойства constructor
.
Отдельная история наблюдается с прототипной связью между функцией-конструктором производного и базового классов. Здесь мы не можем пересоздать функцию. Статические свойства базового класса можно скопировать в базовый с помощью метода Object.assign
. Однако прототипной связи в данном случае установлено не будет, User.__proto__
будет ссылаться на Function.prototype
, а не на Person
.
Можно сказать, что статья даёт простор для выбора того или иного способа реализации базовых методов в производном классе с учётом наилучших практик и рекомендаций, которые можно найти в сети, ограничиваясь их упоминанием. Впрочем, js предоставляет и классический способ проектирования классов.
10. Class вместо function
В js поддерживается такая языковая конструкция как класс. Перепишем наш пример с их использованием.
Реализация классов с помощью конструкции class
оставляет за бортом возню с прототипами. Не будем заостряться на синтаксисе, ибо он не из ряда вон выходящий, но обратим внимание на то, что прототипная архитектура функций осталась прежней. Сам класс при этом остаётся по сути и за исключением каких-то нюансов той же функцией.
Заметим, что прототип производного класса равен базовому: MultipleChoiseTestItem.__proto__ = TestItem
. При использовании конструкции function
такая связь устанавливалась через метод Object.setPrototypeOf
. Эта связь позволяет производному классу вызывать статические методы базового класса.
11. Наследование статических свойств
Как и обычные свойства, статические также наследуются, что показано в предыдущем пункте на примере метода printTest
.
В отличие от обычных методов, указатель this
в теле статического метода содержит ссылку на саму функцию, а не экземпляр класса.
Обращение к статическому свойству выполняется либо через имя функции, либо через this
в теле статического метода.
Операция присваивания свойств производного класса, которая создаёт одноимённое свойство, независимое от базового (например, переопределение метода), может приобрести разночтение относительно статического поля.
Рассмотрим следующий показательный пример.
При вызове метода resetCount
, поскольку собственный метод у класса User
не реализован (то есть не переопределён), вызывается метод базового класса. Однако this
указывает на класс User
, что, собственно, правильно, ибо метод вызван объектом (в данном случае это функция) User
. В теле метода this.count = 0
превращается в User.count = 0,
что приводит к созданию у класса User
своего статического поля count
. Поле count
базового класса обнулено не будет.
Перепишем пример на классах и размножим поле count
на два поля. К одному из них в теле статического метода будем обращаться через this.count1
, а к другому через прямое обращение к базовому классу как Person.count2
. В последнем случае как раз-таки будет обнулено собственное свойство count2
класса Person
, класс User
получит собственное свойство count1
(равное 0), а User.count2
вернёт значение поля базового класса.
Итого
В этой статье мы постарались раскрыть ключевые моменты наследования в js, особое внимание уделив прототипной архитектуре. Скажем само собой разумеющееся: некоторые (или все) темы, затронутые в статье, требуют отдельного изучения, и многое здесь не упомянуто.
С развитием классов в js нюансы работы с прототипами имеют меньшее распространение, но сохраняют свой шарм, присущий JavaScript в целом.
25К открытий26К показов