Rust per sviluppatori Go: guida pratica
Se scrivi Go, in Rust ti sentirai a casa più di quanto pensi. Entrambi i linguaggi vengono compilati in un unico binario veloce. Entrambi hanno struct con metodi al posto delle classi, niente ereditarietà, errori restituiti come valori invece delle eccezioni, un formattatore standard che usano tutti, e un solo strumento da riga di comando che compila, testa e scarica i pacchetti. Una struct Go con qualche metodo si traduce in Rust quasi riga per riga.
La novità riguarda soprattutto la memoria e l’essere espliciti. Rust non ha garbage collector: ogni valore ha un solo proprietario, e il compilatore controlla l’ownership (possesso) e il prestito (borrowing) prima che il programma parta. Non c’è nil (usi Option), e if err != nil diventa Result con l’operatore ?. Le interfacce esistono ancora (si chiamano trait), ma un tipo deve dichiarare apertamente che ne implementa una. Mettiti in conto di passare la maggior parte del tempo di studio sull’ownership. Il resto ti sembrerà un Go più severo e più dettagliato.
Perché gli sviluppatori Go imparano Rust? Di solito per uno di questi motivi: un servizio in cui le pause del garbage collector o l’uso della memoria iniziano a contare, lavoro pesante per la CPU, codice che gira senza sistema operativo (embedded) o nel browser (WebAssembly), o semplicemente per far trovare al compilatore le data race e i crash da nil prima della produzione. Per un confronto più ampio di Rust con Go, C++ e Python, guarda Rust vs C++, Go e Python. Questa pagina va più a fondo ed è scritta per te, sviluppatore Go.
Concetti del linguaggio: da Go a Rust
Sezione intitolata “Concetti del linguaggio: da Go a Rust”Usa questa tabella come un frasario. L’ultima colonna rimanda alla lezione di questo sito che spiega il lato Rust.
| Go | Rust | Per saperne di più |
|---|---|---|
| Garbage collector | Ownership e prestito, controllati in compilazione; la memoria viene liberata quando il proprietario esce dallo scope | Ownership, Prestito |
struct + metodi |
struct per i dati + un blocco impl per i metodi |
Struct |
Receiver per valore func (a Account) |
&self (legge, senza copia) o self (prende l’ownership) |
Struct |
Receiver puntatore func (a *Account) |
&mut self (può modificare il valore) |
Struct |
Funzione costruttore NewAccount(...) |
Una funzione associata, per convenzione Account::new(...) |
Struct |
| Lettera maiuscola = esportato | La parola chiave pub |
Moduli |
Valori zero (var c Config) |
Nessun valore zero implicito; ogni campo va impostato. #[derive(Default)] + Config::default() |
Struct, Trait comuni |
| Embedding di struct | Niente embedding. Metti la struct in un campo e scrivi piccoli metodi che inoltrano le chiamate, o condividi il comportamento con i trait | Trait, Design pattern |
interface (soddisfatta implicitamente) |
trait (implementato esplicitamente con impl Trait for Type) |
Trait |
[]Shape con tipi diversi |
Vec<Box<dyn Shape>> (un trait object) |
Trait object |
any / interface{} + type switch |
Un enum con una variante per caso, poi match |
Enum, match |
Generici func Max[T cmp.Ordered](a, b T) T |
Generici con vincoli di trait (trait bound): fn max<T: PartialOrd>(a: T, b: T) -> T |
Generici, Generici avanzati |
Puntatore nil, mappa nil, interfaccia nil |
Option<T>: o Some(valore) o None |
Option |
v, ok := m[k] |
m.get(&k) restituisce un Option |
HashMap |
(T, error) + if err != nil { return err } |
Result<T, E> + l’operatore ? |
Result |
errors.New, errori sentinella, errors.Is / errors.As |
Un enum di errore su cui fai match (il crate thiserror aiuta) |
Errori personalizzati, anyhow e thiserror |
fmt.Errorf("...: %w", err) |
anyhow con .context("..."), o map_err |
anyhow e thiserror |
panic / recover |
panic! per i veri bug; recuperare è raro (std::panic::catch_unwind) |
Panic |
defer |
Drop, che viene eseguito quando un valore esce dallo scope (RAII) |
Scope, Smart pointer |
go f() |
std::thread::spawn(...), o tokio::spawn(...) per i task async |
Concorrenza, Async/await |
chan T, make(chan T, n) |
std::sync::mpsc::channel() (illimitato) o sync_channel(n); tokio::sync::mpsc nel codice async |
Canali |
select |
tokio::select! nel codice async, o il select! di crossbeam-channel |
Async in profondità |
sync.Mutex accanto ai dati |
Mutex<T> avvolge i dati; si sblocca da solo quando la guardia viene eliminata |
Stato condiviso |
sync.WaitGroup |
JoinHandle::join(), o std::thread::scope |
Concorrenza |
sync.Once |
std::sync::OnceLock / LazyLock |
Idiomi della std |
context.Context |
Nessun equivalente unico: elimina un future (drop) per annullarlo, tokio::time::timeout, o CancellationToken di tokio-util |
Async in profondità |
Valori func e closure |
Closure, descritte dai trait Fn, FnMut, FnOnce |
Closure, Fn, FnMut e FnOnce |
for i, v := range s |
for (i, v) in s.iter().enumerate() |
Cicli for, Iteratori |
L’unico ciclo for |
loop, while e for |
Cicli |
Iteratori range-over-func (iter.Seq) |
Il trait Iterator, con adattatori come filter, map, sum, collect |
Iteratori, Iteratori personalizzati |
switch |
match (il compilatore controlla che tu abbia coperto ogni caso) |
match, Pattern matching |
const + iota |
Un enum (eventualmente con numeri espliciti) |
Enum |
:= |
let (immutabile per default; let mut per permettere modifiche) |
Variabili |
string (byte, di solito UTF-8) |
String (posseduta) e &str (presa in prestito), sempre UTF-8 valido |
Stringhe |
rune, for _, r := range s |
char, for c in s.chars() |
Stringhe |
[]byte |
Vec<u8> o &[u8] |
Slice |
fmt.Println, %v, %+v |
println!, {} (Display), {:?} e {:#?} (Debug, con #[derive(Debug)]) |
Output |
String() string (l’interfaccia Stringer) |
Implementa Display (e .to_string() funziona gratis) |
Trait comuni |
Tag delle struct `json:"user_name"` |
Attributi: #[serde(rename = "user_name")] |
JSON con Serde |
Conversioni int(x), float64(n) |
as per i numeri, From/Into, e TryFrom quando può fallire |
Conversione di tipo, Conversioni |
| L’overflow degli interi ricomincia da capo in silenzio | Panic nelle build di debug, ricomincia da capo in release; checked_add, wrapping_add, saturating_add dicono cosa vuoi |
Overflow degli interi |
*T e &x |
Riferimenti &T e &mut T (mai nulli, non possono sopravvivere al valore); Box<T> per un valore posseduto sull’heap |
Prestito, Smart pointer |
| Package | Moduli (mod) dentro un crate; use al posto di import |
Moduli |
go generate |
Macro e script di build build.rs |
Macro, Macro procedurali |
Package unsafe |
Blocchi unsafe e puntatori grezzi |
Rust unsafe |
| cgo | extern "C" e FFI (bindgen genera i binding dagli header C) |
FFI |
Build tag //go:build linux |
#[cfg(target_os = "linux")] e le feature di Cargo |
Feature di Cargo e cfg |
Collezioni
Sezione intitolata “Collezioni”Go ha slice e mappe integrate nel linguaggio. Rust le ha nella libreria standard, più qualcuna in più.
| Go | Rust | Per saperne di più |
|---|---|---|
[]T che possiedi e a cui aggiungi elementi |
Vec<T> |
Vettori |
[]T passata a una funzione che la legge soltanto |
&[T] (una slice presa in prestito da un Vec o da un array) |
Slice |
[N]T |
[T; N] |
Array |
append(s, x) / append(s, other...) |
v.push(x) / v.extend(other) |
Vettori |
s[a:b] |
&v[a..b] |
Slice |
len(s), cap(s), make([]T, 0, n) |
v.len(), v.capacity(), Vec::with_capacity(n) |
Vettori |
map[K]V |
HashMap<K, V> |
HashMap |
map[K]struct{} o map[K]bool usata come insieme |
HashSet<T> |
HashSet |
| Raccogliere e ordinare le chiavi di una mappa prima di stampare | BTreeMap<K, V> (le chiavi restano ordinate) |
HashMap |
container/list |
VecDeque<T> (push_back / pop_front) |
VecDeque e BinaryHeap |
container/heap (implementi 5 metodi) |
BinaryHeap<T>: solo push e pop; prima il più grande, avvolgi in Reverse per avere prima il più piccolo |
VecDeque e BinaryHeap |
slices.Sort, sort.Slice |
v.sort(), v.sort_by(...), v.sort_by_key(...) |
Ordinamento e ricerca |
slices.Contains, slices.Index |
v.contains(&x), v.iter().position(...) |
Vettori |
strings.Builder |
Una String con push_str, o write! |
Stringhe |
sync.Map |
Mutex<HashMap<K, V>>, RwLock, o il crate dashmap |
Stato condiviso |
Due cose da sapere. Primo: come le mappe di Go, una HashMap di Rust non ha un ordine fisso, quindi ordina prima di stampare o usa una BTreeMap. Secondo: la famosa sorpresa delle slice di Go (due slice che condividono lo stesso array sottostante, e un append che modifica l’altra) in Rust non può capitare: finché una parte del codice tiene un prestito &mut di un Vec, nient’altro può guardarlo.
Strumenti: da go a cargo
Sezione intitolata “Strumenti: da go a cargo”Buone notizie: sei abituato a un solo strumento che fa tutto, e Rust ne ha uno anche lui. Cargo copre go build, go run, go test, go mod e go install.
| Go | Rust | Note |
|---|---|---|
Comando go |
cargo |
Compila, esegue, testa, aggiunge pacchetti, pubblica. Vedi Cargo e crate |
go mod init |
cargo new my_app (cargo new --lib per una libreria) |
Crea la cartella, Cargo.toml, src/main.rs e un repository git |
go.mod |
Cargo.toml |
|
go.sum |
Cargo.lock |
Viene creato e aggiornato per te |
go get github.com/gin-gonic/gin |
cargo add axum |
|
go get -u |
cargo update |
Aggiorna entro gli intervalli di versione indicati in Cargo.toml |
| pkg.go.dev | crates.io per i pacchetti, docs.rs (in inglese) per la documentazione delle loro API | |
go build / go run / go test |
cargo build / cargo run / cargo test |
Aggiungi --release per una build ottimizzata; le build di debug sono molto più lente in esecuzione |
go vet, staticcheck, golangci-lint |
cargo clippy |
Ufficiale, con centinaia di lint utili |
gofmt / goimports |
cargo fmt (rustfmt) |
Un solo stile standard, proprio come gofmt |
go doc |
cargo doc --open |
Gli esempi di codice nei commenti di documentazione vengono eseguiti come test. Vedi Documentazione |
go test -bench |
criterion (si esegue con cargo bench) |
Vedi Prestazioni |
go test -cover |
cargo llvm-cov o cargo tarpaulin |
|
go test -race |
Non serve per il codice safe: le data race sono errori di compilazione. Miri controlla il codice unsafe |
|
go test -fuzz |
cargo fuzz |
Si installa con cargo install cargo-fuzz |
| testify | assert! e assert_eq! integrati; pretty_assertions per differenze più leggibili |
Vedi Test e Test avanzati |
| gomock / mockery | mockall, o finti oggetti scritti a mano che implementano il tuo trait |
|
go install pkg@latest |
cargo install name |
|
go work (workspace) |
Un workspace Cargo | Un Cargo.toml in cima elenca i crate membri |
Riga toolchain in go.mod |
rustup e un file rust-toolchain.toml |
Vedi Strumenti e configurazione |
GOOS=linux GOARCH=arm64 go build |
rustup target add aarch64-unknown-linux-gnu + cargo build --target ... |
Spesso serve un linker per il target; cross o cargo-zigbuild semplificano le cose |
| govulncheck | cargo audit (avvisi RustSec), cargo deny |
|
| gopls | rust-analyzer | Funziona in VS Code, Neovim, Helix, Zed e altri |
| GoLand | JetBrains RustRover | |
| Delve | rust-gdb / rust-lldb, o CodeLLDB in VS Code |
|
| pprof | cargo flamegraph, perf, o qualsiasi profiler nativo |
|
| air (ricaricamento automatico) | bacon o cargo watch |
Un piccolo Cargo.toml accanto al go.mod che conosci:
module example.com/orders-api
go 1.23
require github.com/gin-gonic/gin v1.10.0[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }Una differenza che noterai subito: la libreria standard di Go include un server HTTP, JSON, template e crittografia. Quella di Rust è più piccola di proposito, quindi cose come JSON, HTTP, async e numeri casuali arrivano dai crate. Aggiungerne uno è un solo cargo add, e una manciata di crate (serde, tokio, clap, regex, rand) la usano quasi tutti.
Librerie e framework: «cosa usiamo al posto di…?»
Sezione intitolata “Librerie e framework: «cosa usiamo al posto di…?»”È la prima domanda pratica. «Usiamo Gin e GORM. Qual è la versione Rust?» Ecco gli equivalenti più vicini. Come in Go, di solito in Rust ci sono due o tre buone scelte della comunità invece di una sola risposta ufficiale.
Web e rete
Sezione intitolata “Web e rete”| Go | Rust | Note |
|---|---|---|
| Gin, Echo, Chi, Fiber | axum (ne costruiamo una in Progetto: API web), actix-web, Rocket | axum e actix-web sono molto usati in produzione |
Server net/http |
hyper (di basso livello), di solito usato tramite axum |
Nessun server HTTP nella libreria standard |
Middleware http.Handler |
Layer di tower e tower-http (CORS, compressione, tracing, timeout) |
axum usa tower direttamente |
Client net/http |
reqwest |
Asincrono per default, con un client bloccante opzionale. Vedi Reti |
| grpc-go | tonic (con prost per protobuf) |
|
| gorilla/websocket | tokio-tungstenite, o la feature ws di axum |
|
html/template, text/template |
askama (controllato in compilazione), tera, minijinja |
|
net (TCP/UDP) |
std::net, o tokio::net per l’async |
Vedi Reti |
| errgroup | tokio::task::JoinSet |
| Go | Rust | Note |
|---|---|---|
encoding/json + tag delle struct |
serde + serde_json |
Gestisce anche TOML (crate toml) e altri formati. Vedi JSON con Serde |
database/sql + pgx |
sqlx (asincrono, SQL semplice controllato sul tuo database in compilazione), tokio-postgres |
Non ha niente a che fare con jmoiron/sqlx di Go, nonostante il nome |
| sqlc | Le macro query! di sqlx |
Entrambi partono dal SQL che scrivi e ti danno risultati tipizzati |
| GORM | SeaORM (asincrono, stile a entità), Diesel (query builder controllato sui tipi) | Vedi Database |
| go-sqlite3 | rusqlite |
|
| go-redis | redis |
CLI, configurazione, logging e strumenti di tutti i giorni
Sezione intitolata “CLI, configurazione, logging e strumenti di tutti i giorni”| Go | Rust | Note |
|---|---|---|
cobra, urfave/cli, flag |
clap (ricava gli argomenti da una struct con derive) |
Vedi Argomenti da riga di comando e Progetto: tool CLI con clap |
| viper | config o figment |
Combina file e variabili d’ambiente, poi deserializza con serde |
log/slog, zap, zerolog |
tracing + tracing-subscriber, o log + env_logger |
Logging strutturato. Vedi Logging |
regexp |
regex |
Stessa idea dell’RE2 di Go: confronto in tempo lineare, niente backreference. Vedi Espressioni regolari |
time |
std::time per le durate; chrono o jiff per date e fusi orari |
Vedi Date e orari |
embed |
include_str! / include_bytes!, o rust-embed per cartelle intere |
|
| bubbletea | ratatui |
Interfacce da terminale |
| client_golang di Prometheus | prometheus o metrics |
|
sync/atomic |
std::sync::atomic |
Desktop, giochi ed embedded
Sezione intitolata “Desktop, giochi ed embedded”| Go | Rust | Note |
|---|---|---|
| Wails | Tauri (interfaccia web + backend Rust) | Idea molto simile. Vedi App con interfaccia grafica |
| Fyne | egui, Iced, Slint |
Nessun toolkit grafico ufficiale |
| Ebitengine | macroquad, Bevy |
Bevy è prima della 1.0 e cambia le sue API tra una release e l’altra. Vedi Sviluppo di giochi |
| TinyGo | embedded-hal, Embassy |
Rust gira senza sistema operativo e senza heap. Vedi Rust embedded |
GOOS=js GOARCH=wasm |
wasm-bindgen, Leptos, Yew, Dioxus |
Binari piccoli, nessun GC da distribuire. Vedi Rust e WebAssembly |
Fianco a fianco: struct e metodi (quasi uguali)
Sezione intitolata “Fianco a fianco: struct e metodi (quasi uguali)”Qui gli sviluppatori Go si sentono a casa. Ecco un piccolo tipo per un conto in Go:
type Account struct { Owner string // esportato: lettera maiuscola balance int // non esportato}
func NewAccount(owner string, balance int) *Account { return &Account{Owner: owner, balance: balance}}
func (a *Account) Deposit(amount int) { a.balance += amount }func (a *Account) Balance() int { return a.balance }func (a *Account) String() string { return fmt.Sprintf("%s: %d", a.Owner, a.balance) }
func main() { account := NewAccount("Ada", 100) account.Deposit(50) fmt.Println(account) fmt.Println("balance is", account.Balance())}E in Rust:
use std::fmt;
// `pub` fa il lavoro della lettera maiuscola di Go.pub struct Account { pub owner: String, balance: i64, // privato, come un campo minuscolo in Go}
impl Account { // Come NewAccount in Go: una funzione normale. `new` è solo una convenzione. pub fn new(owner: &str, balance: i64) -> Self { Account { owner: owner.to_string(), balance } }
// Come un receiver puntatore: questo metodo modifica il conto. pub fn deposit(&mut self, amount: i64) { self.balance += amount; }
// Si limita a leggere. Nessuna copia, a differenza di un receiver per valore in Go. pub fn balance(&self) -> i64 { self.balance }}
// Il metodo String() di Go (l'interfaccia Stringer) diventa Display.impl fmt::Display for Account { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.owner, self.balance) }}
fn main() { let mut account = Account::new("Ada", 100); account.deposit(50); println!("{account}"); println!("il saldo è {}", account.balance());}Output:
Ada: 150il saldo è 150La forma è la stessa: dati in una struct, metodi attaccati a essa, un costruttore che è solo una funzione. Le differenze sono piccole ma importanti. I metodi stanno tutti insieme in un blocco impl. Il receiver dice esattamente cosa fa il metodo: &self legge, &mut self modifica, e un semplice self prende il valore e lo consuma. E siccome deposit modifica il conto, la variabile deve essere dichiarata let mut. In Go chiunque abbia un *Account può modificarlo; in Rust il diritto di modificare qualcosa si vede nel codice.
Fianco a fianco: le interfacce implicite diventano trait espliciti
Sezione intitolata “Fianco a fianco: le interfacce implicite diventano trait espliciti”In Go un tipo soddisfa un’interfaccia semplicemente avendo i metodi giusti:
type Shape interface { Area() float64 Name() string}
type Circle struct{ R float64 }type Rect struct{ W, H float64 }
// Nessun "implements" da nessuna parte: avere i metodi basta.func (c Circle) Area() float64 { return math.Pi * c.R * c.R }func (c Circle) Name() string { return "circle" }func (r Rect) Area() float64 { return r.W * r.H }func (r Rect) Name() string { return "rect" }
func describe[T Shape](s T) { fmt.Printf("%s: %.2f\n", s.Name(), s.Area())}
func main() { describe(Circle{R: 1})
shapes := []Shape{Circle{R: 1}, Rect{W: 2, H: 3}} total := 0.0 for _, s := range shapes { total += s.Area() } fmt.Printf("total area: %.2f\n", total)}In Rust il collegamento si scrive esplicitamente, con impl Shape for Circle:
use std::f64::consts::PI;
trait Shape { fn area(&self) -> f64; fn name(&self) -> &str;}
struct Circle { r: f64,}
struct Rect { w: f64, h: f64,}
// Questa riga è la parte che Go lascia fuori.impl Shape for Circle { fn area(&self) -> f64 { PI * self.r * self.r } fn name(&self) -> &str { "cerchio" }}
impl Shape for Rect { fn area(&self) -> f64 { self.w * self.h } fn name(&self) -> &str { "rettangolo" }}
// Generica, come describe[T Shape](s T) in Go.fn describe<T: Shape>(s: &T) { println!("{}: {:.2}", s.name(), s.area());}
fn main() { describe(&Circle { r: 1.0 });
// Come []Shape in Go: tipi diversi dietro un solo trait. let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { r: 1.0 }), Box::new(Rect { w: 2.0, h: 3.0 }), ]; let total: f64 = shapes.iter().map(|s| s.area()).sum(); println!("area totale: {total:.2}");}Output:
cerchio: 3.14area totale: 9.14Essere espliciti costa una riga per tipo, e in cambio ti dà cose che Go non può fare. Un trait può avere metodi predefiniti, così i tipi ottengono gratis un comportamento condiviso. Puoi implementare un tuo trait per tipi che non hai scritto tu, perfino i32 o String (Go ti permette di aggiungere metodi solo ai tipi del tuo package). E la funzione generica describe viene compilata una volta per ogni tipo concreto, quindi la chiamata non ha nessuna ricerca durante l’esecuzione. Box<dyn Shape> è lo stile delle interfacce di Go: un valore che punta a «un tipo qualsiasi che implementa Shape», con il metodo cercato durante l’esecuzione. Vedi Trait e Trait object.
Fianco a fianco: nil diventa Option
Sezione intitolata “Fianco a fianco: nil diventa Option”In Go un puntatore che potrebbe mancare è nil, e devi ricordarti di controllarlo:
type User struct { Name string Manager *User // nil significa "nessun responsabile"}
func findUser(users []*User, name string) *User { for _, u := range users { if u.Name == name { return u } } return nil}
func main() { ada := &User{Name: "Ada"} users := []*User{ada, {Name: "Bob", Manager: ada}}
for _, name := range []string{"Bob", "Ada", "Eve"} { u := findUser(users, name) if u == nil { fmt.Println(name + ": no such user") continue } boss := "nobody" if u.Manager != nil { boss = u.Manager.Name } fmt.Println(name, "reports to", boss) }}In Rust «potrebbe mancare» fa parte del tipo. Option<&User> è o Some(user) o None, e il compilatore non ti lascia usare l’utente finché non hai gestito None:
struct User { name: String, manager: Option<String>, // Go: Manager *User, che può essere nil}
fn find_user<'a>(users: &'a [User], name: &str) -> Option<&'a User> { users.iter().find(|u| u.name == name)}
fn main() { let users = vec![ User { name: "Ada".to_string(), manager: None }, User { name: "Bob".to_string(), manager: Some("Ada".to_string()) }, ];
for name in ["Bob", "Ada", "Eve"] { match find_user(&users, name) { Some(user) => { let boss = user.manager.as_deref().unwrap_or("nessuno"); println!("{name} risponde a {boss}"); } None => println!("{name}: utente inesistente"), } }}Output:
Bob risponde a AdaAda risponde a nessunoEve: utente inesistenteLa stessa idea sostituisce le ricerche «comma-ok» di Go. if email, ok := emails[name]; ok { ... } diventa if let Some(email) = emails.get(name) { ... }. Una cosa a cui fare attenzione: in Go emails["missing"] ti dà tranquillamente il valore zero "". In Rust emails["missing"] provoca un panic, quindi usa .get() ogni volta che la chiave potrebbe non esserci. Il 'a in find_user è un lifetime (durata): dice al compilatore che il &User restituito punta dentro users. Li incontrerai presto.
Fianco a fianco: if err != nil diventa Result e ?
Sezione intitolata “Fianco a fianco: if err != nil diventa Result e ?”La gestione degli errori di Go è famosa per il suo schema di tre righe:
func parsePort(s string) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("invalid port %q: %w", s, err) } if n < 1 || n > 65535 { return 0, fmt.Errorf("port %d out of range", n) } return n, nil}
func address(host, port string) (string, error) { p, err := parsePort(port) if err != nil { return "", err } return fmt.Sprintf("%s:%d", host, p), nil}
func main() { for _, port := range []string{"8080", "abc", "70000", "0"} { addr, err := address("localhost", port) if err != nil { fmt.Println("error:", err) continue } fmt.Println("listening on", addr) }}Rust tiene l’idea (gli errori sono valori che restituisci) ma riduce lo schema a un solo carattere, ?:
fn parse_port(s: &str) -> Result<u16, String> { // Un u16 non può contenere 70000, quindi parse() intercetta per noi il «fuori intervallo». let port: u16 = s.parse().map_err(|e| format!("porta non valida {s:?}: {e}"))?; if port == 0 { return Err("la porta 0 è riservata".to_string()); } Ok(port)}
fn address(host: &str, port: &str) -> Result<String, String> { // `?` significa: se questo è un errore, restituiscilo subito. let port = parse_port(port)?; Ok(format!("{host}:{port}"))}
fn main() { for port in ["8080", "abc", "70000", "0"] { match address("localhost", port) { Ok(addr) => println!("in ascolto su {addr}"), Err(e) => println!("errore: {e}"), } }}Output:
in ascolto su localhost:8080errore: porta non valida "abc": invalid digit found in stringerrore: porta non valida "70000": number too large to fit in target typeerrore: la porta 0 è riservataI testi «invalid digit found in string» (cifra non valida nella stringa) e «number too large to fit in target type» (numero troppo grande per il tipo di destinazione) arrivano dalla libreria standard e sono sempre in inglese.
C’è una differenza più profonda del risparmio di righe. In Go n, err := strconv.Atoi(s) ti dà entrambi i valori, e niente ti impedisce di usare n quando err non è nil. Un Result è o il valore o l’errore, quindi non puoi raggiungere il valore senza prima occuparti dell’errore. E se ignori completamente un Result, il compilatore ti avvisa.
Nei progetti veri gli errori String vengono sostituiti da qualcosa di più ricco. Un enum di errore (spesso scritto con il crate thiserror) fa la parte degli errori sentinella e di errors.Is: chi chiama fa match sulla variante. Il crate anyhow ti dà .context("loading config")?, che è il fmt.Errorf("loading config: %w", err) di Rust. Vedi Errori personalizzati e anyhow e thiserror.
Fianco a fianco: goroutine e canali diventano thread, mpsc e task
Sezione intitolata “Fianco a fianco: goroutine e canali diventano thread, mpsc e task”Ecco il classico fan-in di Go: avvii alcune goroutine, mandi i risultati su un canale, lo chiudi quando hanno finito:
func main() { results := make(chan int) var wg sync.WaitGroup
for _, n := range []int{1, 2, 3} { wg.Add(1) go func(n int) { defer wg.Done() results <- n * n }(n) }
go func() { wg.Wait() close(results) }()
total := 0 for r := range results { total += r } fmt.Println("total:", total)}La versione Rust con i thread e un canale della libreria standard è un po’ più corta:
use std::sync::mpsc;use std::thread;
fn main() { let (tx, rx) = mpsc::channel();
for n in [1, 2, 3] { let tx = tx.clone(); // ogni thread riceve il suo mittente thread::spawn(move || { tx.send(n * n).unwrap(); }); }
// Elimina il nostro mittente. Quando non resta nessun mittente, il canale // si chiude e il ciclo qui sotto termina: niente WaitGroup. drop(tx);
let total: i32 = rx.iter().sum(); println!("totale: {total}");}Output:
totale: 14Alcune cose funzionano in modo diverso. mpsc significa «multi-producer, single-consumer» (più produttori, un solo consumatore): puoi clonare il mittente, ma non il ricevitore (per canali stile Go con molti lettori, usa crossbeam-channel). mpsc::channel() è illimitato, cosa che Go non ha; mpsc::sync_channel(n) è il make(chan T, n) di Go, e sync_channel(0) è un canale senza buffer. Nessuno chiama close: il canale si chiude quando viene eliminato l’ultimo mittente. move dà a ogni thread la sua copia di n e il suo tx, ed è così che Rust si assicura che i thread non condividano mai dati in modo non sicuro. Vedi Canali e Concorrenza.
thread::spawn avvia un vero thread del sistema operativo, più pesante di una goroutine. Per una manciata di worker va benissimo. Per migliaia di connessioni simultanee, Rust usa task async su un runtime come tokio, molto più vicini alle goroutine:
#[tokio::main]async fn main() { let mut handles = Vec::new();
for n in [1, 2, 3] { // Come `go func() { ... }()`, ma ti restituisce un handle. handles.push(tokio::spawn(async move { n * n })); }
let mut total = 0; for handle in handles { total += handle.await.unwrap(); } println!("totale: {total}");}Output:
totale: 14Siamo onesti: questo è un punto in cui Go è più semplice. In Go qualsiasi funzione può bloccarsi e ci pensa il runtime. In Rust segni le funzioni come async, le chiami con .await e scegli un runtime. In cambio, il compilatore si rifiuta di compilare codice in cui due thread potrebbero modificare gli stessi dati nello stesso momento, quindi go test -race non avrebbe niente da trovare. Vedi Async/await, Async in profondità e Stato condiviso per Arc<Mutex<T>>, la versione Rust di una struct con un sync.Mutex accanto ai dati.
Fianco a fianco: defer diventa Drop
Sezione intitolata “Fianco a fianco: defer diventa Drop”In Go programmi la pulizia con defer, e le chiamate rimandate vengono eseguite in ordine inverso quando la funzione restituisce:
func process() { fmt.Println("open file") defer fmt.Println("close file")
fmt.Println("lock mutex") defer fmt.Println("unlock mutex")
fmt.Println("working")}
func main() { process() fmt.Println("done")}In Rust la pulizia appartiene al valore stesso. Quando un valore esce dallo scope, viene eseguita la sua implementazione di Drop, e i valori vengono eliminati in ordine inverso, proprio come i tuoi defer:
struct Guard(&'static str);
impl Drop for Guard { fn drop(&mut self) { println!("{}", self.0); }}
fn process() { println!("apro il file"); let _file = Guard("chiudo il file");
println!("blocco il mutex"); let _lock = Guard("sblocco il mutex");
println!("lavoro");} // qui viene eliminato _lock, poi _file
fn main() { process(); println!("fatto");}Output:
apro il fileblocco il mutexlavorosblocco il mutexchiudo il filefattoNel codice vero raramente scrivi Drop da solo: un File si chiude da solo, un MutexGuard si sblocca da solo, un TcpStream si disconnette da solo. Non c’è niente da dimenticare. Vale la pena conoscere due differenze rispetto a defer. Drop viene eseguito alla fine del blocco, non della funzione, quindi la trappola di Go dei defer che si accumulano dentro un ciclo non esiste: ogni iterazione fa pulizia da sola. E attenzione a let _ = Guard(...): il semplice _ non tiene affatto il valore, che quindi viene eliminato subito. Usa invece un nome come _lock.
Le cose che ti faranno inciampare se vieni da Go
Sezione intitolata “Le cose che ti faranno inciampare se vieni da Go”Chiunque arrivi da Go prende le stesse buche. Conoscerle in anticipo aiuta.
-
Passare un valore può spostarlo. In Go, passare una slice a una funzione copia una piccola intestazione, ed entrambe le parti continuano a usarla. In Rust, passare un
Veclo sposta dentro la funzione, e dopo non puoi più usarlo:fn total(prices: Vec<u32>) -> u32 {prices.iter().sum()}fn main() {let prices = vec![3, 4, 5];let sum = total(prices);println!("{sum} from {} items", prices.len());}error[E0382]: borrow of moved value: `prices`--> src/main.rs:8:37|6 | let prices = vec![3, 4, 5];| ------ move occurs because `prices` has type `Vec<u32>`, which does not implement the `Copy` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after moveIl compilatore dice che
pricesè stato spostato nella chiamata atotale poi usato di nuovo. La soluzione è l’abitudine di Go in versione Rust: fai in modo che la funzione prenda in prestito una slice,fn total(prices: &[u32]), e chiamala contotal(&prices). Leggi prima Ownership: spiega questa regola. E0382 tratta questo errore in dettaglio. -
Il borrow checker. Puoi avere molti lettori (
&T) oppure un solo scrittore (&mut T) alla volta, mai entrambi. Il codice Go in cui tante struct tengono puntatori a un’unica struct condivisa e la modificano richiede in Rust un design diverso, o smart pointer comeRc<RefCell<T>>oArc<Mutex<T>>. Mettiti in conto qualche settimana frustrante. Poi diventa molto più facile. -
Niente valori zero.
let x: i32;seguito daprintln!("{x}")è un errore di compilazione, e ogni campo di una struct va impostato quando la crei. Se vuoi valori predefiniti in stile Go, fai derive diDefaulte scriviConfig { port: 8080, ..Default::default() }. -
Implementare un trait è esplicito. Avere un metodo
areanon rende il tuo tipo unaShape. Ti serveimpl Shape for Circle. Il lato positivo: puoi trovare ogni implementazione semplicemente cercandola. -
Stringcontro&str. Go ha un solo tipostring. Rust ne ha due:Stringpossiede il suo testo,&strlo prende in prestito. Di solito le funzioni accettano&stre le struct contengonoString. E non puoi indicizzare una stringa cons[0]come in Go; usas.chars()os.as_bytes()[0]. Vedi Stringhe. -
Le cose inutilizzate sono avvisi, non errori. Go si rifiuta di compilare con un import o una variabile inutilizzati. Rust si limita ad avvisarti, e puoi mettere
_davanti a un nome per dire «inutilizzato di proposito». -
Overflow degli interi. Go ricomincia da capo in silenzio. Rust provoca un panic nelle build di debug e ricomincia da capo nelle build di release, quindi scegli
checked_add,wrapping_addosaturating_addquando l’overflow è possibile. I letterali interi sonoi32per default, e gli indici sonousize. Vedi Overflow degli interi. -
Una libreria standard più piccola. Niente server HTTP, niente JSON, niente runtime async in
std. Li aggiungerai concargo add. All’inizio sembra strano, ma i crate più popolari sono stabili e ben documentati. -
L’async ha bisogno di un runtime e si propaga. Una
async fnnon fa niente finché non viene attesa dentro un runtime come tokio, e solo il codice async può usare.await. In Go questa separazione non esiste. Inizia con i thread normali; passa all’async quando ti servono tante connessioni simultanee. -
Tempi di compilazione. Go è stato progettato per compilare molto velocemente. Rust compila più lentamente, soprattutto le build pulite con molti crate. Usa
cargo checkmentre scrivi: salta la generazione del codice ed è molto più rapido. -
La compilazione incrociata richiede un po’ di configurazione.
GOOS=linux go buildfunziona e basta. In Rust aggiungi il target con rustup e spesso ti serve anche un linker per quel target. Strumenti comecrossse ne occupano per te.
Go o Rust? Quando scegliere l’uno o l’altro
Sezione intitolata “Go o Rust? Quando scegliere l’uno o l’altro”Sono entrambe buone scelte, e molti team li usano tutti e due. Ecco una regola pratica onesta.
Scegli Go quando vuoi rilasciare rapidamente servizi di rete e strumenti interni, il tuo team tiene a un codice che chiunque possa leggere dopo una settimana, le build veloci contano molto, o lavori nel mondo cloud-native (Kubernetes, Docker, Terraform) dove Go è la lingua comune. La libreria standard di Go e la sua concorrenza semplice rendono facile scrivere molti programmi server.
Scegli Rust quando ti servono latenze prevedibili senza pause del garbage collector, poco uso di memoria, o ogni briciolo di CPU (parser, database, motori di gioco, video, compressione). Rust è anche più adatto per il codice senza sistema operativo, per WebAssembly e per le librerie che vuoi chiamare da altri linguaggi. E ne vale la pena quando un crash dovuto a una data race o a un controllo su nil dimenticato costa caro. Discord ha scritto un articolo molto noto su come ha spostato un servizio da Go a Rust (in inglese) a causa dei picchi di latenza provocati dal garbage collector.
Il costo di Rust è il tempo di apprendimento e build più lente. Il costo di Go è un garbage collector e bug che emergono durante l’esecuzione invece che in compilazione. Quale costo pesa di più dipende dal tuo progetto.
Un percorso di studio per sviluppatori Go
Sezione intitolata “Un percorso di studio per sviluppatori Go”Non devi leggere questo sito in ordine. Da sviluppatore Go, questo percorso salta quello che sai già e si sofferma dove Rust è diverso:
- Primi passi e Strumenti e configurazione: installa Rust, configura rust-analyzer ed esegui
cargo new. - Variabili e Tipi di dati: scorrile velocemente, soprattutto per imparare
let mute i tipi numerici. - Ownership, poi Prestito, poi Slice: le pagine più importanti per te. Prenditi il tuo tempo.
- Stringhe:
Stringcontro&str. - Struct ed Enum, poi match e Pattern matching. Gli enum di Rust con dati sostituiscono molti type switch di Go.
- Option e Result, poi Errori personalizzati e anyhow e thiserror.
- Trait, Generici, Trait comuni e Trait object.
- Closure e Iteratori: Rust si appoggia alle catene di iteratori molto più di Go.
- Vettori, HashMap e HashSet.
- Moduli, Cargo e crate e Test.
- Lifetime e Smart pointer.
- Concorrenza, Canali, Stato condiviso, poi Async/await.
- JSON con Serde e Logging, poi costruisci il progetto API web.
Pratica
Sezione intitolata “Pratica”Entrambi gli esercizi ti danno un frammento di Go. Portalo in Rust.
1. Porta un contatore di parole
Sezione intitolata “1. Porta un contatore di parole”Questo programma Go conta le parole e le stampa in ordine alfabetico. Portalo in Rust. Suggerimento: usa una HashMap<&str, u32> e *counts.entry(word).or_insert(0) += 1 al posto del counts[w]++ di Go. text.split_whitespace() è strings.Fields.
func main() { text := "the cat and the dog and the bird"
counts := map[string]int{} for _, w := range strings.Fields(text) { counts[w]++ }
keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys)
for _, k := range keys { fmt.Printf("%s: %d\n", k, counts[k]) }}Mostra la soluzione
use std::collections::HashMap;
fn main() { let text = "the cat and the dog and the bird";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split_whitespace() { *counts.entry(word).or_insert(0) += 1; }
let mut keys: Vec<&str> = counts.keys().copied().collect(); keys.sort();
for key in keys { println!("{key}: {}", counts[key]); }}Output:
and: 2bird: 1cat: 1dog: 1the: 3Qui non c’è un valore zero su cui appoggiarsi come in Go, quindi entry(word).or_insert(0) dice «parti da 0 se la parola è nuova». Indicizzare con counts[key] è sicuro perché ogni chiave viene dalla mappa. Ancora più breve: usa una BTreeMap al posto della HashMap, e le chiavi escono già ordinate, così puoi eliminare il vettore keys. Il testo resta in inglese perché il risultato dipende dall’ordine alfabetico delle parole.
2. Porta un errore sentinella
Sezione intitolata “2. Porta un errore sentinella”Questo codice Go restituisce un errore sentinella e lo controlla con errors.Is. Portalo in modo che withdraw restituisca un Result<(), WithdrawError>, dove WithdrawError è un enum con due varianti. Suggerimento: implementa fmt::Display per l’enum così puoi stamparlo, e fai match sulla variante dove Go usa errors.Is.
var ErrInsufficientFunds = errors.New("insufficient funds")
type Account struct{ Balance int }
func (a *Account) Withdraw(n int) error { if n <= 0 { return fmt.Errorf("invalid amount %d", n) } if n > a.Balance { return ErrInsufficientFunds } a.Balance -= n return nil}
func main() { acc := &Account{Balance: 100} for _, n := range []int{30, 0, 500} { if err := acc.Withdraw(n); err != nil { if errors.Is(err, ErrInsufficientFunds) { fmt.Println("declined:", err) } else { fmt.Println("error:", err) } continue } fmt.Println("ok, balance", acc.Balance) }}Mostra la soluzione
use std::fmt;
enum WithdrawError { InvalidAmount(i64), InsufficientFunds,}
impl fmt::Display for WithdrawError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { WithdrawError::InvalidAmount(n) => write!(f, "importo non valido {n}"), WithdrawError::InsufficientFunds => write!(f, "fondi insufficienti"), } }}
struct Account { balance: i64,}
impl Account { fn withdraw(&mut self, n: i64) -> Result<(), WithdrawError> { if n <= 0 { return Err(WithdrawError::InvalidAmount(n)); } if n > self.balance { return Err(WithdrawError::InsufficientFunds); } self.balance -= n; Ok(()) }}
fn main() { let mut acc = Account { balance: 100 }; for n in [30, 0, 500] { match acc.withdraw(n) { Ok(()) => println!("ok, saldo {}", acc.balance), Err(e @ WithdrawError::InsufficientFunds) => println!("rifiutato: {e}"), Err(e) => println!("errore: {e}"), } }}Output:
ok, saldo 70errore: importo non valido 0rifiutato: fondi insufficientiL’errore sentinella e l’errore creato con fmt.Errorf diventano due varianti di un solo enum, e errors.Is diventa un ramo del match. e @ WithdrawError::InsufficientFunds riconosce la variante e in più ti dà l’intero valore come e da stampare. Un bel vantaggio: se più avanti aggiungi una terza variante, il compilatore ti mostra ogni match da aggiornare. Il crate thiserror può scrivere per te l’implementazione di Display.
Riepilogo
Sezione intitolata “Riepilogo”- Molto di Go si porta dietro: struct con metodi, niente ereditarietà, errori come valori, un solo formattatore standard e un solo strumento per compilare e testare.
- I grandi cambiamenti sono l’ownership al posto del garbage collector,
Optional posto dinil, eResult+?al posto diif err != nil. - Le interfacce diventano trait, e implementarne uno è esplicito (
impl Trait for Type).deferdiventa ilDropautomatico alla fine di uno scope. - Goroutine e canali diventano
thread::spawn+mpscper pochi worker, o task tokio per molti; il compilatore esclude le data race. - Cargo sostituisce
go build,go testego mod; clippy e rustfmt sostituisconogo vetegofmt. Al posto di Gin guarda axum o actix-web, al posto di GORM guarda SeaORM o Diesel, al posto di pgx e sqlc guarda sqlx, e al posto di cobra guarda clap.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. In Go un tipo soddisfa un'interfaccia semplicemente avendo i metodi giusti. Come implementa un trait un tipo Rust?
2. Cosa fa ? dopo una chiamata che restituisce un Result?
3. Con std::sync::mpsc, quando si ferma for msg in rx (o rx.iter())?
4. Il tuo team Go costruisce le API HTTP con Gin. Quale crate Rust è il più simile?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- The Rust Programming Language (in inglese): il libro ufficiale. I capitoli 4 (ownership), 9 (errori), 10 (generici e trait) e 16 (concorrenza) sono i più importanti per chi viene da Go.
- Tokio tutorial (in inglese): il runtime async usato dalla maggior parte dei server Rust, la cosa più vicina allo scheduler delle goroutine di Go.
- Documentazione di axum (in inglese): il framework web da guardare per primo se vieni da Gin, Echo o Chi.
- Why Discord is switching from Go to Rust (in inglese): la storia vera di un team che ha spostato da Go a Rust un servizio sensibile alla latenza.
- Are We Web Yet? (in inglese): una panoramica della comunità sulle librerie e i framework web di Rust.