关于“Parse, don't validate”的Rust思考
摘要
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])给我。
相似文章
解析,而非验证——在并不鼓励你这样做的语言中
一篇探讨在TypeScript中应用“解析,而非验证”原则的博客文章,展示了如何使用品牌类型(branded types)在解析后保留类型信息,尽管TypeScript的结构类型系统使得这种做法不如在Elm或Haskell等语言中那样自然。
Rust 中参数解析的新旧结合做法
Julio Merino 介绍了 Rust 中参数解析的一种新旧结合做法,将 getopts crate 扩展为一个小型框架,优先考虑 Unix 风格工具集成,而非语言生态系统惯例。
关于整数的思考 (2023)
一篇博客文章,讨论了各种编程语言中整数类型的设计,认为 Rust 强制要求显式指定大小和符号的做法优于那些有默认 `int` 类型的语言。
内存安全绝对主义者
本文批评了编程语言辩论中的内存安全绝对主义,认为像 Fil-C 这样的新方法也有权衡,而将 Rust 视为不安全忽略了实际好处。
Rust:不要 panic
一段视频转录文本,讨论如何通过使用组合器和 Result 类型而不是 unwrap 来避免 Rust 中的 panic,解释了三种典型的崩溃原因,并强调编写健壮的代码。