函数式编程 - Lens(透镜)原理与应用 [Swift描述]

前言

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!

不可变数据的“更改”

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

// Old
let aPoint = Point(x: 2, y: 3)
// New
let 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
}

// 线段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

定义

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。

现在就可以使用setover来操作这个新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

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 —— 欢迎大家关注我的推特

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,829评论 1 331
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,603评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,846评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,600评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,780评论 3 272
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,695评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,136评论 2 293
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,862评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,453评论 0 229
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,942评论 2 233
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,347评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,790评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,293评论 3 221
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,839评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,448评论 0 181
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,564评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,623评论 2 249

推荐阅读更多精彩内容