Propriété Rust

Les programmes informatiques doivent gérer les ressources mémoire qu'ils utilisent à l'exécution.

La plupart des langages de programmation disposent de capacités de gestion de mémoire :

Les langages comme C/C++ gèrent la mémoire principalement par des moyens manuels ; les développeurs doivent allouer et libérer manuellement les ressources mémoire. Cependant, pour améliorer l'efficacité du développement, tant que cela n'affecte pas les fonctionnalités du programme, de nombreux développeurs n'ont pas l'habitude de libérer la mémoire en temps voulu. Par conséquent, la gestion manuelle de la mémoire entraîne souvent un gaspillage de ressources.

Les programmes écrits en Java s'exécutent dans une machine virtuelle (JVM), qui a la capacité de récupérer automatiquement les ressources mémoire. Cependant, cette approche réduit souvent l'efficacité d'exécution, donc la JVM récupère le moins possible de ressources, ce qui fait aussi que les programmes occupent une plus grande quantité de ressources mémoire.

La propriété est un concept nouveau pour la plupart des développeurs ; c'est un mécanisme syntaxique conçu par le langage Rust pour une utilisation efficace de la mémoire. Le concept de propriété a été créé pour permettre à Rust d'analyser plus efficacement l'utilité des ressources mémoire au moment de la compilation, et ainsi de réaliser la gestion de la mémoire.

Règles de propriété

La propriété obéit aux trois règles suivantes :

  • Chaque valeur en Rust possède une variable, appelée son propriétaire.
  • Il ne peut y avoir qu'un seul propriétaire à la fois.
  • Lorsque le propriétaire sort de la portée, la valeur sera libérée.

Ces trois règles constituent le fondement du concept de propriété.

Ensuite, nous présenterons les concepts liés au concept de propriété.

Portée des variables

Nous utilisons le programme suivant pour décrire le concept de portée de variable :

{
    // 在声明以前,变量 s 无效
    let s = "example";
    // 这里是变量 s 的可用范围
}
// 变量范围已经结束,变量 s 无效

La portée d'une variable est un attribut de la variable. Elle représente la région valide de la variable ; par défaut, elle est valide depuis la déclaration de la variable jusqu'à la fin de la portée dans laquelle réside la variable.


Mémoire et allocation

Si nous définissons une variable et lui affectons une valeur, la valeur de la variable réside en mémoire. C'est très courant. Mais si la longueur des données que nous devons stocker est incertaine (par exemple, une chaîne saisie par l'utilisateur), nous ne pouvons pas déterminer la longueur des données au moment de la définition, et nous ne pouvons pas non plus faire en sorte que le programme alloue un espace mémoire de longueur fixe pour le stockage des données au stade de la compilation. (Certains disent qu'allouer autant d'espace que possible peut résoudre le problème, mais cette méthode n'est pas élégante.) Cela nécessite un mécanisme par lequel le programme lui-même demande de la mémoire pendant l'exécution : le tas. Toutes les « ressources mémoire » abordées dans ce chapitre font référence à l'espace mémoire occupé par le tas.

Avec l'allocation vient la désallocation ; un programme ne peut pas continuer à occuper indéfiniment une ressource mémoire. Par conséquent, le facteur clé qui détermine si les ressources sont gaspillées est de savoir si elles sont désallouées en temps voulu.

Écrivons une version C équivalente du programme d'exemple sur les chaînes de caractères :

{
    char *s = strdup("example");
    free(s); // Désallouer la ressource de s
}

De toute évidence, Rust n'appelle pas de fonction free pour libérer les ressources de la chaîne s (je sais que c'est une façon incorrecte de l'écrire en C, car "example" n'est pas sur le tas ; ici nous supposons qu'il l'est). La raison pour laquelle Rust ne montre pas explicitement l'étape de désallocation est que, lorsque la portée de la variable se termine, le compilateur Rust ajoute automatiquement une étape pour appeler la fonction de désallocation de ressource.

Ce mécanisme peut sembler simple : il aide simplement le programmeur à ajouter un appel à la fonction de désallocation de ressource au bon endroit. Mais ce mécanisme simple peut résoudre efficacement l'un des problèmes de programmation les plus épineux de l'histoire pour les programmeurs.


Modes d'interaction entre les variables et les données

Il existe deux principaux modes d'interaction entre les variables et les données : Move et Clone.

Move

Plusieurs variables peuvent interagir avec les mêmes données de différentes manières en Rust :

let x = 5;
let y = x;

Ce programme lie la valeur 5 à la variable x, puis copie la valeur de x et l'assigne à la variable y. Maintenant, il y aura deux valeurs 5 sur la pile. Dans ce cas, les données sont de type « données de base », qui n'ont pas besoin d'être stockées sur le tas. Le « move » pour les données uniquement sur la pile est une copie directe, ce qui ne prend pas plus de temps ni plus d'espace de stockage. Les types de « données de base » sont :

  • Tous les types entiers, tels que i32, u32, i64, etc.
  • Le type booléen bool, avec les valeurs true ou false.
  • Tous les types à virgule flottante, f32 et f64.
  • Le type de caractère char.
  • Les tuples contenant uniquement des données des types ci-dessus.

Mais si les données en interaction se trouvent sur le tas, c'est une situation différente :

let s1 = String::from("hello");
let s2 = s1;

La première étape crée un objet String avec la valeur "hello". Ici, "hello" peut être considéré comme une donnée de longueur incertaine qui doit être stockée sur le tas.

La deuxième étape est légèrement différente (Ce n'est pas tout à fait vrai ; c'est uniquement pour comparaison et référence):

Comme le montre la figure : les deux objets String sont sur la pile, chaque objet String possède un pointeur vers la chaîne "hello" sur le tas. Lors de l'affectation à s2, seules les données sur la pile sont copiées ; la chaîne sur le tas reste la chaîne d'origine.

Nous avons mentionné plus tôt que lorsqu'une variable sort de sa portée, Rust appelle automatiquement la fonction de libération des ressources et nettoie la mémoire du tas de la variable. Mais si s1 et s2 sont tous deux désalloués, le "hello" de la zone du tas serait désalloué deux fois, ce que le système n'autorise pas. Pour garantir la sécurité, s1 devient invalide lors de l'affectation à s2. C'est exact : après avoir affecté la valeur de s1 à s2, s1 ne peut plus être utilisé. Le programme suivant est incorrect :

let s1 = String::from("hello");
let s2 = s1; 
println!("{}, world!", s1); // 错误!s1 已经失效

Ainsi, la situation réelle est :

s1 n'existe que de nom.


Clone

Rust s'efforce de réduire autant que possible les coûts d'exécution du programme, donc par défaut, les données plus volumineuses sont stockées sur le tas, et l'interaction des données utilise la méthode move. Mais si vous devez simplement copier des données pour d'autres usages, vous pouvez utiliser le second mode d'interaction des données : le clonage.

Exemple

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}

Sortie :

s1 = hello, s2 = hello

Ici, le "hello" sur le tas est bien copié, donc s1 et s2 sont chacun liés à une valeur, et lorsqu'ils sont désalloués, ils sont traités comme deux ressources.

Bien sûr, le clonage ne doit être utilisé que lorsqu'une copie est nécessaire, après tout, copier des données prend plus de temps.


Mécanisme de propriété dans les fonctions

C'est la situation la plus complexe pour les variables.

Si une variable est passée comme argument de fonction à une autre fonction, comment gérer la propriété en toute sécurité ?

Le programme suivant décrit comment le mécanisme de propriété fonctionne dans cette situation :

Exemple

fn main() {
    let s = String::from("hello");
    // s est déclaré valide

    takes_ownership(s);
    // la valeur de s est passée en argument à la fonction
    // Donc s peut être considéré comme déplacé, et il n'est plus valide à partir d'ici

    let x = 5;
    // x est déclaré valide

    makes_copy(x);
    // La valeur de x est passée comme argument à la fonction
    // Mais x est un type de base, donc il reste valide
    // Ici, nous pouvons encore utiliser x mais pas s

} // La fonction se termine, x devient invalide, puis s. Mais s a été déplacé, donc il n'a pas besoin d'être désalloué


fn takes_ownership(some_string: String) {
    // Un argument String some_string est passé en entrée, valide
    println!("{}", some_string);
} // La fonction se termine, l'argument some_string est désalloué ici

fn makes_copy(some_integer: i32) {
    // Un argument i32 some_integer est passé en entrée, valide
    println!("{}", some_integer);
} // La fonction se termine, l'argument some_integer est un type de base, pas besoin de désallouer

Si une variable est passée en argument à une fonction, cela a le même effet qu'un déplacement.

Mécanisme de propriété des valeurs de retour de fonction

Exemple

fn main() {
    let s1 = gives_ownership();
    // gives_ownership déplace sa valeur de retour vers s1

    let s2 = String::from("hello");
    // s2 est déclarée valide

    let s3 = takes_and_gives_back(s2);
    // s2 est déplacée en tant qu'argument, s3 acquiert la propriété de la valeur de retour
} // s3 est invalide et désallouée, s2 est déplacée, s1 est invalide et désallouée.

fn gives_ownership() -> String {
    let some_string = String::from("hello");
    // some_string est déclarée valide

    return some_string;
    // some_string est déplacé hors de la fonction comme valeur de retour
}

fn takes_and_gives_back(a_string: String) -> String { 
    // a_string est déclaré valide

    a_string  // a_string est déplacé hors de la fonction comme valeur de retour
}

La propriété d'une variable utilisée comme valeur de retour d'une fonction est déplacée hors de la fonction et renvoyée vers le lieu d'appel, plutôt que d'être invalidée et désallouée directement.


Références et emprunts

La référence est un concept familier aux développeurs C++.

Si vous connaissez les pointeurs, vous pouvez le considérer comme une sorte de pointeur.

Par essence, une « référence » est un moyen indirect d'accéder à une variable.

Exemple

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    println!("s1 is {}, s2 is {}", s1, s2);
}

Sortie :

s1 is hello, s2 is hello

&L'opérateur peut prendre une « référence » sur une variable.

Lorsque la valeur d'une variable est référencée, la variable elle-même n'est pas considérée comme invalide. En effet, une « référence » ne copie pas la valeur de la variable sur la pile :

Le même principe s'applique au passage de paramètres de fonction :

Exemple

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("La longueur de '{}' est {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Sortie :

The length of 'hello' is 5.

Les références n'acquièrent pas la propriété d'une valeur.

Les références ne peuvent qu'emprunter la propriété d'une valeur.

Une référence est elle aussi un type et possède une valeur. Cette valeur enregistre l'emplacement d'une autre valeur, mais la référence ne possède pas la valeur référencée :

Exemple

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    let s3 = s1;
    println!("{}", s2);
}

Ce programme est incorrect : parce que s1, que s2 a emprunté, a déjà transféré sa propriété à s3, s2 ne pourra plus continuer à emprunter la propriété de s1. Si s2 a besoin d'utiliser cette valeur, il doit réemprunter :

Exemple

fn main() {
    let s1 = String::from("hello");
    let mut s2 = &s1;
    let s3 = s1;
    s2 = &s3; // Réemprunter la propriété de s3
    println!("{}", s2);
}

Ce programme est correct.

Comme les références n'ont pas la propriété, même si elles empruntent la propriété, elles ne jouissent que de droits d'utilisation (c'est la même chose que louer une maison).

Si vous tentez d'utiliser le droit emprunté pour modifier des données, cela sera empêché :

Exemple

fn main() {
    let s1 = String::from("run");
    let s2 = &s1;
    println!("{}", s2);
    s2.push_str("oob"); // Erreur : la modification de la valeur empruntée est interdite
    println!("{}", s2);
}

Dans ce programme, la tentative de s2 de modifier la valeur de s1 est empêchée ; une propriété empruntée ne peut pas modifier la valeur de son propriétaire.

Bien sûr, il existe aussi une méthode d'emprunt mutable. Tout comme lorsque vous louez une maison, si le règlement de la propriété permet au propriétaire de modifier la structure de la maison, et que le propriétaire déclare aussi dans le contrat que ce droit vous est accordé pendant la location, vous pouvez rénover la maison :

Exemple

fn main() {
    let mut s1 = String::from("run");
    // s1 est mutable

    let s2 = &mut s1;
    // s2 est une référence mutable

    s2.push_str("oob");
    println!("{}", s2);
}

Ce programme est correct. Nous utilisons `&mut` pour désigner les types de référence mutables.

Par rapport aux références immuables, outre des permissions différentes, les références mutables n'autorisent pas plusieurs références, tandis que les références immuables le permettent :

Exemple

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

Ce programme est incorrect car s a plusieurs références mutables.

La conception des références mutables de Rust est principalement motivée par des considérations de collisions d'accès aux données en concurrence, afin d'empêcher que de telles choses ne se produisent au stade de la compilation.

Comme l'une des conditions nécessaires à une collision d'accès aux données est que les données soient écrites par au moins un utilisateur et simultanément lues ou écrites par au moins un autre utilisateur, une valeur ne peut être référencée par aucune autre référence tant qu'elle fait l'objet d'une référence mutable.

Références pendantes

Il s'agit d'un concept qui porte un autre nom. Dans les langages de programmation qui ont la notion de pointeurs, il désigne un pointeur qui ne pointe pas réellement vers des données véritablement accessibles (note : il ne s'agit pas nécessairement d'un pointeur nul ; cela peut aussi être une ressource déjà libérée). Elles sont comme des cordes qui ont perdu l'objet auquel elles sont suspendues, d'où le nom « référence pendante ».

Les « références pendantes » ne sont pas autorisées en Rust ; si elles existent, le compilateur les détectera.

Voici un exemple typique de référence pendante :

Exemple

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

Évidemment, lorsque la fonction `dangle` se termine, la valeur de sa variable locale n'est pas utilisée comme valeur de retour et est libérée. Cependant, c'est sa référence qui est renvoyée à la place. La valeur pointée par cette référence ne peut plus être confirmée comme existante, donc ce n'est pas autorisé.

Autres extensions