Funciones, structs, enums y módulos básicos en Rust — Capítulo 8

Fundamentos para la organización del código en Rust


En el contexto de un libro sobre programación en Rust, este capítulo introduce los elementos básicos para estructurar código: funciones, structs, enums y módulos. Estos componentes permiten definir comportamientos reutilizables, modelar datos compuestos y organizar el código en unidades lógicas, sentando las bases para programas más complejos sin adentrarse en abstracciones avanzadas como traits o generics. Su comprensión es esencial para pasar de scripts simples a aplicaciones modulares, facilitando la mantenibilidad y la reutilización en proyectos reales.

Definición de funciones

Las funciones en Rust se definen mediante la palabra clave fn, seguida del nombre de la función, paréntesis para los parámetros y un bloque de código delimitado por llaves. Cada función debe especificar los tipos de sus parámetros y, si devuelve un valor, el tipo de retorno mediante una flecha (->). Esta tipificación estática asegura que el compilador verifique la coherencia en tiempo de compilación, evitando errores comunes en lenguajes dinámicos como Python.

Por ejemplo, una función simple que suma dos enteros se define así:

fn suma(a: i32, b: i32) -> i32 {
    a + b
}

Aquí, a y b son parámetros de tipo i32, y el valor de retorno es implícitamente la última expresión del bloque, sin necesidad de una sentencia return explícita. Si se requiere un retorno prematuro, se puede usar return seguido de una expresión. Las funciones sin valor de retorno implícitamente devuelven el tipo unitario (), equivalente a void en otros lenguajes.

Es importante destacar que las funciones en Rust son first-class en el sentido de que pueden ser pasadas como argumentos o devueltas por otras funciones, aunque esta capacidad se explora más adelante. Un caso borde surge con funciones que no terminan, marcadas con -> ! para indicar divergencia, como en bucles infinitos:

fn diverge() -> ! {
    loop {}
}

En comparación con C++, donde las funciones pueden tener firmas similares pero carecen de la verificación de ownership inherente a Rust, esta definición obliga a considerar la semántica de borrow y move desde el principio, aunque estos aspectos se tratan en capítulos previos.

Las funciones también admiten parámetros mutables, especificados con mut antes del nombre del parámetro, permitiendo modificaciones locales sin afectar el ownership global a menos que se use &mut. Por instancia:

fn incrementar(mut x: i32) -> i32 {
    x += 1;
    x
}

Esta rigurosidad en la definición previene errores sutiles, como mutaciones inesperadas, que son frecuentes en lenguajes con referencias implícitas.

Structs con campos

Las structs en Rust proporcionan un mecanismo para definir tipos de datos compuestos, agrupando campos con nombres y tipos específicos. Se declaran con la palabra clave struct, seguida del nombre del tipo y un bloque de campos separados por comas. Cada campo tiene un identificador y un tipo, permitiendo modelar entidades del mundo real de manera tipada y segura.

Una struct básica para representar un punto en un plano cartesiano se define como sigue:

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

Para instanciar una struct, se usa el nombre del tipo seguido de llaves con inicializaciones de campos:

let origen = Punto { x: 0.0, y: 0.0 };

Los campos son accesibles mediante notación de punto (.x), y pueden ser mutables si la instancia se declara con mut. Rust distingue entre structs con campos nombrados (como la anterior), structs tuple (sin nombres, accedidas por índice) y structs unitarias (sin campos, similares a un enum vacío).

Un detalle sutil es que las structs heredan las reglas de ownership: al asignar una instancia a otra variable, se produce un move por defecto, invalidando la original a menos que se implemente Copy (no cubierto aquí). En comparación con clases en Java, las structs de Rust son más livianas, sin herencia ni métodos implícitos, enfocándose en composición de datos pura.

Para casos borde, considere structs con campos de tipos referenciales:

struct Referencia<'a> {
    texto: &'a str,
}

Aquí, el lifetime 'a asegura que la referencia no sobreviva al dato original, un concepto introducido en capítulos anteriores. Las structs también permiten actualización funcional mediante sintaxis de spread (..), como en:

let punto_actualizado = Punto { x: 1.0, ..origen };

Esta característica facilita la creación de variantes sin repetir inicializaciones, promoviendo código conciso y expresivo.

Enums simples

Los enums en Rust definen tipos que pueden tomar uno de varios variantes posibles, cada uno potencialmente asociado a datos. Se declaran con enum, seguido del nombre y un bloque de variantes separadas por comas. A diferencia de enums en C, que son meros enteros, los enums de Rust son sum types o tipos discriminados, capaces de llevar payloads de tipos arbitrarios.

Un enum simple para representar resultados de una operación podría ser:

enum Resultado {
    Exito(i32),
    Error(String),
}

Cada variante actúa como un constructor: Resultado::Exito(42) crea una instancia con un entero, mientras que Resultado::Error("fallo".to_string()) asocia una cadena. Para inspeccionar un enum, se usa pattern matching con match, que obliga a cubrir todos los casos exhaustivamente:

fn procesar(resultado: Resultado) {
    match resultado {
        Resultado::Exito(valor) => println!("Éxito: {}", valor),
        Resultado::Error(mensaje) => println!("Error: {}", mensaje),
    }
}

Enums unitarios (sin datos) son útiles para estados simples, como enum Estado { Activo, Inactivo }. Un caso borde involucra enums recursivos, pero estos requieren indirección con Box para evitar tamaños infinitos, aunque no se profundiza aquí.

En comparación con unions en C++, los enums de Rust proporcionan seguridad de tipos, ya que el compilador verifica que solo se acceda a los datos de la variante activa. Otro detalle es la posibilidad de enums con una sola variante, equivalentes a structs tuple, pero útiles para extensibilidad futura.

Mod + use básico

Los módulos en Rust organizan el código en namespaces jerárquicos, definidos con mod seguido de un nombre y un bloque de ítems. Esto permite encapsular funciones, structs y enums, controlando su visibilidad mediante modificadores como pub. Para importar ítems de un módulo, se usa use con el camino cualificado.

Por ejemplo, un módulo simple se define así:

mod matematica {
    pub fn suma(a: i32, b: i32) -> i32 {
        a + b
    }
}

Para usarlo: let resultado = matematica::suma(1, 2);. Con use, se abrevia: use matematica::suma;, permitiendo llamar directamente suma(1, 2). Los módulos pueden anidarse, como mod externa { pub mod interna { ... } }, y se accede con :: para separación.

Un aspecto clave es la visibilidad: ítems sin pub son privados al módulo, promoviendo encapsulación similar a paquetes en Java pero con granularidad fina. Para módulos en archivos separados, mod declara el módulo y carga el archivo correspondiente (e.g., mod matematica; carga matematica.rs).

Use permite alias con as, como use std::collections::HashMap as Mapa;, y patrones glob, aunque limitados en este contexto básico. Un caso sutil surge con reexportaciones: un módulo puede hacer pub use para exponer ítems internos públicamente.

Proyecto completo: Agenda en memoria

Para ilustrar la integración de funciones, structs, enums y módulos, se presenta un proyecto simple: una agenda en memoria que realiza operaciones CRUD (Create, Read, Update, Delete) básicas utilizando un Vec para almacenar entradas.

Estructura de carpetas

agenda/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── agenda.rs
    └── entrada.rs

Contenido de los archivos

Cargo.toml

[package]
name = "agenda"
version = "0.1.0"
edition = "2021"

[dependencies]

src/main.rs

mod agenda;
mod entrada;

use agenda::{crear_entrada, eliminar_entrada, leer_entradas, actualizar_entrada};
use entrada::{Entrada, Prioridad};

fn main() {
    let mut agenda_vec: Vec<Entrada> = Vec::new();

    // Create
    crear_entrada(&mut agenda_vec, "Reunión", "2023-10-01", Prioridad::Alta);

    // Read
    let entradas = leer_entradas(&agenda_vec);
    for entrada in entradas {
        println!("{:?} - {} - {:?}", entrada.descripcion, entrada.fecha, entrada.prioridad);
    }

    // Update
    if let Some(entrada) = agenda_vec.get_mut(0) {
        actualizar_entrada(entrada, "Reunión actualizada", "2023-10-02", Prioridad::Baja);
    }

    // Delete
    eliminar_entrada(&mut agenda_vec, 0);
}

src/entrada.rs

#[derive(Debug)]
pub struct Entrada {
    pub descripcion: String,
    pub fecha: String,
    pub prioridad: Prioridad,
}

#[derive(Debug)]
pub enum Prioridad {
    Alta,
    Media,
    Baja,
}

src/agenda.rs

use crate::entrada::{Entrada, Prioridad};

pub fn crear_entrada(agenda: &mut Vec<Entrada>, desc: &str, fecha: &str, prio: Prioridad) {
    let nueva = Entrada {
        descripcion: desc.to_string(),
        fecha: fecha.to_string(),
        prioridad: prio,
    };
    agenda.push(nueva);
}

pub fn leer_entradas(agenda: &Vec<Entrada>) -> Vec<&Entrada> {
    agenda.iter().collect()
}

pub fn actualizar_entrada(entrada: &mut Entrada, nueva_desc: &str, nueva_fecha: &str, nueva_prio: Prioridad) {
    entrada.descripcion = nueva_desc.to_string();
    entrada.fecha = nueva_fecha.to_string();
    entrada.prioridad = nueva_prio;
}

pub fn eliminar_entrada(agenda: &mut Vec<Entrada>, indice: usize) {
    if indice < agenda.len() {
        agenda.remove(indice);
    }
}

Este proyecto demuestra cómo las funciones encapsuladas en módulos operan sobre structs y enums, manteniendo una separación clara de preocupaciones.

Estos fundamentos preparan el terreno para explorar patrones de ownership más avanzados y estructuras de datos estándar en el capítulo siguiente.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio