Определение перечисления
Если структуры дают способ группировать связанные поля и данные, например
Rectangle с его width и height, то перечисления дают способ сказать, что
значение является одним вариантом из возможного набора значений. Например, мы
можем захотеть сказать, что Rectangle – это одна из возможных фигур, среди
которых также есть Circle и Triangle. Для этого Rust позволяет кодировать
такие возможности в виде перечисления.
Давайте рассмотрим ситуацию, которую мы можем захотеть выразить в коде, и увидим, почему перечисления в этом случае полезны и подходят лучше, чем структуры. Допустим, нам нужно работать с IP-адресами. Сейчас для IP-адресов используются два основных стандарта: четвертая и шестая версии. Поскольку это единственные возможности для IP-адреса, с которыми столкнется наша программа, мы можем перечислить все возможные варианты; отсюда и происходит название перечисления.
Любой IP-адрес может быть либо адресом четвертой версии, либо адресом шестой версии, но не обоими одновременно. Это свойство IP-адресов делает enum подходящей структурой данных, потому что значение enum может быть только одним из его вариантов. Адреса четвертой и шестой версий при этом по своей сути все еще остаются 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 с помощью structЗдесь мы определили структуру IpAddr с двумя полями: полем kind типа
IpAddrKind (перечисление, которое мы определили ранее) и полем address
типа String. У нас есть два экземпляра этой структуры. Первый – home; в
его поле kind хранится значение IpAddrKind::V4, а связанные данные адреса
равны 127.0.0.1. Второй экземпляр – loopback. В качестве значения kind
у него другой вариант IpAddrKind, V6, и с ним связан адрес ::1. Мы
использовали структуру, чтобы объединить значения kind и address, поэтому
теперь вариант связан со значением.
Однако представить ту же идею с помощью одного только перечисления можно
короче: вместо перечисления внутри структуры мы можем поместить данные прямо в
каждый вариант перечисления. Новое определение enum 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"));
}
Мы прикрепляем данные напрямую к каждому варианту перечисления, поэтому
дополнительная структура не нужна. Здесь также легче увидеть еще одну деталь
работы перечислений: имя каждого варианта enum, который мы определяем, также
становится функцией, создающей экземпляр перечисления. То есть IpAddr::V4()
– это вызов функции, которая принимает аргумент String и возвращает
экземпляр типа IpAddr. Мы автоматически получаем эту функцию-конструктор в
результате определения enum.
У использования enum вместо структуры есть еще одно преимущество: каждый
вариант может иметь разные типы и разное количество связанных данных.
IP-адреса четвертой версии всегда будут иметь четыре числовых компонента со
значениями от 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. В ней есть точно такой enum и такие варианты, какие мы
определили и использовали, но данные адреса встроены внутрь вариантов в форме
двух разных структур, определенных по-разному для каждого варианта:
#![allow(unused)]
fn main() {
struct Ipv4Addr {
// --snip--
}
struct Ipv6Addr {
// --snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
}
Этот код показывает, что внутрь варианта enum можно поместить данные любого вида: например, строки, числовые типы или структуры. Можно даже включить другое перечисление! Кроме того, типы стандартной библиотеки часто не намного сложнее того, что вы могли бы придумать сами.
Обратите внимание: хотя стандартная библиотека содержит определение IpAddr,
мы все равно можем создать и использовать свое собственное определение без
конфликта, потому что мы не ввели определение из стандартной библиотеки в нашу
область видимости. Подробнее о введении типов в область видимости мы поговорим
в главе 7.
Давайте рассмотрим еще один пример enum в листинге 6-2: у него в вариантах встроено большое разнообразие типов.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {}
Message, каждый вариант которого хранит разные количества и типы значенийУ этого enum есть четыре варианта с разными типами:
Quit: вообще не имеет связанных с ним данныхMove: имеет именованные поля, как у структурыWrite: содержит одно значениеStringChangeColor: содержит три значенияi32
Определение enum с такими вариантами, как в листинге 6-2, похоже на
определение разных видов структур, за исключением того, что enum не использует
ключевое слово struct, а все варианты сгруппированы вместе под типом
Message. Следующие структуры могли бы хранить те же данные, что хранят
варианты предыдущего enum:
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
fn main() {}
Но если бы мы использовали разные структуры, каждая из которых имеет свой
собственный тип, нам было бы не так просто определить функцию, принимающую
любой из этих видов сообщений, как с enum Message, определенным в листинге
6-2, потому что он является единым типом.
Между перечислениями и структурами есть еще одно сходство: точно так же, как мы
можем определять методы для структур с помощью impl, мы можем определять
методы и для перечислений. Вот метод с именем call, который мы могли бы
определить для нашего enum Message:
fn main() {
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message {
fn call(&self) {
// method body would be defined here
}
}
let m = Message::Write(String::from("hello"));
m.call();
}
Тело метода использовало бы self, чтобы получить значение, для которого мы
вызвали метод. В этом примере мы создали переменную m со значением
Message::Write(String::from("hello")), и именно этим значением будет self
в теле метода call, когда выполнится m.call().
Давайте рассмотрим еще одно перечисление из стандартной библиотеки, очень
распространенное и полезное: Option.
Перечисление Option
В этом разделе мы разберем пример использования Option – еще одного enum,
определенного стандартной библиотекой. Тип Option кодирует очень
распространенный сценарий, в котором значение может чем-то быть или может быть
ничем.
Например, если вы запросите первый элемент непустого списка, вы получите значение. Если вы запросите первый элемент пустого списка, вы не получите ничего. Выражение этой идеи через систему типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны быть обработаны; эта возможность помогает предотвращать ошибки, чрезвычайно распространенные в других языках программирования.
Проектирование языка программирования часто рассматривают через призму того, какие возможности в него включают, но возможности, которые исключают, тоже важны. В Rust нет возможности null, которая есть во многих других языках. Null – это значение, означающее отсутствие значения. В языках с null переменные всегда могут находиться в одном из двух состояний: null или не-null.
В своей презентации 2009 года «Null References: The Billion Dollar Mistake» Тони Хоар, изобретатель null, сказал об этом так:
Я называю это своей ошибкой на миллиард долларов. В то время я проектировал первую всеобъемлющую систему типов для ссылок в объектно-ориентированном языке. Моей целью было гарантировать, что любое использование ссылок будет абсолютно безопасным, а проверка будет автоматически выполняться компилятором. Но я не смог устоять перед искушением добавить null-ссылку просто потому, что ее было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и сбоям систем, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.
Проблема null-значений в том, что если вы попытаетесь использовать null-значение как значение, которое не является null, вы получите какую-либо ошибку. Поскольку свойство «null или не-null» распространено повсюду, ошибку такого рода сделать чрезвычайно легко.
Однако идея, которую пытается выразить null, все равно полезна: null – это значение, которое сейчас по какой-то причине недопустимо или отсутствует.
Проблема на самом деле не в самой идее, а в конкретной реализации. Поэтому в
Rust нет null, но есть enum, который может кодировать идею наличия или
отсутствия значения. Этот enum называется Option<T>, и он определен
стандартной библиотекой следующим образом:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
Перечисление Option<T> настолько полезно, что даже включено в прелюдию; вам
не нужно явно вводить его в область видимости. Его варианты тоже включены в
прелюдию: вы можете использовать Some и None напрямую, без префикса
Option::. Option<T> при этом остается обычным enum, а Some(T) и None
по-прежнему являются вариантами типа Option<T>.
Синтаксис <T> – это возможность Rust, о которой мы еще не говорили. Это
обобщенный параметр типа, и обобщения мы подробнее рассмотрим в главе 10. Пока
вам достаточно знать, что <T> означает: вариант Some enum 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: компилятор не может вывести тип, который будет хранить
соответствующий вариант Some, глядя только на значение None. Здесь мы
говорим 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>`:
`&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>, потому что это разные типы. Когда в Rust у нас
есть значение такого типа, как i8, компилятор гарантирует, что у нас всегда
есть допустимое значение. Мы можем уверенно продолжать работу, не проверяя
значение на null перед его использованием. Только когда у нас есть Option<i8>
(или Option с любым другим типом значения, с которым мы работаем), нам нужно
думать о возможном отсутствии значения, и компилятор проследит, чтобы мы
обработали этот случай перед использованием значения.
Иными словами, нужно преобразовать Option<T> в T, прежде чем можно будет
выполнять с ним операции для T. В общем случае это помогает поймать одну из
самых распространенных проблем с null: предположение, что что-то не является
null, хотя на самом деле является.
Устранение риска ошибочно предположить, что значение не равно null, помогает
быть увереннее в коде. Чтобы иметь значение, которое потенциально может быть
null, нужно явно согласиться на это, сделав тип этого значения Option<T>.
Затем, когда вы используете это значение, от вас требуется явно обработать
случай, когда значение отсутствует. Везде, где значение имеет тип, отличный от
Option<T>, вы можете безопасно предполагать, что значение не является
null. Это было осознанным проектным решением Rust: ограничить
распространенность null и повысить безопасность кода на Rust.
Так как же получить значение T из варианта Some, когда у вас есть значение
типа Option<T>, чтобы затем использовать это значение? У enum Option<T>
есть большое количество методов, полезных в самых разных ситуациях; вы можете
изучить их в его документации. Знакомство с методами
Option<T> будет чрезвычайно полезно на вашем пути в Rust.
В целом, чтобы использовать значение Option<T>, вам нужен код, который
обработает каждый вариант. Вам нужен один код, который выполнится только при
наличии значения Some(T), и этому коду разрешено использовать внутреннее
значение T. Вам нужен другой код, который выполнится только при значении
None, и у этого кода нет доступного значения T. Выражение match – это
конструкция управления потоком выполнения, которая при использовании с enum
делает именно это: она выполняет разный код в зависимости от того, какой
вариант enum у нее есть, и этот код может использовать данные внутри
совпавшего значения.