4.3 组合与嵌入
到 4.2 为止,Task 已经是一个有行为、有方法的领域对象。但真实的任务系统里,任务之间还有关系:一个大任务可以拆成若干子任务,子任务又需要复用「完成」「改名」这些能力。本节用 Go 的组合与嵌入机制表达这种关系,同时澄清一个常见误解——嵌入不是继承。
本节把 TaskAPI 推进到「任务树」:新增
Subtask类型,通过嵌入复用Task的字段与方法,让子任务天然具备Complete()/Rename(),为第 9 章的分页与第 10 章的批量处理提供数据结构。
4.3.1 为什么不用继承
Go 没有类继承。这不是缺失,而是刻意的设计:继承把「是什么」和「怎么实现」绑死,导致深层继承链难以理解和重构。Go 的替代方案是组合——「一个类型包含另一个类型,并把它的能力暴露出去」。
组合有两种形态,语义完全不同:
| 形态 | 写法 | 访问方式 | 方法提升 |
|---|---|---|---|
| 具名嵌套 | Task Task | s.Task.Title | 不提升 |
| 嵌入 | Task(无字段名) | s.Title | 提升 |
4.1 结尾用过的 DueDate DueDate 是具名嵌套:Task 拥有一个截止日期,调用方必须写 t.DueDate.Year。本节要讲的嵌入则是把内部类型的字段和方法「拉平」到外层,让 Subtask 看起来像是「拥有 Task 的全部能力」。
4.3.2 嵌入的基本写法
嵌入就是在结构体里写一个没有字段名的类型:
type Task struct {
ID int64
Title string
Done bool
}
func (t *Task) Complete() { t.Done = true }
func (t *Task) Rename(s string) { t.Title = s }
func (t Task) Label() string { return fmt.Sprintf("#%d %s", t.ID, t.Title) }
// Subtask 嵌入 Task,并额外携带 ParentID
type Subtask struct {
Task
ParentID int64
}
Subtask 里那行 Task 就是嵌入字段。它有一个隐式的字段名,名字就是类型名 Task,所以你可以写 s.Task 显式访问被嵌入的对象:
s := Subtask{Task: Task{ID: 2, Title: "子"}, ParentID: 1}
fmt.Println(s.Task.Title) // 子:显式访问嵌入字段
但更常用的是提升(promotion):外层类型直接访问内层的字段和方法,中间不用写 .Task。
s.Complete() // 等价于 (&s.Task).Complete()
s.Rename("子任务") // 等价于 s.Task.Rename("子任务")
fmt.Println(s.ID) // 等价于 s.Task.ID
fmt.Println(s.Label()) // 等价于 s.Task.Label()
实测输出:
子
2
#2 子任务
注意 s.Complete() 这一行:Complete 的接收者是 *Task,而 s 是可寻址的变量,所以编译器自动改写为 (&s.Task).Complete()。方法提升遵循与普通方法调用相同的可寻址规则。
4.3.3 提升的规则
提升不是无限递归的魔法,规则可以精确表述:
- 字段提升:外层类型的字段名,包含被嵌入类型的(导出与未导出)字段名,只要不冲突。
- 方法提升:外层类型的方法集,包含被嵌入类型的方法集,只要不冲突。
- 多级嵌入:嵌入可以嵌套,
A嵌入B,B嵌入C,则A能访问C的字段与方法。
但有一条容易忽略的边界:嵌入值类型时,只有值接收者方法会进入外层值类型的方法集;指针接收者方法只有在外层也是指针时才进入。换句话说:
| 外层类型 | 嵌入 Task(含指针接收者方法 Complete) | Complete 在外层方法集中? |
|---|---|---|
Subtask | Task | 否(但 s.Complete() 因可寻址仍能调用) |
*Subtask | Task | 是 |
这个区别在 5.1 决定「Subtask 能否满足某个接口」时会显形。实践建议:如果一个类型要通过嵌入来满足接口,先明确接口方法都是值接收者还是指针接收者。
4.3.4 冲突与消歧
当外层和内层、或两个内层之间有同名字段或方法时,外层优先;如果两个内层在同一层级冲突,则提升被抑制,必须显式指定。
type Base struct{ Name string }
func (b Base) Describe() string { return "Base:" + b.Name }
type Extra struct{ Name string }
func (e Extra) Describe() string { return "Extra:" + e.Name }
// 两个内层都有 Name 和 Describe,直接冲突
type Combined struct {
Base
Extra
}
此时 c.Name 和 c.Describe() 都会编译失败(ambiguous selector),必须写明 c.Base.Name 或 c.Extra.Describe()。这是 Go 有意为之的保护:模糊的语义不被允许,必须由作者做决定。
c := Combined{Base{"b"}, Extra{"e"}}
// fmt.Println(c.Name) // 编译错误:ambiguous
fmt.Println(c.Base.Name) // b
fmt.Println(c.Extra.Describe()) // Extra:e
如果外层自己定义了同名方法,外层直接胜出,内层被遮蔽(shadowed):
func (c Combined) String() string { return c.Base.Describe() + "/" + c.Extra.Describe() }
fmt.Println(c.String()) // Base:b/Extra:e
实测输出 Base:b/Extra:e。遮蔽是「覆写」在 Go 里的对应物,但它不是虚函数分派——外层方法内部如果要调用内层被遮蔽的方法,必须显式写 c.Base.Describe()。
4.3.5 值嵌入 vs 指针嵌入
嵌入的字段既可以是值,也可以是指针:
type Subtask struct {
Task // 值嵌入:Subtask 内嵌一个完整的 Task
ParentID int64
}
type SubtaskPtr struct {
*Task // 指针嵌入:SubtaskPtr 内嵌一个 *Task
ParentID int64
}
两者的差别是实打实的:
| 维度 | 值嵌入 Task | 指针嵌入 *Task |
|---|---|---|
| 复制外层时 | 复制整个 Task | 复制指针,共享同一个 Task |
| 零值 | 内层是合法零值 Task | 内层是 nil,访问字段会 panic |
| 修改可见性 | 只影响自己 | 影响所有共享该指针的对象 |
| 方法集 | 指针方法不进外层值方法集 | 内层方法全部进入 |
指针嵌入的陷阱在于零值不可用:var sp SubtaskPtr 之后 sp.Title 会因为解引用 nil 而 panic。所以指针嵌入必须配合构造函数:
sp := SubtaskPtr{Task: &Task{ID: 3, Title: "指针嵌入"}, ParentID: 1}
sp.Complete()
fmt.Println(sp.Task, sp.Done) // &{3 指针嵌入 true} true
对 Subtask 这个场景,值嵌入更合适:子任务独立拥有一份 Task 状态,不会因为共享指针而产生意外的跨对象修改。指针嵌入留给「多个视图共享同一实体」的场景,比如第 11 章的并发 store 里,多个 goroutine 可能共享同一个 *Task。
4.3.6 嵌入不是继承
这是本节最需要说清的一点。嵌入在语法上像继承,在语义上完全不同:
- 没有虚函数分派。外层调用内层方法时,内层方法里如果调用了「另一个方法」,调用的是内层自己的方法,不是外层的覆写版本。Go 没有动态分派。
- 没有子类型关系。
Subtask不是Task。你不能把Subtask赋给Task类型的变量,也不能把[]Subtask传给需要[]Task的函数。 - 没有
super。要调用被遮蔽的内层方法,只能显式写c.Base.Describe()。
如果你需要「子类型关系」,Go 的答案是接口:定义一个 Task 需要满足的接口,让 Subtask 通过方法集去满足它。这正是第 5 章 TaskStore 要做的事。嵌入解决的是「复用实现」,接口解决的是「抽象行为」,两者配合才是 Go 的组合哲学。
4.3.7 多级嵌入
嵌入可以层层嵌套,字段与方法沿链条传播:
type Meta struct{ Version int }
type Base struct {
Meta
Name string
}
type Top struct {
Base
Level int
}
func main() {
top := Top{Base: Base{Meta: Meta{Version: 3}, Name: "n"}, Level: 2}
fmt.Println(top.Version, top.Name, top.Level) // 3 n 2
}
实测输出 3 n 2。Version 定义在 Top 的「孙子」类型 Meta 里,仍然被提升到 Top。这条链可以任意深,但每深一层,可读性就下降一档——实际项目里建议嵌入层级不超过两层,再深就该考虑用具名字段显式表达关系了。
多级嵌入也是「冲突抑制」的高发区:只要链上任意两层出现同名字段或方法,提升就在那一层被抑制,必须写全路径。层级越深,这种歧义越难排查,这也是「浅优于深」的另一个理由。
4.3.8 项目落地:子任务的形态
综合以上,TaskAPI 里表达子任务的方式定为值嵌入:
// Subtask 表示一个隶属于父任务的任务。
// 值嵌入 Task:独立拥有一份状态,避免共享指针带来的意外修改。
type Subtask struct {
Task
ParentID int64
}
// Label 覆盖 Task.Label,附加父任务信息。
func (s Subtask) Label() string {
return fmt.Sprintf("%s (父 #%d)", s.Task.Label(), s.ParentID)
}
注意 s.Task.Label() 里的显式 .Task:因为 Label 被外层遮蔽了,要调用内层版本必须写全路径。这是遮蔽规则的直接应用。
第 5 章会把 Task 与 Subtask 都放进 TaskStore 的管理范围,届时「用接口统一它们的行为」会成为新的设计问题:子任务和父任务是否共享同一个 Get(id)?Subtask 的 ID 与 ParentID 会不会冲突?这些问题会在 TaskStore 接口的定义里得到回答。
4.3.9 小结与检查清单
- 复用行为用组合,不用继承(Go 没有继承)
- 嵌入无字段名的类型,字段名即类型名,可显式
s.Task访问 - 提升的字段与方法直接可用,但受可寻址规则约束
- 同层冲突必须显式消歧,外层同名方法遮蔽内层
- 值嵌入独立复制,指针嵌入共享且零值不可用
- 嵌入不等于子类型;需要子类型关系请用接口
- 嵌入层级不超过两层,再深改用具名字段
第 4 章到此结束:Task 有了字段语义、有了方法、有了关系。下一章把这些能力抽象成接口——先定义 TaskStore,再用内存实现 MemStore 去满足它。
阅读导航:上一节:4.2 方法与值/指针接收者 · 下一节:5.1 接口定义与隐式实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。