Ir al contenido

Rust para desarrolladores de C#

Si programas en C# para ganarte la vida, tenemos buenas noticias: mucho de Rust te va a resultar familiar. Rust tiene genéricos con restricciones, async/await, una potente coincidencia de patrones, cadenas de iteradores que se leen casi como LINQ y una excelente herramienta de línea de comandos (Cargo) que hace lo mismo que dotnet. Ya sabes pensar en tipos, interfaces y lambdas. Todo eso te sirve.

El gran cambio está en la memoria y los errores. Rust no tiene recolector de basura (garbage collector). En su lugar, cada valor tiene un único dueño, y el compilador comprueba el ownership (propiedad) y el borrowing (préstamo) antes de que tu programa se ejecute. Rust tampoco tiene null (usas Option) ni excepciones (usas Result). Estos tres cambios son los que más tiempo de aprendizaje te van a llevar. Esta página traduce a Rust lo que ya conoces de C# y .NET, para que dediques ese tiempo a lo que de verdad es nuevo.

Si prefieres una comparación general con C++, Go y Python, consulta Rust vs C++, Go y Python.

Usa esta tabla como un diccionario de bolsillo. La última columna enlaza con la lección de este sitio que explica la parte de Rust.

C# Rust Para saber más
class con campos y métodos struct para los datos + un bloque impl para los métodos Structs
interface trait Traits
Clase abstracta, herencia No hay herencia. Usa composición (un struct dentro de otro) + traits con métodos por defecto Traits, Patrones de diseño
List<IShape> con clases distintas Vec<Box<dyn Shape>> (un trait object) Trait objects
Métodos de extensión Tu propio trait, implementado para un tipo que no escribiste tú (como String) Traits
Genéricos con where T : IComparable<T> Genéricos con trait bounds (restricciones): T: Ord o where T: Ord Genéricos
null, Nullable<int>, tipos de referencia anulables Option<T>: o bien Some(value) o bien None Option
?? y ?. .unwrap_or(...), .map(...), .and_then(...) y ? Option
Excepciones, throw, try/catch Result<T, E> + el operador ? Result, Errores personalizados
Una excepción para un bug de verdad (como InvalidOperationException) panic! (detiene el hilo) Panic
IDisposable + using Drop, que se ejecuta automáticamente cuando el dueño sale del ámbito (RAII) Ámbito, Punteros inteligentes
Recolector de basura Ownership y borrowing, comprobados al compilar Ownership, Borrowing
record struct con #[derive(Debug, Clone, PartialEq)] Structs, Traits comunes
Expresión with en un record Sintaxis de actualización de structs: Order { total: 5, ..old } Structs
enum (solo enteros con nombre) enum donde cada variante puede llevar sus propios datos Enums
Expresiones switch, patrones is match (el compilador comprueba que cubriste todos los casos) Match, Coincidencia de patrones
Tuplas (int, string) Tuplas (i32, String) Tuplas
Parámetros ref / out &mut T, o devolver una tupla o un Option Borrowing
Lambdas, Func<T>, Action<T>, delegados Closures, descritas por los traits Fn, FnMut, FnOnce Closures, Fn, FnMut y FnOnce
event Un canal, o una lista de closures en un Box (callbacks) Canales, Fn, FnMut y FnOnce
LINQ: Where, Select, Sum, OrderBy, ToList Adaptadores de iteradores: filter, map, sum, sort_by (en un Vec), collect Iteradores, Ordenar y buscar
IEnumerable<T> y yield return impl Iterator<Item = T>, o tu propio tipo que implemente Iterator Iteradores personalizados
async Task<T> / await async fn devuelve un Future; necesitas un runtime como tokio para ejecutarlo Async/Await
Task.Run, Thread, lock std::thread::spawn o tokio::spawn; Mutex<T> envuelve los datos que protege Concurrencia, Estado compartido
Propiedades (get; set;) No hay propiedades. Usa campos pub, o métodos getter y setter Structs
string y StringBuilder &str (una vista prestada de un texto) y String (con dueño, puede crecer) Strings
Interpolación de cadenas $"Hi {name}" format!("Hi {name}") y println!("Hi {name}") Salida en Rust
var let (inmutable por defecto; escribe let mut para permitir cambios) Variables
const, readonly const, y un let normal (ya es de solo lectura) Variables
Campo static readonly static, o LazyLock para valores que se crean al arrancar Variables
namespace y directivas using Módulos (mod) y use Módulos
Ensamblado / proyecto Crate (una biblioteca o un binario) Cargo y crates
Atributos como [Serializable] Atributos como #[derive(Serialize)] y macros derive Macros, JSON con Serde
Reflexión Casi nada en tiempo de ejecución. Las macros y derive generan código al compilar Macros
unsafe y punteros Bloques unsafe y punteros crudos (raw pointers) Unsafe en Rust
Span<T>, ReadOnlySpan<char> Slices: &[T] y &str Slices
Sobrescribir ToString(), IFormattable Implementar Display (y .to_string() funciona gratis) Traits comunes
IEquatable<T>, IComparable<T>, GetHashCode PartialEq/Eq, PartialOrd/Ord, Hash (normalmente con derive) Traits comunes
Operadores de conversión implicit / explicit From/Into (y TryFrom cuando puede fallar) Conversiones
Sobrecarga de operadores (operator +) Implementar traits de std::ops, como Add Traits avanzados
Aritmética checked / unchecked checked_add, wrapping_add, saturating_add Desbordamiento de enteros
decimal No hay un tipo decimal incorporado; usa el crate rust_decimal, o guarda céntimos en un entero Tipos de datos

La biblioteca estándar cubre casi todo lo que hay en System.Collections.Generic. Cambian los nombres, no las ideas.

C# Rust Para saber más
List<T> Vec<T> Vectores
T[] (longitud fija) [T; N] cuando la longitud se conoce al compilar; si no, Vec<T> Arrays
Dictionary<K, V> HashMap<K, V> HashMap
SortedDictionary<K, V> BTreeMap<K, V> (las claves se mantienen ordenadas) HashMap
HashSet<T> HashSet<T> HashSet
SortedSet<T> BTreeSet<T> HashSet
Queue<T> VecDeque<T> (push_back / pop_front) VecDeque y BinaryHeap
Stack<T> Vec<T> con push / pop Vectores
PriorityQueue<TElement, TPriority> BinaryHeap<T> (primero el mayor; envuélvelo en Reverse para que salga primero el menor) VecDeque y BinaryHeap
LinkedList<T> LinkedList<T> existe, pero rara vez lo necesitas; VecDeque suele ser más rápido VecDeque y BinaryHeap
ConcurrentDictionary<K, V> Mutex<HashMap<K, V>>, o el crate dashmap Estado compartido

Un detalle a tener en cuenta: igual que Dictionary, un HashMap de Rust no tiene un orden fijo. Si imprimes su contenido, ordénalo antes o usa un BTreeMap.

Esta es la parte que más le gusta desde el primer momento a quien viene de C#. Cargo hace el trabajo de la CLI de dotnet, de MSBuild y de NuGet en una sola herramienta.

.NET Rust Notas
CLI de dotnet cargo Compila, ejecuta, prueba, añade paquetes y publica. Consulta Cargo y crates
.csproj Cargo.toml Corto y fácil de editar a mano
packages.lock.json Cargo.lock Se crea solo
.sln con varios proyectos Un workspace de Cargo Un Cargo.toml en la raíz enumera los crates miembros
NuGet / nuget.org crates.io La documentación de la API de cada crate publicado está en docs.rs
dotnet add package Serilog cargo add tracing
dotnet new console cargo new my_app (cargo new --lib para una biblioteca)
dotnet run / build / test cargo run / cargo build / cargo test
-c Release --release Las compilaciones debug son mucho más lentas al ejecutarse; mide el rendimiento en release
dotnet watch cargo watch o bacon (se instalan con cargo install)
dotnet tool install -g cargo install
global.json (versión del SDK) rustup y un archivo rust-toolchain.toml
Analizadores de Roslyn cargo clippy Cientos de lints útiles, oficiales
dotnet format cargo fmt Un único estilo estándar para todos
Visual Studio / Rider VS Code + rust-analyzer, o JetBrains RustRover rust-analyzer ofrece autocompletado, ir a la definición y errores en línea
Comentarios de documentación XML /// Comentarios de documentación /// en Markdown + cargo doc Los ejemplos de código de la documentación se ejecutan como tests. Consulta Documentación en Rust
xUnit / NUnit / MSTest #[test] y cargo test, incluidos de serie (cargo nextest es un ejecutor más rápido muy popular) Consulta Pruebas en Rust
Moq mockall, o implementaciones falsas escritas a mano que implementan tu trait
Coverlet (cobertura de código) cargo llvm-cov o cargo tarpaulin
BenchmarkDotNet criterion Consulta Rendimiento en Rust
dotnet-trace, profilers cargo flamegraph, perf o cualquier profiler nativo

Aquí tienes un Cargo.toml pequeño, para que lo compares 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"] }

Bibliotecas y frameworks: “¿Qué usamos en lugar de…?”

Sección titulada «Bibliotecas y frameworks: “¿Qué usamos en lugar de…?”»

Esta es la primera pregunta de cualquier equipo de .NET. “Usamos Entity Framework. ¿Qué tenemos en Rust?” Aquí tienes los equivalentes más cercanos. Detrás de Rust no hay una sola empresa que marque un “stack por defecto”, como hace Microsoft con .NET, así que a menudo eliges entre dos o tres buenos crates de la comunidad.

.NET Rust Notas
Entity Framework Core Diesel: ORM y constructor de consultas; tus consultas se comprueban por tipos al compilar. Migraciones con diesel_cli Maduro (2.x), síncrono por defecto (existe diesel-async)
Entity Framework Core (asíncrono, estilo entidades) SeaORM: ORM asíncrono con entidades, relaciones y migraciones (sea-orm-cli) Es el que más se parece a EF; está construido sobre sqlx
Dapper sqlx: escribes SQL normal, y la macro query! lo comprueba contra tu base de datos real al compilar. Migraciones con sqlx-cli Asíncrono, muy popular, no es un ORM
ADO.NET sqlx, tokio-postgres, rusqlite (SQLite) Drivers de más bajo nivel
StackExchange.Redis redis

Si te gustaba EF, prueba primero SeaORM. Si te gustaba Dapper y escribir tu propio SQL, prueba sqlx. Si quieres las garantías más fuertes al compilar, prueba Diesel. Bases de datos en Rust muestra los tres con código que funciona.

.NET Rust Notas
ASP.NET Core Web API axum (construimos una en Proyecto: API web con Axum), actix-web, Rocket axum y actix-web se usan mucho en producción
Minimal APIs axum: las rutas son simples handlers async fn El estilo es muy parecido
Pipeline de middleware Capas de tower y el crate tower-http (CORS, compresión, trazas, timeouts) axum usa tower directamente
Inyección de dependencias incorporada State en axum, constructores normales y traits Mira la fila del contenedor de DI más abajo
Swashbuckle / OpenAPI utoipa
SignalR WebSockets en axum (la feature ws), o tokio-tungstenite No trae una capa de protocolo al estilo SignalR
gRPC (Grpc.AspNetCore) tonic (con prost para protobuf)
HttpClient reqwest Asíncrono por defecto, con un cliente bloqueante opcional
Autenticación JWT bearer jsonwebtoken
.NET Rust Notas
System.Text.Json / Newtonsoft.Json serde + serde_json El estándar; también sirve para TOML, YAML y más. Consulta JSON con Serde
ILogger / Serilog tracing (+ tracing-subscriber), o log + env_logger Logging estructurado con spans. Consulta Logging en Rust
IConfiguration / appsettings.json config o figment Combina archivos y variables de entorno, y luego deserializa con serde
Contenedor de DI (IServiceCollection) Normalmente nada: pasa las dependencias a los constructores y usa traits para lo que quieras cambiar en los tests Existe shaku, pero pocos proyectos usan un contenedor
AutoMapper Escribe implementaciones de From / Into Explícito y comprobado por el compilador. Consulta Conversiones
FluentValidation / DataAnnotations validator o garde (validación basada en derive)
Polly (reintentos) backon, o el middleware de reintentos de tower
MediatR No hay un equivalente directo Traits normales, o canales entre tareas
Hangfire / tareas en segundo plano tokio::spawn para tareas dentro del proceso; apalis para colas de trabajos persistentes
CancellationToken Descarta (drop) el future, o usa CancellationToken de tokio-util
.NET Rust Notas
Blazor WebAssembly Leptos, Yew, Dioxus Todos antes de la versión 1.0; sus APIs aún cambian. Consulta Rust y WebAssembly
WPF / WinForms / MAUI Tauri (interfaz web + backend en Rust), Slint, egui, Iced Tauri 2 y Slint 1.x son los más estables. No hay un toolkit de GUI oficial
Unity Bevy, o Rust dentro de Godot con el crate godot Bevy aún no llega a la 1.0 y cambia su API entre versiones
ML.NET candle, burn, linfa Mucho más jóvenes que las herramientas de ML de Python
Azure SDK for .NET El Azure SDK for Rust oficial: azure_core, azure_identity, azure_storage_blob, … Cubre menos servicios que el SDK de .NET

Lado a lado: una clase se convierte en struct + impl

Sección titulada «Lado a lado: una clase se convierte en struct + impl»

Veamos código real. Aquí tienes una pequeña cuenta bancaria en C#:

var account = new BankAccount("Ada", 100);
account.Deposit(50);
Console.WriteLine($"{account.Owner} tiene {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;
}

Y en Rust:

struct BankAccount {
owner: String,
balance: i64,
}
impl BankAccount {
// `new` es solo una convención, no una palabra clave.
fn new(owner: &str, balance: i64) -> Self {
BankAccount { owner: owner.to_string(), balance }
}
// `&mut self`: este método modifica la cuenta.
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// `&self`: este método solo lee.
fn balance(&self) -> i64 {
self.balance
}
}
fn main() {
let mut account = BankAccount::new("Ada", 100);
account.deposit(50);
println!("{} tiene {}", account.owner, account.balance());
}

Salida:

Ada tiene 150
▶ Pruébalo tú mismo

Los datos y el comportamiento viven en dos bloques separados. No hay constructores, solo funciones que devuelven Self. Cada método dice desde el principio si lee (&self) o modifica (&mut self) el valor. Y como deposit modifica la cuenta, la variable tiene que ser let mut. En C#, cualquier método puede cambiar cualquier campo; en Rust, los cambios se ven en la firma.

Lado a lado: una interfaz se convierte en trait

Sección titulada «Lado a lado: una interfaz se convierte en trait»

En C# podrías tener una interfaz IShape con dos clases y 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;
}

En Rust, la interfaz es un trait, y cada struct lo implementa en su propio bloque 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> hace el papel de "una referencia a algún IShape".
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());
}
}

Salida:

3.14
6.00
▶ Pruébalo tú mismo

En C#, cada llamada a través de una interfaz es una llamada virtual. En Rust eliges tú: Box<dyn Shape> usa una llamada virtual (consulta Trait objects), mientras que una función genérica como fn print_area<T: Shape>(s: &T) se compila por separado para cada tipo, sin ninguna llamada virtual. Un extra muy práctico: puedes implementar tu propio trait para tipos que no escribiste tú, incluso String o i32. Así es como Rust hace lo que en C# hacen los métodos de extensión.

C# con tipos de referencia anulables te avisa sobre null, pero null sigue existiendo en tiempo de ejecución:

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("no hay email registrado");
Console.WriteLine(FindEmail(2) ?? "desconocido");

En Rust, un valor que puede faltar tiene el tipo Option<T>. No puedes usarlo como un valor normal hasta que hayas tratado el 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!("no hay email registrado"),
}
// `if let` es la forma corta cuando solo te importa un caso.
if let Some(email) = find_email(2) {
println!("encontrado: {email}");
} else {
println!("no hay email registrado");
}
// unwrap_or funciona como el operador ?? de C#.
let email = find_email(2).unwrap_or("desconocido".to_string());
println!("{email}");
}

Salida:

no hay email registrado
desconocido
▶ Pruébalo tú mismo

En Rust no existe la NullReferenceException, porque no hay ningún null que desreferenciar. El compilador simplemente no te deja llamar a .to_uppercase() sobre un Option<String>. Aprende los métodos auxiliares en la página de Option, y las formas cortas en If Let / While Let en Rust.

Lado a lado: try/catch se convierte en Result y ?

Sección titulada «Lado a lado: try/catch se convierte en Result y ?»

En C#, al convertir texto en número se puede lanzar una excepción, y la capturas en algún punto más arriba de la pila de llamadas:

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

En Rust, una función que puede fallar lo dice en su tipo de retorno. El operador ? significa “si esto falló, devuelve el error a quien me llamó, ahora mismo”. Hace el mismo trabajo que dejar que una excepción suba por la pila, pero lo ves en el código:

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", "treinta")] {
match total_seats(a, b) {
Ok(total) => println!("Total: {total}"),
Err(e) => println!("Error: {e}"),
}
}
}

Salida:

Total: 42
Error: invalid digit found in string
▶ Pruébalo tú mismo

El mensaje “invalid digit found in string” (“se encontró un dígito no válido en la cadena”) sale en inglés porque lo escribe la biblioteca estándar de Rust. La gran diferencia: en C#, cualquier método puede lanzar una excepción y la firma no te lo dice. En Rust, Result forma parte de la firma, así que quien llama no puede olvidarse de tratarlo. Para tus propios tipos de error, consulta Errores personalizados. En aplicaciones, el crate anyhow te da algo parecido a “capturar cualquier excepción” (consulta anyhow y thiserror).

Lado a lado: LINQ se convierte en una cadena de iteradores

Sección titulada «Lado a lado: LINQ se convierte en una cadena de iteradores»

Una consulta LINQ típica:

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($"Ingresos: {orders.Sum(o => o.Total)}");
record Order(string Customer, int Total);

La versión en Rust usa adaptadores de iteradores y closures:

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(...): se ordena en un Vec, no en el iterador.
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!("Ingresos: {revenue}");
}

Salida:

Linus, Ken, Ada
Ingresos: 550
▶ Pruébalo tú mismo

Los iteradores de Rust son perezosos (lazy), igual que la ejecución diferida de LINQ: no se ejecuta nada hasta que llamas a algo como collect(), sum() o usas un bucle for. collect() es tu ToList(). A diferencia de LINQ, las cadenas de iteradores normalmente se compilan al mismo código máquina que un bucle escrito a mano, así que no pagas nada por el estilo elegante. Tienes más sobre ordenación en la página Ordenar y buscar.

Lado a lado: using/IDisposable se convierte en Drop

Sección titulada «Lado a lado: using/IDisposable se convierte en Drop»

En C#, tienes que acordarte de usar using para liberar un recurso a tiempo:

using (var conn = new Connection("orders-db"))
{
Console.WriteLine($"consultando {conn.Name}");
}
Console.WriteLine("después del bloque");
class Connection : IDisposable
{
public string Name { get; }
public Connection(string name) => Name = name;
public void Dispose() => Console.WriteLine($"cerrando la conexión con {Name}");
}

En Rust, la limpieza es automática. Cuando el dueño de un valor sale del ámbito (scope), Rust llama a su implementación de Drop. No hay nada que olvidar:

struct Connection {
name: String,
}
impl Drop for Connection {
fn drop(&mut self) {
println!("cerrando la conexión con {}", self.name);
}
}
fn main() {
{
let conn = Connection { name: "orders-db".to_string() };
println!("consultando {}", conn.name);
} // `conn` sale del ámbito aquí, así que se ejecuta drop().
println!("después del bloque");
}

Salida:

consultando orders-db
cerrando la conexión con orders-db
después del bloque
▶ Pruébalo tú mismo

Esto se llama RAII, y es la forma en que Rust lo gestiona todo: archivos, sockets, locks y la propia memoria. Un archivo se cierra cuando su valor File se descarta (drop). Un Mutex se desbloquea cuando se descarta su guard. No hay finalizadores ni pausas del recolector de basura; la limpieza ocurre en un momento predecible.

Lo que te va a hacer tropezar si vienes de C#

Sección titulada «Lo que te va a hacer tropezar si vienes de C#»

Todo el que llega desde C# se encuentra con los mismos baches. Conocerlos de antemano ayuda.

  • Asignar mueve el valor, no copia una referencia. En C#, var b = a; con una clase te da dos referencias al mismo objeto. En Rust, let b = a; mueve el ownership de un String o un Vec a b, y a ya no se puede usar:

    fn main() {
    let owner = String::from("Ada");
    let copy = owner;
    println!("{owner} y {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} y {copy}");
    | ^^^^^ value borrowed here after move

    El compilador te dice que owner se movió en la línea 3 (“value moved here”) y que después intentas usarlo en la línea 4. La solución es tomarlo prestado (let copy = &owner;) o clonarlo a propósito (owner.clone()). Lee primero Ownership; ahí se explica esta regla.

  • El borrow checker (comprobador de préstamos). Puedes tener muchos lectores (&T) o un solo escritor (&mut T) a la vez, nunca las dos cosas. Patrones normales en C#, como objetos que apuntan a su padre o muchos objetos que modifican un mismo objeto compartido, necesitan punteros inteligentes o un diseño distinto. Cuenta con unas semanas frustrantes. Luego se vuelve mucho más fácil.

  • String frente a &str. C# tiene un solo tipo string. Rust tiene dos: String es dueño de su texto y &str lo toma prestado. Las funciones suelen recibir &str y los structs suelen guardar String. Consulta Strings en Rust.

  • No hay herencia. No puedes escribir una clase base con campos compartidos y métodos que se sobrescriben. Usa traits (con métodos por defecto) para el comportamiento compartido, y pon los datos compartidos en un struct que otros structs contengan.

  • No hay excepciones. Los errores son valores. Vas a escribir ? muchas veces, y tu main también puede devolver Result. Llamar a .unwrap() está bien mientras aprendes, pero es la versión de Rust de “ignora la excepción y deja que el programa se caiga”.

  • No hay null. Al principio, tener Option por todas partes parece trabajo extra. Con el tiempo te darás cuenta de que ya no tienes errores de referencias nulas.

  • Clones explícitos. Rust nunca copia un valor del heap a tus espaldas. Si ves .clone(), se hace una copia real. Es una ventaja: el coste se ve.

  • Tiempos de compilación. Una compilación desde cero de un proyecto con muchos crates tarda más que una compilación de .NET. Las compilaciones incrementales y cargo check (que se salta la generación de código) son mucho más rápidas; usa cargo check mientras editas.

  • Async necesita un runtime. En .NET, el runtime y el pool de hilos siempre están ahí. En Rust, una async fn no hace nada hasta que un ejecutor la ejecuta, así que añades tokio y pones #[tokio::main] en main. Consulta Async/Await en Rust.

Una ruta de aprendizaje para desarrolladores de C#

Sección titulada «Una ruta de aprendizaje para desarrolladores de C#»

No necesitas leer este sitio en orden. Si vienes de C#, esta ruta se salta lo que ya sabes y dedica el tiempo a lo que Rust hace distinto:

  1. Empezar con Rust: instala Rust y ejecuta cargo new.
  2. Variables y Tipos de datos: léelas por encima, sobre todo para aprender let mut y los tipos numéricos.
  3. Ownership, luego Borrowing y luego Slices: las páginas más importantes para ti. Tómate tu tiempo.
  4. Strings: String frente a &str.
  5. Structs y Enums, luego Match y Coincidencia de patrones.
  6. Option y Result, luego Errores personalizados.
  7. Traits, Genéricos, Traits comunes y Trait objects.
  8. Closures e Iteradores: tu LINQ.
  9. Vectores, HashMap y VecDeque y BinaryHeap.
  10. Módulos, Cargo y crates y Pruebas.
  11. Lifetimes y Punteros inteligentes.
  12. Concurrencia, Estado compartido y Async/Await.
  13. JSON con Serde y Logging, y después construye el proyecto de API web.

Los dos ejercicios te dan un fragmento de C#. Pásalo a Rust.

1. Pasa a Rust un record y una consulta LINQ

Sección titulada «1. Pasa a Rust un record y una consulta LINQ»

Pasa a Rust este código de C#. Debe imprimir los nombres de los productos que hay en stock y cuestan menos de 20. Pista: crea un struct con #[derive(Debug, Clone, PartialEq)] y luego usa filter, map y collect para obtener un Vec<&str>. Vec<&str> tiene un método .join(", ").

var products = new List<Product>
{
new("Cuaderno", 5, true),
new("Auriculares", 45, true),
new("Bolígrafo", 2, false),
new("Taza", 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);
Ver solución
#[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("Cuaderno", 5, true),
product("Auriculares", 45, true),
product("Bolígrafo", 2, false),
product("Taza", 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(", "));
}

Salida:

Cuaderno, Taza
▶ Pruébalo tú mismo

Una pequeña función auxiliar hace el papel del constructor posicional del record. Los derives te dan lo que un record de C# te da gratis: imprimir con {:?}, copiar con .clone() y comparar con ==.

2. Pasa a Rust un Dictionary con excepciones

Sección titulada «2. Pasa a Rust un Dictionary con excepciones»

Este código de C# saca una unidad de un artículo de un diccionario de stock y lanza una excepción cuando algo va mal. Pásalo a Rust de forma que take devuelva un Result<u32, String> en lugar de lanzar excepciones. Pista: stock.get_mut(item) devuelve un Option<&mut u32>. Conviértelo en un Result con .ok_or(...) y luego usa ?.

var stock = new Dictionary<string, int> { ["manzanas"] = 3, ["peras"] = 0 };
int Take(string item)
{
if (!stock.TryGetValue(item, out var count))
throw new KeyNotFoundException($"no hay ningún artículo llamado {item}");
if (count == 0)
throw new InvalidOperationException($"no quedan {item}");
stock[item] = count - 1;
return count - 1;
}
foreach (var item in new[] { "manzanas", "peras", "kiwis" })
{
try { Console.WriteLine($"se sacó una unidad de {item}, quedan {Take(item)}"); }
catch (Exception e) { Console.WriteLine($"error: {e.Message}"); }
}
Ver solución
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!("no hay ningún artículo llamado {item}"))?;
if *count == 0 {
return Err(format!("no quedan {item}"));
}
*count -= 1;
Ok(*count)
}
fn main() {
let mut stock = HashMap::new();
stock.insert("manzanas".to_string(), 3);
stock.insert("peras".to_string(), 0);
for item in ["manzanas", "peras", "kiwis"] {
match take(&mut stock, item) {
Ok(left) => println!("se sacó una unidad de {item}, quedan {left}"),
Err(e) => println!("error: {e}"),
}
}
}

Salida:

se sacó una unidad de manzanas, quedan 2
error: no quedan peras
error: no hay ningún artículo llamado kiwis
▶ Pruébalo tú mismo

TryGetValue con un parámetro out se convierte en get_mut, que devuelve un Option. Los dos throw se convierten en valores Err, y el try/catch se convierte en un match. El &mut stock de la llamada deja claro que take modifica el diccionario.

  • Mucho de C# te sirve en Rust: los genéricos, async/await, las lambdas, la coincidencia de patrones y los iteradores al estilo LINQ tienen equivalentes muy parecidos.
  • Los grandes cambios son el ownership en lugar de un recolector de basura, Option en lugar de null, y Result + ? en lugar de excepciones.
  • Las clases se convierten en un struct + impl, las interfaces en traits y la herencia en composición más traits. using/IDisposable se convierte en el Drop automático.
  • Cargo sustituye a la CLI de dotnet, a MSBuild y a NuGet; clippy y rustfmt sustituyen a los analizadores y a dotnet format; los tests vienen incluidos.
  • En lugar de EF Core, mira SeaORM o Diesel; en lugar de Dapper, sqlx; y en lugar de ASP.NET Core, axum o actix-web. Las opciones para GUI, juegos y ML son más jóvenes.

Comprueba lo aprendido

1. ¿Qué usa Rust en lugar de null?

2. Tu equipo usa Dapper y le gusta escribir el SQL a mano. ¿Qué crate de Rust se le parece más?

3. ¿Cuál es el equivalente más cercano en Rust del bloque using de C# con IDisposable?

4. En C#, var b = a; con una clase copia una referencia. ¿Qué hace let b = a; con un String en Rust?

Puntuación: 0 / 4