Ir al contenido

Patrones de diseño en Rust

Un patrón de diseño es una forma probada de resolver un problema que aparece una y otra vez. Los patrones no son bibliotecas que se instalan. Se parecen más a recetas: “cuando tengas este problema, dale esta forma a tu código”.

Muchos patrones de Java o C# se ven distintos en Rust, o directamente no hacen falta. Rust tiene sus propios favoritos, construidos sobre su sistema de tipos tan estricto, los enums, los traits y el ownership (propiedad). La gran idea detrás de casi todos es la misma: deja que el compilador atrape los errores por ti, para que nunca lleguen a tus usuarios. Cada sección empieza con el problema y luego muestra el patrón que lo resuelve.

Newtype: significados distintos, tipos distintos

Sección titulada «Newtype: significados distintos, tipos distintos»

El problema. Un f64 a secas puede significar cualquier cosa: metros, pies, segundos, euros. Si una función recibe un f64 en metros y tú le pasas pies, el compilador no puede ayudarte: los dos son simplemente f64. (Una confusión de unidades de este tipo es la famosa razón por la que la NASA perdió la sonda Mars Climate Orbiter en 1999).

El patrón. Envuelve el valor en un struct diminuto con un solo campo. Ahora Meters y Feet son tipos distintos, y Rust no te deja confundirlos:

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("¿Torre segura? {}", safe_altitude(tower));
println!("900 pies son {:.1} metros", plane.to_meters().0);
println!("¿Avión seguro? {}", safe_altitude(plane.to_meters()));
}

Salida:

¿Torre segura? true
900 pies son 274.3 metros
¿Avión seguro? false
▶ Pruébalo tú mismo

Si te olvidas de convertir y escribes safe_altitude(plane), el programa ni siquiera compila:

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

El compilador dice que los tipos no coinciden: esperaba Meters y encontró Feet. Un newtype no cuesta nada en tiempo de ejecución: en memoria, Meters es exactamente un f64. Úsalo para identificadores (UserId(u32) frente a OrderId(u32)), unidades y cadenas ya validadas. También te permite esquivar la orphan rule (regla del huérfano; consulta Traits avanzados).

Builder: muchas opciones, un resultado claro

Sección titulada «Builder: muchas opciones, un resultado claro»

El problema. Algunos structs tienen muchos campos, y la mayoría son opcionales. Un constructor como Email::new("[email protected]", "Hola", "", vec![], false) cuesta leerlo: ¿qué false es cuál? ¿Y si falta la dirección o está mal?

El patrón. Un struct builder (constructor) aparte va reuniendo la configuración con métodos pequeños y con nombre. Cada método devuelve el builder, así que puedes encadenar llamadas. Un build() final lo comprueba todo y devuelve un Result:

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("un correo necesita un destinatario")?;
if !to.contains('@') {
return Err(format!("'{}' no es una dirección de correo", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(sin asunto)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("Listo: para={} asunto={} cuerpo={:?} urgente={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("Error: {}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("¿Comemos?")
.body("¿Pizza a las 12?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("Hola").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

Salida:

Listo: [email protected] asunto=¿Comemos? cuerpo="¿Pizza a las 12?" urgente=true
Listo: [email protected] asunto=(sin asunto) cuerpo="" urgente=false
Error: un correo necesita un destinatario
Error: 'ana.example.com' no es una dirección de correo
▶ Pruébalo tú mismo

Cada setter recibe mut self (toma el ownership del builder), cambia un campo y devuelve el builder. Eso es lo que hace que la cadena funcione. Quien lo usa solo configura lo que le interesa, los nombres explican cada valor, y build() es el único sitio que comprueba las reglas. Dentro de él, ok_or convierte el None del destinatario que falta en un Err, y ? devuelve ese error antes de tiempo. Verás builders en muchos crates; por ejemplo, std::thread::Builder y std::process::Command (consulta Entorno y procesos).

Default y la actualización de structs: la opción ligera

Sección titulada «Default y la actualización de structs: la opción ligera»

El problema. Un builder es mucho código. Si tu struct no tiene reglas que comprobar, solo valores iniciales razonables, es demasiado.

El patrón. Implementa Default con buenos valores iniciales y luego usa la sintaxis de actualización de structs .. para cambiar solo los campos que te interesan:

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// Parte de otra configuración en lugar de la de por defecto:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

Salida:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ Pruébalo tú mismo

..algo significa “toma de algo todos los campos que no he escrito”. ¿Por qué prod.clone()? La actualización de structs mueve los campos restantes, y host es un String. Sin el clone, prod quedaría parcialmente movido y no podrías imprimirlo después. Una buena regla: usa Default + .. cuando cualquier combinación de valores sea válida, y un builder cuando algunas combinaciones no lo sean.

Typestate: una máquina de estados comprobada al compilar

Sección titulada «Typestate: una máquina de estados comprobada al compilar»

El problema. Algunas cosas tienen que pasar en orden. Una petición web necesita una URL antes de poder enviarla. Con un struct normal, lo comprobarías en tiempo de ejecución (“¿está puesta la URL? si no, devuelve un error”), y cruzarías los dedos para que todo el que la llame maneje ese error.

El patrón. Haz que el estado forme parte del tipo. Un Request<NoUrl> simplemente no tiene método send. Solo al poner la URL se convierte en un Request<HasUrl>, que sí lo tiene:

// Los dos estados. No guardan datos (o solo lo que ese estado necesita).
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// Cambia el tipo: Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// Disponible en cualquier estado
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// Solo disponible cuando ya hay una URL
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {} con {} cabecera(s)", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

Salida:

GET https://example.com con 2 cabecera(s)
▶ Pruébalo tú mismo

Ahora intenta enviar sin URL, con Request::new().send(), y el compilador te detiene:

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

El mensaje dice que Request<NoUrl> no tiene ningún método send, y la nota añade que ese método sí existe para Request<HasUrl>. El error no puede existir en un programa que compila. No hay comprobación en tiempo de ejecución ni coste extra: NoUrl ocupa cero bytes. Como url() recibe self por valor, el antiguo Request<NoUrl> se consume, así que nadie puede seguir usando por accidente el estado “de antes”. Esto usa los genéricos de una forma ingeniosa; vale la pena probar algún pequeño cambio en el Playground para sentir cómo funciona.

RAII y guards con Drop: una limpieza que no se te puede olvidar

Sección titulada «RAII y guards con Drop: una limpieza que no se te puede olvidar»

El problema. Los recursos hay que limpiarlos: cerrar el archivo, liberar el lock, borrar la carpeta temporal, imprimir “terminado”. Si la limpieza es una función que tienes que acordarte de llamar, alguien se olvidará, sobre todo en un return anticipado o en un error.

El patrón. Pon la limpieza en Drop. Rust llama a drop automáticamente cuando un valor sale de su ámbito (scope), por cualquier camino de salida: el final normal, un return anticipado, un ?, incluso un panic. Esto se llama RAII (“Resource Acquisition Is Initialization”, es decir, la adquisición de un recurso es su inicialización: obtener el recurso y ser dueño de un valor son el mismo paso). Un valor cuyo único trabajo es limpiar se llama guard (guardián):

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("empieza {}", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("limpia {}", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("tarea arriesgada");
if fail {
return Err(String::from("algo salió mal")); // el guard limpia igualmente
}
println!("tarea terminada");
Ok(())
}
fn main() {
{
let _block = Guard::new("bloque");
println!("dentro del bloque");
} // aquí se libera _block
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

Salida:

empieza bloque
dentro del bloque
limpia bloque
empieza tarea arriesgada
tarea terminada
limpia tarea arriesgada
Ok(())
empieza tarea arriesgada
limpia tarea arriesgada
Err("algo salió mal")
▶ Pruébalo tú mismo

Ya usas este patrón todo el tiempo. Un File se cierra solo cuando se libera. Un MutexGuard (el que devuelve mutex.lock(), consulta Concurrencia) libera el mutex cuando se descarta. Un Box libera su memoria cuando se descarta (Smart pointers). Por eso el código Rust casi nunca tiene llamadas a “close” o “free”.

From e Into: argumentos de función más cómodos

Sección titulada «From e Into: argumentos de función más cómodos»

El problema. Tu función recibe un String, así que quien tenga un &str debe escribir String::from("Ana") o "Ana".to_string() cada vez. O tu función recibe un UserId, y quien la llama tiene un u32 a secas. Es poca cosa, pero añade ruido por todas partes.

El patrón. Implementa From para tus tipos y acepta impl Into<T> en tus funciones. Así, quien llama puede pasar cualquier cosa que se pueda convertir:

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 y &str
let ben = User::new(UserId(2), String::from("Ben")); // ya son los tipos correctos
println!("{:?}", ana);
println!("{:?}", ben);
}

Salida:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ Pruébalo tú mismo

Implementa From, nunca Into directamente: Into lo obtienes gratis. El mismo truco es el que hace funcionar el manejo de errores: ? llama a From::from sobre el error, así que implementar From<io::Error> for MyError permite que ? convierta los errores por ti (consulta Errores personalizados). Úsalo para conversiones que no pueden fallar. Si una conversión puede fallar, usa TryFrom, que devuelve un Result.

Máquinas de estados con enums y estados ilegales

Sección titulada «Máquinas de estados con enums y estados ilegales»

El problema. Un pedido pasa de “en el carrito” a “pagado” y a “enviado”. Un primer intento suele verse así:

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

Pero esto permite disparates: shipped: true con paid: false, o un número de seguimiento en un pedido que todavía no se ha enviado. Cada función tiene que comprobar estas combinaciones imposibles.

El patrón. Haz que los estados ilegales no se puedan representar. Usa un enum con una variante por estado, y dale a cada variante solo los datos que ese estado tiene de verdad. Las transiciones son métodos que reciben el estado antiguo y devuelven el nuevo:

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // solo se puede pagar un carrito; lo demás se queda igual
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // demasiado tarde para cancelar
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("carrito con {} artículo(s)", items.len()),
Order::Paid { items, amount } => format!("pagado {} por {} artículo(s)", amount, items.len()),
Order::Shipped { tracking } => format!("enviado, seguimiento {}", tracking),
Order::Cancelled => String::from("cancelado"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("libro"), String::from("bolígrafo")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // todavía sin pagar: no pasa nada
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // ya enviado
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

Salida:

carrito con 2 artículo(s)
carrito con 2 artículo(s)
enviado, seguimiento TRACK-42
enviado, seguimiento TRACK-42
cancelado
▶ Pruébalo tú mismo

A un pedido enviado no le puede faltar el número de seguimiento: la variante Shipped siempre tiene uno. Un carrito no puede tener número de seguimiento: no hay ningún campo para él. Y como match debe cubrir todas las variantes, añadir un estado nuevo (por ejemplo, Refunded) hace que el compilador te señale cada sitio que tienes que actualizar.

¿Enums o trait objects? Los lenguajes orientados a objetos suelen usar una interfaz “State” con una clase por estado. En Rust podrías hacerlo con trait objects (Box<dyn State>), y el libro de Rust muestra cómo. Así puedes elegir:

  • Enum: conoces todos los estados de antemano. Obtienes las comprobaciones exhaustivas de match, y toda la lógica está en un solo sitio. Es la opción habitual en Rust.
  • Trait objects: otras personas (u otros crates) necesitan añadir estados nuevos sin cambiar tu código.
  • Typestate (de la sección anterior): quieres que llamar a las cosas en el orden equivocado sea un error de compilación, y no algo que simplemente se ignora en tiempo de ejecución. Es más estricto, pero el estado tiene que conocerse al compilar.

Escribir let _ = guard en lugar de let _guard = guard. Se ven casi iguales, pero se comportan distinto. Un nombre que empieza con guion bajo (como _guard) mantiene vivo el valor hasta el final del ámbito. Un _ a secas no es una variable, así que el valor se libera de inmediato:

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard liberado");
}
}
fn main() {
let _ = Guard;
println!("trabajo con `let _`");
let _g = Guard;
println!("trabajo con `let _g`");
}

Salida:

guard liberado
trabajo con `let _`
trabajo con `let _g`
guard liberado
▶ Pruébalo tú mismo

Con let _, la limpieza se ejecutó antes del trabajo, que es justo lo contrario de lo que quieres con un lock o un temporizador. En el caso de los guards, ponle siempre nombre a la variable.

Usar un patrón solo porque existe. Un builder para un struct de dos campos, o un typestate para algo que nunca se usa mal, añade código sin hacer nada más seguro. Empieza por lo sencillo. Recurre a un patrón cuando notes el problema que resuelve.

  • Newtype (struct Meters(f64)) da tipos distintos a los mismos datos, para que el compilador atrape las confusiones, sin ningún coste en tiempo de ejecución.
  • Builder ofrece una configuración legible y encadenada, y un único build() que valida y devuelve un Result. Default + .. es la opción más ligera cuando no hay nada que comprobar.
  • Typestate pone el estado en el tipo (Request<NoUrl> frente a Request<HasUrl>), así que llamar a las cosas en el orden equivocado no compila.
  • RAII / guards con Drop ejecutan la limpieza automáticamente por cualquier camino de salida de un ámbito.
  • From/Into permiten que las funciones acepten muchos tipos de entrada con impl Into<T>.
  • Los enums modelan máquinas de estados y hacen que los estados ilegales no se puedan representar; usa trait objects solo cuando otros tengan que añadir estados.

Comprueba lo aprendido

1. ¿Qué te da el newtype struct Meters(f64) frente a un f64 a secas?

2. En el patrón builder de esta página, ¿qué devuelve build()?

3. En el ejemplo de typestate, ¿por qué no puedes llamar a send() sobre Request::new()?

4. ¿Cuándo se ejecuta la limpieza de un guard con Drop?

5. ¿Qué hace let _ = Guard;?

Puntuación: 0 / 5