跳转到内容

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包之外没有任何依赖项。
华夏公益教科书