Визначення enum-а

Якщо структури надають спосіб групування пов'язаних полів і даних, як 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"),
    };
}

Listing 6-1: Storing the data and IpAddrKind variant of an IP address using a struct

Тут ми визначили структуру 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() {}

Listing 6-2: A Message enum whose variants each store different amounts and types of values

Цей енум має чотири варіанти різних типів:

  • 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`

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