浏览知识库目录

C#

类、接口、记录与对象建模

用类、接口、记录、属性和组合保护领域不变量,建立不可变任务模型。

类、接口、记录与对象建模

面向对象不是“把函数放进类”。领域类型的职责是让非法状态难以表示,让调用方只能通过明确操作改变状态。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。接口应使用领域类型。

为复用几行代码建立继承树

优先提取协作者或普通函数。继承应表达替换关系,而不是代码复制工具。


十一、练习与自测

练习:

  1. 完成 StudyTask.CreateCompleteRestore,覆盖非法时间。
  2. 为任务 ID 建立 readonly record struct
  3. 实现内存版 ITaskRepository,确保返回快照。
  4. 将一个继承仓储的服务改为构造函数组合。

自测:

  • required 为什么不能替代业务验证?
  • 记录的 with 为什么可能共享内部对象?
  • 哪些能力应进入 Core 接口?
  • 可变结构体为何容易出现副本问题?

十二、官方资料

上一篇:项目、程序集、命名空间与依赖 | 下一篇:可空引用、异常与资源安全