Possession#

La gestion de la mémoire est le problème central de la programmation système. Les langages à ramasse-miettes (Java, Python, Go) libèrent le programmeur de cette charge au prix de pauses imprédictibles et d’une consommation mémoire accrue. Les langages à gestion manuelle (C, C++) offrent un contrôle total mais exposent le code aux erreurs d’accès après libération (use after free), aux doubles libérations (double free) et aux fuites mémoire (memory leaks). Rust emprunte une troisième voie : un système de possession (ownership) vérifie à la compilation, sans coût à l’exécution.

La pile et le tas#

Avant d’aborder les règles de possession, il est essentiel de comprendre les deux régions de mémoire dans lesquelles un programme alloue ses données.

Définition 27 (Pile (stack))

La pile est une zone de mémoire gérée selon le principe dernier entré, premier sorti (LIFO). Chaque appel de fonction pousse un cadre de pile (stack frame) contenant ses variables locales. Lorsque la fonction retourne, le cadre est depilé. L’allocation et la désallocation sur la pile sont extrêmement rapides car elles consistent en un simple déplacement du pointeur de pile.

Définition 28 (Tas (heap))

Le tas est une zone de mémoire destinée aux allocations dynamiques. L’allocateur recherche un bloc libre de la taille demandée, le marque comme occupé et retourne un pointeur. L’allocation sur le tas est plus lente que sur la pile et nécessite une désallocation explicite (ou automatisée par un mécanisme comme la possession).

Remarque 15

Une donnée de taille connue à la compilation et de durée de vie limitée à la portée courante est naturellement placée sur la pile. Les types scalaires (i32, f64, bool, char), les tableaux de taille fixe ([T; N]) et les tuples de types de pile sont tous alloués sur la pile. En revanche, une donnée dont la taille n’est connue qu’à l’exécution, ou qui doit survivre au-delà de la portée courante, est allouée sur le tas. C’est le cas de String, Vec<T> et Box<T>.

// x et y vivent sur la pile
let x: i32 = 42;
let y: f64 = 3.14;

// s possède un tampon alloué sur le tas
// La structure String elle-même (pointeur, longueur, capacité) est sur la pile
let s = String::from("bonjour");

println!("x = {x}, y = {y}, s = {s}");
println!("taille de String sur la pile = {} octets", std::mem::size_of::<String>());
x = 42, y = 3.14, s = bonjour
taille de String sur la pile = 24 octets

Les trois règles de la possession#

Le système de possession repose sur trois règles, vérifiées statiquement par le compilateur.

Proposition 3 (Règles de la possession)

  1. Chaque valeur en Rust a exactement un propriétaire (owner).

  2. Il ne peut y avoir qu”un seul propriétaire à la fois.

  3. Quand le proprietaire sort de la portée (goes out of scope), la valeur est libérée automatiquement.

Exemple 21 (Portée et libération)

Voir le code ci-dessous.

{
    let s = String::from("portée interne");
    println!("{s}");
} // s sort de la portée ici : le tampon mémoire est libéré

// La ligne suivante provoquerait une erreur de compilation :
// println!("{s}");  // erreur : s n'est plus dans la portée
portée interne
()

La libération est déterministe : elle a lieu exactement au point où le propriétaire quitte sa portée, à l’accolade fermante du bloc. Il n’y a ni ramasse-miettes ni appel explicite à free.

Déplacement (move)#

Lorsqu’une valeur allouée sur le tas est assignée à une autre variable, Rust effectue un déplacement (move) : la possession est transférée et l’ancienne variable est invalidée.

Définition 29 (Déplacement)

Un déplacement (move) est le transfert de la possession d’une valeur d’une variable à une autre. Après le déplacement, la variable d’origine ne peut plus être utilisée. Ce mécanisme garantit qu’il n’y a jamais deux propriétaires pour la même allocation sur le tas, ce qui élimine les doubles libérations.

Exemple 22 (Déplacement d’un String)

Voir le code ci-dessous.

let s1 = String::from("hello");
let s2 = s1; // déplacement : s1 est invalide

// println!("{s1}"); // erreur : value used after move
println!("{s2}");
hello

Après let s2 = s1, la variable s2 possède le tampon mémoire. La variable s1 n’est plus utilisable. Le compilateur rejette tout accès ulterieur à s1.

Remarque 16

Le déplacement ne copie pas les données du tas. Seules les métadonnées de la pile (pointeur, longueur, capacité pour un String) sont copiées, ce qui est une opération à coût constant \(O(1)\), quelle que soit la taille des données.

On peut visualiser le déplacement d’un vecteur de la même manière :

let v1 = vec![1, 2, 3, 4, 5];
let v2 = v1; // déplacement

// println!("{:?}", v1); // erreur : v1 a été déplacé
println!("{:?}", v2);
[1, 2, 3, 4, 5]

Le trait Copy#

Certains types font exception à la semantique de déplacement : ils sont copiés plutôt que déplacés.

Définition 30 (Trait Copy)

Le trait Copy marque les types dont les valeurs peuvent être dupliquées par simple copie bit à bit, sans effet de bord. Lorsqu’un type implémente Copy, l’assignation, le passage en argument et le retour de fonction produisent une copie indépendante ; la variable d’origine reste valide. Le trait Copy ne peut être implémenté que si le type n’implémente pas le trait Drop.

Les types suivants implémentent Copy :

  • tous les types scalaires : i8 a i128, u8 a u128, isize, usize, f32, f64, bool, char ;

  • les références partagées &T ;

  • les tuples dont tous les éléments implémentent Copy, par exemple (i32, f64, bool) ;

  • les tableaux [T; N] lorsque T implémente Copy.

Exemple 23 (Copie des types scalaires)

Voir le code ci-dessous.

let a: i32 = 42;
let b = a; // copie, pas déplacement

println!("a = {a}, b = {b}"); // les deux sont valides

let t1 = (1, 2.0, true);
let t2 = t1; // copie (tous les éléments sont Copy)

println!("t1 = {:?}, t2 = {:?}", t1, t2);
a = 42, b = 42
t1 = (1, 2.0, true), t2 = (1, 2.0, true)

Remarque 17

Les types Copy sont généralement de petite taille et entièrement alloués sur la pile. La copie bit à bit est si peu coûteuse que le déplacement n’apporterait aucun bénéfice. C’est pourquoi Rust choisit de copier ces types par défaut.

Le trait Clone#

Quand on souhaite dupliquer une valeur qui n’implémente pas Copy, il faut demander une copie explicite profonde via le trait Clone.

Définition 31 (Trait Clone)

Le trait Clone fournit la méthode clone(), qui produit une copie indépendante et complète de la valeur. Contrairement à Copy, le clonage peut impliquer des allocations mémoire et un coût proportionnel à la taille des données. Tout type qui implémente Copy implémente aussi Clone, mais la réciproque est fausse.

Exemple 24 (Clonage d’un String)

Voir le code ci-dessous.

let s1 = String::from("deep copy");
let s2 = s1.clone(); // copie explicite profonde

println!("s1 = {s1}");
println!("s2 = {s2}");
println!("même contenu ? {}", s1 == s2);
println!("même adresse ? {}", s1.as_ptr() == s2.as_ptr()); // false : tampons distincts
s1 = deep copy
s2 = deep copy
même contenu ? true
même adresse ? false

Le clonage d’un vecteur duplique l’intégralité des données du tas :

let v1 = vec![10, 20, 30];
let v2 = v1.clone();

println!("v1 = {:?}", v1);
println!("v2 = {:?}", v2);
v1 = [10, 20, 30]
v2 = [10, 20, 30]

Remarque 18

L’appel à clone() est un signal visuel dans le code : il indique une allocation potentiellement coûteuse. Les revues de code Rust accordent souvent une attention particulière aux appels à clone(), car un clonage superflu peut révéler un défaut de conception dans la gestion de la possession.

Le trait Drop#

Définition 32 (Trait Drop et RAII)

Le trait Drop fournit la méthode drop(&mut self), appelée automatiquement par le compilateur lorsque le propriétaire d’une valeur sort de la portée. Ce mécanisme est l’équivalent du RAII (Resource Acquisition Is Initialization) du C++ : l’acquisition d’une ressource (mémoire, fichier, verrou) est liée à la durée de vie d’un objet, et sa libération est garantie par le destructeur.

Exemple 25 (Implémentation de Drop)

Voir le code ci-dessous.

struct Ressource {
    nom: String,
}

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

{
    let r1 = Ressource { nom: String::from("fichier.txt") };
    let r2 = Ressource { nom: String::from("connexion_db") };
    println!("Ressources créées");
} // r2 est libérée en premier, puis r1 (ordre inverse de déclaration)
Ressources créées
Libération de la ressource : connexion_db
Libération de la ressource : fichier.txt
()

Remarque 19

Les variables sont libérées dans l”ordre inverse de leur déclaration. Ce comportement est analogue au dépilement d’une pile et garantit que les dépendances entre ressources sont respectées : une ressource déclarée en premier, dont d’autres pourraient dépendre, est libérée en dernier.

Il est interdit d’appeler drop() explicitement sur une valeur. Pour forcer la libération anticipée d’une ressource, on utilise la fonction std::mem::drop qui prend la possession de la valeur.

struct Verrou {
    nom: String,
}

impl Drop for Verrou {
    fn drop(&mut self) {
        println!("Verrou '{}' relaché", self.nom);
    }
}

let v = Verrou { nom: String::from("mutex_a") };
println!("Verrou acquis");

// v.drop(); // erreur : appel direct interdit
std::mem::drop(v); // libération anticipée

println!("Le verrou a été relaché avant la fin de la portée");
// println!("{}", v.nom); // erreur : v a été deplace dans drop()
Verrou acquis
Verrou 'mutex_a' relaché
Le verrou a été relaché avant la fin de la portée

Remarque 20

Les traits Copy et Drop sont mutuellement exclusifs. Un type qui implémente Drop ne peut pas implémenter Copy, car la copie bit à bit d’une valeur possédant un destructeur mènerait à une double libération. Si un type nécessite un nettoyage personnalisé via Drop, sa duplication doit passer par Clone.

Possession et fonctions#

Le passage d’une valeur en argument à une fonction obéit aux mêmes règles que l’assignation : les types Copy sont copiés, les autres sont déplacés.

Proposition 4 (Transfert de possession par appel de fonction)

Passer une valeur non Copy à une fonction transfère la possession au paramètre de la fonction. La variable d’origine est invalidée dans la portée appelante. De même, retourner une valeur depuis une fonction transfère la possession à l’appelant.

Exemple 26 (Déplacement lors d’un appel de fonction)

Voir le code ci-dessous.

fn prend_possession(s: String) {
    println!("J'ai reçu : {s}");
} // s est libéré ici

fn fait_copie(n: i32) {
    println!("J'ai reçu : {n}");
} // n est une copie, rien de spécial ici

let message = String::from("transfert");
let nombre = 42;

prend_possession(message);
// println!("{message}"); // erreur : message a été déplacé

fait_copie(nombre);
println!("nombre est toujours valide : {nombre}"); // ok : i32 est Copy
J'ai reçu : transfert
J'ai reçu : 42
nombre est toujours valide : 42

Pour qu’une fonction utilise une valeur sans en prendre possession, il existe deux stratégies : retourner la valeur après usage, ou utiliser des références (chapitre suivant).

Exemple 27 (Retour de possession)

Voir le code ci-dessous.

fn ajouter_exclamation(mut s: String) -> String {
    s.push('!');
    s // la possession est retournée à l'appelant
}

let texte = String::from("bonjour");
let texte = ajouter_exclamation(texte);

println!("{texte}");
bonjour!

Ce patron consistant à prendre puis retourner la possession fonctionne mais alourdit le code. Les références et les emprunts, présentés au chapitre 6, offriront une solution bien plus ergonomique.

Exemple 28 (Transferts multiples)

Voir le code ci-dessous.

fn longueur_et_retour(s: String) -> (String, usize) {
    let len = s.len();
    (s, len) // on retourne la String et sa longueur
}

let mot = String::from("possession");
let (mot, len) = longueur_et_retour(mot);

println!("'{}' contient {} octets", mot, len);
'possession' contient 10 octets

Remarque 21

L’obligation de retourner les valeurs pour les réutiliser après un appel de fonction est fastidieuse. C’est précisement ce problème qui motive l’introduction des références et du concept d”emprunt (borrowing), qui permettent de donner un accès temporaire à une valeur sans transférer la possession. Ce sera l’objet du chapitre 6.