12 funciones y patrones de Rust que casi nadie usa (y deberías)

Cuando alguien empieza con Rust, lo primero que le explican es ownership, borrowing, lifetimes y concurrencia. Con razón: son la base del lenguaje y lo que lo hace diferente de todo lo demás. Pero una vez pasada esa fase, hay toda una capa de funciones del lenguaje y patrones idiomáticos que casi nunca salen en los tutoriales, y que separan el código que simplemente compila del código que da gusto mantener.

Aquí van doce de esas piezas infravaloradas: desde azúcar sintáctico como let-else, hasta patrones de diseño como el type-state, pasando por herramientas de la librería estándar que muy poca gente exprime del todo.

1. let-else para retornos tempranos

match e if let son las formas más habituales de comprobar si un valor tiene la forma esperada y extraer el dato que necesitamos. El problema llega cuando hay que encadenar varias comprobaciones: el código se anida y cada nivel añade una capa de indentación.

fn get_email(user: Option<User>) -> Result<String, &'static str> {
    if let Some(user) = user {
        if let Some(profile) = user.profile {
            if let Some(email) = profile.email {
                Ok(email)
            } else {
                Err("missing email")
            }
        } else {
            Err("missing profile")
        }
    } else {
        Err("missing user")
    }
}

Con let-else podemos salir de la función en cuanto el patrón no encaja, y dejar la lógica principal totalmente plana:

fn get_email(user: Option<User>) -> Result<String, &'static str> {
    let Some(user) = user else {
        return Err("missing user");
    };

    let Some(profile) = user.profile else {
        return Err("missing profile");
    };

    let Some(email) = profile.email else {
        return Err("missing email");
    };

    Ok(email)
}

El resultado se lee de arriba abajo sin tener que ir descontando llaves de cierre. Es el mismo espíritu que un «guard clause» en otros lenguajes, pero integrado en la sintaxis del propio let.

2. Option y Result tienen muchos más métodos de los que usamos

Es habitual recurrir a match para trabajar con Option y Result incluso cuando lo único que hace falta es transformar un valor, encadenar operaciones o poner un valor por defecto:

let username = match user {
    Some(name) => Some(name.to_uppercase()),
    None => None,
};

Ambos tipos ya traen un arsenal de métodos pensados justo para esto:

let username = user.map(|name| name.to_uppercase());

Los que más se usan en el día a día:

  • map ? transforma el valor contenido.
  • and_then ? encadena operaciones que devuelven Option o Result.
  • filter ? conserva el valor solo si cumple una condición (solo en Option).
  • inspect ? ejecuta un efecto secundario sin tocar el valor.
  • ok_or / ok_or_else ? convierte un Option en un Result.
  • unwrap_or / unwrap_or_else ? da un valor por defecto si está vacío o hay error.
  • map_or / map_or_else ? transforma el valor o devuelve un valor por defecto.
  • is_some_and ? comprueba que un Option es Some y cumple un predicado.
  • is_ok_and ? comprueba que un Result es Ok y cumple un predicado.
  • flatten ? elimina un nivel de anidación en Option o Result.
  • transpose ? convierte entre Option<Result<T, E>> y Result<Option<T>, E>.
  • zip ? combina dos Option en una tupla, solo si ambos son Some.

Merece la pena tener este listado a mano: en la mayoría de los casos hay un método que resuelve el match en una sola línea.

3. std::mem::take y std::mem::replace

Sacar un valor de un campo puede ser más lioso de lo que parece, porque Rust no permite mover parcialmente algo que está detrás de una referencia prestada. La solución rápida suele ser clonar, pero eso añade una asignación de memoria que no hacía falta:

struct Buffer {
    data: Vec<String>,
}

impl Buffer {
    fn flush(&mut self) -> Vec<String> {
        let data = self.data.clone();
        self.data.clear();
        data
    }
}

std::mem::take y std::mem::replace nos dejan mover el valor hacia fuera dejando un reemplazo en su lugar, sin clonar nada:

struct Buffer {
    data: Vec<String>,
}

impl Buffer {
    fn flush(&mut self) -> Vec<String> {
        std::mem::take(&mut self.data)
    }
}

take deja el valor por defecto del tipo (un Vec vacío, en este caso) y devuelve el original. replace hace lo mismo pero con el valor de sustitución que le indiquemos.

4. Pattern matching en todas partes

El pattern matching en Rust no se limita a match. Podemos usar patrones en parámetros de función, en let, en bucles for, en closures y en while let para desestructurar valores justo donde los necesitamos.

En un let:

let point = (10, 20);
let (x, y) = point;
println!("{x}, {y}");

En parámetros de función:

fn print_point((x, y): (i32, i32)) {
    println!("({x}, {y})");
}

En bucles for:

let points = vec![(10, 20), (30, 40)];

for (x, y) in points {
    println!("({x}, {y})");
}

Con while let:

let mut stack = vec![1, 2, 3];

while let Some(value) = stack.pop() {
    println!("{value}");
}

Desestructurando structs:

struct User {
    name: String,
    age: u32,
}

let user = User { name: "Alice".into(), age: 25 };
let User { name, age } = user;
println!("{name} is {age}");

Ignorando campos que no interesan:

struct User {
    name: String,
    age: u32,
    email: String,
}

let User { name, .. } = user;
println!("{name}");

Una vez se coge el hábito, cuesta volver a escribir point.0 y point.1 en vez de desestructurar directamente.

5. El patrón Newtype

Tipos primitivos como String, u32 o usize son cómodos, pero no dicen nada sobre su significado. Cuando dos valores distintos comparten tipo, es fácil intercambiarlos por error y el compilador no puede avisarnos:

fn send_email(user_id: String, email: String) {
    // ...
}

send_email(
    "[email protected]".to_string(),
    "123".to_string(), // ¡los argumentos están al revés y compila igual!
);

El patrón Newtype envuelve un tipo existente en una tuple struct para crear un tipo nuevo y distinto, sin coste en tiempo de ejecución. Mejora la seguridad de tipos, deja las firmas de función más claras y permite añadir comportamiento específico del dominio:

struct UserId(String);
struct Email(String);

fn send_email(user_id: UserId, email: Email) {
    // ...
}

send_email(
    UserId("123".to_string()),
    Email("[email protected]".to_string()),
);

Con este cambio invertir los argumentos ya no compila: el error se detecta al compilar, no se convierte en un bug silencioso en producción.

6. Extension traits

Los extension traits nos dejan añadir métodos nuevos a tipos que no podemos modificar directamente, como los de la librería estándar o los de una crate externa. Sirven para escribir helpers reutilizables que se sienten parte del propio tipo:

trait OptionExt {
    fn is_some_and_even(&self) -> bool;
}

impl OptionExt for Option<i32> {
    fn is_some_and_even(&self) -> bool {
        match self {
            Some(value) => value % 2 == 0,
            None => false,
        }
    }
}

fn main() {
    let a = Some(4);
    let b = Some(3);
    let c: Option<i32> = None;

    println!("{}", a.is_some_and_even()); // true
    println!("{}", b.is_some_and_even()); // false
    println!("{}", c.is_some_and_even()); // false
}

Es la técnica que usan muchas crates populares (itertools, por ejemplo) para dar la sensación de que un tipo «ya trae» un método que en realidad han añadido ellas.

7. #[must_use]

Algunas funciones devuelven valores que no deberíamos ignorar. Descartarlos sin querer puede esconder errores o resultados importantes que nunca llegan a usarse:

fn compute_value() -> i32 {
    42
}

fn main() {
    compute_value(); // el valor se pierde y el compilador no dice nada
}

El atributo #[must_use] hace que el compilador avise si se ignora el valor de retorno:

#[must_use]
fn compute_value() -> i32 {
    42
}

También funciona muy bien en tipos propios:

#[must_use]
struct Response {
    status: u16,
    body: String,
}

fn get_response() -> Response {
    Response { status: 200, body: "OK".into() }
}

Es el mismo mecanismo que usa la propia librería estándar en Result, y por eso el compilador nos avisa si olvidamos gestionar un error.

8. Cow cuando quizá no haga falta reservar memoria

No siempre hace falta clonar. A veces basta con tomar prestados los datos, y clonar de más solo genera copias innecesarias que cuestan memoria y tiempo:

fn normalize(s: &str) -> String {
    let mut result = s.to_string();
    result = result.trim().to_string();
    result
}

Cow (Clone-on-Write) permite tomar prestados los datos cuando es posible y clonar solo cuando de verdad hace falta modificarlos. Reduce las reservas de memoria sin complicar la API:

use std::borrow::Cow;

fn normalize(input: &str) -> Cow<'_, str> {
    if input.contains("  ") {
        Cow::Owned(input.replace("  ", " "))
    } else {
        Cow::Borrowed(input)
    }
}

fn main() {
    let text = "hello  world";
    let result = normalize(text);
    println!("{result}");
}

Si la cadena ya está normalizada, normalize no reserva memoria nueva; solo lo hace cuando realmente tiene que modificar algo.

9. Builder pattern sin macros

Construir un struct con muchos campos opcionales puede complicarse, sobre todo si tiramos de listas largas de parámetros. Hay crates como derive_builder que lo automatizan, pero Rust permite escribir uno a mano sin mucho esfuerzo:

struct Config {
    host: String,
    port: u16,
    use_tls: bool,
}

struct ConfigBuilder {
    host: String,
    port: u16,
    use_tls: bool,
}

impl ConfigBuilder {
    fn new() -> Self {
        Self { host: "localhost".into(), port: 8080, use_tls: false }
    }

    fn host(mut self, host: &str) -> Self {
        self.host = host.into();
        self
    }

    fn port(mut self, port: u16) -> Self {
        self.port = port;
        self
    }

    fn use_tls(mut self, use_tls: bool) -> Self {
        self.use_tls = use_tls;
        self
    }

    fn build(self) -> Config {
        Config { host: self.host, port: self.port, use_tls: self.use_tls }
    }
}

fn main() {
    let config = ConfigBuilder::new()
        .host("example.com")
        .port(443)
        .use_tls(true)
        .build();

    println!("{}:{} tls={}", config.host, config.port, config.use_tls);
}

Un builder manual como este mejora la legibilidad y permite valores por defecto sensatos, sin añadir ninguna dependencia externa.

10. Type-state pattern para eliminar estados inválidos

Muchas APIs exigen seguir un orden concreto de pasos, como conectar antes de enviar datos. Si esa regla solo se comprueba en tiempo de ejecución, el error aparece tarde, normalmente como un panic:

struct Connection {
    connected: bool,
}

impl Connection {
    fn new() -> Self {
        Self { connected: false }
    }

    fn connect(&mut self) {
        self.connected = true;
    }

    fn send(&self, data: &str) {
        if !self.connected {
            panic!("Not connected!");
        }
        println!("Sending: {data}");
    }
}

fn main() {
    let mut conn = Connection::new();
    conn.send("hello"); // panic en tiempo de ejecución
}

El patrón type-state traslada esas reglas al sistema de tipos. Cada estado es un tipo distinto, así que solo las acciones válidas están disponibles para el compilador:

struct Disconnected;
struct Connected;

struct Connection<State> {
    _state: std::marker::PhantomData<State>,
}

impl Connection<Disconnected> {
    fn new() -> Self {
        Self { _state: std::marker::PhantomData }
    }

    fn connect(self) -> Connection<Connected> {
        Connection { _state: std::marker::PhantomData }
    }
}

impl Connection<Connected> {
    fn send(&self, data: &str) {
        println!("Sending: {data}");
    }
}

fn main() {
    let conn = Connection::<Disconnected>::new();
    let conn = conn.connect();
    conn.send("hello");
}

Con Connection<Disconnected> no existe el método send, así que el propio compilador impide llamar a algo que todavía no es válido. El error deja de ser un panic en producción y pasa a ser un error de compilación.

11. Builders con métodos que consumen self

Muchos builders usan &mut self, pero en Rust suele preferirse recibir self por valor y devolver un Self nuevo. Encaja mejor con el modelo de ownership y deja el encadenado de métodos más limpio, sin necesidad de mantener una variable mutable del builder:

// Versión con &mut self
struct Builder {
    value: String,
}

impl Builder {
    fn new() -> Self {
        Self { value: "default".into() }
    }

    fn set(&mut self, v: &str) -> &mut Self {
        self.value = v.into();
        self
    }

    fn build(&self) -> String {
        self.value.clone()
    }
}

fn main() {
    let mut b = Builder::new();
    let result = b.set("hello").build();
    println!("{result}");
}
// Versión consumiendo self
struct Builder {
    value: String,
}

impl Builder {
    fn new() -> Self {
        Self { value: "default".into() }
    }

    fn set(mut self, v: &str) -> Self {
        self.value = v.into();
        self
    }

    fn build(self) -> String {
        self.value
    }
}

fn main() {
    let result = Builder::new()
        .set("hello")
        .build();

    println!("{result}");
}

La segunda versión no necesita declarar el builder como mut desde fuera y encaja mejor con el estilo fluido que se ve en la mayoría de crates de Rust.

12. std::pin::Pin para valores que no se pueden mover

La mayoría de valores en Rust se pueden mover libremente, pero algunos necesitan quedarse en una dirección de memoria fija. Moverlos puede romper referencias internas y provocar comportamiento indefinido:

struct Data {
    value: String,
}

fn main() {
    let mut data = Data { value: String::from("hello") };
    let ptr = &data.value as *const String;

    let data2 = data; // mueve `data`; `ptr` queda apuntando a memoria inválida

    println!("{:?}", unsafe { &*ptr });
}

std::pin::Pin garantiza que un valor no se pueda mover una vez que está «fijado». No es algo que se use a diario de forma directa, pero es imprescindible para entender cómo funcionan async/await, los futures y los generadores por debajo:

use std::pin::Pin;

struct Data {
    value: String,
}

fn main() {
    let data = Data { value: String::from("hello") };
    let pinned = Pin::new(Box::new(data));

    let ptr = &pinned.value as *const String;
    println!("{}", unsafe { &*ptr });
}

Aunque la mayoría de proyectos nunca llegan a escribir Pin a mano, saber qué hace ayuda a entender por qué el compilador exige Unpin en tantos sitios cuando se trabaja con futures.

¿Y ahora qué?

Ninguna de estas doce piezas es imprescindible por sí sola, y se puede escribir Rust en producción sin usar ninguna. Pero juntas marcan la diferencia entre código que compila sin más y código que sigue dando gusto tocar seis meses después.

Si solo te quedas con dos para empezar a aplicar ya, que sean estas: let-else, para aplanar las cadenas de comprobaciones, y el patrón Newtype, para que el compilador detecte por ti los errores de «he pasado los argumentos en el orden equivocado».

Artículo adaptado y ampliado a partir de «12 Underrated Rust Features and Patterns», de bektiaw en Medium.

Imagen: Pexels / Markus Spiske

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP