Rust

Rust

Tutorial Rust: Option y Result

Aprende a manejar valores opcionales y errores en Rust con Option, Result y el operador ? para código seguro y robusto.

Aprende Rust y certifícate

Option<T>

En Rust, uno de los problemas más comunes en programación es cómo representar la ausencia de un valor. Otros lenguajes utilizan null o nil, lo que frecuentemente lleva a errores en tiempo de ejecución cuando intentamos acceder a estos valores nulos. Rust aborda este problema de manera elegante con el tipo Option<T>.

Option<T> es un enum genérico definido en la biblioteca estándar que representa un valor opcional. Puede contener un valor de tipo T o ningún valor. Su definición es sorprendentemente simple:

enum Option<T> {
    None,    // No hay valor
    Some(T), // Contiene un valor de tipo T
}

Este tipo viene preimportado en Rust, lo que significa que no necesitas incluir ninguna declaración use para utilizarlo. Puedes usar directamente Option, Some y None en tu código.

Creando y utilizando Option

Veamos cómo crear y trabajar con valores Option:

// Creando Options
let nombre_completo = Some("Ana García");
let nombre_medio: Option<&str> = None;

// Verificando si contiene un valor
if nombre_completo.is_some() {
    println!("Tenemos un nombre completo");
}

if nombre_medio.is_none() {
    println!("No hay nombre medio");
}

La verdadera fortaleza de Option es que el compilador te obliga a manejar tanto el caso Some como el caso None. No puedes simplemente asumir que un valor existe, lo que elimina una categoría entera de errores comunes.

Pattern matching con Option

La forma más expresiva de trabajar con Option es mediante pattern matching:

fn saludar(nombre: Option<&str>) {
    match nombre {
        Some(n) => println!("¡Hola, {}!", n),
        None => println!("¡Hola, desconocido!"),
    }
}

saludar(Some("Carlos"));  // Imprime: ¡Hola, Carlos!
saludar(None);            // Imprime: ¡Hola, desconocido!

El pattern matching nos asegura que manejamos todos los posibles casos, lo que hace que nuestro código sea más robusto y menos propenso a errores.

Métodos útiles de Option

Option<T> incluye numerosos métodos que facilitan trabajar con valores opcionales sin necesidad de pattern matching explícito:

  • unwrap(): Extrae el valor si existe, o causa un pánico si es None:
let x = Some(5);
let valor = x.unwrap();  // valor = 5

// let y: Option<i32> = None;
// let valor = y.unwrap();  // ¡PÁNICO! Esto causaría un error en tiempo de ejecución
  • expect(): Similar a unwrap(), pero permite personalizar el mensaje de error:
let archivo = Some("config.txt");
let nombre = archivo.expect("El archivo de configuración no existe");
  • unwrap_or(): Extrae el valor o devuelve un valor predeterminado si es None:
let configuracion = None;
let modo = configuracion.unwrap_or("desarrollo");  // modo = "desarrollo"
  • unwrap_or_else(): Similar a unwrap_or(), pero acepta una función para calcular el valor predeterminado:
let usuario_id: Option<i32> = None;
let id = usuario_id.unwrap_or_else(|| {
    println!("Generando ID temporal...");
    generar_id_temporal()
});

fn generar_id_temporal() -> i32 {
    // Lógica para generar un ID
    42
}

Transformando Options con map y and_then

Para transformar el valor dentro de un Option sin desenvolverlo, podemos usar métodos como map y and_then:

  • map(): Aplica una función al valor contenido si es Some, dejando None sin cambios:
let texto = Some("42");
let numero = texto.map(|s| s.parse::<i32>().unwrap());
// numero = Some(42)

let vacio: Option<&str> = None;
let resultado = vacio.map(|s| s.len());
// resultado = None
  • and_then(): Similar a map, pero la función debe devolver un Option:
fn buscar_usuario(id: i32) -> Option<String> {
    // Simulamos una búsqueda en base de datos
    if id > 0 {
        Some(format!("Usuario_{}", id))
    } else {
        None
    }
}

let id_usuario = Some(5);
let nombre = id_usuario.and_then(buscar_usuario);
// nombre = Some("Usuario_5")

let id_invalido = Some(-1);
let resultado = id_invalido.and_then(buscar_usuario);
// resultado = None

Combinando Options

También podemos combinar múltiples Option con métodos como and y or:

let a = Some(2);
let b = Some(3);
let c: Option<i32> = None;

let resultado1 = a.and(b);  // Some(3) - Devuelve el segundo si ambos son Some
let resultado2 = a.and(c);  // None - Si alguno es None, devuelve None

let resultado3 = a.or(b);   // Some(2) - Devuelve el primero que sea Some
let resultado4 = c.or(b);   // Some(3) - Devuelve el primero que sea Some

Ejemplo práctico: Acceso a un diccionario

Un caso de uso común para Option es al acceder a elementos en una colección, como un HashMap:

use std::collections::HashMap;

fn main() {
    let mut usuarios = HashMap::new();
    usuarios.insert("admin", "Administrador");
    usuarios.insert("user1", "Usuario Normal");
    
    // get() devuelve Option<&V>
    match usuarios.get("admin") {
        Some(nombre) => println!("Encontrado: {}", nombre),
        None => println!("Usuario no encontrado"),
    }
    
    // Forma más concisa usando if let
    if let Some(nombre) = usuarios.get("invitado") {
        println!("Encontrado: {}", nombre);
    } else {
        println!("Usuario invitado no encontrado");
    }
    
    // Usando unwrap_or para proporcionar un valor predeterminado
    let nombre = usuarios.get("user1").unwrap_or(&"Desconocido");
    println!("Nombre: {}", nombre);  // Imprime: Nombre: Usuario Normal
}

Cuándo usar Option

Option<T> es ideal para:

  • Valores que pueden estar ausentes
  • Parámetros opcionales en funciones
  • Valores de retorno que podrían no existir
  • Campos opcionales en estructuras
struct Usuario {
    id: i32,
    nombre: String,
    email: String,
    telefono: Option<String>,  // Campo opcional
}

fn buscar_por_email(email: &str) -> Option<Usuario> {
    // Si encontramos el usuario, devolvemos Some(usuario)
    // Si no, devolvemos None
    if email == "ejemplo@correo.com" {
        Some(Usuario {
            id: 1,
            nombre: String::from("Ejemplo"),
            email: String::from(email),
            telefono: None,
        })
    } else {
        None
    }
}

Al usar Option<T>, Rust nos obliga a considerar explícitamente el caso donde un valor podría estar ausente, lo que hace que nuestro código sea más seguro y predecible. Esta es una de las formas en que Rust elimina errores comunes en tiempo de compilación, en lugar de permitir que se manifiesten en tiempo de ejecución.

Result<T,E>

Mientras que Option<T> nos ayuda a manejar la ausencia de valores, Rust ofrece otro tipo algebraico llamado Result<T,E> que está diseñado específicamente para operaciones que pueden fallar. En lugar de usar excepciones como otros lenguajes, Rust encapsula los posibles errores en este tipo.

Result<T,E> es un enum genérico con dos variantes:

enum Result<T, E> {
    Ok(T),    // Operación exitosa con valor de tipo T
    Err(E),   // Error con valor de tipo E
}

Al igual que Option, el tipo Result viene preimportado en el preludio de Rust, por lo que puedes usarlo directamente sin ninguna declaración use.

Trabajando con Result

Veamos un ejemplo básico de cómo se utiliza Result:

fn dividir(numerador: f64, denominador: f64) -> Result<f64, String> {
    if denominador == 0.0 {
        Err(String::from("No se puede dividir por cero"))
    } else {
        Ok(numerador / denominador)
    }
}

// Uso de la función
let resultado = dividir(10.0, 2.0);
let resultado_error = dividir(5.0, 0.0);

La función dividir devuelve un Result que contiene un f64 en caso de éxito o un String con un mensaje de error en caso de fallo.

Pattern matching con Result

La forma más completa de manejar un Result es mediante pattern matching:

match dividir(10.0, 2.0) {
    Ok(valor) => println!("El resultado es: {}", valor),
    Err(e) => println!("Error: {}", e),
}

// Con la división por cero
match dividir(5.0, 0.0) {
    Ok(valor) => println!("El resultado es: {}", valor),
    Err(e) => println!("Error: {}", e),  // Imprime: Error: No se puede dividir por cero
}

Métodos útiles de Result

Result<T,E> incluye varios métodos que facilitan su manejo:

  • unwrap(): Extrae el valor si es Ok, o causa un pánico si es Err:
let x: Result<i32, &str> = Ok(5);
let valor = x.unwrap();  // valor = 5

// let y: Result<i32, &str> = Err("Error");
// let valor = y.unwrap();  // ¡PÁNICO! Termina el programa con el mensaje de error
  • expect(): Similar a unwrap(), pero con un mensaje de error personalizado:
let archivo_resultado = std::fs::File::open("config.txt");
let archivo = archivo_resultado.expect("No se pudo abrir el archivo de configuración");
  • unwrap_or(): Extrae el valor o devuelve un valor predeterminado si es Err:
let x: Result<i32, &str> = Err("Error");
let valor = x.unwrap_or(42);  // valor = 42
  • unwrap_or_else(): Similar a unwrap_or(), pero acepta una función para calcular el valor predeterminado:
let x: Result<i32, &str> = Err("Error de formato");
let valor = x.unwrap_or_else(|error| {
    println!("Ocurrió un error: {}", error);
    -1  // Valor por defecto en caso de error
});

Transformando Results

Al igual que con Option, podemos transformar los valores dentro de un Result sin desenvolverlos:

  • map(): Aplica una función al valor contenido si es Ok, dejando Err sin cambios:
let texto_resultado: Result<&str, &str> = Ok("42");
let numero_resultado = texto_resultado.map(|s| s.len());
// numero_resultado = Ok(2)

let error_resultado: Result<&str, &str> = Err("error");
let resultado = error_resultado.map(|s| s.len());
// resultado = Err("error")
  • map_err(): Similar a map, pero transforma el error:
fn abrir_archivo(ruta: &str) -> Result<std::fs::File, String> {
    std::fs::File::open(ruta).map_err(|e| format!("Error al abrir '{}': {}", ruta, e))
}
  • and_then(): Encadena operaciones que pueden fallar:
fn leer_entero_desde_archivo(ruta: &str) -> Result<i32, String> {
    // Primero intentamos abrir el archivo
    std::fs::File::open(ruta)
        .map_err(|e| format!("Error al abrir el archivo: {}", e))
        .and_then(|mut archivo| {
            // Luego intentamos leer su contenido
            let mut contenido = String::new();
            std::io::Read::read_to_string(&mut archivo, &mut contenido)
                .map_err(|e| format!("Error al leer el archivo: {}", e))
                .and_then(|_| {
                    // Finalmente intentamos convertir el contenido a un entero
                    contenido.trim().parse::<i32>()
                        .map_err(|e| format!("Error al parsear el número: {}", e))
                })
        })
}

Combinando Results

También podemos combinar múltiples Result con métodos como and y or:

let a: Result<i32, &str> = Ok(2);
let b: Result<&str, &str> = Ok("valor");
let c: Result<i32, &str> = Err("error en c");

let resultado1 = a.and(b);  // Ok("valor") - Si ambos son Ok, devuelve el segundo
let resultado2 = a.and(c);  // Err("error en c") - Si alguno es Err, devuelve ese Err

let resultado3 = a.or(c);   // Ok(2) - Devuelve el primer Ok
let resultado4 = c.or(a);   // Ok(2) - Devuelve el primer Ok

Ejemplo práctico: Lectura de archivos

Un caso de uso común para Result es la lectura de archivos, que puede fallar por diversas razones:

use std::fs::File;
use std::io::{self, Read};

fn leer_archivo(ruta: &str) -> Result<String, io::Error> {
    let mut archivo = File::open(ruta)?;  // Usamos el operador ? (lo veremos en detalle más adelante)
    let mut contenido = String::new();
    archivo.read_to_string(&mut contenido)?;
    Ok(contenido)
}

fn main() {
    match leer_archivo("datos.txt") {
        Ok(contenido) => println!("Contenido del archivo:\n{}", contenido),
        Err(e) => println!("Error al leer el archivo: {}", e),
    }
}

Ejemplo: Acceso a un HashMap con conversión de tipos

Combinando Option y Result podemos manejar operaciones complejas de forma segura:

use std::collections::HashMap;

fn obtener_edad(usuarios: &HashMap<String, String>, nombre: &str) -> Result<u32, String> {
    // get() devuelve Option<&String>
    let edad_str = usuarios.get(nombre)
        .ok_or(format!("Usuario '{}' no encontrado", nombre))?;
    
    // parse() devuelve Result<u32, ParseIntError>
    edad_str.parse::<u32>()
        .map_err(|e| format!("La edad '{}' no es un número válido: {}", edad_str, e))
}

fn main() {
    let mut usuarios = HashMap::new();
    usuarios.insert(String::from("Ana"), String::from("28"));
    usuarios.insert(String::from("Carlos"), String::from("treinta"));
    
    match obtener_edad(&usuarios, "Ana") {
        Ok(edad) => println!("Ana tiene {} años", edad),
        Err(e) => println!("Error: {}", e),
    }
    
    match obtener_edad(&usuarios, "Carlos") {
        Ok(edad) => println!("Carlos tiene {} años", edad),
        Err(e) => println!("Error: {}", e),  // Error: La edad 'treinta' no es un número válido
    }
    
    match obtener_edad(&usuarios, "David") {
        Ok(edad) => println!("David tiene {} años", edad),
        Err(e) => println!("Error: {}", e),  // Error: Usuario 'David' no encontrado
    }
}

Cuándo usar Result

Result<T,E> es ideal para:

  • Operaciones que pueden fallar, como E/S de archivos
  • Parseo de datos
  • Conexiones de red
  • Consultas a bases de datos
  • Cualquier función donde quieras comunicar explícitamente posibles errores
struct Usuario {
    id: i32,
    nombre: String,
}

enum DatabaseError {
    Conexion(String),
    Consulta(String),
    NoEncontrado,
}

fn buscar_usuario(id: i32) -> Result<Usuario, DatabaseError> {
    // Simulamos una búsqueda en base de datos
    if id < 0 {
        return Err(DatabaseError::Consulta(String::from("ID no puede ser negativo")));
    }
    
    if id == 0 {
        return Err(DatabaseError::NoEncontrado);
    }
    
    Ok(Usuario {
        id,
        nombre: format!("Usuario_{}", id),
    })
}

El uso de Result hace que los errores sean valores que deben ser manejados explícitamente, lo que lleva a un código más robusto y a una mejor experiencia para los usuarios de tu código. A diferencia de las excepciones en otros lenguajes, los errores en Rust son parte del sistema de tipos, lo que permite al compilador verificar que todos los posibles errores sean manejados adecuadamente.

Operador ?

El manejo de errores en Rust puede volverse verboso cuando encadenamos múltiples operaciones que pueden fallar. Para simplificar este código, Rust ofrece el operador ?, una herramienta concisa y expresiva que nos permite propagar errores de forma elegante.

El operador ? es esencialmente una forma abreviada de hacer pattern matching sobre un Result o un Option. Cuando se coloca después de una expresión que devuelve uno de estos tipos, funciona de la siguiente manera:

  • Si el valor es Ok(v) o Some(v), extrae v y continúa la ejecución
  • Si el valor es Err(e) o None, retorna inmediatamente ese error o None desde la función actual

Uso básico con Result

Veamos cómo el operador ? simplifica el manejo de errores con Result:

use std::fs::File;
use std::io::{self, Read};

// Sin el operador ?
fn leer_archivo_verboso(ruta: &str) -> Result<String, io::Error> {
    let archivo_resultado = File::open(ruta);
    let mut archivo = match archivo_resultado {
        Ok(archivo) => archivo,
        Err(e) => return Err(e),
    };
    
    let mut contenido = String::new();
    match archivo.read_to_string(&mut contenido) {
        Ok(_) => Ok(contenido),
        Err(e) => Err(e),
    }
}

// Con el operador ?
fn leer_archivo_conciso(ruta: &str) -> Result<String, io::Error> {
    let mut archivo = File::open(ruta)?;
    let mut contenido = String::new();
    archivo.read_to_string(&mut contenido)?;
    Ok(contenido)
}

La versión con el operador ? es mucho más limpia y legible, manteniendo la misma funcionalidad. Cada vez que aparece ?, Rust está realizando implícitamente el pattern matching y la propagación del error.

Encadenando operaciones con ?

El operador ? brilla especialmente cuando necesitamos encadenar múltiples operaciones que pueden fallar:

use std::fs::File;
use std::io::{self, Read};
use std::path::Path;

fn leer_configuracion() -> Result<String, io::Error> {
    let mut config = String::new();
    
    // Intentamos leer de varios archivos posibles
    let ruta_config = if Path::new("config.local.txt").exists() {
        "config.local.txt"
    } else {
        "config.default.txt"
    };
    
    File::open(ruta_config)?
        .read_to_string(&mut config)?;
    
    Ok(config)
}

Cada operación que puede fallar está seguida por ?, y si cualquiera falla, la función retorna inmediatamente con el error.

Uso con Option

El operador ? también funciona con Option<T>, propagando None cuando lo encuentra:

fn primer_linea_no_vacia(texto: &str) -> Option<&str> {
    for linea in texto.lines() {
        let linea_limpia = linea.trim();
        if !linea_limpia.is_empty() {
            return Some(linea_limpia);
        }
    }
    None
}

fn obtener_longitud_primera_linea(texto: &str) -> Option<usize> {
    let primera = primer_linea_no_vacia(texto)?;
    Some(primera.len())
}

En este ejemplo, si primer_linea_no_vacia devuelve None, la función obtener_longitud_primera_linea también devolverá None inmediatamente.

Restricciones del operador ?

Es importante entender que el operador ? solo puede usarse en funciones que devuelven un tipo compatible:

  • Para usar ? con Result<T, E>, la función debe devolver Result<U, E> donde E es el mismo tipo de error
  • Para usar ? con Option<T>, la función debe devolver Option<U>
// Esto compila: la función devuelve Result
fn procesar_archivo(ruta: &str) -> Result<(), std::io::Error> {
    let datos = std::fs::read_to_string(ruta)?;
    println!("Contenido: {}", datos);
    Ok(())
}

// Esto NO compila: main() no devuelve Result
/*
fn main() {
    let datos = std::fs::read_to_string("datos.txt")?;  // Error de compilación
    println!("Contenido: {}", datos);
}
*/

// Esto SÍ compila: main() devuelve Result
fn main() -> Result<(), std::io::Error> {
    let datos = std::fs::read_to_string("datos.txt")?;
    println!("Contenido: {}", datos);
    Ok(())
}

Conversión implícita de tipos de error

Una característica poderosa del operador ? es que puede realizar conversiones implícitas entre diferentes tipos de error, siempre que exista una implementación de From<E> para el tipo de error de destino:

use std::fs;
use std::io;
use std::num::ParseIntError;

// Definimos nuestro propio tipo de error
#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    Parse(ParseIntError),
    Missing,
}

// Implementamos conversiones desde otros tipos de error
impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> Self {
        ConfigError::Io(err)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(err: ParseIntError) -> Self {
        ConfigError::Parse(err)
    }
}

fn leer_timeout_config(ruta: &str) -> Result<u64, ConfigError> {
    // El operador ? convertirá io::Error a ConfigError automáticamente
    let contenido = fs::read_to_string(ruta)?;
    
    let lineas: Vec<&str> = contenido.lines().collect();
    let timeout_line = lineas.get(0).ok_or(ConfigError::Missing)?;
    
    // El operador ? convertirá ParseIntError a ConfigError automáticamente
    let timeout = timeout_line.parse::<u64>()?;
    
    Ok(timeout)
}

En este ejemplo, el operador ? convierte automáticamente tanto io::Error como ParseIntError a nuestro tipo personalizado ConfigError, gracias a las implementaciones de From.

Ejemplo práctico: Validación de datos

Veamos un ejemplo más completo que combina Option y Result con el operador ?:

use std::collections::HashMap;
use std::num::ParseIntError;

#[derive(Debug)]
enum ValidacionError {
    Faltante(String),
    FormatoInvalido(ParseIntError),
    ValorInvalido(String),
}

impl From<ParseIntError> for ValidacionError {
    fn from(err: ParseIntError) -> Self {
        ValidacionError::FormatoInvalido(err)
    }
}

fn validar_usuario(datos: &HashMap<String, String>) -> Result<(), ValidacionError> {
    // Verificar que existe el campo "edad"
    let edad_str = datos.get("edad")
        .ok_or(ValidacionError::Faltante("edad".to_string()))?;
    
    // Convertir a número
    let edad = edad_str.parse::<u8>()?;
    
    // Validar el valor
    if edad < 18 {
        return Err(ValidacionError::ValorInvalido(
            format!("La edad debe ser al menos 18, pero es {}", edad)
        ));
    }
    
    // Verificar que existe el campo "email"
    let email = datos.get("email")
        .ok_or(ValidacionError::Faltante("email".to_string()))?;
    
    // Validar formato de email (simplificado)
    if !email.contains('@') {
        return Err(ValidacionError::ValorInvalido(
            format!("Email inválido: {}", email)
        ));
    }
    
    Ok(())
}

fn main() {
    let mut usuario = HashMap::new();
    usuario.insert("nombre".to_string(), "Ana".to_string());
    usuario.insert("edad".to_string(), "25".to_string());
    usuario.insert("email".to_string(), "ana@ejemplo.com".to_string());
    
    match validar_usuario(&usuario) {
        Ok(()) => println!("Usuario válido"),
        Err(e) => println!("Error de validación: {:?}", e),
    }
}

Cuándo usar el operador ?

El operador ? es ideal para:

  • Funciones que realizan múltiples operaciones falibles en secuencia
  • Código que necesita propagar errores hacia arriba en la pila de llamadas
  • Situaciones donde la lógica de manejo de errores es uniforme

Sin embargo, hay casos donde es mejor usar pattern matching explícito:

  • Cuando necesitas manejar diferentes tipos de errores de manera distinta
  • Cuando quieres recuperarte de ciertos errores pero propagar otros
  • Cuando necesitas transformar o enriquecer los errores con información adicional
fn procesar_entrada(texto: &str) -> Result<i32, String> {
    // Usamos match en lugar de ? porque queremos personalizar el mensaje de error
    match texto.parse::<i32>() {
        Ok(n) if n >= 0 => Ok(n),
        Ok(_) => Err("El número debe ser positivo".to_string()),
        Err(e) => Err(format!("Error de formato: {}", e)),
    }
}

Directrices para el manejo de errores

Al trabajar con el operador ? y el manejo de errores en Rust, considera estas directrices:

  • Usa ? para propagar errores cuando no puedes manejarlos localmente
  • Prefiere ? sobre unwrap() o expect() en código de producción
  • Crea tipos de error personalizados para APIs públicas
  • Implementa From<E> para tus tipos de error para facilitar la conversión
  • Usa pattern matching explícito cuando necesites lógica de manejo de errores específica

El operador ? es una de las características más elegantes de Rust, que permite escribir código robusto y a prueba de errores sin sacrificar la legibilidad. Dominar su uso te permitirá escribir código más conciso y expresivo mientras mantienes la seguridad y robustez que Rust ofrece.

Aprende Rust online

Otras lecciones de Rust

Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Rust y certifícate

Ejercicios de programación de Rust

Evalúa tus conocimientos de esta lección Option y Result con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender el propósito y uso del tipo Option para representar valores opcionales sin usar null.
  • Aprender a manejar el tipo Result<T, E> para representar operaciones que pueden fallar y gestionar errores explícitamente.
  • Utilizar pattern matching y métodos asociados para trabajar con Option y Result de forma segura y expresiva.
  • Aplicar el operador ? para simplificar la propagación de errores en funciones que devuelven Option o Result.
  • Identificar cuándo y cómo usar Option, Result y el operador ? en situaciones reales de programación en Rust.