第 13 章 接口
本章内容:
- 类和接口继承
- 定义接口
- 继承接口
- 关于调用接口方法的更多探讨
- 隐式和显示接口方法实现(幕后发生的事情)
- 泛型接口
- 泛型和接口约束
- 实现多个具有相同方法名和签名的接口
- 用显式接口方法实现来增强编译时类型安全性
- 谨慎使用显式接口方法实现
- 设计:基类还是接口?
对于多继承(multiple inheritance)的概念,虚度程序员并不陌生,它是指一个类从两个或多个基类派生的能力。例如,假定 TransmitData类的作用是发送数据,ReceiveData类的作用是接收数据。现在要创建SocketPort类,作用是发送和接收数据。在这种情况下,你会希望SocketPort从TransmitData和ReceiveData这两个类继承。
有的编程语言允许多继承,所以能从TransmitData和ReceiveData这两个基类派生出SocketPort。但 CLR 不支持多继承(因此所有托管编程语言也支持不了)。CLR 只是通过 接口提供了“缩水版”的多继承。本章将讨论如何定义和使用接口,还要提供一些指导性原则,以便你判断何时应该使用接口而不是基类。
13.1 类和接口继承
Microsoft .NET Framework 提供了System.Object类,它定义了 4 个公共实例方法:ToString,Equals,GetHashCode 和GetType。该类是其他所有类的根据或者说终极基类。换言之,所有类都继承了Object的 4 个实例方法。这还意味着只要代码能操作Object类的实例,就能操作任何类的实例。
由于 Microsoft 的开发团队已实现了 Object 的方法,所以从Object派生的任何类实际都继承了以下内容。
- 方法签名
使代码认为自己是在操作Object类的实例,但实际操作的可能是其他类的实例。 - 方法实现
使开发人员定义Object的派生类时不必手动实现Object的方法。
在 CLR 中,任何类都肯定从一个(而且只能是一个)派生类,后者最终从Object派生。这个类称为基类。基类提供了一组方法签名和这些方法的实现。你定义的新类可在将来由其他开发人员用作基类——所有方法签名和方法实现都会由新的派生类继承。
CLR 还允许开发人员定义接口,它实际只是对一组方法签名进行了统一命名。这些方法不提任何实现。类通过指定接口名称来继承接口,而且必须显式实现接口方法,否则 CLR 会认为此类型定义无效。当然,实现接口方法的过程可能比较烦琐,所以我才在前面说接口继承是实现多继承的一种“缩水版”机制。C#编译器和 CLR 允许一个类继承多个接口。当然,继承的所有接口方法都必须实现。
我们知道,类继承的一个重要特点是,凡是能使用基类型实例的地方,都能使用派生类型的实例。类似地,接口继承的一个重点特点是,凡是能使用具名接口类型的实例的地方,都能使用实现了接口的一个类型的实例。下面先看看如何定义接口。
13.2 定义接口
如前所述,接口对一组方法签名进行了统一命名。注意,接口还能定义事件、无参属性和有参属性(C# 的索引器)。如前所述,所有这些东西本质上都是方法,它们只是语法上的简化。不过,接口不能定义任何构造器方法,也不能定义任何实例字段。
虽然 CLR 允许接口定义静态方法、静态字段、常量和静态构造器,但符合 CLS 标准的接口绝不允许,因为有的编程语言不能定义或访问它们。事实上,C#禁止接口定义任何一种这样的静态成员。
C# 用 Interface 关键字定义接口。要为接口指定名称和一组实例方法签名。下面是 FCL 中的几个接口的定义:
|
|
在 CLR 看来,接口定义就是类型定义。也就是说,CLR 会为接口类型对象定义内部数据结构,同时可通过反射机制来查询接口类型的功能。和类型一样,接口可在文件范围中定义,也可嵌套在另一个类型中。定义接口类型时,可指定你希望的任何可见性/可访问性(public,protected,internal等)。
根据约定,接口类型名称以大写字母 I 开头,目的是方便在源代码中辨认接口类型。CLR 支持泛型接口(前面几个例子已进行了演示)和接口中的泛型方法。本章稍后会讨论泛型接口的许多土功能。另外,第 12 章“泛型”已全面讨论了泛型。
接口定义可从另一个或多个接口“继承”。但“继承”应打上引号,因为它并不是严格的继承。接口继承的工作方式并不完全和类继承一样。我个人倾向于将接口继承看成是将其他接口的协定(contract)包括到新接口中。例如,ICollection<T>接口定义就包含了IEnumerable<T>和IEnumerable两个接口的协定。这有下面两层含义。
- 继承
ICollection<T>接口的任何类必须实现ICollection<T>,IEnumerable<T>和IEnumerable这三个接口所定义的方法。 - 任何代码在引用对象时,如果期待该对象的类型实现了
ICollection<T>接口,可以认为该类型还实现了IEnumerable<T>和IEnumerable接口。
13.3 继承接口
本节介绍如何定义实现了接口的类型,然后介绍如何创建该类型的实例,并用这个对象调用接口的方法。C#将这个过程变得很简单,但幕后发生的事情还是有点复杂。本章稍后会详细解释。
下面是在MSCorLib.dll中定义的 System.IComparable<T>接口:
|
|
以下代码展示了如何定义实现了该接口的类型,同时还展示了对两个 Point 对象进行比较的代码:
|
|
C# 编译器要求将实现接口的方法(后文简称为“接口方法”)标记为 public。CLR 要求将接口方法标记为virtual。不将方法显式标记为virtual,编译器会将它们标记为virtual和sealed;这会阻止派生类重写接口方法。将方法显式标记为virtual,编译器就会将该方法标记为virtual(并保持它的非密封状态),使派生类能重写它。
派生类不能重写sealed的接口方法。但派生类可重新继承同一个接口,并为接口方法提供自己的实现。在对象上调用接口方法时,调用的是该方法在该对象的类型中的实现。下例对此进行了演示:
|
|
13.4 关于调用接口方法的更多探讨
FCL 的System.String类型继承了System.Object的方法签名及其实现。此外,String类型还实现了几个接口:IComparable,ICloneable,IConvertible,IEnumerable,IComparable<String>,IEnumerable<Char>和IEquatable<String>。这意味着String类型不需要实现(或重写)其 Object 基类型提供的方法,但必须实现所有接口声明的方法。
CLR 允许定义接口类型的字段、参数或局部变量。使用接口类型的变量可以调用该接口定义的方法。此外,CLR 允许调用 Object 定义的方法,因为所有类都继承了 Object 的方法。以下代码对此进行了演示:
|
|
在这段代码中,所有变量都引用同一个 “Jeffrey” String 对象。该对象在托管堆中;所以,使用其中任何变量时,调用的任何方法都会影响这个“Jeffrey” String对象。不过,变量的类型规定了能对这个对象执行的操作。s 变量是String类型,所以可以用s调用String类型定义的任何成员(比如Length属性)。还可用变量s调用从Object继承的任何方法(比如 GetType)。
cloneable 变量是 ICloneable接口类型。所以,使用cloneable变量可以调用该接口定义的 Clone方法。此外,可以调用 Object定义的任何方法(比如 GetType),因为 CLR 知道所有类型都继承自 Object。不过,不能用cloneable变量调用String本身定义的公共方法,也不能调用由String实现的其他任何接口的方法。类似地,使用comparable变量可以调用CompareTo方法或Object定义的任何方法,但不能调用其他方法。
重要提示 和引用类型相似,值类型可实现零个或多个接口。但值类型的实例在转换为接口类型时必须装箱。这是由于接口变量是引用,必须指向堆上的对象,使 CLR 能检查对象的类型对象的类型对象指针,从而判断对象的确切类型。调用已装箱值类型的接口方法时,CLR 会跟随对象的类型对象指针找到类型对象的方法表,从而调用正确的方法。
13.5 隐式和显式接口方法实现(幕后发生的事情)
类型加载到 CLR 中时,会为该类型创建并初始化一个方法表(参见第 1 章“CLR的执行模型”)。在这个方法表中,类型引入的每个新方法都有对应的记录项;另外,还为该类型继承的所有虚方法添加了记录项。继承的虚方法既有继承层次结构中的各个基类型定义的,也有接口类型定义的。所以,对于下面这个简单的类型定义:
|
|
类型的方法表将包含以下方法的记录项。
Object(隐式继承的基类)定义的所有虚实例方法。IDisposable(继承的接口)定义所有接口方法。本例只有一个方法,即Dispose,因为IDisposable接口只定义了这个方法。SimpleType引入的新方法Dispose。
为简化编程,C#编译器假定 SimpleType 引入的Dispose方法是对IDisposable的Dispose方法的可访问性是public,而接口方法的签名和新引入的方法完全一致。也就是说,两个方法具有相同的参数和返回类型。顺便说一句,如果新的Dispose方法被标记为virtual,C#编译器仍然认为该方法匹配接口方法。
C#编译器将新方法和接口方法匹配起来之后,会生成元数据,指明 SimpleType 类型的方法表中的两个记录项应引用同一个实现。为了更清楚地理解这一点,下面的代码演示了如何调用类的公共Dispose方法以及如何调用IDisposable的Dispose方法在类中的实现:
|
|
在第一个 Dispose 方法调用中,调用的是 SimpleType 定义的 Dispose 方法。然后定义 IDisposable 接口类型的变量d,它引用SimpleType对象st。调用d.Dispose()时,调用的是IDisposable接口的Dispose方法的实现,所以会执行相同的代码。在这个例子中,两个调用你看不出任何区别。输出结果如下所示:
|
|
现在重写 SimpleType,以便于看出区别:
|
|
在不改动前面的Main方法的前提下,重新编译并再次运行程序,输出结果如下所示:
|
|
在 C# 中,将定义方法的那个接口的名称作为方法名前缀(例如 IDisposable.Dispose),就会创建显式接口方法实现(Explicit Interface Method Implementation,EIMI①)。注意,C# 中不允许在定义显式接口方法时指定可访问性(比如 public或private)。但是,编译器生成方法的元数据时,可访问性会自动设为 private,防止其他代码在使用类的实例时直接调用接口方法。只有通过接口类型的变量才能调用接口方法。
① 请记住 EIMI 的意思,本书后面会大量使用这个缩写词。——译注
还要注意,EIMI 方法不能标记为 virtual,所以不能被重写。这是用于 EIMI 方法并非真的是类型的对象模型的一部分,它只是将接口(一组行为或方法)和类型连接起来,同时避免公开行为/方法。如果觉得这一点不好理解,那么你的感觉没有错!它就是不太好理解。本章稍后会介绍 EIMI 有用的一些场合。
13.6 泛型接口
C# 和 CLR 所支持的泛型接口为开发人员提供了许多非常出色的功能。本节要讨论泛型接口提供的一些好处。
首先,泛型接口提供了出色的编译时类型安全性。有的接口(比如非泛型 IComparable 接口)定义的方法使用了 Object 参数或 Object 返回类型,在代码中调用这些接口方法时,可传递对任何类型的实例的引用。但这通常不是我们期望的。下面的代码对此进行了演示:
|
|
接口方法理想情况下应该使用强类型。这正是 FCL 为什么包含泛型 IComparable<in T> 接口的原因。下面修改代码来使用泛型接口:
|
|
泛型接口的第二个好处在于,处理值类型时装箱次数会少很多。在 SomeMethod1 中,非泛型 IComparable 接口的 CompareTo 方法期待获取一个 Object;传递 y(Int32值类型)会造成y中的值装箱。但在 SomeMethod2 中,泛型 IComparable<in T>接口的 CompareTo方法本来期待的就是 Int32;y以传值的方式传递,无需装箱。
注意 FCL 定义了
IComparable,ICollection,IList和IDictionary等接口的泛型和非泛型版本。定义类型时要实现其中任何接口,一般应实现泛型版本。FCL 保留非泛型版本是为了向后兼容,照顾在 .NET Framework 支持泛型之前写的代码。非泛型版本还允许用户以较常规的、类型较不安全(more general,less type-safe)的方式处理数据。
有的泛型接口继承了非泛型版本,所以必须同时实现接口的泛型和非泛型版本。例如,泛型
IEnumerable<out T>接口继承了非泛型IEnumerable接口,所以实现IEnumerable<out T>就必须实现IEnumerable。
和其他代码集成时,有时必须实现非泛型接口,因为接口的泛型版本并不存在。这时,如果接口的任何方法获取或返回
Object,就会失去编译时的类型安全性,而且值类型将发生装箱。可利用本章 13.9 节“用显式接口方法实现来增强编译时类型安全性”介绍的技术来缓解该问题。
泛型接口的第三个好处在于,类可以实现同一个接口若干次,只要每次使用不同的类型参数。以下代码对这个好处进行了演示:
|
|
接口的泛型类型参数可标记为逆变和协变,为泛型接口的使用提供更大的灵活性。欲知协变和逆变的详情,请参见 12.5 节“委托和接口的逆变和协变泛型类型实参”。
13.7 泛型和接口约束
上一节讨论了泛型接口的好处。本节要讨论将泛型类型参数约束为接口的好处。
第一个好处在于,可将泛型类型参数约束为多个接口。这样一来,传递的参数的类型必须实现全部接口约束。例如:
|
|
这真的很“酷”!定义方法参数时,参数的类型规定了传递的实参必须是该类型或者它的派生类型。如果参数的类型是接口,那么实参可以是任意类类型,只是该类实现了接口。使用多个接口约束,实际是表示向方法传递的实参必须实现多个接口。
事实上,如果将 T 约束为一个类和两个接口,就表示传递的实参类型必须是指定的基类(或者它的派生类),而且必须实现两个接口。这种灵活性使方法能细致地约束调用者能传递的内容。调用者不满足这些约束,就会产生编译错误。
接口约束的第二个好处是传递值类型的实例时减少装箱。上述代码向 M方法传递了 x(值类型 Int32 的实例)。x传给M方法时不会发生装箱。如果M方法内部的代码调用t.CompareTo(...),这个调用本身也不会引发装箱(但传给CompareTo的实参可能发生装箱)。
另一方面,如果M方法像下面这样声明:
|
|
那么x要传给M就必须装箱。
C# 编译器为接口约束生成特殊 IL 指令,导致直接在值类型上调用接口方法而不装箱。不用接口约束便没有其他办法让 C# 编译器生成这些 IL 指令,如此一来,在值类型上调用接口方法总是发生装箱。一个例外是如果值类型实现了一个接口方法,在值类型的实例上调用这个方法不会造成值类型的实例装箱。
13.8 实现多个具有相同方法名和签名的接口
定义实现多个接口的类型时,这些接口可能定义了具有相同名称和签名的方法。例如,假定有以下两个接口:
|
|
要定义实现这两个接口的类型,必须使用“显式接口方法实现”来实现这个类型的成员,如下所示:
|
|
由于这个类型必须实现多个接口的 GetMenu 方法,所以要告诉 C# 编译器每个GetMenu方法对应的是哪个接口的实现。
代码在使用MarioPizzeria 对象时必须将其转换为具体的接口才能调用所需的方法。例如:
|
|
13.9 用显式接口方法实现来增强编译时类型安全性
接口很好用,它们定义了在类型之间进行沟通的标准方式。前面曾讨论了泛型接口,讨论了它们如何增强编译时的类型安全性和减少装箱操作。遗憾的是,有时由于不存在泛型版本,所以仍需实现非泛型接口。接口的任何方法接受System.Object类型的参数或返回System.Object类型的值,就会失去编译时的类型安全性,装箱也会发生。本节将介绍如何用“显式接口方法实现”(EIMI)在某种程度上改善这个局面。
下面是极其常用的IComparable接口:
|
|
该接口定义了接受一个 System.Object 参数的方法。可以像下面这样定义实现了接口的类型:
|
|
可用SomeValueType写下面这样的代码:
|
|
上述代码存在两个问题。
-
不希望的装箱操作
v作为实参传给CompareTo方法时必须装箱,因为CompareTo期待的是一个Object。 -
缺乏类型安全性
代码能通过编译,但CompareTo方法内部试图将o转换为SomeValueType时抛出InvalidCastException异常。
这两个问题都可以用 EIMI 解决。下面是 SomeValueType 的修改版本,这次添加了一个 EIMI:
|
|
注意新版本的几处改动。现在有两个 CompareTo 方法。第一个 CompareTo 方法不是获取一个 Object 作为参数,而是获取一个 SomeValueType了,所以用于强制类型转换的代码被去掉了。修改了第一个CompareTo方法使其变得类型安全之后,SomeValueType还必须实现一个CompareTo方法来满足IComparable的协定。这正是第二个IComparable.CompareTo方法的作用,它是一个EIMI。
经过这两处改动之后,就获得了编译时的类型安全性,而且不会发生装箱:
|
|
不过,定义接口类型的变量会再次失去编译时的类型安全性,而且会再次发生装箱:
|
|
事实上,如本章前面所述,将值类型的实例换换为接口类型时,CLR 必须对值类型的实例进行装箱。因此,前面的 Main 方法中会发生两次装箱。
实现 IConvertible,ICollection,IList和IDictionary等接口时 EIMI 很有用。可利用它为这些接口的方法创建类型安全的版本,并减少值类型的装箱。
13.10 谨慎使用显式接口方法实现
使用 EIMI 也可能造成一些严重后果,所以应该尽量避免使用 EIMI。幸好,泛型接口可帮助我们在大多数时候避免使用 EIMI。但有时(比如实现具有相同名称和签名的两个接口方法时)仍然需要它们。EIMI 最主要的问题如下。
-
没有文档解释类型具体如何实现一个
EIMI方法,也没有Microsoft Visual Studio“智能感知”支持。 -
值类型的实例在转换成接口时装箱。
-
EIMI不能由派生类型调用。
下面详细讨论这些问题。
文档在列出一个类型的方法时,会列出显式接口方法实现(EIMI),但没有提供类型特有的帮助,只有接口方法的常规性帮助。例如,Int32类型的文档只是说它实现了IConvertible接口的所有方法。能做到这一步已经不错,它使开发人员知道存在这些方法。但也使开发人员感到困惑,因为不能直接在一个Int32上调用一个IConvertible方法。例如,下面的代码无法编译:
|
|
编译这个方法时,C# 编译器会报告以下消息:error CS0117:“int”不包含“ToSingle”的定义。这个错误信息使开发人员感到困惑,因为它明显是说Int32类型没有定义ToSingle方法,但实际上定义了。
要在一个Int32上调用ToSingle,首先必须将其转换为IConvertible,如下所示:
|
|
对类型转换的要求不明确,而许多开发人员自己看不出来问题出在哪里。还有一个更让人烦恼的问题:Int32值类型转换为IConvertible会发生装箱,既浪费内存,又损害性能。这是本节开头提到的EIMI存在的第二个问题。
EIMI的第三个也可能是最大的问题是,它们不能被派生类调用。下面是一个例子:
|
|
在Derived的CompareTo方法中调用 base.CompareTo导致C#编译器报错。现在的问题是,Base类没有提供一个可供调用的或受保护CompareTo方法,它提供的是一个只能用IComparable类型的变量来调用的CompareTo方法。可将Derived的CompareTo方法修改成下面这样:
|
|
这个版本将this转换成IComparable变量c,然后用c调用CompareTo。但Derived的公共CompareTo方法充当了Derived的IComparable.CompareTo方法的实现,所以造成了无穷递归。这可以通过声明没有IComparable接口的Derived类来解决:
internal sealed class Derived : Base /*, IComparable */ { ... }
现在,前面的CompareTo方法将调用Base中的CompareTo方法。但有时不能因为想在派生类中实现接口方法就将接口从类型中删除。解决这个问题的最佳方法是在基类中除了提供一个被选为显式实现的接口方法,还要提供一个虚方法。然后Derived类可以重写虚方法。下面展示了如何正确定义Base类和Derived类:
|
|
注意,这里是讲虚方法定义成公共方法,但有时可能需要定义成受保护方法。把方法定义为受保护(而不是公共)是可以的,但必须进行另一些小的改动。我们的讨论清楚证明了务必谨慎使用EIMI。许多开发人员在最初接触 EIMI 时,认为 EIMI 非常”酷“,于是开始肆无忌惮地使用。千万不要这样做!.NET在某些情况下确实有用,但应该尽量避免使用,因为它们导致类型变得不好用。
13.11 设计:基类还是接口
经常有人问:”英爱设计基类还是接口?“ 这个问题不能一概而论,以下设计规范或许能帮你理清思路:
- IS-A对比CAN-DO关系①
类型只能继承一个实现,如果派生类型和基类型建立不起 IS-A 关系,就不能用基类而用接口。接口意味着 CAN-DO 关系。如果多种对象类型都”能“做某事,就为它们创建接口。例如,一个类型能将自己的实例转换为另一个类型(IConvertible),一个类型序列化自己的实例(ISerializable)。注意,值类型必须从System.ValueType派生,所以不能从任意的基类派生。这时必须使用CAN-DO关系并定义接口。
① IS-A 是指”属于“,例如,汽车属于交通工具;CAN-DO 是指”能做某事“,例如,一个类型能将自己的实例转换为另一个类型。 ——译注
-
易用性
对于开发人员,定义从基类派生的新类型通常比实现接口的所有方法容易得多。基类型可提供大量功能,所以派生类型可能只需稍做改动。而提供接口的话,新类型必须实现所有成员。 -
一致性实现
无论接口协定(contract)订立得有多好,都无法保证所有人百分之百正确实现它。事实上,COM颇受该问题之累,导致有的 COM 对象只能正常用于Microsoft Office Word 或 Microsoft Internet Explorer。而如果为基类型提供良好的默认实现,那么一开始得到的就是能正常工作并经过良好测试的类型。以后根据需要修改就可以了。 -
版本控制
向基类型添加一个方法,派生类型将继承新方法。一开始使用的就是一个能正常工作的类型,用户的源代码甚至不需要重新编译。而向接口添加新成员,会强迫接口的继承者更改其源代码并重新编译。
FCL 中涉及数据流处理(streaming data)的类采用的是实现继承方案②。System.IO.Stream是抽象基类,提供了包括Read和Write在内的一组方法。其他类(System.IO.FileStream,System.IO.MemoryStream和System.Net.Sockets.NetworkStream)都从Stream派生。在这三个类中,每一个和Stream类都是 IS-A 关系,这使具体类③的实现变得更容易。例如,派生类只需实现同步 I/O 操作,异步 I/O 操作已经从Stream基类继承了。
② 即继承基类的实现。 —— 译注
③ 对应于”抽象类“。 —— 译注
必须承认,为流类(XXXStream)选择继承的理由不是特别充分,因为Stream基类实际只提供了很少的实现。那么就以 Microsoft Windows 窗体控件类为例好了。Button,CheckBox,ListBox 和所有其他窗体控件都从System.Windows.Forms.Control派生。Control实现了大量代码,各种控件类简单继承一下即可正常工作。这时选择继承应该没有疑问了吧?
相反,Microsoft 采用基于接口的方式来设计FCL中的集合。System.Collections.Generic命名空间定义了几个与集合有关的接口:IEnumerable<out T>,ICollection<T>,IList<T>和IDictionary<TKey, Tvalue>。然后,Microsoft 提供了大量类来实现这些接口组合,包括List<T>,Dictionary<Tkey, TValue>,Queue<T>和Stack<T>等等。设计者在类和接口之间选择 CAN-DO 关系,因为不同集合类的实现迥然有异。换句话说,List<T>,Dictionary<Tkey, Tvalue>和Queue<T>之间没有多少能共享的代码。
不过,这些集合类提供的操作相当一致。例如,都维护了一组可枚举的元素,而且都允许添加和删除元素。假定有一个对象引用,对象的类型实现了IList<T>接口,就可在不需要知道集合准确类型的前提下插入、删除和搜索元素。这个机制太强大了!
最后要说的是,两件事情实际能同时做:定义接口,同时提供实现该接口的基类。例如,FCL定义了ICOmparable<in T>接口,任何类型都可选择实现该接口。此外,FCL提供了抽象基类Comparer<T>,它实现了该接口,同时为非泛型IComparer的Compare方法提供了默认实现。接口定义和基类同时存在带来了很大的灵活性,开发人员可根据需要选择其中一个。