プログラミング
「Parse, don't validate」に関するRustでの考察
Rusty thoughts on "Parse, don't validate" (eli.thegreenplace.net)
要約
この記事は、プログラミングにおける「Parse, don't validate」(検証するのではなくパースせよ)というイディオムをRust言語に適用した考察です。Rustの標準ライブラリや人気のあるプロジェクトにおける具体的な例を通じて、このパターンがどのように活用されているか、そして型システムを利用して不変条件を強制することでコードの明確性や安全性を向上させる方法を解説しています。
全文翻訳
多くのプログラマーがそうであるように、私もAlexis Kingの「Parse, don't validate」の記事に魅了されています。なぜなら、それは私にとって馴染み深く、重要だと感じるイディオムに名前を与えてくれるからです。私は以前からこのイディオムを明示的に名前を付けずに観察し、使用してきました。この記事は、「Parse, don't validate」パターンをRustプログラミング言語(元の記事はHaskellを使用)に適用したレビューです。私は特に、Rust標準ライブラリやその他の有名なプロジェクトでこのパターンの教育的な例を見つけることに興味がありました。元の記事を繰り返すことなく(まず読んでください!)、その要点を以下に示します。
有名なVecを考えてみましょう。その最初のメソッドはOption<&T>を返します。なぜでしょうか?ベクターに要素が含まれていることが保証されていないため、空のベクターに対してfirstが呼び出された場合はどうなるでしょうか?この場合、Optionを返すのはRustではidiomaticです[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クレートにはすでにそのような型が存在します。例えば、nonemptyです。
```rust
pub struct NonEmpty<T> {
pub head: T,
pub tail: Vec<T>,
}
```
この型には「要素がない」ことを許可するコンストラクタがありません。そのnewは1つの要素を取り、そのfirstメソッドはOptionなしで&Tを返します。
```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
}
```
このクレートの残りの部分は、多くの有用なトレイトを実装し、さらに次のような変換を提供することで、NonEmptyが通常のVecに可能な限り近い動作をするようにしています。
```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関数が通常のVecではなくNonEmptyを返すようになった場合、どのように見えるか見てみましょう。
```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を使用していることに注意してください。ここで不変条件が確立されます。成功した結果は、単なるVecではなく、NonEmptyになります。クライアントコードは次のようになります。
```rust
fn main() -> Result<()> {
let config_dirs = get_configuration_directories()?;
initialize_cache(config_dirs.first())?;
Ok(())
}
```
返された値が空かどうかを再度チェックする必要はありません。これは型システムによって強制されます!
ここで、元の記事のparse vs. validateという用語が出てきます。get_configuration_directoriesがVecを返した場合、それは単にそれを検証していました。しかし、NonEmptyを返す場合、ベクターは追加の意味を持つ別のエンティティに変換されます。パースという概念を最も一般的な意味、「データをある形式から別の形式に変換する」と捉えるなら、これは当てはまります。
より人工的でない例を挙げると、Rustでのcore POSIXユーティリティのリライトは、いくつかの場所でNonEmptyを使用しています[2]。例えば、シェルパイプラインを構築する場合です。
```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);
};
// ...
}
```
有効なPipelineは、パースされたASTにコマンドがいくつか存在する場合にのみ返されます。それ以外の場合は、単にNoneが返されます。これが完了すると、クライアントコードはNoneを返す可能性を心配することなくcommands.first()を使用できます。
段階的なパースと型洗練
もう少し興味深い例は、rust-analyzerのソースコードに見られます。このプロジェクトには、絶対ファイルシステムパスを表す型があります。
```rust
pub struct AbsPathBuf(Utf8PathBuf);
```
通常のパスを持ち歩く代わりに、初期パースと検証が完了すると、その絶対性が型に記録されます。
```rust
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))
}
}
```
後続のコードでは、パスが絶対であることを検証する必要はありません。型がそれを強制します。また、AbsPathBufはPathBufではなくUtf8PathBufをラップしていることにも注意してください。Utf8PathBuf自体は、caminoクレートからのカスタムの「パース済み」型洗練です。Rust標準ライブラリの通常のパスは有効なUTF-8であることが保証されていないため、文字列(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があります。例えば、thread::available_parallelismは次のように定義されています。
```rust
pub fn available_parallelism() -> Result<NonZero<usize>>
```
呼び出しが成功すると、NonZero<usize>が返されます。これは、ゼロではないという制約を持つ通常のusizeのようなものです。クライアントコードは、並列性が0かどうかを常にチェックする必要はありません。それは型システムに組み込まれています。Rustは、NonZero<usize>を分母とする除算演算子を「パニックしない」操作として定義しています。NonZeroには追加の利点があります。ゼロはこの型の無効な値であるため、RustはゼロビットパターンをNoneを表すために使用できます。したがって、Option<NonZeroUsize>は、NonZeroUsize自体(およびusize)と同じサイズとアライメントを持つことが保証されます。これにより、Option<usize>が通常必要とする追加ストレージを回避できます。
JSONのパース
「Parse, don't validate」イディオムの一般的な例は、JSON文字列からのデータの逆シリアル化に現れます。Rustのserdeクレートは、型にエンコードされた検証済みの決定をパースできるようにします。