错误处理与输入校验

上文我们大致了解了"文字地牢"小游戏的地图生成与迭代器,那么我们本章的目标是理解 Result/Option?match 与早返回;区分 panic! 与可恢复错误;介绍 thiserror/anyhow

基本概念

Result 和 Option 类型

Rust使用 ResultOption类型来进行错误处理,这是一种类型安全的错误处理方式,强制开发者在编译时考虑错误情况。

Result<T, E>类型表示可能成功返回 T值或失败返回 E错误的计算:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option<T>类型表示可能有值 T或无值的情况:

enum Option<T> {
    Some(T),
    None,
}

可恢复错误 vs 不可恢复错误

Rust区分可恢复错误(如文件不存在)和不可恢复错误(如数组越界),并提供不同的处理机制。

可恢复错误:

  • 使用 Result<T, E>类型表示
  • 可以被调用者处理或传播
  • 适用于预期可能发生的错误情况

不可恢复错误:

  • 使用 panic!宏处理
  • 程序会立即终止(除非被捕获)
  • 适用于程序bug或不可恢复的状态

错误传播

通过 ?操作符,可以简洁地将错误传播给调用者,而无需在每个函数中都显式处理错误。

?操作符的工作原理:

  1. 如果值是 Ok(T),则提取 T值继续执行
  2. 如果值是 Err(E),则立即从当前函数返回错误

错误类型转换

Rust通过 From trait实现错误类型之间的自动转换,简化错误处理代码。

如何使用

Result 和 Option 的基本使用

// 使用 Result
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

// 使用 Option
fn find_element(vec: &[i32], target: i32) -> Option<usize> {
    vec.iter().position(|&x| x == target)
}

// 处理 Result
match divide(10.0, 2.0) {
    Ok(result) => println!("Result: {}", result),
    Err(error) => println!("Error: {}", error),
}

// 处理 Option
match find_element(&[1, 2, 3], 2) {
    Some(index) => println!("Found at index: {}", index),
    None => println!("Not found"),
}

错误传播和 ? 操作符

// 传统错误处理
fn read_file_traditional(path: &str) -> Result<String, std::io::Error> {
    let file = match std::fs::File::open(path) {
        Ok(file) => file,
        Err(error) => return Err(error),
    };
  
    let mut contents = String::new();
    match file.read_to_string(&mut contents) {
        Ok(_) => Ok(contents),
        Err(error) => Err(error),
    }
}

// 使用 ? 操作符简化
fn read_file_simplified(path: &str) -> Result<String, std::io::Error> {
    let mut file = std::fs::File::open(path)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)
}

自定义错误类型

#[derive(Debug)]
enum MyError {
    Io(std::io::Error),
    Parse(std::num::ParseIntError),
    Custom(String),
}

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            MyError::Io(err) => write!(f, "IO error: {}", err),
            MyError::Parse(err) => write!(f, "Parse error: {}", err),
            MyError::Custom(msg) => write!(f, "Custom error: {}", msg),
        }
    }
}

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

// 实现 From trait 进行自动转换
impl From<std::io::Error> for MyError {
    fn from(error: std::io::Error) -> Self {
        MyError::Io(error)
    }
}

可恢复 vs 不可恢复

  • 可恢复:文件不存在、格式错误,用 Result 表达并返回给调用者。
  • 不可恢复:逻辑断言失败,用 panic!(测试中可结合 #[should_panic])。

错误边界与转换

  • 在公共边界(如 io::load_state)返回语义清晰的错误类型(AppError)。
  • 使用 From/? 简化底层错误的转换。

工具

  • anyhow:快速聚合错误,适合应用层。
  • thiserror:优雅定义错误枚举,自动实现 Display

注意事项

  1. 始终处理 ResultOption类型,不要忽略它们。
  2. 合理区分可恢复错误和不可恢复错误,避免滥用 panic!
  3. 在公共API边界返回语义清晰的错误类型。
  4. 使用 ?操作符简化错误传播,但要注意函数返回类型必须是 Result
  5. 为错误添加上下文信息,便于调试和用户理解。
  6. 避免在库代码中使用 panic!,除非是不可恢复的错误。
  7. 使用 unwrap()expect()时要谨慎,只在确定不会失败的情况下使用。
  8. 考虑使用 anyhowthiserror crate来简化错误处理。

本项目中的使用

在我们的项目中,我们定义了一个自定义的错误类型 AppError,并为它实现了 From trait来简化错误转换:

pub type Result<T> = std::result::Result<T, AppError>;

#[derive(Debug)]
pub enum AppError {
    Io(std::io::Error),
    Serde(serde_json::Error),
}

impl std::fmt::Display for AppError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AppError::Io(e) => write!(f, "IO error: {}", e),
            AppError::Serde(e) => write!(f, "Serde error: {}", e),
        }
    }
}

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

impl From<std::io::Error> for AppError {
    fn from(e: std::io::Error) -> Self { AppError::Io(e) }
}

impl From<serde_json::Error> for AppError {
    fn from(e: serde_json::Error) -> Self { AppError::Serde(e) }
}

在IO操作中,我们使用 ?操作符来传播错误:

pub fn load_state(path: &str) -> Result<GameState> {
    let s = std::fs::read_to_string(path)?;
    let state: GameState = serde_json::from_str(&s)?;
    Ok(state)
}

错误处理的最佳实践

在地牢探险游戏中,我们可以应用以下错误处理模式:

  1. 输入验证
fn parse_command(input: &str) -> Result<Command, AppError> {
    match input.trim().to_lowercase().as_str() {
        "w" => Ok(Command::Move(Direction::North)),
        "a" => Ok(Command::Move(Direction::West)),
        "s" => Ok(Command::Move(Direction::South)),
        "d" => Ok(Command::Move(Direction::East)),
        "save" => Ok(Command::Save),
        "load" => Ok(Command::Load),
        "q" => Ok(Command::Quit),
        _ => Err(AppError::Custom("Unknown command".to_string())),
    }
}
  1. 文件操作错误处理
pub fn save_game(path: &str, state: &GameState) -> Result<()> {
    let json = serde_json::to_string_pretty(state)
        .map_err(|e| AppError::Serialization(format!("Failed to serialize: {}", e)))?;
  
    std::fs::write(path, json)
        .map_err(|e| AppError::Io(format!("Failed to write file: {}", e)))?;
  
    Ok(())
}
  1. 游戏逻辑错误
impl Game {
    fn move_player(&mut self, direction: Direction) -> Result<()> {
        let new_position = self.calculate_new_position(direction);
      
        if !self.is_valid_position(new_position) {
            return Err(AppError::InvalidMove("Cannot move there".to_string()));
        }
      
        self.state.player.position = new_position;
        Ok(())
    }
}

高级错误处理技术

使用 anyhow 简化错误处理

use anyhow::{Result, Context, bail};

fn process_file(path: &str) -> Result<String> {
    let contents = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read file: {}", path))?;
  
    if contents.is_empty() {
        bail!("File is empty: {}", path);
    }
  
    Ok(contents)
}

使用 thiserror 定义错误类型

use thiserror::Error;

#[derive(Error, Debug)]
pub enum GameError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
  
    #[error("Serialization error: {0}")]
    Serialization(#[from] serde_json::Error),
  
    #[error("Invalid move: {0}")]
    InvalidMove(String),
  
    #[error("Game over: {0}")]
    GameOver(String),
}

错误处理策略

库 vs 应用程序

  1. 库代码:返回具体的错误类型,允许调用者决定如何处理
  2. 应用程序:可以使用 anyhow等通用错误类型简化处理

错误日志记录

use log::{error, warn, info};

fn handle_error(error: &AppError) {
    match error {
        AppError::Io(e) => error!("IO error occurred: {}", e),
        AppError::Serde(e) => warn!("Serialization error: {}", e),
        _ => info!("Other error: {:?}", error),
    }
}

练习:

  1. load_state 区分"文件不存在"与"格式错误"并提示不同信息。
  2. 使用 thiserror 重写 AppError(可选)。

概念补充

  • 错误类型建模:优先用枚举表达可区分的错误分支,利于上层处理与测试断言。
  • Option vs Result:缺失但非错误用 Option;需要错误信息与分支处理用 Result
  • ? 传播与上下文:结合 anyhow::Context/eyre::WrapErr 为错误增加语境(“读取 save.json 失败”)。
  • 日志与错误:错误路径上写日志但不要静默吞错;返回错误让上层决定行为(重试/提示/退出)。
  • 边界策略:库层返回富信息错误类型;应用层可聚合为 anyhow::Error 简化主流程。
  • 恢复与重试:对可恢复错误(I/O、网络)考虑退避重试;对不可恢复错误尽早 panic!/assert! 暴露。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐