1. Вступ
Коли клас стає бодай трохи корисним, швидко зʼясовується: створювати його лише «в одному-єдиному форматі» незручно. Користувачеві класу — зокрема й вам самим за тиждень — то треба створити обʼєкт із повними даними, то з мінімальними, то просто «порожню» заглушку. І тут рука сама тягнеться написати 3–4 конструктори… а далі починається копіпаст.
Уявіть, що ви пишете клас задачі для консольного планувальника — нашого навчального проєкту. У задачі є заголовок і пріоритет. Один конструктор — «повна форма», другий — «заголовок, пріоритет за замовчуванням», третій — «порожня задача». Якщо в кожному з них вручну повторювати ті самі перевірки й присвоювання, рано чи пізно вони розʼїдуться. Це не філософія, а статистика.
Щоб відчути проблему, давайте подивимося на «наївний» варіант:
#include <string>
class Task {
std::string title_;
int priority_;
public:
Task(std::string title, int priority) {
if (title.empty()) title = "без назви";
if (priority < 1) priority = 1;
if (priority > 5) priority = 5;
title_ = title;
priority_ = priority;
}
Task(std::string title) {
if (title.empty()) title = "без назви";
int priority = 3;
title_ = title;
priority_ = priority;
}
};
Код іще не виглядає критично погано, але вже підозріло нагадує копіпаст. А тепер уявіть, що ви додаєте третє правило: «обрізаємо пробіли на краях» або «забороняємо заголовок довший за 80». В одному конструкторі це правило зʼявилося, в іншому — ні… і понеслося.
2. Делегувальні конструктори: ідея й порядок виконання
Ідея людською мовою
Делегувальний конструктор — це спосіб сказати компілятору: «я хочу створити обʼєкт ось так, а інший конструктор — лише зручна обгортка». Тобто один конструктор стає головним, а решта — короткими обгортками, що перенаправляють виклик до нього.
За логікою це дуже схоже на хорошу звичку зі звичайних функцій: не розмазувати логіку по 5 місцях, а зробити одну функцію, яка виконує роботу, і кілька маленьких функцій, що готують аргументи та викликають її. Тільки тут ідеться про конструктори.
Синтаксис делегування виглядає так:
ClassName(args_for_this_ctor) : ClassName(args_for_other_ctor) {
// тіло (виконується ПІСЛЯ делегованого конструктора)
}
Так, конструктор викликає інший конструктор того самого класу. І ні, це не рекурсія в стилі «змійка, що кусає себе за хвіст». Якщо все зроблено правильно, ланцюжок закінчується в «головному» конструкторі, який справді ініціалізує поля.
Головне правило й порядок виконання
Коли ви вперше бачите делегування, легко уявити хибну картину: ніби обидва конструктори «якось разом» ініціалізують поля. Насправді все простіше й суворіше: рівно один конструктор відповідає за створення полів, а другий лише перенаправляє виклик.
У делегувального конструктора є ключова особливість: спочатку виконується той конструктор, до якого ми делегували, — саме він ініціалізує обʼєкт. І лише потім виконується тіло делегувального конструктора, якщо воно є.
Ось компактна схема:
flowchart TD
A["Task(title)"] --> B[": Task(title, 3)"]
B --> C["Task(title, priority): ініціалізує поля"]
C --> D["Тіло Task(title) (якщо є)"]
Якщо ви запамʼятаєте цю схему, то уникнете половини типових помилок.
3. Приклади: Rectangle і Task
Rectangle: квадрат як окремий випадок прямокутника
Перш ніж переходити до нашого проєкту, розімнімося на прикладі, який не потребує додаткового контексту. У прямокутника є ширина й висота. Окремий випадок — квадрат, у якого ширина дорівнює висоті. Найгірше, що тут можна зробити, — продублювати ініціалізацію в двох конструкторах. Найкраще — делегувати.
class Rectangle {
int w_;
int h_;
public:
Rectangle(int w, int h) : w_(w), h_(h) {}
explicit Rectangle(int side) : Rectangle(side, side) {}
};
Зверніть увагу на дві речі.
По-перше, конструктор Rectangle(int side) не ініціалізує w_ і h_ напряму — він делегує до «головного» Rectangle(int w, int h).
По-друге, такий код чудово читається: ми одразу бачимо намір автора — «квадрат створюється як прямокутник зі сторонами side, side». Це майже як коментар, тільки компілятор ще й стежить, щоб ви не зламали логіку.
Практичний приклад: клас Task для консольного застосунку TaskTracker
Тепер зробімо приклад ближчим до реальності: задача (task) у нашому умовному консольному застосунку TaskTracker. Навіть якщо у вашому курсі проєкт називається інакше, це не страшно: важлива сама ідея — «модель + коректне створення».
Нехай у задачі є заголовок і пріоритет від 1 до 5. Наша мета — зробити так, щоб будь-яка задача після створення була валідною: заголовок не порожній, а пріоритет лежить у потрібному діапазоні.
Версія без делегування
Спочатку — «як часто роблять на початку»:
#include <string>
class Task {
std::string title_;
int priority_;
public:
Task(std::string title, int priority) {
if (title.empty()) title = "без назви";
if (priority < 1) priority = 1;
if (priority > 5) priority = 5;
title_ = title;
priority_ = priority;
}
Task(std::string title) {
if (title.empty()) title = "без назви";
int priority = 3;
title_ = title;
priority_ = priority;
}
};
Проблема тут не лише в копіпасті. Є й менш очевидна річ: поля спершу створюються, а вже потім ми щось їм присвоюємо. Для int це не так критично, але для складніших типів, як-от рядки чи вектори, ми втрачаємо частину переваг ініціалізації: обʼєкт уже створений, а ми потім ніби «перевдягаємо» його.
Головний конструктор + делегування
Зробімо головний конструктор, який приймає все потрібне, приводить значення до норми й ініціалізує поля. А решта нехай просто делегує.
#include <string>
#include <utility>
class Task {
std::string title_;
int priority_;
static int clamp_priority(int p) {
if (p < 1) return 1;
if (p > 5) return 5;
return p;
}
static std::string normalize_title(std::string t) {
if (t.empty()) return "без назви";
return t;
}
public:
Task(std::string title, int priority)
: title_(normalize_title(std::move(title)))
, priority_(clamp_priority(priority))
{}
explicit Task(std::string title)
: Task(std::move(title), 3)
{}
Task()
: Task("без назви", 3)
{}
};
Тут одразу тішить кілька речей, але найголовніше — правила валідності живуть в одному місці: у головному конструкторі та його маленьких помічниках. Хочете змінити діапазон пріоритетів на 1..10? Змінюєте clamp_priority, і весь застосунок поводиться однаково.
Ще одна корисна деталь: делегувальні конструктори тут «тонкі». Вони не намагаються повторити в тілі те, що й без того зробить головний конструктор. Вони просто добирають аргументи.
Швидка перевірка в main()
Додамо невеликий main(), щоб побачити, що все працює.
#include <iostream>
#include <string>
int main() {
Task a; // title="без назви", priority=3
Task b{"", 100}; // title="без назви", priority=5
Task c{"Прочитати книжку"}; // title="Прочитати книжку", priority=3
std::cout << "Завдання створено.\n"; // Завдання створено.
}
Ми поки що не виводимо поля (у нас іще немає operator<< для класу — це буде в наступних темах курсу), але вже важливо, що створення не ламається й не потребує «другого кроку налаштування».
Тіло делегувального конструктора: коли воно потрібне
У більшості випадків делегувальний конструктор має бути «майже порожнім». Але інколи хочеться додати трохи додаткової логіки після делегування — наприклад, логування для навчання або просту діагностику.
Важливо розуміти: у делегувальному конструкторі тіло виконується після того, як відпрацював головний конструктор і обʼєкт уже ініціалізований. Це означає, що в тілі ви можете безпечно звертатися до полів: вони вже існують у коректному стані.
Наприклад:
#include <iostream>
#include <string>
#include <utility>
class Task {
std::string title_;
int priority_;
public:
Task(std::string title, int priority)
: title_(title.empty() ? "без назви" : std::move(title))
, priority_(priority < 1 ? 1 : (priority > 5 ? 5 : priority))
{}
Task(std::string title)
: Task(std::move(title), 3)
{
std::cout << "Завдання створено з пріоритетом за замовчуванням.\n";
// Завдання створено з пріоритетом за замовчуванням.
}
};
У реальному коді ви навряд чи друкували б щось із конструктора в консоль: користувач не зобовʼязаний знати, що саме відбувається всередині. Але для навчання це хороший спосіб відчути порядок виконання.
Делегування та інваріанти: ті самі правила без розсинхронізації
Конструктор — це точка входу в коректний стан обʼєкта. Але якщо у вас 3 конструктори, і кожен «по-своєму» нормалізує дані, то у вас насправді не один клас, а три різні версії однієї й тієї самої сутності. І тоді доводиться ловити баги, які виглядають майже магічно: «чому задача, створена через Task(), поводиться інакше, ніж задача, створена через Task(title)?»
Делегування розвʼязує це просто: змушує вас вибрати одне місце, де визначаються правила. Навіть якщо ви не любите теорію, це дуже практично: менше місць — менше шансів щось забути.
Невеличка табличка «до / після»:
| Підхід | Що відбувається | Чим ризикуємо |
|---|---|---|
| Кілька «самостійних» конструкторів | У кожному — власні перевірки й присвоювання | Розсинхронізація правил, копіпаст, зміни вносити складно |
| Головний конструктор + делегування | Один конструктор задає правила, решта добирає параметри | Код коротший, а зміни вносяться в одному місці |
4. Обмеження й антиприклади
Делегувальні конструктори потужні, але не «магічні». У них є кілька правил, які простіше сприйняти як «закони фізики», ніж намагатися обійти.
Делегувальний конструктор не може одночасно делегувати до іншого конструктора й окремо ініціалізувати поля в списку ініціалізації. Тобто не можна написати щось на кшталт «делегую і ще тут підкручу поле». Логіка проста: якщо ви делегуєте, то повністю передаєте відповідальність за ініціалізацію.
Ще один важливий момент: делегування має мати кінцеву точку. Якщо ви побудуєте ланцюжок, який ходить по колу, компілятор не «здогадається, що ви мали на увазі», а просто чесно скаже: «ні».
Поганий приклад — так робити не треба:
class Bad {
public:
Bad() : Bad(0) {} // делегуємо
Bad(int) : Bad() {} // делегуємо назад (цикл)
};
Це концептуально схоже на історію «я пішов у магазин по хліб, але повернувся додому по список, а список залишив у магазині». Сюжет захопливий, але хліба не буде.
5. Типові помилки
Помилка №1: копіпаст перевірок лишається, тільки тепер він «гарно оформлений».
Іноді студент пише делегувальні конструктори, але все одно залишає перевірки й нормалізацію в кожному з них «про всяк випадок». У підсумку маємо дві проблеми одразу: і делегування начебто є, і розсинхронізація все ще можлива. Хороший стиль тут такий: один головний конструктор містить правила, а решта лише готує аргументи й передає керування далі.
Помилка №2: спроба «трішки доініціалізувати поля» в списку ініціалізації делегувального конструктора.
Після появи делегування виникає спокуса написати: Task() : Task("untitled", 3), priority_(5) {} — мовляв, «ну я ж просто зміню пріоритет». Так не можна: або ви делегуєте, або ініціалізуєте поля напряму. Якщо потрібно змінити значення, змінюйте аргументи делегування або робіть це в тілі, якщо це справді має сенс і не ламає інваріанти.
Помилка №3: цикл делегування.
Цикл може бути очевидним, як у прикладі Bad, а може ховатися в ланцюжку з трьох конструкторів. Компілятор цього не схвалить. Практичне правило просте: у ланцюжка делегування має бути «остання станція» — конструктор, який реально ініціалізує поля й нікуди далі не делегує.
Помилка №4: спроба виконати важливу логіку «до делегування».
Іноді хочеться «спочатку щось порахувати, а потім делегувати». Але делегування записується у списку ініціалізації, а він виконується до тіла конструктора. Тобто якщо вам потрібно підготувати параметри, робіть це або через окремі static-функції чи функції-помічники (як normalize_title), або змінюйте дизайн конструктора так, щоб головний конструктор приймав уже готові дані.
Помилка №5: делегування перетворюють на «листковий пиріг» із побічними ефектами.
Якщо в делегувальних конструкторах починають зʼявлятися виведення, записи у файли, зміна зовнішніх обʼєктів та інші ефекти, порядок викликів стає надто важливим, а код — крихким. Для навчальних цілей std::cout допустимий, але як звичку проєктування краще тримати делегувальні конструктори максимально «тонкими» й передбачуваними.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ