Наследование в JavaScript: основные правила
В статье раскрываются ключевые моменты наследования в JavaScript с особым вниманием к прототипной архитектуре.
26К открытий27К показов
Изучая наследование в 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 в целом.
26К открытий27К показов



