Introduzione
Rust è un linguaggio di programmazione compilato a tipizzazione statica, che pone l'enfasi sulla performance, memory safety e concorrenza. In questo modulo viene affrontata una introduzione al linguaggio, si assume una familiarità con almeno un altro linguaggio di programmazione comune, come Java o C++.
Verranno esposte solo un sottoinsieme di tutte le funzionalità e di tutti i costrutti del linguaggio, per approfondire si possono consultare le seguenti risorse ufficiali:
- sito ufficiale di Rust,
- il libro The Rust programming language,
- la documentazione della libreria standard.
Perché Rust
L'utilizzo del linguaggio di programmazione Rust potrebbe sembrare a prima vista una complicazione inutile del processo di sviluppo, soprattutto per la sua nota curva di apprendimento che non ne favorisce un utilizzo immediato (o almeno non come potrebbe fare un linguaggio di programmazione come Python). Una volta però acquisita padronanza con la sua sintassi e con il suo sistema di tipi, ci offre molti vantaggi.
- Memory safety: il compilatore riesce a garantire a tempo di compilazione la correttezza di tutte le operazioni in memoria.
- Alto livello di astrazione: il linguaggio è molto ricco sintatticamente e semanticamente, e permette di programmare ad alto livello. Sono incluse astrazioni simili a interfacce (i trait), tipi generici e il linguaggio ha un ricco supporto per la metaprogrammazione tramite le macro.
- Performance: grazie al backend LLVM e alle assunzioni derivate dal sistema di tipi, il compilatore Rust riesce a produrre binari che competono con linguaggi a basso livello come C e C++.
- Ecosistema: l'ecosistema di Rust è molto ricco di strumenti per l'analisi del codice e molte librarie che implementano delle funzionalità standard sono pubblicate sotto forma di crates.
Vediamo adesso più nel dettaglio due di queste proprietà.
Memory safety
Come abbiamo visto nel modulo di sicurezza a basso livello, la presenza di bug di corruzione della memoria possono essere molto pericolosi per un applicazione, in molti casi permettono a un attaccante, tramite un apposito input, di eseguire codice arbitrario nel contesto dell'applicazione. Questa classe di bug può essere evitata in un modo molto comune, ovvero usando un linguaggio che non permette esplicitamente la gestione della memoria a basso livello. Questo paradigma si basa sull'aggiunta di un gestore della memoria a tempo di esecuzione del programma, che combinato con un garbage collector gestisce automaticamente la distruzione di oggetti non più utilizzati. Molti linguaggi di programmazione molto popolari quali Java, Python, C# e molti altri adottano questo meccanismo. Il vantaggio di usare un garbage collector è la flessibilità e la semplicità di gestione della memoria da parte del programmatore, ma il principale svantaggio di questo sistema è che incorre in overhead a tempo di esecuzione.
Rust adotta un sistema di ownership dei valori, in particolare il compilatore riesce a "tracciare" il ciclo di vita degli oggetti, e riesce a inserire a tempo di compilazione il codice di allocazione e distruzione. Queste meccanismo è alla base del linguaggio, ed è anche la differenza più evidente sperimentando per la prima volta con Rust. Esiste comunque la possibilità di allocare "manualmente" oggetti sull'heap, ma anche in questo caso Rust ci fornisce delle astrazioni (i cosiddetti Smart pointers) per garantire la memory safety.
Alto livello di astrazione
Arrivando da un linguaggio di alto livello come Java o C#, Rust non manca di astrazioni, in particolare:
- permette di definire strutture e metodi (anche se non è strettamente un linguaggio orientato agli oggetti),
- permette l'uso di astrazioni derivanti dai linguaggi funzionali, quali le enumerazioni e l'uso di funzioni di ordine superiore,
- permette la definizione di trait (simili a interfacce in Java, ma con un meccanismo diverso d'instanziazione) per la definizione di attributi e metodi comuni a più strutture,
- ha un ricco ecosistema di meta programmazione tramite macro,
e molto altro.
Installazione e setup
Il compilatore Rust può essere installato seguendo le istruzioni ufficiali a questo link. Dopo l'installazione, aprendo un terminale il comando
rustc --version
dovrebbe stampare la versione del compilatore.
Compilazione di un programma Rust
Iniziamo a scrivere il primo programma Rust che stampa la stringa "Hello, World!"
fn main() { println!("Hello, world!"); }
vedremo nella prossima sezione la sintassi del linguaggio, per adesso possiamo salvare il programma in un file main.rs, e possiamo compilarlo da riga di comando invocando il compilatore Rust:
$ rustc main.rs
$ ./main
Hello, world!
Come già detto, Rust è un linguaggio compilato, quindi il compilatore ci restituisce un file eseguibile che poi possiamo eseguire.
Setup di un progetto Rust tramite Cargo
Cargo è il sistema standard di build e di packaging di Rust, viene usato dalla maggior parte dei progetti per gestire le dipendenze e la compilazione del progetto.
Per chi è familiare con lo sviluppo frontend, svolge una funzione analoga a npm.
Avendo installato Rust, cargo dovrebbe disponibile da riga di comando, per testare che sia installato correttamente il comando
$ cargo --version
dovrebbe mostrare la versione installata di cargo.
Per creare un nuovo progetto si può usare il comando
$ cargo init <nome-progetto>
che crea la cartella <nome-progetto>, e la inizializza con un progetto vuoto.
mioprogetto
├── Cargo.toml
└── src
└── main.rs
Il file Cargo.toml contiene alcuni metadati sul progetto, e le sue dipendenze, mentre il file main.rs viene inizializzato con un programma identico a quello sopra.
Si può compilare ed eseguire il progetto con cargo con il comando
$ cargo run
Per compilare senza eseguire il progetto si può usare il comando
$ cargo build
Questo scriverà gli eseguibili nella cartella target.
Sintassi di base
Un semplice programma
Abbiamo già incontrato il primo programma che stampa una stringa in output, ma adesso prendiamo in considerazione questo programma che chiede da riga di comando un nome e stampa in output un saluto.
// questo è un commento /* Questo è un commento multilinea */ use std::io; fn main() { println!("Inserisci il tuo nome"); let mut nome = String::new(); io::stdin() .read_line(&mut nome) .expect("Errore nella lettura da stdin"); println!("Ciao {nome}"); }
Vediamo ora nel dettaglio i componenti di questo programma:
#![allow(unused)] fn main() { // questo è un commento /* Questo è un commento multilinea */ }
In Rust si possono scrivere dei commenti con una sintassi simile a quella di c++ o Java.
#![allow(unused)] fn main() { use std::io; }
Come prima cosa importiamo il modulo io dalla libreria standard di Rust std. La documentazione completa del modulo si trova a questo link.
Questo ci serve per leggere una stringa da standard input.
fn main() { println!("Inserisci il tuo nome"); }
Definiamo quindi la funzione main, che è la prima a essere chiamata all'esecuzione del nostro programma. La funzione main non prende argomenti in input, al contrario di altri linguaggi di programmazione come C o Java.
Variabili
fn main() { let mut nome = String::new(); }
Tramite il costrutto let definiamo una variabile. Le variabili sono immutabili per default, ma possiamo creare una variabile mutabile inserendo anche la keyword mut.
Infatti, se proviamo a modificare una variabile immutabile
fn main() { let a = 3; a = 4; }
il compilatore Rust restituisce un errore
error[E0384]: cannot assign twice to immutable variable `a`
--> src/main.rs:3:5
|
2 | let a = 3;
| -
| |
| first assignment to `a`
| help: consider making this binding mutable: `mut a`
3 | a = 4;
| ^^^^^ cannot assign twice to immutable variable
For more information about this error, try `rustc --explain E0384`.
L'errore è dovuto al fatto che non possiamo assegnare due volte a una variabile immutabile.
Il compilatore ci suggerisce anche un possibile fix, facendo diventare la variabile a mutabile.
Cambiando il codice come segue l'errore viene risolto e il programma compila con successo.
fn main() { let mut a = 3; a = 4; }
Ritornando al programma iniziale
fn main() { let mut nome = String::new(); }
Stiamo assegnando alla variabile mutabile nome un oggetto di tipo String, ovvero una stringa ridimensionabile e codificata tramite UTF-8, che ci mette a disposizione la libreria standard. In particolare stiamo invocando il metodo associato new del tipo String, che ci permette di creare una nuova stringa.
Input/output
Passiamo adesso alle righe successive
io::stdin()
.read_line(&mut nome)
.expect("Errore nella lettura da stdin");
La funzione stdin importata dal modulo io restituisce un handle allo standard input, che è un oggetto di tipo std::io::Stdin.
Su questo oggetto viene invocato il metodo read_line, che legge una riga da standard input e la scrive all'interno della variabile nome.
La stringa nome viene passata alla funzione read_line come &mut, il che vuol dire che la stiamo passando come una reference mutabile, senza quindi la necessità di copiare l'oggetto.
Vedremo nei prossimi capitoli il meccanismo del borrowing, e come questo ci permette di gestire reference in maniera semplice e sicura.
La funzione read_line restituisce un oggetto di tipo Result, che indica il risultato di una computazione che potrebbe potenzialmente fallire.
Infatti, il valore di un oggetto Result può essere Ok oppure Err, come possiamo vedere dalla sua definizione
#![allow(unused)] fn main() { use std::io::Error; enum Result<T> { Ok(T), Err(Error), } }
Vedremo le enumerazioni in un prossimo capitolo. La funzione expect semplicemente lancia una eccezione se il risultato è un errore.
Rust rende estremamente semplice la gestione degli errori e, sebbene esista un meccanismo di eccezioni, la gestione esplicita degli errori è preferibile, in quanto garantisce che il programma non terminerà improvvisamente.
fn main() { let nome = "Test"; println!("Ciao {nome}"); }
Alla fine, stampiamo la stringa Ciao seguita dal nome. La funzione println! è in realtà una macro (tipicamente i nomi delle macro terminano con un !), che permette di specificare una formattazione per la stringa, e in questo caso d'inserire il valore di variabili da stampare.
Costrutti primitivi
In questo capitolo vediamo alcuni costrutti primitivi del linguaggio Rust che ci permetteranno di scrivere i primi semplici programmi.
Variabili e tipi
Immutabilità
Come abbiamo visto, le variabili in Rust sono immutabili per default, questo potrebbe sembrare un ostacolo ma è una delle feature del linguaggio che tendono verso la safety e la sicurezza. Come abbiamo visto l'assegnamento di una variabile immutabile causa un errore di compilazione
fn main() { let var = 7; println!("var: {var}"); var = 4; println!("var: {var}"); }
provando a compilare, ci viene restituito questo errore:
error[E0384]: cannot assign twice to immutable variable `var`
--> src/main.rs:4:5
|
2 | let var = 7;
| ---
| |
| first assignment to `var`
| help: consider making this binding mutable: `mut var`
3 | println!("var: {var}");
4 | var = 4;
| ^^^^^^^ cannot assign twice to immutable variable
For more information about this error, try `rustc --explain E0384`.
Shadowing
Quello che però possiamo fare è il cosiddetto shadowing, ovvero possiamo definire un'altra variabile con lo stesso nome.
fn main() { let var = 7; println!("var: {var}"); let var = 4; println!("var: {var}"); }
Questa volta il programma compila con successo, e ci viene restituito l'output atteso
var: 7
var: 4
In realtà quello che stiamo facendo è mascherare la prima variabile con la seconda definizione, ma concettualmente sono variabili diverse, entrambe immutabili. Il compilatore Rust è abbastanza intelligente nella maggior parte dei casi e riesce a ottimizzare in fase di compilazione, non allocando ulteriori variabili sullo stack. Le variabili possono anche avere lo stesso nome ma tipi diversi
fn main() { let var = 7; println!("var: {var}"); let var = "stringa di test"; println!("var: {var}"); }
questo avviene perché effettivamente sono variabili diverse, e la prima variabile esce dall'ambiente al momento della seconda definizione.
Variabili mutabili
Possiamo comunque rendere le nostre variabili mutabili con la keyword mut, e se ne potrà cambiare il valore.
fn main() { let mut var = 7; println!("var: {var}"); var = 4; println!("var: {var}"); }
Come sopra, il risultato del programma è quello atteso:
var: 7
var: 4
Tipi di dato primitivi
Fino ad adesso non abbiamo specificato nessun tipo alle variabili, ma abbiamo detto all'inizio che Rust è un linguaggio di programmazione staticamente tipizzato.
Questo vuol dire che il tipo di ogni variabile deve essere definito a tempo di compilazione, e non può cambiare durante l'esecuzione del programma.
Come molti altri linguaggi, Rust supporta la type inference, ovvero per la maggior parte dei casi riesce a inferire il tipo delle variabili dal contesto.
Questa feature è simile alle funzionalità var di Java, e auto di c++.
Avremmo potuto annotare il tipo esplicitamente con la seguente notazione
#![allow(unused)] fn main() { let var: u32 = 7; }
dove u32 è il tipo di dato intero senza segno (unsigned) rappresentabile in 32 bit.
Rust supporta numerosi tipi di dato numerici, sia interi che a virgola mobile.
La lista completa è presente sulla reference ufficiale.
Su questi tipi di dato si possono effettuare le operazioni matematiche comuni
fn main() { let somma = 7 + 3; let sottrazione = 37.0 - 23.5; let prodotto = 17*23; let divisione = 50.3 / 23.1; let resto = 14 % 5; // => 4 let divisione_intera = 7 / 3; // => 2 }
Bisogna notare che le operazioni con gli interi possono risultare in overflow. Rust gestisce questa occorrenza in due casi:
- se il programma è compilato in modalità debug, include dei controlli e il programma si interrompe se incorre in un overflow
- se il programma è compilato in modalità release, non include questi controlli e al risultato applicato un wrap around.
Per un comportamento consistente, è possibile usare le funzioni che eseguono sempre il wrap come
wrapping_add, oppure usare le funzioni che eseguono sempre i controlli comechecked_add.
Altri tipi primitivi sono i booleani e i caratteri.
fn main() { let vero: bool = true; let falso: bool = false; // operatori logici let and = vero && falso; let or = vero || falso; let not = !vero; let c: char = 'A'; // è supportato anche utf-8 let fire: char = '🔥'; }
Tuple
Le tuple sono un modo per raggruppare elementi di tipo potenzialmente diverso in una unica struttura con dimensione fissa. Possiamo accedere agli elementi usando il loro indice, oppure tramite la destrutturazione. Vedremo che quest'ultimo è un caso particolare di una feature di Rust molto usata: il pattern matching.
Il programma
fn main() { let tupla: (i32, f64, bool) = (500, 6.4, true); println!("tupla: {:?}", tupla); // accesso con gli indici println!("tupla.0: {}", tupla.0); println!("tupla.1: {}", tupla.1); println!("tupla.2: {}", tupla.2); // destrutturazione let (x, y, z) = tupla; println!("x: {}", x); println!("y: {}", y); println!("z: {}", z); }
restituisce il seguente risultato.
tupla: (500, 6.4, true)
tupla.0: 500
tupla.1: 6.4
tupla.2: true
x: 500
y: 6.4
z: true
Array
Un array è una collezione di elementi con lo stesso tipo e con una lunghezza fissa. Si può dichiarare con gli elementi tra parentesi quadre, separati con una virgola.
fn main() { let a: [i32; 6] = [1, 34, 27, 2, -1, 0]; }
Il tipo [i32; 6] indica che l'array contiene sei elementi di tipo i32.
Array locali sono allocati sullo stack (in maniera similare agli array in c++).
Vedremo più avanti come creare collezioni memorizzate sullo heap con dimensione dinamica tramite la struttura Vec.
Si può accedere agli elementi con il loro indice, e Rust cerca di limitare gli accessi out of bound. Il seguente codice
fn main() { let a: [i32; 6] = [1, 34, 27, 2, -1, 0]; println!("a: {:?}", a); println!("a[0]: {:?}", a[0]); println!("a[3]: {:?}", a[3]); // indice out of bounds println!("a[6]: {:?}", a[6]); }
restituisce il seguente errore
error: this operation will panic at runtime
--> src/main.rs:9:28
|
9 | println!("a[6]: {:?}", a[6]);
| ^^^^ index out of bounds: the length is 6 but the index is 6
|
= note: `#[deny(unconditional_panic)]` on by default
Questo codice invece
fn main() { let a: [i32; 6] = [1, 34, 27, 2, -1, 0]; println!("a: {:?}", a); println!("a[0]: {:?}", a[0]); println!("a[3]: {:?}", a[3]); }
restituisce il seguente output
a: [1, 34, 27, 2, -1, 0]
a[0]: 1
a[1]: 2
Quando il compilatore non riesce a inferire la correttezza degli accessi, comunque inserisce dei controlli a runtime, e lancia una eccezione quando si prova ad accedere a un array out of bounds. Questa è una delle feature di memory safety di Rust, linguaggi come c o c++ semplicemente effettuano l'accesso anche se l'indice non è nei limiti dell'array.
Funzioni
Abbiamo già incontrato la funzione main, che viene invocata all'inizio dell'esecuzione di ogni programma.
Possiamo definire altre funzioni tramite la parola chiave fn, e possono essere invocate da altre funzioni.
fn main() { println!("funzione main"); // chiama funzione stampa stampa(); } fn stampa() { println!("funzione stampa"); }
Se proviamo a eseguire questo programma ci viene restituito
funzione main
funzione stampa
Le funzioni possono prendere dei parametri in ingresso, in questo caso dobbiamo fornire i tipi dei parametri esplicitamente
fn main() { let x = 5; stampa_valore(x); } fn stampa_valore(valore: u32) { println!("Il valore è {valore}"); }
Il risultato di questo codice è
Il valore è 5
Valori di ritorno
Le funzioni possono ritornare dei valori, e il tipo di ritorno deve essere annotato esplicitamente con la notazione ->.
fn main() { let x = 5; let y = 6; let somma = calcola_somma(x, y); println!("La somma di {} e {} è {}", x, y, somma); } fn calcola_somma(x: i32, y: i32) -> i32 { x + y }
Il risultato dell'esecuzione di questo codice è
La somma di 5 e 6 è 11
Questo codice potrebbe sembrare strano perché non ci sono istruzioni di tipo return.
Di fatto, in Rust è presente questa istruzione e la funzione sopra è equivalente a questa:
#![allow(unused)] fn main() { fn calcola_somma(x: i32, y: i32) -> i32 { return x + y; } }
Ma sopra stiamo utilizzando il fatto che le funzioni in Rust ritornano implicitamente il valore dell'ultima espressione (senza il punto e virgola). Questa è una feature anche presente nei blocchi, per esempio
fn main() { let a = { let x = 4; x * 3 }; println!("a: {a}"); // => a: 12 }
Quello che sta accadendo è che stiamo definendo un nuovo blocco tra parentesi graffe, e il risultato della valutazione del blocco è implicitamente l'ultima espressione senza il punto e virgola finale.
Le classiche regole di scoping vengono applicate, quindi al di fuori del blocco la variabile x non può essere acceduta.
Vedremo nel prossimo capitolo che il passaggio di variabili da uno scope all'altro in Rust avviene in maniera particolare.
Controllo del flusso
Espressioni condizionali
Come in ogni linguaggio di programmazione, Rust supporta l'esecuzione condizionale di codice sulla base di una condizione.
fn main() { let x = 4; if x > 5 { println!("x è maggiore di 5"); } else { println!{"x è minore o uguale di 5"}; } }
Compilando ed eseguendo il codice sopra otteniamo il risultato
x è minore o uguale di 5
Il tipo di una condizione deve essere necessariamente un booleano, a differenza di altri linguaggi come javascript o Python che convertono implicitamente i valori a booleani.
Possiamo avere anche catene di condizioni tramite il costrutto else if
fn main() { let x = 4; if x > 5 { println!("x è maggiore di 5"); } else if x > 3 { println!{"x è minore o uguale di 5 ma maggiore di 3"}; } else { println!{"x è minore o uguale di 3"}; } }
Eseguendo il codice sopra otteniamo questo risultato
x è minore o uguale di 5 ma maggiore di 3
Cicli
Il costrutto loop permette di creare un ciclo infinito in Rust:
fn main() { loop { println!("loop!"); } }
Se proviamo a eseguire questo programma vedremo che verrà stampata la stringa loop! all'infinito, o almeno fino a che non terminiamo il programma in maniera esplicita.
$ cargo run
Compiling loops v0.1.0 (./projects/loops)
Finished dev [unoptimized + debuginfo] target(s) in 0.11s
Running `target/debug/loops`
loop!
loop!
loop!
...
Possiamo interrompere un ciclo tramite la parola chiave break.
fn main() { let mut i = 0; loop { if i == 4 { break; } println!("loop {i}"); i += 1; } }
Eseguendo il programma vediamo che alla quarta iterazione il loop viene interrotto.
loop 0
loop 1
loop 2
loop 3
Fine del loop
Il ciclo sopra può essere trasformato in un ciclo while, che si comporta come in molti altri linguaggi.
fn main() { let mut i = 0; while i <= 4 { println!("loop {i}"); i += 1; } }
Possiamo anche usare il costrutto for per iterare su i membri di una collezione, come ad esempio un array.
fn main() { let arr = [1, 2, 3, 4, 5]; for x in arr { println!("il valore è {}", x); } }
Il risultato del programma è quello che ci aspettiamo, ovvero
il valore è 1
il valore è 2
il valore è 3
il valore è 4
il valore è 5
Ownership e il borrow checker
In questo capitolo vediamo l'ownership, che è una delle caratteristiche che contraddistinguono Rust dagli altri linguaggi, e che lo rendono un linguaggio memory safe senza aver bisogno di un garbage collector. Insieme, vediamo anche dei concetti molto vicini all'ownership, ovvero il borrowing, le reference e le slice.
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.
Il borrow checker
Ownership e funzioni
Il passaggio degli argomenti alle funzioni, e il valore di ritorno seguono le stesse regole della ownership, similarmente agli assegnamenti. Questo vuol dire che se per esempio passiamo una stringa a una funzione, quella ne prende proprietà.
fn main() { let name = String::from("Mario"); // la proprietà della stringa è trasferita quì greet(name); // quì non è più possibile accedere a name } fn greet(x: String) { // x prende la ownership println!("Ciao {x}"); } // x va out of scope quì, quindi la stringa viene eliminata
Un modo per trasferire la proprietà indietro da una funzione è quello di ritornare l'oggetto al chiamante.
fn main() { let name = String::from("Mario"); // la proprietà della stringa è trasferita quì, ma viene ripresa e trasferita in name2 let name2 = greet(name); // quì non è più possibile accedere a name ma è possibile accedere a name2 println!("name2: {name2}"); } fn greet(x: String) -> String { println!("Ciao {x}"); return x; }
Chiaramente questo è oneroso e non pratico, ma Rust offre un modo per "prestare" un oggetto a una variabile in modo che ci possa accedere, ma che non prenda ownership dell'oggetto. Questa feature si chiama borrowing.
Borrowing
Possiamo creare una reference a un oggetto, che intuitivamente è come un puntatore a quell'oggetto, ma senza prendere ownership del valore. La stessa funzione di sopra può essere scritta così:
fn main() { let name = String::from("Mario"); // passiamo una reference con & greet(&name); // name ha ancora ownership della stringa println!("name: {name}"); } fn greet(x: &String) { println!("Ciao {x}"); } // x esce dallo scope, ma x è solo una reference e quindi // la stinga non viene eliminata
Possiamo creare una reference tramite l'operatore &, il suo inverso è l'operatore di de-referenziazione *, ma tipicamente Rust riesce a inferire il livello d'indirezione, così possiamo lavorare trasparentemente con le reference.
Alla chiamata della funzione, viene passata una reference e il valore viene "prestato" alla variabile x.
Quando questa esce dallo scope alla fine della funzione greet, il valore viene restituito indietro.
Mutable reference
Cosa succede se proviamo a modificare un valore tramite una reference?
fn main() { let mut name = String::from("Hello"); make_greet(&name); println!("name: {name}"); } fn make_greet(x: &String) { x.push_str(" World!"); }
Nonostante la variabile originaria sia mutabile, otteniamo un errore:
error[E0596]: cannot borrow `*x` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
8 | x.push_str(" World!");
| ^ `x` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
Questo perché la reference stessa deve essere marcata come mutabile, o in altre parole con solo l'operatore & stiamo creando una reference immutabile.
Come al solito il compilatore ci suggerisce un fix:
help: consider changing this to be a mutable reference
|
7 | fn make_greet(x: &mut String) {
| +++
ovvero quello di cambiare il tipo di reference a una reference mutabile. Le reference mutabili possono accedere al valore sia in lettura che in scrittura, e quindi in particolare possono modificare il valore originale.
fn main() { let mut name = String::from("Hello"); make_greet(&mut name); println!("name: {name}"); } fn make_greet(x: &mut String) { x.push_str(" World!"); }
Notiamo che abbiamo dovuto dichiarare la funzione che accetti una reference mutabile &mut, e poi abbiamo dovuto creare esplicitamente una reference mutabile con l'operatore &mut.
In questo modo è chiaro sia al chiamante che alla funzione che quel particolare valore è acceduto anche in scrittura.
Equivalentemente, se passiamo un valore come reference immutabile a una funzione, Rust garantisce che quel valore non verrà modificato dalla funzione.
Il borrow checker
Le reference seguono due regole che ci aiutano a definire un linguaggio safe, ovvero:
- per ogni oggetto è possibile avere a ogni momento più reference immutabili oppure una sola reference mutabile,
- ogni reference deve essere valida per tutta la sua vita.
La prima regola è una diretta applicazione del principio "single writer, multiple readers", ed è uno dei pilastri con cui Rust riesce per esempio a gestire la concorrenza in maniera sicura. In particolare questa regola implica che by-design non è possibile creare una race condition, perché richiederebbe che due thread abbiano accesso mutabile allo stesso oggetto. Ovviamente a volte questa situazione è inevitabile, ad esempio se vogliamo implementare una coda condivisa tra thread, ma Rust offre dei meccanismi per gestire anche queste situazioni che vedremo in un prossimo capitolo.
Vediamo alcuni esempi di questo principio:
fn main() { let mut x = String::from("Test"); // possiamo creare più reference immutabili senza problemi let r1 = &x; let r2 = &x; println!("r1: {r1}, r2: {r2}"); // r1 e r2 non sono più usati da questo punto in poi // quindi è possibile creare una reference mutabile // (mr non coesiste con r1 e r2) let mr = &mut x; mr.push_str(" String"); let mr2 = &mut x; // ^ errore di compilazione // perché mr è acceduta anche alla riga sotto // quindi a questo punto coesisterebbero due reference // mutabili mr e mr2 println!("mr: {mr}, mr2:{mr2}"); }
L'errore ci riporta che non possiamo "prestare" la variabile x in maniera mutabile più di una volta contemporaneamente. e ci presenta dove vengono definite le reference.
error[E0499]: cannot borrow `x` as mutable more than once at a time
--> src/main.rs:15:15
|
12 | let mr = &mut x;
| ------ first mutable borrow occurs here
...
15 | let mr2 = &mut x; // <- errore di compilazione
| ^^^^^^ second mutable borrow occurs here
...
19 | println!("mr {mr}");
| ---- first borrow later used here
Dangling references
In un linguaggio come Java o c++ sarebbe molto facile creare reference o puntatori a oggetti invalidi o che non esistono più, perché i linguaggi non lo impediscono. In Rust questo non è possibile:
fn main() { let r = return_ref(); } fn return_ref() -> &String { let x = String::from("Test"); &x // ritorna una reference a x, ma non trasferisce l'ownership // quindi l'oggetto sarebbe eliminato alla fine della funzione }
Il compilatore ci restituisce un errore, dicendo che stiamo ritornando una reference a un valore che non esisterà più.
error[E0106]: missing lifetime specifier
--> src/main.rs:5:20
|
5 | fn return_ref() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
Come al solito il compilatore ci fornisce dei possibili fix, e suggerisce che probabilmente vogliamo ritornare anche la ownership della stringa al chiamante.
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
5 | fn return_ref() -> &'static String {
| +++++++
help: instead, you are more likely to want to return an owned value
|
5 - fn return_ref() -> &String {
5 + fn return_ref() -> String {
|
Una altra possibile soluzione è quella di indicare esplicitamente la lifetime della reference, cosa che permette di specificare al compilatore il "periodo di validità" della reference. Vedremo le lifetime in un prossimo capitolo.
Strutture e enumerazioni
Una delle funzionalità basilari di ogni linguaggio di programmazione è quella di aggregare dati in maniera strutturata. Rust ci offre due costrutti per l'aggregazione di dati: le strutture e le enumerazioni.
Strutture
Come ogni linguaggio comune, Rust supporta l'aggregazione di valori in strutture e la definizione di metodi che operano sui dati contenuti nella struttura.
Le strutture possono essere considerate come delle tuple, dove però vengono definiti dei nomi ai diversi campi che la compongono.
Per definire una struttura, viene usata la parola chiave struct, seguita da una lista di campi insieme ai loro tipi.
Questo è un esempio di come si potrebbe definire una struttura per contenere dati di un account.
#![allow(unused)] fn main() { struct Account { username: String, email: String, is_admin: bool, login_count: u64, } }
Possiamo adesso creare una istanza di questa struttura specificando i valori per ogni campo:
struct Account { username: String, email: String, is_admin: bool, login_count: u64, } fn main() { let admin = Account { username: String::from("admin"), email: String::from("admin@example.com"), is_admin: true, login_count: 0, }; }
Possiamo ovviamente creare delle funzioni che restituiscono delle strutture.
struct Account { username: String, email: String, is_admin: bool, login_count: u64, } fn build_account(username: String, email: String) -> Account { Account { username: username, email: email, is_admin: true, login_count: 0, } } fn main() { let admin = build_account(String::from("admin"), String::from("admin@example.com")); }
In questo caso, non prendiamo in ingresso delle reference a stringhe nella funzione build_account, perché vogliamo prendere ownership delle stringhe in ingresso.
L'ownership delle stringhe sarà poi restituita al chiamante all'interno della struttura, nei due campi username e email.
In generale, la variabile che ha ownership di una struttura detiene anche l'ownership di tutti i suoi campi, e se una struttura è dichiarata mutabile allora anche tutti i suoi campi lo sono, mentre se è dichiarata immutabile anche tutti i suoi campi lo sono.
Stampa delle strutture e debug
Una cosa molto importante sarebbe scrivere in output il valore dei campi della struttura, ma non è sufficiente usare la solita macro println! come abbiamo fatto fino ad adesso: infatti se proviamo a stampare una struttura
struct Account { username: String, email: String, is_admin: bool, login_count: u64, } fn build_account(username: String, email: String) -> Account { Account { username: username, email: email, is_admin: true, login_count: 0, } } fn main() { let admin = build_account( String::from("admin"), String::from("admin@example.com"), ); println!("admin: {admin}"); }
Il compilatore ci restituisce un errore perché la il tipo Account non implementa std::fmt::Display, che è un trait che specifica come devono essere formattati gli oggetti quando si provano a stampare.
Una situazione analoga potrebbe essere la definizione di del metodo toString() in Java, anche se in questo caso la semantica è leggermente diversa.
error[E0277]: `Account` doesn't implement `std::fmt::Display`
Quello che possiamo fare, però, è usare un formattatore di debug come ci suggerisce il compilatore
= help: the trait `std::fmt::Display` is not implemented for `Account`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
Possiamo quindi cambiare il formattatore con quello di debug, e abilitare l'opzione per la struttura Account per essere stampabile come debug.
Questo infatti non viene abilitato di default, ma deve essere attivato derivando il trait Debug.
Vedremo meglio i trait nel prossimo capitolo, ma per adesso possiamo stampare la struttura in questo modo:
#[derive(Debug)] struct Account { username: String, email: String, is_admin: bool, login_count: u64, } // ... fn build_account(username: String, email: String) -> Account { Account { username: username, email: email, is_admin: true, login_count: 0, } } fn main() { let admin = build_account( String::from("admin"), String::from("admin@example.com"), ); println!("admin: {:#?}", admin); }
Eseguendo il programma vedremo stampati tutti i campi della nostra struttura, formattati in modo ordinato.
admin: Account {
username: "admin",
email: "admin@example.com",
is_admin: true,
login_count: 0,
}
Bisogna notare che questa feature è strettamente intesa per stampare informazioni di debug, per una serializzazione più robusta ci si può affidare a librerie specifiche come serde.
Metodi
Possiamo definire delle funzioni associate a una struttura tramite dei blocchi impl (implementation):
struct Vector { x: f32, y: f32 } impl Vector { fn length(&self) -> f32 { (self.x*self.x + self.y*self.y).sqrt() } } fn main() { let v = Vector { x: 2.0, y: 3.0 }; println!("La lughezza è {}", v.length()); }
l'output del programma è
La lughezza è 55.31727
Per definire una funzione nel contesto della struttura Vector iniziamo un blocco impl.
Possiamo definire una funzione length che prende in ingresso una reference a self.
In realtà questo è zucchero sintattico equivalente a length(self: &Self) e Self è un alias al tipo di cui stiamo definendo l'implementazione (in questo caso Rectangle).
Il primo argomento è quindi l'oggetto su cui stiamo chiamando il metodo.
Possiamo scegliere se prendere in ingresso una reference immutabile con &self, una reference mutabile con &mut self, oppure prendere direttamente ownership dell'oggetto con self.
Nell'esempio abbiamo scelto &self perché non vogliamo prendere ownership dell'oggetto, in quanto si dovrebbe poi restituire al chiamante, in maniera simile a come è stato già descritto nel capitolo sulle funzioni.
Possiamo poi chiamare il metodo tramite una sintassi particolare, ovvero oggetto.metodo(argomenti).
Questa chiamata popolerà il primo argomento con l'oggetto su cui stiamo invocando il metodo.
Funzioni associate
Non è necessario per una funzione dentro un blocco impl di prendere come primo argomento un oggetto.
Possiamo anche definire delle funzioni associate a una struttura, ma che possono essere chiamate senza avere una istanza della struttura.
Un esempio di tali funzioni è String::from che abbiamo usato in precedenza.
Per definire una funzione del genere semplicemente possiamo omettere il primo parametro self.
struct Vector { x: f32, y: f32 } impl Vector { fn zero() -> Self { Vector { x: 0.0, y: 0.0 } } fn length(&self) -> f32 { (self.x*self.x + self.y*self.y).sqrt() } } fn main() { let v = Vector::zero(); println!("La lughezza è {}", v.length()); }
La funzione può essere chiamata tramite la sintassi Struttura::funzione(argomenti), e si comporta come una qualunque funzione.
L'unica differenza è che viene definita nel contesto della struttura, quindi è possibile utilizzare ad esempio l'alias di tipo Self.
Enumerazioni
Abbiamo visto come le strutture ci danno la possibilità di raggruppare dei valori correlati, come la componente x e y di un vettore.
Le enumerazioni ci danno invece la possibilità di esprimere che un valore può assumere solo un certo insieme di valori possibili.
Per esempio se vogliamo esprimere il fatto che un vettore può essere definito con le sue componenti cartesiane oppure con le coordinate polari, possiamo creare una nuova enumerazione CoordinateKind che può assumere solo due valori:
#![allow(unused)] fn main() { enum CoordinateKind { Cartesian, Polar } }
Possiamo adesso creare funzioni che accettano una CoordinateKind come argomento
#![allow(unused)] fn main() { enum CoordinateKind { Cartesian, Polar } fn do_something(kind: CoordinateKind) {} }
Possiamo adesso chiamare la funzione con una delle due varianti dell'enumerazione.
Il compilatore Rust verificherà che in ogni momento una variabile di tipo CoordinateKind contiene una delle varianti valide.
enum CoordinateKind { Cartesian, Polar } fn do_something(kind: CoordinateKind) {} fn main() { do_something(CoordinateKind::Cartesian); do_something(CoordinateKind::Polar); }
La vera potenza delle enumerazioni sta nel fatto che possiamo memorizzare informazioni per ognuna delle varianti. Intuitivamente è come se stessimo definendo più di una versione di una struttura.
enum Vector { Cartesian(f32, f32), Polar(f32, f32) } fn main() { let v1 = Vector::Cartesian(3.0, 2.0); let v2 = Vector::Polar(4.2, 1.0); }
In realtà le varianti possono includere diversi tipi di dati associati, ad esempio se vogliamo definire un tipo Request che rappresenta una richiesta con più tipi possibili, possiamo esprimerla così
#![allow(unused)] fn main() { enum Request { Exit, GetPage(String), GetValueAtPosition { x: u32, y: u32 }, SetBackgroundColor(u8, u8, u8) } }
Questa enumerazione ha quattro varianti:
Exitnon ha nessun dato associatogetPageha una stringa associatagetValueAtPositionha una struttura associata, con i nomi dei campisetBackgroundColorha una tupla associata, in questo caso tre valoriu8.
Pattern matching
Una operazione utile sui tipi enumerazioni è quella di controllare quale delle varianti sia un valore, così da poter modificare il comportamento del programma.
Ad esempio per calcolare la lunghezza di un vettore, dobbiamo fare due calcoli differenti quando è espresso in coordinate cartesiane e in coordinate polari.
Questo è esattamente quello che ci permette di fare il costrutto match:
enum Vector { Cartesian(f32, f32), Polar(f32, f32) } impl Vector { fn length(&self) -> f32 { match self { Vector::Cartesian(x, y) => (x*x + y*y).sqrt(), Vector::Polar(r, _a) => *r } } } fn main() { let v1 = Vector::Cartesian(3.0, 2.0); let v2 = Vector::Polar(4.2, 1.0); println!("Lunghezza di v1: {}", v1.length()); println!("Lunghezza di v2: {}", v2.length()); }
L'output del programma è
Lunghezza di v1: 3.6055512
Lunghezza di v2: 4.2
Possiamo fare delle osservazioni sul codice sopra:
- la funzione
lengthè inserita all'interno di un bloccoimpl, infatti è possibile definire dei metodi anche per le enumerazioni. Grazie a questo, possiamo chiamare la funzionelengthcon la sintassi di un metodo. - Il costrutto
matchesegue appunto il cosiddetto "pattern matching", ovvero prova a eguagliare il valore target con una struttura, legando i valori interni. Per esempio il primo ramo indica che stiamo cercando un valore del tipoVector::Cartesian, con valori internixey. All'interno della espressione possiamo usare questi valori interni, che sono in un certo senso "estratti" dalla struttura. - Nel secondo ramo, siccome stiamo utilizzando solamente
r, il parametroaè prefissato con un underscore per indicare che è intenzionalmente non usato nell'espressione. Il compilatore Rust, infatti, restituisce un warning ogni volta che una variabile non viene usata. - L'intero costrutto
matchritorna un valore, quindi possiamo omettere i comandireturn.
Possiamo sostituire le espressioni interne con dei blocchi, qualora volessimo eseguire dei comandi
enum Vector { Cartesian(f32, f32), Polar(f32, f32) } impl Vector { fn length(&self) -> f32 { match self { Vector::Cartesian(x, y) => { println!("Il vettore è espresso in coordinate cartesiane"); (x*x + y*y).sqrt() }, Vector::Polar(r, _a) => { println!("Il vettore è espresso in coordinate polari"); *r } } } } fn main() { let v1 = Vector::Cartesian(3.0, 2.0); let v2 = Vector::Polar(4.2, 1.0); println!("Lunghezza di v1: {}", v1.length()); println!("Lunghezza di v2: {}", v2.length()); }
L'output del programma è il seguente.
Il vettore è espresso in coordinate cartesiane
Lunghezza di v1: 3.6055512
Il vettore è espresso in coordinate polari
Lunghezza di v2: 4.2
Il costrutto if-let
A volte vogliamo fare matching solo di una delle possibili varianti di una enumerazione, questo è proprio quello che il costrutto if let ci permette di fare.
Ad esempio supponiamo di avere una enumerazione per dei comandi, e vogliamo controllare se la moneta è un centesimo, potremmo usare match come sopra, ma esiste una maniera più concisa.
enum Request { Exit, GetPage(String), GetValueAtPosition { x: u32, y: u32 }, SetBackgroundColor(u8, u8, u8) } fn get_request() -> Request { Request::Exit } // [...] fn main() { let req = get_request(); if let Request::Exit = req { println!("Exited"); } }
Possiamo anche destrutturare i valori interni, come nel costrutto match, per poi usarli all'interno del corpo dell'if.
enum Request { Exit, GetPage(String), GetValueAtPosition { x: u32, y: u32 }, SetBackgroundColor(u8, u8, u8) } fn get_request() -> Request { Request::Exit } fn main() { let req = get_request(); if let Request::GetPage(page) = req { println!("GetPage request for {}", page); } }
Il pattern matching è una feature molto potente di Rust, e ha molte altre funzionalità rispetto a quelle discusse qua. Queste si possono trovare nella documentazione ufficiale del linguaggio.
L'enumerazione Option
Una delle enumerazioni più utili e usate nella libreria standard di Rust è l'enumerazione Option.
Questa ha solo due varianti: Some, che contiene un valore e None che non contiene nessun valore.
Può essere pensata come un valore che può essere opzionalmente null.
Rust non supporta valori vuoti come null o undefined, ma richiede esplicitamente di incapsulare ogni valore che potrebbe essere vuoto all'interno di una Option.
Questa enumerazione è generica, intuitivamente vuol dire che Some può contenere un valore di qualsiasi tipo, basta che sia marcato nella sua definizione di tipo.
Per esempio Option<u32> può contenere un u32, e una Option<String> può contenere una stringa.
Vedremo i tipi generici nel prossimo capitolo.
Supponiamo di voler implementare una funzione che ritorna il primo elemento di un array, questo potrebbe non esistere se l'array è vuoto.
Possiamo però ritornare una Option:
- se la lista è vuota ritorna
None - altrimenti ritorna il primo elemento incapsulato in
Some.
Una possibile implementazione è la seguente.
fn first_value(arr: &[i32]) -> Option<i32> { if arr.is_empty() { None } else { Some(arr[0]) } } fn main() { let arr = [1, 2, 3]; match first_value(&arr) { Some(value) => println!("Il primo valore è {}", value), None => println!("L'array è vuoto"), } }
La gestione esplicita di valori vuoti semplifica i programmi e li rende meno proni a errori, in quanto siamo forzati dal sistema di tipi a gestire esplicitamente le condizioni in cui un valore è vuoto, oppure le condizioni di errore.
Tipi generici e traits
In questo capitolo vedremo come implementare in Rust delle funzioni e delle strutture in maniera generica rispetto a dei tipi. Vedremo poi come è possibile definire dei trait, ovvero delle definizioni d'interfacce che un tipo può implementare. Vedremo poi cosa sono i lifetime e come possono essere usati per tracciare la vita delle reference a tempo di compilazione.
Tipi generici
Come molti linguaggi di programmazione, Rust supporta la definizione di funzioni e strutture dati in modo generico rispetto ai tipi. Questo è utile perché così possiamo evitare la duplicazione di codice, scrivendo una sola implementazione per ogni tipo. Il meccanismo dei generici in Rust è molto simile ai generici in Java o i templates in C++.
Funzioni generiche
Prendiamo l'esempio della funzione che ritorna il primo elemento di un array, se vogliamo scriverlo per vari tipi di elementi nell'array dovremmo scrivere più funzioni.
#![allow(unused)] fn main() { fn first_u32(arr: &[i32]) -> Option<i32> { if arr.is_empty() { None } else { Some(arr[0]) } } fn first_char(arr: &[char]) -> Option<char> { if arr.is_empty() { None } else { Some(arr[0]) } } }
Entrambe le versioni sono identiche perché in realtà non stiamo sfruttando niente del fatto che gli elementi siano degli i32 oppure dei char.
L'implementazione è completamente generica rispetto ai tipi degli elementi.
Possiamo quindi rendere la funzione generica tramite la seguente notazione.
fn first_generic<T>(arr: &[T]) -> Option<&T> { if arr.is_empty() { None } else { Some(&arr[0]) } } fn main() { let arr = [1, 2, 3]; let first = first_generic(&arr); println!("Il primo elemento è {:?}", first); let arr2 = ["ciao", "mondo"]; let first2 = first_generic(&arr2); println!("Il primo elemento è {:?}", first2); }
L'output del programma è il seguente.
Il primo elemento è Some(1)
Il primo elemento è Some("ciao")
Il parametro T è un parametro di tipo, e indica un tipo generico. Rust provvederà a specializzare la funzione quando verrà usata su dei tipi concreti.
Possiamo notare che stiamo in realtà ritornando una reference al primo elemento, perché il compilatore non ha nessuna informazione rispetto a T (potrebbe essere una struttura dati complessa), quindi non può assumere che sia banalmente copiabile.
Se proviamo a sostituire &T con T otteniamo il seguente errore:
error[E0508]: cannot move out of type `[T]`, a non-copy slice
|
7 | Some(arr[0])
| ^^^^^^
| |
| cannot move out of here
| move occurs because `arr[_]` has type `T`, which does not implement the `Copy` trait
Ovvero il tipo T non è copiabile, e quindi verrebbe trasferita la ownership al chiamante.
Questo però viola le regole della ownership, perché avremmo nello stesso momento due owner dello stesso valore:
- l'array perché il valore è il primo elemento
- il valore di ritorno della funzione.
Strutture ed enumerazioni generiche
Come in molti altri linguaggi, possiamo creare delle strutture dati generiche rispetto a uno o più tipi.
Per esempio una struttura che abbiamo già visto è la struttura Option, che può essere definita così.
#![allow(unused)] fn main() { enum Option<T> { Some(T), None } }
Come nel caso delle funzioni generiche, T rappresenta una variabile di tipo, la variante Some contiene un elemento di tipo T, mentre la variante None non contiene valori.
Possiamo istanziare un valore di una struttura generica esplicitando il parametro di tipo, ma spesso il compilatore può inferirlo dal contesto.
Quando non può farlo ci restituirà un errore.
fn main() { let x: Option<i32> = None; // OK: tipo esplicito Option<i32> let y = Some(5); // OK: tipo inferito Option<i32> let z = None; // errore: impossibile inferire il tipo di z }
Possiamo definire anche delle strutture generiche, come ad esempio una coppia di valori.
struct Pair<T, V> { first: T, second: V, } fn main() { let pair = Pair { first: 1, second: "stringa", }; println!("La coppia è <{},{}>", pair.first, pair.second); }
Il programma ci restituisce il seguente output.
La coppia è <1,stringa>
Metodi generici
Possiamo definire dei metodi generici per le strutture generiche, per esempio se volessimo implementare le funzioni first e second per una coppia possiamo farlo in modo completamente generico.
struct Pair<T, V> { first: T, second: V, } impl<T, V> Pair<T, V> { fn first(&self) -> &T { &self.first } fn second(&self) -> &V { &self.second } } fn main() { let pair = Pair { first: 1, second: "ciao", }; println!("Primo elemento: {}", pair.first()); println!("Secondo elemento: {}", pair.second()); }
Il programma ci restituisce il seguente output.
Primo elemento: 1
Secondo elemento: ciao
Per chi ha già programmato in linguaggi come Java o C++ questa modalità per definire i generici dovrebbe risultare familiare.
Stiamo definendo una implementazione generica nei tipi T e V per la struttura Pair<T, V>.
Per esempio la funzione first restituisce una reference al primo elemento, che è quindi di tipo generico &T.
Funzionalità comuni tramite i traits
I trait in Rust ci permettono di definire delle funzionalità comuni di oggetti in un modo astratto. Intuitivamente sono simili a quello che molti linguaggi come Java chiamano interfacce, ma hanno alcune differenze. In generale però lo scopo è lo stesso: definire delle interfacce comuni per diverse strutture dati, così da poter implementare delle operazioni generiche. Ad esempio tutte le strutture dati che contengono una collezione di valori potrebbero implementare una interfaccia che permette d'iterare su questa collezione. In questo modo potremmo implementare ad esempio la funzione che restituisce la dimensione della collezione in modo completamente generico rispetto alla struttura concreta che memorizza i valori.
Definizione di trait
Un trait è semplicemente una collezione di metodi che è possibile invocare su un oggetto. Diversi oggetti condividono lo stesso comportamento se possiamo chiamare gli stessi metodi su essi.
Per esempio supponiamo di voler scrivere un aggregatore di dati, dove ogni dato è una struttura. Vogliamo per esempio esprimere il fatto che da un dato possiamo estrarre un identificatore univoco. Possiamo definire il trait
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } }
Abbiamo definito così una "interfaccia" che vari oggetti possono implementare.
Questa contiene solo una funzione identifier che prende in ingresso una reference all'oggetto e restituisce una stringa.
Se abbiamo due strutture che rappresentano un dato di una stazione metereologica, e un dato di un termostato, possiamo implementare per entrambi il trait DataIdentifier.
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } struct IndoorTemperature { location: String, timestamp: u64, value: f32, } impl DataIdentifier for IndoorTemperature { fn identifier(&self) -> String { format!("indoor-{}-{}", self.location, self.timestamp) } } struct WeatherStation { location: String, timestamp: u64, temperature: f32, humidity: f32, } impl DataIdentifier for WeatherStation { fn identifier(&self) -> String { format!("weather-{}-{}", self.location, self.timestamp) } } }
Trait come parametri
Una delle feature più importanti per utilizzare i trait nei programmi è quella di creare funzioni o strutture generiche, che abbiano dei constraint sul tipo generico sotto forma di trait.
Ad esempio, potremmo volere una funzione generica che accetta solamente oggetti che implementano il trait che abbiamo definito sopra DataIdentifier.
Questo può essere espresso in Rust così.
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } fn display_id(x: &impl DataIdentifier) -> String { String::from("The id is ") + x.identifier().as_str() } }
Intuitivamente prendiamo in ingresso un qualsiasi tipo che implementi DataIdentifier. In realtà questa notazione, pur essendo molto concisa, è zucchero sintattico che nasconde una funzione generica: x deve avere tipo T, con T tale che implementi il trait DataIdentifier.
Una sintassi equivalente e più completa è quella dei trait bounds:
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } fn display_id<T: DataIdentifier> (x: &T) -> String { String::from("The id is ") + x.identifier().as_str() } }
In questo modo stiamo esplicitando il fatto di definire una funzione generica in T, e che T deve implementare il trait DataIdentifier.
Possiamo aggiungere ulteriori vincoli al tipo con l'operatore +, ad esempio se vogliamo che T implementi sia DataIdentifier che Display, possiamo scrivere così.
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } use std::fmt::Display; fn display_id<T: DataIdentifier + Display> (x: &T) -> String { String::from("The id is ") + x.identifier().as_str() } }
Tracciare le reference: i lifetime
Implicitamente, ogni reference ha una cosiddetta lifetime, ovvero uno scope per cui quella reference rimane valida. La maggior parte delle volte, le lifetime sono gestite implicitamente dal compilatore, così come la maggior parte delle volte i tipi possono essere inferiti dal compilatore. Come vedremo tra poco però, a volte dobbiamo annotare esplicitamente quando potrebbero avere relazioni diverse con altre lifetime, così come dobbiamo annotare i tipi se devono essere generici all'interno del codice.
L'annotazione di lifetime è un concetto che la maggior parte degli altri linguaggi di programmazione non ha, quindi potrebbe inizialmente essere un argomento abbastanza ostico. Per fortuna, i casi in cui bisogna annotare le lifetime sono rari nei programmi comuni.
A cosa servono le lifetime?
Lo scopo principale delle lifetime è quello di prevenire le reference invalide, ovvero reference che puntano a oggetti che non esistono più o che non sono più validi. Ad esempio, prendiamo il seguente codice
fn main() { let reference; { let value = 5; reference = &value; } println!("The value is {}", reference); }
Questo codice non compila, anche se potrebbe sembrare a prima vista corretto: la reference è dichiarata nella funzione main, quindi è presente nell'ambiente quando viene privata a stampare.
Il problema è che il valore value viene eliminato prima, quindi la reference diventa invalida.
L'errore che ci restituisce il compilatore è il seguente:
error[E0597]: `value` does not live long enough
--> src/main.rs:6:21
|
5 | let value = 5;
| ----- binding `value` declared here
6 | reference = &value;
| ^^^^^^ borrowed value does not live long enough
7 |
8 | }
| - `value` dropped here while still borrowed
9 | println!("The value is {}", reference);
| --------- borrow later used here
In effetti, ci viene detto che la variabile value non "vive abbastanza a lungo", infatti la value vive solo nel blocco interno, mentre reference vive per tutta la funzione main.
Il borrow checker controlla che la vita (lifetime) dei valori sia più "grande" o uguale alla vita delle corrispettive reference, per assicurarsi che tutte le reference siano valide.
Ad esempio il programma cambiato in questo modo
fn main() { let value = 5; let reference = &value; println!("The value is {}", reference); }
Compila correttamente, in quanto la lifetime di value è almeno grande quanto quella di reference.
fn main() { let value = 5; //---------------------+ lifetime let reference = &value; // -+ lifetime di | di value println!("ref {}", reference); // | reference | // -+ | // --------------------+ }
Lifetime generiche nelle funzioni
Proviamo adesso a scrivere una funzione che prende in ingresso due stringhe e ritorna la più lunga.
Vogliamo prendere in ingresso due reference a str e ritornare una reference a str, perché non vogliamo che la nostra funzione prenda ownership delle stringhe in ingresso.
Proviamo quindi a scrivere la funzione
#![allow(unused)] fn main() { fn longest(s1: &str, s2: &str) -> &str { if s1.len() > s2.len() { s1 } else { s2 } } }
ma otteniamo il seguente errore.
error[E0106]: missing lifetime specifier
--> src/main.rs:1:35
|
1 | fn longest(s1: &str, s2: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value,
but the signature does not say whether it is borrowed from `s1` or `s2`
help: consider introducing a named lifetime parameter
|
1 | fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
| ++++ ++ ++ ++
error: lifetime may not live long enough
--> src/main.rs:3:9
|
1 | fn longest(s1: &str, s2: &str) -> &str {
| - let's call the lifetime of this reference `'1`
2 | if s1.len() > s2.len() {
3 | s1
| ^^ returning this value requires that `'1` must outlive `'static`
error: lifetime may not live long enough
--> src/main.rs:5:9
|
1 | fn longest(s1: &str, s2: &str) -> &str {
| - let's call the lifetime of this reference `'2`
...
5 | s2
| ^^ returning this value requires that `'2` must outlive `'static`
For more information about this error, try `rustc --explain E0106`.
Quello che questo errore ci sta dicendo è che il compilatore non riesce a inferire il lifetime della reference di ritorno, in quanto potrebbe essere uguale al lifetime di s1 oppure a quello di s2.
Di fatto, non possiamo neanche noi stabilirlo a priori, dipende dalle stringhe passate alla funzione a tempo di esecuzione.
Possiamo però annotare le reference con un lifetime, con la sintassi che segue
#![allow(unused)] fn main() { &str // reference a una str &'a str // reference a una str con la lifetime 'a &'a mut str // reference mutabile a una str con la lifetime 'a }
Possiamo quindi rendere generica la funzione longest rispetto alla lifetime in questo modo
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } }
In particolare tramite <'a> stiamo dicendo che la funzione è parametrica rispetto a un lifetime 'a, e che s1, s2 e il valore di ritorno devono essere "contenute" all'interno della stessa lifetime.
Un altro modo di vederla è che la lifetime della reference ritornata è uguale alla più piccola lifetime tra quella di s1 e quella di s2.
Questo intuitivamente ha senso: siccome non possiamo sapere a priori quale delle due reference verrà ritornata, la lifetime è quella più piccola.
Ad esempio, possiamo usare la funzione longest in questo modo
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let s1 = String::from("abcd"); let s2 = String::from("xyz"); let result = longest(s1.as_str(), s2.as_str()); println!("La stringa più lunga è '{}'", result); }
Fino a qua niente di strano, entrambe s1 e s2 hanno lifetime uguale al blocco della funzione main.
Vediamo però che succede quando proviamo a usare la funzione longest in un altro modo
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let s1 = String::from("abcd"); let result; { let s2 = String::from("xyz"); result = longest(s1.as_str(), s2.as_str()); } println!("La stringa più lunga è '{}'", result); }
Otteniamo il seguente errore di compilazione
error[E0597]: `s2` does not live long enough
--> src/main.rs:13:37
|
12 | let s2 = String::from("xyz");
| -- binding `s2` declared here
13 | result = longest(s1.as_str(), s2.as_str());
| ^^ borrowed value does not live long enough
14 | }
| - `s2` dropped here while still borrowed
15 | println!("La stringa più lunga è '{}'", result);
| ------ borrow later used here
For more information about this error, try `rustc --explain E0597`.
Questo errore ci dice che la seconda reference non vive abbastanza a lungo, nonostante poi sappiamo che il risultato sarà una reference valida (sarà una reference a s1).
Il compilatore Rust, però, non può fare assunzioni sul valore delle stringhe, e quindi impedisce la compilazione.
Esiste una lifetime speciale, ovvero 'static, che rappresenta una reference che è valida per tutta l'esecuzione del programma.
Esempi possono essere reference a stringhe costanti oppure a variabili globali.
Le annotazioni di lifetime esplicite possono anche essere inserite nella definizione di strutture, per esempio per stabilire una relazione tra le lifetime dei membri della struttura. Per ulteriori dettagli è possibile consultare la documentazione ufficiale di Rust.
Altre feature di Rust
In questo capitolo esploriamo due altre feature più avanzate di Rust, ovvero l'allocazione dinamica di oggetti sullo heap, e unsafe Rust. Il linguaggio è molto più ricco di quello che è stato presentato in questo modulo, per approfondire si rimanda alla documentazione ufficiale dei Rust.
Allocazione dinamica
In generale un puntatore è un oggetto che contiene un indirizzo di memoria, si dice che punta a una certa locazione dove possono essere memorizzati dei dati.
Rust ha il supporto a puntatori tramite le reference, che in qualche modo puntano alla locazione di memoria dive è memorizzato il valore.
Si chiamano invece smart pointers delle strutture dati che contengono un indirizzo di memoria, ma hanno anche altre caratteristiche e metadati. Questo concetto è originato dal C++, ed esistono anche in altri linguaggi.
Vediamo adesso il più semplice degli smart pointers, ovvero il tipo Box<T>.
Per default, un valore in Rust è memorizzato sullo stack, ma se vogliamo memorizzarlo sull'heap possiamo incapsularlo all'interno di un Box, in questo modo
fn main() { // x è un intero allocato sull'heap let x = Box::new(42); println!("Il valore è {}", x); }
Come ogni altro valore, possiamo accedere al contenuto semplicemente con il suo nome. Il risultato del programma è il seguente
Il valore è 42
Liste concatenate tramite Box
Da solo, il tipo Box non è molto utile, ma è indispensabile se per esempio vogliamo implementare strutture dati ricorsive.
Prendiamo per esempio una lista concatenata, possiamo provare a implementarla nel modo banale, ovvero come una enumerazione in cui in una variante abbiamo la lista vuota e nell'altra la concatenazione di un intero con un'altra lista.
#![allow(unused)] fn main() { enum List { Cons(i32, List), Nil, } }
Otteniamo però il seguente errore.
error[E0072]: recursive type `List` has infinite size
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^
2 | Cons(i32, List),
| ---- recursive without indirection
|
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
|
2 | Cons(i32, Box<List>),
| ++++ +
For more information about this error, try `rustc --explain E0072`.
Quando il compilatore va a inferire la dimensione del nostro oggetto, non riesce a farlo perché c'è una ricorsione infinita.
In particolare la dimensione di List è alcuni byte per memorizzare la variante e il valore intero, più la dimensione di List.
Possiamo risolvere il problema incapsulando la lista interna in un Box, come ci viene suggerito anche dal compilatore.
La struttura Box ha dimensione fissa (è un puntatore) e il calcolo della dimensione della struttura va a buon fine.
enum List { Cons(i32, Box<List>), Nil, } fn main() { // costruisco la lista 1 -> 2 -> Nil let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil)))); }
Ci sono molti altri contenitori oltre a Box<T>, che hanno altre feature, come per esempio il reference counting, accessi atomici, mutabilità anche come reference immutabili.
Per ulteriori dettagli si può consultare la documentazione ufficiale.
Unsafe Rust
Tutto il codice che abbiamo visto fino ad adesso è stato scritto il quello che si chiama safe Rust, ovvero con delle garanzie di memoria che sono fornite dal compilatore. Questo però non è sempre quello che vogliamo, perché il compilatore è molto conservativo sulle assunzioni che fa sul codice, e a volte vogliamo esprimere delle cose non possibili da esprimere in safe Rust. Ad esempio potremmo volere implementare una primitiva di concorrenza molto efficiente, e possiamo garantire che un certo puntatore sarà valido in un certo momento, oppure da una invariante dell'algoritmo possiamo garantire che non ci sono può thread che stanno scrivendo su uno stesso valore. Oppure potremmo volere implementare un firmware a basso livello che si interfaccia direttamente con l'hardware, leggendo da locazioni di memoria dove magari sono mappati delle interfacce I/O.
Rust ha quindi la possibilità di scrivere unsafe Rust, che è fondamentalmente la stessa identica cosa, ma abbiamo delle possibilità in più, che sono:
- possiamo dereferenziare un puntatore raw
- possiamo chiamare una funzione unsafe
- possiamo accedere o modificare una variabile statica mutabile
- possiamo implementare un trait unsafe
- possiamo accedere ai campi di una union.
La cosa più importante è forse la prima, possiamo leggere e scrivere locazioni arbitrarie di memoria, senza che il compilatore faccia dei check. Il borrrow checker e tutte le altre feature del compilatore non sono disattivate, ma chiamando delle primitive apposite è possibile bypassarle. Unsafe Rust è un modo per dire al compilatore "so quello che sto facendo", e in particolare se ci sono dei bug legati alla corruzione di memoria si troveranno per forza all'interno di blocchi unsafe. Per questo è consigliato limitare al massimo l'utilizzo di blocchi unsafe, la maggior parte delle volte per programmi comuni non sarà mai necessario utilizzarli.
Dereferenziare un puntatore raw
Vediamo un esempio di unsafe Rust, supponiamo che vogliamo leggere un byte all'indirizzo di memoria 0x41414141.
In c potremmo scrivere qualcosa del genere
#include <stdio.h>
int main() {
printf("Valore: %d", *((char*)0x41414141));
}
Ovviamente il programma ci restituirà segmentation fault, perché con molta probabilità l'indirizzo non è mappato nella memoria virtuale.
In Rust si può fare una cosa molto simile, ma va effettuata dentro un blocco unsafe, in questo modo.
fn main() { // indirizzo di memoria let address = 0x41414141usize; // raw pointer a quell'indirizzo di memoria let r = address as *const char; unsafe { // dereferenziazione del puntatore println!("Valore è {}", *r); } }
Ovviamente come nel caso del c, il programma restituirà segmentation fault.
Se andiamo a vedere dove c'è il crash ad esempio con valgrind, otteniamo il seguente risultato
==51758== Invalid read of size 4
[...]
==51758== Address 0x41414141 is not stack'd, malloc'd or (recently) free'd
Quindi effettivamente stiamo provando a leggere dall'indirizzo 0x41414141.
Come già detto, nell'uso normale di Rust non capita quasi mai di dover scrivere codice unsafe, tipicamente esiste sempre una alternativa safe. Se proprio dobbiamo ricorrere a unsafe Rust perché magari stiamo implementando una funzionalità particolare, è fortemente consigliato leggere con attenzione la documentazione, e cercare di capire tutte le implicazioni sulla memoria che può avere quella particolare implementazione unsafe.