Introduction aux traits

L'article original (à ma connaissance) présentant la technique des traits, par Nathan Myers en 1995, est disponible ici, et ça vaut la peine de le lire!

Cela dit, je ne l'avais pas lu moi-même au moment d'écrire ce texte, et ma conception des traits dans ce qui suit découle de mon expérience personnelle et de ma lecture de multiples sources se servant de cette technique (surtout du code de bibliothèque comme Boost et STL), ce qui explique que je n'aie pas cité l'article auparavant.

Celles et ceux qui me connaissant le savent : je suis principalement un programmeur au profil structurel (bibliothèques et code d'infrastructure), ce qui teinte mon approche: je lis quelque chose qui m'intéresse ou je vois une manoeuvre qui me semble intéressante et j'y réfléchis moi-même, parfois (manifestement) avant d'avoir lu les justifications originales. Cela explique que j'aie parfois des réserves et des critiques avec lesquelles la plupart des gens sont en désaccord 😊

Voilà pour l'avertissement d'usage.

Les traits constituent une technique à la fois simple, extrêmement utile et méconnue de la plupart des programmeurs. Les traits permettent d'obtenir, à la compilation, de l'information quant à certains types de données mais pris sur une base générique. Il s'agit à la fois d'une stratégie en soi très abstraite et aux applications très concrètes.

Pour être en mesure de tirer pleinement profit de techniques de programmation générique comme celles rendues possibles par les templates de C++ tout en profitant aussi à plein de la puissance de la compilation statique, où un maximum de savoirs sont cumulés avant l'exécution d'un programme, exploiter les traits est sinon une nécessité, du moins un atout important.

Mise en situation – des traits, pourquoi au juste?

La technique des traits est à la fois belle, extrêmement efficace et très abstraite, reposant sur une double abstraction. C'est pourquoi je vais essayer, à la demande légitime d'ami(e)s et lectrices/ lecteurs estimé(e)s (allo Lamine Sy!), d'expliquer pourquoi on y aurait recours et, au passage, de donner une brève idée générale de ce que cela peut bien être.

Imaginons qu'un programme ayant recours à de la programmation générique contienne un sous-programme applicable à tout type numérique capable de représenter une valeur négative. Imaginons aussi que ce programme veuille lever une exception dans le cas où on chercherait à l'utiliser avec un type numérique pour lequel seuls les nombres positifs sont admis (un type tel que unsigned int par exemple. Ce n'est pas un grand exemple de design logiciel, soit, mais c'est simple et ça fera l'affaire.

#include <limits>
#include <iostream>
using namespace std;
template <class T>
   T abs(const T &val) {
      static_assert(numeric_limits<T>::is_signed, "ne s'applique qu'aux types signés");
      return val < 0? -val : val;
   }

int main() {
   int i = -4;
   cout << abs(i) << endl; // Ok.
   unsigned int ui = 5;
   // ne compilerait pas
   // cout << abs(ui) << endl;
}

Une vision alternative, si vous vous sentez aventurière ou aventurier, serait d'avoir deux versions de la fonction abs(), soit une qui réalise le test (val < 0) comme dans notre exemple, et qui serait applicable aux types signés, et une autre qui ne ferait que retourner son paramètre et qui ne serait applicable qu'aux types non-signés. Le choix de l'une ou de l'autre serait alors fait sur la base des types, une information connue au moment de la compilation.

Si le code qui suit vous échappe, ce n'est pas vraiment grave. Cela dit, si vous souhaitez le comprendre, examinez cet article sur la métaprogrammation :

#include <type_traits>
class type_signe {};
class type_non_signe {};
template <class T>
   T abs(const T &val, type_signe) {
      return val < 0? -val : val;
   }
template <class T>
   T abs(const T &val, type_non_signe) {
      return val;
   }
template <class T>
   T abs(const T &val) {
      using namespace std;
      return abs(
         val, conditional_t<numeric_limits<T>::is_signed, type_signe, type_non_signe>{}
      );
   }

Le tout semble suspect, sans l'ombre d'un doute, mais constitue une véritable optimisation et, il faut le souligner, ne change en rien le code client de la fonction. Avis aux curieuses et aux curieux : une écriture alternative reposerait sur enable_if, et une autre (plus récente) reposerait sur if constexpr.

Si notre souhait est de rédiger un tel programme, alors il nous faut être en mesure de découvrir de l'information quant au type auquel le sous-programme générique sera appliqué. Dans notre cas, pour le type T auquel la fonction abs() est appliquée, il faut savoir s'il est possible de représenter une valeur négative – donc de savoir si, à l'instar des types int et double par exemple, le type T est un type signé.

Cette métadonnée (une information disponible au programme sur les types qui y sont utilisés) est ce qu'on nomme un trait. Avoir accès à des traits dès la compilation permet de rédiger des programmes qui sont à la fois génériques et très efficaces, du fait que le compilateur a lui-même accès à l'information sur les traits et peut procéder à toute la gamme des optimisations possibles.

Des traits permettent donc la jonction entre métadonnées, généricité et efficacité, ce qui entraîne un niveau de flexibilité dans bien des cas comparable à celui des systèmes supportant pleinement la réflexion mais offrant en même temps la performance d'un langage compilé.

Exemple concret de traits – décrire un lieu

Le type par lequel on exprime les différences entre deux coordonnées doit aussi être défini en terme de l'espace (du plan) dans lequel le point sera utilisé, car l'idée de différence entre coordonnées n'est pas nécessairement la même que celle des coordonnées elles-mêmes: une coordonnée peut, par exemple, être en tout temps positive, mais il est probable que la différence entre deux points admette des valeurs négatives.

Prenons par exemple l'idée d'un point sur un plan 2D. Le type par lequel on exprimera la position en abcisses et en ordonnées du point dépendra du type utilisé pour représenter les coordonnées du plan lui-même, du moins si l'objectif est de faire une classe Point capable de représenter un point dans plusieurs catégories d'espaces.

Le problème de définir une classe Point respectant les règles de l'espace dans lequel cette classe sera utilisée est un problème de détermination de règles applicables à des types, ce qui est une problématique distincte de celle de définir une classe représentant un point et de celle de définir une classe représentant un espace.

Débutons notre exemple avec quelques outils primitifs. Si notre objectif est de déterminer un système de coordonnées et un système de différences entre coordonnées en fonction de règles abstraites, nous mettrons de l'avant deux prédicats capables d'évaluer si une valeur d'un type donné se situe entre deux bornes du même type, ce qui nous donnera la capacité de vérifier si une coordonnée d'un point se situe entre les bornes de validité déterminées par l'espace où il est utilisé:

template <class T>
   constexpr bool est_entre_inclusif(const T &val, const T &borne_inf, const T &borne_sup) {
      return borne_inf <= val && val <= borne_sup;
   }
template <class T>
   constexpr bool est_entre_exclusif(const T &val, const T &borne_inf, const T &borne_sup) {
      return borne_inf < val && val < borne_sup;
   }

La seule contrainte imposée aux types sujets à être utilisés à titre de paramètres pour ces prédicats est qu'ils doivent être comparables à l'aide des opérateurs < et <= et que ces opérateurs doivent être des prédicats laissant inchangé le contenu de leurs paramètres.

Définissons maintenant les traits d'un lieu. Si nous souhaitons être génériques, nous voudrons y aller d'un template comme celui-ci :

template <class D, class P>
   struct TraitsLieu {
      using distance_type = D;
      using position_type = P;
      static constexpr position_type origineX() {
         return {};
      }
      static constexpr position_type origineY() {
         return {};
      }
   };

Les traits d'un lieu seront d'avoir un type pour exprimer une distance et un type pour exprimer une position, de même que d'être capable de décrire son origine. Par défaut, l'origine sera à la position {0,0} d'un système de coordonnées, ce qui présume que le type utilisé à titre de position dans un lieu (donc à titre de TraitsLieu<D,P>::position_type) aura un constructeur par défaut.

Comment décririons-nous l'espace que représente l'écran console classique (25 lignes et 80 colonnes) à partir de ces traits? Une possibilité serait :

class EcranConsole : public TraitsLieu<short,short> {
   enum : position_type { X_MIN = 0, X_MAX = 79, Y_MIN = 0, Y_MAX = 24 };
public:
   static constexpr bool est_lieu_legal(position_type X, position_type Y) {
      return est_entre_inclusif(X, origineX(), X_MAX) &&
             est_entre_inclusif(Y, origineY(), Y_MAX);
   }
   static constexpr position_type origineX() {
      return X_MIN;
   }
   static constexpr position_type origineY() {
      return Y_MIN;
   }
};

On aurait pu faire encore mieux dans le cas de TraitsLieu comme dans le cas d'EcranConsole, mais je vous laisser ces raffinements à titre d'exercice.

Le cas le plus évident de trait dans la bibliothèque standard de C++ est la classe std::numeric_limits de la bibliothèque <limits>.

Cette classe détermine plusieurs concepts pour un type numérique T donné, comme par exemple :

Un algorithme peut donc utiliser ces données sur le plan abstrait pour être en mesure de travailler sur des valeurs numériques à partir des règles s'appliquant à leur type sans toutefois connaître ce type dans le détail.

À titre d'exemple, un sous-programme initialisant une variable numérique de type T à la plus petite valeur que peut prendre ce type serait :

#include <limits>
template <class T>
   void init_min(T &val) {
      val = std::numeric_limits<T>::min();
   }

Remarquez que cette procédure sera correcte pour tout type T pour lequel les traits de std::numeric_limits ont été définis.

Voyez déjà quelques traits des traits (pardonnez-moi le calembour) :

Exemple d'utilisation de traits – un point générique

Comment exprimer une classe Point capable d'exploiter, dans sa définition, les traits de son lieu? En deux temps :

Le nom utilisable se voudra naturel (Point) alors que le nom générique se voudra descriptif de son rôle fondamental (au sens de rôle de fondation).

Le code que je vous propose suit :

template <class Lieu2d = EcranConsole>
   class PointBase {
   public:
      using Lieu = Lieu2D;
      using distance_type = typename Lieu2D::distance_type;
      using position_type = typename Lieu2D::position_type;
      struct PointDiff {
         distance_type dx;
         distance_type dy;
         constexpr PointDiff(distance_type dx, distance_type dy) : dx{ dx }, dy{ dy } {
         }
      };
   private:
      position_type x_, y_;
   public:
      class PointIllegal {};
      constexpr PointBase() : x_{ Lieu::origineX() }, y_{ Lieu::origineY() } {
      }
      PointBase(position_type x, position_type y) : x_{ x }, y_{ y } {
         if (!Lieu2D::est_lieu_legal(x(), y())) throw PointIllegal{};
      }
      constexpr position_type x() const {
         return x_;
      }
      constexpr position_type y() const {
         return y_;
      }
      PointBase & operator+=(const PointDiff &p) {
         return *this = PointBase { x() + p.dx, y() + p.dy) }
      }
      constexpr bool operator==(const PointBase &p) const {
         return x() == p.x() && y() == p.y();
      }
      constexpr bool operator!=(const PointBase &p) const {
         return !(*this == p);
      }
   };
using Point = PointBase<>;

Notez que l'exemple utilise l'écran console comme lieu par défaut. Une autre choix aurait été de laisser l'en-tête de PointBase indiquer simplement template <class Lieu2D> class PointBase puis de définir l'alias Point sous la forme typedef PointBase<EcranConsole> Point;.

La plupart des opérations sont évidentes et indépendantes du type utilisé pour représenter une coordonnée :

Un trait, donc, est un type générique permettant de décrire les règles applicables à une catégorie de types (les limites des types numériques; les lieux en 2D; les itérateurs; etc.) de manière telle qu'on puisse en tirer profit à la compilation.

Application concrète des traits – une chaîne de caractères insensible à la casse

Pour ce qui suit, je me suis inspiré à la fois de mes propres cours de Systèmes client/ serveur (SCS), que ce soit celui que j'enseignais autrefois au Collège Lionel-Groulx ou celui que j'enseigne à l'Université de Sherbrooke, et du (très bon) livre Exceptional C++ de Herb Sutter, « un illustre inconnu » qui proposait des problèmes chaque semaine sur son site Guru of the Week (GotW).

La question « Comment rédiger une chaîne de caractères insensible à la casse » est une question récurrente en informatique et est beaucoup plus complexe qu'il n'y paraît à première vue, dans la mesure où sont prises en considération les questions d'internationalisation, qui influencent entre autres le traitement de caractères accentués.

Répondre efficacement à cette question est plus facile en pensant le problème de la comparaison de caractères de manière distincte du problème de la gestion des chaînes de caractères. En fait, si nous savons comparer efficacement des caractères entre eux de manière insensible à la casse, alors nous devrions (dans une approche OO) pouvoir compter sur les algorithmes généraux de manipulation de chaînes de caractères et profiter du meilleur des mondes

Comprendre (brièvement) les chaînes de caractères standards de C++

La bibliothèque standard de C++ propose une chaîne de caractères générique, le modèle (template) std::basic_string. Ce modèle décrit une chaîne de caractères en fonction de certains critères parmi lesquels on trouve le type d'un caractère et les traits (surprise!) qui lui sont applicables.

Je ne saurais trop insister sur l'importance de l'efficacité dans les méthodes de classe définies par les traits d'un type.

Ces méthodes sont susceptibles d'être utilisées par une multitude de mécanismes, standards ou maison, et du code inefficace entraînera rapidement une dégradation globale de la qualité d'une multitude de programmes.

Un certain nombre de traits sont requis, incluant la capacité :

Par défaut, pour un type T, les traits utilisés seront ceux décrits par la classe std::char_traits<T>, ce qui est raisonnable. Cela dit, il est possible de suppléer une autre classe de traits si le coeur nous en dit.

Tous ces types sont définis dans le fichier d'en-tête standard <string>.

La classe std::string est en fait un alias pour le type générique std::basic_string appliqué au type char avec les traits std::char_traits<T>.

Une classe standard existe aussi pour les caractères 16 bits (en fait, les caractères étendus, puisque la taille exacte n'est pas fixée par le standard), donc le type wchar_t : il s'agit de la classe std::wstring qui est un alias pour le type générique std::basic_string appliqué au type wchar_t avec les traits std:char_traits<wchar_t>.

Un truc bien connu – le type ustring

Dans mes cours de SCS, mentionnés plus haut, je propose régulièrement à mes étudiant(e)s de se construire leur propre chaîne de caractères basée sur des caractères non signés (des unsigned char) du fait que l'interopérabilité est, dans ces cours, l'une des principales problématiques à adresser et du fait que le standard ISO de C++ ne dicte pas le signe du type char. Les gens responsables d'implémenter des compilateurs pour une plateforme ou l'autre sont laissés libres de choisir la straégie la plus efficace pour leurs besoins.

En fait, en C++, peu importe le caractère signé ou non du type char, les types char, signed char et unsigned char sont tous trois considérés distincts.

La conversion d'un unsigned char en char est implicite (quoiqu'elle demande une certaine prudence) sur toutes les plateformes, mais le recours à un reinterpret_cast devient nécessaire si le besoin se fait sentir de convertir, par exemple, un unsigned char * en char * même si les deux sont en fait des bytes non signés sur une plateforme donnée. Cette contrainte est sage quand on considère les dégâts potentiels que permettrait la conversion implicite entre pointeurs non apparentés.

Le fait qu'il soit nécessaire, pour réaliser une forme d'interopérabilité entre modules écrits dans des langages différents, de s'entendre sur la nature et la structure des types de données utilisés de part et d'autre, le type char est disqualifié d'office pour cette tâche. En retour, ce type demeure celui à privilégier pour les opérations internes (en comparaison avec les types signed char et unsigned char ) du fait qu'il est nécessairement celui des trois sur lequel les opérations sont les plus efficaces.

Pour fins d'interopérabilité, donc, mes étudiant(e)s doivent souvent utiliser des types dont le caractère signé ou non est connu a priori, mais il reste beaucoup plus simple (et beaucoup, beaucoup plus sécuritaire) d'utiliser des objets efficaces et solides que d'avoir recours à des tableaux bruts et à des manipulations de bas niveau. Je leur suggère donc de se définir leur propre chaîne de caractères, que nous nommons habituellement ustring (par symétrie avec std::wstring) et qui permet de manipuler des séquences de caractères non signés.

La stratégie est simple: crér une instanciation du type std::basic_string appliquée au type unsigned char puis définir les opérations de base requises pour parvenir à se servir de ce type avec les flux standard (car les entrées/ sorties sont des opérations extrêmement communes sur des chaînes de caractères).

Fichier ustring.h Fichier ustring.cpp
#ifndef USTRING_H
#define USTRING_H
#include <string>
#include <iosfwd>

using ustring = std::basic_string<unsigned char> ustring;

//
// Par défaut, ne sont pas définis sur notre nouveau type
//
std::ostream& operator<<(std::ostream&, const ustring&);
std::istream& operator>>(std::istream&, ustring&);
std::istream& getline(std::istream&, ustring&);

#endif
#include "ustring.h"
#include <string>
#include <iostream>
#include <algorithm>
// ... using ...
ostream& operator<<(ostream &os, const ustring &us) {
   for(auto c : us) os << c;
   return os;
}
istream& operator>>(istream &is, ustring &us) {
   if (!is) return is;
   if (string s; is >> s)
      us = { begin(s), end(s) };
   return is;
}
istream& getline(istream &is, ustring &us) {
   if (!is) return is;
   if (string s; getline(is, s))
      us = { begin(s), end(s) };
   return is;
}

Remarquez la grande simplicité du code proposé ici :

Voilà le fruit d'une encapsulation stricte et d'un design de bonne qualité.

Cette stratégie, malgré ses mérites, n'est pas une panacée. Par exemple, il demeure illégal d'écrire ceci :

ustring us = "allo"; // ne compile pas

La raison est que le type du littéral "allo" est const char* et que le modèle std::basic_string<unsigned char> définit un constructeur paramérique sur une séquence primitive de unsigned char (donc sur un const unsigned char * ) mais ne sait rien des autres types primitifs.

Il faut donc demeurer alerte.

Attaquer le problème de la chaîne de caractères insensible à la casse

Dans le même ordre d'idées, nous créerons une chaîne de caractères insensible à la casse sans réécrire le type std::basic_string au grand complet.

La différence entre la stratégie déployée pour le type ustring plus haut et le type ncstring proposé ici est que plutôt que de spécialiser le modèle std::basic_string sur un type de données distinct, nous allons proposer une spécialisation sur le type char avec les traits propres à ce type mais avec, en plus, une spécialisation de comportement pour ce qui est de la comparaison de caractères pris un à un ou en tant que séquence brute.

Notre programme de test ira comme suit :

#include "ncstring.h"
#include <iostream>
int main() {
   using namespace std;
   ncstring nc = "allo";
   ncstring ncOk = "Allo";
   ncstring ncBof = "hello";
   if (nc == ncOk && nc != ncBof)
      cout << "Tout va bien" << endl;
   else
      cout << "Oups!" << endl;
   cout << nc << endl;
}

Nos attentes sont que le programme affiche "Tout va bien" sur une ligne puis "allo" sur la ligne suivante, du fait que les variables nc et ncOk sont égales (en terme de contenu) à la casse près alors que les variables nc et ncBof sont différentes.

Nous souhaitons aussi que nc puisse être affichée normalement, comme toute autre chaîne de caractères.

Le code requis pour définir le type ncstring (le préfixe nc signifiant ici No Case, donc insensible à la casse) va comme suit.

Fichier ncstring.h Fichier ncstring.cpp
#ifndef NCSTRING_H
#define NCSTRING_H

#include <string>
#include <locale>
#include <iosfwd>
class ncstring_traits : public std::char_traits<char> {
   //
   // on a tous les traits de base, et on raffine
   // ceux qui nous préoccupent
   //
public:
   static int compare(const char_type *s0, const char_type *s1, size_t n) {
      for (size_t i = 0; i < n; ++i)
         if (!eq(*(s0 + i), *(s1 + i)))
            return lt(*(s0 + i), *(s1 + i))? -1 : 1;
      return 0;
   }
   static bool eq(const char_type &c0, const char_type &c1) {
      using namespace std;
      const auto &loc = locale{""};
      return toupper(c0, loc) == toupper(c1, loc);
   }
   static bool lt(const char_type &c0, const char_type &c1) {
      using namespace std;
      const auto &loc = locale{""};
      return toupper(c0, loc) < toupper(c1, loc);
   }
};
using ncstring = std::basic_string <char, ncstring_traits>;
//
// Par défaut, ne sont pas définis sur notre nouveau type
//
std::ostream& operator<<(std::ostream&, const ncstring&);
std::istream& operator>>(std::istream&, ncstring&);
std::istream& getline(std::istream&, ncstring&);
#endif
#include "ncstring.h"
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;
ostream& operator<<(ostream &os, const ncstring &ns) {
   return os << string{begin(ns), end(ns)};
}
istream& operator>>(istream &is, ncstring &ns) {
   if (!is) return is;
   if (string s; is >> s)
      ns = ncstring{ begin(s), end(s) };
   return is;
}
istream& getline(istream &is, ncstring &ns) {
   if (!is) return is;
   if (string s; getline(is, s))
      ns = ncstring{ begin(s), end(s) };
   return is;
}

Remarquez que les méthodes de la classe ncstring_traits utilisent des char_type plutôt que des char directement. Le type char_type est un type interne au type std::char_traits et permet de travailler à un plus haut niveau d'abstraction et de généricité, nous détachant du type exact utilisé pour représenter un caractère. Nous en profiterons un peu plus bas.

Les entrées/ sorties sont implémentées selon précisément la même stratégie que dans le cas du type ustring , plus haut. Les traits, quant à eux, sont écrits avec un peu de désinvolture en ne prennent pas les accents en compte (mais rien de vous empêche de raffiner les méthodes eq() et lt() de la classe ncstring_traits).

Élever le niveau d'abstraction pour couvrir plusieurs types primitifs

#include "ncstring.h"
#include <iostream>
int main() {
   // ... using ...
   ncwstring nc = L"allo";
   ncwstring ncOk = L"Allo";
   ncwstring ncBof = L"hello";
   if (nc == ncOk && nc != ncBof)
      cout << "Tout va bien" << endl;
   else
      cout << "Oups!" << endl;
   wcout << nc << endl;
}

Il n'y a pas de bonne raison de s'arrêter ici, sinon la paresse. En effet, avec un tout petit peu plus d'effort, nous serons capables de couvrir à la fois les petits caractères(qui sont typiquement du type char) comme les plus gros (des wchar_t).

Examinons le programme proposé à droite. Au fait que le type primitif sous-jacent aux diverses opérations soit ici wchar_t plutôt que char, nous faisons face à un clone du programme de test proposé lors de notre première version de la classe ncstring, plus haut.

Tout(e) informaticien(ne) digne de ce nom s'attendra à ce que le bon fonctionnement de l'un d'entre eux implique le bon fonctionnement de l'autre, et c'est une attente tout à fait raisonnable.

Nous devons donc, à tout le moins par professionnalisme, terminer le travail et proposer une classe de traits capable de couvrir à la fois les comparaisons de char et de wchar_t à l'aide de séquences dont le nom reste évocateur des types standard (ici, les types que nous proposons sont ncstring pour la spécialisation sur des petits caractères et ncwstring pour celle s'appliquant à de gros caractères.

Les modifications requises pour en arriver élégamment à une solution capable de couvrir les deux cas impliqueront une élévation du niveau de généricité: nous allons faire de notre classe de traits, ncstring_traits, un template applicable à tout type pour lequel il existe un std::char_traits conforme au standard (donc, à toutes fins pratiques, applicable à tout type capable de se comporter comme un caractère).

Le passage à un modèle entièrement générique entraînera la désuétude du fichier ncstring.cpp, tout le code devant être regroupé dans le fichier d'en-tête ncstring.h.

Ne vous étonnez donc pas que seul ce dernier soit présenté ci-dessous. Si vous choisissez de conserver le fichier source ncstring.cpp, alors il ne contiendra plus que la directive servant à inclure le fichier d'en-tête ncstring.h. Ceci peut être utile pour fins de mise au point et de tests.

Le code suit.

Fichier ncstring.h
#ifndef NCSTRING_H
#define NCSTRING_H
#include <string>
#include <locale>
#include <iosfwd>
template <class T>
   struct ncstring_traits : std::char_traits<T> {
      static int compare(const char_type *s0, const char_type *s1, size_t n) {
         for (size_t i = 0; i < n; ++i)
            if (!eq (*(s0 + i), *(s1 + i)))
               return lt(*(s0 + i), *(s1 + i))? -1 : 1;
         return 0;
      }
      static bool eq(const char_type &c0, const char_type &c1) {
         using namespace std;
         const auto &loc = locale{""};
         return toupper(c0, loc) == toupper(c1, loc);
      }
      static bool lt(const char_type &c0, const char_type &c1) {
         using namespace std;
         const auto &loc = locale{""};
         return toupper(c0, loc) < toupper(c1, loc);
      }
   };
using ncstring = std::basic_string <char, ncstring_traits<char>>;
using ncwstring = std::basic_string <wchar_t, ncstring_traits<wchar_t>>;
template <class T>
   std::basic_ostream<T>& operator<<(std::basic_ostream<T> &os, const std::basic_string <T, ncstring_traits<T> > &ns) {
      return os << std::basic_string<T>{ std::begin(ns), std::end(ns) };
   }
template <class T>
   std::basic_istream<T>& operator>>(std::basic_istream<T> &is, std::basic_string <T, ncstring_traits<T> > &ns) {
      if (!is) return is;
      if (std::basic_string<T> s; is >> s)
         ns = std::basic_string<T>{ std::begin(ns), std::end(ns) };
      return is;
   }
template <class T>
   std::basic_istream<T> & getline (std::basic_istream<T> &is, std::basic_string <T, ncstring_traits<T> > &ns) {
      if (!is) return is;
      if (std::basic_string<T> s; getline(is, s))
         ns = std::basic_string<T>{ std::begin(s), std::end(s) };
      return is;
   }

#endif

Il y a là clairement plus de code d'écrit, mais il n'y a par contre pas un accroissement du niveau de difficulté: le code est le même dans chaque cas!

La classe de traits ncstring_traits élève son niveau de généricité en se définissant en terme du type de caractère sous-jacent. Aucun coût n'est encouru à l'exécution du fait que les types générés le sont tous sur demande et sont tous pleinement visibles du compilateur, donc susceptibles d'être soumis à une optimisation agressive de leurs méthodes.

Le code de la classe de traits ne change pas d'un poil du fait que cette classe était déjà rédigée en terme de types internes (quelle sage pratique!). Le nom interne char_type est nécessairement le bon type, peut importe le type T effectivement utilisé.

Les noms ncstring et ncwstring correspondent, tel qu'on s'y serait attendu, à des chaînes de caractères standard utilisant des traits pour lesquels la comparaison ne tient pas compte de la casse. C'est là le mandat que nous nous étions fixés.

Les opérations d'entrée/ sortie sont plus subtiles. Pour qu'il soit possible de tirer pleinement profit de ce niveau de généricité ajouté, il nous faut proposer chacune d'entre elles sous sa forme plus générale (car qui sait vraiment quels types seront utilisés dans les programmes se servant de nos traits génériques?).

Ainsi, chaque opération d'entrée/ sortie doit se décliner dans une version tenant compte du type de caractère sous-jacent (donc en fonction du type T). Nous ne voulons pas couvrir toutes les combinaisons possibles de type de caractère et de type de trait (les nombre de combinaisons possible est un infini potentiel, et le nombre de cas typiques connus au préalable est trop grand).

Notre stratégie est donc :

Le gain de généricité encouru est considérable, et le coût en espace comme en vitesse est nul. Les traits constituent un atout immense dans la mise au point de stratégies de qualité.

Lectures complémentaires

Quelques liens pour en savoir plus.


Valid XHTML 1.0 Transitional

CSS Valide !