Haskell/透鏡和函式式引用
本章節關於函式式引用。所謂“引用”,是指它們指向值的某個部分,允許我們訪問和修改它們。所謂“函式式”,是指它們以一種提供我們對函式所期望的靈活性、可組合性的方式來做到這一點。我們將研究由強大的lens庫實現的函式式引用。lens以透鏡命名,這是一種特別著名的函式式引用。除了從概念角度來看非常有趣外,透鏡和其他函式式引用還允許使用幾種方便且越來越常見的習語,這些習語被許多有用的庫所使用。
作為熱身,我們將演示透鏡的最簡單用例:作為普通 Haskell 記錄的更佳替代方案。本節將很少進行解釋;我們將透過本章的剩餘部分來填補空白。
考慮以下型別,它們與您在 2D 繪相簿中可能找到的型別類似
-- A point in the plane.
data Point = Point
{ positionX :: Double
, positionY :: Double
} deriving (Show)
-- A line segment from one point to another.
data Segment = Segment
{ segmentStart :: Point
, segmentEnd :: Point
} deriving (Show)
-- Helpers to create points and segments.
makePoint :: (Double, Double) -> Point
makePoint (x, y) = Point x y
makeSegment :: (Double, Double) -> (Double, Double) -> Segment
makeSegment start end = Segment (makePoint start) (makePoint end)
記錄語法為我們提供了訪問欄位的函式。藉助它們,獲取定義線段的點的座標非常容易
GHCi> let testSeg = makeSegment (0, 1) (2, 4)
GHCi> positionY . segmentEnd $ testSeg
GHCi> 4.0
但是,更新比較笨拙...
GHCi> testSeg { segmentEnd = makePoint (2, 3) }
Segment {segmentStart = Point {positionX = 0.0, positionY = 1.0}
, segmentEnd = Point {positionX = 2.0, positionY = 3.0}}
...當我們需要訪問巢狀欄位時,就會變得非常醜陋。以下是如何將端點的y座標值加倍
GHCi> :set +m -- Enabling multi-line input in GHCi.
GHCi> let end = segmentEnd testSeg
GHCi| in testSeg { segmentEnd = end { positionY = 2 * positionY end } }
Segment {segmentStart = Point {positionX = 0.0, positionY = 1.0}
, segmentEnd = Point {positionX = 2.0, positionY = 8.0}}
透鏡允許我們避免這種難看之處,因此讓我們從它們開始
-- Some of the examples in this chapter require a few GHC extensions:
-- TemplateHaskell is needed for makeLenses; RankNTypes is needed for
-- a few type signatures later on.
{-# LANGUAGE TemplateHaskell, RankNTypes #-}
import Control.Lens
data Point = Point
{ _positionX :: Double
, _positionY :: Double
} deriving (Show)
makeLenses ''Point
data Segment = Segment
{ _segmentStart :: Point
, _segmentEnd :: Point
} deriving (Show)
makeLenses ''Segment
makePoint :: (Double, Double) -> Point
makePoint (x, y) = Point x y
makeSegment :: (Double, Double) -> (Double, Double) -> Segment
makeSegment start end = Segment (makePoint start) (makePoint end)
這裡唯一真正的變化是使用了makeLenses,它會自動為Point和Segment的欄位生成透鏡(額外的下劃線是makeLenses命名約定所要求的)。正如我們將看到的,手動編寫透鏡定義並不難;但是,如果有很多欄位需要為其建立透鏡,則可能很乏味,因此自動生成非常方便。
藉助makeLenses,我們現在每個欄位都有一個透鏡。它們的名稱與欄位的名稱匹配,只是去掉了前面的下劃線
GHCi> :info positionY
positionY :: Lens' Point Double
-- Defined at WikibookLenses.hs:9:1
GHCi> :info segmentEnd
segmentEnd :: Lens' Segment Point
-- Defined at WikibookLenses.hs:15:1
型別positionY :: Lens' Point Double告訴我們,positionY是Point內Double的引用。要使用這種引用,我們使用由lens庫提供的組合器。其中一個是view,它可以讓我們獲取透鏡指向的值,就像記錄訪問器一樣
GHCi> let testSeg = makeSegment (0, 1) (2, 4)
GHCi> view segmentEnd testSeg
Point {_positionX = 2.0, _positionY = 4.0}
另一個是set,它可以覆蓋透鏡指向的值
GHCi> set segmentEnd (makePoint (2, 3)) testSeg
Segment {_segmentStart = Point {_positionX = 0.0, _positionY = 1.0}
, _segmentEnd = Point {_positionX = 2.0, _positionY = 3.0}}
透鏡的一大優點是它們很容易組合
GHCi> view (segmentEnd . positionY) testSeg
GHCi> 4.0
請注意,在編寫組合透鏡時,例如segmentEnd . positionY,順序是從大到小。在這種情況下,聚焦於線段上的點的透鏡位於聚焦於該點座標的透鏡之前。雖然這與記錄訪問器的工作方式相比可能看起來有點令人驚訝(與本節開頭等效的無透鏡示例進行比較),但這裡使用的(.)只是我們熟知的函式組合運算子。
透鏡的組合為我們提供了一種擺脫巢狀記錄更新困境的方法。以下是如何使用over轉換座標加倍示例,透過它我們可以將函式應用於透鏡指向的值
GHCi> over (segmentEnd . positionY) (2 *) testSeg
Segment {_segmentStart = Point {_positionX = 0.0, _positionY = 1.0}
, _segmentEnd = Point {_positionX = 2.0, _positionY = 8.0}}
這些初始示例一開始可能看起來有點神奇。是什麼讓使用同一個透鏡獲取、設定和修改值成為可能?為什麼用(.)組合透鏡就能奏效?在makeLenses的幫助下,編寫透鏡真的這麼容易嗎?我們將透過揭開幕後,找出透鏡的構成來回答這些問題。
有很多方法可以理解透鏡。我們將遵循一條蜿蜒但平緩的道路,避免概念性飛躍。在此過程中,我們將介紹幾種不同型別的函式式引用。遵循lens術語,從現在起,我們將使用“光學”一詞來統稱各種函式式引用。正如我們將看到的,中的光學lens是相互關聯的,形成了一個層次結構。正是這個層次結構,我們現在將要探索。
我們將從透鏡開始,而不是從與之密切相關的另一個光學型別:遍歷。該可遍歷章節討論瞭如何使用traverse在結構中行走,同時產生一個整體效果
traverse
:: (Applicative f, Traversable t) => (a -> f b) -> t a -> f (t b)
使用traverse,您可以使用任何您喜歡的Applicative來產生效果。特別是,我們已經瞭解瞭如何從traverse獲得fmap,只需選擇Identity作為應用函子,而foldMap和Const m也是如此,使用Monoid m => Applicative (Const m)
fmap f = runIdentity . traverse (Identity . f)
foldMap f = getConst . traverse (Const . f)
lens借鑑了這個理念,讓它蓬勃發展。
在Traversable結構中操作值,就像traverse允許我們做的那樣,是針對整體的一部分的一個例子。然而,traverse儘管靈活,但它只處理了一系列相當有限的目標。首先,我們可能想要遍歷不是Traversable函子的結構。以下是一個使用我們的Point型別執行此操作的非常合理的函式
pointCoordinates
:: Applicative f => (Double -> f Double) -> Point -> f Point
pointCoordinates g (Point x y) = Point <$> g x <*> g y
pointCoordinates是Point的遍歷。它看起來很像traverse的典型實現,並且可以以幾乎相同的方式使用。唯一的區別是Point在內部具有固定型別(Double),而不是多型型別。以下是對來自可遍歷章節的rejectWithNegatives示例的改編
GHCi> let deleteIfNegative x = if x < 0 then Nothing else Just x
GHCi> pointCoordinates deleteIfNegative (makePoint (1, 2))
Just (Point {_positionX = 1.0, _positionY = 2.0})
GHCi> pointCoordinates deleteIfNegative (makePoint (-1, 2))
Nothing
pointCoordinates所體現的遍歷的這種泛化概念被中的核心型別之一捕獲lens: Traversal。
type Traversal s t a b =
forall f. Applicative f => (a -> f b) -> s -> f t
請注意
type 宣告右側的 forall f. 表示可以使用任何 Applicative 來替換 f。這使得在左側不需要提及 f,也不需要在使用 Traversal 時指定要選擇哪個 f。
使用 Traversal 同義詞,pointCoordinates 的型別可以表示為
Traversal Point Point Double Double
讓我們仔細看看 Traversal s t a b 中每個型別變數變成了什麼
s變成Point:pointCoordinates是Point的遍歷。t變成Point:pointCoordinates生成一個Point(在某個Applicative上下文中)。a變成Double:pointCoordinates針對Point中的Double值(點的 X 和 Y 座標)。b變成Double:目標Double值變成Double值(可能與原始值不同)。
在 pointCoordinates 的情況下,s 與 t 相同,a 與 b 相同。pointCoordinates 不會改變遍歷結構的型別,也不會改變其中目標的型別,但這並非必須如此。一個例子是傳統的 traverse,其型別可以表示為
Traversable t => Traversal (t a) (t b) a b
traverse 能夠改變 Traversable 結構中目標值的型別,並因此改變結構本身的型別。
Control.Lens.Traversal 模組包含 Data.Traversable 函式的泛化和用於處理遍歷的各種其他工具。
| 練習 |
|---|
|
設定器
[edit | edit source]接下來,我們的程式將對 Traversable、Functor 和 Foldable 之間的連結進行泛化。我們將從 Functor 開始。
為了從 traverse 中恢復 fmap,我們選擇了 Identity 作為應用函子。這種選擇使我們能夠修改目標值,而不會產生任何額外的副作用。我們可以透過選擇 Traversal 的定義來獲得類似的結果...
forall f. Applicative f => (a -> f b) -> s -> f t
...並將 f 特化為 Identity
(a -> Identity b) -> s -> Identity t
在lens術語中,這就是你獲得 Setter 的方式。由於技術原因,Control.Lens.Setter 中 Setter 的定義略有不同...
type Setter s t a b =
forall f. Settable f => (a -> f b) -> s -> f t
...但是如果你深入研究文件,你會發現 Settable 函子要麼是 Identity,要麼是非常類似於 Identity 的東西,因此我們不必關心這種差異。
當我們採用 Traversal 並限制 f 的選擇時,我們實際上使型別更加通用。鑑於 Traversal 可以與任何 Applicative 函子一起使用,它也可以與 Identity 一起使用,因此任何 Traversal 都是 Setter,並且可以用作 Setter。然而,反過來並不成立:並非所有設定器都是遍歷。
over 是設定器的基本組合器。它的作用與 fmap 非常相似,只是你傳遞一個設定器作為它的第一個引數,以指定要定位結構的哪些部分
GHCi> over pointCoordinates negate (makePoint (1, 2))
Point {_positionX = -1.0, _positionY = -2.0}
實際上,有一個名為 mapped 的 Setter,它允許我們恢復 fmap
GHCi> over mapped negate [1..4]
[-1,-2,-3,-4]
GHCi> over mapped negate (Just 3)
Just (-3)
另一個非常重要的組合器是 set,它將所有目標值替換為一個常量。set setter x = over setter (const x),類似於 (x <$ ) = fmap (const x)
GHCi> set pointCoordinates 7 (makePoint (1, 2))
Point {_positionX = 7.0, _positionY = 7.0}
| 練習 |
|---|
|
摺疊
[edit | edit source]在對 fmap-as-traversal 技巧進行了泛化之後,現在是時候對 foldMap-as-traversal 技巧做同樣的事情了。我們將使用 Const 從...
forall f. Applicative f => (a -> f b) -> s -> f t
...到
forall r. Monoid r => (a -> Const r a) -> s -> Const r s
由於 Const 的第二個引數無關緊要,我們用 a 替換 b,用 s 替換 t,以便使我們的生活更輕鬆。
就像我們針對 Setter 和 Identity 所看到的那樣,Control.Lens.Fold 使用比 Monoid r => Const r 更通用的東西
type Fold s a =
forall f. (Contravariant f, Applicative f) => (a -> f a) -> s -> f s
請注意
Contravariant 是一個用於逆變函子的型別類。關鍵的 Contravariant 方法是 contramap...
contramap :: Contravariant f => (a -> b) -> f b -> f a
...它看起來很像 fmap,只是它在對映時,可以說是將函式箭頭反轉了。以函式引數為引數的型別是 Contravariant 的典型示例。例如,Data.Functor.Contravariant 為型別為 a 的值的布林測試定義了一個 Predicate 型別
newtype Predicate a = Predicate { getPredicate :: a -> Bool }
GHCi> :m +Data.Functor.Contravariant
GHCi> let largerThanFour = Predicate (> 4)
GHCi> getPredicate largerThanFour 6
True
Predicate 是一個 Contravariant,因此你可以使用 contramap 修改 Predicate,以便在將值提交到測試之前以某種方式調整值
GHCi> getPredicate (contramap length largerThanFour) "orange"
True
Contravariant 具有類似於 Functor 的定律
contramap id = id
contramap (g . f) = contramap f . contramap g
Monoid r => Const r 既是 Contravariant 又是 Applicative。由於函子定律和逆變定律,任何既是 Contravariant 又是 Functor 的東西,就像 Const r 一樣,都是一個空函子,fmap 和 contramap 都沒有做任何事情。額外的 Applicative 約束對應於 Monoid r;它允許我們透過組合從目標建立的 Const 類似上下文來實際執行摺疊。
每個 Traversal 都可以用作 Fold,因為 Traversal 必須與任何 Applicative 一起使用,包括那些也是 Contravariant 的 Applicative。這種情況與我們針對 Traversal 和 Setter 所看到的完全相同。
Control.Lens.Fold 提供了 Data.Foldable 中所有內容的類似物。該模組中兩個常見的組合器是 toListOf,它產生一個 Fold 目標的列表...
GHCi> -- Using the solution to the exercise in the traversals subsection.
GHCi> toListOf extremityCoordinates (makeSegment (0, 1) (2, 3))
[0.0,1.0,2.0,3.0]
...以及 preview,它使用 Data.Monoid 中的 First 么半群來提取 Fold 的第一個目標。
GHCi> preview traverse [1..10]
Just 1
獲取器
[edit | edit source]到目前為止,我們透過限制可用於遍歷的函子,從 Traversal 轉移到更通用的光學器(Setter 和 Fold)。我們也可以反過來,即透過擴大他們必須處理的函子範圍來建立更具體的光學器。例如,如果我們採用 Fold...
type Fold s a =
forall f. (Contravariant f, Applicative f) => (a -> f a) -> s -> f s
...並將 Applicative 約束放鬆到僅僅是 Functor,我們就會得到 Getter
type Getter s a =
forall f. (Contravariant f, Functor f) => (a -> f a) -> s -> f s
由於 f 仍然必須既是 Contravariant 又是 Functor,它仍然是一個 Const 類似的空函子。然而,如果沒有 Applicative 約束,我們就無法組合來自多個目標的結果。結果是,Getter 始終只有一個目標,與 Fold(或者,就此而言,Setter 或 Traversal)不同,Fold 可以有任意數量的目標,包括零個目標。
Getter 的本質可以透過將 f 特化為明顯的選擇 Const r 來闡明
someGetter :: (a -> Const r a) -> s -> Const r s
由於 Const r whatever 值可以無損地轉換為 r 值並返回,因此上面的型別等價於
someGetter' :: (a -> r) -> s -> r
someGetter' k x = getConst (someGetter (Const . k) x)
someGetter g x = Const (someGetter' (getConst . g) x)
然而,(a -> r) -> s -> r 函式只是隱藏的 s -> a 函式(偽裝是 延續傳遞風格)
someGetter'' :: s -> a
someGetter'' x = someGetter' id x
someGetter' k x = k (someGetter'' x)
因此,我們得出結論,Getter s a 等價於 s -> a 函式。從這個角度來看,它正好從一個目標獲取一個結果,這很自然。同樣不奇怪的是,Control.Lens.Getter 中的兩個基本組合器是 to,它從任意函式建立 Getter,以及 view,它將 Getter 轉換回任意函式。
GHCi> -- The same as fst (4, 1)
GHCi> view (to fst) (4, 1)
4
請注意
鑑於我們剛剛關於 Getter 比 Fold 更不通用所說的話,view 也可以處理 Fold 和 Traversal 以及 Getter,這可能令人驚訝
GHCi> :m +Data.Monoid
GHCi> view traverse (fmap Sum [1..10])
Sum {getSum = 55}
GHCi> -- both traverses the components of a pair.
GHCi> view both ([1,2],[3,4,5])
[1,2,3,4,5]
這得益於lens的型別簽名中的許多微妙之處之一。view 的第一個引數並不完全是 Getter,而是一個 Getting
type Getting r s a = (a -> Const r a) -> s -> Const r s
view :: MonadReader s m => Getting a s a -> m a
Getting 將函子引數特化為 Const r,這是 Getter 的明顯選擇,但它沒有規定是否會有一個 Applicative 例項(即 r 是否將是一個 Monoid)。以 view 為例,只要 a 是一個 Monoid,Getting a s a 就可以用作 Fold,因此只要摺疊目標是么半群,Fold 就可以與 view 一起使用。
Control.Lens.Getter 和 Control.Lens.Fold 中的許多組合子都是根據 Getting 而不是 Getter 或 Fold 來定義的。使用 Getting 的一個優點是,結果型別簽名能告訴我們更多關於可能執行的摺疊的資訊。例如,考慮來自 Control.Lens.Fold 的 hasn't。
hasn't :: Getting All s a -> s -> Bool
它是一個通用的空測試。
GHCi> hasn't traverse [1..4]
False
GHCi> hasn't traverse Nothing
True
Fold s a -> s -> Bool 可以很好地用作 hasn't 的簽名。但是,實際簽名中的 Getting All 資訊量很大,因為它強烈地表明瞭 hasn't 的作用:它將 s 中的所有 a 目標轉換為 All 么半群(更準確地說,轉換為 All False),將它們摺疊起來,並從整體 All 結果中提取一個 Bool。
最後是透鏡
[edit | edit source]如果我們回到 Traversal...
type Traversal s t a b =
forall f. Applicative f => (a -> f b) -> s -> f t
... 並將 Applicative 約束放鬆為 Functor,就像我們從 Fold 到 Getter 一樣...
type Lens s t a b =
forall f. Functor f => (a -> f b) -> s -> f t
... 我們終於到達了 Lens 型別。
從 Traversal 到 Lens 的轉變有什麼變化?和以前一樣,放鬆 Applicative 約束使我們失去了遍歷多個目標的能力。與 Traversal 不同,Lens 始終專注於單個目標。通常在這種情況下,限制也有一方面好處:使用 Lens,我們可以確定只有一個目標會被找到,而使用 Traversal,我們可能會最終找到許多目標,也可能一個也找不到。
沒有 Applicative 約束和目標的唯一性指向透鏡的另一個關鍵事實:它們可以用作獲取器。Contravariant 加 Functor 是比 Functor 更具體的約束,因此 Getter 比 Lens 更通用。由於每個 Lens 也是一個 Traversal,因此也是一個 Setter,我們得出結論,透鏡可以用作獲取器和設定器。這解釋了為什麼透鏡可以替換記錄標籤。
請注意
仔細閱讀後,我們聲稱每個 Lens 都可以用作 Getter 的說法似乎有些草率。將型別並排放置...
type Lens s t a b =
forall f. Functor f => (a -> f b) -> s -> f t
type Getter s a =
forall f. (Contravariant f, Functor f) => (a -> f a) -> s -> f s
... 表明從 Lens s t a b 到 Getter s a 的轉換涉及使 s 等於 t,a 等於 b。我們如何確定這對於任何透鏡都是可能的?關於 Traversal 和 Fold 之間的關係,也可能會提出類似的問題。目前,這個問題將被擱置;我們將在關於光學定律的部分中回到它。
這裡快速演示了使用 _1 的透鏡靈活性,該透鏡專注於元組的第一個元件。
GHCi> _1 (\x -> [0..x]) (4, 1) -- Traversal
[(0,1),(1,1),(2,1),(3,1),(4,1)]
GHCi> set _1 7 (4, 1) -- Setter
(7,1)
GHCi> over _1 length ("orange", 1) -- Setter, changing the types
(6,1)
GHCi> toListOf _1 (4, 1) -- Fold
[4]
GHCi> view _1 (4, 1) -- Getter
4
| 練習 |
|---|
|
組合
[edit | edit source]到目前為止,我們已經看到了的光學符合形狀...
(a -> f b) -> (s -> f t)
... 其中
f是某種Functor;s是整體的型別,即光學作用的完整結構;t是透過光學整體變成的型別;a是部分的型別,即光學聚焦的s內部的目標;以及b是部分透過光學變成的型別。
這些光學共有的一個關鍵之處是它們都是函式。更具體地說,它們是對映函式,將作用於部分的函式 (a -> f b) 轉換為作用於整體的函式 (s -> f t)。作為函式,它們可以以通常的方式進行組合。讓我們再看一下介紹中關於透鏡組合的示例。
GHCi> let testSeg = makeSegment (0, 1) (2, 4)
GHCi> view (segmentEnd . positionY) testSeg
GHCi> 4.0
光學修改它作為引數接收的函式,使其作用於更大的結構。鑑於 (.) 從右到左組合函式,我們發現,從左到右讀取程式碼時,使用 (.) 組裝的光學的元件會專注於原始結構中越來越小的部分。使用的約定lens型別同義詞與這種從大到小的順序相匹配,s 和 t 在 a 和 b 之前。下表說明了我們如何將光學看作是對映(從小到大)或聚焦(從大到小),以 segmentEnd . positionY 為例。
| 透鏡 | segmentEnd
|
positionY
|
segmentEnd . positionY
|
| 裸型別 | Functor f => (Point -> f Point) -> (Segment -> f Segment) |
Functor f => (Double -> f Double) -> (Point -> f Point) |
Functor f => (Double -> f Double) -> (Segment -> f Segment) |
| “對映”解釋 | 從作用於 Point 的函式到作用於 Segment 的函式。 |
從作用於 Double 的函式到作用於 Point 的函式。 |
從作用於 Double 的函式到作用於 Segment 的函式。 |
帶有 Lens 的型別 |
Lens Segment Segment Point Point
|
Lens Point Point Double Double
|
Lens Segment Segment Double Double
|
帶有 Lens' 的型別 |
Lens' Segment Point
|
Lens' Point Double
|
Lens' Segment Double
|
| “聚焦”解釋 | 專注於 Segment 內的 Point |
專注於 Point 內的 Double |
專注於 Segment 內的 Double |
請注意
Lens' 同義詞只是不改變型別的透鏡的便捷簡寫(即,s 等於 t 且 a 等於 b 的透鏡)。
type Lens' s a = Lens s s a a
還有類似的 Traversal' 和 Setter' 同義詞。
Lens 和 Traversal 等同義詞背後的型別僅在允許的函子方面有所不同。因此,可以自由混合不同型別的光學,只要存在所有型別都適合的型別。以下是一些示例。
GHCi> -- A Traversal on a Lens is a Traversal.
GHCi> (_2 . traverse) (\x -> [-x, x]) ("foo", [1,2])
[("foo",[-1,-2]),("foo",[-1,2]),("foo",[1,-2]),("foo",[1,2])]
GHCi> -- A Getter on a Lens is a Getter.
GHCi> view (positionX . to negate) (makePoint (2,4))
-2.0
GHCi> -- A Getter on a Traversal is a Fold.
GHCi> toListOf (both . to negate) (2,-3)
[-2,3]
GHCi> -- A Getter on a Setter does not exist (there is no unifying optic).
GHCi> set (mapped . to length) 3 ["orange", "apple"]
<interactive>:49:15:
No instance for (Contravariant Identity) arising from a use of ‘to’
In the second argument of ‘(.)’, namely ‘to length’
In the first argument of ‘set’, namely ‘(mapped . to length)’
In the expression: set (mapped . to length) 3 ["orange", "apple"]
運算子
[edit | edit source]一些lens組合子具有中綴運算子同義詞,或者至少與它們幾乎等效的運算子。以下是我們已經看到的一些組合子的對應關係。
| 字首 | 中綴 |
|---|---|
view _1 (1,2) |
(1,2) ^. _1
|
set _1 7 (1,2) |
(_1 .~ 7) (1,2)
|
over _1 (2 *) (1,2) |
(_1 %~ (2 *)) (1,2)
|
toListOf traverse [1..4] |
[1..4] ^.. traverse
|
preview traverse [] |
[] ^? traverse
|
lens提取值的運算子(例如 (^.)、(^..) 和 (^?))相對於相應的字首組合子被翻轉,因此它們以提取結果的結構作為第一個引數。這樣可以提高使用它們的程式碼的可讀性,因為在目標部分的光學之前寫完整結構,反映了從大到小順序寫組合光學的方式。藉助 (&) 運算子(它被簡單地定義為 flip ($)),在使用修改運算子(例如 (.~) 和 (%~))時,也可以先寫結構。當有很多欄位需要修改時,(&) 特別方便。
sextupleTest = (0,1,0,1,0,1)
& _1 .~ 7
& _2 %~ (5 *)
& _3 .~ (-1)
& _4 .~ "orange"
& _5 %~ (2 +)
& _6 %~ (3 *)
GHCi> sextupleTest
(7,5,-1,"orange",2,3)
瑞士軍刀
[edit | edit source]到目前為止,我們已經涵蓋了足夠多的內容lens來介紹透鏡,並表明它們不是神秘的魔法。然而,這僅僅是冰山一角。lens是一個大型庫,提供了豐富的工具,這些工具反過來實現了五顏六色的概念。如果想到核心庫中的任何內容,你很有可能在lens中找到與之相關的組合子。毫不誇張地說,探索lens各個角落的書可能與你正在閱讀的這本書一樣長。不幸的是,我們不能在這裡進行這樣的嘗試。我們可以做的是簡要討論一些其他通用的lens工具,你遲早會在現實世界中遇到它們。
State 操作
[edit | edit source]lens 模組中散佈著許多用於處理狀態函子的組合子。例如
- 來自
Control.Lens.Getter的use是Control.Monad.State中gets的類似物,它接受一個獲取器而不是一個普通函式。 Control.Lens.Setter包含一些看起來很直觀的運算子,它們修改由設定器(例如.=與set相似,%=與over相似,(+= x)與over (+x)相似)目標的 state 的一部分。- Control.Lens.Zoom 提供了一個非常方便的
zoom組合子,它使用遍歷(或透鏡)來放大 state 的一部分。它是透過將 stateful 計算提升到一個使用更大 state 的計算中來實現的,而原始 state 是該更大 state 的一部分。
這些組合子可以用來編寫高度意圖明顯的程式碼,這些程式碼透明地操作 state 的深層部分。
import Control.Monad.State
stateExample :: State Segment ()
stateExample = do
segmentStart .= makePoint (0,0)
zoom segmentEnd $ do
positionX += 1
positionY *= 2
pointCoordinates %= negate
GHCi> execState stateExample (makeSegment (1,2) (5,3))
Segment {_segmentStart = Point {_positionX = 0.0, _positionY = 0.0}
, _segmentEnd = Point {_positionX = -6.0, _positionY = -6.0}}
等距
[edit | edit source]在我們關於Point 和 Segment 的系列示例中,我們一直在使用 makePoint 函式作為一種便捷的方式,將(Double, Double) 對轉換為 Point。
makePoint :: (Double, Double) -> Point
makePoint (x, y) = Point x y
生成的 Point 的 X 和 Y 座標與原始對的兩個分量完全一致。既然如此,我們可以定義一個 unmakePoint 函式...
unmakePoint :: Point -> (Double, Double)
unmakePoint (Point x y) = (x,y)
... 使得 makePoint 和 unmakePoint 成為一對逆運算,也就是說,它們互相抵消。
unmakePoint . makePoint = id
makePoint . unmakePoint = id
換句話說,makePoint 和 unmakePoint 提供了一種無損地將對轉換為點,反之亦然的方式。用術語來說,我們可以說 makePoint 和 unmakePoint 形成了一種同構。
unmakePoint 可以被製作成一個 Lens' Point (Double, Double)。對稱地,makePoint 將產生一個 Lens' (Double, Double) Point,這兩個透鏡將是一對逆運算。具有逆運算的透鏡有自己的型別同義詞 Iso,以及在 Control.Lens.Iso 中定義的一些額外工具。
可以使用 iso 函式從一對逆運算構建一個 Iso。
iso :: (s -> a) -> (b -> t) -> Iso s t a b
pointPair :: Iso' Point (Double, Double)
pointPair = iso unmakePoint makePoint
Iso 是 Lens,因此熟悉的透鏡組合器可以照常工作。
GHCi> import Data.Tuple (swap)
GHCi> let testPoint = makePoint (2,3)
GHCi> view pointPair testPoint -- Equivalent to unmakePoint
(2.0,3.0)
GHCi> view (pointPair . _2) testPoint
3.0
GHCi> over pointPair swap testPoint
Point {_positionX = 3.0, _positionY = 2.0}
此外,Iso 可以使用 from 反轉。
GHCi> :info from pointPair
from :: AnIso s t a b -> Iso b a t s
-- Defined in ‘Control.Lens.Iso’
pointPair :: Iso' Point (Double, Double)
-- Defined at WikibookLenses.hs:77:1
GHCi> view (from pointPair) (2,3) -- Equivalent to makePoint
Point {_positionX = 2.0, _positionY = 3.0}
GHCi> view (from pointPair . positionY) (2,3)
3.0
另一個有趣的組合器是 under。顧名思義,它就像 over,只是它使用 from 給我們的反轉 Iso。我們將透過使用 enum 同構來演示它,在不使用 Data.Char 中的 chr 和 ord 的情況下,玩弄 Char 的 Int 表示。
GHCi> :info enum
enum :: Enum a => Iso' Int a -- Defined in ‘Control.Lens.Iso’
GHCi> under enum (+7) 'a'
'h'
newtype 和其他單構造型別會產生同構。 Control.Lens.Wrapped 利用這一事實提供基於 Iso 的工具,例如,這些工具使我們不必記住記錄標籤名稱來解開 newtype...
GHCi> let testConst = Const "foo"
GHCi> -- getConst testConst
GHCi> op Const testConst
"foo"
GHCi> let testIdent = Identity "bar"
GHCi> -- runIdentity testIdent
GHCi> op Identity testIdent
"bar"
... 並且使 newtype 包裝用於例項選擇變得不那麼混亂。
GHCi> :m +Data.Monoid
GHCi> -- getSum (foldMap Sum [1..10])
GHCi> ala Sum foldMap [1..10]
55
GHCi> -- getProduct (foldMap Product [1..10])
GHCi> ala Product foldMap [1..10]
3628800
稜鏡
[edit | edit source]使用 Iso,我們第一次接觸到光學層次結構中低於 Lens 的一個等級:每個 Iso 都是一個 Lens,但並非每個 Lens 都是 Iso。透過回到 Traversal,我們可以觀察到光學是如何逐漸失去對目標的精確定位的。
Iso是一種只有一個目標且可逆的光學。Lens也只有一個目標,但不可逆。Traversal可以有多個目標,而且不可逆。
在此過程中,我們首先放棄了可逆性,然後放棄了目標的唯一性。如果我們透過先放棄唯一性,然後再放棄可逆性來走另一條路,我們會發現一種介於同構和遍歷之間的第二種光學:稜鏡。Prism 是一種可逆的光學,它不必只有一個目標。由於可逆性與多個目標不相容,我們可以更精確地說:Prism 可以到達零個或一個目標。
以單個目標為目標,並可能失敗,這聽起來很像模式匹配,稜鏡實際上能夠捕獲這一點。如果元組和記錄提供了透鏡的自然示例,那麼 Maybe、Either 和其他具有多個建構函式的型別對稜鏡起著相同的作用。
每個 Prism 都是一個 Traversal,因此遍歷、設定器和摺疊的常用組合器都可以與稜鏡一起使用。
GHCi> set _Just 5 (Just "orange")
Just 5
GHCi> set _Just 5 Nothing
Nothing
GHCi> over _Right (2 *) (Right 5)
Right 10
GHCi> over _Right (2 *) (Left 5)
Left 5
GHCi> toListOf _Left (Left 5)
[5]
Prism 不是 Getter,因為目標可能不存在。因此,我們使用 preview 而不是 view 來檢索目標。
GHCi> preview _Right (Right 5)
Just 5
GHCi> preview _Right (Left 5)
Nothing
為了反轉一個 Prism,我們使用來自 Control.Lens.Review 的 re 和 review。re 與 from 相似,儘管它只提供了一個 Getter。review 等效於使用反轉的稜鏡的 view。
GHCi> view (re _Right) 3
Right 3
GHCi> review _Right 3
Right 3
正如透鏡不僅僅是到達記錄欄位那樣,稜鏡也不限於匹配建構函式。例如,Control.Lens.Prism 定義了 only,它將相等性測試編碼為一個 Prism。
GHCi> :info only
only :: Eq a => a -> Prism' a ()
-- Defined in ‘Control.Lens.Prism’
GHCi> preview (only 4) (2 + 2)
Just ()
GHCi> preview (only 5) (2 + 2)
Nothing
prism 和 prism' 函式使我們能夠構建自己的稜鏡。以下是一個使用 Data.List 中的 stripPrefix 的示例。
GHCi> :info prism
prism :: (b -> t) -> (s -> Either t a) -> Prism s t a b
-- Defined in ‘Control.Lens.Prism’
GHCi> :info prism'
prism' :: (b -> s) -> (s -> Maybe a) -> Prism s s a b
-- Defined in ‘Control.Lens.Prism’
GHCi> import Data.List (stripPrefix)
GHCi> :t stripPrefix
stripPrefix :: Eq a => [a] -> [a] -> Maybe [a]
prefixed :: Eq a => [a] -> Prism' [a] [a]
prefixed prefix = prism' (prefix ++) (stripPrefix prefix)
GHCi> preview (prefixed "tele") "telescope"
Just "scope"
GHCi> preview (prefixed "tele") "orange"
Nothing
GHCi> review (prefixed "tele") "graph"
"telegraph"
prefixed 來自lens,位於 Data.List.Lens 模組中。
| 練習 |
|---|
|
定律
[edit | edit source]有一些定律指定了合理的光學應該如何運作。我們現在將調查適用於我們在此處介紹的光學的那些定律。
從分類的頂端開始,Fold 沒有定律,就像 Foldable 類一樣。Getter 也沒有定律,這並不奇怪,因為任何函式都可以透過 to 轉換為 Getter。
然而,Setter 確實有定律。over 是 fmap 的推廣,因此受函子定律的約束。
over s id = id
over s g . over s f = over s (g . f)
由於 set s x = over s (const x),第二個函子定律的結果是
set s y . set s x = set s y
也就是說,設定兩次與設定一次相同。
Traversal 定律類似於 Traversable 定律的推廣。
t pure = pure
fmap (t g) . t f = getCompose . t (Compose . fmap g . f)
在 Traversable 章節中討論的結果也隨之而來:遍歷會恰好訪問一次其所有目標,並且必須要麼保留周圍的結構,要麼完全破壞它。
每個 Lens 都是一個 Traversal 和一個 Setter,因此上述定律也適用於透鏡。此外,每個 Lens 也是一個 Getter。鑑於透鏡既是獲取器又是設定器,它應該獲得與設定相同的目標。這個常識要求由以下定律表達。
view l (set l x z) = x
set l (view l z) z = z
與上面介紹的設定器的“設定兩次”定律一起,這些定律通常被稱為透鏡定律。
類似的定律適用於 Prism,使用 preview 而不是 view,使用 review 而不是 set。
preview p (review p x) = Just x
review p <$> preview p z = Just z -- If preview p z isn't Nothing.
Iso 既是透鏡又是稜鏡,因此上述所有定律都適用於它們。然而,稜鏡定律可以簡化,因為對於同構來說,preview i = Just . view i(即,preview 從不失敗)。
view i (review i x) = x
review i (view i z) = z
多型更新
[edit | edit source]當我們檢視 Setter s t a b 和 Lens s t a b 這樣的光學型別時,我們會看到四個獨立的型別變數。然而,如果我們考慮各種光學定律,我們會發現並非所有 s、t、a 和 b 的選擇都是合理的。例如,考慮設定器的“設定兩次”定律。
set s y . set s x = set s y
為了使“設定兩次與設定一次相同”有意義,必須能夠使用同一個設定器設定兩次。因此,該定律只能對 Setter s t a b 成立,如果 t 可以以某種方式專門化,使其等於 s(否則,整個型別的型別將在每次 set 時發生變化,導致型別不匹配)。
從關於在上述定律中涉及的型別的考慮來看,可以得出結論,遵守定律的 Setter、Traversal、Prism 和 Lens 中的四個型別引數並非完全相互獨立。我們不會詳細檢查相互依賴關係,只是指出它的一些後果。首先,a 和 b 來自同一個布料,即使一個光學可以更改型別,也必須有一種方法來專門化 a 和 b 使它們相等;此外,s 和 t 也一樣。其次,如果 a 和 b 相等,那麼 s 和 t 也必須相等。
在實踐中,這些限制意味著能夠更改型別的有效光學通常會根據 a 和 b 來引數化 s 和 t。這種型別的更改更新通常被稱為多型更新。為了說明,這裡是從lens:
-- To avoid distracting details,
-- we specialised the types of argument and _1.
mapped :: Functor f => Setter (f a) (f b) a b
contramapped :: Contravariant f => Setter (f b) (f a) a b
argument :: Setter (b -> r) (a -> r) a b
traverse :: Traversable t => Traversal (t a) (t b) a b
both :: Bitraversable r => Traversal (r a a) (r b b) a b
_1 :: Lens (a, c) (b, c) a b
_Just :: Prism (Maybe a) (Maybe b) a b
此時,我們可以回到在介紹 Lens 型別時遺留的開放問題。鑑於 Lens 和 Traversal 允許型別更改,而 Getter 和 Fold 則不允許,那麼說每個 Lens 都是一個 Getter,或者每個 Traversal 都是一個 Fold,確實有些魯莽。然而,型別變數的相互依賴性意味著每個合法的 Lens 都可以用作 Getter,每個合法的 Traversal 都可以用作 Fold,因為合法的透鏡和遍歷始終可以以非型別更改的方式使用。
無拘無束
[edit | edit source]正如我們所見,我們可以使用lens透過諸如lens之類的函式和諸如makeLenses之類的自動生成工具來定義光學。嚴格來說,這些僅僅是方便的助手。鑑於Lens、Traversal等等只是類型別名,在編寫光學時不需要它們的定義 - 例如,我們始終可以編寫Functor f => (a -> f b) -> (s -> f t)而不是Lens s t a b。這意味著我們可以定義與lens不使用lens相容的光學!實際上,任何Lens、Traversal、Setter或Getting都可以定義,除了基礎包之外沒有任何依賴項。
無需依賴於lens庫來定義光學的能力為如何利用它們提供了相當大的靈活性。雖然有一些庫確實依賴於lens,但庫作者通常不願意依賴於像lens這樣具有多個依賴項的大型軟體包,尤其是在編寫小型通用庫時。透過在不使用類型別名或lens中的輔助工具的情況下定義光學,可以避免這些問題。此外,型別只是別名,因此可以擁有多個光學框架(即lens和類似的庫),這些框架可以互換使用。
進一步閱讀
[edit | edit source]- 上面幾段,我們說過lens很容易提供足夠的材料來寫一整本書。我們目前最接近這個目標的是 Artyom Kazak 的 "lens over tea" 部落格系列。它探討了在lens中實現函式引用以及它背後的概念,其深度遠遠超過我們在這裡所能做到的。強烈推薦閱讀。
- 可以透過 lens' GitHub wiki 獲得有用的資訊,當然, lens' API 文件 也值得探索。
- lens是一個龐大而複雜的庫。如果你想研究它的實現,但想從更簡單的東西開始,一個好的起點是極簡的lens相容庫,例如microlens和lens-simple.
- 研究(和使用!)基於光學的庫是瞭解函式引用如何使用的一個好方法。一些任意示例
- diagrams,一個向量圖形庫,使用lens來廣泛處理圖形元素的屬性。
- wreq,一個帶有lens介面的 Web 客戶端庫。
- xml-lens,它提供了用於操作 XML 的光學。
- formattable,一個用於日期、時間和數字格式化的庫。 Formattable.NumFormat 是一個模組的例子,它提供lens相容的光學,而不依賴於lens包之外沒有任何依賴項。