函數的替代品:訊息傳遞

在電腦科學中,指令並不是只有函數這一種隱喻而已。在 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 吧。

Subscribe to Narrativesaw

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe