Coproduct で型の直和型をつくる - 僕でもわかるCoproduct -
はじめに
先日 Tagless Final を合成したときのテストを考えていたとき、@goldarn_ring さんの『Freeモナドの合成』という記事で Coproduct
という概念を知りました。
記事のコードを参考にしたところ、自分のやりたかったことは実現できたのですが、そもそもCoproduct
が何なのか、どういう理屈で期待通り動いているのか最初よくわかりませんでした。
これで「ヨシ!」とするのはまずいので、写経したりテストを書きながらCoproduct
と(一方的に)親睦を深めたので、自分みたいなPureScript初学者が見てもわかるようにじっくりと書いてみようと思います。
話の流れ
話の流れとしては、まずCoproduct
で何ができるかや、どのように使うのかを説明します。
その後、どういう仕組になっているのかの詳しい解説に入っていきます。
Coproductで何ができるか
タイトルにあるように『型の直和型』を作って、それを利用することができます。
例えば2つの代数的データ型の直和型を作ることで、どちらの代数的データ型の値でも動く関数を作れます。
例えば型X
,Y
があったとき、次のような関数を定義できるようになります(擬似コード)。
exec :: XかYどちらか -> String
exec = ...
exec X -- Xで呼び出すことができる
exec Y -- Yでも呼び出すことができる
上記のXかYどちらか
を示す型を実現することができるのがCoproduct
です。
-- X Y Unit の Unit の部分の説明はあとで!
type XかYどちらか = Coproduct X Y Unit
何がうれしいか
たとえば冒頭で紹介した@goldarn_ringさんの記事で書かれているように、Freeモナドを組み合わせるときに使うとうれしいのではないでしょうか。
私の場合は冒頭に書いた通りTagless Finalを合成したときのテストを書くアプローチを模索していて必要になり使いました。
使ってみよう
Coproductの定義をみてみよう
Coproduct
を使うにあたって、まずは定義を見てみましょうか。
-- | `Coproduct f g` is the coproduct of two functors `f` and `g`
newtype Coproduct :: forall k. (k -> Type) -> (k -> Type) -> k -> Type
newtype Coproduct f g a = Coproduct (Either (f a) (g a))
型構築子Coproduct f g a
を見ると型引数に制約がありますね。
型引数のf
やg
は(k -> Type)
となっており、また型引数の3つ目がk
となっているので、f
やg
は任意の型k
を包むような型になっていないといけないのです。
例えば、次のような型X
やY
ですね。既存の型でいえばMaybe
やArray
などもそうでしょう。
newtype X a = X a
newtype Y a = Y a
このX
とY
のCoproduct
型を構築する場合、定義によると、X
型Y
型それぞれを構築する際の型引数は一致している必要があるので、例えば型引数をString
にしたCoproduct
は次のようになります。
type XYString = Coproduct X Y String
またデータコンストラクタの方はこうなっています。
Coproduct (Either (f a) (g a))
おなじみのEither
です。Either
自体が直和型なので、Coproduct
はEither
を利用して直和型を実現しているということですね。
余談ですが、Coproduct(余積)はProduct(積)の双対なのでProduct
も存在して、このような定義になっています。型構築子の部分はCoproduct
と同じですが、データコンストラクタの部分がTuple
になっています。
newtype Product :: forall k. (k -> Type) -> (k -> Type) -> k -> Type
newtype Product f g a = Product (Tuple (f a) (g a))
さーてこれでCoproduct
の構築の仕方がわかったぞ!
ということで、例として単純な型X
・Y
およびこれらのCoproduct
を使う次のような関数を作ってみました。
newtype X a = X a
newtype Y a = Y a
echoXorY :: Coproduct X Y String -> String
echoXorY = case _ of
(Coproduct (Left (X a))) -> "X: " <> a
(Coproduct (Right (Y a))) -> "Y: " <> a
Coproduct
を用いた関数を作るのは簡単で、Coproduct
の実体はEither
なので、上記のようにパターンマッチで書くだけです。
おーし、じゃあいっちょやって(呼び出して)みっか!
・・・・・・というのは残念ながらまだ早いです。
準備が足りないのです。
おめえの出番だぞ inj!!
Coproduct
を使用した関数を呼び出すにはinj
関数を使う必要があるのです。
ということでinj
の出番です。
満を持して登場したinj
ですが、使うのはカンタンです。
X
やY
などの型の値を生成してinj
に与えてやるだけです。
さきほど例に挙げた型と関数を再度登場させます。
newtype X a = X a
newtype Y a = Y a
echoXorY :: Coproduct X Y String -> String
echoXorY = case _ of
(Coproduct (Left (X a))) -> "X: " <> a
(Coproduct (Right (Y a))) -> "Y: " <> a
この関数を呼び出すのには、このようにinj
を呼び出してやればOKです。
echoXorY (inj (X "value"))
嬉しいことに、inj
の結果は、X
やY
をとる関数でも使うことができます。
echoX :: X String -> String
echoX (X v) = v
-- コンパイルエラーにならない
echoX (inj (X "value X"))
毎度inj
を呼び出すのは面倒くさいので、何度も使用する場合は関数を作ってしまった方がよいでしょう。
x :: forall g a. Inject X g => a -> g a
x a = inj (X a)
おーっと、ここでInject
なる型が出てきました。
実はこのInject
型こそ、上記のようなことを実現する上での立役者なのです。
一体どういうことか、Inject
のことを説明していきたいと思います。
が、その前に一旦ここまでをまとめたコードを載せておきます。
import Prelude
import Data.Either (Either(..))
import Data.Functor.Coproduct (Coproduct(..))
import Data.Functor.Coproduct.Inject (class Inject, inj)
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)
newtype X a = X a
newtype Y a = Y a
x :: forall g a. Inject X g => a -> g a
x a = inj (X a)
y :: forall g a. Inject Y g => a -> g a
y a = inj (Y a)
echoX :: X String -> String
echoX (X v) = v
echoY :: Y String -> String
echoY (Y v) = v
echoXorY :: Coproduct X Y String -> String
echoXorY = case _ of
(Coproduct (Left (X a))) -> "X: " <> a
(Coproduct (Right (Y a))) -> "Y: " <> a
spec :: Spec Unit
spec = do
describe "Coproduct Spec" do
it "X型を受け取る関数を呼び出すことができる" do
echoX (x "value X") `shouldEqual` "value X"
it "Y型を受け取る関数を呼び出すことができる" do
echoY (y "value Y") `shouldEqual` "value Y"
it "X型かY型のCoproductを受け取る関数を呼び出すことができる(X)" do
echoXorY (x "value") `shouldEqual` "X: value"
it "X型かY型のCoproductを受け取る関数を呼び出すことができる(Y)" do
echoXorY (y "value") `shouldEqual` "Y: value"
全部 Inject さんのおかげじゃないか
元の型のまま扱えるのも・・・
Coproduct
型として扱えるのも・・・
全部 Inject さんが居たからじゃないか・・・!
ということで、本格的にInject
を説明していきたいと思います。
理解するには、やはりまず定義を見ないとですね。
ということで定義です。
定義を見よう
class Inject :: forall k. (k -> Type) -> (k -> Type) -> Constraint
class Inject f g where
inj :: forall a. f a -> g a
prj :: forall a. g a -> Maybe (f a)
instance injectReflexive :: Inject f f where
inj = identity
prj = Just
else instance injectLeft :: Inject f (Coproduct f g) where
inj = Coproduct <<< Left
prj = coproduct Just (const Nothing)
else instance injectRight :: Inject f g => Inject f (Coproduct h g) where
inj = Coproduct <<< Right <<< inj
prj = coproduct (const Nothing) prj
型クラスInject
にはinj
とprj
という2つの関数が定義されていますが、今回はinj
に着目したいので、prj
の説明は端折ります。
Instance Chain
instanceの定義が次のようになっていますが、これはInstance Chainと呼ばれるものです。
instance xxx
else instance yyy
else instance zzz
どのようなケースで必要になるのか、ちょっと説明しておきましょう。
例えばこのような型クラスがあるとします。
class Echo a where
echo :: a -> Effect Unit
この型クラスのinstanceを定義する場合、次のように具体的な型を用いるのならば特に問題はおきません。
instance echoString :: Echo String where
echo a = log a
instance echoInt :: Echo Int where
echo a = logShow a
しかし次のように任意の型a
を指定した場合コンパイルエラーになります。
instance echoString :: Echo String where
echo a = log a
-- これは駄目
instance echoA :: Show a => Echo a where
echo a = logShow a
このようなときに使えるのがInstance Chainで、次のようにinstance宣言をelseで繋げます。
instance echoString :: Echo String where
echo a = log a
else
instance echoInt :: Show a => Echo a where
echo a = logShow a
この場合String
型の値に対しては上のinstanceが選択され、それ以外のShow
型に属する型の値に対しては下のinstanceが選択されます。
chainの中のinstanceの関係は上の例で説明した通り、より前に宣言されたinstanceから、そのinstanceが選択されか判断されていくという意味でフラットではありません(が、今回の例だとあまり関係ないですね)。
以上を踏まえてあらためてInject
のinstanceを見てみましょう。
injectReflexive
が選択されなかったら、injectLeft
。injectLeft
が選択されなかったらinjectRight
というようになっています。
では続いて、それぞれのinsntanceの意味・役割を調べていきます。
それぞれのinstanceの意味
まず定義を再掲します(prj関数は省略しています)。
class Inject :: forall k. (k -> Type) -> (k -> Type) -> Constraint
class Inject f g where
inj :: forall a. f a -> g a
instance injectReflexive :: Inject f f where
inj = identity
else instance injectLeft :: Inject f (Coproduct f g) where
inj = Coproduct <<< Left
else instance injectRight :: Inject f g => Inject f (Coproduct h g) where
inj = Coproduct <<< Right <<< inj
この3つのinstanceは何を意味しており、何を実現するためのものなのでしょうか?
これを理解するために、各instanceに対して次のような関数を書いてみます。
- prefixにinstanceの名前をつける(例: injectReflexiveInj)
-
inj
を適用した結果を返す -
inj
を適用する型は、すべてのinstanceの例で同じものを使う - 関数が返す型はそれぞれのinstanceごとに変える
(これは同じ値を返しているのに別の型として返せるということを示すためです)
ちなみにそれぞれの関数は、例とするinsntaceの定義なしではコンパイルエラーとなるような関数になっています(つまりそのinsntanceのおかげで実現できている)。
これらの関数を書く前に、何もinsntaceが定義されていない状態で、inj
が何を返すのかを簡単な例で確認してみます(型シグニチャはIDEの機能使って補完させると手っ取り早いです)。
example :: forall g. Inject Maybe g => g String
example = inj (Just "hoge")
この例では、Maybe String
型の値をinj
に与えています。
inj
の定義はinj :: forall a. f a -> g a
なので、f
がMaybe
、a
がString
となり、
結果型シグニチャはforall g. Inject Maybe g => g String
となりました。
上記の関数は、instanceが存在しない状態では、何の意味もない関数です。
が、injectReflexive
やinjectLeft
などのinstanceが定義されることによって、マジカルな関数になるのです。
前置きはここまでにして、そろそろ各insntanceごとの関数を書いていきます。
最初はinjectReflexive
です。一番上に宣言されているinstanceですね。
instance injectReflexive :: Inject f f where
inj = identity
型クラスにおけるinj
の定義はinj :: forall a. f a -> g a
でした。
この場合は、g
とf
は同じなので、inj
はf a
を返すわけです。型を変えないわけですね。実際このinsntanceのinj
はidentity
で値をそのまま返しています。
それでは、確認の関数を書いてみましょう。
injectReflexiveInj :: Maybe String
injectReflexiveInj = inj (Just "hoge")
この関数の場合、inj
はforall g. Inject Maybe g => g String
という関数になりますね。
そして関数の型アノテーションではMaybe String
型を返すと宣言しています。
つまりg
はMaybe
になります。ということはInject Maybe Maybe
つまり、Inject f f
のパターンすなわちinjectReflexive
にマッチします。マッチするinsntanceが存在するため、コンパイルエラーにはなりません。
これにより、ある型X
の値をinj
に与えたとしても、その結果を元の型X
を使う関数にそのまま渡すことができるようになったということです。
次にinjectLeft
です。
else instance injectLeft :: Inject f (Coproduct f g) where
inj = Coproduct <<< Left
prj = coproduct Just (const Nothing)
しつこいですが、inj
の定義はinj :: forall a. f a -> g a
なので、この場合のinj
はforall a. f a -> (Coproduct f g) a
となります。
確認の関数を書いてみます。
injectLeftInj :: Coproduct Maybe Identity String
injectLeftInj = inj (Just "hoge")
この関数の場合inj
はforall g. Maybe String -> (Coproduct Maybe g) String
という関数になりますね。この例ではg
をIdentity
としていますが、g
は任意の型なので何でも大丈夫です。
instance chainのパターンとしては、injectReflexive
にはマッチせずinjectLeft
にマッチしていますね。
inj
に対し最初の例と同じようにMaybe
を与えましたが、今度はCoprodct Maybe g
という解釈もできるようになりました。
最後にinjectRight
です。
else instance injectRight :: Inject f g => Inject f (Coproduct h g) where
inj = Coproduct <<< Right <<< inj
これで最後です。inj
の定義はinj :: forall a. f a -> g a
なので、この場合のinj
はforall a h. f a -> (Coproduct h g) a
となります。
inj
の実装はCoproduct <<< Right <<< inj
となっていますが、inj
はf a -> g a
という定義なので、inj
の結果のg a
をCoproduct <<< Right
に与えればCoproduct h g
のg
に型が合致します(h
は任意の型)。
確認の関数も書いてみます。
injectRightInj :: Coproduct (Const Void) Maybe String
injectRightInj = inj (Just "hoge")
この関数の場合inj
はforall h. Maybe String -> (Coproduct h Maybe) String
という関数になりますね。instance chainのパターンとしては、最後のinjectRight
にマッチします。
inj
に対しこれまでと同じようにMaybe
を与えましたが、今度はCoprodct h Maybe
という解釈もできるようになりました。
さぁーーー、これで、Inject
の「おかげ」で型X
の値を与えたinj
の結果は、X
型のままとしてもCoproduct X g
型としてもCoproduct f X
型としても扱えるようになりました。
Inject
スゴイ!!
長かった説明も以上となります。
真面目に読んでくれた方、お疲れ様&ありがとうございました。
おまけ(3つ以上のCoproduct)
ここまで紹介してきたCoproduct
の例は、2つの型のうちどちらかという型でしたが、これを3つ以上に増やすこともできます。
やり方は簡単で<\/>
で型をつないでやるだけです。使うときはネストさせてパターンマッチします。
以下はサンプルです。
import Prelude
import Data.Either (Either(..))
import Data.Functor.Coproduct (Coproduct(..))
import Data.Functor.Coproduct.Inject (class Inject, inj)
import Data.Functor.Coproduct.Nested (type (<\/>))
import Test.Spec (Spec, describe, it)
import Test.Spec.Assertions (shouldEqual)
newtype X a = X a
newtype Y a = Y a
newtype Z a = Z a
x :: forall g a. Inject X g => a -> g a
x a = inj (X a)
y :: forall g a. Inject Y g => a -> g a
y a = inj (Y a)
z :: forall g a. Inject Z g => a -> g a
z a = inj (Z a)
type XYZ = X <\/> Y <\/> Z
echoXorYorZ :: XYZ String -> String
echoXorYorZ = case _ of
(Coproduct (Left (X a))) -> "X: " <> a
(Coproduct (Right r)) -> case r of
(Coproduct (Left (Y a))) -> "Y: " <> a
(Coproduct (Right (Z a))) -> "Z: " <> a
spec :: Spec Unit
spec = do
describe "Coproduct Spec" do
it "X型かY型かZ型のCoproductを受け取る関数を呼び出すことができる(X)" do
echoXorYorZ (x "value") `shouldEqual` "X: value"
it "X型かY型のZ型のCoproductを受け取る関数を呼び出すことができる(Y)" do
echoXorYorZ (y "value") `shouldEqual` "Y: value"
it "X型かY型のZ型のCoproductを受け取る関数を呼び出すことができる(Z)" do
echoXorYorZ (z "value") `shouldEqual` "Z: value"
Discussion