Пошук уроків, статей та іншого контенту
Розберете принципи інкапсуляції, наслідування, поліморфізму та абстракції на прикладах JavaScript-класів.
Об'єктно-орієнтоване програмування (ООП) — це підхід, у якому програма моделюється через об'єкти, що поєднують:
стан — дані об'єкта;
поведінку — методи, які працюють із цими даними;
правила взаємодії — публічний інтерфейс об'єкта.
У JavaScript ООП базується на прототипах, але синтаксис class надає зручніший спосіб описувати конструктори, методи, наслідування та приватні поля.
Основні принципи ООП:
інкапсуляція;
наслідування;
поліморфізм;
абстракція.
Розглянемо їх на прикладі системи обробки платежів.
Інкапсуляція — це обмеження прямого доступу до внутрішнього стану об'єкта та надання контрольованого публічного інтерфейсу.
Інкапсуляція допомагає:
захистити дані від некоректних змін;
зберігати інваріанти об'єкта;
приховати деталі реалізації;
змінювати внутрішню реалізацію без зміни зовнішнього API.
У сучасному JavaScript приватні поля й методи позначаються символом #.
class BankAccount {
#balance = 0;
constructor(owner, initialBalance = 0) {
if (!owner) {
throw new TypeError("Власник рахунку є обов'язковим");
}
if (!Number.isFinite(initialBalance) || initialBalance < 0) {
throw new RangeError("Початковий баланс має бути невід'ємним числом");
}
this.owner = owner;
this.#balance = 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;
}
#validateAmount(amount) {
if (!Number.isFinite(amount) || amount <= 0) {
throw new RangeError("Сума має бути додатним числом");
}
}
}
const account = new BankAccount("Олена", 1000);
account.deposit(250);
account.withdraw(100);
console.log(account.balance); // 1150Поле #balance недоступне за межами класу:
// Помилка синтаксису:
// account.#balance = 0;Важливо, що приватність #balance реалізована на рівні мови. Це не просто угода на кшталт _balance, яка лише сигналізує іншим розробникам: «не змінюйте це поле напряму».
Гетери й сетери дозволяють контролювати читання та зміну властивостей.
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.toLowerCase().trim();
}
}
const user = new User("USER@EXAMPLE.COM");
console.log(user.email); // user@example.com
user.email = "new@example.com";
console.log(user.email); // new@example.comГетер виглядає як звичайна властивість:
console.log(user.email);Але фактично під час читання виконується метод get email().
Гетер не повинен мати неочікуваних побічних ефектів. Його призначення — прочитати або обчислити значення, а не виконувати складну бізнес-операцію.
Інкапсуляція не вимагає зробити всі поля приватними. Потрібно розділяти:
дані, які є внутрішньою деталлю;
дані, які є частиною публічного API;
операції, які дозволено виконувати зовнішньому коду.
Наприклад, об'єкт замовлення може дозволяти додавати товари через метод addItem(), але не надавати можливість довільно замінювати внутрішній масив товарів.
class Order {
#items = [];
addItem(product, quantity) {
if (!product || quantity <= 0) {
throw new Error("Некоректний товар або кількість");
}
this.#items.push({ product, quantity });
}
get items() {
// Повертаємо копію, щоб зовнішній код не змінив внутрішній масив
return this.#items.map((item) => ({ ...item }));
}
}Якщо повернути безпосередньо this.#items, зовнішній код зможе змінити внутрішній стан:
// Небезпечний варіант:
// get items() {
// return this.#items;
// }Наслідування дозволяє створити новий клас на основі іншого класу.
базовий клас містить спільну поведінку;
похідний клас повторно використовує цю поведінку;
похідний клас може додати або змінити окремі можливості.
Для наслідування використовується extends, а для виклику конструктора базового класу — super().
class Payment {
constructor(amount, currency) {
if (!Number.isFinite(amount) || amount <= 0) {
throw new RangeError("Сума платежу має бути додатним числом");
}
this.amount = amount;
this.currency = currency;
}
getDescription() {
return `Платіж на ${this.amount} ${this.currency}`;
}
}
class CardPayment extends Payment {
constructor(amount, currency, cardNumber) {
super(amount, currency);
this.cardNumber = cardNumber;
}
getDescription() {
return `Оплата карткою на ${this.amount} ${this.currency}`;
}
}
const payment = new CardPayment(500, "UAH", "**** 1234");
console.log(payment.getDescription());
// Оплата карткою на 500 UAHsuperУ конструкторі похідного класу не можна використовувати this до виклику super():
class Child extends Parent {
constructor() {
// this.value = 10; // Помилка
super();
}
}super() створює й ініціалізує частину об'єкта, яка належить базовому класу.
Через super.method() можна викликати метод базового класу:
class LoggedPayment extends Payment {
getDescription() {
const description = super.getDescription();
return `[LOG] ${description}`;
}
}Класи JavaScript не створюють окремий механізм наслідування. Вони використовують прототипний ланцюжок.
class Animal {
speak() {
return "Тварина видає звук";
}
}
class Dog extends Animal {
bark() {
return "Гав";
}
}
const dog = new Dog();
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(dog.speak()); // Тварина видає звук
console.log(dog.bark()); // ГавЛогічно ланцюжок виглядає приблизно так:
dog
-> Dog.prototype
-> Animal.prototype
-> Object.prototype
-> nullМетоди класу зазвичай зберігаються в його prototype, а не копіюються в кожен екземпляр. Це дозволяє економніше використовувати пам'ять.
Поліморфізм означає, що різні об'єкти можуть мати однаковий інтерфейс, але різну реалізацію.
Код, який використовує об'єкти, працює з їхнім спільним контрактом і не залежить від конкретного класу.
class CardPayment extends Payment {
process() {
return `Оброблено оплату карткою: ${this.amount} ${this.currency}`;
}
}
class PayPalPayment extends Payment {
process() {
return `Оброблено оплату через PayPal: ${this.amount} ${this.currency}`;
}
}
class CashPayment extends Payment {
process() {
return `Прийнято готівку: ${this.amount} ${this.currency}`;
}
}
function processPayments(payments) {
return payments.map((payment) => payment.process());
}
const payments = [
new CardPayment(100, "UAH", "**** 1234"),
new PayPalPayment(250, "UAH", "customer@example.com"),
new CashPayment(50, "UAH"),
];
console.log(processPayments(payments));Функція processPayments() не перевіряє:
if (payment instanceof CardPayment) {
// ...
} else if (payment instanceof PayPalPayment) {
// ...
}Вона очікує лише, що кожен об'єкт має метод process().
Це називають структурним поліморфізмом або підходом «duck typing»:
Якщо об'єкт має потрібну поведінку, його можна використовувати незалежно від конкретного класу.
У JavaScript це особливо природний підхід, оскільки мова динамічно типізована.
Похідний клас може перевизначити метод базового класу:
class Notification {
send(message) {
return `Надсилання повідомлення: ${message}`;
}
}
class EmailNotification extends Notification {
send(message) {
return `Надсилання email: ${message}`;
}
}
class SmsNotification extends Notification {
send(message) {
return `Надсилання SMS: ${message}`;
}
}
function notify(notification, message) {
console.log(notification.send(message));
}
notify(new EmailNotification(), "Замовлення створено");
notify(new SmsNotification(), "Код підтвердження: 1234");Одна функція notify() працює з різними реалізаціями.
Абстракція — це виділення суттєвих властивостей і приховування зайвих деталей реалізації.
Абстракція відповідає на питання:
що об'єкт уміє робити;
які операції доступні зовнішньому коду;
які деталі не потрібно знати користувачу класу.
Наприклад, платіжний сервіс може знати, що платіж має метод process(). Йому не обов'язково знати, як саме працює картковий шлюз, PayPal або готівковий розрахунок.
У JavaScript немає окремого ключового слова abstract, як у TypeScript або деяких інших мовах. Але абстрактну поведінку можна змоделювати вручну.
class PaymentMethod {
constructor(amount) {
if (new.target === PaymentMethod) {
throw new TypeError(
"PaymentMethod не можна створювати напряму"
);
}
if (!Number.isFinite(amount) || amount <= 0) {
throw new RangeError("Сума має бути додатним числом");
}
this.amount = amount;
}
process() {
throw new Error("Похідний клас має реалізувати process()");
}
getFee() {
throw new Error("Похідний клас має реалізувати getFee()");
}
getTotal() {
return this.amount + this.getFee();
}
}
class CardPayment extends PaymentMethod {
constructor(amount, cardNumber) {
super(amount);
this.cardNumber = cardNumber;
}
process() {
return `Оплату карткою на ${this.amount} UAH оброблено`;
}
getFee() {
return this.amount * 0.02;
}
}
class BankTransfer extends PaymentMethod {
process() {
return `Банківський переказ на ${this.amount} UAH створено`;
}
getFee() {
return 5;
}
}
const methods = [
new CardPayment(1000, "**** 1234"),
new BankTransfer(1000),
];
for (const method of methods) {
console.log(method.process());
console.log(`До сплати: ${method.getTotal()} UAH`);
}
// Помилка:
// new PaymentMethod(100);У цьому прикладі:
PaymentMethod задає спільний контракт;
process() і getFee() мають бути реалізовані похідними класами;
getTotal() містить спільну реалізацію;
зовнішній код працює з будь-яким об'єктом, який відповідає контракту.
Перевірка new.target показує, який конструктор безпосередньо викликався. Якщо це сам PaymentMethod, створення об'єкта забороняється. Якщо конструктор викликано через super(), new.target містить похідний клас.
Іноді достатньо не створювати спеціальний базовий клас, а домовитися про спільний інтерфейс:
function renderUsers(users, formatter) {
return users.map((user) => formatter.format(user));
}
const jsonFormatter = {
format(user) {
return JSON.stringify(user);
},
};
const textFormatter = {
format(user) {
return `${user.name} <${user.email}>`;
},
};
const users = [
{ name: "Олена", email: "olena@example.com" },
{ name: "Андрій", email: "andrii@example.com" },
];
console.log(renderUsers(users, jsonFormatter));
console.log(renderUsers(users, textFormatter));Тут renderUsers() не знає, яким є тип formatter. Йому потрібен лише метод format().
Це часто простіше та гнучкіше, ніж створення великої ієрархії класів.
Нижче наведено приклад, який поєднує всі чотири принципи.
class PaymentMethod {
#currency;
constructor(amount, currency = "UAH") {
if (new.target === PaymentMethod) {
throw new TypeError("PaymentMethod є абстрактним класом");
}
if (!Number.isFinite(amount) || amount <= 0) {
throw new RangeError("Сума має бути додатним числом");
}
if (typeof currency !== "string" || currency.length !== 3) {
throw new TypeError("Валюта має бути рядком із трьох символів");
}
this.amount = amount;
this.#currency = currency.toUpperCase();
}
get currency() {
return this.#currency;
}
get formattedAmount() {
return `${this.amount.toFixed(2)} ${this.currency}`;
}
process() {
throw new Error("Метод process() має бути реалізований");
}
getFee() {
throw new Error("Метод getFee() має бути реалізований");
}
getTotal() {
return this.amount + this.getFee();
}
}
class CardPayment extends PaymentMethod {
#lastFourDigits;
constructor(amount, cardNumber, currency = "UAH") {
super(amount, currency);
if (!/^\d{16}$/.test(cardNumber)) {
throw new TypeError("Номер картки має містити 16 цифр");
}
this.#lastFourDigits = cardNumber.slice(-4);
}
process() {
return `Оплату карткою **** ${this.#lastFourDigits} оброблено`;
}
getFee() {
return this.amount * 0.02;
}
}
class BankTransfer extends PaymentMethod {
process() {
return "Банківський переказ створено";
}
getFee() {
return 10;
}
}
class PaymentService {
#payments = [];
addPayment(payment) {
if (!(payment instanceof PaymentMethod)) {
throw new TypeError("Очікується платіжний метод");
}
this.#payments.push(payment);
}
processAll() {
return this.#payments.map((payment) => ({
message: payment.process(),
amount: payment.formattedAmount,
fee: payment.getFee().toFixed(2),
total: payment.getTotal().toFixed(2),
}));
}
}
const service = new PaymentService();
service.addPayment(
new CardPayment(1000, "1234567890123456")
);
service.addPayment(
new BankTransfer(500)
);
console.log(service.processAll());Інкапсуляція:
#currency, #lastFourDigits і #payments приховані;
змінювати внутрішній стан можна лише через методи класів;
номер картки не зберігається у відкритому вигляді для зовнішнього коду.
Наслідування:
CardPayment і BankTransfer наслідують PaymentMethod;
спільна перевірка суми та валюти знаходиться в базовому класі.
Поліморфізм:
PaymentService викликає process() і getFee();
кожен тип платежу реалізує ці методи по-своєму.
Абстракція:
PaymentMethod визначає загальний контракт платежу;
PaymentService не знає деталей обробки картки чи банківського переказу.
Наслідування не завжди є найкращим способом повторного використання коду.
Наслідування описує відношення:
CardPaymentєPaymentMethod.
Композиція описує відношення:
PaymentServiceмає платіжний шлюз.
Приклад композиції:
class PaymentService {
constructor(gateway) {
if (!gateway || typeof gateway.charge !== "function") {
throw new TypeError("Шлюз має реалізувати метод charge()");
}
this.gateway = gateway;
}
pay(amount) {
return this.gateway.charge(amount);
}
}
const stripeLikeGateway = {
charge(amount) {
return `Знято ${amount} UAH через платіжний шлюз`;
},
};
const service = new PaymentService(stripeLikeGateway);
console.log(service.pay(250));Композиція дозволяє підмінити залежність без створення нового підкласу:
const testGateway = {
charge(amount) {
return `Тестова оплата на ${amount} UAH`;
},
};
const testService = new PaymentService(testGateway);
console.log(testService.pay(250));Переваги композиції:
менша зв'язаність;
простіше тестування;
можливість динамічно замінювати залежності;
відсутність глибоких ієрархій.
Практичне правило:
Використовуйте наслідування для стабільного відношення «є різновидом», а композицію — для підключення змінної поведінки або залежностей.
Статичні методи належать самому класу, а не його екземплярам.
class Currency {
static supported = new Set(["UAH", "USD", "EUR"]);
static isSupported(code) {
return this.supported.has(code);
}
}
console.log(Currency.isSupported("UAH")); // true
console.log(Currency.isSupported("GBP")); // falseВикликати isSupported() через екземпляр не можна:
// const currency = new Currency();
// currency.isSupported("UAH"); // TypeErrorСтатичні методи доречні, коли операція:
не залежить від стану конкретного екземпляра;
є фабрикою;
виконує валідацію або допоміжне перетворення.
Приклад статичної фабрики:
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
static fromJSON(json) {
const data = JSON.parse(json);
return new User(data.name, data.email);
}
}
const user = User.fromJSON(
'{"name":"Олена","email":"olena@example.com"}'
);
console.log(user.name);Екземпляри класів порівнюються за посиланням:
class Product {
constructor(id) {
this.id = id;
}
}
const first = new Product(1);
const second = new Product(1);
console.log(first === second); // falseОб'єкти мають однаковий id, але це різні екземпляри.
Якщо бізнес-логіка вимагає порівнювати об'єкти за ідентифікатором, потрібно реалізувати це явно:
class Product {
constructor(id) {
this.id = id;
}
equals(other) {
return other instanceof Product && this.id === other.id;
}
}
const first = new Product(1);
const second = new Product(1);
console.log(first.equals(second)); // trueГлибока ієрархія класів ускладнює розуміння коду:
Entity
-> User
-> Admin
-> SuperAdmin
-> RootAdminЗміна базового класу може неочікувано вплинути на всі похідні класи. Часто краще винести окремі можливості в компоненти або сервіси та використати композицію.
Небезпечний варіант:
class Cart {
constructor() {
this.items = [];
}
}
const cart = new Cart();
cart.items.push({ name: "Книга", quantity: -100 });Якщо стан має правила зміни, захистіть його приватним полем і надайте методи з валідацією.
class Example {
_value = 0;
}_value не є приватним. Його можна змінити ззовні:
const example = new Example();
example._value = 100;Символ _ — лише домовленість. Для справжнього приватного поля використовуйте #value.
super() у похідному конструкторіclass Child extends Parent {
constructor() {
// Помилка: this використовується до super()
this.value = 1;
super();
}
}Спочатку викличте super(), а вже потім працюйте з this.
Надмірне використання instanceof робить код залежним від конкретної ієрархії:
function process(payment) {
if (payment instanceof CardPayment) {
// ...
} else if (payment instanceof BankTransfer) {
// ...
}
}Краще викликати спільний метод:
function process(payment) {
return payment.process();
}Так код підтримує нові реалізації без зміни функції.
thisМетоди класу не прив'язуються до екземпляра автоматично:
class Counter {
value = 0;
increment() {
this.value += 1;
}
}
const counter = new Counter();
const increment = counter.increment;
// increment(); // TypeError або некоректний thisВаріанти виправлення:
const increment = counter.increment.bind(counter);
increment();Або використання стрілкової функції як поля класу:
class Counter {
value = 0;
increment = () => {
this.value += 1;
};
}Стрілкова функція зберігає this, але кожен екземпляр отримує власну функцію. Для великої кількості об'єктів звичайний метод на прототипі може бути економнішим.
Гетер виглядає як проста властивість:
object.value;Тому не варто приховувати за ним:
мережеві запити;
складні обчислення;
зміну стану;
операції з побічними ефектами.
Для таких дій краще використовувати явний метод, наприклад loadData() або calculateTotal().
Не кожен об'єкт потребує класу. Якщо немає:
власного стану;
інваріантів;
кількох екземплярів;
спільної поведінки;
чіткої моделі предметної області,
звичайна функція або об'єкт може бути простішим рішенням.
ООП — це інструмент, а не вимога використовувати класи всюди.
Інкапсуляція приховує внутрішній стан і контролює доступ до нього.
Приватні поля JavaScript позначаються через #.
Наслідування створює похідні класи за допомогою extends.
super() викликає конструктор або метод базового класу.
Поліморфізм дозволяє працювати з різними об'єктами через спільний інтерфейс.
Абстракцію можна реалізувати через базовий клас із методами-контрактами.
JavaScript не має вбудованого ключового слова abstract, але абстрактні класи можна змоделювати через new.target і методи, що викидають помилки.
Статичні члени належать класу, а не його екземплярам.
Наслідування підходить для відношення «є різновидом», а композиція — для підключення поведінки та залежностей.
Надійний ООП-код приховує деталі реалізації, має невеликі інтерфейси та не залежить від конкретних типів там, де достатньо спільної поведінки.