Пошук уроків, статей та іншого контенту
Дослідите приватні поля, гетери, сетери й перевизначення методів у сучасній прототипній моделі JavaScript.
Інкапсуляція — це принцип, за якого внутрішній стан об’єкта прихований від зовнішнього коду, а доступ до нього відбувається через контрольований публічний інтерфейс.
У JavaScript інкапсуляція може реалізовуватися кількома способами:
приватними полями класу з префіксом #;
гетерами та сетерами;
замиканнями;
угодою про використання властивостей із префіксом _.
У сучасному JavaScript справжню мовну приватність забезпечують саме поля #.
class BankAccount {
#balance = 0;
constructor(owner, initialBalance = 0) {
this.owner = owner;
this.balance = initialBalance;
}
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) {
throw new Error("Недостатньо коштів");
}
this.#balance -= amount;
}
get balance() {
return this.#balance;
}
#validateAmount(amount) {
if (!Number.isFinite(amount) || amount <= 0) {
throw new TypeError("Сума має бути додатним числом");
}
}
}
const account = new BankAccount("Олена", 1000);
account.deposit(250);
account.withdraw(100);
console.log(account.owner); // Олена
console.log(account.balance); // 1150
// account.#balance; // SyntaxError: приватне поле недоступне зовніУ цьому прикладі:
#balance доступне лише всередині BankAccount;
змінювати баланс напряму неможливо;
методи deposit() і withdraw() контролюють допустимі операції;
гетер balance надає доступ лише для читання;
приватний метод #validateAmount() приховує внутрішню перевірку.
Приватні елементи позначаються символом #:
class User {
#password;
constructor(password) {
this.#password = password;
}
#isValidPassword(password) {
return password === this.#password;
}
authenticate(password) {
return this.#isValidPassword(password);
}
}
const user = new User("secret");
console.log(user.authenticate("secret")); // true
console.log(user.authenticate("wrong")); // falseПриватне поле:
не є властивістю, доступною через object.property;
не читається через Object.keys();
не доступне через Object.getOwnPropertyNames();
не доступне в підкласі, навіть якщо підклас успадковує батьківський клас;
може використовуватися лише в тілі класу, де воно оголошене.
class Parent {
#value = 10;
getValue() {
return this.#value;
}
}
class Child extends Parent {
printValue() {
// return this.#value; // SyntaxError
return this.getValue();
}
}Приватність #value належить конкретному класу, а не всій ієрархії наслідування.
Такий код не працює:
class Example {
initialize() {
// this.#value = 10; // SyntaxError, якщо #value не оголошено в класі
}
}Приватне поле повинно бути оголошене в тілі класу:
class Example {
#value = 0;
initialize() {
this.#value = 10;
}
}Це відрізняється від звичайних властивостей, які можна створити будь-коли:
class Example {
initialize() {
this.value = 10;
}
}За замовчуванням властивості класу публічні:
class Product {
name;
price;
}Будь-який код може змінити їх:
const product = new Product();
product.name = "Клавіатура";
product.price = -100;Якщо значення має бути захищене від некоректних змін, краще використовувати приватне поле і публічний гетер або контрольований метод:
class Product {
#price = 0;
constructor(name, price) {
this.name = name;
this.setPrice(price);
}
setPrice(price) {
if (!Number.isFinite(price) || price < 0) {
throw new RangeError("Ціна не може бути від’ємною");
}
this.#price = price;
}
get price() {
return this.#price;
}
}
const product = new Product("Клавіатура", 200);
console.log(product.price); // 200
product.setPrice(250);
console.log(product.price); // 250Інкапсуляція не означає, що всі властивості потрібно приховувати. Публічними можуть залишатися дані, які не потребують додаткової перевірки або мають бути частиною відкритого стану об’єкта.
Гетер — це метод, який викликається як властивість:
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
}
const rectangle = new Rectangle(5, 4);
console.log(rectangle.area); // 20Гетер не викликається з дужками:
rectangle.area; // правильно
// rectangle.area(); // TypeErrorГетери зручні для:
обчислюваних властивостей;
читання приватного стану;
форматування даних;
створення абстракції над внутрішньою реалізацією.
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
const person = new Person("Ірина", "Коваль");
console.log(person.fullName); // Ірина КовальГетер може повертати копію внутрішньої структури, щоб зовнішній код не змінював її безпосередньо:
class Playlist {
#tracks = [];
addTrack(track) {
this.#tracks.push(track);
}
get tracks() {
return [...this.#tracks];
}
}
const playlist = new Playlist();
playlist.addTrack("Song A");
const tracks = playlist.tracks;
tracks.push("Song B");
console.log(playlist.tracks); // ["Song A"]Якби гетер повертав this.#tracks, зовнішній код міг би змінити приватний масив через отримане посилання.
Сетер — це метод, який викликається під час присвоєння значення властивості:
class Temperature {
#celsius = 0;
get celsius() {
return this.#celsius;
}
set celsius(value) {
if (!Number.isFinite(value)) {
throw new TypeError("Температура має бути числом");
}
this.#celsius = value;
}
}
const temperature = new Temperature();
temperature.celsius = 25;
console.log(temperature.celsius); // 25Сетер дозволяє залишити зручний синтаксис присвоєння, водночас додавши перевірку.
class User {
#email = "";
constructor(email) {
this.email = email;
}
get email() {
return this.#email;
}
set email(value) {
if (typeof value !== "string" || !value.includes("@")) {
throw new TypeError("Некоректна електронна адреса");
}
this.#email = value.trim().toLowerCase();
}
}
const user = new User(" USER@EXAMPLE.COM ");
console.log(user.email); // user@example.comМожна оголосити лише сетер. Це створює властивість, доступну для запису, але не для читання:
class Logger {
set message(value) {
console.log(`[LOG] ${value}`);
}
}
const logger = new Logger();
logger.message = "Операцію виконано"; // [LOG] Операцію виконано
console.log(logger.message); // undefinedЯкщо оголошено лише гетер, зовнішнє присвоєння не змінить значення через цей інтерфейс:
class Circle {
constructor(radius) {
this.radius = radius;
}
get diameter() {
return this.radius * 2;
}
}
const circle = new Circle(10);
console.log(circle.diameter); // 20
// circle.diameter = 100;
// У суворому режимі це спричинить TypeError.Гетер і сетер з однаковим ім’ям утворюють одну властивість аксесора. Їх можна оголосити разом:
class Account {
#name = "";
get name() {
return this.#name;
}
set name(value) {
this.#name = value.trim();
}
}Клас може успадковувати методи іншого класу за допомогою extends:
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} видає звук`;
}
}
class Dog extends Animal {
fetch() {
return `${this.name} приносить предмет`;
}
}
const dog = new Dog("Бобік");
console.log(dog.speak()); // Бобік видає звук
console.log(dog.fetch()); // Бобік приносить предметМетоди класів зберігаються у прототипах. Об’єкт dog не має власної копії методу speak. Під час виклику JavaScript шукає метод у такому ланцюжку:
у самому об’єкті;
у Dog.prototype;
у прототипі Animal.prototype;
далі в Object.prototype.
console.log(Object.hasOwn(dog, "speak")); // false
console.log(Object.hasOwn(Dog.prototype, "speak")); // false
console.log(Object.hasOwn(Animal.prototype, "speak")); // trueСаме прототипний ланцюжок забезпечує успадкування методів у класах JavaScript.
Перевизначення — це визначення в підкласі методу з таким самим ім’ям, як у батьківському класі.
class Notification {
send() {
return "Надсилання звичайного сповіщення";
}
}
class EmailNotification extends Notification {
send() {
return "Надсилання електронного листа";
}
}
const notification = new EmailNotification();
console.log(notification.send());
// Надсилання електронного листаПід час виклику notification.send() JavaScript знаходить метод у EmailNotification.prototype і не доходить до методу з Notification.prototype.
Перевизначення дає змогу реалізувати різну поведінку для об’єктів із спільним інтерфейсом:
class Payment {
pay(amount) {
throw new Error("Метод pay() має бути перевизначений");
}
}
class CardPayment extends Payment {
pay(amount) {
return `Оплата карткою: ${amount} грн`;
}
}
class CashPayment extends Payment {
pay(amount) {
return `Оплата готівкою: ${amount} грн`;
}
}
function processPayment(payment, amount) {
console.log(payment.pay(amount));
}
processPayment(new CardPayment(), 500);
processPayment(new CashPayment(), 300);Функції не потрібно знати конкретний тип об’єкта. Їй достатньо, що об’єкт має метод pay().
Це приклад поліморфізму: один і той самий виклик має різну реалізацію залежно від фактичного типу об’єкта.
superКлючове слово super дає змогу звернутися до батьківського класу.
У методі підкласу super.method() викликає відповідний метод батьківського прототипу:
class Logger {
log(message) {
return `[LOG] ${message}`;
}
}
class TimestampLogger extends Logger {
log(message) {
const timestamp = new Date().toISOString();
return `${timestamp} ${super.log(message)}`;
}
}
const logger = new TimestampLogger();
console.log(logger.log("Система запущена"));Підклас може розширити поведінку, не дублюючи код батьківського методу.
super також використовується в конструкторі:
class User {
constructor(name) {
this.name = name;
}
}
class Admin extends User {
constructor(name, permissions) {
super(name);
this.permissions = permissions;
}
}
const admin = new Admin("Олена", ["users:read"]);
console.log(admin.name); // Олена
console.log(admin.permissions); // ["users:read"]У конструкторі підкласу не можна використовувати this до виклику super():
class Admin extends User {
constructor(name) {
// this.name = name; // ReferenceError
super(name);
}
}Виклик super() створює та ініціалізує частину об’єкта, яка належить батьківському класу.
Гетери та сетери також можна перевизначати.
class Employee {
constructor(name) {
this.name = name;
}
get description() {
return `Працівник: ${this.name}`;
}
}
class Manager extends Employee {
get description() {
return `Керівник: ${this.name}`;
}
}
console.log(new Employee("Андрій").description);
// Працівник: Андрій
console.log(new Manager("Марія").description);
// Керівник: МаріяПід час перевизначення гетера батьківську реалізацію можна викликати через super:
class User {
constructor(name) {
this.name = name;
}
get label() {
return this.name;
}
}
class Admin extends User {
get label() {
return `Адміністратор: ${super.label}`;
}
}
console.log(new Admin("Олена").label);
// Адміністратор: ОленаЯкщо в батьківському класі є гетер, а в підкласі потрібно дозволити запис, сетер необхідно оголосити явно. Інакше присвоєння може не мати очікуваного результату.
Приватні поля не є полями підкласу:
class Base {
#value = 10;
getValue() {
return this.#value;
}
}
class Derived extends Base {
getValueFromParent() {
return super.getValue();
}
}
const object = new Derived();
console.log(object.getValueFromParent()); // 10Підклас не може напряму звернутися до #value, але може використовувати публічні або захищені за угодою методи батьківського класу.
Якщо підкласу потрібен доступ до стану, можливі такі варіанти:
надати публічний гетер;
надати методи для контрольованої зміни;
використовувати властивість із префіксом _;
оголосити окреме приватне поле в підкласі.
class Base {
#value = 10;
get value() {
return this.#value;
}
}
class Derived extends Base {
doubleValue() {
return this.value * 2;
}
}
console.log(new Derived().doubleValue()); // 20Публічний гетер не робить внутрішнє поле публічним. Він лише надає контрольований спосіб отримати його значення.
_ та справжня приватністьДо появи приватних полів часто використовували угоду:
class User {
constructor(password) {
this._password = password;
}
}Префікс _ означає: «це внутрішня властивість, не використовуйте її напряму». Але технічно вона залишається публічною:
const user = new User("secret");
console.log(user._password); // secret
user._password = "new value";Порівняння:
_password — лише домовленість між розробниками;
#password — обмеження на рівні синтаксису та виконання JavaScript.
Поля _ можуть бути корисними, якщо:
підкласам потрібен прямий доступ;
код має працювати в середовищі без підтримки приватних полів;
приватність не є критичною;
потрібно уникнути надмірної кількості гетерів і методів.
Але для справжньої інкапсуляції краще використовувати #.
class Account {
#balance = 0;
constructor(owner, initialBalance = 0) {
this.owner = owner;
this.#setInitialBalance(initialBalance);
}
get balance() {
return this.#balance;
}
deposit(amount) {
this.#validateAmount(amount);
this.#balance += amount;
}
withdraw(amount) {
this.#validateAmount(amount);
if (amount > this.#balance) {
throw new Error("Недостатньо коштів на рахунку");
}
this.#balance -= amount;
}
getSummary() {
return `${this.owner}: ${this.#balance} грн`;
}
#setInitialBalance(amount) {
if (!Number.isFinite(amount) || amount < 0) {
throw new RangeError("Початковий баланс має бути невід’ємним числом");
}
this.#balance = amount;
}
#validateAmount(amount) {
if (!Number.isFinite(amount) || amount <= 0) {
throw new TypeError("Сума має бути додатним числом");
}
}
}
class SavingsAccount extends Account {
#interestRate;
constructor(owner, initialBalance, interestRate) {
super(owner, initialBalance);
if (!Number.isFinite(interestRate) || interestRate < 0) {
throw new RangeError("Відсоткова ставка має бути невід’ємним числом");
}
this.#interestRate = interestRate;
}
applyInterest() {
const interest = this.balance * this.#interestRate;
this.deposit(interest);
}
getSummary() {
return `${super.getSummary()}, ставка: ${this.#interestRate}%`;
}
}
class CheckingAccount extends Account {
withdraw(amount) {
const commission = 10;
super.withdraw(amount + commission);
}
getSummary() {
return `${super.getSummary()}, комісія за зняття: 10 грн`;
}
}
const savings = new SavingsAccount("Олена", 1000, 0.05);
savings.applyInterest();
const checking = new CheckingAccount("Андрій", 1000);
checking.withdraw(100);
console.log(savings.getSummary());
// Олена: 1000.05 грн, ставка: 0.05%
console.log(checking.getSummary());
// Андрій: 890 грн, комісія за зняття: 10 грн
// savings.#interestRate; // SyntaxError
// checking.#balance; // SyntaxErrorУ цьому прикладі:
Account інкапсулює баланс;
SavingsAccount додає власне приватне поле;
SavingsAccount перевизначає getSummary() і використовує super.getSummary();
CheckingAccount перевизначає withdraw() і додає комісію;
підкласи не змінюють баланс напряму, а користуються контрольованими методами deposit() і withdraw().
this у перевизначених методахМетоди класу не прив’язуються автоматично до об’єкта, з якого їх отримали:
class Counter {
value = 0;
increment() {
this.value += 1;
}
}
const counter = new Counter();
const increment = counter.increment;
// increment(); // TypeError або некоректний thisЯкщо метод передається як callback, його потрібно прив’язати:
const boundIncrement = counter.increment.bind(counter);
boundIncrement();
console.log(counter.value); // 1Ця проблема стосується і перевизначених методів. Якщо підклас викликає super.method(), важливо, щоб метод виконувався з правильним this.
Стрілкові функції мають лексичний this, але методи класу зазвичай оголошують звичайним синтаксисом, щоб вони залишалися методами прототипу.
JavaScript підтримує перевизначення методів у підкласах, але не має класичного перевантаження методів за сигнатурою.
Такий код не створює дві версії методу:
class Calculator {
add(a) {
return a;
}
add(a, b) {
return a + b;
}
}Друге оголошення повністю замінює перше.
Для різної кількості аргументів потрібно реалізувати логіку вручну:
class Calculator {
add(a, b = 0) {
return a + b;
}
}
const calculator = new Calculator();
console.log(calculator.add(5)); // 5
console.log(calculator.add(5, 3)); // 8Або використати залишковий параметр:
class Calculator {
add(...numbers) {
return numbers.reduce((sum, number) => sum + number, 0);
}
}
console.log(new Calculator().add(1, 2, 3, 4)); // 10class Example {
#value = 10;
getValue() {
return this.#value;
}
}
const example = new Example();
// example["#value"]; // undefined"#value" у дужковій нотації — це звичайний рядок, а не звернення до приватного поля.
class User {
get name() {
return "Олена";
}
}
const user = new User();
console.log(user.name); // правильно
// console.log(user.name()); // TypeErrorГетер використовується як властивість.
class User {
get name() {
// return this.name; // нескінченна рекурсія
return this.#name;
}
#name = "Олена";
}Гетер повинен звертатися до іншого сховища, наприклад до приватного поля.
Сетер також не повинен присвоювати значення тій самій властивості:
class User {
set name(value) {
// this.name = value; // нескінченна рекурсія
this.#name = value;
}
#name = "";
}super() у конструкторі підкласуclass Parent {
constructor(value) {
this.value = value;
}
}
class Child extends Parent {
constructor(value) {
// this.value = value; // помилка до super()
super(value);
}
}super не в тому контекстіsuper можна використовувати в методах класу, але не в довільній функції:
class Parent {
greet() {
return "Вітаю";
}
}
class Child extends Parent {
greet() {
const callback = () => super.greet();
return callback();
}
}Стрілкова функція в цьому прикладі успадковує лексичний контекст super, тому працює. Але окрема звичайна функція не має такого контексту.
class Parent {
#value = 1;
}
class Child extends Parent {
read() {
// return this.#value; // SyntaxError
}
}Якщо підклас має отримувати значення, батьківський клас повинен надати метод або гетер.
class Settings {
#config = { theme: "dark" };
get config() {
return this.#config;
}
}
const settings = new Settings();
settings.config.theme = "light";Попри приватність поля, об’єкт, який воно містить, був повернутий назовні за посиланням. Для захисту простих структур можна повертати копію:
class Settings {
#config = { theme: "dark" };
get config() {
return { ...this.#config };
}
}Для вкладених структур може знадобитися глибоке копіювання або інший незмінюваний формат даних.
Приховуйте стан, який не повинен змінюватися довільно.
Перевіряйте дані на межі публічного API класу.
Використовуйте гетери для обчислюваних значень і доступу лише для читання.
Використовуйте сетери лише там, де синтаксис присвоєння справді покращує API.
Не повертайте внутрішні змінювані масиви й об’єкти без копіювання.
У підкласах викликайте super, якщо потрібно зберегти логіку батьківського класу.
Не дублюйте реалізацію батьківського методу без потреби.
Пам’ятайте, що приватні поля # належать класу, у якому оголошені.
Не плутайте перевизначення методу з перевантаженням за кількістю або типами аргументів.
Проєктуйте невеликий і зрозумілий публічний інтерфейс замість відкриття всього внутрішнього стану.
Інкапсуляція приховує внутрішню реалізацію та контролює доступ до стану об’єкта.
Приватні поля й методи позначаються префіксом #.
Приватні елементи доступні лише всередині класу, де їх оголошено.
Гетер читається як властивість і зазвичай використовується для контрольованого отримання даних.
Сетер викликається під час присвоєння та може перевіряти або нормалізувати значення.
Класи успадковують методи через прототипний ланцюжок.
Перевизначення дає змогу підкласу замінити реалізацію методу батьківського класу.
super.method() викликає батьківську реалізацію.
super() у конструкторі підкласу потрібен до використання this.
Поліморфізм дає змогу працювати з різними підкласами через спільний інтерфейс.
Приватність # не поширюється автоматично на підкласи; доступ потрібно надавати через методи або гетери.