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à.
Impostazione di 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.
Elaborazione di 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.
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 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 d’ora in poi) 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)
Gestione dei 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à gli 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.
Stampa di 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à.
Utilizzare un crate per ottenere maggiori funzionalità
Ricorda che un crate è una raccolta di file di codice sorgente in Rust. Il
progetto che stiamo costruento è 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
cargo build
dopo l’aggiunt a del crate rand come dipendenzaPotresti 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.
Garanzia di build riproducibili con il file _Cargo.lock
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. In questo caso, 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 Rustaceani 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.
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}");
}
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 craterand
, ad esempio, eseguicargo doc --open
e clicca surand
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.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
// --snip--
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!"),
}
}
Per prima cosa aggiungiamo un’altra istruzione use
, che porta un type
chiamato std::cmp::Ordering
dalla libreria standard. Il type Ordering
è un
altro 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 paramentro 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 contronto, dall’altra il codice da eseguire se il valore dato a
match
corrisponde al pattern. Rust prende il valore dato a match
e lo
contronta 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.");
// --snip--
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’istatnza 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 “Gestione dei
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
(ndt: ripetere) 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);
// --snip--
println!("Il numero segreto è: {numero_segreto}");
loop {
println!("Inserisci la tua ipotesi.");
// --snip--
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 subottimale:
vogliamo che il gioco si fermi anche quando viene indovinato il numero corretto.
Uscita dopo un’ipotesi corretta
Programmiamo il gioco in modo che esca quando l’utente vince, aggiungendo
un’istruzione break
(ndt: 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}");
// --snip--
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
.
Gestione degli 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.
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();
// --snip--
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}");
// --snip--
match ipotesi.cmp(&numero_segreto) {
Ordering::Less => println!("Troppo piccolo!"),
Ordering::Greater => println!("Troppo grande!"),
Ordering::Equal => {
println!("Hai indovinato!");
break;
}
}
}
}
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.
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;
}
}
}
}
A questo punto, hai costruito con successo il gioco dell’indovinello: complimenti!
Riassunto
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 gli enum.