Go 嵌入与组合

结构体与接口的嵌入实现组合复用与方法提升;不是继承,注意选择器冲突、提升与方法集,以及接口嵌入取并集。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 嵌入与组合

这个概念为什么会出现

复用已有能力时,经典 OOP 默认“继承树”:子类 is-a 父类,方法沿链查找。这条路带来:

  • 脆弱基类
  • 过深层次
  • 菱形多重继承
  • 为复用而伪造的 is-a 关系

Go 没有类型继承。复用主路径是组合:结构体里放字段;需要“把内层 API 抬到外层”时,用嵌入(匿名字段)选择器提升(promotion)。接口侧则通过嵌入其他接口做方法集合并

嵌入是语法级组合工具,不是继承的另一种写法。

[!abstract] 一句话理解 嵌入把内嵌类型的字段与方法提升到外层选择器;它提供组合与委派式复用,不建立 is-a 继承。重名时外层优先或选择器歧义,接口嵌入则是方法集的并集。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:HTTP 服务进程复用统一日志前缀

每个服务进程都要打带前缀的日志:[srv] listen :8080
你不想让 Server 手写一遍 Log,而是组合一个现成的 Logger

Go 的嵌入(匿名字段)会把内嵌类型的方法提升到外层选择器上:
s.Log(...) 可读性像“继承”,语义却是组合——Server 不是 Logger 子类。

接口侧同理:ReadWriter 嵌入 Reader+Writer,契约取并集

package main

import "fmt"

// Logger:可复用的日志能力(前缀 + 打印)。
type Logger struct {
	Prefix string
}

func (l Logger) Log(msg string) {
	fmt.Println(l.Prefix + msg)
}

// Server:业务服务 = 监听地址 + 内嵌日志能力。
// 教学点:Logger 是匿名字段 → 方法提升;不是继承树。
type Server struct {
	Logger // 嵌入
	Addr   string
}

func main() {
	s := Server{
		Logger: Logger{Prefix: "[srv] "},
		Addr:   ":8080",
	}

	// 提升调用:看起来像 Server 自己的方法,实际走到嵌入的 Logger.Log
	s.Log("listen " + s.Addr)
	// 完整路径仍然可用:需要消歧或强调“在用内嵌字段”时
	s.Logger.Log("explicit path")

	// 接口嵌入:小契约拼成大契约(并集)
	type Reader interface {
		Read(p []byte) (int, error)
	}
	type Writer interface {
		Write(p []byte) (int, error)
	}
	type ReadWriter interface {
		Reader
		Writer
	}
	// 仅声明类型存在;真实实现需具体类型同时具备 Read+Write
	var _ ReadWriter = nil
	fmt.Printf("concrete type: %T\n", s) // main.Server
}

建议运行:

go run .

期望输出:

[srv] listen :8080
[srv] explicit path
concrete type: main.Server

结合场景再看三个关注点

  1. s.Log 来自提升,不是继承
    外层类型仍是 Server,只是选择器更短。

  2. 组合可替换
    换一种 Logger 实现/字段,不必改业务方法签名。

  3. 接口嵌入 = 方法集合并
    ReadWriter 需要两边方法都在;与结构体嵌入一样是组合,不是子类型自动转换。

核心概念与准确模型

结构体嵌入(匿名字段)

type Outer struct {
    Inner          // 嵌入类型名即字段名
    *Inner2        // 可嵌入指针
    int            // 可嵌入内建类型(字段名是 int)
    pkg.Type       // 字段名是 Type
}

规范:Struct typesSelectors

嵌入后:

  1. 内嵌类型有一个隐式字段名(类型标识符)。
  2. 内嵌类型的导出字段与方法可作为外层选择器使用(提升),规则由选择器深度与冲突决定。
  3. 外层方法与字段不是继承覆盖语义,而是选择器解析。

方法提升与方法集

Outer 嵌入 Inner

  • Outer 的方法集可包含提升自 Inner 的方法(及嵌入 *T 时的指针细节)
  • 这影响 Outer 是否满足某接口

细节与接收者规则见 go-method-sets-and-receivers。直觉:

  • 嵌入 T:提升 T 的值方法;通过取址等规则也可能谈到指针方法(满足接口时要核对方法集)
  • 嵌入 *T:更常直接带来指针接收者方法

提升 ≠ 自动实现继承多态:函数参数若写 Inner,不能传入 Outer,除非显式转换/抽取字段或走接口。

不是继承:is-a 不成立

func useLogger(l Logger) {}

var s Server
// useLogger(s) // 编译错误:Server 不是 Logger
useLogger(s.Logger) // 显式取出

若需要多态,定义接口并由 *Server 因提升方法而隐式满足:

type LogSink interface{ Log(string) }
var sink LogSink = s // 若方法集匹配

选择器冲突与消歧

当同一选择器有多条路径:

  1. 更浅的字段/方法优先。
  2. 同深度多名冲突 → 使用该选择器编译错误(除非用完整路径消歧)。
type A struct{}
func (A) Hello() { fmt.Println("A") }

type B struct{}
func (B) Hello() { fmt.Println("B") }

type C struct {
    A
    B
}

// c.Hello() // 歧义
// 应 c.A.Hello() 或 c.B.Hello()

外层声明同名方法时,外层方法遮蔽提升路径(常见“包装并扩展”写法)。

字段提升

type Point struct{ X, Y int }
type Circle struct {
    Point
    Radius int
}
c := Circle{}
c.X = 1 // 提升字段
c.Point.Y = 2

JSON 等反射库对匿名字段有特殊展开行为,受 json tag 影响(见 go-struct-tags、encoding 文档)。

接口嵌入

type ReadCloser interface {
    Reader
    Closer
}
  • 结果方法集 = 嵌入接口方法集的并集
  • 不能嵌入冲突签名的同名方法
  • 标准库 io.ReadWriterio.ReadWriteCloser 即此模式

这是契约组合,不是结构体布局组合。

常见组合模式

  1. 装饰/包装
    外层嵌入接口字段(常指针/接口值),覆盖部分方法,其余提升。
  2. 实现复用
    小工具 struct 嵌入到多个业务 struct(注意是否真的该提升到公开 API)。
  3. 接口隔离再拼装
    小接口嵌入成大接口,调用方仍可只依赖小接口。

与“装饰器”“委托”的关系

嵌入提供的是语法级委托(选择器转发到内嵌字段)。你仍可手写:

func (s Server) Log(msg string) { s.Logger.Log(msg) }

显式转发更清晰可控;嵌入更省事但暴露面更大——提升会把内层方法变成外层 API 的一部分。

设计动机

  1. 组合优于继承 的语言级落地
  2. 扁平复用:多嵌入并列,而非深 is-a 树
  3. 接口侧并集:标准库 IO 层次的基础

边界情况与反直觉行为

  1. 嵌入未导出类型
    另一包可能无法直接写字段名,但提升的导出方法仍可能可见。
  2. nil 指针嵌入
    type O struct{ *Inner }O{} 上调用提升方法可能 nil 解引用 panic。
  3. 满足接口的意外面扩大
    提升使外层突然实现许多接口,API 表面变大。
  4. 相等性与拷贝
    嵌入字段参与 struct 比较/拷贝规则;含锁等不可拷类型时整体不可乱拷。
  5. JSON/反射
    匿名字段展开可能导致字段名意外暴露或冲突。

常见误区

[!warning] 常见误区:把嵌入当继承 错误:假设 Server 可传给任何要 Logger 的函数。
正确:无 is-a;用接口或显式字段。

[!warning] 常见误区:为复用无脑嵌入并导出 错误:嵌入大而全的类型,公开 API 被抬出一堆无关方法。
正确:需要窄 API 就显式转发;或嵌入未导出字段并自己暴露方法。

[!warning] 常见误区:忽视同名冲突 错误:两个嵌入都有 Close,运行时才发现。
正确:编译期处理歧义;外层提供自己的 Close 统筹。

[!warning] 常见误区:接口嵌入等于多重继承 错误:以为有状态与构造链。
正确:仅方法集并集,无字段继承。

工程实践

  1. 先问是否该提升
    提升 = 纳入外层公共表面。
  2. 包装标准库类型
    嵌入 http.Server 等要警惕方法全集暴露。
  3. 小接口嵌入构建角色契约(Reader/Writer/Closer)。
  4. 测试用接口替换内嵌依赖时,倾向字段是接口类型而非具体 struct 嵌入。
  5. 文档写清:外层生命周期是否拥有内嵌指针。
  6. 冲突时外层显式方法做门面,避免调用方猜路径。

可验证实验

  1. 嵌入后调用提升方法;再传给要内嵌具体类型的函数,确认不通过。
  2. 双嵌入同名方法,观察编译歧义。
  3. 外层定义同名方法,确认遮蔽。
  4. 嵌入 *T 且外层零值调用方法 → nil panic。
  5. 因提升而让外层满足 io.Writer 等接口的最小例子。
  6. 接口嵌入形成 ReadWriter,用结构体方法集隐式实现。

本节总结

  • 本质:组合 + 选择器提升;接口侧方法并集。
  • 不是:类型继承 / 子类替换。
  • 关键风险:API 面扩大、冲突、nil 嵌入。
  • 工程:提升有意为之;否则显式字段与转发。
  • 下一步:方法集细则与接口值。

自测题

概念题

  1. 为什么说嵌入不是继承?
  2. 接口嵌入的结果是什么?
  3. 两个嵌入类型同名方法时会发生什么?

代码推理题

type Inner struct{}
func (Inner) ID() string { return "inner" }

type Outer struct{ Inner }

func (Outer) ID() string { return "outer" }

func main() {
    var o Outer
    fmt.Println(o.ID(), o.Inner.ID())
}

打印什么?

工程思考题

你要给 http.Handler 增加统一日志,用嵌入 struct 包装,还是字段持有 next http.Handler 并手写 ServeHTTP?如何选择?

参考答案

展开
  1. 不建立子类型关系;外层类型不能当作内层类型传入,状态是组合字段而非基类切片。
  2. 方法集合并(并集)。
  3. 同深度冲突则该选择器歧义,需完整路径或外层定义消歧。
    代码:outer inner
    工程:中间件几乎总是 next http.Handler 字段 + 显式 ServeHTTP,避免嵌入具体 handler 带来的错误提升与生命周期问题;嵌入更适合稳定、小、无争议的工具片段。

延伸阅读与资料来源

资料类型支撑
Spec — Struct types规范匿名字段
Spec — Selectors规范提升与冲突
Spec — Interface types规范接口嵌入
Effective Go — Embedding文档组合示例
Method setsWiki提升与方法集

笔记元信息

  • 建议文件名:go-embedding-and-composition.md
  • 所属阶段:阶段二
  • 本篇状态:已深化
  • 建议下一篇:方法集与接收者
创建于 2026/6/25 更新于 2026/7/15