Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

File: src/main.rs
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() {}
Listato 15-25: Una definizione di cons list che contiene un 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.

File: src/main.rs
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());
}
Listato 15-26: Creazione di un ciclo di riferimento di due valori Lista che puntano l’uno all’altro

Creiamo 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.

Un rettangolo etichettato 'a'
che punta a un rettangolo contenente l’intero 5. Un rettangolo etichettato 'b'
che punta a un rettangolo contenente l’intero 10. Il rettangolo contenente 5
punta al rettangolo contenente 10, e il rettangolo contenente 10 punta a sua
volta al rettangolo contenente 5, creando un ciclo.

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.

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)]),
    });
}
Listato 15-27: Creazione di un nodo foglia senza figli e di un nodo ramo con foglia come figlio

Cloniamo 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.

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());
}
Listato 15-28: Un nodo 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.

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!(
        "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),
    );
}
Listato 15-29: Creazione di ramo in uno scope interno ed esame dei conteggi dei reference forti e deboli

Dopo 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.

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.