← 返回模块
3.5.1.4beta 可读 · 未来付费校验通过内容版本 2026-05-27

结构体、枚举、Trait 与错误处理

3.5.1 · Rust 基础 · 编程

国内某私募中频组的风控同事接手一段用 Rust 写的报单解析器: 从某证券公司的 CSV 报价流读 510300.SH 沪深300 ETF 期权报单, 把每一行变成一个强类型的 Order 值。核心函数只有六行, 用三个 ?parse::<u64>parse::<f64>.ok_or(...) 串成一条链。当某一行非法——symbol 为空、价格为负、数量为零——解析器返回 Err(OrderParseError::EmptySymbol) 而不是抛异常、不是 crash、不是默默 produce 一个字段乱填的 Order。对照 3.4.1 的 C++ 版本 (二十八行、两个 try 块、一个 nullptr 判断、一个有符号 / 无符号没对齐的潜在 bug)。复合类型加穷尽性模式匹配加 Result<T, E>? 操作符, 这就是「六行的解析器是完整解析器」背后的语言原因。本课讲这四样。

带命名字段的 struct、方法与构造

struct 有三种形态。带命名字段的是日常驱动形态。元组结构体 struct Tick(u64, f64); 把基本类型包成 newtype。单元结构体 struct Marker; 是配 trait 用的标记类型。日常驱动形态, 见下面这段 Fenced Rust 块:

#[derive(Debug, Clone, PartialEq)]
struct Order {
    id: u64,
    symbol: String,
    price: f64,
    qty: u32,
}

impl Order {
    fn new(id: u64, symbol: String, price: f64, qty: u32) -> Self {
        Self { id, symbol, price, qty }
    }

    fn notional(&self) -> f64 {
        self.price * self.qty as f64
    }
}

用结构体字面量构造 (Self { id, symbol, price, qty }), 字段初始化简写允许在局部变量与字段同名时只写一次名。点访问字段: o.price。「部分更新构造」简写 ..base 这里提一次, 生产代码里会见到。

方法住在 impl Order { ... } 块里。三种 receiver 形态: &self (共享借用——只读方法的日常形态)、&mut self (可变借用——修改型方法)、self (消耗实例——用于 builder 完结操作或 into_string 一类转换)。impl 块里不带 self 的函数叫关联函数 (associated function); 惯例上用 fn new(...) -> Self { ... } 作为构造器, 调用为 Order::new(...)。Rust 没有 class、没有作为语言特性的「构造函数」、没有 this 关键字——只有 struct、普通函数、把它们打包到一起的 impl 块。从 C++ 过来这个心智切换不大但贯穿日常。

enum 即标签联合 (tagged union)

Rust 的 enum ​不是​ C-style 的「带名字的整数常量集合」。每个变体可以带自己的数据载荷——可以是带命名字段的 struct-like、可以是一组元组、也可以什么都不带——整个类型就是所有变体上的一个标签联合 (tagged union)。示例:

enum OrderType {
    Market,
    Limit(f64),
    Stop(f64),
    StopLimit { stop: f64, limit: f64 },
}

fn describe(t: &OrderType) -> String {
    match t {
        OrderType::Market => String::from("market"),
        OrderType::Limit(p) => format!("limit @ {}", p),
        OrderType::Stop(p) => format!("stop @ {}", p),
        OrderType::StopLimit { stop, limit } => format!("stop-limit ({}, {})", stop, limit),
    }
}

Market 不带数据; LimitStop 各带一个 f64; StopLimit 带一个 struct-like 的命名字段载荷。match 解构是穷尽性的多分支选择: 漏写一个变体编译器直接报错, 所以日后加 Iceberg(...) 会让每一个对 OrderTypematch 失配, 强迫开发者在每个调用点做有意识的决定。生产代码里通常显式列出每个变体, 只有在「其余一切」真是预期意图时才用 _。值得说明: 国内 ETF 期权目前不直接支持 Stop / StopLimit 委托类型 (这是 1.4 衍生品 subject 的话题); 本例的 enum 是 pedagogical, 演示「带数据的变体」概念, 不暗示国内市场实际可用。

Option<T>Result<T, E>

标准库里两个 enum 承担了 Rust 的「不存在」与「失败」惯用法。Option<T> = Some(T) | None 表示「这个值可能不存在」——安全 Rust 里没有空指针。Result<T, E> = Ok(T) | Err(E) 表示「这次计算可能失败」——安全 Rust 里没有异常 throw。

fn first_word_len(s: &str) -> Option<usize> {
    s.split_whitespace().next().map(|w| w.len())
}

fn main() {
    let s = "hello world";
    match first_word_len(s) {
        Some(n) => println!("len = {}", n),
        None => println!("empty"),
    }

    if let Some(n) = first_word_len(s) {
        println!("shorthand: len = {}", n);
    }
}

惯用模式: match opt { Some(x) => use(x), None => default() }Option 上的便利方法: .unwrap() 取内部值, 是 None 就 panic (仅在你能证明 None 不可能出现时使用); .expect("msg").unwrap() 但带自定义 panic 消息 (更建议——出问题时这条消息能省一小时调试); .unwrap_or(default).map(|x| f(x)).and_then(|x| g(x)) 覆盖常见变换模式。if let Some(n) = ... 是「只匹配一个变体」的简写, lesson 2 已经见过; 对任何 enum 变体都适用, 不限 Option

? 操作符

Result<T, E> 是「可能失败的计算」的标准类型, 而 ? 操作符是让 Rust 错误处理感觉「轻」而不是「啰嗦」的关键:

fn parse_price(s: &str) -> Result<f64, std::num::ParseFloatError> {
    s.parse::<f64>()
}

fn parse_two_prices(line: &str) -> Result<(f64, f64), std::num::ParseFloatError> {
    let mut parts = line.split(',');
    let a: f64 = parts.next().unwrap_or("").parse()?;
    let b: f64 = parts.next().unwrap_or("").parse()?;
    Ok((a, b))
}

在返回类型为 Result<U, E> 的函数体内, 表达式 let x = foo()?; 求值 foo(); 若为 Ok(v), 绑定 x = v; 若为 Err(e), 从当前函数直接 return Err(e)。两次 parse()? 串起来, 任何一次失败都短路。与 C++ 异常的工程差别: 可失败性写在返回类型里, 调用方除非显式打 .unwrap() / .expect(...) / match, 否则无法忽略——也就是说, 没有「这个函数没意识到自己会失败」的隐式传播。读 Rust 函数签名时, 「能不能失败」一眼就能看清。

库代码要自定义错误 enum 时, 生态里习惯用 thiserror 派生; 应用 / 二进制代码要一个动态错误类型时, 习惯用 anyhow。具体怎么接 lesson 5 与 3.5.4 讲; L4 用标准库错误类型 (std::num::ParseFloatErrorstd::io::Error) 加一个手写的 OrderError enum 已经够用。

trait 与泛型

trait 是「一组方法签名, 类型可以选择实现」的命名集合——Rust 的接口机制。示例:

trait Pricer {
    fn price(&self, t: f64) -> f64;
}

struct EuropeanCall { s: f64, k: f64, r: f64, sigma: f64 }

impl Pricer for EuropeanCall {
    fn price(&self, t: f64) -> f64 {
        // black_scholes_call(self.s, self.k, self.r, self.sigma, t)
        0.0  // placeholder; learner fills in using L2's helper
    }
}

fn report<T: Pricer>(x: T, t: f64) {
    println!("price = {:.4}", x.price(t));
}

然后在 main 里构造一个 510300 锚定的 EuropeanCall { s: 4.20, k: 4.30, r: 0.025, sigma: 0.22 } (与 L2 同一组数, 价格可横向对照) 并调用 report(call, 0.25);report 上的 <T: Pricer> bound 意为「这个函数接受任何实现了 Pricer 的类型 T」。这是 ​静态分发 (static dispatch)​​: 编译器为每个调用点的具体 T 单独生成一份特化的 report——没有 vtable、没有间接调用、可以完整 inline、可以完整优化。多 bound 用 where 子句: fn f<T>(x: T) where T: Pricer + Clone { ... }。trait object (dyn Trait) 走 vtable 做动态分发, 留到 3.5.2; L4 全程静态分发。

derive 当作省时器

#[derive(...)] 属性从 struct / enum 的字段结构自动生成 trait 实现。日常会反复用到的四个内置 derive:

Debugprintln!{:?} 格式可用——你写的每个类型都该 derive Debug, 这样日志里能直接看到值。Clone 让你显式调 x.clone() 复制——在所有权规则挡你路时的「无 move 出逃口」。PartialEq== 可用, 语义带 f64 风格的「可能不可比」(NaN 会传播)。Eq 是一个 marker, 声明 PartialEq 是全序 (不存在 NaN 这类不可比值), 是 HashMap 键的必要条件。本课开头的 Order struct derive 了 Debug, Clone, PartialEq; 想拿它当 HashMap 键就要补 Eq, Hash (并去掉或包装 f64 字段)。

行业背景: 国内 PingCAP TiKV、蚂蚁链的 Rust 代码里, 错误处理近乎统一为「library 端用 thiserror 派生错误 enum, application 端用 anyhow::Result<T>.with_context(...)」。本课暂不引这层堆栈——直接用标准库错误类型——但 forward-pointer 里点名 thiserroranyhow, 你知道下一步往哪走。私募内部的 Rust 行情解析器与撮合外围模块今天几乎都按这个模式写。

通往 Lesson 5

到这里你能: 声明带命名字段和方法的 struct; 声明数据载荷型的 enum; 写 trait 与 impl; 写带 trait bound 的泛型函数; 在「可能缺失」与「可能失败」的所有场景里反射性地用 Option<T>Result<T, E>; 用 ? 把一连串可失败调用的错误向上传播。Lesson 5 把同样的拼图放到 项目 尺度: 拆到 src/lib.rs + src/pricing.rs + src/main.rs, Cargo.toml 声明 approx 依赖, 单元测试与代码同文件以 #[cfg(test)] mod tests 块共存, cargo fmt / cargo clippy / cargo doc 是日常质量工具链。

练习

Exercise

扩展本课的 Order struct, 加一个关联函数与一个方法, 写配套的 OrderError enum。(a) 加 enum OrderError { EmptySymbol, NegativePrice, ZeroQty }, 派生 DebugPartialEq。(b) 加关联函数 fn try_new(id: u64, symbol: String, price: f64, qty: u32) -> Result<Self, OrderError>, 当 symbol.is_empty() 时返回 Err(OrderError::EmptySymbol)price < 0.0 时返回 Err(OrderError::NegativePrice)qty == 0 时返回 Err(OrderError::ZeroQty), 否则 Ok(Self { ... })。(c) 写 fn parse_orders(lines: &[&str]) -> Result<Vec<Order>, OrderError>, 对每行用 , 切分、解析四个字段、用 try_new 构造 Order、用 ? 传播第一个错误。(d) 在 main 里调用 parse_orders, 传入一条合法行加一条 symbol 为空的行, 用 println!("{:?}", result)Debug 格式结果。(e) 在 try_new 上方加一行注释, 解释为什么这里返回 Result<Self, OrderError> 而不是走 assert! 的 panic 路径。

提示
关于 (c): 把每行切分后, for line in lines { let order = try_new(...)?; out.push(order); } 是循环体; try_new(...)? 后的 ? 在第一次出错时短路返回。
提示
关于 (e): 一句即够——返回 Result 把「记日志、重试还是上报」的决定权留给调用方; assert! 会让进程崩并丢失上下文。