Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Nous avons parlé des strings au chapitre 4, mais nous allons maintenant les examiner plus en profondeur. Les nouveaux Rustacés se retrouvent souvent bloqués sur les strings pour une combinaison de trois raisons : la propension de Rust à exposer les erreurs possibles, les strings étant une structure de données plus compliquée que ce que de nombreux développeurs imaginent, et l’UTF-8. Ces facteurs se combinent d’une manière qui peut sembler difficile lorsqu’on vient d’autres langages de programmation.

Nous abordons les strings dans le contexte des collections car les strings sont implémentées comme une collection d’octets, avec en plus quelques méthodes pour fournir des fonctionnalités utiles lorsque ces octets sont interprétés comme du texte. Dans cette section, nous parlerons des opérations sur String que possède chaque type de collection, comme la création, la mise à jour et la lecture. Nous discuterons également des manières dont String diffère des autres collections, à savoir comment l’indexation dans une String est compliquée par les différences entre la façon dont les humains et les ordinateurs interprètent les données d’une String.

Définir les strings

Nous allons d’abord définir ce que nous entendons par le terme string. Rust n’a qu’un seul type de string dans le langage de base, qui est la slice de chaîne str que l’on voit généralement sous sa forme empruntée, &str. Au chapitre 4, nous avons parlé des slices de chaîne, qui sont des références à des données de chaîne encodées en UTF-8 stockées ailleurs. Les littéraux de chaîne, par exemple, sont stockés dans le binaire du programme et sont donc des slices de chaîne.

Le type String, qui est fourni par la bibliothèque standard de Rust plutôt que codé dans le langage de base, est un type de chaîne extensible, mutable, possédé et encodé en UTF-8. Quand les Rustacés font référence aux “strings” en Rust, ils peuvent désigner soit le type String soit le type slice de chaîne &str, et pas seulement l’un de ces types. Bien que cette section porte principalement sur String, les deux types sont intensivement utilisés dans la bibliothèque standard de Rust, et tant String que les slices de chaîne sont encodés en UTF-8.

Créer une nouvelle String

Beaucoup des mêmes opérations disponibles avec Vec<T> sont aussi disponibles avec String car String est en fait implémenté comme un wrapper autour d’un vector d’octets avec quelques garanties, restrictions et capacités supplémentaires. Un exemple de fonction qui fonctionne de la même manière avec Vec<T> et String est la fonction new pour créer une instance, montrée dans l’encart 8-11.

fn main() {
    let mut s = String::new();
}
Listing 8-11: Creating a new, empty String

Cette ligne crée une nouvelle chaîne vide appelée s, dans laquelle nous pouvons ensuite charger des données. Souvent, nous aurons des données initiales avec lesquelles nous voudrons démarrer la chaîne. Pour cela, nous utilisons la méthode to_string, qui est disponible sur tout type implémentant le trait Display, comme c’est le cas des littéraux de chaîne. L’encart 8-12 montre deux exemples.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
Listing 8-12: Using the to_string method to create a String from a string literal

Ce code crée une chaîne contenant initial contents.

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

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: Using the String::from function to create a String from a string literal

Parce que les strings sont utilisées pour tellement de choses, nous pouvons utiliser de nombreuses API génériques différentes pour les strings, ce qui nous offre beaucoup d’options. Certaines d’entre elles peuvent sembler redondantes, mais elles ont toutes leur utilité ! Dans ce cas, String::from et to_string font la même chose, donc le choix entre les deux est une question de style et de lisibilité.

N’oubliez pas que les strings sont encodées en UTF-8, donc nous pouvons y inclure toute donnée correctement encodée, comme montré dans l’encart 8-14.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Listing 8-14: Storing greetings in different languages in strings

Toutes ces valeurs sont des String valides.

Mettre à jour une String

Une String peut grandir en taille et son contenu peut changer, tout comme le contenu d’un Vec<T>, si vous y ajoutez plus de données. De plus, vous pouvez utiliser l’opérateur + ou la macro format! pour concaténer des valeurs String.

Ajouter du contenu avec push_str ou push

Nous pouvons faire grandir une String en utilisant la méthode push_str pour ajouter une slice de chaîne, comme montré dans l’encart 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: Appending a string slice to a String using the push_str method

Après ces deux lignes, s contiendra foobar. La méthode push_str prend une slice de chaîne car nous ne voulons pas nécessairement 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 à s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Listing 8-16: Using a string slice after appending its contents to a String

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

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

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listing 8-17: Adding one character to a String value using push

En conséquence, s contiendra lol.

Concaténer avec + ou format!

Souvent, vous voudrez combiner deux chaînes existantes. Une façon de le faire est d’utiliser l’opérateur +, comme montré dans l’encart 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Listing 8-18: Using the + operator to combine two String values into a new String value

La chaîne s3 contiendra Hello, world!. La raison pour laquelle s1 n’est plus valide après l’addition, et la raison pour laquelle nous avons utilisé une référence à s2, est liée à 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 verrez add définie à l’aide de la généricité et de types associés. Ici, nous avons substitué des types concrets, ce qui est ce qui se passe lorsque nous appelons cette méthode avec des valeurs String. Nous discuterons de la généricité au chapitre 10. Cette signature nous donne les indices nécessaires pour comprendre les subtilités de l’opérateur +.

Premièrement, s2 à un &, ce qui signifie que nous ajoutons une référence de la deuxième chaîne à la première chaîne. C’est à cause du paramètre s dans la fonction add : nous ne pouvons ajouter qu’une slice de chaîne à une String ; nous ne pouvons pas additionner deux valeurs String. Mais attendez – le type de &s2 est &String, et non &str, comme spécifié dans le second paramètre de add. Alors, pourquoi l’encart 8-18 compile-t-il ?

La raison pour laquelle nous pouvons utiliser &s2 dans l’appel à add est que le compilateur peut contraindre l’argument &String en &str. Lorsque nous appelons la méthode add, Rust utilise une coercition de déréférencement (deref coercion), qui transformé ici &s2 en &s2[..]. Nous discuterons de la coercition de déréférencement plus en profondeur au chapitre 15. Comme add ne prend pas possession du paramètre s, s2 sera toujours une String valide après cette opération.

Deuxièmement, nous pouvons voir dans la signature que add prend possession de self car self n’a pas de &. Cela signifie que s1 dans l’encart 8-18 sera déplacé dans l’appel à add et ne sera plus valide après. Donc, bien que let s3 = s1 + &s2; semble copier les deux chaînes et en créer une nouvelle, cette instruction prend en fait possession de s1, ajouté une copie du contenu de s2, puis renvoie la possession du résultat. En d’autres termes, cela semble faire beaucoup de copies, mais ce n’est pas le cas ; l’implémentation est plus efficace qu’une copie.

Si nous devons concaténer plusieurs chaînes, le comportement de l’opérateur + devient lourd à manier : rust {{#rustdoc_include ../listings/ch08-common-collections/no-listing-01-concat-multiple-strings/src/main.rs:here}}

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

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

À ce stade, s vaudra tic-tac-toe. Avec tous les caractères + et ", il est difficile de voir ce qui se passe. Pour combiner des chaînes de manière plus complexe, nous pouvons utiliser la macro format! à la place : rust {{#rustdoc_include ../listings/ch08-common-collections/no-listing-02-format/src/main.rs:here}}

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 également tic-tac-toe à s. La macro format! fonctionne comme println!, mais au lieu d’afficher la sortie à l’écran, elle renvoie une String avec le contenu. La version du code utilisant format! est beaucoup plus facile à lire, et le code généré par la macro format! utilise des références pour que cet appel ne prenne possession d’aucun de ses paramètres.

Indexer dans les strings

Dans de nombreux autres langages de programmation, accéder aux caractères individuels d’une chaîne en les référençant par indice est une opération valide et courante. Cependant, si vous essayez d’accéder à des parties d’une String en utilisant la syntaxe d’indexation en Rust, vous obtiendrez une erreur. Considérez le code invalide de l’encart 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Listing 8-19: Attempting to use indexing syntax with a String

Ce code produira l’erreur suivante : console {{#include ../listings/ch08-common-collections/listing-08-19/output.txt}}

$ 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 dit tout : les strings en Rust ne supportent pas l’indexation. Mais pourquoi ? Pour répondre à cette question, nous devons aborder la façon dont Rust stocké les strings en mémoire.

Représentation interne

Une String est un wrapper autour d’un Vec<u8>. Examinons certaines de nos chaînes d’exemple correctement encodées en UTF-8 de l’encart 8-14. D’abord, celle-ci : rust {{#rustdoc_include ../listings/ch08-common-collections/listing-08-14/src/main.rs:spanish}}

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

Dans ce cas, len vaudra 4, ce qui signifie que le vector stockant la chaîne "Hola" fait 4 octets de long. Chacune de ces lettres occupe 1 octet lorsqu’elle est encodée en UTF-8. La ligne suivante, cependant, pourrait vous surprendre (notez que cette chaîne commence par la lettre cyrillique majuscule Ze, et non le chiffre 3) : rust {{#rustdoc_include ../listings/ch08-common-collections/listing-08-14/src/main.rs:russian}}

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

Si on vous demandait la longueur de la chaîne, vous pourriez dire 12. En fait, la réponse de Rust est 24 : c’est le nombre d’octets nécessaires pour encoder “Здравствуйте” en UTF-8, car chaque valeur scalaire Unicode dans cette chaîne occupe 2 octets de stockage. Par conséquent, un indice dans les octets de la chaîne ne correspondra pas toujours à une valeur scalaire Unicode valide. Pour illustrer cela, considérez ce code Rust invalide :

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

Vous savez déjà que answer ne sera pas З, la première lettre. Encodé en UTF-8, le premier octet de З est 208 et le second est 151, donc il semblerait que answer devrait en fait être 208, mais 208 n’est pas un caractère valide en soi. Renvoyer 208 n’est probablement pas ce qu’un utilisateur souhaiterait s’il demandait la première lettre de cette chaîne ; cependant, c’est la seule donnée que Rust a à l’indice d’octet 0. Les utilisateurs ne veulent généralement pas que la valeur de l’octet soit renvoyée, même si la chaîne ne contient que des lettres latines : si &"hi"[0] était du code valide renvoyant la valeur de l’octet, il renverrait 104, et non h.

La réponse est donc que, pour éviter de renvoyer une valeur inattendue et de causer des bogues qui pourraient ne pas être découverts immédiatement, Rust ne compilé tout simplement pas ce code et prévient les malentendus tôt dans le processus de développement.

Octets, valeurs scalaires et groupes de graphèmes

Un autre point concernant l’UTF-8 est qu’il y a en fait trois façons pertinentes de regarder les strings du point de vue de Rust : sous forme d’octets, de valeurs scalaires et de groupes de graphèmes (ce qui se rapproche le plus de ce que nous appellerions des lettres).

Si nous regardons le mot hindi “नमस्ते” écrit en alphabet devanagari, il est stocké comme un vector de valeurs u8 qui ressemble à ceci :

[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 stockent finalement ces données. Si nous les regardons comme des valeurs scalaires Unicode, ce que représente le type char de Rust, ces octets ressemblent à ceci :

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

Il y a six valeurs char ici, mais la quatrième et la sixième ne sont pas des lettres : ce sont des signes diacritiques qui n’ont pas de sens isolément. Enfin, si nous les regardons comme des groupes de graphèmes, nous obtiendrions ce qu’une personne appellerait les quatre lettres qui composent le mot hindi :

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

Rust fournit différentes façons d’interpréter les données brutes de chaîne que les ordinateurs stockent afin que chaque programme puisse choisir l’interprétation dont il a besoin, quelle que soit la langue humaine dans laquelle se trouvent les données.

Une dernière raison pour laquelle Rust ne nous permet pas d’indexer dans une String pour obtenir un caractère est que les opérations d’indexation sont censées toujours s’exécuter en 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 de caractères valides il y a.

Découper les strings en slices

Indexer dans une chaîne est souvent une mauvaise idée car il n’est pas clair quel devrait être le type de retour de l’opération d’indexation de la chaîne : une valeur d’octet, un caractère, un groupe de graphèmes ou une slice de chaîne. Si vous avez vraiment besoin d’utiliser des indices pour créer des slices de chaîne, Rust vous demande donc d’être plus précis.

Au lieu d’indexer en utilisant [] avec un seul nombre, vous pouvez utiliser [] avec un intervalle pour créer une slice de chaîne contenant des octets particuliers :

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

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

Ici, s sera un &str contenant les 4 premiers octets de la chaîne. Plus tôt, nous avons mentionné que chacun de ces caractères faisait 2 octets, ce qui signifie que s sera Зд.

Si nous essayions de découper seulement une partie des octets d’un caractère avec quelque chose comme &hello[0..1], Rust paniquerait à l’exécution de la même manière que si un indice invalide était accédé dans un vector : console {{#include ../listings/ch08-common-collections/output-only-01-not-char-boundary/output.txt}}

$ 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 preuve de prudence lorsque vous créez des slices de chaîne avec des intervalles, car cela peut faire planter votre programme.

Itérer sur les strings

La meilleure façon d’opérer sur des morceaux de strings est d’être explicite sur le fait que vous vouliez des caractères ou des octets. Pour les valeurs scalaires Unicode individuelles, utilisez la méthode chars. Appeler chars sur “Зд” sépare et renvoie 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 affichera ce qui suit :

З
д

Alternativement, la méthode bytes renvoie chaque octet brut, ce qui pourrait être approprié pour votre domaine :

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Ce code affichera les 4 octets qui composent cette chaîne :

208
151
208
180

Mais n’oubliez pas que les valeurs scalaires Unicode valides peuvent être composées de plus d’un octet.

Obtenir les groupes de graphèmes à partir de strings, comme avec l’alphabet devanagari, est complexe, c’est pourquoi 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 strings

En résumé, les strings sont compliquées. Les différents langages de programmation font des choix différents quant à la manière de présenter cette complexité au développeur. Rust a choisi de faire du traitement correct des données String le comportement par défaut pour tous les programmes Rust, ce qui signifie que les développeurs doivent réfléchir davantage à la gestion des données UTF-8 dès le départ. Ce compromis expose davantage la complexité des strings que ce qui est apparent dans d’autres langages de programmation, mais cela vous évite d’avoir à gérer des erreurs impliquant des caractères non-ASCII plus tard dans votre cycle de développement.

La bonne nouvelle est que la bibliothèque standard offre beaucoup de fonctionnalités construites sur les types String et &str pour aider à gérer correctement ces situations complexes. N’hésitez pas à consulter la documentation pour des méthodes utiles comme contains pour chercher dans une chaîne et replace pour substituer des parties d’une chaîne par une autre chaîne.

Passons à quelque chose d’un peu moins complexe : les hash maps !