Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Визначення перелічення (Defining an Enum)

Там, де структури дають вам спосіб групувати пов’язані поля та дані, як-от Rectangle з його width і height, перелічення (enum) дають вам спосіб сказати, що значення є одним із можливого набору значень. Наприклад, ми можемо захотіти сказати, що Rectangle є одним із набору можливих фігур, до якого також входять Circle і Triangle. Щоб зробити це, Rust дозволяє нам кодувати ці можливості як перелічення (enum).

Давайте подивимося на ситуацію, яку ми могли б виразити в коді, і побачимо, чому перелічення (enum) корисніші та доречніші за структури в цьому випадку. Припустімо, нам потрібно працювати з IP-адресами. Наразі для IP-адрес використовуються два основні стандарти: версія чотири і версія шість. Оскільки це єдині можливості для IP-адреси, з якими зіткнеться наша програма, ми можемо перелічити всі можливі варіанти, звідси й походить назва перелічення (enum).

Будь-яка 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. Він має інший варіант IpAddrKind як значення свого kind, 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. Вона має точнісінько той самий перелічення і ті самі варіанти, які ми визначили та використали, але вона вбудовує дані адреси всередину варіантів у формі двох різних структур, які визначені по-різному для кожного варіанта:

#![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() {}

Це перелічення має чотири варіанти з різними типами:

  • 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() {}

не змогли б так само легко визначити функцію, яка приймає будь-який із цих видів повідомлень, як ми могли б із визначеним у Лістингу 6-2 переліченням Message, яке є одним типом.

Є ще одна схожість між переліченнями та структурами: Так само як ми можемо визначати методи для структур за допомогою 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

Цей розділ досліджує приклад 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>, і воно [визначене стандартною бібліотекою] option ось так:

#![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 для зберігання числових типів і типів char:

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>`:
            `&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>, тому що це різні типи. Коли в нас у Rust є значення типу i8, компілятор гарантуватиме, що ми завжди маємо дійсне значення. Ми можемо впевнено продовжувати без необхідності перевіряти null перед використанням цього значення. Лише коли в нас є Option<i8> (або будь-який інший тип значення, з яким ми працюємо), нам потрібно турбуватися про можливу відсутність значення, і компілятор подбає про те, щоб ми обробили цей випадок перед використанням значення.

Іншими словами, ви маєте перетворити Option<T> на T перед тим, як зможете виконувати з ним операції T. Загалом це допомагає виявити одну з найпоширеніших проблем із 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 — це конструкція керування потоком, яка робить саме це, коли використовується з переліченнями: Він виконуватиме різний код залежно від того, який варіант перелічення він має, і цей код може використовувати дані всередині значення, що зіставляється.