Cos’è la Ownership?
La ownership è un insieme di regole che disciplinano la gestione della memoria da parte di un programma Rust. Tutti i programmi devono gestire il modo in cui utilizzano la memoria del computer durante l’esecuzione. Alcuni linguaggi hanno una garbage collection che cerca regolarmente la memoria non più utilizzata durante l’esecuzione del programma; in altri linguaggi, il programmatore deve allocare e rilasciare esplicitamente la memoria. Rust utilizza un terzo approccio: la memoria viene gestita attraverso un sistema di controllo esclusivo con un insieme di regole che il compilatore controlla. Se una qualsiasi delle regole viene violata, il programma non viene compilato. Nessuna delle caratteristiche di ownership rallenterà il tuo programma mentre è in esecuzione.
Poiché la ownership è un concetto nuovo per molti programmatori, ci vuole un po’ di tempo per abituarsi. La buona notizia è che più si acquisisce esperienza con Rust e con le regole del sistema di ownership, più sarà facile sviluppare naturalmente codice sicuro ed efficiente. Non mollare!
Quando capirai la ownership, avrai una solida base per comprendere le caratteristiche che rendono Rust unico. In questo capitolo imparerai la ownership lavorando su alcuni esempi che si concentrano su una struttura di dati molto comune: le stringhe.
Lo Stack e l’Heap
Molti linguaggi di programmazione non richiedono di pensare allo stack e all’heap molto spesso. Ma in un linguaggio di programmazione di sistema come Rust, il fatto che un valore sia sullo stack o nell’heap influisce sul comportamento del linguaggio e sul motivo per cui devi prendere determinate decisioni.
Sia lo stack che l’heap sono parti di memoria disponibili per il codice da utilizzare in fase di esecuzione, ma sono strutturate in modi diversi. Lo stack memorizza i valori nell’ordine in cui li ottiene e rimuove i valori nell’ordine opposto. Questo viene definito last in, first out (LIFO). Pensa a una pila di piatti: quando aggiungi altri piatti, li metti in cima alla pila e quando ti serve un piatto, ne togli uno dalla cima. Aggiungere o rimuovere i piatti dal centro o dal fondo non funzionerebbe altrettanto bene! L’aggiunta di dati sullo stack viene definita push (immissione), mentre la rimozione dei dati viene definita pop (estrazione). Tutti i dati archiviati sullo stack devono avere una dimensione nota e fissa. I dati con una dimensione sconosciuta in fase di compilazione o una dimensione che potrebbe cambiare devono invece essere archiviati nell’heap.
L’heap è meno organizzato: quando metti i dati nell’heap, richiedi una certa quantità di spazio. L’allocatore di memoria trova un punto vuoto nell’heap che sia sufficientemente grande, lo contrassegna come in uso e restituisce un puntatore, che è l’indirizzo di quella posizione. Questo processo è chiamato allocazione nell’heap e talvolta è abbreviato semplicemente in allocazione (l’inserimento di valori sullo stack non è considerato allocazione). Poiché il puntatore all’heap ha una dimensione nota e fissa, è possibile archiviare il puntatore sullo stack, ma quando si desiderano i dati effettivi è necessario seguire il puntatore. Pensa di essere seduto in un ristorante. Quando entri, indichi il numero di persone nel tuo gruppo e il cameriere trova un tavolo vuoto adatto a tutti e ti conduce lì. Se qualcuno nel tuo gruppo arriva in ritardo, può chiedere dove sei seduto e trovarti.
Il push sullo stack è più veloce dell’allocazione nell’heap perché l’allocatore non deve mai cercare un posto dove archiviare i nuovi dati; quella posizione è sempre in cima allo stack. In confronto, l’allocazione dello spazio nell’heap richiede più lavoro perché l’allocatore deve prima trovare uno spazio sufficientemente grande per contenere i dati e quindi eseguire la contabilità per prepararsi all’allocazione successiva.
L’accesso ai dati nell’heap è più lento dell’accesso ai dati sullo stack perché è necessario leggere un puntatore sullo stack per poi “saltare” all’indirizzo di memoria nell’heap per accedere ai dati. I processori attuali sono più veloci se non “saltano” troppo in giro per la memoria. Continuando l’analogia, consideriamo un cameriere in un ristorante che prende ordini da molti tavoli. È più efficiente ricevere tutti gli ordini su un tavolo prima di passare al tavolo successivo. Prendere un ordine dal tavolo A, poi un ordine dal tavolo B, poi ancora uno da A e poi ancora uno da B sarebbe un processo molto più lento. Allo stesso modo, un processore può svolgere meglio il proprio lavoro se lavora su dati vicini ad altri dati (come sono sullo stack) piuttosto che più lontani (come possono essere nell’heap).
Quando il codice chiama una funzione, i valori passati alla funzione (inclusi, potenzialmente, puntatori ai dati nell’heap) e le variabili locali della funzione vengono inseriti sullo stack. Quando la funzione termina, tali valori vengono estratti, pop, sullo _stack.
Tenere traccia di quali parti del codice utilizzano quali dati nell’heap, ridurre al minimo la quantità di dati duplicati nell’heap e ripulire i dati inutilizzati nell’heap in modo da non esaurire la memoria sono tutti problemi che la ownership risolve. Una volta compresa la ownership, non sarà necessario pensare molto spesso allo stack e all’heap, ma comprendere che lo scopo principale della ownership è gestire i dati dell’heap può aiutare a capire perché funziona in questo modo.
Regole di Ownership
Per prima cosa, diamo un’occhiata alle regole di ownership, tenendole a mente mentre lavoriamo agli esempi che le illustrano:
- Ogni valore in Rust ha un proprietario, owner.
- Ci può essere un solo owner alla volta.
- Quando l’owner esce dallo scope, il valore viene rilasciato.
Scope delle Variabili
Ora che abbiamo visto e imparato la sintassi di base di Rust, non includeremo
tutto il codice fn main() {
negli esempi, quindi se stai seguendo, assicurati
di inserire manualmente i seguenti esempi all’interno di una funzione main
. Di
conseguenza, i nostri esempi saranno un po’ più concisi, permettendoci di
concentrarci sui dettagli reali piuttosto che sul codice di base.
Come primo esempio di ownership, analizzeremo lo scope di alcune variabili. Lo scope è l’ambito all’interno di un programma nel quale un elemento è valido. Prendiamo la seguente variabile:
#![allow(unused)] fn main() { let s = "ciao"; }
La variabile s
si riferisce a un letterale stringa, il cui valore è codificato
nel testo del nostro programma. La variabile è valida dal momento in cui viene
dichiarata fino alla fine dello scope corrente. Il Listato 4-1 mostra un
programma con commenti che annotano i punti in cui la variabile s
sarebbe
valida (in scope).
fn main() { { // `s` non è valida qui, perché non ancora dichiarata let s = "hello"; // `s` è valida da questo punto in poi // fai cose con `s` } // questo scope è finito, e `s` non è più valida }
In altre parole, ci sono due momenti importanti:
- Quando
s
entra nello scope, è valida; - Rimane valida fino a quando non esce dallo scope.
A questo punto, la relazione tra scope e validità delle variabili è simile a
quella di altri linguaggi di programmazione. Ora ci baseremo su questa
comprensione introducendo il type String
.
Il Type String
Per illustrare le regole di ownership, abbiamo bisogno di un tipo di dati più
complesso di quelli trattati nel Capitolo 3. I
type trattati in precedenza hanno dimensioni note, possono essere inseriti e
estratti sullo stack quando il loro scope è terminato, possono essere
rapidamente copiati per creare una nuova istanza indipendente se un’altra parte
del codice deve utilizzare lo stesso valore in uno scope diverso. Ma vogliamo
esaminare i dati archiviati nell’heap e capire come Rust sa quando ripulire la
memoria che quei dati usavano quando non serve più, e il type String
è un
ottimo esempio da cui partire.
Ci concentreremo sulle parti di String
che riguardano la ownership. Questi
aspetti si applicano anche ad altri type di dati complessi, siano essi forniti
dalla libreria standard o creati dall’utente. Parleremo di String
oltre
l’aspetto della ownership nel Capitolo 8.
Abbiamo già visto i letterali stringa, in cui un valore stringa è codificato nel
nostro programma. I letterali stringa sono convenienti, ma non sono adatti a
tutte le situazioni in cui potremmo voler utilizzare del testo. Uno dei motivi è
che sono immutabili. Un altro è che non tutti i valori di stringa possono essere
conosciuti quando scriviamo il nostro codice: ad esempio, cosa succederebbe se
volessimo prendere l’input dell’utente e memorizzarlo? È per queste situazioni
che Rust ha un il type String
. Questo type gestisce i dati allocati
nell’heap e come tale è in grado di memorizzare una quantità di testo a noi
sconosciuta in fase di compilazione. Puoi creare un type String
partendo da
un letterale stringa utilizzando la funzione from
, in questo modo:
#![allow(unused)] fn main() { let s = String::from("ciao"); }
L’operatore double colon (doppio - due punti) ::
ci permette di integrare
questa particolare funzione from
nel type String
piuttosto che usare un
nome come string_from
. Parleremo di questa sintassi nella sezione “Sintassi
dei metodi” del Capitolo 5 e quando parleremo di
come organizzare la nomenclatura nei moduli in “Percorsi per fare riferimento a
un elemento nell’albero dei moduli” nel
Capitolo 7.
Questo tipo di stringa può essere mutata:
fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() aggiunge un letterale a una String println!("{s}"); // verrà stampato `hello, world!` }
Allora, qual è la differenza? Perché String
può essere mutata ma i letterali
no? La differenza sta nel modo in cui questi due type vengono gestiti in
memoria.
Memoria e Allocazione
Nel caso di un letterale stringa, conosciamo il contenuto al momento della compilazione, quindi il testo è codificato direttamente nell’eseguibile finale. Per questo motivo i letterali stringa sono veloci ed efficienti. Ma queste proprietà derivano solo dall’immutabilità del letterale stringa. Sfortunatamente, non possiamo inserire una porzione di memoria indefinita nel binario per ogni pezzo di testo la cui dimensione è sconosciuta al momento della compilazione e la cui dimensione potrebbe cambiare durante l’esecuzione del programma.
Con il type String
, per supportare una porzione di testo mutabile e
espandibile, dobbiamo allocare una quantità di memoria nell’heap, sconosciuta
in fase di compilazione, per contenere il contenuto. Questo significa che:
- La memoria deve essere richiesta all’allocatore di memoria in fase di esecuzione.
- Abbiamo bisogno di un modo per restituire questa memoria all’allocatore quando
abbiamo finito con la nostra
String
.
La prima parte la facciamo noi: quando chiamiamo String::from
, la sua
implementazione richiede la memoria di cui ha bisogno. Questo è praticamente
universale nei linguaggi di programmazione.
Tuttavia, la seconda parte è diversa. Nei linguaggi con un garbage collector (GC), il GC tiene traccia e ripulisce la memoria che non viene più utilizzata e non abbiamo bisogno di pensarci. Nella maggior parte dei linguaggi senza GC, è nostra responsabilità identificare quando la memoria non viene più utilizzata e chiamare il codice per de-allocarla esplicitamente, proprio come abbiamo fatto per richiederla. Farlo correttamente è stato storicamente un difficile problema di programmazione. Se ce lo dimentichiamo, sprecheremo memoria. Se lo facciamo troppo presto, avremo una variabile non valida. Se lo facciamo due volte, anche questo è un bug. Dobbiamo accoppiare esattamente un’allocazione con esattamente una de-allocazione (o rilascio, liberazione).
Rust prende una strada diversa: la memoria viene rilasciata automaticamente una
volta che la variabile che la possiede esce dallo scope. Ecco una versione del
nostro esempio sullo scope che utilizza una String
invece di un letterale
stringa:
fn main() { { let s = String::from("hello"); // `s` è valida da questo punto in poi // fai cose con `s` } // questo scope è finito, e `s` non è più valida }
C’è un punto naturale in cui possiamo rilasciare la memoria di cui la nostra
String
ha bisogno all’allocatore: quando s
esce dallo scope. Quando una
variabile esce dallo scope, Rust chiama per noi una funzione speciale. Questa
funzione si chiama drop
, ed è dove l’autore di String
può inserire il codice per rilasciare la memoria. Rust chiama drop
automaticamente alla parentesi graffa di chiusura.
Nota: in C++, questo schema di de-allocazione delle risorse alla fine del ciclo di vita di un elemento è talvolta chiamato Resource Acquisition Is Initialization (RAII). La funzione
drop
di Rust ti sarà familiare se hai usato gli schemi RAII.
Questo schema ha un profondo impatto sul modo in cui viene scritto il codice Rust. Può sembrare semplice in questo momento, ma il comportamento del codice può essere inaspettato in situazioni più complicate quando vogliamo che più variabili utilizzino i dati che abbiamo allocato nell’heap. Esploriamo ora alcune di queste situazioni.
Interazione tra Variabili e Dati con Move
In Rust, più variabili possono interagire con gli stessi dati in modi diversi. Il Listato 4-2 mostra un esempio che utilizza un integer.
fn main() { let x = 5; let y = x; }
x
a y
Probabilmente possiamo indovinare cosa sta facendo: “Associa il valore 5
a
x
; quindi crea una copia del valore in x
e associala a y
.” Ora abbiamo due
variabili, x
e y
, ed entrambe uguali a 5
. Questo è effettivamente ciò che
sta accadendo. Poiché gli integer sono valori semplici con una dimensione
fissa e nota, questi due valori 5
vengono immessi sullo stack.
Ora diamo un’occhiata alla versione con String
:
fn main() { let s1 = String::from("hello"); let s2 = s1; }
Sembra molto simile, quindi potremmo pensare che il funzionamento sia lo stesso:
cioè che la seconda riga faccia una copia del valore in s1
e lo assegni a
s2
. Ma non è esattamente quello che succede.
Nella Figura 4-1 diamo un’occhiata sotto le coperte per vedere com’è in realtà
una String
. Una String
è composta da tre parti, mostrate a sinistra: un
puntatore (ptr
) alla memoria che contiene il contenuto della stringa, una
lunghezza (len
) e una capienza (capacity
). Questo gruppo di dati è
memorizzato sullo stack. A destra c’è la memoria nell’heap che contiene il
contenuto.
Figura 4-1: La representazione in memoria di una String
con valore "hello"
assegnato a s1
La lunghezza è la quantità di memoria, in byte, utilizzata attualmente dal
contenuto della String
. La capienza è la quantità totale di memoria, in byte,
che String
ha ricevuto dall’allocatore. La differenza tra lunghezza e capacità
è importante, ma non in questo contesto, quindi per ora va bene ignorare la
capienza.
Quando assegniamo s1
a s2
, i dati String
vengono copiati, ovvero copiamo
il puntatore, la lunghezza e la capienza presenti sullo stack. Non copiamo i
dati nell’heap a cui fa riferimento il puntatore. In altre parole, la
rappresentazione dei dati in memoria è simile alla Figura 4-2.
Figura 4-2: La rappresentazione in memoria della variabile
s2
che contiene una copia del puntatore, lunghezza e capienza di s1
La rappresentazione non assomiglia alla Figura 4-3, che è l’aspetto che avrebbe
la memoria se Rust copiasse anche i dati dell’heap. Se Rust facesse così,
l’operazione s2 = s1
potrebbe diventare molto dispendiosa in termini
prestazionali e di memoria qualora i dati nell’heap fossero di grandi
dimensioni.
Figura 4-3: Un’altra possibilità di come potrebbe essere s2 = s1
se Rust copiasse anche i dati dell’heap
In precedenza, abbiamo detto che quando una variabile esce dallo scope, Rust
chiama automaticamente la funzione drop
e ripulisce la memoria nell’heap di
quella variabile. Ma la Figura 4-2 mostra entrambi i puntatori di dati che
puntano alla stessa posizione. Questo è un problema: quando s2
e s1
escono
dallo scope, entrambe tenteranno di de-allocare la stessa memoria. Questo è
noto come errore da doppia de-allocazione (double free error in inglese) ed
è uno dei bug di sicurezza della memoria menzionati in precedenza. Rilasciare la
memoria due volte può portare a corruzione di memoria, che può potenzialmente
esporre il programma a vulnerabilità di sicurezza.
Per garantire la sicurezza della memoria, dopo la riga let s2 = s1;
, Rust
considera s1
come non più valido. Pertanto, Rust non ha bisogno di de-allocare
nulla quando s1
esce dallo scope. Controlla cosa succede quando provi a
usare s1
dopo che s2
è stato creato: non funzionerà:
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!");
}
Otterrai un errore di questo tipo perché Rust ti impedisce di utilizzare il riferimento invalidato:
$ cargo run
Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:6:16
|
3 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 | let s2 = s1;
| -- value moved here
5 |
6 | println!("{s1}, world!");
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
4 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error
Se hai sentito i termini copia superficiale e copia profonda mentre lavoravi
con altri linguaggi, il concetto di copiare il puntatore, la lunghezza e la
capacità senza copiare i dati probabilmente ti sembrerà simile a una copia
superficiale. Ma poiché Rust invalida anche la prima variabile, invece di
essere chiamata copia superficiale, questa operazione è nota come move
(spostamento). In questo esempio, diremmo che s1
è stata spostata in s2
.
Quindi, ciò che accade in realtà è mostrato nella Figura 4-4.
Figura 4-4: La rappresentazione in memoria dopo che s1
è
resa non valida
Questo risolve il nostro problema! Con la sola s2
valida, quando essa uscirà
dallo scope, solo lei rilascerà la memoria e il gioco è fatto.
Inoltre, c’è una scelta progettuale implicita in questo: Rust non creerà mai automaticamente copie “profonde” dei tuoi dati. Pertanto, si può presupporre che qualsiasi copia automatica sia poco dispendiosa in termini prestazionali e di memoria.
Scope e Assegnazione
L’opposto di ciò è vero anche per la relazione tra scope, ownership e
memoria rilasciata tramite la funzione drop
. Quando assegni un valore
completamente nuovo a una variabile esistente, Rust chiamerà drop
e libererà
immediatamente la memoria del valore originale. Considera questo codice, ad
esempio:
fn main() { let mut s = String::from("hello"); s = String::from("ciao"); println!("{s}, world!"); }
Inizialmente dichiariamo una variabile s
e la associamo a una String
con il
valore "hello"
. Poi creiamo immediatamente una nuova String
con il valore
"ciao"
e la assegniamo a s
. A questo punto, non c’è più nulla che faccia
riferimento al valore originale nell’heap. La Figura 4-5 mostra i dati sullo
stack e nell’heap al momento:
Figura 4-5: La rappresentazione in memoria dopo che il primo valore è completamente sostituito.
La stringa originale esce così immediatamente dallo scope. Rust eseguirà la
funzione drop
su di essa e la sua memoria verrà rilasciata immediatamente.
Quando stamperemo il valore alla fine, sarà "ciao, world!"
.
Interazione tra Variabili e Dati con Clone
Se vogliamo effettivamente duplicare i dati nell’heap della String
, e non
solo i dati sullo stack, possiamo utilizzare un metodo comune chiamato
clone
. Parleremo della sintassi dei metodi nel Capitolo 5, ma dato che i
metodi sono una caratteristica comune a molti linguaggi di programmazione,
probabilmente li hai già visti.
Ecco un esempio del metodo clone
in azione:
fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {s1}, s2 = {s2}"); }
Questo funziona benissimo e produce esplicitamente il comportamento mostrato nella Figura 4-3, in cui i anche i dati dell’heap vengono duplicati.
Quando vedi una chiamata a clone
, sai che viene eseguito del codice arbitrario
e che questo potrebbe essere dispendioso. È un indicatore visivo del fatto che
sta succedendo qualcosa di diverso.
Duplicare Dati Sullo Stack
C’è un’altra peculiarità di cui non abbiamo ancora parlato: questo codice che utilizza gli integer, in parte mostrato nel Listato 4-2, funziona ed è valido
fn main() { let x = 5; let y = x; println!("x = {x}, y = {y}"); }
Ma questo codice sembra contraddire ciò che abbiamo appena imparato: non abbiamo
una chiamata a clone
, ma x
è ancora valido e non è stato spostato in y
.
Il motivo è che i type come gli integer che hanno una dimensione nota in
fase di compilazione vengono archiviati interamente sullo stack, quindi le
copie dei valori effettivi sono veloci da creare. Ciò significa che non c’è
motivo per cui vorremmo impedire che x
sia valido dopo aver creato la
variabile y
. In altre parole, qui non c’è differenza tra copia profonda e
superficiale, quindi chiamare clone
non farebbe nulla di diverso dalla solita
copia superficiale e possiamo tralasciarlo.
Rust ha un’annotazione speciale chiamata tratto Copy
che possiamo
appiccicare sui type memorizzati sullo stack, come lo sono gli integer
(parleremo meglio dei tratti nel Capitolo 10). Se un
type implementa il tratto Copy
, le variabili che lo utilizzano non si
spostano, ma vengono semplicemente copiate, rendendole ancora valide dopo
l’assegnazione ad un’altra variabile.
Rust non ci permette di annotare un type con Copy
se il type, o una
qualsiasi delle sue parti, ha implementato il tratto Drop
. Se il type ha
bisogno che accada qualcosa di speciale quando il valore esce dallo scope e
aggiungiamo l’annotazione Copy
a quel type, otterremo un errore in fase di
compilazione. Per sapere come aggiungere l’annotazione Copy
al tuo type,
consulta “Tratti derivabili” nell’Appendice
C.
Quindi, quali type implementano il tratto Copy
? Puoi controllare la
documentazione del type in questione per esserne sicuro, ma come regola
generale, qualsiasi gruppo di valori scalari semplici può implementare Copy
e
niente che richieda l’allocazione o che sia una qualche forma di risorsa può
implementare Copy
:
Ecco alcuni dei type che implementano Copy
:
- Tutti i type integer, come
u32
. - Il type booleano,
bool
, con i valoritrue
efalse
. - Tutti i type in virgola mobile, come
f64
. - Il type carattere,
char
. - Le tuple, se contengono solo type che implementano
Copy
. Ad esempio,(i32, i32)
implementaCopy
, ma(i32, String)
no.
Ownership e Funzioni
I meccanismi che regolano il passaggio di un valore a una funzione sono simili a quelli dell’assegnazione di un valore a una variabile. Passando una variabile a una funzione, questa viene spostata o copiata, proprio come fa l’assegnazione. Il Listato 4-3 contiene un esempio con alcune annotazioni che mostrano dove le variabili entrano ed escono dallo scope.
fn main() { let s = String::from("hello"); // `s` entra nello scope prende_ownership(s); // il valore di `s` viene spostato nella funzione... // ... e quindi qui smette di esser valido let x = 5; // `x` entra nello scope duplica(x); // Siccome i32 implementa il tratto Copy, // `x` NON viene spostato nella funzione, // quindi dopo può ancora essere usata. } // Qui, `x` esce dallo scope, ed anche `s`. Tuttavia, siccome il valore di `s` // era stato spostato, non succede nulla di particolare. fn prende_ownership(una_stringa: String) { // `una_stringa` entra nello scope println!("{una_stringa}"); } // Qui, `una_stringa` esce dallo scope e `drop` viene chiamato. // La memoria corrispondente viene rilasciata. fn duplica(un_integer: i32) { // `un_integer` entra nello scope println!("{un_integer}"); } // Qui, `un_integer` esce dallo scope. Non succede nulla di particolare.
Se provassimo a usare s
dopo la chiamata a prende_ownership
, Rust
segnalerebbe un errore in fase di compilazione. Questi controlli statici ci
proteggono dagli errori. Prova ad aggiungere del codice a main
che usi s
e
x
per sperimentare dove puoi usarli e dove le regole di ownership te lo
impediscono.
Valori di Ritorno e Scope
I valori di ritorno possono anch’essi trasferire la ownership. Il Listato 4-4 mostra un esempio di funzione che restituisce un valore, con annotazioni simili a quelle del Listato 4-3.
fn main() { let s1 = cede_ownership(); // `cede_ownership` sposta il proprio // valore di ritorno in `s1` let s2 = String::from("hello"); // `s2` entra in scope let s3 = prende_e_restituisce(s2); // `s2` viene spostata in // `prende_e_restituisce`, che a sua // volta sposta il proprio valore // di ritorno in `s3` } // Qui, `s3` esce dallo scope e viene cancellata con `drop`. `s2` era stata spostata // e quindi non succede nulla. `s1` viene cancellata con `drop` anch'essa. fn cede_ownership() -> String { // `cede_ownership` spostera il proprio valore di // ritorno alla funzione che l'ha chiamata let una_stringa = String::from("yours"); // `una_stringa` entra in scope una_stringa // `una_stringa` viene ritornata e spostata // alla funzione chiamante } // Questa funzione prende una String e ritorna una String. fn prende_e_restituisce(altra_stringa: String) -> String { // `altra_stringa` entra in scope altra_stringa // `altra_stringa` viene ritornata // e spostata alla funzione chiamante }
La ownership di una variabile segue ogni volta lo stesso schema: assegnare un
valore a un’altra variabile la sposta. Quando una variabile che include dati
nell’heap esce dallo scope, il valore verrà cancellato da drop
a meno che
la ownership dei dati non sia stata spostata ad un’altra variabile.
Anche se funziona, prendere e cedere la ownership con ogni funzione è un po’ faticoso. Cosa succede se vogliamo consentire a una funzione di utilizzare un valore ma non di prenderne la ownership? È piuttosto fastidioso che tutto ciò che passiamo debba anche essere restituito se vogliamo usarlo di nuovo, oltre a tutte le varie elaborazioni sui dati che la funzione esegue e che magari è necessario ritornare pure quelle.
Rust ci permette di ritornare più valori utilizzando una tupla, come mostrato nel Listato 4-5
fn main() { let s1 = String::from("ciao"); let (s2, lung) = calcola_lunghezza(s1); println!("La lunghezza di '{s2}' è {lung}."); } fn calcola_lunghezza(s: String) -> (String, usize) { let lunghezza = s.len(); // len() restituisce la lunghezza di una String (s, lunghezza) }
Ma questa è una procedura inutilmente complessa e richiede molto lavoro per un concetto che dovrebbe essere comune. Fortunatamente per noi, Rust ha una funzionalità che consente di utilizzare un valore senza trasferirne la ownership, chiamata riferimento (reference in inglese).