Перейти к основному содержимому

Идиомы современного 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 handleunique_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 с логированием (крайний случай).


Частые ошибки при внедрении идиом

  1. "Псевдо-RAII" без инвариантов. Класс хранит ресурс, но допускает частично-валидное состояние.
  2. shared_ptr по умолчанию везде. Размывает владение и маскирует жизненный цикл.
  3. Забытый noexcept у move-операций. Контейнеры теряют оптимизации.
  4. Смешивание 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
Скрыть реализацию в .soPIMPL
Опциональный результатoptional

Дальше