Introduction
Ce livre est un travail personnel.
J’ai choisi de le publier et de le partager pour « faire ma part ».
La consultation de ce livre est « en don libre ».
Oui, vous pouvez
Si vous souhaitez m’encourager ou m’aider, vous pouvez.
- effectuer un don.
- Il vous suffit de me contacter, je vous communiquerai mes coordonnées bancaires.
- me proposer un travail rémunéré salarié
- sédentaire
- en télétravail
- ou dans un bureau
- si ce n’est pas dans une grande agglomération
- si c’est au Nord de la France (Bretagne, Normandie, Nord)
- ou plus au Nord (l’Irlande me plairait tout particulièrement)
- que vous soyez :
- une entreprise ;
- une administration ;
- une association ou
- un·e particulier·e.
Mes sites concernant Rust
Bonne lecture
Si vous rencontriez des erreurs, si vous souhaitiez suggérer des ajouts, ou pour toute autre démarche constructive et pertinente, n’hésitez pas à me contacter.
Merci.
Marc JESTIN,
éco-citoyen du monde
https://marcjestin.fr
Avertissement
Ce livre mdbook est à l’étape du projet de création et dans son format brouillon (draft) pour le moment.
Il peut comporter des défauts ou des erreurs et sa forme comme son contenu évoluent continuellement.
Merci de votre compréhension.
Si vous souhaitez m’apporter votre soutien ou votre concours pour ce projet, vous pouvez me contacter.
Marc JESTIN
éco-citoyen du monde
https://marcjestin.fr
Variables et Mutabilité
En Rust, la sécurité et la gestion de la mémoire guident chaque choix de conception.
Contrairement à la majorité des langages, l’immutabilité est le choix par défaut.
L’Immutabilité par défaut
Lorsque vous déclarez une variable avec le mot-clé let, sa valeur est verrouillée. Toute tentative de modification ultérieure provoque une erreur de compilation.
fn main() {
let x = 5;
println!("La valeur de x est : {x}");
// COMPILER ERROR: cannot assign twice to immutable variable `x`
// x = 6;
}
Ce comportement garantit qu’une donnée ne changera pas de manière imprévisible au cours de l’exécution, éliminant ainsi toute une classe de bugs liés aux effets de bord.
Rendre une variable mutable
Pour autoriser explicitement la modification d’une variable, il faut utiliser le mot-clé mut. Cela documente clairement l’intention du code pour le développeur et pour le compilateur.
fn main() {
let mut x = 5;
println!("Valeur initiale : {x}");
x = 6; // Totalement valide
println!("Nouvelle valeur : {x}");
}
Le Masquage (Shadowing)
Le shadowing consiste à déclarer une nouvelle variable avec le même nom qu’une variable existante. La seconde variable masque (« remplace ») la première.
fn main() {
let spaces = " "; // Type: &str (chaîne de caractères)
// On transforme la donnée sans créer un nouveau nom (ex: spaces_len)
let spaces = spaces.len(); // Type: usize (entier)
println!("Nombre d'espaces : {spaces}"); // Affiche « Nombre d'espaces : 3 »
}
Pourquoi utiliser le shadowing ?
- Transformation de type : Idéal pour réutiliser un nom de variable logique après avoir transformé ou nettoyé une donnée (par exemple, parser une chaîne de caractères en entier).
- Préservation de l’immutabilité : Contrairement à
mut, chaque étape du shadowing crée une variable immuable. La donnée ne peut plus être modifiée par accident.
Constantes vs Variables immuables
Il ne faut pas confondre une variable immuable (let x: u32 = 5;) avec une constante (const Y: u32 = 5;).
| Caractéristique | Variable Immuable (let) | Constante (const) |
|---|---|---|
| Mutabilité | Peut être masquée (shadowing) | Strictement immuable |
| Évaluation | À l’exécution (runtime) | À la compilation (compile-time) |
| Type | Optionnel (inféré par le compilateur) | Obligatoire (toujours explicite) |
| Scope | Local au bloc de code | Global ou local |
// Une constante globale, évaluée à la compilation
const SECOUNDS_IN_HOUR: u32 = 60 * 60;
fn main() {
println!("Secondes par heure : {SECOUNDS_IN_HOUR}");
}
Types et inférence
Rust est un langage au typage statique et fort. Cela signifie que le compilateur doit connaître le type de chaque variable à la compilation.
Cependant, grâce à l’inférence de types, nous n’avons pas besoin de tout écrire manuellement : le compilateur devine le type des littéraux et de nos variables dans la grande majorité des cas.
Attention toutefois aux raccourcis de fainéantise et de négligence. Il est souvent préférable d’indiquer clairement le type souhaité au compilateur plutôt que de le laisser appliquer ses types par défaut.
L’Inférence de Types en Action
Le compilateur analyse comment une variable est utilisée pour en déduire son type. Dans la mesure où il contrôle tous les types dans l’ensemble du code, il infère tous les littéraux et toutes les variables et expressions dont nous n’avons pas précisé le type explicitement.
Si plusieurs types sont possibles, nous pouvons (et devrions) orienter le choix en ajoutant une annotation de type.
fn main() {
// Le compilateur inférerait automatiquement le type `i32`
// (entier signé sur 32 bits par défaut)
// Âge ne peut pas être négatif ni très grand
// => nous orientons.
let age: u8 = 30;
// Annotation explicite requise ici car `parse()`
// peut retourner plusieurs types numériques
let score: u8 = "42".parse().expect("Pas un nombre valide");
println!("Age: {age}, Score: {score}");
}
Types Scalaires
Un type scalaire représente une valeur unique. Rust en possède quatre principaux : les entiers, les flottants, les booléens et les caractères.
Les entiers (integers)
Ils se déclinent selon leur taille (en bits) et leur nature (signés i ou non signés u).
| Taille | Signé | Non Signé |
|---|---|---|
| 8 bits | i8 | u8 |
| 16 bits | i16 | u16 |
| 32 bits | i32 (défaut) | u32 |
| 64 bits | i64 | u64 |
| 128 bits | i128 | u128 |
| Architecture | isize | usize (indexation) |
fn main() {
let un_octet: u8 = 255;
let indice: usize = 10; // Dépend de l'architecture (32 ou 64 bits)
}
Les nombres à virgule flottante (floats)
Rust gère le f32 (simple précision) et le f64 (double précision, choix par défaut).
fn main() {
let pi = 3.14159; // f64 par défaut
let rating: f32 = 4.5;
}
3. Le Type Booléen
Le type bool prend deux valeurs possibles : true ou false.
fn main() {
let est_valide = true;
let acces_refuse: bool = false;
}
4. Le Type Caractère
Le type char représente un scalaire Unicode. Il fait 4 octets et se délimite par des simples quotes ('). Il peut stocker un accent, un emoji ou un caractère asiatique.
fn main() {
let lettre = 'A';
let emoji = '🦀'; // Totalement valide
}
Types Composés
Les types composés permettent de regrouper plusieurs valeurs dans un seul type. Rust en propose deux principaux : les tuples et les tableaux.
1. Les Tuples
Un tuple regroupe des valeurs de types différents. Sa taille est fixe à la déclaration.
fn main() {
// Déclaration et annotation optionnelle
let developpeur: (&str, u8, bool) = ("Alice", 28, true);
// Accès direct par indexation avec un point
let nom = developpeur.0;
let age = developpeur.1;
// Décomposition (Destructuring)
let (name, age_exact, _) = developpeur;
println!("{name} a {age_exact} ans.");
}
2. Les Tableaux (Arrays)
Un tableau regroupe des valeurs du même type. Sa taille est fixe et connue dès la compilation. Les données sont stockées de manière contiguë sur la pile (stack).
fn main() {
// Déclaration classique
let calendrier = [1, 2, 3, 4, 5];
// Spécification : [type; taille]
let octets: [u8; 3] = [0, 128, 255];
// Initialisation rapide avec une valeur répétée : [valeur; nombre]
let tampon = [0; 512]; // Un tableau de 512 zéros
// Accès aux éléments (commence à 0)
let premier = calendrier[0];
// ATTENTION : Rust vérifie les limites à l'exécution.
// let hors_limite = calendrier[10]; // Provoque un panic à l'exécution
}
Annotations de type
J’ai regroupé les différentes manières d’annoter des types dans : Annotations de types.
Contrôle de flux et pattern matching
Rust propose les structures de contrôle :
if(then else if else),match
et les structures de boucles avec ou sans contrôle :
for,whileetloop.
En Rust, le contrôle de flux est majoritairement constitué d’expressions : chaque bloc peut retourner une valeur.
L’expression if
if est une expression. Cela signifie qu’un bloc if retourne une valeur et peut être utilisé pour initialiser une variable.
let condition = true;
// Les branches doivent impérativement retourner le même type
let nombre = if condition { 5 } else { 6 };
- Sécurité : Il n’y a pas de conversion implicite en booléen. La condition doit être un booléen strict (
if nombre != 0et nonif nombre). - Robustesse : Si nous utilisons
ifcomme expression, le blocelseest obligatoire.
Les boucles : loop, while et for
Rust dispose de trois mécanismes de boucle qui intègrent des garanties de robustesse.
loop et le retour de valeur
loop crée une boucle infinie. C’est l’outil idéal pour les sondages (polling) ou les serveurs. Elle permet de retourner une valeur via le mot-clé break.
let mut compteur = 0;
let resultat = loop {
compteur += 1;
if compteur == 10 {
break compteur * 2; // Retourne la valeur
}
};
for et la propriété (Iterator)
La boucle for est la méthode la plus sûre pour parcourir une collection, car elle évite les erreurs d’indice hors limites (out of bounds). Elle consomme ou emprunte la collection selon le type d’itérateur fourni.
let elements = vec!["a", "b", "c"];
// Emprunt immuable (&elements) pour pouvoir réutiliser le vecteur par la suite
for el in &elements {
println!("{el}");
}
Le pattern matching avec match
Le match est un des piliers de la robustesse en Rust.
Il permet de comparer une valeur à une série de motifs (patterns).
enum Direction {
Nord,
Sud,
Est,
Ouest,
}
let cap = Direction::Nord;
match cap {
Direction::Nord => println!("Vers le Nord"),
Direction::Sud => println!("Vers le Sud"),
Direction::Est | Direction::Ouest => println!("Sur les côtés"),
}
La règle absolue : L’exhaustivité
Le compilateur Rust impose que tous les cas possibles soient couverts. Si nous oublions une variante d’une structure ou d’un enum, le code ne passe pas l’étape de compilation.
Pour capturer tous les cas restants, nous pouvons utiliser le motif universel _ :
let valeur = 7;
match valeur {
1 => println!("Un"),
3 => println!("Trois"),
_ => println!("Autre nombre"), // Obligatoire pour couvrir tous les i32 possibles
}
if let
Lorsque nous ne voulons gérer qu’un seul motif et ignorer les autres, match peut s’avérer verbeux. Rust propose if let pour simplifier ce flux. C’est particulièrement utile avec les types Option et Result.
let configuration = Some(42);
// Au lieu d'un match exhaustif à deux branches :
if let Some(valeur) = configuration {
println!("La configuration est : {valeur}");
}
if let est très utilisé pour extraire et lier une valeur contenue dans un type énuméré (comme Some(valeur) pour Option ou Ok(valeur) pour Result) seulement si le motif correspond.
Comparaison de deux structures d'affectation protégées
if let Some(valeur) = configuration
Si la configuration est Some, le programme exécute le bloc de code avec la valeur. Si c’est None, il ignore le bloc et le programme continue normalement son exécution en dessous.
#![allow(unused)]
fn main() {
// Avec if let : le programme continue quoi qu'il arrive
if let Some(valeur) = configuration {
println!("Config trouvée : {}", valeur);
}
println!("Ce message est toujours affiché");
}
let valeur = configuration?;
? est un opérateur de propagation (early return). Si la configuration est Some, il extrait la valeur et l’affecte. Mais si c’est None, le programme arrête immédiatement l’exécution de toute la fonction actuelle et retourne None à la fonction appelante.
Cette structure n’est utilisable qu’à l’intérieur d’une fonction qui retourne elle-même un type Option (ou un Result si on utilise ? sur un Result) et lorsque la logique implémentée donne du sens à cette utilisation.
#![allow(unused)]
fn main() {
let valeur = configuration?;
println!("Ce message n'est affiché que si configuration est Some");
}
Fonctions et robustesse des signatures
En Rust, les fonctions ne se contentent pas d’exécuter du code : leur signature constitue un contrat statique. Le compilateur sur cette signature pour garantir la sécurité mémoire.
Déclaration et syntaxe de base
Nous déclarons une fonction avec le mot-clé fn.
L’inférence de type ne s’applique pas aux signatures de fonctions. Nous devons indiquer le type de chaque paramètre ainsi que le type du retour.
fn ajouter(a: i32, b: i32) -> i32 {
a + b // Expression de retour (pas de point-virgule)
}
- Paramètres :
nom: Type. - Retour : Indiqué par la flèche
->. Si aucun type n’est spécifié, la fonction retourne le type unité(). - Expression vs Déclaration : La dernière ligne sans point-virgule est une expression dont la valeur est retournée. Utiliser
returnexplicitement n’est nécessaire que pour les sorties anticipées.
Robustesse et sémantique de mouvement (move)
La signature d’une fonction indique immédiatement comment les données vont être manipulées vis-à-vis de la propriété (ownership).
Passage par valeur (Transfert de propriété)
Si nous passons un type qui n’implémente pas le trait Copy par valeur, la fonction prend possession de la donnée. La variable d’origine devient inutilisable après l’appel.
fn consommer_texte(s: String) {
println!("Je possède maintenant : {s}");
}
fn main() {
let message = String::from("Bonjour");
consommer_texte(message);
// println!("{message}"); // ERREUR de compilation : borrow of moved value
}
Passage par référence (Emprunt)
Pour éviter de transférer la propriété, on utilise des références. La signature exprime clairement les intentions d’accès :
// Emprunt immuable : lecture seule, plusieurs lecteurs simultanés possibles
fn afficher_longueur(s: &String) {
println!("Longueur : {}", s.len());
}
// Emprunt mutable : modification autorisée, exclusivité garantie
fn modifier_texte(s: &mut String) {
s.push_str(" modifié");
}
Les fonctions qui ne retournent jamais (!)
Il existe des fonctions dont le type de retour est le type never (!). Elles indiquent au compilateur (et au développeur) que le programme va s’arrêter ou diverger à cet endroit (par exemple via un panic!, une boucle infinie ou une sortie de processus).
fn arreter_programme() -> ! {
panic!("Erreur critique irrécupérable");
}
Robustesse : Exprimer l’absence et l’échec
Une signature robuste en Rust n’utilise pas null ou des codes d’erreur magiques (comme -1). Elle encapsule les résultats dans des types algébriques de données :
Option<T>quand une fonction peut ne pas retourner de valeur.Result<T, E>quand une opération peut échouer.
fn diviser(numerateur: f64, denominateur: f64) -> Result<f64, String> {
if denominateur == 0.0 {
Err(String::from("Division par zéro impossible"))
} else {
Ok(numerateur / denominateur)
}
}
La signature force l’appelant à gérer explicitement le succès ou l’échec, rendant les flux d’exécution hautement prévisibles et robustes.
Le modèle de propriété (ownership)
Le système de propriété (ownership) est l’innovation majeure de Rust. Il permet de garantir la sécurité de la mémoire (pas de pointeurs orphelins, pas de double libération) sans avoir recours à un ramasse-miettes (garbage collector) ni nécessiter de gestion manuelle de la mémoire de la part du développeur.
Les trois règles d’or
La gestion de la mémoire en Rust repose sur trois règles strictes appliquées par le compilateur :
- Chaque valeur a un propriétaire (une variable appelée son owner).
- Il ne peut y avoir qu’un seul propriétaire à la fois.
- Lorsque le propriétaire sort du bloc de portée (scope), la valeur est automatiquement détruite.
La portée d’une variable (Scope)
La portée est la zone du programme dans laquelle un élément est valide. Elle est généralement délimitée par des accolades {}.
fn main() {
{ // `s` n'est pas valide ici, elle n'est pas encore déclarée
let s = "texte"; // `s` est valide à partir d'ici
// on utilise s
} // la portée prend fin, `s` n'est plus valide
// println!("{s}") // Génère une erreur de compilation
}
Lorsque la variable sort de sa portée, Rust appelle automatiquement une fonction spéciale nommée drop qui restitue la mémoire au système.
Sémantique de déplacement (Move)
Pour comprendre l’ownership, il faut distinguer deux espaces mémoire :
- la pile (stack) et
- le tas (heap).
Prenons l’exemple du type String, dont les données textuelles sont allouées sur le tas.
let s1 = String::from("hello");
let s2 = s1;
En Rust, pour éviter de copier des données potentiellement lourdes sur le tas ou de créer deux pointeurs vers la même zone mémoire (ce qui provoquerait une erreur de double libération à la fin du bloc), la propriété de la donnée est transférée de s1 à s2.
On dit que s1 a été déplacée (moved).
Si nous tentons d’ajouter ce code à celui qui précède, nous obtenons une erreur à la compilation :
println!("{s1}"); // ERREUR DE COMPILATION ! s1 n'est plus valide.
Pour vérifier :
fn main() {
let s1 = String::from("hello");
let s2 = s1;
// println!("{s1}"); // ERREUR DE COMPILATION ! s1 n'est plus valide.
}
Sémantique de copie (Trait Copy)
Certains types font exception à cette règle de déplacement. Ce sont les types dont la taille est connue à la compilation et qui sont entièrement stockés sur la pile. Ils implémentent le trait Copy.
fn main() {
let x = 5;
let y = x; // Copie bit à bit sur la pile
println!("x = {x}, y = {y}"); // Valide ! x n'a pas été déplacé.
}
- Types qui copient : Les entiers (
i32,u64…), les booléens, les flottants, les caractères (char) et les tuples ne contenant que des typesCopy. - Types qui déplacent :
String,Vec, les structures personnalisées (par défaut).
Ownership et fonctions
Le passage d’une valeur à une fonction suit exactement les mêmes règles sémantiques que l’assignation à une variable : passer une variable à une fonction va soit la déplacer, soit la copier.
fn main() {
let s = String::from("Rust");
prendre_possession(s); // `s` est déplacée dans la fonction
// println!("{s}"); // ERREUR ! `s` n'est plus utilisable ici
let x = 42;
faire_une_copie(x); // `x` est copiée
println!("{x}"); // Valide ! `x` est toujours utilisable
}
fn prendre_possession(texte: String) {
println!("{texte}");
} // `drop` est appelé ici pour `texte`, la mémoire est libérée.
fn faire_une_copie(nombre: i32) {
println!("{nombre}");
}
Pour éviter de perdre la propriété d’un objet à chaque appel de fonction, Rust introduit les concepts de références et d’emprunts (references & borrowing), qui permettent d’utiliser une valeur sans en devenir propriétaire.
Emprunts et références (borrowing)
Pour éviter de devoir transférer la propriété (ownership) d’une valeur et la restituer à chaque fois qu’on la passe à une fonction, Rust utilise les références. Ce mécanisme s’appelle l’emprunt (borrowing).
Une référence est un pointeur garanti comme valide, car le compilateur vérifie qu’il pointe toujours vers une mémoire allouée.
Références immuables (&T)
Une référence immuable permet de lire une donnée sans la modifier. On la crée avec le symbole &.
fn main() {
let s1 = String::from("hello");
// On passe une référence (&s1). s1 reste propriétaire.
let longueur = calculer_longueur(&s1);
println!("La longueur de '{s1}' est de {longueur}."); // Valide !
}
fn calculer_longueur(s: &String) -> usize {
s.len()
} // `s` sort de la portée, mais rien n'est libéré car la fonction n'a pas la propriété.
Références mutables (&mut T)
Pour modifier une valeur empruntée, il faut utiliser une référence mutable avec &mut.
La variable d’origine doit elle aussi être déclarée avec mut.
fn main() {
let mut s = String::from("Hello");
modifier(&mut s);
println!("{s}");
}
fn modifier(s: &mut String) {
s.push_str(" World!");
}
Les deux règles d’or de l’emprunt
Pour empêcher les accès concurrents invalides et les conflits de données (data races), Rust impose une règle stricte à tout instant dans la portée d’une ressource :
- Nous pouvons utiliser autant de références immuables (
&T) que nous souhaitons, - OU (XOR) nous pouvons avoir une seule référence mutable (
&mut T),
mais pas les deux en même temps.
Exemple d’interdiction de cumul mutable / immuable
fn main() {
let mut score = 10;
let r1 = &score; // Emprunt immuable : OK
let r2 = &score; // Deuxième emprunt immuable : OK
// let r3 = &mut score; // ERREUR DE COMPILATION ! Impossible d'emprunter en mutable.
println!("{r1} et {r2}");
}
Exemple de cumul de mutables interdit
fn main() {
let mut score = 10;
let r1 = &mut score; // Premier emprunt mutable : OK
// let r2 = &mut score; // ERREUR DE COMPILATION ! Exclusivité rompue.
println!("{r1}");
}
Portée d’une référence (Non-lexical lifetime)
La portée d’une référence commence là où elle est créée et se termine à sa dernière utilisation, et non pas obligatoirement à la fin du bloc {}. Cela rend le code beaucoup plus flexible :
fn main() {
let mut valeur = 5;
let r1 = &valeur;
let r2 = &valeur;
println!("{r1} et {r2}"); // Fin d'utilisation de r1 et r2
let r3 = &mut valeur; // Valide ! Les emprunts précédents ne sont plus actifs.
*r3 += 1;
}
Les références sans donnée sont impossibles
En Rust, le compilateur garantit qu’il n’y aura jamais de référence dans donnée (dangling reference), c’est-à-dire un pointeur qui pointe vers une adresse mémoire libérée.
fn main() {
// let reference_invalide = créer_reference_pendante();
}
// fn créer_reference_pendante() -> &String {
// let s = String::from("hello");
// &s // ERREUR ! s est détruite ici, la référence pointerait vers du vide.
// }
Les slices et la gestion du texte (String vs &str)
Les tranches (slices) permettent de référencer une séquence contiguë d’éléments dans une collection sans en copier les données. C’est un emprunt partiel qui peut être immuable ou mutable.
Qu’est-ce qu’un Slice ?
Un slice est une référence à une partie d’une structure de données. En coulisses, un slice est un « gros pointeur » (fat pointer) : il stocke à la fois l’adresse mémoire du premier élément et la longueur de la séquence.
fn main() {
let tableau = [1, 2, 3, 4, 5];
// Un slice des éléments d'indice 1 (inclus) à 3 (exclu)
let tranche: &[i32] = &tableau[1..3]; // Contient [2, 3]
println!("[{}, {}]", tranche[0], tranche[1]);
}
String vs &str
Rust sépare distinctement la donnée possédée et sa vue dynamique.
String : Le texte possédé et dynamique
Une String est allouée sur le tas (heap). Sa taille peut grandir ou rétrécir. Elle possède ses données.
fn main() {
let mut s = String::from("Hello");
s.push_str(" World!"); // Modifiable et dynamique
println!("{s}");
}
&str : La slice de chaîne (string slice)
Un &str est une référence immuable vers une séquence de données UTF-8.
- Il peut pointer vers une partie d’une
String. - Il peut pointer directement vers la mémoire statique du programme (c’est le cas des littéraux de chaîne comme
"bonjour").
fn main() {
let texte_possede = String::from("Utiliser Rust");
// Un slice de chaîne pointant vers un segment de la String
let mot: &str = &texte_possede[0..8]; // Contient "Utiliser"
println!("{mot}");
}
Pourquoi privilégier &str dans les signatures de fonctions ?
Nous utilisons &str plutôt que &String dans les paramètres des fonctions pour qu’elles soient plus robustes et polyvalentes.
Prendre un &String en paramètre est considéré comme une mauvaise pratique (anti-pattern) en Rust parce que cela restreint inutilement ce que la fonction peut accepter. Nous forçons l’appelant à passer exactement une référence vers un type String (stocké sur le tas), ce qui pose problème s’il possède simplement une chaîne littérale (&str).
La bonne pratique est d’utiliser &str (une tranche de chaîne, ou string slice). Grâce à un mécanisme de Rust appelé la coercion de déréférencement (deref coercion), une &String peut automatiquement être convertie en &str. L’inverse, en revanche, n’est pas automatique et nécessite une allocation mémoire coûteuse (.to_string() ou .into()).
Code illustrant le problème et la solution
Voici le code comparatif qui montre pourquoi la v2 (avec &str) est beaucoup plus flexible et idiomatique :
// MAUVAISE PRATIQUE : oblige l'appelant à posséder ou convertir en String
fn afficher_v1(s: &String) {
println!("{s}");
}
// BONNE PRATIQUE : accepte les &str ET les &String de manière transparente
fn afficher_v2(s: &str) {
println!("{s}");
}
fn main() {
// 1. Une chaîne de caractères littérale (type &str)
let texte_literal: &str = "Bonjour depuis le littéral !";
// 2. Une chaîne dynamique (type String)
let texte_string: String = String::from("Bonjour depuis la String !");
// --- TEST AVEC V1 (Mauvaise pratique) ---
// afficher_v1(texte_literal);
// ^ COMPILATION ERROR : expected `&String`, found `&str`
// Pour que ça marche, on doit allouer de la mémoire inutilement
// et cela fait perdre du temps à l'exécution :
afficher_v1(&texte_literal.to_string());
// Fonctionne, mais syntaxe un peu lourde (&)
afficher_v1(&texte_string);
// --- TEST AVEC V2 (Bonne pratique) ---
// Fonctionne nativement avec un &str, sans aucune allocation !
afficher_v2(texte_literal);
// Fonctionne AUSSI nativement avec une String grâce à la Deref Coercion de Rust !
afficher_v2(&texte_string);
}
Conclusion
En écrivant nos fonctions avec &str :
- Nous permettons à l’appelant de passer un
&strou un&Stringsans distinction. - Nous évitons des allocations de mémoire inutiles et coûteuses en termes de performances.
Les Slices et la sécurité UTF-8
Le découpage par indice (&s[0..4]) sur des chaînes de caractères peut être dangereux en Rust. Les chaînes étant encodées en UTF-8, on ne sait pas à l’avance combien d’octets chaque caractère occupe (entre 1 et 3).
Si nous essayons de couper au milieu d’un caractère multi-octets (comme un émoji ou une lettre accentuée), Rust provoque un panic immédiat à l’exécution pour éviter de corrompre les données.
let emoji = "⚡⚡⚡";
// Chaque éclair prend 3 octets en UTF-8
// let crash = &emoji[0..2]; // CRASH ! panic de l'application (pas sur une frontière de caractère)
Structures et énumérations algébriques
Rust permet de créer des types personnalisés riches en s’appuyant sur le concept de types algébriques de données (ADT, Algebric Data Types). L’association des structures (struct) et des énumérations (enum) offre un système de typage expressif qui élimine de nombreuses erreurs de logique dès la compilation.
Les structures (struct)
Les structures permettent de regrouper plusieurs valeurs liées au sein d’un même bloc de données. Rust propose trois types de structures.
La structure nommée classique
Chaque champ possède un nom et un type explicite.
struct Utilisateur {
pseudo: String,
actif: bool,
score: u64,
}
fn main() {
// Instanciation
let mut joueur = Utilisateur {
pseudo: String::from("Alice"),
actif: true,
score: 100,
};
// Accès et modification (nécessite 'mut')
joueur.score = 150;
}
La structure tuple (tuple struct)
Le tuple est utile lorsque les champs n’ont pas besoin de nom, mais que la structure globale a un sens sémantique propre (souvent utilisé pour le motif Newtype).
struct Point3D(f64, f64, f64);
struct Couleur(u8, u8, u8);
let origine = Point3D(0.0, 0.0, 0.0);
La structure opaque (unit-like struct)
Sans aucun champ, elle est idéale pour implémenter des comportements (traits) sans stocker d’état.
struct ValidateurDeConfiguration;
Les énumérations (enum)
Les enum de Rust sont des types de somme. Chaque variante peut encapsuler ses propres données, de formes et de types différents.
enum Message {
Quitter, // Pas de donnée associée
Deplacer { x: i32, y: i32 }, // Structure nommée anonyme
Ecrire(String), // Tuple anonyme
ChangerCouleur(u8, u8, u8), // Trois valeurs u8
}
Cette syntaxe évite d’avoir à créer des structures complexes avec des pointeurs optionnels ou des unions non sécurisées.
Implémentation de méthodes (impl)
Les méthodes associées à un type sont définies dans un bloc impl.
struct Rectangle {
largeur: u32,
hauteur: u32,
}
impl Rectangle {
// Méthode : prend une référence sur elle-même via `&self`
fn aire(&self) -> u32 {
self.largeur * self.hauteur
}
// Fonction associée (souvent un constructeur, pas de `self`)
fn carre(taille: u32) -> Rectangle {
Rectangle {
largeur: taille,
hauteur: taille,
}
}
}
fn main() {
let mon_carre = Rectangle::carre(10); // Appel de la fonction associée
println!("Aire : {} px²", mon_carre.aire()); // Appel de la méthode
}
Robustesse : zéro pointeur nul avec Option
En Rust, le concept de null n’existe pas. Pour représenter une valeur potentiellement absente, la bibliothèque standard fournit l’énumération algébrique standard Option<T> :
enum Option<T> {
None,
Some(T),
}
Le compilateur nous oblige à inspecter l’Option et à extraire la valeur interne avant de pouvoir l’utiliser.
Cela sécurise les codes produits contre les erreurs de type NullPointerException.
fn trouver_utilisateur(id: u32) -> Option<String> {
if id == 42 {
Some(String::from("Alice"))
} else {
None // Aucun utilisateur trouvé, pas de retour de pointeur magique ou nul
}
}
fn main() {
let resultat = trouver_utilisateur(42);
match resultat {
Some(nom) => println!("Utilisateur trouvé : {nom}"),
None => println!("Aucun utilisateur ne possède cet ID."),
}
}
Collections standards et vecteurs
La bibliothèque standard de Rust fournit une collection de structures de données séquentielles, associatives ou de mise en mémoire tampon.
Contrairement aux tableaux natifs dont la taille est figée à la compilation, ces collections stockent leurs données dynamiquement sur le tas (heap).
Le Vecteur (Vec<T>)
Le vecteur est un tableau dynamique contigu en mémoire, homogène (tous les éléments sont de même type T).
Création et modification
On peut créer un vecteur de deux manières :
- initialisation puis remplissage.
// Création d'un vecteur vide (le type inféré avec la suite du code) let mut notes = Vec::new(); notes.push(18); // ici, le type est inféré i32 par défaut par le compilateur notes.push(15); // et toutes les valeurs suivantes doivent être de ce type.
Écritures plus soignées
Je considère (logique métier), que les valeurs stockées (des notes) vont de 0 à 20.
Une manière plus rigoureuse d’écrire ce code est :
let mut notes: Vec<u8> = Vec::new();
notes.push(18);
notes.push(15);
Une alternative d’écriture de l’initialisation avec le « turbofish » :
let mut notes = Vec::<u8>::new();
On peut aussi préciser le type souhaité ainsi et laisser l’inférence faire sa part du travail (mais c’est moins lisible) :
let mut notes = Vec::new();
notes.push(18_u8);
notes.push(15);
- utilisation de la macro
vec!().let mut prenoms = vec!["Alice", "Bob"]; // `mut` pour pouvoir modifier le vecteur prenoms.push("Charlie"); // Nécessite `mut`
Accès sécurisé aux éléments
Rust offre deux méthodes pour accéder aux éléments d’un vecteur.
Le choix de la méthode dépend du niveau de robustesse souhaité face aux indices hors limites (out of bounds).
Soit un vecteur
let valeurs = vec![10, 20, 30];
nous pouvons accéder à ses valeurs de deux manières :
- Accès direct sans précaution
let element = valeurs[1];
L’accès direct provoque un
panicsi l’index n’existe pas.
- Accès sécurisé avec .get() (recommandé)
match valeurs.get(5) {
Some(valeur) => println!("Valeur : {valeur}"),
None => println!("Index invalide, aucun élément à cette position."),
}
L’accès par
getretourne uneOption<&T>, nous obligeant à penser à gérer l’erreur potentielle.
Itérer sur un vecteur et propriété (ownership)
L’itération sur un vecteur dépend de la manière dont nous utilisons les données.
let mut nombres = vec![1, 2, 3];
// 1. Emprunt immuable (&nombres) : lecture seule
for n in &nombres {
println!("{n}");
}
// 2. Emprunt mutable (&mut nombres) : permet de modifier les éléments en place
for n in &mut nombres {
*n *= 2; // Déréférencement pour modifier la valeur interne
}
// 3. Consommation (nombres) : le vecteur est déplacé (Move) et détruit après la boucle
for n in nombres {
println!("{n}");
}
// println!("{:?}", nombres); // ERREUR ! nombres n'existe plus.
Les autres collections clés
Rust propose d’autres structures adaptées à des besoins algorithmiques spécifiques :
1. HashMap<K, V>
Stocke des paires clé-valeur. Idéal pour les recherches rapides par identifiant.
use std::collections::HashMap;
let mut scores = HashMap::new();
scores.insert(String::from("Alice"), 50);
// Accès sécurisé retournant une Option
let score_alice = scores.get("Alice");
2. VecDeque<T>
Une file à double entrée (Double-Ended Queue). Elle permet d’insérer et de retirer efficacement des éléments au début et à la fin de la collection, là où un Vec classique serait lent pour les opérations au début car il doit décaler tout le vecteur en cas de collecte et suppression d’une valeur au début.
3. HashSet<T> et BTreeSet<T>
Les ensembles (sets) garantissent l’unicité des éléments.
HashSetutilise une table de hachage (non ordonné, très rapide), tandis queBTreeSetmaintient les éléments triés.
Gestion des erreurs : Result et Option
Rust sépare les erreurs en deux catégories :
- les erreurs irrécupérables (qui arrêtent le programme) et
- les erreurs récupérables (qui doivent être traitées).
Erreurs irrécupérables : panic!
Lorsqu’un problème majeur survient et que le programme ne peut pas continuer en toute sécurité (comme un accès hors limites d’un tableau), Rust déclenche un panic!.
fn main() {
panic!("Arrêt immédiat du programme");
}
Le panic! nettoie la pile mémoire (unwinding) puis ferme l’application.
Il doit être réservé aux bogues ou aux états impossibles à anticiper.
Erreurs récupérables : L’énumération Result
Pour les actions qui peuvent échouer normalement (fichier introuvable, problème réseau, saisie utilisateur incorrecte), Rust utilise l’énumération algébrique Result<T, E>.
enum Result<T, E> {
Ok(T), // Contient la valeur en cas de succès
Err(E), // Contient l'erreur en cas d'échec
}
Exemple de fonction retournant un Result
#[derive(Debug)]
enum ErreurCalcul {
DivisionParZero,
}
fn diviser(numerateur: f32, denominateur: f32) -> Result<f32, ErreurCalcul> {
if denominateur == 0.0 {
Err(ErreurCalcul::DivisionParZero)
} else {
Ok(numerateur / denominateur)
}
}
Traiter les erreurs : match et if let
Le compilateur interdit d’utiliser la valeur sans avoir inspecté le Result. Nous devons obligatoirement ouvrir l’enveloppe.
fn main() {
let resultat = diviser(10.0, 0.0);
match resultat {
Ok(valeur) => println!("Résultat : {valeur}"),
Err(e) => println!("Erreur rencontrée : {e:?}"),
}
}
La propagation des erreurs : L’opérateur ?
Écrire un match pour chaque fonction qui peut échouer rendrait le code verbeux. Rust propose l’opérateur de propagation ?.
Placé après un appel renvoyant un Result ou une Option, le ? extrait la valeur si tout s’est bien passé, ou retourne immédiatement l’erreur à la fonction appelante si l’action a échoué.
use std::fs::File;
use std::io::{self, Read};
// La fonction retourne elle-même un Result pour propager le problème
fn lire_username() -> Result<String, io::Error> {
// Si File::open échoue, l'erreur est retournée ici immédiatement
let mut fichier = File::open("username.txt")?;
let mut username = String::new();
// Si read_to_string échoue, l'erreur est retournée ici
fichier.read_to_string(&mut username)?;
Ok(username)
}
Règle stricte : Vous ne pouvez utiliser l’opérateur
?que dans une fonction dont le type de retour est compatible avec ce que le?tente de propager (unResultou uneOption).
Le choix entre Option et Result
Voici une règle simple pour choisir :
| Type | Intention sémantique | Exemples |
|---|---|---|
Option<T> | La valeur peut être absente, et cela fait partie du fonctionnement normal. On ne cherche pas à savoir pourquoi elle n’est pas là. | Chercher une clé dans un dictionnaire, extraire le premier élément d’une liste vide. |
Result<T, E> | L’opération a échoué. On a besoin de savoir pourquoi (via E) afin de pouvoir corriger le tir ou loguer l’information. | Ouvrir un fichier sur le disque, parser une chaîne de caractères en entier. |
Passer de l’un à l’autre
La bibliothèque standard permet de convertir facilement
- une
OptionenResult(via.ok_or()) ou - un
ResultenOption(via.ok()).
let option_valeur: Option<i32> = Some(10);
// Convertit l'Option en Result en fournissant une erreur par défaut si c'est None
let resultat_valeur: Result<i32, &str> = option_valeur.ok_or("Valeur absente");
Les traits : définir des comportements communs
Un trait est une interface qui définit un contrat de comportement. Il indique au compilateur quelles fonctionnalités un type donné doit fournir. L’utilisation des traits est la seule manière de faire du polymorphisme en Rust.
Déclarer et implémenter un trait
Pour définir un trait, on utilise le mot-clé trait suivi d’un ensemble de signatures de méthodes. Pour l’implémenter sur une structure ou une énumération, on utilise la syntaxe impl NomDuTrait for NomDuType.
// 1. Déclaration de l'interface
pub trait Affichable {
fn formater(&self) -> String;
}
pub struct Utilisateur {
pub nom: String,
pub role: String,
}
// 2. Implémentation pour notre type
impl Affichable for Utilisateur {
fn formater(&self) -> String {
format!("{} ({})", self.nom, self.role)
}
}
Implémentations par défaut
Un trait peut fournir une implémentation par défaut pour certaines de ses méthodes. C’est le comportement qui est exécuté si un type qui implémente ce trait ne redéfinit pas la méthode.
pub trait Evaluation {
fn obtenir_note(&self) -> u8;
// Méthode avec un comportement par défaut
fn est_valide(&self) -> bool {
self.obtenir_note() >= 10
}
}
pub struct Examen {
pub note: u8,
}
// On doit implémenter obtenir_note, mais est_valide est optionnelle
impl Evaluation for Examen {
fn obtenir_note(&self) -> u8 {
self.note
}
}
Les traits comme paramètres
L’intérêt majeur des traits est de pouvoir écrire des fonctions qui acceptent n’importe quel type, du moment qu’il implémente le comportement attendu.
La syntaxe simplifiée : impl Trait
C’est la syntaxe la plus lisible pour des cas simples. Elle indique que la fonction accepte n’importe quel argument respectant le contrat :
pub fn publier(element: &impl Affichable) {
println!("Publication : {}", element.formater());
}
La syntaxe générique : Trait Bounds
Derrière le rideau, impl Trait est un sucre syntaxique pour les contraintes de types génériques (Trait Bounds). Cette syntaxe est indispensable dès que plusieurs paramètres doivent partager le même type :
// Force 'a' et 'b' à avoir exactement le même type T, qui doit être Affichable
pub fn comparer_et_publier<T: Affichable>(a: T, b: T) {
println!("A: {}, B: {}", a.formater(), b.formater());
}
Le cumul de traits (Syntaxe +)
On peut exiger qu’un type implémente plusieurs traits distincts à la fois pour être accepté par une fonction :
use std::fmt::Debug;
// Le type T doit être à la fois Affichable ET implémenter le trait standard Debug
pub fn inspecter<T: Affichable + Debug>(element: T) {
println!("Debug internal: {element:?}");
println!("User format: {}", element.formater());
}
Marquer les structures avec #[derive]
La bibliothèque standard de Rust regorge de traits fondamentaux. Pour s’éviter de les réécrire manuellement, Rust intègre un mécanisme de dérivation automatique via l’attribut #[derive(...)].
#[derive(Debug, Clone, PartialEq)]
pub struct Position {
pub x: i32,
pub y: i32,
}
Le compilateur génère automatiquement le code pour afficher (Debug), cloner (Clone) et comparer (PartialEq).
| Trait standard | Utilité |
|---|---|
Debug | Permet d’afficher la structure avec le formateur {:?} (indispensable pour le débogage). |
Clone | Permet de dupliquer explicitement la structure en mémoire via .clone(). |
PartialEq | Permet d’utiliser les opérateurs de comparaison == et != entre deux instances. |
Types génériques et polymorphisme
Les types génériques permettent de concevoir des fonctions, des structures ou des énumérations capables de manipuler différentes catégories de données sans dupliquer le code.
Rust implémente un polymorphisme paramétrique strict qui garantit la sécurité du typage dès la compilation, sans aucun impact sur les performances à l’exécution.
La monomorphisation (l’écriture des variantes type par type) est effectuée lors de la compilation en fonction des besoins identifiés dans le contenu du code source.
Les Structures et Énumérations génériques
On déclare un paramètre de type générique en utilisant des chevrons (souvent désigné par la lettre T par convention).
Exemple avec une structure
// Cette structure peut encapsuler des coordonnées de n'importe quel type
struct Point<T> {
x: T,
y: T,
}
fn main() {
let entier = Point { x: 5, y: 10 }; // T est inféré comme i32 par le compilateur
let flottant = Point { x: 1.0, y: 4.0 }; // T est inféré comme f64 par le compilateur
}
Exemple avec les implémentations (impl)
Pour ajouter des méthodes à une structure générique, le mot-clé impl doit lui aussi déclarer le paramètre générique afin que Rust comprenne qu’il s’agit d’une implémentation pour toutes les variantes du type.
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
Les Fonctions génériques
Une fonction générique peut accepter des arguments de types différents d’un appel à l’autre dans le code source.
Bien entendu, le code binaire généré traite chaque type indépendamment (monomorphisation).
// Rend la fonction polyvalente pour n'importe quel type T
fn inverser<T>(a: T, b: T) -> (T, T) {
(b, a)
}
Toutefois, un type générique pur (<T>) ne possède aucune méthode connue par défaut. Pour pouvoir manipuler l’objet (l’afficher, l’additionner, le comparer), il est nécessaire de restreindre ce type à l’aide des bornes de traits (trait bounds), comme vu dans la section précédente.
3. Le secret des performances : La Monomorphisation
Contrairement à d’autres langages (comme Java ou C#) qui utilisent parfois l’effacement de type (type erasure) ou des pointeurs génériques implicites (induisant un coût à l’exécution), Rust utilise un processus appelé monomorphisation.
Fonctionnement de la monomophisation
- Le développeur écrit une seule fonction ou structure générique.
- À la compilation, Rust analyse le code et repère tous les types concrets utilisés avec ce générique.
- Le compilateur génère une copie du code machine spécifique pour chaque type distinct rencontré.
Ce que nous écrivons :
fn afficher<T: std::fmt::Display>(valeur: T) {
println!("{valeur}");
}
Ce que le compilateur génère réellement dans le binaire suite à l’analyse du code source (représentation par code source évidemment abusive, c’est pour la compréhension) :
fn afficher_i32(valeur: i32) { println!("{valeur}"); }
fn afficher_str(valeur: &str) { println!("{valeur}"); }
Avantages et Inconvénients
- Le gain (Zéro coût à l’exécution) : Le code produit est optimisé au maximum, les appels de fonctions sont directs et il n’y a aucune surcouche dynamique.
- Le coût : Ce mécanisme peut légèrement augmenter le temps de compilation ainsi que la taille finale du fichier binaire.
4. Polymorphisme statique vs dynamique
Rust offre deux manières d’exploiter le polymorphisme via les traits :
Le dispatch statique (impl Trait)
C’est le comportement par défaut utilisant la monomorphisation. Le choix de la fonction à appeler est résolu à la compilation.
- Syntaxe :
fn executer(t: &impl MonTrait) - Performance : Maximale, permet l’inlining par le compilateur.
Le dispatch dynamique (dyn Trait)
Parfois, le type exact ne peut pas être connu à la compilation (par exemple, un vecteur contenant des objets hétérogènes implémentant tous le même trait). On utilise alors un objet de trait (trait object) via le mot-clé dyn, encapsulé derrière une référence ou un pointeur intelligent.
struct Chien;
struct Chat;
trait Animal { fn crier(&self); }
impl Animal for Chien { fn crier(&self) { println!("Wouf"); } }
impl Animal for Chat { fn crier(&self) { println!("Miaou"); } }
fn main() {
// Un vecteur hétérogène grâce au dispatch dynamique
let animaux: Vec<Box<dyn Animal>> = vec![
Box::new(Chien),
Box::new(Chat),
];
for animal in animaux {
animal.crier(); // Résolution à l'exécution via une vtable
}
}
Le polymorphisme dynamique n’est utilisé que lorsqu’il est indispensable, par exemple pour des collections hétérogènes. Il est géré via des méthodes virtuelles vtable et moins performant que les codes binaires monomorphisés.
Le système de modules, organisation du code
Rust propose un système de modules strict : Rust exige une déclaration explicite de l’arborescence.
Le système s’appuie sur trois concepts clés :
- la racine du crate,
- les modules et
- les chemins d’accès.
1. L’arbre des modules : Crate et Racine
Un Crate est l’unité de compilation minimale en Rust (il produit soit un binaire main, soit une bibliothèque lib).
Le compilateur démarre toujours son analyse depuis un fichier racine :
src/main.rspour un exécutable.src/lib.rspour une bibliothèque.
C’est depuis ce fichier racine que l’on va greffer les autres fichiers en utilisant le mot-clé mod.
2. Déclarer un module : Le mot-clé mod
Il y a deux façons d’organiser et d’isoler son code dans un projet.
Méthode A : Le module en ligne (dans le même fichier)
Idéal pour séparer logiquement de petites portions de code ou pour écrire les tests unitaires.
// Dans src/main.rs
mod gestion_utilisateurs {
// Le code du module va ici
pub fn creer() {}
}
Méthode B : Le module dans un fichier séparé
Pour aérer notre projet, nous indiquons au compilateur que le contenu du module se trouve dans un autre fichier portant le même nom.
Nous déclarons et utilisons le module dans src/main.rs :
mod facturation;
fn main() {
facturation::generer_facture();
}
Nous avons écrit le chemin complet ici (
facturation::generer_facture()) pour une meilleure lisibilité en phase d’apprentissage. En réalité, il est plus courant d’utiliser leuse(voir plus bas).
Nous écrivons le module dans le fichier src/facturation.rs :
pub fn generer_facture() {
println!("Facture générée !");
}
Noter la présence du mot-clef
pubindispensable pour que la fonction soit disponible à l’extérieur du module.
L’encapsulation : La visibilité par défaut (pub)
En Rust, tout est privé par défaut. Un module parent ne peut pas voir le contenu de ses modules enfants à moins que ces derniers n’utilisent explicitement le mot-clé pub.
mod reseau {
// Privé par défaut : inaccessible depuis l'extérieur du module 'reseau'
fn requete_dns() {}
// Public : accessible pour quiconque a accès au module 'reseau'
pub fn envoyer_ping() {}
}
La règle de visibilité s’applique à tout : les fonctions, les structures, les énumérations, et même les champs individuels d’une structure.
Chemins d’accès et mot-clé use
Pour appeler une fonction située dans un autre module, nous pouvons
- spécifier son chemin complet ou
- utiliser le mot-clé
usepour créer un raccourci.
On distingue deux types de chemins :
- Absolu : Démarre depuis la racine du projet avec le mot-clé
crate::. - Relatif : Démarre depuis le module courant (ou utilise
super::pour remonter au niveau parent).
// src/main.rs
mod outils;
// Création d'un raccourci d'accès
use crate::outils::maths::calculer_somme;
fn main() {
// Appel direct grâce au raccourci
let total = calculer_somme(5, 10);
}
Structure moderne de projet multi-fichiers
Un module peut lui-même contenir des sous-modules.
Voici l’organisation de dossiers standard recommandée par Rust dans un tel cas :
mon_projet/
├── Cargo.toml
└── src/
├── main.rs # Racine (déclare 'mod base_de_donnees;')
└── base_de_donnees/ # Dossier portant le nom du module
├── mod.rs # Point d'entrée du sous-arbre
├── connexion.rs # Sous-module 'base_de_donnees::connexion'
└── requetes.rs # Sous-module 'base_de_donnees::requetes'
Dans cette configuration, base_de_donnees/mod.rs sert d’aiguillage en déclarant à son tour :
pub mod connexion;
pub mod requetes;
Il existe une autre convention de nommage et d’organisation… dont je trouve qu’elle est moins lisible.
Tests unitaires, d’intégration et de documentation
En Rust, les tests font partie intégrante du langage et de l’outil cargo.
Le compilateur fournit trois niveaux de tests pour valider le comportement du code.
Pour lancer l’intégralité des tests d’un projet, il suffit d’exécuter la commande :
cargo test
Les Tests Unitaires
Les tests unitaires sont conçus pour isoler et tester de petites portions de code (comme une fonction précise), y compris les fonctions privées.
Organisation du code
Par convention, on écrit les tests unitaires dans le même fichier que le code source, à l’intérieur d’un module généralement nommé tests et marqué par l’attribut #[cfg(test)]. Cet attribut indique à Cargo de ne compiler ce code que lors de l’exécution de la commande cargo test, évitant ainsi d’alourdir le binaire final.
Exemple de fichier src/lib.rs ou src/main.rs :
fn additionner(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*; // Permet d'importer les éléments du module parent
#[test] // Indique que cette fonction est un test
fn test_additionner_positifs() {
assert_eq!(additionner(2, 2), 4);
}
#[test]
#[should_panic(expected = "division by zero")] // Valide que le code doit paniquer
fn test_qui_doit_echouer() {
let _ = 1 / 0;
}
}
Il est coutume de définir les fonctions dans la librairie. Les tests associés sont donc écrits au même endroit.
Les macros de vérification les plus courantes :
assert!(condition): Vérifie que la condition esttrue.assert_eq!(a, b): Vérifie quea == b.assert_ne!(a, b): Vérifie quea != b.
Les Tests d’Intégration
Les tests d’intégration vérifient que les différentes parties de notre bibliothèque fonctionnent correctement ensemble. Ils se comportent comme un utilisateur externe : ils ne peuvent tester que l’interface publique (pub) de votre code.
Organisation du code
Ils doivent impérativement être placés dans un dossier nommé tests/ situé à la racine du projet (au même niveau que le dossier src/). Chaque fichier .rs de ce dossier est compilé comme un crate indépendant.
mon_projet/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
└── test_integration_api.rs # Fichier de test d'intégration
Contenu de tests/test_integration_api.rs :
use mon_projet; // On importe notre propre bibliothèque comme un module externe
#[test]
fn test_flux_complet() {
let resultat = mon_projet::executer_action_publique();
assert!(resultat.is_ok());
}
Les Doc-tests (Tests de documentation)
Les exemples de code que nous écrivons dans la documentation de nos fonctions (via les commentaires triple slash ///) sont extraits et exécutés en tant que tests lors de l’exécution de la commande cargo test.
Cela garantit que les exemples de notre documentation restent toujours à jour et que les exemples fournis aux consommateurs de notre code (API) ne comportent pas un code incorrect ou obsolète.
/// Divise deux nombres flottants.
///
/// # Exemples
///
/// ```
/// use mon_projet::diviser;
///
/// let resultat = diviser(10.0, 2.0).unwrap();
/// assert_eq!(resultat, 5.0);
/// ```
pub fn diviser(numerateur: f32, denominateur: f32) -> Option<f32> {
if denominateur == 0.0 {
None
} else {
Some(numerateur / denominateur)
}
}
Pour n’exécuter que les tests des exemples de documentation, nous utilisons la commande
cargo test --doc.
Gestion des dépendances et écosystème crates.io
Gestion des dépendances et écosystème crates.io
La gestion des dépendances en Rust est entièrement centralisée et automatisée par Cargo, le gestionnaire de paquets du langage.
L’écosystème open-source s’articule autour de https://crates.io, le registre public où la communauté publie ses bibliothèques (crates).
Le fichier de configuration : Cargo.toml
Le fichier Cargo.toml (écrit en syntaxe TOML) utilise le concept de Manifest.
Nous y définissons le nom du projet, quelques métadonnées et la liste des bibliothèques externes dont nous nous servons, les dépendances (dependencies).
[package]
name = "mon_application"
version = "6.6.6"
edition = "2024"
[dependencies]
serde = "1.0"
regex = "1.10"
Le SemVer et la reproductibilité : Cargo.lock
Rust suit scrupuleusement les règles du Semantic Versioning (SemVer) pour éviter de casser notre code lors des mises à jour (Majeur.Mineur.Correctif).
Lorsque nous écrivons serde = "1.0", Cargo comprend que nous acceptons n’importe quelle version supérieure compatible (par exemple 1.0.5 ou 1.0.200), mais pas la version 2.0.0.
Le rôle crucial du Cargo.lock
Pour garantir que notre projet compile toujours de la même manière sur notre machine, sur celle d’un collègue ou sur un serveur d’intégration continue, Cargo génère un fichier Cargo.lock.
- Ce fichier enregistre les versions exactes et définitives de toutes les dépendances (et sous-dépendances) installées lors de la compilation.
- Règle d’or : Si nous développons une application (un binaire), nous conservons
Cargo.lockdans les enregistrements de notre système de contrôle de version, Git ou autre.
3. Les types de sources pour les dépendances
Cargo ne se limite pas à crates.io. Nous pouvons importer des dépendances depuis d’autres sources directement dans le bloc [dependencies] :
[dependencies]
lib_du_pote = { git = "https://forgejo.monpote.com/pote/lib_du_pote", branch = "vieille-branche" }
lib_locale_1 = { path = "../meslibs/lib_locale_1" }
Les fonctionnalités à la carte : Les Features
Pour éviter d’alourdir inutilement le temps de compilation et la taille du binaire final, de nombreuses crates découpent leurs fonctionnalités en Features.
Par défaut, une crate n’embarque que son noyau minimal.
Nous pouvons activer des options spécifiques (features) à la demande.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
Mettre à jour avec cargo
L’outil en ligne de commande cargo gère le cycle de vie des dépendances :
cargo update: Analyse le fichierCargo.tomlet mise à jour les dépendances vers leurs dernières versions mineures ou correctives autorisées par le SemVer (met à jour leCargo.lock).
Gestion des durées de vie (lifetimes)
Les durées de vie (lifetimes) garantissent qu’aucune référence ne soit orpheline de valeurs, c’est-à-dire qu’une référence ne vive jamais plus longtemps que la donnée qu’elle pointe.
La plupart du temps, le compilateur devine tout seul le comportement de nos codes.
Il arrive que nous devions l’aider en annotant explicitement les durées de vie, faute de quoi il ne validera pas notre code source s’il a des doutes.
Le problème : Éviter les références orphelines (dangling references)
Voici un exemple de ce que le compilateur cherche à empêcher :
fn main() {
let r; // ---------+-- Durée de vie de r ('a)
// |
{ // |
let x = 5; // ----+ | Durée de vie de x ('b)
r = &x; // | |
} // ----+ | x est détruite ici !
// |
println!("r: {r}"); // | ERREUR ! r pointe vers du vide.
} // ---------+
Rust compare les scopes à la compilation. Comme la durée de vie de la donnée 'b est plus courte que celle de la référence 'a, le compilateur rejette le code.
Les annotations de durées de vie
Les annotations de lifetimes ne modifient pas la durée de vie réelle d’une variable. Elles servent uniquement à expliquer les relations entre les durées de vie de plusieurs variables au compilateur.
Leurs syntaxes se reconnaissent à l’apostrophe ' suivie d’un nom en minuscule (souvent 'a, 'b, etc.).
Dans la signature d’une fonction
Prenons une fonction qui cherche à retourner la plus longue de deux chaînes de caractères :
// ERREUR DE COMPILATION si on omet les annotations :
// Le compilateur ne sait pas si la référence retournée provient de 'x' ou de 'y'.
fn plus_long<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
Ce que dit cette signature :
- La fonction accepte deux références (
xety) qui doivent vivre au moins aussi longtemps qu’une durée de vie générique'a. - La référence retournée vivra exactement aussi longtemps que la plus courte des deux durées de vie de
xety.
L’élision des durées de vie (L’inférence)
Pourquoi n’écrit-on pas des 'a partout en Rust ? Parce que le compilateur applique des règles d’élusion automatiques pour les motifs courants.
Lorsque nous écrivons :
fn premiere_lettre(s: &str) -> &str { ... }
Le compilateur traduit automatiquement par :
fn premiere_lettre<'a>(s: &'a str) -> &'a str { ... }
Les 3 règles d’élusion du compilateur
- Chaque paramètre qui est une référence reçoit sa propre durée de vie (
fn f<'a, 'b>(x: &'a i32, y: &'b i32)). - S’il n’y a qu’un seul paramètre en entrée, sa durée de vie est automatiquement assignée à toutes les références en sortie.
- S’il y a plusieurs paramètres en entrée mais que l’un d’eux est
&selfou&mut self(une méthode), la durée de vie deselfest assignée à toutes les sorties.
Les durées de vie dans les structures
Si une structure possède un champ qui est une référence (et non une donnée possédée comme un String ou un i32), vous devez obligatoirement spécifier une durée de vie. Cela indique que la structure ne peut pas vivre plus longtemps que la référence qu’elle contient.
struct Extrait<'a> {
partie: &'a str,
}
fn main() {
let texte = String::from("Compiles finement.");
let extrait = Extrait { partie: &texte[0..8] };
}
La durée de vie statique : 'static
La lifetime 'static est une durée de vie spéciale. Elle signifie que la donnée ciblée vit pendant toute la durée de l’exécution du programme.
C’est le cas de tous les littéraux de chaînes de caractères (&str), car ils sont directement encodés et stockés dans le fichier binaire de l’application.
let message: &'static str = "Je reste en mémoire pour toujours.";
Closures et itérateurs fonctionnels
Closures et itérateurs fonctionnels
Rust combine l’efficacité d’un langage système avec l’expressivité de la programmation fonctionnelle grâce à deux piliers essentiels :
- les closures (fonctions anonymes, fonctions lamda) et
- les itérateurs.
Ils permettent d’écrire un code concis, lisible et optimisé sans surcoût à l’exécution.
Les Closures (Fonctions anonymes)
Une closure est une fonction anonyme que nous pouvons stocker dans une variable ou passer en argument à une autre fonction.
Sa syntaxe utilise des barres verticales || pour définir ses paramètres.
let ajouter_un = |x: i32| x + 1;
let resultat = ajouter_un(5); // Retourne 6
La capture de l’environnement
Les closures ont la capacité à capturer et accéder aux variables de la portée dans laquelle elles sont définies.
let facteur = 3;
// La closure capture la variable 'facteur' de son environnement
let multiplier = |x| x * facteur;
println!("{}", multiplier(10)); // Affiche 30
En coulisses, le compilateur Rust détermine automatiquement comment capturer ces variables de la manière la plus sûre possible, selon trois modes qui se traduisent par des traits spécifiques :
Fn: Capture par emprunt immuable (&T).FnMut: Capture par emprunt mutable (&mut T).FnOnce: Prend possession de la variable (Move). La closure ne peut être appelée qu’une seule fois.
Le mot-clé move peut être forcé devant la closure pour l’obliger à prendre possession des variables capturées (indispensable lors du passage d’une closure à un autre thread).
Attention, le comportement du
movevarie en fonction du type des variables.
Les Itérateurs
En Rust, un itérateur est un outil qui implémente le trait Iterator et qui permet de traiter une séquence d’éléments les uns après les autres.
Les itérateurs sont paresseux (lazy) : ils ne travaillent que si nous appelons une méthode qui consomme l’itérateur.
Les trois types d’itérateurs de base
Chaque collection propose trois manières de générer un itérateur selon la gestion de la propriété :
.iter(): Donne un emprunt immuable (&T) sur chaque élément.iter_mut(): Donne un emprunt mutable (&mut T) pour modifier les éléments en place.into_iter(): Consomme la collection et prend possession (Move) de chaque élément.
Combinateurs et chaînage fonctionnel
Les itérateurs possèdent des méthodes appelées adaptateurs d’itérateurs qui permettent de transformer l’itérateur en un autre (comme .map() ou .filter()).
Pour obtenir le résultat final, on utilise un adaptateur consommateur (comme .collect() ou .sum()).
Cela permet d’écrire des codes fluides et lisibles comme celui-ci :
fn main() {
let nombres = vec![1, 2, 3, 4, 5, 6];
let somme_des_carres_pairs: u16 = nombres
.iter()
.filter(|&&x| x % 2 == 0) // On ne garde que les pairs
.map(|&x| x * x) // On les élève au carré
.sum(); // On consomme l'itérateur pour faire la somme
println!("Résultat : {somme_des_carres_pairs}"); // Affiche 56 (4 + 16 + 36)
}
Le rôle de .collect()
La méthode .collect() transforme un itérateur en une collection concrète comme un Vec ou une HashMap.
Comme elle est très générique, il faut souvent spécifier le type de la variable qui va la recevoir.
let liste: Vec<u16> = (1..5).map(|x| x * 2).collect(); // Contient [2, 4, 6, 8]
Robustesse et performance : Zéro coût d’abstraction
On pourrait craindre qu’enchaîner ainsi des fonctions anonymes et des structures d’itérateurs ralentisse le programme par rapport à une boucle for ou while écrite manuellement.
C’est exactement l’inverse : les itérateurs en Rust sont des abstractions à coût nul (zero-cost abstractions).
Le compilateur effectue un travail massif d’optimisation (notamment via le mécanisme d’unrolling et d’inlining des closures). Le code machine généré pour un enchaînement de .filter().map() est souvent strictement identique — voire plus performant — qu’une boucle écrite à la main, car il élimine nativement les vérifications d’indices aux frontières (bounds checks).
Concurrence sécurisée : threads, mutex et canaux
Rust permet d’écrire du code concurrent sans craindre les bugs traditionnels comme les corruptions de mémoire ou les data races (accès concurrents non synchronisés dont l’un est une écriture). C’est le concept de Fearless Concurrency (la concurrence sans peur).
Les Threads : Exécution en parallèle
Rust permet de lancer des threads natifs du système d’exploitation via le module std::thread.
thread::spawn: Permet de créer un nouveau thread.- Le mot-clé
move: Indispensable pour transférer la propriété (ownership) des variables du thread principal vers le nouveau thread, garantissant qu’aucune donnée ne soit libérée en cours d’exécution.
use std::thread;
use std::time::Duration;
fn main() {
let message = String::from("Bonjour depuis le thread principal");
// Le mot-clé 'move' capture et transfère la propriété de 'message' au thread
let handle = thread::spawn(move || {
println!("Nouveau thread : {}", message);
thread::sleep(Duration::from_millis(1));
});
// Attend la fin du thread pour s'assurer qu'il termine son exécution
handle.join().unwrap();
}
Les canaux (channels) : Passage de messages
Pour faire communiquer les threads entre eux sans partager de mémoire, Rust utilise le modèle d’acteurs via des canaux MPMC (Multi-Producer, Single-Consumer : plusieurs producteurs, un seul consommateur).
mpsc::channel(): Renvoie un tuple contenant un émetteur (tx) et un récepteur (rx).- Sécurité : Si un message est envoyé dans le canal, sa propriété est transférée. Il devient impossible de le modifier ou de le réutiliser dans le thread d’origine.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
// On clone l'émetteur pour avoir plusieurs producteurs si nécessaire
let tx1 = tx.clone();
thread::spawn(move || {
let val = String::from("Donnée importante");
tx1.send(val).unwrap();
// val n'est plus accessible ici, Rust l'interdit !
});
// Le thread principal reçoit le message
let recu = rx.recv().unwrap();
println!("Reçu : {}", recu);
}
Mutex et Arc : Partage de données mutables
Lorsque le partage de mémoire est inévitable, Rust impose l’utilisation de verrous explicites combinés à des pointeurs intelligents sûrs pour les threads.
Mutex<T>(Mutual Exclusion) : Garantit qu’un seul thread à la fois peut accéder aux données. L’accès est déverrouillé automatiquement dès que la variable de garde sort du scope (RAII).Arc<T>(Atomic Reference Counted) : Un pointeur intelligent à comptage de références atomique. Contrairement àRc, il permet de partager la propriété d’une donnée entre plusieurs threads en toute sécurité.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// On enveloppe le compteur dans un Mutex, puis dans un Arc
let compteur = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let compteur_clone = Arc::clone(&compteur);
let handle = thread::spawn(move || {
// .lock() bloque le thread jusqu'à l'obtention du verrou
let mut donnee = compteur_clone.lock().unwrap();
*donnee += 1;
// Le verrou est libéré automatiquement ici à la fin de la closure
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Résultat final : {}", *compteur.lock().unwrap());
}
Les Garants de la Sécurité : Send et Sync
Le compilateur s’appuie sur deux traits automatiques (marker traits) pour valider la concurrence à la compilation :
Send: Indique que la propriété de ce type peut être transférée d’un thread à un autre (ex : la plupart des types de base,Arc).Sync: Indique qu’il est sûr pour plusieurs threads d’accéder à ce type via des références partagées (ex :Mutex).
Programmation asynchrone avec Async / Await
La programmation asynchrone en Rust permet d’exécuter un grand nombre de tâches de manière concurrente sur un nombre réduit de threads système.
C’est l’outil idéal pour les applications ayant de fortes opérations d’entrées / sorties (I/O) : serveurs web, requêtes réseau, lectures de fichiers.
Le runtime asynchrone de Rust n’est pas intégré dans la bibliothèque standard : il faut utiliser des bibliothèques externes comme tokio ou async-std.
Les fonctions asynchrones et l’attente (async / await)
- Le mot-clé
async: Transforme un bloc de code ou une fonction en une machine à états qui implémente le traitFuture. La fonction ne s’exécute pas immédiatement lorsqu’elle est appelée. - Le mot-clé
.await: Suspend l’exécution de la fonction asynchrone courante, rendant le contrôle au runtime pour qu’il exécute d’autres tâches en attendant que laFuturecible soit prête.
#[tokio::main]
async fn main() {
// L'appel ne fait rien pour l'instant, il retourne une Future
let future_message = recuperer_donnees();
// .await lance l'exécution et attend le résultat de manière non bloquante
let resultat = future_message.await;
println!("{}", resultat);
}
async fn recuperer_donnees() -> String {
String::from("Données reçues de manière asynchrone")
}
Sous le capot : Les futures et les tâches
En Rust, l’asynchronisme repose sur un modèle de pull (et non de push). Le runtime doit interroger (poll) la structure pour savoir si elle a terminé.
- Le Trait
Future: Définit une méthode centralepoll. UneFuturepeut retourner soitPoll::Ready(val)si l’opération est finie, soitPoll::Pendingsi elle attend toujours une ressource. tokio::spawn: Permet de détacher uneFuturepour qu’elle devienne une tâche indépendante gérée en arrière-plan par le planificateur du runtime.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
// Lance une tâche concurrente en arrière-plan
let handle = tokio::spawn(async {
sleep(Duration::from_secs(2)).await;
println!("Tâche en arrière-plan terminée !");
});
println!("Le thread principal continue pendant l'attente...");
handle.await.unwrap();
}
Exécution concurrente avec tokio::join! et tokio::select!
Pour orchestrer plusieurs Futures, Tokio fournit des macros puissantes pour éviter d’attendre les tâches de manière séquentielle.
tokio::join!: Attend que toutes lesFuturespassées en argument soient terminées en les exécutant de manière concurrente sur le même thread ou le pool de threads.tokio::select!: Attend que la premièreFuturese termine, retourne son résultat, et abandonne immédiatement toutes les autres.
use tokio::time::{sleep, Duration};
async fn tache_rapide() -> &'static str {
sleep(Duration::from_millis(50)).await;
"Rapide"
}
async fn tache_lente() -> &'static str {
sleep(Duration::from_millis(200)).await;
"Lente"
}
#[tokio::main]
async fn main() {
// Exemple avec join! (attend les deux)
let (r1, r2) = tokio::join!(tache_rapide(), tache_lente());
println!("Join : {} et {}", r1, r2);
// Exemple avec select! (prend le premier arrivé)
tokio::select! {
premier = tache_rapide() => {
println!("Select : La tâche '{}' a gagné la course !", premier);
}
second = tache_lente() => {
println!("Select : La tâche '{}' a gagné !", second);
}
}
}
Le runtime tokio et la gestion des entrées / sorties (I/O)
La bibliothèque standard de Rust fournit des primitives d’entrées / sorties synchrones et bloquantes.
Pour réaliser des opérations d’I/O asynchrones performantes, le runtime Tokio propose des équivalents asynchrones non bloquants à travers ses modules tokio::io, tokio::fs, et tokio::net.
L’architecture du runtime Tokio
Le runtime de Tokio combine un planificateur de tâches (scheduler) et un switch d’I/O adossé aux primitives du système d’exploitation (comme epoll sur Linux, kqueue sur macOS, ou IOCP sur Windows).
- Le Multi-Thread Scheduler (Work-Stealing) : Par défaut,
#[tokio::main]lance un pool de threads. Si un thread système n’a plus de tâches à exécuter, il va “voler” (steal) des tâches aux autres threads pour équilibrer la charge. - Le piège du blocage : Un thread de calcul de Tokio ne doit jamais être bloqué par une opération synchrone longue ou une I/O bloquante (ex:
std::fs,std::thread::sleep). Cela paralyserait le thread et empêcherait les autres tâches de s’exécuter.
use tokio::task;
#[tokio::main]
async fn main() {
// Si on doit ABSOLUMENT exécuter du code bloquant/synchrone :
let resultat = task::spawn_blocking(|| {
// Ce code s'exécute sur un pool de threads dédié aux opérations bloquantes
std::thread::sleep(std::time::Duration::from_secs(2));
"Calcul lourd ou I/O synchrone terminé"
}).await.unwrap();
println!("{}", resultat);
}
Gestion des fichiers de manière asynchrone
Le module tokio::fs permet de manipuler le système de fichiers sans bloquer le pool de threads principal du runtime.
use tokio::fs::File;
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> io::Result<()> {
// Écriture asynchrone dans un fichier
let mut fichier_ecriture = File::create("exemple.txt").await?;
fichier_ecriture.write_all(b"Apprendre Rust et Tokio").await?;
// Lecture asynchrone depuis un fichier
let mut fichier_lecture = File::open("exemple.txt").await?;
let mut contenu = String::new();
fichier_lecture.read_to_string(&mut contenu).await?;
println!("Contenu lu : {}", contenu);
Ok(())
}
Réseau asynchrone avec TCP
Tokio permet la gestion des connexions réseau concurrentes grâce à tokio::net::TcpListener et tokio::net::TcpStream.
Chaque nouvelle connexion peut être traitée dans une tâche isolée via tokio::spawn, permettant de gérer des milliers de clients simultanément sur un nombre fixe de threads.
use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("127.0.0.1:8080").await?;
println!("Serveur TCP actif sur le port 8080...");
loop {
// Attend une nouvelle connexion de client
let (mut socket, adresse) = listener.accept().await?;
println!("Nouveau client connecté : {}", adresse);
// Traite la connexion de manière concurrente sans bloquer la boucle d'écoute
tokio::spawn(async move {
let mut tampon = [0; 1024];
loop {
let n = match socket.read(&mut tampon).await {
Ok(n) if n == 0 => return, // Connexion fermée par le client
Ok(n) => n,
Err(_) => return, // Erreur de lecture
};
// Renvoie les données lues au client (Écho)
if socket.write_all(&tampon[..n]).await.is_err() {
return;
}
}
});
}
}
Introduction aux Frameworks web (axum / actix)
Rust est reconnu pour :
- ses performantes,
- la sécurité mémoire et
- sa faible empreinte de ressources.
Deux frameworks dominent aujourd’hui le paysage pour le développement d’API et de services web backend :
- Actix Web : historique, ultra-rapide, basé sur les acteurs et
- Axum : moderne, développé par l’équipe Tokio, basé sur la composition.
Axum : Le framework moderne de l’écosystème Tokio
Axum intègre l’écosystème tokio et les middlewares tower.
- Routage déclaratif : Les routes sont chaînées de manière fluide.
- Extracteurs (Extractors) : Les arguments des fonctions de gestion (handlers) déterminent automatiquement ce qui doit être extrait de la requête (JSON, paramètres, headers) grâce au système de types de Rust.
use axum::{routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct NouvelUtilisateur {
nom: String,
}
#[derive(Serialize)]
struct Utilisateur {
id: u64,
nom: String,
}
#[tokio::main]
async fn main() {
// Construction des routes
let app = Router::new()
.route("/", get(racine))
.route("/utilisateurs", post(creer_utilisateur));
// Lancement du serveur avec Tokio
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn racine() -> &'static str {
"Bienvenue sur l'API Axum !"
}
// L'argument Json<NouvelUtilisateur> est un extracteur
async fn creer_utilisateur(Json(payload): Json<NouvelUtilisateur>) -> Json<Utilisateur> {
let utilisateur = Utilisateur {
id: 1,
nom: payload.nom,
};
Json(utilisateur) // Sérialisé automatiquement en JSON
}
Actix Web
Actix Web a longtemps été le framework de référence pour sa vitesse pure (souvent en tête des benchmarks TechEmpower).
Il utilise son propre runtime optimisé au-dessus de Tokio et repose sur un modèle d’exécution par thread très strict.
- Macros d’attributs : Utilise des attributs comme
#[get("/")]pour lier les fonctions aux routes. - État partagé : L’état de l’application est injecté via
web::Data.
use actix_web::{get, post, web, App, HttpServer, Responder, HttpResponse};
use serde::Deserialize;
#[derive(Deserialize)]
struct Info {
projet: String,
}
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Bienvenue sur l'API Actix Web !")
}
#[post("/configuration")]
async fn configurer(form: web::Json<Info>) -> impl Responder {
format!("Configuration reçue pour le projet : {}", form.projet)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Le serveur instancie une fabrique d'application par thread système
HttpServer::new(|| {
App::new()
.service(index)
.service(configurer)
})
.bind(("127.0.0.1:8080"))?
.run()
.await
}
Comparatif
| Critère | Axum | Actix Web |
|---|---|---|
| Écosystème | Intégration parfaite avec tokio, tower (middlewares) et hyper. | Propre écosystème (actix-rt, actix-net). |
| Style d’écriture | Basé sur les fonctions, les extracteurs et la composition. | Basé sur les macros d’attributs et une configuration orientée objet. |
| Courbe d’apprentissage | Plus naturelle si l’on maîtrise déjà Tokio et les types génériques complexes. | Plus proche des frameworks traditionnels (comme Express ou Actix dans d’autres langages). |
| Gestion des erreurs | Repose sur le système de conversion de types (IntoResponse). | Utilise un trait spécifique ResponseError. |
| Performance | Excellente, largement suffisante pour l’immense majorité des cas. | Légèrement supérieure en performance brute pure sur des micro-benchmarks. |
Optimisation de code et profiling
Rust est conçu pour produire des binaires extrêmement rapides et proches du matériel.
Cependant, écrire du code idiomatique ne garantit pas automatiquement des performances optimales.
L’optimisation, en Rust comme ailleurs, repose sur une règle d’or : vérifier par des tests et des mesures.
Le modèle de coût de Rust et les niveaux d’optimisation
Avant de chercher à optimiser le code, il faut s’assurer que le compilateur (rustc) est configuré pour libérer tout le potentiel du langage.
DebugvsRelease: Par défaut,cargo runcompile en modeDebug(sans optimisations, avec de nombreuses vérifications à l’exécution). Les mesures de performance n’ont de sens que sur un binaire construit aveccargo build --release.- Abstractions à coût nul (Zero-cost abstractions) : Les itérateurs, les fermetures (closures) et les génériques sont résolus à la compilation. Un cycle
forécrit avec des itérateurs génère un code assembleur aussi performant, voire plus, qu’une bouclewhilemanuelle grâce à la vectorisation automatique.
Dans le fichier Cargo.toml, il est possible d’ajuster le comportement du mode Release :
[profile.release]
# Niveau d'optimisation maximal (0 à 3)
opt-level = 3
# Active le Link-Time Optimization (optimise à travers les crates)
lto = true
# Réduit le parallélisme de compilation pour une meilleure optimisation globale
codegen-units = 1
# Supprime le mécanisme d'unwinding des panics pour alléger le binaire
panic = "abort"
Pièges de performance courants et bonnes pratiques
L’optimisation commence par éviter les allocations mémoire inutiles sur le tas (heap) et maximiser l’utilisation de la pile (stack).
- Allocations excessives : Cloner des données (
.clone()) ou allouer dynamiquement dans des boucles critiques ralentit drastiquement le code. - Monomorphisation : L’utilisation excessive de génériques peut faire grossir le binaire (code bloat) et saturer le cache d’instructions du processeur. L’utilisation d’objets de traits (
dyn Trait) est parfois préférable.
// ❌ À éviter : allocations répétées dans la boucle
fn traiter_donnees_inefficace(entiers: &[i32]) -> Vec<String> {
let mut resultat = Vec::new();
for &num in entiers {
// Chaque itération réalloue de la mémoire sur le tas
resultat.push(format!("Valeur: {}", num));
}
resultat
}
// Optimisé : pré-allocation et réduction du coût
fn traiter_donnees_efficace(entiers: &[i32]) -> Vec<String> {
// Réserve la mémoire exacte en une seule fois
let mut resultat = Vec::with_capacity(entiers.len());
for &num in entiers {
resultat.push(format!("Valeur: {}", num));
}
resultat
}
Profiling et Outils de Mesure
Pour identifier les goulots d’étranglement (bottlenecks), plusieurs outils natifs ou tiers s’intègrent parfaitement avec Rust.
Micro-benchmarking avec Criterion
Criterion est la bibliothèque standard de facto pour mesurer statistiquement les performances de fonctions isolées. Elle gère automatiquement l’échauffement du CPU (warm-up) et détecte les régressions d’une exécution à l’autre.
// Exemple de fichier de benchmark (benches/mon_bench.rs)
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_calcul(c: &mut Criterion) {
c.bench_function("calcul_lourd", |b| b.iter(|| {
// Le code à mesurer va ici
(0..1000).sum::<i32>()
}));
}
criterion_group!(benches, bench_calcul);
criterion_main!(benches);
Profiling d’exécution (CPU & Mémoire)
Pour analyser un programme complet en cours d’exécution, on s’appuie sur les outils système :
perf(Linux) etInstruments(macOS) permettent d’enregistrer l’activité du CPU et de générer des Flamegraphs (représentations visuelles des fonctions qui consomment le plus de temps CPU).cargo-flamegraphest un outil Cargo simple pour générer un graphique de chaleur directement depuis le terminal.DHAT(via Valgrind) etsamplypermettent de traquer les allocations mémoire abusives et de localiser précisément la ligne de code responsable de la pression sur le tas.
# Exemple d'utilisation de cargo-flamegraph pour profiler le binaire release
cargo flamegraph --bin mon_programme
Rust non sûr (unsafe Rust) et FFI
Rust tire sa force de ses garanties de sécurité strictes vérifiées à la compilation. Cependant, l’ordinateur sous-jacent et les systèmes d’exploitation ne partagent pas ces contraintes. Pour interagir directement avec le matériel, écrire des structures de données ultra-optimisées ou communiquer avec d’autres langages, Rust fournit une trappe de décompression : le mot-clé unsafe.
Les « super-pouvoirs » d’unsafe Rust
Le mot-clé unsafe ne désactive pas le vérificateur de cycle de vie (borrow checker) et ne supprime pas les vérifications de types. Il donne simplement accès à cinq « super-pouvoirs » spécifiques que le compilateur ne peut pas garantir comme sûrs :
- Déréférencer des pointeurs nus (raw pointers).
- Appeler une fonction ou une fonction externe non sûre (
unsafe). - Implémenter un trait non sûr (comme
SendouSyncmanuellement). - Modifier un état statique mutable.
- Accéder aux champs d’une
union.
Pointeurs nus (*const T et *mut T)
Contrairement aux références (&T et &mut T), les pointeurs nus peuvent ignorer les règles d’ownership : ils peuvent être nuls, être clonés librement, et pointer vers de la mémoire partagée en lecture/écriture simultanée. Leur création est sûre, mais leur déréférencement doit se faire dans un bloc unsafe.
fn main() {
let mut num = 42;
// Création légale et sûre de pointeurs nus
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
// Le déréférencement nécessite obligatoirement un bloc unsafe
unsafe {
println!("r1 pointe vers : {}", *r1);
*r2 = 1337;
println!("r2 a modifié la valeur à : {}", *r2);
}
}
Le contrat de confiance : abstraction sûre
Le code unsafe ne doit pas fuiter dans l’API publique de nos modules. La philosophie de Rust est d’envelopper (encapsulate) le code potentiellement dangereux derrière une interface publique totalement sûre (safe abstraction).
C’est ainsi que sont développés les types de la bibliothèque standard comme Vec, String ou Rc.
// Exemple : Création d'une fonction de découpe de slice (similaire à la std)
fn decouper_en_deux_mut(slice: &mut [i32], indice: usize) -> (&mut [i32], &mut [i32]) {
let longueur = slice.len();
let pointeur_nu = slice.as_mut_ptr();
assert!(indice <= longueur);
// Le borrow checker interdirait de retourner deux références mutables
// issues du même slice d'origine. On passe par les pointeurs nus.
unsafe {
(
std::slice::from_raw_parts_mut(pointeur_nu, indice),
std::slice::from_raw_parts_mut(pointeur_nu.add(indice), longueur - indice),
)
}
}
Interopérabilité avec le C avec FFI (Foreign Function Interface)
La FFI permet à Rust d’appeler du code écrit en C, et inversement, de compiler une bibliothèque Rust pour qu’elle soit consommable par du C, du C++, ou des langages de plus haut niveau (Python, Node.js via des bindings).
Appeler du code C depuis Rust
Pour appeler une fonction C, Rust a besoin de déclarer la signature de la fonction externe dans un bloc extern "C". Comme Rust ne peut pas vérifier la validité du code écrit en C, l’appel de ces fonctions est intrinsèquement unsafe.
// Liaison avec la bibliothèque standard C (abs sur Linux/macOS)
unsafe extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
// Rust fait confiance à la signature déclarée
let resultat = abs(-25);
println!("Valeur absolue depuis le C : {}", resultat);
}
}
Exposer du code Rust au C
Pour qu’une fonction Rust soit appelable depuis le C, elle doit respecter deux conditions :
#[no_mangle]: Indique au compilateur de ne pas modifier le nom de la fonction dans le binaire (le mangling permet normalement de gérer les namespaces et surcharges en Rust).extern "C": Force la fonction à utiliser l’ABI (Application Binary Interface) standard du C pour le passage des arguments.
#[unsafe(no_mangle)]
pub extern "C" fn additionner_en_rust(a: i32, b: i32) -> i32 {
a + b
}
Outils de vérification : le comportement indéfini
Écrire du code unsafe expose au comportement indéfini (UB = Undefined Behavior).
Rust dispose d’outils pour valider la correction du code non sûr :
- Miri : Un interpréteur de code intermédiaire (MIR) pour Rust. Il peut exécuter vos suites de tests et détecter instantanément les violations d’alignement, les accès mémoire hors-limites, les fuites de mémoire ou les violations des règles d’aliasing des références.
cargo diehardetcargo-valgrindpermettent de coupler les tests avec des outils d’analyse dynamique de la mémoire.
# Tester son code avec l'interpréteur Miri
rustup component add miri
cargo miri test
Macros déclaratives et procédurales
Nota : certains codes présentés ici ne compilent pas dans le playground.
Les macros en Rust permettent de faire de la métaprogrammation, c’est-à-dire d’écrire du code qui génère d’autres morceaux de code avant la compilation finale.
Contrairement aux fonctions qui manipulent des valeurs à l’exécution, les macros manipulent directement l’arbre de syntaxe abstraite (AST) du langage au moment de la compilation.
On distingue deux grandes familles de macros :
- les macros déclaratives (par correspondance de motifs) et
- les macros procédurales (par transformation de flux de tokens).
Les Macros Déclaratives (macro_rules!)
Souvent appelées “macros par l’exemple”, elles permettent de définir une syntaxe personnalisée en faisant correspondre des motifs de code, à la manière d’un bloc match.
- Designateurs de fragments : On utilise des jetons comme
expr(expression),ident(identifiant),ty(type), oustmt(instruction) pour capturer les éléments de syntaxe. - Répétitions : Les symboles
+ou*permettent de gérer des listes d’arguments de taille variable (comme dansvec![]).
#[macro_export]
macro_rules! creer_map {
// Motifs : capture une liste de clés => valeurs séparées par des virgules
( $( $cle:expr => $valeur:expr ),* ) => {
{
let mut map = std::collections::HashMap::new();
$(
map.insert($cle, $valeur);
)*
map
}
};
}
fn main() {
// Utilisation de la macro déclarative
let capitales = creer_map![
"France" => "Paris",
"Italie" => "Rome"
];
println!("Capitale de la France : {:?}", capitales.get("France"));
}
Les macros procédurales
Les macros procédurales fonctionnent comme des fonctions de compilation : elles prennent un flux de tokens (TokenStream) en entrée, exécutent du code Rust arbitraire pour le manipuler, et retournent un nouveau flux de tokens en sortie.
Pour les créer, il faut obligatoirement utiliser une crate dédiée de type bibliothèque avec l’option proc-macro = true dans le fichier Cargo.toml. On s’appuie généralement sur deux crates piliers de l’écosystème : syn (pour analyser le flux de tokens en structures exploitables) et quote (pour reconvertir du code Rust en tokens).
Il existe trois types de macros procédurales :
Les macros dérivées (Derive macros)
Elles s’ajoutent à l’attribut #[derive(...)] pour générer automatiquement l’implémentation de traits sur des structures ou des énumérations, sans modifier le code d’origine.
// Code dans la crate de la macro procédurale (ex: mon_derive)
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput};
#[proc_macro_derive(Decrire)]
pub fn decrire_derive(input: TokenStream) -> TokenStream {
// Analyse l'AST de la structure cible
let ast = parse_macro_input!(input as DeriveInput);
let nom = &ast.ident;
// Génère le code de l'implémentation du trait
let generated = quote! {
impl Decrire for #nom {
fn decrire(&self) {
println!("Je suis une instance de la structure '{}'.", stringify!(#nom));
}
}
};
generated.into()
}
Utilisation dans notre projet principal :
use mon_derive::Decrire;
trait Decrire {
fn decrire(&self);
}
#[derive(Decrire)]
struct Utilisateur {
nom: String,
}
Les macros d’attribut (Attribute-like macros)
Elles permettent de créer des attributs personnalisés (comme #[tokio::main] ou #[get("/")]) applicables sur n’importe quel élément (fonctions, structures, modules). Elles reçoivent deux arguments : les arguments passés à l’attribut lui-même et l’item sur lequel l’attribut est posé.
// Signature type d'une macro d'attribut
#[proc_macro_attribute]
pub fn mon_attribut(args: TokenStream, item: TokenStream) -> TokenStream {
// Modifie ou enveloppe l'élément ciblé
item
}
Les macros fonctionnelles (Function-like macros)
Elles ressemblent aux macros déclaratives à l’appel (avec le point d’exclamation !), mais sont traitées par une fonction procédurale pour effectuer des manipulations syntaxiques complexes (comme la macro sqlx::query!).
// Signature type d'une macro fonctionnelle
#[proc_macro]
pub fn ma_macro_fonctionnelle(input: TokenStream) -> TokenStream {
// Traite le contenu textuel passé à l'intérieur des parenthèses
input
}
Hygiène des Macros
L’un des grands atouts de Rust par rapport aux macros préprocesseurs du C/C++ est l’hygiène.
- Macros déclaratives : Elles sont parfaitement hygiéniques. Les variables locales créées à l’intérieur de la macro ne risquent pas d’entrer en conflit ou de masquer les variables portant le même nom dans le code de l’utilisateur qui appelle la macro.
- Macros procédurales : Elles ne sont pas automatiquement hygiéniques. Pour éviter les collisions de noms de variables ou de fonctions, on utilise des identifiants uniques appelés
Span(fournis par la crateproc-macroousyn), qui attachent des informations de contexte et d’origine à chaque token créé.
Architecture de projets à grande échelle (workspaces)
Lorsque la taille d’un projet Rust augmente, regrouper tout le code dans une seule et unique crate est contre-productif : les temps de compilation s’allongent et la maintenabilité se dégrade.
Pour structurer de grandes applications ou des monorepos, Cargo intègre nativement le concept d’espaces de travail (workspaces).
Un espace de travail permet de diviser un projet en plusieurs crates indépendantes (modules, bibliothèques, binaires) tout en partageant le même fichier de verrouillage (Cargo.lock), le même répertoire de sortie (target), et des configurations communes.
Structure d’un Workspace Cargo
Un espace de travail est défini par un fichier Cargo.toml racine. Ce fichier ne définit pas une crate en soi, mais orchestre les membres du workspace.
Voici l’architecture type d’un projet d’envergure découpé en Workspace :
mon_projet_workspace/
├── Cargo.toml # Fichier de configuration du Workspace
├── Cargo.lock # Partagé par toutes les crates
├── target/ # Répertoire de build unique et partagé
├── applications/
│ ├── api_web/ # Crate binaire (application finale)
│ │ └── Cargo.toml
│ └── outil_cli/ # Autre crate binaire
│ └── Cargo.toml
└── composants/
├── base_donnees/ # Crate bibliothèque (logique interne)
│ └── Cargo.toml
└── modeles/ # Crate bibliothèque (structures de données)
└── Cargo.toml
Le fichier Cargo.toml à la racine déclare explicitement les dossiers contenant les sous-crates :
[workspace]
members = [
"applications/*",
"composants/*"
]
# Centralisation optionnelle mais recommandée des dépendances du Workspace
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
modeles = { path = "composants/modeles" } # Les membres peuvent aussi être centralisés ici
Gestion des dépendances entre membres
Pour qu’un membre du workspace utilise un autre membre (par exemple, l’application api_web qui a besoin de la bibliothèque modeles), on utilise des chemins relatifs ou la centralisation du workspace.
Dans applications/api_web/Cargo.toml, on hérite des dépendances centralisées grâce au mot-clé workspace = true :
[package]
name = "api_web"
version = "0.1.0"
edition = "2021"
[dependencies]
# Utilise les versions exactes définies au niveau de la racine du workspace
tokio = { workspace = true }
serde = { workspace = true }
modeles = { workspace = true }
Cette approche garantit que l’intégralité du projet utilise exactement les mêmes versions des bibliothèques tierces. Cela permet d’éviter les conflits de types et d’optimiser les temps de compilation.
Commandes Cargo au niveau de l’espace de travail
Cargo est conçu pour comprendre la topologie de l’espace de travail.
Les commandes exécutées à la racine s’appliquent par défaut à tous les membres.
- Compiler tout l’espace de travail :
cargo build - Exécuter les tests de toutes les sous-crates :
cargo test - Compiler ou exécuter un membre spécifique :
cargo run -p api_web
cargo test -p composants_base_donnees
-p pour package (paquet)
Stratégies d’architecture pour le découpage
Pour maintenir un projet à grande échelle sain, le découpage en crates doit suivre des règles strictes d’architecture logicielle :
- Crates de domaine (domain crates) : Destinées uniquement à la logique métier pure et aux structures de données. Elles doivent être vierges de toute dépendance technique lourde (pas de framework web, pas de driver de base de données direct).
- Crates d’infrastructure (infrastructure crates) : Gèrent les détails techniques (implémentation SQLX, connecteurs API externes). Elles implémentent souvent des traits définis par les crates de domaine.
- Crates d’application (application crates) : Les points d’entrée du programme (CLI, serveurs HTTP). Elles assemblent les briques d’infrastructure et injectent les dépendances requises par le domaine.
Ce découpage strict accélère la compilation : si nous modifions le code de l’application api_web, Cargo n’a pas besoin de recompiler la crate modeles ou base_donnees si leurs fichiers n’ont pas été modifiés.
Annotations de types
Il existe de nombreuses situations où l’annotation explicite est obligatoire (comme dans les signatures de fonctions) ou utile pour guider le compilateur.
Voici toutes les façons d’annoter des types en Rust, classées par contexte.
Les variables locales (let)
L’annotation se place après le nom de la variable, séparée par deux-points (:).
// Syntaxe standard
let x: u8 = 42;
// Avec mutabilité (le modificateur 'mut' se place avant le nom, pas le type)
let mut y: f64 = 3.14;
// Avec du destructuring (on annote le motif complet)
let (tuple_a, tuple_b): (bool, char) = (true, 'R');
Les fonctions et méthodes
En Rust, l’inférence de types ne s’applique pas aux signatures de fonctions publiques ou privées. Nous devons obligatoirement annoter les types des paramètres et du retour.
// Les paramètres et le type de retour (après '->')
fn additionner(a: f64, b: f64) -> f64 {
a + b
}
Nota : si les types des opérandes et ou d’une expression ne correspondent pas, le compilateur génère une erreur de type.
Le cas des fonctions anonymes (closures)
Les closures profitent de l’inférence tout comme les fonctions.
L’annotation est très similaire à celle des fonctions :
let closure_annotee = |x: i32| -> i32 { x + 1 };
Les constantes et static
Contrairement aux variables let, les constantes (const) et les variables globales (static) requièrent obligatoirement une annotation de type explicite.
const SEUIL_MAX: u32 = 100_000;
static NOM_APPLICATION: &str = "MonApp";
Les Littéraux
Nous pouvons attacher le type directement comme suffixe d’une valeur numérique littérale.
let entier = 42u8; // Type u8
let long = 1000i64; // Type i64
let flottant = 3.0f32; // Type f32
Pour améliorer la lisibilité, Rust permet d’utiliser le caractère _ comme séparateur visuel. Il est possible de l’insérer juste avant le suffixe ou au milieu des chiffres pour séparer les grands nombres :
let entier = 42_u8;
let flottant = 3.0_f32;
let grand_nombre = 1_000_i64;
Le turbofish (::<>)
L’inférence ne suffit pas toujours à savoir vers quel type nous souhaitons convertir la donnée. Le Turbofish permet de passer des types à une fonction comme s’il s’agissait d’arguments.
Sa syntaxe est identifiant::<Type>.
// Signifie : "Parse cette chaîne en un entier i32"
let nombre = "42".parse::<i32>().unwrap();
// Signifie : "Collecte les éléments de l'itérateur dans un Vec de chaînes"
let liste = vec![1, 2, 3].into_iter().map(|x| x.to_string()).collect::<Vec<String>>();
Nous aurions aussi pu écrire
let liste: Vec<String> = ...collect();. Le choix entre l’annotation de variable et le turbofish est une question de choix personnels en termes de lisibilité et de style.
L’opérateur de transtypage (as)
Pour convertir un type primitif vers un autre (quand la conversion est sûre et supportée), on utilise le mot-clé as. Cela applique une conversion tout en forçant le type final.
let valeur_i32: i32 = 10;
let valeur_f64 = valeur_i32 as f64; // Convertit le i32 en f64
Les types génériques et contraintes
Dans les structures, les énumérations ou les blocs d’implémentation, les types peuvent être annotés sous forme de paramètres génériques, souvent accompagnés de contraintes (trait bounds).
// Annotation d'un paramètre générique T dans une structure
struct Conteneur<T> {
valeur: T,
}
// Annotation avec contrainte : T doit obligatoirement implémenter le trait Display
fn afficher_element<T: std::fmt::Display>(element: T) {
println!("{}", element);
}
// Syntaxe alternative avec la clause 'where' (plus lisible pour les signatures lourdes)
fn traiter<T>(element: T)
where
T: Clone + Debug
{
// ...
}
L’annotation de masquage partiel (_)
Si vous devez annoter un type complexe (comme un type générique composé) mais que vous voulez laisser le compilateur deviner une partie de ce type, vous pouvez utiliser le caractère de soulignement _.
// On dit à Rust : "C'est un Vec, mais devine ce qu'il y a dedans"
let scores: Vec<_> = vec![10, 20, 30];
À propos
mdBook
Ce site est construit avec mdBook, l’outil de documentation externe de la communauté Rust.
Auteur
Je m’appelle Marc JESTIN.
J’ai 55 ans, je réside actuellement en FRANCE à SURGÈRES (17).
Pour en savoir plus ou pour me contacter, rendez-vous sur