Cycoe@Home

Haskell – 初识 Lens

1. 什么是 Lens

在 Haskell 等不可变语言中,如果我们希望修改数据中的某个值,就需要创建数据的一个新的实例。如我们定义一个名为 Name 的数据结构表示人名:

:set +m
data Name = Name { _first :: String
                 , _last :: String
                 } deriving Show

然后定义我们的名字,我们可以通过记录语法提供的 _first_last 函数分别从 Name 对象中获取两个成员,这种形式的函数被称为 getter 函数

name = Name "Cycoe" "Joo"
print name
Name {_first = "Cycoe", _last = "Joo"}

如果我们想要方便地对 first name 进行修改,我们可以定义一个 setFirst 工具函数。接收一个 Name 和新的 first name,返回新的 Name 对象,这种形式的函数被称为 setter 函数

我们可以使用这个函数修改我们的 first name 并生成一个新的名字

:{
setFirst :: Name -> String -> Name
setFirst (Name _ l) f' = Name f' l
:}

setFirst name "Handsome"
Name {_first = "Handsome", _last = "Joo"}

那么有没有什么办法可以通过一个统一的数据类型表示 setter 和 getter 函数呢?当然有,那就是已经被发现的 Lens 类型。Lens 含义是透镜,它的功能就是提供对数据结构内部成员进行查看(view)、写入(set)和变换(over)的能力。

2. 定义 Lens 类型

Lens 类型可以被定义为如下的类型

:set -XRankNTypes
type Lens s a = forall f. Functor f => (a -> f a) -> s -> f s

Lens 被定义为一个高阶函数,接收一个将成员类型 a 转换为 Functor a 的函数和一个聚合类型 s ,并返回 Functor s 。此处为什么会引入 Functor?到下面我们会逐渐明白

有了 Lens 类型,我们还需要一个工具函数 lens 帮助我们将 settergetter 函数构造成 Lens

lens :: (s -> a) -> (s -> a -> s) -> Lens s a

lens 的类型非常清晰,接收一个 getter 函数和一个 setter 函数并返回构造好的 Lens 对象。但是我们要怎么实现它呢?

既然我们已经知道 Lens 类型是一个函数,那我们可以试着将它展开,得到 lens 函数的真实类型

lens :: (s -> a) -> (s -> a -> s) -> (a -> f a) -> s -> f s
lens getter setter f s = ???

我们使用了一些变量表示函数的参数,那么等号的右边又该是什么样的呢?

在实现 Haskell 函数时,一种常用的思路是根据组合的方式拼出需要的类型,再来验证是否正确,此处我们也可以这样做。我们使用 getters 对象中获取出成员 a ,再使用函数 fa 转换为 Functor a ,最后再将 setter s 作用到函子 Functor a 上得到函子 Functor s

:{
lens :: (s -> a) -> (s -> a -> s) -> Lens s a
lens getter setter f s = setter s <$> f (getter s)
:}

这样我们就可以构造我们的第一个 Lens

:{
firstL :: Lens Name String
firstL = lens _first setFirst
:}
:t firstL
firstL :: Functor f => (String -> f String) -> Name -> f Name

3. view 函数

有了 Lens 之后我们要怎么利用它查看结构体中的成员呢?我们需要定义一个 view 函数,接收一个透镜对象和一个结构体,返回要查看的成员

view :: Lens s a -> s -> a
view l s = ???

现在我们有了透镜 l 和结构体 s ,我们需要利用这两个对象构造出类型 a 。我们的透镜是由 lens 函数生成的,因此 l 等价于如下表示,对应到上面 lens 函数的实现

l :: Functor f => (a -> f a) -> s -> f s
l f s = setter s <$> f (getter s)

也就是说我们可以通过把函数 f 和结构体 s 传给 l 生成一个 f s 类型的返回值。那么在函数 view 的参数中我们已经有了 ls ,还缺少一个函数 f 将类型 a 转换为函子。此处函子类型的选择是关键,这也是 Lens 类型中引入函子类型的原因,我们通过选择不同类型的函子实现不同的功能。

此处我们通过 getter s 拿到了成员 a ,又用函数 fa 转换为了函子 Functor a ,又将 setter s 作用到了函子上得到了新的 s 。对于 view 函数来说,我们只希望前半部分生效,也就是说我们希望 setter s <$> f a 仍返回 f a ,并且内部的值不变。

那什么样的函子能满足这个条件呢?这里我们可以定义一个 Const 函子类型,它的性质为任何函数作用在它上面都不会影响内部的值

newtype Const a b = Const { runConst :: a }

Const a 是函子类型类的实例

:{
instance Functor (Const a) where
  fmap _ (Const a) = Const a
:}

我们可以尝试定义一个 Const 函子的实例,并且内部保存数字 1。我们在上面作用函数 (+10) ,通过 runConst 函数获取内部值可以发现保存的值仍为 1

c = Const 1
runConst $ (+10) <$> c
1

那么我们就可以实现 view 函数了

:{
view :: Lens s a -> s -> a
view l s = runConst $ l Const s
:}

为了使用方便可以将 view 实现为运算符 ^.

:{
infixr 4 ^.
(^.) :: s -> Lens s a -> a
(^.) s l = runConst $ l Const s
:}

快来试一下吧

name ^. firstL
Cycoe

4. set 函数

set 函数用于设置聚合数据中的成员,接收透镜 l 、一个原始的聚合数据 s 和要设置的成员值 a ,返回新的聚合数据对象

set :: Lens s a -> a -> s -> s
set l a s = ???

参考我们实现 view 的思路,在此处我们也需要选取一个合适的函子来完成 set 函数。但是与 view 函数中使用的 Const 函子不同,此处我们需要一个能把 setter s 函数作用到内部类型上的函子。标准库中其实已经内置了这个函子,就是 Identity

:{
newtype Identity a = Identity { runIdentity :: a }
instance Functor Identity where
  fmap f (Identity a) = Identity $ f a
:}

那么我们仿照 view 函数的方式补全 set 函数的实现

set :: Lens s a -> a -> s -> s
set l a s = runIdentity $ l Identity s

这个实现对嗎?仔细观察一下就会发现问题,因为我们根本没有使用到变量 a 。再来分析一下 Lens 类型,此处我们希望的流程是通过 getter s 拿到原本的成员 a ,通过某一个函数将 a 转换为 Functor a ,最后再将 setter s 作用上去,并且我们希望忽略掉原本通过 getter 取出的成员 a

这里我们需要引入一个函数 const ,这个函数与 Const 函子不同

:{
const :: a -> b -> a
const a _ = a
:}

也就是说 const 函数可以绑定一个 a 类型的变量返回一个函数,这个函数不管输入什么都会返回原本绑定的变量 a

constF = const 1
constF 2
constF "Cycoe"
1
1

那么我们可以利用这个函数和 Identity 组合出一个新的函数 Identity . const a ,这个函数不管接收什么参数都会返回我们绑定的变量 a ,那么我们的 set 函数可以实现为

:{
set :: Lens s a -> a -> s -> s
set l a s = runIdentity $ l (Identity . const a) s
:}

同样的定义一个对应的运算符 .~

:{
infixr 4 .~
(.~) :: Lens s a -> a -> s -> s
(.~) = set
:}

使用 set 函数设置成员

firstL .~ "Handsome" $ name
Name {_first = "Handsome", _last = "Joo"}

5. over 函数

over 函数的功能是通过一个变换函数 a -> a 修改聚合类型中的成员,有了 set 函数的经验我们可以非常简单地写出 over 函数的实现

:{
over :: Lens s a -> (a -> a) -> s -> s
over l f s = runIdentity $ l (Identity . f) s
:}

同样地,定义 over 函数对应的运算符

:{
infixr 4 %~
(%~) :: Lens s a -> (a -> a) -> s -> s
(%~) = over
:}

使用 over 函数将 first name 变为全部字母大写

import Data.Char (toUpper)
firstL %~ (map toUpper) $ name
Name {_first = "CYCOE", _last = "Joo"}

6. 总结

有了 Lens 类型和 viewsetover 函数,我们可以方便地对聚合类型中的成员执行查看、修改与变换操作。下一篇 Blog 中我们将探讨如何处理泛型类型,即将形如 Data a 的类型变换为 Data b ,以及如何处理嵌套的聚合类型

Author: Cycoe (cycoejoo@163.com)
Date: <2024-01-27 Sat 22:56>
Generator: Emacs 29.2 (Org mode 9.6.15)
Built: <2024-01-29 Mon 23:10>