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。