JavaRush /Курси /C++ SELF /Делегувальні конструктори: усуваємо дублювання

Делегувальні конструктори: усуваємо дублювання

C++ SELF
Рівень 49 , Лекція 0
Відкрита

1. Вступ

Коли клас стає бодай трохи корисним, швидко зʼясовується: створювати його лише «в одному-єдиному форматі» незручно. Користувачеві класу — зокрема й вам самим за тиждень — то треба створити обʼєкт із повними даними, то з мінімальними, то просто «порожню» заглушку. І тут рука сама тягнеться написати 34 конструктори… а далі починається копіпаст.

Уявіть, що ви пишете клас задачі для консольного планувальника — нашого навчального проєкту. У задачі є заголовок і пріоритет. Один конструктор — «повна форма», другий — «заголовок, пріоритет за замовчуванням», третій — «порожня задача». Якщо в кожному з них вручну повторювати ті самі перевірки й присвоювання, рано чи пізно вони розʼїдуться. Це не філософія, а статистика.

Щоб відчути проблему, давайте подивимося на «наївний» варіант:

#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 допустимий, але як звичку проєктування краще тримати делегувальні конструктори максимально «тонкими» й передбачуваними.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ