1. 程式人生 > >指標篇之十三 函式指標精彩回撥

指標篇之十三 函式指標精彩回撥

回撥函式定義

    回撥是通過函式引數傳遞到其它程式碼內的某一段可執行程式碼。或者說,凡是自己定義又主動傳給其他模組呼叫的,都是回撥。回撥允許底層模組呼叫高層定義的子程式。

理解回撥首先要明白什麼是層次/模組,軟體模組是廣義概念,可以包括功能庫(也稱SDK)、C++物件、元件以及作業系統等,但所有這些最終基本都可以歸結為函式實體庫。因此下文中上層模組就是指庫的呼叫方(caller),而下層模組就指功能庫(callee)自身。

    回撥與普通呼叫的區別就體現在上下層模組間的呼叫關係上,常見的普通呼叫是上層呼叫下層,由上向下單向發號施令。但有時下層庫也想做主人,反過來呼叫上層,這時上層就要專門為下層準備介面,這些孤立介面就是回撥。上下層地位不會因個別回撥而改變,而且回撥本身也要上層先主動提供給下層(註冊回撥)。

   回撥在C語言中藉助於函式指標(是實現callback的方式之一,其他手段這裡不涉及)來實現:呼叫者(上層模組)把一個函式指標(地址)作為引數傳遞給被調庫(下層模組),後者在特定時刻再呼叫這個上層定義的函式。被呼叫者回頭call呼叫者提供的函式,故稱為回撥(callback)函式。回撥函式通常由程式設計者自己在上層實現,但上層一般不直接呼叫它,而是在被封裝的下層庫裡反向呼叫。從上層角度看,似乎是定義了一個永遠沒被呼叫的函式,或許就是這點讓習慣普通函式呼叫的初學者感到回撥函式難以理解。

引如回撥的必要性

    程式設計實踐中引入回撥,能夠解決模組間的耦合問題,使分層更清晰。

    普通呼叫是下層為上層提供函式介面,且為保證下層庫的可重用性,庫之間一般是從上到下的樹狀依賴,即:上層庫依賴若干下層庫,下層庫只依賴更下一層的庫,而不能反向依賴上層庫。只有這樣下層庫對於上層應用才是獨立可重用的,但凡事有例外,某些場合下層庫也希望呼叫上層,這時仍按傳統函式呼叫方式就會有麻煩。

    假設有動態庫ABsodll),上層A呼叫下層B中系列函式funB1/funB2…,而下層B又試圖呼叫A中某函式funA1,這就有個問題:動態庫必須符號解析完整才能link成功,因此編譯庫A要依賴庫B提供對funB1/funB2等符號的解析,可是要編譯庫B反過來又要庫A提供funA1,這樣兩個模組互為依賴導致死鎖,都沒法編譯。解耦辦法是A先把函式funA1的指標傳給BB通過函式指標呼叫funA1而不再直接依賴A,就能獨立編譯。有了BA自然也能編譯。這裡funA1就是回撥函式,利用它把雙向依賴變為單向,解除耦合,且使B可重用,前提是所有B的呼叫方額外提供funA1

    上面是從解決編譯死鎖的角度論述函式指標這種回撥實現形式,在這之前有個問題,什麼時候下層非要反向呼叫上層函式?舉例,下面函式在單向連結串列裡查詢一個值,函式引數是一個指向連結串列第一節點的指標以及要查詢的目標值(例子來源於網路):

    Node* search_list(Node* node,int const val)

    {

      while(node!=NULL)

      {

        if(node->val==val)  break;

         node=node->link;

      }

      return node;

    }

    這個函式只能查詢目標值為整數的連結串列,如果要在字串連結串列中查詢,就要另寫一個函式,新函式程式碼基本不變,只是第二個引數的型別及節點值的比較方法不同。能否用單個函式查詢任意型別的連結串列?自己先想想辦法......。可能有人考慮在函式中再加一個代表型別的引數(很順理成章的邏輯)

    #define TYPE_INT 1

    #define TYPE_FLOAT 2

    Node* search_list(Node* node, void *val, int val_type)

    {

       while(node!=NULL)

      {

        if(val_type == TYPE_INT) 

        {

          if(node->val==*((int *)val))  break;

          node=node->link;

        }else if(val_type == TYPE_FLOAT)

        {

          if(node->val == *((float *)val))  break;

          node = node->link;

        }else

          …

      }

      return node;

    }

   呼叫時根據Node裡值的型別設定對應val_type,就能比較各種型別,但感覺有點彆扭:有新型別就要新增型別定義(如TYPE_CHAR)和相應處理程式碼,即擴充套件功能必須修改search_list並重編庫,模組通用性差,且if else裡都是近似重複程式碼,味道很壞。

    這種實現還是受上層單向呼叫下層的習慣思維限制,軟體世界裡不是所有邏輯都能用樹狀結構描述,比如這裡只有用回撥解除耦合後,核心查詢模組才更加簡潔通用,如下:

    Node* search_list(Node *node,void const *value, int(*cmp)(void const*,void const*))

    { 

      while(node!=NULL)

      {

        if(cmp(&node->val,value)==0)   break;

        node=node->link;

      }

      return node;

    }

    這裡函式指標cmp作為查詢函式search_list的引數之一,把上層根據目標型別實現的比較函式傳遞下來,search_list內部通過指標cmp執行相應型別的比較,這就能靈活支援各種型別連結串列的查詢。比如在節點為整型的連結串列中查詢時,只需實現整型比較函式,把函式指標和目標值指標傳給search_list。注意目標值value的型別宣告為"void *"可方便cmp內部的型別轉換,下面是幾個比較函式實現:

    int cmp_ints(void const* a,void const* b)  //整數比較

   {

      if(*(int*)a==*(int*)b)  return 0;

      else  return 1;

    }

    查詢函式這樣呼叫:desired_node=search_list(root,&desired_val,cmp_ints);

    int cmp_flts(void const* a,void const* b)  //浮點比較

    {

       if(*(float*)a==*(float*)b)     return 0;

       else    return 1;

    }

    呼叫方式為:desired_node=search_list(root,&desired_value,cmp_flts);

    模組化實現時,search_lists屬於下層,具有通用性,可編譯成庫供使用者使用。而上層負責根據目標值實現cmp_xxx以及呼叫search_lists,其中cmp_xxx的實現必須符合search_listscmp引數的要求:返回值型別及cmp引數的個數和型別都匹配。

回撥功能分類

    適合選擇回撥的場合大體可分為三種情況:

1)實現一模組又希望把某子功能留給上層呼叫者靈活實現,可在模組內專注於核心功能,把部分定製和擴充套件功能通過回撥函式的方式留給上層模組實現,上面的連結串列搜尋就是一例。常見的還有C庫裡的快速排序函式qsort,它也採用使用者定製回撥函式的方式:

    void qsort(void *base, size_t nelem, size_t width, int (*fcmp)(const void *, const void *));

    它和普通C標準庫函式不一樣的地方在於引數fcmp,使用者呼叫qsort前必須先實現一個與fcmp原型匹配的函式,並把其指標作為引數餵給qsort,(將欲用之,必先喂之),如:

    int sort_func( const void *a, const void *b) //引數和返回值型別要與qsort裡的fcmp一致

   {

      return *(int*)a-*(int*)b;

    }

    int list[5] = { 50, 20, 40, 60, 80 };

    void main(void)

    {

      qsort((void *)list, 5, sizeof(list[0]), sort_func);

      for (int x = 0; x < 5; x++) {  printf("%i\n", list[x]);  }

    }

2)實現事件觸發的通知機制,如程式中設定一個計時器,每過一段設定時間,上層定義的回撥函式就會被下層呼叫,用這種方式向上通知定時時間到的事件。這種場合的回撥往往和訊息/事件混在一起,準備放在後文作業系統篇再討論。

3)非同步操作的回撥通知,即典型的非同步+回撥程式設計模式。當下層模組某介面的內部操作很費時,又不希望上層阻塞等待,一般採用非同步方式:呼叫函式即時返回,下層模組另起一個內部執行緒在後臺執行,完成後通過回撥函式通知上層。其簡單實現思想(偽碼)

    void asyncOperX(…,void(*fcallback)( ……) ) //非同步函式,引數之一為操作完成後的回撥

    {

      thread_create(…,doOperX, fcallback) //建立內部執行緒,並把回撥指標傳給執行緒函式

      return;                          //即刻退出,繼續執行主程式,體現非同步

    }

    void doOperX(void(*fcallback)( ……)) //執行緒函式,引數為回撥函式指標

    {

      ReadfromFileOrRemoteNetwork()         //執行費時操作

      *fcallback()   //操作完成後,通過回撥通知上層

    }

   這裡利用回撥實現了非同步通知機制,更進一步就是再asysncOperX內部不直接建立執行緒,而是向某全域性佇列傳送一個命令,系統專門開闢一個執行緒處理這個佇列裡各種CMD請求,這在UI實現層及系統框架層常用,即事件佇列或命令佇列的概念。大家可能發覺上面實現中,回撥函式指標從非同步介面一路傳到內部執行緒,甚至後面事件佇列等等,會很麻煩。是否能把回撥的傳入和呼叫分開呢,這就引出了——

註冊回撥

    回撥函式可直接通過呼叫回撥函式的函式的引數傳進底層模組(有點繞,也就是回撥函式的傳入和呼叫是在同一個函式內),這是一種簡單方式。但有時下層模組希望能先把回撥函式指標傳進模組內部集中管理,而不是在傳入的介面內部直接呼叫,因為:

    1)模組內部包含多個回撥函式,需要統一儲存管理。

      2)下層模組自身是一個包含內部執行緒迴圈的框架,需要根據外界事件觸發非同步回撥(即不知道什麼時候會觸發回撥),而不是傳入回撥後馬上同步呼叫,這就希望回撥的傳入和呼叫在時間上分開。

     由上層模組提前(一般是在初始化下層模組時)通過呼叫下層模組的一個專用介面RegCallback,告訴下層在某種情況發生時呼叫某回撥函式B,這個過程稱為回撥函式B的註冊或安裝,RegCallback就稱為回撥的註冊函式,所有回撥函式指標都通過這個專門介面集中傳入模組,並在內部儲存和管理,以便在對應的場合回撥它們。

    註冊回撥單單把函式指標傳下去顯然不行,下層還要知道傳進來的函式應何時被回撥,所以要有上下層共享的索引定義作橋樑,以便在註冊時告訴下層:“我送進來一個某某函式指標,當某某事件發生時回撥這個函式“,因此註冊函式的引數最少有兩個,一是某回撥函式指標,另一個就是與此回撥函式對映的事件或功能索引。註冊後下層模組通過內部機制,能根據特定索引找到對映的回撥函式指標(再展開就剎不住了,中斷/事件/訊息統統牽扯進來,就此打住)。這種統一註冊模式可實現回撥在模組內的集中管理和非同步呼叫,因此常被作業系統、裝置驅動等系統模組採用。

總結

    callback傳入下層後直接在傳入的函式體中呼叫,這是同步回撥;而先通過註冊介面把callback匯入模組並儲存,當特定事件發生時才觸發,這是非同步回撥。它們都將上下層從空間呼叫關係上解耦,非同步回撥還實現了時間解耦。

    總之回撥可以實現程式控制權的轉移,是序列程式碼向框架framework演變的基礎,是好萊塢原則的具體實現。

    軟體開發領域的好萊塢原則:“don't call us, we'll call you.”其大概由來是這樣:在好萊塢,由於幻想一朝成名的人太多,負責聯絡藝人的經紀人牛氣十足,如果藝人要想演出,打電話給經紀公司,他們通常答覆:別打電話給我們,留下你的電話,有活我們會打給你。和回撥參照,留下電話就是註冊回撥,有活就是事件,我們打給你就是回撥通知。