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

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

Давайте рассмотрим ситуацию, которую мы могли бы захотеть отразить в коде, и поймём, почему в этом случае перечисления полезны и более уместны, чем структуры. Допустим, нам нужно работать с IP-адресами. В настоящее время для обозначения 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-адреса, то выяснится, что на данный момент у нас нет возможности хранить собственно сам 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 имеет другой вариант IpAddrKindV6, и с ним ассоциирован адрес ::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-адреса 4ой версии всегда будет содержать четыре цифровых компонента, которые будут иметь значения между 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-адреса и указывать их тип настолько распространена, что в стандартной библиотеке уже есть готовое определение! В нём есть точно такое же перечисление с вариантами, которое определили и использовали мы, но она помещает данные об адресе внутрь этих вариантов в виде двух различных структур, которые имеют различные определения для каждого из вариантов:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --код сокращён--
}

struct Ipv6Addr {
    // --код сокращён--
}

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

Это перечисление имеет 4 варианта:

  • Quit — вариант без каких-либо связанных с ним значений.
  • Move — вариант с именованными значениями, как у структуры.
  • Write — вариант, имеющий лишь String.
  • ChangeColor — вариант, имеющий три числа i32.

Определение перечисления с вариантами (такими, как в Листинге 6-2) похоже на определение значений разных возможных структур, за исключением того, что перечисление не использует ключевое слово struct и все варианты сгруппированы внутри типа Message. Следующие структуры могут содержать те же данные, что и предыдущие варианты перечислений:

struct QuitMessage; // unit-подобная структура
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 и его преимущества перед значениями Null

В этом разделе рассматривается пример использования 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>, и оно определено стандартной библиотекой следующим образом:

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

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

Тип some_numberOption<i32>. Тип some_charOption<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 i8` implements `Add<i8>`
            `&i8` implements `Add<&i8>`
            `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>, потому что это разные типы. Когда у нас есть значение типа вроде i8, компилятор гарантирует, что оно всегда действительно. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Лишь когда мы имеем значение типа Option<i8> (или любого другого конкретного типового параметра), у нас есть повод беспокоиться о том, что мы можем и не иметь значения; однако, компилятор обеспечит проверку нами обоих случаев перед тем, как давать использовать (возможное) значение.

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

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

Итак, как же получить значение T из варианта Some, если у вас на руках есть только значение Option<T>? Перечисление Option<T> имеет большое количество методов, полезных в различных ситуациях; вы можете ознакомиться с ними в его документации. Знакомство с методами перечисления Option<T> в вашем путешествии с Rust будет чрезвычайно полезным.

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