wandres.dev
OPTION Y RESULT · sin null ni excepciones

Option: la ausencia de valor sin null

El tipo que mata al null: Option con las variantes Some y None, por qué el error del billón de dólares no puede escribirse en Rust, y cómo el compilador convierte la ausencia en una decisión obligatoria en vez de una bomba silenciosa.

⏱ 15 min

Casi todos los lenguajes tienen un valor que significa “nada” y que cabe en cualquier hueco: null, nil, None, el puntero cero. Es tan cómodo como letal, porque miente sobre el tipo: una variable que dice contener un Usuario puede en realidad no contener nada, y nadie te obliga a comprobarlo. Rust toma la decisión más radical de su diseño de tipos: el null no existe. La ausencia legítima se modela con un enum normal y corriente —Option<T>— y el compilador no te deja mirar dentro sin haber contemplado antes el vacío.

🎯 Al terminar esta lección sabrás
  • Entender por qué el null es un fallo del sistema de tipos, no una característica.
  • Leer Option<T> como el enum de dos variantes que ya conoces: Some y None.
  • Ver cómo el compilador te obliga a manejar la ausencia antes de usar el valor.
  • Distinguir “no hay valor” (Option) de “algo falló” (Result), que llega en 7.3.

El error del billón de dólares

En 1965 Tony Hoare añadió la referencia nula a ALGOL W “simplemente porque era fácil de implementar”. Cuarenta años después la bautizó, sin ironía, como my billion-dollar mistake: el coste acumulado de todas las caídas y agujeros de seguridad que esa idea sembró. El problema no es el valor nulo en sí, sino que en la mayoría de lenguajes todo tipo lo admite en silencio. Un String de Java es en realidad “un String o una bomba”; el *p de C reza por que p no sea NULL. El tipo promete algo que el lenguaje no garantiza, y la mentira se cobra en ejecución, lejos del sitio donde se coló el vacío.

Rust deshace el nudo: no hay palabra null, ni referencia nula, ni valor vacío implícito. Una &T apunta siempre a un T válido; un String contiene siempre una cadena. Si algo puede faltar, ese “puede faltar” tiene que aparecer, explícito, en el tipo.

Option: la ausencia como un enum más

Aquí no hay magia. Option<T> es exactamente el tipo suma que construiste en 6.1, con dos variantes y genérico sobre T:

enum Option<T> {
    None,
    Some(T),
}

Some(valor) envuelve un valor presente; None es la ausencia. Ambas están en el preludio, así que las usas sin importar nada. Una función que quizá no tenga respuesta lo declara en su tipo de retorno:

fn primer_par(v: &[i32]) -> Option<i32> {
    for &x in v {
        if x % 2 == 0 {
            return Some(x);   // encontrado
        }
    }
    None                      // recorrimos todo sin éxito
}

let a = primer_par(&[1, 3, 4, 7]);   // Some(4)
let b = primer_par(&[1, 3, 5]);      // None

Y leerlo es aplicar lo de 6.2 sin ninguna regla nueva: un match que contempla, obligatoriamente, las dos variantes.

match primer_par(&[1, 3, 4, 7]) {
    Some(par) => println!("el primer par es {par}"),
    None => println!("no había ningún par"),
}

La ausencia dejó de ser un fantasma escondido dentro de i32 y pasó a ser una variante visible en la firma. Media biblioteca estándar habla este idioma: slice::first, HashMap::get, str::parse, Iterator::next devuelven todos Option, porque todos describen operaciones que legítimamente pueden no dar nada.

ℹ️
Los lenguajes con null 'arreglado' llegaron después, y a medias

Kotlin, Swift o C# moderno retrofitaron esta idea con tipos anulables (el String? de Kotlin): distinguen en el tipo lo que puede ser nulo de lo que no. Es un avance enorme sobre el null universal, y confirma que Rust acertó. Pero arrastran dos herencias que Rust no tiene: conviven con un null preexistente por compatibilidad, y ofrecen una válvula de escape de un solo carácter —el !! de Kotlin, el ! de Swift— que vuelve a hacer panic con casi nada de fricción. Rust no parte de un null que arrastrar ni da azúcar de una letra para ignorar el vacío: abrir un Option es siempre un acto visible (7.2). Diseñar desde la seguridad, en vez de añadirla después, se nota justo en los bordes.

El compilador no te deja leer el vacío

Esta es la diferencia que lo cambia todo. Option<i32> no es i32; son tipos distintos, y no puedes tratar uno como el otro:

let n: Option<i32> = primer_par(&[1, 3, 4]);
// let doble = n * 2;
error[E0369]: cannot multiply `Option<i32>` by `{integer}`

Para llegar al i32 de dentro estás obligado a abrir el Option con las herramientas de 6.2 —match, if let, let ... else— o con los métodos que verás en 7.2, y en todos los casos tienes que decir explícitamente qué ocurre cuando es None. El caso vacío es imposible de olvidar porque está grabado en el tipo: si no lo contemplas, el programa no compila. Compáralo con el null, donde olvidar la comprobación es lo normal y el castigo llega meses después, en producción.

Y como viste en 6.1, esta seguridad no cuesta memoria: gracias a la optimización de nicho, Option<&T> ocupa lo mismo que un puntero desnudo, porque el patrón nulo —que una & jamás toma— se reserva para None. Obtienes la representación de C con la garantía que C nunca dio.

💡
Preguntar sin desenvolver: is_some, is_none, matches!

A veces solo quieres saber si hay valor, sin sacarlo. Para eso están opt.is_some() y opt.is_none(), que devuelven un bool, y la macro matches!(opt, Some(x) if x > 0), que comprueba forma y condición en una sola expresión. Son atajos de lectura: para usar el valor sigues teniendo que abrir el Option (7.2), pero para un assert! o una guarda rápida son lo idiomático.

flowchart TD
A[Funcion que quiza no tenga valor] --> B[Tipo de retorno Option de T]
B --> S[Some con el valor dentro]
B --> N[None: ausencia explicita y tipada]
S --> E[El compilador exige abrir el Option]
N --> E
E --> F[Imposible usar el vacio por accidente]
style B fill:#cba6f7,color:#11111b
style S fill:#a6e3a1,color:#11111b
style N fill:#f9e2af,color:#11111b
style F fill:#89b4fa,color:#11111b

None no es un error

Un matiz que separa a los principiantes de quien piensa en Rust: Option responde a la pregunta “¿hay un valor?”, no a “¿algo falló?”. Que first() sobre un slice vacío dé None, o que buscar una clave ausente en un HashMapNone, no es un error: es una respuesta perfectamente normal y esperada. El vacío aquí no lleva explicación porque no hace falta ninguna.

use std::collections::HashMap;

let edades = HashMap::from([("ana", 30)]);
let ana: Option<&i32> = edades.get("ana");    // Some(&30)
let juan: Option<&i32> = edades.get("juan");  // None: ausencia normal, no un fallo

Cuando la ausencia necesita justificarse —por qué no se pudo abrir el fichero, qué campo del formulario estaba mal— None se queda corto, porque no puede transportar el motivo. Ese es el territorio de Result<T, E>, que llega en 7.3: el mismo patrón de “un valor envuelto en un enum”, pero con un canal para el error. Guarda esta frontera: Option es ausencia, Result es fallo con causa.

El null es un agujero en el sistema de tipos; Option es un valor

Interioriza la asimetría profunda. El null no es un valor de tipo T: es un impostor que el lenguaje permite colar en cualquier hueco de tipo T sin avisar. Cada tipo pasa a significar en secreto “T o una bomba silenciosa”, y el compilador —que debería ser tu aliado— no sabe dónde están las bombas, así que no puede protegerte. Option<T> hace lo contrario: separa limpiamente “hay un T” de “no hay nada”, y sube esa distinción al nivel de tipos, donde el verificador puede razonar. La consecuencia no es solo estética; es un desplazamiento temporal del error. Lo que en un lenguaje con null es un crash a las tres de la madrugada en un caso raro, en Rust es una línea roja en tu editor mientras escribes. No has añadido comprobaciones que antes no existían —la biblioteca estándar ya devolvía la ausencia—; has hecho que ignorarlas sea imposible. Y como la optimización de nicho borra el coste, esta garantía es literalmente gratis. Ese es el trato que define a Rust: máxima seguridad, coste cero. El día que dejas de ver Option como una molestia y empiezas a verlo como el compilador negándose a dejarte mentir sobre tus datos, has cruzado la primera frontera mental del lenguaje.

⚔️ Haz visible la ausencia
  1. Escribe fn buscar(v: &[i32], objetivo: i32) -> Option<usize> que devuelva la posición del elemento o None.
  2. Llámala con un valor presente y con uno ausente; comprueba que el tipo de retorno te obliga a pensar en ambos.
  3. Intenta usar el resultado como si fuera un usize directamente y lee el error del compilador con calma.
  4. Verifica con std::mem::size_of que Option<&i32> mide lo mismo que &i32, pero Option<i64> no mide lo mismo que i64; razona por qué a partir de lo que aprendiste del nicho en 6.1.
  5. Busca en la documentación tres métodos de la biblioteca estándar que devuelvan Option y anota qué representa el None en cada uno. ¿Alguno debería ser en realidad un Result?