Salta ai contenuti

Rust per sviluppatori C#

Se scrivi C# per lavoro, ho una buona notizia: molto di Rust ti sembrerà familiare. Rust ha generici con vincoli, async/await, un pattern matching potente, catene di iteratori che si leggono quasi come LINQ, e un ottimo strumento da riga di comando (Cargo) che fa quello che fa dotnet. Sai già ragionare per tipi, interfacce e lambda. Tutto questo ti torna utile.

Il grande cambiamento riguarda la memoria e gli errori. Rust non ha garbage collector. Ogni valore ha invece un solo proprietario, e il compilatore controlla l’ownership (possesso) e il prestito (borrowing) prima che il programma parta. Rust inoltre non ha null (usi Option) e non ha eccezioni (usi Result). È su questi tre cambiamenti che passerai la maggior parte del tempo di studio. Questa pagina mette in corrispondenza quello che conosci di C# e .NET con Rust, così puoi dedicare quel tempo alle parti davvero nuove.

Se invece cerchi un confronto generale con C++, Go e Python, guarda Rust vs C++, Go e Python.

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

C# Rust Per saperne di più
class con campi e metodi struct per i dati + un blocco impl per i metodi Struct
interface trait Trait
Classe astratta, ereditarietà Niente ereditarietà. Usa la composizione (una struct dentro una struct) + trait con metodi predefiniti Trait, Design pattern
List<IShape> di classi diverse Vec<Box<dyn Shape>> (un trait object) Trait object
Metodi di estensione Un tuo trait, implementato per un tipo che non hai scritto tu (come String) Trait
Generici con where T : IComparable<T> Generici con vincoli di trait (trait bound): T: Ord o where T: Ord Generici
null, Nullable<int>, tipi riferimento nullable Option<T>: o Some(valore) o None Option
?? e ?. .unwrap_or(...), .map(...), .and_then(...) e ? Option
Eccezioni, throw, try/catch Result<T, E> + l’operatore ? Result, Errori personalizzati
Un’eccezione per un vero bug (come InvalidOperationException) panic! (ferma il thread) Panic
IDisposable + using Drop, che viene eseguito da solo quando il proprietario esce dallo scope (RAII) Scope, Smart pointer
Garbage collector Ownership e prestito, controllati in compilazione Ownership, Prestito
record struct con #[derive(Debug, Clone, PartialEq)] Struct, Trait comuni
Espressione with su un record Sintassi di aggiornamento delle struct: Order { total: 5, ..old } Struct
enum (solo interi con nome) enum in cui ogni variante può portare i propri dati Enum
Espressioni switch, pattern is match (il compilatore controlla che tu abbia coperto ogni caso) match, Pattern matching
Tuple (int, string) Tuple (i32, String) Tuple
Parametri ref / out &mut T, oppure restituisci una tupla o un Option Prestito
Lambda, Func<T>, Action<T>, delegate Closure, descritte dai trait Fn, FnMut, FnOnce Closure, Fn, FnMut e FnOnce
event Un canale, o una lista di closure in un box (callback) Canali, Fn, FnMut e FnOnce
LINQ: Where, Select, Sum, OrderBy, ToList Adattatori di iteratori: filter, map, sum, sort_by (su un Vec), collect Iteratori, Ordinamento
IEnumerable<T> e yield return impl Iterator<Item = T>, o un tuo tipo che implementa Iterator Iteratori personalizzati
async Task<T> / await async fn restituisce un Future; per eseguirlo ti serve un runtime come tokio Async/await
Task.Run, Thread, lock std::thread::spawn o tokio::spawn; Mutex<T> avvolge i dati che protegge Concorrenza, Stato condiviso
Proprietà (get; set;) Niente proprietà. Usa campi pub, o metodi getter e setter Struct
string e StringBuilder &str (una vista presa in prestito su un testo) e String (posseduta, può crescere) Stringhe
Interpolazione di stringhe $"Hi {name}" format!("Hi {name}") e println!("Hi {name}") Output
var let (immutabile per default; scrivi let mut per permettere modifiche) Variabili
const, readonly const, e un semplice let (è già di sola lettura) Variabili
Campo static readonly static, o LazyLock per valori costruiti all’avvio Variabili
namespace e direttive using Moduli (mod) e use Moduli
Assembly / progetto Crate (una libreria o un binario) Cargo e crate
Attributi come [Serializable] Attributi come #[derive(Serialize)] e macro derive Macro, JSON con Serde
Reflection Quasi niente durante l’esecuzione. Al suo posto, macro e derive generano codice in compilazione Macro
unsafe e puntatori Blocchi unsafe e puntatori grezzi Rust unsafe
Span<T>, ReadOnlySpan<char> Slice: &[T] e &str Slice
Override di ToString(), IFormattable Implementa Display (e .to_string() funziona gratis) Trait comuni
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (di solito con derive) Trait comuni
Operatori di conversione implicit / explicit From/Into (e TryFrom quando può fallire) Conversioni
Overloading degli operatori (operator +) Implementa i trait di std::ops, come Add Trait avanzati
Aritmetica checked / unchecked checked_add, wrapping_add, saturating_add Overflow degli interi
decimal Nessun tipo decimale integrato; usa il crate rust_decimal, o salva i centesimi in un intero Tipi di dati

La libreria standard copre quasi tutto quello che c’è in System.Collections.Generic. Cambiano i nomi, non le idee.

C# Rust Per saperne di più
List<T> Vec<T> Vettori
T[] (lunghezza fissa) [T; N] quando la lunghezza è nota in compilazione, altrimenti Vec<T> Array
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V> (le chiavi restano ordinate) HashMap
HashSet<T> HashSet<T> HashSet
SortedSet<T> BTreeSet<T> HashSet
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque e BinaryHeap
Stack<T> Vec<T> con push / pop Vettori
PriorityQueue<TElement, TPriority> BinaryHeap<T> (prima il più grande; avvolgi in Reverse per avere prima il più piccolo) VecDeque e BinaryHeap
LinkedList<T> LinkedList<T> esiste, ma serve di rado; di solito VecDeque è più veloce VecDeque e BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>, o il crate dashmap Stato condiviso

Una cosa a cui fare attenzione: come Dictionary, anche una HashMap di Rust non ha un ordine fisso. Se ne stampi il contenuto, ordinalo prima o usa una BTreeMap.

Questa è la parte che piace subito alla maggior parte degli sviluppatori C#. Cargo fa il lavoro della CLI dotnet, di MSBuild e di NuGet in un solo strumento.

.NET Rust Note
CLI dotnet cargo Compila, esegue, testa, aggiunge pacchetti, pubblica. Vedi Cargo e crate
.csproj Cargo.toml Breve e modificabile a mano
packages.lock.json Cargo.lock Viene creato per te
.sln con più progetti Un workspace Cargo Un Cargo.toml in cima elenca i crate membri
NuGet / nuget.org crates.io La documentazione delle API di ogni crate pubblicato è su docs.rs (in inglese)
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (cargo new --lib per una libreria)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release Le build di debug sono molto più lente in esecuzione; fai i benchmark in release
dotnet watch cargo watch o bacon (si installano con cargo install)
dotnet tool install -g cargo install
global.json (versione dell’SDK) rustup e un file rust-toolchain.toml
Analizzatori Roslyn cargo clippy Centinaia di lint utili, ufficiali
dotnet format cargo fmt Un solo stile standard per tutti
Visual Studio / Rider VS Code + rust-analyzer, o JetBrains RustRover rust-analyzer offre completamento, vai-alla-definizione ed errori in linea
Commenti di documentazione XML /// Commenti di documentazione /// in Markdown + cargo doc Gli esempi di codice nella documentazione vengono eseguiti come test. Vedi Documentazione
xUnit / NUnit / MSTest #[test] integrato e cargo test (cargo nextest è un runner più veloce molto usato) Vedi Test
Moq mockall, o finti oggetti scritti a mano che implementano il tuo trait
Coverlet (copertura del codice) cargo llvm-cov o cargo tarpaulin
BenchmarkDotNet criterion Vedi Prestazioni
dotnet-trace, profiler cargo flamegraph, perf, o qualsiasi profiler nativo

Ecco un piccolo Cargo.toml, così puoi confrontarlo con un .csproj:

[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 .NET. «Usiamo Entity Framework. Cosa c’è in Rust?» Ecco gli equivalenti più vicini. Dietro Rust non c’è una singola azienda con uno «stack predefinito», come Microsoft per .NET, quindi spesso scegli tra due o tre buoni crate della comunità.

.NET Rust Note
Entity Framework Core Diesel: ORM e query builder; le tue query vengono controllate sui tipi in compilazione. Migrazioni con diesel_cli Maturo (2.x), sincrono per default (esiste diesel-async)
Entity Framework Core (async, stile a entità) SeaORM: ORM asincrono con entità, relazioni e migrazioni (sea-orm-cli) È il più simile a EF; costruito sopra sqlx
Dapper sqlx: scrivi SQL semplice, e la macro query! lo controlla sul tuo vero database in compilazione. Migrazioni con sqlx-cli Asincrono, molto popolare, non è un ORM
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) Driver di livello più basso
StackExchange.Redis redis

Se ti piaceva EF, prova prima SeaORM. Se ti piaceva Dapper e scrivere il tuo SQL, prova sqlx. Se vuoi le garanzie più forti in compilazione, prova Diesel. Database in Rust li mostra tutti e tre con codice funzionante.

.NET Rust Note
ASP.NET Core Web API axum (ne costruiamo una in Progetto: API web), actix-web, Rocket axum e actix-web sono molto usati in produzione
Minimal API axum: le rotte sono semplici handler async fn Lo stile è molto simile
Pipeline di middleware Layer di tower e il crate tower-http (CORS, compressione, tracing, timeout) axum usa tower direttamente
Dependency injection integrata State in axum, costruttori semplici e trait Vedi la riga sulla DI più sotto
Swashbuckle / OpenAPI utoipa
SignalR WebSocket in axum (la feature ws), o tokio-tungstenite Nessun livello di protocollo stile SignalR integrato
gRPC (Grpc.AspNetCore) tonic (con prost per protobuf)
HttpClient reqwest Asincrono per default, con un client bloccante opzionale
Autenticazione JWT bearer jsonwebtoken
.NET Rust Note
System.Text.Json / Newtonsoft.Json serde + serde_json Lo standard; gestisce anche TOML, YAML e altro. Vedi JSON con Serde
ILogger / Serilog tracing (+ tracing-subscriber), o log + env_logger Logging strutturato con span. Vedi Logging
IConfiguration / appsettings.json config o figment Combina file e variabili d’ambiente, poi deserializza con serde
Container DI (IServiceCollection) Di solito niente: passa le dipendenze ai costruttori, usa trait per le cose che vuoi sostituire nei test Esiste shaku, ma pochi progetti usano un container
AutoMapper Scrivi implementazioni di From / Into Esplicito e controllato dal compilatore. Vedi Conversioni
FluentValidation / DataAnnotations validator o garde (validazione basata su derive)
Polly (tentativi ripetuti) backon, o il middleware di retry di tower
MediatR Nessun equivalente diretto Semplici trait, o canali tra task
Hangfire / job in background tokio::spawn per task nel processo; apalis per code di job persistenti
CancellationToken Elimina il future (drop), o usa CancellationToken di tokio-util
.NET Rust Note
Blazor WebAssembly Leptos, Yew, Dioxus Tutti prima della versione 1.0; le API cambiano ancora. Vedi WebAssembly
WPF / WinForms / MAUI Tauri (interfaccia web + backend Rust), Slint, egui, Iced Tauri 2 e Slint 1.x sono i più stabili. Nessun toolkit grafico ufficiale
Unity Bevy, o Rust dentro Godot con il crate godot Bevy è prima della 1.0 e rompe le sue API tra una release e l’altra
ML.NET candle, burn, linfa Molto più giovani degli strumenti di ML di Python
Azure SDK per .NET L’Azure SDK ufficiale per Rust: azure_core, azure_identity, azure_storage_blob, … Copre meno servizi dell’SDK .NET

Guardiamo del codice vero. Ecco un piccolo conto bancario in C#:

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner} ha {account.Balance}");
public class BankAccount
{
public string Owner { get; }
private decimal balance;
public BankAccount(string owner, decimal balance)
{
Owner = owner;
this.balance = balance;
}
public void Deposit(decimal amount) => balance += amount;
public decimal Balance => balance;
}

E in Rust:

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` è solo una convenzione, non una parola chiave.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`: questo metodo modifica il conto.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`: questo metodo si limita a leggere.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{} ha {}", account.owner, account.balance());
}

Output:

Ada ha 150
▶ Provalo tu

Dati e comportamento vivono in due blocchi separati. Non ci sono costruttori, solo funzioni che restituiscono Self. Ogni metodo dichiara subito se legge (&self) o modifica (&mut self) il valore. E siccome deposit modifica il conto, la variabile deve essere let mut. In C# qualsiasi metodo può modificare qualsiasi campo; in Rust le modifiche si vedono nella firma.

Fianco a fianco: un’interfaccia diventa un trait

Sezione intitolata “Fianco a fianco: un’interfaccia diventa un trait”

In C# potresti avere un’interfaccia IShape con due classi e una List<IShape>:

var shapes = new List<IShape> { new Circle { Radius = 1 }, new Rectangle { Width = 2, Height = 3 } };
foreach (var s in shapes) Console.WriteLine($"{s.Area():F2}");
public interface IShape { double Area(); }
public class Circle : IShape
{
public double Radius { get; init; }
public double Area() => Math.PI * Radius * Radius;
}
public class Rectangle : IShape
{
public double Width { get; init; }
public double Height { get; init; }
public double Area() => Width * Height;
}

In Rust l’interfaccia è un trait, e ogni struct lo implementa nel proprio blocco impl:

trait Shape {
fn area(&self) -> f64;
}
struct Circle {
radius: f64,
}
struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius * self.radius
}
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
// Box<dyn Shape> fa la parte di «un riferimento a un IShape qualsiasi».
let shapes: Vec<Box<dyn Shape>> = vec![
Box::new(Circle { radius: 1.0 }),
Box::new(Rectangle { width: 2.0, height: 3.0 }),
];
for shape in &shapes {
println!("{:.2}", shape.area());
}
}

Output:

3.14
6.00
▶ Provalo tu

In C# ogni chiamata a un’interfaccia passa per una chiamata virtuale. In Rust scegli tu: Box<dyn Shape> usa una chiamata virtuale (vedi Trait object), mentre una funzione generica come fn print_area<T: Shape>(s: &T) viene compilata a parte per ogni tipo, senza nessuna chiamata virtuale. Un bel vantaggio in più: puoi implementare un tuo trait per tipi che non hai scritto tu, perfino String o i32. È così che Rust fa quello che in C# fanno i metodi di estensione.

Il C# con i tipi riferimento nullable ti avvisa sui null, ma null esiste ancora durante l’esecuzione:

string? FindEmail(int userId) => userId == 1 ? "[email protected]" : null;
var email = FindEmail(2);
if (email is not null) Console.WriteLine(email.ToUpper());
else Console.WriteLine("nessuna email registrata");
Console.WriteLine(FindEmail(2) ?? "sconosciuta");

In Rust un valore che potrebbe mancare ha il tipo Option<T>. Non puoi usarlo come un valore normale finché non hai gestito il caso None:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
match find_email(1) {
Some(email) => println!("{}", email.to_uppercase()),
None => println!("nessuna email registrata"),
}
// `if let` è la forma breve quando ti interessa un solo caso.
if let Some(email) = find_email(2) {
println!("trovata {email}");
} else {
println!("nessuna email registrata");
}
// unwrap_or funziona come l'operatore ?? di C#.
let email = find_email(2).unwrap_or("sconosciuta".to_string());
println!("{email}");
}

Output:

nessuna email registrata
sconosciuta
▶ Provalo tu

In Rust non esiste la NullReferenceException, perché non c’è nessun null da dereferenziare. Il compilatore semplicemente non ti lascia chiamare .to_uppercase() su un Option<String>. Impara i metodi di supporto nella pagina Option, e le forme brevi in if let.

In C# il parsing può lanciare un’eccezione, e tu la catturi da qualche parte più in alto nello stack delle chiamate:

static int TotalSeats(string a, string b) => int.Parse(a) + int.Parse(b);
try
{
Console.WriteLine(TotalSeats("12", "30"));
Console.WriteLine(TotalSeats("12", "trenta"));
}
catch (FormatException e)
{
Console.WriteLine($"Errore: {e.Message}");
}

In Rust una funzione che può fallire lo dichiara nel tipo di ritorno. L’operatore ? significa «se questa cosa è fallita, restituisci subito l’errore a chi mi ha chiamato». Fa lo stesso lavoro di un’eccezione che risale, ma lo vedi nel codice:

use std::num::ParseIntError;
fn total_seats(a: &str, b: &str) -> Result<i32, ParseIntError> {
let first: i32 = a.parse()?;
let second: i32 = b.parse()?;
Ok(first + second)
}
fn main() {
for (a, b) in [("12", "30"), ("12", "trenta")] {
match total_seats(a, b) {
Ok(total) => println!("Totale: {total}"),
Err(e) => println!("Errore: {e}"),
}
}
}

Output:

Totale: 42
Errore: invalid digit found in string
▶ Provalo tu

Il testo dell’errore, «invalid digit found in string» (cifra non valida nella stringa), arriva dalla libreria standard ed è sempre in inglese.

La grande differenza: in C# qualsiasi metodo potrebbe lanciare un’eccezione, e la firma non te lo dice. In Rust Result fa parte della firma, quindi chi chiama non può dimenticarsi di gestirlo. Per i tuoi tipi di errore, vedi Errori personalizzati. Nelle applicazioni, il crate anyhow ti dà qualcosa di simile a «cattura qualsiasi eccezione» (vedi anyhow e thiserror).

Fianco a fianco: LINQ diventa una catena di iteratori

Sezione intitolata “Fianco a fianco: LINQ diventa una catena di iteratori”

Una tipica query LINQ:

var orders = new List<Order> { new("Ada", 120), new("Grace", 80), new("Linus", 200), new("Ken", 150) };
var bigSpenders = orders
.Where(o => o.Total > 100)
.OrderByDescending(o => o.Total)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", bigSpenders));
Console.WriteLine($"Incasso: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

La versione Rust usa gli adattatori degli iteratori e le closure:

struct Order {
customer: String,
total: u32,
}
fn main() {
let orders = vec![
Order { customer: "Ada".to_string(), total: 120 },
Order { customer: "Grace".to_string(), total: 80 },
Order { customer: "Linus".to_string(), total: 200 },
Order { customer: "Ken".to_string(), total: 150 },
];
// Where(...).ToList()
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// OrderByDescending(...): l'ordinamento si fa su un Vec, non sull'iteratore.
big.sort_by(|a, b| b.total.cmp(&a.total));
// Select(...)
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// Sum(...)
let revenue: u32 = orders.iter().map(|o| o.total).sum();
println!("Incasso: {revenue}");
}

Output:

Linus, Ken, Ada
Incasso: 550
▶ Provalo tu

Gli iteratori di Rust sono pigri (lazy), proprio come l’esecuzione differita di LINQ: non succede niente finché non chiami qualcosa come collect(), sum() o un ciclo for. collect() è il tuo ToList(). A differenza di LINQ, le catene di iteratori di solito vengono compilate nello stesso codice macchina di un ciclo scritto a mano, quindi non paghi niente per lo stile elegante. L’ordinamento è spiegato nella pagina Ordinamento e ricerca.

In C# devi ricordarti di using per rilasciare una risorsa al momento giusto:

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"interrogo {conn.Name}");
}
Console.WriteLine("dopo il blocco");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"chiudo la connessione a {Name}");
}

In Rust la pulizia è automatica. Quando il proprietario di un valore esce dallo scope, Rust chiama la sua implementazione di Drop. Non c’è niente da dimenticare:

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("chiudo la connessione a {}", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("interrogo {}", conn.name);
} // qui `conn` esce dallo scope, quindi viene eseguito drop().
println!("dopo il blocco");
}

Output:

interrogo orders-db
chiudo la connessione a orders-db
dopo il blocco
▶ Provalo tu

Si chiama RAII, ed è il modo in cui Rust gestisce tutto: file, socket, lock e la memoria stessa. Un file si chiude quando il suo valore File viene eliminato. Un Mutex si sblocca quando la sua guardia viene eliminata. Non c’è nessun finalizzatore e nessuna pausa del GC; la pulizia avviene in un momento prevedibile.

Chiunque arrivi da C# prende le stesse buche. Conoscerle in anticipo aiuta.

  • L’assegnamento sposta, non copia un riferimento. In C#, var b = a; con una classe ti dà due riferimenti allo stesso oggetto. In Rust, let b = a; sposta l’ownership di una String o di un Vec in b, e a non si può più usare:

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} and {copy}");
    }
    error[E0382]: borrow of moved value: `owner`
    --> src/main.rs:4:16
    |
    2 | let owner = String::from("Ada");
    | ----- move occurs because `owner` has type `String`, which does not implement the `Copy` trait
    3 | let copy = owner;
    | ----- value moved here
    4 | println!("{owner} and {copy}");
    | ^^^^^ value borrowed here after move

    Il compilatore dice che owner è stato spostato in copy e poi usato di nuovo. La soluzione è prendere in prestito (let copy = &owner;) o clonare di proposito (owner.clone()). Leggi prima Ownership: spiega questa regola.

  • Il borrow checker. Puoi avere molti lettori (&T) oppure un solo scrittore (&mut T) alla volta, mai entrambi. Schemi normali in C#, come oggetti che puntano al loro genitore o tanti oggetti che modificano un unico oggetto condiviso, richiedono smart pointer o un design diverso. Mettiti in conto qualche settimana frustrante. Poi diventa molto più facile.

  • String contro &str. C# 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. Vedi Stringhe.

  • Niente ereditarietà. Non puoi scrivere una classe base con campi condivisi e metodi da ridefinire. Usa i trait (con metodi predefiniti) per il comportamento condiviso, e metti i dati condivisi in una struct contenuta nelle altre.

  • Niente eccezioni. Gli errori sono valori. Scriverai ? molto spesso, e anche il tuo main può restituire Result. Chiamare .unwrap() va bene mentre impari, ma è la versione Rust di «ignora l’eccezione e vai in crash».

  • Niente null. All’inizio Option dappertutto sembra lavoro in più. Dopo un po’ ti accorgerai che non hai più bug da riferimento null.

  • Clonazioni esplicite. Rust non copia mai un valore sull’heap alle tue spalle. Se vedi .clone(), viene fatta una copia vera. È una caratteristica voluta: il costo è visibile.

  • Tempi di compilazione. Una build pulita di un progetto con molti crate richiede più tempo di una build .NET. Le build incrementali e cargo check (che salta la generazione del codice) sono molto più veloci; usa cargo check mentre scrivi.

  • L’async ha bisogno di un runtime. In .NET il runtime e il pool di thread ci sono sempre. In Rust una async fn non fa niente finché un executor non la esegue, quindi aggiungi tokio e metti #[tokio::main] su main. Vedi Async/await.

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

  1. Primi passi: installa Rust 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.
  6. Option e Result, poi Errori personalizzati.
  7. Trait, Generici, Trait comuni e Trait object.
  8. Closure e Iteratori: il tuo LINQ.
  9. Vettori, HashMap e VecDeque e BinaryHeap.
  10. Moduli, Cargo e crate e Test.
  11. Lifetime e Smart pointer.
  12. Concorrenza, Stato condiviso e Async/await.
  13. JSON con Serde e Logging, poi costruisci il progetto API web.

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

Porta questo codice C#. Deve stampare i nomi dei prodotti disponibili che costano meno di 20. Suggerimento: crea una struct con #[derive(Debug, Clone, PartialEq)], poi usa filter, map e collect in un Vec<&str>. Vec<&str> ha un metodo .join(", ").

var products = new List<Product>
{
new("Quaderno", 5, true),
new("Cuffie", 45, true),
new("Penna", 2, false),
new("Tazza", 12, true),
};
var cheap = products.Where(p => p.InStock && p.Price < 20).Select(p => p.Name);
Console.WriteLine(string.Join(", ", cheap));
record Product(string Name, int Price, bool InStock);
Mostra la soluzione
#[derive(Debug, Clone, PartialEq)]
struct Product {
name: String,
price: u32,
in_stock: bool,
}
fn product(name: &str, price: u32, in_stock: bool) -> Product {
Product { name: name.to_string(), price, in_stock }
}
fn main() {
let products = vec![
product("Quaderno", 5, true),
product("Cuffie", 45, true),
product("Penna", 2, false),
product("Tazza", 12, true),
];
let cheap: Vec<&str> = products
.iter()
.filter(|p| p.in_stock && p.price < 20)
.map(|p| p.name.as_str())
.collect();
println!("{}", cheap.join(", "));
}

Output:

Quaderno, Tazza
▶ Provalo tu

Una piccola funzione di supporto fa la parte del costruttore posizionale del record. I derive ti danno quello che un record C# ti dà gratis: stampa con {:?}, copia con .clone() e confronto con ==.

Questo codice C# toglie un articolo da un dizionario di scorte e lancia un’eccezione quando qualcosa non va. Portalo in modo che take restituisca un Result<u32, String> invece di lanciare eccezioni. Suggerimento: stock.get_mut(item) restituisce un Option<&mut u32>. Trasformalo in un Result con .ok_or(...), poi usa ?.

var stock = new Dictionary<string, int> { ["mele"] = 3, ["pere"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"nessun articolo chiamato {item}");
if (count == 0)
throw new InvalidOperationException($"{item}: scorte esaurite");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "mele", "pere", "kiwi" })
{
try { Console.WriteLine($"{item}: preso 1, ne restano {Take(item)}"); }
catch (Exception e) { Console.WriteLine($"errore: {e.Message}"); }
}
Mostra la soluzione
use std::collections::HashMap;
fn take(stock: &mut HashMap<String, u32>, item: &str) -> Result<u32, String> {
let count = stock
.get_mut(item)
.ok_or(format!("nessun articolo chiamato {item}"))?;
if *count == 0 {
return Err(format!("{item}: scorte esaurite"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("mele".to_string(), 3);
stock.insert("pere".to_string(), 0);
for item in ["mele", "pere", "kiwi"] {
match take(&mut stock, item) {
Ok(left) => println!("{item}: preso 1, ne restano {left}"),
Err(e) => println!("errore: {e}"),
}
}
}

Output:

mele: preso 1, ne restano 2
errore: pere: scorte esaurite
errore: nessun articolo chiamato kiwi
▶ Provalo tu

TryGetValue con un parametro out diventa get_mut, che restituisce un Option. I due throw diventano valori Err, e il try/catch diventa un match. Il &mut stock nella chiamata rende chiaro che take modifica il dizionario.

  • Molto di C# si porta dietro: generici, async/await, lambda, pattern matching e iteratori stile LINQ hanno tutti equivalenti vicini in Rust.
  • I grandi cambiamenti sono l’ownership al posto del garbage collector, Option al posto di null, e Result + ? al posto delle eccezioni.
  • Le classi diventano struct + impl, le interfacce diventano trait, e l’ereditarietà diventa composizione più trait. using/IDisposable diventa il Drop automatico.
  • Cargo sostituisce la CLI dotnet, MSBuild e NuGet; clippy e rustfmt sostituiscono gli analizzatori e dotnet format; i test sono integrati.
  • Al posto di EF Core guarda SeaORM o Diesel, al posto di Dapper guarda sqlx, e al posto di ASP.NET Core guarda axum o actix-web. Le opzioni per interfacce grafiche, giochi e ML sono più giovani.

Verifica veloce

1. Cosa usa Rust al posto di null?

2. Il tuo team usa Dapper e ama scrivere SQL a mano. Quale crate Rust è il più simile?

3. Qual è l'equivalente Rust più vicino al blocco using di C# con IDisposable?

4. In C#, var b = a; con una classe copia un riferimento. Cosa fa let b = a; con una String in Rust?

Punteggio: 0 / 4