Ir al contenido

Rust para desarrolladores de Go: guía práctica

Si escribes Go, en Rust vas a encontrar más cosas conocidas de las que esperas. Los dos lenguajes se compilan a un único binario rápido. Los dos tienen structs con métodos en lugar de clases, no tienen herencia, devuelven los errores como valores en lugar de usar excepciones, tienen un formateador estándar que usa todo el mundo y una sola herramienta de línea de comandos que compila, prueba y descarga paquetes. Un struct de Go con unos cuantos métodos se traduce a Rust casi línea por línea.

Lo nuevo tiene que ver sobre todo con la memoria y con ser explícito. Rust no tiene recolector de basura (garbage collector): 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. No existe nil (usas Option), y if err != nil se convierte en Result y el operador ?. Las interfaces siguen existiendo (se llaman traits), pero un tipo tiene que decir en voz alta que implementa una. Cuenta con dedicar la mayor parte de tu tiempo de aprendizaje al ownership. El resto te parecerá un Go más estricto y más detallado.

¿Por qué aprenden Rust los desarrolladores de Go? Normalmente por uno de estos motivos: un servicio en el que las pausas del recolector de basura o el consumo de memoria empiezan a importar, trabajo que exige mucha CPU, código que se ejecuta sin sistema operativo (embebido) o en el navegador (WebAssembly), o simplemente que el compilador detecte las condiciones de carrera (data races) y los fallos al estilo de nil antes de llegar a producción. Si buscas una comparación más general de Rust con Go, C++ y Python, consulta Rust vs C++, Go y Python. Esta página va más a fondo y está escrita para ti, que vienes de Go.

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.

Go Rust Para saber más
Recolector de basura Ownership y borrowing, comprobados al compilar; la memoria se libera cuando el dueño sale del ámbito Ownership, Borrowing
struct + métodos struct para los datos + un bloque impl para los métodos Structs
Receptor por valor func (a Account) &self (lee, sin copiar) o self (toma el ownership) Structs
Receptor puntero func (a *Account) &mut self (puede modificar el valor) Structs
Función constructora NewAccount(...) Una función asociada, por convención Account::new(...) Structs
Mayúscula inicial = exportado La palabra clave pub Módulos
Valores cero (var c Config) No hay valores cero implícitos; hay que dar valor a todos los campos. #[derive(Default)] + Config::default() Structs, Traits comunes
Embedding de structs No hay embedding. Pon el struct en un campo y escribe pequeños métodos que reenvíen las llamadas, o comparte comportamiento con traits Traits, Patrones de diseño
interface (se cumple de forma implícita) trait (se implementa de forma explícita con impl Trait for Type) Traits
[]Shape con tipos distintos Vec<Box<dyn Shape>> (un trait object) Trait objects
any / interface{} + type switch Un enum con una variante por caso, y luego match Enums, Match
Genéricos func Max[T cmp.Ordered](a, b T) T Genéricos con trait bounds (restricciones): fn max<T: PartialOrd>(a: T, b: T) -> T Genéricos, Genéricos avanzados
Puntero nil, map nil, interfaz nil Option<T>: o bien Some(value) o bien None Option
v, ok := m[k] m.get(&k) devuelve un Option HashMap
(T, error) + if err != nil { return err } Result<T, E> + el operador ? Result
errors.New, errores centinela, errors.Is / errors.As Un enum de error sobre el que haces match (el crate thiserror ayuda) Errores personalizados, anyhow y thiserror
fmt.Errorf("...: %w", err) anyhow con .context("..."), o map_err anyhow y thiserror
panic / recover panic! para bugs de verdad; recuperarse es poco común (std::panic::catch_unwind) Panic
defer Drop, que se ejecuta cuando un valor sale del ámbito (RAII) Ámbito, Punteros inteligentes
go f() std::thread::spawn(...), o tokio::spawn(...) para tareas async Concurrencia, Async/Await
chan T, make(chan T, n) std::sync::mpsc::channel() (sin límite) o sync_channel(n); tokio::sync::mpsc en código async Canales
select tokio::select! en código async, o el select! de crossbeam-channel Async a fondo
sync.Mutex junto a los datos Mutex<T> envuelve los datos; se desbloquea solo cuando se descarta el guard Estado compartido
sync.WaitGroup JoinHandle::join(), o std::thread::scope Concurrencia
sync.Once std::sync::OnceLock / LazyLock Trucos de la biblioteca estándar
context.Context No hay un único equivalente: descarta (drop) un future para cancelarlo, tokio::time::timeout, o CancellationToken de tokio-util Async a fondo
Valores func y closures Closures, descritas por los traits Fn, FnMut, FnOnce Closures, Fn, FnMut y FnOnce
for i, v := range s for (i, v) in s.iter().enumerate() For en Rust, Iteradores
El único bucle for loop, while y for Bucles
Iteradores range-over-func (iter.Seq) El trait Iterator, con adaptadores como filter, map, sum, collect Iteradores, Iteradores personalizados
switch match (el compilador comprueba que cubriste todos los casos) Match, Coincidencia de patrones
const + iota Un enum (opcionalmente con números explícitos) Enums
:= let (inmutable por defecto; let mut para permitir cambios) Variables
string (bytes, normalmente UTF-8) String (con dueño) y &str (prestado), siempre UTF-8 válido Strings
rune, for _, r := range s char, for c in s.chars() Strings
[]byte Vec<u8> o &[u8] Slices
fmt.Println, %v, %+v println!, {} (Display), {:?} y {:#?} (Debug, con #[derive(Debug)]) Salida en Rust
String() string (la interfaz Stringer) Implementar Display (y .to_string() funciona gratis) Traits comunes
Etiquetas de structs `json:"user_name"` Atributos: #[serde(rename = "user_name")] JSON con Serde
Conversiones int(x), float64(n) as para números, From/Into, y TryFrom cuando puede fallar Conversión de tipos, Conversiones
El desbordamiento de enteros da la vuelta sin avisar Panic en las compilaciones debug, da la vuelta en release; checked_add, wrapping_add, saturating_add dicen lo que quieres Desbordamiento de enteros
*T y &x Referencias &T y &mut T (nunca nulas, no pueden vivir más que el valor); Box<T> para un valor con dueño en el heap Borrowing, Punteros inteligentes
Paquetes Módulos (mod) dentro de un crate; use en lugar de import Módulos
go generate Macros y scripts de compilación build.rs Macros, Macros procedurales
Paquete unsafe Bloques unsafe y punteros crudos (raw pointers) Unsafe en Rust
cgo extern "C" y FFI (bindgen genera los bindings a partir de las cabeceras de C) FFI: llamar a C
Etiquetas de compilación //go:build linux #[cfg(target_os = "linux")] y features de Cargo Features de Cargo y cfg

Go trae los slices y los maps integrados en el lenguaje. Rust los tiene en la biblioteca estándar, junto con algunos más.

Go Rust Para saber más
[]T del que eres dueño y al que añades elementos Vec<T> Vectores
[]T que pasas a una función que solo lo lee &[T] (un slice prestado de un Vec o de un array) Slices
[N]T [T; N] Arrays
append(s, x) / append(s, other...) v.push(x) / v.extend(other) Vectores
s[a:b] &v[a..b] Slices
len(s), cap(s), make([]T, 0, n) v.len(), v.capacity(), Vec::with_capacity(n) Vectores
map[K]V HashMap<K, V> HashMap
map[K]struct{} o map[K]bool como conjunto HashSet<T> HashSet
Recoger y ordenar las claves de un map antes de imprimir BTreeMap<K, V> (las claves se mantienen ordenadas) HashMap
container/list VecDeque<T> (push_back / pop_front) VecDeque y BinaryHeap
container/heap (implementar 5 métodos) BinaryHeap<T>: solo push y pop; primero el mayor, envuelve en Reverse para que salga primero el menor VecDeque y BinaryHeap
slices.Sort, sort.Slice v.sort(), v.sort_by(...), v.sort_by_key(...) Ordenar y buscar
slices.Contains, slices.Index v.contains(&x), v.iter().position(...) Vectores
strings.Builder Un String con push_str, o write! Strings
sync.Map Mutex<HashMap<K, V>>, RwLock o el crate dashmap Estado compartido

Dos cosas que conviene saber. Primero, igual que los maps de Go, un HashMap de Rust no tiene un orden fijo, así que ordena antes de imprimir o usa un BTreeMap. Segundo, la famosa sorpresa de los slices de Go (dos slices que comparten el mismo array de fondo, y un append que cambia el otro) no puede ocurrir en Rust: mientras una parte de tu código tiene un préstamo &mut de un Vec, nada más puede mirarlo.

Buenas noticias: estás acostumbrado a una herramienta que lo hace todo, y Rust también tiene una. Cargo cubre go build, go run, go test, go mod y go install.

Go Rust Notas
El comando go cargo Compila, ejecuta, prueba, añade paquetes y publica. Consulta Cargo y crates
go mod init cargo new my_app (cargo new --lib para una biblioteca) Crea la carpeta, Cargo.toml, src/main.rs y un repositorio git
go.mod Cargo.toml
go.sum Cargo.lock Se crea y se actualiza solo
go get github.com/gin-gonic/gin cargo add axum
go get -u cargo update Actualiza dentro de los rangos de versiones de Cargo.toml
pkg.go.dev crates.io para los paquetes, docs.rs para su documentación de la API
go build / go run / go test cargo build / cargo run / cargo test Añade --release para una compilación optimizada; las compilaciones debug son mucho más lentas al ejecutarse
go vet, staticcheck, golangci-lint cargo clippy Oficial, con cientos de lints útiles
gofmt / goimports cargo fmt (rustfmt) Un único estilo estándar, igual que gofmt
go doc cargo doc --open Los ejemplos de código de los comentarios de documentación se ejecutan como tests. Consulta Documentación en Rust
go test -bench criterion (se ejecuta con cargo bench) Consulta Rendimiento en Rust
go test -cover cargo llvm-cov o cargo tarpaulin
go test -race No hace falta en código seguro: las condiciones de carrera son errores de compilación. Miri revisa el código unsafe
go test -fuzz cargo fuzz Se instala con cargo install cargo-fuzz
testify assert! y assert_eq! incorporados; pretty_assertions para diferencias más claras Consulta Pruebas en Rust y Pruebas avanzadas
gomock / mockery mockall, o implementaciones falsas escritas a mano que implementan tu trait
go install pkg@latest cargo install name
go work (workspaces) Un workspace de Cargo Un Cargo.toml en la raíz enumera los crates miembros
Línea toolchain en go.mod rustup y un archivo rust-toolchain.toml Consulta Herramientas de Rust
GOOS=linux GOARCH=arm64 go build rustup target add aarch64-unknown-linux-gnu + cargo build --target ... A menudo necesita un enlazador para el destino; cross o cargo-zigbuild lo facilitan
govulncheck cargo audit (avisos de RustSec), cargo deny
gopls rust-analyzer Funciona en VS Code, Neovim, Helix, Zed y más
GoLand JetBrains RustRover
Delve rust-gdb / rust-lldb, o CodeLLDB en VS Code
pprof cargo flamegraph, perf o cualquier profiler nativo
air (recarga en vivo) bacon o cargo watch

Un Cargo.toml pequeño junto al go.mod que ya conoces:

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 diferencia que vas a notar enseguida: la biblioteca estándar de Go incluye un servidor HTTP, JSON, plantillas y criptografía. La biblioteca estándar de Rust es más pequeña a propósito, así que cosas como JSON, HTTP, async y los números aleatorios vienen de crates. Añadir uno es un simple cargo add, y un puñado de crates (serde, tokio, clap, regex, rand) los usa casi todo el mundo.

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

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

Esta es la primera pregunta práctica. “Usamos Gin y GORM. ¿Cuál es la versión de Rust?” Aquí tienes los equivalentes más cercanos. Igual que en Go, en Rust suele haber dos o tres buenas opciones de la comunidad en lugar de una única respuesta oficial.

Go Rust Notas
Gin, Echo, Chi, Fiber axum (construimos una API en Proyecto: API web con Axum), actix-web, Rocket axum y actix-web se usan mucho en producción
Servidor de net/http hyper (de bajo nivel), que normalmente se usa a través de axum No hay servidor HTTP en la biblioteca estándar
Middleware http.Handler Capas de tower y tower-http (CORS, compresión, trazas, timeouts) axum usa tower directamente
Cliente de net/http reqwest Asíncrono por defecto, con un cliente bloqueante opcional. Consulta Redes en Rust
grpc-go tonic (con prost para protobuf)
gorilla/websocket tokio-tungstenite, o la feature ws de axum
html/template, text/template askama (comprobado al compilar), tera, minijinja
net (TCP/UDP) std::net, o tokio::net para async Consulta Redes en Rust
errgroup tokio::task::JoinSet
Go Rust Notas
encoding/json + etiquetas de structs serde + serde_json También sirve para TOML (crate toml) y otros formatos. Consulta JSON con Serde
database/sql + pgx sqlx (asíncrono, SQL normal comprobado contra tu base de datos al compilar), tokio-postgres No tiene nada que ver con jmoiron/sqlx de Go, aunque se llamen igual
sqlc Las macros query! de sqlx Los dos parten del SQL que escribes y te dan resultados con tipos
GORM SeaORM (asíncrono, estilo entidades), Diesel (constructor de consultas comprobado por tipos) Consulta Bases de datos
go-sqlite3 rusqlite
go-redis redis

CLI, configuración, logging y herramientas del día a día

Sección titulada «CLI, configuración, logging y herramientas del día a día»
Go Rust Notas
cobra, urfave/cli, flag clap (deriva tus argumentos de un struct) Consulta Argumentos de línea de comandos y Proyecto: CLI con clap
viper config o figment Combina archivos y variables de entorno, y luego deserializa con serde
log/slog, zap, zerolog tracing + tracing-subscriber, o log + env_logger Logging estructurado. Consulta Logging en Rust
regexp regex La misma idea que RE2 de Go: búsqueda en tiempo lineal, sin referencias hacia atrás. Consulta Expresiones regulares
time std::time para duraciones; chrono o jiff para fechas y zonas horarias Consulta Fechas y horas
embed include_str! / include_bytes!, o rust-embed para carpetas enteras
bubbletea ratatui Interfaces de terminal
client_golang de Prometheus prometheus o metrics
sync/atomic std::sync::atomic
Go Rust Notas
Wails Tauri (interfaz web + backend en Rust) Una idea muy parecida. Consulta Apps con interfaz gráfica
Fyne egui, Iced, Slint No hay un toolkit de GUI oficial
Ebitengine macroquad, Bevy Bevy aún no llega a la 1.0 y cambia su API entre versiones. Consulta Desarrollo de videojuegos
TinyGo embedded-hal, Embassy Rust funciona sin sistema operativo y sin heap. Consulta Rust embebido
GOOS=js GOARCH=wasm wasm-bindgen, Leptos, Yew, Dioxus Binarios pequeños, sin GC que distribuir. Consulta Rust y WebAssembly

Lado a lado: structs y métodos (casi iguales)

Sección titulada «Lado a lado: structs y métodos (casi iguales)»

Aquí es donde los desarrolladores de Go se sienten como en casa. Este es un pequeño tipo de cuenta en Go:

type Account struct {
Owner string // exportado: mayúscula inicial
balance int // no exportado
}
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())
}

Y en Rust:

use std::fmt;
// `pub` hace el trabajo de la mayúscula inicial de Go.
pub struct Account {
pub owner: String,
balance: i64, // privado, como un campo en minúscula en Go
}
impl Account {
// Como el NewAccount de Go: una función normal. `new` es solo una convención.
pub fn new(owner: &str, balance: i64) -> Self {
Account { owner: owner.to_string(), balance }
}
// Como un receptor puntero: este método modifica la cuenta.
pub fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
// Solo lee. No se hace ninguna copia, a diferencia de un receptor por valor de Go.
pub fn balance(&self) -> i64 {
self.balance
}
}
// El método String() de Go (la interfaz Stringer) se convierte en 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!("el saldo es {}", account.balance());
}

Salida:

Ada: 150
el saldo es 150
▶ Pruébalo tú mismo

La forma es la misma: los datos en un struct, los métodos asociados a él y un constructor que no es más que una función. Las diferencias son pequeñas pero importantes. Los métodos viven juntos en un bloque impl. El receptor dice exactamente qué hace el método: &self lee, &mut self modifica, y un self sin más toma el valor y lo consume. Y como deposit modifica la cuenta, la variable tiene que declararse con let mut. En Go, cualquiera que tenga un *Account puede modificarlo; en Rust, el permiso para cambiar algo se ve en el código.

Lado a lado: las interfaces implícitas se convierten en traits explícitos

Sección titulada «Lado a lado: las interfaces implícitas se convierten en traits explícitos»

En Go, un tipo cumple una interfaz con solo tener los métodos adecuados:

type Shape interface {
Area() float64
Name() string
}
type Circle struct{ R float64 }
type Rect struct{ W, H float64 }
// No hay ningún "implements": basta con tener los métodos.
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)
}

En Rust, la relación se escribe 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,
}
// Esta línea es la parte que Go no escribe.
impl Shape for Circle {
fn area(&self) -> f64 {
PI * self.r * self.r
}
fn name(&self) -> &str {
"círculo"
}
}
impl Shape for Rect {
fn area(&self) -> f64 {
self.w * self.h
}
fn name(&self) -> &str {
"rectángulo"
}
}
// Genérica, como el describe[T Shape](s T) de Go.
fn describe<T: Shape>(s: &T) {
println!("{}: {:.2}", s.name(), s.area());
}
fn main() {
describe(&Circle { r: 1.0 });
// Como el []Shape de Go: tipos distintos detrás de un mismo 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!("área total: {total:.2}");
}

Salida:

círculo: 3.14
área total: 9.14
▶ Pruébalo tú mismo

Ser explícito cuesta una línea por tipo, y a cambio te da cosas que Go no puede hacer. Un trait puede tener métodos por defecto, así que los tipos reciben comportamiento compartido gratis. Puedes implementar tu propio trait para tipos que no escribiste tú, incluso i32 o String (Go solo te deja añadir métodos a tipos de tu propio paquete). Y la función genérica describe se compila una vez por cada tipo concreto, así que la llamada no necesita ninguna búsqueda en tiempo de ejecución. Box<dyn Shape> es el estilo de las interfaces de Go: un valor que apunta a “algún tipo que implementa Shape”, y el método se busca al ejecutar. Consulta Traits y Trait objects.

En Go, un puntero que puede faltar es nil, y tienes que acordarte de comprobarlo:

type User struct {
Name string
Manager *User // nil significa "sin jefe"
}
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)
}
}

En Rust, “puede faltar” forma parte del tipo. Option<&User> es o bien Some(user) o bien None, y el compilador no te deja usar el usuario hasta que hayas tratado el None:

struct User {
name: String,
manager: Option<String>, // Go: Manager *User, que puede ser 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("nadie");
println!("{name} depende de {boss}");
}
None => println!("{name}: no existe ese usuario"),
}
}
}

Salida:

Bob depende de Ada
Ada depende de nadie
Eve: no existe ese usuario
▶ Pruébalo tú mismo

La misma idea sustituye las búsquedas “comma ok” de Go. if email, ok := emails[name]; ok { ... } se convierte en if let Some(email) = emails.get(name) { ... }. Un detalle a tener en cuenta: en Go, emails["missing"] te da sin avisar el valor cero "". En Rust, emails["missing"] provoca un panic, así que usa .get() siempre que la clave pueda no estar. El 'a de find_user es un lifetime: le dice al compilador que el &User devuelto apunta dentro de users. Pronto vas a conocerlos.

Lado a lado: if err != nil se convierte en Result y ?

Sección titulada «Lado a lado: if err != nil se convierte en Result y ?»

El manejo de errores de Go es famoso por su patrón de tres líneas:

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 conserva la idea (los errores son valores que devuelves), pero reduce el patrón a un solo carácter, ?:

fn parse_port(s: &str) -> Result<u16, String> {
// Un u16 no puede guardar 70000, así que parse() detecta el "fuera de rango" por nosotros.
let port: u16 = s.parse().map_err(|e| format!("puerto no válido {s:?}: {e}"))?;
if port == 0 {
return Err("el puerto 0 está reservado".to_string());
}
Ok(port)
}
fn address(host: &str, port: &str) -> Result<String, String> {
// `?` significa: si esto es un error, devuélvelo ahora mismo.
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!("escuchando en {addr}"),
Err(e) => println!("error: {e}"),
}
}
}

Salida:

escuchando en localhost:8080
error: puerto no válido "abc": invalid digit found in string
error: puerto no válido "70000": number too large to fit in target type
error: el puerto 0 está reservado
▶ Pruébalo tú mismo

Los textos “invalid digit found in string” (“se encontró un dígito no válido”) y “number too large to fit in target type” (“el número es demasiado grande para el tipo de destino”) salen en inglés porque los escribe la biblioteca estándar de Rust. Hay una diferencia más profunda que ahorrarse líneas. En Go, n, err := strconv.Atoi(s) te da los dos valores, y nada te impide usar n cuando err no es nil. Un Result es o bien el valor o bien el error, así que no puedes llegar al valor sin tratar antes el error. Y si ignoras un Result por completo, el compilador te avisa.

En proyectos reales, los errores de tipo String se sustituyen por algo más completo. Un enum de error (a menudo escrito con el crate thiserror) hace el papel de los errores centinela y de errors.Is: quien llama hace match sobre la variante. El crate anyhow te da .context("loading config")?, que es el fmt.Errorf("loading config: %w", err) de Rust. Consulta Errores personalizados y anyhow y thiserror.

Lado a lado: las goroutines y los canales se convierten en hilos, mpsc y tareas

Sección titulada «Lado a lado: las goroutines y los canales se convierten en hilos, mpsc y tareas»

Aquí tienes el clásico fan-in de Go: lanzar unas goroutines, enviar los resultados por un canal y cerrarlo cuando terminan:

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 versión de Rust con hilos y un canal de la biblioteca estándar es un poco más 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(); // cada hilo recibe su propio emisor
thread::spawn(move || {
tx.send(n * n).unwrap();
});
}
// Descarta nuestro propio emisor. Cuando no queda ningún emisor, el canal
// se cierra y el bucle de abajo termina: no hace falta un WaitGroup.
drop(tx);
let total: i32 = rx.iter().sum();
println!("total: {total}");
}

Salida:

total: 14
▶ Pruébalo tú mismo

Hay varias cosas que funcionan distinto. mpsc significa “multi-producer, single-consumer” (varios productores, un solo consumidor): puedes clonar el emisor, pero no el receptor (para canales al estilo de Go con muchos lectores, usa crossbeam-channel). mpsc::channel() no tiene límite, algo que Go no tiene; mpsc::sync_channel(n) es el make(chan T, n) de Go, y sync_channel(0) es un canal sin buffer. Nadie llama a close: el canal se cierra cuando se descarta el último emisor. move le da a cada hilo su propia copia de n y su propio tx, y así es como Rust se asegura de que los hilos nunca compartan datos de forma insegura. Consulta Canales y Concurrencia.

thread::spawn arranca un hilo real del sistema operativo, que es más pesado que una goroutine. Para un puñado de workers no pasa nada. Para miles de conexiones simultáneas, Rust usa tareas async sobre un runtime como tokio, que se parece mucho más a las goroutines:

#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in [1, 2, 3] {
// Como `go func() { ... }()`, pero recibes un handle.
handles.push(tokio::spawn(async move { n * n }));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("total: {total}");
}

Salida:

total: 14
▶ Pruébalo tú mismo

Siendo sinceros: este es un punto en el que Go es más sencillo. En Go, cualquier función puede bloquearse y el runtime se encarga. En Rust, marcas las funciones como async, las llamas con .await y eliges un runtime. La recompensa es que el compilador se niega a compilar código en el que dos hilos podrían modificar los mismos datos a la vez, así que go test -race no tendría nada que encontrar. Consulta Async/Await, Async a fondo y Estado compartido para Arc<Mutex<T>>, la versión de Rust de un struct con un sync.Mutex junto a sus datos.

En Go, programas la limpieza con defer, y las llamadas diferidas se ejecutan en orden inverso cuando la función termina:

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")
}

En Rust, la limpieza pertenece al propio valor. Cuando un valor sale del ámbito (scope), se ejecuta su implementación de Drop, y los valores se descartan en orden inverso, igual que tus defer:

struct Guard(&'static str);
impl Drop for Guard {
fn drop(&mut self) {
println!("{}", self.0);
}
}
fn process() {
println!("abrir archivo");
let _file = Guard("cerrar archivo");
println!("bloquear mutex");
let _lock = Guard("desbloquear mutex");
println!("trabajando");
} // aquí se descarta _lock, y luego _file
fn main() {
process();
println!("listo");
}

Salida:

abrir archivo
bloquear mutex
trabajando
desbloquear mutex
cerrar archivo
listo
▶ Pruébalo tú mismo

En código real casi nunca escribes Drop tú mismo: un File se cierra solo, un MutexGuard se desbloquea solo, un TcpStream se desconecta solo. No hay nada que olvidar. Hay dos diferencias con defer que conviene conocer. Drop se ejecuta al final del bloque, no de la función, así que la trampa de Go de los defer que se acumulan dentro de un bucle no existe: cada vuelta del bucle limpia lo suyo. Y cuidado con let _ = Guard(...): el _ a secas no guarda el valor, así que se descarta inmediatamente. Usa un nombre como _lock.

Lo que te va a hacer tropezar si vienes de Go

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

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

  • Pasar un valor puede moverlo. En Go, pasar un slice a una función copia una pequeña cabecera y los dos lados lo siguen usando. En Rust, pasar un Vec lo mueve a la función, y ya no puedes usarlo después:

    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

    El compilador te dice que prices se movió en la línea 7 (“value moved here”) y que después intentas usarlo en la línea 8. La solución es el hábito de Go en versión Rust: haz que la función tome prestado un slice, fn total(prices: &[u32]), y llámala con total(&prices). Lee primero Ownership; ahí se explica esta regla. E0382 explica este error en detalle.

  • 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. El código de Go en el que muchos structs guardan punteros a un mismo struct compartido y lo modifican necesita en Rust un diseño distinto, o punteros inteligentes como Rc<RefCell<T>> o Arc<Mutex<T>>. Cuenta con unas semanas frustrantes. Luego se vuelve mucho más fácil.

  • No hay valores cero. let x: i32; seguido de println!("{x}") es un error de compilación, y al crear un struct hay que dar valor a todos sus campos. Si quieres valores por defecto al estilo de Go, deriva Default y escribe Config { port: 8080, ..Default::default() }.

  • Implementar un trait es explícito. Tener un método area no convierte tu tipo en un Shape. Necesitas impl Shape for Circle. La ventaja: puedes encontrar cada implementación con una simple búsqueda.

  • String frente a &str. Go 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. Y no puedes indexar un string con s[0] como en Go; usa s.chars() o s.as_bytes()[0]. Consulta Strings en Rust.

  • Lo que no se usa da avisos, no errores. Go se niega a compilar si hay un import o una variable sin usar. Rust solo avisa, y puedes poner _ delante de un nombre para decir “sin usar a propósito”.

  • Desbordamiento de enteros. Go da la vuelta sin avisar. Rust entra en pánico (panic) en las compilaciones debug y da la vuelta en las de release, así que elige checked_add, wrapping_add o saturating_add cuando pueda haber desbordamiento. Los literales enteros son i32 por defecto, y los índices son usize. Consulta Desbordamiento de enteros.

  • Una biblioteca estándar más pequeña. No hay servidor HTTP, ni JSON, ni runtime async en std. Los añades con cargo add. Al principio resulta raro, pero los crates populares son estables y están bien documentados.

  • Async necesita un runtime y se contagia. Una async fn no hace nada hasta que se espera con .await dentro de un runtime como tokio, y solo el código async puede usar .await. Go no tiene esa división. Empieza con hilos normales; recurre a async cuando necesites muchas conexiones simultáneas.

  • Tiempos de compilación. Go se diseñó para compilar muy rápido. Rust compila más despacio, sobre todo en compilaciones desde cero con muchos crates. Usa cargo check mientras editas; se salta la generación de código y es mucho más rápido.

  • Compilar para otra plataforma requiere algo de preparación. GOOS=linux go build funciona sin más. En Rust, añades el destino con rustup y a menudo también necesitas un enlazador para él. Herramientas como cross se encargan de eso por ti.

Los dos son buenas opciones, y muchos equipos usan ambos. Aquí tienes una regla práctica y justa.

Elige Go cuando quieras sacar rápido servicios de red y herramientas internas, tu equipo valore un código que cualquiera pueda leer después de una semana, las compilaciones rápidas importen mucho, o trabajes en el mundo cloud-native (Kubernetes, Docker, Terraform), donde Go es el lenguaje común. La biblioteca estándar de Go y su concurrencia sencilla hacen que muchos programas de servidor sean fáciles de escribir.

Elige Rust cuando necesites una latencia predecible sin pausas del recolector de basura, poco consumo de memoria o hasta el último ciclo de CPU (analizadores, bases de datos, motores de juegos, vídeo, compresión). Rust también encaja mejor en código sin sistema operativo, en WebAssembly y en bibliotecas que quieres llamar desde otros lenguajes. Y merece la pena cuando un fallo por una condición de carrera o por un nil sin comprobar sale caro. Discord escribió un artículo muy conocido sobre cómo pasó un servicio de Go a Rust (en inglés) por los picos de latencia que causaba el recolector de basura.

El coste de Rust es el tiempo de aprendizaje y unas compilaciones más lentas. El coste de Go es un recolector de basura y unos bugs que aparecen al ejecutar en lugar de al compilar. Qué coste duele más depende de tu proyecto.

Una ruta de aprendizaje para desarrolladores de Go

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

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

  1. Empezar con Rust y Herramientas de Rust: instala Rust, configura rust-analyzer 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. Los enums de Rust con datos sustituyen a muchos type switches de Go.
  6. Option y Result, luego Errores personalizados y anyhow y thiserror.
  7. Traits, Genéricos, Traits comunes y Trait objects.
  8. Closures e Iteradores: Rust se apoya en las cadenas de iteradores mucho más que Go.
  9. Vectores, HashMap y HashSet.
  10. Módulos, Cargo y crates y Pruebas.
  11. Lifetimes y Punteros inteligentes.
  12. Concurrencia, Canales, Estado compartido y luego 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 Go. Pásalo a Rust.

Este programa de Go cuenta palabras y las imprime en orden alfabético. Pásalo a Rust. Pista: usa un HashMap<&str, u32> y *counts.entry(word).or_insert(0) += 1 en lugar del counts[w]++ de Go. text.split_whitespace() es strings.Fields. El texto se queda en inglés porque el orden alfabético de la salida depende de las letras.

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])
}
}
Ver solución
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]);
}
}

Salida:

and: 2
bird: 1
cat: 1
dog: 1
the: 3
▶ Pruébalo tú mismo

Aquí no hay un valor cero en el que apoyarse, así que entry(word).or_insert(0) dice “empieza en 0 si la palabra es nueva”. Indexar con counts[key] es seguro porque todas las claves salieron del propio mapa. Y aún más corto: usa un BTreeMap en lugar de un HashMap, y las claves saldrán ordenadas, así que puedes quitar el vector keys.

Este código de Go devuelve un error centinela y lo comprueba con errors.Is. Pásalo a Rust de forma que withdraw devuelva un Result<(), WithdrawError>, donde WithdrawError es un enum con dos variantes. Pista: implementa fmt::Display para el enum para poder imprimirlo, y haz match sobre la variante donde 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)
}
}
Ver solución
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, "cantidad no válida {n}"),
WithdrawError::InsufficientFunds => write!(f, "fondos insuficientes"),
}
}
}
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!("rechazado: {e}"),
Err(e) => println!("error: {e}"),
}
}
}

Salida:

ok, saldo 70
error: cantidad no válida 0
rechazado: fondos insuficientes
▶ Pruébalo tú mismo

El error centinela y el error de fmt.Errorf se convierten en dos variantes de un mismo enum, y errors.Is se convierte en un brazo del match. e @ WithdrawError::InsufficientFunds comprueba la variante y además te da el valor completo como e para imprimirlo. Un extra muy práctico: si más adelante añades una tercera variante, el compilador te señala cada match que hay que actualizar. El crate thiserror puede escribir la implementación de Display por ti.

  • Mucho de Go te sirve en Rust: los structs con métodos, la ausencia de herencia, los errores como valores, un único formateador estándar y una sola herramienta para compilar y probar.
  • Los grandes cambios son el ownership en lugar de un recolector de basura, Option en lugar de nil, y Result + ? en lugar de if err != nil.
  • Las interfaces se convierten en traits, e implementar uno es explícito (impl Trait for Type). defer se convierte en el Drop automático al final de un ámbito.
  • Las goroutines y los canales se convierten en thread::spawn + mpsc para unos pocos workers, o en tareas de tokio para muchos; el compilador descarta las condiciones de carrera.
  • Cargo sustituye a go build, go test y go mod; clippy y rustfmt sustituyen a go vet y gofmt. En lugar de Gin, mira axum o actix-web; en lugar de GORM, SeaORM o Diesel; en lugar de pgx y sqlc, sqlx; y en lugar de cobra, clap.

Comprueba lo aprendido

1. En Go, un tipo cumple una interfaz con solo tener los métodos adecuados. ¿Cómo implementa un trait un tipo de Rust?

2. ¿Qué hace ? después de una llamada que devuelve un Result?

3. Con std::sync::mpsc, ¿cuándo termina for msg in rx (o rx.iter())?

4. Tu equipo de Go construye sus APIs HTTP con Gin. ¿Qué crate de Rust se le parece más?

Puntuación: 0 / 4

  • El lenguaje de programación Rust: el libro oficial, en su traducción de la comunidad. Los capítulos 4 (ownership), 9 (errores), 10 (genéricos y traits) y 16 (concurrencia) son los más importantes si vienes de Go.
  • Tokio tutorial (en inglés): el runtime async que usan la mayoría de los servidores de Rust, lo más parecido al planificador de goroutines de Go.
  • Documentación de axum (en inglés): el framework web que conviene mirar primero si vienes de Gin, Echo o Chi.
  • Why Discord is switching from Go to Rust (en inglés): la historia real de un equipo que pasó de Go a Rust un servicio sensible a la latencia.
  • Are We Web Yet? (en inglés): un resumen de la comunidad sobre las bibliotecas y los frameworks web de Rust.