Программирование PC Rust: перечисления (enum) и совпадение шаблонов (match) Tue, October 08 2024  

Поделиться

Нашли опечатку?

Пожалуйста, сообщите об этом - просто выделите ошибочное слово или фразу и нажмите Shift Enter.

Rust: перечисления (enum) и совпадение шаблонов (match) Печать
Добавил(а) microsin   

В этой главе (перевод документации Rust [1]) мы рассмотрим перечисления (enum). Перечисления позволяют вам определить тип путем перечисления его возможных значений. Сначала мы определим и применим enum чтобы показать, как enum может кодировать осмысленные значения данных. Далее мы рассмотрим особенно полезный перечислитель Option, который выражает, что значение может быть либо чем-то, либо ничем. Затем мы рассмотрим сопоставление шаблонов (pattern matching) выражении match, что облегчает запуск различных частей кода для разных значений enum. И в заключении мы расскажем, как конструкция if let может представить еще одну удобную и короткую идиому, доступную для обработки перечислений в вашем коде.

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

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

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

Любой IP-адрес может быть либо версии 4, либо версии 6, но не может быть обоих версий одновременно. Для этого свойства IP-адресов подходит перечисление enum подходящей, потому что может быть выбран только один из вариантов. Обе версии 4 и 6 фундаментально относятся к IP-адресации, поэтому они должны рассматриваться как один и тот же тип, когда код обрабатывает ситуации, применимые к любому типу адреса IP.

Мы можем выразить эту концепцию в коде, определив перечисление IpAddrKind, и перечислив в нем все возможные виды IP-адресов, которые могут быть: V4 и V6:

enum IpAddrKind {
    V4,
    V6,
}

IpAddrKind теперь это пользовательский тип данных, который мы можем применить в любом месте нашего кода.

Значения enum. Мы можем создавать экземпляры каждого из двух вариантов IpAddrKind примерно так:

    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

Обратите внимание, что варианты enum находятся в пространстве имен своих идентификаторов типа, и мы используем двойное двоеточие, чтобы отделить тип от имени значения перечисления. Это полезно, поскольку оба значения, и IpAddrKind::V4, и IpAddrKind::V6 относятся к одному и тому же типу: IpAddrKind. Затем мы можем, например, определить функцию, которая будет принимать любой вариант из IpAddrKind:

fn route(ip_kind: IpAddrKind) {}

И мы можем вызвать эту функцию на любом варианте из перечисления:

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);

Использование enum дает даже еще больше преимуществ. Когда мы думали про наш тип IP-адреса, у нас не было способа сохранить фактические данные IP. Мы знаем только, какой вид IP-адреса это будет. Учитывая наши знания структур (см. главу 5 [2]), может возникнуть желание решить эту проблему с помощью структур, как показано в листинге 6-1.

    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-адреса с помощью структуры.

Здесь мы определили структуру IpAddr, в которой 2 поля: одно типа IpAddrKind (это перечисление enum, которое мы определили ранее), и поле address типа String. У нас есть два экземпляра этой структуры. Первый это home, и у неё значение IpAddrKind::V4 как вид адреса, привязанного к данным адреса 127.0.0.1. Второй экземпляр это loopback. У него второй вариант IpAddrKind, V6, а связанный с ним адрес ::1. Мы использовали структуру, чтобы сгруппировать тип адреса и его значение.

Однако представление той же самой концепции с использованием только enum получится более кратким: вместо того, чтобы применять enum внутри структуры, мы можем поместить данные напрямую в каждый вариант enum. Это новое определение перечисления IpAddr говорит о том, что оба варианта V4 и V6 будут связаны со значением String:

    enum IpAddr {
        V4(String),
        V6(String),
    }

let home = IpAddr::V4(String::from("127.0.0.1")); let loopback = IpAddr::V6(String::from("::1"));

Мы прикрепляем данные к каждому варианту enum напрямую, поэтому нет необходимости в дополнительной структуре. Здесь также проще увидеть другую подробность, как работают перечисления: имя каждого варианта перечисления, которые мы определяем, также становится функцией, которая конструирует экземпляр enum. Т. е. IpAddr::V4() это вызов функции, который принимает в аргументе String, и возвращает тип IpAddr. Мы автоматически получаем эту функцию конструктора как результат определения перечисления.

Есть и другое достоинство использования enum вместо struct: каждый вариант перечисления может иметь свой тип связанных данных. Версия 4 адреса IP всегда имеет 4 числовых (байтовых) компонента со значениями от 0 до 255. Если мы хотим сохранить адреса в виде четырех значений u8, однако все еще выражая адреса V6 как одно значение строки String, то мы не можем это сделать с помощью структуры. Перечисления обрабатывает этот случай на раз:

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

struct Ipv4Addr {
    // -- вырезано --
}

struct Ipv6Addr { // -- вырезано -- }

enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), }

Этот код иллюстрирует, что внутри перечисления вы можете разместить любого вида данные для каждого варианта: строки, числовые типы или структуры. Например, вы можете даже использовать внутри перечисления другое перечисление! Кроме того, типы стандартной библиотеки часто не настолько сложны, как вы могли бы подумать.

Обратите внимание, что хотя стандартная библиотека содержит определение для IpAddr, мы все еще можем создать и использовать своё собственное определение без возникновения конфликта, потому что мы не включили определение стандартной библиотеки в нашу область видимости. Мы более подробно поговорим о включении типов в главе 7.

Посмотрим на другой пример enum в листинге 6-2: в его вариантах большее количество встроенных типов.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

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

В этом перечислении 4 варианта с различными типами:

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

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

struct QuitMessage;                       // unit-структура
struct MoveMessage { // обычная структура x: i32, y: i32, }
struct WriteMessage(String); // структура кортежа
struct ChangeColorMessage(i32, i32, i32); // структура кортежа

Но если мы бы использовали разные структуры, каждая своего типа, то не смогли бы просто определить функцию, которая принимает любое из этих видов сообщений, как мы можем сделать с перечислением Message из листинга 6-2, которое представляет собой один тип.

Есть еще одно сходство между перечислениями и структурами: точно так же как мы определяли методы на структурах с помощью impl, мы можем также определить методы на перечислениях. Вот метод с именем call, который мы могли бы определить на нашем перечислении Message:

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

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

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

Tony Hoare, изобретатель null, в своей презентации 2009 года "Null References: The Billion Dollar Mistake" (NULL-ссылки: ошибка ценой миллион) говорит следующее:

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

Проблема null-значений состоит в том, что если вы попытаетесь использовать значение null как not-null value, то получите ошибку определенного вида. Поскольку это свойство null или not-null очень распространено, то чрезвычайно просто допустить подобную ошибку.

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

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

enum Option< T> {
    None,
    Some(T),
}

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

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

    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> (ошибка не соответствия типов):

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

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

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

Так как вы получаете значение T из варианта Some, когда у вас есть значение типа Option< T>, чтобы можно было использовать его значение? У перечисления Option< T> есть множество методов, которые подойдут в различных ситуациях, см. документацию [3]. Когда вы освоите методы Option< T>, это будет очень полезно для вашего программирования на Rust.

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

[Управление потоком с помощью match]

В Rust есть очень мощная конструкция ветвления: match. Она позволяет сравнивать значение с последовательностью шаблонов, и выполнять тот код, у которого шаблон совпал со значением. Шаблоны могут быть сделаны из литеральных значений, имен переменных, wildcard, и много чего другого. В главе 18 показаны все виды шаблонов, которые можно делать. Сила match происходит из выразительности шаблонов и того факта, что компилятор подтверждает, что все возможные случаи были обработаны.

Выражение match можно представить себе как машину для сортировки монет: монеты падают на дорожки, направляемые щелями разной ширины, и каждая монета попадет в первую щель, которая подойдет по размеру. Подобным образом значения проходят по каждому шаблону match, как только попадется первый шаблон "подходящий" значению, значение падает в связанный блок кода, который будет использован для выполнения.

Раз уж мы заговорили про монеты, давайте создадим подобный пример с использованием match. Мы можем написать функцию, которая принимает неизвестную монету, и подобно машине счета монет определяет, что это за монета и возвращает её значение в центах, как показано в листинге 6-3.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => 1, Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter => 25, } }

Листинг 6-3. Перечисление и выражение match, в шаблонах которого используются варианты из перечисления.

Давайте разберем match в функции value_in_cents. Первым мы видим ключевое слово match, за которым идет выражение, в нашем случае это значение coin. Это очень похоже на выражение условного ветвления, используемое вместе с if, но здесь большое отличие: в операторе if условие ветвления вычисляется в значение Boolean, а здесь значение может быть любого типа. Тип coin в этом примере это перечисление Coin, которое мы определили выше.

Далее идут ветки соответствия (match arms). Ветка (arm) состоит из 2 частей: шаблона совпадения (pattern) и некоторого кода. Первая часть arm здесь это Coin::Penny, и оператор =>, который отделяет шаблон и запускаемый код. Код в этом случае просто значение 1. Каждая arm отделена от следующей с помощью запятой.

Когда выполняется выражение match, оно сравнивает значение с каждым шаблоном по очереди. Если шаблон совпал со значением, то выполняется код этого шаблона. Если шаблон не соответствует значению, то выполнение продолжается на следующей arm, как в машине сортировки монет. Мы можем создать столько arm, сколько нужно: в листинге 6-3 наше выражение match имеет четыре arm.

Код, связанный с каждой arm, это выражение, и результирующее значение выражения в совпавшей arm возвращается из всего выражения match.

Если код ветки match короткий, как в листинге 6-3, то мы не используем фигурные, здесь ветка arm просто возвращает значение. Если нужно запустить несколько строк кода в совпавшей match arm, то следует использовать фигурные скобки, и тогда запятую после arm вставлять не обязательно. Например, следующий код напечатает "Lucky penny!" каждый раз, когда когда метод вызван с Coin::Penny, однако все еще возвращается значение 1 из блока:

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

Шаблоны, привязанные к значениям. Другая полезная фича match arm в том, что они могут привязываться к они могут быть привязаны к частям значений, которые соответствуют шаблону. Это способ, каким мы можем извлечь значения из вариантов перечисления.

В качестве примера давайте поменяем один из вариантов enum, чтобы он хранил внутри себя данные. Между 1999 и 2008 годами США чеканили четвертинки доллара (квотеры) с разным дизайном на одной стороне для каждого из 50 штатов. Никакие другие монеты не получили такого дизайна от штата, так что только квотеры имеют эту дополнительную ценность. Мы можем добавить эту информацию в перечисление, изменив вариант Quarter так, чтобы он внутри себя еще хранил значение UsState, как показано в листинге 6-4.

#[derive(Debug)] // так мы можем быстро проверить штат
enum UsState { Alabama, Alaska, // -- вырезано -- }

enum Coin { Penny, Nickel, Dime, Quarter(UsState), }

Листинг 6-4. Перечисление Coin, где вариант Quarter также содержит значение UsState (штат США).

Представим себе, что друг пытается собрать коллекцию квотеров всех 50 штатов. Хотя мы сортируем наше свободное изменение по типу монеты, мы также обозначим имя штата, связанного с каждым квотером, так что если у друга нет такого квотера, он мог бы добавить его в свою коллекцию.

В выражении match для этого кода добавим переменную с именем state в шаблон, который совпадет со значением варианта Coin::Quarter. Когда произойдет совпадение с Coin::Quarter, переменная state будет привязана к значению state квотера. Тогда мы можем использовать переменную state в коде arm, примерно так:

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("Квотер из штата {state:?}!");
            25
        }
    }
}

Если теперь вызвать value_in_cents(Coin::Quarter(UsState::Alaska)), то coin будет Coin::Quarter(UsState::Alaska). Когда мы сравним это значение с каждой arm выражения match, не будет совпадения до тех пор, пока не дойдем до Coin::Quarter(state). В этот момент привязка state будет иметь значение UsState::Alaska. Затем мы можем использовать эту привязку в выражении println!, получая таким образом значение внутреннего состояния из варианта перечисления Coin для Quarter.

Совпадения с Option< T>. В предыдущей секции мы хотели получить внутреннее значение T из случая Some, когда использовали Option< T>. Мы также можем обработать Option< T> с помощью match, как это делали с перечислением Coin. Вместо сравнения coin-ов будем сравнивать варианты Option< T>, однако способ применения выражения match останется прежним.

Предположим, что мы хотим написать функцию, которая принимает Option< i32>, и если у него внутри значение, то прибавляет к нему 1. Если внутри значения нет, то функция должна возвратить значение None, и не пытаться выполнять любые операции. Благодаря match такую функцию очень легко написать:

    fn plus_one(x: Option< i32>) -> Option< i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

let five = Some(5); let six = plus_one(five); let none = plus_one(None);

Листинг 6-5. Функция, которая использует выражение match на Option< i32>.

Давайте рассмотрим первое выполнение функции plus_one более подробно. Когда мы вызвали plus_one(five), переменная x в теле функции получит значение Some(5). Затем мы сравниваем это значение в каждой match arm:

            None => None,

Значение Some(5) не подходит для шаблона None, так что мы переходим к следующей arm:

            Some(i) => Some(i + 1),

Some(5) подходит к Some(i)! У нас есть такой же вариант. Переменная i привязывается к значению, содержащемуся в Some, так что i получит значение 5. Выполнится код в match arm, и прибавится 1 к значению i, после чего из этого значение будет создано новое значение Some, у которого внутри будет 6.

Теперь рассмотрим второй вызов plus_one в листинге 6-5, где x будет None. Мы входим в match и выполняем сравнение первой arm:

            None => None,

Произошло совпадение! Здесь никакого значения не добавляется, так что программа возвратит из функции значение None, указанное справа от =>. Поскольку было совпадение в первой arm, то никакие другие arm-ы не сравниваются.

Комбинация match и перечислений полезна во многих ситуациях. Вы много подобного увидите в коде Rust: match по перечислению, привязка переменной к данным внутри варианта, и затем выполнение кода на основе этого. Сначала это кажется немного сложным, но как только вы привыкните, то захотите получить такое же и на всех языках программирования.

Совпадения должны быть исчерпывающими. Есть еще один аспект match, который следует обсудить: шаблоны arm-ов должны охватывать все возможные варианты. Рассмотрим следующую версию функции plus_one, в которой допущена ошибка, и эта версия не скомпилируется:

    fn plus_one(x: Option< i32>) -> Option< i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

Здесь мы не обработали случай None, что недопустимо. Если мы попытаемся скомпилировать этот код, то получим ошибку:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option` defined here
 --> /rustc/9b00956e56009bab2aa15d7bff10916599e3d6d6/library/core/src/option.rs:572:1
 ::: /rustc/9b00956e56009bab2aa15d7bff10916599e3d6d6/library/core/src/option.rs:576:5
  |
  = note: not covered
  = note: the matched value is of type `Option`
help: ensure that all possible cases are being handled by adding a match arm with
      a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

For more information about this error, try `rustc --explain E0004`. error: could not compile `enums` (bin "enums") due to 1 previous error

Rust определил, что мы не обработали каждое возможное значение match, и даже знает, какой вариант шаблона мы забыли указать! Совпадения с шаблонами match в Rust исчерпывающие: чтобы код был допустимым, мы должны извлечь и проверить все возможности для проверяемого по шаблонам значений. Особенно это верно для случая Option< T>, где Rust не позволяет нам забыть о явной обработке случая None. Это дает защиту от предположения, что у нас есть значение, когда на самом деле могли бы иметь null, что делает невозможным "бага на миллион долларов", о чем говорили ранее.

Шаблоны catch-all и заполнитель _. При использовании перечислений мы также можем выполнять специальные действия для некоторых определенных значений, но для всех других значений  применить одно специальное действие по умолчанию. Представим себе, что мы пишем игру, в которой если на броске кости выпадет 3, то ваш игрок не двигается, но вместо этого получает новую прикольную шляпу. Если выпадет 7, то игрок теряет шляпу. Для всех других значений ваш игрок двигается на выпавшее количество шагов по игровой доске. Ниже показан вариант match с такой логикой:

    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

fn add_fancy_hat() {} fn remove_fancy_hat() {} fn move_player(num_spaces: u8) {}

Для первых двух arm шаблоны имеют литеральные значения 3 и 7. Последняя arm обрабатывает каждое другое возможное значение, шаблон здесь это переменная, для которой мы выбрали имя other. Код, который запустится на other arm, использует эту переменную для передачи в функцию move_player.

Этот код нормально скомпилируется несмотря на то, что здесь не были в match перечислены все возможные значения для u8, потому что последний шаблон совпадет со всеми другими значениями, не перечисленными специально. Это так называемый шаблон catch-all, который удовлетворяет требованию для выражения match, чтобы оно было исчерпывающим. Обратите внимание, что catch-all arm указана последней, потому что шаблоны обрабатываются по порядку. Если мы поместим catch-all arm раньше, то другие arm никогда не запустятся, так что Rust предупредит вас, если обнаружит arm-ы после catch-all!

В Rust также есть шаблон, который вы хотите использовать для catch-all, но не нуждаетесь в значении для шаблона catch-all: _. Это специальный шаблон, который совпадает с любым значением, но не создает привязку с ним. Это говорит Rust, что нам не нужно использовать значение, и тогда Rust не будет выводить предупреждение о неиспользуемой переменной.

Давайте поменяем правила игры: теперь, если выпадет значение, отличающееся от 3 или 7, то вы должны сделать повторную попытку броска кости. Теперь нам больше не нужно использовать значение для catch-all, так что поменяем код для использования _ вместо переменной с именем other:

    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

fn add_fancy_hat() {} fn remove_fancy_hat() {} fn reroll() {}

Этот пример также удовлетворяет требованию исчерпывающих проверок match, потому что мы явно игнорируем все другие значения в последней arm; мы ничего не забыли.

И наконец, давайте поменяем правила так, чтобы ничего не происходило, когда выпало значение, отличающееся от 3 или 7. Мы можем это выразить с помощью значения unit (тип пустого кортежа Tuple, который мы уже обсуждали в секции "Составные типы", см. [4]) в качестве кода, который соответствует _ arm:

    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

fn add_fancy_hat() {} fn remove_fancy_hat() {}

Здесь мы явно говорим Rust, что ничего не надо делать, когда не было совпадения значения с предыдущими arm, для такого случая мы не хотим запускать никакой код.

В главе 18 будет рассказано больше про шаблоны и их соответствие в match. Теперь перейдем к синтаксису if let, который может быть полезен в ситуациях, когда выражение match слишком многословное.

[Краткое управление потоком на основе if let]

Синтаксис if let позволит вам комбинировать if и let, чтобы лаконично обработать значения, которые совпадают с одним шаблоном, игнорируя при этом остальные. Рассмотрим программу в листинге 6-6, которая применяет match на значении Option< u8> в переменной config_max, но хочет запускать код только если значение оказалось вариантом Some.

    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("Максимум сконфигурирован в значение {max}"),
        _ => (),
    }

Листинг 6-6. Выражение match, которое заботится только о выполнении кода, когда значение Some.

Если значение Some, то мы печатаем значение, которое находится внутри варианта Some путем привязки переменной max в шаблоне. Мы не хотим ничего делать, если имеем значение None. Чтобы удовлетворять исчерпыванию match, мы добавили _ => () после обработки только одного варианта, что является раздражающим обязательным шаблоном.

Вместо этого мы могли бы написать следующий, более лаконичный код, используя if let. Следующий код ведет себя только так же, как и match в листинге 6-6:

    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("Максимум сконфигурирован в значение {max}");
    }

Синтаксис if let принимает шаблон и выражение, отделенные друг от друга знаком равенства. Это работает так же, как и match, где дано выражение для match и шаблон в его первой arm. В этом случае шаблоном служит Some(max), и переменная max привязывается к значению внутри Some. Мы затем можем использовать в теле блока if let эту переменную точно так же, как в соответствующей match arm. Код в блоке if let не запустится, если значение не совпадет с шаблоном.

Использование if let означает меньше ввода, меньше отступов, и меньше шаблонного кода. Однако вы теряете исчерпывающую проверку, обеспечивающую соответствие. Выбор между match и if let зависит от того, что вы делаете в конкретной ситуации, и является ли краткость кода желаемым компромиссом при потере исчерпывающей проверки.

Другими словами, вы можете думать про if let как об синтаксическом сахаре для match, который запускает код, когда значение совпадает с одним шаблоном, игнорируя при этом все другие значения.

Мы можем применить else вместе if let. Блок кода, который будет для else, это тот же блок кода, который выполнился бы для случая _ в выражении match, эквивалентном if let и else. Вспомним определение перечисления Coin в листинге 6-4, где вариант Quarter также хранит значение UsState. Если мы хотим подсчитать все монеты, которые не относятся к квотерам, то мы видим, а также объявляем штат квотера, что могли бы сделать выражением match, примерно так:

    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("Квотер из штата {state:?}!"),
        _ => count += 1,
    }

Или мы могли бы использовать выражение if let и else, вот так:

    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("Квотер из штата {state:?}!");
    } else {
        count += 1;
    }

Если у вас ситуация, в которой программа имеет логику, которая слишком подробная в реализации при использовании match, то помните также про удобный инструментарий if let на языке Rust.

[Общие выводы]

Мы рассмотрели, как использовать перечисления для создания пользовательских типов, которые могут быть одним значением из некоторого перечисленного набора. Мы показали тип стандартной библиотеки Option< T>, который помогает использовать систему типов для предотвращения стандартных ошибок. Когда у значений перечисления есть внутренние данные, мы можем использовать match или if let для извлечения и использования этих значений, в зависимости от того, как много вариантов необходимо обработать.

Ваши программы на Rust теперь могут выражать концепции вашей предметной области с помощью структур и перечислений. Создание пользовательских типов для использования в вашем API обеспечивает безопасность типов: компилятор будет следить за тем, чтобы ваши функции получали значения только того типа, который ожидает каждая функция.

Чтобы предоставить вашим пользователям хорошо организованный API, который прост в использовании и предоставляет только то, что им нужно, давайте теперь обратимся к модулям Rust, см. главу 7.

[Ссылки]

1. Rust Enums and Pattern Matching site:rust-lang.org.
2. Rust: использование структуры для взаимосвязанных данных.
3. Enum std::option::Option site:rust-lang.org.
4. Rust: общая концепция программирования.

 

Добавить комментарий


Защитный код
Обновить

Top of Page