作者:Stanley B. Lippman 来自 方舟 Blog
[译序:这是一篇古老的文章。但毫无疑问,Lippman对delegate的阐述是精辟的。]
如果你想拿 C# 与其它“C家族”的语言做比较,C# 正有个不同寻常的特性,其在 C++ 或者 Java 里没有真正意义上的对应之物。
--------------------------------------------------------------------------------
C# 是一个颇具争议的新兴语言,由 Microsoft 开发创造,以作为其 Visual Studio.NET 的基石,目前正处于第一个 Beta 版的发布阶段。C# 结合了源自 C++ 和 Java 的许多特性。Java 社群对 C# 主要的批评在于,其声称 C# 只是一个蹩脚的 Java 克隆版本 ——与其说它是语言创新的成果,倒不如说是一桩诉讼的结果。而在 C++ 社群里,主要的批评(也同时针对 Java)是,C# 只不过是另一个泛吹滥捧的私有语言(yet another over-hyped proprietary language)。
本文意在展示一种 C# 的语言特性,而在 C++ 或 Java 中都没有直接支持类似的特性。这就是 C# 的 delegate 型别,其运作近似于一种指向成员函数的指针。我认为,C# delegate 型别是经过深思熟虑的创新型语言特性,C++ 程序员(无论其对 C# 或者 Microsoft 有何想法)应该会对这个特性产生特殊的兴趣。
为了激发讨论,我将围绕一个 testHarness class 的设计来进行阐述。这个 testHarness class 能够让任何类别对 static 或 non-static 的 class methods 进行注册,以便后续予以执行。Delegate 型别正是实现 testHarness class 的核心。
C# 的 Delegate Type
Delegate 是一种函数指针,但与普通的函数指针相比,区别主要有三:
1) 一个 delegate object 一次可以搭载多个方法(methods)[译注1],而不是一次一个。当我们唤起一个搭载了多个方法(methods)的 delegate,所有方法以其“被搭载到 delegate object 的顺序”被依次唤起——稍候我们就来看看如何这样做。
2) 一个 delegate object 所搭载的方法(methods)并不需要属于同一个类别。一个 delegate object 所搭载的所有方法(methods)必须具有相同的原型和形式。然而,这些方法(methods)可以即有 static 也有 non-static,可以由一个或多个不同类别的成员组成。
3) 一个 delegate type 的声明在本质上是创建了一个新的 subtype instance,该 subtype 派生自 .NET library framework 的 abstract base classes Delegate 或 MulticastDelegate,它们提供一组 public methods 用以询访 delegate object 或其搭载的方法(methods)
声明 Delegate Type
一个 delegate type 的声明一般由四部分组成:(a) 访问级别;(b) 关键字 delegate;(c)返回型别,以及该 delegate type 所搭载之方法的声明形式(signature);(d) delegate type 的名称,被放置于返回型别和方法的声明形式(signature)之间。例如,下面声明了一个 public delegate type Action,用来搭载“没有参数并具有 void 返回型别”的方法:
public delegate void Action();
一眼看去,这与函数定义惊人的相似;唯一的区别就是多了 delegate 关键字。增加该关键字的目的就在于:要通过关键字(keyword)——而非字元(token)——使普通的成员函数与其它形似的语法形式区别开来。这样就有了 virtual,static, 以及 delegate 用来区分各种函数和形似函数的语法形式。
如果一个 delegate type 一次只搭载单独一个方法(method),那它就可以搭载任意返回型别及形式的成员函数。然而,如果一个 delegate type 要同时搭载多个方法(methods),那么返回型别就必须是 void[译注2]。 例如,Action 就可以用来搭载一个或者多个方法(method)。在 testHarness class 实现中,我们就将使用上述的 Action 声明。
定义 Delegate Handle
在 C# 中我们无法声明全局对象;每个对象定义必须是下述三种之一:局部对象;或者型别的对象成员;或者函数参数列表中的参数。现在我只向你展示 delegate type 的声明。之后我们再来看如何将其声明为类别中的成员。
C# 中的 delegate type 与 class, interface, 以及 array types 一样,属于 reference type。每个 reference type 被分为两部分:
一个具名的 句柄(named handle),由我们直接操纵;以及
一个该句柄所属型别的不具名对象(unamed object),由我们通过句柄间接进行操纵。必须经由 new 显式的创建该对象。
定义 reference type 是一个“两步走”的过程。当我们写:
Action theAction;
的时候,theAction 代表“delegate type Action 之对象”的一个 handle(句柄),其本身并非 delegate object。缺省情况下,它被设为 null。如果我们试图在对其赋值(译注:assigned,即与相应型别的对象做attachment)之前就使用它,会发生编译期错误。例如,语句:
theAction();
会唤起 theAction 所搭载的方法(method(s))。然而,除非它在定义之后、使用之前被无条件的赋值(译注:assigned,即与相应型别的对象做attachment),否则该语句会引发编译期错误并印出相关信息。
为 Delegate Object 分配空间
在这一节中,为了以最小限度的涉及面继续进行阐述,我们需要访问一个静态方法(static method)和一个非静态方法(non-static method),就此我采用了一个 Announce class。该类别的 announceDate 静态方法(static method)以 long form 的形式(使用完整单字的冗长形式)打印当前的日期到标准输出设备:
Monday, February 26, 2001
非静态方法(non-static method) announceTime 以 short form 的形式(较简短的表示形式)打印当前时间到标准输出设备:
00:58
前两个数字代表小时,从午夜零时开始计算,后两个数字代表分钟。Announce class 使用了由 .NET class framework 提供的 DateTime class。Announce 类别的定义如下所示。
public class Announce
{
public static void announceDate()
{
DateTime dt = DateTime.Now;
Console.WriteLine( "Today''''s date is {0}",
dt.ToLongDateString() );
}
public void announceTime()
{
DateTime dt = DateTime.Now;
Console.WriteLine( "The current time now is {0}",
dt.ToShortTimeString() );
}
}
要让 theAction 搭载上述方法,我们必须使用 new 表达式创建一个 Action delegate type(译注:即创建一个该类别的对象)。要搭载静态方法,则传入构造函数的引数由三部分组成:该方法所属类别的名称;方法的名称;分隔两个名称用的 dot operator(.):
theAction = new Action( Announce.announceDate );
要搭载非静态方法,则传入构造函数的引数也由三部分组成:该方法所属的类别对象名称;方法的名称;分隔两个名称用的 dot operator(.):
Announce an = new Announce();
theAction = new Action( an.announceTime );
可以注意到, theAction 被直接赋值,事先没有做任何检查(比如,检查它是否已经指代一个堆中的对象,如果是,则先删除该对象)。在 C# 中,存在于 managed heap(受托管的堆)中的对象由运行期环境对其施以垃圾收集动作(garbage collected)。我们不需要显式的删除那些经由 new 表达式分配的对象。
在程序的 managed heap(受托管的堆)中,new 表达式既可以为独个对象做分配
HelloUser myProg = new HelloUser();
也可以为数组对象做分配
string [] messages = new string[ 4 ];
分配语句的形式为:型别的名称,后跟关键字 new,后跟一对圆括弧(表示单个对象)或者方括号(表示数组对象)[1]。(在 C# 语言设计中的一个普遍特征就是,坚持使用单一明晰的形式来区别不同的功用。)
一个快速的概览:Garbage Collection(垃圾收集)
如下述数组对象所示,当我们在 managed heap(受托管的堆)中为 reference type 分配了空间:
int [] fib = new int[6]{ 1,1,2,3,5,8 };
对象自动的维护“指向它的句柄(handles)”之数目。在这个例子中,被 fib 所指向的数组对象有一个关联的引用计数器被初始化为1。如果我们现在初始化另一个句柄,使其指向 fib 所指代的数组对象:
int [] notfib = fib;
这次初始化导致了对 fib 所指代数组对象的一次 shallow copy(浅层拷贝)。这就是说,notfib 现在也指向 fib 所指向的数组对象。该数组对象所关联的引用计数变成了2。
如果我们经由 notfib 修改了数组中某个元素,比如
notfib [ 0 ] = 0;
这个改变对于 fib 也是可见的。如果这种对同一个对象的多重访问方式并非所需,我们就需要编写代码,做一个 deep copy(深层拷贝)。例如,
// 分配另一个数组对象
notfib = new int [6];
// 从 notfib 的第0个元素开始,
// 依次将 fib 中的元素拷贝到 notfib 中去。
// 见注释 [2]
fib.CopyTo( notfib, 0 );
notfib 现在并不指代 fib 所指代的那个对象了。先前被它们两个同时指向的那个对象将其关联的引用计数减去1。notfib 所指代对象的初始引用计数为1。如果我们现在也将 fib 重新赋值为一个新的数组对象——例如,一个包含了Fibonacci数列前12个数值的数组:
fib = new int[12]{ 1,1,2,3,5,8,13,21,34,55,89,144 };
对于之前被 fib 所指代的那个数组对象,其现在的引用计数变成了0。在 managed heap(受托管的堆)中,当垃圾收集器(garbage collector)处于活动状态时,引用计数为0的对象被其作上删除标记。
定义 Class Properties
现在让我们将 delegate object 声明为 testHarness class 的一个私有静态(private static)成员。例如 [3],
public class testHarness
{
public delegate void Action();
static private Action theAction;
// ...
}
下一步我们要为这个 delegate 成员提供读写访问机制。在 C# 中,我们不要提供显式的内联方法(inline methods)用来读写非公有的数据成员。取而代之,我们为具名的属性(named property)提供 get 和 set 访问符(accessors)。下面是个简单的 delegate property。我们不妨将其称为 Tester:
public class testHarness
{
static public Action Tester
{
get{ return theAction; }
set{ Action = value; }
}
// ...
}
Property(属性)既可以封装静态数据成员,也可以封装非静态数据成员。Tester 就是 delegate type Action 的一个 static property(静态属性)。(可以注意到。我们将 accessor 定义为一个代码区块。编译器内部由此产生 inline method。)
get 必须以 property(属性)的型别作为返回型别。在这个例子中,其直接返回所封装的对象。如果采用“缓式分配(lazy allocation)”,get 可以在初次被唤起的时候建构并存放好对象,以便后用。
类似的,如果我们希望 property(属性)能够支持写入型访问,我们就提供 set accessor。set 中的 value 是一个条件型关键字(conditional-keyword)。也就是说,value 仅在 set property 中具有预定义的含义(译注:也就是说,value 仅在 set 代码段中被看作一个关键字):其总是代表“该 property(属性)之型别”的对象。在我们的例子中,value 是 Action 型别的对象。在运行期间,其被绑定到赋值表达式的右侧。在下面的例子中,
Announce an = new Announce();
testHarnes.Tester =
new testHarness.Action
( an.announceTime );
set 以内联(inline)的方式被展开到 Tester 出现的地方。value 对象被设置为由 new 表达式返回的对象。
唤起 Delegate Object
如之前所见,要唤起由 delegate 所搭载的方法,我们对 delegate 施加 call operator(圆括弧对):
testHarness.Tester();
这一句唤起了Tester property 的 get accessor;get accessor返回 theAction delegate handle。如果 theAction 在此刻并未指向一个 delegate object,那么就会有异常被抛出。从类别外部实行唤起动作的规范做法(delegate-test-and-execute,先实现代理,再测试,最后执行之)如下所示:
if ( testHarness.Tester != null )
testHarness.Tester();
对于 testHarness class,我们的方法只简单的封装这样的测试:
static public void run()
{
if ( theAction != null )
theAction();
}
关联多个 Delegate Objects
要让一个 delegate 搭载多个方法,我们主要使用 += operator 和 -= operator。例如,设想我们定义了一个 testHashtable class。在构造函数中,我们把各个关联的测试加入到 testHarness 中:
public class testHashtable
{
public void test0();
public void test1();
testHashtable()
{
testHarness.Tester += new testHarness.Action( test0 );
testHarness.Tester += new testHarness.Action( test1 );
}
// ...
}
同样,如果我们定义一个 testArrayList class,我们也在 default constructor 中加入关联的测试。可以注意到,这些方法是静态的。
public class testArrayList
{
static public void testCapacity();
static public void testSearch();
static public void testSort();
testArrayList()
{
testHarness.Tester += new
testHarness.Action(testCapacity);
testHarness.Tester += new testHarness.Action(testSearch);
testHarness.Tester += new testHarness.Action(testSort);
}
// ...
}
当 testHarness.run 方法被唤起时,通常我们并不知道 testHashtable 和 testArrayList 中哪一个的方法先被唤起;这取决于它们构造函数被唤起的顺序。但我们可以知道的是,对于每个类别,其方法被唤起的顺序就是方法被加入 delegate 的顺序。
Delegate Objects 与 Garbage Collection(垃圾收集)
考察下列局部作用域中的代码段:
{
Announce an = new Announce();
testHarness.Tester +=
new testHarness.Action
( an.announceTime );
}
当我们将一个非静态方法加入到 delegate object 中之后,该方法的地址,以及“用来唤起该方法,指向类别对象的句柄(handle)”都被存储起来。这导致该类别对象所关联的引用计数自动增加。
an 经由 new 表达式初始化之后,managed heap(受托管的堆)中的对象所关联的引用计数被初始化为1。当 an 被传给 delegate object 的构造函数之后,Announce 对象的引用计数增加到2。走出局部作用域之后,an 的生存期结束,该引用计数减回到1——delegate object还占用了一个。
好消息是,如果有一个 delegate 引用了某对象的一个方法,那么可以保证该对象会直到“delegate object 不再引用该方法”的时候才会被施以垃圾收集处理[4]。我们不用担心对象会在自己眼皮底下被贸然清理掉了。坏消息是,该对象将持续存在(译注:这可能是不必要的),直到 delegate object 不再引用其方法为止。可以使用 -= operator 从 delegate object 中移除该方法。例如下面修正版本的代码;在局部作用域中,announceTime 先被设置、执行,然后又从 delegate object 中被移除。
{
Announce an = new Announce();
Action act = new testHarness.Action( an.announceTime );
testHarness.Tester += act;
testHarness.run();
testHarness.Tester -= act;
}
我们对于设计 testHashtable class 的初始想法是,实现一个析构函数用以移除在构造函数中加入的测试用方法。然而,C# 中的析构函数调用机制与 C++ 中的却不大相同[5]。C# 的析构函数既不会因为对象生存期结束而跟着被唤起,也不会因为释放了对象最后一个引用句柄( reference handle)而被直接唤起。事实上,析构函数仅在垃圾收集器作垃圾收集时才被调用,而施行垃圾收集的时机一般是无法预料的,甚至可以根本就没施行垃圾收集。
C# 规定,资源去配动作被放进一个称为 Dispose 的方法中完成,用户可以直接调用该方法:
public void Dispose ()
{
testHarness.Tester -= new testHarness.Action( test0 );
testHarness.Tester -= new testHarness.Action( test1 );
}
如果某类别定义了一个析构函数,其通常都会唤起 Dispose。
访问底层的类别接口
让我们再回头看看先前的代码:
{
Announce an = new Announce();
Action act =
new testHarness.Action
( an.announceTime );
testHarness.Tester += act;
testHarness.run();
testHarness.Tester -= act;
}
另一种实现方案是,先检查 Tester 当前是否已经搭载了其它方法,如果是,则保存当前的委托列表(delegation list),将 Tester 重置为 act,然后调用 run,最后将 Tester 恢复为原来的状态。
我们可以利用底层的 Delegate 类别接口来获知 delegate 实际搭载的方法数目。例如,
if ( testHarness.Tester != null &&
testHarnest.GetInvocationList().Length != 0 )
{
Action oldAct = testHarness.Tester;
testHarness.Tester = act;
testHarness.run();
testHarness.Tester = oldAct;
}
else { ... }
GetInvocationList 返回 Delegate class objects 数组,数组的每个元素即代表该 delegate 当前搭载的一个方法。Length 是底层 Array class 的一个 property(属性)。Array class 实现了 C# 内建数组型别的语义[6]。
经由 Delegate class 的 Method property,我们可以获取被搭载方法的全部运行期信息。如果方法是非静态的,那么经由 Delegate class 的 Target property,我们更可以获取调用该方法之对象(译注:即该方法所属类别的那个对象)的全部运行期信息。在下面例子中,Delegate 的 methods(方法) 和 properties(属性)用红色表示:
If (testHarness.Tester != null )
{
Delegate [] methods = test.Tester.GetInvocationList();
foreach ( Delegate d in methods )
{
MethodInfo theFunction = d.Method;
Type theTarget = d.Target.GetType();
// 好的:现在我们可以知道 delegate 所搭载方法的全部信息
}
}
总结
希望本文能够引起你对 C# delegate type 的兴趣。我认为 delegate type 为 C# 提供了一种创新性的“pointer to class method(类别方法之指针)”机制。或许本文还引起了你对 C# 语言以及 .NET class framework 的兴趣。
A good starting page for technical resources is <http://www.microsoft.com/net/>. An informative news group with Microsoft developer input dealing with both .NET and C# is <http://discuss.develop.com/dotnet.html>. Of course, questions or comments on C# or this article can be addressed to me at stanleyl@you-niversity.com. Finally, C# is currently in the process of standardization. On October 31, 2000, Hewlett-Packard, Intel, and Microsoft jointly submitted a proposed C# draft standard to ECMA, an international standards body (ECMA TC39/TG2). The current draft standard and other documentation can be found at <http://www.ecma.ch>.
致谢
I would like to thank Josee Lajoie and Marc Briand for their thoughtful review of an earlier draft of this article. Their feedback has made this a significantly better article. I would also like to thank Caro Segal, Shimon Cohen, and Gabi Bayer of you-niversity.com for providing a safety.NET.
注释
[1] 对于 C++ 程序员来说,有两点值得一题:(a) 需要在对象的型别名称之后放一对圆括弧作为 default constructor,以及(b) 用于数组下标的方括号要放在型别与数组名称之间。
[2] C# 中内建的数组是一种由 .NET class library 提供的 Array class 之对象。Array class 的静态方法和非静态方法都可以被 C# 内建数组对象使用。CopyTo 是 Array 的一个非静态方法。
[3] 与 Java 一样,C# 中的成员声明包括其访问级别。缺省的访问级别是 private。
[4] 类似的,C++ 标准要求,被引用的临时对象必须直到引用的生存期结束时才能够被销毁。
[5] 在内部实现中,析构函数甚至都不曾存在过。一个类别的析构函数会被转换成 virtual Finalize 方法。
[6] 在 C# 中,一个条件判别式的结果必须得到 Boolean 型别。对 Length 值的直接判别,如if(testHarness.Length),并不是合法的条件判断。整型值无法被隐式的转换为 Boolean 值。
Stanley B. Lippman is IT Program Chair with you-niversity.com, an interactive e-learning provider of technical courses on Patterns, C++, C#, Java, XML, ASP, and the .NET platform. Previously, Stan worked for over five years in Feature Animation both at the Disney and DreamWorks Animation Studios. He was the software Technical Director on the Firebird segment of Fantasia 2000. Prior to that, Stan worked for over a decade at Bell Laboratories. Stan is the author of C++ Primer, Essential C++, and Inside the C++ Object Model. He is currently at work on C# Primer for the DevelopMentor Book Series for Addison-Wesley. He may be reached at stanleyl@you-niversity.com.
译注
[译注1] 在C#中,所谓“method(方法)”,其实就是指我们平常所理解的成员函数,其字面意义与“function(函数)”非常接近。
[译注2] 作者是就前述的那个 delegate type Action 声明而有此言。就一般而言,只要多个方法(methods)的返回型别相同并且参数也相同,就可以被同一个 delegate type 搭载。
推荐: 编写更快的托管代码:了解开销情况
http://www.microsoft.com/china/msdn/archives/library/dndotnet/html/fastmanagedcode.asp
310674 - HOW TO: 添加对托管的 Visual C++ 项目的引用
http://support.microsoft.com/default.aspx?scid=kb;zh-cn;310674