接口的运用

接口的运用

1. 为什么需要接口

假设程序中有这些类型:

  • EmailMessage:电子邮件;

  • Report:报表;

  • Invoice:发票。

它们不一定适合继承同一个业务基类,但都可以被打印。我们真正关心的是它们具有“可打印”能力,而不是它们是不是同一种事物。

接口(Interface)就是用来描述这种能力或契约的:

接口规定实现者对外提供哪些成员,但调用者不必知道实现细节。

可以把接口类比为插座标准:标准规定插头形状和电气要求,不同厂家可以采用不同内部结构来满足标准。这个类比强调的是“遵守共同约定”,不代表接口只能用于硬件式功能。

2. 接口的基本语法

使用interface定义接口。C# 中常用大写字母I作为接口名称前缀:

public interface IPrintable { void Print(); }

类在冒号后写出要实现的接口:

public class Report : IPrintable { public string Title { get; set; } = "未命名报表"; ​ public void Print() { Console.WriteLine($"正在打印报表:{Title}"); } }

使用:

Report report = new Report { Title = "年度报表" }; report.Print();

3. 接口是一份契约

实现接口的非抽象类必须提供接口要求的成员:

public interface ISwitchable { void TurnOn(); void TurnOff(); } ​ public class Lamp : ISwitchable { public void TurnOn() { Console.WriteLine("台灯已打开。"); } ​ public void TurnOff() { Console.WriteLine("台灯已关闭。"); } }

如果Lamp漏掉TurnOff(),编译器会报错。接口让“必须提供哪些功能”成为可以由编译器检查的约定。

4. 接口变量与接口多态

接口不能像普通类那样直接创建对象:

// IPrintable item = new IPrintable(); // 编译错误

但是,接口类型的变量可以引用实现该接口的对象:

IPrintable item = new Report { Title = "月度报表" }; item.Print();

多个没有共同业务父类的类型,可以通过同一个接口统一处理:

public class Invoice : IPrintable { public string Number { get; set; } = "未知编号"; ​ public void Print() { Console.WriteLine($"正在打印发票:{Number}"); } } ​ IPrintable[] printableItems = { new Report { Title = "月度报表" }, new Invoice { Number = "FP-001" } }; ​ foreach (IPrintable printable in printableItems) { printable.Print(); }

循环只关心对象是否能够Print(),不关心它是报表还是发票。这也是运行时多态的一种常见形式。

5. 接口可以声明哪些成员

初学阶段最常见的是方法和属性:

public interface IUserService { // 接口属性 int UserCount { get; } ​ // 接口方法 bool AddUser(string name); string? FindUser(int id); }

实现类必须提供兼容的成员:

public class MemoryUserService : IUserService { private readonly List<string> _users = new(); ​ public int UserCount => _users.Count; ​ public bool AddUser(string name) { if (string.IsNullOrWhiteSpace(name)) { return false; } ​ _users.Add(name); return true; } ​ public string? FindUser(int id) { if (id < 0 || id >= _users.Count) { return null; } ​ return _users[id]; } }

现代 C# 接口还支持更多高级成员形式,例如默认实现、静态成员和静态抽象成员。初学时先牢固掌握“实例方法、属性和契约”即可。

6. 接口成员的访问性

没有显式实现代码的普通接口成员默认是公开契约。实现类通常需要使用public

public interface IRunner { void Run(); } ​ public class Athlete : IRunner { public void Run() { Console.WriteLine("运动员开始跑步。"); } }

如果把实现写成private void Run(),它就不能作为普通的公共接口实现。后面介绍的“显式接口实现”语法是另一种特殊情况。

7. 一个类可以实现多个接口

C# 类只能直接继承一个基类,但可以实现多个接口:

public interface IPrintable { void Print(); } ​ public interface ISavable { void Save(string filePath); } ​ public class Document : IPrintable, ISavable { public string Content { get; set; } = string.Empty; ​ public void Print() { Console.WriteLine(Content); } ​ public void Save(string filePath) { Console.WriteLine($"文档已保存到:{filePath}"); } }

Document同时具有“可打印”和“可保存”两种身份:

Document document = new Document { Content = "学习笔记" }; ​ IPrintable printable = document; ISavable savable = document; ​ printable.Print(); savable.Save("note.txt");

这比为了每种能力建立复杂的多层继承关系更灵活。

8. 同时继承基类和实现接口

一个类可以继承一个基类,同时实现多个接口。基类必须写在接口前面:

public abstract class Device { public string Name { get; } ​ protected Device(string name) { Name = name; } } ​ public interface IConnectable { void Connect(); } ​ public interface IRechargeable { void Charge(); } ​ public class SmartPhone : Device, IConnectable, IRechargeable { public SmartPhone(string name) : base(name) { } ​ public void Connect() { Console.WriteLine($"{Name}已连接网络。"); } ​ public void Charge() { Console.WriteLine($"{Name}正在充电。"); } }

这里的设计含义是:

  • SmartPhone是一种Device

  • SmartPhone具有IConnectableIRechargeable所描述的能力。

9. 显式接口实现

如果两个接口包含签名相同但含义不同的成员,或者不希望接口成员直接出现在类的公共表面,可以使用显式接口实现:

public interface IChineseSpeaker { void SayHello(); } ​ public interface IEnglishSpeaker { void SayHello(); } ​ public class BilingualPerson : IChineseSpeaker, IEnglishSpeaker { void IChineseSpeaker.SayHello() { Console.WriteLine("你好!"); } ​ void IEnglishSpeaker.SayHello() { Console.WriteLine("Hello!"); } }

显式实现不能直接通过类变量调用:

BilingualPerson person = new BilingualPerson(); ​ // person.SayHello(); // 编译错误:类上没有普通公共 SayHello() ​ IChineseSpeaker chineseSpeaker = person; IEnglishSpeaker englishSpeaker = person; ​ chineseSpeaker.SayHello(); // 你好! englishSpeaker.SayHello(); // Hello!

显式接口实现的特点:

  • 成员名称写成接口名.成员名

  • 前面不写public

  • 只能通过相应的接口引用访问;

  • 能解决接口成员重名但语义不同的问题;

  • 也可以把较少使用的接口功能从类的常用公共成员中隐藏起来。

不要为了“显得高级”而滥用显式实现。大多数简单接口使用普通公共实现更直观。

10. 接口可以继承接口

接口可以在另一个接口的基础上增加契约:

public interface IReadable { string Read(); } ​ public interface IEditable : IReadable { void Write(string content); }

实现IEditable的非抽象类必须满足它自己和父接口的要求:

public class TextDocument : IEditable { private string _content = string.Empty; ​ public string Read() { return _content; } ​ public void Write(string content) { _content = content; } }

接口还可以继承多个接口,用来组合多种能力。不过接口层次过深会增加理解成本,应保持契约小而清晰。

11. 默认接口实现

从 C# 8 开始,接口成员可以提供默认实现:

public interface ILogger { void Log(string message); ​ void LogWarning(string message) { Log($"警告:{message}"); } } ​ public class ConsoleLogger : ILogger { public void Log(string message) { Console.WriteLine(message); } }

调用默认接口成员时,通常通过接口引用:

ILogger logger = new ConsoleLogger(); logger.LogWarning("磁盘空间不足");

默认接口实现主要用于在不立即破坏已有实现类的前提下扩展接口。对初学者而言,要注意:

  • 接口的核心用途仍然是表达契约;

  • 不要把大量业务状态和实现都塞进接口;

  • 需要共享字段、构造逻辑和大量实现时,抽象类往往更自然;

  • 实际项目还要考虑目标框架和团队规范是否支持或允许这种写法。

12. 接口不能保存每个对象的实例状态

普通类和抽象类可以声明实例字段:

public class Counter { private int _count; }

接口不用于保存每个实现对象自己的实例字段。对象状态应由实现类或结构体保存,接口只规定外部可使用的成员。

现代 C# 允许接口出现某些静态成员,但这不改变接口没有普通实例字段、不能承担每个对象状态存储的基本理解。

13. 结构体也可以实现接口

接口不仅能由类实现,结构体也可以实现:

public interface IFormattableValue { string Format(); } ​ public readonly struct Temperature : IFormattableValue { public double Celsius { get; } ​ public Temperature(double celsius) { Celsius = celsius; } ​ public string Format() { return $"{Celsius:F1} ℃"; } }

把结构体转换为接口引用时可能发生装箱。先根据类型语义选择类或结构体,再在确有性能要求时测量装箱影响。

14. 接口如何降低耦合

如果一个通知服务直接依赖具体邮件类,它就很难改用短信:

public interface IMessageSender { void Send(string message); } ​ public class EmailSender : IMessageSender { public void Send(string message) { Console.WriteLine($"发送邮件:{message}"); } } ​ public class SmsSender : IMessageSender { public void Send(string message) { Console.WriteLine($"发送短信:{message}"); } } ​ public class NotificationService { private readonly IMessageSender _sender; ​ public NotificationService(IMessageSender sender) { _sender = sender; } ​ public void Notify(string message) { _sender.Send(message); } }

使用时选择具体实现:

NotificationService emailService = new NotificationService(new EmailSender()); ​ NotificationService smsService = new NotificationService(new SmsSender()); ​ emailService.Notify("欢迎注册"); smsService.Notify("验证码是 123456");

NotificationService依赖的是IMessageSender契约,不依赖某一种发送方式。以后增加新的实现时,它的核心代码通常不需要修改。这就是接口常用于解耦和依赖注入的原因。

15. 什么时候适合使用接口

以下情况适合优先考虑接口:

  • 多种没有共同父类的类型需要表现同一种能力;

  • 一个类需要同时具备多种独立能力;

  • 调用者只需要知道“能做什么”,不需要知道内部状态和实现;

  • 希望同一功能可以替换不同实现,例如文件存储、数据库存储;

  • 希望便于测试,用简单替代实现代替真实网络、文件或数据库组件;

  • 需要建立稳定的小型公共契约。

接口应尽量职责单一。例如IPrintable只描述打印,不要同时塞入保存、联网、登录和统计等互不相关的功能。

16. 抽象类和接口可以一起使用

二者不是非此即彼。常见设计是:

  • 接口定义对外契约;

  • 抽象类为一组相关实现提供公共代码;

  • 具体类继承抽象类并完成剩余实现。

public interface IExporter { void Export(string content); } ​ public abstract class FileExporter : IExporter { protected string OutputDirectory { get; } ​ protected FileExporter(string outputDirectory) { OutputDirectory = outputDirectory; } ​ public abstract void Export(string content); ​ protected void ShowTarget(string fileName) { Console.WriteLine($"输出到:{OutputDirectory}/{fileName}"); } } ​ public class TextExporter : FileExporter { public TextExporter(string outputDirectory) : base(outputDirectory) { } ​ public override void Export(string content) { ShowTarget("result.txt"); Console.WriteLine(content); } }

调用者可以只依赖IExporter,而相关的文件导出实现可以通过FileExporter复用公共逻辑。