C#2.0新特性
第一章 C#2.0简介
C# 2.0引入了很多语言扩展,最重要的就是泛型(Generics)、匿名方法(Anonymous Methods)、迭代器(Iterators)和不完全类型(Partial Types)。
● 泛型允许类、结构、接口、委托和方法通过它们所存贮和操作的数据的类型来参数化。泛型是很有用的,因为它提供了更为强大的编译期间类型检查,需要更少的数据类型之间的显式转换,并且减少了对装箱操作的需要和运行时的类型检查。
● 匿名方法允许在需要委托值时能够以“内联(in-line)”的方式书写代码块。匿名方法与Lisp语言中的拉姆达函数(lambda functions)类似。
● 迭代器是能够增量地计算和产生一系列值得方法。迭代器使得一个类能够很容易地解释foreach语句将如何迭代他的每一个元素。
● 不完全类型允许类、结构和接口被分成多个小块儿并存贮在不同的源文件中使其容易开发和维护。另外,不完全类型可以分离机器产生的代码和用户书写的部分,这使得用工具来加强产生的代码变得容易。
这一章首先对这些新特性做一个简介。简介之后有四章,提供了这些特性的完整的技术规范。
C# 2.0中的语言扩展的设计可以保证和现有代码的高度的兼容性。例如,尽管C#2.0在特定的环境中对单词where、yield和partial赋予了特殊的意义,这些单词还是可以被用作标识符。确实,C# 2.0没有增加一个会和现有代码中的标识符冲突的关键字。
1.1 泛型
泛型允许类、结构、接口、委托和方法通过它们所存贮和操作的数据的类型来参数化。C#泛型对使用Eiffel或Ada语言泛型的用户和使用C++模板的用户来说相当亲切,尽管它们也许无法忍受后者的复杂性。
1.1.1 为什么泛型?
没有泛型,一些通用的数据结构只能使用object类型来存贮各种类型的数据。例如,下面这个简单的Stack类将它的数据存放在一个object数组中,而它的两个方法,Push和Pop,分别使用object来接受和返回数据:
public class Stack
{
object[] items;
int count;
public void Push(object item) {...}
public object Pop() {...}
}
尽管使用object类型使得Stack类非常灵活,但它也不是没有缺点。例如,可以向堆栈中压入任何类型的值,譬如一个Customer实例。然而,重新取回一个值得时候,必须将Pop方法返回的值显式地转换为合适的类型,书写这些转换变更要提防运行时类型检查错误是很乏味的:
Stack stack = new Stack();
stack.Push(new Customer());
Customer c = (Customer)stack.Pop();
如果一个值类型的值,如int,传递给了Push方法,它会自动装箱。而当待会儿取回这个int值时,必须显式的类型转换进行拆箱:
Stack stack = new Stack();
stack.Push(3);
int i = (int)stack.Pop();
这种装箱和拆箱操作增加了执行的负担,因为它带来了动态内存分配和运行时类型检查。
Stack类的另外一个问题是无法强制堆栈中的数据的种类。确实,一个Customer实例可以被压入栈中,而在取回它的时候会意外地转换成一个错误的类型:
Stack stack = new Stack();
stack.Push(new Customer());
string s = (string)stack.Pop();
尽管上面的代码是Stack类的一种不正确的用法,但这段代码从技术上来说是正确的,并且不会发生编译期间错误。为题知道这段代码运行的时候才会出现,这时会抛出一个InvalidCastException异常。
Stack类无疑会从具有限定其元素类型的能力中获益。使用泛型,这将成为可能。
1.1.2 建立和使用泛型
泛型提供了一个技巧来建立带有类型参数(type parameters)的类型。下面的例子声明了一个带有类型参数T的泛型Stack类。类型参数又类名字后面的定界符“<”和“>”指定。通过某种类型建立的Stack<T>的实例 可以无欲转换地接受该种类型的数据,这强过于与object相互装换。类型参数T扮演一个占位符的角色,直到使用时指定了一个实际的类型。注意T相当于内部数组的数据类型、Push方法接受的参数类型和Pop方法的返回值类型:
public class Stack<T>
{
T[] items;
int count;
public void Push(T item) {...}
public T Pop() {...}
}
使用泛型类Stack<T>时,需要指定实际的类型来替代T。下面的例子中,指定int作为参数类型T:
Stack<int> stack = new Stack<int>();
stack.Push(3);
int x = stack.Pop();
Stack<int>类型称为已构造类型(constructed type)。在Stack<int>类型中出现的所有T被替换为类型参数int。当一个Stack<int>的实例被创建时,items数组的本地存贮是int[]而不是object[],这提供了一个实质的存贮,效率要高过非泛型的Stack。同样,Stack<int>中的Push和Pop方法只操作int值,如果向堆栈中压入其他类型的值将会得到编译期间的错误,而且取回一个值时不必将它显示转换为原类型。
泛型可以提供强类型,这意味着例如向一个Customer对象的堆栈上压入一个int将会产生错误。这是因为Stack<int>只能操作int值,而Stack<Customer>也只能操作Customer对象。下面例子中的最后两行会导致编译器报错:
Stack<Customer> stack = new Stack<Customer>();
stack.Push(new Customer());
Customer c = stack.Pop();
stack.Push(3); // 类型不匹配错误
int x = stack.Pop(); // 类型不匹配错误
泛型类型的声明允许任意数目的类型参数。上面的Stack<T>例子只有一个类型参数,但一个泛型的Dictionary类可能有两个类型参数,一个是键的类型另一个是值的类型:
public class Dictionary<K,V>
{
public void Add(K key, V value) {...}
public V this[K key] {...}
}
使用Dictionary<K,V>时,需要提供两个类型参数:
Dictionary<string,Customer> dict = new Dictionary<string,Customer>();
dict.Add("Peter", new Customer());
Customer c = dict["Peter"];
1.1.3 泛型类型实例化
和非泛型类型类似,编译过的泛型类型也由中间语言(IL, Intermediate Language)指令和元数据表示。泛型类型的IL表示当然已由类型参数进行了编码。
当程序第一次建立一个已构造的泛型类型的实例时,如Stack<int>,.NET公共语言运行时中的即时编译器(JIT, just-in-time)将泛型IL和元数据转换为本地代码,并在进程中用实际类型代替类型参数。后面的对这个以构造的泛型类型的引用使用相同的本地代码。从泛型类型建立一个特定的构造类型的过程称为泛型类型实例化(generic type instantiation)。
.NET公共语言运行时为每个由之类型实例化的泛型类型建立一个专门的拷贝,而所有的引用类型共享一个单独的拷贝(因为,在本地代码级别上,引用知识具有相同表现的指针)。
1.1.4 约束
通常,一个泛型类不会只是存贮基于某一类型参数的数据,他还会调用给定类型的对象的方法。例如,Dictionary<K,V>中的Add方法可能需要使用CompareTo方法来比较键值:
public class Dictionary<K,V>
{
public void Add(K key, V value)
{
...
if (key.CompareTo(x) < 0) {...} // 错误,没有CompareTo方法
...
}
}
由于指定的类型参数K可以是任何类型,可以假定存在的参数key具有的成员只有来自object的成员,如Equals、GetHashCode和ToString;因此上面的例子会发生编译错误。当然可以将参数key转换成为一具有CompareTo方法的类型。例如,参数key可以转换为IComparable:
public class Dictionary<K,V>
{
public void Add(K key, V value)
{
...
if (((IComparable)key).CompareTo(x) < 0) {...}
...
}
}
当这种方案工作时,会在运行时引起动态类型转换,会增加开销。更要命的是,它还可能将错误报告推迟到运行时。如果一个键没有实现IComparable接口,会抛出InvalidCastException异常。
为了提供更强大的编译期间类型检查和减少类型转换,C#允许一个可选的为每个类型参数提供的约束(constraints)列表。一个类型参数的约束指定了一个类型必须遵守的要求,使得这个类型参数能够作为一个变量来使用。约束由关键字where来声明,后跟类型参数的名字,再后是一个类或接口类型的列表,或构造器约束new()。
要想使Dictionary<K,V>类能保证键值始终实现了IComparable接口,类的声明中应该对类型参数K指定一个约束:
public class Dictionary<K,V> where K: IComparable
{
public void Add(K key, V value)
{
...
if (key.CompareTo(x) < 0) {...}
...
}
}
通过这个声明,编译器能够保证所有提供给类型参数K的类型都实现了IComparable接口。进而,在调用CompareTo方法前不再需要将键值显式转换为一个IComparable接口;一个受约束的类型参数类型的值的所有成员都可以直接使用。
对于给定的类型参数,可以指定任意数目的接口作为约束,但只能指定一个类(作为约束)。每一个被约束的类型参数都有一个独立的where子句。在下面的例子中,类型参数K有两个接口约束,而类型参数E有一个类约束和一个构造器约束:
public class EntityTable<K,E>
where K: IComparable<K>, IPersistable
where E: Entity, new()
{
public void Add(K key, E entity)
{
...
if (key.CompareTo(x) < 0) {...}
...
}
}
上面例子中的构造器约束,new(),保证了作为的E类型变量的类型具有一个公共、无参的构造器,并允许泛型类使用new E()来建立该类型的一个实例。
类型参数约束的使用要小心。尽管它们提供了更强大的编译期间类型检查并在一些情况下改进了性能,它还是限制了泛型类型的使用。例如,一个泛型类List<T>可能约束T实现IComparable接口以便Sort方法能够比较其中的元素。然而,这么做使List<T>不能用于那些没有实现IComparable接口的类型,尽管在这种情况下Sort方法从来没被实际调用过。
1.1.5 泛型方法
有的时候一个类型参数并不是整个类所必需的,而只用于一个特定的方法中。通常,这种情况发生在建立一个需要一个泛型类型作为参数的方法时。例如,在使用前面描述过的Stack<T>类时,一种公共的模式就是在一行中压入多个值,如果写一个方法通过单独调用它类完成这一工作会很方便。对于一个特定的构造过的类型,如Stack<int>,这个方法看起来会是这样:
void PushMultiple(Stack<int> stack, params int[] values) {
foreach (int value in values) stack.Push(value);
}
这个方法可以用于将多个int值压入一个Stack<int>:
Stack<int> stack = new Stack<int>();
PushMultiple(stack, 1, 2, 3, 4);
然而,上面的方法只能工作于特定的构造过的类型Stack<int>。要想使他工作于任何Stack<T>,这个方法必须写成泛型方法(generic method)。一个泛型方法有一个或多个类型参数,有方法名后面的“<”和“>”限定符指定。这个类型参数可以用在参数列表、返回至和方法体中。一个泛型的PushMultiple方法看起来会是这样:
void PushMultiple<T>(Stack<T> stack, params T[] values) {
foreach (T value in values) stack.Push(value);
}
使用这个方法,可以将多个元素压入任何Stack<T>中。当调用一个泛型方法时,要在函数的调用中将类型参数放入尖括号中。例如:
Stack<int> stack = new Stack<int>();
PushMultiple<int>(stack, 1, 2, 3, 4);
这个泛型的PushMultiple方法比上面的版本更具可重用性,因为它能工作于任何Stack<T>,但这看起来并不舒服,因为必须为T提供一个类型参数。然而,很多时候编译器可以通过传递给方法的其他参数来推断出正确的类型参数,这个过程称为类型推断(type inferencing)。在上面的例子中,由于第一个正式的参数的类型是Stack<int>,并且后面的参数类型都是int,编译器可以认定类型参数一定是int。因此,在调用泛型的PushMultiple方法时可以不用提供类型参数:
Stack<int> stack = new Stack<int>();
PushMultiple(stack, 1, 2, 3, 4);
1.2 匿名方法
实践处理方法和其他回调方法通常需要通过专门的委托来调用,而不是直接调用。因此,迄今为止我们还只能将一个实践处理和回调的代码放在一个具体的方法中,再为其显式地建立委托。相反,匿名方法(anonymous methods)允许将与一个委托关联的代码“内联(in-line)”到使用委托的地方,我们可以很方便地将代码直接写在委托实例中。除了看起来舒服,匿名方法还共享对本地语句所包含的函数成员的访问。如果想在命名方法(区别于匿名方法)中达成这种共享,需要手动创建一个辅助类并将本地成员“提升(lifting)”到这个类的域中。
下面的例子展示了从一个包含一个列表框、一个文本框和一个按钮的窗体中获取一个简单的输入。当按钮按下时文本框中的文本会被添加到列表框中。
class InputForm: Form
{
ListBox listBox;
TextBox textBox;
Button addButton;
public MyForm() {
listBox = new ListBox(...);
textBox = new TextBox(...);
addButton = new Button(...);
addButton.Click += new EventHandler(AddClick);
}
void AddClick(object sender, EventArgs e) {
listBox.Items.Add(textBox.Text);
}
}
尽管对按钮的Click事件的响应只有一条语句,这条语句也必须放到一个独立的具有完整的参数列表的方法中,并且要手动创建引用该方法的EventHandler委托。使用匿名方法,事件处理的代码会变得更加简洁:
class InputForm: Form
{
ListBox listBox;
TextBox textBox;
Button addButton;
public MyForm() {
listBox = new ListBox(...);
textBox = new TextBox(...);
addButton = new Button(...);
addButton.Click += delegate {
listBox.Items.Add(textBox.Text);
};
}
}
一个匿名方法由关键字delegate和一个可选的参数列表组成,并将语句放入“{”和“}”限定符中。前面例子中的匿名方法没有使用提供给委托的参数,因此可以省略参数列表。要想访问参数,你名方法应该包含一个参数列表:
addButton.Click += delegate(object sender, EventArgs e) {
MessageBox.Show(((Button)sender).Text);
};
上面的例子中,在匿名方法和EventHandler委托类型(Click事件的类型)之间发生了一个隐式的转换。这个隐式的转换是可行的,因为这个委托的参数列表和返回值类型和匿名方法是兼容的。精确的兼容规则如下:
? 当下面条例中有一条为真时,则委托的参数列表和匿名方法是兼容的:
o 匿名方法没有参数列表且委托没有输出(out)参数。
o 匿名方法的参数列表在参数数目、类型和修饰符上与委托参数精确匹配。
? 当下面的条例中有一条为真时,委托的返回值与匿名方法兼容:
o 委托的返回值类型是void且匿名方法没有return语句或其return语句不带任何表达式。
o 委托的返回值类型不是void但和匿名方法的return语句关联的表达式的值可以被显式地转换为委托的返回值类型。
只有参数列表和返回值类型都兼容的时候,才会发生匿名类型向委托类型的隐式转换。
下面的例子使用了匿名方法对函数进行了“内联(in-lian)”。匿名方法被作为一个Function委托类型传递。
using System;
delegate double Function(double x);
class Test
{
static double[] Apply(double[] a, Function f) {
double[] result = new double[a.Length];
for (int i = 0; i < a.Length; i++) result[i] = f(a[i]);
return result;
}
static double[] MultiplyAllBy(double[] a, double factor) {
return Apply(a, delegate(double x) { return x * factor; });
}
static void Main() {
double[] a = {0.0, 0.5, 1.0};
double[] squares = Apply(a, delegate(double x) { return x * x; });
double[] doubles = MultiplyAllBy(a, 2.0);
}
}
Apply方法需要一个给定的接受double[]元素并返回double[]作为结果的Function。在Main方法中,传递给Apply方法的第二个参数是一个匿名方法,它与Function委托类型是兼容的。这个匿名方法只简单地返回每个元素的平方值,因此调用Apply方法得到的double[]包含了a中每个值的平方值。
MultiplyAllBy方法通过将参数数组中的每一个值乘以一个给定的factor来建立一个double[]并返回。为了产生这个结果,MultiplyAllBy方法调用了Apply方法,向它传递了一个能够将参数x与factor相乘的匿名方法。
如果一个本地变量或参数的作用域包括了匿名方法,则该变量或参数称为匿名方法的外部变量(outer variables)。在MultiplyAllBy方法中,a和factor就是传递给Apply方法的匿名方法的外部变量。通常,一个局部变量的生存期被限制在块内或与之相关联的语句内。然而,一个被捕获的外部变量的生存期要扩展到至少对匿名方法的委托引用符合垃圾收集条件时。
1.2.1 方法组转换
像前面章节中描述过的那样,一个匿名方法可以被隐式转换为一个兼容的委托类型。C# 2.0允许对一组方法进行相同的转换,即所任何时候都可以省略一个委托的显式实例化。例如,下面的语句:
addButton.Click += new EventHandler(AddClick);
Apply(a, new Function(Math.Sin));
还可以写做:
addButton.Click += AddClick;
Apply(a, Math.Sin);
当使用短形式时,编译器可以自动地推断应该实例化哪一个委托类型,不过除此之外的效果都和长形式相同。
1.3 迭代器
C#中的foreach语句用于迭代一个可枚举(enumerable)的集合中的元素。为了实现可枚举,一个集合必须要有一个无参的、返回枚举器(enumerator)的GetEnumerator方法。通常,枚举器是很难实现的,因此简化枚举器的任务意义重大。
迭代器(iterator)是一块可以产生(yields)值的有序序列的语句块。迭代器通过出现的一个或多个yield语句来区别于一般的语句块:
? yield return语句产生本次迭代的下一个值。
? yield break语句指出本次迭代完成。
只要一个函数成员的返回值是一个枚举器接口(enumerator interfaces)或一个可枚举接口(enumerable interfaces),我们就可以使用迭代器:
? 所谓枚举器借口是指System.Collections.IEnumerator和从System.Collections.Generic.IEnumerator<T>构造的类型。
? 所谓可枚举接口是指System.Collections.IEnumerable和从System.Collections.Generic.IEnumerable<T>构造的类型。
理解迭代器并不是一种成员,而是实现一个功能成员是很重要的。一个通过迭代器实现的成员可以用一个或使用或不使用迭代器的成员覆盖或重写。
下面的Stack<T>类使用迭代器实现了它的GetEnumerator方法。其中的迭代器按照从顶端到底端的顺序枚举了栈中的元素。
using System.Collections.Generic;
public class Stack<T>: IEnumerable<T>
{
T[] items;
int count;
public void Push(T data) {...}
public T Pop() {...}
public IEnumerator<T> GetEnumerator() {
for (int i = count – 1; i >= 0; --i) {
yield return items[i];
}
}
}
GetEnumerator方法的出现使得Stack<T>成为一个可枚举类型,这允许Stack<T>的实例使用foreach语句。下面的例子将值0至9压入一个整数堆栈,然后使用foreach循环按照从顶端到底端的顺序显示每一个值。
using System;
class Test
{
static void Main() {
Stack<int> stack = new Stack<int>();
for (int i = 0; i < 10; i++) stack.Push(i);
foreach (int i in stack) Console.Write("{0} ", i);
Console.WriteLine();
}
}
这个例子的输出为:
9 8 7 6 5 4 3 2 1 0
语句隐式地调用了集合的无参的GetEnumerator方法来得到一个枚举器。一个集合类中只能定义一个这样的无参的GetEnumerator方法,不过通常可以通过很多途径来实现枚举,包括使用参数来控制枚举。在这些情况下,一个集合可以使用迭代器来实现能够返回可枚举接口的属性和方法。例如,Stack<T>可以引入两个新的属性——IEnumerable<T>类型的TopToBottom和BottomToTop:
using System.Collections.Generic;
public class Stack<T>: IEnumerable<T>
{
T[] items;
int count;
public void Push(T data) {...}
public T Pop() {...}
public IEnumerator<T> GetEnumerator() {
for (int i = count – 1; i >= 0; --i) {
yield return items[i];
}
}
public IEnumerable<T> TopToBottom {
get {
return this;
}
}
public IEnumerable<T> BottomToTop {
get {
for (int i = 0; i < count; i++) {
yield return items[i];
}
}
}
}
TopToBottom属性的get访问器只返回this,因为堆栈本身就是一个可枚举类型。BottomToTop属性使用C#迭代器返回了一个可枚举接口。下面的例子显示了如何使用这两个属性来以任意顺序枚举栈中的元素:
using System;
class Test
{
static void Main() {
Stack<int> stack = new Stack<int>();
for (int i = 0; i < 10; i++) stack.Push(i);
foreach (int i in stack.TopToBottom) Console.Write("{0} ", i);
Console.WriteLine();
foreach (int i in stack.BottomToTop) Console.Write("{0} ", i);
Console.WriteLine();
}
}
当然,这些属性还可以用在foreach语句的外面。下面的例子将调用属性的结果传递给一个独立的Print方法。这个例子还展示了一个迭代器被用作一个带参的FromToBy方法的方法体:
using System;
using System.Collections.Generic;
class Test
{
static void Print(IEnumerable<int> collection) {
foreach (int i in collection) Console.Write("{0} ", i);
Console.WriteLine();
}
static IEnumerable<int> FromToBy(int from, int to, int by) {
for (int i = from; i <= to; i += by) {
yield return i;
}
}
static void Main() {
Stack<int> stack = new Stack<int>();
for (int i = 0; i < 10; i++) stack.Push(i);
Print(stack.TopToBottom);
Print(stack.BottomToTop);
Print(FromToBy(10, 20, 2));
}
}
这个例子的输出为:
9 8 7 6 5 4 3 2 1 0
0 1 2 3 4 5 6 7 8 9
10 12 14 16 18 20
泛型和非泛型的可枚举接口都只有一个单独的成员,一个无参的GetEnumerator方法,它返回一个枚举器接口。一个可枚举接口很像一个枚举器工厂(enumerator factory)。每当调用了一个正确地实现了可枚举接口的类的GetEnumerator方法时,都会产生一个独立的枚举器。
using System;
using System.Collections.Generic;
class Test
{
static IEnumerable<int> FromTo(int from, int to) {
while (from <= to) yield return from++;
}
static void Main() {
IEnumerable<int> e = FromTo(1, 10);
foreach (int x in e) {
foreach (int y in e) {
Console.Write("{0,3} ", x * y);
}
Console.WriteLine();
}
}
}
上面的代码打印了一个从1到10的简单乘法表。注意FromTo方法只调用了一次用来产生可枚举接口e。而e.GetEnumerator()被调用了多次(通过foreach语句)来产生多个相同的枚举器。这些枚举器都封装了FromTo声明中指定的代码。注意,迭代其代码改变了from参数。不过,枚举器是独立的,因为对于from参数和to参数,每个枚举器拥有它自己的一份拷贝。在实现可枚举类和枚举器类时,枚举器之间的过渡状态(一个不稳定状态)是必须消除的众多细微瑕疵之一。C#中的迭代器的设计可以帮助消除这些问题,并且可以用一种简单的本能的方式来实现健壮的可枚举类和枚举器类。
1.4 不完全类型
尽管在一个单独的文件中维护一个类型的所有代码是一项很好的编程实践,但有些时候,当一个类变得非常大,这就成了一种不切实际的约束。而且,程序员经常使用代码生成器来生成一个应用程序的初始结构,然后修改产生的代码。不幸的是,当以后需要再次发布原代码的时候,现存的修正会被重写。
不完全类型允许类、结构和接口被分成多个小块儿并存贮在不同的源文件中使其容易开发和维护。另外,不完全类型可以分离机器产生的代码和用户书写的部分,这使得用工具来加强产生的代码变得容易。
要在多个部分中定义一个类型的时候,我们使用一个新的修饰符——partial。下面的例子在两个部分中实现了一个不完全类。这两个部分可能在不同的源文件中,例如第一部分可能是机器通过数据库影射工具产生的,而第二部分是手动创作的:
public partial class Customer
{
private int id;
private string name;
private string address;
private List<Order> orders;
public Customer() {
...
}
}
public partial class Customer
{
public void SubmitOrder(Order order) {
orders.Add(order);
}
public bool HasOutstandingOrders() {
return orders.Count > 0;
}
}
当上面的两部分编译到一起时,产生的代码就好像这个类被写在一个单元中一样:
public class Customer
{
private int id;
private string name;
private string address;
private List<Order> orders;
public Customer() {
...
}
public void SubmitOrder(Order order) {
orders.Add(order);
}
public bool HasOutstandingOrders() {
return orders.Count > 0;
}
}
不完全类型的所有部分必须放到一起编译,才能在编译期间将它们合并。需要特别注意的是,不完全类型并不允许扩展已编译的类型。