Callback 回調
Callback(回調)是什麼?
中文翻譯字詞會用"回呼""、"回叫"、"回調",都是聽起來很怪異的講法,Callback在英文是"call back"兩個單字的合體,你應該有聽過"Call me back"的英文,實際情況大概是有客戶打來電話給你,可是你正在電話中,客戶會留話說請你等會有空時再"回電"給它,這裡的說法是指在電信公司裡的callback的涵意。而在程式開發上上,callback它的使用情境其實也是有類似的地方。
CPS風格與直接風格
延續傳遞風格(Continuation-passing style, CPS),它的對比是"直接風格(Direct style)",這兩種是程式開發時所使用的風格,CPS早在1970年代就已經被提出來了。CPS用的是明確地移轉控制權到下一個函式中,也就是使用"延續函式"的方式,一般稱它為"回調函式"或"回調(Callback)"。回調是一個可以作為傳入參數的函式,用於在目前的函式呼叫執行最後移交控制權,而不使用函式回傳值的方式。直接風格的控制權移交是不明確的,它是用回傳值的方式,然後進行到下一行程式碼或呼叫接下來其他函式。下面以範例來說明會比較容易。
直接風格的範例如下,其實就是一般函式呼叫的方式,或是用回傳的方式:
CPS風格就不是這樣,它會用另一個函式作為函式中的傳入參數的樣式來撰寫程式,然後將本來應該要回傳的值(不限定只有一個),傳給下一個延續函式,繼續下個函式的執行:
以明確的程式流程的例子來說,假設現在要從資料庫獲取某個會員的資料,然後把裡面的大頭照片輸出。
用直接風格的寫法:
用CPS風格的寫法,像下面這樣:
長久以來在程式語言開發界,直接風格的程式碼是最常被使用的,因為它容易被學習與理解,一個步驟接著一個步驟,學校的程式語言課程大部份也是用這種風格來教學,不論在個人電腦上的、在伺服器端的程式語言設計通常也是這樣。主要是因為個人電腦端或是伺服器端,通常使用多執行緒或改進底層運作的執行方式,來解決多工或並行的問題。CPS風格反而很少被使用,並沒有明顯的誘因讓程式設計師一定要用CPS風格,而且也不是所有的程式語言都能使用CPS風格,在過去CPS風格並不算是主流的程式開發寫作風格。
CPS風格相較於直接風格還有一些明顯的缺點:
在愈複雜的應用情況時,程式碼愈不易撰寫與組織,維護性與閱讀性也很低
在錯誤處理上較為困難
JavaScript中會大量使用CPS風格,除了它本身可以使用這種風格外,其實是有原因:
只有單執行緒,在瀏覽器端只有一個使用者,但事件或網路要求(AJAX)要求不能阻塞其他程式的進行,但這也僅限在這些特殊的情況。不過在伺服器端的執行情況都很嚴峻,要能同時讓多人連線使用,必需要達到不能阻塞I/O,才能與以多執行緒執行的伺服器一樣的執行效益。
一開始就是以CPS風格來設計事件異步處理的模型,用於配合異步回調函式的執行使用。
基本上一個程式語言要具有高階函式(High Order Function)的特性才能使用CPS風格,也就是可以把某個函式當作另一函式的傳入參數,也可以回傳函式。除了JavaScript語言外,具有高階函式特性的程式語言常見的有Python、Java、Ruby、Swift等等。
異步回調函式
並非所有的使用callbacks(回調)函式的API都是異步執行的,但CPS的確是一種可以確保異步回調執行流程的風格。在JavaScript中,除了DOM事件處理中的回調函式9成9都是異步執行的,語言內建API中使用的回調函式不一定是異步執行的,也有同步執行的例如Array.forEach
,要讓開發者自訂的callbacks(回調)的執行轉變為異步,有以下幾種方式:
使用計時器(timer)函式:
setTimeout
,setInterval
特殊的函式:
nextTick
,setImmediate
執行I/O: 監聽網路、資料庫查詢或讀寫外部資源
訂閱事件
針對callbacks(回調)函式來說,異步與同步的執行到底是差在那裡?你可能會產生疑惑。下面用個簡單的例子來說明。
aFunc
是一個簡單的回調結構,callback
回調函式被傳入後最後以value
作為傳入參數執行。
bFunc
函式則是包裹了一個setTimeout
內建方法,它可以在一定時間內(第二個參數)執行第一個參數,也就是setTimeout
會執行的回調函式,第三個參數是要加入到回調函式的傳入參數值。
aFunc
中使用了一般的回調函式,只是傳入到函式中當作參數,然後最後執行而已,這種是同步執行的回調函式,只是用了CPS風格的寫法。
bFunc
中使用了計時器APIsetTimeout
會把傳入的回調函式進行異步執行,也就是先移到工作佇列中,等執行主執行緒的呼叫堆疊空了,在某個時間回到主執行緒再執行。所以即使它的時間設定為0秒,裡面的回調函式並不是立即執行,而是會暫緩(延時)執行的一種回調函式,一般稱為異步回調函式。
最後的執行結果是1 -> 3 -> 2 -> 4
,也就是說,所有的同步回調函式都執行完成了,才會開始依順序執行異步的回調函式。如果你在瀏覽器上測試這個程式,應該會明顯感受到,2與4的輸出時,會有點延遲的現象,這並不是你的瀏覽器或電腦的問題,這是因為不論你設定的setTimeout
為0,它要回到主執行緒上執行,仍然需要按照內部事件迴圈所設定的時間差,在某個時間點才會回來執行。
這個程式執行的流程,可以看這個在loupe網站的流程模擬,輸出一樣在瀏覽器的主控台中可以看到。
由這個範例中,可以看到異步回調函式執行比同步回調函式更慢,異步回調函式還有另一個名稱是延時回調(defer callback),是用延時執行特性來命名。這只是一種因應特別情況所採用的函式執行方式,例如需要與外部資源存取(I/O)、DOM事件處理或是計時器的情況。等待的時間則是在Web API中,等有外部資源有回應了(或超時)才會加到佇列中,佇列裡並不會執行函式中的程式碼,只是個準備排隊進入主執行緒的機制,函式一律在主執行緒中執行。
關於函式的異步執行與事件迴圈一些原理的說明,請再參考[異步執行與事件迴圈]的章節裡的內容。
回調函式的複雜性
callback(回調)運用在瀏覽器端似乎並沒有想像中複雜,一個事件的處理範例大概會像下面這樣:
你也可以把callback寫成另一個函式定義,看起來會更清楚:
AJAX是另一個常使用的情況,內建的XMLHttpRequest
物件的行為類似於事件處理,而且都打包好好的。實際上onreadystatechange
這個屬性,就是XMLHttpRequest
物件在處理事件用的callback(回調)函式。以下為一個簡單的範例:
"匿名函式"、"函式定義"與"函式呼叫"的混合
我會認為回調函式會複雜的原因是主要是來自"匿名函式"、"函式定義"與"函式呼叫"的混合寫法。所以當在看程式碼時,你的腦袋很容易打結。
這例子很簡單,要分作幾個部份來看:
這是一個完整的"函式呼叫",也就是說它是一個被執行的語句結構:
但其中的這一段是什麼,這個是一個"函式定義",而且還是個"匿名函式"定義,它是一個callback(回調)函式的定義,它代表了func
函式執行完後要作的下一件事,這個定義是在func
函式中的程式碼的最後一句被呼叫執行。:
所以整個語法是代表"在函式呼叫時,要寫出下一個要執行的函式定義",這就是常見回調函式的語法樣式。當然,你可以另外用一個函式來寫得更清楚:
不過,你可以發現幾件事情:
callback(回調)的函式名稱,可以用匿名函式取代。(實際上callback的名稱在除錯時很有用,可以在錯誤的堆疊上指示出來)
callback(回調)因為是函式的定義,所以傳入參數
value
的名稱叫什麼其實都可以。callback(回調)其實有Closure(閉包)結構的特性,可以獲取到
func
中的傳入參數,以及裡面的定義的值。(實際上JavaScript中只要函式建立就會有閉包產生)
那麼要說到callback(回調)的最大優點,就是它給了程式開發者很大的彈性,允許開發者可以自訂下一個要執行函式的內容,等於說它可以提高函式的擴充性與重覆使用性。
回調地獄(Callback Hell)
複雜的情況是在於CPS風格使用callback(回調)來移往下一個函式執行,當你開始撰寫一個接著一個執行的流程,也就是一個特定工作的函式呼叫後要接下一個特定工作的函式時,就會看到所謂的"回調地獄"的結構,像下面這樣的例子:
它的執行順序應該是step1 -> step2 -> step3
沒錯,這三個都可能是已經寫好要作某件特定工作的函式。所以真正是這樣的流程嗎?你可能忘了匿名函式(callback)也是一個函式,所以執行的步驟是像下面這樣才對:
step1
執行後,"value1"已經有值,移往function(value1)
執行function(value1)
執行到step2
,step2
執行到最後,"value2"已經有值,移往function(value2)
執行function(value2)
執行到step3
,step3
執行到最後,"value3"已經有值,移往function(value3)
執行function(value3)
執行完成
寫成流程大概是像下面這樣的順序,一共有6個函式要執行的流程,其中的這三個匿名回調函式的主要工作,是負責準備接續下一個要執行特定工作的函式:
那為何為不使用直接風格?而一定要用這麼不易理解的程式流程結構。上面已經有講為什麼JavaScript中會大量的使用CPS的原因:
因為有些I/O或事件類的函式,用直接風格會造成阻塞,所以要寫成異步的回調函式,也就是一定要用CPS
你可能會認為阻塞有這麼容易發生嗎?是的,在JavaScript中要"阻塞"太容易了,它是單執行緖執行的設計,一個比較長時間的程序執行就會造成阻塞,下面的for
迴圈就會讓你的按鈕按下去沒反應,而且幾個訊息都要一段時間執行完才會顯示出來:
或許你會認為在瀏覽器上讓使用者等個幾秒鐘不會怎麼樣,但如果在要求能讓多個使用者同時使用的伺服器上,每個使用者都來阻塞主執行緒幾秒,這個伺服器程式就可以廢了。不過,以異步執行的異步回調函式並不代表就不會阻塞,也有可能從佇列回到主緒行緒後,因為需要CPU密集型的運算,仍然會阻塞到緒行緒的進行。異步回調函式,只是暫時先移到佇列中放著,讓它先不干擾目前的主執行緒的執行而已。這是JavaScript為了在只有單執行緒的情況,用來達成並行(concurrency)模型的設計方式。
如果要配合JavaScript的異步處理流程,也就是非阻塞的I/O處理,只有CPS可以這樣作。
在伺服端的Node.js一開始就使用了CPS作為主要的I/O處理方式,老實說是一個不得已的選擇,當時沒有太多的選擇,而且這原本就是JavaScript中對異步回調函式的設計。Node.js使用error-first(以錯誤為主)的CPS風格,因為考慮到callback(回調)要處理錯誤不容易,所以要優先處理錯誤,它的主要原則如下:
callback的第一個參數保留給Error(錯誤),當錯誤發生時,它將會以第一個參數回傳。
callback的第2個參數保留給成功回應的資料。當沒有錯誤發生時,error(即第一個參數)會設定為null,然後將成功回應的資料傳入第二個參數。
一個典型的Node.js的回調語法範例如下:
Node.js使用CPS風格在複雜的流程時,很容易出現回調地獄的問題,這是因為在伺服器端的各種I/O處理會相當頻繁而且複雜。像下面這個資料庫連接與查詢的範例,出自Node.js MongoDB Driver API:
上面這個範例還算簡單,但裡面的回調函式有3個,函式呼叫了11個,再複雜的話就會更不好維護,我會認為這是一種舊時代的程式碼組織方式的弊病,或是當時不得已的解決方案,當可以採用更好的語法結構來取代它時,這種語法未來大概只會出現在教科書中。現在這種寫法都是一般都已經不建議使用。
現在已經有很多協助處理的方式,回調地獄可以用例如Promise、generator、async/await之類的語法結構,或是Async、co外部函式庫等,來改善或重構原本的程式碼結構,在往後維護程式碼上會比較容易,這些才是你現在應該就要學習的方式。
此外,隨著技術不斷的進步,現在的JavaScript也已經有可以讓它使用其他執行緒的技術,有Web Worker,或是專門給Node.js使用的child_process模組與cluster(叢集)模組。
反樣式(anti-pattern)
回傳callback
callback(回調)有個很常見的反樣式,它會出現在如果回調除了要進行下一步之外,還要負責處理函式在執行中途的錯誤情況,例如:
為了讓最後的callback()不執行,可以正確的作錯誤處理,有可能會寫成這樣:
但是return
用在callback上是個不對的樣式,正確的寫法應該是要用下面的寫法:
或是用if...else
寫清楚整個情況,但這個樣式也不是太理想,CPS風格寫法的最後一行應該就是個回調函式:
參考資源
Last updated