Определение перечисления
Там, где структуры дают вам возможность группировать связанные поля и данные (например, Rectangle с его width и height), перечисления дают вам способ сказать, что данное значение является лишь одним из возможных наборов значений. Например, мы можем захотеть сказать, что Rectangle — это одна из множества возможных фигур, в которую также входят Circle и Triangle. Rust позволяет нам записать эту множественность в виде перечисления.
Давайте рассмотрим ситуацию, которую мы могли бы захотеть отразить в коде, и поймём, почему в этом случае перечисления полезны и более уместны, чем структуры. Допустим, нам нужно работать с IP-адресами. В настоящее время для обозначения IP-адресов используются два основных стандарта: четвёртая и шестая версии. Поскольку это единственно возможные варианты IP-адресов, с которыми может столкнуться наша программа, мы можем перечислить все возможные варианты: отсюда и термин “перечисление”.
Любой IP-адрес может быть либо четвёртой, либо шестой версии, но не обеими одновременно. Эта особенность IP-адресов делает структуру перечисления полностью нам подходящей, поскольку значение перечисления может представлять собой только один из его возможных вариантов. Адреса как четвёртой, так и шестой версии по своей сути все равно являются IP-адресами, поэтому их следует рассматривать как один и тот же тип, когда в коде обрабатываются задачи, относящиеся к любому типу IP-адресов.
Можно выразить эту концепцию в коде, определив перечисление IpAddrKind и перечислив возможные виды IP-адресов: V4 и V6. Вот определение нашего перечисления:
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 теперь является пользовательским типом данных, который мы можем использовать в любом другом месте нашего кода.
Значения перечислений
Экземпляры каждого варианта перечисления 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) {}
Обратите внимание, что варианты перечисления находятся в пространстве имён вместе с его идентификатором, а для их обособления мы используем двойное двоеточие. Это удобно тем, что теперь оба значения IpAddrKind::V4 и IpAddrKind::V6 относятся к одному типу: IpAddrKind. Затем мы можем, например, определить функцию, которая принимает любой из вариантов 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) {}
Можно вызвать эту функцию с любым из вариантов:
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) {}
Использование перечислений имеет ещё больше преимуществ. Если поразмыслить о нашем типе IP-адреса, то выяснится, что на данный момент у нас нет возможности хранить собственно сам IP-адрес; мы пока можем знать лишь его тип. Учитывая, что недавно (в Главе 5) вы узнали о структурах, у вас может возникнуть соблазн решить эту проблему с помощью структур, как показано в Листинге 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"),
};
}
IpAddrKind variant of an IP address using a structЗдесь мы определили структуру IpAddr, у которой есть два поля: kind типа IpAddrKind (перечисление, которое мы определили ранее) и address типа String. У нас есть два экземпляра этой структуры. Первый — home, который представляет адрес типа IpAddrKind::V4 (в соответствии со своим значением kind) с соответствующим адресом 127.0.0.1. Второй экземпляр — loopback. Его kind имеет другой вариант IpAddrKind — V6, и с ним ассоциирован адрес ::1. Мы использовали структуру для объединения значений kind и address вместе; таким образом, тип адреса теперь ассоциирован с непосредственно самим адресом.
However, representing the same concept using just an enum is more concise: Rather than an enum inside a struct, we can put data directly into each enum variant. This new definition of the IpAddr enum says that both V4 and V6 variants will have associated String values:
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"));
}
We attach data to each variant of the enum directly, so there is no need for an extra struct. Here, it’s also easier to see another detail of how enums work: The name of each enum variant that we define also becomes a function that constructs an instance of the enum. That is, IpAddr::V4() is a function call that takes a String argument and returns an instance of the IpAddr type. We automatically get this constructor function defined as a result of defining the enum.
There’s another advantage to using an enum rather than a struct: Each variant can have different types and amounts of associated data. Version four IP addresses will always have four numeric components that will have values between 0 and 255. If we wanted to store V4 addresses as four u8 values but still express V6 addresses as one String value, we wouldn’t be able to with a struct. Enums handle this case with ease:
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"));
}
We’ve shown several different ways to define data structures to store version four and version six IP addresses. However, as it turns out, wanting to store IP addresses and encode which kind they are is so common that the standard library has a definition we can use! Let’s look at how the standard library defines IpAddr. It has the exact enum and variants that we’ve defined and used, but it embeds the address data inside the variants in the form of two different structs, which are defined differently for each variant:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --код сокращён--
}
struct Ipv6Addr {
// --код сокращён--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
На этом примере видно, что мы можем добавлять любые типы данных в варианты перечисления: строку, число, структуру и так далее. Вы можете включать в перечисление даже другие перечисления! Стандартные типы данных часто не так сложны, как то, что из них можно составить.
Обратите внимание, что хотя определение перечисления IpAddr есть в стандартной библиотеке, мы смогли объявлять и использовать свою собственную реализацию с аналогичным названием без каких-либо конфликтов, потому что мы не добавили определение из стандартной библиотеки в область видимости нашей программы. Подробнее об этом поговорим в Главе 7.
Let’s look at another example of an enum in Listing 6-2: This one has a wide variety of types embedded in its variants.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message enum whose variants each store different amounts and types of valuesЭто перечисление имеет 4 варианта:
Quit: Has no data associated with it at allMove: Has named fields, like a struct doesWrite: Includes a singleStringChangeColor: Includes threei32values
Определение перечисления с вариантами (такими, как в Листинге 6-2) похоже на определение значений разных возможных структур, за исключением того, что перечисление не использует ключевое слово struct и все варианты сгруппированы внутри типа Message. Следующие структуры могут содержать те же данные, что и предыдущие варианты перечислений:
struct QuitMessage; // unit-подобная структура
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // кортежная структура
struct ChangeColorMessage(i32, i32, i32); // кортежная структура
fn main() {}
Но если мы используем различные структуры, каждая из которых имеет свои собственные типы, мы не можем легко определять функции, которые принимают любые типы сообщений, как это можно сделать с помощью единого перечисления типа Message, объявленного в Листинге 6-2.
There is one more similarity between enums and structs: Just as we’re able to define methods on structs using impl, we’re also able to define methods on enums. Here’s a method named call that we could define on our Message enum:
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();
}
В теле метода будет использоваться self для получения того значения, на котором мы вызвали этот метод. В этом примере мы создали переменную m, содержащую значение Message::Write(String::from("hello")), и именно это значение будет представлять self в теле метода call при исполнении строчки m.call().
Теперь посмотрим на перечисление из стандартной библиотеки, которое является очень распространённым и полезным: Option.
The Option Enum
This section explores a case study of Option, which is another enum defined by the standard library. The Option type encodes the very common scenario in which a value could be something, or it could be nothing.
Например, если вы запросите первый элемент из непустого списка, вы получите значение. Если вы запросите первый элемент пустого списка, вы получите ничего. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны были обработать; эта функциональность может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.
Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но также важно то, какие функции вы в него не включаете. Например, в Rust нет понятия “null”, однако оно есть во многих других языках. Null — это значение, которое означает, что значения нет. В языках с null переменные всегда могут находиться в одном из двух состояний: null или не-null.
In his 2009 presentation “Null References: The Billion Dollar Mistake,” Tony Hoare, the inventor of null, had this to say:
Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок в объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок будет абсолютно безопасным, с автоматической проверкой компилятором. Но я не смог устоять перед соблазном внедрить в язык пустую ссылку — просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.
Проблема с null заключается в том, что если вы попытаетесь использовать null в качестве не-null значения, вы получите ошибку определённого рода. Поскольку эта возможность (быть null или не-null) распространена повсеместно, сделать такую ошибку очень просто.
However, the concept that null is trying to express is still a useful one: A null is a value that is currently invalid or absent for some reason.
Проблема на самом деле не в концепции, а в конкретной реализации. В Rust нет значений null, но есть перечисление, которое может выразить концепцию присутствия или отсутствия значения. Это перечисление Option<T>, и оно определено стандартной библиотекой следующим образом:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
The Option<T> enum is so useful that it’s even included in the prelude; you don’t need to bring it into scope explicitly. Its variants are also included in the prelude: You can use Some and None directly without the Option:: prefix. The Option<T> enum is still just a regular enum, and Some(T) and None are still variants of type Option<T>.
Запись <T> — это особенность Rust, о которой мы ещё не говорили. Это параметр обобщённого типа, и мы рассмотрим его более подробно в Главе 10. На данный момент всё, что вам нужно знать, это то, что <T> означает, что вариант Some перечисления Option может содержать один фрагмент данных любого типа, и что каждый конкретный тип, который используется вместо T, делает обобщённый Option<T> определённым типом. Вот несколько примеров использования Option для хранения числового и символьного типов:
fn main() {
let some_number = Some(5);
let some_char = Some('e');
let absent_number: Option<i32> = None;
}
The type of some_number is Option<i32>. The type of some_char is Option<char>, which is a different type. Rust can infer these types because we’ve specified a value inside the Some variant. For absent_number, Rust requires us to annotate the overall Option type: The compiler can’t infer the type that the corresponding Some variant will hold by looking only at a None value. Here, we tell Rust that we mean for absent_number to be of type Option<i32>.
When we have a Some value, we know that a value is present, and the value is held within the Some. When we have a None value, in some sense it means the same thing as null: We don’t have a valid value. So, why is having Option<T> any better than having null?
Вкратце, поскольку Option<T> и T (где T может быть любым типом) относятся к разным типам, компилятор не позволит нам использовать значение Option<T> даже если бы оно определённо было допустимым значением. Например, этот код не будет компилироваться, потому что он пытается добавить i8 к значению типа Option<i8>:
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
}
Если мы запустим этот код, то получим такое сообщение об ошибке:
$ 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`
= help: the following other types implement trait `Add<Rhs>`:
`&i8` implements `Add<i8>`
`&i8` implements `Add`
`i8` implements `Add<&i8>`
`i8` implements `Add`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error
Оно большое! Фактически, это сообщение об ошибке означает, что Rust не понимает, как сложить i8 и Option<i8>, потому что это разные типы. Когда у нас есть значение типа вроде i8, компилятор гарантирует, что оно всегда действительно. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Лишь когда мы имеем значение типа Option<i8> (или любого другого конкретного типового параметра), у нас есть повод беспокоиться о том, что мы можем и не иметь значения; однако, компилятор обеспечит проверку нами обоих случаев перед тем, как давать использовать (возможное) значение.
Другими словами, вы должны преобразовать Option<T> в T, прежде чем вы сможете исполнять операции с этим T. Как правило, это помогает выявить одну из наиболее распространённых проблем с null: действия с чем-то в предположении, что это что-то не равно null, когда оно на самом деле равно null.
Eliminating the risk of incorrectly assuming a not-null value helps you be more confident in your code. In order to have a value that can possibly be null, you must explicitly opt in by making the type of that value Option<T>. Then, when you use that value, you are required to explicitly handle the case when the value is null. Everywhere that a value has a type that isn’t an Option<T>, you can safely assume that the value isn’t null. This was a deliberate design decision for Rust to limit null’s pervasiveness and increase the safety of Rust code.
Итак, как же получить значение T из варианта Some, если у вас на руках есть только значение Option<T>? Перечисление Option<T> имеет большое количество методов, полезных в различных ситуациях; вы можете ознакомиться с ними в его документации. Знакомство с методами перечисления Option<T> в вашем путешествии с Rust будет чрезвычайно полезным.
In general, in order to use an Option<T> value, you want to have code that will handle each variant. You want some code that will run only when you have a Some(T) value, and this code is allowed to use the inner T. You want some other code to run only if you have a None value, and that code doesn’t have a T value available. The match expression is a control flow construct that does just this when used with enums: It will run different code depending on which variant of the enum it has, and that code can use the data inside the matching value.