web-dev-qa-db-fra.com

Pourquoi des durées de vie explicites sont-elles nécessaires dans Rust?

Je lisais le chapitre des vies du livre Rust, et je suis tombé sur cet exemple pour une vie nommée/explicite:

struct Foo<'a> {
    x: &'a i32,
}

fn main() {
    let x;                    // -+ x goes into scope
                              //  |
    {                         //  |
        let y = &5;           // ---+ y goes into scope
        let f = Foo { x: y }; // ---+ f goes into scope
        x = &f.x;             //  | | error here
    }                         // ---+ f and y go out of scope
                              //  |
    println!("{}", x);        //  |
}                             // -+ x goes out of scope

Il est tout à fait clair pour moi que l'erreur évitée par le compilateur est le use-after-free de la référence attribuée à x: une fois la portée interne terminée, f et donc &f.x deviennent invalides et n'auraient pas dû être assigné à x.

Mon problème est que le problème aurait facilement pu être analysé loin sans en utilisant la durée de vie explicit'a, par exemple en inférant une assignation illégale d'une référence à une portée plus large (x = &f.x;).

Dans quels cas les durées de vie explicites sont-elles réellement nécessaires pour éviter les erreurs use-after-free (ou d'une autre classe?)?

162
corazza

Les autres réponses ont toutes des points saillants ( l'exemple concret de fjh où une durée de vie explicite est nécessaire ), mais il manque un élément clé: pourquoi des durées de vie explicites sont-elles nécessaires quand le compilateur vous dira que vous vous êtes trompé?

C'est en fait la même question que "pourquoi des types explicites sont-ils nécessaires lorsque le compilateur peut les déduire". Un exemple hypothétique:

fn foo() -> _ {  
    ""
}

Bien sûr, le compilateur peut voir que je retourne un &'static str, alors pourquoi le programmeur doit-il le taper?

La raison principale est que, bien que le compilateur puisse voir ce que fait votre code, il ne sait pas ce que vous vouliez.

Les fonctions sont une limite naturelle au pare-feu contre les effets du changement de code. Si nous permettions à des durées de vie d'être complètement inspectées à partir du code, un changement d'aspect innocent pourrait affecter les durées de vie, ce qui pourrait alors causer des erreurs dans une fonction éloignée. Ce n'est pas un exemple hypothétique. Si je comprends bien, Haskell a ce problème lorsque vous vous fiez à l'inférence de type pour les fonctions de niveau supérieur. La rouille a étouffé ce problème particulier dans l'œuf. 

Le compilateur présente également un avantage en termes d'efficacité: seules les signatures de fonction doivent être analysées afin de vérifier les types et les durées de vie. Plus important encore, il présente un avantage en efficacité pour le programmeur. Si nous n'avions pas de durée de vie explicite, que fait cette fonction:

fn foo(a: &u8, b: &u8) -> &u8

Il est impossible de dire sans inspecter la source, ce qui irait à l’encontre d’un grand nombre de pratiques optimales en matière de codage.

en déduisant une cession illégale d'une référence à une portée plus large

Les scopes sont des durées de vie, essentiellement. Un peu plus clairement, un 'a de durée de vie __ est un paramètre de durée de vie generic qui peut être spécialisé avec une étendue spécifique au moment de la compilation, en fonction du site de l'appel. 

les durées de vie explicites sont-elles réellement nécessaires [...] pour éviter les erreurs?

Pas du tout. Les durées de vie sont nécessaires pour éviter les erreurs, mais des durées de vie explicites sont nécessaires pour protéger le peu de probité des programmeurs.

179
Shepmaster

Regardons l'exemple suivant.

fn foo<'a, 'b>(x: &'a u32, y: &'b u32) -> &'a u32 {
    x
}

fn main() {
    let x = 12;
    let z: &u32 = {
        let y = 42;
        foo(&x, &y)
    };
}

Ici, les durées de vie explicites sont importantes. Ceci est compilé car le résultat de foo a la même durée de vie que son premier argument ('a), de sorte qu'il peut survivre à son deuxième argument. Ceci est exprimé par les noms de durée de vie dans la signature de foo. Si vous avez basculé les arguments dans l'appel en foo, le compilateur se plaint que y ne vit pas assez longtemps:

error[E0597]: `y` does not live long enough
  --> src/main.rs:10:5
   |
9  |         foo(&y, &x)
   |              - borrow occurs here
10 |     };
   |     ^ `y` dropped here while still borrowed
11 | }
   | - borrowed value needs to live until here
79
fjh

L'annotation de durée de vie dans la structure suivante:

struct Foo<'a> {
    x: &'a i32,
}

spécifie qu'une instance Foo ne doit pas survivre à la référence qu'elle contient (champ x).

L'exemple que vous avez trouvé dans le livre Rust ne l'illustre pas, car les variables f et y sortent du même champ.

Un meilleur exemple serait celui-ci:

fn main() {
    let f : Foo;
    {
        let n = 5;  // variable that is invalid outside this block
        let y = &n;
        f = Foo { x: y };
    };
    println!("{}", f.x);
}

Maintenant, f survit réellement à la variable indiquée par f.x.

9
user3151599

Notez qu'il n'y a pas de durée de vie explicite dans cet élément de code, à l'exception de la définition de la structure. Le compilateur est parfaitement capable d'inférer des durées de vie dans main().

Dans les définitions de type, cependant, les durées de vie explicites sont inévitables. Par exemple, il y a une ambiguïté ici:

struct RefPair(&u32, &u32);

Devraient-ils être des durées de vie différentes ou devraient-ils être les mêmes? Cela a de l'importance du point de vue de l'utilisation, struct RefPair<'a, 'b>(&'a u32, &'b u32) est très différent de struct RefPair<'a>(&'a u32, &'a u32)

Maintenant, pour des cas simples, comme celui que vous avez fourni, le compilateur pourrait théoriquement espérer des vies comme il le fait ailleurs, mais ces cas sont très limités et ne valent pas la complexité supplémentaire du compilateur ce gain de clarté serait pour le moins discutable.

8
Vladimir Matveev

Le cas du livre est très simple par conception. Le sujet des durées de vie est considéré comme complexe.

Le compilateur ne peut pas facilement déduire la durée de vie d'une fonction à plusieurs arguments. 

De plus, ma propre caisse optional a un type OptionBool avec une méthode as_slice dont la signature est en réalité:

fn as_slice(&self) -> &'static [bool] { ... }

Il n’ya absolument aucun moyen pour le compilateur d’avoir compris cela.

4
llogiq

Si une fonction reçoit deux références en tant qu'arguments et renvoie une référence, son implémentation peut parfois renvoyer la première référence et parfois la seconde. Il est impossible de prédire quelle référence sera renvoyée pour un appel donné. Dans ce cas, il est impossible de déduire une durée de vie pour la référence renvoyée, car chaque référence d'argument peut faire référence à une liaison de variable différente avec une durée de vie différente. Des durées de vie explicites aident à éviter ou à clarifier une telle situation.

De même, si une structure contient deux références (deux champs de membre), une fonction membre de la structure peut parfois renvoyer la première référence et parfois la seconde. Encore une fois, des durées de vie explicites empêchent de telles ambiguïtés.

Dans quelques situations simples, il existe élision de la durée de vie où le compilateur peut déduire des durées de vie.

3
MichaelMoser

J'ai trouvé une autre excellente explication ici: http://doc.Rust-lang.org/0.12.0/guide-lifetimes.html#returning-references .

En général, il n'est possible de renvoyer des références que si elles sont dérivé d'un paramètre de la procédure. Dans ce cas, le pointeur Le résultat aura toujours la même durée de vie que l’un des paramètres; Les durées de vie nommées indiquent quel paramètre est.

2
corazza

En tant que nouvelle venue à Rust, je crois comprendre que les durées de vie explicites ont deux objectifs.

  1. Mettre une annotation de durée de vie explicite sur une fonction restreint le type de code pouvant apparaître à l'intérieur de cette fonction. Les durées de vie explicites permettent au compilateur de s’assurer que votre programme fait ce que vous vouliez.

  2. Si vous (le compilateur) souhaitez vérifier si un morceau de code est valide, vous (le compilateur) n'aura pas à rechercher de manière itérative à l'intérieur de chaque fonction appelée. Il suffit de regarder les annotations des fonctions directement appelées par ce code. Cela rend votre programme beaucoup plus facile à raisonner pour vous (le compilateur) et rend les temps de compilation gérables.

Sur le point 1., considérons le programme suivant écrit en Python:

import pandas as pd
import numpy as np

def second_row(ar):
    return ar[0]

def work(second):
    df = pd.DataFrame(data=second)
    df.loc[0, 0] = 1

def main():
    # .. load data ..
    ar = np.array([[0, 0], [0, 0]])

    # .. do some work on second row ..
    second = second_row(ar)
    work(second)

    # .. much later ..
    print(repr(ar))

if __name__=="__main__":
    main()

qui va imprimer

array([[1, 0],
       [0, 0]])

Ce type de comportement me surprend toujours. Ce qui se passe, c’est que df partage la mémoire avec ar. Ainsi, lorsque le contenu de df change dans work, ce changement infecte également ar. Cependant, dans certains cas, cela peut être exactement ce que vous voulez, pour des raisons d'efficacité de la mémoire (pas de copie). Le vrai problème dans ce code est que la fonction second_row renvoie la première ligne au lieu de la seconde; bonne chance pour déboguer ça.

Considérons plutôt un programme similaire écrit en Rust:

#[derive(Debug)]
struct Array<'a, 'b>(&'a mut [i32], &'b mut [i32]);

impl<'a, 'b> Array<'a, 'b> {
    fn second_row(&mut self) -> &mut &'b mut [i32] {
        &mut self.0
    }
}

fn work(second: &mut [i32]) {
    second[0] = 1;
}

fn main() {
    // .. load data ..
    let ar1 = &mut [0, 0][..];
    let ar2 = &mut [0, 0][..];
    let mut ar = Array(ar1, ar2);

    // .. do some work on second row ..
    {
        let second = ar.second_row();
        work(second);
    }

    // .. much later ..
    println!("{:?}", ar);
}

En compilant ceci, vous obtenez

error[E0308]: mismatched types
 --> src/main.rs:6:13
  |
6 |             &mut self.0
  |             ^^^^^^^^^^^ lifetime mismatch
  |
  = note: expected type `&mut &'b mut [i32]`
             found type `&mut &'a mut [i32]`
note: the lifetime 'b as defined on the impl at 4:5...
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...does not necessarily outlive the lifetime 'a as defined on the impl at 4:5
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^

En fait, vous obtenez deux erreurs, il y en a aussi une avec les rôles 'a et 'b interchangés. En regardant l'annotation de second_row, nous constatons que la sortie doit être &mut &'b mut [i32], c'est-à-dire qu'elle est supposée être une référence à une référence de durée de vie 'b (durée de vie de la deuxième ligne de Array). Cependant, étant donné que nous renvoyons la première ligne (qui a la durée de vie 'a), le compilateur se plaint de l'incompatibilité entre les durées de vie. Au bon endroit Au bon moment. Le débogage est un jeu d'enfant.

1
Jonas Dahlbæk

La raison pour laquelle votre exemple ne fonctionne pas, c'est simplement parce que Rust n'a qu'une inférence de durée de vie et de type locale. Ce que vous suggérez exige une inférence globale. Chaque fois que vous avez une référence dont la durée de vie ne peut pas être élidée, elle doit être annotée.

0
Klas. S