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:

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:

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.