前言
Lens(透鏡)
是一個較為抽象的概念,顧名思義,它的作用是能夠深入到數據結構的內部中去,觀察和修改結構內的數據。Lens也像現實世界中的透鏡一樣,能相互組合形成透鏡組,以達到可操作結構更深層級數據的效果。
本篇文章將會介紹Lens的相關原理以及使用方式,涉及函數式編程的許多概念。在開始前可以先打個比喻,以激發大家對Lens的初步認識:你可以把Lens理解為不可變數據結構的Getter
跟Setter
。
這里有一點需要提及的是,在一些函數式編程語言(如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原理的文章,敬請期待。
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
協議中的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]
這種“更改”數據的方法在根本上也是沒有做到改變,保證了數據的不可變性。
引入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
函數接收兩個參數,這兩個參數都是函數類型,分表代表著這個字段的Getter
和Setter
函數:
-
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后,我們就可以通過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組合
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
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 —— 歡迎大家關注我的推特