函數指標的進化論 (上)
作者:蔡學鏞
2003 年 10 月
摘要
函數指標 (function pointer) 是傳統 C 語言中少數的動態機制,但是近來許多語言都不再支援函數指標 (包括 Java 和 C#),而改用其他機制來代替。本文章簡單扼要地說明,多型 (polymorphism)、反映 (reflection)、委託 (delegate) 如何取代函數指標。
函數指標 (function pointer) 是一種「指向函數的指標」,和一般指向資料的指標不同。凡是研究過許多系統原始碼 (例如:Linux Kernel、Borland OWL) 的人,對於函數指標應該都不陌生,因爲多數低階系統都使用 C 語言撰寫,而函數指標是傳統C語言中少數的動態機制,有許多不可取代的地方,所以這些 C 原始碼中到處可見函數指標。
透過一個實際的範例,來瞭解何謂函數指標:
// FncPtr.cpp #includeusing std::cin; using std::cout; using std::endl; // 聲明 Fnc1(), Fun2(), Twice() float Fnc1(int); float Fnc2(int); double Twice(float (*)(int), int); // 主程式 int main() { int A = 3; int B = 5; count << "Twice(Fnc1, A)的值為: " << Twice(Fnc1, A) << endl; count << "Twice(Fnc2, B)的值為: " << Twice(Fnc2, B) << endl; } float Fnc1(int N) { return float (N*N); } float Fnc2(int N) { return float (N*N*N); } double Twice(float (*pF)(int), int N) { return 2.0 * double(pF(N)); }
執行結果:
Twice(Fnc1, A)的值為:18 Twice(Fnc2, B)的值為:250
此例中,pF 即為函數指標,而函數名稱本身 (Fun1 與 Fun2) 是常數的函數指標。通過函數指標,函數被資料化了 (變成指標),如此一來函數也可以被傳遞、被紀錄,所以 Fnc1 與 Fnc2 可以被當成參數,傳進 Twice() 中。
一旦函數可以被傳遞、被紀錄,這開啓了許多可能性,産生許多有趣的應用,特別是下列三者:
- 多型 (polymorphism):稍後再說明。
- 多緒 (multithreading):將函數指標傳進負責建立多緒的 API 中:例如 Win32 的 CreateThread(...pF...)。
- 回呼 (call-back):所謂的回呼機制就是:「當發生某事件時,自動呼叫某段程式碼」,Charles Petzold 稱此爲「Don’t Call Me, I'll Call You」。事件驅動 (event-driven) 的系統經常透過函數指標來實現回呼機制,例如 Win32 的 WinProc 其實就是一種回呼,用來處理視窗的訊息。
函數指標的致命缺點是:無法對參數 (parameter) 和返回值 (return value) 的型態進行檢查,因為函數已經退化成指標,指標是不帶有這些型態資訊的。少了型態檢查,當參數或返回值不一致時,會造成嚴重的錯誤。編譯器和虛擬機器 (VM) 並不會幫我們找出函數指標這樣的致命錯誤。所以,許多新的程式語言不支援函數指標,而改用其他方式。
多型
多型的實現方式很複雜,大致上是編譯器或 VM 在資料結構內加入一個資料指標,此指標通常稱爲 vptr,是 Virtual Table Pointer 的意思。vptr 指向一個 Virtual Table,此 Virtual Table 是一個陣列 (array),由許多函數指標所組成,每個函數指標各自指向一個函數的地址。如圖 1 所示。
![圖 1](http://www.microsoft.com/taiwan/msdn/columns/DoNet/images/FP_1_1.gif)
圖 1
不管是 C++ 編譯器、或是 Java VM、或是 .NET CLR,內部都是以此方式來實現多型。儘管如此,這只能算是 black magic,對於 C++、Java 與 .NET 語言來說,函數指標「並未因此」和語言本身有直接相關。換句話說,C++ 和 Java 與 .NET 語言,就算語法本身不支援函數指標,照樣也能實現多型。事實上,C++ 固然支援函數指標,但不是爲了多型的關係,而是爲了和 C 相容 (畢竟 C++ 是 C 的 superset);IL Asm (.NET 上的組合語言) 固然支援函數指標,但由於安全的理由,使用上受到相當大的限制,且不是爲了多型的關係。至於 Java 與 C# 則都不支援函數指標。
沒錯,Java 與 C# 都不支援函數指標。雖然剛剛解釋過,這不會影響對於多型的支援,但是這會不會影響對於多緒 (multithreading) 與回呼 (call-back) 機制的支援呢?答案是:不會!因為 Java 可以利用多型或反映 (reflection) 來實現多緒與回呼,而 C#可 以利用多型或反映或委託 (delegate) 來實現多緒與回呼。
反映
顧名思義,反映 (reflection) 機制就像是在吳承恩所著的西遊記中所提及的「照妖鏡」,可以讓類別或物件 (object) 在執行時期「現出原形」。我們可以利用反映機制來深入瞭解某類別 (class) 的建構子 (constructor)、方法 (method)、欄位 (field),甚至可以改變欄位的值、呼叫方法、建立新的物件。有了反映機制,程式員即使對所欲使用的類別所知不多,也能照樣寫程式。反映機制能夠用來呼叫方法,這正是反映機制能夠取代函數指標的原因。
以 Java 來說,java.lang.reflect.Method (以下簡稱 Method) 類別是用來表示某類別的某方法。我們可以透過 java.lang.Class (以下簡稱 Class) 類別的許多方法來取得 Method 物件。Method 類別提供 invoke() 方法,透過 invoke(),此 Method 物件所表示的方法可以被呼叫,所有的參數則是被組織成一個陣列,以方便傳入 invoke()。
舉個例子,下面是一個名為 Invoke 的程式,它會將命令列的 Java 類別名稱和要呼叫的方法名稱作為參數。為了簡單起見,我假定此方法是靜態的,且沒有參數:
import java.lang.reflect.*; class Invoke { public static void main(String[] args ) { try { Class c = Class.forName( args[0] ); Method m = c.getMethod( args[1], new Class [] { } ); Object ret = m.invoke( null, null ); System.out.println(args[0] + "." + args[1] +"() = " + ret ); } catch ( ClassNotFoundException ex ) { System.out.println("找不到此類別"); } catch (NoSuchMethodException ex ) { System.out.println("此方法不存在"); } catch (IllegalAccessException ex ) { System.out.println("沒有權限調用此方法"); } catch (InvocationTargetException ex ) { System.out.println("調用此方法時發生下列例外:\n" + ex.getTargetException() ); } } }
我們可以執行 Invoke 來取得系統的時間:
java Invoke java.lang.System CurrentTimeMillis
執行的結果如下所示:
java.lang.System.currentTimeMillis() = 1049551169474
我們的第一步就是用名稱去尋找指定的 Class。我們用類別名稱 (命令列的第一個參數) 去呼叫 forName() 方法,然後用方法名稱 (命令列的第二個參數) 去取得方法。getMethod() 方法有兩個參數:第一個是方法名稱 (命令列的第二個參數),第二個是 Class 物件的陣列,這個陣例指明了方法的 signature (任何方法都可能會被多載,所以必須指定 signature 來分辨。) 因為我們的簡單程式只呼叫沒有參數的方法,我們建立一個 Class 物件的匿名空陣列。如果我們想要呼叫有參數的方法,我們可以傳遞一個類別陣列,陣列的內容是各個類別的型態,依順序排列。
一旦我們有了 Method 物件,就呼叫它的 invoke() 方法,這會造成我們的目標方法被調用,並且將結果以 Object 物件傳回。如果要對此物件做其他額外的事,你必須將它轉型為更精確的型態。
invoke() 方法的第一個參數就是我們想要呼叫目標方法的物件,如果該方法是靜態的,就沒有物件,所以我們把第一個參數設為 null,這就是我們範例中的情形。第二個參數是要傳給目標方法作為參數的物件陣列,它們的型態要符合呼叫 getMethod() 方法中所指定的型態。因為我們呼叫的方法沒有參數,所以我們傳遞 null 作為 invoke() 的第二個參數。
以上是 Java 的例子,事實上,.NET 的反映機制也相去不遠,不再贅述。反映機制是最動態的機制,比多型的功能更強大。然而,反映的速度比多型慢許多 (而且多型又比函數指標稍慢),所以若非必要,應該少用反映機制。事實上,不管是 Java API 或 .NET Framework,都不使用反映機制來實現回呼與多緒。
Java 的多緒
Java沒有函數指標(為了系統安全),也不用反映機制來處理多緒(一方面為了效率,二方面反映機制是在JDK1.1才開始支援),而是使用多型的機制來處理多緒,作法如下:(另一種作法是實作java.lang.Runnable介面,與下面的作法雷同,不另說明。)
將執行緒的程式寫在下面的run()方法中:
class MyThread extends java.lang.Thread { public void run() { // ... } }
再利用下面的方式來啟動此執行緒:
MyThread thread = new MyThread(); thread.start();
start() 方法定義在 java.lang.Thread 類別內,start() 方法會請作業系統建立一個執行緒,再呼叫 run(),此時調用的並非在 java.lang.Thread 內定義的 run() (它是空的),而是利用多型機制,調用到 MyThread 內定義的 run()。
Java的回呼
通常回呼機制都是使用 publisher/subscriber (出版者/訂閱者) 的方式,必須先向系統註冊:
- 何事件:我對何種事件感興趣
- 何函數:當事件發生時,請呼叫我的函數,以爲通知。此函數即爲回呼函數 (call-back function)。
Java 也是使用類似的作法,差別在於 Java 無法利用函式指標,且採用物件導向的作法。Java 將出版者 (publisher) 稱爲事件來源 (event source),將訂閱者 (subscriber) 稱爲事件傾聽者 (event listener)。大致的作法如下:
- 向事件來源註冊 (registry) 事件傾聽者
- 該事件發生時,事件來源通知 (notify) 事件傾聽者
事件來源提供名爲 addXxxListener() 的方法來讓事件傾聽者註冊之用,此方法需要傳入事件傾聽者當參數。至於是何種事件,則由 Xxx 以爲識別。例如:addMouseListener() 表示註冊「滑鼠事件」的事件傾聽者。
利用 addXxxListener(),事件來源就可以將事件傾聽者記錄在欄位 (field) 中。當事件發生時,事件來源就可以從欄位中知道該通知和物件。可是應該呼叫該物件的那個方法呢?如果該物件沒有提供該方法呢?
想要解決此問題,事件來源就必須過濾註冊的對象,addXxxListener() 所需要的參數不可以是籠統的 java.lang.Object,而必須是一個實作 XxxListener 介面 (interface) 的物件。只要在 XxxListener 介面內宣告一個比方說 XxxEventHappened(),那麽任何實作 XxxListener 的物件,都必定有實現 XxxEventHappened(),所以事件來源就可以在事件發生時,呼叫事件傾聽者的 XxxEventHappened()。這正是依靠多型機制才能達成。
事件來源通知事件傾聽者時,往往需要夾帶一些額外的訊息,例如:事件來源是誰、事件發生於何時、事件發生的原因為何…。這些訊息被封裝成事件物件,當作參數傳給 XxxEventHappened()。
Java AWT/Swing 規定,所有的事件都必須繼承自 java.util.EventObject 類別;所有的事件傾聽者都必須實現 java.util.EventListener 介面。圖二是 Java AWT 的事件繼承階層圖:
![圖 2](http://www.microsoft.com/taiwan/msdn/columns/DoNet/images/FP_1_2.gif)
圖 2
例如,我要向一個名為 jButton1 的 javax.swing.JButton 物件註冊,成為它的事件傾聽者,那麼就必須實作 java.awt.event.ActionListener 介面,提供 actionPerformed() 方法,如下所示:
class MyActionListener implements ActionListener { public void actionPerformed(ActionEvent evt) { // ... } }
註冊的方式如下:
MyActionListener mal = new MyActionListener(); jButton1.addActionListener(mal);
使用多型的機制來實現多緒和回呼,不但麻煩 (必須繼承),也不能使用靜態方法,因為靜態方法本來就沒有多型的機制 (static 一定是 non-virtual)。順便一提,前面所提到的反映機制,可以支援靜態方法 (static method)。
未完待續 ...
此文章分成兩篇。在本篇中,你可以瞭解 Java 如何利用多型的機制來取代函數指標,在下篇中,你將會體驗到,.NET 是如何利用 delegate 來徹底地取代函數指針,以提供比 Java 更好的解決方式。