Perché rust?
Ma chi ce lo fa fare? Cosa ha di così comodo? Perché c'è tutto questo hype? Ecco una lista dei punti a favore di rust:
Typed
Si, i tipi sono fantastici, almeno sai cosa contiene quella variabile senza che il programma lo scopra a runtime crashando miseramente. (guardo voi js e python)
Compilato
Ci mette qualche secondo a compilare il codice, ma dopo puoi eseguirlo ovunque anche senza runtime (coff coff Java e python), con performance simili a C/C++ (hanno ottimizzazioni simili, a volte rust è più veloce, a volte C è più veloce). Inoltre può essere eseguito su qualsiasi cosa possa eseguire istruzioni (vedi arduino e simili) senza doversi reinventare un compilatore (come invece ha fatto micropython).
Compilatore rompi... scatole
A volte può essere pedante, ma esegue molti controlli, e quando compila abbiamo la certezza che non crashi per stupidi errori ( vedi typesafe). Potete vederlo come più tempo speso nel progettare buon codice che può portare a minor sforzo nel mantenere il codice.
Type safe
I linguaggi non type safe rischiano di avere puntatori che non puntano a niente, vulnerabilità nel codice pronte a creare problemi in seguito. Un programma scritto solo in safe rust ha la garanzia di non avere questi problemi. Ovviamente è possibile generare codice typesafe anche da linguaggi non typesafe, ma il programmatore deve sapere cosa sta facendo (e la storia ci insegna che spesso non è così).
Moderno
La libreria standard include già funzionalità moderne per supportare multithreading, codice asincrono, tcp/ip stack, programmazione funzionale e molto altro. Inoltre già nel codice permette di descrivere documentazione, test, test della documentazione... E già appena scaricato si ha cargo con molti strumenti utili per aumentare la produttività (vedi il capitolo strumenti).
Ricco ecosistema
Essendo più moderno di c non si può pretendere l'ecosistema sia altrettanto ricco e navigato (rust è nato nel 2015, c nel 1983...). In ogni caso c'è un gran sforzo da parte della community per portare a librerie sempre più solide e ricche di funzionalità (136554 librerie su crates.io, con più di 54 miliardi di download). Un esempio potrebbe essere Bevy, un game engine scritto completamente in rust che ha una nuova versione ogni 4/5 mesi circa.
Ok, tutto bello. Ma gli svantaggi?
Ovviamente ci sono svantaggi, come in tutte le cose.
Beginner friendly
Non è di certo un linguaggio semplice per iniziare, ci sono grandi sforzi della community per rendere l'entrata a nuovi sviluppatori il più semplice possibile (vedi https://doc.rust-lang.org/book/ ). Questo libro è un tentativo di semplificare l'entrata a chi ha già un po' di dimestichezza nella programmazione.
Compilazione
Quando il progetto cresce, anche i tempi di compilazione aumentano. Ci sono dei trucchi per tentare di arginare il problema, ad esempio usare linker più performanti, compilare solo il codice di cui si ha veramente bisogno ecc. Ovviamente il compilatore cerca di compilare in parallelo quando possibile, e si crea una cache delle dipendenze, in modo da non ricompilarle ogni volta tutte le librerie, ma almeno una volta vanno compilate. Ovviamente uno svantaggio legato alla cache è che potenzialmente si può usare molto spazio sul disco (in caso di progetti grandi anche svariati GB)
Nuovo
L'ecosistema e il compilatore sono in rapida crescita, ma non ancora maturi al 100%. Pertanto non sempre accade quello che ci si aspetta. In qualche anno questo problema dovrebbe arginarsi da solo.
Pillola rossa o pillola blu
UNA VOLTA ASSAGGIATO IL FRUTTO PROIBITO NON SI TORNA PIU' INDIETRO (scherzo ovviamente)
Ci sono i tempi di esecuzione su un raspberry pi 3b+ con una connessione a 32Mb/s. Sono piuttosto convinto siano degli upper-bound in quasi tutti i casi (e mi auguro di tanto).
Installazione (218s)
Avremo bisogno di rustup per iniziare, è lo strumento che permette di gestire le versioni del compilatore e altri tool
Andate su questo link e seguite le istruzioni: https://rustup.rs/
Per linux e mac-os basta eseguire questo comando: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Ora basta seguire le istruzioni su schermo, consiglio di installare l'ultima versione standard nella posizione di default, ma se avete una buona ragione potete anche personalizzarlo
Ora dovreste poter eseguire rustup -V
e dovreste avere un output simile a:
rustup 1.26.0 (5af9b9484 2023-04-05)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.75.0 (82e1608df 2023-12-21)
e cargo -V
, ottenendo come output:
cargo 1.75.0 (1d8b05cdd 2023-11-20)
Installare e configurare vscode:
Potete usare l'ide che volete, ma noi vi consigliamo vscode con l'estensione per rust (veloce, leggero, gratis, e con il 95% delle funzionalità degli altri ide).
Per installare vscode con un package manager basato su apt, potete eseguire:
sudo apt install code
(87s), altrimenti potete seguire le indicazioni qui https://code.visualstudio.com/Download
Una volta installato, potete aprirlo, e andare sul menù delle estensioni:
, e installate "rust analyzer".
Se preferite farlo da linea di comando:
code --install-extension rust-lang.rust-analyzer
(27 s)
Terminati questi passaggi siete pronti per passare alla lezione 2
In totale mi auguro ci abbiate messo meno di 10 min (6 min su raspberry). In caso contrario vi consiglio l'acquisto di un nuovo pc (al momento di scrivere questo book quel raspberry è più lento di circa il 97% dei dispositivi testati qui), o valutare una connessione ad internet migliore...
Tool
Per iniziare, rustup è difficile che lo utilizzeremo, perché la versione del compilatore è già abbastanza recente se lo avete installato nello scorso anno. per i più avanzati è possibile installare un compilatore "nightly", con parecchie funzionalità in più, ma non ancora stabilizzate. Inoltre è possibile impostare override per alcuni progetti con particolari versioni del compilatore...
Invece Cargo
sarà il nostro migliore amico, lo useremo tantissimo, serve per gestire le dipendenze di un progetto, compilare i progetti, eseguire test, installare eseguibili...
Partiamo dalle basi, con un terminale navigate dove volete creare un progetto di test, ed eseguite cargo new lezione-1
, verrà creata una cartella con tutto l'occorrente per poter iniziare. Apritela con l'ide e avrete un progetto già funzionante.
Se eseguiamo cargo run in un qualunque punto all'interno del progetto dovremmo vedere un Hello, world! comparire nel terminale.
Analizziamo la struttura del crate
(potete vederlo come una sorta di progetto completo, d'ora in poi useremo questo termine)
Cargo.toml
file dove descriviamo le dipendenze del nostro crate e alcune flag di compilazioneCargo.lock
generato in automatico da cargo, serve per tenere traccia delle dipendenze ma non lo useremo maisrc
Cartella dove è contenuto tutto il codice del nostro progettomain.rs
ecco il punto di entrata del nostro programma, da qui partirà la compilazione e l'esecuzione
target
dove vengono compilati i nostri programmi (sempre in automatico), in caso di progetti grandi può diventare grandina (anche decine di GB), eseguite uncargo clean
e verrà ripulita (a discapito dei tempi di compilazione successivi)
Ora possiamo guardare il file main.rs in dettaglio e iniziare a scrivere in rust!
Studiamo Hello World!
fn main() { println!("Hello, world!"); }
Ah, forse è più semplice del previsto.
fn main()
è l'entry point (come il main in C/C++ o di tipo il 90% dei linguaggi){}
le grafe rappresentano un blocco di codice, e servono per raggruppare più istruzioni insieme- le virgolette definiscono una
str
di rust, ne parleremo più in dettaglio più avanti, ma per ora basta vederle come stringhe semplici (e possono contenere unicode 😉) println!()
funzione per la formattazione e la scrittura della stringa sul terminale, al momento prendetela per buona (anche il punto esclamativo è importante, ne parleremo in qualche lezione più avanzata, comunque sappiate che le normali funzioni di rust non hanno bisogno del punto esclamativo, ma println supporta più parametri e pertanto è speciale).;
bisogna metterlo dopo ogni linea di codice (amanti di python, c'è un buon motivo per questo, fidatevi)
Esercizi
- Provate a modificare il messaggio
- Provate a mettere 2 println e vedere che succede
- Esiste anche print!, cosa fa di diverso? Provate
Altri cargo
Ho preso un progetto bello grosso e ho aggiunto i tempi di esecuzione approssimativi per rendere l'idea.
cargo run ( 63s primo build, 11.37s dal secondo in poi)
Compila ed esegue il crate
cargo build ( 63s primo build, 11.37s dal secondo in poi)
Compila il crate senza eseguirlo
Aggiungerei che la prima compilazione è tendenzialmente più lenta rispetto a quelle successive, perché compila tutte le dipendenze, mentre nelle altre ricompila solo quello che cambia.
Questo è uno dei processi più lenti in rust, perché il compilatore deve fare molti controlli, generale molto codice, e applicare ottimizzazioni spinte. Questo accelera l'esecuzione (velocità quasi identiche a C/C++, quindi almeno un 100 di volte più veloce di python).
cargo check ( 30s primo check, 0.28s dal secondo in poi)
Non compila, ma esegue tutti i controlli come se stesse compilando La differenza è che è mooolto più veloce di una compilazione, molto utile
cargo fmt (0.45s)
Prende tutti i file rs presenti nel nostro crate e sistema la formattazione, seguendo le linee guida di rust (aggiungendo spazi, andando a capo quando serve...) Molto comodo, usatelo quando potete
cargo clippy (0.45s)
Vi ricordate clippy nella suite office di microsoft?
Ci guidava e ci consigliava su come creare dei documenti nel modo corretto. Allo stesso modo
cargo clippy
controlla il nostro codice con dei controlli aggiuntivi, dandoci dei consigli su come migliorare il nostro codice (e spesso ci azzecca). Se volete migliorare il vostro codice controllatelo ogni tanto.
cargo test ( 35s primo test, 1s dal secondo in poi)
Compila i test presenti nel nostro crate, e controlla che vengano eseguiti correttamente (e in parallelo). Importantissimo per non fare dei "breaking changes" con refactor o simili.
cargo add
Serve per aggiungere delle dipendenze al nostro progetto.
Ad esempio cargo add rand
aggiunge "rand" al nostro progetto. Le dipendenze sono spesso scaricate da crates.io
Errori
Con la vostra esperienza con rust, potrebbe sembrare che il compilatore sia piuttosto capriccioso e pedantico (e in alcuni casi lo è), ma cercate di vederlo come un alleato. Infatti quasi sempre nel messaggio di errore vi dirà quale è il codice dell'errore, una rapida descrizione, delle indicazioni di solito dettagliate su cosa stia succedendo e a volte un indizio su come risolvere il problema (e come approfondire l'argomento). Se del codice in safe rust compila, strutturalmente abbiamo la certezza che non ci saranno crash imprevisti (niente stack overflow o simili) Un consiglio che posso dirvi è di vederla in questo modo: "sto impiegando più tempo a scrivere il codice perché rust mi obbliga a riflettere alle varie eccezioni che possono triggerarsi, ma una volta compilato ho la certezza matematica che questi problemi non accadranno mai (e quindi codice più facilmente mantenibile)"
Commenti
Ovviamente come in tutti i linguaggi è possibile scrivere commenti, ma in rust non tutti i commenti sono uguali... Per i più temerari lascio questo link dove è possibile visionare le varie alternative, ma in generale useremo questi commenti:
#![allow(unused)] fn main() { // commento su una linea /* commento multilinea */ /// commento per la documentazione su una linea, deve essere immediatamente sopra a una funzione/ struttura struct Hidden; /** commento per la documentazione multilinea, anche lui deve essere immediatamente sopra a una funzione/ struttura */ struct Hidden2; }
Variabili
Non possiamo iniziare a programmare senza sapere come dichiarare e inizializzare le variabili! Rust è un typed language (grazie a dio), e pertanto deve sempre sapere in ogni momento di che tipo è una variabile, e a meno che non gli diciamo altrimenti il tipo non cambia mai. Per dichiarare una variabile basta scrivere
let <nome_variabile>: <tipo> = <valore>;
Dove per un semplice intero possiamo scrivere i32 (intero a 32 bit), e assegnare un intero:
#![allow(unused)] fn main() { let primo_intero: i32 = 3; }
se volete sperimentare con altri tipi, quelli base sono:
- i8: intero a 8 bit
- i16: intero a 16 bit
- i32: intero a 32 bit
- u8: intero senza segno a 8 bit
- u16: intero senza segno a 16 bit
- u32: intero senza segno a 32 bit
- f32: float a 32 bit
- &str: stringhe
- String: stringhe più belle, ne parliamo più avanti
Esercizi
- Provate a dichiarare una variabile e a visualizzarla nel println! (consiglio, cercate la documentazione online su come println! funziona )
- Provate a fare un po' di aritmetica: sommare/dividere interi, e a visualizzare il risultato
- provate a sommare 2 stringhe (questo è un po' più difficile)
If/Else
Ogni linguaggio ha un modo per definire se questa condizione è valida esegui questo codice. In rust esiste l'if:
// NB le parentesi graffe sono obbligatorie, mentre le parentesi tonde intorno alla condizione sono sconsigliate
if condizione{
//se la condizione è vera
//codice
}else{
//se la condizione è falsa
//codice
}
Esercizi
- se un numero è uguale a 0 eseguo del codice altrimenti no
- TODO altri es
Cicli
E se volessimo ripetere del codice?
while condizione{
//ripeti codice fino a quando la condizione è vera
//codice
}
for i in range{
//ripeti codice per ogni valore dentro a range
// esempi di range:
// 0..10 da 0 a 10 con 0 incluso e 10 escluso
// 0..=10 da 0 a 10 con 0 e 10 inclusi
//codice
}
loop{
//ripeti questo codice all'infinito
}
//all'interno di ognuno di questi cicli è possibile usare
break; //esci dal ciclo subito
continue; //salta alla prossima iterazione del ciclo
Esercizi
- Incrementare un valore per 10 volte
- eseguire un ciclo fino a quando non viene verificata una condizione
Funzioni
Per chi non conoscesse il concetto delle funzioni, possiamo vederlo come un "insieme di comandi", che prende in input dei parametri e a volte da in output un valore. In rust la sintassi è:
///questa funzione non da in output niente
fn nome_funzione(parametri){
}
///invece questa funzione da in output un intero
fn ritorno_per_due(val: i32)->i32{
2*val
}
///invece questa funzione da in output un intero (stessa di sopra)
fn ritorno_per_due(val: i32)->i32{
return 2*val;
}
//per chiamare una funzione basta passare i parametri, e leggere il risultato:
let risultato = ritorno_per_due(4);
///... faccio altro
// il valore di ritorno può essere semplicemente inserito come ultimo valore della funzione (SENZA ;) oppure con un return valore; (vedi esempi sopra)
Esercizi
- Scrivere una funzione per visualizzare a schermo un testo, e richiamarla più volte
- Scrivere una funzione che prenda più parametri, e li combini insieme (ad esempio moltiplicazione tra 2 interi)
- Scrivere una funzione, che chiama altre funzioni, che chiamano altre funzioni... divertitevi
- (per chi conosce le funzioni) fare una funzione ricorsiva (ad esempio fibonacci ricorsivo)
- (difficile )provare a mutare un valore preso come argomento (vedi prossimo capitolo)
Mutabilità e visibilità
Vi è mai capitato di non voler prestare un gioco della playstation a un amico? E se me lo rompe? E se non me lo restituisse? E se volessi tenere il mio tessssoro solo per me? Ovviamente rust aiuta con questi problemi (digitalmente parlando), con due strumenti fondamentali.
Mutabilità
Una variabile deve essere modificabile? Allora la dichiaro come modificabile, altrimenti una volta inizializzata è in sola lettura (default). La decisione di cosa deve essere modificabile deve essere fatta oculatamente per evitare il rischio di fare danni. Un esempio: se passo dei soldi a una funzione (immaginate esista il tipo soldi), lascio l'utente della funzione modificarli a piacere? Ovviamente se è un proprietario della banca può vedere ma non toccare niente, e invece l'utente può solo aumentare o calare i soldi attraverso delle funzioni ben definite (ad esempio con un versamento). Da una variabile non mutabile è impossibile creare una variabile mutabile (salvo in alcuni casi attraverso una definizione di una nuova variabile, o strutture particolari).
La mutabilità è riferita alle variabili.
Visibilità
La funzione "aggiungi soldi al conto corrente" non deve essere chiamabile da chiunque, ma solo da alcuni specifici macchinari della banca (ad esempio i bancomat). Ecco che rust ci da uno strumento per decidere cosa e quando devono essere visibili le funzioni. Di default le funzioni sono private, e quindi visibili solo nel modulo corrente. Ad esempio per generare una funzione pubblica è necessario aggiungere pub davanti alla dichiarazione:
#![allow(unused)] fn main() { pub fn sono_in_tv_mamma(mut io_sono_modificabile: i32, io_sono_immutabile: i32){ println!("prima modifiche: {} {}", io_sono_modificabile, io_sono_immutabile); io_sono_modificabile=0; //io_sono_immutabile =3; //impossibile modificare una variabile immutabile, il compilatore si lamenta println!("dopo modifiche: {} {}", io_sono_modificabile, io_sono_immutabile); } sono_in_tv_mamma(4, 7); }
A volte però ci interessa rendere delle funzioni visibili ma non troppo. Un modo per farlo è specificare dove deve essere visibile:
#![allow(unused)] fn main() { mod modulo{ fn test0(){} // privata pub (crate) fn test(){} pub (self) fn test1(){} pub (super) fn test2(){} // pub(in crate::modulo) fn test3(){} // nel playground non compila, ma anche i path si possono usare } //ecc }
Inoltre ci sono varie tecniche avanzate (re-exports e simili) per specificare bene come deve essere visibile. Le vedremo al bisogno
Le regole sulla visibilità valgono per moduli, strutture, funzioni, trait...
Esercizi
- provare a modificare le variabili senza riassegnarle
- definire una funzione pubblica
Ownership and references
Tornando all'esempio della lezione precedente: ma se presto il gioco a un mio amico il gioco rimane mio? Oppure diventa suo? Posso prestarlo a più persone contemporaneamente? E se un giorno bisogna buttarlo via perché occupa spazio nel solaio, chi se ne deve occupare?
Ovviamente e immancabilmente rust ha delle regole ferree (lol, questa era pessima) per gestire questi problemi, e nulla viene lasciato al caso.
Ownership
#![allow(unused)] fn main() { fn test(x: i32){ // ho io la proprietà di x? forse print!("{}", x); } let mut x=3; // adesso abbiamo chiaramente la proprietà di x test(x); // e dopo l'esecuzione della funzione ho ancora la proprietà di x? }
Questo concetto è fondamentale in rust, perché quando finisce lo "scope" (blocco di esecuzioni di codice) tutte le variabili in mio possesso vengono liberate dalla ram (quindi non c'è bisogno di garbage collector o simili porcherie). Nel esempio precedente test prende l'ownership di x, e pertanto sarà compito suo de-allocarla. Quindi se nel blocco di codice iniziale provo ad accedere a x, dopo l'esecuzione della funzione test, il compilatore si lamenterà che il valore non è più presente.
E invece, come faccio a "prestare" un oggetto in modo che la proprietà rimanga mia? Passandolo per reference. Tornando all'esempio di prima, il gioco rimane a casa mia, ma se il mio amico vuole accederci sa come entrare in casa e dove trovare il gioco. E ovviamente esistono le reference mutabili e quelle immutabili (guardare e non toccare).
l'esempio sopra riscritto diventa così:
#![allow(unused)] fn main() { fn test(x: &i32){ // non ho l'ownership, ed inoltre non posso modificarlo print!("{}", x); } let mut x=3; // proprietà di x test(&x); // ho ancora la proprietà, e pertanto posso farci altre operazioni x=7; }
Quante reference posso avere? Sono illimitate? Le regole sono queste:
- la mutabilità è sempre unica, non è possibile che ci siano 2 reference mutabili (se 2 persone modificano insieme cosa succede?)
- se "sacrifico" la mutabilità posso avere infinite reference immutabili (tutti guardano i dati, ma nessuno può modificarlo) (non può esistere una reference mutabile e una immutabile contemporaneamente. Cosa succederebbe se qualcuno modifica mentre qualcun'altro sta leggendo?)
Esercizi
- scrivere una funzione "visualizza_numero" che prenda in input una reference immutabile (e provare a modificare il valore originario)
- scrivere una funzione "moltiplica per due" che non ritorni niente, ma prenda una reference come input
- provare a prendere due reference mutabili della stessa variabile
- cercare di capire chi ha l'ownership
Rust e la gestione degli errori
Durante l'esecuzione di un programma ci sono vari punti in cui possono verificarsi degli errori. È inevitabile, e se si costruisce il software assumendo che gli errori non esistano, ci si ritrova magari con qualcosa con la parvenza di funzionare ma che sotto sotto brulica di bug e vulnerabilità.
Ad esempio, immaginiamo di dover creare un programma con una casella di testo in cui l'utente deve inserire un numero. Mettiamo quindi un bel messaggio che dice "inserire solo numeri qui", così che l'utente sappia di non dover mettere altri caratteri, e poi nella programmazione del programma assumiamo che l'utente abbia effettivamente seguito l'istruzione. Una volta scritto il codice, testiamo il programma a mano, e confermiamo che va tutto bene, poichè inserendo numeri validi il programma fa le cose giuste. Purtroppo però un giorno arriva un utente che sbadatamente inserisce "1234 ", ovvero un numero seguito da uno spazio, nella casella di testo. In base al modo in cui abbiamo scritto la parte del codice che legge numeri dalla casella di testo, possono succedere due cose:
- non aspettandosi il carattere "spazio", viene lanciata un'eccezione che fa crashare il programma, magari facendo perdere tutti i dati non salvati dell'utente
- viene generato un errore silenzioso, ad esempio la conversione da testo a numero potrebbe ritornare
-1
per segnalare che c'è stato un errore, pertanto l'utente crede di aver inserito il numero1234
ed invece il programma ha ricevuto un-1
Entrambi questi scenari non sono belli, e possono avvenire nonostante il programma funzioni perfettamente con input corretti. Rust è molto meticoloso su questo aspetto, per cui non esistono nè eccezioni lanciate in modo incontrollato e ingestibile, nè errori silenziosi. Esistono invece i tipi Result<T, E>
e Option<T>
, che garantiscono che possiamo ottenere il risultato effettivo di un'operazione se e solo se questa ha avuto successo (eliminando errori silenziosi), e che altrimenti siamo praticamente costretti a gestire l'errore correttamente (eliminando crash incontrollati).
Option<T>
È un tipo che contiene:
- un risultato di tipo
T
se tutto è andato bene, e in tal caso si può costruire conSome(valore di tipo T)
- niente se qualcosa è andato storto, e in tal caso si può costruire con
None
Ad esempio, la funzione get()
dei vettori (Vec
) serve per ottenere il valore ad uno specifico indice del vettore, e ritorna un Option
uguale a Some(valore)
se esiste un elemento all'indice fornito, altrimenti ritorna None
. Ad esempio:
fn main() { let vettore = vec![1,7,9]; // vettore.get() ritorna Option<i32>, dato che il vettore contiene interi i32 println!("{:?}, {:?}, {:?}", vettore.get(0), vettore.get(2), vettore.get(7)); // Some(1), Some(9), None }
Result<T, E>
È un tipo che contiene:
- un risultato di tipo
T
se tutto è andato bene, e in tal caso si può costruire conOk(valore di tipo T)
- o un errore di tipo
E
se qualcosa è andato storto, e in tal caso si può costruire conErr(valore di tipo E)
Ad esempio:
#![allow(unused)] fn main() { // questa funzione ritorna o un numero intero (i32), // o se qualcosa è andato storto una stringa come errore fn leggi_numero_da_casella_di_testo(testo: &str) -> Result<i32, String> { if testo.is_empty() { // costruiamo un Result contenente un errore e lo ritorniamo normalmente return Err("Una stringa vuota non puo' essere convertita in numero".to_string()) } let mut numero_convertito = 0; for carattere in testo.chars() { if !carattere.is_digit(10) { // costruiamo un Result contenente un errore e lo ritorniamo normalmente return Err(format!("Il carattere non e' una cifra: '{carattere}'")) } // semplice conversione del testo in numero leggendo una cifra alla volta numero_convertito *= 10; numero_convertito += carattere as i32 - '0' as i32; } // la conversione ha avuto successo return Ok(numero_convertito) } println!("Numero senza spazio: {:?}", leggi_numero_da_casella_di_testo("1234")); // Ok println!("Numero con spazio: {:?}", leggi_numero_da_casella_di_testo("1234 ")); // Err println!("Stringa vuota: {:?}", leggi_numero_da_casella_di_testo("")); // Err }
Come gestire gli errori
Per estrarre il valore che ci serve da un Result
o da un Option
abbiamo varie opzioni disponibili.
.unwrap()
.unwrap()
è l'opzione peggiore, e non andrebbe mai usata. In pratica estrae il risultato se esiste, e altrimenti fa crashare il programma!
if let
Il costrutto if let
serve per controllare il contenuto e gestire il caso di successo:
#![allow(unused)] fn main() { fn leggi_numero_da_casella_di_testo(testo: &str) -> Result<i32, String> { if testo.is_empty() { // costruiamo un Result contenente un errore e lo ritorniamo normalmente return Err("Una stringa vuota non puo' essere convertita in numero".to_string()) } let mut numero_convertito = 0; for carattere in testo.chars() { if !carattere.is_digit(10) { // costruiamo un Result contenente un errore e lo ritorniamo normalmente return Err(format!("Il carattere non e' una cifra: '{carattere}'")) } // semplice conversione del testo in numero leggendo una cifra alla volta numero_convertito *= 10; numero_convertito += carattere as i32 - '0' as i32; } // la conversione ha avuto successo return Ok(numero_convertito) } // qui il numero viene convertito correttamente if let Ok(numero_convertito) = leggi_numero_da_casella_di_testo("1234") { println!("Bravo, hai inserito il numero {numero_convertito}"); } else { println!("Oh no..."); } // qui verrà scritto "Il vettore non ha un quinto elemento!" let vettore = vec![1,7,9]; if let Some(elemento_del_vettore) = vettore.get(4) { println!("Il quinto elemento del vettore e' {elemento_del_vettore}"); } else { println!("Il vettore non ha un quinto elemento!"); } }
match
Il costrutto match
è più potente di if let
ed è in particolare utile quando vogliamo spacchettare sia risultato che errore:
#![allow(unused)] fn main() { fn leggi_numero_da_casella_di_testo(testo: &str) -> Result<i32, String> { if testo.is_empty() { // costruiamo un Result contenente un errore e lo ritorniamo normalmente return Err("Una stringa vuota non puo' essere convertita in numero".to_string()) } let mut numero_convertito = 0; for carattere in testo.chars() { if !carattere.is_digit(10) { // costruiamo un Result contenente un errore e lo ritorniamo normalmente return Err(format!("Il carattere non e' una cifra: '{carattere}'")) } // semplice conversione del testo in numero leggendo una cifra alla volta numero_convertito *= 10; numero_convertito += carattere as i32 - '0' as i32; } // la conversione ha avuto successo return Ok(numero_convertito) } // scrive "Errore: Il carattere non e' una cifra: 'a'" match leggi_numero_da_casella_di_testo("abc") { Ok(numero_convertito) => println!("Bravo, hai inserito il numero {numero_convertito}"), Err(errore) => println!("Errore: {errore}"), } }
L'operatore ?
Quando ci sono troppe cose che possono ritornare Option
o Result
, l'operatore ?
può aiutarci a propagare l'errore da una funzione a quella chiamante in modo pratico.
Scrivere let risultato_ok = oggetto?;
equivale a scrivere
let risultato_ok = match oggetto {
Ok(res) => res, // res viene assegnato alla variabile risultato_ok
Err(errore) => return errore, // l'errore viene ritornato dalla funzione corrente
};
Esercizi
- Scrivere un programma che legge due numeri dalla console (ovvero da
stdin
) e scrive la somma in output, assicurandosi di gestire tutti gli errori accuratamente. Nota: non usare la funzioneleggi_numero_da_casella_di_testo
descritta sopra, ma usa invece String::parse(). - Sperimenta con le varie funzioni utili per le Option e i Result descritte nella documentazione.
Structs
Che dire, ne abbiamo fatta di strada. ma di certo mancano ancora alcuni elementi fondamentali per una programmazione completa. Infatti se devo salvare più dati tutti insieme? Ad esempio un membro di Mindshub è descrivibile con nome, cognome, ambito di preferenze (elettronica, informatica, stampa 3d...) e tanti altri parametri.
Se cercassimo di tenere tutti questi dati in variabili separate in un attimo il codice diventerebbe illeggibile, ad esempio:
fn main(){ let nome_persona_1 = "Alessio"; let nome_persona_2 = "Fabio"; let cognome_persona_1 = "Zeni"; let cognome_persona_2 = "Giovanazzi"; let preferenze_persona_1 = "Informatica, Stampa 3d"; let preferenze_persona_2 = "Informatica, Elettronica"; //... e tanta altra roba }
Esiste un modo per fare un po' di ordine? Per raggruppare più variabili tutte insieme? E magari descrivere dei comportamenti complessi legati a questa struttura? Ma certo che si:
/// le strutture possiamo vederle come un insieme di variabili "attaccate insieme" struct MembroMindshub{ nome: String, cognome: String, preferenze: String, } fn main(){ // in questo modo stiamo inizializzando una struttura, quindi <nome struttura>{campo: valore, ...} let persona_1 = MembroMindshub{ nome: "Alessio".to_string(), cognome: "Zeni".to_string(), preferenze: "Informatica, Stampa 3d".to_string(), }; let persona_2 = MembroMindshub{ nome: "Fabio".to_string(), cognome: "Giovanazzi".to_string(), preferenze: "Informatica, Elettronica".to_string(), }; //per accedere ai campi basta usare la sintassi <variabile>.<campo> println!("Io sono {} {}", persona_1.nome, persona_1.cognome); println!("Io sono {} {}", persona_2.nome, persona_2.cognome); }
Direi che è molto più ordinato il codice, e abbiamo appena iniziato!! Possiamo anche aggiungere metodi e funzioni associate, ad esempio:
struct MembroMindshub{ nome: String, cognome: String, preferenze: String, } // i blocchi impl servono per definire delle funzioni/metodi per la struttura. //Possiamo vederli come funzioni speciali che descrivono come si comporta la struttura //quindi qui abbiamo impl <nome struttura> impl MembroMindshub{ // la funzione new è un tipico esempio di funzione associata. Prende dei valori e costruisce la struttura da quei valori fn new(nome: &str, cognome: &str, preferenze: &str)->Self{ //Self (ATTENZIONE CON LA S GRANDE) è una scorciatoia per definire la struttura che stiamo implementando //una normale inizializzazione come nel blocco precedente MembroMindshub{ nome: nome.to_string(), cognome: cognome.to_string(), preferenze: preferenze.to_string(), } } //questo invece è un metodo, e in questo caso prende una reference immutabile alla struttura (e quindi non può modificarne i campi) //riflettendoci in questo caso ha senso. Quando una persona saluta non cambia il nome all'anagrafe... fn saluta(&self){ //possiamo accedere sempre ai campi con <self>.<campo> (ATTENZIONE, s piccola) println!("Ciao. Io sono {} {}", self.nome, self.cognome); } } fn main(){ // inizializzazione con la funzione associata new (per indicarla ci vogliono i ::) let persona_1 = MembroMindshub::new("Alessio", "Zeni", "Informatica, Stampa 3d"); let persona_2 = MembroMindshub::new("Fabio", "Giovanazzi", "Informatica, Elettronica"); // facciamo salutare queste strutture, per farlo chiamiamo il metodo saluta (con il . ) persona_1.saluta(); persona_2.saluta(); }
Direi che la situazione si sta facendo interessante, abbiamo ridotto la funzione main da 10 righe disordinate a 4 righe ordinate!!! Anche l'esperienza di chi utilizzerà la nostra libreria è mooolto migliore.
Traits
Talvolta serve poter definire dei comportamenti condivisi per struct diverse. Ad esempio, ogni animale può fare il proprio verso:
#![allow(unused)] fn main() { trait Animale{ /// Un qualsiasi oggetto che implementa `Animale` dovrà essere in grado /// di fare un verso, ovvero, dovrà implementare questa funzione fn fai_verso(&self); } struct Maiale {}; impl Animale for Maiale { fn fai_verso(&self){ println!("Oink Oink"); } } struct Mucca {}; impl Animale for Mucca { fn fai_verso(&self){ println!("Mouuu"); } } struct Coccodrillo {}; impl Animale for Coccodrillo { fn fai_verso(&self){ // Il coccodrillo come fa? } } }
Usando i trait
quindi si possono descrivere dei comportamenti condivisi, e poi fare in modo che varie struct
li implementino e espongano appunto questi comportamenti condivisi. Questo è particolarmente utile in casi tipo:
- Voglio poter interagire con dispositivi di un certo genere indipendentemente dalla loro marca, e quindi creo un'interfaccia standard che mi permetta di interagire con i dispositivi in modo generico, astraendo i dettagli implementativi. Ad esempio, per un sensore di temperatura si creerebbe un
trait
con una funzioneleggi_temperatura
, e poi unastruct
diversa per ogni possibile sensore di temperatura che si usa. - Voglio poter stampare i contenuti di una particolare
struct
per vederne il contenuto e aiutarmi a debuggare il programma. Per questo la libreria standard di Rust contiene iltrait Display
che viene implementato da variestruct
della libreria standard, e che grazie a questa interfaccia comune possiamo stampare conprintln!("{:?}", qualsiasi_cosa_che_implementi_display)
. - Ho un videogioco con varie entità presenti nel mondo (ad es. animali, appunto), e voglio che il giocatore possa interagire con ogni entità in un modo comune (ad es. facendogli fare il verso).
La descrizione dei trait
fatta in questa pagina è piuttosto introduttiva, e ci sono varie sintassi che non abbiamo trattato per implementare i casi qui sopra. Comunque sappiate che con Cyberorto avremo taaaante interfacce, e implementarle e descriverle sarà necessario, per cui imparerete facendo.
Enums
Cosa succede se ho dei dati alternativi? Ad esempio un Mindshubber può essere interessato solo a un determinato set di interessi. Oppure se voglio enunciare dei colori?
// i colori possono assumere SOLO questi valori qui enum Colori{ Rosso, Verde, Blu } // si possono anche implementare i trait sugli enum: impl Clone for Colori{ fn clone(&self) -> Self{ match self{ &Colori::Rosso => Colori::Rosso, &Colori::Verde => Colori::Verde, &Colori::Blu => Colori::Blu, } } } fn main(){ let t = Colori::Rosso; // in questo caso possiamo usare le stesse cose che usiamo per i result (che sono esattamente enum...) if let Colori::Rosso = t{ println!("ROSSO"); }else{ println!("NON ROSSO"); } match t{ Colori::Rosso => {println!("ROSSO") }, Colori::Verde => {println!("VERDE") }, Colori::Blu => {println!("BLU") }, } }
Derive magic
In alcuni casi possiamo implementare automaticamente un trait per una struttura/enum (FIGATAAAA). Praticamente sono delle macro scritte da qualcun'altro, che prendono in input codice rust per generarne dell'altro.
Ad esempio, se volessimo derivare Clone senza doverlo scrivere a mano possiamo fare così:
#[derive(Clone, Default, Debug)] enum Colori{ #[default] Rosso, Verde, Blu } #[derive(Clone, Default, Debug)] struct Clonami{ ciao: String, sto_finendo_i_nomi_per_le_variabili: Colori, } fn main(){ let t = Clonami::default(); let c = t.clone(); println!("{:?}", c); }
Torneo: Il dilemma del tradimento
Sono sparite le ultime patatine di MindsHub, e i possibili colpevoli sono 2, te e un tuo amico. Siete stati entrambi imprigionati "da voi sapete chi" nella stanza della parkside. Potete decidere se incolpare l'amico oppure no, e avrete come punizione i seguenti secondi di penalità:
- se entrambi vi incolpate a vicenda: 5s a testa
- se A incolpa B ma B non incolpa A 0s e B 7s (e viceversa)
- se nessuno si incolpa avrete 1 s di penalità a testa.
Visto che "voi sapete chi" non si fida di voi ripeterà la domanda ALMENO 1000 volte, e sommerà le penalita acquisite. L'obbiettivo è minimizzare la penalità.
Seguite le seguenti istruzioni per fare una sottoposizione:
- Clonate la repo https://github.com/MindsHub/torneo-1
- Create il vostro crate con il comando:
cargo new --lib <nome>
, possibilmente per nome mettete il vostro nome o simili. L'importante è che sia unico e valido. - eseguite questo comando:
cargo add -p <nome> --path ./template-torneo
(aggiunge il template come dipendenza del vostro crate) - eseguite questo comando:
cargo add -p runner-torneo --path ./<nome>
(aggiunge la dipendenza a runner-torneo) - registrate il vostro crate dentro il file
runner-torneo/main.rs
(troverete le indicazioni dentro il file) - all'interno del vostro lib.rs dovrete implementare la seguente funzione (potete copiarlo da qua sotto):
use template_torneo::LogTradimento; /// Scegliete quando incolpare il vostro avversario e quando no. Che vinca il migliore!! pub fn devo_incolparlo(_me: &LogTradimento, _other: &LogTradimento) -> bool { todo!() }
- Quando siete convinti del vostro risultato potete eseguire in locale il grader con cargo run. Infine pushate sulla repo. (ripetere a piacere)
Regole
Pls non modificate il runner. In ogni caso alla fine verrà testato con il grader originale (o migliorato), ma potreste ostacolare il testing agli altri. allo stesso modo non fate crashare il vostro codice.
Ogni strumento/libreria/stratagemma è valido, potete aggiungere dipendenze o simili. Se il vostro crate fa qualcosa di lesivo verso la competizione viene semplicemente disattivato. Ognuno può sottoporre fino ad un massimo di 5 crate (rivedibile se me lo argomentate bene).
Git
Non c'entra niente con rust ma lo abbiamo inserito a tradimento per darvi comunque una spolveratina (quando si lavora in più persone sullo stesso codice è fondamentale).
Quindi, git è un "software per il controllo delle versioni", e in particolare controlla le varie versioni dei file, e aiuta a invertire cambiamenti dannosi, e ad unire versioni diverse degli stessi file.
Esempio: stiamo tutti insieme e allegramente lavorando all'orto, magari io e Fabio stiamo entrambi lavorando sullo stesso Cargo.toml. Fabio aggiunge una dipendenza e io ne aggiungo un altra. Ognuno di noi salva le proprie modifiche, e dopo un po' decidiamo di unirle (ad esempio io ho lavorato ai motori e lui al planner). Però ci sono due versioni diverse dello stesso file, come devono essere unite?
Vocabolario
Commit
salvataggio dei file allo stato attuale, particella elementare di git
Branch
serie di commit (e quindi di modifiche). Ad esempio su una repository possono esserci più branch, ognuno per una funzionalità che qualcuno sta implementando. Quando è soddisfatto dei risultati fa un merge
Merge
Unire due branch in uno solo, risolvendo eventuali conflitti (auspicabilmente in automatico).
Repository
Un insieme di branch, commit ecc. Può essere in locale o remota
Github
Non è la stessa cosa di git. Git è "il come" vengono gestite le repository. Github è una piattaforma dove si possono pubblicare e gestire repository.
Comandi
git clone <repository>
hey, scaricami questa repository git e mettimela in questa cartellagit add .
guarda cosa ho modificato in questa cartella, e tienilo pronto per la prossima commitgit commit -m "messaggio"
prendi tutte le modifiche registrate, e salvale in una commit (fatelo spesso)git pull
se ci sono modifiche sull'origine di questa repo, scaricale e cerca di unire alle mie modifiche localigit push
carica le mie commit locali sul branch remotogit checkout <branch>
cambia branchgit branch <nome branch>
crea nuovo branch
Documentazione del backend su Raspberry
%%{init: {'theme':'dark'}}%% graph TD web["Pagina web frontend"] api["API"] arduino["Arduino"] web-->api api-->codaazioni stato["Gestore stato del robot"] codaazioni["Coda delle azioni"] azionecorrente["Azione corrente"] codaazioni-->azionecorrente azionecorrente<-->stato api-->stato stato-->arduino
- Pagina web frontend: è l'interfaccia grafica per mostrare al contadino informazioni sull'orto e permettergli di impartire comandi, vedi Frontend. Usa l'API per ottenere informazioni e inviare comandi, e non fa parte del backend.
- API: permette alla pagina web e potenzialmente ad altri strumenti esterni di accedere ad informazioni e inviare comandi. Interagisce con il gestore dello stato per ricevere informazioni sul robot e il suo ambiente in tempo reale, e potenzialmente per modificare parametri o per metterlo in pausa. Interagisce anche con la coda di azioni per leggerla e modificarla (ad esempio aggiungendo, spostando o eliminando azioni).
- Gestore dello stato: riceve comandi da mandare ad Arduino, e tiene traccia dello stato corrente (es. l'acqua è aperta?). Questo permette non solo di esporre questi dati in tempo reale, ma garantisce anche che in caso di crash di qualche azione, il robot non finisce in uno stato inconsistente (es. l'acqua resta aperta).
- Coda di azioni: gestisce una coda di azioni da eseguire una dopo l'altra, tenendo traccia di eventuali pause e priorità, e fa eseguire l'azione corrente.
- Azione corrente: ogni azione esegue una sequenza di comandi (es. seminare un seme richiede andare a prendere il seme e metterlo nella terra). Le azioni devono tenere costantemente traccia del loro stato interno, perchè potrebbero venir messe in pausa o addirittura salvate su disco per essere eseguite più tardi. Pertanto tutte le azioni implementano una interfaccia comune. Esempi di azioni sono: semina, aratura, cattura immagine, ...
- Arduino: Arduino esegue i comandi di basso livello dettati dall'azione corrente e dal gestore dello stato (es. "muoviti alle coordinate (30,20,0)" o "apri l'acqua"). Internamente si occupa della gestione dei motori e della comunicazione con i vari sensori. Arduino è connesso al Raspberry tramite un protocollo di comunicazione, e non fa parte del backend.
Organizzazione
Si può cominciare a lavorare subito e abbastanza indipendentemente a queste parti:
- design e implementazione delle API
- gestore dello stato con rispettive garanzie
- coda delle azioni
- interfaccia delle azioni