ООП та Класи
ООП теорія
Об'єктно-орієнтоване програмування (ООП) - методологія, яка описує програму як сукупність об'єктів, кожен з яких містить дані (властивості) і методи для взаємодії з ними.
З ідеологічної точки зору, ООП - це підхід до програмування як до моделювання, що вирішує основне завдання - структурування інформації з точки зору керованості, що суттєво покращує контроль процесу моделювання.
Клас - спосіб опису сутності, що визначає стан і поведінку, яка залежить від цього стану, а також правила для взаємодії з цією сутністю (контракт).
Екземпляр (об'єкт) - це окремий представник класу, який має конкретний стан і поведінку, що повністю визначається класом. Це те, що створено за кресленням, тобто на підставі опису з класу.
Інтерфейс - це набір властивостей і методів класу, доступних для використання у роботі з екземпляром. По суті, інтерфейс описує клас, чітко визначаючи усі можливі дії над ним. Хороший приклад інтерфейсу - панель приладів автомобіля, яка дозволяє викликати методи як-от збільшення швидкості, гальмування, повертання, перемикання передач, увімкнення фар тощо.
Прототипне наслідування
ООП в JavaScript побудовано на прототипному наслідуванні. Об'єкти можна організувати у ланцюжки таким чином, щоб здійснювався автоматичний пошук властивості в іншому об'єкті, не знайденої в одному об'єкті. Сполучною ланкою виступає спеціальна прихована властивість [[Prototype]], яка в консолі браузера відображається як __proto__.
Прототип об'єкта
Метод Object.create(obj) створює і повертає новий об'єкт, зв'язуючи його з об'єктом obj.
const animal = { legs: 4, }; const dog = Object.create(animal); dog.name = "Манго"; console.log(dog); // { name: 'Манго', __proto__: animal } console.log(animal.isPrototypeOf(dog)); // true
Тобто прототип - це резервне сховище властивостей і методів об'єкта, автоматично використовується під час їх пошуку. Об'єкт, який виступає прототипом, може також мати свій прототип, наступний - свій, і так далі. Пошук властивості виконується до першого збігу. Інтерпретатор шукає властивість за ім'ям в об'єкті, якщо не знаходить, то звертається до властивості __proto__, тобто переходить за посиланням до об'єкта-прототипу, а потім - до прототипу прототипу. Якщо інтерпретатор дійде до кінця ланцюжка і не знайде властивості з таким ім'ям, то поверне undefined.
const animal = { eats: true, }; const dog = Object.create(animal); dog.barks = true; for (const key in dog) { if (!dog.hasOwnProperty(key)) continue; console.log(key); // barks }
Класи
Оголошення класу
Оголошення класу починається з ключового слова class, після якого стоїть ім'я класу і фігурні дужки - його тіло. Класи прийнято називати з великої літери, а у назві відображати тип об'єкта (іменника), що створюється.
Конструктор класу
Для ініціалізації екземпляра в класі є метод constructor. Якщо він неоголошений, створюється конструктор за замовчуванням - порожня функція, яка не змінює екземпляр.
class User { constructor(name, email) { this.name = name; this.email = email; } } const mango = new User("Манго", "mango@mail.com"); console.log(mango); // { name: 'Манго', email: 'mango@mail.com' }
Виклик класу з оператором new призводить до створення нового об'єкта і виклику конструктора в контексті цього об'єкта. Тобто this всередині конструктора буде посилатися на новостворений об'єкт. Це дозволяє додавати кожному об'єкту властивості з однаковими іменами, але різними значеннями. Властивості name та email називаються публічними властивостями, тому що вони будуть власними властивостями об'єкта-екземпляра і до них можна буде отримати доступ, звернувшись через крапку.
Об'єкт параметрів
Клас може приймати велику кількість вхідних даних для властивостей майбутнього об'єкта. Тому, до них також можна застосувати патерн «Об'єкт параметрів», передаючи один об'єкт з логічно іменованими властивостями, замість непов'язаного набору аргументів.
class User { // Деструктуризуємо об'єкт constructor({ name, email }) { this.name = name; this.email = email; } } const mango = new User({ name: "Манго", email: "mango@mail.com", }); console.log(mango); // { name: "Манго", email: "mango@mail.com" }
Методи класу
Для роботи з властивостями майбутнього екземпляра використовуються методи класу - функції, які будуть доступні екземпляру в його прототипі.
class User { constructor({ name, email }) { this.name = name; this.email = email; } // Метод getEmail getEmail() { return this.email; } // Метод changeEmail changeEmail(newEmail) { this.email = newEmail; } }
Приватні властивості
Припустимо, що пошта користувача повинна бути недоступною для прямої зміни зовні, тобто - приватною. Додаючи до імені властивості символ #, ми робимо її приватною. Оголошення приватної властивості до ініціалізації в конструкторі - обов'язкове
class User { // Необов'язкове оголошення публічних властивостей name; // Обов'язкове оголошення приватних властивостей #email; constructor({ name, email }) { this.name = name; this.#email = email; } getEmail() { return this.#email; } changeEmail(newEmail) { this.#email = newEmail; } } const mango = new User({ name: "Манго", email: "mango@mail.com", }); mango.changeEmail("mango@supermail.com"); console.log(mango.getEmail()); // mango@supermail.com console.log(mango.#email); // Виникне помилка, це приватна властивість
Методи класу також можуть бути приватними, тобто доступні тільки у тілі класу. Для цього, перед їхнім ім'ям необхідно поставити символ #.
Геттери і сеттери
Геттери і сеттери - це коротший синтаксис оголошення методів для взаємодії з властивостями. Геттер і сеттер імітують звичайну публічну властивість класу, але дозволяють змінювати інші властивості зручнішим способом. Геттер виконується при спробі отримати значення властивості, а сеттер - при спробі його змінити. Геттери і сеттери доречно використовувати для простих операцій читання і зміни значення властивостей, особливо приватних, як їх публічний інтерфейс. Для роботи з властивістю, яка зберігає масив або об'єкт, вони не підійдуть.
class User { #email; constructor({ name, email }) { this.name = name; this.#email = email; } // Геттер email get email() { return this.#email; } // Сеттер email set email(newEmail) { this.#email = newEmail; } }
Ми оголосили геттер і сеттер email, поставивши перед ім'ям властивості ключові слова get і set. Всередині цих методів ми або повертаємо значення приватної властивості #email, або змінюємо її значення. Геттер і сеттер застосовуються в парі і повинні називатися однаково
const mango = new User({ name: "Манго", email: "mango@mail.com" }); console.log(mango.email); // mango@mail.com mango.email = "mango@supermail.com"; console.log(mango.email); // mango@supermail.com
Звертаючись до mango.email, викликається геттер get email() {...} і виконується його код. При спробі запису mango.email = "mango@supermail.com" викликається сеттер set email(newEmail) {...} і рядок "mango@supermail.com" буде значенням параметра newEmail. Перевага в тому, що це методи, а значить, під час запису можна виконати додатковий код, наприклад, з будь-якими перевірками, на відміну від виконання цієї ж операції безпосередньо з властивістю.
set email(newEmail) { if(newEmail === "") { console.error("Помилка! Пошта не може бути порожнім рядком!"); return; } this.#email = newEmail; }
Статичні властивості
Крім публічних і приватних властивостей майбутнього екземпляра, в класі можна оголосити його власні властивості, доступні тільки класові, але не його екземплярам - статичні властивості (static). Вони корисні для зберігання інформації, що стосується класу.
Додамо класу користувача приватну властивість role - його роль, що визначає набір прав, наприклад, адміністратор, редактор, звичайний користувач тощо. Можливі ролі користувачів будемо зберігати як статичну властивість Roles - об'єкт з властивостями.
Статичні властивості оголошуються в тілі класу. Перед ім'ям властивості додається ключове слово static.
class User { // Оголошення та ініціалізація статичної властивості static Roles = { ADMIN: "admin", EDITOR: "editor", }; #email; #role; constructor({ email, role }) { this.#email = email; this.#role = role; } get role() { return this.#role; } set role(newRole) { this.#role = newRole; } } const mango = new User({ email: "mango@mail.com", role: User.Roles.ADMIN, }); console.log(mango.Roles); // undefined console.log(User.Roles); // { ADMIN: "admin", EDITOR: "editor" } console.log(mango.role); // "admin" mango.role = User.Roles.EDITOR; console.log(mango.role); // "editor"
Статичні властивості також можуть бути приватними, тобто доступними тільки всередині класу. Для цього ім'я властивості повинно починатися з символу #, так само, як приватні властивості. Звернення до приватної статичної властивості за межами тіла класу викличе помилку.
Статичні методи
У класі можна оголосити не тільки методи майбутнього екземпляра, а також методи, доступні тільки класу - статичні методи, які можуть бути як публічні, так і приватні. Синтаксис оголошення аналогічний статичним властивостям, за винятком того, що значенням буде метод.
class User { static #takenEmails = []; static isEmailTaken(email) { return User.#takenEmails.includes(email); } #email; constructor({ email }) { this.#email = email; User.#takenEmails.push(email); } } const mango = new User({ email: "mango@mail.com" }); console.log(User.isEmailTaken("poly@mail.com")); console.log(User.isEmailTaken("mango@mail.com"));
Особливість статичних методів у тому, що під час їх виклику ключове слово this посилається на сам клас. Це означає, що статичний метод може отримати доступ до статичних властивостей класу, але не до властивостей екземпляра. Логічно, тому що статичні методи викликає сам клас, а не його екземпляри.
Наслідування класів
Ключове слово extends дозволяє реалізувати наслідування класів, коли один клас (дочірній, похідний) наслідує властивості і методи іншого класу (батьківського).
class Child extends Parent { // ... }
У виразі class Child extends Parent дочірній клас Child наслідує (розширює) від батьківського класу Parent.
Це означає, що ми можемо оголосити базовий клас, який зберігає загальні характеристики і методи для групи похідних класів, які наслідують властивості і методи батьківського, але також додають свої унікальні.
Наприклад, у застосунку є користувачі з різними ролями - адміністратор, копірайтер, контент менеджер тощо. У кожного типу користувача є набір загальних характеристик, наприклад, пошта і пароль, але також є й унікальні.Створивши незалежні класи для кожного типу користувача, ми отримаємо дублювання загальних властивостей і методів, і, якщо необхідно змінити, наприклад, назву властивості, доведеться проходити по усіх класах, а це незручно і вимагає багато часу.
Замість цього, можна створити загальний клас User, який буде зберігати набір загальних властивостей і методів, після чого, створити класи для кожного типу користувача, які наслідують цей набір від класу User. За потреби змінити щось спільне, достатньо буде змінити тільки код класу User
class User { #email; constructor(email) { this.#email = email; } get email() { return this.#email; } set email(newEmail) { this.#email = newEmail; } } class ContentEditor extends User { // Тіло класу ContentEditor } const editor = new ContentEditor("mango@mail.com"); console.log(editor); // { email: "mango@mail.com" } console.log(editor.email); // "mango@mail.com"
Клас ContentEditor наслідує від класу User його конструктор, геттер і сеттер email, а також однойменну публічну властивість. Важливо пам'ятати, що приватні властивості і методи батьківського класу не наслідуються дочірнім класом.
Конструктор дочірнього класу
Насамперед в конструкторі дочірнього класу необхідно викликати спеціальну функцію super(аргументи) - це псевдонім конструктора батьківського класу. В іншому випадку, при спробі звернутися до this в конструкторі дочірнього класу, виникне помилка. Під час виклику конструктора батьківського класу передаємо необхідні йому аргументи для ініціалізації властивостей.
class User { #email; constructor(email) { this.#email = email; } get email() { return this.#email; } set email(newEmail) { this.#email = newEmail; } } class ContentEditor extends User { constructor({ email, posts }) { // Виклик конструктора батьківського класу User super(email); this.posts = posts; } } const editor = new ContentEditor({ email: "mango@mail.com", posts: [] }); console.log(editor); // { email: 'mango@mail.com', posts: [] } console.log(editor.email); // 'mango@mail.com'
Методи дочірнього класу
В дочірньому класі можна оголошувати методи, які будуть доступні тільки його екземплярам.
// Уявімо, що вище є оголошення класу User class ContentEditor extends User { constructor({ email, posts }) { super(email); this.posts = posts; } addPost(post) { this.posts.push(post); } } const editor = new ContentEditor({ email: "mango@mail.com", posts: [] }); console.log(editor); // { email: 'mango@mail.com', posts: [] } console.log(editor.email); // 'mango@mail.com' editor.addPost("post-1"); console.log(editor.posts); // ['post-1']