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).

Комментарии (17)


  1. RPG18
    10.04.2015 19:52
    +3

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

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

    Что в имени тебе моем?(с) Пушкин А.С.
    Берем сущность которая не является указателем, не имеет ничего с ним общего, и обобщаем до указателя. За что и поплатились:
    Причина ошибки в приведённом примере – нарушение концепции NullablePointer типом Handle. Вкратце, модель концепции NullablePointer, должна являться объектом, поддерживающим семантику указателей, и в частности допускающим сравнение с nullptr. Наш Handle, определённый как синоним для int, не является таким объектом. Как следствие, мы не можем использовать std::unique_ptr, для вещей наподобие файловых дескрипторов POSIX или ресурсов OpenGL.

    «создаем себе трудности и успешно их преодолеем»
    Получили архитектурную ошибку.


    1. Mingun
      10.04.2015 21:20

      Берем сущность которая не является указателем,

      Да ладно? А что же это тогда? Число в системной таблице, указывающее на ресурс, не?

      Логично параметризовать эту функцию, зачем копипастить?

      А вот с этим соглашусь — тоже сразу приходит в голову такая мысль.


      1. RPG18
        10.04.2015 22:18

        Может таблица, может идентификатор узла в списка или дерева.
        А может это не идентификатор указывающий на ресурс, а некий объект. Принцип инкапсуляции и слоистая архитектура скрывают от меня эту информацию.
        Из подобных примеров у нас есть std::lock_guard, который так же работает в RAII-стиле(забавно, там так же в пример приводят lock_guard).


        1. Mingun
          10.04.2015 22:55
          -3

          Был бы сам объект, так бы его и назвали объектом, а не «ручкой» (тут как раз подходит цитата Пушкина). А раз назвали, то для вас это — указатель. Даже если он «указывает» сам в себя (т.е. являться не указателем, а реальным объектом). Вот скажите, url-адрес это объект? А является ли он указателем? Чтобы прочитать статью на хабре, вам url нужен именно как указатель, и как указатель вы его и используете, не заботясь о том, что это объект, у которого можно посчитать длину, выяснить, по https вы пришли или http, и прочую, безусловно важную, но в данном случае, ненужную информацию.

          Принцип инкапсуляции в данном случае скрывают от вас, как устроен указатель, но то что это указатель — вам говорят совершенно четко.


  1. Eivind
    10.04.2015 22:15

    В классе присутствуют только конструктор перемещения и оператор перемещения, однако инициализация и присваивание ресурса происходит по значению, что может грозить серьезными ошибками в некоторых случаях, что неприемлемо для «универсальной» обертки.
    Пример с std::unique_ptr принципиально некорректен, а спекуляция с использованием std::remove_pointer указывает на полное непонимание принципа его работы. В случае std::unique_ptr работу с ресурсом необходимо оборачивать в работу над указателем на ресурс, а не маскировать ресурс под указатель.

    Кстати последняя ревизия Generic Scope Guard and RAII Wrapper — N4189.

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

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


    1. Door
      11.04.2015 00:13

      спекуляция с использованием std::remove_pointer указывает на полное непонимание принципа его работы. В случае std::unique_ptr работу с ресурсом необходимо оборачивать в работу над указателем на ресурс, а не маскировать ресурс под указатель.

      А можно здесь поподробней? Я использую подобный трюк и хотелось бы узнать в чём-же моё непонимание std::unique_ptr. На примере HANDLE, как бы поступили Вы?


      1. Eivind
        11.04.2015 00:56

        Одно дело что вы используете «трюк» (по сути «хак»), а совсем другое, что он преподносится как единственно верный вариант.

        #include <memory>
        
        using Handle = int;
        Handle CreateHandle() { Handle h{ -1 }; /*...*/ return h; }
        void CloseHandle(Handle& h) { /* ... */ }
        
        struct HandleDeleter {
          void operator()(Handle* h) { CloseHandle(*h);  delete h; }
        };
        using ScopedHandle = std::unique_ptr<Handle, HandleDeleter>;
        
        int main() {
          ScopedHandle h{ new Handle(CreateHandle()) };
        }
        


        1. Door
          11.04.2015 01:49

          Ну, снова таки — из приведённого Вами документа, видно, что автор разбивает ресурсы на «pointer handle type» и «handle types that are not pointers». Для работы с ресурсами первого типа, как раз таки, приводится прекрасный пример использования std::unique_ptr для управления ресурсом (конечно, есть и недостатки).

          Пример из N4189
          typedef void *HANDLE; // System defined opaque handle type
          typedef unsigned long DWORD;
          #define INVALID_HANDLE_VALUE reinterpret_cast<HANDLE>(-1)
          // Can’t help this, that’s from the OS
          // System defined APIs
          void CloseHandle(HANDLE hObject);
          HANDLE CreateFile(const char *pszFileName,
              DWORD dwDesiredAccess,
              DWORD dwShareMode,
              DWORD dwCreationDisposition,
              DWORD dwFlagsAndAttributes,
              HANDLE hTemplateFile);
          bool ReadFile(HANDLE hFile,
              void *pBuffer,
              DWORD nNumberOfBytesToRead,
              DWORD*pNumberOfBytesRead);
          // Using std::unique ptr to ensure file handle is closed on scope-exit:
          void CoercedExample()
          {
              // Initialize hFile ensure it will be ”closed” (regardless of value) on scope-exit
              std::unique_ptr<void, decltype(&CloseHandle)> hFile(
              CreateFile("test.tmp",
                  FILE_ALL_ACCESS,
                  FILE_SHARE_READ,
                  OPEN_EXISTING,
                  FILE_ATTRIBUTE_NORMAL,
                  nullptr),
              CloseHandle);
              // Read some data using the handle
              std::array<char, 1024> arr = { };
              DWORD dwRead = 0;
              ReadFile(hFile.get(), // Must use std::unique ptr::get()
                  &arr[0],
                  static_cast<DWORD>(arr.size()),
                  &dwRead);
          }
          


          1. Eivind
            11.04.2015 02:11

            Все так. Спекуляция в том, что проблемы с использованием std::unique_ptr не по назначению выставляются как его недостатки. Очевидно, что unique_ptr не является панацеей, а unique_resource востребован.


        1. alexeibs
          11.04.2015 14:26

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


          1. Eivind
            11.04.2015 15:37

            В вашем примере происходит лишнее выделение памяти под хендл.

            Именно поэтому unique_ptr и не является хорошим инструментом для реализации scope_guard.
            При освобождении ресурса функция CloseHandle может бросить исключение и память, выделенная под хендл, не будет освобождена.


            Тут у нас есть несколько решений — потребовать условия noexcept(CloseHandle(*h)), просто проигнорировать исключение посредством try-catch, или передать это исключение для дальнейшей обработки:
            struct HandleDeleter {
              void operator()(Handle* h) {
              	std::exception_ptr ex;
              	try {
              		CloseHandle(*h); 
              	} catch (...) {
              		ex = std::current_exception();
              	}
            	delete h;
              	if (ex){
              		std::rethrow_exception(ex);
              	}
              }
            };
            

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


            1. alexeibs
              11.04.2015 15:50
              -1

              Именно поэтому unique_ptr и не является хорошим инструментом для реализации scope_guard.

              Вы сами придумали некорректный пример и теперь приводите его в качестве доказательства бесполезности unique_ptr. Примеры из статьи вполне себе годные


          1. mejedi
            11.04.2015 16:37

            По условиям задачи, CloseHandle бросать исключения не может так как реализована на Си.


            1. alexeibs
              11.04.2015 17:25

              Где же это написано, что функция CloseHandle написана на C? К тому же в Windows у нас есть SEH


              1. mejedi
                11.04.2015 18:11

                По условиям задачи мы пишем удобную обертку для низкоуровневого C-like API, откуда там C++ исключения?

                Взаимодействие SEH и исключений в C++ это тема для отдельного разговора. Если я правильно помню, в Winapi используются коды возврата а не SEH чуть менее чем во всех функциях?

                Но в целом вы конечно правы)


  1. abby
    11.04.2015 01:16
    +1

    Во-первых, такие вещи должны быть обязательно обложены тестами, а, во-вторых, в дизайне и коде есть пара моментов. Самое главное в дизайне — ScopedHandle неудачный пример для производной от Вашего Resource.
    Дальше коментарии (возможно, тянут на доп статью) пойдут про ScopedHandle, поскольку статья выше в основном об этом.
    — я бы сделал ScopedHandle без предложенного Resource, без std::unique_ptr и ограничился бы поддержкой только тривиальных хендлов (static_assert(std::is_trivial<Handle>::value, "The Handle type should be trivial.");). Это существенно всё упрощает.
    — заранее неизвестно, что инициализация ресурса в значение по-умолчанию есть правильно, из этого вытекают всякие моменты почти во всех остальных методах
    — так же из первого довольно сильно могут усложниться реализации всяких специфичных производных (речь о using ScopedHandle = Resource<struct HandleTag, Handle>, кстати, неплохо было бы добавить final, вряд ли тут будет к месту наследование, и так довольно сложный фундаментальный класс)
    — в качестве конструктора по-умолчанию кажется более логично использовать конструктор с одним аргументом, который принимает resource, и значением по-умолчанию
    — нет возможности узнать открыт (валидный) handle или нет
    — по моему опыту, публичный метод close оказался очень полезен
    — иногда надо иметь возможность получить указатель на уже открытый handle (например, чтобы сложить его в какой-нибудь массив), побочный эффект (side effect), очищающий ресурс тут немного некстати. Это действительно очень спорное решение. В зависимости от ситуации тут может лучше вернуть nullptr, если ресурс уже открыт, вернуть указатель на ресурс без изменений, очистить ресурс (как у Вас) или бросить исключение (пока что во всех моих проектах, я был против варианта бросания исключения).
    — неплохо было бы иметь Resource& operator=(ResourceType h)
    operator const ResourceType&() const noexcept { return resource_; } тут вопрос с константностью, допустим у нас константный объект, тогда мы можем получить ресурс (хендл), и изменить его, так как он скопируется при передаче в функцию. Тут философский вопрос о побитовой и логической константности, но все же упомянуть стоит. Я бы лично сделал неконстантным, больше помощи от компилятора.
    — для Resource& operator=(Resource&& other) я бы воспользовался swap idiom, но так тоже хорошо.
    — отсутствие operator bool() const и размышлений на эту тему.

    В этой связи я бы ввёл какой-нибудь traits, например,

    template<typename Handle, Handle InvalidValueT>
    struct BaseScopedHandleTraits {
      typedef Handle Handle;
      typedef Handle* HandlePtr;
      // required to be implemented
      //static bool close(Handle h)
    
      static Handle initialValue() {
        return InvalidValueT;
      }
      static bool test(Handle value) {
        return InvalidValueT != value;
      }
    };
    template<HANDLE InvalidValue>
    struct WindowsHandleTraits : BaseScopedHandleTraits<HANDLE, InvalidValue> {
      static bool close(Handle h) {
        return 0 != CloseHandle(h);
      }
    };
    


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


  1. nickolaym
    13.04.2015 09:48
    +1

    Плохая идея — изображать из хэндлов указатели, только ради повторного использования unique/shared_ptr. Хотя бы потому, что сигнальные значение указателя — это 0, а сигнальные значение хэндла может быть каким угодно: 0, -1, да хоть 0xDEADFACE. Опять же, «указуемый тип» хэндла не всегда существует. Ну и риск случайно сделать shared_ptr<remove_pointer> с дефолтным делетером вместо специального.
    Поэтому для всех будет лучше не впихивать невпихиваемое, а делать отдельные классы умных обёрток.