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(); }
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(); }
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"); }
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"); }
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"); }
À 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); }
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'); }
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é }
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];
}
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 !