C#的变迁史 - C# 2.0篇
在此重申一下,本文仅代表个人观点,如有不妥之处,还请自己辨别。
第一代的值类型装箱与拆箱的效率极其低下,特别是在集合中的表现,所以第二代C#重点解决了装箱的问题,加入了泛型。
1. 泛型 - 珍惜生命,远离装箱
集合作为通用的容器,为了兼容各种类型,不得已使用根类Object作为成员类型,这在C#1.0中带来了很大的装箱拆箱问题。为了C#光明的前途,这个问题必须要解决,而且要解决好。
C++模板是一个有用的启迪,虽然C++模板的运行机制不一样,但是思路确实是正确的。
带有形参的类型,也就是C#中的泛型,作为一种方案,解决了装箱拆箱,类型安全,重用集合的功能,防止具有相似功能的类泛滥等问题。泛型最大的战场就是在集合中,以List<T>,Queue<T>,Stack<T>等泛型版本的集合基本取代了第一代中非泛型版本集合的使用场合。当然除了在集合中,泛型在其他的地方也有广泛的用途,因为程序员都是懒的,重用和应对变化是计算机编程技术向前发展最根本的动力。
为了达到类型安全(比如调用的方法要存在),就必须有约定。本着早发现,早解决的思路,在编译阶段能发现的问题最好还是在编译阶段就发现,所以泛型就有了约束条件。泛型的约束常见的是下面几种:
a. 构造函数约束(使用new关键字),这个约束要求实例化泛型参数的时候要求传入的类必须有公开的无参构造函数。
b. 值类型约束(使用struct关键字),这个约束要求实例化泛型参数的类型必须是值类型。
c. 引用类型约束(使用class关键字),这个约束要求实例化泛型参数的类型必须是引用类型。
d. 继承关系约束(使用具体制定的类或接口),这个约束要求实例化泛型参数的类型必须是指定类型或是其子类。
当然了,泛型参数的约束是可以同时存在多个的,参看下面的例子:
public class Employee { } class MyList<T, V> where T: Employee, IComparable<T> where V: new() { }
如果不指定约束条件,那么默认的约束条件是Object,这个就不多讲了。
当使用泛型方法的时候,需要注意,在同一个对象中,泛型版本与非泛型版本的方法如果编译时能明确关联到不同的定义是构成重载的。例如:
public void Function1<T>(T a); public void Function1<U>(U a); 这样是不能构成泛型方法的重载。因为编译器无法确定泛型类型T和U是否不同,也就无法确定这两个方法是否不同 public void Function1<T>(int x); public void Function1(int x); 这样可以构成重载 public void Function1<T>(T t) where T:A; public void Function1<T>(T t) where T:B; 这样不能构成泛型方法的重载。因为编译器无法确定约束条件中的A和B是否不同,也就无法确定这两个方法是否不同
使用泛型就简单了,直接把类型塞给形参,然后当普通的类型使用就可以了。例如:
List<int> ages = new List<int>(); ages.Add(0); ages.Add(1); ages.Remove(1);
2. 匿名函数delegate
在C# 2.0中,终于实例化一个delegate不再需要使用通用的new方式了。使用delegate关键字就可以直接去实例化一个delegate。这种没有名字的函数就是匿名函数。这个不知道是不是语法糖的玩意儿使用起来确实比先定义一个函数,然后new实例的方式要方便。不过最方便的使用方式将在下一版中将会到来。
delegate void TestDelegate(string s); static void M(string s) { Console.WriteLine(s); } //C# 1.0的方式 TestDelegate testDelA = new TestDelegate(M); //C# 2.0 匿名方法 TestDelegate testDelB = delegate(string s) { Console.WriteLine(s); };
谈到匿名函数,不得不说说闭包的概念。
如果把函数的工作范围比作一个监狱的话,函数内定义的变量就都是监狱中的囚犯,它们只能在这个范围内工作。一旦方法调用结束了,CLR就要回收线程堆栈空间,恢复函数调用前的现场;这些在函数中定义的变量就全部被销毁或者待销毁。但是有一种情况是不一样的,那就是某个变量的工作范围被人为的延长了,通俗的讲就像是某囚犯越狱了,它的工作范围超过了划定的监狱范围,这个时候它的生命周期就延长了。
闭包就是使用函数作为手段延长外层函数中定义的变量的作用域和生命周期的现象,作为手段的这个函数就是闭包函数。看一个例子:
class Program { static void Main(string[] args) { List<Action> actions = getActions(); foreach (var item in actions) { item.Invoke(); } } static List<Action> getActions() { List<Action> actions = new List<Action>(); for (int i = 0; i < 5; i++) { Action item = delegate() { Console.WriteLine(i); }; actions.Add(item); } return actions; } }
你可以试试运行这个例子,结果和你预想的一致吗?这个例子会输出5个5,而不是0到4,出现这个现象的原因就是闭包。getActions函数中的变量i被匿名函数引用了,它在getActions调用结束后还会一直存活到匿名函数执行结束。但是匿名函数是后面才调用的,执行它们的时候,i早就循环完毕,值是5,所以最终所有的匿名函数执行结果都是输出5,这是由闭包现象导致的一个bug。
要想修复这个由闭包导致的问题,方法基本上是破坏闭包引用,方式多种多样,下面是简单的利用值类型的深拷贝实现目的。
第一个方法:让闭包引用不再指向同一个变量
for (int i = 0; i < 5; i++) { int j = i; Action item = delegate() { Console.WriteLine(j); }; actions.Add(item); }
第二个方法:包上一层函数来构造新的作用域
static List<Action> getActions() { List<Action> actions = new List<Action>(); for (int i = 0; i < 5; i++) { Action item = ActionMethod(i); actions.Add(item); } return actions; } static Action ActionMethod(int p) { return delegate() { Console.WriteLine(p); }; }
闭包现象提醒我们使用匿名函数和3.0中的Lambda表达式时都要时刻注意变量的来源。
3. 迭代器
在C# 1.0中,集合实现迭代器模式是需要实现IEnumerable的,这个大家还记得吧,这个接口的核心就是GetEnumerator方法。实现这个接口主要是为了得到Enumerator对象,然后通过其提供的方法遍历集合(主要是Current属性和MoveNext方法)。自己去实现这些还是比较麻烦的,先需要定义一个Enumerator对象,然后在自定义的集合对象中还需要实现IEnumerable接口返回定义的Enumerator对象,于是一个新的语法糖就出现了: yield关键字。
在C# 2.0中,只需要在自定义的集合对象中还需要实现IEnumerable接口返回一个Enumerator对象就行了,这个创建Enumerator对象的工作就由编译器自己完成了。看一个简单的小例子:
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]; } } }
使用yield return创建一个Enumerator对象是不是很方便?编译器遇到yield return会创建一个Enumerator对象并自动维护这个对象。
当然了,多数时候foreach必要遍历集合中的每一个元素,这个时候使用yield return配合for循环枚举每个元素就可以了,但是有时候只需要返回满足条件的部分元素,这个时候就要结合yield break中断枚举了,看一下:
//使用yield break中断迭代: for(int i=count-1;i>=0;--i) { yield return items[i]; if(items[i]>10) { yield break; } }
4. 可空类型
这个特性我觉得又是把值类型设计成引用类型Object类子类后,微软生产的怪语法。空值是引用类型的默认值,0值是值类型的默认值。那么在某些场合,比如从数据库中的记录取到内存中以后,没有值代表的是空值,但是字段的类型却是值类型,怎么搞呢?于是整出了可空类型。当然了,这个问题可以通过在设计表的时候给字段设计一个默认值来解决,但是有的时候某些字段的设置默认值是没有意义的,比如年龄,0有意义吗?
可空类型的概念很简单,没什么可说的,不过一个相似的语法却让我感到很舒服:那就是"??"操作符。这是一个二元操作符,如果第一个操作数是空值,则执行第二个操作数代表的操作,并返回其结果。例如:
static void Main(string[] args) { int? age = null; age = age ?? 10; Console.WriteLine(age); }
5. 部分类与部分方法
这一特性还是比较好用的,终于不用把所有的内容挤到一起了,终于可以申明和实现相分离了,虽然好像以前也可以做到,但是现在这项权利也下放给人民群众了。partial关键字带来了这一切,也带来了一定的扩展性。这个特性也比较简单,就是使用partial关键字。编译的时候,这些文件中定义的部分类会被合并。部分方法是3.0的特性,不过没什么新意,就放到2.0一起说吧。
使用这个特性的时候需要注意:
a. 部分方法只能在部分类中定义。
b. 部分类和部分方法的签名应该是一致的。
c. partial用在类型上的时候只能出现在紧靠关键字 class、struct 或 interface 前面的位置。
d. public等访问修饰符必须出现在partial前面。
f. partial定义的东西必须是在同一程序集和模块中。
看一个简单的例子:
// File1.cs namespace PC { partial class A { int num = 0; void MethodA() { } partial void MethodC(); } } // File2.cs namespace PC { partial class A { void MethodB() { } partial void MethodC() { } } }
这里需要注意一下MethodC的申明和定义是分开的就可以了。
还有一点,很多人认为partial破坏了类的封装性,实际上谈不上。因为一个类能分部,就说明类的设计者认为是需要保留这个扩展性的,所以后面的人才可以给这个类添加一些新的东西。
6. 静态类
这个特性也是比较符合实际情况的,很多情况下,某些对象只需要实例化一次,然后到处使用,单件模式是可以实现这个目的,现在静态类也是一个新的选择。
静态类只能含有静态成员,所以构造函数也是静态的,既然是静态的,那么它与继承就没什么关系了。静态类从首次调用的时候创建,一直到程序结束时销毁。
简单看一个小例子:
public static class A { static string message = "Message"; static A() { Console.WriteLine("Initialize!"); } public static void M() { Console.WriteLine(message); } }
不过据经验讲,有没有静态构造函数对静态类的构造时间是有影响的,一个是出现在首次使用对象成员的时候,一个是程序集加载的时候,不过分清这个实在没什么意义,有兴趣的同学自己研究吧。
C#2.0的新特性绝不止这几个,但是对程序猿们影响比较大的都在这了,更多的就参看微软的MSDN吧。