Option 的 ok_or 与 ok_or_else 在错误构造开销上的选择原则

把 Option<T> 转成 Result<T, E> 时,ok_or 和 ok_or_else 是编译器提示里出现频率最高的一对方法。多数人凭直觉选:错误值简单就用 ok_or,需要格式化就用 ok_or_else。这个直觉方向没错,但理由常常说不清,于是代码里出现两种极端:一种是无脑全用 ok_or,在热路径上白白构造了一个立刻被

把 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 时的记忆。