21 déc. 2010

C++ et l'internationalisation

Ce billet avait été publié une première fois, mais contenait un grand nombre d'erreurs factuelles. Il a été corrigé et amélioré depuis, et le voici publié à nouveau.
Mise à jour du 27 déc. 2010 : certaines erreur de typographie ont été corrigées

Il vous est certainement connu que dans le standard, les classes de chaines de caractère - ainsi qu'en fait tout ce qui manipule des chaines de caractère - sont représentée par une classe template nommée std::basic_string<>. L'un des paramètres de cette classe template est le type des caractères stockés dans la chaine. Et bien évidemment, le standard défini deux types distinct de chaines de caractères : std::string, qui est un std::basic_string<char>, est utilisé pour stocker des chaines ASCII traditionnelles tandis que std::wstring (un type alias sur std::basic_string<wchar_t>) est utilisé pour représenter des chaines dont chaque caractère fait sizeof(wchar_t) bytes[1]. wchar_t est le type caractère large, et est généralement utilisé pour encoder des caractères utilisant le jeu de caractère Unicode.

Du coup, on se surprend à penser qu'il est aisé de transformer une chaine simple, de type std::string, en une chaine en caractères larges de type std::wstring : un simple coup d'algorithme standard du type std::copy, voir l'utilisation de std::basic_string<>::assign() et hop, c'est fini. Du coup, traiter l'internationalisation dans un programme C++ parait d'une simplicité enfantine.

Bien évidemment, si c'était aussi simple, on n'aurait pas besoin de personnes intelligentes au comité de normalisation du C++... Y-aurait-il anguille sous roche ?

Notes

[1] selon le standard C++, sizeof(char) == 1 byte ; il ne s'agit pas d'un octet, mais d'une unité de mesure définie par le texte du standard.

Internationalisation, l'approche naïve

Une simple recherche sur internet sur std::string to std::wstring donne un grand nombre de résultats, mais la plupart donnent une vision très simple de cette transformation. Ainsi, il n'est pas rare de voir du code ressemblant à l'exemple suivant :

std::wstring s2w(const std::string in)
{
  // certains vont utiliser std::copy ou std::wstring::assign(). 
  // le résultat est equivalent. 
  return std::wstring(in.begin(), in.end());
}

Ce code effectue une copie simple des caractères de la chaine source vers la chaine destination. Si la chaine à copier est une chaîne de caractère ne contenant que des caractères ASCII (c'est à dire dont le code est compris entre 0 inclus et 127 inclus), cette conversion est tout à fait correcte. Cela vient du fait que quelque soit le jeu de caractère utilisé, à partir du moment ou celui-ci suit les standards en vigueurs[1], les 128 premiers caractères de ce jeu sont issus de ASCII.

Mais que ce passe-t-il si on tente de transformer une chaîne écrite en français, avec ses accents, ses cédilles, etc. ? Est-ce que cela a du sens ? Dans le jeu de caractère ISO-8859-7 (jeu de caractère standardisé utilisé en Grèce), le caractère 'ג' (lambda minuscule) a le code hexadécimal 0xE2. En supposant que la chaine de type std::wstring obtenue corresponde à une chaine obéissant au standard Unicode ou à l'un des autres standard ISO d'encodage de caractères (16 ou 32 bits)[2][3], qu'est-ce que cette chaine devrait contenir ? Dans l'idée, on souhaite certainement garder le même symbole, mais une copie brute nous fait en fait retomber sur un symbole du jeu de caractère ISO-8859-1 (Europe de l'Ouest) : 'â'. Le caractère lambda est en fait codé 0x03BB en Unicode.

Ce qui pose un sérieux problème : notre routine de conversion fonctionne relativement bien dès lors qu'on traite un jeu de caractère ASCII ou ISO-8859-1, mais ne fonctionne plus dès qu'on quitte ces jeux de caractères. On y perd beaucoup au niveau de l'internationalisation, n'est-ce pas ?

Internationalisation, l'approche C

En C, la question ne se pose pas du tout de la même manière : plutôt que de faire une conversion en copiant la chaine, on va utiliser une fonction. Lorsqu'on prends conscience de cette différence, le fait de coder nous même notre fonction s2w() parait encore plus étrange, et certainement fausse.

Le standard C, dans le header wchar.h, définit un certain nombre de fonctions permettant la transformation de caractères de type char en caractères larges de type wchar_t. Ces fonctions ont un nom un peu abscons (mbstowcs et wcstombs ; mbs signifie multi-byte string tandis que wcs signifie wide character string) mais leur relative simplicité d'utilisation permettent de réaliser des conversions Unicode ↔ code de page courant de manière aisée.

En C++, <wchar.h> est remplacé par <cwchar>, et les fonctions qui y sont définies sont placées dans le nom d'espace std. Il est dont tout à fait possible de les utiliser pour implémenter une conversion std::stringstd::wstring qui serait tout à fait fonctionnelle.

Mais bien évidemment, il y a mieux, comme on va le voir par la suite.

Et puis l'internationalisation, ça ne se résume pas à transformer des chaines de caractères existantes : d'autres fonctionnalités sont tout aussi importantes. Lorsque je représente un nombre à virgule, quel caractère dois-je utiliser comme séparateur ? Un point comme en Angleterre, ou une virgule comme en France ? Dois-je grouper les nombres par bloc de 3 caractères ? Dois-je les séparer par des virgules comme aux Etats Unis ? Comment-dois-je afficher une date ? Quel symbole monétaire ? etc. De nombreux concepts doivent être gérés dès lors qu'on souhaite internationaliser une application.

La librairie standard C utilise une notion de locale : une locale permet de définir un ensemble de comportement du type de ceux énumérés dans le paragraphe précédent. Une fois la locale de l'application définie avec la fonction setlocale()[4], les différentes fonctions de la librairie standard utilisent cette locale afin d'adapter leur comportement en fonction des besoins. La encore, le C++ a une approche différente et tout à fait intéressante. Bien évidement, le header C <locale.h> peut être inclus directement avec son nom C++ <clocale>.

Internationalisation, l'approche C++

En C++, il existe aussi une notion de locale. Mais contrairement au C ou la locale est nécessairement globale, le C++ a une gestion plus fine de ce principe grâce à la classe std::locale définie dans le header <locale>.

#include <iostream>
#include <locale>
void function(const std::string& locale_name) { // ostream::imbue() dit que le flux doit utiliser la locale spécifiée par locale_name. std::cout.imbue(std::locale(locale_name.c_str())); // selon la locale utilisée, le format de sortie change std::cout << std::fixed << 123456.789 << std::endl; }

Le code précédent a un comportement différent selon le nom de locale passé en paramètre à la fonction. En premier lieu, si le nom ne réfère à aucune locale connue sur le système, la construction de la locale échoue (une exception std::runtime_error est générée). Comme en C, il existe une locale qui est toujours définie : c'est la locale classique, dont le nom est "C". D'autres noms de locale peuvent être utilisés : par exemple, le nom de la locale pour le français dans le jeu de caractère UTF8 est "fr_FR.utf8"[5].

int main()
{
  function("C");
  function("fr_FR.utf8");
  function("en_US.utf8");
}

Sur ma machine, le code précédent affiche le résultat suivant :

C: 123456.789000
fr_FR.utf8: 123 456,789000
en_US.utf8: 123,456.789000

Comme on le voit, le résultat diffère selon qu'on affiche le nombre flottant en Français ou en Anglais, ou sans locale particulière. Comment est-ce que la librairie standard fait pour obtenir ce résultat ? Pour obtenir notre réponse, il faut regarder d'un peu plus près la classe std::locale.

Locales et facettes

Simplifions le synopsis de cette classe pour ne garder que les points qui conservent un intérêt pour cette discussion.

namespace std {
  class locale {
  public:
    locale( /* ... */ ); // plusieurs constructeurs
    ~locale(); // destructeur non virtuel
static const locale& classic(); // renvoie std::locale("C") static locale global (const locale& loc); // equivalent de setlocale() de la librairie C
basic_string<char> name() const; // le nom de la locale
// plusieurs opérateurs simples ; on passe.
template <class Facet> locale combine (const locale& other) const; // ??? class facet { ... }; // et ça, qu'est-ce donc ? }; }

le destructeur non virtuel nous dit qu'on hérite pas d'une locale - ce n'est donc pas par ce biais qu'on peut étendre le fonctionnement de cette classe. Les autres méthodes sont assez classiques et nous renseignent peu sur le fonctionnement de la classe. Sauf peut-être la dernière méthode, qui prends en paramètre une autre locale. Mais que fait-elle exactement, et c'est quoi ce paramètre template Facet ? Quid de la classe publique interne nommée facet ?

Le standard C++ a introduit, en plus des locales, un concept séduisant pour traiter les différents problèmes liés à l'internationalisation : les facettes. Ces facettes peuvent être vues comme une combinaison de propriétés et de traitements liés à un besoin particulier. Il définit ainsi de nombreuses facettes standard qui héritent toutes de std::locale::facet. Les facettes standard sont les suivantes dans le nom d'espace std) :

  • collate (comparison, longueur et autres opérations sur les chaines)
  • ctype (contrôle et conversion de types de caractère)
  • codecvt (conversion de jeu de caractère)
  • moneypunct, money_get, money_put (gestion des symboles monétaires)
  • numpunct, num_get, num_put (gestion des nombres)
  • time_get, time_put (gestion des dates et heures)
  • messages (récupération de messages dans des catalogues)

Ces différentes facettes ont toutes un rôle bien défini. Ainsi, numpunct est la facette responsable du choix du séparateurs de milliers et du caractère de séparation décimale lors de l'affichage qui a été montré ci-dessus. Le pseudo-code suivant a été exécuté :

operateur << (ostream o, double v)
{
  locale l ← locale de o
  numpunct f ← facette numpunct de l
  ecrire v sur o, en utisant f.decimal_point(), f.grouping(), f.thousands_sep(), ...
}

Dès lors que certaines opérations doivent prendre place, les différentes facettes de la locale en cours sont utilisées.

Revenons à nos premiers amours : une fonction me permettant de transformer de manière correcte une chaine de caractère std::string en std::wstring. Dans la liste citée ci-dessus, on voit deux facettes qui ont trait à la conversion de caractère : ctype et codecvt. L'une de ces facettes pourrait-elle être utilisée pour nous aider à transformer nos chaines ?

Commençons par ctype. Cette facette est aussi une classe template, mais elle n'admet qu'un seul paramètre : _CharType. Elle offre par contre des services intéressants : en premier lieu, les méthodes classiques ctype<>::toupper() et ctype<>::tolower() qui permettent de transformer un caractère en son équivalent majuscule (ou minuscule). Et puis elle offre aussi deux autres méthodes moins convenues, nommées ctype<>::narrow() et ctype<>::widen(), qui ont pour but respectif de convertir un caractère du type wchar_t ou char en caractère du type _CharType La, ça commence à devenir intéressant. Que dit la norme à ce sujet ?

Pour ctype<>::widen(),

Effects: Applies the simplest reasonable transformation from a char value or sequence of char values to the corresponding charT value or values. The only characters for which unique transformations are required are those in the basic source character set (2.2).

Pour ctype<>::narrow()

Effects: Applies the simplest reasonable transformation from a charT value or sequence of charT values to the corresponding char value or values. For any character c in the basic source character set (2.2) the transformation is such that do_widen(do_narrow(c),0) == c. For any named ctype category ctype_base::mask value M, with a ctype<char> facet ctc however, and (is(M,c) || !ctc.is(M, do_narrow(c),dfault) ) is true (unless do_narrow returns dfault). In addition, for any digit character c, the expression (do_narrow(c,dfault)-’0’) evaluates to the digit value of the character.

Oups. Pas d'une simplicité extrême. Mais ce texte n'est pas si compliqué que ça. Il dit en gros :

  • narrow() et widen() effectue la plus simple des transformations qui conserve le type de caractère transformé (nombre, ponctuation, caractères affichables, etc).
  • seuls les caractères faisant partie de l'ensemble de caractères basique (c'est à dire les caractères utilisés pour écrire le code source en C++) on l'obligation de valider l'expression widen(narrow(c)) == c. Ce qui signifie qu'une implémentation peut tout a fait changer le code d'un caractère si besoin (et que cette opération n'est pas nécessairement bijective).

C'est là que le bat blesse : ces deux fonctions font une conversion qu'on appelle "1-to-1", c'est à dire que chaque caractère en entrée produit un caractère en sortie. Hors ce n'est pas toujours le cas. Ainsi, en Japonais (encodage JIS), plusieurs caractères peuvent être chainés pour, au final, n'avoir qu'un seul caractère affichable. En UTF8, les caractères accentués français sont représentés avec deux char dans la chaine. Hors, dans tous les cas, ces caractères doivent être transformés en un unique wchar_t pour que la conversion soit valide.

Conversion JIS vers Unicode (image (c) Fondation Apache)

Ce type de conversion n'étant pas possible avec ctype, on peut en conclure que cette facette ne peut être utilisée pour implémenter notre fonction correctement.

Quid de codecvt alors ?

codecvt parle de jeu de caractère. La facette est une classe template prenant trois paramètres :

  1. _InternType est le type de caractère à partir duquel on fait la transformation.
  2. _ExternType est le type de caractère vers lequel ont fait la transformation.[6]
  3. _StateType est un type additionnel permettant de spécialiser la transformation[7]).

La facette définit un certain nombre de méthodes, dont les méthodes in() et out() dont le prototype est un peu tarabiscoté (surtout en considérant qu'on aurait pu se servir des services fournis par les itérateurs). Ces méthodes effectuent les tâches suivantes :

Both functions take characters in the range of [from,from_end), apply an appropriate conversion, and place the resulting characters in the buffer starting at to. Each function converts at most from_end-from source characters, and stores no more than to_limit-to characters of the destination type. Both out and in stop if they find a character they cannot convert. In any case, from_next and to_next are always left pointing to the next character beyond the last one successfully converted.

Premier point intéressant : les longueurs des chaines en entrée (== from_end - from) et en sortie (== to_limit - to) ne sont pas nécessairement corrélées. De fait, il semblerait qu'on puisse utiliser ces méthodes pour implémenter une conversion de caractères du type de celle présentée ci-dessus (JIS vers Unicode). Au moins, rien dans cette définition ne l'interdit. Deuxième point intéressant, on parle bien de conversion appropriée - ce n'est pas une conversion définie par ce standard, mais bien d'une conversion qui a un sens plus large. Il est tout à fait légitime de penser que cette conversion va nous sauver la vie. Par contre, point nettement plus ennuyeux, la conversion elle même peut échouer si on rencontre un caractère incompréhensible.

A la base, codecvt a été ajouté à la librairie standard pour transformer les entrées et les sorties d'un programme en prenant en compte la locale courante et l'encodage utilisé pour les entrées / sorties. La facette est ainsi utilisée si vous utilisez std::wcin dans une console standard qui n'est pas configurée en Unicode. Vous pouvez tester ce petit programme :

#include <iostream>
#include <iomanip>
#include <locale>
#include <vector>
#if defined(WIDE) # define tcin wcin # define tstring wstring # define tcout wcout # define T(s) L##s # define PROMPT "WIDE > " #else # define tcin cin # define tstring string # define tcout cout # define T(s) s # define PROMPT "nowide > " #endif
template <class _It> void showstr(_It first, _It last) { while (first != last) { std::tcout << T(" ") << std::setw(8) << std::setfill(T('0')) << std::hex << (int)*first << std::endl; ++first; } }
int main() { std::locale::global(std::locale("fr_FR.utf8"));
std::tcout << T(PROMPT); std::tstring in; std::getline(std::tcin, in);
showstr(in.begin(), in.end()); }

Compilez ce programme en définissant ou non WIDE (avec g++ : g++ -o showcin showcin.cpp -DWIDE) et exécutez le programme. Sur ma console Linux (UTF8), selon le cas, j'ai

WIDE > abçd
  00000061
  00000062
  000000e7
  00000064

et

nowide > abçd
  00000061
  00000062
  ffffffc3
  ffffffa7
  00000064

Entre la première version, où une conversion UTF8 -> UCS-4 a eu lieu, et la seconde version où aucune conversion n'a pris place, on voit clairement une différence : le caractère 'ç' n'est pas codé de la même manière. C'est donc bien ça qu'il nous faut faire si on veut transformer notre chaine char en chaine wchar_t.

Maintenant, essayons donc d'écrire notre fonction de conversions std::string vers std::wstring.

// le paramêtre loc est censé ne jamais être utilisé, mais il n'est pas impossible de l'utiliser
// de temps à autre. Il est tout de même préférable se renseigner la locale globale. 
inline std::wstring s2w(const std::string& s, const std::locale& loc = std::locale())
{
  typedef std::codecvt<wchar_t,char,std::mbstate_t> facet_type;
  typedef facet_type::result result_type;
std::mbstate_t state = std::mbstate_t(); // mandatory: 0-initialized result_type result; std::vector<wchar_t> buffer(s.size()); const char* end_in_ptr = NULL; wchar_t* end_out_ptr = NULL;
result = std::use_facet<facet_type>(loc).in( state, s.data(), s.data() + s.length(), end_in_ptr, &buffer.front(), &buffer.front() + buffer.size(), end_out_ptr );
return std::wstring(&buffer.front(), end_out_ptr); }

On le voit, le code est bien plus complexe que celui proposé au début de ce billet.

En interne, dans la plupart des librairies standard que j'ai pu tester, ces méthodes utilises les fonctions C mbstowcs et wcstombs dont j'ai parlé plus haut. Ces fonctions ont les même limitations (il est probable que le comité ait calqué les limitations de codecvt sur celles existantes des deux fonctions C). Du coup, les conversions qui sont effectuées sont limitées par les outils installés sur le système d'exécution (c'est à dire par les locales connues). Selon l'OS, le compilateur, etc. , le nombre de locale connues peut être relativement réduit, ce qui risque de poser des problèmes de conversion dans certains cas.

On peut ainsi se retrouver avec des erreurs de conversions (qui bloquent le processus) et avoir des comportements erratiques : std::wcin >> wstr; qui stocke tous les caractères jusqu'au premier caractère accentué, etc. Certains de ces comportements erratiques sont corrigés en ajoutant la ligne std::locale::global(std::locale(nom d'une locale)); au début du programme.

Il existe une librairie standard de haute qualité qui implémente ces fonctions pour différentes locales directement dans la librairie : c'est la librairie STDCXX de la Fondation Apache. De plus, cette librairie implémente un grand nombre de locales différentes, ainsi qu'un grand nombre de conversions de caractères différentes (avec un exemple ici). Si jamais vous avez besoin d'une conversion un peu étrange, je vous encourage à vérifier ce que propose cette librairie open source.

Conclusion

On a fait un petit séjour dans le monde merveilleux des facettes. Cependant, tous les aspects intéressants offerts par cette patrie de la librairie n'ont pas encore été étudiés ici : car il est tout à fait possible de développer ses propres facettes et ainsi influer sur la façon dont vont être traités certaines chaines particulières. Tout ceci fera peut-être l'objet d'un futur article.

En attendant, amusez vous bien !

Notes

[1] ASCII est lui même un standard : c'est la norme ISO/CEI 646, à la base de tous les autres normes définissant les jeux de caractères utilisés

[2] ISO/CEI 10646

[3] A noter que sur l'implémentation de la librairie standard que j'utilise, (sizeof(wchar_t) == 4). Du coup, il s'agirait de ISO/CEI 10646.UCS-4, encore que ce point soit difficile à prouver

[4] std::setlocale() en C++

[5] Si vous travaillez sous Linux, vous pouvez obtenir la liste des locales installées sur votre poste avec la commande shell locale -a, qui liste toutes les locales connues du système. Le nom des locales peut changer selon le système - les systèmes POSIX utilisant un nom sous la forme langage[_pays[.encoding]].

[6] si _InternType et _ExternType sont les mêmes, la facette n'opère aucune conversion et se contente de faire des copies.

[7] doit être égal à std::mbstate_t.

Commentaires

1. Le mardi, janvier 4 2011, 00:52 par Christian Kakesa

Très bon article. Cette question sur les problématiques d'internationalisation n'est pas simple à résoudre dans un contexte cplusplus. Les langages tels que Java, CSharp travaillent directement en unicode en background ce qui simplifient un peu les choses et ont leurs équivalents de <locale>.

Étant un adorateur (j'ai un doute sur ce terme) de cplusplus, un projet moderne doit-il travailler avec les fonctions et containers wides ?

2. Le mardi, janvier 4 2011, 09:32 par Emmanuel Deloget

Bonjour Christian,

Étant donné la complexité du sujet dès lors qu'il faut transformer les chaines normale (basic_string<char>) en chaines UCS4 (basic_string<wchar_t>), il est préférable de travailler directement en chaines wide dès le début (et mettre le programme dans une locale qui permet de traiter les entrées dans un autre format. Dans l'idéal, il devrait y avoir une locale UCS4). Le problème est alors que la plupart des librairies ne sont pas programmée pour permettre ce genre de chose - std::string est utilisé de partout - et que les chaînes littérales sont inutilement préfixées d'un L qui fait mal aux yeux au bout d'un moment.

Mais c'est quand même la chose à faire (et pour les personnes qui implémentent des librairies, les chaines de caractères doivent être systématiquement (au moins) des template <typename Char> std::basic_string<Char>. Oui, le code est plus compliqué, il y a plein de templates de partout, mais c'est nécessaire quand même).

A bas UTF8 ! :)

(Et merci pour le compliment :))

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet