关于“解析,而非验证”的锈迹思考
Rusty thoughts on "Parse, don't validate"

原始链接: https://eli.thegreenplace.net/2026/rusty-thoughts-on-parse-dont-validate/

这篇文章探讨了 Rust 中“解析,而非验证”(Parse, don't validate)的惯用法。该方法提倡利用类型系统来强制执行数据不变性,而非依赖重复的运行时检查。 当程序只是在“验证”数据(例如检查一个 `Vec` 是否为空)时,类型系统并不知道这一事实,这迫使开发者必须处理不可能出现的边缘情况,或使用 `unreachable!` 宏。通过将数据“解析”为更具体的类型(如 `NonEmpty` 结构体或 `NonZero`),不变性便被正式固化在类型之中。一旦转换完成,编译器即能保证数据有效,从而消除冗余检查并防止逻辑错误。 作者提供了几个实用的 Rust 示例: * **`NonEmpty`**:确保集合至少包含一个元素。 * **`AbsPathBuf`**:将标准路径细化为绝对路径,防止在文件系统操作中进行重复验证。 * **`NonZero`**:利用类型系统防止除以零,同时为 `Option` 启用内存优化。 * **`serde`**:在反序列化过程中自动强制执行复杂的架构约束。 总之,这种方法将验证的负担从易错的手动运行时逻辑转移到了编译器,从而产出更清晰、更安全且性能更高的代码。

Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 关于“解析,而非验证”的 Rust 思考 ( thegreenplace.net ) 17 点 由 ingve 发布于 5 小时前 | 隐藏 | 过往 | 收藏 | 2 条评论 帮助 tjadfsaj 0 分钟前 | 下一条 [–] 非空类型包装器清楚地提醒我们,我们错过了精细类型(refinement types)。 回复 jph 26 分钟前 | 上一条 [–] 关于 Rust 类型优势的好文章。如果你喜欢这篇文章,你可能会好奇如何构建自己的解析功能。我喜欢 Rust 的 Winnow 和 Nom 库,以及 From 和 Into trait。 回复 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系方式 搜索:
相关文章

原文
Tags Rust

Like many programmers, I find Alexis King's Parse, don't validate article fascinating, because it gives a name to an idiom that seems familiar and important - one I've observed and used in the past without naming it explicitly. This post is a review of the "Parse, don't validate" pattern applied to the Rust programming language (the original post uses Haskell). I was particularly interested in finding educational examples of this pattern in the Rust standard library and other well-known projects.

Without repeating the original article (please read it first!), here's the gist of it.

Consider the venerable Vec; its first method returns Option<&T>. Why? Because a vector is not guaranteed to have any elements in it, so what to do if first is invoked on an empty one? Returning an Option in this case is idiomatic in Rust , with convenient syntax sugar for accepting the result of functions that return Option and deciding what to do next.

So what's the issue?

Imagine we have a function to read some configuration paths from an env var, while enforcing the invariant that the list can't be empty:

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)
}

So far, so good. Now let's take a typical usage of this function:

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(())
}

Once get_configuration_directories returns a successful result, we are guaranteed that the vector isn't empty. And yet, if we want to get the first element of this vector, we have to use the first method that returns Option<&T>. We are therefore forced - again - to handle a potentially empty case (where the option is None).

As the original article states, this has a number of problems with code clarity, potential performance implications and a ticking time bomb if the invariant is ever changed in get_configuration_directories.

The core issue is that Vec is fundamentally a type that can be empty; we can carry along a "This one can't be empty, pinky promise!" comment on all the relevant code, but it's not formally checked by anything.

A type for "non-empty" vector

The solution is leveraging the type system to enforce a newly established invariant. We can use a separate type for "a vector that cannot be empty"; in fact, such types already exist in several Rust crates - for example nonempty:

pub struct NonEmpty<T> {
    pub head: T,
    pub tail: Vec<T>,
}

This type has no constructor that permits "no elements"; its new takes one element, and its first method returns &T without an Option:

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
}

The rest of the crate deals with making NonEmpty behave as close as possible to a normal Vec, by implementing many useful traits, as well as conversions like:

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 })
    }
}

Let's see how our get_configuration_directories function would look if it returned a NonEmpty instead of a plain Vec:

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)
}

Note the use of NonEmpty::from_vec here - this is where the invariant is established. Now a successful result is NonEmpty, not just Vec. The client code looks like:

fn main() -> Result<()> {
    let config_dirs = get_configuration_directories()?;

    initialize_cache(config_dirs.first())?;
    Ok(())
}

There's no need to check if the returned value is empty again; this is enforced by the type system!

This is where the parse vs. validate terminology of the original article comes from. When get_configuration_directories returned a Vec, it simply validated it. But when it returns a NonEmpty - the vector is transformed into another entity which carries additional meaning. If we treat the concept of parsing in the most generic sense - "transforming data from one format to another", this fits.

To mention a less artificial example, the Rust rewrite of core POSIX utilities uses NonEmpty in several places . For example, when constructing a shell pipeline:

pub struct Pipeline {
    pub commands: NonEmpty<Command>,
    pub negate_status: bool,
}

The command parser's code:

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);
    };

    // ...

A valid Pipeline is only returned if there are some commands in the parsed AST. Otherwise, it just returns None. Once this is done, the client code can use commands.first() without having to worry about the possibility of it returning None.

Gradual parsing and type refinement

A somewhat more interesting example can be found in the source code of rust-analyzer. This project has a type that represents an absolute filesystem path:

pub struct AbsPathBuf(Utf8PathBuf);

Instead of carrying around a regular path, the absoluteness is recorded in the type once the initial parsing and validation is done:

impl TryFrom<Utf8PathBuf> for AbsPathBuf {
    type Error = Utf8PathBuf;
    fn try_from(path_buf: Utf8PathBuf) -> Result<AbsPathBuf, Utf8PathBuf> {
        if !path_buf.is_absolute() {
            return Err(path_buf);
        }
        Ok(AbsPathBuf(path_buf))
    }
}

Subsequent code doesn't have to validate the the path is absolute. The type enforces it.

Note also that AbsPathBuf wraps Utf8PathBuf, not PathBuf. Utf8PathBuf is itself a custom, "parsed" type refinement from the camino crate. Regular paths in the Rust standard library aren't guaranteed to be valid UTF-8, so they cannot be easily converted to a String (which has to be valid UTF-8 in Rust); camino::Utf8PathBuf establishes validity on construction, and can then be converted to a string with just:

fn as_str(&self) -> &str {
  ...
}

So we have an example of gradual parsing and type refinement here:

std::path::PathBuf

      |
      |   prove UTF-8
      |
      V

camino::Utf8PathBuf

      |
      |   prove absolute
      |
      V

rust-analyzer's paths::AbsPathBuf

Non-zero integers

Rust has a generic type called NonZero, to describe unsigned numeric quantities that are known to be non-zero.

For example, thread::available_parallelism is defined as:

pub fn available_parallelism() -> Result<NonZero<usize>>

If the call is successful, it returns a NonZero<usize>, which is like a normal usize with the restriction that it's not zero. Client code doesn't have to keep checking whether the parallelism is 0 - it's enshrined in the type system.

Rust defines the division operator with NonZero<usize> in the denominator as an operation that "cannot panic".

NonZero has an additional advantage: zero is an invalid value for the type, so Rust can use the zero bit pattern to represent None. Consequently, Option<NonZeroUsize> is guaranteed to have the same size and alignment as NonZeroUsize itself (and as usize). This can avoid the extra storage that an Option<usize> would generally require.

Parsing JSON

A common example of the "parse, don't validate" idiom appears in deserializing data from a JSON string. Rust's serde crate enables us to do the parsing, with validated decisions encoded into the type system, e.g.:

#[derive(Debug, Deserialize)]
struct Config {
    name: String,
    workers: NonZeroUsize,
    mode: Mode,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Mode {
    Fast,
    Safe,
}

And then later:

let input = r#"
     {
         "name": "compiler",
         "workers": 4,
         "mode": "fast"
     }
 "#;

 let config: Config = serde_json::from_str(input)?;

There is a lot happening behind the scenes:

  • The types of all fields are enforced (e.g. "name" cannot be an array).
  • The mode is validated to be one of the enum values of Mode.
  • workers is validated to be a non-zero integer, because of the NonZeroUsize field type.

We take code like this for granted these days, but it's still a great example of the pattern discussed in this post. Once the parser converted mode into the Mode enum, no further validation is required.

In dynamic languages like Python and JS, the process is usually much more manual. Python's json.loads gives us a dictionary, and it's up to the user to validate its contents. Libraries like Pydantic permit an approach closer to Rust's, but they're not universally used.


联系我们 contact @ memedata.com