JavaRush /Курсы /C++ SELF /Какие спец‑функции генерирует компилятор и когда

Какие спец‑функции генерирует компилятор и когда

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

1. Введение

Если вы только начинаете, кажется, что struct — это просто набор полей, а компилятор — строгий преподаватель, который иногда ругается на лишнюю точку с запятой. Но в реальности компилятор ещё и помощник: он пытается сгенерировать за вас рутинный код жизненного цикла объекта. И пока тип простой — всё красиво. А потом вы добавляете поле std::unique_ptr, и внезапно «почему-то больше нельзя копировать». Не магия, а правила.

Представьте, что ваш тип — это предмет в инвентаре игры. У предмета есть жизненный цикл: его создают, иногда копируют (например, в список), иногда перезаписывают, а потом уничтожают. В C++ эти действия соответствуют специальным функциям-членам.

Какие специальные функции‑члены есть

Термин special member functions — это официальное название группы функций, которые описывают «базовую бытовую жизнь» объекта. В стандарте эти вещи разбросаны по разделам про конструкторы/копирование/удалённые операции, и именно вокруг них строится большая часть правил «почему компилируется / почему не компилируется». Например, в рабочих материалах стандарта можно встретить отсылки к разделам вроде [class.{default,copy}.ctor].

В этой лекции мы концентрируемся на четырёх ключевых «спец‑функциях»:

Что делает Как выглядит в коде Когда используется
Конструктор по умолчанию
T()
T x;, T x{}; (если подходит)
Деструктор
~T()
Автоматически при окончании времени жизни
Копирующий конструктор
T(const T&)
T b = a; (создание копии)
Копирующее присваивание
T& operator=(const T&)
b = a; (перезапись существующего)

Важно: в C++ есть и другие спец‑функции (например, связанные с переносом ресурса), но мы сознательно не уходим туда, чтобы не превращать лекцию в сериал на 12 сезонов.

2. Как компилятор генерирует спец‑функции

Что значит «компилятор сгенерирует сам»

Когда говорят «компилятор сгенерирует», у новичка в голове часто возникает образ гнома, который по ночам дописывает ваш main.cpp. Почти так, только без бороды (хотя кто знает, что там внутри LLVM). На практике это означает следующее: если вы не объявили какую-то спец‑функцию сами, компилятор может неявно объявить её за вас, а потом (если она реально нужна) — неявно определить, то есть сгенерировать тело.

Звучит занудно, но это объясняет «странности»:

  • вы написали программу — всё ок;
  • добавили одну строчку, где объект копируется — и внезапно компилятор ругается;
  • потому что именно в этот момент компилятору пришлось реально сгенерировать копирование, и он обнаружил проблему.

Небольшой пример: «копирование включили — правила проявились»:

#include <iostream>
#include <string>

struct Note {
    std::string title;
};

int main() {
    Note a{"Hello"};
    // Пока мы не копируем — всё тихо и мирно.

    Note b = a; // копирующий конструктор реально понадобился
    std::cout << b.title << '\n'; // Hello
}

Конструктор по умолчанию: когда он есть, а когда «испарился»

Конструктор по умолчанию (T()) — это возможность создать объект «без аргументов». На первых неделях курса мы активно делали int x{}, std::string s;, std::vector<int> v; — и оно работало, потому что у этих типов есть адекватное «состояние по умолчанию». С пользовательскими типами похожая история: компилятор может дать вам T(), но не обещает, что он появится в любой ситуации.

Интуитивное правило для старта такое: если ваш struct похож на «просто набор полей», и все поля умеют нормально создаваться сами, то и ваш тип обычно сможет создаться «пустым».

Пример: у заметки есть id и title, оба умеют нормальный старт.

#include <iostream>
#include <string>

struct Note {
    int id{};                 // 0
    std::string title{};      // пустая строка
};

int main() {
    Note n; // конструктор по умолчанию
    std::cout << n.id << " '" << n.title << "'\n"; // 0 ''
}

А теперь типичная «пропажа конструктора по умолчанию»: вы объявили любой конструктор с параметрами, и «бесплатный» Note() может больше не появиться автоматически. Это не потому, что компилятор вредный, а потому что он считает: «раз ты сам пишешь конструкторы — ты явно контролируешь создание объектов».

#include <string>
#include <utility>

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

    Note(int i, std::string t) : id(i), title(std::move(t)) {}
};

int main() {
    // Note n; // может стать ошибкой: нет конструктора по умолчанию
    Note n{1, "First"};
}

Для этой лекции важна мысль: наличие/отсутствие T() — это часть интерфейса типа, и компилятор честно проверяет её при компиляции.

Деструктор: почему он всегда с вами

Деструктор (~T()) — это «точка выхода» объекта из жизни. И тут полезно переключиться с «я пишу код» на «я запускаю программу во времени». Когда объект выходит из области видимости, компилятор автоматически вставляет вызов деструктора. Поэтому деструктор — это как уборка после вечеринки: вы можете делать вид, что её нет, но мусор всё равно появится.

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

#include <iostream>

struct Trace {
    ~Trace() {
        std::cout << "Trace destroyed\n";
    }
};

int main() {
    Trace t;
    std::cout << "Work...\n"; // Work...
} // Trace destroyed

Ключевой момент: даже когда вы не пишете деструктор, он всё равно существует концептуально, потому что поля надо уничтожать. std::string должен освободить свою память, std::vector — свою, std::unique_ptr — удалить ресурс и т.д.

Копирующий конструктор: копия при создании

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

Синтаксис выглядит максимально безобидно, но смысл фундаментальный:

T b = a; // создать b как копию a

Пример с «заметкой»:

#include <iostream>
#include <string>

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

int main() {
    Note a{1, "Buy milk"};
    Note b = a; // копирующий конструктор

    std::cout << b.id << " " << b.title << "\n"; // 1 Buy milk
}

Почему компилятор вообще может это сделать за вас? Потому что он применяет правило «копирование по полям»: каждое поле копируется так, как оно копируется само.

Копирующее присваивание: «перезаписать существующий объект»

Вторая форма копирования — копирование в уже существующий объект. То есть объект b уже живёт, у него уже есть какие-то поля, и мы хотим переписать их значениями из a. Это другая операция, другой контракт, другая спец‑функция.

Синтаксис:

b = a; // b уже существует

Мини‑пример:

#include <iostream>
#include <string>

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

int main() {
    Note a{1, "Buy milk"};
    Note b{2, "Pay bills"};

    b = a; // копирующее присваивание

    std::cout << b.id << " " << b.title << "\n"; // 1 Buy milk
}

Новички часто воспринимают эти две строки (Note b = a; и b = a;) как «ну там просто равно». Но для компилятора это две разные дороги: одна ведёт через копирующий конструктор, другая — через оператор присваивания.

Member‑wise поведение: «компилятор делает то, что умеют поля»

Теперь соберём в голове центральную модель лекции. Когда компилятор генерирует спец‑функции для вашего типа, он почти всегда делает это по полям. То есть он не выдумывает «умную бизнес‑логику», а честно выполняет механическую работу: для каждого поля вызывает соответствующую операцию.

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

Пример с более реалистичной моделью из условного приложения «Notebook» (храним заметки в векторе):

#include <string>
#include <vector>

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

struct Notebook {
    std::vector<Note> notes{};
};

int main() {
    Notebook nb1;
    nb1.notes.push_back(Note{1, "First"});

    Notebook nb2 = nb1; // копирование по полям:
                        // копируется vector<Note>,
                        // внутри копируются Note,
                        // внутри Note копируется string
}

Важная «проверка здравого смысла»: такое копирование работает, потому что std::vector и std::string ведут себя как нормальные «значения» — у них корректно определено копирование.

4. Когда спец‑функции становятся недоступны

Неявно удалённая операция

Самый полезный практический результат этой темы: компилятор не просто «генерирует или не генерирует». Он может сгенерировать функцию в статусе «вызвать нельзя» — это называется implicitly deleted (неявно удалённая функция). В обсуждениях и материалах по стандарту это всплывает постоянно: отдельно подчёркивается, что бывают deleted special member functions, и это часть формального механизма языка.

Как это выглядит для программиста: вы не писали копирование, но при попытке копировать получаете ошибку компиляции.

Самый учебный (и очень полезный) пример — std::unique_ptr. Он выражает уникальное владение: один владелец — один ресурс. Поэтому копировать его нельзя. И если вы положили unique_ptr в поле, то ваш тип тоже становится некопируемым «по наследству».

#include <memory>

struct Attachment {
    std::unique_ptr<int> data{};
};

int main() {
    Attachment a{std::make_unique<int>(42)};
    // Attachment b = a; // ошибка компиляции: копирование запрещено
}

Это не баг, а защита модели: копирование означало бы «у двух объектов один и тот же владеемый ресурс», что быстро приводит к катастрофе (двойное освобождение, висячие указатели и т.п.). Мы не делаем это руками — и компилятор тоже не даст.

Мини‑сценарий: заметка с вложением и «почему нельзя копировать»

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

Сделаем модель:

#include <memory>
#include <string>
#include <utility>

struct Attachment {
    std::string name{};
    explicit Attachment(std::string n) : name(std::move(n)) {}
};

struct Note {
    int id{};
    std::string title{};
    std::unique_ptr<Attachment> attachment{};
};

Теперь ключевой эффект: Note нельзя копировать автоматически, потому что внутри есть unique_ptr. И это на самом деле помогает: компилятор заставляет вас задуматься, что означает «копия заметки с вложением». Должно ли вложение тоже копироваться (то есть создавать новый Attachment)? Или при копировании вложение теряется? Или копия вообще запрещена по бизнес‑логике?

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

Даже если у вас пока нет ответа, сам факт ошибки компиляции — это полезная диагностика: «Эй, автор, тут у тебя смысловая неоднозначность, давай разберёмся».

5. Типичные ошибки

Ошибка №1: путать T b = a; и b = a; и считать, что это «одно и то же равно».
На уровне синтаксиса разница кажется косметической, но на уровне механики языка это две разные операции. Первая создаёт новый объект и требует копирующий конструктор. Вторая перезаписывает существующий объект и требует копирующее присваивание. Если вы не различаете эти случаи, вы потом удивляетесь «почему вот тут компилируется, а вот тут нет».

Ошибка №2: ожидать, что конструктор по умолчанию существует всегда.
Пока ваш тип похож на «мешок из полей», компилятор часто дарит вам T(). Но стоит добавить свой конструктор с параметрами, и создание без аргументов может стать недоступным. Это не «поломка компилятора», а часть контракта типа: если объект нельзя корректно создать пустым — язык не будет притворяться, что можно.

Ошибка №3: думать, что «если компилятор сгенерировал копирование, значит оно логически правильное».
Компилятор умеет проверить техническую корректность (можно ли скопировать поля), но не умеет понять ваш бизнес‑смысл. Например, копировать «уникальный идентификатор» иногда нельзя по смыслу, даже если технически это просто int. Поэтому автогенерация — это удобный дефолт, но не «автоматическая гарантия правильного дизайна».

Ошибка №4: удивляться, что тип перестал копироваться после добавления поля std::unique_ptr.
Это как удивляться, что дверь перестала открываться после того, как вы вставили ключ не той стороной. unique_ptr специально сделан некопируемым, чтобы защищать уникальное владение. Если он стал полем вашего типа, то и копирование всего типа становится запрещено автоматически. Это один из редких случаев, когда «ошибка компиляции» буквально предотвращает будущую ошибку памяти.

Ошибка №5: пытаться лечить запрет копирования «костылями», вместо того чтобы прояснить модель.
Когда копирование запрещено, самая частая плохая реакция — «ну ладно, сделаю сырой указатель» или «запихну всё в shared_ptr, чтобы копировалось». Это часто ухудшает дизайн: вы теряете ясность владения и можете получить утечки или циклы владения. Гораздо полезнее остановиться и сформулировать: что означает копия объекта в вашей предметной области?

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