Retour aux articles

Apprendre -- Système de propriété (Ownership)

Laurent Wouters

2024-02-05

fdsfdsf

Série des articles 'Apprendre'

Rust et la gestion de la mémoire

Si l'on positionne Rust par rapport à d'autres langages dans un spectre en matière de gestion de la mémoire, nous avons :

  • d'un côté des langages avec une gestion manuelle de la mémoire. Par exemple C avec malloc et free, C++ avec new et delete. Ces langages peuvent avoir des librairies qui facilitent la gestion, mais fondamentalement, celle-ci est manuelle, et surtout statique.
  • d'un autre côté, des langages avec un gestion automatique de la mémoire, réalisée par un garbage collector à l'exécution (runtime), par exemple dans la JVM, .Net, Python, Go, etc. Dans ces langages, nous allouons, mais la dés-allocation est décidée dynamiquement à l'exécution.

Rust se situe dans un intermédiaire avec une gestion de la mémoire

  • automatique, car non réalisée par le programmeur,
  • et statique, car décidée statiquement par le compilateur.

Pour réaliser cela, Rust s'appuie sur son système de propriété ; un système qui n'est pas spécifique à la gestion de la mémoire, il permet en effet de gérer tous les types de ressource, mais la mémoire en fait partie.

Système de propriété (ownership)

Le système de propriété en Rust sert à tracer, à la compilation, qui est propriétaire de quelle ressource. Il faut bien noter que ce concept n'existe que à la compilation. Il n'y a pas de runtime spécifique à l'exécution. Les règles de ce système sont simples :

  • toute valeur a un propriétaire qui est in-fine une variable
  • toute valeur n'a qu'un unique propriétaire
  • lorsque la variable disparait (car elle est scopée), la valeur est détruite.

Par exemple :

{
    let x = String::from("hello world");
}

La variable x est propriétaire de la valeur String. Cette valeur n'a qu'un seul propriétaire, la variable x. A la fin du bloc, x va disparaitre, on détruit alors la valeur, l'objet String.

En réalité ce pattern n'est pas spécifique à Rust, il existe dans beaucoup de langages. En Java :

try (resource) {
    // use the resource
}

en Python:

with resource:
    # use the resource

La différence est que ce pattern est tellement intégré à Rust, qu'il n'y a pas de syntaxe spécifique.

Application à la gestion de la mémoire

Le système de propriété de Rust permet de gérer tous les types de ressource. La mémoire n'en est qu'un type parmi d'autre. Lorsqu'une valeur cache une allocation mémoire, elle en est responsable et lors de sa destruction on procédera à la dés-allocation.

{
    let x = String::from("hello world");
}

Dans cet exemple, String::from alloue de la mémoire, l'objet String en est responsable, à la fin du bloc, x disparait, l'objet String est détruit, la mémoire est désallouée.

Propriété et sémantique move

Le système de propriété impose qu'une valeur ne peut avoir qu'un seul et unique propriétaire. Mais alors que ce passe-t-il lorsque l'on assigne un valeur à une autre variable ? Par exemple :

{
    let x = String::from("hello world");
    let y = x;
}

Comme il ne peut y avoir qu'un seul propriétaire, lors de l'affectation, de la valeur de x à y, nous devons décider de qui est propriétaire de la valeur. L'objet String a une sémantique de déplacement (move) qui indique que lors de cette affectation, nous allons déplacer l'objet String depuis x vers y. y devient alors propriétaire de l'objet String, x quant à lui se retrouve "vide". Pour s'assurer de la correction de ceci, le compilateur va nous interdire de continuer à utiliser x.

Ainsi, nous pouvons utiliser y après l'affectation :

{
    let x = String::from("hello world");
    let y = x;
    println!("{y}");
}

mais pas x :

{
    let x = String::from("hello world");
    let y = x;
    println!("{x}");
    //         ^ erreur de compilation ici,
    //           x a été consommé
}
2 |     let x = String::from("hello world");
  |         - move occurs because `x` has type `String`, which does not implement the `Copy` trait
3 |     let y = x;
  |             - value moved here
4 |     println!("{x}");
  |               ^^^ value borrowed here after move

Le but ici est de gérer correctement la mémoire derrière l'objet String. Seule la disparition du propriétaire de l'objet, y dans cet exemple, qui conduira à la destruction de la mémoire. Et comme il n'y a qu'un seul propriétaire, on sait statiquement quand cela aura lieu.

Ce comportement s'observe car le type String a une sémantique move, c'est à dire de déplacer la propriété lors de l'affectation. Dans ce cas précis, cela nous permet de savoir quel est l'unique propriétaire et donc qui est responsable de libérer la mémoire.

Sémantique de copie

Certains types n'ont pas vocation à être responsable de ressources particulières, et leurs instances n'ont pas d'identités propres. Par exemple les types primitifs. Une instance 5 du type i32 peut très bien remplacer n'importe quelle autre instance de la même valeur 5. Elle n'a pas d'identité propre. Dans ce cas, il est intéressant d'avoir une sémantique de copie implicite de telle sorte qu'une copie implicite soit produite lors de l'affectation :

{
    let x = 5;
    let y = x;
    println!("{x}");
}

Dans cet exemple, lors de l'affectation à y, une copie implicite de x est produite pour remplir y. Du point de vue de la propriété, x est toujours propriétaire de l'original, y devient propriétaire d'une copie implicite.

Cela est possible, car le type i32 est marqué avec le trait Copy. Ce trait est un marqueur, il n'apporte pas de méthode supplémentaire. Soit le type est marqué soit il ne l'est pas. Si un type est marqué avec Copy alors nous avons une sémantique de copie implicite lors de l'affectation. S'il n'est pas marqué, nous avons une sémantique move. Il n'est pas possible d'avoir les deux en même temps, c'est un choix binaire.

La sémantique de copie est toujours une sémantique de copie bit à bit. Il n'y a pas de comportement autre que prendre le pattern de bits dans l'instance d'origine et le dupliquer. Et ce, même pour les types construits.

Conclusion

Retour aux articles