Управление ресурсами с помощью явных специализаций шаблонов
RAII — одна из наиболее важных и полезных идиом в C++. RAII освобождает программиста от ручного управления ресурсами, без неё крайне затруднено написание безопасного с точки зрения исключений кода. Возможно, самое популярное использование RAII — это управление динамически выделяемой памятью с помощью умных указателей, но она также может с успехом применяется и к другим ресурсам, особенно в мире низкоуровневых библиотек. Примеры включают в себя дескрипторы Windows API, файловые дескрипторы POSIX, примитивы OpenGL и тому подобное.
Варианты реализации RAIIЕсли мы решили написать RAII-обёртку для некотого ресурса у нас есть несколько возможностей: написать конкретный класс-обёртку для конкретного типа ресурсов;
использовать умный указатель стандартной библиотеки с пользовательским объектом очистки (например, std: unique_ptr
ScopedResource (const ScopedResource&) = delete; ScopedResource& operator=(const ScopedResource&) = delete;
~ScopedResource () { DestroyResource (resource_); }
operator const Resource&() const { return resource_; }
private: Resource resource_{}; }; Однако, по мере того как наша кодовая база увеличивается в размере, растёт и количество ресурсов, за которыми нужно следить. Рано или поздно мы заметим, что большинство классов-обёрток незначительно отличаются друг от друга: как правило единственное отличие — это функция освобождения ресурса. Подобный подход провоцирует подверженное ошибкам повторное использование кода в стиле «копировать/вставить». С другой стороны, мы видим здесь отличную возможность для обобщения, которая подводит нас к следующему варианту — использованию умных указателей.Умный указатель, реализованный в виде шаблона класса — это обобщённое решение для управления ресурсами. Однако и у него есть недостатки, в чём мы скоро убедимся. Как говорит их название, умные указатели были созданы прежде всего для управления памятью, и, как следствие, их использование с другими ресурсами зачастую приводит как минимум к неудобствам. Давайте остановимся на умных указателях более подробно.
Почему умные указатели не так уж и умны
Рассмотрим следующий код:
#include
// From low-level API. using Handle = void*; Handle CreateHandle () { Handle h{ nullptr }; /*…*/ return h; } void CloseHandle (Handle h) { /* … */ }
struct HandleDeleter {
void operator ()(Handle h) { CloseHandle (h); }
};
using ScopedHandle = std: unique_ptr
#include
using Handle = int; Handle CreateHandle () { Handle h{ -1 }; /*…*/ return h; } void CloseHandle (Handle h) { /* … */ }
struct HandleDeleter {
using pointer = Handle;
void operator ()(Handle h) { CloseHandle (h); }
};
using ScopedHandle = std: unique_ptr
int main () { // Error: type mismatch: «int» and «std: nullptr_t». ScopedHandle h{ CreateHandle () }; } На практике приведённый выше код может работать без проблем в зависимости от реализации std: unique_ptr, но в общем случае это не гарантируется, и определённо такое поведение не является переносимым.Причина ошибки в приведённом примере — нарушение концепции NullablePointer типом Handle. Вкратце, модель концепции NullablePointer, должна являться объектом, поддерживающим семантику указателей, и в частности допускающим сравнение с nullptr. Наш Handle, определённый как синоним для int, не является таким объектом. Как следствие, мы не можем использовать std: unique_ptr, для вещей наподобие файловых дескрипторов POSIX или ресурсов OpenGL.
Стоит упомянуть, что и эту проблему можно обойти. Мы могли бы определить адаптер для Handle, удовлетворяющий требованиям NullablePointer, однако, на мой вкус, написание обёртки для обёртки — это уже чересчур.
И, наконец, ещё одна проблема умных указателей связана с удобством их использования по сравнению с «сырыми» ресурсами. Рассмотрим идиоматическое использование гипотетического класса Bitmap:
// Graphics API. bool CreateBitmap (Bitmap* bmp) { /*…*/ return true; }
bool DestroyBitmap (Bitmap bmp) { /* … */ return true; }
bool DrawBitmap (DeviceContext ctx, Bitmap bmp) { /* … */ return true; }
…
// User code.
DeviceContext ctx{};
Bitmap bmp{};
CreateBitmap (&bmp);
DrawBitmap (ctx, bmp);
Теперь сравним использование Bitmap с использованием std: unique_ptr
…
DeviceContext ctx{}; Bitmap tmp; CreateBitmap (&tmp); ScopedBitmap bmp{ tmp }; DrawBitmap (ctx, bmp.get ()); Как мы видим, использование ScopedBitmap более неуклюже. В частности, мы не можем передать ScopedBitmap непосредственно в функции, ожидающие Bitmap.Принимая во внимание вышеизложенное, переходим к третьему варианту — реализации обобщённой RAII-обёртки.
Реализация
Представленная ниже реализация основана на подходе к освобождению ресурсов, отличному от применяемого в умных указателях стандартной библиотеки. Этот подход опирается на возможность выборочно специализировать нешаблонные члены шаблона класса.
#include
template
Resource& operator=(Resource&& other) noexcept { assert (this!= std: addressof (other)); Cleanup (); resource_ = other.resource_; other.resource_ = {}; return *this; }
~Resource () { Cleanup (); } operator const ResourceType&() const noexcept { return resource_; }
ResourceType* operator&() noexcept { Cleanup (); return &resource_; }
private: // Intentionally undefined — must be explicitly specialized. void Cleanup () noexcept;
ResourceType resource_{};
};
Сначала несколько второстепенных заметок относительно дизайна.Класс не поддерживает семантику копирования, но поддерживает семантику перемещения, таким образом он реализует модель единоличного владения (как std: unique_ptr). При необходимости можно определить аналогичный класс, реализующий модель совместного владения (как std: shared_ptr).
Принимая во внимание тот факт, что большинство аргументов ResourceType на практике являются примитивными дескрипторами (например, void* или int), методы класса помечены как noexcept.
Перегрузка operator& — спорное решение. Так или иначе, я решил сделать это, чтобы облегчить использование класса с функциями-фабриками вида CreateHandle (Handle* handle). Разумной альтернативой в данном случае является обычная именованная функция-член.
Теперь к делу. Как мы видим, метод Cleanup, являющийся краеугольным камнем нашего класса, оставлен без определения. В результате попытка создания объекта класса неминуемо приведёт к ошибке. Трюк заключается в том, что мы должны определить явную специализацию метода Сleanup для каждого ресурса, которым хотим управлять. Например:
// Here «FileId» is some OS-specific file descriptor type
// which must be closed with CloseFile function.
using File = Resource
using ScopedBitmap = Resource
int main () {
DeviceContext ctx;
ScopedBitmap bmp;
ScopedTexture t;
// Passing texture to function expecting bitmap.
// Compiles OK.
DrawBitmap (ctx, t);
}
Если же мы используем тег, компилятор заметит ошибку:
using ScopedBitmap = Resource
int main () { DeviceContext ctx; ScopedBitmap bmp; ScopedTexture t; DrawBitmap (ctx, t); // error: type mismatch } Второе назначение тега следующее: он позволяет нам определять специализации Cleanup для концептуально разных ресурсов, имеющих один и тот же C++ тип. Ещё раз, представим, что ресурс Bitmap удаляется с помощью функции DestroyBitmap, в то время как ресурс Texture — DestroyTexture. Не используй мы тег, ScopedBitmap и ScopedTexture имели бы одинаковый тип (напомню, в нашем примере и Bitmap, и Texture определены как void*), что не позволило бы нам определить разные функции очистки для каждого из ресурсов.Коль уж речь у нас зашла о теге, следующее выражение может показаться странным:
using File = Resource
static constexpr bool False () noexcept { return false; }
void Cleanup () noexcept { static_assert (False (), «This function must be explicitly specialized.»); } Тонкие обёртки против высокоуровневых абстракций Шаблон RAII-обёртки, представленный в статье, является тонкой абстракцией, имеющей дело исключительно с управлением ресурсами. Кто-то может возразить, зачем вообще писать такой класс, не стоит ли сразу реализовать полноценную абстракцию в лучших традициях объектно‑ориентированного проектирования? В качестве примера, посмотрим, как мы могли бы написать класс битовой карты с нуля: class Bitmap { public: Bitmap (int width, int height); ~Bitmap (); int Width () const; int Height () const; Colour PixelColour (int x, int y) const; void PixelColour (int x, int y, Colour colour); DC DeviceContext () const; /* Other methods… */
private: int width_{}; int height_{}; // Raw resources. BITMAP bitmap_{}; DC device_context_{}; }; Чтобы понять, почему такой подход является в общем случае плохой идеей, давайте попробуем написать конструктор для класса Bitmap: Bitmap: Bitmap (int width, int height) : width_{ width }, height_{ height } {
// Create bitmap. bitmap_ = CreateBitmap (width, height); if (! bitmap_) throw std: runtime_error{ «Failed to create bitmap.» };
// Create device context. device_context_ = CreateCompatibleDc (); if (! device_context_) // bitmap_ will be leaked here! throw std: runtime_error{ «Failed to create bitmap DC.» };
// Select bitmap into device context. // … } Как мы видим, наш класс на самом деле управляет двумя ресурсами: непосредственно битовой картой и соответствующим контекстом устройства (этот пример вдохновлён Windows GDI, где битовой карте, как правило, соответствует контекст устройства в памяти, необходимый для операций отрисовки и интероперабельности с современными графическими интерфейсами программирования). И вот здесь то и возникает проблема: если инициализация device_context_ завершится ошибкой, произойдёт утечка bitmap_! Теперь рассмотрим аналогичный код с использованием управляемых ресурсов:
using ScopedBitmap = Resource
…
Bitmap: Bitmap (int width, int height) : width_{ width }, height_{ height } {
// Create bitmap. bitmap_ = ScopedBitmap{ CreateBitmap (width, height) }; if (! bitmap_) throw std: runtime_error{ «Failed to create bitmap.» };
// Create device context. device_context_ = ScopedDc{ CreateCompatibleDc () }; if (! device_context_) // Safe: bitmap_ will be destroyed in case of // exception. throw std: runtime_error{ «Failed to create bitmap DC.» };
// Select bitmap into device context.
// …
}
Этот пример позволяет нам сформулировать следующее правило: не храните в качестве членов данных класса более одного неуправляемого ресурса. Лучше примните RAII к каждому из ресурсов, и затем используйте их как строительные блоки для построения более высокоуровневых абстракций. Такой подход обеспечивает как безопасность исключений, так и повторное использование кода (вы можете рекомбинировать эти строительные блоки в будущем без боязни взывать утечки памяти).Ещё примеры
Ниже приведены реальные примеры полезных специализаций нашего класса для объектов Windows API. Я выбрал Windows API, так как он изобилует возможностями для применения RAII (примеры интуитивно понятны; знание Windows API не требуется).
// Windows handle.
using Handle = Resource
// WinInet handle.
using InetHandle = Resource
// WinHttp handle.
using HttpHandle = Resource
// Pointer to SID.
using Psid = Resource
// Network Management API string buffer.
using NetApiString = Resource
// Certificate store handle.
using CertStore = Resource
// Factory.
template
…
// Usage (predefined deleter).
struct ResourceDeleter {
void operator ()(Resource resource) const noexcept {
if (resource)
DestroyResource (resource);
}
};
using ScopedResource =
unique_resource_t
// Alternative usage (in-place deleter definition). auto r2 = unique_resource ( CreateResource (), [](Resource r){ if ® DestroyResource®; }); Как мы видим, unique_resource_t использует одну процедуру очистки на экземпляр класса, в то время как Resource — одну на класс. Концептуально, процедура очистки является скорее атрибутом типа ресурса, чем его экземпляра (это очевидно, если проанализировать различные реальные примеры использования RAII-обёрток). Как следствие, становится утомительно явно указывать процедуру очистки каждый раз во время создания ресурса. Однако, изредка, подобная гибкость может быть полезной.Представьте себе функцию очистки, которая принимает флаг, описывающий политику удаления ресурса, как, например, функция Windows API CertCloseStore, упомянутая выше в разделе примеров.
Говоря о размере кода, необходимом для определения управляемого ресурса, особой разницы между Resource и unique_resource_t нет. Субъективно, я нахожу определение специализации функции более элегантным, нежели определение функтора (т. е., структуры с operator ()). В случае с unique_resource_t, мы также можем использовать лямбду непосредственно в месте создания объекта, как показано выше, но это быстро становится неудобным по мере возникновения необходимости создавать ресурсы в разных частях кода (в этом случае нам придётся многократно повторять определение лямбды).
С другой стороны, передача вызываемого объекта в конструктор для предоставления пользовательской логики широко используется в C++, в то время как определение явных специализаций шаблонов для той же цели может показаться экзотикой для большинства программистов.
Заключение RAII-обёртка, представленная в статье, устраняет большую часть недостатков умных указателей стандартной библиотеки, в случае когда речь идёт об управлении ресурсами, отличными от памяти. А именно: неочевидный синтаксис с типами — синонимами указателей; ограниченная поддержка типов, не реализующих семантику указателей; неудобное использование управляемых ресурсов с низкоуровневыми интерфейсами программирования по сравнению с неуправляемыми. Мы также познакомились с простой, но интересной техникой статического полиморфизма, основанной на использовании явных специализаций шаблонов. Исторически, явная специализация шаблонов имеет репутацию продвинутого средства языка, ориентированного главным образом на разработчиков библиотек и опытных пользователей. Однако, как мы с вами убедились, это средство может играть гораздо более важную роль центрального механизма абстракции наравне с виртуальными функциями, а не являться лишь ещё одной полезной утилитой в ящике инструментов разработчика библиотек. Я убеждён, что полный потенциал этой возможности языка нам ещё только предстоит раскрыть.Код доступен по ссылке.
Автор: Павел Фролов, программист отдела разработки специальных проектов Positive Technologies
Оригинальная статья опубликована в выпуске 126 журнала Overload (апрель 2015).