Abstractions sans coût
Comment Rust offre la sécurité sans coût à l'exécution
Qu'est-ce que le coût zéro ?
Le principe des abstractions sans coût (zero-cost abstractions) vient de Bjarne Stroustrup, le créateur de C++ :
« Ce que vous n'utilisez pas, vous ne le payez pas. Et ce que vous utilisez, vous ne pourriez pas l'écrire à la main de manière plus efficace. »
Rust pousse ce principe encore plus loin que C++. Les abstractions de haut niveau — itérateurs, génériques, pattern matching, ownership — sont compilées en code machine aussi efficace que si vous aviez tout écrit à la main en assembleur.
Code Rust Code machine de haut niveau optimisé ┌─────────────────┐ rustc ┌─────────────────┐ │ v.iter() │ ──────────→ │ même code que │ │ .filter(...) │ LLVM + │ une boucle for │ │ .map(...) │ optim. │ écrite à la main │ │ .sum() │ │ en C │ └─────────────────┘ └─────────────────┘ Lisible et sûr Rapide et compact Aucun coût supplémentaire à l'exécution.
Les itérateurs
Les itérateurs Rust sont un exemple parfait d'abstraction sans coût. Comparons deux approches pour calculer une somme conditionnelle :
// Approche impérative — boucle manuelle
fn somme_carres_pairs_v1(data: &[i32]) -> i32 {
let mut sum = 0;
for &x in data {
if x % 2 == 0 {
sum += x * x;
}
}
sum
}// Approche fonctionnelle — chaîne d'itérateurs
fn somme_carres_pairs_v2(data: &[i32]) -> i32 {
data.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * x)
.sum()
}Le résultat surprenant : ces deux fonctions produisent exactement le même code machine après compilation. Le compilateur LLVM transforme la chaîne d'itérateurs en une simple boucle optimisée.
Après compilation (pseudo-assembleur simplifié) : Les DEUX versions deviennent : ┌─────────────────────────────────────┐ │ xor eax, eax ; sum = 0 │ │ .loop: │ │ mov ecx, [rsi] ; x = *ptr │ │ test ecx, 1 ; x % 2 │ │ jnz .skip ; si impair │ │ imul ecx, ecx ; x * x │ │ add eax, ecx ; sum += ..│ │ .skip: │ │ add rsi, 4 ; ptr++ │ │ cmp rsi, rdi ; fin ? │ │ jne .loop │ │ ret │ └─────────────────────────────────────┘ Aucune allocation. Aucun appel de fonction. Aucun objet itérateur créé à l'exécution.
En Java ou Python, les itérateurs créent des objets intermédiaires à chaque étape. En Rust, tout est résolu à la compilation : l'itérateur est une abstraction qui disparaît dans le binaire final.
Génériques et monomorphisation
En Java, les génériques utilisent l'effacement de type (type erasure) : le type concret est perdu à l'exécution. En Rust, le compilateur utilise la monomorphisation : il génère une version spécialisée du code pour chaque type concret utilisé.
// Une seule fonction générique dans le code source
fn maximum<T: PartialOrd>(a: T, b: T) -> T {
if a >= b { a } else { b }
}
fn main() {
let x = maximum(3, 7); // T = i32
let y = maximum(2.5, 1.8); // T = f64
let z = maximum("abc", "def"); // T = &str
} Monomorphisation — ce que le compilateur génère :
Code source : Après compilation :
┌────────────────────┐ ┌──────────────────────────┐
│ fn maximum<T>(a,b) │ │ fn maximum_i32(a,b) → i32│
│ → T │ ──→ │ fn maximum_f64(a,b) → f64│
│ │ │ fn maximum_str(a,b) → str│
└────────────────────┘ └──────────────────────────┘
Chaque version est optimisée
pour son type spécifique.
Java (type erasure) : Rust (monomorphisation) :
┌──────────────────┐ ┌──────────────────┐
│ Un seul code │ │ N versions │
│ Casts à runtime │ │ Aucun cast │
│ Boxing/Unboxing │ │ Pas de boxing │
│ Indirection │ │ Appel direct │
└──────────────────┘ └──────────────────┘
Plus lent Aussi rapide qu'un
(vérifications runtime) code écrit à la mainLe compromis ? Le binaire est plus gros car chaque spécialisation existe en double. Mais les performances sont maximales : aucune indirection, aucun cast, aucune vérification de type à l'exécution.
Traits vs dispatch virtuel
En C++, le polymorphisme utilise des fonctions virtuelles avec une vtable : chaque appel passe par une indirection en mémoire. En Rust, le dispatch est statique par défaut.
trait Aire {
fn aire(&self) -> f64;
}
struct Cercle { rayon: f64 }
struct Rectangle { largeur: f64, hauteur: f64 }
impl Aire for Cercle {
fn aire(&self) -> f64 {
std::f64::consts::PI * self.rayon * self.rayon
}
}
impl Aire for Rectangle {
fn aire(&self) -> f64 {
self.largeur * self.hauteur
}
}
// Dispatch STATIQUE : le compilateur sait quel type
// est utilisé → appel direct, pas de vtable
fn afficher_aire(forme: &impl Aire) {
println!("Aire : {:.2}", forme.aire());
}Quand vous avez besoin de stocker des types différents dans une même collection, Rust propose le dispatch dynamique avec dyn, mais c'est un choix explicite :
// Dispatch DYNAMIQUE : seulement quand vous le demandez
fn afficher_aires(formes: &[&dyn Aire]) {
for forme in formes {
println!("Aire : {:.2}", forme.aire());
} // ↑ vtable lookup ici
}
fn main() {
let c = Cercle { rayon: 3.0 };
let r = Rectangle { largeur: 4.0, hauteur: 5.0 };
// Mélange de types → dispatch dynamique nécessaire
afficher_aires(&[&c, &r]);
} Dispatch statique (impl Trait) :
┌────────────────────────────────────────┐
│ forme.aire() → Cercle::aire() │
│ appel DIRECT │
│ inliné par LLVM │
│ coût : 0 indirection │
└────────────────────────────────────────┘
Dispatch dynamique (dyn Trait) :
┌────────────────────────────────────────┐
│ forme.aire() → vtable[0]() │
│ ↓ │
│ chercher le pointeur │
│ dans la vtable │
│ ↓ │
│ appeler la fonction │
│ coût : 1 indirection │
└────────────────────────────────────────┘
C++ : dispatch virtuel TOUJOURS (si virtual)
Rust : dispatch statique par DÉFAUT
dynamique seulement avec dyn (opt-in)L'ownership est gratuit
L'ownership et le borrow checking sont les innovations majeures de Rust. Et pourtant, ils n'ajoutent aucun coût à l'exécution. Tout se passe pendant la compilation.
Comparaison des stratégies de gestion mémoire : ┌─────────────────┬──────────────┬────────────────┐ │ Langage │ Mécanisme │ Coût runtime │ ├─────────────────┼──────────────┼────────────────┤ │ C │ manuel │ 0 (mais bugs) │ │ C++ │ RAII/smart │ ref counting │ │ Java / Go │ GC │ pauses GC │ │ Python │ GC + refcount│ très lourd │ │ Swift │ ARC │ ref counting │ ├─────────────────┼──────────────┼────────────────┤ │ Rust │ ownership │ 0 │ └─────────────────┴──────────────┴────────────────┘ Rust = sécurité de Java + performance de C
Regardons ce que le compilateur fait réellement avec l'ownership :
fn main() {
let s1 = String::from("rust");
let s2 = s1; // move — le compilateur interdit s1
// Que se passe-t-il en mémoire ?
// RIEN. Le move est un simple transfert de pointeur.
// Pas de copie, pas de compteur de références,
// pas de notification au GC.
println!("{}", s2);
} // drop(s2) → free() appelé une seule fois Ce que fait le move à l'exécution :
Avant move : Après move :
Pile : Pile :
┌──────────┐ ┌──────────┐
│ s1: ptr ────→ "rust" │ s1: ─── │ (compilateur
│ len=4 │ │ ignoré │ l'a invalidé)
│ cap=4 │ ├──────────┤
└──────────┘ │ s2: ptr ────→ "rust"
│ len=4 │
Assembleur généré : │ cap=4 │
AUCUNE instruction └──────────┘
pour le move !
Juste un renommage Le même pointeur,
de registre. zéro copie.Les lifetimes (durées de vie) suivent le même principe. Elles guident le borrow checker à la compilation mais disparaissent complètement dans le binaire. Aucune vérification n'est faite à l'exécution :
// Les lifetimes existent UNIQUEMENT dans le code source
fn le_plus_long<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// Le binaire compilé est identique à :
// fn le_plus_long(x: &str, y: &str) -> &str
// Aucune trace des lifetimes dans l'exécutable.Résumé
Les abstractions sans coût sont au coeur de la philosophie de Rust. Elles permettent d'écrire du code expressif et sûr sans sacrifier les performances :
Itérateurs
Les chaînes .filter().map().sum() sont compilées en boucles aussi efficaces que du code écrit à la main.
Génériques (monomorphisation)
Chaque type concret reçoit sa propre version optimisée du code. Pas de boxing, pas de cast, pas d'indirection.
Traits (dispatch statique)
Le polymorphisme est résolu à la compilation par défaut. Le dispatch dynamique n'est utilisé que quand vous le demandez explicitement avec dyn.
Ownership et lifetimes
Toute la sécurité mémoire est vérifiée à la compilation. Le binaire final ne contient aucune trace de ces mécanismes : pas de GC, pas de compteur de références.
C'est la promesse unique de Rust : l'abstraction sans compromis. Vous pouvez écrire du code de haut niveau — expressif, lisible, maintenable — avec la certitude qu'il sera aussi performant que l'équivalent bas niveau optimisé à la main.