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

Определение перечисления

Структуры позволяют сгруппировать связанные поля и данные — как Rectangle со своими width и height. А перечисления позволяют сказать, что значение — одно из заранее известного набора. Например, мы можем захотеть сказать, что Rectangle — одна из возможных фигур, наряду с Circle (кругом) и Triangle (треугольником). Для этого Rust позволяет описать все варианты в виде перечисления.

Рассмотрим ситуацию, которую нужно выразить в коде, и посмотрим, почему здесь перечисления полезнее и уместнее структур. Допустим, нам нужно работать с 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-адресов: сейчас у нас нет способа хранить сами данные адреса — мы знаем только его вид. Вы только что изучили структуры в главе 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"),
    };
}
Листинг 6-1: Храним данные IP-адреса и вариант IpAddrKind с помощью struct

Здесь мы определили структуру IpAddr с двумя полями: kind типа IpAddrKind (перечисление, определённое выше) и address типа String. У нас два экземпляра этой структуры. Первый — home: у него kind равно IpAddrKind::V4, а адрес — 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. Эту функцию-конструктор мы получаем автоматически, просто определив перечисление.

Есть у перечисления перед структурой и ещё одно преимущество: у каждого варианта могут быть данные разных типов и в разном количестве. В адресе четвёртой версии всегда четыре числа от 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() {}
Листинг 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); // кортежная структура

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) {
            // здесь было бы тело метода
        }
    }

    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 — это значение, которое по какой-то причине сейчас недействительно или отсутствует.

Проблема не в самой идее, а в конкретной реализации. Поэтому в 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, хранящих числа и символы:

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 целиком: по одному лишь значению None компилятор не может понять, что хранил бы соответствующий вариант Some. Здесь мы говорим 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>`
 --> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/ops/arith.rs:98:8
  |
  = note: `i8` implements `Add`
 ::: /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/ops/arith.rs:113:0
  |
  = note: in this macro invocation
 --> /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/internal_macros.rs:22:8
  |
  = note: `&i8` implements `Add<i8>`
 ::: /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/internal_macros.rs:33:8
  |
  = note: `i8` implements `Add<&i8>`
 ::: /rustc/88d9e12ae178fab0fb5cc050a94da85685d449ea/library/core/src/internal_macros.rs:44:8
  |
  = note: `&i8` implements `Add`
  = note: this error originates in the macro `add_impl` (in Nightly builds, run with -Z macro-backtrace for more info)

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, компилятор гарантирует, что значение всегда действительное. Можно спокойно им пользоваться, не проверяя перед этим на null. Беспокоиться о том, что значения может не быть, приходится, только когда у нас Option<i8> (или Option с другим нужным типом), — и компилятор проследит, чтобы мы обработали этот случай, прежде чем пользоваться значением.

Иначе говоря, прежде чем выполнять с Option<T> операции типа T, его нужно превратить в T. Как правило, это помогает поймать одну из самых частых проблем с null: когда предполагаешь, что что-то не null, а оно на самом деле null.

Когда исчезает риск ошибочно посчитать значение не-null, код становится надёжнее. Чтобы значение могло отсутствовать, нужно явно на это согласиться и сделать его тип Option<T>. А пользуясь таким значением, вы обязаны явно обработать случай, когда его нет. Везде, где у значения тип не Option<T>, можно смело считать, что оно не null. Это сознательное решение в дизайне Rust: ограничить распространение null и повысить безопасность кода.

Как же достать значение T из варианта Some, если у вас значение типа Option<T>? У перечисления Option<T> множество методов на все случаи жизни — посмотрите их в документации. Знакомство с методами Option<T> очень пригодится вам в работе с Rust.

В целом, чтобы использовать значение Option<T>, нужен код, который обработает каждый вариант. Какой-то код должен выполняться, только когда значение — Some(T), и этот код может пользоваться внутренним T. А другой код должен выполняться, только когда значение — None, и значения T у него нет. Выражение match — конструкция управления потоком, которая с перечислениями делает ровно это: выполняет разный код в зависимости от варианта перечисления, и этот код может пользоваться данными внутри совпавшего значения.