Прочитайте сигнатуру метода на Java и попробуйте угадать, что он бросает. Внутри может скрываться IOException, IllegalState или собственное исключение из глубины зависимостей, и компилятор молчит, если наследник RuntimeException. Прочитайте сигнатуру на Rust, и ошибки лежат на поверхности: Result говорит, что функция может не справиться, Option говорит, что ответа может не быть. Плата за честность была бы многословием, но в 2016 году в языке появился оператор ?, и три четверти рутины исчезли. Ниже разбор обоих типов, механики раннего возврата, границ с исключениями и инструментов экосистемы.
Почему в Rust нет исключений и как ошибки стали значениями
Модель проста: ошибка это обычное значение, которое возвращается из функции как любой другой результат. Нет невидимой второй дорожки управления, нет раскрутки стека, нет сюрпризов из глубины вызовов. Паника в Rust существует, но она предназначена для другого: для бага, который нельзя обработать осмысленно. Разделение обязанностей выглядит так:
- сбой, который можно предвидеть и обработать, возвращается как Result;
- отсутствие ответа это нормальная ситуация, оно выражается через Option;
- нарушение контракта, исправляемое только программистом, зовёт панику;
- внешние сбои вроде недоступного файла оборачиваются типами ошибок библиотеки.
Сравнение с классическими исключениями честнее вести по двум осям. Первая это видимость: проверяемые исключения Java, те что с throws в сигнатуре, тоже документируют сбои, но unchecked живут вне сигнатур и выстреливают в рантайме, а в C# и Python компилятор вообще не требует помнить о бросках. Python растит привычку ловить наугад: блоки try оборачивают любое подозрительное место, потому что сигнатура молчит о срывах. Rust закрывает лазейку: тип в сигнатуре обязателен, и подозрительных мест не остаётся, список сбоев виден до вызова. Вторая ось это цена: исключение захватывает стек и аллоцирует объект при броске, Result это обычное значение на стеке, размер которого известен на этапе компиляции. Даже в C++, где счастливый путь с исключениями бесплатен, сама механика раскрутки тянет за собой таблицы unwind и раздувание бинарника, а ключевое слово noexcept из стандарта 2011 года превратилось в обязательную дисциплину. Rust выбирает модель без второй дорожки: сбой либо в типе, либо его нет.
Тип Option вместо нулевых указателей в сигнатурах функций
Option<T> это перечисление из двух вариантов, Some(T) со значением и None без него. Нулевой ссылки в безопасном Rust нет, и отрицать очевидное смешно: изобретатель нулевого указателя Тони Хоар сам называл его своей ошибкой на миллиард долларов. Option выносит отсутствие в систему типов: компилятор не даст прочитать содержимое без проверки. Стандартная библиотека применяет этот принцип повсюду, поиск в векторе возвращает Option, и отсутствие совпадения это не сбой:
fn main() {
let names = vec!["Анна", "Борис", "Вера"];
let found = names.iter().find(|n| **n == "Борис");
match found {
Some(name) => println!("нашли {}", name),
None => println!("в списке нет"),
}
}
Приятная деталь спрятана в компиляторе: перечисление использует нишу, свободное значение указателя, поэтому Option<&T> занимает столько же байт, сколько голая ссылка, None кодируется нулевым расположением памяти:
use std::mem::size_of;
fn main() {
println!("{}", size_of::<&u64>());
println!("{}", size_of::<Option<&u64>>());
}
Обе строки напечатают 8 на 64-битной платформе, абстракция не стоит ни байта. Отсутствие значения отличается от ошибки, и это различие возвращает честность сигнатурам: функция find не падает и не лжёт, она просто говорит правду, что элемента может не быть. Цепочка комбинаторов собирает и разреженные наборы, выкидывая пустоты без единого if:
fn main() {
let raw = vec!["1", "x", "2", "y", "3"];
let nums: Vec<i32> = raw.iter().filter_map(|s| s.parse().ok()).collect();
println!("{:?}", nums);
}
Тип Result и проверка всех ветвей в match выражении
Result<T, E> тоже перечисление, Ok(T) с полезным значением и Err(E) со сбоем. Разбор результата через match обязан покрыть обе ветви, забыть обработку ошибки компилятор не даст. Небольшой парсер с проверкой выглядит так:
use std::error::Error;
fn parse_age(src: &str) -> Result<u8, Box<dyn Error>> {
let age: u8 = src.trim().parse()?;
if age > 120 {
return Err("нереалистичный возраст".into());
}
Ok(age)
}
fn main() -> Result<(), Box<dyn Error>> {
println!("{}", parse_age("37")?);
Ok(())
}
Конкретные сбои проверяются точечно, без сравнения текстов сообщений:
fn is_missing(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::NotFound
}
Тип Box<dyn Error> стоит читать как любой сбой: ParseIntError из парсера и строка из проверки возраста конвертируются в него без ручного кода конверсии. Функция main, возвращающая Result, разрешена со времён Rust 1.26 в 2018 году, маленьким утилитам это сильно экономит строки. Ошибка здесь тоже значение без лишней магии: в match её можно деконструировать, расширить через map_err, проверить сравнением в matches! или собрать в структуру. С Java тут роднит одно: связка с типом в сигнатуре. Отличает другое: в Rust нет иерархии бросаемых типов, сбой не прыгает через пять слоёв вызовов сам по себе, его передают руками, и каждая передача видна в диффе.
Оператор вопроса от макроса try до раннего возврата
До стабилизации оператора ? в Rust 1.13 в ноябре 2016 года тот же смысл нес макрос try!, и в старом коде его встречают до сих пор. Оператор разворачивается компилятором в ветку match: при Ok значение извлекается и код идёт дальше, при Err функция немедленно возвращает ошибку наверх. Сравнение оригинала и развёртки:
fn double(src: &str) -> Result<u32, std::num::ParseIntError> {
let value: u32 = src.parse()?;
Ok(value * 2)
}
fn double_manual(src: &str) -> Result<u32, std::num::ParseIntError> {
let parsed: Result<u32, std::num::ParseIntError> = src.parse();
let value = match parsed {
Ok(v) => v,
Err(e) => return Err(e),
};
Ok(value * 2)
}
Вторая функция делает то же самое, но читается как канцелярия. Бонус оператора не только в краткости: он попутно конвертирует тип сбоя через трейт From, если функция возвращает более широкий тип ошибки, писать map_err вручную не нужно. Работает ? и на Option, возвращая None тем же манером. В 2022 году в Rust 1.65 к нему добавилась конструкция let else, которая разбирает Option с ранним выходом без вложенности:
fn first_port(src: &str) -> u32 {
let Some(head) = src.split(',').next() else {
return 0;
};
head.parse().unwrap_or(0)
}
Оператор ? заодно сделал идиоматичным сбор коллекции результатов, где любая ошибка прерывает весь цикл:
fn parse_all(src: &[String]) -> Result<Vec<u32>, std::num::ParseIntError> {
src.iter().map(|s| s.parse()).collect()
}
Существует и расширенная механика try-блоков, которые сворачивают последовательность в Result прямо внутри выражения, но она до сих пор гостит в ночных сборках, и практике хватает обычного оператора. Макрос try! после появления оператора помечен устаревшим, и это редкий случай, когда новая механика выиграла безоговорочно: с 2016 года ранняя обработка ошибок перестала быть главным аргументом против Rust.
Комбинаторы map and_then и unwrap_or в цепочках вызовов
Оператор ? хорош внутри функций, а внутри выражений живут комбинаторы. Они строят цепочки без промежуточных переменных:
fn pipeline(src: &str) -> Option<u32> {
src.split_whitespace()
.last()
.and_then(|w| w.parse().ok())
.map(|n| n + 1)
.filter(|n| *n % 2 == 0)
}
map применяет функцию к содержимому, and_then строит последовательность из шагов, которые сами могут не сработать, unwrap_or подставляет значение по умолчанию, ok_or превращает None в полноценную ошибку:
use std::collections::HashMap;
fn price_of(prices: &HashMap<&str, u32>, key: &str) -> Result<u32, String> {
prices.get(key).copied().ok_or(format!("нет ключа {}", key))
}
Стандартный конструктор дефолта выручает при пустых настройках, empty vec вместо None это частый приём на границах конфигурации: