JavaRush /Курсы /C++ SELF /=default — генерируем операции как часть API

=default — генерируем операции как часть API

C++ SELF
45 уровень , 0 лекция
Открыта

1. Зачем нужен =default, если «и так работает»?

Когда вы только начинаете писать свои struct, кажется, что язык и так заботится о вас: поля копируются, строки не текут, векторы сами освобождают память, жизнь прекрасна. Но потом вы добавляете «маленькое улучшение» — например, конструктор с параметром — и внезапно обнаруживаете, что T t; больше не компилируется. Или вы хотите, чтобы любой читающий код видел: «да, этот тип можно копировать, и копирование — обычное». Вот тут =default становится вашим способом разговаривать с компилятором и коллегами на одном языке.

=default — это не «сделай пустую функцию». Это команда: «сгенерируй стандартную реализацию этой функции так, как ты бы сделал это автоматически». То есть вы не придумываете логику сами — вы подтверждаете, что стандартная логика вам подходит, и фиксируете это в API типа.

Практически =default чаще всего применяется к спец-функциям-членам:

Функция О чём речь Когда вызывается
T() = default;
конструктор по умолчанию T x;, T x{}; (в разных контекстах)
~T() = default;
деструктор при завершении времени жизни объекта
T(const T&) = default;
копирующий конструктор T b = a;
T& operator=(const T&) = default;
копирующее присваивание b = a;

(Иногда сюда добавляют ещё «перемещающие» операции, но их мы сегодня сознательно не трогаем, чтобы не забегать в следующий день.)

2. Синтаксис =default и идея «контракта»

В программировании вообще удобно иметь «короткую запись», которая говорит: «сделай правильно и стандартно». В C++ это именно =default. Психологически это похоже на ситуацию, когда вы не пишете свою реализацию сортировки пузырьком, а доверяете стандартному std::sort. Только здесь роль std::sort играет компилятор.

Синтаксис выглядит одинаково просто для всех спец-функций:


struct T {
    T() = default;
    ~T() = default;
    T(const T&) = default;
    T& operator=(const T&) = default;
};

Здесь важно не перепутать две вещи.

Во-первых, =default — это не «поставить пустые фигурные скобки». Пустое тело ({}) означает: «я написал свою функцию, она вот такая». А =default означает: «сгенерируй стандартную версию, как ты умеешь». Иногда разница действительно ощущается только в тонких свойствах типа, но для проектирования интерфейса она принципиальна: =default — маркер намерения.

Во-вторых, =default не «ломает физику». Если стандартная версия функции не может существовать из-за состава полей (например, у вас поле non-copyable), то =default не превращается в волшебную палочку. Максимум — даст вам более прямой и честный контракт в коде: «мы хотели стандартное поведение, но оно недоступно».

3. Как вернуть конструктор по умолчанию

Самая частая причина познакомиться с =default не из книжки, а из жизни — ситуация «я добавил конструктор, и всё сломалось». В C++ есть правило: если вы объявили любой пользовательский конструктор, компилятор больше не обязан предоставлять вам конструктор по умолчанию автоматически.

Давайте вплетём это в наше учебное мини-приложение. Пусть мы пишем простенький консольный менеджер задач TaskBox, и у нас есть настройки приложения.

Сначала — «наивная» версия:

#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";
};

Эта версия прекрасно создаётся как AppConfig cfg{}; и вообще живёт спокойно.

Теперь мы хотим дать возможность указать путь явно:

#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";

    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

И вот тут новичок часто удивляется: «Почему AppConfig cfg; вдруг стал проблемой?» Ответ: потому что мы вмешались в модель создания объекта. И если нам всё же нужен объект «по умолчанию», мы должны сказать это компилятору явно — самым честным способом:

#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";

    AppConfig() = default; // возвращаем "обычное создание"
    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

Теперь оба сценария опять легальны: и «просто запусти с дефолтами», и «запусти с настройкой».

Чуть оживим это в main, чтобы было похоже на кусочек приложения:

#include <iostream>
#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";

    AppConfig() = default;
    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

int main() {
    AppConfig a{};
    AppConfig b{"backup_tasks.txt"};

    std::cout << a.storagePath << '\n'; // tasks.txt
    std::cout << b.storagePath << '\n'; // backup_tasks.txt
}

Смысл =default здесь очень практичный: мы не пишем руками «инициализацию по умолчанию», мы просим компилятор сделать её стандартным способом (учитывая инициализаторы полей, порядок полей и т.д.).

4. Копирование и разница с пустыми телами

Копирование: делаем стандартное поведение частью интерфейса

Следующая причина использовать =default — не потому что без него не компилируется, а потому что без него не так явно читается. Иногда вам важно, чтобы пользователь типа увидел: «копирование разрешено, и оно стандартное, по полям».

В нашем TaskBox пусть будет модель задачи:

#include <string>

struct Task {
    int id{};
    std::string title;

    Task() = default;
    Task(const Task&) = default;
    Task& operator=(const Task&) = default;
};

Чисто технически для такого Task можно было вообще ничего не писать: компилятор и так всё сгенерирует. Но здесь акцент на идее: =default — это способ сделать поведение явным.

Посмотрим на копирование вживую:

#include <iostream>
#include <string>

struct Task {
    int id{};
    std::string title;

    Task() = default;
    Task(const Task&) = default;
    Task& operator=(const Task&) = default;
};

int main() {
    Task a{1, "Read C++ book"};
    Task b = a;            // копирующий конструктор
    b.title = "Write C++ code";

    std::cout << a.title << '\n'; // Read C++ book
    std::cout << b.title << '\n'; // Write C++ code
}

Почему это важно в учебном плане? Потому что так вы начинаете воспринимать спец-функции не как «магии компилятора», а как часть публичного контракта. И когда чуть позже вы встретите тип, который копировать нельзя, вы будете читать код уже в правильной оптике: «ага, копирование запрещено моделью».

Почему T() {} — не то же самое, что T() = default;

На этом месте обычно хочется сказать: «Ладно-ладно, а если я напишу пустой конструктор, это же почти =default

Когда вы пишете:

struct X {
    X() {}
};

вы создаёте пользовательский конструктор. Компилятор больше не считает его «естественным» стандартным поведением.

А когда вы пишете:

struct X {
    X() = default;
};

вы говорите: «сгенерируй стандартную реализацию». То есть в одном случае вы утверждаете: «моя логика пустая», а в другом: «логика стандартная».

Практический эффект для новичка часто проявляется так: пустой конструктор — это место, куда вы потом начинаете незаметно добавлять «чуть-чуть логики», а через месяц удивляетесь, почему объект можно создать в странном состоянии. =default психологически работает как замок на двери: если вы хотели «обычное создание», вы зафиксировали это.

Ещё одна тонкость (сейчас просто как факт, без погружения в дебри): стандартно-сгенерированные операции иногда обладают дополнительными «приятными свойствами» для оптимизаций и строгих правил языка. А пользовательская функция, даже пустая, может эти свойства потерять. Если вы не хотите случайно менять «характер» типа, =default — самый безопасный выбор.

5. Где писать =default: в типе или в .cpp

Когда проект становится чуть больше, хочется разнести объявления и определения по файлам: в .hpp объявили, в .cpp определили. Это нормальный инстинкт — он появляется у человека примерно через неделю после знакомства с линковщиком.

Но со =default есть нюанс: место, где вы его написали, иногда меняет статус функции. Если максимально по-человечески, то правило такое: чаще всего лучше писать =default сразу там, где вы объявляете функцию — то есть прямо в определении struct.

Например, если у нас есть AppConfig, то так обычно лучше:

// config.hpp
#pragma once
#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";
    AppConfig() = default;
};

А вот так — допустимо, но требует осторожности:

// config.hpp
#pragma once
#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";
    AppConfig(); // только объявление
};
// config.cpp
#include "config.hpp"
AppConfig::AppConfig() = default;

С точки зрения «просто компилируется» — оба варианта обычно норм. Но с точки зрения «я хотел максимально стандартное поведение без побочных эффектов» — второй вариант иногда делает функцию более «пользовательской», чем вам казалось. Из-за этого тип может внезапно стать «менее простым», чем мог бы быть.

Запомнить можно так: =default — это про намерение. А намерения лучше писать там, где их увидят сразу, а не в дальнем .cpp, куда заглядывают только при аварии.

6. Практический пример: TaskBox и =default

Чтобы собрать всё в одну картину, сделаем мини-версию нашего приложения TaskBox: есть конфиг, есть задача, есть «хранилище задач» в памяти. Мы не делаем тут файловый ввод/вывод и сложные команды — нам важно увидеть, как =default формирует ясный контракт.

#include <iostream>
#include <string>
#include <vector>

struct AppConfig {
    std::string storagePath = "tasks.txt";
    AppConfig() = default;
    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

struct Task {
    int id{};
    std::string title;

    Task() = default;
    Task(const Task&) = default;
    Task& operator=(const Task&) = default;
};

struct TaskStore {
    std::vector<Task> tasks;

    TaskStore() = default;
    TaskStore(const TaskStore&) = default;
    TaskStore& operator=(const TaskStore&) = default;
};

int main() {
    AppConfig cfg{};
    TaskStore store{};

    store.tasks.push_back(Task{1, "Buy milk"});
    TaskStore backup = store;

    std::cout << cfg.storagePath << '\n';        // tasks.txt
    std::cout << backup.tasks[0].title << '\n';  // Buy milk
}

Заметьте ощущение от кода: он как будто «не пытается быть умнее, чем нужно». Мы нигде не написали ручное копирование, не пытались «оптимизировать на глаз», не добавили сомнительных действий в деструктор. Зато мы сделали контракт явным: эти типы создаются стандартно, копируются стандартно, и за ресурсами следят сами поля (std::string, std::vector).

7. Типичные ошибки при работе с =default

Ошибка №1: писать пустое тело {} вместо =default «потому что одинаково».
На уровне «код компилируется» действительно может показаться, что T() {} и T() =default; — близнецы. Но смысл разный: пустое тело — это уже ваша реализация, а =default — просьба к компилятору сгенерировать стандартную. Из-за этой разницы тип может начать вести себя «чуть иначе» по формальным свойствам языка, а главное — вы теряете ясность намерения.

Ошибка №2: ожидать, что =default «восстановит» операцию, которую запрещают поля.
Если внутри типа есть поле, которое не копируется (например, std::unique_ptr), то копирование содержащего типа недоступно по модели владения. В такой ситуации запись T(const T&) =default; не делает копирование возможным, а лишь честно фиксирует вашу попытку использовать стандартную генерацию — компилятор всё равно не сможет это сгенерировать корректно.

Ошибка №3: добавить конструктор с параметром и забыть, что конструктор по умолчанию мог исчезнуть.
Это классическая ловушка: вы улучшили API (Config(path)), а затем удивились, почему Config cfg; не компилируется. Если «дефолтный объект» по смыслу всё ещё нужен, его надо вернуть явно: Config() =default;. Это один из самых полезных и прикладных случаев =default.

Ошибка №4: объявить спец-функцию в заголовке, а =default написать в .cpp, не понимая, что это меняет статус функции.
Так можно делать, и иногда это оправдано, но тогда вы должны осознанно принять последствия: функция может перестать быть «максимально стандартной» по формальным признакам языка. Если вы не собирались менять характер типа, обычно проще и честнее написать =default прямо в определении struct.

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