JavaRush /Курсы /C++ SELF /Non-copyable типы: сценарии unique ownership, mutex‑подоб...

Non-copyable типы: сценарии unique ownership, mutex‑подобные

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

1. Честный контракт

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

Самая важная мысль: запрет копирования — это часть интерфейса типа. Это такой же элемент дизайна, как «у функции два параметра, а не пять». Вы заранее фиксируете: «этот объект нельзя “ксерокопировать”, с ним можно только работать как с единственным экземпляром».

Чтобы не путаться в терминах, давайте зафиксируем маленькую табличку. Она не про синтаксис, а про смысл.

Ситуация в модели Что было бы при копировании Поэтому тип…
Уникальное владение ресурсом (память, дескриптор, “ручка”) Два владельца начинают думать, что ресурс их …делают non-copyable
Уникальная идентичность (одна “карточка доступа”, один “сеанс”) Появляются «две одинаковые личности» …делают non-copyable
Mutex‑подобная эксклюзивность (один “замок”) Появляются две «копии замка», которые не синхронизируют одно и то же …делают non-copyable

Сейчас разберём три самые типичные «жизненные» сценария. И да, во всех трёх случаях язык C++ помогает нам тем, что ошибку мы получаем при компиляции, а не «через два дня на сервере в 03:00».

2. Сценарий №1: unique ownership — «владелец должен быть один»

Если вы когда-либо держали в руках ключ от квартиры, вы уже понимаете unique ownership: ключ можно дать другому человеку, но если вы просто сделаете копию ключа “из воздуха”, то это меняет правила игры. В программировании та же история: некоторые объекты по смыслу должны иметь ровно одного владельца.

В C++ самым знакомым примером уникального владения является std::unique_ptr. Он как раз и говорит: «я владею объектом, и владельцев два быть не может». Поэтому std::unique_ptr не копируется — и если вы положите его в поле структуры, структура тоже станет non-copyable автоматически (это как раз Rule of Zero в действии: поведение типа следует из поведения полей).

Мини-пример: владелец числа в динамической памяти (да, число — игрушечный ресурс, но принцип видно отлично).

#include <memory>

struct IntOwner {
    std::unique_ptr<int> p;
};

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

Здесь важно не то, что мы владеем int. Важно, что мы владеем чем-то, что нельзя безопасно «размножить по умолчанию». Именно это и выражает non-copyable.

Иногда начинающий разработчик пытается «обойти запрет», потому что «ну мне же надо передать объект в функцию». И вот здесь появляется взрослая мысль: если объект — владелец, то функция должна честно сказать, что она делает. Если функция только читает — она принимает const&. Если функция меняет состояние владельца — принимает &. Если функция пытается принять владельца по значению (то есть потребовала бы копию), значит дизайн функции не совпадает с моделью владения.

Вот маленькая демонстрация на уровне сигнатур (без сложных трюков):

void observe(const IntOwner& o) {
    (void)o; // читаем, но не копируем
}

void modify(IntOwner& o) {
    (void)o; // меняем, но не копируем
}

int main() {
    IntOwner a{std::make_unique<int>(10)};
    observe(a); // OK
    modify(a);  // OK
}

На этом месте полезно запомнить простое правило: если тип non-copyable, принимайте его по ссылке (или по указателю, если допускаете отсутствие значения — но это отдельный дизайн-контракт).

3. Сценарий №2: файловые типы и владение дескриптором

Файл — это отличный пример ресурса, который живёт «снаружи» вашей программы. Когда вы открываете файл для записи, вы получаете некоторую “ручку” (handle) к системному ресурсу. И дальше начинается магия ОС: курсор записи, буферы, права доступа, блокировки, ошибки… Это не тот случай, где хочется случайно сделать копию объекта и получить две сущности, которые думают, что они управляют одним и тем же.

В стандартной библиотеке потоки файлового ввода/вывода (std::ifstream, std::ofstream) не копируются. И это логично: «копия потока» — это крайне мутная вещь по смыслу. Поэтому любые ваши типы, которые содержат файловый поток, тоже обычно становятся non-copyable автоматически.

Давайте аккуратно привяжем это к нашему учебному приложению. Представим, что мы пишем простую консольную программу MiniNotes: она хранит заметки в памяти (вектором) и печатает их на экран. Сегодня добавим к ней логирование действий в файл: “add”, “list”, “remove”. Лог — это как “чёрный ящик”: он должен быть один и настоящий.

Сделаем простейший логгер, который владеет std::ofstream:

#include <fstream>
#include <string>

struct FileLogger {
    std::ofstream out;

    explicit FileLogger(const std::string& path) : out(path) {}

    void log(const std::string& msg) {
        out << msg << '\n';
    }
};

Код выглядит невинно, но у него есть важное свойство: FileLogger не будет копироваться, потому что std::ofstream не копируется. И это хорошо: мы не хотим, чтобы кто-то случайно сделал второй логгер “на ту же ручку” непонятно с каким состоянием.

Теперь покажем, как это использовать в MiniNotes. Мы не будем делать весь интерфейс приложения, а покажем ключевой момент: мы передаём логгер по ссылке.

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

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

void add_note(std::vector<Note>& notes, FileLogger& lg, const std::string& text) {
    int new_id = static_cast<int>(notes.size()) + 1;
    notes.push_back(Note{new_id, text});
    lg.log("add id=" + std::to_string(new_id)); // записали в файл
}

И пример использования:

#include <iostream>
#include <vector>

int main() {
    FileLogger lg{"app.log"};
    std::vector<Note> notes;

    add_note(notes, lg, "Buy milk");
    std::cout << notes.size() << '\n'; // 1
}

Обратите внимание на “архитектурный” эффект: логгер становится “центральным ресурсом”, который мы не копируем и не разбрасываем. Мы либо храним его как поле в “контексте приложения”, либо передаём туда, где он нужен, по ссылке.

Если вы сейчас думаете: “А что, если мне нужно два логгера?” — это нормально. Но два логгера должны быть двумя разными объектами, созданными осознанно: например, один пишет в "app.log", другой — в "error.log". Это не “копия”, это “два разных ресурса”.

Иногда полезно явно подчеркнуть запрет копирования, даже если он и так запретится “по полям”. Это делает сообщение компилятора более ожидаемым и делает контракт типа очевиднее:

#include <fstream>
#include <string>

struct FileLogger {
    std::ofstream out;

    explicit FileLogger(const std::string& path) : out(path) {}

    FileLogger(const FileLogger&) = delete;
    FileLogger& operator=(const FileLogger&) = delete;
};

Да, это слегка избыточно, но новичкам часто помогает: вы буквально видите глазами «копировать нельзя».

4. Сценарий №3: mutex‑подобные типы — «эксклюзивность нельзя ксерить»

Слово “mutex” в названии лекции может звучать страшно, если вы ещё не изучали многопоточность. И это нормально: мы сейчас не будем говорить про потоки и синхронизацию как тему. Нам нужен только образ: есть объект, который гарантирует эксклюзивность. Эксклюзивность — это когда “внутрь” может зайти только один.

Даже в однопоточном мире такое встречается постоянно. Например, у вас есть “карта доступа” в серверную. Она одна. Если вы её скопируете, смысл пропадает: теперь у вас две карты доступа, и кто-то может зайти туда, куда не должен. В программировании “карта доступа” часто выглядит как объект, который представляет право на действие.

Смоделируем такой объект простым типом AccessCard. Он сам по себе дешёвый (всего лишь int), но по смыслу его нельзя копировать, потому что ID должен быть уникальным и единственным.

struct AccessCard {
    int id{};

    explicit AccessCard(int v) : id(v) {}

    AccessCard(const AccessCard&) = delete;
    AccessCard& operator=(const AccessCard&) = delete;
};

Теперь сделаем “комнату”, в которую можно войти только имея карту. И специально сделаем параметр по ссылке, чтобы никто не мог “случайно передать копию”.

#include <iostream>

void enter_server_room(const AccessCard& card) {
    std::cout << "Entered with card #" << card.id << '\n'; // Entered with card #7
}

Пример использования:

int main() {
    AccessCard card{7};
    enter_server_room(card);

    // AccessCard copy = card; // ошибка: копирование запрещено
}

Смысл здесь не в том, что int “плохой тип”. Смысл в том, что мы выразили бизнес-правило средствами языка: “карту нельзя копировать”. И компилятор теперь ваш союзник: он не даст случайно написать код, который нарушает модель безопасности.

Если вы видите где-то в стандартной библиотеке типы, похожие на “замки” или “синхронизаторы”, очень часто они тоже non-copyable. Это ровно по этой причине: “копия замка” не делает второй замок, который защищает то же самое; чаще всего это просто бессмыслица.

5. Non-copyable в приложении: контекст, ссылки и границы владения

Когда в проекте появляется non-copyable объект, у новичка обычно два вопроса. Первый: “Как мне теперь всё это передавать по функциям?” Второй: “Где это хранить?” Оба вопроса решаются одной идеей: non-copyable ресурс должен иметь понятного владельца, а остальной код должен работать через ссылки.

Вернёмся к MiniNotes и сделаем “контекст приложения”, который хранит и данные, и ресурсы. Это удобно: в main вы создаёте всё “одним разом”, а дальше передаёте один объект “контекста” по ссылке.

#include <vector>

struct AppContext {
    FileLogger logger;
    std::vector<Note> notes;

    explicit AppContext(const std::string& log_path)
        : logger(log_path) {}
};

Теперь функции работают с AppContext&:

#include <string>

void add_note(AppContext& app, const std::string& text) {
    int new_id = static_cast<int>(app.notes.size()) + 1;
    app.notes.push_back(Note{new_id, text});
    app.logger.log("add id=" + std::to_string(new_id));
}

И main:

int main() {
    AppContext app{"app.log"};
    add_note(app, "Read C++ book");
}

Что даёт такая организация?

Она делает “ресурсные” объекты (логгер, соединение, эксклюзивный токен) частью одного явного “корня владения”. Этот корень создаётся в main и живёт до конца программы. Поэтому ссылки на его поля безопасны в рамках разумного использования.

При этом очень важно не начинать “лечить” non-copyable тип костылями. Например, не стоит думать: “О, раз нельзя копировать, я положу указатель и буду им везде размахивать”. Указатель — это отдельный контракт (“может быть nullptr”), а ещё он требует дисциплины времени жизни. В нашем сегодняшнем дне самый дружелюбный для новичка путь — ссылки и чёткий владелец.

6. Ошибки компилятора про deleted copy

Ошибки компиляции — это как сообщения от очень честного друга. Они не всегда вежливые и не всегда короткие, но обычно говорят правду. Когда тип non-copyable, вы чаще всего увидите одну из двух ситуаций.

Первая ситуация — вы сами запретили копирование через = delete, и компилятор скажет, что вы пытаетесь использовать удалённую функцию. Это выглядит примерно так (смысл, не точный текст): “use of deleted function AccessCard::AccessCard(const AccessCard&)”.

Вторая ситуация — вы копирование не запрещали руками, но оно запретилось “само”, потому что одно из полей не копируется (например, std::ofstream или std::unique_ptr). Тогда сообщение будет похожим: “copy constructor is implicitly deleted”, и дальше будет подсказка, какое поле виновато. И это, кстати, очень полезно: компилятор как будто говорит “смотри, ты пытаешься копировать владельца ресурса”.

Почти всегда алгоритм починки одинаковый: вы смотрите на место, где вы передаёте/возвращаете объект по значению, и заменяете это на передачу по ссылке.

Например, вот так писать не стоит (потому что параметр по значению требует копию):

void bad(FileLogger lg) {
    (void)lg;
}

А вот так — уже нормально:

void good(FileLogger& lg) {
    lg.log("hello");
}

На этом месте полезно сделать паузу и принять философскую мысль: non-copyable типы заставляют вас писать более честные сигнатуры. Если функция говорит “дай мне объект по значению”, она почти обещает, что ей нужна независимая копия. Если копия невозможна, значит обещание было неправильным.

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

Ошибка №1: запрещают копирование “частично” (только конструктор или только присваивание).
Иногда пишут T(const T&) = delete;, но забывают про operator=(const T&). В итоге один сценарий копирования запрещён, другой формально остаётся возможным (или попытка его использовать даст странное сообщение). Для новичка самая надёжная привычка — запрещать обе операции, чтобы тип был последовательно non-copyable.

Ошибка №2: продолжают принимать non-copyable тип по значению “по привычке”.
Очень легко написать void f(T t) автоматически, потому что так делали с int и std::string. Но для non-copyable типов это не просто “дорого”, это невозможно. Правильный шаг — остановиться и выбрать контракт: const T& для чтения, T& для изменения. Это делает код не только компилируемым, но и более понятным.

Ошибка №3: пытаются “обойти запрет” через сырые указатели вместо нормального дизайна владения.
Когда копирование запрещено, возникает соблазн везде хранить T*, “потому что указатель копируется”. Да, адрес копируется — но вы при этом не решили вопрос “кто владелец” и “когда объект уничтожится”. Часто получается ситуация, когда один объект уже умер, а указатели на него ещё гуляют по программе. В рамках сегодняшней темы лучше держаться стратегии: один владелец + ссылки.

Ошибка №4: делают тип non-copyable без причины и удивляются, почему “теперь неудобно”.
Non-copyable — сильное ограничение, и оно должно быть оправдано моделью. Если объект логически является значением (как Point, User, Report из прошлых лекций), запрет копирования только усложнит жизнь: придётся везде писать ссылки, думать о времени жизни и тратить внимание. Запрещайте копирование там, где копия действительно ломает смысл: уникальное владение, уникальная идентичность, эксклюзивность.

Ошибка №5: путают = delete (удалённая функция) и delete (освобождение памяти).
Из-за одинакового слова в языке мозг новичка иногда “склеивает” эти понятия. T(const T&) = delete; не освобождает память и не “удаляет объект”, это всего лишь запрет вызова функции на этапе компиляции. А delete p; — это операция освобождения памяти (и мы уже обсуждали, почему ручное управление памятью опасно). Это два разных мира с одинаковой вывеской.

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