把 Option<T> 转成 Result<T, E> 时,ok_or 和 ok_or_else 是编译器提示里出现频率最高的一对方法。多数人凭直觉选:错误值简单就用 ok_or,需要格式化就用 ok_or_else。这个直觉方向没错,但理由常常说不清,于是代码里出现两种极端:一种是无脑全用 ok_or,在热路径上白白构造了一个立刻被丢弃的 String;另一种是无脑全用 ok_or_else,写出一堆只返回常量变体的闭包,反而牺牲了可读性。这篇文章要解决的问题是:把「错误构造开销」这件事拆开量化,给出可以照着执行的判断原则,并解释清楚哪些情况下这个选择其实无所谓。
两个方法的签名差异决定了开销模型
先看清楚标准库里的定义(core::option::Option):
impl<T> Option<T> {
pub fn ok_or<E>(self, err: E) -> Result<T, E> {
match self {
Some(v) => Ok(v),
None => Err(err),
}
}
pub fn ok_or_else<E, F>(self, err: F) -> Result<T, E>
where
F: FnOnce() -> E,
{
match self {
Some(v) => Ok(v),
None => Err(err()),
}
}
}
差异只有一处:ok_or 的 err 是按值传入的实参,在调用点求值;ok_or_else 的 err 是 FnOnce() -> E,只在 None 分支里被调用。
由此得到基本结论:只要 Some 分支可能被执行,ok_or 的参数就一定会被构造,然后被丢弃;ok_or_else 的闭包在 Some 时不执行,构造代价为零。这里说的「可能被执行」很关键——如果调用点的上下文已经证明值一定是 None,两者开销相同,但这种情况在实际代码里极少,编译器也不会替你消除 ok_or 的参数求值(实参求值属于可观察行为,构造 String 可能分配内存,不能随意删除)。
E 的构造代价分四档
把常见错误类型按构造成本分类,判断就有了抓手:
| 档位 | 典型类型 | 构造代价 | 推荐 |
|---|---|---|---|
| 零成本 | 无字段的 enum 变体、Option<NonZeroU32>、bool |
无堆分配,通常就是写一个立即数,甚至常量折叠 | ok_or 足够 |
| 栈上小拷贝 | [u8; 4]、u32、SocketAddr(V4,16 字节) |
Copy,无分配 |
ok_or 足够 |
| 一次堆分配 | String、Box<str>、Vec<u8>、字段含 String 的 struct |
一次 malloc + 一次 free,字符串还可能触发格式化 | 只在 None 极少见时用 ok_or |
| 多次分配 / 昂贵计算 | 字段含多个 String 的错误结构、format! 拼接、DNS 解析、读文件、栈回溯 |
多次分配,或毫秒级系统调用 | 必须 ok_or_else |
注意第三、四档的分界线不在「有没有分配」,而在「Some 分支有多热」。一次 String 分配在冷路径上(比如启动时读配置)完全不值得优化;在每帧调用几千次的解析函数里,就是实打实的浪费。
用基准测试看清差别,而不是靠感觉
下面这个例子可以直接跑。它模拟一个高频调用的解析函数:输入是合法的十六进制串,None 只在异常输入时出现。
use std::hint::black_box;
use std::time::Instant;
#[derive(Debug)]
struct ParseError {
input: String,
position: usize,
}
#[inline(never)]
fn parse_with_ok_or(s: &str) -> Result<u32, ParseError> {
u32::from_str_radix(s, 16).ok().ok_or(ParseError {
input: s.to_owned(),
position: 0,
})
}
#[inline(never)]
fn parse_with_ok_or_else(s: &str) -> Result<u32, ParseError> {
u32::from_str_radix(s, 16).ok().ok_or_else(|| ParseError {
input: s.to_owned(),
position: 0,
})
}
fn bench<F>(name: &str, f: F)
where
F: Fn(&str) -> Result<u32, ParseError>,
{
let inputs: Vec<String> = (0..1000).map(|i| format!("{:04x}", i)).collect();
let rounds = 2000;
let start = Instant::now();
let mut acc = 0u32;
for _ in 0..rounds {
for s in &inputs {
acc = acc.wrapping_add(f(black_box(s.as_str())).unwrap());
}
}
let elapsed = start.elapsed();
println!("{name}: {:?} (checksum {acc})", elapsed);
}
fn main() {
bench("ok_or ", parse_with_ok_or);
bench("ok_or_else", parse_with_ok_or_else);
}
在 release 模式下(cargo run --release),ok_or 版本比 ok_or_else 版本慢一个明显量级,因为 200 万次调用中每一次都执行了 s.to_owned(),分配随即被丢弃。用 perf stat 或 DHAT 观察,能看到 ok_or 版本的分配次数与调用次数同阶,而 ok_or_else 版本只在构造 inputs 时有分配。
反过来,把错误类型换成零成本变体,差别就消失了:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SimpleError {
NotHex,
}
fn parse_a(s: &str) -> Result<u32, SimpleError> {
u32::from_str_radix(s, 16).ok().ok_or(SimpleError::NotHex)
}
fn parse_b(s: &str) -> Result<u32, SimpleError> {
u32::from_str_radix(s, 16)
.ok()
.ok_or_else(|| SimpleError::NotHex)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_behave_identically() {
assert_eq!(parse_a("ff"), Ok(255));
assert_eq!(parse_b("ff"), Ok(255));
assert_eq!(parse_a("zz"), Err(SimpleError::NotHex));
assert_eq!(parse_b("zz"), Err(SimpleError::NotHex));
}
}
这两个函数编译出的机器码通常完全一致:SimpleError::NotHex 是零大小类型(ZST),ok_or 的参数没有运行时表示,闭包也被内联掉。所以对 ZST 错误,选哪个纯粹是可读性问题,没有性能问题。这一点值得强调,因为网上不少说法把 ok_or_else 说成「性能更好」的通用建议,这是不准确的。
顺带看清 ? 与 map_err 的关系
实际项目里更常见的是 ? 配合 map_err,它的求值语义与 ok_or 完全不同:
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Empty,
BadNumber(ParseIntError),
}
fn read_port(s: Option<&str>) -> Result<u16, AppError> {
let raw = s.ok_or(AppError::Empty)?;
raw.trim().parse::<u16>().map_err(AppError::BadNumber)
}
map_err 接受的是函数值,闭包只在 Err 分支执行,所以它天然具备 ok_or_else 的惰性;而 ok_or 的参数是急切求值的。? 运算符本身不解糖成 ok_or,但社区习惯把「Option 转 Result 后立刻 ?」写成 .ok_or(MyErr)?,这时如果 MyErr 带堆数据,就踩进了同一个坑。
四条落地判断原则
原则一:错误变体无字段,随手 ok_or
单元变体、bool、NonZero*、Copy 的小数组都属于这一类。ok_or(Error::NotFound) 比 ok_or_else(|| Error::NotFound) 少一层括号噪声,读起来更接近陈述句。此时若有人在 code review 里要求你改成 ok_or_else,可以请对方看上面那段等价性测试。
原则二:错误构造涉及分配或格式化,默认 ok_or_else
只要错误类型的构造路径里出现 to_owned、to_string、format!、vec!、Box::new、anyhow!,就应该是 ok_or_else。这也包括 thiserror 生成的 #[error("... {0}")] 变体——显示字符串是惰性格式化的,但字段里的 String 是急切构造的:
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("missing key `{key}`, expected in section `{section}`")]
MissingKey { key: String, section: String },
}
pub fn get_key<'a>(
map: &'a std::collections::HashMap<String, String>,
key: &str,
section: &str,
) -> Result<&'a str, ConfigError> {
// 每读一个配置项都可能分配两个 String,配置项多时很浪费
map.get(key).map(String::as_str).ok_or(ConfigError::MissingKey {
key: key.to_owned(),
section: section.to_owned(),
})
}
pub fn get_key_lazy<'a>(
map: &'a std::collections::HashMap<String, String>,
key: &str,
section: &str,
) -> Result<&'a str, ConfigError> {
map.get(key).map(String::as_str).ok_or_else(|| ConfigError::MissingKey {
key: key.to_owned(),
section: section.to_owned(),
})
}
两个版本的错误文案完全一致,第二个版本在成功路径上零分配。
原则三:构造逻辑带副作用或耗时,必须 ok_or_else
这是最容易被忽视的一类。错误构造里如果出现读时钟、取 PID、查询环境变量、做 DNS 反查、抓取调用栈,那么用 ok_or 不但浪费,还可能改变程序行为。经典例子是带时间戳的错误:
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug)]
struct TimedError {
msg: &'static str,
at_millis: u128,
}
fn now_millis() -> u128 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis()
}
// 错误:即使成功也调用 now_millis(),热路径上每次一次系统调用
fn bad(v: Option<u8>) -> Result<u8, TimedError> {
v.ok_or(TimedError { msg: "missing", at_millis: now_millis() })
}
// 正确:只有失败时才取时间
fn good(v: Option<u8>) -> Result<u8, TimedError> {
v.ok_or_else(|| TimedError { msg: "missing", at_millis: now_millis() })
}
在 Linux 上 SystemTime::now() 走 vDSO,单次约 20~30ns,看起来不贵;但每帧调用上万次时就是可观的固定成本,而且在某些容器化 / 虚拟化环境里 vDSO 不可用会退化为 clock_gettime 系统调用,代价跳到百纳秒级。「错误构造不做无谓工作」是语义要求,不只是性能优化。
原则四:ok_or_else 的闭包别捕获大对象
ok_or_else 的闭包按需捕获环境,FnOnce 允许移动。如果为了让错误带上上下文而把一个大 Vec 移进闭包,那么即使走 Some 分支,这个捕获也已经发生(移动本身零成本,但会延长生命周期、影响借用检查),而且容易和后续代码冲突:
fn take(buf: Vec<u8>) -> Result<u8, String> {
let head = buf.first().copied();
// 闭包捕获了 buf 的所有权,之后无法再用 buf
head.ok_or_else(|| format!("empty buffer of len {}", buf.len()))
}
这段代码能编译,但 buf 被移进闭包后就不能在函数后半段使用。如果失败时只需要长度,先取出 len 再进闭包更灵活;如果错误确实需要整个 buf,这说明错误类型设计得偏重,可以把 Vec 换成索引或长度,减少失败路径的拷贝。
什么时候这个选择真的不重要
把优化精力放对地方,需要知道边界条件。以下场景里 ok_or 与 ok_or_else 的差别可以忽略:
None是主要路径。比如用Option表示「未找到」,None占多数时ok_or的参数大多被真正用上,浪费的比例低。- 调用频率极低。启动时加载配置、CLI 参数解析、一次性初始化,一次
String分配在整程序生命周期里不到微秒,优化没有意义。 - 错误已经是
Copy或 ZST。前面已验证编译产物一致。 - 上游已在分配。如果
Some分支本身就在构造String、解析 JSON、读文件,额外一次错误分配会被淹没。判断基准是相对开销,不是绝对开销。
还有一个常被问到的问题:编译器会不会把 ok_or 里未被使用的构造优化掉?对于 String::from("literal") 这种纯计算,LLVM 理论上能做死代码消除,但因为可能 panic、可能分配,实际上基本不会跨分支删除。对含 format! 或 to_owned 的情况,更不要指望优化。这也是为什么不应把「编译器会处理」当作理由。
工具辅助:让 lint 替你守住
团队统一原则比个人偏好更可靠。可以用 clippy 的 or_fun_call 系列 lint 来捕获明显的急切构造:
cargo clippy -- -W clippy::or_fun_call -W clippy::unnecessary_lazy_evaluations
clippy::or_fun_call 会提示「ok_or 的参数包含函数调用,考虑 ok_or_else」,正是本文讨论的场景。clippy::unnecessary_lazy_evaluations 则反过来提示「这个 ok_or_else 闭包只是返回常量,可以直接用 ok_or」,两条 lint 合起来就是一对互补的守门员。在 Cargo.toml 或 clippy.toml 里固定下来:
[lints.clippy]
or_fun_call = "warn"
unnecessary_lazy_evaluations = "warn"
如果项目里 or_fun_call 报警太多一时改不完,可以先用 #[allow(clippy::or_fun_call)] 标注在模块级,逐个函数清理,而不是关掉整条 lint。
常见问题(FAQ)
ok_or_else 的闭包会不会带来运行时开销?
不会。闭包是零大小或小尺寸的值,FnOnce 调用经过内联后通常与直接写 ok_or 的机器码相同。唯一可观察的成本是它可能延长被捕获变量的生命周期,影响借用检查器的判断,这是编译期的可读性问题,不是运行时性能问题。
用 ok_or 传一个已经很便宜的值,会不会被 clippy 建议改掉?
不会。or_fun_call 只对包含函数调用、format!、to_owned 之类可能产生实际工作的实参报警;传 Error::Empty 或 SimpleError::NotHex 这样的路径表达式不会触发。如果 unnecessary_lazy_evaluations 反过来提示你的 ok_or_else 多余,直接按建议改回 ok_or 即可。
ok_or(MyError::new()) 算哪一档?
取决于 new() 是否被内联、是否分配。如果 new() 只是给几个字段赋常量,且函数体可见、被内联,那它可能退化为栈上写入,成本很低;但 or_fun_call 仍会报,因为它无法保证。稳妥做法是改成 ok_or_else(MyError::new)——把函数项当闭包传,写法甚至更短。
异步代码里有区别吗?
有,但方向一致。在 async fn 里用 ok_or 构造的错误值是 Future 状态机的一部分,如果错误类型体积大(比如含多个 String 的枚举),会撑大 future 的 size_of,进而影响栈占用与 Box::pin 的堆大小。用 ok_or_else 不改变 E 的类型,因此不会缩小状态机;要缩小状态机得把错误装箱(Box<dyn Error>)或改用小体积错误码。这两件事要分开看:ok_or_else 解决的是「不该构造时别构造」,不是「错误太大」。
能用 Option::ok_or_default 之类的简化吗?
标准库没有 ok_or_default。若 E: Default,可以写 .ok_or_else(E::default),这比 .ok_or(E::default()) 更符合本文原则,且写起来一样短。
错误构造里有 panic! 的可能性怎么办?
例如 key.to_owned() 在极端内存压力下 abort。用 ok_or 意味着成功路径上也在做可能失败的操作,一旦 panic 发生在本该成功的调用里,堆栈信息会极具误导性。ok_or_else 把这类风险限制在失败分支,调试时更容易定位。
小结
把选择压缩成一句话:错误构造是否做了「只要成功执行就白做」的工作,是唯一判断依据。无字段变体、Copy 小值用 ok_or,代码更干净且没有代价;涉及堆分配、格式化、系统调用或任何副作用的构造一律用 ok_or_else,即使调用频率看起来不高,这也是语义正确性问题;剩下的场景(低频调用、None 为主、上游本来就在分配)不必纠结,把时间留给真正的热点。把 clippy::or_fun_call 和 clippy::unnecessary_lazy_evaluations 同时打开,可以让这条原则在团队内自动执行,而不是依赖 code review 时的记忆。