Dlaczego w Rdzy potrzebne są jawne okresy istnienia?


199

Czytałem rozdział poświęcony wcieleniom księgi Rdza i natknąłem się na ten przykład dla nazwanego / jawnego życia:

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

Jest dla mnie jasne, że błąd jest uniemożliwiony przez kompilator jest stosowanie po zwolnieniu odniesienia przypisane do x: po zakres wewnętrzna jest wykonywana, fa zatem &f.xstają się nieważne i nie powinny być przypisane dox .

Moim problemem jest to, że problem można było łatwo przeanalizować bez użycia jawnego 'a okresu istnienia, na przykład poprzez nielegalne przypisanie odwołania do szerszego zakresu (x = &f.x; ).

W jakich przypadkach rzeczywiście potrzebne są jawne czasy życia, aby zapobiec błędom użytkowania po zwolnieniu (lub innej klasie?)?



2
Przyszli czytelnicy tego pytania powinni pamiętać, że zawiera on linki do pierwszego wydania książki, a teraz jest już drugie wydanie :)
carols10cents

Odpowiedzi:


205

Wszystkie pozostałe odpowiedzi mają istotne punkty ( konkretny przykład fjh, w którym potrzebne jest jawne życie ), ale brakuje jednej kluczowej rzeczy: dlaczego potrzebne są jawne czasy życia, kiedy kompilator powie ci, że się pomyliłeś ?

To jest właściwie to samo pytanie, „dlaczego potrzebne są typy jawne, skoro kompilator może je wywnioskować”. Hipotetyczny przykład:

fn foo() -> _ {  
    ""
}

Oczywiście kompilator może zobaczyć, że zwracam &'static str , więc dlaczego programista musi go wpisać?

Głównym powodem jest to, że chociaż kompilator może zobaczyć, co robi twój kod, nie wie, jakie było twoje zamierzenie.

Funkcje są naturalną granicą zapory dla efektów zmiany kodu. Gdybyśmy pozwolili na całkowite sprawdzenie żywotności na podstawie kodu, wówczas niewinnie wyglądająca zmiana mogłaby wpłynąć na czasy życia, co mogłoby następnie spowodować błędy w funkcji daleko. To nie jest hipotetyczny przykład. Jak rozumiem, Haskell ma ten problem, gdy polegasz na wnioskowaniu o typie dla funkcji najwyższego poziomu. Rdza zdusiła ten szczególny problem w zarodku.

Kompilator zapewnia także korzyści związane z wydajnością - parsowane są tylko sygnatury funkcji w celu weryfikacji typów i czasów życia. Co ważniejsze, zapewnia programistom korzyści związane z wydajnością. Jeśli nie mielibyśmy jawnych okresów istnienia, co robi ta funkcja:

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

Nie można powiedzieć bez sprawdzenia źródła, co byłoby sprzeczne z ogromną liczbą najlepszych praktyk kodowania.

na podstawie nielegalnego przypisania odniesienia do szerszego zakresu

Zasadniczo zakresy wcieleniami . Nieco bardziej wyraźnie, lifetime 'ajest ogólnym parametrem lifetime, który może być wyspecjalizowany w określonym zakresie w czasie kompilacji, w oparciu o witrynę wywoływania.

czy rzeczywiście potrzebne są jawne czasy życia, aby zapobiec [...] błędom?

Ani trochę. Czasy życia są potrzebne, aby zapobiec błędom, ale jawne czasy życia są potrzebne, aby chronić to, co mają mało programiści rozsądku.


18
@ jco Wyobraź sobie, że masz jakąś funkcję najwyższego poziomu f x = x + 1bez podpisu typu, którego używasz w innym module. Jeśli później zmienisz definicję na f x = sqrt $ x + 1, jej typ zmieni się z Num a => a -> ana Floating a => a -> a, co spowoduje błędy typu we wszystkich witrynach wywoławczych, w których fjest wywoływany, np. Z Intargumentem. Posiadanie podpisu typu gwarantuje, że błędy występują lokalnie.
fjh

11
„Zasadniczo zakresy są czasem życia. Trochę bardziej wyraźnie, czas życia„ a jest ogólnym parametrem czasu życia, który może być wyspecjalizowany w określonym zakresie w czasie połączenia. ” Wow, to naprawdę świetny, pouczający punkt. Chciałbym, żeby zostało to wyraźnie zawarte w książce.
corazza

2
@fjh Dzięki. Żeby sprawdzić, czy go nie rozumiem - chodzi o to, że jeśli typ został wyraźnie określony przed dodaniem sqrt $, po zmianie wystąpiłby tylko błąd lokalny, a nie wiele błędów w innych miejscach (co byłoby znacznie lepsze, gdybyśmy tego nie zrobili nie chcesz zmienić faktycznego typu)?
corazza

5
@jco Dokładnie. Brak określenia typu oznacza, że ​​możesz przypadkowo zmienić interfejs funkcji. Jest to jeden z powodów, dla których zdecydowanie zaleca się dodawanie adnotacji do wszystkich przedmiotów najwyższego poziomu w Haskell.
fjh

5
Również jeśli funkcja otrzyma dwa odwołania i zwróci odniesienie, może czasami zwrócić pierwsze odwołanie, a czasem drugie. W takim przypadku nie można wnioskować o czasie życia dla zwróconego odwołania. Jawne okresy życia pomagają uniknąć / wyjaśnić taką sytuację.
MichaelMoser

93

Rzućmy okiem na następujący przykład.

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

Tutaj ważne są jawne czasy życia. Kompiluje się, ponieważ wynik fooma taki sam okres istnienia jak pierwszy argument ( 'a), więc może przeżyć drugi argument. Jest to wyrażone przez dożywotnie nazwy w podpisie foo. Jeśli przełączysz argumenty w wywołaniu foona kompilator, narzeka, że ynie istnieje on wystarczająco długo:

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

Adnotacja dożywotnia w następującej strukturze:

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

określa, że Fooinstancja nie powinna przeżyć odwołania, które zawiera ( xpole).

Przykład, na który natrafiłeś w książce Rust, nie ilustruje tego, ponieważ fiy zmienne wychodzić z zakresu jednocześnie.

Lepszym przykładem byłoby to:

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

Teraz fnaprawdę przeżywa zmienną wskazywaną przez f.x.


9

Zauważ, że w tym fragmencie kodu nie ma wyraźnych okresów istnienia, z wyjątkiem definicji struktury. Kompilator doskonale potrafi wnioskować o żywotnościmain() .

Jednak w definicjach typów nie można uniknąć jawnych okresów istnienia. Na przykład tutaj jest dwuznaczność:

struct RefPair(&u32, &u32);

Czy powinny to być różne czasy życia, czy powinny być takie same? Ma to znaczenie z punktu widzenia użytkowania, struct RefPair<'a, 'b>(&'a u32, &'b u32)różni się bardzo odstruct RefPair<'a>(&'a u32, &'a u32) .

Teraz, dla prostych przypadków, takich jak ten, który podałeś, kompilator może teoretycznie uchylać wcielenia tak, jak ma to miejsce w innych miejscach, ale takie przypadki są bardzo ograniczone i nie są warte dodatkowej złożoności w kompilatorze, a to zwiększenie przejrzystości byłoby co najmniej wątpliwe.


2
Czy możesz wyjaśnić, dlaczego są one bardzo różne?
AB

@AB Drugi wymaga, aby oba odwołania miały ten sam okres istnienia. Oznacza to, że refpair.1 nie może żyć dłużej niż refpair.2 i odwrotnie - więc obie referencje muszą wskazywać na coś z tym samym właścicielem. Pierwszy wymaga jednak tylko, aby RefPair przeżył obie jego części.
llogiq

2
@AB, kompiluje, ponieważ oba okresy są zjednoczone - ponieważ lokalne wcieleń są mniejsze, że 'static, 'staticmoże być stosowany wszędzie tam, gdzie można wykorzystać lokalne wcieleń, dlatego w swoim przykładzie pbędzie musiał jej parametrem życia wywnioskować jako lokalnego życia y.
Vladimir Matveev

5
@AB RefPair<'a>(&'a u32, &'a u32)oznacza, że 'abędzie to przecięcie obu wejściowych okresów istnienia, tj. W tym przypadku okres istnienia y.
fjh

1
@llogiq „wymaga, aby RefPair przeżył obie części”? Myślałem, że jest odwrotnie ... a & u32 może mieć sens bez RefPair, podczas gdy RefPair z martwymi referencjami byłby dziwny.
qed

6

Obudowa z książki jest bardzo prosta z założenia. Temat wcieleń uważany jest za złożony.

Kompilator nie może łatwo wnioskować o czasie życia w funkcji z wieloma argumentami.

Ponadto moja opcjonalna skrzynia ma OptionBooltyp z as_slicemetodą, której podpis jest w rzeczywistości:

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

Nie ma absolutnie żadnej możliwości, aby kompilator mógł to rozgryźć.


IINM, wnioskowanie o czasie życia typu zwracanego przez funkcję dwóch argumentów będzie równoznaczne z problemem zatrzymania - IOW, którego nie można rozstrzygnąć w skończonym czasie.
dstromberg,


4

Jeśli funkcja otrzymuje dwa argumenty jako argumenty i zwraca odwołanie, wówczas implementacja funkcji może czasami zwrócić pierwsze odwołanie, a czasem drugie. Nie można przewidzieć, który numer referencyjny zostanie zwrócony dla danego połączenia. W takim przypadku nie można wnioskować o czasie życia dla zwróconego odwołania, ponieważ każde odwołanie do argumentu może odnosić się do innej zmiennej powiązanej z innym czasem życia. Jawne okresy życia pomagają uniknąć lub wyjaśnić taką sytuację.

Podobnie, jeśli struktura zawiera dwa odwołania (jako dwa pola elementów), wówczas funkcja elementu struktury może czasami zwracać pierwsze odwołanie, a czasem drugie. Ponownie jawne okresy życia zapobiegają takim dwuznacznościom.

W kilku prostych sytuacjach istnieje dożywotnia decyzja, w której kompilator może wnioskować o żywotności.


1

Powodem, dla którego twój przykład nie działa, jest po prostu fakt, że Rust ma tylko lokalny okres istnienia i wnioskowanie o typie. To, co sugerujesz, wymaga globalnego wnioskowania. Ilekroć masz odniesienie, którego życia nie można pominąć, należy je opatrzyć adnotacjami.


1

Jako nowicjusz Rust, rozumiem, że jawne czasy życia służą dwóm celom.

  1. Umieszczenie wyraźnej adnotacji na całe życie na funkcji ogranicza rodzaj kodu, który może pojawić się wewnątrz tej funkcji. Jawne czasy życia pozwalają kompilatorowi upewnić się, że program robi to, co zamierzałeś.

  2. Jeśli chcesz (kompilator) sprawdzić, czy fragment kodu jest poprawny, to (kompilator) nie będziesz musiał iteracyjnie sprawdzać każdej wywoływanej funkcji. Wystarczy spojrzeć na adnotacje funkcji, które są bezpośrednio wywoływane przez ten fragment kodu. To sprawia, że ​​twój program jest o wiele łatwiejszy do uzasadnienia dla ciebie (kompilatora) i ułatwia zarządzanie czasami kompilacji.

W punkcie 1. Rozważmy następujący program napisany w języku 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()

który wydrukuje

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

Ten rodzaj zachowania zawsze mnie zaskakuje. To, co się dzieje, dfpolega na dzieleniu się pamięcią ar, więc gdy część treści dfzmian work, ta zmiana również zaraża ar. Jednak w niektórych przypadkach może to być dokładnie to, czego chcesz, ze względu na wydajność pamięci (brak kopii). Prawdziwym problemem w tym kodzie jest to, że funkcja second_rowzwraca pierwszy wiersz zamiast drugiego; powodzenia w debugowaniu tego.

Zamiast tego rozważ podobny program napisany w 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);
}

Kompilujesz to

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

W rzeczywistości otrzymujesz dwa błędy, jest też jeden z rolami 'ai 'bzamienionymi. Patrząc na adnotację second_row, widzimy, że wyjście powinno być &mut &'b mut [i32], tzn. Wyjście powinno być odniesieniem do odwołania z czasem życia 'b(czas życia drugiego rzędu Array). Ponieważ jednak zwracamy pierwszy wiersz (który ma okres istnienia 'a), kompilator skarży się na niedopasowanie czasu życia. We właściwym miejscu. We właściwym czasie. Debugowanie to pestka.


0

Myślę, że adnotacja na całe życie jako umowa o danym ref była ważna tylko w zakresie odbiorczym, dopóki pozostaje ważna w zakresie źródłowym. Deklarowanie większej liczby referencji w tym samym cyklu życia łączy zakresy, co oznacza, że ​​wszystkie referencje źródłowe muszą spełniać tę umowę. Taka adnotacja umożliwia kompilatorowi sprawdzenie wykonania umowy.

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.