C++ et l'internationalisation

Avec l'ubiquité du réseau Internet, le village global est véritablement devenu, pour les techno riches du moins, une réalité.

La plupart des outils de programmation récents, en particulier Java et C#, tiennent compte à même leur design de plusieurs considérations propres à l'internationalisation du code, en particulier dans leur gestion des chaînes de caractère, ayant tous deux adopté le standard Unicode comme système d'encodage par défaut du texte. Cela dit, la situation est plus complexe qu'il n'y paraît (voir cette présentation de 2014 par James McNellis pour des détails : https://www.youtube.com/watch?v=n0GK-9f4dl8)

De son côté, C++ reste à bien des égards un dérivé de C, et son support le plus naturel reste, aux yeux de la majorité, offert au texte encodé sur huit bits ou moins comme le sont les standards ASCII et ANSI[1]. Ce problème en est principalement un de perception, il faut le dire, puisqu'il existe, avec la bibliothèque standard de C++, pratiquement le même support pour les séquences de caractères 8 et 16 bits :

En fait, le plus grand obstacle à l'internationalisation du code C++ est l'habitude que la plupart des gens ont d'utiliser des char (et les opérations et objets qui leur sont apparentés) plutôt que des wchar_t. Une impulsion historique plus que technique, il faut bien le dire.

Cette impulsion qui nous maintient dans nos habitudes d'écrire du code simple mais qui ne fonctionne que dans un environnement « américanocentré » nous fait négliger un aspect très puissant de la bibliothèque standard de C++ qui est son support très complet de l'internationalisation, allant bien au-delà de considérations alphabétiques.

Cette annexe se veut une introduction à l'internationalisation du code en C++, incluant le puissant concept client/ serveur (CS) de facettes, et donne une démonstration non seulement de l'applicabilité du modèle standard, mais aussi de la facilité par laquelle il est possible d'étendre les facettes existantes du modèle d'internationalisation standard.

Représenter les règles locales d'encodage

La première chose à relever quand on adresse la question de l'internationalisation est qu'il s'agit, fondamentalement, d'un problème ouvert. Ce qui constitue la liste de considérations propres à un endroit donné dépend de la culture et des préoccupations propres à ce lieu.

Certains aspects sont reconnus comme plus globaux que d'autres: représentation de la monnaie, des nombres, des dates, etc. D'autres sont moins répandus mais peuvent devoir être pris en considération dans certaines applications ou certains pays. Pensez par exemple :

La représentation d'un groupe d'aspects propres à un lieu ou à une culture se fait dans une instance de std::locale. Pour y avoir accès, il faut inclure <locale>.

Dans un souci d'uniformité conceptuelle, la bibliothèque standard de C++ a été réfléchie de manière à supporter, de façon homogène, les facettes jugées d'utilité globale et les facettes qu'on qualifierait de plus spécifiques.

On peut construire une instance de std::locale pour un lieu spécifique en passant une chaîne descriptive dudit lieu en paramètre à son constructeur[2]. Le constructeur par défaut d'un std::locale crée, sans surprise, une représentation jugée correcte pour la plateforme – avec Visual Studio, comme sur la majorité des plateformes, la présomption est que nous sommes américains, ce qui est associé à la culture dite « classique ».

Culture locale, culture classique

La culture locale peut être obtenue par un appel à std::locale{""} où les guillemets constituent une demande explicite pour un descriptif de la culture locale, telle que définie par la système d'exploitation pour l'environnement d'exécution (donc la culture canadienne française dans votre cas, selon toute probabilité).

La culture classique peut être obtenue par un appel à std::locale() où l'absence de paramètres constitue une demande explicite pour un descriptif de la culture classique, soit celle du langage C et des américains. Appeler std::locale{"C"} est équivalent, et le support pour le nom de culture "C" est garanti par le standard de C++.

Il est possible de choisir un nom de culture spécifique dans un programme en suppléant son nom lors de l'invocation de std::locale() (le paramètre requis étant une chaîne ASCIIZ au sens classique du langage C, donc un pointeur sur une séquence délimitée à la fin par un élément de valeur 0). Les noms de culture supportés ne sont pas standardisés à sur toutes les plateformes, cela dit, et ce problème ne peut être adressé par un langage de programmation. Règle générale, cet irritant ne pose pas vraiment de gros problèmes du fait que la culture de l'usager tend à être celle à laquelle les programmes auront recours.

Imprégner un flux d'une culture

Les entrées/ sorties sont guidées par la culture dont elles sont impégnées. On peut imposer à un programme de respecter les règles propres à une culture donnée. Par exemple, pour appliquer les règles de formatage propres à l'Allemagne dans une sortie à la console sous Microsoft Windows :

std::locale loc{"German_Germany"};
std::cout.imbue(loc); // jusqu'à ce qu'on impose une autre culture, les règles
                      // locales à l'Allemagne seront appliquées

La méthode imbue() d'un flux en sortie imprègne, le mot le dit, ce flux d'un lieu. Elle retourne le lieu dont était précédemment imprégné le flux, ce qui permet facilement d'imposer des règles précises pour quelques opérations d'entrée/ sortie puis de revenir au lieu précédent :

#include <locale>
#include <iostream>

int main() {
   using namespace std;
   //
   // vive le Québec... (les accents ne seront malgré tout pas jolis)
   //
   cout.imbue(locale{"fr_CA"}); // valide avec Windows XP, mais fr_CA est aussi commun
   cout << 3.14159 << endl // la virgule sert de séparateur pour les décimales
        << "Été chaud" << endl << endl;
   //
   // culture locale (devrait être équivalent au cas précédent si vous êtes québecois(e)
   //
   cout.imbue(locale{""});
   cout << 3.14159 << endl // chez moi, la virgule sert de séparateur pour les décimales... et chez vous?
        << "Été chaud" << endl << endl;
   //
   // culture « classique », américaine
   //
   cout.imbue(locale{});
   cout << 3.14159 << endl // le point sert de séparateur pour les décimales
        << "Été chaud" << endl << endl;
   //
   // culture classique, américaine, demandée autrement
   //
   cout.imbue(std::locale("C"));
   cout << 3.14159 << endl // le point sert de séparateur pour les décimales
        << "Été chaud" << endl << endl;
}

Trier des chaînes de caractères

Si vous avez déjà trié des caractères ou des chaînes de caractères, vous avez sans doute remarqué une chose qui peut paraître étrange : par défaut, C++ considère que la valeur de 'a' est plus grande que celle de 'Z', du fait que les valeurs entières associées aux caractères sont utilisées pour les fins de la comparaison, et du fait que dans le format UTF-8 comme dans le format ASCII, le symbole 'A' vaut 65 et le symbole 'a' vaut 97. Si vous voulez voir par vous-mêmes la correspondance entre valeurs entières (Code Points) et symboles affichés, un petit programme comme celui-ci vous permettra de le voir par vous-même :

#include <iostream>
#include <iostream>
#include <iomanip>
using namespace std;
int main() {
   for (int i = 0; i < 128; ++i)
      cout << setw(3) << i << " : '" << static_cast<char>(i) << '\'' << (i % 8 == 7? '\n' : '\t');
}

Portez attention à certains caractères particuliers, soit ceux de valeur 7, 8, 9, 10 et 13 (dans l'ordre : le bell, ou '\a', qui fait un « bip »; le backspace, ou '\b'; la tabulation, ou '\t'; le saut de ligne, ou '\n'; et le retour de chariot, ou '\r'), qui ont un impact sur la présentation à l.'écran.

Cela signifie qu'un programme comme celui-ci :

#include <iostream>
#include <string>
#include <locale>
#include <algorithm>
#include <iostream>
using namespace std;
int main() {
   string txt[] = { "a", "b", "c", "A", "B", "C" };
   sort(begin(txt), end(txt));
   for (const auto & s : txt)
      cout << s << ' ';
   cout << endl;
}

... affichera ce qui suit :

A B C a b c

... ce qui rejoint l'ordre lexicographique, mais ne correspond sans doute pas à votre vision de l'ordre alphabétique.

Le problème de fond est que la gestion de l'ordre dans lequel les symboles doivent être classés pour un alphabet donné dépend de la culture, et qu'il s'agit d'un sujet très, très complexe. Pour que le tri se fasse dans le respect de la culture locale sur le poste exécutant le programme, il faut donc procéder comme suit :

#include <iostream>
#include <string>
#include <locale>
#include <algorithm>
#include <iostream>
using namespace std;
int main() {
   string txt[] = { "a", "b", "c", "A", "B", "C" };
   locale loc{ "" };
   sort(begin(txt), end(txt), loc);
   for (const auto & s : txt)
      cout << s << ' ';
   cout << endl;
}

... ce qui affichera alors :

a A b B c C

Les facettes

Le formatage des nombres est une facette particulière des règles d'internationalisation possibles, mais ce n'est évidemment pas la seule.

Une facette possible pourrait être la suivante, nommée FacettePolitique, dont le rôle serait de représenter les divers aspects du régime politique dans un lieu donné. Il s'agit d'une classe manifestement trop simple pour représenter cet aspect crucial de la vie humaine qu'est la vie politique, mais il ne s'agit que d'une illustration (je me suis un peu amusé, voilà).

La déclaration de cette classe suit, avec annotation à gauche au besoin.

Remarquons tout d'abord que, tel qu'indiqué, une facette conforme doit dériver de la classe std::locale::facet. Ceci permet à std::locale de gérer les facettes par polymorphisme ou à l'aide de templates.

Il devrait être interdit de copier une facette. On y arrive normalement avec la même stratégie que celle appliquée pour empêcher la copie des singletons.

#ifndef FACETTE_POLITIQUE_H
#define FACETTE_POLITIQUE_H
#include "Incopiable.h"
#include <locale>
#include <string>
class FacettePolitique : public std::locale::facet, Incopiable {
public:
   using str_type = std::string;

Dans cet exemple, j'ai choisi de représenter les différentes catégories par lesquelles la facette FacettePolitique décrira un lieu à l'aide de constantes énumérées.

Il s'agit là d'un choix purement arbitraire. Des constantes entières, des chaînes de caractères, des abstractions dynamiques comme des pointeurs sur des objets auraient tous pu servir à de telles représentations

// ...
   enum RepartitionPouvoirs {
      Parlementaire, Presidentiel, SemiPresidentiel,
      Dictatorial, Monarchique, Theocratique,
      Totalitaire, Autoritaire
   };
   enum OrganisationTerritoire {
      Federation, Confederation, EtatUnitaire
   };
   enum Autorite {
      Aucun, Dictateur, PremierMinistre,
      President, Tyran
   };

J'ai aussi choisi de représenter les aspects politiques d'un lieu dans cette facette à l'aide de constantes d'instance. Il s'agit, encore une fois, d'un choix personnel, pas d'une règle du modèle.

private:
   const str_type chef_etat_;
   const Autorite autorite_;
   const OrganisationTerritoire org_terr_;
   const RepartitionPouvoirs rep_pouv_;

Toute facette doit avoir un attribut de classe public de type std::locale::id et nommé id. Cet attribut est utilisé par std::locale dans son rôle de serveur de serveur de facettes pour identifier les facettes par des indices distincts les uns des autres (c'est une belle manoeuvre OO).

public:
   static std::locale::id id;

Une facette n'a habituellement pas de constructeur par défaut. On s'attend à ce que chaque facette soit initialisée dès sa construction avec les informations descriptives qu'elle est supposée contenir.

// ...
   FacettePolitique
      (const str_type &chef,
       const RepartitionPouvoirs rep,
       const OrganisationTerritoire org,
       const Autorite autorite)
      : chef_etat_{chef}, rep_pouv_{rep},
        org_terr_{org}, autorite_{autorite}
   {
   }

Une bonne facette offrira habituellement une série d'accesseurs descriptifs de ce que représente la facette. On s'attend d'ailleurs d'une facette à ce que ses accesseurs soient constants, les facettes étant habituellement des classes dont les instances sont immuables.

// ...
   str_type chef_etat() const {
      return chef_etat_;
   }
   RepartitionPouvoirs repartition_pouvoirs() const noexcept
      return rep_pouv_;
   }
   OrganisationTerritoire organisation_territoire() const noexcept {
      return org_terr_;
   }
   Autorite autorite() const noexcept {
      return autorite_;
   }
};

Enfin, sans que ce ne soit nécessaire, il peut être utile d'offrir une manière simplifiée d'obtenir un std::locale qui inclut notre (ou nos) facette(s).

std::locale& obtenir_locale();
#endif

Personnellement, j'aime bien représenter l'application d'une facette sur un cas particulier du monde, par exemple le système politique canadien, par un singleton. Je vous propose une telle implémentation, annotée comme le fut la facette en tant que telle.

Il faut évidemment s'assurer d'avoir accès à std::locale et à la déclaration de la facette qu'on cherche à représenter.

#ifndef SYSTEME_CANADIEN_H
#define SYSTEME_CANADIEN_H
#include "FacettePolitique.h"
#include "Incopiable.h"
#include <locale>

Le singleton sera... un singleton, et en aura la signature (copie interdite, constructeur par défaut privé). J'ai appliqué la technique de Scott Meyers en faisant du singleton une variable statique dans SystemeCanadien::get().

Ce singleton aura un attribut d'instance, soit le std::locale représentant la localité visée et augmentée de la facette qu'on veut lui ajouter (remarquez la construction de l'attribut ici_, dont le deuxième paramètre est un pointeur de std::locale::facet).

On dira que cela installe notre facette dans un std::locale.

class SystemeCanadien : Incopiable {
   std::locale ici_;
   SystemeCanadien()
      : ici_{std::locale{""},
              new FacettePolitique
                 {"Justin Trudeau",
                  FacettePolitique::Parlementaire,
                  FacettePolitique::Federation,
                  FacettePolitique::PremierMinistre}}

   {
   }

Pour le reste, on reconnaîtra les traits classiques de la plupart des singletons (méthode d'instance donnant un accès contrôlé à ses attributs d'instance; méthode de classe donnant accès au singleton en tant que tel, et destructeur public).

public:
   std::locale& obtenir_locale() noexcept {
      return ici_;
   }
   static SystemeCanadien &get() {
      static SystemeCanadien singleton;
      return singleton;
   }
};
#endif

Le fichier source de la facette FacettePolitique, quant à lui, devrait définir l'attribut de classe FacettePolitique::id, ceci étant requis pour toute facette, et définir la fonction obtenir_locale() pour qu'elle retourne une référence sur le std::locale à utiliser – ici, j'ai évidemment utilisé celle décrite par le singleton SystemeCanadien.

#include "FacettePolitique.h"
#include "SystemeCanadien.h"
#include <locale>
using namespace std;
locale::id FacettePolitique::id;
locale& obtenir_locale()
   { return SystemeCanadien::get().obtenir_locale(); }

Exploiter une facette

Comment se comporterait un programme désireux d'obtenir une facette culturelle d'un lieu donné, par exemple du lieu courant? Un exemple correct serait celui ci-dessous.

#include "FacettePolitique.h"
#include <iostream>
#include <locale>
int main() {
   using namespace std;
   auto &ici = obtenir_locale();
   cout.imbue(ici);
   cout << "Chef d'état local: " << use_facet<FacettePolitique>(ici).chef_etat() << endl;
}

La fonction obtenir_locale() est celle que nous avons définie plus haut. Tel que vu précédemment, on imprègne tout d'abord le flux de sortie visé du lieu dont il doit s'inspirer, à l'aide de sa méthode imbue(). Dans ce cas bien précis, que nous l'ayons imprégné de changera pas grand chose à son comportement.

On voit que pour exploiter une facette d'un lieu, il suffit d'appliquer l'opération std::use_facet à un std::locale donné, ce qui donne accès à une référence à la facette en question et permet d'en appeler les méthodes.

La beauté de std::use_facet est que, étant un template, son bon usage est vérifié de manière statique, à la compilation plutôt qu'à l'exécution, et donne donc du code sécuritaire et aussi rapide que possible à l'exécution.

Exemple plus complexe – classe Date respectant les standards locaux

Présumons que nous désirions écrire le programme ci-dessous et souhaitons que celui-ci fonctionne correctement peu importe les standards locaux d'affichage des dates. La classe Date qui y est utilisée est insérée dans un espace nommé (portant le nom chrono_ext) pour éviter des conflits avec d'autres classes Date, ce nom étant assez commun[3].

#include "Date.h"
#include <iostream>
#include <locale>
int main() {
   using namespace std;
   cout.imbue(locale{""});
   cout << chrono_ext::Date{1972, chrono_ext::Mois::dec, 27} << endl;
}

Notez que la date utilisée dans l'exemple est purement arbitraire, mais que – dû aux choix de représentation internes à la classe chrono_ext::Date, que nous verrons sous peu – elle doit se situer inclusivement entre minuit le 1er janvier 1970 et 19:14:07 le 18 janvier 2038.

La déclaration de la classe chrono_ext::Date irait comme suit (protections contre les exclusions multiples omises pour fins d'économie).

Notez l'utilisation de <iosfwd> pour certaines déclarations (on peut voir ce fichier comme une version légère de <iostream>, mais sachez que <iostream> aurait fait l'affaire). En général, on évitera d'inclure <iostream>, qui est très lourd, dans les fichiers d'en-têtes, surtout quand on n'a besoin que de prototypes de sous-programmes et de déclarations de classes.

Le type std::time_t est un type du langage C permettant de représenter et de manipuler une date. Nous réutiliserons ce qui existe et fonctionne déjà bien.

#include <iosfwd>
#include <ctime>
namespace chrono_ext {
   enum class Mois : short {
      jan, fev,  mar, avr, mai, jun,
      jul, aout, sep, oct, nov, dec
   };
   class Date {
      std::time_t brut_;
   public:
      class Invalide { };
      Date(int annee, const Mois mois, int jour);
      void TempsC(struct std::tm*) const;
   };
   std::ostream &operator<<(std::ostream&, const Date&);
}

La méthode TempsC() a pour rôle d'exposer la date représentée par une instance de chrono_ext::Date dans le format traditionnel du langage C pour faciliter l'interopérabilité entre cette classe et le code existant.

On pourrait imaginer plusieurs autres méthodes pertinentes, comme des opérateurs pour ajouter ou soustraire un jour, un mois, un opérateur pour saisir une date d'un flux et ainsi de suite. Nous limiterons toutefois notre exposé à ceci dans le but de garder le tout simple et de rester axés sur les considérations d'internationalisation.

Le constructeur est relativement simple pour qui connaît un peu l'API standard du langage C pour manipuler des dates.

Un enregistrement de type std::tm est créé et initialisé à zéro pour tous ses champs. Certains champs sont ensuite remplis à la pièce pour représenter la date décrite par les paramètres du constructeur, puis un appel est fait à la fonction std::mktime() pour obtenir un time_t, représentation compacte de cette date.

#include "date.h"
#include <ctime>
#include <ostream>
#include <locale>
using namespace std;
namespace chrono_ext {
   Date::Date(int annee, const Mois mois, int jour) {
      struct std::tm moment = { 0 };
      moment.tm_mday = jour;
      moment.tm_mon = static_cast<int>(mois);
      // respecter la norme C
      moment.tm_year = annee - 1900;
      if ((brut_ = mktime(&moment)) == -1)
         throw Invalide{};
   }

La méthode TempsC() est somme toute banale, encapsulant un appel à une fonction standard C générant en quelque sort l'effet inverse du constructeur ci-dessus.

void Date::TempsC(struct std::tm* TempsC) const {
   *TempsC = *gmtime(&brut_);
}

Le gros morceau pour l'internationalisation de la classe chrono_ext::Date est lié à son affichage. C'est pourquoi, on le comprendra, l'opérateur << appliqué à un flux en sortie et à une chrono_ext::Date sera le point le plus subtil de notre classe, et ce bien qu'il s'agisse au fond d'une fonction globale et non pas d'une méthode.

Tout d'abord, la fonction déclare une sentinelle. Il s'agit d'une technique OO ayant pour objectif d'éviter des problèmes de synchronisation lors d'accès à un flux d'entrée/ sortie[4].

// ...
   ostream & operator<<(ostream &os, const Date &date) {
      ostream::sentry cerbere{os};
      if (!static_cast<bool>(cerbere))
         return os;

Ensuite, on extrait la date en format C standard pour exploiter les outils de formatage de date existants.

// ...
      struct tm tmbuf;
      date.TempsC(&tmbuf);

Puis, on obtient une facette standard de type std::time_put[5], capable de formater une date sur un flux.

// ...
      const auto &timeFacet = use_facet<time_put<char>>(os.getloc());

Enfin, on indique à cette facette où (et comment) écrire la date à exprimer, et on retourne le flux modifié par cette écriture.

// ...
      if (timeFacet.put(os, os, os.fill(), &tmbuf, 'x').failed())
         os.setstate(os.badbit);
      return os;
   }
}

La méthode put() d'une facette std::time_put prend cinq (5) paramètres, mais est moins complexe qu'il n'y paraît :

Ce qu'il faut retenir est que dans ce cas, la facette std::time_put est responsable de décrire de manière standardisée les règles d'écriture d'une date pour un lieu et une culture donnés. Les méthodes de std::time_put importent peu, en fait, si on comprend le principe de la facette, puisque chaque facette est potentiellement unique en forme et en fonction.

Lectures complémentaires

Quelques liens pour enrichir le propos.


[1] En fait, le standard ANSI est plutôt un standard d'encodage sur sept bits.

[2] La liste des noms de lieux supportés sur un ordinateur donné est obtenue par la commande locale -a sur les systèmes conformes à la norme POSIX, et par la base de registres sous Microsoft Windows. On peut obtenir le nom descriptif d'un lieu donné en appelant la méthode name() du std::locale le représentant.

[3] Je me suis fortement inspiré de http://www.cantrip.org/locale.html pour cet exemple.

[4] Des problèmes de ce genre surviendront fréquemment dans vos cours de systèmes client/ serveur. C'est une bonne technique mais pas essentiel à notre propos.

[5] ...applicable à un char, mais elle aurait aussi pu être applicable à un wchar_t par exemple.


Valid XHTML 1.0 Transitional

CSS Valide !