分类课程智能体AI
文章
订阅
分类课程AI导师
文章
价格
课程进度
6 / 11
上一节C# 继承与运行时多态下一节C# 异步与 JSON:从 Task 到可靠数据边界
自在学

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程C#C# 接口与抽象:从契约到可测试设计

C# 接口与抽象:把变化挡在契约之外

订单已经完成,接下来可能要发邮件、短信、站内信,也可能在自动化测试里什么都不发,只记录一次调用。如果结算逻辑亲自创建这些对象,每增加一个通道都要改动核心流程;如果结算逻辑只认识一个稳定契约,通道就能独立替换。

这正是接口最实用的意义:它不是为了“让代码看起来更面向对象”,而是为了划清责任边界。调用方声明自己需要什么,实现方决定具体怎样完成。本章以订单通知为贯穿案例,从语法走到设计判断,并把抽象类、组合、依赖注入、泛型变体和静态抽象接口成员放在同一张地图里。

订单结算只依赖通知接口,邮件、短信与测试记录器作为可替换实现。

学习目标与路线

读完并完成练习后,你应该能够:

  • 写出包含方法、属性、索引器和事件的接口,并正确实现它们;
  • 解释普通实现、默认接口成员和显式接口实现的调用差异;
  • 用构造函数注入把外部依赖替换成测试替身;
  • 根据共享状态、多角色和替换需求,在接口、抽象类与组合之间作选择;
  • 读懂 out、in 与静态抽象接口成员的用途和边界。

建议沿着“契约 → 成员 → 接口演进 → 注入 → 接口隔离 → 抽象选择 → 现代泛型”的顺序学习。每节后的小测不是装饰:先回答,再展开解析。

1
接口首先描述的是什么?
2
为了让未来可能的实现可替换,应该给每个类都配一个接口。

贯穿案例:让通知方式退出结算流程

先定义最小契约。接口成员默认是公开契约,实现类中的对应成员也必须可公开访问。

csharp
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():

csharp
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");

输出:

console
Completed: A-2048
Email queued: Order A-2048 is ready

把最后两行改成 new SmsNotifier(),OrderCheckout 的实现无需变化。这里的多态不是魔法:变量的静态类型是 INotifier,运行时对象仍然是具体通知器,虚方法分派会选择该对象的实现。

3
把 EmailNotifier 换成 SmsNotifier 时,哪一部分代码必须保持不变,才能证明接口边界有效?
4
一个类可以实现多个接口,但只能直接继承 ____ 个基类。

接口成员:契约不等于只有方法

接口可以声明实例方法、属性、索引器和事件。它规定外部可观察的形状,但不规定字段如何存储、集合如何组织,也不替实现对象保存每份实例状态。

csharp
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);
    }
}

调用代码只通过接口就能读取属性、按下标查看历史并订阅事件:

csharp
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]}");
console
Observed: Order A-2048 is ready
memory[0] = Order A-2048 is ready

事件暴露的是订阅能力,而不是让外部任意触发事件;只读属性暴露的是查询能力,而不是实现内部字段。接口越准确,实现就越不容易被迫泄漏状态。

接口可组合方法、属性、索引器和事件,而具体状态由实现对象管理。

5
接口声明 get-only 属性后,实现类可以采用哪种实现?
6
事件订阅者可以在声明类型之外通过接口直接调用 Sent?.Invoke(...)。

默认接口成员与显式实现:两个容易混淆的工具

默认接口成员给契约提供一个可继承的默认行为,主要用途是让已经发布的接口以相对兼容的方式增加小能力。

csharp
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 视图调用。它适合短小、无状态、能从已有成员推导出的行为,不适合堆放一整套共享业务流程。

显式接口实现解决的是另一类问题:同一个类扮演两个角色,而角色拥有同名但不同义的成员。

csharp
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;但如果调用方总在强制转换,说明角色边界可能设计得不自然。

默认接口成员提供小型兼容行为,显式实现为两个同名角色打开不同调用通道。

7
默认接口成员最适合承担哪类职责?
8
为什么 console.Reset() 无法调用示例中的显式接口实现?

面向接口、依赖注入与可测试性

“面向接口”不是把参数类型机械地改成接口,而是让高层策略不再创建低层细节。最直接的做法是构造函数注入:对象创建时就拿到完整依赖,字段可以保持只读,非法的半初始化状态也更少。

测试中,我们不想真的发送邮件。一个记录型替身就足够:

csharp
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]);
console
Completed: T-100
1
Order T-100 is ready

好的测试替身只实现测试真正需要观察的行为。若为了替换一个依赖,不得不伪造十几个无关成员,问题通常不是测试框架,而是接口过胖。

依赖注入容器可以自动完成大型应用的装配,但容器不是这一原则的前提。手写 new OrderCheckout(new EmailNotifier()) 已经是依赖注入;容器只是把对象图的组装集中起来。

构造函数注入让生产通知器与测试记录器可以在同一个接口边界后切换。

9
依赖注入必须借助第三方容器才能实现。
10
与发送真实测试邮件相比,RecordingNotifier 更适合单元测试的原因有哪些?

接口隔离、组合与策略

接口隔离的判断单位是调用者,而不是某个庞大的领域名词。后台管理需要暂停通道,结算流程只需要发送;二者不应被迫依赖同一个“大而全”的接口。

csharp
public interface IMessageSender
{
    void Send(string message);
}
 
public interface IChannelHealth
{
    bool IsReady { get; }
}
 
public interface IChannelControl
{
    void Pause();
    void Resume();
}

同一个实现可以同时满足多个角色,但每个调用者只接收自己需要的窄接口。这样修改控制能力时,不会迫使只负责发送的代码重新编译或重新模拟无关行为。

组合则把可变算法做成协作者。假设通知需要重试,与其创建 RetryingEmailNotifier : EmailNotifier、RetryingSmsNotifier : SmsNotifier 的继承组合,不如让重试包装任意发送器:

csharp
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}");
            }
        }
    }
}

包装器依赖接口,因此邮件、短信和未来实现都能复用同一重试策略。组合把“是什么”的继承关系,转换成“拥有哪个协作者”的装配关系。

11
SimpleSender 不支持 Pause 时,哪种设计更符合接口隔离原则?
12
RetryingNotifier 比为每种通道建立重试子类更容易扩展的主要原因是什么?

接口、抽象类还是具体类:做出可解释的选择

三者不是晋级关系。最好的选择取决于真正需要稳定的东西。

选项优先考虑的场景主要代价
具体类只有一种实现,没有替换或角色边界将来出现边界时再提取契约
接口多个实现、多角色、插件边界、测试替换需要维护契约兼容性
抽象类同一家族共享状态、构造流程或模板骨架占用唯一基类位置,耦合更强
接口 + 组合行为需要独立组合、装饰或运行期替换对象装配数量增加

抽象类可以拥有实例字段、受保护成员、构造函数以及抽象/具体成员混合:

csharp
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 组合往往更灵活。

13
只要两个类出现重复代码,就应该立即提取抽象基类。
14
没有多态需求时,先使用具体类是一种合理选择。

泛型接口变体:读懂 out 与 in 的方向

假设 Dog : Animal。普通泛型类默认不变,因此即使 Dog 能赋给 Animal,Box<Dog> 也不能自动赋给 Box<Animal>。否则调用方可能向“动物盒子”放入一只猫,破坏原盒子只装狗的承诺。

只产出值的接口可以声明协变:

csharp
public interface IProducer<out T>
{
    T Create();
}
 
IProducer<Dog> dogs = GetDogProducer();
IProducer<Animal> animals = dogs;

out T 表示类型参数出现在输出位置。生产狗的对象当然也能被当作生产动物的对象。

只消费值的接口可以声明逆变:

csharp
public interface IConsumer<in T>
{
    void Accept(T value);
}
 
IConsumer<Animal> animals = GetAnimalConsumer();
IConsumer<Dog> dogs = animals;

能处理任何动物的消费者,当然也能处理狗。变体转换只适用于引用类型;并且编译器会限制 T 的使用位置,防止接口做出不安全承诺。

15
为什么 IProducer<Dog> 能赋给 IProducer<Animal>?
16
在 Dog 继承 Animal 的前提下,下面哪项赋值是安全的?

静态抽象接口成员:让泛型算法要求类型级能力

传统接口多态作用于对象实例。现代 C# 还允许接口声明静态抽象成员,使泛型约束能够表达“类型本身必须提供某个运算”。

csharp
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 是否真的是加法单位元、+ 是否满足调用者预期。语法能约束成员存在,却不能替你证明语义正确。

17
静态抽象接口成员主要通过什么机制解析?
18
接口约束能确认 operator + 存在时,编译器还能保证什么?

常见陷阱与一组完整实践

接口设计最常见的问题通常不在分号,而在边界。

  1. 为每个类制造同名接口。 只有一个实现且没有替换需求时,接口只是额外文件。
  2. 接口按实现者切分,而非按调用者切分。 IDatabaseEverything 往往迫使只读报表依赖写入与管理能力。
  3. 默认成员承载过多流程。 接口演进工具逐渐变成隐藏基类,调试与版本兼容都会变复杂。
  4. 服务定位器隐藏依赖。 方法内部从全局容器取服务,使构造函数看不出对象需要什么。
  5. 测试替身复制生产实现。 替身应记录或提供最小行为,而不是维护第二套业务逻辑。
  6. 为了复用两行代码建立继承树。 小函数或组合对象通常更便宜。

现在完成一个小型订单通知模块:

  • 定义 INotifier.Send(string message);
  • 实现 EmailNotifier 与 RecordingNotifier;
  • 让 OrderCheckout 只通过构造函数依赖接口;
  • 为 RecordingNotifier 增加消息列表,并验证完成订单后恰好记录一次;
  • 再写 RetryingNotifier 包装器,不改动任何已有通知器;
  • 最后把“查看健康状态”拆成独立 IChannelHealth,只让需要它的调用者依赖。

验收标准不是“用了多少接口”,而是新增 ConsoleNotifier 时不修改结算流程,测试时不触发真实外部系统,重试策略可以包装任意通知器。

19
类内部调用全局 ServiceLocator.Get<INotifier>() 的主要问题是什么?
20
订单通知实践题最核心的验收标准是什么?

总结:把抽象放在变化真正发生的地方

接口是一份面向调用者的契约:方法、属性、索引器和事件共同描述可用能力,具体对象负责状态与行为。默认接口成员适合谨慎演进契约;显式实现适合区分同名角色并收窄类表面 API。

当业务对象通过构造函数依赖小接口,生产实现、测试替身和装饰器就能自由替换。接口隔离让每个调用者只看到自己需要的角色,组合则把重试、缓存、审计等变化维度从继承树中拆开。需要共享状态和模板骨架时选择抽象类;没有明确多态边界时,具体类完全合理。

泛型接口的 out 与 in 描述安全的类型流向,静态抽象接口成员则把类型级运算纳入泛型约束。无论语法新旧,判断标准始终一致:契约是否小而稳定,是否让调用者更简单,是否把变化挡在正确的边界之外。

21
本章介绍的接口、注入、组合与变体等工具共同解决的核心问题是什么?
22
看到重复代码时,设计者最先应该分析什么?
  • 学习目标与路线
  • 贯穿案例:让通知方式退出结算流程
  • 接口成员:契约不等于只有方法
  • 默认接口成员与显式实现:两个容易混淆的工具
  • 面向接口、依赖注入与可测试性
  • 接口隔离、组合与策略
  • 接口、抽象类还是具体类:做出可解释的选择
  • 泛型接口变体:读懂 out 与 in 的方向
  • 静态抽象接口成员:让泛型算法要求类型级能力
  • 常见陷阱与一组完整实践
  • 总结:把抽象放在变化真正发生的地方

目录

  • 学习目标与路线
  • 贯穿案例:让通知方式退出结算流程
  • 接口成员:契约不等于只有方法
  • 默认接口成员与显式实现:两个容易混淆的工具
  • 面向接口、依赖注入与可测试性
  • 接口隔离、组合与策略
  • 接口、抽象类还是具体类:做出可解释的选择
  • 泛型接口变体:读懂 out 与 in 的方向
  • 静态抽象接口成员:让泛型算法要求类型级能力
  • 常见陷阱与一组完整实践
  • 总结:把抽象放在变化真正发生的地方