Retour au cours

backend / rust

Unsafe Rust : quand et pourquoi

Leçon 191 exercice

Explication

Ce que vous allez apprendre

  • Identifier précisément les 5 opérations que unsafe autorise, et rien de plus
  • Comprendre pourquoi le borrow checker reste actif même à l'intérieur d'un bloc unsafe
  • Construire une abstraction sûre au-dessus d'un code unsafe (le pattern split_at_mut)
  • Reconnaître les cas d'usage légitimes d'unsafe : FFI, structures bas niveau, optimisations vérifiées
  • Éviter le piège classique du débutant qui pense qu'unsafe "désactive Rust"

Dans quel contexte ?

Une équipe qui développe un moteur de jeu en Rust a besoin de diviser un tableau de particules en deux moitiés modifiables simultanément par deux threads différents. Le borrow checker refuse catégoriquement deux emprunts mutables sur le même slice, même si les deux threads travaillent sur des zones mémoire totalement disjointes. C'est exactement le genre de situation où unsafe devient nécessaire — pas pour contourner la sécurité, mais pour exprimer une garantie que le compilateur ne peut pas vérifier seul.

Le malentendu à dissiper tout de suite

Beaucoup de débutants pensent qu'écrire unsafe { ... } revient à "désactiver Rust" et retomber dans les libertés du C. C'est faux : à l'intérieur d'un bloc unsafe, le borrow checker, le système de types et les vérifications de durée de vie continuent de s'appliquer normalement. Seules cinq opérations précises deviennent accessibles, et c'est tout.

Opération autorisée en unsafeExemple typiquePourquoi c'est dangereux sans le mot-clé
Déréférencer un pointeur brut*ptr_brutAucune garantie que le pointeur soit valide ou non-nul
Appeler une fonction unsafedangereux()La fonction a des préconditions non vérifiées par le compilateur
Lire/écrire une static mutCOMPTEUR += 1Accès concurrent possible sans synchronisation
Implémenter un trait unsafeunsafe impl SendLe développeur garantit un contrat que le compilateur ne peut pas prouver
Accéder à un champ d'unionrare en Rust applicatifLe type réel stocké n'est pas vérifié

Prérequis

Cette leçon suppose que tu es à l'aise avec l'emprunt (&, &mut) et le borrow checker classique de Rust, vus dans les leçons précédentes sur l'ownership.

Une fois ces cinq opérations posées, il reste à comprendre le vrai objectif d'unsafe

Le but n'est presque jamais d'écrire du code unsafe partout, mais d'encapsuler une petite portion de code non vérifiable dans une fonction dont l'interface publique reste 100 % sûre. C'est exactement ce que fait split_at_mut dans la bibliothèque standard : à l'intérieur, elle utilise des pointeurs bruts ; à l'extérieur, elle expose une signature parfaitement normale que n'importe quel code sûr peut appeler sans risque.

Ce principe s'appelle l'encapsulation de l'unsafe : la fonction vérifie elle-même ses invariants (ici, assert!(mid <= len)) avant de faire ce que le compilateur ne peut pas vérifier. Si l'assertion passe, l'opération qui suit est garantie correcte.

Piège courant

Ne jamais supposer qu'un bloc unsafe qui compile est forcément correct. Le compilateur ne détecte plus les erreurs de logique mémoire à l'intérieur : un pointeur invalide, un accès hors bornes ou une donnée non initialisée provoquera un comportement indéfini (undefined behavior), potentiellement silencieux, qui peut se manifester bien plus tard dans le programme.

Le cas d'usage le plus fréquent en production : le FFI

Au quotidien, l'usage le plus courant d'unsafe en dehors des bibliothèques bas niveau reste l'interopérabilité avec du code C (Foreign Function Interface). Appeler une fonction C comme abs() nécessite unsafe, car Rust ne peut vérifier aucune des garanties de sécurité de mémoire du côté C.

Bonne pratique

Isole systématiquement tes appels FFI dans un petit module dédié avec une interface Rust sûre autour. Le reste de ton code applicatif ne devrait jamais manipuler directement des pointeurs bruts ou des appels extern "C".

Maintenant que tu sais où et pourquoi utiliser unsafe avec parcimonie, la question naturelle suivante est : est-ce que ce genre d'optimisation bas niveau est même nécessaire ? La prochaine leçon répond en explorant pourquoi les abstractions de haut niveau de Rust (itérateurs, génériques) ne coûtent déjà rien à l'exécution grâce au principe de "zero-cost abstraction".

Commandes & code

Unsafe Rust : quand et pourquoi

unsafe ne désactive pas le borrow checker : il autorise 5 opérations précises, sous la responsabilité du développeur.

rust
fn main() {
    // 1. Dereferencer un pointeur brut (raw pointer)
    let x = 42;
    let ptr_brut = &x as *const i32; // pointeur brut, pas de garantie de validite
    unsafe {
        println!("valeur via pointeur brut: {}", *ptr_brut);
    }

    // 2. Appeler une fonction ou methode unsafe
    unsafe fn dangereux() -> i32 {
        42 // ici triviale, mais typiquement : appel FFI, acces memoire non verifie...
    }
    let resultat = unsafe { dangereux() };
    println!("{}", resultat);

    // 3. Acceder/modifier une variable statique mutable (partagee, potentiellement racee)
    static mut COMPTEUR: i32 = 0;
    unsafe {
        COMPTEUR += 1;
        println!("compteur global: {}", COMPTEUR);
    }
    // En pratique, preferer AtomicI32 (sur, sans unsafe) a une static mut

    // 4. Implementer un trait unsafe (contrat de securite a respecter manuellement)
    unsafe trait TraitDangereux {}
    struct MonType;
    unsafe impl TraitDangereux for MonType {}

    // 5. Acceder a un champ d'union (rarement utilise directement en Rust applicatif)

    // Cas d'usage reel le plus courant : construire une abstraction SURE au-dessus d'unsafe
    // Exemple : diviser un slice en deux parties mutables (impossible avec le borrow checker seul)
    fn diviser_en_deux(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
        let len = slice.len();
        let ptr = slice.as_mut_ptr();
        assert!(mid <= len); // la verification manuelle REMPLACE la garantie du compilateur
        unsafe {
            (
                std::slice::from_raw_parts_mut(ptr, mid),
                std::slice::from_raw_parts_mut(ptr.add(mid), len - mid),
            )
        }
    }
    let mut donnees = [1, 2, 3, 4, 5, 6];
    let (gauche, droite) = diviser_en_deux(&mut donnees, 3);
    gauche[0] = 100;
    droite[0] = 200;
    println!("{:?} {:?}", gauche, droite);

    // FFI : appeler du code C, cas d'usage frequent d'unsafe en production
    extern "C" {
        fn abs(input: i32) -> i32;
    }
    unsafe {
        println!("abs(-5) via C: {}", abs(-5));
    }
}

Résumé

  • unsafe autorise 5 opérations précises (raw pointers, fonctions unsafe, static mut, traits unsafe, unions) — rien de plus.
  • Le borrow checker reste actif : unsafe ne désactive aucune règle, il ajoute une responsabilité manuelle limitée.
  • Le pattern standard : encapsuler l'unsafe dans une fonction sûre qui vérifie ses invariants (comme split_at_mut).
  • Cas d'usage réels dominants : FFI (interop C), structures de données bas niveau, optimisations extrêmes vérifiées.

Exercices pratiques

1 disponible
1

Mission : une abstraction unsafe qui viole ses propres garanties

Objectif : Diagnostiquer un comportement indéfini dans une fonction unsafe inspirée de split_at_mut, puis la corriger avec les vérifications manquantes.

Contexte

Un développeur junior, inspiré par le pattern split_at_mut vu en cours, écrit une fonction censée renvoyer des références mutables vers le premier et le dernier élément d'un slice, pour les modifier simultanément :

rust
fn premier_et_dernier_mut(slice: &mut [i32]) -> (&mut i32, &mut i32) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();
    unsafe {
        (&mut *ptr, &mut *ptr.add(len - 1))
    }
}

Le code compile sans le moindre avertissement. En test avec un slice de 5 éléments, tout fonctionne. Mais une fois déployée, l'équipe observe des plantages aléatoires et des valeurs aberrantes en production, sur des appels qu'elle n'arrive pas à reproduire de façon fiable en local.

Résoudre l’exercice →