Definindo um Enum

Beginner

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

Introdução

Bem-vindo a Definindo um Enum. Este laboratório faz parte do Livro Rust. Você pode praticar suas habilidades em Rust no LabEx.

Neste laboratório, definiremos um enum chamado IpAddrKind para representar os possíveis tipos de endereços IP, incluindo a versão quatro (V4) e a versão seis (V6).

Definindo um Enum

Onde as structs fornecem uma maneira de agrupar campos e dados relacionados, como um Rectangle com sua width e height, os enums fornecem uma maneira de dizer que um valor é um de um conjunto possível de valores. Por exemplo, podemos querer dizer que Rectangle é uma de um conjunto de formas possíveis que também inclui Circle e Triangle. Para fazer isso, o Rust nos permite codificar essas possibilidades como um enum.

Vamos analisar uma situação que podemos querer expressar em código e ver por que os enums são úteis e mais apropriados do que as structs neste caso. Digamos que precisamos trabalhar com endereços IP. Atualmente, dois padrões principais são usados para endereços IP: versão quatro e versão seis. Como essas são as únicas possibilidades para um endereço IP que nosso programa encontrará, podemos enumerar todas as variantes possíveis, que é de onde a enumeração recebe seu nome.

Qualquer endereço IP pode ser um endereço versão quatro ou versão seis, mas não ambos ao mesmo tempo. Essa propriedade dos endereços IP torna a estrutura de dados enum apropriada porque um valor enum pode ser apenas uma de suas variantes. Tanto os endereços versão quatro quanto os versão seis ainda são fundamentalmente endereços IP, portanto, devem ser tratados como o mesmo tipo quando o código está lidando com situações que se aplicam a qualquer tipo de endereço IP.

Podemos expressar esse conceito em código definindo uma enumeração IpAddrKind e listando os tipos possíveis que um endereço IP pode ser, V4 e V6. Estas são as variantes do enum:

enum IpAddrKind {
    V4,
    V6,
}

IpAddrKind é agora um tipo de dado personalizado que podemos usar em outras partes do nosso código.

Valores de Enum

Podemos criar instâncias de cada uma das duas variantes de IpAddrKind assim:

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

Observe que as variantes do enum são namespaced sob seu identificador, e usamos dois pontos duplos para separar os dois. Isso é útil porque agora ambos os valores IpAddrKind::V4 e IpAddrKind::V6 são do mesmo tipo: IpAddrKind. Podemos então, por exemplo, definir uma função que recebe qualquer IpAddrKind:

fn route(ip_kind: IpAddrKind) {}

E podemos chamar esta função com qualquer variante:

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

Usar enums tem ainda mais vantagens. Pensando mais sobre nosso tipo de endereço IP, no momento não temos uma maneira de armazenar os dados reais do endereço IP; só sabemos que tipo ele é. Dado que você acabou de aprender sobre structs no Capítulo 5, você pode ser tentado a abordar este problema com structs, como mostrado na Listagem 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"),
};

Listagem 6-1: Armazenando os dados e a variante IpAddrKind de um endereço IP usando uma struct

Aqui, definimos uma struct IpAddr [2] que tem dois campos: um campo kind [3] que é do tipo IpAddrKind (o enum que definimos anteriormente [1]) e um campo address [4] do tipo String. Temos duas instâncias desta struct. A primeira é home [5], e ela tem o valor IpAddrKind::V4 como seu kind com dados de endereço associados de 127.0.0.1. A segunda instância é loopback [6]. Ela tem a outra variante de IpAddrKind como seu valor kind, V6, e tem o endereço ::1 associado a ele. Usamos uma struct para agrupar os valores kind e address juntos, então agora a variante está associada ao valor.

No entanto, representar o mesmo conceito usando apenas um enum é mais conciso: em vez de um enum dentro de uma struct, podemos colocar dados diretamente em cada variante do enum. Esta nova definição do enum IpAddr diz que as variantes V4 e V6 terão valores String associados:

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

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

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

Anexamos dados a cada variante do enum diretamente, então não há necessidade de uma struct extra. Aqui, também é mais fácil ver outro detalhe de como os enums funcionam: o nome de cada variante do enum que definimos também se torna uma função que constrói uma instância do enum. Ou seja, IpAddr::V4() é uma chamada de função que recebe um argumento String e retorna uma instância do tipo IpAddr. Obtemos automaticamente esta função construtora definida como resultado da definição do enum.

Há outra vantagem em usar um enum em vez de uma struct: cada variante pode ter diferentes tipos e quantidades de dados associados. Os endereços IP versão quatro sempre terão quatro componentes numéricos que terão valores entre 0 e 255. Se quiséssemos armazenar endereços V4 como quatro valores u8, mas ainda expressar endereços V6 como um valor String, não seríamos capazes com uma struct. Os enums lidam com este caso com facilidade:

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

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

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

Mostramos várias maneiras diferentes de definir estruturas de dados para armazenar endereços IP versão quatro e versão seis. No entanto, como acontece, querer armazenar endereços IP e codificar qual tipo eles são é tão comum que a biblioteca padrão tem uma definição que podemos usar! Vamos ver como a biblioteca padrão define IpAddr: ela tem o enum e as variantes exatas que definimos e usamos, mas ela incorpora os dados do endereço dentro das variantes na forma de duas structs diferentes, que são definidas de forma diferente para cada variante:

struct Ipv4Addr {
    --snip--
}

struct Ipv6Addr {
    --snip--
}

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

Este código ilustra que você pode colocar qualquer tipo de dados dentro de uma variante de enum: strings, tipos numéricos ou structs, por exemplo. Você pode até incluir outro enum! Além disso, os tipos da biblioteca padrão geralmente não são muito mais complicados do que o que você pode inventar.

Observe que, embora a biblioteca padrão contenha uma definição para IpAddr, ainda podemos criar e usar nossa própria definição sem conflito porque não trouxemos a definição da biblioteca padrão para nosso escopo. Falaremos mais sobre como trazer tipos para o escopo no Capítulo 7.

Vamos ver outro exemplo de um enum na Listagem 6-2: este tem uma grande variedade de tipos incorporados em suas variantes.

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

Listagem 6-2: Um enum Message cujas variantes armazenam diferentes quantidades e tipos de valores

Este enum tem quatro variantes com tipos diferentes:

  • Quit não tem dados associados a ele.
  • Move tem campos nomeados, como uma struct.
  • Write inclui uma única String.
  • ChangeColor inclui três valores i32.

Definir um enum com variantes como as da Listagem 6-2 é semelhante a definir diferentes tipos de definições de struct, exceto que o enum não usa a palavra-chave struct e todas as variantes são agrupadas sob o tipo Message. As seguintes structs poderiam conter os mesmos dados que as variantes de enum anteriores contêm:

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

Mas se usássemos as diferentes structs, cada uma com seu próprio tipo, não poderíamos definir tão facilmente uma função para receber qualquer um desses tipos de mensagens como poderíamos com o enum Message definido na Listagem 6-2, que é um único tipo.

Há mais uma semelhança entre enums e structs: assim como podemos definir métodos em structs usando impl, também podemos definir métodos em enums. Aqui está um método chamado call que poderíamos definir em nosso enum Message:

impl Message {
    fn call(&self) {
      1 // method body would be defined here
    }
}

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

O corpo do método usaria self para obter o valor no qual chamamos o método. Neste exemplo, criamos uma variável m [2] que tem o valor Message::Write(String::from("hello")), e é isso que self será no corpo do método call [1] quando m.call() for executado.

Vamos ver outro enum na biblioteca padrão que é muito comum e útil: Option.

O Enum Option e Suas Vantagens Sobre Valores Nulos

Esta seção explora um estudo de caso de Option, que é outro enum definido pela biblioteca padrão. O tipo Option codifica o cenário muito comum em que um valor pode ser algo ou pode ser nada.

Por exemplo, se você solicitar o primeiro item em uma lista contendo vários itens, você obterá um valor. Se você solicitar o primeiro item em uma lista vazia, você não obterá nada. Expressar esse conceito em termos do sistema de tipos significa que o compilador pode verificar se você tratou todos os casos que deveria estar tratando; essa funcionalidade pode evitar bugs que são extremamente comuns em outras linguagens de programação.

O design da linguagem de programação é frequentemente pensado em termos de quais recursos você inclui, mas os recursos que você exclui também são importantes. O Rust não tem o recurso nulo que muitas outras linguagens têm. Nulo é um valor que significa que não há valor lá. Em linguagens com nulo, as variáveis podem sempre estar em um de dois estados: nulo ou não-nulo.

Em sua apresentação de 2009 "Null References: The Billion Dollar Mistake", Tony Hoare, o inventor do nulo, tem a dizer:

Eu chamo isso de meu erro de um bilhão de dólares. Naquela época, eu estava projetando o primeiro sistema de tipos abrangente para referências em uma linguagem orientada a objetos. Meu objetivo era garantir que todo o uso de referências fosse absolutamente seguro, com a verificação realizada automaticamente pelo compilador. Mas eu não consegui resistir à tentação de colocar uma referência nula, simplesmente porque era tão fácil de implementar. Isso levou a inúmeros erros, vulnerabilidades e falhas no sistema, que provavelmente causaram um bilhão de dólares em dor e danos nos últimos quarenta anos. O problema com valores nulos é que, se você tentar usar um valor nulo como um valor não-nulo, você obterá um erro de algum tipo. Como essa propriedade nula ou não-nula é generalizada, é extremamente fácil cometer esse tipo de erro.

No entanto, o conceito que nulo está tentando expressar ainda é útil: um nulo é um valor que está atualmente inválido ou ausente por algum motivo.

O problema não é realmente com o conceito, mas com a implementação específica. Como tal, o Rust não tem nulos, mas tem um enum que pode codificar o conceito de um valor presente ou ausente. Este enum é Option<T>, e é definido pela biblioteca padrão da seguinte forma:

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

O enum Option<T> é tão útil que está até incluído no prelúdio; você não precisa trazê-lo para o escopo explicitamente. Suas variantes também estão incluídas no prelúdio: você pode usar Some e None diretamente sem o prefixo Option::. O enum Option<T> ainda é apenas um enum regular, e Some(T) e None ainda são variantes do tipo Option<T>.

A sintaxe <T> é um recurso do Rust sobre o qual ainda não falamos. É um parâmetro de tipo genérico, e cobriremos genéricos com mais detalhes no Capítulo 10. Por enquanto, tudo o que você precisa saber é que <T> significa que a variante Some do enum Option pode conter um pedaço de dados de qualquer tipo, e que cada tipo concreto que é usado no lugar de T torna o tipo geral Option<T> um tipo diferente. Aqui estão alguns exemplos de como usar valores Option para conter tipos numéricos e tipos de string:

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

let absent_number: Option<i32> = None;

O tipo de some_number é Option<i32>. O tipo de some_char é Option<char>, que é um tipo diferente. O Rust pode inferir esses tipos porque especificamos um valor dentro da variante Some. Para absent_number, o Rust exige que anotemos o tipo Option geral: o compilador não pode inferir o tipo que a variante Some correspondente conterá apenas olhando para um valor None. Aqui, dizemos ao Rust que queremos que absent_number seja do tipo Option<i32>.

Quando temos um valor Some, sabemos que um valor está presente e o valor é mantido dentro do Some. Quando temos um valor None, em certo sentido, isso significa a mesma coisa que nulo: não temos um valor válido. Então, por que ter Option<T> é melhor do que ter nulo?

Em suma, porque Option<T> e T (onde T pode ser qualquer tipo) são tipos diferentes, o compilador não nos permitirá usar um valor Option<T> como se fosse definitivamente um valor válido. Por exemplo, este código não compilará, porque está tentando adicionar um i8 a um Option<i8>:

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

let sum = x + y;

Se executarmos este código, obteremos uma mensagem de erro como esta:

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! Na verdade, esta mensagem de erro significa que o Rust não entende como adicionar um i8 e um Option<i8>, porque eles são tipos diferentes. Quando temos um valor de um tipo como i8 em Rust, o compilador garantirá que sempre teremos um valor válido. Podemos prosseguir com confiança sem ter que verificar se há nulo antes de usar esse valor. Somente quando temos um Option<i8> (ou qualquer tipo de valor com o qual estamos trabalhando) é que precisamos nos preocupar em possivelmente não ter um valor, e o compilador garantirá que tratemos esse caso antes de usar o valor.

Em outras palavras, você deve converter um Option<T> em um T antes de poder realizar operações T com ele. Geralmente, isso ajuda a detectar um dos problemas mais comuns com nulo: presumir que algo não é nulo quando na verdade é.

Eliminar o risco de presumir incorretamente um valor não-nulo ajuda você a ter mais confiança em seu código. Para ter um valor que possivelmente pode ser nulo, você deve optar explicitamente por fazer o tipo desse valor Option<T>. Então, quando você usa esse valor, você é obrigado a lidar explicitamente com o caso em que o valor é nulo. Em todos os lugares que um valor tem um tipo que não é um Option<T>, você pode com segurança presumir que o valor não é nulo. Esta foi uma decisão de design deliberada para o Rust limitar a onipresença do nulo e aumentar a segurança do código Rust.

Então, como você obtém o valor T de uma variante Some quando você tem um valor do tipo Option<T> para que possa usar esse valor? O enum Option<T> tem um grande número de métodos que são úteis em uma variedade de situações; você pode conferi-los em sua documentação. Familiarizar-se com os métodos em Option<T> será extremamente útil em sua jornada com Rust.

Em geral, para usar um valor Option<T>, você deseja ter um código que lide com cada variante. Você deseja algum código que seja executado somente quando você tiver um valor Some(T), e esse código pode usar o T interno. Você deseja que algum outro código seja executado somente se você tiver um valor None, e esse código não tem um valor T disponível. A expressão match é uma construção de fluxo de controle que faz exatamente isso quando usada com enums: ela executará um código diferente dependendo de qual variante do enum ela tem, e esse código pode usar os dados dentro do valor correspondente.

Resumo

Parabéns! Você concluiu o laboratório Definindo um Enum. Você pode praticar mais laboratórios no LabEx para aprimorar suas habilidades.