Використання класів

JavaScript – це мова, заснована на прототипах: поведінка об'єкта визначається його власними властивостями та властивостями його прототипу. Однак, завдяки додаванню класів, створення ієрархій об'єктів та успадкування властивостей та їх значень стають більш схожими на інші об'єктноорієнтовані мови, такі як Java. У цьому розділі ми продемонструємо, як об'єкти можна створювати з класів.

У багатьох інших мовах класи, або ж конструктори, явно відрізняються від об'єктів, або ж примірників. У JavaScript класи – це головним чином абстракція над присутнім механізмом прототипного успадкування: будь-який патерн можна перетворити на прототипне успадкування. Самі класи також є звичайними значеннями JavaScript, і мають свої власні ланцюжки прототипів. Насправді більшість звичайних функцій JavaScript можна використовувати як конструктори: потрібен оператор new вкупі з конструкторною функцією, щоб створити новий об'єкт.

В цьому підручнику ми гратимемось з як слід абстрагованою моделлю класів, і обговоримо те, що пропонує семантика класів. За бажання зануритися глибше в систему прототипів, що схована за лаштунками, варто прочитати посібник Успадкування та ланцюжок прототипів.

Цей розділ виходить з припущення, що ви вже дещо знайомі з JavaScript, і ви використовували звичайні об'єкти.

Огляд класів

Якщо ви маєте певний практичний досвід з JavaScript, або якщо йдете сторінками посібника, то, можливо, вже використовували класи, навіть якщо ще їх не створювали. Наприклад, це може вам здатися знайомим:

const bigDay = new Date(2019, 6, 19);
console.log(bigDay.toLocaleDateString());
if (bigDay.getTime() < Date.now()) {
  console.log("Жили-були дід та баба...");
}

На першому рядку створений примірник класу Date, і названий він bigDay. На другому рядку викликано метод toLocaleDateString() на примірнику bigDay, який повертає рядок. Потім порівняно два числа: одне повернене з методу getTime(), інше викликане безпосередньо з самого класу Date, у вигляді Date.now().

Date – це вбудований клас JavaScript. З цього прикладу можна отримати певні базові уявлення про те, що роблять класи:

  • Класи створюють об'єкти за допомогою оператора new.
  • Кожний об'єкт має певні властивості (дані чи методи), додані класом.
  • Клас сам зберігає частину властивостей (дані чи методи), і вони зазвичай використовуються для взаємодії з примірниками.

Ці пункти відповідають трьом ключовим ознакам класів:

  • Конструктору;
  • Методам примірників і полям примірників;
  • Статичним методам і статичним полям.

Оголошення класу

Класи зазвичай створюються за допомогою оголошень класу.

class MyClass {
  // тіло класу...
}

Всередині тіла класу доступна низка можливостей.

class MyClass {
  // Конструктор
  constructor() {
    // Тіло конструктора
  }
  // Поле примірника
  myField = "агов";
  // Метод примірника
  myMethod() {
    // Тіло myMethod
  }
  // Статичне поле
  static myStaticField = "агей";
  // Статичний метод
  static myStaticMethod() {
    // Тіло myStaticMethod
  }
  // Статичний блок
  static {
    // Код статичної ініціалізації
  }
  // І поля, і методи, і статичні поля, і статичні методи
  // мають "приватні" форми
  #myPrivateField = "bar";
}

Якщо ви прийшли зі світу до ES6, то для вас може бути більш знайомим використання функцій як конструкторів. Патерн вище перекладається на функційні конструктори приблизно так:

function MyClass() {
  this.myField = "агов";
  // Тіло конструктора
}
MyClass.myStaticField = "агей";
MyClass.myStaticMethod = function () {
  // Тіло myStaticMethod
};
MyClass.prototype.myMethod = function () {
  // Тіло myMethod
};

(function () {
  // Код статичної ініціалізації
})();

Примітка: Приватні поля та методи - це нова можливість класів, яка не має тривіального еквівалента в функційних конструкторах.

Конструювання класу

Коли клас оголошено, можна створювати його примірники – за допомогою оператора new.

const myInstance = new MyClass();
console.log(myInstance.myField); // 'агов'
myInstance.myMethod();

Типові функційні конструктори можна і конструювати з new, і викликати без new. Проте спроба "викликати" клас без new призведе до помилки.

const myInstance = MyClass(); // TypeError: Class constructor MyClass cannot be invoked without 'new'

Підняття оголошення класу

На відміну від оголошень функцій, оголошення класів не піднімаються (або, в частині тлумачень, піднімаються, але з обмеженнями темпоральної мертвої зони), що означає: клас не можна використовувати раніше, ніж він оголошений.

new MyClass(); // ReferenceError: Cannot access 'MyClass' before initialization

class MyClass {}

Така поведінка подібна до поведінки змінних, оголошених з let і const.

Вирази класів

Подібно до функцій, оголошення класів також мають свої аналоги у вигляді виразів.

const MyClass = class {
  // Тіло класу...
};

Вирази класів також можуть мати назви. Назва виразу нижче доступна лише тілу класу.

const MyClass = class MyClassLongerName {
  // Тіло класу. Тут MyClass і MyClassLongerName вказують на один і той же клас.
};
new MyClassLongerName(); // ReferenceError: MyClassLongerName is not defined

Конструктор

Мабуть, найважливіше призначення класу – працювати "фабрикою" об'єктів. Наприклад, коли використовується конструктор Date, то очікується, що він видасть новий об'єкт, котрий представляє дані дати, що були передані – з котрими пізніше можна працювати за допомогою інших методів, що доступні на примірниках. У класах створення примірників відбувається за допомогою конструктора.

Наприклад, можна створити клас під назвою Color, котрий представляє конкретний колір. Користувачі створюють кольори, передаючи трійку RGB.

class Color {
  constructor(r, g, b) {
    // Присвоїти значення RGB як властивість на `this`.
    this.values = [r, g, b];
  }
}

Відкрийте інструменти розробника у вашому браузері, вставте код вище у консоль, а потім створіть примірник:

const red = new Color(255, 0, 0);
console.log(red);

Повинно вивестися щось на зразок:

Object { values: (3) […] }
  values: Array(3) [ 255, 0, 0 ]

Ви успішно створили примірник Color, і він має властивість values, котра є масивом значень RGB, переданих при створенні. Це практично еквівалентно наступному:

function createColor(r, g, b) {
  return {
    values: [r, g, b],
  };
}

Синтаксис конструктора – точно такий же, як для звичайної функції – а отже, можна використовувати інші записи, такі як решту параметрів:

class Color {
  constructor(...values) {
    this.values = values;
  }
}

const red = new Color(255, 0, 0);
// Створює примірник з такою ж структурою, як і вище.

Щоразу, коли викликається new, створюється новий примірник.

const red = new Color(255, 0, 0);
const anotherRed = new Color(255, 0, 0);
console.log(red === anotherRed); // false

Всередині конструктора класу значення this вказує на новостворений примірник. Котрому можна присвоїти властивості, а також зчитати наявні (особливо методи – про що буде мова далі).

Значення this буде автоматично повернено як результат new. Радять не повертати з конструктора жодних значень, адже якщо повернути непримітивне значення, то воно стане значенням виразу new, і значення this буде відкинуто. (Більше про те, що робить new, можна прочитати у його описі.`)

class MyClass {
  constructor() {
    this.myField = "агов";
    return {};
  }
}

console.log(new MyClass().myField); // undefined

Методи примірників

Якщо клас має лише конструктор, він не надто відрізняється від фабричної функції createX, яка лише створює прості об'єкти. Проте міць класів полягає в тому, що вони можуть вживатися як "шаблони", що автоматично присвоюють примірникам методи.

Наприклад, на примірниках Date можна використовувати низку методів, призначених для отримання різної інформації на основі єдиного значення дати, наприклад, рік, місяць, день тижня тощо. Крім цього, можна задавати ці значення – за допомогою відповідних методів setX, наприклад, setFullYear.

Для нашого власного класу Color можна додати метод, що зветься getRed, котрий повертає червоне значення кольору.

class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  getRed() {
    return this.values[0];
  }
}

const red = new Color(255, 0, 0);
console.log(red.getRed()); // 255

Без методів може бути спокуса визначити цю функцію в конструкторі:

class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
    this.getRed = function () {
      return this.values[0];
    };
  }
}

Це також працює. Проте проблема полягає в тому, що це створює нову функцію щоразу, коли створюється примірник Color, навіть попри те, що ці функції роблять одне й те ж!

console.log(new Color().getRed === new Color().getRed); // false

Натомість якщо використати метод, то він буде спільним для всіх примірників. Функція буде спільною для всіх примірників, але все одно її логіка відрізнятиметься, коли її викликатимуть різні примірники, тому що значення this – різне. Якщо вам цікаво, де зберігається цей метод – то він визначений на прототипі всіх примірників, тобто Color.prototype, що більш докладно пояснено в Успадкуванні та ланцюжку прототипів.

Подібно до цього, можна створити новий метод, що зветься setRed, котрий задає червоне значення кольору.

class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  getRed() {
    return this.values[0];
  }
  setRed(value) {
    this.values[0] = value;
  }
}

const red = new Color(255, 0, 0);
red.setRed(0);
console.log(red.getRed()); // 0; звісно, він повинен зватися "чорним" на цьому етапі!

Приватні поля

Можна запитати: навіщо морочитися з методами getRed і setRed, якщо можна безпосередньо звертатися до масиву values на примірнику?

class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
}

const red = new Color(255, 0, 0);
red.values[0] = 0;
console.log(red.values[0]); // 0

В об'єктноорієнтованому програмуванні є філософія, що зветься "інкапсуляцією". Це означає, що слід звертатися не до прихованої реалізації об'єкта, а до як слід абстрагованих методів, щоб взаємодіяти з ним. Наприклад, якби ми раптом вирішили представляти кольори як HSL:

class Color {
  constructor(r, g, b) {
    // тепер values – це масив HSL!
    this.values = rgbToHSL([r, g, b]);
  }
  getRed() {
    return this.values[0];
  }
  setRed(value) {
    this.values[0] = value;
  }
}

const red = new Color(255, 0, 0);
console.log(red.values[0]); // 0; Уже не 255, тому що значення H для чистого червоного – 0

Припущення про те, що values – це значення RGB, раптом зазнає краху, і це може призвести до зламу логіки. Тому, якщо ви автор класу, вам краще приховати внутрішню структуру даних вашого примірника від користувача, як для того, щоб API було чистим, так і для того, щоб запобігти зламу коду користувача, коли ви робите якусь "безпечну реорганізацію". У класах це робиться за допомогою приватних полів.

Приватне поле – це ідентифікатор, перед яким стоїть # (символ гешу). Геш є невід'ємною частиною імені поля, що означає, що приватна властивість ніколи не може мати конфлікту імен з публічною властивістю. Щоб посилатися на приватне поле в будь-якому місці класу, необхідно оголосити його в тілі класу (не можна створити приватну властивість на льоту). Крім цього, приватне поле практично еквівалентне звичайній властивості.

class Color {
  // Оголосити: кожний примірник Color має приватне поле, що зветься #values.
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  getRed() {
    return this.#values[0];
  }
  setRed(value) {
    this.#values[0] = value;
  }
}

const red = new Color(255, 0, 0);
console.log(red.getRed()); // 255

Звертання до приватних полів поза класом – це рання синтаксична помилка. Мова може захистити від цього, оскільки #privateField – це спеціальний синтаксис, тому вона може виконати деякий статичний аналіз і знайти всі використання приватних полів ще до того, як виконуватиме код.

console.log(red.#values); // SyntaxError: Private field '#values' must be declared in an enclosing class

Примітка: Код, запущений в консолі Chrome, може отримати доступ до приватних властивостей поза класом. Це послаблення є винятком від обмеження синтаксису JavaScript і діє лише в DevTools.

Приватні поля в JavaScript є суворо приватними: якщо клас не має реалізації методів, що відкривають ці поля для навколишнього світу, то не існує геть жодного механізму, щоб отримати їх поза класом. Це означає, що можна безпечно проводити будь-яку реорганізацію приватних полів класу, поки поведінка відкритих методів залишається сталою.

Коли поле values зроблено приватним, можна додати до методів getRed і setRed трохи більше логіки, замість того, аби вони були простими методами доступу. Наприклад, можна додати в setRed перевірку, щоб пересвідчитися, що передано дійсне значення R:

class Color {
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  getRed() {
    return this.#values[0];
  }
  setRed(value) {
    if (value < 0 || value > 255) {
      throw new RangeError("Invalid R value");
    }
    this.#values[0] = value;
  }
}

const red = new Color(255, 0, 0);
red.setRed(1000); // RangeError: Invalid R value

Якщо залишити властивість values відкритою, то користувачі зможуть легко обходити перевірку, присвоюючи значення безпосередньо values[0], і створювати недійсні кольори. Проте з як слід інкапсульованим API можна зробити код більш надійним і запобігти помилкам логіки вниз по ланцюжку.

Метод класу може зчитувати приватні поля інших примірників, якщо вони належать тому ж класу.

class Color {
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  redDifference(anotherColor) {
    // До #values не обов'язково звертатися з this:
    // можна звертатися до приватних полів інших примірників,
    // що належать тому самому класу.
    return this.#values[0] - anotherColor.#values[0];
  }
}

const red = new Color(255, 0, 0);
const crimson = new Color(220, 20, 60);
red.redDifference(crimson); // 35

Проте якщо anotherColor не є примірником Color, то #values не існуватиме. (Навіть якщо інший клас має приватне поле з ідентичною назвою #values, то воно не вказує на те саме, і до нього не можна звернутися з іншого класу.) Звертання до відсутньої приватної властивості викидає помилку, а не повертає undefined, як це буває зі звичайними властивостями. Якщо невідомо, чи на об'єкті існує приватне поле, і хочеться звернутися до нього без try і catch для обробки помилки, можна використати оператор in.

class Color {
  #values;
  constructor(r, g, b) {
    this.#values = [r, g, b];
  }
  redDifference(anotherColor) {
    if (!(#values in anotherColor)) {
      throw new TypeError("Color instance expected");
    }
    return this.#values[0] - anotherColor.#values[0];
  }
}

Примітка: Майте на увазі, що # – це особливий синтаксис ідентифікатора, і не можна використовувати назву поля, ніби це рядок. "#values" in anotherColor буде шукати властивість з назвою #values, а не приватне поле.

Використання приватних властивостей має певні обмеження: одне й те ж ім'я не може бути оголошено двічі в одному класі, а також їх не можна видаляти. І перше, і друге – призводять до ранніх синтаксичних помилок.

class BadIdeas {
  #firstName;
  #firstName; // тут стається синтаксична помилка
  #lastName;
  constructor() {
    delete this.#lastName; // так само синтаксична помилка
  }
}

Методи, гетери та сетери також можуть бути приватними. Це корисно, коли є щось складне, що клас повинен робити приховано, але ніяка інша частина коду не повинна мати доступ до цього.

Наприклад, уявімо створення власних елементів HTML, котрі повинні робити щось складне, коли їх клацають, стукають чи ще якось активують. Понад те, складні речі, що відбуваються при клацанні елемента, повинні бути обмежені цим класом, тому що жодна інша частина JavaScript ніколи не буде (і не повинна) мати до них доступу.

class Counter extends HTMLElement {
  #xValue = 0;
  constructor() {
    super();
    this.onclick = this.#clicked.bind(this);
  }
  get #x() {
    return this.#xValue;
  }
  set #x(value) {
    this.#xValue = value;
    window.requestAnimationFrame(this.#render.bind(this));
  }
  #clicked() {
    this.#x++;
  }
  #render() {
    this.textContent = this.#x.toString();
  }
  connectedCallback() {
    this.#render();
  }
}

customElements.define("num-counter", Counter);

У такому випадку практично всі поля та методи є приватними відносно класу. Таким чином, це дає решті коду інтерфейс, що по суті є таким самим, як у вбудованого елемента HTML. Жодна інша частина програми не має можливості впливати на будь-які нутрощі Counter.

Аксесорні поля

Методи color.getRed() і color.setRed() дають змогу зчитувати та записувати червоне значення кольору. Якщо ви прийшли з мови штибу Java, то вельми знайомі з цим патерном. Проте використання методів для простого звертання до властивості – це все ж трохи не ергономічно в JavaScript. Аксесорні поля дають змогу працювати з чимось, ніби це "справжня властивість".

class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  get red() {
    return this.values[0];
  }
  set red(value) {
    this.values[0] = value;
  }
}

const red = new Color(255, 0, 0);
red.red = 0;
console.log(red.red); // 0

Це має такий вигляд, ніби об'єкт має властивість під назвою red – але насправді такої властивості в екземплярі немає! Є лише два методи, але перед ними стоять get і set, що дає змогу звертатись до них, ніби це властивості.

Якщо поле має лише гетер, але не має сетера, то воно фактично буде доступним лише для зчитування.

class Color {
  constructor(r, g, b) {
    this.values = [r, g, b];
  }
  get red() {
    return this.values[0];
  }
}

const red = new Color(255, 0, 0);
red.red = 0;
console.log(red.red); // 255

У суворому режимі рядок red.red = 0 викине помилку типу: "Cannot set property red of #<Color> which has only a getter". У несуворому режимі присвоєння буде проігноровано без помилок.

Публічні поля

Приватні поля також мають публічні аналоги, завдяки чому кожний примірник може мати властивості. Поля зазвичай розробляються так, щоб бути незалежними від параметрів конструктора.

class MyClass {
  luckyNumber = Math.random();
}
console.log(new MyClass().luckyNumber); // 0.5
console.log(new MyClass().luckyNumber); // 0.3

Публічні поля – майже рівносильні присвоєнню властивості на this. Наприклад, вищезазначений приклад можна перетворити так:

class MyClass {
  constructor() {
    this.luckyNumber = Math.random();
  }
}

Статичні властивості

У прикладі Date також зустрівся метод Date.now(), що повертає поточну дату. Цей метод не належить жодному екземпляру дати – він належить самому класу. Проте він розміщений у класі Date, а не доступний як глобальна функція DateNow(), тому що він корисний переважно при роботі з екземплярами дати.

Примітка: Ставити перед допоміжними методами те, з чим вони працюють, зветься "формуванням простору імен" і вважається доброю практикою. Наприклад, на додачу до старого методу parseInt() JavaScript пізніше також додав метод з префіксом – Number.parseInt(), аби позначити те, що він призначений для роботи з числами.

Статичні властивості – це група членів класу, що визначені на самому класі, а не на окремих примірниках класу. Серед таких членів:

  • Статичні методи
  • Статичні поля
  • Статичні гетери та сетери

У всього цього також є приватні аналоги. Наприклад, у нашому класі Color можна створити статичний метод, що перевіряє, чи є задана трійка дійсним значенням RGB:

class Color {
  static isValid(r, g, b) {
    return r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255;
  }
}

Color.isValid(255, 0, 0); // true
Color.isValid(1000, 0, 0); // false

Статичні властивості – вельми подібні до своїх аналогів на примірниках, окрім того, що:

  • Перед ними всіма написано static, і
  • Вони недоступні на примірниках.
console.log(new Color(0, 0, 0).isValid); // undefined

На додачу, є особлива конструкція, що зветься блоком статичної ініціалізації, – це блок коду, що запускається, коли клас уперше завантажується.

class MyClass {
  static {
    MyClass.myStaticProperty = "агов";
  }
}

console.log(MyClass.myStaticProperty); // 'агов'

Блоки статичної ініціалізації майже рівносильні щодо виконання певного коду зразу після оголошення класу. Єдина відмінність – те, що вони мають доступ до статичних приватних властивостей.

Extends і успадкування

Ключова можливість, котру надають класи (на додачу до ергономічного інкапсулювання з приватними полями) – це успадкування, що означає, що один об'єкт може "запозичити" велику частину поведінки іншого об'єкта, водночас перевизначаючи або покращуючи певні частини за допомогою власної логіки.

Наприклад, припустімо, що наш клас Color тепер повинен підтримувати прозорість. Може бути спокуса додати нове поле, що позначає прозорість:

class Color {
  #values;
  constructor(r, g, b, a = 1) {
    this.#values = [r, g, b, a];
  }
  get alpha() {
    return this.#values[3];
  }
  set alpha(value) {
    if (value < 0 || value > 1) {
      throw new RangeError("Alpha value must be between 0 and 1");
    }
    this.#values[3] = value;
  }
}

Проте це означає, що кожному примірнику – навіть переважній більшості непрозорих кольорів (чиє значення альфа – 1) – доведеться мати додаткове значення альфа, що не дуже елегантно. Крім того, якщо кількість членів зростатиме далі, то клас Color стане роздутим і важким для підтримки.

Замість цього в об'єктноорієнтованому програмуванні створюють похідний клас. Похідний клас має доступ до всіх публічних властивостей батьківського класу. В JavaScript похідні класи оголошуються за допомогою положення extends, котре позначає клас, який розширюється новим класом.

class ColorWithAlpha extends Color {
  #alpha;
  constructor(r, g, b, a) {
    super(r, g, b);
    this.#alpha = a;
  }
  get alpha() {
    return this.#alpha;
  }
  set alpha(value) {
    if (value < 0 || value > 1) {
      throw new RangeError("Alpha value must be between 0 and 1");
    }
    this.#alpha = value;
  }
}

Є кілька речей, що відразу привертають увагу. По-перше, в конструкторі ми викликаємо super(r, g, b). Це вимога мови – викликати super() до звертань до this. Виклик super() викликає конструктор батьківського класу для ініціалізації this – тут він приблизно еквівалентний this = new Color(r, g, b). Можна мати код перед super(), але не можна звертатися до this перед super() – мова не дозволяє звертатися до неініціалізованого this.

Коли батьківський клас завершив унесення змін до this, похідний клас може виконати власну логіку. Тут ми додали приватне поле з назвою #alpha, а також надали пару з гетера та сетера для взаємодії з ними.

Похідний клас успадковує від батьківського всі методи. Наприклад, попри те, що ColorWithAlpha сам не оголошує аксесор get red(), red все одно можна використовувати, бо ця логіка задана батьківським класом:

const color = new ColorWithAlpha(255, 0, 0, 0.5);
console.log(color.red); // 255

Похідні класи також можуть перевизначати методи батьківського класу. Наприклад, усі класи неявно успадковують від класу Object, що визначає певні базові методи, наприклад, toString(). Проте базовий метод toString() – відомий своєю некорисністю, бо він в більшості випадків виводить [object Object]:

console.log(red.toString()); // [object Object]

Зате наш клас може перевизначити його, щоб друкувати значення RGB кольору:

class Color {
  #values;
  // …
  toString() {
    return this.#values.join(", ");
  }
}

console.log(new Color(255, 0, 0).toString()); // '255, 0, 0'

У похідних класах звертатися до методів батьківського класу можна за допомогою super. Це дозволяє створювати методи-розширення та уникати дублювання коду.

class ColorWithAlpha extends Color {
  #alpha;
  // …
  toString() {
    // Викликати toString() батьківського класу та добудувати повернене значення
    return `${super.toString()}, ${this.#alpha}`;
  }
}

console.log(new ColorWithAlpha(255, 0, 0, 0.5).toString()); // '255, 0, 0, 0.5'

Коли вживається extends, то статичні методи успадковуються також, тому їх також можна перевизначати та розширювати.

class ColorWithAlpha extends Color {
  // ...
  static isValid(r, g, b, a) {
    // Викликати isValid() батьківського класу та добудувати повернене значення
    return super.isValid(r, g, b) && a >= 0 && a <= 1;
  }
}

console.log(ColorWithAlpha.isValid(255, 0, 0, -1)); // false

Похідні класи не мають доступу до приватних полів батьківського класу – це ще один ключовий аспект того, що приватні поля в JavaScript є «суворо приватними». Приватні поля обмежені областю видимості тільки тілом класу і не доступні для будь-якого зовнішнього коду.

class ColorWithAlpha extends Color {
  log() {
    console.log(this.#values); // SyntaxError: Private field '#values' must be declared in an enclosing class
  }
}

Клас може розширювати лише один інший клас. Це запобігає проблемам множинного успадкування штибу діамантової проблеми. Проте, завдяки динамічній природі JavaScript, все ж можна досягти ефекту множинного успадкування – за допомогою композиції класів та домішок.

Примірники похідних класів також є примірниками базового класу.

const color = new ColorWithAlpha(255, 0, 0, 0.5);
console.log(color instanceof Color); // true
console.log(color instanceof ColorWithAlpha); // true

Цей посібник досі був прагматичним: ми зосередились на тому, як можна використовувати класи, але є ще одне питання без відповіді: навіщо використовувати класи? Відповідь: буває по-різному.

Класи вводять парадигму, тобто спосіб організації коду. Класи є основою об'єктноорієнтованого програмування, яке побудоване на концепціях, таких як успадкування та поліморфізм (особливо поліморфізм підтипів). Однак багато людей виступає філософськи проти деяких практик ООП і, як наслідок, не використовують класи.

Наприклад, одна з речей, які роблять об'єкти Date поганими, це те, що вони змінні.

function incrementDay(date) {
  return date.setDate(date.getDate() + 1);
}
const date = new Date(); // 2019-06-19
const newDay = incrementDay(date);
console.log(newDay); // 2019-06-20
// Стара дата також змінилася!?
console.log(date); // 2019-06-20

Змінність та прихований стан – важливі аспекти об'єктноорієнтованого програмування, але вони нерідко роблять код важким для розуміння – тому що будь-яка, нібито, невинна операція може мати неочікувані побічні ефекти та змінити поведінку в інших частинах програми.

Щоб перевикористати код, зазвичай доводиться розширювати класи, що може створювати великі ієрархії успадкування.

Типове дерево успадкування ООП, з п'ятьма класами та трьома рівнями

Проте нерідко важко описати успадкування чисто, щоб один клас міг успадковувати тільки один інший клас. Часто потрібна поведінка з декількох класів. У Java це робиться за допомогою інтерфейсів; в JavaScript це можна зробити за допомогою домішок. Але, врешті-решт, це все одно не дуже зручно.

З іншого боку, класи – це дуже потужний спосіб організації коду на вищому рівні. Наприклад, без класу Color довелось би створювати десяток допоміжних функцій:

function isRed(color) {
  return color.red === 255;
}
function isValidColor(color) {
  return (
    color.red >= 0 &&
    color.red <= 255 &&
    color.green >= 0 &&
    color.green <= 255 &&
    color.blue >= 0 &&
    color.blue <= 255
  );
}
// ...

Але завдяки класам їх можна зібрати під одним простором імен – Color, що покращує читабельність. На додачу до цього, запровадження приватних полів дає змогу приховувати певні дані від користувачів, що створює чистий API.

Загалом, слід розглядати варіант використання класів, коли треба створити об'єкти, що зберігають власні потаємні дані та надають багато функціональності. Для прикладу – вбудовані класи JavaScript:

  • Класи Map і Set зберігають колекції елементів і дають змогу звертатися до них за допомогою get(), set(), has() тощо.
  • Клас Date зберігає дату в вигляді часової мітки Unix (числа) та дає змогу форматувати, оновлювати та читати окремі компоненти дати.
  • Клас Error зберігає інформацію про певний виняток, включно з повідомленням про помилку, трасуванням стека, причиною тощо. Це один з небагатьох класів, що доступні з багатою структурою успадкування: є декілька вбудованих класів, наприклад, TypeError і ReferenceError, що розширяють Error. У випадку помилок це успадкування дає змогу уточнювати семантику помилок: кожен клас помилки представляє певний тип помилки, що можна легко перевірити за допомогою instanceof.

JavaScript пропонує механізм організації коду в канонічному об'єктноорієнтованому стилі, але вирішувати, чи використовувати його, і як саме – це цілком на розсуд програміста.