Stocker du texte encodé en UTF-8 avec les Strings

Nous avons déjà parlé des chaînes de caractères dans le chapitre 4, mais nous allons à présent les analyser plus en détail. Les nouveaux Rustacés bloquent souvent avec les chaînes de caractères pour trois raisons : la tendance de Rust à prévenir les erreurs, le fait que les chaînes de caractères sont des structures de données plus compliquées que ne le pensent la plupart des développeurs, et l'UTF-8. Ces raisons cumulées rendent les choses compliquées lorsque vous venez d'un autre langage de programmation.

Nous avons présenté les chaînes de caractères comme des collections car les chaînes de caractères sont en réalité des suites d'octets, avec quelques méthodes supplémentaires qui sont utiles lorsque ces octets sont considérés comme du texte. Dans cette section, nous allons voir les points communs entre le fonctionnement des String et celui des autres collections, comme la création, la modification et la lecture. Nous verrons les raisons pour lesquelles les String sont différentes des autres collections, en particulier pourquoi l'indexation d'une String est compliquée à cause des différences entre la façon dont les gens et les ordinateurs interprètent les données d'une String.

Qu'est-ce qu'une chaîne de caractères ?

Nous allons d'abord définir ce que nous entendons par le terme chaîne de caractères. Rust a un seul type de chaînes de caractères dans le noyau du langage, qui est la slice de chaîne de caractères str qui est habituellement utilisée sous sa forme empruntée, &str. Dans le chapitre 4, nous avons abordé les slices de chaînes de caractères, qui sont des références à des données d'une chaîne de caractères encodée en UTF-8 qui sont stockées autre part. Les littéraux de chaînes de caractères, par exemple, sont stockés dans le binaire du programme et sont des slices de chaînes de caractères.

Le type String, qui est fourni par la bibliothèque standard de Rust plutôt que d'être intégré au noyau du langage, est un type de chaîne de caractères encodé en UTF-8 qui peut s'agrandir, être mutable, et être possédé. Lorsque les Rustacés parlent de “chaînes de caractères” en Rust, ils entendent soit le type String, soit le type de slice de chaînes de caractères &str, et non pas un seul de ces types. Bien que cette section traite essentiellement de String, ces deux types sont utilisés massivement dans la bibliothèque standard de Rust, et tous les deux sont encodés en UTF-8.

La bibliothèque standard de Rust apporte aussi un certain nombre d'autres types de chaînes de caractères, comme OsString, OsStr, CString, et CStr. Les crates de bibliothèque peuvent fournir encore plus de solutions pour stocker des chaînes de caractères. Avez-vous remarqué que ces noms finissent tous par String ou Str ? Cela fait référence aux variantes possédées et empruntées, comme les types String et str que nous avons vus précédemment. Ces types de chaînes de caractères peuvent stocker leur texte dans de différents encodages, ou le stocker en mémoire de manière différente, par exemple. Nous n'allons pas traiter de ces autres types de chaînes de caractères dans ce chapitre ; référez-vous à la documentation de leur API pour en savoir plus sur leur utilisation et leur utilité.

Créer une nouvelle String

De nombreuses opérations disponibles avec Vec<T> sont aussi disponibles avec String, en commençant par la fonction new pour créer une String, utilisée dans l'encart 8-11.

fn main() {
    let mut s = String::new();
}

Encart 8-11 : Création d'une nouvelle String vide

Cette ligne crée une nouvelle String vide qui s'appelle s, dans laquelle nous pouvons ensuite charger des données. Souvent, nous aurons quelques données initiales que nous voudrions ajouter dans la String. Pour cela, nous utilisons la méthode to_string, qui est disponible sur tous les types qui implémentent le trait Display, comme le font les littéraux de chaînes de caractères. L'encart 8-12 nous montre deux exemples.

fn main() {
    let donnee = "contenu initial";

    let s = donnee.to_string();

    // cette méthode fonctionne aussi directement sur un
    // littéral de chaîne de caractères :
    let s = "contenu initial".to_string();
}

Encart 8-12 : Utilisation de la méthode to_string pour créer une String à partir d'un littéral de chaîne

Ce code crée une String qui contient contenu initial.

Nous pouvons aussi utiliser la fonction String::from pour créer une String à partir d'un littéral de chaîne. Le code dans l'encart 8-13 est équivalent au code dans l'encart 8-12 qui utilisait to_string.

fn main() {
    let s = String::from("contenu initial");
}

Encart 8-13 : Utilisation de la fonction String::from afin de créer une String à partir d'un littéral de chaîne

Comme les chaînes de caractères sont utilisées pour de nombreuses choses, nous pouvons utiliser beaucoup d'API génériques pour les chaînes de caractères. Certaines d'entre elles peuvent paraître redondantes, mais elles ont toutes leur place ! Dans notre cas, String::from et to_string font la même chose, donc votre choix est une question de goût et de lisibilité.

Souvenez-vous que les chaînes de caractères sont encodées en UTF-8, donc nous pouvons y intégrer n'importe quelle donnée valide, comme nous le voyons dans l'encart 8-14.

fn main() {
    let bonjour = String::from("السلام عليكم");
    let bonjour = String::from("Dobrý den");
    let bonjour = String::from("Hello");
    let bonjour = String::from("שָׁלוֹם");
    let bonjour = String::from("नमस्ते");
    let bonjour = String::from("こんにちは");
    let bonjour = String::from("안녕하세요");
    let bonjour = String::from("你好");
    let bonjour = String::from("Olá");
    let bonjour = String::from("Здравствуйте");
    let bonjour = String::from("Hola");
}

Encart 8-14 : Stockage de salutations dans différentes langues dans des chaînes de caractères

Toutes ces chaînes sont des valeurs String valides.

Modifier une String

Une String peut s'agrandir et son contenu peut changer, exactement comme le contenu d'un Vec<T>, si on y ajoute des données. De plus, vous pouvez aisément utiliser l'opérateur + ou la macro format! pour concaténer des valeurs String.

Ajouter du texte à une chaîne avec push_str et push

Nous pouvons agrandir une String en utilisant la méthode push_str pour ajouter une slice de chaîne de caractères, comme dans l'encart 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

Encart 8-15 : Ajout d'une slice de chaîne de caractères dans une String en utilisant la méthode push_str

À l'issue de ces deux lignes, s va contenir foobar. La méthode push_str prend une slice de chaîne de caractères car nous ne souhaitons pas forcément prendre possession du paramètre. Par exemple, dans le code de l'encart 8-16, nous voulons pouvoir utiliser s2 après avoir ajouté son contenu dans s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 est {}", s2);
}

Encart 8-16 : Utilisation d'une slice de chaîne de caractères après avoir ajouté son contenu dans une String

Si la méthode push_str prenait possession de s2, à la dernière ligne, nous ne pourrions pas afficher sa valeur. Cependant, ce code fonctionne comme nous l'espérions !

La méthode push prend un seul caractère en paramètre et l'ajoute à la String. L'encart 8-17 ajoute la lettre “l” à une String en utilisant la méthode push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

Encart 8-17 : Ajout d'un unique caractère à la valeur d'une String en utilisant push

Après l'exécution, s contiendra lol.

Concaténation avec l'opérateur + ou la macro format!

Souvent, vous aurez besoin de combiner deux chaînes de caractères existantes. Une façon de faire cela est d'utiliser l'opérateur +, comme dans l'encart 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // notez que s1 a été déplacé ici
                       // et ne pourra plus être utilisé
}

Encart 8-18 : Utilisation de l'opérateur + pour combiner deux valeurs de String

La chaîne de caractères s3 va contenir Hello, world!. La raison pour laquelle s1 n'est plus utilisable après avoir été ajouté, et pour laquelle nous utilisons une référence vers s2, est la signature de la méthode qui est appelée lorsque nous utilisons l'opérateur +. L'opérateur + utilise la méthode add, dont la signature ressemble à ceci :

fn add(self, s: &str) -> String {

Dans la bibliothèque standard, vous pouvez constater que add est défini en utilisant des génériques. Ici, nous avons remplacé par des types concrets à la place des génériques, ce qui se passe lorsque nous utilisons cette méthode avec des valeurs de type String. Nous verrons la généricité au chapitre 10. Cette signature nous donne les éléments dont nous avons besoin pour comprendre les subtilités de l'opérateur +.

Premièrement, s2 a un &, ce qui veut dire que nous ajoutons une référence vers la seconde chaîne de caractères à la première chaîne. C'est à cause du paramètre s de la fonction add : nous pouvons seulement ajouter un &str à une String ; nous ne pouvons pas ajouter deux valeurs de type String ensemble. Mais attendez — le type de &s2 est &String, et non pas &str, comme c'est écrit dans le second paramètre de add. Alors pourquoi est-ce que le code de l'encart 8-18 se compile ?

La raison pour laquelle nous pouvons utiliser &s2 dans l'appel à add est que le compilateur peut extrapoler l'argument &String en un &str. Lorsque nous appelons la méthode add, Rust va utiliser une extrapolation de déréférencement, qui transforme ici &s2 en &s2[..]. Nous verrons plus en détail l'extrapolation de déréférencement au chapitre 15. Comme add ne prend pas possession du paramètre s, s2 sera toujours une String valide après cette opération.

Ensuite, nous pouvons constater que la signature de add prend possession de self, car self n'a pas de &. Cela signifie que s1 dans l'encart 8-18 va être déplacé dans l'appel à add et ne sera plus en vigueur après cela. Donc bien que let s3 = s1 + &s2 semble copier les deux chaînes de caractères pour en créer une nouvelle, cette instruction va en réalité prendre possession de s1, y ajouter une copie du contenu de s2 et nous redonner la possession du résultat. Autrement dit, cela semble faire beaucoup de copies mais en réalité non ; son implémentation est plus efficace que la copie.

Si nous avons besoin de concaténer plusieurs chaînes de caractères, le comportement de l'opérateur + devient difficile à utiliser :

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

Au final, s vaudra tic-tac-toe. Avec tous les caractères + et ", il est difficile de visualiser ce qui se passe. Pour une combinaison de chaînes de caractères plus complexe, nous pouvons utiliser à la place la macro format! :

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{}-{}-{}", s1, s2, s3);
}

Ce code assigne lui aussi à s la valeur tic-tac-toe. La macro format! fonctionne comme println!, mais au lieu d'afficher son résultat à l'écran, elle retourne une String avec son contenu. La version du code qui utilise format! est plus facile à lire, et le code généré par la macro format! utilise des références afin qu'il ne prenne pas possession de ses paramètres.

L'indexation des Strings

Dans de nombreux autres langages de programmation, l'accès individuel aux caractères d'une chaîne de caractères en utilisant leur indice est une opération valide et courante. Cependant, si vous essayez d'accéder à des éléments d'une String en utilisant la syntaxe d'indexation avec Rust, vous allez avoir une erreur. Nous tentons cela dans le code invalide de l'encart 8-19.

fn main() {
    let s1 = String::from("hello");
    let h = s1[0];
}

Encart 8-19 : Tentative d'utilisation de la syntaxe d'indexation avec une String

Ce code va produire l'erreur suivante :

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
 --> src/main.rs:3:13
  |
3 |     let h = s1[0];
  |             ^^^^^ `String` cannot be indexed by `{integer}`
  |
  = help: the trait `Index<{integer}>` is not implemented for `String`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error

L'erreur et la remarque nous expliquent le problème : les String de Rust n'acceptent pas l'utilisation des indices. Mais pourquoi ? Pour répondre à cette question, nous avons besoin de savoir comment Rust enregistre les chaînes de caractères dans la mémoire.

Représentation interne

Une String est une surcouche de Vec<u8>. Revenons sur certains exemples de chaînes de caractères correctement encodées en UTF-8 que nous avions dans l'encart 8-14. Premièrement, celle-ci :

fn main() {
    let bonjour = String::from("السلام عليكم");
    let bonjour = String::from("Dobrý den");
    let bonjour = String::from("Hello");
    let bonjour = String::from("שָׁלוֹם");
    let bonjour = String::from("नमस्ते");
    let bonjour = String::from("こんにちは");
    let bonjour = String::from("안녕하세요");
    let bonjour = String::from("你好");
    let bonjour = String::from("Olá");
    let bonjour = String::from("Здравствуйте");
    let bonjour = String::from("Hola");
}

Dans ce cas-ci, len vaudra 4, ce qui veut dire que le vecteur qui stocke la chaîne “Hola” a une taille de 4 octets. Chacune des lettres prend 1 octet lorsqu'elles sont encodées en UTF-8. Cependant, la ligne suivante peut surprendre. (Notez que cette chaîne de caractères commence avec la lettre majuscule cyrillique Zé, et non pas le chiffre arabe 3.)

fn main() {
    let bonjour = String::from("السلام عليكم");
    let bonjour = String::from("Dobrý den");
    let bonjour = String::from("Hello");
    let bonjour = String::from("שָׁלוֹם");
    let bonjour = String::from("नमस्ते");
    let bonjour = String::from("こんにちは");
    let bonjour = String::from("안녕하세요");
    let bonjour = String::from("你好");
    let bonjour = String::from("Olá");
    let bonjour = String::from("Здравствуйте");
    let bonjour = String::from("Hola");
}

Si on vous demandait la longueur de la chaîne de caractères, vous répondriez probablement 12. En réalité, la réponse de Rust sera 24 : c'est le nombre d'octets nécessaires pour encoder “Здравствуйте” en UTF-8, car chaque valeur scalaire Unicode dans cette chaîne de caractères prend 2 octets en mémoire. Par conséquent, un indice dans les octets de la chaîne de caractères ne correspondra pas forcément à une valeur scalaire Unicode valide. Pour démontrer cela, utilisons ce code Rust invalide :

let bonjour = "Здравствуйте";
let reponse = &bonjour[0];

Vous savez déjà que reponse ne vaudra pas З, la première lettre. Lorsqu'il est encodé en UTF-8, le premier octet de З est 208 et le second est 151, donc on dirait que reponse vaudrait 208, mais 208 n'est pas un caractère valide à lui seul. Retourner 208 n'est pas ce qu'un utilisateur attend s'il demande la première lettre de cette chaîne de caractères ; cependant, c'est la seule valeur que Rust a à l'indice 0 des octets. Les utilisateurs ne souhaitent généralement pas obtenir la valeur d'un octet, même si la chaîne de caractères contient uniquement des lettres latines : si &"hello"[0] était un code valide qui retournait la valeur de l'octet, il retournerait 104 et non pas h.

La solution est donc, pour éviter de retourner une valeur inattendue et générer des bogues qui ne seraient pas découverts immédiatement, que Rust ne va pas compiler ce code et va ainsi éviter des erreurs dès le début du processus de développement.

Des octets, des valeurs scalaires et des groupes de graphèmes !? Oh mon Dieu !

Un autre problème avec l'UTF-8 est qu'il a en fait trois manières pertinentes de considérer les chaînes de caractères avec Rust : comme des octets, comme des valeurs scalaires ou comme des groupes de graphèmes (ce qui se rapproche le plus de ce que nous pourrions appeler des lettres).

Si l'on considère le mot hindi “नमस्ते” écrit en écriture devanagari, il est stocké comme un vecteur de valeurs u8 qui sont les suivantes :

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Cela fait 18 octets et c'est ainsi que les ordinateurs stockeront cette donnée. Si nous les voyons comme des valeurs scalaires Unicode, ce qu'est le type char de Rust, ces octets seront les suivants :

['न', 'म', 'स', '्', 'त', 'े']

Nous avons six valeurs char ici, mais les quatrième et sixième valeurs ne sont pas des lettres : ce sont des signes diacritiques qui n'ont pas de sens employés seuls. Enfin, si nous les voyons comme des groupes de graphèmes, on obtient ce qu'on pourrait appeler les quatre lettres qui constituent le mot hindi :

["न", "म", "स्", "ते"]

Rust fournit différentes manières d'interpréter les données brutes des chaînes de caractères que les ordinateurs stockent afin que chaque programme puisse choisir l'interprétation dont il a besoin, peu importe la langue dans laquelle sont les données.

Une dernière raison pour laquelle Rust ne nous autorise pas à indexer une String pour récupérer un caractère est que les opérations d'indexation sont censées prendre un temps constant (O(1)). Mais il n'est pas possible de garantir cette performance avec une String, car Rust devrait parcourir le contenu depuis le début jusqu'à l'indice pour déterminer combien il y a de caractères valides.

Les slices de chaînes de caractères

L'indexation sur une chaîne de caractères est souvent une mauvaise idée car le type de retour de l'opération n'est pas toujours évident : un octet, un caractère, un groupe de graphèmes ou une slice de chaîne de caractères ? Si vous avez vraiment besoin d'utiliser des indices pour créer des slices de chaînes, Rust vous demande plus de précisions.

Plutôt que d'utiliser [] avec un nombre seul, vous pouvez utiliser [] avec un intervalle d'indices pour créer une slice de chaîne contenant des octets bien précis, plutôt que d'utiliser [] avec un seul nombre :


#![allow(unused)]
fn main() {
let bonjour = "Здравствуйте";

let s = &bonjour[0..4];
}

Ici, s sera un &str qui contiendra les 4 premiers octets de la chaîne de caractères. Précédemment, nous avions mentionné que chacun de ces caractères était encodé sur 2 octets, ce qui veut dire que s vaudra Зд.

Si vous essayons de produire une slice d'une partie des octets d'un caractère avec quelquechose comme &bonjour[0..1], Rust va paniquer au moment de l'exécution de la même façon que si nous utilisions un indice invalide pour accéder à un élément d'un vecteur :

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Vous devriez utiliser les intervalles pour créer des slices avec prudence, car cela peut provoquer un plantage de votre programme.

Les méthodes pour parcourir les chaînes de caractères

La meilleure manière de travailler sur des parties de chaînes de caractères est d'exprimer clairement si vous voulez travailler avec des caractères ou des octets. Pour les valeurs scalaires Unicode une par une, utilisez la méthode chars. Appeler chars sur “नमस्ते” sépare et retourne six valeurs de type char, et vous pouvez itérer sur le résultat pour accéder à chaque élément :


#![allow(unused)]
fn main() {
for c in "नमस्ते".chars() {
    println!("{}", c);
}
}

Ce code va afficher ceci :

न
म
स
्
त
े

Aussi, la méthode bytes va retourner chaque octet brut, ce qui sera peut-être plus utile selon ce que vous voulez faire :


#![allow(unused)]
fn main() {
for b in "नमस्ते".bytes() {
    println!("{}", b);
}
}

Ce code va afficher les 18 octets qui constituent cette String :

224
164
// -- éléments masqués ici --
165
135

Rappelez-vous bien que des valeurs scalaires Unicode peuvent être constituées de plus d'un octet.

L'obtention des groupes de graphèmes à partir de chaînes de caractères est complexe, donc cette fonctionnalité n'est pas fournie par la bibliothèque standard. Des crates sont disponibles sur crates.io si c'est la fonctionnalité dont vous avez besoin.

Les chaînes de caractères ne sont pas si simples

Pour résumer, les chaînes de caractères sont complexes. Les différents langages de programmation ont fait différents choix sur la façon de présenter cette complexité aux développeurs. Rust a choisi d'appliquer par défaut la gestion rigoureuse des données de String pour tous les programmes Rust, ce qui veut dire que les développeurs doivent réfléchir davantage à la gestion des données UTF-8. Ce compromis révèle davantage la complexité des chaînes de caractères par rapport à ce que les autres langages de programmation laissent paraître, mais vous évite d'avoir à gérer plus tard dans votre cycle de développement des erreurs à cause de caractères non ASCII.

Passons maintenant à quelque chose de moins complexe : les tables de hachage !