跳到主要内容

2.4 面向对象、接口与组合

面向对象不是把所有东西都写成类,而是用对象表达业务状态和行为。接口用于定义协作边界,组合用于复用能力,继承应谨慎使用。

学习目标

  • 能用类表达业务对象的状态和行为。
  • 能使用接口抽象外部依赖。
  • 能理解组合优先于继承的原因。
  • 能为测试设计清晰依赖边界。

应用场景

  • TodoItem 封装任务状态和完成行为。
  • ITodoRepository 隔离应用服务和数据库实现。
  • INotificationSender 抽象邮件、短信或站内通知。
  • 用组合复用审计字段、时间服务或校验服务。

核心概念

概念说明
封装状态和行为
接口描述对象能做什么,不描述怎么做
继承表达“是一个”的关系
组合表达“拥有一个”的协作关系

案例:任务实体

public sealed class TodoItem
{
public int Id { get; private set; }
public string Title { get; private set; }
public bool IsCompleted { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }

public TodoItem(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("标题不能为空", nameof(title));
}

Title = title.Trim();
}

public void Complete(DateTimeOffset now)
{
if (IsCompleted)
{
return;
}

IsCompleted = true;
CompletedAt = now;
}
}

示例:接口隔离持久化

public interface ITodoRepository
{
Task<TodoItem?> FindByIdAsync(int id, CancellationToken cancellationToken);
Task AddAsync(TodoItem todo, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}

public sealed class CompleteTodoHandler(
ITodoRepository repository,
TimeProvider timeProvider)
{
public async Task<bool> HandleAsync(int id, CancellationToken cancellationToken)
{
var todo = await repository.FindByIdAsync(id, cancellationToken);
if (todo is null)
{
return false;
}

todo.Complete(timeProvider.GetUtcNow());
await repository.SaveChangesAsync(cancellationToken);
return true;
}
}

重点难点

  • 类应保护自己的不变量,例如“标题不能为空”。
  • 接口应该定义在使用方需要的边界上,而不是机械给每个类加接口。
  • 继承会带来强耦合,只有真正的“是一个”关系才适合。
  • 组合更适合业务服务协作,也更容易测试。

常见误区

误区推荐做法
实体只有公开 set 属性把关键状态变化封装成方法
为所有类创建接口只在需要替换、测试或跨层边界时抽象
用继承复用少量代码优先组合小服务或提取方法

练习

  • TodoItem 增加 Rename 方法,并校验标题。
  • 设计 ITodoRepository 的最小接口,不暴露 EF Core 细节。
  • 把直接调用系统时间的代码改为注入 TimeProvider

延伸阅读

  • C# 类与接口。
  • SOLID 原则。
  • 领域模型与贫血模型对比。