Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 34
Lifetime

Avete mai sentito parlare di dangling pointer? E' uno dei grattacapi che per anni hanno causato incubi notturni e diurni a legioni di programmatori. Sostanzialmente si tratta di un problema che sorge quando si cerca di utilizzare un puntatore che punta a qualche cosa che non esiste più. Quindi riporta ad un indirizzo di memoria che era occupato da un qualche elemento, una variabile, una funzione, un oggetto, che è stato eliminato. Insomma, usiamo il riferimento ad una memoria deallocata. Questo, considerando che in quella locazione non c'è più quello che ci aspettavamo, ovviamente porta a comportamenti randomici del vostro programma, dal risultato sballato, al crash completo dell'applicazione. Non credo che esista programmatore in C/C++ (e non solo) che non sia incappato nei famosi segfault sotto UNIX o negli "errore di protezione generale" in Windows. Difficilmente ve la caverete con poco danno. La questione è stata affrontata in vari modi da chi si occupa di scrivere linguaggi e sul web troverete molti documenti interessanti in merito. Rust ha la sua filosofia e il suo peculiare sistema di affrontare il problema, e vale la pena prendersi un po' di tempo perchè, forse, non è così immediato comprendere tutto.
Il meccanismo di lifetime, come suggerisce anche il suo nome, garantisce il legame tra la vita di un oggetto e del suo riferimento garantendo che quest'ultimo non esista più quando cessa la vita dell'oggetto a cui è legato. Abbiamo già visto vari esempi di "tempo di vita" ad esempio quando si esce da un certo ambito, le variabili definite in quell'ambito cessano di esistere. Si capisce facilmente quindi come non sia possibile usare riferimenti che puntino ad elementi non più esistenti. Questo meccanismo quindi si lega profondamente al concetto di borrowing e la sua comprensione è fondamentale per la scrittura di codice veramente solido. In generale Rust fa da sè per quanto riguarda la faccenda in generale ma in alcuni casi richiede un po' di aiuto e qui vedremo come accontentarlo.
Cominciamo a vedere un semplice esempio:

  Esempio 34.1
1
2
3
4
5
6
7
fn erref() -> &String {
    "hello"
}
fn main()
{
    let r = erref();
}

Questo programma non compila e anzi ne esce un chilometrico errore che inizia così:

error[E0106]: missing lifetime specifier
--> r531.rs:1:15
|
1 | fn erref() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from


La prima riga, quella rossa, ci dice che si tratta di un problema di lifetime e ci torneremo sopra.
L'ultima, quella blu, è esplicativa: la funzione erref, restituisce una riferimento a stringa che vive e muore con la funzione erref, alla riga 3 c'è il riferimento. Quando erref finisce la stringa s viene deallocata e se il riferimento rimanesse in vita ecco che avremmo un dangling pointer. Ma il compilatore lo sa e previene questa situazione. Come si può aggiustare il codice precedente, a parte seguire strade completamente diverse? Una soluzione fa uso della keyword static. Questa è estremamente utile in Rust e sostanzialmente lega il contenuto di un certo indirizzo di memoria al codice binario del programma stesso bloccando quindi i processi di drop. Ecco il programma modificato e funzionante:

fn erref() -> &'static str {
"hello"
}

fn main() {
let r = erref();
println!("{}", r);
}


noterete subito che static è preceduto dall'operatore ' (apice) che costituisce l'annotazione che permette al compilatore di comprendere come i riferimenti si relazionano tra loro in termini di durata. In questoo caso usiamo static che un lifetime speciale che indica che l'elemento avrà vita per tutta la chiamata del programma. Tenete presente che static non si può applicare a tutto ma solo a quei tipi che possono vivere nel binario del programma. In particolare valgono alcune considerazioni:

1) L'accesso a una variabile statica in Rust è sicuro, nel senso che il compilatore garantisce che non ci saranno condizioni di gara (race conditions) o altri problemi di sicurezza della memoria. Tuttavia, per assicurare questa sicurezza, ci sono delle restrizioni specifiche su come possono essere usate le variabili statiche.
2) Per permettere l'accesso sicuro ad elementi static tra diversi thread, il tipo della variabile statica deve implementare il trait Sync.

Più in generale static risulta utile
  • Valori costanti o stringhe letterali, che vivono in un'area di memoria statica.
  • Risorse allocate a livello globale che devono essere disponibili per tutta l’esecuzione.

Il seguente programma, che trovate praticamente identico nella documentazione ufficiale (lo trovo perfetto, quindi lo riporto) invece entra proprio nel cuore della questione:

  Esempio 34.2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("Rust");
    let string2 = String::from("C++");
    let result = longest(string1.as_str(), string2.as_str());
    println!("La stringa più lunga è {}", result);
}

Vediamo di analizzare il codice e la cosa migliore è vedere l'errore che ci restituisce il compilatore nel caso in cui non utilizziamo l'annotazione di lifetime, ovvero se ponessimo come firma della funzione:

fn longest(x: &str, y: &str) -> &str

da cui ricaveremmo:

error[E0106]: missing lifetime specifier
--> r534.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++


error: lifetime may not live long enough
--> r534.rs:3:9
|
1 | fn longest(x: &str, y: &str) -> &str {
| - let's call the lifetime of this reference `'1`
2 | if x.len() > y.len() {
3 | x
| ^ returning this value requires that `'1` must outlive `'static`

error: lifetime may not live long enough
--> r534.rs:5:9
|
1 | fn longest(x: &str, y: &str) -> &str {
| - let's call the lifetime of this reference `'2`
...
5 | y
| ^ returning this value requires that `'2` must outlive `'static`

error: aborting due to 3 previous errors

For more information about this error, try `rustc --explain E0106`


quindi vediamo subito che ci viene detto che manca la specifica del lifetime, immediatamente dopo il compilatore si lamenta che non sa se il tipo di ritorno sarà determinato da x o da y. Per tranquillizzare il compilatore, che nel dubbio blocca il suo lavoro, è necessario proprio assegnare un lifetime il quale comunica che il riferimento, quale che sia, restituito dalla funzione avrà la stessa lifetime dei parametri di input, garantendo così che il riferimento non diventi dangling. Ci viene mostrata anche la riga corretta, che coincide con quella dell'esempio precedente. Questo lifetime è diverso dal precedente, static in effetti, modifica la durata della vita di una variabile la quale, così marcata, dura per tutta l'esistenza del programma, in quest'ultimo esempio invece, <'a> informa il compilatore che la durata delle relazioni stabilite è coerente in termini di durata.
Altro esempio interessante è il seguente:

  Esempio 34.3
1
2
3
4
5
6
7
8
9
10
11
12
#[derive(Debug)]
struct A1 {
    a01: &str,
    a02: i32,
}
fn main() {
    let a1 = A1 {
        a01 : "ciao",
        a02 : 4,
    };
    println!("{}", a1.a01);
}

Questo programma non compila:

error[E0106]: missing lifetime specifier
--> r541.rs:3:10
|
3 | a01: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
2 ~ struct A1<'a> {
3 ~ a01: &'a str,


Il problema nasce dal fatto che a01 è un riferimento e il compilatore non ha informazioni relativamente alla vita dell'elemento puntato. Una soluzione è quindi usare static, modificando la riga 3 come segue:

a01: &' static str

e allora tutto funziona. Però non è tutto così semplice. Se ragioniamo come abbiamo detto, capiamo subito che non è detto che ciò a cui si riferisce il puntatore viva effettivamente per tutto il programma cosa che invece richiede static. Se il riferimento puntasse a qualche altra cosa si potrebbero verificare quelle situazioni di dangling che stiamo cercando di evitare. Ad esempio:

  Esempio 34.4
1
2
3
4
5
6
7
8
9
10
11
12
13
#[derive(Debug)]
struct A1 {
    a01: &'static str,
    a02: i32,
}
fn main() {
    let v1 = vec!["aaa".to_string(), "bbb".to_string()];
    let a1 = A1 {
        a01 : &v1[0],
        a02 : 4,
    };
    println!("{}", a1.a01);
}

Questo codice non compila e l problema principale risiede nel modo in cui viene creato il vettore v1. Utilizziamo il metodo to_string() su ogni stringa letterale "aaa" e "bbb". Questo crea due oggetti String allocati sul heap e ne memorizza i riferimenti nel vettore.
Tuttavia, la struttura A1 si aspetta un riferimento a un 'static str (stringa letterale statica). Le stringhe allocate staticamente risiedono nella memoria del programma per tutta la sua durata e i riferimenti ad esse hanno la stessa durata. Al contrario, le stringhe allocate sul heap (String) hanno una durata dinamica che può essere più breve di quella del programma.
Quando si tenta di memorizzare un riferimento a una stringa allocata sul heap (&v1[0]) in a1.a01, il compilatore genera un errore perché la durata dell'oggetto String all'interno di v1 non corrisponde alla durata richiesta di un riferimento 'static str.
Usare static inoltre riduce l'utilità del meccanismo di borrowing, causa problemi, come visto, di elasticità nel programmare (che può non sentirsi in questi semplici programmi ma che può causare grattacapi non indifferenti in progetti più ampi) e procura gap prestazionali anche sensibili.
Nell'esempio precedenti infatti il compilatore ci propone una soluzione molto più elastica, che rende generica la struttura rispetto alla lifetime, nelle righe evidenziate in verde:

  Esempio 34.5
1
2
3
4
5
6
7
8
9
10
11
12
13
#[derive(Debug)]
struct A1<'a> {
    a01: &'a str,
    a02: i32,
}
fn main() {
    let v1 = vec!["aaa".to_string(), "bbb".to_string()];
    let a1 = A1 {
        a01 : &v1[0],
        a02 : 4,
    };
    println!("{}", a1.a01);
}

La firma della struct alla riga 2 deve essere interpretata come segue:
  • Il parametro 'a viene dichiarato dopo il nome della struct, racchiuso tra apici singoli.
  • Può essere utilizzato per annotare i campi della struct che contengono riferimenti.
  • La durata di validità dei riferimenti è determinata dall'espressione o dal blocco di codice in cui viene creata la struct.
Sostanzialmente, diciamo ad un livello di base, che il blocco di codice, o anche l'espressione, in cui è definita la struttura determina la vita dei riferimenti. Va precisato che noi scriviamo <'a> o <'b> eccetera in quanto è molto rapido dal punto di vista della digitazione ed in un certo senso quasi convenzionale ma potremmo scrivere <'pippo> e le cose funzionerebbero allo stesso modo.

Vediamo un altro esempio, quello più diffuso online legato alle struct, giusto per imprimere ancora di più il concetto:

  Esempio 34.6
1
2
3
4
5
6
struct S01<T> {
    el1: &T
}
fn main() {
    let s01 = S01 {el1: &5}
}

Anche questo esempio non compila e ad esso dovrebbe essere facile capire perchè: la struct contiene un riferimento ma non è specificato quanto quel riferimento resterà valido. Le regole di lifetime in pratica definiscono una sorta di area di validità per il riferimento, si occuperà poi il borrow checker di analizzare tale area. In breve la necessaria annotazione specifica che il riferimento dovrà vivere quanto l'istanza della struttura.
Come già avrete capito la struttura dovrà essere definita come segue:

struct S01<'a, T> {
el1: &'a T,
}

In parole semplici, il riferimento el1 è valido solo finché la struct s01 esiste. Questo garantisce la sicurezza della memoria evitando che il riferimento punti a un valore già deallocato o che non sia più accessibile.

Il borrow checker di Rust entra in gioco per far rispettare queste regole di lifetime. Analizza il codice per assicurarsi che i riferimenti non vengano utilizzati in modo errato, come ad esempio tentando di accedervi dopo che la struct è stata deallocata.

Ecco alcuni punti chiave da tenere a mente:

  • La regione di vita di una struct è determinata dalla sua allocazione e deallocazione. Ad esempio, se una struct viene allocata sulla pila, la sua regione di vita sarà la durata della chiamata alla funzione in cui è stata creata.
  • Il borrow checker tiene traccia delle relazioni di lifetime tra i diversi riferimenti nel codice. Ad esempio, se una struct contiene un riferimento ad un'altra struct, la regione di vita del primo riferimento deve includere la regione di vita del secondo.
  • Se il borrow checker rileva una violazione del lifetime, compilerà il codice con un errore.

Se non fosse per l'annotazione <'a, T>, il compilatore non saprebbe quale lifetime associare al riferimento el1. Potrebbe essere la regione di vita della struct stessa ('a), la regione di vita del valore a cui punta il riferimento (potenzialmente diversa dalla struct), o una terza regione di vita arbitraria. Senza una specifica indicazione, il compilatore non può prendere una decisione sicura. Invece con l'annotazione noi specifichiamo che si tratta proprio dell'elemento che trova nella istanziazione della struct.

REGOLE DI ELISIONE

Lo sviluppo del compilatore Rust è stato caratterizzato da numerosi fasi. Prima di arrivare alla versione 1.0, a seguito delle esperienze e dei feedback dei programmatori si è arrivati a definire dei modelli interni al compilatore che possono evitare la necessità di annotazioni esplicite. In futuro questi modelli potrebbero ulteriormente espandersi. Lo scopo evidentemente è quello di ridurre il codice da scrivere ad opera del programmatore rendendo anche più facile la lettura. Le regoledi elisione sono sostanzialmente 3 una riferita all'input e le altre all'output delle funzioni. .Vedremo poi come estenderle ma iniziamo dall'aspetto più intuitivo. Come da manuale, sono le seguenti:

1) Ogni riferimento ha un proprio lifetime assegnato dal compilatore. Quindi se abbiamo più parametri in ingresso ognuno potrà avere il suo parametro di durata:
fn foo<'a, 'b>(x: &'a i32, y: &'b i32);
2) Se esiste un parametro di durata dell'input esso viene esteso a tutti i parametri in output
3) Se sono presenti più parametri di durata ma uno di essi è &self o &mut self la durata di self viene estsa a tutti i parametri di output.

La cosa non è banale, d'altronde si tratta di uno dei meccanismi più importanti e profondi di Rust. Vediamo il seguente codice:

  Esempio 34.7
1
2
3
4
5
6
7
fn s1<'a>(s: &'a str, start: usize, end: usize) -> &'a str {
    &s[start..end]
}
fn main() {
    let ds1 = s1("abcde", 1,3);
    println!("{}", ds1);
}

Questo programma compila e gira tranquillamente. Applicando le regole di elisione notiamo subito che ogni parametro ha un suo lifetime ben definito inoltre il primo riferimento di input è assegnato al riferimento di output: Il riferimento di output &str ha lo stesso lifetime del riferimento di input s. Quindi possiamo riscrivere il programma come segue:

fn s1(s: &str, start: usize, end: usize) -> &str {
    &s[start..end]
}
fn main() {
    let ds1 = s1("abcde", 1,3);
    println!("{}", ds1);
}


senza annotazioni.

Per chiudere riassumiamo con uno schema che trovate anche nel capitolo dedicato al lifetime sul sito ufficiale:

&i32 // riferimento
&'a i32 // riferimento con lifetime esplicito
&'a mut i32 // riferimento mutabile con lifetime esplicito

e aggiungiamo un semplice esempio dell'ultimo caso:

  Esempio 34.8
1
2
3
4
5
6
7
8
fn update_value<'a>(value: &'a mut i32) {
    *value += 1; // Modifica il valore referenziato
}
fn main() {
    let mut num = 10;
    update_value(&mut num); // Passiamo un riferimento mutabile a `num`
    println!("Il valore aggiornato è: {}", num);
}

L'esempio è molto semplice, abbiamo alla riga 6 il passaggio di un parametro mutabile che viene poi confermato, ovviamente, tale, nella firma della funzione.
Il concetto di lifetime, come avrete capito è fondamentale in Rust e va analizzato con estrema attenzione essendo uno dei cardini dell'innovativo sistema di gestione della memoria di questo linguaggio. Non è possibile illustrarlo, a mio avviso, farlo comprendere del tutto in un semplice paragrafo come questo, tuttavia ne segnaleremo l'entrata in gioco ogni qual volta ciò accadrà nei vari esempi che vedremo. Certo non è la cosa più semplice del mondo, anche concettualmente. Rust però prevede anche un meccanismo che in alcuni casi è utile per affrontare lo stesso problema in modo forse più pratico, almeno io lo giudico così. Lo vedremo più avanti tenete presente questa sigla: Rc.