Раздел 23 · Rust

Перечисления и pattern matching

middle~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Перечисления и pattern matching

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

Идея

Начнём с кода, который пишут каждый день на любом языке. Загрузка файла, состояние храним флагами:

struct Download {
    is_loading: bool,
    has_error: bool,
}

Два булевых поля, четыре комбинации. «Грузится», «загрузилось», «ошибка». А четвёртая? is_loading: true, has_error: true это что: грузится с ошибкой? Осмысленных состояний три, выразимых четыре, и лишнее состояние не запрещено никем, кроме комментария в коде и надежды на дисциплину. Рано или поздно какая-нибудь ветка кода забудет сбросить один из флагов, и программа окажется в состоянии, для которого никто не писал обработку.

Диагноз: мы описали состояния через «И» (загрузка И ошибка), хотя предметная область говорит «ИЛИ» (либо грузится, либо готово, либо ошибка). Прошлый урок дал нам типы-произведения, структуры, и они отвечают только за «И». Сегодняшний инструмент отвечает за «ИЛИ»:

enum Download {
    Idle,
    Loading,
    Failed(String),
    Done(Vec<u8>),
}

Четыре варианта, и каждый существующий осмыслен, а бессмысленный не существует: невозможно быть Loading и Failed одновременно, тип не даст. Бонусом данные приклеились к тем состояниям, где они есть: текст ошибки существует только у Failed, байты только у Done. Во флаговой версии поле для байтов пришлось бы держать всегда, пустым или нет, и проверять его согласованность с флагами руками.

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

Данные в вариантах

Варианты enum умеют всё то же, что структуры, и это удобно показывать на одном типе:

enum Command {
    Quit,                          // без данных
    Move { x: i32, y: i32 },       // именованные поля, как структура
    Say(String),                   // кортежный вариант
}

Создаётся значение через имя типа и вариант: Command::Say(String::from("привет")), Command::Move { x: 3, y: 7 }. В памяти enum занимает место самого толстого варианта плюс дискриминант, номер активного варианта. Подробности раскладки, включая фокусы, которыми компилятор ужимает её до нуля лишних байт, ждут в уроке про раскладку в памяти.

Методы вешаются на enum тем же impl, что и на структуры. А самый знаменитый enum мира Rust живёт в стандартной библиотеке и состоит из двух вариантов:

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

Параметр T это дженерик, «подставь сюда любой тип»: полноценное знакомство с ними в уроке про дженерики, читать можно уже сейчас. Option<T> это ответ Rust на вопрос «а если значения нет?». В языках с null отсутствие значения прячется внутри любого типа: каждая ссылка в Java потенциально null, и компилятор молчит, пока программа не упадёт с NullPointerException в проде. Автор null Тони Хоар публично назвал его ошибкой на миллиард долларов, и Rust сделал выводы: значения-сироты не существует, отсутствие это отдельный вариант отдельного типа.

let numbers = vec![3, 7, 11];
let first = numbers.first();       // Option<&i32>: вектор мог быть пустым

first нельзя использовать как число: это не число, а коробка, в которой число либо лежит, либо нет. Компилятор заставит коробку открыть и сказать, что делать в обоих случаях. Вся стандартная библиотека говорит на этом языке: поиск, который может не найти, возвращает Option, и забыть про случай «не нашлось» технически невозможно. Ты уже видел эту идею в уроке про Option и Either, здесь она встроена в язык до самого дна.

Рядом с Option живёт его брат для операций, которые могут провалиться с причиной: Result<T, E> с вариантами Ok(T) и Err(E). Это главный механизм обработки ошибок в Rust, и ему целиком посвящён восьмой урок. Сегодня нам хватит Option.

match как исчерпывающий разбор

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

fn report(state: &Download) -> String {
    match state {
        Download::Idle => String::from("ожидание"),
        Download::Loading => String::from("грузится"),
        Download::Failed(reason) => format!("ошибка: {reason}"),
        Download::Done(bytes) => format!("готово, {} байт", bytes.len()),
    }
}

Каждая ветвь это образец слева и выражение справа. Образец одновременно проверяет вариант и распаковывает его данные: в ветви Failed(reason) имя reason уже связано с текстом ошибки, отдельного шага «достать значение» нет. И поскольку match это выражение, его результат можно класть в переменную или возвращать, что мы и делаем.

Главное обещание match называется исчерпываемостью. Удали ветвь Idle и получи отказ:

error[E0004]: non-exhaustive patterns: `&Download::Idle` not covered

Теперь представь, что через месяц в Download добавился вариант Cancelled. В языке со switch ты бы искал по проекту все места, где разбирается состояние, и молился, что нашёл все. В Rust ты просто компилируешь: каждый match по этому типу откажется собираться и перечислит, чего ему не хватает. Добавление варианта превращается из охоты за багами в прогулку по списку ошибок компилятора. Это та же суперсила, что у исчерпывающего case в Haskell и Gleam, и ты будешь опираться на неё в каждом проекте курса, от эмулятора до блокчейна.

У ветвей есть два усиления. Охранное условие, if после образца, уточняет ветвь произвольной логикой:

match state {
    Download::Done(bytes) if bytes.is_empty() => println!("готово, но пусто"),
    Download::Done(bytes) => println!("готово: {} байт", bytes.len()),
    _ => println!("ещё не готово"),
}

А биндинг @ связывает значение с именем, одновременно проверяя его на образец:

let label = match score {
    s @ 90..=100 => format!("отлично: {s}"),
    s @ 60..=89 => format!("зачёт: {s}"),
    s => format!("пересдача: {s}"),
}

В примере с охраной мелькнул _, образец «что угодно», и про него нужно поговорить прямо. _ выключает исчерпываемость: match с подстраховкой снизу скомпилируется и после добавления нового варианта, молча отправив его в общую ветвь. Иногда это то, что нужно. Но правило по умолчанию такое: в match по своему enum перечисляй варианты явно и не пиши _, пока тип маленький. Для типов с бесконечным числом значений, чисел и строк, _ обязателен, тут выбора нет. А каждый _ по enum это осознанная сделка: ты покупаешь краткость сейчас за то, что компилятор промолчит при следующем варианте. В уроке про идиоматичный Rust мы вернёмся к этой сделке с прицелом на код, который переживает рефакторинги.

if let, let else, while let

match обязывает разобрать всё, но иногда интересен ровно один вариант. Каждый раз писать вторую ветвь _ => {} утомительно, и для таких случаев есть три формы полегче.

if let выполняет блок, если значение подошло под образец:

if let Download::Failed(reason) = &state {
    eprintln!("повторим позже: {reason}");
}

let else это ранний выход: образец либо подошёл и его биндинги доступны дальше по функции, либо выполняется else, обязанный покинуть функцию:

fn process(numbers: &[i32]) -> i32 {
    let Some(first) = numbers.first() else {
        return 0;                  // пусто: выходим сразу
    };
    first * 10                     // first доступен до конца функции
}

Это идиома для конвейера проверок в начале функции: достали, проверили, не подошло, вышли, и счастливый путь идёт дальше без вложенности. До появления let else такой код писали лесенкой из match, и в старых статьях ты её ещё встретишь.

while let крутит цикл, пока образец подходит:

let mut tasks = vec!["сборка", "тесты", "деплой"];
while let Some(task) = tasks.pop() {
    println!("выполняю: {task}");
}

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

Выбор между формами простой: нужны все варианты, пиши match; нужен один вариант посреди логики, if let; нужен один вариант, без которого функция не имеет смысла, let else; нужен поток значений до первого отказа, while let.

Slice patterns

Образцы умеют разбирать не только enum. Срезы из четвёртого урока тоже матчатся, и это лекарство от кода с индексами:

fn run(args: &[&str]) {
    match args {
        [] => println!("команда не указана"),
        ["help"] => println!("доступно: get, set"),
        ["get", key] => println!("читаю {key}"),
        ["set", key, value] => println!("пишу {key} = {value}"),
        [command, ..] => println!("не знаю команду {command}"),
    }
}

Образец фиксированной длины проверяет и длину, и содержимое разом: ["get", key] подойдёт только срезу из ровно двух элементов с "get" на первом месте. .. пропускает середину, [first, .., last] достаёт края, [single] ловит ровно один элемент. Сравни с версией на индексах: args[0] падает на пустом срезе, проверки длины разъезжаются с обращениями, и через полгода кто-нибудь добавит аргумент и забудет поправить args.len() >= 2 на >= 3. Образец же не может разъехаться сам с собой: длина и содержимое описаны одной строкой. Исчерпываемость работает и тут: убери последнюю ветвь, и компилятор заметит, что срезы длиннее одного элемента с неизвестной командой никто не обрабатывает.

Этот стиль, разбор данных по форме вместо вопросов к данным по одному, называется pattern matching, и в Rust он идиоматичен везде, где есть структура: кортежи, структуры, enum, срезы, и любая их вложенность. let (lat, lon) = point; из второго урока был его частным случаем: let тоже принимает образец.

Практика

Четыре задачи прямо в редакторе, от простого цикла светофора до машины состояний заказа. Правило игры одно на всех: перечисляй варианты явно и дай исчерпываемости работать на тебя.

ДЗ

Дальше

Алгебра собрана: структуры говорят «И», перечисления говорят «ИЛИ», match гарантирует, что ни один случай не потеряется, а Option показал, как на этой алгебре строится сама стандартная библиотека. В уроке 07 идём к рабочим лошадкам: Vec, HashMap, String против &str, владение внутри коллекций и выбор структуры под задачу.