Pattern matching en Rust

Aprende Rust → Lección 5

Casi todos los lenguajes tienen una forma de elegir entre varios caminos. Rust tiene una que además abre el valor, le pone nombre a lo que lleva dentro y no te deja olvidarte de ningún caso. Esa combinación —comparar, desempaquetar y comprobar que no falta nada— es la que hace que en Rust se escriban tan pocos if encadenados.

Un match no es un switch

La diferencia que más importa no es la sintaxis, sino una regla del compilador: un match tiene que cubrir todos los valores posibles del tipo que examina. Si dejas una variante fuera, el programa no compila.

Un match debe cubrir todos los casos Los tres casos cubiertos Semaforo::Rojo Semaforo::Amarillo Semaforo::Verde compila Falta una variante Semaforo::Rojo Semaforo::Amarillo sin cubrir nadie contempla el verde no compila error, no aviso La comprobación es del compilador, no tuya: si mañana añades una variante al enum, todos los match incompletos fallan al compilar.
Esto es lo que convierte a los enums de Rust en una herramienta de diseño: ampliar uno obliga a revisar cada sitio que lo usa.

Un ejemplo mínimo. El tipo tiene tres variantes y el match las nombra todas:

enum Semaforo {
    Rojo,
    Amarillo,
    Verde,
}

fn accion(luz: Semaforo) -> &'static str {
    match luz {
        Semaforo::Rojo => "detenerse",
        Semaforo::Amarillo => "reducir la velocidad",
        Semaforo::Verde => "avanzar",
    }
}

Nota un detalle que se aprovecha mucho: el match es una expresión, así que devuelve un valor y puede ir directamente como cuerpo de la función, sin return y sin una variable intermedia. Todas las ramas tienen que producir el mismo tipo.

Desestructurar: abrir el valor y nombrar sus piezas

Comparar es solo la mitad. La otra es que el patrón puede sacar lo que el valor lleva dentro y vincularlo a variables nuevas en el mismo gesto.

Con tuplas y structs se nota enseguida:

struct Punto {
    x: i32,
    y: i32,
}

fn describir(p: Punto) -> String {
    match p {
        Punto { x: 0, y: 0 } => "en el origen".to_string(),
        Punto { x: 0, y } => format!("sobre el eje vertical, a {y}"),
        Punto { x, y: 0 } => format!("sobre el eje horizontal, a {x}"),
        Punto { x, y } => format!("en ({x}, {y})"),
    }
}

En la segunda rama, el 0 compara y la y captura: el patrón exige que x valga cero y, cuando encaja, deja el otro campo disponible como variable. Las ramas se prueban en orden y gana la primera que encaje, por eso los casos concretos van arriba y el general al final.

Donde esto rinde de verdad es con enums que llevan datos, que es la forma habitual de modelar en Rust:

enum Evento {
    Clic { x: i32, y: i32 },
    Tecla(char),
    Pegar(String),
    Salir,
}

fn procesar(ev: Evento) {
    match ev {
        Evento::Clic { x, y } => println!("clic en {x},{y}"),
        Evento::Tecla(c) => println!("tecla {c}"),
        Evento::Pegar(texto) => println!("pegado: {} caracteres", texto.len()),
        Evento::Salir => println!("cerrando"),
    }
}

Cada variante guarda datos distintos y el patrón los extrae con el nombre y el tipo correctos. No hay conversiones, ni comprobar primero de qué tipo es y luego convertir: el compilador sabe que dentro de la rama Evento::Tecla hay un char y nada más.

Esto ya lo has usado. Option y Result, de la lección de manejo de errores, son enums corrientes con datos dentro. Cuando escribes match resultado { Ok(v) => …, Err(e) => … } estás haciendo exactamente esto, y la exhaustividad es lo que impide que te olvides del caso de error.

Cuando solo importa un caso

Escribir un match de dos ramas para mirar una sola es ruidoso. Rust tiene dos formas abreviadas, y la elección entre ellas depende de qué quieres que pase cuando el patrón no encaja.

match, if let y let else match Todos los casos importan. El compilador exige que no falte ninguna variante. if let Solo un caso importa. El resto se ignora y el programa sigue adelante. let … else Un caso o nada. Si encaja, sigue con el valor; si no, sale de la función. La pregunta que decide cuál usar: cuando el patrón no encaja, ¿hay algo razonable que hacer, o ya no tiene sentido continuar?
Los tres hacen lo mismo por dentro. Cambia qué ocurre con los casos que no mencionas.

El if let atiende un patrón y descarta el resto. Admite else, y se pueden encadenar:

fn main() {
    let configuracion: Option<u16> = Some(8080);

    if let Some(puerto) = configuracion {
        println!("Escuchando en el puerto {puerto}");
    } else {
        println!("Sin puerto configurado, usando el valor por defecto");
    }
}

El let … else sirve para lo contrario: cuando el caso bueno es el que continúa y el malo corta. La rama else está obligada a no seguir —tiene que terminar con return, break, continue o panic!—, y a cambio la variable queda disponible en el resto de la función sin ninguna indentación extra:

fn puerto_de(texto: &str) -> Option<u16> {
    let Ok(numero) = texto.trim().parse::<u16>() else {
        eprintln!("«{texto}» no es un puerto válido");
        return None;
    };

    Some(numero)
}

Esa es su gracia: evita la escalera de if anidados que crece hacia la derecha. Las comprobaciones se despachan arriba, una detrás de otra, y el cuerpo principal de la función se queda al margen izquierdo.

Hay una tercera forma, while let, que repite mientras el patrón siga encajando. Es la manera idiomática de vaciar una colección:

fn main() {
    let mut pila = vec![1, 2, 3];

    while let Some(cima) = pila.pop() {
        println!("sacado: {cima}");
    }
}

El bucle termina solo: cuando la pila se vacía, pop() devuelve None, el patrón deja de encajar y se sale. No hace falta comprobar la longitud ni llevar un índice.

Patrones más expresivos

Un patrón puede ser bastante más que un nombre de variante. Estas son las piezas que conviene conocer:

Patrón Qué hace Ejemplo
1 | 2 | 3 Encaja con cualquiera de varias alternativas 'a' | 'e' | 'i'
1..=9 Encaja con un rango cerrado de valores '0'..='9'
if condición Guarda: añade una condición extra a la rama n if n < 0
nombre @ patrón Comprueba el patrón y guarda el valor a la vez n @ 1..=9
_ Encaja con cualquier cosa y descarta el valor _ => "otro"
.. Ignora el resto de los campos o elementos Punto { x, .. }

Combinadas, resuelven clasificaciones que en otros lenguajes serían una cadena larga de condiciones:

fn clasificar(c: char) -> String {
    match c {
        'a' | 'e' | 'i' | 'o' | 'u' => "vocal".to_string(),
        d @ '0'..='9' => format!("dígito {d}"),
        c if c.is_whitespace() => "espacio".to_string(),
        c if c.is_alphabetic() => "consonante".to_string(),
        _ => "símbolo".to_string(),
    }
}

Dos matices sobre este ejemplo. El d @ '0'..='9' hace las dos cosas de golpe: exige que el carácter esté en el rango y además lo guarda en d para usarlo en el mensaje. Y las guardas no cuentan para la exhaustividad: el compilador no puede saber si c.is_alphabetic() será cierto alguna vez, así que en cuanto usas una guarda necesitas una rama final que lo recoja todo.

Cuando lo único que quieres es una respuesta de sí o no, la macro matches! ahorra el match entero:

fn main() {
    let c = '7';

    if matches!(c, '0'..='9') {
        println!("es un dígito");
    }
}

La trampa del nombre que no compara

Hay un error que casi todo el mundo comete una vez, y conviene cometerlo leyéndolo aquí. Un nombre en minúsculas dentro de un patrón no compara contra la variable que ya existe con ese nombre: crea una variable nueva que encaja con cualquier cosa.

fn main() {
    let esperado = 5;
    let numero = 7;

    match numero {
        esperado => println!("coincide"),
        _ => println!("no coincide"),
    }
}
Ese programa imprime «coincide», con el 7. La primera rama no compara numero con el 5: declara una variable llamada esperado, le asigna el 7 y encaja siempre. La segunda rama queda inalcanzable y el compilador lo avisa con unreachable pattern, que es la señal que hay que aprender a leer.

La forma correcta es usar una guarda, que sí compara contra la variable de fuera:

fn main() {
    let esperado = 5;
    let numero = 7;

    match numero {
        n if n == esperado => println!("coincide"),
        _ => println!("no coincide"),
    }
}

La otra opción es declarar el valor como constante: los nombres en mayúsculas sí se comparan, porque Rust distingue una constante de un nombre nuevo por su forma. Esa es la razón de la convención, y no es solo estética.

En resumen

Lo que quieres hacer Qué usar
Tratar todas las variantes de un tipo match
Atender un caso y seguir con el programa if let
Seguir con el caso bueno y abandonar en el malo let … else
Repetir mientras el patrón encaje while let
Saber solo si encaja, sin más matches!
Varias alternativas en una rama a | b | c
Un intervalo de valores 1..=9
Una condición adicional una guarda if
Comparar contra una variable existente una guarda, nunca el nombre a secas

La idea que conviene llevarse es que en Rust los patrones no son azúcar sintáctico sobre el if, sino la forma normal de leer datos. Casi todo lo que lleva información dentro —enums, structs, tuplas, Option, Result— se abre con un patrón, y la exhaustividad convierte cada caso olvidado en un error de compilación en lugar de en un fallo en producción.

Para verlo explicado

Miniatura del vídeo «Rust a Fondo: Patrones (pattern matching)», del canal Dude the Builder

«Rust a Fondo: Patrones (pattern matching)», del canal Dude the Builder (22:51). Material de otro canal que recomendamos como complemento, no una producción de decodigo.com. El reproductor solo se carga al pulsar, así que YouTube no recibe nada tuyo hasta entonces. También puedes verlo en YouTube.