第 69 讲:C# 2 之泛型(三):泛型接口及泛型参数继承
前面的内容告诉了大家如何使实现一个泛型类型(结构和类)。今天我们来介绍一下泛型参数继承的机制。
Part 1 创建一个泛型接口
先来简单的。我们来创建一个泛型接口类型。泛型接口类型就是一个接口类型带有泛型参数。考虑一种情况。假设我想要提取一种集合类型,暂定名称叫 ICollection<T>
。这个 T
表示这个集合类型存储的每一个元素的类型,比如数组 T[]
、列表 List<T>
的这个 T
。这个接口专门提取出一个集合应该有的一些成员,以便我在以后使用的时候,要想实现一个集合,直接实现这个接口,通过 IDE 提供的自动实现接口成员的功能就可以完美产生一系列的成员,就不必自己手写的时候忘记东西。那么这个接口大概长这样:
是的。和类型的声明方式完全一样,接口也不例外:接口如果想要带有泛型参数,在声明的时候直接按照 <T>
的形式放在后面即可。然后我们就可以在里面追加一系列的成员用来表示一个集合应该有的东西。一般正常思路下,它得是可 foreach
迭代的、可以使用索引器的、可以增删查成员的。其中可迭代的对象我们要求它实现 GetEnumerator
方法即可,而索引器,我们直接给出 get
方法即可,set
方法有些时候集合也不一定非得去实现,所以不用给出;增删查就不多说了。来看下写法:
大概这样就可以了。注意我们这里用到了两个新的数据类型:IEnumerable<T>
和 Predicate<T>
类型。我们简单给大家说一下这两个类型是什么。
1-1 IEnumerable<T>
和 IEnumerator<T>
接口
IEnumerable<T>
接口是 IEnumerable
接口的泛型版本。换句话说,原本的 IEnumerable
接口也带有 GetEnumerable
方法,但由于非泛型,因此返回值是 object
类型来兼容所有数据的;现在我们有了 IEnumerable<T>
后,返回值类型就可以从 IEnumerator
改成 IEnumerator<T>
了。但是,它们都是包含完全一致的方法名:GetEnumerator
。
IEnumerable<T>
的实现是这样的:
IEnumerator
接口我们说过了,它是用来启动迭代 foreach
而现在我们有了泛型版本,因此这段代码就相当于改成了这样:
这样的话,在一定程度上避免了装箱。而早期执行语句下,代码是这样写的:
现在有了泛型,我们就可以把代码替换成这样了:
所以,总的来说,这个接口的意义在于泛型化接口,以尽可能避免装箱。
接着注意一下实现的问题。由于 IEnumerable<T>
是继承接口 IEnumerable
非泛型的接口类型。那么你在实现了 IEnumerable<T>
接口的时候,按照接口的继承机制,你也必须同时也实现 IEnumerable
接口的成员。
问题是,这两个接口都只有一个成员,而且它仅包含这一个成员,而且好巧不巧,它们都是叫 GetEnumerator
,只有返回值不同。正是因为这个原因,在声明 IEnumerable<T>
接口的时候,它里面带的泛型版本的 GetEnumerator
方法的开头带有一个修饰符 new
就是为了隐藏底层的成员。但隐藏归隐藏,你实现了 IEnumerable<T>
接口也必须同时实现这两个不同的方法。new
只是表示我这个方法不是从基类型继承下来的东西,而只是巧合的时候取名遇到重名现象,为了规避继承机制才需要追加 new
关键字的。但你既然两个方法都得实现,那么我必须得考虑一个问题:实现都使用隐式接口实现就会导致重名而出现无法编译的冲突。于是……
于是怎么样呢?稍后我们解释。
1-2 Predicate<T>
委托
是的。C# 灵活就灵活在,所有数据类别均可使用泛型。委托也不例外。这 Predicate<T>
委托想要代表什么方法呢?一个条件,一个以 T
类型作为判断成员的条件。它的声明是这样的:
element
,是 T
类型的。然后进行运算后,得到一个 bool
是的。Predicate<T>
泛型委托用来获取满足条件的成员。这个 predicate
变量假设就是 Predicate<T>
类型的话,那么它就可以通过接口自带的 Invoke
方法调用里面的回调函数,去判断是否条件成立。
1-3 整个接口里的成员
那么这么一来,接口里的东西就很容易看懂了。
索引器:获取第
index
号索引上存储的元素;Add
方法:添加一个元素到集合里;Remove
方法:从集合里删除一个指定数值的元素;Find
方法,带T
参数:找到集合里数值为element
的元素,返回它的索引;Find
方法,带Predicate<T>
参数:找到集合里满足指定条件的元素,返回它的索引。
Part 2 从这个接口派生
2-1 泛型参数继承
很棒。接口有了,下面我们假设实现了一个自己写的类型,然后想要从这个接口派生。
注意这里的语法。在 SequenceList<T>
类型后带有泛型参数 T
。而我们从一个泛型接口派生。我们定义 ICollection<T>
接口的 T
表示的是集合的元素,而 SequenceList<T>
的 T
难道就不是了吗?那很显然是的鸭。既然这个 SequenceList<T>
的 T
就是每一个元素的类型的话,那么按照接口实现的基本规则,我很明显是想要让这个 T
作为泛型接口的实现参数才是。
可问题是,本来 T
就是泛型参数了,它自己都不确定,难道还能拿来替换别的类型里的泛型参数?是的。这就是 C# 一个新的机制:泛型参数继承(Inheritance of Type Argument)。你仔细看看就会发现,我 Predicate<T>
不也这么使用了吗?
C# 认为,你这个 T
自己虽然是泛型参数,但是在类型的声明和它的大括号这段代码里,这个 T
就会自然而然地被认为是一个普通的数据类型,只是我们不知道是什么具体的类型。这个时候我们尝试让 T
作为一个类型来使用,因此才能有之前的 default(T)
这些语法。而现在,在类型声明里面,即使我们使用到了 T
作为比如 int Find(Predicate<T> predicate)
的一部分,但仍然 T
是按实际类型在看待,所以这个地方的 T
编译器是不会管你的;而正相反地,这个写法反而是我们以后自己实现泛型数据类型里,常见的写法。
稍微注意一下用词。这里的术语叫泛型参数继承,用的是“继承”,但它仍然和普通的类型继承有所不同。泛型参数的继承则是基于普通类型在实现接口或类的继承机制的,而它带有的泛型参数可以传入到泛型基类或泛型基接口里当实际泛型参数。
2-2 泛型接口的显隐式接口实现
很好。下面我们来解释一下 Part 1 里没有解释的问题:由于 IEnumerable<T>
和 IEnumerable
接口里的同名但不构成重载的方法 GetEnumerator
无法直接实现,因为名称会冲突,那么怎么办呢?
我们仍然使用 SequenceList<>
类型来描述和演示这个问题的解决办法。由于冲突是无法避免的,因此我们需要使用紧急措施:显式接口实现:因为显式接口实现可以规避重名方法的现象。我们选取一个不常用的接口,它实现的内容以显式接口实现的形式呈现出来,避免冲突。那么哪个不常用呢?显然是非泛型版本 IEnumerable
接口了。因为泛型机制在绝大多数情况下都比非泛型机制要更好,所以我们基本上可以完全放弃掉非泛型的情况,而且也能达到一致的运行目的和效果。因此,我们使用显式接口实现隐藏掉 IEnumerable
接口的成员:
我们仅需隐藏其中一个即可。当然,两个你都可以使用显式接口实现,不过一般我们在任何正常情况下都建议使用隐式接口实现,因此不必显化实现。
2-3 泛型接口的多态
由于你实现了这样的泛型接口,因此按照多态性的规则,当前类型是可以隐式转换为接口类型的实例的;反之,接口类型的实例可以强制转换为当前类型的实例(如果类型匹配的话)。
反之,因为接口类型不知道应该往什么类型上转化,并且转换可能失败,因此需要强制转换:
比如这样。
Part 3 泛型接口的多角色实现
泛型类型的诞生使得接口有一种新的“黑科技”:多角色接口实现。
3-1 基本实现和“多角色”的多态性
思考一个问题。假设我有一个集合,但这个集合拥有多种迭代行为。比如 StudentCollection
集合类型存储的是一系列学生的信息,那么我们迭代可以直接迭代每一个学生的信息;但有些时候也不一定非得是迭代 Student
的实例,比如我还可以只迭代学生的 ID(string
类型)之类的。
我们理想的代码是这样的:
可问题在于,方法的重载是不允许只有返回值不同的。换句话说,两个方法如果只有返回值不同的话,两个方法仍然不作为重载成员出现。因此,这样的代码只能存在于理论里。
别忘了。现在我有了泛型接口了,我们就可以这么做了:我们同时实现两次 IEnumerable<T>
接口,只是泛型参数一个是 Student
,而另外一个则是 string
,然后改一下实现。
还记得接口的显式实现吗?接口是可以显式实现的,这就是为了避免重名成员导致无法继承的问题。而我们可以利用显示接口实现的机制来达到我们以前做不到的操作。
因为接口
IEnumerable<T>
从IEnumerable
接口派生,因此一定仍需要实现接口IEnumerable
的成员。因此最后第 9 行的代码仍旧不可少。
请注意语法。C# 甚至允许我们同时实现完全相同的接口类型多次,只要泛型参数的实际类型不同就可以。这种实现机制称为多角色实现(Multi-role Implementation)。
有了这样的机制后,我们就可以使用 foreach
语句同时实现两个不同的方法:
两种写法全部都可以了。而且它们的不同迭代对象也会自动“路由”到对应的 GetEnumerator
方法上,你甚至无需担心实现机制冲突的问题——C# 可以帮你区分开不同的调用。
另外,多角色接口实现也不影响它自身和接口类型之间的强制转换和隐式转换。这种东西的多态和正常的多态是一样的,只不过多角色实现大不了就多几个转换的可能罢了:
这些转换也都是可以的。
3-2 避免多角色接口实现
多角色接口实现是一种黑科技,正是因为 C# 允许显式接口实现,也允许我们实现多次同一个接口(只要泛型参数的实际类型不同),因此才会有这样的情况出现。
可问题在于,这样的实现是有问题的。思考一下面向对象里的接口是如何一种关系:接口实现等于类型能做什么。类型实现了 IEnumerable
接口说明类型可以迭代,类型实现了 IComparable
接口说明类型可以比较大小,类型实现了 IEquatable
接口说明类型的实例可以判断是否包含相同的内容,而类型实现了 ICollection<T>
接口说明类型可以做集合可以做的事情。诸如此类。
可问题是,我们让一个所谓的 StudentCollection
类型实现了 IEnumerable<string>
,它的目的是什么呢?是不是应该表示 StudentCollection
的实例可以按字符串类型进行成员迭代啊?可是,按正常思考一般也都不会想到,StudentCollection
的迭代过程怎么会和一个字符串绑定关联起来。因此,这样的接口实现是不合面向对象的基本约定的。这个是这么实现接口的第一个问题。
第二个问题是,在稍后我们会对泛型展开协变和逆变性质的讨论,在此我们将会讨论为什么一个泛型类型是不变的,以及类型怎么转换成协变和逆变的对应类型,以及泛型委托类型的协变性。如果我们使用了多角色的接口实现,会破坏安全的类型转换,使得混淆泛型参数的协变和逆变过程。这个点稍微有点超纲,不过你先记住就行。
所以,这样两个原因可以说明,我们都不建议你使用这种接口实现黑科技来完善你的代码,只是存在这种机制是出于代码的兼容性等考虑。
Part 4 泛型类型嵌套
C# 灵活的地方在于,你甚至可以嵌套使用泛型的数据类型。
如代码所示,泛型类型仍可使用嵌套类型。不过稍微要注意一点,就是泛型参数的问题。
泛型参数是在这个数据类型里的任何一处地方都可以使用的,这意味着这个 T
不管你里面有没有嵌套别的类型,嵌套的什么类型,这个 T
也都可以使用。那么,这样的话就需要注意嵌套类型的泛型参数不要和外层数据类型的泛型参数重名。
如果重名,编译器会生成一个警告信息,告诉你,我外层的数据类型已经包含此泛型参数。如果你是无意重名的,请修改内层嵌套的数据类型的泛型参数名称,使之不要重名,否则,泛型参数重名后,嵌套数据类型的泛型参数会覆盖掉同名泛型参数,导致在嵌套类型里无法再看到和使用到外层的泛型参数,说白了就是隐藏掉了。
因此,我们不应书写代码的时候出现这样的情况,因此要么避免使用嵌套类型,要么避免使用重名的泛型参数,特别是处于包含关系的情况下。