Ownership

Il concetto di ownership è uno dei principi su cui si basa il sistema di tipi di Rust. In particolare è un set di regole che governa come Rust gestisce la memoria. Molti linguaggi gestiscono la memoria esplicitamente, ad esempio C o C++ tramite l'allocazione dinamica, e altri la gestiscono tramite un garbage collector. Rust usa un terzo modo, ovvero effettua dei controlli a tempo di compilazione che le regole dell'ownership siano rispettate, e gestisce la memoria di conseguenza senza impatto a tempo di esecuzione.

Vediamo adesso quali sono le regole della ownership:

  • ogni valore ha un proprietario (owner)
  • può esserci solo un proprietario in ogni momento
  • quando il proprietario esce dallo scope, il valore viene eliminato

Vediamo adesso come queste regole sono applicate ai programmi Rust.

Regola 1: ogni valore ha un proprietario

Quando scriviamo un assegnamento

fn main() {
    let x = String::from("Test");
}

in realtà quello che stiamo facendo è legare un valore a una variabile. In questo caso stiamo legando un oggetto di tipo stringa alla variabile x. Fino a che x non andrà fuori dallo scope, oppure il valore verrà mosso in un'altra variabile, x avrà la proprietà dell'oggetto.

Regola 3: scope ed eliminazioni dei valori

Lo scope in Rust è simile a quello di altri linguaggi, e può essere riassunto con un esempio.

fn main() {
    let a = "stringa1";

    // introduciamo un nuovo blocco di codice
    {
        // la variabile a è accessibile anche dentro

        let b = "stringa2";
        // adesso la variabile b è entrata nello scope
    }
    // lo scope della variabile b è finito perché abbiamo chiuso il blocco
    // b non è più accessibile
}
// la variabile a esce dallo scope al termine della funzione

In questo esempio, le stringhe sono allocate nella sezione statica del binario, quindi quando le variabili escono dallo scope non accade niente. Abbiamo però già incontrato il tipo String, che alloca dinamicamente una stringa sullo heap.

fn main() {
    {
        // allochiamo dinamicamente la stringa
        // il proprietario della stringa è la variabile a
        let mut a = String::from("Hello");

        // dato che la stringa è allocata dinamicamente (e mutabile),
        // la possiamo anche modificare per esempio appendendo alla fine
        a.push_str(", World!");
        
        println!("{a}"); // => Hello, World!
    }

    // lo scope della variabile a è finito
    // Rust rilascia implicitamente la memoria della stringa
}

Quando la variabile esce dallo scope, Rust inserisce automaticamente una chiamata alla funzione speciale drop, che rilascia la memoria. Questo chiaramente non accade per valori "piccoli" come gli interi che non hanno bisogno di allocare dinamicamente della memoria. Le altre regole di ownership e, come vedremo, le regole di borrowing, servono al compilatore di Rust per garantire che per ogni allocazione ci sarà una e una sola deallocazione, e che ogni valore non sarà utilizzato dopo la sua deallocazione. Come è stato visto nel modulo di sicurezza a basso livello, se non vengono rispettate queste proprietà, possono emergere bug che possono generare delle vulnerabilità.

Regola 2: un solo proprietario

Analizziamo questo frammento di codice:

fn main() {
    let x = 42;
    let y = x;
}

Quello che ci aspettiamo che accada è che sia definita prima una variabile x che contenga il valore 42, e poi sia definita un'altra variabile y e il valore di x venga copiato in y. Questo è in effetti quello che accade, perché x e y sono tipi intero che vivono sullo stack. Cosa succederebbe invece se provassimo a scrivere la stessa cosa ma con una stringa allocata sull'heap?

fn main() {
    let x = String::from("Stringa di test");
    let y = x;
    // (A)
}

La semantica di molti linguaggi di programmazione (ad esempio java o c++) sarebbe quella di avere al punto (A) due "reference" o puntatori alla stessa regione di memoria. In altre parole se modificassimo la stringa tramite x, i cambiamenti sarebbero riflettuti anche su y, perché entrambe "puntano" alla stessa regione di memoria. Un'altra possibile semantica potrebbe essere quella di copiare la stringa, ma questo è costoso nel caso generale. La semantica di Rust è la seguente: al punto (A) è solo possibile accedere alla stringa tramite y, perché la proprietà della stringa è stata spostata nell'assegnamento a y. Questa è chiamata move semantics e, sebbene sia implementata in linguaggi come c++, in Rust è la semantica di default. Questo vuol dire che se proviamo ad accedere a x dopo che la proprietà della stringa è stata cambiata:

fn main() {
    let x = String::from("Stringa di test");
    let y = x;
    println!("x: {x}");
}

otteniamo un errore di compilazione

error[E0382]: borrow of moved value: `x`
 --> src/main.rs:4:18
  |
2 |     let x = String::from("Stringa di test");
  |         - move occurs because `x` has type `String`, which does not implement the `Copy` trait
3 |     let y = x;
  |             - value moved here
4 |     println!("x: {x}");
  |                  ^^^ value borrowed here after move
  |

Leggendo attentamente il compilatore ci rivela perché non possiamo più accedere a x: l'oggetto di tipo String è stato spostato in y alla riga 3, e stiamo provando ad accedervi (in realtà stiamo provando a creare una reference a quell'oggetto) alla riga 4. Il compilatore ci suggerisce anche un possibile fix all'errore:

help: consider cloning the value if the performance cost is acceptable
  |
3 |     let y = x.clone();
  |              ++++++++

ovvero di copiare esplicitamente la stringa tramite il metodo clone(), creando un nuovo oggetto il cui proprietario è y.

fn main() {
    let x = String::from("Stringa di test");
    let y = x.clone();
    println!("x: {x}");
    println!("y: {y}");
}

Così facendo abbiamo due oggetti separati, che sono di proprietà rispettivamente di x e di y, quindi il programma compila correttamente e stampa due volte la stringa.

Per avere tipi che implicitamente copiano il valore quando vengono assegnati è necessario implementare il trait Copy per quel tipo. Quando il tipo di dato è trivialmente copiabile senza particolari costi, per esempio nel caso degli interi o di struct particolarmente piccole, Rust implementa di default il trait Copy, che fa diventare la semantica di assegnamento una copia del valore bit-wise.

Le regole di ownership degli oggetti è una delle fondamenta su cui si basa la sicurezza e la safety del linguaggio. Vedremo nel prossimo capitolo come sia possibile creare delle reference, per avere più variabili all'interno del programma che "puntano" allo stesso oggetto, ma la regola del singolo proprietario rimane valida sempre. Anche se vengono create più reference allo stesso oggetto, queste non prendono completa ownership dell'oggetto, e si dice che l'oggetto è prestato (borrowed) a un'altra variabile.