Structs y enums en Rust

Aprende Rust → Lección 4

Rust no tiene clases ni herencia. Para describir datos ofrece dos piezas y nada más: el struct, que junta varios valores que van siempre juntos, y el enum, que dice que un valor es una cosa o otra. Parecen pocos materiales, pero con ellos se modela todo, y la elección entre uno y otro es la decisión de diseño más frecuente que vas a tomar.

El struct: cosas que van juntas

Un struct agrupa valores bajo un nombre. La forma más común da un nombre a cada campo:

struct Usuario {
    nombre: String,
    correo: String,
    activo: bool,
    visitas: u32,
}

fn main() {
    let mut u = Usuario {
        nombre: String::from("Ada"),
        correo: String::from("ada@ejemplo.com"),
        activo: true,
        visitas: 1,
    };

    u.visitas += 1;
    println!("{} tiene {} visitas", u.nombre, u.visitas);
}

Dos detalles que sorprenden al venir de otros lenguajes. El primero es que la mutabilidad es de la variable entera, no de cada campo: o u es mutable y se pueden cambiar todos sus campos, o no lo es y no se puede cambiar ninguno. No existe un campo mutable dentro de un valor inmutable. El segundo es que no hay valores por defecto implícitos; al construirlo hay que dar todos los campos, sin excepción.

Hay otras dos formas de struct, menos frecuentes pero útiles:

Las tres formas de struct Con campos con nombre struct Punto { x, y } Lo normal. Cada dato se lee por su nombre. Struct tupla struct Metros(f64); Campos por posición. Da un tipo propio a un valor. Struct unitario struct Marcador; Sin datos. Solo importa que el tipo exista. Las tres son structs de pleno derecho: admiten métodos, se pueden desestructurar y ocupan lo que ocupan sus campos.
El struct tupla se usa muchísimo para envolver un valor y que el compilador no lo confunda con otro del mismo tipo.

Esa última idea merece un ejemplo, porque es un hábito muy rentable. Si una función recibe dos f64, nada impide pasarlos en el orden equivocado. Si recibe un Metros y un Segundos, el error deja de ser posible:

struct Metros(f64);
struct Segundos(f64);

fn velocidad(d: Metros, t: Segundos) -> f64 {
    d.0 / t.0
}

fn main() {
    let v = velocidad(Metros(100.0), Segundos(9.58));
    println!("{v:.2} m/s");
}

Se accede a los campos por su posición: d.0 es el primero. Y el intento de llamar a velocidad(Segundos(9.58), Metros(100.0)) ya no compila, cuando con dos f64 habría pasado inadvertido hasta ver el resultado.

Atajos al construir

Cuando la variable se llama igual que el campo, se puede escribir una sola vez. Y para partir de otro valor y cambiar algunos campos existe la sintaxis de actualización:

struct Config {
    host: String,
    puerto: u16,
    depurar: bool,
}

fn crear(host: String, puerto: u16) -> Config {
    Config { host, puerto, depurar: false }
}

fn main() {
    let base = crear(String::from("localhost"), 8080);
    let pruebas = Config { depurar: true, ..base };

    println!("{}:{} depurar={}", pruebas.host, pruebas.puerto, pruebas.depurar);
}

En crear, host y puerto aprovechan el atajo: como la variable y el campo comparten nombre, basta con nombrarlos. Y ..base rellena los campos que falten copiándolos de base.

Ojo con ..base: no es una copia. Los campos que no sean Copy —aquí host, que es un String— se mueven a la estructura nueva. Después de esa línea, base ya no se puede usar entero. Es la regla de propiedad de la lección 2 aplicada campo a campo, y pilla a todo el mundo la primera vez.

Darle comportamiento: el bloque impl

Los métodos no van dentro de la declaración del tipo, sino en un bloque aparte. Esto permite tener varios bloques, y separar los datos de lo que se hace con ellos.

struct Rectangulo {
    ancho: f64,
    alto: f64,
}

impl Rectangulo {
    fn nuevo(ancho: f64, alto: f64) -> Rectangulo {
        Rectangulo { ancho, alto }
    }

    fn area(&self) -> f64 {
        self.ancho * self.alto
    }

    fn escalar(&mut self, factor: f64) {
        self.ancho *= factor;
        self.alto *= factor;
    }
}

fn main() {
    let mut r = Rectangulo::nuevo(3.0, 4.0);
    println!("área inicial: {}", r.area());

    r.escalar(2.0);
    println!("área doblada: {}", r.area());
}

La diferencia entre las tres firmas es toda la historia de la propiedad en miniatura, y conviene tenerla clara:

Primer parámetro Qué recibe Para qué
&self Un préstamo de solo lectura Consultar sin modificar. Lo más común con diferencia.
&mut self Un préstamo que permite modificar Cambiar el valor en su sitio.
self El valor entero, en propiedad Consumirlo para transformarlo en otra cosa.
ninguno Nada: no actúa sobre una instancia Constructores y utilidades, como Rectangulo::nuevo.

Una función sin self se llama con Tipo::funcion() en vez de con punto. Rust no tiene constructores especiales: nuevo es una función corriente, y el nombre es solo una convención de la comunidad.

El enum: una cosa o la otra

Aquí está la diferencia que conviene interiorizar. Un struct dice que un valor tiene esto y esto y esto, todo a la vez. Un enum dice que es esto o esto o esto, exactamente uno.

struct es Y, enum es O struct todos los campos, a la vez nombre edad correo Un usuario tiene nombre Y edad Y correo: los tres valores existen al mismo tiempo. enum exactamente una variante Rojo Amarillo Verde Un semáforo está en rojo O en ámbar O en verde, nunca en dos a la vez. De esa exclusividad sale la comprobación del compilador: como solo puede ser una, puede exigirte que las contemples todas.
En la jerga se les llama tipos producto y tipos suma. El nombre da igual; lo que importa es el «y» frente al «o».

Lo que hace potentes a los enums de Rust es que cada variante puede llevar datos propios, y de forma distinta:

enum Forma {
    Circulo(f64),
    Rectangulo { ancho: f64, alto: f64 },
    Punto,
}

impl Forma {
    fn area(&self) -> f64 {
        match self {
            Forma::Circulo(radio) => std::f64::consts::PI * radio * radio,
            Forma::Rectangulo { ancho, alto } => ancho * alto,
            Forma::Punto => 0.0,
        }
    }
}

fn main() {
    let formas = vec![
        Forma::Circulo(1.0),
        Forma::Rectangulo { ancho: 3.0, alto: 4.0 },
        Forma::Punto,
    ];

    for f in &formas {
        println!("{:.4}", f.area());
    }
}

Fíjate en que las tres variantes guardan cosas distintas —un número, dos campos con nombre, nada— y aun así caben en el mismo vector, porque todas son Forma. Los enums también admiten impl, así que el comportamiento vive junto al tipo igual que en un struct.

Esto ya lo conocías. Option y Result son exactamente esto: enums de la biblioteca estándar con datos en sus variantes. No son magia del lenguaje, son el mismo material que acabas de usar. Para abrirlos se usa match, que es el tema de la lección 5.

Por qué esto sustituye a la herencia

En un lenguaje con clases, un pedido que pasa por varios estados suele modelarse con una jerarquía o con un puñado de banderas: un booleano pagado, otro enviado, una fecha que a veces es nula. El problema de ese diseño es que admite combinaciones que no tienen sentido, como un pedido enviado pero no pagado, y hay que confiar en que nadie las produzca.

Con un enum, esas combinaciones directamente no se pueden escribir:

enum Pedido {
    Borrador,
    Pagado { importe: f64 },
    Enviado { importe: f64, seguimiento: String },
    Cancelado { motivo: String },
}

impl Pedido {
    fn descripcion(&self) -> String {
        match self {
            Pedido::Borrador => "sin confirmar".to_string(),
            Pedido::Pagado { importe } => format!("pagado: {importe:.2} €"),
            Pedido::Enviado { seguimiento, .. } => format!("en camino, guía {seguimiento}"),
            Pedido::Cancelado { motivo } => format!("cancelado por {motivo}"),
        }
    }
}

fn main() {
    let p = Pedido::Enviado {
        importe: 49.90,
        seguimiento: String::from("MX-77421"),
    };

    println!("{}", p.descripcion());
}

Cada estado lleva justo los datos que tienen sentido en él: el número de seguimiento solo existe cuando el pedido está enviado, así que no hay forma de consultarlo en un borrador ni de olvidarse de ponerlo al enviar. Esa es la idea que en la comunidad se repite como hacer que los estados inválidos sean irrepresentables, y es probablemente el mayor cambio de mentalidad al llegar a Rust desde la programación orientada a objetos.

Dicho esto, el enum tiene un límite claro, y conviene saberlo desde ya: el conjunto de variantes es cerrado. Tú decides cuáles hay, y nadie desde fuera puede añadir una. Cuando lo que necesitas es lo contrario —que otros amplíen el conjunto con tipos que tú no conoces— la herramienta no es el enum sino los traits, que llegan en la lección 8. La pregunta que decide entre uno y otro es si el conjunto de casos lo cierras tú o debe quedar abierto.

derive: lo que el compilador escribe por ti

Por defecto, un tipo propio no se puede imprimir, ni copiar, ni comparar. Rust no supone nada: cada capacidad se pide explícitamente, y en los casos habituales el compilador genera el código con un atributo.

Atributo Qué permite
Debug Imprimir el valor con {:?}, sobre todo al depurar
Clone Hacer una copia explícita con .clone()
Copy Que se copie solo al asignar, en vez de moverse. Solo si todos los campos son Copy
PartialEq Comparar con == y !=
Default Obtener un valor inicial con Tipo::default()
PartialOrd Ordenar con <, > y compañía
#[derive(Debug, Clone, Copy, PartialEq)]
struct Punto {
    x: i32,
    y: i32,
}

#[derive(Debug, Default)]
struct Ajustes {
    verboso: bool,
    reintentos: u32,
}

fn main() {
    let a = Punto { x: 1, y: 2 };
    let b = a;

    println!("{a:?} == {b:?} -> {}", a == b);
    println!("{:?}", Ajustes::default());
}

Ese ejemplo esconde algo importante. Como Punto deriva Copy, la línea let b = a; hace una copia y a sigue siendo utilizable después. Sin Copy, esa misma línea habría movido el valor y usar a en la línea siguiente no compilaría. Derivar Copy solo es razonable en tipos pequeños y de datos simples: para algo que contenga un String ni siquiera está permitido.

En resumen

Lo que quieres modelar Qué usar
Varios datos que van siempre juntos un struct con campos con nombre
Envolver un valor para que tenga tipo propio un struct tupla, como Metros(f64)
Un valor que es un caso de entre varios un enum
Estados con datos distintos en cada uno un enum con datos en las variantes
Comportamiento asociado a un tipo un bloque impl
Consultar sin modificar un método con &self
Modificar en su sitio un método con &mut self
Un constructor una función sin self, por convención nuevo
Imprimir, comparar o copiar un tipo propio #[derive(…)]
Que otros amplíen el conjunto de casos traits, no enums (lección 8)

La idea que conviene llevarse es que en Rust el diseño empieza preguntando «¿esto es un y o es un o?». Si la respuesta es «y», va un struct; si es «o», va un enum. Acertar con esa pregunta ahorra después la mayoría de las comprobaciones defensivas, porque lo que el tipo no permite expresar ya no hace falta validarlo.

Para verlo explicado

Miniatura del vídeo «Structs, Enums y Patterns en Rust | Curso Rust 04 | Stan Tech», del canal Stan Tech

«Structs, Enums y Patterns en Rust | Curso Rust 04 | Stan Tech», del canal Stan Tech (24:36). 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.