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包之外没有任何依赖项。