Code de grande personne – types internes publics

Cet article sera court et inspiré d'une des stratégies typiques de la bibliothèque standard de C++.

Notez que nous utilisons ici la syntaxe C++ 11 pour les définitions de types, qui est plus puissante et plus flexible que celle de C++ 03 (qui était aussi celle de C). À titre comparatif :

AuparavantMaintenant
typedef unsigned int quantite_t; // <--
typedef double (*ptr_fonction)(int); // <--
template <class T>
   void f(const vector<T> v) {
      typedef typename vector<T>::const_iterator iter_type; // <--
      // ...
   }
using quantite_t = unsigned int; // <--
using ptr_fonction = double(*)(int); // <--
template <class T>
   void f(const vector<T> v) {
      using iter_type = typename vector<T>::const_iterator; // <--
      // ...
   }

 

Je commencerai cet article en prenant pour acquis qu'il est préférable, lorsque cela s'avère possible[1], de programmer de manière symbolique que de dépendre des types effectivement utilisés. Ainsi, dans l'exemple ci-dessous, on préférera nettement le code à droite au code à gauche:

#include <iostream>
int main() {
   using namespace std;
   int tab[] { 2, 3, 5, 7, 11 };
   cout << "Le tableau tab contient "
        << 5
        << " éléments de "
        << 4
        << " bytes chacun pour un total de "
        << 5 * 4
        << " bytes"
        << endl;
}
#include <iostream>
int main() {
   using namespace std;
   int tab[] { 2, 3, 5, 7, 11 };
   cout << "Le tableau tab contient "
        << (sizeof tab/sizeof tab[0])
        << " éléments de "
        << sizeof tab[0]
        << " bytes chacun pour un total de "
        << sizeof tab
        << " bytes"
        << endl;
}

Notez que le calcul (sizeof tab/sizeof tab[0]) peut être remplacé par un appel à std::size() depuis C++ 17.

La raison pour cette préférence est que la version de droite conserve son sens peu importe la taille d'un entier sur la plateforme où elle est compilée alors que celle de gauche n'est véridique que si sizeof(int)==4, ce qui n'est pas garanti sur toutes les plateformes en C++.

La méthode length() d'une std::string a le même effet que la méthode size(), mais size() est disponible à même tous les conteneurs standards alors des deux, c'est elle qu'il y a lieu de privilégier pour obtenir des algorithmes plus généralement applicables.

De la même manière, dans l'exemple suivant et sachant que la méthode size() de std::string retourne le nombre de caractères dans la chaîne sous forme d'un std::string::size_type, on préférera la version de droite qui spécifie le compteur comme étant de type std::string::size_type plutôt que celle de gauche qui utilise un unsigned int du fait que celle de droite fonctionnera peu importe le type correspondant effectivement à std::string::size_type alors que celle de gauche sera brisée sur les plateformes où std::string::size_type n'est pas un unsigned int :

#include <locale>
#include <algorithm>
#include <iostream>
#include <string>
using namespace std;
bool est_voyelle(char c) {
   static const char VOYELLES[] { 'A', 'E', 'I', 'O', 'U', 'Y' };
   return find(begin(VOYELLES), end(VOYELLES), toupper(c, locale{""})) != end(VOYELLES);
}
int main() {
   locale::global(locale{ "" });
   string s = "allo";
   cout << "La chaîne " << s << " contient ";
   unsigned int n = {};
   for (unsigned int i = 0; i < s.size(); ++i)
      if (est_voyelle(s[i]))
         ++n;
   cout << n << " voyelles" << endl;
}
#include <locale>
#include <algorithm>
#include <iostream>
#include <string>
using namespace std;
bool est_voyelle(char c) {
   static const char VOYELLES[] { 'A', 'E', 'I', 'O', 'U', 'Y' };
   return find(begin(VOYELLES), end(VOYELLES), toupper(c, locale{""})) != end(VOYELLES);
}
int main() {
   locale::global(locale{ "" });
   string s = "allo";
   cout << "La chaîne " << s << " contient ";
   string::size_type n = {};
   for (string::size_type i = 0; i < s.size(); ++i)
      if (est_voyelle(s[i]))
         ++n;
   cout << n << " voyelles" << endl;
}

Si vous examinez la documentation officielle du type std::string, vous remarquerez que le type officiel de la méthode size() d'une instance de std::string est effectivement size_type, un type dont la correspondance avec les types primitifs de la plateforme est inconnue a priori et qui n'a comme seule contrainte que celle d'être un entier non signé, ce qui est effectivement le cas pour un unsigned  int et s'avère aussi pour le type std::size_t.

Mais d'où vient le nom size_type? Si vous cherchez à déclarer une variable du type size_type, votre code ne compilera probablement pas car, voyez-vous, ce nom est public mais local à la classe std::string. Son nom véritable et complet est donc std::string::size_type.

Remarquez le sens que prend le type size_type lorsqu'on l'exprime: il s'agit du type représentant une taille pour une instance de std::string. Si vous devez itérer à travers les éléments d'une std::string, alors le type de votre indice devrait être std::string::size_type.

Il est probable que ce type corresponde à size_t sur votre plateforme, comme il est probable que le type size_t corresponde à unsigned int au même endroit, mais aucune garantie n'est offerte à cet effet. Une seule chose est certaine: la taille d'une std::string est exprimée en terme de std:string::size_type et ce type est le plus approprié pour exprimer des idées associées à celle de taille d'une std::string.

Remarquez que la notation est porteuse d'une idée féconde : une chaîne standard, comme tout type standard, n'expose pas seulement les méthodes permettant de communiquer avec elle mais aussi les types de données associées à ces méthodes. Les types sont des abstractions qui accompagnent et documentent l'interface d'une classe.

Cette remarque peut être prise comme un conseil et peut être appliquée à tous les types dignes de ce nom : si plusieurs types complexes expriment les types de leurs méthodes sous forme de types internes et publics, alors il devient possible d'exprimer des algorithmes appliquant de manière générique à plusieurs types en même temps, tout comme il devient possible de découpler l'idée d'un programme du substrat matériel auquel il est soumis.

Ainsi, imaginez la pile suivante :

#include <cassert>
template <class T>
   class Pile {
   public:
      using value_type = T;
      // cas d'exception possible
      class Vide {};
   private:
      struct Noeud {
         value_type valeur;
         Noeud *succ {};
         Noeud(const value_type &valeur) : valeur{ valeur } {
         }
      };
      Noeud *tete {};
      void pop_raw() noexcept {
         assert(!empty());
         auto p = tete->succ;
         delete tete;
         tete = p;
      }
   public:
      Pile() = default;
      ~Pile() {
         clear(); // un peu inefficace
      }
      void clear() noexcept {
         while(!empty()) pop_raw();
      }
      void push(const value_type &val) {
         auto p = new Noeud{ val };
         p->succ = tete;
         tete = p;
      }
      bool empty() const noexcept {
         return !tete;
      }
      void pop() {
         if (empty()) throw Vide{};
         pop_raw();
      }
      value_type& top() {
         if (empty()) throw Vide{};
         return tete->valeur;
      }
      value_type top() const {
         if (empty()) throw Vide{};
         return tete->valeur;
      }
   };

Sachant cela, il est possible d'écrire un programme qui fonctionnera peu importe le type de données utilisé comme représentation d'une valeur dans une pile et ce, en n'utilisant que la pile elle-même comme point de référence.

L'exemple d'itération à l'aide d'un indice pour std::string, plus haut, est un cas parmi plusieurs, comme l'est l'algorithme ci-dessous :

#include "Pile.h"
#include <string>
#include <iostream>
#include <vector>
#include <algorithm>
#include <iosfwd>
#include <iterator>
// ... using ...
template <class C>
   void afficher_bas_en_haut(C &conteneur, ostream &os) {
      using value_type = typename C::value_type; // alias local
      vector<value_type> v;
      C temp;
      while (!conteneur.empty()) {
         auto val = conteneur.top();
         v.push_back(val);
         temp.push(val);
         conteneur.pop();
      }
      copy(begin(v), end(v), ostream_iterator<value_type>(os, " "));
      while (!temp.empty()) {
         conteneur.push(temp.top());
         temp.pop();
      }
   }
int main() {
   Pile<int> pi;
   // bla bla
   Pile<string> ps;
   // bla bla
   afficher_bas_en_haut(pi, cout);
   afficher_bas_en_haut(ps, cout);
}

Nommer les types utlisés, même dans l'abstrait, est une pratique qui renforce l'application du principe d'encapsulation et permet la mise en place de programmes plus génériques sans pour autant entraîner de pertes de performance.


[1] Dans les cas (fréquents, du moins dans ma propre vie professionnelle) où l'interopérabilité est une contrainte qu'il est important de respecter, il n'est généralement pas possible d'atteindre ce niveau d'abstraction.


Valid XHTML 1.0 Transitional

CSS Valide !