Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Il Linguaggio di Programmazione Rust

di Steve Klabnik, Carol Nichols, and Chris Krycho, con contributi della Communiy Rust

Questa versione del testo presuppone l’utilizzo di Rust 1.85.0 (rilasciato il 2025-02-17) o successivo con edition = "2024" nel file Cargo.toml di tutti i progetti per configurarli in modo da utilizzare gli idiomi dell’edizione 2024 di Rust. Consulta la sezione “Installazione” del Capitolo 1 per installare o aggiornare Rust, e vedi l’Appendice E per informaszioni sulle varie edizioni.

Il libro originale in inglese in formato HTML si trova su https://doc.rust-lang.org/stable/book/ oppure offline su installazioni di Rust fatte tramite rustup; esegui rustup doc --book per visualizzarlo.

Sono disponibili alcune traduzioni fatte dalla community.

Questo testo, in inglese, è disponibile anche come libro cartaceo o ebook da No Starch Press.

🚨 Vuoi provare un’esperienza di apprendimento più interattiva? Prova una versione modificata del Libro (in inglese), con aggiunta di quiz, sottolineature, visualizzazioni e molto altro: https://rust-book.cs.brown.edu

Prefazione

Il linguaggio di programmazione Rust ha fatto molta strada in pochi anni, dalla sua creazione e incubazione da parte di una piccola e giovane comunità di appassionati, fino a diventare uno dei linguaggi di programmazione più amati e richiesti al mondo. Ripensando a tutto ciò, era inevitabile che la potenza e le promesse di Rust attirassero l’attenzione e trovassero spazio nella programmazione di sistemi. Quello che non era scontato era la crescita globale dell’interesse e dell’innovazione che ha permeato le comunità open source, catalizzando un’adozione su larga scala in vari settori.

Oggi è facile indicare le meravigliose caratteristiche che Rust offre per spiegare questa esplosione di interesse e adozione. Chi non vorrebbe sicurezza nella gestione della memoria, e prestazioni elevate, e un compilatore amichevole, e ottimi strumenti, insieme a tante altre fantastiche funzionalità? Il linguaggio Rust che vedi oggi combina anni di ricerca nella programmazione di sistemi con la saggezza pratica di una comunità vivace e appassionata. Questo linguaggio è stato progettato con uno scopo e realizzato con cura, offrendo agli sviluppatori uno strumento che rende più facile scrivere codice sicuro, veloce e affidabile.

Ma ciò che rende Rust davvero speciale sono le sue radici nel dare potere a te, l’utente, per raggiungere i tuoi obiettivi. Questo è un linguaggio che vuole che tu abbia successo, e il principio di empowerment attraversa il cuore della comunità che costruisce, mantiene e promuove questo linguaggio. Dall’edizione precedente di questo testo definitivo, Rust si è ulteriormente sviluppato in un linguaggio davvero globale e affidabile. Il Rust Project è ora robustamente supportato dalla Rust Foundation, che investe anche in iniziative chiave per garantire che Rust sia sicuro, stabile e sostenibile.

Questa edizione di The Rust Programming Language è un aggiornamento completo, che riflette l’evoluzione del linguaggio nel corso degli anni e fornisce nuove informazioni preziose. Ma non è solo una guida alla sintassi e alle librerie: è un invito a unirti a una comunità che valorizza qualità, prestazioni e design riflessivo. Che tu sia uno sviluppatore esperto che vuole esplorare Rust per la prima volta o un Rustacean esperto che cerca di affinare le proprie abilità, questa edizione offre qualcosa per tutti.

Il viaggio di Rust è stato caratterizzato da collaborazione, apprendimento e iterazione. La crescita del linguaggio e del suo ecosistema è un riflesso diretto della comunità vibrante e diversificata che lo sostiene. I contributi di migliaia di sviluppatori, dai progettisti del linguaggio ai collaboratori occasionali, sono ciò che rende Rust uno strumento così unico e potente. Prendendo in mano questo libro, non stai solo imparando un nuovo linguaggio di programmazione: stai entrando a far parte di un movimento per rendere il software migliore, più sicuro e più piacevole da usare.

Benvenuto nella comunità di Rust!

  • Bec Rumbul, Direttore Esecutivo della Rust Foundation

Introduzione

Nota: questa edizione del libro in inglese è identica a The Rust Programming Language, disponibile in formato cartaceo ed ebook presso No Starch Press.

Benvenuti a Il Linguaggio di Programmazione Rust, un libro introduttivo su Rust. Il linguaggio di programmazione Rust vi aiuta a scrivere software più veloce e affidabile. L’ergonomia di alto livello e il controllo di basso livello sono spesso in contrasto nella progettazione dei linguaggi di programmazione; Rust sfida questo conflitto. Grazie al bilanciamento tra potenti capacità tecniche e un’ottima esperienza di sviluppo, Rust vi offre la possibilità di controllare i dettagli di basso livello (come l’utilizzo della memoria) senza tutte le difficoltà tradizionalmente associate a tale controllo.

A Chi È Rivolto Rust

Rust è ideale per molte persone per una serie di motivi. Diamo un’occhiata ad alcuni dei gruppi più importanti.

Team di Sviluppatori

Rust si sta dimostrando uno strumento produttivo per la collaborazione tra grandi team di sviluppatori con diversi livelli di conoscenza della programmazione di sistemi. Il codice di basso livello è soggetto a vari bug subdoli, che nella maggior parte degli altri linguaggi possono essere individuati solo attraverso test approfonditi e un’attenta revisione del codice da parte di sviluppatori esperti. In Rust, il compilatore svolge un ruolo di gatekeeper rifiutando di compilare il codice con questi bug subdoli, compresi i bug di concorrenza. Lavorando a fianco del compilatore, il team può dedicare il proprio tempo a concentrarsi sulla logica del programma piuttosto che a cercare i bug.

Rust porta anche strumenti di sviluppo contemporanei nel mondo della programmazione di sistemi:

  • Cargo, il gestore di dipendenze e strumento di compilazione integrato, rende l’aggiunta, la compilazione e la gestione delle dipendenze semplice e coerente in tutto l’ecosistema Rust.
  • Lo strumento di formattazione rustfmt garantisce uno stile di programmazione coerente tra gli sviluppatori.
  • Il Server LSP del Linguaggio Rust potenzia l’integrazione dell’ambiente di sviluppo integrato (IDE) per il completamento del codice e i messaggi di errore in linea.

Utilizzando questi e altri strumenti dell’ecosistema Rust, gli sviluppatori possono essere produttivi mentre scrivono codice a livello di sistema.

Studenti

Rust è rivolto agli studenti e a coloro che sono interessati a conoscere i concetti di sistema. Utilizzando Rust, molte persone hanno imparato a conoscere argomenti come lo sviluppo di sistemi operativi. La comunità è molto accogliente e felice di rispondere alle domande degli studenti. Attraverso iniziative come questo libro, i team di Rust vogliono rendere i concetti di sistema più accessibili a un maggior numero di persone, soprattutto a chi è alle prime armi con la programmazione.

Aziende

Centinaia di aziende, grandi e piccole, utilizzano Rust in produzione per una serie di compiti, come strumenti a riga di comando, servizi web, strumenti DevOps, dispositivi embedded, analisi e transcodifica di audio e video, cripto-valute, bioinformatica, motori di ricerca, applicazioni per l’Internet delle cose, machine learning e persino parti importanti del browser web Firefox.

Sviluppatori Open Source

Rust è per le persone che vogliono costruire il linguaggio di programmazione Rust, la comunità, gli strumenti per gli sviluppatori e le librerie. Ci piacerebbe che tu contribuissi al linguaggio Rust.

Persone che Apprezzano la Velocità e la Stabilità

Rust è per le persone che desiderano velocità e stabilità in un linguaggio. Per velocità intendiamo sia la velocità con cui il codice Rust può essere eseguito, sia la velocità con cui Rust ti permette di scrivere programmi. I controlli del compilatore di Rust assicurano la stabilità quando si aggiungono funzionalità e riscrive il codice, in contrasto con la fragilità del codice legacy dei linguaggi senza questi controlli, che spesso gli sviluppatori hanno paura di modificare. Puntando ad astrazioni a costo zero, ovvero funzionalità di livello superiore che vengono compilate in codice di livello inferiore con la stessa velocità del codice scritto manualmente, Rust cerca di rendere il codice sia sicuro che veloce.

Il linguaggio Rust spera di supportare anche molti altri utenti; quelli citati qui sono solo alcuni dei maggiori interessati. Nel complesso, la più grande ambizione di Rust è quella di eliminare i compromessi che i programmatori hanno accettato per decenni, offrendo sicurezza e produttività, velocità e ergonomia. Prova Rust e vedi se le sue scelte funzionano per te.

Per Chi È Questo Libro

Questo libro presuppone che tu abbia scritto codice in un altro linguaggio di programmazione, ma non fa alcuna ipotesi su quale sia. Abbiamo cercato di rendere il materiale ampiamente accessibile a coloro che provengono da un’ampia varietà di background di programmazione. Non dedichiamo molto tempo a parlare di cosa sia la programmazione o di come pensarla. Se sei completamente nuovo alla programmazione, è meglio che tu legga un libro che fornisca un’introduzione specifica alla programmazione.

Come Utilizzare Questo Libro

In generale, questo libro presuppone che tu lo legga in sequenza, dall’inizio alla fine. I capitoli più avanzati si basano sui concetti dei capitoli precedenti, e i capitoli iniziali potrebbero non approfondire un determinato argomento, che invece viene sviluppato più approfonditamente in un capitolo successivo.

In questo libro troverai due tipi di capitoli: i capitoli concettuali e i capitoli di progetto. Nei capitoli concettuali, imparerai a conoscere un aspetto di Rust. Nei capitoli di progetto, costruiremo insieme dei piccoli programmi, applicando ciò che hai imparato finora. I capitoli 2, 12 e 21 sono capitoli di progetto; il resto sono capitoli concettuali.

Il Capitolo 1 spiega come installare Rust, come scrivere un programma “Hello, world!” e come utilizzare Cargo, il gestore di pacchetti e lo strumento di compilazione di Rust. Il Capitolo 2 è un’introduzione pratica alla scrittura di un programma in Rust, con la creazione di un gioco di indovinelli con i numeri. Qui trattiamo i concetti ad un livello più superficiale e i capitoli successivi forniranno ulteriori dettagli. Se vuoi sporcarti subito le mani, il Capitolo 2 è dove puoi iniziare. Se invece sei uno studente particolarmente meticoloso, che preferisce imparare ogni dettaglio prima di passare al successivo, potresti voler saltare il Capitolo 2 e passare direttamente al Capitolo 3 che tratta le caratteristiche di Rust simili a quelle di altri linguaggi di programmazione; dopo, potrai ritornare al Capitolo 2 se vorrai applicare in un progetto quanto appreso.

Nel Capitolo 4 imparerai a conoscere il sistema di proprietà (ownership d’ora in poi) di Rust. Il Capitolo 5 parla di strutture e metodi, mentre il Capitolo 6 tratta le enumerazioni, le espressioni match e il costrutto della struttura di controllo if let. Utilizzerai le strutture e le enumerazioni per creare type personalizzati in Rust.

Nel Capitolo 7 imparerai a conoscere il sistema dei moduli di Rust, le regole di visibilità per l’organizzazione del codice e la sua Application Programming Interface (API) pubblica. Il Capitolo 8 tratta alcune strutture di dati comuni che la libreria standard mette a disposizione, come vettori, stringhe e mappe hash. Il Capitolo 9 esplora la filosofia e le tecniche di gestione degli errori di Rust.

Il Capitolo 10 approfondisce i generici, i tratti e la longevità (traits e lifetime d’ora in poi), che ti danno la possibilità di definire codice applicabile a più tipologie di dato. Il Capitolo 11 è dedicato ai test, che anche con le garanzie di sicurezza di Rust sono necessari per garantire la correttezza della logica del tuo programma. Nel Capitolo 12, costruiremo la nostra implementazione di un sottoinsieme di funzionalità dello strumento da riga di comando grep, che cerca il testo all’interno dei file. Per questo, utilizzeremo molti dei concetti discussi nei capitoli precedenti.

Il Capitolo 13 esplora le chiusure e gli iteratori: caratteristiche di Rust che derivano dai linguaggi di programmazione funzionale. Nel Capitolo 14 esamineremo Cargo in modo più approfondito e parleremo delle migliori pratiche per condividere le tue librerie con altri. Il Capitolo 15 parla dei puntatori intelligenti che la libreria standard mette a disposizione e dei traits che ne consentono la funzionalità.

Nel Capitolo 16, esamineremo diversi modelli di programmazione concorrente e parleremo di come Rust ti aiuti a programmare con più sotto-processi (thread d’ora in poi) senza paura. Nel Capitolo 17, esploreremo la sintassi async e await di Rust, insieme a task, futures e stream, e il modello di concomitanza leggera che consentono.

Il Capitolo 18 analizza come gli idiomi di Rust si confrontano con i principi della programmazione orientata agli oggetti che potresti conoscere. Il Capitolo 19 è un riferimento ai pattern e al loro riconoscimento (pattern matching), che sono modi potenti di esprimere idee nei programmi Rust. Il Capitolo 20 contiene una varietà di argomenti di interesse, tra cui Rust non sicuro (unsafe Rust d’ora in poi), macro e altre informazioni su longevità, trait, tipologie (type), funzioni e chiusure.

Nel Capitolo 21, completeremo un progetto in cui implementeremo un server web multi-thread di basso livello!

Infine, alcune appendici contengono informazioni utili sul linguaggio in un formato meno strutturato. L’Appendice A tratta delle parole chiave di Rust, l’Appendice B degli operatori e dei simboli di Rust, l’Appendice C dei trait derivabili forniti dalla libreria standard, l’Appendice D di alcuni utili strumenti di sviluppo e l’Appendice E delle varie edizioni di Rust. Nell’Appendice F puoi trovare un elenco delle traduzioni del libro, mentre nell’Appendice G si parlerà di come viene realizzato Rust e di cosa sia la nightly Rust.

Non c’è un modo sbagliato di leggere questo libro: se vuoi andare avanti, fallo pure! Potresti dover tornare indietro ai capitoli precedenti se ti senti confuso, ma fai quello che va bene per te.

Una parte importante del processo di apprendimento di Rust consiste nell’imparare a leggere i messaggi di errore visualizzati dal compilatore: questi ti guideranno verso un codice funzionante. Per questo motivo, ti forniremo molti esempi che non si compilano insieme al messaggio di errore che il compilatore ti mostrerà in ogni situazione. Sappi che se inserisci ed esegui un esempio a caso, potrebbe non compilarsi! Assicurati di leggere il testo circostante per capire se l’esempio che stai cercando di eseguire è destinato a dare un errore. Nella maggior parte delle situazioni, ti guideremo alla correzione di questi errori che comportano la mancata compilazione. Ferris ti aiuterà anche a distinguere il codice che non è destinato a funzionare:

FerrisSignificato
Ferris con un punto interrogativoQuesto codice non si compila!
Ferris con le chele alzateQuesto codice genera panic!
Ferris con una chela alzata, interdettoQuesto codice non funziona come dovrebbe.

Nella maggior parte dei casi, ti guideremo alla versione corretta di qualsiasi codice che non si compila.

Codice Sorgente

I file sorgente da cui è stato generato questo libro si trovano su GitHub per la versione italiana.

La versione originale in inglese si trova anch’essa su GitHub.

Primi Passi

Iniziamo il tuo viaggio in Rust! C’è molto da imparare, ma ogni viaggio inizia da qualche parte. In questo capitolo parleremo di come:

  • Installare Rust su Linux, macOS e Windows
  • Scrivere un programma che stampi Hello, world!
  • Utilizzare cargo, il gestore di pacchetti e il sistema di compilazione di Rust

Installazione

Il primo passo è quello di installare Rust. Scaricheremo Rust attraverso rustup, uno strumento a riga di comando per gestire le versioni di Rust e gli strumenti associati. Per il download è necessaria una connessione a internet.

Nota: Se per qualche motivo preferisci non utilizzare rustup, consulta la pagina Altri metodi di installazione di Rust per ulteriori opzioni.

I passaggi seguenti installano l’ultima versione stabile del compilatore Rust. Le garanzie di stabilità di Rust assicurano che tutti gli esempi del libro che vengono compilati continueranno a essere compilati anche con le versioni più recenti di Rust. L’output potrebbe differire leggermente da una versione all’altra perché Rust spesso migliora i messaggi di errore e gli avvertimenti. In altre parole, qualsiasi versione più recente e stabile di Rust che installerai utilizzando questi passaggi dovrebbe funzionare come previsto con il contenuto di questo libro.

Annotazioni per la Riga di Comando

In questo capitolo e in tutto il libro, mostreremo alcuni comandi utilizzati nel terminale. Le linee che dovresti inserire in un terminale iniziano tutte con $. Non è necessario digitare il carattere $; è il prompt della riga di comando mostrato per indicare l’inizio di ogni comando. Le linee che non iniziano con $ mostrano solitamente l’output del comando precedente. Inoltre, gli esempi specifici per PowerShell useranno > anziché $.

Installare rustup su Linux o macOS

Se stai usando Linux o macOS, apri un terminale e inserisci il seguente comando:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Il comando scarica uno script e avvia l’installazione dello strumento rustup, che installa l’ultima versione stabile di Rust. Potrebbe esserti richiesta la tua password. Se l’installazione ha successo, apparirà la seguente riga:

Rust is installed now. Great!

Avrai anche bisogno di un linker, che è un programma che Rust utilizza per unire i suoi output compilati in un unico file. È probabile che tu ne abbia già uno. Se ottieni errori di linker, dovresti installare un compilatore C, che di solito include un linker. Un compilatore C è utile anche perché alcuni pacchetti comuni di Rust dipendono dal codice C e avranno bisogno di un compilatore C.

Su macOS, puoi ottenere un compilatore C eseguendo:

$ xcode-select --install

Gli utenti Linux dovrebbero generalmente installare GCC o Clang, in base alla documentazione della loro distribuzione. Ad esempio, se utilizzi Ubuntu, puoi installare il pacchetto build-essential.

Installare rustup su Windows

Su Windows, vai su https://www.rust-lang.org/tools/install e segui le istruzioni per installare Rust. A un certo punto dell’installazione, ti verrà richiesto di installare Visual Studio, che fornisce un linker e le librerie native necessarie per compilare i programmi. Se hai bisogno di aiuto per questo passaggio, consulta https://rust-lang.github.io/rustup/installation/windows-msvc.html.

Il resto di questo libro utilizza comandi che funzionano sia in cmd.exe che in PowerShell. Se ci sono differenze specifiche, ti spiegheremo quale utilizzare.

Risolvere i Problemi

Per verificare se Rust è stato installato correttamente, apri il terminale e inserisci questo comando:

$ rustc --version

Dovresti vedere il numero di versione, l’hash del commit e la data del commit dell’ultima versione stabile rilasciata, nel seguente formato:

rustc x.y.z (abcabcabc yyyy-mm-dd)

Se vedi queste informazioni, hai installato Rust con successo! Se non vedi queste informazioni, controlla che Rust sia nella tua variabile di sistema %PATH% come segue.

Su Windows con CMD, usa:

> echo %PATH%

In PowerShell, usa:

> echo $env:Path

In Linux e macOS, usa:

$ echo $PATH

Se sembra essere tutto in ordine ma Rust non funziona ancora, ci sono diversi posti in cui puoi trovare aiuto. Scopri come metterti in contatto con altri Rustaceani (Rustacean d’ora in poi) (uno stupido soprannome con cui ci chiamiamo) sulla pagina della comunità.

Aggiornare e Disinstallare

Una volta che Rust è stato installato tramite rustup, l’aggiornamento a una nuova versione è semplice. Dalla tua shell, esegui il seguente script di aggiornamento:

$ rustup update

Per disinstallare Rust e rustup, esegui il seguente script di disinstallazione dalla tua shell:

$ rustup self uninstall

Leggere la Documentazione in Locale

L’installazione di Rust include anche una copia locale della documentazione per poterla leggere offline. Esegui rustup doc per aprire la documentazione locale nel tuo browser.

Ogni qual volta hai un dubbio su un type o una funzione fornita dalla libreria standard e non sei sicuro di cosa faccia o di come usarla, usa la documentazione delle API per scoprirlo!

Usare Editor di Testo e IDE

Questo libro non fa alcuna ipotesi sugli strumenti che utilizzi per scrivere il codice Rust. Qualsiasi editor di testo è in grado di fare il suo lavoro! Tuttavia, molti editor di testo e IDE (ambienti di sviluppo integrati) hanno un supporto integrato per Rust. Puoi sempre trovare un elenco abbastanza aggiornato di molti editor e IDE nella pagina degli strumenti sul sito web di Rust.

Lavorare Offline con Questo Libro

In diversi esempi, utilizzeremo pacchetti Rust oltre alla libreria standard. Per lavorare a questi esempi, dovrai disporre di una connessione a internet o aver scaricato le dipendenze in anticipo. Per scaricare le dipendenze in anticipo, puoi eseguire i seguenti comandi. (Spiegheremo cos’è cargo e cosa fa ciascuno di questi comandi in dettaglio più avanti)

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.8.5 trpl@0.2.0

In questo modo i download di questi pacchetti verranno memorizzati nella cache e non sarà necessario scaricarli in seguito. Una volta eseguito questo comando, non dovrai conservare la cartella get-dependencies. Se hai eseguito questo comando, puoi aggiungere il flag --offline quando userai il comando cargo nel resto del libro per utilizzare queste versioni memorizzate nella cache invece di scaricarle da internet in quel momento.

Hello, World!

Ora che hai installato Rust, è il momento di scrivere il tuo primo programma Rust. Quando si impara un nuovo linguaggio, è consuetudine scrivere un piccolo programma che stampi sullo schermo il testo Hello, world!

Nota: questo libro presuppone una certa familiarità di base con la riga di comando. Rust non ha particolari esigenze per quanto riguarda l’editing o gli strumenti o dove risiede il tuo codice, quindi se preferisci usare IDE invece della riga di comando, sentiti libero di usare il tuo IDE preferito. Molti IDE ora hanno un certo grado di supporto per Rust; controlla la documentazione dell’IDE per maggiori dettagli. Il team di Rust si è concentrato sull’integrazione ottima con gli IDE tramite rust-analyzer. Vedi Appendice D per maggiori dettagli.

Impostare una Directory dei Progetti

Inizierai creando una directory per memorizzare il tuo codice Rust. Per Rust non è importante dove si trovi il tuo codice, ma per gli esercizi e i progetti di questo libro ti consigliamo di creare una cartella progetti nella tua home directory e di tenere tutti i tuoi progetti lì.

Apri un terminale e inserisci i seguenti comandi per creare una cartella progetti e una cartella per il progetto “Hello, world!” all’interno della directory progetti.

Per Linux, macOS, e PowerShell su Windows, digita questo:

$ mkdir ~/progetti
$ cd ~/progetti
$ mkdir hello_world
$ cd hello_world

Per Windows con CMD, digita questo:

> mkdir "%USERPROFILE%\progetti"
> cd /d "%USERPROFILE%\progetti"
> mkdir hello_world
> cd hello_world

Programma Rust Basilare

Adesso crea un nuovo file sorgente e chiamalo main.rs. I file di Rust terminano sempre con l’estensione .rs. Se usi più di una parola nel nome del file, la convenzione è di usare un trattino basso per separarle. Ad esempio, usa hello_world.rs piuttosto che helloworld.rs.

Ora apri il file main.rs che hai appena creato e inserisci il codice del Listato 1-1.

File: main.rs
fn main() {
    println!("Hello, world!");
}
Listato 1-1: Un programma che stampa Hello, world!

Salva il file e torna alla finestra del terminale alla cartella ~/progetti/hello_world . Su Linux o macOS, inserisci i seguenti comandi per compilare ed eseguire il file:

$ rustc main.rs
$ ./main
Hello, world!

Su Windows, inserisci il comando .\main invece di ./main:

> rustc main.rs
> .\main
Hello, world!

Indipendentemente dal sistema operativo, la stringa Hello, world! dovrebbe essere stampata sul terminale. Se non vedi questo output, consulta la sezione “Risolvere i Problemi” nel capitolo Installazione per trovare aiuto.

Se Hello, world! è stato stampato, congratulazioni! Hai ufficialmente scritto un programma Rust. Questo ti rende un programmatore Rust, benvenuto!

Anatomia di un Programma Rust

Esaminiamo in dettaglio questo programma “Hello, world!”. Ecco il primo pezzo del puzzle:

fn main() {

}

Queste righe definiscono una funzione chiamata main. La funzione main è speciale: è sempre il primo codice che viene eseguito in ogni eseguibile Rust. Qui, la prima riga dichiara una funzione chiamata main che non ha parametri e non restituisce nulla. Se ci fossero dei parametri, andrebbero dentro le parentesi (()).

Il corpo della funzione è racchiuso da {}. Rust richiede parentesi graffe intorno a tutti i corpi delle funzioni. È buona norma posizionare la parentesi graffa di apertura sulla stessa riga della dichiarazione della funzione, aggiungendo uno spazio in mezzo.

Nota: se vuoi attenerti a uno stile standard in tutti i progetti Rust, puoi usare uno strumento di formattazione automatica chiamato rustfmt per formattare il tuo codice in un particolare stile (maggiori informazioni su rustfmt in Appendice D). Il team di Rust ha incluso questo strumento nella distribuzione standard di Rust, come rustc, quindi dovrebbe essere già installato sul tuo computer!

Il corpo della funzione main contiene il seguente codice:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

Questa riga fa tutto il lavoro di questo piccolo programma: stampa il testo sullo schermo. Ci sono tre dettagli importanti da notare.

Innanzitutto, println! chiama una macro di Rust. Se invece avesse chiamato una funzione, sarebbe stata inserita come println (senza il !). Le macro di Rust sono un modo per scrivere codice che genera codice per estendere la sintassi di Rust e ne parleremo in modo più dettagliato nel Capitolo 20. Per ora, ti basterà sapere che usare una ! significa che stai chiamando una macro invece di una normale funzione e che le macro non seguono sempre le stesse regole delle funzioni.

In secondo luogo, vedi la stringa "Hello, world!" che viene passata come argomento a println! e la stringa viene stampata sullo schermo.

In terzo luogo, terminiamo la riga con un punto e virgola (;), che indica che questa espressione è terminata e che la prossima è pronta per iniziare. La maggior parte delle righe di codice Rust terminano con un punto e virgola

Compilare ed Eseguire

Hai appena eseguito un programma appena creato, quindi esaminiamo ogni fase del processo.

Prima di eseguire un programma Rust, devi compilarlo con il compilatore Rust inserendo il comando rustc e passandogli il nome del tuo file sorgente, in questo modo:

$ rustc main.rs

Se hai un background in C o C++, noterai che è simile a gcc o clang. Dopo aver compilato con successo, Rust produce un eseguibile binario.

Su Linux, macOS e PowerShell su Windows, puoi vedere l’eseguibile usando il comando ls nella tua shell:

$ ls
main  main.rs

Su Linux e macOS, vedrai due file. Con PowerShell su Windows, vedrai gli stessi tre file che vedresti usando CMD. Con CMD su Windows, inserisci il seguente comando:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

Questo mostra il file del codice sorgente con estensione .rs, il file eseguibile (main.exe su Windows, solo main su tutte le altre piattaforme) e, se usi Windows, un file contenente informazioni di debug con estensione .pdb. Ora puoi eseguire il file main o main.exe, in questo modo:

$ ./main # or .\main su Windows

Se il tuo main.rs è il tuo programma “Hello, world!”, questa riga stampa Hello, world! sul tuo terminale.

Se hai più familiarità con un linguaggio dinamico, come Ruby, Python o JavaScript, potresti non essere abituato alla compilazione e all’esecuzione di un programma come fasi separate. Rust è un linguaggio compilato in anticipo, il che significa che puoi compilare un programma e dare l’eseguibile a qualcun altro, che potrà eseguirlo anche senza avere Rust installato. Se dai a qualcuno un file .rb, .py o .js, deve avere un’implementazione di Ruby, Python o JavaScript installata (rispettivamente). Ma in questi linguaggi, hai bisogno di un solo comando per compilare ed eseguire il tuo programma. Sono compromessi diversi per ogni linguaggio di programmazione.

La semplice compilazione con rustc va bene per i programmi semplici, ma quando il tuo progetto cresce, vorrai gestire tutte le opzioni e rendere facile la condivisione del tuo codice. A seguire, ti presenteremo lo strumento Cargo, che ti aiuterà a scrivere programmi Rust veri e propri.

Hello, Cargo!

Cargo è il sistema di compilazione e il gestore di pacchetti di Rust. La maggior parte dei Rustacean utilizza questo strumento per gestire i propri progetti Rust perché Cargo gestisce molte attività al posto tuo, come la compilazione del codice, il download delle librerie da cui dipende il tuo codice e la compilazione di tali librerie (chiamiamo le librerie di cui il tuo codice ha bisogno dipendenze)

I programmi Rust più semplici, come quello che abbiamo scritto finora, non hanno dipendenze. Se avessimo costruito il progetto “Hello, world!” con Cargo, questo avrebbe utilizzato solo la parte di Cargo che si occupa della costruzione del codice. Man mano che scriverai programmi Rust più complessi, aggiungerai delle dipendenze e se inizierai un progetto utilizzando Cargo, sarà molto più facile aggiungere dipendenze.

Poiché la stragrande maggioranza dei progetti Rust utilizza Cargo, il resto di questo libro presuppone che anche tu stia utilizzando Cargo. Cargo viene installato insieme a Rust se hai utilizzato l’installazione di cui si parla nella sezione “Installazione”. Se hai installato Rust in altro modo, controlla se Cargo è installato inserendo quanto segue nel tuo terminale:

$ cargo --version

Se viene visualizzato un numero di versione, allora è fatta! Se viene visualizzato un errore, come ad esempio comando non trovato, consulta la documentazione relativa al tuo metodo di installazione per determinare come installare Cargo separatamente.

Creare un Progetto con Cargo

Creiamo un nuovo progetto utilizzando Cargo e vediamo come si differenzia dal nostro progetto originale “Hello, world!“. Torna alla tua cartella progetti (o dove hai deciso di memorizzare il tuo codice). Poi, su qualsiasi sistema operativo, esegui il seguente comando:

$ cargo new hello_cargo
$ cd hello_cargo

Il primo comando crea una nuova cartella e un nuovo progetto chiamato hello_cargo. Abbiamo chiamato il nostro progetto hello_cargo e Cargo crea i suoi file in una cartella con lo stesso nome.

Vai nella cartella hello_cargo ed elenca i file. Vedrai che Cargo ha generato due file e una cartella per noi: un file Cargo.toml e una cartella src con un file main.rs al suo interno.

Ha anche inizializzato un nuovo repository Git insieme a un file .gitignore. I file Git non verranno generati se esegui cargo new all’interno di un repository Git esistente; puoi annullare questo comportamento utilizzando cargo new --vcs=git.

Nota: Git è un diffuso software di controllo di versione distribuito. Puoi modificare cargo new per utilizzare un altro sistema di controllo di versione o nessun sistema di controllo di versioni utilizzando il flag --vcs. Esegui cargo new --help per vedere le opzioni disponibili.

Apri Cargo.toml nell’editor di testo che preferisci. Dovrebbe assomigliare al codice del Listato 1-2.

File: Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]
Listato 1-2: Contenuto di Cargo.toml generato da cargo new

Questo file è nel formato TOML (Tom’s Obvious, Minimal Language), che è il formato di configurazione di Cargo.

La prima riga, [package], è un’intestazione di sezione che indica che le dichiarazioni seguenti stanno configurando un pacchetto. Man mano che aggiungeremo altre informazioni a questo file, aggiungeremo altre sezioni.

Le tre righe successive definiscono le informazioni di configurazione di cui Cargo ha bisogno per compilare il tuo programma: il nome, la versione e l’edizione di Rust da utilizzare. Parleremo della chiave edition in Appendice E.

L’ultima riga, [dependencies], è l’inizio di una sezione in cui puoi elencare tutte le dipendenze del tuo progetto. In Rust, i pacchetti di codice sono chiamati crates (inteso come cassetta, contenitore…). Non avremo bisogno di altri crates per questo progetto, ma lo faremo nel primo progetto del Capitolo 2, quindi useremo questa sezione di dipendenze.

Ora apri src/main.rs e dai un’occhiata:

File: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo ha generato per te un programma “Hello, world!”, proprio come quello che abbiamo scritto nel Listato 1-1! Finora, le differenze tra il nostro progetto e quello generato da Cargo sono che Cargo ha inserito il codice nella cartella src, e che c’è un file di configurazione Cargo.toml nella directory principale.

Cargo si aspetta che i tuoi file sorgente si trovino all’interno della cartella src. La directory principale del progetto è solo per i file README, le informazioni sulla licenza, i file di configurazione e tutto ciò che non riguarda il tuo codice. L’utilizzo di Cargo ti aiuta a organizzare i tuoi progetti: c’è un posto per ogni cosa e ogni cosa è al suo posto.

Se hai iniziato un progetto che non utilizza Cargo, come abbiamo fatto con il progetto “Hello, world!”, puoi convertirlo in un progetto che utilizza Cargo. Sposta il codice del progetto nella cartella src e crea un file Cargo.toml appropriato. Un modo semplice per ottenere il file Cargo.toml è eseguire cargo init, che lo creerà automaticamente.

Costruire e Eseguire un Progetto Cargo

Ora vediamo cosa cambia quando costruiamo ed eseguiamo il programma “Hello, world!” con Cargo! Dalla cartella hello_cargo, costruisci il tuo progetto inserendo il seguente comando:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///progetti/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

Questo comando crea un file eseguibile in target/debug/hello_cargo (o target\debug\hello_cargo.exe_ su Windows) anziché nella tua cartella corrente. Poiché la compilazione predefinita è una compilazione di debug, Cargo mette il binario in una cartella chiamata debug. Puoi eseguire l’eseguibile con questo comando:

$ ./target/debug/hello_cargo # o .\target\debug\hello_cargo.exe su Windows
Hello, world!

Se tutto è andato bene, Hello, world! dovrebbe essere stampato sul terminale. L’esecuzione di cargo build per la prima volta fa sì che Cargo crei anche un nuovo file nella directory principale: Cargo.lock. Questo file tiene traccia delle versioni esatte delle dipendenze nel tuo progetto. Questo progetto non ha dipendenze, quindi il file è un po’ scarno. Non dovrai mai modificare questo file manualmente; Cargo gestisce il suo contenuto per te.

Abbiamo appena costruito un progetto con cargo build e lo abbiamo eseguito con ./target/debug/hello_cargo, ma possiamo anche usare cargo run per compilare il codice e poi eseguire l’eseguibile risultante con un solo comando:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

Utilizzare cargo run è più comodo che doversi ricordare di eseguire cargo build e poi utilizzare l’intero percorso del binario, quindi la maggior parte degli sviluppatori utilizza cargo run.

Nota che questa volta non abbiamo visto l’output che indicava che Cargo stava compilando hello_cargo. Cargo ha capito che i file non erano cambiati, quindi non ha ricostruito ma ha semplicemente eseguito il binario. Se avessi modificato il codice sorgente, Cargo avrebbe ricostruito il progetto prima di eseguirlo e avresti visto questo output:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///progetti/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo offre anche un comando chiamato cargo check, che controlla rapidamente il tuo codice per assicurarsi che venga compilato ma che non produce un eseguibile:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///progetti/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

E perché non dovresti volere un eseguibile? Spesso, cargo check è molto più veloce di cargo build perché salta il passaggio della produzione di un eseguibile. Se controlli continuamente il tuo lavoro mentre scrivi il codice, l’uso di cargo check accelererà il processo di sapere se il tuo progetto è privo di errori e compilabile! Per questo motivo, molti Rustacean eseguono cargo check periodicamente mentre scrivono il loro programma per assicurarsi che si compili. Poi eseguono cargo build quando sono pronti a creare l’eseguibile.

Ricapitoliamo quello che abbiamo imparato finora su Cargo:

  • Possiamo creare un progetto utilizzando cargo new.
  • Possiamo costruire un progetto utilizzando cargo build.
  • Possiamo costruire ed eseguire un progetto in un unico passaggio utilizzando cargo run.
  • Possiamo costruire un progetto senza produrre un binario per controllare gli errori utilizzando cargo check.
  • Invece di salvare il risultato della compilazione nella stessa directory del nostro codice, Cargo lo salva nella directory target/debug.

Un ulteriore vantaggio dell’utilizzo di Cargo è che i comandi sono gli stessi indipendentemente dal sistema operativo su cui stai lavorando. Quindi, a questo punto, non forniremo più istruzioni specifiche per Linux e macOS rispetto a Windows.

Costruire per il Rilascio

Quando il tuo progetto è finalmente pronto per essere rilasciato, puoi usare cargo build --release per compilarlo con le ottimizzazioni. Questo comando creerà un eseguibile in target/release invece che in target/debug. Le ottimizzazioni rendono il tuo codice Rust più veloce, ma attivarle allunga i tempi di compilazione del tuo programma. Per questo motivo esistono due profili diversi: uno per lo sviluppo, quando vuoi ricostruire rapidamente e spesso, e un altro per la creazione del programma finale che darai a un utente e che non sarà ricostruito più volte e che funzionerà il più velocemente possibile. Se vuoi fare un benchmark del tempo di esecuzione del tuo codice, assicurati di eseguire cargo build --release e di fare il benchmark con l’eseguibile in target/release.

Sfruttare Le Convenzioni di Cargo

Con i progetti semplici, Cargo non offre molti vantaggi rispetto all’uso di rustc, ma si dimostrerà utile quando i tuoi programmi diventeranno più complessi. Quando i programmi diventano più file o hanno bisogno di una dipendenza, è molto più facile lasciare che Cargo coordini la compilazione.

Anche se il progetto hello_cargo è semplice, ora utilizza gran parte degli strumenti che userai nel resto della tua carriera in Rust. Infatti, per lavorare su qualsiasi progetto esistente, puoi usare i seguenti comandi per verificare il codice usando Git, passare alla directory del progetto e compilare:

$ git clone example.org/un_progetto_nuovo
$ cd un_progetto_nuovo
$ cargo build

Per maggiori informazioni su Cargo, consulta la documentazione.

Sei già partito alla grande nel tuo viaggio assieme a Rust! In questo capitolo hai imparato a..:

  • Installare l’ultima versione stabile di Rust usando rustup.
  • Aggiornare a una versione più recente di Rust.
  • Aprire la documentazione installata localmente.
  • Scrivere ed eseguire un programma “Hello, world!” usando direttamente rustc.
  • Creare ed eseguire un nuovo progetto usando le convenzioni di Cargo.

Questo è un ottimo momento per costruire un programma più sostanzioso per abituarsi a leggere e scrivere codice in Rust. Quindi, nel Capitolo 2, costruiremo un programma di gioco di indovinelli. Se invece preferisci iniziare imparando come funzionano in Rust alcuni concetti base della programmazione, consulta il Capitolo 3 e poi ritorna al Capitolo 2.

Programmare un Gioco di Indovinelli

Cominciamo a programmare in Rust lavorando insieme a un progetto pratico! Questo capitolo ti introduce ad alcuni concetti comuni di Rust mostrandoti come utilizzarli in un programma reale. Imparerai a conoscere let, match, metodi, funzioni associate, crates esterni e molto altro ancora! Nei capitoli successivi esploreremo queste idee in modo più dettagliato, mentre in questo capitolo ti limiterai a mettere in pratica le nozioni fondamentali.

Implementeremo un classico problema di programmazione per principianti: un gioco di indovinelli. Ecco come funziona: il programma genererà un numero intero casuale compreso tra 1 e 100. Poi chiederà al giocatore di inserire un’ipotesi. Dopo aver inserito un’ipotesi, il programma indicherà se l’ipotesi è troppo bassa o troppo alta. Se l’ipotesi è corretta, il gioco stamperà un messaggio di congratulazioni e terminerà.

Impostare un Nuovo Progetto

Per creare un nuovo progetto, vai nella cartella progetti che hai creato nel Capitolo 1 e crea un nuovo progetto con Cargo, in questo modo:

$ cargo new gioco_indovinello
$ cd gioco_indovinello

Il primo comando, cargo new, prende il nome del progetto (gioco_indovinello) come primo argomento. Il secondo comando entra nella directory del nuovo progetto.

Diamo un’occhiata al file Cargo.toml appena generato:

File: Cargo.toml

[package]
name = "gioco_indovinello"
version = "0.1.0"
edition = "2024"

[dependencies]

Come hai visto nel Capitolo 1, cargo new genera per te un programma “Hello, world!”. Guarda il file src/main.rs:

File: src/main.rs

fn main() {
    println!("Hello, world!");
}

Ora compiliamo questo programma “Hello, world!” ed eseguiamolo nello stesso passaggio utilizzando il comando cargo run:

$ cargo run
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.33s
     Running `target/debug/gioco_indovinello`
Hello, world!

Il comando run è utile quando hai bisogno di iterare rapidamente su un progetto, come faremo in questo gioco, testando velocemente ogni modifica prima di passare alla successiva.

Riapri il file src/main.rs. In questo file scriverai tutto il codice.

Elaborare un’Ipotesi

La prima parte del programma del gioco di indovinelli richiederà l’input dell’utente, lo elaborerà e verificherà che l’input sia nella forma prevista. Per iniziare, permetteremo al giocatore di inserire un’ipotesi. Inserisci il codice del Listato 2-1 in src/main.rs.

File: src/main.rs
use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}
Listato 2-1: Codice che riceve l’ipotesi dall’utente e la stampa

Questo codice contiene molte informazioni, quindi analizziamolo riga per riga. Per ottenere l’input dell’utente e poi stampare il risultato come output, dobbiamo utilizzare il modulo di input/output io. Il modulo io proviene dalla libreria standard, nota come std:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Come impostazione predefinita, Rust ha un insieme di risorse definite nella libreria standard che vengono inserite in ogni programma. Questo insieme è chiamato preludio (prelude d’ora in poi) e puoi vedere tutto ciò che contiene nella documentazione della libreria standard.

Se una risorsa che vuoi utilizzare non è presente nel prelude, devi renderla disponibile esplicitamente con un’istruzione use. L’utilizzo del modulo std::io ti offre una serie di utili funzioni, tra cui la possibilità di ricevere input dall’utente.

Come hai visto nel Capitolo 1, la funzione main è il punto di ingresso del programma:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

La sintassi fn dichiara una nuova funzione; le parentesi, (), indicano che non ci sono parametri; e la parentesi graffa, {, inizia il corpo della funzione.

Come hai imparato nel Capitolo 1, println! è una macro che stampa una stringa sullo schermo:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Questo codice stampa un messaggio che introduce il gioco e richiede un input da parte dell’utente.

Memorizzare i Valori con le Variabili

Successivamente, creeremo una variabile per memorizzare l’input dell’utente, in questo modo:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Ora il programma si fa interessante! In questa piccola riga succedono molte cose. Usiamo l’istruzione let per creare la variabile. Ecco un altro esempio:

let mele = 5;

Questa riga crea una nuova variabile di nome mele e la lega al valore 5. In Rust, le variabili sono immutabili (immutable) come impostazione predefinita, il che significa che una volta assegnato un valore alla variabile, il valore non cambierà. Parleremo di questo concetto in dettaglio nella sezione “Variabili e mutabilità” del Capitolo 3. Per rendere mutabile (mutable) una variabile, aggiungiamo mut prima del nome della variabile:

let mele = 5; // immutabile
let mut banane = 5; // mutabile

Nota: la sintassi // inizia un commento che continua fino alla fine della riga. Rust ignora tutto ciò che è contenuto nei commenti. Parleremo dei commenti in modo più dettagliato nel Capitolo 3.

Torniamo al nostro gioco di indovinelli. Ora sai che let mut ipotesi introdurrà una variabile mutabile di nome ipotesi. Il segno di uguale (=) indica a Rust che vogliamo legare qualcosa alla variabile in quel momento. A destra del segno di uguale c’è il valore a cui ipotesi è legata, che è il risultato della chiamata a String::new, una funzione che restituisce una nuova istanza di una String. String è un type di stringa fornito dalla libreria standard che è un pezzo di testo codificato UTF-8 modificabile in lunghezza.

La sintassi :: nella riga ::new indica che new è una funzione associata al type String. Una funzione associata è una funzione implementata su un type, in questo caso String. Questa funzione new crea una nuova stringa vuota. Troverai una funzione new in molti type perché è un nome comune per una funzione che crea un nuovo valore di qualche tipo.

In pratica, la linea let mut ipotesi = String::new(); ha creato una variabile mutable che è attualmente legata a una nuova istanza vuota di String. Wow!

Ricevere l’Input dell’Utente

Ricordiamo che abbiamo incluso le funzionalità di input/output della libreria standard con use std::io; nella prima riga del programma. Ora chiameremo la funzione stdin dal modulo io, che ci permetterà di gestire l’input dell’utente:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Se non avessimo importato il modulo io con use std::io; all’inizio del programma, potremmo comunque utilizzare la funzione scrivendo questa chiamata di funzione come std::io::stdin. La funzione stdin restituisce un’istanza di std::io::Stdin, che è un type che rappresenta un handle all’input standard del tuo terminale.

Successivamente, la riga .read_line(&mut ipotesi) chiama il metodo read_line sull’handle di input standard per ottenere un input dall’utente. Passiamo anche &mut ipotesi come argomento a read_line per dirgli in quale stringa memorizzare l’input dell’utente. Il compito di read_line è quello di prendere tutto ciò che l’utente digita nell’input standard e aggiungerlo a una stringa (senza sovrascriverne il contenuto), quindi passiamo tale stringa come argomento. L’argomento stringa deve essere mutable in modo che il metodo possa cambiare il contenuto della stringa.

Il simbolo & indica che questo argomento è un riferimento (reference d’ora in poi), il che ti dà la possibilità di permettere a più parti del codice di accedere a un dato senza doverlo copiare più volte in memoria. I reference sono una funzionalità complessa e uno dei principali vantaggi di Rust è la sicurezza e la facilità con cui è possibile utilizzarli. Non hai bisogno di conoscere molti di questi dettagli per finire questo programma. Per ora, tutto ciò che devi sapere è che, come le variabili, i reference sono immutabili come impostazione predefinita. Di conseguenza, devi scrivere &mut ipotesi piuttosto che solo &ipotesi per renderli mutable (il Capitolo 4 spiegherà i reference in modo più approfondito)

Gestire i Potenziali Errori con Result

Stiamo ancora lavorando su questa riga di codice. Ora stiamo discutendo di una terza riga di testo, ma notiamo che fa ancora parte di un’unica riga logica di codice. La prossima parte è questo metodo:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Avremmo potuto scrivere questo codice come:

io::stdin().read_line(&mut ipotesi).expect("Errore di lettura");

Tuttavia, una riga lunga può essere difficile da leggere, quindi è meglio dividerla. Spesso è consigliabile andare a capo e aggiungere degli spazi bianchi per aiutare a spezzare le righe lunghe quando chiami un metodo con la sintassi .nome_metodo(). Ora vediamo cosa fa questa riga.

Come accennato in precedenza, read_line inserisce qualsiasi cosa l’utente inserisca nella stringa che gli passiamo, ma restituisce anche un valore Result. Result è una enumerazione (enum per brevità), che è un type che può trovarsi in uno dei molteplici stati possibili. Chiamiamo ogni stato possibile una variante.

Il Capitolo 6 tratterà le enum in modo più dettagliato. Lo scopo di questi type Result è quello di fornire informazioni sulla gestione degli errori.

Le varianti di Result sono Ok e Err. La variante Ok indica che l’operazione è andata a buon fine e contiene il valore generato con successo. La variante Err indica che l’operazione non è andata a buon fine e contiene informazioni su come o perché l’operazione è fallita.

I valori del tipo Result, come i valori di qualsiasi type, hanno dei metodi definiti su di essi. Un’istanza di Result ha un metodo expect che puoi chiamare. Se questa istanza di Result è un valore Err, expect causerà l’arresto del programma e visualizzerà il messaggio che hai passato come argomento a expect. Se il metodo read_line restituisce un Err, è probabile che sia il risultato di un errore proveniente dal sistema operativo sottostante. Se questa istanza di Result è un valore Ok, expect prenderà il valore di ritorno che Ok sta tenendo e ti restituirà solo quel valore in modo che tu possa usarlo. In questo caso, quel valore è il numero di byte nell’input dell’utente.

Se non chiami expect, il programma verrà compilato, ma riceverai un avviso:

$ cargo build
   Compiling gioco_indovinello v0.1.0 (file:///pregetti/gioco_indovinello)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut ipotesi);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut ipotesi);
   |     +++++++

warning: `gioco_indovinello` (bin "gioco_indovinello") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s

Rust avverte che non è stato utilizzato il valore Result restituito da read_line, indicando che il programma non ha gestito un possibile errore.

Il modo corretto per sopprimere l’avvertimento è quello di scrivere del codice che gestisca questi potenziali errori, ma nel nostro caso non è un grosso problema mandare in crash il programma quando si verifica un problema, quindi possiamo usare expect. Imparerai a recuperare dagli errori nel Capitolo 9.

Stampare i Valori con i Segnaposto in println!

A parte la parentesi graffa di chiusura, c’è un’ultima riga da discutere nel codice:

use std::io;

fn main() {
    println!("Indovina il numero!");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Questa riga stampa la stringa che ora contiene l’input dell’utente. La serie di parentesi graffe {} è un segnaposto: pensa a {} come a delle piccole chele di granchio che tengono fermo un valore. Quando stampi il valore di una variabile, il nome della variabile può essere inserito all’interno delle parentesi graffe. Quando devi stampare il risultato della valutazione di un’espressione, inserisci delle parentesi graffe vuote nella stringa di formato, quindi fai seguire alla stringa di formato un elenco separato da virgole di espressioni da stampare in ogni segnaposto vuoto, nello stesso ordine. Stampare una variabile e il risultato di un’espressione in un’unica chiamata a println! sarebbe così:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} e y + 2 = {}", y + 2);
}

Questo codice produrrà x = 5 e y + 2 = 12.

Proviamo la Prima Parte

Proviamo la prima parte del gioco di indovinelli utilizzando cargo run:

$ cargo run
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.40s
     Running `target/debug/gioco_indovinello`
Indovina il numero!
Inserisci la tua ipotesi.
6
Hai ipotizzato: 6

A questo punto, la prima parte del gioco è terminata: stiamo ricevendo input dalla tastiera e poi li stiamo stampando.

Generare un Numero Segreto

Ora dobbiamo generare un numero segreto che l’utente cercherà di indovinare. Il numero segreto dovrebbe essere diverso ogni volta, in modo da rendere il gioco divertente più di una volta. Utilizzeremo un numero casuale compreso tra 1 e 100, in modo che il gioco non sia troppo difficile. Rust non include ancora la funzionalità dei numeri casuali nella sua libreria standard, ma il team di Rust fornisce un crate rand con tale funzionalità.

Aumentare le Funzionalità con un Crate

Ricorda che un crate è una raccolta di file di codice sorgente in Rust. Il progetto che stiamo costruendo è un crate binario, cioè un eseguibile. Il crate rand è un crate libreria, che contiene codice destinato a essere utilizzato in altri programmi e non può essere eseguito da solo.

Prima di poter scrivere del codice che utilizzi rand, dobbiamo modificare il file Cargo.toml per includere il crate rand come dipendenza. Apri il file e aggiungi la seguente riga in fondo, sotto l’intestazione della sezione delle dipendenze [dependencies] che Cargo ha creato per te. Assicurati di specificare rand esattamente come abbiamo fatto qui, con questo numero di versione, altrimenti gli esempi di codice di questo tutorial potrebbero non funzionare:

File: Cargo.toml

[dependencies]
rand = "0.8.5"

Nel file Cargo.toml, tutto ciò che segue un’intestazione fa parte di quella sezione che continua fino all’inizio di un’altra sezione. In [dependencies], indichi a Cargo quali sono i crate esterni da cui dipende il tuo progetto e quali sono le versioni di tali crate richieste. In questo caso, specifichiamo il crate rand con lo specificatore di versione semantica 0.8.5. Cargo comprende il Versionamento Semantico (a volte chiamato SemVer per brevità), che è uno standard per la scrittura dei numeri di versione. Lo specificatore 0.8.5 è in realtà un’abbreviazione di ^0.8.5, che indica qualsiasi versione che sia almeno 0.8.5 ma inferiore a 0.9.0.

Cargo considera queste versioni con API pubbliche compatibili con la versione 0.8.5 e questa specifica ti garantisce di ottenere l’ultima release della patch che si compila ancora con il codice di questo capitolo. Qualsiasi versione 0.9.0 o superiore non garantisce di avere le stesse API utilizzate negli esempi seguenti.

Ora, senza modificare alcun codice, costruiamo il progetto, come mostrato nel Listato 2-2.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
Listato 2-2: L’output dall’esecuzione di cargo build dopo l’aggiunta del crate rand come dipendenza

Potresti vedere numeri di versione diversi (ma saranno tutti compatibili con il codice, grazie a SemVer!) e righe diverse (a seconda del sistema operativo) e le righe potrebbero essere in un ordine diverso.

Quando includiamo una dipendenza esterna, Cargo recupera le ultime versioni di tutto ciò di cui la dipendenza ha bisogno dal registro, registry, che è una copia dei dati di Crates.io. Crates.io è il sito in cui le persone che fanno parte dell’ecosistema Rust pubblicano i loro progetti Rust open source che possono essere utilizzati da altri.

Dopo aver aggiornato il registro, Cargo controlla la sezione [dependencies] e scarica tutti i crate elencati che non sono già stati scaricati. In questo caso, anche se abbiamo elencato solo rand come dipendenza, Cargo ha preso anche altri crate da cui rand dipende per funzionare. Dopo aver scaricato i crate, Rust li compila e poi compila il progetto con le dipendenze disponibili.

Se esegui immediatamente cargo build di nuovo senza apportare alcuna modifica, non otterrai alcun risultato a parte la riga Finished. Cargo sa che ha già scaricato e compilato le dipendenze e che non hai modificato nulla nel tuo file Cargo.toml. Cargo sa anche che non hai modificato nulla del tuo codice, quindi non ricompila nemmeno quello. Non avendo nulla da fare, semplicemente termina l’esecuzione.

Se apri il file src/main.rs, apporti una modifica banale e poi salvi e ricostruisci, vedrai solo due righe di output:

$ cargo build
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

Queste righe mostrano che Cargo ricompila solo le modifiche, il file src/main.rs. Le dipendenze non sono cambiate, quindi Cargo sa di poter riutilizzare ciò che ha già scaricato e compilato in precedenza.

Garantire Build Riproducibili

Cargo ha un meccanismo che ti garantisce di ricostruire lo stesso artefatto ogni volta che tu o chiunque altro costruisce il tuo codice: Cargo utilizzerà solo le versioni delle dipendenze che hai specificato fino a quando non indicherai il contrario. Per esempio, supponiamo che la prossima settimana esca la versione 0.8.6 del crate rand, che contiene un’importante correzione di un bug, ma anche una regressione incompatibile con il tuo codice. Per gestire questo problema, Rust crea il file Cargo.lock la prima volta che esegui cargo build, che quindi ora si trova nella directory gioco_indovinello.

Quando costruisci un progetto per la prima volta, Cargo calcola tutte le versioni delle dipendenze che soddisfano i criteri e le scrive nel file Cargo.lock. Quando costruisci il tuo progetto in futuro, Cargo vedrà che il file Cargo.lock esiste e userà le versioni specificate in esso, invece di fare tutto il lavoro per trovare di nuovo le versioni. In altre parole, il tuo progetto rimarrà alla versione 0.8.5 fino a quando non effettuerai un aggiornamento esplicito, grazie al file Cargo.lock. Poiché il file Cargo.lock è importante per la creazione di build riproducibili, spesso viene inserito nel controllo sorgente insieme al resto del codice del progetto.

Aggiornare un Crate per Ottenere una Nuova Versione

Quando vuoi aggiornare un crate, Cargo mette a disposizione il comando update, che ignorerà il file Cargo.lock e troverà tutte le ultime versioni che corrispondono alle tue specifiche in Cargo.toml. Cargo scriverà quindi queste versioni nel file Cargo.lock. Altrimenti, di default, Cargo cercherà solo le versioni maggiori di 0.8.5 e minori di 0.9.0. Se il crate rand ha rilasciato nuove versioni sia per la versione 0.8 che per la 0.9, vedrai quanto segue se eseguirai cargo update:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo ignora la versione 0.9.0. A questo punto, noterai anche un cambiamento nel tuo file Cargo.lock che indica che la versione del crate rand che stai utilizzando è la 0.8.6. Per utilizzare la versione 0.9.0 di rand o qualsiasi altra versione della serie 0.9.x, dovrai aggiornare il file Cargo.toml in questo modo:

[dependencies]
rand = "0.9.0"

La prossima volta che eseguirai cargo build, Cargo aggiornerà il registro dei crate disponibili e rivaluterà i requisiti di rand in base alla nuova versione che hai specificato.

C’è molto altro da dire su Cargo e sul suo ecosistema, di cui parleremo nel Capitolo 14, ma per ora questo è tutto ciò che devi sapere. Cargo rende molto facile il riutilizzo delle librerie, per cui i Rustacean sono in grado di scrivere progetti più piccoli che sono assemblati da una serie di pacchetti.

Generare un Numero Casuale

Iniziamo a usare rand per generare un numero da indovinare. Il passo successivo è aggiornare src/main.rs, come mostrato nel Listato 2-3.

File: src/main.rs
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}
Listato 2-3: Aggiunta del codice per generare un numero casuale

Per prima cosa aggiungiamo la riga use rand::Rng;. Il trait Rng definisce i metodi che i generatori di numeri casuali implementano e questo trait deve essere nell’ambito di utilizzo (in scope d’ora in poi), per poter utilizzare tali metodi. Il Capitolo 10 tratterà in dettaglio i trait.

Nella prima riga, chiamiamo la funzione rand::thread_rng che ci fornisce il particolare generatore di numeri casuali che utilizzeremo: un generatore locale che si appoggia al sistema operativo. Poi chiamiamo il metodo gen_range sul generatore di numeri casuali. Questo metodo è definito dal trait Rng che abbiamo portato in scope con l’istruzione use rand::Rng;. Il metodo gen_range prende un’espressione di intervallo come argomento e genera un numero casuale nell’intervallo. Il tipo di espressione di intervallo che stiamo usando qui ha la forma inizio..=fine ed è inclusivo dei limiti inferiore e superiore, quindi dobbiamo specificare 1..=100 per richiedere un numero compreso tra 1 e 100.

Nota: non sarai sempre a conoscenza di quali trait utilizzare e quali metodi e funzioni chiamare di un crate, quindi ogni crate ha una documentazione con le istruzioni per utilizzarlo. Un’altra caratteristica interessante di Cargo è che eseguendo il comando cargo doc --open, la documentazione fornita da tutte le tue dipendenze viene creata localmente e aperta nel browser. Se sei interessato ad altre funzionalità del crate rand, ad esempio, esegui cargo doc --open e clicca su rand nella barra laterale a sinistra.

La seconda nuova riga stampa il numero segreto. Questo è utile durante lo sviluppo del programma per poterlo testare, ma lo elimineremo dalla versione finale. Non è un grande gioco se il programma stampa la risposta non appena inizia!

Prova a eseguire il programma alcune volte:

$ cargo run
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/gioco_indovinello`
Indovina il numero!
Il numero segreto è: 7
Inserisci la tua ipotesi.
4
Hai ipotizzato: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/gioco_indovinello`
Indovina il numero!
Il numero segreto è: 83
Inserisci la tua ipotesi.
5
Hai ipotizzato: 5

Dovresti ottenere diversi numeri casuali, tutti compresi tra 1 e 100. Ottimo lavoro!

Confrontare l’Ipotesi con il Numero Segreto

Ora che abbiamo l’input dell’utente e un numero casuale, possiamo confrontarli. Questo passo è mostrato nel Listato 2-4. Nota che questo codice non è compilabile per il momento, come spiegheremo.

File: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --taglio--
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");

    match ipotesi.cmp(&numero_segreto) {
        Ordering::Less => println!("Troppo piccolo!"),
        Ordering::Greater => println!("Troppo grande!"),
        Ordering::Equal => println!("Hai indovinato!"),
    }
}
Listato 2-4: Gestione dei possibili risultati della comparazione di due numeri

Per prima cosa aggiungiamo un’altra istruzione use, che porta un type chiamato std::cmp::Ordering dalla libreria standard. Il type Ordering è un’altra enum e ha le varianti Less, Greater e Equal. Questi sono i tre risultati possibili quando si confrontano due valori.

Poi aggiungiamo cinque nuove righe in basso che utilizzano il type Ordering. Il metodo cmp confronta due valori e può essere richiamato su qualsiasi cosa possa essere confrontata. Come parametro prende un reference a qualsiasi cosa si voglia confrontare: in questo caso sta confrontando ipotesi con numero_segreto. Poi restituisce una variante dell’enum Ordering che abbiamo portato nello scope con l’istruzione use. Utilizziamo un’espressione match per decidere cosa fare successivamente in base a quale variante di Ordering è stata restituita dalla chiamata a cmp con i valori in ipotesi e numero_segreto.

Un’espressione match è composta da due rami. Da una parte un pattern su cui fare il confronto, dall’altra il codice da eseguire se il valore dato a match corrisponde al pattern. Rust prende il valore dato a match e lo confronta con il _ pattern_ dei vari rami, eseguendo poi il codice se corrispondono. I pattern e il costrutto match sono potenti caratteristiche di Rust: ti permettono di esprimere una varietà di situazioni in cui il tuo codice potrebbe imbattersi e ti assicurano di gestirle tutte. Queste caratteristiche saranno trattate in dettaglio nel Capitolo 6 e nel Capitolo 19, rispettivamente.

Facciamo un esempio con l’espressione match che utilizziamo qui. Supponiamo che l’utente abbia ipotizzato 50 e che il numero segreto generato in modo casuale questa volta sia 38.

Quando il codice confronta 50 con 38, il metodo cmp restituirà Ordering::Greater perché 50 è maggiore di 38. L’espressione match ottiene il valore Ordering::Greater e inizia a controllare il pattern di ciascun ramo. Esamina il pattern del primo ramo, Ordering::Less, e vede che il valore Ordering::Greater non corrisponde a Ordering::Less, quindi ignora il codice in quel ramo e passa al ramo successivo. Il modello del ramo successivo è Ordering::Greater, che corrisponde a Ordering::Greater! Il codice associato in quel ramo verrà eseguito e stamperà Troppo grande! sullo schermo. L’espressione match termina dopo la prima corrispondenza riuscita, quindi non esaminerà l’ultimo ramo in questo scenario.

Tuttavia, il codice del Listato 2-4 non viene compilato. Proviamo:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&numero_segreto) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/cmp.rs:964:8

For more information about this error, try `rustc --explain E0308`.
error: could not compile `gioco_indovinello` (bin "gioco_indovinello") due to 1 previous error

Il messaggio di errore afferma che ci sono mismatched types (type non corrispondenti). Rust ha un sistema di type forte e statico. Tuttavia, ha anche l’inferenza del type. Quando abbiamo scritto let mut ipotesi = String::new(), Rust è stato in grado di dedurre che ipotesi doveva essere un String e non ci ha fatto scrivere il type. Il numero_segreto, d’altra parte, è un type numerico. Alcuni type numerici di Rust possono avere un valore compreso tra 1 e 100: i32, un numero a 32 bit; u32, un numero a 32 bit senza segno; i64, un numero a 64 bit; e altri ancora. Se non diversamente specificato, Rust imposta come predefinito un i32, che è il type di numero_segreto a meno che non si aggiungano informazioni sul type altrove che indurrebbero Rust a dedurre un type numerico differente. Il motivo dell’errore è che Rust non può confrontare una type stringa e un type numerico.

In definitiva, vogliamo convertire la String che il programma legge come input in un type numerico in modo da poterlo confrontare numericamente con il numero segreto. Lo facciamo aggiungendo questa riga al corpo della funzione main:

File: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    println!("Inserisci la tua ipotesi.");

    // --taglio--

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    let ipotesi: u32 = ipotesi.trim().parse().expect("Inserisci un numero!");

    println!("Hai ipotizzato: {ipotesi}");

    match ipotesi.cmp(&numero_segreto) {
        Ordering::Less => println!("Troppo piccolo!"),
        Ordering::Greater => println!("Troppo grande!"),
        Ordering::Equal => println!("Hai indovinato!"),
    }
}

La riga è:

let ipotesi: u32 = ipotesi.trim().parse().expect("Inserisci un numero!");

Creiamo una variabile di nome ipotesi. Ma aspetta, il programma non ha già una variabile di nome ipotesi? Sì, ma Rust ci permette di mettere in ombra, il valore precedente di ipotesi con uno nuovo. Lo Shadowing ci permette di riutilizzare il nome della variabile ipotesi invece di costringerci a creare due variabili uniche, come ipotesi_str e ipotesi, per esempio. Ne parleremo in modo più dettagliato nel Capitolo 3, ma per ora, sappi che questa funzione è spesso usata quando vuoi convertire un valore da un type ad un altro.

Leghiamo questa nuova variabile all’espressione ipotesi.trim().parse(). L’ipotesi nell’espressione si riferisce alla variabile ipotesi originale che contiene l’input come stringa. Il metodo trim su un’istanza di String elimina ogni spazio bianco ad inizio e fine, cosa da fare prima di convertire la stringa in u32, che può contenere solo dati numerici. L’utente deve premere invio per confermare l’input da terminale e questo aggiunge un carattere nuova_linea (newline d’ora in poi) alla stringa letta da read_line. Per esempio, se l’utente digita 5 e poi preme invio, ipotesi conterrà: 5\n. Il carattere \n rappresenta newline. (Su Windows, premere invio aggiunge anche il carattere di ritorno a capo oltre a newline, risultando in \r\n.) Il metodo trim elimina sia \n che \r\n, restituendo quindi solo 5.

Il metodo parse sulle stringhe converte una stringa in un altro type. In questo caso, lo usiamo per convertire una stringa in un numero. Dobbiamo indicare a Rust il tipo esatto di numero che vogliamo usando let ipotesi: u32. I due punti (:) dopo ipotesi dicono a Rust che annoteremo il tipo di variabile. Rust ha alcuni type numerici incorporati; u32 visto qui è un intero a 32 bit senza segno. È una buona scelta predefinita per un piccolo numero positivo. Imparerai a conoscere altri type numerici Capitolo 3.

Inoltre, l’annotazione u32 in questo programma di esempio e il confronto con numero_segreto significa che Rust dedurrà che anche numero_segreto dovrebbe essere un u32. Quindi ora il confronto sarà tra due valori con lo stesso type!

Il metodo parse funziona solo su caratteri che possono essere convertiti logicamente in numeri e quindi può facilmente causare errori. Se, ad esempio, la stringa contenesse A👍%, non ci sarebbe modo di convertirla in un numero. Poiché potrebbe fallire, il metodo parse restituisce un type Result, proprio come fa il metodo read_line (discusso in precedenza in “Gestire i Potenziali Errori con Result). Tratteremo questo Result allo stesso modo utilizzando nuovamente il metodo expect. Se parse restituisce una variante Err perché non è riuscito a creare un numero dalla stringa, la chiamata expect causerà il crash del gioco e stamperà il messaggio che gli abbiamo fornito. Se parse riesce a convertire la stringa in un numero, restituirà la variante Ok di Result e expect restituirà il numero che vogliamo dal valore Ok.

Ora eseguiamo il programma:

$ cargo run
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/gioco_indovinello`
Indovina il numero!
Il numero segreto è: 58
Inserisci la tua ipotesi.
  76
Hai ipotizzato: 76
Troppo grande!

Bene! Anche se sono stati aggiunti degli spazi prima del numero, il programma ha capito che l’utente aveva ipotizzato 76. Esegui il programma alcune volte per verificare il diverso comportamento con diversi tipi di input: ipotizzare il numero corretto, ipotizzare un numero troppo alto e ipotizzare un numero troppo basso.

Ora la maggior parte del gioco funziona, ma l’utente può fare una sola ipotesi. Cambiamo questa situazione aggiungendo un ciclo!

Consentire Più Ipotesi con la Ripetizione

La parola chiave loop (ripetizione) crea un ciclo infinito. Aggiungeremo un ciclo per dare agli utenti più possibilità di indovinare il numero:

File: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    // --taglio--

    println!("Il numero segreto è: {numero_segreto}");

    loop {
        println!("Inserisci la tua ipotesi.");

        // --taglio--


        let mut ipotesi = String::new();

        io::stdin()
            .read_line(&mut ipotesi)
            .expect("Errore di lettura");

        let ipotesi: u32 = ipotesi.trim().parse().expect("Inserisci un numero!");

        println!("Hai ipotizzato: {ipotesi}");

        match ipotesi.cmp(&numero_segreto) {
            Ordering::Less => println!("Troppo piccolo!"),
            Ordering::Greater => println!("Troppo grande!"),
            Ordering::Equal => println!("Hai indovinato!"),
        }
    }
}

Come puoi vedere, abbiamo spostato tutto ciò che va dalla richiesta di indovinare in poi all’interno di un ciclo. Assicurati di aggiungere degli spazi ad inizio riga per indentare correttamente il codice all’interno del ciclo ed esegui di nuovo il programma. Il programma ora chiederà sempre un’altra ipotesi, il che introduce un nuovo problema: come fa l’utente a smettere di giocare?

L’utente può sempre interrompere il programma utilizzando la scorciatoia da tastiera ctrl-C. Ma c’è un altro modo per sfuggire a questo mostro insaziabile, come accennato nella discussione su parse in “Confrontare l’ipotesi con il numero segreto”: se l’utente inserisce una risposta non numerica, il programma si blocca. Possiamo approfittarne per consentire all’utente di uscire, come mostrato qui:

$ cargo run
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/gioco_indovinello`
Indovina il numero!
Il numero segreto è: 59
Inserisci la tua ipotesi.
45
Hai ipotizzato: 45
Troppo piccolo!
Inserisci la tua ipotesi.
60
Hai ipotizzato: 60
Troppo grande!
Inserisci la tua ipotesi.
59
Hai ipotizzato: 59
Hai indovinato!
Inserisci la tua ipotesi.
esci

thread 'main' panicked at src/main.rs:28:47:
Inserisci un numero!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Digitando esci chiude il gioco, ma come noterai, anche l’inserimento di qualsiasi altro input che non sia un numero. Questo è a dir poco sub-ottimale: vogliamo che il gioco si fermi anche quando viene indovinato il numero corretto.

Uscire Dopo un’Ipotesi Corretta

Programmiamo il gioco in modo che esca quando l’utente vince, aggiungendo un’istruzione break (uscita):

File: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    loop {
        println!("Inserisci la tua ipotesi.");

        let mut ipotesi = String::new();

        io::stdin()
            .read_line(&mut ipotesi)
            .expect("Errore di lettura");

        let ipotesi: u32 = ipotesi.trim().parse().expect("Inserisci un numero!");

        println!("Hai ipotizzato: {ipotesi}");

        // --taglio--

        match ipotesi.cmp(&numero_segreto) {
            Ordering::Less => println!("Troppo piccolo!"),
            Ordering::Greater => println!("Troppo grande!"),
            Ordering::Equal => {
                println!("Hai indovinato!");
                break;
            }
        }
    }
}

L’aggiunta della riga break dopo Hai indovinato! fa sì che il programma esca dal ciclo quando l’utente indovina correttamente il numero segreto. Uscire dal ciclo significa anche uscire dal programma, perché il ciclo è l’ultima parte di main.

Gestire Gli Input Non Validi

Per perfezionare ulteriormente il comportamento del gioco, invece di mandare in crash il programma quando l’utente non inserisce un numero valido, facciamo in modo che il gioco ignori un valore non numerico in modo che l’utente possa continuare a indovinare. Possiamo farlo modificando la riga in cui ipotesi viene convertito da String in u32, come mostrato nel Listato 2-5.

File: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    loop {
        println!("Inserisci la tua ipotesi.");

        let mut ipotesi = String::new();

        // --taglio--

        io::stdin()
            .read_line(&mut ipotesi)
            .expect("Errore di lettura");

        let ipotesi: u32 = match ipotesi.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("Hai ipotizzato: {ipotesi}");

        // --taglio--

        match ipotesi.cmp(&numero_segreto) {
            Ordering::Less => println!("Troppo piccolo!"),
            Ordering::Greater => println!("Troppo grande!"),
            Ordering::Equal => {
                println!("Hai indovinato!");
                break;
            }
        }
    }
}
Listato 2-5: Ignorare un valore non numerico e continuare a chiedere un’ipotesi anziché terminare il programma

Passiamo da una chiamata expect a un’espressione match per passare dal crash su un errore alla gestione di quell’errore. Ricorda che parse restituisce un type Result e Result è un’enum che ha le varianti Ok e Err. Stiamo usando un’espressione match qui, come abbiamo fatto con il risultato Ordering del metodo cmp.

Se parse riesce a trasformare la stringa in un numero, restituirà un valore Ok che contiene il numero risultante. Questo valore Ok corrisponderà allo schema del primo ramo e l’espressione match restituirà il valore num che parse ha prodotto e messo all’interno del valore Ok. Quel numero finirà proprio dove vogliamo nella nuova variabile ipotesi che stiamo creando.

Se parse non riesce a trasformare la stringa in un numero, restituirà un valore Err che contiene ulteriori informazioni sull’errore. Il valore Err non corrisponde allo schema Ok(num) del primo ramo match, ma corrisponde allo schema Err(_) del secondo ramo. Il trattino basso, _, è un valore piglia-tutto; in questo esempio, stiamo dicendo che va bene qualsiasi valore di Err, indipendentemente dalle informazioni che contene. Quindi il programma eseguirà il codice del secondo ramo, continue, che dice al programma di passare alla successiva iterazione del loop e di chiedere un’altra ipotesi. Quindi, in effetti, il programma ignora tutti gli errori che parse potrebbe incontrare!

Ora tutto il programma dovrebbe funzionare come previsto. Proviamo:

$ cargo run
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/gioco_indovinello`
Indovina il numero!
Il numero segreto è: 61
Inserisci la tua ipotesi.
10
Hai ipotizzato: 10
Troppo piccolo!
Inserisci la tua ipotesi.
99
Hai ipotizzato: 99
Troppo grande!
Inserisci la tua ipotesi.
foo
Inserisci la tua ipotesi.
61
Hai ipotizzato: 61
Hai vinto!

Perfetto! Con un’ultima piccola modifica, finiremo il gioco di indovinelli. Ricorda che il programma continua a stampare il numero segreto. Questo funziona bene per testare il funzionamento, ma rovina il gioco. Eliminiamo il println! che produce il numero segreto. Il Listato 2-6 mostra il codice finale.

File: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Inserisci la tua ipotesi.");

        let mut ipotesi = String::new();

        io::stdin()
            .read_line(&mut ipotesi)
            .expect("Errore di lettura");

        let ipotesi: u32 = match ipotesi.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("Hai ipotizzato: {ipotesi}");

        match ipotesi.cmp(&numero_segreto) {
            Ordering::Less => println!("Troppo piccolo!"),
            Ordering::Greater => println!("Troppo grande!"),
            Ordering::Equal => {
                println!("Hai indovinato!");
                break;
            }
        }
    }
}
Listato 2-6: Codice finale del gioco completo

A questo punto, hai costruito con successo il gioco dell’indovinello: complimenti!

Riepilogo

Questo progetto è stato un modo pratico per introdurti a molti nuovi concetti di Rust: let, match, le funzioni, l’uso di crate esterni e altro ancora. Nei prossimi capitoli imparerai a conoscere questi concetti in modo più dettagliato. Il Capitolo 3 tratta i concetti che la maggior parte dei linguaggi di programmazione possiede, come le variabili, i tipi di dati e le funzioni, e mostra come utilizzarli in Rust. Il Capitolo 4 esplora la ownership (controllo esclusivo), una caratteristica che rende Rust diverso dagli altri linguaggi. Il Capitolo 5 parla delle strutture e della sintassi dei metodi, mentre il Capitolo 6 spiega come funzionano le enum.

Concetti Comuni di Programmazione

Questo capitolo tratta i concetti che appaiono in quasi tutti i linguaggi di programmazione e come funzionano in Rust. Molti linguaggi di programmazione hanno molti punti in comune. Nessuno dei concetti presentati in questo capitolo è esclusivo di Rust, ma li discuteremo nel contesto di Rust e spiegheremo le convenzioni per l’utilizzo di questi concetti.

In particolare, imparerai a conoscere le variabili, i type di base, le funzioni, i commenti e le strutture di controllo. Questi fondamenti saranno presenti in ogni programma di Rust e impararli presto ti darà una base solida da cui partire.

Parole Chiave

Il linguaggio Rust ha una serie di parole chiave che sono riservate all’uso esclusivo del linguaggio, proprio come in altri linguaggi. Tieni presente che non puoi usare queste parole come nomi di variabili o funzioni. La maggior parte delle parole chiave ha un significato speciale e le userai per svolgere varie attività nei tuoi programmi Rust; alcune non hanno alcuna funzionalità correntemente associata, ma sono state riservate per le funzionalità che potrebbero essere aggiunte a Rust in futuro. Puoi trovare l’elenco delle parole chiave nell’Appendice A.

Variabili e Mutabilità

Come accennato nella sezione “Memorizzare i valori con le Variabili”, come impostazione predefinita, le variabili sono immutabili. Questo è uno dei tanti stimoli che Rust ti dà per scrivere il tuo codice in modo da sfruttare la sicurezza e la facilità di concorrenza che Rust offre. Tuttavia, hai ancora la possibilità di rendere le tue variabili mutabili. Esploriamo come e perché Rust ti incoraggia a favorire l’immutabilità e perché a volte potresti voler rinunciare a questa cosa.

Quando una variabile è immutabile, una volta che un valore è legato a un nome, non è più possibile cambiarlo. Per vederlo con mano, genera un nuovo progetto chiamato variabili nella tua cartella progetti utilizzando cargo new variabili.

Ed ora, nella nuova cartella variabili, apri src/main.rs e sostituisci il suo codice con il seguente, che per il momento risulterà non compilabile:

File: src/main.rs

fn main() {
    let x = 5;
    println!("Il valore di x è: {x}");
    x = 6;
    println!("Il valore di x è: {x}");
}

Salva ed esegui il programma utilizzando cargo run. Dovresti ricevere un messaggio di errore relativo a un errore di immutabilità, come mostrato in questo output:

$ cargo run
   Compiling variabili v0.1.0 (file:///progetti/variabili)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("Il valore di x è: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variabili` (bin "variabili") due to 1 previous error

Questo esempio mostra come il compilatore ti aiuta a trovare gli errori nei tuoi programmi. Gli errori del compilatore possono essere frustranti, ma in realtà significano solo che il tuo programma non sta ancora facendo in modo sicuro ciò che vuoi; non significano che non sei un buon programmatore! Anche ai Rustacean più esperti appaiono errori del compilatore.

Hai ricevuto il messaggio di errore cannot assign twice to immutable variable perché hai cercato di assegnare un secondo valore alla variabile immutabile x.

È importante che ci vengano segnalati errori in tempo di compilazione quando si tenta di modificare un valore che è stato definito immutabile, perché proprio questa situazione può portare a dei bug. Se una parte del nostro codice opera sulla base del presupposto che un valore non cambierà mai e un’altra parte del codice modifica quel valore, è possibile che la prima parte del codice non faccia ciò per cui è stata progettata. La causa di questo tipo di bug può essere difficile da rintracciare a posteriori, soprattutto quando la seconda parte del codice modifica il valore solo qualche volta. Il compilatore di Rust garantisce che quando si afferma che un valore non cambierà, non cambierà davvero, quindi non dovrai tenerne traccia tu stesso. Il tuo codice sarà quindi più facile da analizzare.

Ma la mutabilità può essere molto utile e può rendere il codice più comodo da scrivere. Sebbene le variabili siano immutabili come impostazione predefinita, puoi renderle mutabili aggiungendo mut davanti al nome della variabile, come hai fatto nel Capitolo 2. L’aggiunta di mut rende anche palese quando si andrà a rileggere il codice in futuro che altre parti del codice cambieranno il valore di questa variabile.

Ad esempio, cambiamo src/main.rs con il seguente:

File: src/main.rs

fn main() {
    let mut x = 5;
    println!("Il valore di x è: {x}");
    x = 6;
    println!("Il valore di x è: {x}");
}

Quando eseguiamo il programma ora, otteniamo questo:

$ cargo run
   Compiling variabili v0.1.0 (file:///progetti/variabili)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.49s
     Running `target/debug/variabili`
Il valore di x è: 5
Il valore di x è: 6

Siamo autorizzati a cambiare il valore legato a x da 5 a 6 quando si usa mut. In definitiva, decidere se usare la mutabilità o meno dipende da te e da ciò che ritieni più utile in quella particolare situazione.

Dichiarare le Costanti

Come le variabili immutabili, le costanti sono valori legati a un nome che non possono essere modificati, ma ci sono alcune differenze tra le costanti e le variabili.

Innanzitutto, non puoi usare mut con le costanti. Le costanti non solo sono immutabili come impostazione predefinita, sono sempre immutabili. Dichiari le costanti usando la parola chiave const invece della parola chiave let e il type del valore deve essere annotato. Tratteremo i type e le annotazioni dei type nella prossima sezione, “Tipi di Dato”, quindi non preoccuparti dei dettagli in questo momento. Sappi solo che devi sempre annotare il type.

Le costanti possono essere dichiarate in qualsiasi scope, compreso quello globale, il che le rende utili per i valori che molte parti del codice devono conoscere.

L’ultima differenza è che le costanti possono essere impostate solo su un’espressione costante, non sul risultato di un valore che può essere calcolato solo in fase di esecuzione.

Ecco un esempio di dichiarazione di una costante:

#![allow(unused)]
fn main() {
const TRE_ORE_IN_SECONDI: u32 = 60 * 60 * 3;
}

Il nome della costante è TRE_ORE_IN_SECONDI e il suo valore è impostato come il risultato della moltiplicazione di 60 (il numero di secondi in un minuto) per 60 (il numero di minuti in un’ora) per 3 (il numero di ore che vogliamo contare in questo programma). La convenzione di Rust per la denominazione delle costanti prevede l’uso di maiuscole con trattini bassi tra le parole. Il compilatore è in grado di valutare il risultato di un’operazione in fase di compilazione, il che ci permette di scegliere di scrivere questo valore in un modo più facile da capire e da verificare, piuttosto che impostare questa costante al valore 10.800. Consulta la sezione Valutazione delle costanti (in inglese) per maggiori informazioni sulle operazioni che possono essere utilizzate quando si dichiarano le costanti.

Le costanti sono valide per tutto il tempo di esecuzione di un programma, all’interno dello scope in cui sono state dichiarate. Questa proprietà rende le costanti utili per dei valori nella tua applicazione che più parti del programma potrebbero avere bisogno di conoscere, come ad esempio il numero massimo di punti che un giocatore di un gioco può guadagnare o la velocità della luce.

Dichiarare come costanti i valori codificati usati nel tuo programma è utile per trasmettere il significato di quel valore a chi leggerà il codice in futuro. Inoltre, è utile per avere un solo punto del codice da modificare se il valore codificato deve essere aggiornato in futuro.

Shadowing

Come hai visto nel tutorial sul gioco dell’indovinello nel Capitolo 2, puoi dichiarare una nuova variabile con lo stesso nome di una variabile precedente. I Rustacean dicono che la prima variabile è messa in ombra, shadowing, dalla seconda, il che significa che la seconda variabile è quella che il compilatore vedrà quando userai il nome della variabile. In effetti, la seconda variabile mette in ombra la prima, portando a sé qualsiasi uso del nome della variabile fino a quando non sarà essa stessa messa in ombra o lo scope terminerà. Possiamo fare shadowing di una variabile usando lo stesso nome della variabile e ripetendo l’uso della parola chiave let come segue:

File: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("Il valore di x nello scope interno è: {x}");
    }

    println!("Il valore di x è: {x}");
}

Questo programma vincola innanzitutto x a un valore di 5. Poi crea una nuova variabile x ripetendo let x =, prendendo il valore originale e aggiungendo 1 in modo che il valore di x sia 6. Quindi, all’interno di uno scope interno creato con le parentesi graffe, la terza istruzione let mette in ombra x e crea una nuova variabile, moltiplicando il valore precedente per 2 per dare a x un valore di 12. Quando lo scope termina, finisce pure lo shadowing e x torna a essere 6. Quando si esegue questo programma, si ottiene il seguente risultato:

$ cargo run
   Compiling variabili v0.1.0 (file:///progetti/variabili)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.29s
     Running `target/debug/variabili`
Il valore di x nello scope interno è: 12
Il valore di x è: 6

Lo shadowing è diverso dall’indicare una variabile come mut perché otterremo un errore in fase di compilazione se cercassimo accidentalmente di riassegnare questa variabile senza usare la parola chiave let. Usando let, possiamo eseguire alcune trasformazioni su un valore ma far sì che la variabile sia immutabile dopo che le trasformazioni sono completate.

L’altra differenza tra mut e lo shadowing è che, poiché stiamo effettivamente creando una nuova variabile quando usiamo di nuovo la parola chiave let, possiamo cambiare il type del valore ma riutilizzare lo stesso nome. Ad esempio, supponiamo che il nostro programma chieda a un utente di scriverci quanti spazi vuole tra un testo e l’altro inserendo dei caratteri di spazio, e poi vogliamo memorizzare questo input come un numero:

fn main() {
    let spazi = "   ";
    let spazi = spazi.len();
}

La prima variabile spazi è di type stringa e la seconda variabile spazi è di type numerico. Lo shadowing ci evita quindi di dover inventare nomi diversi, come spazi_str e spazi_num; possiamo invece riutilizzare il nome più semplice spazi. Tuttavia, se proviamo a usare mut per fare questa cosa, come mostrato qui, otterremo un errore di compilazione:

fn main() {
    let mut spazi = "   ";
    spazi = spazi.len();
}

L’errore dice che non è consentito mutare il type di una variabile:

$ cargo run
   Compiling variabili v0.1.0 (file:///progetti/variabili)
error[E0308]: mismatched types
 --> src/main.rs:4:13
  |
3 |     let mut spazi = "   ";
  |                     ----- expected due to this value
4 |     spazi = spazi.len();
  |             ^^^^^^^^^^^ expected `&str`, found `usize`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variabili` (bin "variabili") due to 1 previous error

Ora che abbiamo visto il funzionamento delle variabili, passiamo in rassegna le varie tipologie di dato, type, che possono essere.

Tipi di Dato

Ogni valore in Rust è di un determinato type, il che dice a Rust che tipo di dati vengono specificati in modo che sappia come lavorare con quei dati. Esamineremo due sottoinsiemi di tipi di dati: scalare e composto. Tieni presente che Rust è un linguaggio tipizzato staticamente, il che significa che deve conoscere il type di tutte le variabili in fase di compilazione. Il compilatore di solito può dedurre quale type vogliamo utilizzare in base al valore e al modo in cui lo utilizziamo. Nei casi in cui sono possibili molteplici type, come quando abbiamo convertito uno String in un type numerico usando parse nella sezione “Confrontare l’ipotesi con il numero segreto” del Capitolo 2, dobbiamo aggiungere un’annotazione, specificando il type in questo modo:

fn main() {
    let ipotesi: u32 = "42".parse().expect("Non è un numero!");
}

Se non aggiungiamo l’annotazione del type : u32 mostrata nel codice precedente, Rust visualizzerà il seguente errore, il che significa che il compilatore ha bisogno di ulteriori informazioni per sapere quale type vogliamo utilizzare:

$ cargo build
   Compiling annotazione_senza_type v0.1.0 (file:///progetti/annotazione_senza_type)

error[E0284]: type annotations needed
 --> src/main.rs:3:9
  |
3 |     let ipotesi = "42".parse().expect("Non è un numero!");
  |         ^^^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `ipotesi` an explicit type
  |
3 |     let ipotesi: /* Type */ = "42".parse().expect("Non è un numero!");
  |                ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `annotazione_senza_type` (bin "annotazione_senza_type") due to 1 previous error; 1 warning emitted

Potrai vedere annotazioni di type diverso per altri tipi di dati.

I Type Scalari

Un type scalare rappresenta un singolo valore. Rust ha quattro type scalari primari: numeri interi, numeri in virgola mobile, booleani e caratteri. Potresti riconoscerli da altri linguaggi di programmazione. Andiamo a vedere come funzionano in Rust.

Il Type Intero

Un intero, integer d’ora in poi, è un numero senza una componente frazionaria. Nel Capitolo 2 abbiamo utilizzato un tipo integer, il type u32. Questa dichiarazione del type indica che il valore a cui è associato deve essere un integer senza segno (i type integer con segno iniziano con i invece che con u) che occupa 32 bit di spazio. La Tabella 3-1 mostra i type integer incorporati in Rust. Possiamo usare una qualsiasi di queste varianti per dichiarare il type di un valore intero.

Tabella 3-1: Type Integer in Rust

LunghezzaCon SegnoSenza Segno
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
in base all’Architetturaisizeusize

Ogni variante può essere con segno o senza e ha una dimensione esplicita. Con segno e senza segno si riferisce alla possibilità che il numero sia negativo. In altre parole, se il numero deve avere un segno con sé (signed in inglese) o se sarà sempre e solo positivo e potrà quindi essere rappresentato senza segno (unsigned in inglese). È come scrivere numeri su carta: quando il segno conta, un numero viene indicato con il segno più o con il segno meno; tuttavia, quando è lecito ritenere che il numero sia positivo, viene visualizzato senza segno. I numeri con segno vengono memorizzati utilizzando la rappresentazione del complemento a due.

Ogni variante con segno può memorizzare numeri da -(2n - 1) a 2n

  • 1 - 1 inclusi, dove n è il numero di bit che la variante utilizza. Quindi, un i8 può memorizzare numeri da -(27) a 27 - 1, il che equivale a -128 a 127. Le varianti senza segno possono memorizzare numeri da 0 a 2n - 1, quindi un u8 può memorizzare numeri da 0 a 2 8 - 1, il che equivale a 0 a 255.

Inoltre, i type isize e usize dipendono dall’architettura del computer su cui viene eseguito il programma: 64 bit se si tratta di un’architettura a 64 bit e 32 bit se si tratta di un’architettura a 32 bit.

Puoi scrivere i letterali integer in una qualsiasi delle forme mostrate nella Tabella 3-2. Nota che i letterali numerici che possono essere di più type numerici permettono, tramite un suffisso, di specificarne il type in questo modo 57u8. I letterali numerici possono anche usare _ come separatore visivo per rendere il numero più facile da leggere, come ad esempio 1_000, che avrà lo stesso valore che avrebbe se avessi specificato 1000.

Tabella 3-2: letterali Integer in Rust

Letterali numericiEsempio
Decimale98_222
Esadecimale0xff
Ottale0o77
Binario0b1111_0000
Byte (solo u8)b'A'

Come si fa a sapere quale type numerico utilizzare? Se non sei sicuro, le impostazioni predefinite di Rust sono in genere un buon punto di partenza: il type integer di default è i32. La situazione principale in cui puoi usare isize o usize è quando indicizzi qualche tipo di collezione.

Integer Overflow

Supponiamo di avere una variabile di type u8 che può contenere valori compresi tra 0 e 255. Se provi a cambiare la variabile con un valore al di fuori di questo intervallo, ad esempio 256, si verificherà un integer overflow, che può portare a uno dei due comportamenti seguenti. Quando stai compilando in modalità debug, Rust include controlli per l’integer overflow che fanno sì che il tuo programma vada in panico (panic d’ora in poi) in fase di esecuzione se si verifica questo comportamento. Rust usa il termine panic quando un programma termina con un errore; parleremo in modo più approfondito di panic nella sezione “Errori irreversibili con panic! nel Capitolo 9.

Quando si compila in modalità release con il flag --release, Rust non include i controlli per l’overflow degli integer che causano il panic. Invece, se si verifica l’overflow, Rust esegue l’avvolgimento del complemento a due. In pratica, i valori maggiori del valore massimo che il type può contenere si “avvolgono” fino al minimo dei valori che il type può contenere. Nel caso di un u8, il valore 256 diventa 0, il valore 257 diventa 1 e così via. Il programma non andrà in panic, ma la variabile avrà un valore che probabilmente non è quello che ci si aspettava che avesse. Affidarsi all’avvolgimento del complemento a due degli integer è considerato un errore. Per gestire esplicitamente la possibilità di overflow, puoi utilizzare queste famiglie di metodi forniti dalla libreria standard per i type numerici primitivi:

  • Racchiudere tutte le modalità con i metodi wrapping_*, come ad esempio wrapping_add.
  • Restituire il valore None se c’è overflow con i metodi checked_*.
  • Restituire il valore e un booleano che indica se c’è stato overflow con i metodi overflowing_*.
  • Saturare i valori minimi o massimi del valore con i metodi saturating_*.

Il Type a Virgola Mobile

Rust ha anche due type primitivi per i numeri in virgola mobile, abbreviato float in inglese, che sono numeri con punti decimali. I type in virgola mobile di Rust sono f32 e f64, rispettivamente di 32 e 64 bit. Il tipo predefinito è f64 perché sulle CPU moderne ha più o meno la stessa velocità di f32 ma è in grado di garantire una maggiore precisione. Tutti i type in virgola mobile sono con segno.

Ecco un esempio che mostra i numeri in virgola mobile in azione:

File: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

I numeri in virgola mobile sono rappresentati secondo lo standard IEEE-754.

Operazioni Numeriche

Rust supporta le operazioni matematiche di base che ti aspetteresti per tutte le tipologie di numero: addizione, sottrazione, moltiplicazione, divisione e resto. La divisione degli interi tronca verso lo zero al numero intero più vicino. Il codice seguente mostra come utilizzare ogni operazione numerica in una dichiarazione let:

File: src/main.rs

fn main() {
    // addizione
    let somma = 5 + 10;

    // sottrazione
    let differenza = 95.5 - 4.3;

    // multiplicazione
    let prodotto = 4 * 30;

    // divisione
    let quoziente = 56.7 / 32.2;
    let troncato = -5 / 3; // Restituisce -1

    // resto
    let resto = 43 % 5;
}

Ogni espressione in queste dichiarazioni utilizza un operatore matematico e valuta un singolo valore, che viene poi legato a una variabile. Appendice B contiene un elenco di tutti gli operatori che Rust mette a disposizione.

Il Type Booleano

Come nella maggior parte degli altri linguaggi di programmazione, un type booleano in Rust ha due valori possibili: vero o falso (true e false rispettivamente d’ora in poi). I booleani hanno la dimensione di un byte. Il type booleano in Rust viene specificato con bool. Ad esempio:

File: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // con specificazione del type
}

Il modo principale per utilizzare i valori booleani è attraverso i condizionali, come ad esempio un’espressione if. Tratteremo il funzionamento delle espressioni if in Rust nella sezione “Controllare il flusso”.

Il Type Carattere

Il type carattere (char d’ora in poi) di Rust è il tipo alfabetico più primitivo del linguaggio. Ecco alcuni esempi di dichiarazione di valori char:

File: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // con specificazione del type
    let gattino_innamorato = '😻';
}

Nota che specifichiamo i letterali char con le singole virgolette, al contrario dei letterali stringa, che utilizzano le virgolette doppie. Il type char di Rust ha la dimensione di 4 byte e rappresenta un valore scalare Unicode, il che significa che può rappresentare molte altre cose oltre all’ASCII. Le lettere accentate, i caratteri cinesi, giapponesi e coreani, le emoji e gli spazi a larghezza zero sono tutti valori char validi in Rust. I valori scalari Unicode vanno da U+0000 a U+D7FF e da U+E000 a U+10FFFF inclusi. Tuttavia, un “carattere” non è un concetto vero e proprio in Unicode, quindi quello che tu potresti concettualmente pensare essere un “carattere” potrebbe non corrispondere a cosa sia effettivamente un char in Rust. Discuteremo questo argomento in dettaglio in “Memorizzare testo codificato UTF-8 con le stringhe” nel Capitolo 8.

I Type Composti

I type composti possono raggruppare più valori in un unico type. Rust ha due type composti primitivi: le tuple e gli array.

Il Type Tupla

Una tupla è un modo generale per raggruppare una serie di valori di tipo diverso in un unico type composto. Le tuple hanno una lunghezza fissa: una volta dichiarate, non possono crescere o diminuire di dimensione. Creiamo una tupla scrivendo un elenco di valori separati da virgole all’interno di parentesi tonde. Ogni posizione nella tupla ha un type e i type dei diversi valori nella tupla non devono essere necessariamente gli stessi. In questo esempio abbiamo aggiunto annotazioni del type opzionali:

File: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

La variabile tup si lega all’intera tupla perché una tupla è considerata un singolo elemento composto. Per ottenere i singoli valori di una tupla, possiamo fare pattern matching per destrutturare il valore di una tupla, in questo modo:

File: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("Il valore di y è: {y}");
}

Questo programma crea prima una tupla e la associa alla variabile tup. Quindi utilizza un pattern con let per prendere tup e trasformarlo in tre variabili separate, x, y e z. Questa operazione è chiamata destrutturazione perché spezza la singola tupla in tre parti. Infine, il programma stampa il valore di y, che è 6,4.

Possiamo anche accedere direttamente a un elemento della tupla utilizzando un punto (.) seguito dall’indice del valore a cui vogliamo accedere. Ad esempio:

File: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let cinque_cento = x.0;

    let sei_virgola_quattro = x.1;

    let uno = x.2;
}

Questo programma crea la tupla x e poi accede a ogni elemento della tupla utilizzando i rispettivi indici. Come nella maggior parte dei linguaggi di programmazione, il primo indice di una tupla è 0.

La tupla senza valori ha un nome speciale, unit. Questo valore e il suo type corrispondente sono entrambi scritti () e rappresentano un valore vuoto o un type di ritorno vuoto. Le espressioni restituiscono implicitamente il valore unit se non restituiscono nessun altro valore.

Il Type Array

Un altro modo per avere una collezione di valori multipli è un array. A differenza di una tupla, ogni elemento di un array deve avere lo stesso type. A differenza degli array in altri linguaggi, gli array in Rust hanno una lunghezza fissa.

Scriviamo i valori di un array come un elenco separato da virgole all’interno di parentesi quadre:

File: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

Gli array sono utili quando vuoi che i tuoi dati siano allocati sullo stack, come gli altri type che abbiamo visto finora, piuttosto che nell’heap (parleremo dello stack e dell’heap in modo più approfondito nel Capitolo 4) o quando vuoi assicurarti di avere sempre un numero fisso di elementi. Un array, però, non è flessibile come il type vettore (vector d’ora in poi). Un vector è un type simile, che consente la collezione di dati, fornito dalla libreria standard ma che è autorizzato a crescere o a ridursi di dimensione perché il suo contenuto risiede nell’heap. Se non sei sicuro se usare un array o un vector, è probabile che tu debba usare un vector. Il Capitolo 8 tratta i vector in modo più dettagliato.

Tuttavia, gli array sono più utili quando sai che il numero di elementi non dovrà cambiare. Ad esempio, se dovessi utilizzare i nomi dei mesi in un programma, probabilmente utilizzeresti un array piuttosto che un vector perché sai che conterrà sempre 12 elementi:

#![allow(unused)]
fn main() {
let mesi = ["Gennaio", "Febbraio", "Marzo", "Aprile", "Maggio", "Giugno",
            "Luglio", "Agosto", "Settembre", "Ottobre", "Novembre", "Dicembre"];
}

Il type array si scrive utilizzando le parentesi quadre con il type di ogni elemento, il punto e virgola e il numero di elementi dell’array, in questo modo:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

In questo caso, i32 è il type di ogni elemento. Dopo il punto e virgola, il numero 5 indica che l’array contiene cinque elementi.

Puoi anche inizializzare un array in modo che contenga lo stesso valore per ogni elemento specificando il valore iniziale, seguito da un punto e virgola, e poi la lunghezza dell’array tra parentesi quadre, come mostrato qui:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

L’array chiamato a conterrà 5 elementi che saranno tutti impostati inizialmente al valore 3. Questo equivale a scrivere let a = [3, 3, 3, 3, 3]; ma in modo più conciso.

Accedere Agli Elementi dell’Array

Un array è un singolo blocco di memoria di dimensione fissa e nota che può essere allocato nello stack. Puoi accedere agli elementi di un array utilizzando l’indicizzazione, in questo modo:

File: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let primo = a[0];
    let secondo = a[1];
}

In questo esempio, la variabile denominata primo otterrà il valore 1 perché è il valore all’indice [0] dell’array. La variabile denominata secondo otterrà il valore 2 dall’indice [1] dell’array.

Accedere Agli Elementi Non Validi dell’Array

Vediamo cosa succede se cerchi di accedere a un elemento di un array che si trova oltre la fine dell’array stesso. Supponiamo di eseguire questo codice, simile al gioco di indovinelli del Capitolo 2, per ottenere un indice dell’array dall’utente:

File: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Digita un indice dell'array.");

    let mut indice = String::new();

    io::stdin()
        .read_line(&mut indice)
        .expect("Errore di lettura");

    let indice: usize = indice
        .trim()
        .parse()
        .expect("L'indice inserito non è un numero");

    let elemento = a[indice];

    println!("Il valore dell'elemento all'indice {indice} è: {elemento}");
}

Se esegui questo codice utilizzando cargo run e inserisci 0, 1, 2, 3 o 4, il programma stamperà il valore corrispondente a quell’indice nell’array. Se invece inserisci un numero oltre la fine dell’array, come ad esempio 10, vedrai un risultato come questo:

thread 'main' panicked at src/main.rs:19:20:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Il programma ha generato un errore durante l’esecuzione (at runtime in inglese) nel momento in cui ha utilizzato un valore non valido nell’operazione di indicizzazione. Il programma è uscito con un messaggio di errore e non ha eseguito l’istruzione finale println!. Quando si tenta di accedere a un elemento utilizzando l’indicizzazione, Rust controlla che l’indice specificato sia inferiore alla lunghezza dell’array. Se l’indice è maggiore o uguale alla lunghezza, Rust va in panic. Questo controllo deve avvenire durante l’esecuzione, soprattutto in questo caso, perché il compilatore non può sapere quale valore inserirà l’utente quando eseguirà il codice in seguito.

Questo è un esempio dei principi di sicurezza della memoria di Rust in azione. In molti linguaggi di basso livello, questo tipo di controllo non viene fatto e quando si fornisce un indice errato, si può accedere a una memoria non valida. Rust ti protegge da questo tipo di errore uscendo immediatamente invece di consentire l’accesso alla memoria e continuare. Il Capitolo 9 tratta di altri aspetti della gestione degli errori di Rust e di come puoi scrivere codice leggibile e sicuro che non va in panic né consente l’accesso non valido alla memoria.

Funzioni

Le funzioni sono molto diffuse nel codice di Rust. Hai già visto una delle funzioni più importanti del linguaggio: la funzione main, che è il punto di ingresso di molti programmi. Hai anche visto la parola chiave fn, che ti permette di dichiarare nuove funzioni.

Il codice Rust utilizza lo snake case come stile convenzionale per i nomi di funzioni e variabili, in cui tutte le lettere sono minuscole e i trattini bassi separano le parole. Ecco un programma che contiene un esempio di definizione di funzione:

File: src/main.rs

fn main() {
    println!("Hello, world!");

    altra_funzione();
}

fn altra_funzione() {
    println!("Un'altra funzione.");
}

In Rust definiamo una funzione inserendo fn seguito dal nome della funzione e da una serie di parentesi tonde. Le parentesi graffe indicano al compilatore dove inizia e finisce il corpo della funzione.

Possiamo chiamare qualsiasi funzione che abbiamo definito inserendo il suo nome seguito da una serie di parentesi tonde. Poiché altra_funzione è definita nel programma, può essere chiamata dall’interno della funzione main. Nota che abbiamo definito altra_funzione dopo la funzione main nel codice sorgente; avremmo potuto definirla anche prima. A Rust non interessa dove definisci le tue funzioni, ma solo che siano definite in una parte del codice che sia “visibile”, in scope, al chiamante.

Cominciamo un nuovo progetto binario chiamato funzioni per esplorare ulteriormente le funzioni. Inserisci l’esempio altra_funzione in src/main.rs ed eseguilo. Dovresti vedere il seguente output:

$ cargo run
   Compiling funzioni v0.1.0 (file:///progetti/funzioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.37s
     Running `target/debug/funzioni`
Hello, world!
Un'altra funzione.

Le righe vengono eseguite nell’ordine in cui appaiono nella funzione main. Prima viene stampato il messaggio “Hello, world!”, poi viene chiamata altra_funzione e viene stampato il suo messaggio.

Parametri

Possiamo definire le funzioni in modo che abbiano dei parametri, ovvero delle variabili speciali che fanno parte della firma di una funzione. Quando una funzione ha dei parametri, puoi fornirle dei valori concreti per questi parametri. Tecnicamente, i valori concreti sono chiamati argomenti, ma in una conversazione informale si tende a usare le parole parametro e argomento in modo intercambiabile, sia per le variabili nella definizione di una funzione che per i valori concreti passati quando si chiama una funzione.

In questa versione di altra_funzione aggiungiamo un parametro:

File: src/main.rs

fn main() {
    altra_funzione(5);
}

fn altra_funzione(x: i32) {
    println!("Il valore di x è: {x}");
}

Prova a eseguire questo programma; dovresti ottenere il seguente risultato:

$ cargo run
   Compiling funzioni v0.1.0 (file:///progetti/funzioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.36s
     Running `target/debug/funzioni`
Il valore di x è: 5

La dichiarazione di altra_funzione ha un parametro chiamato x. Il type di x è specificato come i32. Quando passiamo 5 ad altra_funzione, la macro println! mette 5 nel punto in cui si trovava la coppia di parentesi graffe contenente x nella stringa di formato.

Nelle firme delle funzioni è obbligatorio dichiarare il type di ogni parametro. Si tratta di una decisione deliberata nel design di Rust: richiedere le annotazioni sul type nelle definizioni delle funzioni significa che il compilatore non ha quasi mai bisogno che tu le usi in altre parti del codice per capire a quale type ti riferisci. In questo modo il compilatore potrà anche dare messaggi di errore più utili se sa quali type si aspetta la funzione.

Quando definisci più parametri, separa le dichiarazioni dei parametri con delle virgole, in questo modo:

File: src/main.rs

fn main() {
    stampa_unita_misura(5, 'h');
}

fn stampa_unita_misura(valore: i32, unita_misura: char) {
    println!("La misura è : {valore}{unita_misura}");
}

Questo esempio crea una funzione chiamata stampa_unita_misura con due parametri. Il primo parametro si chiama valore ed è un i32. Il secondo si chiama unita_misura ed è di type char. La funzione stampa quindi un testo contenente sia il valore che unita_misura.

Eseguiamo il codice. Sostituisci il codice attualmente presente nel file src/main.rs_ del tuo progetto funzioni con l’esempio precedente ed eseguilo con cargo run:

$ cargo run
   Compiling funzioni v0.1.0 (file:///progetti/funzioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/funzioni`
La misura è : 5h

Poiché abbiamo chiamato la funzione con 5 come valore per valore e 'h' come valore per unita_misura, l’output del programma contiene questi valori.

Dichiarazioni ed Espressioni

I corpi delle funzioni sono costituiti da una serie di dichiarazioni che possono eventualmente terminare con un’espressione. Finora le funzioni che abbiamo trattato non hanno incluso un’espressione finale, ma hai visto un’espressione come parte di una dichiarazione. Poiché Rust è un linguaggio basato sulle espressioni, questa è una distinzione importante da capire. Altri linguaggi non hanno le stesse distinzioni, quindi vediamo cosa sono le dichiarazioni e le espressioni e come le loro differenze influenzano il corpo delle funzioni.

  • Le dichiarazioni sono istruzioni che eseguono un’azione e non restituiscono un valore.
  • Le espressioni vengono valutate e restituiscono un valore risultante.

Vediamo alcuni esempi.

In realtà abbiamo già usato le dichiarazioni e le espressioni. Creare una variabile e assegnarle un valore con la parola chiave let è una dichiarazione. Nel Listato 3-1, let y = 6; è una dichiarazione.

File: src/main.rs
fn main() {
    let y = 6;
}
Listato 3-1: La funzione main contenente una dichiarazione

Anche la definizione di una funzione è una dichiarazione; l’intero esempio precedente è, di per sé, una dichiarazione. (Come vedremo più avanti, però, chiamare una funzione non è una dichiarazione.)

Le dichiarazioni non restituiscono valori. Pertanto, non puoi assegnare una dichiarazione let a un’altra variabile, come cerca di fare il codice seguente; otterrai un errore:

File: src/main.rs

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

Quando esegui questo programma, l’errore che otterrai è simile a questo:

$ cargo run
   Compiling funzioni v0.1.0 (file:///progetti/funzioni)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `funzioni` (bin "funzioni") generated 1 warning
error: could not compile `funzioni` (bin "funzioni") due to 1 previous error; 1 warning emitted

La dichiarazione let y = 6 non restituisce un valore, quindi non c’è nulla a cui x possa legarsi. Questo è diverso da ciò che accade in altri linguaggi, come C e Ruby, dove l’assegnazione restituisce il valore dell’assegnazione. In questi linguaggi, puoi scrivere x = y = 6 e far sì che sia x che y abbiano il valore 6; questo non è il caso di Rust.

Le espressioni che valutate restituiscono un valore costituiscono la maggior parte del resto del codice che scriverai in Rust. Considera un’operazione matematica, come 5 + 6, che è un’espressione che restituisce il valore 11. Le espressioni possono far parte di dichiarazioni: nel Listato 3-1, il 6 nella dichiarazione let y = 6; è un’espressione che valuta il valore 6. Chiamare una funzione è un’espressione. Chiamare una macro è un’espressione. Pure definire tramite parentesi graffe un nuovo scope ad esempio:

File: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("Il valore di y è: {y}");
}

Questa espressione:

{
    let x = 3;
    x + 1
}

è un blocco che, in questo caso, valuta 4. Questo valore viene legato a y come parte dell’istruzione let. Nota che la riga x + 1 non ha un punto e virgola alla fine, il che è diverso dalla maggior parte delle righe che hai visto finora. Le espressioni non includono il punto e virgola finale. Se aggiungi un punto e virgola alla fine di un’espressione, la trasformi in una dichiarazione e quindi non restituirà un valore. Tienilo a mente mentre leggi il prossimo paragrafo sui valori di ritorno delle funzioni e le espressioni.

Funzioni con Valori di Ritorno

Le funzioni possono restituire dei valori al codice che le chiama. Non assegniamo un nome ai valori di ritorno, ma dobbiamo esplicitarne il type dopo una freccia (->). In Rust, il valore di ritorno della funzione è sinonimo del valore dell’espressione finale nel blocco del corpo della funzione. Puoi far ritornare un valore anche in anticipo alla funzione usando la parola chiave return e specificando un valore, ma la maggior parte delle funzioni restituisce l’ultima espressione in modo implicito. Ecco un esempio di funzione che restituisce un valore:

File: src/main.rs

fn cinque() -> i32 {
    5
}

fn main() {
    let x = cinque();

    println!("Il valore di x è: {x}");
}

Non ci sono chiamate di funzione, macro o dichiarazioni let nella funzione cinque, ma solo il numero 5 da solo. Si tratta di una funzione perfettamente valida in Rust. Nota che anche il type di ritorno della funzione è specificato come -> i32. Prova a eseguire questo codice; l’output dovrebbe essere simile a questo:

$ cargo run
   Compiling funzioni v0.1.0 (file:///progetti/funzioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.34s
     Running `target/debug/funzioni`
Il valore di x è: 5

Il 5 in cinque è il valore di ritorno della funzione, motivo per cui il type di ritorno è i32. Esaminiamo il tutto più in dettaglio. Ci sono due elementi importanti: innanzitutto, la riga let x = cinque(); mostra che stiamo utilizzando il valore di ritorno di una funzione per inizializzare una variabile. Poiché la funzione cinque restituisce un 5, questa riga è uguale alla seguente:

#![allow(unused)]
fn main() {
let x = 5;
}

In secondo luogo, la funzione cinque non ha parametri e definisce il type del valore di ritorno, ma il corpo della funzione è un solitario 5 senza punto e virgola perché è un’espressione il cui valore vogliamo restituire.

Vediamo un altro esempio:

File: src/main.rs

fn main() {
    let x = più_uno(5);

    println!("Il valore di x è: {x}");
}

fn più_uno(x: i32) -> i32 {
    x + 1
}

Eseguendo questo codice verrà stampato Il valore di x è: 6. Ma che succede se inseriamo un punto e virgola alla fine della riga contenente x + 1, trasformandola da espressione a dichiarazione?

File: src/main.rs

fn main() {
    let x = più_uno(5);

    println!("Il valore di x è: {x}");
}

fn più_uno(x: i32) -> i32 {
    x + 1;
}

La compilazione di questo codice produrrà un errore, come segue:

$ cargo run
   Compiling funzioni v0.1.0 (file:///progetti/funzioni)
error[E0308]: mismatched types
 --> src/main.rs:7:23
  |
7 | fn più_uno(x: i32) -> i32 {
  |    -------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

For more information about this error, try `rustc --explain E0308`.
error: could not compile `funzioni` (bin "funzioni") due to 1 previous error

Il messaggio di errore principale, mismatched types (type incompatibili), rivela il problema principale di questo codice. La definizione della funzione più_uno dice che restituirà un i32, ma le dichiarazioni non risultano in un valore, restituendo un (), il type unit. Pertanto, non viene restituito nulla, il che contraddice la definizione della funzione e provoca un errore. In questo output, Rust fornisce un messaggio che può aiutare a correggere questo problema: suggerisce di rimuovere il punto e virgola, che risolverebbe l’errore.

Commenti

Tutti i programmatori si sforzano di rendere il loro codice facile da capire, ma a volte è necessario fornire ulteriori spiegazioni. In questi casi, i programmatori lasciano dei commenti nel loro codice sorgente che il compilatore ignorerà ma che chi legge il codice sorgente potrebbe trovare utili.

Ecco un semplice commento:

#![allow(unused)]
fn main() {
// hello, world
}

In Rust, lo stile idiomatico di commento inizia un commento con due barre oblique, slash in inglese, e il commento continua fino alla fine della riga. Per i commenti che si estendono oltre una singola riga, dovrai includere // su ogni riga, come in questo caso:

#![allow(unused)]
fn main() {
// Stiamo facendo qualcosa di complicato, tanto da aver bisogno di
// più righe di commento per farlo! Speriamo che questo commento possa
// spiegare cosa sta succedendo.
}

I commenti possono essere inseriti anche alla fine delle righe contenenti codice:

File: src/main.rs

fn main() {
    let numero_fortunato = 7; // Oggi mi sento fortunato
}

Ma più spesso li vedrai utilizzati in questo formato, con il commento su una riga separata sopra il codice che sta annotando:

File: src/main.rs

fn main() {
    // Oggi mi sento fortunato
    let numero_fortunato = 7;
}

Rust ha anche un altro tipo di commento, i commenti alla documentazione, di cui parleremo nella sezione “Pubblicazione di un Crate su Crates.io” del Capitolo 14.

Controllare il Flusso

La possibilità di eseguire del codice a seconda che una condizione sia vera e la possibilità di eseguire ripetutamente del codice finché una data condizione è vera sono elementi fondamentali della maggior parte dei linguaggi di programmazione. I costrutti più comuni che ti permettono di controllare il flusso di esecuzione del codice in Rust sono le espressioni if e i cicli.

L’Espressione if

Un’espressione if (se in italiano) ti permette di ramificare il tuo codice a seconda delle condizioni. Fornisci una condizione e poi dici: “Se questa condizione è soddisfatta, esegui questo blocco di codice. Se la condizione non è soddisfatta, non eseguire questo blocco di codice”.

Crea un nuovo progetto chiamato ramificazioni nella tua directory progetti per sperimentare con l’espressione if. Nel file src/main.rs, inserisci quanto segue:

File: src/main.rs

fn main() {
    let numero = 3;

    if numero < 5 {
        println!("condizione era vera");
    } else {
        println!("condizione era falsa");
    }
}

Tutte le espressioni if iniziano con la parola chiave if, seguita da una condizione. In questo caso, la condizione verifica se la variabile numero ha o meno un valore inferiore a 5. Il blocco di codice da eseguire se la condizione è true viene posizionato subito dopo la condizione, all’interno di parentesi graffe. I blocchi di codice associati alle condizioni nelle espressioni if possono esser viste come dei rami, proprio come i rami nelle espressioni match di cui abbiamo parlato nella sezione “Confrontare l’ipotesi con il numero segreto” del Capitolo 2.

Opzionalmente, possiamo anche includere un’espressione else (altrimenti in italiano), come abbiamo scelto di fare in questo caso, per dare al programma un blocco di codice alternativo da eseguire nel caso in cui la condizione sia valutata false. Se non fornisci un’espressione else e la condizione è false, il programma salterà il blocco if e passerà alla parte di codice successiva.

Prova a eseguire questo codice; dovresti vedere il seguente risultato:

$ cargo run
   Compiling ramificazioni v0.1.0 (file:///progetti/ramificazioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.06s
     Running `target/debug/ramificazioni`
condizione era vera

Proviamo a cambiare il valore di numero con un valore che renda la condizione false per vedere cosa succede:

fn main() {
    let numero = 7;

    if numero < 5 {
        println!("condizione era vera");
    } else {
        println!("condizione era falsa");
    }
}

Esegui nuovamente il programma e guarda l’output:

$ cargo run
   Compiling ramificazioni v0.1.0 (file:///progetti/ramificazioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/ramificazioni`
condizione era falsa

Vale anche la pena di notare che la condizione in questo codice deve essere un bool. Se la condizione non è un bool, otterremo un errore. Ad esempio, prova a eseguire il seguente codice:

File: src/main.rs

fn main() {
    let numero = 3;

    if numero {
        println!("numero era tre");
    }
}

Questa volta la condizione if valuta un valore di 3 e Rust lancia un errore:

$ cargo run
   Compiling ramificazioni v0.1.0 (file:///progetti/ramificazioni)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if numero {
  |        ^^^^^^ expected `bool`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `ramificazioni` (bin "ramificazioni") due to 1 previous error

L’errore indica che Rust si aspettava un bool ma ha ottenuto un numero intero. A differenza di linguaggi come Ruby e JavaScript, Rust non cercherà automaticamente di convertire i type non booleani in booleani. Devi essere esplicito e fornire sempre ad if un’espressione booleana come condizione. Se vogliamo che il blocco di codice if venga eseguito solo quando un numero non è uguale a 0, ad esempio, possiamo modificare l’espressione if nel seguente modo:

File: src/main.rs

fn main() {
    let numero = 3;

    if numero != 0 {
        println!("numero era qualcosa di diverso da zero");
    }
}

L’esecuzione di questo codice stamperà numero era qualcosa di diverso da zero.

Gestire Condizioni Multiple con else if

Puoi utilizzare condizioni multiple combinando if e else in un’espressione else if. Ad esempio:

File: src/main.rs

fn main() {
    let numero = 6;

    if numero % 4 == 0 {
        println!("numero è divisibile per 4");
    } else if numero % 3 == 0 {
        println!("numero è divisibile per 3");
    } else if numero % 2 == 0 {
        println!("numero è divisibile per 2");
    } else {
        println!("numero non è divisibile per by 4, 3, o 2");
    }
}

Questo programma ha quattro possibili rami. Dopo averlo eseguito, dovresti vedere il seguente output:

$ cargo run
   Compiling ramificazioni v0.1.0 (file:///progetti/ramificazioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.36s
     Running `target/debug/ramificazioni`
numero è divisibile per 3

Quando questo programma viene eseguito, controlla ogni espressione if a turno ed esegue il primo corpo per il quale la condizione è valutata true. Nota che anche se 6 è divisibile per 2, non vediamo l’output numero è divisibile per 2, né vediamo il testo numero non è divisibile per 4, 3 o 2 del blocco else. Questo perché Rust esegue il blocco solo per la prima condizione true e una volta che ne trova una, le restanti non vengono controllate.

L’uso di troppe espressioni else if può rendere il codice un po’ confusionario e difficile da leggere, quindi se ne hai più di una, potresti valutare di riscrivere il codice. Il Capitolo 6 descrive un potente costrutto di ramificazione di Rust chiamato match per gestire casi del genere.

Utilizzare if in Una Dichiarazione let

Dato che if è un’espressione, possiamo usarla a destra di una dichiarazione let per assegnare il risultato a una variabile, come nel Listato 3-2.

File: src/main.rs
fn main() {
    let condizione = true;
    let numero = if condizione { 5 } else { 6 };

    println!("Il valore di numero è: {numero}");
}
Listato 3-2: Assegnazione del risultato di un’espressione if as una variabile

La variabile numero sarà legata a un valore basato sul risultato dell’espressione if. Esegui questo codice per vedere cosa succede:

$ cargo run
   Compiling ramificazioni v0.1.0 (file:///progetti/ramificazioni)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.40s
     Running `target/debug/ramificazioni`
Il valore di numero è: 5

Ricorda che i blocchi di codice valutano l’ultima espressione in essi contenuta e i numeri da soli sono anch’essi espressioni. In questo caso, il valore dell’intera espressione if dipende da quale blocco di codice viene eseguito. Ciò significa che i valori che possono essere i risultati di ogni ramo di if devono essere dello stesso tipo; nel Listato 3-2, i risultati sia del ramo if che del ramo else erano numeri interi i32. Se i type non sono corrispondenti, come nell’esempio seguente, otterremo un errore:

File: src/main.rs

fn main() {
    let condizione = true;

    let numero = if condizione { 5 } else { "sei" };

    println!("Il valore di numero è: {numero}");
}

Quando proviamo a compilare questo codice, otterremo un errore: i rami if e else hanno type incompatibili e Rust indica esattamente dove trovare il problema nel programma:

$ cargo run
   Compiling ramificazioni v0.1.0 (file:///progetti/ramificazioni)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:45
  |
4 |     let numero = if condizione { 5 } else { "sei" };
  |                                  -          ^^^^^ expected integer, found `&str`
  |                                  |
  |                                  expected because of this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `ramificazioni` (bin "ramificazioni") due to 1 previous error

L’espressione nel blocco if ritorna un integer e l’espressione nel blocco else ritorna una stringa. Questo non funziona perché le variabili devono avere un type univoco e Rust ha bisogno di sapere definitivamente in fase di compilazione di che type è la variabile numero. Conoscere il type di numero permette al compilatore di verificare che il type sia valido ovunque si utilizzi numero. Rust non sarebbe in grado di farlo se il type di numero fosse determinato solo in fase di esecuzione; il compilatore sarebbe più complesso e darebbe meno garanzie sul codice se dovesse tenere traccia dei più disparati type possibili per ogni variabile.

Ripetizione con i Cicli

Spesso è utile eseguire un blocco di codice più di una volta. Per questo compito, Rust mette a disposizione diversi cicli (loop in inglese), che eseguono il codice all’interno del corpo del ciclo fino alla fine e poi ripartono immediatamente dall’inizio. Per sperimentare con i cicli, creiamo un nuovo progetto chiamato cicli.

Rust mette a disposizione tre tipologie di ciclo: loop, while e for. Proviamo ciascuno di essi.

Ripetere il Codice con loop

La parola chiave loop dice a Rust di eseguire un blocco di codice più e più volte per sempre o finché non gli dici esplicitamente di fermarsi.

A titolo di esempio, modifica il file src/main.rs nella tua cartella cicli in questo modo:

File: src/main.rs

fn main() {
    loop {
        println!("ancora!");
    }
}

Quando eseguiamo questo programma, vedremo ancora! stampato in continuazione fino a quando non interromperemo il programma manualmente. La maggior parte dei terminali supporta la scorciatoia da tastiera ctrl-C per interrompere un programma che è bloccato in un ciclo continuo. Provaci:

$ cargo run
   Compiling cicli v0.1.0 (file:///progetti/cicli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/cicli`
ancora!
ancora!
ancora!
ancora!
^C!

Il simbolo ^C rappresenta quando hai premuto ctrl-C.

Potresti vedere o meno la parola ancora! stampata dopo la ^C, a seconda di dove si trovava il codice nel ciclo quando ha ricevuto il segnale di interruzione.

Fortunatamente, Rust offre anche un modo per uscire da un ciclo utilizzando del codice. Puoi inserire la parola chiave break all’interno del ciclo per indicare al programma quando interrompere l’esecuzione del ciclo. Ricorda che abbiamo fatto questo nel gioco di indovinelli nella sezione “Uscire dopo un’ipotesi corretta” del Capitolo 2 per uscire dal programma quando l’utente indovinava il numero segreto.

Nel gioco di indovinelli abbiamo usato anche continue, che in un ciclo indica al programma di saltare tutto il codice rimanente in questa iterazione del ciclo e di passare all’iterazione successiva.

Restituire Valori dai Cicli

Uno degli utilizzi di un loop è quello di riprovare un’operazione che sai che potrebbe fallire, come ad esempio controllare se un thread ha completato il suo lavoro. Potresti anche aver bisogno di passare il risultato di questa operazione al di fuori del ciclo al resto del tuo codice. Per farlo, puoi aggiungere il valore che vuoi che venga restituito dopo l’espressione break che utilizzi per interrompere il ciclo; quel valore verrà restituito al di fuori del ciclo in modo da poterlo utilizzare, come mostrato qui:

fn main() {
    let mut contatore = 0;

    let risultato = loop {
        contatore += 1;

        if contatore == 10 {
            break contatore * 2;
        }
    };

    println!("Il risultato è {risultato}");
}

Prima del ciclo, dichiariamo una variabile chiamata contatore e la inizializziamo a 0. Poi dichiariamo una variabile chiamata risultato per contenere il valore restituito dal ciclo. A ogni iterazione del ciclo, aggiungiamo 1 alla variabile contatore e poi controlliamo se contatore è uguale a 10. Quando lo è, usiamo la parola chiave break con il valore contatore * 2. Dopo il ciclo, usiamo un punto e virgola per terminare l’istruzione che assegna il valore a risultato. Infine, stampiamo il valore in risultato, che in questo caso è 20.

Puoi anche usare return all’interno di un ciclo. Mentre break esce solo dal ciclo corrente, return esce sempre dalla funzione corrente.

Distinguere con le Etichette di Loop

Se hai un ciclo annidato all’interno di un altro ciclo, break e continue si applicano al loop più interno in quel momento. Puoi specificare facoltativamente un’etichetta (loop label) su uno specifico ciclo per poi usare con break o continue quell’etichetta per specificare a quale ciclo applicare l’istruzione. Le loop label devono iniziare con una virgoletta singola. Ecco un esempio con due cicli annidati:

fn main() {
    let mut conteggio = 0;
    'aumenta_conteggio: loop {
        println!("conteggio = {conteggio}");
        let mut rimanente = 10;

        loop {
            println!("rimanente = {rimanente}");
            if rimanente == 9 {
                break;
            }
            if conteggio == 2 {
                break 'aumenta_conteggio;
            }
            rimanente -= 1;
        }

        conteggio += 1;
    }
    println!("Fine conteggio = {conteggio}");
}

Il ciclo esterno ha la label 'aumenta_conteggio e conta da 0 a 2. Il ciclo interno senza label conta da 10 a 9. Il primo break che non specifica una label esce solo dal ciclo interno. L’istruzione break 'aumenta_conteggio; esce dal ciclo esterno. Questo codice stamperà:

$ cargo run
   Compiling cicli v0.1.0 (file:///progetti/cicli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.35s
     Running `target/debug/cicli`
conteggio = 0
rimanente = 10
rimanente = 9
conteggio = 1
rimanente = 10
rimanente = 9
conteggio = 2
rimanente = 10
Fine conteggio = 2

Semplificare Cicli Condizionali con while

Spesso un programma ha bisogno di valutare una condizione all’interno di un ciclo. Quando la condizione è true, il ciclo viene eseguito. Quando la condizione cessa di essere true, il programma chiama break, interrompendo il ciclo. È possibile implementare un comportamento del genere utilizzando una combinazione di loop, if, else e break; se vuoi, puoi provare a farlo in un programma. Tuttavia, questo schema è così comune che Rust ha un costrutto di linguaggio incorporato per questi casi, chiamato ciclo while (finché in italiano). Nel Listato 3-3, usiamo while per eseguire il ciclo del programma tre volte, contando alla rovescia ogni volta, e poi, dopo il ciclo, stampiamo un messaggio e usciamo.

File: src/main.rs
fn main() {
    let mut numero = 3;

    while numero != 0 {
        println!("{numero}!");

        numero -= 1;
    }

    println!("PARTENZA!!!");
}
Listato 3-3: Utilizzo di un ciclo while per eseguire codice finché la condizione è true

Questo costrutto elimina un sacco di annidamenti che sarebbero necessari se usassi loop, if, else e break, ed è di più semplice lettura. Finché una condizione risulta true, il codice viene eseguito; altrimenti, esce dal ciclo.

Eseguire un Ciclo su una Collezione con for

Puoi scegliere di utilizzare il costrutto while per eseguire un ciclo sugli elementi di una collezione, come un array. Ad esempio, il ciclo nel Listato 3-4 stampa ogni elemento dell’array a.

File: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut indice = 0;

    while indice < 5 {
        println!("il valore è: {}", a[indice]);

        indice += 1;
    }
}
Listato 3-4: Passare in rassegna gli elementi di una collezione con un ciclo while

In questo caso, il codice conteggia tutti gli elementi dell’array: inizia dall’indice 0 e poi esegue un ciclo fino a raggiungere l’ultimo indice dell’array (cioè quando indice < 5 non è più true). L’esecuzione di questo codice stamperà ogni elemento dell’array:

$ cargo run
   Compiling cicli v0.1.0 (file:///progetti/cicli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.36s
     Running `target/debug/cicli`
il valore è: 10
il valore è: 20
il valore è: 30
il valore è: 40
il valore è: 50

Tutti e cinque i valori dell’array appaiono nel terminale, come previsto. Anche se indice raggiungerà un valore di 5 a un certo punto, il ciclo viene bloccato prima che si tenti di leggere un sesto elemento dell’array.

Tuttavia, questo approccio è incline all’errore; potremmo causare il panic del programma se il valore dell’indice o la condizione di test non sono corretti. Per esempio, se cambiassi la definizione dell’array a per avere quattro elementi, ma dimenticassi di aggiornare la condizione a while indice < 4, il codice andrebbe in panic. È anche lento, perché il compilatore aggiunge codice di esecuzione per eseguire il controllo condizionale per verificare se l’indice è entro i limiti dell’array a ogni iterazione del ciclo.

Come alternativa più concisa, puoi usare un ciclo for ed eseguire del codice per ogni elemento di una collezione. Un ciclo for assomiglia al codice del Listato 3-5.

File: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];

    for elemento in a {
        println!("il valore è: {elemento}");
    }
}
Listato 3-5: Passare in rassegna gli elementi di una collezione con un ciclo for

Quando eseguiamo questo codice, vedremo lo stesso risultato del Listato 3-4. Ma, cosa più importante, abbiamo aumentato la sicurezza del codice ed eliminato la possibilità di bug che potrebbero derivare dall’andare oltre la fine dell’array o dal non accedere ad ogni elemento dell’array. Il codice macchina generato dai cicli for può essere anche più efficiente, perché l’indice non deve essere confrontato con la lunghezza dell’array a ogni iterazione.

Utilizzando il ciclo for, non dovrai ricordarti di modificare altro codice se cambierai il numero di valori nell’array, come invece faresti con il metodo while usato nel Listato 3-4.

La sicurezza e la concisione dei cicli for li rendono il costrutto di ciclo più usato in Rust. Anche nelle situazioni in cui vuoi eseguire un certo numero di volte il codice, come nell’esempio del conto alla rovescia che utilizzava un ciclo while nel Listato 3-3, la maggior parte dei Rustacean userebbe un ciclo for. Il modo per farlo sarebbe quello di usare un Range, fornito dalla libreria standard, che genera tutti i numeri in sequenza partendo da un numero e finendo prima di un altro numero.

Ecco come apparirebbe il conto alla rovescia utilizzando un ciclo for e un altro metodo di cui non abbiamo ancora parlato, rev, per invertire l’intervallo.

File: src/main.rs

fn main() {
    for numero in (1..4).rev() {
        println!("{numero}!");
    }
    println!("PARTENZA!!!");
}

Questo codice è un po’ più carino, vero?

Riepilogo

Ce l’hai fatta! Questo capitolo è stato molto impegnativo: hai imparato a conoscere le variabili, i type di dati scalari e composti, le funzioni, i commenti, le espressioni if e i cicli! Per esercitarti con i concetti discussi in questo capitolo, prova a costruire dei programmi per eseguire le seguenti operazioni:

  • Convertire le temperature tra Fahrenheit e Celsius.
  • Generare l’n-esimo numero di Fibonacci.
  • Stampare il testo del canto natalizio “The Twelve Days of Christmas”, sfruttando la ripetizione della canzone.

Quando sarai pronto per andare avanti, parleremo di un concetto di Rust che non esiste in altri linguaggi di programmazione: la ownership.

Capire la Ownership

Il controllo esclusivo o proprietà (la ownership d’ora in poi) è la caratteristica più qualificante di Rust e ha profonde implicazioni per il resto del linguaggio. Permette a Rust di garantire la sicurezza dei dati in memoria senza bisogno di un garbage collector, quindi è importante capire come funziona la ownership. In questo capitolo parleremo della ownership e di diverse caratteristiche correlate: il prestito (borrowing d’ora in poi), le slice e il modo in cui Rust dispone i dati in memoria.

Cos’è la Ownership?

La ownership è un insieme di regole che disciplinano la gestione della memoria da parte di un programma Rust. Tutti i programmi devono gestire il modo in cui utilizzano la memoria del computer durante l’esecuzione. Alcuni linguaggi hanno una garbage collection che cerca regolarmente la memoria non più utilizzata durante l’esecuzione del programma; in altri linguaggi, il programmatore deve allocare e rilasciare esplicitamente la memoria. Rust utilizza un terzo approccio: la memoria viene gestita attraverso un sistema di controllo esclusivo con un insieme di regole che il compilatore controlla. Se una qualsiasi delle regole viene violata, il programma non viene compilato. Nessuna delle caratteristiche di ownership rallenterà il tuo programma mentre è in esecuzione.

Poiché la ownership è un concetto nuovo per molti programmatori, ci vuole un po’ di tempo per abituarsi. La buona notizia è che più si acquisisce esperienza con Rust e con le regole del sistema di ownership, più sarà facile sviluppare naturalmente codice sicuro ed efficiente. Non mollare!

Quando capirai la ownership, avrai una solida base per comprendere le caratteristiche che rendono Rust unico. In questo capitolo imparerai la ownership lavorando su alcuni esempi che si concentrano su una struttura di dati molto comune: le stringhe.

Lo Stack e l’Heap

Molti linguaggi di programmazione non richiedono di pensare allo stack e all’heap molto spesso. Ma in un linguaggio di programmazione di sistema come Rust, il fatto che un valore sia sullo stack o nell’heap influisce sul comportamento del linguaggio e sul motivo per cui devi prendere determinate decisioni.

Sia lo stack che l’heap sono parti di memoria disponibili per il codice da utilizzare in fase di esecuzione, ma sono strutturate in modi diversi. Lo stack memorizza i valori nell’ordine in cui li ottiene e rimuove i valori nell’ordine opposto. Questo viene definito last in, first out (LIFO). Pensa a una pila di piatti: quando aggiungi altri piatti, li metti in cima alla pila e quando ti serve un piatto, ne togli uno dalla cima. Aggiungere o rimuovere i piatti dal centro o dal fondo non funzionerebbe altrettanto bene! L’aggiunta di dati sullo stack viene definita push (immissione), mentre la rimozione dei dati viene definita pop (estrazione). Tutti i dati archiviati sullo stack devono avere una dimensione nota e fissa. I dati con una dimensione sconosciuta in fase di compilazione o una dimensione che potrebbe cambiare devono invece essere archiviati nell’heap.

L’heap è meno organizzato: quando metti i dati nell’heap, richiedi una certa quantità di spazio. L’allocatore di memoria trova un punto vuoto nell’heap che sia sufficientemente grande, lo contrassegna come in uso e restituisce un puntatore, che è l’indirizzo di quella posizione. Questo processo è chiamato allocazione nell’heap e talvolta è abbreviato semplicemente in allocazione (l’inserimento di valori sullo stack non è considerato allocazione). Poiché il puntatore all’heap ha una dimensione nota e fissa, è possibile archiviare il puntatore sullo stack, ma quando si desiderano i dati effettivi è necessario seguire il puntatore. Pensa di essere seduto in un ristorante. Quando entri, indichi il numero di persone nel tuo gruppo e il cameriere trova un tavolo vuoto adatto a tutti e ti conduce lì. Se qualcuno nel tuo gruppo arriva in ritardo, può chiedere dove sei seduto e trovarti.

Il push sullo stack è più veloce dell’allocazione nell’heap perché l’allocatore non deve mai cercare un posto dove archiviare i nuovi dati; quella posizione è sempre in cima allo stack. In confronto, l’allocazione dello spazio nell’heap richiede più lavoro perché l’allocatore deve prima trovare uno spazio sufficientemente grande per contenere i dati e quindi eseguire la contabilità per prepararsi all’allocazione successiva.

L’accesso ai dati nell’heap è più lento dell’accesso ai dati sullo stack perché è necessario leggere un puntatore sullo stack per poi “saltare” all’indirizzo di memoria nell’heap per accedere ai dati. I processori attuali sono più veloci se non “saltano” troppo in giro per la memoria. Continuando l’analogia, consideriamo un cameriere in un ristorante che prende ordini da molti tavoli. È più efficiente ricevere tutti gli ordini su un tavolo prima di passare al tavolo successivo. Prendere un ordine dal tavolo A, poi un ordine dal tavolo B, poi ancora uno da A e poi ancora uno da B sarebbe un processo molto più lento. Allo stesso modo, un processore può svolgere meglio il proprio lavoro se lavora su dati vicini ad altri dati (come sono sullo stack) piuttosto che più lontani (come possono essere nell’heap).

Quando il codice chiama una funzione, i valori passati alla funzione (inclusi, potenzialmente, puntatori ai dati nell’heap) e le variabili locali della funzione vengono inseriti sullo stack. Quando la funzione termina, tali valori vengono estratti, pop, sullo _stack.

Tenere traccia di quali parti del codice utilizzano quali dati nell’heap, ridurre al minimo la quantità di dati duplicati nell’heap e ripulire i dati inutilizzati nell’heap in modo da non esaurire la memoria sono tutti problemi che la ownership risolve. Una volta compresa la ownership, non sarà necessario pensare molto spesso allo stack e all’heap, ma comprendere che lo scopo principale della ownership è gestire i dati dell’heap può aiutare a capire perché funziona in questo modo.

Regole di Ownership

Per prima cosa, diamo un’occhiata alle regole di ownership, tenendole a mente mentre lavoriamo agli esempi che le illustrano:

  • Ogni valore in Rust ha un proprietario, owner.
  • Ci può essere un solo owner alla volta.
  • Quando l’owner esce dallo scope, il valore viene rilasciato.

Scope delle Variabili

Ora che abbiamo visto e imparato la sintassi di base di Rust, non includeremo tutto il codice fn main() { negli esempi, quindi se stai seguendo, assicurati di inserire manualmente i seguenti esempi all’interno di una funzione main. Di conseguenza, i nostri esempi saranno un po’ più concisi, permettendoci di concentrarci sui dettagli reali piuttosto che sul codice di base.

Come primo esempio di ownership, analizzeremo lo scope di alcune variabili. Lo scope è l’ambito all’interno di un programma nel quale un elemento è valido. Prendiamo la seguente variabile:

#![allow(unused)]
fn main() {
let s = "ciao";
}

La variabile s si riferisce a un letterale stringa, il cui valore è codificato nel testo del nostro programma. La variabile è valida dal momento in cui viene dichiarata fino alla fine dello scope corrente. Il Listato 4-1 mostra un programma con commenti che annotano i punti in cui la variabile s sarebbe valida (in scope).

fn main() {
    {                      // `s` non è valida qui, perché non ancora dichiarata
        let s = "hello";   // `s` è valida da questo punto in poi

        // fai cose con `s`
    }                      // questo scope è finito, e `s` non è più valida
}
Listato 4-1: Una variabile e lo scope in cui è valida

In altre parole, ci sono due momenti importanti:

  • Quando s entra nello scope, è valida;
  • Rimane valida fino a quando non esce dallo scope.

A questo punto, la relazione tra scope e validità delle variabili è simile a quella di altri linguaggi di programmazione. Ora ci baseremo su questa comprensione introducendo il type String.

Il Type String

Per illustrare le regole di ownership, abbiamo bisogno di un tipo di dati più complesso di quelli trattati nel Capitolo 3. I type trattati in precedenza hanno dimensioni note, possono essere inseriti e estratti sullo stack quando il loro scope è terminato, possono essere rapidamente copiati per creare una nuova istanza indipendente se un’altra parte del codice deve utilizzare lo stesso valore in uno scope diverso. Ma vogliamo esaminare i dati archiviati nell’heap e capire come Rust sa quando ripulire la memoria che quei dati usavano quando non serve più, e il type String è un ottimo esempio da cui partire.

Ci concentreremo sulle parti di String che riguardano la ownership. Questi aspetti si applicano anche ad altri type di dati complessi, siano essi forniti dalla libreria standard o creati dall’utente. Parleremo di String oltre l’aspetto della ownership nel Capitolo 8.

Abbiamo già visto i letterali stringa, in cui un valore stringa è codificato nel nostro programma. I letterali stringa sono convenienti, ma non sono adatti a tutte le situazioni in cui potremmo voler utilizzare del testo. Uno dei motivi è che sono immutabili. Un altro è che non tutti i valori di stringa possono essere conosciuti quando scriviamo il nostro codice: ad esempio, cosa succederebbe se volessimo prendere l’input dell’utente e memorizzarlo? È per queste situazioni che Rust ha un il type String. Questo type gestisce i dati allocati nell’heap e come tale è in grado di memorizzare una quantità di testo a noi sconosciuta in fase di compilazione. Puoi creare un type String partendo da un letterale stringa utilizzando la funzione from, in questo modo:

#![allow(unused)]
fn main() {
let s = String::from("ciao");
}

L’operatore double colon (doppio - due punti) :: ci permette di integrare questa particolare funzione from nel type String piuttosto che usare un nome come string_from. Parleremo di questa sintassi nella sezione “Sintassi dei metodi” del Capitolo 5 e quando parleremo di come organizzare la nomenclatura nei moduli in “Percorsi per fare riferimento a un elemento nell’albero dei moduli” nel Capitolo 7.

Questo tipo di stringa può essere mutata:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() aggiunge un letterale a una String

    println!("{s}");        // verrà stampato `hello, world!`
}

Allora, qual è la differenza? Perché String può essere mutata ma i letterali no? La differenza sta nel modo in cui questi due type vengono gestiti in memoria.

Memoria e Allocazione

Nel caso di un letterale stringa, conosciamo il contenuto al momento della compilazione, quindi il testo è codificato direttamente nell’eseguibile finale. Per questo motivo i letterali stringa sono veloci ed efficienti. Ma queste proprietà derivano solo dall’immutabilità del letterale stringa. Sfortunatamente, non possiamo inserire una porzione di memoria indefinita nel binario per ogni pezzo di testo la cui dimensione è sconosciuta al momento della compilazione e la cui dimensione potrebbe cambiare durante l’esecuzione del programma.

Con il type String, per supportare una porzione di testo mutabile e espandibile, dobbiamo allocare una quantità di memoria nell’heap, sconosciuta in fase di compilazione, per contenere il contenuto. Questo significa che:

  • La memoria deve essere richiesta all’allocatore di memoria in fase di esecuzione.
  • Abbiamo bisogno di un modo per restituire questa memoria all’allocatore quando abbiamo finito con la nostra String.

La prima parte la facciamo noi: quando chiamiamo String::from, la sua implementazione richiede la memoria di cui ha bisogno. Questo è praticamente universale nei linguaggi di programmazione.

Tuttavia, la seconda parte è diversa. Nei linguaggi con un garbage collector (GC), il GC tiene traccia e ripulisce la memoria che non viene più utilizzata e non abbiamo bisogno di pensarci. Nella maggior parte dei linguaggi senza GC, è nostra responsabilità identificare quando la memoria non viene più utilizzata e chiamare il codice per de-allocarla esplicitamente, proprio come abbiamo fatto per richiederla. Farlo correttamente è stato storicamente un difficile problema di programmazione. Se ce lo dimentichiamo, sprecheremo memoria. Se lo facciamo troppo presto, avremo una variabile non valida. Se lo facciamo due volte, anche questo è un bug. Dobbiamo accoppiare esattamente un’allocazione con esattamente una de-allocazione (o rilascio, liberazione).

Rust prende una strada diversa: la memoria viene rilasciata automaticamente una volta che la variabile che la possiede esce dallo scope. Ecco una versione del nostro esempio sullo scope che utilizza una String invece di un letterale stringa:

fn main() {
    {
        let s = String::from("hello"); // `s` è valida da questo punto in poi

        // fai cose con `s`
    }   // questo scope è finito, e `s` non è più valida
}

C’è un punto naturale in cui possiamo rilasciare la memoria di cui la nostra String ha bisogno all’allocatore: quando s esce dallo scope. Quando una variabile esce dallo scope, Rust chiama per noi una funzione speciale. Questa funzione si chiama drop, ed è dove l’autore di String può inserire il codice per rilasciare la memoria. Rust chiama drop automaticamente alla parentesi graffa di chiusura.

Nota: in C++, questo schema di de-allocazione delle risorse alla fine del ciclo di vita di un elemento è talvolta chiamato Resource Acquisition Is Initialization (RAII). La funzione drop di Rust ti sarà familiare se hai usato gli schemi RAII.

Questo schema ha un profondo impatto sul modo in cui viene scritto il codice Rust. Può sembrare semplice in questo momento, ma il comportamento del codice può essere inaspettato in situazioni più complicate quando vogliamo che più variabili utilizzino i dati che abbiamo allocato nell’heap. Esploriamo ora alcune di queste situazioni.

Interazione tra Variabili e Dati con Move

In Rust, più variabili possono interagire con gli stessi dati in modi diversi. Il Listato 4-2 mostra un esempio che utilizza un integer.

fn main() {
    let x = 5;
    let y = x;
}
Listato 4-2: Assegnazione del valore integer della variabile x a y

Probabilmente possiamo indovinare cosa sta facendo: “Associa il valore 5 a x; quindi crea una copia del valore in x e associala a y.” Ora abbiamo due variabili, x e y, ed entrambe uguali a 5. Questo è effettivamente ciò che sta accadendo. Poiché gli integer sono valori semplici con una dimensione fissa e nota, questi due valori 5 vengono immessi sullo stack.

Ora diamo un’occhiata alla versione con String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

Sembra molto simile, quindi potremmo pensare che il funzionamento sia lo stesso: cioè che la seconda riga faccia una copia del valore in s1 e lo assegni a s2. Ma non è esattamente quello che succede.

Nella Figura 4-1 diamo un’occhiata sotto le coperte per vedere com’è in realtà una String. Una String è composta da tre parti, mostrate a sinistra: un puntatore (ptr) alla memoria che contiene il contenuto della stringa, una lunghezza (len) e una capienza (capacity). Questo gruppo di dati è memorizzato sullo stack. A destra c’è la memoria nell’heap che contiene il contenuto.

Due
tabelle: la prima tabella contiene la rappresentazione di s1 nello stack,
composta dalla lunghezza (5), capienza (5), e un puntatore al primo valore della
seconda tabella. La seconda tabella contiene una rappresentazione del contenuto
della stringa nell’heap, byte per byte.

Figura 4-1: La representazione in memoria di una String con valore "hello" assegnato a s1

La lunghezza è la quantità di memoria, in byte, utilizzata attualmente dal contenuto della String. La capienza è la quantità totale di memoria, in byte, che String ha ricevuto dall’allocatore. La differenza tra lunghezza e capacità è importante, ma non in questo contesto, quindi per ora va bene ignorare la capienza.

Quando assegniamo s1 a s2, i dati String vengono copiati, ovvero copiamo il puntatore, la lunghezza e la capienza presenti sullo stack. Non copiamo i dati nell’heap a cui fa riferimento il puntatore. In altre parole, la rappresentazione dei dati in memoria è simile alla Figura 4-2.

Tre
tabelle: tabella s1 e s2 rappresentano quelle stringhe nello stack,
indipendentemente, ed entrambe puntano agli stessi dati della stringa
nell’heap.

Figura 4-2: La rappresentazione in memoria della variabile s2 che contiene una copia del puntatore, lunghezza e capienza di s1

La rappresentazione non assomiglia alla Figura 4-3, che è l’aspetto che avrebbe la memoria se Rust copiasse anche i dati dell’heap. Se Rust facesse così, l’operazione s2 = s1 potrebbe diventare molto dispendiosa in termini prestazionali e di memoria qualora i dati nell’heap fossero di grandi dimensioni.

Quattro
tabelle: due tabelle rappresentano i dati sullo stack di s1 e s2, ognuna delle
quali punta alla propria copia di dati nell’heap.

Figura 4-3: Un’altra possibilità di come potrebbe essere s2 = s1 se Rust copiasse anche i dati dell’heap

In precedenza, abbiamo detto che quando una variabile esce dallo scope, Rust chiama automaticamente la funzione drop e ripulisce la memoria nell’heap di quella variabile. Ma la Figura 4-2 mostra entrambi i puntatori di dati che puntano alla stessa posizione. Questo è un problema: quando s2 e s1 escono dallo scope, entrambe tenteranno di de-allocare la stessa memoria. Questo è noto come errore da doppia de-allocazione (double free error in inglese) ed è uno dei bug di sicurezza della memoria menzionati in precedenza. Rilasciare la memoria due volte può portare a corruzione di memoria, che può potenzialmente esporre il programma a vulnerabilità di sicurezza.

Per garantire la sicurezza della memoria, dopo la riga let s2 = s1;, Rust considera s1 come non più valido. Pertanto, Rust non ha bisogno di de-allocare nulla quando s1 esce dallo scope. Controlla cosa succede quando provi a usare s1 dopo che s2 è stato creato: non funzionerà:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Otterrai un errore di questo tipo perché Rust ti impedisce di utilizzare il riferimento invalidato:

$ cargo run
   Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:6:16
  |
3 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 |     let s2 = s1;
  |              -- value moved here
5 |
6 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Se hai sentito i termini copia superficiale e copia profonda mentre lavoravi con altri linguaggi, il concetto di copiare il puntatore, la lunghezza e la capacità senza copiare i dati probabilmente ti sembrerà simile a una copia superficiale. Ma poiché Rust invalida anche la prima variabile, invece di essere chiamata copia superficiale, questa operazione è nota come move (spostamento). In questo esempio, diremmo che s1 è stata spostata in s2. Quindi, ciò che accade in realtà è mostrato nella Figura 4-4.

Tre
tabelle: tabelle s1 e s2 rappresentano rispettivamente le quelle stringhe sullo
stack, ed entrambe puntano alla medesima stringa nell’heap. Tabella s1 è scurita
perché s1 non è più valida; solo s2 può essere usata per accedere ai dati
nell’heap.

Figura 4-4: La rappresentazione in memoria dopo che s1 è resa non valida

Questo risolve il nostro problema! Con la sola s2 valida, quando essa uscirà dallo scope, solo lei rilascerà la memoria e il gioco è fatto.

Inoltre, c’è una scelta progettuale implicita in questo: Rust non creerà mai automaticamente copie “profonde” dei tuoi dati. Pertanto, si può presupporre che qualsiasi copia automatica sia poco dispendiosa in termini prestazionali e di memoria.

Scope e Assegnazione

L’opposto di ciò è vero anche per la relazione tra scope, ownership e memoria rilasciata tramite la funzione drop. Quando assegni un valore completamente nuovo a una variabile esistente, Rust chiamerà drop e libererà immediatamente la memoria del valore originale. Considera questo codice, ad esempio:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ciao");

    println!("{s}, world!");
}

Inizialmente dichiariamo una variabile s e la associamo a una String con il valore "hello". Poi creiamo immediatamente una nuova String con il valore "ciao" e la assegniamo a s. A questo punto, non c’è più nulla che faccia riferimento al valore originale nell’heap. La Figura 4-5 mostra i dati sullo stack e nell’heap al momento:

Una tabella
rappresenta la stringa sullo stack, che punta ai dati della stringa (ciao)
nell’heap, con i dati della stringa originale (hello) scuriti perché non più
accessibili.

Figura 4-5: La rappresentazione in memoria dopo che il primo valore è completamente sostituito.

La stringa originale esce così immediatamente dallo scope. Rust eseguirà la funzione drop su di essa e la sua memoria verrà rilasciata immediatamente. Quando stamperemo il valore alla fine, sarà "ciao, world!".

Interazione tra Variabili e Dati con Clone

Se vogliamo effettivamente duplicare i dati nell’heap della String, e non solo i dati sullo stack, possiamo utilizzare un metodo comune chiamato clone. Parleremo della sintassi dei metodi nel Capitolo 5, ma dato che i metodi sono una caratteristica comune a molti linguaggi di programmazione, probabilmente li hai già visti.

Ecco un esempio del metodo clone in azione:

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

    println!("s1 = {s1}, s2 = {s2}");
}

Questo funziona benissimo e produce esplicitamente il comportamento mostrato nella Figura 4-3, in cui i anche i dati dell’heap vengono duplicati.

Quando vedi una chiamata a clone, sai che viene eseguito del codice arbitrario e che questo potrebbe essere dispendioso. È un indicatore visivo del fatto che sta succedendo qualcosa di diverso.

Duplicare Dati Sullo Stack

C’è un’altra peculiarità di cui non abbiamo ancora parlato: questo codice che utilizza gli integer, in parte mostrato nel Listato 4-2, funziona ed è valido

fn main() {
    let x = 5;
    let y = x;

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

Ma questo codice sembra contraddire ciò che abbiamo appena imparato: non abbiamo una chiamata a clone, ma x è ancora valido e non è stato spostato in y.

Il motivo è che i type come gli integer che hanno una dimensione nota in fase di compilazione vengono archiviati interamente sullo stack, quindi le copie dei valori effettivi sono veloci da creare. Ciò significa che non c’è motivo per cui vorremmo impedire che x sia valido dopo aver creato la variabile y. In altre parole, qui non c’è differenza tra copia profonda e superficiale, quindi chiamare clone non farebbe nulla di diverso dalla solita copia superficiale e possiamo tralasciarlo.

Rust ha un’annotazione speciale chiamata tratto Copy che possiamo appiccicare sui type memorizzati sullo stack, come lo sono gli integer (parleremo meglio dei tratti nel Capitolo 10). Se un type implementa il tratto Copy, le variabili che lo utilizzano non si spostano, ma vengono semplicemente copiate, rendendole ancora valide dopo l’assegnazione ad un’altra variabile.

Rust non ci permette di annotare un type con Copy se il type, o una qualsiasi delle sue parti, ha implementato il tratto Drop. Se il type ha bisogno che accada qualcosa di speciale quando il valore esce dallo scope e aggiungiamo l’annotazione Copy a quel type, otterremo un errore in fase di compilazione. Per sapere come aggiungere l’annotazione Copy al tuo type, consulta “Tratti derivabili” nell’Appendice C.

Quindi, quali type implementano il tratto Copy? Puoi controllare la documentazione del type in questione per esserne sicuro, ma come regola generale, qualsiasi gruppo di valori scalari semplici può implementare Copy e niente che richieda l’allocazione o che sia una qualche forma di risorsa può implementare Copy:

Ecco alcuni dei type che implementano Copy:

  • Tutti i type integer, come u32.
  • Il type booleano, bool, con i valori true e false.
  • Tutti i type in virgola mobile, come f64.
  • Il type carattere, char.
  • Le tuple, se contengono solo type che implementano Copy. Ad esempio, (i32, i32) implementa Copy, ma (i32, String) no.

Ownership e Funzioni

I meccanismi che regolano il passaggio di un valore a una funzione sono simili a quelli dell’assegnazione di un valore a una variabile. Passando una variabile a una funzione, questa viene spostata o copiata, proprio come fa l’assegnazione. Il Listato 4-3 contiene un esempio con alcune annotazioni che mostrano dove le variabili entrano ed escono dallo scope.

File: src/main.rs
fn main() {
    let s = String::from("hello"); // `s` entra nello scope

    prende_ownership(s);           // il valore di `s` viene spostato nella funzione...
                                   // ... e quindi qui smette di esser valido

    let x = 5;                     // `x` entra nello scope

    duplica(x);                    // Siccome i32 implementa il tratto Copy,
                                   // `x` NON viene spostato nella funzione,
                                   // quindi dopo può ancora essere usata.

}   // Qui, `x` esce dallo scope, ed anche `s`. Tuttavia, siccome il valore di `s`
    // era stato spostato, non succede nulla di particolare.

fn prende_ownership(una_stringa: String) { // `una_stringa` entra nello scope
    println!("{una_stringa}");
}   // Qui, `una_stringa` esce dallo scope e `drop` viene chiamato.
    // La memoria corrispondente viene rilasciata.

fn duplica(un_integer: i32) { // `un_integer` entra nello scope
    println!("{un_integer}");
}   // Qui, `un_integer` esce dallo scope. Non succede nulla di particolare.
Listato 4-3: Funzioni con ownership e scope annotate

Se provassimo a usare s dopo la chiamata a prende_ownership, Rust segnalerebbe un errore in fase di compilazione. Questi controlli statici ci proteggono dagli errori. Prova ad aggiungere del codice a main che usi s e x per sperimentare dove puoi usarli e dove le regole di ownership te lo impediscono.

Valori di Ritorno e Scope

I valori di ritorno possono anch’essi trasferire la ownership. Il Listato 4-4 mostra un esempio di funzione che restituisce un valore, con annotazioni simili a quelle del Listato 4-3.

File: src/main.rs
fn main() {
    let s1 = cede_ownership();         // `cede_ownership` sposta il proprio
                                       // valore di ritorno in `s1`

    let s2 = String::from("hello");    // `s2` entra in scope

    let s3 = prende_e_restituisce(s2); // `s2` viene spostata in
                                       // `prende_e_restituisce`, che a sua
                                       // volta sposta il proprio valore
                                       // di ritorno in `s3`

} // Qui, `s3` esce dallo scope e viene cancellata con `drop`. `s2` era stata spostata
  // e quindi non succede nulla. `s1` viene cancellata con `drop` anch'essa.

fn cede_ownership() -> String {   // `cede_ownership` spostera il proprio valore di
                                  // ritorno alla funzione che l'ha chiamata

    let una_stringa = String::from("yours"); // `una_stringa` entra in scope

    una_stringa                     // `una_stringa` viene ritornata e spostata
                                    // alla funzione chiamante
}

// Questa funzione prende una String e ritorna una String.
fn prende_e_restituisce(altra_stringa: String) -> String {
                    // `altra_stringa` entra in scope

    altra_stringa   // `altra_stringa` viene ritornata
                    // e spostata alla funzione chiamante
}
Listato 4-4: Trasferimento di ownership nei valori di ritorno

La ownership di una variabile segue ogni volta lo stesso schema: assegnare un valore a un’altra variabile la sposta. Quando una variabile che include dati nell’heap esce dallo scope, il valore verrà cancellato da drop a meno che la ownership dei dati non sia stata spostata ad un’altra variabile.

Anche se funziona, prendere e cedere la ownership con ogni funzione è un po’ faticoso. Cosa succede se vogliamo consentire a una funzione di utilizzare un valore ma non di prenderne la ownership? È piuttosto fastidioso che tutto ciò che passiamo debba anche essere restituito se vogliamo usarlo di nuovo, oltre a tutte le varie elaborazioni sui dati che la funzione esegue e che magari è necessario ritornare pure quelle.

Rust ci permette di ritornare più valori utilizzando una tupla, come mostrato nel Listato 4-5

File: src/main.rs
fn main() {
    let s1 = String::from("ciao");

    let (s2, lung) = calcola_lunghezza(s1);

    println!("La lunghezza di '{s2}' è {lung}.");
}

fn calcola_lunghezza(s: String) -> (String, usize) {
    let lunghezza = s.len(); // len() restituisce la lunghezza di una String

    (s, lunghezza)
}
Listato 4-5: Restituzione ownership dei parametri

Ma questa è una procedura inutilmente complessa e richiede molto lavoro per un concetto che dovrebbe essere comune. Fortunatamente per noi, Rust ha una funzionalità che consente di utilizzare un valore senza trasferirne la ownership, chiamata riferimento (reference in inglese).

Reference e Borrowing

Il problema con il codice nel Listato 4-5 è che dobbiamo restituire la String alla funzione chiamante in modo da poter ancora utilizzare la String dopo la chiamata a calcola_lunghezza, perché la String è stata spostata in calcola_lunghezza. Possiamo invece fornire un riferimento (reference) al valore String. Un reference è come un puntatore in quanto è un indirizzo che possiamo seguire per accedere ai dati archiviati a quell’indirizzo di memoria; la ownership di quei dati appartiene ad un’altra variabile. A differenza di un puntatore, è garantito che un reference punti a un valore valido di un certo type finché il reference è ancora valido.

Ecco come definiresti e utilizzeresti una funzione calcola_lunghezza che abbia un reference ad un oggetto come parametro invece di assumere la ownership del valore:

File: src/main.rs
fn main() {
    let s1 = String::from("ciao");

    let lung = calcola_lunghezza(&s1);

    println!("La lunghezza di '{s1}' è {lung}.");
}

fn calcola_lunghezza(s: &String) -> usize {
    s.len()
}

Innanzitutto, nota che tutto il codice che dichiarava e ritornava la variabile tupla è sparito. In secondo luogo, nota che passiamo &s1 a calcola_lunghezza e, nella sua definizione del parametro, prendiamo &String anziché String. Il carattere & (E commerciale) rappresenta il reference e consente di fare riferimento a un valore senza prenderne la ownership.

La Figura 4-6 illustra questo concetto.

Tre tabelle: la tabella per s
contiene solo un puntatore alla tabella per s1. La tabella per s1 contiene i
dati sullo stack per s1 e punta ai dati della stringa nell’heap.

Figura 4-6: Schema di &String s che punta a String s1

Nota: l’opposto della referenziazione tramite l’uso di & è la de-referenziazione, che si realizza con l’operatore di de-referenziazione * (dereference operator). Vedremo alcuni usi dell’operatore di de-referenziazione nel Capitolo 8 e discuteremo i dettagli della de-referenziazione nel Capitolo 15.

Diamo un’occhiata più da vicino alla chiamata di funzione:

fn main() {
    let s1 = String::from("ciao");

    let lung = calcola_lunghezza(&s1);

    println!("La lunghezza di '{s1}' è {lung}.");
}

fn calcola_lunghezza(s: &String) -> usize {
    s.len()
}

La sintassi &s1 ci permette di creare un reference che punta al valore di s1 ma non lo possiede. Poiché il reference non lo possiede, il valore a cui punta non verrà rilasciato dalla memoria quando il reference smette di essere utilizzato. Allo stesso modo, la firma della funzione utilizza & per indicare che il type del parametro s è un reference. Aggiungiamo alcune annotazioni esplicative:

fn main() {
    let s1 = String::from("ciao");

    let lung = calcola_lunghezza(&s1);

    println!("La lunghezza di '{s1}' è {lung}.");
}

fn calcola_lunghezza(s: &String) -> usize { // `s` è un reference a una String
    s.len()
} // Qui, `s` esce dallo scope. Ma siccome `s` non ha ownership di quello
  // a cui fa riferimento, i valori di String non vengono cancellati

Lo scope in cui la variabile s è valida è lo stesso dello scope di qualsiasi parametro di funzione, ma il valore a cui punta il reference non viene eliminato quando s smette di essere utilizzato, perché s non ha la ownership. Quando le funzioni hanno reference come parametri anziché valori effettivi, non avremo bisogno di restituire i valori per restituire la ownership, perché la ownership non ci è mai stata trasferita.

L’azione di creare un reference viene chiamata borrowing (fare un prestito in italiano). Come nella vita reale, se una persona possiede qualcosa, puoi chiedergliela in prestito. Quando hai finito, devi restituirla. Non la possiedi.

Quindi, cosa succede se proviamo a modificare qualcosa che abbiamo in prestito? Prova il codice nel Listato 4-6. Avviso spoiler: non funziona!

File: src/main.rs
fn main() {
    let s = String::from("hello");

    cambia(&s);
}

fn cambia(una_stringa: &String) {
    una_stringa.push_str(", world");
}
Listato 4-6: Tentativo di modifica di un valore in prestito

Ecco l’errore:

$ cargo run
   Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0596]: cannot borrow `*una_stringa` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     una_stringa.push_str(", world");
  |     ^^^^^^^^^^^ `una_stringa` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn cambia(una_stringa: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Così come le variabili sono immutabili come impostazione predefinita, lo sono anche i reference. Non possiamo modificare qualcosa a cui abbiamo solo un riferimento.

Reference Mutabili

Possiamo correggere il codice del Listato 4-6 per permetterci di modificare un valore preso in prestito con alcune piccole modifiche che utilizzano, invece, un reference mutabile:

File: src/main.rs
fn main() {
    let mut s = String::from("hello");

    cambia(&mut s);
}

fn cambia(una_stringa: &mut String) {
    una_stringa.push_str(", world");
}

Per prima cosa rendiamo s mutabile con mut. Poi creiamo un reference mutabile con &mut s dove chiamiamo la funzione cambia e aggiorniamo la firma della funzione in modo che accetti un reference mutabile con una_stringa: &mut String. In questo modo è molto chiaro che la funzione cambia muterà il valore che prende in prestito. I reference mutabili hanno una grande restrizione: se hai un reference mutabile a un valore, non puoi avere altri reference a quel valore. Questo codice che tenta di creare due reference mutabili a s fallirà:

File: src/main.rs
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{r1}, {r2}");
}

Ecco l’errore:

$ cargo run
   Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:6:14
  |
5 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
6 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
7 |
8 |     println!("{r1}, {r2}");
  |                -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

L’errore dice che questo codice non è valido perché non possiamo avere più di un reference mutabile alla volta ad s. Il primo reference mutabile è in r1 e deve durare fino a quando non viene utilizzato nel println!, ma tra la creazione di quel reference mutabile e il suo utilizzo, abbiamo cercato di creare un altro reference mutabile in r2 che prende in prestito gli stessi dati di r1.

La restrizione che impedisce più reference mutabili agli stessi dati contemporaneamente consente la mutazione ma in modo molto controllato. È qualcosa con cui chi comincia a programmare in Rust fatica perché la maggior parte dei linguaggi ti consente di mutare quando vuoi. Il vantaggio di avere questa restrizione è che Rust può prevenire conflitti di accesso ai dati, data race, in fase di compilazione. Una data race è simile a una condizione di competizione e si verifica quando si verificano questi tre comportamenti:

  • Due o più puntatori accedono contemporaneamente agli stessi dati.
  • Almeno uno dei puntatori viene utilizzato per scrivere nei dati.
  • Non viene utilizzato alcun meccanismo per sincronizzare l’accesso ai dati

I data race causano comportamenti non programmati e possono essere difficili da diagnosticare e risolvere quando si cerca di individuarli durante l’esecuzione; Rust previene questo problema rifiutando di compilare codice contenente data race!

Come sempre, possiamo usare le parentesi graffe per creare uno scope nuovo, consentendo di avere più reference mutabili, ma non simultanee:

fn main() {
    let mut s = String::from("ciao");

    {
        let r1 = &mut s;
    }   // qui `r1` esce dallo scope, quindi possiamo creare
        // un nuovo reference senza problemi

    let r2 = &mut s;
}

Rust applica una regola simile per combinare reference mutabili e immutabili. Questo codice genera un errore:

fn main() {
    let mut s = String::from("ciao");

    let r1 = &s;      // nessun problema
    let r2 = &s;      // nessun problema
    let r3 = &mut s;  // GROSSO PROBLEMA

    println!("{r1}, {r2}, e {r3}");
}

Ecco l’errore:

$ cargo run
   Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:7:14
  |
5 |     let r1 = &s;      // nessun problema
  |              -- immutable borrow occurs here
6 |     let r2 = &s;      // nessun problema
7 |     let r3 = &mut s;  // GROSSO PROBLEMA
  |              ^^^^^^ mutable borrow occurs here
8 |
9 |     println!("{r1}, {r2}, e {r3}");
  |                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Wow! Non possiamo nemmeno avere un reference mutabile mentre ne abbiamo uno immutabile allo stesso valore.

Chi userà un reference immutabile non si aspetta certo che il valore cambi improvvisamente! Tuttavia, sono consentiti reference multipli immutabili perché nessuno che stia leggendo i dati ha la possibilità di influenzare la lettura dei dati da parte di altri.

Nota che lo scope di un reference inizia dal punto in cui viene introdotto e continua fino all’ultima volta che viene utilizzato. Ad esempio, questo codice verrà compilato perché l’ultimo utilizzo dei reference immutabili avviene nel println!, prima che venga introdotto il reference mutabile:

fn main() {
    let mut s = String::from("ciao");

    let r1 = &s; // nessun problema
    let r2 = &s; // nessun problema
    println!("{r1} and {r2}");
    // Le variabili `r1` e `r2` non verranno più usato dopo questo punto

    let r3 = &mut s; // nessun problema
    println!("{r3}");
}

Gli scope dei reference immutabili r1 e r2 terminano dopo il println! in cui sono stati utilizzati per l’ultima volta, ovvero prima che venga creato il reference mutabile r3. Questi scope non si sovrappongono, quindi questo codice è consentito: il compilatore capisce che il reference non verrà più utilizzato in nessun altro punto prima della fine dello scope.

Anche se a volte gli errori di borrowing possono essere frustranti, ricorda che è il compilatore di Rust a segnalare un potenziale bug in anticipo (in fase di compilazione e non in fase di esecuzione) e a mostrarti esattamente dove si trova il problema. In questo modo non dovrai cercare di capire perché i tuoi dati non sono quelli che pensavi fossero quando il programma è in esecuzione.

Reference Pendenti

Nei linguaggi con puntatori, è facile creare erroneamente un puntatore pendente, cioè un puntatore che fa riferimento a una posizione in memoria non più valido, perché quella memoria assegnata a quella variabile è stata liberata, ma non si è provveduto a cancellare anche il puntatore che per l’appunto rimane pendente puntando a qualcosa che non è più disponibile. In Rust, al contrario, il compilatore garantisce che i reference non diverranno mai pendenti: se si ha un reference ad alcuni dati, il compilatore ci impedirà di usare quel reference dopo che i dati sono usciti dallo scope.

Proviamo a creare un reference pendente per vedere come Rust li previene segnalando un errore in fase di compilazione:

File: src/main.rs
fn main() {
    let reference_a_nulla = pendente();
}

fn pendente() -> &String {
    let s = String::from("ciao");

    &s
}

Ecco l’errore:

$ cargo run
   Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:18
  |
5 | fn pendente() -> &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
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 pendente() -> &'static String {
  |                   +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn pendente() -> &String {
5 + fn pendente() -> String {
  |
error[E0515]: cannot return reference to local variable `s`
 --> src/main.rs:8:5
  |
8 |     &s
  |     ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous error

Questo messaggio di errore si riferisce a una funzionalità che non abbiamo ancora trattato: la longevità (lifetime d’ora in poi). Parleremo in dettaglio della lifetime nel Capitolo 10. Ma, se trascuriamo le parti relative alla lifetime, il messaggio contiene la chiave del motivo per cui questo codice è un problema:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

(traduzione: il type di ritorno di questa funzione contiene un valore in
prestito, ma non c’è alcun valore da cui prenderlo in prestito)

Diamo un’occhiata più da vicino a cosa succede esattamente in ogni fase della nostra funzione pendente:

File: src/main.rs
fn main() {
    let reference_a_nulla = pendente();
}

fn pendente() -> &String { // pendente ritorna un reference a String

    let s = String::from("ciao"); // `s` è una String nuova

    &s // ritorniamo un reference alla String `s`
} // Qui `s` esce dallo scope e viene cancellata, così come la memora assegnatale.
  // Pericolo!

Poiché s viene creato all’interno di pendente, quando il codice di pendente sarà terminato, s e la memoria ad essa assegnata verranno rilasciate. Ma abbiamo cercato di restituire un reference a questa memoria. Ciò significa che questo reference punterebbe a una String non valida. Questo non va bene! Rust non ci permette di farlo.

La soluzione è restituire direttamente la String:

fn main() {
    let stringa = non_pendente();
}

fn non_pendente() -> String {
    let s = String::from("ciao");

    s
}

Questo funziona senza problemi: la ownership viene spostata all’esterno e non viene rilasciato nulla.

Le Regole dei Reference

Ricapitoliamo quello che abbiamo detto sui reference:

  • In un dato momento, puoi avere o un singolo reference mutabile o un numero qualsiasi di reference immutabili.
  • I reference devono essere sempre validi.

Successivamente, analizzeremo un’altra tipologia di reference: le sezioni (slice in inglese).

Il Type Slice

Le slice (sezioni, fette, porzioni in italiano) ti permettono di fare riferimento (un reference) a una sequenza contigua di elementi in una collezione. Una slice è una tipologia di reference, quindi non ha ownership.

Ecco un piccolo problema di programmazione: scrivi una funzione che prenda una stringa di parole separate da spazi e restituisca la prima parola che trova in quella stringa. Se la funzione non trova uno spazio nella stringa, l’intera stringa deve essere considerata come una sola parola, quindi deve essere restituita l’intera stringa.

Nota: Ai fini dell’introduzione alle slice di stringhe, stiamo assumendo solo caratteri ASCII in questa sezione; una discussione più approfondita sulla gestione di UTF-8 si trova nella sezione “Memorizzare testo codificato UTF-8 con le stringhe” del Capitolo 8.

Lavoriamo su come scrivere la firma di questa funzione senza usare slice per ora, per comprendere il problema che le slice risolveranno:

fn prima_parola(s: &String) -> ?

La funzione prima_parola ha un parametro di tipo &String. Non abbiamo bisogno di ownership, quindi va bene. (In Rust idiomatico, le funzioni non prendono la ownership dei loro argomenti se non strettamente necessario, e i motivi per questo diventeranno chiari man mano che andremo avanti.) Ma cosa dovremmo ritornare? Non abbiamo davvero un modo per descrivere una parte di una stringa. Tuttavia, potremmo restituire l’indice della fine della parola, indicato da uno spazio. Proviamo a farlo, come mostrato nel Listato 4-7.

File: src/main.rs
fn prima_parola(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}
Listato 4-7: La funzione prima_parola che restituisce un valore di indice byte nella variabile String

Poiché dobbiamo esaminare la String elemento per elemento e controllare se un valore è uno spazio, convertiremo la nostra String in un array di byte usando il metodo as_bytes.

fn prima_parola(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Successivamente, creiamo un iteratore sull’array di byte usando il metodo iter:

fn prima_parola(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Discuteremo gli iteratori in modo più dettagliato nel Capitolo 13. Per ora, sappi che iter è un metodo che restituisce ogni elemento in una collezione e che enumerate prende il risultato di iter e restituisce ogni elemento come parte di una tupla. Il primo elemento della tupla restituita da enumerate è l’indice, e il secondo elemento è un riferimento all’elemento. Questo è un po’ più conveniente rispetto a calcolarci l’indice da soli.

Poiché il metodo enumerate restituisce una tupla, possiamo usare i pattern per destrutturare quella tupla. Discuteremo meglio i pattern nel Capitolo 6. Nel ciclo for, specifichiamo un pattern che ha i per l’indice nella tupla e &item per il singolo byte nella tupla. Poiché da .iter().enumerate() otteniamo un reference all’elemento, usiamo & nel pattern.

All’interno del ciclo for, cerchiamo il byte che rappresenta lo spazio usando la sintassi del letterale byte. Se troviamo uno spazio, restituiamo la posizione. Altrimenti, restituiamo la lunghezza della stringa usando s.len().

fn prima_parola(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

Ora abbiamo un metodo per scoprire l’indice della fine della prima parola nella stringa, ma c’è un problema. Stiamo ritornando un usize da solo, che è un numero significativo solo se usato in contesto con &String. In altre parole, poiché è un valore separato dalla String, non c’è garanzia che rimarrà valido in futuro. Considera il programma nel Listing 4-8 che utilizza la funzione prima_parola dal Listing 4-7.

File: src/main.rs
fn prima_parola(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let parola = prima_parola(&s); // `parola` riceverà il valore 5

    s.clear(); // questo svuota la String, rendendola uguale a ""

    // `parola` mantiene ancora il valore di 5, ma `s` non contiene più quello a cui
    // quel 5 si riferisce, quindi `parola` è adesso considerabile come non valida!
}
Listato 4-8: Memorizzare il risultato della chiamata alla funzione prima_parola e poi modificare il contenuto della String

Questo programma si compila senza errori e lo farebbe anche se usassimo parola dopo aver chiamato s.clear(). Poiché parola non è collegato allo stato di s, parola contiene ancora il valore 5. Potremmo usare quel valore 5 con la variabile s per cercare di estrarre la prima parola, ma questo sarebbe un bug perché il contenuto di s è cambiato da quando abbiamo salvato 5 in parola.

Doversi preoccupare dell’indice in parola che si disallinea con i dati in s è noioso e soggetto a errori! Gestire questi indici è ancora più fragile se scriviamo una funzione seconda_parola. La sua firma dovrebbe apparire così:

fn seconda_parola(s: &String) -> (usize, usize) {

Ora stiamo tracciando un indice di partenza e un indice di fine, e abbiamo ancora altri valori che sono stati calcolati da dati in un determinato stato, ma che non sono per nulla legati a quello stato in qualche modo. Abbiamo tre variabili non correlate e indipendenti che noi dobbiamo mantenere in sincronia.

Fortunatamente, Rust ha una soluzione a questo problema: le slice di stringhe.

Slice di Stringa

Una slice di stringa (string slice) è un reference a una sequenza contigua degli elementi di una String, e appare così:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

Invece di un reference all’intera String, hello è un reference a una porzione della String, specificata con l’aggiunta di [0..5]. Creiamo le slice usando un intervallo all’interno delle parentesi quadre specificando [indice_inizio..indice_fine], dove indice_inizio è la prima posizione nella slice e indice_fine è l’ultima posizione nella slice più uno. Internamente, la struttura dati della slice memorizza la posizione iniziale e la lunghezza della slice, che corrisponde a indice_fine meno indice_inizio. Quindi, nel caso di let world = &s[6..11];, world sarebbe una slice che contiene un puntatore al byte all’indice 6 di w con un valore di lunghezza di 5.

La Figura 4-7 mostra questo in un diagramma.

Tre
tabelle: una tabella che rappresenta i dati dello stack di s, che punta al byte
all’indice 0 in una tabella dei dati della stringa “hello world” nell’heap. La
terza tabella rappresenta i dati sullo stack dello slice world, che ha un valore
di lunghezza di 5 e punta al byte 6 della tabella dei dati nell’heap.

Figura 4-7: Slice di stringa che si riferisce a parte di una String

Con la sintassi d’intervallo .. di Rust, se vuoi iniziare dall’indice 0, puoi omettere il valore prima dei due punti. In altre parole, questi sono equivalenti:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

Allo stesso modo, se la tua slice include l’ultimo byte della String, puoi omettere il numero finale. Ciò significa che questi sono equivalenti:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

Puoi anche omettere entrambi i valori per prendere una slice dell’intera stringa. Quindi questi sono equivalenti:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

Nota: Gli indici di intervallo delle slice di stringa devono trovarsi in posizioni valide tenendo conto anche dei caratteri UTF-8. Se tenti di creare una slice nel mezzo di un carattere multi-byte, il tuo programma terminerà con un errore.

Tenendo presente tutte queste informazioni, riscriviamo prima_parola per restituire una slice. Il type che indica la slice di stringa è scritto come &str:

File: src/main.rs
fn prima_parola(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

Otteniamo l’indice per la fine della parola nello stesso modo in cui lo abbiamo fatto nel Listato 4-7, cercando la prima occorrenza di uno spazio. Quando troviamo uno spazio, restituiamo una slice di stringa usando l’inizio della stringa e l’indice dello spazio come indici di inizio e fine.

Ora, quando chiamiamo prima_parola, otteniamo un singolo valore che è legato ai dati sottostanti. Il valore è composto da un reference al punto di partenza della slice e dal numero di elementi nella slice.

Restituire una slice funzionerebbe anche per una funzione seconda_parola:

fn seconda_parola(s: &String) -> &str {

Ora abbiamo una funzione più semplice in cui è molto più difficile succedano cose strane perché il compilatore garantirà che i reference alla String rimangano validi. Ricordi il bug nel programma nel Listato 4-8, quando abbiamo ottenuto l’indice per la fine della prima parola ma poi abbiamo svuotato la stringa, rendendo il nostro indice non valido? Quel codice era logicamente errato ma non mostrava immediatamente errori. I problemi si sarebbero manifestati più tardi se avessimo continuato a cercare di usare l’indice della prima parola con una stringa svuotata. Le slice rendono questo bug impossibile e ci fanno sapere che abbiamo un problema con il nostro codice molto prima. Usare la versione slice di prima_parola genererà un errore di compilazione:

File: src/main.rs
fn prima_parola(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let parola = prima_parola(&s);

    s.clear(); // errore!

    println!("la prima parola è: {parola}");
}

Ecco l’errore del compilatore:

$ cargo run
   Compiling ownership v0.1.0 (file:///progetti/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:19:5
   |
17 |     let parola = prima_parola(&s);
   |                               -- immutable borrow occurs here
18 |
19 |     s.clear(); // errore!
   |     ^^^^^^^^^ mutable borrow occurs here
20 |
21 |     println!("la prima parola è: {parola}");
   |                                   ------ immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Ricorda le regole di borrowing: se abbiamo un reference immutabile a qualcosa, non possiamo anche prendere un reference mutabile. Poiché clear deve troncare la String, ha bisogno di ottenere un reference mutabile. Il println! dopo la chiamata a clear utilizza il reference a parola, quindi il reference immutabile deve essere ancora attivo a quel punto. Rust vieta che il reference mutabile in clear e il reference immutabile a parola esistano contemporaneamente, e la compilazione fallisce. Non solo Rust ha reso la nostra funzione più facile da usare, ma ha anche eliminato un’intera classe di errori durante la compilazione!

Letterali Stringa come Slice

Ricordi che abbiamo parlato dei letterali stringa memorizzati all’interno del binario? Ora che abbiamo scoperto le slice, possiamo comprendere correttamente i letterali stringa:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

Il type di s qui è &str: è una slice che punta a quel punto specifico nel binario. Questo è anche il motivo per cui i letterali stringa sono immutabili; &str è un reference immutabile.

Slice di Stringa come Parametri

Sapendo che puoi avere slice di letterali e di valori String, arriviamo a un ulteriore miglioramento per prima_parola, e cioè la sua firma:

fn prima_parola(s: &String) -> &str {

Un Rustacean più esperto scriverebbe invece la firma come mostrata nel Listato 4-9, perché ci permette di usare la stessa funzione sia su valori &String che su valori &str.

fn prima_parola(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mia_stringa = String::from("hello world");

    // `prima_parola` funziona con slice di `String`, parziali o intere.
    let parola = prima_parola(&mia_stringa[0..6]);
    let parola = prima_parola(&mia_stringa[..]);
    // `prima_parola` funziona anche con reference a `String`, che corrisponde
    // a una slice intera di `String`.
    let parola = prima_parola(&mia_stringa);

    let mia_stringa_letterale = "hello world";

    // `prima_parola` funziona con slice di letterali di stringa,
    // parziali o intere.
    let parola = prima_parola(&mia_stringa_letterale[0..6]);
    let parola = prima_parola(&mia_stringa_letterale[..]);

    // E siccome i letterali di stringa *sono* già delle slice,
    // funziona pure così, senza usare la sintassi delle slice!
    let parola = prima_parola(mia_stringa_letterale);
}
Listato 4-9: Migliorare la funzione prima_parola utilizzando una slice come type del parametro s

Se abbiamo una slice di stringa, possiamo passarlo direttamente. Se abbiamo una String, possiamo passare una slice della String o un reference alla String. Questa flessibilità sfrutta la deref coercions (de-referenziazione forzata), una funzionalità che tratteremo nella sezione “Usare la De-Referenziazione Forzata in Funzioni e Metodi” del Capitolo 15.

Definire una funzione che come parametro prende una slice di stringa invece di un reference a una String rende la nostra funzione più generica e utile senza perdere alcuna funzionalità:

File: src/main.rs
fn prima_parola(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mia_stringa = String::from("hello world");

    // `prima_parola` funziona con slice di `String`, parziali o intere.
    let parola = prima_parola(&mia_stringa[0..6]);
    let parola = prima_parola(&mia_stringa[..]);
    // `prima_parola` funziona anche con reference a `String`, che corrisponde
    // a una slice intera di `String`.
    let parola = prima_parola(&mia_stringa);

    let mia_stringa_letterale = "hello world";

    // `prima_parola` funziona con slice di letterali di stringa,
    // parziali o intere.
    let parola = prima_parola(&mia_stringa_letterale[0..6]);
    let parola = prima_parola(&mia_stringa_letterale[..]);

    // E siccome i letterali di stringa *sono* già delle slice,
    // funziona pure così, senza usare la sintassi delle slice!
    let parola = prima_parola(mia_stringa_letterale);
}

Altre Slice

Le slice di stringa, come puoi immaginare, sono specifiche per le stringhe. Ma c’è anche un tipo di slice più generale. Considera questo array:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

Proprio come potremmo voler fare riferimento a parte di una stringa, potremmo voler fare riferimento a parte di un array. Lo faremmo in questo modo:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

Questa slice ha il type &[i32]. Funziona allo stesso modo delle string slice, memorizzando un reference al primo elemento e una lunghezza. Utilizzerai questo tipo di slice per tutti i type di altre collezioni. Discuteremo di queste collezioni in dettaglio quando parleremo dei vettori nel Capitolo 8.

Riepilogo

I concetti di ownership, borrowing e slice garantiscono la sicurezza della memoria nei programmi Rust già in fase d compilazione. Il linguaggio Rust ti offre il controllo sul tuo utilizzo della memoria nello stesso modo in cui fanno altri linguaggi di programmazione di sistema, ma avere un proprietario (ownership) per ogni dato e che questo pulisca automaticamente i propri dati quando se ne va (non più in scope), significa non dover scrivere e debuggare codice extra per ottenere questo controllo.

L’ownership influisce su come molte altre parti di Rust funzionano, quindi parleremo di questi concetti ulteriormente nel resto del libro. Passiamo al Capitolo 5 e vediamo come raggruppare pezzi di dati insieme in una struct.

Utilizzare le Struct per Strutturare Dati Correlati

Una struttura (struct d’ora in poi) è un tipo di dati personalizzato che ti permette di raggruppare e denominare più valori correlati che formano un gruppo significativo. Se sei familiare con un linguaggio orientato agli oggetti, una struct è simile agli attributi di dati di un oggetto. In questo capitolo, confronteremo le tuple con le struct per costruire su ciò che già conosci e dimostrare quando le struct sono un modo migliore per raggruppare dati.

Dimostreremo come definire e istanziare le struct. Discuteremo come definire funzioni associate, in particolare il tipo di funzioni associate chiamate metodi (method), per specificare il comportamento associato a un type struct. Le struct ed enum (discussi nel Capitolo 6) sono i blocchi di costruzione per creare nuovi type nel dominio del tuo programma per sfruttare appieno il controllo dei type in fase di compilazione di Rust.

Definire e Istanziare le Struct

Le struct sono simili alle tuple, discussi nella sezione “Il Type Tupla”, in quanto entrambi possono contenere più valori correlati. Come per le tuple, i componenti di una struct possono essere di type diversi. A differenza delle tuple, in una struct puoi denominare ogni pezzo di dati in modo che sia chiaro il significato dei valori. L’aggiunta di questi nomi significa che le struct sono più flessibili delle tuple: non devi fare affidamento sull’ordine dei dati per specificare o accedere ai valori di un’istanza.

Per definire una struct, inseriamo la parola chiave struct e diamo un nome all’intera struct. Il nome di una struct dovrebbe descrivere il significato dei dati raggruppati insieme. Poi, all’interno di parentesi graffe, definiamo i nomi e i type dei pezzi di dati, che chiamiamo campi (field in inglese). Ad esempio, il Listato 5-1 mostra una struct che memorizza informazioni su un account utente.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn main() {}
Listato 5-1: Una definizione della struct Utente

Per utilizzare una struct dopo averla definita, creiamo un’istanza di quella struct specificando valori concreti per ciascuno dei campi. Creiamo un’istanza indicando il nome della struct e poi aggiungendo parentesi graffe contenenti coppie chiave: valore, dove le chiavi sono i nomi dei campi e i valori sono i dati che vogliamo memorizzare in quei campi. Non dobbiamo specificare i campi nello stesso ordine in cui li abbiamo dichiarati nella struct. In altre parole, la definizione della struct è come un modello generale per il type, e le istanze riempiono quel modello con dati particolari per creare valori del type. Ad esempio, possiamo dichiarare un utente particolare come mostrato nel Listato 5-2.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn main() {
    let utente1 = Utente {
        attivo: true,
        nome_utente: String::from("qualcuno123"),
        email: String::from("qualcuno@mia_mail.com"),
        numero_accessi: 1,
    };
}
Listato 5-2: Creazione di un’istanza della struct Utente

Per ottenere un valore specifico da una struct, usiamo la notazione col punto. Ad esempio, per accedere all’indirizzo email di questo utente, usiamo utente1.email. Se l’istanza è mutabile, possiamo cambiare un valore usando la notazione col punto assegnando un valore a un campo in particolare. Il Listato 5-3 mostra come modificare il valore del campo email di un’istanza Utente mutabile.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn main() {
    let mut utente1 = Utente {
        attivo: true,
        nome_utente: String::from("qualcuno123"),
        email: String::from("qualcuno@mia_mail.com"),
        numero_accessi: 1,
    };

    user1.email = String::from("nuova_email@mia_mail.com");
}
Listato 5-3: Cambiare valore del campo email di un’istanza Utente

Nota che l’intera istanza deve essere mutabile; Rust non ci permette di contrassegnare solo alcuni campi come mutabili. Come per qualsiasi espressione, possiamo costruire una nuova istanza della struct come ultima espressione nel corpo di una funzione per restituire implicitamente quella nuova istanza.

Il Listato 5-4 mostra la funzione nuovo_utente che restituisce un’istanza Utente con l’email e il nome utente indicati. Il campo attivo assume il valore true e numero_accessi prende il valore di 1.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn nuovo_utente(email: String, nuome_utente: String) -> Utente {
    User {
        attivo: true,
        nuome_utente: nuome_utente,
        email: email,
        numero_accessi: 1,
    }
}

fn main() {
    let utente1 = nuovo_utente(
        String::from("qualcuno@mia_mail.com"),
        String::from("qualcuno123"),
    );
}
Listato 5-4: Una funzione nuovo_utente che prende una email e un nome utente per ritornare un’istanza Utente

Ha senso chiamare i parametri della funzione con lo stesso nome dei campi della struct, ma dover ripetere i nomi dei campi email e nome_utente e delle variabili è un po’ noioso. Se la struct avesse più campi, la ripetizione di ogni nome diventerebbe ancora più fastidiosa. Per fortuna esiste una comoda scorciatoia!

Utilizzare la Sintassi Abbreviata di Inizializzazione

Poiché i nomi dei parametri e i nomi dei campi della struct sono esattamente gli stessi, possiamo usare la sintassi di inizializzazione abbreviata dei campi (field init shorthand) per riscrivere la funzione nuovo_utente in modo che si comporti esattamente allo stesso modo ma senza la ripetizione di nome_utente e email, come mostrato nel Listato 5-5.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn nuovo_utente(email: String, nome_utente: String) -> Utente {
    Utente {
        attivo: true,
        nome_utente,
        email,
        numero_accessi: 1,
    }
}

fn main() {
    let utente1 = nuovo_utente(
        String::from("qualcuno@mia_mail.com"),
        String::from("qualcuno123"),
    );
}
Listato 5-5: Una funzione nuovo_utente che usa la sintassi abbreviata perché i campi e i parametri nome_utente e email hanno lo stesso nome

Qui stiamo creando una nuova istanza della struct Utente, che ha un campo chiamato email. Vogliamo impostare il valore del campo email sul valore del parametro email della funzione nuovo_utente. Dato che il campo email e il parametro email hanno lo stesso nome, dobbiamo solo scrivere email invece di email: email.

Creare Istanze con la Sintassi di Aggiornamento delle Struct

Spesso è utile creare una nuova istanza di una struct che include la maggior parte dei valori da un’altra istanza dello stesso type, ma con alcune modifiche. Puoi farlo usando la sintassi di aggiornamento delle struct (struct update).

Per prima cosa, nel Listato 5-6 mostriamo come creare regolarmente una nuova istanza Utente in utente2, senza la sintassi di aggiornamento. Impostiamo un nuovo valore per email ma per il resto utilizziamo gli stessi valori di utente1 creati nel Listato 5-2.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn main() {
    // --taglio--

    let utente1 = Utente {
        email: String::from("qualcuno@mia_mail.com"),
        nome_utente: String::from("qualcuno123"),
        attivo: true,
        numero_accessi: 1,
    };

    let utente2 = Utente {
        attivo: utente1.attivo,
        nome_utente: utente1.nome_utente,
        email: String::from("altra_mail@example.com"),
        numero_accessi: utente1.numero_accessi,
    };
}
Listato 5-6: Creazione di una nuova istanza Utente con gli stessi valori tranne uno di utente1

Utilizzando la sintassi di aggiornamento, possiamo ottenere lo stesso effetto con meno codice, come mostrato nel Listato 5-7. La sintassi .. specifica che i restanti campi non impostati esplicitamente dovrebbero avere lo stesso valore dei campi nell’istanza data.

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: String,
    email: String,
    numero_accessi: u64,
}

fn main() {
    // --taglio--

    let utente1 = Utente {
        email: String::from("qualcuno@mia_mail.com"),
        nome_utente: String::from("qualcuno123"),
        attivo: true,
        numero_accessi: 1,
    };

    let utente2 = Utente {
        email: String::from("altra_mail@example.com"),
        ..utente1
    };
}
Listato 5-7: Utilizzo della sintassi struct update per impostare un nuovo valore di email per un’istanza di Utente, ma utilizzando o restanti valori da utente1

Anche il codice nel Listato 5-7 crea un’istanza in utente2 che ha un valore diverso per email ma ha gli stessi valori per i campi nome_utente, attivo e numero_accessi di utente1. La parola chiave ..utente1 deve venire per ultima per specificare che tutti i campi rimanenti dovrebbero ottenere i propri valori dai campi corrispondenti in utente1, ma possiamo scegliere di specificare i valori per tutti i campi che vogliamo in qualsiasi ordine, indipendentemente dall’ordine dei campi nella definizione della struct.

Nota che la sintassi di aggiornamento utilizza = come un assegnazione; questo perché sposta i dati, proprio come abbiamo visto nella sezione ”Interazione tra Variabili e Dati con Move. In questo esempio, non possiamo più utilizzare utente1 dopo aver creato utente2 perché la String nel campo nome_utente di utente1 è stata spostata in utente2. Se avessimo fornito a utente2 nuovi valori String sia per l’email che per nome_utente e quindi avessimo utilizzato solo i valori attivo e numero_accessi di utente1, utente1 sarebbe ancora valido dopo aver creato utente2. Sia attivo che numero_accessi sono type che implementano il trait Copy, quindi si applicherebbe il comportamento discusso nella sezione ”Duplicare dati sullo Stack. In questo esempio possiamo ancora utilizzare utente1.email, perché il suo valore non è stato spostato da utente1.

Creare Type Diversi con Struct Tupla

Rust supporta anche struct che assomigliano alle tuple, chiamate struct tupla (tuple struct). Le struct tupla hanno il significato aggiuntivo che il nome della struct fornisce, ma non hanno nomi associati ai loro campi; piuttosto, hanno solo i type dei campi. Le struct tupla sono utili quando si vuole dare un nome all’intera tupla e renderla un type diverso da altre tuple, e quando denominare ogni campo come in una struct regolare sarebbe poco utile o ridondante. Per definire una struct tupla, inizia con la parola chiave struct e il nome della struct seguito dai type della tupla. Ad esempio, qui definiamo e utilizziamo due struct tupla chiamate Colore e Punto:

File: src/main.rs
struct Colore(i32, i32, i32);
struct Punto(i32, i32, i32);

fn main() {
    let nero = Colore(0, 0, 0);
    let origine = Punto(0, 0, 0);
}

Tieni presente che i valori nero e origine sono di type diverso perché sono istanze di struct tupla diverse. Ogni struct che definisci diventa un nuovo type a sé stante, anche se i campi all’interno della struct potrebbero avere gli stessi type. Ad esempio, una funzione che accetta un parametro di type Colore non può accettare un Punto come argomento, anche se entrambi i type sono costituiti da tre valori i32. Oltretutto, le istanze di una struct tupla sono simili alle tuple in quanto puoi destrutturarle nelle loro singole parti e puoi utilizzare un . seguito dall’indice per accedere a un singolo valore. A differenza delle tuple però, le struct tupla richiedono di nominare il type di struct quando le destrutturi. Ad esempio, scriveremo let Punto(x, y, z) = origine; per destrutturare i valori del Punto origine in variabili chiamate x, y e z.

Definire Struct Unit

Puoi anche definire struct che non hanno campi! Queste sono chiamate struct unit (unit-like struct) perché si comportano in modo simile a (), il type unit menzionato nella sezione “Il Type Tupla”. Le struct unit possono essere utili quando è necessario implementare un trait su un type ma non si hanno dati che si vogliono memorizzare nel type stesso.

Parleremo dei trait nel Capitolo 10. Ecco un esempio di dichiarazione e istanziazione di una struct unit chiamata SempreUguale:

File: src/main.rs
struct SempreUguale;

fn main() {
    let suggetto = SempreUguale;
}

Per definire SempreUguale, utilizziamo la parola chiave struct, il nome che vogliamo e quindi un punto e virgola. Non c’è bisogno di parentesi graffe o tonde! Quindi possiamo ottenere un’istanza di SempreUguale nella variabile soggetto in un modo simile: utilizzando il nome che abbiamo definito, senza parentesi graffe o tonde. Immagina che in seguito implementeremo il comportamento per questo type in modo tale che ogni istanza di SempreUguale sia sempre uguale a ogni istanza di qualsiasi altro type, magari per avere un risultato noto a scopo di test. Non avremmo bisogno di dati per implementare quel comportamento! Vedremo nel Capitolo 10 come definire i trait e implementarli su qualsiasi type, comprese le struct unit.

Ownership dei Dati di Struct

Nella definizione della struct Utente, abbiamo utilizzato il type String invece del type slice di stringa &str. Questa è una scelta deliberata perché vogliamo che ogni istanza di questa struct possieda tutti i suoi dati e che tali dati siano validi per tutto il tempo in cui la struct è valida.

È anche possibile che le struct memorizzino reference a dati posseduti da qualcos’altro, ma per farlo è necessario l’uso di lifetime, una funzionalità di Rust di cui parleremo nel Capitolo 10. Lifetime garantisce che i dati a cui fa riferimento una struct siano validi finché lo è la struct. Supponiamo che provi a memorizzare un reference in una struct senza specificare la lifetime, come nel seguente esempio in src/main.rs; questo non funzionerà:

File: src/main.rs
struct Utente {
    attivo: bool,
    nome_utente: &str,
    email: &str,
    numero_accessi: u64,
}

fn main() {
    let user1 = Utente {
        attivo: true,
        nome_utente: "qualcuno123",
        email: "qualcuno@mia_mail.com",
        numero_accessi: 1,
    };
}

Il compilatore si lamenterà richiedendo degli identificatori di lifetime:

$ cargo run
   Compiling struct v0.1.0 (file:///progetti/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:18
  |
3 |     nome_utente: &str,
  |                  ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct Utente<'a> {
2 |     attivo: bool,
3 ~     nome_utente: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct Utente<'a> {
2 |     attivo: bool,
3 |     nome_utente: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `struct` (bin "struct") due to 2 previous errors

Nel Capitolo 10, discuteremo come risolvere questi errori in modo da poter memorizzare reference nelle struct, ma per ora risolveremo errori come questi usando type con ownership come String invece di reference come &str.

Un Esempio di Programma Che Usa Struct

Per capire quando potremmo voler usare le struct, scriviamo un programma che calcola l’area di un rettangolo. Partiremo usando variabili singole e poi riscriveremo il programma un pezzo per volta finché non useremo le struct.

Creiamo un nuovo progetto binario con Cargo chiamato rettangoli che prenderà la larghezza e l’altezza di un rettangolo specificate in pixel e calcolerà l’area del rettangolo. Il Listato 5-8 mostra un breve programma con un modo per farlo nel file src/main.rs del nostro progetto.

File: src/main.rs
fn main() {
    let larghezza1 = 30;
    let altezza1 = 50;

    println!(
        "L'area del rettangolo è di {} pixel quadrati.",
        area(larghezza1, altezza1)
    );
}

fn area(larghezza: u32, altezza: u32) -> u32 {
    larghezza * altezza
}
Listato 5-8: Calcolo dell’area di un rettangolo specificando in variabili separate larghezza e altezza

Ora esegui questo programma usando cargo run:

$ cargo run
   Compiling rettangoli v0.1.0 (file:///progetti/rettangoli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.15s
     Running `target/debug/rettangoli`
L'area del rettangolo è di 1500 pixel quadrati.

Questo codice riesce a calcolare l’area del rettangolo chiamando la funzione area con ogni dimensione, ma possiamo fare di più per rendere il codice chiaro e leggibile.

Il problema con questo codice è evidente nella firma di area:

fn main() {
    let larghezza1 = 30;
    let altezza1 = 50;

    println!(
        "L'area del rettangolo è di {} pixel quadrati.",
        area(larghezza1, altezza1)
    );
}

fn area(larghezza: u32, altezza: u32) -> u32 {
    larghezza * altezza
}

La funzione area dovrebbe calcolare l’area di un rettangolo singolo, ma la funzione che abbiamo scritto ha due parametri, e non è chiaro da nessuna parte nel nostro programma che i parametri siano correlati. Sarebbe più leggibile e più gestibile raggruppare larghezza e altezza insieme. Abbiamo già discusso un modo per farlo nella sezione “Il Type Tupla” del Capitolo 3: usando le tuple.

Riscrivere con le Tuple

Il Listato 5-9 mostra un’altra versione del nostro programma che usa le tuple.

File: src/main.rs
fn main() {
    let rettangolo1 = (30, 50);

    println!(
        "L'area del rettangolo è di {} pixel quadrati.",
        area(rettangolo1)
    );
}

fn area(dimensioni: (u32, u32)) -> u32 {
    dimensioni.0 * dimensioni.1
}
Listato 5-9: Specificare larghezza e altezza di un rettangolo tramite una tupla

Da un lato, questo programma è migliore. Le tuple ci permettono di aggiungere un po’ di struttura, e ora stiamo passando un solo argomento. Ma dall’altro, questa versione è meno chiara: le tuple non nominano i loro elementi, quindi dobbiamo indicizzare le parti della tupla, rendendo il nostro calcolo meno ovvio.

Confondere larghezza e altezza non avrebbe importanza per il calcolo dell’area, ma se volessimo disegnare il rettangolo sullo schermo, importerebbe! Dovremmo tenere a mente che larghezza è l’indice della tupla 0 e altezza è l’indice della tupla 1. Questo sarebbe ancora più difficile da capire e ricordare per qualcun altro che in futuro leggesse o usasse il nostro codice. Poiché non abbiamo reso palese il significato dei nostri dati nel codice, è più facile introdurre errori.

Riscrivere con le Struct

Usiamo la struct per aggiungere significato etichettando i dati. Possiamo trasformare la tupla che stiamo usando in una struct con un nome per l’intero e nomi per le parti, come mostrato nel Listato 5-10.

File: src/main.rs
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };

    println!(
        "L'area del rettangolo è di {} pixel quadrati.",
        area(&rettangolo1)
    );
}

fn area(rettangolo: &Rettangolo) -> u32 {
    rettangolo.larghezza * rettangolo.altezza
}
Listato 5-10: Definizione di una struct Rettangolo

Qui abbiamo definito una struct e l’abbiamo chiamata Rettangolo. All’interno delle parentesi graffe, abbiamo definito i campi come larghezza e altezza, entrambi di type u32. Poi, in main, abbiamo creato un’istanza particolare di Rettangolo che ha larghezza 30 e altezza 50.

La nostra funzione area è ora definita con un solo parametro, che abbiamo chiamato Rettangolo, il cui type è un reference immutabile a un’istanza della struct Rettangolo. Come menzionato nel Capitolo 4, ci serve solo prendere in prestito la struct piuttosto che averne la ownership. In questo modo, main mantiene la sua ownership e può continuare a usare rettangolo1, che è il motivo per cui usiamo & nella firma della funzione e dove chiamiamo la funzione.

La funzione area accede ai campi larghezza e altezza dell’istanza di Rettangolo (nota che accedere ai campi di un’istanza di struct presa in prestito non muove i valori dei campi, motivo per cui spesso si vedono reference di struct). La nostra firma della funzione per area ora dice esattamente ciò che intendiamo: calcolare l’area di Rettangolo, usando i suoi campi larghezza e altezza. Questo comunica che larghezza e altezza sono correlate tra loro e fornisce nomi descrittivi ai valori invece di usare gli indici della tupla 0 e 1. Questo è un vantaggio in termini di chiarezza.

Aggiungere Funzionalità con i Trait Derivati

Sarebbe utile poter stampare un’istanza di Rettangolo mentre eseguiamo il debug del nostro programma e vedere i valori di tutti i suoi campi. Il Listato 5-11 prova a usare la macro println! come l’abbiamo usata nei capitoli precedenti. Questo però non funzionerà.

File: src/main.rs
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };

    println!("rettangolo1 è {rettangolo1}");
}
Listato 5-11: Tentativo di stampare un’istanza di Rettangolo

Quando compiliamo questo codice, otteniamo un errore con questo messaggio principale:

error[E0277]: `Rettangolo` doesn't implement `std::fmt::Display`

La macro println! può fare molti tipi di formattazione e, come impostazione predefinita, le parentesi graffe dicono a println! di usare una formattazione conosciuta come Display, output pensato per il l’utente finale che utilizzerà il programma. I type primitivi che abbiamo visto finora implementano Display di default perché c’è un solo modo in cui vorresti mostrare un 1 o qualsiasi altro type primitivo a un utente. Ma con le struct il modo in cui println! dovrebbe formattare l’output è meno chiaro perché ci sono più possibilità di visualizzazione: vuoi le virgole o no? Vuoi stampare le parentesi graffe? Devono essere mostrati tutti i campi? A causa di questa ambiguità, Rust non cerca di indovinare ciò che vogliamo, e le struct non hanno un’implementazione standard di Display da usare con println! e il segnaposto {}.

Se continuiamo a leggere gli errori, troveremo questa nota utile:

   = help: the trait `std::fmt::Display` is not implemented for `Rettangolo`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

Proviamolo! La chiamata alla macro println! ora assomiglierà a println!("rettangolo1 è {rettangolo1:?}");. Inserire lo specificatore :? all’interno delle parentesi graffe dice a println! che vogliamo usare un formato di output chiamato Debug. Il trait Debug ci permette di stampare la nostra struct in un modo utile per gli sviluppatori, così possiamo vedere il suo valore mentre eseguiamo il debug del nostro codice.

Compila il codice con questa modifica. Accidenti! Otteniamo ancora un errore:

error[E0277]: `Rettangolo` doesn't implement `Debug`

Ma di nuovo, il compilatore ci dà una nota utile:

   = help: the trait `Debug` is not implemented for `Rettangolo`
   = note: add `#[derive(Debug)]` to `Rettangolo` or manually `impl Debug for Rettangolo`

Rust include effettivamente funzionalità per stampare informazioni di debug, ma dobbiamo esplicitamente dichiararlo per rendere disponibile quella funzionalità alla nostra struct. Per farlo, aggiungiamo l’attributo esterno #[derive(Debug)] appena prima della definizione della struct, come mostrato nel Listato 5-12.

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };

    println!("rettangolo1 è {rettangolo1:?}");
}
Listato 5-12: Aggiunta dell’attributo per derivare il trait Debug e stampare Rettangolo usando la formattazione di debug

Ora quando eseguiamo il programma, non otterremo errori e vedremo il seguente output:

$ cargo run
   Compiling rettangoli v0.1.0 (file:///progetti/rettangoli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/rettangoli`
rettangolo1 è Rettangolo { larghezza: 30, altezza: 50 }

Bene! Non è l’output più bello, ma mostra i valori di tutti i campi per questa istanza, il che aiuterebbe sicuramente durante lo sviluppo e il debug del programma. Quando abbiamo struct più grandi, è utile avere un output un po’ più facile da leggere; in quei casi, possiamo usare {:#?} invece di {:?} nella stringa di println!. In questo esempio, usare lo stile {:#?} produrrà il seguente output:

$ cargo run
   Compiling rettangoli v0.1.0 (file:///progetti/rettangoli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/rettangoli`
rettangolo1 è Rettangolo {
    larghezza: 30,
    altezza: 50,
}

Un altro modo per stampare un valore usando il formato Debug è usare la macro dbg!, che prende ownership di un’espressione (a differenza di println!, che prende un reference), stampa file e numero di linea di dove quella chiamata a dbg! si verifica nel codice insieme al valore risultante di quell’espressione, e restituisce l’ownership del valore.

Nota: Chiamare la macro dbg! stampa sullo stream di errore standard (stderr), a differenza di println!, che stampa sullo stream di output standard (stdout). Parleremo meglio di stderr e stdout nella sezione “Scrivere i Messaggi di Errore su Standard Error invece che su Standard Output” del Capitolo 12.

Ecco un esempio in cui siamo interessati al valore che viene assegnato al campo larghezza, così come al valore dell’intera struct in rettangolo1:

#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let scala = 2;
    let rettangolo1 = Rettangolo {
        larghezza: dbg!(30 * scala),
        altezza: 50,
    };

    dbg!(&rettangolo1);
}

Possiamo mettere dbg! attorno all’espressione 30 * scala e, poiché dbg! restituisce l’ownership del valore dell’espressione, il campo larghezza otterrà lo stesso valore come se non avessimo la chiamata a dbg! lì. Non vogliamo che dbg! prenda ownership di rettangolo1, quindi usiamo un riferimento a rettangolo1 nella chiamata successiva. Ecco come appare l’output di questo esempio:

$ cargo run
   Compiling rettangoli v0.1.0 (file:///progetti/rettangoli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/rettangoli`
[src/main.rs:10:20] 30 * scala = 60
[src/main.rs:14:5] &rettangolo1 = Rettangolo {
    larghezza: 60,
    altezza: 50,
}

Possiamo vedere che il primo frammento di output proviene da src/main.rs riga 10 dove stiamo facendo il debug dell’espressione 30 * scala, e il suo valore risultante è 60 (la formattazione Debug implementata per gli integer è di stampare solo il loro valore). La chiamata a dbg! alla riga 14 di src/main.rs stampa il valore di &rettangolo1, che è la struct Rettangolo. Questo output usa la formattazione Debug “pretty” del type Rettangolo. La macro dbg! può essere davvero utile quando stai cercando di capire cosa sta facendo il tuo codice!

Oltre al trait Debug, Rust fornisce diversi trait che possiamo usare con l’attributo derive che possono aggiungere comportamenti utili ai nostri type personalizzati. Quei trait e i loro comportamenti sono elencati nell’Appendice C. Tratteremo come implementare questi trait con un comportamento personalizzato e come creare i propri trait nel Capitolo 10. Ci sono anche molti attributi oltre a derive; per maggiori informazioni, vedi la sezione “Attributes” del Rust Reference.

La nostra funzione area è molto specifica: calcola solo l’area dei rettangoli. Sarebbe utile legare questo comportamento più strettamente alla nostra struct Rettangolo perché non funzionerà con altri type. Vediamo come possiamo continuare a riscrivere questo codice trasformando la funzione area in un metodo (method) definito sul nostro type Rettangolo.

Metodi

I metodi (method) sono simili alle funzioni: le dichiariamo con la parola chiave fn e un nome, possono avere parametri e un valore di ritorno, e contengono del codice che viene eseguito quando il metodo viene chiamato da un’altra parte. Diversamente dalle funzioni, i metodi sono definiti nel contesto di una struct (o di un’enum o di un trait object, che tratteremo nel Capitolo 6 e Capitolo 18, rispettivamente), e il loro primo parametro è sempre self, che rappresenta l’istanza della struct su cui il metodo viene chiamato.

Sintassi dei Metodi

Trasformiamo la funzione area che prende un’istanza di Rettangolo come parametro rendendola invece un metodo definito sulla struct Rettangolo, come mostrato nel Listato 5-13.

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn area(&self) -> u32 {
        self.larghezza * self.altezza
    }
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };

    println!(
        "L'area del rettangolo è di {} pixel quadrati.",
        rettangolo1.area()
    );
}
Listato 5-13: Definizione di un metodo area nella struct Rettangolo

Per definire la funzione nel contesto di Rettangolo, iniziamo un blocco impl (implementazione) per Rettangolo. Tutto ciò che sta dentro questo blocco impl sarà associato al type Rettangolo. Poi spostiamo la funzione area all’interno delle parentesi graffe dell’impl e cambiamo il primo (e in questo caso, unico) parametro in self nella firma e ovunque nel corpo. In main, dove chiamavamo la funzione area passando rettangolo1 come argomento, possiamo invece usare la sintassi dei metodi per chiamare il metodo area sull’istanza di Rettangolo. La sintassi del metodo va dopo un’istanza: aggiungiamo un punto seguito dal nome del metodo, parentesi tonde ed eventuali argomenti.

Nella firma di area usiamo &self invece di rettangolo: &Rettangolo. Il &self è in realtà l’abbreviazione di self: &Self. All’interno di un blocco impl, il type Self è un alias del type per cui il blocco impl è stato scritto. I metodi devono avere un parametro chiamato self di type Self come primo parametro, quindi Rust permette di abbreviare questo con soltanto il nome self nella prima posizione dei parametri. Nota che dobbiamo comunque usare & davanti alla forma abbreviata self per indicare che questo metodo prende in prestito l’istanza Self, esattamente come facevamo con rettangolo: &Rettangolo. I metodi possono prendere la ownership di self, prendere un reference immutabile a self, come abbiamo fatto qui, oppure prendere un reference mutabile a self, proprio come possono fare con qualsiasi altro parametro.

Qui abbiamo scelto &self per lo stesso motivo per cui abbiamo usato &Rettangolo nella versione precedente: non serve che prendiamo la ownership, vogliamo solo leggere i dati nella struct, non modificarli. Se volessimo modificare l’istanza su cui chiamiamo il metodo come parte di ciò che il metodo fa, useremmo &mut self come primo parametro. Avere un metodo che prende la ownership dell’istanza usando semplicemente self come primo parametro è raro; questa tecnica è solitamente usata quando il metodo trasforma self in qualcos’altro e si vuole impedire al chiamante di usare l’istanza originale dopo la trasformazione.

La ragione principale per usare i metodi invece delle funzioni, oltre a fornire la sintassi dei metodi e a non dover ripetere il type di self in ogni firma dei metodi, è per organizzazione. Abbiamo messo tutte le cose che possiamo fare con un’istanza di un type in un unico blocco impl invece di costringere chi dovrà in futuro leggere o manutenere il nostro codice a cercare le funzionalità di Rettangolo in vari posti nella libreria che forniamo.

Nota che possiamo scegliere di dare a un metodo lo stesso nome di uno dei campi della struct. Per esempio, possiamo definire un metodo su Rettangolo che si chiama anch’esso larghezza:

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn larghezza(&self) -> bool {
        self.larghezza > 0
    }
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };

    if rettangolo1.larghezza() {
        println!("La larghezza del rettangolo è > 0; è {}", rettangolo1.larghezza);
    }
}

Qui scegliamo di fare in modo che il metodo larghezza ritorni true se il valore nel campo larghezza dell’istanza è maggiore di 0 e false se il valore è 0: possiamo usare un campo all’interno di un metodo con lo stesso nome per qualunque scopo. In main, quando seguiamo rettangolo1.larghezza con le parentesi tonde, Rust sa che si intende il metodo larghezza. Quando non usiamo le parentesi tonde, Rust sa che intendiamo il campo larghezza.

Spesso, ma non sempre, quando diamo a un metodo lo stesso nome di un campo vogliamo che esso ritorni soltanto il valore del campo e non faccia altro. I metodi di questo tipo sono chiamati getter (metodi di incapsulamento) e Rust non li implementa automaticamente per i campi della struct come fanno alcuni altri linguaggi di programmazione. I getter sono utili perché puoi rendere il campo privato ma il metodo pubblico, abilitando così accesso in sola lettura a quel campo come parte dell’API pubblica del type. Discuteremo cosa sono pubblico e privato e come designare un campo o un metodo come pubblico o privato nel Capitolo 7.

Dov’è l’Operatore ->?

In C e C++ si usano due operatori diversi per accedere ai membri: si usa . quando si lavora direttamente con un oggetto, e -> quando si lavora con un puntatore all’oggetto e prima bisogna de-referenziarlo. In C++, questi operatori possono essere usati per chiamare i metodi; in C, sono usati solo per accedere ai campi delle struct. In altre parole, se oggetto è un puntatore, oggetto->qualcosa() è simile a (*oggetto).qualcosa().

Rust non ha un equivalente dell’operatore ->; invece, Rust ha una funzionalità chiamata referenziamento e de-referenziamento automatico (automatic referencing and dereferencing). Chiamare i metodi è uno dei pochi posti in Rust che implementa questa funzionalità.

Ecco come funziona: quando chiami un metodo con oggetto.qualcosa(), Rust aggiunge automaticamente &, &mut, o * affinché oggetto corrisponda alla firma del metodo. In altre parole, i seguenti sono equivalenti:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Punto {
    x: f64,
    y: f64,
}

impl Punto {
   fn distanza(&self, altro: &Punto) -> f64 {
       let x_quad = f64::powi(altro.x - self.x, 2);
       let y_quad = f64::powi(altro.y - self.y, 2);

       f64::sqrt(x_quad + y_quad)
   }
}
let p1 = Punto { x: 0.0, y: 0.0 };
let p2 = Punto { x: 5.0, y: 6.5 };
p1.distanza(&p2);
(&p1).distanza(&p2);
}

Il primo sembra molto più pulito. Questo comportamento di referencing automatico funziona perché i metodi hanno un receiver (recettore) chiaro, il type di self. Dato il receiver e il nome di un metodo, Rust può determinare in modo definitivo se il metodo sta leggendo (&self), mutando (&mut self), o consumando (self). Il fatto che Rust renda implicito il borrowing per i receiver dei metodi è una parte importante per rendere l’ownership ergonomica nella pratica.

Metodi con Più Parametri

Esercitiamoci ad usare i metodi implementando un secondo metodo sulla struct Rettangolo. Questa volta vogliamo che un’istanza di Rettangolo prenda un’altra istanza di Rettangolo e ritorni true se la seconda Rettangolo può entrare completamente dentro self (la prima Rettangolo); altrimenti dovrebbe ritornare false. Cioè, una volta definito il metodo può_contenere, dovremmo poter scrivere il programma mostrato nel Listato 5-14.

File: src/main.rs
fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };
    let rettangolo2 = Rettangolo {
        larghezza: 10,
        altezza: 40,
    };
    let rettangolo3 = Rettangolo {
        larghezza: 60,
        altezza: 45,
    };

    println!("Può rettangolo1 contenere rettangolo2? {}", rettangolo1.può_contenere(&rettangolo2));
    println!("Può rettangolo1 contenere rettangolo3? {}", rettangolo1.può_contenere(&rettangolo3));
}
Listato 5-14: Uso del metodo può_contenere ancora da scrivere

L’output atteso sarà il seguente perché entrambe le dimensioni di rettangolo2 sono più piccole delle dimensioni di rettangolo1, ma rettangolo3 è più larga di rettangolo1:

Può rettangolo1 contenere rettangolo2? true
Può rettangolo1 contenere rettangolo3? false

Sappiamo che vogliamo definire un metodo, quindi sarà all’interno del blocco impl Rettangolo. Il nome del metodo sarà può_contenere, e prenderà un reference immutabile di un’altra Rettangolo come parametro. Possiamo dedurre il type del parametro osservando il codice che chiama il metodo: rettangolo1.può_contenere(&rettangolo2) passa &rettangolo2, che è un reference immutabile di rettangolo2, un’istanza di Rettangolo. Questo ha senso perché abbiamo solo bisogno di leggere rettangolo2 (invece di modificarlo, il che richiederebbe un reference mutabile), e vogliamo che main mantenga l’ownership di rettangolo2 così da poterlo usare di nuovo dopo la chiamata a può_contenere. Il valore di ritorno di può_contenere sarà un Booleano, e l’implementazione verificherà se la larghezza e l’altezza di self sono maggiori rispetto alla larghezza e all’altezza dell’altra Rettangolo, rispettivamente. Aggiungiamo il nuovo metodo può_contenere al blocco impl del Listato 5-13, come mostrato nel Listato 5-15.

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn area(&self) -> u32 {
        self.larghezza * self.altezza
    }

    fn può_contenere(&self, altro: &Rettangolo) -> bool {
        self.larghezza > altro.larghezza && self.altezza > altro.altezza
    }
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };
    let rettangolo2 = Rettangolo {
        larghezza: 10,
        altezza: 40,
    };
    let rettangolo3 = Rettangolo {
        larghezza: 60,
        altezza: 45,
    };

    println!("Può rettangolo1 contenere rettangolo2? {}", rettangolo1.può_contenere(&rettangolo2));
    println!("Può rettangolo1 contenere rettangolo3? {}", rettangolo1.può_contenere(&rettangolo3));
}
Listato 5-15: Implementazione del metodo può_contenere in Rettangolo che riceve un’altra istanza di Rettangolo come parametro

Quando eseguiamo questo codice con la funzione main del Listato 5-14, otterremo l’output desiderato. I metodi possono prendere parametri multipli che aggiungiamo alla firma dopo il parametro self, e quei parametri funzionano proprio come i parametri nelle funzioni.

Funzioni Associate

Tutte le funzioni definite all’interno di un blocco impl sono chiamate funzioni associate (associated functions) perché sono associate al type nominato dopo la parola impl. Possiamo definire funzioni associate che non hanno self come primo parametro (e quindi non sono metodi) perché non hanno bisogno di un’istanza del type per svolgere il loro compito. Ne abbiamo già usata una: la funzione String::from implementata sul type String.

Le funzioni associate che non sono metodi sono spesso usate come costruttori che ritornano una nuova istanza della struct. Spesso si chiamano new perché new non è una parola chiave e non è incorporata nel linguaggio. Per esempio, potremmo decidere di fornire una funzione associata chiamata quadrato che prende un parametro di dimensione e lo usa sia come larghezza sia come altezza, rendendo più semplice creare un Rettangolo quadrato invece di dover specificare lo stesso valore due volte:

File: src/main.rs

#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn quadrato(dimensione: u32) -> Self {
        Self {
            larghezza: dimensione,
            altezza: dimensione,
        }
    }
}

fn main() {
    let quad = Rectangle::quadrato(3);
}

La parola chiave Self nel type di ritorno e nel corpo della funzione è un alias per il type che appare dopo la parola chiave impl, che in questo caso è Rettangolo.

Per chiamare questa funzione associata, usiamo la sintassi :: con il nome della struct; let quad = Rettangolo::quadrato(3); è un esempio. Questa funzione è organizzata nel namespace della struct: la sintassi :: è usata sia per le funzioni associate sia per i namespace creati dai moduli. Parleremo più approfonditamente dei moduli nel Capitolo 7.

Blocchi impl Multipli

A ogni struct è permesso avere più blocchi impl. Per esempio, il Listato 5-15 è equivalente al codice mostrato nel Listato 5-16, che ha ognuno dei metodi nel proprio blocco impl.

#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn area(&self) -> u32 {
        self.larghezza * self.altezza
    }
}

impl Rettangolo {
    fn può_contenere(&self, altro: &Rectangle) -> bool {
        self.larghezza > altro.larghezza && self.altezza > altro.altezza
    }
}

fn main() {
    let rettangolo1 = Rettangolo {
        larghezza: 30,
        altezza: 50,
    };
    let rettangolo2 = Rettangolo {
        larghezza: 10,
        altezza: 40,
    };
    let rettangolo3 = Rettangolo {
        larghezza: 60,
        altezza: 45,
    };

    println!("Può rettangolo1 contenere rettangolo2? {}", rettangolo1.può_contenere(&rettangolo2));
    println!("Può rettangolo1 contenere rettangolo3? {}", rettangolo1.può_contenere(&rettangolo3));
}
Listato 5-16: Riscrittura del Listato 5-15 usando più blocchi impl

Non c’è motivo di separare questi metodi in più blocchi impl in questo caso, ma questa è una sintassi valida. Vedremo un caso in cui più blocchi impl sono utili nel Capitolo 10, dove discuteremo i type generici e i trait.

Riepilogo

Le struct ti permettono di creare type personalizzati significativi per il tuo dominio. Usando le struct, puoi mantenere pezzi di dati correlati tra loro e dare un nome a ciascun pezzo per rendere il codice chiaro. Nei blocchi impl puoi definire funzioni associate al tuo type, e i metodi sono un tipo di funzione associata che ti permette di specificare il comportamento che le istanze delle tue struct hanno.

Ma le struct non sono l’unico modo per creare type personalizzati: passiamo ad un altro type di Rust, le enumerazioni, per aggiungere un altro strumento alla tua cassetta degli attrezzi.

Enumerazioni e Corrispondenza dei Pattern

In questo capitolo parleremo delle enumerazioni, abbreviato in enum, termine che sarà usato d’ora in poi. Le enum permettono di definire un type enumerando le sue possibili varianti. Per prima cosa definiremo e useremo un’enum per mostrare come un’enum possa codificare un significato insieme ai dati. Poi esploreremo un’enum particolarmente utile, chiamato Option, che consente di esprimere se un valore può essere o qualcosa o niente. Poi vedremo come la corrispondenza dei pattern nell’espressione match rende facile eseguire codice diverso per valori diversi di un’enum. Infine, vedremo come il costrutto if let sia un altro idioma comodo e conciso disponibile per gestire le enum nel tuo codice.

Definire un’Enum

Laddove le struct ti danno un modo per raggruppare campi e dati correlati, per esempio un Rettangolo con i propri larghezza e altezza, le enum ti danno un modo per indicare che un valore è uno di un insieme possibile di valori. Per esempio, potremmo voler dire che Rettangolo è una delle possibili forme che include anche Cerchio e Triangolo. Per farlo, Rust ci permette di codificare queste possibilità come un’enum.

Esaminiamo una situazione che potremmo voler esprimere nel codice e vediamo perché le enum sono utili e più appropriati delle struct in questo caso. Supponiamo di dover lavorare con gli indirizzi IP. Attualmente, per gli indirizzi IP si usano due standard principali: versione quattro e versione sei. Poiché queste sono le uniche possibilità di indirizzo IP che il nostro programma incontrerà, possiamo enumerare tutte le varianti possibili, da cui il nome enum.

Qualsiasi indirizzo IP può essere o versione quattro o versione sei, ma non entrambi contemporaneamente. Questa proprietà degli indirizzi IP rende la struttura dati enum appropriata perché un valore di enum può essere solo una delle sue varianti. Sia i versione quattro sia i versione sei sono comunque fondamentalmente indirizzi IP, quindi dovrebbero essere trattati come dati dello stesso type quando il codice andrà a gestire situazioni che si applicano agli indirizzi IP d’ogni genere.

Possiamo esprimere questo concetto nel codice definendo un’enum VersioneIndirizzoIp e elencando le possibili tipologie che un indirizzo IP può essere: V4 e V6. Queste sono le varianti dell’enum:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

VersioneIndirizzoIp è ora un type di dato personalizzato che possiamo usare altrove nel nostro codice.

Valori di Enum

Possiamo creare istanze di ciascuna delle due varianti di VersioneIndirizzoIp in questo modo:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

Nota che le varianti dell’enum sono nel namespace del suo identificatore, e usiamo il doppio-due punti :: per separarle. Questo è utile perché ora entrambi i valori VersioneIndirizzoIp::V4 e VersioneIndirizzoIp::V6 sono dello stesso type: VersioneIndirizzoIp. Possiamo quindi, per esempio, definire una funzione che accetta qualsiasi VersioneIndirizzoIp:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

E possiamo chiamare questa funzione con entrambe le varianti:

enum VersioneIndirizzoIp {
    V4,
    V6,
}

fn main() {
    let quattro = VersioneIndirizzoIp::V4;
    let sei = VersioneIndirizzoIp::V6;

    instrada(VersioneIndirizzoIp::V4);
    instrada(VersioneIndirizzoIp::V6);
}

fn instrada(verione_ip: VersioneIndirizzoIp) {}

L’uso delle enum ha ulteriori vantaggi. Pensando meglio al nostro type per gli indirizzi IP, al momento non abbiamo un modo per memorizzare il vero e proprio indirizzo IP; sappiamo solo di che tipologia si tratta. Dato che hai appena imparato le struct nel Capitolo 5, potresti essere tentato di risolvere questo problema con le struct come mostrato nel Listato 6-1.

fn main() {
    enum VersioneIndirizzoIp {
        V4,
        V6,
    }

    struct IpAddr {
        tipo: VersioneIndirizzoIp,
        indirizzo: String,
    }

    let home = IpAddr {
        tipo: VersioneIndirizzoIp::V4,
        indirizzo: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        tipo: VersioneIndirizzoIp::V6,
        indirizzo: String::from("::1"),
    };
}
Listato 6-1: Memorizzare indirizzo e la variante VersioneIndirizzoIp di un indirizzo IP usando una struct

Qui abbiamo definito una struct IpAddr che ha due campi: un campo tipo di type VersioneIndirizzoIp (l’enum definito prima) e un campo indirizzo di type String. Abbiamo due istanze di questa struct. La prima è home, e ha il valore VersioneIndirizzoIp::V4 come suo tipo con l’indirizzo associato 127.0.0.1. La seconda istanza è loopback. Essa ha l’altra variante di VersioneIndirizzoIp come valore del suo tipo, V6, e ha associato l’indirizzo ::1. Abbiamo usato una struct per raggruppare i valori tipo e indirizzo, così la variante è ora associata al valore.

Tuttavia, rappresentare lo stesso concetto usando solo un’enum è più conciso: invece di un’enum dentro una struct, possiamo mettere i dati direttamente in ogni variante dell’enum. Questa nuova definizione dell’enum IpAddr indica che entrambe le varianti V4 e V6 avranno valori String associati:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

Alleghiamo i dati direttamente a ciascuna variante dell’enum, quindi non c’è bisogno di una struct aggiuntiva. Qui è anche più facile vedere un altro dettaglio di come funzionano le enum: il nome di ciascuna variante che definiamo diventa anche una funzione che costruisce un’istanza dell’enum. Cioè, IpAddr::V4() è una chiamata di funzione che prende un argomento String e ritorna un’istanza del type IpAddr. Otteniamo automaticamente questa funzione costruttrice come risultato della definizione dell’enum.

C’è un altro vantaggio nell’usare un’enum invece di una struct: ogni variante può avere type e quantità diverse di dati associati. Gli indirizzi versione quattro, ad esempio, avranno sempre quattro componenti numeriche con valori tra 0 e 255. Se volessimo memorizzare gli indirizzi V4 come quattro valori u8 ma rappresentare gli indirizzi V6 come una singola String, non potremmo farlo con un struct. Le enum gestiscono questo caso con facilità:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

Abbiamo mostrato diversi modi per definire strutture dati per memorizzare indirizzi IP versione quattro e versione sei. Tuttavia, risulta che voler memorizzare indirizzi IP e codificare di quale tipologia siano è così comune che la libreria standard fornisce una definizione che possiamo usare! Diamo un’occhiata a come la libreria standard definisce IpAddr. Ha l’esatta enum e le varianti che abbiamo definito e usato, ma incapsula i dati dell’indirizzo dentro le varianti sotto forma di due diverse struct, definite in modo differente per ciascuna variante:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --taglio--
}

struct Ipv6Addr {
    // --taglio--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

Questo codice illustra che puoi mettere qualsiasi tipologia di dato dentro una variante di enum: stringhe, type numerici o struct, per esempio. Puoi persino includere un’altra enum! Inoltre, i type della libreria standard spesso non sono molto più complicati di quello che potresti creare tu.

Nota che anche se la libreria standard contiene una definizione per IpAddr, possiamo comunque creare e usare la nostra definizione senza conflitti perché non abbiamo importato la definizione della libreria standard nel nostro scope. Parleremo più avanti dell’importazione dei type nello scope nel Capitolo 7.

Diamo un’occhiata a un altro esempio di enum nel Listato 6-2: questo ha una grande varietà di type incorporati nelle sue varianti.

enum Messaggio {
    Esci,
    Sposta { x: i32, y: i32 },
    Scrivi(String),
    CambiaColore(i32, i32, i32),
}

fn main() {}
Listato 6-2: Un’enum Messaggio le cui varianti memorizzano ciascuna quantità e type diversi di valori

Questa enum ha quattro varianti con type diversi:

  • Esci: non ha dati associati
  • Muovi: ha campi nominati, come fa un struct
  • Scrivi: include una singola String
  • CambiaColore: include tre valori i32

Definire un’enum con varianti come quelle nel Listato 6-2 è simile a definire diversi type di struct, eccetto che l’enum non usa la parola chiave struct e tutte le varianti sono raggruppate sotto il type Messaggio. Le seguenti struct potrebbero contenere gli stessi dati che le varianti dell’enum precedente contengono:

struct MessaggioEsci; // unit struct
struct SpostaMessaggio {
    x: i32,
    y: i32,
}
struct ScriviMessaggio(String); // tuple struct
struct CambiaColoreMessaggio(i32, i32, i32); // tuple struct

fn main() {}

Ma se usassimo le diverse struct, ognuna con il proprio type, non potremmo definire altrettanto facilmente una funzione che accetti uno qualsiasi di questi type di messaggi come potremmo fare con l’enum Messaggio definito nel Listato 6-2, che è un singolo type.

C’è un’ulteriore somiglianza tra enum e struct: proprio come possiamo definire metodi sulle struct usando impl, possiamo anche definire metodi sulle enum. Ecco un metodo nominato chiama che potremmo definire sulla nostra enum Messaggio:

fn main() {
    enum Messaggio {
        Esci,
        Sposta { x: i32, y: i32 },
        Scrivi(String),
        CambiaColore(i32, i32, i32),
    }

    impl Messaggio {
        fn chiama(&self) {
            // il corpo del metodo sarà definito qui
        }
    }

    let m = Messaggio::Scrivi(String::from("ciao"));
    m.chiama();
}

Il corpo del metodo userebbe self per ottenere il valore su cui abbiamo chiamato il metodo. In questo esempio, abbiamo creato una variabile m che ha il valore Messaggio::Scrivi(String::from("ciao")), e quello sarà self nel corpo del metodo chiama quando m.chiama() viene eseguito.

Diamo un’occhiata a un’altra enum nella libreria standard che è molto comune e utile: Option.

L’Enum Option

Questa sezione esplora un caso di studio su Option, che è un’altra enum definito dalla libreria standard. Il type Option codifica lo scenario molto comune in cui un valore può essere qualcosa oppure niente.

Per esempio, se richiedi il primo elemento di una lista non vuota, otterrai un valore. Se richiedi il primo elemento di una lista vuota, non otterrai niente. Esprimere questo concetto in termini del sistema dei type permette al compilatore di verificare se hai gestito tutti i casi che dovresti gestire; questa funzionalità può prevenire bug estremamente comuni in altri linguaggi di programmazione.

La progettazione dei linguaggi di programmazione è spesso pensata in termini delle funzionalità che includi, ma anche le funzionalità che escludi sono importanti. Rust non prevede l’uso di null che molti altri linguaggi possiedono. Null è un valore che significa che non c’è alcun valore. Nei linguaggi con null, le variabili possono essere sempre in uno dei due stati: null o non-null.

Nella sua presentazione del 2009 “Null References: The Billion Dollar Mistake”, Tony Hoare, l’inventore del null, disse:

Lo chiamo il mio errore da un miliardo di dollari. All’epoca stavo progettando il primo sistema di type completo per i reference in un linguaggio orientato agli oggetti. Il mio obiettivo era garantire che ogni uso dei reference fosse assolutamente sicuro, con i controlli effettuati automaticamente dal compilatore. Ma non ho resistito alla tentazione di inserire un reference nullo, semplicemente perché era così facile da implementare. Questo ha portato a innumerevoli errori, vulnerabilità e crash di sistema, che probabilmente hanno causato un miliardo di dollari di dolore e danni negli ultimi quarant’anni.

Il problema con i valori null è che se provi a usare un valore null come se fosse un valore non-null, otterrai un errore di qualche tipo. Poiché questa proprietà null o no-null è pervasiva, è estremamente facile commettere questo tipo di errore.

Tuttavia, il concetto che il null cerca di esprimere è ancora utile: null è un valore che è attualmente invalido o assente per qualche motivo.

Il problema non è veramente il concetto ma l’implementazione. Di conseguenza, Rust non ha i null, ma ha un’enum che può codificare il concetto di un valore presente o assente. Questa enum è Option<T>, ed è definito dalla libreria standard come segue:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

L’enum Option<T> è così utile che è incluso nel prelude (preludio è l’insieme di funzionalità che Rust include di default in ogni programma fornite dalla libreria standard); non è necessario portarlo nello scope esplicitamente. Le sue varianti sono anch’esse incluse nel prelude: puoi usare Some e None direttamente senza il prefisso Option::. L’enum Option<T> è comunque un normale enum, e Some(T) e None sono ancora varianti del type Option<T>.

La sintassi <T> è una caratteristica di Rust di cui non abbiamo ancora parlato. È un parametro di type generico, e tratteremo i type generici più in dettaglio nel Capitolo 10. Per ora, tutto ciò che devi sapere è che <T> significa che la variante Some dell’enum Option può contenere un pezzo di dato di qualsiasi type, e che ogni type concreto che viene usato al posto di T rende il type complessivo Option<T> un type diverso. Ecco alcuni esempi di utilizzo di valori Option per contenere type numerici e type carattere:

fn main() {
    let un_numero = Some(5);
    let un_carattere = Some('e');

    let nessun_numero: Option<i32> = None;
}

Il type di un_numero è Option<i32>. Il type di un_carattere è Option<char>, che è un type diverso. Rust può inferire questi type perché abbiamo specificato un valore dentro la variante Some. Per nessun_numero, Rust ci richiede di annotare il type di Option: il compilatore non può inferire il type che la variante Some corrispondente conterrà guardando solo al valore None. Qui diciamo a Rust che intendiamo che nessun_numero sia di type Option<i32>.

Quando abbiamo un valore Some, sappiamo che un valore è presente e il valore è contenuto all’interno di Some. Quando abbiamo un valore None, in un certo senso significa la stessa cosa di null: non abbiamo un valore valido. Quindi perché avere Option<T> è meglio che avere null?

In breve, perché Option<T> e T (dove T può essere qualsiasi type) sono type diversi, il compilatore non ci permetterà di usare un valore Option<T> come se fosse sicuramente un valore valido. Per esempio, questo codice non compilerà, perché sta cercando di sommare un Option<i8> a un i8:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let somma = x + y;
}

Se eseguiamo questo codice, otteniamo un messaggio di errore come questo:

$ cargo run
   Compiling enums v0.1.0 (file:///progetti/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:6:19
  |
6 |     let somma = x + y;
  |                   ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Wow, quanta roba! Nel concreto, questo messaggio di errore significa che Rust non capisce come sommare un i8 e un Option<i8>, perché sono type diversi. Quando abbiamo un valore di un type come i8 in Rust, il compilatore deve assicurarsi che abbiamo sempre un valore valido. Possiamo procedere con fiducia senza dover controllare il null prima di usare quel valore. Solo quando abbiamo un Option<i8> (o qualunque type di valore con cui stiamo lavorando) dobbiamo preoccuparci della possibilità di non avere un valore, e il compilatore ci assicurerà di gestire quel caso prima di usare il valore.

In altre parole, devi convertire un Option<T> in un T prima di poter eseguire operazioni su T. Generalmente, questo aiuta a catturare uno dei problemi più comuni del null: presumere che qualcosa non sia null quando in realtà lo è.

Eliminare il rischio di presumere erroneamente un valore non-null ti aiuta a essere più sicuro nel tuo codice. Per avere un valore che può essere eventualmente null, devi esplicitamente optare per questo facendo sì che il type di quel valore sia Option<T>. Poi, quando usi quel valore, sei obbligato a gestire esplicitamente il caso in cui il valore sia null. Ovunque un valore abbia un type che non è Option<T>, puoi assumere in sicurezza che il valore non sia null. Questa è stata una decisione di design voluta in Rust per limitare la pervasività del null e aumentare la sicurezza del codice Rust.

Quindi come si estrae il valore T da una variante Some quando si ha un valore di type Option<T> in modo da poter usare quel valore? L’enum Option<T> ha un gran numero di metodi utili in varie situazioni; puoi consultarli nella sua documentazione. Familiarizzare con i metodi su Option<T> sarà estremamente utile nel tuo percorso con Rust.

In generale, per usare un valore Option<T> devi avere codice che gestisca ogni variante. Vuoi del codice che venga eseguito solo quando hai un valore Some(T), e quel codice può usare il T interno. Vuoi altro codice che venga eseguito solo se hai un valore None, e quel codice non ha un valore T disponibile. L’espressione match è un costrutto di controllo del flusso che fa esattamente questo quando viene usata con le enum: eseguirà codice diverso a seconda di quale variante dell’enum ha, e quel codice può usare i dati all’interno del valore che corrisponde.

Controllare il Flusso col Costrutto match

Rust offre un costrutto di controllo del flusso estremamente potente chiamato match (corrisponde, combacia) che permette di confrontare un valore con una serie di pattern ed eseguire codice in base al pattern che corrisponde. I pattern possono essere composti da valori letterali, nomi di variabili, caratteri jolly e molte altre cose; il Capitolo 19 copre tutte le diverse tipologie di pattern e cosa fanno. La potenza di match deriva dall’espressività dei pattern e dal fatto che il compilatore conferma che tutti i casi possibili sono gestiti.

Pensa a un’espressione match come a una macchina che smista monete: le monete scivolano lungo una guida con fori di varie dimensioni e ciascuna moneta cade nel primo foro in cui entra. Allo stesso modo, i valori passano attraverso ogni pattern in un match, e al primo pattern in cui il valore «entra», il valore viene fatto ricadere nel blocco di codice associato per essere usato durante l’esecuzione.

Parlando di monete, usiamole come esempio con match! Possiamo scrivere una funzione che prende una moneta USA sconosciuta e, in modo simile alla macchina conta-monete, determina quale moneta sia e restituisce il suo valore in centesimi, come mostrato nel Listato 6-3.

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn valore_in_cent(moneta: Moneta) -> u8 {
    match moneta {
        Moneta::Penny => 1,
        Moneta::Nickel => 5,
        Moneta::Dime => 10,
        Moneta::Quarter => 25,
    }
}

fn main() {}
Listato 6-3: Un’enum e un’espressione match che ha come pattern le varianti dell’enum

Analizziamo il match nella funzione valore_in_cent. Prima troviamo la parola chiave match seguita da un’espressione, che in questo caso è il valore moneta. Questo sembra molto simile a un’espressione condizionale usata con if, ma c’è una grande differenza: con if la condizione deve valutarsi a un valore Booleano, mentre qui può essere di qualsiasi type. Il type di moneta in questo esempio è l’enum Moneta che abbiamo definito nella prima riga.

Seguono i rami di match. Un ramo è composto da due parti: un pattern e del codice. Il primo ramo qui ha come pattern il valore Moneta::Penny e poi l’operatore => che separa il pattern dal codice da eseguire. Il codice in questo caso è semplicemente il valore 1. Ogni ramo è separato dal successivo da una virgola.

Quando l’espressione match viene eseguita, confronta il valore risultante con il pattern di ogni ramo, in ordine. Se un pattern corrisponde al valore, viene eseguito il codice associato a quel pattern. Se quel pattern non corrisponde, l’esecuzione continua con il ramo successivo, proprio come nella macchina che smista monete. Possiamo avere tanti rami quanti ce ne servono: nel Listato 6-3, il nostro match ha quattro rami.

Il codice associato a ciascun ramo è un’espressione, e il valore risultante dell’espressione nel ramo che corrisponde è il valore restituito per l’intera espressione match.

Di solito non usiamo le parentesi graffe se il codice del ramo è breve, come nel Listato 6-3 dove ogni ramo restituisce solo un valore. Se vuoi eseguire più righe di codice in un ramo del match, devi usare le parentesi graffe, e la virgola che segue il ramo diventa opzionale. Per esempio, il codice seguente stampa “Penny fortunato!” ogni volta che il metodo viene chiamato con una Coin::Penny, ma restituisce comunque l’ultimo valore del blocco, 1:

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn valore_in_cent(moneta: Moneta) -> u8 {
    match moneta {
        Moneta::Penny => {
            println!("Penny fortunato!");
            1
        }
        Moneta::Nickel => 5,
        Moneta::Dime => 10,
        Moneta::Quarter => 25,
    }
}

fn main() {}

Pattern che Si Legano ai Valori

Un’altra caratteristica utile dei rami del match è che possono legarsi alle parti dei valori che corrispondono al pattern. È così che possiamo estrarre valori dalle varianti delle enum.

Per esempio, modifichiamo una delle nostre varianti dell’enum per contenerci dei dati. Dal 1999 al 2008, gli Stati Uniti coniarono quarter con design diversi per ciascuno dei 50 stati su un lato. Nessun’altra moneta aveva design statali, quindi solo i quarter hanno questa caratteristica peculiare. Possiamo aggiungere questa informazione al nostra enum cambiando la variante Quarter per includere un valore StatoUSA all’interno, come fatto nel Listato 6-4.

#[derive(Debug)] // così possiamo vederne i valori tra un po'
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn main() {}
Listato 6-4: Un’enum Moneta in cui la variante Quarter contiene anche un valore StatoUSA

Immaginiamo che un amico stia cercando di collezionare tutti e 50 i quarter statali. Mentre separiamo il nostro resto per tipo di moneta, guarderemo anche il nome dello stato associato a ciascun quarter così, se è uno che al nostro amico manca, può aggiungerlo alla collezione.

Nell’espressione match per questo codice, aggiungiamo una variabile chiamata stato al pattern che corrisponde ai valori della variante Coin::Quarter. Quando un Coin::Quarter corrisponde, la variabile stato si legherà al valore dello stato di quel quarter. Possiamo poi usare stato nel codice di quel ramo, così:

#[derive(Debug)]
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn valore_in_cent(moneta: Moneta) -> u8 {
    match moneta {
        Moneta::Penny => 1,
        Moneta::Nickel => 5,
        Moneta::Dime => 10,
        Moneta::Quarter(stato) => {
            println!("Quarter statale del {stato:?}!");
            25
        }
    }
}

fn main() {
    valore_in_cent(Moneta::Quarter(StatoUSA::Alaska));
}

Se chiamassimo valore_in_cent(Moneta::Quarter(StatoUSA::Alaska)), moneta sarebbe Moneta::Quarter(StatoUSA::Alaska). Quando confrontiamo quel valore con ciascuno dei rami del match, nessuna corrisponde fino a che non raggiungiamo Moneta::Quarter(stato). A quel punto stato sarà vincolato al valore StatoUSA::Alaska. Possiamo quindi usare quel vincolo nell’espressione println!, ottenendo così il valore interno dello stato dalla variante Moneta::Quarter.

Corrispondenza con Option<T>

Nella sezione precedente volevamo ottenere il valore interno T di Some quando si usa Option<T>; possiamo anche gestire Option<T> usando match, proprio come abbiamo fatto con l’enum Moneta! Invece di confrontare monete, confronteremo le varianti di Option<T>, ma il funzionamento dell’espressione match rimane lo stesso.

Supponiamo di voler scrivere una funzione che prende un Option<i32> e, se c’è un valore dentro, aggiunge 1 a quel valore. Se non c’è un valore dentro, la funzione dovrebbe restituire il valore None e non tentare di eseguire alcuna operazione.

Questa funzione è molto semplice da scrivere, grazie a match, e apparirà come nel Listato 6-5.

fn main() {
    fn più_uno(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let cinque = Some(5);
    let sei = più_uno(cinque);
    let nulla = più_uno(None);
}
Listato 6-5: Una funzione che utilizza un’espressione match su una Option<i32>

Esaminiamo la prima esecuzione di più_uno in maggiore dettaglio. Quando chiamiamo più_uno(cinque), la variabile x nel corpo di più_uno avrà il valore Some(5). Quindi confrontiamo quello rispetto a ciascun ramo del match:

fn main() {
    fn più_uno(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let cinque = Some(5);
    let sei = più_uno(cinque);
    let nulla = più_uno(None);
}

Il valore Some(5) non corrisponde al pattern None, quindi si continua con il ramo successivo:

fn main() {
    fn più_uno(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let cinque = Some(5);
    let sei = più_uno(cinque);
    let nulla = più_uno(None);
}

Some(5) corrisponde a Some(i)? Sì! Abbiamo la stessa variante. i si lega al valore contenuto in Some, quindi i assume il valore 5. Il codice nel ramo del match viene quindi eseguito: aggiungiamo 1 al valore di i e creiamo un nuovo valore Some con il totale 6 all’interno.

Consideriamo ora la seconda chiamata di più_uno nel Listato 6-5, dove x è None. Entriamo nel match e confrontiamolo con il primo ramo:

fn main() {
    fn più_uno(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let cinque = Some(5);
    let sei = più_uno(cinque);
    let nulla = più_uno(None);
}

Corrisponde! Non c’è alcun valore a cui aggiungere, quindi il programma si ferma e restituisce il valore None sul lato destro di =>. Poiché il primo ramo ha corrisposto, nessun altro ramo viene confrontato.

Combinare match ed enum è utile in molte situazioni. Vedrai questo schema spesso nel codice Rust: fai match su un’enum, leghi una variabile ai dati interni e poi esegui codice basato su di essi. All’inizio è un po’ ostico, ma una volta che ci prendi la mano vorrai averlo in tutti i linguaggi. È un costrutto tra i preferiti dagli utenti.

Le Corrispondenze sono Esaustive

C’è un altro aspetto di match da discutere: i pattern dei rami devono coprire tutte le possibilità. Considera questa versione della nostra funzione più_uno, che contiene un bug e non si compilerà:

fn main() {
    fn più_uno(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let cinque = Some(5);
    let sei = più_uno(cinque);
    let nulla = più_uno(None);
}

Non abbiamo gestito il caso None, quindi questo codice provocherà un errore. Per fortuna, è un errore che Rust sa come intercettare. Se proviamo a compilare questo codice, otterremo questo errore:

$ cargo run
   Compiling enums v0.1.0 (file:///progetti/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:4:15
  |
4 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/option.rs:593:1
 ::: /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/option.rs:597:5
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
5 ~             Some(i) => Some(i + 1),
6 ~             None => todo!(),
  |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Rust sa che non abbiamo coperto ogni possibile caso, e sa persino quale pattern abbiamo dimenticato! I match in Rust sono esaustivi (exhaustive): dobbiamo coprire ogni possibilità affinché il codice sia valido. Soprattutto nel caso di Option<T>, quando Rust ci impedisce di dimenticare di gestire esplicitamente il caso None, ci protegge dall’assumere che abbiamo un valore quando potremmo avere null, rendendo impossibile “l’errore da un miliardo di dollari” accennato nel capitolo precedente.

Pattern Pigliatutto e Segnaposto _

Usando le enum, possiamo anche eseguire determinate azioni per alcuni valori particolari, ma per tutti gli altri valori adottare un’azione predefinita. Immagina di implementare un gioco dove, se tiri un 3 in un lancio di dadi, il tuo giocatore non si muove ma riceve un nuovo cappello buffo. Se tiri un 7, il giocatore perde il cappello buffo. Per tutti gli altri valori, il giocatore si muove di quel numero di spazi sulla tavola di gioco. Ecco un match che implementa quella logica, con il risultato del lancio specificato anziché casuale, e tutta l’altra logica rappresentata da funzioni senza corpo perché implementarli esula da questo esempio:

fn main() {
    let tiro_dadi = 9;
    match tiro_dadi {
        3 => metti_cappello_buffo(),
        7 => togli_cappello_buffo(),
        altro => muovi_giocatore(altro),
    }

    fn metti_cappello_buffo() {}
    fn togli_cappello_buffo() {}
    fn muovi_giocatore(num_spazi: u8) {}
}

Per i primi due rami, i pattern sono i valori letterali 3 e 7. Per l’ultimo ramo che copre tutti gli altri valori possibili, il pattern è la variabile che abbiamo scelto di chiamare altro. Il codice che viene eseguito per il ramo altro usa la variabile passando il suo valore alla funzione muovi_giocatore.

Questo codice compila, anche se non abbiamo elencato tutti i possibili valori che un u8 può avere, perché l’ultimo pattern corrisponderà a tutti i valori non specificamente elencati. Questo pattern pigliatutto (catch-all) soddisfa il requisito che match deve essere esaustivo. Nota che dobbiamo mettere il ramo pigliatutto per ultimo perché i pattern sono valutati in ordine. Se mettessimo il ramo pigliatutto prima, gli altri rami non verrebbero mai eseguiti, quindi Rust ci avvertirebbe se aggiungessimo rami dopo un pigliatutto!

Rust ha anche un pattern che possiamo usare quando vogliamo un pigliatutto ma non vogliamo usare il valore corrispondente: _ è un pattern speciale che corrisponde a qualsiasi valore e non si lega a quel valore. Questo dice a Rust che non useremo il valore, quindi Rust non ci segnalerà una variabile inutilizzata.

Cambiamo le regole del gioco: ora, se tiri qualsiasi cosa diversa da 3 o 7, devi rilanciare. Non abbiamo più bisogno di usare il valore pigliatutto, quindi possiamo cambiare il codice per usare _ al posto della variabile chiamata altro:

fn main() {
    let tiro_dadi = 9;
    match tiro_dadi {
        3 => metti_cappello_buffo(),
        7 => togli_cappello_buffo(),
        _ => tira_ancora(),
    }

    fn metti_cappello_buffo() {}
    fn togli_cappello_buffo() {}
    fn tira_ancora() {}
}

Anche questo esempio soddisfa il requisito di esaustività perché stiamo esplicitamente ignorando tutti gli altri valori nell’ultimo ramo; non abbiamo dimenticato nulla.

Infine, cambiamo ancora una volta le regole del gioco in modo che non succeda nient’altro nel tuo turno se tiri qualcosa di diverso da 3 o 7. Possiamo esprimerlo usando il valore unit (la tupla vuota) come codice associato al ramo _:

fn main() {
    let tiro_dadi = 9;
    match tiro_dadi {
        3 => metti_cappello_buffo(),
        7 => togli_cappello_buffo(),
        _ => (),
    }

    fn metti_cappello_buffo() {}
    fn togli_cappello_buffo() {}
}

Qui stiamo dicendo esplicitamente a Rust che non useremo alcun altro valore che non corrisponda a un pattern in un ramo precedente, e non vogliamo eseguire alcun codice in questo caso.

C’è molto altro sui pattern e sul matching che tratteremo nel Capitolo 19. Per ora, passiamo alla sintassi if let, che può essere utile nelle situazioni più semplici in cui l’espressione match risulta un po’ verbosa.

Controllare il Flusso con if let e let else

La sintassi if let consente di combinare if e let in un modo meno verboso per gestire i valori che corrispondono a un singolo pattern, ignorando gli altri. Considera il programma nel Listato 6-6 che fa matching su un Option<u8> nella variabile config_max ma vuole eseguire codice solo se il valore è la variante Some.

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("Il massimo è configurato per essere {max}"),
        _ => (),
    }
}
Listato 6-6: Un match che si interessa solo di eseguire codice quando il valore è Some

Se il valore è Some, stampiamo il valore contenuto nella variante Some legandolo alla variabile max nel pattern. Non vogliamo fare nulla per il valore None. Per soddisfare l’espressione match dobbiamo aggiungere _ => () dopo aver processato una sola variante, il che, a ben vedere, sembra codice un po’ inutile.

Invece, possiamo scrivere questo in modo più breve usando if let. Il codice seguente si comporta allo stesso modo del match nel Listato 6-6:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("Il massimo è configurato per essere {max}");
    }
}

La sintassi if let prende un pattern e un’espressione separati da un segno di uguale. Funziona come un match, dove l’espressione è data al match e il pattern è il suo primo ramo. In questo caso il pattern è Some(max), e max si lega al valore dentro il Some. Possiamo quindi usare max nel corpo del blocco if let nello stesso modo in cui lo usavamo nel corrispondente ramo del match. Il codice nel blocco if let viene eseguito solo se il valore corrisponde al pattern.

Usare if let significa meno digitazione, meno indentazione e meno codice poco utile. Tuttavia si perde il controllo di esaustività che il match impone e che garantisce di non dimenticare di gestire dei casi. La scelta tra match e if let dipende da cosa stai facendo in quella situazione particolare e se un codice più conciso valga la perdita del controllo esaustivo.

In altre parole, puoi pensare a if let come ad una espressione match ridotta all’osso che esegue codice quando il valore corrisponde a un pattern e poi ignora tutti gli altri valori.

Possiamo includere un else con un if let. Il blocco di codice che accompagna l’else è lo stesso blocco che andrebbe con il caso _ nell’espressione match equivalente all’if let con else. Ricorda la definizione dell’enum Moneta nel Listato 6-4, dove la variante Quarter conteneva anche un valore StatoUSA. Se volessimo contare tutte le monete non-quarter che vediamo annunciando anche lo stato dei quarter, potremmo farlo con un’espressione match, così:

#[derive(Debug)]
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn main() {
    let moneta = Moneta::Penny;
    let mut conteggio = 0;
    match moneta {
        Moneta::Quarter(stato) => println!("Quarter statale del {stato:?}!"),
        _ => conteggio += 1,
    }
}

Oppure potremmo usare un if let e un else, così:

#[derive(Debug)]
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn main() {
    let moneta = Moneta::Penny;
    let mut conteggio = 0;
    if let Moneta::Quarter(stato) = moneta {
        println!("Quarter statale del {stato:?}!");
    } else {
        conteggio += 1;
    }
}

Restare sul “Percorso Felice” con let...else

Una buona pratica è eseguire una computazione quando un valore è presente e restituire un valore di default altrimenti. Continuando con il nostro esempio delle monete con un valore StatoUSA, se volessimo dire qualcosa di divertente a seconda di quanto fosse vecchio lo stato sul quarter, potremmo introdurre un metodo su StatoUSA per verificare l’età dello stato, così:

#[derive(Debug)] // so we can inspect the state in a minute
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

impl StatoUSA {
    fn esistente_nel(&self, anno: u16) -> bool {
        match self {
            StatoUSA::Alabama => anno >= 1819,
            StatoUSA::Alaska => anno >= 1959,
            // --taglio--
        }
    }
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn desc_quarter_statale(moneta: Moneta) -> Option<String> {
    if let Moneta::Quarter(stato) = moneta {
        if stato.esistente_nel(1900) {
            Some(format!("{stato:?} è abbastanza vecchio, per l'America!"))
        } else {
            Some(format!("{stato:?} è abbastanza recente."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = desc_quarter_statale(Moneta::Quarter(StatoUSA::Alaska)) {
        println!("{desc}");
    }
}

Poi potremmo usare if let per fare matching sul tipo di moneta, introducendo una variabile stato all’interno del corpo della condizione, come nel Listato 6-7.

#[derive(Debug)] // so we can inspect the state in a minute
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

impl StatoUSA {
    fn esistente_nel(&self, anno: u16) -> bool {
        match self {
            StatoUSA::Alabama => anno >= 1819,
            StatoUSA::Alaska => anno >= 1959,
            // --taglio--
        }
    }
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn desc_quarter_statale(moneta: Moneta) -> Option<String> {
    if let Moneta::Quarter(stato) = moneta {
        if stato.esistente_nel(1900) {
            Some(format!("{stato:?} è abbastanza vecchio, per l'America!"))
        } else {
            Some(format!("{stato:?} è abbastanza recente."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = desc_quarter_statale(Moneta::Quarter(StatoUSA::Alaska)) {
        println!("{desc}");
    }
}
Listato 6-7: Verificare se uno stato esisteva nel 1900 usando condizionali annidati dentro un if let

Questo risolve il problema, ma ha spostato il lavoro nel corpo dell’if let, e se il lavoro da fare è più complicato potrebbe risultare difficile seguire come i rami di alto livello si relazionano. Potremmo anche sfruttare il fatto che le espressioni producono un valore, o per produrre stato dall’if let o per ritornare anticipatamente, come in Listato 6-8. (Si potrebbe fare qualcosa di simile anche con un match.)

#[derive(Debug)] // so we can inspect the state in a minute
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

impl StatoUSA {
    fn esistente_nel(&self, anno: u16) -> bool {
        match self {
            StatoUSA::Alabama => anno >= 1819,
            StatoUSA::Alaska => anno >= 1959,
            // --taglio--
        }
    }
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn desc_quarter_statale(moneta: Moneta) -> Option<String> {
    let stato = if let Moneta::Quarter(stato) = moneta {
        stato
    } else {
        return None;
    };

    if stato.esistente_nel(1900) {
        Some(format!("{stato:?} è abbastanza vecchio, per l'America!"))
    } else {
        Some(format!("{stato:?} è abbastanza recente."))
    }
}

fn main() {
    if let Some(desc) = desc_quarter_statale(Moneta::Quarter(StatoUSA::Alaska)) {
        println!("{desc}");
    }
}
Listato 6-8: Usare if let per produrre un valore o ritornare anticipatamente

Questo però è un po’ scomodo da seguire: un ramo dell’if let produce un valore e l’altro ritorna dalla funzione completamente.

Per rendere più esprimibile questo pattern comune, Rust ha let...else. La sintassi let...else prende un pattern a sinistra e un’espressione a destra, molto simile a if let, ma non ha un ramo if, soltanto un ramo else. Se il pattern corrisponde, legherà il valore estratto dal pattern nello scope esterno. Se il pattern non corrisponde, il flusso va nel ramo else, che deve restituire dalla funzione.

Nel Listato 6-9 puoi vedere come Listato 6-8 appare usando let...else al posto di if let.

#[derive(Debug)] // so we can inspect the state in a minute
enum StatoUSA {
    Alabama,
    Alaska,
    // --taglio--
}

impl StatoUSA {
    fn esistente_nel(&self, anno: u16) -> bool {
        match self {
            StatoUSA::Alabama => anno >= 1819,
            StatoUSA::Alaska => anno >= 1959,
            // --taglio--
        }
    }
}

enum Moneta {
    Penny,
    Nickel,
    Dime,
    Quarter(StatoUSA),
}

fn desc_quarter_statale(moneta: Moneta) -> Option<String> {
    let Moneta::Quarter(stato) = moneta else {
        return None;
    };

    if stato.esistente_nel(1900) {
        Some(format!("{stato:?} è abbastanza vecchio, per l'America!"))
    } else {
        Some(format!("{stato:?} è abbastanza recente."))
    }
}

fn main() {
    if let Some(desc) = desc_quarter_statale(Moneta::Quarter(StatoUSA::Alaska)) {
        println!("{desc}");
    }
}
Listato 6-9: Usare let...else per semplificare il flusso della funzione

Nota che in questo modo si resta sul “percorso felice” nel corpo principale della funzione, senza avere un controllo di flusso significativamente diverso per i due rami come invece succedeva con if let.

Se hai una situazione in cui la logica è troppo verbosa per essere espressa con un match, ricorda che anche if let e let...else sono nella tua cassetta degli attrezzi di Rust.

Riepilogo

Abbiamo ora coperto come usare le enum per creare type personalizzati che possono essere uno tra un insieme di valori enumerati. Abbiamo mostrato come il type Option<T> della libreria standard ti aiuta a usare il sistema dei type per prevenire errori. Quando i valori delle enum contengono dati, puoi usare match o if let per estrarre e usare quei valori, a seconda di quanti casi devi gestire.

I tuoi programmi Rust possono ora esprimere concetti nel tuo dominio usando struct ed enum. Creare type personalizzati da usare nella tua API assicura maggiore sicurezza dei dati (type safety): il compilatore garantirà che le tue funzioni ricevano solo i valori del type che ciascuna funzione si aspetta.

Per fornire un’API ben organizzata ai tuoi utenti, chiara da usare ed esponendo solo ciò che serve, passiamo ora ai moduli di Rust.

Pacchetti, Crate, e Moduli

Quando scriverai programmi sempre più complessi, organizzare il tuo codice diventerà sempre più importante. Raccogliendo funzionalità correlate e separando il codice con caratteristiche distinte, chiarirai dove trovare il codice che implementa una particolare funzionalità e quindi dove guardare per modificare il codice di quella funzionalità se necessario.

I programmi che abbiamo scritto finora sono stati tutti implementati con un singolo modulo in un unico file. Man mano che un progetto cresce, dovresti organizzare il codice suddividendolo in più moduli e poi in più file. Un pacchetto (package) può contenere più crate binari (binary crate) e opzionalmente un crate libreria (library crate). Man mano che un pacchetto cresce, puoi estrarne parti in crate separate che diventeranno dipendenze esterne. Questo capitolo copre tutte queste tecniche. Per progetti molto grandi che comprendono un insieme di pacchetti interconnessi che evolvono insieme, Cargo fornisce degli spazi di lavoro (workspace), che tratteremo in “Cargo Workspace” nel Capitolo 14.

Discuteremo anche l’incapsulamento dei dettagli di implementazione, che ti consente di riutilizzare il codice a un livello più alto: una volta implementata un’operazione, l’altro codice può chiamare il tuo codice tramite la sua interfaccia pubblica senza dover sapere come funziona l’implementazione. Il modo in cui scrivi il codice definisce quali parti sono pubbliche per l’uso da parte di altri codici e quali parti sono dettagli di implementazione privati che ti riservi il diritto di modificare. Questo è un altro modo per limitare la quantità di dettagli che devi tenere a mente.

Un concetto correlato è lo scope: il contesto annidato in cui è scritto il codice ha un insieme di nomi definiti come “in scope”. Quando si legge, si scrive e si compila il codice, i programmatori e i compilatori devono sapere se un particolare nome in un particolare punto si riferisce a una variabile, funzione, struct, enum, modulo, costante o altro elemento e cosa significa quell’elemento. Puoi creare scope e modificare quali nomi sono in o fuori scope. Non puoi avere due elementi con lo stesso nome nello stesso scope; sono disponibili strumenti per risolvere i conflitti di nomenclatura.

Rust ha una serie di funzionalità che ti consentono di gestire l’organizzazione del tuo codice, inclusi i dettagli esposti, i dettagli privati e quali nomi sono in ogni scope nei tuoi programmi. Queste funzionalità, a volte definite collettivamente sistema dei moduli (module system), includono:

  • Pacchetti: Una funzionalità di Cargo che ti consente di costruire, testare e condividere crate
  • Crate: Un albero di moduli che produce una libreria o un eseguibile
  • Moduli e use: Ti consentono di controllare l’organizzazione, lo scope e la privacy dei percorsi (paths)
  • Path: Un modo per nominare un elemento, come una struct, una funzione o un modulo

In questo capitolo, tratteremo tutte queste funzionalità, discuteremo come interagiscono e spiegheremo come usarle per gestire lo scope. Alla fine, dovresti avere una solida comprensione del module system e essere in grado di lavorare con gli scope come un professionista!

Pacchetti e Crate

Le prime parti del sistema dei moduli che tratteremo sono i pacchetti e i crate.

Un crate è la quantità minima di codice che il compilatore Rust considera in un dato momento. Anche se esegui rustc invece di cargo e passi un singolo file di codice sorgente (come abbiamo fatto all’inizio in “Programma Rust Basilare” nel Capitolo 1), il compilatore considera quel file come un crate. I crate possono contenere moduli, e i moduli possono essere definiti in altri file che vengono compilati con il crate, come vedremo nelle sezioni successive.

Un crate può presentarsi in una delle due forme: un crate binario o un crate libreria. I crate binari sono programmi che puoi compilare in un eseguibile che puoi eseguire, come un programma da riga di comando o un server. Ognuno deve avere una funzione chiamata main che definisce cosa succede quando l’eseguibile viene eseguito. Tutti i crate che abbiamo creato finora sono stati crate binari.

I crate libreria non hanno una funzione main, e non si compilano in un eseguibile. Invece, definiscono funzionalità destinate a essere condivise con progetti multipli. Ad esempio, il crate rand che abbiamo usato nel Capitolo 2 fornisce funzionalità che generano numeri casuali. La maggior parte delle volte, quando i Rustacean dicono “crate”, intendono crate libreria, e usano “crate” in modo intercambiabile con il concetto generale di programmazione di una “libreria”.

La radice del crate (crate root) è un file sorgente da cui il compilatore Rust inizia e costituisce il modulo radice del tuo crate (spiegheremo i moduli in dettaglio nel capitolo “Controllare Scope e Privacy con i Moduli”).

Un pacchetto (package) è un insieme di uno o più crate che fornisce un insieme di funzionalità. Un pacchetto contiene un file Cargo.toml che descrive come costruire quei crate. Cargo è anch’esso in realtà un pacchetto che contiene il crate binario per lo strumento da riga di comando che hai usato per costruire il tuo codice finora. Il pacchetto Cargo contiene anche un crate libreria di cui il crate binario ha bisogno. Altri progetti possono dipendere dal crate libreria Cargo per utilizzare la stessa logica che utilizza lo strumento da riga di comando Cargo.

Un pacchetto può contenere quanti più crate binari desideri, ma al massimo solo un crate libreria. Un pacchetto deve contenere almeno un crate, sia esso un crate libreria o binario.

Esploriamo cosa succede quando creiamo un pacchetto. Cominciamo con il comando cargo new mio-progetto:

$ cargo new mio-progetto
     Created binary (application) `mio-progetto` package
$ ls mio-progetto
Cargo.toml
src
$ ls mio-progetto/src
main.rs

Dopo aver eseguito cargo new mio-progetto, usiamo ls per vedere cosa crea Cargo. Nella cartella mio-progetto, c’è un file Cargo.toml, che definisce un pacchetto. C’è anche una cartella src che contiene main.rs. Apri Cargo.toml nel tuo editor di testo e nota che non c’è menzione di src/main.rs. Cargo segue una convenzione secondo cui src/main.rs è la radice del crate di un crate binario con lo stesso nome del pacchetto. Allo stesso modo, Cargo sa che se la cartella del pacchetto contiene src/lib.rs, il pacchetto contiene un crate libreria con lo stesso nome del pacchetto, e src/lib.rs è la sua radice del crate. Cargo passa i file di radice del crate a rustc per costruire la libreria o il binario.

Qui, abbiamo un pacchetto che contiene solo src/main.rs, il che significa che contiene solo un crate binario chiamato mio-progetto. Se un pacchetto contiene src/main.rs e src/lib.rs, avrà due crate: uno binario e uno libreria, entrambi con lo stesso nome del pacchetto. Un pacchetto può avere più crate binari posizionando file nella cartella src/bin: ogni file sarà un crate binario separato.

Controllare Scope e Privacy con i Moduli

In questa sezione, parleremo dei moduli e di altre parti del sistema dei moduli, in particolare dei path (percorsi), che ti permettono di nominare gli elementi; la parola chiave use che porta un path in scope; e la parola chiave pub per rendere pubblici gli elementi. Discuteremo anche della parola chiave as, dei pacchetti esterni e dell’operatore glob.

Scheda Informativa sui Moduli

Prima di entrare nei dettagli dei moduli e dei path, qui forniamo un rapido riferimento su come funzionano i moduli, i path, la parola chiave use e la parola chiave pub nel compilatore, e come la maggior parte degli sviluppatori organizza il proprio codice. Esamineremo esempi di ciascuna di queste regole nel corso di questo capitolo, ma questo è un ottimo riassunto da consultare come promemoria su come funzionano i moduli.

  • Inizia dalla radice del crate: Quando compili un crate, il compilatore prima cerca nel file di radice del crate (di solito src/lib.rs per un crate libreria e src/main.rs per un crate binario) il codice da compilare.
  • Dichiarare moduli: Nel file di radice del crate, puoi dichiarare nuovi moduli; ad esempio, dichiari un modulo “giardino” con mod giardino;. Il compilatore cercherà il codice del modulo in questi luoghi:
    • Sulla linea, all’interno delle parentesi graffe che sostituiscono il punto e virgola dopo mod giardino
    • Nel file src/giardino.rs
    • Nel file src/giardino/mod.rs
  • Dichiarare sottomoduli: In qualsiasi file diverso dalla radice del crate, puoi dichiarare sottomoduli. Ad esempio, potresti dichiarare mod verdure; in src/giardino.rs. Il compilatore cercherà il codice del sottomodulo all’interno della cartella nominata per il modulo genitore (parent) in questi luoghi:
    • Sulla linea, direttamente dopo mod verdure, all’interno delle parentesi graffe invece del punto e virgola
    • Nel file src/giardino/verdure.rs
    • Nel file src/giardino/verdure/mod.rs
  • Path per il codice nei moduli: Una volta che un modulo è parte del tuo crate, puoi fare riferimento al codice in quel modulo da qualsiasi altro punto dello stesso crate, purché le regole di privacy lo consentano, utilizzando il path per il codice. Ad esempio, un type Asparagi nel modulo delle verdure del giardino si troverebbe al path crate::giardino::verdure::Asparagi.
  • Privato vs. pubblico: Il codice all’interno di un modulo è non utilizzabile, privato, dai suoi moduli genitore come impostazione predefinita. Per rendere un modulo utilizzabile, pubblico, è necessario dichiaralo con pub mod invece di mod. Per rendere pubblici anche gli elementi all’interno di un modulo pubblico, usa pub prima delle loro dichiarazioni.
  • La parola chiave use: All’interno di uno scope, la parola chiave use crea scorciatoie per gli elementi per ridurre la ripetizione di lunghi path. In qualsiasi scope che può fare riferimento a crate::giardino::verdure::Asparagi, puoi creare una scorciatoia con use crate::giardino::verdure::Asparagi; e da quel momento in poi devi scrivere solo Asparagi per utilizzare quel type nello scope.

Ora creiamo un crate binario chiamato cortile che illustra queste regole. La cartella del crate, anch’essa chiamata cortile, contiene questi file e cartelle:

cortile
├── Cargo.lock
├── Cargo.toml
└── src
    ├── giardino
    │   └── verdure.rs
    ├── giardino.rs
    └── main.rs

La radice del crate in questo caso è src/main.rs, e contiene:

File: src/main.rs
use crate::giardino::verdure::Asparagi;

pub mod giardino;

fn main() {
    let pianta = Asparagi {};
    println!("Sto coltivando {pianta:?}!");
}

La riga pub mod giardino; dice al compilatore di includere il codice che trova in src/giardino.rs, che è:

File: src/giardino.rs
pub mod verdure;

Qui, pub mod verdure; significa che il codice in src/giardino/verdure.rs è incluso anch’esso. Quel codice è:

#[derive(Debug)]
pub struct Asparagi {}

Ora entriamo nei dettagli di queste regole e dimostriamo come funzionano!

Raggruppare Codice Correlato in Moduli

I moduli ci permettono di organizzare il codice all’interno di un crate per migliore leggibilità e facilità di riutilizzo. I moduli ci consentono anche di controllare la privacy degli elementi, poiché il codice all’interno di un modulo è privato come impostazione predefinita. Gli elementi privati sono dettagli di implementazione interni non disponibili per l’uso esterno. Possiamo scegliere di rendere pubblici i moduli e gli elementi al loro interno, il che li espone per consentire al codice esterno di utilizzarli e dipendere da essi.

Come esempio, scriviamo un crate libreria che fornisce la funzionalità di un ristorante. Definiremo le firme delle funzioni ma lasceremo i loro corpi vuoti per concentrarci sull’organizzazione del codice piuttosto che sull’implementazione vera e propria.

Nel settore della ristorazione, alcune “funzioni” di un ristorante sono chiamate sala e altre cucina. La “sala” è dove si trovano i clienti; questo comprende dove l’oste riceve i clienti, i camerieri prendono ordini e pagamenti, e i baristi preparano drink. La “cucina” è dove gli chef e i cuochi lavorano in cucina, i lavapiatti puliscono e i manager svolgono lavori amministrativi.

Per strutturare il nostro crate in questo modo, possiamo organizzare le sue funzioni in moduli annidati. Crea una nuova libreria chiamata ristorante eseguendo cargo new ristorante --lib. Poi inserisci il codice nel Listato 7-1 in src/lib.rs per definire alcuni moduli e firme di funzione; questo codice è la sezione sala.

File: src/lib.rs
mod sala {
    mod accoglienza {
        fn aggiungi_in_lista() {}

        fn metti_al_tavolo() {}
    }

    mod servizio {
        fn prendi_ordine() {}

        fn servi_ordine() {}

        fn prendi_pagamento() {}
    }
}
Listato 7-1: Un modulo sala contenente altri moduli che poi contengono funzioni

Definiamo un modulo con la parola chiave mod seguita dal nome del modulo (in questo caso, sala). Il corpo del modulo va quindi all’interno delle parentesi graffe. All’interno dei moduli, possiamo inserire altri moduli, come in questo caso con i moduli accoglienza e servizio. I moduli possono anche contenere definizioni per altri elementi, come struct, enum, costanti, trait e, come nel Listato 7-1, funzioni.

Utilizzando i moduli, possiamo raggruppare definizioni correlate insieme e nominare il motivo per cui sono correlate. I programmatori che utilizzano questo codice possono navigare nel codice in base ai gruppi piuttosto che dover leggere tutte le definizioni, rendendo più facile trovare le definizioni rilevanti per loro. I programmatori che aggiungono nuove funzionalità a questo codice saprebbero dove posizionare il codice per mantenere organizzato il programma.

In precedenza, abbiamo menzionato che src/main.rs e src/lib.rs sono chiamati radici del crate. Il motivo del loro nome è che i contenuti di uno di questi due file formano un modulo chiamato crate alla radice della struttura del modulo del crate, nota come albero dei moduli (module tree).

Il Listato 7-2 mostra l’albero dei moduli per la struttura nel Listato 7-1.

crate
 └── sala
     ├── accoglienza
     │   ├── aggiungi_in_lista
     │   └── metti_al_tavolo
     └── servizio
         ├── prendi_ordine
         ├── servi_ordine
         └── prendi_pagamento
Listato 7-2: L’albero dei moduli per il codice nel Listato 7-1

Questo albero mostra come alcuni dei moduli si annidano all’interno di altri moduli; ad esempio, accoglienza si annida all’interno di sala. L’albero mostra anche che alcuni moduli sono fratelli, il che significa che sono definiti nello stesso modulo; accoglienza e servizio sono fratelli definiti all’interno di sala. Se il modulo A è contenuto all’interno del modulo B, diciamo che il modulo A è il figlio del modulo B e che il modulo B è il genitore del modulo A. Nota che l’intero albero dei moduli è radicato sotto il modulo implicito chiamato crate.

L’albero dei moduli potrebbe ricordarti l’albero delle cartelle del filesystem sul tuo computer; questo è un confronto molto appropriato! Proprio come le cartelle in un filesystem, usi i moduli per organizzare il tuo codice. E proprio come i file in una cartella, abbiamo bisogno di un modo per trovare i nostri moduli.

Percorsi per Fare Riferimento a un Elemento nell’Albero dei Moduli

Per mostrare a Rust dove trovare un elemento in un albero dei moduli, utilizziamo un path (percorso) nello stesso modo in cui usiamo un path quando navighiamo in un filesystem. Per chiamare una funzione, dobbiamo conoscere il suo path.

Un path può assumere due forme:

  • Un path assoluto è il percorso completo che inizia dalla radice del crate; per il codice di un crate esterno, il percorso assoluto inizia con il nome del crate, e per il codice del crate corrente, inizia con il letterale crate.
  • Un path relativo inizia dal modulo corrente e utilizza self, super o un identificatore nel modulo corrente.

Sia i path assoluti che relativi sono seguiti da uno o più identificatori separati da doppi due punti (::).

Tornando al Listato 7-1, supponiamo di voler chiamare la funzione aggiungi_in_lista. Questo è lo stesso che chiedere: qual è il path della funzione aggiungi_in_lista? Il Listato 7-3 contiene il Listato 7-1 con alcuni dei moduli e delle funzioni rimossi.

Mostreremo due modi per chiamare la funzione aggiungi_in_lista da una nuova funzione, mangiare_al_ristorante, definita nella radice del crate. Questi path sono corretti, ma c’è un altro problema che impedirà a questo esempio di compilare così com’è. Spiegheremo perché tra poco.

La funzione mangiare_al_ristorante fa parte dell’API pubblica del nostro crate libreria, quindi la contrassegniamo con la parola chiave pub. Nella sezione “Esporre Path con la Parola Chiave pub, entreremo nei dettagli di pub.

File: src/lib.rs
mod sala {
    mod accoglienza {
        fn aggiungi_in_lista() {}
    }
}

pub fn mangiare_al_ristorante() {
    // Path assoluta
    crate::sala::accoglienza::aggiungi_in_lista();

    // Path relativa
    sala::accoglienza::aggiungi_in_lista();
}
Listato 7-3: Chiamare la funzione aggiungi_in_lista utilizzando path assoluti e relativi

La prima volta che chiamiamo la funzione aggiungi_in_lista in mangiare_al_ristorante, utilizziamo un path assoluto. La funzione aggiungi_in_lista è definita nello stesso crate di mangiare_al_ristorante, il che significa che possiamo usare la parola chiave crate per iniziare un path assoluto. Includiamo quindi ciascuno dei moduli successivi fino a raggiungere aggiungi_in_lista. Puoi immaginare un filesystem con la stessa struttura: specificheremmo il path /sala/accoglienza/aggiungi_in_lista per eseguire il programma aggiungi_in_lista; utilizzare il nome del crate per partire dalla radice del crate è come usare / per partire dalla radice del filesystem nel tuo terminale.

La seconda volta che chiamiamo aggiungi_in_lista in mangiare_al_ristorante, utilizziamo un path relativo. Il path inizia con sala, il nome del modulo definito allo stesso livello dell’albero dei moduli di mangiare_al_ristorante. Qui l’equivalente nel filesystem sarebbe utilizzare il path sala/accoglienza/aggiungi_in_lista. Iniziare con un nome di modulo significa che il path è relativo.

Scegliere se utilizzare un path relativo o assoluto è una decisione che prenderai in base al tuo progetto, e dipende da se è più probabile che tu sposti il codice di definizione dell’elemento separatamente o insieme al codice che utilizza l’elemento. Ad esempio, se spostassimo il modulo sala e la funzione mangiare_al_ristorante in un modulo chiamato gestione_cliente, dovremmo aggiornare il path assoluto per aggiungi_in_lista, ma il path relativo rimarrebbe valido. Tuttavia, se spostassimo la funzione mangiare_al_ristorante separatamente in un modulo chiamato cena, il path assoluto per la chiamata a aggiungi_in_lista rimarrebbe lo stesso, ma il path relativo dovrebbe essere aggiornato. La nostra preferenza in generale è specificare path assoluti perché è più probabile che vogliamo spostare le definizioni di codice e le chiamate agli elementi in modo indipendente l’una dall’altra.

Proviamo a compilare il Listato 7-3 e scopriamo perché non si compila ancora! Gli errori che otteniamo sono mostrati nel Listato 7-4.

$ cargo build
   Compiling ristorante v0.1.0 (file:///progetti/ristorante)
error[E0603]: module `accoglienza` is private
 --> src/lib.rs:9:18
  |
9 |     crate::sala::accoglienza::aggiungi_in_lista();
  |                  ^^^^^^^^^^^  ----------------- function `aggiungi_in_lista` is not publicly re-exported
  |                  |
  |                  private module
  |
note: the module `accoglienza` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod accoglienza {
  |     ^^^^^^^^^^^^^^^

error[E0603]: module `accoglienza` is private
  --> src/lib.rs:12:11
   |
12 |     sala::accoglienza::aggiungi_in_lista();
   |           ^^^^^^^^^^^  ----------------- function `aggiungi_in_lista` is not publicly re-exported
   |           |
   |           private module
   |
note: the module `accoglienza` is defined here
  --> src/lib.rs:2:5
   |
 2 |     mod accoglienza {
   |     ^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `ristorante` (lib) due to 2 previous errors
Listato 7-4: Errori del compilatore durante la costruzione del codice nel Listato 7-3

I messaggi di errore dicono che il modulo accoglienza è privato. In altre parole, abbiamo i path corretti per il modulo accoglienza e la funzione aggiungi_in_lista, ma Rust non ci permette di usarli perché non ha accesso alle sezioni private. In Rust, tutti gli elementi (funzioni, metodi, struct, enum, moduli e costanti) sono privati rispetto ai moduli genitore come impostazione predefinita. Se desideri rendere un elemento come una funzione o una struct privata, lo metti in un modulo.

Gli elementi in un modulo genitore non possono utilizzare gli elementi privati all’interno dei moduli figli, ma gli elementi nei moduli figli possono utilizzare gli elementi nei loro moduli antenati. Questo perché i moduli figli incapsulano e nascondono i loro dettagli di implementazione, ma i moduli figli possono vedere il contesto in cui sono definiti. Per continuare con la nostra metafora, pensa alle regole di privacy come se fossero l’ufficio posteriore di un ristorante: ciò che accade lì è privato e nascosto per i clienti del ristorante, ma i manager dell’ufficio possono vedere e fare tutto nel ristorante che gestiscono.

Rust ha scelto che far funzionare il sistema dei moduli in questo modo, nascondendo i dettagli di implementazione interni come impostazione predefinita. In questo modo, sai quali parti del codice interno puoi modificare senza compromettere il codice esterno. Tuttavia, Rust ti offre la possibilità di esporre le parti interne del codice dei moduli figli ai moduli antenati utilizzando la parola chiave pub per rendere pubblico un elemento.

Esporre Path con la Parola Chiave pub

Torniamo all’errore nel Listato 7-4 che ci diceva che il modulo accoglienza è privato. Vogliamo che la funzione mangiare_al_ristorante nel modulo genitore abbia accesso alla funzione aggiungi_in_lista nel modulo figlio, quindi contrassegniamo il modulo accoglienza con la parola chiave pub, come mostrato nel Listato 7-5.

File: src/lib.rs
mod sala {
    pub mod accoglienza {
        fn aggiungi_in_lista() {}
    }
}

// --taglio--
pub fn mangiare_al_ristorante() {
    // Path assoluta
    crate::sala::accoglienza::aggiungi_in_lista();

    // Path relativa
    sala::accoglienza::aggiungi_in_lista();
}
Listato 7-5: Dichiarare il modulo accoglienza come pub per usarlo da mangiare_al_ristorante

Sfortunatamente, il codice nel Listato 7-5 genera ancora errori del compilatore, come mostrato nel Listato 7-6.

$ cargo build
   Compiling ristorante v0.1.0 (file:///progetti/ristorante)
error[E0603]: function `aggiungi_in_lista` is private
  --> src/lib.rs:12:31
   |
12 |     crate::sala::accoglienza::aggiungi_in_lista();
   |                               ^^^^^^^^^^^^^^^^^ private function
   |
note: the function `aggiungi_in_lista` is defined here
  --> src/lib.rs:4:9
   |
 4 |         fn aggiungi_in_lista() {}
   |         ^^^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `aggiungi_in_lista` is private
  --> src/lib.rs:15:24
   |
15 |     sala::accoglienza::aggiungi_in_lista();
   |                        ^^^^^^^^^^^^^^^^^ private function
   |
note: the function `aggiungi_in_lista` is defined here
  --> src/lib.rs:4:9
   |
 4 |         fn aggiungi_in_lista() {}
   |         ^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `ristorante` (lib) due to 2 previous errors
Listato 7-6: Errori del compilatore durante la costruzione del codice nel Listato 7-5

Cosa è successo? Aggiungere la parola chiave pub davanti a mod accoglienza rende il modulo pubblico. Con questa modifica, se possiamo accedere a sala, possiamo accedere a accoglienza. Ma i contenuti di accoglienza sono ancora privati; rendere pubblico il modulo non rende pubblici i suoi contenuti. La parola chiave pub su un modulo consente solo al codice nei suoi moduli genitore di fare riferimento ad esso, non di accedere al suo codice interno. Poiché i moduli sono contenitori, non possiamo fare molto semplicemente rendendo pubblico il modulo; dobbiamo andare oltre e scegliere di rendere pubblici uno o più degli elementi all’interno del modulo.

Gli errori nel Listato 7-6 dicono che la funzione aggiungi_in_lista è privata. Le regole di privacy si applicano a struct, enum, funzioni e metodi, così come ai moduli.

Facciamo in modo che anche la funzione aggiungi_in_lista sia pubblica aggiungendo la parola chiave pub prima della sua definizione, come nel Listato 7-7.

File: src/lib.rs
mod sala {
    pub mod accoglienza {
        pub fn aggiungi_in_lista() {}
    }
}

// --taglio--
pub fn mangiare_al_ristorante() {
    // Path assoluta
    crate::sala::accoglienza::aggiungi_in_lista();

    // Path relativa
    sala::accoglienza::aggiungi_in_lista();
}
Listato 7-7: Aggiungere la parola chiave pub a mod accoglienza e fn aggiungi_in_lista ci consente di chiamare la funzione da mangiare_al_ristorante

Ora il codice si compilerà! Per capire perché aggiungere la parola chiave pub ci consente di utilizzare questi path in mangiare_al_ristorante rispetto alle regole di privacy, diamo un’occhiata ai path assoluti e relativi.

Nel path assoluto, iniziamo con crate, la radice dell’albero dei moduli del nostro crate. Il modulo sala è definito nella radice del crate. Anche se sala non è pubblico, poiché la funzione mangiare_al_ristorante è definita nello stesso modulo di sala (cioè, mangiare_al_ristorante e sala sono fratelli), possiamo fare riferimento a sala da mangiare_al_ristorante. Successivamente, c’è il modulo accoglienza contrassegnato con pub. Possiamo accedere al modulo genitore di accoglienza, quindi possiamo accedere a accoglienza. Infine, la funzione aggiungi_in_lista è contrassegnata con pub e possiamo accedere al suo modulo genitore, quindi questa chiamata di funzione funziona!

Nel path relativo, la logica è la stessa del path assoluto, tranne per il primo passaggio: invece di partire dalla radice del crate, il path inizia da sala. Il modulo sala è definito all’interno dello stesso modulo di mangiare_al_ristorante, quindi il path relativo che inizia dal modulo in cui è definita mangiare_al_ristorante funziona. Poi, poiché accoglienza e aggiungi_in_lista sono contrassegnati con pub, il resto del path funziona, e questa chiamata di funzione è valida!

Se prevedi di condividere il tuo crate libreria affinché altri progetti possano utilizzare il tuo codice, la tua API pubblica è il tuo contratto con gli utenti del tuo crate che determina come possono interagire con il tuo codice. Ci sono molte considerazioni relative alla gestione delle modifiche alla tua API pubblica per facilitare la dipendenza delle persone dal tuo crate. Queste considerazioni vanno oltre l’ambito di questo libro; se sei interessato a questo argomento, consulta Le Linee Guida per l’API di Rust.

Buone Pratiche per Pacchetti con un Binario e una Libreria

Abbiamo menzionato che un pacchetto può contenere sia una radice di crate binario src/main.rs che una radice di crate libreria src/lib.rs, e entrambi i crate avranno il nome del pacchetto come impostazione predefinita. Tipicamente, i pacchetti che contengono sia una libreria che un crate binario avranno nel crate binario il codice strettamente necessario ad avviare un eseguibile che chiama il codice definito nel crate libreria. Questo consente ad altri progetti di beneficiare della maggior parte delle funzionalità che il pacchetto fornisce, poiché il codice del crate libreria può essere condiviso.

L’albero dei moduli dovrebbe essere definito in src/lib.rs. Quindi, qualsiasi elemento pubblico può essere utilizzato nel crate binario facendo iniziare i path con il nome del pacchetto. Il crate binario diventa un utilizzatore del crate libreria proprio come un crate completamente esterno utilizzerebbe il crate libreria: può utilizzare solo l’API pubblica. Questo ti aiuta a progettare una buona API; non solo sei l’autore, ma sei anche un cliente!

Nel Capitolo 12, dimostreremo questa pratica organizzativa con un programma da riga di comando che conterrà sia un crate binario che un crate libreria.

Iniziare Path Relative con super

Possiamo costruire path relative che iniziano nel modulo genitore, piuttosto che nel modulo corrente o nella radice del crate, utilizzando super all’inizio del path. Questo è simile a iniziare un path del filesystem con la sintassi .. che significa andare nella directory genitore. Utilizzare super ci consente di fare riferimento a un elemento che sappiamo essere nel modulo genitore, il che può rendere più facile riorganizzare l’albero dei moduli quando il modulo è strettamente correlato al genitore, ma il genitore potrebbe essere spostato altrove nell’albero dei moduli in futuro.

Considera il codice nel Listato 7-8 che modella la situazione in cui un cuoco corregge un ordine errato e lo porta personalmente al cliente. La funzione correzione_ordine definita nel modulo cucine chiama la funzione servi_ordine definita nel modulo genitore specificando il path per servi_ordine, iniziando con super.

File: src/lib.rs
fn servi_ordine() {}

mod cucine {
    fn correzione_ordine() {
        cucina_ordine();
        super::servi_ordine();
    }

    fn cucina_ordine() {}
}
Listato 7-8: Chiamare una funzione utilizzando un path relativo che inizia con super

La funzione correzione_ordine si trova nel modulo cucine, quindi possiamo usare super per andare al modulo genitore di cucine, che in questo caso è crate, la radice. Da lì, cerchiamo servi_ordine e lo troviamo. Successo! Pensiamo che il modulo cucine e la funzione servi_ordine siano probabilmente destinati a rimanere nella stessa relazione l’uno con l’altro e verranno spostati insieme se decidiamo di riorganizzare l’albero dei moduli del crate. Pertanto abbiamo usato super in modo da avere meno posti da aggiornare nel codice in futuro se questo codice viene spostato in un modulo diverso.

Rendere Pubbliche Struct e Enum

Possiamo anche utilizzare pub per designare struct ed enum come pubblici, ma ci sono alcuni dettagli aggiuntivi nell’uso di pub con struct ed enum. Se utilizziamo pub prima di una definizione di struct, rendiamo la struct pubblica, ma i campi della struct rimarranno privati (come per i moduli). Possiamo rendere pubblici o meno ciascun campo caso per caso. Nel Listato 7-9, abbiamo definito una struct pubblica cucine::Colazione con un campo pubblico toast ma un campo privato frutta_di_stagione. Questo modella il caso in un ristorante in cui il cliente può scegliere il tipo di pane che accompagna un pasto, ma il cuoco decide quale frutta accompagna il pasto in base a ciò che è di stagione e disponibile. La frutta disponibile cambia rapidamente, quindi i clienti non possono scegliere la frutta o vedere quale frutta riceveranno.

File: src/lib.rs
mod cucine {
    pub struct Colazione {
        pub toast: String,
        frutta_di_stagione: String,
    }

    impl Colazione {
        pub fn estate(toast: &str) -> Colazione {
            Colazione {
                toast: String::from(toast),
                frutta_di_stagione: String::from("pesche"),
            }
        }
    }
}

pub fn mangiare_al_ristorante() {
    // Ordina una colazione in estate con pane tostato di segale.
    let mut pasto = cucine::Colazione::estate("segale");
    // Cambiare idea sul pane che vorremmo.
    pasto.toast = String::from("integrale");
    println!("Vorrei un toast {}, grazie.", pasto.toast);

    // La riga successiva non verrà compilata se la de-commentiamo; non
    // ci è permesso vedere o modificare frutta che accompagna il pasto.
    // pasto.frutta_di_stagione = String::from("mirtilli");
}
Listato 7-9: Una struct con alcuni campi pubblici e alcuni campi privati

Poiché il campo toast nella struct cucine::Colazione è pubblico, in mangiare_al_ristorante possiamo scrivere e leggere il campo toast utilizzando la notazione a punto. Nota che non possiamo utilizzare il campo frutta_di_stagione in mangiare_al_ristorante, perché frutta_di_stagione è privato. Prova a de-commentare la riga che modifica il valore del campo frutta_di_stagione per vedere quale errore ottieni!

Inoltre, nota che poiché cucine::Colazione ha un campo privato, la struct deve fornire una funzione associata pubblica che costruisce un’istanza di Colazione (qui l’abbiamo chiamata estate). Se Colazione non avesse una funzione del genere, non potremmo creare un’istanza di Colazione in mangiare_al_ristorante perché non potremmo impostare il valore del campo privato frutta_di_stagione in mangiare_al_ristorante.

Al contrario, se rendiamo un’enum pubblico, tutte le sue varianti diventano pubbliche. Abbiamo bisogno solo di pub prima della parola chiave enum, come mostrato nel Listato 7-10.

File: src/lib.rs
mod cucine {
    pub enum Antipasti {
        Zuppa,
        Insalata,
    }
}

pub fn mangiare_al_ristorante() {
    let ordine1 = cucine::Antipasti::Zuppa;
    let ordine2 = cucine::Antipasti::Insalata;
}
Listato 7-10: Designare un’enum come pubblico rende pubbliche tutte le sue varianti

Poiché abbiamo reso pubblico l’enum Antipasti, possiamo utilizzare le varianti Zuppa e Insalata in mangiare_al_ristorante.

Le enum non sono molto utili a meno che le loro varianti non siano pubbliche; sarebbe fastidioso dover annotare tutte le varianti delle enum con pub una ad una, quindi la norma per le varianti delle enum è essere pubbliche. Le struct sono spesso utili senza che i loro campi siano pubblici, quindi i campi delle struct seguono la regola generale che tutto è privato come impostazione predefinita, a meno che non sia annotato con pub.

C’è un’ultima situazione che coinvolge pub che non abbiamo trattato, ed è la nostra ultima caratteristica del sistema dei moduli: la parola chiave use. Tratteremo use da solo prima, e poi mostreremo come combinare pub e use.

Portare i Percorsi in Scope con la Parola Chiave use

Dover scrivere i percorsi (path) per chiamare le funzioni può risultare scomodo e ripetitivo. Nel Listato 7-7, sia che scegliessimo il path assoluto o relativo per la funzione aggiungi_in_lista, ogni volta che volevamo chiamare aggiungi_in_lista dovevamo specificare anche sala e accoglienza. Fortunatamente esiste un modo per semplificare questo processo: possiamo creare un collegamento rapido a un path con la parola chiave use una volta, e poi usare il nome più corto nel resto dello scope.

Nel Listato 7-11, portiamo il modulo crate::sala::accoglienza nello scope della funzione mangiare_al_ristorante così da dover specificare solo accoglienza::aggiungi_in_lista per chiamare la funzione aggiungi_in_lista in mangiare_al_ristorante.

File: src/lib.rs
mod sala {
    pub mod accoglienza {
        pub fn aggiungi_in_lista() {}
    }
}

use crate::sala::accoglienza;

pub fn mangiare_al_ristorante() {
    accoglienza::aggiungi_in_lista();
}
Listato 7-11: Portare un modulo nello scope con use

Aggiungere use e un path in uno scope è simile a creare un collegamento simbolico nel filesystem. Aggiungendo use crate::sala::accoglienza nella radice (root) del crate, accoglienza è ora un nome valido in quello scope, come se il modulo accoglienza fosse stato definito nella radice del crate. I path portati nello scope con use rispettano anche la privacy, come qualsiasi altro path.

Nota che use crea il collegamento rapido solo per lo scope particolare in cui use è dichiarato. Il Listato 7-12 sposta la funzione mangiare_al_ristorante in un nuovo modulo figlio chiamato cliente, che ora è in uno scope diverso rispetto alla dichiarazione use, quindi il corpo della funzione non si compilerà.

File: src/lib.rs
mod sala {
    pub mod accoglienza {
        pub fn aggiungi_in_lista() {}
    }
}

use crate::sala::accoglienza;

mod cliente {
    pub fn mangiare_al_ristorante() {
        accoglienza::aggiungi_in_lista();
    }
}
Listato 7-12: Una dichiarazione use si applica solo allo scope in cui si trova

L’errore del compilatore mostra che il collegamento non si applica più all’interno del modulo cliente:

$ cargo build
   Compiling ristorante v0.1.0 (file://progetti/ristorante)
error[E0433]: failed to resolve: use of undeclared crate or module `accoglienza`
  --> src/lib.rs:11:9
   |
11 |         accoglienza::aggiungi_in_lista();
   |         ^^^^^^^^^^^ use of undeclared crate or module `accoglienza`
   |
help: consider importing this module through its public re-export
   |
10 +     use crate::accoglienza;
   |

warning: unused import: `crate::sala::accoglienza`
 --> src/lib.rs:7:5
  |
7 | use crate::sala::accoglienza;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `ristorante` (lib) generated 1 warning
error: could not compile `ristorante` (lib) due to 1 previous error; 1 warning emitted

Nota che c’è l’avviso che il use non è più usato nel suo scope! Per risolvere questo problema, sposta il use anche all’interno del modulo cliente, o riferisciti al collegamento dal modulo genitore con super::accoglienza all’interno del modulo figlio cliente.

Creare Percorsi use Idiomatici

Nel Listato 7-11, forse ti sarai chiesto perché abbiamo specificato use crate::sala::accoglienza e poi chiamato accoglienza::aggiungi_in_lista in mangiare_al_ristorante, invece di specificare il path use fino alla funzione aggiungi_in_lista per ottenere lo stesso risultato, come nel Listato 7-13.

File: src/lib.rs
mod sala {
    pub mod accoglienza {
        pub fn aggiungi_in_lista() {}
    }
}

use crate::sala::accoglienza::aggiungi_in_lista;

pub fn mangiare_al_ristorante() {
    aggiungi_in_lista();
}
Listato 7-13: Portare la funzione aggiungi_in_lista nello scope con use, che non è idiomatico

Sebbene sia il Listato 7-11 sia il Listato 7-13 compiano lo stesso compito, il Listato 7-11 è il modo idiomatico di portare una funzione nello scope con use. Portare il modulo genitore della funzione nello scope con use significa che dobbiamo specificare il modulo genitore quando chiamiamo la funzione. Specificare il modulo genitore quando si chiama la funzione rende chiaro che la funzione non è definita localmente riducendo comunque la ripetizione del path completo. Il codice in Listato 7-13 non chiarisce dove sia definita aggiungi_in_lista.

D’altra parte, quando portiamo struct, enum e altri elementi con use, è idiomatico specificare il path completo. Il Listato 7-14 mostra il modo idiomatico per portare, ad esempio, HashMap della libreria standard nello scope di un crate binario.

File: src/main.rs
use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}
Listato 7-14: Portare HashMap nello scope in modo idiomatico

Non c’è una ragione forte dietro questo uso: è semplicemente la convenzione che è emersa, e le persone si sono abituate a leggere e scrivere codice Rust in questo modo.

L’eccezione a questa idioma è se stiamo portando due elementi con lo stesso nome nello scope con use, perché Rust non lo permette. Il Listato 7-15 mostra come portare due Result nello scope che hanno lo stesso nome ma moduli genitore diversi, e come riferirsi a essi.

File: src/lib.rs
use std::fmt;
use std::io;

fn funzione1() -> fmt::Result {
    // --taglio--
    Ok(())
}

fn funzione2() -> io::Result<()> {
    // --taglio--
    Ok(())
}
Listato 7-15: Portare due type con lo stesso nome nello stesso scope richiede l’uso dei loro moduli genitore

Come si vede, usare i moduli genitore distingue i due type Result. Se invece avessimo specificato use std::fmt::Result e use std::io::Result, avremmo due type Result nello stesso scope, e Rust non saprebbe quale intendessimo quando avremmo usato Result.

Fornire Nuovi Nomi con la Parola Chiave as

C’è un’altra soluzione al problema di portare due type con lo stesso nome nello stesso scope con use: dopo il path, possiamo specificare as e un nuovo nome locale, o alias, per il type. Il Listato 7-16 mostra un altro modo di scrivere il codice del Listato 7-15 rinominando uno dei due type Result con as.

File: src/lib.rs
use std::fmt::Result;
use std::io::Result as IoResult;

fn funzione1() -> Result {
    // --taglio--
    Ok(())
}

fn funzione2() -> IoResult<()> {
    // --taglio--
    Ok(())
}
Listato 7-16: Rinominare un type quando viene portato nello scope con la parola chiave as

Nella seconda dichiarazione use, abbiamo scelto il nuovo alias IoResult per il type std::io::Result, che non entrerà in conflitto con il Result di std::fmt che abbiamo anch’esso portato nello scope. Sia il Listato 7-15 che il Listato 7-16 sono considerati idiomatici, quindi la scelta spetta a te!

Riesportare Nomi con pub use

Quando portiamo un nome nello scope con la parola chiave use, il nome è privato allo scope in cui lo abbiamo importato. Per consentire al codice esterno a quello scope di riferirsi a quel nome come se fosse stato definito in quello scope, possiamo combinare pub e use. Questa tecnica si chiama riesportare (re-exporting) perché portiamo un elemento nello scope ma lo rendiamo anche disponibile affinché altri possano portarlo nel loro scope.

Il Listato 7-17 mostra il codice del Listato 7-11 con use nella radice del modulo cambiato in pub use.

File: src/lib.rs
mod sala {
    pub mod accoglienza {
        pub fn aggiungi_in_lista() {}
    }
}

pub use crate::sala::accoglienza;

pub fn mangiare_al_ristorante() {
    accoglienza::aggiungi_in_lista();
}
Listato 7-17: Rendere un nome disponibile a qualsiasi codice da un nuovo scope con pub use

Prima di questa modifica, il codice esterno avrebbe dovuto chiamare la funzione aggiungi_in_lista usando il path ristorante::sala::accoglienza::aggiungi_in_lista(), che avrebbe inoltre richiesto che il modulo sala fosse marcato come pub. Ora che questo pub use ha riesportato il modulo accoglienza dalla radice del modulo, il codice esterno può invece usare il path ristorante::accoglienza::aggiungi_in_lista().

La riesportazione è utile quando la struttura interna del tuo codice è diversa da come i programmatori che chiamano il tuo codice penserebbero al dominio. Per esempio, in questa metafora del ristorante, chi gestisce il ristorante pensa in termini di “sala” e “cucine”. Ma i clienti che visitano un ristorante probabilmente non penseranno alle parti del ristorante in questi termini. Con pub use, possiamo scrivere il nostro codice con una struttura e però esporre una struttura diversa. Ciò rende la nostra libreria ben organizzata sia per i programmatori che lavorano sulla libreria sia per i programmatori che la usano. Vedremo un altro esempio di pub use e di come influisce sulla documentazione del crate in “Esportare un API Pubblica Efficace” nel Capitolo 14.

Usare Pacchetti Esterni

Nel Capitolo 2, abbiamo programmato un progetto del gioco degli indovinelli che usava un pacchetto esterno chiamato rand per ottenere numeri casuali. Per usare rand nel nostro progetto, abbiamo aggiunto questa riga a Cargo.toml:

File: Cargo.toml
rand = "0.8.5"

Aggiungere rand come dipendenza in Cargo.toml dice a Cargo di scaricare il pacchetto rand e le sue dipendenze da crates.io e di rendere rand disponibile al nostro progetto.

Poi, per portare le definizioni di rand nello scope del nostro pacchetto, abbiamo aggiunto una riga use che cominciava con il nome del crate, rand, e elencava gli elementi che volevamo portare nello scope. Ricorda che in “Generare un Numero Casuale” nel Capitolo 2, abbiamo portato il trait Rng nello scope e chiamato la funzione rand::thread_rng:

use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");
}

Membri della community Rust hanno reso molti pacchetti disponibili su crates.io, e includere uno di essi nel tuo pacchetto implica gli stessi passi: elencarlo nel file Cargo.toml del tuo pacchetto e usare use per portare elementi dai loro crate nello scope.

Nota che la libreria standard std è anch’essa un crate esterno al nostro pacchetto. Poiché la libreria standard è distribuita con il linguaggio Rust, non dobbiamo modificare Cargo.toml per includere std. Ma dobbiamo comunque riferirci ad essa con use per portare elementi da lì nello scope del nostro pacchetto. Per esempio, per HashMap useremmo questa riga:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

Questo è un path assoluto che inizia con std, il nome del crate della libreria standard.

Usare Percorsi Nidificati per Accorpare Elenchi di use

Se usiamo più elementi definiti nello stesso crate o nello stesso modulo, elencare ogni elemento su una sua riga può occupare molto spazio verticale nei file. Per esempio, queste due dichiarazioni use che avevamo nel gioco degli indovinelli nel Listato 2-4 portano elementi da std nello scope:

File: src/main.rs
use rand::Rng;
// --taglio--
use std::cmp::Ordering;
use std::io;
// --taglio--

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    println!("Hai ipotizzato: {ipotesi}");

    match ipotesi.cmp(&numero_segreto) {
        Ordering::Less => println!("Troppo piccolo!"),
        Ordering::Greater => println!("Troppo grande!"),
        Ordering::Equal => println!("Hai indovinato!"),
    }
}

Invece, possiamo usare path nidificati per portare gli stessi elementi nello scope in una sola riga. Lo facciamo specificando la parte comune del path, seguita da due due punti, e poi parentesi graffe intorno a una lista delle parti che differiscono dei path, come mostrato nel Listato 7-18.

File: src/main.rs
use rand::Rng;
// --taglio--
use std::{cmp::Ordering, io};
// --taglio--

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    println!("Inserisci la tua ipotesi.");

    let mut ipotesi = String::new();

    io::stdin()
        .read_line(&mut ipotesi)
        .expect("Errore di lettura");

    let ipotesi: u32 = ipotesi.trim().parse().expect("Inserisci un numero!");

    println!("Hai ipotizzato: {ipotesi}");

    match ipotesi.cmp(&numero_segreto) {
        Ordering::Less => println!("Troppo piccolo!"),
        Ordering::Greater => println!("Troppo grande!"),
        Ordering::Equal => println!("Hai indovinato!"),
    }
}
Listato 7-18: Specificare un path nidificato per portare più elementi con lo stesso prefisso nello scope

Nei programmi più grandi, portare molti elementi nello scope dallo stesso crate o modulo usando path nidificati può ridurre di molto il numero di dichiarazioni use separate necessarie!

Possiamo usare un path nidificato a qualsiasi livello in un path, il che è utile quando si combinano due dichiarazioni use che condividono una parte di path. Per esempio, il Listato 7-19 mostra due dichiarazioni use: una che porta std::io nello scope e una che porta std::io::Write nello scope.

File: src/lib.rs
use std::io;
use std::io::Write;
Listato 7-19: Due dichiarazioni use che condividono parte della path

La parte comune di questi due path è std::io, ed è il path completo della prima path. Per unire questi due path in una sola dichiarazione use, possiamo usare self nel path nidificato, come mostrato nel Listato 7-20.

File: src/lib.rs
use std::io::{self, Write};
Listato 7-20: Combinare i path nel Listato 7-19 in una sola dichiarazione use

Questa riga porta std::io e std::io::Write nello scope.

Importare Elementi con l’Operatore Glob

Se vogliamo portare tutti gli elementi pubblici definiti in un path nello scope, possiamo specificare quel path seguito dall’operatore glob *:

#![allow(unused)]
fn main() {
use std::collections::*;
}

Questa dichiarazione use porta tutti gli elementi pubblici definiti in std::collections nello scope corrente. Stai attento quando usi l’operatore glob! Il glob può rendere più difficile capire quali nomi sono nello scope e da dove proviene un nome usato nel tuo programma. Inoltre, se la dipendenza cambia le sue definizioni, ciò che hai importato cambia a sua volta, il che può portare a errori del compilatore quando aggiorni la dipendenza se la dipendenza aggiunge una definizione con lo stesso nome di una tua definizione nello stesso scope, per esempio.

L’operatore glob viene spesso usato durante i test per portare tutto ciò che è sotto test nel modulo tests; parleremo di questo in “Come Scrivere dei Test” nel Capitolo 11. L’operatore glob è anche a volte usato come parte del pattern prelude: vedi la documentazione della libreria standard per ulteriori informazioni su quel pattern.

Separare i Moduli in File Diversi

Finora tutti gli esempi di questo capitolo definivano più moduli in un unico file. Quando i moduli diventano grandi, potresti voler spostare le loro definizioni in file separati per rendere il codice più facile da navigare.

Per esempio, partiamo dal codice nel Listato 7-17 che aveva più moduli del ristorante. Metteremo ogni modulo in un file invece di avere tutte le definizioni nella radice del crate. In questo caso, il file radice del crate è src/lib.rs, ma questa procedura funziona anche con crate binari il cui file radice è src/main.rs.

Per prima cosa spostiamo il modulo sala in un proprio file. Rimuovi il codice dentro le parentesi graffe del modulo sala, lasciando solo la dichiarazione mod sala;, così che src/lib.rs contenga il codice mostrato nel Listato 7-21. Nota che questo non compilerà finché non creiamo il file src/sala.rs mostrato nel Listato 7-22.

File: src/lib.rs
mod sala;

pub use crate::sala::accoglienza;

pub fn mangiare_al_ristorante() {
    accoglienza::aggiungi_in_lista();
}
Listato 7-21: Dichiarare il modulo sala il cui corpo sarà in src/sala.rs

Ora, metti il codice che era dentro le parentesi graffe in un nuovo file chiamato src/sala.rs, come mostrato nel Listato 7-22. Il compilatore sa di dover cercare in questo file perché ha incontrato la dichiarazione del modulo nella radice del crate con il nome sala.

File: src/sala.rs
pub mod accoglienza {
    pub fn aggiungi_in_lista() {}
}
Listato 7-22: Definizioni all’interno del modulo sala in src/sala.rs

Nota che è necessario caricare un file usando una dichiarazione mod una sola volta nell’albero dei moduli. Una volta che il compilatore sa che il file fa parte del progetto (e sa dove si trova nell’albero dei moduli grazie a dove hai messo la dichiarazione mod), gli altri file del progetto dovrebbero riferirsi al codice del file caricato usando un path verso il punto in cui è stato dichiarato, come trattato nella sezione “Percorsi per fare riferimento a un elemento nell’albero dei moduli”. In altre parole, mod non è un’operazione di “include” come potresti aver visto in altri linguaggi.

Successivamente, sposteremo il modulo accoglienza in un suo file. Il processo è un po’ diverso perché accoglienza è un modulo figlio di sala, non della radice. Metteremo il file per accoglienza in una nuova cartella che sarà chiamata come i suoi antenati nell’albero dei moduli, in questo caso src/sala.

Per iniziare a spostare accoglienza, cambiamo src/sala.rs in modo che contenga solo la dichiarazione del modulo accoglienza:

File: src/sala.rs
pub mod accoglienza;

Poi creiamo una cartella src/sala e un file accoglienza.rs per contenere le definizioni del modulo accoglienza:

File: src/sala/accoglienza.rs
pub fn aggiungi_in_lista() {}

Se invece mettessimo accoglienza.rs nella cartella src, il compilatore si aspetterebbe che il codice di accoglienza.rs sia in un modulo accoglienza dichiarato nella radice del crate, e non come figlio del modulo sala. Le regole del compilatore su quali file cercare per il codice di quali moduli fanno sì che cartelle e file rispecchino più da vicino l’albero dei moduli.

Percorsi di File Alternativi

Finora abbiamo coperto i percorsi di file più idiomatici che il compilatore Rust usa, ma Rust supporta anche uno stile più vecchio. Per un modulo chiamato sala dichiarato nella radice del crate, il compilatore cercherà il codice del modulo in:

  • src/sala.rs (quello che abbiamo visto)
  • src/sala/mod.rs (stile più vecchio, ancora supportato)

Per un modulo chiamato accoglienza che è un sotto-modulo di sala, il compilatore cercherà il codice del modulo in:

  • src/sala/accoglienza.rs (quello che abbiamo visto)
  • src/sala/accoglienza/mod.rs (stile più vecchio, ancora supportato)

Se usi entrambi gli stili per lo stesso modulo, otterrai un errore del compilatore. Usare una combinazione di stili diversi per moduli differenti nello stesso progetto è permesso, ma potrebbe confondere chi oltre a te prende in mano il progetto.

Lo svantaggio principale dello stile con file chiamati mod.rs è che il progetto può finire con molti file chiamati mod.rs, il che può diventare confusionario quando li hai aperti contemporaneamente nell’editor.

Abbiamo spostato il codice di ogni modulo in file separati e l’albero dei moduli rimane lo stesso. Le chiamate di funzione in mangiare_al_ristorante funzioneranno senza alcuna modifica, anche se le definizioni vivono in file diversi. Questa tecnica ti permette di muovere i moduli in nuovi file man mano che crescono di dimensione.

Nota che la dichiarazione pub use crate::sala::accoglienza in src/lib.rs non è cambiata, né use ha alcun impatto su quali file vengono compilati come parte del crate. La parola chiave mod dichiara moduli, e Rust cerca in un file con lo stesso nome del modulo il codice che va in quel modulo.

Riepilogo

Rust ti permette di dividere un pacchetto in più crate e un crate in moduli così da poter riferire elementi definiti in un modulo da un altro modulo. Puoi farlo specificando path assoluti o relativi. Questi path possono essere portati nello scope con una dichiarazione use così da poter usare un path più corto per usi ripetuti dell’elemento in quello scope. Il codice dei moduli è privato per default, ma puoi rendere le definizioni pubbliche aggiungendo la parola chiave pub.

Nel prossimo capitolo vedremo alcune strutture dati di collezione nella libreria standard che puoi usare per rendere ancor più ordinato il tuo codice.

Collezioni Comuni

La libreria standard di Rust include diverse strutture dati molto utili chiamate collezioni (collections). La maggior parte degli altri type rappresenta un valore specifico, ma le collezioni possono contenere più valori. A differenza di array e tuple, i dati a cui puntano queste collezioni vengono memorizzati nell’heap, il che significa che la quantità di dati non deve essere nota in fase di compilazione e può aumentare o diminuire durante l’esecuzione del programma. Ogni tipo di collezione ha funzionalità e costi diversi, e sceglierne una appropriata per le necessità del momento è un’abilità che si svilupperà nel tempo. In questo capitolo, parleremo di tre collezioni utilizzate molto spesso nei programmi Rust:

  • Un vector che consente di memorizzare un numero variabile di valori uno accanto all’altro.
  • Una string è una raccolta di caratteri. Abbiamo menzionato il type String in precedenza, ma in questo capitolo ne parleremo in modo approfondito.
  • Una hash map che consente di associare un valore a una chiave specifica. Si tratta di una particolare implementazione della struttura dati più generale chiamata map.

Per saperne di più sugli altri tipi di collezioni fornite dalla libreria standard, vedere la documentazione.

Parleremo di come creare e aggiornare vector, string e hash map, nonché cosa rende ciascuna di esse speciale.

Memorizzare Elenchi di Valori con Vettori

Il primo tipo di collezione che esamineremo è Vec<T>, nota anche come vector. I vettori consentono di memorizzare più di un valore in un’unica struttura dati che mette tutti i valori uno accanto all’altro in memoria. I vettori possono memorizzare solo valori dello stesso type. Sono utili quando si ha un elenco di elementi, come le righe di testo in un file o i prezzi degli articoli in un carrello.

Creare un Nuovo Vettore

Per creare un nuovo vettore vuoto, chiamiamo la funzione Vec::new, come mostrato nel Listato 8-1.

fn main() {
    let v: Vec<i32> = Vec::new();
}
Listato 8-1: Creazione di un nuovo vettore vuoto per contenere valori di type i32

Nota che qui abbiamo aggiunto un’annotazione di type. Poiché non stiamo inserendo alcun valore in questo vettore, Rust non sa che tipo di elementi intendiamo memorizzare. Questo è un punto importante. I vettori vengono implementati utilizzando i type generici; spiegheremo come utilizzare i type generici quando si creano type propri nel Capitolo 10. Per ora, sappiamo che il type Vec<T> fornito dalla libreria standard può contenere qualsiasi type. Quando creiamo un vettore per contenere uno specifico type, possiamo specificarlo tra parentesi angolari. Nel Listato 8-1, abbiamo detto a Rust che Vec<T> in v conterrà elementi di type i32.

Più spesso, si crea un Vec<T> con valori iniziali e Rust dedurrà il type dai valori che si desidera memorizzare, quindi raramente è necessario eseguire questa annotazione di type. Rust fornisce opportunamente la macro vec!, che creerà un nuovo vettore contenente i valori che gli vengono assegnati. Il Listato 8-2 crea un nuovo Vec<i32> che contiene i valori 1, 2 e 3. Il type intero è i32 perché è il type intero predefinito, come discusso nella sezione “Tipi di Dato” del Capitolo 3.

fn main() {
    let v = vec![1, 2, 3];
}
Listato 8-2: Creazione di un nuovo vettore contenente valori

Poiché abbiamo assegnato valori iniziali i32, Rust può dedurre che il type di v è Vec<i32> e l’annotazione di type non è necessaria. Successivamente, vedremo come modificare un vettore.

Aggiornare un Vettore

Per creare un vettore e aggiungervi elementi, possiamo usare il metodo push, come mostrato nel Listato 8-3.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}
Listato 8-3: Utilizzo del metodo push per aggiungere valori a un vettore

Come per qualsiasi variabile, se vogliamo poterne modificare il valore, dobbiamo renderla mutabile usando la parola chiave mut, come discusso nel Capitolo 3. I numeri che inseriamo al suo interno sono tutti di type i32, e Rust lo deduce dai dati, quindi non abbiamo bisogno dell’annotazione Vec<i32>.

Leggere Elementi dei Vettori

Esistono due modi per fare riferimento a un valore memorizzato in un vettore: tramite indicizzazione o utilizzando il metodo get. Negli esempi seguenti, abbiamo annotato i type dei valori restituiti da queste funzioni per maggiore chiarezza.

Il Listato 8-4 mostra entrambi i metodi per accedere a un valore in un vettore, con la sintassi di indicizzazione e il metodo get.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let terzo: &i32 = &v[2];
    println!("Il terzo elemento è {terzo}");

    let terzo: Option<&i32> = v.get(2);
    match terzo {
        Some(terzo) => println!("Il terzo elemento è {terzo}"),
        None => println!("Non c'è un terzo elemento."),
    }
}
Listato 8-4: Utilizzo della sintassi di indicizzazione e utilizzo del metodo get per accedere a un elemento in un vettore

Sono da notare alcuni dettagli. Utilizziamo il valore di indice 2 per ottenere il terzo elemento poiché i vettori sono indicizzati per numero, a partire da zero. Utilizzando & e [] otteniamo un reference all’elemento a quell’indice. Quando utilizziamo il metodo get con l’indice passato come argomento, otteniamo un Option<&T> che possiamo utilizzare con match.

Rust fornisce questi due modi per ottenere un reference ad un elemento, in modo da poter scegliere come il programma si comporta quando si tenta di utilizzare un valore di indice al di fuori dell’intervallo di elementi esistenti. Ad esempio, vediamo cosa succede quando abbiamo un vettore di cinque elementi e poi proviamo ad accedere a un elemento all’indice 100 con ciascuna tecnica, come mostrato nel Listato 8-5.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let non_esiste = &v[100];
    let non_esiste = v.get(100);
}
Listato 8-5: Tentativo di accesso all’elemento all’indice 100 in un vettore contenente cinque elementi

Quando eseguiamo questo codice, il primo metodo [] causerà un crash del programma perché fa riferimento ad un elemento inesistente. Questo metodo è ideale quando si desidera che il programma si blocchi in caso di tentativo di accesso a un elemento oltre la fine del vettore.

Quando al metodo get viene passato un indice esterno al vettore, restituisce None senza crash. Si consiglia di utilizzare questo metodo se l’accesso a un elemento oltre l’intervallo del vettore può verificarsi occasionalmente in circostanze normali. Il codice avrà quindi una logica per gestire sia Some(&element) che None, come discusso nel Capitolo 6. Ad esempio, l’indice potrebbe provenire da un utente che inserisce un numero. Se inserisce accidentalmente un numero troppo grande e il programma ottiene un valore None, è possibile indicare all’utente quanti elementi sono presenti nel vettore corrente e dargli un’altra possibilità di inserire un valore valido. Sarebbe più intuitivo che mandare in crash il programma a causa di un errore di battitura!

Quando il programma ha un reference valido, il sistema applica le regole di ownership e borrowing (trattate nel Capitolo 4) per garantire che questo reference e qualsiasi altro reference al contenuto del vettore rimangano validi. Ricordati la regola che stabilisce che non è possibile avere reference mutabili e immutabili nello stesso scope. Questa regola si applica al Listato 8-6, dove manteniamo un reference immutabile al primo elemento di un vettore e proviamo ad aggiungere un elemento alla fine. Questo programma non funzionerà se proviamo a fare reference a quell’elemento anche più avanti nella funzione.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let primo = &v[0];

    v.push(6);

    println!("Il primo elemento è: {primo}");
}
Listato 8-6: Tentativo di aggiungere un elemento a un vettore in compresenza di un reference all’oggetto

La compilazione di questo codice genererà questo errore:

$ cargo run
   Compiling collections v0.1.0 (file:///progetti/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:7:5
  |
5 |     let primo = &v[0];
  |                  - immutable borrow occurs here
6 |
7 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
8 |
9 |     println!("Il primo elemento è: {primo}");
  |                                     ----- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Il codice nel Listato 8-6 potrebbe sembrare funzionante: perché un reference al primo elemento dovrebbe preoccuparsi delle modifiche alla fine del vettore? Questo errore è dovuto al funzionamento dei vettori: poiché i vettori posizionano i valori uno accanto all’altro in memoria, se non c’è abbastanza spazio per posizionare tutti gli elementi uno accanto all’altro dove il vettore è attualmente memorizzato l’aggiunta di un nuovo elemento alla fine del vettore potrebbe richiedere l’allocazione di nuova memoria e la copia dei vecchi elementi nel nuovo spazio. In tal caso, il reference al primo elemento punterebbe alla memoria de-allocata. Le regole di borrowing impediscono ai programmi di finire in questa situazione.

Nota: per maggiori dettagli sull’implementazione del type Vec<T>, vedere “Il Rustonomicon”.

Iterare sui Valori di un Vettore

Per accedere a ogni elemento di un vettore a turno, dovremmo iterare su tutti gli elementi anziché utilizzare gli indici per accedervi uno alla volta. Il Listato 8-7 mostra come utilizzare un ciclo for per ottenere reference immutabili a ciascun elemento in un vettore di valori i32 e stamparli.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}
Listato 8-7: Stampa di ogni elemento in un vettore iterando sugli elementi utilizzando un ciclo for

Possiamo anche iterare su reference mutabili a ciascun elemento in un vettore mutabile per apportare modifiche a tutti gli elementi. Il ciclo for nel Listato 8-8 aggiungerà 50 a ciascun elemento.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}
Listato 8-8: Iterare su reference mutabili a elementi in un vettore

Per modificare il valore a cui punta il reference mutabile, dobbiamo usare l’operatore di de-referenziazione * per arrivare al valore in i prima di poter usare l’operatore +=. Approfondiremo l’operatore di de-referenziazione nella sezione “Seguire il Reference al Valore” del Capitolo 15.

L’iterazione su un vettore, sia immutabile che mutabile, è sicura grazie alle regole di ownership e borrowing. Se tentassimo di inserire o rimuovere elementi nei corpi del ciclo for nei Listati 8-7 e 8-8, otterremmo un errore del compilatore simile a quello ottenuto con il codice nel Listato 8-6. Il reference al vettore contenuto nel ciclo for impedisce la modifica simultanea dell’intero vettore.

Utilizzare un’Enum per Memorizzare Più Type

I vettori possono memorizzare solo valori dello stesso type. Questo può essere scomodo; ci sono sicuramente casi d’uso in cui è necessario memorizzare un elenco di elementi di type diverso. Fortunatamente, le varianti di un’enum sono definite sotto lo stesso type di enum, quindi quando abbiamo bisogno di un type per rappresentare elementi di tipi diversi, possiamo definire e utilizzare un’enum!

Ad esempio, supponiamo di voler ottenere valori da una riga di un foglio di calcolo in cui alcune colonne della riga contengono numeri interi, alcuni numeri in virgola mobile e alcune stringhe. Possiamo definire un’enum le cui varianti conterranno i diversi tipi di valore, e tutte le varianti dell’enum saranno considerate dello stesso type: quello dell’enum. Possiamo adesso creare un vettore per contenere quell’enum e quindi, in definitiva, contenere type “diversi”. Lo abbiamo dimostrato nel Listato 8-9.

fn main() {
    enum CellaFoglioDiCalcolo {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        CellaFoglioDiCalcolo::Int(3),
        CellaFoglioDiCalcolo::Text(String::from("blu")),
        CellaFoglioDiCalcolo::Float(10.12),
    ];
}
Listato 8-9: Definizione di un enum per memorizzare valori di type diversi in un vettore

Rust deve sapere quali type saranno presenti nel vettore in fase di compilazione, in modo da sapere esattamente quanta memoria nell’heap sarà necessaria per memorizzare ogni elemento. Dobbiamo anche essere espliciti sui type consentiti in questo vettore. Se Rust permettesse a un vettore di contenere qualsiasi type, ci sarebbe la possibilità che uno o più type causino errori nelle operazioni eseguite sugli elementi del vettore. L’utilizzo di un’enum assieme ad un’espressione match significa che Rust garantirà in fase di compilazione che ogni possibile caso venga gestito, come discusso nel Capitolo 6.

Se non si conosce l’insieme esaustivo di type che un programma avrà una volta in esecuzione da memorizzare in un vettore, la tecnica dell’enum non funzionerà. In alternativa, è possibile utilizzare un oggetto trait, che tratteremo nel Capitolo 18.

Ora che abbiamo discusso alcuni dei modi più comuni per utilizzare i vettori, assicurati di consultare la documentazione dell’API per tutti i numerosi metodi utili definiti su Vec<T> dalla libreria standard. Ad esempio, oltre a push, un metodo pop rimuove e restituisce l’ultimo elemento.

Eliminare un Vettore Elimina i Suoi Elementi

Come qualsiasi altra struct, un vettore viene rilasciato quando esce dallo scope, come annotato nel Listato 8-10.

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // esegui operazioni su `v`
    } // <- qui `v` esce dallo scope e la memoria viene liberata
}
Listato 8-10: Mostra dove vengono rilasciati il vettore e i suoi elementi

Quando il vettore viene rilasciato, anche tutto il suo contenuto viene de-allocato, il che significa che gli interi in esso contenuti verranno de-allocati. Il borrow checker (controllo dei prestiti) garantisce che qualsiasi reference al contenuto di un vettore venga utilizzato solo finché il vettore stesso è valido.

Passiamo al tipo di collezione successivo: String!

Memorizzare Testo Codificato UTF-8 con Stringhe

Abbiamo parlato delle stringhe nel Capitolo 4, ma ora le analizzeremo più approfonditamente. I nuovi Rustacean spesso si bloccano sulle stringhe per una combinazione di tre ragioni: la propensione di Rust a esporre possibili errori, il fatto che le stringhe siano una struttura dati più complicata di quanto molti programmatori credano e la codifica UTF-8. Questi fattori si combinano in un modo che può sembrare difficile per chi proviene da altri linguaggi di programmazione.

Parleremo delle stringhe nel contesto delle collezioni perché le stringhe sono implementate come una collezione di byte, oltre ad alcuni metodi per fornire funzionalità utili quando tali byte vengono interpretati come testo. In questa sezione, parleremo delle operazioni su String che ogni tipo di collezione prevede, come creazione, aggiornamento e lettura. Discuteremo anche le differenze tra String e le altre collezioni, in particolare come l’indicizzazione in una String sia complicata dalle differenze tra il modo in cui le persone e i computer interpretano i dati String.

Definire le Stringhe

Definiremo innanzitutto cosa intendiamo con il termine stringa. Rust ha un solo type di stringa nel linguaggio principale, ovvero la slice di stringa str che viene principalmente utilizzata come reference &str. Nel Capitolo 4 abbiamo parlato delle slice di stringa, che sono reference ad alcuni dati stringa codificati in UTF-8 memorizzati altrove. I letterali stringa, ad esempio, sono memorizzati nel binario del programma e sono quindi slice di stringa.

Il type String, fornito dalla libreria standard di Rust anziché esser definito nel linguaggio principale, è un type di stringa codificato in UTF-8, con ownership, espandibile e modificabile. Quando i Rustacean fanno riferimento alle “stringhe” in Rust, potrebbero riferirsi sia al type String che alla slice di stringa &str, e non solo a uno di questi type. Sebbene questa sezione tratterà principalmente String, entrambe le tipologie sono ampiamente utilizzate nella libreria standard di Rust, e sia String che le slice sono codificate in UTF-8.

Creare una Nuova String

Molte delle operazioni disponibili con Vec<T> sono disponibili anche con String perché String è in realtà implementata come wrapper (involucro) attorno a un vettore di byte con alcune garanzie, restrizioni e funzionalità aggiuntive. Ad esempio, la funzione new per creare un’istanza, funziona allo stesso modo sia con Vec<T> che con String. Eccola mostrata nel Listato 8-11.

fn main() {
    let mut s = String::new();
}
Listato 8-11: Creazione di una nuova String vuota

Questa riga crea una nuova stringa vuota chiamata s, in cui possiamo quindi caricare i dati. Spesso, avremo dei dati iniziali con cui vogliamo inizializzare la stringa. Per questo, utilizziamo il metodo to_string, disponibile su qualsiasi type che implementi il trait Display, come fanno i letterali stringa. Il Listato 8-12 mostra due esempi.

fn main() {
    let data = "contenuto iniziale";

    let s = data.to_string();

    // Il metodo funziona anche direttamente sul letterale:
    let s = "contenuto iniziale".to_string();
}
Listato 8-12: Utilizzo del metodo to_string per creare una String da un letterale stringa

Questo codice crea una stringa contenente contenuto iniziale.

Possiamo anche utilizzare la funzione String::from per creare una String da un letterale stringa. Il codice nel Listato 8-13 è equivalente al codice nel Listato 8-12 che utilizza to_string.

fn main() {
    let s = String::from("contenuto iniziale");
}
Listato 8-13: Utilizzo della funzione String::from per creare una String da un letterale stringa

Poiché le stringhe vengono utilizzate per così tante cose, possiamo utilizzare diverse API generiche per le stringhe, offrendoci numerose opzioni. Alcune possono sembrare ridondanti, ma hanno tutte la loro importanza! In questo caso, String::from e to_string svolgono la stessa funzione, quindi la scelta è una questione di stile e leggibilità.

Ricorda che le stringhe sono codificate in UTF-8, quindi possiamo includere qualsiasi dato codificato correttamente, come mostrato nel Listato 8-14.

fn main() {
    let saluto = String::from("السلام عليكم");
    let saluto = String::from("Dobrý den");
    let saluto = String::from("Hello");
    let saluto = String::from("שלום");
    let saluto = String::from("नमस्ते");
    let saluto = String::from("こんにちは");
    let saluto = String::from("안녕하세요");
    let saluto = String::from("你好");
    let saluto = String::from("Olá");
    let saluto = String::from("Здравствуйте");
    let saluto = String::from("Hola");
}
Listato 8-14: Memorizzazione di saluti in diverse lingue nelle stringhe

Tutti questi sono valori String validi.

Aggiornare una String

Una String può crescere di dimensione e il suo contenuto può cambiare, proprio come il contenuto di un Vec<T>, se vi si inseriscono più dati. Inoltre, è possibile utilizzare comodamente l’operatore + o la macro format! per concatenare valori String.

Aggiungere con push_str e push

Possiamo far crescere una String utilizzando il metodo push_str per aggiungere una slice di stringa, come mostrato nel Listato 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listato 8-15: Aggiungere una slice a una String utilizzando il metodo push_str

Dopo queste due righe, s conterrà foobar. Il metodo push_str accetta una slice di stringa perché non vogliamo necessariamente prendere ownership del parametro. Ad esempio, nel codice del Listato 8-16, vogliamo poter utilizzare s2 dopo averne aggiunto il contenuto a s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 è {s2}");
}
Listato 8-16: Utilizzo di una slice dopo averne aggiunto il contenuto a una String

Se il metodo push_str prendesse ownership di s2, non saremmo in grado di stamparne il valore sull’ultima riga. Tuttavia, questo codice funziona come previsto!

Il metodo push prende un singolo carattere come parametro e lo aggiunge alla String. Il Listato 8-17 aggiunge la lettera l a una String utilizzando il metodo push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listato 8-17: Aggiunta di un carattere a un valore String utilizzando push

Di conseguenza, s conterrà lol.

Concatenare con + o format!

Spesso, si desidera combinare due stringhe esistenti. Un modo per farlo è utilizzare l’operatore +, come mostrato nel Listato 8-18.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // nota che s1 è stato spostato qui e non può più essere utilizzato
}
Listato 8-18: Utilizzo dell’operatore + per combinare due valori String in un nuovo valore String

La stringa s3 conterrà Hello, world!. Il motivo per cui s1 non è più valido dopo l’aggiunta, e il motivo per cui abbiamo utilizzato un reference a s2, ha a che fare con la firma del metodo chiamato quando utilizziamo l’operatore +. L’operatore + utilizza il metodo add, la cui firma è simile a questa:

fn add(self, s: &str) -> String {

Nella libreria standard, add è definito utilizzando type generici e type associati. Qui, abbiamo sostituito type concreti, che è ciò che accade quando chiamiamo questo metodo con valori String. Parleremo dei generici nel Capitolo 10. Questa firma ci fornisce gli indizi necessari per comprendere le parti più complesse dell’operatore +.

Innanzitutto, s2 ha un &, il che significa che stiamo aggiungendo un reference della seconda stringa alla prima stringa. Questo è dovuto al parametro s nella funzione add: possiamo solo aggiungere una slice di stringa a una String; non possiamo aggiungere due valori String insieme. Ma aspetta: il type di &s2 è &String, non &str, come specificato nel secondo parametro di add. Quindi perché il Listato 8-18 si compila?

Il motivo per cui possiamo usare &s2 nella chiamata a add è che il compilatore può costringere l’argomento &String in un &str. Quando chiamiamo il metodo add , Rust usa una deref coercion (de-referenziazione forzata), che qui trasforma &s2 in &s2[..]. Discuteremo la deref coercion più approfonditamente nel Capitolo 15. Poiché add non prende ownership del parametro s, s2 sarà comunque una String valida dopo questa operazione.

In secondo luogo, possiamo vedere nella firma che add prende ownership di self perché self non ha un &. Ciò significa che s1 nel Listato 8-18 verrà spostato nella chiamata add e non sarà più valido da quel momento in poi. Quindi, sebbene let s3 = s1 + &s2; sembri copiare entrambe le stringhe e crearne una nuova, questa istruzione in realtà prende ownership di s1, aggiunge una copia del contenuto di s2 per poi restituire la ownership del risultato. In altre parole, sembra che stia facendo molte copie, ma non è così; l’implementazione è più efficiente della semplice copia.

Se dobbiamo concatenare più stringhe, il comportamento dell’operatore + diventa poco pratico:

fn main() {
    let s1 = String::from("uno");
    let s2 = String::from("due");
    let s3 = String::from("tre");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

A questo punto, s diventerà uno-due-tre. Con tutti i caratteri + e ", è difficile capire cosa sta succedendo. Per combinare stringhe in modi più complessi, possiamo invece usare la macro format!:

fn main() {
    let s1 = String::from("uno");
    let s2 = String::from("due");
    let s3 = String::from("tre");

    let s = format!("{s1}-{s2}-{s3}");
}

Anche questo codice risulterà in s che contiene uno-due-tre. La macro format! funziona come println!, ma invece di visualizzare l’output sullo schermo, restituisce una String con il contenuto. La versione del codice che utilizza format! è molto più facile da leggere e il codice generato dalla macro format! utilizza reference in modo che questa chiamata non assuma ownership di nessuno dei suoi parametri.

Indicizzazione in String

In molti altri linguaggi di programmazione, l’accesso a singoli caratteri in una stringa facendovi riferimento tramite indice è un’operazione valida e comune. Tuttavia, se si tenta di accedere a parti di una String utilizzando la sintassi di indicizzazione in Rust, si otterrà un errore. Considera il codice non valido nel Listato 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Listato 8-19: Tentativo di utilizzare la sintassi di indicizzazione con una String

Questo codice genererà il seguente errore:

$ cargo run
   Compiling collections v0.1.0 (file:///progetti/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
          but trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

L’errore spiega la situazione: le stringhe Rust non supportano l’indicizzazione. Ma perché no? Per rispondere a questa domanda, dobbiamo discutere come Rust memorizza le stringhe in memoria.

Rappresentazione Interna

Una String è un wrapper di un Vec<u8>. Diamo un’occhiata ad alcune delle nostre stringhe di esempio correttamente codificate UTF-8 dal Listato 8-14. Innanzitutto, questo:

fn main() {
    let saluto = String::from("السلام عليكم");
    let saluto = String::from("Dobrý den");
    let saluto = String::from("Hello");
    let saluto = String::from("שלום");
    let saluto = String::from("नमस्ते");
    let saluto = String::from("こんにちは");
    let saluto = String::from("안녕하세요");
    let saluto = String::from("你好");
    let saluto = String::from("Olá");
    let saluto = String::from("Здравствуйте");
    let saluto = String::from("Hola");
}

In questo caso, len sarà 4, il che significa che il vettore che memorizza la stringa "Hola" è lungo 4 byte. Ognuna di queste lettere occupa 1 byte se codificata in UTF-8. La riga seguente, tuttavia, potrebbe sorprendervi (nota che questa stringa inizia con la lettera maiuscola cirillica Ze, non con il numero 3):

fn main() {
    let saluto = String::from("السلام عليكم");
    let saluto = String::from("Dobrý den");
    let saluto = String::from("Hello");
    let saluto = String::from("שלום");
    let saluto = String::from("नमस्ते");
    let saluto = String::from("こんにちは");
    let saluto = String::from("안녕하세요");
    let saluto = String::from("你好");
    let saluto = String::from("Olá");
    let saluto = String::from("Здравствуйте");
    let saluto = String::from("Hola");
}

Se vi chiedessero quanto è lunga la stringa, potreste dire 12. In realtà, la risposta di Rust è 24: questo è il numero di byte necessari per codificare “Здравствуйте” in UTF-8, perché ogni valore scalare Unicode in quella stringa occupa 2 byte di spazio. Pertanto, un indice nei byte della stringa non sarà sempre correlato a un valore scalare Unicode valido. Per dimostrarlo, consideriamo questo codice Rust non valido:

let saluto = "Здравствуйте";
let risposta = &hello[0];

Sapete già che risposta non sarà З, la prima lettera. Quando codificato in UTF-8, il primo byte di З è 208 e il secondo è 151, quindi sembrerebbe che risposta dovrebbe in effetti essere 208, ma 208 non è un carattere valido da solo. Restituire 208 probabilmente non è ciò che un utente vorrebbe se chiedesse la prima lettera di questa stringa; Tuttavia, questo è l’unico dato che Rust ha all’indice di byte 0. Gli utenti generalmente non vogliono che venga restituito il valore in byte, anche se la stringa contiene solo lettere latine: se &"hi"[0] fosse codice valido che restituisce il valore in byte, restituirebbe 104, non h.

La risposta, quindi, è che per evitare di restituire un valore inaspettato e causare bug che potrebbero non essere scoperti immediatamente, Rust non compila affatto questo codice e previene malintesi fin dalle prime fasi del processo di sviluppo.

Byte, Valori Scalari e Cluster di Grafemi!

Un altro punto su UTF-8 è che in realtà ci sono tre modi rilevanti per vedere le stringhe dalla prospettiva di Rust: come byte, valori scalari e cluster di grafemi (la cosa più vicina a ciò che chiameremmo lettere).

Se consideriamo la parola hindi “नमस्ते” scritta in alfabeto Devanagari, essa è memorizzata come un vettore di valori u8 che appare così:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

Sono 18 byte ed è così che i computer memorizzano questi dati. Se li consideriamo come valori scalari Unicode, che corrispondono al type char di Rust, quei byte appaiono così:

['न', 'म', 'स', '्', 'त', 'े']

Ci sono sei valori char qui, ma il quarto e il sesto non sono lettere: sono segni diacritici che da soli non hanno senso. Infine, se li consideriamo come cluster di grafemi, otterremmo ciò che una persona chiamerebbe le quattro lettere che compongono la parola hindi:

["न", "म", "स्", "ते"]

Rust fornisce diversi modi di interpretare i dati stringa grezzi che i computer memorizzano, in modo che ogni programma possa scegliere l’interpretazione di cui ha bisogno, indipendentemente dal linguaggio umano in cui sono espressi i dati.

Un ultimo motivo per cui Rust non ci consente di indicizzare una String per ottenere un carattere è che le operazioni di indicizzazione dovrebbero sempre richiedere un tempo costante (O(1)). Ma non è possibile garantire tali prestazioni con una String, perché Rust dovrebbe esaminare il contenuto dall’inizio fino all’indice per determinare quanti caratteri validi ci sono.

Slicing delle Stringhe

Indicizzare una stringa è spesso una cattiva idea perché non è chiaro quale debba essere il type di ritorno dell’operazione di indicizzazione della stringa: un valore byte, un carattere, un cluster di grafemi o una slice. Se proprio si ha bisogno di usare gli indici per creare slice di stringa, Rust chiede di essere più specifici.

Invece di indicizzare usando [] con un singolo numero, si può usare [] con un intervallo per creare una slice di stringa contenente byte specifici:

#![allow(unused)]
fn main() {
let saluto = "Здравствуйте";

let s = &saluto[0..4];
}

Qui, s sarà un &str che contiene i primi 4 byte della stringa. In precedenza, abbiamo accennato al fatto che ognuno di questi caratteri era composto da due byte, il che significa che s sarà Зд.

Se provassimo a suddividere solo una parte dei byte di un carattere con qualcosa come &saluto[0..1], Rust andrebbe in panic in fase di esecuzione, proprio come se si accedesse a un indice non valido in un vettore:

$ cargo run
   Compiling collections v0.1.0 (file:///progetti/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/collections`

thread 'main' (6240) panicked at src/main.rs:4:20:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

È necessario prestare attenzione quando si creano slice di stringhe con intervalli, perché ciò potrebbe causare l’arresto anomalo del programma.

Iterare sulle Stringhe

Il modo migliore per operare su stringhe è specificare esplicitamente se si desidera caratteri o byte. Per singoli valori scalari Unicode, utilizzare il metodo chars. Chiamando chars su “Зд” si separano e si restituiscono due valori di type char, ed è possibile iterare sul risultato per accedere a ciascun elemento:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

Questo codice stamperà quanto segue:

З
д

In alternativa, il metodo bytes restituisce ogni byte grezzo, che potrebbe essere appropriato per quello che ti serve:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

Questo codice stamperà i 4 byte che compongono questa stringa:

208
151
208
180

Ma ricorda che i valori scalari Unicode validi possono essere composti da più di 1 byte.

Ottenere cluster di grafemi dalle stringhe, come con l’alfabeto Devanagari, è complesso, quindi questa funzionalità non è fornita dalla libreria standard. I crate sono disponibili su crates.io se questa è la funzionalità di cui avete bisogno.

Gestire le Complessità delle Stringhe

In sintesi, le stringhe sono complicate. Diversi linguaggi di programmazione fanno scelte diverse su come presentare questa complessità al programmatore. Rust ha scelto di rendere la corretta gestione dei dati String il comportamento predefinito per tutti i programmi Rust, il che significa che i programmatori devono dedicare da subito maggiore attenzione alla gestione dei dati UTF-8. Questo compromesso rende più evidente la complessità delle stringhe rispetto ad altri linguaggi di programmazione, ma evita di dover gestire errori che coinvolgono caratteri non ASCII in una fase successiva del ciclo di sviluppo.

La buona notizia è che la libreria standard offre numerose funzionalità basate sui type String e &str per aiutare a gestire correttamente queste situazioni complesse. Assicuratevi di consultare la documentazione per metodi utili come contains per la ricerca in una stringa e replace per sostituire parti di una stringa con un’altra stringa.

Passiamo a qualcosa di un po’ meno complesso: le mappe hash!

Memorizzare Chiavi con Valori Associati in Mappe Hash

L’ultima delle nostre collezioni comuni è la mappa hash. Il type HashMap<K, V> memorizza una mappatura di chiavi di type K a valori di type V utilizzando una funzione di hashing, che determina come queste chiavi e valori vengono inseriti in memoria. Molti linguaggi di programmazione supportano questo tipo di struttura dati, ma spesso usano un nome diverso, come hash, map, object, hash table, dictionary o associative array, solo per citarne alcuni.

Le mappe hash (hash map d’ora in poi) sono utili quando si desidera ricercare dati non utilizzando un indice, come è possibile con i vettori, ma utilizzando una chiave che può essere di qualsiasi type. Ad esempio, in una partita, è possibile tenere traccia del punteggio di ogni squadra in una hash map in cui ogni chiave è il nome di una squadra e i valori sono il punteggio di ogni squadra. Dato il nome di una squadra, è possibile recuperarne il punteggio.

In questa sezione esamineremo l’API di base delle hash map, ma molte altre funzionalità si nascondono nelle funzioni definite su HashMap<K, V> dalla libreria standard. Come sempre, consultate la documentazione della libreria standard per ulteriori informazioni.

Creare una Nuova Hash Map

Un modo per creare una hash map vuota è usare new e aggiungere elementi con insert. Nel Listato 8-20, stiamo tenendo traccia del punteggio di due squadre i cui nomi sono Blu e Gialla. La squadra Blu inizia con 10 punti, mentre la squadra Gialla inizia con 50.

fn main() {
    use std::collections::HashMap;

    let mut punteggi = HashMap::new();

    punteggi.insert(String::from("Blu"), 10);
    punteggi.insert(String::from("Gialla"), 50);
}
Listato 8-20: Creazione di una nuova hash map e inserimento di chiavi e valori

Nota che dobbiamo prima portare in scope HashMap con use dalla sezione dedicata alle collezioni della libreria standard. Delle nostre tre collezioni comuni, questa è la meno utilizzata, quindi non è inclusa nelle funzionalità aggiunte allo scope dal preludio. Le hash map hanno anche un supporto minore dalla libreria standard; ad esempio, non esiste una macro integrata per costruirle.

Proprio come i vettori, le hash map memorizzano i loro dati nell’heap. Questa HashMap ha chiavi di type String e valori di type i32. Come i vettori, le hash map sono omogenee: tutte le chiavi devono avere lo stesso type e tutti i valori devono avere lo stesso type.

Accedere ai Valori in una Hash Map

Possiamo ottenere un valore dalla hash map fornendo la sua chiave al metodo get, come mostrato nel Listato 8-21.

fn main() {
    use std::collections::HashMap;

    let mut punteggi = HashMap::new();

    punteggi.insert(String::from("Blu"), 10);
    punteggi.insert(String::from("Gialla"), 50);

    let nome_squadra = String::from("Blu");
    let punteggio = punteggi.get(&nome_squadra).copied().unwrap_or(0);
}
Listato 8-21: Accesso al punteggio per la squadra Blu memorizzato nella hash map

Qui, punteggio avrà il valore associato alla squadra Blu e il risultato sarà 10. Il metodo get restituisce Option<&V>; se non c’è alcun valore per quella chiave nella hash map, get restituirà None. Questo programma gestisce Option chiamando copied per ottenere Option<i32> anziché Option<&i32>, quindi unwrap_or per impostare punteggio a zero se punteggio non ha una voce per la chiave.

Possiamo iterare su ogni coppia chiave-valore in una hash map in modo simile a come facciamo con i vettori, utilizzando un ciclo for:

fn main() {
    use std::collections::HashMap;

    let mut punteggi = HashMap::new();

    punteggi.insert(String::from("Blu"), 10);
    punteggi.insert(String::from("Gialla"), 50);

    for (chiave, valore) in &punteggi {
        println!("{chiave}: {valore}");
    }
}

Questo codice stamperà ogni coppia in un ordine arbitrario:

Gialla: 50
Blu: 10

Gestire Ownership nelle Hash Map

Per i type che implementano il trait Copy, come i32, i valori vengono copiati nella hash map. Per i valori con ownership come String, i valori verranno spostati e la hash map assumerà la ownership di tali valori, come dimostrato nel Listato 8-22.

fn main() {
    use std::collections::HashMap;

    let nome_campo = String::from("Colore preferito");
    let valore_campo = String::from("Blu");

    let mut map = HashMap::new();
    map.insert(nome_campo, valore_campo);
    // `nome_campo` e `valore_campo` non sono validi a questo punto,
    // prova a usarli e vedi quale errore di compilazione ottieni!
}
Listato 8-22: Mostra che chiavi e valori sono di proprietà della hash map una volta inseriti

Non possiamo utilizzare le variabili nome_campo e valore_campo dopo che sono state spostate nella hash map con la chiamata a insert.

Se inseriamo reference a valori nella hash map, i valori non verranno spostati nella hash map. I valori a cui puntano i reference devono essere validi almeno per il tempo in cui è valida la hash map. Approfondiremo questi argomenti in “Validare i Reference con la Lifetime nel Capitolo 10.

Aggiornare una Hash Map

Sebbene il numero di coppie chiave-valore sia espandibile, a ogni chiave univoca può essere associato un solo valore alla volta (ma non viceversa: ad esempio, sia la squadra Blu che quella Gialla potrebbero avere il valore 10 memorizzato nella hash map punteggi).

Quando si desidera modificare i dati in una hash map, è necessario decidere come gestire il caso in cui a una chiave sia già assegnato un valore. È possibile sostituire il vecchio valore con il nuovo valore, ignorando completamente il vecchio valore. È possibile mantenere il vecchio valore e ignorare il nuovo valore, aggiungendo il nuovo valore solo se la chiave non ha già un valore. Oppure è possibile combinare il vecchio valore e il nuovo valore. Vediamo come fare ciascuna di queste cose!

Sovrascrivere un Valore

Se inseriamo una chiave e un valore in una hash map e poi inseriamo la stessa chiave con un valore diverso, il valore associato a quella chiave verrà sostituito. Anche se il codice nel Listato 8-23 chiama insert due volte, la hash map conterrà solo una coppia chiave-valore perché stiamo inserendo il valore per la chiave della squadra Blu entrambe le volte.

fn main() {
    use std::collections::HashMap;

    let mut punteggi = HashMap::new();

    punteggi.insert(String::from("Blu"), 10);
    punteggi.insert(String::from("Blu"), 25);

    println!("{punteggi:?}");
}
Listato 8-23: Sostituzione di un valore memorizzato con una chiave specifica

Questo codice stamperà {"Blu": 25}. Il valore originale di 10 è stato sovrascritto.

Aggiungere una Chiave e un Valore Solo Se una Chiave Non è Presente

È comune verificare se una particolare chiave esiste già nella hash map con un valore e quindi eseguire le seguenti azioni: se la chiave esiste nella hash map, il valore esistente deve rimanere invariato; se la chiave non esiste, inserirla e assegnarle un valore.

Le hash map dispongono di un’API speciale per questo scopo, chiamata entry, che accetta la chiave che si desidera controllare come parametro. Il valore restituito dal metodo entry è un’enum chiamato Entry che rappresenta un valore che potrebbe esistere o meno. Supponiamo di voler verificare se la chiave per la squadra Gialla ha un valore associato. In caso contrario, vogliamo inserire il valore 50, e lo stesso vale per la squadra Blu. Utilizzando l’API entry, il codice appare come nel Listato 8-24.

fn main() {
    use std::collections::HashMap;

    let mut punteggi = HashMap::new();
    punteggi.insert(String::from("Blu"), 10);

    punteggi.entry(String::from("Gialla")).or_insert(50);
    punteggi.entry(String::from("Blu")).or_insert(50);

    println!("{punteggi:?}");
}
Listato 8-24: Utilizzo del metodo entry per inserire solo se la chiave non ha già un valore

Il metodo or_insert su Entry è definito per restituire un reference mutabile al valore della chiave Entry corrispondente se tale chiave esiste, e in caso contrario, inserisce il parametro come nuovo valore per questa chiave e restituisce un reference mutabile al nuovo valore. Questa tecnica è molto più pulita rispetto alla scrittura manuale della logica e, inoltre, si integra meglio con il borrow checker.

L’esecuzione del codice nel Listato 8-24 stamperà {"Gialla": 50, "Blu": 10}. La prima chiamata a entry inserirà la chiave per la squadra Gialla con il valore 50 perché la squadra Gialla non ha già un valore. La seconda chiamata a entry non modificherà la hash map perché la squadra Blu ha già il valore 10.

Aggiornare un Valore in Base al Valore Precedente

Un altro caso d’uso comune per le hash map è cercare il valore di una chiave e quindi aggiornarlo in base al valore precedente. Ad esempio, il Listato 8-25 mostra un codice che conta quante volte ogni parola appare in un testo. Utilizziamo una hash map con le parole come chiavi e incrementiamo il valore per tenere traccia di quante volte abbiamo visto quella parola. Quando incontriamo una parola nuova, verrà inserita inizializzando il valore associato a 0.

fn main() {
    use std::collections::HashMap;

    let testo = "hello world wonderful world";

    let mut map = HashMap::new();

    for parola in testo.split_whitespace() {
        let conteggio = map.entry(parola).or_insert(0);
        *conteggio += 1;
    }

    println!("{map:?}");
}
Listato 8-25: Conteggio delle occorrenze di parole utilizzando una hash map che memorizza parole e conteggi

Questo codice stamperà {"world": 2, "hello": 1, "wonderful": 1}. Potresti vedere le stesse coppie chiave-valore stampate in un ordine diverso: ricorda che, come menzionato precedentemente in “Accesso ai Valori in una Hash Map, l’iterazione su una hash map avviene in un ordine arbitrario.

Il metodo split_whitespace restituisce un iteratore su slice, separate da spazi, del valore in testo. Il metodo or_insert restituisce un reference mutabile (&mut V) al valore della chiave specificata. Qui, memorizziamo quel reference mutabile nella variabile conteggio, quindi per assegnare quel valore, dobbiamo prima de-referenziare conteggio usando l’asterisco (*). Il reference mutabile esce dallo scope alla fine del ciclo for, quindi tutte queste modifiche sono sicure e consentite dalle regole di prestito.

Funzioni di Hashing

Come impostazione predefinita, HashMap utilizza una funzione di hashing chiamata SipHash che può fornire resistenza agli attacchi denial-of-service (DoS) che coinvolgono tabelle di hash1. Questo non è l’algoritmo di hashing più veloce disponibile, ma è un buon compromesso in termini di maggiore sicurezza che ne deriva, nonostante il costo prestazionale derivato. Se si profila il codice e si scopre che la funzione di hashing predefinita è troppo lenta per i propri scopi, è possibile passare a un’altra funzione specificando un hasher diverso. Un hasher è un type che implementa il trait BuildHasher. Parleremo dei trait e di come implementarli nel Capitolo 10. Non è necessario implementare il proprio hasher da zero; crates.io offre librerie condivise da altri utenti Rust che forniscono hasher che implementano molti algoritmi di hashing comuni.

Riepilogo

Vettori, stringhe e hash map forniranno una grande quantità di funzionalità necessarie nei programmi quando avrai necessità di memorizzare, accedere e modificare dati. Ecco alcuni esercizi che ora dovresti essere in grado di risolvere:

  1. Dato un elenco di interi, usa un vettore e restituisci la mediana (quando ordinati, il valore in posizione centrale) e la moda (il valore che ricorre più spesso; una hash map sarà utile in questo caso) dell’elenco.
  2. Converti delle stringhe in pig latin. La prima consonante di ogni parola viene spostata alla fine della parola e viene aggiunto ay, quindi primo diventa rimo-pay. Le parole che iniziano con una vocale hanno invece hay aggiunto alla fine (ananas diventa ananas-hay). Tieni a mente i dettagli sulla codifica UTF-8!
  3. Utilizzando hash map e vettori, crea un’interfaccia testuale che consenta a un utente di aggiungere i nomi dei dipendenti a un reparto di un’azienda; ad esempio, “Aggiungi Sally a Ingegneria” o “Aggiungi Amir a Vendite”. Quindi, consenti all’utente di recuperare un elenco di tutte le persone in un reparto o di tutte le persone in azienda per reparto, ordinate alfabeticamente.

La documentazione API della libreria standard descrive i metodi di vettori, stringhe e hash map che saranno utili per questi esercizi!

Stiamo entrando in programmi più complessi in cui le operazioni possono fallire, quindi è il momento perfetto per discutere della gestione degli errori. Lo faremo in seguito!


  1. SipHash su wikipedia (eng)

Gestione degli Errori

Gli errori nel software sono una realtà, quindi Rust offre diverse funzionalità per gestire le situazioni in cui qualcosa non va. In molti casi, Rust ti forza a riconoscere la possibilità di un errore e ti chiede di intraprendere un’azione prima che il codice venga compilato. Questo requisito rende il programma più robusto, garantendo che gli errori vengano rilevati e gestiti in modo appropriato prima di distribuire il tuo codice all’utente finale!

Rust raggruppa gli errori in due categorie principali: errori reversibili e irreversibili. Per un errore reversibile, come un errore file non trovato, molto probabilmente vorremo semplicemente segnalare il problema all’utente e riprovare l’operazione. Gli errori irreversibili sono sempre sintomi di bug, come il tentativo di accedere a una posizione oltre la fine di un array, e quindi vogliamo interrompere immediatamente il programma.

La maggior parte dei linguaggi non distingue tra questi due tipi di errori e li gestisce entrambi allo stesso modo, utilizzando meccanismi come le eccezioni. Rust non ha le eccezioni. Al contrario, ha il type Result<T, E> per gli errori reversibili e la macro panic! che interrompe l’esecuzione quando il programma incontra un errore irreversibile. Questo capitolo tratta prima la chiamata a panic! e poi parla della restituzione dei valori Result<T, E>. Inoltre, esploreremo le considerazioni da tenere presente quando si decide se tentare di recuperare da un errore o interrompere l’esecuzione.

Errori Irreversibili con panic!

A volte si verificano problemi nel codice e non c’è nulla che si possa fare al riguardo. In questi casi, Rust dispone della macro panic!. Esistono praticamente due modi per causare un panic: eseguendo un’azione che causa il panic del codice (come tentare di accedere a un array oltre la fine) o chiamando esplicitamente la macro panic!. In entrambi i casi, causiamo un panic nel nostro programma. Per impostazione predefinita, questi panic stampano un messaggio di errore, eseguono un unwind, puliscono lo stack e terminano. Tramite una variabile d’ambiente, è anche possibile fare in modo che Rust visualizzi lo stack delle chiamate quando si verifica un panic, per facilitare l’individuazione della causa del panic.

Unwinding dello Stack o Interruzione in Risposta a un Panic

Per impostazione predefinita, quando si verifica un panic il programma avvia l’unwinding, il che significa che Rust risale lo stack e pulisce i dati da ogni funzione che incontra. Tuttavia, risalire lo stack e pulire richiede molto lavoro. Rust, quindi, consente di scegliere l’alternativa di abortire immediatamente, che termina il programma senza pulizia. La memoria che il programma stava utilizzando dovrà quindi essere ripulita dal sistema operativo. Se nel progetto è necessario ridurre al minimo il binario risultante, è possibile passare dall’unwinding all’interruzione in caso di panic aggiungendo panic = 'abort' alle sezioni [profile] appropriate nel file Cargo.toml. Ad esempio, se si desidera interrompere l’esecuzione in caso di panic nell’eseguibile finale (release) ma non in fase di sviluppo, aggiungi quanto segue:

[profile.release]
panic = 'abort'

Proviamo a chiamare panic! in un semplice programma:

File: src/main.rs
fn main() {
    panic!("crepa e brucia");
}

Quando si esegue il programma, si vedrà qualcosa di simile a questo:

$ cargo run
   Compiling panic v0.1.0 (file:///progetti/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:2:5:
crepa e brucia
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

La chiamata a panic! causa il messaggio di errore contenuto nelle ultime righe. La prima riga mostra il punto del codice sorgente in cui si è verificato l’errore: src/main.rs:2:5 indica che si tratta della seconda riga, quinto carattere del nostro file src/main.rs.

In questo caso, la riga indicata fa parte del nostro codice e, se andiamo a quella riga, vediamo la chiamata alla macro panic!. In altri casi, la chiamata a panic! potrebbe essere nel codice richiamato dal nostro codice e il nome del file e il numero di riga riportati dal messaggio di errore saranno il codice di qualcun altro in cui viene richiamata la macro panic!, non la riga del nostro codice che alla fine ha portato alla chiamata a panic!.

La seconda riga mostra il nostro messaggio di errore inserito nella chiamata a panic!.

Possiamo usare il backtrace (andare a ritroso) delle funzioni da cui proviene la chiamata panic! per capire la parte del nostro codice che sta causando il problema. Per capire come usare un backtrace di panic!, diamo un’occhiata a un altro esempio e vediamo cosa succede quando una chiamata panic! proviene da una libreria a causa di un bug nel nostro codice invece che dal nostro codice che chiama direttamente la macro. Il Listato 9-1 contiene del codice che tenta di accedere a un indice in un vettore oltre l’intervallo di indici validi.

File: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
Listato 9-1: Tentativo di accedere a un elemento oltre la fine di un vettore, che causerà una chiamata a panic!

Qui stiamo tentando di accedere al centesimo elemento del nostro vettore (che si trova all’indice 99 perché l’indicizzazione inizia da zero), ma il vettore ha solo tre elementi. In questa situazione, Rust andrà in panic. L’utilizzo di [] dovrebbe restituire un elemento, ma se si passa un indice non valido, non c’è alcun elemento valido che Rust potrebbe restituire.

In C, tentare di leggere oltre la fine di una struttura dati è un comportamento indefinito. Si potrebbe ottenere qualsiasi cosa si trovi nella posizione in memoria che corrisponderebbe a quell’elemento nella struttura dati, anche se la memoria non appartiene a quella struttura. Questo è chiamato buffer overread (lettura oltre il buffer) e può portare a vulnerabilità di sicurezza se un aggressore riesce a manipolare l’indice in modo tale da leggere dati che non dovrebbe essere autorizzato a leggere e che sono memorizzati dopo la struttura dati.

Per proteggere il programma da questo tipo di vulnerabilità, se si tenta di leggere un elemento in un indice che non esiste, Rust interromperà l’esecuzione e si rifiuterà di continuare. Proviamo e vediamo:

$ cargo run
   Compiling panic v0.1.0 (file:///progetti/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.37s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Questo errore punta alla riga 4 del nostro main.rs, dove tentiamo di accedere all’indice 99 del vettore in v.

La riga note: ci dice che possiamo impostare la variabile d’ambiente RUST_BACKTRACE per ottenere un backtrace di ciò che ha causato l’errore. Un backtrace è un elenco di tutte le funzioni che sono state chiamate per arrivare a questo punto. I backtrace in Rust funzionano come in altri linguaggi: la chiave per leggere il backtrace è iniziare dall’inizio e leggere fino a quando non si vedono i file che si sono creati. Quello è il punto in cui si è originato il problema. Le righe sopra quel punto sono il codice che il tuo codice ha chiamato; le righe sottostanti sono il codice che ha chiamato il tuo codice. Queste righe prima e dopo potrebbero includere codice Rust core, codice di libreria standard o pacchetti che stai utilizzando. Proviamo a ottenere un backtrace impostando la variabile d’ambiente RUST_BACKTRACE su un valore qualsiasi tranne 0. Il Listato 9-2 mostra un output simile a quello che vedrai.

$ RUST_BACKTRACE=1 cargo run
thread 'main' (11968) panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: __rustc::rust_begin_unwind
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/std/src/panicking.rs:698:5
   1: core::panicking::panic_fmt
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/panicking.rs:280:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/slice/index.rs:18:15
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/alloc/src/vec/mod.rs:3621:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at /rustc/8e62bfd311791bfd9dca886abdfbab07ec54d8b4/library/core/src/ops/function.rs:253:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Listato 9-2: Il backtrace generato da una chiamata a panic! viene visualizzato quando la variabile d’ambiente RUST_BACKTRACE è impostata

Un output enorme! L’output esatto che vedi potrebbe variare a seconda del sistema operativo e della versione di Rust. Per ottenere backtrace con queste informazioni, i simboli di debug devono essere abilitati. I simboli di debug sono abilitati per impostazione predefinita quando si utilizza cargo build o cargo run senza il flag --release, come in questo caso.

Nell’output del Listato 9-2, la riga 6 del backtrace punta alla riga del nostro progetto che causa il problema: la riga 4 di src/main.rs. Se non vogliamo che il nostro programma vada in panic, dovremmo iniziare la nostra analisi dalla posizione indicata dalla prima riga che menziona un file che abbiamo scritto. Nel Listato 9-1, dove abbiamo volutamente scritto codice che andrebbe in panic, il modo per risolvere il problema è non richiedere un elemento oltre l’intervallo degli indici del vettore. Quando in futuro il codice andrà in panic, dovrai capire quale azione sta eseguendo il codice con quali valori tali da causare il panic e cosa dovrebbe fare il codice al suo posto.

Torneremo su panic! e su quando dovremmo e non dovremmo usare panic! per gestire le condizioni di errore nella sezione panic! o non panic! più avanti in questo capitolo. Ora vedremo come gestire un errore utilizzando Result.

Errori Reversibili con Result

La maggior parte degli errori non è abbastanza grave da richiedere l’arresto completo del programma. A volte, quando una funzione fallisce, è per un motivo facilmente interpretabile e a cui è possibile rispondere. Ad esempio, se si tenta di aprire un file e l’operazione fallisce perché il file non esiste, potrebbe essere opportuno crearlo anziché terminare il processo.

Come menzionato in “Gestire i potenziali errori con Result nel Capitolo 2 l’enum Result è definito come avente due varianti, Ok ed Err, come segue:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

T ed E sono parametri di type generico: parleremo dei generici più in dettaglio nel Capitolo 10. Quello che devi sapere ora è che T rappresenta il type di valore che verrà restituito in caso di successo nella variante Ok, ed E rappresenta il type di errore che verrà restituito in caso di fallimento nella variante Err. Poiché Result ha questi parametri di type generico, possiamo utilizzare il type Result e le funzioni definite su di esso in molte situazioni diverse in cui il valore di successo e il valore di errore che vogliamo restituire potrebbero differire.

Chiamiamo una funzione che restituisca un valore Result perché la funzione potrebbe fallire. Nel Listato 9-3 proviamo ad aprire un file.

File: src/main.rs
use std::fs::File;

fn main() {
    let file_benvenuto_result = File::open("ciao.txt");
}
Listato 9-3: Apertura di un file

Il type restituito da File::open è Result<T, E>. Il parametro generico T è stato riempito dall’implementazione di File::open con il type del valore di successo, std::fs::File, che è un handle al file. Il type di E utilizzato nel valore di errore è std::io::Error. Questo type di ritorno significa che la chiamata a File::open potrebbe avere esito positivo e restituire un handle al file da cui è possibile leggere o scrivere. La chiamata di funzione potrebbe anche fallire: ad esempio, il file potrebbe non esistere o potremmo non avere i permessi per accedervi. La funzione File::open deve avere un modo per indicarci se è riuscita o meno e allo stesso tempo fornirci l’handle al file o le informazioni sull’errore. Queste informazioni sono esattamente ciò che l’enum Result trasmette.

Nel caso in cui File::open abbia esito positivo, il valore nella variabile file_benvenuto_result sarà un’istanza di Ok che contiene un handle al file. In caso di errore, il valore in file_benvenuto_result sarà un’istanza di Err che contiene maggiori informazioni sul tipo di errore che si è verificato.

Dobbiamo aggiungere al codice del Listato 9-3 azioni diverse a seconda del valore restituito da File::open. Il Listato 9-4 mostra un modo per gestire il risultato Result utilizzando uno strumento di base, l’espressione match di cui abbiamo parlato nel Capitolo 6.

File: src/main.rs
use std::fs::File;

fn main() {
    let file_benvenuto_result = File::open("ciao.txt");

    let file_benvenuto = match file_benvenuto_result {
        Ok(file) => file,
        Err(errore) => panic!("Errore nell'apertura del file: {errore:?}"),
    };
}
Listato 9-4: Utilizzo di un’espressione match per gestire le varianti di Result che potrebbero essere restituite

Nota che, come l’enum Option, l’enum Result e le sue varianti sono state introdotte nello scope dal preludio, quindi non è necessario specificare Result:: prima delle varianti Ok ed Err nei rami di match.

Quando il risultato è Ok, questo codice restituirà il valore interno file dalla variante Ok, e quindi assegneremo il valore dell’handle al file alla variabile file_benvenuto. Dopo match, possiamo utilizzare l’handle al file per la lettura o la scrittura.

L’altro ramo di match gestisce il caso in cui otteniamo un valore Err da File::open. In questo esempio, abbiamo scelto di chiamare la macro panic!. Se non c’è alcun file denominato ciao.txt nella nostra cartella corrente ed eseguiamo questo codice, vedremo il seguente output dalla macro panic!:

$ cargo run
   Compiling gestione_errore v0.1.0 (file:///progetti/gestione_errore)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.37s
     Running `target/debug/gestione_errore`

thread 'main' (6304) panicked at src/main.rs:8:24:
Errore nell'apertura del file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Come al solito, questo output ci dice esattamente cosa è andato storto.

Corrispondenza in Caso di Errori Diversi

Il codice nel Listato 9-4 genererà un panic! indipendentemente dal motivo per cui File::open ha fallito. Tuttavia, vogliamo intraprendere azioni diverse per diversi motivi di errore. Se File::open ha fallito perché il file non esiste, vogliamo crearlo e restituire l’handle al nuovo file. Se File::open ha fallito per qualsiasi altro motivo, ad esempio perché non avevamo l’autorizzazione per aprire il file, vogliamo comunque che il codice generi un panic! come nel Listato 9-4. Per questo, aggiungiamo un’espressione match interna, mostrata nel Listato 9-5.

File: src/main.rs
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file_benvenuto_result = File::open("ciao.txt");

    let file_benvenuto = match file_benvenuto_result {
        Ok(file) => file,
        Err(errore) => match errore.kind() {
            ErrorKind::NotFound => match File::create("ciao.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Errore nella creazione del file: {e:?}"),
            },
            _ => {
                panic!("Errore nell'apertura del file: {errore:?}");
            }
        },
    };
}
Listato 9-5: Gestione di diversi tipi di errori in modi diversi

Il type del valore restituito da File::open all’interno della variante Err è io::Error, una struct fornita dalla libreria standard. Questa struct ha un metodo kind che possiamo chiamare per ottenere un valore io::ErrorKind. L’enum io::ErrorKind è fornito dalla libreria standard e ha varianti che rappresentano i diversi tipi di errori che potrebbero verificarsi da un’operazione io. La variante che vogliamo utilizzare è ErrorKind::NotFound, che indica che il file che stiamo cercando di aprire non esiste ancora. Abbiamo in pratica fatto un matching sia su file_benvenuto_result sia, internamente, sulla tipologia di errore in error.kind().

La condizione che vogliamo verificare nel matching interno è se il valore restituito da error.kind() è la variante NotFound dell’enum ErrorKind. In tal caso, proviamo a creare il file con File::create. Tuttavia, poiché anche File::create potrebbe fallire, abbiamo bisogno di un secondo ramo nell’espressione match interna. Quando il file non può essere creato, viene visualizzato un messaggio di errore diverso. Il secondo ramo dell’espressione match esterna rimane invariato, quindi il programma va in panic in caso di qualsiasi errore diverso dall’errore di file mancante.

Alternative all’Utilizzo di match con Result<T, E>

Sono un sacco di match! L’espressione match è molto utile, ma anche molto primitiva. Nel Capitolo 13, imparerai a conoscere le chiusure (closure), che vengono utilizzate con molti dei metodi definiti in Result<T, E>. Questi metodi possono essere più concisi rispetto all’utilizzo di match quando si gestiscono i valori Result<T, E> nel codice. Ad esempio, ecco un altro modo per scrivere la stessa logica mostrata nel Listato 9-5, questa volta utilizzando le chiusure e il metodo unwrap_or_else:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file_benvenuto = File::open("ciao.txt").unwrap_or_else(|errore| {
        if errore.kind() == ErrorKind::NotFound {
            File::create("ciao.txt").unwrap_or_else(|errore| {
                panic!("Errore nella creazione del file: {errore:?}");
            })
        } else {
            panic!("Errore nell’apertura del file: {errore:?}");
        }
    });
}

Sebbene questo codice abbia lo stesso comportamento del Listato 9-5, non contiene alcuna espressione match ed è più chiaro da leggere. Torna a questo esempio dopo aver letto il Capitolo 13 e cerca il metodo unwrap_or_else nella documentazione della libreria standard. Molti altri di questi metodi possono sostituire enormi espressioni annidate di match quando si hanno errori.

Scorciatoie per Panic in Caso di Errore

L’uso di match funziona abbastanza bene, ma può essere un po’ prolisso e non sempre comunica bene l’intento. Il type Result<T, E> ha molti metodi utili definiti al suo interno per svolgere varie attività più specifiche. Ad esempio unwrap è un metodo di scelta rapida implementato proprio come l’espressione match che abbiamo scritto nel Listato 9-4. Se il valore Result è la variante Ok, unwrap restituirà il valore all’interno di Ok. Se Result è la variante Err, unwrap richiamerà la macro panic! per noi. Ecco un esempio di unwrap in azione:

File: src/main.rs
use std::fs::File;

fn main() {
    let file_benvenuto = File::open("ciao.txt").unwrap();
}

Se eseguiamo questo codice senza un file ciao.txt, vedremo un messaggio di errore dalla chiamata panic! effettuata dal metodo unwrap:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Analogamente, il metodo expect ci consente anche di scegliere il messaggio di errore panic!. Usare expect invece di unwrap e fornire messaggi di errore efficaci può trasmettere le proprie intenzioni e facilitare l’individuazione della fonte di un errore. La sintassi di expect è la seguente:

File: src/main.rs
use std::fs::File;

fn main() {
    let file_benvenuto = File::open("ciao.txt")
        .expect("ciao.txt dovrebbe essere presente in questo progetto");
}

Usiamo expect allo stesso modo di unwrap: per restituire l’handle al file o chiamare la macro panic!. Il messaggio di errore utilizzato da expect nella sua chiamata a panic! sarà il parametro che passeremo a expect, anziché il messaggio predefinito panic! utilizzato da unwrap. Ecco come appare:

thread 'main' panicked at src/main.rs:5:10:
ciao.txt dovrebbe essere presente in questo progetto: Os { code: 2, kind: NotFound, message: "No such file or directory" }

Nel codice ottimizzato per il rilascio, la maggior parte dei Rustacean sceglie expect invece di unwrap per fornire più contesto sul motivo per cui ci si aspetta che l’operazione è fallita. In questo modo, se le tue ipotesi dovessero rivelarsi sbagliate, avrai più informazioni da utilizzare per il debug.

Propagazione degli Errori

Quando l’implementazione di una funzione richiama qualcosa che potrebbe non funzionare, invece di gestire l’errore all’interno della funzione stessa, è possibile restituirlo al codice chiamante in modo che possa decidere cosa fare. Questa operazione è nota come propagazione dell’errore e conferisce maggiore controllo al codice chiamante, dove potrebbero esserci più informazioni o logica che determinano come gestire l’errore rispetto a quelle disponibili nel contesto del codice.

Ad esempio, il Listato 9-6 mostra una funzione che legge un nome utente da un file. Se il file non esiste o non può essere letto, questa funzione restituirà gli errori al codice che ha chiamato la funzione.

File: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn leggi_nomeutente_dal_file() -> Result<String, io::Error> {
    let nomeutente_file_result = File::open("ciao.txt");

    let mut nomeutente_file = match nomeutente_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut nomeutente = String::new();

    match nomeutente_file.read_to_string(&mut nomeutente) {
        Ok(_) => Ok(nomeutente),
        Err(e) => Err(e),
    }
}
}
Listato 9-6: Una funzione che restituisce errori al codice chiamante utilizzando match

Questa funzione può essere scritta in un modo molto più breve, ma inizieremo eseguendo gran parte del processo manualmente per esplorare la gestione degli errori; alla fine, mostreremo il modo più breve. Per prima cosa diamo un’occhiata al type di ritorno della funzione: Result<String, io::Error>. Ciò significa che la funzione restituisce un valore del type Result<T, E>, dove il parametro generico T è stato riempito con il type concreto String e il type generico E è stato riempito con il type concreto io::Error.

Se questa funzione ha esito positivo senza problemi, il codice che la chiama riceverà un valore Ok che contiene una String, ovvero il nomeutente che questa funzione ha letto dal file. Se questa funzione riscontra problemi, il codice chiamante riceverà un valore Err che contiene un’istanza di io::Error contenente maggiori informazioni sulla causa del problema. Abbiamo scelto io::Error come type di ritorno di questa funzione perché è il type del valore di errore restituito da entrambe le operazioni che stiamo chiamando nel corpo di questa funzione che potrebbero fallire: la funzione File::open e il metodo read_to_string.

Il corpo della funzione inizia con la chiamata alla funzione File::open. Quindi gestiamo il valore Result con un match simile a quello nel Listato 9-4. Se File::open ha esito positivo, l’handle al file nella variabile pattern file diventa il valore nella variabile mutabile nomeutente_file e la funzione continua. Nel caso Err, invece di chiamare panic!, utilizziamo la parola chiave return per uscire completamente dalla funzione e passare il valore di errore da File::open, ora nella variabile pattern e, al codice chiamante come valore di errore di questa funzione.

Quindi, se abbiamo un handle al file in nomeutente_file, la funzione crea una nuova String nella variabile nomeutente e chiama il metodo read_to_string sull’handle al file in nomeutente_file per leggere il contenuto del file in nomeutente. Anche il metodo read_to_string restituisce un Result perché potrebbe fallire, anche se File::open ha avuto esito positivo. Abbiamo quindi bisogno di un altro match per gestire quel Result: se read_to_string ha esito positivo, la nostra funzione ha avuto successo e restituiamo il nome utente dal file che ora si trova in nomeutente incapsulato in un Ok. Se read_to_string fallisce, restituiamo il valore di errore nello stesso modo in cui abbiamo restituito il valore di errore nel match che gestiva il valore di ritorno di File::open. Tuttavia, non è necessario specificare esplicitamente return, perché questa è l’ultima espressione nella funzione.

Il codice chiamante gestirà quindi l’ottenimento di un valore Ok che contiene un nome utente o di un valore Err che contiene un io::Error. Spetta al codice chiamante decidere cosa fare con questi valori. Se il codice chiamante riceve un valore Err, potrebbe chiamare panic! e mandare in crash il programma, utilizzare un nome utente predefinito o cercare il nome utente da una posizione diversa da un file, ad esempio. Non abbiamo informazioni sufficienti su cosa stia effettivamente cercando di fare il codice chiamante, quindi propaghiamo tutte le informazioni di successo o errore verso l’alto affinché possa gestirle in modo appropriato.

Questo schema di propagazione degli errori è così comune in Rust che Rust fornisce l’operatore punto interrogativo ? per semplificare la procedura.

L’Operatore Scorciatoia ?

Il Listato 9-7 mostra un’implementazione di leggi_nomeutente_dal_file che ha la stessa funzionalità del Listato 9-6, ma questa implementazione utilizza l’operatore ?.

File: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn leggi_nomeutente_dal_file() -> Result<String, io::Error> {
    let mut nomeutente_file = File::open("ciao.txt")?;
    let mut nomeutente = String::new();
    nomeutente_file.read_to_string(&mut nomeutente)?;
    Ok(nomeutente)
}
}
Listato 9-7: Una funzione che restituisce errori al codice chiamante utilizzando l’operatore ?

Il ? inserito dopo un valore Result è definito per funzionare quasi allo stesso modo delle espressioni match che abbiamo definito per gestire i valori Result nel Listato 9-6. Se il valore di Result è Ok, il valore all’interno di Ok verrà restituito da questa espressione e il programma continuerà. Se il valore è Err, Err verrà restituito dall’intera funzione come se avessimo utilizzato la parola chiave return, quindi il valore di errore viene propagato al codice chiamante.

C’è una differenza tra ciò che fa l’espressione match del Listato 9-6 e ciò che fa l’operatore ?: i valori di errore che hanno l’operatore ? chiamato su di essi passano attraverso la funzione from, definita nel trait From nella libreria standard, che viene utilizzata per convertire i valori da un type all’altro. Quando l’operatore ? chiama la funzione from, il type di errore ricevuto viene convertito nel type di errore definito nel type di ritorno della funzione corrente. Questo è utile quando una funzione restituisce un type di errore per rappresentare tutti i modi in cui la funzione potrebbe fallire, anche se alcune parti potrebbero fallire per molti motivi diversi.

Ad esempio, potremmo modificare la funzione leggi_nomeutente_dal_file nel Listato 9-7 per restituire un type personalizzato di errore denominato NostroErrore da noi definito. Se definiamo anche impl From<io::Error> for NostroErrore per costruire un’istanza di NostroErrore partendo da un io::Error, l’operatore ? chiamato nel corpo di leggi_nomeutente_dal_file chiamerà from e convertirà i type di errore senza bisogno di aggiungere altro codice alla funzione.

Nel contesto del Listato 9-7, ? alla fine della chiamata File::open restituirà il valore all’interno di un Ok alla variabile nomeutente_file. Se si verifica un errore, l’operatore ? interromperà l’intera funzione in anticipo e ritornerà un valore Err al codice chiamante. Lo stesso vale per ? alla fine della chiamata read_to_string.

L’operatore ? elimina gran parte del codice superfluo e semplifica l’implementazione di questa funzione. Potremmo anche accorciare ulteriormente questo codice concatenando le chiamate ai metodi subito dopo ?, come mostrato nel Listato 9-8.

File: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn leggi_nomeutente_dal_file() -> Result<String, io::Error> {
    let mut nomeutente = String::new();

    File::open("ciao.txt")?.read_to_string(&mut nomeutente)?;

    Ok(nomeutente)
}
}
Listato 9-8: Concatenamento delle chiamate ai metodi dopo l’operatore ?

Abbiamo spostato la creazione della nuova String in nomeutente all’inizio della funzione; questa parte non è cambiata. Invece di creare una variabile nomeutente_file, abbiamo concatenato la chiamata a read_to_string direttamente al risultato di File::open("hello.txt")?. Abbiamo ancora un ? alla fine della chiamata a read_to_string e continuiamo a restituire un valore Ok contenente nomeutente quando sia File::open che read_to_string hanno esito positivo, anziché restituire errori. La funzionalità è ancora la stessa dei Listati 9-6 e 9-7; questo è solo un modo diverso e più stringato di scriverlo.

Il Listato 9-9 mostra un modo per renderlo ancora più breve usando fs::read_to_string.

File: src/main.rs
#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn leggi_nomeutente_dal_file() -> Result<String, io::Error> {
    fs::read_to_string("ciao.txt")
}
}
Listato 9-9: Usare fs::read_to_string invece di aprire e poi leggere il file

Leggere un file in una stringa è un’operazione abbastanza comune, quindi la libreria standard fornisce la comoda funzione fs::read_to_string che apre il file, crea una nuova String, ne legge il contenuto, lo inserisce in quella String e la restituisce. Ovviamente, usare fs::read_to_string non ci dà l’opportunità di spiegare tutta la gestione degli errori, quindi l’abbiamo fatto prima nel modo più lungo.

Dove Usare l’Operatore ?

L’operatore ? può essere utilizzato solo in funzioni il cui type di ritorno è compatibile con il valore su cui viene utilizzato ?. Questo perché l’operatore ? è definito per eseguire una restituzione anticipata di un valore dalla funzione, allo stesso modo dell’espressione match che abbiamo definito nel Listato 9-6. Nel Listato 9-6, la funzione match utilizzava un valore Result e il ramo di ritorno anticipato restituiva un valore Err(e). Il type di ritorno della funzione deve essere Result in modo che sia compatibile con questo return.

Nel Listato 9-10, esaminiamo l’errore che otterremo se utilizziamo l’operatore ? in una funzione main con un type di ritorno incompatibile con il type del valore su cui utilizziamo ?.

File: src/main.rs
use std::fs::File;

fn main() {
    let file_benvenuto = File::open("ciao.txt")?;
}
Listato 9-10: Il tentativo di utilizzare ? nella funzione main che restituisce () non verrà compilato

Questo codice apre un file, che potrebbe non funzionare. L’operatore ? segue il valore Result restituito da File::open, ma questa funzione main ha come type di ritorno (), non Result. Quando compiliamo questo codice, otteniamo il seguente messaggio di errore:

$ cargo run
   Compiling gestione_errore v0.1.0 (file:///progetti/gestione_errore)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let file_benvenuto = File::open("ciao.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let file_benvenuto = File::open("ciao.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gestione_errore` (bin "gestione_errore") due to 1 previous error

Questo errore indica che possiamo utilizzare l’operatore ? solo in una funzione che restituisce Result, Option o un altro type che implementa FromResidual.

Per correggere l’errore, hai due possibilità. Una è modificare il type di ritorno della funzione in modo che sia compatibile con il valore su cui stai utilizzando l’operatore ?, a condizione che non ci siano restrizioni che lo impediscano. L’altra possibilità è utilizzare un match o uno dei metodi Result<T, E> per gestire Result<T, E> nel modo più appropriato.

Il messaggio di errore indica anche che ? può essere utilizzato anche con i valori Option<T>. Come per l’utilizzo di ? su Result, è possibile utilizzare ? solo su Option in una funzione che restituisce Option. Il comportamento dell’operatore ? quando viene chiamato su Option<T> è simile al suo comportamento quando viene chiamato su Result<T, E>: se il valore è None, None verrà restituito in anticipo dalla funzione in quel punto. Se il valore è Some, il valore all’interno di Some è il valore risultante dell’espressione e la funzione continua. Il Listato 9-11 contiene un esempio di una funzione che trova l’ultimo carattere della prima riga del testo dato.

fn ultimo_char_della_prima_riga(testo: &str) -> Option<char> {
    testo.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        ultimo_char_della_prima_riga("Hello, world\nCome stai oggi?"),
        Some('d')
    );

    assert_eq!(ultimo_char_della_prima_riga(""), None);
    assert_eq!(ultimo_char_della_prima_riga("\nhi"), None);
}
Listato 9-11: Utilizzo dell’operatore ? su un valore Option<T>

Questa funzione restituisce Option<char> perché è possibile che ci sia un carattere, ma è anche possibile che non ci sia. Questo codice prende l’argomento slice testo e chiama il metodo lines su di esso, che restituisce un iteratore sulle righe della stringa. Poiché questa funzione vuole esaminare la prima riga, chiama next sull’iteratore per ottenere il primo valore dall’iteratore. Se testo è una stringa vuota, questa chiamata a next restituirà None, nel qual caso usiamo ? per fermarci e restituire None da ultimo_char_della_prima_riga. Se testo non è una stringa vuota, next restituirà un valore Some contenente una slice della prima riga di testo.

? estrae la slice e possiamo chiamare chars su quella slice per ottenere un iteratore dei suoi caratteri. Siamo interessati all’ultimo carattere in questa prima riga, quindi chiamiamo last per restituire l’ultimo elemento nell’iteratore. Questo è un’Option perché è possibile che la prima riga sia una stringa vuota; ad esempio, se testo inizia con una riga vuota ma ha caratteri su altre righe, come in "\nhi". Tuttavia, se c’è un ultimo carattere sulla prima riga, verrà restituito nella variante Some. L’operatore ? al centro ci fornisce un modo conciso per esprimere questa logica, permettendoci di implementare la funzione in una sola riga. Se non potessimo usare l’operatore ? su Option, dovremmo implementare questa logica utilizzando più chiamate di metodo o un’espressione match.

Nota che è possibile utilizzare l’operatore ? su un Result in una funzione che restituisce Result, e si può utilizzare l’operatore ? su un Option in una funzione che restituisce Option, ma non è possibile combinare le due. L’operatore ? non convertirà automaticamente un Result in un Option o viceversa; in questi casi, è possibile utilizzare il metodo ok su Result o il metodo ok_or su Option per eseguire la conversione in modo esplicito.

Finora, tutte le funzioni main che abbiamo utilizzato restituiscono (). La funzione main è speciale perché è il punto di ingresso e di uscita di un programma eseguibile, e ci sono delle restrizioni sul type di ritorno che può essere usato affinché il programma si comporti come previsto.

Fortunatamente, main può anche restituire Result<(), E>. Il Listato 9-12 contiene il codice del Listato 9-10, ma abbiamo modificato il type di ritorno di main in Result<(), Box<dyn Error>> e aggiunto un valore di ritorno Ok(()) alla fine. Questo codice ora verrà compilato.

File: src/main.rs
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let file_benvenuto = File::open("ciao.txt")?;

    Ok(())
}
Listato 9-12: Modificando main in modo che restituisca Result<(), E> è possibile utilizzare l’operatore ? sui valori Result

Il type Box<dyn Error> è un oggetto trait, di cui parleremo in “Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi” nel Capitolo 18. Per ora, puoi leggere Box<dyn Error> come “qualsiasi type di errore”. L’utilizzo di ? su un valore Result in una funzione main con il type di errore Box<dyn Error> è consentito perché consente la restituzione anticipata di qualsiasi valore Err. Anche se il corpo di questa funzione main restituirà sempre e solo errori di type std::io::Error, specificando Box<dyn Error>, questa firma continuerà a essere corretta anche se al corpo di main viene aggiunto altro codice che restituisce altri errori.

Quando una funzione main restituisce Result<(), E>, l’eseguibile terminerà restituendo un valore di 0 se main restituisce Ok(()) e uscirà con un valore diverso da zero se main restituisce un valore Err. Gli eseguibili scritti in C restituiscono integer quando terminano: i programmi che terminano correttamente restituiscono l’integer 0, e i programmi che generano un errore restituiscono un integer diverso da 0. Anche Rust restituisce integer dagli eseguibili per essere compatibile con questa convenzione.

La funzione main può restituire qualsiasi type che implementi il trait std::process::Termination, contenente una funzione report che restituisce un ExitCode. Consulta la documentazione della libreria standard per maggiori informazioni sull’implementazione del trait Termination per i tuoi type.

Ora che abbiamo discusso i dettagli della chiamata a panic! o della restituzione di Result, discuteremo di come decidere in quali casi sia più appropriato usare l’uno o l’altro.

panic! o non panic!

Quindi, come si decide quando chiamare panic! e quando restituire Result? Quando il codice va in panic, non c’è modo di tornare indietro. Si potrebbe chiamare panic! per qualsiasi situazione di errore, indipendentemente dal fatto che ci sia o meno una possibile soluzione, ma in tal caso si sta prendendo la decisione che una situazione non è reversibile per conto del codice chiamante. Quando si sceglie di restituire un valore Result, si forniscono al codice chiamante delle opzioni. Il codice chiamante potrebbe scegliere di tentare di rispondere in un modo appropriato alla situazione, oppure potrebbe decidere che un valore Err in questo caso è irreversibile, quindi può chiamare panic! e trasformare il tuo errore reversibile in un errore irreversibile. Pertanto, restituire Result è una buona scelta predefinita quando si definisce una funzione che potrebbe fallire.

In situazioni come codice di esempio, prototipale e test, è più appropriato scrivere codice che vada in panic invece di restituire un Result. Esploriamo il motivo, poi analizziamo le situazioni in cui il compilatore non può dire che il fallimento è impossibile, ma tu, in quanto essere umano, sì. Il capitolo si concluderà con alcune linee guida generali su come decidere se andare in panic quando scrivi codice per una libreria.

Codice di Esempio, Prototipale e Test

Quando si scrive un esempio per illustrare un concetto, includere anche un codice robusto per la gestione degli errori può rendere l’esempio meno chiaro. Negli esempi, è sufficientemente chiaro che una chiamata a un metodo come unwrap che potrebbe andare in panico è intesa come segnaposto per il modo in cui si desidera che l’applicazione gestisca gli errori, che può differire in base al comportamento del resto del codice.

Allo stesso modo, i metodi unwrap ed expect sono molto utili durante la fase di prototipazione, prima di decidere come gestire gli errori. Lasciano chiari punti nel codice per quando si è pronti a riscriverlo per renderlo più robusto.

Se una chiamata a un metodo fallisce in un test, si desidera che l’intero test fallisca, anche se quel metodo non è la funzionalità in fase di test. Poiché panic! è il modo in cui un test viene contrassegnato come fallito, chiamare unwrap o expect è esattamente ciò che dovrebbe accadere.

Quando Hai Più Informazioni Del Compilatore

Sarebbe anche appropriato chiamare expect quando si dispone di un’altra logica che garantisce che Result abbia un valore Ok, ma la logica non è qualcosa che il compilatore capisce. Si avrà comunque un valore Result che bisogna gestire: qualsiasi operazione si stia chiamando ha comunque la possibilità di fallire, anche se è logicamente impossibile nella propria situazione specifica. Se puoi assicurarti, ispezionando manualmente il codice, che non avrai mai una variante Err, è perfettamente accettabile chiamare expect e documentare il motivo per cui pensi di non avere mai una variante Err nel testo dell’argomento. Ecco un esempio:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Indirizzo IP definito dovrebbe essere valido");
}

Stiamo creando un’istanza di IpAddr analizzando una stringa scritta direttamente. Possiamo vedere che 127.0.0.1 è un indirizzo IP valido, quindi è accettabile usare expect qui. Tuttavia, avere una stringa valida specificata nel codice non cambia il type di ritorno del metodo parse: otteniamo comunque un valore Result e il compilatore ci farà comunque gestire Result come se la variante Err fosse una possibilità perché il compilatore non è abbastanza intelligente da vedere che questa stringa è sempre un indirizzo IP valido. Se la stringa dell’indirizzo IP provenisse da un utente anziché essere scritta direttamente nel programma e quindi avesse una possibilità di errore, vorremmo sicuramente gestire Result in un modo più robusto. Menzionare il presupposto che questo indirizzo IP sia definito ci indurrà a modificare expect con un codice di gestione degli errori migliore se, in futuro, dovessimo ottenere l’indirizzo IP da un’altra fonte.

Linee Guida Per la Gestione degli Errori

È consigliabile mandare in panic il codice quando è possibile che il codice possa finire in uno stato non valido. In questo contesto, uno stato non valido si verifica quando un presupposto, garanzia o contratto non sono rispettati, ad esempio quando vengono passati al codice valori non validi, valori contraddittori o valori mancanti, più almeno una delle seguenti cose:

  • Lo stato non valido è qualcosa di inaspettato, al contrario di qualcosa che probabilmente accadrà occasionalmente, come un utente che inserisce dati nel formato sbagliato.
  • Il codice da questo punto in poi deve fare affidamento sul fatto di non trovarsi in questo stato non valido, piuttosto che verificare la presenza del problema a ogni passaggio.
  • Non esiste un buon modo per codificare queste informazioni nei type utilizzati. Faremo un esempio di ciò che intendiamo in “Codifica di Stati e Comportamenti Come Type nel Capitolo 18.

Se qualcuno chiama il tuo codice e passa valori che non hanno senso, è meglio restituire un errore, se possibile, in modo che l’utente della libreria possa decidere cosa fare in quel caso. Tuttavia, nei casi in cui continuare potrebbe essere insicuro o dannoso, la scelta migliore potrebbe essere quella di chiamare panic! e avvisare la persona che utilizza la tua libreria del bug nel suo codice in modo che possa correggerlo durante lo sviluppo. Allo stesso modo, panic! è spesso appropriato se stai chiamando codice esterno fuori dal tuo controllo e restituisce uno stato non valido che non hai modo di correggere.

Tuttavia, quando è previsto un errore, è più appropriato restituire un Result piuttosto che effettuare una chiamata panic!. Esempi includono un parser che riceve dati non validi o una richiesta HTTP che restituisce uno stato che indica il raggiungimento di un limite di connessioni. In questi casi, restituire un Result indica che un errore è una possibilità prevista che il codice chiamante deve decidere come gestire.

Quando il codice esegue un’operazione che potrebbe mettere a rischio un utente se viene chiamata utilizzando valori non validi, il codice dovrebbe prima verificare che i valori siano validi e generare un errore di panic se i valori non sono validi. Questo avviene principalmente per motivi di sicurezza: tentare di operare su dati non validi può esporre il codice a vulnerabilità. Questo è il motivo principale per cui la libreria standard chiamerà panic! se si tenta un accesso alla memoria fuori dai limiti: tentare di accedere a memoria che non appartiene alla struttura dati corrente è un problema di sicurezza comune. Le funzioni spesso hanno dei contracts (contratti): il loro comportamento è garantito solo se gli input soddisfano determinati requisiti. Andare in panic quando il contratto viene violato ha senso perché una violazione del contratto indica sempre un bug lato chiamante, e non è un tipo di errore che si desidera che il codice chiamante debba gestire esplicitamente. In effetti, non esiste un modo ragionevole per il codice chiamante di recuperare; i programmatori che chiamano il codice devono correggerlo. I contratti per una funzione, soprattutto quando una violazione causerà un panic, dovrebbero essere spiegati nella documentazione API della funzione.

Tuttavia, avere molti controlli di errore in tutte le funzioni sarebbe prolisso e fastidioso. Fortunatamente, è possibile utilizzare il sistema dei type di Rust (e quindi il controllo dei type effettuato dal compilatore) per eseguire molti dei controlli al tuo posto. Se la tua funzione ha un type particolare come parametro, potete procedere con la logica del codice sapendo che il compilatore ha già verificato la presenza di un valore valido. Ad esempio, se avete un type anziché un’Option, il tuo programma si aspetta di avere qualcosa anziché niente. Il codice non dovrà quindi gestire due casi per le varianti Some e None: gestirà solo il caso che ha sicuramente un valore. Il codice che tenta di non passare nulla alla funzione non verrà nemmeno compilato, quindi la funzione non dovrà verificare quel caso in fase di esecuzione. Un altro esempio è l’utilizzo di un type integer senza segno come u32, che garantisce che il parametro non sia mai negativo.

Type Personalizzati per la Convalida

Sviluppiamo ulteriormente l’idea di utilizzare il sistema dei type di Rust per garantire un valore valido e proviamo a creare un type personalizzato per la convalida. Riprendiamo il gioco di indovinelli del Capitolo 2 in cui il nostro codice chiedeva all’utente di indovinare un numero compreso tra 1 e 100. Non abbiamo mai verificato che la risposta dell’utente fosse compresa tra quei numeri prima di confrontarla con il nostro numero segreto; abbiamo solo verificato che la risposta fosse positiva. In questo caso, le conseguenze non sono state poi così gravi: il nostro output “Troppo alto” o “Troppo basso” sarebbe stato comunque corretto. Ma sarebbe stato un utile miglioramento guidare l’utente verso risposte valide e avere un comportamento diverso quando l’utente ipotizza un numero fuori dall’intervallo rispetto a quando l’utente digita, ad esempio, delle lettere.

Un modo per farlo sarebbe analizzare l’ipotesi come i32 invece che solo come u32 per consentire numeri potenzialmente negativi, e quindi aggiungere un controllo per verificare che il numero sia compreso nell’intervallo, in questo modo:

File: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    loop {
        // --taglio--

        println!("Inserisci la tua ipotesi.");

        let mut ipotesi = String::new();

        io::stdin()
            .read_line(&mut ipotesi)
            .expect("Errore di lettura");

        let ipotesi: i32 = match ipotesi.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if ipotesi < 1 || ipotesi > 100 {
            println!("Il numero segreto è compreso tra 1 e 100.");
            continue;
        }

        match ipotesi.cmp(&numero_segreto) {
            // --taglio--
            Ordering::Less => println!("Troppo piccolo!"),
            Ordering::Greater => println!("Troppo grande!"),
            Ordering::Equal => {
                println!("Hai indovinato!");
                break;
            }
        }
    }
}

L’espressione if verifica se il nostro valore è fuori dall’intervallo, informa l’utente del problema e chiama continue per avviare l’iterazione successiva del ciclo e richiedere un’altra ipotesi. Dopo l’espressione if, possiamo procedere con i confronti tra ipotesi e il numero segreto, sapendo che ipotesi è compreso tra 1 e 100.

Tuttavia, questa non è una soluzione ideale: se fosse assolutamente fondamentale che il programma operasse solo su valori compresi tra 1 e 100, e avesse molte funzioni con questo requisito, avere un controllo di questo tipo in ogni funzione sarebbe ripetitivo (e potrebbe influire sulle prestazioni).

Invece, possiamo creare un nuovo type in un modulo dedicato e inserire le validazioni in una funzione per creare un’istanza del type, anziché ripetere le validazioni ovunque. In questo modo, le funzioni possono utilizzare il nuovo type nelle loro firme in tutta sicurezza e utilizzare con sicurezza i valori che ricevono. Il Listato 9-13 mostra un modo per definire un type Ipotesi che creerà un’istanza di Ipotesi solo se la funzione new riceve un valore compreso tra 1 e 100.

File: src/gioco_indovinello.rs
#![allow(unused)]
fn main() {
pub struct Ipotesi {
    valore: i32,
}

impl Ipotesi {
    pub fn new(valore: i32) -> Ipotesi {
        if valore < 1 || valore > 100 {
            panic!("L'ipotesi deve essere compresa tra 1 e 100, valore ottenuto: {valore}.");
        }

        Ipotesi { valore }
    }

    pub fn valore(&self) -> i32 {
        self.valore
    }
}
}
Listato 9-13: Un type Ipotesi che continuerà solo con valori compresi tra 1 e 100

Nota che questo codice in src/gioco_indovinello.rs dipende dall’aggiunta di una dichiarazione di modulo mod gioco_indovinello; in src/lib.rs che non abbiamo mostrato qui. All’interno del file di questo nuovo modulo, definiamo una struct denominata Ipotesi che ha un campo denominato valore di type i32. È qui che verrà memorizzato il numero.

Quindi implementiamo una funzione associata denominata new su Ipotesi che crea istanze di valori Ipotesi. La funzione new è definita per avere un parametro denominato valore di type i32 e restituire un Ipotesi. Il codice nel corpo della funzione new verifica valore per assicurarsi che sia compreso tra 1 e 100. Se valore non supera questo test, effettuiamo una chiamata panic!, che avviserà il programmatore che sta scrivendo il codice chiamante che ha un bug da correggere, perché creare un Ipotesi con un valore al di fuori di questo intervallo violerebbe il contratto su cui si basa Ipotesi::new. Le condizioni in cui Ipotesi::new potrebbe generare un errore di panic dovrebbero essere discusse nella documentazione dell’API pubblica; tratteremo le convenzioni di documentazione che indicano la possibilità di un errore panic! nella documentazione delle API che creerai nel Capitolo 14. Se valore supera il test, creiamo un nuovo Ipotesi con il suo campo valore impostato sul parametro valore e restituiamo Ipotesi.

Successivamente, implementiamo un metodo chiamato valore che prende in prestito (borrow) self, non ha altri parametri e restituisce un i32. Questo tipo di metodo è talvolta chiamato getter perché il suo scopo è ottenere alcuni dati dai suoi campi e restituirli. È necessario dichiarare questo metodo come public perché il campo valore della struttura Ipotesi è privato. È importante che il campo valore sia privato, in modo che il codice che utilizza la struttura Ipotesi non possa impostare valore direttamente: il codice esterno al modulo gioco_indovinello deve utilizzare la funzione Ipotesi::new per creare un’istanza di Ipotesi, garantendo così che Ipotesi non possa avere un valore che non sia stato verificato dalle condizioni della funzione Ipotesi::new.

Una funzione che ha un parametro o restituisce solo numeri compresi tra 1 e 100 potrebbe quindi dichiarare nella sua definizione che accetta o restituisce un Ipotesi anziché un i32 e non avrebbe bisogno di effettuare ulteriori controlli nel suo corpo.

Riepilogo

Le funzionalità di gestione degli errori di Rust sono progettate per aiutarti a scrivere codice più robusto. La macro panic! segnala che il programma si trova in uno stato che non può gestire e ti consente di dire al processo di interrompersi invece di provare a procedere con valori non validi o errati. L’enum Result utilizza il sistema dei type di Rust per indicare che le operazioni potrebbero fallire in un modo “recuperabile”. Puoi usare Result anche per indicare al codice chiamante che deve gestire potenziali successi o fallimenti. L’utilizzo di panic! e Result nelle situazioni appropriate renderà il tuo codice più affidabile di fronte a inevitabili problemi.

Ora che hai visto i modi utili in cui la libreria standard utilizza i type generici con le enum Option e Result, nel prossimo capitolo ne parleremo in maniera approfondita e di come puoi usarli nel tuo codice.

Type Generici, Trait e Lifetime

Ogni linguaggio di programmazione dispone di strumenti per gestire efficacemente la duplicazione di concetti. In Rust, uno di questi strumenti sono i type generici: sostituti astratti per type concreti o altre proprietà. Possiamo esprimere il comportamento dei type generici o come si relazionano ad altri type generici senza sapere cosa ci sarà al loro posto durante la compilazione e l’esecuzione del codice.

Le funzioni possono accettare parametri di un type generico, invece di un type concreto come i32 o String, allo stesso modo in cui accettano parametri con valori sconosciuti per eseguire lo stesso codice su più valori concreti. Infatti, abbiamo già utilizzato i generici nel Capitolo 6 con Option<T>, nel Capitolo 8 con Vec<T> e HashMap<K, V> e nel Capitolo 9 con Result<T, E>. In questo capitolo, esplorerai come definire i tuoi type, funzioni e metodi personalizzati con i generici!

Per prima cosa, esamineremo come estrarre una funzione per ridurre la duplicazione del codice. Utilizzeremo quindi la stessa tecnica per creare una funzione generica da due funzioni che differiscono solo per il type dei loro parametri. Spiegheremo anche come utilizzare i type generici nelle definizioni di struct ed enum.

Poi imparerai come utilizzare i trait per definire il comportamento in modo generico. Puoi combinare i trait con i type generici per vincolare un type generico ad accettare solo i type che hanno un comportamento particolare, anziché qualsiasi type.

Infine, parleremo della longevità (lifetime): una varietà di generici che fornisce al compilatore informazioni su come i reference si relazionano tra loro. I lifetime ci permettono di fornire al compilatore informazioni sufficienti sui valori presi in prestito in modo che possa garantire che i reference siano validi in più situazioni di quante ne potrebbe avere senza il nostro aiuto.

Rimuovere la Duplicazione Mediante l’Estrazione di una Funzione

I type generici ci permettono di sostituire type specifici con un segnaposto che rappresenta più type per evitare la duplicazione del codice. Prima di addentrarci nella sintassi dei type generici, vediamo come rimuovere le ripetizioni in un modo che non coinvolga type generici, estraendo una funzione che sostituisce valori specifici con un segnaposto che rappresenta più valori. Poi applicheremo la stessa tecnica per estrarre una funzione generica! Analizzando come riconoscere il codice duplicato che è possibile estrarre in una funzione, comincerai a riconoscere il codice duplicato che può utilizzare i generici.

Inizieremo con il breve programma nel Listato 10-1 che trova il numero più grande in un elenco.

File: src/main.rs
fn main() {
    let lista_numeri = vec![34, 50, 25, 100, 65];

    let mut maggiore = &lista_numeri[0];

    for numero in &lista_numeri {
        if numero > maggiore {
            maggiore = numero;
        }
    }

    println!("Il numero maggiore è {maggiore}");
    assert_eq!(*maggiore, 100);
}
Listato 10-1: Trovare il numero più grande in un elenco di numeri

Memorizziamo un elenco di numeri interi nella variabile lista_numeri e inseriamo un reference al primo numero dell’elenco in una variabile denominata maggiore. Quindi eseguiamo un’iterazione su tutti i numeri dell’elenco e, se il numero corrente è più grande del numero memorizzato in maggiore, sostituiamo il reference in quella variabile. Tuttavia, se il numero corrente è minore o uguale al numero più grande visto finora, la variabile non cambia e il codice passa al numero successivo nell’elenco. Dopo aver considerato tutti i numeri nell’elenco, maggiore dovrebbe riferirsi al numero più grande, che in questo caso è 100.

Ora ci è stato chiesto di trovare il numero più grande in due diversi elenchi di numeri. Per farlo, possiamo scegliere di duplicare il codice nel Listato 10-1 e utilizzare la stessa logica in due punti diversi del programma, come mostrato nel Listato 10-2.

File: src/main.rs
fn main() {
    let lista_numeri = vec![34, 50, 25, 100, 65];

    let mut maggiore = &lista_numeri[0];

    for numero in &lista_numeri {
        if numero > maggiore {
            maggiore = numero;
        }
    }

    println!("Il numero maggiore è {maggiore}");

    let lista_numeri = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut maggiore = &lista_numeri[0];

    for numero in &lista_numeri {
        if numero > maggiore {
            maggiore = numero;
        }
    }

    println!("Il numero maggiore è {maggiore}");
}
Listato 10-2: Codice per trovare il numero più grande in due elenchi di numeri

Sebbene questo codice funzioni, duplicarlo è noioso e soggetto a errori. Dobbiamo anche ricordarci di aggiornare il codice in più punti quando vogliamo modificarlo.

Per eliminare questa duplicazione, creeremo un’astrazione definendo una funzione che opera su qualsiasi elenco di integer passati come parametro. Questa soluzione rende il nostro codice più chiaro e ci permette di esprimere il concetto di ricerca del numero più grande in un elenco in modo astratto.

Nel Listato 10-3, estraiamo il codice che trova il numero più grande in una funzione denominata maggiore. Quindi chiamiamo la funzione per trovare il numero più grande nelle due liste del Listato 10-2. Potremmo anche utilizzare la funzione su qualsiasi altro elenco di valori i32 che potremmo avere in futuro.

File: src/main.rs
fn maggiore(lista: &[i32]) -> &i32 {
    let mut maggiore = &list[0];

    for elemento in lista {
        if elemento > maggiore {
            maggiore = elemento;
        }
    }

    maggiore
}

fn main() {
    let lista_numeri = vec![34, 50, 25, 100, 65];

    let risultato = maggiore(&lista_numeri);
    println!("Il numero maggiore è {risultato}");
    assert_eq!(*risultato, 100);

    let lista_numeri = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let risultato = maggiore(&lista_numeri);
    println!("Il numero maggiore è {risultato}");
    assert_eq!(*risultato, 6000);
}
Listato 10-3: Codice astratto per trovare il numero più grande in due liste

La funzione maggiore ha un parametro chiamato lista, che rappresenta qualsiasi slice concreta di valori i32 che potremmo passare alla funzione. Di conseguenza, quando chiamiamo la funzione, il codice viene eseguito sui valori specifici che passiamo.

In sintesi, ecco i passaggi che abbiamo seguito per modificare il codice dal Listato 10-2 al Listato 10-3

  1. Identificare il codice duplicato.
  2. Estrarre il codice duplicato nel corpo della funzione e specificare gli input e i valori restituiti da tale codice nella firma della funzione.
  3. Aggiornare le due istanze di codice duplicato per chiamare la funzione.

Successivamente, utilizzeremo gli stessi passaggi con i type generici per ridurre la duplicazione del codice. Allo stesso modo in cui il corpo della funzione può operare su una lista astratta anziché su valori specifici, i type generici consentono al codice di operare su type astratti.

Ad esempio, supponiamo di avere due funzioni: una che trova l’elemento più grande in un insieme di valori i32 e una che trova l’elemento più grande in un insieme di valori char. Come elimineremmo questa duplicazione? Scopriamolo!

Tipi di Dati Generici

Utilizziamo i type generici per creare definizioni per elementi come firme di funzioni o struct, che possiamo poi utilizzare con molti tip di dati concreti diversi. Vediamo prima come definire funzioni, struct, enum e metodi utilizzando i type generici. Poi discuteremo di come i generici influiscono sulle prestazioni del codice.

Nella Definizione delle Funzioni

Quando definiamo una funzione che utilizza i type generici, li inseriamo nella firma della funzione, dove normalmente specificheremmo i type dei parametri e il type del valore restituito. In questo modo il nostro codice diventa più flessibile e fornisce maggiori funzionalità ai chiamanti della nostra funzione, evitando al contempo la duplicazione del codice.

Continuando con la nostra funzione maggiore, il Listato 10-4 mostra due funzioni che trovano entrambe il valore più grande in una slice. Le combineremo quindi in un’unica funzione che utilizza i type generici.

File: src/main.rs
fn maggior_i32(lista: &[i32]) -> &i32 {
    let mut maggiore = &lista[0];

    for elemento in lista {
        if elemento > maggiore {
            maggiore = elemento;
        }
    }

    maggiore
}

fn maggior_char(lista: &[char]) -> &char {
    let mut maggiore = &lista[0];

    for elemento in lista {
        if elemento > maggiore {
            maggiore = elemento;
        }
    }

    maggiore 
}

fn main() {
    let lista_numeri = vec![34, 50, 25, 100, 65];

    let risultato = maggior_i32(&lista_numeri);
    println!("Il numero maggiore è  {risultato}");
    assert_eq!(*risultato, 100);

    let lista_caratteri = vec!['y', 'm', 'a', 'q'];

    let risultato = maggior_char(&lista_caratteri);
    println!("Il carattere maggiore è  {risultato}");
    assert_eq!(*risultato, 'y');
}
    
Listato 10-4: Due funzioni che differiscono solo per i nomi e per i type nelle loro firme

La funzione maggior_i32 è quella che abbiamo estratto nel Listato 10-3 e che trova l’i32 più grande in una slice. La funzione maggior_char trova il char più grande in una slice. I corpi delle funzioni hanno lo stesso codice, quindi eliminiamo la duplicazione introducendo un parametro di type generico in una singola funzione.

Per parametrizzare i type in una nuova singola funzione, dobbiamo assegnare un nome al parametro di type, proprio come facciamo per i parametri di valore di una funzione. È possibile utilizzare qualsiasi identificatore come nome di parametro di type. Ma useremo T perché, per convenzione, i nomi dei parametri di type in Rust sono brevi, spesso di una sola lettera, e la convenzione di denominazione dei type di Rust è CamelCase1 (nello specifico UpperCamelCase). Abbreviazione di type, T è la scelta predefinita della maggior parte dei programmatori Rust.

Quando utilizziamo un parametro nel corpo della funzione, dobbiamo dichiarare il nome del parametro nella firma in modo che il compilatore ne conosca il significato. Allo stesso modo, quando usiamo un type come parametro nella firma di una funzione, dobbiamo dichiarare il nome del type prima di utilizzarlo. Per definire la funzione generica maggiore, inseriamo le dichiarazioni del nome del type tra parentesi angolari, <>, tra il nome della funzione e l’elenco dei parametri, in questo modo:

fn maggiore<T>(lista: &[T]) -> &T {

Leggiamo questa definizione come “La funzione maggiore è generica su un certo type T”. Questa funzione ha un parametro denominato lista, che è una slice di valori di type T. La funzione maggiore restituirà un reference a un valore dello stesso type T.

Il Listato 10-5 mostra la definizione combinata della funzione maggiore utilizzando il type di dati generico nella sua firma. Il Listato mostra anche come possiamo chiamare la funzione con una slice di valori i32 o char. Nota che questo codice non verrà ancora compilato.

File: src/main.rs
fn maggiore<T>(lista: &[T]) -> &T {
    let mut maggiore = &lista[0];

    for elemento in lista {
        if elemento > maggiore {
            maggiore = elemento;
        }
    }

    maggiore
}

fn main() {
    let lista_numeri = vec![34, 50, 25, 100, 65];

    let risultato = maggiore(&lista_numeri);
    println!("Il numero maggiore è {risultato}");

    let lista_caratteri = vec!['y', 'm', 'a', 'q'];

    let risultato = maggiore(&lista_caratteri);
    println!("Il carattere maggiore è {risultato}");
}
Listato 10-5: La funzione maggiore che utilizza parametri di type generico; non è ancora compilabile

Se compiliamo questo codice adesso, otterremo questo errore:

$ cargo run
   Compiling capitolo10 v0.1.0 (file:///progetti/capitolo10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:21
  |
5 |         if elemento > maggiore {
  |            -------- ^ -------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn maggiore<T: std::cmp::PartialOrd>(lista: &[T]) -> &T {
  |              ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error

Il testo di aiuto menziona std::cmp::PartialOrd, che è un trait, e parleremo dei trait nella prossima sezione. Per ora, sappi che questo errore indica che il corpo di maggiore non funzionerà per tutti i possibili type di T. Poiché vogliamo confrontare valori di type T nel corpo, possiamo utilizzare solo type i cui valori possono essere ordinati. Per abilitare i confronti, la libreria standard include il trait std::cmp::PartialOrd che è possibile implementare sui type (vedere l’Appendice C per maggiori informazioni su questo trait). Per correggere il Listato 10-5, possiamo seguire il suggerimento del testo di aiuto e limitare i type validi per T solo a quelli che implementano PartialOrd. Il Listato verrà quindi compilato, poiché la libreria standard implementa PartialOrd sia su i32 che su char.

Nella Definizione delle Struct

Possiamo anche definire struct per utilizzare un parametro di type generico in uno o più campi utilizzando la sintassi <>. Il Listato 10-6 definisce una struct Punto<T> per contenere i valori delle coordinate x e y di qualsiasi type.

File: src/main.rs
struct Punto<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}
Listato 10-6: Una struct Punto<T> che contiene i valori x e y di type T

La sintassi per l’utilizzo di type generici nelle definizioni di struct è simile a quella utilizzata nelle definizioni di funzione. Per prima cosa dichiariamo il nome del type tra parentesi angolari subito dopo il nome della struct. Quindi utilizziamo il type generico nella definizione della struct, dove altrimenti specificheremmo type di dati concreti.

Nota che, poiché abbiamo utilizzato un solo type generico per definire Punto<T>, questa definizione afferma che la struct Punto<T> è generica su un type T e che i campi x e y sono entrambi dello stesso type, qualunque esso sia. Se creiamo un’istanza di Punto<T> che ha valori di type diversi, come nel Listato 10-7, il nostro codice non verrà compilato.

File: src/main.rs
struct Punto<T> {
    x: T,
    y: T,
}

fn main() {
    let non_funzionante = Punto { x: 5, y: 4.0 };
}
Listato 10-7: I campi x e y devono essere dello stesso type perché entrambi hanno lo stesso type di dati generico T

In questo esempio, quando assegniamo il valore integer 5 a x, comunichiamo al compilatore che il type generico T sarà un integer per questa istanza di Punto<T>. Quindi, quando specifichiamo 4.0 per y, che abbiamo definito come dello stesso type di x, otterremo un errore di mancata corrispondenza di type come questo:

$ cargo run
   Compiling capitolo10 v0.1.0 (file:///progetti/capitolo10)
error[E0308]: mismatched types
 --> src/main.rs:7:44
  |
7 |     let non_funzionante = Punto { x: 5, y: 4.0 };
  |                                            ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error

Per definire una struct Punto in cui x e y sono entrambi type generici ma potrebbero avere type diversi, possiamo utilizzare più parametri di type generico. Ad esempio, nel Listato 10-8, modifichiamo la definizione di Punto in modo che sia generico sui type T e U, dove x è di type T e y è di type U.

File: src/main.rs
struct Punto<T, U> {
    x: T,
    y: U,
}

fn main() {
    let entrambi_interi = Punto { x: 5, y: 10 };
    let entrambi_float = Punto { x: 1.0, y: 4.0 };
    let intero_e_float = Punto { x: 5, y: 4.0 };
}
Listato 10-8: Un Punto<T, U> generico su due type in modo che x e y possano essere valori di type diversi

Ora tutte le istanze di Punto mostrate sono consentite! Puoi usare tutti i parametri di type generico che vuoi in una definizione, ma usarne di più rende il codice difficile da leggere. Se ti accorgi di aver bisogno di molti type generici nel tuo codice, potrebbe essere necessario riscriverlo in parti più piccole.

Nella Definizione delle Enum

Come abbiamo fatto con le struct, possiamo definire le enum per contenere type di dati generici nelle loro varianti. Diamo un’altra occhiata all’enum Option<T> fornito dalla libreria standard, che abbiamo usato nel Capitolo 6:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Questa definizione dovrebbe ora esserti più chiara. Come puoi vedere, l’enum Option<T> è generico sul type T e ha due varianti: Some, che contiene un valore di type T, e una variante None che non contiene alcun valore. Utilizzando l’enum Option<T>, possiamo esprimere il concetto astratto di un valore opzionale e, poiché Option<T> è generico, possiamo usare questa astrazione indipendentemente dal type del valore opzionale.

Anche le enum possono usare più type generici. La definizione dell’enum Result che abbiamo usato nel Capitolo 9 ne è un esempio:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

L’enum Result è generico su due type, T ed E, e ha due varianti: Ok, che contiene un valore di type T, e Err, che contiene un valore di type E. Questa definizione rende comodo usare l’enum Result ovunque abbiamo un’operazione che potrebbe avere successo (restituire un valore di type T) o fallire (restituire un errore di type E). In effetti, questo è ciò che abbiamo usato per aprire un file nel Listato 9-3, dove T veniva riempito con il type std::fs::File quando il file veniva aperto correttamente ed E veniva riempito con il type std::io::Error quando si verificavano problemi durante l’apertura del file.

Quando trovi situazioni nel codice con più definizioni di struct o enum che differiscono solo per il type dei valori che contengono, è possibile evitare la duplicazione utilizzando invece type generici.

Nella Definizione dei Metodi

Possiamo implementare metodi su struct ed enum (come abbiamo fatto nel Capitolo 5) e utilizzare type generici anche nella loro definizione. Il Listato 10-9 mostra la struct Punto<T> definita nel Listato 10-6 con un metodo denominato x implementato su di essa.

File: src/main.rs
struct Punto<T> {
    x: T,
    y: T,
}

impl<T> Punto<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Punto { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listato 10-9: Implementazione di un metodo denominato x sulla struct Punto<T> che restituirà un reference al campo x di type T

Qui, abbiamo definito un metodo denominato x su Punto<T> che restituisce un reference ai dati nel campo x.

Nota che dobbiamo dichiarare T subito dopo impl in modo da poter usare T per specificare che stiamo implementando metodi sul type Punto<T>. Dichiarando T come type generico dopo impl, Rust può identificare che il type tra parentesi angolari in Punto è un type generico piuttosto che un type concreto. Avremmo potuto scegliere un nome diverso per questo parametro generico rispetto al parametro generico dichiarato nella definizione della struct, ma utilizzare lo stesso nome è convenzionale. Se si scrive un metodo all’interno di un impl che dichiara un type generico, tale metodo verrà definito su qualsiasi istanza del type, indipendentemente dal type concreto che finisce per sostituire il type generico.

Possiamo anche specificare vincoli sui type generici quando si definiscono metodi sul type. Ad esempio, potremmo implementare metodi solo su istanze di Punto<f32> piuttosto che su istanze di Punto<T> con qualsiasi type generico. Nel Listato 10-10 utilizziamo il type concreto f32, il che significa che non dichiariamo alcun type dopo impl.

File: src/main.rs
struct Punto<T> {
    x: T,
    y: T,
}

impl<T> Punto<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Punto<f32> {
    fn distanza_da_origine(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Punto{ x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listato 10-10: Un blocco impl che si applica solo a una struct con un particolare type concreto per il parametro di type generico T

Questo codice indica che il type Punto<f32> avrà un metodo distanza_da_origine; altre istanze di Punto<T> in cui T non è di type f32 non avranno questo metodo definito. Il metodo misura la distanza del nostro punto dal punto alle coordinate (0.0, 0.0) e utilizza operazioni matematiche disponibili solo per i type a virgola mobile.

I parametri di type generico nella definizione di una struct non sono sempre gli stessi di quelli utilizzati nelle firme dei metodi della stessa struct. Il Listato 10-11 utilizza i type generici X1 e Y1 per la struct Punto e X2 e Y2 per la firma del metodo misto per rendere l’esempio più chiaro. Il metodo crea una nuova istanza di Punto con il valore x dal self Punto (di type X1) e il valore y dal Punto passato come argomento (di type Y2).

File: src/main.rs
struct Punto<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Punto<X1, Y1> {
    fn misto<X2, Y2>(self, altro: Punto<X2, Y2>) -> Punto<X1, Y2> {
        Punto {
            x: self.x,
            y: altro.y,
        }
    }
}

fn main() {
    let p1 = Punto { x: 5, y: 10.4 };
    let p2 = Punto { x: "Ciao", y: 'c' };

    let p3 = p1.misto(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Listato 10-11: Un metodo che utilizza type generici che sono diversi dalla definizione della sua struct

In main, abbiamo definito un Punto che ha un i32 per x (con valore 5) e un f64 per y (con valore 10.4). La variabile p2 è una struct Punto che ha una slice di stringa per x (con valore "Ciao") e un char per y (con valore c). Chiamando misto su p1 con l’argomento p2 otteniamo p3, che avrà un i32 per x perché x proviene da p1. La variabile p3 avrà un char per y perché y proviene da p2. La chiamata alla macro println! stamperà p3.x = 5, p3.y = c.

Lo scopo di questo esempio è dimostrare una situazione in cui alcuni parametri generici sono dichiarati con impl e altri con la definizione del metodo. Qui, i parametri generici X1 e Y1 sono dichiarati dopo impl perché vanno con la definizione della struct. I parametri generici X2 e Y2 sono dichiarati dopo fn misto perché sono rilevanti solo per il metodo.

Prestazioni del Codice Utilizzando Type Generici

Potresti chiederti se l’utilizzo di parametri di type generico costi in termini prestazionali durante l’esecuzione del codice. La buona notizia è che l’utilizzo di type generici non renderà il tuo programma più lento di quanto lo sarebbe con type concreti.

Rust ottiene questo risultato eseguendo la monomorfizzazione del codice utilizzando i generici in fase di compilazione. La monomorfizzazione è il processo di trasformazione del codice generico in codice specifico inserendo i type concreti utilizzati in fase di compilazione. In questo processo, il compilatore esegue l’opposto dei passaggi che abbiamo utilizzato per creare la funzione generica nel Listato 10-5: il compilatore esamina tutti i punti in cui viene chiamato il codice generico e genera codice per i type concreti con cui viene chiamato il codice generico.

Vediamo come funziona utilizzando l’enum generico Option<T> della libreria standard:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Quando Rust compila questo codice, esegue la monomorfizzazione. Durante questo processo, il compilatore legge i valori utilizzati nelle istanze di Option<T> e identifica due type di Option<T>: uno è i32 e l’altro è f64. Pertanto, espande la definizione generica di Option<T> in due definizioni specializzate per i32 e f64, sostituendo così la definizione generica con quelle specifiche.

La versione monomorfizzata del codice è simile alla seguente (il compilatore usa nomi diversi da quelli che stiamo usando qui a scopo illustrativo):

File: src/main.rs
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

Il generico Option<T> viene sostituito con le definizioni specifiche create dal compilatore. Poiché Rust compila il codice generico in codice che specifica il type in ogni istanza, non si paga alcun costo prestazionale durante l’esecuzione per l’utilizzo di type generici. Quando il codice viene eseguito, si comporta esattamente come se avessimo duplicato ogni definizione manualmente. Il processo di monomorfizzazione rende i generici di Rust estremamente efficienti in fase di esecuzione.


  1. Wiki CamelCase

Definire il Comportamento Condiviso con i Trait

Un tratto (trait) definisce la funzionalità che un particolare type ha e può condividere con altri type. Possiamo usare i trait per definire il comportamento condiviso in modo astratto. Possiamo usare i vincoli del tratto (trait bound) per specificare che un type generico può essere qualsiasi type che abbia un determinato comportamento.

Nota: i trait sono simili a una funzionalità spesso chiamata interfacce (interfaces) in altri linguaggi, sebbene con alcune differenze.

Definire un Trait

Il comportamento di un type consiste nei metodi che possiamo chiamare su quel type. Type diversi condividono lo stesso comportamento se possiamo chiamare gli stessi metodi su tutti quei type. Le definizioni dei trait sono un modo per raggruppare le firme dei metodi per definire un insieme di comportamenti necessari per raggiungere un determinato scopo.

Ad esempio, supponiamo di avere più struct che contengono vari tipi e quantità di testo: una struttura Articolo che contiene una notizia archiviata in una posizione specifica e una PostSocial che può contenere, al massimo, 280 caratteri insieme a metadati che indicano se si tratta di un nuovo post, una ripubblicazione o una risposta a un altro post.

Vogliamo creare una libreria di aggregazione multimediale denominata aggregatore in grado di visualizzare riepiloghi dei dati che potrebbero essere memorizzati in un’istanza di Articolo o PostSocial. Per fare ciò, abbiamo bisogno di un riepilogo per ciascun type e richiederemo tale riepilogo chiamando un metodo riassunto su un’istanza. Il Listato 10-12 mostra la definizione di un trait pubblico Sommario che esprime questo comportamento.

File: src/lib.rs
pub trait Sommario {
    fn riassunto(&self) -> String;
}
Listato 10-12: Un trait Sommario che consiste nel comportamento fornito da un metodo riassunto

Qui, dichiariamo un trait usando la parola chiave trait e poi il nome del trait, che in questo caso è Sommario. Dichiariamo anche il trait come pub in modo che anche i crate che dipendono da questo crate possano utilizzare questo trait, come vedremo in alcuni esempi. All’interno delle parentesi graffe, dichiariamo le firme dei metodi che descrivono i comportamenti dei type che implementano questo trait, che in questo caso è fn riassunto(&self) -> String.

Dopo la firma del metodo, invece di fornire un’implementazione tra parentesi graffe, utilizziamo un punto e virgola. Ogni type che implementa questo trait deve fornire il proprio comportamento personalizzato per il corpo del metodo. Il compilatore imporrà che qualsiasi type che abbia il trait Sommario abbia il metodo riassunto definito esattamente con questa firma.

Una trait può avere più metodi nel suo corpo: le firme dei metodi sono elencate una per riga e ogni riga termina con un punto e virgola.

Implementare un Trait su un Type

Ora che abbiamo definito le firme desiderate dei metodi del trait Sommario, possiamo implementarlo sui type nel nostro aggregatore multimediale. Il Listato 10-13 mostra un’implementazione del trait Sommario sulla struct Articolo che utilizza il titolo, l’autore e la posizione per creare il valore di ritorno di riassunto. Per la struct PostSocial, definiamo riassunto come il nome utente seguito dall’intero testo del post, supponendo che il contenuto del post sia già limitato a 280 caratteri.

File: src/lib.rs
pub trait Sommario {
    fn riassunto(&self) -> String;
}

pub struct Articolo {
    pub titolo: String,
    pub posizione: String,
    pub autore: String,
    pub contenuto: String,
}

impl Sommario for Articolo {
    fn riassunto(&self) -> String {
        format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
    }
}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto(&self) -> String {
        format!("{}: {}", self.nomeutente, self.contenuto)
    }
}
Listato 10-13: Implementazione del trait Sommario sui type Articolo e PostSocial

Implementare un trait su un type è simile a come normalmente sono implementati i metodi. La differenza è che dopo impl, inseriamo il nome del trait che vogliamo implementare, poi utilizziamo la parola chiave for e infine specifichiamo il nome del type per cui vogliamo implementare il trait. All’interno del blocco impl, inseriamo le firme dei metodi definite dalla definizione del trait. Invece di aggiungere un punto e virgola dopo ogni firma, utilizziamo le parentesi graffe e riempiamo il corpo del metodo con il comportamento specifico che vogliamo che i metodi del trait abbiano per quel particolare type.

Ora che la libreria ha implementato il trait Sommario su Articolo e PostSocial, gli utenti del crate possono chiamare i metodi del trait sulle istanze di Articolo e PostSocial nello stesso modo in cui chiamiamo i metodi normali. L’unica differenza è che l’utente deve includere il trait nello scope oltre ai type. Ecco un esempio di come un crate binario potrebbe utilizzare il nostro crate libreria aggregatore:

use aggregatore::{PostSocial, Sommario};

fn main() {
    let post = PostSocial {
        nomeutente: String::from("horse_ebooks"),
        contenuto: String::from(
            "ovviamente, come probabilmente già sapete, gente",
        ),
        risposta: false,
        repost: false,
    };

    println!("1 nuovo post: {}", post.riassunto());
}

Questo codice stampa 1 nuovo post: horse_ebooks: ovviamente, come probabilmente già sapete, gente.

Anche altri crate che dipendono dal crate aggregatore possono includere il trait Sommario nello scope per implementare Sommario sui propri type. Una restrizione da notare è che possiamo implementare un trait su un type solo se il trait o il type, o entrambi, sono locali al nostro crate. Ad esempio, possiamo implementare trait della libreria standard come Display su un type personalizzato come PostSocial come parte della funzionalità del nostro crate aggregatore, perché il type PostSocial è locale al nostro crate aggregatore. Possiamo anche implementare Sommario su Vec<T> nel nostro crate aggregatore, perché il trait Sommario è locale al nostro crate aggregatore.

Ma non possiamo implementare trait esterni su type esterni. Ad esempio, non possiamo implementare il trait Display su Vec<T> all’interno del nostro crate aggregatore perché Display e Vec<T> sono entrambi definiti nella libreria standard e non sono locali al nostro crate aggregatore. Questa restrizione fa parte di una proprietà chiamata coerenza (coherence), e più specificamente della regola dell’orfano (orphan rule), così chiamata perché il type genitore non è presente. Questa regola garantisce che il codice di altri non possa rompere il tuo codice e viceversa. Senza questa regola, due crate potrebbero implementare lo stesso trait per lo stesso type e Rust non saprebbe quale implementazione utilizzare.

Usare le Implementazioni Predefinite

A volte è utile avere un comportamento predefinito per alcuni o tutti i metodi in un trait invece di richiedere implementazioni per tutti i metodi su ogni type. Quindi, quando implementiamo il trait su un type particolare, possiamo mantenere o sovrascrivere il comportamento predefinito di ciascun metodo.

Nel Listato 10-14, specifichiamo una stringa predefinita per il metodo riassunto del trait Sommario invece di definire solo la firma del metodo, come abbiamo fatto nel Listato 10-12.

File: src/lib.rs
pub trait Sommario {
    fn riassunto(&self) -> String {
        String::from("(Leggi di più...)")
    }
}

pub struct Articolo {
    pub titolo: String,
    pub posizione: String,
    pub autore: String,
    pub contenuto: String,
}

impl Sommario for Articolo {}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto(&self) -> String {
        format!("{}: {}", self.nomeutente, self.contenuto)
    }
}
Listato 10-14: Definizione di un trait Sommario con un’implementazione predefinita del metodo riassunto

Per utilizzare un’implementazione predefinita per riassumere le istanze di Articolo, specifichiamo un blocco impl vuoto con impl Sommario for Articolo {}.

Anche se non definiamo più il metodo riassunto su Articolo direttamente, abbiamo fornito un’implementazione predefinita e specificato che Articolo implementa il trait Sommario. Di conseguenza, possiamo comunque chiamare il metodo riassunto su un’istanza di Articolo, in questo modo:


fn main() {
    let articolo = Articolo {
        titolo: String::from("I Penguins vincono la Stanley Cup!"),
        posizione: String::from("Pittsburgh, PA, USA"),
        autore: String::from("Iceburgh"),
        contenuto: String::from(
            "I Pittsburgh Penguins sono ancora una volta\
             la migliore squadra di hockey nella NHL.",
        ),
    };

    println!("Nuovo articolo disponibile! {}", articolo.riassunto());
}

Questo codice stampa Nuovo articolo disponibile! (Leggi di più...).

La creazione di un’implementazione predefinita non richiede alcuna modifica all’implementazione di Sommario su PostSocial nel Listato 10-13. Il motivo è che la sintassi per sovrascrivere un’implementazione predefinita è la stessa della sintassi per implementare un metodo di un trait che non ha un’implementazione predefinita.

Le implementazioni predefinite possono chiamare altri metodi nello stesso trait, anche se questi non hanno un’implementazione predefinita. In questo modo, un trait può fornire molte funzionalità utili e richiedere agli implementatori di specificarne solo una piccola parte. Ad esempio, potremmo definire il trait Sommario in modo che abbia un metodo riassunto_autore la cui implementazione è richiesta, e quindi definire un metodo riassunto con un’implementazione predefinita che chiama il metodo riassunto_autore:

pub trait Sommario {
    fn riassunto_autore(&self) -> String;

    fn riassunto(&self) -> String {
        format!("(Leggi di più da {}...)", self.riassunto_autore())
    }
}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto_autore(&self) -> String {
        format!("@{}", self.nomeutente)
    }
}

Per utilizzare questa versione di Sommario, dobbiamo definire riassunto_autore solo quando implementiamo il trait su un type:

pub trait Sommario {
    fn riassunto_autore(&self) -> String;

    fn riassunto(&self) -> String {
        format!("(Leggi di più da {}...)", self.riassunto_autore())
    }
}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto_autore(&self) -> String {
        format!("@{}", self.nomeutente)
    }
}

Dopo aver definito riassunto_autore, possiamo chiamare riassunto sulle istanze della struct PostSocial e l’implementazione predefinita di riassunto chiamerà la definizione di riassunto_autore che abbiamo fornito. Poiché abbiamo implementato riassunto_autore, il trait Sommario ci ha fornito il comportamento del metodo riassunto senza richiedere ulteriore codice. Ecco come appare:

use aggregatore::{self, PostSocial, Sommario};

fn main() {
    let post = PostSocial {
        nomeutente: String::from("horse_ebooks"),
        contenuto: String::from(
            "ovviamente, come probabilmente già sapete, gente",
        ),
        risposta: false,
        repost: false,
    };

    println!("1 nuovo post: {}", post.riassunto());
}

Questo codice stampa 1 nuovo post: (Leggi di più su @horse_ebooks...).

Nota che non è possibile chiamare l’implementazione predefinita da una implementazione sovrascritta dello stesso metodo.

Usare i Trait come Parametri

Ora che sai come definire e implementare i trait, possiamo esplorare come usarli per definire funzioni che accettano molti type diversi. Useremo il trait Sommario che abbiamo implementato sui type Articolo e PostSocial nel Listato 10-13 per definire una funzione notifica che chiama il metodo riassunto sul suo parametro elemento, che è di un type che implementa il trait Sommario. Per fare ciò, utilizziamo la sintassi impl Trait, in questo modo:

pub trait Sommario {
    fn riassunto(&self) -> String;
}

pub struct Articolo {
    pub titolo: String,
    pub posizione: String,
    pub autore: String,
    pub contenuto: String,
}

impl Sommario for Articolo {
    fn riassunto(&self) -> String {
        format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
    }
}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto(&self) -> String {
        format!("{}: {}", self.nomeutente, self.contenuto)
    }
}

pub fn notifica(elemento: &impl Sommario) {
    println!("Ultime notizie! {}", elemento.riassunto());
}

Invece di un type concreto per il parametro elemento, specifichiamo la parola chiave impl e il nome del trait. Questo parametro accetta qualsiasi type che implementi il trait specificato. Nel corpo di notifica, possiamo chiamare qualsiasi metodo su elemento che provenga dal trait Sommario, come riassunto. Possiamo chiamare notifica e passare qualsiasi istanza di Articolo o PostSocial. Il codice che chiama la funzione con qualsiasi altro type, come String o i32, non verrà compilato perché questi type non implementano Sommario.

Sintassi del Vincolo di Trait

La sintassi impl Trait funziona per i casi più semplici, ma in realtà è solo una sintassi semplificata di una forma più lunga nota come vincolo del tratto (trait bound); si presenta così:

pub fn notifica<T: Sommario>(elemento: &T) {
    println!("Ultime notizie! {}", elemento.riassunto());
}

Questa forma più lunga è equivalente all’esempio della sezione precedente, ma è più dettagliata. Posizioniamo il vincolo di trait con la dichiarazione del parametro di type generico dopo i due punti e tra parentesi angolari.

La sintassi impl Trait è comoda e consente di scrivere codice più conciso nei casi semplici, mentre la sintassi più completa del vincolo di trait può esprimere una maggiore complessità in altri casi. Ad esempio, possiamo avere due parametri che implementano Sommario. Con la sintassi impl Trait, ciò si ottiene in questo modo:

pub fn notifica(elemento1: &impl Sommario, elemento2: &impl Sommario) {

L’utilizzo di impl Trait è appropriato se vogliamo che questa funzione consenta a elemento1 e elemento2 di avere type diversi (purché entrambi i type implementino Sommario). Tuttavia, se vogliamo forzare entrambi i parametri ad avere lo stesso type, dobbiamo usare un vincolo di trait, in questo modo:

pub fn notifica<T: Sommario>(elemento1: &T, elemento2: &T) {

Il type generico T specificato come type dei parametri elemento1 e elemento2 vincola la funzione in modo che il type concreto del valore passato come argomento per elemento1 e elemento2 debba essere lo stesso.

Specificare più Vincoli di Trait con la Sintassi +

Possiamo anche specificare più di un vincolo di trait. Supponiamo di voler che notifica usi sia la formattazione di visualizzazione, fornita dal trait Display, sia che usi riassunto su elemento: specifichiamo nella definizione di notifica che elemento deve implementare sia Display che Sommario. Possiamo farlo utilizzando la sintassi +:

pub fn notifica(elemento: &(impl Sommario + Display)) {

La sintassi + è valida anche con i vincoli di trait sui type generici:

pub fn notifica<T: Sommario + Display>(elemento: &T) {

Con i due vincoli di trait specificati, il corpo di notifica può chiamare riassunto e utilizzare {} per formattare elemento.

Specificare i Vincoli di Trait con le Clausole where

L’utilizzo di troppi vincoli di trait ha i suoi svantaggi. Ogni generico ha i suoi vincoli di trait, quindi le funzioni con più parametri di type generico possono contenere molte informazioni sui vincoli di trait tra il nome della funzione e il suo elenco di parametri, rendendo la firma della funzione difficile da leggere. Per questo motivo, Rust ha una sintassi alternativa per specificare i vincoli di trait all’interno di una clausola where dopo la firma della funzione. Quindi, invece di scrivere:

fn una_funzione<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

possiamo usare una clausola where, in questo modo:

fn una_funzione<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

La firma di questa funzione è meno confusionaria: il nome della funzione, l’elenco dei parametri e il type di ritorno sono vicini, come in una funzione senza molti vincoli di trait.

Restituire Type che Implementano Trait

Possiamo anche usare la sintassi impl Trait nella posizione di ritorno per restituire un valore di un type che implementa un trait, come mostrato qui:

pub trait Sommario {
    fn riassunto(&self) -> String;
}

pub struct Articolo {
    pub titolo: String,
    pub posizione: String,
    pub autore: String,
    pub contenuto: String,
}

impl Sommario for Articolo {
    fn riassunto(&self) -> String {
        format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
    }
}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto(&self) -> String {
        format!("{}: {}", self.nomeutente, self.contenuto)
    }
}

fn riassumibile() -> impl Sommario {
    PostSocial {
        nomeutente: String::from("horse_ebooks"),
        contenuto: String::from(
            "ovviamente, come probabilmente già sapete, gente",
        ),
        risposta: false,
        repost: false,
    }
}

Utilizzando impl Sommario come type di ritorno, specifichiamo che la funzione riassumibile restituisce un type che implementa il trait Sommario senza nominare il type concreto. In questo caso, riassumibile restituisce un PostSocial, ma il codice che chiama questa funzione non ha bisogno di saperlo.

La possibilità di specificare un type di ritorno solo tramite il trait che implementa è particolarmente utile nel contesto di chiusure (closure) e iteratori, che tratteremo nel Capitolo 13. Chiusure e iteratori creano type che solo il compilatore conosce o type che sono molto lunghi da specificare. La sintassi impl Trait consente di specificare in modo conciso che una funzione restituisca un type che implementa il trait Iterator senza dover scrivere un type molto lungo.

Tuttavia, è possibile utilizzare impl Trait solo se si restituisce un singolo type. Ad esempio, questo codice che restituisce un Articolo o un PostSocial con il type di ritorno specificato come impl Sommario non funzionerebbe:

pub trait Sommario {
    fn riassunto(&self) -> String;
}

pub struct Articolo {
    pub titolo: String,
    pub posizione: String,
    pub autore: String,
    pub contenuto: String,
}

impl Sommario for Articolo {
    fn riassunto(&self) -> String {
        format!("{}, di {} ({})", self.titolo, self.autore, self.posizione)
    }
}

pub struct PostSocial {
    pub nomeutente: String,
    pub contenuto: String,
    pub risposta: bool,
    pub repost: bool,
}

impl Sommario for PostSocial {
    fn riassunto(&self) -> String {
        format!("{}: {}", self.nomeutente, self.contenuto)
    }
}

fn riassumibile(switch: bool) -> impl Sommario {
    if switch {
        Articolo {
            titolo: String::from(
                "I Penguins vincono la Stanley Cup!",
            ),
            posizione: String::from("Pittsburgh, PA, USA"),
            autore: String::from("Iceburgh"),
            contenuto: String::from(
                "I Pittsburgh Penguins sono ancora una volta la migliore squadra di hockey nella NHL.",
            ),
        }
    } else {
        PostSocial {
            nomeutente: String::from("horse_ebooks"),
            contenuto: String::from(
                "ovviamente, come probabilmente già sapete, gente",
            ),
            risposta: false,
            riposta: false,
        }
    }
}

Restituire un Articolo o un PostSocial non è consentito a causa di restrizioni relative all’implementazione della sintassi impl Trait nel compilatore. Spiegheremo come scrivere una funzione con questo comportamento nella sezione “Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi” del Capitolo 18.

Utilizzare Vincoli di Trait per Implementare Metodi in Modo Condizionale

Utilizzando un vincolo di trait con un blocco impl che utilizza parametri di type generico, possiamo implementare metodi in modo condizionale per i type che implementano i trait specificati. Ad esempio, il type Coppia<T> nel Listato 10-15 implementa sempre la funzione new per restituire una nuova istanza di Coppia<T> (abbiamo menzionato nella sezione “Metodi” del Capitolo 5 che Self è un alias di type per il type del blocco impl, che in questo caso è Coppia<T>). Ma nel blocco impl successivo, Coppia<T> implementa il metodo mostra_comparazione solo se il suo type interno T implementa il trait PartialOrd che abilita il confronto e il trait Display che abilita la stampa.

File: src/lib.rs
use std::fmt::Display;

struct Coppia<T> {
    x: T,
    y: T,
}

impl<T> Coppia<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Coppia<T> {
    fn mostra_comparazione(&self) {
        if self.x >= self.y {
            println!("Il membro più grande è x = {}", self.x);
        } else {
            println!("Il membro più grande è y = {}", self.y);
        }
    }
}
Listato 10-15: Implementazione condizionale di metodi su un type generico in base ai vincoli di trait

Possiamo anche implementare in modo condizionale un trait per qualsiasi type che implementa un altro trait. Le implementazioni di un trait su qualsiasi type che soddisfi i vincoli di trait sono chiamate implementazioni generali (blanket implementations) e sono ampiamente utilizzate nella libreria standard di Rust. Ad esempio, la libreria standard implementa il trait ToString su qualsiasi type che implementi il trait Display. Il blocco impl nella libreria standard è simile a questo codice:

impl<T: Display> ToString for T {
    // --taglio--
}

Poiché la libreria standard ha questa implementazione generale, possiamo chiamare il metodo to_string definito dal trait ToString su qualsiasi type che implementi il trait Display. Ad esempio, possiamo trasformare gli integer nei loro corrispondenti valori String in questo modo, perché gli integer implementano Display:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

Le implementazioni generali compaiono nella documentazione per il trait in questione nella sezione “Implementatori” (Implementors).

I trait e i vincoli dei trait ci consentono di scrivere codice che utilizza parametri di type generico per ridurre le duplicazioni, ma anche di specificare al compilatore che desideriamo che il type generico abbia un comportamento particolare. Il compilatore può quindi utilizzare le informazioni sui vincoli di trait per verificare che tutti i type concreti utilizzati nel nostro codice forniscano il comportamento corretto. Nei linguaggi a tipizzazione dinamica, otterremmo un errore durante l’esecuzione se chiamassimo un metodo su un type che non lo definisce. Ma Rust sposta questi errori in fase di compilazione, quindi siamo costretti a correggere i problemi prima ancora che il nostro codice possa essere eseguito. Inoltre, non dobbiamo scrivere codice che verifichi il comportamento durante l’esecuzione, perché lo abbiamo già verificato in fase di compilazione. Ciò migliora le prestazioni senza dover rinunciare alla flessibilità dei type generici.

Validare i Reference con la Lifetime

Le lifetime (longevità) sono un’altra tipologia di generico che abbiamo già utilizzato. Invece di garantire che un type abbia il comportamento desiderato, le lifetime assicurano che i reference siano validi finché ne abbiamo bisogno.

Un dettaglio che non abbiamo discusso nella sezione Reference e Borrowing del Capitolo 4 è che ogni reference in Rust ha una certa longevità, lifetime, che è lo scope per il quale quel reference è valido. Il più delle volte, la lifetime è implicita e inferita, proprio come il più delle volte i type sono inferiti. Siamo tenuti ad annotare il type solo quando sono possibili più type. Allo stesso modo, dobbiamo annotare la longevità quando la lifetime dei reference potrebbe essere correlata in diversi modi. Rust ci richiede di annotare le relazioni utilizzando parametri di lifetime generici per garantire che i reference utilizzati in fase di esecuzione siano e rimangano sicuramente validi.

Annotare la lifetime non è un concetto presente nella maggior parte degli altri linguaggi di programmazione, quindi questo ti sembrerà poco familiare. Sebbene non tratteremo la lifetime nella sua interezza in questo capitolo, discuteremo i modi più comuni in cui potresti incontrare la sintassi di lifetime in modo che tu possa familiarizzare con il concetto.

Reference Pendenti

Lo scopo principale della lifetime è prevenire i riferimenti pendenti (dangling references), che, se fossero presenti, causerebbere al programma in esecuzione di avere reference che fanno riferimento a dati a cui non dovrebbero far riferimento. Considera il programma nel Listato 10-16, che ha uno scope esterno e uno interno.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listato 10-16: Tentativo di utilizzare un reference il cui valore è uscito dallo scope

Nota: gli esempi nei Listati 10-16, 10-17 e 10-23 dichiarano variabili senza assegnare loro un valore iniziale, quindi il nome della variabile esiste nello scope esterno. A prima vista, questo potrebbe sembrare in conflitto con il fatto che Rust non abbia valori null. Tuttavia, se proviamo a utilizzare una variabile prima di assegnarle un valore, otterremo un errore in fase di compilazione, il che dimostra che Rust in effetti non ammette valori null.

Lo scope esterno dichiara una variabile denominata r senza valore iniziale, mentre lo scope interno dichiara una variabile denominata x con valore iniziale 5. Nello scope interno, proviamo a impostare il valore di r come reference a x. Quindi lo scope interno termina e proviamo a stampare il valore in r. Questo codice non verrà compilato perché il valore a cui fa riferimento r è uscito dallo scope prima che proviamo ad utilizzarlo. Ecco il messaggio di errore:

$ cargo run
   Compiling capitolo10 v0.1.0 (file:///progetti/capitolo10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                  --- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error

Il messaggio di errore indica che la variabile x “non vive abbastanza a lungo”. Il motivo è che x sarà fuori dallo scope quando lo scope interno termina alla riga 7. Ma r è ancora valido per lo scope esterno; Poiché il suo scope è più ampio, diciamo che “vive più a lungo”. Se Rust permettesse a questo codice di funzionare, r farebbe riferimento alla memoria de-allocata quando x è uscita dallo scope, e qualsiasi cosa provassimo a fare con r non funzionerebbe correttamente. Quindi, come fa Rust a determinare che questo codice non è valido? Utilizza un borrow checker.

Il Borrow Checker

Il compilatore Rust ha un borrow checker (controllore dei prestiti) che confronta gli scope per determinare se tutti i dati presi in prestito tramite reference sono validi. Il Listato 10-17 mostra lo stesso codice del Listato 10-16 ma con annotazioni che mostrano la longevità delle variabili.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listato 10-17: Annotazioni delle lifetime di r e x, denominate rispettivamente 'a e 'b

Qui abbiamo annotato la lifetime di r con 'a e la lifetime di x con 'b. Come puoi vedere, il blocco 'b interno è molto più piccolo del blocco 'a esterno. In fase di compilazione, Rust confronta la dimensione delle due longevità e vede che r ha una lifetime 'a ma che si riferisce alla memoria con una lifetime 'b. Il programma viene rifiutato perché 'b è più breve di 'a: il soggetto del reference non ha la stessa longevità del reference stesso.

Il Listato 10-18 corregge il codice in modo che non abbia un reference pendente e si compili senza errori.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          //   |       |
}                         // --+-------+
Listato 10-18: Un reference valido perché i dati hanno una longevità maggiore del reference

Qui, x ha la longevità 'b, che in questo caso è maggiore di 'a. Questo significa che r può fare riferimento a x perché Rust sa che il reference in r sarà sempre valido finché x è valido.

Ora che sai cosa sono le lifetime dei reference e come Rust analizza la longevità per garantire che i reference siano sempre validi, esploriamo le lifetime generiche dei parametri e dei valori di ritorno nel contesto delle funzioni.

Lifetime Generica nelle Funzioni

Scriveremo una funzione che restituisce la più lunga tra due slice di stringa. Questa funzione prenderà due slice e ne restituirà una singola. Dopo aver implementato la funzione più_lunga, il codice nel Listato 10-19 dovrebbe stampare La stringa più lunga è abcd.

File: src/main.rs
fn main() {
    let stringa1 = String::from("abcd");
    let stringa2 = "xyz";

    let risultato = più_lunga(stringa1.as_str(), stringa2);
    println!("La stringa più lunga è {}", risultato);
}
Listato 10-19: Una funzione main che chiama la funzione più_lunga per trovare la più lunga tra due slice

Nota che vogliamo che la funzione accetti slice, che sono reference, piuttosto che stringhe, perché non vogliamo che la funzione più_lunga prenda possesso dei suoi parametri. Fai riferimento a Slice di Stringa come Parametri” nel Capitolo 4 per una disamina più approfondita sul motivo per cui i parametri che utilizziamo nel Listato 10-19 sono quelli che desideriamo.

Se proviamo a implementare la funzione più_lunga come mostrato nel Listato 10-20, non verrà compilata.

File: src/main.rs
fn main() {
    let stringa1 = String::from("abcd");
    let stringa2 = "xyz";

    let risultato = più_lunga(stringa1.as_str(), stringa2);
    println!("La stringa più lunga è {}", risultato);
}

fn più_lunga(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
Listato 10-20: Un’implementazione della funzione più_lunga che restituisce la più lunga tra due stringhe ma non viene ancora compilata

Invece, otteniamo il seguente errore che parla di lifetime:

$ cargo run
   Compiling capitolo10 v0.1.0 (file:///projects/capitolo10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:35
  |
9 | fn più_lunga(x: &str, y: &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 `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn più_lunga<'a>(x: &'a str, y: &'a str) -> &'a str {
  |             ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error

Il testo di aiuto rivela che il type restituito necessita di un parametro di lifetime generico perché Rust non riesce a stabilire se il reference restituito si riferisce a x o y. In realtà, non lo sappiamo anche perché il blocco if nel corpo di questa funzione restituisce un reference a x e il blocco else restituisce un reference a y!

Quando definiamo questa funzione, non conosciamo i valori concreti che verranno passati a questa funzione, quindi non sappiamo se verrà eseguito il caso if o il caso else. Non conosciamo nemmeno la longevità concreta dei riferimenti che verranno passati, quindi non possiamo esaminare gli scope come abbiamo fatto nei Listati 10-17 e 10-18 per determinare se il reference restituito sarà sempre valido. Nemmeno il borrow checker può determinarlo, perché non sa come la longevità di x e y si relaziona alla longevità del valore di ritorno. Per correggere questo errore, aggiungeremo parametri di lifetime generici che definiscono la relazione tra i reference in modo che il borrow checker possa eseguire la sua analisi.

Sintassi dell’Annotazione di Lifetime

Le annotazioni di lifetime non modificano la longevità di alcun reference. Piuttosto, descrivono e dettagliano le relazioni tra le longevità di più riferimenti senza andare a modificarla. Proprio come le funzioni possono accettare qualsiasi type quando la firma specifica un parametro di type generico, le funzioni possono accettare reference con qualsiasi longevità specificando un parametro di lifetime generico.

Le annotazioni di lifetime hanno una sintassi leggermente insolita: i nomi dei parametri di lifetime devono iniziare con un apostrofo (') e sono solitamente tutti in minuscolo e molto brevi, come i type generici. La maggior parte delle persone usa il nome 'a per la prima annotazione di lifetime. Posizioniamo le annotazioni dei parametri di lifetime dopo la & di un reference, utilizzando uno spazio per separare l’annotazione dal type del reference.

Ecco alcuni esempi:

&i32        // `reference` senza parametro di longevità
&'a i32     // `reference` con annotazione della longevità
&'a mut i32 // `reference` mutabile con annotazione della longevità

Un’annotazione di longevità di per sé non ha molto significato perché le annotazioni servono a indicare a Rust come i parametri di lifetime generici di più reference si relazionano tra loro. Esaminiamo come le annotazioni di longevità si relazionano tra loro nel contesto della funzione più_lunga.

Nella Firma delle Funzioni

Per utilizzare le annotazioni di longevità nelle firme delle funzioni, dobbiamo dichiarare i parametri lifetime generici all’interno di parentesi angolari tra il nome della funzione e l’elenco dei parametri, proprio come abbiamo fatto con i parametri type generici.

Vogliamo che la firma esprima la seguente restrizione: il reference restituito sarà valido finché entrambi i parametri saranno validi. Questa è la relazione tra le lifetime dei parametri e il valore restituito. Chiameremo la lifetime 'a e la aggiungeremo a ciascun reference, come mostrato nel Listato 10-21.

File: src/main.rs
fn main() {
    let stringa1 = String::from("abcd");
    let stringa2 = "xyz";

    let risultato = più_lunga(stringa1.as_str(), stringa2);
    println!("La stringa più lunga è {}", risultato);
}

fn più_lunga<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listato 10-21: La definizione di funzione più_lunga specifica che tutti i reference nella firma devono avere la stessa lifetime 'a

Questo codice dovrebbe compilarsi e produrre il risultato desiderato quando lo utilizziamo con la funzione main del Listato 10-19.

La firma della funzione ora indica a Rust che per un certo lifetime 'a, la funzione accetta due parametri, entrambi slice di stringa che durano almeno quanto la lifetime 'a. La firma della funzione indica anche a Rust che la slice di stringa restituita dalla funzione avrà una longevità massima pari al lifetime 'a. In pratica, significa che la longevità del reference restituito dalla funzione più_lunga è minore o uguale alla minore tra le longevità dei valori a cui fanno riferimento gli argomenti della funzione. Queste relazioni sono ciò che vogliamo che Rust utilizzi quando analizza questo codice.

Ricorda, quando specifichiamo i parametri di longevità nella firma di questa funzione, non stiamo modificando le longevità dei valori passati o restituiti. Piuttosto, stiamo specificando che il borrow checker deve rifiutare qualsiasi valore che non rispetta questi vincoli. Nota che la funzione più_lunga non ha bisogno di sapere esattamente quanto dureranno x e y, ma solo che esiste uno scope che può essere sostituito ad 'a che soddisfi questa firma.

Quando si annotano le longevità nelle funzioni, le annotazioni vanno nella firma della funzione, non nel corpo della funzione. Le annotazioni di lifetime diventano parte del contratto della funzione, proprio come i type nella firma. Avere le firme delle funzioni che contengono il contratto di longevità significa che l’analisi effettuata dal compilatore Rust può essere più semplice. Se c’è un problema con il modo in cui una funzione è annotata o con il modo in cui viene chiamata, gli errori del compilatore possono indicare la parte del nostro codice e le restrizioni in modo più preciso. Se, invece, il compilatore Rust facesse più inferenze su ciò che intendevamo che fossero le relazioni tra le longevità, il compilatore potrebbe essere in grado di indicare solo un utilizzo del nostro codice molto lontano dalla causa del problema.

Quando passiamo reference concreti a più_lunga, la longevità concreta che viene sostituita per 'a è la parte dello scope di x che si sovrappone allo scope di y. In altre parole, la longevità generica 'a otterrà la longevità concreta uguale alla minore tra le longevità di x e y. Poiché abbiamo annotato il reference restituito con lo stesso parametro di longevità 'a, il reference restituito sarà valido anche per la lunghezza della minore tra la longevità di x e y.

Osserviamo come le annotazioni di longevità limitino la funzione più_lunga dal ricevere reference con longevità concrete diverse. Il Listato 10-22 è un esempio semplice.

File: src/main.rs
fn main() {
    let stringa1 = String::from("una stringa bella lunga");

    {
        let stringa2 = String::from("xyz");
        let risultato = più_lunga(stringa1.as_str(), stringa2.as_str());
        println!("La stringa più lunga è {risultato}");
    }
}

fn più_lunga<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listato 10-22: Utilizzo della funzione più_lunga con reference a valori String con longevità concrete diverse

In questo esempio, stringa1 è valida fino alla fine dello scope esterno, stringa2 è valida fino alla fine dello scope interno e risultato fa riferimento a qualcosa che è valido fino alla fine dello scope interno. Esegui questo codice e vedrai che verrà approvato dal borrow checker; compilerà e stamperà La stringa più lunga è una stringa bella lunga.

Proviamo ora un esempio che mostra come la lifetime del reference in risultato deve essere la lifetime più breve tra i due argomenti. Sposteremo la dichiarazione della variabile risultato al di fuori dello scope interno, ma lasceremo l’assegnazione del valore alla variabile risultato all’interno dello scope con stringa2. Quindi sposteremo println! che utilizza risultato al di fuori dello scope interno, dopo che quest’ultimo è terminato. Il codice nel Listato 10-23 non verrà compilato.

File: src/main.rs
fn main() {
    let stringa1 = String::from("una stringa bella lunga");
    let risultato;
    {
        let stringa2 = String::from("xyz");
        risultato = più_lunga(stringa1.as_str(), stringa2.as_str());
    }
    println!("La stringa più lunga è {risultato}");
}

fn più_lunga<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listato 10-23: Tentativo di utilizzare risultato dopo che stringa2 è uscita dallo scope

Quando proviamo a compilare questo codice, otteniamo questo errore:

$ cargo run
   Compiling capitolo10 v0.1.0 (file:///projects/capitolo10)
error[E0597]: `stringa2` does not live long enough
 --> src/main.rs:6:50
  |
5 |         let stringa2 = String::from("xyz");
  |             -------- binding `stringa2` declared here
6 |         risultato = più_lunga(stringa1.as_str(), stringa2.as_str());
  |                                                  ^^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `stringa2` dropped here while still borrowed
8 |     println!("La stringa più lunga è {risultato}");
  |                                       --------- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error

L’errore indica che, affinché risultato sia valido per l’istruzione println!, stringa2 dovrebbe essere valido fino alla fine dello scope esterno. Rust lo sa perché abbiamo annotato le lifetime dei parametri della funzione e del valore di ritorno utilizzando lo stesso parametro 'a.

Come esseri umani, possiamo guardare questo codice e vedere che stringa1 è più lungo di stringa2 e, pertanto, risultato conterrà un reference a stringa1. Poiché stringa1 non è ancora uscito dallo scope, un reference a stringa1 sarà ancora valido per l’istruzione println!. Tuttavia, il compilatore non può verificare che il reference sia valido in questo caso. Abbiamo detto a Rust che la lifetime del reference restituito dalla funzione più_lunga è uguale alla più breve tra le lifetime dei riferimenti passati. Pertanto, il borrow checker non consente il codice nel Listato 10-23 in quanto potrebbe contenere un reference non valido.

Prova a progettare altri esperimenti che varino i valori e le lifetime dei reference passati alla funzione più_lunga e il modo in cui il reference restituito viene utilizzato. Formula ipotesi sul fatto che questi esperimenti supereranno o meno il borrow checker prima di compilare; poi verifica se avevi ragione!

Relazioni

Il modo in cui è necessario specificare i parametri di longevità dipende da cosa sta facendo la tua funzione. Ad esempio, se modificassimo l’implementazione della funzione più_lunga in modo che restituisca sempre il primo parametro anziché la slice di stringa più lunga, non avremmo bisogno di specificare una lifetime per il parametro y. Il codice seguente verrà compilato:

File: src/main.rs
fn main() {
    let stringa1 = String::from("abcd");
    let stringa2 = "efghijklmnopqrstuvwxyz";

    let risultato = più_lunga(stringa1.as_str(), stringa2);
    println!("La stringa più lunga è {risultato}");
}

fn più_lunga<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

Abbiamo specificato un parametro di longevità 'a per il parametro x e il type di ritorno, ma non per il parametro y, perché la lifetime di y non ha alcuna relazione con la lifetime di x o con il valore di ritorno.

Quando si restituisce un reference da una funzione, il parametro di longevità per il type di ritorno deve corrispondere al parametro di longevità per uno dei parametri. Se il reference restituito non fa riferimento ad uno dei parametri, deve fare riferimento ad un valore creato all’interno di questa funzione. Tuttavia, questo sarebbe un reference pendente perché il valore uscirà dallo scope al termine della funzione. Considera questo tentativo di implementazione della funzione più_lunga che non verrà compilato:

File: src/main.rs
fn main() {
    let stringa1 = String::from("abcd");
    let stringa2 = "xyz";

    let risultato = più_lunga(stringa1.as_str(), stringa2);
    println!("La stringa più lunga è {risultato}");
}

fn più_lunga<'a>(x: &str, y: &str) -> &'a str {
    let risultato = String::from("una stringa bella lunga");
    risultato.as_str()
}

Qui, anche se abbiamo specificato un parametro di longevità 'a per il type di ritorno, questa implementazione non verrà compilata perché la lifetime del valore di ritorno non è affatto correlata alla lifetime dei parametri. Ecco il messaggio di errore che riceviamo:

$ cargo run
   Compiling capitolo10 v0.1.0 (file:///progetti/capitolo10)
error[E0515]: cannot return value referencing local variable `risultato`
  --> src/main.rs:11:5
   |
11 |     risultato.as_str()
   |     ---------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `risultato` is borrowed here

For more information about this error, try `rustc --explain E0515`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error; 2 warnings emitted

Il problema è che risultato esce dallo scope e viene de-allocato alla fine della funzione più_lunga. Stiamo anche cercando di restituire un reference a risultato dalla funzione. Non c’è modo di specificare parametri di longevità che modifichino il reference pendente, e Rust non ci permette di creare un reference pendente. In questo caso, la soluzione migliore sarebbe restituire un type piuttosto che un reference, in modo che la funzione chiamante sia responsabile della de-allocazione del valore.

In definitiva, la sintassi di longevità serve a collegare le lifetime dei vari parametri e valori di ritorno delle funzioni. Una volta messi in relazione, Rust ha informazioni sufficienti per consentire operazioni che proteggono la memoria e impedire operazioni che creerebbero reference pendenti o comunque violerebbero la sicurezza della memoria.

Nella Definizione delle Struct

Finora, tutte le struct che abbiamo definito contenevano type con ownership. Possiamo definire struct che contengano reference, ma in tal caso dovremmo aggiungere un’annotazione di longevità su ogni reference nella definizione della struct. Il Listato 10-24 ha una struct denominata ParteImportante che contiene una slice di stringa.

File: src/main.rs
struct ParteImportante<'a> {
    parte: &'a str,
}

fn main() {
    let romanzo = String::from("Chiamami Ishmael. Alcuni anni fa...");
    let prima_frase = romanzo.split('.').next().unwrap();
    let i = ParteImportante {
        parte: prima_frase,
    };
}
Listato 10-24: Una struct che contiene un reference, che richiede un’annotazione di longevità

Questa struct ha il singolo campo parte che contiene una slice di stringa, che è un reference. Come per i type generici, dichiariamo il nome del parametro lifetime generico tra parentesi angolari dopo il nome della struct, in modo da poter utilizzare il parametro lifetime nel corpo della definizione della struct. Questa annotazione significa che un’istanza di ParteImportante avrà una longevità non superiore a quella del reference che contiene nel suo campo parte.

La funzione main qui crea un’istanza della struct ParteImportante che contiene un reference alla prima frase della String di proprietà della variabile romanzo. I dati in romanzo esistono prima che l’istanza di ParteImportante venga creata. Inoltre, romanzo non esce dallo scope finché anche ParteImportante non esce dallo scope, quindi il reference nell’istanza di ParteImportante è valido.

Elidere la Lifetime

Hai imparato che ogni reference ha una lifetime e che è necessario specificare parametri di longevità per funzioni o struct che utilizzano reference. Tuttavia, avevamo una funzione nel Listato 4-9, mostrata di nuovo nel Listato 10-25, che veniva compilata senza annotazioni di longevità.

File: src/lib.rs
fn prima_parola(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &lettera) in bytes.iter().enumerate() {
        if lettera == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mia_stringa = String::from("hello world");

    // `prima_parola` funziona con slice di `String`
    let parola = prima_parola(&mia_stringa[..]);

    let mia_stringa_letterale = "hello world";

    // `prima_parola` funziona con slice di letterali di stringa
    let parola_letterale = prima_parola(&mia_stringa_letterale[..]);

    // E siccome i letterali di stringa *sono* già delle slice,
    // funziona pure così, senza usare la sintassi delle slice!
    let parola = prima_parola(mia_stringa_letterale);
}
Listato 10-25: Una funzione che abbiamo definito nel Listato 4-9 che è stata compilata senza annotazioni di longevità, anche se il parametro e il type restituito sono reference

Il motivo per cui questa funzione viene compilata senza annotazioni di longevità è storico: nelle prime versioni (precedenti alla 1.0) di Rust, questo codice non sarebbe stato compilato perché ogni reference necessitava di una lifetime esplicita. A quel tempo, la firma della funzione sarebbe stata scritta in questo modo:

fn prima_parola<'a>(s: &'a str) -> &'a str {

Dopo aver scritto molto codice Rust, il team Rust ha scoperto che i programmatori Rust inserivano le stesse annotazioni di longevità più e più volte in particolari situazioni. Queste situazioni erano prevedibili e seguivano alcuni schemi deterministici. Gli sviluppatori hanno programmato questi schemi nel codice del compilatore in modo che il borrow checker potesse dedurre le lifetime in queste situazioni e non avesse bisogno di annotazioni esplicite.

Questo pezzo di storia di Rust è rilevante perché è possibile che emergano e vengano aggiunti al compilatore altri schemi deterministici. In futuro, potrebbero essere necessarie ancora meno annotazioni di longevità.

Gli schemi programmati nell’analisi dei riferimenti di Rust sono chiamati regole di elisione della longevità (lifetime elision rules). Queste non sono regole che i programmatori devono seguire; sono un insieme di casi particolari che il compilatore prenderà in considerazione e, se il codice si adatta a questi casi, non sarà necessario esplicitare le lifetime.

Le regole di elisione non forniscono un’inferenza completa. Se persiste un’ambiguità sulle lifetime dei reference dopo che Rust ha applicato le regole, il compilatore non inferirà quale dovrebbe essere la longevità dei reference rimanenti. E quindi, invece di indovinare, il compilatore genererà un errore indicando dove è necessario aggiungere le annotazioni di longevità.

Le longevità dei parametri di funzione o metodo sono chiamate lifetime di input, e le longevità dei valori di ritorno sono chiamate lifetime di output.

Il compilatore utilizza tre regole per calcolare le lifetime dei reference quando non ci sono annotazioni esplicite. La prima regola si applica ai lifetime di input, mentre la seconda e la terza regola si applicano ai lifetime di output. Se il compilatore arriva alla fine delle tre regole e ci sono ancora reference per i quali non riesce a calcolare la longevità, il compilatore si interromperà con un errore. Queste regole si applicano sia alle definizioni fn che ai blocchi impl.

La prima regola è che il compilatore assegna un parametro di lifetime a ogni parametro che è un reference. In altre parole, una funzione con un parametro riceve un parametro di lifetime: fn foo<'a>(x: &'a i32); una funzione con due parametri riceve due parametri di lifetime separati: fn foo<'a, 'b>(x: &'a i32, y: &'b i32); e così via.

La seconda regola è che, se c’è esattamente un parametro di lifetime in input, quel lifetime viene assegnato a tutti i parametri di lifetime in output: fn foo<'a>(x: &'a i32) -> &'a i32.

La terza regola è che, se ci sono più parametri di lifetime in input, ma uno di questi è &self o &mut self perché si tratta di un metodo, la lifetime di self viene assegnata a tutti i parametri di lifetime in output. Questa terza regola rende i metodi molto più facili da leggere e scrivere perché sono necessari meno simboli.

Facciamo finta di essere il compilatore. Applicheremo queste regole per calcolare le longevità dei reference nella firma della funzione prima_parola nel Listato 10-25. La firma inizia senza alcuna lifetime associata ai reference:

fn prima_parola(s: &str) -> &str {

Quindi il compilatore applica la prima regola, che specifica che ogni parametro abbia una propria longevità. La chiameremo 'a come al solito, quindi ora la firma è questa:

fn prima_parola<'a>(s: &'a str) -> &str {

La seconda regola si applica perché esiste esattamente un singolo parametro di longevità in input. La seconda regola specifica che la longevità di un parametro in input viene assegnata alla longevità in output, quindi la firma è ora questa:

fn prima_parola<'a>(s: &'a str) -> &'a str {

Ora tutti i reference in questa firma di funzione hanno una longevità e il compilatore può continuare la sua analisi senza che il programmatore debba annotare le lifetime in questa firma di funzione.

Diamo un’occhiata a un altro esempio, questa volta utilizzando la funzione più_lunga che non aveva parametri di longevità quando abbiamo iniziato a lavorarci nel Listato 10-20:

fn più_lunga(x: &str, y: &str) -> &str {

Applichiamo la prima regola: ogni parametro ha la propria longevità. Questa volta abbiamo due parametri invece di uno, quindi abbiamo due lifetime:

fn più_lunga<'a, 'b>(x: &'a str, y: &'b str) -> &str {

Puoi già notare che la seconda regola non si applica perché c’è più di una lifetime di input. Nemmeno la terza regola si applica, perché più_lunga è una funzione e non un metodo, quindi nessuno dei parametri è self. Dopo aver elaborato tutte e tre le regole, non abbiamo ancora capito qual è la longevità del type di ritorno. Ecco perché abbiamo ricevuto un errore durante la compilazione del codice nel Listato 10-20: il compilatore ha elaborato le regole di elisione della lifetime, ma non è comunque riuscito a calcolare tutte le lifetime dei reference nella firma.

Poiché la terza regola si applica solo alle firme dei metodi, esamineremo le lifetime in quel contesto per capire perché la terza regola ci consente di non dover annotare la longevità nelle firme dei metodi nella maggior parte dei casi.

Nella Definizione dei Metodi

Quando implementiamo metodi su una struct con lifetime, utilizziamo la stessa sintassi dei parametri di type generico, come mostrato nel Listato 10-11. Il punto in cui dichiariamo e utilizziamo i parametri di longevità dipende dal fatto che siano correlati ai campi della struct o ai parametri del metodo e ai valori di ritorno.

I nomi delle lifetime per i campi della struct devono sempre essere dichiarati dopo la parola chiave impl e quindi utilizzati dopo il nome della struct, poiché tali lifetime fanno parte del type della struct.

Nelle firme dei metodi all’interno del blocco impl, i reference potrebbero essere legati alla longevità dei reference nei campi della struct, oppure potrebbero essere indipendenti. Inoltre, le regole di elisione della lifetime spesso fanno sì che le annotazioni della longevità non siano necessarie nelle firme dei metodi. Diamo un’occhiata ad alcuni esempi utilizzando la struct denominata ParteImportante che abbiamo definito nel Listato 10-24.

Per prima cosa useremo un metodo chiamato livello il cui unico parametro è un reference a self e il cui valore di ritorno è un i32, che non è un reference ad alcunché:

struct ParteImportante<'a> {
    parte: &'a str,
}

impl<'a> ParteImportante<'a> {
    fn livello(&self) -> i32 {
        3
    }
}

impl<'a> ParteImportante<'a> {
    fn annunciare_e_restituire_parte(&self, annuncio: &str) -> &str {
        println!("Attenzione per favore: {annuncio}");
        self.parte
    }
}

fn main() {
    let romanzo = String::from("Chiamami Ishmael. Qualche anno fa...");
    let prima_frase = romanzo.split('.').next().unwrap();
    let i = ParteImportante {
        parte: prima_frase,
    };
}

La dichiarazione del parametro lifetime dopo impl e il suo utilizzo dopo il nome del type sono obbligatori ma, grazie alla prima regola di elisione, non siamo tenuti ad annotare la longevità del reference a self.

Ecco un esempio in cui si applica la terza regola di elisione della lifetime:

struct ParteImportante<'a> {
    parte: &'a str,
}

impl<'a> ParteImportante<'a> {
    fn livello(&self) -> i32 {
        3
    }
}

impl<'a> ParteImportante<'a> {
    fn annunciare_e_restituire_parte(&self, annuncio: &str) -> &str {
        println!("Attenzione per favore: {annuncio}");
        self.parte
    }
}

fn main() {
    let romanzo = String::from("Chiamami Ishmael. Qualche anno fa...");
    let prima_frase = romanzo.split('.').next().unwrap();
    let i = ParteImportante {
        parte: prima_frase,
    };
}

Ci sono due lifetime in input, quindi Rust applica la prima regola di elisione della lifetime e assegna sia a &self che a annuncio le corrispettive lifetime. Quindi, poiché uno dei parametri è &self, al type di ritorno viene assegnata la lifetime di &self. Ora tutte le lifetime sono state considerate.

La Lifetime Statica

Una lifetime speciale di cui dobbiamo discutere è 'static, che indica che la longevità del reference interessato corrisponde a quella del programma. Tutti i letterali stringa hanno la lifetime 'static, che possiamo annotare come segue:

#![allow(unused)]
fn main() {
let s: &'static str = "Ho una lifetime statica.";
}

Il testo di questa stringa è memorizzato direttamente nel binario del programma, che è sempre disponibile. Pertanto, la longevità di tutti i letterali stringa è 'static.

Potresti trovare suggerimenti nei messaggi di errore del compilatore di utilizzare la lifetime 'static. Ma prima di specificare 'static come lifetime per un reference, valuta se quel reference ha effettivamente necessità di rimanere valido per l’intera durata dell’esecuzione del tuo programma. Nella maggior parte dei casi, un messaggio di errore che suggerisce la lifetime 'static deriva dal tentativo di creare un reference pendente o da una mancata corrispondenza delle longevità disponibili. In questi casi, la soluzione è risolvere questi problemi, non specificare la lifetime 'static.

Parametri di Type Generico, Vincoli del Trait e Lifetime

Esaminiamo brevemente la sintassi per specificare parametri di type generico, vincoli di trait e lifetime, tutto in un’unica funzione!

fn main() {
    let stringa1 = String::from("abcd");
    let stringa2 = "xyz";

    let risultato = più_lunga_con_annuncio(
        stringa1.as_str(),
        stringa2,
        "Oggi è il compleanno di qualcuno!",
    );
    println!("La stringa più lunga è {risultato}");
}

use std::fmt::Display;

fn più_lunga_con_annuncio<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Annuncio! {ann}");
    if x.len() > y.len() { x } else { y }
}

Questa è la funzione più_lunga del Listato 10-21 che restituisce la più lunga tra due slice. Ma ora ha un parametro aggiuntivo denominato ann di type generico T, che può ricevere qualsiasi type che implementi il trait Display come specificato dalla clausola where. Questo parametro aggiuntivo verrà stampato utilizzando {}, motivo per cui il vincolo del trait Display è necessario. Poiché le lifetime sono un type generico, le dichiarazioni del parametro di longevità 'a e del parametro di type generico T vanno nella stessa lista all’interno delle parentesi angolari dopo il nome della funzione.

Riepilogo

Abbiamo trattato molti argomenti in questo capitolo! Ora che conosci i parametri di type generico, i trait, i vincoli dei trait e i parametri di lifetime generici, sei pronto a scrivere codice senza ripetizioni che funzioni in molte situazioni diverse. I parametri di type generico consentono di applicare il codice a type diversi. I trait e i vincoli dei trait garantiscono che, anche se i type sono generici, abbiano il comportamento di cui il codice ha bisogno. Hai imparato come usare le annotazioni di longevità per garantire che questo codice flessibile non abbia reference pendenti. E tutta questa analisi avviene in fase di compilazione, il che non influisce sulle prestazioni in fase di esecuzione!

Che ci crediate o no, c’è molto altro da imparare sugli argomenti trattati in questo capitolo: il Capitolo 18 tratta degli oggetti trait, che rappresentano un altro modo di utilizzare i trait. Esistono anche scenari più complessi che coinvolgono le annotazioni della lifetime, che ti serviranno solo in scenari molto avanzati; se ti può interessare dovresti leggere The Rust Reference (in inglese). Ma ora imparerai come scrivere test in Rust in modo da poterti assicurare che il tuo codice funzioni a dovere.

Scrivere Test Automatizzati

Nel suo saggio del 1972 “The Humble Programmer”, Edsger W. Dijkstra ha affermato che “il testing dei programmi può essere un modo molto efficace per mostrare la presenza di bug, ma è irrimediabilmente inadeguato per mostrarne l’assenza.” Questo non significa che non dovremmo cercare di testare il più possibile!

La correttezza dei nostri programmi è la misura in cui il nostro codice fa ciò che intendiamo fare. Rust è stato progettato con un alto grado di preoccupazione per la correttezza dei programmi, ma la correttezza è complessa e non facile da dimostrare. Il sistema dei type di Rust si fa carico di gran parte di questo onere, ma il sistema dei type non può catturare tutto. Per questo motivo, Rust include un supporto per la scrittura di test software automatizzati.

Immaginiamo di scrivere una funzione aggiungi_due che aggiunge 2 a qualsiasi numero le venga passato. La firma di questa funzione accetta un intero come parametro e restituisce un intero come risultato. Quando implementiamo e compiliamo questa funzione, Rust esegue tutti i controlli di type e di prestito (borrow) che hai imparato finora per assicurarsi che, ad esempio, non stiamo passando un valore String o un reference non valido a questa funzione. Ma Rust non può controllare che questa funzione faccia esattamente ciò che intendiamo, cioè restituire il parametro più 2 piuttosto che, ad esempio, il parametro più 10 o il parametro meno 50! È qui che entrano in gioco i test.

Possiamo scrivere dei test che verificano, ad esempio, che quando passiamo 3 alla funzione aggiungi_due, il valore restituito sia 5. Possiamo eseguire questi test ogni volta che apportiamo delle modifiche al nostro codice per assicurarci che il comportamento corretto esistente non sia cambiato.

Il testing è un’abilità complessa: anche se non possiamo trattare in un solo capitolo tutti i dettagli su come scrivere dei buoni test, in questo capitolo parleremo dei meccanismi delle strutture di test di Rust. Parleremo delle annotazioni e delle macro a tua disposizione quando scrivi i tuoi test, del comportamento predefinito e delle opzioni fornite per l’esecuzione dei tuoi test e di come organizzare i test in unità di test e test di integrazione.

Come Scrivere dei Test

I test sono funzioni di Rust che verificano che il codice non di test funzioni nel modo previsto. I corpi delle funzioni di test eseguono tipicamente queste tre azioni:

  • Impostare i dati o lo stato necessari.
  • Eseguire il codice che si desidera testare.
  • Verificare che i risultati siano quelli attesi.

Vediamo le funzionalità che Rust mette a disposizione specificamente per scrivere test che eseguono queste azioni, come l’attributo test, alcune macro e l’attributo should_panic.

Strutturare le Funzioni di Test

Nella sua forma più semplice, un test in Rust è una funzione annotata con l’attributo test. Gli attributi sono metadati relativi a pezzi di codice Rust; un esempio è l’attributo derive che abbiamo usato con le struct nel Capitolo 5. Per trasformare una funzione in una funzione di test, aggiungi #[test] nella riga prima di fn. Quando esegui i tuoi test con il comando cargo test, Rust costruisce un eseguibile di test che esegue le funzioni annotate e riporta se ogni funzione di test passa o fallisce.

Ogni volta che creiamo un nuovo progetto di libreria con Cargo, viene generato automaticamente un modulo di test con una funzione di test al suo interno. Questo modulo ti fornisce un modello per scrivere i tuoi test, in modo da non dover cercare la struttura e la sintassi esatta ogni volta che inizi un nuovo progetto. Puoi aggiungere tutte le funzioni di test e tutti i moduli di test che vuoi!

Esploreremo alcuni aspetti del funzionamento dei test sperimentando con il test predefinito prima di testare effettivamente il codice. Poi scriveremo alcuni test reali che richiamano il codice che abbiamo scritto e verificano che il suo comportamento sia corretto.

Creiamo un nuovo progetto di libreria chiamato addizione che aggiungerà due numeri:

$ cargo new addizione --lib
     Created library `addizione` project
$ cd addizione

Il contenuto del file src/lib.rs della tua libreria addizione dovrebbe essere come il Listato 11-1.

File: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Listato 11-1: Il codice generato automaticamente da cargo new

Il file inizia con un esempio di funzione add (aggiungi), in modo da avere qualcosa da testare.

Per ora, concentriamoci solo sulla funzione it_works (funziona). Nota l’annotazione #[test]: questo attributo indica che si tratta di una funzione di test, in modo che il test runner sappia che deve trattare questa funzione come un test. Potremmo anche avere funzioni non di test nel modulo tests per aiutare a configurare scenari comuni o eseguire operazioni comuni, quindi dobbiamo sempre indicare quali funzioni sono di test.

Il corpo della funzione di esempio utilizza la macro assert_eq! per verificare che result, che contiene il risultato della chiamata a add con 2 e 2, sia uguale a 4. Questa asserzione serve come esempio del formato di un test tipico. Eseguiamola per vedere se il test passa.

Il comando cargo test esegue tutti i test del nostro progetto, come mostrato nel Listato 11-2.

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.90s
     Running unittests src/lib.rs (/target/debug/deps/addizione-943d072b5b568c79)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Listato 11-2: L’output dell’esecuzione del test generato automaticamente

Cargo ha compilato ed eseguito il test. Vediamo la riga running 1 test. La riga successiva mostra il nome della funzione di test generata, chiamata tests::it_works, e che il risultato dell’esecuzione di quel test è ok. Il riepilogo complessivo test result: ok. significa che tutti i test sono passati, e la parte che recita 1 passed; 0 failed tiene il conto del numero di test che sono passati o falliti.

È possibile contrassegnare un test come da ignorare in modo che non venga eseguito in una particolare istanza; ne parleremo nella sezione “Ignorare test se non specificamente richiesti” più avanti in questo capitolo. Poiché non l’abbiamo fatto qui, il riepilogo mostra 0 ignored. Possiamo anche passare un argomento al comando cargo test per eseguire solo i test il cui nome corrisponda a una stringa; questo si chiama filtraggio e lo tratteremo nella sezione “Eseguire un sottoinsieme di test in base al nome”. In questo caso non abbiamo filtrato i test in esecuzione, quindi la fine del riepilogo mostra 0 filtered out.

La statistica 0 measured è per i test di benchmark che misurano le prestazioni. I test di benchmark, al momento, sono disponibili solo nelle nightly di Rust. Per saperne di più, consulta la documentazione sui test di benchmark.

La parte successiva dell’output di test che inizia con Doc-tests addizione è per i risultati di qualsiasi test sulla documentazione. Non abbiamo ancora test sulla documentazione, ma Rust può compilare qualsiasi esempio di codice che appare nella nostra documentazione. Questa funzione aiuta a mantenere sincronizzata la documentazione e il codice! Parleremo di come scrivere test sulla documentazione nella sezione “Commenti di documentazione come test” del Capitolo 14. Per ora, ignoreremo l’output Doc-tests.

Iniziamo a personalizzare il test in base alle nostre esigenze. Per prima cosa, cambiamo il nome della funzione it_works, ad esempio esplorazione, in questo modo:

File: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn esplorazione() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Esegui nuovamente cargo test. L’output ora mostra esplorazione invece di it_works:

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.90s
     Running unittests src/lib.rs (/target/debug/deps/addizione-943d072b5b568c79)

running 1 test
test tests::esplorazione ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Ora aggiungeremo un altro test, ma questa volta faremo un test che fallisce! I test falliscono quando qualcosa nella funzione di test va in panico. Ogni test viene eseguito in un nuovo thread e quando il thread principale vede che un thread di test fallisce, il test viene contrassegnato come fallito. Nel Capitolo 9 abbiamo parlato di come il modo più semplice per mandare in panico un programma sia quello di chiamare la macro panic!. Inserisci il nuovo test come una funzione di nome un_altra, in modo che il tuo file src/lib.rs assuma l’aspetto del Listato 11-3.

File: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn esplorazione() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn un_altra() {
        panic!("Fai fallire questo test");
    }
}
Listato 11-3: Aggiungere un secondo test che fallisce perché chiamiamo la macro panic!

Esegui di nuovo i test utilizzando cargo test. L’output dovrebbe assomigliare al Listato 11-4, che mostra come il nostro test esplorazione sia passato e un_altra sia fallito.

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.92s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 2 tests
test tests::esplorazione ... ok
test tests::un_altra ... FAILED

failures:

---- tests::un_altra stdout ----

thread 'tests::un_altra' (4763) panicked at src/lib.rs:17:9:
Fai fallire questo test
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::un_altra

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`
Listato 11-4: Risultati dei test quando uno viene superato e uno fallisce

Al posto di ok, la riga test tests::un_altra mostra FAILED (fallito). Tra i singoli risultati e il riepilogo compaiono due nuove sezioni: la prima mostra il motivo dettagliato del fallimento di ogni test. In questo caso, otteniamo il dettaglio che tests::un_altra ha fallito perché è andato in panico con il messaggio Fai fallire questo test alla riga 17 del file src/lib.rs. La sezione successiva elenca solo i nomi di tutti i test falliti, il che è utile quando ci sono molti test e molti output dettagliati di test falliti. Possiamo usare il nome di un test fallito per eseguire solo quel test e più facilmente fare il debug del problema; parleremo più diffusamente dei modi per eseguire i test nella sezione “Controllare come vengono eseguiti i test”.

Alla fine viene visualizzata la riga di riepilogo: in generale, il risultato del nostro test è FAILED. Abbiamo avuto un test superato e un test fallito.

Ora che hai visto come appaiono i risultati dei test in diversi scenari, vediamo alcune macro diverse da panic! che sono utili nei test.

Verificare i Risultati Con assert!

La macro assert!, fornita dalla libreria standard, è utile quando vuoi assicurarti che una condizione in un test risulti essere vera, true. Diamo alla macro assert! un argomento che valuta in un booleano. Se il valore è true, non succede nulla e il test passa. Se il valore è false, la macro assert! chiama panic! per far fallire il test. L’uso della macro assert! ci aiuta a verificare che il nostro codice funzioni nel modo in cui intendiamo.

Nel Listato 5-15 del Capitolo 5 abbiamo utilizzato una struct Rettangolo e un metodo può_contenere, che sono ripetuti qui nel Listato 11-5. Inseriamo questo codice nel file src/lib.rs e scriviamo alcuni test utilizzando la macro assert!.

File: src/lib.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn può_contenere(&self, altro: &Rettangolo) -> bool {
        self.larghezza > altro.larghezza && self.altezza > altro.altezza
    }
}
Listato 11-5: La struct Rettangolo e il suo metodo può_contenere del Capitolo 5

Il metodo può_contenere restituisce un booleano, il che significa che è un caso d’uso perfetto per la macro assert!. Nel Listato 11-6, scriviamo un test che utilizza il metodo può_contenere creando un’istanza di Rettangolo che ha una larghezza di 8 e un’altezza di 7 e affermando che può contenere un’altra istanza di Rettangolo che ha una larghezza di 5 e un’altezza di 1.

File: src/lib.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn può_contenere(&self, altro: &Rettangolo) -> bool {
        self.larghezza > altro.larghezza && self.altezza > altro.altezza
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grande_contiene_piccolo() {
        let grande = Rettangolo {
            larghezza: 8,
            altezza: 7,
        };
        let piccolo = Rettangolo {
            larghezza: 5,
            altezza: 1,
        };

        assert!(grande.può_contenere(&piccolo));
    }
}
Listato 11-6: Un test per può_contenere che verifica se un rettangolo più grande può effettivamente contenere un rettangolo più piccolo

Nota la riga use super::*; all’interno del modulo tests. Il modulo tests è un modulo normale che segue le solite regole di visibilità che abbiamo trattato nel Capitolo 7 nella sezione “Percorsi per Fare Riferimento a un Elemento nell’Albero dei Moduli”. Poiché il modulo tests è un modulo interno, dobbiamo portare il codice del modulo esterno che vogliamo testare nello scope del modulo interno. Usiamo un glob in questo caso, in modo che tutto ciò che definiamo nel modulo esterno sia disponibile per questo modulo tests.

Abbiamo chiamato il nostro test grande_contiene_piccolo e abbiamo creato le due istanze di Rettangolo di cui abbiamo bisogno. Poi abbiamo chiamato la macro assert! e le abbiamo passato il risultato della chiamata grande.può_contenere(&piccolo). Questa espressione dovrebbe restituire true, quindi il nostro test dovrebbe passare. Scopriamolo!

$ cargo test
   Compiling rettangolo v0.1.0 (file:///progetti/rettangolo)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
     Running unittests src/lib.rs (target/debug/deps/rettangolo-f3dbc7f03c0e31f1)

running 1 test
test tests::grande_contiene_piccolo ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rettangolo

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Passa davvero! Aggiungiamo un altro test, questa volta per verificare che un rettangolo più piccolo non può contenere un rettangolo più grande:

File: src/lib.rs

#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

impl Rettangolo {
    fn può_contenere(&self, altro: &Rettangolo) -> bool {
        self.larghezza > altro.larghezza && self.altezza > altro.altezza
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grande_contiene_piccolo() {
        // --taglio--
        let grande = Rettangolo {
            larghezza: 8,
            altezza: 7,
        };
        let piccolo = Rettangolo {
            larghezza: 5,
            altezza: 1,
        };

        assert!(grande.può_contenere(&piccolo));
    }

    #[test]
    fn piccolo_non_contiene_grande() {
        let grande = Rettangolo {
            larghezza: 8,
            altezza: 7,
        };
        let piccolo = Rettangolo {
            larghezza: 5,
            altezza: 1,
        };

        assert!(!piccolo.può_contenere(&grande));
    }
}

Poiché il risultato corretto della funzione può_contenere in questo caso è false, dobbiamo negare questo risultato prima di passarlo alla macro assert!. Di conseguenza, il nostro test passerà se può_contenere restituisce false:

$ cargo test
   Compiling rettangolo v0.1.0 (file:///progetti/rettangolo)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running unittests src/lib.rs (target/debug/deps/rettangolo-f3dbc7f03c0e31f1)

running 2 tests
test tests::grande_contiene_piccolo ... ok
test tests::piccolo_non_contiene_grande ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rettangolo

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Due test superati! Ora vediamo cosa succede ai risultati dei nostri test quando introduciamo un bug nel nostro codice. Cambieremo l’implementazione del metodo può_contenere sostituendo il segno maggiore (>) con il segno minore (<) quando confronta le larghezze:

#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

// --taglio--
impl Rettangolo {
    fn può_contenere(&self, altro: &Rettangolo) -> bool {
        self.larghezza < altro.larghezza && self.altezza > altro.altezza
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grande_contiene_piccolo() {
        let grande = Rettangolo {
            larghezza: 8,
            altezza: 7,
        };
        let piccolo = Rettangolo {
            larghezza: 5,
            altezza: 1,
        };

        assert!(grande.può_contenere(&piccolo));
    }

    #[test]
    fn piccolo_non_contiene_grande() {
        let grande = Rettangolo {
            larghezza: 8,
            altezza: 7,
        };
        let piccolo = Rettangolo {
            larghezza: 5,
            altezza: 1,
        };

        assert!(!piccolo.può_contenere(&grande));
    }
}

L’esecuzione dei test produce ora il seguente risultato:

$ cargo test
   Compiling rettangolo v0.1.0 (file:///progetti/rettangolo)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running unittests src/lib.rs (target/debug/deps/rettangolo-f3dbc7f03c0e31f1)

running 2 tests
test tests::grande_contiene_piccolo ... FAILED
test tests::piccolo_non_contiene_grande ... ok

failures:

---- tests::grande_contiene_piccolo stdout ----

thread 'tests::grande_contiene_piccolo' (6902) panicked at src/lib.rs:31:9:
assertion failed: grande.può_contenere(&piccolo)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::grande_contiene_piccolo

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Poiché grande.larghezza è 8 e piccolo.larghezza è 5, il confronto delle larghezze in può_contenere ora restituisce false: 8 non è inferiore a 5.

Testare l’Uguaglianza Con assert_eq! e assert_ne!

Un modo comune per verificare le funzionalità è quello di testare l’uguaglianza tra il risultato del codice in esame e il valore che ti aspetti che il codice restituisca. Potresti farlo utilizzando la macro assert! e passandole un’espressione con l’operatore ==. Tuttavia, questo è un test così comune che la libreria standard fornisce una coppia di macro, assert_eq! e assert_ne!, per eseguire questo test in modo più conveniente. Queste macro confrontano due argomenti per l’uguaglianza o l’ineguaglianza, rispettivamente. Inoltre, stampano i due valori se l’asserzione fallisce, il che rende più facile vedere il perché il test è fallito; al contrario, la macro assert! indica solo che ha ottenuto un valore false per l’espressione ==, senza stampare i valori che hanno portato al valore false.

Nel Listato 11-7, scriviamo una funzione chiamata aggiungi_due che aggiunge 2 al suo parametro, e poi testiamo questa funzione usando la macro assert_eq!

File: src/lib.rs
pub fn aggiungi_due(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn aggiungere_due() {
        let risultato = aggiungi_due(2);
        assert_eq!(risultato, 4);
    }
}
Listato 11-7: Test della funzione aggiungi_due utilizzando la macro assert_eq!

Controlliamo che passi!

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.00s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 1 test
test tests::aggiungere_due ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Abbiamo creato una variabile chiamata risultato che contiene il risultato della chiamata a aggiungi_due(2). Poi passiamo risultato e 4 come argomenti alla macro assert_eq!. La riga di output per questo test è test tests::aggiungere_due ... ok, e il testo ok indica che il nostro test è passato!

Introduciamo un bug nel nostro codice per vedere come appare assert_eq! quando fallisce. Cambia l’implementazione della funzione aggiungi_due per aggiungere invece 3:

pub fn aggiungi_due(a: u64) -> u64 {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn aggiungere_due() {
        let risultato = aggiungi_due(2);
        assert_eq!(risultato, 4);
    }
}

Esegui nuovamente i test:

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 1 test
test tests::aggiungere_due ... FAILED

failures:

---- tests::aggiungere_due stdout ----

thread 'tests::aggiungere_due' (5229) panicked at src/lib.rs:14:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::aggiungere_due

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Il nostro test ha rilevato il bug! Il test tests::aggiungere_due è fallito e il messaggio ci dice che l’asserzione fallita era left == right (sinistra/destra) e quali sono i valori left e right. Questo messaggio ci aiuta a iniziare il debug: l’argomento left, dove avevamo il risultato della chiamata a aggiungi_due(2), era 5 ma l’argomento right era 4. Puoi immaginarti quanto questo sia particolarmente utile quando abbiamo molti test in corso.

Nota che in alcuni linguaggi e framework di test, i parametri delle funzioni di asserzione di uguaglianza sono chiamati expected (atteso) e actual (effettivo) e l’ordine in cui specifichiamo gli argomenti è importante. Tuttavia, in Rust, sono chiamati left e right e l’ordine in cui specifichiamo il valore che ci aspettiamo e il valore che il codice produce non ha importanza. Potremmo scrivere l’asserzione in questo test come assert_eq!(4, risultato), che risulterebbe nello stesso messaggio di fallimento che mostra assertion `left == right` failed.

La macro assert_ne! ha successo se i due valori che le diamo non sono uguali e fallisce se sono uguali. Questa macro è molto utile nei casi in cui non siamo sicuri di quale sarà il valore, ma sappiamo quale valore sicuramente non dovrebbe essere. Ad esempio, se stiamo testando una funzione che ha la garanzia di cambiare il suo input in qualche modo, ma il modo in cui l’input viene cambiato dipende dal giorno della settimana in cui eseguiamo i test, la cosa migliore da asserire potrebbe essere che l’output della funzione non è uguale all’input.

Sotto la superficie, le macro assert_eq! e assert_ne! utilizzano rispettivamente gli operatori == e !=. Quando le asserzioni falliscono, queste macro stampano i loro argomenti utilizzando la formattazione di debug, il che significa che i valori confrontati devono implementare i trait PartialEq e Debug. Tutti i type primitivi e la maggior parte dei type della libreria standard implementano questi trait. Per le struct e le enum che definisci tu stesso, dovrai implementare PartialEq per asserire l’uguaglianza di tali type. Dovrai anche implementare Debug per stampare i valori quando l’asserzione fallisce. Poiché entrambi i trait sono derivabili, come menzionato nel Listato 5-12 nel Capitolo 5, questo è solitamente semplice come aggiungere l’annotazione #[derive(PartialEq, Debug)] alla definizione della struct o dell’enum. Vedi l’Appendice C, Trait derivabili”, per ulteriori dettagli su questi e altri trait derivabili.

Aggiungere Messaggi di Errore Personalizzati

Puoi anche aggiungere un messaggio personalizzato da stampare insieme al messaggio di fallimento come argomenti opzionali alle macro assert!, assert_eq! e assert_ne!. Qualsiasi argomento specificato dopo gli argomenti obbligatori viene passato alla macro format! (di cui si parla in “Concatenare con l’Operatore + o la Macro format! nel Capitolo 8), quindi puoi passare una stringa di formato che contenga dei segnaposto {} e dei valori da inserire in quei segnaposto. I messaggi personalizzati sono utili per documentare il significato di un’asserzione; quando un test fallisce, avrai un’idea più precisa del problema del codice.

Ad esempio, supponiamo di avere una funzione che saluta le persone per nome e vogliamo verificare che il nome che passiamo nella funzione appaia nell’output:

File: src/lib.rs

pub fn saluto(nome: &str) -> String {
    format!("Ciao {nome}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn saluto_contiene_nome() {
        let risultato = saluto("Carol");
        assert!(risultato.contains("Carol"));
    }
}

I requisiti per questo programma non sono ancora stati concordati e siamo abbastanza sicuri che il testo Ciao all’inizio del saluto cambierà. Abbiamo deciso di non dover aggiornare il test quando i requisiti cambiano, quindi invece di verificare l’esatta uguaglianza con il valore restituito dalla funzione saluto, asseriremo che l’output debba contenere il testo del parametro di input.

Ora introduciamo un bug in questo codice cambiando saluto per non includere nome e vedere come si presenta il fallimento del test:

pub fn saluto(nome: &str) -> String {
    String::from("Ciao!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn saluto_contiene_nome() {
        let risultato = saluto("Carol");
        assert!(risultato.contains("Carol"));
    }
}

L’esecuzione di questo test produce il seguente risultato:

$ cargo test
   Compiling saluto v0.1.0 (file:///progetti/saluto)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.15s
     Running unittests src/lib.rs (target/debug/deps/saluto-9c4c14b7b4719873)

running 1 test
test tests::saluto_contiene_nome ... FAILED

failures:

---- tests::saluto_contiene_nome stdout ----

thread 'tests::saluto_contiene_nome' (10833) panicked at src/lib.rs:14:9:
assertion failed: risultato.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::saluto_contiene_nome

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Questo risultato indica solo che l’asserzione è fallita e su quale riga si trova l’asserzione. Un messaggio di fallimento più utile stamperebbe il valore della funzione saluto. Aggiungiamo un messaggio di fallimento personalizzato composto da una stringa di formato con un segnaposto riempito con il valore effettivo ottenuto dalla funzione saluto:

pub fn saluto(nome: &str) -> String {
    String::from("Ciao!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn saluto_contiene_nome() {
        let risultato = saluto("Carol");
        assert!(
            risultato.contains("Carol"),
            "Saluto non contiene un nome, il valore era `{risultato}`"
        );
    }
}

Ora, quando eseguiamo il test, otterremo un messaggio di errore più informativo:

$ cargo test
   Compiling saluto v0.1.0 (file:///progetti/saluto)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running unittests src/lib.rs (target/debug/deps/saluto-00f50f06a08e754d)

running 1 test
test tests::saluto_contiene_nome ... FAILED

failures:

---- tests::saluto_contiene_nome stdout ----

thread 'tests::saluto_contiene_nome' (11432) panicked at src/lib.rs:13:9:
Saluto non contiene un nome, il valore era `Ciao!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::saluto_contiene_nome

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Possiamo vedere il valore effettivamente ottenuto nell’output del test, il che ci aiuterebbe a fare il debug di ciò che è accaduto invece di ciò che ci aspettavamo che accadesse.

Verificare i Casi di Panico Con should_panic

Oltre a verificare i valori di ritorno, è importante controllare che il nostro codice gestisca le condizioni di errore come ci aspettiamo. Ad esempio, considera il type Ipotesi che abbiamo creato nel Capitolo 9, Listato 9-13. Altro codice che utilizza Ipotesi dipende dalla garanzia che le istanze di Ipotesi conterranno solo valori compresi tra 1 e 100. Possiamo scrivere un test che verifichi che il tentativo di creare un’istanza di Ipotesi con un valore al di fuori di questo intervallo vada in panico.

Per farlo, aggiungiamo l’attributo should_panic alla nostra funzione di test. Il test passa se il codice all’interno della funzione va in panico; il test fallisce se il codice all’interno della funzione non va in panico.

Il Listato 11-8 mostra un test che verifica che le condizioni di errore di Ipotesi::new si verifichino quando ce lo aspettiamo.

File: src/lib.rs
pub struct Ipotesi {
    valore: i32,
}

impl Ipotesi {
    pub fn new(valore: i32) -> Ipotesi {
        if valore < 1 || valore > 100 {
            panic!("L'ipotesi deve essere compresa tra 1 e 100, valore ottenuto: {valore}.");
        }

        Ipotesi { valore }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn maggiore_di_100() {
        Ipotesi::new(200);
    }
}
Listato 11-8: Test che una condizione generi un panic!

Inseriamo l’attributo #[should_panic] dopo l’attributo #[test] e prima della funzione di test a cui si applica. Vediamo il risultato quando questo test viene superato:

$ cargo test
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
     Running unittests src/lib.rs (target/debug/deps/gioco_indovinello-bd151867e79a86ca)

running 1 test
test tests::maggiore_di_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests gioco_indovinello

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Non male! Ora introduciamo un bug nel nostro codice rimuovendo la condizione per cui la funzione new va in panico se il valore è superiore a 100:

pub struct Ipotesi {
    valore: i32,
}

// --taglio--
impl Ipotesi {
    pub fn new(valore: i32) -> Ipotesi {
        if valore < 1 {
            panic!("L'ipotesi deve essere compresa tra 1 e 100, valore ottenuto: {valore}.");
        }

        Ipotesi { valore }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn maggiore_di_100() {
        Ipotesi::new(200);
    }
}

Quando eseguiamo il test nel Listato 11-8, questo fallirà:

$ cargo test
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.19s
     Running unittests src/lib.rs (target/debug/deps/gioco_indovinello-bd151867e79a86ca)

running 1 test
test tests::maggiore_di_100 - should panic ... FAILED

failures:

---- tests::maggiore_di_100 stdout ----
note: test did not panic as expected at src/lib.rs:24:8

failures:
    tests::maggiore_di_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

In questo caso non riceviamo un messaggio molto utile, ma se guardiamo la funzione di test, vediamo che è annotata con #[should_panic]. Il fallimento ottenuto significa che il codice della funzione di test non ha causato un panico.

I test che utilizzano should_panic possono essere imprecisi. Un test should_panic passerebbe anche se il test va in panico per un motivo diverso da quello atteso. Per rendere i test should_panic più precisi, possiamo aggiungere un parametro opzionale expected all’attributo should_panic. L’infrastruttura di test si assicurerà che il messaggio di fallimento contenga il testo fornito. Per esempio, considera il codice modificato per Ipotesi nel Listato 11-9 dove la funzione new va in panico con messaggi diversi a seconda che il valore sia troppo piccolo o troppo grande.

File: src/lib.rs
pub struct Ipotesi {
    valore: i32,
}

// --taglio--

impl Ipotesi {
    pub fn new(valore: i32) -> Ipotesi {
        if valore < 1 {
            panic!(
                "L'ipotesi deve essere maggiore di zero, valore fornito {valore}."
            );
        } else if valore > 100 {
            panic!(
                "L'ipotesi deve essere minore o uguale a 100, valore fornito {valore}."
            );
        }

        Ipotesi { valore }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "minore o uguale a 100")]
    fn maggiore_di_100() {
        Ipotesi::new(200);
    }
}
Listato 11-9: Test per un panic! con un messaggio di panico contenente una sotto-stringa specificata

Questo test passerà perché il valore che abbiamo inserito nel parametro expected dell’attributo should_panic è una sotto-stringa del messaggio con cui la funzione Ipotesi::new va in panico. Avremmo potuto specificare l’intero messaggio di panico che ci aspettiamo, che in questo caso sarebbe stato L’ipotesi deve essere minore o uguale a 100, valore fornito 200.. Quello che scegli di specificare dipende da quanto il messaggio di panico è unico o dinamico e da quanto preciso vuoi che sia il tuo test. In questo caso, una sotto-stringa del messaggio di panico è sufficiente per garantire che il codice nella funzione di test esegua la parte con else if valore > 100.

Per vedere cosa succede quando un test should_panic con un messaggio expected fallisce, introduciamo nuovamente un bug nel nostro codice scambiando i corpi dei blocchi if valore < 1 e else if valore > 100:

pub struct Ipotesi {
    valore: i32,
}

impl Ipotesi {
    pub fn new(valore: i32) -> Ipotesi {
        if valore < 1 {
            panic!(
                "L'ipotesi deve essere minore o uguale a 100, valore fornito {valore}."
            );
        } else if valore > 100 {
            panic!(
                "L'ipotesi deve essere maggiore di zero, valore fornito {valore}."
            );
        }

        Ipotesi { valore }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "minore o uguale a 100")]
    fn maggiore_di_100() {
        Ipotesi::new(200);
    }
}

Questa volta il test should_panic fallirà:

$ cargo test
   Compiling gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.33s
     Running unittests src/lib.rs (target/debug/deps/gioco_indovinello-bd151867e79a86ca)

running 1 test
test tests::maggiore_di_100 - should panic ... FAILED

failures:

---- tests::maggiore_di_100 stdout ----

thread 'tests::maggiore_di_100' (14846) panicked at src/lib.rs:13:13:
L'ipotesi deve essere maggiore di zero, valore fornito 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: "L'ipotesi deve essere maggiore di zero, valore fornito 200."
 expected substring: "minore o uguale a 100"

failures:
    tests::maggiore_di_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Il messaggio di fallimento indica che questo test è andato in panic come ci aspettavamo, ma il messaggio di panico non includeva la stringa prevista minore o uguale a 100. Il messaggio di panico che abbiamo ottenuto in questo caso è stato L’ipotesi deve essere maggiore di zero, valore fornito 200. Ora possiamo iniziare a capire dove si trova il nostro bug!

Utilizzare Result<T, E> nei Test

Tutti i test che abbiamo fatto finora vanno in panic quando falliscono. Possiamo anche scrivere test che utilizzano Result<T, E>! Ecco il test del Listato 11-1, riscritto per utilizzare Result<T, E> e restituire un Err invece di andare in panico:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("due più due non fa quattro"))
        }
    }
}

La funzione it_works ora ha il type di ritorno Result<(), String>. Nel corpo della funzione, invece di richiamare la macro assert_eq!, restituiamo Ok()) quando il test passa e un Err con una String all’interno quando il test fallisce.

Scrivere i test in modo che restituiscano un Result<T, E> ti permette di usare l’operatore punto interrogativo nel corpo dei test, il che può essere un modo comodo per scrivere test che dovrebbero fallire se qualsiasi operazione al loro interno restituisce una variante Err.

Non puoi usare l’annotazione #[should_panic] nei test che usano Result<T, E>. Per verificare che un’operazione restituisce una variante Err, non usare l’operatore punto interrogativo sul valore Result<T, E>, ma usa assert!(valore.is_err()).

Ora che conosci diversi modi per scrivere i test, vediamo cosa succede quando li eseguiamo ed esploriamo le diverse opzioni che possiamo utilizzare con cargo test

Controllare Come Vengono Eseguiti i Test

Così come cargo run compila il tuo codice e poi esegue il binario risultante, cargo test compila il tuo codice in modalità test ed esegue il binario risultante. Il comportamento predefinito del binario prodotto da cargo test è quello di eseguire tutti i test in parallelo e di catturare l’output generato durante l’esecuzione dei test, impedendo la visualizzazione dell’output e rendendo più facile la lettura dell’output relativo ai risultati dei test. Puoi, tuttavia, specificare alcune opzioni della riga di comando per modificare questo comportamento predefinito.

Alcune opzioni della riga di sono per cargo test, mentre altre vengono passate al binario di test risultante.

Per separare questi due tipi di argomenti, devi elencare gli argomenti che vanno a cargo test seguiti dal separatore -- e poi quelli che vanno al binario di test. Eseguendo cargo test --help vengono visualizzate le opzioni che puoi usare con cargo test, mentre eseguendo cargo test -- --help vengono visualizzate le opzioni che puoi usare dopo il separatore. Queste opzioni sono documentate anche nella sezione “Tests” del libro di rustc.

Eseguire i Test in Parallelo o Sequenzialmente

Quando esegui più test, come impostazione predefinita questi vengono eseguiti in parallelo utilizzando i thread, il che significa che finiscono di essere eseguiti più velocemente e che ricevi più rapidamente un feedback. Poiché i test vengono eseguiti contemporaneamente, devi assicurarti che i tuoi test non dipendano l’uno dall’altro o da un qualsivoglia stato condiviso, incluso un ambiente condiviso, come la directory di lavoro corrente o le variabili d’ambiente.

Ad esempio, supponiamo che ogni test esegua del codice che crea un file su disco chiamato test-output.txt e scrive alcuni dati in quel file. Poi ogni test legge i dati in quel file e verifica che il file contiene un particolare valore, che è diverso in ogni test. Poiché i test vengono eseguiti contemporaneamente, un test potrebbe sovrascrivere il file nel tempo che intercorre tra la scrittura e la lettura del file da parte di un altro test. Il secondo test fallirà, non perché il codice non è corretto, ma perché i test hanno interferito l’uno con l’altro durante l’esecuzione in parallelo. Una soluzione può essere nell’assicurarsi che ogni test scriva in un file diverso; un’altra soluzione consiste nell’eseguire i test uno alla volta.

Se non vuoi eseguire i test in parallelo o se vuoi un controllo più preciso sul numero di thread utilizzati, puoi usare il flag --test-threads e il numero di thread che vuoi utilizzare al binario di test. Guarda il seguente esempio:

$ cargo test -- --test-threads=1

Impostiamo il numero di thread di test a 1, indicando al programma di non utilizzare alcun parallelismo. L’esecuzione dei test con un solo thread richiederà più tempo rispetto all’esecuzione in parallelo, ma i test non interferiranno l’uno con l’altro se condividono lo stato.

Mostrare l’Output Della Funzione

Per impostazione predefinita, se un test viene superato, la libreria di test di Rust cattura tutto ciò che viene stampato sullo standard output. Ad esempio, se chiamiamo println! in un test e il test viene superato, non vedremo l’output println! nel terminale; vedremo solo la riga che indica che il test è stato superato. Se un test fallisce, vedremo tutto ciò che è stato stampato sullo standard output con il resto del messaggio di fallimento.

Come esempio, il Listato 11-10 contiene una funzione stupida che stampa il valore del suo parametro e restituisce 10, oltre a un test che passa e uno che fallisce.

File: src/lib.rs
fn stampa_e_ritorna_10(a: i32) -> i32 {
    println!("Ho ricevuto il valore {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn questo_test_passerà() {
        let valore = stampa_e_ritorna_10(4);
        assert_eq!(valore, 10);
    }

    #[test]
    fn questo_test_fallirà() {
        let valore = stampa_e_ritorna_10(8);
        assert_eq!(valore, 5);
    }
}
Listato 11-10: Test per una funzione che chiama println!

Quando eseguiamo i test con cargo test, vedremo il seguente output:

$ cargo test
   Compiling funzione-stupida v0.1.0 (file:///progetti/funzione-stupida)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.67s
     Running unittests src/lib.rs (target/debug/deps/funzione_stupida-5473422b7951e476)

running 2 tests
test tests::questo_test_passerà ... ok
test tests::questo_test_fallirà ... FAILED

failures:

---- tests::questo_test_fallirà stdout ----
Ho ricevuto il valore 8

thread 'tests::questo_test_fallirà' (8784) panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::questo_test_fallirà

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Nota che in nessun punto di questo output vediamo Ho ricevuto il valore 4, che viene stampato quando viene eseguito il test che passa. Quell’output è stato catturato. L’output del test che è fallito, Ho ricevuto il valore 8, appare nella sezione dell’output di riepilogo del test, che mostra anche la causa del fallimento del test.

Se vogliamo vedere anche i valori stampati per i test superati, possiamo dire a Rust di mostrare anche l’output dei test riusciti con --show-output:

$ cargo test -- --show-output

Quando eseguiamo nuovamente i test del Listato 11-10 con il flag --show-output, vediamo il seguente output:

$ cargo test -- --show-output
   Compiling funzione-stupida v0.1.0 (file:///progetti/funzione-stupida)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/funzione_stupida-5473422b7951e476)

running 2 tests
test tests::questo_test_fallirà ... FAILED
test tests::questo_test_passerà ... ok

successes:

---- tests::questo_test_passerà stdout ----
Ho ricevuto il valore 4


successes:
    tests::questo_test_passerà

failures:

---- tests::questo_test_fallirà stdout ----
Ho ricevuto il valore 8

thread 'tests::questo_test_fallirà' (9352) panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::questo_test_fallirà

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Eseguire un Sottoinsieme di Test in Base al Nome

L’esecuzione di tutti i test che abbiamo definito a volte può richiedere molto tempo. Se stai lavorando sul codice di una particolare area, potresti voler eseguire solo i test relativi a quel codice. Puoi scegliere quali test eseguire passando a cargo test il nome o i nomi dei test che vuoi eseguire come argomento.

Per dimostrare come eseguire un sottoinsieme di test, creeremo prima tre test per la nostra funzione aggiungi_due, come mostrato nel Listato 11-11, e sceglieremo quali eseguire.

File: src/lib.rs
pub fn aggiungi_due(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn somma_due_e_due() {
        let risultato = aggiungi_due(2);
        assert_eq!(risultato, 4);
    }

    #[test]
    fn somma_due_e_tre() {
        let risultato = aggiungi_due(3);
        assert_eq!(risultato, 5);
    }

    #[test]
    fn cento() {
        let risultato = aggiungi_due(100);
        assert_eq!(risultato, 102);
    }
}
Listato 11-11: Tre test con tre nomi diversi

Se eseguiamo i test senza passare alcun argomento, come abbiamo visto in precedenza, tutti i test verranno eseguiti in parallelo:

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 3 tests
test tests::cento ... ok
test tests::somma_due_e_tre ... ok
test tests::somma_due_e_due ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Eseguire un Singolo Test

Possiamo passare il nome di qualsiasi funzione di test a cargo test per eseguire solo quel test:

$ cargo test cento
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.81s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 1 test
test tests::cento ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

Solo il test con il nome cento è stato eseguito; gli altri due test non corrispondevano a quel nome. L’output del test ci fa sapere che ci sono altri test che non sono stati eseguiti mostrando 2 filtered out alla fine.

Non possiamo specificare più di un nome in questo modo; verrà utilizzato solo il primo valore dato a cargo test. Esiste però un modo per eseguire più test.

Filtrare Per Eseguire Più Test

Possiamo specificare una parte del nome di un test e ogni test il cui nome corrisponde a quel valore verrà eseguito. Ad esempio, poiché due dei nomi dei nostri test contengono somma, possiamo eseguirli eseguendo cargo test somma:

$ cargo test somma
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.11s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 2 tests
test tests::somma_due_e_tre ... ok
test tests::somma_due_e_due ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Questo comando ha eseguito tutti i test con somma nel nome e ha filtrato il test chiamato cento. Nota anche che il modulo in cui appare un test diventa parte del nome del test, quindi possiamo eseguire tutti i test di un modulo filtrando sul nome del modulo.

Ignorare Test Se Non Specificamente Richiesti

A volte alcuni test specifici possono richiedere molto tempo per essere eseguiti, quindi potresti volerli escludere durante la maggior parte delle esecuzioni di cargo test. Invece di elencare come argomenti tutti i test che vuoi eseguire, puoi annotare i test che richiedono molto tempo utilizzando l’attributo ignore per escluderli, come mostrato qui:

File: src/lib.rs

pub fn aggiungi_due(a: u64) -> u64 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn somma_due_e_due() {
        let risultato = aggiungi_due(2);
        assert_eq!(risultato, 4);
    }

    #[test]
    #[ignore]
    fn test_impegnativo() {
        // codice che richiede un'ora per completarsi
    }
}

Dopo #[test], aggiungiamo la riga #[ignore] al test che vogliamo escludere. Ora quando eseguiamo i nostri test, somma_due_e_due viene eseguito, ma test_impegnativo no:

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 2 tests
test tests::test_impegnativo ... ignored
test tests::somma_due_e_due ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

La funzione test_impegnativo è elencata come ignored. Se vogliamo eseguire solo i test ignorati, possiamo usare cargo test -- --ignored:

$ cargo test -- --ignored
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 1 test
test tests::test_impegnativo ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Controllando quali test vengono eseguiti, puoi assicurarti che i risultati di cargo test vengano restituiti rapidamente. Quando ha senso controllare i risultati dei test ignored e hai il tempo di aspettare i risultati, puoi invece eseguire cargo test -- --ignored. Se vuoi eseguire tutti i test, indipendentemente dal fatto che siano ignorati o meno, puoi eseguire cargo test -- --include-ignored

Organizzare i Test

Come accennato all’inizio del capitolo, i test sono una disciplina complessa e diverse persone tendono ad utilizzare una terminologia e un’organizzazione diverse. La comunità di Rust pensa ai test in termini di due categorie principali: i test unitari (unit test) e i test di integrazione (integration test). Gli unit test sono piccoli e più mirati, testano un singolo modulo alla volta e possono testare interfacce private. Gli integration test sono invece esterni alla tua libreria e utilizzano il tuo codice nello stesso modo in cui lo farebbe qualsiasi altro codice esterno, utilizzando solo l’interfaccia pubblica e potenzialmente utilizzando più moduli per test.

Scrivere entrambi i tipi di test è importante per garantire che i pezzi della tua libreria facciano ciò che ti aspetti, sia separatamente che quando integrate in altro codice.

Test Unitari

Lo scopo degli unit test è quello di testare ogni unità di codice in modo isolato dal resto del codice per individuare rapidamente i punti in cui il codice funziona e non funziona come previsto. Gli unit test vengono inseriti nella cartella src in ogni file con il codice che stanno testando. La convenzione è quella di creare un modulo chiamato tests in ogni file per contenere le funzioni di test e di annotare il modulo con cfg(test).

Il Modulo tests e #[cfg(test)]

L’annotazione #[cfg(test)] sul modulo tests dice a Rust di compilare ed eseguire il codice di test solo quando si esegue cargo test, non quando si esegue cargo build. In questo modo si risparmia tempo di compilazione quando si vuole costruire solo la libreria e si risparmia spazio nell’artefatto compilato risultante perché i test non sono inclusi. Vedrai che, poiché i test di integrazione si trovano in una cartella diversa, non hanno bisogno dell’annotazione #[cfg(test)]. Tuttavia, poiché gli unit test si trovano negli stessi file del codice, dovrai specificare #[cfg(test)] per evitare che siano inclusi nel risultato compilato.

Ricordi che quando abbiamo generato il nuovo progetto addizione nella prima sezione di questo capitolo, Cargo ha generato questo codice per noi:

File: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

Nel modulo tests generato automaticamente, l’attributo cfg sta per configuration (configurazione) e indica a Rust che il seguente elemento deve essere incluso solo in presenza di una determinata opzione di configurazione. In questo caso, l’opzione di configurazione è test, che viene fornita da Rust per la compilazione e l’esecuzione dei test. Utilizzando l’attributo cfg, Cargo compila il nostro codice di test solo se effettivamente eseguiamo i test con cargo test. Questo include qualsiasi funzione ausiliaria che potrebbero essere presente in questo modulo, oltre alle funzioni annotate con #[test].

Testare Funzioni Private

All’interno della comunità dei tester si discute se le funzioni private debbano essere testate direttamente o meno, e altri linguaggi rendono difficile o impossibile testare le funzioni private. Indipendentemente dall’ideologia di testing a cui aderisci, le regole sulla privacy di Rust ti permettono di testare le funzioni private. Considera il codice nel Listato 11-12 con la funzione privata addizione_privata.

File: src/lib.rs
pub fn aggiungi_due(a: u64) -> u64 {
    addizione_privata(a, 2)
}

fn addizione_privata(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn privata() {
        let result = addizione_privata(2, 2);
        assert_eq!(result, 4);
    }
}
Listato 11-12: Test di una funzione privata

Nota che la funzione addizione_privata non è contrassegnata come pub. I test sono solo codice Rust e il modulo tests è solo un altro modulo. Come abbiamo discusso in “Percorsi per Fare Riferimento a un Elemento nell’Albero dei Moduli”, gli elementi dei moduli figli possono utilizzare gli elementi dei loro moduli antenati. In questo test, portiamo tutti gli elementi dei moduli genitore del modulo tests nello scope con use super::*, e poi il test può chiamare addizione_privata. Se non pensi che le funzioni private debbano essere testate, non c’è nulla in Rust che ti costringa a farlo.

Test di Integrazione

In Rust, i test di integrazione sono esterni alla tua libreria. Utilizzano la libreria nello stesso modo in cui lo farebbe qualsiasi altro codice, il che significa che possono chiamare solo le funzioni che fanno parte dell’API pubblica della libreria. Il loro scopo è quello di verificare se molte parti della libreria funzionano correttamente insieme. Unità di codice che funzionano correttamente da sole potrebbero avere problemi quando vengono integrate, quindi creare test che verifichino queste funzionalità del codice integrato è importante. Per creare i test di integrazione, hai bisogno innanzitutto di una cartella tests.

La Cartella tests

Creiamo una cartella tests all’inizio della nostra cartella di progetto, accanto a src. Cargo sa che deve cercare i file di test di integrazione in questa cartella. Possiamo quindi creare tutti i file di test che vogliamo e Cargo compilerà ciascuno di essi come crate separati.

Creiamo un test di integrazione. Con il codice del Listato 11-12 ancora nel file src/lib.rs, crea una cartella tests e un nuovo file chiamato tests/test_integrazione.rs. La struttura delle cartelle del tuo progetto dovrebbe essere simile a questa:

addizione
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── test_integrazione.rs

Inserisci il codice del Listato 11-13 nel file tests/test_integrazione.rs.

File: tests/test_integrazione.rs
use addizione::aggiungi_due;

#[test]
fn aggiungere_due() {
    let risultato = aggiungi_due(2);
    assert_eq!(risultato, 4);
}
Listato 11-13: Un test di integrazione di una funzione nel crate addizione

Ogni file della cartella tests è un crate separato, quindi dobbiamo portare la nostra libreria nello scope di ogni crate di test. Per questo motivo aggiungiamo use addizione::aggiungi_due; all’inizio del codice, che non era necessario negli unit test usati finora.

Non abbiamo bisogno di annotare alcun codice in tests/test_integrazione.rs con #[cfg(test)]. Cargo tratta la cartella tests in modo speciale e compila i file in questa cartella solo quando eseguiamo cargo test. Esegui ora cargo test:

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.80s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 1 test
test tests::privata ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/test_integrazione.rs (target/debug/deps/test_integrazione-a2e6a22ac01f911a)

running 1 test
test aggiungere_due ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Le tre sezioni di output comprendono gli unit test, i test di integrazione e i test di documentazione. Nota che se un test di una sezione fallisce, le sezioni successive non verranno eseguite. Ad esempio, se un unit test fallisce, non ci sarà alcun output per i test di integrazione e di documentazione perché questi test verranno eseguiti solo se tutti gli unit test passano.

La prima sezione per gli unit test è la stessa che abbiamo visto finora: una riga per ogni unit test (una denominata privata che abbiamo aggiunto nel Listato 11-12) e poi una riga di riepilogo per i unit test.

La sezione dei test di integrazione inizia con la riga Running test/test_integrazione.rs. Poi, c’è una riga per ogni funzione di test in quel test di integrazione e una riga di riepilogo dei risultati del test di integrazione appena prima dell’inizio della sezione Doc-tests addizione.

Ogni file di test di integrazione ha una propria sezione, quindi se aggiungiamo altri file nella cartella tests, ci saranno più sezioni di test di integrazione.

Possiamo comunque eseguire una particolare funzione di test di integrazione specificando il nome della funzione di test come argomento di cargo test. Per eseguire tutti i test in un particolare file di test di integrazione, usa l’argomento --test di cargo test seguito dal nome del file:

$ cargo test --test test_integrazione
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running tests/test_integrazione.rs (target/debug/deps/test_integrazione-a2e6a22ac01f911a)

running 1 test
test aggiungere_due ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Questo comando esegue solo i test presenti nel file tests/test_integrazione.rs.

Sottomoduli nei Test di Integrazione

Man mano che aggiungi altri test di integrazione, potresti voler creare altri file nella cartella tests per organizzarli; ad esempio, puoi raggruppare le funzioni di test in base alla funzionalità che stanno testando. Come già detto, ogni file nella cartella tests viene compilato come un proprio crate separato, il che è utile per creare scope separati per imitare il più possibile il modo in cui gli utenti finali utilizzeranno il tuo crate. Tuttavia, questo significa che i file nella cartella tests non condividono lo stesso comportamento dei file in src, come hai appreso nel Capitolo 7 su come separare il codice in moduli e file.

Il diverso comportamento dei file della cartella tests si nota soprattutto quando hai una serie di funzioni comuni da utilizzare in più file di test di integrazione e cerchi di seguire i passi della sezione “Separare i Moduli in File Diversi” del Capitolo 7 per metterle in un modulo comune. Ad esempio, se creiamo tests/comune.rs e vi inseriamo una funzione chiamata inizializzazione a cui aggiungere del codice che vogliamo chiamare da più funzioni di test in più file di test:

File: tests/comune.rs

pub fn inizializzazione() {
    // codice specifico di inizializzazione della libreria
}

Quando eseguiamo nuovamente i test, vedremo una nuova sezione nell’output del test per il file comune.rs, anche se questo file non contiene alcuna funzione di test né abbiamo chiamato la funzione inizializzazione da nessuna parte:

$ cargo test
   Compiling addizione v0.1.0 (file:///progetti/addizione)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.92s
     Running unittests src/lib.rs (target/debug/deps/addizione-41054392f08da196)

running 1 test
test tests::privata ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/comune.rs (target/debug/deps/comune-9acf22d6dcb0de0a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/test_integrazione.rs (target/debug/deps/test_integrazione-a2e6a22ac01f911a)

running 1 test
test aggiungere_due ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests addizione

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Il fatto che comune appaia nei risultati dei test con running 0 tests (eseguiti 0 test) non è quello che volevamo. Volevamo solo condividere un po’ di codice con gli altri file dei test di integrazione. Per evitare che comune appaia nell’output dei test, invece di creare tests/comune.rs, creeremo tests/comune/mod.rs. La cartella del progetto ora ha questo aspetto:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── comune
    │   └── mod.rs
    └── test_integrazione.rs

Questa è la vecchia convenzione di denominazione comunque compresa da Rust, di cui abbiamo parlato in “Percorsi di File Alternativi” nel Capitolo 7. Nominare il file in questo modo indica a Rust di non trattare il modulo comune come un file di test di integrazione. Quando spostiamo il codice della funzione inizializzazione in tests/comune/mod.rs e cancelliamo il file tests/comune.rs, la sezione nell’output del test non apparirà più. I file nelle sottocartelle della cartella tests non vengono compilati come crate separati né hanno sezioni nell’output del test.

Dopo aver creato tests/comune/mod.rs, possiamo utilizzarlo da qualsiasi file di test di integrazione come modulo. Ecco un esempio di chiamata della funzione inizializzazione dal test aggiungere_due in tests/test_integrazione.rs:

File: tests/test_integrazione.rs

use addizione::aggiungi_due;

mod comune;

#[test]
fn aggiungere_due() {
    comune::inizializzazione();

    let risultato = aggiungi_due(2);
    assert_eq!(risultato, 4);
}

Nota come la dichiarazione mod comune; è uguale alla dichiarazione che abbiamo mostrato nel Listato 7-21. Quindi, nella funzione di test, possiamo chiamare la funzione comune::inizializzazione().

Test di Integrazione per i Crate Binari

Se il nostro progetto è un crate binario che contiene solo un file src/main.rs e non ha un file src/lib.rs, non possiamo creare test di integrazione nella cartella tests e testare le funzioni definite nel file src/main.rs con una dichiarazione use. Solo i crate libreria espongono funzioni che altri crate possono utilizzare; i crate binari sono pensati per essere eseguiti da soli.

Per questo è buona pratica per i progetti Rust che forniscono un binario avere un file src/main.rs semplice che si limita a richiamare la logica che risiede nel file src/lib.rs. Utilizzando questa struttura, i test di integrazione possono testare il crate libreria con use per rendere disponibile le funzionalità che ci interessa testare. Se la funzionalità passa il test, anche la piccola quantità di codice nel file src/main.rs funzionerà e quella piccola quantità di codice non dovrà essere testata.

Riepilogo

Le funzionalità di testing di Rust forniscono un modo per specificare come il codice debba funzionare e ci si assicuri che continui a funzionare come ci si aspetta, anche quando si apportano delle modifiche. I test unitari usano e testano le diverse parti di una libreria separatamente e possono testare i dettagli privati dell’implementazione. I test di integrazione verificano che molte parti della libreria funzionino insieme correttamente e utilizzano l’API pubblica della libreria per testare il codice nello stesso modo in cui lo utilizzerà il codice esterno. Anche se il sistema dei type e le regole di ownership di Rust aiutano a prevenire alcuni tipi di bug, i test sono comunque importanti per ridurre i bug logici che hanno a che fare con il modo in cui ci si aspetta che il codice si comporti.

Combiniamo le conoscenze apprese in questo capitolo e nei capitoli precedenti per lavorare a un progetto!

Un progetto I/O: Creare un Programma da Riga di Comando

Questo capitolo è un riepilogo delle numerose competenze acquisite finora e un’esplorazione di alcune funzionalità aggiuntive della libreria standard. Creeremo uno strumento da riga di comando che interagisce con l’input/output di file e da riga di comando per mettere in pratica alcuni dei concetti di Rust che dovresti aver acquisito finora.

La velocità, la sicurezza, l’output binario singolo e il supporto multi-piattaforma di Rust lo rendono un linguaggio ideale per la creazione di strumenti da riga di comando, quindi per il nostro progetto creeremo la nostra versione del classico strumento di ricerca da riga di comando grep (globally search a regular expression and print) (cerca globalmente tramite espressione regolare e stampa). Nel caso d’uso più semplice, grep cerca una stringa come parametro in un file specificato. Per farlo, grep accetta come argomenti un percorso di file e una stringa. Quindi legge il file, trova le righe in quel file che contengono l’argomento stringa e visualizza quelle righe.

Lungo il capitolo, mostreremo come far sì che il nostro strumento da riga di comando utilizzi le funzionalità del terminale che molti altri strumenti da riga di comando utilizzano. Leggeremo il valore di una variabile d’ambiente per consentire all’utente di configurare il comportamento del nostro programma. Mostreremo anche i messaggi di errore nel flusso di errore standard della console (stderr) invece che nell’output standard (stdout), in modo che, ad esempio, l’utente possa reindirizzare l’output corretto a un file continuando a visualizzare i messaggi di errore sullo schermo.

Un membro della community Rust, Andrew Gallant, ha già creato una versione completa e molto veloce di grep, chiamata ripgrep. In confronto, la nostra versione sarà piuttosto semplice, ma questo capitolo vi fornirà alcune conoscenze di base necessarie per comprendere un progetto reale come ripgrep.

Il nostro progetto grep combinerà diversi concetti che hai imparato finora:

Introdurremo anche brevemente chiusure, iteratori e oggetti trait, che nel Capitolo 13 e nel Capitolo 18 affronteremo in dettaglio.

Ricevere Argomenti dalla Riga di Comando

Crea un nuovo progetto con, come sempre, cargo new. Chiameremo il nostro progetto minigrep per distinguerlo dallo strumento grep che potresti già avere sul tuo sistema:

$ cargo new minigrep
Created binary (application) `minigrep` project
$ cd minigrep

Il primo compito è fare in modo che minigrep accetti i suoi due argomenti della riga di comando: il percorso del file e una stringa da cercare. Cioè, vogliamo essere in grado di eseguire il nostro programma con cargo run, due trattini per indicare che i seguenti argomenti sono per il nostro programma e non per cargo, una stringa da cercare e un percorso a un file in cui cercare, in questo modo:

$ cargo run -- stringa-da-trovare file-esempio.txt

Al momento, il programma generato da cargo new non può elaborare gli argomenti che gli forniamo. Alcune librerie esistenti su crates.io possono aiutare a scrivere un programma che accetti argomenti da riga di comando, ma poiché stiamo apprendendo solo ora questo concetto, implementiamo questa funzionalità da soli.

Leggere i Valori degli Argomenti

Per consentire a minigrep di leggere i valori degli argomenti da riga di comando che gli passiamo, avremo bisogno della funzione std::env::args fornita nella libreria standard di Rust. Questa funzione restituisce un iteratore degli argomenti della riga di comando passati a minigrep. Tratteremo gli iteratori in dettaglio nel Capitolo 13. Per ora, è sufficiente conoscere solo due dettagli sugli iteratori: gli iteratori producono una serie di valori e possiamo chiamare il metodo collect su un iteratore per trasformarlo in una collezione, come un vettore, che contiene tutti gli elementi prodotti dall’iteratore.

Il codice nel Listato 12-1 consente al programma minigrep di leggere qualsiasi argomento della riga di comando passato e quindi raccogliere i valori in un vettore.

File: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}
Listato 12-1: Raccolta degli argomenti della riga di comando in un vettore e stamparli

Per prima cosa, portiamo il modulo std::env nello scope con un’istruzione use in modo da poter utilizzare la sua funzione args. Nota che la funzione std::env::args è annidata in due livelli di moduli. Come discusso nel Capitolo 7, nei casi in cui la funzione desiderata è annidata in più di un modulo, abbiamo scelto di portare nello scope il modulo genitore anziché la funzione. In questo modo, possiamo facilmente utilizzare altre funzioni da std::env. È anche meno ambiguo rispetto all’aggiunta di use std::env::args e quindi alla chiamata della funzione con solo args, perché args potrebbe essere facilmente confuso con una funzione definita nel modulo corrente.

La Funzione args e Unicode non Valido

Nota che std::env::args andrà in panic se un argomento contiene Unicode non valido. Se il programma deve accettare argomenti contenenti Unicode non valido, utilizzare invece std::env::args_os. Questa funzione restituisce un iteratore che produce valori OsString invece di valori String. Abbiamo scelto di utilizzare std::env::args qui per semplicità perché i valori OsString variano a seconda della piattaforma e sono più complessi da gestire rispetto ai valori String.

Nella prima riga del corpo di main, chiamiamo env::args e utilizziamo immediatamente collect per trasformare l’iteratore in un vettore contenente tutti i valori prodotti dall’iteratore. Possiamo usare la funzione collect per creare molti tipi di collezioni, quindi annotiamo esplicitamente il type di args per specificare che vogliamo un vettore di stringhe. Sebbene sia molto raro dover annotare i type in Rust, collect è una funzione che spesso occorre annotare perché Rust non è in grado di dedurre il tipo di collezione desiderata.

Infine, stampiamo il vettore usando la macro di debug. Proviamo a eseguire il codice prima senza argomenti e poi con due argomenti:

$ cargo run
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- ago pagliaio
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep ago pagliaio`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "ago",
    "pagliaio",
]

Nota che il primo valore nel vettore è "target/debug/minigrep", che è il nome del nostro binario. Questo corrisponde al comportamento dell’elenco degli argomenti in C, consentendo ai programmi di utilizzare il nome con cui sono stati invocati durante l’esecuzione. Spesso è comodo avere accesso al nome del programma nel caso in cui si voglia visualizzarlo nei messaggi o modificarne il comportamento in base all’alias della riga di comando utilizzato per invocarlo. Ma ai fini di questo capitolo, lo ignoreremo e salveremo solo i due argomenti di cui abbiamo bisogno.

Salvare i Valori degli Argomenti nelle Variabili

Il programma è attualmente in grado di accedere ai valori specificati come argomenti della riga di comando. Ora dobbiamo salvare i valori dei due argomenti nelle variabili in modo da poterli utilizzare nel resto del programma. Lo facciamo nel Listato 12-2.

File: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let percorso_file = &args[2];

    println!("Cerco {query}");
    println!("Nel file {percorso_file}");
}
Listato 12-2: Creazione di variabili per contenere l’argomento query e l’argomento percorso_file

Come abbiamo visto quando abbiamo stampato il vettore, il nome del programma occupa il primo valore nel vettore in args[0], quindi gli argomenti che servono a noi iniziano dall’indice 1. Il primo argomento preso da minigrep è la stringa che stiamo cercando, quindi inseriamo un reference al primo argomento nella variabile query. Il secondo argomento sarà il percorso del file, quindi inseriamo un reference al secondo argomento nella variabile percorso_file.

Stampiamo temporaneamente i valori di queste variabili per dimostrare che il codice funziona come previsto. Eseguiamo di nuovo questo programma con gli argomenti test e esempio.txt:

$ cargo run -- test esempio.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test esempio.txt`
Cerco test
Nel file esempio.txt

Ottimo, il programma funziona! I valori degli argomenti di cui abbiamo bisogno vengono salvati nelle variabili corrette. In seguito aggiungeremo una gestione degli errori per gestire alcune potenziali situazioni errate, come quando l’utente non fornisce argomenti; per ora ignoreremo questa situazione e lavoreremo invece sull’aggiunta di funzionalità per la lettura dei file.

Leggere un File

Ora aggiungeremo la funzionalità per leggere il file specificato nell’argomento percorso_file . Per prima cosa abbiamo bisogno di un file di esempio con cui testarlo: useremo un file con una piccola quantità di testo su più righe con alcune parole ripetute. Il Listato 12-3 contiene una poesia di Emily Dickinson che funzionerà bene! Crea un file chiamato poesia.txt nella radice del tuo progetto e inserisci la poesia “Io sono Nessuno! Tu chi sei?”

File: poesia.txt
Io sono Nessuno! Tu chi sei?
Sei Nessuno anche tu?
Allora siamo in due!
Non dirlo! Potrebbero spargere la voce!

Che grande peso essere Qualcuno!
Così volgare — come una rana
che gracida il tuo nome — tutto giugno —
ad un pantano in estasi di lei!
Listato 12-3: Una poesia di Emily Dickinson è un buon caso di test

Con il testo inserito, modifica src/main.rs e aggiungi il codice per leggere il file, come mostrato nel Listato 12-4.

File: src/main.rs
use std::env;
use std::fs;

fn main() {
    // --taglio--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let percorso_file = &args[2];

    println!("Cerco {query}");
    println!("Nel file {percorso_file}");

    let contenuto = fs::read_to_string(percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");
}
Listato 12-4: Lettura del contenuto del file specificato dal secondo argomento

Per prima cosa introduciamo una parte rilevante della libreria standard con un’istruzione use: abbiamo bisogno di std::fs per gestire i file.

In main, la nuova istruzione fs::read_to_string prende percorso_file, apre quel file e restituisce un valore di type std::io::Result<String> che contiene il contenuto del file.

Dopodiché, aggiungiamo di nuovo un’istruzione temporanea println! che stampa il valore di contenuto dopo la lettura del file, in modo da poter verificare che il programma funzioni correttamente.

Eseguiamo questo codice con una stringa qualsiasi come primo argomento della riga di comando (perché non abbiamo ancora implementato la parte di ricerca) e il file poesia.txt come secondo argomento:

$ cargo run -- ciao poesia.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep ciao poesia.txt`
Cerco ciao
Nel file poesia.txt
Con il testo:
Io sono Nessuno! Tu chi sei?
Sei Nessuno anche tu?
Allora siamo in due!
Non dirlo! Potrebbero spargere la voce!

Che grande peso essere Qualcuno!
Così volgare — come una rana
che gracida il tuo nome — tutto giugno —
ad un pantano in estasi di lei!

Ottimo! Il codice ha letto e poi stampato il contenuto del file. Ma il codice presenta alcuni difetti. Al momento, la funzione main ha più responsabilità: in genere, le funzioni sono più chiare e facili da gestire se ogni funzione è responsabile di una sola idea. L’altro problema è che non gestiamo gli errori al meglio delle nostre possibilità. Il programma è ancora piccolo, quindi questi difetti non rappresentano un grosso problema, ma man mano che il programma cresce, sarà più difficile correggerli in modo pulito. È buona norma iniziare il refactoring fin dall’inizio quando si sviluppa un programma, perché è molto più facile risistemare piccole quantità di codice. Lo faremo come prossima cosa.

Refactoring per Migliorare Modularità e Gestione degli Errori

Per migliorare il nostro programma, risolveremo quattro problemi che riguardano la struttura del programma e la gestione di potenziali errori. Innanzitutto, la nostra funzione main ora esegue due attività: analizza gli argomenti e legge il file. Man mano che il nostro programma cresce, il numero di attività separate gestite dalla funzione main aumenterà. Man mano che una funzione acquisisce responsabilità, diventa più difficile esaminare, testare e apportare modificare senza danneggiare una delle sue parti. È meglio separare le funzionalità in modo che ogni funzione sia responsabile di un’attività.

Questo problema si collega anche al secondo problema: sebbene query e percorso_file siano variabili di configurazione del nostro programma, variabili come contenuto vengono utilizzate per eseguire la struttura logica del programma. Più main diventa lungo, più variabili dovremo includere nello scope; più variabili abbiamo nello scope, più difficile sarà tenere traccia di cosa faccia ciascuna. È meglio raggruppare le variabili di configurazione in un’unica struttura per chiarirne lo scopo.

Il terzo problema è che abbiamo usato expect per visualizzare un messaggio di errore quando la lettura del file fallisce, ma il messaggio di errore visualizza solo Dovrebbe essere stato possibile leggere il file. La lettura di un file può fallire in diversi modi: ad esempio, il file potrebbe essere mancante o potremmo non avere i permessi per aprirlo. Al momento, indipendentemente dalla situazione, visualizzeremo lo stesso messaggio di errore per tutto, il che non fornirebbe alcuna informazione all’utente!

In quarto luogo, usiamo expect per gestire un errore e, se l’utente esegue il nostro programma senza specificare argomenti sufficienti, riceverà un errore index out of bounds da Rust che non spiega chiaramente il problema. Sarebbe meglio se tutto il codice di gestione degli errori fosse in un unico posto, in modo che chi in futuro prenderà in mano il nostro codice abbia un solo posto in cui guardare se la struttura di gestione degli errori avesse bisogno di cambiamenti. Avere tutto il codice di gestione degli errori in un unico posto garantirà anche la stampa di messaggi comprensibili per gli utenti della nostra applicazione.

Affrontiamo questi quattro problemi riscrivendo il nostro progetto.

Separare Attività nei Progetti Binari

Il problema organizzativo di allocare la responsabilità di più attività alla funzione main è comune a molti progetti binari. Di conseguenza, molti programmatori Rust trovano utile suddividere le attività di un programma binario quando la funzione main inizia a diventare più grande. Questo processo prevede i seguenti passaggi:

  • Suddividere il programma in un file main.rs e un file lib.rs e spostare la logica del programma in lib.rs.
  • Finché la logica di analisi della riga di comando è piccola, può rimanere nella funzione main.
  • Quando la logica di analisi della riga di comando inizia a complicarsi, toglierla dalla funzione main e metterla in altre funzioni o type.

Le responsabilità che rimangono nella funzione main dopo questo processo dovrebbero essere limitate a quanto segue:

  • Chiamare la logica di analisi della riga di comando con i valori degli argomenti
  • Impostare qualsiasi altra configurazione
  • Chiamare una funzione esegui in lib.rs
  • Gestire l’errore se esegui restituisce un errore

Questo schema riguarda la separazione delle attività: main.rs gestisce l’esecuzione del programma e lib.rs gestisce tutta la logica dell’attività in corso. Poiché non è possibile testare direttamente la funzione main, questa struttura consente inoltre di scrivere test e quindi testare tutta la logica del programma spostandola fuori dalla funzione main. Il codice che rimane nella funzione main sarà sufficientemente piccolo da poterne verificare la correttezza leggendolo. Ristrutturiamo il nostro programma seguendo questo processo.

Estrarre il Parser degli Argomenti

Estrarremo la funzionalità per l’analisi degli argomenti in una funzione che verrà chiamata da main. Il Listato 12-5 mostra il nuovo avvio della funzione main che chiama una nuova funzione leggi_config, che definiremo in src/main.rs.

File: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, percorso_file) = leggi_config(&args);

    // --taglio--

    println!("Cerco {query}");
    println!("Nel file {percorso_file}");

    let contenuto = fs::read_to_string(percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");
}

fn leggi_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let percorso_file = &args[2];

    (query, percorso_file)
}
Listato 12-5: Estrazione di una funzione leggi_config da main

Stiamo ancora raccogliendo gli argomenti della riga di comando in un vettore, ma invece di assegnare il valore dell’argomento all’indice 1 alla variabile query e il valore dell’argomento all’indice 2 alla variabile percorso_file all’interno della funzione main, passiamo l’intero vettore alla funzione leggi_config. La funzione leggi_config contiene quindi la logica che determina quale argomento debba andare in quale variabile e restituisce i valori a main. Creiamo ancora le variabili query e percorso_file in main, ma main non ha più la responsabilità di determinare come gli argomenti e le variabili della riga di comando corrispondono.

Questa riscrittura potrebbe sembrare eccessiva per il nostro piccolo programma, ma stiamo eseguendo il refactoring in piccoli passaggi incrementali. Dopo aver apportato questa modifica, esegui nuovamente il programma per verificare che l’analisi degli argomenti funzioni ancora. È consigliabile controllare spesso i progressi per aiutare a identificare la causa dei problemi quando si verificano.

Raggruppare i Valori di Configurazione

Possiamo fare un altro piccolo passo per migliorare ulteriormente la funzione leggi_config. Al momento, restituiamo una tupla, ma poi la suddividiamo immediatamente in singole parti. Questo è un segno che forse non abbiamo ancora l’astrazione giusta.

Un altro indicatore che mostra che c’è margine di miglioramento è la parte config di leggi_config, che implica che i due valori restituiti sono correlati e fanno entrambi parte di un unico valore di configurazione. Al momento non stiamo evidenziando questo significato nella struttura dei dati se non raggruppando i due valori in una tupla; inseriremo invece i due valori in un’unica struct e daremo a ciascuno dei campi della struct un nome significativo. In questo modo, sarà più facile per i futuri manutentori di questo codice comprendere come i diversi valori si relazionano tra loro e qual è il loro scopo.

Il Listato 12-6 mostra i miglioramenti alla funzione leggi_config.

File: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = leggi_config(&args);

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    let contenuto = fs::read_to_string(config.percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    // --taglio--

    println!("Con il testo:\n{contenuto}");
}

struct Config {
    query: String,
    percorso_file: String,
}

fn leggi_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let percorso_file = args[2].clone();

    Config { query, percorso_file }
}
Listato 12-6: Refactoring di leggi_config per ritornare un’istanza di una struttura Config

Abbiamo aggiunto una struct denominata Config definita per avere campi denominati query e percorso_file. La firma di leggi_config ora indica che ritorna un valore Config. Nel corpo di leggi_config, dove prima ritornavamo slice di stringa che fanno riferimento a valori String in args, ora definiamo Config in modo che contenga valori String posseduti. La variabile args in main ha ownership dei valori degli argomenti e consente solo alla funzione leggi_config di prenderli in prestito, il che significa che violeremmo le regole di Rust sui prestiti se Config tentasse di prendere la ownership dei valori in args.

Ci sono diversi modi per gestire i dati String; il modo più semplice, anche se un po’ inefficiente, è chiamare il metodo clone sui valori. Questo creerà una copia completa dei dati per l’istanza di Config, che ne diverrà proprietaria, il che richiede più tempo e memoria rispetto alla memorizzazione di un reference ai dati stringa. Tuttavia, clonare i dati rende anche il nostro codice molto semplice perché non dobbiamo gestire la lifetime di quei reference; in questo caso, rinunciare a un po’ di prestazioni per guadagnare semplicità è un compromesso che vale la pena accettare.

I Compromessi dell’Utilizzo di Clone

Molti utenti di Rust tendono a evitare di usare clone per non incorrere in problemi di ownership a causa del suo costo di esecuzione. Nel Capitolo 13, imparerai come utilizzare metodi più efficienti in questo tipo di situazioni. Ma per ora, va bene copiare alcune stringhe per continuare, perché queste copie verranno eseguite solo una volta e il percorso del file e la stringa di query sono molto piccoli. È meglio avere un programma funzionante ma un po’ inefficiente che cercare di iper-ottimizzare il codice al primo tentativo. Man mano che acquisirai esperienza con Rust, sarà più facile iniziare con la soluzione più efficiente, ma per ora è perfettamente accettabile chiamare clone.

Abbiamo aggiornato main in modo che inserisca l’istanza di Config restituita da leggi_config in una variabile denominata config, e abbiamo aggiornato il codice che in precedenza utilizzava le variabili separate query e percorso_file, in modo che ora utilizzi i campi della struct Config.

Ora il nostro codice comunica più chiaramente che query e percorso_file sono correlati e che il loro scopo è configurare il funzionamento del programma. Qualsiasi codice che utilizza questi valori sa come trovarli nell’istanza di config nei campi denominati appositamente per il loro scopo.

Creare un Costruttore per Config

Finora, abbiamo estratto la logica responsabile dell’analisi degli argomenti della riga di comando da main e l’abbiamo inserita nella funzione leggi_config. In questo modo abbiamo visto che i valori query e percorso_file erano correlati e che questa relazione doveva essere comunicata nel nostro codice. Abbiamo quindi aggiunto una struct Config per denominare lo scopo correlato di query e percorso_file e per poter ritornare i nomi dei valori come nomi di campo della struct dalla funzione leggi_config.

Ora che lo scopo della funzione leggi_config è creare un’istanza di Config, possiamo modificare leggi_config da una semplice funzione a una funzione chiamata new associata alla struct Config. Questa modifica renderà il codice più idiomatico. Possiamo creare istanze di type nella libreria standard, come String, chiamando String::new. Allo stesso modo, modificando leggi_config in una funzione new associata a Config, saremo in grado di creare istanze di Config chiamando Config::new. Il Listato 12-7 mostra le modifiche che dobbiamo apportare.

File: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    let contenuto = fs::read_to_string(config.percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");

    // --taglio--
}

// --taglio--

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Config { query, percorso_file }
    }
}
Listato 12-7: Modifica di leggi_config in Config::new

Abbiamo cambiato in main dove veniva chiamata leggi_config con Config::new. Abbiamo cambiato la funzione leggi_config in new e spostata nel blocco impl così da essere associata a Config. Prova a compilare il codice per verificare che tutto funzioni come dovrebbe.

Migliorare il Messaggio di Errore

Nel Listato 12-8, aggiungiamo un controllo nella funzione new che verificherà che la slice sia sufficientemente lunga prima di accedere agli indici 1 e 2. Se la slice non è sufficientemente lunga, il programma va in panico e visualizza un messaggio di errore più chiaro.

File: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    let contenuto = fs::read_to_string(config.percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");
}

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    // --taglio--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("non ci sono abbastanza argomenti");
        }
        // --taglio--

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Config { query, percorso_file }
    }
}
Listato 12-8: Aggiunta di un controllo per il numero di argomenti

Questo codice è simile alla funzione Ipotesi::new che abbiamo scritto nel Listato 9-13, dove abbiamo chiamato panic! quando l’argomento valore era fuori dall’intervallo di valori validi. Invece di controllare un intervallo di valori, qui controlliamo che la lunghezza di args sia di almeno 3 e che il resto della funzione possa funzionare presupponendo che questa condizione sia stata soddisfatta. Se args ha meno di tre elementi, questa condizione sarà true e chiameremo la macro panic! per terminare immediatamente il programma.

Con queste poche righe di codice in new, eseguiamo di nuovo il programma senza argomenti per vedere come appare ora l’errore:

$ cargo run
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
non ci sono abbastanza argomenti
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Questo output è migliore: ora abbiamo un messaggio di errore ragionevole. Tuttavia, abbiamo anche informazioni estranee che non vogliamo fornire ai nostri utenti. Forse la tecnica che abbiamo usato nel Listato 9-13 non è la migliore da usare in questo contesto: una chiamata a panic! è più appropriata per un problema di programmazione che per un problema di utilizzo, come discusso nel Capitolo 9. Invece, utilizzeremo l’altra tecnica che hai imparato nel Capitolo 9: restituire un Result che indica un successo o un errore.

Restituire un Result Invece di Chiamare panic!

Possiamo invece ritornare un valore Result che conterrà un’istanza di Config nel caso di successo e descriverà il problema nel caso di errore. Cambieremo anche il nome della funzione da new a build perché è buona pratica e molti programmatori si aspettano che le funzioni new non falliscano mai. Quando Config::build comunica con main, possiamo usare il type Result per segnalare che si è verificato un problema. Possiamo quindi modificare main per convertire una variante Err in un errore più pratico per i nostri utenti, senza sporcare l’output con il testo su thread 'main' e RUST_BACKTRACE causata da una chiamata a panic!.

Il Listato 12-9 mostra le modifiche che dobbiamo apportare al valore di ritorno della funzione che stiamo chiamando Config::build e al corpo della funzione necessaria per ritornare un Result. Nota che questa funzione non verrà compilata finché non aggiorneremo anche main, cosa che faremo nel prossimo Listato.

File: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    let contenuto = fs::read_to_string(config.percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");
}

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}
Listato 12-9: Ritorno di un Result da Config::build

La nostra funzione build ritorna un Result con un’istanza di Config in caso di successo e un letterale stringa in caso di errore. I nostri valori di errore saranno sempre letterali stringa con lifetime 'static.

Abbiamo apportato due modifiche al corpo della funzione: invece di chiamare panic! quando l’utente non passa abbastanza argomenti, ora restituiamo un valore Err e abbiamo racchiuso il valore restituito da Config in un Ok. Queste modifiche rendono la funzione conforme al nuovo type ritornato.

Ritornare un valore Err da Config::build consente alla funzione main di gestire il valore Result ritornato dalla funzione build e di uscire dal processo in modo più pulito in caso di errore.

Chiamare Config::build e Gestire gli Errori

Per gestire il caso di errore e visualizzare un messaggio intuitivo, dobbiamo aggiornare main per gestire il Result ritornato da Config::build, come mostrato nel Listato 12-10. Ci assumeremo anche la responsabilità di terminare il nostro strumento da riga di comando con un codice di errore diverso da zero ma senza panic!, e lo implementeremo manualmente. Uno stato di uscita diverso da zero è una convenzione per segnalare al processo chiamante che il programma è uscito con uno stato di errore.

File: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    // --taglio--

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    let contenuto = fs::read_to_string(config.percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");
}

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}
Listato 12-10: Terminazione con un codice di errore se fallisce la creazione di Config

In questo Listato, abbiamo utilizzato un metodo che non abbiamo ancora trattato in dettaglio: unwrap_or_else, definito su Result<T, E> dalla libreria standard. L’utilizzo di unwrap_or_else ci consente di definire una gestione degli errori personalizzata, a differenza di panic!. Se Result è un valore Ok, il comportamento di questo metodo è simile a unwrap: restituisce il valore interno che Ok sta racchiudendo. Tuttavia, se il valore è un valore Err, questo metodo richiama il codice nella closure, che è una funzione anonima che definiamo e passiamo come argomento a unwrap_or_else. Tratteremo le closure (chiusure) più in dettaglio nel Capitolo 13. Per ora, è sufficiente sapere che unwrap_or_else passerà il valore interno di Err, che in questo caso è la stringa statica "Non ci sono abbastanza argomenti" che abbiamo aggiunto nel Listato 12-9, alla nostra chiusura nell’argomento err che appare tra le barre verticali. Il codice nella chiusura può quindi utilizzare il valore err durante l’esecuzione.

Abbiamo aggiunto una nuova riga use per portare process dalla libreria standard nello scope. Il codice nella chiusura che verrà eseguito in caso di errore è composto da sole due righe: stampiamo il valore err e poi chiamiamo process::exit. La funzione process::exit interromperà immediatamente il programma e ritornerà il numero che è stato passato come codice di stato di uscita. Questo è simile alla gestione basata su panic! che abbiamo usato nel Listato 12-8, ma otteniamo un output più pulito. Proviamolo:

$ cargo run
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problema nella lettura degli argomenti: Non ci sono abbastanza argomenti

Ottimo! Questo output è molto più intuitivo per i nostri utenti.

Estrarre la Logica da main

Ora che abbiamo completato il refactoring dell’analisi della configurazione, passiamo alla logica del programma. Come scritto nel paragrafo “Separare le Attività per i Progetti Binari”, estrarremo una funzione denominata esegui che conterrà tutta la logica attualmente presente nella funzione main che non è coinvolta nell’impostazione della configurazione o nella gestione degli errori. Al termine, la funzione main sarà concisa e facile da verificare tramite ispezione, e saremo in grado di scrivere test per tutta la restante logica.

Il Listato 12-11 mostra il piccolo miglioramento incrementale dell’estrazione di una funzione esegui.

File: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    // --taglio--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    esegui(config);
}

fn esegui(config: Config) {
    let contenuto = fs::read_to_string(config.percorso_file)
        .expect("Dovrebbe essere stato possibile leggere il file");

    println!("Con il testo:\n{contenuto}");
}

// --taglio--

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}
Listato 12-11: Estrazione di una funzione esegui contenente il resto della logica del programma

La funzione esegui ora contiene tutta la logica rimanente di main, a partire dalla lettura del file. La funzione esegui prende l’istanza Config come argomento.

Restituire Errori dalla Funzione esegui

Con la logica del programma rimanente separata nella funzione esegui, possiamo migliorare la gestione degli errori, come abbiamo fatto con Config::build nel Listato 12-9. Invece di lasciare che il programma vada in panico chiamando expect, la funzione esegui ritornerà Result<T, E> quando qualcosa va storto. Questo ci permetterà di consolidare ulteriormente la logica di gestione degli errori in main in modo intuitivo. Il Listato 12-12 mostra le modifiche che dobbiamo apportare alla firma e al corpo di esegui.

File: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --taglio--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    esegui(config);
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    println!("Con il testo:\n{contenuto}");

    Ok(())
}

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}
Listato 12-12: Modifica della funzione esegui per ritornare Result

Abbiamo apportato tre modifiche significative. Innanzitutto, abbiamo cambiato il type di ritorno della funzione esegui in Result<(), Box<dyn Error>>. Questa funzione in precedenza restituiva il type unitario, (), e lo manteniamo come valore restituito nel caso Ok.

Per il type di errore, abbiamo utilizzato l’oggetto trait Box<dyn Error> (e abbiamo portato std::error::Error nello scope con un’istruzione use all’inizio). Tratteremo gli oggetti trait nel Capitolo 18. Per ora, sappi solo che Box<dyn Error> significa che la funzione restituirà un type che implementa il trait Error, ma non dobbiamo specificare di quale type specifico sarà il valore restituito. Questo ci offre la flessibilità di restituire valori di errore che possono essere di type diverso in diversi casi di errore. La parola chiave dyn è l’abbreviazione di dynamic.

In secondo luogo, abbiamo rimosso la chiamata a expect a favore dell’operatore ?, come abbiamo illustrato nel Capitolo 9. Invece di panic! in caso di errore, ? ritornerà il valore di errore dalla funzione corrente affinché il chiamante possa gestirlo.

In terzo luogo, la funzione esegui ora ritorna un valore Ok in caso di successo. Abbiamo dichiarato il type di successo della funzione esegui come () nella firma, il che significa che dobbiamo racchiudere il valore del type unitario nel valore Ok. Questa sintassi Ok(()) potrebbe sembrare un po’ strana a prima vista, ma usare () in questo modo è il modo idiomatico per indicare che chiamando esegui vogliamo solo gestirne i suoi effetti collaterali; non deve restituire un valore di cui abbiamo bisogno.

Quando esegui questo codice, verrà compilato ma verrà visualizzato un avviso:

$ cargo run -- ciao poesia.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     esegui(config);
   |     ^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = esegui(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep ciao poesia.txt`
Cerca ciao
Nel file poesia.txt
Con il testo:
Io sono Nessuno! Tu chi sei?
Sei Nessuno anche tu?
Allora siamo in due!
Non dirlo! Potrebbero spargere la voce!

Che grande peso essere Qualcuno!
Così volgare — come una rana
che gracida il tuo nome — tutto giugno —
ad un pantano in estasi di lei!

Rust ci dice che il nostro codice ha ignorato il valore Result e il valore Result potrebbe indicare che si è verificato un errore. Ma non stiamo verificando se si è verificato un errore e il compilatore ci ricorda che probabilmente intendevamo inserire del codice di gestione degli errori! Risolviamo subito il problema.

Gestire gli Errori Restituiti da esegui in main

Verificheremo la presenza di errori e li gestiremo utilizzando una tecnica simile a quella utilizzata con Config::build nel Listato 12-10, ma con una leggera differenza:

File: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --taglio--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    println!("Cerco {}", config.query);
    println!("Nel file {}", config.percorso_file);

    if let Err(e) = esegui(config) {
        println!("Errore applicazione: {e}");
        process::exit(1);
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    println!("Con il testo:\n{contenuto}");

    Ok(())
}

struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}

Utilizziamo if let anziché unwrap_or_else per verificare se esegui restituisce un valore Err e per chiamare process::exit(1) in tal caso. La funzione esegui non restituisce un valore di cui abbiamo bisogno come nel caso di Config::build che restituisce l’istanza di Config. Poiché esegui restituisce () in caso di successo, ci interessa solo rilevare un errore, quindi non abbiamo bisogno di unwrap_or_else per restituire il valore estratto da Ok, che sarebbe solo ().

I corpi delle funzioni if let e unwrap_or_else sono gli stessi in entrambi i casi: stampiamo l’errore ed usciamo.

Suddividere il Codice in un Crate Libreria

Il nostro progetto minigrep sembra funzionare bene finora! Ora suddivideremo il file src/main.rs e inseriremo del codice nel file src/lib.rs. In questo modo, possiamo testare il codice e avere un file src/main.rs con meno responsabilità.

Definiamo il codice responsabile della ricerca del testo in src/lib.rs anziché in src/main.rs, il che permetterà a noi (o a chiunque altro utilizzi la nostra libreria minigrep) di chiamare la funzione di ricerca da più contesti rispetto al nostro binario minigrep.

Per prima cosa, definiamo la firma della funzione cerca in src/lib.rs come mostrato nel Listato 12-13, con un corpo che richiama la macro unimplemented!. Spiegheremo la firma più dettagliatamente quando completeremo l’implementazione.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listato 12-13: Definizione della funzione cerca in src/lib.rs

Abbiamo utilizzato la parola chiave pub nella definizione della funzione per designare cerca come parte dell’API pubblica del nostro crate libreria. Ora abbiamo un crate libreria che possiamo utilizzare dal nostro binario e che possiamo testare!

Ora dobbiamo inserire il codice definito in src/lib.rs nello scope del contenitore binario in src/main.rs e chiamarlo, come mostrato nel Listato 12-14.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --taglio--
use minigrep::cerca;

fn main() {
    // --taglio--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        println!("Errore nell'applicazione: {e}");
        process::exit(1);
    }
}

// --taglio--


struct Config {
    query: String,
    percorso_file: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    for line in cerca(&config.query, &contenuto) {
        println!("{line}");
    }

    Ok(())
}
Listato 12-14: Utilizzo della funzione cerca del crate libreria minigrep in src/main.rs

Aggiungiamo una riga use minigrep::cerca per portare la funzione cerca dal crate libreria nello scope del crate binario. Quindi, nella funzione esegui, anziché stampare il contenuto del file, chiamiamo la funzione cerca e passiamo il valore config.query e contenuto come argomenti. Quindi, esegui utilizzerà un ciclo for per stampare ogni riga restituita da cerca che corrisponde alla query. Questo è anche un buon momento per rimuovere le chiamate println! nella funzione main che visualizzava la query e il percorso del file, in modo che il nostro programma stampi solo i risultati della ricerca (se non si verificano errori).

Nota che la funzione di ricerca raccoglierà tutti i risultati in un vettore che ritornerà prima che venga stampato alcunché. Questa implementazione potrebbe essere lenta nel visualizzare i risultati quando si cercano file di grandi dimensioni, perché i risultati non vengono stampati man mano che vengono trovati; discuteremo un possibile modo per risolvere questo problema utilizzando gli iteratori nel Capitolo 13.

Wow! È stato un duro lavoro, ma ci siamo preparati per il successo futuro. Ora è molto più facile gestire gli errori e abbiamo reso il codice più modulare. Quasi tutto il nostro lavoro sarà svolto in src/lib.rs da ora in poi.

Sfruttiamo questa nuova modularità facendo qualcosa che sarebbe stato difficile con il vecchio codice, ma è facile con il nuovo: scriveremo dei test!

Aggiungere Funzionalità con il Test-Driven Development

Ora che abbiamo la logica di ricerca in src/lib.rs separata dalla funzione main, è molto più facile scrivere test per le funzionalità principali del nostro codice. Possiamo chiamare le funzioni direttamente con vari argomenti e controllare i valori di ritorno senza dover chiamare il nostro binario dalla riga di comando.

In questa sezione, aggiungeremo la logica di ricerca al programma minigrep utilizzando il processo di sviluppo guidato dai test (test-driven development, abbreviato TDD) con i seguenti passaggi:

  1. Scrivere un test che fallisce ed eseguirlo per assicurarsi che fallisca per il motivo previsto.
  2. Scrivere o modificare solo il codice necessario per far passare il nuovo test.
  3. Riscrivere il codice appena aggiunto o modificato e assicurarsi che i test continuino a passare.
  4. Ripetere dal passaggio 1!

Sebbene sia solo uno dei tanti modi per scrivere software, il TDD può aiutare a guidare la progettazione del codice. Scrivere il test prima di scrivere il codice che lo supera aiuta a mantenere un’elevata copertura dei test durante l’intero processo.

Testeremo l’implementazione della funzionalità che effettivamente eseguirà la ricerca della stringa di query nel contenuto del file e produrrà un elenco di righe che corrispondono alla query. Aggiungeremo questa funzionalità in una funzione chiamata cerca.

Scrivere un Test che Fallisce

In src/lib.rs, aggiungeremo un modulo tests con una funzione di test, come abbiamo fatto nel Capitolo 11. La funzione di test specifica il comportamento che vogliamo che abbia la funzione cerca: accetterà una query e il testo in cui cercare e ritornerà solo le righe del testo che contengono la query. Il Listato 12-15 mostra questo test.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --taglio--

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_risultato() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }
}
Listato 12-15: Creazione di un test che fallisce per la funzione cerca per la funzionalità che vorremmo implementare

Questo test cerca la stringa "dut". Il testo che stiamo cercando è composto da tre righe, solo una delle quali contiene "dut" (nota che la barra rovesciata dopo le virgolette doppie di apertura indica a Rust di non inserire un carattere di nuova linea all’inizio del contenuto di questo letterale stringa). Affermiamo che il valore restituito dalla funzione cerca contiene solo la riga che ci aspettiamo.

Se eseguiamo questo test, al momento fallirà perché la macro unimplemented! si blocca con il messaggio “not implemented” (non implementato). In conformità con i principi TDD, aggiungeremo solo il codice necessario per evitare che il test vada in panico quando si chiama la funzione, definendo la funzione cerca in modo che ritorni sempre un vettore vuoto, come mostrato nel Listato 12-16. Quindi il test dovrebbe compilare e fallire perché un vettore vuoto non corrisponde a un vettore contenente la riga "sicuro, veloce, produttivo."

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_risultato() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }
}
Listato 12-16: Definire solo una parte sufficiente della funzione cerca in modo che chiamarla non provochi panic

Ora parliamo del perché è necessario esplicitare la longevità 'a nella firma di cerca e utilizzare tale longevità con l’argomento contenuto e con il valore di ritorno. Ricorda che nel Capitolo 10 i parametri di lifetime specificano quale lifetime dell’argomento è collegata a quella del valore di ritorno. In questo caso, indichiamo che il vettore restituito deve contenere slice di stringa che fanno riferimento alla slice dell’argomento contenuto (piuttosto che all’argomento query).

In altre parole, diciamo a Rust che i dati restituiti dalla funzione cerca rimarranno validi finché saranno validi i dati passati alla funzione cerca nell’argomento contenuto. Questo è importante! I dati referenziati da una slice devono essere validi affinché il reference sia valido; se il compilatore presume che stiamo creando slice di query anziché di contenuto, eseguirà i suoi controlli di sicurezza in modo errato.

Se dimentichiamo le annotazioni di longevità e proviamo a compilare questa funzione, otterremo questo errore:

$ cargo build
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | pub fn cerca(query: &str, contenuto: &str) -> Vec<&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 `query` or `contenuto`
help: consider introducing a named lifetime parameter
  |
1 | pub fn cerca<'a>(query: &'a str, contenuto: &'a str) -> Vec<&'a str> {
  |             ++++         ++                  ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust non può sapere quale dei due parametri ci serve per l’output, quindi dobbiamo indicarlo esplicitamente. Nota che il testo di aiuto suggerisce di specificare lo stesso parametro di longevità per tutti i parametri e il type di output, il che è sbagliato! Poiché contenuto è il parametro che contiene tutto il nostro testo e vogliamo restituire le parti di quel testo che corrispondono, sappiamo che contenuto è l’unico parametro che dovrebbe essere collegato al valore di ritorno utilizzando la sintassi di longevità.

Altri linguaggi di programmazione non richiedono di collegare gli argomenti ai valori di ritorno nella firma, ma questa pratica diventerà più semplice col tempo. Potresti confrontare questo esempio con gli esempi nella sezione “Validare i Reference con la Lifetime nel Capitolo 10.

Scrivere Codice per Superare il Test

Attualmente, il nostro test fallisce perché restituisce sempre un vettore vuoto. Per risolvere il problema e implementare cerca, il nostro programma deve seguire questi passaggi:

  1. Iterare ogni riga del contenuto.
  2. Verificare che la riga contenga la nostra stringa di query.
  3. In caso affermativo, aggiungerla all’elenco dei valori ritornati.
  4. In caso contrario, non fare nulla.
  5. Ritornare l’elenco dei risultati corrispondenti.

Esaminiamo ogni passaggio, iniziando con l’iterazione delle righe.

Iterare le Righe con il Metodo lines

Rust dispone di un metodo utile per gestire l’iterazione riga per riga delle stringhe, opportunamente chiamato lines, che funziona come mostrato nel Listato 12-17. Nota che questo non verrà ancora compilato.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    for line in contenuto.lines() {
        // facciamo qualcosa con la riga
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_risultato() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }
}
Listato 12-17: Iterazione di ogni riga in contenuto

Il metodo lines restituisce un iteratore. Parleremo degli iteratori in modo approfondito nel Capitolo 13. Ma ricorda che hai visto questo modo di usare un iteratore nel Listato 3-5, dove abbiamo usato un ciclo for con un iteratore per eseguire del codice su ogni elemento di una collezione.

Ricercare la Query in Ogni Riga

Successivamente, controlleremo se la riga corrente contiene la nostra stringa di query. Fortunatamente, le stringhe hanno un metodo utile chiamato contains che fa proprio questo per noi! Aggiungiamo una chiamata al metodo contains nella funzione cerca, come mostrato nel Listato 12-18. Nota che questo non verrà ancora compilato.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    for line in contenuto.lines() {
        if line.contains(query) {
            // facciamo qualcosa con la riga
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_risultato() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }
}
Listato 12-18: Aggiunta di funzionalità per verificare se la riga contiene la stringa in query

Al momento, stiamo sviluppando la funzionalità. Per compilare il codice, dobbiamo restituire un valore dal corpo, come indicato nella firma della funzione.

Memorizzare le Righe Corrispondenti

Per completare questa funzione, abbiamo bisogno di un modo per memorizzare le righe corrispondenti che vogliamo restituire. Per farlo, possiamo creare un vettore mutabile prima del ciclo for e chiamare il metodo push per memorizzare una line nel vettore. Dopo il ciclo for, ritorniamo il vettore, come mostrato nel Listato 12-19.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    let mut risultato = Vec::new();

    for line in contenuto.lines() {
        if line.contains(query) {
            risultato.push(line);
        }
    }

    risultato
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_risultato() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }
}
Listato 12-19: Memorizzazione delle righe corrispondenti in modo da poterle restituire

Ora la funzione cerca dovrebbe restituire solo le righe che contengono query, e il nostro test dovrebbe essere superato. Eseguiamo il test:

$ cargo test
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 2.28s
     Running unittests src/lib.rs (target/debug/deps/minigrep-a16801c2a05e2817)

running 1 test
test tests::un_risultato ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-7b49a695afbb6602)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Il nostro test è stato superato, quindi sappiamo che la funzione fa quello che ci aspettiamo!

A questo punto, potremmo valutare l’opportunità di riscrivere e migliorare l’implementazione della funzione di ricerca, controllando che i test continuino a passare per mantenere la stessa funzionalità. Il codice nella funzione di ricerca non è male, ma non sfrutta alcune utili funzionalità degli iteratori. Torneremo su questo esempio nel Capitolo 13, dove esploreremo gli iteratori in dettaglio e vedremo come migliorarla.

Ora l’intero programma dovrebbe funzionare! Proviamolo, prima con una parola che dovrebbe restituire esattamente una riga della poesia di Emily Dickinson: rana.

$ cargo run -- rana poesia.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep rana poesia.txt`
Così volgare — come una rana

Fantastico! Ora proviamo una parola che corrisponda a più righe, come uno:

$ cargo run -- uno poesia.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep uno poesia.txt`
Io sono Nessuno! Tu chi sei?
Sei Nessuno anche tu?
Che grande peso essere Qualcuno!

E infine, assicuriamoci di non ottenere alcuna riga quando cerchiamo una parola che non è presente da nessuna parte nella poesia, come monomorfizzazione:

$ cargo run -- monomorfizzazione poesia.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorfizzazione poesia.txt`

Ottimo! Abbiamo creato la nostra versione in miniatura di uno strumento classico e abbiamo imparato molto su come strutturare le applicazioni. Abbiamo anche imparato qualcosa sull’input e l’output dei file, sulle lifetime, sui test e sull’analisi della riga di comando.

Per completare questo progetto, mostreremo brevemente come lavorare con le variabili d’ambiente e come stampare su standard error, entrambi utili quando si scrivono programmi da riga di comando.

Lavorare con le Variabili d’Ambiente

Miglioreremo il programma minigrep implementando una funzionalità aggiuntiva: un’opzione per la ricerca senza distinzione tra maiuscole e minuscole, che l’utente può attivare tramite una variabile d’ambiente. Potremmo rendere questa funzionalità un’opzione della riga di comando e richiedere che gli utenti la inseriscano ogni volta che desiderano applicarla, ma rendendola invece una variabile d’ambiente, consentiamo ai nostri utenti di impostare la variabile d’ambiente una sola volta e di fare in modo che tutte le loro ricerche in quella sessione di terminale siano senza distinzione (case-insensitive).

Scrivere un Test che Fallisce per la Ricerca Case-Insensitive

Aggiungiamo innanzitutto una nuova funzione cerca_case_insensitive alla libreria minigrep che verrà chiamata quando la variabile d’ambiente ha un valore. Continueremo a seguire il processo TDD, quindi il primo passo sarà di scrivere un nuovo test che fallisce. Aggiungeremo un nuovo test per la nuova funzione cerca_case_insensitive e rinomineremo il nostro vecchio test da un_risultato a case_sensitive per chiarire le differenze tra i due test, come mostrato nel Listato 12-20.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    let mut risultato = Vec::new();

    for line in contenuto.lines() {
        if line.contains(query) {
            risultato.push(line);
        }
    }

    risultato
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.
Duttilità.";

        assert_eq!(
            vec!["sicuro, veloce, produttivo."],
            cerca(query, contenuto)
        );
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.
Una frusta.";

        assert_eq!(
            vec!["Rust:", "Una frusta."],
            cerca_case_insensitive(query, contenuto)
        );
    }
}
Listato 12-20: Aggiunta di un nuovo test che fallisce per la funzione case-insensitive che stiamo per aggiungere

Nota che abbiamo modificato anche il contenuto del vecchio test. Abbiamo aggiunto una nuova riga con il testo "Duttilità." usando una D maiuscola che non dovrebbe corrispondere alla query "dut" quando effettuiamo una ricerca con distinzione tra maiuscole e minuscole. Modificare il vecchio test in questo modo ci aiuta a garantire di non interrompere accidentalmente la funzionalità di ricerca con distinzione tra maiuscole e minuscole che abbiamo già implementato. Questo test dovrebbe ora essere superato e dovrebbe continuare a essere superato mentre lavoriamo sulla ricerca senza distinzione tra maiuscole e minuscole.

Il nuovo test per la ricerca case-insensitive utilizza "rUsT" come query. Nella funzione cerca_case_insensitive che stiamo per aggiungere, la query "rUsT" dovrebbe corrispondere alla riga contenente "Rust:" con una R maiuscola e corrispondere alla riga "Una frusta." anche se entrambe differiscono dalla query. Questo è il nostro test che fallisce e non verrà compilato perché non abbiamo ancora definito la funzione cerca_case_insensitive. Sentiti libero di aggiungere un’implementazione scheletro che restituisca sempre un vettore vuoto, simile a quella che abbiamo fatto per la funzione cerca nel Listato 12-16 per verificare che il test si compili correttamente e fallisca.

Implementare la Funzione cerca_case_insensitive

La funzione cerca_case_insensitive, mostrata nel Listato 12-21, sarà quasi la stessa della funzione cerca. L’unica differenza è che metteremo in minuscolo la query e ogni line in modo che, qualunque sia il carattere maiuscolo/minuscolo degli argomenti di input, saranno gli stessi quando controlleremo se la riga contiene la query.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    let mut risultato = Vec::new();

    for line in contenuto.lines() {
        if line.contains(query) {
            risultato.push(line);
        }
    }

    risultato
}

pub fn cerca_case_insensitive<'a>(
    query: &str,
    contenuto: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut risultato = Vec::new();

    for line in contenuto.lines() {
        if line.to_lowercase().contains(&query) {
            risultato.push(line);
        }
    }

    risultato
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.
Duttilità.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.
Una frusta.";

        assert_eq!(
            vec!["Rust:", "Una frusta."],
            cerca_case_insensitive(query, contenuto)
        );
    }
}
Listato 12-21: Definizione della funzione cerca_case_insensitive per rendere minuscole la query e la riga prima di confrontarle

Per prima cosa, rendiamo minuscola la stringa query e la memorizziamo in una nuova variabile con lo stesso nome, adombrando la query originale. La chiamata a to_lowercase sulla query è necessaria affinché, indipendentemente dal fatto che la query dell’utente sia "rust", "RUST", "Rust" o "rUsT", la query verrà trattata come se fosse "rust" e non sarà case-sensitive. Sebbene to_lowercase gestisca Unicode di base, non sarà accurato al 100%. Se stessimo scrivendo un’applicazione reale, dovremmo lavorare un po’ di più qui, ma questa sezione riguarda le variabili d’ambiente, non Unicode, quindi ci fermeremo qui.

Nota che query ora è una String anziché una slice di stringa, perché la chiamata a to_lowercase crea nuovi dati anziché fare reference a dati esistenti. Supponiamo che la query sia "rUsT", ad esempio: quella slice non contiene una u o una t minuscola da utilizzare, quindi dobbiamo allocare una nuova String contenente "rust". Quando passiamo query come argomento al metodo contains ora, dobbiamo aggiungere una & (e commerciale) perché la firma di contains è definita per accettare una slice di stringa.

Successivamente, aggiungiamo una chiamata a to_lowercase su ogni line per convertire in minuscolo tutti i caratteri della riga su cui stiamo facendo la ricerca. Ora che abbiamo convertito line e query in minuscolo, troveremo corrispondenze indipendentemente dalle maiuscole e dalle minuscole della query.

Vediamo se questa implementazione supera i test:

$ cargo test
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Ottimo! Hanno superato i test. Ora, chiamiamo la nuova funzione cerca_case_insensitive dalla funzione esegui. Per prima cosa, aggiungeremo un’opzione di configurazione alla struttura Config per passare dalla ricerca case-sensitive a quella case-insensitive. L’aggiunta di questo campo causerà errori di compilazione perché non lo stiamo ancora inizializzando da nessuna parte:

File: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

// --taglio--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        println!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}

Abbiamo aggiunto il campo ignora_maiuscole che contiene un valore booleano. Successivamente, abbiamo bisogno della funzione esegui per controllare il valore del campo ignora_maiuscole e utilizzarlo per decidere se chiamare la funzione cerca o la funzione cerca_case_insensitive come mostrato nel Listato 12-22. Questo non verrà ancora compilato.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

// --taglio--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        println!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        Ok(Config { query, percorso_file })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 12-22: Chiamata cerca o cerca_case_insensitive in base al valore in config.ignora_maiuscole

Infine, dobbiamo verificare la variabile d’ambiente. Le funzioni per lavorare con le variabili d’ambiente si trovano nel modulo env della libreria standard, che è già nello scope all’inizio di src/main.rs. Useremo la funzione var del modulo env per verificare se è stato impostato un valore per una variabile d’ambiente denominata IGNORA_MAIUSCOLE, come mostrato nel Listato 12-23.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        println!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 12-23: Controllo di qualsiasi valore in una variabile d’ambiente denominata IGNORA_MAIUSCOLE

Qui creiamo una nuova variabile, ignora_maiuscole. Per impostarne il valore, chiamiamo la funzione env::var e le passiamo il nome della variabile d’ambiente IGNORA_MAIUSCOLE. La funzione env::var restituisce un Result che sarà la variante Ok corretta che contiene il valore della variabile d’ambiente se la variabile d’ambiente è impostata su un valore qualsiasi. Restituirà la variante Err se la variabile d’ambiente non è impostata.

Stiamo utilizzando il metodo is_ok su Result per verificare se la variabile d’ambiente è impostata, il che significa che il programma dovrebbe eseguire una ricerca senza distinzione tra maiuscole e minuscole. Se la variabile d’ambiente IGNORA_MAIUSCOLE non è impostata, is_ok restituirà false e il programma eseguirà una ricerca facendo distinzione tra maiuscole e minuscole. Non ci interessa il valore della variabile d’ambiente, ma solo se è impostata o meno, quindi usare is_ok è sufficiente in questo caso anziché utilizzare unwrap, expect o uno qualsiasi degli altri metodi che abbiamo visto su Result.

Passiamo il valore nella variabile ignora_maiuscole all’istanza Config in modo che la funzione esegui possa leggere quel valore e decidere se chiamare cerca_case_insensitive o cerca, come abbiamo implementato nel Listato 12-22.

Proviamo! Per prima cosa eseguiamo il nostro programma senza la variabile d’ambiente impostata e con la query che, che dovrebbe corrispondere a qualsiasi riga che contenga la parola che in minuscolo:

$ cargo run -- che poesia.txt
   Compiling minigrep v0.1.0 (file:///progetti/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.45s
     Running `target/debug/minigrep che poesia.txt`
Sei Nessuno anche tu?
che gracida il tuo nome — tutto giugno —

Sembra che funzioni ancora! Ora eseguiamo il programma con IGNORA_MAIUSCOLE impostato a 1 ma con la stessa query che:

$ IGNORA_MAIUSCOLE=1 cargo run -- che poesia.txt

Se si utilizza PowerShell, sarà necessario impostare la variabile d’ambiente ed eseguire il programma con comandi separati:

PS> $Env:IGNORA_MAIUSCOLE=1; cargo run -- che poesia.txt

Questo farà sì che IGNORA_MAIUSCOLE persista per il resto della sessione shell. Può essere annullato con il cmdlet Remove-Item:

PS> Remove-Item Env:IGNORA_MAIUSCOLE

Dovremmo ottenere righe che contengono che e che potrebbero contenere lettere maiuscole:

Sei Nessuno anche tu?
Che grande peso essere Qualcuno!
che gracida il tuo nome — tutto giugno —

Eccellente, abbiamo trovato anche le righe contenenti C maiuscolo! Il nostro programma minigrep ora può effettuare ricerche case-insensitive, controllate da una variabile d’ambiente. Ora sai come gestire le opzioni impostate utilizzando argomenti della riga di comando o variabili d’ambiente.

Alcuni programmi consentono argomenti e variabili d’ambiente per la stessa configurazione. In questi casi, i programmi decidono che l’uno o l’altro abbia la precedenza. Come esercizio e per sperimentare un po’, prova a controllare la distinzione tra maiuscole e minuscole tramite un argomento della riga di comando o una variabile d’ambiente. Decidi se l’argomento della riga di comando o la variabile d’ambiente debbano avere la precedenza se il programma viene eseguito con uno impostato case-sensitive e l’altro impostato come case-insensitive.

Il modulo std::env contiene molte altre utili funzionalità per gestire le variabili d’ambiente: consulta la sua documentazione per scoprire quali sono disponibili.

Scrivere i Messaggi di Errore su Standard Error

Al momento, stiamo stampando tutto il nostro output sul terminale usando la macro println!. Nella maggior parte dei terminali, esistono due tipi di output: standard output (stdout) per informazioni generali e standard error (stderr) per i messaggi di errore. Questa distinzione consente agli utenti di scegliere di indirizzare l’output corretto di un programma a un file, ma di visualizzare comunque i messaggi di errore sullo schermo.

La macro println! è in grado di stampare solo sullo standard output, quindi dobbiamo usare qualcos’altro per stampare sullo standard error.

Controllare dove Vengono Scritti gli Errori

Per prima cosa osserviamo come il contenuto stampato da minigrep viene attualmente scritto sullo standard output, inclusi eventuali messaggi di errore che vorremmo invece scrivere sullo standard error. Lo faremo reindirizzando il flusso di standard output a un file, causando intenzionalmente un errore. Non reindirizzeremo il flusso di standard error, quindi qualsiasi contenuto inviato allo standard error continuerà a essere visualizzato sullo schermo.

Ci si aspetta che i programmi a riga di comando inviino messaggi di errore al flusso di standard error, in modo da poterli comunque visualizzare sullo schermo anche se reindirizziamo il flusso di output standard a un file. Il nostro programma al momento non si comporta bene: stiamo per vedere che salverà l’output del messaggio di errore in un file!

Per dimostrare questo comportamento, esegui il programma con > e il percorso del file, output.txt, a cui vogliamo reindirizzare il flusso di standard output. Non passeremo alcun argomento, il che dovrebbe causare un errore:

$ cargo run > output.txt

La sintassi > indica alla shell di scrivere il contenuto dello standard output su output.txt invece che sullo schermo. Non abbiamo visto il messaggio di errore che ci aspettavamo di visualizzare sullo schermo, quindi significa che deve essere finito nel file. Ecco cosa contiene output.txt:

Problema nella lettura degli argomenti: non ci sono abbastanza argomenti

Sì, il nostro messaggio di errore viene visualizzato sullo standard output. È molto più utile che messaggi di errore come questo vengano visualizzati sullo standard error, in modo che solo i dati di un’esecuzione senza errori finiscano nel file. Cambieremo questa impostazione.

Visualizzazione degli Errori sullo Standard Error

Useremo il codice del Listato 12-24 per modificare la modalità di visualizzazione dei messaggi di errore. A causa del refactoring effettuato in precedenza in questo capitolo, tutto il codice che visualizza i messaggi di errore si trova in un’unica funzione, main. La libreria standard fornisce la macro eprintln! che stampa sullo standard error, quindi modifichiamo i due punti in cui chiamavamo println! per visualizzare gli errori in modo che utilizzino eprintln! al loro posto.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        eprintln!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 12-24: Scrittura di messaggi di errore sullo standard error anziché sullo standard output usando eprintln!

Ora eseguiamo di nuovo il programma nello stesso modo, senza argomenti e reindirizzando lo standard output con >:

$ cargo run > output.txt
Problema nella lettura degli argomenti: non ci sono abbastanza argomenti

Ora vediamo l’errore sullo schermo e output.txt non contiene nulla, che è il comportamento che ci aspettiamo dai programmi a riga di comando.

Eseguiamo di nuovo il programma con argomenti che non causano errori ma che comunque reindirizziamo l’output standard a un file, in questo modo:

$ cargo run -- che poesia.txt > output.txt

Non vedremo alcun output sul terminale e output.txt conterrà i nostri risultati:

File: output.txt

Sei Nessuno anche tu?
che gracida il tuo nome — tutto giugno —

Questo dimostra che ora stiamo utilizzando lo standard output per l’output corretto e lo standard error per l’output dei messaggi di errore, a seconda dei casi.

Riepilogo

Questo capitolo ha messo in pratica alcuni dei concetti principali appresi finora e ha spiegato come eseguire operazioni di I/O comuni in Rust. Utilizzando argomenti della riga di comando, file, variabili d’ambiente e la macro eprintln! per la stampa degli errori, ora sei pronto a scrivere applicazioni da riga di comando. In combinazione con i concetti dei capitoli precedenti, il codice sarà ben organizzato, memorizzerà i dati in modo efficace nelle strutture dati appropriate, gestirà gli errori in modo efficiente e sarà ben testato.

Andando avanti, esploreremo alcune funzionalità di Rust ispirate dai linguaggi funzionali: closure (chiusure) e iteratori.

Funzionalità dei Linguaggi Funzionali: Iteratori e Chiusure

Il design di Rust si è ispirato a molti linguaggi e tecniche esistenti, e un’influenza significativa è la programmazione funzionale. La programmazione in stile funzionale spesso include l’utilizzo di funzioni come valori, passandole come argomenti, restituendole da altre funzioni, assegnandole a variabili per l’esecuzione successiva e così via.

In questo capitolo, non discuteremo la questione di cosa sia o non sia la programmazione funzionale, ma discuteremo invece alcune caratteristiche di Rust simili a caratteristiche di molti linguaggi spesso definiti funzionali.

Più specificamente, tratteremo:

  • Chiusure (Closures), un costrutto simile a una funzione che puoi memorizzare in una variabile
  • Iteratori, un modo per elaborare una serie di elementi
  • Come usare chiusure e iteratori per migliorare il progetto I/O del Capitolo 12
  • Le prestazioni di chiusure e iteratori (spoiler: sono più veloci di quanto possiate pensare!)

Abbiamo già trattato altre funzionalità di Rust, come il pattern matching e le enum, che sono anch’esse influenzate dallo stile funzionale. Poiché padroneggiare chiusure e iteratori è una parte importante della scrittura di codice Rust veloce e idiomatico, gli dedicheremo l’intero capitolo.

Chiusure

Le chiusure (closure) di Rust sono funzioni anonime che è possibile salvare in una variabile o passare come argomenti ad altre funzioni. È possibile creare la chiusura in un punto e poi chiamarla altrove per valutarla in un contesto diverso. A differenza delle funzioni, le chiusure possono catturare valori dallo scope in cui sono definite. Dimostreremo come queste funzionalità di chiusura consentano il riutilizzo del codice e la personalizzazione del comportamento.

Catturare l’Ambiente

Esamineremo innanzitutto come possiamo utilizzare le chiusure per catturare valori dall’ambiente in cui sono definite per un uso successivo. Ecco lo scenario: ogni tanto, la nostra azienda di magliette regala una maglietta esclusiva in edizione limitata a qualcuno nella nostra mailing list come promozione. Gli utenti della mailing list possono facoltativamente aggiungere il loro colore preferito al proprio profilo. Se la persona a cui viene assegnata una maglietta gratuita ha impostato il suo colore preferito, riceverà la maglietta di quel colore. Se la persona non ha specificato un colore preferito, riceverà il colore di cui l’azienda ha attualmente la maggiore disponibilità.

Ci sono molti modi per implementarlo. Per questo esempio, useremo un’enum chiamata ColoreMaglietta che ha le varianti Rosso e Blu (limitando il numero di colori disponibili per semplicità). Rappresentiamo l’inventario dell’azienda con una struct Inventario che ha un campo denominato magliette che contiene un Vec<ColoreMaglietta> che rappresenta i colori delle magliette attualmente disponibili in magazzino. Il metodo regalo definito su Inventario ottiene la preferenza opzionale per il colore della maglietta del vincitore della maglietta gratuita e restituisce il colore della maglietta che la persona riceverà. Questa configurazione è mostrata nel Listato 13-1.

File: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ColoreMaglietta {
    Rosso,
    Blu,
}

struct Inventario {
    magliette: Vec<ColoreMaglietta>,
}

impl Inventario {
    fn regalo(&self, preferenze_utente: Option<ColoreMaglietta>) -> ColoreMaglietta {
        preferenze_utente.unwrap_or_else(|| self.maggior_stock())
    }

    fn maggior_stock(&self) -> ColoreMaglietta {
        let mut num_rosso = 0;
        let mut num_blu = 0;

        for colore in &self.magliette {
            match colore {
                ColoreMaglietta::Rosso => num_rosso += 1,
                ColoreMaglietta::Blu => num_blu += 1,
            }
        }
        if num_rosso > num_blu {
            ColoreMaglietta::Rosso
        } else {
            ColoreMaglietta::Blu
        }
    }
}

fn main() {
    let negozio = Inventario {
        magliette: vec![ColoreMaglietta::Blu, ColoreMaglietta::Rosso, ColoreMaglietta::Blu],
    };

    let pref_utente1 = Some(ColoreMaglietta::Rosso);
    let regalo1 = negozio.regalo(pref_utente1);
    println!(
        "L'utente con preferenza {:?} riceve {:?}",
        pref_utente1, regalo1
    );

    let pref_utente2 = None;
    let regalo2 = negozio.regalo(pref_utente2);
    println!(
        "L'utente con preferenza {:?} riceve {:?}",
        pref_utente2, regalo2
    );
}
Listato 13-1: Situazione di un’azienda di magliette che deve fare un regalo

Il negozio definito in main ha due magliette blu e una rossa rimanenti da distribuire per questa promozione in edizione limitata. Chiamiamo il metodo regalo per un utente con preferenza per una maglietta rossa e un utente senza alcuna preferenza.

Anche in questo caso, questo codice potrebbe essere implementato in molti modi e, per concentrarci sulle chiusure, ci siamo attenuti ai concetti che hai già imparato, ad eccezione del corpo del metodo regalo che utilizza una chiusura. Nel metodo regalo, otteniamo la preferenza dell’utente come parametro di type Option<ColoreMaglietta> e chiamiamo il metodo unwrap_or_else su preferenza_utente. Il metodo unwrap_or_else su Option<T> è definito dalla libreria standard. Accetta un argomento: una chiusura senza argomenti che restituisce un valore T (lo stesso type memorizzato nella variante Some di Option<T>, in questo caso ColoreMaglietta). Se Option<T> è la variante Some, unwrap_or_else restituisce il valore presente all’interno di Some. Se Option<T> è la variante None , unwrap_or_else chiama la chiusura e restituisce il valore restituito dalla chiusura.

Specifichiamo l’espressione di chiusura || self.maggior_stock() come argomento di unwrap_or_else. Questa è una chiusura che non accetta parametri (se la chiusura avesse parametri, questi apparirebbero tra le due barre verticali). Il corpo della chiusura chiama self.maggior_stock(). Stiamo definendo la chiusura qui, e l’implementazione di unwrap_or_else valuterà la chiusura in seguito, se il risultato è necessario.

L’esecuzione di questo codice stampa quanto segue:

$ cargo run
   Compiling azienda-magliette v0.1.0 (file:///progetti/azienda-magliette)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.07s
     Running `target/debug/azienda-magliette`
L'utente con preferenza Some(Rosso) riceve Rosso
L'utente con preferenza None riceve Blu

Un aspetto interessante è che abbiamo passato una chiusura che chiama self.maggior_stock() sull’istanza corrente di Inventario. La libreria standard non aveva bisogno di sapere nulla sui type Inventario o ColoreMaglietta che abbiamo definito, né sulla logica che vogliamo utilizzare in questo scenario. La chiusura cattura un reference immutabile all’istanza self di Inventario e lo passa con il codice che specifichiamo al metodo unwrap_or_else. Le funzioni, d’altra parte, non sono in grado di catturare il loro ambiente in questo modo.

Inferenza e Annotazione del Type Delle Chiusure

Esistono ulteriori differenze tra funzioni e chiusure. Le chiusure di solito non richiedono di annotare i type dei parametri o dei valori di ritorno, come fanno le funzioni fn. Le annotazioni del type sono necessarie sulle funzioni perché i type fanno parte di un’interfaccia esplicita esposta agli utenti. Definire rigidamente questa interfaccia è importante per garantire che tutti concordino sui tipi di valori che una funzione utilizza e restituisce. Le chiusure, d’altra parte, non vengono utilizzate in un’interfaccia esposta come questa: vengono memorizzate in variabili e utilizzate senza denominarle ed esporle agli utenti della nostra libreria.

Le chiusure sono in genere brevi e rilevanti solo in un contesto ristretto, piuttosto che in uno scenario arbitrario. In questi contesti limitati, il compilatore può dedurre i type dei parametri e il type restituito, in modo simile a come è in grado di dedurre i type della maggior parte delle variabili (ci sono rari casi in cui il compilatore necessita di annotazioni del type anche per le chiusure).

Come per le variabili, possiamo aggiungere annotazioni del type se vogliamo aumentare l’esplicitezza e la chiarezza, a costo di essere più prolissi del necessario. L’annotazione dei type per una chiusura sarebbe simile alla definizione mostrata nel Listato 13-2. In questo esempio, definiamo una chiusura e la memorizziamo in una variabile, anziché definirla nel punto in cui la passiamo come argomento, come abbiamo fatto nel Listato 13-1.

File: src/main.rs
use std::thread;
use std::time::Duration;

fn genera_allenamento(intensità: u32, numero_casuale: u32) {
    let chiusura_lenta = |num: u32| -> u32 {
        println!("calcolo lentamente...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensità < 25 {
        println!("Oggi, fai {} flessioni!", chiusura_lenta(intensità));
        println!("Poi, fai {} piegamenti!", chiusura_lenta(intensità));
    } else {
        if numero_casuale == 3 {
            println!("Oggi fai una pausa! Ricordati di idratarti!");
        } else {
            println!(
                "Oggi, corri per {} minuti!",
                chiusura_lenta(intensità)
            );
        }
    }
}

fn main() {
    let simulazione_numero_utente = 10;
    let simulazione_numero_casuale = 7;

    genera_allenamento(simulazione_numero_utente, simulazione_numero_casuale);
}
Listato 13-2: Aggiunta di annotazioni facoltative dei type di parametro e valore di ritorno nella chiusura

Con l’aggiunta delle annotazioni del type, la sintassi delle chiusure appare più simile alla sintassi delle funzioni. Qui, per confronto, definiamo una funzione che aggiunge 1 al suo parametro e una chiusura che ha lo stesso comportamento. Abbiamo aggiunto alcuni spazi per allineare le parti rilevanti. Questo illustra come la sintassi delle chiusure sia simile a quella delle funzioni, fatta eccezione per l’uso delle barre verticali e per la quantità di sintassi che è facoltativa:

fn  agg_uno_v1   (x: u32) -> u32 { x + 1 }
let agg_uno_v2 = |x: u32| -> u32 { x + 1 };
let agg_uno_v3 = |x|             { x + 1 };
let agg_uno_v4 = |x|               x + 1  ;

La prima riga mostra una definizione di funzione e la seconda una definizione di chiusura completamente annotata. Nella terza riga, rimuoviamo le annotazioni del type dalla definizione della chiusura. Nella quarta riga, rimuoviamo le parentesi, che sono facoltative perché il corpo della chiusura ha una sola espressione. Queste sono tutte definizioni valide che produrranno lo stesso comportamento quando vengono chiamate. Le righe agg_uno_v3 e agg_uno_v4 richiedono che le chiusure vengano valutate per essere compilabili, poiché i type verranno dedotti dal loro utilizzo. Questo è simile a let v = Vec::new(); che richiede annotazioni del type o valori di qualche tipo da inserire in Vec affinché Rust possa dedurne il type.

Per le definizioni delle chiusure, il compilatore dedurrà un type concreto per ciascuno dei loro parametri e per il loro valore di ritorno. Ad esempio, il Listato 13-3 mostra la definizione di una chiusura breve che restituisce semplicemente il valore ricevuto come parametro. Questa chiusura non è molto utile, se non per gli scopi di questo esempio. Nota che non abbiamo aggiunto alcuna annotazione del type alla definizione. Poiché non ci sono annotazioni, possiamo chiamare la chiusura con qualsiasi type, come abbiamo fatto qui con String la prima volta. Se poi proviamo a chiamare esempio_chiusura con un intero, otterremo un errore.

File: src/main.rs
fn main() {
    let esempio_chiusura = |x| x;

    let s = esempio_chiusura(String::from("ciao"));
    let n = esempio_chiusura(5);
}
Listato 13-3: Tentativo di chiamare una chiusura i cui type sono inferiti con due type diversi

Il compilatore ci dà questo errore:

$ cargo run
   Compiling esempio-chiusura v0.1.0 (file:///progetti/esempio_chiusura)
error[E0308]: mismatched types
 --> src/main.rs:6:30
  |
6 |     let n = esempio_chiusura(5);
  |             ---------------- ^ expected `String`, found integer
  |             |
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:5:30
  |
5 |     let s = esempio_chiusura(String::from("ciao"));
  |             ---------------- ^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:3:29
  |
3 |     let esempio_chiusura = |x| x;
  |                             ^
help: try using a conversion method
  |
6 |     let n = esempio_chiusura(5.to_string());
  |                               ++++++++++++

For more information about this error, try `rustc --explain E0308`.
error: could not compile `esempio_chiusura` (bin "esempio_chiusura") due to 1 previous error

La prima volta che chiamiamo esempio_chiusura con il valore String, il compilatore deduce che il type di x e il type di ritorno della chiusura siano String. Questi type vengono quindi bloccati nella chiusura in esempio_chiusura e si verifica un errore di type quando si tenta nuovamente di utilizzare un type diverso con la stessa chiusura.

Catturare i Reference o Trasferire la Ownership

Le chiusure possono catturare valori dal loro ambiente in tre modi, che corrispondono direttamente ai tre modi in cui una funzione può accettare un parametro: un prestito immutabile, un prestito mutabile o prendendo la ownership. La chiusura deciderà quale di questi utilizzare in base a ciò che il corpo della funzione fa con i valori catturati.

Nel Listato 13-4, definiamo una chiusura che cattura un reference immutabile al vettore denominato lista perché necessita solo di un riferimento immutabile per stampare il valore.

File: src/main.rs
fn main() {
    let lista = vec![1, 2, 3];
    println!("Prima di definire la chiusura: {lista:?}");

    let solo_prestito = || println!("Dalla chiusura: {lista:?}");

    println!("Prima di chiamare la chiusura: {lista:?}");
    solo_prestito();
    println!("Dopo aver chiamato la chiusura: {lista:?}");
}
Listato 13-4: Definizione e chiamata di una chiusura che cattura un reference immutabile

Questo esempio illustra anche che una variabile può essere associata a una definizione di chiusura, e che possiamo successivamente chiamare la chiusura utilizzando il nome della variabile e le parentesi come se il nome della variabile fosse il nome di una funzione.

Poiché possiamo avere più reference immutabili a lista contemporaneamente, lista è comunque accessibile dal codice prima della definizione della chiusura, dopo la definizione della chiusura ma prima che la chiusura venga chiamata, e dopo che la chiusura viene chiamata. Questo codice si compila, esegue e stampa:

$ cargo run
   Compiling esempio-chiusura v0.1.0 (file:///progetti/esempio-chiusura)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.09s
     Running `target/debug/esempio-chiusura`
Prima di definire la chiusura: [1, 2, 3]
Prima di chiamare la chiusura: [1, 2, 3]
Dalla chiusura: [1, 2, 3]
Dopo aver chiamato la chiusura: [1, 2, 3]

Successivamente, nel Listato 13-5, modifichiamo il corpo della chiusura in modo che aggiunga un elemento al vettore list. La chiusura ora cattura un reference mutabile.

File: src/main.rs
fn main() {
    let mut lista = vec![1, 2, 3];
    println!("Prima di definire la chiusura: {lista:?}");

    let mut prestito_mutabile = || lista.push(7);

    prestito_mutabile();
    println!("Dopo aver chiamato la chiusura: {lista:?}");
}
Listato 13-5: Definizione e chiamata di una chiusura che cattura un reference mutabile

Questo codice si compila, esegue e stampa:

$ cargo run
   Compiling esempio-chiusura v0.1.0 (file:///progetti/esempio-chiusura)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.08s
     Running `target/debug/esempio-chiusura`
Prima di definire la chiusura: [1, 2, 3]
Dopo aver chiamato la chiusura: [1, 2, 3, 7]

Nota che non c’è più println! tra la definizione e la chiamata della chiusura prestito_mutabile: quando prestito_mutabile è definita, cattura un reference mutabile a lista. Non usiamo più la chiusura dopo che è stata chiamata, quindi il prestito mutabile termina. Tra la definizione della chiusura e la chiamata alla chiusura, non è consentito un prestito immutabile per stampare perché, quando c’è un prestito mutabile, non sono consentiti altri prestiti. Prova ad aggiungere println! per vedere quale messaggio di errore ottieni!

Se vuoi forzare la chiusura ad assumere la ownership dei valori che usa nell’ambiente, anche se il corpo della chiusura non ne ha strettamente bisogno, puoi usare la parola chiave move prima dell’elenco dei parametri.

Questa tecnica è utile soprattutto quando si passa una chiusura a un nuovo thread per spostare i dati in modo che siano di proprietà del nuovo thread. Discuteremo i thread e perché dovreste utilizzarli in dettaglio nel Capitolo 16, quando parleremo di concorrenza, ma per ora, esploriamo brevemente la creazione di un nuovo thread utilizzando una chiusura che richiede la parola chiave move. Il Listato 13-6 mostra il Listato 13-4 modificato per stampare il vettore in un nuovo thread anziché nel thread principale.

File: src/main.rs
use std::thread;

fn main() {
    let lista = vec![1, 2, 3];
    println!("Prima di definire la chiusura: {lista:?}");

    thread::spawn(move || println!("Dal thread: {lista:?}"))
        .join()
        .unwrap();
}
Listato 13-6: Utilizzo di move per forzare la chiusura affinché il thread prenda la ownership di lista

Generiamo un nuovo thread, assegnandogli una chiusura da eseguire come argomento. Il corpo della chiusura stampa la lista. Nel Listato 13-4, la chiusura catturava solo lista utilizzando un reference immutabile, perché questo rappresenta il minimo accesso a lista necessario per stamparlo. In questo esempio, anche se il corpo della chiusura richiede ancora solo un reference immutabile, dobbiamo specificare che lista debba essere spostato nella chiusura inserendo la parola chiave move all’inizio della definizione della chiusura. Se il thread principale eseguisse più operazioni prima di chiamare join sul nuovo thread, il nuovo thread potrebbe terminare prima del thread principale, oppure il thread principale potrebbe terminare per primo. Se il thread principale mantenesse la ownership di lista ma terminasse prima del nuovo thread e de-allocasse la memoria di lista, il reference immutabile nel thread non sarebbe valido. Pertanto, il compilatore richiede che lista venga spostato nella chiusura assegnata al nuovo thread, affinché il reference sia valido. Prova a rimuovere la parola chiave move o a utilizzare lista nel thread principale dopo la definizione della chiusura per vedere quali errori del compilatore ottieni!

Restituire i Valori Catturati dalle Chiusure

Una volta che una chiusura ha catturato un reference o preso la ownership di un valore nell’ambiente in cui è definita (influenzando quindi cosa, se presente, viene spostato all’interno della chiusura), il codice nel corpo della chiusura definisce cosa succede ai reference o ai valori quando la chiusura viene valutata in seguito (influenzando quindi cosa, se presente, viene spostato fuori dalla chiusura).

Il corpo di una chiusura può eseguire una delle seguenti operazioni: spostare un valore catturato fuori dalla chiusura, mutare il valore catturato, non spostare né mutare il valore, oppure non catturare nulla dall’ambiente fin dall’inizio.

Il modo in cui una chiusura cattura e gestisce i valori dell’ambiente influenza quali trait implementa la chiusura, e i trait sono il modo in cui funzioni e struct possono specificare quali tipi di chiusure possono utilizzare. Le chiusure implementeranno automaticamente uno, due o tutti e tre questi trait Fn, in modo additivo, a seconda di come il corpo della chiusura gestisce i valori:

  • FnOnce si applica alle chiusure che possono essere chiamate una sola volta. Tutte le chiusure implementano almeno questo trait perché tutte le chiusure possono essere chiamate. Una chiusura che sposta i valori catturati fuori dal suo corpo implementerà solo FnOnce e nessuno degli altri tratti Fn perché può essere chiamata una sola volta.
  • FnMut si applica alle chiusure che non spostano i valori catturati fuori dal loro corpo, ma che potrebbero mutarli. Queste chiusure possono essere chiamate più di una volta.
  • Fn si applica alle chiusure che non spostano i valori catturati fuori dal loro corpo e che non mutano i valori catturati, così come alle chiusure che non catturano nulla dal loro ambiente. Queste chiusure possono essere chiamate più di una volta senza mutare il loro ambiente, il che è importante in casi come quando una chiusura viene chiamata più volte contemporaneamente.

Diamo un’occhiata alla definizione del metodo unwrap_or_else su Option<T> che abbiamo usato nel Listato 13-1:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

Ricorda che T è il type generico che rappresenta il type del valore nella variante Some di un’Option. Quel T è anche il type restituito dalla funzione unwrap_or_else: il codice che chiama unwrap_or_else su un’Option<String>, ad esempio, otterrà una String.

Nota inoltre che la funzione unwrap_or_else ha il parametro di type generico aggiuntivo F. F è il type del parametro denominato f, che è la chiusura che forniamo quando chiamiamo unwrap_or_else.

Il vincolo di trait specificato sul type generico F è FnOnce() -> T, il che significa che F deve poter essere chiamato una sola volta, non accettare argomenti e restituire una T. L’utilizzo di FnOnce nel vincolo del trait esprime il limite che unwrap_or_else non chiamerà f più di una volta. Nel corpo di unwrap_or_else, possiamo vedere che se Option è Some, f non verrà chiamata. Se Option è None, f verrà chiamata una volta. Poiché tutte le chiusure implementano FnOnce, unwrap_or_else accetta tutti e tre i tipi di chiusure ed è il più flessibile possibile.

Nota: se ciò che vogliamo fare non richiede l’acquisizione di un valore dall’ambiente, possiamo usare il nome di una funzione anziché una chiusura quando abbiamo bisogno di qualcosa che implementi uno dei trait Fn. Ad esempio, su un valore Option<Vec<T>>, potremmo chiamare unwrap_or_else(Vec::new) per ottenere un nuovo vettore vuoto se il valore è None. Il compilatore implementa automaticamente qualsiasi dei trait Fn applicabile per una definizione di funzione.

Ora diamo un’occhiata al metodo della libreria standard sort_by_key, definito sulle slice, per vedere in che modo differisce da unwrap_or_else e perché sort_by_key utilizza FnMut invece di FnOnce come vincolo del trait. La chiusura riceve un argomento sotto forma di reference all’elemento corrente nella slice in esame e restituisce un valore di type K che può essere ordinato. Questa funzione è utile quando si desidera ordinare una slice in base a un particolare attributo di ciascun elemento. Nel Listato 13-7, abbiamo un elenco di istanze di Rettangolo e utilizziamo sort_by_key per ordinarle in base al loro attributo larghezza dal più stretto al più largo.

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let mut lista = [
        Rettangolo { larghezza: 10, altezza: 1 },
        Rettangolo { larghezza: 3, altezza: 5 },
        Rettangolo { larghezza: 7, altezza: 12 },
    ];

    lista.sort_by_key(|r| r.larghezza);
    println!("{lista:#?}");
}
Listato 13-7: Utilizzo di sort_by_key per ordinare i rettangoli in base alla larghezza

Questo codice stampa:

$ cargo run
   Compiling rettangoli v0.1.0 (file:///progetti/rettangoli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.15s
     Running `target/debug/rettangoli`
[
    Rettangolo {
        larghezza: 3,
        altezza: 5,
    },
    Rettangolo {
        larghezza: 7,
        altezza: 12,
    },
    Rettangolo {
        larghezza: 10,
        altezza: 1,
    },
]

Il motivo per cui sort_by_key è definito per accettare una chiusura FnMut è che chiama la chiusura più volte: una volta per ogni elemento nella slice. La chiusura |r| r.larghezza non cattura, modifica o sposta nulla dal suo ambiente, quindi soddisfa i requisiti del vincolo di trait.

Al contrario, il Listato 13-8 mostra un esempio di una chiusura che implementa solo il trait FnOnce, perché sposta un valore fuori dall’ambiente. Il compilatore non ci permette di usare questa chiusura con sort_by_key.

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let mut lista = [
        Rettangolo { larghezza: 10, altezza: 1 },
        Rettangolo { larghezza: 3, altezza: 5 },
        Rettangolo { larghezza: 7, altezza: 12 },
    ];

    let mut azioni_ordinamento = vec![];
    let valore = String::from("chiusura chiamata");

    lista.sort_by_key(|r| {
        azioni_ordinamento.push(valore);
        r.larghezza
    });
    println!("{lista:#?}");
}
Listato 13-8: Tentativo di usare una chiusura FnOnce con sort_by_key

Questo è un modo artificioso e contorto (che non funziona) per provare a contare il numero di volte in cui sort_by_key chiama la chiusura durante l’ordinamento di lista. Questo codice tenta di effettuare questo conteggio inserendo valore, una String dall’ambiente della chiusura, nel vettore azioni_ordinamento. La chiusura cattura valore e quindi sposta valore fuori dalla chiusura trasferendo la ownership di valore al vettore azioni_ordinamento. Questa chiusura può essere chiamata una sola volta; se provi a chiamarla una seconda volta non funzionerebbe perché valore non sarebbe più nell’ambiente da inserire nuovamente in azioni_ordinamento! Pertanto, questa chiusura implementa solo FnOnce. Quando proviamo a compilare questo codice, otteniamo questo errore che indica che valore non può essere spostato fuori dalla chiusura perché la chiusura deve implementare FnMut:

$ cargo run
   Compiling rettangoli v0.1.0 (file:///progetti/rettangoli)
error[E0507]: cannot move out of `valore`, a captured variable in an `FnMut` closure
   --> src/main.rs:18:33
   |
15 |     let valore = String::from("chiusura chiamata");
   |         ------ captured outer variable
16 |
17 |     lista.sort_by_key(|r| {
   |                       --- captured by this `FnMut` closure
18 |         azioni_ordinamento.push(valore);
   |                                 ^^^^^^ move occurs because `valore` has type `String`, which does not implement the `Copy` trait
   |
help: consider cloning the value if the performance cost is acceptable
   |
18 |         azioni_ordinamento.push(valore.clone());
   |                                       ++++++++

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rettangoli` (bin "rettangoli") due to 1 previous error

L’errore punta alla riga nel corpo della chiusura che sposta valore fuori dall’ambiente. Per risolvere questo problema, dobbiamo modificare il corpo della chiusura in modo che non sposti valori fuori dall’ambiente. Mantenere un contatore nell’ambiente e incrementarne il valore nel corpo della chiusura è il modo più semplice per contare il numero di volte in cui la chiusura viene chiamata. La chiusura nel Listato 13-9 funziona con sort_by_key perché cattura solo un reference mutabile al contatore numero_azioni_ordinamento e può quindi essere chiamata più volte.

File: src/main.rs
#[derive(Debug)]
struct Rettangolo {
    larghezza: u32,
    altezza: u32,
}

fn main() {
    let mut lista = [
        Rettangolo { larghezza: 10, altezza: 1 },
        Rettangolo { larghezza: 3, altezza: 5 },
        Rettangolo { larghezza: 7, altezza: 12 },
    ];  

    let mut numero_azioni_ordinamento = 0;
    lista.sort_by_key(|r| {
        numero_azioni_ordinamento += 1;
        r.larghezza
    });
    println!("{lista:#?}, ordinato in {numero_azioni_ordinamento} azioni");
}
Listato 13-9: È consentito l’utilizzo di una chiusura FnMut con sort_by_key

I trait Fn sono importanti quando si definiscono o si utilizzano funzioni o type che fanno uso di chiusure. Nella prossima sezione, parleremo degli iteratori. Molti metodi iteratori accettano argomenti chiusura, quindi tieni a mente questi dettagli sulle chiusure mentre proseguiamo!

Elaborare una Serie di Elementi con Iteratori

Il modello dell’iteratore consente di eseguire un’attività su una sequenza di elementi a turno. Un iteratore è responsabile della logica di iterazione su ciascun elemento e di determinare quando la sequenza è terminata. Quando si utilizzano gli iteratori, non è necessario re-implementare questa logica da soli.

In Rust, gli iteratori sono lazy1 (pigri), il che significa che non hanno effetto finché non si chiamano metodi che consumano l’iteratore per utilizzarlo. Ad esempio, il codice nel Listato 13-10 crea un iteratore sugli elementi nel vettore v1 chiamando il metodo iter definito su Vec<T>. Questo codice di per sé non fa nulla di utile.

File: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listato 13-10: Creazione di un iteratore

L’iteratore è memorizzato nella variabile v1_iter. Una volta creato un iteratore, possiamo utilizzarlo in diversi modi. Nel Listato 3-5, abbiamo iterato su un array utilizzando un ciclo for per eseguire del codice su ciascuno dei suoi elementi. In pratica, questo creava e poi consumava implicitamente un iteratore, ma finora abbiamo omesso come funziona in pratica.

Nell’esempio del Listato 13-11, separiamo la creazione dell’iteratore dal suo utilizzo nel ciclo for. Quando il ciclo for viene chiamato utilizzando l’iteratore in v1_iter, ogni elemento dell’iteratore viene utilizzato in un’iterazione del ciclo, che stampa ciascun valore.

File: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Ottenuto: {val}");
    }
}
Listato 13-11: Utilizzo di un iteratore in un ciclo for

Nei linguaggi che non hanno iteratori forniti dalle loro librerie standard, probabilmente scriveresti questa stessa funzionalità inizializzando una variabile all’indice 0, usando quella variabile per indicizzare il vettore per ottenere un valore e incrementando il valore della variabile in un ciclo fino a raggiungere il numero totale di elementi nel vettore.

Gli iteratori gestiscono tutta questa logica per te, riducendo il codice ripetitivo che potrebbe potenzialmente creare errori. Gli iteratori offrono maggiore flessibilità nell’utilizzare la stessa logica con molti tipi diversi di sequenze, non solo con strutture dati in cui puoi indicizzare, come i vettori. Esaminiamo come riescono a farlo.

Il Trait Iterator e il Metodo next

Tutti gli iteratori implementano un trait chiamato Iterator definito nella libreria standard. La definizione del trait è la seguente:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // metodi con implementazioni predefinite tralasciati
}
}

Nota che questa definizione utilizza una sintassi che non abbiamo mai visto: type Item e Self::Item, che definiscono un type associato (associated type) a questo trait. Parleremo approfonditamente dei type associati nel Capitolo 20. Per ora, tutto ciò che devi sapere è che questo codice afferma che l’implementazione del trait Iterator richiede anche la definizione di un type Item, e questo type Item viene utilizzato nel type di ritorno del metodo next. In altre parole, il type Item sarà il type restituito dall’iteratore.

Il trait Iterator richiede agli implementatori di definire un solo metodo: il metodo next, che restituisce un elemento dell’iteratore alla volta, racchiuso in Some, e, al termine dell’iterazione, restituisce None.

Possiamo chiamare direttamente il metodo next sugli iteratori; il Listato 13-12 mostra quali valori vengono restituiti da chiamate ripetute a next sull’iteratore creato dal vettore.

File: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn dimostrazione_iteratore() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listato 13-12: Chiamata del metodo next su un iteratore

Nota che è stato necessario rendere v1_iter mutabile: chiamare il metodo next su un iteratore modifica lo stato interno che l’iteratore utilizza per tenere traccia della propria posizione nella sequenza. In altre parole, questo codice consuma, o esaurisce, l’iteratore. Ogni chiamata a next consuma un elemento dall’iteratore. Non era necessario rendere v1_iter mutabile quando abbiamo usato un ciclo for, perché il ciclo prendeva ownership di v1_iter e lo rendeva mutabile in background.

Nota inoltre che i valori ottenuti dalle chiamate a next sono reference immutabili ai valori nel vettore. Il metodo iter produce un iteratore su reference immutabili. Se vogliamo creare un iteratore che prende ownership di v1 e restituisce i valori posseduti, possiamo chiamare into_iter invece di iter. Allo stesso modo, se vogliamo iterare su reference mutabili, possiamo chiamare iter_mut invece di iter.

Metodi che Consumano l’Iteratore

Il trait Iterator ha diversi metodi con implementazioni predefinite fornite dalla libreria standard; è possibile scoprire di più su questi metodi consultando la documentazione API della libreria standard per il trait Iterator. Alcuni di questi metodi chiamano il metodo next nella loro definizione, motivo per cui è necessario implementare il metodo next quando si implementa il trait Iterator su un proprio type.

I metodi che chiamano next sono chiamati consumatori (consuming adapters), perché chiamandoli si consuma l’iteratore. Un esempio è il metodo sum, che prende ownership dell’iteratore e itera attraverso gli elementi chiamando ripetutamente next, consumandolo. Durante l’iterazione, aggiunge ogni elemento a un totale parziale e restituisce il totale al termine dell’iterazione. Il Listato 13-13 contiene un test che illustra l’uso del metodo sum.

File: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn somma_con_iteratore() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let totale: i32 = v1_iter.sum();

        assert_eq!(totale, 6);
    }
}
Listato 13-13: Chiamata del metodo sum per ottenere il totale di tutti gli elementi nell’iteratore

Non è consentito utilizzare v1_iter dopo la chiamata a sum perché sum prende ownership dell’iteratore su cui viene chiamato.

Metodi che Producono Altri Iteratori

Gli adattatori di iteratore (iterator adapter) sono metodi definiti sul trait Iterator che non consumano l’iteratore. Invece, producono iteratori diversi modificando qualche aspetto dell’iteratore originale.

Il Listato 13-14 mostra un esempio di chiamata del metodo dell’adattatore map, che accetta una chiusura per chiamare ogni elemento durante l’iterazione. Il metodo map restituisce un nuovo iteratore che produce gli elementi modificati. La chiusura qui crea un nuovo iteratore in cui ogni elemento del vettore verrà incrementato di 1.

File: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listato 13-14: Chiamata dell’adattatore map per creare un nuovo iteratore

Tuttavia, questo codice genera un avviso:

$ cargo run
   Compiling iteratori v0.1.0 (file:///progetti/iteratori)
warning: unused `Map` that must be used
 --> src/main.rs:5:5
  |
5 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
5 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iteratori` (bin "iteratori") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running `target/debug/iteratori`

Il codice nel Listato 13-14 non fa nulla; la chiusura che abbiamo specificato non viene mai chiamata. L’avviso ci ricorda il motivo: gli adattatori sono lazy e qui dobbiamo consumare l’iteratore.

Per correggere questo avviso e consumare l’iteratore, useremo il metodo collect, che abbiamo usato con env::args nel Listato 12-1. Questo metodo consuma l’iteratore e raccoglie i valori risultanti in un collezione di type appropriato.

Nel Listato 13-15, raccogliamo i risultati dell’iterazione sull’iteratore restituito dalla chiamata a map in un vettore. Questo vettore finirà per contenere ogni elemento del vettore originale, incrementato di 1.

File: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listato 13-15: Chiamata del metodo map per creare un nuovo iteratore, quindi chiamata del metodo collect per consumare il nuovo iteratore e creare un vettore

Poiché map accetta una chiusura, possiamo specificare qualsiasi operazione desideriamo eseguire su ciascun elemento. Questo è un ottimo esempio di come le chiusure consentano di personalizzare alcuni comportamenti, riutilizzando al contempo il comportamento di iterazione fornito dal trait Iterator.

È possibile concatenare più chiamate agli adattatori per eseguire azioni complesse in modo leggibile. Tuttavia, poiché tutti gli iteratori sono lazy, è necessario chiamare uno dei metodi consumatori per ottenere risultati dalle chiamate agli adattatori.

Chiusure che Catturano il Loro Ambiente

Molti adattatori accettano le chiusure come argomenti e, di solito, le chiusure che specificheremo come argomenti degli adattatori saranno chiusure che catturano il loro ambiente.

Per questo esempio, useremo il metodo filter che accetta una chiusura. La chiusura riceve un elemento dall’iteratore e restituisce un valore bool. Se la chiusura restituisce true, il valore verrà incluso nell’iterazione prodotta da filter. Se la chiusura restituisce false, il valore non verrà incluso.

Nel Listato 13-16, utilizziamo filter con una chiusura che cattura la variabile misura_scarpe dal suo ambiente per iterare su una collezione di istanze della struct Scarpa . Restituirà solo le scarpe della taglia specificata.

File: src/lib.rs
#[derive(PartialEq, Debug)]
struct Scarpa {
    misura: u32,
    stile: String,
}

fn misura_scarpe(scarpe: Vec<Scarpa>, misura_scarpa: u32) -> Vec<Scarpa> {
    scarpe.into_iter().filter(|s| s.misura == misura_scarpa).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filtra_per_misura() {
        let scarpe = vec![
            Scarpa {
                misura: 10,
                stile: String::from("sneaker"),
            },
            Scarpa {
                misura: 13,
                stile: String::from("sandalo"),
            },
            Scarpa {
                misura: 10,
                stile: String::from("scarpone"),
            },
        ];

        let della_mia_misura = misura_scarpe(scarpe, 10);

        assert_eq!(
            della_mia_misura,
            vec![
                Scarpa {
                    misura: 10,
                    stile: String::from("sneaker")
                },
                Scarpa {
                    misura: 10,
                    stile: String::from("scarpone")
                },
            ]
        );
    }
}
Listato 13-16: Utilizzo del metodo filter con una chiusura che cattura misura_scarpa

La funzione misura_scarpe prende ownership di un vettore di scarpe e una taglia di scarpa come parametri. Restituisce un vettore contenente solo scarpe della taglia specificata.

Nel corpo di misura_scarpe, chiamiamo into_iter per creare un iteratore che prende ownership del vettore. Quindi chiamiamo filter per adattare quell’iteratore in un nuovo iteratore che contiene solo elementi per i quali la chiusura restituisce true.

La chiusura cattura il parametro misura_scarpa dall’ambiente e confronta il valore con la taglia di ogni scarpa, mantenendo solo le scarpe della taglia specificata. Infine, la chiamata a collect raccoglie i valori restituiti dall’iteratore adattato in un vettore restituito dalla funzione.

Il test mostra che quando chiamiamo misura_scarpe, otteniamo solo le scarpe che hanno la stessa taglia del valore specificato.


  1. Lazy su wikipedia (ita)

Migliorare il Nostro Progetto I/O

Con queste nuove conoscenze sugli iteratori, possiamo migliorare il progetto I/O del Capitolo 12 utilizzando gli iteratori per rendere alcuni punti del codice più chiari e concisi. Vediamo come gli iteratori possono migliorare l’implementazione della funzione Config::build e della funzione cerca.

Rimuovere clone Utilizzando un Iteratore

Nel Listato 12-6, abbiamo aggiunto del codice che prendeva una slice di valori String e creava un’istanza della struct Config indicizzando nella slice e clonando i valori, consentendo alla struct Config di avere ownership di tali valori. Nel Listato 13-17, abbiamo riprodotto l’implementazione della funzione Config::build così com’era nel Listato 12-23.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        println!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 13-17: Riproduzione della funzione Config::build dal Listato 12-23

Allora, avevamo detto di non preoccuparci delle chiamate inefficienti a clone perché le avremmo rimosse in futuro. Bene, quel momento è arrivato!

Qui ci serviva clone perché abbiamo una slice con elementi String nel parametro args, ma la funzione build non ha ownership su args. Per restituire la ownership di un’istanza di Config, abbiamo dovuto clonare i valori dai campi query e percorso_file di Config in modo che l’istanza di Config possa possederne i valori.

Grazie alle nostre nuove conoscenze sugli iteratori, possiamo modificare la funzione build per prendere la ownership di un iteratore come argomento invece di prendere in prestito una slice. Utilizzeremo la funzionalità dell’iteratore invece del codice che controlla la lunghezza della slice e la indicizza in posizioni specifiche. Questo chiarirà cosa fa la funzione Config::build, perché l’iteratore accederà ai valori.

Una volta che Config::build assume la ownership dell’iteratore e smette di utilizzare le operazioni di indicizzazione che prendono in prestito, possiamo spostare i valori String dall’iteratore a Config anziché chiamare clone ed effettuare una nuova allocazione.

Utilizzare Direttamente l’Iteratore Restituito

Apri il file src/main.rs del tuo progetto I/O, che dovrebbe apparire così:

File: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    // --taglio--

    if let Err(e) = esegui(config) {
        eprintln!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}

Per prima cosa modifichiamo l’inizio della funzione main che avevamo nel Listato 12-24 con il codice nel Listato 13-18, che questa volta utilizza un iteratore. Questo non verrà compilato finché non aggiorneremo anche Config::build.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    // --taglio--

    if let Err(e) = esegui(config) {
        eprintln!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 13-18: Passaggio del valore restituito da env::args a Config::build

La funzione env::args restituisce un iteratore! Invece di raccogliere i valori dell’iteratore in un vettore e poi passare una slice a Config::build, ora passiamo la ownership dell’iteratore restituito da env::args direttamente a Config::build.

Dobbiamo quindi aggiornare la definizione di Config::build. Modifichiamo la firma di Config::build in modo che assomigli al Listato 13-19. Questo non verrà comunque ancora compilato, perché dobbiamo aggiornare il corpo della funzione.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        eprintln!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --taglio--
        if args.len() < 3 {
            return Err("non ci sono abbastanza argomenti");
        }

        let query = args[1].clone();
        let percorso_file = args[2].clone();

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 13-19: Aggiornamento della firma di Config::build per aspettarsi un iteratore

La documentazione della libreria standard per la funzione env::args mostra che il tipo di iteratore restituito è std::env::Args, e che tale type implementa il trait Iterator e restituisce valori String.

Abbiamo aggiornato la firma della funzione Config::build in modo che il parametro args abbia un type generico con i vincoli del trait impl Iterator<Item = String> invece di &[String]. Questo utilizzo della sintassi impl Trait, discusso nella sezione “Usare i Trait come Parametri” del Capitolo 10, significa che args può essere qualsiasi type che implementi il trait Iterator e che restituisca elementi String.

Poiché stiamo prendendo la ownership di args e lo muteremo iterandolo, possiamo aggiungere la parola chiave mut nella specifica del parametro args per renderlo mutabile.

Utilizzare i Metodi del Trait Iterator

Successivamente, correggeremo il corpo di Config::build. Poiché args implementa il trait Iterator, sappiamo di poter chiamare il metodo next su di esso! Il Listato 13-20 aggiorna il codice del Listato 12-23 per utilizzare il metodo next.

File: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{cerca, cerca_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problema nella lettura degli argomenti: {err}");
        process::exit(1);
    });

    if let Err(e) = esegui(config) {
        eprintln!("Errore dell'applicazione: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub percorso_file: String,
    pub ignora_maiuscole: bool,
}

impl Config {
    fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Stringa Query non fornita"),
        };

        let percorso_file = match args.next() {
            Some(arg) => arg,
            None => return Err("Percorso file non fornito"),
        };

        let ignora_maiuscole = env::var("IGNORA_MAIUSCOLE").is_ok();

        Ok(Config {
            query,
            percorso_file,
            ignora_maiuscole,
        })
    }
}

fn esegui(config: Config) -> Result<(), Box<dyn Error>> {
    let contenuto = fs::read_to_string(config.percorso_file)?;

    let risultato = if config.ignora_maiuscole {
        cerca_case_insensitive(&config.query, &contenuto)
    } else {
        cerca(&config.query, &contenuto)
    };

    for line in risultato {
        println!("{line}");
    }

    Ok(())
}
Listato 13-20: Modifica del corpo di Config::build per utilizzare i metodi iteratori

Ricorda che il primo valore restituito da env::args è il nome del programma. Vogliamo ignorarlo e passare al valore successivo, quindi prima chiamiamo next e non facciamo nulla con il valore restituito. Poi chiamiamo next per ottenere il valore che vogliamo inserire nel campo query di Config. Se next restituisce Some, usiamo match per estrarre il valore. Se restituisce None, significa che non sono stati forniti abbastanza argomenti e restituiamo subito un valore Err. Facciamo la stessa cosa per il valore percorso_file.

Chiarire il Codice con gli Adattatori

Possiamo anche sfruttare gli iteratori nella funzione cerca nel nostro progetto di I/O, che è riprodotta qui nel Listato 13-21 come nel Listato 12-19.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    let mut risultato = Vec::new();

    for line in contenuto.lines() {
        if line.contains(query) {
            risultato.push(line);
        }
    }

    risultato
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_risultato() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }
}
Listato 13-21: L’implementazione della funzione cerca del Listato 12-19

Possiamo scrivere questo codice in modo più conciso utilizzando gli adattatori. In questo modo evitiamo anche di avere un vettore mutabile risultato. Lo stile di programmazione funzionale preferisce ridurre al minimo la quantità di stato mutabile per rendere il codice più chiaro. La rimozione dello stato mutabile potrebbe consentire un miglioramento futuro per far sì che la ricerca avvenga in parallelo, poiché non dovremmo gestire l’accesso simultaneo al vettore risultati. Il Listato 13-22 mostra questa modifica.

File: src/lib.rs
pub fn cerca<'a>(query: &str, contenuto: &'a str) -> Vec<&'a str> {
    contenuto
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn cerca_case_insensitive<'a>(
    query: &str,
    contenuto: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut risultato = Vec::new();

    for line in contenuto.lines() {
        if line.to_lowercase().contains(&query) {
            risultato.push(line);
        }
    }

    risultato
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "dut";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.
Duttilità.";

        assert_eq!(vec!["sicuro, veloce, produttivo."], cerca(query, contenuto));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contenuto = "\
Rust:
sicuro, veloce, produttivo.
Scegline tre.
Una frusta.";

        assert_eq!(
            vec!["Rust:", "Una frusta."],
            cerca_case_insensitive(query, contenuto)
        );
    }
}
Listato 13-22: Utilizzo degli adattatori nell’implementazione della funzione cerca

Ricorda che lo scopo della funzione cerca è restituire tutte le righe in contenuto che contengono la query. Analogamente all’esempio filter nel Listato 13-16, questo codice utilizza l’adattatore filter per conservare solo le righe per le quali line.contains(query) restituisce true. Quindi raccogliamo le righe corrispondenti in un altro vettore con collect. Molto più semplice! Sentiti libero di apportare la stessa modifica utilizzando i metodi adattatori anche nella funzione cerca_case_insensitive.

Per un ulteriore miglioramento, restituisci un iteratore dalla funzione cerca rimuovendo la chiamata a collect e modificando il type di ritorno in impl Iterator<Item = &'a str> in modo che la funzione diventi essa stessa un adattatore. Nota che dovrai anche aggiornare i test! Prova ad utilizzare lo strumento minigrep per cercare in un file di grandi dimensioni prima e dopo aver apportato questa modifica ed osserva la differenza di comportamento. Prima di questa modifica, il programma non visualizzava alcun risultato finché non aveva raccolto tutti i risultati, ma dopo la modifica, i risultati verranno visualizzati man mano che viene trovata ogni riga corrispondente, perché il ciclo for nella funzione esegui è in grado di sfruttare “la pigrizia” (laziness) dell’iteratore.

Scegliere tra Cicli e Iteratori

La domanda logica successiva è quale stile scegliere nel proprio codice e perché: l’implementazione originale nel Listato 13-21 o la versione che utilizza gli iteratori nel Listato 13-22 (supponendo che stiamo raccogliendo tutti i risultati prima di restituirli piuttosto che restituire l’iteratore). La maggior parte dei programmatori Rust preferisce usare lo stile iteratore. È un po’ più difficile da capire all’inizio, ma una volta che si è presa familiarità con i vari adattatori e con il loro funzionamento, gli iteratori possono essere più facili da capire. Invece di armeggiare con i vari pezzi del ciclo e creare nuovi vettori, il codice si concentra sull’obiettivo di alto livello del ciclo. Questo astrae parte del codice più comune, rendendo più facile comprendere i concetti specifici di questo codice, come la condizione di filtro che ogni elemento dell’iteratore deve soddisfare.

Ma le due implementazioni sono davvero equivalenti? L’ipotesi intuitiva potrebbe essere che il ciclo di livello inferiore sia più veloce. Parliamo di prestazioni.

Prestazioni di Cicli e Iteratori

Per determinare se utilizzare cicli o iteratori, è necessario sapere quale implementazione è più veloce: la versione della funzione cerca con un ciclo for esplicito o la versione con iteratori.

Abbiamo eseguito un benchmark caricando l’intero contenuto di Le avventure di Sherlock Holmes di Sir Arthur Conan Doyle in una String e cercando la parola the nel contenuto. Ecco i risultati del benchmark sulla versione di cerca che utilizza il ciclo for e sulla versione che utilizza gli iteratori:

test bench_cerca_for  ... bench: 19.620.300 ns/iter (+/- 915.700)
test bench_cerca_iter ... bench: 19.234.900 ns/iter (+/- 657.200)

Le due implementazioni hanno prestazioni simili! Non spiegheremo il codice del benchmark qui perché lo scopo non è dimostrare che le due versioni siano equivalenti, ma avere un’idea generale di come queste due implementazioni si confrontino in termini di prestazioni.

Per un benchmark più completo, si consiglia di utilizzare testi di varie dimensioni come contenuto, parole diverse e parole di lunghezza diversa come query e tutti i tipi di altre variazioni che ti vengono in mente. Il punto è questo: gli iteratori, sebbene siano un’astrazione di alto livello, vengono compilati all’incirca nello stesso codice che si otterrebbe scrivendo il codice di livello inferiore. Gli iteratori sono una delle astrazioni a costo zero di Rust. Con questo intendiamo che l’utilizzo dell’astrazione non impone alcun costo prestazionale aggiuntivo (overhead) in fase di esecuzione. Questo è analogo a come Bjarne Stroustrup, il progettista e implementatore originale del C++, definisce zero-overhead nella sua presentazione a ETAPS 2012 “Foundations of C++”:

In generale, le implementazioni del C++ obbediscono al principio di zero-overhead: ciò che non usi, non paghi. E inoltre: ciò che usi, non potresti scriverlo meglio a mano.

In molti casi, il codice Rust che utilizza gli iteratori viene compilato nello stesso assembly che scriveresti a mano. Ottimizzazioni come lo srotolamento dei cicli e l’eliminazione dei limiti di controllo sull’accesso agli array si applicano e rendono il codice risultante estremamente efficiente. Ora che lo sai, puoi usare iteratori e chiusure senza timore! Fanno sembrare il codice di livello superiore, ma non impongono una penalizzazione alle prestazioni in fase di esecuzione.

Riepilogo

Chiusure e iteratori sono funzionalità di Rust ispirate alle idee dei linguaggi di programmazione funzionale. Contribuiscono alla capacità di Rust di esprimere chiaramente idee di alto livello con prestazioni di basso livello. Le implementazioni di chiusure e iteratori sono tali da non compromettere le prestazioni in fase di esecuzione. Questo fa parte dell’obiettivo di Rust di fornire astrazioni a costo zero.

Ora che abbiamo migliorato l’espressività del nostro progetto I/O, diamo un’occhiata ad altre funzionalità di cargo che ci aiuteranno a condividere il progetto con il mondo.

Maggiori informazioni su Cargo e Crates.io

Finora abbiamo utilizzato solo le funzioni più basilari di Cargo per costruire, eseguire e testare il nostro codice, ma può fare molto di più. In questo capitolo parleremo di alcune delle sue funzioni più avanzate per mostrarti come fare quanto segue:

  • Personalizzare la tua build tramite i profili di rilascio.
  • Pubblicare le librerie su crates.io.
  • Organizzare progetti di grandi dimensioni con gli spazi di lavoro.
  • Installare i binari da crates.io.
  • Estendere Cargo usando comandi personalizzati.

Cargo può fare molto di più di quanto descritto in questo capitolo: per una spiegazione completa di tutte le sue caratteristiche, consulta la sua documentazione.

Personalizzare le Build con i Profili di Rilascio

In Rust, i profili di rilascio (release profiles) sono profili predefiniti, personalizzabili con diverse configurazioni che permettono al programmatore di avere un maggiore controllo sulle varie opzioni di compilazione del codice. Ogni profilo è configurato in modo indipendente dagli altri.

Cargo ha due profili principali: il profilo dev che Cargo utilizza quando esegui cargo build e il profilo release che Cargo utilizza quando esegui cargo build --release. Il profilo dev è definito con buoni valori predefiniti per lo sviluppo, mentre il profilo release ha buoni valori predefiniti per le build di rilascio.

I nomi di questi profili potrebbero esserti familiari dall’output che hai visto finora quando compilavi il tuo codice:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

I profili dev e release sono i diversi profili utilizzati dal compilatore.

Cargo ha delle impostazioni predefinite per ogni profilo che si applicano quando non hai aggiunto esplicitamente alcuna sezione [profile.*] nel file Cargo.toml del progetto. Aggiungendo le sezioni [profile.*] per ogni profilo che vuoi personalizzare, puoi sovrascrivere qualsiasi sottoinsieme delle impostazioni predefinite. Ad esempio, ecco i valori predefiniti per l’impostazione opt-level per i profili dev e release:

File: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

L’impostazione opt-level controlla il numero di ottimizzazioni che Rust applicherà al tuo codice, con un range che va da 0 a 3. L’applicazione di più ottimizzazioni allunga i tempi di compilazione, quindi se sei in fase di sviluppo e compili spesso il tuo codice, vorrai meno ottimizzazioni per compilare più velocemente anche se il codice risultante avrà prestazioni non ottimali. Il livello opt-level predefinito per dev è quindi 0. Quando sei pronto a rilasciare il tuo codice, è meglio dedicare più tempo alla compilazione. Compilerai in modalità release una sola volta, ma eseguirai il programma compilato molte volte, quindi la modalità release scambia un tempo di compilazione più lungo con un eseguibile più prestazionale. Ecco perché opt-level predefinito per il profilo release è 3.

Puoi sovrascrivere un’impostazione predefinita aggiungendo un valore diverso nel file Cargo.toml. Ad esempio, se vogliamo utilizzare il livello di ottimizzazione 1 nel profilo di sviluppo, possiamo aggiungere queste due righe al file Cargo.toml del nostro progetto:

File: Cargo.toml

[profile.dev]
opt-level = 1

Questo codice sovrascrive l’impostazione predefinita di 0. Ora, quando lanciamo cargo build, Cargo utilizzerà le impostazioni predefinite per il profilo dev più la nostra personalizzazione di opt-level. Poiché abbiamo impostato opt-level a 1, Cargo applicherà un maggior numero di ottimizzazioni rispetto a quelle predefinite, ma non così tante come in una build di rilascio.

Per l’elenco completo delle opzioni di configurazione e dei valori predefiniti per ogni profilo, consulta la documentazione di Cargo.

Pubblicare un Crate su Crates.io

Abbiamo utilizzato i pacchetti di crates.io come dipendenze del nostro progetto, ma puoi anche condividere il tuo codice con altre persone pubblicando i tuoi pacchetti. Il registro dei crate di crates.io distribuisce il codice sorgente dei tuoi pacchetti, quindi ospita principalmente codice open source.

Rust e Cargo hanno delle funzioni che rendono il tuo pacchetto pubblicato più facile da trovare e da usare. Parleremo di alcune di queste funzioni e poi spiegheremo come pubblicare un pacchetto.

Nota di traduzione: Quando si realizza documentazione è buona pratica che sia scritta in un linguaggio internazionale come l’inglese. In questo capitolo si è deciso di tradurre anche la documentazione del codice in italiano per facilitare la comprensione, ma se vorrai pubblicare codice e la relativa documentazione è consigliabile farlo in inglese.

Commentare il Codice a Fini di Documentazione

Documentare accuratamente i tuoi pacchetti aiuterà gli altri utenti a sapere come e quando usarli, quindi vale la pena investire del tempo per scrivere la documentazione. Nel Capitolo 3 abbiamo parlato di come commentare il codice di Rust usando due barre, //. Rust ha anche un particolare tipo di commento per la documentazione, noto come commento di documentazione, che genererà la documentazione HTML. L’HTML mostra il contenuto dei commenti di documentazione per gli elementi API pubblici destinati ai programmatori interessati a sapere come usare il tuo crate piuttosto che come il tuo crate è implementato.

I commenti di documentazione utilizzano tre barre, ///, invece di due e supportano la notazione Markdown per la formattazione del testo. Posiziona i commenti di documentazione subito prima dell’elemento che stanno documentando. Il Listato 14-1 mostra i commenti di documentazione per una funzione più_uno in un crate chiamato mio_crate

File: src/lib.rs
/// Aggiunge uno al numero dato.
///
/// # Esempi
///
/// ```
/// let arg = 5;
/// let risposta = mio_crate::più_uno(arg);
///
/// assert_eq!(6, risposta);
/// ```
pub fn più_uno(x: i32) -> i32 {
    x + 1
}
Listato 14-1: Un commento di documentazione per una funzione

Qui diamo una descrizione di cosa fa la funzione più_uno, iniziamo una sezione con l’intestazione Esempi (Examples) e poi forniamo del codice che dimostra come utilizzare la funzione più_uno. Possiamo generare la documentazione HTML da questo commento di documentazione eseguendo cargo doc. Questo comando esegue lo strumento rustdoc distribuito con Rust e mette la documentazione HTML generata nella cartella target/doc.

Per comodità, eseguendo cargo doc --open si costruisce l’HTML della documentazione del tuo crate attuale (così come la documentazione di tutte le dipendenze del tuo crate) e si apre il risultato in un browser web. Naviga alla funzione più_uno per vedere che il testo nei commenti di documentazione apparirà come mostrato nella Figura 14-1.

Documentazione HTML
renderizzata per la funzione `più_uno` di `mio_crate`

Figura 14-1: La documentazione HTML per la funzione più_uno

Sezioni Comunemente Utilizzate

Abbiamo usato l’intestazione Markdown # Esempi nel Listato 14-1 per creare una sezione nell’HTML con il titolo “Esempi”. Ecco altre sezioni che gli autori di crate utilizzano comunemente nella loro documentazione:

  • Panic (Panics): Questi sono gli scenari in cui la funzione documentata potrebbe andare in panic. I chiamanti della funzione che non vogliono che i loro programmi vadano in panic devono assicurarsi di non chiamare la funzione in queste situazioni.
  • Errori (Errors): Se la funzione restituisce un Result, descrivere la tipologia di errori che potrebbero verificarsi e quali condizioni potrebbero causare la restituzione di tali errori può essere utile ai chiamanti, in modo che possano scrivere codice per gestire i diversi tipi di errore in modi diversi.
  • Sicurezza (Safety): Se la funzione è unsafe (ne parliamo nel Capitolo 20), deve essere presente una sezione che spieghi perché la funzione non è sicura e che descriva gli invarianti che la funzione si aspetta che i chiamanti rispettino.

La maggior parte dei commenti di documentazione non ha bisogno di tutte queste sezioni, ma questa è una buona lista da controllare per ricordarti gli aspetti del tuo codice che gli utenti saranno interessati a conoscere.

Commenti di Documentazione Come Test

L’aggiunta di blocchi di codice di esempio nei commenti di documentazione può aiutare a dimostrare l’uso della libreria e ha un ulteriore vantaggio: l’esecuzione di cargo test eseguirà gli esempi di codice nella documentazione come test! Non c’è niente di meglio di una documentazione con esempi, ma non c’è niente di peggio di esempi che non funzionano perché il codice è cambiato da quando è stata scritta la documentazione. Se eseguiamo cargo test con la documentazione della funzione più_uno del Listato 14-1, vedremo una sezione nei risultati del test che assomiglia a questa:

   Doc-tests mio_crate

running 1 test
test src/lib.rs - più_uno (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

Ora, se modifichiamo la funzione o l’esempio in modo che assert_eq! nell’esempio vada in panic, ed eseguiamo di nuovo cargo test, vedrai che i Doc-tests rileveranno che l’esempio e il codice non sono compatibili tra loro!

Commentare l’Elemento Contenitore

Lo stile dei commenti di documentazione //! aggiunge la documentazione all’elemento che contiene i commenti piuttosto che agli elementi che seguono i commenti. Di solito usiamo questi commenti di documentazione all’interno del file radice del crate (src/lib.rs per convenzione) o all’interno di un modulo per documentare il crate o il modulo nel suo complesso.

Ad esempio, per aggiungere la documentazione che descrive lo scopo del crate mio_crate che contiene la funzione più_uno, aggiungiamo i commenti di documentazione che iniziano con //! all’inizio del file src/lib.rs, come mostrato nel Listato 14-2.

File: src/lib.rs
//! # Mio Crate
//!
//! `mio_crate` è una collezione di funzioni per compiere
//! certi calcoli più facilmente.

/// Aggiunge uno al numero dato.
// --taglio--
///
/// # Esempi
///
/// ```
/// let arg = 5;
/// let risposta = mio_crate::più_uno(arg);
///
/// assert_eq!(6, risposta);
/// ```
pub fn più_uno(x: i32) -> i32 {
    x + 1
}
Listato 14-2: La documentazione generale per l’intero crate mio_crate

Nota che non c’è codice dopo l’ultima riga che inizia con //!. Poiché abbiamo iniziato i commenti con //! invece che con //, stiamo documentando l’elemento che contiene questo commento piuttosto che un elemento che segue questo commento. In questo caso, questo elemento è il file src/lib.rs stesso, che è la radice del crate. Questi commenti descrivono quindi l’intero crate.

Quando si esegue cargo doc --open, questi commenti verranno visualizzati nella prima pagina della documentazione di mio_crate sopra l’elenco degli elementi pubblici del crate, come mostrato nella Figura 14-2.

I commenti di documentazione all’interno degli elementi sono utili soprattutto per descrivere i crate e i moduli. Utilizzali per spiegare lo scopo generale del contenitore per aiutare i tuoi utenti a capire l’organizzazione del crate.

Documentazione HTML
renderizzata con un commento per l’intero _crate_

Figura 14-2: La documentazione renderizzata per mio_crate, incluso il commento che descrive il crate nel suo complesso

Esportare un API Pubblica Efficace

La struttura della tua API pubblica è una considerazione importante quando pubblichi un crate. Le persone che usano il tuo crate hanno meno familiarità con la struttura di te e potrebbero avere difficoltà a trovare i pezzi che vogliono usare se il tuo crate ha una gerarchia di moduli estesa.

Nel Capitolo 7 abbiamo visto come rendere pubblici gli elementi con la parola chiave pub e come portare gli elementi nello scope con la parola chiave use. Tuttavia, la struttura che ha senso per te mentre sviluppi un crate potrebbe non essere molto comoda per i tuoi utenti. Potresti voler organizzare le tue struct in una gerarchia che contiene più livelli, ma chi vuole usare un type che hai definito in profondità nella gerarchia potrebbe avere problemi a scoprire l’esistenza di quel type. Potrebbe anche essere infastidito dal fatto di dover scrivere use mio_crate::un_modulo::altro_modulo::TypeUtile; piuttosto che use mio_crate::TypeUtile;.

La buona notizia è che se la struttura non è facile per gli altri da usare da un’altra libreria, non devi modificare la tua organizzazione interna: puoi invece riesportare gli elementi per creare una struttura pubblica diversa da quella privata usando pub use. Riesportare prende un elemento pubblico in una posizione e lo rende pubblico in un’altra posizione, come se fosse stato definito nell’altra posizione.

Ad esempio, supponiamo di aver creato una libreria chiamata arte per modellare concetti artistici. All’interno di questa libreria ci sono due moduli: un modulo tipologia contenente due enum chiamate ColorePrimario e ColoreSecondario e un modulo utilità contenente una funzione chiamata mix, come mostrato nel Listato 14-3.

File: src/lib.rs
//! # Arte
//!
//! Una libreria per modellare concetti artistici.

pub mod tipologia {
    /// I colori primari secondo il modello RYB.
    pub enum ColorePrimario {
        Rosso,
        Giallo,
        Blu,
    }

    /// I colori secondari secondo il modello RYB.
    pub enum ColoreSecondario {
        Arancio,
        Verde,
        Viola,
    }
}

pub mod utilità {
    use crate::tipologia::*;

    /// Combina due colori primari in egual quantità
    /// per formare un colore secondario.
    pub fn mix(c1: ColorePrimario, c2: ColorePrimario) -> ColoreSecondario {
        // --taglio--
        unimplemented!();
    }
}
Listato 14-3: Una libreria arte con elementi organizzati nei moduli tipologia e utilità

La Figura 14-3 mostra l’aspetto della prima pagina della documentazione di questo crate generata da cargo doc.

Documentazione renderizzata per
il _crate_ `arte` che elenca i moduli `tipologia` e `utilità`

Figura 14-3: La prima pagina della documentazione per arte che elenca i moduli tipologia e utilità

Nota che i type ColorePrimario e ColoreSecondario non sono elencati nella prima pagina, così come la funzione mix. Dobbiamo cliccare su tipologia e utilità per vederli.

Un altro crate che dipende da questa libreria avrebbe bisogno di dichiarazioni use che portino gli elementi di arte nello scope, specificando la struttura del modulo attualmente definita. Il Listato 14-4 mostra un esempio di crate che utilizza gli elementi ColorePrimario e mix del crate arte.

File: src/main.rs
use arte::tipologia::ColorePrimario;
use arte::utilità::mix;

fn main() {
    let rosso = ColorePrimario::Rosso;
    let giallo = ColorePrimario::Giallo;
    mix(rosso, giallo);
}
Listato 14-4: Un crate che utilizza gli elementi del crate arte con la sua struttura interna esportata

L’autore del codice del Listato 14-4, che utilizza il crate arte, ha dovuto capire che ColorePrimario si trova nel modulo tipologia e mix nel modulo utilità. La struttura dei moduli del crate arte è più importante per gli sviluppatori che lavorano sul crate arte che per quelli che lo utilizzano. La struttura interna non contiene informazioni utili per chi cerca di capire come utilizzare il crate arte, ma piuttosto crea confusione perché gli sviluppatori che lo utilizzano devono capire dove cercare e devono specificare i nomi dei moduli nelle dichiarazioni use.

Per rimuovere l’organizzazione interna dall’API pubblica, possiamo modificare il codice nel crate arte del Listato 14-3 per aggiungere le dichiarazioni pub use per riesportare gli elementi al livello superiore, come mostrato nel Listato 14-5.

File: src/lib.rs
//! # Arte
//!
//! Una libreria per modellare concetti artistici.

pub use self::tipologia::ColorePrimario;
pub use self::tipologia::ColoreSecondario;
pub use self::utilità::mix;

pub mod tipologia {
    // --taglio--
    /// I colori primari secondo il modello RYB.
    pub enum ColorePrimario {
        Rosso,
        Giallo,
        Blu,
    }

    /// I colori secondari secondo il modello RYB.
    pub enum ColoreSecondario {
        Arancio,
        Verde,
        Viola,
    }
}

pub mod utilità {
    // --taglio--
    use crate::tipologia::*;

    /// Combina due colori primari in egual quantità
    /// per formare un colore secondario.
    pub fn mix(c1: ColorePrimario, c2: ColorePrimario) -> ColoreSecondario {
        ColoreSecondario::Arancio
    }
}
Listato 14-5: Aggiunta di dichiarazioni pub use per riesportare elementi

La documentazione API che cargo doc genera per questo crate ora elencherà e collegherà le riesportazioni (Re-exports) nella prima pagina, come mostrato nella Figura 14-4, rendendo più facile trovare i type ColorePrimario e ColoreSecondario e la funzione mix.

Documentazione renderizzata per
il _crate_ `arte` con le riesportazioni in prima pagina

Figura 14-4: La prima pagina della documentazione per arte che elenca le riesportazioni

Gli utenti del crate arte possono ancora vedere e usare la struttura interna del Listato 14-3, come dimostrato nel Listato 14-4, oppure possono usare la struttura più comoda del Listato 14-5, come mostrato nel Listato 14-6.

File: src/main.rs
use arte::ColorePrimario;
use arte::mix;
 
fn main() {
    // --taglio--
    let rosso = ColorePrimario::Rosso;
    let giallo = ColorePrimario::Giallo;
    mix(rosso, giallo);
}
Listato 14-6: Un programma che utilizza gli elementi riesportati dal crate arte

Nei casi in cui ci sono molti moduli annidati, riesportare i type al livello superiore con pub use può fare una differenza significativa nell’esperienza delle persone che utilizzano il crate. Un altro uso comune di pub use è quello di riesportare le definizioni di una dipendenza nel crate corrente per rendere le definizioni di quel crate parte dell’API pubblica del tuo crate.

Creare una struttura API pubblica efficace è più un’arte che una scienza e puoi iterare per trovare l’API che funziona meglio per i tuoi utenti. Scegliere pub use ti dà flessibilità nel modo in cui strutturi il tuo crate internamente e disaccoppia la struttura interna da quella che presenti ai tuoi utenti. Guarda il codice di alcuni crate che hai installato per vedere se la loro struttura interna differisce dalla loro API pubblica.

Creare un Account Crates.io

Prima di poter pubblicare qualsiasi crate, devi creare un account su crates.io e ottenere un token API. Per farlo, visita la home page di crates.io e accedi con un account GitHub (attualmente l’account GitHub è un requisito, ma il sito potrebbe supportare altri modi di creare un account in futuro). Una volta effettuato l’accesso, visita le impostazioni del tuo account su https://crates.io/me/ e genera la nuova chiave API. Quindi esegui il comando cargo login e incolla la tua chiave API quando ti viene richiesto, in questo modo:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

Questo comando informerà Cargo del tuo token API e lo memorizzerà localmente in ~/.cargo/credentials.toml. Nota che questo token è segreto: non condividerlo con nessun altro. Se lo condividi con qualcuno per qualsiasi motivo, devi revocarlo e generare un nuovo token su crates.io.

Aggiungere Metadati a un Nuovo Crate

Supponiamo che tu abbia un crate che vuoi pubblicare. Prima di pubblicarlo, dovrai aggiungere alcuni metadati nella sezione [package] del file Cargo.toml del crate.

Il tuo crate avrà bisogno di un nome univoco. Mentre stai lavorando su un crate in locale, puoi dargli il nome che preferisci. Tuttavia, i nomi dei crate su crates.io sono assegnati in base all’ordine di arrivo. Una volta che il nome di un crate è stato preso, nessun altro può pubblicare un crate con quel nome. Prima di provare a pubblicare un crate, cerca il nome che vuoi usare. Se il nome è già stato usato, dovrai trovarne un altro e modificare il campo name nel file Cargo.toml sotto la sezione [package] per usare il nuovo nome per la pubblicazione, in questo modo:

File: Cargo.toml

[package]
name = "gioco_indovinello"

Anche se hai scelto un nome unico, quando esegui cargo publish per pubblicare il crate a questo punto, riceverai un avviso e poi un errore simili a questo:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.

-- test compilazione --

error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

Questo genera un errore perché mancano alcune informazioni cruciali: una descrizione e una licenza sono necessarie affinché le persone sappiano cosa fa la il tuo crate e a quali condizioni possono utilizzarla. In Cargo.toml, aggiungi una descrizione che sia solo una frase o due, perché apparirà insieme al tuo crate nei risultati di ricerca. Per il campo license, devi indicare un valore identificativo della licenza. Il Linux Foundation’s Software Package Data Exchange (SPDX) elenca gli identificativi che puoi usare per questo valore. Per esempio, per specificare che hai concesso in licenza il tuo crate usando la MIT License, aggiungi l’identificativo MIT:

File: Cargo.toml

[package]
name = "gioco_indovinello"
license = "MIT"

Se vuoi usare una licenza che non compare nell’SPDX, devi inserire il testo della licenza in un file, includere il file nel tuo progetto e poi usare license-file per specificare il nome del file invece di usare la chiave license.

Orientarti su quale licenza sia appropriata per il tuo progetto va oltre lo scopo di questo libro. Molte persone nella comunità di Rust concedono la licenza per i loro progetti nello stesso modo di Rust, utilizzando una doppia licenza MIT OR Apache-2.0. Questa pratica dimostra che puoi anche specificare più identificatori di licenza separati da OR per avere più licenze per il tuo progetto.

Con un nome univoco, la versione, la tua descrizione e la licenza aggiunta, il file Cargo.toml per un progetto pronto per la pubblicazione potrebbe assomigliare a questo

File: Cargo.toml

[package]
name = "gioco_indovinello"
version = "0.1.0"
edition = "2024"
description = "Un giochino divertente dove tenti di indovinare un numero casuale."
license = "MIT OR Apache-2.0"

[dependencies]

Nella documentazione di Cargo trovi la descrizione di altri metadati che puoi specificare per far sì che gli altri possano scoprire e utilizzare il tuo crate più facilmente.

Pubblicare su Crates.io

Ora che hai creato un account, salvato il tuo token API, scelto un nome per il tuo crate e specificato i metadati richiesti, sei pronto a pubblicare! Pubblicare un crate carica una versione specifica su crates.io affinché altri possano utilizzarla.

Fai attenzione, perché una pubblicazione è permanente. La versione non può mai essere sovrascritta e il codice non può essere cancellato se non in determinate circostanze. Uno degli obiettivi principali di Crates.io è quello di fungere da archivio permanente del codice in modo che le compilazioni di tutti i progetti che dipendono dai crate di crates.io continuino a funzionare. Consentire la cancellazione delle versioni renderebbe impossibile il raggiungimento di questo obiettivo. Tuttavia, non c’è limite al numero di versioni del crate che puoi pubblicare.

Esegui di nuovo il comando cargo publish: ora dovrebbe andare a buon fine:

$ cargo publish
    Updating crates.io index
   Packaging gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Packaged 6 files, 1.2KiB (895.0B compressed)
   Verifying gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
   Compiling gioco_indovinello v0.1.0
(file:///progetti/gioco_indovinello/target/package/gioco_indovinello-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading gioco_indovinello v0.1.0 (file:///progetti/gioco_indovinello)
    Uploaded gioco_indovinello v0.1.0 to registry `crates-io`
note: waiting for `gioco_indovinello v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
   Published gioco_indovinello v0.1.0 at registry `crates-io`

Congratulazioni, ora hai condiviso il tuo codice con la comunità di Rust e chiunque può facilmente aggiungere il tuo crate come dipendenza del proprio progetto.

Pubblicare una Nuova Versione di un Crate

Quando hai apportato delle modifiche al tuo crate e sei pronto a rilasciare una nuova versione, cambia il valore version specificato nel file Cargo.toml e ripubblica. Utilizza le regole di Versionamento Semantico per decidere quale sia il numero di versione successivo più appropriato, in base alla tipologia di modifiche apportate. Quindi esegui cargo publish per caricare la nuova versione.

Deprecare Versioni da Crates.io

Sebbene non sia possibile rimuovere le versioni precedenti di un crate, puoi impedire a qualsiasi progetto futuro di aggiungerle come nuova dipendenza. Questo è utile quando una versione del crate è mal funzionante per un motivo o per l’altro. In queste situazioni, Cargo supporta la disabilitazione di una versione del crate.

Disabilitare una versione impedisce ai nuovi progetti di dipendere da quella versione, mentre permette a tutti i progetti esistenti che dipendono da essa di continuare. In sostanza, una disabilitazione significa che tutti i progetti con un Cargo.lock non si romperanno e tutti i futuri file Cargo.lock generati non utilizzeranno la versione disabilitata.

Per disabilitare una versione del crate, nella directory del crate che hai pubblicato in precedenza, esegui cargo yank e specifica quale versione vuoi disabilitare. Ad esempio, se hai pubblicato un crate chiamato gioco_indovinello nella versione 1.0.1 e vuoi disabilitarla, nella directory del progetto per gioco_indovinello dovrai eseguire:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank gioco_indovinello@1.0.1

Aggiungendo --undo al comando, puoi anche annullare una disabilitazione, in pratica riabilitare, e permettere ai progetti di ricominciare a dipendere da quella versione:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank gioco_indovinello@1.0.1

Quando disabiliti una versione non cancelli alcun codice. Non puoi, ad esempio, cancellare dati sensibili (chiavi API, password, ecc.) che per errore hai caricato accidentalmente. Se ciò accadesse, devi immediatamente ripristinare e cambiare quei dati sensibili.

Spazi di Lavoro Cargo

Nel Capitolo 12 abbiamo costruito un pacchetto che includeva un crate binario e un crate libreria. Man mano che il tuo progetto si sviluppa, potresti scoprire che il crate libreria continua a diventare più grande e vorresti dividere ulteriormente il tuo pacchetto in più crate libreria. Cargo offre una funzione chiamata spazi di lavoro (workspace) che può aiutarti a gestire più pacchetti correlati che vengono sviluppati in tandem.

Creare un Workspace

Uno spazio di lavoro è un insieme di pacchetti che condividono lo stesso Cargo.lock e la stessa directory di output. Creiamo un progetto utilizzando un workspace: useremo del codice banale in modo da poterci concentrare sulla struttura del workspace. Esistono diversi modi per strutturare uno spazio di lavoro, quindi ci limiteremo a mostrarne uno comune. Avremo un workspace contenente un binario e due librerie. Il binario, che fornirà la funzionalità principale, dipenderà dalle due librerie. Una libreria fornirà una funzione più_uno e l’altra libreria una funzione più_due. Questi tre crate faranno parte dello stesso workspace. Inizia creando una nuova cartella per lo spazio di lavoro:

Nota di traduzione: per i nomi di crate non è possibile utilizzare caratteri non-ASCII, a differenza della possibilità di utilizzo nei nomi di funzione. Si è scelto di usare quindi sia piu_uno che più_uno per rimarcare questa differenza. Fai quindi attenzione.

$ mkdir somma
$ cd somma

Successivamente, nella cartella somma, creiamo il file Cargo.toml che configurerà l’intero workspace. Questo file non avrà una sezione [package], ma inizierà con una sezione [workspace] che ci permetterà di aggiungere membri al workspace. Inoltre, esplicitiamo di voler usare l’ultima versione dell’algoritmo di risoluzione delle dipendenze di Cargo nel nostro spazio di lavoro, impostando il valore resolver a "3":

File: Cargo.toml

[workspace]
resolver = "3"

Successivamente, creeremo il crate binario sommatore eseguendo cargo new nella cartella somma:

$ cargo new sommatore
     Created binary (application) `sommatore` package
      Adding `sommatore` as member of workspace at `file:///progetti/somma`

L’esecuzione di cargo new all’interno di uno spazio di lavoro aggiunge automaticamente il pacchetto appena creato alla chiave members nella definizione [workspace] del file Cargo.toml, in questo modo:

[workspace]
resolver = "3"
members = ["sommatore"]

A questo punto, possiamo costruire l’intero workspace con cargo build. I file nella tua cartella somma dovrebbero avere questo aspetto:

somma
├── Cargo.lock
├── Cargo.toml
├── sommatore
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Lo spazio di lavoro ha una cartella target al livello superiore in cui verranno inseriti gli artefatti compilati; il pacchetto sommatore non ha una propria cartella target. Anche se dovessimo eseguire cargo build dall’interno della cartella sommatore, gli artefatti compilati finirebbero comunque in somma/target piuttosto che in somma/sommatore/target. Cargo struttura la cartella target in uno spazio di lavoro in questo modo perché i crate in un workspace sono destinati a dipendere l’uno dall’altro. Se ogni crate avesse la propria cartella target, ogni crate dovrebbe ricompilare ogni altro crate nello spazio di lavoro per posizionare gli artefatti nella propria cartella target. Condividendo una cartella target, i crate possono evitare inutili ricostruzioni.

Creare un Secondo Pacchetto nel Workspace

Ora creiamo un altro pacchetto membro dell’area di lavoro e chiamiamolo piu_uno. Generiamo un nuovo crate libreria chiamato piu_uno:

$ cargo new piu_uno --lib
     Created library `piu_uno` package
      Adding `piu_uno` as member of workspace at `file:///progetti/somma`

Il file Cargo.toml nella cartella somma ora includerà il percorso piu_uno nell’elenco dei membri members:

File: Cargo.toml

[workspace]
resolver = "3"
members = ["sommatore", "piu_uno"]

La tua cartella somma dovrebbe ora contenere queste cartelle e questi file:

somma
├── Cargo.lock
├── Cargo.toml
├── piu_uno
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── sommatore
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

Nel file piu_uno/src/lib.rs, aggiungiamo una funzione più_uno:

File: piu_uno/src/lib.rs

pub fn più_uno(x: i32) -> i32 {
    x + 1
}

Ora possiamo fare in modo che il pacchetto sommatore con il nostro binario dipenda dal pacchetto piu_uno che contiene la nostra libreria. Per prima cosa, dovremo aggiungere un percorso di dipendenza a piu_uno nel file sommatore/Cargo.toml.

File: sommatore/Cargo.toml

[dependencies]
piu_uno = { path = "../piu_uno" }

Cargo non presuppone che i crate dello stesso workspace dipendano l’uno dall’altro, quindi dobbiamo essere espliciti sulle relazioni di dipendenza.

Quindi, utilizziamo la funzione più_uno (dal crate piu_uno) nel crate sommatore. Apri il file sommatore/src/main.rs e modifica la funzione main per richiamare la funzione più_uno, come nel Listato 14-7

File: sommatore/src/main.rs
fn main() {
    let num = 10;
    println!("Ciao! {num} più uno fa {}!", piu_uno::più_uno(num));
}
Listato 14-7: Utilizzo del crate libreria piu_uno dal crate sommatore

Compiliamo lo spazio di lavoro eseguendo cargo build nella directory di primo livello somma!

$ cargo build
   Compiling piu_uno v0.1.0 (file:///progetti/somma/piu_uno)
   Compiling sommatore v0.1.0 (file:///progetti/somma/sommatore)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

Per eseguire il crate binario dalla directory somma, possiamo specificare quale pacchetto del workspace vogliamo eseguire utilizzando l’argomento -p e il nome del pacchetto con cargo run:

$ cargo run -p sommatore
   Compiling sommatore v0.1.0 (file:///progetti/somma/sommatore)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/sommatore`
Ciao! 10 più uno fa 11!

Questo esegue il codice in sommatore/src/main.rs, che dipende dal crate piu_uno.

Dipendere da un Pacchetto Esterno

Avrai notato che lo spazio di lavoro ha un solo file Cargo.lock al livello superiore, invece di avere un Cargo.lock nella cartella di ogni crate. Questo assicura che tutti i crate utilizzino la stessa versione di tutte le dipendenze. Se aggiungiamo il pacchetto rand ai file sommatore/Cargo.toml e piu_uno/Cargo.toml, Cargo li risolverà entrambi in un’unica versione di rand e la registrerà nell’unico Cargo.lock. Fare in modo che tutti i crate nel workspace utilizzino le stesse dipendenze significa che i crate saranno sempre compatibili tra loro. Aggiungiamo il crate rand alla sezione [dependencies] nel file piu_uno/Cargo.toml in modo da poter utilizzare il crate rand nel crate piu_uno:

File: piu_uno/Cargo.toml

[dependencies]
rand = "0.8.5"

Ora possiamo aggiungere use rand; al file piu_uno/src/lib.rs e la creazione dell’intero workspace eseguendo cargo build nella cartella somma scaricherà e compilerà il crate rand. Riceveremo un avviso perché non stiamo effettivamente usando rand che abbiamo portato nello scope:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --taglio--
   Compiling rand v0.8.5
   Compiling piu_uno v0.1.0 (file:///progetti/somma/piu_uno)
warning: unused import: `rand`
 --> piu_uno/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `piu_uno` (lib) generated 1 warning (run `cargo fix --lib -p piu_uno` to apply 1 suggestion)
   Compiling sommatore v0.1.0 (file:///progetti/somma/sommatore)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

Il file Cargo.lock al livello più alto ora contiene informazioni sulla dipendenza di piu_uno da rand. Tuttavia, anche se rand è utilizzato da qualche parte nello spazio di lavoro, non possiamo utilizzarlo in altri crate del workspace a meno che non aggiungiamo rand anche ai loro file Cargo.toml. Ad esempio, se aggiungiamo use rand; al file sommatore/src/main.rs per il pacchetto sommatore, otterremo un errore:

$ cargo build
  --taglio--
   Compiling sommatore v0.1.0 (file:///progetti/somma/sommatore)
error[E0432]: unresolved import `rand`
 --> sommatore/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

Per risolvere questo problema, modifica il file Cargo.toml per il pacchetto sommatore e indica che rand è una dipendenza anche per esso. Costruendo il pacchetto sommatore aggiungerà rand all’elenco delle dipendenze di sommatore in Cargo.lock, ma non verranno scaricate copie aggiuntive di rand. Cargo farà in modo che ogni crate in ogni pacchetto dell’area di lavoro che utilizza il pacchetto rand utilizzi la stessa versione, a patto che specifichi versioni compatibili di rand, risparmiando spazio e assicurando che i crate nel workspace siano compatibili tra loro.

Se i crate nel workspace specificano versioni incompatibili della stessa dipendenza, Cargo risolverà ciascuna di esse, ma cercherà comunque di risolvere il minor numero possibile di versioni.

Aggiungere un Test a un Workspace

Per un altro miglioramento, aggiungiamo un test della funzione piu_uno::più_uno all’interno del crate piu_uno:

File: piu_uno/src/lib.rs

pub fn più_uno(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn funziona() {
        assert_eq!(3, più_uno(2));
    }
}

Ora esegui cargo test nella cartella di primo livello somma. Eseguendo cargo test in un workspace strutturato come questo, verranno eseguiti i test per tutti i crate presenti nello spazio di lavoro:

$ cargo test
   Compiling piu_uno v0.1.0 (file:///progetti/somma/piu_uno)
   Compiling sommatore v0.1.0 (file:///progetti/somma/sommatore)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/piu_uno-93c49ee75dc46543)

running 1 test
test tests::funziona ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/sommatore-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests piu_uno

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

La prima sezione dell’output mostra che il test funziona nel crate piu_uno è passato. La sezione successiva mostra che sono stati trovati zero test nel crate sommatore e l’ultima sezione mostra che sono stati trovati zero test di documentazione nel crate piu_uno.

Possiamo anche eseguire i test per un particolare crate in un workspace dalla directory di primo livello utilizzando il flag -p e specificando il nome del crate che vogliamo testare:

$ cargo test -p piu_uno
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/piu_uno-93c49ee75dc46543)

running 1 test
test tests::funziona ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests piu_uno

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Questo output mostra che cargo test ha eseguito solo i test del crate piu_uno e non ha eseguito i test del crate sommatore.

Se pubblichi i crate nello workspace su crates.io, ogni crate nello spazio di lavoro dovrà essere pubblicato separatamente. Come nel caso di cargo test, possiamo pubblicare un particolare crate nel nostro spazio di lavoro utilizzando il flag -p e specificando il nome del crate che vogliamo pubblicare.

Per fare ulteriore pratica, aggiungi un crate piu_due a questo spazio di lavoro in modo simile al crate piu_uno!

Quando il tuo progetto cresce, prendi in considerazione l’utilizzo di uno spazio di lavoro: ti permette di lavorare con componenti più piccoli e più facili da capire rispetto a un unico grande blocco di codice. Inoltre, mantenere i crate in uno spazio di lavoro può rendere più facile il coordinamento tra i crate se questi vengono spesso modificati nello stesso momento.

Installazione di Binari con cargo install

Il comando cargo install ti permette di installare e utilizzare localmente i crate binari. Questo non è destinato a sostituire la gestione dei pacchetti di sistema, ma è un modo comodo per gli sviluppatori di Rust di installare gli strumenti che altri hanno condiviso su crates.io. Nota che puoi installare solo i pacchetti che hanno dei target binari. Un target binario è il programma eseguibile che viene creato se il crate ha un file src/main.rs o un altro file specificato come binario, in contrapposizione a un target libreria che non è eseguibile da solo ma è adatto per essere incluso in altri programmi. Di solito, i crate hanno informazioni nel file README sul fatto che un crate è una libreria, è un binario o entrambi.

Tutti i file binari installati con cargo install sono memorizzati nella cartella bin della radice dell’installazione. Se hai installato Rust utilizzando rustup e non hai configurazioni personalizzate, questa cartella sarà $HOME/.cargo/bin. Assicurati che questa cartella sia presente nella tua $PATH per poter eseguire i programmi che hai installato con cargo install.

Ad esempio, nel Capitolo 12 abbiamo accennato all’esistenza di un’implementazione Rust dello strumento grep chiamata ripgrep per la ricerca di file. Per installare ripgrep, possiamo usare il seguente comando:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--taglio--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

La penultima riga dell’output mostra la posizione e il nome del binario installato, che nel caso di ripgrep è rg. Se la directory di installazione è presente nel tuo $PATH, come detto in precedenza, puoi eseguire rg --help e iniziare a usare uno strumento più veloce e più ruspante per la ricerca dei file!

Estendere Cargo con Comandi Personalizzati

Cargo è stato progettato in modo che tu possa estenderlo con nuovi sotto-comandi senza doverlo modificare. Se un binario nella tua $PATH si chiama cargo-qualcosa, puoi eseguirlo come se fosse un sotto-comando di Cargo eseguendo cargo qualcosa. I comandi personalizzati come questo sono anche elencati quando esegui cargo --list. La possibilità di usare cargo install per installare le estensioni e poi eseguirle proprio come gli strumenti integrati di Cargo è un vantaggio super conveniente del design di Cargo!

Riepilogo

La condivisione di codice con Cargo e crates.io è parte di ciò che rende l’ecosistema Rust utile per molti compiti diversi. La libreria standard di Rust è piccola e stabile, ma i crate sono facili da condividere, usare e migliorare con una tempistica diversa da quella del linguaggio. Non essere timido nel condividere il codice che ti è utile su crates.io; è probabile che sia utile anche a qualcun altro!

Puntatori Intelligenti

Un puntatore è un concetto generale che rappresenta una variabile che contiene un indirizzo in memoria. Questo indirizzo fa riferimento, o “punta a”, altri dati. Il tipo più comune di puntatore in Rust è un reference, come hai imparato nel Capitolo 4. I reference sono indicati dal simbolo & e prendono in prestito il valore a cui puntano. Non hanno capacità speciali oltre al riferimento ai dati, e non hanno costi prestazionali aggiuntivi (overhead).

I puntatori intelligenti (smart pointers), d’altra parte, sono strutture dati che si comportano come un puntatore ma hanno anche metadati e capacità aggiuntive. Il concetto di puntatori intelligenti non è esclusivo di Rust: i puntatori intelligenti hanno avuto origine in C++ ed esistono anche in altri linguaggi. Rust ha una varietà di puntatori intelligenti definiti nella libreria standard che forniscono funzionalità che vanno oltre quelle fornite dai reference. Per esplorare il concetto generale, esamineremo un paio di esempi diversi di puntatori intelligenti, incluso un tipo di puntatore intelligente con conteggio dei riferimenti. Questo puntatore consente ai dati di avere più proprietari tenendo traccia del loro numero e, quando non ne rimane nessuno, de-allocare i dati.

Rust, con il suo concetto di ownership e borrowing, presenta un’ulteriore differenza tra reference e i puntatori intelligenti: mentre i reference prendono solo in prestito dati, in molti casi i puntatori intelligenti posseggono i dati a cui puntano.

I puntatori intelligenti sono solitamente implementati tramite struct. A differenza di una normale struct, i puntatori intelligenti implementano i trait Deref e Drop. Il trait Deref consente a un’istanza della struct del puntatore intelligente di comportarsi come un reference in modo da poter scrivere codice che funzioni sia con reference che con puntatori intelligenti. Il trait Drop consente di personalizzare il codice che viene eseguito quando un’istanza del puntatore intelligente esce dallo scope. In questo capitolo, discuteremo entrambi questi trait e dimostreremo perché sono importanti per i puntatori intelligenti.

Dato che il puntatore intelligente è un design generale utilizzato frequentemente in Rust, questo capitolo non tratterà tutti i puntatori intelligenti esistenti. Molte librerie hanno i propri puntatori intelligenti, ed è anche possibile scriverne di propri. Tratteremo i più comuni nella libreria standard:

  • Box<T>, per l’allocazione di valori nell’heap
  • Rc<T>, un type di conteggio dei reference che consente la ownership multipla
  • Ref<T> e RefMut<T>, accessibili tramite RefCell<T>, type che applicano le regole di prestito durante l’esecuzione anziché in fase di compilazione

Inoltre, tratteremo il modello di mutabilità interna, in cui un type immutabile espone un’API per la mutazione di un valore interno. Discuteremo anche dei cicli di riferimento e di come possono causare perdite di memoria e come prevenirle.

Cominciamo!

Utilizzare Box<T> per Puntare ai Dati nell’Heap

Il puntatore intelligente più semplice è una box (scatola), il cui type è scritto Box<T>. Le box consentono di memorizzare i dati nell’heap anziché sullo stack. Ciò che rimane sullo stack è il puntatore ai dati nell’heap. Fai riferimento al Capitolo 4 per rinfrescare la memoria sulla differenza tra stack e heap.

Le box non hanno un overhead di prestazioni, a parte il fatto che memorizzano i dati nell’heap anziché sullo stack. Ma non hanno nemmeno molte funzionalità extra. Li userai più spesso in queste situazioni:

  • Quando hai un type la cui dimensione non può essere conosciuta in fase di compilazione e vuoi utilizzare un valore di quel type in un contesto che richiede una dimensione esatta
  • Quando hai una grande quantità di dati e vuoi trasferirne la ownership ma vuoi evitare che i dati vengano copiati quando lo fai
  • Quando vuoi possedere un valore e ti interessa solo che sia un type con un determinato trait piuttosto che essere di un type specifico

Descriveremo la prima situazione in “Abilitare i Type Ricorsivi con le Box. Nel secondo caso, il trasferimento della ownership di una grande quantità di dati può richiedere molto tempo perché i dati vengono copiati sullo stack. Per migliorare le prestazioni in questa situazione, possiamo memorizzare la grande quantità di dati nell’heap in una box. Quindi, solo la piccola quantità di dati del puntatore viene copiata sullo stack, mentre i dati a cui fa riferimento rimangono in un unico punto dell’heap. Il terzo caso è noto come oggetto trait (trait object), e la sezione “Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi” nel Capitolo 18 è dedicata specificamente a questo argomento. Quindi, ciò che imparerai qui lo applicherai di nuovo in quella sezione!

Memorizzare Dati nell’Heap

Prima di discutere il caso d’uso di archiviazione nell’heap per Box<T>, tratteremo la sintassi e come interagire con i valori memorizzati all’interno di una Box<T>.

Il Listato 15-1 mostra come utilizzare una box per memorizzare un valore i32 nell’heap.

File: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listato 15-1: Memorizzare un valore i32 nell’heap tramite una box

Definiamo la variabile b come avente il valore di una Box che punta al valore 5, allocato nell’heap. Questo programma stamperà b = 5; in questo caso, possiamo accedere ai dati nella box in modo simile a come faremmo se questi dati fossero sullo stack. Proprio come qualsiasi valore posseduto, quando una box esce dallo scope, come accade a b alla fine di main, verrà de-allocata. La de-allocazione avviene sia per la box (memorizzata sullo stack) sia per i dati a cui punta (memorizzati nell’heap).

Mettere un singolo valore nell’heap non è molto utile, quindi le box non verranno utilizzate molto spesso da sole in questo modo. Avere valori come un singolo i32 sullo stack, dove vengono memorizzati di default, è più appropriato nella maggior parte delle situazioni. Diamo un’occhiata a un caso in cui le box ci consentono di definire type che non saremmo autorizzati a definire se non avessimo le box.

Abilitare i Type Ricorsivi con le Box

Un valore di un type ricorsivo (recursive type) può avere un altro valore dello stesso type come parte di sé. I type ricorsivi pongono un problema perché Rust deve sapere in fase di compilazione quanto spazio occupa un certo type. Tuttavia, l’annidamento dei valori dei type ricorsivi potrebbe teoricamente continuare all’infinito, quindi Rust non può sapere di quanto spazio ha bisogno il valore. Poiché le box hanno dimensioni note, possiamo abilitare i type ricorsivi inserendo una box nella definizione del type ricorsivo.

Come esempio di type ricorsivo, esploriamo la cons list (lista di costrutti). Questo è un tipo di dato comunemente presente nei linguaggi di programmazione funzionale. Il type di cons list che definiremo è semplice, fatta eccezione per la ricorsione; pertanto, i concetti nell’esempio con cui lavoreremo saranno utili ogni volta che ti troverai in situazioni più complesse che coinvolgono i type ricorsivi.

Comprendere la Cons List

Una Cons List è una struttura dati derivata dal linguaggio di programmazione Lisp e dai suoi dialetti, è composta da coppie annidate ed è la versione Lisp di una lista concatenata. Il suo nome deriva dalla funzione cons (abbreviazione di construct function) in Lisp, che costruisce una nuova coppia a partire dai suoi due argomenti. Chiamando cons su una coppia composta da un valore e un’altra coppia, possiamo costruire cons list composte da coppie ricorsive.

Ad esempio, ecco una rappresentazione in pseudo-codice di una cons list contenente la lista 1, 2, 3 con ciascuna coppia tra parentesi:

(1, (2, (3, Nil)))

Ogni elemento in una cons list contiene due elementi: il valore dell’elemento corrente e l’elemento successivo. L’ultimo elemento della lista contiene solo un valore chiamato Nil senza un elemento successivo. Una cons list viene prodotta chiamando ricorsivamente la funzione cons. Il nome canonico per indicare il caso base della ricorsione è Nil. Nota che questo non è lo stesso del concetto di “null” o “nil” discusso nel Capitolo 6, che indica un valore non valido o assente.

La cons list non è una struttura dati comunemente utilizzata in Rust. Nella maggior parte dei casi quando si ha una lista di elementi in Rust, Vec<T> è una scelta migliore. Altri tipi di dati ricorsivi più complessi sono utili in varie situazioni, ma iniziando con la cons list in questo capitolo, possiamo capire come le box ci consentano di definire un tipo di dati ricorsivo senza troppe distrazioni.

Il Listato 15-2 contiene una definizione enum per una cons list. Nota che questo codice non verrà ancora compilato perché il type Lista non ha una dimensione nota, che dimostreremo.

File: src/main.rs
enum Lista {
    Cons(i32, Lista),
    Nil,
}

fn main() {}
Listato 15-2: Il primo tentativo di definire una enum per rappresentare una struttura dati di tipo cons list di valori i32

Nota: stiamo implementando una cons list che contiene solo valori i32 per gli scopi di questo esempio. Avremmo potuto implementarla utilizzando i generici, come discusso nel Capitolo 10, per definire un tipo di cons list in grado di memorizzare valori di qualsiasi type.

L’utilizzo del type Lista per memorizzare l’elenco 1, 2, 3 sarebbe simile al codice nel Listato 15-3.

File: src/main.rs
enum Lista {
    Cons(i32, Lista),
    Nil,
}

// --taglio--

use crate::Lista::{Cons, Nil};

fn main() {
    let lista = Cons(1, Cons(2, Cons(3, Nil)));
}
Listato 15-3: Utilizzo dell’enum Lista per memorizzare la lista 1, 2, 3

Il primo valore Cons contiene 1 e un altro valore Lista. Questo valore Lista è un altro valore Cons che contiene 2 e un altro valore Lista. Questo valore Lista è un altro valore Cons che contiene 3 e un valore Lista, che è infine Nil, la variante non ricorsiva che segnala la fine della lista.

Se proviamo a compilare il codice nel Listato 15-3, otteniamo l’errore mostrato nel Listato 15-4.

$ cargo run
   Compiling cons-list v0.1.0 (file:///progetti/cons-list)
error[E0072]: recursive type `Lista` has infinite size
 --> src/main.rs:1:1
  |
1 | enum Lista {
  | ^^^^^^^^^^
2 |     Cons(i32, Lista),
  |               ----- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<Lista>),
  |               ++++     +

error[E0391]: cycle detected when computing when `Lista` needs drop
 --> src/main.rs:1:1
  |
1 | enum Lista {
  | ^^^^^^^^^^
  |
  = note: ...which immediately requires computing when `Lista` needs drop again
  = note: cycle used when computing whether `Lista` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listato 15-4: L’errore che otteniamo quando si tenta di definire una enum ricorsivo

L’errore indica che questo type “ha dimensione infinita”. Il motivo è che abbiamo definito Lista con una variante che è ricorsiva: contiene direttamente un altro valore di se stessa. Di conseguenza, Rust non riesce a calcolare quanto spazio è necessario per memorizzare un valore Lista. Analizziamo il motivo per cui otteniamo questo errore. Innanzitutto, vedremo come Rust determina quanto spazio è necessario per memorizzare un valore di un type non ricorsivo.

Calcolare la Dimensione di un Type Non Ricorsivo

Riprendiamo l’enum Messaggio che abbiamo definito nel Listato 6-2 quando abbiamo discusso le definizioni delle enum nel Capitolo 6:

enum Messaggio {
    Esci,
    Sposta { x: i32, y: i32 },
    Scrivi(String),
    CambiaColore(i32, i32, i32),
}

fn main() {}

Per determinare quanto spazio allocare per un valore Messaggio, Rust esamina ciascuna delle varianti per vedere quale variante necessita di più spazio. Rust vede che Messaggio::Esci non necessita di spazio, Messaggio::Sposta necessita di spazio sufficiente per memorizzare due valori i32 e così via. Poiché verrà utilizzata una sola variante, lo spazio massimo di cui un valore Messaggio avrà bisogno è lo spazio che richiederebbe per memorizzare la più grande delle sue varianti.

Confrontiamo questo con ciò che accade quando Rust cerca di determinare di quanto spazio necessita un type ricorsivo come l’enum Lista nel Listato 15-2. Il compilatore inizia esaminando la variante Cons, che contiene un valore di type i32 e un valore di type Lista. Pertanto, Cons necessita di una quantità di spazio pari alla dimensione di un i32 più la dimensione di un Lista. Per calcolare la quantità di memoria necessaria per il type Lista, il compilatore esamina le varianti, a partire dalla variante Cons. La variante Cons contiene un valore di type i32 e un valore di type Lista, e questo processo continua all’infinito, come mostrato nella Figura 15-1.

Una lista
_Cons_ infinita: un rettangolo etichettato 'Cons' diviso in due rettangoli più
piccoli. Il primo rettangolo più piccolo contiene l’etichetta 'i32', e il
secondo rettangolo più piccolo contiene l’etichetta 'Cons' e una versione più
piccola del rettangolo 'Cons' esterno. I rettangoli 'Cons' continuano a
contenere versioni sempre più piccole di se stessi finché il rettangolo più
piccolo, di dimensioni adeguate, contiene un simbolo di infinito, a indicare che
questa ripetizione continua all’infinito.

Figura 15-1: Una Lista infinita composta da infinite varianti Cons

Ottenere un Type Ricorsivo con una Dimensione Nota

Poiché Rust non riesce a calcolare quanto spazio allocare per i type definiti ricorsivamente, il compilatore genera un errore con questo utile suggerimento:

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<Lista>),
  |               ++++     +

In questo suggerimento, indirection significa che invece di memorizzare un valore direttamente, dovremmo modificare la struttura dati per memorizzarlo indirettamente, memorizzando invece un puntatore al valore.

Poiché Box<T> è un puntatore, Rust sa sempre di quanto spazio una Box<T> necessita: la dimensione di un puntatore non cambia in base alla quantità di dati a cui punta. Questo significa che possiamo inserire Box<T> all’interno della variante Cons invece di un altro valore Lista direttamente. Box<T> punterà al successivo valore Lista che si troverà nell’heap anziché all’interno della variante Cons. Concettualmente, abbiamo ancora una lista, creata con liste che contengono altre liste, ma questa implementazione ora è più simile al posizionamento degli elementi uno accanto all’altro piuttosto che uno dentro l’altro.

Possiamo modificare la definizione dell’enum Lista nel Listato 15-2 e l’utilizzo di Lista nel Listato 15-3 con il codice nel Listato 15-5, che verrà compilato.

File: src/main.rs
enum Lista {
    Cons(i32, Box<Lista>),
    Nil,
}

use crate::Lista::{Cons, Nil};

fn main() {
    let lista = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listato 15-5: La definizione di Lista utilizzando Box<T> per avere una dimensione nota

La variante Cons richiede la dimensione di un i32 più lo spazio per memorizzare i dati del puntatore della box. La variante Nil non memorizza alcun valore, quindi necessita di meno spazio sullo stack rispetto alla variante Cons. Ora sappiamo che qualsiasi valore Lista occuperà le dimensioni di un i32 più le dimensioni dei dati del puntatore di una box. Utilizzando una box, abbiamo interrotto la catena infinita e ricorsiva, in modo che il compilatore possa calcolare la dimensione necessaria per memorizzare un valore Lista. La Figura 15-2 mostra l’aspetto attuale della variante Cons.

Un rettangolo etichettato
'Cons' diviso in due rettangoli più piccoli. Il primo rettangolo più piccolo
contiene l’etichetta 'i32', e il secondo rettangolo più piccolo contiene
l’etichetta 'Box' con un rettangolo interno che contiene l’etichetta 'usize',
che rappresenta la dimensione finita del puntatore della box.

Figura 15-2: Una Lista che non ha dimensioni infinite perché Cons contiene una Box

Le box forniscono solo l’indirezione e l’allocazione nell’heap; non hanno altre funzionalità speciali, come quelle che vedremo con gli altri tipi di puntatori intelligenti. Inoltre, non hanno alcun overhead prestazionale che queste funzionalità speciali comporterebbero, quindi possono essere utili in casi come la cons list, in cui l’indirezione è l’unica funzionalità di cui abbiamo bisogno. Esamineremo altri casi d’uso per le box nel Capitolo 18.

Il type Box<T> è un puntatore intelligente perché implementa il trait Deref, che consente di trattare i valori Box<T> come reference. Quando un valore Box<T> esce dallo scope, anche i dati dell’heap a cui punta il box vengono de-allocati grazie all’implementazione del trait Drop. Questi due trait saranno ancora più importanti per le funzionalità fornite dagli altri tipi di puntatore intelligente che discuteremo nel resto di questo capitolo. Vediamo questi due trait più in dettaglio.

Trattare i Puntatori Intelligenti Come Normali Reference

L’implementazione del trait Deref consente di personalizzare il comportamento dell’operatore di de-referenziazione (dereference operator) * (da non confondere con l’operatore di moltiplicazione o glob). Implementando Deref in modo tale che un puntatore intelligente possa essere trattato come un normale reference, è possibile scrivere codice che opera sui reference e utilizzarlo anche con i puntatori intelligenti.

Vediamo prima come funziona l’operatore di de-referenziazione con i normali reference. Poi proveremo a definire un type personalizzato che si comporti come Box<T> e vedremo perché l’operatore di de-referenziazione non funziona come un reference sul nostro nuovo type che abbiamo definito. Esploreremo come l’implementazione del trait Deref consenta ai puntatori intelligenti di funzionare in modo simile ai reference. Infine, esamineremo la funzionalità di Rust di de-referenziazione forzata (deref coercion) e come ci consenta di lavorare sia con i reference che con i puntatori intelligenti.

Seguire il Reference al Valore

Un normale reference è un tipo di puntatore, un modo per pensare a un puntatore è immaginare una freccia che punta verso un valore memorizzato altrove. Nel Listato 15-6, creiamo un reference a un valore i32 e poi utilizziamo l’operatore di de-referenziazione per seguire il riferimento al valore.

File: src/main.rs
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listato 15-6: Utilizzo dell’operatore di de-referenziazione per seguire un riferimento a un valore i32

La variabile x contiene un valore i32 5. Impostiamo y uguale a un reference a x. Possiamo asserire che x è uguale a 5. Tuttavia, se vogliamo fare un’asserzione sul valore in y, dobbiamo usare *y per seguire il reference al valore a cui punta (da qui de-referenziazione) in modo che il compilatore possa confrontare il valore effettivo. Una volta de-referenziato y, abbiamo accesso al valore intero a cui punta y, che possiamo confrontare con 5.

Se provassimo a scrivere assert_eq!(5, y);, otterremmo questo errore di compilazione:

$ cargo run
   Compiling esempio-deref v0.1.0 (file:///progetti/esempio-deref)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `esempio-deref` (bin "esempio-deref") due to 1 previous error

Il confronto tra un numero e un reference a un numero non è consentito perché sono diversi. Dobbiamo usare l’operatore di de-referenziazione per seguire il reference al valore a cui punta.

Utilizzare Box<T> Come Reference

Possiamo riscrivere il codice nel Listato 15-6 per utilizzare Box<T> invece di un reference; l’operatore di de-referenziazione utilizzato su Box<T> nel Listato 15-7 funziona allo stesso modo dell’operatore di de-referenziazione utilizzato sul reference nel Listato 15-6.

File: src/main.rs
fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listato 15-7: Utilizzare l’operatore di de-referenziazione su una Box<i32>

La differenza principale tra il Listato 15-7 e il Listato 15-6 è che qui impostiamo y come un’istanza di una box che punta a un valore copiato di x anziché come un reference che punta al valore di x. Nell’ultima asserzione, possiamo usare l’operatore di de-referenziazione per seguire il puntatore della box nello stesso modo in cui facevamo quando y era un reference. Successivamente, esploreremo le peculiarità di Box<T> che ci consentono di utilizzare l’operatore di de-referenziazione definendo un nostro type di box.

Definire il Nostro Puntatore Intelligente

Creiamo un type incapsulatore, simile al type Box<T> fornito dalla libreria standard, per sperimentare come i tipi di puntatore intelligente si comportino diversamente dai normali reference. Poi vedremo come aggiungere la possibilità di utilizzare l’operatore di de-referenziazione.

Nota: c’è una grande differenza tra il type MioBox<T> che stiamo per creare e il vero Box<T>: la nostra versione non memorizzerà i dati nell’heap. Ci stiamo concentrando su Deref, quindi dove vengono effettivamente memorizzati i dati è meno importante del comportamento “simile” a un puntatore.

Il type Box<T> è essenzialmente definito come una struct tupla con un elemento, quindi il Listato 15-8 definisce un type MioBox<T> allo stesso modo. Definiremo anche una funzione new che corrisponda alla funzione new definita su Box<T>.

File: src/main.rs
struct MioBox<T>(T);

impl<T> MioBox<T> {
    fn new(x: T) -> MioBox<T> {
        MioBox(x)
    }
}

fn main() {}
Listato 15-8: Definizione di un type MioBox<T>

Definiamo una struct denominata MioBox e dichiariamo un parametro generico T perché vogliamo che il nostro type contenga valori di qualsiasi type. Il type MioBox è una struct tupla con un elemento di type T. La funzione MioBox::new accetta un parametro di type T e restituisce un’istanza di MioBox che contiene il valore passato.

Proviamo ad aggiungere la funzione main del Listato 15-7 al Listato 15-8 e modificarla in modo che utilizzi il type MioBox<T> che abbiamo definito invece di Box<T>. Il codice nel Listato 15-9 non verrà compilato perché Rust non sa come de-referenziare MioBox.

File: src/main.rs
struct MioBox<T>(T);

impl<T> MioBox<T> {
    fn new(x: T) -> MioBox<T> {
        MioBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MioBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listato 15-9: Tentativo di utilizzare MioBox<T> nello stesso modo in cui abbiamo utilizzato i reference e Box<T>

Ecco l’errore di compilazione risultante:

$ cargo run
   Compiling esempio-deref v0.1.0 (file:///progetti/esempio-deref)
error[E0614]: type `MioBox<{integer}>` cannot be dereferenced
  --> src/main.rs:15:19
   |
15 |     assert_eq!(5, *y);
   |                   ^^ can't be dereferenced

For more information about this error, try `rustc --explain E0614`.
error: could not compile `esempio-deref` (bin "esempio-deref") due to 1 previous error

Il nostro type MioBox<T> non può essere de-referenziato perché non abbiamo implementato tale possibilità sul nostro type. Per abilitare la de-referenziazione con l’operatore *, implementiamo il trait Deref.

Implementare il Trait Deref

Come discusso in “Implementare un Trait su un Type nel Capitolo 10, per implementare un trait dobbiamo fornire le implementazioni per i metodi richiesti dal trait. Il trait Deref, fornito dalla libreria standard, richiede l’implementazione di un metodo chiamato deref che prende in prestito self e restituisce un reference ai dati interni. Il Listato 15-10 contiene un’implementazione di Deref da aggiungere alla definizione di MioBox<T>.

File: src/main.rs
use std::ops::Deref;

impl<T> Deref for MioBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MioBox<T>(T);

impl<T> MioBox<T> {
    fn new(x: T) -> MioBox<T> {
        MioBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MioBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listato 15-10: Implementazione di Deref su MioBox<T>

La sintassi type Target = T; definisce un type associato che il trait Deref può utilizzare. I type associati rappresentano un modo leggermente diverso di dichiarare un parametro generico, ma per ora non è necessario preoccuparsene; li tratteremo più dettagliatamente nel Capitolo 20.

Nel corpo del metodo deref inseriamo &self.0 in modo che deref restituisca un reference al valore a cui vogliamo accedere con l’operatore *; come detto in “Creare Type Diversi con Struct Tupla ” nel Capitolo 5, .0 accede al primo valore in una struct tupla. La funzione main nel Listato 15-10 che chiama * sul valore MioBox<T> ora si compila e le asserzioni vengono verificate!

Senza il trait Deref, il compilatore può de-referenziare solo i reference &. Il metodo deref consente al compilatore di accettare un valore di qualsiasi type che implementi Deref e chiamare il metodo deref per ottenere un reference & che sa come de-referenziare.

Quando abbiamo inserito *y nel Listato 15-10, dietro le quinte Rust ha effettivamente eseguito questo codice:

*(y.deref())

Rust sostituisce l’operatore * con una chiamata al metodo deref e poi un semplice de-referenziamento, così non dobbiamo pensare se sia necessario o meno chiamare il metodo deref. Questa funzionalità di Rust ci permette di scrivere codice che funziona nello stesso modo indipendentemente dal fatto che abbiamo un normale reference o un type che implementa Deref.

Il motivo per cui il metodo deref restituisce un reference a un valore, e il fatto che il semplice de-referenziamento al di fuori delle parentesi in *(y.deref()) sia ancora necessario, ha a che fare con il sistema di ownership. Se il metodo deref restituisse il valore direttamente invece di un reference al valore, il valore verrebbe spostato fuori da self. Non vogliamo prendere la ownership del valore interno di MioBox<T> in questo caso, né nella maggior parte dei casi in cui utilizziamo l’operatore di de-referenziazione.

Nota che l’operatore * viene sostituito con una chiamata al metodo deref e poi con una chiamata all’operatore * una sola volta, ogni volta che utilizziamo * nel nostro codice. Poiché la sostituzione dell’operatore * non è ricorsiva all’infinito, otteniamo dati di type i32, che corrispondono al 5 in assert_eq! nel Listato 15-9.

Usare la De-Referenziazione Forzata in Funzioni e Metodi

La deref coercion converte un reference a un type che implementa il trait Deref in un reference a un altro type. Ad esempio, la de-referenziazione forzata può convertire &String in &str perché String implementa il trait Deref in modo tale da restituire &str. La de-referenziazione forzata è una funzionalità che Rust applica agli argomenti di funzioni e metodi e funziona solo sui type che implementano il trait Deref. Avviene automaticamente quando passiamo un reference al valore di un type specifico come argomento a una funzione o a un metodo che non corrisponde al type di parametro nella definizione della funzione o del metodo. Una sequenza di chiamate al metodo deref converte il type fornito nel type richiesto dal parametro.

La deref coercion è stata aggiunta a Rust in modo che i programmatori che scrivono chiamate di funzioni e metodi non debbano esplicitare troppo spesso i reference o i dereference con & e *. La funzionalità di de-referenziazione forzata ci consente anche di scrivere più codice che può funzionare sia per reference che per puntatori intelligenti.

Per vedere la deref coercion in azione, utilizziamo il type MioBox<T> definito nel Listato 15-8 e l’implementazione di Deref aggiunta nel Listato 15-10. Il Listato 15-11 mostra la definizione di una funzione che ha un parametro di type slice stringa.

File: src/main.rs
fn ciao(nome: &str) {
    println!("Ciao, {nome}!");
}

fn main() {}
Listato 15-11: Una funzione ciao che ha il parametro nome di type &str

Possiamo chiamare la funzione ciao con un parametro di type slice stringa come argomento, ad esempio ciao("Rust");. La deref coercion consente di chiamare ciao con un reference a un valore di type MioBox<String>, come mostrato nel Listato 15-12.

File: src/main.rs
use std::ops::Deref;

impl<T> Deref for MioBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MioBox<T>(T);

impl<T> MioBox<T> {
    fn new(x: T) -> MioBox<T> {
        MioBox(x)
    }
}

fn ciao(nome: &str) {
    println!("Ciao, {nome}!");
}

fn main() {
    let m = MioBox::new(String::from("Rust"));
    ciao(&m);
}
Listato 15-12: Chiamata di ciao con un reference a un valore MioBox<String>, che funziona grazie alla deref coercion

Qui chiamiamo la funzione ciao con l’argomento &m, che è un reference a un valore MioBox<String>. Poiché abbiamo implementato il trait Deref su MioBox<T> nel Listato 15-10, Rust può trasformare &MioBox<String> in &String chiamando deref. La libreria standard fornisce un’implementazione di Deref su String che restituisce una slice di stringa, ed è presente nella documentazione API per Deref. Rust chiama nuovamente deref per trasformare &String in &str, che corrisponde alla definizione della funzione ciao.

Se Rust non implementasse la de-referenziazione forzata, dovremmo scrivere il codice nel Listato 15-13 invece del codice nel Listato 15-12 per chiamare ciao con un valore di tipo &MioBox<String>.

File: src/main.rs
use std::ops::Deref;

impl<T> Deref for MioBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MioBox<T>(T);

impl<T> MioBox<T> {
    fn new(x: T) -> MioBox<T> {
        MioBox(x)
    }
}

fn ciao(nome: &str) {
    println!("Ciao, {nome}!");
}

fn main() {
    let m = MioBox::new(String::from("Rust"));
    ciao(&(*m)[..]);
}
Listato 15-13: Il codice che dovremmo scrivere se Rust non avesse la de-referenziazione forzata

(*m) de-referenzia MioBox<String> in una String. Quindi & e [..] prendono una slice di String che è uguale all’intera stringa per corrispondere alla firma di ciao. Questo codice senza deref coercion con tutti questi simboli coinvolti è più difficile da leggere, scrivere e comprendere. La deref consente a Rust di gestire automaticamente queste conversioni.

Quando il trait Deref è definito per i type coinvolti, Rust analizzerà i type e utilizzerà Deref::deref tutte le volte necessarie per ottenere un reference che corrisponda al type del parametro. Il numero di volte in cui Deref::deref deve essere inserito viene risolto in fase di compilazione, quindi non ci sono penalità prestazionali in fase di esecuzione per aver sfruttato la deref coercion!

Gestire la De-referenziazione Forzata con Reference Mutabili

Analogamente a come si usa il trait Deref per sovrascrivere l’operatore * sui reference immutabili, è possibile usare il trait DerefMut per sovrascrivere l’operatore * sui reference mutabili.

Rust esegue la deref coercion quando trova type e implementazioni di trait in tre casi:

  1. Da &T a &U quando T: Deref<Target=U>
  2. Da &mut T a &mut U quando T: DerefMut<Target=U>
  3. Da &mut T a &U quando T: Deref<Target=U>

I primi due casi sono gli stessi, tranne per il fatto che il secondo implementa la mutabilità. Il primo caso afferma che se si ha un &T e T implementa Deref a un type U, è possibile ottenere un &U in modo trasparente. Il secondo caso afferma che la stessa de-referenziazione forzata avviene per i reference mutabili.

Il terzo caso è più complicato: Rust convertirà anche un reference mutabile a uno immutabile. Ma il contrario non è possibile: i reference immutabili non verranno mai convertirti in reference mutabili. A causa delle regole di prestito, se si ha un reference mutabile, quel reference mutabile deve essere l’unico reference a quei dati (altrimenti, il programma non verrebbe compilato). La conversione di un reference mutabile in un reference immutabile non violerà mai le regole di prestito. La conversione di un reference immutabile in un reference mutabile richiederebbe che il reference immutabile iniziale sia l’unico reference immutabile a quei dati, ma le regole di prestito non lo garantiscono. Pertanto, Rust non può dare per scontato che sia possibile convertire un reference immutabile in un reference mutabile.

Eseguire del Codice Durante la Pulizia con il Trait Drop

Il secondo trait importante per i puntatori intelligenti è Drop, che consente di personalizzare ciò che accade quando un valore sta per uscire dallo scope. È possibile fornire un’implementazione per il trait Drop su qualsiasi type e tale codice può essere utilizzato per rilasciare risorse come file o connessioni di rete.

Stiamo introducendo Drop nel contesto dei puntatori intelligenti perché la funzionalità del trait Drop viene quasi sempre utilizzata quando si implementa un puntatore intelligente. Ad esempio, quando una Box<T> viene eliminata, de-alloca lo spazio nell’heap a cui punta la box.

In alcuni linguaggi, per alcuni type, il programmatore deve richiamare il codice per liberare memoria o risorse ogni volta che termina di utilizzare un’istanza di quei type. Esempi includono handle a file, socket e lock. Se il programmatore dimentica di farlo, al sistema potrebbe riempirsi la memoria ed eventualmente bloccarsi. In Rust, è possibile specificare che un particolare pezzo di codice venga eseguito ogni volta che un valore esce dallo scope, e il compilatore inserirà questo codice automaticamente. Di conseguenza, non è necessario prestare attenzione a inserire codice di de-allocazione ovunque in un programma in cui un’istanza di un particolare type viene rilasciata: non si saranno problemi di memoria!

Si specifica il codice da eseguire quando un valore esce dallo scope implementando il trait Drop. Il trait Drop richiede l’implementazione di un metodo chiamato drop che accetta un reference mutabile a self. Per vedere quando Rust chiama drop, implementiamo per ora drop con istruzioni println!.

Il Listato 15-14 mostra una struct MioSmartPointer la cui unica funzionalità personalizzata è quella di stampare Pulizia MioSmartPointer! quando l’istanza esce dallo scope, per mostrare quando Rust esegue il metodo drop.

File: src/main.rs
struct MioSmartPointer {
    data: String,
}

impl Drop for MioSmartPointer {
    fn drop(&mut self) {
        println!("Pulizia MioSmartPointer con dati `{}`!", self.data);
    }
}

fn main() {
    let c = MioSmartPointer {
        data: String::from("mia roba"),
    };
    let d = MioSmartPointer {
        data: String::from("altra roba"),
    };
    println!("MioSmartPointer creati.");
}
Listato 15-14: Una struct MioSmartPointer che implementa il trait Drop dove inseriremo il nostro codice di de-allocazione

Il trait Drop è incluso nel preludio, quindi non è necessario portarlo in scope. Implementiamo il trait Drop su MioSmartPointer e forniamo un’implementazione per il metodo drop che chiama println!. Il corpo del metodo drop è dove inseriremmo qualsiasi logica che si desidera eseguire quando un’istanza del type esce dallo scope. Stiamo stampando del testo per mostrare visivamente quando Rust chiamerà drop.

In main, creiamo due istanze di MioSmartPointer e poi stampiamo MioSmartPointer creati. Alla fine di main, le nostre istanze di MioSmartPointer usciranno dallo scope e Rust chiamerà il codice che abbiamo inserito nel metodo drop, stampando il nostro messaggio finale. Nota che non è stato necessario chiamare esplicitamente il metodo drop.

Quando eseguiamo questo programma, vedremo il seguente output:

$ cargo run
   Compiling esempio-drop v0.1.0 (file:///progetti/esempio-drop)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.02s
     Running `target/debug/esempio-drop`
MioSmartPointer creati.
Pulizia MioSmartPointer con dati `altra roba`!
Pulizia MioSmartPointer con dati `mia roba`!

Rust ha chiamato automaticamente drop per noi quando le nostre istanze sono uscite dallo scope, eseguendo il codice che abbiamo specificato. Le variabili vengono eliminate nell’ordine inverso alla loro creazione, quindi d è stata eliminata prima di c. Lo scopo di questo esempio è fornire una guida visiva al funzionamento del metodo drop; Di solito, si specifica il codice di de-allocazione che il type deve eseguire anziché un messaggio di stampa.

Purtroppo, non è semplice disabilitare la funzionalità automatica drop. Disabilitare drop di solito non è necessario; lo scopo del trait Drop è che venga gestito automaticamente. Occasionalmente, tuttavia, potrebbe essere necessario rilasciare un valore in anticipo. Un esempio è quando si utilizzano puntatori intelligenti che gestiscono i blocchi: potresti voler forzare il metodo drop per rilasciare il blocco in modo che altro codice nello stesso scope possa acquisire il blocco. Rust non consente di chiamare manualmente il metodo drop del trait Drop; invece, è necessario chiamare la funzione std::mem::drop fornita dalla libreria standard se si desidera forzare l’eliminazione di un valore prima della fine del suo scope.

Provare a chiamare manualmente il metodo drop del trait Drop modificando la funzione main del Listato 15-14, come mostrato nel Listato 15-15, risulterà in un errore del compilatore.

File: src/main.rs
struct MioSmartPointer {
    data: String,
}

impl Drop for MioSmartPointer {
    fn drop(&mut self) {
        println!("Pulizia MioSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = MioSmartPointer {
        data: String::from("alcuni dati"),
    };
    println!("MioSmartPointer creato.");
    c.drop();
    println!("MioSmartPointer pulito prima della fine di main.");
}
Listato 15-15: Tentativo di chiamare manualmente il metodo drop del trait Drop per una de-allocazione anticipata

Quando proviamo a compilare questo codice, otterremo questo errore:

$ cargo run
   Compiling esempio-drop v0.1.0 (file:///progetti/esempio-drop)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 |     drop(c);
   |     +++++ ~

For more information about this error, try `rustc --explain E0040`.
error: could not compile `esempio-drop` (bin "esempio-drop") due to 1 previous error

Questo messaggio di errore indica che non siamo autorizzati a chiamare esplicitamente drop. Il messaggio di errore utilizza il termine destructor, che è il termine di programmazione generale per una funzione che de-alloca un’istanza. Un destructor è analogo a un constructor, che crea un’istanza. La funzione drop in Rust è una forma particolare di distruttore.

Rust non ci permette di chiamare drop esplicitamente perché Rust chiamerebbe comunque automaticamente drop sul valore alla fine di main. Questo causerebbe un errore di tipo double free perché Rust cercherebbe di de-allocare lo stesso valore due volte.

Non possiamo disabilitare l’inserimento automatico di drop quando un valore esce dallo scope, e non possiamo chiamare esplicitamente il metodo drop. Quindi, se dobbiamo forzare la de-allocazione anticipata di un valore, usiamo la funzione std::mem::drop.

La funzione std::mem::drop è diversa dal metodo drop nel trait Drop. La chiamiamo passando come argomento il valore di cui vogliamo forzare il rilascio. La funzione si trova nel preludio, quindi possiamo modificare main nel Listato 15-15 per chiamare la funzione drop, come mostrato nel Listato 15-16.

File: src/main.rs
struct MioSmartPointer {
    data: String,
}

impl Drop for MioSmartPointer {
    fn drop(&mut self) {
        println!("Pulizia MioSmartPointer con dati `{}`!", self.data);
    }
}

fn main() {
    let c = MioSmartPointer {
        data: String::from("alcuni dati"),
    };
    println!("MioSmartPointer creato.");
    drop(c);
    println!("MioSmartPointer pulito prima della fine di main.");
}
Listato 15-16: Chiamata a std::mem::drop per eliminare esplicitamente un valore prima che esca dallo scope

L’esecuzione di questo codice stamperà quanto segue:

$ cargo run
   Compiling esempio-drop v0.1.0 (file:///progetti/esempio-drop)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.83s
     Running `target/debug/esempio-drop`
MioSmartPointer creato.
Pulizia MioSmartPointer con dati `alcuni dati`!
MioSmartPointer pulito prima della fine di main.

Il testo Eliminazione di MioSmartPointer con dati `alcuni dati`! viene stampato tra MioSmartPointer creato. e MioSmartPointer pulito prima della fine di main., a dimostrazione del fatto che il codice del metodo drop è chiamato per eliminare c in quel punto.

Puoi utilizzare il codice specificato in un’implementazione del trait Drop in molti modi per rendere la de-allocazione comoda e sicura: ad esempio, è possibile utilizzarlo per creare il proprio allocatore di memoria! Con il trait Drop e il sistema di ownership di Rust, non è necessario ricordarsi di de-allocare perché Rust lo fa automaticamente.

Inoltre, non è necessario preoccuparsi dei problemi derivanti dalla de-allocazione accidentale di valori ancora in uso: il sistema di ownership che garantisce che i reference siano sempre validi garantisce anche che drop venga chiamato solo una volta quando il valore non è più in uso.

Ora che abbiamo esaminato Box<T> e alcune delle caratteristiche dei puntatori intelligenti, diamo un’occhiata ad alcuni altri puntatori intelligenti definiti nella libreria standard.

Rc<T>, il Puntatore Intelligente con Conteggio dei Reference

Nella maggior parte dei casi, la ownership è chiara: si sa esattamente quale variabile possiede un dato valore. Tuttavia, ci sono casi in cui un singolo valore potrebbe avere più proprietari. Ad esempio, nelle strutture dati grafo1, più archi potrebbero puntare allo stesso nodo, e quel nodo è concettualmente di proprietà di tutti gli archi che puntano ad esso. Un nodo non dovrebbe essere de-allocato a meno che non abbia più archi che puntano ad esso e quindi non abbia proprietari.

È necessario abilitare esplicitamente la ownership multipla utilizzando il type Rc<T>, che è l’abbreviazione di conteggio dei reference (reference counting). Il type Rc<T> tiene traccia del numero di reference a un valore per determinare se il valore è ancora in uso. Se non ci sono reference a un valore, il valore può essere de-allocato senza che alcun reference diventi invalido.

Immaginate Rc<T> come una TV in un soggiorno. Quando una persona entra per guardare la TV, la accende. Altri possono entrare nella stanza e guardare la TV. Quando l’ultima persona esce dalla stanza, spegne la TV perché non la sta più utilizzando. Se qualcuno spegne la TV mentre altri la stanno ancora guardando, ci sarebbe un putiferio da parte degli altri spettatori!

Usiamo il type Rc<T> quando vogliamo allocare alcuni dati nell’heap affinché più parti del nostro programma possano leggerli e non possiamo determinare in fase di compilazione quale parte terminerà l’utilizzo dei dati per ultima. Se sapessimo quale parte terminerà per ultima, potremmo semplicemente impostare quella parte come proprietaria dei dati e applicare le normali regole di ownership che entrerebbero in vigore in fase di compilazione.

Nota che Rc<T> è utilizzabile solo in scenari a thread singolo. Quando parleremo di concorrenza nel Capitolo 16, spiegheremo come eseguire il conteggio dei reference nei programmi multi-thread.

Condividere Dati

Torniamo al nostro esempio di cons list del Listato 15-5. Lo avevamo definito utilizzando Box<T>. Questa volta creeremo due elenchi che condividono la proprietà di un terzo elenco. Concettualmente, questo è simile alla Figura 15-3.

Una lista concatenata con
etichetta 'a' che punta a tre elementi: il primo elemento contiene l’intero 5 e
punta al secondo elemento. Il secondo elemento contiene l’intero 10 e punta al
terzo elemento. Il terzo elemento contiene il valore 'Nil' che indica la fine
della lista; non punta da nessuna parte. Una lista concatenata con etichetta 'b'
punta a un elemento che contiene l’intero 3 e punta al primo elemento della
lista 'a'. Una lista concatenata con etichetta 'c' punta a un elemento che
contiene l’intero 4 e punta anche al primo elemento della lista 'a', in modo che
la coda delle liste 'b' e 'c' sia entrambe la lista 'a'

Figura 15-3: Due liste, b e c, che condividono la proprietà di una terza lista, a

Creeremo la lista a che contiene 5 e poi 10. Quindi creeremo altre due liste: b che inizia con 3 e c che inizia con 4. Entrambe le liste b e c proseguiranno quindi con la prima lista a contenente 5 e 10. In altre parole, entrambe le liste condivideranno la prima lista contenente 5 e 10.

Cercare di implementare questo scenario utilizzando la nostra definizione di Lista con Box<T> non funzionerebbe, come mostrato nel Listato 15-17.

File: src/main.rs
enum Lista {
    Cons(i32, Box<Lista>),
    Nil,
}

use crate::Lista::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}
Listato 15-17: Dimostrazione che non è consentito avere due liste che utilizzano Box<T> che tentano di condividere la proprietà di una terza lista

Quando compiliamo questo codice, otteniamo questo errore:

$ cargo run
   Compiling cons-list v0.1.0 (file:///progetti/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
 9 |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `Lista`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

Le varianti Cons hanno ownership dei dati che contengono, quindi quando creiamo la lista b, a viene spostata in b e b possiede a. Quindi, quando proviamo a usare di nuovo a durante la creazione di c, non ci viene consentito perché a è stata spostata.

Potremmo modificare la definizione di Cons per contenere dei reference, ma in tal caso dovremmo poi specificare anche i parametri di lifetime. Specificando i parametri di lifetime, specificheremmo che ogni elemento della lista avrà longevità di almeno quanto l’intera lista. Questo vale per gli elementi e le liste nel Listato 15-17, ma non in tutti gli scenari.

Invece, modificheremo la nostra definizione di Lista per usare Rc<T> al posto di Box<T>, come mostrato nel Listato 15-18. Ogni variante di Cons ora conterrà un valore e un Rc<T> che punta a una Lista. Quando creiamo b, invece di assumere la proprietà di a, cloneremo Rc<Lista> che contiene a, aumentando così il numero di reference da 1 a 2 e lasciando che a e b condividano la ownership dei dati in quella Rc<Lista>. Cloneremo anche a quando creiamo c, aumentando il numero di reference da 2 a 3. Ogni volta che chiamiamo Rc::clone, il conteggio dei reference ai dati all’interno di Rc<Lista> aumenterà e i dati non verranno de-allocati a meno che non ci siano zero riferimenti ad essi.

File: src/main.rs
enum Lista {
    Cons(i32, Rc<Lista>),
    Nil,
}

use crate::Lista::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listato 15-18: Una definizione di Lista che usa Rc<T>

Dobbiamo aggiungere un’istruzione use per portare Rc<T> nello scope perché non è nel preludio. In main, creiamo la lista contenente 5 e 10 e la memorizziamo in una nuova Rc<Lista> in a. Quindi, quando creiamo b e c, chiamiamo la funzione Rc::clone e passiamo un reference a Rc<Lista> in a come argomento.

Avremmo potuto chiamare a.clone() invece di Rc::clone(&a), ma la convenzione di Rust in questo caso prevede di usare Rc::clone. L’implementazione di Rc::clone non esegue una copia profonda (deep copy) di tutti i dati come fanno la maggior parte delle implementazioni di clone dei vari type. La chiamata a Rc::clone incrementa solo il conteggio dei reference, il che non richiede molto tempo. Le copie profonde dei dati possono richiedere molto tempo. Utilizzando Rc::clone per il conteggio dei reference, possiamo distinguere visivamente tra i type che clonano usando la copia profonda e quelli che aumentano il conteggio dei reference. Quando cercheremo problemi di prestazioni nel codice, possiamo considerare solo i type che clonano tramite copia profonda e possiamo ignorare le chiamate a Rc::clone.

Clonare per Aumentare il Conteggio dei Reference

Modifichiamo il nostro esempio di lavoro nel Listato 15-18 in modo da poter vedere i conteggi dei reference cambiare quando creiamo ed eliminiamo reference a Rc<Lista> in a.

Nel Listato 15-19, modificheremo main in modo che abbia uno scope interno attorno alla lista c; poi potremo vedere come cambia il conteggio dei reference quando c esce dallo scope.

File: src/main.rs
enum Lista {
    Cons(i32, Rc<Lista>),
    Nil,
}

use crate::Lista::{Cons, Nil};
use std::rc::Rc;

// --taglio--

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("conteggio dopo la creazione di a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("conteggio dopo la creazione di b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("conteggio dopo la creazione di c = {}", Rc::strong_count(&a));
    }
    println!("conteggio dopo che c è uscita dallo scope c = {}", Rc::strong_count(&a));
}
Listato 15-19: Stampa del conteggio dei reference

In ogni punto del programma in cui il conteggio dei reference cambia, stampiamo il conteggio dei reference, che otteniamo chiamando la funzione Rc::strong_count. Questa funzione si chiama strong_count anziché count perché il type Rc<T> ha anche un weak_count; vedremo a cosa serve weak_count in “Prevenire Cicli di Riferimento Usando Weak<T>.

Questo codice stampa quanto segue:

$ cargo run
   Compiling cons-list v0.1.0 (file:///progetti/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.17s
     Running `target/debug/cons-list`
conteggio dopo la creazione di a = 1
conteggio dopo la creazione di b = 2
conteggio dopo la creazione di c = 3
conteggio dopo che c è uscita dallo scope c = 2

Possiamo vedere che Rc<Lista> in a ha un conteggio dei reference iniziale pari a 1; quindi ogni volta che chiamiamo clone, il conteggio aumenta di 1. Quando c esce dallo scope, il conteggio diminuisce di 1. Non dobbiamo chiamare una funzione per diminuire il conteggio dei reference, come dobbiamo chiamare Rc::clone per aumentarlo: l’implementazione del trait Drop diminuisce il conteggio dei reference automaticamente quando un valore Rc<T> esce dallo scope.

Ciò che non possiamo vedere in questo esempio è che quando b e poi a escono dallo scope alla fine di main, il conteggio diventa 0 e Rc<Lista> viene de-allocato completamente. L’utilizzo di Rc<T> consente a un singolo valore di avere più proprietari e il conteggio garantisce che il valore rimanga valido finché uno qualsiasi dei proprietari esiste ancora.

Tramite reference immutabili, Rc<T> consente di condividere dati tra più parti del programma in sola lettura. Se Rc<T> consentisse di avere anche più reference mutabili, si potrebbe violare una delle regole di prestito discusse nel Capitolo 4: più prestiti mutabili nello stesso posto possono causare conflitti di dati e incongruenze. Ma poter mutare i dati è molto utile! Nella prossima sezione, discuteremo di mutabilità interna e del type RefCell<T> che è possibile utilizzare insieme a Rc<T> per lavorare con questa restrizione di immutabilità.


  1. Grafo su wikipedia

RefCell<T> e il Modello di Mutabilità Interna

Interior mutability è un modello di design in Rust che consente di mutare i dati anche in presenza di reference immutabili a tali dati; normalmente, questa azione non è consentita dalle regole di prestito. Per mutare i dati, il modello utilizza codice unsafe all’interno di una struttura dati per modificare le normali regole di Rust che governano la mutabilità e il prestito. Il codice unsafe indica al compilatore che stiamo controllando le regole manualmente invece di affidarci al compilatore affinché le controlli per noi; approfondiremo il codice unsafe nel Capitolo 20.

Possiamo utilizzare type che utilizzano il modello di mutabilità interna solo quando possiamo garantire che le regole di prestito vengano rispettate durante l’esecuzione, anche se il compilatore non può garantirlo. Il codice unsafe coinvolto viene quindi racchiuso in un’API sicura e il type esterno rimane immutabile.

Esploriamo questo concetto esaminando il type RefCell<T> che segue il modello di mutabilità interna.

Applicare le Regole di Prestito in Fase di Esecuzione

A differenza di Rc<T>, il type RefCell<T> rappresenta la ownership singola sui dati che contiene. Quindi, cosa rende RefCell<T> diverso da un type come Box<T>? Ricorda le regole di prestito apprese nel Capitolo 4:

  • In qualsiasi momento, puoi avere o un reference mutabile o un numero qualsiasi di reference immutabili (ma non entrambi).
  • I reference devono essere sempre validi.

Con i reference e Box<T>, le invarianti delle regole di prestito vengono applicate in fase di compilazione. Con RefCell<T>, queste invarianti vengono applicate in fase di esecuzione. Con i reference, se si violano queste regole, si otterrà un errore di compilazione. Con RefCell<T>, se si violano queste regole, il programma andrà in panic e si chiuderà.

I vantaggi del controllo delle regole di prestito in fase di compilazione sono che gli errori vengono rilevati durante il processo di sviluppo e non vi è alcun impatto sulle prestazioni in fase di esecuzione perché tutta l’analisi viene completata in anticipo. Per questi motivi, il controllo delle regole di prestito in fase di compilazione è la scelta migliore nella maggior parte dei casi, ed è per questo che questa è la scelta predefinita di Rust.

Il vantaggio del controllo delle regole di prestito in fase di esecuzione è che vengono consentiti determinati scenari di sicurezza della memoria, laddove sarebbero stati non consentiti dai controlli in fase di compilazione. L’analisi statica, come quella effettuata dal compilatore Rust, è intrinsecamente conservativa. Alcune proprietà del codice sono impossibili da rilevare analizzando il codice: l’esempio più famoso è il problema della terminazione (Halting Problem), che esula dall’ambito di questo libro ma è un argomento interessante da approfondire se vuoi.

Poiché alcune analisi sono impossibili, se il compilatore Rust non può essere sicuro che il codice sia conforme alle regole di ownership, potrebbe rifiutare di compilare un programma corretto; in questo modo, è conservativo. Se Rust accettasse un programma errato, gli utenti non potrebbero fidarsi delle garanzie fornite da Rust. Tuttavia, se Rust rifiuta di compilare un programma corretto, il programmatore non sarà certo contento, anche se non è nulla di catastrofico. Il type RefCell<T> è utile quando si è certi che il codice segua le regole di prestito, ma il compilatore non è in grado di comprenderlo e garantirlo.

Simile a Rc<T>, RefCell<T> è utilizzabile solo in scenari a thread singolo e genererà un errore in fase di compilazione se si tenta di utilizzarlo in un contesto multi-thread. Parleremo di come ottenere la funzionalità di RefCell<T> in un programma multi-thread nel Capitolo 16.

Ecco un riepilogo delle ragioni per scegliere Box<T>, Rc<T> o RefCell<T>:

  • Rc<T> consente più proprietari degli stessi dati; Box<T> e RefCell<T> hanno proprietari singoli.
  • Box<T> consente prestiti immutabili o mutabili controllati in fase di compilazione; Rc<T> consente solo prestiti immutabili controllati in fase di compilazione; RefCell<T> consente prestiti immutabili o mutabili controllati in fase di esecuzione.
  • Poiché RefCell<T> consente prestiti mutabili controllati in fase di esecuzione, è possibile modificare il valore all’interno di RefCell<T> anche quando RefCell<T> è immutabile.

Mutare il valore all’interno di un valore immutabile è il modello di Interior Mutability . Esaminiamo una situazione in cui la mutabilità interna è utile e vediamo come sia possibile.

Usare la Mutabilità Interna

Una conseguenza delle regole di prestito è che quando si ha un valore immutabile, non è possibile prenderlo in prestito mutabilmente. Ad esempio, questo codice non verrà compilato:

fn main() {
    let x = 5;
    let y = &mut x;
}

Se provassi a compilare questo codice, otterresti il seguente errore:

$ cargo run
   Compiling borrowing v0.1.0 (file:///progetti/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

Tuttavia, ci sono situazioni in cui sarebbe utile che un valore mutasse se stesso nei suoi metodi, ma apparisse immutabile ad altro codice. Il codice esterno ai metodi del valore non sarebbe in grado di mutare il valore. Usare RefCell<T> è un modo per ottenere la possibilità di avere una mutabilità interna, senza però aggirare completamente le regole di prestito: il controllore di prestito nel compilatore consente questa mutabilità interna e le regole di prestito vengono invece verificate durante l’esecuzione. Se si violano le regole, si otterrà un panic! invece di un errore del compilatore.

Esaminiamo un esempio pratico in cui possiamo usare RefCell<T> per mutare un valore immutabile e vediamo perché è utile.

Testare con gli Oggetti Mock

A volte, durante i test, un programmatore usa un type al posto di un altro, per osservare un comportamento particolare e verificare che sia implementato correttamente. Questo type segnaposto è chiamato test double (doppione di test). Pensalo come ad una controfigura nel cinema, dove una persona interviene e sostituisce un attore per girare una scena particolarmente difficile. I test double sostituiscono altri type durante l’esecuzione dei test. Gli oggetti mock sono type specifici di test double che registrano ciò che accade durante un test, in modo da poter verificare che sono state eseguite le azioni corrette.

Rust non ha oggetti nello stesso senso in cui li hanno altri linguaggi, e Rust non ha funzionalità di oggetti mock integrate nella libreria standard come altri linguaggi. Tuttavia, è sicuramente possibile creare una struct che svolgerà le stesse funzioni di un oggetto mock.

Ecco lo scenario che testeremo: creeremo una libreria che tiene traccia di un valore rispetto a un valore massimo e invia messaggi in base a quanto il valore corrente è vicino al valore massimo. Questa libreria potrebbe essere utilizzata, ad esempio, per tenere traccia della quota di un utente per il numero di chiamate API che gli è consentito effettuare.

La nostra libreria fornirà solo la funzionalità di tracciare quanto un valore è vicino al massimo e quali messaggi dovrebbero essere inviati e in quali momenti. Le applicazioni che utilizzano la nostra libreria dovranno fornire il meccanismo per l’invio dei messaggi: l’applicazione potrebbe mostrare il messaggio direttamente all’utente, inviare un’email, inviare un messaggio di testo o fare altro. La libreria non ha bisogno di conoscere questo dettaglio. Tutto ciò di cui ha bisogno è qualcosa che implementi un trait che forniremo chiamato Messaggero. Il Listato 15-20 mostra il codice della libreria.

File: src/lib.rs
pub trait Messaggero {
    fn invia(&self, msg: &str);
}

pub struct TracciaLimiti<'a, T: Messaggero> {
    messaggero: &'a T,
    valore: usize,
    max: usize,
}

impl<'a, T> TracciaLimiti<'a, T>
where
    T: Messaggero,
{
    pub fn new(messaggero: &'a T, max: usize) -> TracciaLimiti<'a, T> {
        TracciaLimiti {
            messaggero,
            valore: 0,
            max,
        }
    }

    pub fn setta_valore(&mut self, valore: usize) {
        self.valore = valore;

        let percentuale_di_max = self.valore as f64 / self.max as f64;

        if percentuale_di_max >= 1.0 {
            self.messaggero
                .invia("Errore: Hai superato la tua quota!");
        } else if percentuale_di_max >= 0.9 {
            self.messaggero
                .invia("Avviso urgente: Hai utilizzato oltre il 90% della tua quota!");
        } else if percentuale_di_max >= 0.75 {
            self.messaggero
                .invia("Avviso: Hai utilizzato oltre il 75% della tua quota!");
        }
    }
}
Listato 15-20: Una libreria per tenere traccia di quanto un valore sia vicino a un valore massimo e avvisare quando il valore raggiunge determinati livelli

Una parte importante di questo codice è che il trait Messaggero ha un metodo chiamato invia che accetta un reference immutabile a self e il testo del messaggio. Questo trait è l’interfaccia che il nostro oggetto mock deve implementare in modo che il mock possa essere utilizzato allo stesso modo di un oggetto reale. L’altra parte importante è che vogliamo testare il comportamento del metodo setta_valore su TracciaLimiti. Possiamo modificare ciò che passiamo per il parametro valore, ma setta_valore non restituisce nulla su cui fare asserzioni. Vogliamo poter dire che se creiamo un TracciaLimiti con qualcosa che implementa il trait Messaggero e un valore specifico per max, al messaggero viene detto di inviare i messaggi appropriati quando passiamo numeri diversi per valore.

Abbiamo bisogno di un oggetto mock che, invece di inviare un’email o un messaggio di testo quando chiamiamo invia, tenga traccia solo dei messaggi che gli viene detto di inviare. Possiamo creare una nuova istanza dell’oggetto mock, creare un TracciaLimiti che utilizzi l’oggetto mock, chiamare il metodo setta_valore su TracciaLimiti e quindi verificare che l’oggetto mock contenga i messaggi che ci aspettiamo. Il Listato 15-21 mostra un tentativo di implementare un oggetto mock per fare proprio questo, ma il controllo dei prestiti non lo consente.

File: src/lib.rs
pub trait Messaggero {
    fn invia(&self, msg: &str);
}

pub struct TracciaLimiti<'a, T: Messaggero> {
    messaggero: &'a T,
    valore: usize,
    max: usize,
}

impl<'a, T> TracciaLimiti<'a, T>
where
    T: Messaggero,
{
    pub fn new(messaggero: &'a T, max: usize) -> TracciaLimiti<'a, T> {
        TracciaLimiti {
            messaggero,
            valore: 0,
            max,
        }
    }

    pub fn setta_valore(&mut self, valore: usize) {
        self.valore = valore;

        let percentuale_di_max = self.valore as f64 / self.max as f64;

        if percentuale_di_max >= 1.0 {
            self.messaggero.invia("Errore: Hai superato la tua quota!");
        } else if percentuale_di_max >= 0.9 {
            self.messaggero
                .invia("Avviso urgente: Hai utilizzato oltre il 90% della tua quota!");
        } else if percentuale_di_max >= 0.75 {
            self.messaggero
                .invia("Avviso: Hai utilizzato oltre il 75% della tua quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessaggero {
        messaggi_inviati: Vec<String>,
    }

    impl MockMessaggero {
        fn new() -> MockMessaggero {
            MockMessaggero {
                messaggi_inviati: vec![],
            }
        }
    }

    impl Messaggero for MockMessaggero {
        fn invia(&self, messaggio: &str) {
            self.messaggi_inviati.push(String::from(messaggio));
        }
    }

    #[test]
    fn invia_un_messaggio_di_avviso_di_superamento_del_75_percento() {
        let mock_messaggero = MockMessaggero::new();
        let mut traccia_limiti = TracciaLimiti::new(&mock_messaggero, 100);

        traccia_limiti.setta_valore(80);

        assert_eq!(mock_messaggero.messaggi_inviati.len(), 1);
    }
}
Listato 15-21: Tentativo di implementare un MockMessaggero non consentito dal controllo dei prestiti

Questo codice di test definisce una struct MockMessaggero che ha un campo messaggi_inviati con un Vec di valori String per tenere traccia dei messaggi che gli viene chiesto di inviare. Definiamo anche una funzione associata new per semplificare la creazione di nuovi valori MockMessaggero che iniziano con un elenco vuoto di messaggi. Implementiamo quindi il trait Messaggero per MockMessaggero in modo da poter assegnare un MockMessaggero a un TracciaLimiti. Nella definizione del metodo invia, prendiamo il messaggio passato come parametro e lo memorizziamo nella lista MockMessaggero di messaggi_inviati.

Nel test, stiamo testando cosa succede quando a TracciaLimiti viene chiesto di impostare valore a un valore superiore al 75% del valore max. Per prima cosa creiamo un nuovo MockMessaggero, che inizierà con una lista vuota di messaggi. Quindi creiamo un nuovo TracciaLimiti e gli diamo un reference al nuovo MockMessaggero e un valore max di 100. Chiamiamo il metodo setta_valore su TracciaLimiti con un valore di 80, che è superiore al 75% di 100. Quindi verifichiamo che la lista di messaggi di cui MockMessaggero sta tenendo traccia dovrebbe ora contenere un messaggio.

Tuttavia, c’è un problema con questo test, come mostrato qui:

$ cargo test
   Compiling traccia-limiti v0.1.0 (file:///progetti/traccia-limiti)
error[E0596]: cannot borrow `self.messaggi_inviati` as mutable, as it is behind a `&` reference
  --> src/lib.rs:59:13
   |
59 |             self.messaggi_inviati.push(String::from(messaggio));
   |             ^^^^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
 2 ~     fn invia(&mut self, msg: &str);
 3 | }
...
57 |     impl Messaggero for MockMessaggero {
58 ~         fn invia(&mut self, messaggio: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `traccia-limiti` (lib test) due to 1 previous error

Non possiamo modificare MockMessaggero per tenere traccia dei messaggi perché il metodo invia accetta un reference immutabile a self. Inoltre, non possiamo accettare il suggerimento dal testo di errore di utilizzare &mut self sia nel metodo impl che nella definizione del trait. Non vogliamo modificare il trait Messaggero solo per il funzionamento del test. Dobbiamo invece trovare un modo per far funzionare il nostro codice di test correttamente con il nostro design esistente.

Questa è una situazione in cui la mutabilità interna può essere d’aiuto! Memorizzeremo messaggi_inviati all’interno di un RefCell<T>, e poi il metodo invia sarà in grado di modificare messaggi_inviati per memorizzare i messaggi che abbiamo visto. Il Listato 15-22 mostra come fare.

File: src/lib.rs
pub trait Messaggero {
    fn invia(&self, msg: &str);
}

pub struct TracciaLimiti<'a, T: Messaggero> {
    messaggero: &'a T,
    valore: usize,
    max: usize,
}

impl<'a, T> TracciaLimiti<'a, T>
where
    T: Messaggero,
{
    pub fn new(messaggero: &'a T, max: usize) -> TracciaLimiti<'a, T> {
        TracciaLimiti {
            messaggero,
            valore: 0,
            max,
        }
    }

    pub fn set_valore(&mut self, valore: usize) {
        self.valore = valore;

        let percentuale_di_max = self.valore as f64 / self.max as f64;

        if percentuale_di_max >= 1.0 {
            self.messaggero.invia("Errore: Hai superato la tua quota!");
        } else if percentuale_di_max >= 0.9 {
            self.messaggero
                .invia("Avviso urgente: Hai utilizzato oltre il 90% della tua quota!");
        } else if percentuale_di_max >= 0.75 {
            self.messaggero
                .invia("Avviso: Hai utilizzato oltre il 75% della tua quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessaggero {
        messaggi_inviati: RefCell<Vec<String>>,
    }

    impl MockMessaggero {
        fn new() -> MockMessaggero {
            MockMessaggero {
                messaggi_inviati: RefCell::new(vec![]),
            }
        }
    }

    impl Messaggero for MockMessaggero {
        fn invia(&self, messaggio: &str) {
            self.messaggi_inviati.borrow_mut().push(String::from(messaggio));
        }
    }

    #[test]
    fn invia_un_messaggio_di_avviso_di_superamento_del_75_percento() {
        // --taglio--
        let mock_messaggero = MockMessaggero::new();
        let mut traccia_limiti = TracciaLimiti::new(&mock_messaggero, 100);

        traccia_limiti.set_valore(80);

        assert_eq!(mock_messaggero.messaggi_inviati.borrow().len(), 1);
    }
}
Listato 15-22: Usare RefCell<T> per modificare un valore interno mentre il valore esterno è considerato immutabile

Il campo messaggi_inviati è ora di type RefCell<Vec<String>> invece di Vec<String>. Nella funzione new, creiamo una nuova istanza di RefCell<Vec<String>> incapsulando il vettore vuoto.

Per l’implementazione del metodo send, il primo parametro è ancora un prestito immutabile di self, che corrisponde alla definizione del trait. Chiamiamo borrow_mut su RefCell<Vec<String>> in self.messaggi_inviati per ottenere un reference mutabile al valore all’interno di RefCell<Vec<String>>, che è il vettore. Quindi possiamo chiamare push sul reference mutabile al vettore per tenere traccia dei messaggi inviati durante il test.

L’ultima modifica che dobbiamo apportare riguarda l’asserzione: per vedere quanti elementi ci sono nel vettore interno, chiamiamo borrow su RefCell<Vec<String>> per ottenere un reference immutabile al vettore.

Ora che hai visto come usare RefCell<T>, approfondiamo il suo funzionamento!

Tracciare i Prestiti in Fase di Esecuzione

Quando creiamo reference immutabili e mutabili, utilizziamo rispettivamente la sintassi & e &mut. Con RefCell<T>, utilizziamo i metodi borrow e borrow_mut, che fanno parte dell’API sicura di RefCell<T>. Il metodo borrow restituisce il type di puntatore intelligente Ref<T>, mentre borrow_mut restituisce il type di puntatore intelligente RefMut<T>. Entrambi i type implementano Deref, quindi possiamo trattarli come normali reference.

RefCell<T> tiene traccia di quanti puntatori intelligenti Ref<T> e RefMut<T> sono attualmente attivi. Ogni volta che chiamiamo borrow, RefCell<T> aumenta il conteggio dei prestiti immutabili attivi. Quando un valore Ref<T> esce dallo scope, il conteggio dei prestiti immutabili diminuisce di 1. Proprio come per le regole di prestito in fase di compilazione, RefCell<T> ci consente di avere molti prestiti immutabili o un prestito mutabile in qualsiasi momento.

Se proviamo a violare queste regole, anziché ottenere un errore di compilazione come accadrebbe con i reference, l’implementazione di RefCell<T> andrà in panic in fase di esecuzione. Il Listato 15-23 mostra una modifica dell’implementazione di invia nel Listato 15-22. Stiamo deliberatamente cercando di creare due prestiti mutabili attivi nello stesso scope per dimostrare che RefCell<T> ci impedisce di farlo in fase di esecuzione.

File: src/lib.rs
pub trait Messaggero {
    fn invia(&self, msg: &str);
}

pub struct TracciaLimiti<'a, T: Messaggero> {
    messaggero: &'a T,
    valore: usize,
    max: usize,
}

impl<'a, T> TracciaLimiti<'a, T>
where
    T: Messaggero,
{
    pub fn new(messaggero: &'a T, max: usize) -> TracciaLimiti<'a, T> {
        TracciaLimiti {
            messaggero,
            valore: 0,
            max,
        }
    }

    pub fn set_valore(&mut self, valore: usize) {
        self.valore = valore;

        let percentuale_di_max = self.valore as f64 / self.max as f64;

        if percentuale_di_max >= 1.0 {
            self.messaggero.invia("Errore: Hai superato la tua quota!");
        } else if percentuale_di_max >= 0.9 {
            self.messaggero
                .invia("Avviso urgente: Hai utilizzato oltre il 90% della tua quota!");
        } else if percentuale_di_max >= 0.75 {
            self.messaggero
                .invia("Avviso: Hai utilizzato oltre il 75% della tua quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessaggero {
        messaggi_inviati: RefCell<Vec<String>>,
    }

    impl MockMessaggero {
        fn new() -> MockMessaggero {
            MockMessaggero {
                messaggi_inviati: RefCell::new(vec![]),
            }
        }
    }

    impl Messaggero for MockMessaggero {
        fn invia(&self, messaggio: &str) {
            let mut borrow_uno = self.messaggi_inviati.borrow_mut();
            let mut borrow_due = self.messaggi_inviati.borrow_mut();

            borrow_uno.push(String::from(messaggio));
            borrow_due.push(String::from(messaggio));
        }
    }

    #[test]
    fn invia_un_messaggio_di_avviso_di_superamento_del_75_percento() {
        let mock_messaggero = MockMessaggero::new();
        let mut traccia_limiti = TracciaLimiti::new(&mock_messaggero, 100);

        traccia_limiti.set_valore(80);

        assert_eq!(mock_messaggero.messaggi_inviati.borrow().len(), 1);
    }
}
Listato 15-23: Creazione di due reference mutabili nello stesso scope per verificare che RefCell<T> generi un panic

Creiamo una variabile borrow_uno per il puntatore intelligente RefMut<T> restituito da borrow_mut. Quindi creiamo un altro prestito mutabile allo stesso modo nella variabile borrow_due. Questo crea due reference mutabili nello stesso scope, cosa non consentita. Quando eseguiamo i test per la nostra libreria, il codice nel Listato 15-23 verrà compilato senza errori, ma il test fallirà:

$ cargo test
   Compiling traccia-limiti v0.1.0 (file:///progetti/traccia-limiti)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running unittests src/lib.rs (target/debug/deps/traccia_limiti-b118b9738b4f0e54)

running 1 test
test tests::invia_un_messaggio_di_avviso_di_superamento_del_75_percento ... FAILED

failures:

---- tests::invia_un_messaggio_di_avviso_di_superamento_del_75_percento stdout ----

thread 'tests::invia_un_messaggio_di_avviso_di_superamento_del_75_percento' panicked at src/lib.rs:61:56:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::invia_un_messaggio_di_avviso_di_superamento_del_75_percento

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

Nota che il codice è andato in panic con il messaggio already borrowed: BorrowMutError. Ecco come RefCell<T> gestisce le violazioni delle regole di prestito in fase di esecuzione.

Scegliere di rilevare gli errori di prestito durante l’esecuzione anziché in fase di compilazione, come abbiamo fatto qui, significa che potenzialmente si troverebbero errori nel codice in una fase successiva del processo di sviluppo: probabilmente non prima del rilascio del codice in produzione. Inoltre, il codice subirebbe una piccola penalizzazione delle prestazioni durante l’esecuzione a causa del monitoraggio dei prestiti durante l’esecuzione anziché in fase di compilazione. Tuttavia, l’utilizzo di RefCell<T> consente di scrivere un oggetto fittizio in grado di modificarsi per tenere traccia dei messaggi visualizzati durante l’utilizzo in un contesto in cui sono consentiti solo valori immutabili. È possibile utilizzare RefCell<T> nonostante i suoi compromessi per ottenere più funzionalità rispetto a quelle fornite dai reference standard.

Consentire più Proprietari di Dati Mutabili

Un modo comune per utilizzare RefCell<T> è in combinazione con Rc<T>. Ricorda che Rc<T> consente di avere più proprietari di alcuni dati, ma fornisce solo un accesso immutabile a tali dati. Se hai un Rc<T> che contiene un RefCell<T>, puoi ottenere un valore che può avere più proprietari e che puoi mutare!

Ad esempio, ricorda l’esempio della cons list nel Listato 15-18, dove abbiamo utilizzato Rc<T> per consentire a più liste di condividere la proprietà di un’altra lista. Poiché Rc<T> contiene solo valori immutabili, non possiamo modificare nessuno dei valori nell’elenco una volta creato. Aggiungiamo RefCell<T> per la sua capacità di modificare i valori negli elenchi. Il Listato 15-24 mostra che utilizzando RefCell<T> nella definizione di Cons, possiamo modificare il valore memorizzato in tutte le liste.

File: src/main.rs
#[derive(Debug)]
enum Lista {
    Cons(Rc<RefCell<i32>>, Rc<Lista>),
    Nil,
}

use crate::Lista::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let valore = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&valore), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *valore.borrow_mut() += 10;

    println!("a dopo = {a:?}");
    println!("b dopo = {b:?}");
    println!("c dopo = {c:?}");
}
Listato 15-24: Utilizzo di Rc<RefCell<i32>> per creare una Lista che possiamo modificare

Creiamo un valore che è un’istanza di Rc<RefCell<i32>> e lo memorizziamo in una variabile denominata valore in modo da potervi accedere direttamente in seguito. Quindi creiamo una Lista in a con una variante Cons che contiene valore. Dobbiamo clonare valore in modo che sia a che valore abbiano la ownership del valore 5 interno, anziché trasferire la proprietà da valore ad a o far sì che a prenda il prestito da valore.

Racchiudiamo la lista a in un Rc<T> in modo che quando creiamo le liste b e c, possano entrambe fare riferimento ad a, come abbiamo fatto nel Listato 15-18.

Dopo aver creato le liste in a, b e c, vogliamo aggiungere 10 al valore in valore. Lo facciamo chiamando borrow_mut su valore, che utilizza la funzione di de-referenziazione automatica di cui abbiamo parlato in “Dov’è l’operatore ->?” nel Capitolo 5 per de-referenziare Rc<T> al valore interno RefCell<T>. Il metodo borrow_mut restituisce un puntatore intelligente RefMut<T>, su cui utilizziamo l’operatore di de-referenziazione e modifichiamo il valore interno.

Quando stampiamo a, b e c, possiamo vedere che hanno tutti il valore modificato di 15 anziché 5:

$ cargo run
   Compiling cons-list v0.1.0 (file:///progetti/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.78s
     Running `target/debug/cons-list`
a dopo = Cons(RefCell { value: 15 }, Nil)
b dopo = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c dopo = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

Questa tecnica è davvero interessante! Utilizzando RefCell<T>, abbiamo un valore Lista esternamente immutabile. Ma possiamo usare i metodi su RefCell<T> che forniscono l’accesso alla sua mutabilità interna, così da poter modificare i nostri dati quando necessario. I controlli durante l’esecuzione delle regole di prestito ci proteggono dalle data race, e a volte vale la pena sacrificare un po’ di prestazioni per questa flessibilità nelle nostre strutture dati. Nota che RefCell<T> non funziona per il codice multi-thread! Mutex<T> è la versione di RefCell<T> che funzioni ai ambito multi-thread e ne parleremo nel Capitolo 16.

Cicli di Riferimento Possono Causare Perdite di Memoria

Le garanzie di sicurezza della memoria di Rust rendono difficile, ma non impossibile, creare accidentalmente memoria che non viene mai de-allocata (noto come memory leak, perdita di memoria). Prevenire completamente le perdite di memoria non è una delle garanzie di Rust, il che significa che le perdite di memoria sono sicure in Rust. Possiamo vedere che Rust consente perdite di memoria utilizzando Rc<T> e RefCell<T>: è possibile creare reference in cui gli elementi si riferiscono l’uno all’altro in un ciclo. Questo crea perdite di memoria perché il conteggio dei reference di ciascun elemento nel ciclo non raggiungerà mai 0 e i valori non verranno mai de-allocati.

Creare un Ciclo di Riferimento

Esaminiamo come potrebbe verificarsi un ciclo di riferimento e come prevenirlo, iniziando con la definizione dell’enum Lista e di un metodo coda nel Listato 15-25.

File: src/main.rs
use crate::Lista::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum Lista {
    Cons(i32, RefCell<Rc<Lista>>),
    Nil,
}

impl Lista {
    fn coda(&self) -> Option<&RefCell<Rc<Lista>>> {
        match self {
            Cons(_, elemento) => Some(elemento),
            Nil => None,
        }
    }
}

fn main() {}
Listato 15-25: Una definizione di cons list che contiene un RefCell<T> in modo da poter modificare a cosa fa riferimento una variante Cons

Stiamo utilizzando un’altra variante della definizione Lista del Listato 15-5. Il secondo elemento nella variante Cons è ora RefCell<Rc<Lista>>, il che significa che invece di poter modificare il valore i32 come abbiamo fatto nel Listato 15-24, vogliamo modificare il valore Lista a cui punta una variante Cons. Stiamo anche aggiungendo un metodo coda per facilitare l’accesso al secondo elemento se abbiamo una variante Cons.

Nel Listato 15-26, stiamo aggiungendo una funzione main che utilizza le definizioni nel Listato 15-25. Questo codice crea una lista in a e una lista in b che punta alla lista in a. Quindi modifica la lista in a per puntare a b, creando un ciclo di riferimento. Ci sono istruzioni println! lungo il codice per mostrare quali sono i conteggi dei reference in vari punti di questo processo.

File: src/main.rs
use crate::Lista::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum Lista {
    Cons(i32, RefCell<Rc<Lista>>),
    Nil,
}

impl Lista {
    fn coda(&self) -> Option<&RefCell<Rc<Lista>>> {
        match self {
            Cons(_, elemento) => Some(elemento),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a conteggio rc iniziale = {}", Rc::strong_count(&a));
    println!("a prossimo elemento = {:?}", a.coda());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a conteggio rc dopo creazione b = {}", Rc::strong_count(&a));
    println!("b conteggio rc iniziale = {}", Rc::strong_count(&b));
    println!("b prossimo elemento = {:?}", b.coda());

    if let Some(link) = a.coda() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b conteggio rc dopo modifica a = {}", Rc::strong_count(&b));
    println!("a conteggio rc dopo modifica a = {}", Rc::strong_count(&a));

    // Togli il commento alla prossima riga per vedere che
    // abbiamo un ciclo; causerà un overflow dello stack.
    // println!("a prossimo elemento = {:?}", a.coda());
}
Listato 15-26: Creazione di un ciclo di riferimento di due valori Lista che puntano l’uno all’altro

Creiamo un’istanza Rc<Lista> che contiene un valore Lista nella variabile a con una lista iniziale di 5, Nil. Creiamo quindi un’istanza Rc<Lista> che contiene un altro valore Lista nella variabile b che contiene il valore 10 e punta alla lista in a.

Modifichiamo a in modo che punti a b invece che a Nil, creando un ciclo. Lo facciamo utilizzando il metodo coda per ottenere un reference a RefCell<Rc<Lista>> in a, che inseriamo nella variabile link. Quindi utilizziamo il metodo borrow_mut su RefCell<Rc<Lista>> per modificare il valore al suo interno da Rc<Lista> che contiene un valore Nil a Rc<Lista> in b.

Quando eseguiamo questo codice, lasciando l’ultimo println! commentato per il momento, otterremo questo output:

$ cargo run
   Compiling cons-list v0.1.0 (file:///progetti/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.36s
     Running `target/debug/cons-list`
a conteggio rc iniziale = 1
a prossimo elemento = Some(RefCell { value: Nil })
a conteggio rc dopo creazione b = 2
b conteggio rc iniziale = 1
b prossimo elemento = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b conteggio rc dopo modifica a = 2
a conteggio rc dopo modifica a = 2

Il conteggio dei reference delle istanze di Rc<Lista> sia in a che in b è 2 dopo aver modificato la lista in a in modo che punti a b. Alla fine di main, Rust elimina la variabile b, che riduce il conteggio dei reference dell’istanza b Rc<Lista> da 2 a 1. La memoria che Rc<Lista> ha nell’heap non verrà de-allocata in questo punto perché il suo conteggio dei reference è 1, non 0. Quindi Rust elimina a, che riduce anche il conteggio dei reference dell’istanza a Rc<Lista> da 2 a 1. Anche la memoria di questa istanza non può essere de-allocata, perché l’altra istanza Rc<Lista> fa ancora riferimento ad essa. La memoria allocata alla lista rimarrà non utilizzata per sempre. Per visualizzare questo ciclo di riferimento, abbiamo creato il diagramma in Figura 15-4.

Un rettangolo etichettato 'a'
che punta a un rettangolo contenente l’intero 5. Un rettangolo etichettato 'b'
che punta a un rettangolo contenente l’intero 10. Il rettangolo contenente 5
punta al rettangolo contenente 10, e il rettangolo contenente 10 punta a sua
volta al rettangolo contenente 5, creando un ciclo.

Figura 15-4: Un ciclo di riferimento delle liste a e b che puntano l’una all’altra

Se si rimuove il commento dall’ultimo println! e si esegue il programma, Rust proverà a stampare questo ciclo con a che punta a b che punta a a e così via fino a quando lo stack non si riempie completamente (stack overflow).

Rispetto a un programma reale, le conseguenze della creazione di un ciclo di riferimento in questo esempio non sono poi così gravi: subito dopo aver creato il ciclo di riferimento, il programma termina. Tuttavia, se un programma più complesso allocasse molta memoria in un ciclo e la mantenesse per un lungo periodo, utilizzerebbe più memoria del necessario e potrebbe sovraccaricare il sistema, causando l’esaurimento della memoria disponibile.

Creare cicli di riferimento non è facile, ma non è nemmeno impossibile. Se si hanno valori RefCell<T> che contengono valori Rc<T> o simili combinazioni annidate di type con mutabilità interna e conteggio dei reference, è necessario assicurarsi di non creare cicli; non ci si può affidare a Rust per individuarli. Creare un ciclo di riferimento rappresenterebbe un bug logico nel programma che bisognerebbe minimizzare tramite test automatizzati, revisioni del codice e altre pratiche di sviluppo software.

Un’altra soluzione per evitare i cicli di riferimento è riorganizzare le strutture dati in modo che alcuni reference esprimano la ownership e altri no. Di conseguenza, si possono avere cicli composti da alcune relazioni di ownership e alcune relazioni di non ownership, e solo le relazioni di ownership influiscono sulla possibilità o meno di eliminare un valore. Nel Listato 15-25, vogliamo sempre che le varianti Cons posseggano la propria lista, quindi riorganizzare la struttura dati non è possibile. Diamo un’occhiata a un esempio che utilizza grafi composti da nodi genitore e nodi figlio per vedere quando le relazioni di non ownership sono un modo appropriato per prevenire i cicli di riferimento.

Prevenire Cicli di Riferimento Usando Weak<T>

Finora, abbiamo dimostrato che la chiamata a Rc::clone aumenta lo strong_count di un’istanza di Rc<T> e che un’istanza di Rc<T> viene de-allocata solo se il suo strong_count è 0. È anche possibile creare un reference debole (weak reference) al valore all’interno di un’istanza di Rc<T> chiamando Rc::downgrade e passando un reference a Rc<T>. I reference forti (strong reference) rappresentano il modo in cui è possibile condividere la ownership di un’istanza di Rc<T>. I reference deboli non esprimono una relazione di ownership e il loro conteggio non influisce sulla de-allocazione di un’istanza di Rc<T>. Non causeranno un ciclo di riferimento perché qualsiasi ciclo che coinvolga reference deboli verrà interrotto quando il conteggio dei valori coinvolti nei reference forti sarà pari a 0.

Quando si chiama Rc::downgrade, si ottiene un puntatore intelligente di type Weak<T>. Invece di aumentare di 1 il valore strong_count nell’istanza di Rc<T>, la chiamata Rc::downgrade aumenta di 1 il valore weak_count. Il type Rc<T> utilizza weak_count per tenere traccia del numero di reference Weak<T> esistenti, in modo simile a strong_count. La differenza è che weak_count non deve essere 0 affinché l’istanza Rc<T> venga de-allocata.

Poiché il valore a cui fa riferimento Weak<T> potrebbe essere stato eliminato, per fare qualsiasi cosa con il valore a cui Weak<T> punta, è necessario assicurarsi che il valore esista ancora. Per farlo, devi chiamare il metodo upgrade su un’istanza Weak<T> che restituirà Option<Rc<T>>. Otterrai il risultato Some se il valore Rc<T> non è stato ancora eliminato e il risultato None se il valore Rc<T> è stato eliminato. Poiché upgrade restituisce Option<Rc<T>>, Rust garantirà che i casi Some e None vengano gestiti e che non ci saranno puntatori non validi.

Ad esempio, invece di utilizzare una lista i cui elementi conoscono solo l’elemento successivo, creeremo un albero i cui elementi conoscono i loro elementi figlio e il loro elemento genitore.

Creare una Struttura Dati ad Albero

Per iniziare, creeremo un albero con nodi che conoscono i loro nodi figlio. Creeremo una struct denominata Nodo che contiene il proprio valore i32 e i reference ai valori dei suoi Nodo figli:

File: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Nodo {
    valore: i32,
    figli: RefCell<Vec<Rc<Nodo>>>,
}

fn main() {
    let foglia = Rc::new(Nodo {
        valore: 3,
        figli: RefCell::new(vec![]),
    });

    let ramo = Rc::new(Nodo {
        valore: 5,
        figli: RefCell::new(vec![Rc::clone(&foglia)]),
    });
}

Vogliamo che un Nodo possieda i suoi figli e vogliamo condividere tale ownership con le variabili in modo da poter accedere direttamente a ciascun Nodo nell’albero. Per fare ciò, definiamo gli elementi Vec<T> come valori di type Rc<Nodo>. Vogliamo anche modificare quali nodi sono figli di un altro nodo, quindi abbiamo una RefCell<T> in figli che incapsula Vec<Rc<Nodo>>.

Successivamente, utilizzeremo la nostra struct qui definita e creeremo un’istanza Nodo denominata foglia con valore 3 e nessun elemento figlio, e un’altra istanza denominata ramo con valore 5 e foglia come elemento figlio, come mostrato nel Listato 15-27.

File: src/main.rs
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Nodo {
    valore: i32,
    figli: RefCell<Vec<Rc<Nodo>>>,
}

fn main() {
    let foglia = Rc::new(Nodo {
        valore: 3,
        figli: RefCell::new(vec![]),
    });

    let ramo = Rc::new(Nodo {
        valore: 5,
        figli: RefCell::new(vec![Rc::clone(&foglia)]),
    });
}
Listato 15-27: Creazione di un nodo foglia senza figli e di un nodo ramo con foglia come figlio

Cloniamo Rc<Nodo> in foglia e lo memorizziamo in ramo, il che significa che il Nodo in foglia ora ha due proprietari: foglia e ramo. Possiamo passare da ramo a foglia tramite ramo.figli, ma non c’è modo di passare da foglia a ramo. Il motivo è che foglia non ha alcun reference a ramo e non sa che sono correlati. Vogliamo che anche foglia sappia che ramo è il suo genitore. Lo faremo ora.

Aggiungere un Reference da un Nodo Figlio al Genitore

Per far sì che il nodo figlio riconosca il suo genitore, dobbiamo aggiungere un campo genitore alla definizione della nostra struct Nodo. Il problema sta nel decidere quale tipo di genitore debba essere. Sappiamo che non può contenere un Rc<T>, perché ciò creerebbe un ciclo di riferimenti con foglia.genitore che punta a ramo e ramo.figlio che punta a foglia, il che farebbe sì che i loro valori strong_count non siano mai pari a 0.

Pensando alle relazioni in un altro modo, un nodo genitore dovrebbe possedere i suoi figli: se un nodo genitore viene eliminato, anche i suoi nodi figli dovrebbero essere eliminati. Tuttavia, un figlio non dovrebbe possedere il suo genitore: se eliminiamo un nodo figlio, il genitore dovrebbe comunque continuare ad esistere. Questa è una situazione in cui i reference deboli sono utili!

Quindi, invece di Rc<T>, faremo in modo che il type di genitore sia Weak<T>, in particolare RefCell<Weak<Nodo>>. Ora la definizione della struct Nodo appare così:

File: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Nodo {
    valore: i32,
    genitore: RefCell<Weak<Nodo>>,
    figli: RefCell<Vec<Rc<Nodo>>>,
}

fn main() {
    let foglia = Rc::new(Nodo {
        valore: 3,
        genitore: RefCell::new(Weak::new()),
        figli: RefCell::new(vec![]),
    });

    println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade());

    let ramo = Rc::new(Nodo {
        valore: 5,
        genitore: RefCell::new(Weak::new()),
        figli: RefCell::new(vec![Rc::clone(&foglia)]),
    });

    *foglia.genitore.borrow_mut() = Rc::downgrade(&ramo);

    println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade());
}

Un nodo potrà fare riferimento al suo nodo genitore, ma non ne sarà il proprietario. Nel Listato 15-28, aggiorniamo main per utilizzare questa nuova definizione, in modo che il nodo foglia abbia un modo per fare riferimento al suo genitore, ramo.

File: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Nodo {
    valore: i32,
    genitore: RefCell<Weak<Nodo>>,
    figli: RefCell<Vec<Rc<Nodo>>>,
}

fn main() {
    let foglia = Rc::new(Nodo {
        valore: 3,
        genitore: RefCell::new(Weak::new()),
        figli: RefCell::new(vec![]),
    });

    println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade());

    let ramo = Rc::new(Nodo {
        valore: 5,
        genitore: RefCell::new(Weak::new()),
        figli: RefCell::new(vec![Rc::clone(&foglia)]),
    });

    *foglia.genitore.borrow_mut() = Rc::downgrade(&ramo);

    println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade());
}
Listato 15-28: Un nodo foglia con un reference debole al suo nodo genitore, ramo

La creazione del nodo foglia è simile a quella del Listato 15-27, ad eccezione del campo genitore: foglia inizia senza un genitore, quindi creiamo una nuova istanza reference vuota Weak<Nodo>.

A questo punto, quando proviamo a ottenere un reference al genitore di foglia utilizzando il metodo upgrade, otteniamo il valore None. Lo vediamo nell’output della prima istruzione println!:

genitore `foglia` = None

Quando creiamo il nodo ramo, avrà anche un nuovo reference Weak<Nodo> nel campo genitore perché ramo non ha un nodo genitore. Abbiamo ancora foglia come uno dei figli di ramo. Una volta che abbiamo l’istanza Nodo in ramo, possiamo modificare foglia per assegnargli un reference Weak<Nodo> al suo genitore. Utilizziamo il metodo borrow_mut su RefCell<Weak<Nodo>> nel campo genitore di foglia, quindi utilizziamo la funzione Rc::downgrade per creare un reference Weak<Nodo> a ramo da Rc<Nodo> in ramo.

Quando stampiamo di nuovo il genitore di foglia, questa volta otterremo una variante Some che contiene ramo: ora foglia può accedere al suo genitore! Quando stampiamo foglia, evitiamo anche il ciclo che alla fine si è concluso con uno stack overflow come nel Listato 15-26; i riferimenti Weak<Nodo> vengono stampati come (Weak):

genitore `foglia` = None
genitore `foglia` = Some(Nodo { valore: 5, genitore: RefCell { value: (Weak) }, figlio: RefCell { value: [Nodo { valore: 3, genitore: RefCell { value: (Weak) }, figlio: RefCell { value: [] } }] } })

L’assenza di output infinito indica che questo codice non ha creato un ciclo di riferimento. Possiamo anche dedurlo osservando i valori ottenuti chiamando Rc::strong_count e Rc::weak_count.

Visualizzare le Modifiche a strong_count e weak_count

Osserviamo come i valori di strong_count e weak_count delle istanze di Rc<Nodo> cambiano creando un nuovo scope interno e spostando la creazione di ramo in tale scope. In questo modo, possiamo vedere cosa succede quando ramo viene creato e poi eliminato quando esce dallo scope. Le modifiche sono mostrate nel Listato 15-29.

File: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Nodo {
    valore: i32,
    genitore: RefCell<Weak<Nodo>>,
    figli: RefCell<Vec<Rc<Nodo>>>,
}

fn main() {
    let foglia = Rc::new(Nodo {
        valore: 3,
        genitore: RefCell::new(Weak::new()),
        figli: RefCell::new(vec![]),
    });

    println!(
        "foglia forte = {}, debole = {}",
        Rc::strong_count(&foglia),
        Rc::weak_count(&foglia),
    );

    {
        let ramo = Rc::new(Nodo {
            valore: 5,
            genitore: RefCell::new(Weak::new()),
            figli: RefCell::new(vec![Rc::clone(&foglia)]),
        });

        *foglia.genitore.borrow_mut() = Rc::downgrade(&ramo);

        println!(
            "ramo forte = {}, debole = {}",
            Rc::strong_count(&ramo),
            Rc::weak_count(&ramo),
        );

        println!(
            "foglia forte = {}, debole = {}",
            Rc::strong_count(&foglia),
            Rc::weak_count(&foglia),
        );
    }

    println!("genitore `foglia` = {:?}", foglia.genitore.borrow().upgrade());
    println!(
        "foglia forte = {}, debole = {}",
        Rc::strong_count(&foglia),
        Rc::weak_count(&foglia),
    );
}
Listato 15-29: Creazione di ramo in uno scope interno ed esame dei conteggi dei reference forti e deboli

Dopo la creazione di foglia, il suo Rc<Nodo> ha un conteggio forte di 1 e un conteggio debole di 0. Nello scope interno, creiamo ramo e lo associamo a foglia; a quel punto, quando stampiamo i conteggi, Rc<Nodo> in ramo avrà un conteggio forte di 1 e un conteggio debole di 1 (per foglia.genitore che punta a ramo con un Weak<Nodo>). Quando stampiamo i conteggi in foglia, vedremo che avrà un conteggio forte di 2 perché ramo ora ha un clone di Rc<Nodo> di foglia memorizzato in ramo.figlio, ma avrà ancora un conteggio debole di 0.

Quando lo scope interno termina, ramo esce dallo scope e il conteggio forte di Rc<Nodo> scende a 0, quindi il suo Nodo viene eliminato. Il conteggio debole di 1 da foglia.genitore non ha alcuna influenza sull’eliminazione o meno di Nodo, quindi non si verificano perdite di memoria!

Se proviamo ad accedere al genitore di foglia dopo la fine dello scope, otterremo di nuovo None. Alla fine del programma, Rc<Nodo> in foglia ha un conteggio forte di 1 e un conteggio debole di 0 perché la variabile foglia è ora di nuovo l’unico reference a Rc<Nodo>.

Tutta la logica che gestisce i conteggi e l’eliminazione dei valori è integrata in Rc<T> e Weak<T> e nelle loro implementazioni del trait Drop. Specificando che la relazione tra un figlio e il suo genitore debba essere un reference Weak<T> nella definizione di Nodo, è possibile fare in modo che i nodi genitore puntino ai nodi figlio e viceversa senza creare un ciclo di riferimento e perdite di memoria.

Riepilogo

Questo capitolo ha spiegato come utilizzare i puntatori intelligenti per ottenere garanzie e compromessi diversi da quelli che Rust applica di default con i normali reference. Il type Box<T> ha una dimensione nota e punta ai dati allocati nell’heap. Il type Rc<T> tiene traccia del numero di reference ai dati nell’heap, in modo che i dati possano avere più proprietari. Il type RefCell<T> con la sua mutabilità interna ci fornisce un type che possiamo usare quando abbiamo bisogno di un type immutabile ma con la possibilità di modificare un valore interno di quel type; inoltre, applica le regole di prestito in fase di esecuzione anziché in fase di compilazione.

Sono stati inoltre discussi i trait Deref e Drop, che abilitano molte delle funzionalità dei puntatori intelligenti. Abbiamo esplorato i cicli di riferimento che possono causare perdite di memoria e come prevenirle utilizzando Weak<T>.

Se questo capitolo ha suscitato il tuo interesse e desideri implementare i tuoi puntatori intelligenti, consulta “The Rustonomicon” per ulteriori informazioni utili.

In seguito, parleremo della concorrenza in Rust. Imparerai anche a conoscere alcuni nuovi puntatori intelligenti.

Concorrenza Senza Paura

Gestire la programmazione concorrente in modo sicuro ed efficiente è un altro degli obiettivi principali di Rust. La programmazione concorrente, in cui le diverse parti di un programma vengono eseguite in modo indipendente, e la programmazione parallela, in cui le diverse parti di un programma vengono eseguite contemporaneamente, stanno diventando sempre più importanti per sfruttare i processori multi-core dei moderni computer. Storicamente, la programmazione in questi contesti è stata difficile e soggetta a errori. Rust spera di cambiare questa situazione.

Inizialmente, il team di Rust pensava che garantire la sicurezza della memoria e prevenire i problemi di concorrenza fossero due sfide distinte da risolvere con metodi diversi. Con il tempo, il team ha scoperto che i sistemi di ownership e dei type sono un potente insieme di strumenti che aiutano a gestire la sicurezza della memoria e i problemi di concorrenza! Sfruttando la ownership e il controllo dei type, molti errori di concorrenza in Rust sono rilevati in fase di compilazione piuttosto che presentarsi durante l’esecuzione. Pertanto, invece di farti perdere molto tempo a cercare di riprodurre le circostanze esatte in cui si verifica un bug di concorrenza in fase di esecuzione, il codice errato non verrà compilato e presenterà un errore che spiega il problema. Di conseguenza, puoi correggere il tuo codice mentre ci stai lavorando piuttosto che potenzialmente dopo che è stato compilato e distribuito a chi lo utilizza. Abbiamo soprannominato questo aspetto di Rust fearless concurrency (concorrenza senza paura). La concorrenza senza paura ti permette di scrivere codice privo di bug subdoli e facile da riscrivere senza introdurre nuovi bug.

Nota: per semplicità, ci riferiremo a molti problemi come concorrenti piuttosto che essere più precisi dicendo concorrenti e/o paralleli. Per questo capitolo, sostituisci mentalmente concorrenti e/o paralleli ogni volta che usiamo concorrenti. Nel prossimo capitolo, dove la distinzione è più importante, saremo più specifici.

Molti linguaggi sono dogmatici sulle soluzioni che offrono per gestire i problemi di concorrenza. Ad esempio, Erlang dispone di eleganti funzionalità per la concomitanza con il passaggio di messaggi, ma mette a disposizione solo metodi astrusi per condividere lo stato tra i thread. Supportare solo un sottoinsieme di soluzioni possibili è una strategia ragionevole per i linguaggi di livello superiore, perché un linguaggio di livello superiore promette di trarre vantaggio dalla rinuncia a un po’ di controllo per ottenere astrazioni. Tuttavia, i linguaggi di livello inferiore devono fornire la soluzione con le migliori prestazioni in ogni situazione e hanno meno astrazioni sull’hardware. Per questo motivo, Rust offre una varietà di strumenti per modellare i problemi in qualsiasi modo sia appropriato per la tua situazione e i tuoi requisiti.

Ecco gli argomenti che tratteremo in questo capitolo:

  • Come creare thread per eseguire più parti di codice contemporaneamente
  • Concorrenza con passaggio di messaggi (Message-passing concurrency), in cui i canali inviano messaggi tra i thread
  • Concorrenza con stato condiviso (Shared-state concurrency), in cui più thread hanno accesso ad alcuni dati
  • I trait Sync e Send, che estendono le garanzie di concorrenza di Rust ai type definiti dall’utente oltre che ai type forniti dalla libreria standard

Usare i Thread Per Eseguire Codice Simultaneamente

Nella maggior parte dei sistemi operativi attuali, il codice di un programma viene eseguito in un processo e il sistema operativo gestisce più processi contemporaneamente. All’interno di un programma, è possibile avere anche parti indipendenti che vengono eseguite simultaneamente. Le funzionalità che eseguono queste parti indipendenti sono chiamate thread. Ad esempio, un server web potrebbe avere più thread in modo da poter rispondere a più richieste contemporaneamente.

Suddividere i calcoli del tuo programma in più thread per eseguire più attività contemporaneamente può migliorare le prestazioni, ma aggiunge anche complessità. Poiché i thread possono essere eseguiti simultaneamente, non c’è alcuna garanzia intrinseca sull’ordine di esecuzione dei thread del tuo codice. Questo può portare a problemi, come ad esempio:

  • Competizione (race condition), in cui i thread accedono ai dati o alle risorse in un ordine incoerente
  • Stallo (deadlock), in cui due thread sono in attesa l’uno dell’altro, impedendo a entrambi di continuare
  • Bug che si verificano solo in determinate situazioni e sono difficili da riprodurre e risolvere in modo affidabile

Rust cerca di mitigare gli effetti negativi dell’uso dei thread, ma programmare in un contesto multi-thread richiede comunque un’attenta riflessione e una struttura del codice diversa da quella dei programmi eseguiti in un singolo thread.

I linguaggi di programmazione implementano i thread in diversi modi e molti sistemi operativi forniscono un’API che il linguaggio di programmazione può richiamare per creare nuovi thread. La libreria standard di Rust utilizza un modello 1:1 di implementazione dei thread, in base al quale un programma utilizza un thread del sistema operativo per ogni thread del linguaggio. Esistono dei crate che implementano altri modelli di threading che fanno dei compromessi diversi rispetto al modello 1:1. (Anche il sistema async di Rust, che vedremo nel prossimo capitolo, fornisce un ulteriore approccio alla concorrenza.)

Creare un Nuovo Thread con spawn

Per creare un nuovo thread, chiamiamo la funzione thread::spawn e le passiamo una chiusura (abbiamo parlato delle chiusure nel Capitolo 13) contenente il codice che vogliamo eseguire nel nuovo thread. L’esempio nel Listato 16-1 stampa del testo da un thread principale e altro testo da un nuovo thread.

File: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("ciao numero {i} dal thread generato!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("ciao numero {i} dal thread principale!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listato 16-1: Creazione di un nuovo thread per stampare una cosa mentre il thread principale stampa qualcos’altro

Come puoi notare dall’output quando il thread main del programma Rust finisce anche il thread generato viene interrotto, che abbia o meno finito di fare quello che doveva fare. Eccolo qui:

ciao numero 1 dal thread principale!
ciao numero 1 dal thread generato!
ciao numero 2 dal thread principale!
ciao numero 2 dal thread generato!
ciao numero 3 dal thread principale!
ciao numero 3 dal thread generato!
ciao numero 4 dal thread principale!
ciao numero 4 dal thread generato!
ciao numero 5 dal thread generato!

Le chiamate a thread::sleep costringono un thread a interrompere la sua esecuzione per un breve periodo, consentendo a un altro thread di funzionare. I thread probabilmente si alterneranno, ma questo non è garantito: dipende da come il sistema operativo pianifica i thread. In questa esecuzione, il thread principale ha stampato per primo, anche se l’istruzione di stampa del thread generato (spawned) appare per prima nel codice. E anche se abbiamo detto al thread generato di stampare finché i non è 9, è arrivato solo a 5 prima che il thread principale si concludesse.

Se esegui questo codice e vedi solo l’output del thread principale o non vedi alcuna sovrapposizione, prova ad aumentare i numeri negli intervalli per creare più opportunità per il sistema operativo di passare da un thread all’altro.

Attendere Che Tutti i Thread Finiscano

Il codice nel Listato 16-1 non solo arresta il thread generato prematuramente nella maggior parte dei casi a causa della fine del thread principale, ma poiché non c’è alcuna garanzia sull’ordine di esecuzione dei thread, non possiamo nemmeno garantire che il thread generato venga eseguito!

Possiamo risolvere il problema del thread generato che non viene eseguito o che termina prematuramente salvando il valore di ritorno di thread::spawn in una variabile. Il type restituito da thread::spawn è JoinHandle<T>. Un JoinHandle<T> è un valore posseduto che, quando chiamiamo il metodo join su di esso, aspetterà che il suo thread finisca. Il Listato 16-2 mostra come utilizzare il JoinHandle<T> del thread creato nel Listato 16-1 e come chiamare join per assicurarsi che il thread generato finisca prima che main si concluda.

File: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("ciao numero {i} dal thread generato!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("ciao numero {i} dal thread principale!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listato 16-2: Salvare un JoinHandle<T> da thread::spawn per garantire che il thread venga eseguito fino al completamento

La chiamata a join sull’handle blocca il thread attualmente in esecuzione fino a quando il thread rappresentato dall’handle non termina. Bloccare un thread significa che a quel thread viene impedito di eseguire lavori o di uscire. Poiché abbiamo inserito la chiamata a join dopo il ciclo for del thread principale, l’esecuzione del Listato 16-2 dovrebbe produrre un risultato simile a questo:

ciao numero 1 dal thread principale!
ciao numero 1 dal thread generato!
ciao numero 2 dal thread principale!
ciao numero 2 dal thread generato!
ciao numero 3 dal thread principale!
ciao numero 3 dal thread generato!
ciao numero 4 dal thread principale!
ciao numero 4 dal thread generato!
ciao numero 5 dal thread generato!
ciao numero 6 dal thread generato!
ciao numero 7 dal thread generato!
ciao numero 8 dal thread generato!
ciao numero 9 dal thread generato!

I due thread continuano ad alternarsi, ma il thread principale attende a causa della chiamata a handle.join() e non termina finché il thread generato non è terminato.

Ma vediamo cosa succede se spostiamo handle.join() prima del ciclo for di main, in questo modo:

File: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("ciao numero {i} dal thread generato!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("ciao numero {i} dal thread principale!");
        thread::sleep(Duration::from_millis(1));
    }
}

Il thread principale aspetterà che il thread generato finisca e poi eseguirà il suo ciclo for, quindi l’output non sarà più alternato, come mostrato qui:

ciao numero 1 dal thread generato!
ciao numero 2 dal thread generato!
ciao numero 3 dal thread generato!
ciao numero 4 dal thread generato!
ciao numero 5 dal thread generato!
ciao numero 6 dal thread generato!
ciao numero 7 dal thread generato!
ciao numero 8 dal thread generato!
ciao numero 9 dal thread generato!
ciao numero 1 dal thread principale!
ciao numero 2 dal thread principale!
ciao numero 3 dal thread principale!
ciao numero 4 dal thread principale!

Piccoli dettagli, come il punto in cui viene chiamato join, possono influenzare l’esecuzione simultanea o meno dei thread.

Usare le Chiusure move con i Thread

Spesso useremo la parola chiave move con le chiusure passate a thread::spawn perché la chiusura prenderà la ownership dei valori che utilizza dall’ambiente, trasferendo così la ownership di quei valori da un thread all’altro. In “Catturare i Reference o Trasferire la Ownership del Capitolo 13, abbiamo parlato di move nel contesto delle chiusure. Ora ci concentreremo maggiormente sull’interazione tra move e thread::spawn.

Nota nel Listato 16-1 che la chiusura che passiamo a thread::spawn non accetta argomenti: non stiamo utilizzando alcun dato del thread principale nel codice del thread generato. Per utilizzare i dati del thread principale nel thread generato, la chiusura del thread generato deve catturare i valori di cui ha bisogno. Il Listato 16-3 mostra un tentativo di creare un vettore nel thread principale e di utilizzarlo nel thread generato. Tuttavia, questo non funziona ancora, come vedrai tra poco.

File: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Ecco un vettore: {v:?}");
    });

    handle.join().unwrap();
}
Listato 16-3: Tentativo di utilizzare un vettore creato dal thread principale in un altro thread

La chiusura utilizza v, quindi catturerà v e lo renderà parte dell’ambiente della chiusura. Poiché thread::spawn esegue questa chiusura in un nuovo thread, dovremmo essere in grado di accedere a v all’interno di questo nuovo thread. Ma quando compiliamo questo esempio, otteniamo il seguente errore:

$ cargo run
   Compiling threads v0.1.0 (file:///progetti/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Ecco un vettore: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Ecco un vettore: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust inferisce come catturare v e, poiché println! ha bisogno solo di un reference a v, la chiusura cerca di prendere in prestito v. Tuttavia, c’è un problema: Rust non può sapere per quanto tempo verrà eseguito il thread generato, quindi non sa se il reference a v sarà sempre valido.

Il Listato 16-4 mostra uno scenario in cui è più probabile che un reference a v non sia valido.

File: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Ecco un vettore: {v:?}");
    });

    drop(v); // oh, no!

    handle.join().unwrap();
}
Listato 16-4: Un thread con una chiusura che tenta di catturare un reference a v da un thread principale che libera v

Se Rust ci permettesse di eseguire questo codice, è possibile che il thread generato venga immediatamente messo in background senza essere eseguito affatto. Il thread generato ha un reference a v al suo interno, ma il thread principale libera immediatamente v, utilizzando la funzione drop di cui abbiamo parlato nel Capitolo 15. Poi, quando il thread generato viene eseguito, v non è più valido, quindi anche il reference ad esso non è valido. Oh, no!

Per risolvere l’errore del compilatore nel Listato 16-3, possiamo utilizzare i consigli del messaggio di errore:

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

Aggiungendo la parola chiave move prima della chiusura, obblighiamo la chiusura a prendere ownership dei valori che sta utilizzando, invece di permettere a Rust di dedurre che deve prendere in prestito i valori. La modifica al Listato 16-3 mostrata nel Listato 16-5 verrà compilata ed eseguita come previsto.

File: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Ecco un vettore: {v:?}");
    });

    handle.join().unwrap();
}
Listato 16-5: Usare la parola chiave move per forzare una chiusura a prendere ownership dei valori che utilizza

Potremmo essere tentati di fare la stessa cosa per correggere il codice del Listato 16-4 in cui il thread principale chiamava drop utilizzando una chiusura move. Tuttavia, questa correzione non funzionerà perché ciò che il Listato 16-4 sta cercando di fare è vietato per un motivo diverso. Se aggiungessimo move alla chiusura, sposteremmo v nell’ambiente della chiusura e non potremmo più chiamare drop su di essa nel thread principale. Otterremmo invece questo errore del compilatore:

$ cargo run
   Compiling threads v0.1.0 (file:///progetti/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Ecco un vettore: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Le regole di ownership di Rust ci hanno salvato ancora una volta! Abbiamo ricevuto un errore dal codice del Listato 16-3 perché Rust era conservativo e prendeva solo in prestito v per il thread, il che significava che il thread principale poteva teoricamente invalidare il reference del thread generato. Dicendo a Rust di spostare la ownership di v al thread generato, garantiamo a Rust che il thread principale non userà più v. Se modifichiamo il Listato 16-4 nello stesso modo, violeremo le regole di ownership quando cercheremo di usare v nel thread principale. La parola chiave move sovrascrive il comportamento conservativo di Rust di prendere in prestito; non ci permette di violare le regole di ownership.

Ora che abbiamo analizzato cosa sono i thread e i metodi forniti dall’API dei thread, vediamo alcune situazioni in cui possiamo utilizzarli.

Trasferire Dati tra Thread Usando il Passaggio di Messaggi

Un approccio sempre più diffuso per garantire una concomitanza sicura è il passaggio di messaggi (message passing), in cui i thread o gli attori comunicano inviandosi messaggi contenenti dati. Ecco l’idea in uno slogan tratto dalla documentazione del linguaggio Go: “Non comunicare condividendo la memoria; condividi invece la memoria comunicando.”

Per realizzare la concomitanza tramite invio di messaggi, la libreria standard di Rust fornisce un’implementazione dei canali. Un canale è un concetto generale di programmazione con cui i dati vengono inviati da un thread all’altro.

Puoi immaginare un canale nella programmazione come un canale d’acqua direzionale, come un ruscello o un fiume. Se metti una paperella di gomma in un fiume, questa viaggerà a valle fino alla fine del corso d’acqua.

Un canale ha due estremità: una trasmettitore e una ricevitore. L’estremità del trasmettitore è il punto a monte in cui metti la paperella di gomma nel fiume, mentre l’estremità del ricevitore è il punto in cui la paperella di gomma finisce a valle. Una parte del tuo codice chiama i metodi del trasmettitore con i dati che vuoi inviare, mentre un’altra parte controlla la ricezione dei messaggi in arrivo. Un canale si dice chiuso se una delle due estremità del trasmettitore o del ricevitore viene cancellata.

Qui lavoreremo su un programma che ha un thread che genera valori e li invia attraverso un canale, e un altro thread che riceve i valori e li stampa. Per illustrare la funzione, invieremo semplici valori tra i thread utilizzando un canale. Una volta che avrai acquisito familiarità con la tecnica, potrai utilizzare i canali per qualsiasi thread che abbia bisogno di comunicare tra loro, come ad esempio un sistema di chat o un sistema in cui molti thread eseguono parti di un calcolo e inviano i risultati parziali a un thread che aggrega i risultati.

Iniziamo nel Listato 16-6 creando semplicemente un canale senza fargli fare nulla. Nota che questo non verrà ancora compilato perché Rust non può dire che tipo di valori vogliamo inviare attraverso il canale.

File: src/main.rs
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}
Listato 16-6: Creare un canale e assegnare le due estremità a tx e rx

Creiamo un nuovo canale utilizzando la funzione mpsc::channel; mpsc sta per multiple producer, single consumer. In breve, il modo in cui la libreria standard di Rust implementa i canali significa che un canale può avere più punti di invio (produttori) che producono valori, ma un solo punto di ricezione (consumatore) che li riceve. Immagina più ruscelli che confluiscono in un unico grande fiume: tutto ciò che viene inviato lungo uno qualsiasi dei ruscelli finirà in un unico fiume alla fine. Inizieremo con un singolo produttore per ora, ma aggiungeremo più produttori quando questo esempio funzionerà.

La funzione mpsc::channel restituisce una tupla, in cui il primo elemento è l’estremità di invio, il trasmettitore, e il secondo elemento è l’estremità di ricezione, il ricevitore. Le abbreviazioni tx e rx sono tradizionalmente utilizzate in molti campi per indicare rispettivamente il trasmettitore e il ricevitore, quindi chiamiamo le nostre variabili in questo modo per indicare ciascuna estremità. Stiamo utilizzando un’istruzione let con un pattern che destruttura la tupla; parleremo più approfonditamente dell’uso dei pattern nelle istruzioni let e della destrutturazione nel Capitolo 19. Per ora, sappi che l’utilizzo di un’istruzione let in questo modo è un approccio conveniente per estrarre i pezzi della tupla restituita da mpsc::channel.

Spostiamo l’estremità di trasmissione in un thread generato e facciamogli inviare una stringa in modo che il thread generato comunichi con il thread principale, come mostrato nel Listato 16-7. Questo è come mettere una paperella di gomma nel fiume a monte o inviare un messaggio di chat da un thread all’altro.

File: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("ciao");
        tx.send(val).unwrap();
    });
}
Listato 16-7: Spostamento di tx in un thread generato e invio di “ciao”

Anche in questo caso, usiamo thread::spawn per generare un nuovo thread e poi usiamo move per spostare tx nella chiusura in modo che il thread generato possieda tx. Il thread generato deve possedere il trasmettitore per poter inviare messaggi attraverso il canale.

Il trasmettitore ha un metodo send (invio) che accetta il valore che vogliamo inviare. Il metodo send restituisce un type Result<T, E>, quindi se il ricevitore è già stato cancellato e non c’è nessuno che possa ricevere quanto inviato, l’operazione di invio restituirà un errore. In questo esempio, chiamiamo unwrap per andare in panic in caso di errore. Ma in un’applicazione reale, lo gestiremmo in modo corretto: torna al Capitolo 9 per rivedere le strategie per una corretta gestione degli errori.

Nel Listato 16-8, otterremo il valore dal ricevitore nel thread principale. È come recuperare la paperella di gomma dall’acqua alla fine del fiume o ricevere un messaggio di chat.

File: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("ciao");
        tx.send(val).unwrap();
    });

    let ricevuto = rx.recv().unwrap();
    println!("Ricevuto: {ricevuto}");
}
Listato 16-8: Ricevere il valore “ciao” nel thread principale e stamparlo

Il ricevitore ha due metodi utili: recv e try_recv. Utilizzeremo recv, abbreviazione di receive (ricevi), che bloccherà l’esecuzione del thread principale e aspetterà che un valore venga ricevuto dal canale. Una volta ricevuto un valore, recv lo restituirà in un Result<T, E>. Quando il trasmettitore si chiude, recv restituirà un errore per segnalare che non arriveranno altri valori.

Il metodo try_recv invece non aspetterà, ma restituisce immediatamente un Result<T, E>: un valore Ok che contiene un messaggio se è disponibile e un valore Err se non ci sono messaggi questa volta. L’uso di try_recv è utile se questo thread ha altro lavoro da fare mentre aspetta i messaggi: potremmo scrivere un ciclo che chiama try_recv di tanto in tanto, gestisce un messaggio se è disponibile e altrimenti svolge altro lavoro per un po’ di tempo fino a quando non viene controllato di nuovo.

In questo esempio abbiamo usato recv per semplicità; non abbiamo altro lavoro da fare per il thread principale oltre all’attesa dei messaggi, quindi bloccare il thread principale è appropriato.

Quando eseguiamo il codice nel Listato 16-8, vedremo il valore stampato dal thread principale:

Ricevuto: ciao

Perfetto!

Trasferire Ownership Attraverso i Canali

Le regole di ownership giocano un ruolo fondamentale nell’invio dei messaggi perché ti aiutano a scrivere codice sicuro e concorrente. Prevenire gli errori nella programmazione concorrente è il vantaggio di pensare in termini di ownership in tutti i tuoi programmi Rust. Facciamo un esperimento per mostrare come i canali e la ownership lavorino insieme per prevenire i problemi: proveremo a usare un valore val nel thread generato dopo che lo abbiamo inviato nel canale. Prova a compilare il codice nel Listato 16-9 per vedere perché questo codice non è consentito.

File: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("ciao");
        tx.send(val).unwrap();
        println!("val è {val}");
    });

    let ricevuto = rx.recv().unwrap();
    println!("Ricevuto: {ricevuto}");
}
Listato 16-9: Tentativo di utilizzare val dopo averlo inviato nel canale

In questo caso, cerchiamo di stampare val dopo averlo inviato nel canale tramite tx.send. Consentire questa operazione sarebbe una cattiva idea: una volta che il valore è stato inviato a un altro thread, questo thread potrebbe modificarlo o liberarne la memoria prima che noi cerchiamo di utilizzarlo di nuovo. Potenzialmente, le modifiche dell’altro thread potrebbero causare errori o risultati inaspettati a causa di dati incoerenti o inesistenti. Tuttavia, Rust ci dà un errore se proviamo a compilare il codice del Listato 16-9:

$ cargo run
   Compiling passaggio-messaggio v0.1.0 (file:///progetti/passaggio-messaggio)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:26
   |
 8 |         let val = String::from("ciao");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
 9 |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val è {val}");
   |                          ^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `passaggio-messaggio` (bin "passaggio-messaggio") due to 1 previous error

Il nostro errore di concorrenza ha causato un errore in fase di compilazione. La funzione send prende ownership del suo parametro e quando il valore viene inviato, è il destinatario che ne prende la ownership. Questo ci impedisce di utilizzare accidentalmente il valore dopo averlo inviato; il sistema di ownership controlla che tutto sia a posto.

Inviare Più Valori

Il codice del Listato 16-8 è stato compilato ed eseguito, ma non mostrava chiaramente che due thread separati stavano parlando tra loro attraverso il canale.

Nel Listato 16-10, abbiamo apportato alcune modifiche che dimostreranno che il codice del Listato 16-8 è in esecuzione simultanea: il thread generato ora invierà più messaggi e farà una pausa di un secondo tra un messaggio e l’altro.

File: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let valori = vec![
            String::from("ciao"),
            String::from("dal"),
            String::from("thread"),
            String::from("!!!"),
        ];

        for valore in valori {
            tx.send(valore).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for ricevuto in rx {
        println!("Ricevuto: {ricevuto}");
    }
}
Listato 16-10: Invio di più messaggi e pausa tra uno e l’altro

Questa volta, il thread generato ha un vettore di stringhe che vogliamo inviare al thread principale. Le iteriamo, inviandole singolarmente, e facciamo una pausa tra una e l’altra chiamando la funzione thread::sleep con un valore Duration di 1 secondo.

Nel thread principale, non chiamiamo più esplicitamente la funzione recv, ma trattiamo rx come un iteratore. Per ogni valore ricevuto, lo stampiamo. Quando il canale viene chiuso perché i messaggi inviati finiscono, l’iterazione termina.

Quando esegui il codice del Listato 16-10, dovresti vedere il seguente output con una pausa di 1 secondo tra una riga e l’altra:

Ricevuto: ciao
Ricevuto: dal
Ricevuto: thread
Ricevuto: !!!

Poiché non abbiamo alcun codice che mette in pausa o ritarda il ciclo for nel thread principale, possiamo dire che il thread principale sta effettivamente aspettando di ricevere i valori dal thread generato.

Creare più Produttori

Prima abbiamo detto che mpsc è l’acronimo di multiple producer, single consumer. Mettiamo in pratica mpsc ed espandiamo il codice del Listato 16-10 per creare thread multipli che tutti inviano i valori allo stesso ricevitore. Possiamo farlo clonando il trasmettitore, come mostrato nel Listato 16-11.

File: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --taglio--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let valori = vec![
            String::from("ciao"),
            String::from("dal"),
            String::from("thread"),
            String::from("!!!"),
        ];

        for valore in valori {
            tx1.send(valore).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let valori = vec![
            String::from("ancora"),
            String::from("messaggi"),
            String::from("per"),
            String::from("te"),
        ];

        for valore in valori {
            tx.send(valore).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for ricevuto in rx {
        println!("Ricevuto: {ricevuto}");
    }

    // --taglio--
}
Listato 16-11: Invio di più messaggi da più produttori

Questa volta, prima di creare il primo thread generato, chiamiamo clone sul trasmettitore. In questo modo avremo un nuovo trasmettitore da passare al primo thread generato. Passiamo poi il trasmettitore originale a un secondo thread generato. In questo modo avremo due thread, ognuno dei quali invierà messaggi diversi all’unico ricevitore.

Quando esegui il codice, l’output dovrebbe essere simile a questo:

Ricevuto: ciao
Ricevuto: altri
Ricevuto: dal
Ricevuto: messaggi
Ricevuto: thread
Ricevuto: per
Ricevuto: !!!
Ricevuto: te

Potresti vedere i valori in un altro ordine, a seconda del tuo sistema. Questo è ciò che rende la concorrenza interessante e difficile. Se sperimenti con thread::sleep, dandogli vari valori nei diversi thread, ogni esecuzione sarà più non deterministica e creerà ogni volta un output diverso.

Ora che abbiamo visto come funzionano i canali, analizziamo un altro metodo di concorrenza.

Concorrenza a Stato Condiviso

Il passaggio di messaggi è un buon modo per gestire la concorrenza, ma non è l’unico. Un altro metodo potrebbe essere quello di far accedere più thread agli stessi dati condivisi. Considera ancora una volta questa parte dello slogan della documentazione del linguaggio Go: “Non comunicare condividendo la memoria”.

Come funzionerebbe comunicare condividendo la memoria? Inoltre, perché gli appassionati della tecnica del passaggio di messaggi raccomandano di non utilizzare la condivisione della memoria?

In un certo senso, i canali in qualsiasi linguaggio di programmazione sono simili alla proprietà singola, perché una volta che trasferisci un valore lungo un canale, non dovresti più utilizzarlo. La concorrenza della memoria condivisa è simile alla proprietà multipla: più thread possono accedere alla stessa posizione di memoria nello stesso momento. Come hai visto nel Capitolo 15, dove i puntatori intelligenti rendono possibile la ownership multipla, questo può aggiungere complessità perché questi diversi proprietari devono essere gestiti. Il sistema dei type e le regole di ownership di Rust aiutano molto a gestire correttamente questo aspetto. Per fare un esempio, vediamo i mutex, uno dei type primitivi di concorrenza più comuni per la memoria condivisa.

Controllare l’Accesso con i Mutex

Mutex è l’abbreviazione di mutual exclusion (mutua esclusione), ovvero un mutex permette a un solo thread di accedere ad alcuni dati in un determinato momento. Per accedere ai dati di un mutex, un thread deve prima segnalare che vuole accedervi chiedendo di acquisire il blocco del mutex. Il blocco (lock) è una struttura di dati che fa parte del mutex e che tiene traccia di chi attualmente ha accesso esclusivo ai dati. Per questo motivo, il mutex può esser visto come un custode di dati a cui garantisce accesso tramite il sistema di blocco.

I mutex hanno la reputazione di essere difficili da usare perché devi ricordare due regole:

  1. Devi cercare di acquisire il blocco prima di utilizzare i dati.
  2. Quando hai finito di utilizzare i dati che il mutex custodisce, devi sbloccare i dati in modo che altri thread possano acquisirne il blocco.

Per una metafora del mondo reale di un mutex, immagina una tavola rotonda a una conferenza con un solo microfono. Prima che un relatore possa parlare, deve chiedere o segnalare che vuole usare il microfono. Quando ottiene il microfono, può parlare per tutto il tempo che vuole e poi passare il microfono al relatore successivo che chiede di parlare. Se un relatore dimentica di passare il microfono quando ha finito, nessun altro potrà parlare. Se chi gestisce la condivisione del microfono condiviso non fa il suo lavoro correttamente, la tavola rotonda non funzionerà come previsto!

La gestione dei mutex può essere incredibilmente complicata, per questo molte persone sono entusiaste dei canali. Tuttavia, grazie al sistema dei type e alle regole di ownership di Rust, non è possibile sbagliare il blocco e lo sblocco.

L’API di Mutex<T>

Come esempio di utilizzo di un mutex, iniziamo con l’utilizzo di un mutex in un contesto a thread singolo, come mostrato nel Listato 16-12.

File: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listato 16-12: Uso dell’API di Mutex<T> in un contesto a thread singolo per semplicità

Come per molti altri type, creiamo un Mutex<T> utilizzando la funzione associata new. Per accedere ai dati all’interno del mutex, utilizziamo il metodo lock per acquisire il blocco. Questa chiamata bloccherà il thread corrente in modo che non possa svolgere alcuna attività finché non sarà il nostro turno di avere il blocco.

La chiamata a lock fallirebbe se un altro thread che detiene il lock andasse in panic. In questo caso, nessuno sarebbe in grado di ottenere il lock, quindi abbiamo scelto di usare unwrap e di far andare in panic questo thread se ci troviamo in quella situazione.

Dopo aver acquisito il blocco, possiamo trattare il valore di ritorno, chiamato num in questo caso, come un reference mutabile ai dati all’interno. Il sistema dei type assicura che acquisiamo un blocco prima di utilizzare il valore in m. Il type di m è Mutex<i32>, non i32, quindi dobbiamo chiamare lock per poter utilizzare il valore i32. Non possiamo dimenticarcene; altrimenti il sistema dei type non ci permetterà di accedere al valore interno i32.

La chiamata a lock restituisce un type chiamato MutexGuard, incapsulato in un LockResult che abbiamo gestito con la chiamata a unwrap. Il type MutexGuard implementa Deref per puntare ai nostri dati interni; il type ha anche un’implementazione Drop che rilascia automaticamente il blocco quando un MutexGuard esce dallo scope, cosa che accade alla fine dello scope interno. Di conseguenza, non rischiamo di dimenticarci di rilasciare il blocco e di bloccare l’utilizzo del mutex da parte di altri thread perché il rilascio del blocco avviene automaticamente.

Dopo aver rilasciato il blocco, possiamo stampare il valore del mutex e vedere che siamo riusciti a cambiare l’interno i32 in 6.

Condividere Accesso a Mutex<T>

Ora proviamo a condividere un valore tra più thread utilizzando Mutex<T>. Avvieremo 10 thread e faremo in modo che ognuno di essi incrementi il valore di un contatore di 1, in modo che il contatore vada da 0 a 10. L’esempio nel Listato 16-13 avrà un errore del compilatore, che useremo per imparare di più sull’uso di Mutex<T> e su come Rust ci aiuta a usarlo correttamente.

File: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let contatore = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = contatore.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Risultato: {}", *contatore.lock().unwrap());
}
Listato 16-13: Dieci thread, ognuno dei quali incrementa un contatore custodito da un Mutex<T>

Creiamo una variabile contatore per contenere un i32 all’interno di un Mutex<T>, come abbiamo fatto nel Listato 16-12. Poi creiamo 10 thread iterando su un intervallo di numeri. Usiamo thread::spawn e diamo a tutti i thread la stessa chiusura: una che sposta il contatore nel thread, acquisisce un blocco sul Mutex<T> chiamando il metodo lock e poi aggiunge 1 al valore nel mutex. Quando un thread termina l’esecuzione della sua chiusura, num uscirà dallo scope e rilascerà il blocco in modo che un altro thread possa acquisirlo.

Nel thread principale, sugli handle dei thread raccolti in un vettore, come fatto nel Listato 16-2, chiamiamo join su ognuno di essi per assicurarci che tutti i thread finiscano. A quel punto, il thread principale acquisirà il blocco e stamperà il risultato di questo programma.

Abbiamo accennato al fatto che questo esempio non sarebbe stato compilato. Ora scopriamo perché!

$ cargo run
   Compiling stato-condiviso v0.1.0 (file:///progetti/stato-condiviso)
error[E0382]: borrow of moved value: `contatore`
  --> src/main.rs:21:32
   |
 5 |     let contatore = Mutex::new(0);
   |         --------- move occurs because `contatore` has type `std::sync::Mutex<i32>`, which does not implement the `Copy` trait
...
 8 |     for _ in 0..10 {
   |     -------------- inside of this loop
 9 |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Risultato: {}", *contatore.lock().unwrap());
   |                                ^^^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
 8 ~     let mut value = contatore.lock();
 9 ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `stato-condiviso` (bin "stato-condiviso") due to 1 previous error

Il messaggio di errore indica che il valore contatore è stato spostato nell’iterazione precedente del ciclo. Rust ci sta dicendo che non possiamo spostare la ownership del blocco contatore in più thread. Risolviamo l’errore del compilatore con il metodo della ownership multipla di cui abbiamo parlato nel Capitolo 15.

Ownership Multipla con Thread Multipli

Nel Capitolo 15, abbiamo dato un valore a più proprietari utilizzando il puntatore intelligente Rc<T> per creare un conteggio di reference. Facciamo lo stesso qui e vediamo cosa succede. Incapsuleremo il Mutex<T> in Rc<T> nel Listato 16-14 e cloneremo Rc<T> prima di spostare la ownership al thread.

File: src/main.rs
use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let contatore = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let contatore = Rc::clone(&contatore);
        let handle = thread::spawn(move || {
            let mut num = contatore.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Risultato: {}", *contatore.lock().unwrap());
}
Listato 16-14: Tentativo di utilizzare Rc<T> per consentire a più thread di possedere il Mutex<T>

Ancora una volta, compiliamo e otteniamo… errori diversi! Il compilatore ci sta insegnando molto.

$ cargo run
   Compiling stato-condiviso v0.1.0 (file:///progetti/stato-condiviso)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = contatore.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1

For more information about this error, try `rustc --explain E0277`.
error: could not compile `stato-condiviso` (bin "stato-condiviso") due to 1 previous error

Wow, questo messaggio di errore è molto prolisso! Ecco la parte importante su cui concentrarsi: `Rc<Mutex<i32>>` cannot be sent between threads safely (Rc<Mutex<i32>> non può essere inviato tra i thread in modo sicuro). Il compilatore ci dice anche il motivo: the trait `Send` is not implemented for `Rc<Mutex<i32>>` ( (il trait Send non è implementato per Rc<Mutex<i32>>). Parleremo di Send nella prossima sezione: è uno dei trait che garantisce che i type che utilizziamo con i thread siano pensati per l’uso in situazioni concorrenti.

Sfortunatamente, Rc<T> non è sicuro da condividere tra i thread. Quando Rc<T> gestisce il conteggio dei reference, aggiunge al conteggio per ogni chiamata a clone e sottrae dal conteggio quando ogni clone viene rilasciato. Ma non utilizza alcun type primitivo di concorrenza per assicurarsi che le modifiche al conteggio non possano essere interrotte da un altro thread. Questo potrebbe portare a conteggi sbagliati; bug che potrebbero a loro volta portare a perdite di memoria o alla de-allocazione di un valore prima che abbiamo finito di usarlo. Ciò di cui abbiamo bisogno è un type che sia esattamente come Rc<T>, ma che apporti modifiche al conteggio dei reference in modo sicuro quando usato con i thread.

Conteggio di Reference Atomico con Arc<T>

Fortunatamente, Arc<T> è un type come Rc<T> che è sicuro da usare in situazioni di concorrenza. La A sta per atomico, cioè è un type contatore di reference atomico. Gli atomici sono un ulteriore type di primitivo di concorrenza che non tratteremo in dettaglio in questa sede: per maggiori dettagli, consulta la documentazione della libreria standard per std::sync::atomic. A questo punto, ti basterà sapere che gli atomici funzionano come i type primitivi ma sono sicuri da condividere tra i thread.

Potresti chiederti perché tutti i type primitivi non sono atomici e perché i type della libreria standard non sono implementati in modo da utilizzare Arc<T> come impostazione predefinita. Il motivo è che la sicurezza dei thread comporta una penalizzazione delle prestazioni che vorrai scegliere di usare solo quando ne hai veramente bisogno. Se stai eseguendo operazioni su valori all’interno di un singolo thread, il tuo codice può funzionare più velocemente se non deve applicare le garanzie che gli atomici forniscono.

Torniamo al nostro esempio: Arc<T> e Rc<T> hanno la stessa API, quindi correggiamo il nostro programma cambiando la riga use, la chiamata a new e la chiamata a clone. Il codice nel Listato 16-15 verrà finalmente compilato ed eseguito.

File: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let contatore = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let contatore = Arc::clone(&contatore);
        let handle = thread::spawn(move || {
            let mut num = contatore.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Risultato: {}", *contatore.lock().unwrap());
}
Listato 16-15: Utilizzo di un Arc<T> per incapsulare il Mutex<T> per poter condividere la ownership tra più thread

Questo codice stamperà quanto segue:

Risultato: 10

Ce l’abbiamo fatta! Abbiamo contato da 0 a 10, il che può non sembrare molto impressionante, ma ci ha insegnato molto su Mutex<T> e sulla sicurezza dei thread. Puoi anche utilizzare la struttura di questo programma per fare operazioni più complicate del semplice incremento di un contatore. Utilizzando questa strategia, puoi dividere un calcolo in parti indipendenti, suddividere queste parti tra i vari thread e poi utilizzare un Mutex<T> per far sì che ogni thread aggiorni il risultato finale con la sua parte.

Nota che se stai eseguendo semplici operazioni numeriche, esistono type più semplici di Mutex<T> forniti dal modulo std::sync::atomic della libreria standard. Questi type forniscono un accesso sicuro, concorrente e atomico ai type primitivi. Abbiamo scelto di utilizzare Mutex<T> con un type primitivo per questo esempio, in modo da poterci concentrare sul funzionamento di Mutex<T>.

Comparazione tra RefCell<T>/Rc<T> e Mutex<T>/Arc<T>

Avrai notato che contatore è immutabile ma possiamo ottenere un reference mutabile al valore al suo interno; questo significa che Mutex<T> fornisce la mutabilità interna, come fa la famiglia Cell. Nello stesso modo in cui abbiamo usato RefCell<T> nel Capitolo 15 per permetterci di mutare i contenuti all’interno di un Rc<T>, usiamo Mutex<T> per mutare i contenuti all’interno di un Arc<T>.

Un altro dettaglio da notare è che Rust non può proteggerti da tutti i tipi di errori logici quando usi Mutex<T>. Ricordiamo dal Capitolo 15 che l’uso di Rc<T> comporta il rischio di creare cicli di riferimento, in cui due valori Rc<T> fanno riferimento l’uno all’altro, causando perdite di memoria. Allo stesso modo, Mutex<T> comporta il rischio di creare dei deadlock (stallo). Questi si verificano quando un’operazione deve bloccare due risorse e due thread hanno acquisito ciascuno uno dei blocchi, facendoli attendere all’infinito l’un l’altro. Se ti interessano i deadlock, prova a creare un programma Rust che abbia un deadlock; quindi ricerca le strategie di mitigazione degli stalli per i mutex in qualsiasi altro linguaggio e prova a implementarle in Rust. La documentazione API della libreria standard per Mutex<T> e MutexGuard offre informazioni utili.

Concluderemo questo capitolo parlando dei trait Send e Sync e di come possiamo utilizzarli con i type personalizzati.

Concorrenza Estensibile con Send e Sync

È interessante notare che quasi tutte le funzioni di concorrenza di cui abbiamo parlato finora in questo capitolo fanno parte della libreria standard, non del linguaggio stesso. Le opzioni per gestire la concorrenza non sono limitate al linguaggio o alla libreria standard; puoi scrivere le tue funzioni di concorrenza o usare quelle scritte da altri.

Tuttavia, tra i concetti chiave della concorrenza che sono incorporati nel linguaggio piuttosto che nella libreria standard ci sono i tratti std::marker, Send e Sync.

Trasferire Ownership tra Thread

Il trait marcatore Send indica che la ownership dei valori del type che implementa Send può essere trasferita tra i thread. Quasi tutti i type di Rust implementano Send, ma ci sono alcune eccezioni, tra cui Rc<T>: questo non può implementare Send perché se si clona un valore Rc<T> e si cerca di trasferire la ownership del clone a un altro thread, entrambi i thread potrebbero aggiornare il conteggio dei reference allo stesso tempo. Per questo motivo, Rc<T> è implementato per essere utilizzato in situazioni a thread singolo in cui non si vuole pagare una penalizzazione in prestazioni rispetto ad una maggiore sicurezza.

Pertanto, il sistema dei type di Rust e i vincoli di trait assicurano che non si possa mai inviare accidentalmente un valore Rc<T> tra i thread in modo non sicuro. Quando abbiamo provato a farlo nel Listato 16-14, abbiamo ottenuto l’errore the trait `Send` is not implemented for `Rc<Mutex<i32>>. Quando siamo passati ad Arc<T>, che implementa Send, il codice è stato compilato.

Qualsiasi type composto interamente da type che implementano Send, anch’esso implementerà automaticamente il trait Send. Quasi tutti i type primitivi implementano Send, a parte i puntatori grezzi, di cui parleremo nel Capitolo 20.

Accedere da Più Thread

Il trait marcatore Sync indica che il type che implementa Sync può essere referenziato da più thread. In altre parole, qualsiasi type T implementa Sync se &T (un reference immutabile a T) implementa Send, il che significa che il reference può essere inviato in modo sicuro a un altro thread. Analogamente a Send, i type primitivi implementano tutti Sync e i type composti interamente da type che implementano Sync, anch’essi implementano Sync.

Il puntatore intelligente Rc<T> non implementa neanche Sync per le stesse ragioni per cui non implementa Send. Il type RefCell<T> (di cui abbiamo parlato nel Capitolo 15) e la famiglia correlata di type Cell<T> non implementano Sync. L’implementazione del controllo dei prestiti che RefCell<T> fa durante l’esecuzione non è sicura per l’uso coi thread. Invece il puntatore intelligente Mutex<T> implementa Sync e può essere utilizzato per condividere l’accesso con più thread, come hai visto in “Condividere Accesso a Mutex<T>.

Implementare Manualmente Send e Sync È Insicuro

Poiché i type composti interamente da altri type che implementano i trait Send e Sync implementano automaticamente anche Send e Sync, non dobbiamo implementare questi trait manualmente. Come trait marcatori, non hanno nemmeno metodi da implementare. Sono solo utili per far rispettare gli invarianti relativi alla concorrenza.

L’implementazione manuale di questi trait comporta l’implementazione di codice Rust insicuro. Parleremo dell’utilizzo di codice Rust insicuro (Unsafe Rust) nel Capitolo 20; per ora, l’informazione importante è che la creazione di nuovi type concorrenti non costituiti da parti Send e Sync richiede un’attenta riflessione per mantenere le garanzie di sicurezza. “The Rustonomicon” contiene maggiori informazioni su queste garanzie e su come rispettarle.

Riepilogo

Non è l’ultima volta che vedrai la concorrenza in questo libro: il prossimo capitolo si concentra sulla programmazione asincrona e il progetto del Capitolo 21 utilizzerà i concetti di questo capitolo in una situazione più realistica rispetto agli esempi minori discussi qui.

Come accennato in precedenza, dato che la gestione della concorrenza in Rust fa parte del linguaggio solo in minima parte, molte soluzioni per la concorrenza sono implementate sotto forma di crate. Questi si evolvono più rapidamente rispetto alla libreria standard, quindi assicurati di cercare online i crate più aggiornati e all’avanguardia da utilizzare in situazioni in cui necessiti di elaborazioni multi-thread.

La libreria standard di Rust fornisce canali per il passaggio di messaggi e i puntatori intelligenti, come Mutex<T> e Arc<T>, che sono sicuri da usare in contesti concorrenti. Il sistema dei type e il controllo di prestiti assicurano che il codice che utilizza queste soluzioni non finisca con accessi ai dati conflittuali o riferimenti non validi. Una volta che avrai compilato il tuo codice, potrai essere certo che verrà eseguito felicemente su più thread senza i tipi di bug difficili da rintracciare comuni in altri linguaggi. La programmazione concorrente non è più un concetto di cui aver paura: vai avanti e rendi i tuoi programmi concorrenti, senza paura!

Fondamenti di Programmazione Asincrona: Async, Await, Future e Stream

Molte operazioni che chiediamo al computer di fare possono richiedere del tempo per completarsi. Sarebbe bello poter fare altro mentre aspettiamo che questi processi che richiedo molto tempo finiscano. I computer moderni offrono due tecniche per lavorare su più operazioni contemporaneamente: parallelismo e concorrenza. Tuttavia, non appena iniziamo a scrivere programmi che coinvolgono operazioni parallele o concorrenti, ci imbattiamo rapidamente in nuove sfide intrinseche alla programmazione asincrona, dove le operazioni potrebbero non finire sequenzialmente nell’ordine in cui sono state avviate. Questo capitolo espande quanto spiegato nel Capitolo 16 sull’uso dei thread per parallelismo e concorrenza, introducendo un approccio alternativo alla programmazione asincrona: i Future , gli Stream, la sintassi async e await che li supporta, e gli strumenti per gestire e coordinare operazioni asincrone.

Consideriamo un esempio. Immagina di esportare un video che hai creato di una celebrazione familiare, un’operazione che potrebbe durare da alcuni minuti a ore. L’esportazione del video userà tutta la potenza disponibile di CPU e GPU. Se avessi solo un core CPU e il tuo sistema operativo non mettesse in pausa quell’esportazione fino al suo completamento - cioè, se la eseguisse sincronicamente - non potresti fare nient’altro sul tuo computer mentre quel compito è in esecuzione. Sarebbe un’esperienza davvero frustrante. Fortunatamente, il sistema operativo del tuo computer può, e lo fa, interrompere invisibilmente l’esportazione abbastanza spesso da permetterti di fare altro lavoro contemporaneamente.

Ora immagina di scaricare un video condiviso da qualcun altro, che può richiedere del tempo ma non occupa altrettanta potenza CPU. In questo caso, la CPU deve aspettare che i dati arrivino dalla rete. Puoi iniziare a leggere i dati non appena iniziano ad arrivare, ma potrebbe volerci del tempo perché siano completamente disponibili. Anche una volta che tutti i dati sono presenti, se il video è molto grande, potrebbe volerci almeno un secondo o due per caricarlo completamente. Potrebbe non sembrare molto, ma è un tempo lunghissimo per un processore moderno, che può eseguire miliardi di operazioni ogni secondo. Ancora una volta, il tuo sistema operativo interromperà invisibilmente il tuo programma per permettere alla CPU di eseguire altro lavoro mentre aspetta che la chiamata di rete finisca.

L’esportazione video è un esempio di operazione vincolata dalla CPU o vincolata dal calcolo. È limitata dalla velocità potenziale di elaborazione dati all’interno della CPU o GPU e da quanto di quella velocità può dedicare all’operazione. Il download video è un esempio di operazione vincolata da I/O, perché è limitata dalla velocità di input e output del computer; può andare solo veloce quanto i dati possono essere inviati attraverso la rete.

In entrambi questi esempi, le invisibili interruzioni del sistema operativo forniscono una forma di concorrenza. Quella concorrenza avviene solo al livello dell’intero programma: il sistema operativo interrompe un programma per permettere ad altri programmi di fare lavoro. In molti casi, poiché comprendiamo i nostri programmi a un livello molto più granulare di quanto faccia il sistema operativo, possiamo individuare opportunità di concorrenza che il sistema operativo non vede.

Ad esempio, se stiamo costruendo uno strumento per gestire download di file, dovremmo essere in grado di scrivere il nostro programma in modo che l’avvio di un download non blocchi l’interfaccia utente, e gli utenti dovrebbero essere in grado di avviare più download contemporaneamente. Molte API del sistema operativo per interagire con la rete sono bloccanti; ovvero, bloccano il progresso del programma finché i dati che stanno elaborando non sono completamente pronti.

Nota: È così che funzionano la maggior parte delle chiamate di funzione, se ci pensi. Tuttavia, il termine bloccante è solitamente riservato per chiamate di funzioni che interagiscono con file, rete o altre risorse del computer, perché questi sono i casi in cui un singolo programma trarrebbe beneficio se l’operazione fosse non-bloccante.

Potremmo evitare di bloccare il nostro thread principale creando un thread dedicato per scaricare ogni file. Tuttavia, l’overhead di questi thread diventerebbe presto un problema. Sarebbe preferibile se la chiamata non bloccasse fin dall’inizio. Sarebbe anche meglio se potessimo scrivere nello stesso stile diretto che usiamo nel codice bloccante, qualcosa di simile a:

let dati = ricevi_dati_da(url).await;
println!("{dati}");

Proprio questo è ciò che l’astrazione async di Rust ci offre. In questo capitolo, imparerai tutto su async mentre affronteremo i seguenti argomenti:

  • Come usare la sintassi async e await di Rust
  • Come usare il modello async per risolvere alcune delle sfide che abbiamo esaminato nel Capitolo 16
  • Come multi-threading e async forniscono soluzioni complementari, che in molti casi puoi combinare

Prima di vedere come async funziona nella pratica, però, dobbiamo fare una breve deviazione per discutere le differenze tra parallelismo e concorrenza.

Parallelismo e Concorrenza

Finora abbiamo trattato parallelismo e concorrenza come quasi intercambiabili. Ora dobbiamo distinguerli più precisamente, perché le differenze emergeranno man mano che inizieremo a lavorarci.

Considera i diversi modi in cui un team potrebbe dividere il lavoro in un progetto software. Potresti assegnare a un singolo membro più compiti, assegnare a ciascun membro un compito, o usare un mix dei due approcci.

Quando un individuo lavora su diversi compiti prima che uno di essi sia completato, questo è concorrenza. Magari hai due progetti diversi aperti sul tuo computer, e quando ti annoi o ti blocchi su un progetto, passi all’altro. Sei una sola persona, quindi non puoi fare progressi su entrambi i compiti esattamente nello stesso momento, ma puoi fare multi-tasking, facendo progressi su uno alla volta passando dall’uno all’altro (vedi Figura 17-1).

Un diagramma con riquadri
etichettati Compito A e Compito B, con diamanti che rappresentano sotto-compiti.
Ci sono frecce che vanno da A1 a B1, B1 a A2, A2 a B2, B2 a A3, A3 a A4, e A4 a
B3. Le frecce tra i sotto-compiti attraversano i riquadri tra Compito A e
Compito B.

Figura 17-1: Un flusso di lavoro concorrente, passando tra Compito A e Compito B

Quando il team divide un insieme di compiti facendo sì che ogni membro prenda un compito e lo porti avanti da solo, questo è parallelismo. Ogni persona del team può fare progressi esattamente nello stesso momento (vedi Figura 17-2).

Un diagramma con riquadri
etichettati Compito A e Compito B, con diamanti che rappresentano sotto-compiti.
Ci sono frecce che vanno da A1 a A2, A2 a A3, A3 a A4, B1 a B2, e B2 a B3.
Nessuna freccia attraversa tra i riquadri di Compito A e Compito B.

Figura 17-2: Un flusso di lavoro parallelo, dove il lavoro avviene sui Compiti A e B indipendentemente

In entrambi questi flussi di lavoro, potresti dover coordinare tra diversi compiti. Forse pensavi che il compito assegnato a una persona fosse totalmente indipendente dal lavoro di tutti gli altri, ma in realtà richiede che un’altra persona del team completi prima il proprio compito. Parte del lavoro potrebbe essere eseguita in parallelo, ma parte di esso sarebbe effettivamente seriale: potrebbe avvenire solo in serie, un compito dopo l’altro, come nella Figura 17-3.

Un diagramma con riquadri
etichettati Compito A e Compito B, con diamanti che rappresentano sotto-compiti.
Ci sono frecce che vanno da A1 a A2, A2 a un paio di linee verticali spesse come
un simbolo di “pausa”, da quel simbolo a A3, B1 a B2, B2 a B3, che è sotto quel
simbolo, B3 a A3, e B3 a B4.

Figura 17-3: Un flusso di lavoro parzialmente parallelo, dove il lavoro sui Compiti A e B procede indipendentemente finché A3 non è bloccato aspettando i risultati di B3.

Allo stesso modo, potresti renderti conto che uno dei tuoi compiti dipende da un altro dei tuoi compiti. Ora il tuo lavoro concorrente è diventato seriale.

Parallelismo e concorrenza possono anche intersecarsi tra loro. Se scopri che un collega è bloccato finché non completi uno dei tuoi compiti, probabilmente concentrerai tutti i tuoi sforzi su quel compito per “sbloccare” il tuo collega. Tu e il tuo collega non siete più in grado di lavorare in parallelo, e non siete nemmeno più in grado di lavorare concorrentemente sui vostri compiti.

Le stesse dinamiche di base si applicano al software e all’hardware. Su una macchina con un singolo core CPU, la CPU può eseguire solo un’operazione alla volta, ma può comunque lavorare concorrentemente. Utilizzando strumenti come thread, processi e async, il computer può mettere in pausa un’attività e passare ad altre prima di tornare eventualmente a quella prima attività. Su una macchina con più core CPU, può anche eseguire lavoro in parallelo. Un core può eseguire un compito mentre un altro core esegue un compito completamente indipendente, e quelle operazioni accadono effettivamente nello stesso momento.

Quando si lavora con async in Rust, stiamo sempre trattando con la concorrenza. A seconda dell’hardware, del sistema operativo e del runtime async che stiamo utilizzando (parleremo presto dei runtime async), quella concorrenza potrebbe anche star utilizzando in realtà il parallelismo.

Ora, immergiamoci in come funziona effettivamente la programmazione asincrona in Rust.

Future e la Sintassi Async

Gli elementi chiave della programmazione asincrona in Rust sono le future e le parole chiave async e await di Rust.

Una future è un valore che potrebbe non essere pronto ora, ma lo diventerà in qualche momento in futuro. (Questo stesso concetto compare in molti linguaggi, a volte sotto altri nomi come task o promise.) Rust fornisce un trait Future come blocco costruttivo in modo che diverse operazioni async possano essere implementate con strutture dati diverse ma con un’interfaccia comune. In Rust, le future sono type che implementano il trait Future. Ogni future contiene le proprie informazioni sui progressi fatti e su cosa significa essere “pronti”.

Puoi applicare la parola chiave async a blocchi e funzioni per specificare che possono essere interrotti e ripresi. All’interno di un blocco async o di una funzione async, puoi usare la parola chiave await per attendere una future (cioè, aspettare che sia pronta). Ogni punto in cui attendi una future all’interno di un blocco o funzione async è un potenziale punto in cui quel blocco o funzione async può mettersi in pausa e riprendere. Il processo di verifica con una future per vedere se il suo valore è già disponibile è chiamato polling.

Alcuni altri linguaggi, come C# e JavaScript, usano parole chiave async e await per la programmazione asincrona. Se hai familiarità con questi linguaggi, potresti notare alcune differenze significative nel modo in cui Rust fa le cose, incluso come gestisce la sintassi. E questo è per una buona ragione, come vedremo!

Quando scriviamo codice async in Rust, usiamo la maggior parte delle volte le parole chiave async e await. Rust le compila in codice equivalente usando il trait Future, proprio come compila i cicli for in codice equivalente usando il trait Iterator. Poiché Rust fornisce il trait Future, puoi anche implementarlo per i type da te definiti quando ne hai bisogno. Molte delle funzioni che vedremo in questo capitolo restituiscono type con le proprie implementazioni di Future. Torneremo alla definizione del trait alla fine del capitolo e approfondiremo come funziona, ma questi dettagli sono sufficienti per procedere.

Tutto questo potrebbe sembrare un po’ astratto, quindi scriviamo il nostro primo programma async: un piccolo web scraper (estrattore info da pagine web). Passeremo due URL dalla riga di comando, li recupereremo contemporaneamente e restituiremo il risultato di quello che finisce per primo. Questo esempio avrà parecchia nuova sintassi, ma non preoccuparti, spiegheremo tutto ciò che serve sapere man mano che procediamo.

Il Nostro Primo Programma Async

Per mantenere l’attenzione di questo capitolo sull’apprendimento di async piuttosto che sulla gestione di parti dell’ecosistema, abbiamo creato il crate trpl (trpl è abbreviazione di “The Rust Programming Language”). Riesporta tutti i type, i trait e le funzioni di cui avrai bisogno, principalmente dai crate futures e tokio. Il crate futures è la sede ufficiale per la sperimentazione Rust del codice async, ed è in realtà dove il trait Future è stato originariamente progettato. Tokio è il runtime async più utilizzato in Rust oggi, specialmente per applicazioni web. Ci sono altri ottimi runtime là fuori, e potrebbero essere più adatti ai tuoi scopi. Usiamo il crate tokio come base per trpl perché è ben testato e ampiamente utilizzato.

In alcuni casi, trpl rinomina o incapsula le API originali per mantenerti concentrato sui dettagli rilevanti per questo capitolo. Se vuoi capire cosa fa il crate, ti incoraggiamo a controllare il suo codice sorgente. Sarai in grado di vedere da quale crate proviene ogni riesportazione, e abbiamo lasciato commenti esaurienti che spiegano cosa fa il crate.

Crea un nuovo progetto binario chiamato hello-async e aggiungi il crate trpl come dipendenza:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

Ora possiamo usare i vari pezzi forniti da trpl per scrivere il nostro primo programma async. Costruiremo un piccolo strumento da riga di comando che recupera due pagine web, estrae l’elemento <title> da ciascuna e stampa il titolo della pagina che completa per prima l’intero processo.

Definire la Funzione titolo_pagina

Iniziamo scrivendo una funzione che prende un URL di una pagina come parametro, la scarica e restituisce il testo dell’elemento del titolo (vedi Listato 17-1).

File: src/main.rs
extern crate trpl; // necessario per test mdbook

fn main() {
    // TODO: lo aggiungeremo in seguito!
}

use trpl::Html;

async fn titolo_pagina(url: &str) -> Option<String> {
    let risposta = trpl::get(url).await;
    let testo_risposta = risposta.text().await;
    Html::parse(&testo_risposta)
        .select_first("title")
        .map(|titolo| titolo.inner_html())
}
Listato 17-1: Definizione di una funzione asincrona per ottenere l’elemento del titolo da una pagina HTML

Per prima cosa, definiamo una funzione chiamata titolo_pagina e la contrassegniamo con la parola chiave async. Poi usiamo la funzione trpl::get per recuperare l’URL passato e aggiungiamo la parola chiave await per aspettare la risposta. Per ottenere il testo della risposta, chiamiamo il suo metodo text e di nuovo aspettiamo con la parola chiave await. Entrambi questi passaggi sono asincroni.

Per la funzione get, dobbiamo aspettare che il server invii la prima parte della sua risposta, che includerà intestazioni HTTP, cookie e così via, e può essere consegnata separatamente dal corpo della risposta. Soprattutto se il corpo è molto grande, può volerci del tempo perché arrivi tutto. Poiché dobbiamo aspettare l’intera risposta, anche il metodo text è asincrono.

Dobbiamo esplicitamente attendere entrambi queste future, perché le future in Rust sono lazy (pigre): non fanno nulla finché non le chiedi di farlo con la parola chiave await. (In effetti, Rust mostrerà un avviso del compilatore se non usi una future.) Questo potrebbe ricordarti la discussione del Capitolo 13 sugli iteratori nella sezione Elaborare una Serie di Elementi con Iteratori. Gli iteratori non fanno nulla a meno che non chiami il loro metodo next, sia direttamente che usando cicli for o metodi come map che usano next sotto il cofano. Allo stesso modo, le future non fanno nulla a meno che tu non le chieda esplicitamente di farlo. Questa pigrizia permette a Rust di evitare di eseguire codice asincrono finché non è effettivamente necessario.

Nota: Questo è diverso dal comportamento che abbiamo visto nel capitolo precedente quando abbiamo usato thread::spawn in Creare un Nuovo Thread con spawn, dove la chiusura passata a un altro thread veniva eseguita immediatamente. È anche diverso da come molti altri linguaggi gestiscono l’asincronia. Ma è importante per Rust poter fornire le sue garanzie di prestazioni, proprio come accade con gli iteratori.

Una volta che abbiamo testo_risposta, possiamo analizzarlo in un’istanza del type Html usando Html::parse. Invece di una stringa grezza, ora abbiamo un tipo di dato che possiamo usare per lavorare con l’HTML come una struttura dati più funzionale. In particolare, possiamo usare il metodo select_first per trovare la prima istanza di un dato selettore CSS. Passando la stringa "title", otterremo il primo elemento <title> nel documento, se presente. Poiché potrebbe non esserci alcun elemento corrispondente, select_first restituisce un Option<ElementRef>. Infine, usiamo il metodo Option::map, che ci permette di lavorare sull’elemento nell’Option se è presente, e non fare nulla se non lo è. (Potremmo anche usare un’espressione match, ma map è più idiomatico.) Nel corpo della funzione che forniamo a map, chiamiamo inner_html su titolo per ottenere il suo contenuto, che è una String. Alla fine dei conti, abbiamo un Option<String>.

Nota che la parola chiave await di Rust va dopo l’espressione che stai attendendo, non prima. Cioè, è una parola chiave post-fissa. Questo potrebbe differire da ciò a cui sei abituato se hai usato async in altri linguaggi, ma in Rust rende le catene di metodi molto più gradevoli da gestire. Di conseguenza, possiamo modificare il corpo di titolo_pagina per concatenare le chiamate di funzione trpl::get e text con await in mezzo, come mostrato nel Listato 17-2.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::Html;

fn main() {
    // TODO: lo aggiungeremo in seguito!
}

async fn titolo_pagina(url: &str) -> Option<String> {
    let testo_risposta = trpl::get(url).await.text().await;
    Html::parse(&testo_risposta)
        .select_first("title")
        .map(|titolo| titolo.inner_html())
}
Listato 17-2: Concatenazione con la parola chiave await

Con questo, abbiamo scritto con successo la nostra prima funzione asincrona! Prima di aggiungere del codice in main per chiamarla, parliamo un po’ di più di cosa abbiamo scritto e cosa significa.

Quando Rust vede un blocco contrassegnato con la parola chiave async, lo compila in un type anonimo e univoco che implementa il trait Future. Quando Rust vede una funzione contrassegnata con async, la compila in una funzione non asincrona il cui corpo è un blocco asincrono. Il type di ritorno di una funzione asincrona è il type anonimo che il compilatore crea per quel blocco asincrono.

Quindi, scrivere async fn è equivalente a scrivere una funzione che restituisce una future del type di ritorno. Per il compilatore, una definizione di funzione come async fn titolo_pagina nel Listato 17-1 è equivalente a una funzione non asincrona definita in questo modo:

#![allow(unused)]
fn main() {
extern crate trpl; // necessario per test mdbook
use std::future::Future;
use trpl::Html;

fn titolo_pagina(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let testo_risposta = trpl::get(url).await.testo_risposta().await;
        Html::parse(&testo_risposta)
            .select_first("title")
            .map(|titolo| titolo.inner_html())
    }
}
}

Analizziamo ogni parte della versione trasformata:

  • Usa la sintassi impl Trait che abbiamo discusso nel Capitolo 10 nella sezione “Usare i Trait come Parametri”.
  • Il trait restituito è una Future con un type associato di Output. Nota che il type Output è Option<String>, che è lo stesso type di ritorno della versione async fn di titolo_pagina.
  • Tutto il codice chiamato nel corpo della funzione originale è racchiuso in un blocco async move. Ricorda che i blocchi sono espressioni. Questo intero blocco è l’espressione restituita dalla funzione.
  • Questo blocco asincrono produce un valore di type Option<String>, come appena descritto. Quel valore corrisponde al type Output nel type di ritorno. È proprio come altri blocchi che hai visto.
  • Il nuovo corpo della funzione è un blocco async move per come usa il parametro url. (Confronteremo molto più approfonditamente async e async move più avanti in questo capitolo).

Ora possiamo chiamare titolo_pagina in main.

Determinare il Titolo di una Singola Pagina

Per iniziare, prenderemo il titolo di una singola pagina. Nel Listato 17-3, seguiamo lo stesso schema che abbiamo usato nel Capitolo 12 per Ricevere Argomenti dalla Riga di Comando. Poi passiamo il primo URL a titolo_pagina e attendiamo il risultato. Poiché il valore prodotto dalla future è un Option<String>, usiamo un’espressione match per stampare messaggi diversi a seconda che la pagina abbia o meno un <title>.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match titolo_pagina(url).await {
        Some(titolo) => println!("Il titolo per {url} era {titolo}"),
        None => println!("{url} non aveva titolo"),
    }
}

async fn titolo_pagina(url: &str) -> Option<String> {
    let testo_risposta = trpl::get(url).await.text().await;
    Html::parse(&testo_risposta)
        .select_first("title")
        .map(|titolo| titolo.inner_html())
}
Listato 17-3: Chiamare la funzione titolo_pagina da main con un argomento fornito dall’utente

Purtroppo, questo codice non si compila. L’unico posto in cui possiamo usare la parola chiave await è in funzioni o blocchi async, e Rust non ci permette di contrassegnare la funzione main speciale come async.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

Il motivo per cui main non può essere contrassegnata async è che il codice async ha bisogno di un runtime: un crate Rust che gestisce i dettagli dell’esecuzione del codice asincrono. La funzione main di un programma può inizializzare un runtime, ma non è un runtime in sé. (Vedremo più avanti perché è così.) Ogni programma Rust che esegue codice asincrono ha almeno un punto in cui configura un runtime ed esegue le future.

La maggior parte dei linguaggi che supportano async includono un runtime, ma Rust no. Invece, ci sono molti runtime asincroni disponibili, ognuno dei quali fa compromessi diversi adatti al caso d’uso che intende coprire. Ad esempio, un server web che gestisce grandi quantità di dati eseguito su CPU multi-core e una grande quantità di RAM ha esigenze molto diverse da un micro-controllore con un singolo core, poca RAM e nessuna capacità di allocazione nell’heap. I crate che forniscono questi runtime spesso forniscono anche versioni async di funzionalità comuni come I/O su file o di rete.

Qui, e nel resto di questo capitolo, useremo la funzione run del crate trpl, che prende una future come argomento e la esegue fino al completamento. Dietro le quinte, chiamare run configura un runtime usato per eseguire la future passata. Una volta che la future è completata, run restituisce qualsiasi valore che la future ha prodotto.

Potremmo passare direttamente la future restituita da titolo_pagina a run, e una volta completata, potremmo fare il match sul risultante Option<String>, come abbiamo provato a fare nel Listato 17-3. Tuttavia, per la maggior parte degli esempi in questo capitolo (e per la maggior parte del codice async nel mondo reale), faremo più di una singola chiamata di funzione async, quindi invece passeremo un blocco async ed esplicitamente attendiamo il risultato della chiamata titolo_pagina, come nel Listato 17-4.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match titolo_pagina(url).await {
            Some(titolo) => println!("Il titolo per {url} era {titolo}"),
            None => println!("{url} non aveva titolo"),
        }
    })
}

async fn titolo_pagina(url: &str) -> Option<String> {
    let testo_risposta = trpl::get(url).await.text().await;
    Html::parse(&testo_risposta)
        .select_first("title")
        .map(|titolo| titolo.inner_html())
}
Listato 17-4: Eseguire ed attendere un blocco async con trpl::run

Quando eseguiamo questo codice, otteniamo il comportamento che avevamo inizialmente previsto:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
Il titolo per https://www.rust-lang.org era
            Rust Programming Language

Bene! Finalmente abbiamo del codice async funzionante! Ma prima di aggiungere il codice per mettere a gara i due siti l’uno contro l’altro, dedichiamo brevemente la nostra attenzione a come funzionano le future.

Ogni punto di attesa (await point) - cioè, ogni punto in cui il codice usa la parola chiave await - rappresenta un punto in cui il controllo viene restituito al runtime. Perché la cosa funzioni, Rust deve tenere traccia dello stato nel blocco async in modo che il runtime possa avviare altro lavoro e poi tornare quando è pronto per provare a far avanzare il primo. Questa è in pratica una macchina a stati finiti1, come se avessi scritto un enum come questo per salvare lo stato corrente ad ogni punto di attesa:

#![allow(unused)]
fn main() {
extern crate trpl; // necessario per test mdbook

enum TitoloPaginaFuture<'a> {
    Iniziale { url: &'a str },
    PrendiPuntoAttesa { url: &'a str },
    TestoPuntoAttesa { risposta: trpl::Response },
}
}

Scrivere il codice per passare manualmente tra ogni stato sarebbe laborioso e soggetto a errori, soprattutto quando è necessario aggiungere più funzionalità e più stati al codice in seguito. Fortunatamente, il compilatore Rust crea e gestisce automaticamente le strutture dati della macchina a stati per il codice async. Tutte le normali regole di prestito e ownership intorno alle strutture dati si applicano ancora, e felicemente, il compilatore gestisce anche la verifica di quelle per noi e fornisce messaggi di errore utili. Ne esamineremo alcuni più avanti in questo capitolo.

Alla fine, qualcosa deve eseguire questa macchina a stati, e quella cosa è un runtime. (Questo è il motivo per cui potresti imbatterti in riferimenti a executor quando cerchi informazioni sui runtime: un executor è la parte di un runtime responsabile dell’esecuzione del codice async.)

Ora puoi vedere perché il compilatore ci ha impedito di rendere main stesso una funzione async nel Listato 17-3. Se main fosse una funzione async, qualcos’altro dovrebbe gestire la macchina a stati per qualsiasi future che main restituisse, ma main è il punto di partenza del programma! Invece, abbiamo chiamato la funzione trpl::run in main per configurare un runtime ed eseguire la future restituita dal blocco async fino al suo completamento.

Nota: Alcuni runtime forniscono macro in modo che tu possa scrivere una funzione main async. Quelle macro riscrivono async fn main() { ... } per essere un normale fn main, che fa la stessa cosa che abbiamo fatto a mano nel Listato 17-4: chiamare una funzione che esegue una future fino al completamento proprio come fa trpl::run.

Ora mettiamo insieme questi pezzi e vediamo come possiamo scrivere codice concorrente.

Mettere a Gara i Due URL l’Uno Contro l’Altro

Nel Listato 17-5, chiamiamo titolo_pagina con due URL diversi passati dalla riga di comando e li mettiamo a gara.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let titolo_fut_1 = titolo_pagina(&args[1]);
        let titolo_fut_2 = titolo_pagina(&args[2]);

        let (url, forse_titolo) =
            match trpl::race(titolo_fut_1, titolo_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} ritornato per primo");
        match forse_titolo {
            Some(titolo) => println!("Il suo titolo era: '{titolo}'"),
            None => println!("Non aveva titolo."),
        }
    })
}

async fn titolo_pagina(url: &str) -> (&str, Option<String>) {
    let testo_risposta = trpl::get(url).await.text().await;
    let titolo = Html::parse(&testo_risposta)
        .select_first("title")
        .map(|titolo| titolo.inner_html());
    (url, titolo)
}
Listato 17-5: Creazione di due future con chiamata a titolo_pagina per farle competere tra loro

Iniziamo chiamando titolo_pagina per ciascuno degli URL forniti dall’utente. Salviamo le future risultanti come titolo_fut_1 e titolo_fut_2. Ricorda, queste non fanno ancora nulla, perché le future sono lazy e non le abbiamo ancora messe in coda. Poi passiamo le future a trpl::race, che restituisce un valore per indicare quale delle future a esso passate finisce per prima.

Nota: Sotto il cofano, race è costruito su una funzione più generale, select, che incontrerai più spesso nel codice Rust reale. Una funzione select può fare molte cose che la funzione trpl::race non può, ma ha anche alcune complessità aggiuntive che possiamo tralasciare per ora.

Può legittimamente “vincere” una qualsiasi delle future, quindi non ha senso restituire un Result. Invece, race restituisce un type che non abbiamo ancora visto, trpl::Either. Il type Either è in qualche modo simile a un Result in quanto ha due casi. A differenza di Result, però, non c’è alcuna nozione di successo o fallimento incorporata in Either. Invece, usa Left e Right per indicare “l’uno o l’altro”:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

La funzione race restituisce Left con l’output dalla prima future che finisce, o Right con l’output della seconda future se quella finisce per prima. Questo corrisponde all’ordine in cui appaiono gli argomenti quando si chiama la funzione: il primo argomento è a sinistra del secondo argomento.

Aggiorniamo anche titolo_pagina per restituire lo stesso URL passato. In modo che, se la pagina che restituisce per prima non ha un <title> che possiamo risolvere, possiamo comunque stampare un messaggio significativo. Con queste informazioni disponibili, concludiamo aggiornando l’output di println! per indicare sia quale URL ha finito per primo, sia qual è il <title>, se presente, per la pagina web a quell’URL.

Hai costruito ora un piccolo web scraper funzionante! Scegli un paio di URL ed esegui lo strumento da riga di comando. Potresti scoprire che alcuni siti sono costantemente più veloci di altri, mentre in altri casi il sito più veloce varia da un’esecuzione all’altra. Cosa più importante, hai imparato le basi del lavoro con le future, quindi ora possiamo approfondire cosa possiamo fare con async.


  1. Macchina a Stati Finiti su wikipedia

Applicare la Concorrenza con Async

In questa sezione, vedremo come usare async per affrontare alcune sfide di concorrenza che abbiamo già visto con i thread nel Capitolo 16. Dato che abbiamo già parlato dei concetti chiave, ci concentreremo sulle differenze tra thread e future.

In molti casi, le API per lavorare con la concorrenza usando async sono molto simili a quelle per usare i thread. In altri casi, finiscono per essere piuttosto diverse. Anche quando le API sembrano simili tra thread e async, spesso hanno comportamenti diversi e quasi sempre caratteristiche di prestazioni differenti.

Creare un Nuovo Task con spawn_task

La prima operazione che abbiamo affrontato in “Creare un Nuovo Thread con spawn era contare su due thread separati. Facciamo la stessa cosa usando async. Il crate trpl fornisce una funzione spawn_task che sembra molto simile all’API thread::spawn, e una funzione sleep che è una versione async dell’API thread::sleep. Possiamo usarle insieme per implementare l’esempio di conteggio, come mostrato nel Listato 17-6.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("ciao numero {i} dal primo task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("ciao numero {i} dal secondo task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}
Listato 17-6: Creare un nuovo task per stampare una cosa mentre il task principale ne stampa un’altra

Come punto di partenza, impostiamo la nostra funzione main con trpl::run in modo che la nostra funzione di livello superiore possa essere async.

Nota: Da questo punto in poi nel capitolo, ogni esempio includerà lo stesso esatto codice di incapsulamento con trpl::run in main, quindi spesso lo salteremo proprio come facciamo con main. Non dimenticare di includerlo nel tuo codice!

Poi scriviamo due loop all’interno di quel blocco, ciascuno contenente una chiamata a trpl::sleep, che aspetta mezzo secondo (500 millisecondi) prima di inviare il prossimo messaggio. Mettiamo un loop nel corpo di un trpl::spawn_task e l’altro è un ciclo for nel task principale. Aggiungiamo anche un await dopo le chiamate sleep.

Questo codice si comporta in modo simile all’implementazione basata su thread, inclusa la possibilità che tu possa vedere i messaggi apparire in un ordine diverso nel tuo terminale quando lo esegui:

ciao numero 1 dal secondo task!
ciao numero 1 dal primo task!
ciao numero 2 dal primo task!
ciao numero 2 dal secondo task!
ciao numero 3 dal primo task!
ciao numero 3 dal secondo task!
ciao numero 4 dal primo task!
ciao numero 4 dal secondo task!
ciao numero 5 dal primo task!

Questa versione si ferma non appena il ciclo for nel corpo del blocco async principale finisce, perché il task avviato da spawn_task viene chiuso quando la funzione main termina. Se vuoi che si esegua fino al completamento del task, dovrai usare un join handle per aspettare che il primo task si completi. Con i thread, abbiamo usato il metodo join per “bloccare” fino a quando il thread avesse finito di eseguirsi. Nel Listato 17-7, possiamo usare await per fare la stessa cosa, perché l’handle del task stesso è un future. Il suo type Output è un Result, quindi dopo averlo atteso (await), dobbiamo anche esporlo (unwrap).

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("ciao numero {i} dal primo task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("ciao numero {i} dal secondo task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}
Listato 17-7: Usare await con un join handle per eseguire un task fino al completamento

Questa versione aggiornata si esegue fino a quando entrambi i loop finiscono.

ciao numero 1 dal secondo task!
ciao numero 1 dal primo task!
ciao numero 2 dal primo task!
ciao numero 2 dal secondo task!
ciao numero 3 dal primo task!
ciao numero 3 dal secondo task!
ciao numero 4 dal primo task!
ciao numero 4 dal secondo task!
ciao numero 5 dal primo task!
ciao numero 6 dal primo task!
ciao numero 7 dal primo task!
ciao numero 8 dal primo task!
ciao numero 9 dal primo task!

Finora, sembra che async e thread ci diano gli stessi risultati di base, solo con una sintassi diversa: usando await invece di chiamare join sull’handle, e aspettando le chiamate sleep.

La differenza più grande è che non abbiamo dovuto avviare un altro thread del sistema operativo per farlo. In realtà, non dobbiamo nemmeno avviare un task qui. Poiché i blocchi async si compilano in future anonime, possiamo mettere ogni loop in un blocco async e far eseguire al runtime entrambe fino al completamento usando la funzione trpl::join.

Nella sezione “Attendere Che Tutti i Thread Finiscano”, abbiamo mostrato come usare il metodo join sul type JoinHandle restituito quando si chiama std::thread::spawn. La funzione trpl::join è simile, ma per le future. Quando gli dai due future, produce una singola nuova future il cui output è una tupla che contiene l’output di ciascuna future che hai passato una volta che entrambe si completano. Quindi, nel Listato 17-8, usiamo trpl::join per aspettare che sia fut1 che fut2 finiscano. Non aspettiamo fut1 e fut2 ma invece la nuova future prodotta da trpl::join. Ignoriamo l’output, perché è solo una tupla che contiene due valori unitari.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let fut1 = async {
            for i in 1..10 {
                println!("ciao numero {i} dal primo task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("ciao numero {i} dal secondo task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}
Listato 17-8: Usare trpl::join per aspettare due future anonime

Quando lo eseguiamo, vediamo entrambe le future eseguirsi fino al completamento:

ciao numero 1 dal primo task!
ciao numero 1 dal secondo task!
ciao numero 2 dal primo task!
ciao numero 2 dal secondo task!
ciao numero 3 dal primo task!
ciao numero 3 dal secondo task!
ciao numero 4 dal primo task!
ciao numero 4 dal secondo task!
ciao numero 5 dal primo task!
ciao numero 6 dal primo task!
ciao numero 7 dal primo task!
ciao numero 8 dal primo task!
ciao numero 9 dal primo task!

Ora, vedrai lo stesso ordine ogni volta, il che è molto diverso da quello che abbiamo visto con i thread. Questo perché la funzione trpl::join è equa, il che significa che controlla ciascuna future con la stessa frequenza, alternando tra loro, e non lascia che una “corra avanti” se l’altra è pronta. Con i thread, il sistema operativo decide quale thread controllare e per quanto tempo farlo eseguire. Con async Rust, il runtime decide quale task controllare. (Nella pratica, i dettagli si complicano perché un runtime async potrebbe in realtà usare i thread del sistema operativo come parte della gestione della concorrenza, quindi garantire l’equità può essere più lavoro per un runtime, ma è comunque possibile!) I runtime non devono garantire l’equità per qualsiasi operazione data, e spesso offrono diverse API per farti scegliere se vuoi l’equità o meno.

Prova alcune di queste varianti sull’attesa dei future e vedi cosa fanno:

  • Rimuovi il blocco async da uno o entrambi i loop.
  • Aspetta ogni blocco async immediatamente dopo averlo definito.
  • Incapsula solo il primo loop in un blocco async e aspetta il future risultante dopo il corpo del secondo loop.

Per una sfida extra, cerca di capire quale sarà l’output in ciascun caso prima di eseguire il codice!

Conteggiare su Due Task Usando il Passaggio di Messaggi

Condividere dati tra future sarà familiare: useremo di nuovo il passaggio di messaggi, ma questa volta con le versioni async dei type e delle funzioni. Prenderemo una strada leggermente diversa rispetto a quella che abbiamo preso in “Usare il Passaggio di Messaggi per Trasferire Dati tra Thread per illustrare alcune delle differenze chiave tra concorrenza basata su thread e concorrenza basata su future. Nel Listato 17-9, inizieremo con un singolo blocco async, non creando un task separato come avevamo creato un thread separato.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("ciao");
        tx.send(val).unwrap();

        let ricevuto = rx.recv().await.unwrap();
        println!("ricevuto '{ricevuto}'");
    });
}
Listato 17-9: Creare un canale async e assegnare le due metà a tx e rx

Qui, usiamo trpl::channel, una versione async dell’API del canale multi-produttore, singolo-consumatore che abbiamo usato con i thread nel Capitolo 16. La versione async dell’API è solo un po’ diversa dalla versione basata su thread: usa un ricevitore rx mutabile piuttosto che immutabile, e il suo metodo recv produce una future che dobbiamo aspettare piuttosto che produrre il valore direttamente. Ora possiamo inviare messaggi dal mittente al ricevitore. Nota che non dobbiamo avviare un thread separato o nemmeno un task; dobbiamo solo aspettare la chiamata rx.recv.

Il metodo sincrono Receiver::recv in std::mpsc::channel blocca fino a quando non riceve un messaggio. Il metodo trpl::Receiver::recv non lo fa, perché è async. Invece di bloccare, restituisce il controllo al runtime fino a quando non viene ricevuto un messaggio o la estremità di invio del canale si chiude. Al contrario, non aspettiamo la chiamata send, perché non blocca. Non ne ha bisogno, perché il canale in cui lo stiamo inviando è senza vincoli.

Nota: Poiché tutto questo codice async si esegue in un blocco async in una chiamata trpl::run, tutto al suo interno può evitare di bloccare. Tuttavia, il codice fuori da esso si bloccherà sulla funzione run che restituisce. Questo è proprio lo scopo della funzione trpl::run: ci permette di scegliere dove bloccare su un insieme di codice async, e quindi dove passare tra codice sincrono e asincrono. In molti runtime asincroni, run è effettivamente chiamato block_on proprio per questo motivo.

Nota due cose in questo esempio. Prima di tutto, il messaggio arriverà subito. Secondo, anche se usiamo una future qui, non c’è ancora concorrenza. Tutto nell’elenco accade in sequenza, proprio come farebbe se non ci fossero future coinvolte.

Affrontiamo la prima parte inviando una serie di messaggi e “dormendo” tra di loro, come mostrato nel Listato 17-10.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let valori = vec![
            String::from("ciao"),
            String::from("dalla"),
            String::from("future"),
            String::from("!!!"),
        ];

        for valore in valori {
            tx.send(valore).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(val) = rx.recv().await {
            println!("ricevuto '{val}'");
        }
    });
}
Listato 17-10: Inviare e ricevere più messaggi sul canale async e dormire con un await tra ogni messaggio

Oltre ad inviare i messaggi, dobbiamo riceverli. In questo caso, poiché sappiamo quanti messaggi stanno arrivando, potremmo farlo manualmente chiamando rx.recv().await quattro volte. Nel mondo reale, tuttavia, di solito stiamo aspettando un numero sconosciuto di messaggi, quindi dobbiamo continuare ad aspettare fino a quando non determiniamo che non ci sono più messaggi.

Nel Listato 16-10, abbiamo usato un ciclo for per elaborare tutti gli elementi ricevuti da un canale sincrono. Rust non ha ancora un modo per scrivere un ciclo for su una serie asincrona di elementi, quindi dobbiamo usare un ciclo che non abbiamo visto prima: il ciclo condizionale while let. Questo è la versione ciclo della costruzione if let che abbiamo visto nella sezione “Controllare il Flusso con if let e let else. Il ciclo continuerà ad eseguirsi finché il pattern specificato continua a corrispondere al valore.

La chiamata rx.recv produce una future, che aspettiamo. Il runtime metterà in pausa la future fino a quando non sarà pronta. Una volta che arriva un messaggio, la future si risolverà in Some(messaggio) tutte le volte che arriva un messaggio. Quando il canale si chiude, indipendentemente dal fatto che siano arrivati alcuni messaggi, la future si risolverà invece in None per indicare che non ci sono più valori e quindi dobbiamo smettere di aspettare.

Il ciclo while let mette insieme tutto questo. Se il risultato della chiamata rx.recv().await è Some(messaggio), otteniamo accesso al messaggio e possiamo usarlo nel corpo del ciclo, proprio come potremmo fare con if let. Se il risultato è None, il ciclo termina. Ogni volta che il ciclo si completa, raggiunge di nuovo il punto di attesa, quindi il runtime lo mette di nuovo in pausa fino a quando non arriva un altro messaggio.

Il codice invia e riceve ora tutti i messaggi con successo. Purtroppo, ci sono ancora un paio di problemi. Innanzitutto, i messaggi non arrivano a intervalli di mezzo secondo. Arrivano tutti insieme, 2 secondi (2.000 millisecondi) dopo aver avviato il programma. In secondo luogo, questo programma non si arresta mai! Invece, aspetta per sempre nuovi messaggi. Dovrai interromperlo usando ctrl-C.

Iniziamo esaminando perché i messaggi arrivano tutti insieme dopo il ritardo cumulativo, piuttosto che arrivare con ritardi tra ciascuno. All’interno di un dato blocco async, l’ordine in cui compaiono le parole chiave await nel codice è anche l’ordine in cui vengono eseguite quando il programma si avvia.

C’è un singolo blocco async nel Listato 17-10, quindi tutto in esso si esegue linearmente. Non c’è ancora concorrenza. Tutti i tx.send accadono, intercalati con tutte le chiamate trpl::sleep e i loro punti di attesa associati. Solo allora il ciclo while let può passare in rassegna alcuni dei punti di attesa sulle chiamate recv.

Per ottenere il comportamento che vogliamo, dove il ritardo accade tra ogni messaggio, dobbiamo mettere le operazioni tx e rx nei loro blocchi async separati, come mostrato nel Listato 17-11. In questo modo il runtime può eseguire ciascuno di essi separatamente usando trpl::join, proprio come nell’esempio del conteggio. Ancora una volta, aspettiamo il risultato della chiamata a trpl::join, non le future singole. Se avessimo aspettato le future singole in sequenza, saremmo tornati a un flusso sequenziale, proprio quello che stiamo cercando di non fare.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for valore in valori {
                tx.send(valore).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(val) = rx.recv().await {
                println!("ricevuto '{val}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listato 17-11: Separare send e recv nei loro blocchi async e aspettare le future per quei blocchi

Con il codice aggiornato nel Listato 17-11, i messaggi vengono stampati a intervalli di 500 millisecondi, piuttosto che tutti insieme dopo 2 secondi.

Il programma non si arresta comunque, perché il ciclo while let interagisce con trpl::join:

  • La future restituita da trpl::join si completa solo una volta che entrambe le future passate ad esso si sono completate.
  • La future tx si completa una volta che ha finito di dormire dopo aver inviato l’ultimo messaggio in vals.
  • La future rx non si completerà fino a quando il ciclo while let non termina.
  • Il ciclo while let non terminerà fino a quando l’attesa di rx.recv produce None.
  • L’attesa di rx.recv restituirà None solo una volta che l’altra estremità del canale è chiusa.
  • Il canale si chiuderà solo se chiamiamo rx.close o quando l’estremità invio, tx, viene eliminata.
  • Non chiamiamo rx.close da nessuna parte, e tx non verrà eliminato fino a quando il blocco async più esterno passato a trpl::run non termina.
  • Il blocco non può terminare perché è bloccato su trpl::join in attesa di completamento, il che ci riporta all’inizio di questo elenco.

Potremmo chiudere manualmente rx chiamando rx.close da qualche parte, ma non ha molto senso. Fermarsi dopo aver gestito un numero arbitrario di messaggi farebbe chiudere il programma, ma potremmo perdere messaggi. Abbiamo bisogno di un altro modo per assicurarci che tx venga eliminato prima della fine della funzione.

Al momento, il blocco async in cui inviamo i messaggi prende in prestito solo tx perché inviare un messaggio non richiede la ownership, ma se potessimo spostare tx in quel blocco async, verrebbe eliminato una volta che quel blocco termina. Nella sezione del Capitolo 13 “Catturare i Reference o Trasferire la Ownership, hai imparato come usare la parola chiave move con le chiusure, e, come discusso nella sezione del Capitolo 16 “Usare le Chiusure move con i Thread, spesso dobbiamo spostare i dati nelle chiusure quando lavoriamo con i thread. Le stesse dinamiche di base si applicano ai blocchi async, quindi la parola chiave move funziona con i blocchi async proprio come fa con le chiusure.

Nel Listato 17-12, cambiamo il blocco usato per inviare messaggi da async a async move. Quando eseguiamo questa versione del codice, si chiude correttamente dopo che l’ultimo messaggio è stato inviato e ricevuto.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for valore in valori {
                tx.send(valore).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(val) = rx.recv().await {
                println!("ricevuto '{val}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listato 17-12: Una revisione del codice nel Listato 17-11 che si chiude correttamente al completamento

Questo canale async è anche un canale multi-produttore, quindi possiamo chiamare clone su tx se vogliamo inviare messaggi da più future, come mostrato nel Listato 17-13.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        };

        let tx_fut = async move {
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join3(tx1_fut, tx_fut, rx_fut).await;
    });
}
Listato 17-13: Usare più produttori con blocchi async

Prima di tutto, cloniamo tx, creando tx1 fuori dal primo blocco async. Spostiamo tx1 in quel blocco proprio come abbiamo fatto prima con tx. Poi, in seguito, spostiamo l’originale tx in un nuovo blocco async, dove inviamo più messaggi con un ritardo leggermente minore. Abbiamo messo questo nuovo blocco async dopo il blocco async per ricevere messaggi, ma andrebbe bene anche se messo prima. La chiave è l’ordine in cui le future vengono attese, non quello in cui vengono create.

Entrambi i blocchi async per inviare messaggi devono essere blocchi async move in modo che sia tx che tx1 vengano eliminati quando quei blocchi finiscono. Altrimenti, finiremo di nuovo nello stesso ciclo infinito da cui siamo partiti. Infine, passiamo da trpl::join a trpl::join3 per gestire la future aggiuntiva.

Ora vediamo tutti i messaggi da entrambe le future di invio, e poiché le future di invio usano ritardi leggermente diversi dopo l’invio, i messaggi vengono anche ricevuti a quegli intervalli diversi.

ricevuto 'ciao'
ricevuto 'altri'
ricevuto 'dalla'
ricevuto 'future'
ricevuto 'messaggi'
ricevuto '!!!'
ricevuto 'per'
ricevuto 'te'

Questo è un buon inizio, ma ci limita a solo una manciata di future: due con join, o tre con join3. Vediamo come potremmo lavorare con più future.

Lavorare con un Numero Qualsiasi di Future

Quando siamo passati dall’usare due future a tre nella sezione precedente, abbiamo dovuto passare da join a join3. Sarebbe fastidioso dover chiamare una funzione diversa ogni volta che cambiamo il numero di future che vogliamo unire. Per fortuna, abbiamo una forma macro di join a cui possiamo passare un numero arbitrario di argomenti. Gestisce anche l’attesa delle future stessa. Quindi, potremmo riscrivere il codice del Listato 17-13 per usare join! invece di join3, come nel Listato 17-14.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        };

        let tx_fut = async move {
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}
Listato 17-14: Usare join! per aspettare più future

Questo è sicuramente un miglioramento rispetto allo scambio tra join e join3 e join4 e così via! Tuttavia, anche questa macro funziona solo quando conosciamo il numero di future in anticipo. Nel mondo reale di Rust, tuttavia, mettere le future in una collezione e poi aspettare che alcune o tutte le future si completino è il modello più comune.

Per controllare tutte le future in una qualche collezione, dovremo iterare e unire su tutte loro. La funzione trpl::join_all accetta qualsiasi type che implementa il trait Iterator, che hai imparato nel Capitolo 13 in “Il Trait Iterator e il Metodo next, quindi sembra proprio la cosa giusta. Proviamo a mettere le nostre future in un vettore e sostituire join! con join_all come mostrato nel Listato 17-15.

extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        };

        let tx_fut = async move {
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let future = vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(future).await;
    });
}
Listato 17-15: Memorizzare future anonime in un vettore e chiamare join_all

Purtroppo, questo codice non si compila. Invece, otteniamo questo errore:

error[E0308]: mismatched types
  --> src/main.rs:45:36
   |
10 |         let tx1_fut = async move {
   |                       ---------- the expected `async` block
...
24 |         let rx_fut = async {
   |                      ----- the found `async` block
...
45 |         let future = vec![tx1_fut, rx_fut, tx_fut];
   |                                    ^^^^^^ expected `async` block, found a different `async` block
   |
   = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
              found `async` block `{async block@src/main.rs:24:22: 24:27}`
   = note: no two async blocks, even if identical, have the same type
   = help: consider pinning your async block and casting it to a trait object

Some errors have detailed explanations: E0308, E0425.
For more information about an error, try `rustc --explain E0308`.
error: could not compile `async_await` (bin "async_await") due to 2 previous errors

Questo potrebbe essere sorprendente. Dopotutto, nessuno dei blocchi async restituisce nulla, quindi ciascuno produce un Future<Output = ()>. Ricorda che Future è un trait, e che il compilatore crea una enum univoca per ogni blocco async. Non puoi mettere due struct scritte a mano diverse in un Vec, e la stessa regola si applica alle enum diverse generate dal compilatore.

Per farlo funzionare, dobbiamo usare gli oggetti trait, proprio come abbiamo fatto in “Restituire Errori dalla Funzione esegui nel Capitolo 12. (Parleremo degli oggetti trait in dettaglio nel Capitolo 18.) Usare oggetti trait ci permette di trattare ciascuna delle future anonime prodotte da questi type come fossero il medesimo type, perché tutti implementano il trait Future.

Nota: In “Utilizzare un’Enum per Memorizzare Più Type nel Capitolo 8, abbiamo discusso un altro modo per includere più type in un Vec: usando una enum per rappresentare ciascun type che può apparire nel vettore. Non possiamo farlo qui, però. Per prima cosa, non abbiamo modo di nominare i diversi type, perché sono anonimi. Inoltre, il motivo per cui abbiamo aggiunto un vettore e join_all in primo luogo era per poter lavorare con una collezione dinamica di future dove ci importa solo che abbiano lo stesso tipo di output.

Iniziamo incapsulando ciascuna future nel vec! in una Box::new, come mostrato nel Listato 17-16.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        };

        let tx_fut = async move {
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let future =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(future).await;
    });
}
Listato 17-16: Usare Box::new per allineare i type delle future in un Vec

Purtroppo, questo codice non si compila ancora. In realtà, otteniamo lo stesso errore di base che abbiamo ottenuto prima sia per la seconda che la terza chiamata a Box::new , oltre a nuovi errori che fanno riferimento al trait Unpin. Torneremo sugli errori Unpin tra un momento. Prima, correggiamo gli errori di type sulle chiamate Box::new annotando esplicitamente il type della variabile future come nel Listato 17-17.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        };

        let tx_fut = async move {
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let future: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(future).await;
    });
}
Listato 17-17: Correggere il resto degli errori di type non corrispondente usando una dichiarazione di type esplicita

Questa dichiarazione di type è un po’ complicata, quindi descriviamola pezzo per pezzo:

  1. Il type più interno è la future stessa. Annotiamo esplicitamente che l’output della future è il type unitario () scrivendo Future<Output = ()>.
  2. Quindi annotiamo il trait con dyn per marcarlo come dinamico.
  3. L’intero reference al trait è incapsulato in una Box.
  4. Infine, dichiariamo esplicitamente che future è un Vec che contiene questi elementi.

Questo ha già fatto una grande differenza. Ora, quando eseguiamo la compilazione, otteniamo solo gli errori che menzionano Unpin. Anche se ce ne sono tre, i loro contenuti sono molto simili.

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
   --> src/main.rs:49:24
    |
 49 |         trpl::join_all(future).await;
    |         -------------- ^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |         |
    |         required by a bound introduced by this call
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `join_all`
   --> /home/utente/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:105:14
    |
102 | pub fn join_all<I>(iter: I) -> JoinAll<I::Item>
    |        -------- required by a bound in this function
...
105 |     I::Item: Future,
    |              ^^^^^^ required by this bound in `join_all`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:9
   |
49 |         trpl::join_all(future).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> /home/utente/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:32
   |
49 |         trpl::join_all(future).await;
   |                                ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> /home/utente/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

Questo è un sacco da digerire, quindi facciamolo a pezzi. La prima parte del messaggio ci dice che il primo blocco async (src/main.rs:8:23: 20:10) non implementa il trait Unpin e suggerisce di usare pin! o Box::pin per risolverlo. Più avanti nel capitolo, approfondiremo alcuni dettagli su Pin e Unpin. Per il momento, però, possiamo semplicemente seguire il consiglio del compilatore per sbloccarci. Nel Listato 17-18, iniziamo importando Pin da std::pin. Quindi aggiorniamo l’annotazione di type per future, con un Pin che incapsula ogni Box. Infine, usiamo Box::pin per sistemare le stesse future.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::pin::Pin;

// --taglio--

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        };

        let tx_fut = async move {
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let future: Vec<Pin<Box<dyn Future<Output = ()>>>> =
            vec![Box::pin(tx1_fut), Box::pin(rx_fut), Box::pin(tx_fut)];

        trpl::join_all(future).await;
    });
}
Listato 17-18: Usare Pin e Box::pin per far sì che il type Vec superi il controllo

Se compiliamo ed eseguiamo questo, otteniamo finalmente l’output che speravamo:

ricevuto 'ciao'
ricevuto 'altri'
ricevuto 'dalla'
ricevuto 'messaggi'
ricevuto 'future'
ricevuto 'per'
ricevuto '!!!'
ricevuto 'te'

Bene!

C’è ancora un po’ da fare qui. Per prima cosa, usare Pin<Box<T>> aggiunge una piccola quantità di overhead perché mettiamo queste future nell’heap con Box, e lo stiamo facendo solo per far sì che i type si allineino. In realtà, non abbiamo bisogno dell’allocazione nell’heap: queste future sono locali a questa particolare funzione. Come notato prima, Pin è esso stesso un type di incapsulamento, quindi possiamo ottenere il beneficio di avere un singolo type nel Vec, la ragione per cui abbiamo usato Box, senza fare un’allocazione nell’heap. Possiamo perciò usare Pin direttamente con ciascuna future, usando la macro std::pin::pin.

Tuttavia, dobbiamo ancora essere espliciti sul type del reference fissato; altrimenti, Rust non saprà di interpretare questi come oggetti trait dinamici, che è ciò di cui abbiamo bisogno che siano nel Vec. Aggiungiamo pin alla nostra lista di importazioni da std::pin e quindi possiamo usare pin! con ciascuna future quando la definiamo per poi definire future come un Vec che contiene reference mutabili fissati ai type future dinamici, come nel Listato 17-19.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::pin::{Pin, pin};

// --taglio--

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --taglio--
            let valori = vec![
                String::from("ciao"),
                String::from("dalla"),
                String::from("future"),
                String::from("!!!"),
            ];

            for val in valori {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let rx_fut = pin!(async {
            // --taglio--
            while let Some(valore) = rx.recv().await {
                println!("ricevuto '{valore}'");
            }
        });

        let tx_fut = pin!(async move {
            // --taglio--
            let valori = vec![
                String::from("altri"),
                String::from("messaggi"),
                String::from("per"),
                String::from("te"),
            ];

            for val in valori {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        });

        let future: Vec<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(future).await;
    });
}
Listato 17-19: Usare Pin direttamente con la macro pin! per evitare allocazioni nell’heap non necessarie

Siamo arrivati fin qui ignorando il fatto che potremmo avere type Output diversi. Ad esempio, nel Listato 17-20, la future anonima per a implementa Future<Output = u32>, la future anonima per b implementa Future<Output = &str>, e la future anonima per c implementa Future<Output = bool>.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

fn main() {
    trpl::run(async {
        let a = async { 1u32 };
        let b = async { "Ciao!" };
        let c = async { true };

        let (risultato_a, risultato_b, risultato_c) = trpl::join!(a, b, c);
        println!("{risultato_a}, {risultato_b}, {risultato_c}");
    });
}
Listato 17-20: Tre future con type distinti

Possiamo usare trpl::join! per aspettarle, perché ci permette di passare più type di future e produce una tupla di quei type. Non possiamo usare trpl::join_all, perché richiede che tutte le future passate abbiano lo stesso type. Ricorda, quell’errore è quello che ci ha fatto iniziare questa avventura con Pin!

Questo è un compromesso fondamentale: possiamo gestire un numero dinamico di future con join_all, purché abbiano tutte lo stesso type, oppure possiamo gestire un numero fisso di future con le funzioni join o la macro join!, anche se hanno type diversi. Questo è lo stesso scenario che affronteremmo lavorando con qualsiasi altro type in Rust. Le future non sono speciali, anche se abbiamo una bella sintassi per lavorare con loro, e questo è un bene.

Competizione tra Future

Quando “uniamo” le future con la famiglia di funzioni e macro join, richiediamo che tutte finiscano prima di andare avanti. A volte, però, abbiamo bisogno che solo alcune future di un insieme finiscano prima di proseguire, un po’ come mettere in competizione una future contro un’altra.

Nel Listato 17-21, utilizziamo di nuovo trpl::race per eseguire due future, lenta e veloce, l’una contro l’altra.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let lenta = async {
            println!("'lenta' iniziato.");
            trpl::sleep(Duration::from_millis(100)).await;
            println!("'lenta' finito.");
        };

        let veloce = async {
            println!("'veloce' iniziato.");
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'veloce' finito.");
        };

        trpl::race(lenta, veloce).await;
    });
}
Listato 17-21: Utilizzo di race per ottenere il risultato di quale future finisce prima

Ogni future stampa un messaggio quando inizia l’esecuzione, si ferma per un certo periodo di tempo chiamando e aspettando sleep, e poi stampa un altro messaggio quando finisce. Poi passiamo sia lenta che veloce a trpl::race e aspettiamo che una di esse finisca. (Il risultato qui non è troppo sorprendente: veloce vince.) A differenza di quando abbiamo usato race ne “Il Nostro Primo Programma Async, qui ignoriamo semplicemente l’istanza Either che restituisce, perché tutto il comportamento interessante avviene nel corpo dei blocchi async.

Nota che se inverti l’ordine degli argomenti a race, l’ordine dei messaggi “iniziati” cambia, anche se la future veloce si conclude sempre per prima. Questo perché l’implementazione di questa particolare funzione race non è equa. Esegue sempre le future passate come argomenti nell’ordine in cui sono passate. Altre implementazioni sono eque e sceglieranno casualmente quale future eseguire per prima. Indipendentemente dal fatto che l’implementazione di race che stiamo usando sia equa, però, una delle future eseguirà fino al primo await nel suo corpo prima che un’altra attività possa iniziare.

Ricorda da “Il Nostro Primo Programma Async che ad ogni punto di attesa, Rust dà a un runtime la possibilità di mettere in pausa l’attività e passare a un’altra se la future in attesa non è pronta. Anche l’inverso è vero: Rust mette in pausa solo i blocchi async e restituisce il controllo a un runtime in un punto di attesa. Tutto ciò che si trova tra i punti di attesa è sincrono.

Questo significa che se fai un sacco di lavoro in un blocco async senza un punto di attesa, quella future bloccherà qualsiasi altra future dal fare progressi. A volte potresti sentire questo comportamento riferito come una future che affama (starving) altre future. In alcuni casi, potrebbe non essere un grosso problema. Tuttavia, se stai facendo qualche tipo di elaborazione dispendiosa o lavoro a lungo termine, o se hai una future che continuerà a fare un particolare compito indefinitamente, dovrai pensare a quando e dove restituire il controllo al runtime.

Allo stesso modo, se hai operazioni bloccanti a lungo termine, l’async può essere uno strumento utile per fornire modi affinché diverse parti del programma si relazionino tra loro.

Ma come restituiresti il controllo al runtime in quei casi?

Restituire il Controllo al Runtime

Simuliamo un’operazione a lungo termine. Il Listato 17-22 introduce una funzione lenta.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        // Pià tardi chiameremo `lenta` da qui
    });
}

fn lenta(nome: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nome}' eseguita per {ms}ms");
}
Listato 17-22: Utilizzo di thread::sleep per simulare operazioni lente

Questo codice utilizza std::thread::sleep invece di trpl::sleep in modo che chiamare lenta blocchi il thread corrente per un certo numero di millisecondi. Possiamo usare lenta per rappresentare operazioni del mondo reale che sono sia a lungo termine che bloccanti.

Nel Listato 17-23, utilizziamo lenta per emulare questo tipo di lavoro legato alla CPU in un paio di future.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' iniziata.");
            lenta("a", 30);
            lenta("a", 10);
            lenta("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finita.");
        };

        let b = async {
            println!("'b' iniziata.");
            lenta("b", 75);
            lenta("b", 10);
            lenta("b", 15);
            lenta("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finita.");
        };

        trpl::race(a, b).await;
    });
}

fn lenta(nome: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nome}' eseguita per {ms}ms");
}
Listato 17-23: Due future che utilizzano la funzione lenta per simulare operazioni di lunga durata

Per cominciare, ogni future restituisce il controllo al runtime dopo aver eseguito alcune operazioni lente. Se esegui questo codice, vedrai questo output:

'a' iniziata.
'a' eseguita per 30ms
'a' eseguita per 10ms
'a' eseguita per 20ms
'b' iniziata.
'b' eseguita per 75ms
'b' eseguita per 10ms
'b' eseguita per 15ms
'b' eseguita per 350ms
'a' finita.

Come nel nostro esempio precedente, race termina non appena a è completata. Non c’è “intreccio” tra le due future, però. La future a fa tutto il suo lavoro fino a quando la chiamata a trpl::sleep è in attesa, poi la future b fa tutto il suo lavoro fino a quando la sua chiamata a trpl::sleep è in attesa, e infine la future a finisce. Per consentire a entrambe le future lente di fare progressi, abbiamo bisogno di punti di attesa in modo da poter restituire il controllo al runtime di tanto in tanto per consentire anche all’altra di proseguire!

Possiamo già vedere questo tipo di passaggio avvenire nel Listato 17-23: se rimuovessimo trpl::sleep alla fine della future a, essa completerebbe la propria esecuzione senza che la future b nemmeno cominciasse. Proviamo a utilizzare la funzione sleep come punto di partenza per consentire alle operazioni di alternarsi nel fare progressi, come mostrato nel Listato 17-24.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let un_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' iniziata.");
            lenta("a", 30);
            trpl::sleep(un_ms).await;
            lenta("a", 10);
            trpl::sleep(un_ms).await;
            lenta("a", 20);
            trpl::sleep(un_ms).await;
            println!("'a' finita.");
        };

        let b = async {
            println!("'b' iniziata.");
            lenta("b", 75);
            trpl::sleep(un_ms).await;
            lenta("b", 10);
            trpl::sleep(un_ms).await;
            lenta("b", 15);
            trpl::sleep(un_ms).await;
            lenta("b", 350);
            trpl::sleep(un_ms).await;
            println!("'b' finita.");
        };

        trpl::race(a, b).await;
    });
}

fn lenta(nome: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nome}' eseguita per {ms}ms");
}
Listato 17-24: Utilizzo di sleep per consentire alle operazioni di alternarsi nel fare progressi

Nel Listato 17-24, aggiungiamo chiamate a trpl::sleep con punti di attesa tra ogni chiamata a lenta. Ora il lavoro delle due future è intervallato:

'a' iniziata.
'a' eseguita per 30ms
'b' iniziata.
'b' eseguita per 75ms
'a' eseguita per 10ms
'b' eseguita per 10ms
'a' eseguita per 20ms
'b' eseguita per 15ms
'a' finita.

La future a continua a lavorare per un po’ prima di restituire il controllo a b, perché chiama lenta prima di chiamare trpl::sleep, ma dopo ciò le future si alternano ogni volta che una di esse incontra un punto di attesa. In questo caso, abbiamo fatto ciò dopo ogni chiamata a lenta, ma potremmo suddividere il lavoro in qualsiasi modo abbia più senso per noi.

Ma non vogliamo davvero dormire qui, però: vogliamo eseguire le nostre operazioni il più velocemente possibile e restituire il controllo al runtime quando possibile. Possiamo farlo direttamente, utilizzando la funzione yield_now. Nel Listato 17-25, sostituiamo tutte quelle chiamate a sleep con yield_now.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' iniziata.");
            lenta("a", 30);
            trpl::yield_now().await;
            lenta("a", 10);
            trpl::yield_now().await;
            lenta("a", 20);
            trpl::yield_now().await;
            println!("'a' finita.");
        };

        let b = async {
            println!("'b' iniziata.");
            lenta("b", 75);
            trpl::yield_now().await;
            lenta("b", 10);
            trpl::yield_now().await;
            lenta("b", 15);
            trpl::yield_now().await;
            lenta("b", 350);
            trpl::yield_now().await;
            println!("'b' finita.");
        };

        trpl::race(a, b).await;
    });
}

fn lenta(nome: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{nome}' eseguita per {ms}ms");
}
Listato 17-25: Utilizzo di yield_now per consentire alle operazioni di alternarsi nel fare progressi

Questo codice è sia più chiaro riguardo all’intento reale sia può essere significativamente più veloce rispetto all’uso di sleep, perché i timer come quello usato da sleep hanno spesso limiti su quanto possono essere granulari. La versione di sleep che stiamo usando, ad esempio, dormirà sempre per almeno un millisecondo, anche se le passiamo una Duration di un nanosecondo. Ancora una volta, i computer moderni sono veloci: possono fare molto in un millisecondo!

Puoi vedere questo di persona impostando un piccolo benchmark, come quello nel Listato 17-26. (Questo non è un modo particolarmente rigoroso per fare test di prestazioni, ma è sufficiente a mostrare la differenza qui.)

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::{Duration, Instant};

fn main() {
    trpl::run(async {
        let un_ns = Duration::from_nanos(1);
        let inizio = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::sleep(un_ns).await;
            }
        }
        .await;
        let tempo = Instant::now() - inizio;
        println!(
            "versione 'sleep' finita dopo {} secondi.",
            tempo.as_secs_f32()
        );

        let inizio = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::yield_now().await;
            }
        }
        .await;
        let tempo = Instant::now() - inizio;
        println!(
            "versione 'yield' finita dopo {} secondi.",
            tempo.as_secs_f32()
        );
    });
}
Listato 17-26: Confronto delle prestazioni di sleep e yield_now

Qui, saltiamo tutte le stampe di stato, passiamo una Duration di un nanosecondo a trpl::sleep, e lasciamo che ogni future giri da sola, senza alternarci tra le future. Poi eseguiamo per 1.000 iterazioni e vediamo quanto tempo impiega la future che utilizza trpl::sleep rispetto alla future che utilizza trpl::yield_now.

versione 'sleep' finita dopo 1.1282331 secondi.
versione 'yield' finita dopo 0.000536924 secondi.

La versione con yield_now è di gran lunga più veloce!

Questo significa che l’async può essere utile anche per compiti legati al calcolo, a seconda di cosa sta facendo il tuo programma, perché fornisce uno strumento utile per strutturare le relazioni tra le diverse parti del programma. Questa è una forma di multitasking cooperativo, in cui ogni future ha il potere di determinare quando restituisce il controllo tramite i punti di attesa. Ogni future ha quindi anche la responsabilità di evitare di bloccarsi troppo a lungo. In alcuni sistemi operativi embedded basati su Rust, questo è l’unico tipo di multi-tasking!

Nel codice reale, di solito non lavorerai direttamente alternando chiamate di funzione con punti di attesa su ogni singola riga, ovviamente. Anche se restituire il controllo in questo modo è relativamente poco costoso, non è gratuito. In molti casi, cercare di suddividere un compito legato al calcolo potrebbe renderlo significativamente più lento, quindi a volte è meglio per le prestazioni complessive lasciare che un’operazione si blocchi brevemente. Misura sempre per vedere quali sono i veri colli di bottiglia delle prestazioni del tuo codice. Tuttavia, la dinamica sottostante è importante da tenere a mente, se stai vedendo molto lavoro avvenire in serie che ti aspettavi avvenisse in parallelo!

Costruire le Nostre Astrazioni Async

Possiamo anche comporre le future insieme per creare nuovi schemi. Ad esempio, possiamo costruire una funzione timeout con i blocchi async che abbiamo già. Quando abbiamo finito, il risultato sarà un altro blocco di costruzione che potremmo usare per creare ancora più astrazioni async.

Il Listato 17-27 mostra come ci aspettiamo che funzioni questo timeout con una future lenta.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let lento = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finalmente finito"
        };

        match timeout(lento, Duration::from_secs(2)).await {
            Ok(messaggio) => println!("Completato con '{messaggio}'"),
            Err(durata) => {
                println!("Fallito dopo {} secondi", durata.as_secs())
            }
        }
    });
}
Listato 17-27: Utilizzo del nostro immaginato timeout per eseguire un’operazione lenta con un limite di tempo

Implementiamolo! Per cominciare, pensiamo all’API per timeout:

  • Deve essere essa stessa una funzione async in modo da poterla attendere.
  • Il suo primo parametro dovrebbe essere una future da eseguire. Possiamo renderla generica per consentirle di funzionare con qualsiasi future.
  • Il suo secondo parametro sarà il tempo massimo da attendere. Se usiamo una Duration, sarà facile passarla a trpl::sleep.
  • Dovrebbe restituire un Result. Se la future completa con successo, il Result sarà Ok con il valore prodotto dalla future. Se il timeout scade prima, il Result sarà Err con la durata che il timeout ha atteso.

Il Listato 17-28 mostra questa dichiarazione.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

fn main() {
    trpl::run(async {
        let lento = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finalmente finito"
        };

        match timeout(lento, Duration::from_secs(2)).await {
            Ok(messaggio) => println!("Completato con '{messaggio}'"),
            Err(durata) => {
                println!("Fallito dopo {} secondi", durata.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_da_testare: F,
    tempo_massimo: Duration,
) -> Result<F::Output, Duration> {
    // Qui è dove metteremo l'implementazione!
}
Listato 17-28: Definire la firma di timeout

Questo soddisfa i nostri obiettivi per i type. Ora pensiamo al comportamento di cui abbiamo bisogno: vogliamo far competere la future passata contro la durata fornita. Possiamo usare trpl::sleep per creare una future che duri quanto richiesto e usare trpl::race per eseguirla contro la future che il chiamante passa.

Sappiamo anche che race non è equa, processando gli argomenti nell’ordine in cui sono passati. Pertanto, passiamo future_da_testare a race per prima in modo che abbia la possibilità di completare anche se tempo_massimo è una durata molto breve. Se future_da_testare finisce prima, race restituirà Left con l’output da future_da_testare. Se il timer finisce prima, race restituirà Right con l’output del timer di ().

Nel Listato 17-29, facciamo il match sul risultato dell’attesa di trpl::race.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::time::Duration;

use trpl::Either;

// --taglio--

fn main() {
    trpl::run(async {
        let lento = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finalmente finito"
        };

        match timeout(lento, Duration::from_secs(2)).await {
            Ok(messaggio) => println!("Completato con '{messaggio}'"),
            Err(durata) => {
                println!("Fallito dopo {} secondi", durata.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_da_testare: F,
    tempo_massimo: Duration,
) -> Result<F::Output, Duration> {
    match trpl::race(future_da_testare, trpl::sleep(tempo_massimo)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(tempo_massimo),
    }
}
Listato 17-29: Definire timeout con race e sleep

Se future_da_testare ha successo e otteniamo un Left(output), restituiamo Ok(output). Se invece il timer finisce prima e otteniamo un Right(()), ignoriamo il () con _ e restituiamo Err(tempo_massimo).

Con questo, abbiamo un timeout funzionante combinando più blocchi async. Se eseguiamo il nostro codice, stamperà la modalità di errore dopo il timeout:

Fallito dopo 2 secondi

Poiché le future si compongono con altre future, puoi costruire strumenti davvero potenti utilizzando blocchi di costruzione async più piccoli. Ad esempio, puoi utilizzare questo stesso approccio per combinare timeout con ripetizioni, e a loro volta usarli con operazioni come chiamate di rete (uno degli esempi dall’inizio del capitolo).

Nella pratica, di solito lavorerai direttamente con async e await, e secondariamente con funzioni e macro come join, join_all, race, e così via. Avrai bisogno di ricorrere a pin solo di tanto in tanto per utilizzare le future con quelle API.

Abbiamo ora visto diversi modi per lavorare con più future contemporaneamente. Prossimamente, vedremo come possiamo lavorare con più future in una sequenza nel tempo con gli stream. Ecco un paio di altre cose che potresti voler considerare prima, però:

  • Abbiamo usato un Vec con join_all per attendere che tutte le future in un gruppo finissero. Come potresti usare un Vec per elaborare un gruppo di future in sequenza invece? Quali sono i compromessi nel farlo?
  • Dai un’occhiata al type futures::stream::FuturesUnordered dal crate futures. Come sarebbe diverso usarlo rispetto a un Vec? (Non preoccuparti del fatto che provenga dalla parte stream del crate; funziona benissimo con qualsiasi collezione di future.)

Stream: Future in Sequenza

Fino ad ora in questo capitolo, ci siamo principalmente concentrati su future singole. L’unica grande eccezione è stata il canale async che abbiamo usato. Ricorda come abbiamo utilizzato il ricevitore per il nostro canale async in precedenza in questo capitolo nella sezione “Conteggiare su Due Task Usando il Passaggio di Messaggi”. Il metodo async recv produce una sequenza di elementi nel tempo. Questo è un esempio di un modello molto più generale noto come stream.

Abbiamo visto una sequenza di elementi nel Capitolo 13, quando abbiamo esaminato il trait Iterator nella sezione “Il Trait Iterator e il Metodo next, ma ci sono due differenze tra gli iteratori e il ricevitore del canale async. La prima differenza sono le tempistiche: gli iteratori sono sincroni, mentre il ricevitore del canale è asincrono. La seconda è l’API. Quando lavoriamo direttamente con Iterator, chiamiamo il suo metodo sincrono next. Con lo stream trpl::Receiver, in particolare, abbiamo invece chiamato un metodo asincrono recv. A parte questo, le API si somigliano molto, e questa somiglianza non è una coincidenza. Uno stream è come una forma asincrona di iterazione. Mentre il trpl::Receiver aspetta specificamente di ricevere messaggi, però, l’API dello stream di uso generale è molto più ampia: fornisce il prossimo elemento come fa Iterator, ma in modo asincrono.

La somiglianza tra iteratori e stream in Rust significa che possiamo effettivamente creare uno stream da qualsiasi iteratore. Come con un iteratore, possiamo lavorare con uno stream chiamando il suo metodo next e poi aspettare l’output, come nel Listato 17-30.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

fn main() {
    trpl::run(async {
        let valori = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = valori.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(valore) = stream.next().await {
            println!("Il valore era: {valore}");
        }
    });
}
Listato 17-30: Creare uno stream da un iteratore e stampare i suoi valori

Iniziamo con un array di numeri, che convertiamo in un iteratore e poi chiamiamo map su di esso per raddoppiare tutti i valori. Poi convertiamo l’iteratore in uno stream usando la funzione trpl::stream_from_iter. Successivamente, iteriamo sugli elementi nello stream man mano che arrivano con il ciclo while let.

Sfortunatamente, quando proviamo a eseguire il codice, non si compila, ma invece riporta che non c’è alcun metodo next disponibile:

error[E0599]: no method named `next` found for struct `tokio_stream::iter::Iter<I>` in the current scope
  --> src/main.rs:10:41
   |
10 |         while let Some(valore) = stream.next().await {
   |                                         ^^^^
   |
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
 1 + use crate::trpl::StreamExt;
   |
 1 + use futures_util::stream::stream::StreamExt;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(valore) = stream.try_next().await {
   |                                         ++++

Come spiega questo output, la ragione dell’errore del compilatore è che abbiamo bisogno del trait giusto in scope per poter utilizzare il metodo next. Dato il nostro discorso finora, potresti ragionevolmente aspettarti che quel trait sia Stream, ma in realtà è StreamExt. Abbreviazione di estensione, Ext è un modello comune nella comunità Rust per estendere un trait con un altro.

Spiegheremo i trait Stream e StreamExt in modo un po’ più dettagliato alla fine del capitolo, ma per ora tutto ciò che devi sapere è che il trait Stream definisce un’interfaccia a basso livello che combina efficacemente i trait Iterator e Future. StreamExt fornisce un insieme di API di livello superiore costruite sulla base di Stream, inclusi il metodo next e altri metodi utili simili a quelli forniti dal trait Iterator. Stream e StreamExt non fanno ancora parte della libreria standard di Rust, ma la maggior parte dei crate dell’ecosistema utilizza la stessa definizione.

La soluzione all’errore del compilatore è aggiungere una dichiarazione use per trpl::StreamExt, come nel Listato 17-31.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let valori = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = valori.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(valore) = stream.next().await {
            println!("Il valore era: {valore}");
        }
    });
}
Listato 17-31: Utilizzare con successo un iteratore come base per uno stream

Con tutti questi pezzi messi insieme, questo codice funziona come vogliamo! Inoltre, ora che abbiamo StreamExt in scope, possiamo utilizzare tutti i suoi metodi utili, proprio come con gli iteratori. Ad esempio, nel Listato 17-32, utilizziamo il metodo filter per filtrare tutto tranne i multipli di tre e cinque.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let valori = 1..101;
        let iter = valori.map(|n| n * 2);
        let stream = trpl::stream_from_iter(iter);

        let mut filtrato =
            stream.filter(|val| val % 3 == 0 || val % 5 == 0);

        while let Some(valore) = filtrato.next().await {
            println!("Il valore era: {valore}");
        }
    });
}
Listato 17-32: Filtrare uno stream con il metodo StreamExt::filter

Certo, questo non è molto interessante, dato che potremmo fare lo stesso con normali iteratori e senza alcun async. Vediamo cosa possiamo fare che è unico per gli stream.

Combinare Stream

Molti concetti sono naturalmente rappresentati come stream: elementi che diventano disponibili in una coda, porzioni di dati che vengono estratti incrementalmente dal filesystem quando l’intero set di dati è troppo grande per la memoria del computer, o dati che arrivano attraverso la rete nel tempo. Poiché gli stream sono future, possiamo usarli con qualsiasi altro tipo di future e combinarli in modi interessanti. Ad esempio, possiamo raggruppare eventi per evitare di attivare troppe chiamate di rete, impostare timeout su sequenze di operazioni a lungo termine, o limitare gli eventi dell’interfaccia utente per evitare di fare lavoro inutile.

Iniziamo costruendo un piccolo stream di messaggi come sostituto di uno stream di dati che potremmo vedere da un WebSocket o un altro protocollo di comunicazione in tempo reale, come mostrato nel Listato 17-33.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messaggi = ricevi_messaggi();

        while let Some(messaggio) = messaggi.next().await {
            println!("{messaggio}");
        }
    });
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for messaggio in messaggi {
        tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listato 17-33: Utilizza il ricevitore rx come ReceiverStream

Per prima cosa, creiamo una funzione chiamata ricevi_messaggi che restituisce impl Stream<Item = String>. Per la sua implementazione, creiamo un canale async, iteriamo sulle prime 10 lettere dell’alfabeto inglese e le inviamo attraverso il canale.

Utilizziamo anche un nuovo type: ReceiverStream, che converte il ricevitore rx da trpl::channel in uno Stream con un metodo next. Tornando a main, utilizziamo un ciclo while let per stampare tutti i messaggi dallo stream.

Quando eseguiamo questo codice, otteniamo esattamente i risultati che ci aspetteremmo:

$ cargo run
Messaggio: 'a'
Messaggio: 'b'
Messaggio: 'c'
Messaggio: 'd'
Messaggio: 'e'
Messaggio: 'f'
Messaggio: 'g'
Messaggio: 'h'
Messaggio: 'i'
Messaggio: 'j'

Ancora una volta, potremmo fare questo con l’API Receiver regolare o anche con l’API Iterator regolare, quindi aggiungiamo una funzionalità che richiede stream: aggiungere un timeout che si applica a ogni elemento nello stream e un ritardo sugli elementi che emettiamo, come mostrato nel Listato 17-34.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};
use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messaggi =
            pin!(ricevi_messaggi().timeout(Duration::from_millis(200)));

        while let Some(risultato) = messaggi.next().await {
            match risultato {
                Ok(messaggio) => println!("{messaggio}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    })
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for messaggio in messaggi {
        tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listato 17-34: Utilizzo del metodo StreamExt::timeout per impostare un limite di tempo sugli elementi in uno stream

Iniziamo aggiungendo un timeout allo stream con il metodo timeout, che proviene dal trait StreamExt. Poi aggiorniamo il corpo del ciclo while let, perché ora lo stream restituisce un Result. La variante Ok indica che un messaggio è arrivato in tempo; la variante Err indica che il timeout è scaduto prima che arrivasse un messaggio. Facciamo il match su quel risultato e stampiamo il messaggio quando lo riceviamo con successo o stampiamo una notifica riguardo al timeout. Infine, nota che fissiamo i messaggi con pin! dopo aver applicato il timeout, perché timeout produce uno stream che deve essere fissato per essere letto.

Tuttavia, poiché non ci sono ritardi tra i messaggi, questo timeout non cambia il comportamento del programma. Ora aggiungiamo un ritardo variabile ai messaggi che inviamo, come mostrato nel Listato 17-35.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messaggi =
            pin!(ricevi_messaggi().timeout(Duration::from_millis(200)));

        while let Some(risultato) = messaggi.next().await {
            match risultato {
                Ok(messaggio) => println!("{messaggio}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    })
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-35: Invio di messaggi attraverso tx con un ritardo async senza rendere ricevi_messaggi una funzione async

In ricevi_messaggi, utilizziamo il metodo enumerate dell’iteratore con l’array messaggi in modo da poter ottenere l’indice di ogni elemento che stiamo inviando insieme all’elemento stesso. Poi applichiamo un ritardo di 100 millisecondi agli elementi con indice pari e un ritardo di 300 millisecondi agli elementi con indice dispari per simulare i diversi ritardi che potremmo vedere da uno stream di messaggi nel mondo reale. Poiché il nostro timeout è di 200 millisecondi, questo dovrebbe influenzare metà dei messaggi.

Per “dormire” tra i messaggi nella funzione ricevi_messaggi senza bloccare, dobbiamo usare async. Tuttavia, non possiamo rendere ricevi_messaggi stessa una funzione async, perché altrimenti restituiremmo un Future<Output = Stream<Item = String>> invece di uno Stream<Item = String>. Il chiamante dovrebbe attendere ricevi_messaggi stessa per accedere allo stream. Ma ricorda: tutto in una data future avviene linearmente; la concorrenza avviene tra le future. Attendere ricevi_messaggi richiederebbe che inviasse tutti i messaggi, incluso il ritardo tra ogni messaggio, prima di restituire il ricevitore dello stream. Di conseguenza, il timeout sarebbe inutile. Non ci sarebbero ritardi nello stream stesso; si verificherebbero tutti ancor prima che lo stream fosse disponibile.

Invece, lasciamo ricevi_messaggi come una funzione regolare che restituisce uno stream e invece creiamo un task per gestire le chiamate async a sleep.

Nota: Chiamare spawn_task in questo modo funziona perché abbiamo già impostato il nostro runtime; se non lo avessimo fatto, causerebbe un panic. Altre implementazioni scelgono compromessi diversi: potrebbero avviare un nuovo runtime e evitare il panic, ma avrebbero un po’ di overhead extra, oppure potrebbero semplicemente non fornire un modo autonomo per avviare un task senza un riferimento a un runtime. Assicurati di sapere quale compromesso ha scelto il tuo runtime e scrivi il tuo codice di conseguenza!

Ora il nostro codice ha un risultato molto più interessante. Tra ogni coppia di messaggi, appare un errore Problema: Elapsed(()).

$cargo run
Messaggio: 'a'
Problema: Elapsed(())
Messaggio: 'b'
Messaggio: 'c'
Problema: Elapsed(())
Messaggio: 'd'
Messaggio: 'e'
Problema: Elapsed(())
Messaggio: 'f'
Messaggio: 'g'
Problema: Elapsed(())
Messaggio: 'h'
Messaggio: 'i'
Problema: Elapsed(())
Messaggio: 'j'

Il timeout non impedisce ai messaggi di arrivare alla fine. Riceviamo ancora tutti i messaggi originali, perché il nostro canale è illimitato: può contenere quanti più messaggi possiamo inserire in memoria. Se il messaggio non arriva prima del timeout, il nostro gestore di stream ne terrà conto, ma quando interroga di nuovo lo stream, il messaggio potrebbe ora essere arrivato.

Puoi ottenere un comportamento diverso se necessario utilizzando altri tipi di canali o altri tipi di stream in modo più generale. Vediamo uno di questi in pratica combinando uno stream di intervalli di tempo con questo stream di messaggi.

Unire Stream

Per prima cosa, creiamo un altro stream, che invierà un elemento ogni millisecondo se lo lasciamo girare direttamente. Per semplicità, possiamo usare la funzione sleep per inviare un messaggio con un ritardo e combinarlo con lo stesso approccio che abbiamo usato in ricevi_messaggi per creare uno stream da un canale. La differenza è che questa volta, stiamo per restituire il conteggio degli intervalli che sono trascorsi, quindi il type di ritorno sarà impl Stream<Item = u32>, e possiamo chiamare la funzione ricevi_intervalli (vedi Listato 17-36).

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messaggi =
            pin!(ricevi_messaggi().timeout(Duration::from_millis(200)));

        while let Some(risultato) = messaggi.next().await {
            match risultato {
                Ok(messaggio) => println!("{messaggio}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    })
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn ricevi_intervalli() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut conteggio = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            conteggio += 1;
            tx.send(conteggio).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-36: Creare uno stream con un contatore che verrà inviato una volta ogni millisecondo

Iniziamo definendo un conteggio nel task. (Potremmo definirlo anche al di fuori del task, ma il codice risulterà più chiaro se limitiamo ogni variabile allo scope che la riguarda.) Poi creiamo un ciclo infinito. Ogni iterazione del ciclo “dorme” asincronamente per un millisecondo, incrementa il conteggio e poi lo invia attraverso il canale. Poiché tutto questo è incapsulato nell’attività creata da spawn_task, tutto, incluso il ciclo infinito, verrà de-allocato insieme al runtime.

Questo tipo di ciclo infinito, che termina solo quando l’intero runtime viene eliminato, è abbastanza comune in Rust async: molti programmi devono continuare a girare indefinitamente. Con async, questo non blocca nulla, purché ci sia almeno un punto di attesa in ogni iterazione del ciclo.

Ora, tornando al blocco async della nostra funzione principale, possiamo tentare di unire gli stream messaggi e intervalli, come mostrato nel Listato 17-37.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messaggi = ricevi_messaggi().timeout(Duration::from_millis(200));
        let intervalli = ricevi_intervalli();
        let uniti = messaggi.merge(intervalli);

        while let Some(risultato) = uniti.next().await {
            match risultato {
                Ok(messaggio) => println!("{messaggio}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    })
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn ricevi_intervalli() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut conteggio = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            conteggio += 1;
            tx.send(conteggio).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-37: Tentativo di unire gli stream messaggi e intervalli

Iniziamo chiamando ricevi_intervalli. Poi uniamo gli stream messaggi e intervalli con il metodo merge, che combina più stream in uno stream unico che produce elementi da qualsiasi degli stream sorgente non appena gli elementi sono disponibili, senza imporre alcun ordinamento particolare. Infine, iteriamo su quello stream unico invece che su messaggi.

A questo punto, né messaggiintervalli devono essere fissati o mutabili, perché entrambi finiranno nello stream unico uniti. Tuttavia, questa chiamata a merge non si compila! (Neanche la chiamata a next nel ciclo while let, ma ci torneremo.) Questo perché i due stream hanno type diversi. Lo stream messaggi ha il type Timeout<impl Stream<Item = String>>, dove Timeout è il type che implementa Stream per una chiamata di timeout. Lo stream intervalli ha il type impl Stream<Item = u32>. Per unire questi due stream, dobbiamo trasformare uno di essi per farlo corrispondere all’altro. Rielaboreremo lo stream degli intervalli, perché messaggi è già nel formato di base che vogliamo e deve gestire gli errori di timeout (vedi Listato 17-38).

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messaggi = ricevi_messaggi().timeout(Duration::from_millis(200));
        let intervalli = ricevi_intervalli()
            .map(|conteggio| format!("Intervallo: {conteggio}"))
            .timeout(Duration::from_secs(10));
        let uniti = messaggi.merge(intervalli);
        let mut stream = pin!(uniti);

        while let Some(risultato) = stream.next().await {
            match risultato {
                Ok(messaggio) => println!("{messaggio}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    })
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn ricevi_intervalli() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut conteggio = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            conteggio += 1;
            tx.send(conteggio).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-38: Allineare il type dello stream intervalli con il type dello stream messaggi

Per prima cosa, possiamo usare il metodo map per trasformare gli intervalli in una stringa. In secondo luogo, dobbiamo abbinare il Timeout da messaggi. Siccome non vogliamo effettivamente un timeout per intervalli, possiamo semplicemente creare un timeout che sia più lungo delle altre durate che stiamo usando. Qui, creiamo un timeout di 10 secondi con Duration::from_secs(10). Infine, dobbiamo rendere stream mutabile, in modo che le chiamate next del ciclo while let possano iterare attraverso lo stream, e fissarlo in modo che sia sicuro farlo. Questo ci porta quasi dove dobbiamo essere. Tutto è dello stesso type. Se lo esegui in questo momento, però, ci saranno due problemi. Primo, non si fermerà mai! Dovrai fermarlo con ctrl-C. Secondo, i messaggi dall’alfabeto inglese saranno sepolti in mezzo a tutti i messaggi del contatore degli intervalli:

--taglio--
Intervallo: 43
Intervallo: 44
Intervallo: 45
Messaggio: 'a'
Intervallo: 46
Intervallo: 47
Intervallo: 48
--taglio--

Il Listato 17-39 mostra un modo per risolvere questi ultimi due problemi.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messaggi = ricevi_messaggi().timeout(Duration::from_millis(200));
        let intervalli = ricevi_intervalli()
            .map(|conteggio| format!("Intervallo: {conteggio}"))
            .throttle(Duration::from_millis(100))
            .timeout(Duration::from_secs(10));
        let uniti = messaggi.merge(intervalli).take(20);
        let mut stream = pin!(uniti);

        while let Some(risultato) = stream.next().await {
            match risultato {
                Ok(messaggio) => println!("{messaggio}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    })
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            tx.send(format!("Messaggio: '{messaggio}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn ricevi_intervalli() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut conteggio = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            conteggio += 1;
            tx.send(conteggio).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-39: Utilizzo di throttle e take per gestire gli stream uniti

Per prima cosa, utilizziamo il metodo throttle sullo stream intervalli in modo che non sovraccarichi lo stream messaggi. Throttling è un modo per limitare la frequenza con cui una funzione verrà chiamata, o, in questo caso, quanto spesso lo stream verrà interrogato. Una volta ogni 100 millisecondi dovrebbe andare bene, perché è più o meno quanto spesso arrivano i nostri messaggi.

Per limitare il numero di elementi che accetteremo da uno stream, applichiamo il metodo take allo stream uniti, perché vogliamo limitare l’output finale, non solo uno stream o l’altro.

Ora, quando eseguiamo il programma, si ferma dopo aver estratto 20 elementi dallo stream, e gli intervalli non sovraccaricano i messaggi. Non otteniamo Interval: 100 o Interval: 200 e così via, ma invece otteniamo Interval: 1, Interval: 2, e così via, anche se abbiamo uno stream sorgente che può produrre un evento ogni millisecondo. Questo perché la chiamata a throttle produce un nuovo stream che incapsula lo stream originale, in modo che lo stream originale venga interrogato solo alla velocità di throttle, non alla sua “velocità nativa”. Non abbiamo un sacco di messaggi di intervallo non gestiti che scegliamo di ignorare. Invece, evitiamo di non produrre quei messaggi di intervallo! Questa è l’innata “pigrizia” dei future di Rust che entra in gioco, permettendoci di scegliere le nostre caratteristiche prestazionali.

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.94s
     Running `target/debug/async_await`
Intervallo: 1
Messaggio: 'a'
Intervallo: 2
Intervallo: 3
Problema: Elapsed(())
Intervallo: 4
Messaggio: 'b'
Intervallo: 5
Messaggio: 'c'
Intervallo: 6
Intervallo: 7
Problema: Elapsed(())
Intervallo: 8
Messaggio: 'd'
Intervallo: 9
Messaggio: 'e'
Intervallo: 10
Intervallo: 11
Problema: Elapsed(())
Intervallo: 12

C’è un’ultima cosa che dobbiamo gestire: gli errori! Con entrambi questi stream basati su canali, le chiamate a send potrebbero fallire quando l’altra estremità del canale si chiude, e questo è solo una questione di come il runtime esegue le future che compongono lo stream. Fino ad ora, abbiamo ignorato questa possibilità chiamando unwrap, ma in un’applicazione ben progettata, dovremmo gestire esplicitamente l’errore, almeno terminando il ciclo in modo da non provare a inviare ulteriori messaggi. Il Listato 17-40 mostra una semplice strategia per gli errori: stampare il problema e poi uscire dai cicli con break.

extern crate trpl; // necessario per test mdbook

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messaggi = ricevi_messaggi().timeout(Duration::from_millis(200));
        let intervalli = ricevi_intervalli()
            .map(|conteggio| format!("Intervallo: {conteggio}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let uniti = messaggi.merge(intervalli).take(20);
        let mut stream = pin!(uniti);

        while let Some(risultato) = stream.next().await {
            match risultato {
                Ok(elemento) => println!("{elemento}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    });
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            if let Err(errore_invio) = tx.send(format!("Messaggio: '{messaggio}'")) {
                eprintln!("Impossibile inviare messaggio '{messaggio}': {errore_invio}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn ricevi_intervalli() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut conteggio = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            conteggio += 1;

            if let Err(errore_invio) = tx.send(conteggio) {
                eprintln!("Impossibile inviare intervallo {conteggio}: {errore_invio}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-40: Gestione degli errori e chiusura dei cicli

Come al solito, il modo corretto per gestire un errore di invio di un messaggio varierà; assicurati solo di avere una strategia per gestirlo.

Ora che abbiamo visto un sacco di async nella pratica, facciamo un passo indietro e approfondiamo alcuni dettagli su come Rust usa Future, Stream e gli altri trait chiave per far funzionare l’async.

Uno Sguardo Più Da Vicino ai Trait per Async

Nel corso del capitolo, abbiamo utilizzato i trait Future, Pin, Unpin, Stream e StreamExt in vari modi. Finora, però, abbiamo evitato di addentrarci troppo nei dettagli di come funzionano o di come interagiscono, il che va bene per la maggior parte delle volte che li userai nel tuo lavoro quotidiano con Rust. A volte, però, ti capiterà di incontrare situazioni in cui avrai bisogno di comprendere queste cose più in dettaglio. In questa sezione, ci addentreremo il giusto in questi dettagli per aiutarti in quegli scenari, lasciando comunque il vero e proprio approfondimento completo alla documentazione specifica di quello che ti interessa.

Il Trait Future

Iniziamo a dare un’occhiata più da vicino a come funziona il trait Future. Ecco come Rust lo definisce:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Quella definizione di trait include alcuni nuovi type e anche una sintassi che non abbiamo visto prima d’ora, quindi esaminiamola un pezzo per volta.

Per prima cosa, il type associato Output di Future dice in cosa si risolve la future. Questo è analogo al type associato Item per il trait Iterator. In secondo luogo, Future ha anche il metodo poll, che prende un reference speciale Pin per il suo parametro self e un reference mutabile a un type Context, e restituisce un Poll<Self::Output>. Parleremo più avanti di Pin e Context. Per ora, concentriamoci su cosa restituisce il metodo, il type Poll:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

Questo type Poll è simile a un Option. Ha una variante che ha un valore, Ready(T), e una che non ce l’ha, Pending (in attesa). Tuttavia, Poll significa qualcosa di molto diverso da Option! La variante Pending indica che la future ha ancora lavoro da fare, quindi il chiamante dovrà controllare di nuovo più tardi. La variante Ready indica che la future ha finito il suo lavoro e il valore T è disponibile.

Nota: Con la maggior parte delle future, il chiamante non dovrebbe chiamare poll di nuovo dopo che la future ha restituito Ready. Molte future andranno in panic se interrogate di nuovo dopo essere diventate pronte. Le future che possono essere interrogate di nuovo lo diranno esplicitamente nella loro documentazione. Questo è simile a come si comporta Iterator::next.

Quando vedi codice che usa await, Rust lo compila dietro le quinte in codice che chiama poll. Se guardi indietro al Listato 17-4, dove abbiamo stampato il titolo della pagina per un singolo URL, Rust lo compila in qualcosa di simile (anche se non esattamente) a questo:

match titolo_pagina(url).poll() {
    Ready(valore) => match titolo_pagina {
        Some(titolo) => println!("Il titolo per {url} era {titolo}"),
        None => println!("{url} non aveva titolo"),
    }
    Pending => {
        // Ma cosa mettiamo qui?
    }
}

Cosa dovremmo fare quando la future è ancora Pending? Abbiamo bisogno di un modo per riprovare, e riprovare, e riprovare, fino a quando la future è finalmente pronta. In altre parole, abbiamo bisogno di un ciclo:

let mut titolo_pagina_fut = titolo_pagina(url);
loop {
    match titolo_pagina_fut.poll() {
        Ready(valore) => match titolo_pagina {
            Some(titolo) => println!("Il titolo per {url} era {titolo}"),
            None => println!("{url} non aveva titolo"),
        }
        Pending => {
            // continua
        }
    }
}

Se Rust lo compilasse esattamente in quel codice, però, ogni await sarebbe bloccante, esattamente l’opposto di ciò che volevamo! Invece, Rust si assicura che il ciclo possa cedere il controllo a qualcosa che può mettere in pausa il lavoro su questa future per lavorare su altre future e poi controllare di nuovo questo più tardi. Come abbiamo visto, quel qualcosa è un runtime async, e questo lavoro di pianificazione e coordinamento è uno dei suoi compiti principali.

In precedenza nel capitolo, abbiamo descritto l’attesa su rx.recv. La chiamata recv restituisce una future, e attendere la future la richiama. Abbiamo notato che un runtime metterà in pausa la future fino a quando non è pronta con Some(messaggio) o None quando il canale si chiude. Ora che comprendi meglio il trait Future, e specificamente Future::poll, possiamo vedere come funziona. Il runtime sa che la future non è pronta quando restituisce Poll::Pending. Al contrario, il runtime sa che la future è pronta e la avanza quando poll restituisce Poll::Ready(Some(messaggio)) o Poll::Ready(None).

I dettagli esatti di come un runtime faccia ciò vanno oltre lo scopo di questo libro, ma la chiave è vedere i meccanismi di base delle future: un runtime interroga ogni future di cui è responsabile, rimettendo la future a dormire quando non è ancora pronta.

I Trait Pin e Unpin

Quando abbiamo introdotto l’idea di pinning (fissare) nel Listato 17-16, ci siamo imbattuti in un messaggio di errore molto complicato. Ecco la parte rilevante di esso di nuovo:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:32
   |
48 |         trpl::join_all(future).await;
   |                                ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> /home/utente/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

Questo messaggio di errore ci dice non solo che dobbiamo fissare i valori, ma anche perché il pinning è richiesto. La funzione trpl::join_all restituisce una struct chiamata JoinAll. Quella struct è generica su un type F, che è vincolato a implementare il trait Future. Attendere direttamente una future con await blocca implicitamente la future. Ecco perché non abbiamo bisogno di usare pin! ovunque vogliamo attendere le future.

Tuttavia, qui non stiamo attendendo direttamente una future. Invece, costruiamo un nuova future, JoinAll, passando una collezione di future alla funzione join_all. La firma per join_all richiede che i type degli elementi nella collezione implementino tutti il trait Future, e Box<T> implementa Future solo se il T che incapsula è una future che implementa il trait Unpin.

Sono un sacco di informazioni da assorbire! Per capire davvero, approfondiamo un po’ di più come funziona effettivamente il trait Future, in particolare riguardo al pinning.

Guarda di nuovo la definizione del trait Future:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Metodo richiesto
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Il parametro cx e il suo type Context sono la chiave per capire come un runtime sa effettivamente quando controllare una data future pur rimanendo lazy. Ancora una volta, i dettagli di come ciò funzioni vanno oltre lo scopo di questo capitolo, e generalmente devi pensare a questo solo quando scrivi un’implementazione personalizzata di Future. Ci concentreremo invece sul type per self, poiché è la prima volta che vediamo un metodo in cui self ha un’annotazione di type. Un’annotazione di type per self funziona come le annotazioni di type per altri parametri di funzione, ma con due differenze chiave:

  • Indica a Rust quale type deve essere self affinché il metodo possa essere chiamato.
  • Non può essere semplicemente qualsiasi type. È limitato al type su cui il metodo è implementato, a un reference o a un puntatore intelligente a quel type, o a un Pin che incapsula un reference a quel type.

Vedremo di più su questa sintassi nel Capitolo 18. Per ora, è sufficiente sapere che se vogliamo interrogare una future per controllare se è Pending o Ready(Output), abbiamo bisogno di un reference mutabile al type incapsulato in Pin.

Pin è un wrapper per type simili a puntatori come &, &mut, Box e Rc. (Tecnicamente, Pin funziona con type che implementano i trait Deref o DerefMut, ma questo è effettivamente equivalente a lavorare solo con puntatori.) Pin non è un puntatore e non ha alcun comportamento proprio, come invece Rc e Arc fanno con il conteggio dei reference; è puramente uno strumento che il compilatore può utilizzare per imporre vincoli sull’uso dei puntatori.

Ricordando che await è implementato in termini di chiamate a poll, iniziamo a capire il messaggio di errore che abbiamo visto in precedenza, ma quello era in termini di Unpin, non di Pin. Quindi, come si relazionano esattamente Pin e Unpin, e perché il Future ha bisogno che self sia in un type Pin per chiamare poll?

Come menzionato in precedenza nel capitolo, una serie di punti di attesa in una future viene compilata in una macchina a stati, e il compilatore si assicura che quella macchina a stati segua tutte le normali regole di sicurezza di Rust, inclusi il prestito e la ownership. Per far funzionare tutto ciò, Rust guarda quali dati sono necessari tra un punto di attesa e l’altro, o tra il punto di attesa e la fine del blocco async. Crea quindi una variante corrispondente nella macchina a stati compilata. Ogni variante ottiene l’accesso di cui ha bisogno ai dati che verranno utilizzati in quella sezione del codice sorgente, sia prendendo possesso di quei dati sia ottenendo un reference mutabile o immutabile ad essi.

Finora, tutto bene: se commettiamo errori riguardo alla ownership o ai riferimenti in un dato blocco async, il borrow checker ce lo dirà. Quando vogliamo spostare la future che corrisponde a quel blocco, come spostarla in un Vec da passare a join_all, le cose diventano più complicate.

Quando spostiamo una future, sia mettendola in una struttura dati da utilizzare come iteratore con join_all o restituendola da una funzione, significa effettivamente spostare la macchina a stati che Rust crea per noi. E a differenza della maggior parte degli altri type in Rust, le future che Rust crea per i blocchi async possono finire con riferimenti a se stesse nei campi di una data variante, come mostrato nell’illustrazione semplificata nella Figura 17-4.

Una tabella a colonna singola e
tre righe che rappresenta una `future`, fut1, che ha valori di dati 0 e 1 nelle
prime due righe e una freccia che punta dalla terza riga di nuovo alla seconda
riga, rappresentando un riferimento interno all’interno della `future`.

Figura 17-4: Un tipo di dato auto-referenziale.

Per impostazione predefinita, però, qualsiasi oggetto che ha un riferimento a se stesso è insicuro da spostare, perché i riferimenti puntano sempre all’indirizzo di memoria effettivo di ciò a cui si riferiscono (vedi Figura 17-5). Se sposti la struttura dati stessa, quei riferimenti interni rimarranno puntati alla vecchia posizione. Tuttavia, quella posizione di memoria è ora non valida. Per un verso, il suo valore non verrà aggiornato quando apporti modifiche alla struttura dati. Per un altro e più importante motivo, il computer è ora libero di riutilizzare quella memoria per altri scopi! Potresti finire per leggere dati completamente non correlati in seguito.

Due tabelle, che raffigurano
due `future`, fut1 e fut2, ciascuna delle quali ha una colonna e tre righe,
rappresentando il risultato di aver spostato una `future` da fut1 a fut2. La
prima, fut1, è grigia, con un punto interrogativo in ciascun indice,
rappresentando una memoria sconosciuta. La seconda, fut2, ha 0 e 1 nella prima e
nella seconda riga e una freccia che punta dalla sua terza riga di nuovo alla
seconda riga di fut1, rappresentando un puntatore che fa riferimento alla
vecchia posizione in memoria della `future` prima che fosse spostata.

Figura 17-5: Il risultato non sicuro di spostare un tipo di dato auto-referenziale

Teoricamente, il compilatore Rust potrebbe cercare di aggiornare ogni riferimento a un oggetto ogni volta che viene spostato, ma ciò potrebbe aggiungere un notevole sovraccarico di prestazioni, specialmente se un’intera rete di riferimenti deve essere aggiornata. Se potessimo invece assicurarci che la struttura dati in questione non si muova in memoria, non dovremmo aggiornare alcun riferimento. Questo è esattamente ciò che il borrow checker di Rust richiede: nel codice sicuro, impedisce di spostare qualsiasi elemento con un riferimento attivo.

Pin si basa su questo per darci la garanzia esatta di cui abbiamo bisogno. Quando fissiamo un valore incapsulando un puntatore a quel valore in Pin, non può più muoversi. Quindi, se hai Pin<Box<QualcheType>>, in realtà fissi il valore QualcheType, non il puntatore Box. La Figura 17-6 illustra questo processo.

Tre scatole disposte
affiancate. La prima è etichettata “Pin”, la seconda “b1”, e la terza “pinned”.
All’interno di “pinned” c’è una tabella etichettata “fut”, con una singola
colonna; rappresenta una `future` con celle per ciascuna parte della struttura
dati. La sua prima cella ha il valore “0”, la sua seconda cella ha una freccia
che esce da essa e punta alla quarta e ultima cella, che ha il valore “1”, e la
terza cella ha linee tratteggiate e un’ellissi per indicare che potrebbero
esserci altre parti nella struttura dati. Insieme, la tabella “fut” rappresenta
una `future` che è auto-referenziale. Una freccia esce dalla scatola etichettata
“Pin”, passa attraverso la scatola etichettata “b1” e termina all’interno della
scatola “pinned” nella tabella “fut”.

Figura 17-6: Pinning di una Box che punta a un type di future auto-referenziale.

In effetti, il puntatore Box può ancora muoversi liberamente. Ricorda: ci interessa assicurarci che i dati a cui si fa riferimento rimangano al loro posto. Se un puntatore si muove, ma i dati a cui punta sono nello stesso posto, come nella Figura 17-7, non c’è alcun problema potenziale. Come esercizio indipendente, dai un’occhiata alla documentazione per i type così come a quella del modulo std::pin e prova a capire come faresti questo con un Pin che incapsula una Box. La chiave è che il type auto-referenziale stesso non può muoversi, perché è ancora fissato.

Quattro scatole disposte in tre
colonne approssimative, identiche al diagramma precedente con una modifica alla
seconda colonna. Ora ci sono due scatole nella seconda colonna, etichettate “b1”
e “b2”, “b1” è grigia, e la freccia da “Pin” passa attraverso “b2” invece di
“b1”, indicando che il puntatore si è spostato da “b1” a “b2”, ma i dati in
“pinned” non si sono mossi.

Figura 17-7: Spostare una Box che punta a un type di future auto-referenziale.

Tuttavia, la maggior parte dei type è perfettamente sicura da spostare, anche se sono incapsulati da Pin. Dobbiamo pensare al pinning solo quando gli elementi hanno reference interni. I valori primitivi come numeri e booleani sono sicuri perché ovviamente non hanno reference interni. Né la maggior parte dei type con cui normalmente lavori in Rust. Puoi spostare un Vec, ad esempio, senza preoccuparti. Dato ciò che abbiamo visto finora, se hai un Pin<Vec<String>>, dovresti fare tutto tramite le API sicure ma restrittive fornite da Pin, anche se un Vec<String> è sempre sicuro da spostare se non ci sono altri riferimenti ad esso. Abbiamo bisogno di un modo per dire al compilatore che va bene spostare gli elementi in casi come questo, ed è qui che entra in gioco Unpin.

Unpin è un trait marcatore, simile ai trait Send e Sync che abbiamo visto nel Capitolo 16, e quindi non ha funzionalità propria. I trait marcatori esistono solo per dire al compilatore che è sicuro utilizzare il type che implementa un dato trait in un contesto particolare. Unpin informa il compilatore che un dato type non ha bisogno di verificare alcuna garanzia sul fatto che il valore in questione possa essere spostato in sicurezza.

Proprio come per Send e Sync, il compilatore implementa automaticamente Unpin per tutti i type per i quali può dimostrare che è sicuro. Un caso speciale, di nuovo simile a Send e Sync, è dove Unpin non è implementato per un type. La notazione per questo è impl !Unpin for QualcheType, dove QualcheType è il nome di un type che deve mantenere quelle garanzie per essere sicuro ogni volta che un puntatore a quel type viene utilizzato in un Pin.

In altre parole, ci sono due cose da tenere a mente riguardo alla relazione tra Pin e Unpin. Prima di tutto, Unpin è il caso “normale”, e !Unpin è il caso speciale. In secondo luogo, se un type implementa Unpin o !Unpin importa solo quando stai usando un puntatore fissato a quel type come Pin<&mut QualcheType>.

Per andare nel concreto, pensa a una String: ha una lunghezza e i caratteri Unicode che la compongono. Possiamo incapsulare una String in Pin, come visto nella Figura 17-8. Tuttavia, String implementa automaticamente Unpin, così come la maggior parte degli altri type in Rust.

Flusso di lavoro concorrente

Figura 17-8: Pinning di una String; la linea tratteggiata indica che la String implementa il trait Unpin, e quindi non è fissata.

Di conseguenza, possiamo fare cose che sarebbero illegali se String implementasse !Unpin, come sostituire una stringa con un’altra nella stessa posizione in memoria, come nella Figura 17-9. Questo non viola il contratto di Pin, perché String non ha riferimenti interni che la rendano insicura da spostare! È proprio per questo che implementa Unpin piuttosto che !Unpin.

Flusso di lavoro concorrente

Figura 17-9: Sostituzione della String con un’altra String completamente diversa in memoria.

Ora sappiamo abbastanza per comprendere gli errori segnalati per quella chiamata a join_all dal Listato 17-16. Inizialmente abbiamo cercato di spostare le future prodotte dai blocchi async in un Vec<Box<dyn Future<Output = ()>>>, ma come abbiamo visto, quelle future possono avere riferimenti interni, quindi non implementano Unpin. Devono essere fissate, e poi possiamo passare il type Pin nel Vec, certi che i dati sottostanti nelle future non verranno spostati.

Pin e Unpin sono principalmente importanti per costruire librerie di basso livello, o quando stai costruendo un runtime stesso, piuttosto che per il codice Rust quotidiano. Tuttavia, quando vedi questi trait nei messaggi di errore, ora avrai un’idea migliore di come correggere il tuo codice!

Nota: Questa combinazione di Pin e Unpin rende possibile implementare in modo sicuro un’intera classe di type complessi in Rust che altrimenti risulterebbero difficili a causa della loro auto-referenzialità. I type che richiedono Pin si presentano più comunemente nella programmazione asincrona di Rust, ma di tanto in tanto potresti vederli anche in altri contesti.

I dettagli specifici su come funzionano Pin e Unpin, e le regole che devono rispettare, sono trattati ampiamente nella documentazione API per std::pin, quindi se sei interessato a saperne di più, quello è un ottimo punto di partenza.

Se vuoi capire come funzionano le cose sotto il cofano in modo ancora più dettagliato, consulta i capitoli su gestione dell’esecuzione e pinning di Asynchronous Programming in Rust.

Il Trait Stream

Ora che hai una comprensione più profonda dei trait Future, Pin e Unpin, possiamo rivolgere la nostra attenzione al trait Stream. Come hai appreso in precedenza nel capitolo, gli stream sono simili agli iteratori asincroni. A differenza di Iterator e Future, tuttavia, Stream non ha una definizione nella libreria standard al momento della scrittura, ma c’è una definizione molto comune dal crate futures utilizzata in tutto l’ecosistema.

Rivediamo le definizioni dei trait Iterator e Future prima di vedere come un trait Stream potrebbe unirli. Da Iterator, abbiamo l’idea di una sequenza: il suo metodo next fornisce un Option<Self::Item>. Da Future, abbiamo l’idea di prontezza nel tempo: il suo metodo poll fornisce un Poll<Self::Output>. Per rappresentare una sequenza di elementi che diventano pronti nel tempo, definiamo un trait Stream che mette insieme queste caratteristiche:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Il trait Stream definisce un type associato chiamato Item per il type degli elementi prodotti dallo stream. Questo è simile a Iterator, dove possono esserci da zero a molti elementi, e a differenza di Future, dove c’è sempre un singolo Output, anche se è il type unitario ().

Stream definisce anche un metodo per ottenere quegli elementi. Lo chiamiamo poll_next, per chiarire che interroga nello stesso modo in cui fa Future::poll e produce una sequenza di elementi nello stesso modo in cui fa Iterator::next. Il suo type di ritorno combina Poll con Option. Il type esterno è Poll, perché deve essere controllato per prontezza, proprio come una future. Il type interno è Option, perché deve segnalare se ci sono altri messaggi, proprio come fa un iteratore.

Qualcosa di molto simile a questa definizione diverrà probabilmente parte della libreria standard di Rust in futuro. Nel frattempo, fa parte dell’arsenale della maggior parte dei runtime, quindi puoi fare affidamento su di essa, e tutto ciò che copriremo successivamente dovrebbe generalmente applicarsi!

Nell’esempio che abbiamo visto nella sezione sugli stream, però, non abbiamo usato poll_next o Stream, ma invece abbiamo usato next e StreamExt. Potremmo lavorare direttamente in termini dell’API poll_next scrivendo a mano le nostre macchine a stati Stream, ovviamente, proprio come potremmo lavorare con le future direttamente tramite il loro metodo poll. Tuttavia, usare await è molto più piacevole, e il trait StreamExt fornisce il metodo next in modo da poter fare proprio questo:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // altri metodi...
}
}

Nota: La definizione effettiva che abbiamo utilizzato in precedenza nel capitolo appare leggermente diversa da questa, perché supporta versioni di Rust che non supportavano ancora l’uso di funzioni async nei trait. Di conseguenza, appare in questo modo:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Quel type Next è una struct che implementa Future e ci consente di nominare la lifetime del reference a self con Next<'_, Self>, in modo che await possa funzionare con questo metodo.

Il trait StreamExt è anche la casa di tutti i metodi interessanti disponibili per l’uso con gli stream. StreamExt è implementato automaticamente per ogni type che implementa Stream, ma questi trait sono definiti separatamente per consentire alla comunità di aggiungere API extra senza influenzare il trait fondamentale.

Nella versione di StreamExt utilizzata nel crate trpl, il trait non solo definisce il metodo next, ma fornisce anche un’implementazione predefinita di next che gestisce correttamente i dettagli della chiamata a Stream::poll_next. Questo significa che anche quando hai bisogno di scrivere il tuo type di stream, devi solo implementare Stream, e poi chiunque utilizzi il tuo type può utilizzare StreamExt e i suoi metodi con esso automaticamente.

Questo è tutto ciò che tratteremo per i dettagli di basso livello su questi trait. Per concludere, vedremo come future (inclusi gli stream), task e thread si integrano tutti insieme!

Future, Task e Thread

Come abbiamo visto nel Capitolo 16, i thread offrono un approccio alla concorrenza. Abbiamo visto un altro approccio in questo capitolo: utilizzare async con future e stream. Se ti stai chiedendo quando scegliere un metodo rispetto all’altro, la risposta è: dipende! E in molti casi, la scelta non è tra thread o async, ma piuttosto tra thread e async.

Molti sistemi operativi hanno fornito modelli di concorrenza basati su thread per decenni, e molti linguaggi di programmazione li supportano di conseguenza. Tuttavia, questi modelli non sono privi di compromessi. Su molti sistemi operativi, utilizzano una buona quantità di memoria per ogni thread e comportano un certo overhead per l’avvio e lo spegnimento. I thread sono anche un’opzione solo quando il tuo sistema operativo e hardware li supportano. A differenza dei computer desktop e smartphone moderni, alcuni sistemi embedded non hanno affatto un OS, quindi non hanno nemmeno thread.

Il modello async fornisce un insieme di compromessi diverso, e alla fine complementare. Nel modello async, le operazioni concorrenti non richiedono i propri thread. Invece, possono essere eseguite su task, come quando abbiamo utilizzato trpl::spawn_task per avviare un lavoro da una funzione sincrona nella sezione degli stream. Un task è simile a un thread, ma invece di essere gestito dal sistema operativo, è gestito da codice a livello di libreria: il runtime.

Nella sezione precedente, abbiamo visto che potevamo costruire uno stream utilizzando un canale async e avviando un task asincrono che potevamo chiamare da codice sincrono. Possiamo fare esattamente la stessa cosa con un thread. Nel Listato 17-40, avevamo utilizzato trpl::spawn_task e trpl::sleep. Nel Listato 17-41, li sostituiamo con le API thread::spawn e thread::sleep della libreria standard nella funzione ricevi_intervalli.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{pin::pin, thread, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messaggi = ricevi_messaggi().timeout(Duration::from_millis(200));
        let intervalli = ricevi_intervalli()
            .map(|conteggio| format!("Intervallo: {conteggio}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let uniti = messaggi.merge(intervalli).take(20);
        let mut stream = pin!(uniti);

        while let Some(risultato) = stream.next().await {
            match risultato {
                Ok(elemento) => println!("{elemento}"),
                Err(ragione) => eprintln!("Problema: {ragione:?}"),
            }
        }
    });
}

fn ricevi_messaggi() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messaggi = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (indice, messaggio) in messaggi.into_iter().enumerate() {
            let tempo_dormita = if indice % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(tempo_dormita)).await;

            if let Err(errore_invio) = tx.send(format!("Messaggio: '{messaggio}'")) {
                eprintln!("Impossibile inviare messaggio '{messaggio}': {errore_invio}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn ricevi_intervalli() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    // Questo NON è `trpl::spawn` ma `std::thread::spawn`!
    thread::spawn(move || {
        let mut conteggio = 0;
        loop {
            // E questo NON è `trpl::sleep` ma `std::thread::sleep`!
            thread::sleep(Duration::from_millis(1));
            conteggio += 1;

            if let Err(errore_invio) = tx.send(conteggio) {
                eprintln!("Impossibile inviare intervallo {conteggio}: {errore_invio}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listato 17-41: Utilizzo delle API std::thread invece delle API async trpl per la funzione ricevi_intervalli

Se esegui questo codice, l’output è identico a quello del Listato 17-40. E nota quanto poco cambia qui dalla prospettiva del codice chiamante. Inoltre, anche se una delle nostre funzioni ha avviato un task async sul runtime e l’altra ha avviato un thread del sistema operativo, gli stream risultanti non sono stati influenzati dalle differenze.

Nonostante le loro somiglianze, questi due approcci si comportano in modo molto diverso, anche se potremmo avere difficoltà a misurarlo in questo esempio molto semplice. Potremmo avviare milioni di task async su qualsiasi computer moderno. Se provassimo a farlo con i thread, esauriremmo letteralmente la memoria!

Tuttavia, c’è un motivo per cui queste API sono così simili. I thread agiscono come un confine per insiemi di operazioni sincrone; la concorrenza è possibile tra i thread. I task agiscono come un confine per insiemi di operazioni asincrone; la concorrenza è possibile sia tra che all’interno dei task, perché un task può passare tra future nel suo corpo. Infine, le future sono l’unità di concorrenza più granulare di Rust, e ogni future può rappresentare un albero di altre future. Il runtime, e nello specifico il suo esecutore, gestisce i task, e i task gestiscono le future. In questo senso, i task sono simili a thread leggeri gestiti dal runtime con capacità aggiuntive che derivano dal fatto di essere gestiti da un runtime anziché dal sistema operativo.

Questo non significa che i task async siano sempre migliori dei thread (o viceversa). La concorrenza con i thread è in alcuni modi un modello di programmazione più semplice rispetto alla concorrenza con async. Questo può essere un punto di forza o una debolezza. I thread sono in un certo senso “esegui e dimenticatene”; non hanno un equivalente nativo a una future, quindi semplicemente eseguono fino al completamento senza essere interrotti, tranne che dal sistema operativo stesso. Cioè, non hanno supporto integrato per la concorrenza intra-task come fanno le future. I thread in Rust non hanno nemmeno meccanismi per la cancellazione, un argomento che non abbiamo trattato esplicitamente in questo capitolo, ma che dovrebbe esserti apparso implicito dal fatto che ogni volta che abbiamo terminato una future, il suo stato è stato ripulito correttamente.

Queste limitazioni rendono anche i thread più difficili da comporre rispetto alle future. È molto più difficile, ad esempio, utilizzare i thread per costruire funzionalità come i metodi timeout e throttle che abbiamo costruito in precedenza in questo capitolo. Il fatto che le future siano strutture dati più ricche significa che possono essere composte insieme in modo più naturale, come abbiamo visto.

I task, quindi, ci danno un controllo aggiuntivo sulle future, permettendoci di scegliere dove e come raggrupparle. E se non bastasse, i thread e i task spesso funzionano molto bene insieme, perché i task possono (almeno in alcuni runtime) essere spostati tra i thread. Infatti, dietro le quinte, il runtime che abbiamo utilizzato, comprese le funzioni spawn_blocking e spawn_task, è multi-thread per impostazione predefinita! Molti runtime utilizzano un approccio chiamato work stealing per spostare in modo trasparente i task tra i thread, in base a come i thread vengono attualmente utilizzati, per migliorare le prestazioni complessive del sistema. Questo approccio richiede effettivamente sia thread che task, e quindi future.

Quando si pensa a quale metodo utilizzare, considera queste regole pratiche:

  • Se il lavoro è molto parallelizzabile, come l’elaborazione di un insieme di dati in cui ogni parte può essere elaborata separatamente, i thread sono una scelta migliore.
  • Se il lavoro è molto concorrente, come gestire messaggi provenienti da diverse fonti che possono arrivare a intervalli o tassi diversi, async è una scelta migliore.

E se hai bisogno sia di parallelismo che di concorrenza, non devi scegliere tra thread e async. Puoi usarli insieme liberamente, lasciando a ciascuno il compito che svolge meglio. Ad esempio, il Listato 17-42 mostra un esempio piuttosto comune di questo tipo di mix nel codice Rust reale.

File: src/main.rs
extern crate trpl; // necessario per test mdbook

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::run(async {
        while let Some(messaggio) = rx.recv().await {
            println!("{messaggio}");
        }
    });
}
Listato 17-42: Invio di messaggi con codice bloccante in un thread e attesa dei messaggi in un blocco async

Iniziamo creando un canale async, quindi avviamo un thread che prende possesso della estremità del mittente del canale. All’interno del thread, inviamo i numeri da 1 a 10, dormendo per un secondo tra ciascuno. Infine, eseguiamo una future creata con un blocco async passato a trpl::run, proprio come abbiamo fatto in tutto il capitolo. In quella future, attendiamo quei messaggi, proprio come negli altri esempi di invio messaggi che abbiamo visto.

Per tornare allo scenario con cui abbiamo aperto il capitolo, immagina di eseguire un insieme di task di codifica video utilizzando un thread dedicato (perché la codifica video è vincolata al calcolo) ma notificando l’interfaccia utente che quelle operazioni sono terminate con un canale async. Ci sono innumerevoli esempi di queste combinazioni in casi d’uso reali.

Riepilogo

Questa non è l’ultima volta che vedrai la concorrenza in questo libro. Il progetto nel Capitolo 21 applicherà questi concetti in una situazione più realistica rispetto agli esempi più semplici discussi qui e confronterà la risoluzione dei problemi con i thread rispetto ai task in modo più diretto.

Indipendentemente da quale di questi approcci scegli, Rust ti offre gli strumenti necessari per scrivere codice concorrente sicuro e veloce, sia per un server web ad alta capacità che per un sistema operativo embedded.

Nel prossimo capitolo, parleremo di modi idiomatici per modellare problemi e strutturare soluzioni man mano che i tuoi programmi Rust crescono. Inoltre, discuteremo di come gli idiomi di Rust si relazionano a quelli con cui potresti avere familiarità provenienti dalla programmazione orientata agli oggetti.

Funzionalità della Programmazione Orientata agli Oggetti

La programmazione orientata agli oggetti (OOP) è un modo per modellare i programmi. Il concetto di oggetti è stato introdotto negli anni ’60 nel linguaggio Simula. Quegli oggetti hanno influenzato l’architettura di Alan Kay, dove gli oggetti si scambiano messaggi tra loro. Per descrivere questa architettura, lui ha coniato il termine programmazione orientata agli oggetti nel 1967. Ci sono un sacco di definizioni diverse su cosa sia esattamente la OOP, e secondo alcune Rust è orientato agli oggetti, secondo altre no. In questo capitolo vedremo alcune caratteristiche che di solito si considerano tipiche della programmazione orientata agli oggetti e come queste si traducono in Rust in modo naturale. Poi ti mostreremo come implementare un modello di design orientato agli oggetti in Rust e discuteremo i pro e i contro rispetto a soluzioni che sfruttano i punti di forza di Rust in altri modi.

Caratteristiche dei Linguaggi Orientati agli Oggetti

Non c’è consenso nella comunità di programmatori su quali caratteristiche un linguaggio deve avere per essere considerato orientato agli oggetti. Rust è influenzato da molti paradigmi di programmazione, inclusa la OOP; per esempio, abbiamo esplorato le caratteristiche provenienti dalla programmazione funzionale nel Capitolo 13. Si può dire che i linguaggi OOP condividano certe caratteristiche comuni, come oggetti, incapsulamento ed ereditarietà. Vediamo cosa significa ognuna di queste caratteristiche e se Rust le supporta.

Gli Oggetti Contengono Dati e Comportamenti

Il libro Design Patterns: Elements of Reusable Object-Oriented Software di Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides (Addison-Wesley, 1994) (traduzione in italiano pubblicata da Pearson, 2002), noto come il libro della Gang of Four (Banda dei quattro), è un catalogo di modelli di progettazione orientati agli oggetti. Definisce la OOP in questo modo:

I programmi orientati agli oggetti sono composti da oggetti. Un oggetto raggruppa sia i dati sia le procedure che operano su quei dati. Le procedure sono tipicamente chiamate metodi o operazioni.

Secondo questa definizione, Rust è orientato agli oggetti: struct ed enum contengono dati, e i blocchi impl forniscono metodi su struct ed enum. Anche se struct ed enum con metodi non sono chiamati oggetti, forniscono la stessa funzionalità, secondo la definizione della Gang of Four.

Incapsulamento che Nasconde i Dettagli di Implementazione

Un altro aspetto comunemente associato alla OOP è l’idea di incapsulamento, che significa che i dettagli di implementazione di un oggetto non sono accessibili al codice che usa quell’oggetto. Quindi, l’unico modo per interagire con un oggetto è attraverso la sua API pubblica; il codice che usa l’oggetto non dovrebbe poter modificare direttamente i dati o il comportamento interni. Questo permette al programmatore di cambiare e ristrutturare l’implementazione interna di un oggetto senza dover modificare il codice che usa l’oggetto.

Abbiamo visto come controllare l’incapsulamento nel Capitolo 7: possiamo usare la parola chiave pub per decidere quali moduli, type, funzioni e metodi del nostro codice devono essere pubblici e, di default, tutto il resto è privato. Per esempio, possiamo definire una struct CollezioneConMedia che ha un campo contenente un vettore di valori i32. La struct può avere anche un campo che contiene la media dei valori nel vettore, quindi la media non deve essere calcolata ogni volta che serve. In altre parole, CollezioneConMedia tiene memorizzata la media calcolata di volta in volta al posto nostro. Il Listato 18-1 mostra la definizione della struct CollezioneConMedia.

File: src/lib.rs
pub struct CollezioneConMedia {
    lista: Vec<i32>,
    media: f64,
}
Listato 18-1: Una struct CollezioneConMedia che mantiene una lista di interi e la media degli elementi nella collezione

La struct è marcata pub così il resto del codice può usarla, ma i campi interni restano privati. Questo è importante perché vogliamo assicurarci che ogni volta che un valore viene aggiunto o rimosso dalla lista, anche la media venga aggiornata. Lo facciamo implementando i metodi aggiungi, rimuovi e media sulla struct, come nel Listato 18-2.

File: src/lib.rs
pub struct CollezioneConMedia {
    lista: Vec<i32>,
    media: f64,
}

impl CollezioneConMedia {
    pub fn aggiungi(&mut self, valore: i32) {
        self.lista.push(valore);
        self.aggiorna_media();
    }

    pub fn rimuovi(&mut self) -> Option<i32> {
        let risultato = self.lista.pop();
        match risultato {
            Some(valore) => {
                self.aggiorna_media();
                Some(valore)
            }
            None => None,
        }
    }

    pub fn media(&self) -> f64 {
        self.media
    }

    fn aggiorna_media(&mut self) {
        let totale: i32 = self.lista.iter().sum();
        self.media = totale as f64 / self.lista.len() as f64;
    }
}
Listato 18-2: Implementazioni dei metodi pubblici aggiungi, rimuovi e media su CollezioneConMedia

I metodi pubblici aggiungi, rimuovi e media sono gli unici modi con cui accedere o modificare i dati in un’istanza di CollezioneConMedia. Quando si aggiunge un elemento alla lista usando aggiungi o si rimuove con rimuovi, questi metodi chiamano il metodo privato aggiorna_media che si occupa di aggiornare il campo media.

Lasciamo i campi lista e media privati, in questo modo il codice esterno non può aggiungere o rimuovere elementi direttamente dalla lista; altrimenti, il campo media potrebbe diventare non più corrispondente a quello che rappresenta rispetto alla lista. Il metodo media restituisce il valore nel campo media, permettendo al codice esterno di leggere la media ma non di modificarla.

Poiché abbiamo incapsulato i dettagli dell’implementazione della struct CollezioneConMedia, possiamo cambiare facilmente aspetti come la struttura dati in futuro. Per esempio, potremmo usare un HashSet<i32> al posto di un Vec<i32> per il campo lista. Finché le firme dei metodi pubblici aggiungi, rimuovi e media rimangono uguali, il codice esterno che usa CollezioneConMedia non deve cambiare. Se invece avessimo reso lista pubblico, non sarebbe così: HashSet<i32> e Vec<i32> hanno metodi diversi per aggiungere e rimuovere elementi, quindi il codice esterno dovrebbe cambiare se modificasse la lista direttamente.

Se l’incapsulamento è un requisito necessario per considerare un linguaggio orientato agli oggetti, allora Rust lo soddisfa. La possibilità di usare o meno pub per parti diverse del codice abilita l’incapsulamento dei dettagli di implementazione.

Ereditarietà come Sistema dei Type e come Condivisione di Codice

L’ereditarietà è un meccanismo per cui un oggetto può ereditare elementi dalla definizione di un altro oggetto, ottenendo i dati e i comportamenti dell’oggetto genitore senza doverli ridefinire.

Se un linguaggio deve avere l’ereditarietà per essere orientato agli oggetti, allora Rust non lo è. Non c’è modo di definire una struct che erediti i campi e i metodi del genitore senza usare una macro.

Tuttavia, se sei abituato a usare l’ereditarietà, puoi usare in Rust altre soluzioni a seconda del motivo per cui la vorresti usare.

Le due ragioni principali per scegliere l’ereditarietà sono: il riuso del codice e il sistema dei type.

Per il riuso del codice, puoi implementare un comportamento particolare per un type e l’ereditarietà ti consente di riusarlo per un altro type. In Rust puoi farlo in modo limitato con le implementazioni dei metodi default dei trait, come nel Listato 10-14 quando abbiamo dato una implementazione di default al metodo riassunto sul trait Sommario. Qualsiasi type che implementa Sommario avrà il metodo riassunto senza dover scrivere ulteriore codice. Questo è simile a una classe genitore che ha un’implementazione di un metodo e una classe figlia che eredita quella implementazione. Possiamo anche sovrascrivere l’implementazione di default di riassunto quando implementiamo il trait Sommario, simile a una classe figlia che modifica un metodo ereditato.

L’altra ragione per usare l’ereditarietà riguarda il sistema dei type: permettere a un type figlio di essere usato nei posti in cui si usa il type genitore. Questo si chiama anche polimorfismo, che significa poter sostituire oggetti diversi durante l’esecuzione se hanno certe caratteristiche in comune.

Polimorfismo

Per molti, polimorfismo è sinonimo di ereditarietà. Ma in realtà è un concetto più generale che si riferisce a codice in grado di lavorare con dati di tipi diversi. Per l’ereditarietà, questi tipi sono solitamente sottoclassi.

Rust invece usa i generici per astrarre su type diversi e i vincoli di trait per imporre cosa questi type devono fornire. Questo viene solitamente chiamato polimorfismo parametrico vincolato.

Rust ha scelto un set di compromessi diverso non offrendo ereditarietà. L’ereditarietà spesso condivide più codice del necessario. Le sottoclassi non dovrebbero sempre condividere tutte le caratteristiche della classe genitore, ma con l’ereditarietà lo fanno, il che può rendere il design del programma meno flessibile. Può anche introdurre la possibilità di chiamare metodi su sottoclassi che non hanno senso o causano errori perché quei metodi non si applicano. Inoltre, alcuni linguaggi permettono solo l’ereditarietà singola (cioè una sottoclasse può ereditare da una sola classe genitore), limitando ulteriormente la flessibilità nel design.

Per questi motivi, Rust usa un approccio diverso basato sugli oggetti trait invece dell’ereditarietà per ottenere il polimorfismo durante l’esecuzione. Vediamo come funzionano gli oggetti trait.

Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi

Nel Capitolo 8, abbiamo detto che un limite dei vettori è che possono contenere elementi di un solo type. Abbiamo trovato una soluzione nel Listato 8-9 definendo una enum CellaFoglioDiCalcolo che aveva varianti per interi, float e testo. Questo ci permetteva di mettere type diversi in ogni cella e comunque avere un vettore che rappresentava una riga di celle. Questa è una soluzione perfetta quando gli elementi intercambiabili sono un insieme fisso di type noti a tempo di compilazione.

Però a volte vogliamo che chi usa la nostra libreria possa estendere l’insieme di type validi in una situazione. Per mostrare come farlo, creeremo un esempio di interfaccia grafica (GUI) che scorre una lista di elementi, chiamando per ognuno un metodo disegna per disegnarlo a schermo, una tecnica comune negli strumenti GUI. Creeremo un crate libreria chiamato gui che contiene la struttura base di una libreria GUI. Questo crate includerà type da usare, come Bottone o CampoTesto. Inoltre, gli utenti della libreria vorranno creare i propri type disegnabili: per esempio, uno aggiungerà un Immagine e un altro una BoxSelezione.

Quando scriviamo la libreria, non possiamo sapere e definire tutti i type che altri programmatori potrebbero voler creare. Ma sappiamo che gui deve tenere traccia di molti valori di type diversi e deve chiamare un metodo disegna su ognuno di questi valori. Non deve sapere esattamente cosa succede quando chiama disegna, solo che quel metodo è disponibile.

In un linguaggio con ereditarietà, potremmo definire una classe Componente con un metodo disegna. Le altre classi, come Bottone, Immagine e BoxSelezione, erediterebbero da Componente e quindi avrebbero il metodo disegna. Ognuno potrebbe sovrascriverlo per comportamenti personalizzati, ma il framework tratterebbe tutti i type come istanze di Componente e chiamare disegna. Ma dato che Rust non ha ereditarietà, serve un altro modo per strutturare gui permettendo agli utenti di creare nuovi type compatibili con la libreria.

Definire un Trait per un Comportamento Comune

Per implementare il comportamento che vogliamo in gui, definiamo un trait chiamato Disegna con un metodo disegna. Poi definiamo un vettore che contiene oggetti trait. Un oggetto trait punta sia a un’istanza di un type che implementa il trait, sia a una tabella usata per cercare durante l’esecuzione i metodi trait su quel type. Creiamo un oggetto trait specificando un puntatore, come un reference & o uno puntatore intelligente Box<T>, poi la parola chiave dyn e infine specifichiamo il trait rilevante. (Parleremo del motivo per cui gli oggetti trait devono usare un puntatore in Type a Dimensione Dinamica e il Trait Sized nel Capitolo 20.) Possiamo usare oggetti trait al posto di type generici o concreti. Ovunque usiamo un oggetto trait, il sistema dei type di Rust garantisce durante la compilazione che ogni valore in quel contesto implementi il trait dell’oggetto trait, quindi non serve conoscere tutti i type possibili al momento della compilazione.

Abbiamo detto che in Rust evitiamo di chiamare “oggetti” struct ed enum per distinguerli dagli oggetti di altri linguaggi. In una struct o enum, dati e comportamento in blocchi impl sono separati, mentre in altri linguaggi dati e comportamento uniti formano un oggetto. E quindi gli oggetti trait sono in qualche modo simili agli oggetti in altri linguaggi nel senso che combinano dati e comportamento. Ma gli oggetti trait differiscono dalla tradizionale definizione di oggetto in altri linguaggi perché non possono contenere dati. Gli oggetti trait non hanno la completezza che si trova in altri linguaggi: servono specificamente solo per astrarre comportamenti comuni.

Il Listato 18-3 mostra come definire un trait Disegna con un metodo disegna.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}
Listato 18-3: Definizione del trait Disegna

La sintassi dovrebbe essere familiare dalle discussioni sui trait nel Capitolo 10. Poi, nel Listato 18-4, definiamo una struct Schermo che contiene un vettore componenti. Questo vettore è di type Box<dyn Disegna>, che è un oggetto trait; è un contenitore per qualunque type in una Box che implementi il trait Disegna.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}

pub struct Schermo {
    pub componenti: Vec<Box<dyn Disegna>>,
}
Listato 18-4: Definizione della struct Schermo con una campo componenti contenente un vettore di oggetti trait che implementano Disegna

Definiamo un metodo esegui su Schermo che chiama disegna su ogni elemento di componenti, come nel Listato 18-5.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}

pub struct Schermo {
    pub componenti: Vec<Box<dyn Disegna>>,
}

impl Schermo {
    pub fn esegui(&self) {
        for componente in self.componenti.iter() {
            componente.disegna();
        }
    }
}
Listato 18-5: Un metodo esegui in Schermo che chiama disegna per ogni componente

Questo funziona diversamente da una struct con un parametro di type generico con vincoli di trait. Un type generico può essere sostituito da un solo type concreto alla volta, mentre gli oggetti trait permettono a più type concreti di poter essere usati per quel ruolo durante l’esecuzione. Per esempio, potremmo aver definito la struct Schermo con un type generico e un vincolo di trait, come nel Listato 18-6.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}

pub struct Schermo<T: Disegna> {
    pub componenti: Vec<T>,
}

impl<T> Screen<T>
where
    T: Disegna,
{
    pub fn esegui(&self) {
        for componente in self.componenti.iter() {
            componente.disegna();
        }
    }
}
Listato 18-6: Implementazione alternativa della struct Schermo e del metodo esegui usando type generici e vincoli di trait

Questo limita a istanze di Schermo con una lista di componenti tutte dello stesso type, per esempio tutti Bottone o tutti CampoTesto. Se si hanno solo collezioni omogenee, usare generici è preferibile perché il codice sarà monomorfizzato durante la compilazione usando i type concreti.

Con gli oggetti trait, invece, una singola istanza di Schermo può contenere un Vec<T> con una Box<Bottone> e una Box<CampoTesto> insieme. Vediamo come funziona e poi parleremo delle implicazioni sulle prestazioni durante l’esecuzione.

Implementare il Trait

Ora aggiungiamo type che implementano il trait Disegna. Aggiungiamo un type Bottone. Scrivere una vera e propria libreria GUI va oltre lo scopo del libro, quindi il metodo disegna in non contiene nulla di utile nel corpo. Per farsi un’idea di una possibile implementazione, un Bottone potrebbe avere campi larghezza, altezza e etichetta, come nel Listato 18-7.

File: src/lib.rs
pub trait Disegna {
    fn disegna(&self);
}

pub struct Schermo {
    pub componenti: Vec<Box<dyn Disegna>>,
}

impl Schermo {
    pub fn esegui(&self) {
        for componente in self.componenti.iter() {
            componente.disegna();
        }
    }
}

pub struct Bottone {
    pub larghezza: u32,
    pub altezza: u32,
    pub etichetta: String,
}

impl Disegna for Bottone {
    fn disegna(&self) {
        // codice per disegnare il bottone
    }
}
Listato 18-7: La struct Bottone che implementa il trait Disegna

I campi larghezza, altezza e etichetta su Bottone sono diversi dagli altri componenti; per esempio, un type CampoTesto potrebbe avere gli stessi campi più un campo temporaneo. Ogni type che vogliamo disegnare implementerà il trait Disegna usando codice diverso in disegna per definire come disegnarsi, come fa Bottone (senza codice GUI reale). Bottone potrebbe anche avere altri metodi nel suo blocco impl, ad esempio per gestire cosa succede al click, metodi che non si applicano a type come CampoTesto.

Se qualcuno che usa la libreria definisce una BoxSelezione con campi larghezza, altezza e opzioni, implementerà il trait Disegna anche su BoxSelezione, come nel Listato 18-8.

File: src/main.rs
use gui::Disegna;

struct BoxSelezione {
    larghezza: u32,
    altezza: u32,
    opzioni: Vec<String>,
}

impl Disegna for BoxSelezione {
    fn disegna(&self) {
        // codice per disegnare il box di selezione
    }
}

fn main() {}
Listato 18-8: Un altro crate che usa gui e implementa Disegna su BoxSelezione

Chi userà la nostra libreria può quindi scrivere la funzione main creando un’istanza di Schermo. All’istanza di Schermo aggiunge una BoxSelezione e un Bottone mettendoli in Box<T>, facendoli diventare oggetti trait. Poi chiama esegui sull’istanza di Schermo, che a sua volta chiama disegna su ogni componente. Il Listato 18-9 mostra l’implementazione:

File: src/main.rs
use gui::Disegna;

struct BoxSelezione {
    larghezza: u32,
    altezza: u32,
    opzioni: Vec<String>,
}

impl Disegna for BoxSelezione {
    fn disegna(&self) {
        // code to actually draw a select box
    }
}

use gui::{Bottone, Schermo};

fn main() {
    let schermo = Schermo {
        componenti: vec![
            Box::new(BoxSelezione {
                larghezza: 75,
                altezza: 10,
                opzioni: vec![
                    String::from("Sì"),
                    String::from("Forse"),
                    String::from("No"),
                ],
            }),
            Box::new(Bottone {
                larghezza: 50,
                altezza: 10,
                etichetta: String::from("OK"),
            }),
        ],
    };

    schermo.esegui();
}
Listato 18-9: Uso di oggetti trait per memorizzare valori di differente type che implementano il medesimo trait

Quando scriviamo la libreria, non sapevamo che qualcuno avrebbe aggiunto BoxSelezione, ma l’implementazione di Schermo funziona comunque con quel type perché BoxSelezione implementa il trait Disegna che quindi ha il metodo disegna.

Questo concetto, preoccuparsi solo dei messaggi a cui un valore risponde invece che del type concreto, somiglia al duck typing (tipizzazione ad anatra) nei linguaggi a tipizzazione dinamica: se cammina come un’anatra e fa “qua qua”, allora è un’anatra! Nel metodo esegui di Schermo nel Listato 18-5, esegui non sa di che type concreto è ogni componente, non controlla se è un’istanza di Bottone o BoxSelezione, chiama semplicemente disegna sul componente. Specificando Box<dyn Disegna> come type dei valori nel vettore componenti, abbiamo definito Schermo per accettare solo valori su cui si può chiamare disegna.

Il vantaggio di usare oggetti trait e il sistema dei type di Rust per scrivere codice simile a quello con duck typing è che non dobbiamo mai controllare durante l’esecuzione se un valore implementa un metodo o temere errori se non l’implementa ma lo chiamiamo comunque. Rust non compila il codice se i valori non implementano i trait richiesti dagli oggetti trait.

Per esempio, il Listato 18-10 mostra cosa succede se proviamo a creare uno Schermo con una String come componente.

File: src/main.rs
use gui::Schermo;

fn main() {
    let schermo = Schermo {
        componenti: vec![Box::new(String::from("Ciao"))],
    };

    schermo.esegui();
}
Listato 18-10: Tentativo di usare un type che non implementa il trait dell’oggetto trait

Avremo questo errore perché String non implementa il trait Disegna:

$ cargo run
   Compiling gui v0.1.0 (file:///progetti/gui)
error[E0277]: the trait bound `String: Disegna` is not satisfied
 --> src/main.rs:5:26
  |
5 |         componenti: vec![Box::new(String::from("Ciao"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Disegna` is not implemented for `String`
  |
  = help: the trait `Disegna` is implemented for `Bottone`
  = note: required for the cast from `Box<String>` to `Box<dyn Disegna>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

L’errore ci dice che o stiamo passando a Schermo qualcosa che non volevamo, oppure dobbiamo implementare Disegna su String per permettere che Schermo chiami disegna anche su quel type.

Eseguire il Dynamic Dispatch

Come detto in “Prestazioni del Codice utilizzando Type Generici” nel Capitolo 10 sulla monomorfizzazione eseguita dal compilatore per i type generici: il compilatore genera implementazioni non generiche di funzioni e metodi per ogni type concreto usato al posto del type generico. Il codice che risulta dalla monomorfizzazione usa static dispatch, durante la compilazione il compilatore conosce quale metodo stai chiamando. Questo è all’opposto del dynamic dispatch, dove il compilatore non può sapere durante la compilazione quale metodo stai chiamando. Nel caso di dynamic dispatch, il compilatore genera codice che solo durante l’esecuzione saprà quale metodo chiamare.

Quando usiamo oggetti trait, Rust deve usare il dynamic dispatch. Il compilatore non conosce tutti i type che possono essere usati con il codice che usa oggetti trait, quindi non sa quale metodo di quale type chiamare. Durante l’esecuzione, Rust usa i puntatori dentro l’oggetto trait per decidere il metodo da chiamare. Questa ricerca ha un costo prestazionale che non c’è con lo static dispatch. Inoltre, il dynamic dispatch impedisce che il compilatore possa fare alcune ottimizzazioni, e Rust ha regole su dove si può usare dynamic dispatch, chiamate compatibilità dyn. Queste regole esulano da questa discussione, ma puoi leggere di più a riguardo nella documentazione. Però abbiamo guadagnato più flessibilità nel codice del Listato 18-5 e possiamo supportarla come nel Listato 18-9, quindi è un compromesso da considerare.

Implementare un Modello di Design Orientato agli Oggetti

Lo state pattern è un modello di design orientato agli oggetti. Il punto centrale di questo modello è definire un insieme di stati che un valore può avere internamente. Gli stati sono rappresentati da un insieme di oggetti stato, e il comportamento del valore cambia in base allo stato in cui si trova. Vedremo un esempio con una struct di un post del blog che ha un campo per mantenere il suo stato, che può essere uno stato “bozza”, “in revisione” o “pubblicato”.

Gli oggetti stato condividono la funzionalità: in Rust, ovviamente, usiamo struct e trait invece di oggetti e ereditarietà. Ogni oggetto stato è responsabile del proprio comportamento e di decidere quando deve cambiare stato. Il valore che contiene l’oggetto stato non sa nulla del comportamento specifico degli stati o di quando avvengono le transizioni tra stati.

Il vantaggio dello state pattern è che, quando cambiano i requisiti del programma, non serve modificare il codice del valore che contiene lo stato né quello che usa il valore. Basterà aggiornare il codice dentro uno degli oggetti stato per cambiare le regole o aggiungere nuovi stati.

Inizieremo implementando lo state pattern in un modo più tradizionalmente orientato agli oggetti, poi vedremo un approccio più naturale in Rust. Cominciamo implementando passo passo un flusso di lavoro per un post del blog usando lo state pattern.

La funzionalità finale sarà questa:

  1. Un post inizia come bozza vuota.
  2. Quando la bozza è pronta, si richiede la revisione del post.
  3. Quando il post è approvato, viene pubblicato.
  4. Solo i post pubblicati restituiscono contenuto da stampare, in modo che i post non approvati non possano essere pubblicati accidentalmente.

Qualsiasi altra modifica tentata su un post non avrà effetto. Per esempio, se proviamo ad approvare una bozza prima di richiedere la revisione, il post resterà una bozza non pubblicata.

Tentativo in Tradizionale Stile Orientato agli Oggetti

Ci sono infiniti modi per strutturare il codice per risolvere lo stesso problema, ciascuno con compromessi diversi. Questa implementazione è più in uno stile orientato agli oggetti tradizionale, possibile in Rust, ma che non sfrutta appieno i punti di forza di Rust. Più avanti mostreremo una soluzione diversa che usa comunque lo state pattern ma in modo meno familiare a chi ha esperienza solo con OOP. Confronteremo le due soluzioni per capire i compromessi di progettare in Rust in modo diverso da altri linguaggi.

Il Listato 18-11 mostra questo flusso di lavoro in forma di codice: un esempio dell’uso dell’API che implementeremo nel crate blog. Ancora non si compila perché il crate blog non è implementato.

File: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.aggiungi_testo("Oggi a pranzo ho mangiato un'instalata");
    assert_eq!("", post.contenuto());

    post.richiedi_revisione();
    assert_eq!("", post.contenuto());

    post.approva();
    assert_eq!("Oggi a pranzo ho mangiato un'instalata", post.contenuto());
}
Listato 18-11: Codice che dimostra il comportamento che vogliamo per il crate blog

Vogliamo permettere all’utente di creare una nuova bozza con Post::new. Vogliamo permettere di aggiungere testo al post. Se proviamo a richiedere subito il contenuto del post, prima dell’approvazione, non dobbiamo ricevere alcun testo perché il post è ancora una bozza. Abbiamo aggiunto assert_eq! come dimostrazione, che in un test potrebbe verificare che il metodo contenuto di una bozza restituisca una stringa vuota, ma non scriveremo test in questo esempio.

Vogliamo poi abilitare la richiesta di revisione, e contenuto deve restituire una stringa vuota durante l’attesa di revisione. Quando il post riceve l’approvazione, viene pubblicato e il testo sarà disponibile quando chiamiamo contenuto.

Nota che l’unico type con cui interagiamo dal crate è Post. Questo type userà lo state pattern e conterrà un valore che sarà uno tra tre oggetti stato: bozza, revisione o pubblicato. Il passaggio da uno stato all’altro sarà gestito internamente da Post. Gli stati cambiano rispondendo ai metodi chiamati dall’utente sull’istanza di Post, ma l’utente non gestisce direttamente le transizioni. Inoltre, l’utente non può sbagliare gli stati, per esempio pubblicando senza revisione.

Definire Post e Creare una Nuova Istanza

Cominciamo l’implementazione della libreria! Sappiamo di aver bisogno di una struct pubblica Post che tenga un contenuto, quindi partiamo dalla definizione della struct e da una funzione associata pubblica new per creare istanze di Post, come mostrato nel Listato 18-12. Creeremo anche un trait privato Stato che definisce il comportamento che tutti gli oggetti stato per Post devono avere.

Post terrà un oggetto trait Box<dyn Stato> dentro un Option<T> in un campo privato chiamato stato per memorizzare l’oggetto stato. Vediamo dopo perché serve Option<T>.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }
}

trait Stato {}

struct Bozza {}

impl Stato for Bozza {}
Listato 18-12: Definizione di una struct Post, con funzione new che crea una nuova istanza di Post, un trait Stato, e una struct Bozza

Il trait Stato definisce il comportamento condiviso tra gli stati del post. Gli oggetti stato sono Bozza, AttesaRevisione e Pubblicato, e implementeranno tutti il trait Stato. Per ora il trait non ha alcun metodo; inizieremo definendo Bozza perché è lo stato iniziale del post.

Quando creiamo un nuovo Post, impostiamo il campo stato con Some che contiene una Box che punta a un’istanza della struct Bozza. Questo assicura che quando creiamo una nuova istanza di Post, inizia sempre come bozza. Poiché il campo stato è privato, non si può creare un Post in altri stati! La funzione Post::new imposta il campo contenuto come una String vuota.

Memorizzare il Testo del Post

Abbiamo visto nel Listato 18-11 che vogliamo la possibilità di chiamare un metodo aggiungi_testo che prende un &str e lo aggiunge al contenuto del post. Lo facciamo come metodo, non esponendo il campo contenuto come pub, così poi possiamo in seguito implementare un metodo per controllare come leggere contenuto. Il metodo aggiungi_testo è semplice; lo aggiungiamo nel blocco impl Post come nel Listato 18-13.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }
}

trait Stato {}

struct Bozza {}

impl Stato for Bozza {}
Listato 18-13: Implementazione del metodo aggiungi_testo per aggiungere testo al contenuto del post

Il metodo aggiungi_testo prende un reference mutabile a self perché stiamo modificando l’istanza Post su cui chiamiamo il metodo aggiungi_testo. Chiamiamo poi push_str sulla stringa contenuto aggiungendovi testo. Questo comportamento non dipende dallo stato in cui si trova il post, quindi non fa parte dello state pattern. Il metodo aggiungi_testo non interagisce affatto con il campo stato, ma fa parte del comportamento che vogliamo supportare.

Garantire che il Contenuto di una Bozza sia Vuoto

Anche dopo aver chiamato aggiungi_testo aggiungendo del contenuto al nostro post, vogliamo che il metodo contenuto ritorni una slice vuota perché il post è ancora una bozza, come mostrato dal primo assert_eq! nel Listato 18-11. Per ora implementiamo il metodo contenuto con la cosa più semplice che possa soddisfare questo requisito: restituire semplicemente una slice vuota. Lo cambieremo in seguito quando aggiungeremo la possibilità di cambiare stato per la pubblicazione. Per ora, i post possono essere solo bozze, e quindi il contenuto del post è sempre vuoto. Il Listato 18-14 mostra questa implementazione temporanea.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        ""
    }
}

trait Stato {}

struct Bozza {}

impl Stato for Bozza {}
Listato 18-14: Implementazione temporanea del metodo contenuto di Post che restituisce sempre una slice vuota

Con l’aggiunta del metodo contenuto, tutto nel Listato 18-11 fino al primo assert_eq! funziona come previsto.

Richiedere una Revisione, Che Cambia lo Stato del Post

Ora dobbiamo aggiungere la funzionalità per richiedere una revisione di un post, che dovrebbe cambiare il suo stato da Bozza a AttesaRevisione. Il Listato 18-15 mostra questo codice.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        ""
    }

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }
}

trait Stato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
}

struct Bozza {}

impl Stato for Bozza {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}
Listato 18-15: Implementazione dei metodi richiedi_revisione su Post e il trait Stato

Diamo a Post un metodo pubblico chiamato richiedi_revisione che prende un reference mutabile a self. Poi chiamiamo un metodo interno richiedi_revisione sullo stato corrente di Post, e questo secondo metodo consuma lo stato corrente e restituisce un nuovo stato.

Aggiungiamo il metodo richiedi_revisione al trait Stato; tutti i type che implementano il trait dovranno implementare questo metodo. Nota che invece di avere self, &self o &mut self come primo parametro del metodo, abbiamo self: Box<Self>. Questa sintassi significa che il metodo è valido solo chiamandolo su una Box che contiene il type. Questa sintassi prende ownership di Box<Self>, invalidando il vecchio stato in modo che il valore di stato del Post possa trasformarsi in un nuovo stato.

Per consumare il vecchio stato, il metodo richiedi_revisione prende ownership del valore di stato. Qui entra in gioco l’Option nel campo stato di Post: chiamiamo il metodo take per estrarre il valore Some dal campo stato e sostituirlo con un None al suo posto, perché Rust non permette campi non popolati nelle struct. Questo ci permette di spostare il valore stato fuori da Post invece di prenderlo in prestito. Poi assegniamo al campo stato del post il risultato di questa operazione.

Dobbiamo impostare temporaneamente stato a None invece di assegnarlo direttamente con codice come self.stato = self.stato.richiedi_revisione(); per ottenere la ownership del valore stato. Questo evita che Post usi il vecchio stato dopo averlo trasformato.

Il metodo richiedi_revisione su Bozza restituisce una nuova istanza incapsulata in Box di una nuova struct AttesaRevisione, che rappresenta lo stato di un post in attesa di revisione. Anche la struct AttesaRevisione implementa il metodo richiedi_revisione ma senza trasformazioni, semplicemente restituisce sé stessa perché richiedere una revisione su un post già in stato AttesaRevisione lo mantiene nello stesso stato.

Qui si cominciano a comprendere i vantaggi dello state pattern: il metodo richiedi_revisione su Post è lo stesso qualunque sia il valore di stato. Ogni stato gestisce le sue regole.

Lasciamo il metodo contenuto su Post così com’è, che restituisce una slice di stringa vuota. Ora possiamo avere un Post sia nello stato AttesaRevisione sia nello stato Bozza, ma vogliamo lo stesso comportamento in entrambi gli stati. Il Listato 18-11 funziona ora fino al secondo assert_eq!!

Aggiungere approva per Cambiare il Comportamento di contenuto

Il metodo approva sarà simile a richiedi_revisione: imposterà stato al valore che lo stato corrente dice debba avere quando è stato approvato, come mostrato nel Listato 18-16.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        ""
    }

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }

    pub fn approva(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.approva())
        }
    }
}

trait Stato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
    fn approva(self: Box<Self>) -> Box<dyn Stato>;
}

struct Bozza {}

impl Stato for Bozza {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(Pubblicato {})
    }
}

struct Pubblicato {}

impl Stato for Pubblicato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}
Listato 18-16: Implementazione del metodo approva su Post e il trait Stato

Aggiungiamo il metodo approva al trait Stato e una nuova struct che implementa Stato, lo stato Pubblicato.

Simile a come funziona richiedi_revisione su AttesaRevisione, se chiamiamo il metodo approva su una Bozza, non avrà effetto perché approva restituirà self. Quando chiamiamo approva su AttesaRevisione, restituisce una nuova istanza incapsulata in Box di Pubblicato. La struct Pubblicato implementa il trait Stato, e sia per richiedi_revisione che per approva restituisce se stessa perché il post dovrebbe rimanere nello stato Pubblicato in quei casi.

Ora dobbiamo aggiornare il metodo contenuto su Post. Vogliamo che il valore restituito da contenuto dipenda dallo stato corrente di Post, quindi faremo in modo che Post deleghi a un metodo contenuto definito sul suo stato, come mostrato nel Listato 18-17.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        self.stato.as_ref().unwrap().contenuto(self)
    }
    // --taglio--

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }

    pub fn approva(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.approva())
        }
    }
}

trait Stato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
    fn approva(self: Box<Self>) -> Box<dyn Stato>;
}

struct Bozza {}

impl Stato for Bozza {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(Pubblicato {})
    }
}

struct Pubblicato {}

impl Stato for Pubblicato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}
Listato 18-17: Aggiornamento del metodo contenuto su Post per delegare a un metodo contenuto su Stato

Poiché l’obiettivo è tenere tutte queste regole dentro le struct che implementano Stato, chiamiamo un metodo contenuto sul valore in stato passando l’istanza del post (self) come argomento. Quindi restituiamo il valore restituito dall’uso del metodo contenuto sul valore stato.

Chiamiamo il metodo as_ref sull’Option perché vogliamo un reference al valore dentro l’Option piuttosto che la ownership del valore. Poiché stato è un Option<Box<dyn Stato>>, chiamando as_ref otteniamo un Option<&Box<dyn Stato>>. Se non chiamassimo as_ref, otterremmo un errore perché non possiamo spostare stato fuori dal parametro in prestito &self della funzione.

Chiamiamo poi il metodo unwrap, che sappiamo non andrà mai in panic perché i metodi su Post assicurano che stato conterrà sempre un valore Some quando quei metodi sono terminati. Questo è uno di quei casi discussi in “Quando Hai Più Informazioni Del Compilatore” nel Capitolo 9 quando sappiamo che un valore None è impossibile, anche se il compilatore non è in grado di inferirlo.

A questo punto, quando chiamiamo contenuto su &Box<dyn Stato>, la de-referenziazione forzata agirà su & e Box così che il metodo contenuto sarà chiamato sul type che implementa il trait Stato. Ciò significa che dobbiamo aggiungere contenuto alla definizione del trait Stato e lì metteremo la logica per cosa restituire in base allo stato, come mostrato nel Listato 18-18.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        self.stato.as_ref().unwrap().contenuto(self)
    }

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }

    pub fn approva(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.approva())
        }
    }
}

trait Stato {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
    fn approva(self: Box<Self>) -> Box<dyn Stato>;

    fn contenuto<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --taglio--

struct Bozza {}

impl Stato for Bozza {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(Pubblicato {})
    }
}

struct Pubblicato {}

impl Stato for Pubblicato {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn contenuto<'a>(&self, post: &'a Post) -> &'a str {
        &post.contenuto
    }
}
Listato 18-18: Aggiunta del metodo contenuto al trait Stato

Aggiungiamo un’implementazione di default per il metodo contenuto che restituisce una slice di stringa vuota. Ciò significa che non dobbiamo implementare contenuto nelle struct Bozza e AttesaRevisione. La struct Pubblicato sovrascriverà il metodo contenuto e restituirà il valore in post.contenuto. Anche se comodo, avere il metodo contenuto su Stato che determina il contenuto di Post sfuma i confini tra la responsabilità di Stato e quella di Post.

Nota che abbiamo bisogno di annotazioni di lifetime su questo metodo, come discusso nel Capitolo 10. Stiamo prendendo un reference a un post come argomento e restituiamo un reference ad una parte di quel post, quindi la longevità del reference restituito è legata alla longevità dell’argomento post.

E abbiamo finito: tutto il Listato 18-11 ora funziona! Abbiamo implementato lo state pattern con le regole del flusso di lavoro del blog. La logica relativa alle regole vive negli oggetti di stato invece di essere sparsa in Post.

Perché Non un’enum?

Potresti chiederti perché non abbiamo usato un’enum con i diversi possibili stati del post come varianti. È certamente una soluzione possibile; provalo e confronta i risultati finali per vedere cosa preferisci! Uno svantaggio dell’uso di un’enum è che ogni posto che verifica il valore dell’enum avrà bisogno di un’espressione match o simile per gestire ogni variante possibile. Questo potrebbe diventare più ripetitivo rispetto a questa soluzione con un oggetto trait.

Valutazione dello State Pattern

Abbiamo mostrato che Rust è capace di implementare lo state pattern orientato agli oggetti per incapsulare i diversi tipi di comportamento che un post dovrebbe avere in ogni stato. I metodi su Post non sanno nulla dei vari comportamenti. Siccome abbiamo organizzando il codice in questo modo, dobbiamo guardare in un solo posto per conoscere i diversi modi in cui un post pubblicato può comportarsi: l’implementazione del trait Stato sulla struct Pubblicato.

Se creassimo un’implementazione alternativa che non usasse lo state pattern, potremmo invece usare espressioni match nei metodi su Post o anche nel codice main che verifica lo stato del post e cambia comportamento in quei posti. Ciò significherebbe dover guardare in più posti per capire tutte le implicazioni di un post che è nello stato “pubblicato”.

Con lo state pattern, i metodi Post e i posti dove usiamo Post non hanno bisogno di espressioni match, e per aggiungere un nuovo stato dovremmo solo aggiungere una nuova struct e implementare i metodi del trait su quella struct in un solo punto.

L’implementazione usando lo state pattern è facile da estendere per aggiungere più funzionalità. Per vedere la semplicità di mantenere codice che usa lo state pattern, prova ad implementare qualcuna di queste proposte:

  • Aggiungi un metodo respingi che cambia lo stato del post da AttesaRevisione a Bozza.
  • Richiedi due chiamate al metodo approva prima che lo stato possa essere cambiato in Pubblicato.
  • Permetti agli utenti di aggiungere testo al contenuto solo quando il post è nello stato Bozza. Suggerimento: fai in modo che l’oggetto stato sia responsabile di cosa può cambiare del contenuto ma non responsabile di modificare Post.

Un lato negativo dello state pattern è che, siccome gli stati implementano le transizioni tra stati, alcuni stati sono accoppiati tra loro. Se aggiungessimo un altro stato tra AttesaRevisione e Pubblicato, come Programmato, dovremmo modificare il codice in AttesaRevisione per passare a Programmato. Sarebbe meno lavoro se AttesaRevisione non dovesse cambiare con l’aggiunta di un nuovo stato, ma ciò significherebbe passare a un altro modello di design.

Un altro lato negativo è che abbiamo duplicato un po’ di logica. Per eliminare la duplicazione, potremmo provare a fare implementazioni predefinite per i metodi richiedi_revisione e approva sul trait Stato che restituiscono self. Tuttavia, questo non funzionerebbe: quando usiamo Stato come oggetto trait, il trait non conosce esattamente il type concreto di self, quindi il type di ritorno non è noto durante la compilazione. (Questa è una delle regole di compatibilità dyn menzionate prima.)

Altra duplicazione è nelle implementazioni simili dei metodi richiedi_revisione e approva su Post. Entrambi i metodi usano Option::take con il campo stato di Post, e se stato è Some, delegano all’implementazione del metodo con lo stesso nome del valore incapsulato e impostano il nuovo valore del campo stato al risultato. Se avessimo molti metodi su Post che seguono questo schema, potremmo considerare di definire una macro per eliminare la ripetizione (vedi la sezione “Macro” nel Capitolo 20).

Implementando lo state pattern esattamente come definito per i linguaggi orientati agli oggetti, non sfruttiamo appieno i punti di forza di Rust come potremmo. Vediamo qualche cambiamento da fare al crate blog che può trasformare stati e transizioni invalide in errori durante la compilazione.

Codifica di Stati e Comportamenti Come Type

Ti mostreremo come ripensare lo state pattern per ottenere un diverso set di compromessi. Invece di incapsulare completamente gli stati e le transizioni così che il codice esterno non ne sappia nulla, codificheremo gli stati in type differenti. Di conseguenza, il sistema di controllo dei type di Rust impedirà tentativi di usare post in bozze dove sono permessi solo post pubblicati, generando un errore di compilazione.

Consideriamo la prima parte di main nel Listato 18-11:

File: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.aggiungi_testo("Oggi a pranzo ho mangiato un'instalata");
    assert_eq!("", post.contenuto());

    post.richiedi_revisione();
    assert_eq!("", post.contenuto());

    post.approva();
    assert_eq!("Oggi a pranzo ho mangiato un'instalata", post.contenuto());
}

Continuiamo a permettere la creazione di nuovi post nello stato bozza usando Post::new e la possibilità di aggiungere testo al contenuto del post. Ma invece di avere un metodo contenuto su un post bozza che restituisce una stringa vuota, facciamo in modo che i post bozza non abbiano affatto il metodo contenuto. In questo modo, se proviamo a ottenere il contenuto di un post bozza, otterremo un errore di compilazione che ci dice che il metodo non esiste. Di conseguenza, sarà impossibile mostrare accidentalmente il contenuto di un post bozza in produzione perché quel codice nemmeno si compila. Il Listato 18-19 mostra la definizione di una struct Post e una PostBozza, oltre ai metodi su ciascuna.

File: src/lib.rs
pub struct Post {
    contenuto: String,
}

pub struct PostBozza {
    contenuto: String,
}

impl Post {
    pub fn new() -> PostBozza {
        PostBozza {
            contenuto: String::new(),
        }
    }

    pub fn contenuto(&self) -> &str {
        &self.contenuto
    }
}

impl PostBozza {
    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }
}
Listato 18-19: Un Post con un metodo contenuto e un PostBozza senza metodo contenuto

Sia le struct Post che PostBozza hanno un campo privato contenuto che memorizza il testo del post. Le struct non hanno più il campo stato perché stiamo spostando la codifica dello stato nei type delle struct. La struct Post rappresenta un post pubblicato e ha un metodo contenuto che restituisce il contenuto.

Abbiamo ancora una funzione Post::new, ma invece di restituire un’istanza di Post, restituisce un’istanza di PostBozza. Poiché contenuto è privato e non ci sono funzioni che restituiscono Post, al momento non è possibile creare un’istanza di Post.

La struct PostBozza ha un metodo aggiungi_testo, quindi possiamo aggiungere testo a contenuto come prima, ma nota che PostBozza non ha un metodo contenuto definito! Quindi ora il programma assicura che tutti i post inizino come bozze, e i post bozza non hanno il loro contenuto disponibile per la visualizzazione. Qualsiasi tentativo di aggirare questi vincoli causerà un errore di compilazione.

Come facciamo allora a ottenere un post pubblicato? Vogliamo imporre la regola che un post bozza deve essere revisionato e approvato prima di poter essere pubblicato. Un post nello stato di attesa di revisione non dovrebbe comunque mostrare contenuti. Implementiamo questi vincoli aggiungendo un’altra struct, PostAttesaRevisione, definendo il metodo richiedi_revisione su PostBozza che restituisce un PostAttesaRevisione e un metodo approva su PostAttesaRevisione che restituisce un Post, come mostrato nel Listato 18-20.

File: src/lib.rs
pub struct Post {
    contenuto: String,
}

pub struct PostBozza {
    contenuto: String,
}

impl Post {
    pub fn new() -> PostBozza {
        PostBozza {
            contenuto: String::new(),
        }
    }

    pub fn contenuto(&self) -> &str {
        &self.contenuto
    }
}

impl PostBozza {
    // --taglio--
    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn richiedi_revisione(self) -> PostAttesaRevisione {
        PostAttesaRevisione {
            contenuto: self.contenuto,
        }
    }
}

pub struct PostAttesaRevisione {
    contenuto: String,
}

impl PostAttesaRevisione {
    pub fn approva(self) -> Post {
        Post {
            contenuto: self.contenuto,
        }
    }
}
Listato 18-20: Un PostAttesaRevisione creato chiamando richiedi_revisione su PostBozza e un metodo approva che trasforma un PostAttesaRevisione in un Post pubblicato

I metodi richiedi_revisione e approva prendono ownership di self, consumando così le istanze PostBozza e PostAttesaRevisione trasformandole rispettivamente in un PostAttesaRevisione e un Post pubblicato. In questo modo non avremo istanze residue di PostBozza dopo aver chiamato richiedi_revisione su di loro, e così via. La struct PostAttesaRevisione non ha un metodo contenuto definito, quindi tentare di leggere il suo contenuto causa un errore di compilazione, come per PostBozza. Poiché l’unico modo per ottenere un’istanza di Post pubblicato che ha un metodo contenuto definito è chiamare approva su un PostAttesaRevisione, e l’unico modo per ottenere un PostAttesaRevisione è chiamare richiedi_revisione su un PostBozza, abbiamo ora codificato il flusso di lavoro del blog col sistema dei type.

Dobbiamo anche fare qualche piccolo cambiamento in main. I metodi richiedi_revisione e approva restituiscono nuove istanze invece di modificare la struct su cui sono chiamati, quindi dobbiamo aggiungere più assegnazioni di shadowing let post = per salvare le istanze restituite. Non possiamo nemmeno avere le asserzioni sui contenuti vuoti dei post bozza e revisione pendente, né ne abbiamo bisogno: non possiamo più compilare codice che tenta di usare il contenuto dei post in quegli stati. Il codice aggiornato in main è mostrato nel Listato 18-21.

File: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.aggiungi_testo("Oggi a pranzo ho mangiato un'instalata");

    let post = post.richiedi_revisione();

    let post = post.approva();

    assert_eq!("Oggi a pranzo ho mangiato un'instalata", post.contenuto());
}
Listato 18-21: Modifiche a main per usare la nuova implementazione del flusso di lavoro del blog

I cambiamenti necessari per riassegnare post significano che questa implementazione non segue più esattamente lo state pattern orientato agli oggetti: le trasformazioni tra gli stati non sono più completamente incapsulate nella implementazione di Post. Tuttavia, abbiamo guadagnato che stati invalidi ora sono impossibili grazie al sistema dei type e al controllo durante la compilazione! Questo assicura che alcuni bug, come la visualizzazione del contenuto di un post non pubblicato, vengano scoperti prima che arrivino in produzione.

Prova a realizzare i compiti suggeriti all’inizio di questa sezione sul crate blog così com’è dopo il Listato 18-21 per vedere cosa pensi del design di questa versione del codice. Nota che alcune attività potrebbero essere già implementate con questo design.

Abbiamo visto che anche se Rust è capace di implementare modelli di design orientati agli oggetti, altri modelli, come la codifica dello stato nel sistema dei type, sono disponibili in Rust. Questi modelli hanno diversi compromessi. Anche se potresti essere molto familiare con i modelli orientati agli oggetti, ripensare il problema per sfruttare le caratteristiche di Rust può offrire benefici, come prevenire alcuni bug già in fase di compilazione. I modelli orientati agli oggetti non saranno sempre la miglior soluzione in Rust a causa di alcune caratteristiche come la ownership, che i linguaggi orientati agli oggetti non hanno.

Riepilogo

Indipendentemente dal fatto che tu pensi che Rust sia un linguaggio orientato agli oggetti dopo aver letto questo capitolo, ora sai che puoi usare oggetti trait per ottenere alcune funzionalità orientate agli oggetti in Rust. Il dynamic dispatch può dare al tuo codice un po’ di flessibilità in cambio di una piccola perdita in prestazioni durante l’esecuzione. Puoi usare questa flessibilità per implementare modelli orientati agli oggetti che possono aiutare nella manutenibilità del tuo codice. Rust ha anche altre caratteristiche, come la ownership, che i linguaggi orientati agli oggetti non hanno. Un modello orientato agli oggetti non sarà sempre il modo migliore per sfruttare i punti di forza di Rust, ma è un’opzione disponibile.

In seguito parleremo di pattern, un’altra delle caratteristiche di Rust che consente molta flessibilità. Li abbiamo visti brevemente in precedenza nel libro, ma non ne abbiamo ancora visto tutto il potenziale. Cominciamo!

Pattern e Matching

I Pattern sono una sintassi speciale in Rust per il matching con la struttura dei tipi, sia complessi che semplici. L’utilizzo di pattern insieme a espressioni match e altri costrutti offre un maggiore controllo sul flusso di controllo di un programma. Un pattern consiste in una combinazione dei seguenti elementi:

  • Letterali
  • Array destrutturati, enum, struct o tuple
  • Variabili
  • Caratteri jolly
  • Segnaposto

Alcuni esempi di pattern includono x, (a, 3) e Some(Color::Red). Nei contesti in cui i pattern sono validi, questi componenti descrivono la forma dei dati. Il nostro programma confronta quindi i valori con i pattern per determinare se ha la forma corretta dei dati per continuare a eseguire una particolare porzione di codice.

Per utilizzare un pattern, lo confrontiamo con un valore. Se il pattern corrisponde al valore, utilizziamo le parti del valore nel nostro codice. Ricordate le espressioni match nel Capitolo 6 che utilizzavano pattern, come l’esempio della macchina selezionatrice di monete. Se il valore corrisponde alla forma del pattern, possiamo usare i pezzi indicati. In caso contrario, il codice associato al pattern non verrà eseguito.

Questo capitolo è un riferimento su tutto ciò che riguarda i pattern. Tratteremo le situazioni valide in cui utilizzare i pattern, la differenza tra pattern confutabili e inconfutabili e i diversi tipi di sintassi dei pattern che potreste incontrare. Entro la fine del capitolo, saprete come usare i pattern per esprimere molti concetti in modo chiaro.

Tutti i Posti in cui Possono Essere Utilizzati i Pattern

I pattern compaiono in diversi punti di Rust e li hai usati senza rendertene conto! Questa sezione illustra tutti i posti in cui i pattern sono validi.

Rami match

Come discusso nel Capitolo 6, utilizziamo i pattern nei rami delle espressioni match. Formalmente, le espressioni match sono definite come la parola chiave match, un valore su cui effettuare la corrispondenza e uno o più rami di corrispondenza costituiti da un pattern e un’espressione da eseguire se il valore corrisponde al pattern di quel ramo, in questo modo:

match VALUE {
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
}

Ad esempio, ecco l’espressione match del Listato 6-5 che corrisponde a un valore Option<i32> nella variabile x:

match x {
None => None,
Some(i) => Some(i + 1),
}

I pattern in questa espressione match sono None e Some(i) a sinistra di ciascuna freccia.

Un requisito per le espressioni match è che siano esaustive, nel senso che tutte le possibilità per il valore nell’espressione match devono essere considerate. Un modo per assicurarsi di aver coperto ogni possibilità è avere un pattern generico per l’ultimo caso: ad esempio, un nome di variabile che corrisponde a qualsiasi valore non può mai fallire e quindi copre tutti i casi rimanenti.

Il pattern specifico _ corrisponderà a qualsiasi cosa, ma non si vincola mai a una variabile, quindi viene spesso utilizzato nell’ultimo ramo di corrispondenza. Il pattern _ può essere utile quando si desidera ignorare qualsiasi valore non specificato, ad esempio. Tratteremo il pattern _ più dettagliatamente in “Ignorare Valori In un Pattern più avanti in questo capitolo.

Istruzioni let

Prima di questo capitolo, avevamo discusso esplicitamente dell’uso dei pattern solo con match e if let, ma in realtà abbiamo utilizzato pattern anche in altri contesti, anche nelle istruzioni let. Ad esempio, si consideri questa semplice assegnazione di variabile con let:

#![allow(unused)]
fn main() {
let x = 5;
}

Ogni volta che avete utilizzato un’istruzione let come questa, avete utilizzato dei pattern, anche se potreste non esservene accorti! Più formalmente, un’istruzione let si presenta così:

let PATTERN = EXPRESSION;

In istruzioni come let x = 5; con un nome di variabile nello slot PATTERN, il nome della variabile è solo una forma particolarmente semplice di pattern. Rust confronta l’espressione con il pattern e assegna qualsiasi nome trovi. Quindi, nell’esempio let x = 5;, x è un pattern che significa “associa ciò che corrisponde qui alla variabile x”. Poiché il nome x è l’intero pattern, questo pattern significa effettivamente “associa tutto alla variabile x, qualunque sia il valore”.

Per comprendere più chiaramente l’aspetto di pattern-matching di let, si consideri il Listato 19-1, che utilizza un pattern con let per destrutturare una tupla.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listato 19-1: Utilizzo di un pattern per destrutturare una tupla e creare tre variabili contemporaneamente

Qui, confrontiamo una tupla con un pattern. Rust confronta il valore (1, 2, 3) con il pattern (x, y, z) e verifica che il valore corrisponde al pattern, in quanto vede che il numero di elementi è lo stesso in entrambi, quindi Rust associa 1 a x, 2 a y e 3 a z. Si può pensare a questo pattern di tupla come all’annidamento di tre pattern di variabili individuali al suo interno.

Se il numero di elementi nel pattern non corrisponde al numero di elementi nella tupla, il tipo complessivo non corrisponderà e si verificherà un errore del compilatore. Ad esempio, il Listato 19-2 mostra un tentativo di destrutturare una tupla con tre elementi in due variabili, che non funzionerà.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listato 19-2: Costruzione errata di un pattern le cui variabili non corrispondono al numero di elementi nella tupla

Il tentativo di compilare questo codice genera questo tipo di errore:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

Per correggere l’errore, potremmo ignorare uno o più valori nella tupla usando _ o .., come vedrai nella sezione “Ignorare Valori In un Pattern. Se il problema è che abbiamo troppe variabili nel pattern, la soluzione è far corrispondere i tipi rimuovendo le variabili in modo che il numero di variabili sia uguale al numero di elementi nella tupla.

Espressioni Condizionali if let

Nel Capitolo 6, abbiamo discusso come utilizzare le espressioni if let principalmente come un modo più breve per scrivere l’equivalente di un match che corrisponde a un solo caso. Facoltativamente, if let può avere un else corrispondente contenente il codice da eseguire se il pattern in if let non corrisponde.

Il Listato 19-3 mostra che è anche possibile combinare e abbinare le espressioni if let, else if e else if let. Ciò offre maggiore flessibilità rispetto a un’espressione match in cui possiamo esprimere un solo valore da confrontare con i pattern. Inoltre, Rust non richiede che le condizioni in una serie di rami if let, else if e else if let siano correlate tra loro.

Il codice nel Listato 19-3 determina il colore da utilizzare per lo sfondo in base a una serie di controlli per diverse condizioni. Per questo esempio, abbiamo creato variabili con valori hardcoded che un programma reale potrebbe ricevere dall’input dell’utente.

File: src/main.rs
fn main() {
    let colore_preferito: Option<&str> = None;
    let e_martedi = false;
    let eta: Result<u8, _> = "34".parse();

    if let Some(color) = colore_preferito {
        println!("Usando il tuo colore preferito, {color}, come sfondo");
    } else if e_martedi {
        println!("Martedì è il giorno verde!");
    } else if let Ok(eta) = eta {
        if eta > 30 {
            println!("Usando il viola come colore di sfondo");
        } else {
            println!("Usando l'arancione come colore di sfondo");
        }
    } else {
        println!("Usando il blu come colore di sfondo");
    }
}
Listato 19-3: Combinazione di if let, else if, else if let e else

Se l’utente specifica un colore preferito, quel colore viene utilizzato come sfondo. Se non viene specificato alcun colore preferito e oggi è martedì, il colore di sfondo è verde. Altrimenti, se l’utente specifica la propria età come stringa e possiamo analizzarla come un numero correttamente, il colore sarà viola o arancione a seconda del valore del numero. Se nessuna di queste condizioni si applica, il colore di sfondo è blu.

Questa struttura condizionale ci consente di supportare requisiti complessi. Con i valori hardcoded che abbiamo qui, questo esempio stamperà Usando il viola come colore di sfondo.

Si può notare che if let può anche introdurre nuove variabili che oscurano le variabili esistenti allo stesso modo in cui match può farlo: la riga if let Ok(eta) = eta introduce una nuova variabile eta che contiene il valore all’interno della variante Ok, oscurando la variabile eta esistente. Ciò significa che dobbiamo inserire la condizione if eta > 30 all’interno di quel blocco: non possiamo combinare queste due condizioni in if let Ok(eta) = eta && eta > 30. Il nuovo eta che vogliamo confrontare con 30 non è valido finché il nuovo ambito non inizia con la parentesi graffa.

Lo svantaggio dell’utilizzo di espressioni if let è che il compilatore non verifica l’esaustività, mentre con le espressioni match lo fa. Se omettessimo l’ ultimo blocco else e quindi non gestissimo alcuni casi, il compilatore non ci avviserebbe del possibile bug logico.

Cicli Condizionali while let

Simile nella costruzione a if let, il ciclo condizionale while let consente a un ciclo while di essere eseguito finché un pattern continua a corrispondere. Nel Listato 19-4 mostriamo un ciclo while let che attende i messaggi inviati tra thread, ma in questo caso controlla un Result invece di un Option.

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}
Listato 19-4: Utilizzo di un ciclo while let per stampare valori finché rx.recv() restituisce Ok

Questo esempio stampa 1, 2 e poi 3. Il metodo recv prende il primo messaggio dal lato ricevente del canale e restituisce Ok(value). Quando abbiamo visto per la prima volta recv nel Capitolo 16, abbiamo analizzato direttamente l’errore, o abbiamo interagito con esso come un iteratore usando un ciclo for. Come mostra il Listato 19-4, tuttavia, possiamo anche usare while let, perché il metodo recv restituisce Ok ogni volta che arriva un messaggio, finché il mittente esiste, e poi produce un Err una volta che il mittente si disconnette.

Cicli for

In un ciclo for, il valore che segue direttamente la parola chiave for è un pattern. Ad esempio, in for x in y, x è il pattern. Il Listato 19-5 mostra come utilizzare un pattern in un ciclo for per destrutturare, o scomporre una tupla come parte del ciclo for.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (indice, valore) in v.iter().enumerate() {
        println!("{valore} è all'indice {indice}");
    }
}
Listato 19-5: Utilizzo di un pattern in un ciclo for per destrutturare una tupla

Il codice nel Listato 19-5 stamperà quanto segue:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

Adattiamo un iteratore utilizzando il metodo enumerate in modo che produca un valore e l’indice per quel valore, inserito in una tupla. Il primo valore prodotto è la tupla (0, 'a'). Quando questo valore viene confrontato con il pattern (indice, valore), indice sarà 0 e valore sarà a, stampando la prima riga dell’ output.

Parametri di Funzione

Anche i parametri di funzione possono essere pattern. Il codice nel Listato 19-6, che dichiara una funzione chiamata foo che accetta un parametro chiamato x di tipo i32, dovrebbe ormai risultare familiare.

fn foo(x: i32) {
    // code goes here
}

fn main() {}
Listato 19-6: Una firma di funzione usa pattern nei parametri

La parte x è un pattern! Come abbiamo fatto con let, potremmo abbinare una tupla negli argomenti di una funzione al pattern. Il Listato 19-7 suddivide i valori in una tupla mentre la passiamo a una funzione.

File: src/main.rs
fn stampa_coordinate(&(x, y): &(i32, i32)) {
    println!("Posizione corrente: ({x}, {y})");
}

fn main() {
    let punto = (3, 5);
    stampa_coordinate(&punto);
}
Listato 19-7: Una funzione con parametri che destrutturano una tupla

Questo codice stampa Posizione corrente: (3, 5). I valori &(3, 5) corrispondono al pattern &(x, y), quindi x è il valore 3 e y è il valore 5.

Possiamo anche usare i pattern nelle liste di parametri di chiusura allo stesso modo delle liste di parametri di funzione, perché le chiusure sono simili alle funzioni, come discusso nel Capitolo 13.

A questo punto, avete visto diversi modi per usare i pattern, ma i pattern non funzionano allo stesso modo in tutti i casi in cui possiamo usarli. In alcuni casi, i pattern devono essere inconfutabili; in altre circostanze, possono essere confutabili. Discuteremo questi due concetti più avanti.

Confutabilità: Quando un Pattern Potrebbe non Corrispondere

I pattern si presentano in due forme: confutabili e inconfutabili. I pattern che corrispondono per qualsiasi possibile valore passato sono inconfutabili. Un esempio sarebbe x nell’ istruzione let x = 5; perché x corrisponde a qualsiasi cosa e quindi non può non corrispondere. I pattern che possono non corrispondere per un possibile valore sono confutabili. Un esempio sarebbe Some(x) nell’espressione if let Some(x) = a_value perché se il valore nella variabile a_value è None anziché Some, il pattern Some(x) non corrisponderà.

I parametri di funzione, le istruzioni let e i cicli for possono accettare solo pattern inconfutabili perché il programma non può fare nulla di significativo quando i valori non corrispondono. Le espressioni if let e while let e l’istruzione let...else accettano pattern confutabili e inconfutabili, ma il compilatore mette in guardia contro i pattern inconfutabili perché, per definizione, sono pensati per gestire possibili fallimenti: la funzionalità di una condizione risiede nella sua capacità di comportarsi in modo diverso a seconda del successo o del fallimento.

In generale, non ci si dovrebbe preoccupare della distinzione tra pattern confutabili e inconfutabili; tuttavia, è necessario avere familiarità con il concetto di confutabilità in modo da poter rispondere quando lo si vede in un messaggio di errore. In questi casi, sarà necessario modificare il pattern o il costrutto con cui si sta utilizzando il pattern, a seconda del comportamento previsto per il codice.

Esaminiamo un esempio di cosa succede quando proviamo a utilizzare un pattern confutabile dove Rust richiede un pattern inconfutabile e viceversa. Il Listato 19-8 mostra un’istruzione let, ma per il pattern abbiamo specificato Some(x), un pattern confutabile. Come ci si potrebbe aspettare, questo codice non verrà compilato.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}
Listato 19-8: Tentativo di utilizzare un pattern confutabile con let

Se some_option_value fosse un valore None, non corrisponderebbe al pattern Some(x), il che significa che il pattern è confutabile. Tuttavia, l’istruzione let può accettare solo un pattern inconfutabile perché non c’è nulla di valido che il codice possa fare con un valore None. In fase di compilazione, Rust si lamenterà del fatto che abbiamo provato a utilizzare un pattern confutabile laddove è richiesto un pattern inconfutabile:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

For more information about this error, try `rustc --explain E0005`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

Poiché non abbiamo coperto (e non potevamo coprire!) ogni valore valido con il pattern Some(x), Rust genera giustamente un errore di compilazione.

Se abbiamo un pattern confutabile laddove è necessario un pattern inconfutabile, possiamo correggerlo modificando il codice che utilizza il pattern: invece di usare let, possiamo usare let else. Quindi, se il pattern non corrisponde, il codice salterà semplicemente il codice tra parentesi graffe, consentendogli di continuare validamente. Il Listato 19-9 mostra come correggere il codice nel Listato 19-8.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}
Listato 19-9: Usare let...else e un blocco con pattern confutabili invece di let

Abbiamo dato al codice una via d’uscita! Questo codice è perfettamente valido, anche se significa che non possiamo usare un pattern inconfutabile senza ricevere un avviso. Se diamo a let...else un pattern che corrisponderà sempre, come x, come mostrato nel Listato 19-10, il compilatore genererà un avviso.

fn main() {
    let x = 5 else {
        return;
    };
}
Listato 19-10: Tentativo di utilizzare un pattern inconfutabile con let...else

Rust lamenta che non ha senso utilizzare let...else con un pattern inconfutabile:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern will always match, so the `else` clause is useless
  = help: consider removing the `else` clause
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

Per questo motivo, i rami di corrispondenza devono utilizzare pattern inconfutabili, ad eccezione dell’ultimo ramo, che dovrebbe corrispondere a tutti i valori rimanenti con un pattern inconfutabile. Rust ci consente di utilizzare un pattern inconfutabile in un match con un solo ramo, ma questa sintassi non è particolarmente utile e potrebbe essere sostituita con una più semplice istruzione let.

Ora che sappiamo dove usare i pattern e la differenza tra pattern confutabili e irrefutabili, esaminiamo tutta la sintassi che possiamo usare per creare pattern.

Sintassi dei Pattern

In questa sezione, raccogliamo tutta la sintassi valida nei pattern e discutiamo perché e quando potresti voler utilizzare ciascuna di esse.

Corrispondenza di Letterali

Come hai visto nel Capitolo 6, puoi confrontare i pattern direttamente con i letterali. Il codice seguente fornisce alcuni esempi:

fn main() {
    let x = 1;

    match x {
        1 => println!("uno"),
        2 => println!("due"),
        3 => println!("tre"),
        _ => println!("altro"),
    }
}

Questo codice stampa uno perché il valore in x è 1. Questa sintassi è utile quando vuoi che il codice esegua un’azione se ottiene un particolare valore concreto.

Corrispondenza di Variabili Denominate

Le variabili denominate sono pattern inconfutabili che corrispondono a qualsiasi valore e le abbiamo utilizzate molte volte in questo libro. Tuttavia, si verifica una complicazione quando si utilizzano variabili con nome nelle espressioni match, if let o while let. Poiché ciascuna di queste espressioni inizia un nuovo scope, le variabili dichiarate come parte di un pattern all’interno di queste espressioni copriranno quelle con lo stesso nome all’esterno dei costrutti, come nel caso di tutte le variabili. Nel Listato 19-11, dichiariamo una variabile denominata x con il valore Some(5) e una variabile y con il valore 10. Creiamo quindi un’espressione match sul valore x. Osserviamo i pattern nel campo match e println! alla fine, e cerchiamo di capire cosa stamperà il codice prima di eseguirlo o di proseguire con la lettura.

File: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Ricevuto 50"),
        Some(y) => println!("Corrisponde, y = {y}"),
        _ => println!("Caso predefinito, x = {x:?}"),
    }

    println!("alla fine: x = {x:?}, y = {y}");
}
Listato 19-11: Un’espressione match con un braccio che introduce una nuova variabile che oscura una variabile esistente y

Esaminiamo cosa succede quando viene eseguita l’espressione match. Il pattern nel primo braccio di corrispondenza non corrisponde al valore definito di x, quindi il codice continua.

Il pattern nel secondo ramo di corrispondenza introduce una nuova variabile denominata y che corrisponderà a qualsiasi valore all’interno di un valore Some. Poiché ci troviamo in un nuovo ambito all’interno dell’espressione match, questa è una nuova variabile y, non la y che abbiamo dichiarato all’inizio con il valore 10. Questo nuovo y corrisponderà a qualsiasi valore all’interno di Some, che è ciò che abbiamo in x. Pertanto, questo nuovo y si lega al valore interno di Some in x. Quel valore è 5, quindi l’espressione per quel braccio viene eseguita e stampa Matched, y = 5.

Se x fosse stato un valore None invece di Some(5), i pattern nei primi due bracci non avrebbero trovato corrispondenza, quindi il valore avrebbe trovato corrispondenza con il trattino basso. Non abbiamo introdotto la variabile x nel pattern del braccio con trattino basso, quindi la x nell’espressione è ancora la x esterna che non è stata ombreggiata. In questo caso ipotetico, match stamperebbe Default case, x = None.

Quando l’espressione match è terminata, il suo ambito termina, così come quello della y interna. L’ultimo println! produce alla fine: x = Some(5), y = 10.

Per creare un’espressione match che confronti i valori delle variabili esterne x e y, anziché introdurre una nuova variabile che oscura la variabile y esistente, dovremmo usare una condizione di controllo della corrispondenza. Parleremo delle condizioni di controllo della corrispondenza più avanti in “Aggiungere espressioni condizionali con le condizioni di controllo della corrispondenza”.

Corrispondenza di più Pattern

Nelle espressioni match, è possibile confrontare più pattern utilizzando la sintassi |, che è l’operatore di controllo della corrispondenza or. Ad esempio, nel codice seguente confrontiamo il valore di x con i valori corrispondenti, il primo dei quali ha un’opzione or, il che significa che se il valore di x corrisponde a uno dei valori in quel valore, verrà eseguito il codice di quel valore:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("uno o due"),
        3 => println!("tre"),
        _ => println!("altro"),
    }
}

Questo codice stampa uno o due.

Corrispondenza di Intervalli di Valori con ..=

La sintassi ..= ci consente di confrontare un intervallo di valori inclusivo. Nel codice seguente, quando un pattern corrisponde a uno qualsiasi dei valori all’interno dell’intervallo indicato, quel braccio verrà eseguito:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

Se x è 1, 2, 3, 4 o 5, il primo braccio corrisponderà. Questa sintassi è più comoda per più valori di corrispondenza rispetto all’utilizzo dell’operatore | per esprimere la stessa idea; se dovessimo usare |, dovremmo specificare 1 | 2 | 3 | 4 | 5. Specificare un intervallo è molto più breve, soprattutto se vogliamo trovare una corrispondenza, ad esempio, con un numero qualsiasi compreso tra 1 e 1.000!

Il compilatore verifica che l’intervallo non sia vuoto in fase di compilazione e, poiché gli unici tipi per cui Rust può stabilire se un intervallo è vuoto o meno sono char e i valori numerici, gli intervalli sono consentiti solo con valori numerici o char.

Ecco un esempio che utilizza intervalli di valori char:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust can tell that 'c' is within the first pattern’s range and prints early ASCII letter.

Destructuring to Break Apart Values

We can also use patterns to destructure structs, enums, and tuples to use different parts of these values. Let’s walk through each value.

Structs

Listing 19-12 shows a Point struct with two fields, x and y, that we can break apart using a pattern with a let statement.

File: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}
Listato 19-12: Destructuring a struct’s fields into separate variables

Questo codice crea le variabili a e b che corrispondono ai valori dei campi x e y della struttura p. Questo esempio mostra che i nomi delle variabili nel pattern non devono necessariamente corrispondere ai nomi dei campi della struttura. Tuttavia, è comune far corrispondere i nomi delle variabili ai nomi dei campi per rendere più facile ricordare quali variabili provengono da quali campi. A causa di questo uso comune, e poiché scrivere let Point { x: x, y: y } = p; contiene molte duplicazioni, Rust ha una scorciatoia per i pattern che corrispondono ai campi della struttura: è sufficiente elencare il nome del campo della struttura e le variabili create dal pattern avranno gli stessi nomi. Il Listato 19-13 si comporta allo stesso modo del codice del Listato 19-12, ma le variabili create nel pattern let sono x e y invece di a e b.

File: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}
Listato 19-13: Destrutturazione dei campi struct tramite abbreviazione dei campi struct

Questo codice crea le variabili x e y che corrispondono ai campi x e y della variabile p. Il risultato è che le variabili x e y contengono i valori della struct p.

Possiamo anche destrutturare con valori letterali come parte del pattern struct piuttosto che creare variabili per tutti i campi. In questo modo possiamo testare alcuni campi per valori specifici mentre creiamo variabili per destrutturare gli altri campi.

Nel Listato 19-14, abbiamo un’espressione match che separa i valori Point in tre casi: punti che giacciono direttamente sull’asse x (che è vero quando y = 0), sull’asse y (x = 0) o su nessuno dei due assi.

File: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("Sull'asse x a {x}"),
        Point { x: 0, y } => println!("Sull'asse y a {y}"),
        Point { x, y } => {
            println!("Su nessun asse: ({x}, {y})");
        }
    }
}
Listato 19-14: Destrutturazione e corrispondenza di valori letterali in un unico pattern

Il primo caso corrisponderà a qualsiasi punto che si trovi sull’asse x specificando che il campo y corrisponde se il suo valore corrisponde al letterale 0. Il pattern crea comunque una variabile x che possiamo utilizzare nel codice per questo ramo.

Analogamente, il secondo caso corrisponde a qualsiasi punto sull’asse y specificando che il campo x corrisponde se il suo valore è 0 e crea una variabile y per il valore del campo y. Il terzo ramo non specifica alcun letterale, quindi corrisponde a qualsiasi altro Point e crea variabili per entrambi i campi x e y.

In questo esempio, il valore p corrisponde al secondo ramo in virtù del fatto che x contiene uno 0, quindi questo codice stamperà Sull'asse y a 7.

Ricorda che un’espressione match interrompe il controllo dei rami una volta trovato il primo pattern corrispondente, quindi anche se Point { x: 0, y: 0} si trova sull’asse x e sull’asse y, questo codice stamperà solo Sull'asse x a 0.

Enum

Abbiamo destrutturato gli enum in questo libro (ad esempio, Listato 6-5 nel Capitolo 6), ma non abbiamo ancora spiegato esplicitamente che il pattern per destrutturare un enum corrisponde al modo in cui sono definiti i dati memorizzati all’interno dell’enum. Ad esempio, nel Listato 19-15 utilizziamo l’enum Messaggio del Listato 6-2 e scriviamo un match con pattern che destruttureranno ogni valore interno.

File: src/main.rs
enum Messaggio {
    Esci,
    Muovi { x: i32, y: i32 },
    Scrivi(String),
    CambiaColore(i32, i32, i32),
}
// ANCHOR: here                                         
fn main() {
    let msg = Messaggio::CambiaColore(0, 160, 255);

    match msg {
        Messaggio::Esci => {
            println!("Il variante Esci non ha dati da destrutturare.");
        }
        Messaggio::Muovi { x, y } => {
            println!("Muovi in direzione x {x} e in direzione y {y}");
        }
        Messaggio::Scrivi(text) => {
            println!("Messaggio di testo: {text}");
        }
        Messaggio::CambiaColore(r, g, b) => {
            println!("Cambia colore in rosso {r}, verde {g}, e blu {b}");
        }
    }
}
Listato 19-15: Destrutturazione delle varianti di enum che contengono diversi tipi di valori

Questo codice stamperà Cambia colore in rosso 0, verde 160 e blu 255. Prova a modificare il valore di msg per vedere l’esecuzione del codice degli altri bracci.

Per le varianti di enum senza dati, come Messaggio::Esci, non possiamo destrutturare ulteriormente il valore. Possiamo trovare corrispondenze solo sul valore letterale Messaggio::Esci, e non ci sono variabili in quel pattern.

Per varianti di enum di tipo struct, come Messaggio::Muovi, possiamo usare un pattern simile a quello che specifichiamo per la corrispondenza con le struct. Dopo il nome della variante, inseriamo parentesi graffe e poi elenchiamo i campi con le variabili, in modo da separare i pezzi da usare nel codice per questo braccio. Qui usiamo la forma abbreviata come abbiamo fatto nel Listato 19-13.

Per varianti di enum di tipo tuple, come Messaggio::Scrivi che contiene una tupla con un elemento e Messaggio::CambiaColore che contiene una tupla con tre elementi, il pattern è simile a quello che specifichiamo per la corrispondenza con le tuple. Il numero di variabili nel pattern deve corrispondere al numero di elementi nella variante che stiamo correlando.

Strutture ed Enumerazioni Annidate

Finora, i nostri esempi hanno tutti confrontato strutture o enumerazioni con un solo livello di profondità, ma la corrispondenza può funzionare anche su elementi annidati! Ad esempio, possiamo rifattorizzare il codice nel Listato 19-15 per supportare i colori RGB e HSV nel messaggio CambiaColore come mostrato nel Listato 19-16.

enum Colore {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}
    
enum Messaggio {
    Esci,
    Muovi { x: i32, y: i32 },
    Scrivi(String),
    CambiaColore(Colore),
}

fn main() {
    let msg = Messaggio::CambiaColore(Colore::Hsv(0, 160, 255));

    match msg {
        Messaggio::CambiaColore(Colore::Rgb(r, g, b)) => {
            println!("Cambia colore in rosso {r}, verde {g}, e blu {b}");
        }
        Messaggio::CambiaColore(Colore::Hsv(h, s, v)) => {
            println!("Cambia colore in tonalità {h}, saturazione {s}, valore {v}");
        }
        _ => (),
    }
}
Listato 19-16: Corrispondenza su enumerazioni annidate

Il pattern del primo braccio nell’espressione match corrisponde a una variante dell’enumerazione Messaggio::CambiaColore che contiene una variante Colore::Rgb; quindi il pattern si lega ai tre valori i32 interni. Il pattern del secondo braccio corrisponde anche a una variante dell’enum Messaggio::CambiaColore, ma l’enum interno corrisponde invece a Colore::Hsv. Possiamo specificare queste condizioni complesse in un’unica espressione match, anche se sono coinvolte due enum.

Strutture e Tuple

Possiamo combinare, abbinare e annidare pattern di destrutturazione in modi ancora più complessi. L’esempio seguente mostra una destrutturazione complessa in cui annidiamo strutture e tuple all’interno di una tupla e destrutturamo tutti i valori primitivi:

fn main() {
    struct Punto {
        x: i32,
        y: i32,
    }

    let ((piedi, pollici), Punto { x, y }) = ((3, 10), Punto { x: 3, y: -10 });
}

Questo codice ci permette di scomporre i tipi complessi nelle loro parti componenti in modo da poter utilizzare i valori che ci interessano separatamente.

La destrutturazione con i pattern è un modo comodo per utilizzare parti di valori, come il valore di ciascun campo in una struttura, separatamente l’uno dall’altro.

Ignorare Valori In un Pattern

Avete visto che a volte è utile ignorare i valori in un pattern, come nell’ultimo ramo di un match, per ottenere un catch-all che in realtà non fa nulla ma tiene conto di tutti i valori possibili rimanenti. Esistono diversi modi per ignorare interi valori o parti di valori in un pattern: utilizzare il pattern _ (che avete visto), utilizzare il pattern _ all’interno di un altro pattern, utilizzare un nome che inizia con un trattino basso o utilizzare .. per ignorare le parti rimanenti di un valore. Esploriamo come e perché utilizzare ciascuno di questi pattern.

Un Valore Intero con _

Abbiamo utilizzato il trattino basso come pattern jolly che corrisponde a qualsiasi valore ma non si lega al valore. Questo è particolarmente utile come ultimo ramo in un’espressione match , ma possiamo utilizzarlo anche in qualsiasi pattern, inclusi i parametri di funzione, come mostrato nel Listato 19-17.

File: src/main.rs
fn foo(_: i32, y: i32) {
    println!("Questa funzione utilizza solo il parametro y: {y}");
}

fn main() {
    foo(3, 4);
}
Listato 19-17: Usare _ in una firma di funzione

Questo codice ignorerà completamente il valore 3 passato come primo argomento e stamperà Questa funzione usa solo il parametro y: 4.

Nella maggior parte dei casi, quando non è più necessario un particolare parametro di funzione, si modifica la firma in modo che non includa il parametro non utilizzato. Ignorare un parametro di funzione può essere particolarmente utile nei casi in cui, ad esempio, si sta implementando un tratto per il quale è necessaria una certa firma di tipo, ma il corpo della funzione nell’implementazione non richiede uno dei parametri. Si evita così di ricevere un avviso del compilatore sui parametri di funzione non utilizzati, come si farebbe utilizzando un nome.

Parti di un Valore con un _ Annidato

Possiamo anche usare _ all’interno di un altro pattern per ignorare solo una parte di un valore, ad esempio, quando vogliamo testare solo una parte di un valore ma non abbiamo bisogno delle altre parti nel codice corrispondente che vogliamo eseguire. Il Listato 19-18 mostra il codice responsabile della gestione del valore di un’impostazione. I requisiti aziendali prevedono che l’utente non possa sovrascrivere una personalizzazione esistente di un’impostazione, ma possa annullarla e assegnarle un valore se è attualmente annullata.

fn main() {
    let mut valore_setting = Some(5);
    let nuovo_valore_setting = Some(10);

    match (valore_setting, nuovo_valore_setting) {
        (Some(_), Some(_)) => {
            println!("Non è possibile sovrascrivere un valore personalizzato esistente");
        }
        _ => {
            valore_setting = nuovo_valore_setting;
        }
    }

    println!("Valore setting è {valore_setting:?}");
}
Listato 19-18: Utilizzo di un trattino basso all’interno di pattern che corrispondono alle varianti di Some quando non è necessario utilizzare il valore all’interno di Some

Questo codice stamperà Non è possibile sovrascrivere un valore personalizzato esistente e poi Valore setting è Some(5). Nel primo ramo di corrispondenza, non è necessario abbinare o utilizzare i valori all’interno di una delle varianti Some, ma è necessario testare il caso in cui valore_setting e nuovo_valore_setting siano la variante Some. In tal caso, stampiamo il motivo per cui valore_setting non viene modificato, e non viene modificato.

In tutti gli altri casi (se valore_setting o nuovo_valore_setting è None) espressi dal pattern _ nel secondo braccio, vogliamo consentire a nuovo_valore_setting di diventare valore_setting.

Possiamo anche utilizzare caratteri di sottolineatura in più punti all’interno di un pattern per ignorare valori specifici. Il Listato 19-19 mostra un esempio di come ignorare il secondo e il quarto valore in una tupla di cinque elementi.

fn main() {
    let numeri = (2, 4, 8, 16, 32);

    match numeri {
        (primo, _, terzo, _, quinto) => {
            println!("Alcuni numeri: {primo}, {terzo}, {quinto}");
        }
    }
}
Listato 19-19: Ignorare più parti di una tupla

Questo codice stamperà Alcuni numeri: 2, 8, 32, e i valori 4 e 16 saranno ignorati.

Una Variabile Inutilizzata Iniziando il suo Nome con _

Se si crea una variabile ma non la si utilizza da nessuna parte, Rust di solito genera un avviso perché una variabile inutilizzata potrebbe essere un bug. Tuttavia, a volte è utile poter creare una variabile che non si utilizzerà ancora, ad esempio quando si sta realizzando un prototipo o si sta appena iniziando un progetto. In questa situazione, puoi dire a Rust di non avvisarti della variabile inutilizzata iniziando il nome della variabile con un trattino basso. Nel Listato 19-20, creiamo due variabili inutilizzate, ma quando compiliamo questo codice, dovremmo ricevere un avviso solo per una di esse.

File: src/main.rs
fn main() {
    let _x = 5;
    let y = 10;
}
Listato 19-20: Iniziare il nome di una variabile con un trattino basso per evitare di ricevere avvisi di variabili inutilizzate

Qui, riceviamo un avviso sul mancato utilizzo della variabile y, ma non riceviamo un avviso sul mancato utilizzo di _x.

Si noti che c’è una sottile differenza tra l’utilizzo di solo _ e l’utilizzo di un nome che inizia con un trattino basso. La sintassi _x vincola comunque il valore alla variabile, mentre _ non lo vincola affatto. Per mostrare un caso in cui questa distinzione è importante, il Listato 19-21 ci fornirà un errore.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("trovata una stringa");
    }

    println!("{s:?}");
}
Listato 19-21: Una variabile inutilizzata che inizia con un trattino basso vincola comunque il valore, che potrebbe assumerne la proprietà

Riceveremo un errore perché il valore s verrà comunque spostato in _s, il che ci impedisce di utilizzare nuovamente s. Tuttavia, l’utilizzo del trattino basso da solo non vincola mai il valore. Il Listato 19-22 verrà compilato senza errori perché s non viene spostato in _.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("trovata una stringa");
    }

    println!("{s:?}");
}
Listato 19-22: L’utilizzo di un trattino basso non vincola il valore

Questo codice funziona perfettamente perché non vincola mai s a nulla; non viene spostato.

Parti Rimanenti di un Valore con ..

Con valori composti da molte parti, possiamo usare la sintassi .. per usare parti specifiche e ignorare il resto, evitando la necessità di elencare caratteri di sottolineatura per ogni valore ignorato. Il pattern .. ignora qualsiasi parte di un valore che non abbiamo corrisposto esplicitamente nel resto del pattern. Nel Listato 19-23, abbiamo una struttura Punto che contiene una coordinata nello spazio tridimensionale. Nell’espressione match, vogliamo operare solo sulla coordinata x e ignorare i valori nei campi y e z.

fn main() {
    struct Punto {
        x: i32,
        y: i32,
        z: i32,
    }

    let origine = Punto { x: 0, y: 0, z: 0 };

    match origine {
        Punto { x, .. } => println!("x è {x}"),
    }
}
Listato 19-23: Ignorare tutti i campi di un Point tranne x usando ..

Elenchiamo il valore x e poi includiamo semplicemente il pattern ... Questo è più veloce che dover elencare y: _ e z: _, soprattutto quando lavoriamo con strutture che hanno molti campi in situazioni in cui solo uno o due campi sono rilevanti.

La sintassi .. si espanderà a tutti i valori necessari. Il Listato 19-24 mostra come usare .. con una tupla.

File: src/main.rs
fn main() {
    let numeri = (2, 4, 8, 16, 32);

    match numeri {
        (primo, .., ultimo) => {
            println!("Alcuni numeri: {primo}, {ultimo}");
        }
    }
}
Listato 19-24: Corrispondenza solo del primo e dell’ultimo valore in una tupla e ignorando tutti gli altri valori

In questo codice, il primo e l’ultimo valore vengono confrontati con primo e ultimo. .. corrisponderà e ignorerà tutto ciò che si trova nel mezzo.

Tuttavia, l’utilizzo di .. deve essere univoco. Se non è chiaro quali valori siano destinati alla corrispondenza e quali debbano essere ignorati, Rust restituirà un errore. Il Listato 19-25 mostra un esempio di utilizzo ambiguo di .., quindi non verrà compilato.

File: src/main.rs
fn main() {
    let numeri = (2, 4, 8, 16, 32);

    match numeri {
        (.., secondo, ..) => {
            println!("Alcuni numeri: {secondo}")
        },
    }
}
Listato 19-25: Un tentativo di utilizzare .. in modo ambiguo

Quando compiliamo questo esempio, otteniamo questo errore:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., secondo, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

È impossibile per Rust determinare quanti valori nella tupla ignorare prima di abbinare un valore con secondo e poi quanti altri valori ignorare successivamente. Questo codice potrebbe significare che vogliamo ignorare 2, associare secondo a 4 e quindi ignorare 8, 16 e 32; oppure che vogliamo ignorare 2 e 4, associare secondo a 8 e quindi ignorare 16 e 32; e così via. Il nome della variabile secondo non ha alcun significato particolare in Rust, quindi otteniamo un errore del compilatore perché usare .. in due punti come questo è ambiguo.

Aggiungere Istruzioni Condizionali con le Match Guard

Una match guard è una condizione if aggiuntiva, specificata dopo il pattern in un ramo match, che deve corrispondere affinché quel ramo venga scelto. Le Match Guard sono utili per esprimere idee più complesse di quelle consentite da un solo pattern. Si noti, tuttavia, che sono disponibili solo nelle espressioni match, non nelle espressioni if let o while let.

La condizione può utilizzare variabili create nel pattern. Il Listato 19-26 mostra un match in cui il primo ramo ha il pattern Some(x) e ha anche una match guard if x % 2 == 0 (che sarà true se il numero è pari).

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("Il numero {x} è pari"),
        Some(x) => println!("Il numero {x} è dispari"),
        None => (),
    }
}
Listato 19-26: Aggiungere una match guard a un pattern

Questo esempio stamperà Il numero 4 è pari. Quando num viene confrontato con il pattern nel primo ramo, corrisponde perché Some(4) corrisponde a Some(x). Quindi la match guard controlla se il resto della divisione di x per 2 è uguale a 0 e, poiché lo è, viene selezionato il primo ramo.

Se num fosse stato Some(5), la match guard nel primo ramo sarebbe stata false perché il resto di 5 diviso 2 è 1, che è diverso da 0. Rust passerebbe quindi al secondo ramo, che corrisponderebbe perché il secondo ramo non ha una match guard e quindi corrisponde a qualsiasi variante di Some.

Non c’è modo di esprimere la condizione if x % 2 == 0 all’interno di un pattern, quindi la match guard ci dà la possibilità di esprimere questa logica. Lo svantaggio di questa espressività aggiuntiva è che il compilatore non cerca di verificare l’esaustività quando sono coinvolte espressioni di match guard.

Nel Listato 19-11, abbiamo accennato alla possibilità di utilizzare le match guards per risolvere il nostro problema di pattern-shadowing. Ricordiamo che abbiamo creato una nuova variabile all’interno del pattern nell’espressione match invece di utilizzare la variabile esterna a match. Questa nuova variabile ci impediva di testare il valore della variabile esterna. Il Listato 19-27 mostra come possiamo usare una match guard per risolvere questo problema.

File: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Ricevuto 50"),
        Some(n) if n == y => println!("Corrisponde, n = {n}"),
        _ => println!("Caso predefinito, x = {x:?}"),
    }

    println!("alla fine: x = {x:?}, y = {y}");
}
Listato 19-27: Usare una match guard per verificare l’uguaglianza con una variabile esterna

Questo codice ora stamperà Caso predefinito, x = Some(5). Il pattern nel secondo ramo di corrispondenza non introduce una nuova variabile y che oscurerebbe la y esterna, il che significa che possiamo usare la y esterna nella match guard. Invece di specificare il pattern come Some(y), che avrebbe oscurato la y esterna, specifichiamo Some(n). Questo crea una nuova variabile n che non oscura nulla perché non esiste alcuna variabile n al di fuori della match.

La clausola di controllo if n == y non è un pattern e quindi non introduce nuove variabili. Questa y è la y esterna anziché una nuova y che la oscura, e possiamo cercare un valore che abbia lo stesso valore della y esterna confrontando n con y.

È anche possibile utilizzare l’operatore or | in una clausola di controllo per specificare più pattern; la condizione di controllo si applicherà a tutti i pattern. Il Listato 19-28 mostra la precedenza quando si combina un pattern che utilizza | con una clausola di controllo. La parte importante di questo esempio è che la match guard if y si applica a 4, 5 e 6, anche se potrebbe sembrare che if y si applichi solo a 6.

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("si"),
        _ => println!("no"),
    }
}
Listato 19-28: Combinazione di più pattern con una match guard

La condizione di corrispondenza stabilisce che il ramo corrisponde solo se il valore di x è uguale a 4, 5 o 6 e se y è true. Quando questo codice viene eseguito, il pattern del primo ramo corrisponde perché x è 4, ma la match guard if y è false, quindi il primo ramo non viene scelto. Il codice passa al secondo ramo, che corrisponde, e questo programma stampa no. Il motivo è che la condizione if si applica all’intero pattern 4 | 5 | 6, non solo all’ultimo valore 6. In altre parole, la precedenza di una match guard rispetto a un pattern si comporta in questo modo:

(4 | 5 | 6) if y => ...

piuttosto che in questo modo:

4 | 5 | (6 if y) => ...

Dopo aver eseguito il codice, il comportamento della precedenza è evidente: se la match guard fosse stata applicata solo al valore finale nell’elenco di valori specificato utilizzando l’operatore |, armil ramo avrebbe trovato una corrispondenza e il programma avrebbe stampato yes.

Utilizzo dei Binding @

L’operatore at @ ci consente di creare una variabile che contiene un valore mentre stiamo testando quel valore per una corrispondenza con il pattern. Nel Listato 19-29, vogliamo verificare che un campo Message::Hello id sia compreso nell’intervallo 3..=7. Vogliamo anche associare il valore alla variabile id in modo da poterlo utilizzare nel codice associato al ramo.

fn main() {
    enum Messaggio {
        Hello { id: i32 },
    }

    let msg = Messaggio::Hello { id: 5 };

    match msg {
        Messaggio::Hello { id: id @ 3..=7 } => {
            println!("Trovato un id nell'intervallo: {id}")
        }
        Messaggio::Hello { id: 10..=12 } => {
            println!("Trovato un id in un altro intervallo")
        }
        Messaggio::Hello { id } => println!("Trovato un altro id: {id}"),
    }
}
Listato 19-29: Usare @ per associare un valore in un pattern e testarlo

Questo esempio stamperà Trovato un id nell'intervallo: 5. Specificando id @ prima dell’intervallo 3..=7, catturiamo qualsiasi valore corrispondente all’intervallo in una variabile denominata id, verificando anche che il valore corrisponda al pattern dell’intervallo.

Nel secondo ramo, dove abbiamo specificato solo un intervallo nel pattern, il codice associato al ramo non ha una variabile che contenga il valore effettivo del campo id. Il valore del campo id avrebbe potuto essere 10, 11 o 12, ma il codice associato a quel pattern non sa quale sia. Il codice del pattern non è in grado di utilizzare il valore del campo id, perché non abbiamo salvato il valore id in una variabile.

Nell’ultimo ramo, dove abbiamo specificato una variabile senza intervallo, abbiamo il valore disponibile da utilizzare nel codice del ramo in una variabile denominata id. Il motivo è che abbiamo utilizzato la sintassi abbreviata del campo struct. Ma non abbiamo applicato alcun test al valore del campo id in questo ramo, come abbiamo fatto con i primi due rami: qualsiasi valore corrisponderebbe a questo pattern.

L’utilizzo di @ ci consente di testare un valore e salvarlo in una variabile all’interno di un pattern.

Riepilogo

I pattern di Rust sono molto utili per distinguere tra diversi tipi di dati. Quando vengono utilizzati nelle espressioni match, Rust garantisce che i pattern coprano ogni valore possibile, altrimenti il programma non verrà compilato. I pattern nelle istruzioni let e nei parametri di funzione rendono questi costrutti più utili, consentendo la destrutturazione dei valori in parti più piccole e l’assegnazione di tali parti a variabili. Possiamo creare pattern semplici o complessi in base alle nostre esigenze.

Successivamente, nel penultimo capitolo del libro, esamineremo alcuni aspetti avanzati di una varietà di funzionalità di Rust.

Caratteristiche Avanzate

A questo punto, hai imparato le parti più comunemente usate del linguaggio di programmazione Rust. Prima di fare un altro progetto, nel Capitolo 21, esamineremo alcuni aspetti del linguaggio che potresti incontrare di tanto in tanto, ma che potresti non usare tutti i giorni. Puoi usare questo capitolo come riferimento quando incontri qualcosa di sconosciuto. Le caratteristiche trattate qui sono utili in situazioni molto specifiche. Anche se potresti non usarle spesso, vogliamo assicurarci che tu abbia una comprensione di tutte le funzionalità che Rust ha da offrire.

In questo capitolo, tratteremo:

  • Unsafe Rust: come rinunciare ad alcune delle garanzie di Rust e assumersi la responsabilità di mantenere manualmente tali garanzie
  • Trait avanzati: type associati, parametri di type di default, sintassi completamente qualificata, supertrait e il modello newtype in relazione ai trait
  • Type avanzati: approfondimento sul modello newtype, type alias, il type never e type a dimensione dinamica
  • Funzioni avanzate e closure: puntatori a funzione e ritorno di closure
  • Macro: modi per definire codice che definisce altro codice durante la compilazione

È un insieme variegato di funzionalità di Rust con qualcosa per tutti! Iniziamo!

Unsafe Rust

Tutto il codice di cui abbiamo parlato finora ha avuto le garanzie di sicurezza della memoria di Rust applicate durante la compilazione. Però, Rust ha un secondo linguaggio nascosto al suo interno che non applica queste garanzie: si chiama unsafe Rust e funziona come il Rust regolare, ma ci dà dei superpoteri extra.

Unsafe Rust esiste perché, per natura, l’analisi statica è conservativa. Quando il compilatore cerca di capire se il codice rispetta le garanzie, è meglio per lui rifiutare qualche programma valido piuttosto che accettarne uno non valido. Anche se il codice potrebbe andare bene, se il compilatore Rust non ha abbastanza informazioni per esserne sicuro, rifiuterà di compilare il codice. In questi casi, puoi usare codice unsafe per dire al compilatore: “Fidati, so cosa sto facendo.” Attenzione però, usare unsafe Rust è un rischio tuo: se usi codice unsafe in modo sbagliato, possono succedere problemi legati alla sicurezza della memoria, tipo il de-referenziamento di puntatori nulli.

Un altro motivo per cui Rust ha un alter ego unsafe è che l’hardware del computer è intrinsecamente unsafe. Se Rust non ti permettesse di fare operazioni unsafe, non potresti fare certi compiti. Rust deve permetterti di fare programmazione di sistema a basso livello, tipo interagire direttamente con il sistema operativo o persino scrivere il tuo sistema operativo. La programmazione di sistema a basso livello è uno degli obiettivi del linguaggio. Vediamo cosa si può fare con unsafe Rust e come farlo.

Usare i Superpoteri Unsafe

Per passare a unsafe Rust, usa la parola chiave unsafe e poi inizia un nuovo blocco dove metti il codice unsafe. Ci sono cinque azioni che puoi fare in unsafe Rust che non puoi fare in safe Rust, che chiamiamo superpoteri unsafe. Questi superpoteri includono la possibilità di:

  1. De-referenziare un puntatore grezzo
  2. Chiamare una funzione o un metodo unsafe
  3. Accedere o modificare una variabile statica mutabile
  4. Implementare un trait unsafe
  5. Accedere ai campi di union

È importante capire che unsafe non disabilita il borrow checker né gli altri controlli di sicurezza di Rust: se usi un reference in codice unsafe, esso verrà comunque controllato. La parola chiave unsafe ti dà solo accesso a queste cinque caratteristiche che non sono controllate dal compilatore nell’aspetto della sicurezza della memoria. Avrai comunque un certo grado di sicurezza dentro un blocco unsafe.

Inoltre, unsafe non significa che il codice dentro il blocco sia necessariamente pericoloso o che sicuramente avrà problemi di sicurezza della memoria: l’intento è che tu, programmatore, garantisca che il codice dentro un blocco unsafe accederà alla memoria in modo valido.

Gli esseri umani sbagliano, possono fare errori, ma richiedendo che queste cinque operazioni unsafe siano usate dentro blocchi annotati con unsafe, saprai che ogni errore legato alla sicurezza della memoria deve per forza essere dentro un blocco unsafe. Mantieni i blocchi unsafe piccoli; ne sarai contento quando dovrai cercare bug di memoria.

Per isolare il codice unsafe il più possibile, è meglio racchiuderlo in un’astrazione safe e offrire un’API safe, di cui parleremo più avanti nel capitolo quando esamineremo funzioni e metodi unsafe. Parti della libreria standard sono implementate come astrazioni safe su codice unsafe che è stato controllato. Racchiudere il codice unsafe in un’astrazione safe evita che gli usi di unsafe vadano a infiltrarsi in tutte le parti del codice dove tu o i tuoi utenti potreste voler usare la funzionalità scritta con codice unsafe, perché usare un’astrazione safe è sicuro.

Ora vediamo uno per uno i cinque superpoteri unsafe. Daremo anche un’occhiata ad alcune astrazioni che forniscono una interfaccia safe a codice unsafe.

De-referenziare un Puntatore Grezzo

Nel Capitolo 4, nella sezione Reference Pendenti”, abbiamo detto che il compilatore si assicura che i reference siano sempre validi. Unsafe Rust ha due nuovi type chiamati puntatori grezzi (raw pointer) simili ai reference. Come con i reference, i puntatori grezzi possono essere immutabili o mutabili e si scrivono *const T e *mut T rispettivamente. L’asterisco non è l’operatore di de-referenziazione; fa parte del nome del type. Nel contesto dei puntatori grezzi, immutabile significa che il puntatore non può essere assegnato direttamente dopo essere stato de-referenziato.

Diversamente da reference e puntatori intelligenti, i puntatori grezzi:

  • Possono ignorare le regole di borrowing avendo sia puntatori immutabili che mutabili o molteplici puntatori mutabili allo stesso dato
  • Non è garantito che puntino a memoria valida
  • Possono essere nulli
  • Non fanno nessuna pulizia automatica

Rinunciando a far rispettare queste garanzie da parte di Rust, puoi rinunciare alla sicurezza garantita in cambio di maggiori prestazioni o della possibilità di interfacciarti con altri linguaggi o hardware dove le garanzie di Rust non valgono.

Il Listato 20-1 mostra come creare un puntatore grezzo immutabile e uno mutabile.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listato 20-1: Creazione di puntatori grezzi con gli operatori di prestito grezzi

Nota che in questo codice non usiamo la parola chiave unsafe. Possiamo creare puntatori grezzi in codice safe; non possiamo però de-referenziarli fuori da un blocco unsafe, come vedremo tra poco.

Abbiamo creato puntatori grezzi usando gli operatori di prestito grezzi (raw borrow): &raw const num crea un puntatore grezzo immutabile *const i32, mentre &raw mut num crea un puntatore grezzo mutabile *mut i32. Siccome li abbiamo creati direttamente da una variabile locale, sappiamo che questi puntatori grezzi sono validi, ma non possiamo fare questa assunzione per qualsiasi puntatore grezzo.

Per dimostrarlo, creiamo un puntatore grezzo di cui non possiamo essere così certi che sia valido, usando la parola chiave as per fare un cast invece di usare l’operatore di prestito grezzo. Il Listato 20-2 mostra come creare un puntatore grezzo verso una posizione arbitraria in memoria. Usare un indirizzo di memoria arbitrario è un comportamento indefinito: potrebbe esserci qualche dato a quell’indirizzo o magari no, il compilatore potrebbe ottimizzare il codice e evitare l’accesso alla memoria, oppure il programma potrebbe terminare con un errore accesso non valido alla memoria. Di solito non c’è una buona ragione per scrivere codice così, specialmente quando si può usare un operatore di prestito grezzo, ma è possibile farlo.

fn main() {
    let indirizzo = 0x012345usize;
    let r = indirizzo as *const i32;
}
Listato 20-2: Creazione di un puntatore grezzo a un indirizzo di memoria arbitrario

Ricorda che possiamo creare puntatori grezzi in codice safe, però non possiamo de-referenziarli per leggere i dati a cui puntano. Nel Listato 20-3 usiamo l’operatore di de-referenziazione * su un puntatore grezzo che richiede un blocco unsafe.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 è: {}", *r1);
        println!("r2 è: {}", *r2);
    }
} 

Creare un puntatore non fa danno; è solo quando proviamo a leggere il valore a cui punta che potremmo avere a che fare con un valore invalido.

Nota anche che nei Listati 20-1 e 20-3 abbiamo creato puntatori grezzi *const i32 e *mut i32 dove entrambi puntavano alla stessa locazione di memoria, dove si trova num. Se invece avessimo provato a creare un reference immutabile e uno mutabile a num, il codice non sarebbe stato compilato perché le regole di ownership di Rust non permettono un reference mutabile contemporaneamente a reference immutabili. Con i puntatori grezzi possiamo creare un puntatore mutabile e uno immutabile sugli stessi dati in memoria e modificarli tramite il puntatore mutabile, creando potenzialmente una data race. Fai attenzione!

Con tutti questi pericoli, perché mai usare i puntatori grezzi? Un uso molto comune è quando ci si interfaccia con codice in C, come vedremo nella prossima sezione. Un altro caso è quando si costruiscono astrazioni safe che il borrow checker non capisce. Introdurremo le funzioni unsafe e poi vedremo un esempio di astrazione safe che usa codice unsafe.

Chiamare una Funzione o Metodo Unsafe

Il secondo tipo di operazione che puoi fare in un blocco unsafe è chiamare funzioni unsafe. Le funzioni e i metodi unsafe sembrano esattamente normali funzioni e metodi, ma hanno unsafe prima della definizione. La parola chiave unsafe qui indica che la funzione ha dei requisiti che dobbiamo rispettare quando la chiamiamo, perché Rust non può garantire che li rispettiamo. Chiamando una funzione unsafe dentro un blocco unsafe stiamo dicendo che abbiamo letto la documentazione di quella funzione e ci assumiamo la responsabilità di rispettarne i contratti.

Ecco una funzione unsafe chiamata pericolosa che non fa nulla nel corpo:

fn main() {
    unsafe fn pericolosa() {}

    unsafe {
        pericolosa();
    }
}

Dobbiamo chiamare la funzione pericolosa dentro un blocco unsafe separato. Se proviamo a chiamarla senza il blocco unsafe, avremo un errore:

$ cargo run
   Compiling esempio-unsafe v0.1.0 (file:///progetti/esempio-unsafe)
error[E0133]: call to unsafe function `pericolosa` is unsafe and requires unsafe block
 --> src/main.rs:5:5
  |
5 |     pericolosa();
  |     ^^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `esempio-unsafe` (bin "esempio-unsafe") due to 1 previous error

Con il blocco unsafe, stiamo dicendo a Rust che abbiamo letto la documentazione della funzione, sappiamo come usarla correttamente e abbiamo verificato di rispettare il contratto.

Per fare operazioni unsafe dentro una funzione unsafe, serve comunque un blocco unsafe anche dentro il corpo, e il compilatore ti avvertirà se lo dimentichi. Questo ci aiuta a tenere i blocchi unsafe più piccoli possibile, perché spesso non servono in tutto il corpo della funzione.

Creare un’Astrazione Safe su Codice Unsafe

Solo perché una funzione contiene codice unsafe non significa che debba essere tutta marcata come unsafe. Infatti, incapsulare codice unsafe in una funzione safe è una pratica comune. Come esempio, studiamo la funzione split_at_mut della libreria standard, che richiede un po’ di codice unsafe. Vedremo come potremmo implementarla. Questo metodo safe è definito per slice mutabili: prende una slice e la divide in due a partire da un indice passato come argomento. Il Listato 20-4 mostra come usare split_at_mut.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
Listato 20-4: Uso della funzione safe split_at_mut

Non possiamo implementare questa funzione usando solo safe Rust. Una prova potrebbe essere il Listato 20-5, che non si compila. Per semplicità, implementeremo split_at_mut come funzione invece che come metodo, e solo per slice di i32, non per un type generico T.

fn split_at_mut(valori: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = valori.len();

    assert!(mid <= len);

    (&mut valori[..mid], &mut valori[mid..])
}

fn main() {
    let mut vettore = vec![1, 2, 3, 4, 5, 6];
    let (sinistra, destra) = split_at_mut(&mut vettore, 3);
}
Listato 20-5: Tentativo di implementazione di split_at_mut usando solo safe Rust

Questa funzione prima prende la lunghezza totale della slice. Poi assicura che l’indice passato sia entro la slice, verificando che sia minore o uguale alla lunghezza. Questa asserzione significa che se passiamo un indice maggiore della lunghezza, la funzione farà panic prima di usare quell’indice.

Poi ritorna due slice mutabili in una tupla: una dalla parte iniziale fino a mid e l’altra da mid fino alla fine della slice.

Quando proviamo a compilare il codice in Listato 20-5, otteniamo un errore:

$cargo run
   Compiling esempio-unsafe v0.1.0 (file:///progetti/esempio-unsafe)
error[E0499]: cannot borrow `*valori` as mutable more than once at a time
 --> src/main.rs:7:31
  |
2 | fn split_at_mut(valori: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
7 |     (&mut valori[..mid], &mut valori[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*valori` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `esempio-unsafe` (bin "esempio-unsafe") due to 1 previous error

Il borrow checker di Rust non capisce che stiamo prendendo due parti diverse della stessa slice; sa solo che stiamo prendendo la stessa slice due volte. Prendere in prestito parti diverse di una stessa slice è fondamentalmente non problematico perché le due slice non si sovrappongono, ma Rust non è abbastanza intelligente da capire questo. Quando sappiamo che il codice va bene, ma Rust no, è ora di usare codice unsafe.

Il Listato 20-6 mostra come usare un blocco unsafe, un puntatore grezzo e alcune chiamate a funzioni unsafe per far funzionare split_at_mut.

use std::slice;

fn split_at_mut(valori: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = valori.len();
    let ptr = valori.as_mut_ptr();

    assert!(mid < len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listato 20-6: Uso di codice unsafe nell’implementazione della funzione split_at_mut

Ricordiamo da “Il Type Slice nel capitolo 4 che una slice è un puntatore a un dato e la sua lunghezza. Usiamo il metodo len per avere la lunghezza e il metodo as_mut_ptr per accedere al puntatore grezzo della slice. In questo caso, poiché abbiamo una slice mutabile di i32, as_mut_ptr ritorna un puntatore grezzo di type *mut i32 che conserviamo nella variabile ptr.

Manteniamo l’asserzione che mid stia dentro la slice. Poi arriviamo al codice unsafe: la funzione slice::from_raw_parts_mut prende un puntatore grezzo e una lunghezza e crea una slice. La usiamo per creare una slice che parte da ptr ed è lunga mid elementi. Poi chiamiamo il metodo add su ptr con mid come argomento per ottenere un puntatore grezzo che punta a mid, e creiamo una slice usando quel puntatore e la lunghezza rimanente dopo mid.

La funzione slice::from_raw_parts_mut è unsafe perché prende un puntatore grezzo e deve fidarsi che quel puntatore sia valido. Anche il metodo add su puntatore grezzo è unsafe perché deve fidarsi che la locazione di memoria puntata sia valida. Per questo abbiamo messo un blocco unsafe attorno alle nostre chiamate a slice::from_raw_parts_mut e add per poterle chiamare. Guardando il codice e aggiungendo l’asserzione che mid deve essere minore o uguale a len, possiamo dire che tutti i puntatori grezzi usati nel blocco unsafe saranno validi e punteranno a dati nella slice. Questo è un uso accettabile e appropriato di unsafe.

Nota che non dobbiamo marcare la funzione split_at_mut risultante come unsafe e possiamo chiamarla da safe Rust. Abbiamo creato un’astrazione safe su codice unsafe con un’implementazione che usa codice unsafe in modo safe, perché crea solo puntatori validi dai dati a cui quella funzione ha accesso.

Al contrario, usare slice::from_raw_parts_mut come nel Listato 20-7 probabilmente terminerebbe con un errore quando si usa la slice. Quel codice prende una locazione arbitraria di memoria e crea una slice lunga 10.000 elementi.

fn main() {
    use std::slice;

    let indirizzo = 0x01234usize;
    let r = indirizzo as *mut i32;

    let valori: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Listato 20-7: Creazione di una slice da una locazione arbitraria di memoria

Non abbiamo ownership della memoria in quella posizione arbitraria, e non ci sono garanzie che la slice così creata contenga valori i32 validi. Usare quei valori come se fosse una slice valida è comportamento indefinito.

Usare Funzioni extern per Chiamare Codice Esterno

A volte il tuo codice Rust potrebbe aver bisogno di interagire con codice scritto in un altro linguaggio. Per questo, Rust ha la parola chiave extern che facilita la creazione e l’uso di una interfaccia per funzioni esterne, abbreviato in FFI (Foreign Function Interface), cioè un modo per un linguaggio di definire funzioni e consentire a un diverso linguaggio (esterno) di chiamarle.

Il Listato 20-8 mostra come impostare l’integrazione con la funzione abs dalla libreria standard di C. Le funzioni dichiarate dentro blocchi extern sono generalmente unsafe da chiamare da codice Rust, quindi anche i blocchi extern devono essere marcati come unsafe. Il motivo è che altri linguaggi non impongono le regole e garanzie di Rust, e Rust non può controllarle, quindi la responsabilità è del programmatore.

File: src/main.rs
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Valore assoluto di -3 secondo C: {}", abs(-3));
    }
}
Listato 20-8: Dichiarazione e chiamata di una funzione extern definita in un altro linguaggio

Dentro il blocco unsafe extern "C", elenchiamo nomi e firme delle funzioni esterne di un altro linguaggio che vogliamo chiamare. La parte "C" definisce quale interfaccia binaria dell’applicazione, abbreviato in ABI (application binary interface), quella funzione usa: l’ABI definisce come chiamare la funzione a livello assembly. L’ABI "C" è il più comune ed è l’ABI del linguaggio C. Informazioni su tutte le ABI supportate da Rust sono disponibili nella Rust Reference.

Ogni elemento dichiarato dentro un blocco unsafe extern è implicitamente unsafe. Però, alcune funzioni FFI sono sicure da chiamare. Per esempio, la funzione abs della libreria standard C non ha considerazioni di sicurezza della memoria di cui preoccuparsi e sappiamo che può essere chiamata con qualunque i32. In questi casi possiamo usare la parola chiave safe per dire che quella funzione specifica è sicura da chiamare anche se si trova dentro un blocco unsafe extern. Dopo questa modifica, chiamarla non richiede più un blocco unsafe, come mostra il Listato 20-9.

File: src/main.rs
unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Valore assoluto di -3 secondo C: {}", abs(-3));
}
Listato 20-9: Marcatura esplicita di una funzione come safe dentro un blocco unsafe extern e chiamata in maniera sicura

Marcare una funzione come safe non la rende automaticamente sicura! È come una promessa a Rust che è sicura. Sta comunque a te fare in modo che la promessa sia mantenuta!

Chiamare Funzioni Rust da Altri Linguaggi

Possiamo anche usare extern per creare un’interfaccia che permetta ad altri linguaggi di chiamare funzioni Rust. Invece di creare un blocco extern completo, mettiamo la parola chiave extern e specifichiamo l’ABI da usare subito prima della parola fn per la funzione interessata. Dobbiamo anche aggiungere l’annotazione #[unsafe(no_mangle)] per disabilitare il mangling da parte del compilatore per quella funzione. Il mangling è quando un compilatore cambia il nome di una funzione in un nome diverso che contiene più info per altre parti della compilazione, ma è meno leggibile dall’uomo. Ogni linguaggio compila in modo diverso, quindi per permettere a una funzione Rust di essere chiamata da altri linguaggi dobbiamo disabilitare il name mangling, ma questo è unsafe perché potrebbero esserci collisioni di nomi tra varie librerie, quindi sta a noi scegliere un nome sicuro da esportare senza mangling.

Nell’esempio seguente rendiamo la funzione call_from_c accessibile da codice C, dopo essere stata compilata in una libreria condivisa e collegata dal C:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Chiamata una funzione Rust da C!");
}
}

Questo uso di extern richiede unsafe solo nell’attributo, non nel blocco extern.

Accedere o Modificare una Variabile Statica Mutabile

Nel libro finora non abbiamo parlato di variabili globali, che Rust supporta ma che possono dare problemi con le regole di ownership. Se due thread accedono contemporaneamente alla stessa variabile globale mutabile, può succedere una data race.

In Rust, le variabili globali si chiamano variabili static. Il Listato 20-10 mostra un esempio di dichiarazione e uso di una variabile statica con una slice di stringa come valore.

File: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("valore è: {HELLO_WORLD}");
}
Listato 20-10: Definizione e uso di una variabile statica immutabile

Le variabili statiche sono simili alle costanti, di cui abbiamo parlato in “Dichiarare le Costanti” nel Capitolo 3. I nomi delle variabili statiche sono, per convenzione, in SNAKE_CASE_MAIUSCOLO. Le variabili statiche possono contenere solo reference con longevità 'static, quindi il compilatore Rust può ricavarne la lifetime e non serve annotarla esplicitamente. Accedere a una variabile statica immutabile è sicuro.

Una sottile differenza tra costanti e variabili statiche immutabili è che i valori in una variabile statica hanno un indirizzo fisso in memoria. Usare quel valore significa sempre accedere agli stessi dati. Le costanti invece possono duplicare i dati ogni volta che sono usate. Un’altra differenza è che le variabili statiche possono essere mutabili. Accedere e modificare variabili statiche mutabili è unsafe. Il Listato 20-11 mostra come dichiarare, accedere e modificare una variabile statica mutabile chiamata CONTATORE.

File: src/main.rs
static mut CONTATORE: u32 = 0;

/// SAFETY: Chiamarlo da più di un unico thread alla volta è un comportamento
/// non definito, *devi* quindi garantire che verra chiamato da un singolo
/// thread alla volta
unsafe fn aggiungi_a_contatore(inc: u32) {
    unsafe {
        CONTATORE += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: È chiamato da un singolo thread in `main`.
        aggiungi_a_contatore(3);
        println!("CONTATORE: {}", *(&raw const CONTATORE));
    }
}
Listato 20-11: Lettura o scrittura su una variabile statica mutabile è unsafe

Come per le variabili normali, specifichiamo la mutabilità con la parola chiave mut. Qualsiasi codice che legge o scrive da CONTATORE deve stare dentro un blocco unsafe. Il codice nel Listato 20-11 viene compilato e stampa CONTATORE: 3, come ci aspettiamo perché è a singolo thread. Se più thread accedessero a CONTATORE probabilmente si creerebbero data races, quindi è un comportamento indefinito. Per questo dobbiamo marcare tutta la funzione come unsafe e documentarne i limiti di sicurezza, così chi la chiama sa cosa può e non può fare in sicurezza.

Quando scriviamo una funzione unsafe, è pratica comune scrivere un commento che inizi con SAFETY per spiegare cosa deve fare chi chiama la funzione per farla funzionare in sicurezza. Allo stesso modo, quando facciamo un’operazione unsafe, scriviamo un commento con SAFETY per spiegare come vengono rispettate le regole di sicurezza.

Il compilatore blocca di default ogni tentativo di creare reference a variabili statiche mutabili tramite i controlli del linter. Devi quindi o disabilitare esplicitamente il lint con #[allow(static_mut_refs)] o accedere alla variabile statica mutabile tramite un puntatore grezzo creato con uno degli operatori di prestito grezzi. Questo include i casi in cui il reference è creato in modo invisibile, come quando è usato in println! in quel codice. Richiedere che i reference alle variabili statiche mutabili siano creati tramite puntatore grezzo rende più evidente quali sono i requisiti di sicurezza per usarle.

Con dati mutabili che sono accessibili globalmente, è difficile assicurarsi che non ci siano data race, motivo per cui Rust considera le variabili statiche mutabili unsafe. Quando possibile, è preferibile usare tecniche di concorrenza e puntatori intelligenti thread-safe di cui abbiamo parlato nel Capitolo 16, così il compilatore verifica che l’accesso da thread diversi sia sicuro.

Implementare un Trait Unsafe

Possiamo usare unsafe per implementare un trait unsafe. Un trait è unsafe quando almeno uno dei suoi metodi ha una proprietà che il compilatore non può verificare. Dichiariamo un trait unsafe mettendo la parola chiave unsafe prima di trait e marcando anche l’implementazione del trait come unsafe, come mostra il Listato 20-12.

unsafe trait Foo {
    // metodi vanno qui
}

unsafe impl Foo for i32 {
    // implementazioni dei metodi vanno qui
}

fn main() {}
Listato 20-12: Definizione e implementazione di un trait unsafe

Usando unsafe impl promettiamo che rispetteremo le proprietà che il compilatore non può verificare.

Per esempio, ricordiamo i trait marcatori Send e Sync di cui abbiamo parlato in “Concorrenza Estensibile con Send e Sync nel Capitolo 16: il compilatore implementa automaticamente questi trait se i nostri type sono composti solo da type che implementano Send e Sync. Se implementiamo un type che contiene un type che non implementa Send o Sync, come i puntatori grezzi ad esempio, e vogliamo marcare quel type come Send o Sync, dobbiamo usare unsafe. Rust non può verificare che il nostro type rispetti le garanzie per poterlo spostare in sicurezza tra thread o essere usato da più thread, quindi dobbiamo fare quei controlli manualmente e indicarlo con unsafe.

Accedere ai Campi di una Union

L’ultima cosa che si può fare solo con unsafe è accedere ai campi di una union. Una union è simile a una struct, ma solo uno dei campi dichiarati è usato in una certa istanza in un dato momento. Le union sono usate soprattutto per interfacciarsi con le union di codice C. Accedere ai campi di una union è unsafe perché Rust non può garantire il tipo dei dati conservati in quel momento nell’istanza di union. Puoi imparare di più sulle union nella Rust Reference.

Usare Miri per Controllare il Codice Unsafe

Quando scrivi codice unsafe, potresti voler controllare che quello che hai scritto sia davvero sicuro e corretto. Uno dei modi migliori per farlo è usare Miri, uno strumento ufficiale Rust per rilevare comportamenti indefiniti. Mentre il borrow checker è uno strumento statico che lavora durante la compilazione, Miri è uno strumento dinamico che lavora durante l’esecuzione. Controlla il tuo codice eseguendo il programma o i vari test e rilevando quando violi le regole che conosce su come dovrebbe funzionare Rust.

Usare Miri richiede una build nightly di Rust (di cui parliamo di più nell’Appendice G: Come è Fatto Rust e “Nightly Rust”). Puoi installare sia la versione nightly di Rust che lo strumento Miri digitando rustup +nightly component add miri. Questo non cambia la versione di Rust che usa il tuo progetto; aggiunge solo lo strumento al tuo sistema per poterlo usare quando vuoi. Puoi far girare Miri su un progetto digitando cargo +nightly miri run o cargo +nightly miri test.

Per esempio, guarda cosa succede se lo usiamo con il codice nel Listato 20-7.

$ cargo +nightly miri run
   Compiling esempio-unsafe v0.1.0 (file:///progetti/esempio-unsafe)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `/home/utente/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/bin/cargo-miri runner target/miri/x86_64-unknown-linux-gnu/debug/esempio-unsafe`
warning: integer-to-pointer cast
 --> src/main.rs:6:13
  |
6 |     let r = indirizzo as *mut i32;
  |             ^^^^^^^^^^^^^^^^^^^^^ integer-to-pointer cast
  |
  = help: this program is using integer-to-pointer casts or (equivalently) `ptr::with_exposed_provenance`, which means that Miri might miss pointer bugs in this program
  = help: see https://doc.rust-lang.org/nightly/std/ptr/fn.with_exposed_provenance.html for more details on that operation
  = help: to ensure that Miri does not miss bugs in your program, use Strict Provenance APIs (https://doc.rust-lang.org/nightly/std/ptr/index.html#strict-provenance, https://crates.io/crates/sptr) instead
  = help: you can then set `MIRIFLAGS=-Zmiri-strict-provenance` to ensure you are not relying on `with_exposed_provenance` semantics
  = help: alternatively, `MIRIFLAGS=-Zmiri-permissive-provenance` disables this warning
  = note: BACKTRACE:
  = note: inside `main` at src/main.rs:6:13: 6:34

error: Undefined Behavior: pointer not dereferenceable: pointer must be dereferenceable for 40000 bytes, but got 0x1234[noalloc] which is a dangling pointer (it has no provenance)
 --> src/main.rs:8:35
  |
8 |     let valori: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
  |                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
  |
  = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
  = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
  = note: BACKTRACE:
  = note: inside `main` at src/main.rs:8:35: 8:70

note: some details are omitted, run with `MIRIFLAGS=-Zmiri-backtrace=full` for a verbose backtrace

error: aborting due to 1 previous error; 2 warnings emitted

Miri ci avverte correttamente che stiamo facendo un cast da intero a puntatore, che potrebbe essere un problema, ma Miri non può sapere se lo è dato che non conosce l’origine del puntatore. Poi Miri segnala un errore perché il Listato 20-7 ha un comportamento indefinito dovuto a un puntatore pendente. Grazie a Miri, sappiamo che c’è un rischio di comportamento indefinito e possiamo pensare a come mettere in sicurezza il codice. In certi casi Miri può perfino suggerire come correggere gli errori.

Miri non cattura tutto quello che potresti sbagliare scrivendo codice unsafe. È uno strumento di analisi dinamica, quindi cattura solo i problemi nel codice che viene realmente eseguito. Questo significa che devi usarlo insieme a buone tecniche di testing per aumentare la fiducia nel codice unsafe che hai scritto. Miri non copre tutti i possibili modi in cui il tuo codice può essere insicuro.

In altre parole: se Miri rileva un problema, sai che c’è un bug, ma non è detto che se Miri non trova bug, il codice sia sicuro. Però in molti casi aiuta davvero tanto. Prova a farlo girare sugli altri esempi di codice unsafe in questo capitolo e vedi cosa dice!

Puoi saperne di più su Miri nel suo repository GitHub.

Quando Usare il Codice Unsafe

Usare unsafe per sfruttare uno dei cinque superpoteri appena visti non è sbagliato o malvisto, ma è più difficile scrivere codice unsafe corretto perché il compilatore non può garantire la sicurezza della memoria. Quando hai una buona ragione per usare codice unsafe, puoi farlo, e avere la marcatura esplicita unsafe ti aiuta a rintracciare più facilmente la fonte di problemi quando capitano. Ogni volta che scrivi codice unsafe, puoi usare Miri per essere più sicuro che il codice scritto rispetti le regole di Rust.

Per una trattazione molto più approfondita su come lavorare efficacemente con unsafe Rust, leggi la guida ufficiale di Rust sull’argomento, il Rustonomicon.

Trait Avanzati

Abbiamo già visto i trait in Trait: Definire il Comportamento Condiviso con i Trait nel Capitolo 10, ma non abbiamo trattato i dettagli più avanzati. Ora che sai di più su Rust, possiamo mettere le mani in pasta in certi dettagli più complessi.

Definire Trait con Type Associati

I type associati collegano un type segnaposto con un trait in modo che le definizioni dei metodi del trait possano usare questi segnaposto nelle loro firme. Chi implementa il trait specificherà il type concreto da usare per quella particolare implementazione. In questo modo possiamo definire un trait che usa qualche type senza dover sapere esattamente quali siano fino a quando il trait non verrà implementato.

Abbiamo detto che molte delle funzionalità avanzate di questo capitolo sono usate raramente. I type associati stanno a metà: si usano meno rispetto ad altre funzionalità spiegate nel resto del libro, ma più frequentemente di altre funzionalità in questo capitolo.

Un esempio di trait con un type associato è il trait Iterator della libreria standard. Il type associato si chiama Item e rappresenta il type dei valori su cui il type che implementa Iterator itera. La definizione del trait Iterator è mostrata nel Listato 20-13.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listato 20-13: La definizione del trait Iterator con type associato Item

Il type Item è un segnaposto, e la definizione del metodo next mostra che restituirà valori di type Option<Self::Item>. Chi implementa il trait Iterator specifica il type concreto per Item, e il metodo next ritorna un Option che contiene un valore di quel type.

I type associati potrebbero sembrare simili ai generici, visto che anche questi ultimi permettono di definire una funzione senza specificare i type. Per capirne la differenza, vediamo un’implementazione di Iterator su un type chiamato Contatore che specifica Item come u32:

File: src/lib.rs
struct Contatore {
    conteggio: u32,
}

impl Contatore {
    fn new() -> Contatore {
        Contatore { conteggio: 0 }
    }
}

impl Iterator for Contatore {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --taglio--
        if self.conteggio < 5 {
            self.conteggio += 1;
            Some(self.conteggio)
        } else {
            None
        }
    }
}

Questa sintassi ricorda i type generici. Allora perché non definire Iterator usando solo generici, come mostra il Listato 20-14?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listato 20-14: Definizione ipotetica di Iterator usando i generici

La differenza è che usando i generici, come nel Listato 20-14, dobbiamo annotare i type in ogni implementazione; siccome potremmo anche implementare Iterator<String> for Contatore o qualsiasi altro type, potremmo avere più implementazioni di Iterator per Contatore. In altre parole, quando un trait ha un parametro generico, può essere implementato per un type più volte, cambiando i type concreti dei parametri generici ogni volta. Quando usiamo il metodo next su Contatore, dovremmo fornire annotazioni di type per indicare quale implementazione di Iterator vogliamo usare.

Con i type associati non serve annotare i type perché non possiamo implementare un trait più volte su uno stesso type. Nel Listato 20-13, con i type associati, scegliamo il type di Item una sola volta perché c’è una sola impl Iterator for Contatore. Non serve indicare che vogliamo un iteratore di u32 ogni volta che chiamiamo next su Contatore.

I type associati diventano parte del contratto del trait: chi implementa il trait deve fornire un type per sostituire il segnaposto. Spesso i type associati hanno nomi che descrivono come saranno usati, ed è buona prassi documentarli nelle API.

Usare Parametri Generici di Default e Sovrascrivere gli Operatori

Quando usiamo type generici, possiamo specificare un type concreto di default per il type generico. Questo elimina la necessità per chi implementa il trait di specificare un type concreto se il type di default va bene. Si specifica un type di default dichiarando il generico con la sintassi <TypeSegnaposto=TypeConcreto>.

Un ottimo esempio di questa tecnica è la sovrascrittura degli operatori, dove personalizzi il comportamento di un operatore (come +) in situazioni particolari.

Rust non permette di creare operatori propri o sovrascrivere operatori arbitrari. Ma puoi sovrascrivere le operazioni e i trait corrispondenti elencati in std::ops implementando i trait associati all’operatore. Per esempio, nel Listato 20-15 sovrascriviamo l’operatore + per sommare due istanze di Punto. Lo facciamo implementando il trait Add per la struct Punto.

File: src/main.rs
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Punto {
    x: i32,
    y: i32,
}

impl Add for Punto {
    type Output = Punto;

    fn add(self, altro: Punto) -> Punto {
        Punto {
            x: self.x + altro.x,
            y: self.y + altro.y,
        }
    }
}

fn main() {
    assert_eq!(
        Punto { x: 1, y: 0 } + Punto { x: 2, y: 3 },
        Punto { x: 3, y: 3 }
    );
}
Listato 20-15: Implementazione del trait Add per sovrascrivere l’operatore + per le istanze di Punto

Il metodo add somma i valori x di due istanze Punto e i valori y di due istanze Punto per creare un nuovo Punto. Il trait Add ha un type associato chiamato Output che determina il type restituito dal metodo add.

Il type generico di default in questo codice si trova all’interno del trait Add. Ecco la sua definizione:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

Questo codice dovrebbe sembrarti familiare: un trait con un metodo e un type associato. La novità è Rhs=Self: questa sintassi si chiama default type parameters ( type di default dei parametri). Il parametro generico Rhs (abbreviazione di right-hand side, lato destro) definisce il type del parametro rhs nel metodo add. Se non specifichiamo un type concreto per Rhs nell’implementazione di Add, il type di Rhs di default sarà Self, il type su cui stiamo implementando Add.

Quando abbiamo implementato Add per Punto, abbiamo usato il default per Rhs perché volevamo sommare due Punto. Passiamo ora a un esempio di implementazione del trait Add in cui vogliamo personalizzare il type Rhs invece di usare il default.

Abbiamo due struct, Millimetri e Metri, che contengono valori in unità diverse. Questo tipo di “incapsulamento sottile” attorno a un type esistente è chiamato newtype pattern, di cui parleremo più avanti nella sezione “Implementare Trait Esterni con il Modello Newtype. Vogliamo sommare valori in millimetri a valori in metri e far sì che l’implementazione di Add si occupi di fare la conversione corretta. Possiamo implementare Add per Millimetri con Metri come Rhs, come mostrato nel Listato 20-16.

File: src/lib.rs
use std::ops::Add;

struct Millimetri(u32);
struct Metri(u32);

impl Add<Metri> for Millimetri {
    type Output = Millimetri;

    fn add(self, altro: Metri) -> Millimetri {
        Millimetri(self.0 + (altro.0 * 1000))
    }
}
Listato 20-16: Implementazione del trait Add su Millimetri per sommare Millimetri con Metri

Per sommare Millimetri e Metri, specifichiamo impl Add<Metri> per impostare il valore del parametro di type Rhs invece di usare il default Self.

Userai i type di default per i parametri in due modi principali:

  1. Per estendere un type senza rompere il codice esistente
  2. Per permettere personalizzazioni in casi specifici che la maggior parte degli utenti non userà

Il trait Add della libreria standard è un esempio del secondo punto: di solito si sommano due type uguali, ma il trait Add permette di personalizzare questo comportamento. L’uso di un type di default come parametro nella definizione di Add significa che non devi specificare quel parametro extra la maggior parte delle volte, riducendo il codice ripetitivo e facilitandone l’uso.

Il primo punto è simile ma all’opposto: se vuoi aggiungere un type come parametro a un trait esistente, puoi dargli un default per permettere l’estensione della funzionalità del trait senza rompere il codice esistente.

Disambiguare Tra Metodi Con lo Stesso Nome

Nulla in Rust vieta ad un trait di avere metodi che hanno lo stesso nome di metodi in un altro trait. E Rust non ti impedisce di implementare entrambi i trait su di un type. È possibile anche definire un metodo sul type con lo stesso nome di un metodo del trait.

Quando chiami metodi con lo stesso nome devi indicare a Rust quale vuoi usare. Considera il codice nel Listato 20-17, dove sono definiti due trait, Pilota e Mago, entrambi con un metodo chiamato vola. Entrambi i trait sono implementati su un type Umano, che ha anche un metodo vola definito direttamente.

File: src/main.rs
trait Pilota {
    fn vola(&self);
}

trait Mago {
    fn vola(&self);
}

struct Umano;

impl Pilota for Umano {
    fn vola(&self) {
        println!("Qui parla il capitano.");
    }
}

impl Mago for Umano {
    fn vola(&self) {
        println!("Sali!");
    }
}

impl Umano {
    fn vola(&self) {
        println!("*sbatte furiosamente le braccia*");
    }
}

fn main() {}
Listato 20-17: Due trait con un metodo vola implementati sul type Umano, e un metodo vola definito direttamente su Umano

Quando chiamiamo vola su un’istanza Umano, come impostazione predefinita il compilatore chiama il metodo definito direttamente sul type, come mostrato nel Listato 20-18.

File: src/main.rs
trait Pilota {
    fn vola(&self);
}

trait Mago {
    fn vola(&self);
}

struct Umano;

impl Pilota for Umano {
    fn vola(&self) {
        println!("Qui parla il capitano.");
    }
}

impl Mago for Umano {
    fn vola(&self) {
        println!("Sali!");
    }
}

impl Umano {
    fn vola(&self) {
        println!("*sbatte furiosamente le braccia*");
    }
}

fn main() {
    let persona = Umano;
    persona.fly();
}
Listato 20-18: Chiamare vola su un’istanza di Umano

Questo codice stampa *sbatte furiosamente le braccia*, mostrando che Rust chiama il metodo vola definito direttamente su Umano.

Per chiamare i metodi vola dal trait Pilota o Mago serve una sintassi più esplicita per indicare quale metodo si intende. Il Listato 20-19 mostra questa sintassi.

File: src/main.rs
trait Pilota {
    fn vola(&self);
}

trait Mago {
    fn vola(&self);
}

struct Umano;

impl Pilota for Umano {
    fn vola(&self) {
        println!("Qui parla il capitano.");
    }
}

impl Mago for Umano {
    fn vola(&self) {
        println!("Sali!");
    }
}

impl Umano {
    fn vola(&self) {
        println!("*sbatte furiosamente le braccia*");
    }
}

fn main() {
    let persona = Umano;
    Pilota::vola(&persona);
    Mago::vola(&persona);
    persona.vola();
}
Listato 20-19: Specificare quale metodo vola di quale trait si vuole chiamare

Specificare il nome del trait prima del metodo chiarisce a Rust quale implementazione di vola vogliamo chiamare. Possiamo anche scrivere Umano::vola(&persona), che è equivalente a person.vola(), ma è più verboso se non serve disambiguare.

Questo codice stampa:

$ cargo run
   Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.07s
     Running `target/debug/esempio-trait`
Qui parla il capitano.
Sali!
*sbatte furiosamente le braccia*

Poiché il metodo vola prende self come parametro, se avessimo due type che implementano entrambi un trait, Rust potrebbe inferire quale implementazione del trait usare in base al type di self.

Tuttavia, le funzioni associate che non sono metodi non hanno self. Quando ci sono più type o trait che definiscono funzioni con lo stesso nome, Rust non sa sempre quale type intendi, a meno che non si usi la sintassi completamente qualificata. Per esempio, nel Listato 20-20 creiamo un trait per un rifugio per animali che vuole chiamare tutti i cuccioli di cane Rex. Realizziamo un trait Animale con una funzione associata chiamata nomignolo. Il trait Animale è implementato per la struct Cane, sulla quale definiamo una funzione associata nomignolo.

File: src/main.rs
trait Animale {
    fn nomignolo() -> String;
}

struct Cane;

impl Cane {
    fn nomignolo() -> String {
        String::from("Rex")
    }
}

impl Animale for Cane {
    fn nomignolo() -> String {
        String::from("cucciolo")
    }
}

fn main() {
    println!("Un piccolo di cane è detto {}", Cane::nomignolo());
}
Listato 20-20: Trait con funzione associata e type con funzione associata dello stesso nome che implementa il trait

Implementiamo il codice che chiama tutti i cuccioli Rex nella funzione associata nomignolo definita direttamente in Cane. Il type Cane implementa anche il trait Animale, che descrive caratteristiche comuni a tutti gli animali. I piccoli di cane sono chiamati cuccioli, e questo è espresso nell’implementazione del trait Animale per Cane nella funzione nomignolo associata al trait Animale.

In main, chiamando Cane::nomignolo chiamiamo la funzione associata definita direttamente su Cane. Questo codice stampa:

$ cargo run
   Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 5.10s
     Running `target/debug/esempio-trait`
Un piccolo di cane è detto Rex

Questo output non è quello voluto: vogliamo chiamare la funzione nomignolo del trait Animale implementato su Cane così che il codice stampi Un piccolo di cane è detto cucciolo. La tecnica di specificare il nome del trait come nel Listato 20-19 non aiuta; se cambiamo main con il codice del Listato 20-21, otterremo un errore di compilazione.

File: src/main.rs
trait Animale {
    fn nomignolo() -> String;
}

struct Cane;

impl Cane {
    fn nomignolo() -> String {
        String::from("Rex")
    }
}

impl Animale for Cane {
    fn nomignolo() -> String {
        String::from("cucciolo")
    }
}

fn main() {
    println!("Un piccolo di cane è detto {}", Animale::nomignolo());
}
Listato 20-21: Tentativo di chiamare la funzione nomignolo del trait Animale, ma Rust non sa quale implementazione usare

Poiché Animale::nomignolo non ha il parametro self, e potrebbero esserci altri type che implementano il trait Animale, Rust non riesce a inferire quale implementazione di Animale::nomignolo usare. Otterremo questo errore del compilatore:

$ cargo run
   Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:21:47
   |
 2 |     fn nomignolo() -> String;
   |     ------------------------- `Animale::nomignolo` defined here
...
21 |     println!("Un piccolo di cane è detto {}", Animale::nomignolo());
   |                                               ^^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
21 |     println!("Un piccolo di cane è detto {}", <Cane as Animale>::nomignolo());
   |                                               ++++++++        +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `esempio-trait` (bin "esempio-trait") due to 1 previous error

Per disambiguare e indicare a Rust che vogliamo usare l’implementazione del trait Animale per Cane anziché quella per un altro type, usiamo la sintassi completamente qualificata. Il Listato 20-22 mostra come.

File: src/main.rs
trait Animale {
    fn nomignolo() -> String;
}

struct Cane;

impl Cane {
    fn nomignolo() -> String {
        String::from("Rex")
    }
}

impl Animale for Cane {
    fn nomignolo() -> String {
        String::from("cucciolo")
    }
}

fn main() {
    println!("Un piccolo di cane è detto {}", <Cane as Animale>::nomignolo());
}
Listato 20-22: Uso della sintassi completamente qualificata per chiamare la funzione nomignolo del trait Animale implementato su Cane

Forniamo un’annotazione di type tra parentesi angolari < > per dire a Rust che vogliamo chiamare il metodo nomignolo del trait Animale implementato su Cane, trattando il type Cane come un type Animale per questa chiamata. Ora il codice stampa quello che vogliamo:

$ cargo run
   Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.11s
     Running `target/debug/esempio-trait`
Un piccolo di cane è detto cucciolo

In generale la sintassi completamente qualificata è:

<Tipo as Trait>::funzione(ricevente_se_metodo, prossimo_argomento,...);

Per funzioni associate che non sono metodi, non c’è un parametro self, solo la lista degli altri argomenti. Puoi usare la sintassi completamente qualificata ovunque chiami funzioni o metodi. Tuttavia, puoi omettere parti che Rust può dedurre dal contesto. Devi usarla solo quando ci sono più implementazioni con lo stesso nome e Rust ha bisogno di aiuto per distinguere quale usare.

Usare Supertrait

A volte puoi scrivere un trait che dipende da un altro trait: per un type che implementa il primo trait, richiedi che implementi anche il secondo trait. Lo fai perché la definizione del trait può usare gli elementi associati del secondo. Il trait da cui il trait che implementi dipende viene chiamato supertrait del tuo trait.

Per esempio, supponiamo di voler creare un trait StampaContorno con un metodo stampa_contorno che stampa un valore delimitato da un contorno di asterischi. Data una struct Punto che implementa il trait Display della libreria standard per mostrare (x, y), quando chiami stampa_contorno su un’istanza di Punto con x=1 e y=3, dovrebbe stampare:

**********
*        *
* (1, 3) *
*        *
**********

Nell’implementazione di stampa_contorno vogliamo usare la funzionalità del trait Display. Quindi il trait StampaContorno dovrebbe funzionare solo per type che implementano anche Display. Lo specifichiamo nella definizione con StampaContorno: Display. Questo è simile ad aggiungere un vincolo di trait al trait in questione. Il Listato 20-23 mostra un implementazione del trait StampaContorno.

File: src/main.rs
use std::fmt;

trait StampaContorno: fmt::Display {
    fn stampa_contorno(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}
Listato 20-23: Implementazione del trait StampaContorno che richiede la funzionalità di Display

Dichiarando che StampaContorno richiede il trait Display, possiamo usare la funzione to_string che è implementata automaticamente su tutti i type che implementano Display. Se provassimo a usare to_string senza specificare Display, otterremmo un errore perché il metodo to_string non sarebbe trovato per il type &Self nello scope corrente.

Vediamo cosa succede se provassimo a implementare StampaContorno su un type che non implementa Display come la struct Punto:

File: src/main.rs
use std::fmt;

trait StampaContorno: fmt::Display {
    fn stampa_contorno(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Punto {
    x: i32,
    y: i32,
}

impl StampaContorno for Punto {}

fn main() {
    let p = Punto { x: 1, y: 3 };
    p.stampa_contorno();
}

Riceveremmo un errore che dice che Display è richiesto ma non implementato:

$ cargo run
   Compiling esempio-trait v0.1.0 (file:///progetti/esempio-trait)
error[E0277]: `Punto` doesn't implement `std::fmt::Display`
  --> src/main.rs:21:25
   |
21 | impl StampaContorno for Punto {}
   |                         ^^^^^ `Punto` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Punto`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `StampaContorno`
  --> src/main.rs:3:23
   |
3  | trait StampaContorno: fmt::Display {
   |                       ^^^^^^^^^^^^ required by this bound in `StampaContorno`

error[E0277]: `Punto` doesn't implement `std::fmt::Display`
  --> src/main.rs:26:7
   |
26 |     p.stampa_contorno();
   |       ^^^^^^^^^^^^^^^ `Punto` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Punto`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `StampaContorno::stampa_contorno`
  --> src/main.rs:3:23
   |
3  | trait StampaContorno: fmt::Display {
   |                       ^^^^^^^^^^^^ required by this bound in `StampaContorno::stampa_contorno`
4  |     fn stampa_contorno(&self) {
   |        --------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `esempio-trait` (bin "esempio-trait") due to 2 previous errors

Lo risolviamo implementando Display su Punto e soddisfiamo le necessità di StampaContorno:

File: src/main.rs
trait StampaContorno: fmt::Display {
    fn stampa_contorno(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Punto {
    x: i32,
    y: i32,
}

impl StampaContorno for Punto {}

use std::fmt;

impl fmt::Display for Punto {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Punto { x: 1, y: 3 };
    p.stampa_contorno();
}

A questo punto l’implementazione di StampaContorno per Punto si compila correttamente e possiamo chiamare stampa_contorno su un’istanza di Punto per stamparlo con un contorno di asterischi.

Implementare Trait Esterni con il Modello Newtype

In “Implementare un Trait su un Type nel Capitolo 10 abbiamo parlato della orphan rule che dice che possiamo implementare un trait su un type solo se il trait o il type (o entrambi) sono locali al crate. Si può aggirare questa restrizione usando il modello newtype, che consiste nel creare un nuovo type come struct tupla. (Ne abbiamo già parlato in “Creare Type Diversi con Struct Tupla” del Capitolo 5.) La struct tupla avrà un solo campo e sarà un “incapsulamento sottile” attorno al type su cui vuoi implementare un trait. L’incapsulamento è locale al crate e puoi implementare il trait sull’incapsulatore. La parola newtype deriva dal linguaggio Haskell. Non c’è alcuna penalità in prestazioni nell’uso di questo modello, e il type dell’involucro viene eliso in fase di compilazione.

Per esempio, supponiamo di voler implementare Display su Vec<T>, cosa che la orphan rule ci impedisce perché sia il trait Display che il type Vec<T> sono definiti fuori dal nostro crate. Possiamo invece creare una struct Capsula che contiene un’istanza di Vec<T>, poi implementare Display su Capsula e usare il valore Vec<T> all’interno, come mostrato nel Listato 20-24.

File: src/main.rs
use std::fmt;

struct Capsula(Vec<String>);

impl fmt::Display for Capsula {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Capsula(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}
Listato 20-24: Creazione di un type Capsula attorno a Vec<String> per implementare Display

L’implementazione di Display usa self.0 per accedere a Vec<T> perché Capsula è una struct tupla e Vec<T> è il campo all’indice 0 della tupla. Così possiamo usare la funzionalità del trait Display su Capsula.

Lo svantaggio di questa tecnica è che Capsula è un nuovo type e non ha i metodi del valore che incapsula. Dovresti implementare manualmente tutti i metodi di Vec<T> in Capsula, in modo che i metodi deleghino a self.0, per poter usare Capsula come fosse effettivamente un Vec<T>. Se volessimo che il nuovo type abbia tutti i metodi del type interno, implementare il trait Deref su Capsula per restituire il type interno potrebbe essere una soluzione (abbiamo parlato dell’implementazione di Deref in “Trattare i Puntatori Intelligenti Come Normali Reference in Chapter 15). Se invece non vogliamo che Capsula abbia tutti i metodi del type interno, ad esempio per restringerne le funzionalità, dovremmo implementare i metodi che vogliamo manualmente.

Il modello newtype è utile anche quando non si tratta di trait. Passiamo ora a delle tecniche avanzate per interagire col sistema dei type di Rust.

Type Avanzati

Il sistema dei type di Rust ha alcune caratteristiche che abbiamo già menzionato ma non ancora discusso. Inizieremo parlando dei newtype in generale, esaminando perché i newtype sono utili come type. Poi passeremo agli alias di type, una caratteristica simile ai newtype ma con una semantica leggermente diversa. Discuteremo anche del type ! e dei type a dimensione dinamica.

Sicurezza dei Type e Astrazione Con il Modello Newtype

Questa sezione presuppone che tu abbia letto la sezione precedente “Implementare Trait Esterni con il Modello Newtype. Il modello newtype è utile anche per compiti oltre quelli che abbiamo già discusso, tra cui far rispettare staticamente che i valori non vengano confusi e indicare le unità di misura di un valore. Hai visto un esempio dell’uso dei newtype per indicare unità di misura nel Listato 20-16: ricorda che le struct Millimetri e Metri incapsulavano valori u32 come newtype. Se scrivessimo una funzione con un parametro di type Millimetri, non potremmo compilare un programma che accidentalmente provasse a chiamare quella funzione con un valore di type Metri o con un semplice u32.

Possiamo anche usare il modello newtype per astrarre alcuni dettagli di implementazione di un type: il nuovo type può esporre una API pubblica diversa dall’API del type interno privato.

I newtype possono anche nascondere l’implementazione interna. Ad esempio, potremmo fornire un type Persone per incapsulare un HashMap<i32, String> che associa l’ID di una persona al nome. Il codice che usa Persone interagirebbe solo con l’API pubblica che definiamo, ad esempio un metodo per aggiungere un nome alla collezione Persone; quel codice non avrebbe bisogno di sapere che internamente associamo un ID i32 ai nomi. Il modello newtype è un modo leggero per ottenere l’incapsulamento per nasconde dettagli di implementazione, come abbiamo discusso in “Incapsulamento che Nasconde i Dettagli di Implementazione” nel Capitolo 18.

Sinonimi e Alias di Type

Rust permette di dichiarare un alias di type per dare a un type esistente un altro nome. Per fare questa cosa usiamo la parola chiave type. Ad esempio, possiamo creare l’alias Chilometri per i32 così:

fn main() {
    type Chilometri = i32;

    let x: i32 = 5;
    let y: Chilometri = 5;

    println!("x + y = {}", x + y);
}

Ora Chilometri è un sinonimo di i32; a differenza dei type Millimetri e Metri che abbiamo creato nel Listato 20-16, Chilometri non è un type distinto e separato. I valori di type Chilometri saranno trattati allo stesso modo di quelli di type i32:

fn main() {
    type Chilometri = i32;

    let x: i32 = 5;
    let y: Chilometri = 5;

    println!("x + y = {}", x + y);
}

Poiché Chilometri e i32 sono lo stesso type, possiamo sommare valori di entrambi i type e possiamo passare valori di type Chilometri a funzioni che accettano parametri i32. Tuttavia, usando questo metodo non otteniamo i benefici di controllo dei type che otteniamo con il modello newtype discusso prima. In altre parole, se confondiamo valori di type Chilometri e i32 da qualche parte, il compilatore non ci darà errore.

Il caso d’uso principale per i sinonimi di type è ridurre la ripetizione. Ad esempio, potremmo avere una definizione di type un po’ lunga:

Box<dyn Fn() + Send + 'static>

Scrivere questo type lungo nelle firme delle funzioni e come annotazioni di type in tutto il codice può essere verboso e soggetto a errori. Immagina un progetto pieno di codice come quello del Listato 20-25.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("ciao"));

    fn prende_type_lungo(f: Box<dyn Fn() + Send + 'static>) {
        // --taglio--
    }

    fn ritorna_type_lungo() -> Box<dyn Fn() + Send + 'static> {
        // --taglio--
        Box::new(|| ())
    }
}
Listato 20-25: Uso di un type lungo in molti posti

Un alias di type rende questo codice più gestibile riducendo la ripetizione. Nel Listato 20-26, abbiamo introdotto un alias chiamato Thunk per il type verboso e possiamo sostituire tutti gli usi di quel type con l’alias più corto Thunk.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn prende_type_lungo(f: Thunk) {
        // --taglio--
    }

    fn ritorna_type_lungo() -> Thunk {
        // --taglio--
        Box::new(|| ())
    }
}
Listato 20-26: Introduzione di un alias di type, Thunk, per ridurre la ripetizione

Questo codice è molto più facile da leggere e scrivere! Scegliere un nome significativo per un alias di type può anche aiutare a comunicare chiaramente la nostra intenzione (thunk è una parola tecnica usata per indicare codice che verrà eseguito e valutato in un secondo momento, quindi è un nome appropriato per una closure che viene memorizzata).

Gli alias di type sono anche comunemente usati con il type Result<T, E> per ridurre la ripetizione. Considera il modulo std::io nella libreria standard. Le operazioni I/O spesso restituiscono un Result<T, E> per gestire le situazioni in cui le operazioni possono fallire. Questa libreria ha una struct std::io::Error che rappresenta tutti i possibili errori di I/O. Molte funzioni in std::io restituiscono un Result<T, E> dove E è std::io::Error, come nelle funzioni del trait Write:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Il Result<..., Error> si ripete molto. Per questo motivo, std::io ha questa dichiarazione di alias di type:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Poiché questa dichiarazione è nel modulo std::io, possiamo usare l’alias completamente qualificato std::io::Result<T>; cioè, un Result<T, E> con E riempito come std::io::Error. Le firme delle funzioni del trait Write diventano così:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

L’alias di type aiuta in due modi: rende il codice più facile da scrivere e leggere e ci fornisce un’interfaccia coerente in tutto std::io. Poiché è un alias, è solo un altro Result<T, E>, il che significa che possiamo usare tutti i metodi che funzionano su Result<T, E>, oltre alla sintassi speciale come l’operatore ?.

Il Type Never Che Non Ritorna Mai

Rust ha un type speciale chiamato ! che in gergo di teoria dei type è chiamato type vuoto perché non ha valori. Preferiamo chiamarlo type never (type mai) perché rappresenta il type di ritorno di una funzione che non restituirà mai nulla. Ecco un esempio:

fn bar() -> ! {
    // --taglio--
    panic!();
}

Questo codice significa “la funzione bar non restituirà mai”. Le funzioni che non restituiscono mai sono chiamate funzioni divergenti. Non possiamo creare valori di type !, per cui bar non potrà mai restituirli.

Ma a cosa serve un type per cui non si possono creare valori? Ricorda il codice del Listato 2-5, parte del gioco degli indovinelli; ne riproduciamo un pezzo qui nel Listato 20-27.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Indovina il numero!");

    let numero_segreto = rand::thread_rng().gen_range(1..=100);

    println!("Il numero segreto è: {numero_segreto}");

    loop {
        println!("Inserisci la tua ipotesi.");

        let mut ipotesi = String::new();

        // --taglio--

        io::stdin()
            .read_line(&mut ipotesi)
            .expect("Errore di lettura");

        let ipotesi: u32 = match ipotesi.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("Hai ipotizzato: {ipotesi}");

        // --taglio--

        match ipotesi.cmp(&numero_segreto) {
            Ordering::Less => println!("Troppo piccolo!"),
            Ordering::Greater => println!("Troppo grande!"),
            Ordering::Equal => {
                println!("Hai indovinato!");
                break;
            }
        }
    }
}
Listato 20-27: Un match con un ramo che finisce con continue

All’epoca abbiamo trascurato alcuni dettagli di questo codice. In “Controllare il Flusso con il costrutto match nel Capitolo 6 abbiamo spiegato che tutti i rami di un match devono ritornare lo stesso type. Quindi, per esempio, il seguente codice non funziona:

fn main() {
    let ipotesi = "3";
    let ipotesi = match ipotesi.trim().parse() {
        Ok(_) => 5,
        Err(_) => "ciao",
    };
}

Il type di ipotesi in questo codice dovrebbe essere un intero e una stringa, e Rust richiede che ipotesi sia di un solo type. Allora cosa ritorna continue? Come facciamo a ritornare un u32 da un ramo e avere un altro ramo che termina con continue nel Listato 20-27?

Come avrai intuito, continue ha un valore di type !. Questo significa che quando Rust calcola il type di ipotesi, guarda entrambi i rami: il primo con valore u32 e il secondo con valore !. Poiché ! non può avere un valore, Rust decide che il type di ipotesi è u32.

Il modo formale di descrivere questo comportamento è che le espressioni di type ! possono essere forzate in qualsiasi altro type. È permesso terminare questo ramo di match con continue perché continue non restituisce un valore; invece, sposta il controllo in cima al ciclo, quindi nel caso di Err non assegniamo mai un valore a ipotesi.

Il type never è utile anche con la macro panic!. Ricorda la funzione unwrap che chiamiamo sui valori Option<T> per ottenere un valore o fare panic, con questa definizione:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("chiamato `Option::unwrap()` su un valore `None`"),
        }
    }
}

In questo codice, succede la stessa cosa vista nel match del Listato 20-27: Rust vede che val ha type T e panic! ha type !, quindi il risultato dell’espressione complessiva match è T. Questo codice funziona perché panic! non produce un valore; termina il programma. Nel caso None non ritorneremo un valore da unwrap, quindi questo codice è valido.

Un’ultima espressione che ha type ! è un loop:

fn main() {
    print!("sempre ");

    loop {
        print!("e per sempre ");
    }
}

Qui il loop non termina mai, per cui il valore dell’espressione è !. Questo non varrebbe se usassimo un break perché il ciclo terminerebbe quando incontrasse break.

Type a Dimensione Dinamica e il Trait Sized

Rust ha bisogno di conoscere alcune informazioni sui type, tipo quanto spazio allocare per un valore di quel type. Questo rende un po’ complicato il concetto di type a dimensione dinamica_. Detti anche DST o type non dimensionati, consentono di scrivere codice che usa valori la cui dimensione è nota solo in fase di esecuzione.

Parliamo nel dettaglio di un type a dimensione dinamica chiamato str, che abbiamo usato spesso nel libro. Proprio così, non &str ma str da solo è un DST. In molti casi, come nel caso di stringhe inserite da un utente, non possiamo sapere la lunghezza della stringa a priori se non durante l’esecuzione. Questo significa che non possiamo creare una variabile di type str, né ricevere un argomento di type str. Considera il seguente codice, che non funziona:

fn main() {
    let s1: str = "Ciao!";
    let s2: str = "Come va?";
}

Rust deve sapere quanto spazio allocare per un valore di un qualsiasi type e tutti i valori di quel type devono occupare la stessa quantità di memoria. Se Rust ci permettesse di scrivere questo codice, quei due valori str dovrebbero occupare la stessa quantità di spazio, ma hanno lunghezze diverse: s1 necessita di 12 byte, s2 di 15. Ecco perché non è possibile creare una variabile di type str.

Quindi cosa facciamo? La risposta la conosci già: cambiamo i type di s1 e s2 da str a &str. Ricorda da Slice di Stringa” nel Capitolo 4 che la struttura dati slice memorizza solo l’indirizzo di partenza e la lunghezza della slice. Perciò, anche se un &T è un singolo valore che memorizza l’indirizzo di memoria di T, un &str è due valori: l’indirizzo di str e la sua lunghezza. Perciò sappiamo sempre la dimensione statica di un valore &str: è doppia rispetto alla lunghezza di un usize. E quindi, conosciamo sempre la dimensione di una &str indipendentemente dalla lunghezza della stringa. In generale, questo è il modo in cui si usano i type dimensionati dinamicamente in Rust: hanno un pezzettino di metadati in più per memorizzare la dimensione dell’informazione dinamica. La regola d’oro dei DST è che dobbiamo sempre mettere valori di type dimensionato dinamicamente dietro a qualche tipo di puntatore.

Possiamo combinare str con tanti type di puntatori: ad esempio, Box<str> o Rc<str>. Hai già visto questo ma con un altro type a dimensione dinamica: i trait. Ogni trait è un type dimensionato dinamicamente che può essere indicato usando il nome del trait. In “Usare Oggetti Trait per Astrarre Comportamenti Condivisi” nel Capitolo 18 abbiamo menzionato che per usare trait come oggetti trait dobbiamo metterli dietro a un puntatore, come &dyn Trait o Box<dyn Trait> (anche Rc<dyn Trait> andrebbe bene).

Per lavorare con i DST, Rust fornisce il trait Sized, che determina se la dimensione di un type è nota a tempo di compilazione. Questo trait è implementato automaticamente per tutto ciò che ha dimensione nota a compilazione. Inoltre, Rust aggiunge implicitamente un vincolo su Sized per ogni funzione generica: questo vuol dire che la definizione di una funzione generica come questa:

fn generica<T>(t: T) {
    // --taglio--
}

viene trattata come se fosse scritta così:

fn generica<T: Sized>(t: T) {
    // --taglio--
}

Per default, le funzioni generiche funzionano solo su type con dimensione nota a tempo di compilazione. Però puoi usare questa sintassi speciale per allentare questa restrizione:

fn generica<T: ?Sized>(t: &T) {
    // --taglio--
}

Un vincolo ?Sized significa “T può essere o no di dimensione fissa” e questa notazione sovrascrive il comportamento predefinito che i generici debbano avere dimensione nota a tempo di compilazione. La sintassi ?Trait con questo significato è disponibile solo per Sized e non per altri trait.

Nota anche che abbiamo cambiato il type del parametro t da T a &T. Poiché il type potrebbe non essere Sized, dobbiamo usarlo dietro a un qualche tipo di puntatore. In questo caso usiamo un reference.

Ed ora, continuiamo parlando di funzioni e chiusure!

Funzioni e Chiusure Avanzate

Questa sezione esplora alcune funzionalità avanzate legate a funzioni e chiusure, inclusi i puntatori a funzione e il ritorno di chiusure.

Puntatori a Funzione

Abbiamo parlato di come passare chiusure alle funzioni; puoi anche passare funzioni normali a funzioni! Questa tecnica è utile quando vuoi passare una funzione che hai già definito piuttosto che definire una nuova chiusura. Le funzioni si convertono automaticamente al type fn (con la f minuscola), da non confondere con il trait Fn delle chiusure. Il type fn è chiamato puntatore a funzione. Passare funzioni con puntatori a funzione ti permette di utilizzare funzioni come argomenti per altre funzioni.

La sintassi per specificare che un parametro è un puntatore a funzione è simile a quella delle chiusure, come mostrato nel Listato 20-28, dove abbiamo definito una funzione più_uno che aggiunge 1 al suo parametro. La funzione due_volte prende due parametri: un puntatore a funzione verso qualsiasi funzione che prende un parametro i32 e ritorna un i32, e un valore i32. La funzione due_volte chiama la funzione f due volte, passando il valore arg, poi somma i risultati delle due chiamate. La funzione main chiama due_volte con gli argomenti più_uno e 5.

File: src/main.rs
fn più_uno(x: i32) -> i32 {
    x + 1
}

fn due_volte(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let risposta = due_volte(più_uno, 5);

    println!("La risposta è: {risposta}");
}
Listato 20-28: Uso del type fn per accettare un puntatore a funzione come argomento

Questo codice stampa La risposta è: 12. Specifichiamo che il parametro f in due_volte è un fn che prende un parametro di type i32 e ritorna un i32. Possiamo quindi chiamare f nel corpo di due_volte. In main, possiamo passare il nome della funzione più_uno come primo argomento a due_volte.

A differenza delle chiusure, fn è un type, non un trait, quindi specifichiamo fn come type del parametro direttamente piuttosto che dichiarare un type generico con uno dei trait Fn come vincolo.

I puntatori a funzione implementano tutti e tre i trait delle chiusure (Fn, FnMut e FnOnce), il che significa che puoi sempre passare un puntatore a funzione come argomento a una funzione che si aspetta una chiusura. È meglio scrivere funzioni usando un type generico e uno dei trait delle chiusure così che le tue funzioni possano accettare sia funzioni che chiusure.

Detto questo, un esempio in cui potresti voler accettare solo fn e non chiusure è quando ti interfacci con codice esterno che non ha chiusure: le funzioni in C possono accettare funzioni come argomenti, ma C non ha chiusure.

Come esempio di dove potresti usare sia una chiusura definita che una funzione nominata, diamo un’occhiata all’uso del metodo map fornito dal trait Iterator nella libreria standard. Per usare il metodo map per trasformare un vettore di numeri in un vettore di stringhe, potremmo usare una chiusura, come nel Listato 20-29.

fn main() {
    let lista_di_numeri = vec![1, 2, 3];
    let lista_di_stringhe: Vec<String> =
        lista_di_numeri.iter().map(|i| i.to_string()).collect();
}
Listato 20-29: Uso di una chiusura con il metodo map per convertire numeri in stringhe

Oppure potremmo nominare una funzione come argomento di map al posto della chiusura. Il Listato 20-30 mostra come sarebbe.

fn main() {
    let lista_di_numeri = vec![1, 2, 3];
    let lista_di_stringhe: Vec<String> =
        lista_di_numeri.iter().map(ToString::to_string).collect();
}
Listato 20-30: Uso della funzione String::to_string con il metodo map per convertire numeri in stringhe

Nota che dobbiamo usare la sintassi completamente qualificata di cui abbiamo parlato in Trait Avanzati” perché ci sono più funzioni con nome to_string.

Qui usiamo la funzione to_string definita nel trait ToString, che la libreria standard ha implementato per ogni type che implementa Display.

Ricorda da “Valori di Enum nel Capitolo 6 che il nome di ogni variante enum diventa anche una funzione inizializzatrice. Possiamo usare queste funzioni inizializzatrici come puntatori a funzione che implementano i trait delle chiusure, il che significa che possiamo specificare le funzioni inizializzatrici come argomenti per metodi che accettano chiusure, come nel Listato 20-31.

fn main() {
    enum Stato {
        Valore(u32),
        Stop,
    }

    let lista_stati: Vec<Stato> = (0u32..20).map(Stato::Valore).collect();
}
Listato 20-31: Uso di una funzione inizializzatrice di un enum con il metodo map per creare un’istanza Stato dai numeri

Qui creiamo istanze di Stato::Valore usando ogni valore u32 nell’intervallo su cui è chiamato map, usando la funzione inizializzatrice di Stato::Valore. Alcuni preferiscono questo stile, altri preferiscono usare chiusure. Entrambi i metodi si compilano allo stesso modo, quindi usa quello che trovi più chiaro.

Restituire Chiusure

Le chiusure sono rappresentate da trait, il che significa che non puoi restituire direttamente una chiusura. Nella maggior parte dei casi in cui potresti voler ritornare un trait, puoi invece usare il type concreto che implementa il trait come type di ritorno della funzione. Tuttavia, di solito non puoi fare questo con le chiusure perché non hanno un type concreto restituibile; per esempio, non puoi usare il puntatore a funzione fn come type di ritorno se la chiusura cattura qualche valore dal suo scope.

Al contrario, normalmente userai la sintassi impl Trait che abbiamo imparato nel Capitolo 10. Puoi restituire qualsiasi tipo di funzione usando Fn, FnOnce e FnMut. Per esempio, il codice nel Listato 20-32 verrà compilato senza problemi.

#![allow(unused)]
fn main() {
fn ritorna_chiusura() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
Listato 20-32: Restituire una chiusura da una funzione usando la sintassi impl Trait

Tuttavia, come abbiamo notato in “Inferenza e Annotazione del Type delle Chiusure” nel Capitolo 13, ogni chiusura è anche il suo type distinto. Se ti serve lavorare con più funzioni che hanno la stessa firma ma implementazioni diverse, dovrai usare un oggetto trait per loro. Considera cosa succede se scrivi un codice come nel Listato 20-33.

File: src/main.rs
fn main() {
    let handlers = vec![ritorna_chiusura(), ritorna_chiusura_inizializzata(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn ritorna_chiusura() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn ritorna_chiusura_inizializzata(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}
Listato 20-33: Creazione di un Vec<T> di chiusure definite tramite funzioni che restituiscono type impl Fn

Qui abbiamo due funzioni, ritorna_chiusura e ritorna_chiusura_inizializzata, che entrambe ritornano impl Fn(i32) -> i32. Nota che le chiusure restituite sono diverse anche se implementano lo stesso type. Se provi a compilare, Rust ti dice che non funziona:

$ cargo run
   Compiling esempio-funzioni v0.1.0 (file:///progetti/esempio-funzioni)
error[E0308]: mismatched types
  --> src/main.rs:2:45
   |
2  |     let handlers = vec![ritorna_chiusura(), ritorna_chiusura_inizializzata(123)];
   |                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9  | fn ritorna_chiusura() -> impl Fn(i32) -> i32 {
   |                          ------------------- the expected opaque type
...
13 | fn ritorna_chiusura_inizializzata(init: i32) -> impl Fn(i32) -> i32 {
   |                                                 ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:9:26>)
              found opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:13:49>)
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.
error: could not compile `esempio-funzioni` (bin "esempio-funzioni") due to 1 previous error

Il messaggio di errore dice che ogni volta che ritorni un impl Trait, Rust crea un type opaco univoco, un type di cui non possiamo conoscere i dettagli di come Rust l’ha costruito né sapere il type generato. Quindi anche se queste funzioni ritornano chiusure che implementano lo stesso trait, i type opachi che Rust genera sono diversi. (Questo è simile a come Rust genera type concreti distinti per blocchi async diversi anche se hanno lo stesso type di output, come abbiamo visto in “Lavorare con un Numero Qualsiasi di Future nel Capitolo 17.) Abbiamo già visto una soluzione a questo problema: possiamo usare un oggetto trait, come nel Listato 20-34.

fn main() {
    let handlers = vec![ritorna_chiusura(), ritorna_chiusura_inizializzata(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn ritorna_chiusura() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn ritorna_chiusura_inizializzata(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}
Listato 20-34: Creazione di un Vec<T> di chiusure definite tramite funzioni che ritornano Box<dyn Fn>

Questo codice si compila correttamente. Per più informazioni sugli oggetti trait, vedi la sezione “Usare gli Oggetti Trait per Astrarre Comportamenti Condivisi” nel Capitolo 18.

Passiamo ora a vedere le macro!

Macro

Abbiamo usato macro come println! in tutto il libro, ma non abbiamo ancora esplorato appieno cosa sia una macro e come funzioni. Il termine macro si riferisce a una famiglia di funzionalità in Rust: le macro dichiarative con macro_rules!, e tre tipi di macro procedurali:

  • Macro #[derive] personalizzate che specificano codice aggiunto con l’attributo derive usato su struct ed enum.
  • Macro simil-attributo che definiscono attributi personalizzati usabili su qualsiasi elemento.
  • Macro simil-funzione che sembrano chiamate di funzione ma operano sui token specificati come argomento.

Parleremo di ciascuna di queste a turno, ma prima vediamo perché abbiamo bisogno delle macro se abbiamo già le funzioni.

Differenza Tra Macro e Funzioni

Fondamentalmente, le macro sono un modo di scrivere codice che scrive altro codice, noto come meta-programmazione. Nell’Appendice C parliamo dell’attributo derive, che genera per te l’implementazione di vari trait. Abbiamo anche usato le macro println! e vec! in tutto il libro. Tutte queste macro espandono il codice, producendo più codice di quello scritto manualmente.

La meta-programmazione è utile per ridurre la quantità di codice da scrivere e mantenere, che è uno degli scopi delle funzioni. Tuttavia, le macro hanno poteri aggiuntivi che le funzioni non hanno.

La firma di una funzione deve dichiarare il numero ed il type dei parametri. Le macro, invece, possono accettare un numero variabile di parametri: possiamo chiamare println!("ciao") con un argomento o println!("ciao {}", nome) con due. Inoltre, le macro sono espanse prima che il compilatore interpreti il codice, quindi una macro può, ad esempio, implementare un trait su un type. Una funzione non può farlo, perché viene chiamata durante l’esecuzione e i trait devono essere implementati durante la compilazione.

Lo svantaggio delle macro rispetto alle funzioni è che definire macro è più complesso, perché stai scrivendo codice Rust che scrive codice Rust. Per questo, definire macro è generalmente più difficile da leggere, capire e mantenere rispetto alle funzioni.

Un’altra differenza importante è che devi definire o importare le macro prima di usarle in un file, mentre le funzioni possono essere definite e chiamate ovunque.

Macro dichiarative per la metaprogrammazione generale

La forma di macro più diffusa in Rust è la macro dichiarativa. A volte queste sono anche chiamate “macro per esempio”, “macro macro_rules!” o semplicemente “macro”. Nel loro nucleo, le macro dichiarative permettono di scrivere qualcosa di simile a un’espressione match di Rust. Come discusso nel Capitolo 6, le espressioni match sono strutture di controllo che prendono un’espressione, confrontano il valore risultante con dei pattern, e quindi eseguono il codice associato al pattern corrispondente. Le macro confrontano anch’esse un valore con dei pattern associati a codice particolare: in questa situazione, il valore è il codice sorgente letterale di Rust passato alla macro; i pattern sono confrontati con la struttura di quel codice sorgente; e il codice associato a ciascun pattern, quando corrisponde, sostituisce il codice passato alla macro. Tutto ciò accade durante la compilazione.

Per definire una macro, si usa il costrutto macro_rules!. Esploriamo come utilizzare macro_rules! osservando come viene definita la macro vec!. Il Capitolo 8 ha trattato come possiamo usare la macro vec! per creare un nuovo vettore con valori particolari. Per esempio, la seguente macro crea un nuovo vettore contenente tre interi:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

Potremmo anche usare la macro vec! per creare un vettore di due interi o un vettore di cinque stringhe. Non potremmo usare una funzione per fare lo stesso perché non sapremmo a priori il numero o il tipo di valori.

Il Listato 20-35 mostra una definizione leggermente semplificata della macro vec!.

File: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Listato 20-35: Una versione semplificata della definizione della macro vec!

Nota: La definizione reale della macro vec! nella libreria standard include codice per pre-allocare la quantità corretta di memoria anticipatamente. Quel codice è un’ottimizzazione che non includiamo qui, per rendere l’esempio più semplice.

L’annotazione #[macro_export] indica che questa macro deve essere resa disponibile ogni volta che il crate in cui è definita la macro viene incluso nello scope. Senza questa annotazione, la macro non può essere portata nello scope.

Iniziamo quindi la definizione della macro con macro_rules! e il nome della macro che stiamo definendo senza il punto esclamativo. Il nome, in questo caso vec, è seguito da parentesi graffe che indicano il corpo della definizione della macro.

La struttura nel corpo di vec! è simile alla struttura di un’espressione match. Qui abbiamo un ramo con il pattern ( $( $x:expr ),* ), seguito da => e dal blocco di codice associato a questo pattern. Se il pattern corrisponde, il blocco di codice associato verrà espanso. Poiché questo è l’unico pattern in questa macro, c’è solo un modo valido per fare match; qualsiasi altro pattern porterà a un errore. Macro più complesse avranno più rami.

La sintassi valida dei pattern nelle definizioni di macro è diversa dalla sintassi dei pattern trattata nel Capitolo 19 perché i pattern delle macro vengono confrontati con la struttura del codice Rust piuttosto che con valori. Vediamo cosa significano le parti del pattern nel Listato 20-35; per la sintassi completa dei pattern nelle macro, consultare il Rust Reference.

Prima usiamo una coppia di parentesi per racchiudere tutto il pattern. Usiamo un segno del dollaro ($) per dichiarare una variabile nel sistema macro che conterrà il codice Rust che corrisponde al pattern. Il segno del dollaro rende chiaro che questa è una variabile macro e non una variabile Rust normale. Poi viene una coppia di parentesi che cattura i valori che corrispondono al pattern dentro le parentesi per l’uso nel codice di sostituzione. Dentro $() c’è $x:expr, che corrisponde a qualsiasi espressione Rust e assegna il nome $x a quell’espressione.

La virgola che segue $() indica che deve apparire un carattere letterale di separazione virgola tra ogni istanza del codice che corrisponde al codice in $(). L’asterisco * specifica che il pattern corrisponde a zero o più occorrenze di qualunque cosa preceda l’asterisco.

Quando chiamiamo questa macro con vec![1, 2, 3];, il pattern $x fa match tre volte con le tre espressioni 1, 2 e 3.

Ora vediamo il pattern nel corpo del codice associato a questo ramo: temp_vec.push() dentro $()* viene generato per ciascuna parte che corrisponde a $() nel pattern da zero a più volte a seconda di quante volte il pattern fa match. Il $x viene sostituito con ogni espressione trovata. Quando chiamiamo questa macro con vec![1, 2, 3];, il codice generato che sostituisce questa chiamata di macro sarà il seguente:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

Abbiamo definito una macro che può prendere qualsiasi numero di argomenti di qualsiasi type e può generare codice per creare un vettore contenente gli elementi specificati.

Per imparare di più su come scrivere macro, consultare la documentazione online o altre risorse, come “The Little Book of Rust Macros” iniziato da Daniel Keep e continuato da Lukas Wirth.

Macro Procedurali Per Generare Codice da Attributi

La seconda forma di macro è la macro procedurale, che si comporta più come una funzione (e è un tipo di procedura). Le macro procedurali accettano del codice come input, operano su quel codice, e producono del codice come output, invece di fare match su dei pattern e sostituire il codice con altro codice come fanno le macro dichiarative. Le tre tipologie di macro procedurali sono derive personalizzate, macro simil-attributi, e macro simil-funzioni, e tutte funzionano in modo simile.

Quando si creano macro procedurali, le definizioni devono risiedere in un proprio crate con un tipo speciale di crate. Questo per ragioni tecniche complesse che si spera di eliminare in futuro. Nel Listato 20-36 mostriamo come definire una macro procedurale, dove qualche_attributo è un segnaposto per l’uso di una specifica varietà di macro.

File: src/lib.rs
use proc_macro;

#[qualche_attributo]
pub fn qualche_attributo(input: TokenStream) -> TokenStream {
}
Listato 20-36: Un esempio di definizione di una macro procedurale

La funzione che definisce una macro procedurale prende un TokenStream come input e produce un TokenStream come output. Il type TokenStream è definito dal crate proc_macro incluso in Rust e rappresenta una sequenza di token. Questo è il nucleo della macro: il codice sorgente su cui la macro opera costituisce il TokenStream di input, e il codice che la macro produce è il TokenStream di output. La funzione ha anche un attributo che specifica quale tipo di macro procedurale stiamo creando. Possiamo avere più tipi di macro procedurali nello stesso crate.

Vediamo le diverse tipologie di macro procedurali. Inizieremo con una macro derive personalizzata per poi spiegare le piccole differenze che caratterizzano le altre forme.

Macro derive Personalizzate

Creiamo un crate chiamato ciao_macro che definisce un trait chiamato CiaoMacro con una funzione associata chiamata ciao_macro. Invece di far implementare agli utenti il trait CiaoMacro per ogni loro type, forniremo una macro procedurale che consente agli utenti di annotare il loro type con #[derive(CiaoMacro)] per ottenere un’implementazione di default della funzione ciao_macro. L’implementazione di default stamperà Ciao, Macro! Il mio nome è NomeType! dove NomeType è il nome del type su cui il trait è stato definito. In altre parole, scriveremo un crate che permette a un altro programmatore di scrivere codice come nel Listato 20-37 usando il nostro crate.

File: src/main.rs
use ciao_macro::CiaoMacro;
use ciao_macro_derive::CiaoMacro;

#[derive(CiaoMacro)]
struct Pancake;

fn main() {
    Pancake::ciao_macro();
}
Listato 20-37: Il codice che un utente del nostro crate potrà scrivere usando la nostra macro procedurale

Questo codice stamperà Ciao, Macro! Il mio nome è Pancake! quando sarà eseguito. Il primo passo è creare un nuovo crate libreria come segue:

$ cargo new ciao_macro --lib

Successivamente, nel Listato 20-38, definiremo il trait CiaoMacro e la sua funzione associata.

File: src/lib.rs
pub trait CiaoMacro {
    fn ciao_macro();
}
Listato 20-38: Un semplice trait che useremo con la macro derive

Abbiamo un trait e la sua funzione. A questo punto, l’utente del nostro crate potrebbe implementare il trait per ottenere la funzionalità desiderata, come mostrato nel Listato 20-39.

File: src/main.rs
use ciao_macro::CiaoMacro;

struct Pancake;

impl CiaoMacro for Pancake {
    fn ciao_macro() {
        println!("Ciao, Macro! Il mio nome è Pancake!");
    }
}

fn main() {
    Pancake::ciao_macro();
}
Listato 20-39: Come apparirebbe se gli utenti scrivessero manualmente l’implementazione del trait CiaoMacro

Tuttavia, gli utenti dovrebbero scrivere il blocco di implementazione per ogni type su cui vogliono usare ciao_macro; vogliamo risparmiarli da questo lavoro.

Inoltre, non possiamo ancora fornire alla funzione ciao_macro un’implementazione di default che stampi il nome del type su cui il trait è implementato: Rust non possiede capacità riflessive (reflection), cioè non può ricavare il nome del type durante l’esecuzione. Abbiamo bisogno di una macro per generare il codice a durante la compilazione.

Il passo successivo è definire la macro procedurale. Al momento della scrittura, le macro procedurali devono risiedere in un crate a parte. Questa restrizione potrebbe essere rimossa in futuro. La convenzione per strutturare crate e crate macro è la seguente: per un crate chiamato foo, un crate di macro procedurali derive personalizzate si chiama foo_derive. Creiamo quindi un nuovo crate chiamato ciao_macro_derive all’interno del progetto ciao_macro:

$ cargo new ciao_macro_derive --lib

I due crate sono strettamente correlati, quindi creiamo il crate di macro procedurali nella cartella del crate ciao_macro. Se cambiamo la definizione del trait in ciao_macro, dovremo cambiare anche l’implementazione della macro procedurale in ciao_macro_derive. I due crate dovranno essere pubblicati separatamente, e i programmatori che usano questi crate dovranno aggiungerli entrambi come dipendenze e importarli entrambi nello scope. Potremmo invece far sì che il crate ciao_macro utilizzi ciao_macro_derive come dipendenza e rimandi il codice della macro procedurale. Tuttavia, la struttura scelta consente ai programmatori di usare ciao_macro anche se non vogliono la funzionalità derive.

Dobbiamo dichiarare il crate ciao_macro_derive come crate di macro procedurali. Avremo anche bisogno della funzionalità dai crate syn e quote, come vedremo a breve, quindi dobbiamo aggiungerli come dipendenze. Aggiungi quanto segue al file Cargo.toml di ciao_macro_derive:

File: ciao_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

Per iniziare a definire la macro procedurale, inserisci il codice del Listato 20-40 nel file src/lib.rs del crate ciao_macro_derive. Nota che questo codice non si compila fino a quando non aggiungiamo una definizione per la funzione impl_ciao_macro.

File: ciao_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(CiaoMacro)]
pub fn ciao_macro_derive(input: TokenStream) -> TokenStream {
    // Costruisci una rappresentazione di codice Rust come
    // albero sintattico che possiamo manipolare
    let ast = syn::parse(input).unwrap();

    // Costruisci l'implementazione del trait
    impl_ciao_macro(&ast)
}
Listato 20-40: Codice che la maggior parte dei crate di macro procedurali richiederà per processare codice Rust

Nota che abbiamo diviso il codice in una funzione ciao_macro_derive, responsabile del parsing del TokenStream, e una funzione impl_ciao_macro, responsabile della trasformazione dell’albero sintattico: questo rende la scrittura di una macro procedurale più comoda. Il codice nella funzione esterna (ciao_macro_derive in questo caso) sarà simile in quasi tutti i crate di macro procedurali che vedrai o creerai. Il codice che specifichiamo nel corpo della funzione interna (impl_ciao_macro in questo caso) sarà diverso a seconda dello scopo della macro procedurale.

Abbiamo introdotto tre nuovi crate: proc_macro, syn e quote. Il crate proc_macro fa parte di Rust, quindi non abbiamo dovuto aggiungerlo alle dipendenze in Cargo.toml. Il crate proc_macro è l’API del compilatore che consente di leggere e manipolare codice Rust dal nostro codice.

Il crate syn analizza il codice Rust da una stringa in una struttura dati su cui possiamo eseguire operazioni. Il crate quote trasforma le strutture dati di syn nuovamente in codice Rust. Questi crate rendono molto più semplice analizzare qualsiasi tipo di codice Rust che vogliamo gestire: scrivere un parser completo per Rust non è un compito semplice.

La funzione ciao_macro_derive verrà chiamata quando un utente della nostra libreria specifica #[derive(CiaoMacro)] su un type. Questo è possibile perché abbiamo annotato la funzione ciao_macro_derive con proc_macro_derive e specificato il nome CiaoMacro, che corrisponde al nostro nome di trait; questa è la convenzione che la maggior parte delle macro procedurali segue.

La funzione ciao_macro_derive prima converte l’input da un TokenStream a una struttura dati che possiamo interpretare ed elaborare. Qui entra in gioco syn. La funzione parse di syn prende un TokenStream e restituisce una struttura DeriveInput che rappresenta il codice Rust analizzato. Il Listato 20-41 mostra le parti rilevanti della struttura DeriveInput ottenuta analizzando la stringa struct Pancake;.

DeriveInput {
    // --taglio--

    ident: Ident {
        ident: "Pancake",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
Listato 20-41: L’istanza DeriveInput che otteniamo analizzando il codice con l’attributo macro del Listato 20-37

I campi di questa struttura mostrano che il codice Rust che abbiamo analizzato è una struct unit con ident (identificatore, cioè il nome) Pancake. Ci sono altri campi in questa struttura per descrivere ogni tipo di codice Rust; consultare la documentazione syn per DeriveInput per maggiori dettagli.

Presto definiremo la funzione impl_ciao_macro, dove costruiremo il nuovo codice Rust da includere. Ma prima nota che l’output della nostra macro derive è anch’esso un TokenStream. Il TokenStream ritornato viene aggiunto al codice scritto dai nostri utenti, così che quando compilano il loro crate ottengano la funzionalità aggiuntiva che forniamo nel TokenStream modificato.

Potresti aver notato che chiamiamo unwrap per far generare un panic alla funzione ciao_macro_derive se la chiamata a syn::parse fallisce. È necessario che la macro procedurale vada in panic su errori perché le funzioni proc_macro_derive devono restituire un TokenStream e non un Result per conformarsi all’API delle macro procedurali. Abbiamo semplificato questo esempio usando unwrap; nei codici di produzione, si dovrebbero fornire messaggi di errore più specifici usando panic! o expect.

Ora che abbiamo il codice per trasformare il codice Rust annotato da un TokenStream a un’istanza DeriveInput, generiamo il codice che implementa il trait CiaoMacro sul type annotato, come mostrato nel Listato 20-42.

File: hello_macro_derive/src/lib.rs
use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(CiaoMacro)]
pub fn ciao_macro_derive(input: TokenStream) -> TokenStream {
    // Costruisci una rappresentazione di codice Rust come
    // albero sintattico che possiamo manipolare
    let ast = syn::parse(input).unwrap();

    // Costruisci l'implementazione del trait
    impl_ciao_macro(&ast)
}

fn impl_ciao_macro(ast: &syn::DeriveInput) -> TokenStream {
    let nome = &ast.ident;
    let generato = quote! {
        impl CiaoMacro for #nome {
            fn ciao_macro() {
                println!("Ciao, Macro! Il mio nome è {}!", stringify!(#nome));
            }
        }
    };
    generato.into()
}
Listato 20-42: Implementazione del trait CiaoMacro usando il codice Rust analizzato

Otteniamo un’istanza Ident contenente il nome (identificatore) del type annotato usando ast.ident. La struct nel Listato 20-41 mostra che quando eseguiamo la funzione impl_ciao_macro sul codice nel Listato 20-37, il campo ident sarà Pancake. Quindi la variabile nome nel Listato 20-42 sarà un’istanza di Ident che quando stampata sarà la stringa "Pancake", il nome della struct del Listato 20-37.

La macro quote! ci permette di definire il codice Rust che vogliamo restituire. Il compilatore si aspetta qualcosa di diverso dal risultato diretto dell’esecuzione della macro quote!, quindi dobbiamo convertirlo in un TokenStream. Lo facciamo chiamando il metodo into, che consuma questa rappresentazione intermedia e ritorna un valore richiesto di type TokenStream.

La macro quote! fornisce anche un meccanismo di modellazione molto interessante: possiamo inserire #nome e quote! lo sostituirà con il valore contenuto nella variabile nome. Si possono anche fare ripetizioni simili alle macro normali. Consulta la documentazione del crate quote per un’introduzione completa.

Vogliamo che la nostra macro procedurale generi un’implementazione del trait CiaoMacro per il type annotato dall’utente, che otteniamo usando #nome. L’implementazione del trait ha una funzione, ciao_macro, il cui corpo contiene la funzionalità che vogliamo fornire: stampare Ciao, Macro! Il mio nome è e poi il nome del type annotato.

La macro stringify! utilizzata qui è incorporata in Rust. Prende un’espressione Rust, come 1 + 2, e durante la compilazione la trasforma in una stringa letterale, ad esempio "1 + 2". Questo è diverso da format! o println!, macro che valutano l’espressione e poi trasformano il risultato in una String. C’è la possibilità che l’input #nome possa essere un’espressione da stampare letteralmente, quindi usiamo stringify!. Usare stringify! evita anche un’allocazione convertendo #nome in una stringa letterale durante la compilazione.

A questo punto, il comando cargo build dovrebbe completarsi con successo sia in ciao_macro che in ciao_macro_derive. Colleghiamo questi crate al codice nel Listato 20-37 per vedere la macro procedurale in azione! Creiamo un nuovo progetto binario nella directory progetti con cargo new pancake. Dobbiamo aggiungere ciao_macro e ciao_macro_derive come dipendenze nel Cargo.toml del crate pancake. Se pubblichi le tue versioni di ciao_macro e ciao_macro_derive su crates.io, saranno dipendenze normali; altrimenti puoi specificarle come dipendenze di tipo path come segue:

[dependencies]
ciao_macro = { path = "../ciao_macro" }
ciao_macro_derive = { path = "../ciao_macro/ciao_macro_derive" }

Inserisci il codice del Listato 20-37 in src/main.rs, ed esegui cargo run: dovrebbe stampare Ciao, Macro! Il mio nome è Pancake!. L’implementazione del trait CiaoMacro dalla macro procedurale è stata inclusa senza che il crate pancakes dovesse implementarla; il #[derive(CiaoMacro)] ha aggiunto l’implementazione del trait.

Successivamente, esploreremo come le altre tipologie di macro procedurali differiscono dalle macro derive personalizzate.

Macro Simil-Attributo

Le macro simil-attributo sono simili alle macro derive personalizzate, ma invece di generare codice per l’attributo derive, permettono di creare nuovi attributi. Sono anche più flessibili: derive funziona solo per struct ed enum; gli attributi possono essere applicati anche ad altri elementi, come funzioni. Ecco un esempio di utilizzo di una macro simil-attributo. Supponiamo di avere un attributo chiamato route che annota funzioni in un framework per applicazioni web:

#[route(GET, "/")]
fn index() {

Questo attributo #[route] sarebbe definito dal framework come una macro procedurale. La firma della funzione che definisce la macro sarebbe simile a questa:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

Qui abbiamo due parametri di type TokenStream. Il primo è per il contenuto dell’attributo: la parte GET, "/". Il secondo è il corpo dell’elemento a cui l’attributo è associato: in questo caso, fn index() {} e il resto del corpo della funzione.

A parte questo, le macro simil-attributo funzionano come le macro derive personalizzate: si crea un crate con tipo crate proc-macro e si implementa una funzione che genera il codice desiderato.

Macro Simil-Funzioni

Le macro simil-funzioni definiscono macro che sembrano chiamate di funzione. Come le macro macro_rules!, sono più flessibili delle funzioni; per esempio, possono prendere un numero variabile di argomenti. Tuttavia, le macro macro_rules! possono essere definite solo usando la sintassi simile a match vista nella sezione sulle macro dichiarative con macro_rules!. Le macro simil-funzioni prendono un parametro TokenStream e la loro definizione manipola quel TokenStream usando codice Rust, come fanno le altre due tipologie di macro procedurali.

Un esempio di macro simil-funzione è una macro sql! che potrebbe essere chiamata in questo modo:

let sql = sql!(SELECT * FROM posts WHERE id=1);

Questa macro potrebbe analizzare la dichiarazione SQL al suo interno e verificare che sia sintatticamente corretta, un’elaborazione molto più complessa di quella che una macro macro_rules! può fare. La macro sql! sarebbe definita così:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

Questa definizione è simile alla firma di una macro derive personalizzata: riceviamo i token che stanno dentro le parentesi e restituiamo il codice che vogliamo generare.

Riepilogo

Wow! Ora hai appreso alcune funzionalità di Rust che probabilmente non userai troppo spesso, ma sarà utile sapere che sono disponibili in circostanze particolari. Abbiamo introdotto diversi argomenti complessi in modo che, quando li incontrerai nei suggerimenti dei messaggi di errore o nel codice scritto da altri, tu possa riconoscere questi concetti e sintassi. Usa questo capitolo come riferimento per guidarti nelle soluzioni.

Adesso metteremo in pratica tutto ciò di cui abbiamo discusso durante il libro e realizzeremo un altro progetto!

Final Project: Building a Multithreaded Web Server

It’s been a long journey, but we’ve reached the end of the book. In this chapter, we’ll build one more project together to demonstrate some of the concepts we covered in the final chapters, as well as recap some earlier lessons.

For our final project, we’ll make a web server that says “hello” and looks like Figure 21-1 in a web browser.

Here is our plan for building the web server:

  1. Learn a bit about TCP and HTTP.
  2. Listen for TCP connections on a socket.
  3. Parse a small number of HTTP requests.
  4. Create a proper HTTP response.
  5. Improve the throughput of our server with a thread pool.

hello from rust

Figure 21-1: Our final shared project

Before we get started, we should mention two details. First, the method we’ll use won’t be the best way to build a web server with Rust. Community members have published a number of production-ready crates available at crates.io that provide more complete web server and thread pool implementations than we’ll build. However, our intention in this chapter is to help you learn, not to take the easy route. Because Rust is a systems programming language, we can choose the level of abstraction we want to work with and can go to a lower level than is possible or practical in other languages.

Second, we will not be using async and await here. Building a thread pool is a big enough challenge on its own, without adding in building an async runtime! However, we will note how async and await might be applicable to some of the same problems we will see in this chapter. Ultimately, as we noted back in Chapter 17, many async runtimes use thread pools for managing their work.

We’ll therefore write the basic HTTP server and thread pool manually so you can learn the general ideas and techniques behind the crates you might use in the future.

Building a Single-Threaded Web Server

We’ll start by getting a single-threaded web server working. Before we begin, let’s look at a quick overview of the protocols involved in building web servers. The details of these protocols are beyond the scope of this book, but a brief overview will give you the information you need.

The two main protocols involved in web servers are Hypertext Transfer Protocol (HTTP) and Transmission Control Protocol (TCP). Both protocols are request-response protocols, meaning a client initiates requests and a server listens to the requests and provides a response to the client. The contents of those requests and responses are defined by the protocols.

TCP is the lower-level protocol that describes the details of how information gets from one server to another but doesn’t specify what that information is. HTTP builds on top of TCP by defining the contents of the requests and responses. It’s technically possible to use HTTP with other protocols, but in the vast majority of cases, HTTP sends its data over TCP. We’ll work with the raw bytes of TCP and HTTP requests and responses.

Listening to the TCP Connection

Our web server needs to listen to a TCP connection, so that’s the first part we’ll work on. The standard library offers a std::net module that lets us do this. Let’s make a new project in the usual fashion:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

Now enter the code in Listing 21-1 in src/main.rs to start. This code will listen at the local address 127.0.0.1:7878 for incoming TCP streams. When it gets an incoming stream, it will print Connection established!.

File: src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connessione stabilita!");
    }
}
Listato 21-1: Listening for incoming streams and printing a message when we receive a stream

Using TcpListener, we can listen for TCP connections at the address 127.0.0.1:7878. In the address, the section before the colon is an IP address representing your computer (this is the same on every computer and doesn’t represent the authors’ computer specifically), and 7878 is the port. We’ve chosen this port for two reasons: HTTP isn’t normally accepted on this port, so our server is unlikely to conflict with any other web server you might have running on your machine, and 7878 is rust typed on a telephone.

The bind function in this scenario works like the new function in that it will return a new TcpListener instance. The function is called bind because, in networking, connecting to a port to listen to is known as “binding to a port.”

The bind function returns a Result<T, E>, which indicates that it’s possible for binding to fail. For example, if we ran two instances of our program and so had two programs listening to the same port. Because we’re writing a basic server just for learning purposes, we won’t worry about handling these kinds of errors; instead, we use unwrap to stop the program if errors happen.

The incoming method on TcpListener returns an iterator that gives us a sequence of streams (more specifically, streams of type TcpStream). A single stream represents an open connection between the client and the server. A connection is the name for the full request and response process in which a client connects to the server, the server generates a response, and the server closes the connection. As such, we will read from the TcpStream to see what the client sent and then write our response to the stream to send data back to the client. Overall, this for loop will process each connection in turn and produce a series of streams for us to handle.

For now, our handling of the stream consists of calling unwrap to terminate our program if the stream has any errors; if there aren’t any errors, the program prints a message. We’ll add more functionality for the success case in the next listing. The reason we might receive errors from the incoming method when a client connects to the server is that we’re not actually iterating over connections. Instead, we’re iterating over connection attempts. The connection might not be successful for a number of reasons, many of them operating system specific. For example, many operating systems have a limit to the number of simultaneous open connections they can support; new connection attempts beyond that number will produce an error until some of the open connections are closed.

Let’s try running this code! Invoke cargo run in the terminal and then load 127.0.0.1:7878 in a web browser. The browser should show an error message like “Connection reset” because the server isn’t currently sending back any data. But when you look at your terminal, you should see several messages that were printed when the browser connected to the server!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

Sometimes you’ll see multiple messages printed for one browser request; the reason might be that the browser is making a request for the page as well as a request for other resources, like the favicon.ico icon that appears in the browser tab.

It could also be that the browser is trying to connect to the server multiple times because the server isn’t responding with any data. When stream goes out of scope and is dropped at the end of the loop, the connection is closed as part of the drop implementation. Browsers sometimes deal with closed connections by retrying, because the problem might be temporary.

Browsers also sometimes open multiple connections to the server without sending any requests, so that if they do later send requests, those requests can happen faster. When this happens, our server will see each connection, regardless of whether there are any requests over that connection. Many versions of Chrome-based browsers do this, for example; you can disable that optimization by using private browsing mode or using a different browser.

The important factor is that we’ve successfully gotten a handle to a TCP connection!

Remember to stop the program by pressing ctrl-C when you’re done running a particular version of the code. Then restart the program by invoking the cargo run command after you’ve made each set of code changes to make sure you’re running the newest code.

Reading the Request

Let’s implement the functionality to read the request from the browser! To separate the concerns of first getting a connection and then taking some action with the connection, we’ll start a new function for processing connections. In this new handle_connection function, we’ll read data from the TCP stream and print it so we can see the data being sent from the browser. Change the code to look like Listing 21-2.

File: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Richiesta: {http_request:#?}");
}
Listato 21-2: Reading from the TcpStream and printing the data

We bring std::io::prelude and std::io::BufReader into scope to get access to traits and types that let us read from and write to the stream. In the for loop in the main function, instead of printing a message that says we made a connection, we now call the new handle_connection function and pass the stream to it.

In the handle_connection function, we create a new BufReader instance that wraps a reference to the stream. The BufReader adds buffering by managing calls to the std::io::Read trait methods for us.

We create a variable named http_request to collect the lines of the request the browser sends to our server. We indicate that we want to collect these lines in a vector by adding the Vec<_> type annotation.

BufReader implements the std::io::BufRead trait, which provides the lines method. The lines method returns an iterator of Result<String, std::io::Error> by splitting the stream of data whenever it sees a newline byte. To get each String, we map and unwrap each Result. The Result might be an error if the data isn’t valid UTF-8 or if there was a problem reading from the stream. Again, a production program should handle these errors more gracefully, but we’re choosing to stop the program in the error case for simplicity.

The browser signals the end of an HTTP request by sending two newline characters in a row, so to get one request from the stream, we take lines until we get a line that is the empty string. Once we’ve collected the lines into the vector, we’re printing them out using pretty debug formatting so we can take a look at the instructions the web browser is sending to our server.

Let’s try this code! Start the program and make a request in a web browser again. Note that we’ll still get an error page in the browser, but our program’s output in the terminal will now look similar to this:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

Depending on your browser, you might get slightly different output. Now that we’re printing the request data, we can see why we get multiple connections from one browser request by looking at the path after GET in the first line of the request. If the repeated connections are all requesting /, we know the browser is trying to fetch / repeatedly because it’s not getting a response from our program.

Let’s break down this request data to understand what the browser is asking of our program.

Looking Closer at an HTTP Request

HTTP is a text-based protocol, and a request takes this format:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

The first line is the request line that holds information about what the client is requesting. The first part of the request line indicates the method being used, such as GET or POST, which describes how the client is making this request. Our client used a GET request, which means it is asking for information.

The next part of the request line is /, which indicates the uniform resource identifier (URI) the client is requesting: a URI is almost, but not quite, the same as a uniform resource locator (URL). The difference between URIs and URLs isn’t important for our purposes in this chapter, but the HTTP spec uses the term URI, so we can just mentally substitute URL for URI here.

The last part is the HTTP version the client uses, and then the request line ends in a CRLF sequence. (CRLF stands for carriage return and line feed, which are terms from the typewriter days!) The CRLF sequence can also be written as \r\n, where \r is a carriage return and \n is a line feed. The CRLF sequence separates the request line from the rest of the request data. Note that when the CRLF is printed, we see a new line start rather than \r\n.

Looking at the request line data we received from running our program so far, we see that GET is the method, / is the request URI, and HTTP/1.1 is the version.

After the request line, the remaining lines starting from Host: onward are headers. GET requests have no body.

Try making a request from a different browser or asking for a different address, such as 127.0.0.1:7878/test, to see how the request data changes.

Now that we know what the browser is asking for, let’s send back some data!

Writing a Response

We’re going to implement sending data in response to a client request. Responses have the following format:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

The first line is a status line that contains the HTTP version used in the response, a numeric status code that summarizes the result of the request, and a reason phrase that provides a text description of the status code. After the CRLF sequence are any headers, another CRLF sequence, and the body of the response.

Here is an example response that uses HTTP version 1.1, and has a status code of 200, an OK reason phrase, no headers, and no body:

HTTP/1.1 200 OK\r\n\r\n

The status code 200 is the standard success response. The text is a tiny successful HTTP response. Let’s write this to the stream as our response to a successful request! From the handle_connection function, remove the println! that was printing the request data and replace it with the code in Listing 21-3.

File: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let risposta = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(risposta.as_bytes()).unwrap();
}
Listato 21-3: Writing a tiny successful HTTP response to the stream

The first new line defines the response variable that holds the success message’s data. Then we call as_bytes on our response to convert the string data to bytes. The write_all method on stream takes a &[u8] and sends those bytes directly down the connection. Because the write_all operation could fail, we use unwrap on any error result as before. Again, in a real application you would add error handling here.

With these changes, let’s run our code and make a request. We’re no longer printing any data to the terminal, so we won’t see any output other than the output from Cargo. When you load 127.0.0.1:7878 in a web browser, you should get a blank page instead of an error. You’ve just handcoded receiving an HTTP request and sending a response!

Returning Real HTML

Let’s implement the functionality for returning more than a blank page. Create the new file hello.html in the root of your project directory, not in the src directory. You can input any HTML you want; Listing 21-4 shows one possibility.

File: ciao.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Ciao!</title>
  </head>
  <body>
    <h1>Ciao!</h1>
    <p>Un saluto da Rust</p>
  </body>
</html>
Listato 21-4: A sample HTML file to return in a response

This is a minimal HTML5 document with a heading and some text. To return this from the server when a request is received, we’ll modify handle_connection as shown in Listing 21-5 to read the HTML file, add it to the response as a body, and send it.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --taglio--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("ciao.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listato 21-5: Sending the contents of hello.html as the body of the response

We’ve added fs to the use statement to bring the standard library’s filesystem module into scope. The code for reading the contents of a file to a string should look familiar; we used it when we read the contents of a file for our I/O project in Listing 12-4.

Next, we use format! to add the file’s contents as the body of the success response. To ensure a valid HTTP response, we add the Content-Length header which is set to the size of our response body, in this case the size of hello.html.

Run this code with cargo run and load 127.0.0.1:7878 in your browser; you should see your HTML rendered!

Currently, we’re ignoring the request data in http_request and just sending back the contents of the HTML file unconditionally. That means if you try requesting 127.0.0.1:7878/something-else in your browser, you’ll still get back this same HTML response. At the moment, our server is very limited and does not do what most web servers do. We want to customize our responses depending on the request and only send back the HTML file for a well-formed request to /.

Validating the Request and Selectively Responding

Right now, our web server will return the HTML in the file no matter what the client requested. Let’s add functionality to check that the browser is requesting / before returning the HTML file, and return an error if the browser requests anything else. For this we need to modify handle_connection, as shown in Listing 21-6. This new code checks the content of the request received against what we know a request for / looks like and adds if and else blocks to treat requests differently.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}
// --taglio--

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("ciao.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // qualche altra richiesta
    }
}
Listato 21-6: Handling requests to / differently from other requests

We’re only going to be looking at the first line of the HTTP request, so rather than reading the entire request into a vector, we’re calling next to get the first item from the iterator. The first unwrap takes care of the Option and stops the program if the iterator has no items. The second unwrap handles the Result and has the same effect as the unwrap that was in the map added in Listing 21-2.

Next, we check the request_line to see if it equals the request line of a GET request to the / path. If it does, the if block returns the contents of our HTML file.

If the request_line does not equal the GET request to the / path, it means we’ve received some other request. We’ll add code to the else block in a moment to respond to all other requests.

Run this code now and request 127.0.0.1:7878; you should get the HTML in hello.html. If you make any other request, such as 127.0.0.1:7878/something-else, you’ll get a connection error like those you saw when running the code in Listing 21-1 and Listing 21-2.

Now let’s add the code in Listing 21-7 to the else block to return a response with the status code 404, which signals that the content for the request was not found. We’ll also return some HTML for a page to render in the browser indicating the response to the end user.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("ciao.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --taglio--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}
Listato 21-7: Responding with status code 404 and an error page if anything other than / was requested

Here, our response has a status line with status code 404 and the reason phrase NOT FOUND. The body of the response will be the HTML in the file 404.html. You’ll need to create a 404.html file next to hello.html for the error page; again feel free to use any HTML you want, or use the example HTML in Listing 21-8.

File: 404.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Ciao!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Scusa, non so cosa stai cercando.</p>
  </body>
</html>
Listato 21-8: Sample content for the page to send back with any 404 response

With these changes, run your server again. Requesting 127.0.0.1:7878 should return the contents of hello.html, and any other request, like 127.0.0.1:7878/foo, should return the error HTML from 404.html.

Refactoring

At the moment, the if and else blocks have a lot of repetition: they’re both reading files and writing the contents of the files to the stream. The only differences are the status line and the filename. Let’s make the code more concise by pulling out those differences into separate if and else lines that will assign the values of the status line and the filename to variables; we can then use those variables unconditionally in the code to read the file and write the response. Listing 21-9 shows the resultant code after replacing the large if and else blocks.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}
// --taglio--

fn gestisci_connessione(mut stream: TcpStream) {
    // --taglio--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "ciao.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listato 21-9: Refactoring the if and else blocks to contain only the code that differs between the two cases

Now the if and else blocks only return the appropriate values for the status line and filename in a tuple; we then use destructuring to assign these two values to status_line and filename using a pattern in the let statement, as discussed in Chapter 19.

The previously duplicated code is now outside the if and else blocks and uses the status_line and filename variables. This makes it easier to see the difference between the two cases, and it means we have only one place to update the code if we want to change how the file reading and response writing work. The behavior of the code in Listing 21-9 will be the same as that in Listing 21-7.

Awesome! We now have a simple web server in approximately 40 lines of Rust code that responds to one request with a page of content and responds to all other requests with a 404 response.

Currently, our server runs in a single thread, meaning it can only serve one request at a time. Let’s examine how that can be a problem by simulating some slow requests. Then we’ll fix it so our server can handle multiple requests at once.

From Single-Threaded to Multithreaded Server

Right now, the server will process each request in turn, meaning it won’t process a second connection until the first is finished processing. If the server received more and more requests, this serial execution would be less and less optimal. If the server receives a request that takes a long time to process, subsequent requests will have to wait until the long request is finished, even if the new requests can be processed quickly. We’ll need to fix this, but first we’ll look at the problem in action.

Simulating a Slow Request

We’ll look at how a slow-processing request can affect other requests made to our current server implementation. Listing 21-10 implements handling a request to /sleep with a simulated slow response that will cause the server to sleep for five seconds before responding.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --taglio--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        gestisci_connessione(stream);
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    // --taglio--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "ciao.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "ciao.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --taglio--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listato 21-10: Simulating a slow request by sleeping for five seconds

We switched from if to match now that we have three cases. We need to explicitly match on a slice of request_line to pattern-match against the string literal values; match doesn’t do automatic referencing and dereferencing, like the equality method does.

The first arm is the same as the if block from Listing 21-9. The second arm matches a request to /sleep. When that request is received, the server will sleep for five seconds before rendering the successful HTML page. The third arm is the same as the else block from Listing 21-9.

You can see how primitive our server is: real libraries would handle the recognition of multiple requests in a much less verbose way!

Start the server using cargo run. Then open two browser windows: one for http://127.0.0.1:7878 and the other for http://127.0.0.1:7878/sleep. If you enter the / URI a few times, as before, you’ll see it respond quickly. But if you enter /sleep and then load /, you’ll see that / waits until sleep has slept for its full five seconds before loading.

There are multiple techniques we could use to avoid requests backing up behind a slow request, including using async as we did Chapter 17; the one we’ll implement is a thread pool.

Improving Throughput with a Thread Pool

A thread pool is a group of spawned threads that are waiting and ready to handle a task. When the program receives a new task, it assigns one of the threads in the pool to the task, and that thread will process the task. The remaining threads in the pool are available to handle any other tasks that come in while the first thread is processing. When the first thread is done processing its task, it’s returned to the pool of idle threads, ready to handle a new task. A thread pool allows you to process connections concurrently, increasing the throughput of your server.

We’ll limit the number of threads in the pool to a small number to protect us from DoS attacks; if we had our program create a new thread for each request as it came in, someone making 10 million requests to our server could create havoc by using up all our server’s resources and grinding the processing of requests to a halt.

Rather than spawning unlimited threads, then, we’ll have a fixed number of threads waiting in the pool. Requests that come in are sent to the pool for processing. The pool will maintain a queue of incoming requests. Each of the threads in the pool will pop off a request from this queue, handle the request, and then ask the queue for another request. With this design, we can process up to N requests concurrently, where N is the number of threads. If each thread is responding to a long-running request, subsequent requests can still back up in the queue, but we’ve increased the number of long-running requests we can handle before reaching that point.

This technique is just one of many ways to improve the throughput of a web server. Other options you might explore are the fork/join model, the single-threaded async I/O model, and the multithreaded async I/O model. If you’re interested in this topic, you can read more about other solutions and try to implement them; with a low-level language like Rust, all of these options are possible.

Before we begin implementing a thread pool, let’s talk about what using the pool should look like. When you’re trying to design code, writing the client interface first can help guide your design. Write the API of the code so it’s structured in the way you want to call it; then implement the functionality within that structure rather than implementing the functionality and then designing the public API.

Similar to how we used test-driven development in the project in Chapter 12, we’ll use compiler-driven development here. We’ll write the code that calls the functions we want, and then we’ll look at errors from the compiler to determine what we should change next to get the code to work. Before we do that, however, we’ll explore the technique we’re not going to use as a starting point.

Spawning a Thread for Each Request

First, let’s explore how our code might look if it did create a new thread for every connection. As mentioned earlier, this isn’t our final plan due to the problems with potentially spawning an unlimited number of threads, but it is a starting point to get a working multithreaded server first. Then we’ll add the thread pool as an improvement, and contrasting the two solutions will be easier.

Listing 21-11 shows the changes to make to main to spawn a new thread to handle each stream within the for loop.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            gestisci_connessione(stream);
        });
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "ciao.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "ciao.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listato 21-11: Spawning a new thread for each stream

As you learned in Chapter 16, thread::spawn will create a new thread and then run the code in the closure in the new thread. If you run this code and load /sleep in your browser, then / in two more browser tabs, you’ll indeed see that the requests to / don’t have to wait for /sleep to finish. However, as we mentioned, this will eventually overwhelm the system because you’d be making new threads without any limit.

You may also recall from Chapter 17 that this is exactly the kind of situation where async and await really shine! Keep that in mind as we build the thread pool and think about how things would look different or the same with async.

Creating a Finite Number of Threads

We want our thread pool to work in a similar, familiar way so that switching from threads to a thread pool doesn’t require large changes to the code that uses our API. Listing 21-12 shows the hypothetical interface for a ThreadPool struct we want to use instead of thread::spawn.

File: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            gestisci_connessione(stream);
        });
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "ciao.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "ciao.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listato 21-12: Our ideal ThreadPool interface

We use ThreadPool::new to create a new thread pool with a configurable number of threads, in this case four. Then, in the for loop, pool.execute has a similar interface as thread::spawn in that it takes a closure the pool should run for each stream. We need to implement pool.execute so it takes the closure and gives it to a thread in the pool to run. This code won’t yet compile, but we’ll try so that the compiler can guide us in how to fix it.

Building ThreadPool Using Compiler-Driven Development

Make the changes in Listing 21-12 to src/main.rs, and then let’s use the compiler errors from cargo check to drive our development. Here is the first error we get:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Great! This error tells us we need a ThreadPool type or module, so we’ll build one now. Our ThreadPool implementation will be independent of the kind of work our web server is doing. So let’s switch the hello crate from a binary crate to a library crate to hold our ThreadPool implementation. After we change to a library crate, we could also use the separate thread pool library for any work we want to do using a thread pool, not just for serving web requests.

Create a src/lib.rs file that contains the following, which is the simplest definition of a ThreadPool struct that we can have for now:

File: src/lib.rs
pub struct ThreadPool;

Then edit the main.rs file to bring ThreadPool into scope from the library crate by adding the following code to the top of src/main.rs:

File: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            gestisci_connessione(stream);
        });
    }
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "ciao.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "ciao.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

This code still won’t work, but let’s check it again to get the next error that we need to address:

$ cargo check
    Checking hello v0.1.0 (file:///progetti/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

This error indicates that next we need to create an associated function named new for ThreadPool. We also know that new needs to have one parameter that can accept 4 as an argument and should return a ThreadPool instance. Let’s implement the simplest new function that will have those characteristics:

File: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

We chose usize as the type of the size parameter because we know that a negative number of threads doesn’t make any sense. We also know we’ll use this 4 as the number of elements in a collection of threads, which is what the usize type is for, as discussed in “Integer Types” in Chapter 3.

Let’s check the code again:

$ cargo check
    Checking hello v0.1.0 (file:///progetti/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

Now the error occurs because we don’t have an execute method on ThreadPool. Recall from “Creating a Finite Number of Threads” that we decided our thread pool should have an interface similar to thread::spawn. In addition, we’ll implement the execute function so it takes the closure it’s given and gives it to an idle thread in the pool to run.

We’ll define the execute method on ThreadPool to take a closure as a parameter. Recall from “Restituire i Valori Catturati dalle Chiusure” in Chapter 13 that we can take closures as parameters with three different traits: Fn, FnMut, and FnOnce. We need to decide which kind of closure to use here. We know we’ll end up doing something similar to the standard library thread::spawn implementation, so we can look at what bounds the signature of thread::spawn has on its parameter. The documentation shows us the following:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

The F type parameter is the one we’re concerned with here; the T type parameter is related to the return value, and we’re not concerned with that. We can see that spawn uses FnOnce as the trait bound on F. This is probably what we want as well, because we’ll eventually pass the argument we get in execute to spawn. We can be further confident that FnOnce is the trait we want to use because the thread for running a request will only execute that request’s closure one time, which matches the Once in FnOnce.

The F type parameter also has the trait bound Send and the lifetime bound 'static, which are useful in our situation: we need Send to transfer the closure from one thread to another and 'static because we don’t know how long the thread will take to execute. Let’s create an execute method on ThreadPool that will take a generic parameter of type F with these bounds:

File: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    // --taglio--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

We still use the () after FnOnce because this FnOnce represents a closure that takes no parameters and returns the unit type (). Just like function definitions, the return type can be omitted from the signature, but even if we have no parameters, we still need the parentheses.

Again, this is the simplest implementation of the execute method: it does nothing, but we’re only trying to make our code compile. Let’s check it again:

$ cargo check
    Checking hello v0.1.0 (file:///progetti/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

It compiles! But note that if you try cargo run and make a request in the browser, you’ll see the errors in the browser that we saw at the beginning of the chapter. Our library isn’t actually calling the closure passed to execute yet!

Note: A saying you might hear about languages with strict compilers, such as Haskell and Rust, is “if the code compiles, it works.” But this saying is not universally true. Our project compiles, but it does absolutely nothing! If we were building a real, complete project, this would be a good time to start writing unit tests to check that the code compiles and has the behavior we want.

Consider: what would be different here if we were going to execute a future instead of a closure?

Validating the Number of Threads in new

We aren’t doing anything with the parameters to new and execute. Let’s implement the bodies of these functions with the behavior we want. To start, let’s think about new. Earlier we chose an unsigned type for the size parameter because a pool with a negative number of threads makes no sense. However, a pool with zero threads also makes no sense, yet zero is a perfectly valid usize. We’ll add code to check that size is greater than zero before we return a ThreadPool instance and have the program panic if it receives a zero by using the assert! macro, as shown in Listing 21-13.

File: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --taglio--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listato 21-13: Implementing ThreadPool::new to panic if size is zero

We’ve also added some documentation for our ThreadPool with doc comments. Note that we followed good documentation practices by adding a section that calls out the situations in which our function can panic, as discussed in Chapter 14. Try running cargo doc --open and clicking the ThreadPool struct to see what the generated docs for new look like!

Instead of adding the assert! macro as we’ve done here, we could change new into build and return a Result like we did with Config::build in the I/O project in Listing 12-9. But we’ve decided in this case that trying to create a thread pool without any threads should be an unrecoverable error. If you’re feeling ambitious, try to write a function named build with the following signature to compare with the new function:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

Creating Space to Store the Threads

Now that we have a way to know we have a valid number of threads to store in the pool, we can create those threads and store them in the ThreadPool struct before returning the struct. But how do we “store” a thread? Let’s take another look at the thread::spawn signature:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

The spawn function returns a JoinHandle<T>, where T is the type that the closure returns. Let’s try using JoinHandle too and see what happens. In our case, the closures we’re passing to the thread pool will handle the connection and not return anything, so T will be the unit type ().

The code in Listing 21-14 will compile but doesn’t create any threads yet. We’ve changed the definition of ThreadPool to hold a vector of thread::JoinHandle<()> instances, initialized the vector with a capacity of size, set up a for loop that will run some code to create the threads, and returned a ThreadPool instance containing them.

File: src/lib.rs
use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --taglio--
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // crea qualche thread e memorizzali in un vettore
        }

        ThreadPool { threads }
    }
    // --taglio--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listato 21-14: Creating a vector for ThreadPool to hold the threads

We’ve brought std::thread into scope in the library crate because we’re using thread::JoinHandle as the type of the items in the vector in ThreadPool.

Once a valid size is received, our ThreadPool creates a new vector that can hold size items. The with_capacity function performs the same task as Vec::new but with an important difference: it pre-allocates space in the vector. Because we know we need to store size elements in the vector, doing this allocation up front is slightly more efficient than using Vec::new, which resizes itself as elements are inserted.

When you run cargo check again, it should succeed.

Sending Code from the ThreadPool to a Thread

We left a comment in the for loop in Listing 21-14 regarding the creation of threads. Here, we’ll look at how we actually create threads. The standard library provides thread::spawn as a way to create threads, and thread::spawn expects to get some code the thread should run as soon as the thread is created. However, in our case, we want to create the threads and have them wait for code that we’ll send later. The standard library’s implementation of threads doesn’t include any way to do that; we have to implement it manually.

We’ll implement this behavior by introducing a new data structure between the ThreadPool and the threads that will manage this new behavior. We’ll call this data structure Worker, which is a common term in pooling implementations. The Worker picks up code that needs to be run and runs the code in its thread.

Think of people working in the kitchen at a restaurant: the workers wait until orders come in from customers, and then they’re responsible for taking those orders and filling them.

Instead of storing a vector of JoinHandle<()> instances in the thread pool, we’ll store instances of the Worker struct. Each Worker will store a single JoinHandle<()> instance. Then we’ll implement a method on Worker that will take a closure of code to run and send it to the already running thread for execution. We’ll also give each Worker an id so we can distinguish between the different instances of Worker in the pool when logging or debugging.

Here is the new process that will happen when we create a ThreadPool. We’ll implement the code that sends the closure to the thread after we have Worker set up in this way:

  1. Define a Worker struct that holds an id and a JoinHandle<()>.
  2. Change ThreadPool to hold a vector of Worker instances.
  3. Define a Worker::new function that takes an id number and returns a Worker instance that holds the id and a thread spawned with an empty closure.
  4. In ThreadPool::new, use the for loop counter to generate an id, create a new Worker with that id, and store the Worker in the vector.

If you’re up for a challenge, try implementing these changes on your own before looking at the code in Listing 21-15.

Ready? Here is Listing 21-15 with one way to make the preceding modifications.

File: src/lib.rs
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --taglio--
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --taglio--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listato 21-15: Modifying ThreadPool to hold Worker instances instead of holding threads directly

We’ve changed the name of the field on ThreadPool from threads to workers because it’s now holding Worker instances instead of JoinHandle<()> instances. We use the counter in the for loop as an argument to Worker::new, and we store each new Worker in the vector named workers.

External code (like our server in src/main.rs) doesn’t need to know the implementation details regarding using a Worker struct within ThreadPool, so we make the Worker struct and its new function private. The Worker::new function uses the id we give it and stores a JoinHandle<()> instance that is created by spawning a new thread using an empty closure.

Note: If the operating system can’t create a thread because there aren’t enough system resources, thread::spawn will panic. That will cause our whole server to panic, even though the creation of some threads might succeed. For simplicity’s sake, this behavior is fine, but in a production thread pool implementation, you’d likely want to use std::thread::Builder and its spawn method that returns Result instead.

This code will compile and will store the number of Worker instances we specified as an argument to ThreadPool::new. But we’re still not processing the closure that we get in execute. Let’s look at how to do that next.

Sending Requests to Threads via Channels

The next problem we’ll tackle is that the closures given to thread::spawn do absolutely nothing. Currently, we get the closure we want to execute in the execute method. But we need to give thread::spawn a closure to run when we create each Worker during the creation of the ThreadPool.

We want the Worker structs that we just created to fetch the code to run from a queue held in the ThreadPool and send that code to its thread to run.

The channels we learned about in Chapter 16—a simple way to communicate between two threads—would be perfect for this use case. We’ll use a channel to function as the queue of jobs, and execute will send a job from the ThreadPool to the Worker instances, which will send the job to its thread. Here is the plan:

  1. The ThreadPool will create a channel and hold on to the sender.
  2. Each Worker will hold on to the receiver.
  3. We’ll create a new Job struct that will hold the closures we want to send down the channel.
  4. The execute method will send the job it wants to execute through the sender.
  5. In its thread, the Worker will loop over its receiver and execute the closures of any jobs it receives.

Let’s start by creating a channel in ThreadPool::new and holding the sender in the ThreadPool instance, as shown in Listing 21-16. The Job struct doesn’t hold anything for now but will be the type of item we’re sending down the channel.

File: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --taglio--
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --taglio--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listato 21-16: Modifying ThreadPool to store the sender of a channel that transmits Job instances

In ThreadPool::new, we create our new channel and have the pool hold the sender. This will successfully compile.

Let’s try passing a receiver of the channel into each Worker as the thread pool creates the channel. We know we want to use the receiver in the thread that the Worker instances spawn, so we’ll reference the receiver parameter in the closure. The code in Listing 21-17 won’t quite compile yet.

File: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --taglio--
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --taglio--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --taglio--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listato 21-17: Passing the receiver to each Worker

We’ve made some small and straightforward changes: we pass the receiver into Worker::new, and then we use it inside the closure.

When we try to check this code, we get this error:

$ cargo check
    Checking hello v0.1.0 (file:///progetti/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

The code is trying to pass receiver to multiple Worker instances. This won’t work, as you’ll recall from Chapter 16: the channel implementation that Rust provides is multiple producer, single consumer. This means we can’t just clone the consuming end of the channel to fix this code. We also don’t want to send a message multiple times to multiple consumers; we want one list of messages with multiple Worker instances such that each message gets processed once.

Additionally, taking a job off the channel queue involves mutating the receiver, so the threads need a safe way to share and modify receiver; otherwise, we might get race conditions (as covered in Chapter 16).

Recall the thread-safe smart pointers discussed in Chapter 16: to share ownership across multiple threads and allow the threads to mutate the value, we need to use Arc<Mutex<T>>. The Arc type will let multiple Worker instances own the receiver, and Mutex will ensure that only one Worker gets a job from the receiver at a time. Listing 21-18 shows the changes we need to make.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --taglio--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --taglio--
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --taglio--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --taglio--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --taglio--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listato 21-18: Sharing the receiver among the Worker instances using Arc and Mutex

In ThreadPool::new, we put the receiver in an Arc and a Mutex. For each new Worker, we clone the Arc to bump the reference count so the Worker instances can share ownership of the receiver.

With these changes, the code compiles! We’re getting there!

Implementing the execute Method

Let’s finally implement the execute method on ThreadPool. We’ll also change Job from a struct to a type alias for a trait object that holds the type of closure that execute receives. As discussed in “Sinonimi e Alias di Type in Chapter 20, type aliases allow us to make long types shorter for ease of use. Look at Listing 21-19.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --taglio--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --taglio--
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --taglio--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listato 21-19: Creating a Job type alias for a Box that holds each closure and then sending the job down the channel

After creating a new Job instance using the closure we get in execute, we send that job down the sending end of the channel. We’re calling unwrap on send for the case that sending fails. This might happen if, for example, we stop all our threads from executing, meaning the receiving end has stopped receiving new messages. At the moment, we can’t stop our threads from executing: our threads continue executing as long as the pool exists. The reason we use unwrap is that we know the failure case won’t happen, but the compiler doesn’t know that.

But we’re not quite done yet! In the Worker, our closure being passed to thread::spawn still only references the receiving end of the channel. Instead, we need the closure to loop forever, asking the receiving end of the channel for a job and running the job when it gets one. Let’s make the change shown in Listing 21-20 to Worker::new.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --taglio--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} ha un lavoro; in esecuzione.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listato 21-20: Receiving and executing the jobs in the Worker instance’s thread

Here, we first call lock on the receiver to acquire the mutex, and then we call unwrap to panic on any errors. Acquiring a lock might fail if the mutex is in a poisoned state, which can happen if some other thread panicked while holding the lock rather than releasing the lock. In this situation, calling unwrap to have this thread panic is the correct action to take. Feel free to change this unwrap to an expect with an error message that is meaningful to you.

If we get the lock on the mutex, we call recv to receive a Job from the channel. A final unwrap moves past any errors here as well, which might occur if the thread holding the sender has shut down, similar to how the send method returns Err if the receiver shuts down.

The call to recv blocks, so if there is no job yet, the current thread will wait until a job becomes available. The Mutex<T> ensures that only one Worker thread at a time is trying to request a job.

Our thread pool is now in a working state! Give it a cargo run and make some requests:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

Success! We now have a thread pool that executes connections asynchronously. There are never more than four threads created, so our system won’t get overloaded if the server receives a lot of requests. If we make a request to /sleep, the server will be able to serve other requests by having another thread run them.

Note: If you open /sleep in multiple browser windows simultaneously, they might load one at a time in five-second intervals. Some web browsers execute multiple instances of the same request sequentially for caching reasons. This limitation is not caused by our web server.

This is a good time to pause and consider how the code in Listings 21-18, 21-19, and 21-20 would be different if we were using futures instead of a closure for the work to be done. What types would change? How would the method signatures be different, if at all? What parts of the code would stay the same?

After learning about the while let loop in Chapter 17 and Chapter 19, you might be wondering why we didn’t write the Worker thread code as shown in Listing 21-21.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --taglio--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} ha un lavoro; in esecuzione.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listato 21-21: An alternative implementation of Worker::new using while let

This code compiles and runs but doesn’t result in the desired threading behavior: a slow request will still cause other requests to wait to be processed. The reason is somewhat subtle: the Mutex struct has no public unlock method because the ownership of the lock is based on the lifetime of the MutexGuard<T> within the LockResult<MutexGuard<T>> that the lock method returns. At compile time, the borrow checker can then enforce the rule that a resource guarded by a Mutex cannot be accessed unless we hold the lock. However, this implementation can also result in the lock being held longer than intended if we aren’t mindful of the lifetime of the MutexGuard<T>.

The code in Listing 21-20 that uses let job = receiver.lock().unwrap().recv().unwrap(); works because with let, any temporary values used in the expression on the right-hand side of the equal sign are immediately dropped when the let statement ends. However, while let (and if let and match) does not drop temporary values until the end of the associated block. In Listing 21-21, the lock remains held for the duration of the call to job(), meaning other Worker instances cannot receive jobs.

Graceful Shutdown and Cleanup

The code in Listing 21-20 is responding to requests asynchronously through the use of a thread pool, as we intended. We get some warnings about the workers, id, and thread fields that we’re not using in a direct way that reminds us we’re not cleaning up anything. When we use the less elegant ctrl-C method to halt the main thread, all other threads are stopped immediately as well, even if they’re in the middle of serving a request.

Next, then, we’ll implement the Drop trait to call join on each of the threads in the pool so they can finish the requests they’re working on before closing. Then we’ll implement a way to tell the threads they should stop accepting new requests and shut down. To see this code in action, we’ll modify our server to accept only two requests before gracefully shutting down its thread pool.

One thing to notice as we go: none of this affects the parts of the code that handle executing the closures, so everything here would be just the same if we were using a thread pool for an async runtime.

Implementing the Drop Trait on ThreadPool

Let’s start with implementing Drop on our thread pool. When the pool is dropped, our threads should all join to make sure they finish their work. Listing 21-22 shows a first attempt at a Drop implementation; this code won’t quite work yet.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Spegnimento worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} ha un lavoro; in esecuzione.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listato 21-22: Joining each thread when the thread pool goes out of scope

First we loop through each of the thread pool workers. We use &mut for this because self is a mutable reference, and we also need to be able to mutate worker. For each worker, we print a message saying that this particular Worker instance is shutting down, and then we call join on that Worker instance’s thread. If the call to join fails, we use unwrap to make Rust panic and go into an ungraceful shutdown.

Here is the error we get when we compile this code:

$ cargo check
    Checking hello v0.1.0 (file:///progetti/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:1876:17

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

The error tells us we can’t call join because we only have a mutable borrow of each worker and join takes ownership of its argument. To solve this issue, we need to move the thread out of the Worker instance that owns thread so join can consume the thread. One way to do this is by taking the same approach we did in Listing 18-15. If Worker held an Option<thread::JoinHandle<()>>, we could call the take method on the Option to move the value out of the Some variant and leave a None variant in its place. In other words, a Worker that is running would have a Some variant in thread, and when we wanted to clean up a Worker, we’d replace Some with None so the Worker wouldn’t have a thread to run.

However, the only time this would come up would be when dropping the Worker. In exchange, we’d have to deal with an Option<thread::JoinHandle<()>> anywhere we accessed worker.thread. Idiomatic Rust uses Option quite a bit, but when you find yourself wrapping something you know will always be present in an Option as a workaround like this, it’s a good idea to look for alternative approaches to make your code cleaner and less error-prone.

In this case, a better alternative exists: the Vec::drain method. It accepts a range parameter to specify which items to remove from the vector and returns an iterator of those items. Passing the .. range syntax will remove every value from the vector.

So we need to update the ThreadPool drop implementation like this:

File: src/lib.rs
#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Spegnimento worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} ha un lavoro; in esecuzione.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

This resolves the compiler error and does not require any other changes to our code. Note that, because drop can be called when panicking, the unwrap could also panic and cause a double panic, which immediately crashes the program and ends any cleanup in progress. This is fine for an example program, but isn’t recommended for production code.

Signaling to the Threads to Stop Listening for Jobs

With all the changes we’ve made, our code compiles without any warnings. However, the bad news is that this code doesn’t function the way we want it to yet. The key is the logic in the closures run by the threads of the Worker instances: at the moment, we call join, but that won’t shut down the threads, because they loop forever looking for jobs. If we try to drop our ThreadPool with our current implementation of drop, the main thread will block forever, waiting for the first thread to finish.

To fix this problem, we’ll need a change in the ThreadPool drop implementation and then a change in the Worker loop.

First we’ll change the ThreadPool drop implementation to explicitly drop the sender before waiting for the threads to finish. Listing 21-23 shows the changes to ThreadPool to explicitly drop sender. Unlike with the thread, here we do need to use an Option to be able to move sender out of ThreadPool with Option::take.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --taglio--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        // --taglio--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Spegnimento worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} ha un lavoro; in esecuzione.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listato 21-23: Explicitly dropping sender before joining the Worker threads

Dropping sender closes the channel, which indicates no more messages will be sent. When that happens, all the calls to recv that the Worker instances do in the infinite loop will return an error. In Listing 21-24, we change the Worker loop to gracefully exit the loop in that case, which means the threads will finish when the ThreadPool drop implementation calls join on them.

File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Spegnimento worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} ha un lavoro; in esecuzione.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnesso; spegnimento.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}
Listato 21-24: Explicitly breaking out of the loop when recv returns an error

To see this code in action, let’s modify main to accept only two requests before gracefully shutting down the server, as shown in Listing 21-25.

File: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            gestisci_connessione(stream);
        });
    }

    println!("Spegnimento.");
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "ciao.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "ciao.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listato 21-25: Shutting down the server after serving two requests by exiting the loop

You wouldn’t want a real-world web server to shut down after serving only two requests. This code just demonstrates that the graceful shutdown and cleanup is in working order.

The take method is defined in the Iterator trait and limits the iteration to the first two items at most. The ThreadPool will go out of scope at the end of main, and the drop implementation will run.

Start the server with cargo run, and make three requests. The third request should error, and in your terminal you should see output similar to this:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

You might see a different ordering of Worker IDs and messages printed. We can see how this code works from the messages: Worker instances 0 and 3 got the first two requests. The server stopped accepting connections after the second connection, and the Drop implementation on ThreadPool starts executing before Worker 3 even starts its job. Dropping the sender disconnects all the Worker instances and tells them to shut down. The Worker instances each print a message when they disconnect, and then the thread pool calls join to wait for each Worker thread to finish.

Notice one interesting aspect of this particular execution: the ThreadPool dropped the sender, and before any Worker received an error, we tried to join Worker 0. Worker 0 had not yet gotten an error from recv, so the main thread blocked, waiting for Worker 0 to finish. In the meantime, Worker 3 received a job and then all threads received an error. When Worker 0 finished, the main thread waited for the rest of the Worker instances to finish. At that point, they had all exited their loops and stopped.

Congrats! We’ve now completed our project; we have a basic web server that uses a thread pool to respond asynchronously. We’re able to perform a graceful shutdown of the server, which cleans up all the threads in the pool.

Here’s the full code for reference:

File: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            gestisci_connessione(stream);
        });
    }

    println!("Spegnimento.");
}

fn gestisci_connessione(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "ciao.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "ciao.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
File: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Crea un nuovo ThreadPool.
    ///
    /// La dimensione é il numero di thread nel gruppo.
    ///
    /// # Panics
    ///
    /// La funzione `new` genera panic se la dimensione é zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Spegnimento worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} ha un lavoro; in esecuzione.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnesso; spegnimento.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

We could do more here! If you want to continue enhancing this project, here are some ideas:

  • Add more documentation to ThreadPool and its public methods.
  • Add tests of the library’s functionality.
  • Change calls to unwrap to more robust error handling.
  • Use ThreadPool to perform some task other than serving web requests.
  • Find a thread pool crate on crates.io and implement a similar web server using the crate instead. Then compare its API and robustness to the thread pool we implemented.

Summary

Well done! You’ve made it to the end of the book! We want to thank you for joining us on this tour of Rust. You’re now ready to implement your own Rust projects and help with other people’s projects. Keep in mind that there is a welcoming community of other Rustaceans who would love to help you with any challenges you encounter on your Rust journey.

Appendice

Le seguenti sezioni contengono materiale di riferimento che potrebbe esserti utile nel tuo percorso con Rust.

Appendice A: Parole chiave

Il seguente elenco contiene parole chiave che sono riservate per l’uso attuale o futuro del linguaggio Rust. In quanto tali, non possono essere utilizzate come identificatori (tranne che come identificatori grezzi, come discuteremo nella sezione dedicata). Gli identificatori sono nomi di funzioni, variabili, parametri, elementi di struct, moduli, crate, costanti, macro, valori statici, attributi, type, trait o lifetime.

Parole Chiave Attualmente in Uso

Di seguito è riportato un elenco di parole chiave attualmente in uso, con la loro funzionalità descritta.

  • as - eseguire un casting primitivo, disambiguare il trait specifico che contiene un elemento o rinominare elementi nelle dichiarazioni use
  • async - restituire un Future invece di bloccare il thread corrente
  • await - sospendere l’esecuzione fino a quando il risultato di un Future è pronto
  • break - uscire immediatamente da un ciclo
  • const - definire elementi costanti o puntatori raw costanti
  • continue - continuare all’iterazione successiva del ciclo
  • crate - in un percorso di modulo, si riferisce alla radice del crate
  • dyn - dispatch dinamico a un oggetto trait
  • else - alternativa per i costrutti di controllo di flusso if e if let
  • enum - definire un’enumerazione
  • extern - collegare una funzione o una variabile esterna
  • false - letterale booleano falso
  • fn - definire una funzione o il tipo di puntatore a funzione
  • for - iterare su elementi da un iteratore, implementare un trait o specificare una lifetime di rango superiore
  • if - ramificazione in base al risultato di un’espressione condizionale
  • impl - implementare funzionalità innate o di trait
  • in - parte della sintassi del ciclo for
  • let - inizializzare una variabile
  • loop - ciclo senza condizioni
  • match - abbinare un valore a pattern
  • mod - definire un modulo
  • move - fare in modo che una closure prenda possesso di tutte le sue catture
  • mut - denotare mutabilità in reference, puntatori raw o binding di pattern
  • pub - denotare visibilità pubblica nei campi delle strutture, nei blocchi impl o nei moduli
  • ref - inizializzare per reference
  • return - ritorno dalla funzione
  • Self - un alias di type per il type che stiamo definendo o implementando
  • self - soggetto del metodo o modulo corrente
  • static - variabile globale o lifetime che dura per l’intera esecuzione del programma
  • struct - definire una struttura
  • super - modulo genitore del modulo corrente
  • trait - definire un trait
  • true - letterale booleano vero
  • type - definire un alias di type o un type associato
  • union - definire un’unione; è solo una parola chiave quando utilizzata in una dichiarazione di unione
  • unsafe - denotare codice, funzioni, trait o implementazioni non sicure
  • use - portare simboli in scope; specificare catture precise per vincoli generici e di lifetime
  • where - denotare clausole che vincolano un type
  • while - ciclo condizionato al risultato di un’espressione

Parole Chiave Riservate per Usi Futuri

Le seguenti parole chiave non hanno ancora alcuna funzionalità ma sono riservate da Rust per un potenziale uso futuro.

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

Identificatori Grezzi

Gli Identificatori grezzi (raw identifiers) sono la sintassi che ti permette di utilizzare parole chiave dove normalmente non sarebbero consentite. Utilizzi un identificatore grezzo anteponendo a una parola chiave il prefisso r#. Ad esempio, match è una parola chiave. Se provi a compilare la seguente funzione che utilizza match come nome:

File: src/main.rs

fn match(ago: &str, pagliaio: &str) -> bool {
    pagliaio.contains(ago)
}

otterrai questo errore:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(ago: &str, pagliaio: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

L’errore indica che non è possibile utilizzare la parola chiave match come identificatore di funzione. Per utilizzare match come nome di funzione, devi utilizzare la sintassi dell’identificatore grezzo, in questo modo:

File: src/main.rs

fn r#match(ago: &str, pagliaio: &str) -> bool {
    pagliaio.contains(ago)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

Questo codice verrà compilato senza errori. Nota il prefisso r# sul nome della funzione nella sua definizione e il punto in cui la funzione viene chiamata in main.

Gli identificatori grezzi ti permettono di utilizzare qualsiasi parola che scegli come identificatore, anche se si tratta di una parola chiave riservata. Questo ci dà maggiore libertà nella scelta dei nomi degli identificatori e ci permette di integrarci con programmi scritti in un linguaggio in cui queste parole non sono parole chiave. Inoltre, gli identificatori grezzi ti permettono di utilizzare librerie scritte in un’edizione di Rust diversa da quella utilizzata dal tuo crate. Per esempio, try non è una parola chiave nell’edizione 2015, ma lo è nelle edizioni 2018, 2021 e 2024. Se dipendi da una libreria scritta con l’edizione 2015 e che ha una funzione try, dovrai utilizzare la sintassi dell’identificatore grezzo, r#try in questo caso, per richiamare quella funzione dal tuo codice nelle edizioni successive. Per ulteriori informazioni sulle edizioni, consulta Appendice E.

Appendice B: Operatori e Simboli

Questa appendice contiene un glossario della sintassi di Rust, compresi gli operatori e altri simboli che appaiono da soli o nel contesto di percorsi, generics, traits, macro, attributi, commenti, tuple e parentesi.

Operatori

La Tabella B-1 contiene gli operatori in Rust, un esempio di come l’operatore appare nel contesto, una breve spiegazione e se l’operatore in questione è sovrascrivibile. Se un operatore è sovrascrivibile, viene elencato il relativo trait da utilizzare per sovrascriverlo.

Tabella B-1: Operatori

OperatoreEsempioSpiegazioneSovrascrivibile
!ident!(...), ident!{...}, ident![...]Espansione delle macro
!!exprComplemento logico o bit per bitNot
!=expr != exprDifferentePartialEq
%expr % exprResto aritmeticoRem
%=var %= exprResto aritmetico con assegnazioneRemAssign
&&expr, &mut exprPrestito (Borrow)
&&type, &mut type, &'a type, &'a mut typeType Puntatore a Prestito
&expr & exprAND Bit per BitBitAnd
&=var &= exprAND Bit per Bit con assegnazioneBitAndAssign
&&expr && exprAND logico
*expr * exprMoltiplicazione aritmeticaMul
*=var *= exprMoltiplicazione aritmetica con assegnazioneMulAssign
**exprDe-referenziazioneDeref
**const type, *mut typePuntatore grezzo (Raw pointer)
+trait + trait, 'a + traitVincolo per type composto
+expr + exprAddizione aritmenticaAdd
+=var += exprAddizione aritmentica con assegnazioneAddAssign
,expr, exprSeparatore di argomenti ed elementi
-- exprNegazione aritmeticaNeg
-expr - exprSottrazione aritmeticaSub
-=var -= exprSottrazione aritmetica con assegnazioneSubAssign
->fn(...) -> type, |…| -> typeType di ritorno per funzioni e chiusure
.expr.identAccesso a campo
.expr.ident(expr, ...)Chiamata a metodo
.expr.0, expr.1, etc.Indicizzazione tupla
...., expr.., ..expr, expr..exprRange esclusivoPartialOrd
..=..=expr, expr..=exprRange inclusivoPartialOrd
....exprAggiornamento struct
..variant(x, ..), struct_type { x, .. }“E il resto” con assrgnazione tramite pattern
...expr...expr(Non più utilizzabile, usa ..=) In un pattern: range inclusivo
/expr / exprDivisione aritmeticaDiv
/=var /= exprDivisione aritmetica con assegnazioneDivAssign
:pat: type, ident: typeVincoli
:ident: exprInizializzazione campo di struct
:'a: loop {...}Etichetta loop
;expr;Terminatore di dichiarazioni ed elementi
;[...; len]Parte della sintassi per vettori a grandezza fissa
<<expr << exprShift a sinistraShl
<<=var <<= exprShift a sinistra con assegnazioneShlAssign
<expr < exprMinorePartialOrd
<=expr <= exprMinore o ugualePartialOrd
=var = expr, ident = typeAssegnazione/equivalenza
==expr == exprComparazione di egualitàPartialEq
=>pat => exprParte della sintassi del ramo di match
>expr > exprMaggiorePartialOrd
>=expr >= exprMaggiore o ugualePartialOrd
>>expr >> exprShift a destraShr
>>=var >>= exprShift a destra con assegnazioneShrAssign
@ident @ patVincolo di Pattern
^expr ^ exprOR esclusivo Bit per BitBitXor
^=var ^= exprOR esclusivo Bit per Bit con assegnazioneBitXorAssign
|pat | patPattern alternativi
|expr | exprOR Bit per BitBitOr
|=var |= exprOR Bit per Bit con assegnazioneBitOrAssign
||expr || exprOR logico
?expr?Propagazione errore

Simboli

L’elenco seguente contiene tutti i simboli che non funzionano come operatori, cioè non si comportano come una funzione o una chiamata di metodo.


La Tabella B-2 mostra i simboli che appaiono da soli e sono validi in diverse posizioni.

Tabella B-2: Sintassi stand alone

SimboloSpiegazione
'identLifetime nominale o etichetta loop
Numeri immediatamente seguiti da u8, i32, f64, usize, ecc.Letterale numerico di un type specifico
"..."Letterale stringa
r"...", r#"..."#, r##"..."##, etc.Letterale stringa grezzo, senza elaborazione dei caratteri di escape
b"..."Letterale byte di stringa; costituisce un vettore di byte anzichè una stringa
br"...", br#"..."#, br##"..."##, etc.Letterale byte di stringa grezzo
'...'Letterale carattere
b'...'Letterale byte ASCII
|…| exprChiusure
!Type vuoto per funzioni divergenti
_Pattern “Ignorato” nel ramo di match; usato anche per rendere i letterali più leggibili

La Tabella B-3 mostra i simboli che venmgonb usati nel contesto dei percorosi di un elemento nella gerarchia dei moduli.

Tabella B-3: Sintassi relativa ai Percorsi

SimboloSpiegazione
ident::identNomenclatura percorso
::pathPercorso relativo al preludio esterno, dove sono tutti gli altri crate (es., un percorso assoluto esplicito che include il nome del crate)
self::pathPercorso relativo al modulo corrente (es., un percorso relativo esplicito).
super::pathPercorso relativo al genitore del modulo corrente
type::ident, <type as trait>::identCostanti, funzioni o type associati
<type>::...Elemento associato per un type generico (es, <&T>::..., <[T]>::..., etc.)
trait::method(...)Disambiguare una chiamata a un metodi specificando il trait che lo definisce
type::method(...)Disambiguare una chiamata a un metodo specificando il type in cui per cui è definito
<type as trait>::method(...)Disambiguare una chiamata a un metodo specificando il trait e il type

La Tabella B-4 mostra i simboli che appaiono quando si usano type generici come parametri.

Table B-4: Generici

SimboloSpiegazione
percorso<...>Specifica parametri a type generici in u n type (es., Vec<u8>)
percorso::<...>, metodo::<...>Specifica parametri a type, funzioni, metodi genirici in un’espressione; spesso chiamato operatore turbofish (e.g., "42".parse::<i32>())
fn ident<...> ...Definizione di funzione generica
struct ident<...> ...Definizione di struct generica
enum ident<...> ...Definizione di enum generica
impl<...> ...Definizione di implementazione generica
for<...> typeVincolo di lifetime prioritario
type<ident=type>Un type generico dove uno o più type associati hanno assegnazioni specifiche (es., Iteratore<Elemento=T>)

La Tabella B-5 mostra i simboli che appaiono nel contesto della dichiarazioni di type generici come paramentri e dei corrispettivi vincoli di trait.

Table B-5: Vincoli di Trait

SimboloSpiegazione
T: UParamentro generico T vincolato a type che implementano U
T: 'aType generico type T con longevità 'a (implica cxhe non possa conteenre reference con lifetime inferiore ad 'a)
T: 'staticType generico T contenente solo reference con longevità infinita
'b: 'aLifetime generica 'b deve essere maggiore di lifetime 'a
T: ?SizedConsente a parametri con type generico di essere type a dimensione dimamica
'a + trait, trait + traitDefinizione di vincolo multiplo

La Tabella B-6 mostra i simboli utilizzati nell’ambito della invocazione o definizione di macro e degli attributi di un dato elemento.

Table B-6: Macro e Attributi

SimboloSpiegazione
#[meta]Attributo esterno
#![meta]Attributo interno
$identSostituzione macro
$ident:kindMetavariabile macro
$(...)...Ripetizione macro
ident!(...), ident!{...}, ident![...]Invocazione macro

La Tabella B-7 mostra i simboli che creano commenti.

Tabella B-7: Commenti

SimboloSpiegazione
//Commento in linea
//!Linea interna commento documentazione
///Linea esterna commento documentazione
/*...*/Blocco di commento, commento multilinea
/*!...*/Blocco interno commento documentazione
/**...*/Blocco esterno commento documentazione

La Tablella B-8 mostra i contesti in cui sono usate le parentesi tonde.

Table B-8: Parentesi Tonde

SimboloSpiegazione
()Tupla vuota (unit), sia letterale che type
(expr)Espressione tra parentesi
(expr,)Espressione di tupla con singolo elemento
(type,)Tupla con singolo type
(expr, ...)Espressione tupla
(type, ...)Type tupla
expr(expr, ...)Espressione di chiamata di funzione; usato anche per inizializzare le varianti struct tupla e enum tupla

La Tabella B-9 mostra i contesti di utilizzo delle parentesi graffe.

Tabella B-9: Parentesi Graffe

ContestoSpiegazione
{...}Blocco di codice
Type {...}Struct letterali

La Tabella B-10 mostra i contesti in cui vengono utilizzate le parentesi quadre.

Tabella B-10: Parentesi Quadre

ContestoSpiegazione
[...]Vettore letterale
[x; n]Vettore letterale contenente n copie di x
[type; n]Vettore tipizzato contenente n istanze di type
collezione[i]Indicizzazione di una collezione. Sovraccaricabile (Index, IndexMut)
expr[..], expr[a..], expr[..b], expr[a..b]Indicizzazione in collezioni per estrazione slice, usando Range, RangeFrom, RangeTo, o RangeFull come “indici”

Appendix C: Derivable Traits

In various places in the book, we’ve discussed the derive attribute, which you can apply to a struct or enum definition. The derive attribute generates code that will implement a trait with its own default implementation on the type you’ve annotated with the derive syntax.

In this appendix, we provide a reference of all the traits in the standard library that you can use with derive. Each section covers:

  • What operators and methods deriving this trait will enable
  • What the implementation of the trait provided by derive does
  • What implementing the trait signifies about the type
  • The conditions in which you’re allowed or not allowed to implement the trait
  • Examples of operations that require the trait

If you want different behavior from that provided by the derive attribute, consult the standard library documentation for each trait for details on how to manually implement them.

The traits listed here are the only ones defined by the standard library that can be implemented on your types using derive. Other traits defined in the standard library don’t have sensible default behavior, so it’s up to you to implement them in the way that makes sense for what you’re trying to accomplish.

An example of a trait that can’t be derived is Display, which handles formatting for end users. You should always consider the appropriate way to display a type to an end user. What parts of the type should an end user be allowed to see? What parts would they find relevant? What format of the data would be most relevant to them? The Rust compiler doesn’t have this insight, so it can’t provide appropriate default behavior for you.

The list of derivable traits provided in this appendix is not comprehensive: libraries can implement derive for their own traits, making the list of traits you can use derive with truly open-ended. Implementing derive involves using a procedural macro, which is covered in the “Macros” section of Chapter 20.

Debug for Programmer Output

The Debug trait enables debug formatting in format strings, which you indicate by adding :? within {} placeholders.

The Debug trait allows you to print instances of a type for debugging purposes, so you and other programmers using your type can inspect an instance at a particular point in a program’s execution.

The Debug trait is required, for example, in the use of the assert_eq! macro. This macro prints the values of instances given as arguments if the equality assertion fails so programmers can see why the two instances weren’t equal.

PartialEq and Eq for Equality Comparisons

The PartialEq trait allows you to compare instances of a type to check for equality and enables use of the == and != operators.

Deriving PartialEq implements the eq method. When PartialEq is derived on structs, two instances are equal only if all fields are equal, and the instances are not equal if any fields are not equal. When derived on enums, each variant is equal to itself and not equal to the other variants.

The PartialEq trait is required, for example, with the use of the assert_eq! macro, which needs to be able to compare two instances of a type for equality.

The Eq trait has no methods. Its purpose is to signal that for every value of the annotated type, the value is equal to itself. The Eq trait can only be applied to types that also implement PartialEq, although not all types that implement PartialEq can implement Eq. One example of this is floating point number types: the implementation of floating point numbers states that two instances of the not-a-number (NaN) value are not equal to each other.

An example of when Eq is required is for keys in a HashMap<K, V> so the HashMap<K, V> can tell whether two keys are the same.

PartialOrd and Ord for Ordering Comparisons

The PartialOrd trait allows you to compare instances of a type for sorting purposes. A type that implements PartialOrd can be used with the <, >, <=, and >= operators. You can only apply the PartialOrd trait to types that also implement PartialEq.

Deriving PartialOrd implements the partial_cmp method, which returns an Option<Ordering> that will be None when the values given don’t produce an ordering. An example of a value that doesn’t produce an ordering, even though most values of that type can be compared, is the not-a-number (NaN) floating point value. Calling partial_cmp with any floating-point number and the NaN floating-point value will return None.

When derived on structs, PartialOrd compares two instances by comparing the value in each field in the order in which the fields appear in the struct definition. When derived on enums, variants of the enum declared earlier in the enum definition are considered less than the variants listed later.

The PartialOrd trait is required, for example, for the gen_range method from the rand crate that generates a random value in the range specified by a range expression.

The Ord trait allows you to know that for any two values of the annotated type, a valid ordering will exist. The Ord trait implements the cmp method, which returns an Ordering rather than an Option<Ordering> because a valid ordering will always be possible. You can only apply the Ord trait to types that also implement PartialOrd and Eq (and Eq requires PartialEq). When derived on structs and enums, cmp behaves the same way as the derived implementation for partial_cmp does with PartialOrd.

An example of when Ord is required is when storing values in a BTreeSet<T>, a data structure that stores data based on the sort order of the values.

Clone and Copy for Duplicating Values

The Clone trait allows you to explicitly create a deep copy of a value, and the duplication process might involve running arbitrary code and copying heap data. See Variables and Data Interacting with Clone” in Chapter 4 for more information on Clone.

Deriving Clone implements the clone method, which when implemented for the whole type, calls clone on each of the parts of the type. This means all the fields or values in the type must also implement Clone to derive Clone.

An example of when Clone is required is when calling the to_vec method on a slice. The slice doesn’t own the type instances it contains, but the vector returned from to_vec will need to own its instances, so to_vec calls clone on each item. Thus the type stored in the slice must implement Clone.

The Copy trait allows you to duplicate a value by only copying bits stored on the stack; no arbitrary code is necessary. See “Duplicare Dati Sullo Stack in Chapter 4 for more information on Copy.

The Copy trait doesn’t define any methods to prevent programmers from overloading those methods and violating the assumption that no arbitrary code is being run. That way, all programmers can assume that copying a value will be very fast.

You can derive Copy on any type whose parts all implement Copy. A type that implements Copy must also implement Clone, because a type that implements Copy has a trivial implementation of Clone that performs the same task as Copy.

The Copy trait is rarely required; types that implement Copy have optimizations available, meaning you don’t have to call clone, which makes the code more concise.

Everything possible with Copy you can also accomplish with Clone, but the code might be slower or have to use clone in places.

Hash for Mapping a Value to a Value of Fixed Size

The Hash trait allows you to take an instance of a type of arbitrary size and map that instance to a value of fixed size using a hash function. Deriving Hash implements the hash method. The derived implementation of the hash method combines the result of calling hash on each of the parts of the type, meaning all fields or values must also implement Hash to derive Hash.

An example of when Hash is required is in storing keys in a HashMap<K, V> to store data efficiently.

Default for Default Values

The Default trait allows you to create a default value for a type. Deriving Default implements the default function. The derived implementation of the default function calls the default function on each part of the type, meaning all fields or values in the type must also implement Default to derive Default.

The Default::default function is commonly used in combination with the struct update syntax discussed in “Creare Istanze con la Sintassi di Aggiornamento delle Struct in Chapter 5. You can customize a few fields of a struct and then set and use a default value for the rest of the fields by using ..Default::default().

The Default trait is required when you use the method unwrap_or_default on Option<T> instances, for example. If the Option<T> is None, the method unwrap_or_default will return the result of Default::default for the type T stored in the Option<T>.

Appendice D - Utili Strumenti di sviluppo

In questa appendice parliamo di alcuni utili strumenti di sviluppo che il progetto Rust mette a disposizione: la formattazione automatica, i modi rapidi per applicare le correzioni degli avvisi, un linter e l’integrazione con gli IDE.

Formattazione automatica con `rustfmt

Lo strumento rustfmt riformatta il tuo codice secondo lo stile di codice della comunità. Molti progetti collaborativi utilizzano rustfmt per evitare discussioni su quale stile utilizzare quando si scrive Rust: tutti formattano il loro codice utilizzando lo strumento.

Le installazioni di Rust includono rustfmt come impostazione predefinita, quindi dovresti già avere i programmi rustfmt e cargo-fmt sul tuo sistema. Questi due comandi hanno lo stesso rapporto che esiste tre rustc e cargo, nel senso che rustfmt è il formattatore vero e proprio mentre cargo-fmt usa e comprende le convenzioni di un progetto che utilizza Cargo per quanto riguarda la forattazione di quel progetto. Per formattare un qualsiasi progetto, inserisci quanto segue:

$ cargo fmt

L’esecuzione di questo comando riformatta tutto il codice Rust nel crate corrente. Questo dovrebbe cambiare solo lo stile del codice, non la sua semantica. Per maggiori informazioni su rustfmt, consulta la sua documentazione.

Correggere il Tuo Codice con `rustfix

Lo strumento rustfix è incluso nelle installazioni di Rust ed è in grado di correggere automaticamente gli avvertimenti del compilatore in cui è specificato in modo preciso come quell’errore vada risolto. Probabilmente hai già visto degli avvertimenti del compilatore. Per esempio, considera questo codice:

File: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

In questo caso, stiamo definendo la variabile x come mutabile, ma in realtà non la mutiamo mai. Rust ci avverte di questo:

$ cargo build
   Compiling mioprogramma v0.1.0 (file:///progetti/mioprogramma)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

L’avviso suggerisce di rimuovere la parola chiave mut. Possiamo applicare automaticamente questo suggerimento utilizzando lo strumento rustfix eseguendo il comando cargo fix:

$ cargo fix
    Checking mioprogramma v0.1.0 (file:///progetti/mioprogramma)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

Se guardiamo di nuovo src/main.rs, vedremo che cargo fix ha modificato il codice:

File: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

La variabile x è ora immutabile e l’avviso non appare più.

Puoi anche usare il comando cargo fix per far passare il tuo codice tra diverse edizioni di Rust. Le edizioni sono trattate nell’Appendice E.

Altri strumenti di analisi del codice con Clippy

Lo strumento Clippy è una raccolta di strumenti di analisi, lint in inglese, per analizzare il tuo codice in modo da individuare gli errori più comuni e migliorare il tuo codice Rust. Clippy è incluso nelle installazioni standard di Rust.

Per eseguire i lint di Clippy su qualsiasi progetto Cargo, inserisci quanto segue:

$ cargo clippy

Ad esempio, supponiamo di scrivere un programma che utilizza un’approssimazione di una costante matematica, come il pi greco, come fa questo programma:

File: src/main.rs
fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("l'area del cerchio è {}", x * r * r);
}

Eseguendo cargo clippy su questo progetto si ottiene questo errore:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

Questo errore ti informa che in Rust c’è già definita una costante PI più precisa e che il tuo programma sarebbe più corretto se usassi questa costante. Dovresti quindi modificare il tuo codice per usare la costante PI.

Il codice seguente non produce alcun errore o avviso da parte di Clippy:

File: src/main.rs
fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("l'area del cerchio è {}", x * r * r);
}

Per maggiori informazioni su Clippy, consulta la sua documentazione.

Integrazione nell’IDE con `rust-analyzer

Per aiutare l’integrazione con l’IDE, la comunità di Rust raccomanda l’uso di rust-analyzer. Questo strumento è un insieme di utility incentrate sul compilatore che parlano in Language Server Protocol, che è una specifica per gli IDE e i linguaggi di programmazione per comunicare tra loro. Diversi client possono usare rust-analyzer, come ad esempio il plug-in Rust Analyzer per Visual Studio Code.

Visita la [home page] del progetto rust-analyzerrust-analyzer per le istruzioni di installazione, quindi installa il supporto per il server linguistico nel tuo IDE specifico. Il tuo IDE otterrà funzionalità come l’autocompletamento, il salto alla definizione e gli errori in linea.

Appendix E - Editions

In Chapter 1, you saw that cargo new adds a bit of metadata to your Cargo.toml file about an edition. This appendix talks about what that means!

The Rust language and compiler have a six-week release cycle, meaning users get a constant stream of new features. Other programming languages release larger changes less often; Rust releases smaller updates more frequently. After a while, all of these tiny changes add up. But from release to release, it can be difficult to look back and say, “Wow, between Rust 1.10 and Rust 1.31, Rust has changed a lot!”

Every three years or so, the Rust team produces a new Rust edition. Each edition brings together the features that have landed into a clear package with fully updated documentation and tooling. New editions ship as part of the usual six-week release process.

Editions serve different purposes for different people:

  • For active Rust users, a new edition brings together incremental changes into an easy-to-understand package.
  • For non-users, a new edition signals that some major advancements have landed, which might make Rust worth another look.
  • For those developing Rust, a new edition provides a rallying point for the project as a whole.

At the time of this writing, four Rust editions are available: Rust 2015, Rust 2018, Rust 2021, and Rust 2024. This book is written using Rust 2024 edition idioms.

The edition key in Cargo.toml indicates which edition the compiler should use for your code. If the key doesn’t exist, Rust uses 2015 as the edition value for backward compatibility reasons.

Each project can opt in to an edition other than the default 2015 edition. Editions can contain incompatible changes, such as including a new keyword that conflicts with identifiers in code. However, unless you opt in to those changes, your code will continue to compile even as you upgrade the Rust compiler version you use.

All Rust compiler versions support any edition that existed prior to that compiler’s release, and they can link crates of any supported editions together. Edition changes only affect the way the compiler initially parses code. Therefore, if you’re using Rust 2015 and one of your dependencies uses Rust 2018, your project will compile and be able to use that dependency. The opposite situation, where your project uses Rust 2018 and a dependency uses Rust 2015, works as well.

To be clear: most features will be available on all editions. Developers using any Rust edition will continue to see improvements as new stable releases are made. However, in some cases, mainly when new keywords are added, some new features might only be available in later editions. You will need to switch editions if you want to take advantage of such features.

For more details, the Edition Guide is a complete book about editions that enumerates the differences between editions and explains how to automatically upgrade your code to a new edition via cargo fix.

Appendice F: Traduzioni del Libro

Per le risorse in lingue diverse dall’inglese, la maggior parte sono ancora in traduzione; consulta l’etichetta Traduzioni per aiutarci o segnalarci una nuova traduzione!

Appendice G: Come è Fatto Rust e “Nightly Rust”

This appendix is about how Rust is made and how that affects you as a Rust developer.

Stability Without Stagnation

As a language, Rust cares a lot about the stability of your code. We want Rust to be a rock-solid foundation you can build on, and if things were constantly changing, that would be impossible. At the same time, if we can’t experiment with new features, we may not find out important flaws until after their release, when we can no longer change things.

Our solution to this problem is what we call “stability without stagnation”, and our guiding principle is this: you should never have to fear upgrading to a new version of stable Rust. Each upgrade should be painless, but should also bring you new features, fewer bugs, and faster compile times.

Choo, Choo! Release Channels and Riding the Trains

Rust development operates on a train schedule. That is, all development is done on the master branch of the Rust repository. Releases follow a software release train model, which has been used by Cisco IOS and other software projects. There are three release channels for Rust:

  • Nightly
  • Beta
  • Stable

Most Rust developers primarily use the stable channel, but those who want to try out experimental new features may use nightly or beta.

Here’s an example of how the development and release process works: let’s assume that the Rust team is working on the release of Rust 1.5. That release happened in December of 2015, but it will provide us with realistic version numbers. A new feature is added to Rust: a new commit lands on the master branch. Each night, a new nightly version of Rust is produced. Every day is a release day, and these releases are created by our release infrastructure automatically. So as time passes, our releases look like this, once a night:

nightly: * - - * - - *

Every six weeks, it’s time to prepare a new release! The beta branch of the Rust repository branches off from the master branch used by nightly. Now, there are two releases:

nightly: * - - * - - *
                     |
beta:                *

Most Rust users do not use beta releases actively, but test against beta in their CI system to help Rust discover possible regressions. In the meantime, there’s still a nightly release every night:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

Let’s say a regression is found. Good thing we had some time to test the beta release before the regression snuck into a stable release! The fix is applied to master, so that nightly is fixed, and then the fix is backported to the beta branch, and a new release of beta is produced:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

Six weeks after the first beta was created, it’s time for a stable release! The stable branch is produced from the beta branch:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

Hooray! Rust 1.5 is done! However, we’ve forgotten one thing: because the six weeks have gone by, we also need a new beta of the next version of Rust, 1.6. So after stable branches off of beta, the next version of beta branches off of nightly again:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

This is called the “train model” because every six weeks, a release “leaves the station”, but still has to take a journey through the beta channel before it arrives as a stable release.

Rust releases every six weeks, like clockwork. If you know the date of one Rust release, you can know the date of the next one: it’s six weeks later. A nice aspect of having releases scheduled every six weeks is that the next train is coming soon. If a feature happens to miss a particular release, there’s no need to worry: another one is happening in a short time! This helps reduce pressure to sneak possibly unpolished features in close to the release deadline.

Thanks to this process, you can always check out the next build of Rust and verify for yourself that it’s easy to upgrade to: if a beta release doesn’t work as expected, you can report it to the team and get it fixed before the next stable release happens! Breakage in a beta release is relatively rare, but rustc is still a piece of software, and bugs do exist.

Maintenance time

The Rust project supports the most recent stable version. When a new stable version is released, the old version reaches its end of life (EOL). This means each version is supported for six weeks.

Unstable Features

There’s one more catch with this release model: unstable features. Rust uses a technique called “feature flags” to determine what features are enabled in a given release. If a new feature is under active development, it lands on master, and therefore, in nightly, but behind a feature flag. If you, as a user, wish to try out the work-in-progress feature, you can, but you must be using a nightly release of Rust and annotate your source code with the appropriate flag to opt in.

If you’re using a beta or stable release of Rust, you can’t use any feature flags. This is the key that allows us to get practical use with new features before we declare them stable forever. Those who wish to opt into the bleeding edge can do so, and those who want a rock-solid experience can stick with stable and know that their code won’t break. Stability without stagnation.

This book only contains information about stable features, as in-progress features are still changing, and surely they’ll be different between when this book was written and when they get enabled in stable builds. You can find documentation for nightly-only features online.

Rustup and the Role of Rust Nightly

Rustup makes it easy to change between different release channels of Rust, on a global or per-project basis. By default, you’ll have stable Rust installed. To install nightly, for example:

$ rustup toolchain install nightly

You can see all of the toolchains (releases of Rust and associated components) you have installed with rustup as well. Here’s an example on one of your authors’ Windows computer:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

As you can see, the stable toolchain is the default. Most Rust users use stable most of the time. You might want to use stable most of the time, but use nightly on a specific project, because you care about a cutting-edge feature. To do so, you can use rustup override in that project’s directory to set the nightly toolchain as the one rustup should use when you’re in that directory:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

Now, every time you call rustc or cargo inside of ~/projects/needs-nightly, rustup will make sure that you are using nightly Rust, rather than your default of stable Rust. This comes in handy when you have a lot of Rust projects!

The RFC Process and Teams

So how do you learn about these new features? Rust’s development model follows a Request For Comments (RFC) process. If you’d like an improvement in Rust, you can write up a proposal, called an RFC.

Anyone can write RFCs to improve Rust, and the proposals are reviewed and discussed by the Rust team, which is comprised of many topic subteams. There’s a full list of the teams on Rust’s website, which includes teams for each area of the project: language design, compiler implementation, infrastructure, documentation, and more. The appropriate team reads the proposal and the comments, writes some comments of their own, and eventually, there’s consensus to accept or reject the feature.

If the feature is accepted, an issue is opened on the Rust repository, and someone can implement it. The person who implements it very well may not be the person who proposed the feature in the first place! When the implementation is ready, it lands on the master branch behind a feature gate, as we discussed in the “Unstable Features” section.

After some time, once Rust developers who use nightly releases have been able to try out the new feature, team members will discuss the feature, how it’s worked out on nightly, and decide if it should make it into stable Rust or not. If the decision is to move forward, the feature gate is removed, and the feature is now considered stable! It rides the trains into a new stable release of Rust.

Appendice H - Note di Traduzione ( in corso )

In questa appendice verranno raccolte note e indicazioni sulle scelte di traduzione usate nel corso di questo lavoro.

Come regola generale è stato scelto di tradure in italiano termini tecnici che sono di uso comune nella programmazione o in altri linguaggi di programmazione (funzioni, muduli, ecc…) e mantenere in inglese termini che sono specifici del linguaggio Rust.

Segue un elenco, si spera esaustivo, della terminologia usata in questo libro e della traduzione/non-traduzione con una spiegazione della scelta se necessario.

Tipi di dato / Strutture Dati

TerminologiaTermini usati nel libroSpiegazione
TypeTypeTipo di dato.
IntegerIntero
FloatFloat
BooleanBoolean
StructStruct
EnumEnumEnumerazione
TupleTupla / Tuple
CollectionCollezione
ArrayArrayspecificatamente riferito al type Array
VectorVettorespecificatamente riferito al type Vec
Hash MapHash MapMappa hash
SliceSliceRiferimento ad una porzione di dati
String SliceSlice di stringaRiferimento ad una porzione di stringa
ReferenceReference / RiferimentoRiferimento ad una variabile
TraitTraitTratto, Caratteristica
Trait BoundVincolo di Trait
HandleHandlePuntatore ad un thread/processo
String literalLetterale stringa
Numeric literalLettarale numerico

Ownership e varie

TerminologiaTermini usati nel libroItaliano
OwnershipOwnershipPossesso / Proprietà / Controllo di una variabile sui dati che contiene
Borrow CheckerBorrow Checker / Controllo dei prestitiFunzionalità del compilatore Rust per verificare la consistenza dei riferimenti
Borrowed TypeType Preso in prestitoTipo di dato di cui si è ricevuta la ownership
Owned TypeType posseduto / Type con ownership
Borrowing RulesRegole di prestito
LifetimeLifetime / LongevitàUsato sia termine originale che tradotto per facilità di lettura

Concetti del linguaggio

TerminologiaTermini usati nel libroItaliano
CrateCrateContenitore. Mantenuto termine originale per semplicità
PackagePacchetto
PathPath / PercorsoPercorso file o moduli
RootRoot / Radice / Cartella principale
WorkspaceWorkspace / Spazio di lavoroSpazio di lavoro gestito da Cargo
Namespace???
RuntimeEsecuzioneUsato quando si intende l’esecuzione di un programma ecc.
RuntimeRuntimeUsato quando si intende il gestore dei blocchi asincroni (Capitolo 16-17 ecc.)
ClosureChiusuraTermine che si trova anche in altri linguaggi
EnvironmentAmbienteRiferito alle chiusure
RefactoringRefactoring / RiscritturaRiscrivere, spostare parte del codice
PanicPanic / Panico
ReturnRestituire / Ritornare
Return ValueValore di ritorno / Valore restituito
IteratorIteratore
Iterator AdapterAdattatoreSarebbe “Adattatore all’iteratore”
Consuming AdapterConsumatoreSarebbe “Adattatore all’iteratore che consuma l’adattatore”
LazyLazyPigro / Pigrizia

Rust Asincrono

TerminologiaTermini usati nel libroItaliano
ConcurrencyConcorrenza
AsyncAsync / AsincronoUsato il termine originale quando specificamente richiesto, tradotto quando usato nella descrizione meno approfondita.
ThreadThreadMantenuto termine originale per semplicità
TaskTaskMantenuto termine originale per semplicità
Spawned Thread/TaskThread/Task Generato
FutureFutureMantenuto termine originale per semplicità
StreamStreamMantenuto termine originale per semplicità

Gerarchia Moduli

Per la gerarchia tra moduli sono utilizzati termini che si rifanno alla vita reale:

OriginaleTradotto
ParentGenitore
ChildFiglio
AncestorAntenato