Definiendo un Enum

RustRustBeginner
Practicar Ahora

This tutorial is from open-source community. Access the source code

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

Bienvenido a Definiendo un Enum. Esta práctica es parte del Rust Book. Puedes practicar tus habilidades de Rust en LabEx.

En esta práctica, definiremos un enum llamado IpAddrKind para representar los posibles tipos de direcciones IP, incluyendo la versión cuatro (V4) y la versión seis (V6).


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/MemorySafetyandManagementGroup(["Memory Safety and Management"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/integer_types("Integer Types") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/MemorySafetyandManagementGroup -.-> rust/lifetime_specifiers("Lifetime Specifiers") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/traits("Traits") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100398{{"Definiendo un Enum"}} rust/integer_types -.-> lab-100398{{"Definiendo un Enum"}} rust/string_type -.-> lab-100398{{"Definiendo un Enum"}} rust/function_syntax -.-> lab-100398{{"Definiendo un Enum"}} rust/expressions_statements -.-> lab-100398{{"Definiendo un Enum"}} rust/lifetime_specifiers -.-> lab-100398{{"Definiendo un Enum"}} rust/method_syntax -.-> lab-100398{{"Definiendo un Enum"}} rust/traits -.-> lab-100398{{"Definiendo un Enum"}} rust/operator_overloading -.-> lab-100398{{"Definiendo un Enum"}} end

Definiendo un Enum

Mientras que los structs te dan una forma de agrupar campos y datos relacionados, como un Rectangle con su width y height, los enums te dan una forma de decir que un valor es uno de un conjunto posible de valores. Por ejemplo, podríamos querer decir que Rectangle es uno de un conjunto posible de formas que también incluye Circle y Triangle. Para hacer esto, Rust nos permite codificar estas posibilidades como un enum.

Veamos una situación que podríamos querer expresar en código y veamos por qué los enums son útiles y más adecuados que los structs en este caso. Digamos que necesitamos trabajar con direcciones IP. Actualmente, se utilizan dos estándares principales para las direcciones IP: la versión cuatro y la versión seis. Debido a que estas son las únicas posibilidades para una dirección IP que nuestro programa encontrará, podemos enumerar todas las variantes posibles, de ahí que el enumerado tenga su nombre.

Cualquier dirección IP puede ser una dirección de versión cuatro o una dirección de versión seis, pero no ambas al mismo tiempo. Esa propiedad de las direcciones IP hace que la estructura de datos enum sea adecuada porque un valor enum solo puede ser una de sus variantes. Tanto las direcciones de versión cuatro como las de versión seis todavía son fundamentalmente direcciones IP, por lo que deben tratarse como el mismo tipo cuando el código está manejando situaciones que se aplican a cualquier tipo de dirección IP.

Podemos expresar este concepto en código definiendo una enumeración IpAddrKind y listando los tipos posibles que puede tener una dirección IP, V4 y V6. Estas son las variantes del enum:

enum IpAddrKind {
    V4,
    V6,
}

IpAddrKind ahora es un tipo de datos personalizado que podemos utilizar en otros lugares de nuestro código.

Valores de Enum

Podemos crear instancias de cada una de las dos variantes de IpAddrKind de la siguiente manera:

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

Tenga en cuenta que las variantes del enum están en un espacio de nombres bajo su identificador, y usamos dos puntos para separar los dos. Esto es útil porque ahora ambos valores IpAddrKind::V4 y IpAddrKind::V6 son del mismo tipo: IpAddrKind. Luego, por ejemplo, podemos definir una función que tome cualquier IpAddrKind:

fn route(ip_kind: IpAddrKind) {}

Y podemos llamar a esta función con cualquiera de las variantes:

route(IpAddrKind::V4);
route(IpAddrKind::V6);

Usar enums tiene aún más ventajas. Pensando más en nuestro tipo de dirección IP, en este momento no tenemos una forma de almacenar los datos reales de la dirección IP; solo sabemos de qué tipo es. Dado que acaba de aprender sobre structs en el Capítulo 5, es posible que se sienta tentado de abordar este problema con structs como se muestra en la Lista 6-1.

1 enum IpAddrKind {
    V4,
    V6,
}

2 struct IpAddr {
  3 kind: IpAddrKind,
  4 address: String,
}

5 let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

6 let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
};

Lista 6-1: Almacenando los datos y la variante IpAddrKind de una dirección IP usando un struct

Aquí, hemos definido un struct IpAddr [2] que tiene dos campos: un campo kind [3] que es del tipo IpAddrKind (el enum que definimos anteriormente [1]) y un campo address [4] del tipo String. Tenemos dos instancias de este struct. La primera es home [5], y tiene el valor IpAddrKind::V4 como su kind con los datos de dirección asociados de 127.0.0.1. La segunda instancia es loopback [6]. Tiene la otra variante de IpAddrKind como su valor kind, V6, y tiene la dirección ::1 asociada a ella. Hemos usado un struct para agrupar los valores kind y address juntos, por lo que ahora la variante está asociada con el valor.

Sin embargo, representar el mismo concepto solo con un enum es más conciso: en lugar de un enum dentro de un struct, podemos poner los datos directamente en cada variante de enum. Esta nueva definición del enum IpAddr dice que tanto las variantes V4 como V6 tendrán valores String asociados:

enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

Adjuntamos los datos a cada variante del enum directamente, por lo que no es necesario un struct adicional. Aquí, también es más fácil ver otro detalle de cómo funcionan los enums: el nombre de cada variante de enum que definimos también se convierte en una función que construye una instancia del enum. Es decir, IpAddr::V4() es una llamada a función que toma un argumento String y devuelve una instancia del tipo IpAddr. Automáticamente obtenemos esta función constructor definida como resultado de definir el enum.

Hay otra ventaja de usar un enum en lugar de un struct: cada variante puede tener diferentes tipos y cantidades de datos asociados. Las direcciones IP de versión cuatro siempre tendrán cuatro componentes numéricos que tendrán valores entre 0 y 255. Si quisiéramos almacenar las direcciones V4 como cuatro valores u8 pero todavía expresar las direcciones V6 como un valor String, no podríamos hacerlo con un struct. Los enums manejan este caso con facilidad:

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

Hemos mostrado varias maneras diferentes de definir estructuras de datos para almacenar direcciones IP de versión cuatro y versión seis. Sin embargo, resulta que querer almacenar direcciones IP y codificar de qué tipo son es tan común que la biblioteca estándar tiene una definición que podemos usar. Echemos un vistazo a cómo la biblioteca estándar define IpAddr: tiene exactamente el enum y las variantes que hemos definido y usado, pero embebe los datos de dirección dentro de las variantes en forma de dos structs diferentes, que se definen de manera diferente para cada variante:

struct Ipv4Addr {
    --snip--
}

struct Ipv6Addr {
    --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

Este código ilustra que se puede poner cualquier tipo de datos dentro de una variante de enum: cadenas, tipos numéricos o structs, por ejemplo. Incluso se puede incluir otro enum. Además, los tipos de la biblioteca estándar a menudo no son mucho más complicados que los que uno podría inventar.

Tenga en cuenta que aunque la biblioteca estándar contiene una definición para IpAddr, todavía podemos crear y usar nuestra propia definición sin conflicto porque no hemos traído la definición de la biblioteca estándar a nuestro ámbito. Hablaremos más sobre traer tipos al ámbito en el Capítulo 7.

Echemos un vistazo a otro ejemplo de un enum en la Lista 6-2: este tiene una amplia variedad de tipos embebidos en sus variantes.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Lista 6-2: Un enum Message cuyas variantes cada una almacena diferentes cantidades y tipos de valores

Este enum tiene cuatro variantes con diferentes tipos:

  • Quit no tiene datos asociados en absoluto.
  • Move tiene campos con nombre, como un struct.
  • Write incluye una sola String.
  • ChangeColor incluye tres valores i32.

Definir un enum con variantes como las de la Lista 6-2 es similar a definir diferentes tipos de definiciones de struct, excepto que el enum no usa la palabra clave struct y todas las variantes se agrupan juntas bajo el tipo Message. Los siguientes structs podrían contener los mismos datos que las variantes de enum anteriores:

struct QuitMessage; // struct unitario
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // struct tupla
struct ChangeColorMessage(i32, i32, i32); // struct tupla

Pero si usáramos los diferentes structs, cada uno de los cuales tiene su propio tipo, no podríamos definir una función para tomar cualquiera de estos tipos de mensajes tan fácilmente como lo podríamos hacer con el enum Message definido en la Lista 6-2, que es un solo tipo.

Hay una más similitud entre enums y structs: al igual que podemos definir métodos en structs usando impl, también podemos definir métodos en enums. Aquí hay un método llamado call que podríamos definir en nuestro enum Message:

impl Message {
    fn call(&self) {
      1 // el cuerpo del método se definiría aquí
    }
}

2 let m = Message::Write(String::from("hello"));
m.call();

El cuerpo del método usaría self para obtener el valor en el que se llamó el método. En este ejemplo, hemos creado una variable m [2] que tiene el valor Message::Write(String::from("hello")), y eso es lo que self será en el cuerpo del método call [1] cuando se ejecute m.call().

Echemos un vistazo a otro enum en la biblioteca estándar que es muy común y útil: Option.

El enum Option y sus ventajas sobre los valores nulos

Esta sección explora un estudio de caso de Option, que es otro enum definido por la biblioteca estándar. El tipo Option codifica el escenario muy común en el que un valor puede ser algo o puede ser nada.

Por ejemplo, si solicita el primer elemento de una lista que contiene múltiples elementos, obtendrá un valor. Si solicita el primer elemento de una lista vacía, no obtendrá nada. Expresar este concepto en términos del sistema de tipos significa que el compilador puede comprobar si ha manejado todos los casos que debe manejar; esta funcionalidad puede prevenir errores que son extremadamente comunes en otros lenguajes de programación.

El diseño de los lenguajes de programación a menudo se piensa en términos de las características que se incluyen, pero las características que se excluyen también son importantes. Rust no tiene la característica de null que tienen muchos otros lenguajes. Null es un valor que significa que no hay un valor allí. En los lenguajes con null, las variables siempre pueden estar en uno de dos estados: null o no-null.

En su presentación de 2009 "Null References: The Billion Dollar Mistake", Tony Hoare, el inventor de null, dice lo siguiente:

Lo llamo mi error de mil millones de dólares. En aquel momento, estaba diseñando el primer sistema de tipos integral para referencias en un lenguaje orientado a objetos. Mi objetivo era garantizar que todo uso de referencias fuera absolutamente seguro, con comprobaciones realizadas automáticamente por el compilador. Pero no pude resistir la tentación de incluir una referencia nula, simplemente porque era tan fácil de implementar. Esto ha dado lugar a innumerables errores, vulnerabilidades y errores del sistema, que probablemente han causado mil millones de dólares de dolor y daño en los últimos cuarenta años. El problema con los valores nulos es que si intenta usar un valor nulo como un valor no nulo, obtendrá algún tipo de error. Debido a que esta propiedad de null o no-null es generalizada, es extremadamente fácil cometer este tipo de error.

Sin embargo, el concepto que intenta expresar null todavía es útil: un null es un valor que actualmente es inválido o ausente por alguna razón.

El problema no es realmente el concepto sino la implementación particular. Por lo tanto, Rust no tiene nulls, pero tiene un enum que puede codificar el concepto de que un valor está presente o ausente. Este enum es Option<T>, y está definido por la biblioteca estándar de la siguiente manera:

enum Option<T> {
    None,
    Some(T),
}

El enum Option<T> es tan útil que incluso está incluido en el preludio; no es necesario traerlo al ámbito explícitamente. Sus variantes también están incluidas en el preludio: se puede usar Some y None directamente sin el prefijo Option::. El enum Option<T> todavía es solo un enum regular, y Some(T) y None todavía son variantes del tipo Option<T>.

La sintaxis <T> es una característica de Rust que todavía no hemos mencionado. Es un parámetro de tipo genérico, y cubriremos los genéricos en más detalle en el Capítulo 10. Por ahora, todo lo que necesita saber es que <T> significa que la variante Some del enum Option puede contener un dato de cualquier tipo, y que cada tipo concrete que se utiliza en lugar de T hace que el tipo Option<T> en general sea un tipo diferente. Aquí hay algunos ejemplos de uso de valores Option para contener tipos numéricos y tipos de cadena:

let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;

El tipo de some_number es Option<i32>. El tipo de some_char es Option<char>, que es un tipo diferente. Rust puede inferir estos tipos porque hemos especificado un valor dentro de la variante Some. Para absent_number, Rust requiere que anote el tipo Option en general: el compilador no puede inferir el tipo que contendrá la variante Some correspondiente solo al ver un valor None. Aquí, le decimos a Rust que queremos que absent_number sea del tipo Option<i32>.

Cuando tenemos un valor Some, sabemos que un valor está presente y el valor se encuentra dentro de Some. Cuando tenemos un valor None, en cierto sentido significa lo mismo que null: no tenemos un valor válido. Entonces, ¿por qué tener Option<T> es mejor que tener null?

En resumen, porque Option<T> y T (donde T puede ser cualquier tipo) son tipos diferentes, el compilador no nos permitirá usar un valor Option<T> como si fuera definitivamente un valor válido. Por ejemplo, este código no se compilará, porque está intentando sumar un i8 a un Option<i8>:

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

Si ejecutamos este código, obtenemos un mensaje de error como este:

error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`

¡Intenso! En efecto, este mensaje de error significa que Rust no entiende cómo sumar un i8 y un Option<i8>, porque son tipos diferentes. Cuando tenemos un valor de un tipo como i8 en Rust, el compilador asegurará de que siempre tengamos un valor válido. Podemos continuar con confianza sin tener que comprobar si es null antes de usar ese valor. Solo cuando tenemos un Option<i8> (o cualquier tipo de valor con el que estemos trabajando) tenemos que preocuparnos por no tener un valor, y el compilador se asegurará de que manejemos ese caso antes de usar el valor.

En otras palabras, tiene que convertir un Option<T> en un T antes de poder realizar operaciones de T con él. En general, esto ayuda a detectar uno de los problemas más comunes con null: suponer que algo no es null cuando en realidad lo es.

Eliminar el riesgo de suponer incorrectamente un valor no nulo lo ayuda a tener más confianza en su código. Para tener un valor que posiblemente sea null, debe optar explícitamente haciéndolo del tipo Option<T>. Luego, cuando use ese valor, se le exige manejar explícitamente el caso en el que el valor sea null. En todas partes donde un valor tiene un tipo que no es Option<T>, puede suponer con seguridad que el valor no es null. Esta fue una decisión de diseño deliberada de Rust para limitar la generalización de null y aumentar la seguridad del código de Rust.

Entonces, ¿cómo se obtiene el valor T de una variante Some cuando se tiene un valor del tipo Option<T> para poder usar ese valor? El enum Option<T> tiene una gran cantidad de métodos que son útiles en una variedad de situaciones; puede consultarlos en su documentación. Volverse familiarizado con los métodos en Option<T> será extremadamente útil en su viaje con Rust.

En general, para usar un valor Option<T>, se desea tener código que manejará cada variante. Se desea tener algún código que solo se ejecutará cuando se tiene un valor Some(T), y este código puede usar el T interno. Se desea que otro código solo se ejecute si se tiene un valor None, y ese código no tiene un valor T disponible. La expresión match es una construcción de flujo de control que hace exactamente esto cuando se utiliza con enums: ejecutará código diferente dependiendo de qué variante del enum tenga, y ese código puede usar los datos dentro del valor coincidente.

Resumen

¡Felicidades! Has completado la práctica Definiendo un Enum. Puedes practicar más prácticas en LabEx para mejorar tus habilidades.