Un programma in C che libera un buffer e continua a leggerlo compila comunque. Poi corrompe la memoria, a volte subito, a volte tre richieste più tardi, in una parte di codice che quel buffer non lo ha mai toccato. Rust rifiuta quel programma già al cargo build, senza alcun garbage collector di mezzo. Il compilatore tiene traccia di chi possiede ogni valore e di chi può accedervi. Questo tracciamento si chiama ownership e borrowing.

Quali sono le regole di ownership di Rust?

Il modello di ownership di Rust si riduce a tre regole:

  1. Ogni valore ha esattamente un owner.
  2. Può esserci un solo owner alla volta.
  3. Quando l’owner esce dallo scope, il valore viene droppato.

“Droppato” significa che il suo distruttore viene eseguito e la memoria liberata subito, non quando un garbage collector decide di occuparsene. È questo il patto di Rust: nessun runtime che decide quando recuperare la memoria, in cambio del fatto che il compilatore applica queste regole a ogni riga che scrivi.

La regola 2 è quella che manda in crisi le prime volte. Assegna una String a una seconda variabile e la prima smette di funzionare:

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}
1
2
3
4
5
6
7
8
9
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

let s2 = s1 non copia i byte della stringa. String contiene un puntatore a un buffer sull’heap, una lunghezza e una capacità. Copiare quella struct bit per bit lascerebbe due owner che puntano allo stesso buffer, e quando entrambi uscissero dallo scope il buffer verrebbe liberato due volte. Rust evita il problema trasformando l’assegnazione in una move: s2 prende possesso del valore, e s1 non è più valido. Passare una String a una funzione fa la stessa cosa: la funzione ne prende possesso, e la variabile che hai passato torna invalida nel chiamante.

Perché gli interi non vengono spostati in Rust?

Gli interi non hanno questo problema:

1
2
3
4
5
6
fn main() {
    let x = 5;
    let y = x;

    println!("{x} and {y}");
}

Questo compila. i32 implementa il trait Copy, quindi l’assegnazione duplica il valore invece di spostarlo. Non c’è niente sull’heap da liberare due volte, solo quattro byte sullo stack. Ogni tipo intero, bool, ogni tipo a virgola mobile, char, e le tuple composte solo da tipi Copy ((i32, bool), non (i32, String)) si comportano nello stesso modo. Un tipo può implementare Copy solo se nessuna delle sue parti implementa Drop, che è il modo in cui Rust ti dice che, se la pulizia conta, la duplicazione deve essere esplicita.

Quando vuoi una seconda copia indipendente di una String o di un Vec, rendilo esplicito con .clone(). È un’allocazione sull’heap in più, ed è esattamente per questo che Rust non lo fa in silenzio a ogni assegnazione.

Come funziona il borrowing in Rust?

Se ogni funzione che deve leggere un valore dovesse prenderne possesso, finiresti per restituire valori da ogni funzione dentro una tupla solo per continuare a usarli. Il borrowing elimina il problema: prendi un reference con &, usa il valore, e l’ownership non cambia mai di mano.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

s1 resta valido dopo la chiamata, perché calculate_length lo ha solo preso in prestito. Il tipo del parametro &String è un reference: punta ai dati di s1 senza possederli, quindi non c’è nulla da droppare quando il borrow finisce.

Due regole mantengono tutto questo sicuro:

  • In un dato momento puoi avere un solo reference mutabile a un valore, oppure un numero qualsiasi di reference immutabili, mai entrambi insieme.
  • Un reference non può mai vivere più a lungo del dato a cui punta.

Rompi la prima e il compilatore ti blocca:

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{r1}, {r2}, and {r3}");
}
1
2
3
4
5
6
7
8
9
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

Anche due reference mutabili allo stesso valore nello stesso momento vengono rifiutati (error[E0499]: cannot borrow `s` as mutable more than once at a time). Nessuno dei due errori è arbitrario. Un reference immutabile è una promessa al resto del programma che il valore non cambierà sotto i suoi piedi, e un reference mutabile ha bisogno di esclusività per mantenere quella promessa. Il classico bug di thread-safety in C o Go, dove due pezzi di codice scrivono sulla stessa memoria mentre un terzo la legge, è esattamente la situazione che queste regole rendono impossibile da compilare.

Questo non significa che Rust catturi ogni race condition che potresti scrivere. Il Rust sicuro può comunque bloccarsi su un mutex o sbagliare l’ordine delle operazioni, e i blocchi unsafe possono bypassare i controlli del tutto. Quello che il borrow checker elimina è una forma specifica e molto comune del bug: la memoria mutabile aliasata, catturata prima che il programma venga eseguito.

Cosa significano le annotazioni di lifetime come ‘a?

Ogni reference ha un lifetime: lo scope in cui è valido. Di solito il compilatore lo calcola da solo. Ha bisogno del tuo aiuto quando una funzione restituisce un reference e riceve più di un reference come parametro, perché non ha modo di sapere a quale input è legato l’output.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

'a è un parametro di lifetime, si legge “tick-a”. È un nome, non una durata. Scrivere &'a str su entrambi i parametri e sul tipo di ritorno dice al compilatore: il reference che restituisco vive al massimo quanto il più breve tra x e y. L’annotazione non cambia quanto vive qualcosa. Descrive una relazione che il compilatore deve comunque verificare, così può rifiutare una chiamata in cui il reference restituito vivrebbe più a lungo di uno dei suoi input.

Non scrivi 'a ovunque, perché Rust lo elide nei casi comuni. Una funzione con esattamente un parametro reference assegna automaticamente quel lifetime al proprio tipo di ritorno, e un metodo che prende &self assegna il lifetime di self al tipo di ritorno. Per questo fn first_word(s: &str) -> &str compila senza alcuna annotazione: un input, un output, niente di ambiguo. longest prende due parametri reference indipendenti e nessun self, quindi l’elisione non ha niente a cui aggrapparsi, e il lifetime lo scrivi a mano.

Cosa fare quando il borrow checker complica il design

Le proteste del borrow checker sembrano ostili le prime settimane. E a volte l’implementazione più ovvia di una struttura dati non si adatta per niente al modello one-owner-at-a-time: un albero con puntatori al genitore, o una cache che due sottosistemi diversi devono poter modificare. Le vie d’uscita pragmatiche:

  • .clone() il valore invece di combattere un borrow. Costa un’allocazione, ed è una correzione di una riga che puoi ottimizzare più avanti, una volta che il resto del codice funziona.
  • Rc<T> (o Arc<T> tra thread) per ownership condivisa, quando più di una parte del programma ha davvero bisogno di mantenere in vita un valore.
  • RefCell<T> (o Mutex<T> tra thread) quando devi modificare qualcosa attraverso un reference condiviso. Questo sposta il controllo del borrow dal momento della compilazione a quello dell’esecuzione, scambiando un errore del compilatore con un panic se sbagli.

Nessuna di queste è una soluzione di cui vergognarsi. Queste scelte dicono anche perché questo modello non è giusto per ogni progetto. Uno script usa e getta che legge un file e stampa un report non guadagna nulla dal tracciare l’ownership byte per byte, e un linguaggio con garbage collector e goroutine ti fa arrivare a un programma concorrente funzionante più in fretta se la memory safety a compile time non è un requisito. Rust ripaga quando un use-after-free o una data race in produzione è il fallimento che non ti puoi permettere, e preferisci pagarne il prezzo in anticipo con errori del compilatore.

La prossima volta che rustc rifiuta il tuo codice con E0382 o E0502, leggi l’errore prima di ricorrere per riflesso a .clone(). Indica i due borrow in conflitto e dove inizia ciascuno: risolvere il problema di scope che segnala di solito costa meno fatica che nasconderlo sotto il tappeto. Quei messaggi sono il modo più rapido per imparare il modello: sono più precisi della maggior parte delle spiegazioni che se ne danno, questa compresa.

Articoli correlati