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 原则。
- 领域模型与贫血模型对比。