На попередніх уроках ми вивчили основи спадкування в мові C++ і порядок ініціалізації дочірніх класів. На цьому уроці ми докладніше розглянемо роль конструкторів в ініціалізації дочірніх класів.
Конструктори і ініціалізація
А допомагати нам у цьому будуть класи Parent і Child:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
class Parent { public: int m_id; Parent(int id=0) : m_id(id) { } int getId() const { return m_id; } }; class Child: public Parent { public: double m_value; Child(double value=0.0) : m_value(value) { } double getValue() const { return m_value; } }; |
Зі звичайними (НЕ дочірніми) класами конструктору потрібно морочитися тільки з членами свого класу. Наприклад, об’єкт класу Parent створюється наступним чином:
1 2 3 4 5 6 |
int main() { Parent parent(7); // викликається конструктор Parent(int) return 0; } |
Ось що насправді відбувається при ініціалізації об’єкта parent
:
виділяється пам’ять об’єкту parent
;
викликається відповідний конструктор класу Parent;
список ініціалізації ініціалізує змінні;
виконується тіло конструктора;
точка виконання повертається назад в caller.
Все досить-таки просто. З дочірніми класами справи йдуть трохи складніше:
1 2 3 4 5 6 |
int main() { Child child(1.5); // викликається конструктор Child(double) return 0; } |
Ось що відбувається при ініціалізації об’єкту child
:
виділяється пам’ять об’єкту дочірнього класу (достатня порція пам’яті для частини Parent і частини Child об’єкту класу Child);
викликається відповідний конструктор класу Child;
створюється об’єкт класу Parent з використанням відповідного конструктора класу Parent. Якщо такий конструктор програмістом не надано, то буде використовуватися конструктор за замовчуванням класу Parent;
список ініціалізації ініціалізує змінні;
виконується тіло конструктора класу Child;
точка виконання повертається назад в caller.
Єдина відмінність між ініціалізацією об’єктів звичайного і дочірнього класів полягає в тому, що при ініціалізації об’єкта дочірнього класу, спочатку виконується конструктор батьківського класу (для ініціалізації частини батьківського класу) і тільки потім вже виконується конструктор дочірнього класу.
Ініціалізація членів батьківського класу
Одним з недоліків нашого дочірнього класу Child є те, що ми не можемо ініціалізувати m_id
при створенні об’єкта класу Child. Що, якщо ми хочемо задати значення як для m_value
(частини Child), так і для m_id
(частини Parent)?
Початківці часто намагаються вирішити цю проблему наступним чином:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Child: public Parent { public: double m_value; Child(double value=0.0, int id=0) // Не спрацює : m_value(value), m_id(id) { } double getValue() const { return m_value; } }; |
Це хороша спроба і майже правильна ідея. Нам дійсно потрібно додати ще один параметр в наш конструктор, інакше C++ не розумітиме, яким значенням ми хочемо ініціалізувати m_id
.
Однак C++ забороняє дочірнім класам ініціалізувати наслідувані змінні-члени батьківського класу в списку ініціалізації свого конструктора. Іншими словами, значення змінної може бути задано лише в списку ініціалізації конструктора, який належить до того ж класу, що і змінна-член.
Чому C++ так робить? Відповідь пов’язана з константними змінними і посиланнями. Подумайте, що сталося б, якби m_id
був const. Оскільки константи повинні бути ініціалізовані значеннями при створенні, то конструктор батьківського класу повинен встановити це значення при створенні змінної-члена. У той же час конструктор дочірнього класу виконується тільки після виконання конструкторів батьківського класу. Кожен дочірній клас мав би тоді можливість ініціалізувати цю змінну, потенційно змінюючи її значення! Обмежуючи ініціалізацію змінних конструктором класу, до якого належать ці змінні, мова C++ гарантує, що всі змінні будуть ініціалізовані лише один раз.
Кінцевим результатом виконання вищенаведеного коду є помилка, тому що m_id
успадкований від класу Parent, а тільки неуспадковані змінні-члени можуть бути змінені в списку ініціалізації конструктора класу Child.
Однак успадковані змінні можуть як і раніше змінювати свої значення в тілі конструктора через операцію присвоювання. Початківці часто намагаються зробити наступне:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Child: public Parent { public: double m_value; Child(double value=0.0, int id=0) : m_value(value) { m_id = id; } double getValue() const { return m_value; } }; |
Хоча подібне дійсно спрацює в даному випадку, але це не спрацює, якщо m_id
буде константою або посиланням (оскільки константи і посилання повинні бути ініціалізовані в списку ініціалізації конструктора). Крім того, це неефективно, тому що для m_id
присвоюють значення двічі: перший раз в списку ініціалізації конструктора класу Parent, а потім в тілі конструктора класу Child. І, нарешті, що, якщо класу Parent необхідний доступ до цього значення під час ініціалізації?
Отже, як правильно ініціалізувати m_id
при створенні об’єкта класу Child?
У всіх наших прикладах, при створенні об’єкта класу Child, викликався конструктор за замовчуванням класу Parent. Чому так? Тому що ми не вказували інакше!
На щастя, мова C++ надає нам можливість явно вибирати конструктор класу Parent для виконання ініціалізації частини Parent! Для цього нам потрібно просто додати виклик необхідного нам конструктора в списку ініціалізації конструктора дочірнього класу:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Child: public Parent { public: double m_value; Child(double value=0.0, int id=0) : Parent(id), // викликається конструктор Parent(int) зі значенням id! m_value(value) { } double getValue() const { return m_value; } }; |
Тепер при виконанні наступного коду:
1 2 3 4 5 6 7 8 |
int main() { Child child(1.5, 7); // викликається конструктор Child(double, int) std::cout << "ID: " << child.getId() << '\n'; std::cout << "Value: " << child.getValue() << '\n'; return 0; } |
Конструктор Parent(int)
буде використовуватися для ініціалізації m_id
значенням 7
, а конструктор дочірнього класу буде використовуватися для ініціалізації m_value
значенням 1.5
!
Результат виконання програми:
ID: 7
Value: 1.5
Розглянемо детально, що відбувається:
Виділяється пам’ять об’єкту child
.
Викликається конструктор Child(double, int)
, де value = 1.5
, а id = 7
.
Компілятор дивиться, чи запитуємо ми якийсь конкретний конструктор класу Parent. І бачить, що запитуємо! Тому викликається Parent(int)
з параметром id
, якому ми до цього присвоїли значення 7
.
Список ініціалізації конструктора класу Parent присвоює для m_id
значення 7
.
Виконується тіло конструктора класу Parent, яке нічого не робить.
Завершується виконання конструктора класу Parent.
Список ініціалізації конструктора класу Child присвоює для m_value
значення 1.5
.
Виконується тіло конструктора класу Child, яке нічого не робить.
Завершується виконання конструктора класу Child.
Це може здатися трохи складним, але насправді все дуже просто. Все, що відбувається — це виклик конструктором класу Child конкретного конструктора класу Parent для ініціалізації частини Parent об’єкта класу Child. Оскільки m_id
знаходиться в частині Parent, то тільки конструктор класу Parent може ініціалізувати це значення.
Зверніть увагу, не має значення, де в списку ініціалізації конструктора класу Child викликається конструктор класу Parent — він завжди виконуватиметься першим.
Тепер ми можемо зробити наші члени private
Тепер, коли ми знаємо про ініціалізацію членів батьківського класу, немає ніякої необхідності зберігати наші змінні-члени відкритими. Ми зробимо їх private, як і повинно бути.
В якості нагадування: Доступ до членів public відкритий для всіх. Доступ до членів private відкритий тільки для інших членів цього ж класу. Зверніть увагу, це означає, що дочірні класи не можуть напряму звертатися до закритих членів батьківського класу! Дочірнім класам потрібно використовувати геттери і сеттери для доступу до цих членів.
Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
#include <iostream> class Parent { private: // наш m_id тепер закритий int m_id; public: Parent(int id=0) : m_id(id) { } int getId() const { return m_id; } }; class Child: public Parent { private: // наш m_value тепер закритий double m_value; public: Child(double value=0.0, int id=0) : Parent(id), // викликається конструктор Parent(int) зі значенням id! m_value(value) { } double getValue() const { return m_value; } }; int main() { Child child(1.5, 7); // викликається конструктор Child(double, int) std::cout << "ID: " << child.getId() << '\n'; std::cout << "Value: " << child.getValue() << '\n'; return 0; } |
У коді, наведеному вище, ми робимо m_id
і m_value
закритими. Для їх ініціалізації використовуються відповідні конструктори, а для доступу — відкриті функції доступу (геттери).
Результат виконання програми:
Ще один приклад
Розглянемо ще кілька класів, з якими ми працювали раніше:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
#include <iostream> #include <string> class Human { public: std::string m_name; int m_age; Human(std::string name = "", int age = 0) : m_name(name), m_age(age ) { } std::string getName() const { return m_name; } int getAge() const { return m_age; } }; // BasketballPlayer відкрито наслідує клас Human class BasketballPlayer: public Human { public: double m_gameAverage; int m_points; BasketballPlayer(double gameAverage = 0.0, int points = 0) : m_gameAverage(gameAverage), m_points(points) { } }; |
Як ми вже знаємо, клас BasketballPlayer тільки ініціалізує свої власні члени і не вказує використання конкретного конструктора класу Human. Це означає, що кожен створений об’єкт класу BasketballPlayer буде використовувати конструктор за замовчуванням класу Human, який ініціалізуватиме змінну-член name
порожнім значенням, а age
— значенням 0
. Оскільки ми хочемо назвати нашого BasketballPlayer і вказати вік при його створенні, то ми повинні змінити цей конструктор, додавши необхідні параметри.
Ось наші оновлені класи з членами private і з викликом конкретного конструктора класу Human:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
#include <string> class Human { private: std::string m_name; int m_age; public: Human(std::string name = "", int age = 0) : m_name(name), m_age(age ) { } std::string getName() const { return m_name; } int getAge() const { return m_age; } }; // BasketballPlayer відкрито наслідує клас Human class BasketballPlayer: public Human { private: double m_gameAverage; int m_points; public: BasketballPlayer(std::string name = "", int age = 0, double gameAverage = 0.0, int points = 0) : Human(name, age), // викликається Human(std::string, int) для ініціалізації членів name і age m_gameAverage(gameAverage), m_points(points) { } double getGameAverage() const { return m_gameAverage; } int getPoints() const { return m_points; } }; |
Тепер ми можемо створювати об’єкти класу BasketballPlayer наступним чином:
1 2 3 4 5 6 7 8 9 10 |
int main() { BasketballPlayer anton("Anton Ivanovuch", 45, 300, 310); std::cout << anton.getName() << '\n'; std::cout << anton.getAge() << '\n'; std::cout << anton.getPoints() << '\n'; return 0; } |
Результат виконання програми:
Anton Ivanovuch
45
310
Як ви можете бачити, все коректно ініціалізовано.
“Ланцюжки” наслідувань
Класи в “ланцюжку” наслідувань працюють аналогічно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
#include <iostream> class A { public: A(int a) { std::cout << "A: " << a << '\n'; } }; class B: public A { public: B(int a, double b) : A(a) { std::cout << "B: " << b << '\n'; } }; class C: public B { public: C(int a , double b , char c) : B(a, b) { std::cout << "C: " << c << '\n'; } }; int main() { C c(7, 5.4, 'D'); return 0; } |
У цьому прикладі клас C успадковує властивості класу B, який успадковує властивості класу A. Що станеться при створенні об’єкта класу C? А ось що:
функція main() викличе C(int, double, char)
;
конструктор класу C викличе B(int, double)
;
конструктор класу B викличе A(int)
;
оскільки A не наслідує ніякий клас, то побудова почнеться саме з цього класу;
побудова A виконана, виводиться значення 7
і виконання переходить до B;
клас B побудований, виводиться значення 5.4
і виконання переходить до C;
клас C побудований, виводиться D
і виконання повертається назад в main();
Фініш!
Таким чином, результат виконання програми:
A: 7
B: 5.4
C: D
Варто відзначити, що конструктори дочірнього класу можуть викликати конструктори тільки того батьківського класу, від якого вони безпосередньо виконують наслідування. Отже, конструктор класу C не може напряму викликати або передавати параметри в конструктор класу A. Конструктор класу C може викликати тільки конструктор класу B (який вже, в свою чергу, викликає конструктор класу A).
Деструктори
При знищенні дочірнього класу, кожен деструктор викликається в порядку зворотному побудові класів. У прикладі, наведеному вище, при знищенні об’єкта класу С, спочатку викликається деструктор класу C, потім деструктор класу B, а потім вже деструктор класу A.
Висновки
При ініціалізації об’єктів дочірнього класу, конструктор дочірнього класу відповідає за те, який конструктор батьківського класу викликати. Якщо цей конструктор явно не вказаний, то викликається конструктор за замовчуванням батьківського класу. Якщо ж компілятор не може знайти конструктор за замовчуванням батьківського класу (або цей конструктор не може бути створений автоматично), то компілятор видасть помилку.
Тест
Реалізуємо наш приклад з Фруктом, про який ми говорили на уроці №161. Створіть батьківський клас Fruit, який має два закритих члени: name
(std::string) і color
(std::string). Створіть клас Apple, який наслідує властивості Fruit. У Apple повинен бути додатковий закритий член: fiber
(тип double). Створіть клас Banana, який також успадковує клас Fruit. Banana не має додаткових членів.
Наступний код:
1 2 3 4 5 6 7 8 9 10 |
int main() { const Apple a("Red delicious", "red", 7.3); std::cout << a; const Banana b("Cavendish", "yellow"); std::cout << b; return 0; } |
Повинен видавати наступний результат:
Apple(Red delicious, red, 7.3)
Banana(Cavendish, yellow)
Підказка: Оскільки a
і b
є const, то переконайтеся, що ваші параметри і функції відповідають const.
Відповідь
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
#include <iostream> #include <string> class Fruit { private: std::string m_name; std::string m_color; public: Fruit(std::string name, std::string color) : m_name(name), m_color(color) { } std::string getName() const { return m_name; } std::string getColor() const { return m_color; } }; class Apple : public Fruit { private: double m_fiber; public: Apple(std::string name, std::string color, double fiber) :Fruit(name, color), m_fiber(fiber) { } double getFiber() const { return m_fiber; } friend std::ostream& operator<<(std::ostream &out, const Apple &a) { out << "Apple (" << a.getName() << ", " << a.getColor() << ", " << a.getFiber() << ")\n"; return out; } }; class Banana : public Fruit { public: Banana(std::string name, std::string color) :Fruit(name, color) { } friend std::ostream& operator<<(std::ostream &out, const Banana &b) { out << "Banana (" << b.getName() << ", " << b.getColor() << ")\n"; return out; } }; int main() { const Apple a("Red delicious", "red", 7.3); std::cout << a; const Banana b("Cavendish", "yellow"); std::cout << b; return 0; } |