Traversable API

与えられた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) }

userFieldsContainerで包めば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:厳密には順番を入れ替えることは可能だが、そのような使い方はまずありえない