アプリカティブによる検証
この章の目標
この章では重要な抽象化と新たに出会うことになります。
Applicative型クラスによって表現されるアプリカティブ関手です。
名前が難しそうに思えても心配しないでください。
フォームデータの検証という実用的な例を使ってこの概念の動機付けをします。
アプリカティブ関手の技法があることにより、通常であれば大量の決まり文句の検証を伴うようなコードを、簡潔で宣言的なフォームの記述へと変えられます。
また、巡回可能関手を表現するTraversableという別の型クラスにも出会います。現実の問題への解決策からこの概念が自然に生じることがわかるでしょう。
この章のコードでは第3章に引き続き住所録を例とします。 今回は住所録のデータ型を拡張し、これらの型の値を検証する関数を書きます。 これらの関数は、例えばwebユーザインターフェースで使えることが分かります。 データ入力フォームの一部として、使用者へエラーを表示するのに使われます。
プロジェクトの準備
この章のソースコードは、2つのファイルsrc/Data/AddressBook.purs、及びsrc/Data/AddressBook/Validation.pursで定義されています。
このプロジェクトには多くの依存関係がありますが、その大半は既に見てきたものです。 新しい依存関係は2つです。
control-Applicativeのような、型クラスを使用して制御フローを抽象化する関数が定義されています。validation- この章の主題である アプリカティブによる検証 のための関手が定義されています。
Data.AddressBookモジュールにはこのプロジェクトのデータ型とそれらの型に対するShowインスタンスが定義されています。
また、Data.AddressBook.Validationモジュールにはそれらの型の検証規則が含まれています。
関数適用の一般化
アプリカティブ関手 の概念を理解するために、以前扱った型構築子Maybeについて考えてみましょう。
このモジュールのソースコードでは、次の型を持つaddress関数が定義されています。
address :: String -> String -> String -> Address
この関数は、通りの名前、市、州という3つの文字列から型Addressの値を構築するために使います。
この関数は簡単に適用できますので、PSCiでどうなるか見てみましょう。
> import Data.AddressBook
> address "123 Fake St." "Faketown" "CA"
{ street: "123 Fake St.", city: "Faketown", state: "CA" }
しかし、通り、市、州の3つ全てが必ずしも入力されないものとすると、3つの場合がそれぞれ省略可能であることを示すためにMaybe型を使用したくなります。
考えられる場合としては、市が省略されている場合があるでしょう。
もしaddress関数を直接適用しようとすると、型検証器からエラーが表示されます。
> import Data.Maybe
> address (Just "123 Fake St.") Nothing (Just "CA")
Could not match type
Maybe String
with type
String
勿論、これは期待通り型エラーになります。
addressはMaybe String型の値ではなく、文字列を引数として取るためです。
しかし、もしaddress関数を「持ち上げる」ことができれば、Maybe型で示される省略可能な値を扱うことができるはずだという予想は理に適っています。実際それは可能で、Control.Applyで提供されている関数lift3が、まさに求めているものです。
> import Control.Apply
> lift3 address (Just "123 Fake St.") Nothing (Just "CA")
Nothing
このとき、引数の1つ(市)が欠落していたので、結果はNothingになります。
もし3つの引数全てにJust構築子を使ったものが与えられたら、結果は値を含むことになります。
> lift3 address (Just "123 Fake St.") (Just "Faketown") (Just "CA")
Just ({ street: "123 Fake St.", city: "Faketown", state: "CA" })
lift3という関数の名前は、3引数の関数を持ち上げるために使えることを示しています。関数を持ち上げる同様の関数で、引数の数が異なるものがControl.Applyで定義されています。
任意個の引数を持つ関数の持ち上げ
これで、lift2やlift3のような関数を使えば、引数が2個や3個の関数を持ち上げることができるのはわかりました。
でも、これを任意個の引数の関数へと一般化できるのでしょうか。
lift3の型を見てみるとわかりやすいでしょう。
> :type lift3
forall (a :: Type) (b :: Type) (c :: Type) (d :: Type) (f :: Type -> Type). Apply f => (a -> b -> c -> d) -> f a -> f b -> f c -> f d
上のMaybeの例では型構築子fはMaybeですから、lift3は次のように特殊化されます。
forall a b c d. (a -> b -> c -> d) -> Maybe a -> Maybe b -> Maybe c -> Maybe d
この型で書かれているのは、3引数の任意の関数を取り、その関数を引数と返り値がMaybeで包まれた新しい関数へと持ち上げられる、ということです。
勿論、どんな型構築子fについても持ち上げができるわけではないのですが、それではMaybe型を持ち上げができるようにしているものは何なのでしょうか。
さて、先ほどの型の特殊化では、fに対する型クラス制約からApply型クラスを取り除いていました。
ApplyはPreludeで次のように定義されています。
class Functor f where
map :: forall a b. (a -> b) -> f a -> f b
class Functor f <= Apply f where
apply :: forall a b. f (a -> b) -> f a -> f b
Apply型クラスはFunctorの下位クラスであり、追加の関数applyを定義しています。<$>がmapの別名として定義されているように、Preludeモジュールでは<*>をapplyの別名として定義しています。これから見ていきますが、これら2つの演算子はよく一緒に使われます。
なお、このapplyはData.Functionのapply(中置で$)とは異なります。
幸いにも後者はほぼ常に中置記法として使われるので、名前の衝突については心配ご無用です。
applyの型はmapの型と実によく似ています。
mapとapplyの違いは、mapがただの関数を引数に取るのに対し、applyの最初の引数は型構築子fで包まれているという点です。
これをどのように使うのかはこれからすぐに見ていきますが、その前にまずMaybe型についてApply型クラスをどう実装するのかを見ていきましょう。
instance Functor Maybe where
map f (Just a) = Just (f a)
map f Nothing = Nothing
instance Apply Maybe where
apply (Just f) (Just x) = Just (f x)
apply _ _ = Nothing
この型クラスのインスタンスで書かれているのは、任意の省略可能な値に省略可能な関数を適用でき、その両方が定義されている時に限り結果も定義される、ということです。
それでは、mapとapplyを一緒に使い、引数が任意個の関数を持ち上げる方法を見ていきましょう。
1引数の関数については、mapをそのまま使うだけです。
2引数関数については、型a -> b -> cのカリー化された関数gがあるとします。これは型a -> (b -> c)と同じですから、Functorインスタンス付きのあらゆる型構築子fについて、mapをfに適用すると型f a -> f (b -> c)の新たな関数を得ることになります。持ち上げられた(型f aの)最初の引数にその関数を部分適用すると、型f (b -> c)の新たな包まれた関数が得られます。fにApplyインスタンスもあるなら、そこから、2番目の持ち上げられた(型f bの)引数へapplyを適用でき、型f cの最終的な値を得ます。
纏めると、x :: f aとy :: f bがあるとき、式(g <$> x) <*> yの型はf cになります(この式はapply (map g x) yと同じ意味だということを思い出しましょう)。Preludeで定義された優先順位の規則に従うと、g <$> x <*> yというように括弧を外すことができます。
一般的には、最初の引数に<$>を使い、残りの引数に対しては<*>を使います。lift3で説明すると次のようになります。
lift3 :: forall a b c d f
. Apply f
=> (a -> b -> c -> d)
-> f a
-> f b
-> f c
-> f d
lift3 f x y z = f <$> x <*> y <*> z
この式に関する型の検証は、読者への演習として残しておきます。
例として、<$>と<*>をそのまま使うと、Maybe上にaddress関数を持ち上げることができます。
> address <$> Just "123 Fake St." <*> Just "Faketown" <*> Just "CA"
Just ({ street: "123 Fake St.", city: "Faketown", state: "CA" })
> address <$> Just "123 Fake St." <*> Nothing <*> Just "CA"
Nothing
同様にして、引数が異なる他のいろいろな関数をMaybe上に持ち上げてみてください。
この代わりに、お馴染のdo記法に似た見た目のアプリカティブdo記法が同じ目的で使えます。
以下ではlift3にアプリカティブdo記法を使っています。
なお、adoがdoの代わりに使われており、生み出された値を示すために最後の行でinが使われています。
lift3 :: forall a b c d f
. Apply f
=> (a -> b -> c -> d)
-> f a
-> f b
-> f c
-> f d
lift3 f x y z = ado
a <- x
b <- y
c <- z
in f a b c
アプリカティブ型クラス
関連するApplicativeという型クラスが存在しており、次のように定義されています。
class Apply f <= Applicative f where
pure :: forall a. a -> f a
ApplicativeはApplyの下位クラスであり、pure関数が定義されています。
pureは値を取り、その型の型構築子fで包まれた値を返します。
MaybeについてのApplicativeインスタンスは次のようになります。
instance Applicative Maybe where
pure x = Just x
アプリカティブ関手は関数を持ち上げることを可能にする関手だと考えるとすると、pureは引数のない関数の持ち上げだというように考えられます。
アプリカティブに対する直感的理解
PureScriptの関数は純粋であり、副作用は持っていません。Applicative関手は、関手fによって表現されるある種の副作用を提供するような、より大きな「プログラミング言語」を扱えるようにします。
例えば関手Maybeは欠けている可能性がある値の副作用を表現しています。
その他の例としては、型errのエラーの可能性の副作用を表すEither errや、大域的な構成を読み取る副作用を表すArrow関手 (arrow functor) r ->があります。
ここではMaybe関手についてのみ考えることにします。
もし関手fが作用を持つ、より大きなプログラミング言語を表すとすると、ApplyとApplicativeインスタンスは小さなプログラミング言語
(PureScript) から新しい大きな言語へと値や関数を持ち上げることを可能にします。
pureは純粋な(副作用がない)値をより大きな言語へと持ち上げますし、関数については上で述べた通りmapとapplyを使えます。
ここで疑問が生まれます。
もしPureScriptの関数と値を新たな言語へ埋め込むのにApplicativeが使えるなら、どうやって新たな言語は大きくなっているというのでしょうか。
この答えは関手fに依存します。
もしなんらかのxについてpure xで表せないような型f aの式を見つけたなら、その式はそのより大きな言語だけに存在する項を表しているということです。
fがMaybeのときは、式Nothingがその例になっています。
どんなxがあってもNothingをpure xというように書くことはできません。
したがって、PureScriptは値の欠落を表す新しい項Nothingを含むように拡大されたと考えることができます。
もっと作用を
様々なApplicative関手へと関数を持ち上げる例をもっと見ていきましょう。
以下は、PSCiで定義された3つの名前を結合して完全な名前を作る簡単な関数の例です。
> import Prelude
> fullName first middle last = last <> ", " <> first <> " " <> middle
> fullName "Phillip" "A" "Freeman"
Freeman, Phillip A
この関数が、クエリ引数として与えられた3つの引数を持つ、(とっても簡単な)webサービスの実装を形成しているとしましょう。
使用者が3つの各引数を与えたことを確かめたいので、引数が存在するかどうかを表すMaybe型を使うことになるでしょう。
fullNameをMaybeの上へ持ち上げると、欠けている引数を検査するwebサービスの実装を作成できます。
> import Data.Maybe
> fullName <$> Just "Phillip" <*> Just "A" <*> Just "Freeman"
Just ("Freeman, Phillip A")
> fullName <$> Just "Phillip" <*> Nothing <*> Just "Freeman"
Nothing
またはアプリカティブdoで次のようにします。
> import Data.Maybe
> :paste…
… ado
… f <- Just "Phillip"
… m <- Just "A"
… l <- Just "Freeman"
… in fullName f m l
… ^D
(Just "Freeman, Phillip A")
… ado
… f <- Just "Phillip"
… m <- Nothing
… l <- Just "Freeman"
… in fullName f m l
… ^D
Nothing
この持ち上げた関数は、引数の何れかがNothingならNothingを返すことに注意してください。
引数が不正のときにwebサービスからエラー応答を送り返せるのは良いことです。 しかし、どのフィールドが不正確なのかを応答で示せると、もっと良くなるでしょう。
Meybe上へ持ち上げる代わりにEither String上へ持ち上げるようにすると、エラー文言を返せるようになります。
まずはEither Stringを使い、省略可能な入力からエラーを発信できる計算に変換する演算子を書きましょう。
> import Data.Either
> :paste
… withError Nothing err = Left err
… withError (Just a) _ = Right a
… ^D
補足:Either errアプリカティブ関手において、Left構築子は失敗を表しており、Right構築子は成功を表しています。
これでEither String上へ持ち上げることで、それぞれの引数について適切なエラー文言を提供できるようになります。
> :paste
… fullNameEither first middle last =
… fullName <$> (first `withError` "First name was missing")
… <*> (middle `withError` "Middle name was missing")
… <*> (last `withError` "Last name was missing")
… ^D
またはアプリカティブdoで次のようにします。
> :paste
… fullNameEither first middle last = ado
… f <- first `withError` "First name was missing"
… m <- middle `withError` "Middle name was missing"
… l <- last `withError` "Last name was missing"
… in fullName f m l
… ^D
> :type fullNameEither
Maybe String -> Maybe String -> Maybe String -> Either String String
これでこの関数はMaybeを使う3つの省略可能な引数を取り、Stringのエラー文言かStringの結果のどちらかを返します。
いろいろな入力でこの関数を試してみましょう。
> fullNameEither (Just "Phillip") (Just "A") (Just "Freeman")
(Right "Freeman, Phillip A")
> fullNameEither (Just "Phillip") Nothing (Just "Freeman")
(Left "Middle name was missing")
> fullNameEither (Just "Phillip") (Just "A") Nothing
(Left "Last name was missing")
このとき、全てのフィールドが与えられば成功の結果が表示され、そうでなければ省略されたフィールドのうち最初のものに対応するエラー文言が表示されます。 しかし、もし複数の入力が省略されているとき、最初のエラーしか見られません。
> fullNameEither Nothing Nothing Nothing
(Left "First name was missing")
これでも充分なときもありますが、エラー時に全ての省略されたフィールドの一覧がほしいときは、Either Stringよりも強力なものが必要です。この章の後半で解決策を見ていきます。
作用の結合
抽象的にアプリカティブ関手を扱う例として、この節ではアプリカティブ関手fによって表現された副作用を一般的に組み合わせる関数を書く方法を示します。
これはどういう意味でしょうか。
何らかのaについて型f aで包まれた引数のリストがあるとしましょう。
それは型List (f a)のリストがあるということです。
直感的には、これはfによって追跡される副作用を持つ、返り値の型がaの計算のリストを表しています。
これらの計算の全てを順番に実行できれば、List a型の結果のリストを得るでしょう。
しかし、まだfによって追跡される副作用が残ります。
つまり、元のリストの中の作用を「結合する」ことにより、型List (f a)の何かを型f (List a)の何かへと変換できると考えられます。
任意の固定長リストの長さnについて、n引数からその引数を要素に持つ長さnのリストを構築する関数が存在します。
例えばもしnが3なら、関数は\x y z -> x : y : z : Nilです。
この関数は型a -> a -> a -> List aを持ちます。
Applicativeインスタンスを使うと、この関数をfの上へ持ち上げられ、関数型f a -> f a -> f a -> f (List a)が得られます。
しかし、いかなるnについてもこれが可能なので、いかなる引数のリストについても同じように持ち上げられることが確かめられます。
したがって、次のような関数を書くことができるはずです。
combineList :: forall f a. Applicative f => List (f a) -> f (List a)
この関数は副作用を持つかもしれない引数のリストを取り、それぞれの副作用を適用することで、fに包まれた単一のリストを返します。
この関数を書くためには、引数のリストの長さについて考えます。
リストが空の場合はどんな作用も実行する必要がありませんから、pureを使用して単に空のリストを返すことができます。
combineList Nil = pure Nil
実際のところ、これが唯一できることです。
入力のリストが空でないならば、型f aの包まれた引数である先頭要素と、型List (f a)の尾鰭について考えます。
また、再帰的にリストの残りを結合すると、型f (List a)の結果が得られます。
それから<$>と<*>を使うと、Cons構築子を先頭と新しい尾鰭の上に持ち上げることができます。
combineList (Cons x xs) = Cons <$> x <*> combineList xs
繰り返しになりますが、これは与えられた型に基づいている唯一の妥当な実装です。
Maybe型構築子を例にとって、PSCiでこの関数を試してみましょう。
> import Data.List
> import Data.Maybe
> combineList (fromFoldable [Just 1, Just 2, Just 3])
(Just (Cons 1 (Cons 2 (Cons 3 Nil))))
> combineList (fromFoldable [Just 1, Nothing, Just 2])
Nothing
Meybeへ特殊化すると、リストの全ての要素がJustであるときに限りこの関数はJustを返しますし、そうでなければNothingを返します。
これは省略可能な値に対応する、より大きな言語に取り組む上での直感と一貫しています。
省略可能な結果を生む計算のリストは、全ての計算が結果を持っているならばそれ自身の結果のみを持つのです。
ところがcombineList関数はどんなApplicativeに対しても機能するのです。
Either errを使ってエラーを発信する可能性を持たせたり、r ->を使って大域的な構成を読み取る計算を組み合わせるためにも使えます。
combineList関数については、後ほどTraversable関手について考えるときに再訪します。
演習
- (普通)数値演算子
+、-、*、/の別のバージョンを書いてください。 ただし省略可能な引数(つまりMaybeに包まれた引数)を扱ってMaybeに包まれた値を返します。 これらの関数にはaddMaybe、subMaybe、mulMaybe、divMaybeと名前を付けてください。 手掛かり:lift2を使ってください。 - (普通)上の演習を(
Maybeだけでなく)全てのApply型で動くように拡張してください。 これらの新しい関数にはaddApply、subApply、mulApply、divApplyと名前を付けます。 - (難しい)型
forall a f. Applicative f => Maybe (f a) -> f (Maybe a)を持つ関数combineMaybeを書いてください。 この関数は副作用を持つ省略可能な計算を取り、省略可能な結果を持つ副作用のある計算を返します。
アプリカティブによる検証
この章のソースコードでは住所録アプリケーションで使うであろう幾つかのデータ型が定義されています。
詳細はここでは割愛しますが、Data.AddressBookモジュールからエクスポートされる鍵となる関数は次のような型を持ちます。
address :: String -> String -> String -> Address
phoneNumber :: PhoneType -> String -> PhoneNumber
person :: String -> String -> Address -> Array PhoneNumber -> Person
ここで、PhoneTypeは次のような代数的データ型として定義されています。
data PhoneType
= HomePhone
| WorkPhone
| CellPhone
| OtherPhone
これらの関数は住所録の項目を表すPersonを構築できます。
例えば、Data.AddressBookでは以下の値が定義されています。
examplePerson :: Person
examplePerson =
person "John" "Smith"
(address "123 Fake St." "FakeTown" "CA")
[ phoneNumber HomePhone "555-555-5555"
, phoneNumber CellPhone "555-555-0000"
]
PSCiでこれらの値を試してみましょう(結果は整形されています)。
> import Data.AddressBook
> examplePerson
{ firstName: "John"
, lastName: "Smith"
, homeAddress:
{ street: "123 Fake St."
, city: "FakeTown"
, state: "CA"
}
, phones:
[ { type: HomePhone
, number: "555-555-5555"
}
, { type: CellPhone
, number: "555-555-0000"
}
]
}
前の章では型Personのデータ構造を検証する上でEither String関手の使い方を見ました。例えば、データ構造の2つの名前を検証する関数が与えられたとき、データ構造全体を次のように検証できます。
nonEmpty1 :: String -> Either String String
nonEmpty1 "" = Left "Field cannot be empty"
nonEmpty1 value = Right value
validatePerson1 :: Person -> Either String Person
validatePerson1 p =
person <$> nonEmpty1 p.firstName
<*> nonEmpty1 p.lastName
<*> pure p.homeAddress
<*> pure p.phones
またはアプリカティブdoで次のようにします。
validatePerson1Ado :: Person -> Either String Person
validatePerson1Ado p = ado
f <- nonEmpty1 p.firstName
l <- nonEmpty1 p.lastName
in person f l p.homeAddress p.phones
最初の2行ではnonEmpty1関数を使って空文字列でないことを検証しています。
もし入力が空ならnonEmpty1はLeft構築子で示されるエラーを返します。
そうでなければRight構築子で包まれた値を返します。
最後の2行では何の検証も実行せず、単にaddressフィールドとphonesフィールドを残りの引数としてperson関数へと提供しています。
この関数はPSCiでうまく動作するように見えますが、以前見たような制限があります。
> validatePerson $ person "" "" (address "" "" "") []
(Left "Field cannot be empty")
Either Stringアプリカティブ関手は最初に遭遇したエラーだけを返します。
仮にこの入力だったとすると、2つのエラーが分かったほうが良いでしょう。
1つは名前の不足で、2つ目は姓の不足です。
validationライブラリでは別のアプリカティブ関手も提供されています。
これはVという名前で、何らかの半群でエラーを返せます。
例えばV (Array String)を使うと、新しいエラーを配列の最後に連結していき、Stringの配列をエラーとして返せます。
Data.ValidationモジュールはData.AddressBookモジュールのデータ構造を検証するためにV (Array String)アプリカティブ関手を使っています。
Data.AddressBook.Validationモジュールから取材した検証器の例は次のようになります。
type Errors
= Array String
nonEmpty :: String -> String -> V Errors String
nonEmpty field "" = invalid [ "Field '" <> field <> "' cannot be empty" ]
nonEmpty _ value = pure value
lengthIs :: String -> Int -> String -> V Errors String
lengthIs field len value | length value /= len =
invalid [ "Field '" <> field <> "' must have length " <> show len ]
lengthIs _ _ value = pure value
validateAddress :: Address -> V Errors Address
validateAddress a =
address <$> nonEmpty "Street" a.street
<*> nonEmpty "City" a.city
<*> lengthIs "State" 2 a.state
またはアプリカティブdoで次のようにします。
validateAddressAdo :: Address -> V Errors Address
validateAddressAdo a = ado
street <- nonEmpty "Street" a.street
city <- nonEmpty "City" a.city
state <- lengthIs "State" 2 a.state
in address street city state
validateAddressはAddressの構造を検証します。
streetとcityが空でないかどうか、stateの文字列の長さが2であるかどうかを検証します。
nonEmptyとlengthIsの2つの検証関数が何れも、Data.Validationモジュールで提供されているinvalid関数をエラーを示すために使っているところに注目してください。
Array String半群を扱っているので、invalidは引数として文字列の配列を取ります。
PSCiでこの関数を試しましょう。
> import Data.AddressBook
> import Data.AddressBook.Validation
> validateAddress $ address "" "" ""
(invalid [ "Field 'Street' cannot be empty"
, "Field 'City' cannot be empty"
, "Field 'State' must have length 2"
])
> validateAddress $ address "" "" "CA"
(invalid [ "Field 'Street' cannot be empty"
, "Field 'City' cannot be empty"
])
これで、全ての検証エラーの配列を受け取ることができるようになりました。
正規表現検証器
validatePhoneNumber関数では引数の形式を検証するために正規表現を使っています。重要なのはmatches検証関数で、この関数はData.String.Regexモジュールで定義されているRegexを使って入力を検証しています。
matches :: String -> Regex -> String -> V Errors String
matches _ regex value | test regex value
= pure value
matches field _ _ = invalid [ "Field '" <> field <> "' did not match the required format" ]
繰り返しになりますが、pureは常に成功する検証を表しており、エラーの配列の伝達にはinvalidが使われています。
これまでと同様に、validatePhoneNumberはmatches関数から構築されています。
validatePhoneNumber :: PhoneNumber -> V Errors PhoneNumber
validatePhoneNumber pn =
phoneNumber <$> pure pn."type"
<*> matches "Number" phoneNumberRegex pn.number
またはアプリカティブdoで次のようにします。
validatePhoneNumberAdo :: PhoneNumber -> V Errors PhoneNumber
validatePhoneNumberAdo pn = ado
tpe <- pure pn."type"
number <- matches "Number" phoneNumberRegex pn.number
in phoneNumber tpe number
また、PSCiでいろいろな有効な入力や無効な入力に対して、この検証器を実行してみてください。
> validatePhoneNumber $ phoneNumber HomePhone "555-555-5555"
pure ({ type: HomePhone, number: "555-555-5555" })
> validatePhoneNumber $ phoneNumber HomePhone "555.555.5555"
invalid (["Field 'Number' did not match the required format"])
演習
- (簡単)正規表現
stateRegex :: Regexを書いて文字列が2文字のアルファベットであることを確かめてください。 手掛かり:phoneNumberRegexのソースコードを参照してみましょう。 - (普通)文字列全体が空白でないことを検査する正規表現
nonEmptyRegex :: Regexを書いてください。 手掛かり:この正規表現を開発するのに手助けが必要なら、RegExrをご確認ください。 素晴しい早見表と対話的なお試し環境があります。 - (普通)
validateAddressに似ていますが、上のstateRegexを使ってstateフィールドを検証し、nonEmptyRegexを使ってstreetとcityフィールドを検証する関数validateAddressImprovedを書いてください。 手掛かり:matchesの用例についてはvalidatePhoneNumberのソースを見てください。
巡回可能関手
残った検証器はvalidatePersonです。
これはこれまで見てきた検証器と以下の新しいvalidatePhoneNumbers関数を組み合わせてPerson全体を検証するものです。
validatePhoneNumbers :: String -> Array PhoneNumber -> V Errors (Array PhoneNumber)
validatePhoneNumbers field [] =
invalid [ "Field '" <> field <> "' must contain at least one value" ]
validatePhoneNumbers _ phones =
traverse validatePhoneNumber phones
validatePerson :: Person -> V Errors Person
validatePerson p =
person <$> nonEmpty "First Name" p.firstName
<*> nonEmpty "Last Name" p.lastName
<*> validateAddress p.homeAddress
<*> validatePhoneNumbers "Phone Numbers" p.phones
またはアプリカティブdoで次のようにします。
validatePersonAdo :: Person -> V Errors Person
validatePersonAdo p = ado
firstName <- nonEmpty "First Name" p.firstName
lastName <- nonEmpty "Last Name" p.lastName
address <- validateAddress p.homeAddress
numbers <- validatePhoneNumbers "Phone Numbers" p.phones
in person firstName lastName address numbers
validatePhoneNumbersはこれまでに見たことのない新しい関数であるtraverseを使っています。
traverseはData.TraversableモジュールのTraversable型クラスで定義されています。
class (Functor t, Foldable t) <= Traversable t where
traverse :: forall a b m. Applicative m => (a -> m b) -> t a -> m (t b)
sequence :: forall a m. Applicative m => t (m a) -> m (t a)
Traversableは 巡回可能関手
の型クラスを定義します。これらの関数の型は少し難しそうに見えるかもしれませんが、validatePersonは良いきっかけとなる例です。
全ての巡回可能関手はFunctorとFoldableのどちらでもあります(畳み込み可能関手は畳み込み操作に対応する型構築子であったことを思い出してください。
畳み込みとは構造を1つの値へと簡約するものでした)。
それに加えて、巡回可能関手はその構造に依存した副作用の集まりを組み合わせられます。
複雑そうに聞こえるかもしれませんが、配列の場合に特殊化して簡単にした上で考えてみましょう。配列型構築子はTraversableであり、つまりは次のような関数が存在するということです。
traverse :: forall a b m. Applicative m => (a -> m b) -> Array a -> m (Array b)
直感的にはこうです。
任意のアプリカティブ関手mと、型aの値を取って型bの値を返す(fで追跡される副作用を持つ)関数が与えられたとします。
このとき、その関数を型Array aの配列のそれぞれの要素に適用して型Array bの(fで追跡される副作用を持つ)結果を得ることができます。
まだよくわからないでしょうか。それでは更に、fを上記のV Errorsアプリカティブ関手に特殊化して考えてみましょう。これで次の型を持つ関数が得られます。
traverse :: forall a b. (a -> V Errors b) -> Array a -> V Errors (Array b)
この型シグネチャでは、型aについての検証関数mがあれば、traverse mは型Array aの配列についての検証関数であると書かれています。
ところがこれは正にPersonデータ構造体のphonesフィールドを検証できるようにするのに必要なものです。
各要素が成功するかを検証する検証関数を作るために、validatePhoneNumberをtraverseへ渡しています。
一般に、traverseはデータ構造の要素を1つずつ辿っていき、副作用を伴いつつ計算し、結果を累算します。
Traversableのもう1つの関数、sequenceの型シグネチャには見覚えがあるかもしれません。
sequence :: forall a m. Applicative m => t (m a) -> m (t a)
実際、先ほど書いたcombineList関数はTraversable型クラスのsequence関数の特別な場合に過ぎません。
tを型構築子Listだとすると、combineList関数の型が復元されます。
combineList :: forall f a. Applicative f => List (f a) -> f (List a)
巡回可能関手はデータ構造走査の考え方を見据えたものです。
これにより作用のある計算の集合を集めてその作用を結合します。
実際、sequenceとtraversableはTraversableを定義する上でどちらも同じくらい重要です。
これらはお互いがお互いを利用して実装できます。
これについては興味ある読者への演習として残しておきます。
Data.Listで与えられているリストのTraversableインスタンスは次の通り。
instance Traversable List where
-- traverse :: forall a b m. Applicative m => (a -> m b) -> List a -> m (List b)
traverse _ Nil = pure Nil
traverse f (Cons x xs) = Cons <$> f x <*> traverse f xs
(実際の定義は後にスタック安全性を向上するために変更されました。その変更についてより詳しくはこちらで読むことができます)
入力が空のリストのときには、pureを使って空のリストを返せます。
リストが空でないときは、関数fを使うと先頭の要素から型f bの計算を作成できます。
また、尾鰭に対してtraverseを再帰的に呼び出せます。
最後に、アプリカティブ関手mまでCons構築子を持ち上げて、2つの結果を組み合わせられます。
巡回可能関手の例はただの配列やリスト以外にもあります。
以前に見たMaybe型構築子もTraversableのインスタンスを持っています。
PSCiで試してみましょう。
> import Data.Maybe
> import Data.Traversable
> import Data.AddressBook.Validation
> traverse (nonEmpty "Example") Nothing
pure (Nothing)
> traverse (nonEmpty "Example") (Just "")
invalid (["Field 'Example' cannot be empty"])
> traverse (nonEmpty "Example") (Just "Testing")
pure ((Just "Testing"))
これらの例では、Nothingの値の走査は検証なしでNothingの値を返し、Just xを走査するとxを検証するのに検証関数が使われるということを示しています。
要は、traverseは型aについての検証関数を取り、Maybe aについての検証関数、つまり型aの省略可能な値についての検証関数を返すのです。
他の巡回可能関手には、任意の型aについてのArray a、Tuple a、Either aが含まれます。
一般に、「容器」のようなほとんどのデータ型構築子はTraversableインスタンスを持っています。
一例として、演習には二分木の型のTraversableインスタンスを書くことが含まれます。
演習
-
(簡単)
EqとShowインスタンスを以下の2分木データ構造に対して書いてください。data Tree a = Leaf | Branch (Tree a) a (Tree a)これらのインスタンスを手作業で書くこともできますし、コンパイラに導出してもらうこともできることを前の章から思い起こしてください。
Showの出力には多くの「正しい」書式の選択肢があります。 この演習のテストでは以下の空白スタイルを期待しています。 これは一般化されたshowの既定の書式と合致しているため、このインスタンスを手作業で書くつもりのときだけ、このことを念頭に置いておいてください。(Branch (Branch Leaf 8 Leaf) 42 Leaf) -
(普通)
TraversableインスタンスをTree aに対して書いてください。 これは副作用を左から右に結合するものです。 手掛かり:Traversableに定義する必要のある追加のインスタンス依存関係が幾つかあります。 -
(普通)行き掛け順に木を巡回する関数
traversePreOrder :: forall a m b. Applicative m => (a -> m b) -> Tree a -> m (Tree b)を書いてください。 つまり作用の実行は根左右と行われ、以前の通り掛け順の巡回の演習でしたような左根右ではありません。 手掛かり:追加でインスタンスを定義する必要はありませんし、前に定義した関数は何も呼ぶ必要はありません。 アプリカティブdo記法 (ado) はこの関数を書く最も簡単な方法です。 -
(普通)木を帰り掛け順に巡回する関数
traversePostOrderを書いてください。作用は左右根と実行されます。 -
(普通)
homeAddressフィールドが省略可能(Maybeを使用)な新しい版のPerson型をつくってください。 それからこの新しいPersonを検証する新しい版のvalidatePerson(validatePersonOptionalAddressと改名します)を書いてください。 手掛かり:traverseを使って型Maybe aのフィールドを検証してください。 -
(難しい)
sequenceのように振る舞う関数sequenceUsingTraverseを書いてください。 ただしtraverseを使ってください。 -
(難しい)
traverseのように振る舞う関数traverseUsingSequenceを書いてください。 ただしsequenceを使ってください。
アプリカティブ関手による並列処理
これまでの議論では、アプリカティブ関手がどのように「副作用を結合」させるかを説明するときに、「結合」(combine) という単語を選びました。
しかし、これらの全ての例において、アプリカティブ関手は作用を「連鎖」(sequence) させる、というように言っても同じく妥当です。
巡回可能関手がデータ構造に従って作用を順番に結合させるsequence関数を提供していることと、この直感的理解とは一致するでしょう。
しかし一般には、アプリカティブ関手はこれよりももっと一般的です。 アプリカティブ関手の規則は、その計算の副作用にどんな順序付けも強制しません。 実際、並列に副作用を実行するアプリカティブ関手は妥当でしょう。
例えばV検証関手はエラーの配列を返しますが、その代わりにSet半群を選んだとしてもやはり正常に動き、このときどんな順序で各検証器を実行しても問題はありません。
データ構造に対して並列にこれの実行さえできるのです。
2つ目の例として、parallelパッケージは並列計算に対応するParallel型クラスを提供します。
Parallelは関数parallelを提供しており、何らかのApplicative関手を使って入力の計算の結果を並列に計算します。
f <$> parallel computation1
<*> parallel computation2
この計算はcomputation1とcomputation2を非同期に使って値の計算を始めるでしょう。そして両方の結果の計算が終わった時に、関数fを使って1つの結果へと結合するでしょう。
この考え方の詳細は、本書の後半で コールバック地獄 の問題に対してアプリカティブ関手を応用するときに見ていきます。
アプリカティブ関手は並列に結合できる副作用を捉える自然な方法です。
まとめ
この章では新しい考え方を沢山扱いました。
- アプリカティブ関手の概念を導入しました。 これは、関数適用の概念から副作用の観念を捉えた型構築子へと一般化するものです。
- データ構造の検証という課題をアプリカティブ関手やその切り替えで解く方法を見てきました。 単一のエラーの報告からデータ構造を横断する全てのエラーの報告へ変更できました。
Traversable型クラスに出会いました。巡回可能関手の考え方を内包するものであり、要素が副作用を持つ値の結合に使うことができる容れ物でした。
アプリカティブ関手は多くの問題に対して優れた解決策を与える興味深い抽象化です。 本書を通じて何度も見ることになるでしょう。 今回の場合、アプリカティブ関手は宣言的な流儀で書く手段を提供していましたが、これにより検証器がどうやって検証を実施するかではなく、何を検証すべきなのかを定義できました。 一般にアプリカティブ関手が領域特化言語を設計する上で便利な道具になることを見ていきます。
次の章では、これに関連する考え方であるモナドクラスを見て、住所録の例をブラウザで実行させられるように拡張しましょう。