卷 III · 接口CH 12深度 12/24

组合优于继承:嵌入

本章卸下extendssuperoverride类的层级

从第 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 对照

⇄ 继承树 vs 组合装配

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」的接口坑,以及「怎么用接口做好设计」的几条实战准则。