
Types de données composés essentiels
Explorez les types de données composés en Rust : chaînes de caractères (String, &str), tuples et tableaux. Apprenez à structurer vos données efficacement.
Au-delà des scalaires : structurer l'information avec les types composés
Après avoir exploré les types de données scalaires qui représentent des valeurs uniques, nous allons maintenant nous pencher sur les types de données composés. Ces types permettent de regrouper plusieurs valeurs en une seule entité, offrant ainsi des moyens plus sophistiqués de structurer et de gérer les données dans vos programmes Rust. Ce chapitre se concentre sur trois types composés fondamentaux et omniprésents : les chaînes de caractères, les tuples et les tableaux.
La manipulation de texte est une tâche courante en programmation, et Rust propose une gestion robuste et nuancée des chaînes de caractères. Nous distinguerons les deux principaux types de chaînes, `String` et `&str`, et expliquerons leurs différences en termes de possession, de mutabilité et d'allocation mémoire. Comprendre cette distinction est crucial pour écrire du code Rust idiomatique et performant lorsqu'on travaille avec du texte.
Ensuite, nous découvrirons les tuples, un moyen simple et efficace de grouper un nombre fixe de valeurs de types potentiellement différents. Enfin, nous aborderons les tableaux (arrays), qui permettent de stocker une collection d'éléments de même type et de taille fixe. Ces types composés sont les premières briques pour construire des structures de données plus complexes et pour organiser logiquement les informations que vos programmes manipulent.
Les chaînes de caractères : `String` et `&str` (introduction)
La gestion des chaînes de caractères est un aspect fondamental de nombreux programmes. Rust aborde ce sujet avec une attention particulière à la sécurité et à la performance, ce qui se traduit par l'existence de deux types principaux de chaînes : `String` et `&str` (prononcé "string slice" ou "str slice").
`String` est un type de chaîne de caractères possédé, alloué sur le tas, et qui peut être modifié. Il est garanti d'être encodé en UTF-8. Comme `String` gère ses propres données, il est responsable de leur allocation et de leur libération. Vous utiliserez `String` lorsque vous avez besoin de créer ou de modifier des données textuelles dont la taille peut changer au cours de l'exécution.
Exemple de création et d'utilisation de `String` :
fn main() {
let mut s1 = String::new(); // Crée une String vide
s1.push_str("Bonjour"); // Ajoute une tranche de chaîne à s1
println!("s1 : {}", s1);
let s2 = String::from(" le monde !"); // Crée une String à partir d'un littéral de chaîne
let s3 = s1 + &s2; // L'opérateur + concatène des Strings (s1 est déplacée, s2 est empruntée)
// La méthode `add` prend possession de `self` et une tranche de chaîne `&str`.
// `s2` (de type String) est déréférencée en `&str` (coercition de `&String` en `&str`).
println!("s3 : {}", s3);
let hello = "Hello".to_string(); // Une autre façon de créer une String à partir d'un littéral
println!("hello (String) : {}", hello);
}`&str` est une "tranche de chaîne" (string slice). C'est une référence immuable à une séquence d'octets encodés en UTF-8, qui peut se trouver n'importe où en mémoire : dans les données statiques du programme (pour les littéraux de chaîne), sur le tas (en tant que partie d'une `String`), ou sur la pile. Les littéraux de chaîne (par exemple, `"texte"`) sont de type `&'static str`, ce qui signifie qu'ils sont des tranches de chaînes qui vivent pendant toute la durée d'exécution du programme.
Exemple avec `&str` :
fn main() {
let literal_str = "Ceci est un littéral de chaîne."; // Type &str (plus précisément &'static str)
println!("Littéral : {}", literal_str);
let my_string = String::from("Une chaîne possédée");
let slice_of_string: &str = &my_string[0..3]; // Prend une tranche de la String (les 3 premiers caractères)
// Attention: le découpage doit se faire sur les frontières de caractères UTF-8 valides.
println!("Tranche : {}", slice_of_string); // Affiche "Une"
// Les fonctions prennent souvent des &str pour plus de flexibilité
fn print_message(message: &str) {
println!("Message : {}", message);
}
print_message("Un message direct.");
print_message(&my_string); // On peut passer une référence à une String là où un &str est attendu.
}La distinction entre `String` (possédé, mutable, sur le tas) et `&str` (emprunté, immuable, vue sur des données) est centrale en Rust. Les fonctions devraient généralement accepter des `&str` en paramètre si elles n'ont besoin que de lire la chaîne, car cela les rend plus flexibles (elles peuvent accepter des `String` via une référence ou des littéraux de chaîne directement). Nous reviendrons plus en détail sur les chaînes et leurs opérations dans des chapitres ultérieurs.
Les tuples : regrouper des valeurs de types différents
Un tuple est un type composé qui permet de regrouper un nombre fixe de valeurs de types potentiellement différents en une seule entité. Les tuples sont une manière simple et légère de combiner plusieurs éléments. La taille d'un tuple est connue à la compilation et ne peut pas changer une fois qu'il est créé.
On crée un tuple en écrivant une liste de valeurs séparées par des virgules, entre parenthèses. Chaque position dans le tuple a un type, et les types des différentes valeurs dans le tuple n'ont pas besoin d'être les mêmes.
fn main() {
let tup1: (i32, f64, u8) = (500, 6.4, 1);
let tup2 = ("Rust", true, 42i16);
// Accès aux éléments d'un tuple par déstructuration
let (x, y, z) = tup1;
println!("Les valeurs de tup1 sont : x={}, y={}, z={}", x, y, z);
// Accès direct aux éléments par indexation (commence à 0)
let premier_element_tup1 = tup1.0;
let deuxieme_element_tup2 = tup2.1;
println!("Premier élément de tup1 : {}", premier_element_tup1);
println!("Deuxième élément de tup2 : {}", deuxieme_element_tup2);
}La déstructuration est un moyen courant d'extraire les valeurs d'un tuple dans des variables distinctes. On peut aussi accéder à un élément individuel d'un tuple en utilisant un point (`.`) suivi de l'index de l'élément (commençant à 0). Par exemple, `tup1.0` accède au premier élément de `tup1`.
Les tuples sont particulièrement utiles lorsque vous voulez qu'une fonction retourne plusieurs valeurs. Au lieu de définir une structure dédiée (que nous verrons plus tard), un tuple peut suffire pour des cas simples. Par exemple, une fonction calculant les coordonnées pourrait retourner un `(i32, i32)`.
Un tuple sans aucune valeur, `()`, est un type spécial appelé "unité" (unit type). Sa valeur est aussi `()`. Les expressions qui ne retournent pas explicitement de valeur (comme la fonction `main` ou une fonction qui effectue une action sans retourner de résultat) retournent implicitement le type unité.
fn do_nothing() {
// Cette fonction retourne implicitement ()
}
fn main() {
let result = do_nothing();
// `result` est de type () et sa valeur est ()
// println!("{:?}", result); // Afficherait "()"
}Les tuples offrent une flexibilité pour regrouper des données hétérogènes de manière concise, surtout quand la structure des données est simple et que nommer les champs n'apporterait pas beaucoup de clarté supplémentaire.
Les tableaux (arrays) : collections de taille fixe et de même type
Un autre type composé essentiel est le tableau (array). Contrairement aux tuples, tous les éléments d'un tableau doivent avoir le même type. De plus, les tableaux en Rust ont une taille fixe, qui est connue au moment de la compilation et ne peut pas être modifiée par la suite.
On écrit les types de tableau en utilisant des crochets : `[type; taille]`. Par exemple, `[i32; 5]` est un tableau de cinq entiers 32 bits. On peut initialiser un tableau avec une liste de valeurs entre crochets, séparées par des virgules :
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
let b = [3.0, 1.5, 4.2]; // Rust infère le type [f64; 3]
// Initialiser un tableau avec la même valeur pour chaque élément
let c = [3; 10]; // Crée un tableau de 10 éléments, tous initialisés à 3. Equivalent à [3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
println!("Tableau a (premier élément) : {}", a[0]);
println!("Tableau b (deuxième élément) : {}", b[1]);
println!("Tableau c (longueur) : {}", c.len());
}Vous accédez aux éléments d'un tableau en utilisant l'indexation avec des crochets, comme dans de nombreux autres langages. Les indices commencent à 0. Par exemple, `a[0]` est le premier élément du tableau `a`.
Rust est très strict concernant l'accès aux éléments d'un tableau. Si vous essayez d'accéder à un élément en utilisant un index qui est en dehors des limites du tableau (par exemple, `a[10]` pour un tableau de 5 éléments), Rust vérifiera cela au moment de l'exécution. Si l'index est invalide, le programme entrera en `panic` (il s'arrêtera avec un message d'erreur). C'est une mesure de sécurité importante qui prévient les accès mémoire non valides, une source courante de bugs et de vulnérabilités dans d'autres langages.
Exemple d'accès invalide (qui paniquerait à l'exécution) :
fn main() {
let arr = [10, 20, 30];
// let element = arr[5]; // Cela provoquerait un panic: index out of bounds
// println!("Elément : {}", element);
// Accès sécurisé en vérifiant la longueur
let index = 1;
if index < arr.len() {
println!("Elément à l'index {} : {}", index, arr[index]);
} else {
println!("Index {} hors limites pour un tableau de longueur {}.", index, arr.len());
}
}Les tableaux sont utiles lorsque vous savez que le nombre d'éléments ne changera pas, comme les jours de la semaine ou les mois de l'année. Les données d'un tableau sont allouées sur la pile plutôt que sur le tas (sauf si le tableau contient des éléments qui sont eux-mêmes alloués sur le tas). Cela peut être plus performant que d'utiliser des collections de taille dynamique comme les vecteurs (que nous verrons plus tard) si la taille fixe est appropriée. Pour des collections dont la taille doit pouvoir changer, le type `Vec<T>` (vecteur) est généralement plus adapté.