Управление ресурсами с помощью явных специализаций шаблонов

в 14:27, , рубрики: c++, Блог компании Positive Technologies, обобщённое программирование, Программирование, разработка, С++, метки: ,

Управление ресурсами с помощью явных специализаций шаблонов - 1

RAII – одна из наиболее важных и полезных идиом в C++. RAII освобождает программиста от ручного управления ресурсами, без неё крайне затруднено написание безопасного с точки зрения исключений кода. Возможно, самое популярное использование RAII – это управление динамически выделяемой памятью с помощью умных указателей, но она также может с успехом применяется и к другим ресурсам, особенно в мире низкоуровневых библиотек. Примеры включают в себя дескрипторы Windows API, файловые дескрипторы POSIX, примитивы OpenGL и тому подобное.

Варианты реализации RAII

Если мы решили написать RAII-обёртку для некотого ресурса у нас есть несколько возможностей:

  • написать конкретный класс-обёртку для конкретного типа ресурсов;
  • использовать умный указатель стандартной библиотеки с пользовательским объектом очистки (например, std::unique_ptr<Handle, HandleDeleter>);
  • реализовать свой обобщённый класс-обёртку.

Первый вариант – написание специализированного класса-обёртки – сперва может показаться довольно разумным и действительно является хорошей отправной точкой. Простейшая RAII-обёртка может выглядеть примерно так:

class ScopedResource {
public:
  ScopedResource() = default;
  explicit ScopedResource(Resource resource)
    : resource_{ resource } {}

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

  ~ScopedResource() { DestroyResource(resource_); }

  operator const Resource&() const { return resource_; }  

private:
  Resource resource_{};
};

Однако, по мере того как наша кодовая база увеличивается в размере, растёт и количество ресурсов, за которыми нужно следить. Рано или поздно мы заметим, что большинство классов-обёрток незначительно отличаются друг от друга: как правило единственное отличие – это функция освобождения ресурса. Подобный подход провоцирует подверженное ошибкам повторное использование кода в стиле «копировать/вставить». С другой стороны, мы видим здесь отличную возможность для обобщения, которая подводит нас к следующему варианту – использованию умных указателей.

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

Почему умные указатели не так уж и умны

Рассмотрим следующий код:

#include <memory>

// 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<Handle, HandleDeleter>;
int main() {
  // error: expected argument of type void**
  ScopedHandle h{ CreateHandle() };
}

Почему конструктор ScopedHandle ожидает аргумент с типом void**? Вспомним, умные указатели проектировались прежде всего для управления памятью (то есть указателями): std::unique_ptr<int> на самом деле оборачивает int*. Аналогично std::unique_ptr<Handle> оборачивает Handle*, который в нашем примере является синонимом для void**. Как мы можем это обойти? Во-первых, мы можем использовать метафункцию std::remove_pointer:

using ScopedHandle =
  std::unique_ptr<std::remove_pointer_t<Handle>, HandleDeleter>; 

Во-вторых, мы можем использовать неочевидную особенность объекта очистки умного указателя: если в классе объекта очистки объявлен тип с именем pointer, то умный указатель будет считать этот тип типом управляемого ресурса:

struct HandleDeleter {
  using pointer = Handle;
  void operator()(Handle h) { CloseHandle(h); }
};
using ScopedHandle = std::unique_ptr<Handle, HandleDeleter>;

Ни одно из двух представленных решений не является настолько удобным, насколько нам того хотелось бы, но основная проблема не в этом: умный указатель вынуждает нас делать предположения о типе Handle. Но тип Handle задумывался как непрозрачный дескриптор; фактическое определение Handle – это деталь реализации, о которой пользователь не обязан знать.

Есть и другая, более серьёзная проблема с умными указателями:

#include <memory>

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<Handle, HandleDeleter>;

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<Bitmap>:

struct BitmapDeleter {
  using pointer = Bitmap;
  void operator()(Bitmap bmp) {
    DestroyBitmap(bmp); }
};
using ScopedBitmap = std::unique_ptr<Bitmap, BitmapDeleter>;

...

DeviceContext ctx{};
Bitmap tmp;
CreateBitmap(&tmp);
ScopedBitmap bmp{ tmp };
DrawBitmap(ctx, bmp.get());

Как мы видим, использование ScopedBitmap более неуклюже. В частности, мы не можем передать ScopedBitmap непосредственно в функции, ожидающие Bitmap.

Принимая во внимание вышеизложенное, переходим к третьему варианту – реализации обобщённой RAII-обёртки.

Реализация

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

#include <cassert>
#include <memory> // std::addressof

template<typename ResourceTag, typename ResourceType>
class Resource {
public:
  Resource() noexcept = default;
  explicit Resource(ResourceType resource) noexcept
    : resource_{ resource } {}
  
  Resource(const Resource&) = delete;
  Resource& operator=(const Resource&) = delete;
  
  Resource(Resource&& other) noexcept
    : resource_{ other.resource_ } { other.resource_ = {}; }

  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<struct FileIdTag, FileId>;
template<> void File::Cleanup() noexcept {
  if (resource_)
    CloseFile(resource_);
}

Теперь мы можем использовать наш класс для управления объектами FileId:

{
  File file{ CreateFile(file_path) };
  ...
} // "file" will be destroyed here

Мы можем рассматривать объявление Cleanup внутри Resource как своего рода «чисто виртуальную функцию времени компиляции». Схожим образом, явная специализация Cleanup для FileId является «конкретной реализацией» этой функции.

Что за параметр такой — ResourceTag?

Кто-то может поинтересоваться, зачем нам нужен неиспользуемый параметр шаблона ResourceTag? Он служит двум целям.

Во-первых, типобезопасность. Представим, что два разных типа ресурсов определены как синонимы для типа void*. Без параметра-тега компилятор просто не сможет обнаружить баг в следующем коде:

using ScopedBitmap = Resource<Bitmap>;
using ScopedTexture = Resource<Texture>;
void DrawBitmap(DeviceContext& ctx, ScopedBitmap& bmp) {
  /* ... */
}

int main() {
  DeviceContext ctx;
  ScopedBitmap bmp;
  ScopedTexture t;
  // Passing texture to function expecting bitmap.
  // Compiles OK.
  DrawBitmap(ctx, t);
}

Если же мы используем тег, компилятор заметит ошибку:

using ScopedBitmap = Resource<struct BitmapTag, Bitmap>;
using ScopedTexture = Resource<struct TextureTag, Texture>;

int main() {
  DeviceContext ctx;
  ScopedBitmap bmp;
  ScopedTexture t;
  DrawBitmap(ctx, t);  // error: type mismatch
}

Второе назначение тега следующее: он позволяет нам определять специализации Cleanup для концептуально разных ресурсов, имеющих один и тот же C++ тип. Ещё раз, представим, что ресурс Bitmap удаляется с помощью функции DestroyBitmap, в то время как ресурс TextureDestroyTexture. Не используй мы тег, ScopedBitmap и ScopedTexture имели бы одинаковый тип (напомню, в нашем примере и Bitmap, и Texture определены как void*), что не позволило бы нам определить разные функции очистки для каждого из ресурсов.

Коль уж речь у нас зашла о теге, следующее выражение может показаться странным:

using File = Resource<struct FileIdTag, FileId>; 

В частности я говорю об использовании конструкции struct FileIdTag в качестве аргумента шаблона. Давайте рассмотрим эквивалентное выражение, смысл которого я думаю ясен тем, кто знаком с диспетчированием на основе тегов:

struct FileIdTag{};
using File = Resource<FileIdTag, FileId>;

Традиционное диспетчирование с помощью тегов подразумевает использование перегруженных функций, в которых аргумент с типом тега является селектором перегрузки. Тег передаётся в перегруженную функцию по значению, поэтому он должен быть полным типом. Однако, в нашем случае перегрузка функций не используется. Тег нужен нам лишь как аргумент шаблона, чтобы обеспечить возможность определения явных специализаций для различных ресурсов. Принимая во внимания тот факт, что C++ позволяет использовать неполный тип в качестве аргумента шаблона, мы можем заменить определение тега на его объявление:

struct FileIdTag;
using File = Resource<FileIdTag, FileId>;

Далее, учитывая, что FileIdTag используется только внутри объявления синонима типа, мы можем перенести его непосредственно в место использования:

using File = Resource<struct FileIdTag, FileId>; 

Делаем требование явной специализации более… явным

Если пользователь не предоставит явную специализацию для метода Cleanup, он/она не сможет собрать программу. Это намеренное поведение. Однако, с ним связана пара проблем:

  • ошибка выбрасывается во время компоновки, в то время как предпочтительно (и возможно) обнаружить её раньше, на этапе компиляции;
  • сообщение об ошибке не даёт пользователю подсказки относительно истинной причины проблемы и пути её решения.
  • Давайте попробуем исправить эти недостатки с помощью static_assert:

void Cleanup() noexcept {
  static_assert(false,
                "This function must be explicitly specialized.");
}

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

Зная это, проблему легко решить: сделать условие зависимым от параметров шаблона. В частности, мы можем определить функцию-член времени компиляции, которая всегда возвращает значение false:

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<struct BitmapTag, BITMAP>;
using ScopedDc = Resource<struct DcTag, DC>;

...

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<struct HandleTag, HANDLE>;
template<> void Handle::Cleanup() noexcept {
  if (resource_ && resource_ != INVALID_HANDLE_VALUE)
    CloseHandle(resource_);
}

// WinInet handle.
using InetHandle = Resource<struct InetHandleTag, HINTERNET>;
template<> void InetHandle::Cleanup() noexcept {
  if (resource_)
    InternetCloseHandle(resource_);
}

// WinHttp handle.
using HttpHandle = Resource<struct HttpHandleTag, HINTERNET>;
template<> void HttpHandle::Cleanup() noexcept {
  if (resource_)
    WinHttpCloseHandle(resource_);
}

// Pointer to SID.
using Psid = Resource<struct PsidTag, PSID>;
template<> void Psid::Cleanup() noexcept {
  if (resource_)
    FreeSid(resource_);
}

// Network Management API string buffer.
using NetApiString = Resource<struct NetApiStringTag, wchar_t*>;
template<> void NetApiString::Cleanup() noexcept {
  if (resource_ && NetApiBufferFree(resource_) != NERR_Success) {
    // Log diagnostic message in case of error.
  }
}

// Certificate store handle.
using CertStore = Resource<struct CertStoreTag, HCERTSTORE>;
template<> void CertStore::Cleanup() noexcept {
  if (resource_)
    CertCloseStore(resource_, CERT_CLOSE_STORE_FORCE_FLAG);
}

О чём нужно помнить, определяя явные специализации шаблонов:

  • явная специализация должна быть определена в том же пространстве имен, что и основной шаблон (в нашем случае, шаблон класса Resource);
  • явная специализация шаблона функции, определённая в заголовочном файле, должна быть встроенной (inline): запомните, явная специализация – это уже не шаблон, а обычная функция.

Сравнение с unique_resource из N3949

Ограничения умных указателей как инструмента управления ресурсами, рассмотренные ранее, привели к разработке предложения по включению в стандарт N3949. N3949 описывает шаблон класса unique_resource_t, схожий с предложным в настоящей статье, однако использующий более традиционный подход к освобождению ресурсов (а именно, в ключе std::unique_ptr):

template<typename Resource, typename Deleter>
class unique_resource_t {
  /* … */
};

// Factory.
template<typename Resource, typename Deleter>
unique_resource_t<Resource, Deleter>
unique_resource(Resource&& r, Deleter d) noexcept {
  /* … */
}

...

// Usage (predefined deleter).
struct ResourceDeleter {
  void operator()(Resource resource) const noexcept {
    if (resource)
      DestroyResource(resource);
  }
};
using ScopedResource =
  unique_resource_t<Resource, ResourceDeleter>;
ScopedResource r{ CreateResource(), ResourceDeleter{} };

// Alternative usage (in-place deleter definition).
auto r2 = unique_resource(
  CreateResource(),
  [](Resource r){ if (r) DestroyResource(r); });

Как мы видим, unique_resource_t использует одну процедуру очистки на экземпляр класса, в то время как Resource – одну на класс. Концептуально, процедура очистки является скорее атрибутом типа ресурса, чем его экземпляра (это очевидно, если проанализировать различные реальные примеры использования RAII-обёрток). Как следствие, становится утомительно явно указывать процедуру очистки каждый раз во время создания ресурса. Однако, изредка, подобная гибкость может быть полезной.

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

Говоря о размере кода, необходимом для определения управляемого ресурса, особой разницы между Resource и unique_resource_t нет. Субъективно, я нахожу определение специализации функции более элегантным, нежели определение функтора (т. е., структуры с operator()). В случае с unique_resource_t, мы также можем использовать лямбду непосредственно в месте создания объекта, как показано выше, но это быстро становится неудобным по мере возникновения необходимости создавать ресурсы в разных частях кода (в этом случае нам придётся многократно повторять определение лямбды).

С другой стороны, передача вызываемого объекта в конструктор для предоставления пользовательской логики широко используется в C++, в то время как определение явных специализаций шаблонов для той же цели может показаться экзотикой для большинства программистов.

Заключение

RAII-обёртка, представленная в статье, устраняет большую часть недостатков умных указателей стандартной библиотеки, в случае когда речь идёт об управлении ресурсами, отличными от памяти. А именно:

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

Мы также познакомились с простой, но интересной техникой статического полиморфизма, основанной на использовании явных специализаций шаблонов. Исторически, явная специализация шаблонов имеет репутацию продвинутого средства языка, ориентированного главным образом на разработчиков библиотек и опытных пользователей. Однако, как мы с вами убедились, это средство может играть гораздо более важную роль центрального механизма абстракции наравне с виртуальными функциями, а не являться лишь ещё одной полезной утилитой в ящике инструментов разработчика библиотек. Я убеждён, что полный потенциал этой возможности языка нам ещё только предстоит раскрыть.

Код доступен по ссылке.

Автор: Павел Фролов, программист отдела разработки специальных проектов Positive Technologies

Оригинальная статья опубликована в выпуске 126 журнала Overload (апрель 2015).

Автор: ptsecurity

Источник

Поделиться новостью

* - обязательные к заполнению поля