Windows и C++

Visual C++ 2015 вдыхает новую жизнь в устаревший код

Кенни Керр

Кенни КеррВ системном программировании в Windows интенсивно используются непрозрачные описатели (opaque handles), которые представляют объекты, скрытые за API в стиле C. Если только вы не программируете на сравнительно высоком уровне, все шансы за то, что вам придется управлять описателями самых разных видов. Концепция описателя существует во многих библиотеках и платформах и определенно не уникальна для операционной системы Windows. О шаблоне класса смарт-описателей я впервые написал в 2011 г. (msdn.microsoft.com/magazine/hh288076), когда в Visual C++ начали вводить некоторые начальные языковые средства C++11. Visual C++ 2010 сделал возможным создание удобных и семантически корректных оболочек описателей, но их поддержка для C++11 была минимальной и на тот момент требовалось еще много усилий, чтобы правильно написать такой класс. С появлением в этом году Visual C++ 2015 я подумал, что стоит вернуться к этой теме и поделиться с вами новыми идеями о том, как использовать современный C++, чтобы оживить некоторые из старых библиотек в стиле C.

Лучшие библиотеки не выделяют никакие ресурсы и поэтому требуют минимальных усилий в обертывании. Мой любимый пример — Windows-блокировка SRW (slim reader/writer). Вот все, что нужно для создания и инициализации этой блокировки:

SRWLOCK lock = {};

Структура блокировки SRW содержит всего один указатель void *, и очищать просто нечего! Ее следует инициализировать перед использованием; единственное ограничение заключается в том, что ее нельзя перемещать или копировать. Очевидно, что любая модернизация этой блокировки должна иметь дело с безопасностью по исключениям на время ее удержания, а не с управлением ресурсами. Тем не менее, современный C++ может помочь в выполнении этих простых требований. Прежде всего я воспользуюсь возможностью инициализировать нестатические поля (члены данных) там, где они объявляются, для подготовки блокировки SRW к применению:

class Lock
{
  SRWLOCK m_lock = {};
};

Это обеспечивает инициализацию, но блокировку все еще можно перемещать и копировать. Для этого надо удалить конструктор копии по умолчанию и оператор присваивания копии:

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
};

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

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock() noexcept = default;
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
};

Несмотря на тот факт, что я не написал никакого кода (компилятор генерирует все за меня), теперь можно легко создать блокировку:

Lock lock;

Компилятор будет запрещать любые попытки копирования или перемещения блокировки:

Lock lock2 = lock; // ошибка: копирование запрещено!
Lock lock3 = std::move(lock); // ошибка: перемещение запрещено!

Далее я могу просто добавлять методы для захвата и освобождения блокировки разнообразными способами. Блокировка SRW, как и предполагает ее название, обеспечивает семантику как общего чтения, так и монопольной записи. На рис. 1 приведен минимальный набор методов для простой монопольной блокировки.

Рис. 1. Простая и эффективная блокировка SRW

class Lock
{
  SRWLOCK m_lock = {};
public:
  Lock() noexcept = default;
  Lock(Lock const &) = delete;
  Lock & operator=(Lock const &) = delete;
  void Enter() noexcept
  {
    AcquireSRWLockExclusive(&m_lock);
  }
  void Exit() noexcept
  {
    ReleaseSRWLockExclusive(&m_lock);
  }
};

Подробнее о магии, скрытой в этом невероятно компактном синхронизирующем примитиве, см. в «The Evolution of Synchronization in Windows and C++» (msdn.microsoft.com/ magazine/jj721588). Теперь остается лишь обеспечить некоторую безопасность по исключениям в периоды владения блокировкой. Я совершенно точно не хотел бы писать нечто вроде:

lock.Enter();
// Защищаемый код
lock.Exit();

Вместо этого я предпочитаю, чтобы сторож блокировки (lock guard) взял на себя захват и освобождение блокировки в данной области видимости:

Lock lock;
{
  LockGuard guard(lock);
  // Защищаемый код
}

Такой сторож может просто удерживать ссылку на нижележащую блокировку:

class LockGuard
{
  Lock & m_lock;
};

Как и в случае самого класса Lock, лучше всего, чтобы класс LockGuard тоже не разрешал копирование или перемещение:

class LockGuard
{
  Lock & m_lock;
public:
  LockGuard(LockGuard const &) = delete;
  LockGuard & operator=(LockGuard const &) = delete;
};

Теперь конструктору достаточно войти в блокировку, а деструктору выйти из нее. Этот пример показан на рис. 2.

Рис. 2. Простой сторож блокировки

class LockGuard
{
  Lock & m_lock;
public:
  LockGuard(LockGuard const &) = delete;
  LockGuard & operator=(LockGuard const &) = delete;
  explicit LockGuard(Lock & lock) noexcept :
    m_lock(lock)
  {
    m_lock.Enter();
  }
  ~LockGuard() noexcept
  {
    m_lock.Exit();
  }
};

Если честно, Windows-блокировка SRW — весьма уникальная жемчужина, потому что большинство библиотек потребует определенного пространства в хранилище или какого-то ресурса, которым придется управлять явным образом. Я уже показывал, как лучше всего управлять указателями на COM-интерфейсы в своей статье «COM Smart Pointers Revisited» (msdn.microsoft.com/magazine/dn904668), поэтому теперь сосредоточусь на более универсальном случае непрозрачных описателей. Как я рассказывал ранее, шаблон класса описателя должен предоставлять какой-то способ параметризации не только типа описателя, но и того, как закрывается описатель и что именно считается недопустимым описателем. Не во всех библиотеках недопустимые описатели представляются null- или нулевым значением. Мой исходный шаблон класса описателя предполагал, что вызвавший код обеспечит класс типажей описателей (handle traits class), который предоставляет необходимые семантику и информацию о типах. Написав за прошедшие годы множество классов типажей, я пришел к выводу, что в подавляющем большинстве случаев они имеют сходные закономерности. И, как вам скажет любой разработчик на C++, шаблоны как раз и являются описанием закономерностей. Поэтому наряду с шаблоном класса описателя я теперь использую шаблон класса типажей описателей. Этот шаблон не обязателен, но реально упрощает большинство определений. Вот его определение:

template <typename T>
struct HandleTraits
{
  using Type = T;
  static Type Invalid() noexcept
  {
    return nullptr;
  }
  // Static void Close(Type value) noexcept;
};

Обратите внимание на то, что шаблон класса HandleTraits предоставляет, и на то, что он преднамеренно не предоставляет. Я написал так много методов Invalid, которые возвращали значения nullptr, что это уже кажется очевидным вариантом по умолчанию. С другой стороны, по очевидным причинам каждый конкретный класс типажей должен содержать свой метод Close. Комментарий присутствует только как шаблон, которому нужно следовать. Псевдоним типа тоже не обязателен и присутствует просто для удобства определения собственных классов типажей, наследуемых от этого шаблона. Таким образом, я могу определить класс типажей для файловых описателей, возвращаемых Windows-функцией CreateFile, как показано ниже:

struct FileTraits
{
  static HANDLE Invalid() noexcept
  {
    return INVALID_HANDLE_VALUE;
  }
  static void Close(HANDLE value) noexcept
  {
    VERIFY(CloseHandle(value));
  }
};

В случае неудачи функция CreateFile возвращает значение INVALID_HANDLE_VALUE. В ином случае полученный описатель должен быть закрыт с помощью функции CloseHandle. Следует признать, что это несколько необычно. Windows-функция CreateThreadpoolWork возвращает описатель PTP_WORK, представляющий рабочий объект. Это просто непрозрачный указатель, и при неудаче, естественно, возвращается значение nullptr. В итоге класс типажей для рабочих объектов может использовать преимущества шаблона класса HandleTraits, которые избавляет меня от набора лишнего кода:

struct ThreadPoolWorkTraits : HandleTraits<PTP_WORK>
{
  static void Close(Type value) noexcept
  {
    CloseThreadpoolWork(value);
  }
};

Как же выглядит сам шаблон класса Handle? Ну, он может просто опираться на данный класс traits, логически распознавать тип описателя и вызывать метод Close, когда в этом возникает необходимость. Логическое распознавание (inference) принимает форму выражения decltype для определения типа описателя:

template <typename Traits>
class Handle
{
  using Type = decltype(Traits::Invalid());
  Type m_value;
};

Этот подход избавляет автора класса traits от необходимости включать псевдоним типа или typedef, чтобы явно и избыточно предоставлять тип. Закрытие описателя — первоочередная задача, и безопасный вспомогательный метод Close помещается в закрытую часть шаблона класса Handle:

void Close() noexcept
{
  if (*this) // булев оператор
  {
    Traits::Close(m_value);
  }
}

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

explicit operator bool() const noexcept
{
  return m_value != Traits::Invalid();
}

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

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

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

explicit Handle(Type value = Traits::Invalid()) noexcept :
  m_value(value)
{}

А деструктор — на вспомогательный метод Close:

~Handle() noexcept
{
  Close();
}

Итак, копии запрещены, но, помимо блокировки SRW, я не могу придумать какой-либо ресурс, который не позволял бы перемещать его описатель в памяти. Возможность перемещения описателей чрезвычайно удобна. Перемещение описателя состоит из двух индивидуальных операций, которые можно назвать отключением (detach) и подключением (attach) или, скажем, отключением и сбросом (reset). Отключение включает освобождение владения описателем:

Type Detach() noexcept
{
  Type value = m_value;
  m_value = Traits::Invalid();
  return value;
}

Значение описателя возвращается вызвавшему коду, и копия объекта описателя объявляется недействительной, чтобы его деструктор не вызвал метод Close, предоставленный классом traits. Операция подключения или сброса включает закрытие любого существующего описателя с последующей передачей владения значением нового описателя:

bool Reset(Type value = Traits::Invalid()) noexcept
{
  Close();
  m_value = value;
  return static_cast<bool>(*this);
}

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

work.Reset(CreateThreadpoolWork( ... ));
if (work)
{
  // Рабочий объект создан успешно
}

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

if (work.Reset(CreateThreadpoolWork( ... )))
{
  // Рабочий объект создан успешно
}

Теперь я могу довольно легко реализовать операции перемещения, начиная с конструктора перемещения:

Handle(Handle && other) noexcept :
  m_value(other.Detach())
{}

Метод Detach вызывается применительно к rvalue-ссылке, и только что сконструированный Handle отнимает владение у другого объекта Handle. Оператор присваивания перемещения (move assignment operator) лишь немногим сложнее:

Handle & operator=(Handle && other) noexcept
{
  if (this != &other)
  {
    Reset(other.Detach());
  }
  return *this;
}

Сначала выполняется проверка идентификации, чтобы избежать подключения закрытого описателя. Нижележащий метод Reset не выполняет эту проверку, так как это потребовало бы введения двух дополнительных ветвлений для каждого присваивания перемещения. Одна проверка разумна, а две — избыточны. Конечно, семантика перемещения превосходна, но семантика перестановки (swap semantics) еще лучше, особенно если вы будете хранить описатели в стандартных контейнерах:

void Swap(Handle<Traits> & other) noexcept
{
  Type temp = m_value;
  m_value = other.m_value;
  other.m_value = temp;
}

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

template <typename Traits>
void swap(Handle<Traits> & left, Handle<Traits> & right) noexcept
{
  left.Swap(right);
}

Заключительный штрих в шаблоне класса Handle — пара методов Get и Set. Get очевиден:

Type Get() const noexcept
{
  return m_value;
}

Он просто возвращает значение нижележащего описателя, которое может понадобиться для передачи в различные библиотечные функции. Но Set, возможно, менее очевиден:

Type * Set() noexcept
{
  ASSERT(!*this);
  return &m_value;
}

Это операция непрямого присваивания (indirect set). Контрольное выражение (assertion) подчеркивает этот факт. В прошлом я называл ее GetAddressOf, но данное имя противоречит ее истинному предназначению. Такая операция непрямого присваивания нужна в случаях, где библиотека возвращает описатель как выходной параметр. Функция WindowsCreateString — лишь один из многих примеров:

HSTRING string = nullptr;
HRESULT hr = WindowsCreateString( ... , &string);

Я мог бы вызвать WindowsCreateString таким образом, а затем подключить полученный описатель к объекту Handle или просто использовать метод Set method, чтобы напрямую вступить во владение:

Handle<StringTraits> string;
HRESULT hr = WindowsCreateString( ... , string.Set());

Это гораздо надежнее и четче заявляет направление, в котором передаются данные. Шаблон класса Handle также предоставляет обычные операторы сравнения, но благодаря языковой поддержке операторов явных преобразований они больше не нужны для того, чтобы избежать неявного преобразования. Они предусмотрены просто для удобства, но их исследование я оставлю вам. Шаблон класса Handle — не более чем еще один пример из современного C++ для Windows Runtime (moderncpp.com).


Кенни Керр (Kenny Kerr) — высококвалифицированный программист. Живет в Канаде. Автор учебных курсов для Pluralsight, обладатель звания Microsoft MVP. Ведет блог kennykerr.ca. Кроме того, читайте его заметки в twitter.com/kennykerr.