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

defer в C++ через RAII и ScopeGuard

··864 слов·5 минут

Суть

Когда-то я долгое время писал на Swift, а в этом языке достаточно много сахара, который упрощает код и прикрывает программисту пятую точку.

Чего только стоит guard, который позволяет проверять условия, не плодя лишние уровни вложенности и не заставляя морочиться с областями видимости.

Но сейчас не о guard.

Был в Swift ещё один приятный механизм - defer.

Да, это примерно та же идея, которая многим знакома по Go: можно отложить выполнение куска кода до момента, когда текущая функция завершится.

Как это выглядит в Go

Например, в Go часто пишут так:

func main() {
    file, _ := os.Open("myfile.txt")
    defer file.Close()

    // Do something with opened file
}

То есть мы открываем файл, сразу говорим, что его нужно закрыть при выходе из функции, а дальше спокойно работаем с ним.

Файл закроется независимо от того, как мы выйдем из функции: обычным путём или через ошибку. Насколько я помню, при panic deferred-вызовы тоже выполняются, если выполнение до них дошло.

А что в C++

В C++ ключевого слова defer нет.

Зато есть постоянная потребность освобождать ресурсы:

  • закрывать файлы;
  • закрывать ключи реестра;
  • освобождать дескрипторы;
  • откатывать временные изменения;
  • возвращать состояние обратно.

И есть принцип RAII: Resource Acquisition Is Initialization.

Если по-человечески: ресурс привязывается к объекту, а когда объект выходит из области видимости, вызывается его деструктор. В деструкторе и выполняется очистка.

После нескольких дней работы с Windows API, где постоянно нужно закрывать то файлы, то ключи реестра, то ещё что-нибудь, я написал маленький кусочек кода, который теперь радостно таскаю из проекта в проект.

defer.hpp

У меня это лежит в файле defer.hpp.

#pragma once

#include <concepts>
#include <functional>
#include <utility>

template <std::invocable Func>
class ScopeGuard {
public:
    explicit ScopeGuard(Func func) noexcept
        : m_function(std::move(func)) {}

    ~ScopeGuard() noexcept {
        m_function();
    }

    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;

    ScopeGuard(ScopeGuard&&) = delete;
    ScopeGuard& operator=(ScopeGuard&&) = delete;

private:
    Func m_function;
};

#define DEFER_CONCAT_IMPL_(x, y) x##y
#define DEFER_CONCAT_(x, y) DEFER_CONCAT_IMPL_(x, y)

#define defer(code) \
    auto DEFER_CONCAT_(defer_object_, __LINE__) = ScopeGuard([&]() noexcept { code; })

Использование

Например, открываем ключ реестра через Windows API:

#include <optional>
#include <string>
#include <windows.h>

#include "defer.hpp"

// Some code before

HKEY hKey;

if (RegOpenKeyExW(
        HKEY_LOCAL_MACHINE,
        regPath.c_str(),
        0,
        KEY_READ,
        &hKey
    ) != ERROR_SUCCESS) {
    return std::nullopt;
}

defer(RegCloseKey(hKey));

// Some code after

Если ключ реестра был открыт успешно, то что бы ни произошло ниже, он будет закрыт при выходе из области видимости.

Можно вернуть результат:

return value;

Можно выбросить исключение:

throw std::runtime_error("something went wrong");

Всё равно вызовется деструктор объекта ScopeGuard, созданного макросом defer, а он вызовет переданный код:

RegCloseKey(hKey);

Почему это работает

Макрос:

defer(RegCloseKey(hKey));

разворачивается примерно в такое:

auto defer_object_42 = ScopeGuard([&]() noexcept {
    RegCloseKey(hKey);
});

Число в имени берётся из __LINE__, чтобы на разных строках можно было писать несколько defer.

Когда выполнение выходит из текущей области видимости, объект defer_object_42 уничтожается. При уничтожении вызывается деструктор, а в деструкторе вызывается лямбда.

Вот и весь фокус.

Несколько defer подряд

Можно написать несколько отложенных действий:

defer(CleanupFirst());
defer(CleanupSecond());

Важный момент: порядок выполнения будет соответствовать обычному порядку уничтожения локальных объектов C++ - в обратном порядке их создания.

То есть сначала выполнится CleanupSecond(), потом CleanupFirst().

Это похоже на стек и близко к поведению defer в Go.

Важная оговорка про исключения

Деструктор ScopeGuard объявлен как noexcept.

Это сделано намеренно: бросать исключения из деструктора - плохая идея, особенно если уже идёт раскрутка стека из-за другого исключения.

Поэтому код внутри defer(...) тоже должен быть безопасным и не бросать исключения.

Хорошие кандидаты:

defer(RegCloseKey(hKey));
defer(CloseHandle(handle));
defer(DeleteFileW(tempFilePath.c_str()));

Плохой кандидат:

defer(SomeFunctionThatCanThrow());

Если функция теоретически может бросить исключение, лучше обработать его внутри:

defer(
    try {
        SomeFunctionThatCanThrow();
    }
    catch (...) {
        // log if needed
    }
);

Хотя если таких случаев много, возможно, нужен уже не defer, а нормальная RAII-обёртка.

Когда лучше не использовать такой defer

Этот подход удобен для небольших участков кода, особенно когда работаешь с C API или WinAPI.

Но если ресурс используется часто, лучше написать отдельную RAII-обёртку.

Например, для HKEY можно сделать класс, который сам вызывает RegCloseKey в деструкторе. Для HANDLE - обёртку вокруг CloseHandle.

То есть:

defer(RegCloseKey(hKey));

хорошо для быстрого локального решения.

А для постоянного использования в проекте лучше что-то вроде:

class RegistryKey {
public:
    ~RegistryKey() {
        if (m_key) {
            RegCloseKey(m_key);
        }
    }

private:
    HKEY m_key = nullptr;
};

Чем это отличается от настоящего defer

Понятно, что это не полностью то же самое, что встроенный defer в Swift или Go.

Отличия есть:

  • это не ключевое слово языка, а макрос;
  • работает через локальный объект и деструктор;
  • область действия - текущий C++ scope, а не обязательно вся функция;
  • нужно следить, чтобы код очистки не бросал исключения;
  • при очень сложном коде макрос может ухудшать читаемость.

Но базовую идею конструкция реализует: написал очистку рядом с захватом ресурса и больше не держишь в голове все возможные return, throw и ветки выхода.

Итог

В C++ нет встроенного defer, но есть RAII. А RAII позволяет довольно просто собрать маленький ScopeGuard.

Для работы с WinAPI и другим C-style API такая штука часто оказывается удобной: открыл ресурс, сразу написал, как его закрыть, и пошёл дальше.

Не серебряная пуля, не замена нормальным RAII-классам, но как маленький инструмент в defer.hpp - вполне рабочая вещь.

Upd сильно позже

Конкретно мою задачу можно было решить использованием WIL, в которой уже всё сделано до меня, причём более безопасно и профессионально. Но тогда я о существовании этой библиотеки не знал, а сделать надо было вот прямо сейчас и на коленке.

Кирилл Белоусов
Автор
Кирилл Белоусов
Также известен как cyrmax. Пишу код, тестирую, автоматизирую инфраструктуру и помогаю делать цифровые продукты доступнее.