L'ownership en Rust, vu à travers le C
Comprendre l'ownership Rust en le rapprochant du C : la structure d'une String, malloc/realloc/free, et pourquoi le move évite le double free.
Le principe de l’ownership
- chaque valeur a une variable qui s’appelle son propriétaire
- il ne peut y avoir à la fois qu’un unique propriétaire
- quand le propriétaire sortira de sa portée (son contexte), la valeur sera supprimée
Voyons la possession avec le type String.
Le type String
Le type String est un type de données complexe en Rust, différent des types de base (i32, u32, usize, isize, f32, f64…) et de str.
Petite précision sur
str: c’est un type non-dimensionné (on ne connaît pas sa taille à la compilation), donc on le manipule presque toujours derrière une référence,&str— c’est ce qu’on appelle un littéral de chaîne.String, elle, est allouée sur le tas et redimensionnable.
Le type String est défini par une structure qui comprend 3 éléments :
- un pointeur vers la heap
- sa capacité
- la taille de la chaîne
La capacité est le nombre d’octets total que la
Stringpeut occuper en mémoire. Elle diffère de la taille car elle peut valoir jusqu’à 2× plus qu’elle.
Pour créer une string on utilise new() ou from() :
new(): crée une chaîne vide
from(): crée une chaîne à partir d’unstr(nb :str!=String)
Exemple :
1
2
3
4
5
6
7
fn main() {
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() ajoute un littéral de chaîne dans une String
println!("{}", s); // Cela va afficher `hello, world!`
}
Voyons à peu près ce qui se passe en C et saisissons la différence entre cap & len. push_str() peut faire une ré-allocation de la heap :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stdlib.h>
#include <string.h>
struct String {
char *ptr;
size_t cap;
size_t len;
}; // ici on a la représentation de la structure String
// 1. String::from("hello")
struct String s; // la string sur la pile ; 24 octets en mémoire sur une arch 64 bits
s.len = 5;
s.cap = 5;
s.ptr = (char*)malloc(s.cap); // Allocation sur le tas
memcpy(s.ptr, "hello", 5);
// 2. s.push_str(", world!")
size_t new_len = s.len + 8;
if (new_len > s.cap) {
s.cap = new_len * 2; // ici la capacité, donc cap != len
s.ptr = (char*)realloc(s.ptr, s.cap); // on réalloue la mémoire avec realloc
}
memcpy(s.ptr + s.len, ", world!", 8);
s.len = new_len;
// 3. À la fin du scope (le "Drop" auto de Rust)
free(s.ptr);
Note : c’est pas vraiment ce qui se passe, mais plutôt un rapprochement.
Détail bas niveau qui a son importance :
reallocpeut renvoyer un pointeur différent de celui passé en argument. Si la zone ne peut pas être agrandie sur place, l’allocateur copie les données ailleurs et te rend une nouvelle adresse — d’où le fait qu’on réassigne toujourss.ptr = realloc(s.ptr, ...).
Move, Clone, Copy
1
2
3
4
fn main() {
let x = 5;
let y = x;
}
1
2
3
4
fn main() {
let s1 = String::from("hello");
let s2 = s1;
}
1
2
3
4
5
6
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
}
Ici dans le premier cas on effectue une copie, et les variables x et y sont sur la pile.
y = x fait une copie de la valeur de x et l’affecte à y (pas d’allocation, car Rust utilise la pile pour les types simples).
Dans le second cas c’est un peu différent.
Dans le second cas, à la ligne 2 on définit
s1dans sa portée (main), et elle devient inutilisable après la ligne 3 : car lorsqu’on faits2 = s1on passe às2le même pointeur qui pointe vers les données sur la heap, donc on a 2 éléments qui peuvent accéder à une même ressource en mémoire.Dans ce cas, que se passerait-il si on sort de la portée et que la fonction
dropessaie de libérer la mémoire des1ets2?On aurait une vulnérabilité mémoire qui s’appelle le double free : en gros on essaie de libérer 2 fois la même ressource, ce qui corrompt les structures internes de l’allocateur (heap).
Donc voilà pourquoi il faut bien comprendre ce point. Rust résout ça en invalidant
s1au moment dus2 = s1: c’est un move, pas une copie.s1n’est plus utilisable, un seul propriétaire reste, un seulfree.
Et pour le 3ᵉ cas, ici on réalise une copie profonde (copier non seulement les données mais aussi avoir un autre pointeur qui pointe vers une adresse différente de la heap). On utilise la fonction
clone, et avec ça on a 2 chaînes différentes (une copie de la 1ʳᵉ mais avec des adresses différentes).
Possession et fonction
Transfert de propriété : pour les paramètres et les retours de fonctions, il y a un transfert de possession qui est effectué. Exemple :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
fn main() {
let s = String::from("hello");
prendre_possession(s); // On donne 's' à la fonction
// println!("{}", s); // ERREUR : 's' n'est plus à nous.
// Elle a été "déplacée" (move) dans la fonction.
}
fn prendre_possession(texte: String) {
println!("{}", texte);
} // <--- 'texte' sort de la portée ici, Rust appelle 'free()' !
// ______________________________________________________________________
fn main() {
let s1 = String::from("hello");
let s2 = redonner_possession(s1); // s1 va dans la fonction, le retour va dans s2
println!("{}", s2); // On peut utiliser s2
}
fn redonner_possession(texte: String) -> String {
println!("{}", texte);
texte // On retourne la possession au parent
}
En gros, on déplace la variable dans la nouvelle portée (stack frame, avec un move) et le compilateur invalide l’étiquette de l’ancienne portée — comme ça, pas de double free ni de use-after-free.