Salta ai contenuti

Rust per sviluppatori Java: guida pratica

Se hai passato anni con Java, hai già quasi tutto quello che ti serve per imparare Rust. Ragioni per tipi. Conosci i generici, le interfacce, le lambda e le pipeline di stream. Rust ha tutto questo: i trait assomigliano molto alle interfacce con metodi predefiniti, gli iteratori si leggono come gli stream, e Cargo fa il lavoro di Maven o Gradle, solo con molta meno configurazione.

La novità è tutto quello che prima faceva la JVM al posto tuo. Rust non ha garbage collector né macchina virtuale. Il tuo codice viene compilato in un binario nativo, e il compilatore decide quando liberare la memoria controllando l’ownership (possesso) e il prestito (borrowing) prima che il programma parta. Rust inoltre non ha null (usi Option), non ha eccezioni (usi Result) e non ha ereditarietà tra classi. È su questi quattro cambiamenti che passerai la maggior parte del tempo di studio.

Perché gli sviluppatori Java si avvicinano a Rust? Di solito per uno di questi motivi: servizi che devono usare poca memoria, in modo stabile e senza pause del GC; strumenti da riga di comando e funzioni serverless che devono partire all’istante senza JVM; un percorso critico di un’app Java che vuoi spostare in codice nativo (Rust si può chiamare da Java tramite JNI o la più recente Foreign Function & Memory API); o semplicemente curiosità sul perché Rust continui a vincere i sondaggi sul «linguaggio più amato». Questa pagina mette in corrispondenza quello che conosci di Java con Rust, così puoi concentrarti sulle parti davvero diverse.

Se invece cerchi un confronto generale con C++, Go e Python, guarda Rust vs C++, Go e Python. Se conosci anche .NET, c’è una pagina a parte: Rust per sviluppatori C#.

Usa questa tabella come un frasario. L’ultima colonna rimanda alla lezione di questo sito che spiega il lato Rust.

Java Rust Per saperne di più
class con campi e metodi struct per i dati + un blocco impl per i metodi Struct
Costruttori e new Una funzione normale, per convenzione chiamata new, che restituisce Self Struct
interface trait Trait
Metodi default in un’interfaccia Metodi predefiniti in un trait (funzionano allo stesso modo) Trait
abstract class, extends, super Niente ereditarietà. Usa trait con metodi predefiniti per il comportamento condiviso e la composizione (una struct dentro una struct) per i dati condivisi Trait, Design pattern
List<Shape> con classi diverse Vec<Box<dyn Shape>> (un trait object) Trait object
sealed interface + record + switch con pattern (Java 17/21) Un enum le cui varianti portano dati, più match Enum, match
enum con campi e costruttori enum in cui ogni variante può contenere dati diversi Enum
record Point(int x, int y) #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } Struct, Trait comuni
toString(), equals(), hashCode(), compareTo() Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (di solito con derive) Trait comuni
== (stesso oggetto) contro .equals() (stesso valore) == confronta sempre i valori. Non c’è la trappola dell’«uguaglianza di riferimento» Operatori
null e Optional<T> Option<T>: o Some(valore) o None Option
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) Option, if let
Eccezioni checked e throws Result<T, E> nel tipo di ritorno, più l’operatore ? Result, Errori personalizzati
Eccezioni unchecked per veri bug (IllegalStateException) panic! (ferma il thread) Panic
Concatenazione di eccezioni (new ConfigException(msg, cause)) map_err, o #[source] di thiserror e .context(...) di anyhow anyhow e thiserror
try-with-resources e AutoCloseable Drop, che viene eseguito da solo quando il proprietario esce dallo scope Scope, Smart pointer
Garbage collector Ownership e prestito, controllati in compilazione Ownership, Prestito
Variabili locali final Un semplice let (immutabile per default); scrivi let mut per permettere modifiche Variabili
Costanti static final const Variabili
Campi static modificabili, singleton static con LazyLock o OnceLock, avvolto in un Mutex se deve cambiare Stato condiviso
Generici con <T extends Comparable<T>> Generici con vincoli di trait (trait bound): T: Ord o where T: Ord Generici
Type erasure, List<Integer> invece di List<int> Monomorfizzazione: il codice generico viene compilato per ogni tipo, e Vec<i32> contiene numeri semplici Generici, Generici avanzati
Lambda, Function<T, R>, Supplier<T>, Consumer<T> Closure, descritte dai trait Fn, FnMut, FnOnce Closure, Fn, FnMut e FnOnce
Stream: filter, map, reduce, collect Adattatori di iteratori: filter, map, fold, sum, collect Iteratori
Iterable<T> e Iterator<T> IntoIterator e Iterator (un solo metodo: next, che restituisce un Option) Iteratori personalizzati
parallelStream() par_iter() del crate rayon Concorrenza
Thread, ExecutorService std::thread::spawn e std::thread::scope; rayon o tokio per i pool Concorrenza
synchronized, ReentrantLock Mutex<T>, che avvolge i dati che protegge Stato condiviso
AtomicInteger, AtomicLong AtomicI32, AtomicI64, AtomicUsize Stato condiviso
BlockingQueue tra thread Canali (std::sync::mpsc) Canali
CompletableFuture, virtual thread async fn e .await, eseguiti da un runtime come tokio Async/await
String (immutabile) e StringBuilder &str (una vista presa in prestito su un testo) e String (posseduta, può crescere) Stringhe
char (un’unità UTF-16) char (qualsiasi carattere Unicode, 4 byte); le stringhe sono UTF-8 Stringhe
int, long, double e Integer in un box i32, i64, f64, più i tipi senza segno da u8 a u128. Mai in un box Tipi di dati
Overflow degli interi silenzioso Un panic nelle build di debug; scegli tu con checked_add, wrapping_add, saturating_add Overflow degli interi
Cast come (int) x as per i numeri, From / TryFrom per le conversioni sicure Conversione di tipo, Conversioni
BigDecimal, BigInteger I crate rust_decimal e num-bigint Tipi di dati
Package e import Moduli (mod) e use Moduli
public, private, protected, visibilità di package Privato per default; pub e pub(crate). Non esiste protected Moduli
Un JAR / artefatto Maven Un crate (una libreria o un binario) Cargo e crate
Annotazioni (@Override, @Data di Lombok) Attributi come #[derive(Debug)] e #[test] Macro
Reflection, elaborazione delle annotazioni durante l’esecuzione Quasi niente durante l’esecuzione. Al suo posto, le macro generano codice in compilazione Macro procedurali
Overloading dei metodi Niente overloading. Usa nomi diversi, o un parametro generico Funzioni
Controlli con instanceof e downcast match su un enum (il downcast con Any esiste, ma è raro) Pattern matching
JNI, Foreign Function & Memory API Funzioni extern "C" FFI

Il Java Collections Framework corrisponde in modo pulito a std::collections. La differenza più grande: le collezioni di Rust non restituiscono mai null. Una ricerca ti dà un Option.

Java Rust Per saperne di più
ArrayList<T> Vec<T> Vettori
int[], String[] [T; N] quando la lunghezza è nota in compilazione, altrimenti Vec<T>. Passa l’uno o l’altro come slice &[T] Array, Slice
List.of(...) (non modificabile) Un array, o un Vec che passi in giro solo come &Vec<T> / &[T] (nessuno può modificarlo tramite un riferimento condiviso) Prestito
HashMap<K, V> HashMap<K, V> (get restituisce Option<&V>) HashMap
map.merge(word, 1, Integer::sum) *map.entry(word).or_insert(0) += 1 HashMap
TreeMap<K, V> BTreeMap<K, V> (le chiavi restano ordinate) HashMap
LinkedHashMap<K, V> (ordine di inserimento) IndexMap del crate indexmap
HashSet<T>, TreeSet<T> HashSet<T>, BTreeSet<T> HashSet
ArrayDeque<T> VecDeque<T> VecDeque e BinaryHeap
Stack<T>, o un Deque usato come pila Vec<T> con push / pop Vettori
PriorityQueue<T> (prima il più piccolo) BinaryHeap<T> (prima il più grande; avvolgi gli elementi in Reverse per avere prima il più piccolo) VecDeque e BinaryHeap
LinkedList<T> LinkedList<T> esiste, ma Vec o VecDeque sono quasi sempre più veloci VecDeque e BinaryHeap
ConcurrentHashMap<K, V> Mutex<HashMap<K, V>>, o il crate dashmap Stato condiviso
list.sort(Comparator.comparing(...)) v.sort_by_key(...) o v.sort_by(...) Ordinamento e ricerca
list.removeIf(...) v.retain(...) (tiene gli elementi che corrispondono) Vettori

Come la HashMap di Java, anche una HashMap di Rust non ha un ordine fisso, e l’ordine può cambiare da un’esecuzione all’altra. Se ti serve un risultato ordinato, usa una BTreeMap o ordina prima le chiavi.

La maggior parte degli sviluppatori Java si innamora presto di Cargo. C’è un solo strumento, un solo formato di configurazione e nessun XML di plugin. Build, dipendenze, test, documentazione, formattazione e lint arrivano tutti dallo stesso posto.

Java Rust Note
Maven / Gradle cargo Compila, esegue, testa, aggiunge dipendenze, pubblica. Vedi Cargo e crate
pom.xml / build.gradle.kts Cargo.toml Breve, e per i progetti normali non servono script di build
Blocco delle dipendenze di Gradle Cargo.lock Viene creato e aggiornato per te
Build multi-modulo Un workspace Cargo Un Cargo.toml in cima elenca i crate membri
Maven Central crates.io La documentazione delle API di ogni crate pubblicato è su docs.rs (in inglese)
Aggiungere una <dependency> cargo add serde
mvn archetype:generate / gradle init cargo new my_app (cargo new --lib per una libreria)
mvn compile / mvn test / mvn package cargo build / cargo test / cargo build --release Le build di debug sono molto più lente in esecuzione; misura le prestazioni in release
java -jar app.jar ./target/release/app Un unico binario nativo. Nessuna JVM da installare sul server
Versioni del JDK, SDKMAN!, toolchain rustup e un file rust-toolchain.toml Vedi Strumenti e configurazione
javac rustc Lo chiama Cargo per te; raramente lo esegui a mano
JShell Il Rust Playground nel browser, o evcxr_repl
Checkstyle, SpotBugs, PMD, Error Prone cargo clippy Centinaia di lint ufficiali
google-java-format, Spotless cargo fmt Un solo stile standard; nessuno discute sulle graffe
IntelliJ IDEA, Eclipse JetBrains RustRover, o VS Code + rust-analyzer rust-analyzer offre completamento, vai-alla-definizione ed errori in linea
Javadoc Commenti di documentazione /// in Markdown + cargo doc Gli esempi di codice nella documentazione vengono eseguiti come test. Vedi Documentazione
JUnit 5 #[test] integrato e cargo test (cargo nextest è un runner più veloce molto usato) Vedi Test
@ParameterizedTest rstest
Mockito mockall, o finti oggetti scritti a mano che implementano il tuo trait Vedi Test avanzati
AssertJ / Hamcrest assert!, assert_eq! e pretty_assertions per differenze leggibili
WireMock, Testcontainers wiremock, testcontainers Stesse idee, versioni Rust
JaCoCo cargo llvm-cov o cargo tarpaulin
JMH criterion Vedi Prestazioni
JFR, async-profiler, VisualVM cargo flamegraph (si installa con cargo install flamegraph), perf Nessun GC o JIT da regolare
GraalVM native-image Non serve Ogni build di Rust è già codice nativo

Ecco un piccolo Cargo.toml. Confrontalo con il pom.xml che ti servirebbe per le stesse tre dipendenze:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Librerie e framework: «cosa usiamo al posto di…?»

Sezione intitolata “Librerie e framework: «cosa usiamo al posto di…?»”

È la prima domanda che fa ogni team Java: «Usiamo Spring Boot e Hibernate. Qual è la versione Rust?» Rust non ha uno Spring, un unico framework che porta tutto con sé. Combini invece alcuni crate specializzati. Ecco gli equivalenti più vicini.

Java Rust Note
Spring Boot (Spring MVC / WebFlux) axum (ne costruiamo una in Progetto: API web), actix-web, Rocket axum e actix-web sono molto usati in produzione
@RestController + @GetMapping Rotte di axum che puntano a semplici handler async fn Niente annotazioni né scansione del classpath; colleghi le rotte nel codice
Filtri delle servlet, interceptor di Spring Layer di tower e il crate tower-http (CORS, compressione, tracing, timeout) axum usa tower direttamente
Dependency injection di Spring (@Autowired) State condiviso in axum, costruttori semplici e trait Vedi la riga sulla DI più sotto
springdoc-openapi utoipa
Thymeleaf askama (template controllati in compilazione) o tera
Spring WebSocket WebSocket in axum (la feature ws), o tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic (con prost per protobuf)
java.net.http.HttpClient, OkHttp, WebClient reqwest Asincrono per default, con un client bloccante opzionale
Netty tokio + hyper
Spring Security (JWT, hashing delle password) jsonwebtoken, argon2 Nessun framework di sicurezza tutto-in-uno
Java Rust Note
Hibernate / JPA / Spring Data JPA SeaORM: ORM asincrono con entità, relazioni e migrazioni È il più simile a JPA; costruito sopra sqlx
Hibernate, se ti interessa una tipizzazione rigorosa Diesel: ORM e query builder; le query vengono controllate sui tipi in compilazione Maturo (2.x), sincrono per default (esiste diesel-async)
JDBC, JdbcTemplate, jOOQ, MyBatis sqlx: SQL semplice, e la macro query! lo controlla sul tuo vero database in compilazione Asincrono, molto popolare, non è un ORM
Flyway / Liquibase sqlx migrate (sqlx-cli), diesel migration, o refinery
Jedis / Lettuce (Redis) redis
Client Kafka, Spring Kafka rdkafka (basato su librdkafka)
Client Java di RabbitMQ lapin
AWS SDK for Java L’AWS SDK ufficiale per Rust: aws-config, aws-sdk-s3, …

Se ti piacciono le entità JPA, prova prima SeaORM. Se preferisci scrivere SQL (il popolo di jOOQ e MyBatis), prova sqlx. Se vuoi le garanzie più forti in compilazione, prova Diesel. Database in Rust li mostra tutti e tre con codice funzionante.

Java Rust Note
Jackson / Gson serde + serde_json Lo standard. Gestisce anche TOML, YAML e altro. Vedi JSON con Serde
JAXB, Jackson XML quick-xml (con la sua feature serialize per serde)
SLF4J + Logback / Log4j 2 tracing (+ tracing-subscriber), o log + env_logger Vedi Logging
Lombok Derive integrati (Debug, Clone, PartialEq, Default, …); bon o derive_builder per i builder Nessun trucco sul bytecode; sono tutte macro normali
MapStruct Implementazioni di From / Into Scritte a mano, controllate dal compilatore. Vedi Conversioni
Container IoC di Spring Di solito niente: passa le dipendenze ai costruttori, usa trait per le cose che vuoi sostituire nei test I progetti Rust usano raramente un container DI
Bean Validation (@NotNull, @Size) validator o garde (validazione basata su derive) @NotNull non serve: usa Option solo dove un valore può mancare
Resilience4j backon, o il middleware di retry e timeout di tower
Caffeine (cache) moka
Guava, Apache Commons Quasi sempre la libreria standard, più itertools
java.time chrono o jiff Vedi Date e orari
picocli clap Vedi Progetto: tool CLI con clap
@Scheduled, Quartz tokio-cron-scheduler
Micrometer metrics, o opentelemetry
Jakarta Mail lettre
Java Rust Note
JavaFX / Swing Tauri (interfaccia web + backend Rust), Slint, egui, Iced Tauri 2 e Slint 1.x sono i più stabili. Vedi App con interfaccia grafica
LibGDX, jMonkeyEngine Bevy, macroquad Bevy è prima della 1.0 e cambia le sue API tra una release e l’altra. Vedi Sviluppo di giochi
Codice di app Android Tieni Kotlin o Java per l’interfaccia; metti la logica condivisa in una libreria Rust compilata con l’Android NDK uniffi di Mozilla genera i binding Kotlin
Chiamare codice nativo da Java Esponi funzioni extern "C" e chiamale tramite la FFM API (JDK 22+), o scrivi funzioni JNI con il crate jni Vedi FFI

Fianco a fianco: una classe astratta diventa un trait

Sezione intitolata “Fianco a fianco: una classe astratta diventa un trait”

Ecco una classica gerarchia Java: una classe base astratta con un metodo astratto e un metodo condiviso.

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " earns " + monthlyPay() + " a month"; }
}
class Salaried extends Employee {
private final int yearly;
Salaried(String name, int yearly) { super(name); this.yearly = yearly; }
@Override int monthlyPay() { return yearly / 12; }
}
class Contractor extends Employee {
private final int hours, rate;
Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; }
@Override int monthlyPay() { return hours * rate; }
}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));
for (Employee e : staff) System.out.println(e.payslip());

Rust non ha extends. Il metodo condiviso si sposta in un trait come metodo predefinito, e ogni struct tiene i propri dati:

trait Employee {
fn name(&self) -> &str;
fn monthly_pay(&self) -> u32;
// Un metodo predefinito: come il payslip() concreto della classe astratta.
fn payslip(&self) -> String {
format!("{} guadagna {} al mese", self.name(), self.monthly_pay())
}
}
struct Salaried {
name: String,
yearly: u32,
}
struct Contractor {
name: String,
hours: u32,
rate: u32,
}
impl Employee for Salaried {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.yearly / 12
}
}
impl Employee for Contractor {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.hours * self.rate
}
}
fn main() {
// Box<dyn Employee> fa la parte di «un riferimento a un Employee qualsiasi».
let staff: Vec<Box<dyn Employee>> = vec![
Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }),
Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }),
];
for e in &staff {
println!("{}", e.payslip());
}
}

Output:

Ada guadagna 5000 al mese
Linus guadagna 6000 al mese
▶ Provalo tu

Un trait non può contenere campi, quindi il protected name diventa un piccolo metodo name() che ogni struct implementa. All’inizio sembra codice in più da scrivere, ma elimina un’intera categoria di problemi: niente classi base fragili, niente chiamate a super da dimenticare, niente gerarchie profonde da scorrere. Se conosci i metodi default delle interfacce di Java 8, sai già come Rust condivide il comportamento. E quando l’insieme dei tipi è fisso (come una sealed interface in Java 17), un enum con un match è spesso più semplice di un trait.

Optional di Java è stato un grande passo avanti, ma si usa solo per i valori di ritorno, e map.get() o qualsiasi campo possono ancora essere null:

static Optional<String> findEmail(int userId) {
return userId == 1 ? Optional.of("[email protected]") : Optional.empty();
}
for (int id : new int[] {1, 2}) {
System.out.println(findEmail(id).map(String::toUpperCase).orElse("no email on file"));
}
findEmail(1).ifPresent(email -> System.out.println("sending to " + email));

L’Option di Rust ha quasi la stessa API, ma si usa ovunque un valore possa mancare, comprese le ricerche nelle collezioni. E dietro non si nasconde nessun null:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
for id in [1, 2] {
// map + orElseGet
let shown = find_email(id)
.map(|email| email.to_uppercase())
.unwrap_or_else(|| "nessuna email registrata".to_string());
println!("{shown}");
}
// ifPresent
if let Some(email) = find_email(1) {
println!("invio a {email}");
}
// flatMap si chiama and_then
let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string()));
println!("{domain:?}");
}

Output:

nessuna email registrata
Some("example.com")
▶ Provalo tu

Il compilatore non ti lascia chiamare .to_uppercase() su un Option<String> finché non hai gestito None, quindi la NullPointerException semplicemente non esiste. E Option non costa niente in più: un Option<&T> ha la stessa dimensione di un semplice riferimento. Impara i metodi di supporto nella pagina Option.

Fianco a fianco: le eccezioni checked diventano Result

Sezione intitolata “Fianco a fianco: le eccezioni checked diventano Result”

Le eccezioni checked partivano da una buona idea: la firma ti dice cosa può andare storto. Ecco una tipica versione Java:

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("need " + needed + " more"); }
}
static int withdraw(int balance, int amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
static int pay(int balance, int amount) throws InsufficientFundsException {
int left = withdraw(balance, amount);
System.out.println("paid " + amount + ", " + left + " left");
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("error: " + e.getMessage()); }

Rust tiene la buona idea e butta via il resto. L’errore fa parte del tipo di ritorno (Result<u32, BankError>), e ? fa quello che fa throws: «se questa cosa è fallita, passa l’errore a chi mi ha chiamato». Di solito gli errori sono un enum, così un solo tipo può elencare tutti i modi in cui un’operazione può fallire:

use std::fmt;
#[derive(Debug)]
enum BankError {
InvalidAmount,
InsufficientFunds { needed: u32 },
}
// Display fa la parte di getMessage().
impl fmt::Display for BankError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BankError::InvalidAmount => write!(f, "l'importo deve essere maggiore di zero"),
BankError::InsufficientFunds { needed } => write!(f, "mancano {needed}"),
}
}
}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> {
if amount == 0 {
return Err(BankError::InvalidAmount);
}
if amount > balance {
return Err(BankError::InsufficientFunds { needed: amount - balance });
}
Ok(balance - amount)
}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> {
let left = withdraw(balance, amount)?; // come chiamare un metodo che «lancia»
println!("pagati {amount}, ne restano {left}");
Ok(left)
}
fn main() {
for (balance, amount) in [(100, 80), (50, 80), (100, 0)] {
if let Err(e) = pay(balance, amount) {
println!("errore: {e}");
}
}
}

Output:

pagati 80, ne restano 20
errore: mancano 30
errore: l'importo deve essere maggiore di zero
▶ Provalo tu

Nota cosa è sparito: niente blocchi try, niente srotolamento dello stack, niente eccezioni avvolte in RuntimeException solo per farle passare attraverso una lambda. Gli errori sono valori normali, quindi funzionano bene dentro le closure e le catene di iteratori. Il ? è un segno visibile in ogni punto in cui la funzione può uscire in anticipo. Scrivere Display a mano stanca presto; il crate thiserror lo genera per te, e anyhow dà alle applicazioni un tipo di errore pigliatutto (vedi anyhow e thiserror). Per i veri bug, quelli che segnaleresti con un’eccezione unchecked, Rust ha panic!.

Una pipeline di stream con un collector groupingBy:

record Order(String customer, String city, int total) {}
List<Order> orders = List.of(
new Order("Ada", "London", 120), new Order("Grace", "New York", 80),
new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream()
.filter(o -> o.total() > 100)
.sorted(Comparator.comparingInt(Order::total).reversed())
.map(Order::customer)
.toList();
System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));
byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));

In Rust .iter() fa la parte di .stream(), e gli adattatori hanno nomi familiari:

use std::cmp::Reverse;
use std::collections::BTreeMap;
struct Order {
customer: String,
city: String,
total: u32,
}
fn order(customer: &str, city: &str, total: u32) -> Order {
Order { customer: customer.to_string(), city: city.to_string(), total }
}
fn main() {
let orders = vec![
order("Ada", "London", 120),
order("Grace", "New York", 80),
order("Linus", "Helsinki", 200),
order("Ken", "New York", 150),
];
// filter + toList
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// sorted(...reversed()): l'ordinamento si fa su un Vec, non dentro l'iteratore.
big.sort_by_key(|o| Reverse(o.total));
// map
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total))
let mut by_city: BTreeMap<&str, u32> = BTreeMap::new();
for o in &orders {
*by_city.entry(o.city.as_str()).or_insert(0) += o.total;
}
for (city, sum) in &by_city {
println!("{city}: {sum}");
}
}

Output:

Linus, Ken, Ada
Helsinki: 200
London: 120
New York: 230
▶ Provalo tu

Come gli stream, anche gli iteratori di Rust sono pigri (lazy) e partono solo quando qualcosa li consuma (collect, sum, un ciclo for). A differenza degli stream, non c’è una classe Collectors: collect() guarda il tipo che chiedi (Vec, HashMap, String, …) e lo costruisce. Per raggruppare, la risposta abituale è un semplice ciclo con l’API entry; il crate itertools aggiunge funzioni di supporto se ti manca groupingBy. E le catene di iteratori vengono compilate nello stesso codice macchina di un ciclo scritto a mano, senza mettere int in un box lungo la strada. Leggi di più in Iteratori e Ordinamento e ricerca.

Fianco a fianco: type erasure contro monomorfizzazione

Sezione intitolata “Fianco a fianco: type erasure contro monomorfizzazione”

I generici di Java esistono solo in compilazione. Durante l’esecuzione T viene cancellato, quindi non puoi scrivere new T(), e non puoi mettere un int in una List senza metterlo in un box:

static <T> List<T> filled(int n) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(new T()); // non compila: T viene cancellato
return list;
}
List<int> ids; // non compila nemmeno questo: serve List<Integer>

Rust compila una copia separata di ogni funzione generica per ogni tipo con cui la usi (si chiama monomorfizzazione). Così la funzione sa esattamente cos’è T, e può chiamare T::default():

use std::mem::size_of;
// «T deve avere un valore predefinito ed essere clonabile»
fn filled<T: Default + Clone>(n: usize) -> Vec<T> {
vec![T::default(); n]
}
fn main() {
let ids: Vec<i32> = filled(3);
let names: Vec<String> = filled(2);
let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// Niente box: un Vec<i32> contiene semplici numeri da 4 byte, uno accanto all'altro.
println!("un i32 occupa {} byte", size_of::<i32>());
println!("un bool occupa {} byte", size_of::<bool>());
}

Output:

[0, 0, 0] ["", ""] [false, false]
un i32 occupa 4 byte
un bool occupa 1 byte
▶ Provalo tu

Il compromesso: più copie del codice significano binari più grandi e compilazioni più lunghe, ma ogni chiamata è diretta e può essere resa inline. Quando vuoi una sola copia condivisa (come in Java), usa un trait object come Box<dyn Trait>. I vincoli dopo i due punti (T: Default + Clone) sono la versione Rust di <T extends ...>. Vedi Generici e Generici avanzati.

Quattro thread incrementano un contatore condiviso. In Java il lock sta sull’oggetto, e tocca a te ricordarti synchronized:

class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Counter counter = new Counter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); });
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
System.out.println("count = " + counter.get());

In Rust il Mutex possiede il numero. Non c’è modo di raggiungerlo senza prima acquisire il lock. Arc (un puntatore con conteggio atomico dei riferimenti) permette a più thread di condividere il mutex:

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 {
// Ogni thread riceve il suo handle allo stesso Mutex.
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// lock() restituisce una guardia; il lock viene rilasciato quando la guardia viene eliminata.
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("conteggio = {}", *counter.lock().unwrap());
}

Output:

conteggio = 4000
▶ Provalo tu

Ecco la parte che gli sviluppatori Java adorano: se in Java dimentichi synchronized, il codice compila e ogni tanto dà numeri sbagliati. In Rust, se provi a condividere tra thread un semplice i32 o un Vec e a modificarlo, il programma non compila. Le data race vengono intercettate dal sistema dei tipi (tramite i trait Send e Sync). Per un singolo contatore, un AtomicUsize sarebbe più leggero di un mutex, proprio come AtomicInteger. Vedi Concorrenza e Stato condiviso.

Le cose che ti faranno inciampare se vieni da Java

Sezione intitolata “Le cose che ti faranno inciampare se vieni da Java”

Ogni sviluppatore Java prende le stesse buche. Conoscerle in anticipo le rende più piccole.

  • Passare un valore a una funzione lo sposta. In Java, passare un oggetto passa un riferimento, e puoi continuare a usare la tua variabile. In Rust, passare per valore una String o un Vec lo sposta dentro la funzione:

    fn greet(name: String) {
    println!("Ciao, {name}!");
    }
    fn main() {
    let name = String::from("Ada");
    greet(name);
    greet(name);
    }
    error[E0382]: use of moved value: `name`
    --> src/main.rs:8:11
    |
    6 | let name = String::from("Ada");
    | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
    7 | greet(name);
    | ---- value moved here
    8 | greet(name);
    | ^^^^ value used here after move

    Il compilatore dice che name è stato spostato con la prima chiamata e poi usato di nuovo. La soluzione abituale è lasciare che la funzione prenda in prestito: fn greet(name: &str) e chiamala con greet(&name). Leggi prima Ownership: spiega questa regola, e E0382 tratta questo errore in dettaglio.

  • Il borrow checker. Puoi avere molti lettori (&T) oppure un solo scrittore (&mut T) alla volta, mai entrambi. Grafi di oggetti normali in Java, come un figlio che punta al genitore, o tanti oggetti che contengono e modificano un unico oggetto condiviso, richiedono smart pointer o un design diverso. Le prime settimane possono essere frustranti. Poi diventa davvero più facile.

  • Niente ereditarietà, niente protected, niente super. Sarai tentato di ricreare le gerarchie di classi con i trait. Prova prima con enum e composizione; di solito si adattano meglio.

  • == confronta i valori. Una volta tanto, una buona notizia: a == b su due String confronta il testo, quindi il classico bug == contro .equals() non può capitare. Per le tue struct, == funziona solo dopo che hai fatto derive o implementato PartialEq.

  • BinaryHeap estrae l’elemento più grande. La PriorityQueue di Java ti dà prima il più piccolo. In Rust, avvolgi gli elementi in std::cmp::Reverse per avere il comportamento di un min-heap. Vedi VecDeque e BinaryHeap.

  • L’overflow degli interi non è silenzioso. Integer.MAX_VALUE + 1 in Java ricomincia da capo senza dire niente. In una build di debug di Rust provoca un panic. Dichiara cosa vuoi con checked_add, wrapping_add o saturating_add. Vedi Overflow degli interi.

  • String contro &str, e niente charAt. Le stringhe di Rust sono UTF-8, quindi s[0] non compila. Usa s.chars() per scorrere i caratteri. Di solito le funzioni accettano &str e le struct contengono String. Vedi Stringhe.

  • Niente overloading dei metodi. Non puoi avere due metodi print con parametri diversi. Usa nomi diversi (print, print_with_color), o un parametro generico.

  • Niente magia durante l’esecuzione. Niente scansione del classpath, niente @Autowired, niente proxy che aggiungono transazioni attorno ai tuoi metodi. Tutto viene collegato esplicitamente nel codice, e le macro girano in compilazione. È più codice da scrivere, ma vedi sempre cosa succede.

  • Niente null e niente eccezioni. All’inizio Option e Result dappertutto sembrano lavoro in più. Dopo qualche settimana ti accorgerai che non vedi più una NullPointerException o uno stack trace a sorpresa. Chiamare .unwrap() va bene mentre impari, ma nel codice vero è la versione Rust di «lascia che vada in crash».

  • Tempi di compilazione. Una build pulita scarica e compila tutte le dipendenze dal sorgente, quindi è più lenta di javac. Le build incrementali sono molto più rapide, e cargo check (che salta la generazione del codice) è il comando che userai di più mentre scrivi. La buona notizia dall’altra parte: il programma parte all’istante, senza riscaldamento della JVM.

  • L’async ha bisogno di un runtime. Non ci sono i virtual thread. Una async fn non fa niente finché un executor non la esegue, quindi i progetti web aggiungono tokio e mettono #[tokio::main] su main. Vedi Async/await.

Non devi leggere questo sito in ordine. Questo percorso salta quello che Java ti ha già insegnato e si sofferma dove Rust è diverso:

  1. Primi passi e Strumenti e configurazione: installa Rust, scegli un editor, esegui cargo new.
  2. Variabili e Tipi di dati: scorrile velocemente, soprattutto per imparare let mut e i tipi numerici.
  3. Ownership, poi Prestito, poi Slice: le pagine più importanti per te. Prenditi il tuo tempo.
  4. Stringhe: String contro &str.
  5. Struct ed Enum, poi match e Pattern matching.
  6. Option e Result, poi Errori personalizzati e anyhow e thiserror.
  7. Trait, Generici, Trait comuni e Trait object: le tue interfacce e classi astratte.
  8. Closure e Iteratori: le tue lambda e i tuoi stream.
  9. Vettori, HashMap e VecDeque e BinaryHeap.
  10. Moduli, Cargo e crate e Test.
  11. Lifetime e Smart pointer.
  12. Concorrenza, Stato condiviso, Canali e Async/await.
  13. JSON con Serde, Logging e Database in Rust, poi costruisci il progetto API web.
  14. Vuoi velocizzare un’app Java esistente? Leggi FFI per vedere come Rust espone funzioni extern "C".

Entrambi gli esercizi ti danno un po’ di Java. Portalo in Rust.

Questo codice stampa gli studenti promossi, se qualcuno ha preso il punteggio pieno, e il punteggio migliore. Suggerimento: gli iteratori hanno any (come anyMatch) e max (che restituisce un Option, come OptionalInt). Al posto di Collectors.joining, raccogli in un Vec<&str> e chiama .join(", ").

record Student(String name, int score) {}
List<Student> students = List.of(
new Student("Ada", 91), new Student("Brian", 42),
new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream()
.filter(s -> s.score() >= 50)
.map(Student::name)
.collect(Collectors.joining(", "));
boolean perfect = students.stream().anyMatch(s -> s.score() == 100);
int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("passed: " + passed);
System.out.println("perfect score: " + perfect);
System.out.println("best: " + best);
Mostra la soluzione
#[derive(Debug, Clone, PartialEq)]
struct Student {
name: String,
score: u32,
}
fn student(name: &str, score: u32) -> Student {
Student { name: name.to_string(), score }
}
fn main() {
let students = vec![
student("Ada", 91),
student("Brian", 42),
student("Grace", 100),
student("Ken", 67),
];
let passed: Vec<&str> = students
.iter()
.filter(|s| s.score >= 50)
.map(|s| s.name.as_str())
.collect();
let perfect = students.iter().any(|s| s.score == 100);
let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("promossi: {}", passed.join(", "));
println!("punteggio pieno: {perfect}");
println!("migliore: {best}");
}

Output:

promossi: Ada, Grace, Ken
punteggio pieno: true
migliore: 100
▶ Provalo tu

I derive ti danno quello che un record Java ti dà gratis: una stringa di debug ({:?}), la copia (.clone()) e l’uguaglianza per valore (==). La funzione di supporto student fa la parte del costruttore del record. A differenza di uno stream Java, puoi chiamare students.iter() tutte le volte che vuoi, perché ogni chiamata crea un nuovo iteratore sullo stesso Vec.

Questo metodo legge una riga port=<numero> da un file di configurazione. Lancia una ConfigException checked, e avvolge la NumberFormatException che riceve da Integer.parseInt. Portalo in modo che read_port restituisca un Result<u16, String>. Suggerimento: line.split_once('=') restituisce un Option<(&str, &str)>; trasformalo in un Result con .ok_or_else(...). .parse::<u16>() restituisce un Result, e .map_err(...) sostituisce il suo errore con un tuo messaggio, proprio come avvolgere un’eccezione in un’altra.

class ConfigException extends Exception {
ConfigException(String message) { super(message); }
}
static int readPort(String line) throws ConfigException {
String[] parts = line.split("=", 2);
if (parts.length != 2 || !parts[0].trim().equals("port"))
throw new ConfigException("expected port=<number>, got '" + line + "'");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("bad port number: " + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=eighty", "host=localhost")) {
try { System.out.println("port is " + readPort(line)); }
catch (ConfigException e) { System.out.println("config error: " + e.getMessage()); }
}
Mostra la soluzione
fn read_port(line: &str) -> Result<u16, String> {
let expected = || format!("atteso port=<numero>, trovato '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?;
if key.trim() != "port" {
return Err(expected());
}
let value = value.trim();
value
.parse::<u16>()
.map_err(|_| format!("numero di porta non valido: {value}"))
}
fn main() {
for line in ["port = 8080", "port=eighty", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("la porta è {port}"),
Err(e) => println!("errore di configurazione: {e}"),
}
}
}

Output:

la porta è 8080
errore di configurazione: numero di porta non valido: eighty
errore di configurazione: atteso port=<numero>, trovato 'host=localhost'
▶ Provalo tu

La clausola throws ConfigException è diventata il tipo di ritorno Result, il try/catch attorno a parseInt è diventato un solo map_err, e il throw è diventato Err(...). Usare u16 per la porta è un piccolo regalo in più: un numero sopra 65535 viene rifiutato da parse stesso, cosa che un int in Java non farebbe. La piccola closure expected costruisce il messaggio di errore solo quando serve, come il supplier di un orElseThrow(() -> ...).

  • Molto di Java si porta dietro: generici, interfacce (come trait), lambda (come closure) e pipeline di stream (come iteratori).
  • I grandi cambiamenti sono l’ownership al posto del garbage collector, Option al posto di null, Result e ? al posto delle eccezioni, e trait più composizione al posto dell’ereditarietà.
  • I generici vengono compilati per ogni tipo (monomorfizzazione), quindi non c’è type erasure e i numeri non finiscono in un box.
  • I dati condivisi tra thread vivono dentro un Mutex (avvolto in un Arc), e dimenticare il lock è un errore di compilazione, non una race.
  • Cargo sostituisce Maven o Gradle; clippy e rustfmt sostituiscono Checkstyle, SpotBugs e i formattatori; i test sono integrati.
  • Al posto di Spring Boot guarda axum o actix-web, al posto di JPA guarda SeaORM o Diesel, al posto di JDBC e jOOQ guarda sqlx, e al posto di Jackson usa serde.

Verifica veloce

1. Una classe astratta Java ha un metodo astratto e un metodo concreto che lo chiama. Qual è il sostituto abituale in Rust?

2. La PriorityQueue di Java restituisce prima l'elemento più piccolo. Cosa restituisce BinaryHeap::pop in Rust?

3. Perché una funzione generica di Rust può chiamare T::default(), mentre in Java non puoi scrivere new T()?

4. Condividi un semplice Vec tra thread e provi ad aggiungere elementi senza un Mutex. Cosa succede in Rust?

Punteggio: 0 / 4

  • The Rust Programming Language (in inglese): il libro ufficiale. I capitoli 4 (ownership), 9 (gestione degli errori), 10 (generici e trait) e 16 (concorrenza) sono i più importanti per chi viene da Java.
  • std::collections (in inglese): la guida della libreria standard per scegliere tra Vec, VecDeque, HashMap, BTreeMap e le altre.
  • JEP 454: Foreign Function & Memory API (in inglese): la funzionalità del JDK 22 per chiamare da Java librerie native, comprese quelle in Rust, senza scrivere codice JNI.
  • Il crate jni su docs.rs (in inglese): binding Rust per JNI, se devi chiamare Rust da JDK più vecchi o richiamare Java da Rust.
  • Are We Web Yet? (in inglese): una panoramica della comunità su framework web, librerie per database e altro in Rust, se cerchi il tuo stack al posto di Spring Boot.