Rust を読んでいると、さまざまなものに型付けをするコードをよく見かけます。強めに(厳密に)型付けをする文化があるプログラミング言語、と言えるかもれません。
バリデーションチェックに対してもこうした強めの型付けが適用できます。具体的なバリデーションの情報を型情報として落としておくことで、コードを読むだけでバリデーション情報を把握できたり、あるいは誤った値の代入をコンパイルタイムで弾くことができるようになるというメリットを享受できるようになります。
一方で、型情報があまりに複雑化すると、あまりそうした型付け手法に慣れていないプログラマがキャッチアップするのに少し時間がかかったり、あるいはとんでもなく複雑になってそもそもその型を作り切るのが大変というデメリットも生じることになります。
今日紹介する手法は、さまざまなトレードオフの上に成り立つものであり、もし導入の結果、そのプロジェクトにとって得られるものが最適であると判断できるならば利用するとよいと思います。
それでは、実際にどのように型付けできるのかを見ていきましょう。Web アプリケーションではバリデーションチェックはまずまず大きな比重を占める実装であり、今回は Web アプリケーションで使用することを前提としています。
今回の要件
今回は、所定の長さ以下の文字列かどうかを判定するバリデーションを実装したいものとします。8文字以下の判定と、4文字以下の判定を実装することにします。また、文字列は空であってはならないものとします。
バリデーションを通過できなかった場合は、下記のエラーを表現する enum を返すものとします。
#[derive(Debug)] pub enum ValidationError { NotAllowedEmpty, InvalidFormat(String), }
Rust で愚直に実装するとしたら、「特定の条件を満たすかどうかを if 文で判定し、満たさなければエラーにして返す」のような実装が考えられるかもしれません。下記は文字列が空でないか、ならびに8文字以下かをチェックする関数です。
fn maybe_fail(source: String) -> Result<String, ValidationError> { if source.is_empty() { return Err(ValidationError::NotAllowedEmpty); } if source.len() > 8 { return Err(ValidationError::InvalidFormat("文字列は8文字以下にしてください".to_string())); } Ok(source) }
このような実装を、型駆動のプログラミングではどのように実装していくかについて、今回の記事では議論を進めることにします。
デザイン
どういう結果が得られるのか
最終的な使い心地は、たとえば次のようになるようにしたいものとします。
pub struct MyUserId(NonEmptyString<LessThanEqualEight>); impl MyUserId { pub fn new(raw: NonEmptyString<LessThanEqualEight>) -> Result<Self, ValidationError> { Ok(MyUserId(raw)) } } fn create_user_id_with_validate() -> Result<MyUserId, ValidationError> { let raw_words = "myuseridisover8words".parse()?; // これはエラーになるが MyUserId::new(raw_words) }
&str
に対するparse
を呼び出すと、裏で自動でバリデーションチェックをかけてくれる。内容は、可能ならば後続の処理から型推論される。- バリデーションチェックを通った値のみが
MyUserId::new
に入ってくることになる。どういう値が入っているかは、new
関数の引数の型を見るとわかる。
このコードを実行すると、結果エラーになります。parse
の時点で、「文字列が空でないか&8文字以下か」というバリデーションチェックが走るように実装しているためです。
では、どのようにそうした仕組みを実現しているのかについて、これから見ていきます。
実装する
基本的な登場人物
今回主役となる型は NonEmptyString
とその型引数に使用されている LessThanEqualEight
です。これらは実際にどのようになっているのでしょうか。
NonEmptyString
NonEmptyString
は非常に単純な実装で、型引数として V
を受け取り、内部には String
型なフィールドと PhantomData
を持っているだけの構造体です。PhantomData
が必要なのは、 V
を実際には使用していないものの、Rust では使用しない型引数を構造体に残したままにするとコンパイルエラーになるためです。それを回避するために、_marker
というフィールドが必要になります。
pub struct NonEmptyString<V> where V: ValidateStrategy, { pub value: String, _marker: PhantomData<fn() -> V>, }
V
にはさらに ValidationStrategy
が必要というトレイト境界が付与されています。この ValidationStrategy
が、「どのようなバリデーション規則を適用するか」を型で表現するために重要になります。次はこの ValidationStrategy
と、その活用方法について見ていきましょう。
ValidationStrategy
ValidationStrategy
はトレイトです。関数に validate
をもっています。
pub trait ValidationStrategy { fn validate(target: &str) -> Result<String, ValidationError>; }
先ほど LessThanEqualEight
という型引数が少し登場してきました。この型は ValidationStrategy
を実装しています。この validate
の実装内容は、後々別の箇所で呼び出されることになります。
8文字以下であることのバリデーションチェックの実装内容は、具体的には下記のようになっています。
pub struct LessThanEqualEight; impl ValidationStrategy for LessThanEqualEight { fn validate(target: &str) -> Result<String, ValidationError> { if target.len() <= 8 { Ok(target.to_string()) } else { Err(ValidationError::InvalidFormat(format!( "{} must be less than equal 8", &target ))) } } }
もし、他のバリデーション規則を追加したいとなった場合には、ValidationStrategy
を実装した新しい構造体を用意することで追加できます。今回のサンプルコードでは、rules
というモジュールを新たに切り、その中に別のバリデーション規則を用意しています。下記は、たとえば4文字以下となるような文字列かどうかをバリデーションするという規則を追加する例です。
pub mod rules { use super::ValidationStrategy; use crate::types::error::ValidationError; pub struct LessThanEqualFour; impl ValidationStrategy for LessThanEqualFour { fn validate(target: &str) -> Result<String, ValidationError> { if target.len() <= 4 { Ok(target.to_string()) } else { Err(ValidationError::InvalidFormat(format!( "{} must be less than equal 4", &target ))) } } } pub struct LessThanEqualEight; impl ValidationStrategy for LessThanEqualEight { fn validate(target: &str) -> Result<String, ValidationError> { if target.len() <= 8 { Ok(target.to_string()) } else { Err(ValidationError::InvalidFormat(format!( "{} must be less than equal 8", &target ))) } } } }
ここまでで準備は整いました。
// 8文字以下のバリデーションチェックを含む文字列型であると示す型 NonEmptyString<LessThanEqualEight> // 4文字以下のバリデーションチェックを含む文字列型であると示す型 NonEmptyString<LessThanEqualFour>
使いやすくする
ただ、都度 validate
のような関数を呼び出していると、そもそも呼び出し忘れが起きたり、あるいは面倒に感じたりするなど厄介です。この型を生成するタイミングでバリデーションチェックが走ってくれると、非常に使い勝手のよいものになるかもしれません。
今回はたとえば、&str
型から変換するタイミングでバリデーションチェックを起こせるとよいかもしれないというユースケースにあたっているものとします。Rust では、&str
型から任意の型に変換する際の作業を統一的に扱えるようにしてくれる便利な機能があります。FromStr
トレイトです。今回は、これを NonEmptyString
で実装するようにします。
NonEmptyString
には V
という「どのようなバリデーション規則を用いるか」を示す型引数がありました。これは ValidationStrategy
というトレイトをトレイト境界としてもっています。なので、validate
関数を呼び出すと、あとは解決された具象実装側の validate
が実行されることになります。LessThanEqualEight
型なら、8文字以下かどうかのチェックが走りますし、LessThanEqualFour
型なら、4文字以下のチェックが走ります。
impl<T> FromStr for NonEmptyString<T> where T: ValidationStrategy, { type Err = ValidationError; fn from_str(s: &str) -> Result<Self, Self::Err> { if s.is_empty() { return Err(ValidationError::NotAllowedEmpty); } let validated = T::validate(s)?; Ok(NonEmptyString { value: validated, _marker: PhantomData, }) } }
あとは、文字列をパースする関数を呼び出します。型推論が難しい箇所では次のように Turbofish による型の明示が必要になりますが
let _ = "is this less than eight?".parse::<NonEmptyString<LessThanEqualEight>>()?;
後続処理に関数がありそこから型推論が可能な場合には、単に parse
関数を呼び出すだけでよくなります。
let source = "is this less than eight?".parse()?; // new 関数で NonEmptyString<LessThanEqualEight> を受け取ることがわかっているので、parse の型がここから逆算されて判明する let user_id = MyUserId::new(source);
あるいは、NonEmptyString::new
のような関数を生やして、そこに FromStr
に記述したのと同等の内容を実装してもよいかもしれません。これはどうこの型を使いたいかによると思います。
工夫の余地
- バリデーション規則の
impl
の実装が少々面倒くさい: これは専用のマクロを実装することで解消できます。今回は書いていませんが、具象型、バリデーション規則、エラーとして何を返すかを引数として取るマクロを用意し、そのマクロの内部でimpl ValidationStrategy for {具象型}
のようなコードの生成を行えます。 - 文字列型以外にも適用したい:
FromStr
は使えなくなりますが、文字列型以外でも当然実装可能です。ただ、ValidationStrategy
はもう少し一般化が可能で、そうした方が便利だと思います。
最終的な実装
まとめ
- バリデーション情報を型に落とし込む方法を紹介しました。
- 型引数を用いることでそれが実現できることを学びました。
- トレイトを上手に使うと、実装を自動でいくつか導出でき、結果使いやすい形におさめることができることも学びました。