订单已经完成,接下来可能要发邮件、短信、站内信,也可能在自动化测试里什么都不发,只记录一次调用。如果结算逻辑亲自创建这些对象,每增加一个通道都要改动核心流程;如果结算逻辑只认识一个稳定契约,通道就能独立替换。
这正是接口最实用的意义:它不是为了“让代码看起来更面向对象”,而是为了划清责任边界。调用方声明自己需要什么,实现方决定具体怎样完成。本章以订单通知为贯穿案例,从语法走到设计判断,并把抽象类、组合、依赖注入、泛型变体和静态抽象接口成员放在同一张地图里。

读完并完成练习后,你应该能够:
out、in 与静态抽象接口成员的用途和边界。建议沿着“契约 → 成员 → 接口演进 → 注入 → 接口隔离 → 抽象选择 → 现代泛型”的顺序学习。每节后的小测不是装饰:先回答,再展开解析。
先定义最小契约。接口成员默认是公开契约,实现类中的对应成员也必须可公开访问。
public interface INotifier
{
void Send(string message);
}
public sealed class EmailNotifier : INotifier
{
public void Send(string message) =>
Console.WriteLine($"Email queued: {message}");
}
public sealed class SmsNotifier : INotifier
{
public void Send(string message) =>
Console.WriteLine($"SMS queued: {message}");
}结算服务通过构造函数取得接口,而不是在内部 new EmailNotifier():
public sealed class OrderCheckout
{
private readonly INotifier _notifier;
public OrderCheckout(INotifier notifier)
{
_notifier = notifier;
}
public void Complete(string orderId)
{
Console.WriteLine($"Completed: {orderId}");
_notifier.Send($"Order {orderId} is ready");
}
}
var checkout = new OrderCheckout(new EmailNotifier());
checkout.Complete("A-2048");输出:
Completed: A-2048
Email queued: Order A-2048 is ready把最后两行改成 new SmsNotifier(),OrderCheckout 的实现无需变化。这里的多态不是魔法:变量的静态类型是 INotifier,运行时对象仍然是具体通知器,虚方法分派会选择该对象的实现。
接口可以声明实例方法、属性、索引器和事件。它规定外部可观察的形状,但不规定字段如何存储、集合如何组织,也不替实现对象保存每份实例状态。
public interface INotificationChannel
{
string Name { get; }
bool IsReady { get; }
string this[int index] { get; }
event EventHandler<string>? Sent;
void Send(string message);
}
public sealed class MemoryChannel : INotificationChannel
{
private readonly List<string> _messages = [];
public string Name => "memory";
public bool IsReady => true;
public string this[int index] => _messages[index];
public event EventHandler<string>? Sent;
public void Send(string message)
{
_messages.Add(message);
Sent?.Invoke(this, message);
}
}调用代码只通过接口就能读取属性、按下标查看历史并订阅事件:
INotificationChannel channel = new MemoryChannel();
channel.Sent += (_, text) => Console.WriteLine($"Observed: {text}");
channel.Send("Order A-2048 is ready");
Console.WriteLine($"{channel.Name}[0] = {channel[0]}");Observed: Order A-2048 is ready
memory[0] = Order A-2048 is ready事件暴露的是订阅能力,而不是让外部任意触发事件;只读属性暴露的是查询能力,而不是实现内部字段。接口越准确,实现就越不容易被迫泄漏状态。

默认接口成员给契约提供一个可继承的默认行为,主要用途是让已经发布的接口以相对兼容的方式增加小能力。
public interface INotifier
{
void Send(string message);
void SendOrderReady(string orderId)
{
Send($"Order {orderId} is ready");
}
}
INotifier notifier = new EmailNotifier();
notifier.SendOrderReady("A-2048");要注意调用视图:如果 EmailNotifier 没有自行声明 SendOrderReady,那么 new EmailNotifier().SendOrderReady(...) 不可用;默认成员属于接口契约,应通过 INotifier 视图调用。它适合短小、无状态、能从已有成员推导出的行为,不适合堆放一整套共享业务流程。
显式接口实现解决的是另一类问题:同一个类扮演两个角色,而角色拥有同名但不同义的成员。
public interface IUserReset
{
void Reset();
}
public interface IAdminReset
{
void Reset();
}
public sealed class NotificationConsole : IUserReset, IAdminReset
{
void IUserReset.Reset() => Console.WriteLine("Clear current filters");
void IAdminReset.Reset() => Console.WriteLine("Reset all settings");
}
var console = new NotificationConsole();
((IUserReset)console).Reset();
((IAdminReset)console).Reset();显式成员不出现在类本身的公共成员列表中,必须先转换成对应接口。它既能消除冲突,也能防止低频角色成员挤满类的主要 API;但如果调用方总在强制转换,说明角色边界可能设计得不自然。

“面向接口”不是把参数类型机械地改成接口,而是让高层策略不再创建低层细节。最直接的做法是构造函数注入:对象创建时就拿到完整依赖,字段可以保持只读,非法的半初始化状态也更少。
测试中,我们不想真的发送邮件。一个记录型替身就足够:
public sealed class RecordingNotifier : INotifier
{
public List<string> Messages { get; } = [];
public void Send(string message) => Messages.Add(message);
}
var fake = new RecordingNotifier();
var checkout = new OrderCheckout(fake);
checkout.Complete("T-100");
Console.WriteLine(fake.Messages.Count);
Console.WriteLine(fake.Messages[0]);Completed: T-100
1
Order T-100 is ready好的测试替身只实现测试真正需要观察的行为。若为了替换一个依赖,不得不伪造十几个无关成员,问题通常不是测试框架,而是接口过胖。
依赖注入容器可以自动完成大型应用的装配,但容器不是这一原则的前提。手写 new OrderCheckout(new EmailNotifier()) 已经是依赖注入;容器只是把对象图的组装集中起来。

接口隔离的判断单位是调用者,而不是某个庞大的领域名词。后台管理需要暂停通道,结算流程只需要发送;二者不应被迫依赖同一个“大而全”的接口。
public interface IMessageSender
{
void Send(string message);
}
public interface IChannelHealth
{
bool IsReady { get; }
}
public interface IChannelControl
{
void Pause();
void Resume();
}同一个实现可以同时满足多个角色,但每个调用者只接收自己需要的窄接口。这样修改控制能力时,不会迫使只负责发送的代码重新编译或重新模拟无关行为。
组合则把可变算法做成协作者。假设通知需要重试,与其创建 RetryingEmailNotifier : EmailNotifier、RetryingSmsNotifier : SmsNotifier 的继承组合,不如让重试包装任意发送器:
public sealed class RetryingNotifier(INotifier inner, int attempts) : INotifier
{
public void Send(string message)
{
for (var i = 1; i <= attempts; i++)
{
try
{
inner.Send(message);
return;
}
catch when (i < attempts)
{
Console.WriteLine($"Retry {i}");
}
}
}
}包装器依赖接口,因此邮件、短信和未来实现都能复用同一重试策略。组合把“是什么”的继承关系,转换成“拥有哪个协作者”的装配关系。
三者不是晋级关系。最好的选择取决于真正需要稳定的东西。
抽象类可以拥有实例字段、受保护成员、构造函数以及抽象/具体成员混合:
public abstract class NotifierBase
{
protected NotifierBase(string senderName)
{
SenderName = senderName;
}
protected string SenderName { get; }
public void SendWithAudit(string message)
{
Console.WriteLine($"[{SenderName}] sending");
SendCore(message);
}
protected abstract void SendCore(string message);
}如果“发送者名称 + 审计骨架”确实属于同一家族且长期稳定,抽象类很自然。如果它们会被完全不同的服务复用,独立的 IAuditWriter 组合往往更灵活。
假设 Dog : Animal。普通泛型类默认不变,因此即使 Dog 能赋给 Animal,Box<Dog> 也不能自动赋给 Box<Animal>。否则调用方可能向“动物盒子”放入一只猫,破坏原盒子只装狗的承诺。
只产出值的接口可以声明协变:
public interface IProducer<out T>
{
T Create();
}
IProducer<Dog> dogs = GetDogProducer();
IProducer<Animal> animals = dogs;out T 表示类型参数出现在输出位置。生产狗的对象当然也能被当作生产动物的对象。
只消费值的接口可以声明逆变:
public interface IConsumer<in T>
{
void Accept(T value);
}
IConsumer<Animal> animals = GetAnimalConsumer();
IConsumer<Dog> dogs = animals;能处理任何动物的消费者,当然也能处理狗。变体转换只适用于引用类型;并且编译器会限制 T 的使用位置,防止接口做出不安全承诺。
传统接口多态作用于对象实例。现代 C# 还允许接口声明静态抽象成员,使泛型约束能够表达“类型本身必须提供某个运算”。
public interface ICombiner<TSelf>
where TSelf : ICombiner<TSelf>
{
static abstract TSelf Empty { get; }
static abstract TSelf operator +(TSelf left, TSelf right);
}
public static T Sum<T>(IEnumerable<T> values)
where T : ICombiner<T>
{
var total = T.Empty;
foreach (var value in values)
{
total += value;
}
return total;
}T.Empty 与 + 在编译期由约束保证存在,泛型算法不需要反射或 dynamic。它非常适合数值、单位、解析等类型级协议,但不应拿来替代普通实例接口:通知发送显然仍然是对象行为。
设计这类接口时,重点是代数语义是否清晰。例如 Empty 是否真的是加法单位元、+ 是否满足调用者预期。语法能约束成员存在,却不能替你证明语义正确。
接口设计最常见的问题通常不在分号,而在边界。
IDatabaseEverything 往往迫使只读报表依赖写入与管理能力。现在完成一个小型订单通知模块:
INotifier.Send(string message);EmailNotifier 与 RecordingNotifier;OrderCheckout 只通过构造函数依赖接口;RecordingNotifier 增加消息列表,并验证完成订单后恰好记录一次;RetryingNotifier 包装器,不改动任何已有通知器;IChannelHealth,只让需要它的调用者依赖。验收标准不是“用了多少接口”,而是新增 ConsoleNotifier 时不修改结算流程,测试时不触发真实外部系统,重试策略可以包装任意通知器。
接口是一份面向调用者的契约:方法、属性、索引器和事件共同描述可用能力,具体对象负责状态与行为。默认接口成员适合谨慎演进契约;显式实现适合区分同名角色并收窄类表面 API。
当业务对象通过构造函数依赖小接口,生产实现、测试替身和装饰器就能自由替换。接口隔离让每个调用者只看到自己需要的角色,组合则把重试、缓存、审计等变化维度从继承树中拆开。需要共享状态和模板骨架时选择抽象类;没有明确多态边界时,具体类完全合理。
泛型接口的 out 与 in 描述安全的类型流向,静态抽象接口成员则把类型级运算纳入泛型约束。无论语法新旧,判断标准始终一致:契约是否小而稳定,是否让调用者更简单,是否把变化挡在正确的边界之外。