Так сталось у світі JavaScript’у, що зручніший синтаксис класів з`явився, відносно інших мов, не так давно, а саме в js класовий синтаксис з`явився орієнтовно в червні 2015 року з виходом EcmaScript 6 (ES6) і якщо порівнювати, наприклад, з PHP у якому ООП було введене з виходом 5 версії мови, це в нас 2004 рік, то відразу стане зрозуміло, що JS трохи пасе задніх. Це все тому, що справді популярним JS став в 2010-х роках.
Сьогодні ми поговоримо саме про механізм спадкування в JS і як воно працює, а також розглянемо інші механізми на прикладі інших мов.
ЩО ТАКЕ СПАДКУВАННЯ
Спадкування — одна з основних концепцій об’єктно-орієнтованого програмування, в рамках якої деякий тип даних (клас або об’єкт) може успадковувати властивості й методи іншого, що дозволяє значно зменшити дублювання коду й ефективно перевикористовувати його. У JavaScript немає класів у традиційному розумінні, тому наслідування базується на об'єктах і їхніх прототипах. Проте, з введенням синтаксису класів в ES6, було додано зручний спосіб опису успадкування, що дає можливість працювати з прототипами у вигляді, подібному до класів в інших мовах програмування. Однак, класи в JavaScript — це синтаксичний цукор, що спрощує роботу з прототипним наслідуванням.
Синтаксичний цукор — зручний синтаксис, який робить код простіше й більш читабельним, але не додає нової функціональності.
НАВІЩО ПОТРІБНЕ СПАДКУВАННЯ?
Повторне використання коду (Code Reusability)
Проблема:
- Уявімо, що у вас є кілька класів з подібною логікою.
- Копіювання одних і тих же методів у кожен клас робить код громіздким і важким для підтримки.
Рішення:
- Спільний функціонал можна винести в базовий (батьківський) клас.
- Дочірні класи можуть успадковувати цей функціонал і розширювати його.
Приклад на php
<?php
class Animal {
public function eat() {
echo "This animal is eating.";
}
}
class Dog extends Animal {
public function bark() {
echo "Dog barks.";
}
}
$dog = new Dog();
$dog->eat(); // Спадкував метод eat() від Animal
$dog->bark();
?>
Полегшення масштабування (Scalability)
Проблема:
- Проєкт розширюється, додаються нові вимоги й функції.
- Без спадкування потрібно дублювати логіку у багатьох місцях.
Рішення:
- Легко додати новий клас, який буде успадковувати логіку батьківського класу і додавати власні методи.
Приклад на JavaScript
class Vehicle {
startEngine() {
console.log('Engine started');
}
}
class Car extends Vehicle {
drive() {
console.log('Car is driving');
}
}
class Motorcycle extends Vehicle {
wheelie() {
console.log('Motorcycle is doing a wheelie');
}
}
const car = new Car();
car.startEngine(); // Engine started
car.drive(); // Car is driving
const bike = new Motorcycle();
bike.startEngine(); // Engine started
bike.wheelie(); // Motorcycle is doing a wheelie
Поліморфізм (Polymorphism)
Проблема:
- Ви хочете працювати з різними об'єктами однаковим чином, навіть якщо вони мають різну реалізацію методів.
Рішення:
- Дочірні класи можуть перевизначати (override) методи батьківського класу.
Приклад на php
<?php
class Animal {
public function speak() {
echo "Animal makes a sound.";
}
}
class Dog extends Animal {
public function speak() {
echo "Dog barks.";
}
}
class Cat extends Animal {
public function speak() {
echo "Cat meows.";
}
}
function makeSound(Animal $animal) {
$animal->speak();
}
makeSound(new Dog()); // Dog barks.
makeSound(new Cat()); // Cat meows.
?>
Зменшення залежностей (Decoupling)
Проблема:
- Занадто тісні зв'язки між класами роблять код крихким і важким для змін.
Рішення:
- Використання абстрактних класів або інтерфейсів дозволяє створити менш залежні компоненти.
Приклад (JavaScript з інтерфейсами через TypeScript):
interface Shape {
area(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
area(): number {
return this.width * this.height;
}
}
class Circle implements Shape {
constructor(private radius: number) {}
area(): number {
return Math.PI * this.radius * this.radius;
}
}
function printArea(shape: Shape) {
console.log(shape.area());
}
printArea(new Rectangle(5, 10)); // 50
printArea(new Circle(7)); // 153.94
Покращення читабельності й організації коду
Проблема:
- Великий код без чіткої структури важко зрозуміти й підтримувати.
Рішення:
- Створення ієрархії класів забезпечує чітку структуру і зрозумілу архітектуру.
Animal
├── Mammal
│ ├── Dog
│ ├── Cat
├── Bird
├── Sparrow
├── Eagle
Результат:
- Легше зрозуміти зв'язки між класами.
- Зменшення кількості дубльованого коду.
Принцип DRY (Don't Repeat Yourself)
- Спадкування дозволяє слідувати принципу DRY — не дублювати код.
- Замість копіювання однакових методів у різні класи, можна успадковувати їх від спільного базового класу.
ВИДИ СПАДКУВАННЯ
Я виділю 2 основних види спадкування, а саме прототипне та класове, про які розповім у деталях.
Прототипне наслідування (Prototype-based inheritance)
Це базовий підхід, де об’єкти напряму успадковують методи та властивості від інших об’єктів через їхні прототипи. При такому підході можна використовувати кілька методів для задання прототипів:
Метод 1: Object.create()
Метод Object.create (proto[, propertiesObject]) створює новий об’єкт з вказаним прототипом. Він приймає два параметри:
- proto — об'єкт, який буде прототипом для створеного об'єкта, може бути будь-яким об'єктом або null.
- propertiesObject (не обов'язковий) — об'єкт, що містить додаткові властивості та їхні дескриптори. Наприклад, значення і методи, які будуть додані до нового об'єкта.
Приклад на JavaScript
// Створюємо базовий об'єкт
const animal = {
speak() {
console.log('Animal makes a sound');
}
};
// Створюємо новий об'єкт, успадковуючи від `animal`
const dog = Object.create(animal);
dog.speak(); // "Animal makes a sound"
// Можна додавати властивості та методи до нового об'єкта
dog.bark = function() {
console.log('Dog barks');
};
dog.bark(); // "Dog barks"
Метод 2: __Proto__
Це властивість доступу (гетер та сетер) для внутрішньої властивості [[Prototype]] об'єкта. Вона дозволяє змінювати або отримувати прототип об'єкта, що уможливлює прототипне успадкування в JavaScript. Проте, використання __Proto__ вважається застарілим і не рекомендується до користування, через ряд причин, а саме:
- зміни прототипа може порушувати оптимізацію движків JS;
- змінює внутрішню структуру об’єкта, що ускладнює розуміння коду;
- непередбачувана підтримка в різних середовищах і браузерах
Тож, використовувати __proto__ слід з обережністю, рекомендується використовувати інші методи, такі як Object.create() або Object.setPrototypeOf().
Приклад на JavaScript
// Створюємо об'єкт 'animal'
const animal = {
speak() {
console.log('Animal makes a sound');
}
};
// Створюємо об'єкт `dog`
const dog = {
bark() {
console.log('Dog barks');
}
};
dog.__proto__ = animal; // Встановлюємо animal, як прототип для об'єкта `dog`
// Тепер об'єкт `dog` може викликати метод з прототипу і свій власний
dog.speak(); // "Animal makes a sound"
dog.bark(); // "Dog barks"
Метод 3: Object.getPrototypeOf() та Object.setPrototypeOf()
- Метод Object.getPrototypeOf(obj) дозволяє отримати прототип (батьківський об’єкт) для заданого у параметрі об’єкта. Це може бути корисно для перевірки, від якого об’єкта успадковується конкретний об’єкт.
- Метод Object.setPrototypeOf(obj, prototype) дає можливість змінювати прототип об’єкта й динамічно налаштовувати успадкування.
Але хочу зазначити, що зміни у прототипах — можуть негативно впливати на продуктивність, оскільки движки JS оптимізують прототипи під час їхнього створення, тож треба використовувати цей метод з обережністю.
Приклад на JavaScript
// Створюємо базовий об'єкт
const animal = {
speak() {
console.log('Animal makes a sound');
}
};
// Створюємо новий об'єкт
const dog = {
bark() {
console.log('Dog barks');
}
};
// Перевіряємо прототип об'єкта `dog`
console.log(Object.getPrototypeOf(dog) === animal); // false
// Встановлюємо прототип для об'єкта `dog`
Object.setPrototypeOf(dog, animal);
// Тепер `dog` успадковує методи від `animal`
dog.speak(); // "Animal makes a sound"
dog.bark(); // "Dog barks"
console.log(Object.getPrototypeOf(dog) === animal); // true
Метод 4: Функція-конструктор
Функція-конструктор — це функція, яка використовується для створення нових об'єктів. Коли ми викликаємо функцію-конструктор з оператором new, створюється новий порожній об'єкт. Цей об'єкт передається в конструктор як this, і всередині конструктора додаються властивості й методи. Через спеціальну властивість F.prototype у функції зберігаються методи і властивості, які будуть доступні всім об'єктам, створеним через цей конструктор.
Приклад на JavaScript
unction Car(brand, model, year) {
this.brand = brand; // властивість марка
this.model = model; // властивість модель
this.year = year; // властивість рік випуску
}
// Додаємо метод до всіх об'єктів, створених через Car
Car.prototype.displayInfo = function() {
console.log(`This is a ${this.year} ${this.brand} ${this.model}`);
};
// Створюємо нові об'єкти за допомогою конструктора
const car1 = new Car('Toyota', 'Corolla', 2020);
const car2 = new Car('Honda', 'Civic', 2019);
// Викликаємо метод для кожного об'єкта
car1.displayInfo(); // "This is a 2020 Toyota Corolla"
car2.displayInfo(); // "This is a 2019 Honda Civic"
Класове спадкування (Class-based inheritance)
Класове спадкування — це підхід, де об'єкти створюються на основі чітко визначених класів. Клас виступає як шаблон, що описує властивості й методи для створення об'єктів. У цьому підході дочірній клас успадковує властивості й методи батьківського класу через ключове слово, таке як extends. Важливою особливістю є те, що структура та ієрархія класів зазвичай фіксуються під час оголошення і мають статичну природу. Наприклад, клас «Тварина» може мати метод «їсти», а клас «Собака», що успадковує його, матиме доступ до цього методу, але також може додати специфічний метод «гавкати». У класовому спадкуванні головний акцент робиться на чіткості структури й передбачуваності ієрархії, де об'єкти є екземплярами класів.
Прототипне спадкування, на відміну від класового, працює безпосередньо з об'єктами, а не з класами. Об'єкти можуть наслідувати властивості й методи один від одного через прототипи. Кожен об'єкт має приховану властивість, що вказує на його прототип, і якщо об'єкт не має певної властивості або методу, пошук продовжується в його прототипі, і так далі по ланцюжку прототипів. У цьому підході немає чіткої ієрархії класів — об'єкти можуть динамічно змінювати свої прототипи під час виконання програми, а також розширювати або змінювати їх. Наприклад, об'єкт «собака» може наслідувати метод «їсти» від об'єкта «тварина» без використання класів.
Різниця
Отже, ключова різниця полягає в тому, що класове спадкування базується на фіксованій ієрархії класів із чітко визначеними ролями, тоді як прототипне спадкування гнучкіше й дозволяє динамічно налаштовувати об'єкти й змінювати їхню поведінку під час виконання програми.
Основні елементи класів:
Конструктор (constructor) — метод, який викликається при створенні нового екземпляра класу за допомогою ключового слова new. Він ініціалізує властивості об'єкта.
Методи — це функції, які визначають поведінку об'єктів, створених класом. Методи можуть бути спільними для всіх екземплярів класу або можуть бути перепризначені дочірніми класами.
Успадкування — можливість створення нових класів на основі існуючих за допомогою ключового слова extends. Це дозволяє класу-нащадку отримувати властивості і методи батьківського класу, перевизначати їх або додавати нові. Для доступу до конструктора й методів батьківського класу використовується ключове слово super(), яке викликає конструктор батьківського класу і дозволяє нащадковому класу успадковувати його властивості й методи.
Приклад використання JavaScript:
class Animal {
constructor(name) {
this.name = name; // властивість для зберігання імені тварини
}
speak() {
console.log(`${this.name} makes a sound`); // стандартний метод для всіх тварин
}
}
class Dog extends Animal {
constructor(name, breed) {
super(name); // викликає конструктор батьківського класу
this.breed = breed; // властивість breed, специфічна для класуDog
}
speak() {
console.log(`${this.name} barks`); // переопреділений метод speak для собак
}
}
const dog = new Dog('Rex', 'German Shepherd');
dog.speak(); // "Rex barks"
ДОДАТКОВІ МОЖЛИВОСТІ Й КОНЦЕПЦІЇ
Поліморфізм: дозволяє об'єктам різних класів мати однакові методи, але з різною реалізацією. Наприклад, метод speak() у класі Dog та в класі Cat можуть виконувати різні дії, але мати однакове ім'я:
Приклад на JavaScript
class Animal {
speak() {
console.log('Animal makes a sound');
}
}
class Dog extends Animal {
speak() {
console.log('Dog barks');
}
}
class Cat extends Animal {
speak() {
console.log('Cat meows');
}
}
const dog = new Dog();
const cat = new Cat();
dog.speak(); // Dog barks
cat.speak(); // Cat meows
Інкапсуляція: захищає дані всередині класу і контролювати доступ до них. Замість того, щоб прямо звертатися до властивостей класу, часто використовуються геттери і сеттери:
Приклад на JavaScript
class Person {
constructor(name, age) {
this._name = name;
this._age = age;
}
get name() {
return this._name;
}
set name(value) {
this._name = value;
}
}
const person = new Person('Alice', 30);
console.log(person.name); // Alice
person.name = 'Bob';
console.log(person.name); // Bob
Приватні поля: властивості класу, які доступні лише всередині самого класу, а не ззовні. Вони позначаються через #. Тут поле #balance є приватним і доступне тільки через методи класу, такі як deposit та getBalance.
Приклад на JavaScript
class Account {
#balance; // Приватне поле
constructor(initialBalance) {
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
}
getBalance() {
return this.#balance;
}
}
const myAccount = new Account(100);
myAccount.deposit(50);
console.log(myAccount.getBalance()); // 150
// console.log(myAccount.#balance); // Помилка: доступ до приватного поля заборонений
Статичні методи та властивості: належать самому класу, а не екземплярам цього класу. Вони використовуються для операцій або даних, що не залежать від конкретного об'єкта. Метод add є статичним, тому до нього можна звертатися без створення екземпляра класу.
Приклад на JavaScript
static add(a, b) {
return a + b; // Статичний метод
}
}
console.log(Calculator.add(2, 3)); // 5
Прототипний ланцюг
У JavaScript кожен об'єкт має приховану властивість [[Prototype]], яка є посиланням на інший об'єкт або null. Коли об'єкт не має певної властивості чи методу, JS автоматично звертається до його прототипу. Якщо і в прототипі немає цієї властивості, пошук продовжується в прототипі прототипу, і так далі, поки не буде знайдена властивість або ланцюг не завершиться (коли прототип стане null).
Приклад:
Об'єкт admin має прототип user, через який успадковує методи та властивості. Прототип user має прототип Object.prototype, який надає загальні методи, такі як toString() та hasOwnProperty(). Ланцюг закінчується на null, що означає кінець пошуку властивостей та методів.
Ланцюг виглядає так:
Примітка
У реальних проєктах зазвичай створюються окремі файли для кожного класу, і ці файли імпортуються за потребою. Важливою практикою є використання аперкейсу (великої літери) для іменування файлів, що містять класи, наприклад, Dog.js. Це дає чітке позначення того, що файл містить клас, і полегшує підтримку коду.
Після цього можна імпортувати клас у потрібний файл і створювати його екземпляри через new Class(). Це правило є важливим для підтримки чистоти коду та зручності роботи в команді, навіть якщо здається, що це дрібниця.
ПОРІВНЯННЯ КЛАСОВОГО ТА ПРОТОТИПНОГО НАСЛІДУВАННЯ
Прототипне спадкування:
Переваги:
- Гнучкість: прототипне наслідування дозволяє змінювати об'єкти на льоту, додаючи чи змінюючи їх властивості й методи в будь-який момент виконання програми, це може бути корисно, якщо треба швидко адаптувати поведінку об’єкта.
- Менше коду: можна безпосередньо створювати об'єкти, що успадковують поведінку від інших, що спрощує код і робить його більш лаконічним.
- Природність для JavaScript: цей підхід інтегрований в мову і є природним для її синтаксису.
Вразливості:
- Менш очевидна ієрархія: для розробників, знайомих із класичним ООП, ієрархія прототипів може бути менш зрозумілою, так як ієрархія не така очевидна як у класах.
- Складність в управлінні: коли структура прототипів стає дуже складною, зрозуміти звідки приходять методи та властивості, може бути доволі важко.
- Ризик конфліктів: оскільки прототипи можна змінювати на ходу, це може призвести до непередбачуваних результатів і конфліктів в поведінці об'єктів.
Класове спадкування:
Переваги:
- Зрозумілий і організований синтаксис: синтаксис класів знайомий багатьом програмістам, це може бути перевагою, коли над проєктом працюють розробники різних мов програмування.
- Чітка ієрархія: Ключові слова extends і super дозволяють створювати чітку ієрархію класів, що підвищує читабельність й організацію коду.
- Підтримка ООП концепцій: Класове наслідування забезпечує інкапсуляцію, успадкування та поліморфізм, що робить код більш структурованим і підтримуваним, в довгостроковій перспективі це дуже важливо.
Вразливості:
- Надмірний синтаксис: для простих задач створення класів і конструкторів може бути зайвим, оскільки це додає додаткову складність та значно збільшує кількість файлів у проєкті.
- Менш гнучке: обмежує можливості динамічного змінювання структури класу, на відміну від прототипного наслідування.
- Підвищена складність для маленьких проектів: у невеликих проектах може бути надмірно складним, коли можна обійтися простішими підходами.
ВИСНОВОК
У JavaScript немає справжнього класового спадкування. Класи, додані в ES6 (2015), — це синтаксичний цукор над прототипним спадкуванням. Під капотом уся логіка спадкування в JavaScript базується на ланцюжку прототипів, де кожен об'єкт може успадковувати властивості й методи від іншого об'єкта через свій прототип.
Синтаксис class і ключове слово extends надають розробникам більш звичний і структурований спосіб опису спадкування, подібний до інших об'єктно-орієнтованих мов, таких як Java чи PHP. Однак це не змінює фундаментального механізму спадкування в JavaScript, який залишається прототипним.
Різниця між класовим і прототипним спадкуванням лише у способі оголошення та сприйняття коду. У реальності, навіть коли ми використовуємо class у JavaScript, ми все одно працюємо з прототипами. Розуміння цього дозволяє краще контролювати спадкування й уникати поширених помилок у роботі з об'єктами та їхніми прототипами.