Визначення Енума
Якщо структури надають спосіб групування пов'язаних полів і даних, як Rectangle
з його width
і height
, то енуми дають вам спосіб виразити значення, що є одним з можливого набору значень. Скажімо, ми хочемо сказати, що Rectangle
є однією з можливих фігур, які також включають Circle
(круг) і Triangle
(трикутник). Для цього Rust надає нам можливість закодувати ці варіанти у енум.
Розгляньмо ситуацію, яку ми можемо захотіти виразити в коді, і побачимо, чому енуми корисні і краще за структури підходять для цієї ситуації. Нехай нам потрібно працювати із IP-адресами. Наразі використовується два стандарти IP-адрес, четверта та шоста версії. Оскільки це єдині можливі IP-адреси, які наша програма може зустріти, ми можемо перелічити (enumerate) усі можливі варіанти, звідси й назва для енумів.
Будь-яка 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) {}
Але використання enum-ів дає ще більше переваг. Наразі ми не маємо способу зберігати власне дані 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-адреси четвертої версії завжди складаються з чотирьох числових компонентів зі значеннями між 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
: там є точно такий enum і варіанти, як і ті, що ми визначили, але дані адрес усередині варіантів представлені двома різними структурами, які визначені окремо для кожного варіанту:
#![allow(unused)] fn main() { struct Ipv4Addr { // --snip-- } struct Ipv6Addr { // --snip-- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
Цей код показує, що ми можемо помістити будь-який вид даних усередину варіанту енума: стрічки, числові типи, структури тощо. Можна навіть вкласти інший енум! Також типи стандартної бібліотеки часто не набагато складніші за те, що ви б могли самі придумати.
Зверніть увагу, що хоча стандартна бібліотека містить визначення IpAddr
, ми можемо створити і користуватися нашим власним визначенням без конфлікту, бо ми не ввели визначення зі стандартної бібліотеки до області видимості програми. Ми ще поговоримо про введення до області видимості в Розділі 7.
Let’s look at another example of an enum in Listing 6-2: this one has a wide variety of types embedded in its variants.
enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn main() {}
Цей енум має чотири варіанти різних типів:
Quit
("вийти") не має пов'язаних даних.Move
("перейти") має всередині анонімний struct.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()
.
Let’s look at another enum in the standard library that is very common and useful: Option
.
Енум Option
і Його Переваги над Null-Значеннями
Цей підрозділ розглядає використання Option
, ще одного енума, визначеного в стандартній бібліотеці. Тип Option
кодує дуже поширену ситуацію, де значення може бути чи його може не бути.
Наприклад, якщо ви запитуєте перший елемент з непорожнього списку, ви отримаєте значення. Якщо ж ви запитаєте перший елемент порожнього списку, ви не отримаєте нічого. Те, що ця концепція виражена в системі типів, означає, що компілятор може перевірити, що ви обробили всі можливі варіанти, які потребують обробки; цей функціонал запобігає вкрай поширеним в інших мовах програмування вадам.
Дизайн мови програмування часто оцінюють за тим, який функціонал у ній є; але функціонал, який свідомо уникли, також важливий. Rust не має такої особливості, як null, що є в багатьох інших мовах. Null - це значення, що означає відсутність значення. У мовах із null змінні завжди можуть бути в одному з двох станів: null і не-null.
In his 2009 presentation “Null References: The Billion Dollar Mistake,” Tony Hoare, the inventor of null, has this to say:
Я називаю це своєю помилкою на мільярд доларів. У той час я розробляв першу всеосяжну систему типів для посилань в об'єктноорієнтованій мові. Моєю метою було переконатися, що всі використання посилань будуть абсолютно безпечними, з автоматичною перевіркою компілятором. Але я не міг опиратися спокусі додати нульове посилання просто тому, що його було так легко реалізувати. Це призвело до незлічених помилок, вразливостей і системних збоїв, які, мабуть, коштували мільярд доларів болю і шкоди за останні 40 років.
Проблема з null-значеннями полягає в тому, що якщо ви спробуєте використовувати значення, яке є null, ніби це не null, ви дістанете помилку. А оскільки ця властивість є поширеною, стає неймовірно просто помилитися таким чином.
However, the concept that null is trying to express is still a useful one: a null is a value that is currently invalid or absent for some reason.
Проблема насправді не в самій концепції, а в конкретній реалізації. Відтак Rust не має null-значень, але має енум, що представляє концепцію присутнього чи відсутнього значення. Цей енум - Option<T>
, і він визначений у стандартній бібліотеці
ось так:
#![allow(unused)] fn main() { enum Option<T> { None, Some(T), } }
Enum 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_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 f32 as Add<f32>>
<&'a f64 as Add<f64>>
<&'a i128 as Add<i128>>
<&'a i16 as Add<i16>>
<&'a i32 as Add<i32>>
<&'a i64 as Add<i64>>
<&'a i8 as Add<i8>>
<&'a isize as Add<isize>>
and 48 others
For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` due to previous error
Сильно! Насправді це повідомлення про помилку означає, що Rust не розуміє, як додати i8
та Option<i8>
, оскільки вони різних типів. Коли у нас у Rust є значення типу на кшталт i8
, компілятор гарантує, що у нас завжди є коректне значення. Ми можемо діяти впевнено без потреби у перевірці на null перш ніж використовувати це значення. Тільки тоді, коли у нас є Option<i8>
(або будь-який тип чи значення, з яким ми працюємо), ми маємо турбуватися про те, що, можливо, значення не буде, і компілятор переконається, що ми обробляємо цей випадок, перш ніж використовувати значення.
Іншими словами, перед тим, як виконувати операції, які можна робити з T
, треба перетворити значення Option<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
- це конструкція управління, що саме це й робить, коли використовується з енумами: воно виконає різний код залежно від варіанту енума, і цей код може використовувати дані всередині відповідного значення.