Domande da colloquio su Rust
Queste sono domande che tornano di continuo nei colloqui di lavoro su Rust, dalle posizioni junior a quelle senior. Ogni risposta è abbastanza breve da dirla ad alta voce, e rimanda alla lezione che spiega l’argomento a fondo.
Non imparare le risposte a memoria parola per parola. Chi ti fa il colloquio se ne accorge, e di solito fa una domanda di approfondimento per vedere se hai capito davvero. Usa questa pagina per controllare quello che sai, poi vai alla lezione collegata per tutto quello che ti sembra traballante. Se sai spiegare una risposta con parole tue e scrivere un piccolo esempio, sei pronto.
D: Che cos’è Cargo?
Sezione intitolata “D: Che cos’è Cargo?”Cargo è lo strumento ufficiale di Rust per compilare i progetti e gestire i pacchetti. Arriva insieme a Rust. Con un solo strumento puoi:
- creare un progetto (
cargo new), - compilarlo ed eseguirlo (
cargo build,cargo run), - eseguire i test (
cargo test), - aggiungere librerie, chiamate crate, da crates.io (
cargo add serde).
Le impostazioni e le dipendenze di un progetto stanno in Cargo.toml. Le versioni esatte vengono bloccate in Cargo.lock, così le build sono ripetibili. Un buon punto in più: altri linguaggi spesso hanno bisogno di diversi strumenti per fare tutto questo, mentre in Rust tutti usano Cargo, quindi ogni progetto ti sembra familiare.
Approfondisci: Cargo e crate, Primi passi.
D: Che differenza c’è tra shadowing e mut?
Sezione intitolata “D: Che differenza c’è tra shadowing e mut?”mut ti permette di cambiare il valore della stessa variabile. Il tipo deve restare lo stesso.
Lo shadowing (oscuramento) significa scrivere di nuovo let con lo stesso nome. Crea una variabile nuova che nasconde quella vecchia. La nuova può perfino avere un tipo diverso.
fn main() { let spaces = " "; let spaces = spaces.len(); // shadowing: una nuova variabile, ora è un numero println!("{spaces}");
let mut count = 1; count += 1; // mut: la stessa variabile riceve un nuovo valore println!("{count}");}Output:
32Lo shadowing è comodo per trasformare un valore un passo alla volta (per esempio, convertire un testo in un numero) senza inventare nomi come input_str e input_num. E la variabile finale resta comunque immutabile.
Approfondisci: Variabili.
D: Che differenza c’è tra una macro e una funzione?
Sezione intitolata “D: Che differenza c’è tra una macro e una funzione?”Una funzione viene chiamata durante l’esecuzione, con un numero fisso di argomenti di tipo preciso. Una macro lavora durante la compilazione: riceve del codice e scrive altro codice prima che il compilatore lo controlli. Le macro si riconoscono dal !, come println!, vec! e format!.
Le macro possono fare cose che le funzioni non possono, come ricevere un numero variabile di argomenti (println!("{} {}", a, b)) o controllare una stringa di formato durante la compilazione. Inoltre le macro di Rust lavorano sulla struttura del codice, non sul testo grezzo, quindi evitano i classici bug delle macro del C:
macro_rules! square { ($x:expr) => { $x * $x };}
fn main() { // In C, una macro basata sul testo darebbe 2 + 1 * 2 + 1 = 5. println!("{}", square!(2 + 1));}Output:
9Il compromesso, detto onestamente: le macro sono più difficili da leggere, scrivere e correggere. Usa una funzione quando una funzione basta.
Approfondisci: Macro.
D: Che cosa significa «astrazioni a costo zero» (zero-cost abstractions)?
Sezione intitolata “D: Che cosa significa «astrazioni a costo zero» (zero-cost abstractions)?”Significa che le funzionalità di alto livello non dovrebbero rendere il programma più lento del codice di basso livello che scriveresti a mano. «Non paghi per quello che non usi, e quello che usi non potresti scriverlo meglio da solo.»
Per esempio, una catena di iteratori come .iter().filter(...).map(...).sum() di solito diventa lo stesso codice macchina di un ciclo scritto a mano. I generici sono un altro esempio: il compilatore crea una copia specializzata per ogni tipo che usi (si chiama monomorfizzazione), quindi durante l’esecuzione non c’è nessuna ricerca.
Sii sincero sulla parte del «costo»: è un costo zero in esecuzione. Paghi comunque in tempo di compilazione, e i generici possono rendere più grande il file del programma.
Approfondisci: Iteratori, Generici.
D: Gli iteratori di Rust sono pigri (lazy)?
Sezione intitolata “D: Gli iteratori di Rust sono pigri (lazy)?”Sì. Metodi come .map() e .filter() da soli non fanno nessun lavoro. Costruiscono una descrizione del lavoro da fare. Non succede niente finché qualcosa non consuma l’iteratore, come .collect(), .sum() o un ciclo for.
fn main() { let numbers = vec![1, 2, 3]; let doubled = numbers.iter().map(|n| { println!("raddoppio {n}"); n * 2 });
println!("Non è ancora successo niente."); let result: Vec<i32> = doubled.collect(); println!("{:?}", result);}Output:
Non è ancora successo niente.raddoppio 1raddoppio 2raddoppio 3[2, 4, 6]È anche il motivo per cui il compilatore ti avvisa se scrivi numbers.iter().map(...); e non usi mai il risultato: non farebbe niente.
Approfondisci: Iteratori.
Ownership e borrowing
Sezione intitolata “Ownership e borrowing”D: Quali sono le regole dell’ownership?
Sezione intitolata “D: Quali sono le regole dell’ownership?”Sono tre:
- Ogni valore in Rust ha un proprietario (owner), cioè una variabile.
- Può esserci un solo proprietario alla volta.
- Quando il proprietario esce dallo scope, il valore viene distrutto (drop) e la sua memoria viene liberata.
Queste regole permettono a Rust di liberare la memoria esattamente al momento giusto, senza garbage collector e senza che tu debba chiamare free a mano. Il compilatore le controlla, quindi errori come «use after free» (uso dopo la liberazione) o «double free» (doppia liberazione) non possono succedere nel codice sicuro.
Approfondisci: Ownership, Scope.
D: Che differenza c’è tra move, copy e clone?
Sezione intitolata “D: Che differenza c’è tra move, copy e clone?”- Move (spostamento): assegnare o passare un valore trasferisce l’ownership. La vecchia variabile non si può più usare. È il comportamento predefinito per tipi come
StringeVec. Non viene copiato niente, a parte un piccolo puntatore, la lunghezza e la capacità. - Copy: per tipi piccoli e semplici come
i32,f64,boolechar, l’assegnazione fa una copia bit per bit poco costosa, ed entrambe le variabili restano utilizzabili. - Clone:
.clone()è una copia profonda esplicita. Per unaString, copia tutto il testo in una nuova zona di memoria. Può costare parecchio, ed è per questo che Rust ti obbliga a scriverlo.
fn main() { let x = 5; let y = x; // Copy: x si può ancora usare println!("{x} {y}");
let a = String::from("ciao"); let b = a.clone(); // Clone: una copia completa del testo let c = a; // Move: `a` non si può più usare dopo questa riga println!("{b} {c}");}Output:
5 5ciao ciaoApprofondisci: Ownership.
D: Che cos’è il trait Copy, e quali tipi lo hanno?
Sezione intitolata “D: Che cos’è il trait Copy, e quali tipi lo hanno?”Copy è un trait marcatore che dice «questo tipo si può duplicare semplicemente copiandone i bit». I tipi Copy vengono copiati invece che spostati quando li assegni.
Gli interi, i numeri in virgola mobile, bool, char, i riferimenti condivisi (&T) e le tuple o gli array fatti solo di tipi Copy sono tutti Copy. String, Vec e Box no, perché possiedono memoria da qualche altra parte, e copiare il puntatore darebbe due proprietari.
Puoi derivarlo per i tuoi tipi, ma solo se ogni campo è Copy:
#[derive(Clone, Copy)]struct User { name: String, age: u32,}error[E0204]: the trait `Copy` cannot be implemented for this type --> src/main.rs:2:8 |1 | #[derive(Clone, Copy)] | ---- in this derive macro expansion2 | struct User { | ^^^^3 | name: String, | ------------ this field does not implement `Copy`Il compilatore dice che il trait Copy non si può implementare per questo tipo, perché il campo name (una String) non implementa Copy.
Copy richiede sempre anche Clone. Inoltre un tipo che implementa Drop non può essere Copy.
Approfondisci: Trait comuni, Ownership.
D: Che differenza c’è tra String e &str?
Sezione intitolata “D: Che differenza c’è tra String e &str?”Stringè un testo posseduto (owned), che può crescere, memorizzato sullo heap. Puoi modificarlo, per esempio conpush_str.&str(una «string slice») è una vista in prestito su un testo. È solo un puntatore e una lunghezza. I letterali stringa come"ciao"sono&str.
Una buona regola: le funzioni che leggono soltanto un testo dovrebbero ricevere un &str. Così accettano entrambi, perché un &String diventa automaticamente un &str.
fn greet(name: &str) { println!("Ciao, {name}!");}
fn main() { let owned: String = String::from("Ana"); let literal: &str = "Ben";
greet(&owned); // un &String funziona dove è atteso un &str greet(literal);
let mut full = owned; full.push_str(" Rossi"); // solo una String può crescere greet(&full);}Output:
Ciao, Ana!Ciao, Ben!Ciao, Ana Rossi!Approfondisci: Stringhe, Slice.
D: Quali sono le regole del borrowing?
Sezione intitolata “D: Quali sono le regole del borrowing?”In ogni momento, per un certo valore, puoi avere o:
- un numero qualsiasi di riferimenti condivisi (
&T), oppure - esattamente un riferimento mutabile (
&mut T),
ma non entrambi. E ogni riferimento deve sempre puntare a dati validi.
Perché? Se qualcuno potesse modificare i dati mentre qualcun altro li sta leggendo, avresti bug come un vettore che sposta la sua memoria mentre tu tieni un puntatore al suo interno. Con i thread, è esattamente quello che si chiama data race. Rust lo impedisce durante la compilazione. Dall’edizione 2018 il compilatore è furbo nel capire quando finisce un prestito (si chiamano non-lexical lifetimes): un prestito dura solo fino al suo ultimo uso, non fino alla fine del blocco.
Approfondisci: Prestito (borrowing).
D: Che cosa sono i lifetime?
Sezione intitolata “D: Che cosa sono i lifetime?”Un lifetime (durata) è il nome che il compilatore dà a «per quanto tempo un riferimento è valido». Ogni riferimento ne ha uno. Nella maggior parte dei casi il compilatore li capisce da solo (si chiama elisione dei lifetime), quindi non li vedi mai.
Li scrivi solo quando il compilatore non riesce a capire che rapporto c’è tra il riferimento in uscita e quelli in entrata, come in una funzione che restituisce una tra due stringhe prese in prestito:
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { if a.len() >= b.len() { a } else { b }}
fn main() { let first = String::from("borrow checker"); let second = String::from("lifetimes"); println!("{}", longest(&first, &second));}Output:
borrow checkerUn punto importante da dire in un colloquio: le annotazioni dei lifetime non cambiano quanto a lungo vive qualcosa. Descrivono solo dei rapporti, così il compilatore può controllare che nessun riferimento sopravviva ai dati a cui punta.
Approfondisci: Lifetime.
Tipi e trait
Sezione intitolata “Tipi e trait”D: Rust non ha null. Che cosa usa al suo posto?
Sezione intitolata “D: Rust non ha null. Che cosa usa al suo posto?”Option<T>, un enum con due varianti: Some(valore) oppure None. Siccome è un tipo diverso da T, il compilatore ti obbliga a gestire il caso None prima di poter usare il valore. Non puoi dimenticare un controllo sul null, perché il codice non compilerebbe.
fn find_user(id: u32) -> Option<&'static str> { match id { 1 => Some("Ana"), 2 => Some("Ben"), _ => None, }}
fn main() { for id in [1, 3] { match find_user(id) { Some(name) => println!("Utente trovato: {name}"), None => println!("Nessun utente con id {id}"), } }}Output:
Utente trovato: AnaNessun utente con id 3Un bel dettaglio in più: Option<&T> e Option<Box<T>> non occupano memoria in più rispetto a un semplice puntatore. Internamente Rust usa la configurazione di bit del «null» per indicare None, quindi qui la sicurezza non costa niente.
Approfondisci: Option, If let.
D: Che cos’è un trait? È la stessa cosa di un’interfaccia?
Sezione intitolata “D: Che cos’è un trait? È la stessa cosa di un’interfaccia?”Un trait definisce un insieme di metodi che un tipo può implementare. È simile a un’interfaccia in Java, C# o Go, ed è un buon modo per cominciare la risposta. Poi cita le differenze:
- I trait possono avere implementazioni predefinite dei metodi.
- Puoi implementare un trait per un tipo dopo che il tipo è stato scritto, anche per tipi che non hai scritto tu, per esempio implementare un tuo trait per
i32. (La «orphan rule», regola dell’orfano, dice che il trait o il tipo deve essere tuo.) - I trait si usano come vincoli sui generici (
T: Display), risolti durante la compilazione, e anche come trait object (dyn Display) durante l’esecuzione. - Rust non ha l’ereditarietà tra classi. Al suo posto ci sono i trait più la composizione.
Approfondisci: Trait, Trait comuni.
D: Che differenza c’è tra generici e trait object?
Sezione intitolata “D: Che differenza c’è tra generici e trait object?”Entrambi permettono a una funzione di lavorare con molti tipi. La differenza è quando viene deciso il tipo:
- I generici (
fn f<T: Speak>(x: &T)) usano il dispatch statico. Il compilatore crea una copia separata della funzione per ogni tipo. Le chiamate sono veloci come normali chiamate di funzione, ma il programma può diventare più grande. - I trait object (
&dyn Speak,Box<dyn Speak>) usano il dispatch dinamico. C’è una sola copia della funzione, e il metodo giusto viene cercato durante l’esecuzione in una tabella (una «vtable»). È un pochino più lento, ma ti permette di mescolare tipi diversi in un’unica collezione.
trait Speak { fn speak(&self) -> String;}
struct Dog;struct Cat;
impl Speak for Dog { fn speak(&self) -> String { String::from("Bau") }}
impl Speak for Cat { fn speak(&self) -> String { String::from("Miao") }}
fn speak_static<T: Speak>(animal: &T) -> String { animal.speak()}
fn speak_dynamic(animal: &dyn Speak) -> String { animal.speak()}
fn main() { println!("{}", speak_static(&Dog)); println!("{}", speak_dynamic(&Cat));
// Solo i trait object possono mescolare tipi diversi in un Vec: let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)]; for animal in &animals { println!("{}", animal.speak()); }}Output:
BauMiaoBauMiaoRegola pratica: parti dai generici. Usa i trait object quando ti serve una collezione di tipi diversi, o quando vuoi contenere i tempi di compilazione e le dimensioni dell’eseguibile.
Approfondisci: Generici, Trait object.
D: Che differenza c’è tra Box<dyn Trait> e impl Trait?
Sezione intitolata “D: Che differenza c’è tra Box<dyn Trait> e impl Trait?”Entrambi ti permettono di dire «un tipo che implementa questo trait» senza nominarlo. La differenza è quanti tipi concreti sono ammessi:
impl Traitsignifica un tipo preciso, che il compilatore conosce ma non ti obbliga a scrivere. È dispatch statico: niente allocazione sullo heap, niente vtable, veloce come se scrivessi tu il nome del tipo. Come argomento (fn f(x: impl Display)) è solo un modo più breve di scrivere un generico. Come valore di ritorno è il modo per restituire cose con tipi lunghi o impossibili da nominare, come closure e catene di iteratori.Box<dyn Trait>è un trait object sullo heap. Il tipo reale può essere diverso ogni volta, deciso durante l’esecuzione, e il metodo viene trovato tramite una vtable. Costa un’allocazione e una chiamata indiretta, ma è l’unico modo per restituire uno tra diversi tipi, o per memorizzarli in un unicoVec.
use std::fmt::Display;
// impl Trait: un solo tipo nascosto, fissato durante la compilazione.fn evens(limit: u32) -> impl Iterator<Item = u32> { (0..limit).filter(|n| n % 2 == 0)}
// Box<dyn Trait>: il tipo reale può cambiare durante l'esecuzione.fn label(as_number: bool) -> Box<dyn Display> { if as_number { Box::new(42) } else { Box::new("quarantadue") }}
fn main() { let v: Vec<u32> = evens(10).collect(); println!("{v:?}"); println!("{}", label(true)); println!("{}", label(false));}Output:
[0, 2, 4, 6, 8]42quarantadueUn bel dettaglio da citare: se cambi label perché restituisca impl Display e togli i box, non compila più. L’if restituisce un i32 e l’else un &str, e impl Trait deve essere un unico tipo. Regola pratica: usa impl Trait come scelta predefinita, e passa a Box<dyn Trait> quando hai davvero bisogno di scegliere tra più tipi durante l’esecuzione.
Approfondisci: Trait object, Generici.
D: Perché match deve essere esaustivo?
Sezione intitolata “D: Perché match deve essere esaustivo?”Un match deve gestire ogni valore possibile, altrimenti il codice non compila. Questo significa che quando aggiungi una nuova variante a un enum, il compilatore ti mostra ogni punto da aggiornare. In altri linguaggi, un case dimenticato in uno switch semplicemente non fa niente, in silenzio.
Puoi usare _ per catturare tutto il resto, ma attenzione: un ramo «pigliatutto» nasconde anche le nuove varianti che aggiungerai in futuro, quindi perdi quella rete di sicurezza.
Approfondisci: Match, Pattern matching, Enum.
Gestione degli errori
Sezione intitolata “Gestione degli errori”D: Come gestisce gli errori Rust senza eccezioni?
Sezione intitolata “D: Come gestisce gli errori Rust senza eccezioni?”Gli errori recuperabili sono normali valori di tipo Result<T, E>: o Ok(valore) o Err(errore). Una funzione che può fallire lo dice nel suo tipo di ritorno, e chi la chiama deve occuparsene. Niente risale in modo invisibile lo stack come fa un’eccezione.
I vantaggi: dalla firma di una funzione vedi che può fallire, e il compilatore ti avvisa se ignori un Result. Il costo è un po’ più di codice da scrivere, che l’operatore ? elimina quasi del tutto.
Approfondisci: Result.
D: Quando usare panic! invece di restituire un Result?
Sezione intitolata “D: Quando usare panic! invece di restituire un Result?”- Restituisci un
Resultquando il fallimento è previsto e chi chiama potrebbe ragionevolmente gestirlo: un file che manca, un input sbagliato dell’utente, un timeout di rete. - Vai in panic quando si tratta di un bug, cioè quando il programma è arrivato in uno stato che «dovrebbe essere impossibile», e continuare sarebbe sbagliato. Accedere a un indice oltre la fine di un vettore va in panic proprio per questo.
I panic vanno bene anche nei test, negli esempi e nei prototipi veloci. In una libreria, evita il panic su input controllati da chi la chiama. Restituisci un errore e lascia decidere a loro.
D: Che differenza c’è tra unwrap, expect e ??
Sezione intitolata “D: Che differenza c’è tra unwrap, expect e ??”unwrap()ti dà il valore dentroOk/Some, oppure va in panic con un messaggio generico.expect("msg")fa lo stesso, ma va in panic con il tuo messaggio. Usalo per spiegare perché sei convinto che non possa fallire.?non va in panic. SuErr/Nonerestituisce l’errore dalla funzione corrente, così chi l’ha chiamata può gestirlo. La funzione stessa deve restituire unResulto unOption.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> { let n: i32 = text.parse()?; // in caso di errore, lo restituisce a chi chiama Ok(n * 2)}
fn main() { println!("{:?}", double("21")); println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("un numero scritto nel codice deve sempre essere valido"); println!("{n}");}Output:
Ok(42)Err(ParseIntError { kind: InvalidDigit })7InvalidDigit significa «cifra non valida». Nel codice di produzione preferisci ?. Usa expect quando un fallimento sarebbe un bug, e tieni unwrap per i test e gli esperimenti veloci.
Approfondisci: Result, Option.
D: Come crei i tuoi tipi di errore?
Sezione intitolata “D: Come crei i tuoi tipi di errore?”Di solito con un enum che elenca i modi in cui il tuo codice può fallire, più le implementazioni di std::fmt::Display e std::error::Error. Implementare From per altri tipi di errore permette a ? di convertirli automaticamente.
In pratica molti progetti usano crate di supporto: thiserror (in inglese) per definire i tipi di errore nelle librerie, e anyhow (in inglese) per le applicazioni che devono solo passare gli errori verso l’alto con un po’ di contesto. Per i programmi veloci, Box<dyn Error> può contenere qualsiasi errore.
Approfondisci: Errori personalizzati.
D: Quando usare anyhow e quando thiserror?
Sezione intitolata “D: Quando usare anyhow e quando thiserror?”Risolvono le due metà dello stesso problema, quindi la risposta sincera è «spesso entrambi»:
thiserrorè per le librerie, o per qualsiasi codice in cui chi chiama deve reagire a errori specifici. Scrivi il tuo enum di errori e#[derive(Error)]compila per te il codice ripetitivo diDisplayedError. Chi chiama riceve un tipo vero su cui può farematch, comeConfigError::MissingField.anyhowè per le applicazioni, dove quasi tutti gli errori vanno solo segnalati, non gestiti uno per uno. Ogni funzione restituisceanyhow::Result<T>,?accetta quasi ogni tipo di errore, e.context("while reading config")aggiunge sopra una spiegazione comprensibile per le persone. Il compromesso: chi chiama non può più fare facilmentematchsull’errore.
Una configurazione comune è thiserror dentro i crate di libreria e anyhow in main e nel resto dell’eseguibile. Nessuno dei due crate è magico. Entrambi tolgono solo codice ripetitivo sopra il trait standard std::error::Error, quindi puoi sempre scrivere la stessa cosa a mano.
Approfondisci: anyhow e thiserror, Errori personalizzati.
Memoria e smart pointer
Sezione intitolata “Memoria e smart pointer”D: Rust ha un garbage collector?
Sezione intitolata “D: Rust ha un garbage collector?”No. La memoria viene liberata quando il suo proprietario esce dallo scope, in un punto che il compilatore conosce in anticipo (si chiama RAII, e il metodo di pulizia è Drop). Questo ti dà prestazioni prevedibili, senza pause del garbage collector.
Quando ti serve davvero un’ownership condivisa, la scegli esplicitamente con il conteggio dei riferimenti (Rc o Arc), che libera il valore quando sparisce l’ultimo proprietario.
Approfondisci: Ownership, Smart pointer.
D: Quando useresti Box, Rc, Arc e RefCell?
Sezione intitolata “D: Quando useresti Box, Rc, Arc e RefCell?”Box<T>: un solo proprietario, valore sullo heap. Usalo per i tipi ricorsivi (come un albero o una lista concatenata), i valori grandi o i trait object (Box<dyn Trait>).Rc<T>: più proprietari in un solo thread. Conta i riferimenti e libera il valore quando il conteggio arriva a zero. I dati sono in sola lettura.Arc<T>: comeRc, ma il conteggio è atomico, quindi si può condividere in sicurezza tra thread. Un po’ più lento diRc.RefCell<T>: ti permette di modificare i dati tramite un riferimento condiviso («mutabilità interna», interior mutability). Le regole del prestito vengono controllate durante l’esecuzione invece che durante la compilazione, e violarle provoca un panic.
Si possono combinare. Rc<RefCell<T>> significa «condiviso e modificabile, in un solo thread». Arc<Mutex<T>> è la versione sicura tra thread.
use std::cell::RefCell;use std::rc::Rc;
fn main() { let shared = Rc::new(RefCell::new(vec![1, 2])); let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow()); println!("proprietari: {}", Rc::strong_count(&shared));}Output:
[1, 2, 3]proprietari: 2Approfondisci: Smart pointer.
D: Si può avere un memory leak in Rust sicuro?
Sezione intitolata “D: Si può avere un memory leak in Rust sicuro?”Sì, ed è onesto dirlo. Rust garantisce la sicurezza della memoria (niente use-after-free, niente puntatori pendenti, niente data race), ma perdere memoria (memory leak) non è considerato insicuro. Due modi comuni:
- Un ciclo di
Rc: due valori che puntano l’uno all’altro conRcnon arrivano mai a un conteggio di zero. La soluzione è rendere una delle due direzioni un riferimentoWeak. - Funzioni come
Box::leakestd::mem::forget, che perdono memoria apposta.
Nel codice di tutti i giorni i leak sono rari, perché l’ownership libera automaticamente quasi tutto.
Approfondisci: Smart pointer.
D: Che cos’è unsafe, e quando si usa?
Sezione intitolata “D: Che cos’è unsafe, e quando si usa?”unsafe marca un blocco (o una funzione) dove puoi fare cinque cose in più che il compilatore non può controllare, come dereferenziare puntatori grezzi, chiamare funzioni unsafe (compreso codice C tramite FFI) o accedere a variabili statiche mutabili. Lì dentro sei tu a garantire che le regole siano rispettate.
Importante: unsafe non spegne il borrow checker né gli altri controlli. Sblocca solo quelle poche operazioni in più.
Si usa per parlare con le librerie C, scrivere strutture dati di basso livello e spremere prestazioni dove il compilatore non riesce a dimostrare che una cosa è sicura. La libreria standard stessa usa unsafe dentro API sicure come Vec. Buona pratica: tieni piccoli i blocchi unsafe, avvolgili in un’API sicura e scrivi un commento // SAFETY: che spiega perché il codice è corretto. La maggior parte del codice applicativo non ne ha mai bisogno.
Approfondisci: Rust unsafe.
Concorrenza e async
Sezione intitolata “Concorrenza e async”D: Che cosa significano Send e Sync?
Sezione intitolata “D: Che cosa significano Send e Sync?”Sono trait marcatori che il compilatore usa per mantenere sicuri i thread:
Send: un valore di questo tipo si può spostare in un altro thread.Sync: un valore di questo tipo si può condividere tramite riferimento tra thread (&TèSend).
La maggior parte dei tipi è entrambe le cose, e il compilatore li implementa automaticamente. La famosa eccezione è Rc, il cui conteggio non è sicuro tra thread. Prova a mandarne uno a un thread, e non compila:
use std::rc::Rc;use std::thread;
fn main() { let data = Rc::new(5); let handle = thread::spawn(move || { println!("{data}"); }); handle.join().unwrap();}error[E0277]: `Rc<i32>` cannot be sent between threads safely ... = help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`Il compilatore dice che Rc<i32> non si può inviare tra thread in sicurezza, perché non implementa il trait Send. Sostituisci Rc con Arc e funziona. È gran parte di quello che si intende con la «concorrenza senza paura» (fearless concurrency) di Rust: gli errori di sicurezza tra thread diventano errori di compilazione.
Approfondisci: Concorrenza.
D: Come condividi dati modificabili tra thread?
Sezione intitolata “D: Come condividi dati modificabili tra thread?”La risposta classica è Arc<Mutex<T>>. Arc permette a più thread di possedere il valore. Mutex fa in modo che un solo thread alla volta possa modificarlo: chiami .lock() e ricevi una guardia che sblocca automaticamente quando esce dallo scope.
use std::sync::{Arc, Mutex};use std::thread;
fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = Vec::new();
for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); }
for handle in handles { handle.join().unwrap(); }
println!("Totale: {}", *counter.lock().unwrap());}Output:
Totale: 4000Cita anche l’alternativa: invece di condividere la memoria, manda messaggi tra i thread con i canali (std::sync::mpsc). E per i contatori semplici, i tipi atomici come AtomicUsize evitano il lock.
Approfondisci: Concorrenza.
D: Come si confrontano i canali con lo stato condiviso tramite Mutex?
Sezione intitolata “D: Come si confrontano i canali con lo stato condiviso tramite Mutex?”Sono due stili per dividere il lavoro tra thread:
- I canali (
std::sync::mpsc) passano messaggi. Un valore viene spostato nel canale ed esce dall’altra parte, quindi un solo thread alla volta lo possiede. Sono adatti alle pipeline e ai gruppi di worker: «i worker producono risultati, un thread li raccoglie». Di solito è più facile ragionarci, perché due thread non toccano mai gli stessi dati. - Lo stato condiviso (
Arc<Mutex<T>>) significa che più thread usano gli stessi dati, a turno tramite un lock. È adatto quando molti thread devono leggere e aggiornare un’unica struttura, come una cache o una tabella di contatori. I rischi sono i deadlock e i rallentamenti quando i thread aspettano continuamente il lock.
Ecco il contatore della risposta precedente, riscritto con un canale. Ogni worker conta per conto suo, senza lock, e invia il suo risultato:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for _ in 0..4 { let tx = tx.clone(); thread::spawn(move || { let mut count = 0; for _ in 0..1000 { count += 1; } tx.send(count).unwrap(); }); } drop(tx); // elimina il mittente originale, altrimenti rx.iter() non finisce mai
let total: i32 = rx.iter().sum(); println!("Totale: {total}");}Output:
Totale: 4000La riga drop(tx) è un ottimo dettaglio da citare: rx.iter() continua ad aspettare finché tutti i mittenti non sono spariti, quindi se te la dimentichi il programma si blocca. In entrambi gli stili, Rust controlla la sicurezza tra thread durante la compilazione. La scelta riguarda quale design è più chiaro per il tuo problema, e molti programmi li usano entrambi.
Approfondisci: Canali, Concorrenza.
D: Rust impedisce tutti i bug di concorrenza?
Sezione intitolata “D: Rust impedisce tutti i bug di concorrenza?”No, e dirlo dimostra che hai capito davvero. Il Rust sicuro impedisce le data race (due thread che toccano la stessa memoria nello stesso momento, con almeno uno che scrive). Non impedisce:
- I deadlock, per esempio due thread che aspettano ciascuno un lock tenuto dall’altro.
- Le race condition nella tua logica, per esempio controllare un valore e poi agire dopo che un altro thread l’ha già cambiato.
Quindi Rust elimina un’intera categoria di bug difficili, ma devi comunque progettare con cura i tuoi lock.
Approfondisci: Concorrenza.
D: Che differenza c’è tra async e thread?
Sezione intitolata “D: Che differenza c’è tra async e thread?”- I thread sono gestiti dal sistema operativo. Ognuno ha il suo stack, e il sistema operativo passa dall’uno all’altro. Sono ottimi per il lavoro che usa molto la CPU. Ma migliaia di thread consumano molta memoria.
- I task async sono molto più leggeri. Una
async fnrestituisce unFuture, e un runtime (di solito Tokio, in inglese) esegue molti future su pochi thread. Quando un task è in attesa (della rete, di un timer, di un file),.awaitlascia girare gli altri task. È l’ideale per i programmi che aspettano molto, come i server web che gestiscono migliaia di connessioni.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String { sleep(Duration::from_millis(ms)).await; // fa finta di aspettare la rete format!("{name} ha finito")}
#[tokio::main]async fn main() { // I due girano in contemporanea, quindi ci vogliono circa 200 ms, non 300 ms. let (a, b) = tokio::join!(fetch("primo", 200), fetch("secondo", 100)); println!("{a}"); println!("{b}");}Output:
primo ha finitosecondo ha finitoDue dettagli sinceri che piacciono a chi fa i colloqui: i future di Rust sono pigri e non fanno niente finché non li attendi con .await, e la libreria standard non include un runtime, quindi devi sceglierne uno. Inoltre il codice bloccante (come un calcolo lungo o std::thread::sleep) dentro un task async blocca gli altri task su quel thread.
Approfondisci: Async/await, Concorrenza.
Consigli per rispondere in un colloquio
Sezione intitolata “Consigli per rispondere in un colloquio”- Comincia semplice, poi vai più a fondo. Dai prima una risposta di una frase («
Rcè un puntatore con conteggio dei riferimenti per l’ownership condivisa in un solo thread»). Poi aggiungi dettagli se sembrano interessati. - Spiega il perché. Le regole di Rust esistono per prevenire bug precisi. Dire «le regole del borrowing impediscono data race e puntatori pendenti» è più convincente che recitare le regole.
- Usa un piccolo esempio. Scrivere tre righe di codice, o descriverle, spesso spiega più di un lungo discorso.
- Sii sincero sui compromessi. Rust ha i suoi costi: compilazione più lenta, una curva di apprendimento, più codice per gestire gli errori. Ammetterlo dimostra esperienza, non debolezza.
- Di’ «non lo so» quando non lo sai, poi ragiona ad alta voce. «Non ho mai usato
Pindirettamente, ma so che riguarda i future che non devono spostarsi in memoria» è un’ottima risposta. - Tieni pronta una storia. Spesso ti chiederanno: «Raccontami di una volta in cui il borrow checker ti ha fermato». Pensa a un esempio reale del tuo codice, e a che cosa ne hai imparato.
- Esercitati scrivendo codice. La pagina Esercizi e i progetti Gioco «indovina il numero» e Lista di cose da fare sono ottimi per scaldarsi.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Quale smart pointer useresti per condividere dati in sola lettura tra thread?
2. Che cosa fa l'operatore ? quando incontra un Err?
3. Quale di questi problemi il Rust sicuro impedisce durante la compilazione?
4. Le annotazioni dei lifetime cambiano quanto a lungo vive un valore?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- The Rust Programming Language (il Rust Book) (in inglese): il libro ufficiale. Quasi tutte le risposte di questa pagina vengono direttamente dai suoi capitoli su ownership, trait, errori e concorrenza.
- Rust by Example (in inglese): brevi esempi eseguibili, comodi per ripassare la sintassi subito prima di un colloquio.
- The Rust Reference (in inglese): le regole precise del linguaggio, per quando ti chiedono «ma esattamente quando succede?».
- The Rustonomicon (in inglese): la guida al Rust
unsafe, con approfondimenti suSend,Synce la disposizione in memoria, per le domande di livello senior. - Asynchronous Programming in Rust (in inglese): come si incastrano future,
async/.awaite runtime, nel libro ufficiale su async del progetto Rust.