Menu

C# 接口:声明、实现、默认方法与示例

C# 中接口的工作方式:如何声明接口,在类和结构体中实现它,把接口当作类型使用,同时实现多个接口,显式实现,你最常实现的框架接口(IComparable<T>、IEnumerable<T>、IDisposable),以及 C# 8 的默认接口方法。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

接口是一份契约:一个类型承诺会提供的成员清单。它只说明一个类型能做什么,完全不涉及怎么做。类和结构体实现接口,而针对接口编写的代码可以和每一个实现一起工作,包括以后才写出来的实现。

声明和实现接口

接口声明的成员没有方法体。按照约定,接口名以 I 开头。类通过在冒号后面列出接口,并为每个成员提供一个 public 成员来实现它:

输出:

15% off: 68
10 off: 70
Best for 80: 68
Best for 40: 30

BestPrice 对 PercentOff 和 FixedOff 一无所知。它只依赖 IDiscount,所以以后再加一个 BuyOneGetOne 类,它也无需任何修改。这种解耦正是接口存在的全部意义。

关于声明的几条规则:

  • 接口成员默认是 public 的,实现它们的成员也必须是 public 的(显式实现除外,见下文)。
  • 接口可以声明方法、属性、索引器和事件,但不能声明实例字段或构造函数。
  • 漏掉某个成员的类无法编译:报 CS0535 错误,“'FixedOff' does not implement interface member 'IDiscount.Apply(decimal)'”。
  • 你不能用 new 创建接口。你要创建一个实现它的类,然后可以用接口类型的变量来持有它:IDiscount d = new FixedOff(5);。

把接口当作类型

变量、参数、字段或返回类型都可以声明为接口类型。这样它就能接受任何实现了该接口的对象,并且只暴露接口的成员。你可以借此编写不关心具体收到哪个实现的代码,测试时也能借此换上一个假实现:

public class Checkout
{
    private readonly IPaymentGateway gateway;          // not StripeGateway, not PayPalGateway
    public Checkout(IPaymentGateway gateway) { this.gateway = gateway; }

    public bool Pay(decimal amount) => gateway.Charge(amount);
}

// Production: new Checkout(new StripeGateway(apiKey))
// Unit test:  new Checkout(new FakeGateway(alwaysSucceeds: true))

ASP.NET Core 中的依赖注入容器就建立在这一点之上:服务按接口注册,也按接口获取。

实现多个接口

一个类最多只有一个基类,但可以实现任意数量的接口。先写基类,再写接口,用逗号分隔:

输出:

INVOICE Studio rent March: 950.00
True
True

接口也可以继承其他接口:interface IRepository<T> : IReadRepository<T> 在继承来的成员之外再添加新成员,实现 IRepository<T> 的类必须提供这两组成员。

显式接口实现

有时两个接口声明了同名但含义不同的成员,或者某个接口成员放在类自己的公开成员里毫无意义。这时可以显式实现它:在成员名前加上接口名,并省略访问修饰符:

输出:

Desk lamp,34.90
Desk lamp for 34.90

显式实现的成员只能通过接口访问。框架用这种方式让常用类型保持清爽:数组显式实现了 IList.Add(它会抛出 NotSupportedException,因为数组长度是固定的),所以你输入 array. 时这个方法不会出现。

你会实现的框架接口

实现一个标准接口,就能把你的类型接入现有的框架代码。有三个接口经常出现。

IComparable<T> 给类型一个自然顺序,List<T>.Sort()、Array.Sort 和 Max() 都会用到它。CompareTo 返回负数、零或正数:

输出:

1.4, 2.9, 2.10, 10.0

如果把这些版本号当作字符串排序,结果会是 1.4, 10.0, 2.10, 2.9。没有 IComparable<T> 的话,Sort() 会抛出 InvalidOperationException,因为它无从比较两个 Version 对象。

IEnumerable<T> 让类型可以用在 foreach 中,也可以配合 LINQ 使用。实现 GetEnumerator 最简单的方式是用 yield return,IEnumerable 与 yield 页面对此有详细介绍。

IDisposable 标记一个持有必须释放的资源(文件句柄、连接、计时器)的类型。它唯一的方法 Dispose,就是 using 语句在代码块结束时调用的方法,即使抛出了异常也会调用:

输出:

open sales.txt
  write to sales.txt: March total: 12400
close sales.txt
after using

默认接口方法(C# 8)

过去,给一个已发布的接口添加成员,会让每个实现它的类都编译失败。从 C# 8 开始,接口成员可以带有方法体,实现类会继承它,除非提供了自己的版本:

public interface ILogger
{
    void Write(string message);

    // New in version 2 of the library. Existing implementers keep compiling.
    void Error(string message) => Write("ERROR: " + message);
}

public class ConsoleLogger : ILogger
{
    public void Write(string message) => Console.WriteLine(message);
}

ILogger log = new ConsoleLogger();
log.Error("disk full");            // ERROR: disk full

var direct = new ConsoleLogger();
// direct.Error("x");              // does not compile: the default method belongs to the interface

最后一行正是让人意外的地方:默认方法不会被继承为类自己的成员,所以只能通过接口类型调用。默认方法也不能使用实例字段(接口没有实例字段);它们通过接口的其他成员完成工作。这个特性需要 .NET Core 3.0 或更高版本,在 .NET Framework 上不可用。

C# 8 还允许接口中有静态成员,C# 11 又加入了静态抽象成员,让泛型代码可以调用 T 上的静态方法或运算符。.NET 7 的泛型数学(INumber<T>、where T : INumber<T>)就是建立在它之上的。

用接口还是抽象类?

当一种能力需要由互不相关的类型共享、需要结构体参与,或者一个类需要扮演好几个这样的角色时,用接口。当各个实现是同一种东西的不同变体,并且共享状态或固定的算法时,用抽象类。抽象类页面有一张并排对比的表格。很多设计会两者并用:给使用方一个接口,再给实现者一个抽象基类,把那些枯燥的部分先实现好。

常见错误

  • 缺少实现或实现不是 public。 每个接口成员都需要一个签名匹配的 public 成员,或者一个显式实现。否则会报 CS0535(如果方法存在但不是 public,则报 CS0737)。
  • 只有一个实现的接口,“以防万一”。 只有当存在多个实现或需要测试替身时,接口才物有所值。否则它只是又多了一个要翻找的文件。
  • 臃肿的接口。 一个有二十个成员的接口会强迫每个实现者写出二十个成员。把它拆成更小的角色(IReader、IWriter),让类自由组合。
  • 把接口强制转换回类。 ((StripeGateway)gateway).Refund() 抵消了解耦的效果。如果调用方需要 Refund,它就应该放进接口。
  • 以为能在类上调用默认接口方法。 它要通过接口类型来调用。

常见问题

C# 中的接口是什么?

接口是一组有名字的成员(方法、属性、事件、索引器),某个类型承诺会提供它们,接口本身不含实例状态。interface IDiscount { decimal Apply(decimal price); } 的意思是“任何 IDiscount 都能把自己应用到一个价格上”。类和结构体用冒号来实现它,之后代码就可以通过接口类型使用其中任意一个实现。

C# 中如何实现一个接口?

在类声明的冒号后面列出接口(如果有基类,写在基类之后),并为每个接口成员提供一个 public 成员:class HolidayDiscount : IDiscount { public decimal Apply(decimal price) => price * 0.9m; }。缺少成员会报 CS0535 错误。Visual Studio 和 Rider 可以通过快速修复自动生成这些成员的骨架。

C# 的类可以实现多个接口吗?

可以,想实现几个都行,用逗号分隔:class Invoice : IPrintable, IComparable<Invoice>, IDisposable。这就是 C# 对多重继承的回答:一个类只有一个基类,却可以扮演多个角色。如果两个接口声明了同一个成员,一个 public 方法可以同时满足两者,你也可以用显式实现给每个接口各自的版本。

C# 中的显式接口实现是什么?

实现成员时在前面加上接口名,并且不写访问修饰符:string IExportable.Format() { ... }。这样这个成员只能通过接口类型的变量调用,不能通过类调用。它用于解决两个接口之间的命名冲突,也用于把不常用的接口成员从类的公开成员中隐藏起来。

C# 中的默认接口方法是什么?

从 C# 8 开始,接口成员可以带有方法体:void Error(string m) => Write("ERROR: " + m);。实现它的类会自动得到这个方法,也可以提供自己的版本。这个特性的存在,是为了让库的作者可以给已发布的接口添加成员,而不会让每个实现者都编译失败。默认方法要通过接口类型调用,而不是通过类。

为什么 C# 的接口名以 I 开头?

这是 .NET 的命名约定(IEnumerable、IDisposable、IComparable<T>):大写字母 I 后面跟一个 PascalCase 名称,通常是以“-able”结尾的形容词。编译器并不强制要求,但遵循它能让人一眼认出接口类型,尤其是在类声明中,基类和接口写在同一个列表里的时候。

Coddy 编程语言插图

和 Coddy 一起学编程

开始