paint-brush
La propriété et les emprunts de Rust renforcent la sécurité de la mémoirepar@senthilnayagan
2,203 lectures
2,203 lectures

La propriété et les emprunts de Rust renforcent la sécurité de la mémoire

par Senthil Nayagan31m2022/07/15
Read on Terminal Reader
Read this story w/o Javascript

Trop long; Pour lire

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 déjà appris à un nouveau paradigme ; nous appelons cela un changement de paradigme. Si un programme n'est pas vraiment sécurisé en mémoire, il y a peu d'assurances quant à sa fonctionnalité.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - La propriété et les emprunts de Rust renforcent la sécurité de la mémoire
Senthil Nayagan HackerNoon profile picture

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.

Qu'est-ce que la sécurité de la mémoire ?

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.


Conçu par Freepik.


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


Référence pendante

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 :


  • Lectures hors limites
  • Écritures hors limites
  • Utiliser-Après-Libre


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.

Qu'est-ce qu'une fuite de mémoire ?

Il est important de comprendre ce qu'est une fuite de mémoire et quelles en sont les conséquences.


Conçu par Freepik.


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.

Insécurité de la mémoire vs fuites de mémoire

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.


Figure 1 : insécurité de la mémoire par rapport aux fuites de mémoire.


Différents types de mémoires et leur fonctionnement

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.

Mémoire de pile et fonctionnement

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.


Mémoire de tas et comment ça marche

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.

Comment divers autres langages de programmation garantissent-ils la sécurité de la mémoire ?

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 :

  • Nous préférons libérer la mémoire dès que possible lorsqu'elle n'est plus nécessaire, sans surcharge d'exécution.
  • Nous ne devons jamais conserver une référence à une donnée qui a été libérée (c'est-à-dire une référence pendante). Sinon, des plantages et des problèmes de sécurité pourraient survenir.


La sécurité de la mémoire est assurée de différentes manières par les langages de programmation au moyen de :

  • Désallocation de mémoire explicite (adoptée par C, C++)
  • Désallocation de mémoire automatique ou implicite (adoptée par Java, Python et C#)
  • Gestion de la mémoire basée sur la région
  • Systèmes de type linéaire ou unique


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.

Désallocation de mémoire manuelle ou explicite

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 :

  • Référence pendante
  • Fuite de mémoire


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 :

  • Avoir un meilleur contrôle sur la gestion de la mémoire.
  • Moins de sécurité en raison de références pendantes et de fuites de mémoire.
  • Résultats dans un temps de développement plus long.

Désallocation de mémoire automatique ou implicite

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 :

  • Élimine le besoin pour les développeurs de libérer manuellement la mémoire.
  • Fournit une sécurité de mémoire efficace sans références pendantes ni fuites de mémoire.
  • Code plus simple et direct.
  • Cycle de développement plus rapide.
  • Avoir moins de contrôle sur la gestion de la mémoire.
  • Provoque une latence car il consomme à la fois de la mémoire et des cycles CPU.

Comment Rust garantit-il la sécurité de la mémoire ?

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.


Figure 2 : Rust contrôle mieux la gestion de la mémoire et offre une plus grande sécurité sans problèmes de mémoire.


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.


Gestion explicite de la mémoire contre gestion implicite de la mémoire contre le modèle de propriété de Rust.


Il faut un certain temps pour s'habituer à la propriété car c'est un nouveau concept pour de nombreux programmeurs, comme moi.

La possession

À 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.


Figure 3 : La liaison de variable montre le propriétaire et sa valeur/ressource.


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.

Vérificateur d'emprunt

Rust implémente la propriété via le vérificateur d'emprunt , un analyseur statique . Le vérificateur d'emprunt est un composant du compilateur Rust qui garde une trace de l'endroit où les données sont utilisées tout au long du programme, et en suivant les règles de propriété, il est capable de déterminer où les données doivent être publiées. De plus, le vérificateur d'emprunt garantit que la mémoire désallouée ne sera jamais accessible au moment de l'exécution. Il élimine même la possibilité de courses de données causées par une mutation simultanée (modification).

Règles de propriété

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 :

  • Dans Rust, chaque valeur a 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 est supprimée.


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 :

  • Références pendantes : c'est là qu'une référence pointe vers une adresse mémoire qui ne contient plus les données auxquelles le pointeur faisait référence ; ce pointeur pointe vers des données nulles ou aléatoires.
  • Utiliser après les libérations : c'est là que la mémoire est accessible une fois qu'elle a été libérée, ce qui peut planter. Cet emplacement mémoire peut également être utilisé par des pirates pour exécuter du code.
  • Doubles libérations : c'est là que la mémoire allouée est libérée, puis libérée à nouveau. Cela pourrait entraîner le blocage du programme, exposant potentiellement des informations sensibles. Cela permet également à un pirate d'exécuter le code de son choix.
  • Défauts de segmentation : c'est là que le programme tente d'accéder à la mémoire à laquelle il n'est pas autorisé à accéder.
  • Buffer overrun : C'est là que le volume de données dépasse la capacité de stockage de la mémoire tampon, provoquant le plantage du programme.


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 .

copie

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 :


Figure 4 : x et y ont leurs propres données.

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.

mouvement

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 .

  • Pointeur - pointe vers la mémoire qui contient le contenu de la chaîne.
  • Longueur - c'est la quantité de mémoire, en octets, que le contenu de la String utilise actuellement.
  • Capacité - c'est la quantité totale de mémoire, en octets, que la 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.


Figure 5 : La pile contient les métadonnées tandis que le tas contient le contenu réel.


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 :


Figure 6 : La variable s2 obtient une copie du pointeur, de la longueur et de la capacité de s1.


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.


Figure 7 : Si Rust copiait les données du tas, une autre possibilité pour ce que let s2 = s1 pourrait faire est la réplication des données. Cependant, Rust ne copie pas par défaut.


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 :


Figure 8 : Représentation de la 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!

cloner

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 :


Figure 9 : Lors de l'utilisation de la méthode de clonage, les données du tas sont copiées dans s2.


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.

Règle de propriété 1

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;

Règle de propriété 2

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é :-)


Conçu par Freepik.


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.


Figure 10 : Le compilateur a fait des copies de x vers y et z.


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 liaison variable est similaire à celle des autres langages de programmation. Pour illustrer les règles de propriété, nous avons besoin d'un type de données complexe.


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.


Règle de propriété 3

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 :


Figure 11 : Représentation de la mémoire après la suppression de s1.

Comment la propriété se déplace

Il existe trois façons de transférer la propriété d'une variable à une autre dans un programme Rust :

  1. Attribuer la valeur d'une variable à une autre variable (cela a déjà été discuté).
  2. Passer une valeur à une fonction.
  3. De retour d'une fonction.

Passer une valeur à une fonction

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.

Retour d'une fonction

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.

Conçu par Freepik.


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.

Emprunt

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é.


Conçu par Freepik.


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 .

Suivre le pointeur vers la valeur avec l'opérateur de déréférencement

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 :


Représentation de la mémoire de pile.


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.

Les références sont immuables par défaut

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


Règles d'emprunt

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 :

  1. La portée de l'emprunteur ne peut pas durer plus longtemps que la portée du propriétaire d'origine.
  2. Il peut y avoir plusieurs références immuables, mais une seule référence mutable.
  3. Les propriétaires peuvent avoir des références immuables ou modifiables, mais pas les deux en même temps.
  4. Toutes les références doivent être valides (ne peuvent pas être nulles).

La référence ne doit pas survivre au propriétaire

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.

De nombreuses références immuables, mais une seule référence mutable autorisée

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


Mot de la fin

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!


Conçu par Freepik.