Tutorial di Rust
Rust è un linguaggio di programmazione di Mozilla. Può essere usato per scrivere strumenti a riga di comando, applicazioni web e programmi di rete. Il linguaggio, che gode di grande popolarità tra i programmatori, è adatto anche per la programmazione vicina all’hardware.
In questo tutorial di Rust vi mostriamo le sue caratteristiche più importanti evidenziando analogie e differenze rispetto ad altri linguaggi di uso comune. Vi guideremo attraverso l’installazione di Rust e imparerete a scrivere e compilare il codice Rust sul vostro sistema.
Una panoramica del linguaggio di programmazione Rust
Rust è un linguaggio compilato. Questa caratteristica si traduce in elevate prestazioni; allo stesso tempo il linguaggio offre astrazioni sofisticate che rendono più facile il lavoro del programmatore. Un’attenzione particolare di Rust è rivolta alla sicurezza della memoria. Ciò gli conferisce un particolare vantaggio rispetto ai linguaggi più vecchi come C e C++.
Come usare Rust sul proprio sistema
Poiché Rust è un software gratuito e open source (FOSS), chiunque può scaricare la sua toolchain e utilizzarla sul proprio sistema. A differenza di Python e JavaScript, Rust non è un linguaggio interpretato. Al posto di un interprete viene utilizzato un compilatore, come in C, C++ e Java. In pratica, questo significa che esistono due fasi per l’esecuzione del codice:
- La compilazione del codice sorgente che produce un file binario eseguibile.
- L’esecuzione del file binario risultante.
Nel caso più semplice, entrambi i passaggi sono controllati dalla riga di comando.
In un altro articolo della nostra Digital Guide diamo un’occhiata più da vicino alla differenza tra compilatore e interprete.
Rust può essere usato per creare non solo file binari eseguibili ma anche librerie. Se il codice compilato è un programma direttamente eseguibile, nel codice sorgente deve essere definita una funzione main(). Come in C / C+++, questo serve come punto d’ingresso nell’esecuzione del codice.
Installare Rust sul sistema locale
Per utilizzare Rust, è necessario prima di tutto un’installazione locale. Su macOS si usa il gestore di pacchetti Homebrew. Homebrew funziona anche su Linux. Aprite la riga di comando (‘Terminal.App’ sul Mac), copiate la seguente riga di codice nel terminale ed eseguitela:
brew install rust
Per installare Rust su Windows o su un altro sistema senza l’ausilio di Homebrew, usate lo strumento ufficiale Rustup.
Per verificare che l’installazione di Rust sia andata a buon fine, aprite una nuova finestra sulla riga di comando ed eseguite il seguente codice:
rustc --version
Se Rust è installato correttamente sul vostro sistema, vi verrà mostrata la versione del compilatore. Se invece appare un messaggio di errore, riavviate l’installazione.
Compilare il codice Rust
Per compilare il codice Rust è necessario un file di codice sorgente Rust. Aprite la riga di comando ed eseguite i seguenti pezzi di codice. Per prima cosa creeremo una cartella per il tutorial di Rust sul desktop e la apriremo:
cd "$HOME/Desktop/"
mkdir rust-tutorial && cd rust-tutorial
Successivamente creiamo il file del codice sorgente di Rust per un semplice esempio di “Hello World”:
cat << EOF > ./rust-tutorial.rs
fn main() {
println!("Hello World!");
}
EOF
I file del codice sorgente di Rust terminano con l’abbreviazione .rs.
Infine, compileremo il codice sorgente di Rust ed eseguiremo il file binario risultante:
# compilare il codice sorgente di Rust
rustc rust-tutorial.rs
# eseguire il file binario risultante
./rust-tutorial
Utilizzate il comando rustc rust-tutorial.rs && ./rust-tutorial per combinare i due passaggi. In questo modo è possibile ricompilare ed eseguire il programma sulla riga di comando premendo il tasto freccia in alto seguito da Invio.
Gestire i pacchetti Rust con Cargo
Oltre al linguaggio Rust vero e proprio esistono vari pacchetti esterni. Questi cosiddetti crates possono essere ottenuti dal Rust Package Registry. A questo scopo viene utilizzato lo strumento Cargo installato insieme a Rust. Il comando cargo viene usato sulla riga di comando e permette di installare pacchetti e crearne di nuovi. Prima di tutto, verificate che Cargo sia stato installato correttamente:
cargo --version
Imparare le basi di Rust
Per imparare a usare Rust vi consigliamo di provare direttamente esempi di codice. A tale proposito è possibile utilizzare il file rust-tutorial.rs già creato. Copiate un campione di codice nel file, compilatelo ed eseguite il file binario risultante. Per funzionare, il codice di esempio deve essere inserito all’interno della funzione main()!
Potete anche usare Rust Playground direttamente nel vostro browser per provare il codice Rust.
Istruzioni e blocchi
Le istruzioni sono blocchi di base del codice in Rust. Un’istruzione termina con un punto e virgola (;) e, a differenza di un’espressione, non restituisce un valore. Più istruzioni possono essere raggruppate in un blocco. I blocchi sono delimitati da parentesi graffe ‘{}’, come in C/C++ e Java.
Commenti
I commenti sono una caratteristica importante di qualsiasi linguaggio di programmazione. Vengono utilizzati sia per la documentazione del codice che per la sua pianificazione prima che il codice stesso venga scritto. Rust usa la stessa sintassi dei commenti di C, C++, Java e JavaScript, pertanto qualsiasi testo dopo una doppia barra viene interpretato come un commento e ignorato dal compilatore:
// Commento
// Commento
// che si estende
// su più
// righe.
Variabili e costanti
In Rust usiamo la parola chiave ‘let’ per dichiarare una variabile. Una variabile esistente può essere dichiarata di nuovo, dopodiché “oscura” la variabile esistente. A differenza di molti altri linguaggi, il valore di una variabile non può essere cambiato facilmente:
// dichiarare variabile ‘età’ e fissare il valore a ‘42’
let età = 42;
// valore della variabile ‘età’ non può essere modificato
età = 49; // errore di compilazione
// la variabile può essere sovrascritta con un nuovo ‘let’
let età = 49;
Per contrassegnare il valore di una variabile come successivamente modificabile, Rust si serve della parola chiave ‘mut’. Il valore di una variabile dichiarata con ‘mut’ può essere modificato:
let mut peso = 78;
peso = 75;
Con la parola chiave ‘const’ viene generata una costante. Il valore di una costante di Rust deve essere noto al momento della compilazione. Anche il tipo deve essere specificato esplicitamente:
const VERSION: &str = "1.46.0";
Il valore di una costante non può essere modificato; una costante non può nemmeno essere dichiarata ‘mut’. Inoltre, una costante non può essere dichiarata nuovamente:
// definire costante
const MAX_NUM: u8 = 255;
MAX_NUM = 0; // errore di compilazione, perché il valore di una costante non è modificabile
const MAX_NUM = 0; // errore di compilazione, perché la costante non può essere dichiarata nuovamente
Concetto di proprietà
Una delle caratteristiche decisive di Rust è il concetto di proprietà (in inglese: “ownership”). La proprietà è strettamente legata al valore delle variabili, alla loro durata di vita e alla gestione della memoria degli oggetti in memoria dello heap. Quando una variabile si allontana dal campo di applicazione (in inglese: “scope”), il suo valore viene distrutto e la memoria rilasciata. Rust può quindi fare a meno di una “garbage collection”, di conseguenza può garantire prestazioni elevate.
Ogni valore appartiene a una variabile, cioè al proprietario. Ci può essere un solo proprietario per ciascun valore. Se il proprietario passa ad altri il valore, allora non è più il proprietario:
let nome = String::from("Elena Rossi");
let _nome = nome;
println!("{}, world!", nome); // errore di compilazione, perché il valore è passato da ‘nome’ a ‘_nome’
Occorre prestare particolare attenzione nella definizione delle funzioni: se una variabile viene passata a una funzione, il proprietario del valore cambia. La variabile non può essere riutilizzata dopo l’apertura della funzione. Qui, Rust usa un trucco: invece di passare il valore alla funzione stessa, viene dichiarato un riferimento con il simbolo della e commerciale (&). Ciò consente di “prendere in prestito” il valore di una variabile. Di seguito un esempio:
let nome = String::from("Elena Rossi");
// se il tipo del parametro del ‘nome’ viene definito ‘String’ invece di ‘&String’
// la variabile ‘nome’ non può più essere utilizzata dopo l’apertura della funzione
fn ciao(nome: &String) {
println!("Ciao, {}", nome);
}
// anche l’argomento di funzione deve essere
// contrassegnato come riferimento con ‘&’
ciao(&nome);
// senza l’utilizzo del riferimento questa riga porta a un errore di compilazione
println!("Ciao, {}", nome);
Strutture di controllo
Una proprietà fondamentale della programmazione è quella di rendere il flusso del programma non lineare. Un programma può diramarsi e i componenti del programma possono essere eseguiti più di una volta. È solo attraverso questa variabilità che un programma diventa veramente utile.
Rust ha le strutture di controllo disponibili nella maggior parte dei linguaggi di programmazione, tra cui i costrutti dei cicli ‘for’ e ‘while’, così come le ramificazioni tramite ‘if’ ed ‘else’. Oltre a ciò, Rust ha alcune caratteristiche speciali. Il costrutto ‘match’ permette l’assegnazione di pattern, mentre l’istruzione ‘loop’ crea un ciclo infinito. Per permettere quest’ultimo, viene utilizzata un’istruzione ‘break’.
Cicli
L’esecuzione ripetuta di un blocco di codice per mezzo di cicli (indicati in inglese come “loop”) è anche nota come “iterazione”. Spesso l’iterazione viene eseguita sugli elementi di un contenitore. Come Python, Rust conosce il concetto di “iteratore”. Un iteratore estrae l’accesso successivo agli elementi di un contenitore. Facciamo un esempio:
// elenco di nomi
let nomi = ["Elisa", "Giorgio", "Francesca"];
// ciclo ‘for’ con iteratore nell’elenco
for nome in nomi.iter() {
println!("Ciao, {}", nome);
}
Ora, cosa fare se si vuole scrivere un ciclo ‘for’ nello stile C/C++ o Java? Volete dunque specificare un numero iniziale e un numero finale e passare in rassegna tutti i valori intermedi. In tal caso, esiste l’oggetto “range” in Rust, proprio come in Python. Questo a sua volta crea un iteratore su cui opera la parola chiave ‘for’:
// emettere numeri da ‘1’ a ‘10’
// ciclo ‘for’ con iteratore ‘range’
// attenzione: range non contiene il numero finale!
for numero in 1..11 {
println!("Numero: {}", numero);
}
// (inclusa) scrittura range alternativa
for numero in 1..=10 {
println!("Numero: {}", numero);
}
Un ciclo ‘while’ funziona in Rust come nella maggior parte degli altri linguaggi. Viene impostata una condizione e il corpo del ciclo viene eseguito finché la condizione è vera:
// emettere i numeri da ‘1’ a ‘10’ tramite ciclo ‘while’
let mut numero = 1;
while (numero <= 10) {
println!(Numero: {}, numero);
numero += 1;
}
Per tutti i linguaggi di programmazione è possibile creare un ciclo infinito con ‘while’. Normalmente si tratta di un errore, ma ci sono anche casi d’uso che lo richiedono. Rust ricorre all’istruzione “loop” per questi casi:
// ciclo infinito con ‘while’
while true {
// …
}
// ciclo infinito con ‘loop’
loop {
// …
}
In entrambi i casi può essere utilizzata la parola chiave ‘break’ per uscire dal ciclo.
Ramificazioni
Anche la ramificazione con ‘if’ ed ‘else’ funziona in Rust esattamente come in altri linguaggi simili:
const limit: u8 = 42;
let numero = 43;
if numero < limit {
println!("Sotto al limite.");
}
else if numero == limit {
println!("Proprio al limite…");
}
else {
println!("Oltre il limite!");
}
Particolarmente interessante è la parola chiave ‘match’, che ha una funzione simile all’istruzione ‘switch’ di altri linguaggi. Per capire come viene utilizzata, consultate la funzione simbolo_ carte () nella sezione “Tipi di dati compositi” (vedi sotto).
Funzioni, procedure e metodi
Nella maggior parte dei linguaggi di programmazione le funzioni sono l’elemento base della programmazione modulare. Le funzioni sono definite in Rust con la parola chiave ‘fn’. Non viene fatta una distinzione rigorosa tra i relativi concetti di funzione e procedura. Entrambi sono definiti in modi quasi identici.
Una funzione in senso stretto restituisce un valore. Come molti altri linguaggi di programmazione, anche Rust conosce le procedure, cioè le funzioni che non restituiscono alcun valore. L’unica restrizione fissa è che il tipo di ritorno di una funzione deve essere esplicitamente specificato. Se non viene specificato alcun tipo di ritorno, la funzione non può restituire un valore; allora viene definita come una procedura.
fn procedura() {
println!("Questa procedura non restituisce alcun valore.");
}
// negare un numero
// Tipo di ritorno dopo l’operatore ‘->‘
fn nega(numerointero: i8) -> i8 {
return numerointero * -1;
}
Oltre alle funzioni e alle procedure, Rust conosce anche i metodi noti dalla programmazione orientata agli oggetti. Un metodo è una funzione legata a una struttura di dati. Come in Python, i metodi in Rust sono definiti con il primo parametro ‘self’. Un metodo viene chiamato secondo il consueto schema oggetto.metodo(). Di seguito un esempio del metodo superficie(), legato a una struttura dati ‘struct’:
// definizione ‘struct’
struct rettangolo {
larghezza: u32,
altezza: u32,
}
// implementazione ‘struct’
impl rettangolo {
fn superficie(&self) -> u32 {
return self.larghezza* self.altezza;
}
}
let rettangolo = rettangolo {
larghezza: 30,
altezza: 50,
};
println!("La superficie del rettangolo è di {}.", rettangolo.superficie());
Tipi di dati e strutture di dati
Rust è un linguaggio tipizzato staticamente. A differenza dei linguaggi dinamici Python, Ruby, PHP e JavaScript, Rust richiede che il tipo di ogni variabile sia noto al momento della compilazione.
Tipi di dati elementari
Come la maggior parte dei linguaggi di programmazione superiori, Rust conosce alcuni tipi di dati elementari (“primitives”). Le istanze dei tipi di dati elementari sono assegnate sulla memoria dello stack, che è particolarmente performante. Inoltre, i valori dei tipi di dati elementari possono essere definiti con la sintassi “literal”. Ciò significa che i valori possono essere semplicemente scritti.
Tipo di dato | Spiegazione | Annotazione del tipo |
---|---|---|
Integer | Numero intero | i8, u8, ecc. |
Floating point | Numero in virgola mobile | f64, f32 |
Boolean | Valore di verità | bool |
Character | Singolo carattere Unicode | char |
String | Catena di caratteri Unicode | str |
Sebbene Rust sia un linguaggio tipizzato staticamente, non sempre il tipo di valore deve essere dichiarato esplicitamente. In molti casi, il tipo può essere derivato dal compilatore nel contesto (“type inferencer”). In alternativa, il tipo è esplicitamente specificato dall’annotazione del tipo. In alcuni casi, quest’ultimo è obbligatorio:
- Il tipo di ritorno di una funzione deve essere sempre specificato esplicitamente.
- Il tipo di costante deve essere sempre specificato esplicitamente.
- Le stringhe literal devono essere trattate in modo speciale in modo che la loro dimensione sia nota al momento della compilazione.
Di seguito alcuni esempi illustrativi di istanziazione di tipi di dati elementari con sintassi literal:
// qui il compilatore riconosce automaticamente il tipo di variabile
let cents = 42;
// annotazione del tipo: numero positivo (‘u8’ = "unsigned, 8 bits")
let età: u8 = -15; // errore di compilazione, perché il valore inserito è negativo
// numero in virgola mobile
let angolo = 38.5;
// equivalente a
let angolo: f64 = 38.5;
// valore di verità
let utente_registrato = true;
// equivalente a
let utente_registrato: bool = true;
// lettera richiede virgolette apici
let lettera = ‘a’;
// catena di caratteri statica richiede virgolette alte
let nome = "Serena";
// con tipo esplicito
let nome: &'static str = "Serena";
// in alternativa come ‘String’ dinamico con ‘String::from()’
let nome: String = String::from("Serena");
Tipi di dati compositi
I tipi di dati elementari mappano i singoli valori, mentre i tipi di dati compositi raggruppano diversi valori. Rust fornisce al programmatore una manciata di tipi di dati compositi.
Le istanze dei tipi di dati compositi sono assegnate sullo stack come le istanze dei tipi di dati elementari. Per poterlo fare, le istanze devono avere una dimensione fissa. Ciò significa anche che non possono essere modificati arbitrariamente dopo l’istanziazione. Di seguito un riepilogo dei più importanti tipi di dati compositi in Rust:
Tipo di dato | Spiegazione | Tipo di elemento | Sintassi literal |
---|---|---|---|
Array | Elenco di più valori | Stesso tipo | [a1, a2, a3] |
Tuple | Disposizione di più valori | Qualsiasi tipo | (t1, t2) |
Struct | Raggruppamento di più valori nominati | Qualsiasi tipo | – |
Enum | Enumerazione | Qualsiasi tipo | – |
Vediamo ora da vicino una struttura di dati ‘struct’. Definiamo una persona con tre campi nominati:
struct persona = {
nome: String,
cognome: String,
età: u8,
}
Per rappresentare una persona concreta, istanziamo ‘struct’:
let giocatore = persona {
nome: String::from("Elena"),
cognome: String::from("Rossi"),
età: 42,
};
// accedere al campo di un’istanza ‘struct’
println!("L’età del giocatore è: {}", giocatore.età);
Una ‘enum’ (abbreviazione di “enumeration”, enumerazione) rappresenta le possibili varianti di una proprietà. Lo illustriamo qui con un esempio dei quattro possibili semi di una carta da gioco:
enum semecarta {
fiori,
picche,
cuori,
quadri,
}
// il seme di una carta da gioco concreta
let seme = semecarta::fiori;
Rust conosce la parola chiave ‘match’ per “pattern matching”. La funzionalità è paragonabile all’istruzione ‘switch’ di altri linguaggi. Di seguito un esempio:
// determinare il simbolo appartenente al seme di una carta
fn simbolo_carta(seme: semecarta) -> &'static str {
match seme {
semecarta::fiori => "♣︎",
semecarta::picche => "♠︎",
semecarta::cuori => "♥︎",
semecarta::quadri => "♦︎",
}
}
println!("simbolo: {}", simbolo_carta(semecarta::fiori)); // restituisce il simbolo ♣︎
Una tupla è una disposizione di diversi valori, i quali possono essere di tipo diverso. I singoli valori della tupla possono essere assegnati a diverse variabili attraverso la destrutturazione. Se uno dei valori non è necessario, l’underscore (_) viene utilizzato come segnaposto, come di consueto in Haskell, Python e JavaScript. Di seguito un esempio:
// definire carta da gioco come tupla
let carta: (semecarta, u8) = (semecarta::cuori, 7);
// assegnare i valori di una tupla a più variabili
let (seme, valore) = carta;
// dovessimo avere bisogno solo del valore
let (_, valore) = carta;
Poiché i valori delle tuple sono ordinati, è possibile accedervi anche tramite un indice numerico. L’indicizzazione non avviene tra parentesi quadre, ma utilizzando una sintassi a punti. Nella maggior parte dei casi, la destrutturazione dovrebbe portare a una maggiore leggibilità del codice:
let nome = ("Elena", "Rossi");
let cognome = nome.0;
let cognome = nome.1;
Apprendere i costrutti di programmazione in Rust
Strutture dati dinamiche
I tipi di dati compositi già introdotti hanno in comune il fatto che le loro istanze sono assegnate sullo stack. La libreria standard di Rust contiene anche una serie di strutture di dati dinamici di uso comune. Le istanze di queste strutture dati sono assegnate sullo heap. Ciò significa che la dimensione delle istanze può essere modificata in seguito. Di seguito un breve riepilogo delle strutture dati dinamiche usate frequentemente:
Tipo di dati | Spiegazione |
---|---|
Vector | Elenco dinamico di più valori dello stesso tipo |
String | Successione dinamica di lettere Unicode |
HashMap | Assegnazione dinamica di coppie di valori chiave |
Di seguito un esempio di un vettore dinamico crescente:
// dichiarare vettore con ‘mut’ come modificabile
let mut nome = Vec::new();
// aggiungere valori al vettore
nomi.push("Elisa");
nomi.push("Giorgio");
nomi.push("Francesca");
Programmazione orientata agli oggetti (OOP) in Rust
A differenza di linguaggi come C++ e Java, Rust non conosce il concetto di classi. Tuttavia, è possibile programmare secondo la metodologia OOP. La base è costituita dai tipi di dati già presentati. Soprattutto il tipo ‘struct’ può essere usato per definire la struttura degli oggetti.
In più, in Rust esistono i ‘traits’, cioè le caratteristiche. Un trait raggruppa un insieme di metodi che possono poi essere implementati con qualsiasi tipo. Un trait comprende le dichiarazioni di metodo, ma può anche contenere delle implementazioni. Concettualmente, un trait si colloca a metà strada tra un’interfaccia Java e una classe base astratta.
Un trait esistente può essere implementato da diversi tipi. Inoltre, un tipo può implementare diversi traits. Rust permette quindi la composizione di funzionalità per diversi tipi senza dover ereditare da un’interfaccia comune.
Metaprogrammazione
Come molti altri linguaggi di programmazione, Rust permette di scrivere codice per la metaprogrammazione. Si tratta di codice che genera ulteriore codice. In Rust questo include da un lato le “macro” che potreste conoscere da C/C++. Le macro terminano con un punto esclamativo (!); la macro ‘println!’ per l’output di testo sulla riga di comando è già stata menzionata più volte in questo articolo.
D’altra parte, Rust conosce anche i “generics”, i quali permettono di scrivere codice astraibile in diversi tipi. I generics sono all’incirca paragonabili ai modelli in C++ o agli omonimi generics in Java. Un generic spesso usato in Rust è ‘Option<T>‘, che astrae la dualità ‘None’/’Some(T)’ per qualsiasi tipo ‘T’.
Rust ha il potenziale per sostituire i favoriti di lunga data C e C++ come linguaggio di programmazione di sistema.