На попередньому уроці ми дізналися, що змінні-члени класу за замовчуванням є закритими. Початківці, які вивчають об’єктно-орієнтоване програмування, дуже часто не розуміють, чому саме так.
Навіщо робити змінні-члени класу закритими?
Як відповідь, скористаємося аналогією. У сучасному житті ми маємо доступ до багатьох електронних пристроїв. До телевізору є пульт дистанційного керування, за допомогою якого можна вмикати/вимикати телевізор. Керування автомобілем дозволяє в рази швидше пересуватися між двома точками. За допомогою фотоапарата можна робити знімки.
Всі ці 3 речі використовують загальний шаблон: вони надають вам простий інтерфейс (кнопка, кермо тощо), щоб виконати дію. Однак, те, як ці пристрої фактично працюють, приховано від вас (як від користувачів). Для натискання кнопки на пульті дистанційного керування вам не потрібно знати, що виконується під кришкою пульта для взаємодії з телевізором. Коли ви натискаєте на педаль газу в своєму автомобілі, вам не потрібно знати про те, як двигун внутрішнього згоряння змушує колеса обертатися. Коли ви робите знімок, вам не потрібно знати, як датчики збирають світло в піксельне зображення.
Такий поділ інтерфейсу і реалізації є надзвичайно корисним, оскільки дозволяє використовувати об’єкти без необхідності розуміти їх реалізацію. Це значно знижує складність використання цих пристроїв і значно збільшує їх кількість (пристрої з якими можна взаємодіяти).
З аналогічних причин поділ реалізації та інтерфейсу корисний і в програмуванні.
Інкапсуляція
В об’єктно-орієнтованому програмуванні інкапсуляція (або “приховування інформації”) — це процес прихованого зберігання деталей реалізації об’єкта. Користувачі звертаються до об’єкта через відкритий інтерфейс.
У мові C++ інкапсуляція реалізована через специфікатори доступу. Як правило, всі змінні-члени класу є закритими (приховуючи деталі реалізації), а більшість методів є відкритими (з відкритим інтерфейсом для користувача). Хоча вимога до користувачів використовувати відкритий інтерфейс може здатися більш обтяжливою, ніж просто відкрити доступ до змінних-членів, але насправді це надає велику кількість корисних переваг, які покращують можливість повторного використання коду і його підтримку.
Перевага №1: Інкапсульовані класи простіші у використанні і зменшують складність ваших програм.
З повністю інкапсульованим класом вам потрібно знати тільки те, які методи є доступними для використання, які аргументи вони приймають і які значення повертають. Не потрібно знати, як клас реалізований всередині. Наприклад, клас, що містить список імен, може бути реалізований з використанням динамічного масиву, рядків C-style, std::array, std::vector, std::map, std::list або будь-якої іншої структури даних. Для використання цього класу, вам не потрібно знати деталі його реалізації. Це значно знижує складність ваших програм, а також зменшує кількість можливих помилок. Це є ключовою перевагою інкапсуляції.
Всі класи Стандартної бібліотеки C++ інкапсульовані. Уявіть, наскільки складнішим був би процес вивчення мови C++, якби вам потрібно було знати реалізацію std::string, std::vector або std::cout (та інших об’єктів) для того, щоб їх використовувати!
Перевага №2: Інкапсульовані класи допомагають захистити ваші дані і запобігають їх неправильному використанню.
Глобальні змінні небезпечні, так як немає строгого контролю над тим, хто має до них доступ і як їх використовують. Класи з відкритими членами мають ту ж проблему, тільки в менших масштабах. Наприклад, припустимо, що нам потрібно написати рядковий клас. Ми могли б почати з наступного:
1 2 3 4 5 |
class MyString { char *m_string; // динамічно виділяємо рядок int m_length; // використовуємо змінну для відслідковування довжини рядка }; |
Ці два члени пов’язані: m_length
завжди повинен відповідати довжині рядка m_string
. Якби m_length
був відкритим, то будь-хто міг би змінити довжину рядка без зміни m_string
(або навпаки). Це точно призвело б до проблем. Роблячи як m_length
, так і m_string
закритими, користувачі змушені використовувати методи для взаємодії з класом.
Ми також можемо самі поліпшити захист нашого класу від його неправильного використання. Наприклад, розглянемо клас з відкритою змінною-членом у вигляді масиву:
1 2 3 4 5 |
class IntArray { public: int m_array[10]; }; |
Якби користувачі могли напряму звертатися до масиву, то вони могли б використати некоректний індекс:
1 2 3 4 5 |
int main() { IntArray array; array.m_array[16] = 2; // некоректний індекс, внаслідок чого перезаписуємо пам'ять, якою ми не володіємо } |
Однак, якщо ми зробимо масив закритим, то зможемо змусити користувача використати функцію, яка в першу чергу перевіряє коректність індексу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class IntArray { private: int m_array[10]; // користувач не має прямого доступу до цього члену public: void setValue(int index, int value) { // Якщо індекс недійсний, то нічого не робимо if (index < 0 || index >= 10) return; m_array[index] = value; } }; |
Таким чином, ми захистимо цілісність нашої програми.
Примітка: Функція at() в std::array і std::vector робить щось схоже!
Перевага №3: Інкапсульовані класи легше змінити.
Розглянемо наступним простий приклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <iostream> class Values { public: int m_number1; int m_number2; int m_number3; }; int main() { Values value; value.m_number1 = 7; std::cout << value.m_number1 << '\n'; } |
Хоча ця програма працює нормально, але що станеться, якщо ми вирішимо перейменувати m_number1
або змінити тип цієї змінної? Ми зламали б не тільки цю програму, але й більшість інших програм, які використовують клас Values!
Інкапсуляція надає можливість зміни способу реалізації класів, не порушуючи при цьому роботу всіх програм, які їх використовують. Ось інкапсульована версія класу, наведеного вище, але яка використовує методи для доступу до m_number1
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <iostream> class Values { private: int m_number1; int m_number2; int m_number3; public: void setNumber1(int number) { m_number1 = number; } int getNumber1() { return m_number1; } }; int main() { Values value; value.setNumber1(7); std::cout << value.getNumber1() << '\n'; } |
Тепер давайте змінимо реалізацію класу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <iostream> class Values { private: int m_number[3]; // тут змінюємо реалізацію цього класу public: // Нам потрібно оновити змінні методів, щоб запрацювала нова реалізація void setNumber1(int number) { m_number[0] = number; } int getNumber1() { return m_number[0]; } }; int main() { // Але наша програма продовжує працювати як і раніше Values value; value.setNumber1(7); std::cout << value.getNumber1() << '\n'; } |
Зверніть увагу, оскільки ми не змінювали прототипи будь-яких функцій у відкритому інтерфейсі нашого класу, наша програма, яка використовує клас, продовжує працювати без будь-яких змін або проблем.
Аналогічно, якби вночі гноми пробралися в ваш будинок і замінили внутрішню частину вашого пульту від телевізора на іншу (сумісну) технологію, ви, ймовірно, нічого навіть не помітили б!
Перевага №4: З інкапсульованими класами легше проводити відлагодження.
І, нарешті, інкапсуляція допомагає проводити відлагодження програм, коли щось іде не за планом. Часто причиною неправильної роботи програми є некоректне значення однієї із змінних. Якщо кожен об’єкт має прямий доступ до змінної, то відстежити частину коду, яка змінила змінну, може бути досить-таки важко. Однак, якщо для зміни значення потрібно викликати один і той же метод, ви можете просто використати точку зупину для цього методу і подивитися, як кожний викликаючий об’єкт змінює значення, поки не побачите щось дивне.
Функції доступу (геттери і сеттери)
Залежно від класу, може бути доречно (в контексті того, що робить клас) мати можливість отримувати/встановлювати значення закритих змінних-членів класу.
Функція доступу — це коротка відкрита функція, завданням якої є отримання або зміна значення закритої змінної-члена класу. Наприклад:
1 2 3 4 5 6 7 8 9 |
class MyString { private: char *m_string; // динамічно виділяємо рядок int m_length; // використовуємо змінну для відстеження довжини рядка public: int getLength() { return m_length; } // функція доступу для отримання значення m_length }; |
Тут getLength() є функцією доступу, яка просто повертає значення m_length
.
Функції доступу зазвичай бувають двох типів:
геттери — це функції, які повертають значення закритих змінних-членів класу;
сеттери — це функції, які дозволяють присвоювати значення закритим змінним-членам класу.
Ось приклад класу, який використовує геттери і сеттери для всіх своїх закритих змінних-членів:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class Date { private: int m_day; int m_month; int m_year; public: int getDay() { return m_day; } // геттер для day void setDay(int day) { m_day = day; } // сеттер для day int getMonth() { return m_month; } // геттер для month void setMonth(int month) { m_month = month; } // сеттер для month int getYear() { return m_year; } // геттер для year void setYear(int year) { m_year = year; } // сеттер для year }; |
В цьому класі немає ніяких проблем з тим, щоб користувач міг напряму отримувати або присвоювати значення закритим змінним-членам цього класу, так як є повний набір геттерів і сеттерів. У прикладі з класом MyString для змінної m_length
не було надано сеттера, так як не було необхідності в тому, щоб користувач міг напряму встановлювати довжину.
Правило: Надавайте функції доступу тільки в тому випадку, коли потрібно, щоб користувач мав можливість отримувати або присвоювати значення членам класу.
Хоча іноді ви можете побачити, що геттер повертає неконстантне посилання на змінну-член — цього слід уникати, так як в такому випадку порушується інкапсуляція, дозволяючи caller-у змінювати внутрішній стан класу, не знаходячись у цьому ж класі. Краще, щоб ваші геттери використовували тип повернення по значенню або по константному посиланню.
Правило: Геттери повинні використовувати тип повернення по значенню або по константному посиланню. Не використовуйте для геттерів тип повернення по неконстантному посиланню.
Висновки
Інкапсуляція має багато переваг, основна з яких полягає у використанні класу без необхідності розуміння його реалізації. Інкапсульовані класи:
простіші у використанні;
зменшують складність програм;
захищають дані і запобігають їх неправильному використанню;
легше змінити;
легше відлагоджувати.
Це значно полегшує використання класів.