Перейти к основному содержимому

Обработка ошибок в Rust

Разработчику Архитектору
Сначала — общая теория

Ошибка vs исключение — в Rust ожидаемые сбои — Result<T, E>; panic! — когда продолжать выполнение нельзя (как фатальный сбой процесса).


О чём эта статья

В Rust нет исключений в стиле Java: сбой — Result<T, E> или panic! для невосстановимых случаев. match, оператор ?, разница между ожидаемой ошибкой и багом.

Опора: управление, типы.

Интерактивное демо — в Rust нет исключений, ошибки — Result<T, E>; смотрите сценарий "код ошибки" и стек. Подробнее: ошибки и исключения.

Play ITЗагрузка интерактивного демо…


Основные принципы обработки ошибок в Rust

  1. Ошибки — это значения.
    Функции, которые могут завершиться неудачей, возвращают тип Result<T, E>, где:

    • T — тип успешного результата,
    • E — тип ошибки.
  2. Нет try/catch, throw, raise.
    Обработка ошибок происходит явно через сопоставление с образцом (match), макросы (?), или комбинаторы (map, and_then и т.д.).

  3. Паника (panic!) существует, но предназначена только для необратимых ошибок (например, нарушение инвариантов, ошибки в логике программы).

    • Вызывает разворачивание стека (unwinding) или немедленное завершение (abort).
    • Не должна использоваться для обработки ожидаемых ошибок (например, отсутствие файла, недопустимый ввод).

Явная обработка через match:

fn parse_port(text: &str) -> Result<u16, String> {
match text.parse::<u16>() {
Ok(port) if port > 0 => Ok(port),
Ok(_) => Err("port must be greater than 0".into()),
Err(_) => Err(format!("invalid port: {text}")),
}
}

Разбор:

  • text.parse::<u16>() возвращает Result<u16, ParseIntError> — ожидаемый сбой превращается в значение, а не в панику.
  • Ok(port) if port > 0 — успешный разбор с дополнительным условием (guard): ноль отсекается как бизнес-ошибка.
  • Ok(_) => Err(...) перехватывает технически корректный парсинг, но семантически неверное значение.
  • Err(_) => Err(...) преобразует низкоуровневую ошибку парсера в понятное сообщение для вызывающего кода.
  • Функция возвращает единый тип ошибки String, скрывая детали ParseIntError от внешнего API.

Типы ошибок в Rust

Rust не определяет глобальную иерархию ошибок. Вместо этого используются конкретные типы, реализующие трейт std::error::Error.


1. Стандартные типы ошибок из std

  • std::io::Error — ошибки ввода-вывода:

    • Возникают при работе с файлами, сетью, процессами.
    • Содержит код ошибки (std::io::ErrorKind), например:
      • NotFound
      • PermissionDenied
      • ConnectionRefused
      • InvalidInput
      • TimedOut
      • и др.
  • std::fmt::Error — ошибка при форматировании (например, в пользовательском Display::fmt).

  • std::num::ParseIntError, std::num::ParseFloatError — ошибки при парсинге чисел.

  • std::str::Utf8Error — ошибка при проверке UTF-8.

  • std::string::FromUtf8Error — ошибка при создании String из байтов.

  • std::env::VarError — ошибка при чтении переменной окружения:

    • NotPresent
    • NotUnicode
  • std::ffi::FromBytesWithNulError, std::ffi::NulError — ошибки при работе с C-совместимыми строками.

  • std::path::StripPrefixError — ошибка при удалении префикса пути.


2. Трейт std::error::Error

Любой пользовательский тип ошибки должен реализовывать этот трейт (обычно автоматически через thiserror или вручную). Он требует:

  • Display::fmt — человекочитаемое сообщение,
  • Debug::fmt — техническое представление,
  • опционально: source() — ссылка на вложенную ошибку (цепочка причин).

3. Паники (не ошибки в обычном смысле)

Следующие ситуации вызывают panic!:

  • Выход за границы массива: index out of bounds
  • Деление на ноль
  • Нарушение assert! или unwrap() на Err/None
  • Некорректная работа с unsafe (например, двойное освобождение памяти)

Эти события не являются типами ошибок, а приводят к аварийному завершению потока.


Экосистемные практики

  • Библиотеки определяют собственные типы ошибок, часто с помощью крейта thiserror (для удобного определения) или anyhow (для упрощённой обработки в приложениях).
  • Цепочки ошибок строятся через поле source, а не через наследование.
  • Нет общего корневого типа ошибок, кроме как через динамическую диспетчеризацию (Box<dyn std::error::Error>).

Примеры

Option и безопасное извлечение:

fn first_even(numbers: &[i32]) -> Option<i32> {
numbers.iter().copied().find(|n| n % 2 == 0)
}

fn main() {
let values = [1, 3, 4, 7];
match first_even(&values) {
Some(n) => println!("first even: {n}"),
None => println!("no even numbers"),
}
}

Разбор:

  • find возвращает Option<i32>: Some при первом подходящем элементе, None если чётных нет.
  • copied() превращает итератор ссылок &i32 в итератор значений i32.
  • match заставляет обработать оба варианта — компилятор не даст забыть про None.
  • Это типичная замена null: отсутствие значения видно в типе и в ветвлении.

Оператор ? в main:

fn read_port() -> Result<u16, Box<dyn std::error::Error>> {
let raw = std::env::var("APP_PORT")?;
let port: u16 = raw.parse()?;
Ok(port)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
let port = read_port()?;
println!("listening on {port}");
Ok(())
}

Разбор:

  • std::env::var("APP_PORT")? при NotPresent или NotUnicode сразу завершает read_port с ошибкой.
  • raw.parse()? пробрасывает ParseIntError, если строка не число.
  • main -> Result<(), ...> позволяет использовать ? и на верхнем уровне приложения.
  • Каждый ? — сокращение для match, который при Err делает ранний return.
  • В production вместо println! ошибку обычно логируют и завершают процесс с ненулевым кодом.
use std::fs;
use std::num::ParseIntError;

fn read_number_from_file(path: &str) -> Result<i32, Box<dyn std::error::Error>> {
let contents = fs::read_to_string(path)?; // io::Error
let number: i32 = contents.trim().parse()?; // ParseIntError
Ok(number)
}

Разбор:

  • use std::fs; подключает модуль работы с файловой системой, здесь используется функция чтения файла целиком в строку.
  • use std::num::ParseIntError; показывает один из конкретных типов ошибок, который может возникнуть при парсинге числа.
  • Сигнатура Result<i32, Box<dyn std::error::Error>> говорит, что функция либо возвращает число i32, либо любую ошибку, реализующую трейт Error.
  • fs::read_to_string(path)? читает файл по пути path; оператор ? автоматически завершает функцию с ошибкой, если чтение не удалось.
  • contents.trim().parse()? удаляет пробелы/переносы по краям и пытается преобразовать строку в i32; ошибки парсинга также пробрасываются через ?.
  • Ok(number) формирует успешный результат и явно показывает "счастливый путь" выполнения функции.
  • Такой пример демонстрирует главный стиль Rust: ошибки не прячутся, а проходят по типам и обрабатываются контролируемо.

Здесь возможны две конкретные ошибки: std::io::Error и ParseIntError. Обе реализуют std::error::Error и могут быть упакованы в Box<dyn Error>.


Нет типов ошибок

В Rust нет единого списка "типов ошибок", аналогичного другим языкам, потому что:

  • Ошибки представлены конкретными типами, а не иерархией классов.
  • Нет встроенных исключений вроде IndexError, KeyError.
  • Стандартная библиотека предоставляет набор специализированных типов ошибок для разных подсистем.
  • Пользовательские ошибки определяются явно и композиционно.

Таким образом, вместо таксономии исключений Rust предлагает типобезопасную, композиционную модель обработки ошибок через Result и трейт Error.