Определение перечисления
Структуры позволяют сгруппировать связанные поля и данные — как Rectangle
со своими width и height. А перечисления позволяют сказать, что значение —
одно из заранее известного набора. Например, мы можем захотеть сказать, что
Rectangle — одна из возможных фигур, наряду с Circle (кругом) и Triangle
(треугольником). Для этого Rust позволяет описать все варианты в виде
перечисления.
Рассмотрим ситуацию, которую нужно выразить в коде, и посмотрим, почему здесь перечисления полезнее и уместнее структур. Допустим, нам нужно работать с 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-адресов: сейчас у нас нет способа хранить сами данные адреса — мы знаем только его вид. Вы только что изучили структуры в главе 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 с помощью structЗдесь мы определили структуру IpAddr с двумя полями: kind типа
IpAddrKind (перечисление, определённое выше) и address типа String. У нас
два экземпляра этой структуры. Первый — home: у него kind равно
IpAddrKind::V4, а адрес — 127.0.0.1. Второй — loopback: у него kind —
другой вариант IpAddrKind, V6, а адрес — ::1. Мы использовали структуру,
чтобы связать kind и address вместе, так что теперь вариант привязан к
значению.
Однако то же самое можно выразить короче — одним только перечислением. Вместо
перечисления внутри структуры можно положить данные прямо в каждый вариант
перечисления. Новое определение перечисления IpAddr говорит, что у обоих
вариантов, V4 и V6, есть связанное значение String:
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"));
}
Мы прикрепляем данные прямо к каждому варианту перечисления, так что
дополнительная структура не нужна. Здесь заодно легче увидеть ещё одну деталь
работы перечислений: имя каждого варианта, который мы определяем, становится
также функцией, создающей экземпляр перечисления. То есть IpAddr::V4() —
это вызов функции, которая принимает аргумент String и возвращает экземпляр
типа IpAddr. Эту функцию-конструктор мы получаем автоматически, просто
определив перечисление.
Есть у перечисления перед структурой и ещё одно преимущество: у каждого
варианта могут быть данные разных типов и в разном количестве. В адресе
четвёртой версии всегда четыре числа от 0 до 255. Если бы мы захотели хранить
адреса V4 как четыре значения u8, а адреса V6 — по-прежнему как одно
значение String, со структурой так бы не получилось. А перечисление
справляется с этим легко:
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"));
}
Мы показали несколько разных способов описать структуры данных для хранения
IP-адресов четвёртой и шестой версий. Но, как оказалось, хранить IP-адреса
вместе с их видом нужно настолько часто, что в стандартной библиотеке уже
есть готовое определение! Посмотрим, как стандартная
библиотека определяет IpAddr. Там ровно такое же перечисление с такими же
вариантами, но данные адреса хранятся внутри вариантов в виде двух разных
структур, по-своему устроенных для каждого варианта:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Этот код показывает, что внутрь варианта перечисления можно положить данные любого вида: строки, числа, структуры. Можно даже положить другое перечисление! А ещё видно, что типы стандартной библиотеки зачастую ненамного сложнее того, что могли бы придумать вы сами.
Заметьте: хотя в стандартной библиотеке есть определение IpAddr, мы всё
равно можем создавать и использовать собственное, и конфликта не будет — ведь
определение из стандартной библиотеки мы не подключали в свою область
видимости. Подробнее о подключении типов в область видимости мы поговорим в
главе 7.
Рассмотрим ещё один пример перечисления — в листинге 6-2. В его вариантах хранятся самые разные типы.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message, варианты которого хранят значения разных типов и в разном количествеУ этого перечисления четыре варианта с разными типами:
Quit— никаких связанных данных нет вообще;Move— именованные поля, как у структуры;Write— однаString;ChangeColor— три значенияi32.
Определить перечисление с вариантами, как в листинге 6-2, — почти то же, что
определить несколько разных структур. Разница в том, что у перечисления нет
ключевого слова struct и все варианты собраны вместе под типом Message.
Те же данные, что хранят варианты перечисления, можно было бы хранить в таких
структурах:
struct QuitMessage; // единичная структура
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // кортежная структура
struct ChangeColorMessage(i32, i32, i32); // кортежная структура
fn main() {}
Но у каждой из этих структур был бы свой тип, и написать функцию, которая
принимает любое такое сообщение, было бы не так просто, как с перечислением
Message из листинга 6-2 — ведь это один тип.
Есть у перечислений и структур ещё одно сходство: точно так же, как для
структур определяют методы через impl, их можно определять и для
перечислений. Вот метод call, который можно определить для нашего
перечисления 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();
}
В теле метода self даёт доступ к значению, у которого метод вызван. В этом
примере мы создали переменную m со значением
Message::Write(String::from("hello")) — именно оно и будет self в теле
метода call, когда выполнится m.call().
Теперь посмотрим на другое перечисление из стандартной библиотеки — очень
распространённое и полезное: Option.
Перечисление Option
В этом разделе мы подробно разберём Option — ещё одно перечисление из
стандартной библиотеки. Тип Option описывает очень частую ситуацию: значение
может быть, а может и не быть.
Например, если попросить первый элемент непустого списка, вы получите значение. А если попросить первый элемент пустого списка — ничего не получите. Если выразить это через систему типов, компилятор сможет проверить, что вы обработали все нужные случаи. Это предотвращает ошибки, которые крайне часто встречаются в других языках программирования.
О дизайне языка программирования обычно судят по тому, какие возможности в нём есть, но не менее важно, каких в нём нет. В Rust нет значения null, которое есть во многих других языках. Null — это значение, которое означает, что значения нет. В языках с null переменная всегда находится в одном из двух состояний: null или не null.
В своём выступлении 2009 года «Null References: The Billion Dollar Mistake» («Нулевые ссылки: ошибка на миллиард долларов») изобретатель null Тони Хоар сказал:
Я называю это своей ошибкой на миллиард долларов. Тогда я проектировал первую полноценную систему типов для ссылок в объектно-ориентированном языке. Моей целью было сделать любое использование ссылок абсолютно безопасным, чтобы проверки автоматически выполнял компилятор. Но я не смог устоять перед соблазном добавить нулевую ссылку — просто потому, что её было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и сбоям систем, которые за последние сорок лет, вероятно, принесли боли и ущерба на миллиард долларов.
Проблема с null в том, что если попытаться использовать null как обычное значение, получится какая-нибудь ошибка. А поскольку любое значение может оказаться null или не null, ошибиться таким образом очень легко.
При этом сама идея, которую пытается выразить null, полезна: null — это значение, которое по какой-то причине сейчас недействительно или отсутствует.
Проблема не в самой идее, а в конкретной реализации. Поэтому в Rust нет null,
зато есть перечисление, которое выражает, что значение есть или его нет. Это
перечисление называется Option<T>, и в стандартной библиотеке оно
определено так:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Перечисление Option<T> настолько полезно, что входит в прелюдию: подключать
его в область видимости явно не нужно. Его варианты тоже входят в прелюдию:
Some и None можно использовать напрямую, без префикса Option::. При этом
Option<T> — самое обычное перечисление, а Some(T) и None — его варианты
типа 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;
}
Тип some_number — Option<i32>. Тип some_char — Option<char>, и это
уже другой тип. Rust может вывести эти типы, потому что внутри варианта Some
мы указали значение. А вот для absent_number Rust требует указать тип
Option целиком: по одному лишь значению None компилятор не может понять,
что хранил бы соответствующий вариант Some. Здесь мы говорим Rust, что
absent_number должна иметь тип Option<i32>.
Если у нас значение Some, мы знаем, что значение есть и лежит внутри Some.
Если же у нас None, то в каком-то смысле это то же самое, что null:
действительного значения нет. Так чем же Option<T> лучше 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>`
--> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/ops/arith.rs:98:8
|
= note: `i8` implements `Add`
::: /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/ops/arith.rs:113:0
|
= note: in this macro invocation
--> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/internal_macros.rs:22:8
|
= note: `&i8` implements `Add<i8>`
::: /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/internal_macros.rs:33:8
|
= note: `i8` implements `Add<&i8>`
::: /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/internal_macros.rs:44:8
|
= note: `&i8` implements `Add`
= note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)
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 с другим нужным типом), — и компилятор проследит, чтобы мы обработали
этот случай, прежде чем пользоваться значением.
Иначе говоря, прежде чем выполнять с Option<T> операции типа T, его нужно
превратить в T. Как правило, это помогает поймать одну из самых частых
проблем с null: когда предполагаешь, что что-то не null, а оно на самом деле
null.
Когда исчезает риск ошибочно посчитать значение не-null, код становится
надёжнее. Чтобы значение могло отсутствовать, нужно явно на это согласиться и
сделать его тип Option<T>. А пользуясь таким значением, вы обязаны явно
обработать случай, когда его нет. Везде, где у значения тип не Option<T>,
можно смело считать, что оно не null. Это сознательное решение в дизайне Rust:
ограничить распространение null и повысить безопасность кода.
Как же достать значение T из варианта Some, если у вас значение типа
Option<T>? У перечисления Option<T> множество методов на все случаи жизни —
посмотрите их в документации. Знакомство с методами
Option<T> очень пригодится вам в работе с Rust.
В целом, чтобы использовать значение Option<T>, нужен код, который
обработает каждый вариант. Какой-то код должен выполняться, только когда
значение — Some(T), и этот код может пользоваться внутренним T. А другой
код должен выполняться, только когда значение — None, и значения T у него
нет. Выражение match — конструкция управления потоком, которая с
перечислениями делает ровно это: выполняет разный код в зависимости от
варианта перечисления, и этот код может пользоваться данными внутри
совпавшего значения.