La propriété et l'emprunt de Rust peuvent être déroutants si nous ne comprenons pas ce qui se passe réellement. Cela est particulièrement vrai lors de l'application d'un style de programmation appris précédemment à un nouveau paradigme ; nous appelons cela un changement de paradigme. La propriété est une idée nouvelle, mais difficile à comprendre au début, mais elle devient plus facile à mesure que nous y travaillons.
Avant d'aller plus loin sur la propriété et les emprunts de Rust, commençons par comprendre ce que sont la "sécurité de la mémoire" et la "fuite de mémoire" et comment les langages de programmation les gèrent.
La sécurité de la mémoire fait référence à l'état d'une application logicielle dans laquelle les pointeurs ou références de mémoire font toujours référence à une mémoire valide. Étant donné que la corruption de la mémoire est une possibilité, il existe très peu de garanties quant au comportement d'un programme s'il n'est pas sécurisé en mémoire. En termes simples, si un programme n'est pas vraiment sécurisé en mémoire, il y a peu de garanties quant à sa fonctionnalité. Lorsqu'il s'agit d'un programme dangereux pour la mémoire, une partie malveillante est capable d'utiliser la faille pour lire des secrets ou exécuter du code arbitraire sur la machine de quelqu'un d'autre.
Utilisons un pseudocode pour voir ce qu'est une mémoire valide.
// pseudocode #1 - shows valid reference { // scope starts here int x = 5 int y = &x } // scope ends here
Dans le pseudocode ci-dessus, nous avons créé une variable x
affectée d'une valeur de 10
. Nous utilisons l'opérateur &
ou le mot-clé pour créer une référence. Ainsi, la syntaxe &x
nous permet de créer une référence qui fait référence à la valeur de x
. Pour le dire simplement, nous avons créé une variable x
qui possède 5
et une variable y
qui est une référence à x
.
Étant donné que les deux variables x
et y
se trouvent dans le même bloc ou la même portée, la variable y
a une référence valide qui fait référence à la valeur de x
. Par conséquent, la variable y
vaut 5
.
Jetez un œil au pseudo-code ci-dessous. Comme nous pouvons le voir, la portée de x
est limitée au bloc dans lequel il est créé. Nous entrons dans des références pendantes lorsque nous essayons d'accéder à x
en dehors de sa portée. Référence en suspens… ? Qu'est-ce que c'est exactement ?
// pseudocode #2 - shows invalid reference aka dangling reference { // scope starts here int x = 5 } // scope ends here int y = &x // can't access x from here; creates dangling reference
Une référence pendante est un pointeur qui pointe vers un emplacement mémoire qui a été donné à quelqu'un d'autre ou libéré (libéré). Si un programme (alias process ) fait référence à de la mémoire qui a été libérée ou effacée, il peut se bloquer ou provoquer des résultats non déterministes.
Cela dit, l'insécurité de la mémoire est une propriété de certains langages de programmation qui permet aux programmeurs de traiter des données invalides. En conséquence, l'insécurité de la mémoire a introduit une variété de problèmes pouvant entraîner les principales vulnérabilités de sécurité suivantes :
Les vulnérabilités causées par l'insécurité de la mémoire sont à l'origine de nombreuses autres menaces de sécurité graves. Malheureusement, découvrir ces vulnérabilités peut être extrêmement difficile pour les développeurs.
Il est important de comprendre ce qu'est une fuite de mémoire et quelles en sont les conséquences.
Une fuite de mémoire est une forme involontaire de consommation de mémoire dans laquelle le développeur ne parvient pas à libérer un bloc alloué de mémoire de tas lorsqu'il n'est plus nécessaire. C'est tout simplement le contraire de la sécurité de la mémoire. Plus d'informations sur les différents types de mémoire plus tard, mais pour l'instant, sachez simplement qu'une pile stocke des variables de longueur fixe connues au moment de la compilation, alors que la taille des variables susceptibles de changer ultérieurement lors de l'exécution doit être placée sur le tas .
Par rapport à l'allocation de mémoire de tas, l'allocation de mémoire de pile est considérée comme plus sûre car la mémoire est automatiquement libérée lorsqu'elle n'est plus pertinente ou nécessaire, soit par le programmeur, soit par l'exécution du programme lui-même.
Cependant, lorsque les programmeurs génèrent de la mémoire sur le tas et ne parviennent pas à la supprimer en l'absence d'un ramasse-miettes (dans le cas de C et C++), une fuite de mémoire se développe. De plus, si nous perdons toutes les références à un morceau de mémoire sans désallouer cette mémoire, nous avons une fuite de mémoire. Notre programme continuera à posséder cette mémoire, mais il n'a aucun moyen de l'utiliser à nouveau.
Une petite fuite de mémoire n'est pas un problème, mais si un programme alloue une plus grande quantité de mémoire et ne la libère jamais, l'empreinte mémoire du programme continuera d'augmenter, entraînant un déni de service.
Lorsqu'un programme se termine, le système d'exploitation récupère immédiatement toute la mémoire qu'il possède. Par conséquent, une fuite de mémoire n'affecte un programme qu'en cours d'exécution ; il n'a aucun effet une fois le programme terminé.
Passons en revue les principales conséquences des fuites de mémoire.
Les fuites de mémoire réduisent les performances de l'ordinateur en réduisant la quantité de mémoire disponible (mémoire de tas). Cela finit par entraîner l'arrêt du fonctionnement correct de l'ensemble ou d'une partie du système ou un ralentissement important. Les plantages sont généralement liés à des fuites de mémoire.
Notre approche pour déterminer comment prévenir les fuites de mémoire variera en fonction du langage de programmation que nous utilisons. Les fuites de mémoire peuvent commencer comme un petit problème presque invisible, mais elles peuvent s'aggraver très rapidement et submerger les systèmes qu'elles affectent. Dans la mesure du possible, nous devrions être à l'affût d'eux et prendre des mesures pour les rectifier plutôt que de les laisser se développer.
Les fuites de mémoire et l'insécurité de la mémoire sont les deux types de problèmes qui ont reçu la plus grande attention en termes de prévention et de résolution. Il est important de noter que réparer l'un ne résout pas automatiquement l'autre.
Avant d'aller plus loin, il est important de comprendre les différents types de mémoire que notre code utilisera lors de l'exécution.
Il existe deux types de mémoire, comme suit, et ces mémoires sont structurées différemment.
Registre du processeur
Statique
Empiler
Tas
Les types de registre de processeur et de mémoire statique dépassent le cadre de cet article.
La pile stocke les données dans l'ordre dans lequel elles sont reçues et les supprime dans l'ordre inverse. Les éléments sont accessibles à partir de la pile dans l'ordre dernier entré, premier sorti (LIFO). L'ajout de données sur la pile s'appelle «pousser» et supprimer des données de la pile s'appelle «sauter».
Toutes les données stockées sur la pile doivent avoir une taille fixe connue. Les données dont la taille est inconnue au moment de la compilation ou dont la taille pourrait changer ultérieurement doivent être stockées sur le tas à la place.
En tant que développeurs, nous n'avons pas à nous soucier de l' allocation et de la désallocation de la mémoire de la pile ; l'allocation et la désallocation de la mémoire de la pile sont "automatiquement effectuées" par le compilateur. Cela implique que lorsque les données sur la pile ne sont plus pertinentes (hors champ), elles sont automatiquement supprimées sans que nous ayons besoin de notre intervention.
Ce type d'allocation de mémoire est également connu sous le nom d'allocation de mémoire temporaire , car dès que la fonction termine son exécution, toutes les données appartenant à cette fonction sont supprimées de la pile "automatiquement".
Tous les types primitifs de Rust vivent sur la pile. Les types tels que les nombres, les caractères, les tranches, les booléens, les tableaux de taille fixe, les tuples contenant des primitives et les pointeurs de fonction peuvent tous s'asseoir sur la pile.
Contrairement à une pile, lorsque nous mettons des données sur le tas, nous demandons une certaine quantité d'espace. L'allocateur de mémoire localise un emplacement inoccupé suffisamment grand dans le tas, le marque comme étant utilisé et renvoie une référence à l'adresse de cet emplacement. C'est ce qu'on appelle l' allocation .
L'allocation sur le tas est plus lente que la poussée vers la pile car l'allocateur n'a jamais à rechercher un emplacement vide pour mettre de nouvelles données. De plus, comme nous devons suivre un pointeur pour accéder aux données sur le tas, c'est plus lent que d'accéder aux données sur la pile. Contrairement à la pile, qui est allouée et désallouée au moment de la compilation, la mémoire du tas est allouée et désallouée lors de l'exécution des instructions d'un programme.
Dans certains langages de programmation, pour allouer de la mémoire de tas, nous utilisons le mot-clé new
. Ce new
mot-clé (alias operator ) indique une demande d'allocation de mémoire sur le tas. Si suffisamment de mémoire est disponible sur le tas, le new
opérateur initialise la mémoire et renvoie l'adresse unique de cette mémoire nouvellement allouée.
Il convient de mentionner que la mémoire de tas est "explicitement" désallouée par le programmeur ou le runtime.
En ce qui concerne la gestion de la mémoire, en particulier la mémoire de tas, nous préférerions que nos langages de programmation aient les caractéristiques suivantes :
La sécurité de la mémoire est assurée de différentes manières par les langages de programmation au moyen de :
La gestion de la mémoire basée sur les régions et les systèmes de type linéaire dépassent le cadre de cet article.
Les programmeurs doivent libérer ou effacer « manuellement » la mémoire allouée lors de l'utilisation de la gestion de la mémoire explicite. Un opérateur de « désallocation » (par exemple, delete
en C) existe dans les langages avec une désallocation de mémoire explicite.
La récupération de place est trop coûteuse dans les langages système tels que C et C++, par conséquent, l'allocation de mémoire explicite continue d'exister.
Laisser la responsabilité de libérer de la mémoire au programmeur a l'avantage de donner au programmeur un contrôle total sur le cycle de vie de la variable. Cependant, si les opérateurs de désallocation ne sont pas utilisés correctement, une erreur logicielle peut survenir lors de l'exécution. En fait, ce processus manuel d'allocation et de libération est sujet aux erreurs. Certaines erreurs de codage courantes incluent :
Malgré cela, nous avons préféré la gestion manuelle de la mémoire à la récupération de place car elle nous donne plus de contrôle et offre de meilleures performances. Notez que le but de tout langage de programmation système est de se rapprocher le plus possible du métal. En d'autres termes, ils favorisent de meilleures performances par rapport aux fonctionnalités de commodité dans le compromis.
Il est de notre entière responsabilité (aux développeurs) de nous assurer qu'aucun pointeur vers la valeur que nous avons libérée n'est jamais utilisé.
Dans un passé récent, il y a eu plusieurs modèles éprouvés pour éviter ces erreurs, mais tout se résume à maintenir une discipline de code rigoureuse, ce qui nécessite d'appliquer systématiquement la bonne méthode de gestion de la mémoire.
Les principaux plats à emporter sont :
La gestion automatique de la mémoire est devenue une caractéristique essentielle de tous les langages de programmation modernes, y compris Java.
Dans le cas d'une désallocation automatique de mémoire, les ramasse-miettes servent de gestionnaires de mémoire automatiques. Ces récupérateurs de mémoire parcourent périodiquement le tas et recyclent les morceaux de mémoire qui ne sont pas utilisés. Ils gèrent l'allocation et la libération de la mémoire en notre nom. Nous n'avons donc pas besoin d'écrire de code pour effectuer des tâches de gestion de la mémoire. C'est très bien puisque les ramasse-miettes nous libèrent de la responsabilité de la gestion de la mémoire. Un autre avantage est qu'il réduit le temps de développement.
Le ramassage des ordures, en revanche, présente un certain nombre d'inconvénients. Pendant la récupération de place, le programme doit s'arrêter et passer du temps à déterminer ce qu'il doit nettoyer avant de continuer.
De plus, la gestion automatique de la mémoire a des besoins en mémoire plus importants. Cela est dû au fait qu'un ramasse-miettes effectue pour nous une désallocation de mémoire, ce qui consomme à la fois de la mémoire et des cycles CPU. Par conséquent, la gestion automatisée de la mémoire peut dégrader les performances des applications, en particulier dans les applications volumineuses aux ressources limitées.
Les principaux plats à emporter sont :
Certains langages proposent un garbage collection , qui recherche la mémoire qui n'est plus utilisée pendant l'exécution du programme ; d'autres exigent que le programmeur alloue et libère explicitement de la mémoire . Ces deux modèles présentent des avantages et des inconvénients. Le ramassage des ordures, bien que peut-être le plus largement utilisé, présente certains inconvénients ; il facilite la vie des développeurs au détriment des ressources et des performances.
Cela dit, l'un donne un contrôle efficace de la gestion de la mémoire, tandis que l'autre offre une plus grande sécurité en éliminant les références en suspens et les fuites de mémoire. Rust combine les avantages des deux mondes.
Rust adopte une approche différente des deux autres, basée sur un modèle de propriété avec un ensemble de règles que le compilateur vérifie pour assurer la sécurité de la mémoire. Le programme ne compilera pas si l'une de ces règles n'est pas respectée. En fait, la propriété remplace la récupération de place à l'exécution par des vérifications au moment de la compilation pour la sécurité de la mémoire.
Il faut un certain temps pour s'habituer à la propriété car c'est un nouveau concept pour de nombreux programmeurs, comme moi.
À ce stade, nous avons une compréhension de base de la façon dont les données sont stockées en mémoire. Regardons de plus près la propriété dans Rust. La plus grande caractéristique distinctive de Rust est la propriété, qui garantit la sécurité de la mémoire au moment de la compilation.
Pour commencer, définissons la « propriété » dans son sens le plus littéral. La propriété est l'état de « posséder » et de « contrôler » la possession légale de « quelque chose ». Cela dit, nous devons identifier qui est le propriétaire et ce que le propriétaire possède et contrôle . Dans Rust, chaque valeur a une variable appelée son propriétaire . Pour le dire simplement, une variable est un propriétaire, et la valeur d'une variable est ce que le propriétaire possède et contrôle.
Avec un modèle de propriété, la mémoire est automatiquement libérée (libérée) une fois que la variable qui la possède sort de la portée. Lorsque les valeurs sortent de la portée ou que leur durée de vie se termine pour une autre raison, leurs destructeurs sont appelés. Un destructeur, en particulier un destructeur automatisé, est une fonction qui supprime les traces d'une valeur du programme en supprimant des références et libère de la mémoire.
Rust implémente la propriété via le vérificateur d'emprunt , un
Comme indiqué précédemment, le modèle de propriété repose sur un ensemble de règles appelées règles de propriété , et ces règles sont relativement simples. Le compilateur Rust (rustc) applique ces règles :
Les erreurs de mémoire suivantes sont protégées par ces règles de propriété de vérification au moment de la compilation :
Avant d'entrer dans les détails de chaque règle de propriété, il est important de comprendre les distinctions entre copy , move et clone .
Un type avec une taille fixe (en particulier les types primitifs) peut être stocké sur la pile et supprimé lorsque sa portée se termine, et peut être rapidement et facilement copié pour créer une nouvelle variable indépendante si une autre partie du code nécessite la même valeur dans une portée différente. Parce que copier la mémoire de la pile est bon marché et rapide, on dit que les types primitifs avec une taille fixe ont une sémantique de copie . Il crée à moindre coût une réplique parfaite (un doublon).
Il convient de noter que les types primitifs à taille fixe implémentent le trait de copie pour faire des copies.
let x = "hello"; let y = x; println!("{}", x) // hello println!("{}", y) // hello
Dans Rust, il existe deux types de chaînes :
String
(heap alloué et extensible) et&str
(taille fixe et ne peut pas être muté).
Parce que x
est stocké sur la pile, copier sa valeur pour produire une autre copie pour y
est plus facile. Ce n'est pas le cas pour une valeur qui est stockée sur le tas. Voici à quoi ressemble le cadre de la pile :
La duplication des données augmente la durée d'exécution du programme et la consommation de mémoire. Par conséquent, la copie n'est pas adaptée aux gros volumes de données.
Dans la terminologie Rust, "déplacer" signifie que la propriété de la mémoire est transférée à un autre propriétaire. Prenons le cas des types complexes qui sont stockés sur le tas.
let s1 = String::from("hello"); let s2 = s1;
Nous pourrions supposer que la deuxième ligne (ie let s2 = s1;
) ferait une copie de la valeur dans s1
et la lierait à s2
. Mais ce n'est pas le cas.
Jetez un œil à celui ci-dessous pour voir ce qui arrive à String
sous le capot. Une chaîne est composée de trois parties, qui sont stockées sur la pile . Le contenu réel (bonjour, dans ce cas) est stocké sur le tas .
String
utilise actuellement.String
a reçue de l'allocateur.
En d'autres termes, les métadonnées sont conservées sur la pile tandis que les données réelles sont conservées sur le tas.
Lorsque nous attribuons s1
à s2
, les métadonnées String
sont copiées, ce qui signifie que nous copions le pointeur, la longueur et la capacité qui se trouvent sur la pile. Nous ne copions pas les données sur le tas auquel le pointeur fait référence. La représentation des données en mémoire ressemble à celle ci-dessous :
Il convient de noter que la représentation ne ressemble pas à celle ci-dessous, à quoi ressemblerait la mémoire si Rust copiait également les données du tas. Si Rust effectuait cela, l'opération s2 = s1
pourrait être extrêmement lente en termes de performances d'exécution si les données du tas étaient volumineuses.
Notez que lorsque les types complexes ne sont plus dans la portée, Rust appellera la fonction drop
pour libérer explicitement la mémoire du tas. Cependant, les deux pointeurs de données de la figure 6 pointent vers le même emplacement, ce qui n'est pas la façon dont Rust fonctionne. Nous entrerons dans les détails sous peu.
Comme indiqué précédemment, lorsque nous attribuons s1
à s2
, la variable s2
reçoit une copie des métadonnées de s1
(pointeur, longueur et capacité). Mais qu'arrive-t-il à s1
une fois qu'il a été assigné à s2
? Rust ne considère plus s1
comme valide. Oui, vous avez bien lu.
Réfléchissons un instant à cette affectation let s2 = s1
. Considérez ce qui se passe si Rust considère toujours s1
comme valide après cette affectation. Lorsque s2
et s1
sortent de la portée, ils essaieront tous les deux de libérer la même mémoire. Oh-oh, ce n'est pas bon. C'est ce qu'on appelle une double erreur gratuite et c'est l'un des bogues de sécurité de la mémoire. La corruption de la mémoire peut résulter d'une double libération de la mémoire, ce qui présente un risque pour la sécurité.
Pour assurer la sécurité de la mémoire, Rust a considéré s1
invalide après la ligne let s2 = s1
. Par conséquent, lorsque s1
n'est plus dans la portée, Rust n'a pas besoin de publier quoi que ce soit. Examinez ce qui se passe si nous essayons d'utiliser s1
après la création de s2
.
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.
Nous aurons une erreur comme celle ci-dessous car Rust vous empêche d'utiliser la référence invalidée :
$ cargo run Compiling playground v0.0.1 (/playground) error[E0382]: borrow of moved value: `s1` --> src/main.rs:6:28 | 3 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait 4 | let s2 = s1; | -- value moved here 5 | 6 | println!("{}, world!", s1); | ^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info) For more information about this error, try `rustc --explain E0382`.
Comme Rust a "déplacé" la propriété de la mémoire de s1
vers s2
après la ligne let s2 = s1
, il a considéré s1
invalide. Voici la représentation en mémoire après l'invalidation de s1 :
Lorsque seul s2
reste valide, c'est lui seul qui libère la mémoire lorsqu'il sort de la portée. En conséquence, le potentiel d'une double erreur gratuite est éliminé dans Rust. C'est merveilleux!
Si nous voulons copier en profondeur les données de tas de String
, pas seulement les données de pile, nous pouvons utiliser une méthode appelée clone
. Voici un exemple d'utilisation de la méthode clone :
let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2);
Lors de l'utilisation de la méthode de clonage, les données du tas sont copiées dans s2. Cela fonctionne parfaitement et produit le comportement suivant :
L'utilisation de la méthode du clone a de graves conséquences ; non seulement il copie les données, mais il ne synchronise pas non plus les modifications entre les deux. En général, les clones doivent être planifiés avec soin et en pleine conscience des conséquences.
À présent, nous devrions être en mesure de faire la distinction entre copier, déplacer et cloner. Examinons chaque règle de propriété plus en détail maintenant.
Chaque valeur a une variable appelée son propriétaire. Cela implique que toutes les valeurs appartiennent à des variables. Dans l'exemple ci-dessous, la variable s
possède le pointeur vers notre chaîne, et dans la deuxième ligne, la variable x
possède une valeur 1.
let s = String::from("Rule 1"); let n = 1;
Il ne peut y avoir qu'un seul propriétaire d'une valeur à un moment donné. On peut avoir plusieurs animaux de compagnie, mais en ce qui concerne le modèle de propriété, il n'y a qu'une seule valeur à un moment donné :-)
Regardons l'exemple utilisant des primitives , dont la taille est connue au moment de la compilation.
let x = 10; let y = x; let z = x;
Nous avons pris 10 et l'avons affecté à x
; en d'autres termes, x
possède 10. Ensuite, nous prenons x
et l'attribuons à y
et nous l'attribuons également à z
. Nous savons qu'il ne peut y avoir qu'un seul propriétaire à un moment donné, mais nous n'obtenons aucune erreur ici. Donc, ce qui se passe ici, c'est que le compilateur fait des copies de x
chaque fois que nous l'assignons à une nouvelle variable.
Le cadre de pile pour cela serait le suivant : x = 10
, y = 10
et z = 10
. Ceci, cependant, ne semble pas être le cas car ceci : x = 10
, y = x
et z = x
. Comme nous le savons, x
est le seul propriétaire de cette valeur 10, et ni y
ni z
ne peuvent posséder cette valeur.
Parce que la copie de la mémoire de la pile est bon marché et rapide, les types primitifs avec une taille fixe sont dits avoir une sémantique de copie , tandis que les types complexes changent de propriété, comme indiqué précédemment. Ainsi, dans ce cas, le compilateur effectue les copies .
A ce stade, le comportement de
Examinons les données stockées sur le tas et voyons comment Rust comprend quand les nettoyer ; le type String est un excellent exemple pour ce cas d'utilisation. Nous nous concentrerons sur le comportement lié à la propriété de String ; cependant, ces principes s'appliquent également à d'autres types de données complexes.
Le type complexe, comme nous le savons, gère les données sur le tas, et son contenu est inconnu au moment de la compilation. Regardons le même exemple que nous avons vu auparavant :
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.
Dans le cas du type
String
, la taille peut s'étendre et être stockée sur le tas. Ça signifie:
- Au moment de l'exécution, la mémoire doit être demandée à l'allocateur de mémoire (appelons-le première partie).
- Lorsque nous avons fini d'utiliser notre
String
, nous devons renvoyer (libérer) cette mémoire à l'allocateur (appelons-la deuxième partie).
Nous (les développeurs) nous sommes occupés de la première partie : lorsque nous appelons
String::from
, son implémentation demande la mémoire dont elle a besoin. Cette partie est presque commune à tous les langages de programmation.
Cependant, la deuxième partie est différente. Dans les langages avec un ramasse-miettes (GC), le GC garde une trace et nettoie la mémoire qui n'est plus utilisée, et nous n'avons pas à nous en soucier. Dans les langages sans ramasse-miettes, il est de notre responsabilité d'identifier quand la mémoire n'est plus nécessaire et d'exiger qu'elle soit explicitement libérée. Cela a toujours été une tâche de programmation difficile de le faire correctement :
- Nous perdrons de la mémoire si nous oublions.
- Nous aurons une variable invalide si nous le faisons trop tôt.
- Nous aurons un bogue si nous le faisons deux fois.
Rust gère la désallocation de mémoire d'une nouvelle manière pour nous faciliter la vie : la mémoire est automatiquement renvoyée une fois que la variable qui la possède est hors de portée.
Revenons aux affaires. Dans Rust, pour les types complexes, les opérations telles que l'attribution d'une valeur à une variable, sa transmission à une fonction ou son retour depuis une fonction ne copient pas la valeur : elles la déplacent. Pour le dire simplement, les types complexes déplacent la propriété.
Lorsque les types complexes ne sont plus dans la portée, Rust appellera la fonction drop pour libérer explicitement la mémoire du tas.
Lorsque le propriétaire sort de la portée, la valeur est supprimée. Reprenons le cas précédent :
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. The value of s1 has already been dropped.
La valeur de s1
a chuté après l'affectation de s1
à s2
(dans l'instruction d'affectation let s2 = s1
). Ainsi, s1
n'est plus valide après cette affectation. Voici la représentation de la mémoire après la suppression de s1 :
Il existe trois façons de transférer la propriété d'une variable à une autre dans un programme Rust :
La transmission d'une valeur à une fonction a une sémantique similaire à l'attribution d'une valeur à une variable. Tout comme l'affectation, le passage d'une variable à une fonction entraîne son déplacement ou sa copie. Jetez un œil à cet exemple, qui montre à la fois les cas d'utilisation de copie et de déplacement :
fn main() { let s = String::from("hello"); // s comes into scope move_ownership(s); // s's value moves into the function... // so it's no longer valid from this // point forward let x = 5; // x comes into scope makes_copy(x); // x would move into the function // It follows copy semantics since it's // primitive, so we use x afterward } // Here, x goes out of scope, then s. But because s's value was moved, nothing // special happens. fn move_ownership(some_string: String) { // some_string comes into scope println!("{}", some_string); } // Here, some_string goes out of scope and `drop` is called. // The occupied memory is freed. fn makes_copy(some_integer: i32) { // some_integer comes into scope println!("{}", some_integer); } // Here, some_integer goes out of scope. Nothing special happens.
Si nous essayions d'utiliser s après l'appel à move_ownership
, Rust renverrait une erreur de compilation.
Les valeurs renvoyées peuvent également transférer la propriété. L'exemple ci-dessous montre une fonction qui renvoie une valeur, avec des annotations identiques à celles de l'exemple précédent.
fn main() { let s1 = gives_ownership(); // gives_ownership moves its return // value into s1 let s2 = String::from("hello"); // s2 comes into scope let s3 = takes_and_gives_back(s2); // s2 is moved into // takes_and_gives_back, which also // moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing // happens. s1 goes out of scope and is dropped. fn gives_ownership() -> String { // gives_ownership will move its // return value into the function // that calls it let some_string = String::from("yours"); // some_string comes into scope some_string // some_string is returned and // moves out to the calling // function } // This function takes a String and returns it fn takes_and_gives_back(a_string: String) -> String { // a_string comes into // scope a_string // a_string is returned and moves out to the calling function }
La propriété d'une variable suit toujours le même schéma : une valeur est déplacée lorsqu'elle est affectée à une autre variable . À moins que la propriété des données n'ait été déplacée vers une autre variable, lorsqu'une variable qui inclut des données sur le tas sort de la portée, la valeur sera nettoyée par drop
.
Espérons que cela nous donne une compréhension de base de ce qu'est un modèle de propriété et comment il influence la façon dont Rust gère les valeurs, comme les assigner les unes aux autres et les faire entrer et sortir des fonctions.
Tenir. Encore une chose…
Le modèle de propriété de Rust, comme pour toutes les bonnes choses, présente certains inconvénients. On se rend vite compte de certains désagréments une fois qu'on commence à travailler sur Rust. Nous avons peut-être observé que s'approprier puis redevenir propriétaire avec chaque fonction est un peu gênant.
Il est ennuyeux que tout ce que nous transmettons à une fonction doive être renvoyé si nous voulons l'utiliser à nouveau, en plus de toute autre donnée renvoyée par cette fonction. Que se passe-t-il si nous voulons qu'une fonction utilise une valeur sans s'en approprier ?
Prenons l'exemple suivant. Le code ci-dessous entraînera une erreur car la variable, v
ne peut plus être utilisée par la fonction main
(dans println!
) qui la possédait initialement une fois la propriété transférée à la fonction print_vector
.
fn main() { let v = vec![10,20,30]; print_vector(v); println!("{}", v[0]); // this line gives us an error } fn print_vector(x: Vec<i32>) { println!("Inside print_vector function {:?}",x); }
Le suivi de la propriété peut sembler assez facile, mais cela peut devenir compliqué lorsque nous commençons à traiter des programmes volumineux et complexes. Nous avons donc besoin d'un moyen de transférer des valeurs sans transférer la propriété, c'est là que le concept d' emprunt entre en jeu.
Emprunter, dans son sens littéral, fait référence à recevoir quelque chose avec la promesse de le rendre. Dans le contexte de Rust, emprunter est un moyen d'accéder à de la valeur sans en revendiquer la propriété, car elle doit être restituée à son propriétaire à un moment donné.
Lorsque nous empruntons une valeur, nous référençons son adresse mémoire avec l'opérateur &
. A &
est appelé une référence . Les références elles-mêmes n'ont rien de spécial - sous le capot, ce ne sont que des adresses. Pour ceux qui sont familiers avec les pointeurs C, une référence est un pointeur vers la mémoire qui contient une valeur qui appartient à (c'est-à-dire qui appartient à) une autre variable. Il convient de noter qu'une référence ne peut pas être nulle dans Rust. En fait, une référence est un pointeur ; c'est le type de pointeur le plus basique. Il n'y a qu'un seul type de pointeur dans la plupart des langages, mais Rust a différents types de pointeurs, plutôt qu'un seul. Les pointeurs et leurs différents types sont un sujet différent qui sera abordé séparément.
Pour le dire simplement, Rust fait référence à la création d'une référence à une valeur en empruntant la valeur, qui doit éventuellement retourner à son propriétaire.
Regardons un exemple simple ci-dessous :
let x = 5; let y = &x; println!("Value y={}", y); println!("Address of y={:p}", y); println!("Deref of y={}", *y);
Ce qui précède produit la sortie suivante :
Value y=5 Address of y=0x7fff6c0f131c Deref of y=5
Ici, la variable y
emprunte le nombre détenu par la variable x
, tandis que x
possède toujours la valeur. On appelle y
une référence à x
. L'emprunt prend fin lorsque y
sort de la portée, et parce que y
ne possède pas la valeur, il n'est pas détruit. Pour emprunter une valeur, prenez une référence par l'opérateur &
. Le formatage p, {:p}
sortie comme un emplacement mémoire présenté en hexadécimal.
Dans le code ci-dessus, "*" (c'est-à-dire un astérisque) est un opérateur de déréférencement qui opère sur une variable de référence. Cet opérateur de déréférencement permet de récupérer la valeur stockée dans l'adresse mémoire d'un pointeur.
Voyons comment une fonction peut utiliser une valeur sans s'en approprier par l'emprunt :
fn main() { let v = vec![10,20,30]; print_vector(&v); println!("{}", v[0]); // can access v here as references can't move the value } fn print_vector(x: &Vec<i32>) { println!("Inside print_vector function {:?}", x); }
Nous passons une référence ( &v
) (alias pass-by-reference ) à la fonction print_vector
plutôt que de transférer la propriété (c'est-à-dire pass-by-value ). Par conséquent, après avoir appelé la fonction print_vector
dans la fonction main, nous pouvons accéder à v
.
Comme indiqué précédemment, une référence est une sorte de pointeur, et un pointeur peut être considéré comme une flèche pointant vers une valeur stockée ailleurs. Considérez l'exemple ci-dessous :
let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y);
Dans le code ci-dessus, nous créons une référence à une valeur de type i32
, puis utilisons l'opérateur de déréférencement pour suivre la référence aux données. La variable x
contient une valeur de type i32
, 5
. Nous fixons y
égal à une référence à x
.
Voici comment la mémoire de la pile apparaît :
On peut affirmer que x
est égal à 5
. Cependant, si nous voulons faire une assertion sur la valeur de y
, nous devons suivre la référence à la valeur à laquelle elle se réfère en utilisant *y
(d'où le déréférencement ici). Une fois que nous déréférencons y
, nous avons accès à la valeur entière vers laquelle pointe y
, que nous pouvons comparer à 5
.
Si nous essayons d'écrire assert_eq!(5, y);
à la place, nous aurions cette erreur de compilation :
error[E0277]: can't compare `{integer}` with `&{integer}` --> src/main.rs:11:5 | 11 | assert_eq!(5, y); | ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
Étant donné qu'il s'agit de types différents, la comparaison d'un nombre et d'une référence à un nombre n'est pas autorisée. Par conséquent, nous devons utiliser l'opérateur de déréférencement pour suivre la référence à la valeur vers laquelle il pointe.
Comme variable, une référence est immuable par défaut - elle peut être rendue mutable avec mut
, mais seulement si son propriétaire est également mutable :
let mut x = 5; let y = &mut x;
Les références immuables sont également appelées références partagées, tandis que les références mutables sont également appelées références exclusives.
Considérez le cas ci-dessous. Nous accordons un accès en lecture seule aux références puisque nous utilisons l'opérateur &
au lieu de &mut
. Même si la source n
est modifiable, ref_to_n
et another_ref_to_n
ne le sont pas, car ils sont en lecture seule n emprunte.
let mut n = 10; let ref_to_n = &n; let another_ref_to_n = &n;
Le vérificateur d'emprunt donnera l'erreur ci-dessous :
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable --> src/main.rs:4:9 | 3 | let x = 5; | - help: consider changing this to be mutable: `mut x` 4 | let y = &mut x; | ^^^^^^ cannot borrow as mutable
On peut se demander pourquoi un emprunt n'est pas toujours préféré à un déménagement . Si tel est le cas, pourquoi Rust a-t-il même une sémantique de déplacement , et pourquoi n'emprunte -t-il pas par défaut ? La raison en est qu'emprunter une valeur dans Rust n'est pas toujours possible. L'emprunt n'est autorisé que dans certains cas.
L'emprunt a son propre ensemble de règles, que le vérificateur d'emprunt applique strictement pendant la compilation. Ces règles ont été mises en place pour éviter les courses aux données . Ils sont les suivants :
La portée d'une référence doit être contenue dans la portée du propriétaire de la valeur. Sinon, la référence peut faire référence à une valeur libérée, ce qui entraîne une erreur d'utilisation après libération.
let x; { let y = 0; x = &y; } println!("{}", x);
Le programme ci-dessus essaie de déréférencer x
après que le propriétaire y
soit hors de portée. Rust empêche cette erreur d'utilisation après libération.
Nous pouvons avoir autant de références immuables (alias références partagées) à une donnée particulière à la fois, mais une seule référence mutable (alias référence exclusive) autorisée à un moment donné. Cette règle existe pour éliminer les courses de données . Lorsque deux références pointent vers le même emplacement mémoire en même temps, qu'au moins l'une d'elles est en train d'écrire et que leurs actions ne sont pas synchronisées, on parle de data race.
Nous pouvons avoir autant de références immuables que nous le souhaitons car elles ne modifient pas les données. L'emprunt, en revanche, nous limite à ne garder qu'une seule référence mutable ( &mut
) à la fois pour éviter la possibilité de courses de données au moment de la compilation.
Regardons celui-ci :
fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }
Le code ci-dessus qui tente de créer deux références mutables ( r1
et r2
) à s
échouera :
error[E0499]: cannot borrow `s` as mutable more than once at a time --> src/main.rs:6:14 | 5 | let r1 = &mut s; | ------ first mutable borrow occurs here 6 | let r2 = &mut s; | ^^^^^^ second mutable borrow occurs here 7 | 8 | println!("{}, {}", r1, r2); | -- first borrow later used here
Espérons que cela clarifie les concepts de propriété et d'emprunt. J'ai également brièvement abordé le vérificateur d'emprunt, l'épine dorsale de la propriété et de l'emprunt. Comme je l'ai mentionné au début, la propriété est une idée nouvelle qui peut être difficile à comprendre au début, même pour les développeurs chevronnés, mais qui devient de plus en plus facile à mesure que vous y travaillez. Ceci n'est qu'un aperçu de la façon dont la sécurité de la mémoire est appliquée dans Rust. J'ai essayé de rendre cet article aussi facile à comprendre que possible tout en fournissant suffisamment d'informations pour saisir les concepts. Pour plus de détails sur la fonctionnalité de propriété de Rust, consultez leur documentation en ligne .
Rust est un excellent choix lorsque les performances sont importantes et il résout les problèmes qui dérangent de nombreux autres langages, ce qui se traduit par un pas en avant significatif avec une courbe d'apprentissage abrupte. Pour la sixième année consécutive, Rust a été le langage le plus apprécié de Stack Overflow , ce qui implique que de nombreuses personnes qui ont eu la chance de l'utiliser en sont tombées amoureuses. La communauté Rust continue de croître.
Selon les résultats de Rust Survey 2021 : L'année 2021 a sans aucun doute été l'une des plus importantes de l'histoire de Rust. Il a vu la fondation de la Rust Foundation, l'édition 2021 et une communauté plus grande que jamais. Rust semble être sur une bonne voie alors que nous nous dirigeons vers le futur.
Bon apprentissage!