さいきんC#でgenericの再帰定義を使った型遊びをしていたのですが、これって型レベルプログラミングの一種なのでは?と気づいてそろそろ型レベルの概念をちゃんと覚えたくなってきたところに、@hiruberutoさんの「RowToListであそぼう!(苦悶)」というたのしい記事を見て「こんなことまでできるのか!」と興味を持ったのでぼくもRowToListで遊んでみることにしました。
propsってFFI使わないで定義できるのでは?
元記事のpropsはかなり使えそうな形をしていますが、対象がAff
固定なのと、実装がJavaScriptのFFIなのが気になりました。ぼくはJavaScriptを1行も書いたことがないのでこれからも1行も書かないまま生きていたいのです。
それは置いといて、要するに{ x :: m a, y :: m b }
をm { x :: a, y :: b }
にできればいいわけです。この形ってなんか見覚えがあるような?sequence :: t (m a) -> m (t a)
ですね。つまりRecordに対するTraversableみたいなものをつくればいいということだ!
一筋縄ではいかなかったですが、なんか、できました。
ついでにいろいろな知見を得られたので最後に書いています。
まずは全てのコードから
class RowTraversable list xs ys m f | list -> ys m where
traverseRow :: (RLProxy list) -> (m ~> f) -> { | xs } -> f { | ys }
instance rowTraversableNil :: Applicative f => RowTraversable RL.Nil xs () m f where
traverseRow _ _ _ = pure {}
instance rowTraversableCons ::
( IsSymbol key
, Row.Cons key (m a) xs' xs
, Row.Cons key a ys' ys
, Row.Lacks key ys'
, RowTraversable tail xs ys' m f
, Applicative f
) => RowTraversable (RL.Cons key (m a) tail) xs ys m f where
traverseRow _ f obj = Record.insert key <$> f (Record.get key obj) <*> traverseRow tail f obj
where
key = SProxy :: SProxy key
tail = RLProxy :: RLProxy tail
sequenceRow
:: forall rowList mr r m
. RL.RowToList mr rowList
=> RowTraversable rowList mr r m m
=> { | mr } -> m { | r }
sequenceRow = traverseRow (RLProxy :: RLProxy rowList) identity
props
:: forall rowList mr r m f
. RL.RowToList mr rowList
=> RowTraversable rowList mr r m f
=> Parallel f m
=> { | mr } -> m { | r }
props = sequential <<< traverseRow (RLProxy :: RLProxy rowList) parallel
とりあえず解説は後にして、
使ってみる
-- {x :: Maybe Int, y :: Maybe String } -> Maybe { x :: Int, y :: String }
> sequenceRow { x: Just 123, y: Just "abc" }
Just { x: 123, y: "abc" }
> sequenceRow { x: Just 123, y: Nothing :: Maybe String }
Nothing -- 1個でもNothingがあればNothingになる
-- Arrayの場合でも……?
> sequenceRow { x: [1, 2, 3], y: ["a", "b"] }
[{ x: 1, y: "a" },{ x: 1, y: "b" },{ x: 2, y: "a" },{ x: 2, y: "b" },{ x: 3, y: "a" },{ x: 3, y: "b" }]
props
くんは元記事と同じ動きをします。Parallel
に一般化してるのでAff以外も入ります!
classの定義
class RowTraversable list xs ys m f | list -> ys m where
traverseRow :: (RLProxy list) -> (m ~> f) -> { | xs } -> f { | ys }
list
はRecordのRowList
、xs
とys
は変換元/変換先のrow、m
とf
はコンテナの型です。
ふつうのtraverse
はa -> m b
の関数を取って全ての要素を変換できるんですが、Recordの場合はa
の型が一つにならないので、代わりにm ~> f
を置いてみました。わりと便利。かも。
list -> ys m
はFunctional Dependenciesの定義で、型のinputとoutputを区別します。今回はlist
が決まれば残りが一意に定まるという意味で、つまり、classのパラメータのうちys
m
は「出力変数」ってことになりますね。これを「型の関数」と見なして悪用するのが型レベルプログラミング……ということなのかしら?
インスタンスの定義
instance rowTraversableNil :: Applicative f => RowTraversable RL.Nil xs () m f where
traverseRow _ _ _ = pure {}
instance rowTraversableCons ::
( IsSymbol key
, Row.Cons key (m a) xs' xs
, Row.Cons key a ys' ys
, Row.Lacks key ys'
, RowTraversable tail xs ys' m f
, Applicative f
) => RowTraversable (RL.Cons key (m a) tail) xs ys m f where
traverseRow _ f obj = Record.insert key <$> f (Record.get key obj) <*> traverseRow tail f obj
where
key = SProxy :: SProxy key
tail = RLProxy :: RLProxy tail
うわーすごい。わけがわからん。一つずつ追っていきます。
再帰的定義
Nil
まずRowList
はkey :: Symbol
とvalue :: Type
のペアを1要素とした再帰リストとなっているので、これをRL.Cons
を使ったパターンマッチで型定義のところで分解しています。そうすると、再帰定義の終端があるわけで、それが、
RowTraversable RL.Nil xs () m f
です。
RL.Nil
が「空のRowList」で、これにマッチしたときys
が()
になる、という定義です。
関数の実装は、
traverseRow _ _ _ = pure {}
で、RowListが空なんだから空のRecordを返せばよいということですね。後々このnewobjを元にRecordを構築することになります。
Cons
Cons側のインスタンス定義は、
instance rowTraversableCons ::
( IsSymbol key
, Row.Cons key (m a) xs' xs
, Row.Cons key a ys' ys
, Row.Lacks key ys'
, RowTraversable tail xs ys' m f
, Applicative f
) => RowTraversable (RL.Cons key (m a) tail) xs ys m f
constraint多すぎてわけがわからない……一つずつ追います。
まずはIsSymbol key
ですが、これはkey
がSymbol
であることを表していて、Symbol
を用いる関数を呼ぶために必要となる制約です。直接の仕組みには関係ないものです。
次のRow.Cons
については、2つの役割があります。一つは「分解」で、Row.Cons key (m a) xs' xs
の行は「xs
とkey
が定まっている時に、(m a)
とxs'
が取り出せる」という意味になります。xs'
にはxs
からkey
の要素を取り除いたものが入ります。Listのパターンマッチで使う場合のconsですね。
もう一つの役割は「結合」で、Row.Cons key a ys' ys
の行では結合の用途で使っています。「ys'
があるときにkey
とa
を渡すと、ys
を取得できる」というもので、これはListを結合するときのconsに相当します。
と、ここでようやくRowListの使い道に気づいたんですが、Row.Cons
はあくまで「key
があらかじめ分かっている場合」に使えるもので、rowから「key
を取り出すことはできない」のです。そこで、RowToList
でrowをRowListに変換することで初めて未知のkey
を取り出すことができるようになるのです。これは便利。
次のRow.Lacks key row
は、rowに対してkey
の項目が存在しないことを保証する制約です。こちらも実装側の関数を呼ぶために必要になっています。
最後のRowTraversable tail xs ys' m f
が再帰的なパターンマッチです。tail
はRowListから1つ目の要素を取り出した残り(この後に書いてます)で、これをxs
といっしょに渡すとys'
が返ってくるということを宣言しています。マッチする度にtailが減っていくので、最終的にはRL.Nil
にマッチします。そのときにys'
が()
となるので、そこから戻ってくるときにRow.Cons key a ys' ys
で1個ずつ結合していくことになります。
そしてインスタンスの定義ですが、
RowTraversable (RL.Cons key (m a) tail) xs ys m f
これは、与えられたlist
をRL.Cons
でその場で分解しています。ここで未知のkey
とm a
を取得し、さらにkey
を取り除いた後のtail
が手に入ります。すごい!定義に書かれた通り、要素の型がm a
であるときだけマッチするので、この再帰定義は全てのrow要素がm なんとか
であるときだけ成立します。それ以外ではコンパイルエラーになる!すごいすごい!m
は定義中で固定していますが、a
にはどの型が入っていても成り立ちます。
ここでいったん型変数を整理しましょう。
パターンマッチ時に最初から与えられている型はkey
m
a
tail
xs
の5つです。前の4つは事前にxs
から作られるlist :: RowList
をRL.Cons
で分割するだけで得られるので、実質の型情報の大本はxs
になりますね。RL.Cons
のパワーがすごい。ここからys
の型を確定させなければなりません。
次に、RowTraversable tail xs ys' m f
の制約によってtail
からys'
m
が取得できます。わからなかったら上に書いてあるfundepの定義をもう一度見てみてください。
最後に、Row.Cons key a ys' ys
の制約によってys
が決まります。
これで定義に必要な型情報が手元に揃いました。
- input:
key
m
a
tail
xs
- output:
xs'(使用しない)
ys'
ys
f
に関しては、traverseRow
の引数によって指定されるためunknownのままで良いようです。わからない……。
ここから関数の定義ですが、
traverseRow _ f obj = Record.insert key <$> f (Record.get key obj) <*> traverseRow tail f obj
where
key = SProxy :: SProxy key
tail = RLProxy :: RLProxy tail
Applicativeで大分アレな見た目ですけど、単に「tail
から作ったnewobjに対して、objからRecord.get
で手に入れた値をf
にくぐらせて、Record.insert
で挿入」というのをしているだけです。traverseRow tail f obj
は最終的にRL.Nil
にマッチして{}
を返すので、再帰の戻り際に逆順で挿入を繰り返していることになります。
SProxy
はSymbolのProxy、RLProxy
はRowListのProxyです。Proxyは簡単に言うと型情報を関数の引数として渡すための入れ物ですね。例えばNothing :: Maybe a
を引数に渡すと、値はただのNothing
ですがこっそり型a
の情報を渡せる、みたいなイメージです。
ここで、Record操作による構築を行っていますが、Record系の関数を扱うにはrowの挿入や要素の抽出を型安全に行うためにIsSymbol
やRow.Cons
、Row.Lacks
による制約を付ける必要があります。obj :: Record xs
に対してRecord.get
を行うためにはRow.Cons key (m a) xs' xs
が必要なのはなんとなく分かるんじゃないでしょうか。そして、newobj :: Record ys'
に対するRecord.insert
を行うには、Row.Cons key a ys' ys
に加えてRow.Lacks key ys'
が必要になります。ys'
にkey
が存在しない場合に限り、key a
の要素をinsertできてys
が得られる、という意味になります。
使える
まずは冒頭に書いたsequence :: t (m a) -> m (t a)
に相当するものを作りました。
sequenceRow
:: forall rowList mr r m
. RL.RowToList mr rowList
=> RowTraversable rowList mr r m m
=> { | mr } -> m { | r }
sequenceRow = traverseRow (RLProxy :: RLProxy rowList) identity
ここでようやくRowToList
が出てきました。関数の引数は{ | mr }
で、これは「mr
のrowで構成されたRecord型」を表していて、つまり普通のRecordです。ここからmr
を手に入れることができて、それをRL.RowToList mr rowList
でrowList
に変換しています。mr
は# Type
で、rowList
はRowList
なので種類が違うのです。rowList
が手に入ってしまえば、あとはさっき定義したRowTraversable
に放り込むだけです。traverseRow
の引数m ~> f
は、この場合はidentity
を渡します。これで、{ x :: m a, ... } -> m { x :: a, ... }
という面白関数を最初から最後まで型安全で実装することができました。わーい!
props
:: forall rowList mr r m f
. RL.RowToList mr rowList
=> RowTraversable rowList mr r m f
=> Parallel f m
=> { | mr } -> m { | r }
props = sequential <<< traverseRow (RLProxy :: RLProxy rowList) parallel
props
のほうは、m ~> f
にparallel
を渡して、まとめた後にsequential
で戻す、をやってるだけなのでほとんど一緒ですね。
くるしい
これだけを書くのに何時間意味不明なエラーメッセージを見続けたのであたまがおかしくなってしまった。
でもこれを書いてから急にRowToList系の定義が読めるようになったので得られたものは思ったより多かった。MultiParamTypeClassが型の変換ルールを記述しているというのが見えるようになったのが大きい。
型でプログラミングするときに重要なのは、「どの型があらかじめ与えられていて、どの型が計算されるものか」を見極めることだということを理解するまでに時間を掛けすぎたのだ……。
さんこう
PureScriptに関しては、@hiruberutoさんがちょう丁寧に解説してるので、記事全部読むのがいいと思います。型レベルの話だとJustin-san(@kimagure)が最先端でいつも何言ってるのか全然わからないんですけど、それ以前に英語よめないマンなのでむしろソースコード直読みがめちゃ参考になりました。purescript-simple-jsonが素晴らしい。どうかしてる。
おわり
- RowToListはrowから未知の
key
を取り出すための手段 - 「与えられた型」と「計算された型」を区別し、見極める
- fundepの定義はinの型とoutの型を見分ける唯一の方法
-
Row.Cons
は分解と結合の両方に用いる - Recordを操作するには
purescript-record
の関数を使おう - 型安全なRecord操作をするには
Row.Cons
やRow.Lacks
による正しい制約が必要 - Proxyは型の情報を渡したいけど値は別に要らないときに使うもの
- kindの種類によって
Proxy
とかSProxy
とかRProxy
とかRLProxy
とかいろいろある
- kindの種類によって
- 型レベルプログラミングわからない。人間の書くものじゃない。
全体のコードはここに置いてます