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

Заметка вторая · 6 минут чтения

В Rust нет исключений. Ошибка — обычное значение, которое функция возвращает, а вызывающий код обязан что-то с ним сделать.

Option: значения может не быть

Вместо null в языке есть тип Option<T> с двумя вариантами: Some(значение) и None. Забыть про пустой случай не получится — код просто не скомпилируется.

fn first_word(text: &str) -> Option<&str> {
    text.split_whitespace().next()
}

fn main() {
    match first_word("привет мир") {
        Some(word) => println!("первое слово: {word}"),
        None => println!("строка пустая"),
    }
}

Result и оператор ?

Если операция может не удаться, функция возвращает Result<T, E>: либо Ok(результат), либо Err(ошибка). Разбирать каждый вызов через match утомительно, поэтому есть оператор ?. Он достаёт значение из Ok, а при Err сразу возвращает ошибку из текущей функции.

use std::error::Error;
use std::fs;

fn read_port(path: &str) -> Result<u16, Box<dyn Error>> {
    let text = fs::read_to_string(path)?;     // может вернуть io::Error
    let port = text.trim().parse::<u16>()?;   // может вернуть ParseIntError
    Ok(port)
}

fn main() {
    match read_port("port.txt") {
        Ok(port) => println!("порт: {port}"),
        Err(err) => eprintln!("не удалось прочитать порт: {err}"),
    }
}

Тип Box<dyn Error> означает «любая ошибка». Для небольших программ и прототипов этого достаточно: оператор ? сам упакует в него и ошибку ввода-вывода, и ошибку разбора числа.

Собственный тип ошибки

В библиотеке лучше дать вызывающему коду возможность различать причины. Для этого ошибку описывают перечислением и реализуют для него From — тогда ? продолжит работать без единой лишней строки.

use std::fmt;
use std::io;
use std::num::ParseIntError;

#[derive(Debug)]
enum ConfigError {
    Io(io::Error),
    BadPort(ParseIntError),
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(err) => write!(f, "ошибка чтения: {err}"),
            ConfigError::BadPort(err) => write!(f, "некорректный порт: {err}"),
        }
    }
}

impl std::error::Error for ConfigError {}

impl From<io::Error> for ConfigError {
    fn from(err: io::Error) -> Self {
        ConfigError::Io(err)
    }
}

impl From<ParseIntError> for ConfigError {
    fn from(err: ParseIntError) -> Self {
        ConfigError::BadPort(err)
    }
}

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

Полезные методы

МетодЧто делает
unwrap_or(x)Возвращает значение или запасной вариант x.
unwrap_or_default()Значение или значение по умолчанию для типа.
map(f)Преобразует содержимое Some или Ok, не трогая пустой случай и ошибку.
and_then(f)Цепочка операций, каждая из которых может не удаться.
ok_or(err)Превращает Option в Result.
map_err(f)Преобразует ошибку, например в собственный тип.

А как же panic?

Паника — для ситуаций, когда продолжать бессмысленно: нарушен инвариант, в программе ошибка. Методы unwrap() и expect("…") паникуют, если значения нет. Они уместны в тестах, примерах и там, где отсутствие значения действительно невозможно. В остальном коде возвращайте Result: решать, что делать с ошибкой, должен тот, кто вызвал функцию.