Идиомы современного C++
Идиома — устойчивый приём, который команда узнаёт по имени. В C++ они связывают владение ресурсами, копирование и исключения. Подробности по памяти: Управление памятью в C++, по ООП: Объектно-ориентированное программирование в C++, по стилю проекта: Рекомендации по разработке на C++.
RAII (Resource Acquisition Is Initialization)
Ресурс захватывается в конструкторе, освобождается в деструкторе. Работает при любом выходе из области видимости — обычный return, break, continue, выход из блока {} или раскрутка стека при исключении. Не нужно вручную вызывать fclose, free, CloseHandle на каждой ветке: деструктор сделает это один раз, когда объект покидает область видимости.
Подробнее про стек, владение и умные указатели: Управление памятью в C++.
Без RAII: fclose на каждом return
Классическая ловушка при работе с C-API — открыть файл и не забыть закрыть:
void load_config(const char* path) {
std::FILE* f = std::fopen(path, "rb");
if (!f) return;
do_work(f);
if (something_bad()) {
std::fclose(f); // легко забыть при новой ветке
return;
}
std::fclose(f);
}
При каждом новом раннем выходе приходится дублировать std::fclose. Пропустили одну ветку — утечка дескриптора. Добавили исключение в do_work — снова утечка, если не оборачивать всё в try/finally (в C++ "finally" нет). RAII переносит освобождение в одно место — деструктор локального объекта.
Свой класс-обёртка
class ScopedFile {
std::FILE* fp_{};
public:
explicit ScopedFile(const char* path, const char* mode)
: fp_(std::fopen(path, mode)) {
if (!fp_) throw std::runtime_error("open failed");
}
~ScopedFile() { if (fp_) std::fclose(fp_); }
std::FILE* get() const { return fp_; }
ScopedFile(const ScopedFile&) = delete;
ScopedFile& operator=(const ScopedFile&) = delete;
};
Разбор:
- Класс
ScopedFileреализует RAII для файлового дескриптора: открытие происходит в конструкторе, закрытие — в деструкторе. explicit ScopedFile(...)не даёт неявно преобразовывать строки в объект-обёртку, что снижает риск случайных вызовов.- Проверка
if (!fp_) throw ...сразу сигнализирует об ошибке открытия файла и не оставляет объект в невалидном состоянии. ~ScopedFile()гарантируетstd::fclose, даже если из вызывающего кода выброшено исключение.= deleteу копирования запрещает два владельца одногоFILE*, предотвращая двойное закрытие ресурса.
Использование в функции — без ручного fclose:
void load_config(const char* path) {
ScopedFile f(path, "rb"); // или throw, если файл обязателен
do_work(f.get());
if (something_bad()) return; // fclose в ~ScopedFile
}
Класс уместен, когда нужны методы (read_line, size), инварианты или перемещение владения между функциями с явным контрактом.
std::unique_ptr с custom deleter
Для разовой обёртки вокруг FILE* (или другого C-дескриптора) достаточно умного указателя: deleter вызывается в деструкторе unique_ptr, как и у своего класса.
Код ITЗагрузка примера кода…
Разбор:
- Второй параметр шаблона
unique_ptr— тип deleter’а; дляstd::fcloseнужен именно указатель на функцию (decltype(&std::fclose)), иначе размер обёртки и вызов deleter’а будут некорректны. - При
fopenвернувшемnullptr, вunique_ptrпопадает нулевой указатель —fcloseдляnullptrбезопасен (ничего не делает), но проверятьif (!f)перед работой с файлом всё равно нужно. f.get()передаёт "заёмный"FILE*в C-API; владение остаётся уfдо конца области видимости.open_fileубирает дублирование пары(fopen, &fclose)по всему проекту.
Тот же приём работает для malloc/free, socket/close, WinAPI HANDLE/CloseHandle — везде, где есть пара "получить ресурс / освободить ресурс".
Умные указатели — RAII для кучи и не только: unique_ptr с deleter — RAII для чужих дескрипторов; shared_ptr — разделяемое владение; lock_guard — RAII для мьютексов. См. умные указатели в статье про память.
| Ресурс | Типичная обёртка |
|---|---|
| Динамическая память | std::unique_ptr / make_unique |
| Разделяемая память | std::shared_ptr + weak_ptr против циклов |
| Мьютекс | std::lock_guard, std::unique_lock |
FILE*, сокет, WinAPI handle | unique_ptr + deleter или свой класс |
| Файл "по-современному" | std::fstream, std::ifstream (RAII из коробки) |
Rule of Zero / Three / Five
Если класс не владеет уникальным ресурсом (только значения и стандартные контейнеры), не объявляйте деструктор, копирование и перемещение вручную — компилятор сгенерирует корректные операции (Rule of Zero).
Если владеете (сырой указатель, дескриптор, FILE*), нужны пять специальных функций (или явный = delete):
- деструктор;
- копирующий конструктор и оператор присваивания;
- перемещающий конструктор и оператор присваивания (C++11+).
Иначе — двойное освобождение, утечки, "полураспадающие" объекты после std::move.
Copy-and-swap
Безопасное присваивание через копию во временный объект и обмен:
class StringHolder {
std::size_t size_{};
char* data_{nullptr};
void swap(StringHolder& other) noexcept {
std::swap(size_, other.size_);
std::swap(data_, other.data_);
}
public:
StringHolder& operator=(StringHolder other) { // по значению
swap(other);
return *this;
}
};
Разбор:
- Здесь показана идиома
copy-and-swap: оператор присваивания принимает аргумент по значению и работает черезswap. - Параметр
otherуже содержит безопасную копию исходного объекта, поэтому*thisменяет состояние атомарно с точки зрения инвариантов. swapпомеченnoexcept, что делает операцию обмена предсказуемой и быстрой для полей с ресурсами.- После
swap(other)старые ресурсы текущего объекта переходят во временныйotherи освобождаются при его уничтожении. - Подход даёт сильную гарантию исключений: при ошибке копирования целевой объект остаётся без частично применённых изменений.
Копирующий конструктор можно реализовать через *this = other. Исключение в середине присваивания не оставляет объект в "полусломанном" состоянии — сильная гарантия.
Move-and-swap
Для типов с дорогим копированием и дешёвым перемещением:
- перемещающий конструктор забирает ресурсы у источника (источник — в валидном "пустом" состоянии);
- перемещающее присваивание:
otherперемещают во временный, затемswapс*this.
Помечайте перемещение noexcept, если оно не бросает: иначе std::vector при росте выберет копирование вместо перемещения.
Идиома "удалить через алгоритм" (remove + erase)
std::remove / std::remove_if не сжимают контейнер — они сдвигают "живые" элементы в начало и возвращают итератор на новый "логический" конец. Размер нужно уменьшить вручную:
std::vector<int> v{1, 2, 3, 2, 4};
v.erase(std::remove(v.begin(), v.end(), 2), v.end());
// v == {1, 3, 4}
Разбор:
- В примере используется стандартная связка
remove + eraseдля удаления значений изstd::vector. std::remove(...)не меняет размер контейнера: он только сдвигает "полезные" элементы в начало диапазона.- Возвращаемый итератор указывает на новый логический конец, начиная с которого лежат элементы "мусорной" хвостовой зоны.
v.erase(new_end, v.end())физически уменьшает размер контейнера и удаляет хвост, завершая операцию.- Такой шаблон предпочтительнее ручного удаления в цикле, потому что короче, безопаснее и проще для ревью.
В C++20 то же через ranges: Диапазоны и представления.
PIMPL (Pointer to Implementation)
Скрывает детали реализации за указателем на неполный тип — ускоряет пересборку и стабилизирует ABI библиотеки:
// widget.hpp
class Widget {
struct Impl;
std::unique_ptr<Impl> impl_;
public:
Widget();
~Widget();
void draw();
};
Разбор:
- Фрагмент показывает PIMPL: публичный класс хранит только указатель на скрытую реализацию
Impl. struct Impl;— неполный тип в заголовке; это уменьшает число пересборок при изменении деталей реализации.std::unique_ptr<Impl> impl_закрепляет единичное владение и автоматическое освобождение реализации.- Внешний API (
Widget,draw) остаётся стабильным, а внутренние поля и зависимости можно менять в.cpp. - При библиотечной разработке это помогает держать ABI стабильнее и скрывать внутренние детали от пользователя.
Деструктор объявляют в .cpp, где тип Impl полный.
Enum class и явные преобразования
enum class Status { Ok, Error }; — не смешивается с int без static_cast. Для флагов — enum class с операторами или std::byte + маски.
std::optional вместо "магических" значений
Возврат "нет результата" через std::optional<T> понятнее, чем -1, nullptr или пустая строка с особым смыслом. См. Переменные и области видимости в C++.
Не бросать из деструктора
Если деструктор бросает исключение при раскрутке стека — std::terminate. Деструкторы должны быть noexcept по умолчанию (для многих типов — неявно). Освобождение в деструкторе — только небросающие операции или try/catch с логированием (крайний случай).
Частые ошибки при внедрении идиом
- "Псевдо-RAII" без инвариантов. Класс хранит ресурс, но допускает частично-валидное состояние.
shared_ptrпо умолчанию везде. Размывает владение и маскирует жизненный цикл.- Забытый
noexceptу move-операций. Контейнеры теряют оптимизации. - Смешивание manual delete и smart pointers. Риск двойного освобождения.
Мини-набор правил для команды
- По умолчанию использовать Rule of Zero.
unique_ptrкак базовая модель владения,shared_ptrтолько при обоснованном совместном жизненном цикле.- В публичных API явно документировать владение (
borrowed,owner). - Для удаления по условию использовать
remove-eraseилиstd::erase_if.
Эти четыре правила покрывают большую часть реальных ошибок в прикладном C++.
Сводная таблица "когда что"
| Задача | Идиома |
|---|---|
| Файл, мьютекс, GPU-буфер | RAII |
Класс с new внутри | Rule of Five + умные указатели |
Оператор = с сильной гарантией | Copy-and-swap |
vector элементов с ресурсом | Move + noexcept |
| Удалить элементы по условию | remove-erase |
| Скрыть реализацию в .so | PIMPL |
| Опциональный результат | optional |
Дальше
- Конвейеры данных без лишних копий: Диапазоны и представления в C++20
- Vulkan и дескрипторы GPU: Vulkan и низкоуровневая графика на C++
- Самопроверка: C++ — чек-лист, задачи: Практические задания по C++