Licence CC 0

La représentation des chaînes de caractères

Dernière mise à jour :

Nous nous sommes attardés précédemment sur la représentation des types entiers et flottants. Dans ce chapitre, nous allons nous intéresser à la représentation des chaînes de caractères. Nous l’avons vu, une chaîne de caractères est un tableau de char finit par un caractère nul. Toutefois, il reste à savoir comment ces caractères sont stockés au sein de ces tableaux de char, ce qui est l’objet de ce chapitre. :)

Les séquences d'échappement

Avant de nous pencher sur la représentation des chaînes de caractères, un petit aparté sur les séquences d’échappement s’impose. Vous avez vu tout au long de ce tutoriel la séquence \0, utilisée pour représenter le caractère nul terminant une chaîne de caractères, sans qu’elle ne vous soit véritablement expliquée.

Cette séquence est en fait tout simplement remplacée par la valeur octale indiquée après le symbole \, ici zéro. Ainsi les trois définitions ci-dessous sont équivalentes.

char s1[] = { 0, 0 };
char s2[] = { '\0', '\0' };
char s3[] = "\0"; /* N'oubliez pas qu'un '\0' est ajouté automatiquement s'agissant d'une chaîne de caractères */

Ces séquences peuvent toutefois être utilisées pour produire n’importe quelle valeur, du moment qu’elle ne dépasse pas la capacité du type char. Ainsi, la séquence \42 a pour valeur 42.

L’octal n’étant cependant pas la base la plus pratique à utiliser, il est également possible d’employer la base hexadécimale en faisant suivre le symbole \ de la lettre « x ». Ainsi, la séquence \x0 est équivalente à la séquence \0.

Les tables de correspondances

Cette section ne constitue qu’une introduction posant les bases nécessaires pour la suite. Si vous souhaitez en apprendre davantage à ce sujet, nous vous invitons à lire le cours de Maëlan sur les encodages sur lequel nous nous sommes appuyé pour écrire cette section.

La table ASCII

Vous le savez, un ordinateur ne manipule jamais que des nombres et, de ce fait, toute donnée non numérique à la base doit être numérisée pour qu’il puisse la traiter. Les caractères ne font dès lors pas exception à cette règle et il a donc été nécessaire de trouver une solution pour transformer les caractères en nombres.

La solution retenue dans leur cas a été d’utiliser des tables de correspondance (aussi appelées code page en anglais) entre nombres et caractères. Chaque caractère se voit alors attribuer un nombre entier qui est ensuite stocké dans un char.

Vocabulaire

Le terme de « jeu de caractères » (character set en anglais, ou charset en abrégé) est également souvent employé. Ce dernier désigne tout simplement un ensemble de caractères. Ainsi, l’ASCII est également un jeu de caractères qui comprend les caractères composant sa table de correspondance.

Une des tables les plus connues est la table ASCII. Cette table sert quasi toujours de base à toutes les autres tables existantes, ce que vous pouvez d’ailleurs vérifier aisément.

#include <stdio.h>


int
main(void)
{
    char ascii[] = "ASCII";

    for (unsigned i = 0; ascii[i] != '\0'; ++i)
        printf("'%c' = %d\n", ascii[i], ascii[i]);

    return 0;
}
Résultat
'A' = 65
'S' = 83
'C' = 67
'I' = 73
'I' = 73

Si vous jetez un coup d’œil à la table vous verrez qu’au caractère A correspond bien le nombre 65 (en décimal), au caractère S le nombre 83, au caractère C le nombre 67 et au caractère I le nombre 73. Le compilateur a donc remplacé pour nous chaque caractère par le nombre entier correspondant dans la table ASCII et a ensuite stocké chacun de ces nombres dans un char de la chaîne de caractères ascii.

Toutefois, si vous regardez plus attentivement la table ASCII, vous remarquerez qu’il n’y a pas beaucoup de caractères. En fait, il y a de quoi écrire en anglais et… en latin. :-°

La raison est historique : l’informatique s’étant principalement développé aux États-Unis, la table choisie ne nécessitait que de quoi écrire l’anglais (l’acronyme ASCII signifie d’ailleurs American Standard Code for Information Interchange). Par ailleurs, la capacité des ordinateurs de l’époque étant limitée, il était le bienvenu que tous ces nombres puissent tenir sur un seul multiplet (il n’était pas rare à l’époque d’avoir des machines dont les multiplets comptaient 7 bits).

La multiplication des tables

L’informatique s’exportant, il devenait nécessaire de pouvoir employer d’autres caractères. C’est ainsi que d’autres tables ont commencés à faire leur apparition, quasiment toutes basées sur la table ASCII et l’étendant comme le latin-1 ou ISO 8859–1 pour les pays francophones et plus généralement « latins », l'ISO 8859–5 pour l’alpabet cyrillique ou l'ISO 8859–6 pour l’alphabet arabe.

Ainsi, il devenait possible d’employer d’autres caractères que ceux de la table ASCII sans modifier la représentation des chaînes de caractères. En effet, en créant grosso modo une table par alphabet, il était possible de maintenir la taille de ces tables dans les limites du type char.

Cependant, cette multiplicité de tables de correspondance amène deux problèmes :

  1. Il est impossible de mélanger des caractères de différents alphabets puisqu’une seule table de correspondance peut être utilisée.
  2. L’échange de données peut vite devenir problématique si le destinateur et la destinataire n’utilisent pas la même table de correspondance.

Par ailleurs, si cette solution fonctionne pour la plupart des alphabets, ce n’est par exemple pas le cas pour les langues asiatiques qui comportent un grand nombre de caractères.

La table Unicode

Afin de résoudre ces problèmes, il a été décidé de construire une table unique, universelle qui contiendrait l’ensemble des alphabets : la table Unicode. Ainsi, finis les problèmes de dissonance entre tables : à chaque caractère correspond un seul et unique nombre. Par ailleurs, comme tous les caractères sont présents dans cette table, il devient possible d’employer plusieurs alphabets. Il s’agit aujourd’hui de la table la plus utilisée.

Sauf qu’avec ses 137.374 caractères (à l’heure où ces lignes sont écrites), il est impossible de représenter les chaînes de caractères simplement en assignant la valeur entière correspondant à chaque caractère dans un char. Il a donc fallu changer de tactique avec l’Unicode en introduisant des méthodes pour représenter ces valeurs sur plusieurs char au lieu d’un seul. Ces représentations sont appelées des encodages.

Il existe trois encodages majeures pour l’Unicode : l’UTF-32, l’UTF-16 et l’UTF-8.

L’UTF-32

L’UTF-32 stocke la valeur correspondant à chaque caractère sur 4 char en utilisant la même représentation que les entiers non signés. Autrement dit, les 4 char sont employés comme s’ils constituaient les 4 multiplets d’un entier non signé. Ainsi, le caractère é qui a pour valeur 233 (soit E9 en hexadécimal) donnera la suite \xE9\x0\x0\x0 (ou l’inverse suivant le boutisme utilisé) en UTF-32.

Oui, vous avez bien lu, vu qu’il emploie la représentation des entiers non signés, cet encodage induit l’ajout de pas mal de char nuls. Or, ces derniers étant utilisés en C pour représenter la fin d’une chaîne de caractères, il est assez peu adapté… De plus, cette représentation implique de devoir gérer les questions de boutisme.

L’UTF-16

L’UTF-16 est fort semblable à l’UTF-32 mis à part qu’il stocke la valeur correspondant à chaque caractère sur 2 char au lieu de 4. Avec une différence notable toutefois : les valeurs supérieures à 65535 (qui sont donc trop grandes pour être stockées sur deux char) sont, elles, stockées sur des paires de 2 char.

Toutefois, dans notre cas, l’UTF-16 pose les mêmes problèmes que l’UTF-32 : il induit la présence de char nuls au sein des chaînes de caractères et nécessite de s’occuper des questions de boutisme.

L’UTF-8

L’UTF-8 utilise un nombre variable de char pour stocker la valeur d’un caractère. Plus précisément, les caractères ASCII sont stockés sur un char, comme si la table ASCII était utilisée, mais tous les autres caractères sont stockés sur des séquences de deux ou plusieurs char.

À la différence de l’UTF-32 ou de l’UTF-16, cet encodage ne pose pas la question du boutisme (s’agissant de séquences d’un ou plusieurs char) et n’induit pas l’ajout de caractères nuls (la méthode utilisée pour traduire la valeur d’un caractère en une suite de char a été pensée à cet effet).

Par exemple, en UTF-8, le caractère é sera traduit par la suite de char \xc3\xa9. Vous pouvez vous en convaincre en exécutant le code suivant.

#include <stdio.h>


int
main(void)
{
    char utf8[] = u8"é";

    for (unsigned i = 0; utf8[i] != '\0'; ++i)
        printf("%02x\n", (unsigned char)utf8[i]);

    return 0;
}
Résultat
c3
a9

Notez que nous avons fait précédé la chaîne de caractères "é" de la suite u8 afin que celle-ci soit encodée en UTF-8.

Comme vous le voyez, notre chaîne est bien composée de deux char ayant pour valeur \xc3 et \xa9. Toutfois, si l’UTF-8 permet de stocker n’importe quel caractère Unicode au sein d’une chaîne de caractères, il amène un autre problème : il n’y a plus de correspondance systématique entre un char et un caractère.

En effet, si vous prenez l’exemple présenté juste au-dessus, nous avons une chaîne composée d’un seul caractère, é, mais dans les faits composée de deux char valant \xc3 et \xa9. Or, ni \xc3, ni \xa9 ne représentent un caractère (rappelez-vous : seuls les caractères ASCII tiennent sur un char en UTF-8), mais uniquement leur succession.

Ceci amène plusieurs soucis, notamment au niveau de la manipulation des chaînes de caractères, puisque les fonctions de la bibliothèque standard partent du principe qu’à un char correspond un caractère (pensez à strlen(), strchr() ou strpbrk()).

Des chaînes, des encodages

Bien, nous savons à présent comment les chaînes de caractère sont représentées. Toutefois, il reste une question en suspend : vu qu’il existe pléthore de tables de correspondance, quelle est celle qui est utilisée en C ? Autrement dit : quelle table est utilisée en C pour représenter les chaînes de caractères ? Eh bien, cela dépend de quelles chaînes de caractères vous parlez… :-°

Pour faire bref :

  1. La table de correspondance et l’encodage des chaînes littérales dépendent de votre compilateur ;
  2. La table de correspondance et l’encodage des chaînes reçues depuis le terminal dépendent de votre système d’exploitation ;
  3. La table de correspondance et l’encodage des fichiers dépendent de ceux utilisés lors de leur écriture (typiquement celui utilisé par un éditeur de texte).

Et le plus amusant, c’est qu’ils peuvent tous être différents ! o_O

L’exemple suivant illustre tout l’enfer qui peut se cacher derrière cette possibilité.

#include <stdio.h>
#include <stdlib.h>


static void chomp(char *s)
{
    while (*s != '\n' && *s != '\0')
        ++s;

    *s = '\0';
}


int
main(void)
{
    char ligne[255];

    printf("Cette ligne est encodée en UTF-8\n");

    if (fgets(ligne, sizeof ligne, stdin) == NULL)
    {
        perror("scanf");
        return EXIT_FAILURE;
    }

    chomp(ligne);
    printf("Cette chaîne en ISO 8859-1 : %s\n", ligne);
    FILE *fp = fopen("fichier.txt", "r");

    if (fp == NULL)
    {
        perror("fopen");
        return EXIT_FAILURE;
    }
    if (fgets(ligne, sizeof ligne, fp) == NULL)
    {
        perror("fscanf");
        return EXIT_FAILURE;
    }

    chomp(ligne);
    printf("Et celle-là en IBM 850 : %s\n", ligne);
    fclose(fp);
    return 0;
}
Résultat
Cette ligne est encodée en UTF-8
L'ISO 8859-1 c'est tellement plus élégant !
Cette chaîne en ISO 8859-1 : L'ISO 8859-1 c'est tellement plus élégant !
Et celle-là en IBM 850 : Moi aussi je suis lgant !

Cet exemple illustre l’affichage d’un programme dont les chaîne littérales sont encodées en UTF-8, les entrées du terminal en ISO 8859–1 et le texte d’un fichier (dont le contenu est « Moi aussi je suis élégant ! ») en IBM 850.

Que se passe-t-il, donc ?

  1. Tout d’abord, la chaîne « Cette ligne est encodée en UTF-8 » est affichée sur le terminal. Comme celui-ci utilise l’ISO 8859–1 et non l’UTF-8, la suite \xc3\xa9 (le « é » en UTF-8) est interprétée comme deux caractères distincts (l’ISO 8859–1 encode les caractères sur un seul multiplet) : « Ã » et « © » (dont les valeurs correspondent à \xc3 et \xa9 dans la table ISO 8859–1).
  2. Ensuite, la chaîne « L’ISO 8859–1 c’est tellement plus élégant ! » est récupérée en entrée et est affichée. Étant donné qu’elle est lue depuis le terminal, elle est encodée en ISO 8859–1 et est donc correctement affichée. Notez que comme « Cette chaîne en ISO 8859–1 : » est une chaîne littérale, elle est encodée en UTF-8, d’où la suite de caractères « î » au lieu du « î » (\xc3\xae en UTF-8).
  3. Enfin, la chaîne « Moi aussi je suis élégant ! » est lue depuis le fichier fichier.txt. Celle-ci est encodée en IBM 850 qui associe au caractère « é » le nombre 130 (\x82). Cependant, ce nombre ne correspond à rien dans la table ISO 8859–1, du coup, nous obtenons « Moi aussi je suis lgant ! ». En passant, remarquez à nouveau que la chaîne littérale « Et celle-là en IBM 850 : » est elle encodée en UTF-8, ce qui fait que la suite correspondant au caractère « à » (\xc3\xa0) est affichée comme deux caractères : « Ã » et un espace insécable.

Vers une utilisation globale de l’UTF-8

Toutefois, si ce genre de situations peut encore se présenter, de nos jours, elles sont rares car la plupart des compilateurs (c’est le cas de Clang et GCC par exemple) et systèmes d’exploitation (à l’exception notable de Windows, mais vous évitez ce problème en utilisant MSys2) utilisent l’Unicode comme table de correspondance et l’UTF-8 comme encodage par défaut. Cependant, rien n’est imposé par la norme de ce côté, il est donc nécessaire de vous en référer à votre compilateur et/ou à votre système d’exploitation sur ce point.

Choisir la table et l’encodage utilisés par le compilateur

Cela étant dit, dans le cas du compilateur, comme nous l’avons déjà vu, il est possible d’imposer l’UTF-8 comme encodage en faisant précédé vos chaînes de caractères par la suite u8. Également, certains compilateurs vous permettent de spécifier l’encodage que vous souhaitez utiliser, c’est par exemple le cas de GCC qui fourni l’option --fexec-charset=<encodage> à cet effet.

#include <stdio.h>


int
main(void)
{
    char s[] = "Élégant";


    for (unsigned i = 0; s[i] != '\0'; ++i)
        printf("%02x ", (unsigned char)s[i]);

    printf("\n");
    return 0;
}
Résultat
$ zcc main.c --fexec-charset=ISO8859-1 -o x
$ ./x
c9 6c e9 67 61 6e 74

$ zcc main.c -o x
$ ./x
c3 89 6c c3 a9 67 61 6e 74

Comme vous le voyez, nous avons demandé à GCC d’utiliser l’encodage ISO 8859–1, puis nous n’avons rien spécifié et c’est alors l’UTF-8 qui a été utilisé.


En résumé
  1. Les séquences d’échappement \n et \xn sont remplacées, respectivement, par la valeur octale ou hexadécimale précisée après \ ou \x.
  2. Les caractères sont numérisés à l’aide de tables de correspondance comme la table ASCII ou la table ISO 8859–1 ;
  3. L’Unicode a pour vocation de construire une table de correspondance universelle pour tous les caractères ;
  4. Une chaîne de caractères étant une suite de char finie par un multiplet nul, il est nécessaire de répartir les valeurs supérieures à la capacité d’un char sur plusieurs char suivant un encodage comme l’UTF-8 ;
  5. La table de correspondance et l’encodage utilisés pour représenter les chaîne de caractères littérales dépendent du compilateur ;
  6. La table de correspondance et l’encodage utilisés pour représenter les chaîne de caractères lues depuis le terminal dépendent de votre système ;
  7. La table de correspondance et l’encodage des fichiers dépendent de ceux utilisés lors de leur écriture ;
  8. Il est possible d’imposer l’UTF-8 comme encodage pour une chaîne de caractères en la faisant précéder de la suite u8 ;
  9. L’option --fexec-charset de GCC permet de préciser l’encodage désiré pour représenter les chaînes de caractères.