Neste capítulo, examinaremos as enumerações, também conhecidas como enums. Enums permitem definir um tipo enumerando suas variantes possíveis. Primeiro, vamos definir e usar um enum para mostrar como um enum pode codificar o significado junto com os dados. A seguir, exploraremos um enum particularmente útil, chamado Option
, que expressa que um valor pode ser algo ou nada. Em seguida, veremos como a correspondência de padrões na expressão match
facilita a execução de diferentes códigos para diferentes valores de um enum. Finalmente, vamos cobrir como a construção if let
é outro conveniencia e disponível para você lidar com enums em seu código.
Enums são um recurso em muitas linguagens, mas seus recursos diferem em cada linguagem. Os enums de Rust são mais semelhantes aos tipos de dados algébricos em linguagens funcionais, como F#, OCaml e Haskell.
Definindo um Enum
Vamos examinar uma situação que podemos querer expressar em código e ver por que enums são úteis e mais apropriados do que 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. Estas 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 obtém seu nome.
Qualquer endereço IP pode ser um endereço de versão quatro ou seis, mas não os dois ao mesmo tempo. Essa propriedade dos endereços IP torna a estrutura de dados enum apropriada, porque os valores enum podem ser apenas uma de suas variantes. Os endereços da versão quatro e da 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
ou V6
. Estas são as variantes do enum:
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
IpAddrKind
agora é um tipo de dados personalizado que podemos usar em outro lugar em nosso código.
Valores Enum
Podemos criar instâncias de cada uma das duas variantes IpAddrKind
assim:
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
Observe que as variantes do enum têm namespaces sob seu identificador e usamos dois-pontos duplos para separar os dois. A razão pela qual isso é útil é que 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 leva qualquer IpAddrKind
:
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
E podemos chamar essa função com qualquer uma das variantes:
enum IpAddrKind {
V4,
V6,
}
fn main() {
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
route(IpAddrKind::V4);
route(IpAddrKind::V6);
}
fn route(ip_kind: IpAddrKind) {}
Usar enums tem ainda mais vantagens. Pensando mais no nosso tipo de endereço IP, no momento não temos como armazenar os dados reais do endereço IP; só sabemos de que tipo é. Dado que você acabou de aprender sobre structs no Capítulo 5, você pode resolver esse problema conforme mostrado na Listagem 6-1.
fn main() {
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
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 estrutura IpAddr
que possui dois campos: um campo kind
do tipo IpAddrKind
(o enum que definimos anteriormente) e um campo address
do tipo String
. Temos duas instâncias desta estrutura. O primeiro, home
, tem um valor IpAddrKind::V4
como kind
com dados de endereço associado 127.0.0.1
. A segunda instância, loopback
, tem a outra variante de IpAddrKind
como seu valor de kind
sendo V6
, e tem o endereço ::1
associado a ela. Nós usamos uma estrutura para agrupar os valores kind
e address
juntos, então agora a variante está associada com o valor.
Podemos representar o mesmo conceito de uma maneira mais concisa usando apenas um enum, em vez de um enum dentro de uma estrutura, colocando dados diretamente em cada variante de enum. Esta nova definição do enum IpAddr
diz que ambas variantes, V4
e V6
, terão valores String
associados:
fn main() {
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, portanto, não há necessidade de uma estrutura extra.
Há outra vantagem em usar um enum em vez de uma estrutura: cada variante pode ter diferentes tipos e quantidades de dados associados. Os endereços IP do tipo da 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 poderíamos com uma estrutura. Enums lidam com este caso com facilidade:
fn main() {
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 da versão quatro e da versão seis. No entanto, ao que parece, querer armazenar endereços IP e codificar que tipo eles são é tão comum que a biblioteca padrão tem uma definição que podemos usar! Vejamos como a biblioteca padrão define IpAddr
: ela tem o enum exato e as variantes que definimos e usamos, mas incorpora os dados de endereço dentro das variantes na forma de duas estruturas diferentes, que são definidas de forma diferente para cada variante:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
}
struct Ipv6Addr {
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Este código ilustra que você pode colocar qualquer tipo de dado dentro de uma variante enum: strings, tipos numéricos ou structs, por exemplo. Você pode até incluir outro enum! Além disso, os tipos de biblioteca padrão geralmente não são muito mais complicados do que o que você pode imaginar.
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 o nosso escopo. Falaremos mais sobre como trazer os tipos no escopo no Capítulo 7.
Vejamos outro exemplo de enum na Listagem 6-2: este possui uma grande variedade de tipos embutidos em suas variantes.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Listagem 6-2: um Message
enum cujas variantes cada uma armazena diferentes quantidades e tipos de valores.
Este enum tem quatro variantes com tipos diferentes:
Quit
não tem nenhum dado associado a ele.
Move
inclui uma estrutura anônima dentro dela.
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 estrutura, exceto que o enum não usa a struct
palavra - chave e todas as variantes são agrupadas sob o Message
tipo. Os seguintes structs podem conter os mesmos dados que as variantes enum anteriores contêm:
struct QuitMessage;
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String);
struct ChangeColorMessage(i32, i32, i32);
fn main() {}
Mas se usássemos as diferentes estruturas, 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 Message
enum definido na Listagem 6-2, que é um tipo único.
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 podemos definir em nosso enum Message
:
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
}
}
let m = Message::Write(String::from("hello"));
m.call();
}
O corpo do método usaria self
para obter o valor com o qual chamamos o método. Neste exemplo, criamos uma variável m
que tem o valor Message::Write(String::from("hello"))
e é isso que self
será no corpo do método call
quando m.call()
for executado.
Vamos olhar um outro enum na biblioteca padrão que é muito comum e útil: Option
.
O Enum Option
e suas vantagens sobre os valores nulos
Na seção anterior, vimos como o enum IpAddr
nos permite usar o sistema de tipos de Rust para codificar mais informações do que apenas os dados em nosso programa. Esta seção explora um estudo de caso de Option
, que é outro enum definido pela biblioteca padrão. O tipo Option
é usado em muitos lugares porque codifica o cenário muito comum em que um valor pode ser algo ou nada. Expressar esse conceito em termos do sistema de tipos significa que o compilador pode verificar se você tratou de todos os casos que deveria tratar; essa funcionalidade pode evitar bugs que são extremamente comuns em outras linguagens de programação.
O design da linguagem de programação geralmente é pensado em termos de quais recursos você inclui, mas os recursos que você exclui também são importantes. Rust não possui o recurso nulo que muitas outras linguagens possuem. 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, "Referências nulas: o erro de um bilhão de dólares", Tony Hoare, o inventor do nulo, disse:
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 uso de referências fosse absolutamente seguro, com checagem realizada automaticamente pelo compilador. Mas não pude resistir à tentação de colocar uma referência nula, simplesmente porque era muito 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 dores 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, obterá algum tipo de erro. Como essa propriedade nula ou não nula é difundida, é extremamente fácil cometer esse tipo de erro.
No entanto, o conceito que nulo está tentando expressar ainda é útil: um nulo é um valor que atualmente é inválido ou ausente por algum motivo.
O problema não é realmente com o conceito, mas com a implementação particular. Como tal, 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:
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
O enum Option<T>
é tão útil que até está incluído no prelúdio; você não precisa colocá-lo no escopo explicitamente. Além disso, são suas variantes: você pode usar Some
e None
diretamente sem o prefixo Option::
. O enum Option<T>
ainda é apenas um enum regular 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 abordaremos os genéricos com mais detalhes no Capítulo 10. Por enquanto, tudo o que você precisa saber é que <T>
a variante Some
do enum Option
pode conter um dado de qualquer tipo. Aqui estão alguns exemplos de uso dos valores Option
para conter tipos de número e tipos de string:
fn main() {
let some_number = Some(5);
let some_string = Some("a string");
let absent_number: Option<i32> = None;
}
Se usarmos None
em vez de Some
, precisamos dizer a Rust que tipo Option<T>
temos, porque o compilador não pode inferir o tipo que a variante Some
manterá olhando apenas para um valor None
.
Quando temos um valor Some
, sabemos que ele está presente e o valor está dentro de Some
. Quando temos um valor None
, em certo sentido, significa a mesma coisa que nulo: não temos um valor válido. Então, por que ter algum Option<T>
é melhor do que ter null?
Resumindo, como Option<T>
e T
(onde T
pode ser qualquer tipo) são tipos diferentes, o compilador não nos permite usar um valor Option<T>
como se fosse definitivamente um valor válido. Por exemplo, este código não compila porque está tentando adicionar um valor i8
a um Option<i8>
:
Esse código não compila.
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
Se executarmos esse código, receberemos uma mensagem de erro como esta:
$ cargo run
Compiling enums v0.1.0 (file:///projects/enums)
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`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums`
To learn more, run the command again with --verbose.
Intenso! Na verdade, essa mensagem de erro significa que Rust não entende como adicionar um i8
e um Option<i8>
, porque são tipos diferentes. Quando temos um valor de um tipo como i8
em Rust, o compilador garantirá que sempre tenhamos um valor válido. Podemos prosseguir com segurança sem precisar 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 temos que nos preocupar com a possibilidade de não ter um valor, e o compilador se certificará de que trataremos desse caso antes de usar o valor.
Em outras palavras, você deve converter um Option<T>
em a T
antes de poder realizar operações T
com ele. Geralmente, isso ajuda a detectar um dos problemas mais comuns com nulo: assumir que algo não é nulo quando na verdade é.
Não ter que se preocupar em assumir 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 ativá-lo explicitamente definindo o tipo desse valor Option<T>
. Então, ao usar esse valor, você deve lidar explicitamente com o caso em que o valor é nulo. Onde quer que um valor tenha um tipo que não seja um Option<T>
, você pode assumir com segurança que o valor não é nulo. Esta foi uma decisão de design deliberada do Rust para limitar a difusão do null e aumentar a segurança do código do 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>
possui um grande número de métodos que são úteis em uma variedade de situações; você pode verificá-los em sua documentação. Familiarizar-se com os métodos do Option<T>
será extremamente útil em sua jornada com o Rust.
Em geral, para usar um valor Option<T>
, você deseja ter um código que trate de cada variante. Você quer algum código que será executado apenas quando você tiver um valor Some(T)
, e esse código tem permissão para usar o interno T
. Você deseja que algum outro código seja executado se tiver um valor None
e esse código não tiver 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 da enum ela possui, e esse código pode usar os dados dentro do valor correspondente.
Traduzido por Acervo Lima. O original pode ser acessado aqui.
Licença