C# in Depth-委托

2.1 委托

委托在某种程度上提供了间接的方法。换言之,不需要直接指定一个要执行的行为,而是将这个行为用某种方式“包含”在一个对象中。

这个对象可以像其他任何对象那样使用。在该对象中,可以执行封装的操作。

可以选择将委托类型看做只定义了一个方法的接口,将委托的实例看做实现了那个接口的一个对象。

让我们以遗嘱为例。遗嘱由一系列指令组成,比如:“付账单,捐善款,其余财产留给猫。”

遗嘱一般是在某人去世之前写好,然后把它放到一个安全的地方。去世后,(希望)律师会执行这些指令。

C#中的委托和现实世界的遗嘱一样,也是要在恰当的时间执行一系列操作。

如果代码想要执行操作,但不知道操作细节,一般可以使用委托。

例如, Thread 类之所以知道要在一个新线程里运行什么,唯一的原因就是在启动新线程时,向它提供了一个 ThreadStart 或 ParameterizedThreadStart 委托实例。


2.1.1 简单委托的构成

为了让委托做某事,必须满足4个条件:

① 声明委托类型

②必须有一个方法包含了要执行的代码

③必须创建一个委托实例

④必须调用(invoke)委托实例

下面依次讨论上述每一步。


1. 声明委托类型

委托类型实际上只是参数类型的一个列表以及一个返回类型。它规定了类型的实例能表示的操作。

例如,以如下方式声明一个委托类型:

delegate void StringProcessor(string input);

上述代码指出,如果要创建 StringProcessor 的一个实例,需要只带一个参数(string)的方法,而且这个方法要有一个 void 返回类型(什么都不返回)。

这里的重点在于, StringProcessor 其实是一个从 System.MulticastDelegate 派生的类型,后者又派生自 System.Delegate 。

它有方法,可以创建它的实例,并将引用传递给实例,所有这些都没有问题。

虽然它有一些自己的“特性”,但假如你对特定情况下发生的事情感到困惑,那么首先想一想使用“普通”的引用类型时发生的事情。

讨论委托的下一个基本元素时,会用到 StringProcessor 委托类型。

混乱的根源:容易产生歧义的“委托”

委托经常被人误解,这是由于大家喜欢用委托这个词来描述委托类型和委托实例。

这两者的区别其实就是任何一个类型和该类型的实例的区别。

例如, string 类型本身和一组特定的字符肯定不同。委托类型和委托实例这两个词会贯穿本章始终,从而让你明白我具体说的是什么。


2. 为委托实例的操作找到一个恰当的方法

我们的下一个基本元素是找到(或者写)一个方法,它能做我们想做的事情,同时具有和委托类型相同的签名。

基本的思路是,要确保在调用(invoke)一个委托实例时,使用的参数完全匹配,而且能以我们希望的方式(就像普通的方法调用)使用返回值(如果有的话)。

看看以下 StringProcessor 实例的5个备选方法签名:

void PrintString(string x)
void PrintInteger(int x)
void PrintTwoStrings(string x,string y)
int GetStringLength(string x)
void PrintObject(object x)

第1个方法完全符合要求,所以可以用它创建一个委托实例。

第2个方法虽然也有一个参数,但不是 string 类型,所以不兼容 StringProcessor 。

第3个方法第1个参数的类型匹配,但参数数量不匹配,所以也不兼容。

第4个方法有正确的参数列表,但返回类型不是 void 。(如果委托类型有返回类型,方法的返回类型也必须与之匹配。)

第5个方法比较有趣,任何时候调用一个 StringProcessor 实例,都可以调用具有相同的参数的 PrintObject 方法,这是由于 string 是从 object 派生的。

把这个方法作为 StringProcessor 的一个实例来使用是合情合理的,但C# 1要求委托必须具有完全相同的参数类型。C# 2改善了这个状况——详见第5章。

在某些方面,第4个方法也是相似的,因为总是可以忽略不需要的返回值。

然而, void 和非 void 返回类型目前一直被认为是不兼容的。

部分原因是因为系统的其他方面(特别是JIT)需要知道,在执行方法时返回值是否会留在栈上。

假定有一个针对兼容的签名( PrintString )的方法体。接着,讨论下一个基本元素——委托实例本身。


3. 创建委托实例

有了一个委托类型和一个有正确签名的方法后,接着可以创建委托类型的一个实例,指定在调用委托实例时就执行该方法。作者将该方法称为委托实例的操作。

至于具体用什么形式的表达式来创建委托实例,取决于操作使用实例方法还是静态方法。

假定 PrintString 是 StaticMethods 类型中的一个静态方法,在 InstanceMethods 类型中是一个实例方法。

下面是创建一个 StringProcessor 实例的两个例子:

//静态方法
StringProcessor pro1,proc2;
proc1 = new StringProcessor(StaticMethods.PrintString);
//实例方法
InstanceMethods instance = new InstanceMethods();
proc2 = new StringProcessor(instance.PrintString);

如果操作是静态方法,指定类型名称就可以了。如果操作是实例方法,就需要先创建类型(或者它的派生类型)的一个实例。这和平时调用方法是一样的。

这个对象称为操作的目标。调用委托实例时,就会为这个对象调用(invoke)方法。

如果操作在同一个类中(这种情况经常发生,尤其是在UI代码中写事件处理程序时),那么两种限定方式都不需要——实例方法隐式将 this 引用作为前缀。

同样,这些规则和你直接调用方法时没什么两样。单纯创建一个委托实例却不在某一时刻调用它是没有什么意义的。看看最后一步——调用。

最终的垃圾(或者不是,视情况而定)

必须注意,假如委托实例本身不能被回收,委托实例会阻止它的目标被作为垃圾回收。这可能造成明显的内存泄漏(leak),尤其是假如某“短命”对象调用了一个“长命”对象中的事件,并用它自身作为目标。“长命”对象间接容纳了对“短命”对象的一个引用,延长了“短命”对象的寿命。


4. 调用委托实例

这是很容易的一件事儿,调用一个委托实例的方法就可以了。这个方法本身被称为 Invoke 。

在委托类型中,这个方法以委托类型的形式出现,并且具有委托类型声明中指定的相同参数列表和返回类型。

所以,在我们的例子中,有一个像下面这样的方法:

调用 Invoke 会执行委托实例的操作,向它传递在调用 Invoke 时指定的任何参数。另外,如果返回类型不是 void ,还要返回操作的返回值。

C#将这个过程变得更简单——如果有一个委托类型的变量,就可以把它视为方法本身。

观察由不同时间发生的事件构成的一个事件链,很容易就可以理解这一点,如图2-1所示。

就是这么简单。所有原料都已齐备,接着将CLR预热到200℃,将所有东西都搅拌到一起,看看会发生什么。


5. 一个完整的例子和一些动机

//代码清单2-1 以各种简单的方式使用委托
delegate void StringProcessor(string input);//声明委托类型
class Person
{
    string name;
    public Person(string name) { this.name = name; }
    //声明兼容的实例方法
    public void Say(string message)
    {
        Console.WriteLine("{0}说:{1}",name,message);
    }
}
class BackGround
{
    //声明兼容的静态方法
    public static void Note(string note)
    {
        Console.WriteLine("({0})",note);
    }
}
class Program
{
    static void Main()
    {
        //声明人物实例
        Person jon = new Person("铁子");
        Person tom = new Person("杨树");
        //声明委托实例,并添加对应的实例方法和静态方法
        StringProcessor jonsVoice, tomsVoice, background;
        jonsVoice = new StringProcessor(jon.Say);
        tomsVoice = new StringProcessor(tom.Say);
        background = new StringProcessor(BackGround.Note);
        //调用委托实例
        jonsVoice("你瞅啥?");
        tomsVoice.Invoke("瞅你咋地?");
        background("烧烤摊又开始洋溢着欢快的声音了");
        Console.Read();
    }
}

首先声明委托类型 ,接着创建两个方法,它们都与委托类型兼容。

一个是实例方法( Person.Say ),另一个是静态方法( Background.Note ),这样就可以看到在创建委托实例时 ,它们在使用方式上的区别。

代码清单2-1创建了 Person 类的两个实例,便于观察委托目标所造成的差异。

jonsVoice 被调用时 它会调用 name 为 Jon 的那个 Person 对象的 Say 方法。

同样,tomsVoice 被调用时使用的是 name 为 Tom 的对象。

然后展示了调用委托实例的两种方式,显式调用 Invoke 和使用C#的简化形式。一般情况下只需使用简化形式。以下是代码清单2-1的输出:

铁子说:你瞅啥?
杨树说:瞅你咋地?
(烧烤摊又开始洋溢着欢快的声音了)

使用委托的意义

如果仅仅是为了显示上述3行输出,代码清单2-1的代码未免太多了。即使想要使用Person 类和 Background 类,也没有必要使用委托。

但是我们不能仅仅由于你希望某事发生,就意味着你始终会在正确的时间和地点出现,并亲自使之发生。有时,你需要给出一些指令,将职责委托给别人。

应该强调的一点是,在软件世界中,没有对象“留遗嘱”这样的事情发生。经常都会发现这种情况:委托实例被调用时,最初创建委托实例的对象仍然是“活蹦乱跳”的。

相反,委托相当于指定一些代码在特定的时间执行,那时,你也许已经无法(或者不想)更改要执行的代码。

如果我希望在单击一个按钮后发生某事,但不想对按钮的代码进行修改,我只是希望按钮调用我的某个方法,那个方法能执行恰当的操作。

委托的实质是间接完成某种操作,事实上,许多面向对象编程技术都在做同样的事情。我们看到,这增大了复杂性,但同时也增加了灵活性。

现在已经对简单委托有了更多的理解,接着看看如何将委托合并到一起,以便成批地执行操作,而不是只执行一个。


2.1.2 合并和删除委托

委托实例实际有一个操作列表与之关联。这称为委托实例的调用列表(invocation list)。

System.Delegate 类型的静态方法 Combine 和 Remove 负责创建新的委托实例。

其中,Combine 负责将两个委托实例的调用列表连接到一起,而 Remove 负责从一个委托实例中删除另一个实例的调用列表。

委托是不易变的

创建了委托实例后,有关它的一切就不能改变。这样一来,就可以安全地传递委托实例的引用,并把它们与其他委托实例合并,同时不必担心一致性、线程安全性或者是否有其他人试图更改它。

在这一点上,委托实例和 string 是一样的。string的 实 例 也 是 不 易 变 的 。 之 所 以 提 到 string , 是 因 为 Delegate.Combine 和String.Concat 很像——都是合并现有的实例来形成一个新实例,同时根本不更改原始对象。对于委托实例,原始调用列表被连接到一起。注意,如果试图将 null 和委托实例合并到一起, null 将被视为带有空调用列表的一个委托。

使用操作符代替两个方法

很少在C#代码中看到对 Delegate.Combine 的显式调用,一般都是使用+和+=操作符。

图2-2展示了转换过程,其中 x 和 y 都是相同(或兼容)委托类型的变量。所有转换都由C#编译器完成。

 

可以看出,这是一个相当简单的转换过程,但它使代码变得整洁多了。

Delegate.Remove 方法从一个实例中删除另一个实例的调用列表。C#使用-和-=运算符简写形式的方法非常简单,一看便知。

Delegate.Remove(source, value) 将创建一个新的委托实例,其调用列表来自 source , value 中的列表则被删除。如果结果有一个空的调用列表,就返回 null 。

委托返回非void操作中最后一个操作的返回值

调用委托实例时,它的所有操作都顺序执行。如果委托的签名具有一个非 void 的返回类型,则 Invoke 的返回值是最后一个操作的返回值。

很少有非 void 的委托实例在它的调用列表中指定多个操作,因为这意味着其他所有操作的返回值永远都看不见。除非每次调用代码使用Delegate.GetInvocationList 获取操作列表时,都显式调用某个委托。

任何操作异常立都会即终止委托

如果调用列表中的任何操作抛出一个异常,都会阻止执行后续的操作。例如,假定调用一个委托实例,它的操作列表是 [a, b, c] ,但操作 b 抛出了一个异常,这个异常会立即“传播”,操作 c 不会执行。

进行事件处理时,委托实例的合并与删除会特别有用。既然我们已经理解了合并与删除涉及的操作,就很容易理解事件。


2.1.3 对事件的简单讨论

事件的基本思想是让代码在发生某事时作出响应,如在正确单击一个按钮后保存一个文件。在这个例子中,事件是“单击按钮”,操作是“保存文件”。

开发者经常将事件和委托实例,或者将事件和委托类型的字段混为一谈。

但它们之间的差异十分大:事件不是委托类型的字段。之所以产生混淆,原因和以前相同,因为C#提供了一种简写方式,允许使用字段风格的事件(field-like event)。

先从C#编译器的角度看看事件到底由什么组成。


将事件看成属性

将事件看做类似于属性(property)的东西是很有好处的。

声明为具有一种特定的类型

两者都声明为具有一种特定的类型。对于事件来说,必须是一个委托类型。

能获取或设置字段/方法

使用属性时,实际是在调用方法,也就是取值方法和赋值方法。实现属性时,可以在那些方法中做任何事情。

同样,在订阅或取消订阅一个事件时,看起来就像是在通过 += 和 -= 运算符使用委托类型的字段。

但和属性的情况一样,这个过程实际是在调用方法。对于一个纯粹的事件,你所能做的事情就是订阅或者取消订阅。

最终是由事件方法来做真正有用的事情,如找到你试图添加和删除的事件处理程序,并使它们在类中的其他地方可用。

使用封装实现发布/订阅

事件存在的首要理由和属性差不多,它们都添加了一个封装层以实现发布/订阅模式。

通常,我们不希望其他代码能直接设置字段值。最起码也要先由所有者(owner)对新值进行证。

同样,我们通常不希望类外部的代码随意更改(或调用)一个事件的处理程序。

类能通过添加方法的方式来提供额外的访问。例如,可以重置事件的处理程序列表,或者引发事件。但是如果只对外揭示事件本身,类外部的代码就只能添加和删除事件处理程序。

字段风格的事件使所有这些的实现变得更易阅读,只需一个声明就可以了。

编译器会将声明转换成一个具有默认 add / remove 实现的事件和一个私有委托类型的字段。类内的代码能看见字段;类外的代码只能看见事件。

这样一来,表面上似乎能调用一个事件,但为了调用事件处理程序,实际做的事情是调用存储在字段中的委托实例。

posted @ 2018-11-26 12:24  田错  阅读(340)  评论(0编辑  收藏  举报