(转自MSDN,繁转简:sunheart)
大内高手专栏:
函数指针的进化论
作者:蔡学镛
2003 年 10 月
摘要
函数指针 (function pointer) 是传统 C 语言中少数的动态机制,但是近来许多语言都不再支持函数指针 (包括 Java 和 C#),而改用其它机制来代替。本文章简单扼要地说明,多型 (polymorphism)、反射(reflection)、委托 (delegate) 如何取代函数指针。
函数指针 (function pointer) 是一种「指向函数的指针」,和一般指向数据的指针不同。凡是研究过许多系统原始码 (例如:Linux Kernel、Borland OWL) 的人,对于函数指针应该都不陌生,因为多数低阶系统都使用 C 语言撰写,而函数指针是传统C语言中少数的动态机制,有许多不可取代的地方,所以这些 C 原始码中到处可见函数指针。
透过一个实际的范例,来了解何谓函数指针:
// FncPtr.cpp
#include
using 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
不管是 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
例如,我要向一个名为 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 更好的解决方式。
Delegate
C# 也支持多型与反射,但是 C# 却是使用 delegate 来实现多线程和回调 (而不使用多型与反射)。delegate 是函数指针的改良品种。delegate 的效率应该比多型稍差,但是用起来更方便,且允许使用静态方法。
C# 编译器对 delegate 以及 event 提供了大量的语法甜头 (syntactic sugar),这些语法甜头并不符合一般观念中的程序语法,所以往往让许多初学者丈二金刚摸不着头脑。后面会陆续揭露 C# 编译器的这些内幕。
C# 不支持函数指针,所以不能使用下面的语法:
void (*pFnc)(int, double);
必须改用下面的语法来宣告 delegate:
delegate void MyDelegate(int p1, double p2);
而上面的语法,等于下面的效果:
//版本一
class MyDelegate : System.MulticastDelegate {
public MyDelegate(Object target, System.IntPtr)
{ ... }
public void virtual Invoke(int p1, double p2)
{ ... }
public virtual IasyncResult BeginInvoke(...)
{ ... }
public virtual void EndInvoke(...)
{ ... }
}
其实,也可以是:
//版本二
class MyDelegate : System.MulticastDelegate {
public MyDelegate(Object target, System.IntPtr)
{ ... } // IntPtr 和 pointer 无关,是 native int 的意思
public void virtual Invoke(int p1, double p2)
{ ... }
}
为了简单起见,我们只讨论版本二。请注意,不管版本一与版本二,都无法编译成功,因为 C# 语言规定:只有 C# 编译器可以直接制造出继承自 MulticastDelegate 的类别,编程员不可以在 C# 原始码中定义 MulticastDelegate 的派生类别。换句话说,这样的语法甜头是强制的,非用不可,别无选择。
补充说明,MulticastDelegate 继承自 Delegate。微软原本的意思是让 Delegate 的派生类别只能包装一个方法,MulticastDelegate 的派生类别可以包装多个方法。但是后来发现这样的设计有相当多缺点,所以干脆让所有的 delegate 都继承自 MulticastDelegate。由于这样重大的设计变更来得太晚,所以微软不敢全面调整 .NET Framework,怕会因此出现 bug,所以没有更动原先的链接库,只有更动编译器和文件。读者可能会认为,为何不用 .NET 特有的 side-by-side execution 方式 (用来解决 DLL Hell),同时执行两个不同版本的 dll?我认为,问题之一出在 MulticastDelegate 与 Delegate 是属于 mscorlib.dll,这是绝对不能使用 side-by-side execution 的 dll。目前 (1.0 与 1.1) 虽然 MulticastDelegate 与 Delegate 都还存在,但是在未来的 .NET 版本可就难说了。
编译器帮我们产生的建构子需要两个参数,第一个是方法所属的对象,第二个是方法在「Method」metadata table 中的位置。编程员当然不会知道这个位置是几号 (但是编译器知道),所以编程员无法直接使用此构造方法。事实上,产生 delegate 对象的过程中充满离奇,有许多语法甜头,下面会一一解释。
你可以用下面的方式,来产生一个非静态方法的 delegate:
new MyDelegate(myObject.MyNonStaticMethod);
编译器会自动调用 MyDelegate 构造方法,第一个参数是 myObject,第二个参数是 MyNonStaticMethod 方法在「Method」metadata table中的位置。
你也可以用下面的方式,来产生一个静态方法的 delegate:
new MyDelegate(MyClass.MyStaticMethod);
编译器会自动调用 MyDelegate 构造方法,第一个参数是 null,第二个参数是 MyStaticMethod 方法在 「Method」metadata table中的位置。
注意:不管是不是 static 方法,都必须符合 MyDelegate 的 signature (参数和返回值的型态),否则编译会失败。
下面有更怪的例子:
MyDelegate md = null;
md += new MyDelegate(MyClass.MyStaticMethod);
第一次看到这样的程序代码,许多人都会吓了一跳:md 是 null,怎么可以使用 +=?这会不会导致 System.NullReferenceException?事实上,这样的写法,编译之后会变成:
MyDelegate md = null;
md = System.Delegate.Combine(md, new MyDelegate(MyClass.MyStaticMethod));
Combine() 是 System.Delegate 所提供的静态方法,目的在于将第二个 Delegate 结合到第一个 Delegate 中,传回此一新的 Delegate;如果第一个 Delegate 为 null,则直接传回第二个 Delegate。
类似地,下面的程序:
md -= new MyDelegate(MyClass.MyStaticMethod);
编译之后会变成:
md = System.Delegate.Remove(md, new MyDelegate(MyClass.MyStaticMethod));
Remove() 是 System.Delegate 所提供的静态方法,目的在于将第二个 Delegate 自第一个 Delegate 中移除,并传回此一新的 Delegate。
稍早提到下面的定义:
delegate void MyDelegate(int p1, double p2);
会造成编译器会自动产生下面的定义。
class MyDelegate : System.MulticastDelegate {
public MyDelegate(Object target, System.IntPtr)
{ ... } // IntPtr 和 pointer 无关,是 native int 的意思
public void virtual Invoke(int p1, double p2)
{ ... }
}
现在我们把焦点集中在 Invoke() 上,此方法的参数和返回值型态一定会和 delegate 相同,以此例来说,方法参数必须是 int,double,而传出值必须是 void。
如何调用 delegate?相当简单,请看下面的例子:
MyDelegate d = new MyDelegate(MyClass.MyStaticMethod);
d(1, 3.4);
delegate 其实还有许多有趣的主题,包括 System.Reflection.RuntimeMethodInfo 类别做了哪些事 (这个类别是 Undocumented,.NET 1.0 文件中没有说明)、多个 delegate 如何串接、delegate 如何和反射机制合作......等,因为篇幅有限,我都不在本文章说明,请感兴趣的读者自行研究这些主题。
C# 的多线程
传统的多线程使用函数指针当参数,C# 利用 delegate 来取代函数指针,所以当然也将 delegate 用在多线程上。下面是一个 C# 多线程的例子:
using System;
using System.Threading;
class SimpleThreadApp {
public static void WorkerThreadMethod() {
// ...
}
public static void
Main
() {
ThreadStart woker = new ThreadStart(WorkerThreadMethod);
Thread t = new Thread(worker);
t.start();
}
}
ThreadStart 是一个 delegate,由 System.Threading 所提供。这个程序应该不难理解,所以我不再解释。
C# 的回调
对于 C# 来说,事件来源可以使用下面的方式来定义:
public class YourButton {
public YourDelegate Click;
// ...
}
这么一来,外面的程序如果想要注册,用法如下:
yourButton.Click += new YourDelegate(MyClass.MyStaticMethod);
在 YourButton 类别定义「内」,如果想通知所有的事件倾听者,只要用下面的程序代码即可:
Click();
糟糕的是,连在 YourButton 类别定义「外」,也可以使用下面的方式,来产生通知,这样子会违反对象导向的封装精神。
yourButton.Click();
所以显然我们应该将 YourButton 内的 Click 由 public 改成 private:
public class YourButton {
private YourDelegate Click;
// ...
}
但是这样却造成外面的程序无法向 YourButton 注册,所以我们再将程序改成下面的模样:
//作法一
public class YourButton {
private YourDelegate Click;
public void add_Click(YourDelegate d) {
Click += d;
}
public void remove_Click(YourDelegate d) {
Click -= d;
}
// ...
}
几乎大家都有这样的需求,所以 C# 编译器于是又提供了一个语法甜头 (利用 event 关键词),只要写出下面 (作法二) 的程序,编译之后的结果就和上面 (作法一) 一样:
//作法二
public class YourButton {
public event YourDelegate Click;
// ...
}
或者你想要自行提供 add 和 remove 内的程序代码也成 (可能是为了提供 side-effect 程序代码),如下所示 (有点类似 property 的语法):
//作法三
public class YourButton {
private YourDelegate _Click;
public event YourDelegate Click {
add {
// .. side-effect code here, if any
Click += value;
// .. side-effect code here, if any
}
remove {
// .. side-effect code here, if any
Click -= value;
// .. side-effect code here, if any
}
}
// ...
}
为何用作法三,不用作法一,因为作法三有使用 event 关键词,只要有使用 event 关键词 (包括作法二),就会使得编译器将它记录在「Event」Metadata Table 内。有没有纪录这个对于执行时的毫无影响,但是可以帮助编译器等工具软件判读,来简化原始码。例如,使用作法一,无法用下面的方式来注册以及取消注册。
yourButton.Click += new YourDelegate(MyClass.MyStaticMethod);
yourButton.Click -= new YourDelegate(MyClass.MyStaticMethod);
但是,使用作法二和三,则可以用这种方式来注册以及取消注册。因为编译器从「Event」Metadata Table 内发现 Click 是 event,所以只要程序中使用 +=,则自动编译成 add_Click();使用 -=,则自动编译成 remove_Click()。
结论
函数指针、多型、反映、delegate,彼此之间互有关连,也各有优缺点。从函数指针演化到 delegate 的这段过程中,我对于这些机制设计者的巧思益发感到敬佩。