Si vous êtes développeur Rust, il y a fort à parier que cous avez déjà entendu parler de l’Ownership. Ce modèle unique est la pierre angulaire de la gestion de la mémoire et de la sûreté des programmes.
Dans cet article, nous allons fouiller dans les entrailles de l’Ownership pour démystifier ce concept et vous donner les clés pour l’apprivoiser.
Découvrez notre formation Rust, idéale pour développer des applications modernes et performantes. Ce cours vous permettra d’acquérir des bases solides sur l’ensemble des concepts du langage.
L’équipe AMBIENT IT
Qu’est-ce que l’Ownership ?
L’Ownership ou propriété en français est la méthode qui permet à Rust de garantir la sécurité de sa mémoire sans avoir recours à un garbage collector (un système informatique de gestion de mémoire qui permet de récupérer des espaces de stockage non utilisés).
Sans garbage collector, l’ownership se définit par trois règles :
- Chaque valeur en Rust a un propriétaire unique, son owner
- Il ne peut y avoir qu’un seul propriétaire à la fois
- Lorsque le propriétaire sort de la portée (scope), la valeur est supprimée
Sur Rust, la mémoire est gérée par un système de propriété avec un ensemble de règles que le compilateur vérifie. Il suffit qu’une de ces règles ne soit pas respectée pour que le compilateur s’arrête. La règle d’unicité du propriétaire assure que deux variables ne peuvent pas posséder la même donnée en même temps. Cela évite les problèmes de concurrence et de sécurité mémoire.
Grâce à ce mécanisme, Rust résout les problèmes de gestion de mémoire comme les fuites ou les accès à des emplacements mémoire déjà libérés. À noter que les propriétés sont également conçues pour ne pas ralentir vos applications, car ce système natif est exécuté lors de la compilation.
L’ownership permet, par ailleurs, aux développeurs un gain de temps indéniable puisqu’ils n’ont plus à se soucier de l’affectation de la mémoire. Celle-ci étant géré par le langage.
L’Ownership en détail
Portée et Lifetimes
La portée détermine quand une variable est valide ou non. La variable est disponible uniquement dans la portée déclarée.
fn func() {
{ // string n'est pas disponible car non déclaré
let string = "Hello world"; // string est disponible
} // string n'est plus disponible car nous sortons du scope
}
La portée est également liée à la durée de vie (lifetime) de la variable afin que la mémoire puisse être libéré si celle-ci n’est plus active.
La Pile et le Tas : qu’est-ce donc ?
Deux systèmes importants pour comprendre l’ownership. La pile (stack) et le tas (heap) gèrent la gestion de la mémoire.
La pile ou stack stocke les données de taille connue lors de la compilation, il est utilisé pour des données à taille fixe. La stack est intéressante pour la gestion de données de petites tailles. Pour manipuler la stack, il existe deux méthodes :
- Push : ajouter une donnée dans la stack
- Pop : supprimer une donnée de la stack
Au contraire, le tas ou heap est nécessaire pour le stockage de données dont la taille est variable (ne peut être déterminé avant le runtime). Le heap est moins organisé et plus lent que la stack. La mémoire allouée dans le tas est décidée par le développeur.
Le pointeur sur les données est stocké sur la pile, mais les données elles-mêmes sont stockées sur le tas.
Le cas du string
Du fait de la variabilité du type string. Rust stocke ses valeurs dans le tas afin de déterminer la taille de la variable au moment de la compilation.
Il existe cependant une différence de stockage avec les chaînes de caractères littérales. En effet, les chaînes de caractère peuvent être modifiées, tandis que les caractères littéraux, stockés en mémoire fixe, ne peuvent pas l’être.
fn main() {
// Littéral de chaîne
// salutation est une référence à une chaîne statique, stockée immuablement dans le binaire du programme.
// Ce type de chaîne est de type &'static str et ne nécessite pas d'allocation mémoire lors de l'exécution.
let salutation: &'static str = "Bonjour le monde!";
println!("Littéral de chaîne: {}", salutation);
// Chaîne en mémoire (String)
// `message` est une chaîne de caractères allouée sur le tas.
// Ce type est mutable et peut être modifié, agrandi ou réduit à l'exécution.
// Il nécessite une allocation mémoire lors de sa création.
let mut message: String = String::from("Bonjour");
println!("Chaîne en mémoire avant modification: {}", message);
// Modification de la chaîne `message`
message.push_str(" le monde!");
println!("Chaîne en mémoire après modification: {}", message);
// Comparaison de performance
// Les littéraux de chaînes sont généralement plus rapides à accéder car ils sont stockés de manière immuable dans le binaire du programme.
// Les chaînes allouées sur le tas (`String`) offrent une flexibilité (par exemple, la modification), mais avec un coût supplémentaire en termes de performance due à l'allocation et la gestion de la mémoire.
}
Le Trait Copy et ses implications
Le trait Copy est une annotation spéciale utilisée pour des données de la stack. Elle permet de créer une copie automatique des valeurs de données. La copie des valeurs est rapide et ne nécessite pas d’action sur la mémoire. Ainsi, la valeur copiée est utilisée indépendamment de l’original.
Rust n’autorise pas l’annotation Copy sur les types qui ont implémenté le trait Drop. Le trait Drop est utilisé pour spécifier un comportement personnalisé à l’expiration de la portée d’une variable, souvent pour libérer des ressources.
Si un type a des composants qui nécessitent des opérations spéciales à la fin de leur cycle de vie (comme la libération de la mémoire allouée), ce type ne peut pas implémenter Copy, car cela pourrait conduire à une duplication néfaste pour la mémoire.
Les types simples tels que les integers, les floats, les chars, les booléens et les tuples peuvent être annotés Copy. Ils peuvent être copiés sans risque d’erreurs de gestion de la mémoire.
Le Trait clone vs le trait move
Move intervient lorsque vous transférez une valeur d’une variable à une autre. Si la variable est « déplacée », elle ne peut plus être utilisée après le transfert, car elle ne possède plus la donnée.
Par exemple :
let s1 = String::from("hello");
let s2 = s1; // s1 est déplacée vers s2
// s1 ne peut plus être utilisée ici
Dans cet exemple, s1 est déplacée vers s2. Suite au « move« , s1 est considérée comme non valide et ne peut plus être utilisée. Ce comportement permet d’éviter les erreurs de sécurité liée à la mémoire. Comme la règle l’énonce, chaque donnée dispose d’un seul propriétaire à tout moment.
Le trait « clone« , d’autre part, est utilisé lorsque l’on veut créer une copie complète des données d’une variable, permettant à la fois à l’original et à la copie d’être utilisés indépendamment. Utiliser clone génère une nouvelle instance des données sur laquelle la variable originale pointait.
Par exemple :
let s1 = String::from("hello");
let s2 = s1.clone(); // une copie complète de s1 pour s2
println!("s1 = {}, s2 = {}", s1, s2); // Les deux variables sont valides et utilisables
clone peut être coûteux en termes de performance, surtout pour les grands volumes de données, car des valeurs identiques sont stockées plusieurs fois.
Nous vous conseillons d’utiliser move lorsque vous n’avez plus besoin de la variable originale et clone lorsque le maintien de la variable originale est obligatoire. Prenez soin de considérer l’impact sur les performances d’une utilisation excessive de clone.
L’ownership au sein d’une fonction Rust
Lorsqu’une fonction récupère une valeur, celle-ci est soit déplacée (moved), soit empruntée (borrowed). Cela influence la façon dont la valeur peut être utilisée suite à l’appel de la fonction.
Les valeurs retournées par une fonction peuvent également transférer l’Ownership. Il est possible de retourner plusieurs valeurs tout en respectant les règles d’Ownership.
Voici un tableau récapitulant les différences entre l’opération de move et de borrow pour l’utilisation des valeurs après l’appel d’une fonction :
Opération | Description | Conséquences | Sécurité garantie |
---|---|---|---|
Déplacement (move) | Transfère la propriété de la valeur à la fonction. Utilisé par défaut pour les types non-Copy (ex. structures, vecteurs). | La variable originale ne peut plus être utilisée, à moins que la valeur ne soit retournée. | Prévient l’utilisation après libération. |
Emprunt immuable | Passe une référence immuable à la fonction. Permet plusieurs lectures simultanées, mais aucune modification. | La valeur originale reste accessible en lecture. Aucune écriture n’est permise tant que l’emprunt est actif. | Assure que la valeur ne sera pas modifiée pendant l’emprunt, prévenant les conflits de données. |
Emprunt mutable | Passe une référence mutable à la fonction. Permet une seule référence mutable à la fois pour modifier la valeur. | La valeur originale ne peut pas être accédée jusqu’à ce que l’emprunt se termine. | Empêche l’accès concurrent à la valeur, éliminant les conditions de course. |
Pour en savoir plus sur le concept de borrowing en Rust, nous vous invitions à consulter notre article sur les concepts à connaître sur Rust.
Concurrence, Parallélisme et Ownership
L’Ownership a des implications sur la gestion de la concurrence. Pour optimiser vos performances, vous devriez utiliser les pointeurs intelligents (smart pointers) comme Box, Rc, et Arc.
Voici un aperçu de ces différents pointeurs :
Box
- Box est un type de pointeur intelligent qui alloue des données sur le tas
- Box est principalement utilisé pour gérer des structures de grande taille ou des données dont la taille n’est pas connue à la compilation
- Box peut être transféré entre threads à condition que le type T soit lui-même Send. Mais il ne peut pas être partagé entre plusieurs threads, car il ne permet pas plusieurs propriétaires
Rc
- Rc signifie « Reference Counted », c’est un pointeur intelligent utilisé pour permettre à plusieurs parties de « posséder » une donnée liée à un compte de références qui assure la gestion de la mémoire
- La libération de la mémoire se produit lorsque le dernier propriétaire (la dernière référence) est détruit
- Rc ne gère pas les modifications du compteur de références de manière atomique
- Utiliser Rc dans un contexte multithread n’est pas recommandé, cela peut mener à des courses de données et à des comportements non définis
Arc
- Arc signifie « Atomic Reference Counted », c’est une version thread-safe de Rc
- Utilise des opérations atomiques pour gérer le compteur de références sans verrou, permettant ainsi à plusieurs threads de partager la propriété d’une instance de manière sécurisée
- Idéal lorsque les données doivent être accessibles depuis plusieurs threads
- Souvent utilisé avec Mutex ou RwLock pour permettre un accès sécurisé aux données
Contourner le vérificateur de propriété
Vous pouvez utiliser des blocs unsafe pour contourner les vérifications de propriété, mais cela doit être fait avec précaution en suivant les bonnes pratiques.
En effet, unsafe vous permettra de réaliser des opérations normalement restreintes par le système de type de Rust, comme la manipulation directe de pointeurs ou l’appel à du code C.
Restez vigilant en respectant ces bonnes pratiques :
- Le moins d’utilisation possible : Limitez l’utilisation d’
unsafe
autant que possible. Essayez de confiner les blocsunsafe
à de petites portions de code et fournissez une interface sûre autour de ces blocs - Documentez rigoureusement : Documentez clairement l’utilisation d’
unsafe
en expliquant la raison de son utilisation. Expliquez les invariants que le code unsafe doit respecter pour maintenir la sécurité globale du programme - Revues de code : Les sections de code
unsafe
doivent toujours être examinées par d’autres développeurs Rust expérimentés - Testez rigoureusement : Testez tout code
unsafe
pour s’assurer qu’il se comporte comme prévu sous toutes les conditions anticipées. Cela inclut des tests pour les comportements de bord et les conditions de concurrence