11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

HaskellのData.Either.Validationを使う

Last updated at Posted at 2016-12-28

先日使ってみたいモジュールがあるからとこんな記事を書きました。
まぁ記事と言っても殴り書きの自習ノートみたいなもんですが。

で、使うには十分な理解度に到達したと思うので、使いたかったData.Either.Validationを使ってみます。

使おうとしたら全然使用例みつからないし日本語の記事も全くと言って良いほど見当たらなかったので、せっかくだしまとめてみます。

どんなもの?

一言で言うと、全部のエラーを溜められるEitherです。
ちなみにインストールは不要です。

これがEitherApplicativeの実装です。
一度失敗したらそれ以降が何であろうと、その失敗を保持し続けます。

Either
instance Applicative (Either e) where
    pure          = Right
    Left  e <*> _ = Left e
    Right f <*> r = fmap f r

動かしてみます。

(+) <$> Right 1 <*> Right 2
Right 3

(+) <$> Left "error 1" <*> Right 2
Left "error 1"

(+) <$> Left "error 1" <*> Left "error 2"
Left "error 1"

最後に得られるのは最初の失敗だけです。

それに対してValidationの実装はこうです。
2つめ以降の失敗の場合に連結しています。

Validation
instance Semigroup e => Applicative (Validation e) where
  pure = Success
  Failure e1 <*> Failure e2 = Failure (e1 <> e2)
  Failure e1 <*> Success _  = Failure e1
  Success _  <*> Failure e2 = Failure e2
  Success f  <*> Success a  = Success (f a)

こちらも動かしてみます。

import Data.Either.Validation 

(+) <$> Success 1 <*> Success 2
Success 3

(+) <$> Failure ["error 1"] <*> Success 2
Failure ["error 1"]

(+) <$> Failure ["error 1"] <*> Failure ["error 2"]
Failure ["error 1","error 2"]

実例イメージ

4つのパラメータを受けて、全てのバリデーションが通ったら購入処理を行ってみます。

購入処理
import Text.Printf

purchase :: String -> String -> String -> Int -> String
purchase userId mailAddress itemName itemCount = printf "ordered [userId: %s, mailAddress: %s, itemName: %s, itemCount: %d]" userId mailAddress itemName itemCount

手抜きで文字列連結をするだけですが、今回の本体です。
どこにもバリデーションに関することは現れていません。

purchase "user-1" "foo@bar.com" "item-1" 3
"ordered [userId: user-1, mailAddress: foo@bar.com, itemName: item-1, itemCount: 3]"

ただの値を渡すとただの値が返ってくる関数です。

さて、この4つのパラメータのバリデータを書いてみます。

バリデータ
import Data.Either.Validation

validateUserId :: String -> Validation [String] String
validateUserId value = if value /= ""
    then Success value
    else Failure ["UserId: empty not allowed"]

validateMailAddress :: String -> Validation [String] String
validateMailAddress value = if '@' `elem` value
    then Success value
    else Failure ["AailAddress: no atmark"]

validateItemName :: String -> Validation [String] String
validateItemName value = if value /= ""
    then Success value
    else Failure ["ItemName: empty not allowed"]

validateItemCount :: Int -> Validation [String] Int
validateItemCount value = if value /= 0
    then Success value
    else Failure ["ItemCount: zero not allowed"]

1項目につき1つのルールでチェックします。

validateUserId ""
Failure ["UserId: empty not allowed"]

validateUserId "user-1"
Success "user-1"

validateMailAddress "foo.bar.com"
Failure ["AailAddress: no atmark"]

validateMailAddress "foo@bar.com"
Success "foo@bar.com"

validateItemName ""
Failure ["ItemName: empty not allowed"]

validateItemName "item-1"
Success "item-1"

validateItemCount 0
Failure ["ItemCount: zero not allowed"]

validateItemCount 3
Success 3

本文とバリデータは揃いました。

あとはバリデート結果が全部Successかチェックして、その時だけSuccessが保持している値を引っ張りだして本文を呼ぶ...
なんて面倒な事はしませんよ!

ValidationApplicativeなので、ただの値を受け取るpurchaseをそのまま使います!

purchase <$> validateUserId "user-1" <*> validateMailAddress "foo@bar.com" <*> validateItemName "item-1" <*> validateItemCount 3
Success "ordered [userId: user-1, mailAddress: foo@bar.com, itemName: item-1, itemCount: 3]"

purchase <$> validateUserId "" <*> validateMailAddress "foo@bar.com" <*> validateItemName "item-1" <*> validateItemCount 0
Failure ["UserId: empty not allowed","ItemCount: zero not allowed"]

全てのバリデーションが通ったときはpurchaseの結果が、1つ以上のバリデーションエラーがある場合は出たエラー全てが得られます!

これすごくない!?

purchase自体の単体テストをする場合なんかはそのままStringとかを渡して、プロダクトコードで使うときはValidationを渡す(風)に使えちゃいます。
エラーがあったら〜、なかったら〜、という処理はHaskellの標準ライブラリで実現しているのでテスト書かないで良いでしょう。

バリデータと本文の単体テストだけをすれば良いんです!

なんて簡単!
型注釈とifの改行を考えないで実質行数で考えると5行しか書いてないです!

以上でData.Either.Validationの紹介を終わります。
ぜひ触ってみてください、感動しますよ!

余談

printfって引数の数とか型を間違えると実行例外になっちゃうんですね。
Haskellで実行例外出すとものすごーく損した気持ちになるので今後はちゃんと作るときは使わない様にしようと思いました。

おまけ

せっかくValidationは複数のエラーを全部返せるのだから、1つのバリデータにつき1つのルールじゃあなくて複数ルールにしたい。

必須とか文字長は独立部品として用意してみます。

ルール
notNull :: String -> String -> Validation [String] String
notNull name value = if value == "" then Failure [printf "%s: not null" name] else Success value

len :: Int -> String -> String -> Validation [String] String
len size name value = if length value /= size then Failure [printf "%s: length must be %d" name size] else Success value

ルールと呼ぶ事にします、こうやって使います。
バリデータから独立させたので、バリデータ名と値を受けてValidationを返します。

notNull "UserId" ""
Failure ["UserId: not null"]

len 6 "UserId" "abc"
Failure ["UserId: length must be 6"]

さて、これらの部品を全て呼んで、エラーが混じっていたらエラー全てを、正常なら値を返さなければなりません。
こう書いてみました。

バリデータ
validateUserId :: String -> Validation [String] String
validateUserId value = (\v _ -> v) <$> notNull "UserId" value <*> len 6 "UserId" value

エラーがあったら〜、なかったら〜、はそのままさっきのApplicativeと同じ様に出来ますが、Successの場合の値はどうしたら良いでしょうか...
全てがSuccessの場合は、全てのSuccessに同じ値(value)が入っているので、ラムダ式で適当に1つだけを取り出してみました。

validateUserId "user-1"
Success "user-1"

validateUserId ""
Failure ["UserId: not null","UserId: length must be 6"]

とりあえずは正しそうです。

けどこれだとルールを増やした場合にラムダ式の引数も増やさないといけないので、イケてない感があります。

どうしようか考えていたところで、先日読んだH本のリストのApplicativeを思い出しました。
あれは確か1引数関数を総当たりで適用出来る感じだったはず...

[(+2), (+3)] <*> [5]
[7,8]

お、これ良さそうなのでは?

[notNull "UserId", len 6 "UserId"] <*> [""]
[Failure ["UserId: not null"],Failure ["UserId: length must be 6"]]

[notNull "UserId", len 6 "UserId"] <*> ["user-1"]
[Success "user-1",Success "user-1"]

うん、これで進めてみよう。

けどその前に細かいですが、何回もバリデータ名を書くのは嫌だし見通しも悪いので、mapを使ってまとめて部分適用するようにちょっとだけ改造します。

map (\f -> f "UserId") [notNull, len 6] <*> ["user-1"]
[Success "user-1",Success "user-1"]

あとは...やっぱり[Validation]を全部のエラーか成功値にまとめないといけないよね...
こんな感じで畳み込んでみようかな...

flat :: Validation [String] String -> Validation [String] String -> Validation [String] String
flat (Success x) (Success y) = Success x
flat (Success x) (Failure y) = Failure y
flat (Failure x) (Success y) = Failure x
flat (Failure x) (Failure y) = Failure $ x ++ y
foldl1 flat $ map (\f -> f "UserId") [notNull, len 6] <*> ["user-1"]
Success "user-1"

foldl1 flat $ map (\f -> f "UserId") [notNull, len 6] <*> [""]
Failure ["UserId: not null","UserId: length must be 6"]

よし、Successか全てのFailureに平らに出来てます。

こんな感じで名前をつけてバリデータ名とルールと値を渡すだけで良い様にしておきます。

validate :: String -> [(String -> String -> Validation [String] String)] -> String -> Validation [String] String
validate name rules value = foldl1 flat $ map (\f -> f name) rules <*> [value]

これで各バリデータの実装がとてもすっきりして宣言的になりました!

バリデータ
validateUserId :: String -> Validation [String] String
validateUserId = validate "UserId" [notNull, len 6]

最後に、下のコードとかを見るとStringが多すぎてよくわからなくなってきたのでもう一工夫します。

validate :: String -> [(String -> String -> Validation [String] String)] -> String -> Validation [String] String

型シノニムを使ってみます。
全部Stringではあるのですが、エイリアスを付けて別名で書ける様にします。

type FormName = String
type Value = String
type Error = String
type Validated = Validation [Error] Value
type Rule = FormName -> Value -> Validated

こうするだけで大分読み易くなります。
(最初掲載したコードにIntの部分がありましたが、WebAPIのパラメータは全部Stringなのでまとめてしまいました)

notNull :: Rule

len :: Int -> Rule

flat :: Validated -> Validated -> Validated

validate :: FormName -> [Rule] -> Value -> Validated

validateUserId :: Value -> Validated

これでとても読み易くなりました!

バリデーション部分の最終成果物はこんな感じです。
それではまたノシ

import Text.Printf
import Data.Either.Validation

type FormName = String
type Value = String
type Error = String
type Validated = Validation [Error] Value
type Rule = FormName -> Value -> Validated

notNull :: Rule
notNull name value = if value == "" then Failure [printf "%s: not null" name] else Success value

len :: Int -> Rule
len size name value = if length value /= size then Failure [printf "%s: length must be %d" name size] else Success value

flat :: Validated -> Validated -> Validated
flat (Success x) (Success y) = Success x
flat (Success x) (Failure y) = Failure y
flat (Failure x) (Success y) = Failure x
flat (Failure x) (Failure y) = Failure $ x ++ y

validate :: FormName -> [Rule] -> Value -> Validated
validate name rules value = foldl1 flat $ map (\f -> f name) rules <*> [value]

validateUserId :: Value -> Validated
validateUserId = validate "UserId" [notNull, len 6]
11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?