Обложка: Наследование в JavaScript: основные правила

Наследование в JavaScript: основные правила

Изучая наследование в JavaScript, автор (не специалист в JS) нашёл целесообразным зафиксировать как можно более системно, а значит строго и последовательно, основные правила этой «дисциплины».

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

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

Подразумевается, что читатель знаком с основами языка и вообще ООП.

Содержание:

  1. Объект
  2. Типы и классы
  3. Класс Object
  4. Свойства функций и статические свойства класса
  5. Флаги свойств объектов и некоторые статические методы функции Object
  6. Прототипы
  7. Прототипная связь функций
  8. Полезные свойства Function.prototype
  9. Наследование с помощью прототипов
  10. Class вместо function
  11. Наследование статических свойств

1. Объект

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

"use strict"
let log = console.log;

// Просто создаём пустой объект
let o = {};
// Добавим поле
o.name = "Alice";
// Добавим метод
o.speak = function()
    {
        log("Hi, I'm " + this.name);
    };
// Можно сделать всё сразу
o =
    {
        name: "Alice",
        speak()
            {
                log("Hi, I'm " + this.name);
            },
    };
// Вызываем метод speak
o.speak();

Ключевое слово this в методе объекта указывает на сам же объект и используется для обращения к его свойствам. Заметим, что свойства в js могут назначаться объекту не только при его создании, но и после. Также заметим, что возможно обращение к несуществующим полям, и они будут равны undefined, но обращение к несуществующим методам, конечно, невозможно.

2. Типы и классы

В js все объекты имеют тип object. Тем не менее, они могут иметь различную структуру, иначе говоря — состав свойств. Чтобы отличать «тип js» от «типа объекта» будем использовать для последнего обозначение «класс». Класс описывается через функцию, и такая функция называется функцией-конструктором, иногда просто конструктором. Имя функции-конструктора выступает именем класса, и соглашением предусмотрено, что оно начинается с прописной буквы. Экземпляры класса создаются через вызов new <ИмяФункции>().

"use strict"
let log = console.log;

// Это функция-конструктор, которая описывает тип объекта, а потому именуется с прописной буквы
function Person(name)
{
    // Предполагается, что при вызове через new будет создан пустой объект
    // К этому объекту мы обращаемся через this
    // Присваиваем объекту свойства
    this.name = name;
    this.speak = function()
    {
        log("Hi, I'm " + this.name);
    }
    // return не нужен, поскольку функция вызывается через new
}

let p = new Person("Alice"); // Создаём объект
log(typeof p); // Выводим тип
log(p); // Выводим объект
log(p instanceof Person); // Узнаём, является ли p экземпляром Person
// Обращаемся к свойствам
p.speak();

Вывод консоли показывает, что тип объекта object. Когда же мы выводим сам экземпляр класса, то обозначен он будет как Person {}. Также есть способ проверить, является ли объект экземпляром класса через оператор instanceof.

3. Класс Object

Для создания простого объекта, помимо литерального способа (let o = {}), использованного ранее, можно использовать функцию Object. Не следует путать тип object и класс Object. Последний выполняет роль фундаментального базового класса в генеалогии классов js. Если вывести Object в консоль, будет видно, что это функция.

"use strict"
let log = console.log;

let o1 = {};
let o2 = new Object();
log(o1 instanceof Object);
log(o2 instanceof Object);

4. Свойства функций и статические свойства класса

Говорят, что в js функция — это объект. Действительно, функции можно назначить произвольное свойство и вообще обращаться с ней как с обычным объектом, неважно, с маленькой она или большой буквы, является функцией-конструктором или возвращает результат вычисления.

Скажем, что функция в js имеет дуалистическую природу: с одной стороны, это функция, и её можно вызывать через круглые скобки, с другой, это объект, и у неё могут быть свои свойства, будь то поля или даже методы. Их объявление и обращение к ним выполняется через точку от имени функции. Нетрудно догадаться, что свойства функции-конструктора выступают статическими свойствами реализуемого ею класса. В этом примере мы объявляем у класса Person статическое поле count и статический метод getCount.

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
    Person.count++;
}

Person.count = 0;
Person.getCount = function()
    {
        return Person.count;
    }

for(let i = 0; i < 10; i++)
{
    let person = new Person(); // без параметра name, будет undefined
}
log(Person.getCount()); // 10

5. Флаги свойств объектов и некоторые статические методы функции Object

У свойств объекта есть значение и флаги. Их совокупность называется дескриптором свойства. Получить дескриптор можно через метод Object.getOwnPropertyDescriptor. Дескриптор в свою очередь представляет собой объект с полями с говорящими названиями value, writable, enumerable, configurable.

Метод Object.defineProperty позволяет назначить или модифицировать свойство объекта через дескриптор. В качестве дескриптора передаётся объект с любыми значимыми полями. В примере мы назначаем объекту свойство age со значением 18 и флагом enumerable, равным false. Этот флаг отвечает за видимость свойства при перечислении свойств объекта в некоторых конструкциях языка. Например, метод Object.keys проигнорирует свойство age. Однако есть метод Object.getOwnPropertyNames, который воспринимает и «неперечислимые» свойства.

Флаг writable отвечает за возможность перезаписи свойства, а configurable — его удаления (с помощью оператора delete).

"use strict"
let log = console.log;

let o =
    {
        name: "Alice",
        speak()
            {
                log("Hi, I'm " + this.name);
            },
    };
    
// Разузнаем дескрипторы свойств
let descriptor;

descriptor = Object.getOwnPropertyDescriptor(o, "name");
log(descriptor);

descriptor = Object.getOwnPropertyDescriptor(o, "speak");
log(descriptor);

// Назначим свойство с помощью дескриптора
Object.defineProperty(o, "age", {value: 18, enumerable: false});

log(Object.keys(o)); // Только name, speak
log(Object.getOwnPropertyNames(o)); // Теперь и age тоже

6. Прототипы

6.1. Связь объекта с прототипом

У объектов есть дефолтное свойство __proto__, которое указывает на его «прототип». Это обычный объект: в том смысле, что это не функция. При создании объекта ему явно или неявно назначается прототип. При этом объект получает возможность обращаться к тем свойствам прототипа, которых у него нет.

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

6.2. Указание прототипа и обращение к свойствам прототипа

Рассмотрим существенно искусственный пример прототипной связи между двумя объектами.

"use strict"
let log = console.log;

// Создаём некий объект
let person = new Object();
person.name = "John";
person.speak = function()
    {
        log("Hi, I'm " + this.name);
    };

// Создаём объект с прототипом person
let person1 = Object.create(person);
// Так тоже можно: сначала создаём объект, а потом указываем прототип
let person2 = new Object();
Object.setPrototypeOf(person2, person);
// Так тоже можно: оперируем напрямую свойством __proto__
let person3 = new Object();
person3.__proto__ = person;
// Можно и так
let person4 =
    {
        __proto__: person,
    };
    
// Выводим свойства объектов
log(Object.keys(person)); // name, speak
log(Object.keys(person1)); // пусто

// person1 не содержит метода speak и поля name, метод и поле берутся у прототипа
person1.speak();

// Теперь person1 имеет поле name, но не имеет метода speak
person1.name = "Alice";
person1.speak();

//Назначим person1 свой метод speak
person1.speak = function()
    {
        log("Hello, I'm " + this.name);
    };
person1.speak();

// Вызываем метод объекта person
person.speak();

Здесь мы создаём объект 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 с цепочкой прототипов.

"use strict"
let log = console.log;

let person = new Object();
person.name = "John";
let user = new Object();
user.pass = "12345";
Object.setPrototypeOf(user, person);
let account = new Object();
account.number = "3003";
Object.setPrototypeOf(account, user);

//Выводим объект account и цепочку прототипов
let obj = account;
do
    {
        log(obj);
        obj = Object.getPrototypeOf(obj);
    } while (obj != null);

// Свойство name будет заимствовано у person
log(account.name);

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.

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
}

let person1 = new Person("Alice");
let person2 = new Person("Peter");

// Прототипы объектов равны Person.prototype
log(person1.__proto__ === Person.prototype);
log(person1.__proto__ === person2.__proto__);
// Свойства Person.prototype
log(Person.prototype.constructor === Person);
log(Person.prototype.__proto__ === Object.prototype);
// Создание объекта через свойство constructor прототипа объекта
let person3 = new person1.constructor("Bob");
log(person3);
log(person3 instanceof Person);
// То же с функцией String
let str = new "".__proto__.constructor("London");
log(str);
log(str instanceof String);

6.5. Расширение прототипов

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

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
    this.speak = function()
    {
        log("Hi, I'm " + this.name);
    }
}
Person.prototype.talk = function()
{
    log("Hello, my name is " + this.name);
}

let person1 = new Person("Alice");
let person2 = new Person("Peter");
person1.speak();
person2.talk();

Здесь у каждого объекта наличествуют собственные свойства name и speak, а вот свойство talk принадлежит одному лишь прототипу. Объекты могут беспрепятственно обращаться к свойству прототипа. При этом указатель this не теряет свой контекст и ссылается на сам объект. Отсюда следует, что при проектировании классов через функции-конструкторы целесообразно размещать методы класса как свойства prototype.

6.6. Как работает оператор instanceof

Оператор имеет синтаксис obj instanceof <ИмяФункции>, где слева какой-то объект, а справа какая-то функция-конструктор. Это выражение возвращает истину, если объект является экземпляром функции.

Объект считается экземпляром функции, если свойство prototype функции, т.е. <ИмяФункции>.prototype, является звеном цепочки прототипов объекта. Т.е., если:

obj.__proto__ === <ИмяФункции>.prototype

или

obj.__proto__.__proto__ === <ИмяФункции>.prototype

и т.д.

"use strict"
let log = console.log;

function Person()
{
}

let person = new Person();
log(person instanceof Person); // true
log(person instanceof Object); // true

log({} instanceof Object); // true

6.7. О прочтении слов «прототип» и «prototype»

Говоря о прототипе объекта, мы имеем в виду некий иной объект, на который указывает свойство __proto__ этого первого объекта. В то же время, как правило, все объекты одного класса имеют один прототип, на который указывает свойство prototype функции-конструктора. Поскольку функция сама является объектом, выражение «прототип функции» может быть воспринято двояко: то ли это <ИмяФункции>.__proto__, то ли <ИмяФункции>.prototype. В целях этой статьи под прототипом функции мы понимаем <ИмяФункции>.__proto__, а свойство prototype, равное свойству __proto__ экземпляров этой функции, так и называем — prototype.

7. Прототипная связь функций

Если функция является объектом, то, стало быть, у неё может/должен быть прототип. Если мы возьмём любую функцию, то вывод в консоль выражения типа <ИмяФункции>.__proto__ покажет результат. Более того, они все ссылаются на один и тот же объект.

"use strict"
let log = console.log;

function double(x)
{
    return x*2;
}

function Person(name)
{
    this.mame = name;
}

log(double.__proto__ === Person.__proto__ ); // true
log(Person.__proto__ === Object.__proto__); // true

Этот загадочный объект находится по адресу Function.prototype. Здесь Function тоже функция. То есть получается, что при описании/создании какой-нибудь функции она как будто бы создаётся через вызов new Function(). В принципе, мы даже можем создать функцию подобным образом.

"use strict"
let log = console.log;

let myfunc = new Function('console.log("Hello");');
myfunc();

Из этого, конечно, не следует, что именно так и создаются функции. Просто их свойство __proto__ указывает на Function.prototype.

Возникает резонный вопрос: если прототип всякой функции ссылается на Function.prototype, то что является прототипом самой функции Function? Ответ: Function.__proto__ также ссылается на Function.prototype. В принципе это логично, ибо Function и сама является функцией.

"use strict"
let log = console.log;

log(Object.__proto__ === Function.prototype); //true
log(Function.__proto__ === Function.prototype); // true
log(Function.prototype.__proto__ === Object.prototype); // true

А вот Function.prototype.__proto__ ссылается на Object.prototype, что тоже логично, ибо свойство prototype любой функции является объектом, а не функцией. Вспомним как работает оператор instanceof и изобразим прототипные отношения наших функций следующим образом.

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
}

// Везде true

// Потому что Person.__proto__ === Function.prototype
log(Person instanceof Function);

// Потому что Person.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
log(Person instanceof Object);

// Потому что Object.__proto__ === Function.prototype
log(Object instanceof Function);

// Потому что Object.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
log(Object instanceof Object);

// Потому что Function.__proto__ === Function.prototype
log(Function instanceof Function);

// Потому что Function.__proto__.__proto__ === Function.prototype.__proto__ === Object.prototype
log(Function instanceof Object);

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. Подобный приём нам пригодится при реализации наследования.

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
}

let person1 = new Person("Alice");

let person2 = {};
Object.setPrototypeOf(person2, Person.prototype);
Person.call(person2, "Peter");

let person3 = Object.create(Person.prototype);
Person.call(person3, "Bob");

log(person1);
log(person2);
log(person3);

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.

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

"use strict"
let log = console.log;

// Базовый класс TestItem
function TestItem(question, points, answer)
{
    this.question = question;
    this.points = points;
    this.answer = answer;
}

TestItem.prototype.check = function(answer)
{
    return answer === this.answer;
}

TestItem.prototype.printItem = function()
{
    // Этого хватит
    log(this.question);
}
// Статический метод
TestItem.printTest = function()
{
    // Этого хватит
    log("static method");
}

// Создаём экземпляр класса
let item;
item = new TestItem("Столица США", ["Вашингтон", "Нью-Йорк", "Филадельфия"], 0);

// Имитируем проверку
log(item.check(0)); // true

// Расширяем базовый класс
function MultipleChoiceTestItem(question, points, answer, hint)
{
    TestItem.call(this, question, points, answer);
    this.hint = hint;
}

// Устанавливаем прототипную связь свойства prototype производного класса с prototype базового класса
Object.setPrototypeOf(MultipleChoiceTestItem.prototype, TestItem.prototype);
// Устанавливаем прототипную связь производного класса с базовым классом
Object.setPrototypeOf(MultipleChoiceTestItem, TestItem);

// Переопределяем обычный метод базового класса
MultipleChoiceTestItem.prototype.check = function(answer)
{
    // Лишние пункты
    let arr1 = this.answer.filter(value => !answer.includes(value));
    // Недостающие пункты
    let arr2 = answer.filter(value => !this.answer.includes(value));
    // Обоих быть не должно
    return arr1.length == 0 && arr2.length == 0;
}

// Переопределяем обычный метод базового класса с использованием базового метода
MultipleChoiceTestItem.prototype.printItem = function()
{
    // Вызов базового метода
    TestItem.prototype.printItem.call(this);
    // Этого хватит
    log(this.points);
}

// Создаём экземпляр производного класса
item = new MultipleChoiceTestItem("Выберите произведения Тургенева", ["Рудин", "Обломов", "Дым"], [0, 2], "Одно из произведений Гончарова названо по фамилии главного героя");    

// Имитируем проверку
log(item.check([0, 1])); // false

// В этом методе также вызывается метод базового класса
item.printItem();

// Вызов статического метода
MultipleChoiceTestItem.printTest(item);

На практике некоторые приёмы обращения со свойством __proto__ считаются нерекомендованными. Например, при наследовании вместо метода Object.setPrototypeOf используется Object.create. В этом случае prototype функции-конструктора создаётся с нуля (якобы быстрее создать prototype с нуля, чем модифицировать существующий).

После такого действия объекту prototype необходимо назначить свойство constructor, равным самой функции-конструктору. В следующем примере для этого используется метод Object.defineProperty с передачей дескриптора свойства. Если мы напишем просто User.prototype.constructor = User (в принципе можно и так), то флаги свойства могут быть отличны от дефолтных значений флагов свойства constructor.

Отдельная история наблюдается с прототипной связью между функцией-конструктором производного и базового классов. Здесь мы не можем пересоздать функцию. Статические свойства базового класса можно скопировать в базовый с помощью метода Object.assign. Однако прототипной связи в данном случае установлено не будет, User.__proto__ будет ссылаться на Function.prototype, а не на Person.

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

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
}
Person.staticMethod = function()
{
    log("Person.staticMethod");
}

function User(name, password)
{
    Person.call(this, name);
    this.password = password;
}

// Назначаем (пересоздаём) User свойство prototype со свойством __proto__, равным Person.prototype
User.prototype = Object.create(Person.prototype);
Object.defineProperty(User.prototype, "constructor", { value: User, writable: true, enumerable: false, configurable: true});

// Копируем свойства Person в User
Object.assign(User, Person);

let user = new User();
log(user instanceof User); // true
log(user instanceof Person); // true

log(Object.getOwnPropertyNames(User)); // Среди прочих и staticMethod

10. Class вместо function

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

"use strict"
let log = console.log;

// Базовый класс TestItem
class TestItem
{
    constructor(question, points, answer)
    {
        this.question = question;
        this.points = points;
        this.answer = answer;
    }
    check(answer)
    {
        return answer === this.answer;
    }
    printItem()
    {
        // Этого хватит
        log(this.question);
    }
    static printTest()
    {
        // Этого хватит
        log("static method");
    }
}

// Создаём экземпляр класса
let item;
item = new TestItem("Столица США", ["Вашингтон", "Нью-Йорк", "Филадельфия"], 0);

// Имитируем проверку
log(item.check(0)); // true

// Расширяем базовый класс
class MultipleChoiceTestItem extends TestItem
{
    constructor(question, points, answer, hint)
    {
        super(question, points, answer);
        this.hint = hint;
    }
    check(answer)
    {
        // Лишние пункты
        let arr1 = this.answer.filter(value => !answer.includes(value));
        // Недостающие пункты
        let arr2 = answer.filter(value => !this.answer.includes(value));
        // Обоих быть не должно
        return arr1.length == 0 && arr2.length == 0;
    }
    printItem()
    {
        super.printItem();
        // Этого хватит
        log(this.points);
    }
}

// Создаём экземпляр производного класса
item = new MultipleChoiceTestItem("Выберите произведения Тургенева", ["Рудин", "Обломов", "Дым"], [0, 2], "Одно из произведений Гончарова названо по фамилии главного героя");

// Имитируем проверку
log(item.check([0, 1])); // false

// В этом методе также вызывается метод базового класса
item.printItem();

// Вызов статического метода
MultipleChoiceTestItem.printTest();

// Отображаем структуру и взаимосвязи
log(MultipleChoiceTestItem.prototype.constructor === MultipleChoiceTestItem); // true
log(MultipleChoiceTestItem.prototype.__proto__ === TestItem.prototype); // true
log(MultipleChoiceTestItem.__proto__ === TestItem); // true (!)
log(TestItem.__proto__ === Function.prototype); // true

Реализация классов с помощью конструкции class оставляет за бортом возню с прототипами. Не будем заостряться на синтаксисе, ибо он не из ряда вон выходящий, но обратим внимание на то, что прототипная архитектура функций осталась прежней. Сам класс при этом остаётся по сути и за исключением каких-то нюансов той же функцией.

Заметим, что прототип производного класса равен базовому: MultipleChoiseTestItem.__proto__ = TestItem. При использовании конструкции function такая связь устанавливалась через метод Object.setPrototypeOf. Эта связь позволяет производному классу вызывать статические методы базового класса.

11. Наследование статических свойств

Как и обычные свойства, статические также наследуются, что показано в предыдущем пункте на примере метода printTest.

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

Обращение к статическому свойству выполняется либо через имя функции, либо через this в теле статического метода.

Операция присваивания свойств производного класса, которая создаёт одноимённое свойство, независимое от базового (например, переопределение метода), может приобрести разночтение относительно статического поля.

Рассмотрим следующий показательный пример.

"use strict"
let log = console.log;

function Person(name)
{
    this.name = name;
    Person.count++;
}
Person.resetCount = function()
{
    this.count = 0;
}
Person.count = 0;

function User(name, password)
{
    Person.call(this, name);
    this.password = password;
}

Object.setPrototypeOf(User.prototype, Person.prototype);
Object.setPrototypeOf(User, Person);

let user = new User();

log("before");
log(Object.getOwnPropertyNames(User));
log(Person.count); // 1
log(User.count); // 1

User.resetCount();

log("after");
log(Object.getOwnPropertyNames(User)); // теперь и count
log(Person.count); // 1
log(User.count); // 0

При вызове метода 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 вернёт значение поля базового класса.

"use strict"
let log = console.log;

class Person
{
    constructor(name)
    {
        this.name = name;
        Person.count1 ++;
        Person.count2 ++;
    }
    static resetCount()
    {
        this.count1 = 0;
        Person.count2 = 0;
    }
    static count1 = 0;
    static count2 = 0;
}

class User extends Person
{
    constructor(name)
    {
        super(name);
    }
}

let user = new User();

log("before");
log(Object.getOwnPropertyNames(User));
log(Person.count1, Person.count2); // 1 1
log(User.count1, User.count2); // 1 1

User.resetCount();

log("after");
log(Object.getOwnPropertyNames(User)); // теперь и count1
log(Person.count1, Person.count2); // 1 0
log(User.count1, User.count2); // 0 0

Итого

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

С развитием классов в js нюансы работы с прототипами имеют меньшее распространение, но сохраняют свой шарм, присущий JavaScript в целом.

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

Перейти к регистрации