Frontend blog.

Классы

На практике нам часто надо создавать много объектов одного вида, например пользователей, товары или что-то еще.

В современном 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('Иван'):

  1. Создается новый объект.
  2. constructor запускается с заданным аргументом и сохраняет его в this.name.

Что такое класс на самом деле

В JavaScript класс — это разновидность функции.

Вот что на самом деле делает конструкция class User {...}:

  1. Создает функцию с именем User. Код функции берется из constructor (будет пустым, если такого метода нет).
  2. Сохраняет все методы, такие как 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Классы

вернуться к списку