在软件工程领域,抽象是指从众多具体实现中提炼出共同特征,以形成稳定的外部契约。在 C# 等面向对象语言中,这种契约通常通过“接口(interface)”体现。接口仅定义所需的功能集合(即方法签名、属性等),并不涉及任何具体实现,体现了“规范与实现分离”的原则。
例如,针对电视、机顶盒、投影仪等多种设备,如果它们均支持“开关”、“切换频道/信号源”等功能,则可以抽象出一个统一的接口作为遥控协议。所有设备只需实现该接口,即可保证操作一致性,实现面向接口编程。
接口的核心价值在于将系统的使用方与具体实现彻底解耦,提高灵活性与可扩展性。
在实际开发中,实现细节经常会发生变更,但接口作为契约应尽量保持稳定。调用方只依赖接口契约,无需关心背后具体实现细节;而实现方则可以在不影响调用方的前提下持续优化、重构或替换实现。这种机制大幅提升了系统的可维护性和可测试性,是大型软件工程中的关键抽象工具。

我们先写一个最小的接口与两种实现:
|using System; // 一个最小的“问候器”接口:约定一件事——给出问候语 public interface IGreeter { string Greet(string name); } // 实现一:中文问候 public class ChineseGreeter : IGreeter { public string Greet(string name) { // 具体怎么问候,由实现决定 return $"你好,{name}"; } } // 实现二:英文问候 public class EnglishGreeter : IGreeter { public string Greet(string name) => $"Hello, {name}"; } class Program { static void Main() { IGreeter g1 = new ChineseGreeter(); IGreeter g2 = new EnglishGreeter(); Console.WriteLine(g1.Greet("小王")); // 你好,小王 Console.WriteLine(g2.Greet("Li")); // Hello, Li } }
我们可以把接口想象成一张“插槽形状”的蓝图,上面清楚地标明了需要哪些功能,但并没有规定这些功能具体怎么实现。每个实现类就像是根据这张蓝图来制作的积木,把所有的“插槽”都填满,提供了具体的实现细节。
这样一来,调用方只需要关心接口本身,不用在意背后是哪一个具体的实现类。只要实现了接口,无论是中文问候还是英文问候,调用方都能用同样的方式与它们交互。这种“面向接口编程”的方式,让我们的代码结构更加灵活,耦合度更低。比如以后我们想增加一个“日语问候”类,只要它实现了同样的接口,原有的代码就可以无缝使用,无需任何修改。这就是接口带来的强大抽象能力和扩展性。
接口不仅能声明方法,还能声明属性、索引器与事件。我们写一个稍完整的接口:
|using System; public interface ICounter { int Value { get; } // 只读属性 void Increment(); // 方法 event Action? Changed; // 事件:当值发生变化时通知订阅者 int this[int index] { get; } // 索引器(示例:返回历史快照) } public class MemoryCounter : ICounter
在接口中,事件其实就像是我们给外部世界留的“门铃”——当某个重要的事情发生时(比如数据变化),我们可以通过事件通知所有关心这件事的对象。这样,接口的实现者只需要在合适的时候“按门铃”,外部订阅者就能收到消息,做出响应,非常适合解耦和扩展。
而属性和索引器,则让接口的“数据访问”变得更加自然和直观。属性就像是给对象贴上的标签,外部可以直接读取或设置这些标签的值;索引器则让我们像访问数组一样,通过下标来获取或设置对象内部的数据。这种设计让接口不仅能描述“能做什么”,还能描述“能访问什么数据”,让我们的代码既清晰又易于维护。
在较新的 C# 版本中,接口可为成员提供“默认实现”。这能缓解某些“接口演进”带来的破坏(比如给接口加了新方法,老的实现还来不及改)。不过默认实现也带来复杂度,应谨慎使用。
|public interface ILogger { void Log(string message); // C# 8+ 默认实现:如果实现类没覆写,调用此默认逻辑 void Info(string message) { Log("[INFO] " + message); } } public class ConsoleLogger : ILogger { public void Log(string message)
默认接口成员是“版本化”的工具,而不是把接口当成“半个抽象类”的借口。若需要共享大量实现,优先考虑抽象类与组合。
有时候,我们会让一个类实现好几个接口,而这些接口里可能会有名字一样的方法或者属性。这个时候,如果我们直接实现这些接口,类里就会出现“重名”的成员,容易引起混淆。还有一种情况是,我们希望某些接口的方法只让特定的接口使用者看到,而不想让所有人都能直接通过类的实例访问到。这个时候,C# 提供了“显式接口实现”这个小技巧。
通过显式接口实现,我们可以把接口的方法“藏起来”,只有当我们把对象强制转换成对应的接口类型时,才能调用这些方法。这样既能解决命名冲突的问题,也能让类的公共 API 更加干净整洁,非常适合在复杂场景下使用。
举个例子,如果我们有两个接口都叫 Start,但它们的含义不同,我们就可以用显式接口实现来区分它们的行为。
|using System; public interface ICanRun { void Start(); } public interface ICanStart { void Start(); } public class Engine : ICanRun, ICanStart { void ICanRun.Start() { Console.WriteLine("Run as runner"); } void ICanStart.Start(){ Console.WriteLine
我们可以把显式接口实现理解为:把接口的方法“藏”在接口的专属通道里。这样,类本身不会直接暴露这些成员,只有当我们把对象当作接口来用时,才能访问到这些方法。这样做有两个好处:一是可以避免不同接口里同名方法产生的冲突,二是让类的公共 API 更加简洁、干净。比如说,如果一个类实现了两个接口,这两个接口里都有叫 Start 的方法,我们就可以用显式实现分别实现它们。这样,只有通过接口变量才能调用对应的 Start 方法,普通情况下类的实例是看不到这些“专属”方法的。这种方式特别适合在复杂系统中,既要满足多个接口的要求,又不希望类的公共成员被搞得乱七八糟的时候使用。
在实际开发中,我们经常会遇到这样的情况:我们的代码需要获取当前时间、访问文件系统、或者请求网络数据。这些操作都属于“外部依赖”,如果我们直接在代码里调用像 DateTime.Now、File.ReadAllText、HttpClient 这样的静态 API,看起来很方便,但其实会带来一个大麻烦——测试变得非常困难。比如说,我们想要测试一个根据当前时间问候用户的功能,如果直接用 DateTime.Now,每次测试的结果都可能不一样,根本没法控制。
为了解决这个问题,我们可以把这些外部依赖“抽象”出来,也就是先定义一个接口,比如 IClock 表示时钟、IFileSystem 表示文件系统、INetworkClient 表示网络客户端。然后在实际运行时,把具体的实现(比如 SystemClock、RealFileSystem、HttpNetworkClient)“注入”到我们的业务代码里。这样一来,我们在测试的时候就可以传入“假的”实现,比如 FakeClock、FakeFileSystem、FakeNetworkClient,让测试变得可控、可预测。
|using System; public interface IClock { DateTime Now { get; } } public class SystemClock : IClock { public DateTime Now => DateTime.Now; } public class Greeter { private readonly IClock _clock; public Greeter(
当一个接口太大,迫使实现类去实现很多用不上的成员,这就是“胖接口”。更好的做法是拆分成多个小接口,让实现类只依从它所需。
|// 胖接口(反例) public interface IPrinter { void Print(); void Scan(); void Fax(); } // 瘦身后 public interface ICanPrint { void Print(); } public interface ICanScan { void Scan(); } public interface ICanFax { void Fax(); } public
“需要什么就说什么”(接口瘦身),能有效降低耦合、提升复用度。调用方也只依赖它真的需要的能力。
在 C# 里,我们经常会遇到带有类型参数的接口,比如 IEnumerable<T> 或 IComparer<T>。
这些接口不仅让我们可以处理各种类型的数据,还经常会用到“协变(out)”和“逆变(in)”这样的关键字。那这两个词是什么意思呢?其实它们就是帮我们在泛型接口之间做类型兼容时,变得更加灵活和安全。
比如说,协变(用 out 修饰)表示这个类型参数只会被当作输出用,也就是只会被“产出”,不会被“消费”。这样的话,我们就可以把一个返回更具体类型的对象,当作返回更抽象类型的对象来用。反过来,逆变(用 in 修饰)表示类型参数只会被当作输入用,也就是只会被“消费”,不会被“产出”。这样我们就能把一个处理更抽象类型的处理器,当作处理更具体类型的处理器来用。
举个生活中的例子:假如我们有一个“动物”类和一个“狗”类,狗是动物的子类。如果有个接口只负责“产出”动物(比如动物工厂),那我们完全可以用一个“狗工厂”来代替“动物工厂”,因为狗也是动物嘛。这就是协变。如果有个接口只负责“消费”动物(比如动物喂食器),那我们也可以用一个“动物喂食器”来喂狗,因为喂食器本来就能喂所有动物,这就是逆变。
|// 协变:out T —— 只输出 T(不消费),可以把“更具体”的集合当作“更抽象”的集合来用 public interface IReadOnlySource<out T> { T Get(); } // 逆变:in T —— 只输入 T(不产出),可以把“更抽象”的处理器当作“更具体”的处理器来用 public interface IProcessor<in T> { void Process(T item); } class Animal { } class Dog : Animal { }
在 C# 的泛型接口中,我们经常会遇到“约束”这个概念。所谓约束,就是我们可以规定类型参数 T 必须满足某些条件,比如必须实现某个接口、必须有无参构造函数等等。举个例子,如果我们写 where T : IDisposable,意思就是 T 必须是能被释放资源的类型,这样我们在代码里就可以放心地调用 T 的 Dispose 方法了。
说到接口和泛型,最常见的例子就是 IEnumerable<T> 和 IEnumerator<T> 这两个接口。它们是 C# 集合和遍历的基石。
我们平时用的 foreach 语句,其实背后就是靠这两个接口在默默工作。我们会实现一个 Range 类,让它能像数组一样被 foreach 遍历。这个类会实现 IEnumerable<int> 接口,而它的枚举器则实现 IEnumerator<int>。
这样一来,我们就能用 foreach 语法来遍历我们自定义的集合了。
|using System; using System.Collections; using System.Collections.Generic; // 一个简单的只读数列:1..N public class Range : IEnumerable<int> { private readonly int _end; public Range(int end) => _end = end;
两者都能表示“抽象”。差异主要在:
|// 抽象类:共享基础实现 public abstract class Worker { public string Name { get; } protected Worker(string name) => Name = name; public abstract void Work(); public virtual void Report() => Console.WriteLine($"{Name} 报告进度"
在一个系统中,抽象类与接口往往搭配使用:抽象类提供默认骨架,接口提供横切能力与“多通道”协作。
4. 接口最小实现练习
实现 IAdder 接口,完成 SimpleAdder 类:
IAdder 接口定义了 Add(int a, int b) 方法,返回两个整数的和SimpleAdder 类需要实现 IAdder 接口Add 方法,返回 a + bMain 方法中测试:创建 SimpleAdder 实例,调用 Add 方法并输出结果|using System; public interface IAdder { int Add(int a, int b); } class SimpleAdder : IAdder { public int Add(int a, int b) { return a + b; }
5. 显式实现与公开 API 练习
实现 IA 接口,使用显式接口实现,让 X 类对外不暴露 Run 方法:
IA 接口定义了 Run() 方法X 类需要实现 IA 接口,但使用显式实现void IA.Run() { ... }Main 方法中测试:直接创建 X 对象不能调用 Run,需要通过接口类型调用|using System; interface IA { void Run(); } class X : IA { // 显式实现:只有通过接口类型才能调用 void IA.Run() { Console.WriteLine("X.Run (explicit)"); } } class Program { static void Main
6. 事件在接口中的使用练习
实现 ITimer 接口,完成一个简单的计时器:
ITimer 接口定义了 event Action Tick 事件和 StartOnce() 方法Tick 事件(使用 event Action? Tick;)StartOnce() 方法,在方法中触发 Tick 事件Main 方法中测试:订阅 Tick 事件,调用 StartOnce() 方法,验证事件被触发|using System; interface ITimer { event Action? Tick; void StartOnce(); } class SimpleTimer : ITimer { public event Action? Tick; public void StartOnce() { Console.WriteLine("Timer started");
7. 默认接口方法练习
扩展 ILog 接口,添加 Info 方法的默认实现:
ILog 接口定义了 Log(string m) 方法Info(string m) 方法,提供默认实现Info 方法Main 方法中测试:创建实现类,调用 Info 方法|using System; interface ILog { void Log(string m); // 默认接口方法(C# 8+) void Info(string m) { Log($"[INFO] {m}"); // 调用Log方法,添加INFO前缀 } } class ConsoleLogger : ILog { public
8. 协变与逆变判断练习
分析以下两个接口,判断哪个应该使用 out(协变),哪个应该使用 in(逆变):
IReadOnlyBox<T> 接口:只有 T Get() 方法,只输出 T,不消费 TIConsumer<T> 接口:只有 void Use(T x) 方法,只消费 T,不输出 Tout):类型参数只作为输出(返回值),可以将更具体的类型当作更抽象的类型in):类型参数只作为输入(参数),可以将更抽象的类型当作更具体的类型请修改接口定义,添加 out 或 in 关键字。
|using System; // 协变:out T - 只输出T,不消费T interface IReadOnlyBox<out T> { T Get(); // T只作为返回值(输出) } // 逆变:in T - 只输入T,不产出T interface IConsumer<in T> { void Use(T x); // T只作为参数(输入) } class Box<T> :
9. 接口隔离练习
将以下"胖接口"拆分为多个小接口,遵循接口隔离原则:
IDevice 包含三个方法:Print()、Scan()、Fax()IPrinter、IScanner、IFaxer请完成接口拆分,并创建两个实现类:一个只支持打印,一个支持所有功能。
|using System; // 拆分后的瘦接口 public interface IPrinter { void Print(); } public interface IScanner { void Scan(); } public interface IFaxer { void Fax(); } // 简单打印机:只实现打印功能 class SimplePrinter : IPrinter {
10. IEnumerable 实现片段练习
实现 Bag<T> 类,让它实现 IEnumerable<T> 接口:
Bag<T> 类内部使用 List<T> 存储元素IEnumerable<T> 接口需要:
GetEnumerator() 方法,返回 IEnumerator<T>IEnumerable.GetEnumerator() 方法(显式实现)_list.GetEnumerator() 直接返回列表的枚举器Main 方法中测试:使用 foreach 遍历 Bag 中的元素|using System; using System.Collections; using System.Collections.Generic; class Bag<T> : IEnumerable<T> { private readonly List<T> _items = new List<T>(); public void
11. 接口 vs 抽象类选择题
根据以下场景,选择合适的抽象方式:
请说明在场景A和场景B中应该选择接口还是抽象类,并解释原因。
|using System; // 场景A:需要共享实现和状态 -> 使用抽象类 public abstract class Worker { protected string Name { get; } // 共享状态 protected Worker(string name) => Name = name; // 共享实现 public virtual void Report() => Console.WriteLine($"{Name
|8
说明:
: IAdder 表示实现接口|X.Run (explicit)
说明:
void IA.Run() 语法,方法名前加接口名|Timer started Tick event fired!
说明:
event Action? Tick;?.Invoke() 安全触发事件(如果为null则不触发)|[INFO] 程序启动 直接日志
说明:
|hello world
说明:
out T:协变,类型参数只作为输出(返回值)in T:逆变,类型参数只作为输入(参数)string)当作更抽象的类型(如 object)使用object)当作更具体的类型(如 string)使用|打印中... 打印中... 扫描中... 传真中...
说明:
IPrinter,不需要实现 Scan() 和 Fax()|苹果 香蕉 橙子
说明:
IEnumerable<T> 接口使类型可以被 foreach 遍历GetEnumerator() 方法:泛型和非泛型版本GetEnumerator() 方法IEnumerable<T> 后,类型就可以使用 LINQ 和 foreach 语法|张三 在写代码 张三 报告进度 文件内容 写入: 新数据
说明: