Прочитайте сигнатуру метода на Java и попробуйте угадать, что он бросает. Внутри может скрываться IOException, IllegalState или собственное исключение из глубины зависимостей, и компилятор молчит, если наследник RuntimeException. Прочитайте сигнатуру на Rust, и ошибки лежат на поверхности: Result говорит, что функция может не справиться, Option говорит, что ответа может не быть. Плата за честность была бы многословием, но в 2016 году в языке появился оператор ?, и три четверти рутины исчезли. Ниже разбор обоих типов, механики раннего возврата, границ с исключениями и инструментов экосистемы.

Почему в Rust нет исключений и как ошибки стали значениями

Модель проста: ошибка это обычное значение, которое возвращается из функции как любой другой результат. Нет невидимой второй дорожки управления, нет раскрутки стека, нет сюрпризов из глубины вызовов. Паника в Rust существует, но она предназначена для другого: для бага, который нельзя обработать осмысленно. Разделение обязанностей выглядит так:

  1. сбой, который можно предвидеть и обработать, возвращается как Result;
  2. отсутствие ответа это нормальная ситуация, оно выражается через Option;
  3. нарушение контракта, исправляемое только программистом, зовёт панику;
  4. внешние сбои вроде недоступного файла оборачиваются типами ошибок библиотеки.

Сравнение с классическими исключениями честнее вести по двум осям. Первая это видимость: проверяемые исключения 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 это частый приём на границах конфигурации:

CODE10 Ветвление с if let превращается в линейную цепочку, где шаги читаются слева направо. Сравните с кодом на Go, где каждый шаг требует ручного if err != nil: там в 2019 году в версию 1.13 добавили функции errors.Is и errors.As, чтобы проверять сбои по значению и типу, но многословность осталась родовой особенностью. Цепочки комбинаторов решают ту же задачу лаконичнее, при этом типы остаются честными на каждом звене.

Паника и unwrap для отказов которые нельзя исправить в рантайме

Паника это не механизм ошибок, а сигнал о баге: индекс вне границ, нарушенный инвариант, unwrap на None. В продукте место такому вызову только в тестах:

#[test]
fn parses() {
    let age: u32 = "37".parse().expect("строка обязана быть числом");
    assert_eq!(age, 37);
}

Разворачивание стека при панике отключается профилем сборки, для встраиваемых систем и кода рядом с C это обычная практика:

[profile.release]
panic = "abort"

На границе с внешним кодом панику ловит catch_unwind, чтобы чужая обёртка не уронила процесс целиком. Общее правило безопасной части языка простое: recoverable сбои живут в типах, паника остаётся для невосстановимых состояний. Отсюда и эстетика unwrap: каждый вызов это подписка на крах при невыполненном ожидании, и линтеры предупреждают о лишнем.

Экосистема обработки ошибок thiserror для библиотек и anyhow для приложений

Разделение ролей устоялось годами, хотя экосистема перепробовала три поколения инструментов, прежде чем прийти к нему: error-chain сменился failure, а их место заняли thiserror и anyhow. Библиотека объявляет конкретный тип ошибки и не тащит лишних зависимостей, приложение агрегирует сбои из разных источников и печатает их человеку. Первую роль закрывает thiserror: тип ошибки описывается как перечисление с атрибутами, из которых макрос генерирует и трейт Error, и конверсии:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum StoreError {
    #[error("ключ {0} не найден")]
    NotFound(String),
    #[error("файл недоступен: {0}")]
    Io(#[from] std::io::Error),
}

Атрибут #[from] создаёт конверсию из io::Error, поэтому оператор ? внутри библиотечного кода работает без ручного map_err. Подключение в Cargo.toml выглядит буднично:

[dependencies]
thiserror = "1"
anyhow = "1"

Вторую роль закрывает anyhow: у него один тип Result, конверсия через ? из чего угодно и метод context, добавляющий пояснение к каждому шагу:

use anyhow::{Context, Result};

fn load() -> Result<String> {
    let text = std::fs::read_to_string("config.txt")
        .context("не удалось прочитать config.txt")?;
    Ok(text)
}

Иерархия сбоев при необходимости уточняется через downcast_ref у Box<dyn Error>, а атрибут #[non_exhaustive] на перечислении ошибок оставляет библиотеке право добавлять варианты без ломки совместимости. Придраться можно к одному: длинные цепочки конверсий иногда рождают простыню сообщений компилятора, когда типы ошибок не совпали на каком-то звене.

Итоговое сравнение выходит спокойным, без религиозного пыла. Исключения выигрывают в краткости вызова, зато прячут контрольные точки из сигнатур и стоят раскруткой стека. Result и Option переносят все ветки в типы, заставляют размышлять о сбоях в момент проектирования, а оператор ? снимает с этой честности главную плату, многословие. Проверяемые исключения Java честны, но допускают лазейку unchecked, и дисциплина держится на ревью, а не на компиляторе. В Rust отсутствие значения и сбой разведены по разным типам, оба стоят ноль дополнительных байт в горячем пути, а весь путь ошибки читается по сигнатурам сверху вниз. Это не серебряная пуля, а рабочая дисциплина: сбои проектируют так же тщательно, как и основной сценарий работы.