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 не скасовує обмежень. Якщо стандартна версія функції не може існувати через склад полів, наприклад коли у вас є поле, яке не можна копіювати, то =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.

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