Windows avec C++

C++ et l'API Windows

Kenny Kerr

Kenny KerrL'API Windows représente un défi pour le développeur C++. Les différentes bibliothèques qui constituent l'API sont, pour la plupart, exposées en tant que fonctions et descripteurs de style C, ou en tant qu'interfaces de style COM. Aucune de ces options n'est très pratique à utiliser et l'une comme l'autre nécessitent un certain niveau d'encapsulation ou d'indirection.

Pour le développeur C++, le défi consiste à déterminer le niveau d'encapsulation. Les développeurs qui ont toujours connu des bibliothèques comme MFC et ATL peuvent avoir tendance à tout encapsuler sous forme de classes et de fonctions membres, car ce modèle est utilisé par les bibliothèques C++ sur lesquelles ils s'appuient depuis si longtemps. D'autres développeurs quant à eux méprisent tout type d'encapsulation et se contentent d'utiliser directement les interfaces, fonctions et descripteurs bruts. Ces derniers ne sont sans doute pas réellement des développeurs C++, mais simplement des développeurs C souffrant d'un problème d'identité. Il existe à mon avis un juste milieu plus naturel pour le développeur C++ actuel.

Comme je reprends ma rubrique dans MSDN Magazine, je vais vous montrer comment utiliser C++0x (ou C++ 2011 comme il sera vraisemblablement nommé) avec l'API Windows pour faire sortir le développement logiciel Windows natif de l'âge de pierre. Au cours des prochains mois, nous allons faire un tour complet de l'API de pool de threads de Windows. Suivez-moi et vous découvrirez comment écrire des applications étonnamment évolutives sans recourir aux nouveaux langages fantaisistes, ni à des runtimes coûteux et compliqués. Vous devez juste disposer d'un excellent compilateur Visual C++, de l'API Windows et avoir envie de maîtriser votre art.

Comme dans tous les bons projets, certaines tâches préparatoires sont nécessaires pour prendre un bon départ. Comment vais-je donc m'y prendre pour « encapsuler » l'API Windows ? Plutôt que d'encombrer tous les articles suivants avec ces détails, je vais détailler l'approche que je recommande aujourd'hui et m'en servir comme base pour la suite. Pour le moment, je vais laisser de côté le problème des interfaces de style COM, car nous n'en aurons pas besoin dans les prochains articles.

L'API Windows est constituée de nombreuses bibliothèques qui exposent un ensemble de fonctions de style C et d'un ou plusieurs pointeurs opaques appelés descripteurs. Ces descripteurs représentent généralement une bibliothèque ou des ressources système. Les fonctions permettent de créer, manipuler et libérer les ressources à l'aide de descripteurs. À titre d'exemple, la fonction CreateEvent crée un objet d'événement, en renvoyant un descripteur à l'objet d'événement. Pour libérer le descripteur et informer le système que vous avez fini d'utiliser l'objet d'événement, transmettez simplement le descripteur à la fonction CloseHandle. S'il n'existe pas d'autres descripteurs en attente pour le même objet d'événement, le système détruira ce dernier :

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

Novice en C++

Si vous êtes novice en C++ 2011, je tiens à signaler que le mot clé Auto indique au compilateur de déduire le type de variable à partir de l'expression d'initialisation. Ceci est utile lorsque vous ne connaissez pas le type d'une expression, comme c'est souvent le cas en métaprogrammation, ou que vous souhaitez simplement enregistrer quelques séquences de touches.

Mais vous ne devriez quasiment jamais écrire du code de cette manière. La fonctionnalité de classe est indéniablement la plus précieuse de toutes celles que C++ propose. Certes, les modèles sont géniaux et la bibliothèque STL (Standard Template Library) est magique, mais sans classe rien n'a de sens en C++. La classe est ce qui rend les programmes C++ concis et fiables. Je ne parle pas des fonctions virtuelles, d'héritage ou d'autres fonctionnalités sophistiquées. Je parle uniquement d'un constructeur et d'un destructeur. C'est souvent tout ce dont vous avez besoin, et devinez quoi ? Cela ne vous coûte rien. En pratique, vous devez être conscient de la surcharge imposée par la gestion des exceptions, et je traiterai ce point à la fin de cet article.

Pour maîtriser l'API Windows et la rendre accessible aux développeurs C++ modernes, une classe qui encapsule un descripteur est nécessaire. Il est certes possible que votre bibliothèque C++ favorite contienne déjà un wrapper de descripteur, mais a-t-il été conçu dès le départ pour C++ 2011 ? Pouvez-vous stocker de manière fiable ces descripteurs dans un conteneur STL et les insérer dans votre programme sans perdre la trace de leur propriétaire ?

La classe C++ est l'abstraction parfaite pour les descripteurs. Notez que je ne dis pas « objets ». Souvenez-vous que le descripteur est le représentant d'un objet dans votre programme, et non l'objet lui-même la plupart du temps. C'est le descripteur qui a besoin d'être piloté, pas l'objet. Avoir une relation un-à-un entre un objet d'API Windows et une classe C++ peut parfois être pratique, mais il s'agit d'un problème différent.

Même si les descripteurs sont généralement opaques, il en existe malgré tout différents types, ainsi que, souvent, des différences sémantiques subtiles qui nécessitent un modèle de classe pour encapsuler correctement les descripteurs de façon générale. Les paramètres de modèles sont nécessaires pour spécifier le type de descripteur et les caractéristiques spécifiques ou traits du descripteur.

En C++, une classe de traits est couramment utilisée pour fournir des informations sur un type donné. De cette façon, je peux écrire un seul modèle de classe pour les descripteurs et fournir différentes classes de traits pour les différents types de descripteurs dans l'API Windows. Une classe de traits de descripteur doit également définir comment un descripteur est libéré pour que le modèle de classe de descripteur puisse le libérer automatiquement si nécessaire. Voici donc une classe de traits pour des descripteurs d'événements :

struct handle_traits
{
  static HANDLE invalid() throw()
  {
    return nullptr;
  }

  static void close(HANDLE value) throw()
  {
    CloseHandle(value);
  }
};

Comme de nombreuses bibliothèques dans l'API Windows partagent ces sémantiques, elles peuvent être utilisées pour autre chose que les objets d'événements. Comme vous le voyez, la classe de traits se compose uniquement de fonctions membres statiques. En conséquence, le compilateur peut facilement insérer le code et aucune surcharge n'est introduite, tout en offrant une grande souplesse pour la métaprogrammation.

La fonction non valide renvoie la valeur d'un descripteur non valide. Il s'agit habituellement de nullptr, un nouveau mot clé dans C++ 2011 qui représente une valeur de pointeur nulle. Contrairement aux alternatives traditionnelles, nullptr est fortement typé de façon à bien fonctionner avec les modèles et les surcharges de fonctions. Dans certains cas, un descripteur non valide a une autre valeur que nullptr ; c'est pour cela qu'existe l'inclusion de la fonction non valide dans la classe de traits. La fonction Close encapsule le mécanisme par lequel le descripteur est fermé ou libéré.

La classe de traits ainsi présentée, je peux continuer et commencer à définir le modèle de classe de descripteur, comme illustré à la figure 1.

Figure 1 Modèle de classe de descripteur

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

Je l'ai nommé unique_handle parce qu'il partage le même esprit que le modèle de classe unique_ptr standard. De nombreuses bibliothèques utilisent également des sémantiques et des types de descripteurs identiques, il est donc logique d'offrir un typedef, simplement appelé handle, pour les cas les plus fréquents :

typedef unique_handle<HANDLE, handle_traits> handle;

À présent, je peux créer un objet d'événement et le « gérer » comme suit :

handle h(CreateEvent( ... ));

J'ai déclaré le constructeur de copie et l'opérateur d'assignation de copie comme privés et je les ai laissés non implémentés. Ceci empêche le compilateur de les générer automatiquement, car ils sont rarement appropriés pour les descripteurs. L'API Windows permet la copie de certains types de descripteurs, mais le concept est très différent de la sémantique de copie en C++.

Le paramètre de valeur du constructeur s'appuie sur la classe de traits pour fournir une valeur par défaut. Le destructeur appelle la fonction membre Close, qui s'appuie à son tour sur la classe de traits pour fermer le descripteur, si nécessaire. De cette manière, je dispose d'un descripteur compatible avec la pile et garanti contre les exceptions.

Mais je n'ai pas encore fini. La fonction membre Close s'appuie sur la présence d'une conversion booléenne pour déterminer si le descripteur doit être fermé. Bien que C++ 2011 introduise des fonctions de conversion explicite, celles-ci ne sont pas encore disponibles dans Visual C++. J'utilise donc une approche courante de la conversion booléenne pour éviter les conversions implicites redoutées que le compilateur autorise autrement :

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

Cela signifie que je peux maintenant juste tester si j'ai un descripteur valide, sans que des conversions dangereuses puissent passer inaperçues :

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

if (socket && event) {} // Are both valid?

if (!event) {} // Is event invalid?

int i = socket; // Compiler error!

if (socket == event) {} // Compiler error!

Utiliser l'opérateur booléen plus évident aurait permis à ces deux dernières erreurs de passer inaperçues. Cela permet cependant de comparer un socket à un autre, d'où la nécessité d'implémenter de manière explicite les opérateurs d'égalité ou de les déclarer comme privés et de les laisser non implémentés.

La façon de posséder un descripteur, pour un unique_handle, est analogue à la façon de posséder un objet et de le gérer par le biais d'un pointeur, pour un modèle de classe unique_ptr standard. Il est donc logique de fournir les fonctions membres get, reset et release familières pour gérer le descripteur sous-jacent. La fonction get est facile :

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

La fonction reset requiert un peu plus de travail, mais s'appuie sur ce dont j'ai déjà parlé.

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

  return *this;
}

J'ai pris la liberté de modifier légèrement la fonction reset à partir du modèle fourni par unique_ptr en renvoyant une valeur booléenne qui indique si l'objet a été réinitialisé ou non avec un descripteur valide. Ceci peut être utile pour la gestion des erreurs, sur laquelle je reviendrai bientôt. La fonction release doit désormais être évidente :

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

Copier ou déplacer

La touche finale consiste à prendre en compte la sémantique de copie et de déplacement. Comme j'ai déjà banni la sémantique de copie pour les descripteurs, il est logique d'autoriser la sémantique de déplacement. Cela devient essentiel si vous souhaitez stocker des descripteurs dans des conteneurs STL. Ces conteneurs s'appuient traditionnellement sur la sémantique de copie, mais avec l'arrivée de C++ 2011, la sémantique de déplacement est prise en charge.

Sans entrer dans une description interminable de la sémantique de déplacement et des références rvalue, disons que l'idée consiste à autoriser la transmission de la valeur d'un objet à un autre d'une manière qui soit prévisible pour le développeur et cohérente pour les auteurs de bibliothèques et les compilateurs.

Avant C++ 2011, les développeurs devaient recourir à toutes sortes d'astuces compliquées pour éviter le goût excessif du langage, et par extension de STL, pour la copie d'objets. Le compilateur créait souvent une copie d'un objet, puis détruisait immédiatement l'original. Avec la sémantique de déplacement, le développeur peut déclarer qu'un objet ne sera plus utilisé, ni sa valeur déplacée ailleurs, souvent avec une simple permutation de pointeur.

Dans certains cas, le développeur doit être explicite et indiquer ceci ; mais, plus souvent, le compilateur peut tirer parti d'objets prenant en charge les déplacements et réaliser des optimisations incroyablement efficaces qui étaient impossibles auparavant. La bonne nouvelle, c'est que le fait de permettre une sémantique de déplacement pour vos propres classes est simple. De même que la copie s'appuie sur un constructeur de copie et un opérateur d'assignation de copie, la sémantique de déplacement s'appuie sur un constructeur de déplacement et un opérateur d'assignation de déplacement :

unique_handle(unique_handle && other) throw() :
  m_value(other.release())
{
}

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

Référence rvalue

C++ 2011 introduit un nouveau type de référence, appelé référence rvalue. Elle est déclarée à l'aide de && ; c'est ce qui est utilisé dans les membres unique_handle dans le code précédent. Bien qu'elles soient similaires à des références anciennes, appelées à présent références lvalue, les nouvelles références rvalue présentent des règles légèrement différentes en matière d'initialisation et de résolution de surcharge. Pour le moment, je vais laisser ce sujet de côté (j'y reviendrai plus tard). À ce stade, le principal avantage d'un descripteur avec une sémantique de déplacement est que vous pouvez stocker efficacement et correctement des descripteurs dans des conteneurs STL.

Gestion des erreurs

C'est tout pour le modèle de classe unique_handle. Le dernier sujet que je vais traiter ce mois-ci, et qui me permet de vous préparer aux articles à venir, est la gestion des erreurs. Nous pouvons passer des heures à débattre des avantages et des inconvénients des exceptions et des codes d'erreur, mais si vous souhaitez utiliser les bibliothèques C++ standard, vous devez vous habituer aux exceptions. Bien sûr, l'API Windows utilise des codes d'erreur, donc un compromis est nécessaire.

Mon approche de la gestion des erreurs consiste à en faire le moins possible, et à écrire du code garanti sans exception, tout en évitant d'intercepter les exceptions. S'il n'y a pas de gestionnaires d'exceptions, Windows générera automatiquement un rapport d'erreur incluant un minidump de l'incident que vous pouvez déboguer postmortem. Levez des exceptions uniquement lorsque des erreurs d'exécution inattendues se produisent et gérez tout le reste avec des codes d'erreur. Lorsqu'une exception est levée, vous savez qu'il s'agit d'un bogue dans votre code ou qu'un grave problème affecte l'ordinateur.

L'exemple que j'aime donner est celui de l'accès au Registre Windows. L'échec d'écriture d'une valeur dans le Registre est généralement le symptôme d'un problème plus important qui sera difficile à gérer judicieusement dans votre programme. Cela devrait se traduire par une exception. L'échec de lecture d'une valeur du Registre, en revanche, devrait être anticipé et géré correctement. Cela ne devrait pas se traduire par une exception, mais par le renvoi d'une valeur booléenne ou enum indiquant si la valeur ne peut pas être lue ou pourquoi.

L'API Windows n'est pas particulièrement cohérente avec sa gestion des erreurs ; c'est la conséquence d'une API qui a évolué pendant de nombreuses années. Pour l'essentiel, les erreurs sont renvoyées sous forme de valeurs BOOL ou HRESULT. Il en existe d'autres, que j'ai tendance à gérer explicitement en comparant la valeur de retour aux valeurs documentées.

Si je décide qu'un appel de fonction donné doit réussir pour que mon programme continue à fonctionner convenablement, j'utilise l'une des fonctions répertoriées à la figure 2 pour vérifier la valeur de retour.

Figure 2 Vérification de la valeur de retour

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

Deux choses méritent d'être mentionnées à propos de ces fonctions. La première est que la fonction check_bool est surchargée pour que vous puissiez également vérifier la validité d'un objet descripteur, ce qui ne permet pas de conversion implicite en BOOL et ce, à juste titre. La seconde est la fonction check_hr, qui compare explicitement à S_OK plutôt que d'utiliser la macro SUCCEEDED plus courante. Cela évite d'accepter en silence d'autres codes de réussite douteux, tels que S_FALSE, qui ne correspond quasiment jamais aux attentes du développeur.

Ma première tentative d'écriture de ces fonctions de vérification était un ensemble de surcharges. Mais en les utilisant dans plusieurs projets, je me suis rendu compte que l'API Windows définit bien trop de types de résultats et de macros, et qu'il était donc tout simplement impossible de créer un ensemble de surcharges qui fonctionnerait pour tous. D'où les noms de fonction assortis. J'ai trouvé quelques cas dans lesquels les erreurs n'étaient pas détectées en raison d'une résolution de surcharge inattendue. Le type check_failed généré est très simple :

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

  long error;
};

Je pourrais le parer de toutes sortes de fonctionnalités recherchées, telles que l'ajout de support pour les messages d'erreur, mais quel intérêt ? J'inclus la valeur de l'erreur, de façon à pouvoir facilement la sélectionner lors de l'autopsie d'une application qui a rencontré un problème. Au-delà, cela ne fera que nous gêner.

Avec ces fonctions de vérification, je peux créer un objet d'événement et le signaler, en levant une exception si quelque chose se passe mal :

handle h(CreateEvent( ... ));

check_bool(h);

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

Gestion des exceptions

L'autre problème avec la gestion des exceptions concerne l'efficacité. Là encore, les développeurs sont divisés, mais la plupart du temps c'est en raison de présuppositions sans rapport avec la réalité.

Le coût de la gestion des exceptions se fait sentir dans deux domaines. Le premier est la levée des exceptions. Elle a tendance à être plus lente que l'utilisation des codes d'erreur : c'est l'une des raisons pour lesquelles vous ne devriez lever d'exception qu'en cas d'erreur fatale. Si tout se passe bien, cela ne vous coûtera rien.

La seconde cause de contre-performances est liée à la surcharge d'exécution que représente le fait de s'assurer que les destructeurs appropriés soient appelés, dans le cas peu probable où une exception serait levée. Vous avez besoin de code pour effectuer le suivi des destructeurs qui doivent être exécutés. Bien sûr, cela augmente également la taille de la pile, ce qui, dans les bases de code volumineuses, peut affecter les performances de manière significative. Notez que vous en paierez le prix, qu'une exception soit effectivement levée ou non. Il est donc essentiel de limiter ceci pour garantir de bonnes performances.

Cela signifie s'assurer que le compilateur a une idée claire des fonctions susceptibles de lever des exceptions. Si le compilateur peut prouver qu'aucune exception ne proviendra de certaines fonctions, il peut optimiser le code qu'il génère pour définir et gérer la pile. C'est la raison pour laquelle j'ai paré la totalité du modèle de classe de descripteur et les fonctions membres de la classe de traits avec la spécification d'exception. Bien qu'elle soit déconseillée dans C++ 2011, c'est une optimisation importante propre à la plateforme.

C'est tout pour ce mois-ci. Vous disposez à présent de l'un des ingrédients clés pour écrire des programmes fiables à l'aide de l'API Windows. Rejoignez-moi le mois prochain lorsque j'entamerai l'exploration de l'API de pool de threads de Windows.        

Kenny Kerr fait figure d'artisan informatique passionné par le développement Windows natif. Vous pouvez le contacter à cette adresse : kennykerr.ca.