《Go 语言编程入门》4.3 组合与嵌入

Go 用组合而非继承来复用代码。本节给 TaskAPI 引入子任务 Subtask,用结构体嵌入复用 Task 的字段与方法,讲清提升规则、嵌入字段名、方法冲突消歧、值嵌入与指针嵌入的差别、多级嵌入的传播,并说明何时该用具名嵌套而非结构体嵌入。

4.3 组合与嵌入

到 4.2 为止,Task 已经是一个有行为、有方法的领域对象。但真实的任务系统里,任务之间还有关系:一个大任务可以拆成若干子任务,子任务又需要复用「完成」「改名」这些能力。本节用 Go 的组合与嵌入机制表达这种关系,同时澄清一个常见误解——嵌入不是继承。

本节把 TaskAPI 推进到「任务树」:新增 Subtask 类型,通过嵌入复用 Task 的字段与方法,让子任务天然具备 Complete() / Rename(),为第 9 章的分页与第 10 章的批量处理提供数据结构。

4.3.1 为什么不用继承

Go 没有类继承。这不是缺失,而是刻意的设计:继承把「是什么」和「怎么实现」绑死,导致深层继承链难以理解和重构。Go 的替代方案是组合——「一个类型包含另一个类型,并把它的能力暴露出去」。

组合有两种形态,语义完全不同:

形态写法访问方式方法提升
具名嵌套Task Tasks.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 在外层方法集中?
SubtaskTask否(但 s.Complete() 因可寻址仍能调用)
*SubtaskTask是

这个区别在 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 接口定义与隐式实现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练