Salta al contenuto
Rust

Ownership in Rust

L’ownership (proprietà) è il concetto più importante e distintivo di Rust. È il meccanismo che permette a Rust di garantire la sicurezza della memoria senza garbage collector. Ogni valore in Rust ha un unico proprietario, e quando il proprietario esce dallo scope, il valore viene automaticamente deallocato. Comprendere l’ownership è essenziale per scrivere codice Rust corretto.

Le tre regole dell’ownership

L’intero sistema di ownership si basa su tre regole fondamentali:

  1. Ogni valore in Rust ha una variabile che è il suo proprietario (owner).
  2. Può esserci un solo proprietario alla volta per ogni valore.
  3. Quando il proprietario esce dallo scope, il valore viene deallocato (dropped).
fn main() {
    {
        let s = String::from("ciao"); // s è il proprietario della String
        println!("{s}");
    } // s esce dallo scope: la memoria viene liberata automaticamente

    // s non è più accessibile qui
}

Stack vs Heap

Per capire l’ownership, è importante distinguere tra stack e heap:

  • Stack: memoria veloce, dimensione fissa e nota a compile time. Qui vivono interi, float, bool, char, tuple di tipi Copy e riferimenti.
  • Heap: memoria dinamica, per dati la cui dimensione non è nota a compile time. Qui vivono String, Vec<T>, Box<T> e altri tipi allocati dinamicamente.
fn main() {
    let x = 42;                        // Stack: intero a dimensione fissa
    let y = true;                       // Stack: booleano
    let s = String::from("hello");      // Stack: puntatore, lunghezza, capacità
                                        // Heap: i dati effettivi "hello"
}

L’ownership gestisce principalmente i dati sull’heap, perche sono quelli che necessitano di una deallocazione esplicita.

Move semantics

Quando assegni un valore heap a un’altra variabile, Rust non copia il dato: lo muove. Il proprietario originale non è più valido.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // s1 viene "moved" in s2

    // println!("{s1}"); // Errore! s1 non è più valido
    println!("{s2}");    // OK: s2 è il nuovo proprietario
}

Questo comportamento previene il double free: se entrambe le variabili fossero valide, la memoria verrebbe deallocata due volte quando escono dallo scope.

Il move avviene anche nel passaggio a funzioni:

fn stampa(s: String) {
    println!("{s}");
} // s viene deallocata qui

fn main() {
    let messaggio = String::from("Ciao Rust");
    stampa(messaggio);

    // println!("{messaggio}"); // Errore! messaggio è stato moved
}

Il trait Copy

I tipi che vivono interamente sullo stack implementano il trait Copy. Per questi tipi, l’assegnamento crea una copia completa anziché un move, e il valore originale resta valido.

fn main() {
    let x = 42;
    let y = x; // Copia, non move!

    println!("x = {x}, y = {y}"); // Entrambi validi

    let a = 3.14;
    let b = a; // Copia

    let c = true;
    let d = c; // Copia

    let e = 'A';
    let f = e; // Copia

    println!("{a} {b} {c} {d} {e} {f}"); // Tutto valido
}

Tipi che implementano Copy: tutti gli interi (i8, i32, u64, ecc.), i float (f32, f64), bool, char, tuple composte solo da tipi Copy, e i riferimenti &T.

Clone per copie profonde

Per i tipi che non implementano Copy, si può usare .clone() per creare una copia profonda esplicita. Questo alloca nuova memoria sull’heap.

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // Copia profonda esplicita

    println!("s1 = {s1}"); // Valido
    println!("s2 = {s2}"); // Valido, è una copia indipendente
}

clone() è un’operazione potenzialmente costosa perché duplica i dati sull’heap. Usala consapevolmente.

fn main() {
    let nomi = vec![String::from("Alice"), String::from("Bob")];
    let nomi_copia = nomi.clone(); // Clona il vettore e tutte le stringhe

    println!("Originale: {:?}", nomi);
    println!("Copia: {:?}", nomi_copia);
}

Ownership e funzioni

Il passaggio di valori a funzioni segue le stesse regole dell’assegnamento: i tipi Copy vengono copiati, gli altri vengono moved.

fn prende_ownership(s: String) {
    println!("Ho ricevuto: {s}");
} // s viene deallocata

fn crea_copia(n: i32) {
    println!("Ho ricevuto una copia: {n}");
} // n è solo una copia, l'originale resta valido

fn main() {
    let stringa = String::from("hello");
    let numero = 42;

    prende_ownership(stringa);
    // stringa non è più valida

    crea_copia(numero);
    println!("numero è ancora: {numero}"); // OK: è stato copiato
}

Valori di ritorno e ownership

Le funzioni possono restituire l’ownership di un valore al chiamante.

fn crea_stringa() -> String {
    let s = String::from("creata nella funzione");
    s // L'ownership viene trasferita al chiamante
}

fn prendi_e_restituisci(s: String) -> String {
    println!("Usando: {s}");
    s // Restituisce l'ownership
}

fn main() {
    let s1 = crea_stringa();
    println!("{s1}");

    let s2 = String::from("hello");
    let s3 = prendi_e_restituisci(s2);
    // s2 non è più valido, ma s3 lo è
    println!("{s3}");
}

Questo pattern di “prendi e restituisci” è scomodo. Per questo Rust offre il borrowing (prestito tramite riferimenti), che permette di usare un valore senza prenderne l’ownership.

Perche l’ownership è importante

L’ownership risolve diversi problemi di sicurezza della memoria presenti in altri linguaggi:

  1. Niente double free: un valore ha un solo proprietario, quindi viene deallocato una sola volta.
  2. Niente use-after-free: una volta che il proprietario esce dallo scope, il compilatore impedisce l’accesso al valore.
  3. Niente dangling pointer: il compilatore garantisce che i riferimenti siano sempre validi.
  4. Niente data race: le regole di borrowing impediscono accessi concorrenti non sicuri.
  5. Niente garbage collector: la memoria viene gestita in modo deterministico a costo zero a runtime.
fn main() {
    let mut dati = vec![1, 2, 3];

    // Il compilatore impedisce situazioni pericolose:
    // let riferimento = &dati[0];
    // dati.push(4); // Errore! Non si può modificare dati mentre esiste un riferimento

    dati.push(4); // OK: nessun riferimento attivo
    println!("{:?}", dati);
}

Conclusione

L’ownership è il cuore di Rust e la ragione principale per cui il linguaggio può garantire sicurezza della memoria senza costi a runtime. Le tre regole sono semplici da enunciare ma hanno implicazioni profonde. Padroneggiare l’ownership, insieme al borrowing e ai lifetime, è la chiave per scrivere codice Rust idiomatico. Inizialmente il borrow checker può sembrare severo, ma col tempo si apprezza come una guida che spinge verso design migliori.

Contenuto redatto con l'assistenza di strumenti di intelligenza artificiale e rivisto dalla redazione di Codegrind, che ne è responsabile. Hai trovato un errore? Scrivici.