成員函數指針與高效C++委託 (delegate)

成員函數指針與高效C++委託 (delegate)

翻譯: adie 
日期: Jun,2011 
原文作者: Don Clugston 
原文地址: http://www.codeproject.com/KB/cpp/FastDelegate.aspx

概要

很遺憾, C++ 標準中沒能提供面向對象的函數指針. 面向對象的函數指針也被稱爲閉包(closures) 或委託(delegates), 在類似的語言中已經體現出了它的價值. 在 Delphi(Object Pascal) 中, 他們是 VCL (Borland's Visual Component Library, 寶藍可視化組件) 的基礎. 最近的 C# 讓委託的概念更爲流行, 這也成爲 C# 成功的因素之一. 在許多程序中, 委託可以簡化由鬆耦合對象組成的高級設計模式(觀察者模式, 策略模式, 狀態模式)的使用. 毫無疑問, 委託在 C++ 中是非常有用的.

C++中沒有委託, 只提供成員函數指針. 在非必要的情況下, 大多數程序員都不願意使用成員函數指針. 它們語法複雜(比如 ->* 和.* 操作符), 難以理解, 別且大多數情況下都有更好的代替辦法. 更爲諷刺的是: 編譯器實現委託比實現成員函數指針要簡單得多!

本文將爲你揭開成員函數指針的神祕面紗. 學習完成員函數指針的語法和特性之後, 我會詳細解釋常見的編譯器是如何實現成員函數指針的. 之後我們會看到編譯器該如何來實現高效的委託, 最終, 利用上面關於成員函數指針的知識, 我會實現一個在大多數編譯器上都高效的委託. 比如, 在 Visual C++(6.0, .NET 和 .NET 2003) 調用一個單目標的委託只會產生兩行彙編代碼.

函數指針

讓我們從函數指針開始. 在 C/C++ 中, 假如有一個函數帶一個int參數和一個char *參數, 返回值爲float, 那麼一個名爲my_func_ptr 的指向這個函數的函數指針聲明如下:

float (*my_func_ptr)(int, char *);
// 爲了便於理解, 強烈建議使用 typedef.

// 否則在使用函數指針作爲參數時代碼會難以閱讀和理解.

// 使用 typedef 後的聲明如下:

typedef float (*MyFuncPtrType)(int, char *);
MyFuncPtrType my_func_ptr;

需要注意的是, 函數參數不同, 其指針的類型也不同. 在 MSVC(Microsoft Visual C++ 系列編譯器) 中, 調用方式(__cdecl,__stdcall, 和 __fastcall)不同, 其指針類型也不相同. 讓函數指針指向一個函數 float some_func(intchar *) 的代碼如下:

 my_func_ptr = some_func;

通過函數指針調用其指向的函數方法如下:

 (*my_func_ptr)(7, "Arbitrary String");

函數指針之間可以互相轉換, 但不能被轉換成數據指針 void *. 還有一些其它不重要的操作這裏就不再累述了. 函數指針可以被設置成 0 來標識空指針. 所有的比較操作符(==!=<><=>=) 對函數指針都有效, 你也可以通過把函數指針隱式轉換成 bool 或使用==0來測試空指針. 更爲有趣的是, 你還可以把函數指針作爲非類型的模板參數來使用. 這與使用類型的模板參數, 整數的非類型模板參數本質上都是不一樣的. 它會按照名字來實例化, 而不是類型或值. 所有編譯器都支持基於名字的模板參數, 甚至有些編譯器還支持偏特化.

在 C 中, 函數指針通常用來作爲 qsort 這種庫函數的參數, Windows API 函數的回調參數等等. 當然, 函數指針還有許多其它的應用. 函數指針的實現非常簡單: 它們只"代碼地址(code pointers)": 它存儲了彙編代碼的開始地址. 函數指針有各種不同的類型只是爲了在調用的時候做語法檢查, 保證以正確的方式進行調用.

成員函數指針

在 C++ 程序中, 大多數的函數都是成員函數, 是類的一部分. 你不能用普通的函數指針來指向成員函數, 必須使用成員函數指針. 一個指向 SomeClass 類的, 參數同上的成員函數指針聲明如下:

float (SomeClass::*my_memfunc_ptr)(int, char *);

// 對常量的成員函數, 聲明如下:
float (SomeClass::*my_const_memfunc_ptr)(int, char *) const;

注意這裏使用了一個特殊的操作符 (::*), 而且 SomeClass 也是聲明的一部分. 成員函數指針有一個可怕的限制: 它們只能指向固定的一個類的成員函數. 對每一種參數組合, 每一個類型的 const 版本或非 const 版本, 以及每一個不同的類, 其成員函數指針的類型都是不同的. 在 MSVC 中, 對每一種調用方式 __cdecl__stdcall__fastcall, 以及 __thiscall. (__thiscall 是默認的調用方式, 有趣的是, 你在文檔中無法找到 __thiscall 這個關鍵詞, 但是它經常出現在錯誤消息中. 如果你顯示的使用它, 你會得到一個錯誤消息, 這個關鍵詞是被保留以便將來使用的.) 成員函數指針依然有不同的類型. 在使用成員函數指針時, 你應該始終使用typedef 以避免混淆.

讓函數指針指向 float SomeClass::some_member_func(intchar *) 的代碼如下:

 my_memfunc_ptr = &SomeClass::some_member_func;
 
// 下面是針對操作符的語法:
 my_memfunc_ptr = &SomeClass::operator !;
 
// 你沒有辦法取得構造函數和析構函數的地址

某些編譯器 (以 MSVC 6 和 7 爲代表) 允許你省略 &, 顯然這並不符合標準, 而且容易引起混亂. 對大多數符合標準的編譯器 (比如, GNU G++ 和 MSVC 8 (也叫 VS 2005)) 來說, & 是必須的, 所以, 你應該始終使用它. 在調用成員函數指針時, 你需要提供一個 SomeClass 類的對象, 然後使用一個特殊的操作符 ->*. 這個操作符優先級很低, 你還需要把它放在括號裏面.

  SomeClass *x = new SomeClass;
  (x->*my_memfunc_ptr)(6, "Another Arbitrary Parameter");

// 如果對象在棧上, 你也可以使用 .* 操作符.

  SomeClass y;
  (y.*my_memfunc_ptr)(15, "Different parameters this time");

不要問我語法的問題 -- 看起來某位 C++ 的設計者特別喜歡這些符號!

C++ 在 C 的基礎上添加了三個操作符來支持成員函數指針. ::* 用於指針的聲明, ->* 和 .* 用於調用函數指針指向的函數. 看起來 C++ 的設計者們對這個語言中很少使用的部分給予了特別的關注. (雖然我不明白爲什麼要這麼做, 但是你還可以重載 ->* 操作符. 我只知道一種需要重載這個操作符的情況 [參見 Meyers 的文章].)

成員函數指針可以設置爲 0. 對同一個類的成員函數指針, 可以進行 == 和 != 操作. 所有的成員函數指針都可以和 0 比較來判斷是否爲空. [2005 年三月更新: 並不是所有編譯器都這樣, 在 Metrowerks MWCC 中, 指向類的第一個虛函數的成員函數指針是和 0 相等的!] 和普通函數指針不同, 對大小進行比較的操作符 (<><=>=) 是不可用的. 和普通函數指針一樣, 成員函數指針也可以作爲非類型的模板參數, 不過好像支持的編譯器還不多.

成員函數指針的特點

成員函數指針的某些地方顯得很奇怪. 首先, 成員函數指針不能指向一個靜態成員函數. 指向靜態成員函數需要使用普通函數指針("成員函數指針"這個名字顯得有些不恰當: 它們實際上應該叫做"非靜態成員函數指針"). 其次, 在處理繼承的類時它的行爲很奇怪. 例如: 下面的代碼在註釋完整的時候是可以在 MSVC 上編譯的:

class SomeClass {
 public: 
    virtual void some_member_func(int x, char *p) {
       printf("In SomeClass"); };
};

class DerivedClass : public SomeClass {
 public:

    // 如果你取消下面這行註釋, 在 * 的位置將會編譯失敗!
    // virtual void some_member_func(int x, char *p) { printf("In DerivedClass"); };

};

int main() {
    // 爲 SomeClass 聲明函數指針

    typedef void (SomeClass::*SomeClassMFP)(int, char *);
    SomeClassMFP my_memfunc_ptr;
    my_memfunc_ptr = &DerivedClass::some_member_func; // ---- (*)

}

很奇怪, &DerivedClass::some_member_func 是類 SomeClass 的一個成員函數指針, 而不是 DerivedClass 的! (某些編譯器有一些細微的差別: 比如, 對 Digital Mars C++ 來說, &DerivedClass::some_member_func 在這種情況下是未定義的.) 但是, 如果DerivedClass 重寫 some_member_func, 上面的代碼就不能編譯了, 因爲 &DerivedClass::some_member_func 已經變成DerivedClass 的成員函數指針了!

成員函數指針之間的轉換是一個相當灰暗的領域. 在 C++ 標準化的過程中, 對這種轉換有過激烈的爭論: 是否允許將成員函數指針轉換爲它的基類或派生類的成員函數指針? 還是直接允許在不相干的兩個類之間轉換? 在標準委員會還在爲他們的想法糾結時, 不同的編譯器已經用他們的實現來對這個問題做了不同的回答. 根據標準 (5.2.10/9 節), 可以使用 reinterpret_cast 在不相關的類的成員函數指針之間進行轉換. 對轉換後的成員函數指針進行調用的結果是未定義的. 你對轉換後的成員函數指針唯一能做的就是再把它們轉換回去. 對於這個各種編譯器還沒有統一標準的問題, 我稍後還會詳細討論.

在某些編譯器中, 轉換基類和派生類的成員函數指針會發生靈異事件. 涉及多繼承時, 如果想用 reinterpret_cast 把派生類的成員函數指針轉換爲基類的成員函數指針, 是否可以編譯通過還要看這些類聲明時使用的順序. 來看這個例子:

class Derived: public Base1, public Base2 // 方法 (a)

class Derived2: public Base2, public Base1 // 方法 (b)

typedef void (Derived::* Derived_mfp)();
typedef void (Derived2::* Derived2_mfp)();
typedef void (Base1::* Base1mfp) ();
typedef void (Base2::* Base2mfp) ();
Derived_mfp x;

在方法 (a) 中, static_cast<Base1mfp>(x) 運行正常, 但是 static_cast<Base2mfp>(x) 則會編譯失敗. 同理, 對方法 (b) 來說,情況剛好相反. 只有把派生類的成員函數指針轉換成第一個基類的成員函數指針纔是安全的! 你可以測試一下, MSVC 對次會發出警告 C4407, Digital Mars C++ 則會產生錯誤. 如果用 reinterpret_cast 代替 static_cast, 這兩個編譯器都會出錯, 只是提示的原因不同. 需要當心的是, 還有一些編譯器對這種用法完全接受, 沒有任何提示!

標準中還有一條有趣的規則: 你可以在類定義之前聲明這個類的成員函數指針. 你甚至還可以調用一個沒有完成的類型的成員函數! 這個問題稍後再討論. 注意, 還有一小部分編譯器不能處理這種情況(較早的 MSVC, 較早的 CodePlay, LVMM).

還有一點需要注意一下, 同成員函數指針一樣, C++ 標準還提供了成員數據指針. 他們使用相同的操作符, 一部分用法也相同. 成員數據指針在某些 stl::stable_sort 的實現中會用到, 對成員數據指針的其它問題這裏就不在涉及了.

成員函數指針的使用

看到這裏, 你應該相信成員函數指針的確是有些怪異了. 那麼, 它們有何用處呢? 我搜索了網上的大量代碼後發現, 成員函數指針的主要用途有兩點:

  1. 作爲例子, 向 C++ 新手演示語法, 以及
  2. 實現委託!

當然成員函數指針還有一些不那麼重要的用法, 比如在 STL 和 boost 中作爲短小的函數適配器, 讓你可以用成員函數來調用標準算法. 這種情況下, 它們只是用於編譯, 在編譯後的代碼中並不會真正出現成員函數指針. 成員函數指針最有趣的應用是用來定義複雜的接口, 用於實現很炫的效果, 但我還沒有找到這種例子. 大多數情況下, 成員函數指針能做的都可以用虛函數代替. 雖然如此, 成員函數指針還是在各種基於 MFC 消息映射機制的框架中被廣泛使用.

當你使用 MFC 的消息映射宏 (比如, ON_COMMAND) 時, 你實際上是在填充一個包含消息 ID 和成員函數指針(具體是指CCmdTarget::* 的成員函數指針)的數組. 這就是爲什麼你想要處理消息的話就得從 CCmdTarget 繼承才行. 但是不同的消息處理函數有不同的參數 (例如, OnDraw 的第一個參數爲 CDC *), 所以數組也需要包含不同類型的成員函數指針. MFC 怎麼處理這個問題的呢? 它們使用了一個可怕的作弊手段, 把所有可能用到的成員函數指針放到一個巨大的聯合(union)中來避免 C++ 的類型檢查. (查看 afximpl.h 和 cmdtarg.cpp 中的 MessageMapFunctions 聯合即可發現這個可怕的事實.) 由於 MFC 的重要性, 所有的編譯器都支持這種用法了.

除了編譯時的外, 我沒有搜索到什麼對成員函數指針用得很好的例子. 它的複雜性使得它沒能在 C++ 中留下多少足跡. C++ 成員函數指針在設計上的缺陷是無法否認的.

在寫這篇文章時, 我意識到了一點: C++ 標準允許你轉換成員函數指針, 以及不讓你調用轉換後的指針都是非常荒謬的. 其荒謬體現在這三點: 首先, 這種轉換在許多常用的編譯器上都無法工作(這種轉換符合標準, 但是不兼容). 其次, 在所有編譯器上, 一旦轉換成功了, 調用轉換後的函數指針的行爲和你希望的一模一樣, 並不會是什麼"未定義行爲". (調用是兼容的, 可移植的, 但是不符合標準!) 最後, 允許轉換而不允許調用是完全沒用的. 但是如果轉換和調用都可行, 那麼實現高效委託就很簡單了. 這對 C++ 語言來說將會帶來巨大的好處.

如果你還懷疑這種觀點, 請看這個例子. 考慮一個只有如下代碼的文件. 這些在 C++ 中都是合法的:

class SomeClass;

typedef void (SomeClass::* SomeClassFunction)(void);

void Invoke(SomeClass *pClass, SomeClassFunction funcptr) {
  (pClass->*funcptr)(); };

注意編譯器需要在對 SomeClass 類 一無所知 的情況下生成調用成員函數指針的彙編代碼. 顯然, 除非連接器去做一些複雜的調整, 這部分代碼 必須 在不知道實際的類定義的情況下正確的運行. 這直接證明了你可以安全的調用一個從完全不同的類轉換過來的成員函數指針.

要解釋這個觀點的另外一半 (即成員函數指針的轉換並不像標準說的那樣運行), 我們需要討論編譯器實現成員函數指針的細節. 這也會幫助我們理解爲什麼成員函數指針在使用上會有那麼多限制. 要通過普通的錯誤消息來獲得成員函數指針的準確描述是很困難的, 所以我檢查了很多編譯器生成的彙編代碼. 是該我們動手的時候了.

成員函數指針爲什麼這麼複雜?

類的成員函數和標準的 C 函數有很大的不同. 除了被聲明的參數外, 它還有一個隱藏的指向具體對象的 this 參數. 在不同的編譯器中, this 可能被當成一個普通的參數處理, 也可能被特別處理(比如, 在 VC++ 中, this 通常使用 ECX 寄存器進行傳遞, 這和普通的函數參數有本質的不同). 虛函數還要等到 運行時 才能知道該執行哪一個函數. 即使成員函數是一個非虛擬的函數(real function), 在標準 C++ 中你也沒有辦法讓一個普通函數處理得像成員函數一樣: 標準中並沒有 thiscall 這樣的關鍵字可以保證調用方式正確. 成員函數和普通函數有天壤之別(成員函數來自火星, 普通函數來自金星).

你可能認爲, 成員函數指針像普通函數指針一樣, 只是保存了函數代碼的起始地址. 這麼認爲你就錯了. 在大多數編譯器中, 成員函數指針都比普通函數指針佔用的空間要大. 更奇怪的是, 在 Visual C++ 中, 一個成員函數指針可能是 4, 8, 12, 或者 16 字節大小, 這和與之相關的類, 以及編譯器的設置相關! 成員函數指針比你想象的更爲複雜. 但並不總是這樣.

讓我們回到二十世紀八十年代早期. 在原始的 C++ 編譯器 (CFront) 剛被開發出來的時候, 它只支持單繼承. 那時的成員函數指針很簡單: 它們只是有一個額外的 this 參數做爲第一個參數的普通函數指針. 調用虛函數時, 函數指針指向一小塊額外的代理指令('thunk' code). (10 月 4 日更新: comp.lang.c++.moderated 討論組已經確認, CFront 並沒有真正使用代理指令, 而是採用了一種更爲優雅的方法. 但是, 它應該曾經使用過, 或者看起是使用的這種方法, 這會讓下面的討論簡單些. )

CFont 2.0 的發佈讓這個田園般的世界破碎了. 它引入了模板和多繼承. 多繼承所帶來的副作用是成員函數指針被去掉了. 因爲在使用多繼承時, 還沒有調用函數之前你無法知道該用哪個 this 指針. 舉例來說, 假如你有如下的四個類:

class A {
 public:
       virtual int Afunc() { return 2; };
};

class B {
 public: 
      int Bfunc() { return 3; };
};

// C 是單繼承, 從 A 派生

class C: public A {
 public: 
     int Cfunc() { return 4; };
};

// D 使用多繼承

class D: public A, public B {
 public: 
    int Dfunc() { return 5; };
};

試想, 我們爲 C 類創建了一個成員函數指針. 在這個例子中, Afunc 和 Cfunc 都是 C 的成員函數, 我們的成員函數指針可以指向Afunc 或者 Cfunc. 但是 Afunc 需要一個指向 C::A 的 this 指針(簡稱 Athis). 而 Cfunc 需要一個指向 C 的 this 指針(簡稱Cthis). 編譯器在處理這個問題時耍了一些小花招: 它們把 A 存儲在內存中 C 的開始位置. 這意味着 Athis == Cthis. 我們只需要考慮一個 this 就可以處理所有情況了.

現在假設我們創建了一個 D 類的成員函數指針. 這時, 我們的成員函數指針可以指向 AfuncBfunc, 或者 Dfunc. 但是 Afunc 需要一個指向 D::A 的 this 指針, 而 Bfunc 需要一個指向 D::B 的 this 指針. 這次編譯器耍的小花招就不靈了. 我們不能把 A  B都放在 D 的開始位置. 所以一個指向 D 的成員函數指針不僅要知道該調用哪個函數, 還需要知道怎麼使用 this 指針才行. 如果編譯器知道了 A 的大小, 它就可以通過增加偏移量 (delta = sizeof(A)) 來把 Athis 轉換成 Bthis 了.

如果你使用了虛繼承 (即虛基類), 情況就更糟了, 理解起來也更困難. 通常來說, 編譯器會使用虛函數表 ('vtable') 來存儲虛函數, 其中包括函數地址和虛偏移信息(virtual_delta): 把提供的 this 指針轉換爲函數需要的 this 指針所需要的偏移量.

如果 C++ 用稍微不同的方式來定義成員函數指針, 其實沒必要這麼複雜的. 在上面的代碼中, 允許 A::Afunc 作爲 D::Afunc 是產生複雜性的根源, 這通常也不是設計良好的代碼風格. 通常, 你應該使用基類作爲接口. 如果嚴格遵循, 那麼成員函數指針就成了有特殊調用方式的普通函數指針了. 恕我直言, 允許它們指向重載的函數是一個不幸的錯誤, 爲了這個很少用到的功能, 成員函數指針變得稀奇古怪, 也讓製作編譯器的人爲實現它們而頭疼不已.

成員函數指針的實現

那麼, 編譯器究竟是怎樣來實現成員函數指針的呢? 下面是各種編譯器對不同的類型使用 sizeof 的結果. 編譯器包括 32位, 64位 和 16 位的編譯器. 測試的類型有 intvoid * 數據指針, 普通函數指針(比如, 指向靜態函數的), 成員函數指針(指向的類包括單繼承的, 多繼承的, 虛繼承的, 或者未知類(比如, 使用前向聲明這種).

編譯器 選項 int 數據指針 函數指針 單繼承類 多繼承類 虛繼承類 未知類
MSVC   4 4 4 4 8 12 16
MSVC /vmg 4 4 4 16# 16# 16# 16
MSVC /vmg /vmm 4 4 4 8# 8# -- 8
Intel_IA32   4 4 4 4 8 12 16
Intel_IA32 /vmg /vmm 4 4 4 4 8 -- 8
Intel_Itanium   4 8 8 8 12 16 20
G++   4 4 4 8 8 8 8
Comeau   4 4 4 8 8 8 8
DMC   4 4 4 4 4 4 4
BCC32   4 4 4 12 12 12 12
BCC32 /Vmd 4 4 4 4 8 12 12
WCL386   4 4 4 12 12 12 12
CodeWarrior   4 4 4 12 12 12 12
XLC   4 8 8 20 20 20 20
DMC small 2 2 2 2 2 2 2
  medium 2 2 4 4 4 4 4
WCL small 2 2 2 6 6 6 6
  compact 2 4 2 6 6 6 6
  medium 2 2 4 8 8 8 8
  large 2 4 4 8 8 8 8

注#: 使用 __single__multi__virtual_inheritance 關鍵字後大小爲 4, 8, 或 12 字節.

編譯器爲 Microsoft Visual C++ 4.0 到 7.1 (.NET 2003), GNU G++ 3.2 (MingW binaries, www.mingw.org), Borland BCB 5.1 (www.borland.com), Open Watcom (WCL) 1.2 (www.openwatcom.org), Digital Mars (DMC) 8.38n (www.digitalmars.com), Intel C++ 8.0 for Windows IA-32, Intel C++ 8.0 for Itanium (www.intel.com), IBM XLC for AIX (Power, PowerPC), Metrowerks Code Warrior 9.1 for Windows (www.metrowerks.com), 以及 Comeau C++ 4.3 (www.comeaucomputing.com). Comeau 的數據在他們所支持的所有 32 位平臺(x86, Alpha, SPARC, 等)上測試過. 16 位編譯器在 4 種 DOS 配置 (tiny, compact, medium, 和 large) 下測試過. MSVC 在選項 (/vmg) 下也進行了測試. (如果你的編譯器不在列表中, 請告知我. 非x86體系下的編譯器測試結果有特別的價值.)

很吃驚, 是吧? 看着這張表, 你可以感覺到, 稍不留神你寫的代碼在某些編譯器下就不能運行, 即使它們在某些環境下可以工作良好. 很明顯編譯器內部的實現各不相同, 實際上, 我覺得沒有任何語言的實現會有如此的不同. 這些實現的細節也非常的不雅.

行爲良好的編譯器

幾乎所有的編譯器都使用 delta 和 vindex 這兩個字段來把傳入的 this 指針轉換爲調用函數所需要的指針 (adjustedthis). 舉例來說, 下面是 Watcom C++ 和 Borland 所使用的技術:

struct BorlandMFP { // Watcom 也這樣用

   CODEPTR m_func_address;
   int delta;
   int vindex; // 沒有使用虛繼承時爲 0

};
if (vindex==0) adjustedthis = this + delta; 
else adjustedthis = *(this + vindex -1) + delta
CALL funcadr

如果使用了虛函數, 函數指針指向一塊兩個指令的代理(thunk), 它們決定了實際調用的函數. Borland 使用了一種優化: 如果它知道類只用了單繼承, 就可以推斷 delta 和 vindex 的值爲 0, 因此可以跳過這些計算. 需要注意的是, 它只跳過了計算, 並沒有改變數據結構.

許多其它編譯器也使用這種計算方法, 數據結構相差也不大.

// Metrowerks CodeWarrior 的實現稍有變化.

// 在多繼承被禁用的嵌入式 C++ 中, 這個結構也是相同的.

struct MetrowerksMFP {
   int delta;
   int vindex; // 沒有使用虛繼承時爲 -1

   CODEPTR func_address;
};

// 早期的 SunCC 顯然使用了另一種順序:

struct {
   int vindex; // 沒有虛函數時爲 0 

   CODEPTR func_address; // 使用虛函數時爲 0

   int delta;
};

Metrowerks 看起來沒有把這些計算內聯. 而是提供了一個短小的成員函數調用器 (member function invoker). 這使得代碼的大小會小一點, 但是讓他們的成員函數指針調用要慢一些.

Digital Mars C++ (原來叫 Zortech C++, 又曾叫 Symantec C++) 使用了不同的優化方式. 單繼承類的成員函數指針只是一個函數地址. 對於更復雜的繼承, 成員函數指針指向一個代理函數, 代理函數裏面先對 this 指針進行調整, 然後再調用實際的函數. 這些代理函數在每次多繼承類成員函數指針被調用時都會創建. 這是我所喜歡的簡潔實現方式.

struct DigitalMarsMFP { // 爲什麼其他人不這麼做呢?

   CODEPTR func_address;
};

當前版本的 GNU 編譯器使用了一種聰明且奇怪的優化方法. 我們已經看到, 使用虛繼承的時候必須查找虛函數表 (vtable) 來獲得計算 this 指針所需要的 voffset. 你那麼做的時候, 或許也想把函數指針放在虛函數表中. 他們這麼做了, 把 m_func_address 和m_vtable_index 組合在了一起. 然後他們利用函數指針必須指向一個地址而虛函數表序號 (vtable index) 總是奇數來區分它們.

// GNU g++ 使用了一種聰明的方法優化空間, IBM's VisualAge 和 XLC 也模仿了這種方法.

struct GnuMFP {
   union {
     CODEPTR funcadr; // 總是偶數

     int vtable_index_2; //  = vindex*2+1, 總是奇數

   };
   int delta;
};
adjustedthis = this + delta
if (funcadr & 1) CALL (* ( *delta + (vindex+1)/2) + 4)
else CALL funcadr

G++ 使用的方法在文檔中有詳細描述, 也已經被許多的編譯廠商所模仿, 包括 IBM's VisualAge 和 XLC 編譯器, 新版本的 Open64, Pathscale EKO, 以及 Metrowerks 的 64 位編譯器. 低版本的 GCC 也使用了那些常見的簡單結構. SGI 已經不再更新 MIPSPro 和 Pro64 編譯器了, 蘋果古老的 MrCpp 編譯器也使用這種方法. (Pro64 編譯器現在已經成爲開源的 Open64 編譯器了).

struct Pro64MFP {
     short delta;
     short vindex;
     union {
       CODEPTR funcadr; // 如果 vindex==-1

       short __delta2;
     } __funcadr_or_delta2;
   };
// vindex==0 代表空指針.

那些基於 Edison Design Group 前端的編譯器 (Comeau, Portland Group, Greenhills) 使用了一種近似的方法. 它們的計算方法如下 (PGI 32 位編譯器):

//使用 EDG 前端的編譯器 (Comeau, Portland Group, Greenhills, 等)

struct EdisonMFP{
    short delta;
    short vindex;
    union {
     CODEPTR funcadr; // vindex=0 時

     long vtordisp;   // vindex!=0 時

    };
};
if (vindex==0) {
   adjustedthis=this + delta;
   CALL funcadr;  
} else { 
   adjustedthis = this+delta + *(*(this+delta+vtordisp) + vindex*8);
   CALL *(*(this+delta+funcadr)+vindex*8 + 4); 
};

大多數嵌入式系統的編譯器不允許多繼承. 因此他們沒有這些問題: 一個成員函數指針就是一個有隱藏 'this' 參數的普通函數指針.

微軟 "最小類(Smallest For Class)" 方法的噁心之處

微軟的編譯器使用和 Borland 的優化方法類似. 他們能高效的處理單繼承. 和 Borland 不同的是, 他始終讓浪費的空間爲 0. 也就是說單繼承指針和普通函數指針大小一樣, 多繼承要大些, 虛繼承又更大. 這可以節省空間, 但和標準不兼容, 而且有些古怪的副作用.

首先, 在派生類和基類之間轉換成員函數指針會改變其大小! 所以, 轉換過程會造成信息丟失. 其次, 當成員函數指針在類定義之前時, 編譯器需要判斷該爲它分配多少空間. 但是, 它做不到, 因爲在看到類的定義之前它不知道它的繼承關係. 它只能靠猜, 如果在某個編譯單元中猜錯了, 而在另一個單元中猜對了, 程序運行的時候會莫名其妙的崩潰. 所以微軟爲他們的編譯器增加了一些保留字:__single_inheritance__multiple_inheritance, 和 __virtual_inheritance. 他們還增加了一個編譯器選項開關: /vmg, 這會通過填充 0 的方式讓所有的成員函數指針大小一樣. 這些處理方法非常噁心.

文檔中說 /vmg 選項和在每個類前都聲明 __virtual_inheritance 是一樣的. 但事實上並不是這樣, 對於未知繼承方式的類 (unknown_inheritance) 使用的結構會更大. 在使用前向聲明時產生的成員函數指針也是一樣. 他們不能使用__virtual_inheritance 指針, 因爲他們用了一種非常傻逼的優化方法. 下面是他們使用的算法:

// Microsoft 和 Intel 在不知類定義情況下使用的方式.

// Microsoft 在設置了 /vmg 選項後也這樣使用

// 在 VC1.5 - VC6 中, 這個結構已經被破壞了! 詳見下文. 

struct MicrosoftUnknownMFP{
   FunctionPointer m_func_address; // 安騰處理器 (Itanium) 是 64 位.

   int m_delta;
   int m_vtordisp;
   int m_vtable_index; // 沒有虛繼承時爲 0

};
 if (vindex=0) adjustedthis = this + delta
 else adjustedthis = this + delta + vtordisp + *(*(this + vtordisp) + vindex)
 CALL funcadr

虛繼承中, vtordisp 的值並沒有存儲在 __virtual_inheritance 指針中! 而是在調用函數時直接硬編碼到彙編裏面. 但是在處理未完成的類時, 需要知道這些, 所以他們最終使用了兩種類型的虛繼承指針. 一直到 VC7, 未知繼承 (unknown_inheritance) 的 Bug 已經多得無可救藥了. vtordisp 和 vindex 的值總是爲 0! 結果很恐怖: 從 VC4 到 VC6, /vmg 選項 (沒有 /vmm 和/vms 的情況下) 會導致錯誤的函數被調用! 非常難於跟蹤. 在 VC4 裏, IDE 設置 /vmg 選項的輸入框是被禁用的. 我猜微軟裏面的某些人應該知道這個 bug, 但是他們並沒有把它列出來. 他們最終在 VC7 中修復了這個問題.

Intel 的計算方法和 MSVC 一樣, 但是他們的 /vmg 選項作用完全不同 (它通常是不起效的 - 只對未知繼承(unknown_inheritance)有影響). 在他們編譯器的官方發佈聲明中提到, 並沒有完全支持虛繼承成員指針的轉換, 如果你試圖去轉換, 編譯器會發出警告, 編譯可能會停止, 也可能產生錯誤的代碼. 這是語言中非常灰暗的角落.

最後來看看 CodePlay. 老版本 Codeplay 的 VectorC 有與 VC6, GNU, Metrowerks 兼容的鏈接選項. 但是他們使用的方法只是微軟那種. 他們像我一樣進行了反編譯, 但是他們沒有檢測未知繼承 (unknown_inheritance), 即 vtordisp 的值. 他們計算時私自(錯誤的)假設 vtordisp=0, 因此在某些情況下(很難發現)這會調用到錯誤的函數. 但是 Codeplay 即將發佈的 VectorC 2.2.1 已經修復了這個問題. 現在的成員函數指針和 Microsoft, GNU 都是二進制兼容的. 在經過高度優化, 以及對兼容 C++ 標準的大量改進(模板偏特化等)後, 它現在已經成爲一個非常優秀的編譯器了.

我們學到了什麼?

理論上講, 所有的編譯器廠商都得徹底改變它們的技術來適應成員函數指針. 按常規, 這是不太可能的, 這會讓很多已有的代碼無法工作. MSDN 中有一篇微軟發佈的很老的文章解釋了 Visual C++ 在運行時的實現細節[JanGray]. 這篇文章是 Jan Gray 寫的, 他在 1990 年也曾寫過微軟C++對象模型(MS C++ object model). 雖然文章是 1994 年寫的, 但對現在仍然非常有用 - 除了修復一些小 bug, 微軟已經十五年沒有修改過這篇文章了. 同樣的, 除了把寄存器從 16 位替換成了 32 位外, 現在的 Borland 編譯器生成的代碼和我用過的最早的版本 (Borland C++ 3.0, (1990)) 生成的也沒什麼差別.

現在, 你對成員函數指針已經瞭解很多了. 那麼, 關鍵在哪裏呢? 我們已經看過了相關的規則. 雖然他們的實現各不相同, 但有些共同點很有用: 不管是什麼類, 有什麼參數, 彙編代碼都需要調用成員函數指針. 有些編譯器根據類的繼承關係來進行優化, 但是對還沒有定義好的類, 這些優化是不可能的. 這個事實可以用來實現委託.

委託

和成員函數指針不同, 不難找到委託的用處. 它可以用於你在 C 程序中使用函數指針的任何地方. 或許最重要的是, 可以用委託輕易的實現改進後的目標/觀察者模式[GoF, p. 293]. 觀察者模式在 GUI 代碼中很常見, 而且我發現在程序的核心部分也非常有效. 委託也可以讓策略和狀態模式實現得更加優雅.

有個情況需要說明下, 委託不僅比成員函數指針更有用, 而且要簡單得多! 因爲委託由 .NET 語言提供, 你可能認爲一個如此高層次的概念,實現它的彙編代碼會很複雜. 事實並不是這樣: 委託的調用是一個很底層的概念, 像普通函數調用一樣底層和高效. 一個 C++ 委託只需要包含一個 this 指針和普通函數指針. 你在構建委託的時候, 你需要提供函數和調用那個函數的 this 指針. 編譯器會在創建委託的時候而不是調用的時候調整 this 指針. 更棒的是, 某些編譯器可以在編譯的時候就完成所有的事情, 所以創建委託也不會有什麼複雜的操作. 在 x86 系統下調用委託的彙編代碼應該是這個樣子:

    mov ecx, [this]
    call [pfunc]

但是, 在標準的 C++ 中無法產生這樣高效的代碼. Borland 爲他們的 C++ 編譯器增加了一個關鍵字 (__closure) 來解決這個問題, 這可以用簡潔的語法來生成代碼. GNU 編譯器也使用了一種語言擴展, 但是和 Borland 不兼容. 如果你使用這些擴展, 你將會依賴於特定的廠商. 如果遵循標準, 仍然可以實現委託, 只是效率就會低一些.

有趣的是, 在 C# 和其他 .NET 語言中, 委託比函數調用 (MSDN) 要慢許多. 我估計是因爲垃圾收集機制和 .NET 的安全性造成的. 最近, 微軟在 Visual C++ 中增加了統一事件模型 (unified event model), 引入了關鍵字 __event__raise__hook,__unhookevent_source 和 event_receiver. 坦白講, 我覺得這些特性很可怕. 它們完全不符合標準, 語法醜陋, 看起來都不像 C++ 了, 而且產生的代碼效率也非常低.

動力: 對高效委託的迫切需求

使用 C++ 標準來實現的委託已經很多了. 他們都使用同樣的原理, 主要是利用成員函數指針來實現委託 -- 他們只有單繼承時才能運行. 爲了避免這個限制, 可以增加一個間接層: 使用模板來爲每一個類生成一個"成員函數調用器(member function invoker)". 這種委託保存着 this 指針和一個要調用的函數指針. 這個成員函數調用器需要在堆上進行分配.

使用這種方法的實現有許多, 在 CodeProject 也有好幾個. 他們在複雜性, 語法(尤其是和 C# 的近似程度), 以及架構上都不一樣. 其中最有影響力的是 boost::function. 最近, 它已經被下一版本的 C++ 標準[Sutter1]接受了. 希望它能被廣泛使用.

儘管這些實現很聰明, 但還是不夠讓人滿意. 他們提供了需要的功能, 並試圖掩蓋潛在的問題: 在語言底層缺乏相應的支持. 讓人沮喪的是, 在所有平臺上, 成員函數調用器的代碼對所有類都是相同的. 更重要的是, 它使用了堆, 對某些程序來說, 這是不能接受的.

我在其中一個工程中模擬了獨立的事件. 這個程序的核心是事件分發, 並對調用不同對象的成員函數進行了模擬. 大多數成員函數都很簡單: 他們只是更新對象的內部狀態, 有時在事件隊列中添加事件. 這是使用委託的最佳例子. 但是, 每一個委託都只會調用一次. 最初, 我使用了 boost::function, 但是我發現運行過程中爲委託分配的內存超過了整個程序內存的三分之一. 我要真正的委託! 爲不禁大喊, 它應該只有兩行彙編代碼!

我通常很難稱心如意, 但這次很幸運. 我現在的 C++ 代碼在多數情況下可以生成理想的彙編代碼. 最重要的是, 調用單目標的委託和普通函數調用是一樣快的. 這並沒用到什麼高深的東西, 只是有點遺憾, 在實現的時候有些東西不符合 C++ 標準的規範, 我使用了一些未公開的成員函數指針的知識. 如果你能小心點, 並且不介意使用一點點編譯器相關的代碼, 高效委託可以在所有編譯器上運行.

技巧: 把成員函數指針轉換成標準格式

我代碼的核心是一個類, 讓你可以把各種類指針和成員函數指針轉換成一個普通類指針和一個普通成員函數. C++ 並沒有普通成員函數 (generic member function) 的說法, 因此我使用一個未定義的 CGenericClass 類的成員函數來代替.

大多數編譯器對不同類的成員函數指針都使用相同的處理方式. 對這些, 直接使用 reinterpret_cast<> 來將成員函數指針轉換爲普通成員函數指針 (generic member function pointer) 就可以了. 實際上, 如果這樣不行, 那麼編譯器就不符合標準了. 對剩下的那些編譯器 (Microsoft Visual C++ 和 Intel C++), 我們需要先把多繼承, 虛繼承類的成員函數指針轉換爲單繼承類的成員函數指針. 這會用到一些靈異的, 可怕的手段. 注意這些的手段只對那些不兼容標準的編譯器纔是必須的, 而且可以得到非常不錯的獎勵: 我們得到了理想的代碼.

因爲我們知道編譯器內部怎麼存儲成員函數指針的, 而且瞭解怎麼調整 this 指針來調用函數, 我們可以在構造委託的時候自己來調整 this 指針. 單繼承的不需要調整; 多繼承只是一個簡單的加法; 虛繼承 ... 這就複雜了. 但是它在大多數時候都可以運行, 並且所有事情都是在編譯階段完成的.

我們怎麼區分不同的繼承類型呢? 官方沒有提供方法來判斷一個類是否是多繼承. 有個不太光彩的做法, 你看看我前面提供的那張表 -- 在 MSVC 裏, 不同繼承方式的成員函數指針大小是不一樣的. 因此, 我們可以使用基於成員函數指針大小的模板特化! 多繼承涉及到複雜的計算. 類似的, 未知繼承 (unknown_inheritance) (16 字節) 使用的計算方法有一點細微的差別.

對於微軟(以及 Intel)的, 醜陋的, 非標準的 12 字節 virtual_inheritance 指針, 需要玩另一個把戲, 這還是 John Dlugosz 的主意. 我們已經瞭解到, 微軟/Intel 成員函數指針的一個重要特性是, 不管其它成員的值是什麼, 它始終會調用 CODEPTR 成員. (對其他編譯器來說並不一定是這樣的, 比如, GCC 中調用虛函數時會從虛函數表中取得函數地址來調用.) Dlugosz 的方法是使用一個假的函數指針, 讓它的 codeptr 指向一個檢測函數, 這個檢測函數返回要使用的 'this' 指針. 當你調用這個函數時, 編譯器會利用內部的 vtordisp 值爲你計算好一切.

一旦你能將類指針和成員函數指針轉換爲標準形式, 實現單目標的委託就簡單了(雖然很麻煩). 你只需要爲不同數量的參數創建模板類就行了.

實現委託的這種非標準轉換方式帶來的另一個極大的好處就是你可以比較他們是否相等. 大多數現有的委託都不行, 這在一些特定的任務中就很難處理了, 比如實現多播委託[Sutter3].

靜態函數的委託

理論上講, 簡單的非成員函數, 或者靜態成員函數應該能作爲委託的目標. 這可以通過把靜態函數轉換爲成員函數來實現. 我想到有兩種方法可以實現, 這兩種方法的委託都指向一個稱作 "調用器 (invoker)" 的成員函數, 它在裏面調用靜態函數.

有一種邪惡的辦法. 你可以把函數指針存儲在存放 this 指針的位置, 在調用器(invoker)函數中, 只需要把 this 指針轉換成靜態函數指針並調用就行了. 這種做法對普通函數調用完全沒有影響. 問題在於這種方法需要在代碼指針與數據指針間進行轉換. 這在某些代碼指針比數據指針大的系統 (DOS 編譯器使用 medium 內存模型) 上就無法工作了. 據我所知, 這在所有 32 位和 64 位處理器上都可以工作. 但是這太邪惡了, 我們得找個更好的方法.

更安全的方法是把函數指針存儲在委託的一個額外成員中. 委託指向自己的成員函數. 但是, 當拷貝委託時, 這些自引用需要被轉換, 而且 = 和 == 操作符也變得複雜了. 這會讓委託增加 4 個字節大小, 也會增加代碼的複雜性, 但對調用的速度沒有影響.

我實現了這兩種方法, 因爲他們各有各的優點: 安全的方法保證可以工作, 邪惡的方法產生的彙編代碼和編譯器可能產生的一樣, 如果編譯器原生支持委託的話. 邪惡的方法可以通過 #define (FASTDELEGATE_USESTATICFUNCTIONHACK) 來啓用.

備註: 邪惡的那種方法爲什麼能運行呢? 如果你仔細檢查各種編譯器在調用成員函數指針時使用的算法, 你將發現對單繼承中非虛函數(即 delta=vtordisp=vindex=0), 所有編譯器都不會去計算該調用什麼函數. 所以, 即使傳入一個垃圾指針, 也會調用正確的函數. 在那個函數裏面, 接收到的 this 指針將是 garbage + delta = garbage. (換句話說, 傳進去的垃圾指針會原封不動的傳出來!) 基於這點, 我們可以把這個垃圾指針還原成函數指針. 這對於靜態函數調用器 (static function invoker) 是虛函數的情況就無效了.

代碼使用方法

源代碼中包含了高效委託 (FastDelegate) 的實現, 以及一個展示語法的 demo.cpp 文件. 要在 MSVC 上使用, 先創建一個空的控制檯應用程序, 然後把這兩個文件加入工程. 要在 GNU 上使用, 在命令行下輸入 "g++ demo.cpp" 即可.

高效委託可以在任意的參數組合下運行. 爲了在更多的編譯器上工作, 你需要在聲明委託的時候指明參數的個數. 預定義的參數最多八個, 要增加這個上限需要的代碼比較瑣碎. 委託使用了 fastdelegate 名字空間, 具體的實現在裏面嵌套的 detail 名字空間裏面.

Fastdelegate 可以通過構造函數或 bind() 方法來綁定成員函數或靜態函數(自由函數). 它們默認爲 0 (null). 他們也可以通過clear() 設置爲 null. 可以使用 ! 操作符或 empty() 來判斷是否爲 null.

和其它大多數委託的實現不一樣, Fastdelegate 提供了相等比較 (==!=) 操作符. 在內聯函數中也可以調用.

這裏摘錄了部分 FastDelegateDemo.cpp 的代碼, 它們展示了大部分可以使用的操作符. CBaseClass 是 CDerivedClass 的虛基類. 這些例子都很簡單, 只是爲了展示語法而已.

using namespace fastdelegate;

int main(void)
{
    // 委託支持 8 個參數上限.

    // 這是沒有參數的情況.

    // 我們聲明一個委託, 並與 SimpleVoidFunction() 綁定

    printf("-- FastDelegate demo --\nA no-parameter 
             delegate is declared using FastDelegate0\n\n");             
           
    FastDelegate0 noparameterdelegate(&SimpleVoidFunction);

    noparameterdelegate(); 
    // 調用委託 - 這會調用到 SimpleVoidFunction() 函數


    printf("\n-- Examples using two-parameter delegates (int, char *) --\n\n");

    typedef FastDelegate2<int, char *> MyDelegate;

    MyDelegate funclist[10]; // 委託都被初始化爲空

    CBaseClass a("Base A");
    CBaseClass b("Base B");
    CDerivedClass d;
    CDerivedClass c;
    
     // 綁定一個簡單的成員函數
    funclist[0].bind(&a, &CBaseClass::SimpleMemberFunction);
    
     // 也可以綁定一個靜態函數(自由函數)
    funclist[1].bind(&SimpleStaticFunction);
    
     // 以及靜態成員函數
    funclist[2].bind(&CBaseClass::StaticMemberFunction);
    
     // 和常量成員函數
    funclist[3].bind(&a, &CBaseClass::ConstMemberFunction);
    
     // 還有虛函數.
    funclist[4].bind(&b, &CBaseClass::SimpleVirtualFunction);

  // 你也可以使用 = 操作符. 

  // 對於靜態函數, 委託看起來就像普通函數指針.

    funclist[5] = &CBaseClass::StaticMemberFunction;

  // 繼承類的成員函數指針語法古怪, 應儘量避免.

  // 你也可以像 .bind() 一樣使用全局函數 MakeDelegate().

    funclist[6] = MakeDelegate(&d, &CBaseClass::SimpleVirtualFunction);
    
  // 最麻煩的是有非虛基類的虛派生類的抽象虛函數
  //   (an abstract virtual function of a virtually-derived class 
  //    with at least one non-virtual base class).
  
  // 這是非常極端的情況, 你在真實世界中應該很難遇到,
  // 但是作爲測試的一個極端例子, 這裏包含了這種情況.

    funclist[7].bind(&c, &CDerivedClass::TrickyVirtualFunction);
    
  // ...這種情況下, 你應該總是使用基類作爲接口.

  // 下面這行代碼使用了同一個函數.

    funclist[8].bind(&c, &COtherClass::TrickyVirtualFunction);

  // 你也可以使用構造函數直接綁定

    MyDelegate dg(&b, &CBaseClass::SimpleVirtualFunction);

    char *msg = "Looking for equal delegate";
    for (int i=0; i<10; i++) {
        printf("%d :", i);
        
        // 提供的 ==, !=, <=,<,>, 和 >= 操作符可以在內聯函數中使用

        if (funclist[i]==dg) { msg = "Found equal delegate"; };
        
        // 有好幾種方法可以檢測空指針

        // 你可以使用 if (funclist[i])

        // 或者          if (!funclist.empty())

        // 或者          if (funclist[i]!=0)

        // 或者          if (!!funclist[i])

        if (funclist[i]) {
        
            // 調用生成的高效彙編代碼.

            funclist[i](i, msg);
        } else { 
            printf("Delegate is empty\n");
        };
    }
};

返回值

1.3 版本的代碼增加了處理非 void 返回值類型的能力. 像 std::unary_function 一樣, 返回類型是最後一個參數(譯註: 指的是聲明時的模板參數). 默認爲 void, 這樣可以保持向後兼容, 而且意味着大多數時候還可以保持簡潔. 我想讓它在任何平臺上都有完整的功能. 除了 MSVC6, 其它編譯器都很好處理. VC6 有兩個重大限制:

  1. 你不能用 void 作爲默認模板參數.
  2. 你不能返回 void.

我使用了兩個手段來處理這種情況:

  1. 我創建了一個 DefaultVoid 的傀儡類. 需要的時候把它轉換成 void.
  2. 當需要返回 void 時, 返回 const void * 來代替. 這個返回值會放在 EAX 寄存器裏. 從編譯器的觀點來看, 沒有使用返回值時 void 函數和 void * 函數是毫無區別的. 最後需要明白, 想調用一個不產生無效代碼的函數來把 void 轉換成 void * 是不可能的. 但是, 如果你在構造委託的時候立即就把接收到的函數指針轉換掉, 所有事情都會在編譯期完成. 也就是說, 你需要轉換函數的定義, 而不是返回值本身.

還有一個會破環兼容性的修改: 所有使用 FastDelegate0 的地方必須改成 FastDelegate0<>. 這個可以通過在你的所有文件中使用全局查找替換來完成, 相信你不會在意. 我覺得這個修改可以讓語法更直觀: 所有 void 的 FastDelegate 聲明現在看起來更像函數聲明瞭, 除了 () 被替換成 <> 了. 如果這個修改還是讓你不爽, 你可以修改頭文件: 爲 FastDelegate0<> 在 newstyle 名字空間內定義一個包裝形式: typedef newstyle::FastDelegate0<> FastDelegate0;. 對 MakeDelegate 你也需要做同樣的事情.

用委託做函數參數

MakeDelegate 模板可以讓你使用 FastDelegate 做爲需要函數指針參數的地方. 一種典型的場景是把 FastDelegate 做爲類的私有成員, 然後使用一個修改函數來設置它.(就像微軟的 __event.) 例子如下:

// 接受任何原型爲: int func(double, double); 的函數

class A {
public:
    typedef FastDelegate2<double, double, int> FunctionA;
    void setFunction(FunctionA somefunc){ m_HiddenDelegate = somefunc; }
private:
    FunctionA m_HiddenDelegate; 
};

// 設置委託的語法是:

A a;
a.setFunction( MakeDelegate(&someClass, &someMember) ); // 成員函數或

a.setFunction( &somefreefunction ); // 靜態函數

原生語法和與 boost 的兼容性 (1.4 新增)

Jody Hagins 在最近的 Boost.Function 和 Boost.Signal 版本中擴充了 FastDelegateN 類, 提供了一種漂亮的語法. 在支持偏特化的編譯器中, 你可以寫 FastDelegate< int (char *, double)> 來代替 FastDelegate2<char *, doubleint>. 做得太漂亮了, Jody! 如果你的代碼需要在 VC6, VC7.0, 或 Borland 上編譯, 你只得使用舊的, 兼容的語法. 我做了些修改來保證新舊兩種語法 100% 等價, 可以互相交換.

Jody 還提供了一個輔助函數, bind, 可以讓爲 Boost.Function 和 Boost.Bind 寫的代碼直接轉換成 FastDelegate. 這讓你很快就可以看到如果切換到 FastDelegate 能提高多少性能. 這可以在 "FastDelegateBind.h" 中找到. 假如我們的代碼如下:

      using boost::bind;
      bind(&Foo:func, &foo, _1, _2);

如果你把 "using" 替換成 using fastdelegate::bind, 一切仍將照常運行. 警告: bind 的參數會被忽略! 沒有實際的綁定操作會執行. 只有在只使用 _1, _2, _3, 等基本佔位符參數時這些行爲才和 boost::bind 相同. 將來的版本可能會完全支持boost::bind.

比較操作符 (1.4 新增)

相同類型的 FastDelegate 現在可以使用 <><=>= 來進行比較了. 成員函數指針不支持這些操作符, 但是他們可以用memcmp() 做簡單的二進制比較. 比較的結果沒什麼意義, 也是編譯器相關的, 不過這可以讓他們存儲在像 std:set 這樣的有序容器中了.

DelegateMemento 類 (1.4 新增)

一個新類 DelegateMemento 被加入了, 它允許把不同類型的委託集合在一起. 每個 FastDelegate 類都增加了兩個額外的成員:

const DelegateMemento GetMemento() const;
void SetMemento(const DelegateMemento mem);

DelegegateMemento 可以被拷貝和比較 (==!=><>=<=), 可以被存儲在任何有序或無序的容器裏. 可以用來代替 C 中的指針聯合(union of function pointers in C). 作爲各種不同內容的聯合, 你有責任保證使用一致的類型. 舉例來說, 如果你從FastDelegate2 取得 DelegateMemento, 並存儲到 FastDelegate3 中, 你的程序可能就會在運行時崩潰. 將來我可能會加入一個調試模式, 並使用 typeid 來保證安全. DelegegateMemento 主要是給其他庫用的, 而不是給用戶使用的. 一種重要的用途就是窗口消息, 動態的 std::map<MESSAGE, DelegateMemento> 可以替換 MFC 和 WTL 中的靜態消息表. 不過, 那是另一個故事了.

隱式轉換成 bool (1.5 新增)

你現在可以使用 if (dg) {...} 這種語法 (其中 dg 是高效委託) 來代替 if (!dg.empty())if (dg!=0) 還有更醜陋的 if(!!dg) 了. 如果你正在使用以前的代碼, 你只需要知道它可以在所有編譯器上運行, 原來那些操作符也還可以使用.

它的實現比預想的要難. 僅僅提供 operator bool 是很危險的, 因爲它允許你這樣寫 int a = dg; 而你實際上想要的可能是 inta = dg();. 解決的辦法是使用安全布爾 (Safe Bool idiom)[Karlsson]: 提供一個向私有成員數據指針的轉換來代替 bool. 不幸的是, 安全布爾不支持 if (dg==0) 語法, 而且有些編譯器在實現成員數據指針 (咦, 該再寫一篇文章?) 時還有 bug, 因此, 我不得不又開始玩鬼把戲了. 有人曾使用過的方法是提供和整數的比較, 並且當整數不等於 0 時觸發 ASSERT. 我使用了一種更麻煩些的方法, 和函數指針進行比較. 和常數 0 比較大小是不支持的(但是和等於 null 的函數指針比較大小是有效的).

許可協議

文章相關的代碼使用公開的. 不管什麼目的, 你都可以使用它. 坦白的說, 寫文章的時間幾乎是寫代碼的十倍. 當然, 我很希望聽到有人用這些代碼寫出了偉大的軟件. 最後, 歡迎大家多提意見.

移植性

因爲使用的方法不符合標準, 我在許多編譯器上小心的做了測試. 可笑的是, 它比許多標準的代碼兼容性更好, 因爲很多編譯器不完全遵循標準. 知道的人多了以後, 它也更安全了. 主要的編譯器廠商和一些 C++ 標準委員會成員也知道了這裏提到的技術 (經常有編譯器的主要開發人員因爲這篇文章和我聯繫的). 編譯器廠商不太可能冒險去做讓以上代碼不能工作的修改. 舉例來說, 要支持微軟的第一個 64 位編譯器, 不需要做任何修改. Codeplay 甚至已經用 FastDelegates 作爲他們的 VectorC 編譯器的內部測試 (即使不夠準確, 也差不多了).

FastDelegate 的實現已經在 Windows, DOS, Solaris, BSD, 和好幾種 Linux 上測試過了, 使用過 x86, AMD64, Itanium, SPARC, MIPS, .NET 虛擬機, 和一些嵌入式系統處理器. 下面這些是測試成功的編譯器:

  • Microsoft Visual C++ 6.0, 7.0 (.NET), 7.1 (.NET 2003) 和 8.0 (2005) Beta (包括 /clr '託管 C++').
  • {測試了編譯和鏈接, 檢查了彙編代碼, 但是沒有運行} Microsoft 8.0 Beta 2 for Itanium and for AMD64.
  • GNU G++ 2.95, 3.0, 3.1, 3.2 以及 3.3 (Linux, Solaris, 以及 Windows (MingW, DevCpp, Bloodshed)).
  • Borland C++ Builder 5.5.1 和 6.1.
  • Digital Mars C++ 8.38 (x86, 包括 32-bit 和 16-bit, Windows 和所有內存模型的 DOS).
  • Intel C++ for Windows (x86) 8.0 和 8.1.
  • Metrowerks CodeWarrior for Windows 9.1 (包括 C++ 和 EC++ 模式).
  • CodePlay VectorC 2.2.1 (Windows, Playstation 2). 更早的版本不支持.
  • Portland Group PGI Workstation 5.2 for Linux, 32-bit.
  • {編譯了, 但是沒有鏈接和運行} Comeau C++ 4.3 (x86 NetBSD).
  • {編譯鏈接了, 檢查過彙編代碼, 但是沒有運行} Intel C++ 8.0 and 8.1 for Itanium, Intel C++ 8.1 for EM64T/AMD64.

下面是我知道的其它還在使用的編譯器的情況:

  • Open Watcom WCL: 在加入了成員函數模版後的編譯器版本中可用. 核心代碼可以在 (成員函數指針間的轉換) WCL 1.2 上運行.
  • LVMM: 核心代碼可以運行, 但是目前編譯 bug 太多.
  • IBM Visual Age and XLC: 應該可以運行, 因爲 IBM 聲稱它和 GCC 100% 的二進制兼容.
  • Pathscale EKO: 應該可以運行, 它也和 GCC 二進制兼容.
  • 所有使用 EDG 前端的編譯器 (GreenHills, Apogee, WindRiver, 等等.) 也應該可以運行.
  • Paradigm C++: 未知, 看起來只是 Borland 早期編譯器的一個包裝.
  • Sun C++: 未知.
  • Compaq CXX: 未知.
  • HP aCC: 未知.

仍然還有人在抱怨代碼不夠兼容! (唉).

總結

從解釋幾行代碼開始, 我已經寫了幾乎一個教程了. 目前我還沒發現在流行的那六個編譯器上有 bug 或者不兼容的情況. 爲了這兩行彙編代碼的工作還真多!

我希望我已經解釋清楚了成員函數指針和委託中的灰色地帶. 我們已經看到了由於各種編譯器不同的實現所帶來的成員函數指針的古怪行爲. 相反的, 我們也看到委託並不是什麼複雜的高級概念, 它實際上非常簡單. 我希望你已經相信它應該是語言的一部分. 有理由相信, 委託將會被編譯器直接支持, 當 C++0x 標準發佈時將被加入到 C++ 語言中 (去遊說標準委員會吧!).

據我所知, 還沒有哪個委託的實現比我這個 FastDelegates 更高效或很簡單. 說了你別笑話, 大部分代碼還是我在哄小女兒睡覺都時用一隻手寫的. 希望它對你有用.

參考資料

  • [GoF] 設計模式("Design Patterns: Elements of Reusable Object-Oriented Software", E. Gamma, R. Helm, R. Johnson, and J. Vlissides).

我在研究這個問題時參考了許多網站, 下面是裏面比較有趣的:

  • [Boost]. 委託可以通過 boost::function 和 boost::bind 的組合來實現. Boost::signals 是最好的事件/消息 (event/messaging) 系統之一. boost 庫大多數都要求和標準非常兼容的編譯器.
  • [Loki]. Loki 提供的 'functors' 就是綁定參數的委託. 他們和 boost::function 很相似. 看起來 Loki 最終會和 boost 合併.
  • [Qt]. Qt 庫包含信號/插槽 (Signal/Slot) 機制 (即委託). 要讓他工作, 你需要在編譯前在你的代碼上運行一個特殊的處理程序. 性能很低, 但是可以在對模版支持很差的編譯器上運行.
  • [Libsigc++]. 一個基於 Qt 的事件系統. 它避免了 Qt 需要特殊處理程序的麻煩, 但是要求所有的目標都繼承一個基類 (使用虛繼承 -- 靠!).
  • [JanGray] MSDN 文章 "Under the Hood", 描述了 Microsoft C/C++ 7 的對象模型. 對隨後版本的編譯器也適用.
  • [Hickey]. 一種古老的委託實現, 避免了內存分配. 需要保證所有的成員函數指針大小相同, 所有不能在 MSVC 運行. 這裏有一些關於這份代碼的有用的討論.
  • [Haendal]. 專注於函數指針的網站?! 但是沒有多少關於成員函數指針的細節.
  • [Karlsson]. 安全布爾 (Safe Bool Idiom).
  • [Meyers]. Scott Meyer 的關於重載 operator ->* 的文章. 注意經典的智能指針實現 (Loki and boost) 並不麻煩.
  • [Sutter1]. 函數指針的: 關於 boost::function 應該怎麼加入 C++ 標準的討論.
  • [Sutter2]. 使用 std::tr1::function 的觀察者模式 (需要多播委託). 關於 boost::function 侷限的討論, 想讓它提供== 操作符.
  • [Sutter3]. Herb Sutter 的 Guru of the Week, 關於回調的文章.
  • [Dlugosz]. 最近的一份委託/閉包實現, 像我的一樣, 非常高效, 但是隻支持 MSVC7 和 7.1.
發佈了2 篇原創文章 · 獲贊 40 · 訪問量 31萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章