在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指標的方式應該說是目前比較完善的解決辦法。不失封裝性,又不失易用性。