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 風格的實現。

簡單來說,非同步操作時指定回呼函式,讓最後執行結果能傳入函式做些處理,是處理非同步結果的一種簡單模式;不過(在缺少純函數式語言的一些特性下),這種簡單模式建議只用在不需要繼續將計算傳遞下去的場合,否則會形成回呼地獄的問題而影響可讀性,這會在下一篇文件再來討論…

分享到 LinkedIn 分享到 Facebook 分享到 Twitter