跳轉到內容

Haskell/透鏡和函式式引用

來自 Wikibooks,開放世界中的開放書籍

本章節關於函式式引用。所謂“引用”,是指它們指向值的某個部分,允許我們訪問和修改它們。所謂“函式式”,是指它們以一種提供我們對函式所期望的靈活性、可組合性的方式來做到這一點。我們將研究由強大的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,它會自動為PointSegment的欄位生成透鏡(額外的下劃線是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告訴我們,positionYPointDouble引用。要使用這種引用,我們使用由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作為應用函子,而foldMapConst 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

pointCoordinatesPoint遍歷。它看起來很像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 變成 PointpointCoordinatesPoint 的遍歷。
  • t 變成 PointpointCoordinates 生成一個 Point(在某個 Applicative 上下文中)。
  • a 變成 DoublepointCoordinates 針對 Point 中的 Double 值(點的 X 和 Y 座標)。
  • b 變成 Double:目標 Double 值變成 Double 值(可能與原始值不同)。

pointCoordinates 的情況下,st 相同,ab 相同。pointCoordinates 不會改變遍歷結構的型別,也不會改變其中目標的型別,但這並非必須如此。一個例子是傳統的 traverse,其型別可以表示為

Traversable t => Traversal (t a) (t b) a b

traverse 能夠改變 Traversable 結構中目標值的型別,並因此改變結構本身的型別。

Control.Lens.Traversal 模組包含 Data.Traversable 函式的泛化和用於處理遍歷的各種其他工具。

練習
  1. 編寫 extremityCoordinates,一個遍歷,它按 data 宣告中建議的順序遍歷定義 Segment 的所有點的座標。(提示:使用 pointCoordinates 遍歷。)

設定器

[edit | edit source]

接下來,我們的程式將對 TraversableFunctorFoldable 之間的連結進行泛化。我們將從 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.SetterSetter 的定義略有不同...

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}

實際上,有一個名為 mappedSetter,它允許我們恢復 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}
練習
  1. 使用 over 實現...
    scaleSegment :: Double -> Segment -> Segment
    ...這樣 scaleSegment n 就將段的所有座標乘以 x。(提示:使用你對上一練習的答案。)
  2. 實現 mapped。對於本練習,你可以將 Settable 函子特化為 Identity。(提示:你需要 Data.Functor.Identity。)

摺疊

[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,以便使我們的生活更輕鬆。

就像我們針對 SetterIdentity 所看到的那樣,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 一樣,都是一個空函子,fmapcontramap 都沒有做任何事情。額外的 Applicative 約束對應於 Monoid r;它允許我們透過組合從目標建立的 Const 類似上下文來實際執行摺疊。

每個 Traversal 都可以用作 Fold,因為 Traversal 必須與任何 Applicative 一起使用,包括那些也是 ContravariantApplicative。這種情況與我們針對 TraversalSetter 所看到的完全相同。

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 轉移到更通用的光學器(SetterFold)。我們也可以反過來,即透過擴大他們必須處理的函子範圍來建立更具體的光學器。例如,如果我們採用 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(或者,就此而言,SetterTraversal)不同,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

請注意

鑑於我們剛剛關於 GetterFold 更不通用所說的話,view 也可以處理 FoldTraversal 以及 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 是一個 MonoidGetting a s a 就可以用作 Fold,因此只要摺疊目標是么半群,Fold 就可以與 view 一起使用。

Control.Lens.GetterControl.Lens.Fold 中的許多組合子都是根據 Getting 而不是 GetterFold 來定義的。使用 Getting 的一個優點是,結果型別簽名能告訴我們更多關於可能執行的摺疊的資訊。例如,考慮來自 Control.Lens.Foldhasn'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,就像我們從 FoldGetter 一樣...

type Lens s t a b =
  forall f. Functor f => (a -> f b) -> s -> f t

... 我們終於到達了 Lens 型別。

TraversalLens 的轉變有什麼變化?和以前一樣,放鬆 Applicative 約束使我們失去了遍歷多個目標的能力。與 Traversal 不同,Lens 始終專注於單個目標。通常在這種情況下,限制也有一方面好處:使用 Lens,我們可以確定只有一個目標會被找到,而使用 Traversal,我們可能會最終找到許多目標,也可能一個也找不到。

沒有 Applicative 約束和目標的唯一性指向透鏡的另一個關鍵事實:它們可以用作獲取器。ContravariantFunctor 是比 Functor 更具體的約束,因此 GetterLens 更通用。由於每個 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 bGetter s a 的轉換涉及使 s 等於 ta 等於 b。我們如何確定這對於任何透鏡都是可能的?關於 TraversalFold 之間的關係,也可能會提出類似的問題。目前,這個問題將被擱置;我們將在關於光學定律的部分中回到它。


這裡快速演示了使用 _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
練習
  1. 實現 PointSegment 欄位的透鏡,即我們之前使用 makeLenses 生成的那些。(提示:關注型別。一旦你寫下簽名,你就會發現除了 fmap 和記錄標籤之外,你沒有太多可以用來寫它們的工具。)
  2. 實現 lens 函式,它接受一個獲取器函式 s -> a 和一個設定器函式 s -> b -> t,並生成一個 Lens s t a b。(提示:你的實現將能夠最大限度地減少先前練習解決方案中的重複性。)

組合

[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型別同義詞與這種從大到小的順序相匹配,stab 之前。下表說明了我們如何將光學看作是對映(從小到大)或聚焦(從大到小),以 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 等於 ta 等於 b 的透鏡)。

type Lens' s a = Lens s s a a

還有類似的 Traversal'Setter' 同義詞。


LensTraversal 等同義詞背後的型別僅在允許的函子方面有所不同。因此,可以自由混合不同型別的光學,只要存在所有型別都適合的型別。以下是一些示例。

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.GetteruseControl.Monad.Stategets 的類似物,它接受一個獲取器而不是一個普通函式。
  • 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]

在我們關於PointSegment 的系列示例中,我們一直在使用 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)

... 使得 makePointunmakePoint 成為一對逆運算,也就是說,它們互相抵消。

unmakePoint . makePoint = id
makePoint . unmakePoint = id

換句話說,makePointunmakePoint 提供了一種無損地將對轉換為點,反之亦然的方式。用術語來說,我們可以說 makePointunmakePoint 形成了一種同構

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

IsoLens,因此熟悉的透鏡組合器可以照常工作。

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 中的 chrord 的情況下,玩弄 CharInt 表示。

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 可以到達零個或一個目標。

以單個目標為目標,並可能失敗,這聽起來很像模式匹配,稜鏡實際上能夠捕獲這一點。如果元組和記錄提供了透鏡的自然示例,那麼 MaybeEither 和其他具有多個建構函式的型別對稜鏡起著相同的作用。

每個 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.Reviewrereviewrefrom 相似,儘管它只提供了一個 Getterreview 等效於使用反轉的稜鏡的 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

prismprism' 函式使我們能夠構建自己的稜鏡。以下是一個使用 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 模組中。

練習
  1. Control.Lens.Prism 定義了一個 outside 函式,它具有以下(簡化)型別

    outside :: Prism s t a b
            -> Lens (t -> r) (s -> r) (b -> r) (a -> r)
    1. 解釋 outside 的作用,不要提到它的實現。(提示:文件說,透過它,我們可以“將 Prism 用作一種一等模式”。你的答案應該擴充套件這一點,解釋我們如何以這種方式使用它。)
    2. 使用 outside 實現 Prelude 中的 maybeeither

      maybe :: b -> (a -> b) -> Maybe a -> b

      either :: (a -> c) -> (b -> c) -> Either a b -> c

定律

[edit | edit source]

有一些定律指定了合理的光學應該如何運作。我們現在將調查適用於我們在此處介紹的光學的那些定律。

從分類的頂端開始,Fold 沒有定律,就像 Foldable 類一樣。Getter 也沒有定律,這並不奇怪,因為任何函式都可以透過 to 轉換為 Getter

然而,Setter 確實有定律。overfmap 的推廣,因此受函子定律的約束。

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 bLens s t a b 這樣的光學型別時,我們會看到四個獨立的型別變數。然而,如果我們考慮各種光學定律,我們會發現並非所有 stab 的選擇都是合理的。例如,考慮設定器的“設定兩次”定律。

set s y . set s x = set s y

為了使“設定兩次與設定一次相同”有意義,必須能夠使用同一個設定器設定兩次。因此,該定律只能對 Setter s t a b 成立,如果 t 可以以某種方式專門化,使其等於 s(否則,整個型別的型別將在每次 set 時發生變化,導致型別不匹配)。

從關於在上述定律中涉及的型別的考慮來看,可以得出結論,遵守定律的 SetterTraversalPrismLens 中的四個型別引數並非完全相互獨立。我們不會詳細檢查相互依賴關係,只是指出它的一些後果。首先,ab 來自同一個布料,即使一個光學可以更改型別,也必須有一種方法來專門化 ab 使它們相等;此外,st 也一樣。其次,如果 ab 相等,那麼 st 也必須相等。

在實踐中,這些限制意味著能夠更改型別的有效光學通常會根據 ab 來引數化 st。這種型別的更改更新通常被稱為多型更新。為了說明,這裡是從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 型別時遺留的開放問題。鑑於 LensTraversal 允許型別更改,而 GetterFold 則不允許,那麼說每個 Lens 都是一個 Getter,或者每個 Traversal 都是一個 Fold,確實有些魯莽。然而,型別變數的相互依賴性意味著每個合法Lens 都可以用作 Getter,每個合法的 Traversal 都可以用作 Fold,因為合法的透鏡和遍歷始終可以以非型別更改的方式使用。

無拘無束

[edit | edit source]

正如我們所見,我們可以使用lens透過諸如lens之類的函式和諸如makeLenses之類的自動生成工具來定義光學。嚴格來說,這些僅僅是方便的助手。鑑於LensTraversal等等只是類型別名,在編寫光學時不需要它們的定義 - 例如,我們始終可以編寫Functor f => (a -> f b) -> (s -> f t)而不是Lens s t a b。這意味著我們可以定義與lens不使用lens相容的光學!實際上,任何LensTraversalSetterGetting都可以定義,除了基礎包之外沒有任何依賴項。

無需依賴於lens庫來定義光學的能力為如何利用它們提供了相當大的靈活性。雖然有一些庫確實依賴於lens,但庫作者通常不願意依賴於像lens這樣具有多個依賴項的大型軟體包,尤其是在編寫小型通用庫時。透過在不使用類型別名或lens中的輔助工具的情況下定義光學,可以避免這些問題。此外,型別只是別名,因此可以擁有多個光學框架(即lens和類似的庫),這些框架可以互換使用。

進一步閱讀

[edit | edit source]
  • 上面幾段,我們說過lens很容易提供足夠的材料來寫一整本書。我們目前最接近這個目標的是 Artyom Kazak 的 "lens over tea" 部落格系列。它探討了在lens中實現函式引用以及它背後的概念,其深度遠遠超過我們在這裡所能做到的。強烈推薦閱讀。
  • 可以透過 lens' GitHub wiki 獲得有用的資訊,當然, lens' API 文件 也值得探索。
  • lens是一個龐大而複雜的庫。如果你想研究它的實現,但想從更簡單的東西開始,一個好的起點是極簡的lens相容庫,例如microlenslens-simple.
  • 研究(和使用!)基於光學的庫是瞭解函式引用如何使用的一個好方法。一些任意示例
    • diagrams,一個向量圖形庫,使用lens來廣泛處理圖形元素的屬性。
    • wreq,一個帶有lens介面的 Web 客戶端庫。
    • xml-lens,它提供了用於操作 XML 的光學。
    • formattable,一個用於日期、時間和數字格式化的庫。 Formattable.NumFormat 是一個模組的例子,它提供lens相容的光學,而不依賴於lens包之外沒有任何依賴項。
華夏公益教科書