その構成要素の合計よりも強力な型
Rust の開発者が std::path::Component 列挙型を複数の独立した型に分け、シグネチャをより正確にし、パスの扱いを分かりやすくした話。型で不正な状態を表現できなくする設計の実例。
日本語
コピー

具体的には、std::path::Component は std::path::Path の参照から得られる enum だ。path は component のイテレータと見なせる。たとえば /tmp/hello は [Component::RootDir, Component::Normal("tmp"), Component::Normal("hello")] になる。path を分解して扱うときには非常に便利な enum だが、単一の component を受け取るインターフェースがその任意の variant を取りうるとなると、そのインターフェースは広すぎて使いものにならない。
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 variant 由来であることが保証されている。上の例では、この型の性質を利用して、すでに絶対パスである path に join した結果も必ず絶対パスになることを保証している。
大したことないように聞こえるかもしれないが、これ以前の代替案はおおよそこうだった:
pub(crate) fn join_normal(&self, path: &OsStr) -> AbsPath
しかし OsStr は何にでもなりうる。.. を含むかもしれないし、それ自体が絶対パスかもしれない(その場合、join API は対象を置き換えてしまう)。もう一つの用途は、ディレクトリ内のエントリを表すことだ。
振り返ってみればあまりに当然の一歩だった。よく設計された既存の enum を持ってきて、それが内包しうる variant ごとに型を一つずつ作る。「N 個の可能性のうち 1 つを持っている」(つまり enum)と知ることが役に立つのなら、「1 つの非常に具体的で、かつその enum に収まるものを持っている」と知ることもおそらく同じくらい役に立つ。enum は「和型」(sum type)とも呼ばれる。別の見方をすればこうだ。和型が役に立つのなら、その各構成要素もまた役に立つ。
この抽象化に至るまでに、他にも一連の型を作っていた:
- AbsPath——
absoluteを通した path。 - RelativePath——ご想像のとおり、相対パス。
- CanonicalPath——すでに
canonicalize済みの path。
これらの型は確かに役に立ったが、それでも範囲が広すぎた。path を扱うときに厄介なのは、path が字句上の値であると同時に物理的な位置を指しており、その二つが一致しないことがある点だ。字句上は相対パスでも、ディスク上では絶対パスを指すシンボリックリンクかもしれない。しかも path を正規化したり変換したりすると、奇妙な結果を招きがちだ。たとえば /path/to/location/skipped/.. にあるファイルのメタデータを読もうとしても、skipped が存在しないかディレクトリでなければ失敗する。だが先に path を正規化すればこのステップは成功し、/path/to/location が得られ、それに対してメタデータを取ればもう失敗しない。
絶対パスは正規化済みとは限らないので、..(ParentDir)や .(CurDir)を含みうる。だがその path がどう使われるかは分からない(私のライブラリでは、呼び出し側がなぜこの path の情報を問い合わせるのか知る由もない)。物理的な親ディレクトリを先に解決しない限り、これらの値を安全に正規化することはできない。理由は、先ほど触れた /path/to/location/skipped 自体が別のまったく異なる絶対パスを指すシンボリックリンクでありうるからで、「.. を適用して親ディレクトリを畳む」ステップを実行する前に、それを解決しなければならない。さらに厄介なことに、Windows には検索動作を変える特殊な path がある。\\?\ で始まる path、たとえば \\?\C:\windows は、. や .. をリテラルとして扱う。
つまり、path を std::path::canonicalize に渡すと、返ってくる path にはこの構文が付く。これは Windows で canonicalize(canonicalize(&path).join(&other)) を呼ぶのが安全でないことも意味する。&other に .. が含まれていれば、verbatim 検索が発生し、まず失敗する。幸い Component の解決はここでは一貫しているので、.. に対しては常に Component::ParentDir を返し、Component::Normal("..") にはならない。
join の安全性はおそらくこの新しい型がもたらした最大の利点だが、次のようなコードが書けるのも面白い:
fn up(position: Reached, name: ParentDirComponent) -> (Step, Reached)
ここで up 関数は、ディスク上の path に沿って component 単位でたどっていく。以前は OsString を受け取っており、プログラマが自分で気をつけるしかなかった。このシグネチャは、開発者に対して、手にしているのが .. component であることをコンパイラに先に証明させてからでないと、このロジックを呼べないようにする。これも大したことではないが、こうした細かい確実性が、ここではただ……心地よい。
食べ物や型の好みは人それぞれだ。ここで出した例が気に入らなくても構わないが、私は満足しているし、何か考えてもらえる種になればと思う。他にもまだ思い返してしまう、満足のいく型のパターンがあれば、ぜひ聞いてみたい。
AI に関する免責事項:Gen AI のコーディングツールについて。自分のコードもかなり手書きしているし、「手書きコードの月曜日」みたいなものを提唱している。この src/component.rs は完全に自分の肉脳の産物だ。実際、ネットのない車の中で、子どものサッカー練習が終わるのを待ちながら書いた。文章の編集には Grammarly(非 gen-ai モード)を使っている。
