函數式編程 - 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閱讀 228,412評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,514評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,373評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,975評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,743評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,199評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,262評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,414評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,951評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,780評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,527評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,218評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,649評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,889評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,673評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,967評論 2 374

推薦閱讀更多精彩內容