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 —— 歡迎你們關注個人推特