Callback/CPS
January 25, 2022以下這個程式,可以指定網址下載網頁:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.io.*;
public class Download {
public static void main(String[] args) throws Exception {
String[] urls = {
"https://openhome.cc/Gossip/Encoding/",
"https://openhome.cc/Gossip/Scala/",
"https://openhome.cc/Gossip/JavaScript/",
"https://openhome.cc/Gossip/Python/"
};
String[] fileNames = {
"Encoding.html",
"Scala.html",
"JavaScript.html",
"Python.html"
};
for(var i = 0; i < urls.length; i++) {
var url = urls[i];
var fileName = fileNames[i];
Files.copy(openStream(url), Paths.get(fileName), REPLACE_EXISTING);
}
}
static InputStream openStream(String uri) throws Exception {
return HttpClient
.newHttpClient()
.send(
HttpRequest.newBuilder(URI.create(uri)).build(),
BodyHandlers.ofInputStream()
)
.body();
}
}
同步?非同步?
這個程式每次迭代時,會以指定網址開啟網路連結、進行 HTTP 請求,然後再寫入檔案等,在等待網路連結、HTTP 協定時很耗時(也就是進入 Blocked 的時間較長),第一個網頁下載完後,再下載第二個網頁,接著才是第三個、第四個。
無論是運算式、陳述句或是函式,都是在定義的任務完成之後,才會往下一個運算式、陳述句或函式執行,這樣的流程稱為同步(Synchronous)。
如果可以第一個網頁在等待網路連結、HTTP 協定時,就進行第二個、第三個、第四個網路連結的開啟,那效率會改進很多,例如〈一個請求一個執行緒〉:
for(var i = 0; i < urls.length; i++) {
var url = urls[i];
var fileName = fileNames[i];
new Thread(() -> {
try {
Files.copy(openStream(url), Paths.get(fileName), REPLACE_EXISTING);
} catch (Exception e) {
throw new RuntimeException(e);
}
}).start();
}
為什麼這個程式碼能行得通?因為對主執行緒而言,每次迭代,不用等執行結束就可以繼續下一步,而下載任務這類獨立於程式主流程的任務,稱為非同步(Asynchronous)。
Callback
就方才的程式而言,最後是直接將下載的資料存至檔案,如果你需要取得結果呢?使用〈Future〉是其中一種方式,另一種方式是透過回呼(Callback):
static void download(String url, Consumer<Exception> error, Consumer<byte[]> success) {
new Thread(() -> {
try {
success.accept(openStream(url).readAllBytes());
} catch (Exception e) {
error.accept(e);
}
}).start();
}
public static void main(String[] args) throws Exception {
...略
for(var i = 0; i < urls.length; i++) {
var url = urls[i];
var fileName = fileNames[i];
download(url,
// 發生例外時的回呼
exception -> {
exception.printStackTrace();
},
// 成功時的回呼
content -> {
try(var file = new FileOutputStream(fileName)) {
file.write(content);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
);
}
}
因為非同步呼叫後會立刻執行後續程式碼,當 download
中發生例外時,不能直接 throw
,因為就算你這麼寫:
try {
download(...);
}
catch(Exception e) {
...
}
因為是非同步,流程早就離開了 try/catch
區塊,錯誤實際上不會被捕捉,因此範例中的 download
,是在捕捉到例外後,執行指定的 error
回呼,將例外傳入。
稱為回呼是因為,指定的函式會在適當時機被回頭呼叫,實作起來簡單,在一些非同步環境常見這種模式。例如,在 Node.js 使用 fs
模組的 readFile
時,就會看到這種模式:
require('fs').readFile('text', 'utf-8', (err, data) => {
...
});
那麼你會說,這跟圖形介面程式中,事件發生時的回呼有什麼不同嗎?回呼只是一種行為上的稱呼,圖形介面程式中,是在事件發生時回呼,可能是滑鼠、鍵盤等事件,傳入的結果是事件物件,就以上的範例來說,那個事件是最後執行結果成功或失敗,圖形介面程式中你指定的回呼可能會被多次地呼叫(多次滑鼠事件或鍵盤事件發生),就以上的範例來說,就是完成執行後那麼一次的呼叫。
Continuation-passing 風格
有時會有人稱以上這種風格是 Continuation-passing style(CPS),這是個更古老的名稱,Continuation-passing 風格是指執行結果是持續傳遞下去。例如:
function doubleMe(n) {
return n * 2;
}
function plus10(n) {
return n + 10;
}
const doubled = doubleMe(10);
const r = plus10(doubled);
console.log(r);
執行的結果會 return
給呼叫者,而 Continuation-passing 風格會是將執行結果,傳入指定的函式繼續執行:
function doubleMe(n, next) {
return next(n * 2);
}
function plus10(n, next) {
return next(n + 10);
}
const r = doubleMe(5, doubled => plus10(doubled, r => r));
console.log(r);
你可能會想,這樣有比較好懂一些嗎?其實 Continuation-passing 風格是源於函數式,上例是將 plus10(doubleMe(5))
,改為 doubleMe(5, doubled => plus10(doubled, r => r))
的風格,讓閱讀順序上符合計算順序。
然而,就命令式語言來說,在缺少純函數式語言的一些特性,例如 Haskell 的部份套用(partial application)、中序標示(infix notation)(參考〈Haskell Tutorial(4)這裏,那裏,到處都是函式〉),單純寫 doubleMe(10, doubled => plus10(doubled, r => r))
這種風格,不見得好讀到哪就是了。
來看看 Python 好了,透過 functools.partial
實現部份套用:
from functools import partial as _
def doubleMe(next, n):
return next(n * 2)
def plus10(next, n):
return next(n + 10)
def identity(r):
return r
def feed(n, f):
return f(n)
r = feed(5, _(doubleMe, _(plus10, identity)))
print(r)
_
是為了避免閱讀時干擾視覺,讓流程看來像是 5 -> doubleMe -> plus10 這樣的寫法,如果 Python 能有 Haskell 的中序標示,大概可以寫成以下,5 就像是餵給 doubleMe
,結果再餵給 plus10
:
# 實際上 Python 沒這種寫法
r = 5 `feed` _(doubleMe, _(plus10, identity))
print(r)
雖然是在這篇文件中談 Continuation-passing 風格,談非同步、Callback 時,因為能直接看出持續傳遞結果的流程,也常與 Continuation-passing 風格扯上關係,,不過〈FlatMap〉在將執行結果繼續往下個指定函式傳遞的精神上,也算是 Continuation-passing 風格的實現。
簡單來說,非同步操作時指定回呼函式,讓最後執行結果能傳入函式做些處理,是處理非同步結果的一種簡單模式;不過(在缺少純函數式語言的一些特性下),這種簡單模式建議只用在不需要繼續將計算傳遞下去的場合,否則會形成回呼地獄的問題而影響可讀性,這會在下一篇文件再來討論…