SOPを使ってジェネリックにCase Analysis関数を実装する
case analysis関数が何かについては、まず kakkun61氏 の以下の記事を参照してください。
case analysis関数はデータ型毎に定まる関数ですがよくよく見てみるとシンプルなルールで統一的に実装できそうです。
Haskellで同じ名前の関数を使って複数のデータ型を扱えるようにするには、モジュールを分けたり型クラスを利用したり工夫する必要があります。
この記事ではジェネリックプログラミングの考え方に基づき、様々なデータ型に対応した一つのcase analysis関数を実装してみたいと思います。
これから実装するgfold'
(generic fold)という関数は以下のような振る舞いをするようになります。
> :t unFun . gfold' @Bool
unFun . gfold' @Bool :: Bool -> r -> r -> r
> :t unFun . gfold' @(Maybe Bool)
unFun . gfold' @(Maybe Bool) :: Maybe Bool -> r -> (Bool -> r) -> r
> :t unFun . gfold' @(Either Bool Int)
unFun . gfold' @(Either Bool Int)
:: Either Bool Int -> (Bool -> r) -> (Int -> r) -> r
SOP
SOPは sums of products
の略でデータ型を直積の直和に分解する考え方です。SOPの論文で解説されている実装をライブラリにしたものがgenerics-sop
です。
直和や直積といったワードに慣れない方はまずは以下の記事を参照してみてください。
通常、代数的データ型の文脈で直和や直積と言うとEither
とタプル(,)
を使って表現しますが、実際に使うデータ型では複数の型による直積と直和を考えることが多いためSOPでは n-ary sums を表すNS
と n-ary products を表す NP
という型を使います(SOPが sum"s" of product"s" の略であることを思い出してください)。どちらも型レベルリストによって直積あるいは直和される型を管理しています。
実際にSOPを使って得られるデータ型の表現を見てみましょう。
> from (Just 'a')
SOP (S (Z (I 'a' :* Nil)))
GHC.Generics と違ってメタ情報が含まれていないため、単純に直和と直積の構造を扱いたい場合はSOPを使うのがシンプルでしょう。
> :t from (Just 'a')
from (Just 'a') :: SOP I '[ '[], '[Char]]
型を見てみるとネストされた型レベルリストがあることが分かります。外側の型レベルリストが直和に、内側の型レベルリストが直積に対応しています。 これはMaybe型の表現なので'[]
はNothing
に、'[Char]
はJust Char
に対応しているというわけです。詳しく実装を見てみましょう。
-- | ジェネリックなSOPの表現との相互変換を扱う型クラス
class ... => Generic (a :: Type) where
type Code a :: [[Type]]
from :: a -> Rep a
to :: Rep a -> a
-- | 型aのジェネリックな表現
type Rep a = SOP I (Code a)
-- | 直積の直和を表す型
newtype SOP (f :: k -> Type) (xss :: [[k]]) = SOP (NS (NP f) xss)
-- | 恒等関手
newtype I a = I a
Generics
の型クラス制約に関しては気にしなくていいので省略しました。ライブラリは標準的なほとんどの型に対してGenerics
のインスタンスを定義していますし、自分で定義した型に対してGenerics
のインスタンスを自動的に導出することも可能です。
N個の型の直和を表すNS
は以下の様に定義されています。
data NS :: (k -> Type) -> [k] -> Type where
Z :: f x -> NS f (x ': xs)
S :: NS f xs -> NS f (x ': xs)
ペアノの自然数のような実装になっていますね。これは型レベルリストは直和を表しているため値としてはリストに含まれるいずれかの型の値しか持っていないので何番目の型の値を持っているのかを表すために自然数のような実装になっています。例えば以下のようなChar
とInt
とString
の直和を表すことができます。
> Z (I 'a') :: NS I '[Char, Int, String]
Z (I 'a')
> S $ Z (I 1) :: NS I '[Char, Int, String]
S (Z (I 1))
> S . S $ Z (I "abc") :: NS I '[Char, Int, String]
S (S (Z (I "abc")))
次にN個の型の直積を表すNP
の実装を見てみましょう。
data NP :: (k -> Type) -> [k] -> Type where
Nil :: NP f '[]
(:*) :: f x -> NP f xs -> NP f (x ': xs)
これは言わずもがなへテロリストと同様の実装になっていますね。例えば以下のようなChar
とInt
とString
の直積を表すことができます。
> I 'a' :* I 1 :* I "abc" :* Nil :: NP I '[Char, Int, String]
I 'a' :* I 1 :* I "abc" :* Nil
ジェネリックなcase analysis関数の型
それではSOPを使ってジェネリックなcase analysis関数gfold
を実装していきましょう。
まず型a
に関するcase analysis関数の型を考えてみましょう。
gfold :: (直和の各成分を処理する関数の直積) -> a -> r
このように書けるはずですがカッコ内の関数の数はa
の形によって変わってくるため、このままでは表現することが難しいです。そこでa
の表現Rep a
がSOP I xss
だったとしてもう一度case analysis関数の型を考えてみましょう。
gfold :: SOP I xss -> Fun xss r
第一引数はa
の表現です。返り値の型であるFun
はこれから定義しますが、(直和の各成分を処理する関数の直積) -> r
を表していると考えてください。Fun
は型族を扱うための型であり、方針としてはa
のSOPの構造を反映したxss
を使って直和の各成分を処理する関数の直積
の型を型族によって求めようと考えます。
例えば型の直積'[a, b, c]
からr
への関数はカリー化を考えるとa -> b -> c -> r
となるでしょう。これを型族で実装すると以下のように書けます。
type family FunP (xs :: [Type]) r where
FunP '[] r = r
FunP (x ': xs) r = x -> FunP xs r
更に型の直和'[a, b, c]
からr
への関数は(a -> r, b -> r, c -> r)
と書けるでしょう。case analysis関数とし最終的に必要なのはこの対応の逆(a -> r, b -> r, c -> r) -> '[a, b, c] -> r
であり、'[a, b, c]
はSOPで得られているので(a -> r, b -> r, c -> r) -> r
を得るための型族を実装します。
type family FunS (xss :: [[Type]]) r where
FunS '[] r = r
FunS (xs ': xss) r = FunP xs r -> FunS xss r
内側のリストが直積であることも考慮に入れて実装にはFunP
も利用しています。
最後にFunS
は単射な型族ではないため型として取り扱うのが面倒です。なのでFunS
を単純にnewtype
によってラップしたFun
を用意しておきます。
newtype Fun xss r = Fun {unFun :: FunS xss r}
これで gfold
の型は完成です。次は実装に進みましょう。
ジェネリックなcase analysis関数の実装
gfold
は型レベルリストを型変数に持つため型クラスを使って帰納的に定義するのが良さそうです。
class GFold (xss :: [[Type]]) where
gfold :: SOP I xss -> Fun xss r
まずxss
が空リストの場合を考えましょう。これは型としては値を持たないVoid
に相当します。Void
のcase analysis関数は何も値を返さないundefined
ので実装としては以下のようにすると良さそうです。
instance GFold '[] where
gfold _ = Fun undefined
次にxss
が空リストではなく値を持つ場合を考えていきましょう。
instance GFold xss => GFold (xs ': xss) where
gfold (SOP (S xs)) = ...
gfold (SOP (Z x)) = ...
帰納的な定義を考えているのでtail
に相当する部分はGFold
のインスタンスになっていることを前提にしています。gfold
の実装は更に直和を表すNS
が何かの後続S
であるのか、Z
であるのかによって場合分けが行われます。後続S
である場合は帰納的にxs
に対して再びgfold
を行うだけです。
gfold (SOP (S xs)) = constFun (gfold (SOP xs))
ここでconstFun
は型レベルリストの型を合わせるための関数です。
constFun :: Fun xss r -> Fun (xs ': xss) r
constFun (Fun f) = Fun $ const f
ようするにxs
に対応する関数を無視するようにして型を合わせています。case analysisが直和で対応する関数だけ実行するという挙動がこれによって実現されるわけです。
次に直和で対応する関数を実行する実装に当たるZ
のケースを見てみましょう。
gfold (SOP (Z x)) = embed (Fun $ \f -> apply f x)
embed
とapply
は未定義なので後述します。直和の型に対応する値x
が存在した場合、Fun $ \f ->
によって対応する関数を取り出します。f
の型はa -> b -> c -> r
の様になっていてx
の型はNP I (xs :: [Type])
の様になっているので、関数適用するための工夫が必要です。そのため以下のような型クラスを用意します。
class Apply (xs :: [Type]) where
apply :: FunP xs r -> NP I xs -> r
instance Apply '[] where
apply r _ = r
instance Apply xs => Apply (x ': xs) where
apply f ((I x) :* xs) = apply (f x) xs
これを使ってapply f x
とすることでf
をx
によって評価することができます。
Fun $ \f -> apply f x
の型は結局Fun '[xs] r
になりますが、型を合わせるためにはFun (xs ': xss) r
にする必要があります。そこで必要になるのがembed
です。embed
も型クラスによって以下のように実装されています。
class Embed (xss :: [[Type]]) where
embed :: Fun (xs ': '[]) r -> Fun (xs ': xss) r
instance Embed '[] where
embed = id
instance Embed xss => Embed (xs ': xss) where
embed = flipFun . constFun . embed
flipFun :: Fun (xs ': ys ': xss) r -> Fun (ys ': xs ': xss) r
flipFun f = Fun $ \ys xs -> unFun f xs ys
つまりcase analysis関数として対応する関数で評価した後に並んでいる関数は全て無視するconstFun
様な実装になっています。
以上により最終的なgfold
の実装は以下のようになります。
class GFold (xss :: [[Type]]) where
gfold :: SOP I xss -> Fun xss r
instance GFold '[] where
gfold _ = Fun undefined
instance (Apply xs, Embed xss, GFold xss) => GFold (xs ': xss) where
gfold (SOP (S xs)) = constFun (gfold (SOP xs))
gfold (SOP (Z x)) = embed (Fun $ \f -> apply f x)
constFun :: Fun xss r -> Fun (xs ': xss) r
constFun (Fun f) = Fun $ const f
class Apply (xs :: [Type]) where
apply :: FunP xs r -> NP I xs -> r
instance Apply '[] where
apply r _ = r
instance Apply xs => Apply (x ': xs) where
apply f ((I x) :* xs) = apply (f x) xs
class Embed (xss :: [[Type]]) where
embed :: Fun (xs ': '[]) r -> Fun (xs ': xss) r
instance Embed '[] where
embed = id
instance Embed xss => Embed (xs ': xss) where
embed = flipFun . constFun . embed
flipFun :: Fun (xs ': ys ': xss) r -> Fun (ys ': xs ': xss) r
flipFun f = Fun $ \ys xs -> unFun f xs ys
実装したgfold
を使ってジェネリックなcase analysis関数を実装しましょう。
gfold' :: (GFold (Code a), Generic a) => a -> Fun (Code a) r
gfold' = gfold . from
本当は gfold' = unFun . gfold . from
としたいところですが、Fun
の中身はa
が与えられるまで決まらないのでこの実装ではコンパイルを通すことはできません。
冒頭に上げた例をもう一度見てみましょう。
> :t unFun . gfold' @Bool
unFun . gfold' @Bool :: Bool -> r -> r -> r
> :t unFun . gfold' @(Maybe Bool)
unFun . gfold' @(Maybe Bool) :: Maybe Bool -> r -> (Bool -> r) -> r
> :t unFun . gfold' @(Either Bool Int)
unFun . gfold' @(Either Bool Int)
:: Either Bool Int -> (Bool -> r) -> (Int -> r) -> r
もちろん実際に実行することも可能です。
> (unFun . gfold') True 1 2
2
> (unFun . gfold') (Just 1) "empty" show
"1"
> (unFun . gfold') (Right "Haskell") (++ "??") (++ "!!")
"Haskell!!"
ここで実装したgfold'
はGeneric
のインスタンスにさえしてしまえば自前で実装した型にも適用することができます。
ところでリストについてはどうでしょうか?
> :t unFun . gfold' @[Int]
unFun . gfold' @[Int] :: [Int] -> r -> (Int -> [Int] -> r) -> r
リストのcase analysis関数を思い出すと
> :t GHC.OldList.foldr
GHC.OldList.foldr :: (a -> b -> b) -> b -> [a] -> b
となっていて処理を行う関数側にはリストの型が現れていないことが分かります。
これを実現するためにはRecursion SchemesにおけるCatamorphismという考え方に触れる必要があります。
cata :: (Base t a -> a) -> t -> a
リスト[a]
のBase [a]
に対応する型は
data ListF a b = Nil | Cons a b
という型であり、ListF
のcase analysis関数を考えると
(a -> b -> b) -> b -> ListF a b -> b
という型になることが分かります。これとcata
を組み合わせれば
(a -> b -> b) -> b -> [a] -> b
となりリストのcase analysis関数と一致することが分かります。
これをジェネリックに作るためには
distFun :: (a -> Fun xss r) -> Fun xss (a -> r)
mapFun :: (a -> b) -> Fun xss a -> Fun xss b
のような関数を実装した上で
gcata = mapFun cata (distFun gfold)
と実装すれば良さそうです。
ただ実装は大変そうなので今回は方針に触れるだけで終わりにしたいと思います。
\読んでいただきありがとうございました!/
この記事が面白かったら いいね♡ をいただけると嬉しいです☺️
バッジを贈っていただければ次の記事を書くため励みになります🙌
Discussion