L’Ownership e il borrowing sono due insiemi di regole che guidano il linguaggio Rust a gestire efficacemente la memoria senza dovere fare le cose manualmente o usare una libreria esterna. RIguardano sia la proprietà che i riferimenti dei valori conservati in memoria.
Alcuni linguaggi hanno un garbage collector che verifica la memoria non più utilizzata e la cancella mentre in altri dobbiamo creare delle funzioni per allocare e liberare memoria. Rust, invece, prima di compilare il programma verifica che tutte le regole della memoria vengono rispettate.
Prima di concentrarci sull’ownership e sul borrowing è bene conoscere in che modo i programmi gestiscono la memoria. La memoria viene suddivisa in due aree: la prima più veloce e volatile contiene i dati che devono essere gestiti e usati nell’immediato e viene chiamata stack mentre la seconda conserva tutto il resto e viene chiamata heap.
I dati nello stack devono essere statici altrimenti la memoria non può essere così veloce da eseguire il programma dato che dovrebbe prima creare i riferimenti, modificarne la lunghezza e i valori. Gli ultimi dati che vengono inseriti nello stack sono i primi ad essere utilizzati sempre per lo stesso motivo. Perciò al suo interno ci sono tutti i tipi di dato statici e scalari.
L’heap si può paragonare alla memoria interna dei nostri dispositivi ma con alcune differenze. E’ sempre il programma a generare, conservare e gestire i dati. Al suo interno vanno inseriti tutti i tipi di dato dinamici, cioè i cui valori non si conoscono in fase di compilazione e che possono anche cambiare le loro dimensioni.
Per fare un esempio pratico, se creiamo una struct non possiamo inserire le stringhe letterali di tipo scalare perché sono fisse e immutabili ma ci verrà richiesto il tipo String. I numeri invece, essendo tipi di dato molto semplici non devono rispettare le regole dell’ownership.
Le regole dell’ownership
Vediamo ora le regole dell’ownership (proprietà) di Rust. Ogni valore deve avere un unico proprietario alla volta, cioè una variabile che ne ha il controllo assoluto; quando il proprietario esce dal suo scope, o ambito, anche il valore viene eliminato liberando memoria. Questo è diverso dagli altri linguaggi dove dobbiamo essere noi ad eliminare i dati manualmente.
Per i dati contenuti nello stack non ci sono differenze. Possiamo creare variabili con valori uguali oppure assegnare una variabile ad un’altra. Questi tipi di dato semplici hanno la capacità di essere copiati automaticamente.
fn main() {
let s = "ciao";
let s2 = s;
//Le variabili e i valori esistono ancora
println!("{s}");
println!("{s2}");
}
Invece se creiamo un’istanza di String, essendo un tipo di dato dinamico valgono le regole menzionate sopra. Nel momento che colleghiamo la prima variabile alla seconda, la prima verrà subito eliminata e la seconda sarà il nuovo proprietario del valore.
fn main() {
let s = String::from("ciao");
let s2 = s;
//Il programma andrà nel panico perché s non esiste più
println!("{s}");
println!("{s2}");
}
Tuttavia, il codice funzionerà se duplichiamo il valore con il trait clone.
fn main() {
let s = String::from("ciao");
let s2 = s.clone();
println!("{s}");
println!("{s2}");
}
Allo stesso modo, se ad una variabile modifichiamo il valore quello precedente verrà eliminato completamente. Nel codice sotto, Rust ci avviserà che il primo valore non è stato mai utilizzato dato che usiamo la macro println!() solo alla fine.
fn main() {
let mut s = String::from("ciao");
s = String::from("Buongiorno");
println!("{s}");
}
Le regole sulla proprietà valgono anche quando passiamo una variabile ad una funzione. A quel punto è la funzione stessa il nuovo proprietario di quel valore. Se vogliamo conservare il proprietario di un valore quando lo passiamo ad una funzione dobbiamo passare il suo riferimento oppure ritornarla e riassegnarla alla variabile di partenza o una nuova.
fn main() {
let s = String::from("ciao");
do_something(s);
//s non esiste più
println!("{s}");
}
fn do_something(s: String) {
todo!()
}
fn main() {
let s = String::from("ciao");
//Passiamo soltanto il riferimento al valore
do_something(&s);
println!("{s}");
}
fn do_something(s: &String) {
todo!()
}
Il borrowing
Oltre alle regole di proprietà ci sono anche delle regole per i riferimenti (borrowing). Quando una variabile è immutabile possiamo creare tutti i riferimenti che vogliamo, quindi avere molte variabili che si collegano a quel valore. Ma se una variabile è mutabile possiamo avere un solo riferimento all’interno dello stesso ambito o scope. Questo perché se un riferimento viene modificato lo stesso vale per tutti gli altri creando problemi.
fn main() {
let s = String::from("ciao");
let s1 = &s;
let s2 = &s;
//OK, perché s è immutabile
println!("{s}");
println!("{s1}");
println!("{s2}");
}
fn main() {
let mut s = String::from("ciao");
let s1 = &mut s;
let s2 = &mut s;
//s2 non è valido.
println!("{s}");
println!("{s1}");
println!("{s2}");
}
fn main() {
let mut s = String::from("ciao");
println!("{s}");
{
let s1 = &mut s;
println!("{s1}");
//s1 viene eliminato alla fine del suo scope
}
//s2 è valido perché s1 non esiste più
let s2 = &mut s;
println!("{s2}");
}
fn main() {
let mut s = String::from("ciao");
println!("{s}");
let s1 = &mut s;
//usando l'asterisco prima del nome della variabile modifichiamo il valore a cui fa riferimento
*s1 = String::from("Hello");
println!("{s}"); //Il valore di s è adesso Hello.
}
Riassumendo le regole dell’ownership e del borrowing di Rust ci permettono di gestire la memoria in modo efficiente. Ci fa inoltre capire meglio come i programmi gestiscono la memoria e per quale motivo potremmo avere dei problemi quando passiamo degli argomenti alle variabili e ritorniamo i dati anche in altri linguaggi.