Drop, référence et borrow checker
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.
int
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.
void
int
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 :
La même chose, mais en prenant soin de libérer la mémoire
void
int
Cette fois-ci, aucun souci ✅
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.
;
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 😄.
;
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.
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.
;
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.
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.
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 Borrow;
Il est possible d'écrire exactement la même chose en utilisant une autre syntaxe :
Ce qui est important de voir ici, c'est l'utilisation du
&
devanttoto
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.
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.
Une autre manière de faire est de borrow de manière différenciée à chaque appel à lend.
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
.
Et définissons une méthode lend_and_display
qui va avoir pour tâche d'afficher le résultat.
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 BorrowMut;
Soit via la syntaxe:
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
.
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.
Soit en éditant plusieurs.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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é.
De même, dès que tous les contrats arrivent à leur terme. Rien n'interdit de créer un contrat en modification.
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 :
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.
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 ^^
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 :
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.
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.
On peut aussi définir une référence mutable comme champ.
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.
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!
.
Et finalement, mais ça devient une évidence maintenant.
tata
ne peut mourir qu'après toto
.
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.
Ce travail est sous licence CC BY-NC-SA 4.0.