Aller au contenu

Lifetimes en Rust : Durée de vie des Valeurs

À quoi ça sert ?

Les lifetimes vont nous permettre d’éviter des références invalides, c’est-à-dire des références vers des valeurs qui ont été drop.

Pour cela, le borrow checker aura besoin qu’on indique explicitement la durée de vie d’une référence afin qu’il puisse déterminer ou non s’il y a des références invalide.

Voyons ça tout de suite avec un exemple :

fn main() {
let r;// On déclare r
// mais sans lui donner de valeur pour le moment
{
let x = 5;
r = &x; // r est maintenant une réf vers x
}
// x est détruit en sortant de son scope
println!("r: {}", r); //Erreur car r est une ref vers
// x qui n'existe plus !
}

Ici tu peux voir que r et x ne sont pas dans le même scope, donc lorsque x est drop à la sortie de son scope, r se retrouve à faire référence à une valeur qui n’existe plus !

Pour qu’une référence soit valide il faut que la data ait une lifetime (durée de vie) supérieure ou égale à la référence.

Concrètement ici, il aurait fallu que la lifetime de x soit plus grande ou supérieure à celle de r.

Or dans notre exemple x a une lifetime plus courte que r d’où l’erreur.

On va noter 'a le lifetime de r et 'b le lifetime de x, voici à quoi correspondent nos lifetimes. (C’est une convention en Rust de noter les lifetimes avec une apostrophe devant).

On peut noter nos lifetime avec n’importe quelle lettre, mais par convention on commence par a puis b, c, d etc à chaque fois qu’on a besoin d’un nouveau lifetime.

fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+

Tu vois donc que le lifetime 'a est supérieur à 'b.

Notation des lifetimes

Voici la syntaxe des lifetimes :

  • &i32 // une référence sans lifetime
  • &‘a i32 // une référence, de lifetime ‘a, de type i32
  • &‘a mut i32 // une référence mutable, de lifetime ‘a, de type i32

Quand on spécifie le lifetime quelque part, comme dans la signature d’une fonction, on ne modifie pas les lifetime des paramètres, mais on les spécifie juste pour expliquer à Rust la portée de chaque élément.

Dans les structs

Lorsqu’un struct contient une référence dans un de ses champs, il va falloir définir la durée de vie de la référence.

Pourquoi ?

Encore une fois pour éviter que le struct se retrouve avec une référence qui n’existe plus.

Voici un exemple très simple :

struct User<'a>{
age: &'a u8
}
fn main(){
let first_name = "Jo";
let age: u8 = 32;
let jo = User{first_name: &first_name, age: &age};
}

Ainsi, en faisant ça on indique que le lifetime ‘a doit être plus grand que le lifetime de User.

Eh oui, car comme User a un champ age qui est une référence, si la référence meurt avant le struct, le struct se retrouverait avec une référence non valide.

Maintenant, il existe des cas ou un struct va avoir des références avec des lifetime différents.

Voici un exemple

struct User<'a, 'b>{
age: &'a u8
size: &'b u8
}
fn main(){
let age:u8 = 44;
{
let size:u8 = 188;
let user = User{age: &age, size: &size};
}
}

Tu vois que dans ce cas présent on a dû ajouter les lifetimes de chacun des paramètres

Le lifetime static

Le lifetime static est une lifetime spéciale qui signifie que la lifetime est celle du programme.

Cette lifetime est la plus grande qui existe et si on l’utilise, on s’assure de ne pas avoir de problème de référence qui pointe vers une valeur drop puisque ces valeurs ne sont jamais drop.

Toutes les strings literals ont static comme lifetime.

Lifetime coercion

Les lifetimes d’une certaine longueur peuvent être contraint en des lifetimes plus courts.

Par exemple dans ce programme

static age: u8 = 44;
fn lifetime_change(x:&'a u8) -> &'a u8{
return age;
}
fn main(){
lifetime_change();
}

En revanche l’inverse est impossible, on ne peut pas ‘allonger’ un lifetime.

Dans certains cas, il arrive qu’une fonction définisse un seul lifetime alors que nos paramètres ont 2 lifetime différents, exemple :

struct User<'a>{
pseudo: &'a str,
name: &'a str
}
fn main(){
let pseudo = String::from("DarkLord");
{
let name = String::from("Jean Dupont");
let user = User{pseudo:&pseudo, name:&name};
}
}

Ici tu vois que name et pseudo ont des durées de vie différentes, mais pourtant User ne spécifie qu’un seul lifetime, ce qui se passe dans ce cas, c’est que Rust va définir pour lifetime de User ‘a, le plus petit lifetime entre pseudo et name, donc ici il va prendre le lifetime de name (comme c’est le plus petit).

Un exemple en profondeur

static ZERO: i32 = 0;
// le struct a 2 refs
// avec des lifetimes différentes
struct Foo<'a, 'b> {
x: &'a i32,
y: &'b i32,
}
fn get_x_or_zero_ref<'a, 'b>(x: &'a i32, y: &'b i32) -> &'a i32 {
if *x > *y {
return x
} else {
return &ZERO // le lifetime static
// est contraint en 'a implicitement
}
}
fn main() {
// x possede un lifetime qu'on note 'a
let x = 1;
let v;
{
// y possede un lifetime plus court
// qu'on notera 'b
let y = 2;
let f = Foo { x: &x, y: &y };
v = get_x_or_zero_ref(&f.x, &f.y);
}
// on accède à v
// qui a le même lifetime que x
// càd 'a
println!("{}", *v);
}

Ici le fait de spécifier 2 lifetimes différents dans Foo, un pour x et un pour y ne sert à rien au niveau du struct Foo, car Foo sera invalide dès qu’une de ses références sera invalide, donc son lifetime est celui de sa plus courte référence, donc ici celle de y.

En revanche avoir spécifié 2 lifetimes au niveau du struct va être très utile pour la fonction get_x_or_zero_ref, car cette fonction prend 2 références avec des lifetimes différents et retourne du même lifetime que x.

Si on avait spécifié un seul lifetime ‘a au niveau du struct Foo, la fonction get_x_or_zero_ref n’aurait pas pu définir le type de retour comme ayant le même lifetime que x, car Rust aurait contraint le lifetime ‘a de Foo à la plus petite référence qu’il contient et dans ce cas ‘a serait le lifetime de y.

Ce qui veut dire que v sera valide tant que x sera valide.