iOS App 程式開發

從零打造基本版 Combine 認識 Functional Reactive Programming

隨著 Apple 在 WWDC 2019 推出了官方的 Functional Reactive Programming 框架 Combine,FRP 可以說是大勢所趨。此文將帶大家打造一個基本版的類 Combine 框架,讓你一步一步了解 FRP 的基本概念。
從零打造基本版 Combine 認識 Functional Reactive Programming
從零打造基本版 Combine 認識 Functional Reactive Programming
In: iOS App 程式開發, Swift 程式語言, SwiftUI 框架

這篇文章可搭配投影片版閱讀。

Apple 平台的開發者社群對於 FRP (Functional Reactive Programming ,函數式反應式程式設計) 的接受,相對於網頁開發者來說算是晚的。即使有了 RxSwiftReactiveSwift 等開源的 FRP 框架流行,FRP 似乎還是被社群視為是一個艱深難學、不適合一般人的概念。不過,這樣的觀感正在改變中。隨著 Apple 在 WWDC 2019 推出了官方的 FRP 框架 Combine,大家終於發現到 FRP 是未來所趨,學習它好像變得不可避免。

不過,只接觸了命令式程式設計 (Imperative Programming) 的開發者,大概會覺得 FRP 的宣告式 (Declarative) 寫法像是外星來的東西吧。比方說,同樣是做一個網路呼叫,兩者的寫法就非常不一樣:

// 一般的命令式寫法

let task = URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in
    if let data = data {
        // 處理 data...
    }
})
task.resume()


// Combine 的宣告式寫法

let subscription = URLSession.shared.dataTaskPublisher(for: url)
    .map { $0.data }
    .sink(receiveCompletion: { completion in // 處理 completion...  },
          receiveValue: { data in // 處理 data...  })

我們可以看到,在命令式寫法中,我們是把 url 跟一個名為 completionHandler 的閉包放進 dataTask(with:completionHandler:) 裡面。呼叫之後,我們取回一個 URLSessionDataTask,並以此來控制網路呼叫的生命週期(開始、取消等)。

但是在宣告式寫法中,以上的做法都被打破、重組了。風格上來說,不同的陳述句被整合在一起,變成中間有斷行的同一個句子。completionHandler 被拆解成更小的閉包,並且少了 ifelse 等控制流 (Control Flow) 語法。另一方面,則是多了 map(_:)sink(receiveCompletion:receiveValue:) 等可以連鎖呼叫的方法。

為甚麼要這樣寫呢?

時間性的序列 (Temporal Sequence)

讓我們先來想想,到底 FRP 指的是甚麼東西。社群裡,幾個比較流行的 FRP 框架分別是這樣定義自己的:

An API for asynchronous programming with observable streams… ReactiveX is a combination of the best ideas from the Observer pattern, the Iterator pattern, and functional programming
ReactiveX

ReactiveSwift offers composable, declarative and flexible primitives that are built around the grand concept of streams of values over time.
These primitives can be used to uniformly represent common Cocoa and generic programming patterns that are fundamentally an act of observation…
ReactiveSwift

Customize handling of asynchronous events by combining event-processing operators…The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events.
Combine

在這些定義中,我們可以發現幾個關鍵字:Asynchronous (非同步)、streams(流)、observation(觀察)等。這些概念也是網上多數 FRP 相關的文章的切入點,但我今天想從一個函數式 (functional) 的角度來解析 FRP,所以 values over time 這個描述才是重點。

甚麼是 values over time 呢?如果我們拆開來看的話,“over time” 指的是隨著時間推移,而 “values” 則是複數形態的值。綜合起來,values over time 就可以解釋成「隨著時間推移而存在的多個值」。而如果我們把「多個值」以序列 (Sequence) 來表示的話,它也可以被描述為「時間性的序列」。事實上,這也就是 Streams 的概念。

所以,讓我們先忘記 ReactiveX 定義中提到的觀察者模式與迭代器模式,著眼於怎樣對時間性序列做操作吧!不過在開始解析之前,我們還是得先認識 Combine 的基礎使用方法。

怎樣使用 Combine

Combine 框架主要分成發布者 (Publisher)、操作者 (Operator)、和訂閱者 (Subscriber) 三大部分。我們可以透過這三種元件的組裝,來建立各式各樣的訂閱關係 (Subscription):

// 4. 訂閱關係
let subscription = 

    // 1. 發布者
    URLSession.shared.dataTaskPublisher(for: url)

    // 2. 操作者
    .map { $0.data }

    // 3. 訂閱者
    .sink(receiveCompletion: { completion in // 處理 completion...  },
          receiveValue: { data in // 處理 data...  })
  1. 回傳一個發布者結構體。發布者代表的,是值的產生源頭。
  2. 使用 map(_:) 與其它操作者方法,來對發布者產生的值做變動。
  3. 使用 sink(receiveCompletion:receiveValue:) 等訂閱者方法,來處理最後拿到的值,並且啟動與發布者之間的訂閱關係。
  4. 代表訂閱關係的實體,可以用來取消訂閱。

發布者、操作者與訂閱者這些概念,對於命令式寫法的工廠方法與 completionHandler 閉包來說,看起來或許非常陌生,但其實它們可以用一個非常古老的 OOP(Object-Oriented Programming,物件導向程式設計)設計模式來理解,那就是 ⋯⋯

建造者模式 (Builder Pattern)

在 Foundation 裡面有一個型別叫做 URLComponents,這是專門用來建構 URL 的一個結構體。通常我們是用初始化方法 (Initializer) 與工廠方法 (Factory Method) 來建構實體,而建構實體所需的資訊就用參數傳進去方法裡面。但是 URLComponents 不一樣,它是把這些資訊先存起來,等到被呼叫 url 屬性的時候,才建構一個 URL 出來:

// 1. 建構空的 URLComponents 實體。
var components = URLComponents()

// 2. 將 URL 的個別部位分別存入 components 裡面。
components.scheme = "https"
components.host = "www.example.com"
components.path = "/path/to/resource"

// 3. 呼叫 components 的 url 屬性以建構一個 URL 實體。
let url = components.url

這樣的建構模式明顯地跟初始化方法與工廠方法不同,而且它其實有自己的名字:建造者模式。它的結構就如前面所展示的,分成三個步驟:

  1. 先取得一個空的建造者。
  2. 把建構所需的資訊存到建造者裡面。
  3. 呼叫建造者的 build() 方法來建構產品(前例為 URLComponentsurl 屬性)。

而如果我們比對一下 Combine 的使用方式,就會發現其實它的三大元件:發布者、操作者與訂閱者,正好可以對應到這三個步驟:

// 4. 訂閱關係:Combine 中的建構產品。
let subscription = 

    // 1. 發布者:Combine 中的建造者。
    URLSession.shared.dataTaskPublisher(for: url)

    // 2. 操作者:Combine 中的建構資訊。
    .map { $0.data }

    // 3. 訂閱者:Combine 中的 build()。
    .sink(receiveCompletion: { completion in // 處理 completion...  },
          receiveValue: { data in // 處理 data...  })

簡單來說,就是用建造者模式把網路呼叫的整個操作都抽象化了。照著這個思路,我們已經可以開始嘗試寫基本版 Combine 的骨架了。

DIY:發布者+訂閱者

首先,還是讓我們先從一般的命令式寫法開始:

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let data = data {
        // 處理 data...
    }
}
task.resume()

請記住,我們是要把這幾行程式碼的操作,當成是建造者的最終產品。所以這裡我們不用去考慮協定或繼承、重寫等複雜的事情,只要大筆一揮,把整段程式碼包到一個閉包裡面就可以了:

// 相當於建造者
let subscribe = {
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data {
            // 處理 data...
        }
    }
    task.resume()
}

// 相當於建造者的 build()
subscribe()

但這個閉包應該要可以注入一個 completionHandler,否則我們就完全無法更動它,獲得伺服器回應之後的行為了:

let subscribe = { (dataHandler: @escaping (Data) -> Void) in
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data {
            dataHandler(data)
        }
    }
    task.resume()
}

subscribe { data in
    // 處理 data...
}

用閉包雖然簡單明瞭,但如果要重用的話,還是把它用結構體來抽象會方便一點:

// 直接把程式碼用一個 struct 包起來。
struct Publisher {
    let subscribe = { (dataHandler: @escaping (Data) -> Void) in
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let data = data {
                dataHandler(data)
            }
        }
        task.resume()
    }
}

Publisher().subscribe { data in
    // 處理 data...
}

哪裡方便呢?

接下來就是見證奇蹟的時刻了:

// 把閉包的內容剪下,只留下閉包的型別描述。
struct Publisher {
    let subscribe: (@escaping (Data) -> Void) -> Void
}

// 把閉包的內容直接貼到 Publisher 的建構式後面,然後再整理一下。
Publisher { dataHandler in
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        if let data = data {
            dataHandler(data)
        }
    }
    task.resume()
}.subscribe { data in
    // 處理 data...
}

我們把 subscribe 閉包的實作從 Publisher 的定義裡面拿走,只剩型別的描述。如此一來,就可以在每次初始化 Publisher 實體的時候,去提供不同的 subscribe 閉包,支援不同種類的時間性序列了,比如說 NotificationCenter

不過,subscribe 現在所接受的 dataHandler 閉包,還是只能處理 Data 型別的值。還好,Swift 提供了通用型別 (Generic) 這個工具:

// 把 Data 替換成通用型別 Value。
struct Publisher<Value> {
    let subscribe: (@escaping (Value) -> Void) -> Void
}

// 現在 Publisher 可以送出任何型別的值了。
Publisher<Notification> { valueHandler in
    NotificationCenter.default.addObserver(forName: UIWindow.keyboardDidShowNotification, object: nil, queue: nil) { notification in
        valueHandler(notification)
    }
}.subscribe { notification in
    // 處理 notification...
}

如果每次初始化 Publisher,都要重新寫整個 subscribe 實作的話,其實蠻麻煩的。所以,我們可以用工廠方法模式再封裝它:

struct Publisher<Value> {
    let subscribe: (@escaping (Value) -> Void) -> Void
}

// 將工廠方法放到 extension 裡。
extension URLSession {

    // 將 url 當作參數傳進去。
    func dataTaskPublisher(with url: URL) -> Publisher<Data> {
        return Publisher<Data> { valueHandler in

            // 將 URLSession.shared 替換成 self。
            let task = self.dataTask(with: url) { data, response, error in
                if let data = data {
                    valueHandler(data)
                }
            }
            task.resume()
        }
    }
}

// 呼叫端的寫法。
URLSession.shared.dataTaskPublisher(with: url)
    .subscribe { data in
    // 處理 data...
}

到這邊為止,我們的基本版 Combine 已經有模有樣了。它不只表面上長得像 Combine,也真的提供不同時間性序列的操作,包括網路呼叫、通知模式、KVO、Target-Action 等,是一個統一的抽象介面。Publishersubscribe 閉包像 SequenceforEach(_:) 方法一樣,會對序列中的元素一個個處理,只不過 subscribe 的操作對象是一個時間性的序列,而不是一般的序列。

然而,我們現在還沒有辦法可以主動停止整個時間性序列。為此,我們必須定義並回傳一個代表訂閱關係的物件。

DIY:訂閱關係

為甚麼訂閱關係非得是物件不可呢?除了因為訂閱關係是獨一無二的之外,也因為只有物件才會在被釋放的時候發出訊息(通過呼叫 deinit 方法)。如果我們希望代表訂閱關係的實體,可以完美對應到訂閱關係本身的生命週期,那在它即將被釋放的時候,應該要可以自動去取消訂閱關係才是。

依著這樣的想法,寫出來的物件類型定義大概會是這樣:

class Subscription {

    // 一樣使用閉包來做屬性。
    let cancel: () -> Void
    init(cancel: @escaping () -> Void) {
        self.cancel = cancel
    }

    // 在被釋放的時候會自動取消訂閱關係。
    deinit {
        cancel()
    }
}

同時,我們也需要去修改其它地方的程式碼:

struct Publisher<Value> {

    // 改成回傳一個 Subscription 實體。
    let subscribe: (@escaping (Value) -> Void) -> Subscription
}

extension URLSession {

    func dataTaskPublisher(with url: URL) -> Publisher<Data> {
        return Publisher<Data> { valueHandler in
            let task = self.dataTask(with: url) { data, response, error in
                if let data = data {
                    valueHandler(data)
                }
            }
            task.resume()

            // 回傳一個 Subscription 實體。
            return Subscription {

                // 取消任務。
                task.cancel()
            }
        }
    }
}

於是,我們在呼叫端就多了一個 Subscription 物件可以操作:

// 記得要持有 subscription,否則整個訂閱關係會馬上被取消掉。
let subscription = 
    URLSession.shared.dataTaskPublisher(with: url)
    .subscribe { data in
    // 處理 data...
}

// 只要呼叫 subscription 的 cancel() 閉包就可以主動取消訂閱關係。
subscription.cancel()

到這裡,我們已經重新實作了原版 Combine 中的發布者、訂閱者、與訂閱關係。然而,Combine 中最重要的特色其實不是這些,而是操作者。操作者之外的元件充其量也就只是一個統一的介面罷了,但 FRP —— 或者說整個函數式程式設計 —— 最重要的特點,就是它的組裝 (composition) 取向。

在函數式寫法中,與其寫一個巨大的函數去包含所有操作,不如寫很多個小函數去組裝出所需的行為。就這點來說,我們現在對時間性序列中的值的所有操作,仍然是全部包含在一個 subscribe 閉包當中,跟原本命令式寫法的 completionHandler 閉包並沒有太大差異。

而我接下來要介紹的,就是可以把操作拆散,並且一個個丟到 Publisher 裡面組裝起來的方法:map(_:)

帶你跨越世界的 map(_:) 方法

首先,請你想像在程式碼中有許多不同的世界。除了一般的世界之外,還有不確定性的世界、多重性的世界、錯誤性的世界等。在一般世界裡的值可以直接被操作,但在其它世界裡的值就不行,要嘛會產生執行期錯誤,要嘛就是編譯器不允許。

舉例來說,在不確定性的世界裡,變數的值有可能存在,也有可能不存在。從編譯器的角度來看,是完全無法確定的,所以才叫不確定性的世界。或者,我們也可以叫它做「薛丁格的世界」。

假設在薛丁格的世界裡有一個可以裝貓的變數,但是我們完全不知道這個貓到底存不存在。如果我們想取得這隻貓的體重的話,那我們有可能成功,也有可能會失敗。成功的話很好,但如果失敗的話,就會導致後面的操作做不下去,也就相當於程式的崩潰。

於是,包括 Swift 在內的許多程式語言就將薛丁格世界裡的變數用一個 Optional 型別來描述,以防止開發者去對它們直接操作,並造成程式的崩潰。但是真正的問題還是沒有解決:那我們要怎麼樣安全地為薛丁格的貓量體重呢?

第一種方法是解封 (Unwrapping)。透過 Optional Binding (if let) 等功能,我們試著把貓從薛丁格的世界裡拿出來,如果成功的話再量它的體重。

第二種方法,則是把「量體重」這個操作也傳送到薛丁格的世界裡,使它得以用在薛丁格的貓上面,並且產出一個薛丁格的體重。如果貓存在的話,那體重就存在。如果貓不存在的話,體重也就不存在。

map(_:) 方法,就是用來把「量體重」等操作,從一般世界傳送到特殊世界裡的一個工具:

Functional Reactive Programming-1

而 Swift 的 map(_:) 方法把一般世界的操作轉換到特殊世界之後,通常還會執行它,所以會回傳該操作的結果回來。就前面的例子來說,就是會傳回薛丁格的體重。而如果還要對這個結果操作的話,那就再用一次 map(_:) 方法吧:

除了 Optional 之外,Swift 的 SequenceResult 等型別,其實也分別代表了多重性的世界與錯誤性的世界,所以也分別有 map(_:) 方法可以用:

Functional Reactive Programming-2

而 Combine 的 Publisher 代表的即是時間性的序列,或者說,時間性序列的世界:

Functional Reactive Programming-3

也就是說,只要我們實作了 map(_:),就可以把一般世界的操作轉化成針對時間性序列的操作。除此之外,我們也一併透過 map(_:) 的連環呼叫,實現了不同函數之間的組裝。

另一方面,針對時間性序列的世界來說,我們也沒辦法像解封 Optional 一樣,把 Publisher 裡面的值解封來操作。因為 ⋯⋯ 時間性序列的值,在操作的當下通常是不存在的啊!

那麼,我們要怎麼幫 Publisher 來實作 map(_:) 呢?

DIY:map(_:) 方法

首先,先讓我們把方法介面打出來:

// 實作成 Publisher 的實體方法。
extension Publisher {

    // 輸入一個具有轉型能力的操作,並回傳一個包含這個操作的 Publisher。
    // 因為 Publisher 的通用型別可能會改變,所以必須要回傳一個新的。
    func map<NewValue>(_ transform: @escaping (Value) -> NewValue) -> Publisher<NewValue> {
        // 待實作...
    }
}

接著填上 Publisher<NewValue> 的初始化方法:

extension Publisher {

    func map<NewValue>(_ transform: @escaping (Value) -> NewValue) -> Publisher<NewValue> {

        // newValueHandler 的回傳值的型別會是 NewValue。
        return Publisher<NewValue> { newValueHandler in
            // 待實作...
        }
    }
}

現在等待我們實作的,是新的 Publisher<NewValue>subscribe 閉包。新的 subscribe 應該要去呼叫舊的 subscribe,然後在舊的 valueHandler 裡面,對拿到的值加上操作 (transform),之後再餵給新的 newValueHandler

extension Publisher {

    func map<NewValue>(_ transform: @escaping (Value) -> NewValue) -> Publisher<NewValue> {

        return Publisher<NewValue> { newValueHandler in

            // 呼叫舊的 subscribe 閉包,並回傳原本的 Subscription。
            // Subscription 不需要轉型。
            return self.subscribe { value in

                // 每次拿到原始的 value 時,都先套用操作(transform)。
                let newValue = transform(value)

                // 然後再把操作過的值丟給轉型後的 newValueHandler。
                newValueHandler(newValue)
            }
        }
    }
}

這樣就完成實作了!雖然邏輯看起來好像蠻複雜的,但實際上就是把 transform 這個閉包插入到 valueHandler 被呼叫之前而已。所以,每次拿到值之後,值都會先經過 transform 才到 valueHandler。而如果呼叫多次 map(_:) 的話,越後面加入的 transform 也就會越後面被執行。

總結

讓我們回顧一下這篇文章說了甚麼。

  1. Functional Reactive Programming 是一種處理 values over time,或者說時間性序列的程式設計典範 (programming paradigm)。
  2. Combine 是用發布者、操作者與訂閱者去組裝呼叫,來得到一個訂閱關係物件。
  3. 前一點可以對應到建造者模式中的建造者、建構資訊(由建造者持有)、build() 方法、以及建構產品。
  4. 在實作中,我們用了閉包、結構體與通用型別來做抽象。
  5. 程式碼中有不同的世界,而我們透過 map(_:) 方法來把操作套用到特殊世界的變數上。

而最終的程式碼非常簡短,只有短短二十幾行:

class Subscription {
    let cancel: () -> Void
    init(cancel: @escaping () -> Void) {
        self.cancel = cancel
    }
    deinit {
        cancel()
    }
}

struct Publisher<Value> {
    let subscribe: (@escaping (Value) -> Void) -> Subscription
}

extension Publisher {
    func map<NewValue>(_ transform: @escaping (Value) -> NewValue) -> Publisher<NewValue> {
        return Publisher<NewValue> { newValueHandler in
            return self.subscribe { value in
                let newValue = transform(value)
                newValueHandler(newValue)
            }
        }
    }
}

搭配寫在 URLSessionNotificationCenter 等類型的 Extension 裡的工廠方法:

extension URLSession {
    func dataTaskPublisher(with url: URL) -> Publisher<Data> {
        return Publisher<Data> { valueHandler in
            let task = self.dataTask(with: url) { data, response, error in
                if let data = data {
                    valueHandler(data)
                }
            }
            task.resume()
            return Subscription {
                task.cancel()
            }
        }
    }
}

就可以寫出這樣子的時間性序列操作:

let subscription = URLSession.shared
    .dataTaskPublisher(with: url)
    .map { $0.count }
    .subscribe { count in /* 操作 count... */ }

是不是跟 Apple 的 Combine 用法很接近了呢?

如果你還想更進一步認識 map(_:) 的概念,它其實是附屬於範疇論 (Category Theory) 中的函子 (Functor) 概念。不過由於函子的內涵不只有這樣,所以就留給以後再來介紹了!

作者
Hsu Li-Heng
iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。