函数式编程 – Lens(透镜)原理与应使用 [Swift形容]

作者 : 开心源码 本文共7438个字,预计阅读时间需要19分钟 发布时间: 2022-05-11 共72人阅读

前言

Lens(透镜)是一个较为笼统的概念,顾名思义,它的作使用是能够深入到数据结构的内部中去,观察和修改结构内的数据。Lens也像现实世界中的透镜一样,能相互组合形成透镜组,以达到可操作结构更深层级数据的效果。

本篇文章将会详情Lens的相关原理以及用方式,涉及函数式编程的许多概念。在开始前可以先打个比喻,以激发大家对Lens的初步认识:你可以把Lens了解为不可变数据结构的GetterSetter

这里有一点需要提及的是,在少量函数式编程语言(如Haskell)中,Lens有着高度笼统性的实现,均具有GetterSetter的功能。本篇用的程序形容语言为Swift,但因为Swift语言类型系统还不够完善,某些函数式编程中的类型特性暂时还无法实现(少量高阶的Type class,如Functor、Monad),无法像Haskell等语言一样,让Lens均具有GetterSetter的能力。考虑到Swift作为一门兼容面向对象编程范式的语言,可以通过点语法来对不可变数据结构的内部成员进行访问,所以本篇文章只对Lens的Setter特性进行实现和讲解。

在Haskell等语言中,Lens的实现核心为Functor(函子),其目的是为了提升笼统性,让Lens均具有SetterGetter的能力:Identity functor实现了Setter功能,Const functor实现了Getter功能。后期可能会推出用Haskell来形容Lens原理的文章,敬请期待。

Lens的Swift实现源码已经上传到Github,有兴趣的朋友可以点击查看:TangentW/Lens | Lens for Swift,欢迎提Issue或者PR。

你可能在日常的开发中很少使用到不可变数据,但是Lens的概念或者许可以为你的编程思维扩开视野,让你感受到函数式编程的另一番天地。

不可变数据

为保证程序的稳固运行,开发者时常需要花费大量精力去细致地调控各种可变的程序状态,特别是在多线程开发的情境下。数据的不变性是函数式编程中的一大特点,这种对数据的束缚能够保证纯函数的存在、减少程序代码中的不确定性因素,从而让开发者能够更容易地编写出健壮的程序。

Swift针对不可变数据建立了一套完善的机智,我们用let公告和定义的常量本身就具有不可变性(不过这里需要区分Swift的值类型和引使用类型,引使用类型因为传递的是引使用,就像指针一样,所以引使用类型常量不能保证其指向的对象不可改变)。

struct Point {    let x: CGFloat    let y: CGFloat}let mPoint = Point(x: 2, y: 3)mPoint.x = 5 // Error!

不可变数据的“更改”

很多时候,改变的确需要,程序在运行过程中不可能所有的状态都静止不动。事实上,“改变”对于不可变数据来说其实就是以原数据为基础去构建一个新的数据,所有的这些“改变”都不是发生在原数据身上:

// Oldlet aPoint = Point(x: 2, y: 3)// Newlet bPoint = Point(x: aPoint.x, y: aPoint.y + 2)

像是Swift STL中的很多API都是运使用了这种思想,如Sequence协议中的mapfilter方法:

let inc = { $0 + 1 }[1, 2, 3].map(inc) // [2, 3, 4]let predicate = { $0 > 2 }[2, 3, 4].filter(predicate) // [3, 4]

这种“更改”数据的方法在根本上也是没有做到改变,保证了数据的不可变性。

引入Lens

“改变”一个不可变数据,以原数据为基础,创立新的数据,这非常简单,就像前面展现的例子一样:

let bPoint = Point(x: aPoint.x, y: aPoint.y + 2)

但是假如数据的层级结构更加复杂时,这种对不可变数据进行“改变”的方法将迎来灾难:

// 代表线段的结构体struct Line {    let start: Point    let end: Point}// 线段Alet aLine = Line(    start: Point(x: 2, y: 3),    end: Point(x: 5, y: 7))// 将线段A的起点向上移动2个坐标点,得到一条新的线段Blet bLine = Line(    start: Point(x: aLine.start.x, y: aLine.start.y),    end: Point(x: aLine.end.x, y: aLine.end.y - 2))// 将线段B向右移动3个坐标点,得到一条新的线段Clet cLine = Line(    start: Point(x: bLine.start.x + 3, y: bLine.start.y),    end: Point(x: bLine.end.x + 3, y: bLine.end.y))// 用一条线段和一个端点确定一个三角形struct Triangle {    let line: Line    let point: Point}// 三角形Alet aTriangle = Triangle(    line: Line(      start: Point(x: 10, y: 15),      end: Point(x: 50, y: 15)    ),    point: Point(x: 20, y: 60))// 改变三角形A线段的末端点,让其成为一个等腰三角形Blet bTriangle = Triangle(    line: Line(        start: Point(x: aTriangle.line.start.x, y: aTriangle.line.start.y),        end: Point(x: 30, y: aTriangle.line.end.y)    ),    point: Point(x: aTriangle.point.x, y: aTriangle.point.y))

如上方例子所示,当数据的层次结构越深,这种基于原数据来创立新数据的“修改”方法将变得越复杂,最终你将迎来一堆无谓的模板代码,实在蛋疼无比。

Lens的诞生就是为理解决这种复杂的不可变数据的“修改”问题~

Lens

定义

Lens的定义很简单,它就是一个函数类型:

typealias Lens<Subpart, Whole> = (@escaping (Subpart) -> (Subpart)) -> (Whole) -> Whole

其中Whole泛型指代了数据结构本身的类型,Subpart指代了结构中特定字段的类型。

下面使用少量特定符号来代入了解这个Lens函数:

Lens = ((A) -> A') -> (B) -> B'

Lens函数接收一个针对字段的转换函数(A) -> A',我们根据获取到的字段的旧值A来创立一个新的字段值A’,当我们传入这个转换函数后,Lens将返回一个函数,这个函数将旧的数据B映射成了新的数据B’,也就是之前说到的用原来的数据去构造新的数据从而实现不可变数据的“改变”。

构建

我们可以针对每个字段进行Lens的构建:

extension Point {    // x字段的Lens    static let xL: Lens<CGFloat, Point> = { mapper in        return { old in            return Point(x: mapper(old.x), y: old.y)        }    }        // y字段的Lens    static let yL: Lens<CGFloat, Point> = { mapper in        return { old in            return Point(x: old.x, y: mapper(old.y))        }    }}extension Line {    // start字段的Lens    static let startL: Lens<Point, Line> = { mapper in        return { old in            return Line(start: mapper(old.start), end: old.end)        }    }        // end字段的Lens    static let endL: Lens<Point, Line> = { mapper in        return { old in            return Line(start: old.start, end: mapper(old.end))        }    }}

不过这样看来Lens的构建是有点复杂,所以我们可以创立一个使用于更为简单地初始化Lens的函数:

func lens<Subpart, Whole>(view: @escaping (Whole) -> Subpart, set: @escaping (Subpart, Whole) -> Whole) -> Lens<Subpart, Whole> {    return { mapper in { set(mapper(view($0)), $0) } }}

lens函数接收两个参数,这两个参数都是函数类型,分表代表着这个字段的GetterSetter函数:

  • view:类型(B) -> A ,B代表数据结构本身,A代表数据结构中某个字段,这个函数的目的就是为了从数据结构本身获取到指定字段的值。
  • set:类型(A, B) -> B',A是经过转换后得到的新的字段值,B为旧的数据结构值,B’则是基于旧的数据结构B和新的字段值A而构建出的新的数据结构。

现在我们可以用这个lens函数来进行Lens的构建:

extension Point {    static let xLens = lens(       view: { $0.x },        set: { Point(x: $0, y: $1.y) }    )    static let yLens = lens(        view: { $0.y },        set: { Point(x: $1.x, y: $0) }    )}extension Line {    static let startLens = lens(        view: { $0.start },        set: { Line(start: $0, end: $1.end) }    )    static let endLens = lens(        view: { $0.end },         set: { Line(start: $1.start, end: $0) }    )}

这样比起之前的Lens定义简洁了不少,我们在view参数中传入字段的获取方法,在set参数中传入新数据的创立方法就可。

Set / Over

定义好各个字段的Lens后,我们即可以通过setover函数来对数据结构进行修改了:

let aPoint = Point(x: 2, y: 3)// 这个函数能够让Point的y设置成5 (y = 5)let setYTo5 = set(value: 5, lens: Point.yLens)let bPoint = setYTo5(aPoint)// 这个函数能够让Point向右移动3 (x += 3)let moveRight3 = over(mapper: { $0 + 3 }, lens: Point.xLens)let cPoint = moveRight3(aPoint)

我们可以看一下overset函数的代码:

func over<Subpart, Whole>(mapper: @escaping (Subpart) -> Subpart, lens: Lens<Subpart, Whole>) -> (Whole) -> Whole {    return lens(mapper)}func set<Subpart, Whole>(value: Subpart, lens: Lens<Subpart, Whole>) -> (Whole) -> Whole {    return over(mapper: { _ in value }, lens: lens)}

非常简单,over只是单纯地调使用Lens函数,而set同样也只是简单调使用over函数,在传入over函数的mapper参数中直接将新的字段值返回。

组合

在前面说到,Lens的作使用就是为了优化复杂、多层次的数据结构的“更改”操作,那么对于多层次的数据结构,Lens是如何工作呢?答案是:组合,并且这只是普通的函数组合。这里首先详情下函数组合的概念:

函数组合

现有函数f: (A) -> B和函数g: (B) -> C,若存在类型为A的值a,我们希望将其通过函数fg,从而得到一个类型为C的值c,我们可以这样调使用:let c = g(f(a))。在函数以一等公民存在的编程语言中,我们可能希望将这种多层级的函数调使用能够更加简洁,于是引入了函数组合的概念:let h = g . f,其中,h的类型为(A) -> C,它是函数fg的组合,本身也是函数,而.运算符的作使用正是将两个函数组合起来。经过函数的组合后,我们即可以使用原来的值去调使用新得到的函数:let c = h(a)

在Swift中,我们可以定义以下的函数组合运算符:

func >>> <A, B, C> (lhs: @escaping (A) -> B, rhs: @escaping (B) -> C) -> (A) -> C {    return { rhs(lhs($0)) }}func <<< <A, B, C> (lhs: @escaping (B) -> C, rhs: @escaping (A) -> B) -> (A) -> C {    return { lhs(rhs($0)) }}

运算符>>><<<在左右两个运算值的类型上刚好相反,所以g <<< ff >>> g得到的组合函数相同。其中,>>>为左结合运算符,<<<为右结合运算符。

Lens组合

Lens本身就是函数,所以它们可以进行普通的函数组合:

let lineStartXLens = Line.startLens <<< Point.xLens

lineStartXLens这个Lens针对的字段是线段起始端点的x坐标Line.start.x,我们可以分析一下这个组合过程:

Line.startLens作为一个Lens,类型为((Point) -> Point) -> (Line) -> Line,我们可以看成是(A) -> B,其中A的类型为(Point) -> Point,B的类型为(Line) -> LinePoint.xLens的类型则为((CGFloat) -> CGFloat) -> (Point) -> Point,我们可以看成是(C) -> D,其中C类型为(CGFloat) -> CGFloat,D类型为(Point) -> Point。凑巧,我们可以看到其实A类型跟D类型是一样的,这样我们即可以把Point.xLens看成是(C) -> A,当我们把这两个Lens组合在一起后,我们即可以得到一个(C) -> B的函数,也就是类型为((CGFloat) -> CGFloat) -> (Line) -> Line的一个新Lens。

现在即可以用set或者over来操作这个新Lens:

// 将线段A的起始端点向右移动3个坐标let startMoveRight3 = over(mapper: { $0 + 3 }, lens: lineStartXLens)let bLine = startMoveRight3(aLine)

运算符

为了代码简洁,我们可以为Lens定义以下运算符:

func |> <A, B> (lhs: A, rhs: (A) -> B) -> B {    return rhs(lhs)}func %~ <Subpart, Whole>(lhs: Lens<Subpart, Whole>, rhs: @escaping (Subpart) -> Subpart) -> (Whole) -> Whole {    return over(mapper: rhs, lens: lhs)}func .~ <Subpart, Whole>(lhs: Lens<Subpart, Whole>, rhs: Subpart) -> (Whole) -> Whole {    return set(value: rhs, lens: lhs)}

它们的作使用是:

  • |>:左结合的函数应使用运算符,只是简单地将值传入函数中进行调使用,使用于减少函数连续调使用时括号的数量,加强代码的美观性和可读性。
  • %~:完成Lens中over函数的工作。
  • .~:完成Lens中set函数的工作。

用以上运算符,我们即可以写出更加简洁美观的Lens代码:

// 要做什么?// 1.将线段A的起始端点向右移动3个坐标值// 2.接着将终止点向左移动5个坐标值// 3.将终止点的y坐标设置成9let bLine = aLine    |> Line.startLens <<< Point.xLens %~ { $0 + 3 }    |> Line.endLens <<< Point.xLens %~ { $0 - 5 }    |> Line.endLens <<< Point.yLens .~ 9

KeyPath

配合Swift的KeyPath特性,我们就能够发挥Lens更增强大的能力。首先我们先对KeyPath进行Lens的扩展:

extension WritableKeyPath {    var toLens: Lens<Value, Root> {        return lens(view: { $0[keyPath: self] }, set: {            var copy = $1            copy[keyPath: self] = $0            return copy        })    }}func %~ <Value, Root>(lhs: WritableKeyPath<Root, Value>, rhs: @escaping (Value) -> Value) -> (Root) -> Root {    return over(mapper: rhs, lens: lhs.toLens)}func .~ <Value, Root>(lhs: WritableKeyPath<Root, Value>, rhs: Value) -> (Root) -> Root {    return set(value: rhs, lens: lhs.toLens)}

通过KeyPath,我们就不需要为每个特定的字段去定义Lens,直接开袋食使用就可:

let formatter = DateFormatter()    |> \.dateFormat .~ "yyyy-MM-dd"    |> \.timeZone .~ TimeZone(secondsFromGMT: 0)

由于DateFormatter是引使用类型,我们一般情况下对它进行配置是这样写的:

let formatter = DateFormatter()formatter.dateFormat = "yyyy-MM-dd"formatter.timeZone = TimeZone(secondsFromGMT: 0)...

比起这种传统写法,Lens的语法更加简洁美观,每一个对象的配置都在一个特定的语法块里,十分清晰。

不过这里需要注意的是,能够直接兼容Lens的KeyPath类型只能为WritableKeyPath,所以少量用let修饰的字段属性,我们还是要为他们创立Lens。

链接

TangentW/Lens | Lens for Swift —— 本文所对应的代码
@TangentsW —— 欢迎大家关注我的推特

说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » 函数式编程 – Lens(透镜)原理与应使用 [Swift形容]

发表回复