← [ BACK_TO_NOTES ]

Go 嵌入与组合

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

#concept #growing #dev #go

[!info] related notes

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. 组合可替换
    通过字段组合日志能力;若要替换为另一具体实现,应定义接口或修改字段及其调用。

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

核心概念与准确模型

结构体嵌入(匿名字段)

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

规范:Struct types、Selectors

嵌入后:

  1. 内嵌类型有一个隐式字段名(类型标识符)。
  2. 内嵌类型的字段与方法可作为外层选择器使用(提升);仍受包可见性、选择器深度与冲突规则限制。\
  3. 外层方法与字段不是继承覆盖语义,而是选择器解析。

方法提升与方法集

在选择器无歧义且未被遮蔽时,对结构体类型 T:

外层声明Outer 的提升方法*Outer 的提升方法
type Outer struct{ T }接收者为 T 的方法接收者为 T 或 *T 的方法
type Outer struct{ *T }接收者为 T 或 *T 的方法接收者为 T 或 *T 的方法

可寻址值调用指针接收者方法时的隐式取址,不会扩大值类型的方法集。接口赋值仍按方法集判断,见 go-method-sets-and-receivers。

提升 ≠ 自动实现继承多态:参数若是 Inner,应显式传 outer.Inner;嵌入本身不允许把 Outer 转换成 Inner。也可让函数接收两者都满足的接口。

不是继承:is-a 不成立

func useLogger(l Logger) {}

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

若需要多态,定义接口并由 Server 或 *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()

外层声明同名方法时,外层方法遮蔽提升路径(常见“包装并扩展”写法)。但内层方法中的接收者仍是内层值:若 Inner.Call() 内调用 Inner.ID(),outer.Call() 不会自动改为调用 Outer.ID()。

字段提升

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

提升字段不能直接作为外层复合字面量的键:Circle{X: 1} 无效,应写 Circle{Point: Point{X: 1}}。

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

接口嵌入

type ReadCloser interface {
    Reader
    Closer
}
  • 对本文的基本接口,结果方法集是嵌入接口方法集的并集。
  • Go 1.14 起允许嵌入接口重复声明签名相同的方法;同名但签名不同仍冲突。
  • 从类型集看,接口元素取交集。含类型项或 ~T 的非基本接口只能用于类型约束,不能直接用作普通接口值类型。\
  • 标准库 io.ReadWriter、io.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 就用具名的未导出字段(如 logger Logger)并显式转发;嵌入未导出类型仍可能提升其导出方法。

[!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/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本