Мы умеем описывать категории расходов перечислением и разбирать их через match. Но пока категория у нас всегда уже готова. А пользователь печатает слово еда текстом, и это слово нужно превратить в Category::Food. Что делать, если он напечатал кино, а такой категории нет? Для таких случаев в Rust есть Option, и его мы сегодня разберём.
Нет значения: Option вместо null
Представьте коробку. В ней может лежать что-то нужное, а может быть пусто. Прежде чем что-то достать, вы заглядываете внутрь. Именно так Rust относится к ситуации «ответа может не быть»: функция отдаёт не само значение, а коробку, и вы обязаны проверить, что в ней.
Во многих языках для «ничего» есть специальное значение вроде null или None без всяких проверок, и забытая проверка приводит к падению программы. В Rust null нет совсем. Вместо него есть тип из стандартной библиотеки, который называется Option («вариант, возможность»). Это обычное перечисление с двумя вариантами: Some(x) — «значение есть, вот оно» и None — «значения нет». Запись Option<u32> читается как «коробка, в которой может лежать число u32»; в угловых скобках указано, что именно лежит в коробке. Свои такие типы мы писать не будем, нужно только уметь их читать.
fn half(n: u32) -> Option<u32> {
if n % 2 == 0 {
Some(n / 2)
} else {
None
}
}
fn main() {
println!("{:?}", half(10));
println!("{:?}", half(7));
}
Some(5)
None
Разбор по строкам
-> Option<u32>— функция возвращает не число, а коробку с числом или без него.n % 2 == 0— остаток от деления на 2 равен нулю, то есть число чётное.Some(n / 2)— кладём в коробку половину числа. СловаSomeиNoneдоступны всегда, писатьOption::перед ними не нужно.None— пустая коробка: нечётное число пополам нацело не делится.{:?}— отладочная печать, которую вы уже знаете; она показывает и словоSome, и значение внутри.
Главное свойство коробки: с её содержимым нельзя обращаться как с самим значением, пока вы не заглянули внутрь. Попробуйте прибавить единицу к half(10), и компилятор остановит вас:
error[E0369]: cannot add `{integer}` to `Option<u32>`
Это не придирка, а защита. Если бы half(7) можно было сложить с единицей, пришлось бы складывать «ничего» с числом. Компилятор заставляет вас сначала решить, что делать в обоих случаях. Самый основной способ решить — match.
match по Option: обе ветки обязательны
С match по перечислению вы уже работали: у каждого варианта своя ветка. Option — такое же перечисление, поэтому всё работает так же, с одним приятным бонусом. В образце ветки можно записать Some(h): если в коробке что-то есть, это значение автоматически попадёт в новую переменную h, и в этой ветке им можно пользоваться. Такая запись и называется сопоставлением с образцом: Rust сравнивает форму значения с образцом и, если совпало, вынимает из него части.
fn main() {
let n = 8;
match half(n) {
Some(h) => println!("Половина: {h}"),
None => println!("Нечётное, пополам не делится"),
}
}
Половина: 4
Разбор по строкам
match half(n)— проверяем коробку, которую вернула функция (самаhalfиз прошлого примера лежит надmain).Some(h) => ...— ветка для непустой коробки; число из неё попадает вh.None => ...— ветка для пустой коробки; вынимать нечего, поэтому переменной нет.
Как и в прошлый раз, match обязан перечислить все возможности. Если вычеркнуть ветку None, компилятор напомнит о ней:
error[E0004]: non-exhaustive patterns: `None` not covered
Именно здесь Rust избавляет вас от целого класса ошибок: забыть про «ничего» просто не получится. Программа не соберётся, пока вы не скажете, что делать, когда значения нет.
Образцы шире: диапазоны, «или», условия и подчёркивание
Образец — это не только имя варианта. Им может быть и конкретное значение, и целый диапазон. Разберём четыре приёма, которые встречаются в match постоянно.
Литералы и диапазоны. Ветка может сравнивать значение с числом или строкой: 0 => ..., "еда" => .... Для промежутка пишут 1..=499 — от 1 до 499 включительно (= после двух точек значит «включая последнее»).
Несколько вариантов через |. Если для нескольких значений нужна одна и та же ветка, их соединяют чертой, как слово «или»: "метро" | "такси".
Подчёркивание _. Это образец «всё остальное». Он подходит к любому значению и ничего не запоминает. Ставят его последним, потому что ветки проверяются сверху вниз.
fn size_word(amount: u32) -> &'static str {
match amount {
0 => "ничего",
1..=499 => "мелочь",
500..=4999 => "обычная",
_ => "крупная",
}
}
fn kind(word: &str) -> &'static str {
match word {
"метро" | "такси" | "автобус" => "транспорт",
"хлеб" => "еда",
_ => "неизвестно",
}
}
Здесь size_word(499) вернёт "мелочь", а size_word(500) — "обычная": границы диапазонов входят в свои ветки. А kind("такси") вернёт "транспорт", как и для метро и автобуса. Тип результата &'static str вы уже встречали: это текст, вшитый в программу.
Условие в ветке. Иногда образца мало и хочется добавить проверку. После образца можно написать if и условие: ветка сработает, только если образец подошёл и условие верно. Такое условие называется guard («страж»).
match half(n) {
Some(h) if h > 100 => println!("Много: {h}"),
Some(h) => println!("Половина: {h}"),
None => println!("Нечётное"),
}
При n = 400 напечатается Много: 200, при n = 8 — Половина: 4, а при n = 7 — Нечётное. Порядок важен: если поставить обычную ветку Some(h) выше ветки со стражем, до стража дело никогда не дойдёт.
Ветки проверяются сверху вниз
Rust выбирает первую подошедшую ветку и дальше не смотрит. Поэтому частные случаи (конкретные числа, ветки со стражем) пишут выше, а общие (Some(h), _) — ниже. Если же _ поставить слишком рано, компилятор предупредит, что остальные ветки недостижимы.
if let и let else: когда важен только один вариант
Иногда нас интересует только один случай, а на пустую коробку достаточно не реагировать вообще. Писать для этого целый match с пустой веткой None => {} громоздко. Для таких случаев есть короткая запись if let: «если значение подходит под этот образец, то сделай вот это».
if let Some(c) = Category::from_word("еда") {
println!("Нашли: {}", c.title());
} else {
println!("Такой категории нет");
}
Разбор по строкам
if let Some(c) = ...— читается так: «если справа лежитSome, достань значение в переменнуюc». Обратите внимание, что здесь один знак=, а не==: это не сравнение, а сопоставление с образцом.c.title()— внутри блокаcуже обычная категория, и методtitleработает как всегда.else— необязательная часть: что делать, если коробка пуста.
Есть и обратная ситуация. В цикле ввода пустая коробка значит «этот ввод никуда не годится, пропускаем его», а всё остальное тело цикла должно идти с готовым значением на том же уровне, без лишней вложенности. Для этого придумана запись let else:
let Some(category) = Category::from_word(word) else {
println!("Не знаю категорию «{word}»");
continue;
};
// здесь category уже обычная Category, не коробка
Читается так: «пусть category будет значением из Some; а если там None, то выполни блок else». Правило одно: блок else обязан не продолжать выполнение дальше — то есть закончиться через continue, break или return (а также паникой). Иначе после него переменной category просто не было бы.
Что выбрать
Нужны обе ветки — берите match. Интересен один вариант, остальное можно пропустить — if let. Нужно «достать значение или выйти из этого места» — let else. Все три записи делают одно и то же, отличается только удобство.
Полезные методы Option
У коробки Option есть несколько готовых методов (вспомните: метод вызывается через точку), которые закрывают самые частые случаи без match.
unwrap_or(запасное)— достаёт значение, а если коробка пуста, возвращает запасное.None::<u32>.unwrap_or(7)даст7, аSome(3).unwrap_or(7)—3.is_some()иis_none()— отвечаютtrueилиfalse: есть что-то в коробке или нет. Само значение они не отдают.
Теперь станет понятным и то, что вы встречали раньше. Когда вы разбирали строку на слова, метод next() у split_whitespace() возвращал именно Option: слова может не оказаться, если строка пустая. Поэтому и писали .unwrap_or(""): «нет слова — пусть будет пустая строка». Так же устроен и find(' ') в rest_after_first: его ответы Some(i) и None — это тот же Option, только внутри лежит номер байта. А в модуле про ввод у parse::<u32>() результат был устроен чуть сложнее, и тогда мы пользовались шаблоном с Ok и Err. Если вам важно только «получилось или нет», добавьте .ok(): "350".parse::<u32>().ok() даст Some(350), а "abc".parse::<u32>().ok() — None. Подробно про Ok и Err мы поговорим в модуле про ошибки.
А что такое unwrap
У Option есть соблазнительный метод unwrap(): он достаёт значение, а на пустой коробке аварийно останавливает программу сообщением called `Option::unwrap()` on a `None` value. Так проверка превращается в падение, как в языках с null. Привыкайте использовать match, if let или unwrap_or; когда unwrap уместен, расскажем в модуле про ошибки.
Практика: категория из слова
Научим «Копилку» превращать слово в категорию по-настоящему. Откройте src/main.rs проекта kopilka и замените всё содержимое кодом ниже. Это программа из прошлого урока (цикл команд, Expense, Command, run, разбор строки), в которой изменилось два места. Во-первых, временная функция category_from_word с веткой _ => Category::Other исчезла, а в impl Category появилась from_word, которая возвращает Option. Во-вторых, в ветке "добавить" мы разбираем этот Option через match: неизвестное слово больше не превращается молча в «другое», а даёт сообщение, и трата не записывается.
use std::io;
const MONTH_LIMIT: u32 = 30_000;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Category {
Food,
Transport,
Home,
Fun,
Other,
}
impl Category {
fn from_word(word: &str) -> Option<Category> {
match word {
"еда" => Some(Category::Food),
"транспорт" => Some(Category::Transport),
"дом" => Some(Category::Home),
"отдых" => Some(Category::Fun),
"другое" => Some(Category::Other),
_ => None,
}
}
fn title(&self) -> &'static str {
match self {
Category::Food => "еда",
Category::Transport => "транспорт",
Category::Home => "дом",
Category::Fun => "отдых",
Category::Other => "другое",
}
}
}
#[derive(Debug)]
struct Expense {
amount: u32,
category: Category,
note: String,
}
impl Expense {
fn new(amount: u32, category: Category, note: &str) -> Self {
Expense {
amount,
category,
note: note.to_string(),
}
}
fn describe(&self) -> String {
format!(
"{} руб., {}, «{}»",
self.amount,
self.category.title(),
self.note
)
}
}
enum Command {
Add(Expense),
List,
Report,
Help,
Quit,
}
fn run(command: Command) {
match command {
Command::Add(expense) => println!("Записано: {}", expense.describe()),
Command::List => println!("Список пока пуст"),
Command::Report => println!("Отчёт пока пуст"),
Command::Help => println!("Команды: добавить, список, отчёт, помощь, выход"),
Command::Quit => println!("До свидания"),
}
}
fn first_word(line: &str) -> &str {
line.split_whitespace().next().unwrap_or("")
}
fn rest_after_first(line: &str) -> &str {
let line = line.trim();
match line.find(' ') {
Some(i) => line[i + 1..].trim_start(),
None => "",
}
}
fn split_command(line: &str) -> (&str, &str) {
(first_word(line), rest_after_first(line))
}
fn parse_amount(text: &str) -> u32 {
match text.trim().parse::<u32>() {
Ok(n) => n,
Err(_) => 0,
}
}
fn note_or_default(rest: &str) -> String {
let note = rest.trim();
if note.is_empty() {
"без заметки".to_string()
} else {
note.to_string()
}
}
fn print_status(spent: u32, limit: u32) {
if spent > limit {
println!("Статус: лимит превышен");
} else if spent * 100 >= limit * 80 {
println!("Статус: почти лимит");
} else {
println!("Статус: в норме");
}
}
fn print_header() {
println!("Копилка: добавить <сумма> <категория> [заметка], список, отчёт, помощь, выход");
}
fn main() {
print_header();
let mut spent: u32 = 0;
let mut line = String::new();
loop {
line.clear();
let bytes = io::stdin()
.read_line(&mut line)
.expect("не удалось прочитать строку");
if bytes == 0 {
break;
}
let (command, rest) = split_command(&line);
match command {
"" => continue,
"добавить" => {
let amount = parse_amount(first_word(rest));
if amount == 0 {
println!("Это не число, попробуйте ещё раз");
continue;
}
let rest = rest_after_first(rest);
let word = first_word(rest);
if word.is_empty() {
println!("Не хватает категории, попробуйте ещё раз");
continue;
}
let category = match Category::from_word(word) {
Some(category) => category,
None => {
println!("Не знаю категорию «{word}»");
continue;
}
};
let note = note_or_default(rest_after_first(rest));
let expense = Expense::new(amount, category, ¬e);
spent += expense.amount;
run(Command::Add(expense));
println!("Потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
print_status(spent, MONTH_LIMIT);
}
"список" => run(Command::List),
"отчёт" | "отчет" => run(Command::Report),
"помощь" => run(Command::Help),
"выход" => {
run(Command::Quit);
break;
}
_ => println!("Неизвестная команда: {command}"),
}
}
println!("Итого потрачено: {spent} руб. из {MONTH_LIMIT} руб.");
}
Разбор по строкам
fn from_word(word: &str) -> Option<Category>— принимает слово, возвращает коробку: категорию или пустоту. Это ассоциированная функция, поэтому вызывается какCategory::from_word(...).match word { "еда" => Some(Category::Food), ... }— каждое известное слово кладём вSome._ => None— любое другое слово: коробка пустая.let word = first_word(rest);— слово категории уже без пробелов и перевода строки:first_wordрежет по пробельным символам.match Category::from_word(word)— веткаSome(category)отдаёт категорию дальше, веткаNoneобъясняет, что слово не знакомо, и черезcontinueначинает новый круг цикла, так что дальше код идёт только с готовой категорией.run(Command::Add(expense))— дальше всё как раньше: трата прибавляется кspentи печатается.
Как запустить
В папке проекта выполните команду и вводите команды по одной:
cargo run
Копилка: добавить <сумма> <категория> [заметка], список, отчёт, помощь, выход
Записано: 350 руб., еда, «обед в столовой»
Потрачено: 350 руб. из 30000 руб.
Статус: в норме
Не знаю категорию «кино»
Записано: 24000 руб., дом, «без заметки»
Потрачено: 24350 руб. из 30000 руб.
Статус: почти лимит
До свидания
Итого потрачено: 24350 руб. из 30000 руб.
Для примера набрано: добавить 350 еда обед в столовой, добавить 90 кино, добавить 24000 дом, выход; сами набранные строки в выводе не показаны. Если вместо Записано вы видите Не знаю категорию для слова еда, проверьте, что слово набрано маленькими буквами: сравнение точное, Еда с большой буквы не подойдёт.
Второй шаг: let else
Перепишите разбор категории с let else. В ветке "добавить" удалите весь блок let category = match Category::from_word(word) { ... }; и вставьте на его место этот код (на тот же отступ):
let Some(category) = Category::from_word(word) else {
println!("Не знаю категорию «{word}»");
continue;
};
Поведение не изменилось, но код стал короче: сразу видно, что при неизвестной категории мы пропускаем круг, а всё остальное идёт с готовой category. Именно такой вариант нам пригодится в практикуме, где разбор команды будет длиннее.
Проверьте себя
- Команды
добавить 350 еда,добавить 350 транспорт,добавить 350 дом,добавить 350 отдых,добавить 350 другоедают строкуЗаписано: 350 руб., ...с тем же словом. - Команда
добавить 90 кинодаётНе знаю категорию «кино», сумма вspentне растёт, программа продолжает работать. - Удалите строку
_ => None,изfrom_word— какая ошибка появится? Верните строку обратно. - Удалите из
match Category::from_word(word)веткуNone => ...(верните первый вариант сmatch, если уже перешли наlet else) — какую ошибку покажет компилятор? Верните ветку обратно. - Добавьте в
from_wordслово"проезд"через|вместе с"транспорт".
Частые ошибки
Забыли ветку None
Что видно: error[E0004]: non-exhaustive patterns: `None` not covered. Почему: match по Option обязан разобрать и Some, и None. Как исправить: добавьте ветку None => ... или, если остальное безразлично, _ => ....
Сравнивают коробку со значением
Что видно: error[E0308]: mismatched types с пояснением «expected `Option<Category>`, found `Category`» при записи Category::from_word("еда") == Category::Food. Почему: слева коробка, справа сама категория, а сравнивать можно только одинаковые типы. Как исправить: достаньте значение через match или if let, либо сравнивайте коробку с коробкой: ... == Some(Category::Food) (для этого у Category есть PartialEq в derive).
Используют коробку как число
Что видно: error[E0369]: cannot add `{integer}` to `Option<u32>` — или похожая ошибка при любой попытке сложить, умножить, напечатать через {}. Почему: внутри Some число, но снаружи это ещё не число. Как исправить: сначала достаньте значение (match, if let, let else или unwrap_or).
unwrap везде
Что видно: программа собирается, а при неожиданном вводе падает с сообщением called `Option::unwrap()` on a `None` value. Почему: unwrap не проверяет коробку, а ломает программу, если она пуста. Как исправить: замените на match, let else или unwrap_or с разумным запасным значением.
Блок else в let else не выходит
Что видно: ошибка error[E0308]: `else` clause of `let...else` does not diverge. Почему: после else код пошёл бы дальше без значения. Как исправить: завершите блок словом continue, break или return.
Что важно запомнить
- В Rust нет
null: возможное отсутствие значения выражает типOption<T>с вариантамиSome(x)иNone. - Значение внутри
Optionнельзя использовать напрямую; сначала его нужно достать, и компилятор следит, чтобы вы не забыли проNone. matchпоOptionтребует обе ветки; в образцеSome(h)значение сразу попадает в переменнуюh.- Образцы бывают шире имён вариантов: числа и строки, диапазоны
1..=499, варианты через|, условиеif(guard) и_для остального; ветки проверяются сверху вниз. if letудобен, когда важен один вариант;let elseдостаёт значение или выходит из цикла/функции черезcontinue,break,return.unwrap_or(запасное),is_some(),is_none()и.ok()избавляют от лишнегоmatch;unwrap()роняет программу наNone.- Шаг проекта:
Category::from_word(word: &str) -> Option<Category>превращает слово пользователя в категорию или честно говорит «не знаю».
Дальше у нас практикум: из этих кирпичиков соберём функцию parse_command, которая превращает всю строку ввода в команду и заменит цепочку слов-команд в main.