Конструктори, деструктори та порядок ініціалізації в ієрархіях
Від простих класів до ієрархій: нова складність
У попередній статті ми дослідили наслідування як відношення «є різновидом» (is-a) та побудували перші ієрархії класів — Person → Student, Employee. Ми навчилися створювати похідні класи за допомогою синтаксису class Derived : public Base та розуміти, що похідний об'єкт фізично містить у собі під'об'єкт базового класу.
Проте ми не розглянули фундаментальне питання: що відбувається під час створення та знищення об'єктів у ієрархіях? Якщо похідний клас містить у собі базовий як частину, то хто відповідає за ініціалізацію полів базового класу? У якому порядку викликаються конструктори та деструктори? Чи може похідний клас безпосередньо ініціалізувати поля предка?
Ці питання не виникали при роботі з автономними класами, але у світі наслідування вони стають критично важливими. Без розуміння життєвого циклу об'єктів у ієрархіях ми не зможемо коректно проєктувати конструктори, керувати ресурсами та уникати тонких помилок ініціалізації.
🎯 Мета статті
- Дослідити точний життєвий цикл об'єктів у ієрархіях: порядок виклику конструкторів (згори донизу) та деструкторів (знизу догори)
- Навчитися явно викликати конструктор базового класу через список ініціалізації (
Derived(...) : Base(...)) - Розібрати типову помилку компілятора за відсутності конструктора за замовчуванням у базовому класі
- Дослідити порядок конструювання та руйнування при багаторівневому наслідуванні (
A→B→C) - Засвоїти правило: похідний клас не повинен безпосередньо ініціалізувати поля предка в тілі свого конструктора
🔑 Ключові терміни
- Під'об'єкт базового класу (base class subobject): частина пам'яті похідного об'єкта, що відповідає базовому класу
- Поетапна ініціалізація (staged initialization): процес послідовного конструювання об'єктів ієрархії від предка до нащадка
- Виклик конструктора батька (parent constructor invocation): явна передача параметрів конструктору базового класу через список ініціалізації
- Правило безпосереднього батька (direct parent rule): похідний клас може викликати лише конструктор свого безпосереднього предка
Фізична структура похідного об'єкта
Перш ніж аналізувати життєвий цикл, нагадаємо фундаментальну властивість наслідування: похідний об'єкт не копіює члени базового класу — він містить у собі під'об'єкт базового класу як фізичну частину.
Розглянемо просту ієрархію:
class Person
{
private:
string name;
int age;
public:
Person(string n, int a) : name(n), age(a) {}
string getName() const { return name; }
int getAge() const { return age; }
};
class Student : public Person
{
private:
string studentId;
double gpa;
public:
Student(string n, int a, string id, double g)
: Person(n, a), studentId(id), gpa(g)
{}
string getStudentId() const { return studentId; }
double getGpa() const { return gpa; }
};
Коли ми створюємо об'єкт Student, компілятор виділяє в пам'яті достатньо місця для усіх полів: name та age (з Person) + studentId та gpa (власні поля Student). Концептуально об'єкт Student складається з двох логічних частин:
Student* може безпечно приводитися до Person* — адреса об'єкта збігається з адресою його першого поля (під'об'єкта Person).Чому фундамент будують раніше за стіни
Логіка поетапної ініціалізації ієрархій спирається на фундаментальний принцип: базовий клас повинен бути повністю сконструйований раніше похідного. Цей принцип не є довільним рішенням розробників C++ — він диктується реальними потребами безпеки та коректності.
Розглянемо причини, чому конструктор базового класу завжди викликається першим:
🔒 Гарантія інваріантів базового класу
Конструктор базового класу встановлює інваріанти (invariants) — умови, які завжди мають виконуватись для коректної роботи об'єкта. Наприклад, конструктор Person може забезпечити, що age ніколи не буде від'ємним, або що name не буде порожнім.
Якби похідний клас конструювався першим, він міг би викликати методи базового класу до того, як ці інваріанти встановлені — що призвело б до невизначеної поведінки.
⚙️ Похідний клас використовує інтерфейс базового
Конструктор похідного класу часто викликає публічні методи базового класу для виконання підготовчих операцій. Наприклад, конструктор Student може викликати Person::getName() для логування або валідації.
Ці виклики безпечні лише якщо базовий об'єкт уже повністю ініціалізований. Інакше метод працюватиме з неініціалізованими полями.
📦 Ефективність компіляції та виконання
Person усі поля name та age вже мають коректні значення — не потрібні додаткові перевірки валідності при кожному зверненні до них з конструктора Student.::
Правило просте: фундамент будують раніше за стіни. Базовий клас — це «фундамент» об'єкта, а похідний — «стіни», які на ньому зведені. Спроба побудувати стіни на неготовому фундаменті завершиться катастрофою.
Порядок виклику конструкторів: демонстрація
Продемонструємо порядок виклику конструкторів на простому прикладі. Додамо виведення повідомлень у конструктори, щоб побачити послідовність їхнього виконання:
#include <iostream>
using namespace std;
class Person
{
private:
string name;
int age;
public:
Person(string n, int a) : name(n), age(a)
{
cout << " Конструктор Person: " << name << ", вік " << age << "\n";
}
string getName() const { return name; }
int getAge() const { return age; }
};
class Student : public Person
{
private:
string studentId;
public:
Student(string n, int a, string id)
: Person(n, a), studentId(id)
{
cout << " Конструктор Student: ID " << studentId << "\n";
}
string getStudentId() const { return studentId; }
};
int main()
{
cout << "Створюємо автономний об'єкт Person:\n";
Person person("Олена Петрова", 42);
cout << "\nСтворюємо об'єкт Student:\n";
Student student("Іван Сидоренко", 20, "CS-2024-0517");
return 0;
}
Результат підтверджує послідовність:
- Створення
Person person(...): викликається лише конструкторPerson— це автономний клас без предків. - Створення
Student student(...): спочатку викликається конструкторPerson(для ініціалізації частиниPersonоб'єктаstudent), а потім — конструкторStudent(для ініціалізації власних полівStudent).
Person під'об'єкт Person вже повністю сформований: поля name та age проініціалізовані. У момент виконання конструктора Student частина Personвже готова, і конструктор Student може безпечно викликати getName(), getAge() та інші успадковані методи.Детальна послідовність створення об'єкта похідного класу
Розглянемо покроково, що відбувається під час виконання рядка Student student("Іван Сидоренко", 20, "CS-2024-0517");:
Крок 1. Виділення пам'яті
Компілятор обчислює розмір об'єкта Student: розмір базової частини Person (дві змінні: string name + int age) плюс розмір власних полів Student (string studentId).
Виділяється єдиний безперервний блок пам'яті, достатній для збереження всіх полів ієрархії. Цей блок розміщується на стеку (якщо об'єкт локальний) або в купі (якщо створений через new).
Крок 2. Виклик конструктора похідного класу
Компілятор викликає конструктор Student(string n, int a, string id) з аргументами "Іван Сидоренко", 20, "CS-2024-0517".
Виконання конструктора ще не почалося — компілятор лише встановлює контекст виклику та передає параметри.
Крок 3. Виконання списку ініціалізації: виклик конструктора базового класу
Компілятор аналізує список ініціалізації (Member Initializer List) конструктора Student:
Student(string n, int a, string id)
: Person(n, a), studentId(id) // ← Список ініціалізації
Перший запис у списку — Person(n, a) — явний виклик конструктора базового класу. Компілятор викликає Person(string n, int a) з аргументами "Іван Сидоренко" та 20.
Конструктор Person ініціалізує поля name і age, виконує своє тіло (виводить повідомлення) і завершує роботу. Тепер під'об'єкт Person усередині об'єкта student повністю готовий.
Крок 4. Ініціалізація власних полів похідного класу
Після завершення конструктора Person компілятор продовжує обробляти список ініціалізації. Наступний запис — studentId(id) — ініціалізує поле studentId значенням "CS-2024-0517".
Крок 5. Виконання тіла конструктора похідного класу
Після завершення списку ініціалізації виконується тіло конструктора Student:
{
cout << " Конструктор Student: ID " << studentId << "\n";
}
У цей момент усі поля об'єкта вже проініціалізовані: і частина Person, і власні поля Student. Тіло конструктора може безпечно з ними працювати.
Крок 6. Повернення управління
Конструктор Student завершує роботу. Виконання повертається до місця створення об'єкта (main()). Об'єкт student готовий до використання.
Стан об'єкта student після завершення конструктора
| Name | Type | Value |
|---|---|---|
| student | Student | {...} |
| Person | Person | {...} |
| name | std::string | "Іван Сидоренко" |
| age | int | 20 |
| studentId | std::string | "CS-2024-0517" |
Person(...).Наприклад, навіть якщо написати так:Student(string n, int a, string id)
: studentId(id), Person(n, a) // Person записаний другим
Person усе одно виконається першим. Порядок визначається порядком успадкування та оголошення полів, а не порядком записів у списку ініціалізації.Неявний виклик конструктора за замовчуванням базового класу
У попередньому прикладі ми явно викликали конструктор Person(string, int) через список ініціалізації:
Student(string n, int a, string id)
: Person(n, a), studentId(id) // Явний виклик Person(string, int)
Але що станеться, якщо не вказати виклик конструктора базового класу? Компілятор не може залишити базову частину об'єкта неініціалізованою — це порушило б гарантії C++. Тому компілятор автоматично викликає конструктор за замовчуванням базового класу.
Розглянемо приклад:
class Person
{
private:
string name;
int age;
public:
// Конструктор за замовчуванням
Person() : name("Невідомо"), age(0)
{
cout << " Person(): використано значення за замовчуванням\n";
}
Person(string n, int a) : name(n), age(a)
{
cout << " Person(string, int): " << name << ", вік " << age << "\n";
}
string getName() const { return name; }
int getAge() const { return age; }
};
class Student : public Person
{
private:
string studentId;
public:
// Не вказуємо виклик конструктора Person!
Student(string id) : studentId(id)
{
cout << " Student(string): ID " << studentId << "\n";
}
};
int main()
{
Student student("CS-2024-1001");
cout << "\nДані студента:\n";
cout << "Ім'я: " << student.getName() << "\n";
cout << "Вік: " << student.getAge() << "\n";
return 0;
}
Оскільки конструктор Student(string id) не містить явного виклику конструктора Person, компілятор автоматично вставляє виклик Person() — конструктора за замовчуванням. Результат: частина Person об'єкта student ініціалізована значеннями «Невідомо» та 0.
Проблема: відсутність конструктора за замовчуванням
А що станеться, якщо в базовому класі немає конструктора за замовчуванням, а похідний клас не викликає жодний конструктор базового класу явно?
Розглянемо приклад:
class Person
{
private:
string name;
int age;
public:
// Є лише параметризований конструктор, немає конструктора за замовчуванням
Person(string n, int a) : name(n), age(a)
{
cout << " Person(string, int): " << name << "\n";
}
string getName() const { return name; }
};
class Student : public Person
{
private:
string studentId;
public:
// Не вказуємо виклик Person(string, int)
Student(string id) : studentId(id) // ❌ Помилка компіляції!
{
cout << " Student(string): ID " << studentId << "\n";
}
};
Спроба скомпілювати цей код призведе до помилки:
Компілятор намагається неявно викликати Person() (конструктор за замовчуванням), але такого конструктора не існує. Є лише Person(string, int), який вимагає два аргументи.
Person з єдиним конструктором Person(string, int)не має конструктора за замовчуванням.Якщо похідний клас не викликає явно Person(string, int), компіляція завершується помилкою.Виправлення: завжди явно викликайте потрібний конструктор базового класу:
class Student : public Person
{
private:
string studentId;
public:
Student(string id, string name, int age)
: Person(name, age), studentId(id) // ✅ Явний виклик Person(string, int)
{
cout << " Student: ID " << studentId << "\n";
}
};
Тепер компіляція успішна:
Student student("CS-2024-1001", "Марія Коваленко", 19);
Ініціалізація полів базового класу: чому не можна це робити безпосередньо
Початківці часто намагаються ініціалізувати успадковані поля базового класу безпосередньо у списку ініціалізації похідного конструктора:
class Student : public Person
{
private:
string studentId;
public:
Student(string n, int a, string id)
: name(n), age(a), studentId(id) // ❌ Помилка компіляції!
{
}
};
Компілятор відхилить цей код із помилкою:
C++ категорично забороняє похідному класу ініціалізувати успадковані поля в своєму списку ініціалізації. Чому? З кількох причин:
🔐 Захист const-полів та посилань
Якщо поле базового класу оголошене як const або є посиланням (&), воно повинно бути ініціалізоване в списку ініціалізації конструктора базового класу — і лише там.
Якби похідний клас міг «переініціалізувати» це поле, константа змінювала б своє значення після створення, що порушує семантику const.
🎯 Гарантія однократної ініціалізації
⚙️ Інкапсуляція та приховування реалізації
Поля базового класу можуть бути private — похідний клас не має до них доступу. Навіть якщо поля protected або public, їх ініціалізація може включати складну логіку валідації, яка інкапсульована в конструкторі базового класу.
Дозволити похідному класу напряму ініціалізувати поля означало б порушити інкапсуляцію.
::
Правильний підхід: завжди викликати конструктор базового класу та передавати йому параметри:
Student(string n, int a, string id)
: Person(n, a), studentId(id) // ✅ Базовий клас сам ініціалізує свої поля
Альтернативний (але не рекомендований) підхід: присвоєння в тілі конструктора
Деякі початківці намагаються обійти обмеження списку ініціалізації, присвоюючи значення успадкованим полям у тілі конструктора похідного класу:
class Person
{
protected: // Змінили на protected, щоб похідний клас мав доступ
string name;
int age;
public:
Person() : name(""), age(0) {} // Потрібен конструктор за замовчуванням
string getName() const { return name; }
int getAge() const { return age; }
};
class Student : public Person
{
private:
string studentId;
public:
Student(string n, int a, string id) : studentId(id)
{
// Присвоєння в тілі конструктора
name = n;
age = a;
}
};
Цей код скомпілюється та працюватиме, але він має кілька серйозних недоліків:
⚠️ Неефективність
Поля name та age ініціалізуються двічі:
- Спочатку конструктор за замовчуванням
Person()ініціалізує їх значеннями""та0. - Потім тіло конструктора
Studentперезаписує ці значення черезname = n;таage = a;.
Для простих типів (int) це незначна витрата, але для складних типів (std::string, контейнери STL) це означає подвійне виділення пам'яті та копіювання даних.
🚫 Не працює з const-полями та посиланнями
name було б оголошено як const string name; або string& name;, цей підхід взагалі не скомпілювався б. Константи та посилання повинні бути ініціалізовані в списку ініціалізації — присвоєння в тілі конструктора заборонене.🔓 Порушення інкапсуляції
protected або public. Це порушує принцип інкапсуляції — поля базового класу стають доступними зовнішньому коду, а не лише через контрольований інтерфейс (геттери/сеттери).❓ Що якщо базовому класу потрібні ці дані під час ініціалізації?
Person виконує валідацію age (перевіряє, що вік не від'ємний) або логує створення об'єкта. Якщо похідний клас присвоює age після завершення конструктора Person, ця валідація та логування не спрацюють — вони виконались раніше, з неправильними значеннями.::
Висновок: завжди передавайте параметри конструктору базового класу через список ініціалізації. Присвоєння в тілі конструктора — це анти-патерн, який призводить до неефективності та тонких помилок.
Багаторівневе наслідування: ланцюжок викликів конструкторів
До цього моменту ми розглядали прості ієрархії з двома рівнями: Person → Student. Але реальні системи часто будують багаторівневі ієрархії (multi-level inheritance), де один клас успадковує від іншого, який, у свою чергу, успадковує від третього.
Розглянемо триланкову ієрархію:
class Person
{
protected:
string name;
public:
Person(string n) : name(n)
{
cout << " → Конструктор Person: " << name << "\n";
}
};
class Student : public Person
{
protected:
string studentId;
public:
Student(string n, string id) : Person(n), studentId(id)
{
cout << " → Конструктор Student: ID " << studentId << "\n";
}
};
class GraduateStudent : public Student
{
private:
string thesisTopic;
public:
GraduateStudent(string n, string id, string topic)
: Student(n, id), thesisTopic(topic)
{
cout << " → Конструктор GraduateStudent: тема \"" << thesisTopic << "\"\n";
}
};
int main()
{
cout << "Створюємо аспіранта:\n";
GraduateStudent grad("Олександр Мельник", "PhD-2025-042",
"Оптимізація алгоритмів машинного навчання");
return 0;
}
Порядок виклику конструкторів у багаторівневій ієрархії: від найвищого предка до найнижчого нащадка, як будівництво будинку поверх за поверхом:
GraduateStudent викликає Student(...), конструктор Student викликає Person(...). Конструктор GraduateStudentне може напряму викликати Person(...) — він навіть не знає, що Student успадковує від Person.Це правило безпосереднього батька (direct parent rule): кожен клас відповідає лише за передачу параметрів своєму безпосередньому предку, а не всім предкам у ланцюжку.Правило безпосереднього батька: чому не можна «перестрибнути» рівень
Початківці іноді намагаються оптимізувати код, напряму викликаючи конструктор «віддаленого предка»:
class GraduateStudent : public Student
{
public:
GraduateStudent(string n, string id, string topic)
: Person(n), thesisTopic(topic) // ❌ Спроба викликати Person напряму
{
}
};
Цей код не скомпілюється:
C++ забороняє похідному класу викликати конструктори класів, від яких він не успадковує безпосередньо. GraduateStudent успадковує від Student, а не від Person. Тому GraduateStudent може викликати лише конструктори Student.
Чому це обмеження існує?
🔒 Гарантія послідовності ініціалізації
GraduateStudent міг викликати Person(...) напряму, проміжний клас Student залишився б неініціалізованим. Поле studentId класу Student не мало б значення, що призвело б до невизначеної поведінки при спробі викликати getStudentId().📦 Інкапсуляція проміжних класів
GraduateStudent не повинен знати деталі реалізації Person. Він знає лише про свій безпосередній базовий клас — Student. Якщо Student вирішить змінити свою базу (наприклад, успадкувати від іншого класу замість Person), код GraduateStudent не повинен ламатися.Правильний підхід: кожен клас передає параметри своєму безпосередньому батьку, а той — своєму батьку, і так далі:
GraduateStudent(string n, string id, string topic)
: Student(n, id), thesisTopic(topic) // ✅ Викликаємо лише безпосереднього батька
Клас Student сам вирішить, як передати параметри конструктору Person.
Порядок деструкторів: дзеркальне відображення конструкторів
Тепер розглянемо знищення об'єктів у ієрархіях. Коли об'єкт похідного класу виходить зі scope або видаляється через delete, деструктори викликаються у зворотному порядку відносно конструкторів: від нащадка до предка.
Продемонструємо це на попередньому прикладі, додавши деструктори:
#include <iostream>
using namespace std;
class Person
{
protected:
string name;
public:
Person(string n) : name(n)
{
cout << " → Конструктор Person: " << name << "\n";
}
~Person()
{
cout << " ← Деструктор Person: " << name << "\n";
}
};
class Student : public Person
{
protected:
string studentId;
public:
Student(string n, string id) : Person(n), studentId(id)
{
cout << " → Конструктор Student: ID " << studentId << "\n";
}
~Student()
{
cout << " ← Деструктор Student: ID " << studentId << "\n";
}
};
class GraduateStudent : public Student
{
private:
string thesisTopic;
public:
GraduateStudent(string n, string id, string topic)
: Student(n, id), thesisTopic(topic)
{
cout << " → Конструктор GraduateStudent: \"" << thesisTopic << "\"\n";
}
~GraduateStudent()
{
cout << " ← Деструктор GraduateStudent: \"" << thesisTopic << "\"\n";
}
};
int main()
{
cout << "Створюємо аспіранта:\n";
{
GraduateStudent grad("Олександр Мельник", "PhD-2025-042",
"Оптимізація алгоритмів");
cout << "\n(об'єкт grad існує)\n\n";
}
cout << "Об'єкт grad знищено (вийшов зі scope)\n";
return 0;
}
Порядок деструкції — дзеркальне відображення порядку конструкції:
- Спочатку викликається деструктор
GraduateStudent(найнижчого класу ієрархії). - Потім деструктор
Student. - Нарешті деструктор
Person(найвищого класу ієрархії).
Чому деструктори викликаються у зворотному порядку? З тих самих причин, що й конструктори — але навпаки:
- Похідний клас використовує ресурси базового класу. Поки виконується деструктор
GraduateStudent, він може викликати методиStudentтаPersonдля звільнення ресурсів. Якби деструкторPersonвиконався першим, об'єкт був би частково знищений, і виклики методів призвели б до невизначеної поведінки. - Правило RAII: ресурси звільняються у зворотному порядку відносно захоплення. Якщо конструктор
Studentвідкрив файл, а конструкторGraduateStudentзахопив м'ютекс, то спочатку потрібно звільнити м'ютекс (деструкторGraduateStudent), а потім закрити файл (деструкторStudent).
Практичний приклад: ієрархія транспортних засобів
Закріпимо знання на реалістичному прикладі. Побудуємо ієрархію транспортних засобів з правильною ініціалізацією всіх рівнів:
#include <iostream>
using namespace std;
// Базовий клас: транспортний засіб
class Vehicle
{
private:
string manufacturer;
int yearOfManufacture;
public:
Vehicle(string manuf, int year)
: manufacturer(manuf), yearOfManufacture(year)
{
cout << " [Vehicle] створено: " << manufacturer
<< ", рік " << yearOfManufacture << "\n";
}
~Vehicle()
{
cout << " [Vehicle] знищено: " << manufacturer << "\n";
}
string getManufacturer() const { return manufacturer; }
int getYear() const { return yearOfManufacture; }
};
// Проміжний клас: автомобіль
class Car : public Vehicle
{
private:
int numberOfDoors;
string fuelType;
public:
Car(string manuf, int year, int doors, string fuel)
: Vehicle(manuf, year), numberOfDoors(doors), fuelType(fuel)
{
cout << " [Car] створено: " << doors << " дверей, паливо: "
<< fuel << "\n";
}
~Car()
{
cout << " [Car] знищено: " << numberOfDoors << " дверей\n";
}
int getDoors() const { return numberOfDoors; }
string getFuelType() const { return fuelType; }
};
// Конкретний клас: електромобіль
class ElectricCar : public Car
{
private:
int batteryCapacity; // Ємність батареї в кВт·год
public:
ElectricCar(string manuf, int year, int doors, int battery)
: Car(manuf, year, doors, "Електрика"),
batteryCapacity(battery)
{
cout << " [ElectricCar] створено: батарея " << battery << " кВт·год\n";
}
~ElectricCar()
{
cout << " [ElectricCar] знищено: батарея " << batteryCapacity
<< " кВт·год\n";
}
int getBatteryCapacity() const { return batteryCapacity; }
};
int main()
{
cout << "=== Створення електромобіля ===\n\n";
{
ElectricCar tesla("Tesla", 2024, 4, 75);
cout << "\n--- Використання об'єкта ---\n";
cout << "Виробник: " << tesla.getManufacturer() << "\n";
cout << "Рік випуску: " << tesla.getYear() << "\n";
cout << "Кількість дверей: " << tesla.getDoors() << "\n";
cout << "Тип палива: " << tesla.getFuelType() << "\n";
cout << "Ємність батареї: " << tesla.getBatteryCapacity() << " кВт·год\n";
cout << "\n=== Вихід зі scope — знищення об'єкта ===\n\n";
}
cout << "\n=== Програма завершена ===\n";
return 0;
}
Цей приклад демонструє всі ключові принципи:
- Явна передача параметрів: кожен конструктор явно викликає конструктор свого безпосереднього батька та передає йому потрібні параметри.
- Правило безпосереднього батька:
ElectricCarвикликаєCar(...),CarвикликаєVehicle(...).ElectricCarне знає про існуванняVehicle. - Порядок конструювання: від
VehicleдоCarдоElectricCar— фундамент перед стінами. - Порядок руйнування: від
ElectricCarдоCarдоVehicle— стіни перед фундаментом. - Доступ до всіх рівнів ієрархії: об'єкт
teslaможе викликати методиgetManufacturer()(зVehicle),getDoors()(зCar) таgetBatteryCapacity()(зElectricCar).
Типові помилки та як їх уникнути
❌ Помилка 1: Забули викликати конструктор базового класу
class Student : public Person
{
public:
Student(string n, int a, string id) : studentId(id) // Забули Person(n, a)
{
}
};
Наслідок: якщо в Person немає конструктора за замовчуванням — помилка компіляції. Якщо є — поля базового класу ініціалізовані невірно.
Виправлення: завжди явно викликати потрібний конструктор базового класу:
Student(string n, int a, string id) : Person(n, a), studentId(id) {}
❌ Помилка 2: Спроба ініціалізувати поля базового класу в списку ініціалізації
class Student : public Person
{
public:
Student(string n, int a, string id)
: name(n), age(a), studentId(id) // ❌ name і age — не члени Student!
{
}
};
Наслідок: помилка компіляції — Student не має полів name та age.
Виправлення: передати параметри конструктору Person:
Student(string n, int a, string id) : Person(n, a), studentId(id) {}
❌ Помилка 3: Спроба викликати конструктор «віддаленого предка»
class GraduateStudent : public Student
{
public:
GraduateStudent(string n, string id, string topic)
: Person(n), thesisTopic(topic) // ❌ Person — не прямий батько!
{
}
};
Наслідок: помилка компіляції — Person не є безпосереднім базовим класом GraduateStudent.
Виправлення: викликати конструктор безпосереднього батька Student:
GraduateStudent(string n, string id, string topic)
: Student(n, id), thesisTopic(topic) {}
❌ Помилка 4: Присвоєння полям базового класу в тілі конструктора
class Student : public Person
{
public:
Student(string n, int a, string id) : studentId(id)
{
name = n; // ❌ Неефективно, не працює з const/посиланнями
age = a;
}
};
Наслідок: неефективність (подвійна ініціалізація), не працює з const-полями.
Виправлення: завжди передавати параметри конструктору базового класу:
Student(string n, int a, string id) : Person(n, a), studentId(id) {}
Практичне завдання
Створіть ієрархію класів для моделювання публікацій у бібліотеці:
- Базовий клас
Publicationмає поля:title(назва,string),year(рік видання,int). - Похідний клас
BookуспадковуєPublicationта додає полеauthor(автор,string). - Похідний клас
MagazineуспадковуєPublicationта додає полеissueNumber(номер випуску,int).
Вимоги:
- Усі поля мають бути
private. - Кожен клас має конструктор, що ініціалізує всі поля (власні + успадковані).
- Кожен клас має геттери для всіх полів.
- Додайте в кожен конструктор та деструктор виведення повідомлення, щоб побачити порядок викликів.
Тестовий код:
int main()
{
cout << "=== Створення книги ===\n";
Book book("Чистий код", 2008, "Роберт Мартін");
cout << "\nДані книги:\n";
cout << "Назва: " << book.getTitle() << "\n";
cout << "Рік: " << book.getYear() << "\n";
cout << "Автор: " << book.getAuthor() << "\n";
cout << "\n=== Створення журналу ===\n";
Magazine magazine("National Geographic", 2025, 3);
cout << "\nДані журналу:\n";
cout << "Назва: " << magazine.getTitle() << "\n";
cout << "Рік: " << magazine.getYear() << "\n";
cout << "Номер випуску: " << magazine.getIssueNumber() << "\n";
cout << "\n=== Знищення об'єктів ===\n";
return 0;
}
Очікуваний вивід:
=== Створення книги ===
[Publication] створено: Чистий код, 2008
[Book] створено: автор Роберт Мартін
Дані книги:
Назва: Чистий код
Рік: 2008
Автор: Роберт Мартін
=== Створення журналу ===
[Publication] створено: National Geographic, 2025
[Magazine] створено: випуск #3
Дані журналу:
Назва: National Geographic
Рік: 2025
Номер випуску: 3
=== Знищення об'єктів ===
[Magazine] знищено: випуск #3
[Publication] знищено: National Geographic
[Book] знищено: автор Роберт Мартін
[Publication] знищено: Чистий код
#include <iostream>
using namespace std;
class Publication
{
private:
string title;
int year;
public:
Publication(string t, int y) : title(t), year(y)
{
cout << " [Publication] створено: " << title << ", " << year << "\n";
}
~Publication()
{
cout << " [Publication] знищено: " << title << "\n";
}
string getTitle() const { return title; }
int getYear() const { return year; }
};
class Book : public Publication
{
private:
string author;
public:
Book(string t, int y, string a) : Publication(t, y), author(a)
{
cout << " [Book] створено: автор " << author << "\n";
}
~Book()
{
cout << " [Book] знищено: автор " << author << "\n";
}
string getAuthor() const { return author; }
};
class Magazine : public Publication
{
private:
int issueNumber;
public:
Magazine(string t, int y, int issue)
: Publication(t, y), issueNumber(issue)
{
cout << " [Magazine] створено: випуск #" << issueNumber << "\n";
}
~Magazine()
{
cout << " [Magazine] знищено: випуск #" << issueNumber << "\n";
}
int getIssueNumber() const { return issueNumber; }
};
int main()
{
cout << "=== Створення книги ===\n";
Book book("Чистий код", 2008, "Роберт Мартін");
cout << "\nДані книги:\n";
cout << "Назва: " << book.getTitle() << "\n";
cout << "Рік: " << book.getYear() << "\n";
cout << "Автор: " << book.getAuthor() << "\n";
cout << "\n=== Створення журналу ===\n";
Magazine magazine("National Geographic", 2025, 3);
cout << "\nДані журналу:\n";
cout << "Назва: " << magazine.getTitle() << "\n";
cout << "Рік: " << magazine.getYear() << "\n";
cout << "Номер випуску: " << magazine.getIssueNumber() << "\n";
cout << "\n=== Знищення об'єктів ===\n";
return 0;
}
Ключові моменти розв'язку:
- Кожен конструктор явно викликає конструктор свого безпосереднього батька (
Publication(t, y)) та передає йому потрібні параметри. - Усі поля
private— доступ лише через геттери. - Деструктори викликаються автоматично у зворотному порядку: спочатку
~Magazine/~Book, потім~Publication.
Резюме
У цій статті ми дослідили життєвий цикл об'єктів у ієрархіях наслідування — від моменту створення до знищення. Тепер ви розумієте фундаментальні правила роботи конструкторів та деструкторів у багаторівневих ієрархіях і можете коректно проєктувати ініціалізацію складних об'єктних структур.
🎯 Головні висновки
- Поетапна ініціалізація: об'єкти ієрархії конструюються від предка до нащадка — фундамент будують раніше за стіни
- Явний виклик конструктора батька: завжди передавайте параметри конструктору базового класу через список ініціалізації:
Derived(...) : Base(...), field(...) {} - Правило безпосереднього батька: кожен клас може викликати лише конструктор свого прямого батька, а не «віддалених предків»
- Зворотний порядок деструкції: деструктори викликаються від нащадка до предка — стіни руйнують раніше за фундамент
⚠️ Що запам'ятати
- C++ забороняє похідному класу ініціалізувати успадковані поля безпосередньо в своєму списку ініціалізації
- Якщо в базовому класі немає конструктора за замовчуванням, похідний клас зобов'язаний явно викликати один з параметризованих конструкторів базового класу
- Присвоєння успадкованим полям у тілі конструктора — антипатерн: неефективно та не працює з
const-полями - Деструктори базових класів викликаються автоматично — їх не потрібно (і не можна) викликати явно
🔜 Наступна стаття
protected та види наслідування (public, protected, private). Ви дізнаєтесь, як контролювати доступ похідних класів до членів базового класу та чим відрізняється публічне наслідування від приватного.Вступ до наслідування: базові поняття та відношення «is-a»
Другий стовп ООП — наслідування (inheritance). Відношення «є різновидом» (is-a), побудова ієрархій, синтаксис public наслідування, ліквідація дублювання коду, фізична структура успадкованого об'єкта.
Специфікатор `protected` та види наслідування
Третій специфікатор доступу protected та його роль у наслідуванні. Три види наслідування: public, private, protected. Як тип наслідування змінює доступ до успадкованих членів. Зведена таблиця специфікаторів та правила вибору типу наслідування.