Cicli di Riferimento Possono Causare Perdite di Memoria
Le garanzie di sicurezza della memoria di Rust rendono difficile, ma non
impossibile, creare accidentalmente memoria che non viene mai de-allocata (noto
come memory leak, perdita di memoria). Prevenire completamente le perdite di
memoria non è una delle garanzie di Rust, il che significa che le perdite di
memoria sono sicure in Rust. Possiamo vedere che Rust consente perdite di
memoria utilizzando Rc<T>
e RefCell<T>
: è possibile creare reference in
cui gli elementi si riferiscono l’uno all’altro in un ciclo. Questo crea perdite
di memoria perché il conteggio dei reference di ciascun elemento nel ciclo non
raggiungerà mai 0 e i valori non verranno mai de-allocati.
Creare un Ciclo di Riferimento
Esaminiamo come potrebbe verificarsi un ciclo di riferimento e come prevenirlo,
iniziando con la definizione dell’enum Lista
e di un metodo coda
nel
Listato 15-25.
use crate::Lista::{Cons, Nil}; use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] enum Lista { Cons(i32, RefCell<Rc<Lista>>), Nil, } impl Lista { fn coda(&self) -> Option<&RefCell<Rc<Lista>>> { match self { Cons(_, elemento) => Some(elemento), Nil => None, } } } fn main() {}
RefCell<T>
in modo da poter modificare a cosa fa riferimento una variante Cons
Stiamo utilizzando un’altra variante della definizione Lista
del Listato 15-5.
Il secondo elemento nella variante Cons
è ora RefCell<Rc<Lista>>
, il che
significa che invece di poter modificare il valore i32
come abbiamo fatto nel
Listato 15-24, vogliamo modificare il valore Lista
a cui punta una variante
Cons
. Stiamo anche aggiungendo un metodo coda
per facilitare l’accesso al
secondo elemento se abbiamo una variante Cons
.
Nel Listato 15-26, stiamo aggiungendo una funzione main
che utilizza le
definizioni nel Listato 15-25. Questo codice crea una lista in a
e una lista
in b
che punta alla lista in a
. Quindi modifica la lista in a
per puntare
a b
, creando un ciclo di riferimento. Ci sono istruzioni println!
lungo il
codice per mostrare quali sono i conteggi dei reference in vari punti di
questo processo.
use crate::Lista::{Cons, Nil}; use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] enum Lista { Cons(i32, RefCell<Rc<Lista>>), Nil, } impl Lista { fn coda(&self) -> Option<&RefCell<Rc<Lista>>> { match self { Cons(_, elemento) => Some(elemento), Nil => None, } } } fn main() { let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil)))); println!("a conteggio rc iniziale = {}", Rc::strong_count(&a)); println!("a prossimo elemento = {:?}", a.coda()); let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a)))); println!("a conteggio rc dopo creazione b = {}", Rc::strong_count(&a)); println!("b conteggio rc iniziale = {}", Rc::strong_count(&b)); println!("b prossimo elemento = {:?}", b.coda()); if let Some(link) = a.coda() { *link.borrow_mut() = Rc::clone(&b); } println!("b conteggio rc dopo modifica a = {}", Rc::strong_count(&b)); println!("a conteggio rc dopo modifica a = {}", Rc::strong_count(&a)); // Togli il commento alla prossima riga per vedere che // abbiamo un ciclo; causerà un overflow dello stack. // println!("a prossimo elemento = {:?}", a.coda()); }
Lista
che puntano l’uno all’altroCreiamo un’istanza Rc<Lista>
che contiene un valore Lista
nella variabile
a
con una lista iniziale di 5, Nil
. Creiamo quindi un’istanza Rc<Lista>
che contiene un altro valore Lista
nella variabile b
che contiene il valore
10
e punta alla lista in a
.
Modifichiamo a
in modo che punti a b
invece che a Nil
, creando un ciclo.
Lo facciamo utilizzando il metodo coda
per ottenere un reference a
RefCell<Rc<Lista>>
in a
, che inseriamo nella variabile link
. Quindi
utilizziamo il metodo borrow_mut
su RefCell<Rc<Lista>>
per modificare il
valore al suo interno da Rc<Lista>
che contiene un valore Nil
a Rc<Lista>
in b
.
Quando eseguiamo questo codice, lasciando l’ultimo println!
commentato per il
momento, otterremo questo output:
$ cargo run
Compiling cons-list v0.1.0 (file:///progetti/cons-list)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.36s
Running `target/debug/cons-list`
a conteggio rc iniziale = 1
a prossimo elemento = Some(RefCell { value: Nil })
a conteggio rc dopo creazione b = 2
b conteggio rc iniziale = 1
b prossimo elemento = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b conteggio rc dopo modifica a = 2
a conteggio rc dopo modifica a = 2
Il conteggio dei reference delle istanze di Rc<Lista>
sia in a
che in b
è 2 dopo aver modificato la lista in a
in modo che punti a b
. Alla fine di
main
, Rust elimina la variabile b
, che riduce il conteggio dei reference
dell’istanza b
Rc<Lista>
da 2 a 1. La memoria che Rc<Lista>
ha nell’heap
non verrà de-allocata in questo punto perché il suo conteggio dei reference è
1, non 0. Quindi Rust elimina a
, che riduce anche il conteggio dei reference
dell’istanza a
Rc<Lista>
da 2 a 1. Anche la memoria di questa istanza non
può essere de-allocata, perché l’altra istanza Rc<Lista>
fa ancora riferimento
ad essa. La memoria allocata alla lista rimarrà non utilizzata per sempre. Per
visualizzare questo ciclo di riferimento, abbiamo creato il diagramma in Figura
15-4.
Figura 15-4: Un ciclo di riferimento delle liste a
e b
che puntano l’una all’altra
Se si rimuove il commento dall’ultimo println!
e si esegue il programma, Rust
proverà a stampare questo ciclo con a
che punta a b
che punta a a
e così
via fino a quando lo stack non si riempie completamente (stack overflow).
Rispetto a un programma reale, le conseguenze della creazione di un ciclo di riferimento in questo esempio non sono poi così gravi: subito dopo aver creato il ciclo di riferimento, il programma termina. Tuttavia, se un programma più complesso allocasse molta memoria in un ciclo e la mantenesse per un lungo periodo, utilizzerebbe più memoria del necessario e potrebbe sovraccaricare il sistema, causando l’esaurimento della memoria disponibile.
Creare cicli di riferimento non è facile, ma non è nemmeno impossibile. Se si
hanno valori RefCell<T>
che contengono valori Rc<T>
o simili combinazioni
annidate di type con mutabilità interna e conteggio dei reference, è
necessario assicurarsi di non creare cicli; non ci si può affidare a Rust per
individuarli. Creare un ciclo di riferimento rappresenterebbe un bug logico nel
programma che bisognerebbe minimizzare tramite test automatizzati, revisioni del
codice e altre pratiche di sviluppo software.
Un’altra soluzione per evitare i cicli di riferimento è riorganizzare le
strutture dati in modo che alcuni reference esprimano la ownership e altri
no. Di conseguenza, si possono avere cicli composti da alcune relazioni di
ownership e alcune relazioni di non ownership, e solo le relazioni di
ownership influiscono sulla possibilità o meno di eliminare un valore. Nel
Listato 15-25, vogliamo sempre che le varianti Cons
posseggano la propria
lista, quindi riorganizzare la struttura dati non è possibile. Diamo un’occhiata
a un esempio che utilizza grafi composti da nodi genitore e nodi figlio per
vedere quando le relazioni di non ownership sono un modo appropriato per
prevenire i cicli di riferimento.
Prevenire Cicli di Riferimento Usando Weak<T>
Finora, abbiamo dimostrato che la chiamata a Rc::clone
aumenta lo
strong_count
di un’istanza di Rc<T>
e che un’istanza di Rc<T>
viene
de-allocata solo se il suo strong_count
è 0. È anche possibile creare un
reference debole (weak reference) al valore all’interno di un’istanza di
Rc<T>
chiamando Rc::downgrade
e passando un reference a Rc<T>
. I
reference forti (strong reference) rappresentano il modo in cui è
possibile condividere la ownership di un’istanza di Rc<T>
. I reference
deboli non esprimono una relazione di ownership e il loro conteggio non
influisce sulla de-allocazione di un’istanza di Rc<T>
. Non causeranno un ciclo
di riferimento perché qualsiasi ciclo che coinvolga reference deboli verrà
interrotto quando il conteggio dei valori coinvolti nei reference forti sarà
pari a 0.
Quando si chiama Rc::downgrade
, si ottiene un puntatore intelligente di type
Weak<T>
. Invece di aumentare di 1 il valore strong_count
nell’istanza di
Rc<T>
, la chiamata Rc::downgrade
aumenta di 1 il valore weak_count
. Il
type Rc<T>
utilizza weak_count
per tenere traccia del numero di
reference Weak<T>
esistenti, in modo simile a strong_count
. La differenza
è che weak_count
non deve essere 0 affinché l’istanza Rc<T>
venga
de-allocata.
Poiché il valore a cui fa riferimento Weak<T>
potrebbe essere stato eliminato,
per fare qualsiasi cosa con il valore a cui Weak<T>
punta, è necessario
assicurarsi che il valore esista ancora. Per farlo, devi chiamare il metodo
upgrade
su un’istanza Weak<T>
che restituirà Option<Rc<T>>
. Otterrai il
risultato Some
se il valore Rc<T>
non è stato ancora eliminato e il
risultato None
se il valore Rc<T>
è stato eliminato. Poiché upgrade
restituisce Option<Rc<T>>
, Rust garantirà che i casi Some
e None
vengano
gestiti e che non ci saranno puntatori non validi.
Ad esempio, invece di utilizzare una lista i cui elementi conoscono solo l’elemento successivo, creeremo un albero i cui elementi conoscono i loro elementi figlio e il loro elemento genitore.
Creare una Struttura Dati ad Albero
Per iniziare, creeremo un albero con nodi che conoscono i loro nodi figlio.
Creeremo una struct denominata Nodo
che contiene il proprio valore i32
e i
reference ai valori dei suoi Nodo
figli:
File: src/main.rs
use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] struct Nodo { valore: i32, figli: RefCell<Vec<Rc<Nodo>>>, } fn main() { let foglia = Rc::new(Nodo { valore: 3, figli: RefCell::new(vec![]), }); let ramo = Rc::new(Nodo { valore: 5, figli: RefCell::new(vec![Rc::clone(&foglia)]), }); }
Vogliamo che un Nodo
possieda i suoi figli e vogliamo condividere tale
ownership con le variabili in modo da poter accedere direttamente a ciascun
Nodo
nell’albero. Per fare ciò, definiamo gli elementi Vec<T>
come valori di
type Rc<Nodo>
. Vogliamo anche modificare quali nodi sono figli di un altro
nodo, quindi abbiamo una RefCell<T>
in figli
che incapsula Vec<Rc<Nodo>>
.
Successivamente, utilizzeremo la nostra struct qui definita e creeremo
un’istanza Nodo
denominata foglia
con valore 3
e nessun elemento figlio, e
un’altra istanza denominata ramo
con valore 5
e foglia
come elemento
figlio, come mostrato nel Listato 15-27.
use std::cell::RefCell; use std::rc::Rc; #[derive(Debug)] struct Nodo { valore: i32, figli: RefCell<Vec<Rc<Nodo>>>, } fn main() { let foglia = Rc::new(Nodo { valore: 3, figli: RefCell::new(vec![]), }); let ramo = Rc::new(Nodo { valore: 5, figli: RefCell::new(vec![Rc::clone(&foglia)]), }); }
foglia
senza figli e di un nodo ramo
con foglia
come figlioCloniamo Rc<Nodo>
in foglia
e lo memorizziamo in ramo
, il che significa
che il Nodo
in foglia
ora ha due proprietari: foglia
e ramo
. Possiamo
passare da ramo
a foglia
tramite ramo.figli
, ma non c’è modo di passare da
foglia
a ramo
. Il motivo è che foglia
non ha alcun reference a ramo
e
non sa che sono correlati. Vogliamo che anche foglia
sappia che ramo
è il
suo genitore. Lo faremo ora.
Aggiungere un Reference da un Nodo Figlio al Genitore
Per far sì che il nodo figlio riconosca il suo genitore, dobbiamo aggiungere un
campo genitore
alla definizione della nostra struct Nodo
. Il problema sta
nel decidere quale tipo di genitore
debba essere. Sappiamo che non può
contenere un Rc<T>
, perché ciò creerebbe un ciclo di riferimenti con
foglia.genitore
che punta a ramo
e ramo.figlio
che punta a foglia
, il
che farebbe sì che i loro valori strong_count
non siano mai pari a 0.
Pensando alle relazioni in un altro modo, un nodo genitore dovrebbe possedere i suoi figli: se un nodo genitore viene eliminato, anche i suoi nodi figli dovrebbero essere eliminati. Tuttavia, un figlio non dovrebbe possedere il suo genitore: se eliminiamo un nodo figlio, il genitore dovrebbe comunque continuare ad esistere. Questa è una situazione in cui i reference deboli sono utili!
Quindi, invece di Rc<T>
, faremo in modo che il type di genitore
sia
Weak<T>
, in particolare RefCell<Weak<Nodo>>
. Ora la definizione della
struct Nodo
appare così:
File: src/main.rs
use std::cell::RefCell; use std::rc::{Rc, Weak}; #[derive(Debug)] struct Nodo { valore: i32, genitore: RefCell<Weak<Nodo>>, figli: RefCell<Vec<Rc<Nodo>>>, } fn main() { let foglia = Rc::new(Nodo { valore: 3, genitore: RefCell::new(Weak::new()), figli: RefCell::new(vec![]), }); println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade()); let ramo = Rc::new(Nodo { valore: 5, genitore: RefCell::new(Weak::new()), figli: RefCell::new(vec![Rc::clone(&foglia)]), }); *foglia.genitore.borrow_mut() = Rc::downgrade(&ramo); println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade()); }
Un nodo potrà fare riferimento al suo nodo genitore, ma non ne sarà il
proprietario. Nel Listato 15-28, aggiorniamo main
per utilizzare questa nuova
definizione, in modo che il nodo foglia
abbia un modo per fare riferimento al
suo genitore, ramo
.
use std::cell::RefCell; use std::rc::{Rc, Weak}; #[derive(Debug)] struct Nodo { valore: i32, genitore: RefCell<Weak<Nodo>>, figli: RefCell<Vec<Rc<Nodo>>>, } fn main() { let foglia = Rc::new(Nodo { valore: 3, genitore: RefCell::new(Weak::new()), figli: RefCell::new(vec![]), }); println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade()); let ramo = Rc::new(Nodo { valore: 5, genitore: RefCell::new(Weak::new()), figli: RefCell::new(vec![Rc::clone(&foglia)]), }); *foglia.genitore.borrow_mut() = Rc::downgrade(&ramo); println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade()); }
foglia
con un reference debole al suo nodo genitore, ramo
La creazione del nodo foglia
è simile a quella del Listato 15-27, ad eccezione
del campo genitore
: foglia
inizia senza un genitore, quindi creiamo una
nuova istanza reference vuota Weak<Nodo>
.
A questo punto, quando proviamo a ottenere un reference al genitore di
foglia
utilizzando il metodo upgrade
, otteniamo il valore None
. Lo vediamo
nell’output della prima istruzione println!
:
genitore `foglia` = None
Quando creiamo il nodo ramo
, avrà anche un nuovo reference Weak<Nodo>
nel
campo genitore
perché ramo
non ha un nodo genitore. Abbiamo ancora foglia
come uno dei figli di ramo
. Una volta che abbiamo l’istanza Nodo
in ramo
,
possiamo modificare foglia
per assegnargli un reference Weak<Nodo>
al suo
genitore. Utilizziamo il metodo borrow_mut
su RefCell<Weak<Nodo>>
nel campo
genitore
di foglia
, quindi utilizziamo la funzione Rc::downgrade
per
creare un reference Weak<Nodo>
a ramo
da Rc<Nodo>
in ramo
.
Quando stampiamo di nuovo il genitore di foglia
, questa volta otterremo una
variante Some
che contiene ramo
: ora foglia
può accedere al suo genitore!
Quando stampiamo foglia
, evitiamo anche il ciclo che alla fine si è concluso
con uno stack overflow come nel Listato 15-26; i riferimenti Weak<Nodo>
vengono stampati come (Weak)
:
genitore `foglia` = None
genitore `foglia` = Some(Nodo { valore: 5, genitore: RefCell { value: (Weak) }, figlio: RefCell { value: [Nodo { valore: 3, genitore: RefCell { value: (Weak) }, figlio: RefCell { value: [] } }] } })
L’assenza di output infinito indica che questo codice non ha creato un ciclo di
riferimento. Possiamo anche dedurlo osservando i valori ottenuti chiamando
Rc::strong_count
e Rc::weak_count
.
Visualizzare le Modifiche a strong_count
e weak_count
Osserviamo come i valori di strong_count
e weak_count
delle istanze di
Rc<Nodo>
cambiano creando un nuovo scope interno e spostando la creazione di
ramo
in tale scope. In questo modo, possiamo vedere cosa succede quando
ramo
viene creato e poi eliminato quando esce dallo scope. Le modifiche sono
mostrate nel Listato 15-29.
use std::cell::RefCell; use std::rc::{Rc, Weak}; #[derive(Debug)] struct Nodo { valore: i32, genitore: RefCell<Weak<Nodo>>, figli: RefCell<Vec<Rc<Nodo>>>, } fn main() { let foglia = Rc::new(Nodo { valore: 3, genitore: RefCell::new(Weak::new()), figli: RefCell::new(vec![]), }); println!( "foglia forte = {}, debole = {}", Rc::strong_count(&foglia), Rc::weak_count(&foglia), ); { let ramo = Rc::new(Nodo { valore: 5, genitore: RefCell::new(Weak::new()), figli: RefCell::new(vec![Rc::clone(&foglia)]), }); *foglia.genitore.borrow_mut() = Rc::downgrade(&ramo); println!( "ramo forte = {}, debole = {}", Rc::strong_count(&ramo), Rc::weak_count(&ramo), ); println!( "foglia forte = {}, debole = {}", Rc::strong_count(&foglia), Rc::weak_count(&foglia), ); } println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade()); println!( "foglia forte = {}, debole = {}", Rc::strong_count(&foglia), Rc::weak_count(&foglia), ); }
ramo
in uno scope interno ed esame dei conteggi dei reference forti e deboliDopo la creazione di foglia
, il suo Rc<Nodo>
ha un conteggio forte di 1 e un
conteggio debole di 0. Nello scope interno, creiamo ramo
e lo associamo a
foglia
; a quel punto, quando stampiamo i conteggi, Rc<Nodo>
in ramo
avrà
un conteggio forte di 1 e un conteggio debole di 1 (per foglia.genitore
che
punta a ramo
con un Weak<Nodo>
). Quando stampiamo i conteggi in foglia
,
vedremo che avrà un conteggio forte di 2 perché ramo
ora ha un clone di
Rc<Nodo>
di foglia
memorizzato in ramo.figlio
, ma avrà ancora un conteggio
debole di 0.
Quando lo scope interno termina, ramo
esce dallo scope e il conteggio
forte di Rc<Nodo>
scende a 0, quindi il suo Nodo
viene eliminato. Il
conteggio debole di 1 da foglia.genitore
non ha alcuna influenza
sull’eliminazione o meno di Nodo
, quindi non si verificano perdite di memoria!
Se proviamo ad accedere al genitore di foglia
dopo la fine dello scope,
otterremo di nuovo None
. Alla fine del programma, Rc<Nodo>
in foglia
ha un
conteggio forte di 1 e un conteggio debole di 0 perché la variabile foglia
è
ora di nuovo l’unico reference a Rc<Nodo>
.
Tutta la logica che gestisce i conteggi e l’eliminazione dei valori è integrata
in Rc<T>
e Weak<T>
e nelle loro implementazioni del trait Drop
.
Specificando che la relazione tra un figlio e il suo genitore debba essere un
reference Weak<T>
nella definizione di Nodo
, è possibile fare in modo che
i nodi genitore puntino ai nodi figlio e viceversa senza creare un ciclo di
riferimento e perdite di memoria.
Riepilogo
Questo capitolo ha spiegato come utilizzare i puntatori intelligenti per
ottenere garanzie e compromessi diversi da quelli che Rust applica di default
con i normali reference. Il type Box<T>
ha una dimensione nota e punta ai
dati allocati nell’heap. Il type Rc<T>
tiene traccia del numero di
reference ai dati nell’heap, in modo che i dati possano avere più
proprietari. Il type RefCell<T>
con la sua mutabilità interna ci fornisce un
type che possiamo usare quando abbiamo bisogno di un type immutabile ma con
la possibilità di modificare un valore interno di quel type; inoltre, applica
le regole di prestito in fase di esecuzione anziché in fase di compilazione.
Sono stati inoltre discussi i trait Deref
e Drop
, che abilitano molte
delle funzionalità dei puntatori intelligenti. Abbiamo esplorato i cicli di
riferimento che possono causare perdite di memoria e come prevenirle utilizzando
Weak<T>
.
Se questo capitolo ha suscitato il tuo interesse e desideri implementare i tuoi puntatori intelligenti, consulta “The Rustonomicon” per ulteriori informazioni utili.
In seguito, parleremo della concorrenza in Rust. Imparerai anche a conoscere alcuni nuovi puntatori intelligenti.