第 72 讲:C# 2 之泛型(六):常见泛型数据类型的使用
前面我们介绍了众多的泛型的相关语法和特性。那么泛型基本上就算是告一段落了。下面我们针对于 C# 2 提供的泛型语法,对 .NET 提供的一些 API 做一个简要的介绍和使用。
Part 1 泛型类和泛型结构
下面我们来学习一下 .NET 里都有哪些好玩的泛型的 API。
本节内容需要你至少知道或对数据结构有一定的了解。如果你对数据结构了解不多,或者是完全没有学过的话,这段内容可能会不知所云。
另外,本节由于篇幅的关系,也只对基本操作做一个介绍。对于复杂的操作,你……还是等着看我的视频吧。
1-1 List<>
顺序表
第一个要说的是 List<>
集合。在之前我们学习了 ArrayList
集合,它类似于一个可变长的序列,因此我们也经常称它为顺序表。可这个顺序表的缺陷在于,它的元素类型是 object
类型接收的,这样会产生和造成装箱行为,导致性能的损失。虽然我们也确实知道,早期 C# 没有泛型机制,所以也不得不这么去处理。但是……自从 C# 2 诞生后,就立马有了 List<>
集合。该集合依然是顺序表,但每一个元素均为同一种数据类型,也就是泛型参数所规定的、给出的类型了。要说这个集合的好处,自然是用到的泛型机制巧妙地避开了装箱行为。换句话说,现在有了 List<>
后,ArrayList
基本上可以说是基本完全可以抛弃了,因为 List<>
集合可以完成 ArrayList
所有的基本功能,而且还能做得更好、更棒。
那么,这个集合的基本用法呢?不瞒你说,这个集合的用法和 ArrayList
可以说是基本完全一样。仍然使用 Add
方法来添加追加元素,Remove
则是删除元素,RemoveAt
则是删除指定索引上的元素,也可以使用索引器运算符 []
来获取指定索引上的元素。
当然,Count
属性确实没有说过,不过也不复杂:它表示该集合的总元素数量。
1-2 LinkedList<>
链表和 LinkedListNode<>
链表节点
是的,学了数据结构的人,最开始就会接触链表。链表是另外一种线性表的实现类型,它采用指针的形式关联相邻的数据,而顺序表则直接用的是数组。对于不关心内存位置的存储情况,我们可以使用链表来完成元素的存储和取值,但是相对地,链表不如顺序表搜索元素来得快:顺序表用数组存储,那么自然元素的索引取值的时间复杂度就是 ,但在链表里,时间复杂度则是
。
使用 AddLast
方法为集合的末尾追加一个节点。与此同时,链表还支持在开头插入节点(AddFirst
方法),在指定节点的前面插入节点(AddBefore
方法)以及在指定节点的后面插入节点(AddAfter
方法)。而我们使用 First
属性获取链表的头节点,而 Last
则是尾节点。注意,这两个属性返回的数据类型是 LinkedListNode<>
而不是数据本身,因此需要再次对该节点实例使用 Value
属性才可以得到最终内的数值。
顺带一说。.NET 里的
LinkedList<>
是双向循环链表,不是单向链表,也不是双向非循环链表。
1-3 Stack<>
栈
如果你想要完成数据结构里的栈,那么很高兴的是,.NET 提供了该数据结构的泛型版本:Stack<>
。
使用 Push
和 Pop
方法可以入栈(压栈)元素和出栈(弹栈)元素,而使用 Peek
方法则可以在不弹栈的情况下获取栈顶的元素。
1-4 Queue<>
队列
同理,既然都有栈了,自然少不了队列。.NET 里也有队列集合 Queue<>
。
我们使用 Enqueue
入队元素,使用 Dequeue
注意队列和栈的存储机制的不同。队列是先入先出表(FIFO),因此先进去的元素会先被读取出来;那么出队的操作和入队操作并不在同一个方向,因此 Peek
方法的结果是 3,因为 Dequque
方法出队的是第一个入队元素 1,因此该集合现在只剩下 3、6、10 三个元素。
1-5 Dictionary<,>
哈希表
哈希表是一个极为复杂的数据结构。在学习数据结构的时候很多人就会因为这个数据结构的理论复杂性而被劝退。还记得哈希码吗?是的,哈希码就是标识同一种数据类型的实例的唯一编码。它好比身份证编号,以保证实例的不一致。
和 Equals
方法的思路一致,它们都是用来比较相等性的,可 Equals
是严格判断,这导致有些时候速度会很慢。哈希码可以通过某个稍微简单一些的公式得到结果。那么同一个数据类型的实例自然就会使用同一套计算规则得到两个哈希码数值。如果哈希码数值一致,我们就可以认为两个实例的数值是相同的了。
虽然,有些时候哈希码是自己实现的,也避免不了极端情况(哈希码相同但实例包含了不同的数值),但按照规范去实现,往往这样的情况遇到的概率极低,是人类可以接受的情况。因此,哈希码机制就保留下来了。
下面要说的哈希表,其实就是利用 GetHashCode
方法来计算对象的数值,来达到例如对象去重之类的、对对象同一性非常敏感的操作的一种数据类型。
要说 Dictionary<,>
的使用方式的话,它其实跟 List<>
也差不太多,也是使用 Add
追加元素,Remove
删除元素。但请注意的是,哈希表是存储的一对一对的数据,而不是一个一个的数据,这也是为什么,这个数据类型的英语单词用的是 dictionary——因为我们需要按照自定义的索引规则去获取里面存储的元素,这个搜索的过程和查字典的操作相当类似,根据笔画、读音等信息找寻到对应的汉字。
比如这样的例子。请注意,Dictionary<,>
数据类型是两个泛型参数的数据类型,就跟你根据拼音查找汉字一样,拼音是一个数据类型,汉字是一个数据类型,因此对应到编程的概念里可以类比成 Dictionary<拼音, 汉字>
的感觉:第一个泛型参数表示索引项目到底是什么类型,而第二个泛型参数才是对应的取值信息。另外,我们把 Add
方法的第一个参数(它的类型对应到的是第一个泛型参数)我们称为键(Key),而第二个参数(它的类型则对应第二个泛型参数)我们则称为值(Value)。也就是说,Add
方法在调用的时候,'c'
、'a'
和 'A'
我们都称为键,而 99
、97
和 65
我们则称为值。
不过请你注意一点。字典集合的 Add
操作,键是不能重复的。你想想,你查字典的时候,不论是按笔画还是按拼音去查找,至少笔画和拼音是不相同的吧,虽然每一个笔画项或拼音项对应了多个汉字。是的,Dictionary<,>
的键也是必须保证全局唯一性:Add
方法调用多次的时候,必须保证每一次调用传入的第一个参数都是互不相同的数据才行。那么,如果我故意或无意传入相同的键呢?当然是抛异常啦。恭喜你 100% 获得 InvalidOperationException
异常实例一枚。
而索引器,也是针对于键来取值的,因此索引器运算符 []
里写的是键的信息。如果找不到这个键的话,就好比你查字典没有对应的拼音和笔画一样,那么自然就会抛异常了。是的,你将 100% 获得 KeyNotFoundException
异常实例一枚。
1-6 ArraySegment<>
数组片段
如果你想要获取数组集合的一小段数据的话,你可以使用 ArraySegment<>
集合来完成。操作很简单,new
一下就完事了。
我们使用 new ArraySegment<int>(数组, 从哪个索引开始, 取多少元素)
来完成。比如 arr, 2, 6
三个参数分别对应从 arr
获取 6 个元素出来,从第三个元素开始(注意传参的 2 是表示索引位置是 2,索引是从 0 开始的,所以是第三个元素)。
然后,我们使用 Count
属性获取取出来的数据片段的元素总数(6 个),然后遍历序列。使用索引器来获取取出来的元素。注意,这里的 ArraySegment<>
类型里的索引器,索引又从 0 开始了,即使你并非从 0 索引开始截取的数组片段。所以,这个代码里的 i
从 0 到 5 就恰好对应了原数组的 arr[2]
到 arr[6]
这 6 个元素。
另外,ArraySegment<>
数组片段一般用于一维数组。也就是说,如果是二维数组甚至更高维度的数组,就不要用这个类型了。
1-7 PriorityQueue<,>
优先级队列
.NET 6 里诞生了一个新的数据类型,叫 PriorityQueue<,>
。该数据类型和队列基本上是差不多的,因为它的底层实现就是一个队列。不过,PriorityQueue<,>
用到了两个泛型参数。其中第一个泛型参数表示队列里的每一个元素的类型,而第二个泛型参数则表示的是优先级。
优先级队列用于一些复杂的处理过程里,它将一系列的操作存储到优先级队列之中,并按照指定的优先级(第二个泛型参数给出的这个类型)来排序,并抉择到底什么内容应该优先执行。那么,优先选择出来的(优先级高的)会被提出来先出队。这个是优先级队列的用法。
不过,对于 C# 语法层面来说很少用到,这里就做一个科普吧,提一嘴。它一般用在很多复杂的多线程处理过程里,比如消息队列等等。
Part 2 泛型接口
下面我们来说说泛型接口。泛型的接口一般多从普通的接口类型上进行拓展,因此多数都是我们前文讲解过的知识点,只是它变为泛型罢了。
2-1 IEquatable<>
接口
还记得之前说的东西吗?
IEquatable
接口是不存在的。因为按照设计规则,IEquatable
非泛型接口应当包含一个这样的方法:
但可以从这样的设计上看出问题:这个 Equals
方法在最终基类型 object
里就已经自带。所以你设计的所有数据类型均全部从 object
类型里派生。不论你是不是重写了 Equals
方法,该类型都具备 Equals
方法。因此,该接口类型存在与否都没有任何区别,所以,.NET 体系里是没有 IEquatable
接口的。
但是,泛型接口 IEquatable<>
是否可有可无呢?按照这样的设计规则,我们应该是这样的:
可以从这里看到,因为参数类型从 object
改成了 T
,而 T
类型显然就不是 object
,因此是构成重载的。重载意味着两个方法即使方法名相同,也可以共存。正是因为如此,这个接口就有意义了:因为你 override
掉的是 Equals(object)
这样签名的方法,而 Equals(T)
并不存在于你的代码里,因此这样的接口是具有约束性的。实际上,.NET 也确实是这样设计 API 的。
因此,要想强约束一个类型必须包含强类型的 Equals
方法,那么就速速实现 IEquatable<>
接口吧!
如代码所示,这样是一种实现的大概的代码写法。既然你都从 IEquatable<>
接口派生了,那么就相当于说明了类型肯定是具备相等判断规则的类型。那么既然如此,你就没有理由不一起重写掉 Equals(object)
这个不起眼的比较方法。然后,顺带重载掉运算符 ==
和 !=
,以后写代码的时候就方便多了。
是的,虽然看似无用的代码,但是这么写有一个好处在于,两种情况的 Equals
都有可能被调用到,一种是模糊类型校验,一种是具体类型校验,它们各有各的好处。
2-2 IComparable<>
接口
之前学习了 IComparable
接口,那么对应的泛型版本自然就是 IComparable<>
接口了。不过,因为原始的接口类型传入的参数类型是 object
,因此相当不方便。现在我们有了泛型接口 IComparable<>
后,就可以完美代替掉原来的这个接口类型了。
和 IEquatable<>
接口不同,由于 IEquatable<>
接口不存在非泛型版本,因此设计上完全不影响;但 IComparable
非泛型接口包含的方法 CompareTo(object)
并非 object
自带的方法,因此这个非泛型接口类型设计起来是有必要的。emmm……起码,在非泛型的时代,是有必要的。而在后期,泛型时代来临,这些非泛型版本就得抛弃了。.NET 在设计 IComparable<>
接口的时候,是这么设计的:
object
参数类型改成了 T
如代码所示,我们多写一个 CompareTo
方法,替换掉 object
参数类型的版本即可。然后重载掉运算符 >=
、<=
、>
和 <
,这样使用起来更方便一些。
2-3 IEqualityComparer<>
接口
还记得我们以前怎么实现的吗?我们之前用的是一个方法,传入的是 object[]
来搞定的。然后再对每一个元素比较,使用非泛型版本的那个接口类型。既然我们有了泛型版本了,那么代码就可以改了。
object
都可以替代成 T
了。然后,我们就可以开始调用了。倘若还是用以前的 Student
IEqualityComparer<>
是的,清爽多了。这次有了具体类型,我们就不必做那些类型判断了。
最后,我们开始调用那个方法。
是的,这次执行结果是完全一致的,不过这次要效率高一些,因为没有冗余的类型判断,没有那些莫名其妙的类型转换机制和操作。
2-4 IComparer<>
接口
是的,这个和非泛型版本也形成了对比。因此我们还是来讲一下实现即可。
然后,执行排序。
是的,多简单。
2-5 ICollection<>
和 IReadOnlyCollection<>
接口
要说接口的作用,那么……还记得接口的作用吗?接口的作用是为了起到成员实现的限制作用,为了能够让你能实现这些成员,接口就把你需要实现的成员以名字的形式列举出来,然后你要想加上 : 接口
的语法,就必须实现 接口
类型里的所有成员。从另外一个角度来说,既然你已经实现了该接口,那么基本上就可以认定你能够做到接口本应该抽象体现出来的事物的基本功能和作用了。
那么,要想了解 ICollection<>
接口,那么必须要看懂这个单词。collection 这个单词在编程里是“集合”的意思。所谓的“集合”,就是说明一个数据类型,它专门实现出来存储元素的数据信息。而且存储的数据信息还得是一系列的数据,而不是一个单独的数据。我们看一下 ICollection<>
集合的接口内成员都有哪些。
一共 7 个成员,两个属性、5 个方法。它们的含义分别是这样的:
Count
属性:表示集合多少元素;IsReadOnly
属性:表示集合是不是只读的(就是说,是不是集合在初始化之后就永不改变里面的数值,只用来读取了);Add
方法:往集合追加一个元素进去;Clear
方法:表示将集合的所有已经存储进去的元素全部清除掉;CopyTo
方法:表示将这个集合里的每一个元素往参数array
里拷贝,就是复制一份副本到参数这个数组里去。arrayIndex
表示从第几个元素开始拷贝;Remove
方法:表示删除、移除集合里指定数值的元素。
可以看出,它们都跟增删改查相关。虽然查找集合序列在这里没有提及,但 Remove
方法传入的参数要一定能从集合里删除,自然肯定要求底层实现得比较数据是不是一样。那么必然会调用一些方法,例如 Equals
方法等成员来判别数据是否一致,那么自然就相当于是在查找元素了。那么,增删改查都有了:增加元素、删除元素、改变数据(Clear
清零)、查找数据。这就是 ICollection<>
泛型接口的基本用法。
同理,IReadOnlyCollection<>
接口的名字里带有 read only 一词的,因此它和 ICollection<>
接口的使用场景的不同在于是不是表示集合只读。
再次查看 IReadOnlyCollection<>
接口的内容,可以发现它只有一个成员。
Count
属性了。如果一个集合包含签名一致的 Count
IReadOnlyCollection<>
接口,那么就说明你该类型只读了;当然这个是字面意思。如果你在使用的时候,因为多态性导致你该类型的接收方是用的接口来接收的,说明该集合现在只读了,因此你仅能使用里面的Count
属性,以及foreach
循环(foreach
循环绑定上的是IReadOnlyCollection<>
接口的基接口IEnumerable<>
和IEnumerable
的行为,这个我们稍后说明)。
另外稍微需要你注意的是,ICollection<>
和 IReadOnlyCollection<>
接口是不共通的,因为它们各自派生的关系上,并没有用到“其中一个接口是另外一个接口的基接口”的情况。因此,它们俩是不共通的。也就是说,你不能把一个 ICollection<>
接口类型的对象赋值给 IReadOnlyCollection<>
接口类型作为接收类型;反之亦然。
可以看到,这两个接口类型,全部都从 IEnumerable<>
和 IEnumerable
接口派生,但这两个接口都是什么呢?下面我们就来讨论一下。
2-6 IEnumerable<>
和 IEnumerator<>
接口
实际上,这个接口也没什么好讲的。因为在前面基本上也都说过了。在之前讲解接口良构类型的时候就说过该接口的用法,并且提到过这样的内容:
“如果一个数据类型实现了该接口里面的成员信息的话,我们就可以认为这个接口是可以使用 foreach
循环的。”
不过问题在于,foreach
的迭代变量的数据类型上。由于早期的接口 IEnumerable
是不带有泛型的,因此它迭代的每一个元素都会自动被关联为 object
。也就是说,它基本上在用的过程都等效于这样的语法:
然后,我们简单还提过一句。object element
可以替换掉 object
,改成你的具体的类型。因此基本上也算是方便了,因为我们期望迭代元素也就只需要让它能够写代码看起来更加“优雅”,而允许 object
自动换成具体的类型这一点来说,就算是比较方便了。
int
的,结果我又不想去自己实现一个“鸭子类型”来完成成员的具体类型的迭代过程(毕竟,太复杂了),那么我们只能接受装箱拆箱的操作。还记得鸭子类型吧。鸭子类型说的是一个数据类型,一旦满足一定的条件,即使它不实现接口,也能做一些接口才能做的事情,因为它已经被当成能做这个事情的类型了。
C# 2 带来了接口,就引入了 IEnumerable<>
泛型版本的该接口。于是,它实现起来就比起原本的 IEnumerable
要更好,因为它是泛型的,也就意味着频繁装箱拆箱的时代结束了。是的,它的用法基本上和 IEnumerable
没有任何区别,唯一的、也是最方便的好处就是它避免了原来类型的 Current
属性是 object
类型而会导致隐式的装箱拆箱行为。同时地,在 IEnumerator
接口里,Current
属性原本是 object
类型的,那么我们想要改成自己的一个具体类型的话,只需要加上泛型参数的实际类型,就可以了。这样省得你自己实现具体类型来避免复杂的迭代内部机制的实现。
2-7 IList<>
和 IReadOnlyList<>
接口
要想说清楚这个接口类型,我们必须回去看看 List<>
泛型列表类型。这个 List<>
类型包含了众多的成员,比如 Add
方法啊、Remove
方法啊、Count
属性之类的。但是,这些方法在实现期间,其实背后是有一个接口约束的。是的,这个接口就是 IList<>
。是的,list 单词的意思是“列表”,因此实现了接口就等同于表示这个自定义的数据类型可以做到一个列表该做的基本功能(增删改查什么的)。
我们来看看 IList<>
接口的基本定义吧。
是的,它里面带有四个成员:索引器,IndexOf
方法、Insert
方法和 RemoveAt
方法。而它从 ICollection<>
、IEnumerable<>
和 IEnumerable
接口派生。其中 ICollection<>
接口有点“大”,因为它里面的成员非常多,一共有 7 个(前面介绍了),而该 IList<>
接口又从 ICollection<>
派生,就意味着你在实现一个集合,从 IList<>
的时候要顺带也把 ICollection<>
里的成员都给实现了。
同理,既然 ICollection<>
都有只读版的接口 IReadOnlyCollection<>
接口,那么 IList<>
也有对应的只读版本的接口类型:IReadOnlyList<>
。不过,这个接口长这样:
是的,它走 IReadOnlyCollection<>
接口派生,然后里面包含的是索引器。这意味着,如果你使用的是 IReadOnlyList<>
作为接收类型的话,那么这个类型可以用的三个操作自然就是 foreach
循环、Count
属性以及索引器了。
2-8 IDictionary<,>
和 IReadOnlyDictionary<>
接口
和 IList<>
以及 IReadOnlyList<>
是一样的存在,这两个接口是 Dictionary<,>
接口的抽象。我们来大概看看这个接口类型里都有一些什么,就可以了。
Keys
和 Values
属性,以及 ContainsKey
和 TryGetValue
方法,是它们两个接口都有的成员。其中:
索引器:获取指定键的对应值是什么;
Keys
属性:获取整个字典序列里的所有已经存储进去的键,作为一个集合返回出来;Values
属性:获取整个字典序列里的所有已经存储进去的值,作为一个集合返回出来;Add
方法:追加一个键值对的数据进去,到集合里;ContainsKey
方法:查找字典里是不是包含指定的键;Remove
方法:删除字典里指定键的键值对信息;TryGetValue
方法:尝试去获取指定键的对应数值信息。如果字典里没有这个键的存储,就返回false
;否则返回true
,并把结果从value
参数返回出来。
这个接口也没啥好说的,因为很少我们会自己实现一个集合去满足里面的成员。所以细节上就不多说了。
Part 3 泛型委托
最后,我们来说说泛型委托的内容。是的,委托类型也有泛型的 API 提供。而且它们用得相当广泛。
3-1 Action
和 Func
系列泛型委托
下面我们来说一下 Action
和 Func
系列委托。为什么说是系列呢?是因为 Action
和 Func
并不是单个委托类型,而是包含泛型类型的重载版本。
还记得之前学习的委托类型的用法吗?委托类型定义了具体的类型和返回值后,只要签名一样, 就可以使用 new 委托
的方式把方法赋值过去。不过,如果定义的参数和返回值有泛型参数怎么办呢?是的,这就是我们说的泛型委托的一种特殊用法。
举个例子,假设一般的定义是这样的:
这表示一个无参无返回值的委托类型。如果我们替换掉返回值类型:
可以看到,这次我们将返回值替换为了一个泛型参数。这个情况我们就称为泛型委托类型。而 Action
和 Func
就是如此的泛型委托类型。
3-1-1 Action
系列委托
先来说 Action
系列委托。Action
系列委托一共是 17 个重载版本。长相是这样的:
数数看,是不是 17 个。一个非泛型版本,16 个带泛型参数的版本。泛型参数因为是等效的,因此泛型参数的重载只存在个数不同的重载规则。并且请注意,这 17 个委托类型都是 void
返回值,因此它们都不接受任何返回值类型。
举个例子,假设我有一个方法 Sort
:
如果我们想要使用委托类型的话,可以这么写代码:
可能你很少见到,int[]
当泛型参数的实际类型的。实际上,C# 允许这么做。因为它也是 Array
的派生类型,而 int[]
也只是特殊记号罢了,所以没有道理不允许这么写。
可以从这里看到,这种代码写起来相当方便了。因为委托类型就可以省去很多次的委托类型声明的语句。比如我们经常定义一些奇怪的委托类型。现在有了 Action
的系列委托,只要你的方法的参数少于或等于 16 个,都可以直接使用这个东西来完成,就……很方便。
用法和普通的委托类型用法是一样的:Invoke
调用即可。
3-1-2 Func
系列委托
前面的委托类型并不能解决返回值不空的情况,因此 .NET 也提供了自定义返回值类型的委托类型,叫 Func
。它包含 16 个重载的版本,长这样:
这样就可以解决返回值类型的问题了。再举个例子,加减乘除。
用法也是一样的。
3-1-3 Action
和 Func
系列委托的缺陷
虽然它可以替代绝大多数我们用得到的委托类型的定义,但仍然有时候无法替代。比如说,参数带有修饰符。比如说:
由于泛型参数带有 T
修饰符,因此我们无法替换为 Action
或 Func
系列委托,因为定义里的所有带参数的情况也都没有 ref
修饰的情况。这种情况下,委托类型只能自己定义。
3-2 Predicate<>
谓词
虽然,Func
和 Action
系列委托类型基本上能解决大多数时候的问题,但有些时候,我们直接使用 Action
和 Func
系列委托也不方便,因为写起来有些长。于是,C# 派生出了两个特殊的委托类型,这样用起来方便。一个是 Predicate<>
,另外一个是稍后介绍的 Comparison<>
。
Predicate<T>
委托类型只带一个泛型参数 T
,而它的签名基本等价于 Func<T, bool>
,即一个方法带有一个参数和一个返回值,参数可替代为任何的类型,而返回值是 bool
类型。从这个签名可以看出这个 bool
就表示一个条件结果。这就是为什么,这个委托类型被一些资料上称为“谓词”。“谓词”这个说法来自于逻辑学,它指的是一个句子里的谓语动词,并且整句话能作出判断的情况。那么抽象为编程语言,不就是一个语句,执行出来的结果是 bool
结果吗?
用法很简单,因为它和 Func<T, bool>
是一个意思,因此当它这么用就行。有些 API 就会用到这个委托类型,比如前文介绍的 List<>
列表类型,里面自带了一个叫做 FindAll
方法,它用来找到整个列表里所有满足指定条件的元素。既然要找到满足条件的,那么自然就得把每一个元素挨个迭代一次,然后判断条件,然后条件为 true
,就记录到结果里吗?所以,它用 Predicate<>
充当条件部分就相当合适。事实上,.NET 也确实是这么设计的:
在使用的时候,可以这么做:
可以从第 6 行代码看到,它需要一个参数,正是这里的 Predicate<>
类型。而为什么这里没有写泛型参数部分呢?因为 IsOdd
方法是带有 int
参数的方法,可以从方法本身推断和暗示 Predicate<int>
是合适的实例化情况。因此,编译器允许我们省略 <int>
泛型参数部分。
3-3 Comparison<>
比较器
和谓词委托相似的,还有比较器对象。Comparison<T>
委托类型的签名基本等价于 Func<T, T, int>
,也就是传入两个 T
类型的参数,并返回 int
结果。试想一下,什么样的时候,会用到这个情况?
是的,CompareTo
方法的类似逻辑。要想比较两个对象,然后比较出一个大小,是不是就得这么搞啊?因为部分的数据类型是不支持运算符 >=
这类重载的,因此我们并非所有时候都可以这么简单使用比较操作。于是,我们有了比较器委托后,就可以简略很多代码了。
和 Predicate<>
委托类型一样,它也在 .NET 的系统自带 API 里就有所使用和体现。比如 Array
类型(所有数组的基类型)就包含一个方法,叫 Sort
。它的签名是这样的:
是的,两个参数,没有返回值。第一个参数肯定是数组本身了,因为 Array
类型里基本带的都是静态的成员(当然, Length
属性就是实例成员,但这样的成员很少),所以要执行操作,比如优先考虑把数据给传入到方法里,那么自然就需要占用一个参数的名额来完成;而第二个参数就是我们这里所说的比较器委托类型对象了。
用法也很简单。考虑对字符串排序。我们可以定义字符串的比较方式为比较字符串的长度(为了例子简单一些,我们这里暂时不考虑比较 ASCII 码等内容)。于是我们可以这么写代码:
注意第 11 行代码,我们需要的两个参数是这么写的。其中第二个参数我们传入委托类型的对象,所以需要实例化;带有泛型的时候,需要同时在实例化的时候传入合适的泛型参数。
Part 4 其它问题
下面针对于前文没有提到的内容进行一个问题解答,或者补充。
4-1 没有泛型特性
是的,你压根就没有看错。我们大家都知道,整个 C# 的派生体系非常庞大,里面还包含了别的数据类型,比如特性。虽说特性写法跟类也没啥区别,但是,特性奇怪的点在于,它虽然是个普通的类的实现,从 Attribute
抽象类派生,但它并不能是泛型的。
这挺奇怪的。既然是一个普通的类类型,那么为啥它不能是泛型的呢?原因在于,特性在运行期间是作为元数据存储的。还记得元数据的基本概念吧。元数据指的是一种构建整个程序运行的基本数据信息。它们被放在一个特殊的地方,受程序初始化的时候自动初始化,并且永不可修改。
是的,这些数据都是实体的数据类型(什么必须是 Type
类型啊、基本的内置类型啊、一维数组类型之类的),设定这种限制很明显是因为,它们是可以在运行前通过编译器自动计算到指定的数据以及存储的内存空间大小,并且丢进元数据的存储内存区域里的。正是因为它们是预先就可以被编译器处理掉,因此泛型是不允许的:因为泛型受到运行时管理。换句话说,泛型得等到运行时期才可以确定具体的存储机制(比如内存占多大啊,数值是多少什么的)。所以,泛型在特性里是不允许存在的。
不过,这一点将在 C# 10 里被打破。是的,从 C# 10 开始,你就可以使用泛型特性机制来完成一些奇特的操作了,不过,这一点得等到后面去说。而且,C# 10 的对应 .NET 运行环境比较高(.NET 6),因此如果你使用的是旧版本的 .NET 框架,说不定就不可以使用这种语法机制了:因为泛型特性除了是语法要支持以外,还得运行环境自身支持才行。这种新语法就不能随便引用到项目里,否则你会直接预先收到一条编译器错误信息,告诉你,这样的程序无法编译,因为运行时就不支持。
4-2 数组的接口实现的奇怪现象
IList<>
、IReadOnlyList<>
、ICollection<>
和 IReadOnlyCollection<>
接口是用来表示一个集合的,只是细节不同。比如说 IList<>
和 IReadOnlyList<>
是表示集合的可列举性,而 ICollection<>
和 IReadOnlyCollection<>
则更侧重于集合的基本实现标准和规范。
那么,数组呢?数组难道就不是集合了?数组也是集合啊,数组也是可列举的啊。那么自然,一个数组类型也应当实现这些接口类型。可问题来了。ICollection<>
接口里包含了一些比如 Add
、Remove
方法的成员,用来增删数据。可数组呢?数组是不能增删的,数组只能改变里面的数值,以及查找数值。那么这个实现机制岂不是太奇怪了?
问题很好。这个现象我们先给出结论吧。数组实现了这些接口类型,也意味着你可以直接这么写代码:
这里的
Array.Empty<int>()
方法是一个泛型方法。它提供一个空数组。换句话说,它调用后会产生一个类似于new int[0]
的数组,即没有任何元素的数组,Length
属性返回 0。这个方法是泛型方法,意味着你需要传入一个泛型参数进去,比如这里的int
传入进去,返回的就是new int[0]
类似的结果;如果是别的数据类型,例如表示为T
的话,那么结果就对应了new T[0]
里的这个T
。这个方法比
new T[0]
直接写要高效,因此我们永远都建议你使用Array.Empty<T>
泛型方法来代替掉new T[0]
语法。
不过,请勿调用这些接口里有关增删数据的成员,因为它们会导致程序在运行时期抛出 NotSupportedException
异常,告诉你这个集合并不支持这个方法,毕竟,数组并不支持增删操作。是的,仅是抛异常而已。
至此,我们就把泛型给全部说完了。当然,泛型的水很深,这一点内容还不足以说明清楚更深层次的内容,但它们已经不属于教程考虑和讨论的范畴了。如果有兴趣的话,可以参考《CLR Via C#》之类的书籍,来学习有关泛型的底层实现机制。当然,我也不是一定不考虑讲这些内容。我只是说本教程不考虑这些。说不定我以后还出一些比如专讲底层机制的系列教程呢?