Possession

Mécanisme de possession (ownership)

En Rust, la mémoire est gérée avec un mécanisme de possession (ownership) qui est vérifié au moment de la compilation. Les règles de l'ownership sont les suivantes:

  • chaque valeur en Rust a un propriétaire (owner)
  • il ne peut y avoir qu'un seul propriétaire de cette valeur à un instant t
  • quand le propriétaire est hors de portée, la valeur est détruite

Pour illustrer ce concept, nous utiliserons le type String qui est stocké sur le tas (heap). Nous avons déjà utilisé des chaînes de caractères mais ces dernières, de type &str, ont un contenu immutable:

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>())
}

fn main() {
    let mut x = "hello";
    x = "world";
    print_type_of(&x);
    println!("{}", x);
}

La fonction from de String permet de créer une chaîne de caractère dont on va pouvoir modifier le contenu:

fn print_type_of<T>(_: &T) {
    println!("{}", std::any::type_name::<T>())
}

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

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{}", s); // This will print `hello, world!`

    print_type_of(&s);
}

Examinons la relation entre une variable s1 et la mémoire dans le cas de la déclaration suivante:

#![allow(unused)]
fn main() {
let s1 = String::from("hello");
}

Dans le cas d'un type String, la mémoire utilisée est de deux types: une structure, stockée sur la pile, contenant ta taille maximale de la chaîne de caractère, le nombre de caractères actuels, ainsi qu'un pointeur vers une zone mémoire sur le tas (heap) contenant la chaîne elle-même:

Two tables: the first table contains the representation of s1 on the
stack, consisting of its length (5), capacity (5), and a pointer to the first
value in the second table. The second table contains the representation of the
string data on the heap, byte by byte.

Si l'on écrit le code suivant, regardons ce qui se passe au niveau de la mémoire:

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

En mémoire, la structure contenant les informations sur la chaîne est dupliquée, à la différence de la chaîne elle-même se trouvant sur le tas:

Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.

Si l'on fait un parallèle avec d'autres langages comme C++, on parlera de shallow copy: une partie de la mémoire est dupliquée, à la différence d'une deep copy.

En Rust, la mémoire avec laquelle la variable est liée est automatiquement détruire lorsque la variable devient hors de portée: Rust appelle la méthode drop qui rend (au système d'exploitation) la mémoire allouée sur le tas. Sur le schéma ci-dessus, la question de pose de savoir qui de s1 et s2 doit posséder la zone mémoire et doit donc être responsable de sa destruction, afin d'éviter les double free.

Pour résoudre ce problème, Rust considère qu'après la ligne:

#![allow(unused)]
fn main() {
let s2 = s1;
}

la variable s1 n'est plus valable. En effet, pour toutes les données possédant une méthode drop, une affectation correspond à un déplacement (move) plutôt qu'à une copie (copy): l'emplacement mémoire de départ est donc invalidé.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    // s1 n'est plus valide
    println!("{}", s1);
    println!("{}", s2);
}

Le schéma de la mémoire après l'affectation de s1 à s2 est donc le suivant:

Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.
Table s1 is grayed out be-cause s1 is no longer valid; only s2 can be used to
access the heap data.

Rust, par défaut, ne fera jamais de copie profonde des données stockées sur le tas. Cette copie doit être faite de manière explicite avec clone():

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

    println!("{}", s1);
    println!("{}", s2);
}

Le code ci-dessus correspondant à l'organisation de la mémoire suivante:

Four tables: two tables representing the stack data for s1 and s2,
and each points to its own copy of string data on the heap.

Implémentation des traits Copy et Drop

Revenons au code exemple que nous avions vu précédemment:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);
}

Ici, il n'est pas nécessaire d'utiliser clone() car pour les types de données de base (comme les entiers), la taille est connue au moment de la compilation. Ces types de données donnent donc lieu à une allocation statique effectuée sur la pile, les notions de copie profonde et de shallow copy ont ici le même comportement: les données, dans le cas d'une affectation sont bien copiées.

Ces types de données implémentent le trait Copy, régissant le comportement de l'affectation. Les données de type String implémentent quant à elles le trait Drop: ces données impliquent une affectation de type déplacement plutôt qu'une copie: elles seront donc régies par le mécanisme de possession. Rust ne permet pas qu'un type implémentant Drop implémente le type Copy.

Parmi les types implémentant le trait Copy, on trouve:

  • Tous les types entiers, tel que u32
  • Le type booléen bool
  • Tous les types flottants, comme f64
  • Le type caractère char
  • Les tuples, seulement si tous leurs membres implémentent Copy. Par exemple, (i32, i32) implémente Copy, mais pas (i32, String)

Possession et fonctions

Le passage d'un paramètre à une fonction utilise le même mécanisme que l'affectation. Dans l'exemple ci-dessous, le fait de passer s en paramètre à la fonction takes_ownership transfère la propriété de s au paramètre some_string de la fonction. Lorsque l'on sort de la fonction, comme some_string est hors de portée, la mémoire associée est rendue. Pour une variable entière le comportement est différent puisque le passage au paramètre some_integer de la fonction makes_copy se fait en effectuant une copie.

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function,
                                    // but i32 is Copy, so it's okay to still
                                    // use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // 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.

Il est tout à fait possible de transférer la possession à une fonction et de la récupérer ensuite. Il suffit de retourner les données:

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 one
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
}

Nous avons donc trois possibilités: une fonction peut prendre la possession de données, la donner, ou la prendre puis la rendre. Notons un dernier mécanisme: une fonction peut tout à fait retourner un tuple, permettant ainsi de retourner plusieurs valeurs.

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

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}