函數語言程式設計 – Lens(透鏡)原理與應用 [Swift描述]

TangentW發表於2019-03-02

前言

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

相關文章