Определение перечисления
Там, где структуры дают вам возможность группировать связанные поля и данные, например 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"), }; }
Листинг 6-1. Сохранение данных и IpAddrKind
IP-адреса с использованием struct
Здесь мы определили структуру IpAddr
, у которой есть два поля: kind
типа IpAddrKind
(перечисление, которое мы определили ранее) и address
типа String
. У нас есть два экземпляра этой структуры. Первый - home
, который является IpAddrKind::V4
в качестве значения kind
с соответствующим адресом 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
. Мы автоматически получаем эту функцию-конструктор, определяемую в результате определения перечисления.
Ещё одно преимущество использования перечисления вместо структуры заключается в том, что каждый вариант перечисления может иметь разное количество ассоциированных данных представленных в разных типах. Версия 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
: в ней есть точно такое же перечисление с вариантами, которое мы определили и использовали, но она помещает данные об адресе внутрь этих вариантов в виде двух различных структур, которые имеют различные определения для каждого из вариантов:
#![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-значениями
В этом разделе рассматривается пример использования Option
, ещё одного перечисления, определённого в стандартной библиотеке. Тип Option
кодирует очень распространённый сценарий, в котором значение может быть чем-то, а может быть ничем.
Например, если вы запросите первое значение из списка, содержащего элементы, вы получите значение. Если вы запросите первое значение из пустого списка, вы ничего не получите. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны были обработать; эта функциональность может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.
Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null).
В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее:
Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок на объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок должно быть абсолютно безопасным, с автоматической проверкой компилятором. Но я не мог устоять перед соблазном вставить пустую ссылку просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.
Проблема с null значениями заключается в том, что если вы попытаетесь использовать null значение в качестве not-null значения, вы получите ошибку определённого рода. Поскольку свойство null или not-null распространено повсеместно, сделать такую ошибку очень просто.
Тем не менее, концепция, которую null пытается выразить, является полезной: null - это значение, которое в настоящее время по какой-то причине недействительно или отсутствует.
Проблема с null значениями заключается в том, что если вы попытаетесь использовать null значение как не-null значение, вы получите какую-то ошибку. Поскольку это свойство null или не-null широко распространено, очень легко совершить такую ошибку.
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
Перечисление Option<T>
настолько полезно, что оно даже включено в прелюдию; вам не нужно явно вводить его в область видимости. Его варианты также включены в прелюдию: вы можете использовать Some
и None
напрямую, без префикса Option::
. При всём при этом, Option<T>
является обычным перечислением, а Some(T)
и None
представляют собой его варианты.
Проблема не в самой концепции, а в конкретной реализации. Таким образом, в Rust нет null-значений, но есть перечисление, которое может закодировать концепцию наличия или отсутствия значения. Это перечисление Option<T>
и оно определено в стандартной библиотеке следующим образом:
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>
даже если бы оно было определённо допустимым вариантом 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`
= help: the following other types implement trait `Add<Rhs>`:
<&'a i8 as Add<i8>>
<&i8 as Add<&i8>>
<i8 as Add<&i8>>
<i8 as Add>
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.
Устранение риска ошибочного предположения касательно не-null значения помогает вам быть более уверенным в своём коде. Чтобы иметь значение, которое может быть null, вы должны явно описать тип этого значения с помощью Option<T>
. Затем, когда вы используете это значение, вы обязаны явно обрабатывать случай, когда значение равно null. Везде, где значение имеет тип, отличный от Option<T>
, вы можете смело рассчитывать на то, что значение не равно null. Это продуманное проектное решение в Rust, ограничивающее распространение null и увеличивающее безопасность кода на Rust.
Итак, как же получить значение T
из варианта Some
, если у вас на руках есть только объект Option<T>
, и как можно его, вообще, использовать? Перечисление Option<T>
имеет большое количество методов, полезных в различных ситуациях; вы можете ознакомиться с ними в его документации. Знакомство с методами перечисления Option<T>
будет чрезвычайно полезным в вашем путешествии с Rust.
В общем случае, чтобы использовать значение Option<T>
, нужен код, который будет обрабатывать все варианты перечисления Option<T>
. Вам понадобится некоторый код, который будет работать только тогда, когда у вас есть значение Some(T)
, и этому коду разрешено использовать внутреннее T
. Также вам понадобится другой код, который будет работать, если у вас есть значение None
, и у этого кода не будет доступного значения T
. Выражение match
— это конструкция управления потоком выполнения программы, которая делает именно это при работе с перечислениями: она запускает разный код в зависимости от того, какой вариант перечисления имеется, и этот код может использовать данные, находящиеся внутри совпавшего варианта.