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

199

Je lisais le chapitre sur les vies du livre Rust, et je suis tombé sur cet exemple pour une durée de 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 empêchée par le compilateur est l' utilisation après libération de la référence affectée à x: une fois la portée interne terminée, fet donc &f.xinvalide, et n'aurait pas dû être affectée à x.

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

Dans quels cas des durées de vie explicites sont-elles réellement nécessaires pour éviter les erreurs d'utilisation après la libération (ou d'une autre classe?)?

corazza
la source
1
Ceci a été posté sur Reddit
Shepmaster le
2
Pour les futurs lecteurs de cette question, veuillez noter qu'elle est liée à la première édition du livre et il y a maintenant une deuxième édition :)
carols10cents

Réponses:

205

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 une chose clé: pourquoi des durées de vie explicites sont-elles nécessaires lorsque le compilateur vous dira que vous vous êtes trompé ?

Il s'agit en fait de 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 quelle était votre intention.

Les fonctions sont une frontière naturelle pour pare-feu les effets du changement de code. Si nous devions permettre que les durées de vie soient complètement inspectées à partir du code, un changement d'apparence innocente pourrait affecter les durées de vie, ce qui pourrait alors provoquer 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 comptez sur 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 d'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 le dire sans inspecter la source, ce qui irait à l'encontre d'un grand nombre de bonnes pratiques de codage.

en inférant l'attribution illégale d'une référence à un champ d'application plus large

Les portées sont essentiellement des durées de vie. Un peu plus clairement, une durée de vie 'aest un paramètre de durée de vie générique qui peut être spécialisé avec une portée spécifique au moment de la compilation, en fonction du site d'appel.

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

Pas du tout. Des 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 programmeurs sains.

Shepmaster
la source
19
@jco Imaginez que vous ayez une fonction de haut niveau f x = x + 1sans signature de type que vous utilisez dans un autre module. Si vous modifiez ultérieurement la définition en f x = sqrt $ x + 1, son type passe de Num a => a -> aà Floating a => a -> a, ce qui entraînera des erreurs de type sur tous les sites d'appel où fest appelé, par exemple avec un Intargument. Avoir une signature de type garantit que les erreurs se produisent localement.
fjh
11
"Les portées sont essentiellement des durées de vie. Un peu plus clairement, une durée de vie 'a est un paramètre de durée de vie générique qui peut être spécialisé avec une portée spécifique au moment de l'appel." Wow, c'est un très bon point d'éclairage. J'aimerais bien que cela soit inclus explicitement dans le livre.
corazza
2
@fjh Merci. Juste pour voir si je le bloque - le fait est que si le type était explicitement déclaré avant d'ajouter sqrt $, seule une erreur locale se serait produite après le changement, et pas beaucoup d'erreurs dans d'autres endroits (ce qui est beaucoup mieux si nous ne l'avons pas fait) ne veux pas changer le type réel)?
corazza
5
@jco Exactement. Si vous ne spécifiez pas de type, vous pouvez modifier accidentellement l'interface d'une fonction. C'est l'une des raisons pour lesquelles il est fortement encouragé d'annoter tous les éléments de niveau supérieur dans Haskell.
fjh
5
De plus, si une fonction reçoit deux références et renvoie une référence, elle peut parfois renvoyer la première référence et parfois la seconde. Dans ce cas, il est impossible d'inférer une durée de vie pour la référence renvoyée. Des durées de vie explicites aident à éviter / clarifier une telle situation.
MichaelMoser
93

Jetons un coup d'œil à 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. Cela se compile car le résultat de fooa la même durée de vie que son premier argument ( 'a), il peut donc survivre à son deuxième argument. Ceci est exprimé par les noms à vie dans la signature de foo. Si vous commutiez les arguments de l'appel vers foole compilateur, vous vous plaindriez de yne pas vivre 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
fjh
la source
16

L'annotation à vie dans la structure suivante:

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

spécifie qu'une Fooinstance ne doit pas survivre à la référence qu'elle contient ( xchamp).

L'exemple que vous avez rencontré dans le livre Rust n'illustre pas cela car fet les yvariables sortent du champ d'application en même temps.

Un meilleur exemple serait ceci:

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, fsurvit vraiment la variable pointée par f.x.

user3151599
la source
9

Notez qu'il n'y a pas de durée de vie explicite dans ce morceau 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, des durées de vie explicites sont inévitables. Par exemple, il y a une ambiguïté ici:

struct RefPair(&u32, &u32);

Ces durées devraient-elles être différentes ou devraient-elles être identiques? Il importe 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 éliminer des durées de vie comme il le fait dans d'autres endroits, mais ces cas sont très limités et ne valent pas une complexité supplémentaire dans le compilateur, et ce gain de clarté serait au très peu discutable.

Vladimir Matveev
la source
2
Pouvez-vous expliquer pourquoi ils sont très différents?
AB
@AB La seconde nécessite que les deux références partagent la même durée de vie. Cela signifie que refpair.1 ne peut pas vivre plus longtemps que refpair.2 et vice versa - les deux arbitres doivent donc pointer vers quelque chose avec le même propriétaire. Le premier ne requiert cependant que le RefPair survit à ses deux parties.
llogiq
2
@AB, il compile parce que les deux durées de vie sont unifiées - parce que les durées de vie locales sont plus petites 'static, elles 'staticpeuvent être utilisées partout où les durées de vie locales peuvent être utilisées, par conséquent, dans votre exemple p, son paramètre de durée de vie sera déduit comme la durée de vie locale de y.
Vladimir Matveev
5
@AB RefPair<'a>(&'a u32, &'a u32)signifie que ce 'asera l'intersection des deux durées de vie d'entrée, c'est-à-dire dans ce cas la durée de vie de y.
fjh
1
@llogiq "exige que le RefPair survive à ses deux parties"? Je pensais que c'était le contraire ... un & u32 peut toujours avoir un sens sans le RefPair, alors qu'un RefPair avec ses refs morts serait étrange.
qed
6

Le boîtier du livre est très simple de par sa conception. Le sujet des vies est jugé complexe.

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

De plus, ma propre caisse optionnelle a un OptionBooltype avec une as_sliceméthode dont la signature est en fait:

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

Il n'y a absolument aucun moyen que le compilateur ait pu comprendre cela.

llogiq
la source
IINM, inférer la durée de vie du type de retour d'une fonction à deux arguments sera équivalent au problème d'arrêt - IOW, non décidable en un temps limité.
dstromberg
4

J'ai trouvé une autre grande 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ées d'un paramètre de la procédure. Dans ce cas, le résultat du pointeur aura toujours la même durée de vie que l'un des paramètres; les durées de vie nommées indiquent de quel paramètre il s'agit.

corazza
la source
4

Si une fonction reçoit deux références en tant qu'arguments et renvoie une référence, l'implémentation de la fonction 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 d'inférer 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 (comme deux champs membres), 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 une élision de durée de vie où le compilateur peut déduire des durées de vie.

MichaelMoser
la source
1

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

Klas. S
la source
1

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

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

  2. Si vous (le compilateur) voulez (s) vérifier si un morceau de code est valide, vous (le compilateur) n'aurez pas à regarder itérativement à l'intérieur de chaque fonction appelée. Il suffit de regarder les annotations des fonctions qui sont directement appelées par ce morceau de code. Cela rend votre programme beaucoup plus facile à raisonner (le compilateur) et rend les temps de compilation gérables.

Au 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 imprimera

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

Ce type de comportement me surprend toujours. Ce qui se passe, c'est le dfpartage de la mémoire avec ar, donc quand une partie du contenu des dfchangements work, ce changement infecte araussi. 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_rowrenvoie la première ligne au lieu de la seconde; bonne chance pour déboguer ça.

Considérez 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);
}

Compiler cela, 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 de 'aet 'béchangés. En regardant l'annotation de second_row, nous constatons que la sortie doit être &mut &'b mut [i32], c'est-à-dire que la sortie est censée être une référence à une référence avec une durée de vie 'b(la durée de vie de la deuxième ligne de Array). Cependant, comme nous renvoyons la première ligne (qui a une durée de vie 'a), le compilateur se plaint de la non-concordance de la durée de vie. Au bon endroit. Au bon moment. Le débogage est un jeu d'enfant.

Jonas Dahlbæk
la source
0

Je pense qu'une annotation à vie comme un contrat sur une référence donnée n'a été valide dans la portée de réception que pendant qu'elle reste valide dans la portée source. La déclaration de plusieurs références dans le même type de durée de vie fusionne les étendues, ce qui signifie que toutes les références source doivent respecter ce contrat. Une telle annotation permet au compilateur de vérifier l'exécution du contrat.

Jorge Gonzalez
la source