比其组成部分之和更强大的类型
Rust 开发者把 std::path::Component 枚举拆成 N 个独立类型,让接口签名更精确、处理路径更顺手。
中文
复制

具体来说,std::path::Component 是一个 enum,可以从任意一个 std::path::Path 引用上拿到。一个 path 可以看作是由 component 组成的迭代器。举个例子,/tmp/hello 就是 [Component::RootDir, Component::Normal("tmp"), Component::Normal("hello")]。这个 enum 在拆解和处理 path 时非常顺手,但如果一个接口接收单个 component,而它可能是其中任意一个变体,那这个接口就过于宽泛,用处不大。
在一个我经常处理 path 的库里,我为每一种 component 类型都做了拥有所有权的 struct,这样就能写出这样的函数签名:
impl AbsPath {
// ...
pub(crate) fn join_normal(&self, path: &NormalComponent) -> AbsPath {
AbsPath(self.as_ref().join(path.as_ref()))
}
}
其中 NormalComponent 是一个 struct NormalComponent(OsString),保证来自 Component::Normal 变体。在上面的例子里,我利用这个类型的性质来保证:把它 join 到一个已经是绝对路径的 path 上,得到的 path 也一定是绝对路径。
这听起来可能不算惊天动地,但在此之前,替代方案大概是这样:
pub(crate) fn join_normal(&self, path: &OsStr) -> AbsPath
可 OsStr 什么都可能是。它可能包含 ..,也可能本身就是绝对路径(这种情况下,join API 会替换掉目标)。另一个用例是用它来表示目录里的条目。
事后看,这一步实在太显然了:拿一个现成的、设计良好的 enum,为它能容纳的每一个变体各做一个类型。如果知道“你拥有 N 种可能事物中的 1 种”(也就是 enum)是有用的,那么知道“你拥有 1 个非常具体、同时也能放进那个 enum 的事物”大概也同样有用。enum 也叫“和类型”(sum type)。所以换个角度看:如果和类型有用,那么它的各个组成部分也同样有用。
在做出这层抽象之前,我还做过一系列其他类型:
- AbsPath——一个经过
absolute处理的路径。 - RelativePath——你猜到了,就是相对路径。
- CanonicalPath——一个已经
canonicalize过的路径。
这些类型确实有用,但覆盖面还是太宽。处理路径时有个很别扭的地方:路径既是一个词法上的值,又指向一个物理位置,而这两者可能并不一致。一个词法上是相对路径的东西,在磁盘上可能是个符号链接,指向一个绝对路径。而对路径做规范化或变换,往往会带来奇怪的后果。比如你想从 /path/to/location/skipped/.. 处的文件读取元数据,只要 skipped 不存在或不是目录,就会失败。可如果你先把路径规范化,这一步就能成功,得到 /path/to/location,再对它取元数据就不会失败了。
绝对路径并不等于已规范化,所以里面可能含有 ..(ParentDir)和 .(CurDir)。但你并不知道这个路径会被怎么使用(在我的库里,我无从得知调用者为什么要查询这个路径的信息)。除非先解析出它的物理父目录,否则你没法安全地规范化这些值。原因在于,上面提到的 /path/to/location/skipped 本身也可能是指向另一个完全不同绝对路径的符号链接,必须先解析它,才能执行“套用 .. 折叠父目录”这一步。更麻烦的是,Windows 还有一些特殊路径会改变查找行为。以 \\?\ 开头的路径,比如 \\?\C:\windows,会把 . 和 .. 当作字面值处理。
也就是说,把路径交给 std::path::canonicalize 处理,返回的路径会带上这种语法。这同样意味着在 Windows 上调用 canonicalize(canonicalize(&path).join(&other)) 是不安全的。如果 &other 里含有 ..,就会产生一次 verbatim 查找,多半会失败。好在 Component 的解析在这里是一致的,所以对于 .. 它总是返回 Component::ParentDir,而不是 Component::Normal("..")。
连接安全性大概是这个新类型带给我的最大好处,不过能写出下面这样的代码也很有意思:
fn up(position: Reached, name: ParentDirComponent) -> (Step, Reached)
这里 up 函数会沿着磁盘上的路径逐个组件地遍历/追踪。以前它接收的是 OsString,程序员必须自己小心。而这个类型签名迫使开发者先向编译器证明自己手里拿的是一个 .. 组件,然后才能调用这段逻辑。也算不上什么惊天动地的事,但这种吹毛求疵的确定性,在这里就是让人……很愉快。
不是每个人对食物或类型的口味都一样。我在这里端上来的例子你不喜欢也没关系,但我觉得挺满足的,也想给你一些可以琢磨的东西。如果你还有其他仍在回味的、令人满足的类型模式,我很想听听。
关于 AI 的免责声明:Gen AI 编码工具,我很多代码也是手写的,并且主张搞个“手写代码星期一”之类的东西。这个 src/component.rs 完全是我这颗肉脑的产物。实际上我是在一辆没有网络的车上写的,当时正等着孩子的足球训练结束。我用 Grammarly(非 gen-ai 模式)帮我编辑文字。
