Texte
Contexte
Si vous vous êtes déjà retrouvé face à un tas de ’ dans la sortie textuelle d’un programme que vous utilisez, vous vous êtes peut-être demandé comment on en arrive là.
C’est en fait une conséquence naturelle de la façon dont le C et le C++ gèrent les chaînes de caractère : la gestion du texte dans la bibliothèque standard de ces langages a une conception trop simpliste qui encourage le programmeur à entretenir des croyances telles que :
- “C’est une bonne idée de raisonner sur le texte en termes de tableau de caractères.”
- “Un caractère tient dans les 8 bits du type
char
.” - “Il n’y a qu’une seule façon d’interpréter les 8 bits d’un
char
en caractères écrits.” - “Puisqu’une chaîne est un tableau, on peut la couper en n’importe quel point et obtenir deux fragments du texte original en sortie.”
En réalité, manipuler du texte avec du code est nettement plus complexe que le débutant ne l’imagine. Et je ne vous parle pas ici des horreurs lovecraftiennes cachées dans l’implémentation des moteurs de rendu texte à l’écran qu’on utilise tous les jours sans y penser. Rien que pour échanger correctement des octets de texte avec un fichier ou stdin/stdout en effectuant des modifications mineures, on doit déjà surmonter un grand nombre de préjugés.
A cause de cette complexité, il ne serait pas raisonnable d’intégrer à la biblothèque standard d’un langage de programmation une solution complète qui couvre tous les besoins courants. Tout ce qu’un langage et sa bibliothèque standard peuvent faire, c’est couvrir les bases correctement, en concevant et documentant les types et interfaces très soigneusement pour aider les programmeurs à prendre conscience de leurs suppositions inconscientes.
C’est dans le contexte de ce compromis qu’on peut comprendre les types textuels standard de Rust.
char
Pour des raisons de familiarité, Rust perpétue malheureusement un piège ergonomique millénaire en nous fournissant un type char dont le nom suggère qu’il contient un caractère
En réalité, ce que ce type contient, c’est un point de code, la brique de base de la norme Unicode.
Comme la norme ASCII, la norme Unicode définit un texte comme une séquence de points de code. Mais cette modélisation logique doit être distinguée de la représentation machine d’un texte Unicode, qui elle n’est généralement pas un tableau de points de code comme en ASCII. Nous reviendrons sur ce point un peu plus loin.
Pour l’heure, commençons par clarifier ma remarque initiale sur char
. Même si
de nombreux caractères peuvent être encodés avec un seul point de code Unicode,
la notion de point de code en Unicode n’est ni un sur-ensemble, ni un
sous-ensemble de celle de caractère. En effet…
- Parmi les points de code d’Unicode, on trouve toutes sortes de modificateurs
affectant l’interprétation des points de code suivants et précédents dans le
texte : accents, changement de sens d’écriture, césures… Il ne serait pas
raisonnable d’appeler cela des caractères.
#![allow(unused)] fn main() { // Cette marque de sens d'écriture n'est pas un caractère println!("\u{200E}"); }
- A l’inverse, de nombreux caractères peuvent être encodés avec plusieurs
points de code (par exemple les caractères accentués en français), et pour les
caractères de nombreuses langues c’est même la seule option disponible.
#![allow(unused)] fn main() { // Ce caractère est encodé via une paire de points de code : println!("e\u{0301}"); }
A l’heure où ces lignes sont écrites, la norme Unicode définit 149186 points de
code. On peut facilement stocker des valeurs représentant ces différents points
de code dans un entier 32 bits, mais on devine que si on utilisait simplement
un type entier primitif comme u32
pour ça, on se retrouverait dans une
situation de typage trop faible, le type u32
autorisant aussi des valeurs qui
ne correspondent à aucun point de code.
Par conséquent, chaque fois qu’une fonction prendrait en paramètre un u32
censé être un point de code, elle devrait commencer par vérifier que c’en est
bien un. Et de façon symétrique, un code qui appelle une fonction retournant un
u32
censé être un point de code devrait aussi vérifier que c’en est bien un si
la source n’est pas fiable (ex : utilisateur).
Pour éviter toutes ces vérifications à l’exécution, Rust définit donc le type
char
, qui est un entier 32-bit spécialisé qui n’accepte que les valeurs
numériques des points de code d’Unicode.
Ainsi, une fonction qui prend un char
en paramètre est assurée à la
compilation que ce type contient un point de code valide, et à l’inverse un
appelant de fonction qui reçoit un char
en résultat sait aussi que ce sera un
point de code valide.
La syntaxe des litérales char
est au reste très similaire à ce qu’on connaît
en C++ : une paire d’apostrophes entourant le point de code désiré, avec des
séquences d’échappement possibles pour représenter les points de code
exotiques.
#![allow(unused)] fn main() { let c1 = 'a'; let c2 = '\u{0301}'; // Accent aigü isolé println!("{c1} {c2}"); }
Le type char
fournit également un certain nombre de méthodes
utiles pour la manipulation
du point de code qu’il contient, permettant par exemple de distinguer les
marques typographiques des caractères au sens usuel du terme.
str
Sur le principe, on pourrait stocker du texte Unicode sous forme de tableau de
char
, c’est ce qu’on appelle l’encodage UTF-32. Mais cet encodage est très peu
utilisé dans le monde réel, car il est extrêmement gourmand en mémoire.
Par exemple, la version UTF-32 d’un texte anglais prendrait quatre fois plus de
places en mémoire en UTF-32 que sa version ASCII, à information équivalente.
A la place, tous les programmes ayant effectué leur transition Unicode récemment
privilégient l’encodage UTF-8, qui est de taille variable et permet d’assurer
que tous les caractères simples tiennent en un seul octet, au prix d’une
complexité de décodage un peu plus élevée. Rust, qui a été stabilisé bien après
la création d’UTF-8, a naturellement suivi cette tendance pour son type
primitif de chaîne de caractère, str
.
Mais comme précédemment avec char
, le type str
ne peut pas être une simple
séquence d’octets (comme le type [u8; N]
de taille fixe que nous avons déjà
vu et le type [u8]
de taille variable que nous allons voir plus tard), car
toutes les séquences d’octet ne sont pas de l’UTF-8 valide.
Le type str
est donc représenté au niveau machine comme une séquence d’octets,
mais il impose en plus pour toutes les interfaces qui acceptent ou consomment
des str
l’invariant supplémentaire que la séquence d’octets manipulée doit
être de l’UTF-8 valide. Cela permet à la bibliothèque standard d’offrir des
fonctionnalités utiles comme le décodage de str
en séquence de char
, sans
devoir pour autant s’imposer des vérifications de types à l’exécution.
Comme char
, str
fournit une grande palette
d’outils pour les
manipulations Unicode simples, incluant par exemple la recherche d’une
sous-chaîne et le découpage d’une chaîne. Mais ces opérations illustrent aussi
les limites du support Unicode de la bibliothèque standard :
- Les comparaisons standard, et donc la recherche standard, sont non seulement
sensibles à la casse mais aussi à la séquence précise de points de code
utilisée pour encoder un caractère :
Pour éviter ce problème en présence de données d’entrée non contrôlées, il faut normaliser ses chaînes de caractère en ré-encodant chaque caractère par une séquence de points de code unique. Ce travail est laissé à des bibliothèques tierces comme unicode_normalization.#![allow(unused)] fn main() { let normalisation1 = "é"; let normalisation2 = "e\u{0301}"; let comparaison = normalisation1 == normalisation2; println!("{normalisation1} == {normalisation2} -> {comparaison}"); }
- Le découpage d’une chaîne ne peut être effectué qu’à la frontière entre deux
points de code, ce qui est suffisant pour préserver les invariants du type
str
mais pas toujours correct :
Là encore, le problème du choix d’un bon point de découpe (on parle de segmentation en Unicode) est sous-traité à des bibliothèque tierces comme unicode_segmentation.#![allow(unused)] fn main() { let mot = "rate\u{0301}"; let (debut, fin) = mot.split_at(4); println!("{mot} -> {debut} {fin}"); }
Comme les nombreux exemples précédents de ce cours le suggèrent, les litérales
chaînes de caractères sont du texte entre guillemets, comme en C++. Pour les
cas où le texte contient des guillemets, on peut éviter le cancer du \"
en
utilisant des litérales brutes (raw string literals) :
fn main() { // Vous pouvez utilisez autant de # que vous voulez ou presque (<256), // ce qui permet de supporter aussi la séquence "# dans une chaîne. let brut1 = r#"Il a dit "Bonjour !"."#; let brut2 = r##"Elle a répondu r#"Au revoir !"#."##; println!("{brut1}\n{brut2}"); }
Ce qui vous surprendra peut-être un tout petit peu plus, c’est d’apprendre que
les litérales chaînes ne sont pas des valeurs type str
, mais de type
&'static str
: ce qu’on manipule, ce n’est pas directement la chaîne, mais une
référence à une chaîne de caractère stockée quelque part dans le binaire, à la
manière des litérales const char*
de C.
On reviendra là-dessus quand on abordera les références, mais en gros, c’est parce que…
- On n’a pas envie de recopier tout le contenu d’une chaîne (ou d’espérer que l’optimiseur du compilateur parviendra à éliminer la copie) à chaque fois qu’on passe une litérale chaîne en paramètre d’une fonction.
- Il n’est pas aussi évident qu’on pourrait
l’imaginer
de supporter l’allocation de données de taille variable directement sur la
pile. Ce type d’allocation n’est donc pour l’instant pas supporté en Rust.
Or le type
str
est, par nature, de taille variable.
Support ASCII
Même si l’Unicode devrait être utilisé dans toutes les interactions avec les utilisateurs, Rust reconnaît l’importance historique de l’encodage ASCII et les bénéfices de performance qu’il peut y avoir à traiter un flux de données ASCII comme tel plutôt que comme un cas particulier d’UTF-8.
On retrouve donc, à différents endroits du langage Rust et de sa bibliothèque standard, quelques outils qui simplifient la manipulation de données ASCII. Par exemple…
- Des conversions faillibles entre le type
char
et le sous-ensemble deu8
utilisé par l’ASCII. - Des litérales ASCII telles que
b'\n'
etb"Je suis ASCII"
, qui permettent de déclarer des octets ou séquences d’octets correspondant à du texte en ASCII. - Des méthodes de
&str
qui permettent de détecter et gérer de façon optimisée le cas particulier où une chaîne UTF-8 est entièrement composée de caractères ASCII. - Des méthodes des slices d’octets qui permettent de gérer de façon optimisée le cas particulier où les octets appartiennent tous à l’encodage ASCII.
Ces fonctionnalités ne sont pas aussi développées ni ergonomiques que la gestion de texte standard basées sur l’Unicode, mais elles suffisent pour ne pas être complètement démuni dans les cas où on sait avoir affaire à de l’ASCII et veut profiter de ce cas particulier pour optimiser le traitement.
Conversions depuis et vers le texte
Tous les types primitifs de Rust implémentent les traits
ToString
et
FromStr
, qui
permettent respectivement de les convertir en format textuel sans pertes
d’information, et de décoder du texte de format standardisé (en gros, toutes les
litérales Rust) en gérant les erreurs.
Ces opérations utilisent des notions que nous n’avons pas encore abordées (traits, gestion des erreurs), donc pour l’instant je vous demande juste de retenir qu’elles existent, et vous pourrez y revenir quand vous aurez lu les sections adéquates de ce cours.