Определение перечисления
Давайте посмотрим на ситуацию, которую мы могли бы выразить в коде, и рассмотрим почему перечисления полезны и более уместны чем структуры в данном случае. Представим, что нам нужно работать с IP-адресами. В настоящее время используются два основных стандарта IP-адресов: версия четыре и версия шесть. Это единственные варианты IP адресов, с которым столкнётся наша программа: мы можем перечислить (enumerate) все возможные варианты, отсюда и появляется понятие перечисление (enumeration, enum).
Любой IP-адрес может быть либо адресом версии четыре, либо версии шесть - но не может быть одновременно и шестой и четвёртой версии. Это свойство IP-адресов делает перечисление подходящей структурой данных для их хранения, т.к. значения enum, как и версия 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"), }; }
Листинг 6-1. Сохранение данных и IpAddrKind
IP-адреса с использованием struct
Здесь мы определили структуру IpAddr
, которая имеет два поля: поле kind
имеет тип IpAddrKind
(перечисление, которое мы определили ранее) и поле address
типа String
. У нас есть два экземпляра этой структуры. Первый, home
, имеет значение kind
равное IpAddrKind::V4
и связан с адресом 127.0.0.1
. Второй экземпляр, loopback
, имеет другой вариант IpAddrKind
качестве значения kind
- вариант 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
. Мы автоматически получаем эту функцию-конструктор, определяемую в результате определения перечисления.
Ещё одно преимущество использования перечисления вместо структуры заключается в том, что каждый вариант перечисления может иметь разное количество ассоциированных данных представленных в разных типах. Версия 4 для типа 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
: она так же как и у нас имеет аналогичное перечисление с аналогичными вариантами (подобными тем, которые мы определили и использовали ранее), но она представляет (а затем и встраивает в варианты) данные IP-адресов в форме двух разных структур, которые определяются по-разному для каждого варианта.
#![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() {}
Листинг 6-2. Перечисление Message
, в каждом из вариантов которого хранятся разные количества и типы значений.
Это перечисление имеет 4 элемента:
Quit
- пустой элемент без ассоциированных данных,Move
имеет именованные поля, как и структура.Write
- элемент с единственной строкой типаString
,ChangeColor
- кортеж из трёх значений типаi32
.
Определение перечисления с вариантами, такими как в листинге 6-2, похоже на определение значений различных типов внутри структур, за исключением того, что перечисление не использует ключевое слово struct
и все варианты сгруппированы внутри типа Message
. Следующие структуры могут содержать те же данные, что и предыдущие варианты перечислений:
struct QuitMessage; // unit struct struct MoveMessage { x: i32, y: i32, } struct WriteMessage(String); // tuple struct struct ChangeColorMessage(i32, i32, i32); // tuple struct 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) { // 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
и его преимущества перед Null-значениями
В предыдущем разделе мы рассмотрели, как перечисление IpAddr
позволило нам использовать систему типов Rust для кодирования в программе большего количества информации, чем просто данные. В этом разделе рассматривается пример использования Option
, ещё одного перечисления, определённого стандартной библиотекой. Тип Option
используется во многих местах, потому что он кодирует очень распространённый сценарий, в котором значение может быть чем-то или может быть ничем. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработаны ли все случаи для данного типа, которые должны обрабатываться; такого рода проверка компилятора может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.
Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null).
В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее:
Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок на объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок должно быть абсолютно безопасным, с автоматической проверкой компилятором. Но я не мог устоять перед соблазном вставить пустую ссылку просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.
Проблема с 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_string
- Option<&str>
, который другого типа. 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>
даже если бы оно было определённо допустимым вариантом Some
. Например, этот код не будет компилироваться, потому что он пытается добавить 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`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error
Сильно! Фактически, это сообщение об ошибке означает, что Rust не понимает, как сложить i8
и Option<i8>
, потому что это разные типы. Когда у нас есть значение типа на подобие i8
, компилятор гарантирует, что у нас всегда есть допустимое значение типа. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Однако, когда у нас есть значение типа Option<T>
(где T
- это любое значение любого типа T
, упакованное в Option
, например значение типа i8
или String
), мы должны беспокоиться о том, что значение типа T возможно не имеет значения (является вариантом None
), и компилятор позаботится о том, чтобы мы обработали такой случай, прежде чем мы бы попытались использовать None
значение.
Другими словами, вы должны преобразовать Option<T>
в T
прежде чем вы сможете выполнять операции с этим T
. Как правило, это помогает выявить одну из наиболее распространённых проблем с null: когда мы предполагаем, что что-то не равно null, хотя на самом деле оно null.
С Rust не нужно беспокоиться о неправильном предположении касательно не-null значения, это помогает чувствовать себя более уверенно. Для того, чтобы иметь значение, которое может быть null, вы должны явно сказать об этом, указав тип T
этого значения как Option<T>
(обернуть его в Option). Затем, когда вы используете это значение, вы обязаны явно обрабатывать случай, когда значение равно null. Везде, где значение имеет тип, не являющий Option<T>
, вы можете смело рассчитывать на то, что значение не равно null. Такой подход - продуманное проектное решение в Rust, ограничивающее распространение null и увеличивающее безопасность Rust кода.
Итак, как мы можем получить желанное значение типа T
, упакованное в варианте Some
типа Option
, когда у нас на руках есть только значение типа Option<T>
? Option<T>
имеет большое количество методов, которые полезны в различных ситуациях; можно проверить их в документации. Знакомство с методами в Option<T>
будет чрезвычайно полезным в вашем путешествии по языку Rust.
В общем случае, чтобы использовать значение Option<T>
, нужен код, который будет обрабатывать все варианты перечисления Option<T>
. Вам понадобится некоторый код, который будет работать только тогда, когда у вас есть значение Some(T)
, и этому коду разрешено использовать внутреннее T
. Также вам понадобится другой код, который будет работать, если у вас есть значение None
, и у этого кода не будет доступного значения T
. Выражение match
— это конструкция управления потоком выполнения программы, которая делает именно это при работе с перечислениями: она запускает разный код в зависимости от того, какой вариант перечисления имеется, и этот код может использовать данные, находящиеся внутри совпавшего варианта.