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:
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.
..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.
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.
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

«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.