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

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

Давайте рассмотрим ситуацию, которую мы могли бы захотеть отразить в коде, и поймём, почему перечисления полезны и более уместны, чем структуры в этом случае. Допустим, нам нужно работать с IP-адресами. В настоящее время для обозначения IP-адресов используются два основных стандарта: четвёртая и шестая версии. Поскольку это единственно возможные варианты IP-адресов, с которыми может столкнуться наша программа, мы можем перечислить все возможные варианты, откуда перечисление и получило своё название.

Любой IP-адрес может быть либо четвёртой, либо шестой версии, но не обеими одновременно. Эта особенность IP-адресов делает структуру данных enum подходящей, поскольку значение enum может представлять собой только один из его возможных вариантов. Адреса как четвёртой, так и шестой версии по своей сути все равно являются 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"),
    };
}

Листинг 6-1. Сохранение данных и IpAddrKind IP-адреса с использованием 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. Мы автоматически получаем эту функцию-конструктор, определяемую в результате определения перечисления.

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

Листинг 6-2. Перечисление Message, в каждом из вариантов которого хранятся разные количества и типы значений.

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

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

Но когда мы использовали различные структуры, каждая из которых имеет свои собственные типы, мы не могли легко определять функции, которые принимают любые типы сообщений, как это можно сделать с помощью перечисления типа 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().

Теперь посмотрим на другое наиболее часто используемое перечисление из стандартной библиотеки, которое является очень распространённым и полезным: Option.

Перечисление Option и его преимущества перед Null-значениями

В этом разделе рассматривается пример использования Option, ещё одного перечисления, определённого в стандартной библиотеке. Тип Option кодирует очень распространённый сценарий, в котором значение может быть чем-то, а может быть ничем.

Например, если вы запросите первый элемент из непустого списка, вы получите значение. Если вы запросите первый элемент пустого списка, вы ничего не получите. Выражение этой концепции в терминах системы типов означает, что компилятор может проверить, обработали ли вы все случаи, которые должны были обработать; эта функциональность может предотвратить ошибки, которые чрезвычайно распространены в других языках программирования.

Дизайн языка программирования часто рассматривается с точки зрения того, какие функции вы включаете в него, но те функции, которые вы исключаете, также важны. Например в Rust нет такого функционала как null значения, однако он есть во многих других языках. Null значение - это значение, которое означает, что значения нет. В языках с null значением переменные всегда могут находиться в одном из двух состояний: нет значения (null) или есть значение (not-null).

В своей презентации 2009 года «Null ссылки: ошибка в миллиард долларов» Тони Хоар (Tony Hoare), изобретатель null, сказал следующее:

Я называю это своей ошибкой на миллиард долларов. В то время я разрабатывал первую комплексную систему типов для ссылок на объектно-ориентированном языке. Моя цель состояла в том, чтобы гарантировать, что любое использование ссылок должно быть абсолютно безопасным, с автоматической проверкой компилятором. Но я не мог устоять перед соблазном вставить пустую ссылку просто потому, что это было так легко реализовать. Это привело к бесчисленным ошибкам, уязвимостям и системным сбоям, которые, вероятно, причинили боль и ущерб на миллиард долларов за последние сорок лет.

Проблема с null значениями заключается в том, что если вы попытаетесь использовать null значение в качестве not-null значения, вы получите ошибку определённого рода. Поскольку свойство null или not-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 представляют собой его варианты.

<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>`:
            <i8 as Add>
            <i8 as Add<&i8>>
            <&'a i8 as Add<i8>>
            <&i8 as Add<&i8>>

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

Другими словами, вы должны преобразовать Option<T> в 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>, нужен код, который будет обрабатывать все варианты перечисления Option<T>. Вам понадобится некоторый код, который будет работать только тогда, когда у вас есть значение Some(T), и этому коду разрешено использовать внутри T. Также вам понадобится другой код, который будет работать, если у вас есть значение None, и у этого кода не будет доступного значения T. Выражение match — это конструкция управления потоком выполнения программы, которая делает именно это при работе с перечислениями: она запускает разный код в зависимости от того, какой вариант перечисления имеется, и этот код может использовать данные, находящиеся внутри совпавшего варианта.