Salta ai contenuti

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.

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

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.

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.

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
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

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: 150
il saldo è 150
▶ Provalo tu

La 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.14
area totale: 9.14
▶ Provalo tu

Essere 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.

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 Ada
Ada risponde a nessuno
Eve: utente inesistente
▶ Provalo tu

La 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.

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:8080
errore: porta non valida "abc": invalid digit found in string
errore: porta non valida "70000": number too large to fit in target type
errore: la porta 0 è riservata
▶ Provalo tu

I 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: 14
▶ Provalo tu

Alcune 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: 14
▶ Provalo tu

Siamo 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.

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 file
blocco il mutex
lavoro
sblocco il mutex
chiudo il file
fatto
▶ Provalo tu

Nel 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.

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 Vec lo 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` trait
    7 | let sum = total(prices);
    | ------ value moved here
    8 | println!("{sum} from {} items", prices.len());
    | ^^^^^^ value borrowed here after move

    Il compilatore dice che prices è stato spostato nella chiamata a total e 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 con total(&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 come Rc<RefCell<T>> o Arc<Mutex<T>>. Mettiti in conto qualche settimana frustrante. Poi diventa molto più facile.

  • Niente valori zero. let x: i32; seguito da println!("{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 di Default e scrivi Config { port: 8080, ..Default::default() }.

  • Implementare un trait è esplicito. Avere un metodo area non rende il tuo tipo una Shape. Ti serve impl Shape for Circle. Il lato positivo: puoi trovare ogni implementazione semplicemente cercandola.

  • String contro &str. Go ha un solo tipo string. Rust ne ha due: String possiede il suo testo, &str lo prende in prestito. Di solito le funzioni accettano &str e le struct contengono String. E non puoi indicizzare una stringa con s[0] come in Go; usa s.chars() o s.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_add o saturating_add quando l’overflow è possibile. I letterali interi sono i32 per default, e gli indici sono usize. Vedi Overflow degli interi.

  • Una libreria standard più piccola. Niente server HTTP, niente JSON, niente runtime async in std. Li aggiungerai con cargo 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 fn non 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 check mentre scrivi: salta la generazione del codice ed è molto più rapido.

  • La compilazione incrociata richiede un po’ di configurazione. GOOS=linux go build funziona e basta. In Rust aggiungi il target con rustup e spesso ti serve anche un linker per quel target. Strumenti come cross se ne occupano per te.

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.

Non devi leggere questo sito in ordine. Da sviluppatore Go, questo percorso salta quello che sai già e si sofferma dove Rust è diverso:

  1. Primi passi e Strumenti e configurazione: installa Rust, configura rust-analyzer ed 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. Gli enum di Rust con dati sostituiscono molti type switch di Go.
  6. Option e Result, poi Errori personalizzati e anyhow e thiserror.
  7. Trait, Generici, Trait comuni e Trait object.
  8. Closure e Iteratori: Rust si appoggia alle catene di iteratori molto più di Go.
  9. Vettori, HashMap e HashSet.
  10. Moduli, Cargo e crate e Test.
  11. Lifetime e Smart pointer.
  12. Concorrenza, Canali, Stato condiviso, poi Async/await.
  13. JSON con Serde e Logging, poi costruisci il progetto API web.

Entrambi gli esercizi ti danno un frammento di Go. Portalo in Rust.

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: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Provalo tu

Qui 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.

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 70
errore: importo non valido 0
rifiutato: fondi insufficienti
▶ Provalo tu

L’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.

  • 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, Option al posto di nil, e Result + ? al posto di if err != nil.
  • Le interfacce diventano trait, e implementarne uno è esplicito (impl Trait for Type). defer diventa il Drop automatico alla fine di uno scope.
  • Goroutine e canali diventano thread::spawn + mpsc per pochi worker, o task tokio per molti; il compilatore esclude le data race.
  • Cargo sostituisce go build, go test e go mod; clippy e rustfmt sostituiscono go vet e gofmt. 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

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

  • 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.