Rust - Borrowing e Ownership
Abbiamo già detto quanto barrowing e
ownership siano pilastri di questo
linguaggio. Proviamo ad approfondire un po' il discorso.
Cominciamo con
una affermazione concisa:
- ownership = possesso esclusivo dei dati
-
borrowing = accesso temporaneo ai dati senza possesso.
Sono due meccanismi complementari, non alternativi. Inoltre, per chiarire i ruoli:
- ownership dà controllo totale su un valore: puoi
spostarlo, modificarlo, distruggerlo.
- borrowing dà
accesso controllato a un valore posseduto da qualcun altro: puoi leggerlo
o modificarlo, ma non possederlo né spostarlo.
Ampliando il discorso:
Ownership (Proprietà): In Rust, ogni valore ha un
proprietario singolo. Il proprietario è responsabile per la deallocazione
della memoria associata al valore quando non è più necessario. Questo
principio aiuta a evitare problemi di doppia deallocazione o memory leak. In
sintesi, il sistema di ownership di Rust permette di scrivere codice sicuro
e performante, riducendo al minimo gli errori legati alla gestione della
memoria potendo anche evitare l'uso di un garbage collector cosa
quest'ultima che piace molto ai programmatori esperti.
Borrowing (Prestito): Invece di trasferire la proprietà di
un valore quando viene passato a una funzione o assegnato a un'altra
variabile, Rust consente di "prendere in prestito" temporaneamente il
valore. Quando un valore viene preso in prestito, il proprietario originale
continua a detenere la proprietà, ma il prestatore temporaneo può accedere
al valore per la sua durata di vita. Questo prestito può essere sia mutabile
(mutabile borrowing) che immutabile (immutable borrowing). Il concetto di “borrowing” è fondamentale per
garantire la sicurezza e la gestione della memoria. Il borrowing permette di
prestare una variabile a un’altra parte del programma senza trasferirne la
proprietà. Questo meccanismo assicura che ci siano regole chiare su chi può
accedere e modificare i dati, prevenendo problemi come la concorrenza non
sicura o la modifica simultanea di dati condivisi. In pratica, Rust utilizza
il borrowing per evitare situazioni in cui più parti del programma tentano
di modificare gli stessi dati contemporaneamente, il che potrebbe portare a
comportamenti imprevedibili o errori. Grazie a queste regole, Rust riesce a
garantire un alto livello di sicurezza e affidabilità nel codice. Un aspetto
importante del borrowing in Rust è che rispetta le regole di mutabilità e
immutabilità. Non è consentito avere contemporaneamente più riferimenti
mutabili allo stesso dato, in modo da evitare potenziali condizioni di gara
(race conditions) o problemi di sincronizzazione. Questo approccio, insieme
alla gestione statica della durata di vita (lifetime) dei riferimenti, aiuta
Rust a garantire che i programmi siano liberi da errori di accesso alla
memoria, deadlock e altre problematiche comuni nei linguaggi che non
dispongono di un sistema di tipi forte come Rust.
Come abbiamo detto il
borrowing si presenta nelle due forme mutabile o
immutabile.
Borrowing immutabile: Un
riferimento immutabile permette solo la lettura dei dati referenziati e non
consente la modifica dei dati stessi. Puoi avere più riferimenti immutabili
allo stesso dato contemporaneamente e possono coesistere con altri
riferimenti immutabili o un riferimento mutabile.
Borrowing
mutabile: Un riferimento mutabile consente sia la lettura che la
modifica dei dati referenziati, ma non consente di avere contemporaneamente
altri riferimenti (mutabili o immutabili) allo stesso dato. Questo evita
condizioni di gara (race conditions) e garantisce che ci sia un'unica fonte
di modifica per i dati in un determinato momento.
BORROWING
Esempio 11.1
fn main() {
let mut x = 1;
println!("x: {}", x);
let y = & x;
*y += 1;
println!("x: {}", x);
// println!("y: {}", y);
}
Quindi:
- definiamo la variabile mutabile x
- definiamo la
variabile y che punta al valore di x, ovvero all'area di memoria ove è
contenuto il valore di x
- incrementiamo di una unità il valore di y ma
questo equivale ad aumentare il valore che puntiamo
- x ora è uguale a 2
come facilmente comprovabile eseguendo il programma
Cosa succede se togliamo le barre di commento nell'ultima riga di codice (nin considerando la chiusura del programma)? Ci viene fuori questo:ù
error[E0502]: cannot borrow `x` as immutable because it
is also borrowed as mutable
--> r122.rs:6:23
|
4 | let y = &mut x;
| ------ mutable borrow occurs here
5 | *y += 1;
6 | println!("x: {}", x);
| ^ immutable borrow occurs here
7 |
println!("y: {}", y);
| - mutable borrow later used here
error:
aborting due to 1 previous error
For more information about this error,
try `rustc --explain E0502`.
Cosa vuol dire: println!("x: {}",
x);
è una richiesta di uso
immutabile di x e il compilatore ce lo dice esplicitamente. Ma nel frattempo
è ancora attivo un uso, un prestito mutabile e questo viene usato
nell'istruzione successiva, quella che determina la mancata compilazione. E
finchè il prestito mutabile è attivo nessun altro può essere messo in atto
nè mutabile nè immutabile. E il prestito mutabile resta attivo fino al
momento del suo ultimo utilizzo. In pratica se commentiamo la riga
println!("y: {}", y);
il prestito mutabile resta attivo fino a *y += 1 e
quindi successivamente è possibile attivare un altro prestito, quello della
stampa di x. L'idea di base è quella di assegnare un tempo di vita ogni qual
volta creaimo un prestito. E questo tempo di vita (lifetime, ne sentirete
parlare ancora e ancora) arriva all'ultimo utilizzo nel codice.
Più
avanti daremo la regola "aurea" su questo argomento per intanto vediamo cosa
succede se cerchiamo di
creare due prestiti mutabili su uno stesso elemento che conferma quanto
detto in particolare la dipendenza dal codice. Guardiamo ad esempio la
seguente sequenza:
1)let mut x = 1; ;
let y = &mut x;
let z = &mut x
2)let mut x = 1;
let y = &mut x;
let z = &mut x;
*z += 1;
3)let mut x = 1;
let y = &mut x;
let z = &mut x;
*y += 1;
Questi sono spezzoni simili ma 1) e 2) compilano (una volta immersi in un normale main) mentre 3) no. Perchè? Forse lo avrete già capito. 1) semplicemente crea due prestiti della variabile x ma tali prestiti non sono utilizzati e quindi muoiono subito (il compilatore tiene conto dell'ultimo uso). 2) funziona perchè y non viene più utilizzato e quindi z può procedere alla modifica del valore memorizzato di x. 3) Invece riattiva y che quindi è ancora vivo quando viene chiamata la seconda modifica tramite z. Infatti l'errore che otterrete è:
error[E0499]: cannot borrow `x` as mutable
more than once at a time
--> r125.rs:4:13
|
3 | let y = &mut
x;
| ------
first mutable borrow occurs here
4 | let z = &mut x;
| ^^^^^^ second mutable
borrow occurs here
Ovvero vi indica un secondo tentativo di agganciare un borrow mutabile
quando y, il primo è ancora attivo perchè tenuto vivo dall'istruzione finale
di incremento. La cosa non cambierebbe nemmeno se la 3) diventasse.let
mut x = 1;
let y = &mut x;
let z = &x;
*y += 1;
perchè in quel caso il compilatore vi direbbe:
error[E0502]: cannot borrow `x` as
immutable because it is also borrowed as mutable
--> r128.rs:4:13
|
3 | let
y = &mut x;
| ------ mutable borrow occurs here
4 | let z = &x;
| ^^ immutable borrow occurs here
5 | *y+= 1; // Modifica `x` attraverso
`y`
| ------ mutable borrow later used here
ovvero non si può creare un prestito immutabile essendovene già un mutabile.
In generale vale la seguente regola aurea, come promesso, che troverete più
o simile su tutti i testi relativi a Rust:
Rust permette o
aliasing (più riferimenti immutabili) o mutazione (un solo riferimento
mutabile), ma non entrambi contemporaneamente.
Adesso, per chiarire la cosa, mischiamo un po' le carte: possiamo creare una sequenza di prestiti
mutabile - immutabile e viceversa? Dipende sempre dal tempo di vita. Se il
primo definito si estende oltre il secondo come nell'esempio 3, il
compilatore vi dirà qualcosa come:
cannot borrow `x` as immutable because it
is also borrowed as mutable
oppure
cannot borrow `x` as mutable because it is
also borrowed as immutable
La morale di tutto ciò è che Rust cerca quanto più è possibile di
conservare l'integrità dei dati ed evitare quei data-race che sono fonte di
errori anche molto pesanti e a volte difficili da rintracciare. Fate i
vostri esperimenti e vedrete che le cose sono molto interessanti e che
questo linguaggio è realmente, molto rigoroso nelle sue regole, certamente
più del C/C++ e anche più di Zig.
E' possibile invece avere più borrowing
immutabili? Certo, quanti ne volete:
Esempio 11.2
fn main() {
let x = 1;
let y = &x;
let z = &;
let v = &;
let t = &;
println!("x: {}, y: {}, z: {}, v: {}, t: {}", x, y, z, v, t)
}
Qui non avete limitazioni.
Il borrowing va un attimo analizzato perchè
semplice concettualmente ma nella pratica, come visto, può nascondere delle
insidie.
OWNERSHIP
Passiamo
ora ad analizzare brevemente le possibilità di chi esercita il potere, l'ownership
su un dato elemento.
L'owner ha diritto esclusivo di:
- leggere
- modificare
- spostare (move)
- distruggere (drop)
- prestare (borrow)
Si tratta di un modello verificabile a compile time,
deterministico, in quanto possiamo sapere esattamente
quando il dato viene distrutto e locale, non serve
un'analisi del programma nel suo complesso.
ricordiamo le 3 regole viste
nel capitolo delle stringhe relativamente all'ownership:
- ogni valore ha un solo owner (proprietario)
- può esservi quindi un solo proprietario per volta
- quando il proprietario esce dallo scope il valore nello heap è eliminato
E' importante specificare che l'ownership si può applicare solo ai dati nello heap e non a quelli nello stack. In quest'ultimo infatti i dati sono copiati nello heap (che è dinamico) possono essere mossi.
L'esempio classico dello spostamento, l'operazione di move, l'abbiamo già
vista:let s1 = String::from("ciao");
let s2 = s1; // move
in questo
caso s1 era l'owner della stringa "ciao" e ha spostato (move =
trasferimento di possesso) il possesso su s2.
Soprattutto non viene creata alcuna copia della stringa stessa, non ci sono più
puntamenti contemporanei ad essa. Ora un tentativo di usare di nuovo s1, ad
esempio stampandola, darebbe origina ad un errore del tipo:
borrow of moved value: `s1`
perchè
il valore è stato spostato da s1 ad s2. Capire questo meccanismo è fondamentale.
Un'altra interessante possibilità automaticamente offerta dal compilatore è quella di liberare automaticamente memoria senza ricorrere ad un garbage collector o senza bisogno di intervento del programmatore:
{
let s1 = String::from("ciao");
} // qui c'è il drop
automatico. La memoria viene liberata
E' un meccanismo che ricorda molto da vicino il RAII
(Resource Acquisition Is Initialization) di C++ ma più semplice. Se ci fate caso,
questo è un meccanismo molto efficiente. Come detto detto non è necessario un
garbage collector, non ci sono copie, nessun overhead nascosto e nemmeno un
reference counting, è tutto molto pulito e gestito dal compilatore. Questo, che
possiamo definire zero cost abstraction, è uno
degli aspetti che hanno decretato il successo di Rust in molti ambiti. Potete
anche scordarvi i problemi di dangling pointer e double free.
L'owner
stabilisce in conseguenza di quanto detto, il lifetime del dato che possiede e inoltre accetta e
gestisce il prestito verso chi lo richiede permettendo, come abbiamo visto
essere condizione necessaria, un
solo borrowing mutabile o più borrowing immutabili. Di
lifetime comunque parleremo ancora, un accenno lo trovate qui di seguito, visto che non è sempre possibile gestirlo in
maniera automatica ed è un altro importante meccanismo-pilastro del linguaggio.
Ecco un frammento di codice relativo al borrowing:
let s1 = String::from("Hello");
let y = &s1;
let z = &s1;
println!("s1: {}, y: {}, z: {}", s1, y, z)
Qui abbiamo due prestiti immutabili che sono permessi. Se inseriste la
clausola mut per y e z il compilatore si farebbe sentire.
Analizzeremo
più avanti altri costrutti sintattici, come la selezione tramite if-else e
lì avremo qualche sorpresa legata proprio alla ownership.
LIFETIME
Questo
concetto lo approfondiremo, più avanti, qui diamo solo un accenno, ma intanto cominciate a prendere
visione di quanto segue perchè coinvolge in maniera interessante
ownership e borrowing:
Esempio 11.3
fn main() {
let r;
{
let s = String::from("ciao");
r = &s;
}
println!("{}", r); // errore
}
perchè errore? Ce lo dice il compilatore:
error[E0597]: `s` does not live long enough
--> r149.rs:6:13
|
5 | let s =
String::from("ciao");
| - binding `s` declared here
6 | r = &s;
| ^^ borrowed value does not live long enough
7 | }
| - `s` dropped here while still borrowed
8 |
9 | println!("{}", r); // errore
| - borrow later used here
error: aborting due to 1 previous error
La spiegazione è semplice: r punta alla zona di memoria dove è contenuta la stringa "ciao". Ma questa è di proprietà di s che va in drop dopo la chiusura del suo scope. Pertanto r punterebbe ad una zona non più valida (ed ecco che in altri linguaggi si avrebbe un bel dangling pointer). Il compilatore, per fortuna riconosce il problema e ci aiuta ad individuarlo con il consueto errore ottimamente espresso. Il lifetime è centrale in Rust, direi che è il terzo vero pilastro del linguaggio e avrà un suo paragrafo dedicato.
Un'ultima annotazione: avrete notato che il
drop, ovvero l'eliminazione dei un elemento della memoria è qui
implementato in maniera automatica. Richiamando
Drop::drop è possibile farlo anche manualmente quando necessario, in
particolare se non implementato automaticamente da quello che volete che
sparisca.
Quando avremo affrontato le funzioni allargheremo i discorsi accennati in questo paragrafo.