函数指针的进化论 (大内高手专栏-蔡学镛)

(转自MSDN,繁转简:sunheart)

大内高手专栏:

函数指针的进化论

作者:蔡学镛

2003 10

 

摘要

函数指针 (function pointer) 是传统 C 语言中少数的动态机制,但是近来许多语言都不再支持函数指针 (包括 Java C#),而改用其它机制来代替。本文章简单扼要地说明,多型 (polymorphism)、反射(reflection)、委托 (delegate) 如何取代函数指针。

函数指针 (function pointer) 是一种「指向函数的指针」,和一般指向数据的指针不同。凡是研究过许多系统原始码 (例如:Linux KernelBorland 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 版本可就难说了。

编译器帮我们产生的建构子需要两个参数,第一个是方法所属的对象,第二个是方法在「Methodmetadata table 中的位置。编程员当然不会知道这个位置是几号 (但是编译器知道),所以编程员无法直接使用此构造方法。事实上,产生 delegate 对象的过程中充满离奇,有许多语法甜头,下面会一一解释。

你可以用下面的方式,来产生一个非静态方法的 delegate

new MyDelegate(myObject.MyNonStaticMethod);
   
   

编译器会自动调用 MyDelegate 构造方法,第一个参数是 myObject,第二个参数是 MyNonStaticMethod 方法在「Methodmetadata table中的位置。

你也可以用下面的方式,来产生一个静态方法的 delegate

new MyDelegate(MyClass.MyStaticMethod);
   
   

编译器会自动调用 MyDelegate 构造方法,第一个参数是 null,第二个参数是 MyStaticMethod 方法在 Methodmetadata 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 相同,以此例来说,方法参数必须是 intdouble,而传出值必须是 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 关键词 (包括作法二),就会使得编译器将它记录在「EventMetadata Table 内。有没有纪录这个对于执行时的毫无影响,但是可以帮助编译器等工具软件判读,来简化原始码。例如,使用作法一,无法用下面的方式来注册以及取消注册。

yourButton.Click += new YourDelegate(MyClass.MyStaticMethod);
   
   
yourButton.Click -= new YourDelegate(MyClass.MyStaticMethod);
   
   

但是,使用作法二和三,则可以用这种方式来注册以及取消注册。因为编译器从「EventMetadata Table 内发现 Click event,所以只要程序中使用 +=,则自动编译成 add_Click();使用 -=,则自动编译成 remove_Click()

结论

函数指针、多型、反映、delegate,彼此之间互有关连,也各有优缺点。从函数指针演化到 delegate 的这段过程中,我对于这些机制设计者的巧思益发感到敬佩。

 

 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值