Windows и C++

Написание настольных приложений на Visual C++ 2012

Кенни Керр

 

Kenny KerrИз-за всей этой шумихи вокруг Windows 8 и приложений Windows Store мне стали задавать вопросы об актуальности настольных приложений и о том, является ли Standard C++ по-прежнему целесообразным выбором. Иногда на эти вопросы трудно ответить, но могу сказать, что компилятор Visual C++ 2012 больше соответствует принципам Standard C++ и, по моему скромному мнению, остается лучшим набором инструментов для создания впечатляющих настольных Windows-приложений независимо от того, на какую платформу вы ориентируетесь — Windows 7, Windows 8 или даже Windows XP.

После этого неизбежно следует уточняющий вопрос о том, какой подход к разработке настольных Windows-приложений лучше и с чего начать. Что ж, сегодня я рассмотрю основы создания настольных приложений на Visual C++. Когда Джеф Просиз Jeff Prosise (bit.ly/WmoRuR) впервые познакомил меня с программированием для Windows, новым многообещающим способом создания приложений была библиотека Microsoft Foundation Classes (MFC). Хотя MFC по-прежнему доступна, на практике хорошо виден ее почтенный возраст, а потребность в современных, гибких альтернативах заставляет программистов искать новые подходы. Эта проблема усугубляется переходом с ресурсов USER и GDI (msdn.com/library/ms724515) на Direct3D в качестве основной подсистемы визуализации контента на экране.

Многие годы я содействовал развитию Active Template Library (ATL) и ее расширения — Windows Template Library (WTL), которые были отличными альтернативами при создании приложений. Однако сейчас даже эти библиотеки понемногу устаревают. С отходом от использования ресурсов USER и GDI причин для применения этих библиотек остается еще меньше. Так с чего же начать? Конечно же, с Windows API. Я покажу вам, что создание окна в настольном приложении безо всяких библиотек — вовсе не столь устрашающая задача, как это может показаться с первого взгляда. Затем мы обсудим, как придать этому чуть больше привкуса C++, если вы того хотите, с помощью ATL и WTL. Эти библиотеки стоит использовать, если вы хорошо понимаете, как работает все то, что скрывается за шаблонами и макросами.

Windows API

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

#include <windows.h>

Затем вы можете определить стандартную входную точку для приложений:

int __stdcall wWinMain(HINSTANCE module, HINSTANCE, PWSTR, int)

Если вы пишете консольное приложение, то можете просто использовать стандартную в C++ входную функцию main, но я буду исходить из того, что вы не хотите, чтобы при каждом запуске программы на экран выскакивало консольное окно. Функция wWinMain имеет богатое историческое прошлое. Соглашение о вызовах __stdcall проясняет ситуацию на запутанной архитектуре x86, где предлагается целый набор соглашений о вызовах. Если вы ориентируетесь на x64 или ARM, то эта проблема вас не касается, поскольку компилятор Visual C++ реализует на этих архитектурах только одно соглашение о вызовах, однако знать о ней вам не повредит.

Два HINSTANCE-параметра особенно глубоко коренятся в истории. Во времена 16-разрядной Windows второй HINSTANCE был описателем любого предыдущего экземпляра приложения. Это позволяло приложению взаимодействовать с любым своим предыдущим экземпляром или даже переключаться на него, если пользователь случайно запустил программу повторно. Сегодня этот параметр — всегда nullptr. Возможно, вы также заметили, что я назвал первый параметр как «module» (модуль), а не «instance» (экземпляр). И вновь в 16-разрядной Windows экземпляры и модули были разными понятиями. Все приложения должны были совместно использовать модуль, содержащий сегменты кода, но получали уникальные экземпляры, содержащие сегменты данных. Текущий и предыдущий HINSTANCE-параметры теперь имели бы больше смысла. В 32-разрядной Windows появились раздельные адресные пространства, и наряду с этим возникла необходимость для каждого процесса проецировать в это пространство свой экземпляр/модуль — теперь это одно и то же. В наши дни это просто базовый адрес исполняемого файла в памяти. Компоновщик (linker) Visual C++ предоставляет этот адрес через псевдопеременную, к которой можно обращаться, объявив ее следующим образом:

extern "C" IMAGE_DOS_HEADER __ImageBase;

Адрес __ImageBase будет иметь то же значение, что и HINSTANCE-параметр. Это фактически способ, которым C Run-Time Library (CRT) получает адрес модуля, чтобы передать его вашей функции wWinMain. Это удобное сокращение, если вы не хотите в дальнейшем использовать этот параметр wWinMain в своем приложении. Но учтите, что эта переменная указывает на текущий модуль независимо от того, чем он является — DLL или EXE, и тем самым она полезна для загрузки ресурсов, специфичных для модуля.

Следующий параметр предоставляет любые аргументы командной строки, а последний параметр — это значение, которое должно быть передано функции ShowWindow для главного окна приложения в предположении, что вы изначально вызывали ShowWindow. Ирония здесь в том, что это почти всегда будет игнорироваться. Причина кроется в способе, которым приложение запускается через CreateProcess и родственные ей функции, чтобы позволить ярлыку (или другому приложению) определить начальное состояние главного окна приложения — свернутое, развернутое на весь экран или показываемое обычным образом.

Внутри функции wWinMain приложение должно зарегистрировать оконный класс. Оконный класс описывается структурой WNDCLASS и регистрируется с помощью функции RegisterClass. Факт регистрации хранится в таблице как пара, состоящая из указателя на модуль и имени класса, что позволяет функции CreateWindow находить информацию о классе, когда приходит пора создать окно:

WNDCLASS wc = {};
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.hInstance = module;
wc.lpszClassName = L"window";
wc.lpfnWndProc = []
 (HWND window, UINT message, WPARAM wparam, 
    LPARAM lparam) -> LRESULT
{
  ...
};
VERIFY(RegisterClass(&wc));

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

Предыдущий код — это минимум, который потребуется для описания стандартного окна. Структура WNDCLASS инициализируется пустой парой фигурных скобок. Это гарантирует, что все члены структуры инициализируется нулевыми значениями или nullptr. Вы должны задать лишь следующие члены: hCursor (указывает, какой курсор мыши нужно использовать, когда он находится поверх окна), hInstance и lpszClassName (идентифицируют оконный класс в процессе) и lpfnWndProc (указывают на оконную процедуру, которая будет обрабатывать сообщения, посылаемые окну). В данном случае я использую лямбда-выражение. К оконной процедуре я вскоре вернусь. Следующий шаг — создание окна:

VERIFY(CreateWindow(wc.lpszClassName, L"Title",
  WS_OVERLAPPEDWINDOW | WS_VISIBLE,
  CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
  nullptr, nullptr, module, nullptr));

Функция CreateWindow ожидает довольно много параметров, но для большинства из них просто используются значения по умолчанию. Первый и предпоследний параметры, как я упоминал, совместно представляют ключ, создаваемый функцией RegisterClass для того, чтобы функция CreateWindow могла найти информацию об оконном классе. Второй параметр указывает текст, который будет отображаться в заголовке окна. Третий параметр задает стиль окна. Чаще всего используется стиль, указываемый константой WS_OVERLAPPEDWINDOW; этот стиль описывает обычное окно верхнего уровня с заголовком, кнопками в нем, изменяемыми границами и т. д. В сочетании с константой WS_VISIBLE функция CreateWindow получает команду продолжить и показать окно. Если вы опускаете WS_VISIBLE, вам потребуется самостоятельно вызвать функцию ShowWindow перед тем, как пройдет дебют вашего окна на рабочем столе.

Следующие четыре параметра определяют начальную позицию окна и размер, а константа CW_USEDEFAULT, использованная в каждом случае, сообщает Windows выбрать соответствующие значения по умолчанию. Следующие два параметра предоставляют описатель родительского окна и меню (и ни один из них не требуется). Последний параметр дает возможность передать оконной процедуре при создании окна некое значение размер с указатель (pointer-sized value). Если все проходит успешно, окно появляется на рабочем столе и возвращается описатель этого окна. Если же что-то пойдет не так, вместо этого будет возвращен nullptr и для выяснения причины можно вызвать функцию GetLastError. Несмотря на все разговоры о трудностях использования Windows API, оказывается, что создать окно на самом деле довольно просто и эта процедура сводится к следующему:

WNDCLASS wc = { ... };
RegisterClass(&wc);
CreateWindow( ... );

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

MSG message;
BOOL result;
while (result = GetMessage(&message, 0, 0, 0))
{
  if (-1 != result)
  {
    DispatchMessage(&message);
  }
}

Как это ни удивительно, но этот кажущийся простым цикл зачастую реализуется неправильно. Корни этой проблемы связаны с тем, что прототип функции GetMessage возвращает BOOL-значение, но на самом деле это просто int. GetMessage извлекает сообщение из очереди в вызвавшем потоке. В очереди могут находиться сообщения для любого окна, но в нашем случае поток «прокачивает» сообщения только для одного окна. Если извлекается сообщение WM_QUIT, то GetMessage возвращает 0, указывая, что окно исчезло, обработку сообщений прекратило и приложение следует завершить. Если что-то пойдет наперекосяк, GetMessage может вернуть –1 и вы снова сможете вызвать GetLastError, чтобы получить больше информации. В ином случае любое ненулевое значение, возвращаемое GetMessage, указывает, что сообщение было извлечено и готово к отправке окну. Естественно, это обязанность функции DispatchMessage. Разумеется, существует масса вариаций такого цикла обработки сообщений, и возможность создавать свои циклы дает вам большой выбор в том, как будет вести себя приложение, какой ввод оно будет принимать и как он будет транслироваться. Кроме указателя MSG, остальные параметры в GetMessage можно при желании использовать для фильтрации сообщений.

Оконная процедура начнет получать сообщения еще до того, как функция CreateWindow вернет управление, поэтому она должна быть готова к этому и ожидать приема сообщений. Но как это все выглядит? Окну требуется карта (или таблица) сообщений. Ею могла бы быть в буквальном смысле цепочка выражений if-else или большое выражение switch внутри оконной процедуры. Однако такая конструкция быстро становится слишком громоздкой, так что в различных библиотеках и инфраструктурах прикладывают много усилий в попытке как-то управлять ей. На практике ничего заумного не требуется, и во многих случаях простой статической таблицы вполне достаточно. Для начала полезно представлять, из чего состоит оконное сообщение. Самое главное, что в нем есть константа, например WM_PAINT или WM_SIZE, которая уникально идентифицирует сообщение. В каждом сообщении передаются два аргумента: WPARAM и LPARAM. В зависимости от конкретного сообщения в них может отсутствовать какая-либо информация. Наконец, Windows ожидает, что в результате обработки определенных значений возвращается некое значение — LRESULT. Однако при обработке вашим приложением большинства сообщений ничего не возвращается, и вы должны возвращать нулевое значение.

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

typedef LRESULT (* message_callback)(HWND, WPARAM, LPARAM);
struct message_handler
{
  UINT message;
  message_callback handler;
};

Как минимум, затем можно создать статическую таблицу обработчиков сообщений, показанную на рис. 1.

Рис. 1. Статическая таблица обработчиков сообщений

static message_handler s_handlers[] =
{
  {
    WM_PAINT, [] (HWND window, WPARAM, LPARAM) -> LRESULT
    {
      PAINTSTRUCT ps;
      VERIFY(BeginPaint(window, &ps));
      // Здесь что-то рисуем
      EndPaint(window, &ps);
      return 0;
    }
  },
  {
    WM_DESTROY, [] (HWND, WPARAM, LPARAM) -> LRESULT
    {
      PostQuitMessage(0);
      return 0;
    }
  }
};

Сообщение WM_PAINT поступает, когда окну нужна отрисовка. Теперь это случается гораздо реже, чем в ранних версиях Windows, благодаря достижениям в рендеринге и композиции рабочего стола. Функции BeginPaint и EndPaint являются пережитками GDI, но все еще требуются, если вы при отрисовке используете совершенно другой механизм рендеринга. Дело в том, что они сообщают Windows, что вы делаете при отрисовке, проверяя область рисования окна. Без этих вызовов Windows сочла бы, что вы не отреагировали на сообщение WM_PAINT, и ваше окно получало бы ненужный поток сообщений WM_PAINT.

Сообщение WM_DESTROY поступает после того, как окно исчезло, подсказывая вам, что в данный момент оно уничтожается. Обычно это сообщение является индикатором, указывающим на необходимость завершения программы, но функция GetMessage внутри цикла обработки сообщений все равно ожидает сообщение WM_QUIT. Постановка этого сообщения в очередь — обязанность функции PostQuitMessage. В своем единственном параметре она принимает значение, которое передается через WPARAM сообщения WM_QUIT, что позволяет возвращать разные коды завершения при закрытии приложения.

Последний фрагмент головоломки — реализация самой оконной процедуры. Я опустил тело лямбды, которую использовал ранее для подготовки структуры WNDCLASS, но, учитывая то, что вы теперь знаете, несложно представить, как она могла бы выглядеть:

wc.lpfnWndProc =
  [] (HWND window, UINT message,
      WPARAM wparam, LPARAM lparam) -> LRESULT
{
  for (auto h = s_handlers; h != s_handlers +
    _countof(s_handlers); ++h)
  {
    if (message == h->message)
    {
      return h->handler(window, wparam, lparam);
    }
  }
  return DefWindowProc(window, message, wparam, lparam);
};

В цикле for находится подходящий обработчик. К счастью, Windows обеспечивает обработку сообщений по умолчанию на случай, если вы не делаете этого сами. Эта задача возлагается на функцию DefWindowProc.

И это все: если вы добрались до этого момента, то успешно создали окно настольного приложения с помощью Windows API!

Вариант на основе ATL

Проблема с этими функциями Windows API в том, что они были разработаны задолго до того, как C++ стал настолько популярен, и поэтому их не так-то легко адаптировать под объектно-ориентированное мировоззрение. Тем не менее, при достаточно изощренном кодировании этот API в стиле C можно трансформировать в нечто более подходящее среднему программисту на C++. ATL предоставляет библиотеку шаблонов классов и макросов, которые именно это и делают, так что, если вам нужно управлять большим количеством оконных классов или по-прежнему опираться на ресурсы USER и GDI в реализации окна, нет никаких причин не использовать ATL. Окно из предыдущего раздела можно выразить средствами ATL, как показано на рис. 2.

Рис. 2. Выражение окна средствами ATL

class Window : public CWindowImpl<Window, CWindow,
  CWinTraits<WS_OVERLAPPEDWINDOW | WS_VISIBLE>>
{
  BEGIN_MSG_MAP(Window)
    MESSAGE_HANDLER(WM_PAINT, PaintHandler)
    MESSAGE_HANDLER(WM_DESTROY, DestroyHandler)
  END_MSG_MAP()
  LRESULT PaintHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PAINTSTRUCT ps;
    VERIFY(BeginPaint(&ps));
    // Здесь что-то рисуем
    EndPaint(&ps);
    return 0;
  }
  LRESULT DestroyHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PostQuitMessage(0);
    return 0;
  }
};

Класс CWindowImpl обеспечивает необходимую маршрутизацию сообщений. CWindow является базовым классом, который предоставляет множество оболочек функций-членов, — главным образом для того, чтобы вам не требовалось явно передавать описатель окна в каждом вызове функции. Вы можете увидеть это в действии на примере с вызовами функций BeginPaint и EndPaint. Шаблон CWinTraits предоставляет константы стилей, которые будут использоваться при создании окна.

Макросы уходят своими корнями в MFC и работают с CWindowImpl, чтобы отбирать входящие сообщения и направлять их соответствующим функциям-членам для обработки. Каждый обработчик в первом аргументе принимает константу сообщения. Это может быть полезно, если вам нужно обрабатывать разнообразные сообщения с помощью одной функции-члена. Значение по умолчанию для последнего параметра равно TRUE, и этот параметр позволяет обработчику решать в период выполнения, кто должен обрабатывать сообщение — он сам, Windows или даже какой-то другой обработчик. Эти макросы наряду с CWindowImpl весьма эффективны и дают возможность обрабатывать отраженные сообщения (reflected messages), соединять карты сообщений в цепочку и т. д.

Чтобы создать окно, вы должны использовать функцию-член Create, которую ваше окно наследует от CWindowImpl, и она в свою очередь вызовет добрые старые функции RegisterClass и CreateWindow:

Window window;
VERIFY(window.Create(nullptr, 0, L"Title"));

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

WTL: дополнительная доза ATL

Если ATL проектировалась в основном для упрощения разработки COM-серверов и предоставляет лишь простую (но чрезвычайно эффективную) модель обработки окон, то WTL включает довольное большое количество дополнительных шаблонов классов и макросов, предназначенных специально для поддержки создания более сложных окон на основе ресурсов USER и GDI. WTL теперь доступна на сайте SourceForge (wtl.sourceforge.net), но для нового приложения, использующего современный механизм рендеринга, она не представляет особой ценности. Тем не менее, в ней есть ряд полезных вспомогательных средств. Из заголовочного файла WTL (atlapp.h) можно позаимствовать ее реализацию цикла обработки сообщений для замены самодельной версии, которую я описывал ранее:

CMessageLoop loop;
loop.Run();

Хотя ее легко включить в свое приложение и использовать, в WTL заключена огромная мощь, которая раскрывается, если вам нужны сложные принципы фильтрации и диспетчеризации сообщений. WTL также предоставляет atlcrack.h с макросами, предназначенными для замены обобщенного макроса MESSAGE_HANDLER из ATL. Эти макросы созданы исключительно для большего удобства, но реально упрощают подготовку к работе с новым сообщением, так как берут на себя всю черновую работу и избавляют вас от гаданий в интерпретации WPARAM и LPARAM. Хороший пример — WM_SIZE, которое упаковывает новую клиентскую область окна в младшее и старшее слова своего LPARAM. В случае ATL это могло бы выглядеть так:

BEGIN_MSG_MAP(Window)
  ...
  MESSAGE_HANDLER(WM_SIZE, SizeHandler)
END_MSG_MAP()
LRESULT SizeHandler(UINT, WPARAM, 
  LPARAM lparam, BOOL &)
{
  auto width = LOWORD(lparam);
  auto height = HIWORD(lparam);
  // Здесь обрабатываем новый размер ...
  return 0;
}

А в случае WTL это заметно упрощается:

BEGIN_MSG_MAP(Window)
  ...
  MSG_WM_SIZE(SizeHandler)
END_MSG_MAP()
void SizeHandler(UINT, SIZE size)
{
  auto width = size.cx;
  auto height = size.cy;
  // Здесь обрабатываем новый размер ...
}

Обратите внимание на новый макрос MSG_WM_SIZE, который заменил обобщенный макрос MESSAGE_HANDLER, в исходной карте сообщений. Функция-член, обрабатывающая сообщение, тоже стала проще. Как видите, здесь нет лишних параметров или возвращаемого значения. Первый параметр — это WPARAM; вы анализируете его, если вам нужно знать, что вызвало изменение размера окна.

Изящество ATL и WTL в том, что они предоставляются просто как набор заголовочных файлов, которые вы можете включать по своему выбору. Вы используете только то, что требуется, а остальное игнорируете. Однако, как я уже показывал здесь, вы можете прекрасно обойтись без этих библиотек и писать свое приложение, используя Windows API. Присоединяйтесь ко мне в следующей статье, когда я буду демонстрировать современный подход к визуализации пикселей в окне приложения.


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

Выражаю благодарность за рецензирование статьи эксперту Ворачаи Чаовеерапраситу (Worachai Chaoweeraprasit).