Фото Goran Ivos на Unsplash
Класи є основою об'єктно-орієнтованого програмування (OOP) і широко використовуються в TypeScript. Однак вони не завжди є найкращим вибором для кожного сценарію. Ця стаття розглядає відмінності між класами та їх альтернативами в TypeScript, наводячи детальні приклади, переваги, недоліки та варіанти використання кожного з них.
1. Класи в TypeScript 🔥
Класи в TypeScript є шаблонами для створення об'єктів. Вони вносять концепції об'єктно-орієнтованого програмування (OOP) до JavaScript, такі як наслідування, інкапсуляція та поліморфізм. Класи дозволяють розробникам визначати властивості та методи, а також підтримують функції, як-от модифікатори доступу (private
, protected
, public
), статичні члени та конструктори.
Приклад:
class Animal {
// Protected: Доступно всередині класу та підкласів (наприклад, Dog)
protected name: string;
constructor(name: string) {
this.name = name;
}
// Public: Доступно звідусіль
// Метод, який можна перевизначити
public makeSound(): void {
console.log(`${this.name} makes a sound.`);
}
// Public: Доступно звідусіль
public move(): void {
console.log(`${this.name} is moving.`);
}
// Private: Доступно тільки в межах класу Animal
private rest(): void {
console.log(`${this.name} is resting.`);
}
// Public: Надає доступ до приватного методу `rest`
public takeRest(): void {
this.rest();
}
}
// Підклас Dog
class Dog extends Animal {
constructor(name: string) {
super(name); // Виклик конструктора батьківського класу
}
// Public: Перевизначення методу makeSound
public override makeSound(): void {
console.log(`${this.name} barks.`);
}
// Public: Доступ до захищеного члена
public fetch(): void {
console.log(`${this.name} is fetching a ball.`);
}
}
// Приклад використання
const genericAnimal = new Animal("Generic Animal");
const dog = new Dog("Buddy");
genericAnimal.makeSound(); // Output: Generic Animal makes a sound.
genericAnimal.move(); // Output: Generic Animal is moving.
genericAnimal.takeRest(); // Output: Generic Animal is resting.
// Неприпустимий доступ до приватних або захищених членів
// genericAnimal.rest(); // Error: Property 'rest' is private
// genericAnimal.name; // Error: Property 'name' is protected
dog.makeSound(); // Output: Buddy barks.
dog.move(); // Output: Buddy is moving.
dog.fetch(); // Output: Buddy is fetching a ball.
// Неприпустимий доступ до захищених членів
// dog.name; // Error: Property 'name' is protected
Класи добре підходять для сценаріїв, де потрібно моделювати сутності з станом, поведінкою та взаємозв'язками (наслідуванням або поліморфізмом). Однак вони можуть вводити накладні витрати і не завжди відповідають парадигмам функціонального програмування.
Переваги класів 👍
- Інкапсуляція: Дані та поведінка об'єднані разом, що робить код більш модульним і зручним для управління. Крім того, ви можете контролювати доступ до внутрішніх деталей через модифікатори доступу (
private
,protected
,public
), що гарантує захист стану та поведінки класу. - Типова безпека: Вбудована підтримка сильного типізування в TypeScript допомагає виявляти помилки на етапі компіляції. Це забезпечує створення об'єктів з правильними типами, знижуючи ймовірність помилок під час виконання.
- Наслідування: Спрощує повторне використання коду та розширення функціональності. Підклас може успадковувати властивості та методи від батьківського класу, що зменшує надмірність та сприяє повторному використанню.
- Поліморфізм: Класи дозволяють поліморфізм, коли метод у підкласі може перевизначити метод у батьківському класі. Це дозволяє реалізувати динамічну поведінку на основі фактичного типу об'єкта під час виконання, підвищуючи гнучкість і адаптивність коду до змін.
5.
Знайома синтаксична структура: Розробники з досвідом в OOP (Object-Oriented Programming) можуть знайти класи інтуїтивно зрозумілими завдяки їх знайомій синтаксичній структурі (конструктори, методи, наслідування).
Недоліки класів 👎
- Накладні витрати: Рішення, основані на класах, можуть бути важчими, особливо для простих випадків.
- Жорсткі ієрархії: Глибоке наслідування може ускладнити підтримку коду.
- Менш функціональні: Класи менш складні для складання та менш гнучкі порівняно з функціональними альтернативами.
Найкращі варіанти використання ℹ️
- Коли потрібно управляти складним станом або поведінкою.
- Коли потрібне наслідування або поліморфізм.
- Фреймворки, такі як Angular, які використовують класи для компонентів і сервісів.
Альтернативи класам в TypeScript
Є кілька конструкцій і патернів, які можуть замінити класи в TypeScript, кожен з яких має свої переваги, але також і певні недоліки. Ось деякі з найбільш поширених альтернатив:
1. Фабричні функції 🔥
Фабричні функції — це звичайні функції, які створюють і повертають об'єкти. Вони легші та дозволяють інкапсулювати логіку без необхідності інстанціації за допомогою ключового слова new
.
Приклад:
// Фабрична функція для створення Animal
function createAnimal(name: string) {
// Приватний стан за допомогою замикань
let animalName = name;
// Приватний метод за допомогою замикання
function rest() {
console.log(`${animalName} is resting.`);
}
// Публічні методи
return {
name: animalName, // Публічна властивість (як `protected` в класі)
makeSound() {
console.log(`${animalName} makes a sound.`);
},
move() {
console.log(`${animalName} is moving.`);
},
// Доступ до приватного методу `rest`
takeRest() {
rest();
}
};
}
// Фабрична функція для створення Dog (розширює функціональність Animal)
function createDog(name: string) {
const animal = createAnimal(name); // Створення базового Animal
// Перевизначення методу `makeSound`
const makeSound = () => {
console.log(`${name} barks.`);
};
// Поведінка, специфічна для собаки
const fetch = () => {
console.log(`${name} is fetching a ball.`);
};
// Повернення розширеного об'єкта собаки (композиція)
return {
...animal, // Спадкування методів Animal (makeSound, move, takeRest)
makeSound, // Перевизначення методу makeSound класу Animal
fetch // Додавання поведінки, специфічної для собаки
};
}
// Приклад використання
const genericAnimal = createAnimal("Generic Animal");
const dog = createDog("Buddy");
// Виклик методів для загального тварини
genericAnimal.makeSound(); // Output: Generic Animal makes a sound.
genericAnimal.move(); // Output: Generic Animal is moving.
genericAnimal.takeRest(); // Output: Generic Animal is resting.
// Виклик методів для собаки (яка успадковує поведінку Animal і розширює її)
dog.makeSound(); // Output: Buddy barks.
dog.move(); // Output: Buddy is moving.
dog.fetch(); // Output: Buddy is fetching a ball.
// Доступ до властивостей
console.log(dog.name); // Output: Buddy (публічна властивість)
Вищенаведений приклад містить еквівалентний код, що продемонстровано в секції класів. Для деяких це може виглядати менш читабельним.
Переваги 👍
- Легкість: Фабричні функції легші за класи. Вони не потребують накладних витрат на конструктори, прив'язку
this
або наслідування класів. Немає класу або ключового словаnew
, просто звичайна функція, що повертає об'єкт, що робить код більш компактним і зрозумілим, особливо для менш складних чи простих задач. - Гнучкість: Дозволяють більш гнучкий підхід до визначення об'єктів. Ви можете динамічно додавати або змінювати поведінку під час виконання, що робить їх ідеальними, коли структура об'єктів має змінюватися або розвиватися з часом. Легко створювати об'єкти з різними властивостями та поведінкою на основі параметрів, не потрібно мати справу з ланцюгами наслідування.
3.
Функціональна парадигма: Фабричні функції добре поєднуються з принципами функціонального програмування, такими як незмінність (immutability), чисті функції (pure functions) і використання замикань (closures). Оскільки фабричні функції можуть повертати об'єкти з внутрішнім станом (через замикання), вони є потужною альтернативою класам для збереження стану без необхідності використовуватиthis
або наслідування на основі класів.
Недоліки 👎
- Відсутність вбудованого наслідування: На відміну від класів, фабричні функції не підтримують наслідування за замовчуванням. Якщо вам потрібно створити ієрархічні стосунки між об'єктами, вам доведеться вручну реалізувати наслідування або використовувати композицію для спільного використання функціональності.
- Менш знайомі: Фабричні функції можуть здаватися менш інтуїтивно зрозумілими для розробників, звиклих до OOP. Відсутність класів і ключового слова
this
може викликати дезорієнтацію у тих, хто очікує більш структурованого підходу до визначення об'єктів і поведінки. Фабричні функції можуть вимагати від розробників мислення в категоріях замикань (closures) і функціональних патернів, а не об'єктів з властивостями і методами, які додаються черезthis
.
Найкращі варіанти використання ℹ️
- Безстанні або легкі об'єкти: Фабричні функції чудово підходять для створення простих, легких об'єктів, які не потребують складної поведінки або наслідування. Наприклад, вони ідеально підходять для створення об'єктів, що просто зберігають дані або мають кілька методів для маніпулювання цими даними.
- Коли вам не потрібне наслідування чи поліморфізм: Якщо задача, яку ви вирішуєте, не вимагає складнощів з наслідуванням (наприклад, множинні рівні підкласів або поліморфізм), фабричні функції є більш простим і гнучким рішенням.
- Якщо ви працюєте в парадигмі функціонального програмування, де метою є незмінність і відсутність побічних ефектів, фабричні функції є чудовим вибором. Вони дозволяють використовувати замикання для збереження стану, при цьому уникати використання класів і
this
.
2. Літерали об'єктів 🔥
Літерал об'єкта в JavaScript і TypeScript позначає простий об'єкт, створений за допомогою фігурних дужок {}
і що містить властивості і методи, безпосередньо визначені всередині нього. Літерали об'єктів надають простий, стислий спосіб визначення об'єкта з фіксованими властивостями і методами без необхідності використовувати синтаксис класів або фабричних функцій.
Приклад:
// Літерал об'єкта Animal
const genericAnimal = {
name: "Generic Animal",
makeSound() {
console.log(`${this.name} makes a sound.`);
},
move() {
console.log(`${this.name} is moving.`);
},
takeRest() {
console.log(`${this.name} is resting.`);
}
};
// Літерал об'єкта Dog
const dog = {
name: "Buddy",
// Спадкування спільних методів від genericAnimal (вручну)
makeSound() {
console.log(`${this.name} barks.`);
},
move() {
console.log(`${this.name} is running.`);
},
fetch() {
console.log(`${this.name} is fetching a ball.`);
}
};
// Приклад використання
genericAnimal.makeSound(); // Виведення: Generic Animal makes a sound.
genericAnimal.move(); // Виведення: Generic Animal is moving.
genericAnimal.takeRest(); // Виведення: Generic Animal is resting.
dog.makeSound(); // Виведення: Buddy barks.
dog.move(); // Виведення: Buddy is running.
dog.fetch(); // Виведення: Buddy is fetching a ball.
Переваги 👍
- Простота та зрозумілість: Літерали об'єктів прості в створенні та розумінні. Немає потреби в класах, конструкторах чи складному синтаксисі.
- Стиснутий синтаксис: Літерали об'єктів надають компактний спосіб групувати пов'язані властивості і методи.
- Прямий доступ: Властивості і методи можна отримати безпосередньо через сам об'єкт, без необхідності в методах getter/setter або інстанціації.
- Миттєва ініціалізація: Властивості і методи ініціалізуються одразу при створенні об'єкта.
Недоліки 👎
- Відсутність наслідування: Літерали об'єктів не підтримують наслідування, що ускладнює повторне використання або розширення поведінки.
2.
Відсутність поліморфізму: Оскільки немає ієрархії класів, поліморфну поведінку не можна досягти лише за допомогою літералів об'єктів. - Відсутність конструктора: Літерали об'єктів не є ідеальними, коли потрібно більш складне ініціалізаційне логіку об'єкта, яка вимагає використання конструкторів.
- Обмежена гнучкість: Додавання поведінки, такої як управління станом або розширені методи, може вимагати більш складних структур, таких як фабричні функції або класи. Неможливо динамічно інкапсулювати або зберігати стан.
Найкращі варіанти використання ℹ️
- Прості, статичні дані: Коли вам потрібно створити об'єкт з фіксованим набором властивостей та методів, які не змінюватимуться і не потребують наслідування.
- Конфігураційні об'єкти: Літерали об'єктів часто використовуються для конфігураційних цілей, коли властивості вже визначені і не змінюватимуться.
- Невикористовувані структури: Коли немає необхідності створювати кілька екземплярів об'єкта, літерали об'єктів є ідеальними для одноразових об'єктів.
3. Замикання (Closures) 🔥
Замикання (Closure) в JavaScript та TypeScript — це функція, яка "пам'ятає" та може отримувати доступ до змінних із своєї лексичної області видимості, навіть коли функція виконується поза цією областю. По суті, замикання дозволяють функціям зберігати доступ до змінних середовища, в якому вони були визначені, навіть після завершення виконання зовнішньої функції.
Замикання — це основна концепція в JavaScript і є особливо корисними для інкапсуляції даних і управління приватним станом. Вони дозволяють створювати більш складні патерни поведінки і широко використовуються у функціональному програмуванні.
Приклад:
function createCounter(): {
increment: () => void;
getCount: () => number;
} {
let count: number = 0; // Приватна змінна, недоступна зовні
return {
increment(): void {
count++; // інкрементуємо лічильник
},
getCount(): number {
return count; // повертаємо поточний лічильник
},
};
}
const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // Виведення: 1
Замикання дуже схожі на фабричні функції. Насправді, фабрична функція може бути замиканням. Однак не кожна фабрична функція обов'язково є замиканням. Наприклад, якщо фабрична функція просто повертає об'єкт без захоплення зовнішніх змінних, вона не вважається замиканням:
function createPerson(name: string, age: number) {
return { name, age };
}
const person = createPerson("Alice", 30);
console.log(person.name); // Виведення: Alice
console.log(person.age); // Виведення: 30
У наведеному прикладі createPerson
— це фабрична функція, але методи чи повернуті властивості не залежать від змінних із області видимості фабричної функції. Тому це не створює замикання, оскільки немає захоплених внутрішніх змінних.
Переваги замикань 👍
- Інкапсуляція даних: Замикання надають спосіб створювати приватні змінні, які не можуть бути доступні зовні функції, що є чудовою технікою для приховування внутрішнього стану.
- Збереження стану між викликами: Замикання можуть "пам'ятати" своє середовище навіть після виконання функції. Це дозволяє функціям зберігати свій стан між кількома викликами.
- Модульність коду: Замикання дозволяють створювати малі, багаторазові і самодостатні одиниці логіки, що робить код більш модульним і легким для підтримки.
- Функціональне програмування: Замикання є потужною особливістю функціонального програмування та дозволяють використовувати високорівневі функції, каррінг і більш гнучку композицію функцій.
Недоліки замикань 👎
- Навантаження на пам'ять: Оскільки замикання зберігають посилання на змінні зовнішньої функції, вони можуть збільшувати використання пам'яті. Якщо замикання не очищається, це може призвести до витоків пам'яті.
- Складність: Поведінка замикань може бути важкою для відслідковування, особливо коли вони включають кілька рівнів вкладених функцій. Це може призвести до непорозумінь і помилок, якщо не обережно керувати.
3.
Можливість ненавмисних побічних ефектів: Оскільки замикання можуть змінювати змінні з зовнішнього середовища, легко випадково змінити стан, що призводить до важких для дебагу побічних ефектів.
Найкращі варіанти використання ℹ️
- Приватний стан і інкапсуляція даних: Замикання ідеально підходять для інкапсуляції приватних даних та створення getter/setter функцій. Це дозволяє контролювати доступ до чутливих даних і приховувати деталі реалізації.
- Обробка подій: Замикання часто використовуються в прослуховувачах подій (Event Listener), де потрібно передавати додаткові аргументи разом з обробником подій.
- Часткове застосування і каррінг: Замикання дозволяють використовувати потужні патерни каррінгу (currying), коли функція повертає іншу функцію з деякими заздалегідь заповненими аргументами.
- Фабрики функцій: Замикання корисні при динамічному створенні функцій, які повинні зберігати деякий стан разом із собою.
Порівняння варіантів
Вибір між класами і їхніми альтернативами залежить від складності вашого застосунку та цілей проектування. Ось підсумок, який допоможе вам прийняти рішення:
Використовуючи сильні сторони кожного підходу, ви зможете написати чистий, підтримуваний і ефективний TypeScript код, який відповідає вашим потребам.
Коли використовувати класи
- Складні сутності: Коли ваш застосунок містить складні взаємозв'язки, стан або поведінку, що виграють від концепцій ООП.
- Наслідування: Коли потрібно створити ієрархію взаємопов'язаних об'єктів.
- Фреймворки: Фреймворки, як Angular, використовують класи для компонентів, сервісів і впровадження залежностей.
Коли використовувати альтернативи
- Простіші випадки: Фабричні функції або літерали об'єктів добре підходять для легких, безстанних або об'єктів з єдиною відповідальністю.
- Функціональне програмування: Коли ви віддаєте перевагу незмінності (immutability), композиції та безстанному дизайну.
Висновок 📣
Класи — це потужні інструменти для організації коду в TypeScript, але вони не завжди є найкращим вибором. Альтернативи, як фабричні функції, літерали об'єктів і замикання, забезпечують гнучкість, простоту та узгодженість з парадигмами функціонального програмування. Розуміючи сильні та слабкі сторони кожного підходу, ви зможете вибрати правильний інструмент для потреб вашого застосунку та забезпечити чистіший, підтримуваніший код.
Перекладено з: Understanding the Differences between Classes, Factory Functions, Object Literals, and Closures in TypeScript