Enums frente a la herencia: una cosa u otra sin clases
Cómo Rust modela la disyunción sin jerarquías de clases ni polimorfismo de subtipos. Enums cerrados contra trait objects abiertos, el problema de la expresión, y la revelación de que Option y Result son solo enums.
En un lenguaje orientado a objetos, “una figura es un círculo o un rectángulo” se dice con una clase base y subclases. Rust no tiene herencia de implementación, y sin embargo modela esa disyunción con más precisión, no con menos. La clave es entender qué eje de flexibilidad eliges: el enum cierra el conjunto de casos y abre el de operaciones; el trait hace lo contrario. Y de este mismo mecanismo salen, sin magia, Option y Result.
- Contrastar el
enumcerrado con la jerarquía de clases abierta. - Situar el eje de decisión: casos fijos y operaciones abiertas, o al revés.
- Entender
trait objectscomo la otra mitad del polimorfismo. - Ver que
OptionyResultno son magia: son enums de biblioteca.
Dos formas de decir “una cosa u otra”
La herencia modela la disyunción de abajo arriba: defines una clase base Shape y cada figura es una subclase que la extiende. El conjunto de figuras queda abierto (cualquiera añade una subclase en otro archivo, otra librería) y el de operaciones queda cerrado dentro de la clase base (añadir un método nuevo obliga a tocar todas las subclases). Rust con enum invierte los dos ejes:
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
impl Shape {
fn area(&self) -> f64 { /* ... */ 0.0 }
fn perimeter(&self) -> f64 { /* ... */ 0.0 } // anadir esto NO toca las variantes
}
Con el enum, el conjunto de figuras está cerrado: las variantes se declaran en un solo sitio y nadie las amplía desde fuera. A cambio, añadir una operación nueva (perimeter) es trivial: escribes una función con su match y listo, sin tocar la definición del tipo. Has intercambiado los dos ejes respecto a la herencia, y esa elección tiene nombre.
Lo que acabas de ver es el clásico problema de la expresión: ningún paradigma te deja añadir casos y operaciones sin modificar código existente. La orientación a objetos facilita añadir casos (nuevas subclases) y penaliza añadir operaciones. Los tipos suma con match hacen justo lo contrario: añadir operaciones es libre, añadir variantes toca todos los match. No hay bando ganador; hay una decisión de diseño sobre qué eje esperas que crezca.
El criterio: qué esperas que crezca
De ahí sale una heurística nítida. Elige enum cuando el conjunto de casos es conocido y estable pero las operaciones proliferan: los estados de una conexión, los nodos de un AST, los tokens de un lexer. Elige polimorfismo abierto (un trait con trait objects) cuando ocurre lo contrario: el conjunto de tipos es abierto y extensible pero comparten un puñado fijo de operaciones, como los plugins de un sistema o los widgets de una interfaz.
// Conjunto ABIERTO de tipos, operaciones FIJAS: trait objects
trait Dibujable {
fn dibujar(&self);
}
struct Boton;
struct Deslizador;
impl Dibujable for Boton { fn dibujar(&self) { /* ... */ } }
impl Dibujable for Deslizador { fn dibujar(&self) { /* ... */ } }
fn render(widgets: &[Box<dyn Dibujable>]) {
for w in widgets { // cualquiera implementa Dibujable, incluso en otra crate
w.dibujar();
}
}
flowchart TD
P[Necesito una cosa u otra] --> Q{Que eje crece}
Q -->|casos fijos, operaciones muchas| E[enum mas match]
Q -->|tipos abiertos, operaciones pocas| T[trait mas dyn]
E --> E2[Exhaustividad y despacho estatico]
T --> T2[Extensible y despacho dinamico]
style E fill:#a6e3a1,color:#11111b
style T fill:#89b4fa,color:#11111bLa diferencia también se paga distinto en tiempo de ejecución. El match sobre un enum es despacho estático: el compilador conoce todas las variantes y genera un salto directo, casi siempre en línea, sin coste. El dyn Dibujable es despacho dinámico: una llamada indirecta a través de una vtable, con la variante decidida en ejecución. El enum es más rápido y verificable; el trait object es más flexible y extensible. No hay uno mejor: hay uno correcto por problema.
enum cerrado
Casos fijos, operaciones que proliferan. match exhaustivo y despacho estático sin coste. Para ASTs, estados, tokens: dominios que controlas de punta a punta.
trait abierto
Tipos extensibles, operaciones fijas. dyn Trait con vtable y despacho dinámico. Para plugins, widgets, backends: dominios que terceros amplían sin tocar tu código.
La revelación: Option y Result son solo enums
Y ahora la pieza que cierra el nivel entero. Option y Result, los tipos que definen la relación de Rust con la ausencia y el error, no son construcciones mágicas del compilador ni palabras clave. Son enum corrientes de la biblioteca estándar que podrías haber escrito tú:
// Asi, casi al pie de la letra, estan definidos en la std
enum Option<T> {
None,
Some(T),
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Eso es todo. Option<T> es un tipo suma genérico con dos variantes: “no hay nada” o “hay un T”. Result<T, E> es “salió bien con un T” o “salió mal con un E”. Cada línea de este nivel 6 aplica sin cambios: match los abre y extrae sus datos, la exhaustividad te obliga a contemplar el None y el Err, los patrones desestructuran su contenido. Cuando en el nivel 7 escribas match archivo { Ok(f) => ..., Err(e) => ... }, no estarás usando una característica nueva: estarás usando un enum, con las mismas reglas que ya dominas. La razón por la que Rust no necesita null ni excepciones es que un tipo suma genérico las subsume a ambas con más seguridad.
Y por ser tan centrales, la biblioteca les cuelga métodos que encapsulan los match más comunes, para que no repitas el mismo match de dos brazos por todas partes:
fn primer_par(v: &[i32]) -> Option<i32> {
v.iter().copied().find(|n| n % 2 == 0) // Option: hay par o no lo hay
}
fn doblar_primer_par(v: &[i32]) -> Option<i32> {
primer_par(v).map(|n| n * 2) // map: transforma el Some, respeta el None
}
fn leer_puerto(texto: &str) -> Result<u16, std::num::ParseIntError> {
let port: u16 = texto.parse()?; // ?: propaga el Err, extrae el Ok
Ok(port)
}
map, and_then, unwrap_or, el operador ?: cada uno es un match idiomático empaquetado. No son características aparte del lenguaje, son métodos sobre un enum que podrías haber escrito tú. El nivel 7 los abre en canal, pero fíjate en lo que eso significa: todo el manejo de ausencia y error de Rust se construye sobre lo que aprendiste en este nivel, sin ni una pieza nueva de sintaxis.
Cuando quieres las dos cosas
El eje enum-trait no es una frontera infranqueable: hay patrones que combinan lo mejor de ambos extremos. Si tu conjunto de tipos es cerrado pero cansa repetir el match en veinte funciones, puedes hacer que todas las variantes implementen un trait común y delegar cada brazo a un método:
trait Area { fn area(&self) -> f64; }
impl Area for Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
}
Y en el sentido inverso, cuando de verdad necesitas que terceros añadan tipos pero no quieres pagar la vtable en el camino caliente, la crate enum_dispatch genera un enum envolvente cuyos brazos llaman al trait con despacho estático. La conclusión práctica es que enum y trait no son bandos: son los dos extremos de un continuo de “apertura frente a rendimiento”, y el diseño experto se desliza por él según cómo espere que crezca el sistema.
Detente en la economía conceptual de lo que acabas de ver. Los lenguajes orientados a objetos apoyan buena parte de su expresividad sobre dos mecanismos del lenguaje, cableados en el compilador: la herencia para modelar “es un tipo de”, y las excepciones o el null para modelar “puede faltar o fallar”. Rust mira esos dos pilares y responde con uno solo, que además no es una palabra clave sino un enum que vive en una biblioteca que podrías reescribir. La disyunción entre tipos, que la OOP resuelve con jerarquías, Rust la resuelve con variantes cerradas y despacho exhaustivo; la ausencia y el error, que la OOP resuelve con null y throw, Rust las resuelve con Option y Result, que no son más que dos usos del mismo tipo suma genérico. Esto no es minimalismo por pobreza: es que un mecanismo bien elegido y compuesto con genéricos y con match cubre el terreno que en otros lenguajes exige tres o cuatro características especiales, y lo cubre con verificación en compilación en lugar de con convenciones que rezas por que se respeten. La lección que trasciende a Rust es esta: cuando un tipo de datos es lo bastante fundamental, prefiere darle un mecanismo componible antes que una palabra clave rígida, porque el mecanismo se combina con todo lo demás y la palabra clave se queda sola. Los enums son ese mecanismo, y por eso este nivel, que parecía tratar de sintaxis, era en realidad el nivel donde aprendiste a modelar la realidad con la parte del lenguaje sobre la que se sostiene todo el resto.
- Modela un conjunto de figuras primero como
enum Shapeconmatchy luego comotrait Formaconimplpor tipo; enumera qué cambio es fácil en cada uno y cuál es doloroso. - Decide, para tres dominios (tokens de un parser, plugins de un editor, estados de un pedido), si usarías
enumotrait object, y justifica con el eje que esperas que crezca. - Escribe tu propio
enum Opcion<T> { Nada, Algo(T) }con un métodomap; comprueba que reinventasOption. - Reimplementa
Resultcomoenum Resultado<T, E>y una función que devuelvaResultado<f64, String>al dividir; ábrela conmatch. - Razona en un párrafo por qué el despacho de un
matchsobreenumpuede ser más rápido que una llamada a través dedyn, y en qué situación la flexibilidad del segundo justifica ese coste.