Классы
На практике нам часто надо создавать много объектов одного вида, например пользователей, товары или что-то еще.
В современном JavaScript есть конструкция «class», которая предоставляет возможности, полезные для ООП.
Класс: базовый синтаксис
Базовый синтаксис выглядит так:
class MyClass {
// методы класса
constructor() {...}
method1() {...}
method2() {...}
method3() {...}
...
}
Затем используется вызов new MyClass()
для создания объекта со всеми перечисленными методами. При этом автоматически
вызывается метод constructor()
. В нем можно инициализировать объект.
Например:
class User {
constructor(name) {
this.name = name;
}
sayHi() {
console.log(this.name);
}
}
// Использование:
const user = new User('Иван');
user.sayHi(); // -> Иван
Когда вызывается new User('Иван')
:
- Создается новый объект.
constructor
запускается с заданным аргументом и сохраняет его вthis.name
.
Что такое класс на самом деле
В JavaScript класс — это разновидность функции.
Вот что на самом деле делает конструкция class User {...}
:
- Создает функцию с именем
User
. Код функции берется изconstructor
(будет пустым, если такого метода нет). - Сохраняет все методы, такие как
sayHi()
вUser.prototype
.
При вызове метода объекта new User
он будет взят из прототипа, как это описано
в F.prototype. Таким образом, объекты new User
имеют доступ к
методам класса.
Свойства классов
В приведенном выше примере у класса User
есть только метод. Добавим свойство:
class User {
name = 'Аноним';
sayHi() {
console.log(this.name);
}
}
new User().sayHi(); // -> Аноним
Свойство name
не устанавливается в User.prototype
. Вместо этого оно создается оператором new
перед запуском
конструктора.
Наследование классов
Допустим у нас есть два класса: Animal
и Rabbit
. Мы хотим, чтобы Rabbit
расширял Animal
. Другими словами,
кролики должны происходить от животных, то есть иметь доступ к методам Animal
. и расширять функциональность Animal
своими методами.
Для того, чтобы наследовать класс от другого, используется ключевое слово extends
с названием родительского класса
перед {...}
.
Ниже Rabbit
наследует от Animal
:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
console.log(`${this.name} бежит со скоростью ${this.speed}.`);
}
stop() {
this.speed = 0;
console.log(`${this.name} стоит.`);
}
}
// Наследуем от Animal
class Rabbit extends Animal {
hide() {
console.log(`${this.name} прячется.`);
}
}
const rabbit = new Rabbit('Белый кролик');
rabbit.run(5); // -> Белый кролик бежит со скоростью 5.
rabbit.hide(); // -> Белый кролик прячется.
Код Rabbit
стал короче, так как используется constructor
класса Animal
по умолчанию.
Переопределение методов
Сейчас Rabbit
наследует от Animal
метод stop
, который устанавливает this.speed = 0
.
Если определить свой метод stop
в классе Rabbit
, то он будет использован взамен родительского:
class Rabbit extends Animal {
stop() {
// ... будет использован для rabbit.stop()
}
}
Но обычно, мы хотим не заменить родительский метод, а сделать новый на его основе, изменяя или расширяя функциональность.
У классов есть ключевое слово super
для таких случаев.
super.method(...)
вызывает родительский метод.super(...)
вызывает родительский конструктор (работает только внутри конструктора).
Пусть кролик автоматически прячется при остановке:
class Animal {
constructor(name) {
this.name = name;
this.speed = 0;
}
run(speed) {
this.speed = speed;
console.log(`${this.name} бежит со скоростью ${this.speed}.`);
}
stop() {
this.speed = 0;
console.log(`${this.name} стоит.`);
}
}
class Rabbit extends Animal {
hide() {
console.log(`${this.name} прячется.`);
}
stop() {
super.stop(); // вызываем родительский метод
this.hide(); // и затем hide
}
}
const rabbit = new Rabbit('Белый кролик');
rabbit.run(5); // -> Белый кролик бежит со скоростью 5.
rabbit.stop(); // -> Белый кролик стоит. Белый кролик прячется.
Переопределение конструктора
До сих пор у Rabbit
не было своего конструктора.
Если класс расширяет другой класс и не имеет конструктора, то автоматически создается такой «пустой» конструктор:
class Rabbit extends Animal {
// генерируется для классов-потомков, у которых нет своего конструктора
constructor(...args) {
super(...args);
}
}
Дочерний класс просто вызывает constructor
родительского класса. Так будет происходить, пока мы не создадим
собственный конструктор:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Aniamal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLength;
}
// ...
}
// Не работет!
const rabbit = new Rabbit('Белый кролик', 10); // Error: this is not defined
Произошла ошибка. В классах потомках конструктор обязан вызывать super(...)
, и (!) делать это перед
использованием this
.
В JavaScript существует различие между «функцией-конструктором наследующего класса» и всеми остальными. В наследующем
классе соответствующая функция-конструктор помечена специальным внутренним свойством [[ConstructorKind]]:"derived"
.
Разница в следующем:
- Когда выполняется обычный конструктор, он создает пустой объект и присваивает его
this
. - Когда запускается конструктор унаследованного класса, он этого не делает. Вместо этого он ждет, что это сделает конструктор родительского класса.
Поэтому, если мы создаем собственный конструктор, мы должны вызвать super()
, в противном случае объект для this
не
будет создан, и мы получим ошибку.
Чтобы конструктор Rabbit
работал, он должен вызвать super()
до того, как использовать this
:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Aniamal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// Теперь работает
const rabbit = new Rabbit('Белый кролик', 10);
console.log(rabbit.name); // -> Белый кролик
console.log(rabbit.earLength); // -> 10
Статические методы
Мы так же можем присвоить метод самой функции-классу, а не ее prototype
. Такие методы называются статическими.
В классе такие методы обозначаются ключевым словом static
, например:
class User {
static staticMethod() {
console.log(this === User);
}
}
User.staticMethod(); // -> true
Это фактически то же самое, что присвоить метод напрямую как свойство функции:
class User {
}
User.staticMethod = function () {
console.log(this === User);
};
Значением this
при вызове User.staticMethod()
будет сам конструктор класса User
(правило «объект до точки»).
Обычно статические методы используют для реализации функций, принадлежащих классу, но не к каким то конкретным объектам.
Например, есть объекты статей Article
, и нужна функция для их сравнения. Естественное решение — сделать для этого
метод Article.compare
:
class Article {
constructor(title, date) {
this.title = title;
this.date = date;
}
static compare(articleA, articleB) {
return articleA.date - articleB.date;
}
}
// Использование
const articles = [
new Article('HTM:', new Date(2019, 1, 1)),
new Article('CSS', new Date(2019, 0, 1)),
new Article('JavaScript', new Date(2019, 11, 1)),
]
articles.sort(Article.compare());
console.log(articles[0].title); // -> CSS
Здесь метод compare
стоит «над» статьями, как способ их сравнения. Это метод не статьи, а всего класса.
Приватные и защищенные методы и свойства
Один из важнейших принципов объектно-ориентированного программирования — разделение внутреннего и внешнего интерфейсов.
Внутренний и внешний интерфейсы
В ООП свойства и методы разделены на 2 группы:
- Внутренний интерфейс — методы и свойства, доступные из других методов класса, но не снаружи класса.
- Внешний интерфейс — методы и свойства, доступные снаружи класса.
В JavaScript есть два типа полей (свойств и методов) объекта:
- Публичные: доступные отовсюду. Они составляют внешний интерфейс.
- Приватные: доступные только внутри класса. Они для внутреннего интерфейса.
Во многих других языках также существуют «защищенные» поля, доступные только внутри класса или для дочерних классов (то есть, как приватные, но разрешен доступ для наследующих классов).
Защищенные поля не реализованы в JavaScript на уровне языка, но на практике они очень удобны, поэтому их эмулируют.
Для примера, сделаем кофеварку на JavaScript со всеми этими типами свойств.
Защищенное свойство «waterAmount»
Для начала создадим простой класс для описания кофеварки:
class CoffeeMachine {
waterAmount = 0; // количество воды внутри
constructor(power) {
this.power = power;
console.log(`Создана кофеварка, мощность: ${power}`);
}
}
// создаем кофеварку
const coffeeMachine = new CoffeeMachine(100);
// добавляем воды
coffeeMachine.waterAmount = 200;
Сейчас свойства waterAmount
и power
публичные. Мы можем получать и устанавливать им любое значение извне.
Изменим свойство waterAmount
на защищенное, чтобы иметь больше контроля над ним. Например, мы не хотим, чтобы кто-либо
устанавливал его ниже нуля.
Защищенные свойства обычно начинаются с префикса
_
.
Теперь наше свойство будет называться _waterAmount
:
class CoffeeMachine {
_waterAmount = 0; // количество воды внутри
setWaterAmount(value) {
if (value < 0) throw new Error('Отрицательное количество воды');
this._waterAmount = value;
}
getWaterAmount() {
return this._waterAmount;
}
constructor(power) {
this._power = power;
}
}
// создаем кофеварку
const coffeeMachine = new CoffeeMachine(100);
// устанавливаем количество воды
coffeeMachine.setWaterAmount(-10); // Error: Отрицательное количество воды
Теперь доступ под контролем, поэтому указать воду ниже нуля не удалось.
Свойство только для чтения «power»
Теперь сделаем свойство power
доступным только для чтения. Иногда нужно, чтобы свойство устанавливалось только при
создании объекта и после этого никогда не изменялось.
Это нам и нужно для кофеварки: мощность никогда не меняется.
Для этого нужно создать только геттер, но не сеттер:
class CoffeeMachine {
// ...
constructor(power) {
this._power = power;
}
get power() {
return this._power;
}
}
// создаем кофеварку
const coffeeMachine = new CoffeeMachine(100);
console.log(`Мощность ${coffeeMachine.power}W`); // -> Мощность: 100W
coffeeMachine.power = 25; // Error (no setter)
Приватное свойство «#waterLimit»
Приватные свойства и методы должны начинаться с #
. Они доступны только внутри класса.
class CoffeeMachine {
#waterLimit = 200;
#checkWater(value) {
if (value < 0) throw new Error('Отрицательный уровень воды');
if (value > this.#waterLimit) throw new Error('Слишком много воды');
}
}
const coffeeMachine = new CoffeeMachine();
// снаружи нет доступа к приватным методам класса
coffeeMachine.#checkWater(); // Error
coffeeMachine.#waterLimit = 1000; // Error
В терминах ООП отделение внутреннего интерфейса от внешнего называется инкапсуляция.
Для сокрытия внутреннего интерфейса мы используем защищенные или приватные свойства:
- Защищенные поля имеют префикс
_
. Это хорошо известное соглашение, не поддерживаемое на уровне языка. Программисты должны обращаться к полю, начинающемуся с_
, только из его класса и классов, унаследовавших от него. - Приватные поля имеют префикс
#
. JavaScript гарантирует, что мы можем получить доступ к таким полям только внутри класса.
Конспект главы из учебника по JavaScript — Классы