组合优于继承:嵌入
从第 1 章欠到现在的大问题:Go 没有继承,那「复用一个类型的行为」怎么办?Go 的答案是嵌入——在一个 struct 里放一个匿名字段,被嵌类型的方法就自动「提升」成外层的方法。它长得有点像继承,用起来也能达到不少继承的效果,但它不是继承——没有 super、没有多态覆盖。这一章讲清它怎么工作、以及那条著名的原则「组合优于继承」在 Go 里被做成了什么样。
先看它长什么样
type Animal struct {
Name string
}
func (a Animal) Speak() string { return a.Name + " 发声" }
type Dog struct {
Animal // ← 匿名字段:只写类型,不写字段名。这就是「嵌入」
Breed string
}
d := Dog{Animal: Animal{Name: "旺财"}, Breed: "土狗"}
fmt.Println(d.Speak()) // "旺财 发声" —— Dog 直接就能调 Animal 的方法!
fmt.Println(d.Name) // "旺财" —— 连字段也提升上来了
Dog 里那行 Animal(只有类型、没有字段名)就是嵌入。它做了两件事:一,Animal 成了 Dog 的一个匿名字段;二,Animal 的方法和字段被「提升」(promote)成了 Dog 的 —— 你能直接 d.Speak()、d.Name,好像它们本来就是 Dog 的一样。
这很像继承的效果吧?Dog 白白得到了 Animal 的能力。下面这台引擎会帮你算「提升后外层到底有哪些方法」,跑一下第一个例子:
但它不是继承 —— 三个关键差别
看着像继承,但如果你带着继承的全套预期来用它,会撞墙。嵌入和继承有三个本质不同,每一个都很重要。
它是「有一个」,不是「是一个」
继承是 is-a:Dog 是一个 Animal。嵌入是 has-a:Dog 有一个 Animal 字段,只不过这个字段是匿名的、方法被提升了。这不只是措辞 —— 那个 Animal 是实实在在的一个字段,你能直接访问它:d.Animal 就是里面那个 Animal 值。嵌入的本质是组合(把 Animal 组装进 Dog),提升只是让访问更方便的语法糖。
没有 super,被嵌类型看不见外层
这是最关键的差别。继承里,父类方法能通过多态回调到子类的覆盖版本(模板方法模式全靠这个)。嵌入没有这种回调:
func (a Animal) Describe() string {
return "我是 " + a.Speak() // 这里的 Speak 永远是 Animal.Speak
}
type Dog struct { Animal }
func (d Dog) Speak() string { return "汪汪" } // Dog 重定义 Speak
d := Dog{Animal{Name: "旺财"}}
fmt.Println(d.Speak()) // "汪汪"(调 Dog 自己的)
fmt.Println(d.Describe()) // "我是 旺财 发声" —— 不是 "汪汪"!
看清 Describe() 的结果:Animal.Describe 里调的 Speak,永远是 Animal 自己的 Speak,它根本不知道 Dog 的存在,更不会回调到 Dog 重定义的版本。这在继承里叫「虚方法覆盖」,是 OOP 多态的核心;嵌入完全没有它。被嵌的 Animal 是个独立的东西,它对「谁嵌了我」一无所知,也没有 super 让 Dog 去调父类版本。
方法调用只会「向内层找」,从不「向外层回调」。d.Speak() 先在 Dog 找,找到就用 Dog 的;d.Describe() 在 Dog 没找到,提升自 Animal,那就完全在 Animal 的世界里执行,Animal 里的 Speak 就是 Animal 的 Speak。
这正是「组合优于继承」这句老话在 Go 里被强制执行的样子:你没法搞出那种「父类调用子类、子类调用父类」的双向纠缠(继承里最容易把人绕晕、最难维护的部分)。嵌入只给你单向的「借用能力」,把继承里那套危险的双向多态删掉了。要真正的多态?用接口 —— 下一节。
外层能遮蔽内层(shadowing)
如果外层自己定义了一个和被嵌类型同名的方法 / 字段,外层的遮蔽内层的(Demo 第二个例子演示了这个)。d.Speak() 优先用 Dog 自己的。这不叫覆盖(override,那需要多态),只是「就近原则」的名字解析 —— 外层的名字盖住内层的,内层的还在(你可以 d.Animal.Speak() 显式点进去调)。
嵌入 + 接口 = Go 的组合威力
嵌入真正发光,是和接口配合的时候。接口也能嵌入接口,标准库最经典的例子:
type Reader interface { Read(p []byte) (int, error) }
type Writer interface { Write(p []byte) (int, error) }
// 用嵌入把两个小接口组合成一个大接口
type ReadWriter interface {
Reader
Writer
}
ReadWriter 就是「既能 Read 又能 Write」—— 它嵌入了两个小接口,要求实现者同时满足两者。这呼应了第 9 章「小接口 + 组合」的美学:不设计一个列七八个方法的大接口,而是用一堆单方法小接口,需要组合时嵌一下。
还有一个非常实用的模式:嵌入接口到 struct 里,白嫖一份默认实现、只重写你在意的部分:
// 我想要一个 ReadWriter,但只关心 Write,Read 用现成的
type LoggingWriter struct {
io.Writer // 嵌入接口!Write 由传进来的实现负责
prefix string
}
func (w LoggingWriter) Write(p []byte) (int, error) {
log.Println(w.prefix, string(p))
return w.Writer.Write(p) // 显式转发给内层
}
这是 Go 版的「装饰器模式」,干净利落 —— 嵌入一个接口,绝大多数方法自动转发,你只重写想拦的那个。这类「包一层、改一点」的组合,在 Go 里靠嵌入做得极其自然,根本用不着继承。
如果你嵌入两个类型、它们各有一个同名方法(比如都有 Close()),Go 不会替你选一个 —— 提升在这里「打平」,你直接 d.Close() 会编译报错:ambiguous selector。你必须显式指明 d.A.Close() 还是 d.B.Close()。这和 C++ 多重继承的「菱形问题」是同类困扰,但 Go 的处理很干脆:不猜、报错、让你自己说清楚。Demo 第三个例子里嵌了 Reader 和 Writer,因为方法不重名,就没这个问题。
Kotlin 对照
Kotlin 给你完整的继承体系:open class / abstract / override / super / 抽象方法 / 模板方法模式。你能建深层类型树,父类调子类、子类调父类。威力大,但也是 OOP 里最容易被滥用、最难维护的部分 —— 深继承树、脆弱基类问题、「改父类炸一片子类」,你八成都领教过。
Go 直接把继承整个删了,只留组合(嵌入)。你不能建类型树,不能 override,没有 super。想复用行为 → 嵌入。想多态 → 接口。这逼着你把「is-a 的层级」思维换成「has-a 的装配」思维:不是「Dog 继承 Animal 继承 LivingThing」,而是「Dog 组装了 Animal 的能力,并满足 Speaker 接口」。
迁移时的核心动作:把你脑子里的继承树推平成两样东西 —— 用嵌入复用具体实现,用接口表达多态。你会发现,你以前用继承干的事,绝大多数用「嵌入 + 小接口」能做得更松、更好测(接口天然好 mock)。真正需要「父类回调子类」那种双向多态的场景,其实远比你以为的少 —— 而 Go 赌的就是这一点。
这一章的一句话
Go 没有继承,用「嵌入」复用行为:匿名字段让被嵌类型的方法被提升到外层;但它是 has-a 的组合不是 is-a 的继承——没有 super、没有多态回调(嵌入向下不回头)、外层能遮蔽内层、嵌两个同名方法会报歧义。想复用实现用嵌入,想多态用接口,这就是「组合优于继承」被 Go 做成了强制。
接口卷还剩最后一章,讲两件容易翻车又必须懂的事:那个「装了 nil 却不等于 nil」的接口坑,以及「怎么用接口做好设计」的几条实战准则。