利用 Swift 5.1 新功能實作 Fluent Interface 讓程式碼更易讀流暢!


最近,SwiftUI 正如火如荼地在全世界進行公開測試。如果你也有經意或不經意地接觸到 SwiftUI,那你可能會發現,它在設定 View 性質的語法上,跟我們以前學過的很不一樣。

一般在設定物件的時候,我們通常是這樣寫的:

let imageView = UIImageView(image: myImage)
imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
imageView.backgroundColor = .white
imageView.alpha = 0.5

但是在 SwiftUI 裡,我們卻得這樣寫:

Image(uiImage: myImage)
    .frame(width: 100, height: 100)
    .background(Color.white)
    .opacity(0.5)

差別很大對不對?先不要管句型的細節,光就排版的美感來說,後者不只比前者簡潔許多,它的縮排也很清楚地告訴讀者:後面三行都是第一行的子句,重點還是在第一行。反之,前者的每一行縮排都一樣,看不出重點,而且每一句還都得重複寫一次 imageView 這個變數名,相當累贅。很明顯的,後者的可讀性比較高。

SwiftUI 所採用的這種寫法並不是什麼新的發明。事實上,著名的程式設計架構專家 Martin Fowler 早在 2005 年的時候就已經給它一個名字了,叫做 Fluent Interface ,或者流暢界面。為什麼這樣稱呼呢?首先,它是一種操作物件的界面,跟屬性界面一樣,是一種寫作風格,而非一種全面性的架構。再來,這種風格的重點就在於它的易讀性與流暢性。因為它不需要用一個暫時的變數來操作物件,所以寫出來的程式碼雜音很少。

在 Swift 5.1 以前

流暢界面本身其實是針對物件導向程式語言的寫作風格,所以不管是 Objective-C,還是一開始的 Swift 都可以把物件的界面設計成流暢界面。不過,這些語言並沒有像對屬性界面的支援一樣,特別去支援流暢界面。所以,如果要支援流暢界面的話,我們必須要手動去改每一個屬性。

舉例來說,如果原本的型別界面長這個樣子:

class Scene {
    var title: String { get set }
    // ...
}

那我們必須手動給每個屬性加上這樣的方法:

extension Scene {
    func title(_ title: String) -> Scene {
        self.title = title
        return self
    }
}

為什麼要回傳 self 呢?因為如此才能實現方法鏈 (Method Chaining),一個方法接一個方法地呼叫下去,而不用開新的陳述句。

// 不斷行
Scene().title("Scene 1").backgroundColor(.yellow)

// 斷行
Scene()
    .title("Scene 1")
    .backgroundColor(.yellow)

這樣的做法對開發者來說負擔不小,因為每次屬性界面一改,我們就得跟著去改流暢界面的方法。或許有天 Xcode 會內建自動產生流暢界面的功能,又或者某位熱心人士會寫出相關的 Xcode 擴充套件,但目前,我們還是得手動去同步兩種不同的界面。

但如果你用的 Swift 版本是 5.1 版以上的話,你還有另一個選擇。

Dynamic Member Lookup

在 4.2 版的時候,Swift 新增了一個比較少被提到的功能:Dynamic Member Lookup,或者動態成員查詢。這個功能基本上就是讓我們能夠用屬性語法,去存取原本需要用字串存取的值。比如說,如果原本的型別定義是這樣:

struct Person {
    var info: [String: Any]
}

實作動態成員查詢的方式是這樣的:

// 1. 在型別定義前加上 @dynamicMemberLookup 關鍵字
@dynamicMemberLookup
struct Person {
    var info: [String: Any]

    // 2. 新增一個名為 subscript(dynamicMember:) 的下標方法
    subscript(dynamicMember infoKey: String) -> Any? {
        get {
            return info[infoKey]
        }
        set {
            info[infoKey] = newValue
        }
    }
}

接著,我們就可以像直接存取 Person 實體的屬性一樣,存取它的 info 屬性內容:

person.name = "Emilia" // 等於寫 personA.info["name"] = "Emilia"
print(person.name) // 等於寫 print(personA.info["name"])

這個功能主要是設計來支援跟 Python、Javascript、或 Ruby 等動態語言的互通性 (interoperatibility),算是比較少見的案例,所以也不多人談。但為什麼我會在這邊提到呢?

因為在 Swift 5.1 裡,這個功能升級了。

Swift 5.1 的 Key Path Member Lookup

在 Swift 5.1 中,除了字串之外,現在也可以用 key path 來當作動態成員查詢的媒介

假設我們把 Person 的定義改成這樣:

struct Person {
    struct Info {
        var name: String
    }
    var info: Info
}

加上 Key path member lookup 的方式如下:

// 1. 一樣使用 @dynamicMemberLookup 關鍵字
@dynamicMemberLookup
struct Person {
    struct Info {
        var name: String
    }
    var info: Info

    // 2. 把下標 subscript(dynamicMember:) 的參數型別改成 KeyPath
    // 這邊因為要使界面是讀寫皆可,所以使用 WritableKeyPath 通用型別
    // 通用型別 Value 指的則是查詢目標的型別
    subscript<Value>(dynamicMember keyPath: WritableKeyPath<Info, Value>) -> Value {
        get {
            return info[keyPath: keyPath]
        }
        set {
            info[keyPath: keyPath] = newValue
        }
    }
}

現在,我們可以這樣存取 person.info.name 了:

person.name = "Jackson"
print(person.name) // Jackson

Wrapper Type

如果你有開 Xcode 照著實作的話,可能會發現一件事:打出「person.」之後,「name」也會出現在自動完成的清單裡面!這是因為編譯器現在可以從 Key path 去查詢所有的目標、以及它們的型別了。正是因為如此,Key path 成員查詢的應用範圍比字串成員查詢還要大很多。

舉例來說,它最主要的設計目標,就是拿來給所謂的包裝型別 (Wrapper Type) 用:

// 基本上就是把 Person 的 Info 換成一個通用型別(Content)。
@dynamicMemberLookup
struct Wrapper<Content> {
    var content: Content

    subscript<Value>(dynamicMember keyPath: WritableKeyPath<Content, Value>) -> Value {
        get {
            return content[keyPath: keyPath]
        }
        set {
            content[keyPath: keyPath] = newValue
        }
    }
}

// 可以直接把 Wrapper<Scene> 當成 Scene 來存取屬性
var scene2 = Wrapper(content: Scene())
scene2.title = "Scene 2"

你可能會問:這樣有什麼意義呢?

關鍵在於 subscript(dynamicMember:) 這個下標方法。當我們用 key path 成員查詢來存取 content 的任何屬性時,每次都會經過 subscript(dynamicMember:),所以我們有機會在這裡做額外的處理。另外一點很重要的是,subscript(dynamicMember:) 的回傳值是沒有限制型別的。綜合這兩點來說,所謂的包裝型別,其實就等於是一個轉換器

比如說,我們可以定義一個專門回傳屬性型別的包裝型別,類似 type(of:) 的功能:

@dynamicMemberLookup
struct PropertyTypeInspector<Subject> {
    let subject: Subject

    // 這裡的回傳型別是一個 Metatype
    subscript<Value>(dynamicMember keyPath: KeyPath<Subject, Value>) -> Value.Type {
        return type(of: subject[keyPath: keyPath])
    }
}

let inspector = PropertyTypeInspector(subject: Scene())
print(inspector.title) // String

其它的運用包括把值語義(Value Semantics) 轉換成參照語義 (Reference Semantics) 等,不過那已經超出本文的主題了。

等一下!本文的主題不是流暢界面嗎?Key path 成員查詢與包裝型別又跟流暢界面有什麼關係呢?

Wrapper Type 與 Fluent Interface

其實講到這邊,只剩下一步就可以把兩件事串起來了。

讓我們回頭看看流暢界面的實作方式:

class Scene {
    // 屬性界面
    var title: String { get set }
}

extension Scene {
    // 流暢界面
    func title(_ title: String) -> Scene {
        self.title = title
        return self
    }
}

簡單來說,就是把屬性界面轉換成一個回傳 Self 的 Setter 方法。

而包裝型別的本質是什麼?轉換

也就是說,我們可以設計一個包裝型別,去把所有可寫入的屬性都轉換成回傳 Self 的 Setter 方法:

@dynamicMemberLookup
struct FluentInterface<Subject> {
    let subject: Subject

    // 因為要動到 subject 的屬性,所以 keyPath 的型別必須是 WritableKeyPath
    // 回傳值是一個 Setter 方法
    subscript<Value>(dynamicMember keyPath: WritableKeyPath<Subject, Value>) -> ((Value) -> FluentInterface<Subject>) {

        // 因為在要回傳的 Setter 方法裡不能更改 self,所以要把 subject 從 self 取出來用
        var subject = self.subject

        // subject 實體的 Setter 方法
        return { value in

            // 把 value 指派給 subject 的屬性
            subject[keyPath: keyPath] = value

            // 回傳的型別是 FluentInterface<Subject> 而不是 Subject
            // 因為現在的流暢界面是用 FluentInterface 型別來串,而不是 Subject 本身
            return FluentInterface(subject: subject)
        }
    }
}

接著,只要把任何實體FluentInterface 包起來,它的所有可寫入屬性就都會變成流暢界面了:

FluentInterface(subject: Scene()) // 把 Scene() 包進去
    .title("Scene 3") // 流暢界面
    .subject // 讀取更改後的內容物

怎麼辦到的?拆開來看就知道了:

let interface = FluentInterface(subject: Scene())
interface // 型別為 FluentInterface<Scene>
interface.title // (String) -> FluentInterface<Scene>
interface.title("Scene 3") // FluentInterface<Scene>
interface.title("Scene 3").subject // Scene

拿來改寫一開始的例子的話:

import UIKit

FluentInterface(subject: UIView())
    .frame(CGRect(x: 0, y: 0, width: 100, height: 100))
    .backgroundColor(.white)
    .alpha(0.5)
    .subject

我們只寫了一個型別,就省下了手動實作的許多麻煩,是不是心情都流暢起來了呢?

但說實在的,每次要用流暢界面的時候,都要把物件包起來再解開來,還是有點不舒爽。這兩個步驟雖然基本上是無法避免的,但我們可以想辦法讓它更流暢一點。

自訂運算子與你

自Swift 推出以來,就有自訂運算子 (Custom Operator) 這個頗有爭議的功能。它帶給開發者自行設計語法的自由度,但一旦被濫用的話,程式碼很容易變得一團亂,而且難以維護。簡單來說,它是一個需要小心使用的功能。

運算子可以拿來幹嘛呢?其實運算子不只包含加減乘除、或比較大小等數學操作,也可以拿來做任何其它事情。比如說,我們就可以用 ! 來強制解開 Optional

同樣的道理,我們也可以設計用來包裝與解開 FluentInterface 的運算子。我的選擇是 +-,但你也可以嘗試別的組合。

// 原本 + 只被用在 infix,所以需要另外宣告為 postfix 運算子
postfix operator +

// 把任何實體用 FluentInterface 包裝起來的函數
postfix func + <T>(lhs: T) -> FluentInterface<T> {
    return FluentInterface(subject: lhs)
}

// 同上
postfix operator -

// 把 FluentInterface 的內容取出的函數
// 也可以宣告成 FluentInterface 的 static 方法
postfix func - <T>(lhs: FluentInterface<T>) -> T {
    return lhs.subject
}

如此宣告之後,一開始的例子就可以改成這樣了:

UIView()+ // 把 UIView 實體包進 FluentInterface 結構體
    .frame(CGRect(x: 0, y: 0, width: 100, height: 100))
    .backgroundColor(.white)
    .alpha(0.5)- // 從 FluentInterface 結構體中取出 UIView 實體

是不是跟 SwiftUI 的風格幾乎一樣了呢?


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
Shares
Share This