Определение перечисления
Там, где структуры дают вам возможность группировать связанные поля и данные (например, 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"), }; }
Здесь мы определили структуру 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
. Эту функцию-конструктор мы получаем автоматически, когда определяем перечисление.
Ещё одно преимущество использования перечисления вместо структуры заключается в том, что каждый вариант перечисления может иметь разное количество связанных с ним данных, представленных в разных типах. IP-адреса 4ой версии всегда будет содержать четыре цифровых компонента, которые будут иметь значения между 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-адреса и указывать их тип настолько распространена, что в стандартной библиотеке уже есть готовое определение! В нём есть точно такое же перечисление с вариантами, которое определили и использовали мы, но она помещает данные об адресе внутрь этих вариантов в виде двух различных структур, которые имеют различные определения для каждого из вариантов:
#![allow(unused)] fn main() { struct Ipv4Addr { // --код сокращён-- } struct Ipv6Addr { // --код сокращён-- } 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() {}
Это перечисление имеет 4 варианта:
Quit
— вариант без каких-либо связанных с ним значений.Move
— вариант с именованными значениями, как у структуры.Write
— вариант, имеющий лишьString
.ChangeColor
— вариант, имеющий три числаi32
.
Определение перечисления с вариантами (такими, как в Листинге 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.
Есть ещё одно сходство между перечислениями и структурами: так же, как мы можем определять методы структур с помощью блока 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
и его преимущества перед значениями Null
В этом разделе рассматривается пример использования Option
— ещё одного перечисления, которое определено в стандартной библиотеке. Тип Option
реализует очень распространённый случай, в котором значение может быть чем-то, а может быть ничем.
Например, если вы запросите первый элемент из непустого списка, вы получите значение. Если вы запросите первый элемент пустого списка, вы получите ничего. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны были обработать; эта функциональность может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.
Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но также важно то, какие функции вы в него не включаете. Например, в Rust нет понятия "null", однако оно есть во многих других языках. Null — это значение, которое означает, что значения нет. В языках с null переменные всегда могут находиться в одном из двух состояний: null или не-null.
В своей презентации 2009 года "Null References: The Billion Dollar Mistake" Тони Хоар, автор концепции null, сказал следующее:
Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок в объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок будет абсолютно безопасным, с автоматической проверкой компилятором. Но я не смог устоять перед соблазном внедрить в язык пустую ссылку — просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.
Проблема с null заключается в том, что если вы попытаетесь использовать null в качестве не-null значения, вы получите ошибку определённого рода. Поскольку эта возможность (быть null или не-null) распространена повсеместно, сделать такую ошибку очень просто.
Тем не менее, концепция, которую null пытается выразить, является полезной: null — это значение, которое в настоящее время по какой-то причине недействительно или отсутствует.
Проблема на самом деле не в концепции, а в конкретной реализации. В Rust нет значений null, но есть перечисление, которое может выразить концепцию присутствия или отсутствия значения. Это перечисление Option<T>
, и оно определено стандартной библиотекой следующим образом:
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
Перечисление Option<T>
настолько полезно, что оно даже включено в prelude; вам не нужно самому вводить его в область видимости. Его варианты также включены в prelude: вы можете использовать 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
: компилятор не может вывести тип, который будет в 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>`:
`&'a i8` implements `Add<i8>`
`&i8` implements `Add<&i8>`
`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.
Устранение риска ошибочного предположения касательно не-null значения помогает вам быть более уверенным в своём коде. Чтобы иметь значение, которое может быть null, вы должны явно описать тип этого значения с помощью Option<T>
. Затем, когда вы используете это неопределённое значение, вы обязаны явно обрабатывать случай, когда значение — null. Везде, где значение имеет тип, отличный от Option<T>
, вы можете смело рассчитывать на то, что значение не равно null. Это — продуманное решение архитектуры Rust, ограничивающее распространение null и увеличивающее безопасность кода на этом языке.
Итак, как же получить значение T
из варианта Some
, если у вас на руках есть только значение Option<T>
? Перечисление Option<T>
имеет большое количество методов, полезных в различных ситуациях; вы можете ознакомиться с ними в его документации. Знакомство с методами перечисления Option<T>
в вашем путешествии с Rust будет чрезвычайно полезным.
В общем случае, чтобы использовать значение Option<T>
, нужен код, который будет обрабатывать все варианты этого перечисления. Если у вас есть значение Some(T)
, вам нужен один код для его обработки, и этому коду должно быть разрешено работать с этим внутренним T
. Если же у вас есть значение None
, то вам захочется обрабатывать его иначе, при этом у этого кода не будет никакого значения T
для обработки. Выражение match
— это конструкция управления потоком исполнения программы, которая делает с перечислениями именно всё, что мы только что сказали: она запускает разный код в зависимости от того, какой вариант перечисления ей предоставлен, и этот код может использовать данные, находящиеся внутри варианта, отвечающего шаблону.