Avatar ·

Как правильно создавать и использовать пользовательские типы ошибок в Rust?

Всем привет! Я новичок в Rust и пытаюсь разобраться с обработкой ошибок. Прочитал книгу, но всё равно не понимаю, как правильно создавать свои типы ошибок для библиотеки. У меня есть простой парсер конфигурационных файлов. Я хочу, чтобы он возвращал понятные ошибки: "синтаксическая ошибка в строке X", "неизвестный параметр", "ошибка ввода-вывода". Сейчас я сделал так: ```rust enum ConfigError { IoError, ParseError, UnknownParam, } ``` Но это неудобно — нет информации о том, где именно произошла ошибка. Плюс я не могу использовать `?` с `std::io::Error` и своим типом одновременно. Я читал про трейты `std::error::Error`, `Display` и `Debug`, но не понимаю, как их правильно реализовать. Видел, что многие используют крейт `thiserror`, но хочется сначала понять, как это делается вручную. Подскажите, как правильно спроектировать пользовательский тип ошибки в Rust? Как сделать его совместимым со стандартными типами ошибок и оператором `?`? Нужен ли какой-то специальный паттерн для вложенных ошибок? Вот пример моего кода, который не компилируется: ```rust use std::fs::File; use std::io::Read; fn read_config(path: &str) -> Result { let mut file = File::open(path)?; // Ошибка: несовместимость типов let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents) } ``` Как это исправить? Заранее спасибо! <<>>

Правильный дизайн пользовательских ошибок в Rust

Вы на верном пути! Проблема в том, что ваш ConfigError не реализует преобразование из std::io::Error, поэтому оператор ? не может автоматически конвертировать типы. Давайте разберем правильный подход.

Шаг 1: Структурированный enum с данными

Вместо простых вариантов, добавляйте вложенные данные:

use std::fmt;
use std::io;

#[derive(Debug)]
pub enum ConfigError {
    Io(io::Error),
    Parse { line: usize, message: String },
    UnknownParam(String),
}

Шаг 2: Реализация трейтов Display и Error

Трейт Display нужен для пользовательского сообщения, а Error — для совместимости со стандартной обработкой ошибок:

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ConfigError::Io(err) => write!(f, "Ошибка ввода-вывода: {}", err),
            ConfigError::Parse { line, message } => {
                write!(f, "Синтаксическая ошибка в строке {}: {}", line, message)
            }
            ConfigError::UnknownParam(param) => {
                write!(f, "Неизвестный параметр: {}", param)
            }
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ConfigError::Io(err) => Some(err),  // Позволяет получить цепочку ошибок
            _ => None,
        }
    }
}

Шаг 3: Реализация From для автоматической конвертации

Чтобы оператор ? работал, реализуйте From для стандартных типов:

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

Теперь ваш код будет работать:

fn read_config(path: &str) -> Result<String, ConfigError> {
    let mut file = File::open(path)?; // Автоматически конвертируется в ConfigError::Io
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

Шаг 4: Использование в функциях с парсингом

Добавим реальный парсинг с ошибками:

fn parse_line(line: &str, line_num: usize) -> Result<(), ConfigError> {
    if line.trim().is_empty() {
        return Ok(());
    }
    if !line.contains('=') {
        return Err(ConfigError::Parse {
            line: line_num,
            message: "Ожидается формат 'ключ=значение'".into(),
        });
    }
    let parts: Vec<&str> = line.splitn(2, '=').collect();
    if parts[0].trim().is_empty() {
        return Err(ConfigError::Parse {
            line: line_num,
            message: "Пустое имя параметра".into(),
        });
    }
    Ok(())
}

fn parse_config(path: &str) -> Result<(), ConfigError> {
    let contents = read_config(path)?;
    for (i, line) in contents.lines().enumerate() {
        parse_line(line, i + 1)?;
    }
    Ok(())
}

Шаг 5: Использование крейта thiserror (рекомендуется для продакшна)

Хотя вы просили без крейтов, вот как это упрощается с thiserror:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("Ошибка ввода-вывода: {0}")]
    Io(#[from] io::Error), // Автоматически реализует From и source()
    
    #[error("Синтаксическая ошибка в строке {line}: {message}")]
    Parse { line: usize, message: String },
    
    #[error("Неизвестный параметр: {0}")]
    UnknownParam(String),
}

Этот код делает то же самое, что и ручная реализация выше, но в 10 раз короче.

Ключевые принципы:

  • Всегда реализуйте Display и Debug — это минимальное требование для ошибок
  • Реализуйте Error — для совместимости с dyn Error и цепочками ошибок
  • Используйте From — для автоматической конвертации через ?
  • Храните контекст — номер строки, имя файла и т.д.
  • Для вложенных ошибок — используйте Box или enum с source()
<<>>

Альтернативный подход: использование обобщенного типа ошибки

Иногда не хочется создавать полноценный enum для каждой библиотеки. Можно использовать обобщенный тип с динамической ошибкой:

use std::fmt;
use std::error::Error as StdError;

#[derive(Debug)]
pub struct MyError {
    kind: String,
    message: String,
    source: Option>,
}

impl MyError {
    pub fn new(kind: &str, message: &str) -> Self {
        Self {
            kind: kind.to_string(),
            message: message.to_string(),
            source: None,
        }
    }
    
    pub fn with_source(mut self, source: impl StdError + 'static) -> Self {
        self.source = Some(Box::new(source));
        self
    }
}

impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "[{}] {}", self.kind, self.message)
    }
}

impl StdError for MyError {
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        self.source.as_ref().map(|e| e.as_ref() as &(dyn StdError + 'static))
    }
}

// Использование:
fn read_file(path: &str) -> Result<String, MyError> {
    std::fs::read_to_string(path)
        .map_err(|e| MyError::new("IO", "Не удалось прочитать файл")
            .with_source(e))
}

Этот подход удобен для прототипов или когда типы ошибок меняются часто. Недостаток — теряется строгая типизация, и пользователь вашей библиотеки не сможет легко обработать конкретные варианты ошибок через match. Для библиотек лучше использовать enum, как описано в первом ответе.