Partager via


Windows et C++

Création d'applications de bureau avec Visual C++ 2012

Kenny Kerr

 

Kenny KerrSuite à l'effervescence autour de Windows 8 et de ce que nous connaissons maintenant comme les applications du Windows Store, j'ai reçu des questions sur la pertinence des applications de bureau et la viabilité à long terme du choix du C++ standard. Il n'est pas toujours facile de répondre à ces questions, mais il est certain que le compilateur Visual C++ 2012 est plus que jamais engagé dans le C++ standard, et qu'il reste la meilleure chaîne d'outils, à mon avis, pour développer des applications de bureau puissantes pour Windows, qu'il s'agisse de Windows 7, Windows 8 ou même Windows XP.

Une question subsidiaire que je reçois inévitablement concerne le meilleur moyen d'aborder le développement d'applications de bureau sur Windows et la façon de procéder. Eh bien, dans l'article de ce mois-ci, je vais aborder les notions fondamentales de la création d'applications de bureau avec Visual C++. Lorsque j'ai commencé à programmer pour Windows avec Jeff Prosise (bit.ly/WmoRuR), Microsoft Foundation Classes (MFC) représentait une nouvelle solution prometteuse pour le développement d'applications. Bien que toujours disponible, MFC est de plus en plus désuet, et la nécessité de trouver des alternatives modernes et flexibles a poussé les programmeurs à rechercher de nouvelles approches. Ceci est d'autant plus vrai dans la mesure où les ressources USER et GDI sont de moins en moins utilisées (msdn.com/library/ms724515) et remplacées par Direct3D comme base pour le rendu de contenu à l'écran.

Pendant de nombreuses années, j'ai fait la promotion de la Bibliothèque ATL (Active Template Library) et de son extension, WTL (Windows Template Library), qui sont d'excellentes solutions de développement d'applications. Cependant, même ces bibliothèques commencent à être dépassées. Les ressources USER et GDI étant de moins en moins utilisées, le recours à ces bibliothèques est de moins en moins justifié. Alors, par où commencer ? Eh bien, par l'API Windows, bien sûr. Je vous montrerai que la création d'une fenêtre de bureau sans aucune bibliothèque n'est pas aussi effrayante qu'il peut y paraître à première vue. Nous verrons ensuite comment y apporter une touche de C++, si vous le souhaitez, avec un petit coup de main de la part d'ATL et de WTL. ATL et WTL prennent tout leur sens lorsque vous avez une idée claire du fonctionnement sous-jacent aux modèles et macros.

API Windows

Le problème que pose l'utilisation de l'API Windows pour la création d'applications de bureau est qu'il existe de nombreuses façons (trop, en réalité) de procéder. Malgré tout, il y a une façon simple de créer une fenêtre et cela commence par le fichier maître include pour Windows :

#include <windows.h>

Vous pouvez ensuite définir le point d'entrée standard pour les applications :

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

Si vous codez une application de console, vous pouvez simplement continuer d'utiliser la fonction de point d'entrée principal C++ standard. Mais je pars du principe que vous ne souhaitez pas qu'une boîte de console s'affiche chaque fois que votre application démarre. La fonction wWinMain a une longue histoire. La convention d'appel __stdcall met au clair la complexe architecture x86, qui fournit plusieurs conventions d'appel. Si vous ciblez x64 ou ARM, cela n'a pas d'importance, car le compilateur Visual C++ n'implémente qu'une seule convention d'appel sur ces architectures (mais, cela ne fait pas de mal non plus).

Les deux paramètres HINSTANCE sont particulièrement mystérieux. À l'époque où Windows était en 16 bits, la deuxième instance HINSTANCE constituait le handle de toute instance préalable de l'application. Cela permettait à l'application de communiquer avec toute instance préalable d'elle-même, ou encore de revenir à l'instance préalable si l'utilisateur redémarrait l'application par erreur. Aujourd'hui, ce deuxième paramètre est toujours un nullptr. Vous avez peut-être remarqué que j'ai appelé le premier paramètre « module » et non « instance ». Ici encore, sous Windows 16 bits, les instances et les modules étaient deux choses totalement distinctes. Toutes les applications partageaient le module contenant les segments de code, mais chacune disposait d'instances uniques contenant les segments de données. Vous comprenez sans doute mieux maintenant la logique sous-jacente aux deux paramètres HINSTANCE (actuel et préalable). Avec Windows 32 bits sont apparus les espaces d'adressage distincts ainsi que la nécessité pour chaque processus de mapper ses propres instances/modules, deux éléments désormais interchangeables. Aujourd'hui, il ne s'agit plus que de l'adresse de base de l'exécutable. L'éditeur de liens Visual C++ expose, en réalité, l'adresse par le biais d'une pseudo-variable, à laquelle vous accédez par la déclaration suivante :

extern "C" IMAGE_DOS_HEADER __ImageBase;

L'adresse de __ImageBase sera identique à la valeur du paramètre HINSTANCE. En fait, c'est de cette façon que la bibliothèque Runtime C (CTR) obtient l'adresse du module à transmettre à la fonction wWinMain. C'est un raccourci pratique si vous ne souhaitez pas que le paramètre wWinMain circule dans votre application. N'oubliez pas, cependant, que cette variable pointe sur le module actuel, qu'il s'agisse d'une DLL ou d'un exécutable, et s'avère donc utile lorsqu'il s'agit de charger sans ambiguïté les ressources d'un module spécifique.

Le paramètre suivant doit fournir tout argument de la ligne de commande, et le paramètre final est une valeur qui doit être transmise à la fonction ShowWindow pour la fenêtre principale de l'application, dans la mesure où vous avez au départ appelé ShowWindow. Ironiquement, ce détail est souvent ignoré. Cela vient de la façon dont une application est lancée par le processus CreateProcess et ses dérivés afin de permettre à un raccourci (ou une autre application) de définir si la fenêtre principale de l'application doit être affichée en taille réduite, agrandie ou normale au démarrage.

À l'intérieur de la fonction wWinMain, l'application doit enregistrer une classe de fenêtre. Cette classe est décrite par une structure WNDCLASS et enregistrée avec la fonction RegisterClass. Cet enregistrement est stocké dans un tableau à l'aide d'une paire composée d'un pointeur vers le module et du nom de la classe, permettant à la fonction CreateWindow de rechercher les informations de la classe au moment de créer la fenêtre :

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

Afin de vous présenter des exemples concis, j'utiliserai simplement la macro VERIFY courante comme espace réservé pour indiquer les emplacements où vous devrez ajouter un système de gestion des erreurs dans le but de gérer les échecs éventuellement signalés par les diverses fonctions API. Considérez-les comme des espaces réservés pour votre méthode habituelle de gestion des erreurs.

Le code ci-dessus constitue le minimum requis pour décrire une fenêtre standard. La structure WNDCLASS débute avec une paire d'accolades vide. Vous vous assurez ainsi que tous les membres de la structure sont remis à zéro ou définis comme nullptr. Les seuls membres qui doivent être définis sont hCursor pour indiquer quel pointeur de souris, ou curseur, utiliser lorsque la souris passe sur la fenêtre, hInstance et lpszClassName pour identifier la classe fenêtre au sein du processus et lpfnWndProc pour pointer sur la procédure de fenêtre qui traite les messages envoyées à la fenêtre. Pour aujourd'hui, j'utilise une expression lambda pour ne pas compliquer l'explication. Je reviendrai sur la procédure de fenêtre tout à l'heure. La prochaine étape consiste créer la fenêtre :

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

La fonction CreateWindow fait appel à un certain nombre de paramètres, mais pour la plupart, la valeur par défaut peut être conservée. Le premier et l'antépénultième paramètres, comme je vous l'ai dit, représentent ensemble la clé que la fonction RegisterClass crée pour permettre à CreateWindow de trouver les informations sur la classe de fenêtre. Le second paramètre présente le texte qui va être affiché dans la barre de titre de la fenêtre. Le troisième spécifie le style de la fenêtre. La constante WS_OVERLAPPEDWINDOW est un style fréquemment utilisé. Il décrit une fenêtre de niveau supérieur classique disposant d'une barre de titre à boutons, de contours redimensionnables, etc. En le combinant avec la constant WS_VISIBLE, vous indiquez à CreateWindow d'afficher la fenêtre. Si vous ne spécifiez pas WS_VISIBLE, vous devrez appeler la fonction ShowWindow afin de permettre à votre fenêtre de faire ses débuts sur le bureau.

Les quatre paramètres suivants spécifient la position et la taille initiales de la fenêtre, et la constante CW_USEDEFAULT utilisée chaque fois indique simplement à Windows de choisir les valeurs par défaut adéquates. Les deux paramètres suivants constituent le handle de la fenêtre parent de la fenêtre et son menu, respectivement (et ni l'un, ni l'autre ne sont nécessaires). Le paramètre final fournit la possibilité de transmettre une valeur de dimensions de pointeur à la fenêtre au cours de la procédure de création. Si tout fonctionne correctement, une fenêtre apparaît sur le bureau et un handle de fenêtre est retourné. Si ça ne se passe pas aussi bien que prévu, nullptr est retourné au lieu du handle et la fonction GetLastError peut être utilisée pour découvrir ce qui n'a pas fonctionné. Malgré tous les bruits qui courent sur les difficultés d'utilisation de l'API Windows, la création d'une fenêtre est finalement assez simple et se résume à ceci :

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

Une fois que la fenêtre apparaît, il est important que votre application commence à distribuer des messages au plus vite (sinon, elle donnera l'impression de ne pas répondre). Windows est, avant tout, un système d'exploitation piloté par des événements et basé sur des messages. En particulier lorsqu'il s'agit de la version bureau. Alors que c'est Windows qui crée et gère la file d'attente des messages, c'est à l'application de les sortir de la file d'attente et de les distribuer, car les messages sont envoyés au thread de la fenêtre, et non directement à la fenêtre. Vous disposez grâce à cela d'une grande flexibilité, mais une boucle de messages simple n'a pas besoin d'être très complexe, comme illustré ci-dessous :

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

C'est peut-être évident, mais cette boucle de messages apparemment simple est souvent mal implémentée. Cela vient du fait que la fonction GetMessage est configurée par défaut pour renvoyer une valeur BOOL, alors qu'en réalité, il s'agit simplement d'un nombre entier. GetMessage retire un message, le récupère, dans la file d'attente des messages du thread appelant. Cela se passe ainsi pour toute fenêtre ou aucune fenêtre. Mais dans notre cas, le thread gère des messages pour une seule fenêtre. Si le message WM_QUIT sort de la file d'attente, alors GetMessage revient à zéro, indiquant que la fenêtre a disparu et a terminé de traiter les messages, ce qui signifie que l'application peut se fermer. Si un gros problème survient, GetMessage peut alors renvoyer -1 et vous pouvez alors aussi faire appel à GetLastError pour en savoir plus. Sinon, toute valeur autre que zéro retournée par GetMessage signifie que le message est sorti de la file d'attente et qu'il est prêt à être distribué à la fenêtre. Bien entendu, c'est exactement le but de la fonction DispatchMessage. Bien sûr, il existe de nombreuses variantes possibles de la boucle de messages, et avoir la possibilité de façonner la vôtre vous offre le choix entre plusieurs fonctionnements possibles pour votre application, plusieurs types d'entrées acceptés et plusieurs façons de les interpréter. À l'exception du pointeur de message, les paramètres restants de GetMessage peuvent être utilisés pour ajouter le filtrage des messages en option.

La procédure de la fenêtre commencera à recevoir des messages avant même le retour de la fonction CreateWindow. La fenêtre doit donc être prête et disponible. Mais à quoi cela ressemble-t-il ? Une fenêtre a besoin d'une table de messages ou d'un tableau. Il peut s'agir, tout simplement, d'une chaîne d'arguments if-else ou d'un grand argument switch à l'intérieur de la procédure de fenêtre. Mais ces procédés rendent rapidement le code difficile à manier, et de gros efforts ont été faits dans différentes bibliothèques et différents frameworks pour tenter de le gérer plus efficacement. En réalité, il n'y a pas besoin de quoi que ce soit de très sophistiqué. Un simple tableau statique suffit dans de nombreux cas. Pour commencer, il est utile de savoir de quoi est constitué un message de fenêtre. L'élément le plus important est la constante (telle que WM_PAINT or WM_SIZE) qui permet d'identifier le message sans ambiguïté. Chaque message dispose de deux arguments (si on peut les appeler ainsi), qui s'appellent WPARAM et LPARAM. En fonction du message, ils peuvent ne fournir aucune information. Enfin, Windows s'attend à ce que le traitement de certains messages retourne une valeur, qui est appelée LRESULT. La plupart des messages que traite votre application ne retourneront, en revanche, aucune valeur et doivent plutôt renvoyer zéro.

Sur la base de cette définition, nous pouvons construire un tableau simple de traitement des messages en utilisant ces types de blocs de construction :

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

Au minimum, nous pouvons ensuite créer un tableau statique de gestionnaires de messages, comme illustré dans la figure 1.

Figure 1 Tableau statique des gestionnaires de messages

static message_handler s_handlers[] =
{
  {
    WM_PAINT, [] (HWND window, WPARAM, LPARAM) -> LRESULT
    {
      PAINTSTRUCT ps;
      VERIFY(BeginPaint(window, &ps));
      // Dress up some pixels here!
      EndPaint(window, &ps);
      return 0;
    }
  },
  {
    WM_DESTROY, [] (HWND, WPARAM, LPARAM) -> LRESULT
    {
      PostQuitMessage(0);
      return 0;
    }
  }
};

Le message WM_PAINT survient lorsque la fenêtre a besoin d'être dessinée. Cela arrive beaucoup moins actuellement que dans les versions précédentes de Windows, grâce aux avancées technologiques en matière de rendu et de composition sur le bureau. Les fonctions BeginPaint et EndPaint sont des reliques du GDI, mais elles restent nécessaires, même si vous dessinez avec un moteur de rendu complètement différent. Cela vient du fait qu'elles servent à avertir Windows que vous avez fini de dessiner en validant la surface de dessin de la fenêtre. Sans ces appels, Windows ne considèrerait pas que le message WM_PAINT a obtenu réponse et votre fenêtre recevrait inutilement un flux constant de messages WM_PAINT.

Le message WM_DESTROY arrive une fois que la fenêtre a disparu, et vous prévient que la fenêtre est en train d'être détruite. Cela indique généralement que l'application doit se fermer, mais la fonction GetMessage de la boucle de messages attend encore le message WM_QUIT. Mettre ce message dans la file d'attente est le rôle de la fonction PostQuitMessage. Son paramètre unique accepte une valeur qui est transmise par le WPARAM de la fonction WM_QUIT, ce qui constitue un moyen de retourner différents codes de sortie lors de l'arrêt de l'application.

La dernière étape de ce puzzle est l’implémentation de la procédure de fenêtre en elle-même. J'ai omis le corps de l'expression lambda que j'ai utilisée afin de commencer par la préparation de la structure de la classe WNDCLASS. Mais, avec ce que vous savez désormais, vous n'aurez sans doute pas de mal à deviner à quoi cela peut ressembler :

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);
};

La boucle for recherche un gestionnaire correspondant. Par chance, Windows fournit le traitement par défaut des messages que vous ne souhaitez pas gérer vous-même. C'est ce dont se charge la fonction DefWindowProc.

Et voilà. Vous y êtes parvenu. Vous avez créé une fenêtre de bureau en utilisant l'API Windows.

La méthode ATL

Le problème des fonctions de l'API Windows est qu'elles ont été conçues bien avant que C++ ne rencontre le succès qu'il connaît aujourd'hui, et qu'elles ne sont donc pas conçues pour une vision du monde orientée objet. Malgré tout, avec un code suffisamment astucieux, cette API de style C peut être transformée en un outil mieux adapté au programmateur C++ ordinaire. ATL offre une bibliothèque de modèles de classes et de macros à cet effet. Alors, si vous devez gérer un bon nombre de classes de fenêtres ou si vous utilisez toujours les ressources USER et GDI pour l'implémentation de votre fenêtre, il n'y a vraiment pas de raison de vous priver d'ATL. La fenêtre de la section précédente peut être exprimée avec ATL de la façon présentée dans la figure 2.

Figure 2 Expression d'une fenêtre en 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));
    // Dress up some pixels here!
    EndPaint(&ps);
    return 0;
  }
  LRESULT DestroyHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PostQuitMessage(0);
    return 0;
  }
};

La classe CWindowImpl fournit le routage des messages dont vous avez besoin. CWindow est une classe de base qui fournit de nombreux wrappers de fonctions membres, principalement pour que vous n'ayez pas à fournir le handle de fenêtre explicitement à chaque appel de fonction. Vous pouvez le constater dans les appels des fonctions BeginPaint et EndPaint de cet exemple. Le modèle CWinTraits fournit les constantes de style de la fenêtre qui seront utilisées pendant la création.

Les macros sont à l'écoute de MFC et travaillent avec CWindowImpl pour associer les messages entrants et les fonctions membres adéquates pour les traiter. Chaque gestionnaire reçoit la constante de message comme premier argument. Cela peut être utile si vous avez besoin de traiter des messages variés avec une seule fonction membre. Le paramètre final est définit comme TRUE par défaut et laisse le gestionnaire décider lors de l'exécution s'il souhaite traiter le message ou laisser Windows (ou un autre gestionnaire) s'en charger. Ces macros, associées à CWindowImpl, sont assez puissantes et vous permettent de gérer des messages reflétés, d'enchaîner les tables des messages les unes autres, etc.

Pour créer la fenêtre, vous devez utiliser la fonction membre Create dont votre fenêtre hérite grâce à CWindowImpl, ce qui déclenchera pour vous l'appel des fonctions traditionnelles que sont RegisterClass et CreateWindow :

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

À ce stade, le thread doit rapidement se remettre à distribuer des messages, et la boucle de messages de l'API Windows exposée dans la section précédente devrait suffire. L'approche ATL est, certes, très pratique si vous avez besoin de gérer plusieurs fenêtres sur un thread unique, mais si vous n'avez qu'une seule fenêtre de niveau supérieur, cela correspond presque intégralement à ce que donne l'approche avec l'API Windows présentée dans la section précédente.

WTL : une dose supplémentaire d'ATL

Alors qu'ATL a été conçu principalement pour simplifier le développement des serveurs COM et ne fournit qu'un simple (mais extrêmement efficace) modèle de traitement des fenêtres, WTL est constitué d'une pléiade de modèles de classes et de macros supplémentaires, conçus spécifiquement pour prendre en charge la création de fenêtres plus complexes basées sur des ressources USER et GDI. WTL est désormais disponible sur SourceForge (wtl.sourceforge.net), mais pour une nouvelle application utilisant un moteur de rendu moderne, il ne présente pas beaucoup d'intérêt. Il dispose, malgré tout, de quelques petits trucs utiles. Dans l'en-tête WTL atlapp.h, vous pouvez utiliser son implémentation de boucle de messages pour remplacer celle (créée manuellement) que je vous ai décrite ci-dessus :

CMessageLoop loop;
loop.Run();

Même s'il est simple de le mettre dans votre application et de l'utiliser, WTL est bourré de solutions puissantes si vous appliquez des filtres de messages et un routage sophistiqués. WTL fournit aussi à atlcrack.h des macros destinées à remplacer la macro générique MESSAGE_HANDLER de ATL. Ce ne sont que de petites améliorations, mais qui facilitent véritablement la mise en place d'un nouveau message en se chargeant de l'essentiel, afin de vous éviter d'essayer de deviner comment interpréter WPARAM et LPARAM. WM_SIZE en est un bon exemple. Il permet d'insérer le nouvel espace du client de la fenêtre sous la forme des mots d'ordre bas et hauts de son LPARAM. Avec ATL, cela donnerait quelque chose comme ceci :

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);
  // Handle the new size here ...
  return 0;
}

Avec l'aide de WTL, c'est un peu plus simple :

BEGIN_MSG_MAP(Window)
  ...
  MSG_WM_SIZE(SizeHandler)
END_MSG_MAP()
void SizeHandler(UINT, SIZE size)
{
  auto width = size.cx;
  auto height = size.cy;
  // Handle the new size here ...
}

Remarquez la nouvelle macro MSG_WM_SIZE qui remplace la macro générique MESSAGE_HANDLER dans la table de messages d'origine. La fonction membre qui traite le message est aussi simplifiée. Comme vous le voyez, il ne s'agit pas de paramètres inutiles ni d'une valeur retournée. Le premier paramètre est simplement WPARAM, que vous pouvez inspecter si vous avez besoin de connaître la cause du changement de taille.

Le plus d'ATL et de WTL est qu'ils sont fournis sous la forme d'un simple ensemble de fichiers d'en-tête que vous pouvez ajouter comme bon vous semble. Vous prenez ce dont vous avez besoin et ne vous occupez pas du reste. Cependant, comme je vous l'ai montré ici, vous pouvez aller assez loin sans avoir à vous appuyer sur ces bibliothèques et vous contenter de coder votre application avec l'API Windows. Ne manquez pas mon prochain article dans lequel je vous présenterai une approche moderne pour le rendu des pixels dans la fenêtre de votre application.

Kenny Kerr est programmeur en informatique, basé au Canada, auteur chez Pluralsight et MVP chez Microsoft. Il possède un blog sur kennykerr.ca et vous pouvez le suivre sur Twitter à l'adresse twitter.com/kennykerr.

Merci à l'expert technique suivant d'avoir relu cet article : Worachai Chaoweeraprasit