Windows и C++

Еще раз о смарт-указателях в COM

Кенни Керр

Kenny KerrПосле второго пришествия COM, иначе известной как Windows Runtime, потребность в эффективном и надежном смарт-указателе для COM-интерфейсов становится еще важнее. Но как сделать хороший смарт-указатель для COM? ATL-шаблон класса CComPtr был де-факто смарт-указателем в COM в течение десятилетий. В Windows SDK for Windows 8 введен шаблон класса ComPtr, который является частью Windows Runtime C++ Template Library (WRL) — некоторые провозглашали ее как современную замену ATL CComPtr. Поначалу я тоже думал, что это был серьезный шаг вперед, но, получив достаточный опыт в работе с WRL ComPtr, я пришел к выводу, что его следует избегать. Почему? Читайте дальше.

Так что же делать? Вернуться к ATL? Ни в коем случае, но, возможно, пришла пора применить кое-что из современного C++, предлагаемого Visual C++ 2015, к проекту нового смарт-указателя для COM-интерфейсов. В прошлом номере я показал, насколько легко с помощью Visual C++ 2015 реализовать IUnknown и IInspectable, используя шаблон класса Implements. Теперь я намерен продемонстрировать, как задействовать больше функционала Visual C++ 2015 для реализации нового шаблона класса ComPtr.

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

На самом базовом уровне смарт-указатель COM должен обеспечивать управление ресурсом для нижележащего указателя на COM-интерфейс. Это подразумевает, что смарт-указатель будет шаблоном класса и будет хранить указатель на интерфейс нужного типа. С технической точки зрения, ему на самом деле не требуется хранить указатель на интерфейс конкретного типа, и вместо этого он мог бы хранить просто указатель на интерфейс IUnknown, но тогда смарт-указателю пришлось бы полагаться на static_cast при каждом разыменовании смарт-указателя. Это может быть полезно и концептуально опасно, но об этом мы поговорим в следующем выпуске моей рубрики. А пока я начну с базового шаблона класса для хранения строго типизированного указателя:

template <typename Interface>
class ComPtr
{
public:
  ComPtr() noexcept = default;
private:
  Interface * m_ptr = nullptr;
};

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

Теперь, когда указатель на интерфейс гарантированно инициализируется, я также могу опереться на другой новый функционал, чтобы явным образом запросить определение по умолчанию особых функций-членов. В предыдущем примере я запрашивал определение по умолчанию конструктора по умолчанию — стандартного конструктора default, если хотите. Не стреляйте в посредника. Тем не менее, возможность делать особые функции-члены дефолтными или удалять их, а также возможность инициализировать переменные-члены в точке их объявления относятся к числу моих любимых средств, предлагаемых Visual C++ 2015. На таких мелочах все и держится.

Самый важный сервис, который должен обеспечиваться смарт-указателем COM, — защита разработчика от рисков интрузивной COM-модели учета ссылок. На самом деле мне нравится подход к учету ссылок в COM, но я хочу, чтобы об этом за меня позаботилась библиотека. Это делает явным ряд тонкостей в шаблоне класса ComPtr, но, вероятно, наиболее очевиден случай, когда вызывающий разыменовывает смарт-указатель. Мне не хочется, чтобы вызывающий писал нечто вроде того, что показано ниже, — случайно или по какой-то иной причине:

ComPtr<IHen> hen;
hen->AddRef();

Возможность вызова виртуальных функций AddRef и Release должна относиться исключительно к компетенции смарт-указателя. Конечно, смарт-указатель должен по-прежнему разрешать вызов остальных методов через такую операцию разыменования. Обычно оператор разыменования смарт-указателя выглядит примерно так:

Interface * operator->() const noexcept
{
  return m_ptr;
}

Это работает для указателей на COM-интерфейсы, и нет нужды в проверочном выражении (assertion), так как нарушение доступа к памяти здесь более информативное. Но эта реализация будет по-прежнему разрешать вызывать AddRef и Release. Решение в том, чтобы просто возвращать тип, который запрещает вызовы AddRef и Release. Тут пригодится небольшой шаблон класса:

template <typename Interface>
class RemoveAddRefRelease : public Interface
{
  ULONG __stdcall AddRef();
  ULONG __stdcall Release();
};

Шаблон класса RemoveAddRefRelease наследует все методы аргумента шаблона, но объявляет AddRef и Release закрытыми, чтобы вызывающий не мог случайно сослаться на эти методы. Оператор разыменования смарт-указателя может просто использовать static_cast для защиты возвращаемого указателя на интерфейс:

RemoveAddRefRelease<Interface> * operator->() const noexcept
{
  return static_cast<RemoveAddRefRelease<Interface> *>(m_ptr);
}

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

Поскольку мой ComPtr определенно принимает команду учета ссылок, ему следует делать это корректно. Что ж, я начну с пары закрытых вспомогательных функций и прежде всего займусь той, которая предназначена для AddRef:

void InternalAddRef() const noexcept
{
  if (m_ptr)
  {
    m_ptr->AddRef();
  }
}

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

void InternalRelease() noexcept
{
  Interface * temp = m_ptr;
  if (temp)
  {
    m_ptr = nullptr;
    temp->Release();
  }
}

Зачем здесь временная переменная? Возьмем более интуитивно понятную, но некорректную реализацию, которая примерно отражает то, что я сделал (корректно) в функции InternalAddRef:

if (m_ptr)
{
  m_ptr->Release(); // BUG!
  m_ptr = nullptr;
}

Здесь проблема в том, что вызов метода Release может инициировать цепочку событий, которые заставили бы повторно освободить объект. Этот второй проход через InternalRelease мог бы вновь обнаружить указатель на интерфейс, отличный от null, и привести к попытке вновь вызвать Release для него. Согласен. что этот сценарий необычен, но задача разработчика библиотеки предусматривать такие вещи. Моя реализация, включающая временную переменную, предотвращает этот двойной вызов Release, сначала отсоединяя указатель на интерфейс от смарт-указателя и лишь после этого вызывая Release. Если поискать в анналах истории, окажется, что вроде бы первым такую досадную ошибку в ATL отловил Джим Спрингфилд (Jim Springfield). Так или иначе, располагая этими двумя вспомогательными функциями, я могу приступить к реализации некоторых особых функций-членов, которые помогают сделать получаемый объект таким, чтобы он действовал как встроенный. Простой пример — конструктор копии.

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

ComPtr(ComPtr const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

Это решает очевидную задачу конструирования копии. Указатель на интерфейс копируется до вызова InternalAddRef. Если бы я оставил этот код в таком виде, копирование ComPtr, в целом, казалось бы таким же, как и встроенного указателя, но не в полной мере. Я мог бы, например, создать копию так:

ComPtr<IHen> hen;
ComPtr<IHen> another = hen;

Это отражает то, что можно делать с исходными указателями (raw pointers):

IHen * hen = nullptr;
IHen * another = hen;

Но исходные указатели позволяют делать и такое:

IUnknown * unknown = hen;

Мой простой конструктор копии не в состоянии сделать такое с ComPtr:

ComPtr<IUnknown> unknown = hen;

Хотя IHen в конечном счете должен наследовать от IUnknown, ComPtr<IHen> не наследует от ComPtr<IUnknown>, и компилятор рассматривает их как не связанные типы. Здесь нужен конструктор, который действует как логический конструктор копии для других логически связанных ComPtr-объектов, особенно для любого ComPtr с аргументом шаблона, преобразуемым в аргумент шаблона сконструированного ComPtr. В таких случаях WRL полагается на типажи типа (type traits), но на самом деле это не обязательно. Все, что нужно мне, — шаблон функции, обеспечивающей возможность преобразования; после этого я просто позволю компилятору проверить, действительно ли тип можно преобразовать:

template <typename T>
ComPtr(ComPtr<T> const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

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

ComPtr<IHen> hen;
ComPtr<IUnknown> unknown = hen;

Но эти строки скомпилировать не удастся:

ComPtr<IUnknown> unknown;
ComPtr<IHen> hen = unknown;

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

template <typename T>
friend class ComPtr;

У вас может возникнуть соблазн удалить некоторый избыточный код, поскольку IHen можно преобразовать в ComPtr. Почему бы просто не удалить сам конструктор копии? Дело в том, что этот второй конструктор не рассматривается компилятором как конструктор копии. Если вы его опустите, компилятор предположит, что вы подразумевали удалить его и любую ссылку на эту удаленную функцию.

Позаботившись о конструкторе копии, очень важно ввести в ComPtr еще и конструктор перемещения. Если в данном сценарии перемещение допустимо, ComPtr должен предоставить компилятору такую возможность, поскольку это предотвратит увеличение счетчика ссылок (reference bump), которое обходится гораздо дороже, чем операция перемещения. Конструктор перемещения еще проще конструктора копии, потому что в нем не требуется вызывать InternalAddRef:

ComPtr(ComPtr && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

Он копирует указатель на интерфейс до очистки или сброса указателя в rvalue-ссылке на перемещаемый объект. Однако в этом случае компилятор не столь привередлив, и вы можете просто отказаться от этого конструктора перемещения для обобщенной версии, которая поддерживает конвертируемые типы:

template <typename T>
ComPtr(ComPtr<T> && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

И на этом мы закончим с конструкторами ComPtr. Деструктор предсказуемо прост:

~ComPtr() noexcept
{
  InternalRelease();
}

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

void InternalCopy(Interface * other) noexcept
{
  if (m_ptr != other)
  {
    InternalRelease();
    m_ptr = other;
    InternalAddRef();
  }
}

Предполагая, что указатели на интерфейсы не равны друг другу (или не являются оба null-указателями), функция освобождает любую существующую ссылку до создания копии указателя и защищает ссылку на новый указатель на интерфейс. Тем самым я могу легко вызвать InternalCopy, чтобы получить во владение уникальную ссылку на данный интерфейс, даже если она уже хранится в смарт-указателе. Аналогично вторая функция обрабатывает безопасное перемещение данного указателя на интерфейс, а также его счетчика ссылок:

template <typename T>
void InternalMove(ComPtr<T> & other) noexcept
{
  if (m_ptr != other.m_ptr)
  {
    InternalRelease();
    m_ptr = other.m_ptr;
    other.m_ptr = nullptr;
  }
}

Хотя InternalCopy естественным образом поддерживает конвертируемые типы, эта функция является шаблоном, который предоставляет эту возможность шаблону класса. В остальном InternalMove сильно похожа на InternalCopy, но логически перемещает указатель на интерфейс вместо получения дополнительной ссылки. Покончив с этим, можно реализовать операторы присваивания. Сначала присваивание копии, и, как в случае любого конструктора копии, я должен предоставить каноническую форму:

ComPtr & operator=(ComPtr const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

А затем предоставить шаблон для конвертируемых типов:

template <typename T>
ComPtr & operator=(ComPtr<T> const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

Но, как и в случае конструктора перемещения, можно предоставить единую обобщенную версию присваивания перемещения (move assignment):

template <typename T>
ComPtr & operator=(ComPtr<T> && other) noexcept
{
  InternalMove(other);
  return *this;
}

Хотя семантика move зачастую лучше семантики копирования, когда это касается смарт-указателей с учетом ссылок, перемещения не обходятся без издержек, и отличный способ избегать перемещений в некоторых ключевых сценариях — обеспечивать семантику обмена (swap semantics). Многие типы-контейнеры отдают предпочтение операциям обмена, а не перемещения, что может предотвратить конструирования колоссального количества временных объектов. Реализация функциональности обмена для ComPtr довольно прямолинейна:

void Swap(ComPtr & other) noexcept
{
  Interface * temp = m_ptr;
  m_ptr = other.m_ptr;
  other.m_ptr = temp;
}

Мне следовало бы использовать стандартный алгоритм обмена (Standard swap algorithm), но, по крайней мере в реализации из Visual C++, обязательный заголовочный файл <utility> неявно включает заголовочный файл <stdio.h>, и я не хочу заставлять разработчиков включать все это только ради обмена. Конечно, чтобы обобщенные алгоритмы находили мой метод Swap, мне также нужно предоставить функцию swap (имя должно иметь буквы нижнего регистра), которая не является членом:

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

Пока все определяется в том же пространстве имен, что и шаблон класса ComPtr, компилятор без проблем разрешит обобщенным алгоритмам использовать swap.

Другая приятная особенность C++11 — операторы явных преобразований. Исторически сложилось так, что создание надежного и работающего явным образом булева оператора для проверки смарт-указателя на логическое неравенство null требовало весьма «грязных хаков». Сегодня это сводится к:

explicit operator bool() const noexcept
{
  return nullptr != m_ptr;
}

Все это обеспечивает моему смарт-указателю поведение, во многом подобное таковому у встроенного типа, и при этом я максимально помогаю компилятору оптимизировать любые издержки. Остается лишь небольшой набор вспомогательных функций, который во многих случаях обязателен для COM-приложений. И здесь нужно проявить максимум осторожности, чтобы избежать добавления лишних «прибамбасов». Тем не менее, существует ряд функций, на которые будет полагаться почти каждое нетривиальное приложение или компонент. Прежде всего нужен какой-то способ явного освобождения нижележащей ссылки. Это достаточно легко:

void Reset() noexcept
{
  InternalRelease();
}

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

Interface * Get() const noexcept
{
  return m_ptr;
}

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

Interface * Detach() noexcept
{
  Interface * temp = m_ptr;
  m_ptr = nullptr;
  return temp;
}

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

void Copy(Interface * other) noexcept
{
  InternalCopy(other);
}

Или у меня может оказаться исходный указатель (raw pointer), который владеет ссылкой на свой целевой объект, а я хотел бы присоединить его без получения дополнительной ссылки. В редких случаях это также может быть полезно для сцепления ссылок (coalescing references):

void Attach(Interface * other) noexcept
{
  InternalRelease();
  m_ptr = other;
}

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

Interface ** GetAddressOf() noexcept
{
  ASSERT(m_ptr == nullptr);
  return &m_ptr;
}

И здесь мой ComPtr вновь отличается от WRL-реализации — это отличие небольшое, но критически важное. Заметьте, что GetAddressOf гарантирует, что она не содержит ссылку до возврата ее адреса. Это жизненно необходимо: иначе вызвавшая функция просто перезапишет любую ссылку, которую она могла хранить, а вы получите утечку ссылок. Без используемой мной проверки обнаруживать такие ошибки гораздо труднее. На другом конце спектра — возможность передачи ссылок либо тому же типу, либо другим интерфейсам, которые может реализовать нижележащий объект. Если нужна другая ссылка на тот же интерфейс, я могу избежать вызова QueryInterface и просто вернуть дополнительную ссылку, используя соглашение, которое предписывается COM:

void CopyTo(Interface ** other) const noexcept
{
  InternalAddRef();
  *other = m_ptr;
}

А вы можете использовать ее следующим образом:

hen.CopyTo(copy.GetAddressOf());

В ином случае можно применить саму QueryInterface без дальнейшей помощи со стороны ComPtr:

HRESULT hr = hen->QueryInterface(other.GetAddressOf());

На самом деле этот код полагается на шаблон функции, напрямую предоставляемый IUnknown, чтобы предотвратить явную передачу GUID интерфейса.

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

template <typename T>
ComPtr<T> As() const noexcept
{
  ComPtr<T> temp;
  m_ptr->QueryInterface(temp.GetAddressOf());
  return temp;
}

Затем я могу просто использовать явный оператор bool, чтобы проверить, был ли запрос успешным. Наконец, ComPtr также предоставляет все ожидаемые операторы сравнения, не являющиеся членами; это необходимо для удобства и поддержки различных контейнеров и обобщенных алгоритмов. И вновь это лишь способствует тому, что смарт-указатель кажется работающим как встроенный указатель, в то же время обеспечивая важные сервисы для корректного управления ресурсом и обязательные сервисы, ожидаемые COM-приложениями и компонентами. Шаблон класса ComPtr — это лишь еще один пример из Modern C++ для Windows Runtime (moderncpp.com).


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

Выражаю благодарность за рецензирование статьи эксперту Microsoft Джеймсу Макнеллису (James McNellis).