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.
Définition des chaînes 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.
Créer une nouvelle String
De nombreuses opérations disponibles avec Vec<T> sont aussi disponibles avec String car String est en fait implémenté comme un conteneur autour d’un vecteur d’octets avec quelques garanties, restrictions et fonctionnalités supplémentaires. Un exemple d’une fonction qui se comporte de la même manière avec Vec<T> et String est la fonction new function qui permet de créer une instance, comme illustré dans l’encart 8-11.
fn main() {
let mut s = String::new();
}
StringCette 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();
}
to_string method to create a String from a string literalCe code crée une chaîne de caractères 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");
}
String::from function to create a String from a string literalComme 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");
}
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");
}
String using the push_str methodÀ 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}");
}
StringSi 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');
}
String value using pushAprès l’exécution, s contiendra lol.
Concaténation avec + ou 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é
}
+ operator to combine two String values into a new String valueLa 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 et des types associés. Ici, nous avons remplacé ces types génériques par des types concrets, ce qui se produit lorsque nous appelons cette méthode avec des valeurs de type String. Nous aborderons 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 une slice de chaîne de caractères à 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 des combinaisons plus complexes de chaînes de caractères, 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("hi");
let h = s1[0];
}
StringCe code va produire l’erreur suivante :
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the following other types implement trait `SliceIndex<T>`:
`usize` implements `SliceIndex<ByteStr>`
`usize` implements `SliceIndex<[T]>`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 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 &"hi"[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.
Octets, valeurs scalaires et groupes de graphèmes
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` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Vous devriez faire attention quand vous créez des slices de chaînes de caractères avec des intervalles, car cela peut provoquer un plantage de votre programme.
Parcours de 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 deux 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 4 octets qui constituent cette String :
208
151
208
180
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, comme dans l’écriture Devanagari, 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.
Gérer la complexité des chaînes de caractères
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.
La bonne nouvelle, c’est que la bibliothèque standard propose de nombreuses fonctionnalités reposant sur les chaînes String et &str pour vous aider à gérer correctement ces situations complexes. Assurez-vous de consulter la documentation pour découvrir des méthodes utiles telles que contains, qui permet d’effectuer une recherche dans une chaîne, et replace, qui permet de remplacer des parties d’une chaîne par une autre chaîne.
Passons maintenant à quelque chose de moins complexe : les tables de hachage !