実例によるPureScript
ウェブのための
第7章 Applicativeによる検証
7.1 この章の目標
この章では、Applicative型クラスによって表現されるApplicative関手(applicative functor)という重要な抽象化と新たに出会うことになります。名前が難しそうに思えても心配しないでください。フォームデータの検証という実用的な例を使ってこの概念を説明していきます。Applicative関手を使うと、大量の決まり文句を伴うような入力項目の内容を検証するためのコードを、簡潔で宣言的な記述へと変えることができるようになります。
また、Traversable関手(traversable functor)を表現するTraversableという別の型クラスにも出会います。現実の問題への解決策からこの概念が自然に生じるということがわかるでしょう。
この章では第3章に引き続き住所録を例として扱います。今回は住所録のデータ型を拡張し、これらの型の値を検証する関数を書きます。これらの関数は、例えばデータ入力フォームの一部で、使用者へエラーを表示するウェブユーザインタフェースで使われると考えてください。
7.2 プロジェクトの準備
この章のソース·コードは、次のふたつのファイルで定義されています。
src/Data/AddressBook.purssrc/Data/AddressBook/Validation.purs
このプロジェクトは多くのBower依存関係を持っていますが、その大半はすでに見てきたものです。新しい依存関係は2つです。
purescript-control-Applicativeのような型クラスを使用して制御フローを抽象化する関数が定義されていますpurescript-validation- この章の主題であるApplicativeによる検証 のための関手が定義されています。
Data.AddressBookモジュールには、このプロジェクトのデータ型とそれらの型に対するShowインスタンスが定義されており、Data.AddressBook.Validationモジュールにはそれらの型の検証規則含まれています。
7.3 関数適用の一般化
Applicative関手の概念を理解するために、まずは以前扱った型構築子Maybeについて考えてみましょう。
このモジュールのソースコードでは、次のような型を持つaddress関数が定義されています。
address :: String -> String -> String -> Address
この関数は、通りの名前、市、州という3つの文字列から型Addressの値を構築するために使います。
この関数は簡単に適用できますので、PSCiでどうなるか見てみましょう。
> import Data.AddressBook
> address "123 Fake St." "Faketown" "CA"
Address { street: "123 Fake St.", city: "Faketown", state: "CA" }
しかし、通り、市、州の三つすべてが必ずしも入力されないものとすると、三つの場合がそれぞれ省略可能であることを示すために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
このとき、引数のひとつ(市)が欠落していたので、結果はNothingになります。もし3つの引数すべてがJust構築子を使って与えられれば、結果は値を含むことになります。
> lift3 address (Just "123 Fake St.") (Just "Faketown") (Just "CA")
Just (Address { street: "123 Fake St.", city: "Faketown", state: "CA" })
lift3という関数の名前は、3引数の関数を持ち上げるために使用できることを示しています。関数を持ち上げる同様の関数で、引数の数が異なるものが、Control.Applyで定義されています。
7.4 任意個の引数を持つ関数の持ち上げ
これで、lift2やlift3のような関数を使えば、引数が2個や3個の関数を持ち上げることができるのはわかりました。でも、これを任意個の引数の関数へと一般化することはできるのでしょうか。
lift3の型を見てみるとわかりやすいでしょう。
> :type lift3
forall a b c d f. 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が定義しています。Preludeモジュールでは<$>を、mapの別名として、<*>をapplyの別名として定義しています。mapとよく似た型を持つ追加の関数applyが定義されています。mapとapplyの違いは、mapがただの関数を引数に取るのに対し、applyの最初の引数は型構築子fで包まれているという点です。これをどのように使うのかはこれからすぐに見ていきますが、その前にまずMaybe型についてApply型クラスをどう実装するのかを見ていきましょう。
instance functorMaybe :: Functor Maybe where
map f (Just a) = Just (f a)
map f Nothing = Nothing
instance applyMaybe :: Apply Maybe where
apply (Just f) (Just x) = Just (f x)
apply _ _ = Nothing
この型クラスのインスタンスが言っているのは、任意のオプショナルな値にオプショナルな関数を適用することができ、その両方が定義されている時に限り結果も定義される、ということです。
それでは、mapとapplyを一緒に使ってどうやって引数が任意個の関数を持ち上げるのかを見ていきましょう。
1引数の関数については、mapをそのまま使うだけです。
2引数の関数についても考えてみます。型a -> b -> cを持つカリー化された関数fがあるとしましょう。これは型a -> (b -> c)と同じですから、mapをfに適用すると型f a -> f (b -> c)の新たな関数を得ることになります。持ち上げられた(型f aの)最初の引数にその関数を部分適用すると、型f (b -> c)の新たな包まれた関数が得られます。それから、2番目の持ち上げられた(型f bの)引数へapplyを適用することができ、型f cの最終的な値を得ます。
まとめると、x :: f aとy :: f bがあるとき、式(f <$> x) <*> yの型はf cになります(この式はapply (map f x) yと同じ意味だということを思い出しましょう)。Preludeで定義された優先順位の規則に従うと、f <$> 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 (Address { street: "123 Fake St.", city: "Faketown", state: "CA" })
> address <$> Just "123 Fake St." <*> Nothing <*> Just "CA"
Nothing
このように、引数が異なる他のいろいろな関数をMaybe上に持ち上げてみてください。
7.5 Applicative型クラス
これに関連するApplicativeという型クラスが存在しており、次のように定義されています。
class Apply f <= Applicative f where
pure :: forall a. a -> f a
ApplicativeはApplyの下位クラスであり、pure関数が定義されています。pureは値を取り、その型の型構築子fで包まれた値を返します。
MaybeについてのApplicativeインスタンスは次のようになります。
instance applicativeMaybe :: Applicative Maybe where
pure x = Just x
Applicative関手は関数を持ち上げることを可能にする関手だと考えるとすると、pureは引数のない関数の持ち上げだというように考えることができます。
7.6 Applicativeに対する直感的理解
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がその例になっています。Nothingを何らかのxについてpure xというように書くことはできません。したがって、PureScriptは省略可能な値を表す新しい項Nothingを含むように拡大されたと考えることができます。
7.7 その他の作用について
それでは、他にもApplicative関手へと関数を持ち上げる例をいろいろ見ていきましょう。
次は、PSCiで定義された3つの名前を結合して完全な名前を作る簡単なコード例です。
> import Prelude
> fullName first middle last = last <> ", " <> first <> " " <> middle
> fullName "Phillip" "A" "Freeman"
Freeman, Phillip A
この関数は、クエリパラメータとして与えられた3つの引数を持つ、(とても簡単な!)ウェブサービスの実装であるとしましょう。使用者が3つの引数すべてを与えたことを確かめたいので、引数が存在するかどうかを表すMaybe型をつかうことになるでしょう。fullNameをMaybeの上へ持ち上げると、省略された引数を確認するウェブサービスを実装することができます。
> import Data.Maybe
> fullName <$> Just "Phillip" <*> Just "A" <*> Just "Freeman"
Just ("Freeman, Phillip A")
> fullName <$> Just "Phillip" <*> Nothing <*> Just "Freeman"
Nothing
この持ち上げた関数は、引数のいずれかがNothingならNothing返すことに注意してください。
これで、もし引数が不正ならWebサービスからエラー応答を送信することができるので、なかなかいい感じです。しかし、どのフィールドが間違っていたのかを応答で表示できると、もっと良くなるでしょう。
Meybe上へ持ち上げる代わりにEither String上へ持ち上げるようにすると、エラーメッセージを返すことができるようになります。まずは入力をEither Stringを使ってエラーを発信できる計算に変換する演算子を書きましょう。
> :paste
… withError Nothing err = Left err
… withError (Just a) _ = Right a
… ^D
注意:Either errApplicative関手において、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
> :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よりも強力なものが必要です。この章の後半でこの解決策を見ていきます。
7.8 作用の結合
抽象的にApplicative関手を扱う例として、Applicative関手fによって表現された副作用を総称的に組み合わせる関数をどのように書くのかをこの節では示します。
これはどういう意味でしょうか?何らかのaについて型f aの包まれた引数の配列があるとしましょう。型List (f a)の配列があるということです。直感的には、これはfによって追跡される副作用を持つ、返り値の型がaの計算の配列を表しています。これらの計算のすべてを順番に実行することができれば、List a型の結果の配列を得るでしょう。しかし、まだfによって追跡される副作用が残ります。つまり、元の配列の中の作用を「結合する」ことにより、型List (f a)の何かを型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を返します。オプショナルな結果を返す計算の配列は、そのすべての計算が結果を持っていたときに全体も結果を持っているという、オプショナルな値に対応したより大きな言語での振る舞いに対する直感的な理解とこれは一致しています。
しかも、combineArray関数はどんなApplicativeに対しても機能します!Either errを使ってエラーを発信するかもしれなかったり、r ->を使って大域的な状態を読み取る計算を連鎖させるときにもcombineArray関数を使うことができるのです。
combineArray関数については、後ほどTraversable関手について考えるときに再び扱います。
演習
-
(簡単)
lift2を使って、オプショナルな引数に対して働く、数に対する演算子+、-、*、/の持ち上げられたバージョンを書いてください。 -
(やや難しい) 上で与えられた
lift3の定義について、<$>と<*>の型が整合していることを確認して下さい。 -
(難しい) 次の型を持つ関数
combineMaybeを書いてください。combineMaybe : forall a f. (Applicative f) => Maybe (f a) -> f (Maybe a)この関数は副作用をもつオプショナルな計算をとり、オプショナルな結果をもつ副作用のある計算を返します。
7.9 Applicativeによる検証
この章のソースコードでは住所録アプリケーションで使われるいろいろなデータ型が定義されています。詳細はここでは割愛しますが、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
Person
{ firstName: "John",
, lastName: "Smith",
, address: Address
{ street: "123 Fake St."
, city: "FakeTown"
, state: "CA"
},
, phones: [ PhoneNumber
{ type: HomePhone
, number: "555-555-5555"
}
, PhoneNumber
{ type: CellPhone
, number: "555-555-0000"
}
]
}
前の章では型Personのデータ構造を検証するのにEither String関手をどのように使うかを見ました。例えば、データ構造の2つの名前を検証する関数が与えられたとき、データ構造全体を次のように検証することができます。
nonEmpty :: String -> Either String Unit
nonEmpty "" = Left "Field cannot be empty"
nonEmpty _ = Right unit
validatePerson :: Person -> Either String Person
validatePerson (Person o) =
person <$> (nonEmpty o.firstName *> pure o.firstName)
<*> (nonEmpty o.lastName *> pure o.lastName)
<*> pure o.address
<*> pure o.phones
最初の2行ではnonEmpty関数を使って空文字列でないことを検証しています。もし入力が空ならnonEMptyはエラーを返し(Left構築子で示されています)、そうでなければRight構築子を使って空の値(unit)を正常に返します。2つの検証を実行し、右辺の検証の結果を返すことを示す連鎖演算子*>を使っています。ここで、入力を変更せずに返す検証器として右辺では単にpureを使っています。
最後の2行では何の検証も実行せず、単にaddressフィールドとphonesフィールドを残りの引数としてperson関数へと提供しています。
この関数はPSCiでうまく動作するように見えますが、以前見たような制限があります。
> validatePerson $ person "" "" (address "" "" "") []
(Left "Field cannot be empty")
Either StringApplicative関手は遭遇した最初のエラーだけを返します。でもこの入力では、名前の不足と姓の不足という2つのエラーがわかるようにしたくなるでしょう。
purescript-validationライブラリは別のApplicative関手も提供されています。これは単にVと呼ばれていて、何らかの半群(Semigroup)でエラーを返す機能があります。たとえば、V (Array String)を使うと、新しいエラーを配列の最後に連結していき、Stringの配列をエラーとして返すことができます。
Data.ValidationモジュールはData.AddressBookモジュールのデータ構造を検証するためにV (Array String)Applicative関手を使っています。
Data.AddressBook.Validationモジュールにある検証の例としては次のようになります。
type Errors = Array String
nonEmpty :: String -> String -> V Errors Unit
nonEmpty field "" = invalid ["Field '" <> field <> "' cannot be empty"]
nonEmpty _ _ = pure unit
lengthIs :: String -> Number -> String -> V Errors Unit
lengthIs field len value | S.length value /= len =
invalid ["Field '" <> field <> "' must have length " <> show len]
lengthIs _ _ _ =
pure unit
validateAddress :: Address -> V Errors Address
validateAddress (Address o) =
address <$> (nonEmpty "Street" o.street *> pure o.street)
<*> (nonEmpty "City" o.city *> pure o.city)
<*> (lengthIs "State" 2 o.state *> pure o.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"
])
これで、すべての検証エラーの配列を受け取ることができるようになりました。
7.10 正規表現検証器
validatePhoneNumber関数では引数の形式を検証するために正規表現を使っています。重要なのはmatches検証関数で、この関数はData.String.Regexモジュールのて定義されているRegexを使って入力を検証しています。
matches :: String -> R.Regex -> String -> V Errors Unit
matches _ regex value | R.test regex value =
pure unit
matches field _ _ =
invalid ["Field '" <> field <> "' did not match the required format"]
繰り返しになりますが、pureは常に成功する検証を表しており、エラーの配列の伝達にはinvalidが使われています。
これまでと同じような感じで、validatePhoneNumberはmatches関数から構築されています。
validatePhoneNumber :: PhoneNumber -> V Errors PhoneNumber
validatePhoneNumber (PhoneNumber o) =
phoneNumber <$> pure o."type"
<*> (matches "Number" phoneNumberRegex o.number *> pure o.number)
また、PSCiでいろいろな有効な入力や無効な入力に対して、この検証器を実行してみてください。
> validatePhoneNumber $ phoneNumber HomePhone "555-555-5555"
Valid (PhoneNumber { type: HomePhone, number: "555-555-5555" })
> validatePhoneNumber $ phoneNumber HomePhone "555.555.5555"
Invalid (["Field 'Number' did not match the required format"])
演習
-
(簡単) 正規表現の検証器を使って、
Address型のstateフィールドが2文字のアルファベットであることを確かめてください。ヒント:phoneNumberRegexのソースコードを参照してみましょう。 -
(やや難しい)
matches検証器を使って、文字列に全く空白が含まれないことを検証する検証関数を書いてください。この関数を使って、適切な場合にnonEmptyを置き換えてください。
7.11 Traversable関手
残った検証器は、これまで見てきた検証器を組み合わせてPerson全体を検証するvalidatePersonです。
arrayNonEmpty :: forall a. String -> Array a -> V Errors Unit
arrayNonEmpty field [] =
invalid ["Field '" <> field <> "' must contain at least one value"]
arrayNonEmpty _ _ =
pure unit
validatePerson :: Person -> V Errors Person
validatePerson (Person o) =
person <$> (nonEmpty "First Name" o.firstName *>
pure o.firstName)
<*> (nonEmpty "Last Name" o.lastName *>
pure o.lastName)
<*> validateAddress o.address
<*> (arrayNonEmpty "Phone Numbers" o.phones *>
traverse validatePhoneNumber o.phones)
ここに今まで見たことのない興味深い関数がひとつあります。最後の行で使われているtraverseです。
traverseはData.TraversableモジュールのTraversable型クラスで定義されています。
class (Functor t, Foldable t) <= Traversable t where
traverse :: forall a b f. Applicative f => (a -> f b) -> t a -> f (t b)
sequence :: forall a f. Applicative f => t (f a) -> f (t a)
TraversableはTraversable関手の型クラスを定義します。これらの関数の型は少し難しそうに見えるかもしれませんが、validatePersonは良いきっかけとなる例です。
すべてのTraversable関手はFunctorとFoldableのどちらでもあります(Foldable 関手は構造をひとつの値へとまとめる、畳み込み操作を提供する型構築子であったことを思い出してください)。それ加えて、Traversable関手はその構造に依存した副作用のあつまりを連結する機能を提供します。
複雑そうに聞こえるかもしれませんが、配列の場合に特殊化して簡単に考えてみましょう。配列型構築子はTraversableである、つまり次のような関数が存在するということです。
traverse :: forall a b f. Applicative f => (a -> f b) -> Array a -> f (Array b)
直感的には、Applicative関手fと、型aの値をとり型bの値を返す(fで追跡される副作用を持つ)関数が与えられたとき、型[a]の配列の要素それぞれにこの関数を適用し、型[b]の(fで追跡される副作用を持つ)結果を得ることができます。
まだよくわからないでしょうか。それでは、更にfをV ErrorsApplicative関手に特殊化して考えてみましょう。traversableが次のような型の関数だとしましょう。
traverse :: forall a b. (a -> V Errors b) -> Array a -> V Errors (Array b)
この型シグネチャは、型aについての検証関数fがあれば、traverse fは型Array aの配列についての検証関数であるということを言っています。これはまさに今必要になっているPersonデータ構造体のphonesフィールドを検証する検証器そのものです!それぞれの要素が成功するかどうかを検証する検証関数を作るために、validatePhoneNumberをtraverseへ渡しています。
一般に、traverseはデータ構造の要素をひとつづつ辿っていき、副作用のある計算を実行して結果を累積します。
Traversableのもう一つの関数、sequenceの型シグネチャには見覚えがあるかもしれません。
sequence :: forall a f. (Applicative m) => t (f a) -> f (t a)
実際、先ほど書いたcombineArray関数はTraversable型のsequence関数が特殊化されたものに過ぎません。tを配列型構築子として、combineArray関数の型をもう一度考えてみましょう。
combineList :: forall f a. Applicative f => List (f a) -> f (List a)
Traversable関手は、作用のある計算の集合を集めてその作用を連鎖させるという、データ構造走査の考え方を把握できるようにするものです。実際、sequenceとtraversableはTraversableを定義するのにどちらも同じくらい重要です。これらはお互いが互いを利用して実装することができます。これについては興味ある読者への演習として残しておきます。
配列のTraversableインスタンスはData.Traversableモジュールで与えられています。traverseの定義は次のようになっています。
-- traverse :: forall a b f. Applicative f => (a -> f b) -> List a -> f (List b)
traverse _ Nil = pure Nil
traverse f (Cons x xs) = Cons <$> f x <*> traverse f xs
入力が空の配列のときには、単にpureを使って空の配列を返すことができます。配列が空でないときは、関数fを使うと先頭の要素から型f bの計算を作成することができます。また、配列の残りに対してtraverseを再帰的に呼び出すことができます。最後に、Applicative関手fまでcons演算子(:)を持ち上げて、2つの結果を組み合わせます。
Traversable関手の例はただの配列以外にもあります。以前に見たMaybe型構築子もTraversableのインスタンスを持っています。PSCiで試してみましょう。
> import Data.Maybe
> import Data.Traversable
> traverse (nonEmpty "Example") Nothing
(Valid Nothing)
> traverse (nonEmpty "Example") (Just "")
(Invalid ["Field 'Example' cannot be empty"])
> traverse (nonEmpty "Example") (Just "Testing")
(Valid (Just unit))
これらの例では、Nothingの値の走査は検証なしでNothingの値を返し、Just xを走査するとxを検証するのにこの検証関数が使われるということを示しています。つまり、traverseは型aについての検証関数をとり、Maybe aについての検証関数を返すのです。
他にも、何らかの型aについてのTuple aやEither aや、連結リストの型構築子ListといったTraversable関手があります。一般的に、「コンテナ」のようなデータの型構築子は大抵Traversableインスタンスを持っています。例として、演習では二分木の型のTraversableインスタンスを書くようになっています。
演習
-
(やや難しい) 左から右へと副作用を連鎖させる、次のような二分木データ構造についての
Traversableインスタンスを書いてください。data Tree a = Leaf | Branch (Tree a) a (Tree a)これは木の走査の順序に対応しています。行きがけ順の走査についてはどうでしょうか。帰りがけ順では?
-
(やや難しい)
Data.Maybeを使ってPersonのaddressフィールドを省略可能になるようにコードを変更してください。ヒント:traverseを使って型Maybe aのフィールドを検証してみましょう。 -
(難しい)
traverseを使ってsequenceを書いてみましょう。また、sequenceを使ってtraverseを書けるでしょうか?
7.12 Applicative関手による並列処理
これまでの議論では、Applicative関手がどのように「副作用を結合」させるかを説明するときに、「結合」(combine)という単語を選びました。しかしながら、これらのすべての例において、Applicative関手は作用を「連鎖」(sequence)させる、というように言っても同じく妥当です。Traverse関手はデータ構造に従って作用を順番に結合させるsequence関数を提供する、という直感的理解とこれは一致するでしょう。
しかし一般には、Applicative関手はこれよりももっと一般的です。Applicative関手の規則は、その計算を実行する副作用にどんな順序付けも強制しません。実際、並列に副作用を実行するためのApplicative関手というものは妥当になりえます。
たとえば、V検証関手はエラーの配列を返しますが、その代わりにSet半群を選んだとしてもやはり正常に動き、このときどんな順序でそれぞれの検証器を実行しても問題はありません。データ構造に対して並列にこれを実行することさえできるのです!
別の例とし、purescript-parallelパッケージは、並列計算をサポートするParallel型クラスを与えます。非同期計算を表現する型構築子parallelは、並列に結果を計算するApplicativeインスタンスを持つことができます。
f <$> parallel computation1
<*> parallel computation2
この計算は、computation1とcomputation2を非同期に使って値を計算を始めるでしょう。そして両方の結果の計算が終わった時に、関数fを使ってひとつの結果へと結合するでしょう。
この考え方の詳細は、本書の後半でコールバック地獄の問題に対してApplicative関手を応用するときに見ていきます。
Applicative関手は並列に結合されうる副作用を捕捉する自然な方法です。
まとめ
この章では新しい考え方をたくさん扱いました。
- 関数適用の概念を副作用の考え方を表現する型構築子へと一般化する、Applicative関手の概念を導入しました。
- データ構造の検証という課題にApplicative関手がどのような解決策を与えるか、単一のエラーの報告からデータ構造を横断するすべてのエラーの報告へ変換できるApplicative関手を見てきました。
- 要素が副作用を持つ値の結合に使われることのできるコンテナであるTraversable関手の考え方を表現する、
Traversable型クラス導入しました。
Applicative関手は多くの問題に対して優れた解決策を与える興味深い抽象化です。本書を通じて何度も見ることになるでしょう。今回は、どうやって検証を行うかではなく、何を検証器が検証すべきなのかを定義することを可能にする、宣言的なスタイルで書く手段をApplicative関手は提供しました。一般に、Applicative関手は領域特化言語の設計のための便利な道具になります。
次の章では、これに関連するモナドという型クラスについて見ていきましょう。