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.
Conceptos del lenguaje: de Go a Rust
Sección titulada «Conceptos del lenguaje: de Go a Rust»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 |
Colecciones
Sección titulada «Colecciones»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.
Herramientas: de go a cargo
Sección titulada «Herramientas: de go a cargo»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.
Web y redes
Sección titulada «Web y redes»| 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 |
Escritorio, juegos y sistemas embebidos
Sección titulada «Escritorio, juegos y sistemas embebidos»| 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: 150el saldo es 150La 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.14Ser 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.
Lado a lado: nil se convierte en Option
Sección titulada «Lado a lado: nil se convierte en Option»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 AdaAda depende de nadieEve: no existe ese usuarioLa 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:8080error: puerto no válido "abc": invalid digit found in stringerror: puerto no válido "70000": number too large to fit in target typeerror: el puerto 0 está reservadoLos 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: 14Hay 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: 14Siendo 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.
Lado a lado: defer se convierte en Drop
Sección titulada «Lado a lado: defer se convierte en Drop»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 archivobloquear mutextrabajandodesbloquear mutexcerrar archivolistoEn 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
Veclo 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` trait7 | let sum = total(prices);| ------ value moved here8 | println!("{sum} from {} items", prices.len());| ^^^^^^ value borrowed here after moveEl compilador te dice que
pricesse 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 contotal(&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 comoRc<RefCell<T>>oArc<Mutex<T>>. Cuenta con unas semanas frustrantes. Luego se vuelve mucho más fácil. -
No hay valores cero.
let x: i32;seguido deprintln!("{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, derivaDefaulty escribeConfig { port: 8080, ..Default::default() }. -
Implementar un trait es explícito. Tener un método
areano convierte tu tipo en unShape. Necesitasimpl Shape for Circle. La ventaja: puedes encontrar cada implementación con una simple búsqueda. -
Stringfrente a&str. Go tiene un solo tipostring. Rust tiene dos:Stringes dueño de su texto y&strlo toma prestado. Las funciones suelen recibir&stry los structs suelen guardarString. Y no puedes indexar un string cons[0]como en Go; usas.chars()os.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_addosaturating_addcuando pueda haber desbordamiento. Los literales enteros soni32por defecto, y los índices sonusize. 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 concargo 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 fnno hace nada hasta que se espera con.awaitdentro 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 checkmientras 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 buildfunciona sin más. En Rust, añades el destino con rustup y a menudo también necesitas un enlazador para él. Herramientas comocrossse encargan de eso por ti.
¿Go o Rust? Cuándo elegir cada uno
Sección titulada «¿Go o Rust? Cuándo elegir cada uno»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:
- Empezar con Rust y Herramientas de Rust: instala Rust, configura rust-analyzer y ejecuta
cargo new. - Variables y Tipos de datos: léelas por encima, sobre todo para aprender
let muty los tipos numéricos. - Ownership, luego Borrowing y luego Slices: las páginas más importantes para ti. Tómate tu tiempo.
- Strings:
Stringfrente a&str. - Structs y Enums, luego Match y Coincidencia de patrones. Los enums de Rust con datos sustituyen a muchos type switches de Go.
- Option y Result, luego Errores personalizados y anyhow y thiserror.
- Traits, Genéricos, Traits comunes y Trait objects.
- Closures e Iteradores: Rust se apoya en las cadenas de iteradores mucho más que Go.
- Vectores, HashMap y HashSet.
- Módulos, Cargo y crates y Pruebas.
- Lifetimes y Punteros inteligentes.
- Concurrencia, Canales, Estado compartido y luego Async/Await.
- JSON con Serde y Logging, y después construye el proyecto de API web.
Práctica
Sección titulada «Práctica»Los dos ejercicios te dan un fragmento de Go. Pásalo a Rust.
1. Pasa a Rust un contador de palabras
Sección titulada «1. Pasa a Rust un contador de palabras»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: 2bird: 1cat: 1dog: 1the: 3Aquí 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.
2. Pasa a Rust un error centinela
Sección titulada «2. Pasa a Rust un error centinela»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 70error: cantidad no válida 0rechazado: fondos insuficientesEl 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.
Resumen
Sección titulada «Resumen»- 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,
Optionen lugar denil, yResult+?en lugar deif err != nil. - Las interfaces se convierten en traits, e implementar uno es explícito (
impl Trait for Type).deferse convierte en elDropautomático al final de un ámbito. - Las goroutines y los canales se convierten en
thread::spawn+mpscpara unos pocos workers, o en tareas de tokio para muchos; el compilador descarta las condiciones de carrera. - Cargo sustituye a
go build,go testygo mod; clippy y rustfmt sustituyen ago vetygofmt. 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 que aprendiste
Sección titulada «Comprueba lo que aprendiste»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
Para leer más
Sección titulada «Para leer más»- 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.