Введение
Добро пожаловать в Определение перечисления. Эта лабораторная работа является частью Rust Book. Вы можете практиковать свои навыки Rust в LabEx.
В этой лабораторной работе мы определим перечисление IpAddrKind, которое представляет возможные виды IP-адресов, включая версию четыре (V4) и версию шесть (V6).
Определение перечисления
Структуры позволяют вам группировать связанные поля и данные, например, Rectangle с его width и height. Перечисления же дают вам способ указать, что значение является одним из возможных наборов значений. Например, мы можем сказать, что Rectangle является одним из возможных фигур, которые также включают Circle и Triangle. Для этого Rust позволяет нам закодировать эти возможности в виде перечисления.
Рассмотрим ситуацию, которую мы можем захотеть выразить в коде, и увидим, почему перечисления полезны и более подходят, чем структуры в этом случае. Предположим, что нам нужно работать с IP-адресами. В настоящее время для IP-адресов используются два основных стандарта: версия четыре и версия шесть. Поскольку эти два варианта - единственные, которые могут встретиться в нашей программе, мы можем перечислить все возможные варианты, откуда и взялось название "перечисление".
Любой IP-адрес может быть либо адресом версии четыре, либо адресом версии шесть, но не одновременно. Эта особенность IP-адресов делает структуру данных перечисления подходящей, потому что значение перечисления может быть только одним из его вариантов. И адреса версии четыре, и адреса версии шесть по-прежнему по своей本质 являются IP-адресами, поэтому они должны быть обработаны как один и тот же тип, когда код обрабатывает ситуации, которые относятся к любому типу IP-адреса.
Мы можем выразить этот концепт в коде, определив перечисление IpAddrKind и перечислив возможные виды IP-адресов: V4 и V6. Эти являются вариантами перечисления:
enum IpAddrKind {
V4,
V6,
}
IpAddrKind теперь является пользовательским типом данных, который мы можем использовать в других частях нашего кода.
Значения перечисления
Мы можем создать экземпляры каждого из двух вариантов IpAddrKind следующим образом:
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
Обратите внимание, что варианты перечисления находятся в пространстве имен под его идентификатором, и мы используем двойную точку, чтобы разделить их. Это полезно, потому что теперь оба значения IpAddrKind::V4 и IpAddrKind::V6 имеют один и тот же тип: IpAddrKind. Затем мы, например, можем определить функцию, которая принимает любой IpAddrKind:
fn route(ip_kind: IpAddrKind) {}
И мы можем вызвать эту функцию с любым вариантом:
route(IpAddrKind::V4);
route(IpAddrKind::V6);
Использование перечислений имеет еще больше преимуществ. Подумаем больше о типе нашего IP-адреса. В данный момент у нас нет способа хранить фактические данные IP-адреса; мы знаем только, какой вид он. С учетом того, что вы только что узнали о структурах в главе 5, вы, возможно, захотите решить эту проблему с использованием структур, как показано в листинге 6-1.
1 enum IpAddrKind {
V4,
V6,
}
2 struct IpAddr {
3 kind: IpAddrKind,
4 address: String,
}
5 let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
6 let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};
Листинг 6-1: Сохранение данных и варианта IpAddrKind IP-адреса с использованием struct
Здесь мы определили структуру IpAddr [2], которая имеет два поля: поле kind [3], которое имеет тип IpAddrKind (перечисление, которое мы определили ранее [1]), и поле address [4] типа String. У нас есть два экземпляра этой структуры. Первый - это home [5], и у него значение IpAddrKind::V4 в качестве kind с ассоциированными данными адреса 127.0.0.1. Второй экземпляр - это loopback [6]. Он имеет другой вариант IpAddrKind в качестве значения kind, V6, и имеет адрес ::1, связанный с ним. Мы использовали структуру, чтобы связать значения kind и address вместе, так что теперь вариант связан с значением.
Однако представление того же концепта с использованием только перечисления гораздо более компактно: вместо перечисления внутри структуры мы можем напрямую поместить данные в каждый вариант перечисления. Эта новая определение перечисления IpAddr говорит, что оба варианта V4 и V6 будут иметь ассоциированные значения String:
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-адреса версии четыре всегда будут иметь четыре числовых компонента, значения которых будут находиться в диапазоне от 0 до 255. Если бы мы хотели хранить адреса V4 в виде четырех значений u8, но по-прежнему представлять адреса V6 в виде одного значения String, мы не могли бы этого сделать с использованием структуры. Перечисления обрабатывают этот случай с легкостью:
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: у нее есть такое же перечисление и варианты, как мы определили и использовали, но она встраивает данные адреса внутри вариантов в виде двух разных структур, которые определяются по-разному для каждого варианта:
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),
}
Листинг 6-2: Перечисление Message, варианты которого хранят разные количества и типы значений
Это перечисление имеет четыре варианта с разными типами:
Quitвообще не имеет ассоциированных данных.Moveимеет именованные поля, как и структура.Writeвключает в себя однуString.ChangeColorвключает три значенияi32.
Определение перечисления с вариантами, подобными тем, что показаны в листинге 6-2, похоже на определение разных типов структур, за исключением того, что перечисление не использует ключевое слово struct, и все варианты группируются вместе под типом Message. Следующие структуры могли бы содержать те же данные, что и предыдущие варианты перечисления:
struct QuitMessage; // единичная структура
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // кортежная структура
struct ChangeColorMessage(i32, i32, i32); // кортежная структура
Но если бы мы использовали разные структуры, каждая из которых имеет свой собственный тип, мы не могли бы так легко определить функцию, которая принимает любой из этих типов сообщений, как мы могли бы сделать с перечислением Message, определенным в листинге 6-2, которое является одним типом.
Есть еще одна сходство между перечислениями и структурами: так же, как мы можем определить методы для структур с использованием impl, мы также можем определить методы для перечислений. Вот метод под названием call, который мы могли бы определить для нашего перечисления Message:
impl Message {
fn call(&self) {
1 // тело метода будет определено здесь
}
}
2 let m = Message::Write(String::from("hello"));
m.call();
Тело метода будет использовать self, чтобы получить значение, для которого был вызван метод. В этом примере мы создали переменную m [2], которая имеет значение Message::Write(String::from("hello")), и это то, что будет self в теле метода call [1], когда выполняется m.call().
Посмотрим на еще одно перечисление в стандартной библиотеке, которое очень распространено и полезно: Option.
Перечисление Option и его преимущества по сравнению с null-значениями
В этом разделе мы рассмотрим пример использования Option, которое является еще одним перечислением, определенным в стандартной библиотеке. Тип Option кодирует очень распространенную ситуацию, когда значение может быть чем-то или быть отсутствующим.
Например, если вы запрашиваете первый элемент в списке, содержащем несколько элементов, вы получите значение. Если вы запрашиваете первый элемент в пустом списке, вы ничего не получите. Выражение этой концепции в рамках типовой системы означает, что компилятор может проверить, обрабатываете ли вы все случаи, которые должны быть обработаны; эта функциональность может предотвратить ошибки, которые встречаются очень часто в других языках программирования.
В дизайне языков программирования часто говорят о том, какие функции вы включаете, но важны и те, которые вы исключаете. Rust не имеет функции null, как это есть в многих других языках. Null - это значение, означающее, что там нет значения. В языках с null переменные всегда могут находиться в одном из двух состояний: null или not-null.
В своем докладе "Null References: The Billion Dollar Mistake" в 2009 году Tony Hoare, изобретатель null, говорит следующее:
Я называю это моей ошибкой на миллиард долларов. Тогда я проектировал первую всеобъемлющую типовую систему для ссылок в объектно-ориентированном языке. Моя цель была гарантировать, что все использование ссылок будет абсолютно безопасным, с автоматической проверкой компилятором. Но я не смог сопротивиться соблазну вставить null-референс, просто потому, что он был настолько легко реализовать. Это привело к innumerable ошибкам, уязвимостям и сбоям системы, которые, вероятно, вызвали миллиард долларов урона и ущерба за последние сорок лет.
Проблема с null-значениями заключается в том, что если вы пытаетесь использовать null-значение как not-null-значение, вы получите какую-то ошибку. Поскольку эта особенность null или not-null является всеобщей, очень легко допустить этот тип ошибки.
Однако концепция, которую пытается выразить null, по-прежнему полезна: null - это значение, которое в настоящее время недопустимо или отсутствует по какой-то причине.
Проблема не в самой концепции, а в конкретной реализации. Поэтому Rust не имеет null, но имеет перечисление, которое может закодировать концепцию наличия или отсутствия значения. Это перечисление - Option<T>, и оно определено в стандартной библиотеке следующим образом:
enum Option<T> {
None,
Some(T),
}
Перечисление Option<T> настолько полезно, что даже включено в прелюд (prelude); вам не нужно явно подключать его в область видимости. Его варианты также включены в прелюд: вы можете напрямую использовать Some и None без префикса Option::. Перечисление Option<T> по-прежнему является обычным перечислением, и Some(T) и None по-прежнему являются вариантами типа Option<T>.
Синтаксис <T> - это особенность Rust, о которой мы еще не говорили. Это обобщенный параметр типа, и мы рассмотрим обобщения более подробно в главе 10. На данный момент все, что вам нужно знать, - это то, что <T> означает, что вариант Some перечисления Option может содержать одно значение любого типа, и каждый конкретный тип, используемый вместо T, делает весь тип Option<T> другим типом. Вот несколько примеров использования значений Option для хранения числовых типов и строковых типов:
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>:
let x: i8 = 5;
let y: Option<i8> = Some(5);
let sum = x + y;
Если мы запустим этот код, мы получим сообщение об ошибке, похожее на это:
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`
Очень серьезная ошибка! По сути, это сообщение об ошибке означает, что Rust не понимает, как сложить i8 и Option<i8>, потому что они - разные типы. Когда у нас есть значение типа i8 в Rust, компилятор гарантирует, что у нас всегда есть действительное значение. Мы можем с уверенностью продолжать работу, не проверяя на null перед использованием этого значения. Только когда у нас есть Option<i8> (или любой другой тип значения, с которым мы работаем), мы должны беспокоиться о том, что может не быть значения, и компилятор убедит нас обработать этот случай перед использованием значения.
Другими словами, вы должны преобразовать Option<T> в T, прежде чем сможете выполнять операции с T. В целом, это помогает избежать одной из самых распространенных проблем с null: полагаться на то, что что-то не null, когда на самом деле оно является null.
Исключение риска неправильного полагания, что значение не null, помогает вам быть более уверенным в своем коде. Чтобы иметь значение, которое может быть null, вы должны явно указать это, сделав тип этого значения Option<T>. Затем, когда вы используете это значение, вы должны явно обработать случай, когда значение равно null. Везде, где значение имеет тип, отличный от Option<T>, вы можете безопасно полагаться на то, что значение не null. Это была сознательная décision в дизайне Rust, чтобы ограничить распространенность null и повысить безопасность кода Rust.
Итак, как получить значение T из варианта Some, когда у вас есть значение типа Option<T>, чтобы вы могли использовать это значение? Перечисление Option<T> имеет большое количество методов, которые полезны в различных ситуациях; вы можете ознакомиться с ними в его документации. Закрепление методов на Option<T> будет очень полезно для вас в вашем опыте работы с Rust.
В целом, чтобы использовать значение Option<T>, вы должны написать код, который будет обрабатывать каждый вариант. Вы хотите, чтобы какой-то код работал только, когда у вас есть значение Some(T), и этот код может использовать внутреннее значение T. Вы хотите, чтобы другой код работал только, если у вас есть значение None, и у этого кода нет доступного значения T. Выражение match - это конструкция управления потоком, которая делает именно это, когда используется с перечислениями: оно будет запускать разные коды в зависимости от варианта перечисления, и этот код может использовать данные внутри совпадающего значения.
Резюме
Поздравляем! Вы завершили лабораторную работу по определению перечисления. Вы можете выполнить больше лабораторных работ в LabEx, чтобы улучшить свои навыки.