第 13 章 接口

第 13 章 接口

本章内容:

13.1 类和接口继承

由于 Microsoft 的开发团队已实现了 Object 的方法,所以从Object派生的任何类实际都继承了以下内容。

  • 方法签名
    使代码认为自己是在操作Object类的实例,但实际操作的可能是其他类的实例。
  • 方法实现
    使开发人员定义Object的派生类时不必手动实现Object的方法。

13.2 定义接口

如前所述,接口对一组方法签名进行了统一命名。注意,接口还能定义事件、无参属性和有参属性(C# 的索引器)。如前所述,所有这些东西本质上都是方法,它们只是语法上的简化。不过,接口不能定义任何构造器方法,也不能定义任何实例字段。

13.3 继承接口

C# 编译器要求将实现接口的方法(后文简称为“接口方法”)标记为 public。CLR 要求将接口方法标记为virtual。不将方法显式标记为virtual,编译器会将它们标记为virtualsealed;这会阻止派生类重写接口方法。将方法显式标记为virtual,编译器就会将该方法标记为virtual(并保持它的非密封状态),使派生类能重写它。

派生类不能重写sealed的接口方法。但派生类可重新继承同一个接口,并为接口方法提供自己的实现。在对象上调用接口方法时,调用的是该方法在该对象的类型中的实现。下例对此进行了演示:

13.4 关于调用接口方法的更多探讨

CLR 允许定义接口类型的字段、参数或局部变量。使用接口类型的变量可以调用该接口定义的方法。此外,CLR 允许调用 Object 定义的方法,因为所有类都继承了 Object 的方法。

重要提示 和引用类型相似,值类型可实现零个或多个接口。但值类型的实例在转换为接口类型时必须装箱。这是由于接口变量是引用,必须指向堆上的对象,使 CLR 能检查对象的类型对象的类型对象指针,从而判断对象的确切类型。调用已装箱值类型的接口方法时,CLR 会跟随对象的类型对象指针找到类型对象的方法表,从而调用正确的方法。

13.5 隐式和显式接口方法实现(幕后发生的事情)

类型加载到 CLR 中时,会为该类型创建并初始化一个方法表(参见第 1 章“CLR的执行模型”)。在这个方法表中,类型引入的每个新方法都有对应的记录项;另外,还为该类型继承的所有虚方法添加了记录项。继承的虚方法既有继承层次结构中的各个基类型定义的,也有接口类型定义的。所以,对于下面这个简单的类型定义:

1
2
3
internal sealed class SimpleType : IDisposable {
public void Dispose() { Console.WriteLine("Dispose"); }
}

类型的方法表将包含以下方法的记录项。

  • Object(隐式继承的基类)定义的所有虚实例方法。
  • IDisposable(继承的接口)定义所有接口方法。本例只有一个方法,即Dispose,因为IDisposable接口只定义了这个方法。
  • SimpleType引入的新方法 Dispose

C#编译器将新方法和接口方法匹配起来之后,会生成元数据,指明 SimpleType 类型的方法表中的两个记录项应引用同一个实现。

1
2
3
4
5
6
7
8
9
10
11
12
public sealed class Program {
public static void Main() {
SimpleType st = new SimpleType();

// 调用公共 Dispose 方法实现
st.Dispose();

// 调用 IDisposable 的 Dispose 方法的实现
IDisposable d = st;
d.Dispose();
}
}
1
2
Dispose
Dispose

现在重写 SimpleType,以便于看出区别:

1
2
3
4
internal sealed class SimpleType : IDisposable {
public void Dispose() { Console.WriteLine("public Dispose"); }
void IDisposable.Dispose() { Console.WriteLine("IDisposable Dispose"); }
}
1
2
public Dispose
IDisposable Dispose

在 C# 中,将定义方法的那个接口的名称作为方法名前缀(例如 IDisposable.Dispose),就会创建显式接口方法实现(Explicit Interface Method Implementation,EIMI)。注意,C# 中不允许在定义显式接口方法时指定可访问性(比如 publicprivate)。但是,编译器生成方法的元数据时,可访问性会自动设为 private,防止其他代码在使用类的实例时直接调用接口方法。只有通过接口类型的变量才能调用接口方法。

还要注意,EIMI 方法不能标记为 virtual,所以不能被重写。这是用于 EIMI 方法并非真的是类型的对象模型的一部分

13.6 泛型接口

泛型接口的一些好处

  1. 泛型接口提供了出色的编译时类型安全性。
  2. 处理值类型时装箱次数会少很多。
  3. 类可以实现同一个接口若干次,只要每次使用不同的类型参数。

13.7 泛型和接口约束

将泛型类型参数约束为接口的好处

  1. 可将泛型类型参数约束为多个接口。这样一来,传递的参数的类型必须实现全部接口约束。
1
2
3
4
5
// M 的类型参数 T 被约束为只支持同时实现了
// IComparable 和 IConvertible 接口的类型
private static Int32 M<T>(T t) where T : IComparable, IConvertible {
...
}
  1. 传递值类型的实例时减少装箱。
    C# 编译器为接口约束生成特殊 IL 指令,导致直接在值类型上调用接口方法而不装箱。

13.8 实现多个具有相同方法名和签名的接口

定义实现多个接口的类型时,这些接口可能定义了具有相同名称和签名的方法。要定义实现这两个接口的类型,必须使用“显式接口方法实现”来实现这个类型的成员,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private interface IWindow {
Object GetMenu();
}

public Interface IRestaurant {
Object GetMenu();
}

// 这个类型派生自 System.Object
// 并实现了 IWindow 和 IRestaurant 接口
public sealed class MarioPizzeria : IWindow, IRestaurant {

// 这是 IWindow 的 GetMenu 方法的实现
Object IWindow.GetMenu() { ... }

// 这是 IRestaurant 的 GetMenu 方法的实现
Object IRestaurant.GetMenu() { ... }

// 这个 GetMenu 方法是可选的,与接口无关
public Object GetMenu() { ... }
}

代码在使用MarioPizzeria 对象时必须将其转换为具体的接口才能调用所需的方法。

13.9 用显式接口方法实现来增强编译时类型安全性

1
2
3
4
5
6
7
8
9
10
11
12
13
internal struct SomeValueType : IComparable {
private Int32 m_x;
public SomeValueType(Int32 x) { m_x = x; }

public Int32 CompareTo(SomeValueType other) {
return (m_x - other.m_x);
}

// 注意以下代码没有指定 pulbic/private 可访问性
Int32 IComparable.CompareTo(Object other) {
return CompareTo((SomeValueType) other);
}
}

经过这两处改动之后,就获得了编译时的类型安全性,而且不会发生装箱:

1
2
3
4
5
6
public static void Main() {
SomeValueType v = new SomeValueType(0);
Object o = new Object();
Int32 n = v.CompareTo(v); // 不发生装箱
n = v.CompareTo(o); // 编译时错误
}

13.10 谨慎使用显式接口方法实现

EIMI 最主要的问题如下。

  • 没有文档解释类型具体如何实现一个 EIMI 方法,也没有Microsoft Visual Studio“智能感知”支持。

  • 值类型的实例在转换成接口时装箱。

  • EIMI不能由派生类型调用。

13.11 设计:基类还是接口

  • 易用性
    对于开发人员,定义从基类派生的新类型通常比实现接口的所有方法容易得多。基类型可提供大量功能,所以派生类型可能只需稍做改动。而提供接口的话,新类型必须实现所有成员。

  • 一致性实现
    无论接口协定(contract)订立得有多好,都无法保证所有人百分之百正确实现它。而如果为基类型提供良好的默认实现,那么一开始得到的就是能正常工作并经过良好测试的类型。以后根据需要修改就可以了。

  • 版本控制
    向基类型添加一个方法,派生类型将继承新方法。一开始使用的就是一个能正常工作的类型,用户的源代码甚至不需要重新编译。而向接口添加新成员,会强迫接口的继承者更改其源代码并重新编译。