第2章 C# 1所搭建的核心基础
本章概要
- 委托
- 类型系统的特征
- 值/引用类型
本章不是对整个C# 1的全面复习。我也不会那样做。如果用一章的篇幅对C# 1进行全面讲述,那么我无法对每个主题做到“一视同仁”。我写这本书时假定读者至少对C# 1有一个基本的掌握。当然,多少才算“基本掌握”,这里面肯定存在一些主观因素。但是,我假定你至少是满怀信心地去应聘一份初级C#开发者的工作,能正确回答与工作相关的技术问题。我的期望是许多读者都有更多的经验,但这是我假设的知识水平。
本章的重点是对理解更高版本的C#特性来说特别重要的3个C# 1主题。这应该使“最小公分母”变得稍微大一点,使我能在本书以后进行稍大胆的假设1。假定这是“最小公分母”,你可能会发现自己已经对本章的所有概念都有了一个很好的理解。如果你认为你恰属于这种情况,完全可以跳过本章。如果以后发现有一些东西不是像你想的那么简单,那么任何时候都可以回来。但是,或许至少应该看看每一节末尾的小结,那里罗列了重点——如果发现有自己不熟悉的东西,就应该详细阅读那一节。
1作者用“最小公分母”来形容所有读者都应该掌握的最起码的知识。掌握的基础知识越多,基础越牢靠,自然有利于以后理解更高深的主题。——译者注
我们将先介绍委托,然后比较C#类型系统与其他语言的不同,最后介绍值类型和引用类型的区别。在各个话题中,我都将阐述概念和行为,并借此机会定义后面要用到的术语。在介绍完C# 1的工作原理之后,我们将快速浏览后续版本中有哪些与本章话题相关的新特性。
2.1 委托
可以肯定,你对委托(delegate)已经有了一个直观概念,只是无法清晰地说出来。如果你熟悉C语言,而且必须向另一个C程序员描述委托,你一定会立刻想到“函数指针”这个术语。实际上,委托在某种程度上提供了间接的方法。换言之,不需要直接指定一个行为,而是将这个行为用某种方式“包含”在一个对象中。这个对象可以像其他任何对象那样使用。在该对象中,可以执行封装的行为。可以选择将委托类型看做只定义了一个方法的接口,将委托的实例看做实现了那个接口的一个对象。
如果认为这些说法仍然很空洞,也许可以通过一个例子来加深理解。虽然这个例子有些变态,但它的确能清楚地说明何为委托。让我们以遗嘱为例。遗嘱由一系列指令组成,比如:“付账单,捐善款,其余财产留给猫。”一般是在去世之前写好遗嘱,然后把它放到一个安全的地方。去世后,(希望)律师1会执行这些指令。
1 换言之,指示别人去做某事,但不知道他具体会怎么做,因为当事人已经死了。——译者注
C#中的委托和现实世界的遗嘱一样,也是要在恰当的时间执行一系列操作。如果代码想要执行操作,但不知道操作细节,一般可以使用委托。例如,Thread类之所以知道要在一个新线程里运行什么,唯一的原因就是在启动新线程时,向它提供了一个ThreadStart委托实例。
为了开始我们的委托之旅,首先必须知道委托的4个基本条件,它们缺一不可。
2.1.1 简单委托的构成
为了让委托做某事,必须满足4个条件:
- 声明委托类型;
- 必须有一个方法包含了要执行的代码;
- 必须创建一个委托实例;
- 必须调用(invoke)委托实例。
让我们依次讨论上述每一个步骤。
声明委托类型
委托类型实际上只是参数类型的一个列表以及一个返回类型。它规定了类型的实例能表示的操作。以如下方式声明一个委托类型:
delegate void StringProcessor(string input);
上述代码指出,如果要创建StringProcessor的一个实例,需要只带一个参数(一个字符串)的方法,而且这个方法要有一个void返回类型(该方法什么都不返回)。这里的重点在于,StringProcessor其实是一个类型。它有方法,可以创建它的实例,并将引用传递给实例,所有这些都没有问题。虽然它肯定有一些自己的“特性”,但假如你对特定情况下发生的事情感到困惑,那么首先想一想使用“普通”的引用类型时发生的事情。
说明**混乱的根源:容易产生歧义的“委托”**
委托经常被人误解,这是由于大家喜欢用“委托”这个词来同时描述“委托类型”和“委托实例”。这两者的区别其实就是任何一个类型和该类型的实例的区别。例如,string类型本身和一组特定的字符肯定是不同的。“委托类型”和“委托实例”这两个词会贯穿本章始终,从而让你明白我具体说的是什么。
讨论委托的下一个基本元素时,会用到StringProcessor委托类型。
为委托实例的操作找到一个恰当的方法
我们的下一个基本元素是找到(或者写)一个方法,它能做我们想做的事情,同时具有和委托类型相同的签名。基本的思路是,要确保在调用(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要求委托必须具有相同的参数类型2。C# 2改善了这个状况——详见第5章。在某些方面,第4个方法也是相似的,因为总是可以忽略不需要的返回值。然而,void和非void返回类型目前一直被认为是不兼容的。部分原因是因为系统的其他方面(特别是JIT)需要知道,在执行方法时返回值是否会留在栈上3。
2 和参数的类型一样,参数的(默认)out或ref前缀也必须匹配。然而,委托很少使用out/ref参数。
3这个词,以避免太多不相关的细节。更多的信息请参阅Eric Lippert的博文The void is invariant(http://mng.bz/4g58)。
假定有一个针对兼容的签名(PrintString)的方法体。接着,让我们讨论下一个基本元素——委托实例本身。
创建委托实例
既然已经有了一个委托类型和一个有正确签名的方法,接着可以创建委托类型的一个实例,指定在调用委托实例时就执行该方法。虽然没有什么好的官方术语来定义这一行为,但在本书中,我会将该方法称为“委托实例的操作”。至于具体用什么形式的表达式来创建委托实例,取决于操作使用实例方法还是静态方法。假定PrintString是StaticMethods类型中的一个静态方法,在InstanceMethods类型中是一个实例方法。下面是创建一个StringProcessor实例的两个例子:
StringProcessor proc1, proc2;
proc1 = new StringProcessor(**StaticMethods.PrintString**);
InstanceMethods instance = new InstanceMethods();
proc2 = new StringProcessor(**instance.PrintString**);
如果操作是静态方法,指定类型名称就可以了。如果操作是实例方法,就需要先创建类型(或者它的派生类型)的一个实例。这和平时调用方法是一样的。这个对象4称为操作的目标。当委托实例被调用时,就会为这个对象调用方法5。如果操作在同一个类中(这种情况经常发生,尤其是在UI代码中写事件处理程序的时候),那么两种限定方式都不需要——实例方法隐式将this引用作为前缀{6![
]}。同样,这些规则和你直接调用方法时没什么两样。
4就是刚才创建的实例。——译者注
5 “委托实例被调用”中的“调用”对应的是invoke,理解为“唤出”更恰当。它和后面的“为这个对象调用方法”中的“调用”稍有不同,后者对应的是call。在英语的语境中,invoke和call的区别在于,在执行一个所有信息都已知的方法时,用call比较恰当。这些信息包括要引用的类型、方法的签名以及方法名。但是,在需要先“唤出”某个东西来帮你调用一个信息不明的方法时,用invoke就比较恰当。但是,由于两者均翻译为“调用”不会对读者的理解造成太大的困扰,所以本书仍然采用约定俗成的方式来进行翻译。——译者注
说明**最终的垃圾(或者不是,视情况而定)** 必须注意,假如委托实例本身不能被回收,委托实例会阻止它的目标被作为垃圾回收。这可能造成明显的内存泄漏(leak),尤其是假如某“短命”对象调用了一个“长命”对象中的事件,并用它自身作为目标。“长命”对象间接容纳了对“短命”对象的一个引用,延长了“短命”对象的寿命。
单纯创建一个委托实例却不在某一时刻调用它是没有什么意义的。来看看最后一步——调用。
调用委托实例
这是很容易的一件事儿7——调用一个委托实例的方法就可以了。这个方法本身被称为Invoke。在委托类型中,这个方法以委托类型的形式出现,并且具有与委托类型的声明中所指定的相同的参数列表和返回类型。所以,在我们的例子中,有一个像下面这样的方法:
7 仅对同步调用而言。可以用BeginInvoke和EndInvoke来异步调用一个委托实例,但那超出了本章的范围。
void Invoke(string input)
调用Invoke会执行委托实例的操作,向它传递在调用Invoke时指定的任何参数。另外,如果返回类型不是void,还要返回操作的返回值。
是不是很简单?C#将这个过程变得更简单——如果有一个委托类型的变量8,就可以把它视为方法本身。观察由不同时间发生的事件构成的一个事件链,很容易就可以理解这一点,如图2-1所示。
8 或其他任何种类的表达式——但通常是一个变量。
图2-1 处理使用C#简化语法的委托实例的调用
就是这么简单。所有原料都已齐备,接着将CLR预热到200℃,将所有东西都搅拌到一起,看看会发生什么。
一个完整的例子和一些动机
通过一个完整的例子,可以看到操作中的全部内容——我们终于能真正运行一些东西了!由于有不少零碎的东西,所以这一次包含了完整的源代码,而不是使用“代码段”。在代码清单2-1中,没有什么令人兴奋的东西,所以不要期待惊喜——只是有了具体的代码可供讨论。
代码清单2-1 以各种简单的方式使用委托
using System;
//声明委托类型
delegate void StringProcessor(string input);
class Person
{
string name;
public Person(string name) { this.name = name; }
//声明兼容的实例方法
public void Say(string message)
{
Console.WriteLine("{0} says: {1}", name, message);
}
}
class Background
{
//声明兼容的静态方法
public static void Note(string note)
{
Console.WriteLine("({0})", note);
}
}
class SimpleDelegateUse
{
static void Main()
{
Person jon = new Person("Jon");
Person tom = new Person("Tom");
//创建3个委托实例
StringProcessor jonsVoice, tomsVoice, background;
jonsVoice = new StringProcessor(jon.Say);
tomsVoice = new StringProcessor(tom.Say);
background = new StringProcessor(Background.Note);
//调用委托实例
jonsVoice("Hello, son.");
tomsVoice.Invoke("Hello, Daddy!");
background("An airplane flies past.");
}
}
首先声明委托类型(第4行),接着创建两个方法(第12和20行),它们都与委托类型兼容。一个是实例方法(Person.Say),另一个是静态方法(Background.Note),这样就可以看到在创建委托实例时(第33至36行),它们在使用方式上的区别。我们创建了Person类的两个实例,便于观察委托目标所造成的差异。jonsVoice被调用时(第39至41行),它会调用name为Jon的那个Person对象的Say方法。同样,tomsVoice被调用时,使用的是name为Tom的对象。这里只是出于兴趣,才展示了调用委托实例的两种方式——显式调用Invoke和使用C#的简化形式。一般情况下只需使用简化形式。代码清单2-1的输出是相当明显的:
Jon says: Hello, son.
Tom says: Hello, Daddy!
(An airplane flies past.)
坦率地说,如果仅仅是为了显示上述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#编译器完成的。
图2-2 用C#简化语法来合并委托实例时,C#编译器所执行的转换过程
可以看出,这是一个相当简单的转换过程,但它使代码变得整洁多了。除了能合并委托实例,还可以使用Delegate.Remove方法从一个实例中删除另一个的调用列表。对应的C#的简化写法明显是使用-和-=操作符。Delegate.Remove(source, value)将创建一个新的委托实例,其调用列表来自source,value中的列表则被删除。如果结果有一个空的调用列表,就返回null。
一个委托实例被调用时,它的所有操作都顺序执行。如果委托的签名具有一个非void的返回类型,则Invoke的返回值是最后一个操作的返回值。很少有非void的委托实例在它的调用列表中指定了多个操作,因为这意味着其他所有操作的返回值是永远都看不见的。除非每次调用代码使用Delegate.GetInvocationList获取行为列表时都显式调用某个委托。
如果调用列表中的任何操作抛出一个异常,都会阻止执行后续的操作。例如,假定调用一个委托实例,它的操作列表是[a, b, c],但操作b抛出了一个异常,这个异常会立即“传播”,操作c不会执行。
进行事件处理的时候,委托实例的合并与删除会特别有用。既然我们已经理解了合并与删除涉及的操作,就很容易理解事件。
2.1.3 对事件的简单讨论
你可能对事件有了一个直观的概念——尤其是写过任何UI。它的基本思想是让代码在发生某事时作出响应,如在正确单击一个按钮后保存一个文件。在这个例子中,事件是“按钮被单击”,操作是“保存文件”。然而,仅仅理解了一个概念的缘起,并不等同于理解了C#具体如何用语言来定义事件。
开发者经常将事件和委托实例,或者将事件和委托类型的字段混为一谈。但两者的差异是十分大的:事件不是委托类型的字段。之所以产生混淆,原因和以前是相同的,因为C#提供了一种简写方式,允许使用字段风格的事件(field-like event)。稍后就会讲到这种简写方式,但在此之前,先从C#编译器的角度看看事件到底由什么组成。
我认为将事件看做类似于属性(property)的东西是很有好处的。首先,两者都声明为具有一种特定的类型。对于事件来说,必须是一个委托类型。使用属性时,感觉就像是直接对它的字段进行取值和赋值,但你实际是在调用方法,也就是取值方法和赋值方法。实现属性时,可以在那些方法中做它喜欢做的任何事情。但凑巧的是,大多数属性都只是实现了简单的字段,有时会在赋值方法中添加一些校验机制,有时则会添加一些线程安全性。
同样,在订阅或取消订阅一个事件时,看起来就像是在通过+=和-=操作符使用委托类型的字段。但和属性的情况一样,这个过程实际是在调用方法(add和remove方法)。对于一个纯粹的事件,你所能做的事情就是订阅(添加一个事件处理程序)或者取消订阅(删除一个事件处理程序)。最终是由事件方法来做真正有用的事情,如找到你试图添加和删除的事件处理程序,并使它们在类中的其他地方可用。
“事件”存在的首要理由和“属性”差不多——它们添加了一个封装层,实现发布/订购模式(publish/subscribe pattern),参见http://csharpindepth.com/Articles/chapter2/Events.aspx。通常,我们不希望其他代码能直接设置字段值;最起码也要先由所有者(owner)对新值进行验证。同样,我们通常不希望类外部的代码随意更改(或调用)一个事件的处理程序。当然,类能通过添加方法的方式来提供额外的访问。例如,可以重置事件的处理程序列表,或者引发事件(也就是调用它的事件处理程序)。例如,BackgroundWorker. OnProgressChanged只是调用了ProgressChanged事件的处理程序。然而,如果只对外揭示事件本身,类外部的代码就只能添加和删除事件处理程序。
字段风格的事件使所有这些的实现变得更易阅读——只需一个声明就可以了。编译器会将声明转换成一个具有默认add/remove实现的事件和一个私有委托类型的字段。类内的代码能看见字段;类外的代码只能看见事件。这样一来,表面上似乎能调用一个事件,但为了调用事件处理程序,实际做的事情是调用存储在字段中的委托实例。
事件的细节超出了本章的范围——事件本身在更高版本的C#里没有多大变化,但我希望现在就强调一下委托实例和事件的差异,以免日后混淆。
2.1.4 委托小结
下面对委托进行了小结:
- 委托封装了包含特殊返回类型和一组参数的行为,类似包含单一方法的接口;
- 委托类型声明中所描述的类型签名决定了哪个方法可用于创建委托实例,同时决定了调用的签名;
- 为了创建委托实例,需要一个方法以及(对于实例方法来说)调用方法的目标(目标·方法);
- 委托实例是不易变的;
- 每个委托实例都包含一个调用列表——一个行动列表;
- 委托实例可以合并到一起,也可以从一个委托实例中删除另一个;
- 事件不是委托实例——只是成对的add/remove方法(类似于属性的取值方法/赋值方法)。
- 委托是C#和.NET的一个非常具体的主题,是大背景下的一个小细节。在本章剩余的部分,将讨论一些更宽泛