Lens(透镜)
是一个较为抽象的概念,顾名思义,它的做用是可以深刻到数据结构的内部中去,观察和修改结构内的数据。Lens也像现实世界中的透镜同样,能相互组合造成透镜组,以达到可操做结构更深层级数据的效果。git
本篇文章将会介绍Lens的相关原理以及使用方式,涉及函数式编程的许多概念。在开始前能够先打个比喻,以激发你们对Lens的初步认识:你能够把Lens理解为不可变数据结构的Getter
跟Setter
。github
这里有一点须要说起的是,在一些函数式编程语言(如Haskell)中,Lens有着高度抽象性的实现,均具有Getter
跟Setter
的功能。本篇使用的程序描述语言为Swift,但因为Swift语言类型系统还不够完善,某些函数式编程中的类型特性暂时还没法实现(一些高阶的Type class,如Functor、Monad),没法像Haskell等语言同样,让Lens均具有Getter
和Setter
的能力。考虑到Swift做为一门兼容面向对象编程范式的语言,能够经过点语法
来对不可变数据结构的内部成员进行访问,因此本篇文章只对Lens的Setter
特性进行实现和讲解。编程
在Haskell等语言中,Lens的实现核心为Functor(函子)
,其目的是为了提高抽象性,让Lens均具有Setter
和Getter
的能力:Identity functor
实现了Setter
功能,Const functor
实现了Getter
功能。后期可能会推出使用Haskell来描述Lens原理的文章,敬请期待。api
Lens的Swift实现源码已经上传到Github,有兴趣的朋友能够点击查看:TangentW/Lens | Lens for Swift,欢迎提Issue或PR。数组
你可能在平常的开发中不多用到不可变数据,可是Lens的概念或许能够为你的编程思惟扩开视野,让你感觉到函数式编程的另外一番天地。数据结构
为保证程序的稳定运行,开发者时常须要花费大量精力去细致地调控各类可变的程序状态,特别是在多线程开发的情境下。数据的不变性是函数式编程中的一大特色,这种对数据的约束可以保证纯函数的存在、减小程序代码中的不肯定性因素,从而让开发者可以更容易地编写出健壮的程序。多线程
Swift针对不可变数据创建了一套完善的机智,咱们使用let
声明和定义的常量自己就具有不可变性(不过这里须要区分Swift的值类型和引用类型,引用类型因为传递的是引用,就像指针同样,因此引用类型常量不能保证其指向的对象不可改变)。app
struct Point {
let x: CGFloat
let y: CGFloat
}
let mPoint = Point(x: 2, y: 3)
mPoint.x = 5 // Error!
复制代码
不少时候,改变确实须要,程序在运行过程当中不可能全部的状态都静止不动。事实上,“改变”对于不可变数据来讲其实就是以原数据为基础去构建一个新的数据,全部的这些“改变”都不是发生在原数据身上:编程语言
// Old
let aPoint = Point(x: 2, y: 3)
// New
let bPoint = Point(x: aPoint.x, y: aPoint.y + 2)
复制代码
像是Swift STL中的不少API都是运用了这种思想,如Sequence
协议中的map
和filter
方法:函数式编程
let inc = { $0 + 1 }
[1, 2, 3].map(inc) // [2, 3, 4]
let predicate = { $0 > 2 }
[2, 3, 4].filter(predicate) // [3, 4]
复制代码
这种“更改”数据的方法在根本上也是没有作到改变,保证了数据的不可变性。
“改变”一个不可变数据,以原数据为基础,建立新的数据,这很是简单,就像前面展现的例子同样:
let bPoint = Point(x: aPoint.x, y: aPoint.y + 2)
复制代码
可是若是数据的层级结构更加复杂时,这种对不可变数据进行“改变”的方法将迎来灾难:
// 表明线段的结构体
struct Line {
let start: Point
let end: Point
}
// 线段A
let aLine = Line(
start: Point(x: 2, y: 3),
end: Point(x: 5, y: 7)
)
// 将线段A的起点向上移动2个坐标点,获得一条新的线段B
let 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个坐标点,获得一条新的线段C
let 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
}
// 三角形A
let aTriangle = Triangle(
line: Line(
start: Point(x: 10, y: 15),
end: Point(x: 50, y: 15)
),
point: Point(x: 20, y: 60)
)
// 改变三角形A线段的末端点,让其成为一个等腰三角形B
let 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的定义很简单,它就是一个函数类型:
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
函数接收两个参数,这两个参数都是函数类型,分表表明着这个字段的Getter
和Setter
函数:
(B) -> A
,B表明数据结构自己,A表明数据结构中某个字段,这个函数的目的就是为了从数据结构自己获取到指定字段的值。(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
参数中传入新数据的建立方法便可。
定义好各个字段的Lens后,咱们就能够经过set
和over
函数来对数据结构进行修改了:
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)
复制代码
咱们能够看一下over
和set
函数的代码:
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,咱们但愿将其经过函数f
和g
,从而获得一个类型为C的值c,咱们能够这样调用:let c = g(f(a))
。在函数以一等公民存在的编程语言中,咱们可能但愿将这种多层级的函数调用可以更加简洁,因而引入了函数组合的概念:let h = g . f
,其中,h
的类型为(A) -> C
,它是函数f
和g
的组合,自己也是函数,而.
运算符的做用正是将两个函数组合起来。通过函数的组合后,咱们就能够用原来的值去调用新获得的函数: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 <<< f
和f >>> g
获得的组合函数相同。其中,>>>
为左结合运算符,<<<
为右结合运算符。
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) -> Line
。Point.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坐标设置成9
let bLine = aLine
|> Line.startLens <<< Point.xLens %~ { $0 + 3 }
|> Line.endLens <<< Point.xLens %~ { $0 - 5 }
|> Line.endLens <<< Point.yLens .~ 9
复制代码
配合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 —— 欢迎你们关注个人推特