9 min read

函數的替代品:訊息傳遞

在電腦科學中,指令並不是只有函數這一種隱喻而已。在 Actors Model 中,指令是以訊息傳遞(message passing)的形式存在的。
函數的替代品:訊息傳遞

我們在 coding 的時候,都會用函數來下指令。一個函數的構造很簡單,可以用參數來將值傳進去,然後函數會把一個值回傳出來。這是所謂的純函數,也就是數學定義上的函數:只對輸入的參數做映射。但我們同時也會用函數來做其他的事情,比如說取值跟更動狀態。也就是說,其實函數只是指令的一種型態而已。

在電腦科學中,指令並不是只有函數這一種隱喻而已。在 Actors Model 中,指令是以訊息傳遞(message passing)的形式存在的。訊息傳遞跟函數有幾個很重要的差別:

  1. 它沒有回傳值。
  2. 它不用等指令執行完再進行下一步。也就是異步執行的意思。

換句話說,就是它發送訊息出去之後,就不管訊息了。相較之下,函數(以及物件上的方法)被呼叫的時候會擋住呼叫端的執行,直到自己回傳,呼叫端才能繼續執行之後的程式碼。所以我們在呼叫一個函數的時候,基本上就是被這個函數所綁架。如果這個函數很快就回傳的話就沒問題,但是如果它需要時間運算的話,就會鎖住整個呼叫端。而如果是訊息傳遞的話就不會有這個問題。

唯一的問題是,我們要怎麼用訊息傳遞來取值?

第一個方法,是把呼叫端的地址一併放到訊息裡面傳出去,就好像寄信的時候寫寄件人地址一樣,讓對方知道要回信到哪裡。就好像是 Cocoa 中的 Target-Action 模式,把自己物件跟呼叫的地址交給按鈕等控制項,去取得按鈕的任何狀態變動。

第二個方法,是把取值之後要做的事打包起來,一併傳給對方。在 Swift 中無所不在的 completion handler 就是這種做法。

是的,雖然 Swift 本身是以函數為核心的程式語言,但也包含了很多訊息傳遞的概念在,只是它們的名字叫做異步函數而已。當我們呼叫具備 completion handler 的函數時,我們知道它會馬上回傳,而且它也通常沒有回傳值(回傳 Void),因為它要回傳的值會變成傳進 completion handler 裡面。整體而言,就是表現得像訊息傳遞的函數。

問題是,它有點醜。

Swift Team 在 Swift 5.5 釋出了 async/await 語法,讓異步函數可以被寫成跟一般函數一樣的形式。雖然解決了美觀的問題,但也只是讓程式碼風格回到函數的寫法而已。對我來說,其實並沒有真的比較直觀。

執行一連串指令的 DSL

什麼是 DSL?DSL 原文是 Domain-Specific Language,也就是針對某個問題領域特別設計的語言。比如說 HTML 就是針對網頁元件架構的 DSL,而 CSS 是針對排版的 DSL。在蘋果開發圈裡也有 DSL,就是以前的 NIB 跟新的 SwiftUI,都是針對顯示排版的 DSL。

那如果運行指令也有 DSL 呢?它會長什麼樣子?

一般當我們需要將一個函數的回傳值放到另一個函數裡的時候,會有兩種做法。假設我們要去一個網址抓 JSON 資料回來並解碼,用最新的 async/await 寫法來寫的話,第一種寫法會是這樣:

Task {
    let post = try JSONDecoder().decode(Post.self, from: await URLSession.shared.data(from: URL(string: "https://www.example.com/api/post/123")!).0)
}

這樣的寫法就像是俄羅斯娃娃一樣,一層套一層。雖然可以濃縮到一行之內,但這一行會變超長,而且很難看懂程序的先後順序。照理說,網址應該是最先被建構出來的,但在這一行程式碼裡面,它卻是排最後出現的。

如果要更好讀的話,第二種寫法會好一點:

Task {
    let url = URL(string: "https://www.example.com/api/post/123")!
    let data = try await URLSession.shared.data(from: url).0
    let post = try JSONDecoder().decode(Post.self, from: data)
}

這樣確實好多了。執行的順序變得跟閱讀的順序一樣,每行做的事情也更清楚了。但說實在的,這離理想還有一段距離。在類 Unix 作業系統中,正好就有一個串接程序的 DSL 可以參考,叫做管道(pipeline)。它是用「|」這個字元來串接程序,把左手邊程序的輸出接到右手邊程序的輸入。比如說要把當下目錄的檔案清單傳給閱讀程序 less 的話,就是這樣寫:

ls -l | less

因為這些程序都已經定義好接口(stdinstdout),所以只需要一個符號就可以把它們接起來。這樣的寫法既簡潔又好懂,幾乎像是某種 ASCII 流程圖一樣。

但這樣的寫法,要怎麼在 Swift 中實現呢?

幸運的是,我們已經可以用這種寫法了——它叫做 Combine(或者 RxSwift、ReactiveSwift)。用 Combine 來寫上面的例子會是這樣:

Just(URL(string: "https://www.example.com/api/post/123")!)
    .flatMap(URLSession.shared.dataTaskPublisher(for:))
    .map(\.data)
    .decode(type: Post.self, decoder: JSONDecoder())
    .sink(receiveCompletion: { _ in }) { post in
        
    }

它的意思是:

  1. 建構一個 URL。
  2. 用這個 URL 去取得回傳。
  3. 取得回傳中的資料。
  4. 將資料拿去解碼。
  5. 處理解碼後的資料。

雖然多了一些所謂的 Operator,也就是 flatMapmap 等等的串接用函數,但注意一點:在這個例子中,唯一的命名是在最後的 post,前面完全不用命名。同時,也不用寫 tryawait 等關鍵字前綴,更不用把整個呼叫放在 Task { } 裡面。它本身就是一個完整的管道表達式,清楚的定義每個步驟。

更重要的是,它的整個概念都是訊息傳遞的。

Publisher 是一種對話通道

前面說到,訊息傳遞有兩種回傳值的方式:位址或閉包。而 Combine 中的 Publisher,就是閉包的強化版。這次,異步函數不再包含位址或閉包參數,而是回傳一個 Publisher,並且通過這個 Publisher 來回傳值。用訊息傳遞的概念來說,就是打開了一個對話通道

函數呼叫的輸入輸出其實也可以看成是一個對話,只是它的概念是一往-一來,而且就像在聊天大廳裡面發言一樣,每個對話都是在大廳裡公開的。Publisher 比較像是私訊,大家都在包廂裡做對談,而且我還可以要對方不要回傳給我,而是回傳給另一個人(使用 flatMap)。

如果用管理的語彙來說的話,命令式呼叫就像是微觀管理(micromanagement),每個員工的產出都一定要經過管理者的手上,再交給下一個員工去處理;宣告式的訊息傳遞則是像管理者寫出工作流程表,讓員工自己去傳值給彼此,把工作完成。所以我們在使用 Combine 的時候才可以不用去宣告變數常數,因為 Publisher 在管道中間的產出會直接傳給下一個 Operator 或別的 Publisher,直到整串程序都完成之後再把成果的值交出來就好。

函數之外的可能性

我在認識宣告式程式設計之前,就已經在想函數回傳的語法的問題了。因為在 Swift 中,有個語法糖叫做 trailing closure,長這樣:

doSomething(with: input) { output in

}

這樣寫法的隱喻就是當 doSomething(with:) 執行結束之後產生的值會變成 output 引數進到後面的閉包裡。這個寫法十分好懂,但可惜它只是個語法糖,並不是真正能取代函數結構的替代品,也不能像這樣串接:

//行不通
doSomething(with: input) { output in
    return doOtherThing(with: output)
} { anotherOutput in

}

當然,我們一開始就可以這樣寫:

Optional(doSomething(with: input))
    .map(doOtherThing(with:))
    .map { anotherOutput in 

    }

後來的 Combine 的寫法,其實也是遵循這個語法傳統。畢竟 Swift 的根本語法是屬於 C 語言的命令式風格,宣告式的寫法怎麼樣都還是給人一點 workaround 的感覺。舉例來說,我們沒辦法直接在任何值上面使用宣告式寫法,它必須被裝在某種容器(Optional、Publisher、Sequence 等等)裡面才可以寫成宣告式。要這樣做不是不行,但就需要自訂運算子,而自訂運算子就是個 workaround。

// 透過自訂運算子是可行的
doSomething(with: input) | doOtherThing(with:) | { anotherOutput in

}

但我還是很希望未來 Swift 可以達到這個境界,也就是把 Combine 完全吸收,並找出一個更簡潔的語法,讓 Publisher(或新的 AsyncSequence)不只是一個 struct 或 protocol,更是一種全新的函數型態,必須使用特殊的 trailing closure 語法來呼叫,而且可以串連,像這樣:

func monitorValue() publishes -> Value {
    // ...
}

func getResult() publishes -> Result {
    // ...
}

monitorValue() | { value in

    // 可能被呼叫多次
    return getResult()
} | { result in // 前一個閉包的回傳值會傳到下一個閉包裡

    // 可串接
}

這樣應該更接近 Actors Model 的訊息傳遞精神吧?不過在這樣的語法出現在 Swift Standard Library 之前,我想我還是會繼續用 mapflatMap 吧。