Retour au cours

backend / rust

Gestion mémoire avancée : drop et memory layout

Leçon 211 exercice

Explication

Ce que vous allez apprendre

  • Prédire précisément l'ordre d'appel de Drop::drop à la sortie d'un scope
  • Utiliser std::mem::size_of pour connaître la taille réelle d'un type en mémoire
  • Comprendre la niche optimization et pourquoi Option<Box<T>> ne coûte pas plus cher que Box<T>
  • Réorganiser les champs d'une structure pour réduire le padding mémoire
  • Utiliser mem::replace et mem::take pour échanger une valeur sans clone coûteux

Dans quel contexte ?

Une équipe qui développe un service de traitement de fichiers en Rust remarque que sa structure Metrique occupe 40 octets alors que la somme théorique de ses champs ne devrait faire que 26 octets. En creusant avec std::mem::size_of, l'équipe découvre que l'ordre des champs déclarés dans la structure crée du padding (des octets de remplissage) imposé par l'alignement mémoire — un problème invisible dans le code mais bien réel une fois que des millions d'instances de cette structure sont allouées.

D'abord, comprendre quand la mémoire est libérée

Rust n'a pas de garbage collector : la libération de mémoire est déterministe et suit une règle simple, le RAII (Resource Acquisition Is Initialization). Quand une valeur sort de son scope, sa méthode drop() s'exécute automatiquement — et si plusieurs valeurs sortent du même bloc, elles sont libérées dans l'ordre inverse de leur création, exactement comme une pile (LIFO).

C'est le même principe que defer en Go, mais appliqué automatiquement et systématiquement à toute valeur, sans avoir besoin de l'écrire explicitement à chaque fois.

Prérequis

Cette leçon suppose que tu connais déjà les notions d'ownership et de scope vues en début de parcours : c'est ce mécanisme qui déclenche Drop.

Une fois ce mécanisme compris, il devient utile de mesurer la mémoire réellement utilisée

std::mem::size_of::<T>() révèle la taille exacte d'un type, en octets, telle que le compilateur la voit. Certains résultats surprennent au premier abord.

TypeTaille (octets, plateforme 64 bits)Explication
i324Taille fixe de l'entier
&str16Pointeur (8) + longueur (8)
String24Pointeur (8) + longueur (8) + capacité (8)
Option<Box<i32>>8Niche optimization : aucun octet supplémentaire pour le tag Some/None

La dernière ligne illustre la niche optimization : comme un pointeur Box ne peut jamais être nul en Rust, le compilateur réutilise la valeur "nulle" impossible pour représenter None, évitant ainsi un octet de tag supplémentaire qu'un langage plus naïf aurait dû ajouter.

Il reste un problème pratique : l'ordre des champs peut gaspiller de la mémoire

Une structure composée d'un bool (1 octet), d'un i64 (8 octets) et d'un autre bool (1 octet) ne fait pas 10 octets en mémoire. À cause de l'alignement (un i64 doit commencer à une adresse multiple de 8), le compilateur insère du padding entre les champs. Avec #[repr(C)], cet ordre est figé tel que déclaré — utile pour l'interopérabilité avec du C, mais potentiellement gaspilleur. Sans cet attribut, le compilateur Rust est libre de réordonner les champs pour minimiser ce padding automatiquement.

Piège courant

#[repr(C)] est nécessaire dès qu'une structure doit correspondre à un layout mémoire C (FFI, protocole réseau binaire), mais elle empêche alors les optimisations automatiques de layout de Rust. N'ajoute cet attribut que si tu en as réellement besoin.

Enfin, un dernier outil pour éviter des clones inutiles

mem::replace et mem::take permettent de "voler" une valeur d'une variable en la remplaçant instantanément par une autre (ou par sa valeur par défaut pour take), sans jamais avoir besoin de cloner la donnée d'origine. C'est un pattern très courant dès qu'on manipule des Option<T> ou qu'on doit satisfaire le borrow checker tout en réorganisant des données.

Bonne pratique

Avant de suspecter une structure de gaspiller de la mémoire, vérifie toujours avec std::mem::size_of::<T>() plutôt qu'en te fiant à une estimation théorique — le padding est rarement intuitif.

Maintenant que tu maîtrises la mémoire au niveau de l'octet, la suite logique consiste à organiser un vrai projet Rust à grande échelle : c'est l'objet de la prochaine leçon sur les workspaces Cargo et la publication de crates.

Commandes & code

Gestion mémoire avancée : drop et memory layout

Comprendre précisément quand et comment la mémoire est libérée, et comment elle est organisée.

rust
// Le trait Drop personnalise le nettoyage lors de la destruction d'une valeur
struct Ressource {
    nom: String,
}

impl Drop for Ressource {
    fn drop(&mut self) {
        println!("liberation de la ressource: {}", self.nom);
    }
}

fn main() {
    {
        let _r1 = Ressource { nom: String::from("fichier.txt") };
        let _r2 = Ressource { nom: String::from("connexion-db") };
        println!("les ressources sont ouvertes");
    } // drop() appele ici, dans l'ordre INVERSE de creation : r2 puis r1

    // drop() explicite pour liberer plus tot que la fin naturelle du scope
    let r3 = Ressource { nom: String::from("temporaire") };
    println!("avant drop manuel");
    drop(r3); // equivalent a std::mem::drop, force la liberation immediate
    println!("apres drop manuel");

    // std::mem::size_of : taille exacte d'un type en memoire
    println!("taille i32: {}", std::mem::size_of::<i32>());       // 4
    println!("taille bool: {}", std::mem::size_of::<bool>());     // 1
    println!("taille &str: {}", std::mem::size_of::<&str>());     // 16 (ptr + longueur)
    println!("taille String: {}", std::mem::size_of::<String>()); // 24 (ptr + len + capacite)
    println!("taille Option<Box<i32>>: {}", std::mem::size_of::<Option<Box<i32>>>()); // 8 : niche optimization !

    // Struct alignment : comme en Go, l'ordre des champs impacte le padding
    #[repr(C)] // force l'ordre memoire C (utile pour FFI), desactive les optimisations Rust
    struct AlignementC {
        a: bool,  // 1 octet + padding
        b: i64,   // 8 octets
        c: bool,  // 1 octet + padding
    }
    println!("taille AlignementC: {}", std::mem::size_of::<AlignementC>());

    // Sans #[repr(C)], le compilateur Rust peut reordonner les champs pour minimiser le padding
    struct AlignementOptimise {
        a: bool,
        b: i64,
        c: bool,
    }
    println!("taille AlignementOptimise: {}", std::mem::size_of::<AlignementOptimise>());

    // Stack vs heap : les types de taille connue vont sur la pile, Box/Vec/String vont sur le heap
    let sur_pile = [0u8; 1024];      // 1 Ko sur la pile
    let sur_heap = vec![0u8; 1024];  // 1 Ko sur le heap, la variable elle-meme (ptr) est sur la pile
    println!("{} {}", sur_pile.len(), sur_heap.len());

    // mem::replace / mem::take : recuperer une valeur en la remplacant, sans clone couteux
    let mut chaine = String::from("valeur originale");
    let ancienne = std::mem::replace(&mut chaine, String::from("nouvelle valeur"));
    println!("{} / {}", ancienne, chaine);

    let mut option = Some(String::from("presente"));
    let valeur = std::mem::take(&mut option); // laisse None a la place, sans clone
    println!("{:?} {:?}", valeur, option);
}

Résumé

  • Drop::drop s'exécute automatiquement à la sortie de scope, dans l'ordre inverse de création (LIFO, comme defer).
  • std::mem::size_of::<T>() révèle la taille réelle en mémoire, utile pour optimiser le layout de struct.
  • La niche optimization (ex: Option<Box<T>> = 8 octets) exploite des valeurs impossibles pour éviter un tag séparé.
  • mem::replace/mem::take échangent une valeur sans clone, un pattern clé pour du code sûr et performant.

Exercices pratiques

1 disponible
1

Mission : un message de fermeture qui disparaît toujours du fichier de log

Objectif : Diagnostiquer un bug d'ordre de destruction lié au Drop, puis distinguer l'ordre de destruction de l'optimisation du layout mémoire d'une struct.

Contexte

Un service crée, dans cet ordre, une ConnexionDb puis un FichierLog :

rust
struct ConnexionDb { nom: String }
impl Drop for ConnexionDb {
    fn drop(&mut self) {
        println!("fermeture de la connexion {}", self.nom);
        // veut ecrire une derniere ligne "connexion fermee" dans le fichier de log ici
    }
}

struct FichierLog { nom: String }
impl Drop for FichierLog {
    fn drop(&mut self) {
        println!("fermeture du fichier {}", self.nom);
    }
}

fn main() {
    let connexion = ConnexionDb { nom: String::from("prod-db") };
    let fichier = FichierLog { nom: String::from("app.log") };
    // ... utilisation de connexion et fichier
}

Le Drop de ConnexionDb est censé écrire une dernière ligne dans le fichier de log avant de couper la connexion. Mais en observant app.log après chaque exécution de test, l'équipe constate que ce message n'apparaît jamais — comme si le fichier était déjà fermé au moment où ConnexionDb::drop s'exécute.

Résoudre l’exercice →