C#
类、接口、记录与对象建模
用类、接口、记录、属性和组合保护领域不变量,建立不可变任务模型。
发布于 2026年7月23日
类、接口、记录与对象建模
面向对象不是“把函数放进类”。领域类型的职责是让非法状态难以表示,让调用方只能通过明确操作改变状态。C# 的类、接口、记录、属性和访问控制为这种建模提供了不同工具。
一、学习目标
- 使用构造和工厂方法保护不变量
- 区分类、记录与结构体的适用场景
- 用属性和访问级别控制状态
- 使用接口定义能力而不是实现细节
- 理解继承、组合和密封类型
- 为任务建立不可变领域模型
二、类与封装
public sealed class TaskCounter
{
public int Total { get; private set; }
public int Completed { get; private set; }
public void Add(bool completed)
{
Total++;
if (completed)
{
Completed++;
}
}
}
字段保持私有,属性只公开外部真正需要读取的状态。调用方无法令 Completed > Total,因为修改必须经过 Add。
类默认是引用类型。多个服务共享同一可变实例时,修改对所有持有引用的位置可见。共享是否必要必须成为设计决定,而不是依赖注入容器的偶然生命周期。
三、构造函数与工厂方法
构造函数适合直接建立合法对象;工厂方法适合规范化输入、生成默认值或返回不同结果。
public sealed record StudyTask
{
public int Id { get; init; }
public required string Title { get; init; }
public TaskStatus Status { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? CompletedAt { get; init; }
private StudyTask()
{
}
public static StudyTask Create(
string title,
DateTimeOffset createdAt)
{
string clean = title.Trim();
if (clean.Length is < 1 or > 120)
{
throw new ArgumentException("标题长度必须为 1~120", nameof(title));
}
return new StudyTask
{
Title = clean,
Status = TaskStatus.Todo,
CreatedAt = createdAt
};
}
}
私有构造函数阻止外部跳过工厂。required 要求对象初始化时设置标题,但它不是运行时业务校验;空字符串仍需显式拒绝。
从数据库恢复对象时可以提供单独的 Restore 工厂,并验证持久化状态一致性,不要为了 ORM 方便把所有 setter 公开。
四、属性与 field
属性可以在赋值时保护规则。C# 14 的 field 关键字允许自动属性访问其编译器生成的后备字段:
public string Name
{
get;
init => field = string.IsNullOrWhiteSpace(value)
? throw new ArgumentException("名称不能为空", nameof(value))
: value.Trim();
}
field 只在访问器中表示后备字段。如果类型本来就有同名标识符,阅读可能产生歧义,应改用显式字段。
计算属性不要伪装昂贵 I/O:
public bool IsCompleted => Status == TaskStatus.Done;
访问属性通常应快速、确定且无明显副作用。数据库查询或网络请求使用方法,并提供异步与取消。
五、记录与值相等
记录默认按组成值比较,适合领域快照、命令和 DTO:
var first = StudyTask.Create("阅读文档", now);
var second = first with { Id = 10 };
with 表达式创建浅复制。若记录包含可变列表,两个记录仍可能共享同一列表:
public sealed record TaskBatch(List<StudyTask> Tasks);
因此不可变记录应优先包含不可变值或只读快照,例如数组的私有副本。记录的值相等也会递归依赖成员自身的相等规则;数组默认按引用比较。
实体是否按 ID 相等、值对象是否按全部字段相等,应由领域语义决定,不要只因为语法简短就把所有类型改成记录。
六、不可变状态转换
public StudyTask Complete(DateTimeOffset completedAt)
{
if (Status == TaskStatus.Done)
{
return this;
}
if (completedAt < CreatedAt)
{
throw new ArgumentOutOfRangeException(
nameof(completedAt),
"完成时间不能早于创建时间");
}
return this with
{
Status = TaskStatus.Done,
CompletedAt = completedAt
};
}
返回新对象让旧快照保持稳定,减少共享可变状态。但“不可变对象”不等于“数据库自动更新”;仓储仍需明确保存新版本。
重复完成任务在这里设计为幂等返回自身。也可以选择抛出领域异常,但必须在用例、CLI 和测试中保持一致。
七、接口定义能力
public interface ITaskRepository
{
StudyTask Add(StudyTask task);
StudyTask? Find(int id);
IReadOnlyList<StudyTask> List(TaskStatus? status = null);
void Update(StudyTask task);
bool Delete(int id);
}
接口描述 Core 需要的能力,不暴露 SQLite 连接、SQL 文本或 HTTP 响应。内存仓储和 SQLite 仓储都可以实现它。
接口不是为了“每个类都有一个 I 前缀版本”。只有存在替换实现、架构端口或需要独立契约时才有价值。纯数据类型通常不需要接口。
默认接口成员可以演进 API,但会增加版本与调用分派复杂度;应用内部接口优先保持小而直接。
八、组合优先
public sealed class TaskService(
ITaskRepository repository,
TimeProvider timeProvider)
{
public StudyTask Complete(int id)
{
StudyTask task = repository.Find(id)
?? throw new TaskNotFoundException(id);
StudyTask completed = task.Complete(timeProvider.GetUtcNow());
repository.Update(completed);
return completed;
}
}
服务通过组合获得仓储与时间能力。测试可以替换依赖,而不必继承服务并覆盖半数方法。
继承适合稳定的“是一个”关系和多态契约。基类必须为派生设计并记录扩展点,否则脆弱基类会让修改影响未知子类。没有扩展需求的类型标记 sealed。
九、结构体的边界
小型、值语义、通常不可变的数据适合 readonly record struct:
public readonly record struct TaskId
{
public int Value { get; }
public TaskId(int value)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
Value = value;
}
public override string ToString() => Value.ToString();
}
大型结构体按值复制成本高;可变结构体在属性、集合和接口装箱中容易产生“修改了副本”的问题。领域实体通常使用类或记录类。
十、常见错误
公开 set 后再依赖调用方自觉
任何调用方都能制造非法状态。把状态转换收口到方法或工厂。
记录中包含可变集合
with 是浅复制,值相等也可能不符合预期。创建不可变快照或封装集合。
接口泄露基础设施类型
Core 接口若返回 SqliteDataReader,就已经依赖 SQLite。接口应使用领域类型。
为复用几行代码建立继承树
优先提取协作者或普通函数。继承应表达替换关系,而不是代码复制工具。
十一、练习与自测
练习:
- 完成
StudyTask.Create、Complete与Restore,覆盖非法时间。 - 为任务 ID 建立
readonly record struct。 - 实现内存版
ITaskRepository,确保返回快照。 - 将一个继承仓储的服务改为构造函数组合。
自测:
required为什么不能替代业务验证?- 记录的
with为什么可能共享内部对象? - 哪些能力应进入 Core 接口?
- 可变结构体为何容易出现副本问题?
十二、官方资料
上一篇:项目、程序集、命名空间与依赖 | 下一篇:可空引用、异常与资源安全