【CSharp高级编程】泛型
前言
我本来之前打算出一个C#
入门到中级的相关文章教程的,但是考虑到太简单了我实在是懒得写了,可以参考很多网上的教程来学习,对于现在的我来说,我更加想记录一下C#
的高级教程,因为对于C#
的高级用法网上的教程并没有太多或者说太详细或者系统,也算是记录我自己的C#
进阶路程。
前排提醒:查看学习本文章,需要掌握一定的C#
基础。
引用说明:部分内容学习自网络和《深入理解C#》
泛型
在C#
入门的时候,就大概说明过泛型的用法和功能,它可以在不需要事先知道要使用的类型,即可以在不同位置表示相同的类型。在C# 2
之前(即.NET 1
),泛型主要用于集合。如今,泛型以及广泛应用到各个部分,其中使用较多的如下几项:
- 集合
- 委托(尤其是
LINQ
中的应用) - 异步代码(
Task<T>
表示该方法将返回一个类型为T
的值) - 可空值类型
如下实例将会从集合角度来展示泛型的优势。
泛型诞生前的集合
在.NET 1
有如下三大类集合:
数组:语言和运行时直接支持数组。数组的大小在初始化的时候就确定了。
普通对象集合:API中的值通过
System.Object
描述。尽管诸如索引器和和foreach
语句这些语言特性可以应用在普通对象结合,但语言和运行时并未对其提供专门的支持。ArrayList
和Hashtable
是更常见的两种对象集合。专用类型集合:API中描述的值具有特定类型,集合只能用于该类型。例如:
StringCollection
是保存字符串的集合,虽然其 API 看起来和ArrayList
类似,但是它只能接收String
类型元素,而不能接收Object
类型。
数组和专用类型集合都属于静态类型,因此 API 可以阻止讲错误类型的值添加到集合中。在集合中取值也无需手动转换类型。
现在假设有一个名为GenerateName
,该方法用于创建一个String
类型的集合,此外还有一个名为PrintNames
的方法,它可以把该集合的所有元素显示出来。我们分别用上面所示的三种集合(数组,ArrayList
以及StringCollection
)来实现,然后对比三者的优劣。
使用数组创建并打印
1 | //创建数组 |
如上代码中,并没有特意使用数组初始化器来创建数组,而是模拟了逐个获取names
元素的场景,例如读取文件内容。另外,在创建数组的时候应当为其创建合适的大小。读文件这种情况就需要事先知道文件中有多少个名字,才能在创建数组的时候为他分配大小。或者采用更复杂的,先创建一个数组,如果初始数组被填满,则创建一个更大的数组,如此反复,直到所有元素添加完毕。当然最后如果数组依然有空间,可以再创建一个合适的数组来讲元素复制过去。
诸如追踪当前集合大小,重新分配数组等重复性操作,都可以用一个类型封装起来,使用ArrayList
即可实现。
使用 ArrayList 创建并打印
1 | //创建ArrayList对象 |
在创建ArrayList
时,无须事先知道names
的个数,因此GenerateNames()
方法得以简化。不过,和数组一样存在一个相同的问题:使用ArrayList
依旧无法确保非String
类型的值被添加进来,因为ArrayList.Add
方法的参数类型是Object
。
此外,PrintNames
方法看似是类型安全的,但是如果该ArrayList
中包含一个WebReaquest
类型的值,由于name
变量声明为string
类型,因此foreach
循环每次都会对集合中的元素做隐式类型转换,把object
转换为string
类型。最终,从WebRequest
到string
类型的转换抛出InvalidCastException
。
虽然使用ArrayList
解决了数组大小的问题,但是缺引出了类型安全的问题,有什么办法可以二者兼顾?
使用 StringCollection 创建并打印
1 | //创建ArrayList对象 |
注:需要引用
using System.Collections.Specialized
命名空间
除了把ArrayList
都替换成了StringCollection
之外,其他都和ArrayList
的代码一致。但是使用了StringCollection
这样与其他通用类型集合无区别,只是其只负责处理String
类型的元素。**StringCollection.Add
方法参数类型是String
**,因此不能向其添加非String
类型的元素,这样就保证了在显示names
的时候不会出现非String
类型的错误了。
如果只是在处理String
类型的情况下,StringCollection
确实是不二之选。但是如果使用的是其他的类型集合,要么就需要寄希望于.NET Framework
已经提供了所需的集合类型,要么就需要自己写一个了。但是由于类型的需求非常普遍,因此就有了System.Collections.CollectionBase
这个抽象类,用于减少上述工作量。
使用专用类型集合可以解决前面提到的两个问题(数组大小和类型安全),但是创建更多的额外类型,代价实在太高了。另外,编译实际,程序集大小,JIT 耗时,代码段内存都会产生额外的内存消耗,最关键的是维护这些集合需要额外的人力成本。
即使忽略上述成本,也没办法避免代码灵活性的降低:无法以静态方式编写适用于所有集合类型的通用方法,也无法把集合元素的类型用于参数或者返回值类型。假设需要创建一个方法,该方法把一个集合的前 N 项元素复制到另一个新的集合中,之后返回该集合。如果使用ArrayList
,那就等同于舍弃了静态类型的优势。如果传入StringCollection
,那么返回值类型也必须是StringCollection
。String
类型成了方法输入的要素,于是返回值也必须是String
类型。**C# 1
对这个问题束手无策,于是泛型登场了。**
泛型诞生
解决上述问题的办法就是泛型List<T>
。List<T>
是一个集合,其中 T 表示集合元素的类型,在如上的例子中,string
就是这个 T ,因此List<string>
就可以替换所有StringCollection
。代码示例:
1 | //创建ArrayList对象 |
List<T>
解决了前面说到的所有问题:
- 与数组不同,**
List<T>
无须在创建前获取集合的大小** - 与
ArrayList
不同,在对外提供的 API 之中,一切表示元素类型都可以用 T 来表示。这样我们就能知道List<string>
的集合只能包含string
类型的引用。如果添加了错误的类型,则编译时会报错。 - 与
StringCollection
等类型不同,**List<T>
兼容所有类型**,省去了诸多烦恼。
类型形参与类型实参
形参(parameter)和实参(argument)的概念,比C#
泛型概念出现的还要早。
声明函数时用于描述函数输入数据的参数称为形参,函数调用时实际传递给函数的参数称为实参。
实参的值相当于方法形参的初始值,而泛型涉及两个参数概念:类型参数(type parameter)和类型实参(type argument),相当于把普通形参和实参的思想用在了表示类型信息上。在声明泛型类或者泛型方法时,需要把类型形参写在类名或者方法名称之后,并用尖括号<>
包围。之后在声明体中,就可以像普通类型一样使用该类型形参了。
1 | //类型形参 |
当使用如上List<string>
时,如果使用其的Add()
方法,其函数原型如下:
1 | public void Add(T item); |
在 Visual Studio 中输入List.Add()
方法,智能补全会提示我们应该是string
类型,当传入不合法类型时,会引发编译错误。
泛型也可以用于方法,在方法声明中给出类型形参,之后就可以在方法中使用这些类型参数了。
如下解决了之前的一个问题,以静态类型的方式把一个集合的前 N 个元素复制到另一个新的集合中。
静态类型如果传入错误的类型编译报错,且不需要进行类型转换
1 | class CopyArray |
运行结果:
同样的,当声明有基类或者接口时,泛型形参也可以作为基类或者接口的泛型实参,比如下的泛型类和泛型接口:
1 | class Generics<T>:IEnumerable<T>{} |
实际上要实现泛型接口,需要实现其成员,不是如上面这么简化
泛型类型和泛型方法的度
泛型类型或者泛型方法可以声明多个类型形参,只需要在尖括号内用逗号把它们隔开即可,代码示例:
1 | class Test<T1, T2>{} |
泛型度(arity)是泛型声明中类型形参的数量。我们可以将非泛型的度理解为零。
泛型度是区分同名泛型声明的有效指标。比如前面提到的C# 2
中的泛型接口IEnumerable<T>
,它和.NET 1.0
中非泛型接口IEnumerable
就不属于同一类型。同样的,可以根据不同度来编写同名重载方法,代码示例:
1 | void Method() { } |
需要注意的是,泛型方法重载的定义区分是根据泛型的度,而不是泛型的名称,代码示例:
1 | //这么写编译器会报错的 |
另外,泛型的形参也是不可以相同的,代码示例:
1 | //两个形参相同,编译器依旧会报错 |
泛型的适用范围
并非所有类型或者类型成员适合用泛型。对于类型,很好区分,因为可供声明的类型比较有限;对于枚举类型不能声明泛型,而类,结构体,接口以及委托这些可以声明为泛型类型。
判断一个声明是否是泛型声明的唯一标准,是看它是否引入了新的类型形参。
方法和类型可以是泛型,当时以下类型成员不能是泛型:
- 字段
- 属性
- 索引器
- 构造器
- 事件
- 终结器
下面举一个貌似是泛型但是实际不是的例如,代码示例:
1 | public class Test<TItem> |
items
是类型为List<TItem>
的一个字段,它将TItem
作为List<T>
的类型实参。TItem
是由Test
类声明引入的类型形参,而不是由items
声明本身引入的。
方法类型实参的类型推断
对于如上类型形参和实参中的代码举例,摘出如下所示:
1 | //复制并返回数组 |
其泛型方法声明原型如下:
1 | public static List<T> CopyArrayNum<T>(List<T> list, int num) |
现在,我们在main
方法中声明一个List<int>
类型的变量numbers
,并将该变量作为如上泛型方法CopyArratNum()
的调用实参(参数)传入,代码示例:
1 | List<int> numbers = new List<int>(); |
如上代码其实可以省略为如下:
1 | List<int> numbers = new List<int>() |
从编译器之后生成的 IL 代码的角度来说,这两种写法是完全相同的。这里并不需要明确指出给定的泛型实参int
,因为编译器可以根据传入的参数来判断泛型类型。
编译器只能推断出传递给方法的类型实参,但是推断不出返回值的类型实参。对于返回值的类型实参,要么显式的表示出来,要么全部省略掉。
尽管类型判断只能用于方法,但是它可以简化泛型类型实例的创建,代码示例:
1 | class Tuple |
如上写法看似没有什么意义。前面说过,泛型类型推断并不适用于构造器,这么做旨在将对象构造的同时利用方法的类型推断。如果直接使用对象构造器实现会比较麻烦,代码示例:
1 | new Tuple<int,string,int>(10,"x",20); |
但是如果使用上述静态方法配合类型推断,代码就会简单很多,代码示例:
1 | Tuple.Create(10,"x",20); |
使用这个技巧可以简化部分代码。
通常来说,使用泛型会遇到如下三种情况:
- 类型推断成功,并且取得预期成果
- 类型推断成功,但是没有取得预期成果。此时,只需要显式指定类型实参或者对某些实参转换类型即可。例如上面的
Tuple.Create
方法,如果目标结果是Tuple<int,object,int>
类型的元组,则显示的指定类型实参:Tuple.Create<int,object,int>(10,"x",20);
或者直接使用构造器new Tuple<int,object,int>(...);
或者调用Tuple.Create(10,(object)"x",20);
。 - 类型推断在编译时出错。有时候只需要转换参数类型就能解决。例如调用
Tuple.Create(null,50)
就会出错,因为null
本身不包含任何类型的信息,改写成Tuple.Create((string)null,50)
即可。
类型约束
前面提到的类型参数都是没有经过约束的,它们可以表示任意类型。有的时候我们需要对于某个类型参数需要它只限于特定的类型,这就引出了类型约束的概念。
在泛型类型或者泛型方法中什么类型形参时,可以使用类型约束来限定哪些类型可以作为类型实参。
假设我们需要一个方法来实现某个功能,该方法存在于IObject
接口里,我们传入参数使用,现在如果我们定义需要传入特定类型的参数如下所示:
1 | public void OutPut(List<Cube> items); |
这样如果有一个Sphere
类型也继承自IObject
接口,但是没有办法传入参数,编译器会报错,但是我们如果使用List<T>
类型则没有办法约束传入的参数是否适合我们的这个方法,这个时候我们就需要使用类型约束就可以将传入的类型实参,代码示例:
1 | staic void OutPut<T>(List<T> items) where T : IObject |
这样就约束了需要传入的类型实参必须是实现继承了IObject
接口的对象。
类型约束不仅仅适用于接口,还可以约束如下类型:
引用类型约束(
where T : class
):类型实参必须是一个引用类型。class
关键词容易引起误解,它表示任何引用类型,包括接口和委托。值类型约束(
where T : struct
):类型实参必须是非可空值类型(结构体类型或者枚举类型)。构造器约束(
where T : new()
):类型实参必须是公共的无参构造器。该约束保证了可以通过new T()
来创建一个T
类型的实例。转换约束(
where T : SomeType
):这里的SomeType
可以是类,接口或者其他类型形参,如上我所使用的IObjecy
类型约束可以组合使用,一般组合规则比较复杂,例如:
1 | void Test<T1,T2>() |
泛型类型初始化与状态
前面说到类型约束的时候,提到过:这样如果有一个Sphere
类型也继承自IObject
接口,但是没有办法传入参数,编译器会报错,因为每个不同泛型类型的对象都会被当作不同类型处理,代码示例:
1 | //定义一个泛型类 |
运行结果:
你会发现对于这两个类,系统将其各自的静态字段num
执行了不同次数的加法,说明Tests<int>
和Tests<string>
本质上是两个类型。
这个问题还可以进一步复杂化,将泛型进行嵌套,代码示例:
1 | class A<T> |
对于上述嵌套,如果使用int
和string
两个作为类型实参,如下的每种组合得到的都是一个独立的对象。
A<int>.B<int>
A<string>.B<int>
A<int>.B<string>
A<string>.B<string>
上述情况基本见不到,但是此处仅作说明表示这在编译器中都是独立的对象。
End
如上就是泛型的全部内容了,我跳过了default
和typeof
关键词的说明,我打算把它们单独写一起会更加的条理化一些。**泛型的出现也引出了可空值类型,即null
**,说实话我不太想去写关于null
即可空值类型的相关部分的,对于这个来说,我更加像写一写关于C#
多线程的说明。