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 和 method FullAddress() 被提升到 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 时就用有名字的字段。

golangstruct embeddingcompositioninterfaceoop