Go 中用 Struct Embedding 代替继承
1. 概述
如果你用过 Java 或 C#,刚开始写 Go 时通常会问:“extends 去哪儿了?”。Go 没有继承。取而代之的是 struct embedding:把一个类型嵌入到另一个 struct 中,来重用字段和方法。
本文讲解 embedding 的工作原理、与继承的区别,以及常见的陷阱。读完后,你就能用 embedding 来共享行为、写出优雅的装饰器,还能避免那些很容易掉进去的坑。
2. Struct Embedding 基础
Embedding 就是声明一个字段,只有类型,没有名字:
package main
import "fmt"
type Address struct {
Street string
City string
}
func (a Address) FullAddress() string {
return a.Street + ", " + a.City
}
type Customer struct {
Name string
Address // 嵌入字段
}
func main() {
c := Customer{
Name: "Lan",
Address: Address{Street: "12 Lê Lợi", City: "Huế"},
}
fmt.Println(c.City) // 字段被提升
fmt.Println(c.FullAddress()) // method 被提升
fmt.Println(c.Address.City) // 显式访问
}
Huế
12 Lê Lợi, Huế
Huế
分析一下:
Address在Customer内部,但没有字段名。Go 自动把字段名设为Address,也就是类型的名字。Address的字段City和 methodFullAddress()被提升到Customer。这样你就可以写c.City而不是c.Address.City。- 用 composite literal 初始化时,你还是要用字段名
Address:。没有办法“直接填入”City到Customer。
本质上,Customer 是包含 Address。这是 has-a 的关系,编译器只是给你方便了语法。Customer 不是 Address。你不能把 Customer 传给一个接收 Address 的函数:
func printCity(a Address) { fmt.Println(a.City) }
// printCity(c) // 编译错误:cannot use c (type Customer) as Address
printCity(c.Address) // OK
3. 用 Shadowing 来“覆盖” Method
如果外层 struct 声明了一个与嵌入 type 的 method 同名的 method,外层 struct 的 method 就会遮蔽内层的 method:
package main
import "fmt"
type Logger struct {
Prefix string
}
func (l Logger) Log(msg string) {
fmt.Println(l.Prefix + msg)
}
type OrderService struct {
Logger
}
func (s OrderService) Log(msg string) {
s.Logger.Log("[order] " + msg) // 调用嵌入 type 的 method
}
func main() {
s := OrderService{Logger: Logger{Prefix: "> "}}
s.Log("created #1001")
s.Logger.Log("raw message")
}
> [order] created #1001
> raw message
s.Log(...)调用OrderService的 method,因为它的深度更浅。s.Logger.Log(...)仍然能调用原始版本。这相当于 Java 中的super.log(...)。
乍看起来像 override。但这正是很多人容易踩坑的地方,下一部分会讲到。
4. 最大的陷阱:没有 Virtual Dispatch
在 Java 中,如果父类调用 this.channel() 而子类覆盖了 channel(),子类的版本会被执行。Go 不会 这样做:
package main
import "fmt"
type Notifier struct{}
func (n Notifier) Channel() string { return "email" }
func (n Notifier) Send(msg string) string {
return "[" + n.Channel() + "] " + msg
}
type SMSNotifier struct {
Notifier
}
func (s SMSNotifier) Channel() string { return "sms" }
func main() {
s := SMSNotifier{}
fmt.Println(s.Channel())
fmt.Println(s.Send("订单已送达"))
}
sms
[email] 订单已送达
s.Send(...) 实际上是 s.Notifier.Send(...)。在 Send 里面,receiver n 的类型是 Notifier。它根本不知道自己在一个 SMSNotifier 里面,所以 n.Channel() 总是返回 "email"。
这是根本区别。嵌入的 type 没有反向引用到外层的 struct。Java 式的“模板方法”模式在这里不能直接用。
4.1. 修复办法:把变化的部分抽成 Interface
如果你需要可变的行为,要显式地传进来:
package main
import "fmt"
type Channel interface {
Name() string
}
type Email struct{}
func (Email) Name() string { return "email" }
type SMS struct{}
func (SMS) Name() string { return "sms" }
type Notifier struct {
Channel Channel
}
func (n Notifier) Send(msg string) string {
return "[" + n.Channel.Name() + "] " + msg
}
func main() {
fmt.Println(Notifier{Channel: SMS{}}.Send("订单已送达"))
fmt.Println(Notifier{Channel: Email{}}.Send("十月发票"))
}
[sms] 订单已送达
[email] 十月发票
现在变化的部分(Channel)是一个明确的 dependency。这比试图模拟 override 更“Go”的方式。
5. Embedding 与 Interface
Go 的 interface 是隐式满足的。被提升的 method 也算在外层 struct 的 method set 里。因此 embedding 可以让 struct 满足 interface,而不用重新写一遍 method:
package main
import "fmt"
type Describer interface {
FullAddress() string
}
type Address struct{ Street, City string }
func (a Address) FullAddress() string { return a.Street + ", " + a.City }
type Warehouse struct {
Code string
Address
}
func describe(d Describer) { fmt.Println(d.FullAddress()) }
func main() {
w := Warehouse{Code: "HN-01", Address: Address{"KCN Thăng Long", "Hà Nội"}}
describe(w) // Warehouse 因为 method 被提升,满足 Describer
}
KCN Thăng Long, Hà Nội
5.1. 关于 Pointer Receiver 的注意事项
当嵌入时,method set 的规则如下:
S 中的声明 |
S 的 Method Set |
*S 的 Method Set |
|---|---|---|
嵌入 T |
receiver 为 T 的 method |
receiver 为 T 和 *T 的 method |
嵌入 *T |
receiver 为 T 和 *T 的 method |
receiver 为 T 和 *T 的 method |
比如 Counter 有 func (c *Counter) Inc() 而你在 Stats 中嵌入 Counter(不是指针),那么只有 *Stats 满足有 Inc() 的 interface。Stats 的值不满足。这种编译错误 “Stats does not implement Incrementer (method Inc has pointer receiver)” 经常从这里来。
6. 在 Struct 中嵌入 Interface:简洁的装饰器
你可以把一个 interface 嵌入到 struct 中。Struct 会自动拥有 interface 的所有 method,每个 method 都被委托到内部值。你只需要覆盖想改变的 method:
package payment
import "fmt"
type PaymentService interface {
Charge(orderID string, amount int) error
Refund(orderID string) error
}
type LoggingPayment struct {
PaymentService // 委托所有 method
Logs []string
}
func (l *LoggingPayment) Charge(orderID string, amount int) error {
l.Logs = append(l.Logs, fmt.Sprintf("charge %s: %d", orderID, amount))
return l.PaymentService.Charge(orderID, amount)
}
LoggingPayment只重写Charge。Refund自动委托给内部的PaymentService。*LoggingPayment满足PaymentService,你可以把它包装在任何 implementation 周围,调用方不用知道。
当 interface 有十个 method 而你只想拦截一个,这个技巧可以省下一大堆重复代码。
提醒: 如果嵌入的 interface 字段是
nil,代码仍然能编译。但调用一个没被覆盖的 method(比如Refund)会在运行时触发 panic nil pointer dereference。要记得总是通过 constructor 初始化它,比如NewLoggingPayment(inner PaymentService)。
7. 测试委托行为
Go 用标准库 testing package 代替 JUnit。下面的测试检查两件事:Charge 被记日志并调用了原始 service,而 Refund 被委托而不记日志。
package payment
import "testing"
type fakePayment struct {
charged []string
refunded []string
}
func (f *fakePayment) Charge(orderID string, amount int) error {
f.charged = append(f.charged, orderID)
return nil
}
func (f *fakePayment) Refund(orderID string) error {
f.refunded = append(f.refunded, orderID)
return nil
}
func TestLoggingPayment(t *testing.T) {
inner := &fakePayment{}
var svc PaymentService = &LoggingPayment{PaymentService: inner}
if err := svc.Charge("ORD-1", 250000); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := svc.Refund("ORD-1"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
lp := svc.(*LoggingPayment)
if len(lp.Logs) != 1 || lp.Logs[0] != "charge ORD-1: 250000" {
t.Errorf("logs = %v, want [charge ORD-1: 250000]", lp.Logs)
}
if len(inner.charged) != 1 || len(inner.refunded) != 1 {
t.Errorf("inner not called: charged=%v refunded=%v", inner.charged, inner.refunded)
}
}
运行测试:
go test ./...
ok example.com/shop/payment 0.002s
var svc PaymentService = &LoggingPayment{...} 也是一个编译时检查:如果 *LoggingPayment 不满足 interface,测试都构建不了。在生产代码中,你可以用常见的做法 var _ PaymentService = (*LoggingPayment)(nil) 来实现同样的目的。
8. 名字冲突和常见错误
8.1. 模糊的选择器
当两个同深度的嵌入 type 有同名字段或 method 时,通过外层 struct 访问会编译报错:
type Billing struct{ Email string }
type Shipping struct{ Email string }
type Order struct {
Billing
Shipping
}
func main() {
o := Order{}
// fmt.Println(o.Email) // 编译错误:ambiguous selector o.Email
fmt.Println(o.Billing.Email) // OK
}
Order struct 本身是合法的。只有当你访问 o.Email 时才会报错。处理方法是显式调用,或者在 Order 上声明 Email 来遮蔽两个。
8.2. 无意中暴露 API:例如 sync.Mutex
type Inventory struct {
sync.Mutex
stock map[string]int
}
这段代码能运行。但 Lock() 和 Unlock() 现在是 Inventory 的公开 method,因为嵌入字段名 Mutex 被导出了。包外的代码可以锁定你的 inventory。应该写成:
type Inventory struct {
mu sync.Mutex
stock map[string]int
}
通则是:embedding 把嵌入 type 的全部 method 带进你的 API。如果你不想公开它们,用有名字的字段。
8.3. 嵌入的指针是 nil
嵌入 *Address 而不是 Address 意味着 struct 的零值中指针是 nil。那样 c.City 会 panic。只在你真的需要共享实例或需要 *T 的 method set 时才嵌入指针,并记得初始化它。
9. 什么时候用、什么时候不用 Embedding
应该用在:
- 你想让外层 struct 确实暴露嵌入 type 的全部行为,比如嵌入
http.Handler或io.Reader。 - 你写一个装饰器或 wrapper,只改变大 interface 中的几个 method。
- 你把常用的字段如
Audit{CreatedAt, UpdatedAt}集中到多个 entity 中。
不应该用在:
- 你想重新创建多层继承树,“父类调用子类的 method”。用 interface 和显式的 dependency(第 4.1 部分)。
- 嵌入的 type 有你不想公开的 method(第 8.2 部分)。
- 你只需要那个 type 的一两个 method。写几个有名字的字段加手写的几个 method 委托会更清楚。
记住: Embedding 不创建 is-a 关系。如果你需要多态,定义一个 interface 让多个 type 满足它。别指望“struct 子类就是 struct 父类”。
10. 总结
Struct embedding 是 Go 的 composition 工具,有一点语法糖的帮助:嵌入 type 的字段和 method 被提升到外层 struct。它帮你重用代码、满足 interface 和写非常简洁的装饰器。
但 embedding 不是继承。没有 virtual dispatch,嵌入 type 不知道外层 struct 的存在,被提升的 method 都成了你的 API 的一部分。记住三点:对变化的行为用 interface,完整初始化嵌入的 interface 或指针字段,不想暴露 method 时就用有名字的字段。