Levans' workshop

Experiments and thoughts about Machine Learning, Rust, and other stuff...

Les emprunts et les durées de vies en Rust


Les emprunts (borrowing) et les durées de vie (lifetime) sont deux concepts centraux de Rust. Ils peuvent parfois s'avérer difficiles d'accès, car finalement très intriqués l'un avec l'autre.

Je m'en vais donc écrire ce billet, en vue d'en expliquer les tenants et les aboutissants.

Résumé des bases à avoir

Je m'adresse ici à des lecteurs déjà un peu familiers du langage et de sa syntaxe, notamment des références &T et &mut T, des structures génériques comme Vec<T> et des slices &[T]

Pour rappel, les références sont soumises à des règles plutôt strictes:

  • Une référence ne peut pas survivre plus longtemps que l'objet vers lequel elle pointe
  • Si une référence &mut _ existe, elle a un accès exclusif à l'objet
  • Si une ou plusieurs références &_ existent, on ne peut pas créer de référence &mut _

Les annotations de durée de vie

L'exemple le plus direct des durées de vie serait par exemple:

fn first_element<'a>(slice: &'a [u8]) -> &'a u8 {
    &slice[0]
}

Ici, nous avons une fonction qui prend une référence vers une slice en argument et renvoie une référence vers son premier élément (et paniquera si la slice est vide).

Mais nous avons ajouté un autre argument, au même endroit que les arguments de types pour les fonctions génériques: 'a. Il s'agit d'un paramètre de durée de vie. Il sert ici à lier les durées de vie de l'entrée et de la sortie.

Les durées de vie, pourquoi, comment ?

Pourquoi lier ainsi les durées de vie en entrée et en sortie ? Rappellez-vous, la première règle des références:

Une référence ne peut pas survivre plus longtemps que l'objet vers lequel elle pointe.

Voilà justement le rôle des durées de vie. À la compilation, le compilateur va associer à chaque objet une durée de vie, tout en respectant la règle suivante:

La durée de vie d'une référence doit toujours être strictement plus courte que celle de l'objet qu'elle pointe (elle doit donc être créée après et détruite avant lui)

Mais en restectant également les règles imposées par les prototypes des fonctions appelées.

S'il n'y arrive pas, vous aurez un message d'erreur dans cet esprit :

error: `...` does not live long enough

error: cannot infer an appropriate lifetime due to conflicting requirements

La syntaxe des annotations

Dans le prototype de la fonction first_ement(..), on a défini un paramètre de durée de vie 'a, que l'on a associé à la référence d'entrée et la référence de sortie de la fonction, dont je rappelle le protoype:

fn first_element<'a>(slice: &'a [u8]) -> &'a u8 { /* ... */ }

Ici, 'a sert à donner un nom à la durée de vie que le compilateur associe à la référence &u8 de sortie de la fonction. Et on associe cette durée de vie à l'argument d'entrée de la fonction également, pour dire au compilateur qu'elles sont égales.

Néanmoins, pour le compilateur, il est possible de transformer une référence &'b _ en une référence &'a _ sous réserve que la durée de vie 'a soit plus courte que 'b (le contraire est par contre interdit). Donc notre fonction pourra prendre n'importe quelle référence vers une slice [u8] tant que cette dernière a une durée de vie plus longue que la référence de sortie &u8.

On dit donc au compilateur que la durée de vie de la référence de sortie doit être plus courte que celle de la référence d'entrée.

Si notre fonction prenait plusieurs références en argument, plusieurs cas sont possibles:

// Ici la durée de vie de l'argument de sortie n'est lié qu'à celle de arg2
fn fonction<'a>(arg1: &u8, arg2: &'a u8) -> &'a u8 { /* ... */ }

// Ici, elle est liée aux deux, donc doit être plus courte que chacune
// de celles des arguments, donc plus courte que la plus courte des deux
fn fonction<'a>(arg1: &'a u8, arg2: &'a u8) -> &'a u8 { /* ... */ }

// Ici, la première référence en sortie est liée aux deux premiers arguments
// et la deuxième au troisième argument
fn fonction<'a, 'b>(arg1: &'a u8, arg2: &'a u8, arg3: &'b u8)
        -> (&'a u8, &'b u8) { /* ... */ }

Notez que syntaxiquement parlant, les durées de vie doivent être toutes définies avant les paramètres de types, comme ceci:

fn many_parameters<'a, 'b, 'c, T, U, S>(...) -> ... { /* ... */ }

il est également posible de définir des contraintes entre les durées de vie en entrée, de la même manière que l'on peut contraindre les types à implémenter des traits, via la syntaxe: 'a: 'b, qui impose que la durée de vie 'a soit valide quand 'b l'est, dont que 'a soit au moins aussi longue que 'b:

fn with_constraints<'b, 'a: 'b>(arg1: &'a u8, arg2: &'b u8) { /* ... */ }

Les paramètres de durée de vie pour les structures

Si une structure encapsule une référence, comme ceci:

struct Foo {
    r: &u8
}

le compilateur va se plaindre comme ceci

error: missing lifetime specifier [E0106]
    r: &u8

en effet, un structure contenant des références doit toujours expliciter leurs durées de vie, comme ceci:

struct Foo<'a> {
    ref: &'a u8
}

Comme on peut le voir, dans ce cas, la durée de vie fait partie intégrante du type, et comme pour les références, elle dit au compilateur qu'un objet de ce type n'a pas le droit de vivre plus longtemps que la durée de vie associée à 'a.

C'est ce qu'on peut observer avec la méthode .iter() de Vec<T>, dont le protype exhaustif est le suivant:

fn iter<'a>(&'a self) -> Iter<'a, T> { /* ... */ }

L'objet Iter<T> renvoyé est lié à la durée de vie du Vec<T> vers lequel il pointe, et donc n'a pas le droit de vivre plus longtemps.

On peut également mettre des contraintes de durées de vie sur un type, comme par exemple T: 'a. Dans ce cas, on impose que tous les paramètres de durée de vie et de type de T vérifient la condition : 'a.

Par exemple, T: 'a imposée pour T == Iter<'v, U> va imposer 'v: 'a ainsi que U: 'a.

La durée de vie 'static

Vous pouvez donner n'importe quel nom à vos durées de vie, tant qu'il serait sous la forme d'un ' suivi par ce qui serait un nom de variable valide.

Un seul nom de durée de vie est réservé par le langage : 'static. Cette durée de vie représente des valeurs qui sont valides pour l'intégralité de la durée du programme.

C'est par exemple la durée de vie des chaines de caractère littérales du programme:

let text: &'static str = "Hello world!";

Une conséquence intéressante est la condition T: 'static, qui permet d'imposer que le type T n'emprunte rien à personne. (En effet, si tous ses paramètres de durée de vie sont 'static, s'il a des références internes elles pointent vers des valeurs qui ne risquent pas d'être détruites.)

Les élisions des annotations

Il existe certains cas dans lesquels il n'est pas nécessaire d'écrire les annotations de durée de vie dans le prototype des fonctions et où le compilateur les devine tout seul:

  • S'il n'y a qu'une seule durée de vie élidée en entrée, celle-ci est associée à toutes les durées de vie élidées en sortie
  • S'il y a plusieurs durées de vie élidées en entrée, mais que l'une d'elles est associée à self, elle est associée à toutes les durées de vie élidées en sortie
  • Dans tous les autres cas, il y a erreur et les durées de vie doivent être explicitées

Relations entre durées de vie et emprunts

Nous avons vu comment les annotations durées de vie permettent de définir les relations de survie entre les objets, par exemple comment un Iter<'a, T> n'a pas le droit de vivre plus longtemps que le Vec<T> auquel il est associé. Mais les annotations de durée de vie font plus que ça: en effet, tant que notre Iter<'a, T> est en vie, il emprunte le Vec<T> associé !

Pour le coup, ici la règle est simple : quand une durée de vie d'une sortie est associée à une entrée, cette sortie emprunte cette entrée. Si l'entrée était une référence constante, l'emprunt est constant, sinon l'emprunt est mutable.

Ceci permet à Rust de préserver les invariants des références, même quand la référence est encapsulée dans un autre type.

Voilà, j'espère que ce rapide tour d'horizon aura pu éclairer ce que sont les durées de vie en Rust, à quoi elles servent, et comment elles marchent.