【Swift腦洞系列】輕鬆無痛實現非同步操作序列

aaaron7發表於2016-05-05

開個新坑,來寫寫用 Swift 來做函數語言程式設計的技巧。

引言:任何一個沾點 Functional 特性的框架,比如Promise,ReactiveCocoa或者RxSwift都提供了處理非同步操作順序執行的方案。
但其實用 Swift 本身自帶很多Functional的特性,自己實現也並不難。
本文探討了其中一種方法。

提起非同步操作的序列執行,指的是有一系列的非同步操作(比如網路請求)的執行有前後的依賴關係,前一個請求執行完畢後,才能執行下一個請求。

非同步操作的定義

我們定義一般非同步操作都是如下形式:

常規的非同步操作都會接受一個閉包作為引數,用於操作執行完畢後的回撥。

那非同步操作的序列化會有什麼問題呢? 看如下的虛擬碼:

我們定義了三個操作asyncOperation,asyncOperation1asyncOperation2,現在我們想序列執行三個操作,然後在執行完後輸出 all executed。 按照常規,我們就寫下了如下的程式碼:

可以看到,明明才三層,程式碼似乎就有點複雜了,而我們真正關心的程式碼卻只有 print("all executed") 這一行。但為了遵從前後依賴的時許關係,我們不得不小心的處理回撥,以防搞錯層級。如果層級多了就有可能像這樣:

這就是傳說中的callback hell, 而且這還只是最clean的情況,實際情況中還會耦合很多的邏輯程式碼,更加無法維護。

用reduce來實現非同步操作的序列

那是否有解決辦法呢? 答案是有的。很多FRP的框架都提供了類似的實現,有興趣的讀者可以自行檢視Promise、 ReactiveCocoa 和 RxSwift中的實現。

然後正如本節的標題所說,Swift提供了兩個函式式的特性:

  • 函式是一等公民(可以像變數一樣傳來傳去,可以做函式引數、返回值
  • 高階函式,比如 mapreduce

接下來我們就用這兩個特性,實現一個更加優雅的方式來做非同步操作序列。

1. 定義型別

為了方便書寫,我們先定義一下非同步操作的型別:

AsyncFunc 代表了一個函式型別,這樣的函式有一個閉包引數(其實就是上面 asyncOperation 的型別)

2. 從序列兩個操作開始

我們先化簡問題,假設我們只需要序列兩個非同步操作呢? 有沒有辦法把兩個非同步操作序列成一個非同步操作呢? 想到這裡,我們可以YY出這樣一個函式:

concat函式,顧名思義,是連線的意思。指的是將兩個非同步操作:leftright序列起來,並返回一個新的非同步操作。

那現在,我們來思考如何實現concat函式,既然返回的是AsyncFunc 也就是一個函式,那我們可以先YY出這樣的結構:

仔細回憶 AsyncFunc 的型別: (()->Void) -> Void,所以閉包引數complete就對應前面的引數。

架子已經寫好了,我們來思考要實現如何實現最終返回這個函式。根據concat的定義我們可以知道,我們最終返回的是一個 接受一個閉包作為引數, 先執行left,成功後執行right,成功後再執行傳入的閉包

你看,這樣一分析,邏輯就非常清晰了,閉包引數就是complete. 我們抽絲剝繭,找到了問題的本質,於是很容易可以寫出:

核心邏輯和我們最原始的版本其實並沒有區別,區別就是不論再多個序列,我們都不需要寫更多的巢狀了。

基於最開始的例子,我們測試一下:

至此,我們以及成功的實現了把兩個非同步操作合併成一個序列的非同步操作。

3. 定義一個運算子

讓我們回過頭去,再審視一個我們concact的簽名:

我們忘記什麼函式,什麼閉包,什麼非同步。就來看簽名:他接收兩個相同型別的引數,最後返回一個結果,結果的型別和引數一致。

像什麼?像霧像雨又像風? 還是像加法像減法又像乘法?總之我們可以把他看做是某種運算,具備如下性質:

既然是運算,我們乾脆給他定義個運算子,修改我們的concat函式如下所示, + 代表這是一種用來表示結合的運算,>代表他有前後的依賴關係,不滿足交換律。+> 就是我們自己定義的非同步序列運算子。

這樣,我們最開始的,五個非同步操作序列執行的程式碼就可以改為這樣:

我們先把五個操作序列成一個,然後執行它。

4. 序列任意多個非同步操作

那你會說,如果我們有更多的非同步操作呢?比如我們有一組非同步操作:[AsyncFunc], 難道只能展開來一個個用 +> 來合併嗎?

其實,現在我們有了序列運算子,那就很容易想到我們可以拿我們剛才實現的+>運算子來reduce一組非同步操作。繼續用剛才的例子,我們先寫下如下程式碼:

我們把剛才定義的三個非同步函式扔到列表裡,然後用我們的序列運算子+>來reduce他,combine 其實就是+>,但此時似乎又面臨另外一個問題,【初始值】填什麼?

每次思考reduce的初始值都是一個哲學問題,大多數情況下我們不希望他參與運算,但又不得不讓他參與運算(因為combine是個二元函式),所以我們希望reduce的初始值(記為initial)具備如下性質:

  • combine(initial,x) = x

這種性質,大家應該能聯想到一個類似的東西叫 CGAffineTransformIdentity,往深了講,這其實是一個代數問題,不過這裡暫時不討論。

在本例,我們的initial可以定義為:

它是這樣的一個函式,接受閉包作為引數,然後什麼都不做,馬上呼叫閉包。這裡大家簡單感受一下。_(:зゝ∠)

於是,我們完整的reduce版本可以定義為:

首先定義了identityFunc作為初始值,然後把我們開頭定義的幾個非同步操作reduce成一個:reducedFunction,然後呼叫了它,可以觀察輸出結果,和我們最開始寫的巢狀版本是一樣的。

引申的話題

帶引數的序列

真實世界裡,當我們需要序列非同步操作的時候,一般後一個操作都需要前一個操作的執行結果。比如我們可能需要先請求新聞的列表,拿到新聞的id之後,再請求新聞的一些具體的資訊,前後操作有資料上的依賴關係。(當然一般不這麼搞,這裡只是舉個例子)

抽象的來看,我們要處理一組序列的操作,為了方便處理,我們希望函式的簽名是一樣的,偷懶的做法可以這樣:

定義閉包的型別為AnyObject->Void ,同時非同步函式也接受一個AnyObject的引數,這樣在各個非同步函式中通過把引數cast成字典,提取資訊,操作完畢後把結果的值傳到回撥的閉包中。具體實現見一下節

如果嫌AnyObject太醜的話也可以針對序列操作的場景設計一個protocol,然後用protocol作為引數的型別來傳遞資訊。

錯誤處理

我們最終將一組非同步操作,reduce成了一個非同步操作,那如果中間某個操作出錯了,我們該怎麼知道呢? 其中一種實現,可以是:

對比之前帶引數的例子,唯一的區別就是在閉包的引數里加了一個NSError?,以及把AnyObject改成了optional,因為這裡的AnyObject代表的是結果,如果失敗了,結果自然就是nil.

於是,我們的核心,序列運算子可以變成這樣:

邏輯也是很直接的,我們首先嚐試執行left,在left的回撥中檢視error是否是nil,如果不是,說明有錯誤,則立刻執行complete,並且帶上這個error。否則再執行right,並將right的結果呼叫complete。然後在用+>連線了一組非同步操作的時候,一旦有錯,這個邏輯就可以讓錯誤一步步傳播到最頂層,避免執行了冗餘的程式碼。

一個稍微非同步一點的例子

隨便建一個single view application,在viewcontroller.swift的頂部(swift 要求 operator 定義在 file scope,所以不能寫在類裡),新增:

然後修改viewDidLoad為如下程式碼:

執行程式後,會每兩秒有一個輸出。:)

本文旨在拋磚引玉,其實swift的functional特性已經非常豐富,稍微探索一下是可以做出很多fancy的應用出來的。

在函數語言程式設計的世界裡,我們定義的 identity加上+> 就是一種monoid,常見的monoid還有:

加法: identity 就是 0 , +> 就對應 +
乘法:identity 就是 1 , +> 就對應 *

一點有趣的思考: 剛才我們已經解釋了,我們的+> 運算子是不支援交換律的,因為是序列。那它是否支援結合律呢? 比如: (a +> b) +> c 是否等於 a +> (b +> c)

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

【Swift腦洞系列】輕鬆無痛實現非同步操作序列 【Swift腦洞系列】輕鬆無痛實現非同步操作序列

相關文章