Go
结构体、方法、接口与组合
使用结构体、方法集、隐式接口与组合保护领域不变量,并避开带类型 nil 和过度抽象,并通过可运行的 StudyTasks 示例验证边界、失败处理与工程取舍。
发布于 2026年7月23日
结构体、方法、接口与组合
Go 没有类继承层次,结构体保存状态,方法表达行为,接口由方法集合描述能力,嵌入提供便捷提升但不是子类型继承。对象建模的关键是让无效状态难以扩散,并让依赖方向由真正的使用者决定。
一、学习目标
- 使用结构体和方法表达 Task 不变量
- 理解值接收者、指针接收者与方法集
- 通过隐式接口降低耦合
- 区分组合、嵌入和继承
- 处理接口零值、带类型 nil 与类型断言
二、结构体与不变量
字段是否导出决定外部包能否直接修改:
type Task struct {
id TaskID
title string
status Status
createdAt time.Time
}
func NewTask(id TaskID, title string, now time.Time) (Task, error) {
title = strings.TrimSpace(title)
if id == "" || title == "" {
return Task{}, ErrInvalidTask
}
return Task{id: id, title: title, status: StatusTodo, createdAt: now.UTC()}, nil
}
隐藏字段能集中修改规则,但 JSON 编码需要单独 DTO 或自定义封送。不要把持久化标签直接扩散到领域模型,只为了少写一次转换。
三、接收者与方法集
值接收者获得接收值副本,适合小型不可变语义;指针接收者可修改原值并避免大结构复制:
func (t *Task) MarkDone(now time.Time) error {
if t == nil {
return ErrInvalidTask
}
if t.status == StatusDone {
return ErrAlreadyDone
}
t.status = StatusDone
return nil
}
同一类型的方法通常保持一致接收者策略。方法调用有时会由编译器自动取地址,但接口方法集规则更严格:若接口要求的方法只定义在 *Task 上,Task 值不实现该接口,*Task 才实现。
四、小接口与隐式实现
接口实现无需声明:
type Clock interface {
Now() time.Time
}
type SystemClock struct{}
func (SystemClock) Now() time.Time { return time.Now().UTC() }
接口放在使用它的包中,并保持最小。接受接口、返回具体类型通常让 API 更容易演进。返回接口也有合理场景,例如隐藏多个实现,但要承担动态类型、nil 和兼容契约。
编译期断言可在实现附近记录意图:var _ Clock = SystemClock{}。它不是必需语法,却能在方法签名变化时给出明确错误。
五、组合与嵌入
普通命名字段最清楚:
type Service struct {
repo Repository
clock Clock
}
匿名嵌入会提升字段和方法,适合“暴露被嵌入能力确实属于外层 API”的情况。它不会建立 Java/C# 式继承;外层类型不能自动替代内层类型,方法覆盖也只是选择规则。
嵌入 sync.Mutex 往往会把 Lock 暴露给调用者,还会使结构体复制危险。更稳妥的是使用未导出的命名字段并禁止复制含锁值。
六、接口值与类型检查
接口值包含动态类型和动态值。只有两者都为空时接口才等于 nil。类型断言应使用逗号 ok:
temporary, ok := err.(interface{ Temporary() bool })
if ok && temporary.Temporary() {
// 有边界地重试
}
判断错误类型优先使用 errors.As,因为直接断言看不到包装链。类型 switch 适合有限协议分派,但若业务代码不断按具体类型分支,往往说明接口抽象不稳定或缺少行为方法。
七、从知识点到工程契约
本篇的示例最终要进入可维护的 Go 包,而不是停留在 main 中的一次性片段。先把目标写成调用者可以观察的契约:输入是否允许零值或 nil,返回值是否是快照,错误能否通过 errors.Is/As 分类,函数是否启动 goroutine、取得资源或修改共享状态。然后再选择结构体、接口、函数值或泛型;抽象形式必须服务于契约,而不是反过来决定需求。
可以用以下顺序把知识点落到工程代码:
- 在独立小函数中写出最小成功路径,并让
go test能直接调用。 - 加入一个与“把所有字段导出,再期望构造函数能保护不变量”相关的失败样例,确认失败可观察且不会留下半完成状态。
- 把文件、网络、时间、环境或并发等外部因素改成显式依赖,测试使用临时目录、固定时钟或本地服务。
- 运行 gofmt、vet 和相关测试;涉及共享状态时追加
-race,涉及解析器时追加有上限的 fuzz。 - 最后再评估 API 是否需要导出。只在同一模块内部使用的能力保留在
internal,避免过早形成公共兼容负担。
审查代码时至少回答四个问题:谁拥有数据,谁允许修改,失败由谁处理,工作由谁停止。Go 的垃圾回收只解决不可达内存回收,不会替你关闭文件、取消请求、等待 goroutine 或恢复被覆盖的数据。只要其中一个问题没有答案,就先缩小函数或包的边界。
本篇最重要的能力是“使用结构体和方法表达 Task 不变量”。不要用注释替代可执行约束:能由类型表达的就交给类型,能由构造或验证表达的就返回错误,能由测试观察的就保存回归用例。示例扩展到 StudyTasks 时,还要保持领域包不导入命令行、文件和 HTTP 细节。
八、验证策略与复盘
验证分为静态、动态和故障三层。静态层检查格式、模块图和分析器;动态层用正常输入证明结果;故障层主动制造取消、权限、损坏数据、超时或竞态。一次测试通过只能说明执行过的路径符合断言,不能证明所有输入都安全,因此需要让每条关键契约至少对应一个成功用例和一个反例。
建议保存下面的复盘记录:
| 项目 | 需要记录的证据 |
|---|---|
| 版本 | go version、模块与 toolchain 指令 |
| 输入 | 最小正常值、零值、边界值和非法值 |
| 状态 | 调用前后数据、资源和 goroutine 的所有者 |
| 输出 | 返回值、错误链、stdout/stderr 与日志字段 |
| 失败 | 第一个失败点、清理动作和可恢复状态 |
| 工具 | 实际运行的 test、race、vet、benchmark 或 build 命令 |
完成验证后,用另一份干净临时目录重跑,不读取开发机的用户配置、缓存数据或真实网络。若测试只能按特定顺序成功,就说明状态隔离仍不完整。若为了让测试通过必须长时间 sleep,应改用 channel、WaitGroup、context 或可注入时钟表达确定的同步条件。
本篇可以用以下目标做验收:理解值接收者、指针接收者与方法集;通过隐式接口降低耦合;区分组合、嵌入和继承。把它们逐项转成命令输出或断言,而不是写成“人工看起来正确”。当实现与预期不符时,先保存最小失败样例,再调整设计。
发布前再做一次反向审查:从调用方而不是实现内部出发,写出一个完全不知道具体类型和文件布局的使用示例;从故障点出发,假设进程在每个 I/O 之后被取消;从升级出发,假设下一版改变字段或默认值。若调用方必须知道未公开细节、故障会留下无法判断的状态,或升级只能覆盖旧数据,契约就还不完整。把这三个场景加入测试或文档,比继续增加抽象更有价值。
最后检查示例能否被复制到一份最小程序独立运行,所有导入、错误处理和清理是否完整。教学代码可以省略与主题无关的界面,却不能省略会改变正确性的 context、Close、边界检查或同步。对为了篇幅省略的部分要明确标注,不能让读者把伪代码误当成生产承诺。
九、StudyTasks 实践
把 Task 改为保护字段的领域值,提供构造函数、只读访问器和 MarkDone。为时间定义调用方接口并注入固定时钟;分别编译 Task 与 *Task 赋给接口的例子,记录方法集差异。
完成本节后,不要只保存代码或 SQL。请同时保存执行命令、关键输出和失败案例;学习笔记真正有价值的部分,是能够说明输入、状态变化、输出以及失败后的恢复方式。
十、常见错误
- 把所有字段导出,再期望构造函数能保护不变量
- 混用值和指针接收者却不了解方法集影响
- 为每个结构体创建一一对应的大接口
- 把嵌入当成继承并无意暴露内部方法
- 只检查接口是否为 nil,忽略动态值可能为 nil
十一、练习与自测
- 为 Task 设计序列化 DTO,并实现领域值与 DTO 的双向转换。
- 写两个接口断言,证明 Task 与 *Task 的方法集区别。
- 把嵌入的 Mutex 改为私有命名字段并解释收益。
- 构造带类型 nil 错误并修复返回路径。
自测时应在干净的临时目录或临时数据库中重新执行,而不是依赖上一节遗留的状态。如果结果与预期不同,先记录实际输出,再缩小问题范围。
十二、官方资料
版本行为与二手文章不一致时,以本系列固定版本的官方文档、命令输出和可重复测试结果为准。
上一篇:包、模块、工作区与依赖 下一篇:指针、内存、defer 与资源安全