Que sont les durées de vie non-lexicales ? Découverte de l'amélioration majeure de Rust
\n\nToute personne ayant écrit en Rust ne serait-ce que quelques semaines a déjà rencontré cette situation : le code est parfaitement logique, sûr, mais le compilateur (ou plus précisément, son strict borrow checker) refuse de le compiler. Vous regardez l'erreur et pensez : « Mais je n'utilise plus cette référence ! Pourquoi se plaint-il ? ».
\n\nAvant la version Rust 1.31 (édition 2018), la réponse était simple : « Parce que la durée de vie d'une variable (lifetime) est déterminée par sa portée — lexicalement ». Ce sont les durées de vie lexicales. Depuis Rust 2018, le compilateur est devenu plus intelligent. Il a acquis les durées de vie non-lexicales (NLL).
\n\nDans cet article, nous allons comprendre ce qu'est cette fonctionnalité, pourquoi son apparition a été une révolution pour les développeurs Rust, et comment elle rend le code plus propre et plus intuitif. Nous ne nous plongerons pas dans les méandres de la théorie des types, mais nous concentrerons sur des exemples pratiques.
\n\nLe problème des durées de vie lexicales : pourquoi le borrow checker était trop strict
\n\nRappelons comment fonctionne l'emprunt (borrowing) en Rust. Nous avons des règles :
\n- \n
- À tout moment, il peut y avoir soit une seule référence mutable (&mut T), soit un nombre quelconque de références immuables (&T). \n
- Les références doivent toujours être valides (on ne peut pas référencer des données supprimées). \n
Avant NLL, le compilateur déterminait la durée de vie d'une référence par sa portée lexicale. C'est-à-dire du { au } où la variable est déclarée. Si une référence est créée à l'intérieur d'un bloc, elle « vit » jusqu'à la fin de ce bloc, même si vous ne l'utilisez plus.
Regardez cet exemple classique qui ne se compilait pas dans les anciennes versions de Rust :
\n\nfn main() {\n let mut data = vec![1, 2, 3];\n\n let x = &data[0]; // référence immuable\n\n data.push(4); // tentative d'emprunt mutable\n\n println!("{}", x); // utilisation de la référence immuable\n}\n\nDu point de vue humain : nous avons créé une référence x, puis modifié le vecteur, puis utilisé x. Mais le vecteur data lors de l'appel à push aurait pu réallouer la mémoire, et la référence x serait devenue invalide. Le compilateur a raison — c'est dangereux.
Mais que se passe-t-il si nous utilisons la référence avant la modification ? Voici la vraie douleur :
\n\nfn main() {\n let mut name = String::from("Alice");\n\n let first_char = &name[0..1]; // référence immuable\n println!("First char: {}", first_char); // on l'utilise\n\n name.push_str(" Johnson"); // on modifie la chaîne\n println!("Full name: {}", name);\n}\n\nCe code est sûr. Nous avons utilisé la référence first_char, affiché son contenu, puis modifié la chaîne. Mais avant Rust 2018, le compilateur donnait une erreur ! Pourquoi ? Parce que la durée de vie lexicale de la référence first_char durait jusqu'à la fin de la fonction main, c'est-à-dire jusqu'à l'accolade fermante. Et un emprunt mutable (push_str) à l'intérieur de cet intervalle est interdit.
Les développeurs devaient utiliser des solutions de contournement : introduire des blocs supplémentaires {} pour limiter manuellement la durée de vie de la référence :
fn main() {\n let mut name = String::from("Alice");\n\n {\n let first_char = &name[0..1];\n println!("First char: {}", first_char);\n } // la référence first_char meurt ici\n\n name.push_str(" Johnson");\n println!("Full name: {}", name);\n}\n\nCela fonctionnait, mais c'était peu pratique, encombrait le code d'accolades inutiles et rendait la lecture difficile. Surtout dans des fonctions complexes avec plusieurs emprunts.
\n\nDurées de vie non-lexicales (NLL) : comment cela fonctionne
\n\nL'idée des NLL est géniale dans sa simplicité : la durée de vie d'une référence n'est pas déterminée par sa portée, mais par l'endroit où elle est réellement utilisée.