跳转到主内容从返回码到 Result | 物联网民工

从返回码到 Result

C 开发者很熟悉这种错误处理方式:

int read_config(const char *path, struct Config *out);

函数返回 0 表示成功,非 0 表示失败;真正的结果通过输出参数写回。系统调用还会用 -1 加 errno。资源多一点时,再配合 goto cleanup 收尾。

这套方式能工作,但有几个长期成本:

  • 调用方可能忘记检查返回值
  • 成功值和错误码分散在不同位置
  • 输出参数在失败时是否有效需要约定
  • 错误码很容易丢上下文
  • cleanup 路径和错误传播缠在一起

Rust 的 Result 不是异常。它更像是把“成功或失败”写进函数返回类型。

C 的返回码靠约定维持

看一个简化的 C 版本:

#include <errno.h>
#include <stdio.h>

int read_first_byte(const char *path, unsigned char *out)
{
    FILE *fp = fopen(path, "rb");
    if (fp == NULL) {
        return errno;
    }

    int ch = fgetc(fp);
    if (ch == EOF) {
        fclose(fp);
        return EIO;
    }

    *out = (unsigned char)ch;
    fclose(fp);
    return 0;
}

调用方必须记得检查:

unsigned char byte;
int ret = read_first_byte("config.bin", &byte);
if (ret != 0) {
    fprintf(stderr, "read failed: %d\n", ret);
    return ret;
}

这里的几个问题都很典型:

  • byte 只有成功时才有效
  • ret 的含义要查约定
  • 如果调用方忘记检查 ret,错误可能继续扩散
  • 如果函数内部路径变多,每条失败路径都要处理资源

Rust 不会让错误自动消失。它只是把这种约定变成返回类型。

Result 把成功和失败放在一起

Rust 里对应的函数可以写成:

use std::fs;
use std::io;

fn read_first_byte(path: &str) -> Result<u8, io::Error> {
    let data = fs::read(path)?;

    data.first()
        .copied()
        .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "empty file"))
}

签名里的重点是:

Result<u8, io::Error>

它表示:

成功:返回一个 u8
失败:返回一个 io::Error

这和 C 的“返回码 + 输出参数”相比,边界更清楚。成功值和错误值在同一个返回类型里,调用方不能假装这个函数永远成功。

调用时通常这样写:

match read_first_byte("config.bin") {
    Ok(byte) => println!("byte = {byte}"),
    Err(err) => eprintln!("read failed: {err}"),
}

Ok 和 Err 是两个分支。没有检查错误,编译器和 lint 工具会更容易提醒你。

? 是“失败就返回”,不是异常

这行代码:

let data = fs::read(path)?;

可以先按 C 直觉理解成:

ret = read_file(path, &data);
if (ret != 0) {
    return ret;
}

? 的作用是:

如果是 Ok(value),取出 value 继续执行
如果是 Err(error),从当前函数返回这个 error

它不是异常。没有隐藏的 catch,也不会跨任意栈层跳转。它只在返回 Result 的函数里沿着返回值传播错误。

所以这个函数:

fn load_config(path: &str) -> Result<String, io::Error> {
    let text = fs::read_to_string(path)?;
    Ok(text)
}

等价于把错误检查写短了:

fn load_config(path: &str) -> Result<String, io::Error> {
    match fs::read_to_string(path) {
        Ok(text) => Ok(text),
        Err(err) => Err(err),
    }
}

? 省掉的是重复的错误转发代码,不是错误处理本身。

main 负责把错误变成进程结果

命令行工具里,main 通常处在进程边界。内部函数可以返回 Result,最后由 main 决定怎么输出错误和退出。

use std::{fs, process};

fn main() {
    if let Err(err) = run() {
        eprintln!("tool: {err}");
        process::exit(1);
    }
}

fn run() -> Result<(), Box<dyn std::error::Error>> {
    let text = fs::read_to_string("config.toml")?;
    println!("{}", text.len());
    Ok(())
}

这个结构和 C 里“底层函数返回错误,上层统一打印和退出”很像。

区别在于:Rust 的函数签名会明确告诉你哪里可能失败;? 把错误一路传到边界;main 再把错误转成 stderr 和退出码。

库不要只返回字符串错误

小工具里为了省事,可以用 Box<dyn std::error::Error> 或 anyhow 这类方式把错误统一往上传。但写库时,错误类型就是 API 的一部分。

例如一个解析函数:

#[derive(Debug)]
enum ParseError {
    TooShort,
    BadVersion(u8),
    LengthMismatch,
}

fn parse_packet(input: &[u8]) -> Result<(), ParseError> {
    if input.len() < 2 {
        return Err(ParseError::TooShort);
    }

    let version = input[0];
    if version != 1 {
        return Err(ParseError::BadVersion(version));
    }

    Ok(())
}

这比只返回 -1 或 "parse failed" 更有信息量。调用方可以根据错误类型决定重试、丢包、报警还是统计。

后面真正写库 API 时,还会继续讲错误类型设计。第一层先记住:CLI 可以把错误转成人能读的消息;库应该尽量保留机器能判断的错误结构。

errno 和 Result 不是一一替换

errno 是全局或线程局部的错误状态,常和系统调用返回值配合使用:

fd = open(path, O_RDONLY);
if (fd < 0) {
    perror("open");
}

Rust 标准库通常把这类错误包进 io::Error:

let data = std::fs::read(path)?;

如果你需要底层 OS 错误码,可以取出来:

if let Some(code) = err.raw_os_error() {
    eprintln!("os error = {code}");
}

但大多数库 API 不应该把所有错误都压成一个整数。整数错误码适合 ABI 和 C 边界;Rust 内部更适合用有结构的错误类型。

这一篇先记住什么

C 和 Rust 的第一层对应关系可以这样看:

返回 0 / 非 0              -> Result<T, E>
输出参数 out               -> Ok(value)
errno / 错误码             -> Err(error)
手写 if (ret != 0) return  -> ?
顶层 fprintf + return code -> main 里打印错误并设置退出码

Result 不是异常。它是一个普通返回值,只是把成功和失败都写进类型。

? 也不是隐藏控制流。它只是遇到 Err 就从当前函数返回,让错误沿调用链往上走。

下一篇再看资源释放。C 里的 goto cleanup、free、fclose 和多返回路径,会对应到 Rust 的所有权和 Drop。