关于“Parse, don't validate”的Rust思考

Lobsters Hottest 工具

摘要

Eli Bendersky 将‘Parse, don't validate’模式应用于 Rust,展示如何使用类型系统来强制不变量并提高代码清晰度,使用标准库和像 nonempty 这样的 crates 中的示例。

<p><a href="https://lobste.rs/s/uvmajz/rusty_thoughts_on_parse_don_t_validate">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/26 21:29

# 关于“解析,而非验证”的Rust思考 来源:https://eli.thegreenplace.net/2026/rusty-thoughts-on-parse-dont-validate 与许多程序员一样,我被 Alexis King 的《解析,而非验证》(https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/)一文深深吸引,因为它为一个看似熟悉且重要的惯用模式赋予了名称——这是我过去观察并使用过,却未曾明确命名的模式。本文旨在回顾“解析,而非验证”这一模式在 Rust 编程语言中的应用(原文使用 Haskell)。我特别感兴趣的是在 Rust 标准库和其他知名项目中寻找该模式的教学示例。在不重复原文内容(请先阅读原文)的前提下,以下是我总结的核心要点。 以经典的 `Vec` 为例;其 `first` 方法返回 `Option<&T>`。为什么?因为无法保证向量中一定包含任何元素,那么当对一个空向量调用 `first` 时该怎么办?在这种情况下返回 `Option` 是 Rust 中的惯用做法[[1]](https://eli.thegreenplace.net/2026/rusty-thoughts-on-parse-dont-validate#footnote-1),它提供了便捷的语法糖,用于处理返回 `Option` 的函数结果并决定下一步操作。 那么问题是什么?假设我们有一个函数用于从环境变量读取某些配置路径,同时强制要求该列表不能为空: ```rust use anyhow::{Result, ensure}; fn get_configuration_directories() -> Result<Vec<PathBuf>> { let value = env::var("CONFIG_DIRS").context("could not read CONFIG_DIRS")?; let directories: Vec<PathBuf> = value .split(',') .map(str::trim) .map(PathBuf::from) .collect(); ensure!(!directories.is_empty(), "empty CONFIG_DIRS"); Ok(directories) } ``` 目前一切正常。现在来看这个函数的一个典型用法: ```rust fn main() -> Result<()> { let config_dirs = get_configuration_directories()?; match config_dirs.first() { Some(cache_dir) => initialize_cache(cache_dir), None => unreachable!("already checked that CONFIG_DIRS is non-empty"), } Ok(()) } ``` 一旦 `get_configuration_directories` 返回成功结果,我们就保证了向量非空。然而,如果我们想获取此向量的第一个元素,就必须使用返回 `Option<&T>` 的 `first` 方法。因此,我们被迫——再次——处理可能为空的情况(当 `Option` 为 `None` 时)。正如原文所述,这会导致代码清晰度问题、潜在的性能影响,以及如果 `get_configuration_directories` 中的不变性被修改,则可能成为定时炸弹。 核心问题在于 `Vec` 从根本上说是一个可能为空的类型;我们可以在所有相关代码上附加“这个保证非空,拉钩上吊!”的注释,但这并非由任何东西进行形式化检查。 ## 用于“非空”向量的类型 解决方案是利用类型系统来强制执行新建立的不变性。我们可以使用一个单独的类型表示“不可为空的向量”;事实上,此类类型已存在于多个 Rust crate 中——例如 `nonempty` (https://docs.rs/nonempty/latest/nonempty/): ```rust pub struct NonEmpty<T> { pub head: T, pub tail: Vec<T>, } ``` 此类型没有允许“无元素”的构造函数;其 `new` 接受一个元素,其 `first` 方法直接返回 `&T` 而非 `Option`: ```rust pub const fn new(e: T) -> Self { Self::singleton(e) } pub const fn singleton(head: T) -> Self { NonEmpty { head, tail: Vec::new() } } pub const fn first(&self) -> &T { &self.head } ``` 该 crate 的其余部分致力于让 `NonEmpty` 的行为尽可能接近普通 `Vec`,通过实现许多有用的 trait,以及提供诸如以下的转换方法: ```rust pub fn from_vec(mut vec: Vec<T>) -> Option<NonEmpty<T>> { if vec.is_empty() { None } else { let head = vec.remove(0); Some(NonEmpty { head, tail: vec }) } } ``` 让我们看看,如果 `get_configuration_directories` 返回 `NonEmpty` 而非普通 `Vec`,它会是什么样子: ```rust fn get_configuration_directories() -> Result<NonEmpty<PathBuf>> { let value = env::var("CONFIG_DIRS").context("could not read CONFIG_DIRS")?; let directories = value .split(',') .map(str::trim) .map(PathBuf::from) .collect(); let Some(directories) = NonEmpty::from_vec(directories) else { bail!("CONFIG_DIRS cannot be empty"); }; Ok(directories) } ``` 注意这里使用了 `NonEmpty::from_vec`——这正是建立不变性的地方。现在成功的结果类型是 `NonEmpty`,而不仅仅是 `Vec`。客户端代码如下所示: ```rust fn main() -> Result<()> { let config_dirs = get_configuration_directories()?; initialize_cache(config_dirs.first())?; Ok(()) } ``` 无需再次检查返回值是否为空;这由类型系统强制执行!这正是原文中“解析”与“验证”术语的由来。当 `get_configuration_directories` 返回 `Vec` 时,它只是进行了验证。但当它返回 `NonEmpty` 时——向量被转换成了另一个携带额外含义的实体。 如果我们以最通用的含义理解“解析”概念——“将数据从一种格式转换为另一种”,这就很契合了。举一个不那么人为的例子,Rust 重写的核心 POSIX 工具集 (https://github.com/rustcoreutils/posixutils-rs) 在多个地方使用了 `NonEmpty`[[2]](https://eli.thegreenplace.net/2026/rusty-thoughts-on-parse-dont-validate#footnote-2)。例如,在构建 shell 管道时: ```rust pub struct Pipeline { pub commands: NonEmpty<Command>, pub negate_status: bool, } ``` 命令解析器的代码: ```rust fn parse_pipeline(&mut self, alias_table: &AliasTable) -> ParseResult<Option<Pipeline>> { // pipeline = "!" command ("|" linebreak command)* let negate_status = self.match_alternatives(&[CommandToken::Bang])?.is_some(); let mut commands = if let Some(command) = self.parse_command(alias_table)? { NonEmpty::new(command) } else { return Ok(None); }; // ... } ``` 只有在解析的 AST 中存在一些命令时,才会返回有效的 `Pipeline`。否则,它只返回 `None`。一旦完成,客户端代码就可以使用 `commands.first()`,而不必担心它可能返回 `None`。 ## 逐步解析与类型细化 一个更有趣的例子可以在 `rust-analyzer` (https://github.com/rust-lang/rust-analyzer) 的源代码中找到。该项目有一个表示绝对文件系统路径的类型: ```rust pub struct AbsPathBuf(Utf8PathBuf); ``` 它不是携带常规路径,而是在初始解析和验证完成后,将绝对性记录在类型中: ```rust impl TryFrom<Utf8PathBuf> for AbsPathBuf { type Error = Utf8PathBuf; fn try_from(path_buf: Utf8PathBuf) -> Result<Self, Self::Error> { if !path_buf.is_absolute() { return Err(path_buf); } Ok(AbsPathBuf(path_buf)) } } ``` 后续代码无需验证路径是否绝对。类型系统强制了这一点。另请注意,`AbsPathBuf` 包装了 `Utf8PathBuf`,而非 `PathBuf`。`Utf8PathBuf` 本身是 `camino` crate (https://docs.rs/camino/latest/camino/) 中的一个自定义、“已解析”的类型细化。Rust 标准库中的常规路径不能保证是有效的 UTF-8,因此不能轻易转换为 `String`(在 Rust 中必须是有效的 UTF-8);`camino::Utf8PathBuf` 在构造时即确立了有效性,之后只需通过以下方式即可转换为字符串: ```rust fn as_str(&self) -> &str { ... } ``` 因此,我们在这里有一个逐步解析和类型细化的例子: ``` std::path::PathBuf |(证明 UTF-8 有效性) V camino::Utf8PathBuf |(证明绝对性) V rust-analyzer 的 paths::AbsPathBuf ``` ## 非零整数 Rust 有一个泛型类型 `NonZero` (https://doc.rust-lang.org/std/num/struct.NonZero.html),用于描述已知非零的无符号数值量。例如,`thread::available_parallelism` 定义为: ```rust pub fn available_parallelism() -> Result<NonZeroUsize> ``` 如果调用成功,它返回一个 `NonZero`,它就像一个普通的 `usize`,但附带了不为零的限制。客户端代码不必反复检查并行度是否为 0——这已体现在类型系统中。Rust 将分母为 `NonZero` 的除法运算符 (https://doc.rust-lang.org/std/primitive.u32.html#impl-Div%3CNonZero%3Cu32%3E%3E-for-u32) 定义为“不会 panic”的操作。 `NonZero` 还有一个额外优势:对于该类型,零是一个无效值,因此 Rust 可以使用零位模式来表示 `None`。因此,`Option<NonZeroUsize>` 保证与 `NonZeroUsize` 本身(以及 `usize`)具有相同的大小 (https://doc.rust-lang.org/std/option/index.html#representation) 和对齐方式。这避免了 `Option` 通常所需的额外存储空间。 ## 解析 JSON “解析,而非验证”惯用法的一个常见示例出现在从 JSON 字符串反序列化数据时。Rust 的 `serde` crate 使我们能够进行解析,将验证过的决定编码到类型系统中,例如: ```rust #[derive(Debug, Deserialize)] struct Config { name: String, workers: NonZeroUsize, mode: Mode, } #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] enum Mode { Fast, Safe, } ``` 然后: ```rust let input = r#" { "name": "compiler", "workers": 4, "mode": "fast" } "#; let config: Config = serde_json::from_str(input)?; ``` 幕后发生了很多事情: - 所有字段的类型被强制执行(例如“name”不能是数组)。 - `mode` 被验证为 `Mode` 枚举值之一。 - `workers` 被验证为非零整数,因为字段类型是 `NonZeroUsize`。 如今我们对此类代码习以为常,但它仍然是本文所讨论模式的绝佳示例。一旦解析器将 `mode` 转换为 `Mode` 枚举,就无需进一步验证了。在 Python 和 JavaScript 等动态语言中,这个过程通常更加手动化。Python 的 `json.loads` 给我们一个字典,验证其内容是用户的责任。像 Pydantic 这样的库允许更接近 Rust 的方法,但它们并未被普遍使用。 --- [脚注] [[1]](https://eli.thegreenplace.net/2026/rusty-thoughts-on-parse-dont-validate#footnote-reference-1)其他语言——如 Go 或 Python——有运行时检查,当对空列表或切片访问 `lst[0]` 时会引发某种异常或 panic。 [[2]](https://eli.thegreenplace.net/2026/rusty-thoughts-on-parse-dont-validate#footnote-reference-2)该项目实现了自己的 `NonEmpty`,不依赖于 `nonempty` crate,但本文的所有观点均适用。 --- 如需评论,请[发送邮件](mailto:[email protected])给我。

相似文章

解析,而非验证——在并不鼓励你这样做的语言中

Hacker News Top

一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。

Rust 中参数解析的新旧结合做法

Lobsters Hottest

Julio Merino 介绍了 Rust 中参数解析的一种新旧结合做法,将 getopts crate 扩展为一个小型框架,优先考虑 Unix 风格工具集成,而非语言生态系统惯例。

关于整数的思考 (2023)

Lobsters Hottest

一篇博客文章,讨论了各种编程语言中整数类型的设计,认为 Rust 强制要求显式指定大小和符号的做法优于那些有默认 `int` 类型的语言。

内存安全绝对主义者

Lobsters Hottest

本文批评了编程语言辩论中的内存安全绝对主义,认为像 Fil-C 这样的新方法也有权衡,而将 Rust 视为不安全忽略了实际好处。

Rust:不要 panic

Lobsters Hottest

一段视频转录文本,讨论如何通过使用组合器和 Result 类型而不是 unwrap 来避免 Rust 中的 panic,解释了三种典型的崩溃原因,并强调编写健壮的代码。