backend / rust
Unsafe Rust : quand et pourquoi
Explication
Ce que vous allez apprendre
- Identifier précisément les 5 opérations que
unsafeautorise, 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 patternsplit_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 unsafe | Exemple typique | Pourquoi c'est dangereux sans le mot-clé |
|---|---|---|
| Déréférencer un pointeur brut | *ptr_brut | Aucune garantie que le pointeur soit valide ou non-nul |
Appeler une fonction unsafe | dangereux() | La fonction a des préconditions non vérifiées par le compilateur |
Lire/écrire une static mut | COMPTEUR += 1 | Accès concurrent possible sans synchronisation |
Implémenter un trait unsafe | unsafe impl Send | Le développeur garantit un contrat que le compilateur ne peut pas prouver |
| Accéder à un champ d'union | rare en Rust applicatif | Le 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.
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é
unsafeautorise 5 opérations précises (raw pointers, fonctions unsafe, static mut, traits unsafe, unions) — rien de plus.- Le borrow checker reste actif :
unsafene désactive aucune règle, il ajoute une responsabilité manuelle limitée. - Le pattern standard : encapsuler l'
unsafedans une fonction sûre qui vérifie ses invariants (commesplit_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
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 :
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.