让泛型的思维扎根在脑海——深刻理解泛型
1.前言
往往一些刚接触C#编程的初学者,对于泛型的认识就是直接跳到对泛型集合的使用上,虽然微软为我们提供了很多内置的泛型类型,但是如果我们只是片面的了解调用方式,这会导致我们对泛型盲目的使用。至于为什么要使用泛型,什么情况下定义属于自己的泛型,定义泛型又能为程序带来哪些好处。要理清这些问题,我们就必须深刻理解泛型的本质,形成泛型编程的思维方式。
接下来我将基于一个基础示例,然后通过需求不断的演化示例,从而让泛型在关键时刻脱颖而出,以便让我们能够深刻体会泛型的作用。假设.NET没有为我们提供用于存储数据的集合,而我们需要一个能够用于存储string元素的集合,基于这个情况我们自定义了一个用于存储字符串的集合类:
class ArraryStr
{
public ArraryStr()
{
_items = new string[100]; //初始化存储元素的容量,只是为了演示故将容量定义为固定值
}
private string[] _items; //存储元素的数组
private int _count; //元素总数
public int Count
{
get { return _count; }
}
public void Add(string item) //新增元素
{
_items[_count] = item;
_count++;
}
public string this[int index] //索引
{
get { return _items[index]; }
set { _items[index] = value; }
}
} // END ArraryStr
为了验证自定义string集合的可行性,我们对其进行了如下的应用:
1 ArraryStr arraryStr = new ArraryStr();
2 arraryStr.Add("张三");
3 Console.WriteLine(arraryStr[0]);
2.重复
目前对于创建string类型的集合已经大功告成,而此刻我们又接到了一个新的需求,即我们需要一个集合存储int类型的元素。基于自定义string集合的经验来看,我们可以发现,string集合类型和我们即将要创建的int集合类型的结构和内容几乎是一样的。这就意味着我们可以使用江湖盛行的“复制大法”,将之前的代码复制一遍,然后轻微修改下即可。下面是两个集合类型代码的对比图。
在早年有款热门的游戏叫做“大家来找茬”,该游戏主要玩法就是在两个大致相同的图片中,查找两者之间的细微差异之处。我们使用的“复制大法”,促使我们编写的代码形成了可以用于这个游戏游玩的场景。“对于上面的两个代码截图,你能找出图中不同的地方吗?”
对于软件开发者而言,面对的最主要的敌人就是“变化”,假设后面还会出现N个类型的元素需要我们定义集合来存储,那我们是不是要将相同的代码无穷尽的复制下去?DRY(Don't Repeat Yourself,不要重复自己),请记住这是作为一名软件开发者编码的原则,“复制大法”很明显的违背了这个原则。
3.安全和性能
通过“复制,粘贴”的手段可以很明显的感受到我们在做重复的事情,在重复中我们可以发现:集合存储的类型在增加,但是集合的结构和添加元素的方法都是相同的逻辑。简单来说就是,不同类型的处理,其处理逻辑都是类似的。基于这个特点,为了满足自定义集合能够应对所有类型的存储,我们必须使用一个通用类型来作为代表,此时此刻我们脑海中就能浮现出一句话:object是一切类型的基类。这就意味着我们添加的所有类型,都可以隐式的转换为object类型,从而使得自定义集合可以添加任何类型的元素。让我们来运用这个object类型来试试:
class ArraryList
{
public ArraryList() { _items = new object[100]; }
private object[] _items;
private int _count;
public int Count
{
get { return _count; }
}
public void Add(object item)
{
_items[_count] = item;
_count++;
}
public object this[int index]
{
get { return _items[index]; }
set { _items[index] = value; }
}
} // END ArraryStr
internal class Program
{
static void Main(string[] args)
{
ArraryList arraryList = new ArraryList();
arraryList.Add("张三");
arraryList.Add(18);
string name = (string)arraryList[0];
int age = (int)arraryList[1];
} // END Main()
}
在上面的代码中,我们结合了object是一切类型基类的特点,对集合类型进行改造,并成功的使用该方式的集合添加了不同类型的元素。虽然在使用的角度来看已经完美无缺(可以添加任何类型),但是获取集合元素进行赋值的时候,还使用了类型强制转换的手段。这是因为这种方式存在很严重的问题,主要包括以下两个方面:
- 类型安全方面,如果集合的第一个元素是sting类型,但是你客观认为是int类型,于是你在获取时进行了int类型的强制转换,这个时候代码不会提示错误且可以正常编译,那么这就意味着程序在运行时会产生一个你无法预料的类型无效转换的异常。
- 性能方面,值类型元素添加到集合时,必然会存在装箱操作;而在获取元素并赋值给一个值类型变量时,又会发生相应的拆箱操作。这种拆箱和装箱的操作,在操作大量元素时会大幅度的损失程序的性能。
到目前位置,我们还是没有能创建一个能够存储任何类型的集合,但是我们可以对于上述的示例演变的过程进行一个总结:对于不同类型有相同处理逻辑的情况,如果一味的复制会导致我们出现重复代码,如果使用object来作为解决重复的方案,会存在类型安全和性能的问题。至于如何让彻底解决这些问题,这就要说到了本文讲解的主题——泛型。
4.代码模板
C#中有两种不同的机制来编写跨类型(一个类型代替多个类型)可复用的代码:继承和泛型。继承的复用性来自于基类,而泛型的复用性是通过带有“占位符”的代码模板类型实现的。继承实现复用是站在面向对象的角度思考的,而泛型的复用是站在实现特定功能上思考的。相比于继承,泛型不用遵循里氏替换原则,并且能够提高类型的安全性,减少类型转换带来的拆箱和装箱。
怎么样理解泛型?泛型本质上相当于一种“代码模板”,可以用一套代码,为不同类型的同一逻辑使用统一的方式实现。其中“模板”一词的概念需要进行深刻的体会。例如,公司在招聘时会与用人方签订劳动合同,而这个劳动合同的主要内容对于所有人来说几乎都是一样的,只是在极个别的地方有所差异,如薪资、姓名等。所以公司不会为某个人(张三或李四)去特意的制定合同,而是会统一制定一份劳动合同作为模板,将其中针对个人存在差异的部分通过“下划线”进行占位预留,“下划线”的值将在签订合同时由具体的聘用者根据自身情况填写。
对于这种模板方式的使用,公司在制定合同时则不用考虑签订合同的人具体是谁,因为劳动合同(模板)和使用者是分开的,所以公司只用专注于合同的主要内容即可。而我们在实际的编程运用中,使用泛型的目的,其实和公司制定通用的劳动合同模板是一个道理。假设你的公司需要雇佣100名员工时,你不希望为每一个人都制定一个专属的合同吧?假设你的代码中,如果遇到10个类型,它们的操作处理逻辑都一样时,你不希望为这个10个类型写10个处理方式吧?
通过上面的介绍和例子,接下来我们将泛型运用到我们的示例中来,代码如下:
1 class ArraryList<T>
2 {
3 public ArraryList() { _items = new T[100]; }
4
5 private T[] _items;
6 private int _count;
7 public int Count
8 {
9 get { return _count; }
10 }
11
12 public void Add(T item)
13 {
14 _items[_count] = item;
15 _count++;
16 }
17
18 public T this[int index]
19 {
20 get { return _items[index]; }
21 set { _items[index] = value; }
22 }
23 } // END ArraryStr
24 internal class Program
25 {
26 static void Main(string[] args)
27 {
28 ArraryList<string> arraryStr = new ArraryList<string>();
29 arraryStr.Add("张三");
30 Console.WriteLine(arraryStr[0]);
31
32 ArraryList<int> arraryInt = new ArraryList<int>();
33 arraryInt.Add(18);
34 Console.WriteLine(arraryInt[0]);
35
36 } // END Main()
37
38 }
5.类型参数
在上面的代码中,我们将集合类型定义为了泛型类,该类型中出现的T属于泛型中的类型参数(Type Parameter)。泛型为了达到通用处理的目的,所以不能将某个具体类型作为处理的目标类型,故而将要处理的类型用“T”作为一个类型占位符。
“T”并不是真正的数据类型,它更像是泛型使用的类型蓝图,所以在使用时,泛型类型的消费者必须将一个具体类型作为“类型参数”传递到尖括号内,以此构造一个有明确处理类型的泛型实例。所以我们在外部使用泛型时不能以:“ArraryList<T>list =new ArraryList<T>()”、“T t=new T()”这种方式去实例化泛型类型。另外,“T”本身仅仅是类型参数的名称,它只是代表了类型参数的标识而已,这意味着我们可以使用其他字符来为类型参数命名。
6.替换
通过类型参数的使用我们可以得知,泛型类型代码在静态阶段没有明确的类型,那么在程序运行的时候,它又是如何和使用时指定的“类型参数”进行对接的呢?为了搞清楚这个问题,下面我们来了解下泛型运行时的本质。
我们编写的C#程序在编译后生成的代码,并不是计算机可以直接执行的代码,而是会生成CIL(通用中间语言)代码并包含在程序集中,如果想要生成计算机可执行的代码,则还需要JIT(即时编译器)对CIL代码进行二次编译。然而泛型类型确认其具体类型的时机,就在JIT进行二次编译时,JIT编译的代码如果包含了泛型的内容,那么它会根据泛型类型的消费者指定的类型参数,将CIL中泛型代码中的占位符T替换为一个具体的类型,从而明确当前执行的泛型代码是针对哪个类型来使用的,其中替换的过程是由CLR在运行时进行主导,JIT来实际操作完成的。这个在运行时确认了类型的泛型又被称之为“封闭类型”,反之在运行时确认之前的泛型称为“开放类型”。
泛型使用占位符在运行时替换具体类型的机制,其实和本文中例举劳动合同模板使用“下划线”的方式有同样的思想。在指定劳动合同模板时,对于聘用者的姓名并不能写一个具体的名字,因为模板的目的是为了通用化,所以对于名字采用了“下划线”的方式。当公司与某个具体的人签订合同的时候,劳动合同模板中的下划线将由聘用者根据自身情况填写。回到泛型中其使用思想也是如此,我们使用泛型的目的是为了让多个类型的处理通用化,所以在定义泛型代码的时候并不能指定一个具体类型,故使用类型参数T进行代替,这个类型参数T就相当于劳动合同模板中的“下划线”,当泛型在实际运行的时候,JIT会根据泛型消费者指定的具体类型与占位符T进行替换。