在软件工程领域,抽象是指从众多具体实现中提炼出共同特征,以形成稳定的外部契约。在 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 语法张三 在写代码
张三 报告进度
文件内容
写入: 新数据说明: