C語言關於回撥函式和this指標探討

shentar.me發表於2014-08-25

在C裡面,經常需要提供一個函式地址,註冊到結構裡,然後在程式執行到特定階段時,回撥該函式。建立執行緒,註冊執行緒執行的主函式就是一個典型的例子。這裡以簡單的回撥例項,說明C++中回撥函式為成員函式時有關this指標的問題。由於C++對C的繼承關係,C++沒有自己的執行緒封裝技術,一般而言我們建立執行緒時,還是用C的回撥函式機制。類似的例子也挺多的。在Java等純粹的面嚮物件語言,則不一樣,不光有自己的獨立的執行緒型別,對於回撥,也是註冊整個物件,而不是註冊一個方法,如常用的觀察者模式。這裡,在網上查閱了大量關於this指標、類成員函式和靜態成員函式的相關知識點,結合自己的理解作一些總結。

關於回撥函式,類的成員函式作為回撥函式,一般而言大家已經形成了程式設計正規化,討論一些生僻的用法,可能被認為是腐朽的,無價值的。這裡只想客觀分析一下技術點,思想可能在類似的場景中遇到也說不準。

通常我們理解的成員函式和this指標是:

《深入探索C++物件模型》中提到成員函式時,當成員函式不是靜態的,虛擬函式,那麼我們有以下結論:
(1) &類名::函式名 獲取的是成員函式的實際地址;
(2) 對於函式x來講obj.x()編譯器轉化後表現為x(&obj),&obj作為this指標傳入;
(3) 無法通過強制型別轉換在類成員函式指標與其外形幾乎一樣的普通函式指標之間進行有效的轉換。

通常我們理解的是類的普通成員函式有一個隱藏的引數,即第一個引數,其值是this。如果希望一個成員函式既能訪問類的資料成員,又能作為回撥函式,有如下幾種方法:

1、靜態成員函式作為回撥函式

為了不失封裝性,可以將需要作為回撥的函式宣告為靜態的。靜態的成員函式,可以直接在類的外部呼叫。我們知道靜態成員函式是不能直接訪問類的非靜態資料和介面的。那麼此時需要知道具體的物件地址或者引用才能訪問具體的物件成員。又有兩個方法能實現這個:

1)將物件的地址用全域性變數記錄,在靜態成員函式中通過該全域性變數訪問資料成員和方法。來看具體的程式碼例項:

#include <stdio.h>
#include <stdlib.h>

typedef void (*func)(void*);

class CallBack;
class CallBackTest;

CallBack* g_obj = NULL;
CallBackTest* g_test = NULL;

class CallBackTest
{
public:
    CallBackTest()
    {
        m_fptr = NULL;
        m_arg = NULL;
    }

    ~CallBackTest()
    {

    }

    void registerProc(func fptr, void* arg = NULL)
    {
        m_fptr = fptr;
        if (arg != NULL)
        {
            m_arg = arg;
        }
    }

    void doCallBack()
    {
        m_fptr(m_arg);
    }

private:
    func m_fptr;
    void* m_arg;

};

class CallBack
{
public:
    CallBack(CallBackTest* t) : a(2)
    {
        if (t)
        {
            t->registerProc((func)display);
        }
    }

    ~CallBack()
    {

    }

    static void display(void* p)
    {
        if (g_obj)
        {
            g_obj->a++;
            printf("a is: %d", g_obj->a);
        }
    }

private:
    int a;

};

int main(int argc, char** argv)
{
    g_test = new CallBackTest();
    g_obj = new CallBack(g_test);
    g_test->doCallBack();

    return 0;
}

如上程式碼,實現對CallBack成員函式的回撥。在callback類的建構函式中註冊靜態的成員函式到callbacktest類中。如果對該程式碼稍加該井,可以將g_obj變數放在callback類裡面,作為一個靜態成員,這樣就更好了。更優雅的,將g_obj作為display的引數傳入,就更好了。於是有了我們通常的做法,將成員函式宣告為靜態的,帶一個引數,是其所在的類的物件指標,這樣我們可以在註冊的時候將this指標傳遞給靜態成員函式,使用起來就好像是靜態的成員函式有了this指標一樣。

#include <stdio.h>
#include <stdlib.h>

typedef void (*func)(void*);

class CallBack;
class CallBackTest;

class CallBackTest
{
public:
    CallBackTest()
    {

    }

    ~CallBackTest()
    {

    }

    void registerProc(func fptr, void* arg = NULL)
    {
        m_fptr = fptr;
        if (arg != NULL)
        {
            m_arg = arg;
        }
    }

    void doCallBack()
    {
        m_fptr(m_arg);
    }

private:
    func m_fptr;
    void* m_arg;

};

class CallBack
{
public:
    CallBack(CallBackTest* t) : a(2)
    {
        if (t)
        {
            t->registerProc((func)display, this);
        }
    }

    ~CallBack()
    {

    }

    static void display(void* _this = NULL)
    {
        if (!_this)
        {
            return;
        }
        CallBack* pc = (CallBack*)_this;
        pc->a++;
        printf("a is: %d", pc->a);
    }

private:
    int a;

};

int main(int argc, char** argv)
{
    CallBackTest* cbt = new CallBackTest();
    CallBack* cb = new CallBack(cbt);
    cbt->doCallBack();

    return 0;
}

最常用和正統的解決方法,藉助於static成員函式對類資料成員的可見性,可以很方便的利用:

pc->a++;
printf("a is: %d", pc->a);

這樣的語句來操作類的成員函式和成員資料。但是仍然不能像普通成員函式那樣利用隱藏的this指標就直接操作類的成員函式。肯定有很多“好事”的同學希望直接像普通的成員函式那樣訪問類的成員。接下來就探討一下這個方法。

2、非靜態成員函式作為回撥函式

既然我們知道,非靜態成員函式有一個隱藏的引數,那麼能否註冊的時候,多傳入一個引數,然後隱藏的那個指向物件的引數預設就轉為this指標的值了,相當於在呼叫時給this賦值。可以做一個嘗試,程式碼如下:

#include <stdio.h>
#include <stdlib.h>

typedef void (*func)(void*);

class CallBack;
class CallBackTest;

class CallBackTest
{
public:
    CallBackTest()
    {

    }

    ~CallBackTest()
    {

    }

    void registerProc(func fptr, void* arg = NULL)
    {
        m_fptr = fptr;
        if (arg != NULL)
        {
            m_arg = arg;
        }
    }

    void doCallBack()
    {
        m_fptr(m_arg);
    }

private:
    func m_fptr;
    void* m_arg;

};

class CallBack
{
public:
    CallBack(CallBackTest* t) : a(2)
    {
        if (t)
        {
            t->registerProc((func)display, this);
        }
    }

    ~CallBack()
    {

    }

    void display()
    {
        a++;
        printf("a is: %d", a);
    }

private:
    int a;

};

int main(int argc, char** argv)
{
    CallBackTest* cbt = new CallBackTest();
    CallBack* cb = new CallBack(cbt);
    cbt->doCallBack();

    return 0;
}

嘗試失敗了,提示編譯錯誤。在附錄的引用[1]文中,作者採用了更直接的給指標變數賦值的方式,避開了編譯錯誤的問題,但呼叫時仍然會報錯。因此this指標並不是簡單的在函式呼叫時以第一個引數的方式傳遞進去的,在理解成員函式訪問資料的過程可以這樣去理解,但是實際上的執行過程並不是這樣的。在引文1、2中給出了一些可行的辦法,進一步找了一下,這個也就是thunk技術,由於與平臺和編譯器的行為強相關。大體思路是,首先將this指標填寫到指定的暫存器或者指定的地方,當呼叫成員函式名時,會自動根據暫存器的地址值加上偏移量實現跳轉。這裡不詳細介紹了,有興趣的同學可以參考連結。

使用靜態成員函式加上引數傳入this指標的方式應該說是目前比較完善的解決辦法。不失封裝性,又不失易用性。

相關文章