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 :
    #![allow(unused)]
    fn main() {
    let normalisation1 = "é";
    let normalisation2 = "e\u{0301}";
    let comparaison = normalisation1 == normalisation2;
    println!("{normalisation1} == {normalisation2} -> {comparaison}");
    }
    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.
  • 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 :
    #![allow(unused)]
    fn main() {
    let mot = "rate\u{0301}";
    let (debut, fin) = mot.split_at(4);
    println!("{mot} -> {debut} {fin}");
    }
    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.

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…

  1. 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.
  2. 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 de u8 utilisé par l’ASCII.
  • Des litérales ASCII telles que b'\n' et b"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.