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

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

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

Листинг 6-1: Сохранение данных и вариантов IpAddrKind IP адреса используя структуру struct

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

Мы добавили данные в каждый вариант перечисления. Таким образом, мы упростили наш предыдущий код и получили тот же результат.

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

Пример 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-значениями

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

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

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

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

Проблема с null значениям заключается в том, что если вы попытаетесь использовать его значение в качестве не-null значения, вы получите какую-то ошибку. Из-за того, что null или не-null свойство всеобъемлющее и может быть использовано повсеместно, очень легко использовать null и в дальнейшем получить такого рода ошибку.

Тем не менее, концепция, которую null пытается выразить, является полезной: null - это значение, которое в настоящее время по какой-то причине недействительно или отсутствует.

Проблема не в самой концепции, а в конкретной реализации. Таким образом, в Rust нет null-значений, но есть перечисление, которое может закодировать концепцию наличия или отсутствия значения. Это перечисление Option<T> и оно объявляется в стандартной библиотеке следующим образом:


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

Перечисление Option<T> настолько полезно, что даже подключено в авто-импорте; его не нужно явно подключать в область видимости. Дополнительно подключены также и его варианты: можно использовать Some и None напрямую, без префикса Option::. Перечисление Option<T> все ещё является обычным перечислением, а Some(T) и None являются вариантами типа Option<T>.

Синтаксис <T> - это особенность Rust, о которой мы ещё не говорили. Это параметр обобщённого типа, и мы рассмотрим его более подробно в Главе 10. На данный момент всё, что вам нужно знать, это то, что <T> означает, что вариант Some из перечисления Option может содержать один фрагмент данных любого типа. Вот несколько примеров использования значений Option для хранения числовых и строковых типов:

fn main() {
    let some_number = Some(5);
    let some_string = Some("a string");

    let absent_number: Option<i32> = None;
}

Если используется None, а не Some , то нужно сообщить Rust, какой тип Option<T> у нас есть, потому что компилятор не может определить тип варианта который будет содержать Some, глядя только на значение None.

Когда есть значение Some, мы знаем, что значение присутствует и содержится внутри Some. Когда есть значение None, это означает то же самое, что и null в некотором смысле: у нас нет действительного значения. Так почему наличие Option<T> лучше, чем null?

Вкратце, поскольку Option<T> и T (где T может быть любым типом) относятся к разным типам, компилятор не позволит нам использовать значение Option<T> даже если бы оно было определённо допустимым вариантом Some. Например, этот код не будет компилироваться, потому что он пытается добавить 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`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums`

To learn more, run the command again with --verbose.

Сильно! Фактически, это сообщение об ошибке означает, что Rust не понимает, как сложить i8 и Option<i8>, потому что это разные типы. Когда у нас есть значение типа на подобие i8, компилятор гарантирует, что у нас всегда есть допустимое значение типа. Мы можем уверенно продолжать работу, не проверяя его на null перед использованием. Однако, когда у нас есть значение типа Option<T> (где T - это любое значение любого типа T, упакованное в Option, например значение типа i8 или String), мы должны беспокоиться о том, что значение типа T возможно не имеет значения (является вариантом None), и компилятор позаботится о том, чтобы мы обработали такой случай, прежде чем мы бы попытались использовать None значение.

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

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

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

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