拡張可能直和型を引数に取る関数の定義 (Haskell)
「extensible
パッケージの楽しみ その2」です.
拡張可能レコードやら Extensible Effect やら,Haskell の Extensible なものを全て統一された仕組みで提供する化け物パッケージ extensible
について,割とドキュメントには無い(?)ネタを書いておくシリーズ第二弾です. ぼく自身は作者ではないし,間違っているかもなのでこの記事を完全には当てにしないでください.
また,現在の最新バージョンは 0.4.7.1 です(そのバージョンでハナシをしてる).
前回は拡張可能レコードの拡縮の話を書きました. その最後の方に,おまけとして拡張可能直和型の話を書きました. 実際に自分のプログラムで,この拡張可能直和型を使おうとしてみたんですが,パターンマッチが分からず分岐が出来なかったので調べました,ってのが今回のハナシ.
今回の Haskell コードは基本的にコレ.
拡張可能直和型
ここでは,Haskell の代数的データ型において
data Color
= RGB Int Int Int
| CMYK Int Int Int Int
のように複数の値コンストラクタをとり得る型を直和型ということにします(あんまり一般的ではない?). このようなデータ型を extensible を使って書くと次のようになる.
type Color = Variant
"rgb" >: (Int,Int,Int)
'[ "cmyk" >: (Int,Int,Int,Int)
, ]
これが拡張可能直和型である(と呼んでいる). 例えば次のような,Color
型の構造的に部分型である RGB
型の値を spread
という関数を使って持ち上げることが出来る.
> type RGB = Variant '[ "rgb" >: (Int,Int,Int) ]
ghci> color1 = embedAssoc $ #rgb @= (0,0,0) :: RGB
ghci> color2 = spread color1 :: Color ghci
ちなみに「拡張可能直和型」って単語は,ほぼほぼ造語です(すいません). 作者さんは Extensible Variants と書いているので,正確には拡張可能バリアント型ですかね. ML 系では「バリアント型」って単語は良く使われてますが,Haskell では馴染みが無いので「直和型」って単語を用います.
パターンマッチ
ここからが本題. 例として次のような図形の型を用いる.
data Shape
= Circle Point Double
| Rect Point Point
type Point = (Double, Double)
Circle
が円で中心点の座標と半径を持ち,Rect
が矩形で左下と右上の座標を持つ. 例えば,図形の面積を求める関数 area
を書くときはパターンマッチを用いて次のように書きますよね.
area :: Shape -> Double
Circle _ r) = pi * (r ^ 2)
area (Rect (x1,y1) (x2,y2)) = abs (x2 - x1) * abs (y2 - y1) area (
では,この Shape
型を拡張可能直和型で書いてみる.
type Shape = Variant
"circle" >: Circle
'[ "rect" >: Rect
,
]
type Point = Record '[ "x" >: Double, "y" >: Double ]
newtype Circle =
Circle (Record '[ "mid" >: Point, "r" >: Double ]) deriving (Show, Eq)
newtype Rect =
Rect (Record '[ "ll" >: Point, "ur" >: Point ]) deriving (Show, Eq)
Record
は拡張可能レコードだ. 後の話の流れのために newtype
で Circle
型と Rect
型を定義したが,別に型エイリアスでもいい.
問題はココからで,どうやって area
関数を定義するか. (作者さんに聞いてしまえば早いんだけど)パッケージを漁ってたらそれらしい関数 match
があった. イロイロ試した結果,match
よりも matchField
の方が使いやすかったので,こっちを使って次のように書ける.
area :: Shape -> Double
= matchField
area $ #circle @= (\(Circle s) -> pi * (s ^. #r) ^ 2)
<: #rect @= ((*) <$> width <*> height)
<: nil
height :: Rect -> Double
width,Rect s) = abs $ s ^. #ur ^. #x - s ^. #ll ^. #x
width (Rect s) = abs $ s ^. #ur ^. #y - s ^. #ll ^. #y height (
意外と簡単にできた!
(なんでこれでうまくいくかを説明しようと思ったけど,ぼくには説明するのが難しすぎてやめた.) (ちなみに,すごい簡潔に書けるのは Wrapper
型クラスのおかげだと思う.)
型クラスを使う
ここからが拡張可能直和型のすごいところ(パターンマッチしかできないなら普通の直和型でもいいもんね...). 型クラスを用いて次のようにも area
関数を定義できる.
class Area a where
area :: a -> Double
instance Area Circle where
Circle s) = pi * (s ^. #r) ^ 2
area (
instance Area Rect where
= (*) <$> width <*> height
area
instance Forall (KeyValue KnownSymbol Area) xs => Area (Variant xs) where
= matchField $
area Proxy :: Proxy (KeyValue KnownSymbol Area)) $
htabulateFor (-> Field (Match $ area . runIdentity) \_
Forall (KeyValue KnownSymbol Area) xs
という型制約の直観的な解釈は,型レベルリスト xs
の全ての要素 k >: v
が KnownSymbol k
と Area v
を満たす,という意味だ. 即ち,拡張可能直和型の全ての要素が Area
型クラスのインスタンスであれば,その拡張可能直和型は Area
型クラスのインスタンスになり得るのだ. ちゃんと次のように動作する.
> shape1
ghciEmbedAt $(mkMembership 0) (circle @= Circle (mid @= (x @= 1.0 <: y @= 2.0 <: nil) <: r @= 2.0 <: nil))
> :t shape1
ghcishape1 :: Shape
> area shape1
ghci12.566370614359172
これの何がすごいかと言うと,例えば次のように Shape
型を拡張しよう.
newtype Triangle =
Triangle (Point, Point, Point) deriving (Show, Eq)
type Shape = Variant
"circle" >: Circle
'[ "rect" >: Rect
, "triangle" >: Triangle
, ]
一般的な直和型の場合は,Shape
型のパターンマッチしている部分を全て増やさないといけない. しかし,型クラスを用いた拡張可能直和型の関数であれば,次のようにインスタンスを増やすだけで良い.
instance Area Triangle where
Triangle (p1, p2, p3)) =
area (abs ((p1 ^. #x - p3 ^. #x) * (p2 ^. #y - p3 ^. #y) - (p2 ^. #x - p3 ^. #x) * (p1 ^. #y - p3 ^. #y)) / 2
書き込んでる行数は大差ないかもしれないが,例えばファイルを分けたりなど,より柔軟に関数を定義できる!
おしまい
ちなみに,area
関数は a -> Double
で返り値の型が Double
だったから楽だった. これが,例えば座標移動をする nudge :: a -> Point -> a
のように,返り値が a
なものは Variant
のインスタンスを定義するのが大変(頑張った).