1. Зачем нужен =default, если «и так работает»?
Когда вы только начинаете писать свои struct, кажется, что язык и так заботится о вас: поля копируются, строки не текут, векторы сами освобождают память, жизнь прекрасна. Но потом вы добавляете «маленькое улучшение» — например, конструктор с параметром — и внезапно обнаруживаете, что T t; больше не компилируется. Или вы хотите, чтобы любой читающий код видел: «да, этот тип можно копировать, и копирование — обычное». Вот тут =default становится вашим способом разговаривать с компилятором и коллегами на одном языке.
=default — это не «сделай пустую функцию». Это команда: «сгенерируй стандартную реализацию этой функции так, как ты бы сделал это автоматически». То есть вы не придумываете логику сами — вы подтверждаете, что стандартная логика вам подходит, и фиксируете это в API типа.
Практически =default чаще всего применяется к спец-функциям-членам:
| Функция | О чём речь | Когда вызывается |
|---|---|---|
|
конструктор по умолчанию | T x;, T x{}; (в разных контекстах) |
|
деструктор | при завершении времени жизни объекта |
|
копирующий конструктор | T b = a; |
|
копирующее присваивание | 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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ