Warum werden in Rust explizite Lebensdauern benötigt?


199

Ich habe das Kapitel "Lebenszeiten" des Rust-Buches gelesen und bin auf dieses Beispiel für eine benannte / explizite Lebenszeit gestoßen:

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

Mir ist ziemlich klar, dass der Fehler, der vom Compiler verhindert wird, die Verwendung der Referenz ist, die zugewiesen wurde x: nachdem der innere Bereich fertig ist, fund daher &f.xungültig wird und nicht zugewiesen werden sollte x.

Mein Problem ist, dass das Problem ohne die explizite 'a Lebensdauer leicht hätte analysiert werden können, indem beispielsweise auf eine illegale Zuordnung eines Verweises zu einem größeren Bereich geschlossen wurde ( x = &f.x;).

In welchen Fällen werden explizite Lebensdauern tatsächlich benötigt, um Use-After-Free-Fehler (oder Fehler anderer Klassen?) Zu verhindern?



2
Für zukünftige Leser dieser Frage beachten Sie bitte, dass sie auf die erste Ausgabe des Buches
verweist

Antworten:


205

Die anderen Antworten haben alle wichtige Punkte ( fjhs konkretes Beispiel, in dem eine explizite Lebensdauer erforderlich ist ), aber es fehlt eine wichtige Sache: Warum werden explizite Lebensdauern benötigt, wenn der Compiler Ihnen mitteilt, dass Sie sie falsch verstanden haben ?

Dies ist eigentlich die gleiche Frage wie "Warum werden explizite Typen benötigt, wenn der Compiler darauf schließen kann". Ein hypothetisches Beispiel:

fn foo() -> _ {  
    ""
}

Natürlich kann der Compiler sehen, dass ich a zurückgebe. &'static strWarum muss der Programmierer es also eingeben?

Der Hauptgrund ist, dass der Compiler zwar sehen kann, was Ihr Code tut, aber nicht weiß, was Ihre Absicht war.

Funktionen sind eine natürliche Grenze für die Firewall der Auswirkungen von Codeänderungen. Wenn wir zulassen würden, dass Lebensdauern vollständig anhand des Codes überprüft werden, könnte eine unschuldig aussehende Änderung die Lebensdauern beeinflussen, was dann zu Fehlern in einer weit entfernten Funktion führen könnte. Dies ist kein hypothetisches Beispiel. Soweit ich weiß, hat Haskell dieses Problem, wenn Sie sich für Funktionen der obersten Ebene auf die Typinferenz verlassen. Rust drückte dieses spezielle Problem im Keim.

Der Compiler bietet auch einen Effizienzvorteil: Es müssen nur Funktionssignaturen analysiert werden, um Typen und Lebensdauern zu überprüfen. Noch wichtiger ist, dass dies einen Effizienzvorteil für den Programmierer hat. Wenn wir keine expliziten Lebensdauern hatten, was macht diese Funktion:

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

Es ist unmöglich zu sagen, ohne die Quelle zu untersuchen, was gegen eine große Anzahl von Best Practices für die Codierung verstoßen würde.

durch Ableiten einer illegalen Zuordnung eines Verweises zu einem breiteren Geltungsbereich

Bereiche sind im Wesentlichen Lebensdauern. Etwas klarer ist, dass eine Lebensdauer 'aein generischer Lebensdauerparameter ist , der zur Kompilierungszeit basierend auf der Aufrufsite auf einen bestimmten Bereich spezialisiert werden kann.

Werden explizite Lebensdauern tatsächlich benötigt, um [...] Fehler zu vermeiden?

Überhaupt nicht. Lebensdauern sind erforderlich, um Fehler zu vermeiden, aber explizite Lebensdauern sind erforderlich, um die kleinen vernünftigen Programmierer zu schützen.


18
@jco Stellen Sie sich vor, Sie haben eine Funktion der obersten Ebene f x = x + 1ohne Typensignatur , die Sie in einem anderen Modul verwenden. Wenn Sie die Definition später in ändern f x = sqrt $ x + 1, ändert sich ihr Typ von Num a => a -> abis Floating a => a -> a, was zu Typfehlern an allen Aufrufstellen führt, an denen fz Int. B. mit einem Argument aufgerufen wird . Durch eine Typensignatur wird sichergestellt, dass Fehler lokal auftreten.
fjh

11
"Bereiche sind im Wesentlichen Lebensdauern. Ein bisschen klarer ist ein Lebenszyklus ein allgemeiner Lebensdauerparameter, der zur Anrufzeit auf einen bestimmten Bereich spezialisiert werden kann." Wow, das ist ein wirklich großartiger, aufschlussreicher Punkt. Ich würde es mögen, wenn es ausdrücklich in das Buch aufgenommen würde.
Corazza

2
@fjh Danke. Nur um zu sehen, ob ich es verstehe - der Punkt ist, dass, wenn der Typ vor dem Hinzufügen explizit angegeben wurde sqrt $, nach der Änderung nur ein lokaler Fehler aufgetreten wäre und nicht viele Fehler an anderen Stellen (was viel besser ist, wenn wir es nicht getan hätten Möchten Sie den tatsächlichen Typ nicht ändern?
Corazza

5
@jco Genau. Wenn Sie keinen Typ angeben, können Sie versehentlich die Schnittstelle einer Funktion ändern. Dies ist einer der Gründe, weshalb dringend empfohlen wird, alle Elemente der obersten Ebene in Haskell mit Anmerkungen zu versehen.
fjh

5
Wenn eine Funktion zwei Referenzen empfängt und eine Referenz zurückgibt, gibt sie manchmal die erste und manchmal die zweite Referenz zurück. In diesem Fall ist es unmöglich, eine Lebensdauer für die zurückgegebene Referenz abzuleiten. Explizite Lebensdauern helfen, eine solche Situation zu vermeiden / zu klären.
MichaelMoser

93

Schauen wir uns das folgende Beispiel an.

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)
    };
}

Hier sind die expliziten Lebensdauern wichtig. Dies wird kompiliert, da das Ergebnis von foodieselbe Lebensdauer hat wie sein erstes Argument ( 'a), sodass es möglicherweise sein zweites Argument überlebt. Dies wird durch die Lebensdauernamen in der Signatur von ausgedrückt foo. Wenn Sie die Argumente im Aufruf an fooden Compiler umstellen würden, würde sich das beschweren, ydas nicht lange genug lebt:

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

16

Die Lebensdaueranmerkung in der folgenden Struktur:

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

FooGibt an, dass eine Instanz die darin enthaltene Referenz ( xFeld) nicht überleben soll .

Das Beispiel , das Sie in Rust Buch stieß auf nicht illustrieren dies nicht , weil fund yVariablen zugleich der Umfang hinausgehen.

Ein besseres Beispiel wäre dies:

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);
}

Jetzt füberlebt wirklich die Variable, auf die von gezeigt wird f.x.


9

Beachten Sie, dass dieser Code außer der Strukturdefinition keine expliziten Lebensdauern enthält. Der Compiler ist perfekt in der Lage, Lebensdauern in abzuleiten main().

In Typdefinitionen sind jedoch explizite Lebensdauern unvermeidbar. Zum Beispiel gibt es hier eine Mehrdeutigkeit:

struct RefPair(&u32, &u32);

Sollten dies unterschiedliche Lebensdauern sein oder sollten sie gleich sein? Es spielt aus Sicht der Nutzung eine Rolle, unterscheidet struct RefPair<'a, 'b>(&'a u32, &'b u32)sich stark vonstruct RefPair<'a>(&'a u32, &'a u32) .

Nun, für einfache Fälle, wie die, die Sie zur Verfügung gestellt, der Compiler könnte theoretisch elide Lebensdauern wie es funktioniert in anderen Orten, aber solche Fälle sind sehr begrenzt und nicht wert zusätzliche Komplexität in den Compiler, und dieser Gewinn an Klarheit bei der wäre am wenigsten fraglich.


2
Können Sie erklären, warum sie sehr unterschiedlich sind?
AB

@AB Die zweite erfordert, dass beide Referenzen dieselbe Lebensdauer haben. Dies bedeutet, dass refpair.1 nicht länger leben kann als refpair.2 und umgekehrt. Daher müssen beide refs auf etwas mit demselben Eigentümer verweisen. Das erste erfordert jedoch nur, dass das RefPair beide Teile überlebt.
Logiq

2
@AB wird kompiliert, weil beide Lebensdauern vereinheitlicht sind - weil die lokalen Lebensdauern kleiner sind 'static, 'statickann überall dort verwendet werden, wo lokale Lebensdauern verwendet werden können. In Ihrem Beispiel pwird daher der Lebensdauerparameter als lokale Lebensdauer von abgeleitet y.
Vladimir Matveev

5
@AB RefPair<'a>(&'a u32, &'a u32)bedeutet, dass 'adies der Schnittpunkt der beiden Eingangslebensdauern ist, dh in diesem Fall die Lebensdauer von y.
fjh

1
@llogiq "erfordert, dass das RefPair beide Teile überlebt"? Ich dachte, es wäre das Gegenteil ... ein & u32 kann ohne das RefPair immer noch Sinn machen, während ein RefPair mit seinen toten Refs seltsam wäre.
Qed

6

Der Fall aus dem Buch ist von Natur aus sehr einfach. Das Thema Lebenszeiten wird als komplex angesehen.

Der Compiler kann die Lebensdauer einer Funktion mit mehreren Argumenten nicht einfach ableiten.

Außerdem hat meine eigene optionale Kiste einen OptionBoolTyp mit einer as_sliceMethode, deren Signatur tatsächlich lautet:

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

Der Compiler hätte das auf keinen Fall herausfinden können.


IINM, das auf die Lebensdauer des Rückgabetyps einer Funktion mit zwei Argumenten schließen lässt, entspricht dem Stoppproblem - IOW, das in einer begrenzten Zeitspanne nicht entscheidbar ist.
Dstromberg

4

Ich habe hier eine weitere gute Erklärung gefunden: http://doc.rust-lang.org/0.12.0/guide-lifetimes.html#returning-references .

Im Allgemeinen können Referenzen nur zurückgegeben werden, wenn sie von einem Parameter an die Prozedur abgeleitet wurden. In diesem Fall hat das Zeigerergebnis immer die gleiche Lebensdauer wie einer der Parameter. Die benannten Lebensdauern geben an, um welchen Parameter es sich handelt.


4

Wenn eine Funktion zwei Referenzen als Argumente empfängt und eine Referenz zurückgibt, gibt die Implementierung der Funktion manchmal die erste und manchmal die zweite Referenz zurück. Es ist unmöglich vorherzusagen, welche Referenz für einen bestimmten Anruf zurückgegeben wird. In diesem Fall ist es unmöglich, eine Lebensdauer für die zurückgegebene Referenz abzuleiten, da jede Argumentreferenz auf eine andere Variablenbindung mit einer anderen Lebensdauer verweisen kann. Explizite Lebensdauern helfen, eine solche Situation zu vermeiden oder zu klären.

Wenn eine Struktur zwei Referenzen enthält (als zwei Mitgliedsfelder), kann eine Mitgliedsfunktion der Struktur manchmal die erste Referenz und manchmal die zweite zurückgeben. Wiederum verhindern explizite Lebensdauern solche Unklarheiten.

In einigen einfachen Situationen gibt es eine Lebenszeitentscheidung, bei der der Compiler auf Lebensdauern schließen kann.


1

Der Grund, warum Ihr Beispiel nicht funktioniert, liegt einfach darin, dass Rust nur eine lokale Lebensdauer und Typinferenz hat. Was Sie vorschlagen, erfordert globale Folgerung. Wenn Sie eine Referenz haben, deren Lebensdauer nicht geändert werden kann, muss sie mit Anmerkungen versehen werden.


1

Als Neuling bei Rust verstehe ich, dass explizite Lebenszeiten zwei Zwecken dienen.

  1. Durch das Hinzufügen einer expliziten lebenslangen Annotation zu einer Funktion wird der Codetyp eingeschränkt, der möglicherweise in dieser Funktion angezeigt wird. Durch explizite Lebensdauern kann der Compiler sicherstellen, dass Ihr Programm das tut, was Sie beabsichtigt haben.

  2. Wenn Sie (der Compiler) überprüfen möchten, ob ein Code gültig ist, müssen Sie (der Compiler) nicht iterativ in jede aufgerufene Funktion schauen. Es reicht aus, einen Blick auf die Anmerkungen von Funktionen zu werfen, die direkt von diesem Code aufgerufen werden. Dies erleichtert es Ihnen (dem Compiler), über Ihr Programm nachzudenken, und macht die Kompilierungszeiten überschaubar.

Betrachten Sie unter Punkt 1 das folgende in Python geschriebene Programm:

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()

welches drucken wird

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

Diese Art von Verhalten überrascht mich immer wieder. Was passiert, ist, dass dfdas Gedächtnis mit geteilt arwird. Wenn also ein Teil des Inhalts von dfÄnderungen in work, ändert sich diese Änderung arauch. In einigen Fällen kann dies jedoch aus Gründen der Speichereffizienz genau das sein, was Sie möchten (keine Kopie). Das eigentliche Problem in diesem Code ist, dass die Funktion second_rowdie erste Zeile anstelle der zweiten zurückgibt. Viel Glück beim Debuggen.

Betrachten Sie stattdessen ein ähnliches Programm, das in Rust geschrieben wurde:

#[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);
}

Wenn Sie dies zusammenstellen, erhalten Sie

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> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^

In der Tat erhalten Sie zwei Fehler, es gibt auch einen mit den Rollen von 'aund 'bvertauscht. Wenn second_rowwir uns die Annotation von ansehen , stellen wir fest, dass die Ausgabe sein sollte &mut &'b mut [i32], dh die Ausgabe soll eine Referenz auf eine Referenz mit Lebensdauer sein 'b(die Lebensdauer der zweiten Zeile von Array). Da wir jedoch die erste Zeile zurückgeben (die eine Lebensdauer hat 'a), beschwert sich der Compiler über eine Nichtübereinstimmung der Lebensdauer. Am richtigen Ort. Zur richtigen Zeit. Das Debuggen ist ein Kinderspiel.


0

Ich stelle mir eine lebenslange Anmerkung als einen Vertrag über eine bestimmte Referenz vor, der nur im Empfangsbereich gültig war, während er im Quellbereich gültig bleibt. Wenn Sie mehr Referenzen in derselben Lebenszeit deklarieren, werden die Bereiche zusammengeführt, was bedeutet, dass alle Quellenreferenzen diesen Vertrag erfüllen müssen. Durch diese Anmerkung kann der Compiler die Vertragserfüllung überprüfen.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.