Introduzione alla gestione della memoria in Rust - Ownership & Borrowing

Introduzione alla gestione della memoria in Rust - Ownership & Borrowing

Come Rust fa a meno del garbage collector e dell'allocazione manuale sfruttando un meccanismo di proprietà

Introduzione

I linguaggi di programmazione di basso livello sono fondamentali per la creazione di software che necessitano di performance e di gestione manuale dell'allocazione di memoria come sistemi operativi o drivers.

I linguaggi più conosciuti ed utilizzati per questo scopo sono sicuramente C e C++ che possiedono una grande fetta del mercato anche in confronto a linguaggi di alto livello come Python, C# o Java.

La gestione della memoria può rivelarsi dannatamente complessa, soprattutto per chi si approccia per la prima volta al mondo del low-level e non solo, infatti la stessa Microsoft ha annunciato che circa il 70% dei bug riscontrati negli ultimi 12 anni sono dovuti proprio a questo.

Qui entra in gioco Rust, un linguaggio di programmazione di basso livello, relativamente giovane, che consente di garantire la corretta gestione di memoria senza la necessità di un garbage collector, caratteristica che gli conferisce una velocità pari agli altri linguaggi della stessa categoria.

Cos'è Rust?

Rust è un linguaggio di programmazione di basso livello tipizzato staticamente che ha visto la luce nel 2010 (prima versione stabile nel 2014) negli uffici di Mozilla progettato a partire dal 2006 da Graydon Hoare.

In pochissimo tempo ha avuto un'espansione a macchia d'olio venendo adottato dalle più grandi aziende tech come Google, Amazon, Facebook, Microsoft e la stessa Mozilla fino ad aziende aerospaziali come SpaceX.

Nel 2021 il progetto Rust è stato preso in carico dalla Rust Foundation, organizzazione non profit composta da AWS, Huawei, Google, Mozilla e Microsoft.

Dal 2016 ad oggi (2022) Rust è stato il linguaggio di programmazione più amato dagli sviluppatori secondo il report annuale stilato da StackOverflow e la sua adozione aumenta anno dopo anno.

Ownership

L'ownership è la funzionalità di Rust che gli consente di gestire in sicurezza la memoria.

Questo approccio è diverso sia dai linguaggi di alto livello che sfruttano un garbage collector (come Python o C#) sia dai linguaggi che necessitano di allocazione e deallocazione della memoria manualmente (come C o C++).

La memoria di Rust viene gestita attraverso un sistema di proprietà che il compilatore stesso controlla e verifica, questo porta necessariamente a tempi di compilazione mediamente più lunghi rispetto ad altri linguaggi che potrebbero arrivare anche a diversi minuti. Se stiamo creando soluzioni di dimensioni medio-basse non sarà un problema, infatti in questi casi i tempi di compilazione rientrano più o meno nel range classico. Si dilatano notevolmente per progetti di grandi dimensioni, ma avere la garanzia che l'applicazione non vada in crash improvvisamente a causa di bug di gestione della memoria, secondo me, vale il tempo atteso.

Le 3 regole principali dell'ownership sono le seguenti:

  • Ogni valore è posseduto da una variabile chiamata owner (proprietario)
  • Un valore non può avere più owners
  • Quando l'owner esce dallo scope il valore viene eliminato e la memoria occupata deallocata

I dati con dimensione fissa vengono salvati nello stack, ovvero delle porzioni di memoria dedicate e ben definite assegnate ad un determinato thread. Quelli con dimensione sconosciuta al compilatore o con dimensione variabile durante l'esecuzione del programma, vengono invece salvati nell'heap, una memoria decisamente più grande rispetto alla precedente nella quale i dati vengono salvati senza un particolare ordine logico.

Quando un valore viene salvato nell'heap si rende necessaria la creazione di quello che si chiama puntatore che permette di identificare l'area di memoria appena allocata. Questo puntatore ha una dimensione fissa e quindi può essere tranquillamente salvato nello stack.

Allocazione di memoria

Il tipo String è uno di quelli che viene salvato nell'heap per la possibilità di essere soggetto a modifiche con conseguente variazione della dimensione occupata in memoria. Allochiamo la memoria semplicemente dichiarando la variabile.

let my_string: String = String::from("Hello world");

Nell'heap sarà salvato il valore della nostra stringa mentre nello stack sarà presente il puntatore che punta all'area di memoria allocata.

Deallocazione di memoria

Nel momento in cui la variabile esce dallo scope allora Rust chiamerà per noi la funzione drop() andando ad eliminarla e deallocando la memoria occupata.

fn my_func() {
    let my_string: String = String::from("Hello world");
} // Fine scope funzione, deallocazione memoria di "my_string"

Passaggio di proprietà

Il passaggio di proprietà è la parte fondamentale dell'ownership. Facciamo un esempio:

  • Dichiariamo una stringa s1

    Viene allocata la memoria nell'heap e creato il puntatore nello stack all'area di memoria.

  • Dichiariamo una stringa s2 assegnandole il valore della stringa s1

    Copia i dati presenti nello stack (quindi il puntatore) non dell'area nell'heap!

A questo punto abbiamo due puntatori che fanno riferimento alla stessa area di memoria. Quando le due stringhe usciranno dallo scope Rust cercherà di di deallocare due volte la stessa area di memoria causando un grave bug (conosciuto come double free). Per ovviare a questo problema, quando si assegna il valore di una variabile allocata nell'heap ad un'altra variabile allora Rust non considererà più la prima valida sollevando un errore di compilazione nel momento in cui proveremo ad utilizzarla.

let s1: String = String::from("Hello world");
let s2: String = s1;
println!("{}", s2); 
println!("{}", s1); // ERRORE DI COMPILAZIONE

In questo modo Rust è sicuro di deallocare la memoria una sola volta nel momento in cui esce dallo scope la seconda variabile. La stessa cosa vale per il passaggio del valore ad una funzione, anche in questo caso la proprietà del valore verrà spostata e non sarà più utilizzabile.

fn main() {
    let my_string: String = String::from("Hello world");
    my_func(my_string);
    println!("{}", my_string); // ERRORE DI COMPILAZIONE
}

fn my_func(value: String) {
    println!("{}", value);
}

Questo è il passaggio di proprietà, il comportamento di default che Rust tiene quanto lavoriamo con valori salvati nell'heap. Se utilizzassimo un valore salvato nello stack (come un numero) allora il valore verrebbe semplicemente copiato e non incorreremo nello spostamento della proprietà.

fn main() {
    let my_number: u8 = 5;
    my_func(my_number);
    println!("{}", my_number); // 5
}

fn my_func(value: u8) {
    println!("{}", value); // 5
}

Clonazione

Invece di copiare solo i dati presenti nello stack è possibile anche copiare quelli presenti nell'heap utilizzando il metodo clone() che esegue una duplicazione a tutti gli effetti creando due oggetti separati e non collegati logicamente. Così facendo avremmo due proprietari differenti per i due valori e potremmo andare ad utilizzarli entrambi.

let s1: String = String::from("Hello world");
let s2: String = s1.clone();
println!("{}", s1);
println!("{}", s2);

Borrowing

Invece di spostare la proprietà di una variabile possiamo creare più semplicemente un riferimento utilizzando l'operatore &.

fn main() {
    let my_string: String = String::from("Hello world");
    my_func(&my_string);
    println!("{}", my_string); // Hello world
}

fn my_func(value: &String) {
    println!("{}", value); // Hello world
}

In questo modo stiamo semplicemente passando alla funzione un puntatore in modo che possa sfruttarlo per accedere all'area di memoria interessata senza doverne assumere la proprietà. Dato che la proprietà non è stata spostata sarà quindi ancora disponibile ed utilizzabile nella variabile originaria. L'azione di creazione del riferimento è chiamata borrowing.

Conclusione

Rust ha una curva di apprendimento molto ripida e quello che abbiamo visto in questo articolo è solo la punta dell'iceberg per quanto riguarda ownership e borrowing.

Ora conosci il modo in cui Rust gestisce la memoria garantendo sicurezza ed efficacia.

E' possibile approfondire ulteriormente il linguaggio sfruttando la documentazione ufficiale oppure leggendo il Rust book.

Happy coding!