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 = "untitled";
        if (priority < 1) priority = 1;
        if (priority > 5) priority = 5;

        title_ = title;
        priority_ = priority;
    }

    Task(std::string title) {
        if (title.empty()) title = "untitled";
        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 = "untitled";
        if (priority < 1) priority = 1;
        if (priority > 5) priority = 5;

        title_ = title;
        priority_ = priority;
    }

    Task(std::string title) {
        if (title.empty()) title = "untitled";
        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 "untitled";
        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("untitled", 3)
    {}
};

Здесь приятно сразу несколько вещей, но самое главное — правила валидности живут в одном месте: в главном конструкторе и его маленьких помощниках. Хотите поменять диапазон приоритетов на 1..10? Меняете clamp_priority, и всё приложение ведёт себя одинаково.

Ещё полезная деталь: делегирующие конструкторы тут «тонкие». Они не пытаются повторить в теле то, что и так сделает главный конструктор. Они просто подбирают аргументы.

Быстрая проверка в main()

Добавим небольшой main(), чтобы увидеть, что всё работает.

#include <iostream>
#include <string>

int main() {
    Task a;                   // title="untitled", priority=3
    Task b{"", 100};          // title="untitled", priority=5
    Task c{"Read a book"};    // title="Read a book", priority=3

    std::cout << "Tasks created.\n"; // Tasks created.
}

Мы пока не печатаем поля (у нас ещё нет operator<< для класса — это будет в следующих темах дня/курса), но уже важно, что создание не разваливается и не требует «второго шага настройки».

Тело делегирующего конструктора: когда оно нужно

В большинстве случаев делегирующий конструктор должен быть «почти пустым». Но иногда хочется сделать маленькую дополнительную логику после делегирования — например, логирование (для обучения) или простую диагностику.

Важно понимать: в делегирующем конструкторе тело выполняется после того, как отработал главный конструктор и объект уже инициализирован. Это значит, что в теле вы можете безопасно обращаться к полям: они уже существуют в корректном состоянии.

Например:

#include <iostream>
#include <string>
#include <utility>

class Task {
    std::string title_;
    int priority_;
public:
    Task(std::string title, int priority)
        : title_(title.empty() ? "untitled" : std::move(title))
        , priority_(priority < 1 ? 1 : (priority > 5 ? 5 : priority))
    {}

    Task(std::string title)
        : Task(std::move(title), 3)
    {
        std::cout << "Task created with default priority.\n";
        // Task created with default priority.
    }
};

В реальном продакшене вы бы не печатали из конструктора в консоль (пользователь не обязан знать, что вы там внутри «делегировали»), но для обучения это хороший способ почувствовать порядок выполнения.

Делегирование и инварианты: одни и те же правила без рассинхронизации

Конструктор — это точка входа в валидное состояние. Но если у вас 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 допустим, но как привычка проектирования лучше держать делегирующие конструкторы максимально «тонкими» и предсказуемыми.

1
Задача
C++ SELF, 49 уровень, 0 лекция
Недоступна
Квадратный виджет
Квадратный виджет
1
Задача
C++ SELF, 49 уровень, 0 лекция
Недоступна
Шаблон книги
Шаблон книги
1
Задача
C++ SELF, 49 уровень, 0 лекция
Недоступна
Умный календарь
Умный календарь
1
Задача
C++ SELF, 49 уровень, 0 лекция
Недоступна
Профиль новичка
Профиль новичка
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ