Retour aux leçons
Scrollytelling

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. »
— Bjarne Stroustrup, Foundations of C++

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 :

boucle_manuelle.rs
// 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
}
iterateur.rs
// 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é.

generics.rs
// 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 main

Le 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.

static_dispatch.rs
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 :

dynamic_dispatch.rs
// 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 :

zero_cost_ownership.rs
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 :

lifetimes.rs
// 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.