https://lafor.ge/feed.xml

Drop, référence et borrow checker

2022-10-31

Contrairement à plusieurs autres langages modernes comme le Golang et le Java. Rust ne possède pas de système qui lors de l'exécution du programme vient nettoyer les variables qui ne sont plus utilisées. Ces systèmes sont appelés des Garbage Collectors.

L'avantage principal est de permettre de créer des programmes sans se soucier de la gestion de la mémoire et sans risquer de fuites de mémoires, car celle-ci est gérée par un autre processus.

Le désavantage de cette manière de faire est qu'il faut régulièrement figer le programme pour nettoyer ce qui doit l'être. Dans la plupart des cas d'utilisation, cela n'a pas d'impact, le GC est suffisamment efficient pour être capable de nettoyer dans un laps de temps suffisamment court pour que l'opération soit quasi transparente pour l'éxécution du programme.

Mais il peut arriver des contextes de hautes performances où ce garbage collector peut avoir un impact si important qu'il vient dégrader les performances du programme.

Allocation et libération de la mémoire

Navré d'avance, mais nous allons faire un chouia de C 😅

Lorsque que l'on définit en C une donnée qui est allouée, quelque part il y a deux types de mémoires la Stack et la Heap.

Ce qui est alloué dans la stack est entièrement géré par l'exécution du programme qui sait à quel moment la variable n'est plus utilisée.

Par contre, ce qui est alloué dans la heap est lui totalement à la charge du développeur.

Un article va sortir sur la segmentation de la mémoire, pour le moment je vous propose cette ressource si vous en avez le besoin.

Prenons un exemple pour montrer ce qui se passe.

#include <stdio.h>
#include <stdlib.h>

int main() {
    // On demande gentiement au système d'allouer de la mémoire pour contenir un
    // entier (4 octets)
    // On stocke l'adresse de cette endroit dans *p
    int *p = malloc(sizeof(int));

    printf("L'adresse de p est %p et vaut %d\n",p, *p);

    if (p == NULL) {
        // Cela peut arriver lorsque l'on n'a plus de place en RAM
        printf("Impossible d'allouer :(\n");
    } else {
        // On affecte une valeur à notre zone nouvellement allouée
        *p = 12;
        printf("Allocation réussite :)\n");
        printf("p a l'adresse %p vaut maintenant %d\n",p, *p);
    }

    // On libère la mémoire à l'adresse de p
    free(p);

    printf("L'adresse de p est %p et vaut %d\n",p, *p);

    return 0;
}

Cela devrait donner quelque chose qui ressemble à cela :

L'adresse de p est 0x55e2dc2542a0 et vaut 0
Allocation réussite :)
p a l'adresse 0x55e2dc2542a0 vaut maintenant 12
L'adresse de p est 0x55e2dc2542a0 et vaut 0

Maintenant prenons un autre exemple pour mettre en application.

Réalisons une boucle qui appelle une fonction qui alloue sans cesse des blocs de mémoires sans jamais les libérer.

#include <stdio.h>
#include <stdlib.h>

void greedy(int i) {

    // On demande de la mémoire
    int *p = malloc(sizeof(int));

    if (p != NULL) {
        *p = i;
    }
    // pas de free
}

int main() {

    for (int i = 0; i < 1000000000; i++) {
        greedy(i);
    }

    return 0;
}

Boom 💥

[1]    7008 killed     ./a.out

On demande tellement de place que le système d'exploitation tue le processus et nous jette !

Ceci s'appelle : une fuite mémoire !

Temporellement ça donne ça :

Graphique temporel de l'évolution de la mémoire au cours du temps. Grosse monté, plateau puis redescente à plusieurs dizaines de gigaoctets

La même chose, mais en prenant soin de libérer la mémoire

#include <stdio.h>
#include <stdlib.h>

void ungreedy(int i) {

    // On demande de la mémoire
    int *p = malloc(sizeof(int));

    if (p != NULL) {
        *p = i;
    }
    // libération de la mémoire détenu par p
    free(p);
}

int main() {

    for (int i = 0; i < 1000000000; i++) {
        ungreedy(i);
    }

    return 0;
}

Cette fois-ci, aucun souci ✅

Graphique temporel de l'évolution de la mémoire au cours du temps. Le graphique reste plat

Drop

Promis, c'est fini avec le C, on ne fera que du Rust à partir de maintenant. 😁

Comme vous avez pu le constater, la mauvaise manipulation du free peut avoir des graves répercutions sur le programme. Et ce non pas lors du développement mais lorsqu'il tourne.

Cette situation est un problème. On fait reposer une trop grande responsabilité sur le développeur qui n'est pas infaillible. Bien-sûr, il existe des outils qui pourraient permettre de détecter les problèmes. Mais ça ne serait pas mieux que le langage puisse directement le faire à notre place ou à la place de l'outil ?

Donc pour le moment, nous avons le choix entre des langages à Garbage Collector qui peuvent avoir des performances moindres dans certains contextes et des langages sans garbage collector mais qui nécessitent une grande rigueur sous peine de créer des abominations !

Et bien, il existe une 3ème voie, qui est en quelque sorte une fusion des deux autres voies. Nous allons avoir un système qui n'utilise pas de garbage collector, mais qui ne nécessite pas non plus d'explicitement définir les free dans le code. Au risque de les oublier.

Nous allons voir dans toute la suite de l'article comment le langage est à même de placer les free aux bons endroits comme le ferait le développeur rigoureux, mais sans que celui-ci ait à le faire.

Voici le code le plus simple de la Terre.

struct Toto;

fn main() {
    let toto = Toto;
}

A la fin de l'exécution, avant que le programme se coupe, Rust a libéré la mémoire détenue par toto.

Vous ne me croyez pas ? Attendez, on va implémenter un trait Drop et vous allez voir que c'est vrai 😄.

struct Toto;

impl Drop for Toto {
    fn drop(&mut self) {
        println!("Toto est détruit")
    }
}

fn main() {
    let toto = Toto;
    println!("Hello, world!");
}

Vous voyez que j'avais raison 😁

Hello, world!
Toto est détruit

Nous avons bien le "Toto est détruit", juste avant que le programme ne se coupe.

Drop est un trait qui dispose d'une unique fonction drop.

Celle-ci est appelée juste avant que la variable ne soit libérée de la mémoire.

Il n'est pas possible d'appeler explicitement toto.drop(), bien que la méthode existe, cela se soldera par une erreur de compilation!

1  |     toto.drop();
   |     -----^^^^--
   |     |    |
   |     |    explicit destructor calls not allowed

toto.drop() est appelé implicitement lorsque le Drop est déclenché sur toto. Et celui-ci se déclenche lorsque plus personne n'utilise toto.

Si l'on move toto dans un autre contexte, ici la fonction eat, on obtient un résultat un peu différent.

fn main() {
    let toto = Toto;
    eat(toto);
    println!("Hello, world!");
}

fn eat(toto: Toto) {
    println!("Eat!");
}

Cette fois-ci, nous avons d'abord l'appel à la méthode eat, puis le drop de toto dans la méthode eat et finalement la fin du programme.

Eat!
Toto est détruit
Hello, world!

Il n'y a plus de drop de toto dans main, car celui-ci n'est plus présent dans le contexte de la méthode main, il n'est plus possible de le drop.

Par contre la méthode eat reçoit la propriété de toto et donc de fait, lorsque le contexte de eat se termine, toto est drop.

Par contre, si l'on clone toto avant de move vers eat. On a une situation encore différente.

#[derive(Clone)]
struct Toto;

fn main() {
    let toto = Toto;
    eat(toto.clone());
    println!("Hello, world!");
}

fn eat(toto: Toto) {
    println!("Eat!");
}

Cela provoque le déclenchement du drop 2 fois. La première fois à l'issue de la fin de contexte de eat et la seconde à la fin de contexte de main.

Eat!
Toto est détruit
Hello, world!
Toto est détruit

Un autre chose que l'on peut également réaliser est de rendre la propriété de toto à main à l'issue de la fin de la méthode eat.

Que l'on renomme give_back pour l'occasion.

fn main() {
    let toto = Toto;
    let toto2 = give_back(toto);
    println!("Hello, world!");
}

fn give_back(toto: Toto) -> Toto {
    println!("Give back!");
    toto
}

Cette fois-ci, il n'y a plus de drop dans

Give back!
Hello, world!
Toto est détruit

On peut encore influer le comportement en n'utilisant pas la valeur de retour de give_back.

fn main() {
    let toto = Toto;
    give_back(toto);
    println!("Hello, world!");
}

fn give_back(toto: Toto) -> Toto {
    println!("Give back!");
    toto
}

Si personne n'utilise le retour, Rust est capable de comprendre que le move serait superflu et donc ne le rajoute pas.

En contrepartie, à la sortie du contexte de give_back le drop est déclenché.

Par contre, il ne l'est pas dans main, car toto a été précédemment move vers eat et donc n'existe plus dans main.

Give back!
Toto est détruit
Hello, world!

Reference

Il n'est pas toujours bon de tout cloner, et parfois ce n'est même pas possible. Même le move peut avoir un coût non négligeable dans certains contextes.

Si on prend une analogie entre deux amis qui s'échangent des jeux vidéos, parfois on ne veut pas donner son jeu ni sortir le graveur pour dupliquer (oui oui à l'époque on faisait ça).

On préfère prêter le jeu et s'attendre à ce que l'on nous le redonne, mais plus tard. Pour ça on inscrit une "reconnaissance de dette" sur un bout de papier, et on se dit que dans un mois : "tu me le rends, hein". (Spoiler: j'ai perdu plein de jeux comme ça 😭 ).

Nous allons faire de même, mais en Rust, et avec un système qui s'assure que ce qui est prêté est bien rendu. 😁

Borrow

Pour cela, nous introduisons un nouveau concept.

Il s'agit du borrow.

Celui-ci est réalisé via méthode .borrow() qui est disponible sur n'importe quel type de Rust.

use std::borrow::Borrow;

fn main() {
    let toto = Toto; // on définit toto

    // on définit toto_ref une référence vers toto
    let toto_ref = toto.borrow();
}

Il est possible d'écrire exactement la même chose en utilisant une autre syntaxe :

fn main() {
    let toto = Toto; // on définit toto

    // on définit toto_ref une référence vers toto
    let toto_ref = &toto;
}

Ce qui est important de voir ici, c'est l'utilisation du & devant toto pour signifier le prêt.

Si l'on exécute ce code, nous obtenons:

Toto est détruit

Cela prouve qu'il n'est drop qu'une seule fois. 🙂

Il faut voir cela comme la machine à clone, sauf que cette fois-ci au lieu de créer un duplicata de notre valeur en entrée.

La machine va éditer la "reconnaissance de dette" ou "l'acte notarié" qui signifie qui est le propriétaire de l'objet prêté.

Nous pouvons alors utiliser ce système pour prêter toto à une méthode lend.

fn main() {
    let toto = Toto;
    lend(&toto);
    println!("Hello world!");
}

fn lend(toto: &Toto) {
    println!("Lend!")
}

Pour signifier que la méthode lend attend un prêt de Toto et non Toto lui-même on note &Toto le type.

En exécutant, nous nous retrouvons avec :

Lend!
Hello World!
Toto est détruit

Ce qui est le résultat attendu, toto reste vivre dans main et n'est drop qu'à l'issue de la fin du contexte de main.

Il est tout à fait possible de prêter à plusieurs personnes successivement.

On peut créer une référence et la partager plusieurs fois.

fn main() {
    let toto = Toto;
    let toto_ref = &toto;
    lend(toto_ref);
    lend(toto_ref);
    println!("Hello world!");
}

Une autre manière de faire est de borrow de manière différenciée à chaque appel à lend.

fn main() {
    let toto = Toto;
    lend(&toto);
    lend(&toto);
    println!("Hello world!");
}

Pour les deux cas, nous aurons le même résultat.

Lend!
Lend!
Hello world!
Toto est détruit

L'intérêt du borrow est de pouvoir accéder en lecture seule à ce qui est prêté.

Pour cela, rendons Toto un poil plus intéressant.Donnons-lui un champ value.

struct Toto {
    value: u8
}

Et définissons une méthode lend_and_display qui va avoir pour tâche d'afficher le résultat.

fn main() {
    let toto = Toto {value : 2};
    lend_and_display(&toto);
    println!("Hello World!");
}

fn lend_and_display(toto: &Toto) {
    println!("Toto = {}", toto.value)
}

Nous accédons bien à la valeur 2.

Toto = 2
Hello world
Toto est détruit

Ce qui est important de comprendre ici, c'est que toto, ne quitte jamais le contexte de main. Le contexte lend_and_display n'a qu'un droit de regard sur toto qui vie dans main.

Borrow mut

Bien, on peut lire, mais peut-on écrire ?

Oui mais il va falloir revoir les termes du contrat. Littéralement.

Et il nous faut une machine différente !

De la même manière que pour borrow, une référence mutable peut-être soit créée ainsi :

use std::borrow::BorrowMut;

fn main() {
    let mut toto = Toto;
    let toto_mut_ref = toto.borrow_mut();
}

Soit via la syntaxe:

fn main() {
    let mut toto = Toto;
    let toto_mut_ref = &mut toto;
}

Celle-ci prend nécessairement quelque chose de mutable et crée une référence mutable.

Si la variable n'est pas déclarée mutable

let toto = Toto;
let toto_mut_ref = &mut toto;

Une erreur de compilation est levée.

cannot borrow `toto` as mutable, as it is not declared as mutable
   |     let toto = Toto;
   |         - help: consider changing this to be mutable: `mut toto`
   |     let toto_mut_ref = &mut toto;
   |             ^^^^^^ cannot borrow as mutable

Pourquoi cette restriction ?

Et bien on ne peut pas permettre à un tiers de modifier une variable si celle-ci est immuable. Il s'agit d'une sécurité supplémentaire qui nous sera bien utile lorsque l'on abordera le parrallélisme dans un prochain article.

Définissons une méthode inc qui prend une référence mutable et qui incrémente la valeur de Toto::value.

fn main() {
    let mut toto = Toto {value : 2};
    println!("Toto = {}", toto.value);
    inc(&mut toto);
    println!("Toto = {}", toto.value);
}

fn inc(toto: &mut Toto) {
    println!("Inc!");
    toto.value += 1;
}

Ce qui affiche

Toto = 2
Inc!
Toto = 3
Toto est détruit

L'idée reste la même, le contexte de inc ne possède qu'un droit de regard sur la modification de la valeur de toto.value. Mais toto reste dans le contexte de main. Et donc le drop n'a lieu qu'à l'issue du contexte de main.

Il est possible de borrow_mut plusieurs fois, soit en éditant un seul acte notarié et en le distribuant.

fn main() {
    let mut toto = Toto {value : 2};
    println!("Toto = {}", toto.value);
    let toto_ref_mut = &mut toto;
    inc(toto_ref_mut);
    inc(toto_ref_mut);
    println!("Toto = {}", toto.value);
}

Soit en éditant plusieurs.

fn main() {
    let mut toto = Toto {value : 2};
    println!("Toto = {}", toto.value);
    inc(&mut toto);
    inc(&mut toto);
    println!("Toto = {}", toto.value);
}
Toto = 2
Inc!
Inc!
Toto = 4
Toto est détruit

Le résultat est identique, mais il y a plusieurs subtilités que nous allons voir dans la prochaine partie.

Borrow checker

Pour le moment, nous n'avons abordé que des cas nominaux, tout se passe bien et les oiseaux chantent. 🐦🎶

Maintenant, plongeons dans la face sombre de Rust, celle qui a mis à genoux bon nombre de développeurs (dont moi) et qui leur fait abandonner le langage.

"Parce que c'est trop compliqué et le compilo me laisse pas développer comme je veux, etc..."

J'ai nommé le Borrow Checker !!! 🔥

On va démystifier son comportement et comprendre les règles qui le régissent.

Les variables doivent vivre quelque part

La première règle est la plus importante et souvent la plus contraignante.

Pour vous guider, nous allons prendre une analogie immobilière.

Supposons que vous signez un contrat de bail avec un propriétaire. Vous êtes tout content de vous. Et là soudain sans vous prévenir, le propriétaire démolit son immeuble et l'appart que vous avez loué avec !

C'est exactement ce que cette méthode try_borrow qui ne compile pas tente de faire.

fn main() {
    let toto_ref = try_borrow();
}

fn try_borrow() -> &'static Toto {
    let toto = Toto;
    &toto
}

On va voir dans la suite ce que &'static signifie.

try_borrow définit toto en possède la propriété, et tente de prêter &toto à main. Sauf que le contexte de try_borrow vie moins longtemps que celui de main. La règle du drop s'applique à l'issue de la fin du contexte de try_borrow.

Résultat, le contrat de prêt de toto, n'a aucune valeur. Le Borrow checker, interdit cette transaction douteuse et le code ne compile pas

error[E0515]: cannot return reference to local variable `toto`
   |
   |     &toto
   |     ^^^^^ returns a reference to data owned by the current function

Ici le borrow checker indique explicitement ce qui ne va pas. On retourne une référence de quelque chose que l'on possède. Et donc automatiquement, ce quelque chose mourra à l'issue de notre contexte. Il est donc impossible d'y faire référence après.

Il est possible par contre de faire un prêt de quelque chose que l'on nous a préalablement prêté. Cela ne sert pas à grand-chose, mais renforce l'idée de durée de vie.

fn main() {
    let toto = Toto;
    let toto_ref = lend_borrow(&toto);
}

fn lend_borrow(toto: &Toto) -> &Toto {
    &toto
}

toto vivant dans le contexte de main, lend_borrow peut prêter &toto puis mourir, cela ne change pas le fait que toto existe toujours. Et ne sera drop qu'à l'issue de main.

Un contrat ça s'honnore

La situation peut être inversée, on peut aussi prêter un quelque chose et le détruire après signature du contrat.

Pour expliquer cela je vous présente la méthode drop(). Elle est disponible dans la librairie standard et est la plus simple qui puisse exister.

fn drop<T>(_x: T) {}

Elle prend la propriété de ce qu'on lui donne et drop _x.

T signifie n'importe quel type, nous verrons cela en profondeur dans un prochain article.

Allons-y

fn main() {
    let toto = Toto;
    let toto_ref = &toto;
    drop(toto);
    lend(toto_ref);
}

fn lend(toto: &Toto) {
    println!("Lend!")
}

Le borrow checker est vraiment pas content ! 👹

error[E0505]: cannot move out of `toto` because it is borrowed
   |
   |     let toto_ref = &toto;
   |                    ----- borrow of `toto` occurs here
   |     drop(toto);
   |          ^^^^ move out of `toto` occurs here
   |     lend(toto_ref);
   |          -------- borrow later used here

Et à raison, on définit un prêt à lend et dans son dos avant qu'il puisse l'utiliser toto sort du contexte de main est déplacé dans drop qui le détruit.

Votre propriétaire à vendu votre appart à une autre personne qui décide de le détruire alors que votre bail n'est pas fini.

On prête en l'état

Lorsque vous signez un contrat de bail et que vous obtenez la clef, vous attendez à ce que le propriétaire ne rentre pas avec son double de clefs sans votre autorisation et commence à modifier la déco de votre appart.

Et bien Rust, c'est pareil. Si vous prêtez quelque chose, tant que le prêt n'est pas expiré, vous n'avez pas le droit de modifier ce que vous avez prêté !

Pour rigidifier cet état de fait on demande à main une clause supplémentaire de non-modification de l'état de ce qui prêté.

Voyons comment se matérialise cette nouvelle clause du point de vue de Rust.

struct Toto {
    value: u8
}

fn main() {
    let mut toto = Toto {value: 2};
    let toto_ref = &toto;
    toto.value = 3;
    lend(toto_ref);
}

Pas content

error[E0506]: cannot assign to `toto.value` because it is borrowed
   |
   |     let toto_ref = &toto;
   |                    ----- borrow of `toto.value` occurs here
   |     toto.value = 3;
   |     ^^^^^^^^^^^^^^ assignment to borrowed `toto.value` occurs here
   |     lend(toto_ref);
   |          -------- borrow later used here

Pourquoi ? Et bien le contrat stipule que toto ne sera pas modifiée tant que le contrat a court. Or au milieu de ce contrat, main propriétaire de toto prend la liberté de modifier toto.value. Mais c'est interdit !

Et le borrow_checker nous le fait ainsi comprendre.

De même, il n'est pas possible de modifier une variable tant que tous les contrats qui ont court n'ont pas échu.

fn main() {
    let mut toto = Toto {value: 2};
    let toto_ref = &toto;
    lend(toto_ref);
    toto.value = 3;
    lend(toto_ref);
}
error[E0506]: cannot assign to `toto.value` because it is borrowed
   |
   |     let toto_ref = &toto;
   |                    ----- borrow of `toto.value` occurs here
   |     lend(toto_ref);
   |     toto.value = 3;
   |     ^^^^^^^^^^^^^^ assignment to borrowed `toto.value` occurs here
   |     lend(toto_ref);
   |          -------- borrow later used here

Le prêt du premier lend est fini, mais celui du second n'a pas encore commencé, on reste toujours tenu de ne rien modifier tant que le deuxième lend n'a pas rendu le prêt.

Il est par contre possible de créer des contrats différenciés et de modifier la valeur entre les prêts.

fn main() {
    let mut toto = Toto {value: 2};
    lend(&toto);
    toto.value = 3;
    lend(&toto);
}

Cette fois-ci, tout est correct, chaque contrat est respecté.

Mutable ou non, il faut choisir

De la contrainte précédente, nous pouvons sortir une autre situation.

Imaginons que le propriétaire de l'appart loue avec l'autorisation de refaire le papier-peint. Vous qui avez précédemment signé un bail pour un appart aux murs blancs vous vous retrouvez avec du bleu pervenche partout ! Et vous, vous n'avez pas le droit de repeindre. 😥

Du coup il est temps de créer un nouveau contrat plus restrictif !

Désormais, dès qu'une référence mutable est éditée, on a la certitude que c'est la seule, l'unique ayant cours à la rédaction du contrat.

Voyons comment le borrow_checker va permettre de verrouiller cela.

fn main() {
    let mut toto = Toto {value : 2};
    let toto_ref = &toto;
    let toto_ref_mut = &mut toto;
    inc(toto_ref_mut);
    lend(toto_ref);
}

Il vient d'empêcher la possibilité de créer à la fois des références mutables et immutables qui se chevauchent.

error[E0502]: cannot borrow `toto` as mutable because
              it is also borrowed as immutable
   |
   |     let toto_ref = &toto;
   |                    ----- immutable borrow occurs here
   |     let toto_ref_mut = &mut toto;
   |                        ^^^^^^^^^ mutable borrow occurs here
   |     inc(toto_ref_mut);
   |     lend(toto_ref);
   |          -------- immutable borrow later used here

Et inverser l'ordre de rédaction des contrats, n'a pas non plus d'effet positif.

fn main() {
    let mut toto = Toto {value : 2};
    let toto_ref_mut = &mut toto;
    let toto_ref = &toto;
    inc(toto_ref_mut);
    lend(toto_ref);
}

On rompt ici deux contrats, le premier contrat est rompu car il existe une autre référence que lui-même. Et on rompt le second contrat en ne pouvant assurer que la la valeure de toto ne variera pas avant la fin du contrat qui lie à toto_ref. En effet, le toto_ref_mut a les pouvoirs de modifier la valeur.

Le compilateur, décide d'interdire la rédaction du second contrat. Mais dans les faits, les deux sont caducs dans cette situation.

error[E0502]: cannot borrow `toto` as immutable because it is also borrowed as mutable
   |
   |     let toto_ref_mut = &mut toto;
   |                        --------- mutable borrow occurs here
   |     let toto_ref = &toto;
   |                    ^^^^^ immutable borrow occurs here
   |     inc(toto_ref_mut);
   |         ------------ mutable borrow later used here

De même, il est impossible d'avoir deux contrats en modification qui ont cours au même moment.

fn main() {
    let mut toto = Toto {value : 2};
    let toto_ref_mut = &mut toto;
    let toto_ref_mut2 = &mut toto;
    inc(toto_ref_mut);
    inc(toto_ref_mut2);
}

Ici la règle violée est encore le fait qu'un autre contrat à cours au moment de la rédaction du second.

error[E0499]: cannot borrow `toto` as mutable more than once at a time
   |
   |     let toto_ref_mut = &mut toto;
   |                        --------- first mutable borrow occurs here
   |     let toto_ref_mut2 = &mut toto;
   |                         ^^^^^^^^^ second mutable borrow occurs here
   |     inc(toto_ref_mut);
   |         ------------ first borrow later used here

Par contre, on peut avoir autant de référence que l'on veut en même temps tant qu'il n'y a jamais modification de ce qui est référencé.

fn main() {
    let toto = Toto {value: 2};
    let toto_ref = &toto;
    let toto_ref2 = &toto;
    lend(toto_ref);
    lend(toto_ref2);
}

De même, dès que tous les contrats arrivent à leur terme. Rien n'interdit de créer un contrat en modification.

fn main() {
    let mut toto = Toto {value : 2};
    let toto_ref = &toto;
    lend(toto_ref);
    let toto_ref_mut = &mut toto;
    inc(toto_ref_mut);
}

De tout cela, on peut dégager une règle :

Il peut y avoir autant de références non mutables que l'on veut en même temps OU une unique référence mutable

Le contenu doit survivre au contenant

Depuis que l'on a commencé, on a manipulé une quantité importante de Toto, certains avaient des champs, d'autres non.

Mais tous étaient propriétaires de leurs champs.

Qu'est ce qui se passe si on commence à construire des choses comme :

struct Tata {
    value: u8
}

struct Toto {
    value: &Tata
}

Désormais, le champ Toto::value n'est plus propriétaire de la valeur. Elle vit autre part.

Et donc on se retrouve dans cette situation.

fn main() {
    let tata = Tata {value : 2};
    let toto = Toto {value: &tata};
}

Bon, il est déjà de mauvais poil. 🙄

error[E0106]: missing lifetime specifier
  |
  |     value: &Tata
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
  ~ struct Toto<'a> {
  ~     value: &'a Tata

Mais par contre, il nous mâche tout le boulot. 😁

On fait comme il a dit ^^

struct Toto<'a> {
    value: &'a Tata
}

Bon, l'excitation d'avoir un code qui compile étant retombé, on peut essayer de comprendre ce que l'on vient de faire.

Le Toto<'a> signifie, la structure Toto vivra une durée de vie 'a.

Le &'a signifie lui, la référence sera valide pendant 'a.

Ce qui signifie qu'il ne peut plus rien arriver à tata tant que 'a n'a pas expiré et donc que toto a été drop.

Par exemple, ce code est désormais impossible :

fn main() {
    let mut tata = Tata {value : 2};
    let toto = Toto {value: &tata};
    tata.value = 3;
    println!("Toto = {}", toto.value.value);
}
error[E0506]: cannot assign to `tata.value` because it is borrowed
  --> src\main.rs:24:5
   |
   |     let toto = Toto {value: &tata};
   |                             ----- borrow of `tata.value` occurs here
   |     tata.value = 3;
   |     ^^^^^^^^^^^^^^ assignment to borrowed `tata.value` occurs here
   |     println!("Toto = {}", toto.value.value);
   |                           ---------------- borrow later used here

Pour les mêmes raisons qu'avant, le contrat stipule que tata ne sera pas modifié durant toute la durée du prêt. Or, on rompt le contrat en assignant. Ce qui est interdit !

Le println! final force toto à vivre après l'affectation. Sans lui le code compilerait car toto serait drop juste avant.

De même, ce n'est pas possible de réaliser une référence mutable.

fn main() {
    let mut tata = Tata {value : 2};
    let toto = Toto {value: &tata};
    let tata_ref_mut = &mut tata;
    inc(tata_ref_mut);
    println!("Toto = {}", toto.value.value);
}

fn inc(tata: &mut Tata) {
    tata.value += 1;
}

Ici, il existe une référence à tata dans le contexte de toto qui a encore cours au moment de la rédaction du contrat en mutation. Ce qui est interdit !

error[E0502]: cannot borrow `tata` as mutable because it is also borrowed as immutable
  --> src\main.rs:24:24
   |
   |     let toto = Toto {value: &tata};
   |                             ----- immutable borrow occurs here
   |     let tata_ref_mut = &mut tata;
   |                        ^^^^^^^^^ mutable borrow occurs here
   |     inc(tata_ref_mut);
   |     println!("Toto = {}", toto.value.value);
   |                           ---------------- immutable borrow later used here

Il est par contre tout à fait légitime de créer des références à tata pour peu qu'elles ne soient pas mutables tant que toto vie encore.

fn main() {
    let mut tata = Tata {value : 2};
    let toto = Toto {value: &tata};
    let tata_ref = &tata;
    let tata_ref2 = &tata;
    lend(tata_ref);
    lend(tata_ref2);
    println!("Toto = {}", toto.value.value);
    inc(&mut tata);
}

fn lend(tata: &Tata) {
    println!("Lend!")
}

On peut aussi définir une référence mutable comme champ.

struct Toto<'a> {
    value: &'a mut Tata
}

Alors la règle d'une seule et unique référence s'applique :

Il n'est plus possible de créer de référence vers tata tant que toto vie.

fn main() {
    let mut tata = Tata {value : 2};
    let toto = Toto {value: &mut tata};
    let tata_ref = &tata;
    println!("Toto = {}", toto.value.value);
}
error[E0502]: cannot borrow `tata` as immutable because it is also borrowed as mutable
  --> src\main.rs:24:20
   |
   |     let toto = Toto {value: &mut tata};
   |                             --------- mutable borrow occurs here
   |     let tata_ref = &tata;
   |                    ^^^^^ immutable borrow occurs here
   |     println!("Toto = {}", toto.value.value);
   |                           ---------------- mutable borrow later used here

Ce nouveau borrow ne peut avoir lieu qu'à la mort de toto après le println!.

fn main() {
    let mut tata = Tata {value : 2};
    let toto = Toto {value: &mut tata};
    println!("Toto = {}", toto.value.value);
    lend(&tata)
}

Et finalement, mais ça devient une évidence maintenant.

tata ne peut mourir qu'après toto.

fn main() {
    let mut tata = Tata {value : 2};
    let toto = Toto {value: &mut tata};
    drop(tata);
    println!("Toto = {}", toto.value.value);
}
error[E0505]: cannot move out of `tata` because it is borrowed
   |
   |     let toto = Toto {value: &mut tata};
   |                             --------- borrow of `tata` occurs here
   |     drop(tata);
   |          ^^^^ move out of `tata` occurs here
   |     println!("Toto = {}", toto.value.value);
   |                           ---------------- borrow later used here

Ah oui, j'oubliais le 'static signifie que la référence survie aussi longtemps que le programme. Par défaut toutes les durées de vie ou lifetimes sont 'static. Il n'y a que lorsque Rust a un doute sur ce que veut faire le développeur qu'il impose d'expliciter la lifetime. C'est toujours le cas pour les champs des structures.

Conclusion

Finalement de toute notre épopée, nous pouvons dégager 4 règles :

  • Lorsqu'une variable n'est référencée par personne ou qu'elle atteint la fin de son contexte, elle est drop.
  • Si une variable est déplacée dans un autre contexte, alors la variable sera drop avec les mêmes règles que la première mais dans le nouveau contexte.
  • Toutes les références, à tout moment, doivent être valides !!
  • Il peut exister autant de références à une variable que l'on veut OU une UNIQUE référence mutable

Voilà, en gros les mécanismes qui permettent à Rust de savoir où placer ses free() sans que le développeur ait à se soucier de le faire.

Maintenant, je dois vous avouer une chose, tous les exemples que l'on a réalisés, sont des variables stockés dans la stack. On verra lorsque l'on abordera les chaînes de caractères et les Smart Pointer des données qui seront dans la heap et donc qui seront réellement freed.

Mais toutes les règles énoncées resteront valides.

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.