在面向对象 (OOP) 的三大特性之外,接口 (Interface) 构成了一种契约。
C# 的单一继承虽然解决了多重继承带来的复杂性,但也限制了类的设计,难以设计多层级的多态结构。好在 C# 接口的多重实现(一个类能实现多个接口)弥补了这些部分。
模型
接口本质上是一种契约 (Contract),它保证了所有实现该接口的类都具备相同的能力。
原本的接口只是在定义一些方法、属性、事件等成员,同时绝对不提供任何具体实现细节,后者都是类来完成的。
从这个角度就能看出它和继承的区别:继承是is-a关系,接口是can-do关系。因为很多方法并不在乎你是谁,而在乎你能做什么。
这种设计思路同样赋予 OOP 原本自上而下的层级到了一个新的抽象:比如说,IDisposable 接口就定义了一个 Dispose() 方法,任何实现了这个接口的类都能被当作可释放资源来处理,而每个类对于如何释放自己的资源则有自己的实现细节。这种横切的感觉使得接口能够让整个系统的设计变得更低耦合,更高内聚。
同时,接口的多重实现机制比类的多重继承更安全。传统的接口不允许成员实现,也不保存状态,因此在实现多个接口的时候,不会出现成员冲突和状态不一致的问题。以二义性来促使系统设计其实是不如在编译期就捋清楚这一切的。
对于接口与抽象类,可以参阅这篇。
实现
C# 的接口可以分为隐式和显式两种实现方式。
通常,我们接触到的接口实现都是隐式的,即直接在类中实现接口成员,这样接口成员就成为了类的公共成员,可以通过类的实例来访问。
1 | interface IAnimal |
在某些情况下,可能会遇到接口成员与类成员同名的情况,这时就需要使用显式接口实现来解决二义性问题。
1 | interface IControl |
显式实现看起来只是在类里面注明了我在实现哪个接口的哪个方法,但这个区分的意义不止这个。
首先,显式实现的接口成员不成为类的公共成员,只能通过接口类型来访问:
1 | Button button = new Button(); |
在设计时,IDE 的智能提示不会把显式实现的接口成员当作类的公共成员来提示,这样开发者就能保持 API 本身的纯净,避免混淆。
实例
.NET C# 中有很多实用且强大的接口。
迭代器
我们往往在处理数据集的时候使用 LINQ,这时就会用到 IEnumerable<T> 和 IEnumerator<T> 这两个接口。
IEnumerable<T> 是声明该集合可以被枚举的契约,实现了该接口的类就可以使用 foreach 循环来遍历集合中的元素。
观察源代码:
1 | public interface IEnumerable<out T> : IEnumerable |
它只是声明了一个 GetEnumerator() 方法,返回一个 IEnumerator<T> 的实例,而具体的枚举逻辑则由实现这个接口的类来完成。
那IEnumerator<T> 又是什么呢?它是一个迭代器接口,定义了迭代器的基本操作,包括获取当前元素、移动到下一个元素以及重置迭代器等方法。所有想要自己实现IEnumerable<T> 的类都必须提供一个 IEnumerator<T> 的实现来支持迭代器的功能。
这样 C# 就能从语言层面上支持迭代器的概念,让开发者能够自定义自己的集合类型,并且能够使用 foreach 循环来遍历。
资源释放
尽管 C# 的垃圾回收器足够高效,但对于非托管 (Unmanaged) 资源的释放,还是需要开发者来管理的。这个手动管理的过程就需要 IDisposable 接口来提供一个统一的契约。
GC 对于包括文件操作、数据库连接、网络连接等在内的非托管资源是无能为力的,这些资源需要开发者来显式地释放,否则就会导致资源泄漏,甚至可能引发性能问题。
比如System.IO.FileStream(采用OSFileStreamStrategy的实现):
1 | protected sealed override void Dispose(bool disposing) |
文件流被释放时,Dispose 方法会被调用来释放底层的文件句柄资源,确保系统资源得到回收。这样别的软件就能继续使用这些资源了。
协变与逆变
Apple是Fruit的子类,那么IEnumerable<Apple>在逻辑上可以被视为IEnumerable<Fruit>,因为苹果也是水果。
我们再次观察IEnumerable<T>的定义,里面并不是简单的<T>,而是<out T>,这就是协变 (Covariance) 的标记。
An object that is instantiated with a more derived type argument is assigned to an object instantiated with a less derived type argument.
一个对象被实例化时使用了一个相对子类 (More derived) 的类型参数,并被赋值给一个使用了一个相对父类 (Less derived) 的类型参数的对象。
协变允许我们在接口中使用输出类型参数时,保持类型安全的同时实现类型的兼容性。
1 | IEnumerable<Apple> apples = new List<Apple>(); |
协变会保留赋值的兼容性,而逆变则会反转这个过程:
1 | static void SetObject(object o) { } |
Action<T> 是一个逆变 (Contravariant) 的委托类型,以<in T>的形式出现。
An object that is instantiated with a less derived type argument is assigned to an object instantiated with a more derived type argument.
一个对象被实例化时使用了一个相对父类 (Less derived) 的类型参数,并被赋值给一个使用了一个相对子类 (More derived) 的类型参数的对象。
它允许我们将一个接受 object 参数的委托赋值给一个接受 string 参数的委托,因为 string 是 object 的子类。
可以前往Action的源代码观赏登神长阶
协变操作不是类型安全的,因为它允许我们将一个更具体的类型赋值给一个更抽象的类型,这可能会导致在运行时出现类型错误。
1 | object[] array = new String[10]; |
这里不安全是因为从string[]拿元素当作object来用是没问题的,但如果我们试图把一个int放到这个数组里,由于这会破坏string[]的内存结构,就会抛出ArrayTypeMismatchException异常。
当然,这是一个历史遗留问题,自从in和out关键字引入之后,C# 的泛型接口和委托就能够在编译时就保证类型安全。
然而逆变操作是类型安全的,它允许将一个更抽象的类型赋值给一个更具体的类型,因为在这种情况下,所有的操作都是合法的。
1 | Action<object> actObject = (obj) => { |
如果一个人能吃所有的水果,那么他也能吃苹果。
当然,如果既没有in也没有out,那么这个接口就是不变 (Invariant) 的了,这时就不能进行类型转换了:
1 | List<string> strings = new List<string>(); |
默认接口实现
当一个接口被很多很多类实现了之后,如果我们想要在接口中添加一个新的方法,那么就会面临一个问题:所有实现了这个接口的类都必须提供这个新方法的实现,否则就会导致编译错误。
这个很容易引发巨型 Breaking Change 的问题在 C# 8.0 引入了默认接口实现 (Default Interface Implementation) 的特性后得到了很好的解决。
影响
说实话,这玩意对于传统继承和接口的职责划分来说是有点模糊的了。但是,它确实提供了一种在不破坏现有实现的前提下扩展接口功能的方式。
- 在多重实现和默认实现的基础上,接口的功能已经非常接近于Trait了。在架构中,多个完全不相关、无共同祖先的类可以通过实现同一个接口来共享一些行为,及其安全地混入 (Mixin) 到这些类中。在必要时通过重写接口的默认实现来定制行为。
- 接口虽然可以提供方法的默认实现,但它仍然不能包含任何状态(字段)。这是因为接口的设计初衷是为了定义行为的契约,而不是存储数据。
- 接口的默认实现不能隐式地注入到实现类的公共 API 中,调用默认实现的方法必须通过接口类型来访问。
对于第三个:
1 | interface IGreeter |
静态抽象成员
在 C# 11 之前,接口存在一个局限性:它只能定义实例成员的契约,而对静态成员(如静态方法、运算符重载、静态工厂)无能为力。这就导致我们无法通过接口来强制要求一个类必须提供某个特定的静态方法或运算符。
而静态抽象成员 (Static Abstract Members in Interfaces) 彻底打破了这个限制,这也是实现泛型数学 (Generic Math) 的关键。
概念
如果我们想写一个泛型方法来计算一组数字的和,会发现:泛型 <T> 并不知道自己是否支持 + 运算符。
通过引入静态抽象成员,接口现在可以定义静态的属性、方法甚至运算符作为契约。
1 | public interface IAddable<T> where T : IAddable<T> |
应用
当一个类实现这个接口时,它必须提供这些静态成员的具体实现:
1 | public struct MyNumber : IAddable<MyNumber> |
之后你可以直接通过泛型类型参数 T 来调用这些静态成员,而不需要任何实例:
1 | // 约束:T 一定有 + 运算符和 Zero 属性 |
去虚化
传统的接口需要在运行时通过虚方法表 (VTable) 来进行动态分派,这导致 JIT 无法对方法体进行内联优化,从而引入了性能开销。
然而,当我们结合泛型约束 (Generic Constraints) 和值类型时,JIT 能够执行一种称为去虚化 (Devirtualization) 的优化。
当我们把实现了接口的结构体传递给带有泛型约束的方法时(例如 void Process<T>(T item) where T : IInterface),JIT 会为这个特定的值类型生成一份专属的机器码。由于在编译期具体的类型已经完全确定,JIT 就能完全绕过虚方法表,将原本的接口虚方法调用直接转换为直接调用 (Direct Call)。
实例
对于下面的代码:
1 | interface IProcessor |
检查 IL 代码,我们会发现:
1 | IL_0000 nop |
为什么还有个callvirt?因为去虚化是到了 JIT 编译阶段才发生的优化,IL 代码中仍然是一个接口调用的形式。那 JIT 又是怎么知道它可以被去虚化的呢?
我们会发现 IL 中还有个 constrained. 指令,这个指令仅作为callvirt的前缀存在。而存在时,会进行如下检查:
- 如果
T是引用类型,那么它会取消引用,并像普通对象一样正常进行虚方法调用。 - 如果
T是值类型,且实现了 该调用的方法(比如IProcessor.Process()),那么它会直接调用该方法的实例实现,绕过虚方法表。 - 如果
T是值类型,但没有实现 该调用的方法,那么它会被装箱 (Boxing),然后进行虚方法调用。
上述例子会进入第二个情况,从而达到零装箱无多态开销的效果。