Cours de programmation séquentielle

Propriété (Ownership)

Orestis Malaspinas

Généralités

  • Les règles sur la propriété sont ce qui rend Rust unique.
  • La propriété est un ensemble de règles vérifiées à la compilation.
  • Garantit la bonne gestion de la mémoire sans garbage collector.
  • Oblige à réfléchir à ce que fait notre programme quand on le compile (et que le compilateur râle).
  • Spoiler alert: simple à expliquer, difficile à appliquer.

Pile et tas (Stack and heap)

La pile

  • Partie de la mémoire réservée pour chaque thread.
  • Lorsqu’une fonction est appelée, un bloc est réservé pour stocker les variables locales.
  • Lorsque la fonction retourne, la mémoire est libérée et peut être réutilisée.
  • C’est une structure LIFO (Last in first out): le dernier ajout sera libéré en premier.
  • Stocke typiquement les objets dont la taille est connue à la compilation (entiers, nombres à virgule flottante, caractères, booléens, …).

Le tas

  • Partie de la mémoire réservée est commune tous les threads.
  • Utilisée pour l’allocation dynamique (la taille des objets peut varier en cours d’exécution).
  • Peut de la mémoire non-locale à une fonction.
  • Pas de pattern particulier pour l’allocation/désallocation.
  • Plus compliqué de gérer l’allocation/déasllocation.
  • Typiquement plus lent que la pile (il faut chercher de la place pour l’allocation et “sauter” en mémoire pour la retrouver).

La propriété (Ownership)

Les règles de la propriété

  1. Chaque valeur a une variable qui est son propriétaire (owner).
  2. Une valeur ne peut avoir qu’un seul propriétaire à chaque instant.
  3. Quand le programme sort de la portée du propriétaire, la valeur est détruite (dropped).

fn main() 
{
    let x = 5; // x est propriétaire de la mémoire contenant 5
    {
        let y = 6; // y est propriétaire de la mémoire contenant 5

        println!("La valeur de (x,y) est: ({}, {}).", x, y);
    } // y sort de la portée et est détruite avec la valeur 6

    println!("La valeur de x est: {}", x);
} // x sort de la portée et sa valeur est détruite

Allocation de la mémoire (1/3)

  • Deux façon principales d’allouer/désallouer dynamiquement de la mémoire.
    1. Manuellement (C/C++, …): on ordonne au système d’exploitation d’allouer/désallouer de la mémoire sur le tas.
      • Problèmes:
        1. On peut oublier de désallouer la mémoire allouée (fuite mémoire/memory leak).
        2. On peut désallouer de la mémoire trop tôt (dangling pointer et comportement indéfini).
        3. Libérer la mémoire à double.
    2. Automatiquement: on a un “garbage collector” (java, scala, …).
      • Problèmes:
        1. Consomme des ressources.
        2. Est une “boîte magique”: il fait ce qu’il veut.
  • En Rust c’est complètement différent: on contrôle complètement où et quand on alloue/désalloue:
    • Problème: il faut suivre des règles très strictes.

Allocation de mémoire (2/3)

  • Les types vus jusque là sont stockés dans la pile: leur taille est connue à la compilation.
  • Que se passe-t-il lorsque la taille est inconnue à la compilation?
  • Exemple:
    
    fn main() 
    {
      let x = [1, 2, 3, 4]; // x de type [i32; 4], sur la pile
      // on ne peut pas augmenter sa taille
    
      println!("La valeur de x est: {:?}", x);
    
      let mut y = Vec::new(); // un vecteur dont la taille est variable
      // On rajoute des éléments au vecteur avec push(elem)
      y.push(1); // rust infère que le type des éléments est entier
      y.push(2);
      y.push(3);
      y.push(4); // on lui a rajouté 4 éléments
      println!("La valeur de y est: {:?}", y);
    } // y sort de la portée, il est détruit et la mémoire est libérée
    
  • Que se passe-t-il passé ici?

Allocation de mémoire (3/3)

  • La représentation en mémoire du vecteur v⃗ = (1, 2, 3, 4).
  • Sur le tas: 1 pointeur vers le tas, et 2 entiers (longueur et capacité).
  • Sur la pile les 4 nombres: 1, 2, 3, 4.
  • Lorsque la variable (proprétaire) sort de la portée, la mémoire est automatiquement libérée.
  • Simple, mais très contraignant (comment peut-on partager les données?).

Extension de la propriété

Flexibiliser la propriété:

  1. Donner la propriété à un autre propriétaire: move.
  2. Emprunter pour un temps les données: borrow.
  3. Être co-propriétaires dans des structures avancées: Rc et Arc.

Move

Changement de propriétaire (1/3)

  • Le propriétaire des données devient une autre variable

fn main() 
{
    let mut y = Vec::new(); // un vecteur dont la taille est variable
    // On rajoute des éléments au vecteur avec push(elem)
    y.push(1); // rust infère que le type des éléments est entier
    y.push(2);
    y.push(3);
    y.push(4); // on lui a rajouté 4 éléments
    println!("La valeur de y est: {:?}", y);

    let z = y; // le vecteur (1,2,3,4) est maintenant propriété de z
                   // y est  une variable non initialisée
   println!("La valeur de z est: {:?}", z);
} // z sort de la portée, il est détruit et la mémoire est libérée

Changement de propriétaire (2/3)

  • Lorsqu’elle donne ses données à un autre propriétaire, la variable devient invalidée et non-initialisée

fn main() 
{
    let mut y = Vec::new(); // un vecteur dont la taille est variable
    // On rajoute des éléments au vecteur avec push(elem)
    y.push(1); // rust infère que le type des éléments est entier
    y.push(2);
    y.push(3);
    y.push(4); // on lui a rajouté 4 éléments
    println!("La valeur de y est: {:?}", y);

    let z = y; // le vecteur (1,2,3,4) est maintenant propriété de z
                   // y est  une variable non initialisée
   println!("La valeur de y est: {:?}", y);
} // Ce code ne compilera pas.

Changement de propriétaire (3/3)

  • La variable y et copiée dans la variable z.
  • En ne faisant rien on a deux propriétaires des données.
  • Illégal: on invalide y.

Exception au move

  • Les types de base de Rust sont stockés sur la pile (entiers, flottants, énumérés, …).
  • Une copie des données est donc rapide.
  • Lorsqu’on effectuera une assignation de ces types, on effectuera d’abord une copie, puis l’assignation (le type est dit Copy).

fn main() 
{
    let y = 1;
    let mut z = y; // ici si y avait été de type vecteur ont aurait fait un move
                    // Comme y est un i32, on en fait d'abord une copie puis 
                    // on assigne la valeur à z

    println!("Les valeurs de y et z sont : ({}, {})",  y, z);

    z = 2; // comme la valeur est copiée modifier z ne modifie pas y
    println!("Les valeurs de y et z sont : ({}, {})",  y, z);
} // Ce code compilera et s'exécutera.

Différence entre Copie et Move

  • Lors du move on copie uniquement la variable, pas les données et le propriétaire des données change.
  • Lors de la copie on duplique la variable et les données.
  • Pour effectuer une copie on utilise .clone().

Quand interviennent les move?

  • Si le type de la variable n’est pas Copy:
    1. Lors d’une assignation.
    2. Lors du passage en paramètre à une fonction.
    3. Lors du retour d’une fonction.

Lors du passage en paramètre à une fonction


fn take_own(_v: Vec<i32>) {
    // on fait des choses
}

fn main() 
{
    let mut y = Vec::new(); // un vecteur dont la taille est variable
    // On rajoute des éléments au vecteur avec push(elem)
    y.push(1); y.push(2); y.push(3); y.push(4);

    take_own(y);

    println!("La valeur de y est: {:?}", y);
} // A votre avis que se passe-t-il?

Lors du retour d’une fonction


fn give_own() -> Vec<i32> {
    let mut y = Vec::new(); // un vecteur dont la taille est variable
    // On rajoute des éléments au vecteur avec push(elem)
    y.push(1); y.push(2); y.push(3); y.push(4);

    v // on retourne v
}

fn main() 
{
    let y = give_own();

    println!("La valeur de y est: {:?}", y);
} // A votre avis que se passe-t-il?

Un mélange des deux


fn get_len(v: Vec<i32>) -> (Vec<i32>, usize) {
    let length = v.len();     // on ajoute 2 au vecteur

    (v, length) // on retourne v et sa longueur
}

fn main() 
{
    let mut y = Vec::new();
    y.push(1); y.push(2); y.push(3); y.push(4);

    let (y, length) = get_len(y);

    println!("La valeur de y est: {:?} et sa longueur {}", y, length);
} // A votre avis que se passe-t-il?

L’emprunt (Borrowing)

La référence

  • Le move est trop contraignant, la copie lente (et pas toujours ce qu’on veut).
  • Il serait donc pratique de pouvoir emprunter les objets.
  • Le borrowing permet d’accéder aux données sans avoir la propriété de l’objet.
  • Cela se fait à l’aide d’une référence sur l’objet qu’on souhaite emprunter.
  • Si y est une variable, &yest la référence vers la variable (le pointeur vers cette variable).
  • La référence permet l’emprunt de données sans en prendre la propriété.

Exemple 1


fn get_len(v: &Vec<i32>) -> usize {
    v.len()
} 
// on sort de la prtée de la fonction, 
// la propriété des données dans v est rendue.

fn main() 
{
    let mut y = Vec::new();
    y.push(1); y.push(2); y.push(3); y.push(4);

    let length = get_len(&y); // la référence vers y est passée

    println!("La valeur de y est: {:?} et sa longueur {}", y, length);
}

Exemple 2


fn get_len(v: &Vec<i32>) -> usize {
    v.push(2); // on ajoute 2 à v
    v.len()
} 
// on sort de la prtée de la fonction, 
// la propriété des données dans v est rendue.

fn main() 
{
    let mut y = Vec::new();
    y.push(1); y.push(2); y.push(3); y.push(4);

    let length = get_len(&y); // la référence vers y est passée

    println!("La valeur de y est: {:?} et sa longueur {}", y, length);
}

Référence mutable

  • Pour modifier les données lors d’un emprunt:
    1. La variable doit être mutable.
    2. La référence doit être mutable.

fn get_len(v: &mut Vec<i32>) -> usize {
    v.push(2); // on ajoute 2 à v
    v.len()
} 
// on sort de la prtée de la fonction, 
// la propriété des données dans v est rendue.

fn main() 
{
    let mut y = Vec::new();
    y.push(1); y.push(2); y.push(3); y.push(4);

    let length = get_len(&mut y); // la référence vers y est passée

    println!("La valeur de y est: {:?} et sa longueur {}", y, length);
}

Règles pour les références

  • Sur une variable on peut avoir:
    1. Autant de références immutables qu’on veut.
    2. Une seule référence mutable.
  • La référence doit toujours être valide.

Dangling pointer (“pointeur pendouillant”)

  • En désallouant de la mémoire mais en ne détruisant pas le pointeur vers cette mémoire on crée un dangling pointer.
  • Très dangereux, car on ne sait pas du tout ce qui se pourrait se passer si on essaie de suivre le pointeur pendouillant.
  • Tout un tas de langages autorise ce comportement indéfini.
  • En Rust cela est impossible.
  • Exemple :

fn dangling() -> &Vec<i32> { // la fonction retourne une référence vers un pointeur
    let mut v = Vec::new();
    v.push(1); v.push(2); // on a créé un vec avec 1,2 dedans.

    &v; // on retourne une réf vers v
} // v sort de la portée de la fonction et est détruit:
  // la mémoire est libérée.

fn main() {
    let dangling_reference = dangling();
}

Exemples (1/2)


fn main() 
{
    let mut y = Vec::new();
    y.push(1); y.push(2); y.push(3); y.push(4);

    let y1 = &y;
    let y2 = &y;

    println!("La valeur de y1 et y2 sont: {:?}, {:?}.", y1, y2);
}

Exemples (2/2)


fn main() 
{
    let mut y = Vec::new();
    y.push(1); y.push(2); y.push(3); y.push(4);

    {
        let mut y1 = &mut y;
        y1.push(7);
    }

    println!("La valeur de y est: {:?}.", y);
}