与えられたConnection
を通じて、指定したKey
に対応するByteString
を取り出すような、シンプルなKey-ValueストアのAPIを考えてみよう。
type Key = ByteString fetchOne :: Connection -> Key -> IO ByteString
ネットワーク越しにたくさんのデータを取得したいとき、何度もこれを呼び出していては効率が悪い。一度にまとめて取り出せるように拡張するなら、このように書ける。
fetchMany :: Connection -> [Key] -> IO [ByteString]
悪くはないが、この型はたとえば「["foo", "bar"]
を要求したのに返ってきたのは[]
」のような振る舞いを許してしまうため、使い手に不必要なパターンマッチを強いる。だが、リスト[]
にちょっとした一般化を施すだけでそれを防ぐことが可能だ。
fetch :: Traversable t => Connection -> t Key -> IO (t ByteString)
リストが任意のTraversableになっているのがミソだ。TraversableはFunctorとFoldableのサブクラスで、各要素に対して作用を伴って関数適用し、元の構造を保ったまま返すような関数、traverse
を持つ。パラメトリシティのおかげで、勝手に要素を追加したり減らしたりするような振る舞いは許されない*1。Identity、Maybe、[]のような型はもちろん、data V3 a = V3 a a a
のような固定長のコンテナもインスタンスになる。
class (Functor t, Foldable t) => Traversable t where traverse :: Applicative f => (a -> f b) -> t a -> f (t b) ...
これだけでもfetchOneとfetchManyの一般化になるが、最近流行りのHKD(higher kinded datatype)とのコンボでさらなる力を発揮する。HKDは、パラメータとして与えられた型で各フィールドを包むことによって、同じデータ型に複数の役割を与えられるような手法である。例えばConst String
で包んで各フィールドの名前を表したり、Parser
で各フィールド用のパーサーを定義することができ、Identity
なら当然通常のデータ型と同型になる。
data User h = User { userId :: h Int , userName :: h Text } userFields :: User (Const Key) userFields = User (Const "userId") (Const "userName")
barbiesのData.Barbie.Containerは、このパラメータがConst
な場合にTraversable
として使えるようにする。
newtype Container b a = Container { getContainer :: b (Const a) }
userFields
をContainer
で包めばfetch
に渡せて、形を保ったままByteString
のレコードが返ってくるので、あとは煮るなり焼くなり好きにすればいい。
Container result :: Container User ByteString <- fetch conn $ Container userFields let user :: Maybe (User Identity) user = btraverseC @FromJSON (fmap Identity . decode . getConst) result
リクエストと同じ形のレスポンスが得られるという性質はGraphQLにも通じる。このような関手ライクなインターフェイスは、今後のAPI設計の鍵を握っているかもしれない。
*1:厳密には順番を入れ替えることは可能だが、そのような使い方はまずありえない