Code de grande personne – une classe Incompilable

Cet article sera inspiré d'une des stratégies typiques de la bibliothèque Boost. Des thématiques passionnantes et stimulantes pour l'esprit, mais définitivement du code de grande personne (quoique cet article soit beaucoup moins lourd sur l'esprit que d'autres de la même nature). L'essentiel de l'idée me trottait dans la tête depuis un bout de temps mais la clé d'une implémentation élégante m'est venue des travaux de Dave Abrahams et d'une particularité a priori toute simple de BOOST_STATIC_ASSERT.

Cet article présume que vous avez lu au préalable l'article sur la métaprogrammation, en particulier la section sur les assertions statiques. J'utiliserai aussi une forme simple de sélection de parent alors il est possible que vous souhaitiez vous familiariser avec ces manoeuvres, bien que ce ne soit pas strictement nécessaire.

Des raffinements ont été apportés à la version originale suite à des commentaires du sympathique Matthieu Schaller puis d'autres amis et étudiants à moi (Patrick Boutot et Alexandre Biron), et enfin suite à des échanges avec les gens oeuvrant sur gcc. Les explications se trouvent à même le texte de l'article, plus bas.

Notez que la technique décrite ici est désuète, donc ne devrait avoir qu'un intérêt historique. Pour atteindre l'objectif décrit dans cet article, utilisez désormais static_assert, qui existe précisément pour cette fin.

Il arrive, croyez-le ou non, qu'on puisse souhaiter avoir recours au stratagème de classe incompilable, au sens de classe qui ne compilera pas si on s'en sert mais qui n'empêchera pas le programme de compiler si on n'y a pas recours.

Pourquoi donc? Imaginons par exemple le code naïf (et canonique) de factorielle statique ci-dessous.

template <int N>
   struct Factorielle {
      enum { value = N * factorielle<N-1>::value };
   };
template<>
   struct Factorielle<0> {
      enum { value = 1 };
   };
#include <iostream>
int main() {
   using namespace std;
   cout << Factorielle<5>::value << endl;
}

Ce code fonctionne très bien dans les cas qu'on pourrait juger « normaux ». J'entends ici les cas où :

Ce sont les autres cas qui nous préoccuperont ici. En particulier, comment faire en sorte que l'instanciation de Factorielle<N>::value échoue gracieusement à la compilation? Par gracieusement, j'entends un échec qui provoquerait un message relativement clair d'erreur quant à la nature du problème.

Évidemment, nous chercherons une solution qui ne change pas la moindre ligne du code client.

Première clé : l'assertion statique

La première clé de notre solution sera d'avoir recours aux assertions statiques. Veuillez vous référer à cet article si vous ressentez le besoin de vous rafraîchir la mémoire, mais l'idée ici est de pouvoir définir un type qui, si on devait s'en servir, provoquerait une erreur de compilation.

Essentiellement, le code requis en C++ 03 pour définir une assertion statique basée sur un booléen connu à la compilation est tel que proposé à droite, en haut.

Maintenant, avec C++ 11, le terme static_assert est un mot clé et l'assertion statique s'exprime sous la forme présentée à droite, en bas. C'est cette forme que nous privilégierons dans le présent document.

//
// Assertion statique (C++ 03)
//
template<bool>
   struct static_assert;
template<>
   struct static_assert<true>{};
int main() {
   static_assert<condition_statique> nom_de_variable_representant_le_probleme;
}
//
// Assertion statique (C++ 11)
//
int main() {
   static_assert(condition_statique, "message d'erreur");
}

Essayer d'instancier un static_assert basé sur un prédicat statique faux provoquera une erreur de compilation. Par exemple, dans les exemples de code ci-dessous, si sizeof(int) == 4, alors celui de gauche compilera et celui de droite ne compilera pas.

int main() {
   static_assert(sizeof(int)==4, "Je compile");
}
int main() {
   static_assert(sizeof(int)==2, "Je ne compile pas");
}

Cette technique est à la fois simple et précieuse pour rédiger du code rapide et robuste. Elle évite en particulier le recours aux macros, chose qu'on cherche à éviter si possible.

Cette clé est d'un intérêt historique, au sens où les versions antérieures de cet article utilisaient static_assert<false> là où la version actuelle utilise maintenant Incomplet (plus bas). Maintenant que static_assert est un mot clé, pas un type, il n'est plus possible de s'en servir pour représenter une entité incomplète, et c'est vraiment de cela que nous avons besoin ici.

Deuxième clé : sélection statique de type

La deuxième clé de notre solution sera de choisir entre deux types (un compilable, un incompilable) à la compilation, sur la base d'une condition statique. Au besoin, référez-vous à cet article pour un rappel.

Une alternative statique permet de « choisir » entre deux types au moment de la compilation d'une programme. Le code est tout simple.

Pour un compilateur C++ 11 conforme, préférez std::conditional (de la bibliothèque <type_traits>) qui s'utilise exactement de la même manière mais qui est un outil standard. Ce sera peut-être équivalent, peut-être mieux, mais sûrement pas moins bon.

template <bool, class, class>
   struct static_if_else;
template <class SiVrai, class SiFaux>
   struct static_if_else<true, SiVrai, SiFaux> {
      using type = SiVrai;
   };
template <class SiVrai, class SiFaux>
   struct static_if_else<false, SiVrai, SiFaux> {
      using type = SiFaux;
   };

Un exemple d'utilisation serait le code ci-dessous, qui fera en sorte que char sera le type de c si est seulement si char est signé pour un compilateur donné (sinon signed char sera utilisé), le tout savoir avoir recours à des macros.

// ... inclusions et using ...
typedef typename conditional<
   static_cast<char>(-1) < ), // condition
   char,       // si la condition est vraie
   signed char // si la condition est fausse
>::type caractere_t;
int main() {
   caractere_t c = 'A';
}

Troisième clé : définir la nature de la classe Incompilable

Ce que nous allons faire maintenant est déterminer deux types, soit un qui sera nommé Compilable et dont la compilation réussira sans peine et un autre, générique, qui sera nommé Incompilable<Raison> et qui ne compilera pas si on s'en sert.

L'idée ici est qu'une classe générique n'existera dans un programme que si on s'en sert. Pour cette raison, Incompilable sera générique – si elle ne l'était pas, sa simple inclusion dans un programme empêcherait la compilation du programme en tout temps! Nous utiliserons la généricité de Incompilable pour faire en sorte de décrire, par le type Raison, la nature du problème.

Ce que nous ferons ensuite est de dériver Factorielle<N> d'une classe Incompilable seulement si N est négatif; si N est positif, alors nous dériverons plutôt Factorielle<N> de Compilable. Ceci rendra illégale toute instanciation de Factorielle<N> pour un N négatif, et nous aurons atteint notre but.

Définir Compilable et Incompilable<Raison>

Tout d'abord, une classe vide est compilable (et sujette au Empty Base Class Optimization), ce qui rend la définition de Compilable banale. Vous pouvez bien entendu remplacer struct par class si cela convient à vos préférences esthétiques.

// Surprise! Ceci compile!
struct Compilable {};

La clé d'un Incompilable<Raison> élégant est d'exprimer, dans Incompilable<Raison>, quelque chose qui sera à la fois illégal et échappera au compilateur à moins que Incompilable<Raison> ne soit instancié. Ceci signifie entre autres qu'une classe comme celle proposée ci-dessous ne serait pas recevable : un template doit être syntaxiquement correct même s'il est destiné à ne pas compiler, et le résultat d'une division par zéro n'est pas un entier légal (le compilateur s'en apercevra même si la classe TentativeInfructueuse n'est pas instanciée).

// ceci ne fonctionnera pas
template <class Raison>
   class TentativeInfructueuse {
      // considéré incorrect par le compilateur même si la
      // classe Tentative n'est jamais générée
      enum { BEL_ESSAI_MAIS_PAS_UNE_SOLUTION = 1 / 0 };
   };

J'ai cherché un certain temps à exprimer cela avec élégance, et c'est dans l'expression de BOOST_STATIC_ASSERT que j'ai trouvé la manoeuvre la plus élégante (à ma connaissance) d'y arriver : définir une constante énumérée (au nom bidon, soit _ qui est un nom légal... Peu importe qu'il soit lisible ou non, puisque ce nom est privé et ne sera jamais utilisé) à partir de la taille d'une classe illégale (tout simplement Incomplet).

class Incomplet;
template <class Raison>
   class Incompilable {
      // ceci est illégal si on l'instancie, du fait
      // qu'Incomplet est un type... incomplet (qui n'a
      // pas de définition), mais ce fait ne peut pas
      // être déduit au préalable par le compilateur
      enum { _ = sizeof(Incomplet) };
   };

Ceci fonctionne presque (en fait, ça fonctionne sur certains compilateurs presque conformes au standard) car l'opérateur sizeof ne sera pas invoqué avant que le code ne soit véritablement généré, le compilateur ne pouvant pas prévoir a priori si Incomplet est une classe complète ou non (un programmeur – malicieux! – aurait pu donner un sens à cette expression, qui sait?).

Notez que je ne me préoccupe pas du type Raison. Techniquement, ce type n'est présent que pour rendre Incompilable générique. Cela dit, il apparaîtra dans les messages d'erreur de la majorité des compilateurs et pourra, par conséquent, jouer le rôle de documentation à même le code et assister les programmeurs dans leurs diagnostics.

Cela dit, il reste un hic, découvert par celles et ceux qui utilisent une version récente de gcc et que l'un des artisans de ce compilateur a eu la gentillesse de m'expliquer. Un compilateur C++ conforme au standard a l'obligation de valider au point de déclaration les énoncés qui ne sont pas génériques, que ceux-ci soient insérés ou non dans une classe elle-même générique.

Conséquence : la forme proposée plus haut est illégale car, au moment où le compilateur rencontre la définition de Incompilable<Raison>::_, le type Incomplet n'est pas défini (et on ne compte pas le définir puisque notre stratégie repose sur ce fait!), et Incomplet est un type indépendant du type Raison dans Incompilable<Raison>.

Par contre, l'énoncé suivant est légal et conforme tout en donnant précisément les mêmes résultats :

class Incomplet;
template <class Raison>
   class Incompilable {
      static Incomplet bloquer_la_compilation();
      enum { _ = sizeof (bloquer_la_compilation()) };
   };

La nuance entre la forme précédente et celle-ci est que la méthode de classe bloquer_la_compilation() est générique sur la base du type Raison – son véritable nom, après tout, est Incompilable<Raison>::bloquer_la_compilation().

Cette dépendance de la méthode envers le type Raison fait que la méthode ne sera considérée que si la classe Incompilable<Raison> est générée pour un type Raison donné. Le compilateur n'évaluera pas sizeof(Incompilable<Raison>::bloquer_la_compilation()) pour les cas où le type Incompilable<Raison> n'est pas généré.

Nous voilà maintenant bien en selles.

Définir Factorielle<N> de manière plus rigoureuse

Sachant que, pour Factorielle<N>, la valeur de N est connue à la compilation, nous utiliserons une alternative statique pour choisir un parent à Factorielle<N> en fonction de N.

La réécriture de Factorielle<N> ira comme suit. Notez le recours à l'héritage privé : le caractère compilable ou non de Factorielle<N> est un détail d'implémentation! Notez aussi que la raison d'un problème est une classe (vide!) dont le nom est significatif pour le problème détecté, ce qui est délibéré ici puisque nous souhaitons voir quelque chose comme « Raison=ValeurNegative » apparaître dans les messages d'erreur lors de la compilation.

//
// La raison du problème, s'il yu en a un :
// Factorielle<N> sera illégal si N < 0
//
class ValeurNegative {};
//
// Factorielle<N> sera compilable si et seulement
// si N est positif
//
template <int N>
   struct Factorielle : private std::conditional_t<(N<0), Incompilable<ValeurNegative>, Compilable > {
      enum { value = N * Factorielle<N-1>::value };
   };
template<>
   struct Factorielle<0> {
      enum { value = 1 };
   };

Nous choisissons donc un parent à Factorielle<N> selon la valeur de N. Notez que Compilable est vide et, sur un bon compilateur, n'influencera pas la taille de Factorielle<N>. Notez aussi que Factorielle<0> n'a pas de parent puisque nous savons déjà qu'il s'agit d'un cas légal.

Solution complète

Une solution complète suit.

C++ 03 C++ 14
template <bool, class, class>
   struct static_if_else;
template <class SiVrai, class SiFaux>
   struct static_if_else<true, SiVrai, SiFaux> {
      typedef SiVrai type;
   };
template <class SiVrai, class SiFaux>
   struct static_if_else<false, SiVrai, SiFaux> {
      typedef SiFaux type;
   };
class Incomplet;
template <class Raison>
   class Incompilable {
      static Incomplet bloquer_la_compilation();
      enum { _ = sizeof (bloquer_la_compilation()) };
   };
struct Compilable {};
//
// La raison du problème, s'il y en a un :
// Factorielle<N>::value sera illégal si N
// est négatif
//
class ValeurNegative {};

//
// Factorielle<N> est compilable ssi N est positif
//
template <int N>
   struct Factorielle : static_if_else<
      (N<0), Incompilable<ValeurNegative>, Compilable
   >::type {
      enum { value = N * Factorielle<N-1>::value };
   };
template<>
   struct Factorielle<0> {
      enum { value = 1 };
   };
#include <iostream>
int main() {
   using namespace std;
   cout << Factorielle<5>::value << endl;
   // ce qui suit ne compile pas si on enlève les commentaires. Essayez-le!
   // cout << Factorielle<-3>::value << endl;
}
#include <type_traits>
class Incomplet;
template <class Raison>
   class Incompilable {
      static Incomplet bloquer_la_compilation();
      enum { _ = sizeof (bloquer_la_compilation()) };
   };
struct Compilable {};
//
// La raison du problème, s'il y en a un :
// Factorielle<N>::value sera illégal si N
// est négatif
//
class ValeurNegative {};

//
// Factorielle<N> est compilable ssi N est positif
//
template <int N>
   struct Factorielle : std::conditional_t<
      (N<0), Incompilable<ValeurNegative>, Compilable
   > {
      enum { value = N * Factorielle<N-1>::value };
   };
template<>
   struct Factorielle<0> {
      enum { value = 1 };
   };
#include <iostream>
int main() {
   using namespace std;
   cout << Factorielle<5>::value << endl;
   // ce qui suit ne compile pas si on enlève les commentaires. Essayez-le!
   // cout << Factorielle<-3>::value << endl;
}

Voilà. Le recours à Incompilable<Raison> nous a permis, sans changer le code client, sans grossir la taille ni ralentir l'exécutable résultant, de clarifier le code client en permettant les cas légitimes de Factorielle<N> et en générant des messages d'erreurs plus pertinents pour les cas que nous n'admettons pas.

Autre exemple

La technique des classes incompilables étant un peu surprenantes, il arrive que mes étudiant(e)s soient perplexes quant à ses cas d'utilisation. Voici donc un autre exemple, qui peut être résolu soit par une assertion statique, soit par une classe incompilable – il existe des cas pour lesquels on préférera l'une ou l'autre de ces deux approches; ici, elles sont essentiellement équivalentes, quoique si votre compilateur supporte les assertions statiques, celles-ci seront plus légères à utiliser et, à votre place, je les privilégierais.

Imaginez le code suivant :

template <class T>
   class Entier {
   public:
      using value_type = T;
   private:
      T valeur_;
   public:
      constexpr Entier(const value_type &valeur) : valeur_{valeur} {
      }
      constexpr value_type valeur() const {
         return valeur_;
      }
      constexpr bool operator==(const Entier &e) const {
         return valeur() == e.valeur();
      }
      // etc.
      Entier operator% (const Entier &diviseur) const {
         return Entier{valeur() % diviseur.valeur()};
      }
      // etc.
   };
#include <iostream>
int main() {
   using namespace std;
   Entier<int> e(3);
   cout << e.valeur() << endl;
   Entier<int> e2 = e % 2;
   cout << e2.valeur() << endl;
}

La classe Entier<T> utilise comme substrat un type T et offre des opérations qui correspondent à nos attentes (on peut imaginer qu'elle assure par exemple que l'entier soit situé entre deux bornes). Le code qui précède compile et s'exécute normalement.

Imaginons que, sans doute par accident (ce qui arrive parfois dans du code générique : un moment d'inattention peut suffire), un collègue écrive ce qui suit :

template <class T>
   class Entier {
   public:
      using value_type = T;
   private:
      T valeur_;
   public:
      constexpr Entier(const value_type &valeur) : valeur_{valeur} {
      }
      constexpr value_type valeur() const {
         return valeur_;
      }
      bool operator==(const Entier &e) const {
         return valeur() == e.valeur();
      }
      // etc.
      Entier operator% (const Entier &diviseur) const {
         return Entier{valeur() % diviseur.valeur()};
      }
      // etc.
   };
#include <string>
#include <iostream>
int main() {
   using namespace std;
   Entier<int> e{3};
   cout << e.valeur() << endl;
   Entier<int> e2 = e % 2;
   cout << e2.valeur() << endl;
   Entier<string> es{"3"}; // passe!!! Oups!
   cout << es % 2 << endl; // erreur tardive (et peut-être message cryptique)...
}

La construction de l'instance es dans main() ne devrait pas être légale, à moins qu'on ne puisse définir ce que signifie Entier<string>. L'erreur sera toutefois rencontrée tardivement (ici, à l'opération suivante, qui tente un modulo entre une string et un int), et peut mener à des messages d'erreurs cryptiques. On peut toutefois vérifier statiquement si T est un type entier (le trait numeric_limits<T>::is_integer nous donne une telle constante).

Si vous avez un compilateur C++ 11 sous la main, envisagez static_assert dans un tel cas :

#include <limits>
template <class T>
   class Entier {
      static_assert(std::numeric_limits<T>::is_integer, "type entier requis");
   public:
      using value_type = T;
   private:
      T valeur_;
   public:
      constexpr Entier(const value_type &valeur) : valeur_{valeur} {
      }
      constexpr value_type valeur() const {
         return valeur_;
      }
      bool operator==(const Entier &e) const {
         return valeur() == e.valeur();
      }
      // etc.
      Entier operator% (const Entier &diviseur) const {
         return Entier{valeur() % diviseur.valeur()};
      }
      // etc.
   };
#include <string>
#include <iostream>
int main() {
   using namespace std;
   Entier<int> e{3};
   cout << e.valeur() << endl;
   Entier<int> e2 = e % 2;
   cout << e2.valeur() << endl;
   Entier<string> es{"3"}; // ne passe pas
   cout << es % 2 << endl;
}

Le message d'erreur est "type entier requis" avec le numéro de la ligne où se trouve l'instanciation d'un Entier<string>. Ça ne peut être plus clair.

Si votre compilateur ne supporte pas les assertions statiques, alors la technique de la classe imcompilable peut être utile:

#include <type_traits>
class Compilable{};
class Incomplet;
template <class Raison>
    class Incompilable {
       static Incomplet bloquer_la_compilation();
       enum { _ = sizeof(bloquer_la_compilation()) };
    };
class type_entier_requis {}; // "message d'erreur"
#include <limits> 
template <class T>
   class Entier : std::conditional_t<std::numeric_limits<T>::is_integer, Compilable, Incompilable<type_entier_requis>> {
   public:
      using value_type = T;
   private:
      T valeur_;
   public:
      constexpr Entier(const value_type &valeur) : valeur_{valeur} {
      }
      constexpr value_type valeur() const {
         return valeur_;
      }
      constexpr bool operator==(const Entier &e) const {
         return valeur() == e.valeur();
      }
      // etc.
      constexpr Entier operator%(const Entier &diviseur) const {
         return Entier{valeur() % diviseur.valeur()};
      }
      // etc.
   };
#include <string>
#include <iostream>
int main() {
   using namespace std;
   Entier<int> e{3};
   cout << e.valeur() << endl;
   Entier<int> e2 = e % 2;
   cout << e2.valeur() << endl;
   Entier<string> es{"3"}; // ne passe plus
   cout << es % 2 << endl;
}

L'erreur, avec Visual Studio 2010, devient telle que présentée ci-dessous. J'ai retiré les numéros de lignes par souci de concision.

error C2027: utilisation du type non défini 'Incomplet'
voir la déclaration de 'Incomplet'
voir la référence à l'instanciation de la classe modèle  
'Incompilable<Raison>' en cours de compilation
           with
           [
               Raison=Type_entier_requis
           ]
voir la référence à l'instanciation de la classe modèle 'Entier<T>' en  
cours de compilation
           with
           [
               T=std::string
           ]

Si vous prenez les lignes d'erreurs une à une, c'est très clair, ce qui est précisément le but recherché. Malheureusement, cette technique a ses limites, et les messages d'erreur produits par les compilateurs plus récents (par exemple Visual Studio 2017) sont moins pertinents. Maintenant que cette fonctionnalité est standard, préférez donc static_assert, qui est précisément fait pour cela.

Lectures complémentaires

Quelques liens pour enrichir votre compréhension.


Valid XHTML 1.0 Transitional

CSS Valide !