Bonnes pratiques C++ (7/n)

Ce billet appartient à une série consacrée aux bonnes pratiques de développement C++. Certains sont toutefois applicables à d'autres langages. Aujourd’hui : les chaînes de caractères et la gestion du texte.

Les chaînes de caractères n’existent pas

Un fichier texte ou une chaîne de caractères, ça n’existe pas. Un ordinateur ne sait manipuler que des nombres et pour stocker une suite de caractères, il faut la convertir au préalable en une suite de nombres. Hélas, tant pour des raisons historiques que techniques, il existe des dizaines de conventions et de standards différents pour encoder les caractères sous forme de nombres : EBDIC, ASCII, Big5, Shift-JIS, KOI8-R, puis plus récemment les innombrables pages de code inventées par IBM, Windows et Apple pour leurs systèmes d’exploitation respectifs et enfin plus récemment encore, Unicode.

Si vous récupérez le contenu d’un fichier texte, d’un mail ou d’une page web et que vous ne savez pas comment il a été encodé, vous ne pouvez pas le manipuler de façon fiable. Vous ne pouvez ni l’afficher correctement à coup sûr, ni le découper en caractères individuels, ni le modifier (le convertir en majuscule ou en minuscule par exemple), ni même compter le nombre de caractères qu’il contient. C’est aussi simple que cela, et le nombre d’applications qui ne savent pas gérer correctement les lettres accentuées ou les alphabets autres que latins est un bon indice du nombre de développeurs qui ne maîtrisent pas ces sujets. (Ou qui n’y accordent pas l’importance qu’ils devraient…)

Avant d’aller plus loin, il est indispensable de s’attarder sur la façon dont les caractères sont encodés, que ce soit dans un fichier sur disque ou en mémoire.

Tout d’abord, chaque caractère est converti en une valeur numérique grâce à une table d’encodage : la page de codes. Il en existe des dizaines. La plupart sont conçues pour un encodage sur 8 bits, elles ne peuvent donc représenter que 256 caractères différents. La raison en est que ces pages de code ont été conçues à une époque où la mémoire était rare et chère, il fallait donc de représenter les caractères en utilisant le moins de bits possibles ; de plus, l’informatique n’était pas encore mondialisée et il était très exceptionnel, pour un ordinateur français par exemple, d’avoir à afficher des caractères grecs, russes ou japonais. Les constructeurs se contentaient donc d’utiliser une page de code par pays ou par région du monde, par exemple pour Windows : la page 1251 pour la Russie, 1252 pour l’Europe de l’Ouest, 1253 pour la Grèce, 1254 pour la Turquie, 1255 pour Israël, et ainsi de suite. C’était simple et efficace, la contrepartie acceptable pour l’époque étant qu’il était impossible d’afficher des alphabets étrangers à la région où l’on se trouvait.

Tout a changé avec l’arrivée d’internet, qui a rendu impérieux le besoin d’afficher des textes dans n’importe quelle langue sur n’importe quel ordinateur. Le standard ISO 2022 a apporté une ébauche de solution, sous la forme de séquences d’échappement normalisées qui permettaient de réaliser un changement de page de code au milieu d’un texte. Par exemple, on pouvait avoir : une séquence d’échappement pour indiquer que la page de code en vigueur était la 1252, puis du texte français, puis une séquence d’échappement pour passer à la page 1253, puis une citation en grec, puis une nouvelle séquence d’échappement pour revenir à la page initiale, puis la suite du texte en français. Si cette norme permet de résoudre le problème de l’internationalisation, elle rend le traitement des chaînes de caractère particulièrement complexe, une opération comme le changement de casse ou le découpage en caractères individuels ne se faisant pas suivant le même algorithme sur tous les segments de la chaîne. Malgré tout, ISO 2022 est encore souvent utilisé, sous une forme améliorée, pour encoder le japonais.

La solution définitive est apparue avec Unicode : une page de code « géante » qui permet d’encoder pratiquement tous les caractères de tous les alphabets connus, ainsi qu’un nombre incalculable de signes de ponctuations, de symboles et autres émojis. Pour cela, Unicode utilise un encodage sur 24 bits ; sachant que des plages de valeurs sont interdites ou réservées, la table peut contenir jusqu’à 1 114 112 caractères différents et à l’heure actuelle, environ 250 000 entrées sont attribuées. Parfois, il existe plusieurs encodages possibles pour un caractère donné. Par exemple, le caractère « É » peut être représenté soit par la valeur U+00E9, soit par la succession du caractère « E » U+0045 et du caractère « accent aigu » U+00B4. En pratique, sur un système donné, il est rare d’avoir un mélange de caractères composites et de caractères précomposés ; mais des surprises sont possibles si l’on essaie de comparer un texte saisi sous Windows avec un texte saisi sous Linux…

Il faut noter que pratiquement toutes les pages de code, y compris Unicode, sont compatibles avec l’encodage ASCII : les valeurs comprises entre 32 et 127 représentent toujours les mêmes caractères dans tous les systèmes. Cela s’explique très simplement : les langages informatiques utilisant l’anglais, il faut représenter les caractères latins sur tous les ordinateurs de la même façon si l’on veut pouvoir partager du code informatique. Ainsi, les balises HTML ou CSS de n’importe quelle page web sont comprises par n’importe quel navigateur, quelle que soit la langue du système où il tourne, de même qu’un compilateur C++ accepte n’importe quel fichier source C++ valide, quelle que soit la langue du système d’exploitation où ce fichier a été créé. Cette particularité est également utile pour indiquer l’encodage d’un texte de façon univoque dans une balise en début de fichier ; cette balise n’utilisant que des caractères ASCII, elle peut être lue et décodée sans ambiguïté sur tous les systèmes. Par exemple, on peut trouver au début d’un fichier XML :

<?xml version="1.0" encoding="ISO-8859-15"?>

Dans le cas des pages de code historiques sur 8 bits, comme ISO 8859 ou Windows-1252, l’encodage s’arrête là. Les octets correspondants aux différents caractères du texte sont directement stockés en mémoire ou dans un fichier les uns à la suite des autres. L’immense avantage de ce système est que la manipulation des chaînes de caractères y est très simple, surtout en C/C++ puisque l’arithmétique des pointeurs sur un char * correspond précisément au découpage de la chaîne en caractères. Extraire le énième caractère d’une chaîne, compter le nombre total de caractères, ou bien découper une chaîne en mots séparés par des espaces sont des opérations triviales. Transformer un texte en majuscule ou en minuscule est aussi relativement simple, grâce à une table de conversion à 256 entrées.

En revanche, dans le cas d’Unicode, il n’est pas possible de représenter chaque caractère par sa valeur sur 24 bits, essentiellement parce que les processeurs ne sont pas performants pour manipuler des entiers dont la taille n’est une puissance de deux ; des « transformations » ont donc été inventées pour convertir la suite de valeurs d’une chaîne Unicode en une suite d’octets plus faciles à manipuler et aussi, le plus souvent, moins gourmande en occupation mémoire.

La plus connue est UTF-8. Elle représente chaque point de la table Unicode par une séquence d’octets, les valeurs inférieures à 127 étant représentées par un seul octet et les valeurs supérieures par deux, trois ou quatre octets. (Reportez-vous à Wikipédia pour une description détaillée de l’algorithme !) La logique sous-jacente est que les caractères correspondants aux petites valeurs sont aussi ceux que l’on rencontre le plus fréquemment (en gros : les caractères latins non accentués et les chiffres arabes) et donc, que la majorité des caractères d’un texte peuvent être représentés par un seul octet. Il en résulte une grande économie de place, voire dans le cas des fichiers sources de la plupart des langages de programmation, qui n’utilisent que des caractères ASCII, aucune perte de place par rapport aux anciens encodages. (Bien sûr, ce n’est pas vrai pour des textes en mandarin ou en japonais et dans ces langues, UTF-8 conduit à augmenter considérablement la taille des fichiers par rapport à d’autres encodages. C’est un cas flagrant d’impérialisme technologique : les pays occidentaux ont imposé une norme qui favorise leurs langues au détriment des langues orientales. C’est sans doute aussi pourquoi ISO 2022 est encore utilisé pour le japonais, malgré ses inconvénients.)

Comme le montre la figure ci-dessus, le principal inconvénient d’UTF-8 est qu’un caractère occupe un nombre variable d’octets. Il n’est donc plus possible de découper simplement un texte en caractères, ni même de compter le nombre de caractères dans une chaîne. Ces opérations nécessitent de prendre en compte l’effet de la transformation. Toutefois, la valeur zéro ne pouvant pas être générée par l’algorithme UTF-8, il reste possible d’utiliser le caractère nul comme marqueur de fin de chaîne et toutes les fonctions historiques du C comme strcpy ou strcat fonctionnent toujours parfaitement, de même que des constructions idiomatiques telles que malloc(strlen(p)+1). C’est l’une des raisons du succès d’UTF-8 dans le monde UNIX : la majorité du code du système d’exploitation est en C et n’a nécessité aucune modification pour supporter Unicode.

L’encodage UTF-16 est une variante à 16 bits d’UTF-8 : il utilise un mot de deux octets pour représenter les valeurs de 0 à 65535 et deux mots de deux octets pour les valeurs supérieures. L’algorithme de décodage détermine si le caractère qui vient est sur un ou deux mots grâce à une valeur spéciale dans les bits de poids fort du premier mot. Aucune ambiguïté n’est possible, car cette valeur spéciale correspond à une plage interdite de la table Unicode, elle ne peut donc pas apparaître dans un caractère encodé sur un seul mot. (Là encore, reportez-vous à Wikipedia pour plus d’informations.) Bien sûr, comme à chaque fois qu’il s’agit de représenter des entiers d’une taille supérieure à un octet en mémoire, se pose la question de l’ordre de ces octets. Il existe donc deux variantes d’UTF-16, une little endian et une big endian.

Cet encodage représente un compromis. Dans l’immense majorité des cas, un texte est encodé avec des mots de deux octets par caractère. On retrouve donc la simplicité du C historique, avec la possibilité d’accéder aux différents caractères d’une chaîne par la simple arithmétique des pointeurs, au détriment de l’occupation mémoire, puisqu’il faut deux fois plus de place qu’avec les anciennes pages de code à 8 bits. C’est l’encodage utilisé par Windows.

Enfin, l’encodage UTF-32 est le plus simple, puisqu’il utilise toujours quatre octets, soit un entier long par caractère. C’est très simple à manipuler mais au prix d’un gaspillage de place énorme, puisque Unicode n’ayant de besoin que de 24 bits, les 8 bits de poids fort de chaque caractère sont toujours à zéro. Pour un fichier n’utilisant que des caractères ASCII, comme beaucoup de pages web, ce sont même les vingt-cinq premiers bits de chaque caractère qui sont inutilisés, soit 78 % de perte ! On n’utilise UTF-32 que rarement et toujours très localement, par exemple pour un algorithme qui serait trop complexe à implémenter avec les autres encodages. Comme pour UTF-16, il en existe deux variantes, une little endian et une big endian.

Pourquoi est-il essentiel de maîtriser ces bases sur l’encodage des textes ? Parce que contrairement à des langages plus évolués tels que Python, Ruby ou Haskell, en matière d’encodage des textes, le C/C++ est agnostique. Un char * est juste un pointeur sur un tableau d’octets, un wchar_t * est juste un pointeur sur un tableau d’entiers 16 ou 32 bits selon la machine, et les objets std::string et std::wstring ne sont que des encapsulations des deux précédents. Ces objets ne se préoccupent pas des problèmes d’encodage. Ce sont juste des buffers sans intelligence. C’est à vous de savoir si ces buffers contiennent du texte en UTF-8, en ISO-8859-15 ou en Shift-JIS, et à vous de les manipuler en conséquence. Vous remarquerez d’ailleurs que std::string ne fournit aucune fonction de manipulation de texte (extraction de caractères, découpage selon un séparateur donné, mise en majuscule ou en minuscule, etc.) : c’est parce que de telles fonctions ne peuvent pas opérer sans connaître l’encodage utilisé, et std::string ignore tout des encodages.

Si votre application doit manipuler du texte, ce qui est probablement le cas de la plupart des applications sur lesquelles vous travaillez, il est impératif de vous fixer des règles pour ne pas vous emmêler les pinceaux et ne pas passer un std::string contenant une chaîne UTF-8 à une fonction qui attend une chaîne CP-1252 ou inversement (cas extrêmement fréquent sous Windows et source inépuisable de bugs avec les caractères accentués). La stratégie la plus simple est sans doute la suivante :

  • Choisissez un encodage donné et utilisez-le partout, dans toute l’application : pour toutes les variables, pour toutes les données stockées en bases, pour toutes les chaines écrites dans des fichiers propriétaires, etc.
  • Faites les conversions nécessaires aux interfaces avec le monde extérieur. Lorsque l’utilisateur saisit un texte dans un champ, convertissez-le au plus tôt depuis l’encodage du système vers votre encodage interne. Inversement, pour afficher un texte, ou pour appeler une fonction système qui attend un nom de fichier, convertissez la chaîne de caractère le plus tard possible depuis votre encodage interne vers l’encodage attendu par le système.
  • Si vous devez importer des données, typiquement depuis un fichier ou depuis internet, convertissez-le vers votre encodage interne avant de le stocker. Il n’existe hélas pas de méthode universelle pour déterminer l’encodage d’un texte arbitraire. Soit vous le connaissez parce qu’il est normalisé ou indiqué quelque part (par exemple dans un fichier HTML, il y a toujours une balise indiquant de façon univoque l’encodage du fichier), soit il faut se rabattre sur des heuristiques basées sur la fréquence de caractères ou sur les caractéristiques numériques de l’encodage. Par exemple, un fichier contenant du texte dans une langue européenne et dont tous les octets pairs sont à zéro est très probablement encodé en UTF-16 little endian. Notons qu’il est possible de déterminer à coup sûr qu’un fichier n’est pas UTF-8 puisque dans cet encodage, les premiers bits de chaque octet doivent suivre des séquences particulières. Sachant que dans tous les autres encodages, la probabilité de retrouver les mêmes séquences est quasi nulle, un fichier qui ne contient que des codes UTF-8 valides est presque certainement un fichier UTF-8.

Toute la question est de savoir quel encodage interne choisir. Si votre application est destinée à ne tourner que sur une seule plateforme, le plus simple est sans doute d’utiliser l’encodage natif de cette plateforme. Ainsi, il n’y a besoin d’aucune conversion lorsque vous appelez des fonctions du système ou lorsque le système vous passe des informations. Une application destinée à Windows uniquement a tout intérêt à choisir UTF-16 comme format de stockage interne, tandis qu’une application Linux ou macOS préfèrera UTF-8. Il faut toutefois avoir conscience que C++ ne fournit aucune méthode pour manipuler simplement ces deux encodages. Si vous devez compter le nombre de lettres dans un texte ou changer la casse d’une lettre, il faudra vous rabattre sur des fonctions fournies par le système (donc non portables) ou par des bibliothèques externes. Utiliser naïvement les bons vieux strlen ou strlower sur une chaîne UTF-8 est une erreur fréquente, qui passera sans doute inaperçue jusqu’au jour où quelqu’un saisira un caractère accentué ou un émoji là où vous ne l’attendez pas. Notons toutefois que strlen peut toujours s'utiliser pour compter le nombre d’octets d’une chaine ; c’est utile pour savoir quelle quantité de mémoire allouer pour la stocker. Mais ce n’est pas la même chose que le nombre de caractères, dont vous avez besoin pour implémenter une fonctionnalité telle que vérifier qu’un mot de passe saisi par l’utilisateur n’est pas trop court.

Si en revanche votre application doit être portable sur plusieurs plateformes, alors il faut choisir un encodage arbitraire, soit UTF-8, soit UTF-16, et avec de la compilation conditionnelle, insérer des conversions aux points d’interfaces avec le monde extérieur. Par exemple, si vous décidez que toutes vos chaines sont UTF-8 en interne :

void openFile(std::string utf8_filename) {
#ifdef _WIN32
    std::wstring filename = to_utf16(utf8_filename);
    FILE * fp = _wfopen(filename.c_str(), L"r");
#else
    FILE * fp = fopen(utf8_filename.c_str(), "r");
#endif
  // ...
}

Pour l’implémentation de la conversion entre les différents formats UTF, symbolisée dans l’exemple ci-dessus par la fonction to_utf16, tous les systèmes fournissent des fonctions dédiées : MultiByteToWideChar et WideCharToMultiByte sous Windows, iconv_open, iconv et iconv_close sous Linux par exemple. Pour une implémentation plus portable, il est aussi possible d’utiliser la classe std::wstring_convert de la STL.

Sauf exception, il n’est pas conseillé d’utiliser un autre encodage qu’Unicode, soit en pratique : UTF-8, UTF-16 ou UTF-32. De nos jours, les applications sont distribuées aux quatre coins du monde, il est donc impératif de gérer correctement tous les scripts y compris non latins et seul Unicode le permet de façon fiable sans trop de complication.

Si vous avez besoin de traiter du texte, par exemple le mettre en majuscule, ou bien faire des comparaisons insensibles à la casse (et insensible aux mélanges de caractères composites et précomposés !), outre les fonctions que le système d’exploitation peut fournir, la bibliothèque de référence est ICU, abréviation de International Components for Unicode. Elle est open source et garantit une prise en charge correcte et homogène de toutes les langues sur tous les systèmes d’exploitation.

Si vous travaillez sous Windows, je ne peux que très vivement recommander la lecture de la documentation Microsoft concernant la manipulation de texte, l’internationalisation et l’API Win32. Il existe tout un mécanisme permettant de faire cohabiter des applications Unicode (qui manipulent donc des chaînes encodées en UTF-16) et des applications ANSI historiques (qui manipulent des chaînes encodées avec l’une des pages de code à 8 bits) et ce mécanisme n’est pas dépourvu de pièges. Dans les grandes lignes, toutes les fonctions de l’API Win32 existent en deux versions, l’une ANSI et l’autre Unicode, qui se différencient par la lettre A ou W suffixée au nom de chaque fonction. Des macros permettent d’appeler la bonne version selon que votre application est compilée en ANSI ou en Unicode. Ainsi, il existe une fonction CreateFileA, une fonction CreateFileW, et une macro CreateFile qui redirige vers la bonne version. De plus, le type TCHAR est défini automatiquement comme étant un char en mode ANSI et un unsigned short en mode Unicode. C’est transparent, mais en pratique il faut rester vigilant. Voici un exemple de piège :

TCHAR * p = GetEnvironmentString();
std::string env(p, p + _tcslen(p));

Ce code d’apparence inoffensive compilera aussi bien en mode ANSI qu’en mode Unicode. Dans le premier cas, après traitement des macros par le préprocesseur, le code effectivement compilé sera :

char * p = GetEnvironmentStringA();
std::string env(p, p + strlen(p));

Dans le second cas, ce sera :

unsigned short * p = GetEnvironmentStringW();
std::string env(p, p + wcslen(p));

Mais cette dernière version ne fonctionnera pas correctement pour les caractères accentués – ne parlons même pas des scripts non latins. En effet, il n’existe aucun constructeur de std::string qui accepte une chaine encodée en UTF-16 ! Rappelez-vous : la STL ignore les encodages. En revanche, le C++ considère que tout pointeur est un itérateur valide et en réalité, le constructeur qui permet au code ci-dessus de compiler sans erreur ni warning est celui-ci :

template<class InputIterator> string(InputIterator first, InputIterator last);

Bien sûr, ce constructeur ne fait pas du tout ce que vous attendez. Il n’opère aucune conversion d’encodage mais se contente de construire une chaine avec la suite des valeurs obtenues en parcourant un itérateur de first à last. Dans notre cas, sachant que dans la version Unicode du code, cet itérateur retourne des unsigned short et que std::string ne stocke que des char, les valeurs supérieures à 255 sont purement et simplement tronquées.

Enfin, ne faites surtout pas l’erreur de croire que sous Windows, un caractère est un unsigned short et que comme au bon vieux temps du C, vous allez pouvoir utiliser l’arithmétique de pointeur pour parcourir et découper une chaîne. Windows utilise l’encodage UTF-16, ce qui signifie que toutes les valeurs nécessitant plus de 16 bits sont encodées sur plusieurs unsigned short consécutifs. Ce n’était probablement pas grave d’en faire abstraction il y a quinze ans, mais de nos jours, il existe des caractères très utilisés qui ont un code supérieur à 65535 dans la table Unicode : les émojis…