Определение перечисления
Если структуры дают вам способ группировать связанные поля и данные, например
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.
Здесь мы определили структуру IpAddr, которая имеет два поля: поле kind
типа IpAddrKind (перечисление, которое мы определили ранее) и поле address
типа String. У нас есть два экземпляра этой структуры. Первый — home, и у
него значение IpAddrKind::V4 в качестве kind с связанными данными адреса
127.0.0.1. Второй экземпляр — loopback. У него другой вариант
IpAddrKind в качестве значения kind, V6, и связанный с ним адрес ::1.
Мы использовали структуру, чтобы сгруппировать значения kind и address,
теперь вариант связан со значением.
Однако представление той же концепции с помощью только перечисления более
лаконично: вместо перечисления внутри структуры мы можем поместить данные
непосредственно в каждый вариант перечисления. Это новое определение перечисления
IpAddr говорит, что оба варианта V4 и V6 будут иметь связанные значения
String:
Мы присоединяем данные к каждому варианту перечисления напрямую, поэтому нет
необходимости в дополнительной структуре. Здесь также легче увидеть другую
особенность работы перечислений: имя каждого определяемого нами варианта
перечисления также становится функцией, которая создаёт экземпляр перечисления.
То есть IpAddr::V4() — это вызов функции, которая принимает аргумент String
и возвращает экземпляр типа IpAddr. Мы автоматически получаем эту функцию-
конструктор в результате определения перечисления.
Есть ещё одно преимущество использования перечисления вместо структуры: каждый
вариант может иметь разные типы и количество связанных данных. IP-адреса четвёртой
версии всегда будут иметь четыре числовых компонента со значениями от 0 до 255.
Если бы мы хотели хранить адреса V4 как четыре значения u8, но по-прежнему
выражать адреса V6 как одно значение String, мы не смогли бы этого сделать со
структурой. Перечисления легко справляются с этим случаем:
Мы показали несколько разных способов определения структур данных для хранения
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: Включает однуStringChangeColor: Включает три значения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 или не-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> настолько полезно, что оно даже включено в прелюдию; вам
не нужно явно вводить его в область видимости. Его варианты также включены в
прелюдию: вы можете использовать Some и None напрямую без префикса Option::.
Option<T> по-прежнему просто обычное перечисление, и Some(T) и None — это
всё ещё варианты типа Option<T>.
Синтаксис <T> — это особенность Rust, о которой мы ещё не говорили. Это
обобщённый параметр типа, и мы подробнее рассмотрим обобщения в главе 10. Пока
всё, что вам нужно знать, это то, что <T> означает, что вариант Some
перечисления Option может хранить один фрагмент данных любого типа, и что каждый
конкретный тип, который используется вместо T, делает общий тип Option<T>
другим типом. Вот несколько примеров использования значений Option для хранения
числовых типов и типов символов:
Тип 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>, потому что это разные типы. Когда у нас есть значение
типа, такого как i8 в Rust, компилятор обеспечит, чтобы у нас всегда было
действительное значение. Мы можем действовать уверенно, не проверяя null перед
использованием этого значения. Только когда у нас есть Option<i8> (или любой
другой тип значения, с которым мы работаем), мы должны беспокоиться о возможном
отсутствии значения, и компилятор убедится, что мы обработаем этот случай перед
использованием значения.
Другими словами, вы должны преобразовать 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>, вы хотите иметь код, который будет
обрабатывать каждый вариант. Вы хотите, чтобы некоторый код выполнялся только при
наличии значения Some(T), и этот код может использовать внутреннее T. Вы
хотите, чтобы другой код выполнялся только при наличии значения None, и у этого
кода нет доступного значения T. Выражение match — это конструкт управления
потоком выполнения, который делает именно это при использовании с перечислениями:
оно будет выполнять разный код в зависимости от того, какой вариант перечисления у
него есть, и этот код может использовать данные внутри сопоставляемого значения.