wandres.dev
CONTROL DE FLUJO · if, loop, while, match

Bloques, el tipo unit y el tipo never

Los bloques como expresiones que devuelven su última línea, el tipo unit () que representa la ausencia de valor útil, y el tipo never ! de las funciones que no retornan, como panic!. La teoría de tipos detrás del control de flujo.

⏱ 14 min

Cerramos el control de flujo mirando la maquinaria de tipos que lo sostiene. Un bloque { } es una expresión que vale su última línea. Cuando no hay nada útil que devolver, aparece el tipo unit (). Y cuando una expresión nunca devuelve —un panic!, un return, un loop sin fin— su tipo es el enigmático never, escrito !. Estos dos tipos, uno con un solo valor y otro con ninguno, son las piezas que hacen que toda la orientación a expresiones de Rust encaje sin fisuras.

🎯 Al terminar esta lección sabrás
  • Ver el bloque { } como una expresión con valor.
  • Entender el tipo unit () y dónde aparece.
  • Entender el tipo never ! y las funciones que divergen.
  • Ver por qué ! encaja como cualquier tipo, y su fundamento teórico.

El bloque es una expresión

Un bloque delimitado por llaves no es solo una agrupación de sentencias: es una expresión cuyo valor es el de su última expresión sin punto y coma. Puedes usarlo para dar un scope acotado a cálculos intermedios y devolver solo el resultado:

let y = {
    let x = 3;
    x * x + 1     // sin `;`: este es el valor del bloque
};
println!("{y}"); // 10

x vive únicamente dentro del bloque; fuera solo sobrevive y. Es el mismo mecanismo por el que el cuerpo de una función devuelve su última expresión sin return, o por el que las ramas de un if “valen” algo. Un bloque que termina en ;, o que está vacío, no devuelve nada útil: vale ().

Esto convierte al bloque en una herramienta de composición: encapsulas varios pasos y usas el resultado justo donde iría un solo valor, sin ensuciar el scope exterior con variables auxiliares que ya no necesitas.

let area = {
    let base = 4.0;
    let altura = 3.0;
    base * altura / 2.0   // el bloque entero "vale" 6.0
};

El tipo unit: ()

() es a la vez un tipo y su único valor. Se le llama unit, y representa “aquí no hay información útil que transmitir”. Aparece por todas partes en cuanto sabes verlo:

fn saludar() {          // sin `-> ...`: devuelve () implícitamente
    println!("hola");   // println! evalúa a ()
}

let nada: () = saludar(); // el valor de retorno ES ()
let a = {};               // un bloque vacío vale ()
let b = { 5; };           // termina en `;`, así que también vale ()

Una función sin tipo de retorno explícito devuelve (). Una sentencia produce (). Poner ; a una expresión descarta su valor y deja (). No es un caso especial ni un “null”: es un tipo normal, con exactamente un habitante, y de tamaño cero en memoria. Por eso Result<(), Error> es idiomático: “esto puede fallar, y si sale bien no hay nada que devolver, solo el hecho de haber salido bien”.

Que () ocupe cero bytes tiene consecuencias reales de diseño: un HashSet<T> de la librería estándar no es más que un HashMap<T, ()> cuyo valor no gasta ni un byte. El () marca “esta clave está presente” sin almacenar nada junto a ella.

El tipo never: !

Ahora el opuesto. Algunas expresiones no se evalúan a ningún valor porque nunca terminan de la forma normal: transfieren el control a otro sitio y no vuelven. Su tipo es !, el tipo never:

fn siempre_falla() -> ! {   // el `-> !` promete: esta función no retorna
    panic!("algo va muy mal");
}

fn bucle_eterno() -> ! {
    loop { /* nunca hay break */ }
}

Tienen tipo !, entre otras, las expresiones panic!(), return, break, continue, std::process::exit(), y los marcadores todo!() y unreachable!(). Todas comparten una propiedad: el código que las sigue en secuencia es inalcanzable, porque el control ya se marchó a otra parte.

Lo fascinante de ! es que encaja como cualquier tipo. Como una expresión de tipo ! no produce ningún valor, el compilador puede tratarla como si fuese del tipo que haga falta en ese contexto, sin peligro. Esto es lo que permite mezclar una rama que devuelve un valor con otra que diverge:

let config: Option<i32> = None;
let valor: i32 = match config {
    Some(v) => v,            // esta rama vale i32
    None => panic!("falta"), // esta vale !, y ! encaja como i32
};

Sin esta regla, cada match con un panic! o un return en una rama fallaría por “tipos incompatibles”. Gracias a ella, divergir es siempre compatible con cualquier tipo esperado.

Divergir en la práctica

Esta compatibilidad universal de ! no es un tecnicismo académico: es lo que hace que los patrones de salida temprana se compongan sin fricción. El let-else de la lección anterior obliga a que su bloque else diverja precisamente porque su tipo ha de ser !; así el compilador sabe que, si la ejecución continúa, el valor quedó ligado.

fn primer_par(nums: &[i32]) -> i32 {
    let Some(&x) = nums.iter().find(|n| *n % 2 == 0) else {
        return -1; // el else diverge: su tipo es !
    };
    x // aquí x existe con seguridad
}

Lo mismo sostiene a los métodos de Option y Result que verás pronto: unwrap, expect y el operador ? se apoyan en que panic! y return tengan tipo ! para encajar en cualquier expresión sin estropear la inferencia de tipos.

let cfg = leer_config().expect("config ilegible");
// expect() devuelve el valor interior o diverge: su tipo encaja como T

Y observa el contraste final entre los dos tipos como retorno: una función que devuelve () termina y no dice nada; una que devuelve ! no termina en absoluto. Son los dos extremos de cuánta información sale de una llamada: ninguna útil, o literalmente ninguna.

fn registra(msg: &str) {       // -> () implícito: termina, sin valor
    println!("[log] {msg}");
}

fn aborta(codigo: i32) -> ! {  // -> !: no vuelve jamás
    std::process::exit(codigo);
}
flowchart LR
U[unit parentesis vacio] -->|exactamente un valor| I[Tipo habitado]
N[never signo de cierre] -->|cero valores| V[Tipo vacio]
V -->|encaja como cualquier tipo| Any[i32 String bool y mas]
style U fill:#89b4fa,color:#11111b
style N fill:#f38ba8,color:#11111b
style Any fill:#a6e3a1,color:#11111b
⚠️
Un cambio de la edición 2024: el fallback de never

Cuando el inferidor de tipos no puede fijar del todo el tipo de una expresión que involucra !, tiene que elegir un valor por defecto. En ediciones anteriores ese fallback era (), lo que en casos raros ocultaba errores. La edición 2024 cambia el fallback por defecto a !, alineándolo con la semántica real de la divergencia. Es un cambio sutil que casi nunca notarás, pero explica por qué cierto código en el límite de la inferencia se comporta distinto según la edición. Es un ejemplo de cómo las ediciones de Rust afinan la semántica sin romper el código existente.

unit y never son el uno y el cero del sistema de tipos

Estos dos tipos parecen curiosidades, pero son los cimientos sobre los que se apoya toda la orientación a expresiones de Rust, y su elegancia viene directa de la teoría de tipos. Piensa en un tipo como en un conjunto de valores posibles. bool tiene dos habitantes; u8, doscientos cincuenta y seis; una tupla, el producto de sus partes. En ese conteo, () es el tipo con un habitante: el mínimo tipo que aún existe, la “unidad” multiplicativa, igual que el 1 en los números. Y ! es el tipo con cero habitantes: el conjunto vacío, imposible de construir. De ahí sale todo lo demás por pura lógica. ¿Por qué una función que devuelve () no necesita return? Porque su valor está determinado: solo hay uno posible. ¿Por qué ! encaja como cualquier tipo? Porque para “producir un valor de tipo T a partir de uno de tipo !” no hace falta hacer nada: no existe ningún valor de ! que convertir, así que la promesa se cumple de forma vacía. Es el ex falso quodlibet de la lógica —de una contradicción se sigue cualquier cosa— hecho tipo: si de verdad tuvieras un valor de un tipo vacío, ya estarías en una rama imposible, y el compilador te deja afirmar en ella lo que quieras. Bajo la correspondencia de Curry y Howard, los tipos son proposiciones y los programas, demostraciones: () es la proposición trivialmente verdadera y ! la falsa. Que Rust exponga ambos como tipos de primera clase, y que su control de flujo —loop, panic!, return— hable con fluidez este idioma, no es accidente: es lo que hace que expresiones y divergencia convivan en una sola gramática coherente. Has estado usando lógica formal disfrazada de control de flujo desde la primera línea.

⚔️ Explora los límites del sistema de tipos
  1. Escribe un bloque que calcule un valor intermedio en una variable local y devuelva solo el resultado final; comprueba que la local no existe fuera del bloque.
  2. Declara let a: () = { 5; }; y let b: () = println!("x"); y convéncete de que ambos valen unit.
  3. Escribe fn nunca() -> ! con un loop {}, y otra versión con panic!; intenta poner código después de llamarla y observa el aviso de código inalcanzable.
  4. Escribe un match sobre un Option<i32> donde la rama None haga return o panic!, y explica con el tipo ! por qué compila aunque la otra rama devuelva un i32.