21 лютого 2024 р.

Приватні та захищені властивості та методи

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

Це обов’язкова практика у розробці вcього, що складніше, ніж застосунок “hello world”.

Щоб зрозуміти це, відійдімо від розробки та поглянемо на реальний світ.

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

Приклад з реального життя

Візьмемо до прикладу кавоварку. Проста ззовні: кнопка, дисплей, кілька отворів … і, безумовно, результат – чудова кава!:)

Але всередині… (малюнок з інструкції з ремонту)

Багато деталей. Але ми можемо використовувати її, не знаючи нічого.

Кавові машини досить надійні, чи не так? Ми можемо використовувати її один рік, і тільки якщо щось піде не так – віднести її в ремонт.

Секрет надійності та простоти кавоварки – всі деталі добре налаштовані і приховані всередині.

Якщо ми видалимо захисне покриття з кавоварки, то використовувати її буде набагато складніше (де натискати?), і небезпечніше (вона може вдарити електричним струмом).

Як ми побачимо, об’єкти у програмуванні подібні до кавоварок.

Але для того, щоб приховати внутрішні деталі, ми будемо використовувати не захисну кришку, а особливий синтаксис мови та конвенції.

Внутрішній та зовнішній інтерфейс

У об’єктно-орієнтованому програмуванні, властивості та методи розділені на дві групи:

  • Внутрішній інтерфейс – методи та властивості, доступні в інших методах класу, але не ззовні.
  • Зовнішній інтерфейс – методи та властивості, доступні також ззовні класу.

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

Внутрішній інтерфейс використовується об’єктом для роботи, його деталі використовують один одного. Наприклад, до нагрівального елемента прикріплюється трубка кип’ятильника.

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

Отже, все, що нам потрібно для використання об’єкту, – це знати його зовнішній інтерфейс. Ми можемо повністю не знати, як він працює всередині, і це чудово.

Це було загальне введення.

У JavaScript існує два типи полів об’єктів (властивостей та методів):

  • Публічні: доступні з будь-якого місця. Вони складають зовнішній інтерфейс. Досі ми використовували лише публічні властивості та методи.
  • Приватні: доступний тільки зсередини класу. Вони для внутрішнього інтерфейсу.

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

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

Тепер ми зробимо кавоварку в JavaScript з усіма цими типами властивостей. Кавоварка має багато деталей, ми не будемо моделювати їх для простоти прикладу (хоча ми можемо).

Захищена властивість “waterAmount”

Давайте спочатку зробимо простий клас для кавоварки:

class CoffeeMachine {
  waterAmount = 0; // кількість води всередині

  constructor(power) {
    this.power = power;
    alert( `Створено кавоварку, потужність: ${power}` );
  }

}

// створюємо кавоварку
let coffeeMachine = new CoffeeMachine(100);

// додаємо воду
coffeeMachine.waterAmount = 200;

Прямо зараз властивості waterAmount та power публічні. Ми можемо легко отримати/встановити їм будь-яке значення ззовні.

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

Захищені властивості зазвичай починають з підкресленням _.

Це не синтаксис на рівні мови, а лише відома домовленість між програмістами, що такі властивості та методи не повинні бути доступними ззовні.

Тому наша властивість буде називатися _waterAmount:

class CoffeeMachine {
  _waterAmount = 0;

  set waterAmount(value) {
    if (value < 0) {
      value = 0;
    }
    this._waterAmount = value;
  }

  get waterAmount() {
    return this._waterAmount;
  }

  constructor(power) {
    this._power = power;
  }

}

// створюємо кавоварку
let coffeeMachine = new CoffeeMachine(100);

// додаємо воду
coffeeMachine.waterAmount = -10; // _waterAmount буде 0, а не -10

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

Властивість тільки для читання “power”

Зробімо властивість power доступною лише для читання. Іноді трапляється, що властивість повинна бути встановлена тільки при створенні, а потім ніколи не змінюватися.

Для кавоварки це саме так: потужність ніколи не змінюється.

Щоб це зробити, потрібно зробити лише гетер, без сетеру:

class CoffeeMachine {
  // ...

  constructor(power) {
    this._power = power;
  }

  get power() {
    return this._power;
  }

}

// створюємо кавоварку
let coffeeMachine = new CoffeeMachine(100);

alert(`Потужність: ${coffeeMachine.power} Вт`); // Потужність: 100 Вт

coffeeMachine.power = 25; // Помилка (немає сетера)
Функції гетери/сетери

Тут ми використовували синтаксис гетерів/сетерів.

Але найчастіше get.../set... функції є кращими, наприклад:

class CoffeeMachine {
  _waterAmount = 0;

  setWaterAmount(value) {
    if (value < 0) value = 0;
    this._waterAmount = value;
  }

  getWaterAmount() {
    return this._waterAmount;
  }
}

new CoffeeMachine().setWaterAmount(100);

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

З іншого боку, get/set синтаксис коротше, тому, в кінцевому підсумку, немає жорсткого правила, рішення залежить від вас.

Захищені поля успадковуються

Якщо ми успадкуємо class MegaMachine extends CoffeeMachine, то потім ніщо не завадить нам мати доступ до this._waterAmount або this._power з методів нового класу.

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

Приватна властивість “#waterLimit”

Нещодавнє доповнення
Це нещодавнє доповнення до мови. Не підтримується рушіями JavaScript або підтримується частково та вимагає поліфілу.

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

Приватні властивості і методи повинні починатися з #. Вони доступні лише з класу.

Наприклад, ось приватна властивість #waterLimit та приватний метод, що перевіряє кількість води #fixWaterAmount:

class CoffeeMachine {
  #waterLimit = 200;

  #fixWaterAmount(value) {
    if (value < 0) return 0;
    if (value > this.#waterLimit) return this.#waterLimit;
  }

  setWaterAmount(value) {
    this.#waterLimit = this.#fixWaterAmount(value);
  }

}

let coffeeMachine = new CoffeeMachine();

// не можна отримати доступ до приватних властивостей і методів ззовні класу
coffeeMachine.#fixWaterAmount(123); // Помилка
coffeeMachine.#waterLimit = 1000; // Помилка

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

Приватні поля не конфліктують з публічним. Ми можемо мати як приватне #waterAmount та і публічне waterAmount поле одночасно.

Наприклад, зробімо аксесор waterAmount для #wateramount:

class CoffeeMachine {

  #waterAmount = 0;

  get waterAmount() {
    return this.#waterAmount;
  }

  set waterAmount(value) {
    if (value < 0) value = 0;
    this.#waterAmount = value;
  }
}

let machine = new CoffeeMachine();

machine.waterAmount = 100;
alert(machine.#waterAmount); // Помилка

На відміну від захищених, приватні поля забезпечуються самою мовою. Це добре.

Але якщо ми наслідуємося від CoffeeMachine, то ми не матимемо прямого доступу до #waterAmount. Ми повинні будемо покладатися на wateramount гетер/сетер:

class MegaCoffeeMachine extends CoffeeMachine {
  method() {
    alert( this.#waterAmount ); // Помилка: можна отримати доступ лише до CoffeeMachine
  }
}

У багатьох сценаріях таке обмеження занадто важке. Якщо ми розширимо CoffeeMachine, ми можемо мати виправдані причини доступу до своїх внутрішніх методів і властивостей. Ось чому захищені поля використовуються частіше, навіть якщо вони не підтримуються синтаксисом мови.

Приватні поля недоступні через this[name]

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

Як відомо, зазвичай ми можемо отримати доступ до поля, використовуючи this[name]:

class User {
  ...
  sayHi() {
    let fieldName = "ім’я";
    alert(`Привіт, ${this[fieldName]}`);
  }
}

З приватними полями це неможливо: this['#name'] не працює. Це обмеження синтаксису зроблено для забезпечення конфіденційності.

Підсумки

З точки зору ООП, відокремлення внутрішнього інтерфейсу від зовнішнього називається інкапсуляція.

Це дає наступні переваги:

Захист для користувачів, щоб вони не вистрелили собі в ногу

Уявіть, що є команда розробників, які використовують кавоварку. Вона була зроблена компанією “Best CoffeeMachine”, і добре працює, але захисне покриття було видалено. Таким чином, внутрішній інтерфейс викритий.

Всі розробники цивілізовані – вони використовують кавову машину, як це передбачено. Але один з них, Іван, вирішив, що він найрозумніший, і зробив деякі налаштування в нутрощах кавоварки. Через це кавоварка зламалася через два дні.

Це, безумовно, не вина Івана, а скоріше особи, яка зняла захисну кришку, і дозволила Івану виконувати свої маніпуляції.

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

Підтримка

Ситуація в програмуванні складніша, ніж з реальною кавоваркою, тому що ми не просто купуємо щось раз. Код постійно зазнає розробки та вдосконалення.

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

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

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

Прихована складність

Люди обожнюють, використовуючи речі, які є простими. Принаймні ззовні. Що всередині – це вже інша річ.

Програмісти не є винятком.

Завжди зручно, коли деталі реалізації приховані, а простий, добре документований зовнішній інтерфейс – доступний.

Щоб приховати внутрішній інтерфейс, ми використовуємо захищені або приватні властивості:

  • Захищені поля починаються з _. Це відома домовленість, яка не підкріплена на рівні мови. Програмісти повинні отримувати доступ до поля, що починається з _, лише з його класу та класів, успадковуваних від нього.
  • Приватні поля починаються з #. JavaScript гарантує, що ми можемо отримати доступ до них лише з середини класу.

Зараз, приватні поля не дуже добре підтримуються браузерами, але можна використовувати поліфіл.

Навчальна карта