1. 程式人生 > 其它 >C++-callback回撥函式

C++-callback回撥函式

這篇文章講的很清楚。

本文由 簡悅 SimpRead 轉碼, 原文地址 zhuanlan.zhihu.com

不知你是不是也有這樣的疑惑,我們為什麼需要回調函式這個概念呢?直接呼叫函式不就可以了?回撥函式到底有什麼作用?程式設計師到底該如何理解回撥函式?

這篇文章就來為你解答這些問題,讀完這篇文章後你的武器庫將新增一件功能強大的利器

一切要從這樣的需求說起

假設你們公司要開發下一代國民 App“明日油條”,一款主打解決國民早餐問題的 App,為了加快開發進度,這款應用由 A 小組和 B 小組協同開發。

其中有一個核心模組由 A 小組開發然後供 B 小組呼叫,這個核心模組被封裝成了一個函式,這個函式就叫 make_youtiao()。

如果 make_youtiao() 這個函式執行的很快並可以立即返回,那麼 B 小組的同學只需要:

  1. 呼叫 make_youtiao()
  2. 等待該函式執行完成
  3. 該函式執行完後繼續後續流程

從程式執行的角度看這個過程是這樣的:

  1. 儲存當前被執行函式的上下文
  2. 開始執行 make_youtiao() 這個函式
  3. make_youtiao() 執行完後,控制轉回到呼叫函式中

如果世界上所有的函式都像 make_youtiao() 這麼簡單,那麼程式設計師大概率就要失業了,還好程式的世界是複雜的,這樣程式設計師才有了存在的價值。

現實情況並不容易

現實中 make_youtiao() 這個函式需要處理的資料非常龐大,假設有 10000 個,那麼 make_youtiao(10000) 不會立刻返回

,而是可能需要 10 分鐘才執行完成並返回。

這時你該怎麼辦呢?想一想這個問題。

可能有的同學就像把頭埋在沙子裡的鴕鳥一樣:和剛才一樣直接呼叫不可以嗎,這樣多簡單。

是的,這樣做沒有問題,但就像愛因斯坦說的那樣 “一切都應該儘可能簡單,但是不能過於簡單”。

想一想直接呼叫會有什麼問題?

顯然直接呼叫的話,那麼呼叫執行緒會被阻塞暫停,在等待 10 分鐘後才能繼續執行。在這 10 分鐘內該執行緒不會被作業系統分配 CPU,也就是說該執行緒得不到任何推進。

這並不是一種高效的做法。

沒有一個程式設計師想死盯著螢幕 10 分鐘後才能得到結果。

那麼有沒有一種更加高效的做法呢?

想一想我們上一篇中那個一直盯著你寫程式碼的老闆 (見《

從小白到高手,你需要理解同步與非同步》),我們已經知道了這種一直等待直到另一個任務完成的模式叫做同步。

如果你是老闆的話你會什麼都不幹一直盯著員工寫程式碼嗎?因此一種更好的做法是程式設計師在程式碼的時候老闆該幹啥幹啥,程式設計師寫完後自然會通知老闆,這樣老闆和程式設計師都不需要相互等待,這種模式被稱為非同步。

回到我們的主題,這裡一種更好的方式是呼叫 make_youtiao() 這個函式後不再等待這個函式執行完成,而是直接返回繼續後續流程,這樣 A 小組的程式就可以和 make_youtiao() 這個函式同時進行了,就像這樣:

在這種情況下,回撥 (callback) 就必須出場了。

為什麼我們需要回調 callback

有的同學可能還沒有明白為什麼在這種情況下需要回調,彆著急,我們慢慢講。

假設我們 “明日油條”App 程式碼第一版是這樣寫的:

make_youtiao(10000);
sell();

可以看到這是最簡單的寫法,意思很簡單,製作好油條後賣出去。

我們已經知道了由於 make_youtiao(10000) 這個函式 10 分鐘才能返回,你不想一直死盯著螢幕 10 分鐘等待結果,那麼一種更好的方法是讓 make_youtiao() 這個函式知道製作完油條後該幹什麼,即,更好的呼叫 make_youtiao 的方式是這樣的:“製作 10000 個油條,炸好後賣出去”,因此呼叫 make_youtiao 就變出這樣了:

make_youtiao(10000, sell);

看到了吧,現在 make_youtiao 這個函式多了一個引數,除了指定製作油條的數量外還可以指定製作好後該幹什麼,第二個被 make_youtiao 這個函式呼叫的函式就叫回調,callback。

現在你應該看出來了吧,雖然 sell 函式是你定義的,但是這個函式卻是被其它模組呼叫執行的,就像這樣:

make_youtiao 這個函式是怎麼實現的呢,很簡單:

void make_youtiao(int num, func call_back) {
    // 製作油條
    call_back(); //執行回撥 
}

這樣你就不用死盯著螢幕了,因為你把 make_youtiao 這個函式執行完後該做的任務交代給 make_youtiao 這個函數了,該函式製作完油條後知道該幹些什麼,這樣就解放了你的程式。

有的同學可能還是有疑問,為什麼編寫 make_youtiao 這個小組不直接定義 sell 函式然後呼叫呢?

不要忘了明日油條這個 App 是由 A 小組和 B 小組同時開發的,A 小組在編寫 make_youtiao 時怎麼知道 B 小組要怎麼用這個模組,假設 A 小組真的自己定義 sell 函式就會這樣寫:

void make_youtiao(int num) {
    real_make_youtiao(num);
    sell(); //執行回撥 
}

同時 A 小組設計的模組非常好用,這時 C 小組也想用這個模組,然而 C 小組的需求是製作完油條後放到倉庫而不是不是直接賣掉,要滿足這一需求那麼 A 小組該怎麼寫呢?

void make_youtiao(int num) {
    real_make_youtiao(num);

    if (Team_B) {
       sell(); // 執行回撥
    } else if (Team_D) {
       store(); // 放到倉庫
    }
}

故事還沒完,假設這時 D 小組又想使用呢,難道還要接著新增 if else 嗎?這個問題該怎麼解決呢?關於這個問題的答案,你懂的。

新的程式設計思維模式

讓我們再來仔細的看一下這個過程。

程式設計師最熟悉的思維模式是這樣的:

  1. 呼叫某個函式,獲取結果
  2. 處理獲取到的結果
res = request();
handle(res);

這就是函式的同步呼叫,只有 request() 函式返回拿到結果後,才能呼叫 handle 函式進行處理,request 函式返回前我們必須等待,這就是同步呼叫,其控制流是這樣的:

但是如果我們想更加高效的話,那麼就需要非同步呼叫了,我們不去直接呼叫 handle 函式,而是作為引數傳遞給 request:

request(handle);

我們根本就不關心 request 什麼時候真正的獲取的結果,這是 request 該關心的事情,我們只需要把獲取到結果後該怎麼處理告訴 request 就可以了,因此 request 函式可以立刻返回,真的獲取結果的處理可能是在另一個執行緒、程序、甚至另一臺機器上完成。

這就是非同步呼叫,其控制流是這樣的:

從程式設計思維上看,非同步呼叫和同步有很大的差別,如果我們把處理流程當做一個任務來的話,那麼同步下整個任務都是我們來實現的,但是非同步情況下任務的處理流程被分為了兩部分:

  1. 第一部分是我們來處理的,也就是呼叫 request 之前的部分
  2. 第二部分不是我們處理的,而是在其它執行緒、程序、甚至另一個機器上處理的。

我們可以看到由於任務被分成了兩部分,第二部分的呼叫不在我們的掌控範圍內,同時只有呼叫方才知道該做什麼,因此在這種情況下回調函式就是一種必要的機制了。

也就是說回撥函式的本質就是 “只有我們才知道做些什麼,但是我們並不清楚什麼時候去做這些,只有其它模組才知道,因此我們必須把我們知道的封裝成回撥函式告訴其它模組”。

現在你應該能看出非同步回撥這種程式設計思維模式和同步的差異了吧。

接下來我們給回撥一個較為學術的定義

正式定義

在電腦科學中,回撥函式是指一段以引數的形式傳遞給其它程式碼的可執行程式碼。

這就是回撥函式的定義了。

回撥函式就是一個函式,和其它函式沒有任何區別。

注意,回撥函式是一種軟體設計上的概念,和某個程式語言沒有關係,幾乎所有的程式語言都能實現回撥函式。

對於一般的函式來說,我們自己編寫的函式會在自己的程式內部呼叫,也就是說函式的編寫方是我們自己,呼叫方也是我們自己。

但回撥函式不是這樣的,雖然函式編寫方是我們自己,但是函式呼叫方不是我們,而是我們引用的其它模組,也就是第三方庫,我們呼叫第三方庫中的函式,並把回撥函式傳遞給第三方庫,第三方庫中的函式呼叫我們編寫的回撥函式,如圖所示:

而之所以需要給第三方庫指定回撥函式,是因為第三方庫的編寫者並不清楚在某些特定節點,比如我們舉的例子油條製作完成、接收到網路資料、檔案讀取完成等之後該做什麼,這些只有庫的使用方才知道,因此第三方庫的編寫者無法針對具體的實現來寫程式碼,而只能對外提供一個回撥函式,庫的使用方來實現該函式,第三方庫在特定的節點呼叫該回調函式就可以了。

另一點值得注意的是,從圖中我們可以看出回撥函式和我們的主程式位於同一層中,我們只負責編寫該回調函式,但並不是我們來呼叫的。

最後值得注意的一點就是回撥函式被呼叫的時間節點,回撥函式只在某些特定的節點被呼叫,就像上面說的油條製作完成、接收到網路資料、檔案讀取完成等,這些都是事件,也就是 event,本質上我們編寫的回撥函式就是用來處理 event 的,因此從這個角度看回調函式不過就是 event handler,因此回撥函式天然適用於事件驅動程式設計 event-driven,我們將會在後續文章中再次回到這一主題。

回撥的型別

我們已經知道有兩種型別的回撥,這兩種型別的回撥區別在於回撥函式被呼叫的時機。

注意,接下來會用到同步和非同步的概念,對這兩個概念不熟悉的同學可以參考上一盤文章《從小白到高手,你需要理解同步和非同步》。

同步回撥

這種回撥就是通常所說的同步回撥 synchronous callbacks、也有的將其稱為阻塞式回撥 blocking callbacks,或者什麼修飾都沒有,就是回撥,callback,這是我們最為熟悉的回撥方式。

當我們呼叫某個函式 A 並以引數的形式傳入回撥函式後,在 A 返回之前回調函式會被執行,也就是說我們的主程式會等待回撥函式執行完成,這就是所謂的同步回撥。

有同步回撥就有非同步回撥。

回撥對應的程式設計思維模式

讓我們用簡單的幾句話來總結一下回調下與常規程式設計思維模式的不同。

假設我們想處理某項任務,這項任務需要依賴某項服務 S,我們可以將任務的處理分為兩部分,呼叫服務 S 前的部分 PA,和呼叫服務 S 後的部分 PB。

在常規模式下,PA 和 PB 都是服務呼叫方來執行的,也就是我們自己來執行 PA 部分,等待服務 S 返回後再執行 PB 部分。

但在回撥這種方式下就不一樣了。

在這種情況下,我們自己來執行 PA 部分,然後告訴服務 S:“等你完成服務後執行 PB 部分”。

因此我們可以看到,現在一項任務是由不同的模組來協作完成的。

即:

  • 常規模式:呼叫完 S 服務後後我去執行 X 任務,

  • 回撥模式:呼叫完 S 服務後你接著再去執行 X 任務,

其中 X 是服務呼叫方制定的,區別在於誰來執行。

為什麼非同步回撥這種思維模式正變得的越來越重要

在同步模式下,服務呼叫方會因服務執行而被阻塞暫停執行,這會導致整個執行緒被阻塞,因此這種程式設計方式天然不適用於高並發動輒幾萬幾十萬的併發連線場景,

針對高併發這一場景,非同步其實是更加高效的,原因很簡單,你不需要在原地等待,因此從而更好的利用機器資源,而回調函式又是非同步下不可或缺的一種機制。

回撥地獄,callback hell

有的同學可能認為有了非同步回撥這種機制應付起一切高併發場景就可以高枕無憂了。

實際上在電腦科學中還沒有任何一種可以橫掃一切包治百病的技術,現在沒有,在可預見的將來也不會有,一切都是妥協的結果。

那麼非同步回撥這種機制有什麼問題呢?

實際上我們已經看到了,非同步回撥這種機制和程式設計師最熟悉的同步模式不一樣,在可理解性上比不過同步,而如果業務邏輯相對複雜,比如我們處理某項任務時不止需要呼叫一項服務,而是幾項甚至十幾項,如果這些服務呼叫都採用非同步回撥的方式來處理的話,那麼很有可能我們就陷入回撥地獄中。

舉個例子,假設處理某項任務我們需要呼叫四個服務,每一個服務都需要依賴上一個服務的結果,如果用同步方式來實現的話可能是這樣的:

a = GetServiceA();
b = GetServiceB(a);
c = GetServiceC(b);
d = GetServiceD(c);

程式碼很清晰,很容易理解有沒有。

我們知道非同步回撥的方式會更加高效,那麼使用非同步回撥的方式來寫將會是什麼樣的呢?

GetServiceA(function(a){
    GetServiceB(a, function(b){
        GetServiceC(b, function(c){
            GetServiceD(c, function(d) {
                ....
            });
        });
    });
});

我想不需要再強調什麼了吧,你覺得這兩種寫法哪個更容易理解,程式碼更容易維護呢?

博主有幸曾經維護過這種型別的程式碼,不得不說每次增加新功能的時候恨不得自己化為兩個分身,一個不得不去重讀一邊程式碼;另一個在一旁罵自己為什麼當初選擇維護這個專案。

非同步回撥程式碼稍不留意就會跌到回撥陷阱中,那麼有沒有一種更好的辦法既能結合非同步回撥的高效又能結合同步編碼的簡單易讀呢?

幸運的是,答案是肯定的,我們會在後續文章中詳細講解這一技術。

總結

在這篇文章中,我們從一個實際的例子出發詳細講解了回撥函式這種機制的來龍去脈,這是應對高併發、高效能場景的一種極其重要的編碼機制,非同步加回調可以充分利用機器資源,實際上非同步回撥最本質上就是事件驅動程式設計,這是我們接下來要重點講解的內容。