Prismメモ
前回の続きで、今回はPrismについて調べたのでそれについて記す。(lensパッケージ バージョン4.7に基づく)
前回書いたように、PrismはTraversalの一種であり、全体データの中の0個もしくは1個の部分データに着目する。また、Prismは「部分データから全体データを復元することができる」という、Lensにはない特徴を持つ。(そのため、Prismでは「全体」と「部分」という言い方は不適切かもしれない)
>>> (Right "hoge") ^. _Right "hoge" >>> "hoge" ^. re _Right Right "hoge"
この、Prismの効果を「逆転」する関数であるre
がどういう仕組みになっているか気になったので、lensのマニュアルやソースコードを読んだ。
Prismの型
Prismのデータ型は以下のようになっている。
type Prism s t a b = forall p f. (Choice p, Applicative f) => p a (f b) -> p s (f t)
ちなみにTraversalのデータ型はこうである。
type Traversal s t a b = forall f. Applicative f => (a -> f b) -> s -> f t
Traversalと比べると、Prismは関数(->)
のかわりにChoice
という型クラスが使われていることが分かる。実際、型コンストラクタ(->)
はChoice
のインスタンスであるため、PrismはTraversalとしても使えるようになっている。
では、Choice
とは何か?Choice
はProfunctor
を拡張した型クラスであり、まずProfunctor
を理解する必要がある。
Profunctor
Profunctor
はData.Profunctorモジュールで定義されている型クラスである。
Profunctorはdimap
関数を持つ。
class Profunctor p where dimap :: (a -> b) -> (c -> d) -> p b c -> p a d
関数の型コンストラクタ(->)
はProfunctor
のインスタンスである。
instance Profunctor (->) where dimap ab cd bc = cd . bc . ab
関数をモチーフとして考えると、Profunctor
とは「2つの値の関係性を示すなにか」と考えることができる。dimap
関数とは、2つの関数ab
とcd
によって、Profunctor
の持つ「関係性」の左辺(定義域)と右辺(値域)を変換する操作であると言える。
ただし、このようなアナロジーはProfunctor
のインスタンスとして関数(->)
を考えた場合に成り立つものであり、他のインスタンスでは必ずしも成り立たない。
dimap
は左辺領域と右辺領域を両方同時に変換するが、左辺のみ変換するlmap
と右辺のみ変換するrmap
もProfunctor
に定義されている。これらはdimap
から作ることができる。
Choice
はProfunctor
を拡張した型クラスであり、left'
とright'
という関数を持つ。
class Profunctor p => Choice p where left' :: p a b -> p (Either a c) (Either b c) right' :: p a b -> p (Either c a) (Either c b) instance Choice (->) where left' ab (Left a) = Left (ab a) left' _ (Right c) = Right c right' = fmap
left'
およびright'
はProfunctor
のEither
型への自然な拡張である。これらはProfunctor
の持つ「値の関係」をEither
型のLeft
もしくはRight
にそれぞれ移す。移されなかった側は単にid
関数で写像される。
prismコンストラクタ
以上を踏まえ、Prismを作るコンストラクタ関数を見てみる。
prism :: (b -> t) -> (s -> Either t a) -> Prism s t a b prism bt seta = dimap seta (either pure (fmap bt)) . right'
prism
関数は二つの関数を素材としてPrismを作る。
bt :: (b -> t)
-- 「部分」から「全体」を作る関数seta :: (s -> Either t a)
-- 「全体」から「部分」を取り出す関数(戻り値がRight
の場合)。「部分」を取り出せなかった場合は「全体」をそのまま返す(戻り値がLeft
の場合)。
こうしてできるPrismの動作は以下のようになる。
Prismへの入力データの型はp a (f b)
である。まず、right'
関数によってこれがp (Either c a) (Either c (f b))
型へ変換される。
その後、dimap
によってp (Either c a) (Either c (f b))
の左辺領域と右辺領域が変換され、p s (f t)
となる。型推論により、型変数c
はt
となる。
prism
関数では、bt
(部分→全体)とseta
(全体→部分)を与えることで、right'
とdimap
を使ってPrismを定義している。そのため、Prismは(->)
以外のChoice
インスタンスに対しても適用することができる。
re関数
Prismの効果を逆転するre
関数はこのように定義されている。
re :: AReview t b -> Getter b t re p = to (runIdentity #. unTagged #. p .# Tagged .# Identity)
to
関数は通常の関数をLens化する関数であり、ここでは大した意味を持たない。重要なのはその引数であるカッコの中身である。
まず、見慣れない演算子(#.)
と(.#)
があるが、これはProfunctor
型クラスのメソッドであり、そのデフォルト実装は以下のようになっている。
infixr 9 #. ( #. ) :: (b -> c) -> p a b -> p a c ( #. ) = \f -> \p -> p `seq` rmap f p infixl 8 .# ( .# ) :: p b c -> (a -> b) -> p a c ( .# ) = \p -> p `seq` \f -> lmap f p
ざっくり言って、rmap
とlmap
の演算子バージョンということっぽいが、ドキュメントにはいろいろ不穏な文言が書かれているので、左辺(または右辺)領域の「単純な」型変換にのみ使うべきものなようだ。
また、Tagged
とunTagged
はTagged
型に関する関数である。
newtype Tagged s b = Tagged { unTagged :: b } retag :: Tagged s b -> Tagged t b retag = Tagged . unTagged instance Functor (Tagged s) where fmap f (Tagged x) = Tagged (f x) instance Profunctor Tagged where dimap _ f (Tagged s) = Tagged (f s) lmap _ = retag rmap = fmap ( #. ) _ = unsafeCoerce Tagged s .# _ = Tagged s instance Choice Tagged where left' (Tagged b) = Tagged (Left b) right' (Tagged b) = Tagged (Right b)
Tagged
型コンストラクタは2つの型引数を持つProfunctor
であるが、実際にデータとして保持するのは右辺領域(b
型)のデータのみである。そのため、rmap
では呼び出し元が与えたマップ関数f
がきちんと適用されるが、lmap
では与えられたマップ関数は豪快に無視される。
このへんの動作は前回紹介したConstant
ファンクターに似ている。Constant
ファンクターは第1型引数のデータを保持し、第2型引数はフェイクであった。一方、Tagged
は第1型引数がフェイクで第2型引数のデータを持つ。
以上を前提に、改めてre
関数のカッコの中身を見る。演算子の優先順位を解決すると、以下のようになる。
(((runIdentity #. (unTagged #. p)) .# Tagged) .# Identity)
この演算のそれぞれの段階で型がどう変換されるかを見ていく。まず、引数p
の型はAReview t b
であり、これは展開すると、
AReview t b = Optic' Tagged Identity t b = Tagged b (Identity b) -> Tagged t (Identity t)
となる。
これを起点として型を変換していく。
p0 = p :: Tagged b (Identity b) -> Tagged t (Identity t) p1 = unTagged #. p0 :: Tagged b (Identity b) -> Identity t p2 = runIdentity #. p1 :: Tagged b (Identity b) -> t p3 = p2 .# Tagged :: Identity b -> t p4 = p3 .# Identity :: b -> t
ここで注意したいのは、この一連の操作で(#.)
と(.#)
が作用しているのはTagged
Profunctorではなく、(->)
Profunctorである点である。これらは単に関数の定義域と値域から2つのnewtypeを引き剥がしている。その結果、「部分」から「全体」を作る関数(b -> t)
を得る。
なぜ(b -> t)
を取ってくることができるかというと、Tagged
が右辺領域の値しか持たないからだろう。Tagged
を適用した場合のPrismの型は以下のようになる。
Tagged a (f b) -> Tagged s (f t)
Tagged
は右辺領域の値しか持たないnewtypeなので、実質的にこれは
f b -> f t
と同じである。あとはf
として同様に単純なファンクターであるIdentity
を使えば(b -> t)
を取り出せる。
Iso
Prismはre
で効果を逆転できるが、その結果はPrismではない。したがって、re (re p)
はエラーとなる。
一方、再逆転可能なものとして、Isoというデータ型が定義されている。
- Control.Lens.Iso http://hackage.haskell.org/package/lens/docs/Control-Lens-Iso.html
IsoはLensとPrismの両方の性質を持つ。すなわち、
- 与えられた「全体」のうち、必ず1つの「部分」に着目する
- 「部分」から「全体」を構築できる
やはり「全体」と「部分」という言い方はここでは不適切であり、Isoは「本質的に同じデータの相互変換」を表すようだ。例えばByteString
とText
の相互変換などがIsoで表現される。
Isoのデータ型はこのようになっている。
type Iso s t a b = forall p f. (Profunctor p, Functor f) => p a (f b) -> p s (f t)
Prismと比べると、p
はChoice
ではなくProfunctor
に、f
はApplicative
ではなくFunctor
になっている。
Isoは「全体→部分」の正変換と「部分→全体」の逆変換を保持する。withIso
関数を使うとそれらを取り出すことができる。
withIso :: AnIso s t a b -> ((s -> a) -> (b -> t) -> r) -> r
withIso
関数の第2引数として与えられる関数に、正変換(s -> a
)と逆変換(b -> t
)が渡される。
withIso
関数はExchange
というProfunctor
を使うことで、Isoから正変換と逆変換を分離して取り出しているようだ。
Isoの効果を逆転する関数はfrom
である。
from :: AnIso s t a b -> Iso b a t s
from
の結果はIsoであり、from (from l) = l
となる。