Utilizzare Box<T>
per Puntare ai Dati nell’Heap
Il puntatore intelligente più semplice è una box (scatola), il cui type è
scritto Box<T>
. Le box consentono di memorizzare i dati nell’heap anziché
sullo stack. Ciò che rimane sullo stack è il puntatore ai dati nell’heap.
Fai riferimento al Capitolo 4 per rinfrescare la memoria sulla
differenza tra stack e heap.
Le box non hanno un overhead di prestazioni, a parte il fatto che memorizzano i dati nell’heap anziché sullo stack. Ma non hanno nemmeno molte funzionalità extra. Li userai più spesso in queste situazioni:
- Quando hai un type la cui dimensione non può essere conosciuta in fase di compilazione e vuoi utilizzare un valore di quel type in un contesto che richiede una dimensione esatta
- Quando hai una grande quantità di dati e vuoi trasferirne la ownership ma vuoi evitare che i dati vengano copiati quando lo fai
- Quando vuoi possedere un valore e ti interessa solo che sia un type con un determinato trait piuttosto che essere di un type specifico
Descriveremo la prima situazione in “Abilitare i Type Ricorsivi con le Box”. Nel secondo caso, il trasferimento della ownership di una grande quantità di dati può richiedere molto tempo perché i dati vengono copiati sullo stack. Per migliorare le prestazioni in questa situazione, possiamo memorizzare la grande quantità di dati nell’heap in una box. Quindi, solo la piccola quantità di dati del puntatore viene copiata sullo stack, mentre i dati a cui fa riferimento rimangono in un unico punto dell’heap. Il terzo caso è noto come oggetto trait (trait object), e la sezione “Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi” nel Capitolo 18 è dedicata specificamente a questo argomento. Quindi, ciò che imparerai qui lo applicherai di nuovo in quella sezione!
Memorizzare Dati nell’Heap
Prima di discutere il caso d’uso di archiviazione nell’heap per Box<T>
,
tratteremo la sintassi e come interagire con i valori memorizzati all’interno di
una Box<T>
.
Il Listato 15-1 mostra come utilizzare una box per memorizzare un valore i32
nell’heap.
fn main() { let b = Box::new(5); println!("b = {b}"); }
i32
nell’heap tramite una boxDefiniamo la variabile b
come avente il valore di una Box
che punta al
valore 5
, allocato nell’heap. Questo programma stamperà b = 5
; in questo
caso, possiamo accedere ai dati nella box in modo simile a come faremmo se
questi dati fossero sullo stack. Proprio come qualsiasi valore posseduto,
quando una box esce dallo scope, come accade a b
alla fine di main
,
verrà de-allocata. La de-allocazione avviene sia per la box (memorizzata sullo
stack) sia per i dati a cui punta (memorizzati nell’heap).
Mettere un singolo valore nell’heap non è molto utile, quindi le box non
verranno utilizzate molto spesso da sole in questo modo. Avere valori come un
singolo i32
sullo stack, dove vengono memorizzati di default, è più
appropriato nella maggior parte delle situazioni. Diamo un’occhiata a un caso in
cui le box ci consentono di definire type che non saremmo autorizzati a
definire se non avessimo le box.
Abilitare i Type Ricorsivi con le Box
Un valore di un type ricorsivo (recursive type) può avere un altro valore dello stesso type come parte di sé. I type ricorsivi pongono un problema perché Rust deve sapere in fase di compilazione quanto spazio occupa un certo type. Tuttavia, l’annidamento dei valori dei type ricorsivi potrebbe teoricamente continuare all’infinito, quindi Rust non può sapere di quanto spazio ha bisogno il valore. Poiché le box hanno dimensioni note, possiamo abilitare i type ricorsivi inserendo una box nella definizione del type ricorsivo.
Come esempio di type ricorsivo, esploriamo la cons list (lista di costrutti). Questo è un tipo di dato comunemente presente nei linguaggi di programmazione funzionale. Il type di cons list che definiremo è semplice, fatta eccezione per la ricorsione; pertanto, i concetti nell’esempio con cui lavoreremo saranno utili ogni volta che ti troverai in situazioni più complesse che coinvolgono i type ricorsivi.
Comprendere la Cons List
Una Cons List è una struttura dati derivata dal linguaggio di programmazione
Lisp e dai suoi dialetti, è composta da coppie annidate ed è la versione Lisp di
una lista concatenata. Il suo nome deriva dalla funzione cons
(abbreviazione
di construct function) in Lisp, che costruisce una nuova coppia a partire dai
suoi due argomenti. Chiamando cons
su una coppia composta da un valore e
un’altra coppia, possiamo costruire cons list composte da coppie ricorsive.
Ad esempio, ecco una rappresentazione in pseudo-codice di una cons list
contenente la lista 1, 2, 3
con ciascuna coppia tra parentesi:
(1, (2, (3, Nil)))
Ogni elemento in una cons list contiene due elementi: il valore dell’elemento
corrente e l’elemento successivo. L’ultimo elemento della lista contiene solo un
valore chiamato Nil
senza un elemento successivo. Una cons list viene
prodotta chiamando ricorsivamente la funzione cons
. Il nome canonico per
indicare il caso base della ricorsione è Nil
. Nota che questo non è lo stesso
del concetto di “null” o “nil” discusso nel Capitolo 6, che indica un valore non
valido o assente.
La cons list non è una struttura dati comunemente utilizzata in Rust. Nella
maggior parte dei casi quando si ha una lista di elementi in Rust, Vec<T>
è
una scelta migliore. Altri tipi di dati ricorsivi più complessi sono utili in
varie situazioni, ma iniziando con la cons list in questo capitolo, possiamo
capire come le box ci consentano di definire un tipo di dati ricorsivo senza
troppe distrazioni.
Il Listato 15-2 contiene una definizione enum per una cons list. Nota che
questo codice non verrà ancora compilato perché il type Lista
non ha una
dimensione nota, che dimostreremo.
enum Lista {
Cons(i32, Lista),
Nil,
}
fn main() {}
i32
Nota: stiamo implementando una cons list che contiene solo valori
i32
per gli scopi di questo esempio. Avremmo potuto implementarla utilizzando i generici, come discusso nel Capitolo 10, per definire un tipo di cons list in grado di memorizzare valori di qualsiasi type.
L’utilizzo del type Lista
per memorizzare l’elenco 1, 2, 3
sarebbe simile
al codice nel Listato 15-3.
enum Lista {
Cons(i32, Lista),
Nil,
}
// --taglio--
use crate::Lista::{Cons, Nil};
fn main() {
let lista = Cons(1, Cons(2, Cons(3, Nil)));
}
Lista
per memorizzare la lista 1, 2, 3
Il primo valore Cons
contiene 1
e un altro valore Lista
. Questo valore
Lista
è un altro valore Cons
che contiene 2
e un altro valore Lista
.
Questo valore Lista
è un altro valore Cons
che contiene 3
e un valore
Lista
, che è infine Nil
, la variante non ricorsiva che segnala la fine della
lista.
Se proviamo a compilare il codice nel Listato 15-3, otteniamo l’errore mostrato nel Listato 15-4.
$ cargo run
Compiling cons-list v0.1.0 (file:///progetti/cons-list)
error[E0072]: recursive type `Lista` has infinite size
--> src/main.rs:1:1
|
1 | enum Lista {
| ^^^^^^^^^^
2 | Cons(i32, Lista),
| ----- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<Lista>),
| ++++ +
error[E0391]: cycle detected when computing when `Lista` needs drop
--> src/main.rs:1:1
|
1 | enum Lista {
| ^^^^^^^^^^
|
= note: ...which immediately requires computing when `Lista` needs drop again
= note: cycle used when computing whether `Lista` needs drop
= note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information
Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
L’errore indica che questo type “ha dimensione infinita”. Il motivo è che
abbiamo definito Lista
con una variante che è ricorsiva: contiene direttamente
un altro valore di se stessa. Di conseguenza, Rust non riesce a calcolare quanto
spazio è necessario per memorizzare un valore Lista
. Analizziamo il motivo per
cui otteniamo questo errore. Innanzitutto, vedremo come Rust determina quanto
spazio è necessario per memorizzare un valore di un type non ricorsivo.
Calcolare la Dimensione di un Type Non Ricorsivo
Riprendiamo l’enum Messaggio
che abbiamo definito nel Listato 6-2 quando
abbiamo discusso le definizioni delle enum nel Capitolo 6:
enum Messaggio { Esci, Sposta { x: i32, y: i32 }, Scrivi(String), CambiaColore(i32, i32, i32), } fn main() {}
Per determinare quanto spazio allocare per un valore Messaggio
, Rust esamina
ciascuna delle varianti per vedere quale variante necessita di più spazio. Rust
vede che Messaggio::Esci
non necessita di spazio, Messaggio::Sposta
necessita di spazio sufficiente per memorizzare due valori i32
e così via.
Poiché verrà utilizzata una sola variante, lo spazio massimo di cui un valore
Messaggio
avrà bisogno è lo spazio che richiederebbe per memorizzare la più
grande delle sue varianti.
Confrontiamo questo con ciò che accade quando Rust cerca di determinare di
quanto spazio necessita un type ricorsivo come l’enum Lista
nel Listato
15-2. Il compilatore inizia esaminando la variante Cons
, che contiene un
valore di type i32
e un valore di type Lista
. Pertanto, Cons
necessita
di una quantità di spazio pari alla dimensione di un i32
più la dimensione di
un Lista
. Per calcolare la quantità di memoria necessaria per il type
Lista
, il compilatore esamina le varianti, a partire dalla variante Cons
. La
variante Cons
contiene un valore di type i32
e un valore di type
Lista
, e questo processo continua all’infinito, come mostrato nella Figura
15-1.
Figura 15-1: Una Lista
infinita composta da infinite
varianti Cons
Ottenere un Type Ricorsivo con una Dimensione Nota
Poiché Rust non riesce a calcolare quanto spazio allocare per i type definiti ricorsivamente, il compilatore genera un errore con questo utile suggerimento:
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<Lista>),
| ++++ +
In questo suggerimento, indirection significa che invece di memorizzare un valore direttamente, dovremmo modificare la struttura dati per memorizzarlo indirettamente, memorizzando invece un puntatore al valore.
Poiché Box<T>
è un puntatore, Rust sa sempre di quanto spazio una Box<T>
necessita: la dimensione di un puntatore non cambia in base alla quantità di
dati a cui punta. Questo significa che possiamo inserire Box<T>
all’interno
della variante Cons
invece di un altro valore Lista
direttamente. Box<T>
punterà al successivo valore Lista
che si troverà nell’heap anziché
all’interno della variante Cons
. Concettualmente, abbiamo ancora una lista,
creata con liste che contengono altre liste, ma questa implementazione ora è più
simile al posizionamento degli elementi uno accanto all’altro piuttosto che uno
dentro l’altro.
Possiamo modificare la definizione dell’enum Lista
nel Listato 15-2 e
l’utilizzo di Lista
nel Listato 15-3 con il codice nel Listato 15-5, che verrà
compilato.
enum Lista { Cons(i32, Box<Lista>), Nil, } use crate::Lista::{Cons, Nil}; fn main() { let lista = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil)))))); }
Lista
utilizzando Box<T>
per avere una dimensione notaLa variante Cons
richiede la dimensione di un i32
più lo spazio per
memorizzare i dati del puntatore della box. La variante Nil
non memorizza
alcun valore, quindi necessita di meno spazio sullo stack rispetto alla
variante Cons
. Ora sappiamo che qualsiasi valore Lista
occuperà le
dimensioni di un i32
più le dimensioni dei dati del puntatore di una box.
Utilizzando una box, abbiamo interrotto la catena infinita e ricorsiva, in
modo che il compilatore possa calcolare la dimensione necessaria per memorizzare
un valore Lista
. La Figura 15-2 mostra l’aspetto attuale della variante
Cons
.
Figura 15-2: Una Lista
che non ha dimensioni infinite
perché Cons
contiene una Box
Le box forniscono solo l’indirezione e l’allocazione nell’heap; non hanno altre funzionalità speciali, come quelle che vedremo con gli altri tipi di puntatori intelligenti. Inoltre, non hanno alcun overhead prestazionale che queste funzionalità speciali comporterebbero, quindi possono essere utili in casi come la cons list, in cui l’indirezione è l’unica funzionalità di cui abbiamo bisogno. Esamineremo altri casi d’uso per le box nel Capitolo 18.
Il type Box<T>
è un puntatore intelligente perché implementa il trait
Deref
, che consente di trattare i valori Box<T>
come reference. Quando un
valore Box<T>
esce dallo scope, anche i dati dell’heap a cui punta il
box vengono de-allocati grazie all’implementazione del trait Drop
. Questi
due trait saranno ancora più importanti per le funzionalità fornite dagli
altri tipi di puntatore intelligente che discuteremo nel resto di questo
capitolo. Vediamo questi due trait più in dettaglio.