C++ и Windows API

Источник: oszone
Кенни Керр

Windows API представляет проблему для разработчика на C++. Различные библиотеки, из которых состоит этот API, по большей части предоставляют либо функции и описатели в стиле C, либо интерфейсы в стиле COM. Ни то, ни другое совершенно неудобно для работы из C++ и требует некоторого уровня инкапсуляции или абстракции.

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

Поскольку я возобновляю свою рубрику в этом журнале, я покажу, как использовать C++0x, или C++ 2011 (это более вероятное название), в сочетании с Windows API, чтобы вывести из средневековья искусство программирования на основе "родных" интерфейсов Windows. На протяжении следующих нескольких месяцев я намерен предпринять расширенный экскурс по Windows Thread Pool API. Следуя за мной, вы откроете для себя, как писать удивительно масштабируемые приложения безо всяких причудливых новых языков и сложных или дорого обходящихся исполняющих сред. Все, что вам понадобится, - великолепный компилятор Visual C++, Windows API и желание учиться.

Для удачного начала любой проект требует некоторой предварительной подготовки. Как же я собираюсь "обернуть" Windows API? Чтобы не тратить время на эти детали в каждой статье из этой рубрики, я поясню рекомендуемый мной подход в сегодняшней статье и в последующем буду просто опираться на него. Проблему интерфейсов в стиле COM я пока оставлю за скобками, так как в ближайших нескольких статьях они нам не понадобятся.

Windows API состоит из многих библиотек, которые предоставляют набор функций в стиле C и один или несколько непрозрачных указателей, называемых описателями (handles). Эти описатели обычно представляют библиотеку или системный ресурс. Функции позволяют создавать ресурсы, манипулировать ими и освобождать, используя описатели. Например, функция CreateEvent создает объект события и возвращает описатель этого объекта. Чтобы освободить этот описатель и сообщить системе, что использование данного объекта события закончено, просто передайте описатель в функцию CloseHandle. Если других занятых описателей этого же объекта нет, система уничтожит его:

auto h = CreateEvent( ... );
CloseHandle(h);

Новое в C++

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

Но вы почти никогда не должны писать такой код. Несомненно, что самое ценное в C++ - концепция класса. Шаблоны впечатляют, Standard Template Library (STL) просто волшебна, но без класса ничто не имеет смысла в C++. Именно понятие класса делает программы на C++ лаконичными и надежными. Я не говорю о виртуальных функциях, наследовании и других замысловатых возможностях - только о конструкторе и деструкторе. Зачастую это все, что нужно, и знаете что? Классы не создают никаких издержек. На практике вы должны знать об издержках, связанных с обработкой исключений, и об этом мы поговорим в конце статьи.

Чтобы приручить Windows API и сделать его доступным разработчикам на современном C++, нужен класс, инкапсулирующий описатель. Да, в вашей любимой библиотеке для C++, возможно, уже есть оболочка описателя, но годится ли она для C++ 2011? Сможете ли вы надежно хранить эти описатели в STL-контейнере и передавать их в своей программе, не теряя из виду, кому они принадлежат?

Класс в C++ - отличная абстракция для описателей. Заметьте, что я не сказал "для объектов". Вспомните, что описатель - это представление объекта в вашей программе и чаще всего сам он не является объектом. Присмотра требует описатель - не объект. Иногда отношение "один к одному" между объектом Windows API и классом C++ очень удобно, но это отдельная история.

Хотя описатели, как правило, непрозрачны, они, тем не менее, бывают разных типов и зачастую имеют небольшие семантические различия, которые требуют от шаблона класса адекватно, универсальным образом обертывать описатели. Параметры шаблона должны указывать тип описателя и конкретные характеристики, или особенности (traits) описателя.

В C++ класс traits часто используется, чтобы предоставлять информацию о конкретном типе. Точно так же я могу написать единый шаблон класса для описателей и предоставлять разные классы traits для описателей разных типов в Windows API. Класс traits описателя также должен определять, как освобождается описатель, чтобы шаблон класса описателя мог автоматически освобождать его при необходимости. Вот класс traits для описателей события:

struct handle_traits
{
  static HANDLE invalid() throw()
  {
    return nullptr;
  }
  static void close(HANDLE value) throw()
  {
    CloseHandle(value);
  }
};

Поскольку такая семантика применяется во многих библиотеках Windows API, ее можно задействовать не только для объектов событий. Как видите, класс traits состоит только из статических функций-членов. В итоге компилятор может легко подставить код в строку, не внося никаких издержек, но обеспечивая высокую гибкость для метапрограммирования.

Функция invalid возвращает значение недействительного описателя (invalid handle). Обычно это nullptr, новое ключевое слово в C++ 2011, представляющее значение null-указателя. В отличие от прежних альтернатив nullptr строго типизирован, чтобы нормально работать с шаблонами и механизмом перегрузки функций. Бывают случаи, где недействительный описатель определяется как нечто отличное от nullptr, и именно на такие случаи в класс traits включается функция invalid. Функция close инкапсулирует механизм, с помощью которого описатель закрывается или освобождается.

Обрисовав контуры класса traits, двинемся дальше и приступим к определению шаблона класса описателя, как показано на рис. 1.

 Шаблон класса описателя


template <typename Type, typename Traits>
class unique_handle
{
  unique_handle(unique_handle const &);
  unique_handle & operator=(unique_handle const &);

  void close() throw()
  {
    if (*this)
    {
      Traits::close(m_value);
    }
  }

  Type m_value;

public:

  explicit unique_handle(Type value = Traits::invalid()) throw() :
    m_value(value)
  {
  }

  ~unique_handle() throw()
  {
    close();
  }

Я назвал его unique_handle, потому что по духу он аналогичен стандартному шаблону класса unique_ptr. Многие библиотеки также используют идентичные типы и семантику описателей, поэтому имеет смысл предоставить typedef handle для наиболее распространенного случая:

typedef unique_handle<HANDLE, handle_traits> handle;

Теперь я могу создать объект события и его "описатель" таким образом:

handle h(CreateEvent( ... ));

Я объявил конструктор копии (copy constructor) и оператор присваивания копии (copy assignment operator) как закрытые и оставил их нереализованными. Это не дает компилятору автоматически генерировать их, так как автоматическая генерация редко годится для описателей. Windows API разрешает копировать определенные типы описателей, но эта концепция полностью отличается от семантики копирования в C++.

Параметр value конструктора получает значение по умолчанию через класс traits. Деструктор вызывает закрытую функцию-член close, которая в свою очередь полагается на тот же класс в закрытии описателя, если это необходимо. Тем самым я получаю описатель, дружественный к стеку (stack-friendly) и безопасный при исключениях (exception-safe).

Но я еще не закончил. Функция-член close полагается на наличие булева преобразования, чтобы определить, требует ли описатель закрытия. Хотя в C++ 2011 введены функции явного преобразования, в Visual C++ их пока нет, поэтому я использую универсальный подход к булеву преобразованию, чтобы избежать опасных неявных преобразований:
private:

  struct boolean_struct { int member; };
  typedef int boolean_struct::* boolean_type;

  bool operator==(unique_handle const &);
  bool operator!=(unique_handle const &);

public:

  operator boolean_type() const throw()
  {
    return Traits::invalid() != m_value ? &boolean_struct::member : nullptr;
  }

Это означает, что теперь я могу просто проверять, действителен ли у меня описатель, не разрешая опасных неявных преобразований:

unique_handle<SOCKET, socket_traits> socket;
unique_handle<HANDLE, handle_traits> event;

if (socket && event) {} // оба действительны?

if (!event) {} // событие недействительно?

int i = socket; // ошибка компилятора!

if (socket == event) {} // ошибка компилятора!

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

Способ владения описателем у unique_handle аналогичен тому, как стандартный шаблон класса unique_ptr владеет объектом и управляет им через указатель. В таком случае имеет смысл предоставлять привычные функции-члены get, reset и release для управления нижележащим описателем. Функция get проста:

Type get() const throw()
{
  return m_value;
}

Функция reset немного посложнее, но опирается на то, о чем я уже рассказывал:

bool reset(Type value = Traits::invalid()) throw()
{
  if (m_value != value)
  {
    close();
    m_value = value;
  }
  return *this;
}

Я позволил себе слегка изменить функцию reset из шаблона pattern, предоставляемого unique_ptr: моя версия возвращает bool-значение, указывающее, был ли объект сброшен в исходное состояние с действительным описателем. Это удобно при обработке ошибок, к которой я вскоре вернусь. Функция release теперь должна быть очевидной:

Type release() throw()
{
  auto value = m_value;
  m_value = Traits::invalid();
  return value;
}


Копирование против перемещения

И последнее - сравним семантику копирования и перемещения. Поскольку я уже запретил семантику копирования для описателей, имеет смысл разрешить семантику перемещения. Это важно, если вы хотите хранить описатели в STL-контейнерах. Эти контейнеры традиционно полагаются на семантику копирования, но с появлением C++ 2011 введена поддержка семантики перемещения.

Если обойтись без длинного описания семантики перемещения и ссылок rvalue, то идея заключается в том, чтобы разрешить передачу значения одного объекта другому предсказуемым для разработчика и понятным для авторов библиотек и для компиляторов образом.

До C++ 2011 разработчикам приходилось прибегать ко всяческим ухищрениям, чтобы избежать чрезмерной любви языка и расширения STL к копированию объектов. Иначе компилятор часто создавал копию объекта, а затем тут же уничтожал исходный объект. Благодаря семантике перемещения разработчик может объявить, что объект больше не будет использоваться и его значение перемещается в какое-то другое место - зачастую для этого достаточно обменять указатели.

В некоторых случаях разработчик должен явным образом указывать это, но гораздо чаще компилятор сам может использовать преимущества объектов с поддержкой семантики перемещения и выполнять невероятно эффективные оптимизации, ранее просто невозможные. Хорошая новость в том, что включение семантики перемещения для ваших классов осуществляется достаточно прямолинейно. Если при копировании используются конструктор копии и оператор присваивания копии, то семантика перемещения полагается на конструктор перемещаемого объекта и оператор его присваивания:
unique_handle(unique_handle && other) throw() :

  m_value(other.release())
{
}

unique_handle & operator=(unique_handle && other) throw()
{
  reset(other.release());
  return *this;
}


Ссылка rvalue

В C++ 2011 введен новый вид ссылки - rvalue. Она объявляется с помощью &&; это как раз то, что используется в членах unique_handle в предыдущем коде. Новые ссылки rvalue, хоть и аналогичны старым (теперь переименованным в lvalue), ведут себя несколько иначе, когда дело доходит до инициализации и разрешения перегрузок. Пока что я оставлю эту тему и вернусь к ней позже. Главное преимущество описателя с семантикой перемещения на данном этапе в том, что вы можете корректно и эффективно сохранять описатели в STL-контейнерах.


Обработка ошибок

Вот и все о шаблоне класса unique_handle. Последнее, что я хотел бы осветить в этой статье (в рамках подготовки к будущим статьям), - обработка ошибок. Мы могли бы до бесконечности вести дебаты насчет того, что лучше - исключения или коды ошибок, но, если вы хотите задействовать стандартные библиотеки C++, вам придется смириться с исключениями. Конечно, в Windows API используются коды ошибок, поэтому компромисс все же возможен.

Мой подход к обработке ошибок - делать как можно меньше и писать код, безопасный для исключений, но избегать их перехвата. В отсутствие обработчиков исключений Windows будет автоматически генерировать отчет об ошибке, включающий мини-дамп памяти рухнувшего приложения, с помощью которого можно вести "посмертную" отладку. Генерируйте исключения, только когда возникают неожиданные ошибки в период выполнения, а все остальное обрабатывайте как коды ошибок. Тогда при появлении исключения вы будете знать, что в вашем коде кроется ошибка или что с вашим компьютером произошла какая-то катастрофа.

Приведу пример обращения к реестру Windows. Неудача при попытке записи какого-либо значения в реестр обычно является симптомом более крупной проблемы, которую будет трудно решить в вашей программе. Это должно заканчиваться исключением. Однако неудачу при чтении некоего значения из реестра следует предвидеть заранее и корректно обрабатывать. Это не должно приводить к исключению - возвращайте значение типа bool или enum, указывающее, что операция чтения не удалась, и сообщающее причину.

Windows API, в частности, не согласован с заложенной в него обработкой ошибок; это результат того, что данный API развивался в течение многих лет. По большей части ошибки возвращаются либо как BOOL, либо как HRESULT. Есть и некоторые другие, которые я обычно обрабатываю явным образом, сравнивая возвращаемое значение с документированными.

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

Проверка возвращаемого значения


inline void check_bool(BOOL result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_bool(bool result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_hr(HRESULT result)
{
  if (S_OK != result)
  {
    throw check_failed(result);
  }
}

template <typename T>
void check(T expected, T actual)
{
  if (expected != actual)
  {
    throw check_failed(0);
  }
}

Есть два момента, связанных с этими функциями, о которых стоит упомянуть. Во-первых, функция check_bool является перегруженной, чтобы вы также могли проверять корректность объекта описателя, который совершенно справедливо не разрешает неявное преобразование в BOOL. Во-вторых, функция check_hr явно сравнивает результат на S_OK вместо использования более распространенного макроса SUCCEEDED. Это предотвращает прием других бессмысленных кодов вроде S_FALSE, которые почти никогда не соответствуют ожиданиям разработчиков.

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

struct check_failed
{
  explicit check_failed(long result) :
    error(result)
  {
  }
  long error;
};

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

С этими функциями проверки я могу создать объект события и перевести его в незанятое состояние (signal), сгенерировав исключение, если что-то пойдет не так:

handle h(CreateEvent( ... ));

check_bool(h);

check_bool(SetEvent(h.get()));


Обработка исключений

Другая проблема с обработкой исключений касается эффективности. И вновь мнения разработчиков здесь расходятся, так как они исходят из необоснованных предпосылок.

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

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

Это означает, что у компилятора должно быть четкое представление о том, какие функции потенциально могут генерировать исключения. Если компилятор точно знает, что от определенных функций никогда не будет исключений, он может оптимизировать код, генерируемый для определения стека и управления им. Вот почему я дополнил спецификацией исключений весь шаблон класса описателя и функции-члены класса traits. Это важная оптимизация, специфичная для конкретной платформы, хотя в C++ 2011 такая оптимизация не рекомендуется.

На сегодня все. Теперь у нас есть один из главных ингредиентов для написания надежных программ на основе Windows API.


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=27573