Portabilité sans macros

Ce texte est directement inspiré d'une technique proposée par Michael Tedder dans un texte de 2011 : http://altdevblogaday.com/2011/06/27/platform-abstraction-with-cpp-templates/

Lorsque nous souhaitons écrire du code multiplateforme en C ou en C++, deux grandes familles de solutions sont traditionnellement appliquées. L'une dépend de macros, l'autre dépend de compilation séparée et de la séparation de l'interface et de l'implémentation.

Pour illustrer notre propos, nous utiliserons un exemple simplifié cherchant à invoquer un service de suspension volontaire de l'exécution d'un programme. Ce type de service porte un nom différent d'une plateforme à l'autre (Sleep(), sleep(), usleep(), sginap(), System::Threading::Thread::Sleep(), etc.) et offre une sémantique tout aussi variable (on passe à ces fonctions un nombre de secondes, de millisecondes, de microsecondes, de tics, ... Certains sont des sommeils interruptibles, d'autres non, etc.).

La portabilité par des macros a recours à une inclusion conditionnelle en fonction des plateformes. Le code à droite en offre un exemple relativement conforme aux usages (il se peut que je n'aie pas choisi les meilleures fonctions de suspension volontaire dans chaque cas, mais bon, c'est un exemple sans prétention) :

  • On y trouve une vérification de la présence de divers symboles connus représentant les plateformes de destination envisagées. Ici, j'y vais à l'oeil et je n'ai probablement pas les bonnes, mais si vous compilez pour une plateforme donnée et si vous appliquez cette technique, alors il vous faudra les connaître. Sur toute plateforme, certains symboles sont définis d'office à la compilation et peuvent être testés par un programme pour que ce dernier puisse utiliser des services non-portables seulement si cela s'avère opportun
  • On y trouve une mise en correspondance entre le symbole à utiliser dans le code (ici : dodo(ms) ms est un nombre de millisecondes) et son équivalent sur la plateforme pour laquelle la compilation est réalisée. Ici, nous avons supposé des fonctions (ou des macros) de conversion pour les paramètres, qui sont pour la plupart banales à écrire (prudence avec les types : dans certains cas, une division est requise et il et probable qu'un transtypage soit requis)
  • Enfin, pour les plateformes non-supportées, une erreur à la compilation est générée, pour éviter une séance de débogage sur la base d'erreurs cryptiques
#ifdef __WIN32
#define dodo(ms) Sleep(ms)
#elif defined(__sgi)
#define dodo(ms) sginap(ms_to_ticks(ms))
#elif defined (__linux)
#define dodo(ms) sleep(ms_to_seconds(ms))
#elif defined (__aix)
#define dodo(ms) usleep(ms_to_microseconds(ms))
#elif defined (__qnx)
#define dodo(ms) sleep(ms_to_seconds(ms))
#else
#error "Plateforme non supportée"
#endif

Notez que les directives de plateformes ne sont pas toujours mutuellement exclusives (p. ex. : si vous compilez pour Android, la macro __ANDROID__ sera définie mais la macro __linux__ le sera aussi, alors mieux vaut organiser les tests en conséquence).

Évidemment, on suppose les en-têtes appropriés inclus chaque fois (sinon, au pire, vous pouvez les ajouter, par exemple <windows.h> pour Sleep()).

De tels échafaudages sont très fréquemment rencontrés en pratique (c'est idiomatique du code C portable), mais sont complexes à maintenir à moyen terme et reposent sur la discipline des programmeurs (il faut s'assurer de bien couvrir toutes les plateformes visées, de tenir à jour les symboles de chaque plateforme, de ne pas faire la moindre erreur de conversion, etc.).

En retour, le code client qui repose sur l'abstraction portable (la « fonction » dodo(ms) dans ce cas-ci) sera tel que ses sources seront portables sur les diverses plateformes supportées.

Une autre pratique répandue est de découpler l'interface de l'implémentation, tout simplement. L'interface est portable, et l'implémentation sollicite des services propres à la plateforme.

Dans un tel cas, il est possible d'avoir un fichier source par plateforme, tout comme il est possible d'implémenter le tout en appliquant, « sous la couverture », l'approche par inclusions conditionnelles décrite un peu plus haut.

Il n'est d'ailleurs pas rare que les diverses implémentations non-portables soient groupées dans une bibliothèque et liées en bloc à l'exécutable final. Notez au passage que le recours au type int ici pour dénoter un délai en millisecondes est discutable, car peu explicite; un type représentant l'unité de mesure serait nettement préférable.

#ifndef DODO_H
#define DODO_H
void dodo(int ms);
#endif
#include "dodo.h"
#include <windows.h>
void dodo(int ms)
   { Sleep(ms); }

Les macros ont le gros défaut d'échapper au compilateur. Le préprocesseur les traite avant que la compilation n'ait lieu, ce qui complique le développement. Une alternative intéressante est de procéder de manière générique.

Pour être efficaces, supposons que votre entreprise utilise des symboles en fonction des plateformes pour lesquelles vous compilez.

Ici, j'utiliserai des types plutôt que des constantes. Vous auriez aussi pu utiliser des constantes statiques entières, au choix. Il est possible que j'aie mal ciblé mes plateformes (par exemple, peut-être aurait-il été préférable de remplacer PlateformeWindows par un template tel que PlateformWindows<int N> N pourrait être 32 ou 64, pour mieux profiter de certains services).

Nous terminons le tout par une sélection du type (ou de la constante) approprié(e) pour la plateforme de destination. C'est ce nom, Plateforme, que nous utiliserons par la suite.

class PlateformeWindows {};
class PlateformeLinux {};
class PlateformeAix {};
class PlagteformeQnx {};
class PlateformeSgi {};
//
// ICI: Plateforme sera le nom à utiliser dans
// le code client. J'ai mis une plateforme en
// particulier, mais c'est à titre d'exemple,
// rien de plus
//
using  Plateforme = PlateformeWindows;

Par la suite, nous pouvons spécialiser les services à offrir en fonction des plateformes, sans avoir recours à des macros. Vous trouverez à droite un synopsis de la technique.

L'exemple à droite suppose ici encore que les inclusions propres à la plateforme utilisée ont été faites.

Vous remarquerez ici que nous obtiendrons le même degré très direct d'accès aux services (inlining possible, entre autres) qu'avec les macros, mais sans toutefois introduire une dépendance envers un mécanisme externe au compilateur.

Le code non-portable des versions inutilisées des divers services ne sera pas généré, selon le principe SFINAE, et ne causera pas d'erreurs à la compilation dans la mesure où il est syntaxiquement correct. Il aurait aussi été possible de séparer l'interface de l'implémentation et de placer le code des fonctions de sommeil dans un fichier source distinct.

La définition du type sommeil en fin de parcours est une simplification syntaxiqueé

namespace platform_dependent
{
   template <class>
      struct sommeil
      {
         static void executer(int ms)
         {
            // comportement par défaut
            // générique, si applicable
         }
      };
   template <>
      struct sommeil<PlateformeWindows>
      {
         static void executer(int ms)
            { Sleep(ms); }
      };
   template <>
      struct sommeil<PlateformeLinux>
      {
         static void executer(int ms)
            { sleep(ms_to_seconds(ms)); }
      };
   // etc.
}
using commeil = platform_dependent::sommeil<
   Plateforme
>;

Un programme de test correct utilisant le service de sommeil propre à la plateforme serait celui à droite. Pas mal, n'est-ce pas?

#include "sommeil.h"
int main()
{
   // dormir une seconde
   sommeil::executer(1000);
}

Valid XHTML 1.0 Transitional

CSS Valide !