Skip to content

Go 结构体:从数据布局到组合式设计

面向有 Java 经验的开发者,基于 Go 1.26。

结构体是 Go 组织数据的基本工具,但不能把它简单理解成“没有构造器的 Java class”。结构体声明描述的是一组字段,方法写在类型声明之外;Go 用组合代替继承,也不会默认赋予每个值稳定的对象身份。结构体值可以直接复制、比较或放进数组,究竟按值还是按指针使用,要看字段和业务语义,而不是个人语法偏好。

我们先从声明、初始化和字段访问讲起,再顺着复制语义进入内存布局、嵌入与方法集。这里的目标不是记住某段代码“可以运行”,而是能说清它为什么成立,以及值从哪里开始共享。

目录

1. 先建立心智模型

先看一个只描述数据形状的类型:

go
type User struct {
	ID   int64
	Name string
}

User 是一个已定义类型,底层类型是结构体。变量里直接保存字段,而不是像 Java 局部变量那样默认保存对象引用:

go
u1 := User{ID: 1, Name: "Alice"}
u2 := u1
u2.Name = "Bob"

fmt.Println(u1.Name) // Alice
fmt.Println(u2.Name) // Bob

理解结构体时,先抓住三件事:

  • 结构体值是字段值的有序集合,赋值会逐字段复制;
  • *T 才是指向结构体的指针,多个指针可以共享同一个值;
  • 字段若含切片、map、指针、接口等引用性成分,复制结构体只会复制这些描述信息,底层数据仍可能共享。

所以,“结构体是值类型”既不表示复制一定是深复制,也不表示它一定分配在栈上。栈或堆由逃逸分析和编译器实现决定;阅读代码时,更值得关注的是哪些字段被复制,哪些底层数据仍在共享。

2. 声明与初始化

2.1 命名结构体

go
type Address struct {
	City    string
	ZipCode string
}

type Customer struct {
	ID      int64
	Name    string
	Address Address
}

同类型相邻字段可以合写:

go
type Point struct {
	X, Y float64
}

工程代码通常一行一个字段,便于写注释、标签和查看 diff。字段名首字母大写表示包外可见,小写表示仅当前包可见:

go
type Account struct {
	ID      int64  // 其他包可读写
	balance int64  // 仅当前包可访问
}

“未导出”并不等于安全边界。反射、unsafe、进程内存和序列化规则都有各自限制;它主要是包级 API 的封装手段。

2.2 字面量

推荐使用带字段名的字面量:

go
u := User{
	ID:   1,
	Name: "Alice",
}

同包内可以按字段顺序省略名称:

go
u := User{1, "Alice"}

这种写法会把调用方和字段顺序绑死,增加字段后还可能大面积修改。除坐标、颜色这类字段少且语义稳定的内部值外,不建议使用。包外为其他包定义的结构体写无字段名字面量时,还会受到未导出字段限制。

嵌套结构体需要写出内层类型:

go
c := Customer{
	ID:   7,
	Name: "Lin",
	Address: Address{
		City:    "Shanghai",
		ZipCode: "200000",
	},
}

2.3 匿名结构体

匿名结构体适合局部、一次性的形状:

go
result := struct {
	Count int
	OK    bool
}{
	Count: 3,
	OK:    true,
}

常见场景包括表驱动测试、临时 JSON 载体和小范围配置。若这个形状会出现在函数签名、多个文件或业务概念中,就应当命名。匿名结构体不是元组替代品,过度嵌套会让类型和错误信息都难读。

结构体类型的身份包括字段名、字段类型、字段顺序、是否嵌入、标签以及字段是否导出。两个匿名结构体只有满足规范中的同一性规则才是同一类型。

3. 字段访问、指针与零值

3.1 选择器

使用 . 访问字段:

go
u := User{ID: 1}
u.Name = "Alice"
fmt.Println(u.Name)

如果手中是指针,Go 会自动解引用选择器:

go
p := &u
p.Name = "Bob"       // 等价于 (*p).Name = "Bob"
fmt.Println(p.Name)

这里的自动解引用只让选择器简洁,不意味着指针和值没有区别。p = nil 时访问 p.Name 仍会触发 panic。

3.2 零值可用

结构体的零值是每个字段取零值:

go
var s struct {
	N     int
	Ready bool
	Note  string
}
fmt.Printf("%#v\n", s) // N=0, Ready=false, Note=""

好的 Go 类型通常尽量让零值可用:

go
type Counter struct {
	n int64
}

func (c *Counter) Add(delta int64) { c.n += delta }
func (c Counter) Value() int64     { return c.n }

var c Counter 可以立即使用,不必调用构造函数。标准库的 bytes.Buffersync.Mutex 就遵循这一思路。

并非所有类型都能做到这一点。例如连接池必须知道地址,货币金额必须知道币种。此时应该提供构造函数,并在文档中明确零值是否有效,而不是让无效状态悄悄流入系统。

3.3 Go 1.26 的 new

传统写法 new(T) 返回指向 T 零值的指针:

go
p := new(User) // *User

Go 1.26 还允许 new 接收一个表达式,用表达式的值初始化新变量:

go
age := new(42)             // *int,指向值 42
name := new("Alice")       // *string
user := new(User{ID: 10})  // *User

这对可选字段很实用,但不要把所有值都改成指针。指针表达“缺失”、共享身份或需要原地修改时才有意义。

4. 构造、校验与不变量

Go 没有构造器语法,通常写返回 T*T 的普通函数:

go
type Email string

type Member struct {
	id    int64
	email Email
}

func NewMember(id int64, email Email) (*Member, error) {
	if id <= 0 {
		return nil, fmt.Errorf("id must be positive")
	}
	if email == "" {
		return nil, fmt.Errorf("email is required")
	}
	return &Member{id: id, email: email}, nil
}

构造函数不是为了模仿 Java。它的价值在于建立不变量、隐藏实现字段,以及把默认值集中到一个入口。若结构体字面量已经足够清楚,也没有校验逻辑,就不必机械地补一个 NewX

常见返回策略:

  • 小型不可变值、复制便宜:返回 T
  • 需要共享身份、修改状态、包含不可复制字段:返回 *T
  • 构造可能失败:增加 error
  • 选项很多:使用配置结构体或函数选项,但先评估是否真的复杂。

不要让构造函数启动无法停止的 goroutine,也不要在构造阶段偷偷做昂贵网络 I/O。生命周期应在 API 中可见。

5. 值语义、浅复制与比较

5.1 赋值是逐字段复制

go
type Document struct {
	Title string
	Tags  []string
	Meta  map[string]string
}

a := Document{
	Title: "Go",
	Tags:  []string{"language"},
	Meta:  map[string]string{"author": "Lin"},
}
b := a

b.Title = "Go Notes"          // 不影响 a.Title
b.Tags[0] = "programming"     // 影响 a.Tags[0]
b.Meta["author"] = "Wang"     // 影响 a.Meta

Title 的字符串描述符被复制,字符串内容不可变;切片描述符和 map 引用被复制,仍指向共享数据。若要独立副本,应明确实现克隆:

go
func (d Document) Clone() Document {
	out := d
	out.Tags = slices.Clone(d.Tags)
	out.Meta = maps.Clone(d.Meta)
	return out
}

“深复制”必须结合领域含义来定义。若 Meta 的 value 是指针,maps.Clone 仍然只复制一层。

5.2 比较

结构体只有在所有字段类型都可比较时才可用 ==

go
type Key struct {
	Tenant string
	ID     int64
}

fmt.Println(Key{"a", 1} == Key{"a", 1}) // true
m := map[Key]string{}

包含切片、map 或函数字段的结构体不可比较,也不能作为 map 键:

go
type BadKey struct {
	Parts []string
}
// _ = BadKey{} == BadKey{} // 编译错误

业务相等往往不等于逐字段相等。缓存字段、时间单调部分、大小写规则都会改变含义,最好写 Equal 方法:

go
func (u User) Equal(other User) bool {
	return u.ID == other.ID
}

测试中也不要把 reflect.DeepEqual 当万能答案。优先比较业务可观察行为,或使用相应类型的 Equal 方法。

6. 内存布局与对齐

字段按声明顺序布局,但编译器会在字段之间和末尾插入填充,使字段地址满足对齐要求,并保证结构体数组中每个元素正确对齐。字段不会被编译器随意重排。

go
type Poor struct {
	A byte  // 1 字节
	B int64 // 通常要求 8 字节对齐
	C byte
}

type Compact struct {
	B int64
	A byte
	C byte
}

在常见 64 位架构上,Poor 往往是 24 字节,Compact 往往是 16 字节。可以用 unsafe 检查当前目标平台:

go
fmt.Println(unsafe.Sizeof(Poor{}))
fmt.Println(unsafe.Alignof(Poor{}))
fmt.Println(unsafe.Offsetof(Poor{}.B))

这些结果和架构、编译器 ABI 有关,不要把某台机器的数字当语言保证。排序的一般经验是把对齐要求大的字段放前面,但只在大量实例确实造成内存压力时优化。可读性和逻辑分组通常更重要。

6.1 尾部填充与零尺寸字段

结构体整体大小通常是其对齐值的整数倍,因此数组里的下一个元素能正确对齐。空结构体 struct{} 大小为零,常用于“只表达存在”的集合:

go
seen := map[string]struct{}{}
seen["go"] = struct{}{}

不要依赖零尺寸对象拥有不同地址。规范允许不同的零尺寸变量地址相同。

6.2 对齐不是并发安全

字段对齐只能说明地址满足访问要求,不能让复合更新变成原子操作,也不能消除数据竞争。并发写字段仍需 channel、锁或 sync/atomic

若两个高频写字段位于同一缓存行,不同 CPU 核心更新它们可能产生伪共享。只有性能剖析证明它是瓶颈时,才考虑填充或拆分数据;手写“缓存行大小”本身具有平台假设。

6.3 unsafe 的边界

unsafe.Pointer 把任意字节解释成结构体,会遇到对齐、字节序、填充、指针扫描和跨版本 ABI 问题。网络协议和磁盘格式应使用 encoding/binary 或明确编码;与 C 交互应使用 cgo 规则。Go 结构体内存布局不是通用序列化格式。

7. 方法和值接收者

方法声明在类型之外:

go
type Point struct {
	X, Y float64
}

func (p Point) DistanceTo(q Point) float64 {
	return math.Hypot(p.X-q.X, p.Y-q.Y)
}

Point 是接收者基类型,必须是当前包定义的非指针、非接口类型。Go 1.26 不能给方法单独声明自己的类型参数;泛型类型的方法可以使用接收者类型参数。

值接收者拿到接收者副本:

go
func (p Point) MoveX(delta float64) {
	p.X += delta // 只修改副本
}

适合值接收者的典型类型:

  • 小且复制便宜;
  • 逻辑上不可变;
  • 方法不改变接收者;
  • 希望 T*T 都拥有该方法,以扩大方法集兼容性。

“只读方法就一定用值接收者”并不可靠。一个很大的结构体即便只读也可能不适合复制;包含 sync.Mutex 的结构体更不能复制。

8. 指针接收者与方法集

指针接收者可以修改原值:

go
func (p *Point) Translate(dx, dy float64) {
	p.X += dx
	p.Y += dy
}

调用时,若值可取地址,编译器会帮忙取址:

go
p := Point{}
p.Translate(1, 2) // 等价于 (&p).Translate(1, 2)

这只是调用语法提供的便利,并不会改变方法集:

  • 已定义类型 T 的方法集只包含接收者为 T 的方法;
  • *T 的方法集包含接收者为 T*T 的方法;
  • 接口满足关系严格按方法集判断。
go
type Mover interface {
	Translate(float64, float64)
}

var _ Mover = (*Point)(nil)
// var _ Mover = Point{} // 不满足:Translate 的接收者是 *Point

选择指针接收者的常见理由:

  • 方法需要修改字段;
  • 结构体较大,复制代价明显;
  • 类型包含锁、once、原子值等不可复制状态;
  • 类型代表具有身份和生命周期的实体;
  • 希望一组方法保持一致的接收者形式。

一个类型的方法最好不要随意混用值和指针接收者。像 time.Time 这种小型值类型可以统一值接收者;有状态服务通常统一指针接收者。

nil 指针也能进入方法,是否允许由方法决定:

go
type Node struct {
	Value int
	Next  *Node
}

func (n *Node) Len() int {
	if n == nil {
		return 0
	}
	return 1 + n.Next.Len()
}

方法调用本身不会自动 panic,真正解引用 nil 才会。若 nil 没有自然语义,尽早 panic 或返回错误更清晰。

9. 嵌入、字段提升与组合

9.1 嵌入字段

只写类型、不写字段名,就是嵌入字段:

go
type Audit struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

type Order struct {
	ID int64
	Audit
}

字段名默认为类型名,因此两种写法都成立:

go
o.CreatedAt = time.Now()       // 提升后的选择器
o.Audit.CreatedAt = time.Now() // 完整路径

“提升”只是选择器简写。Order 不是 Audit 的子类,不能把 Order 传给需要 Audit 的函数,也没有动态分派和 super

9.2 嵌入指针

go
type Service struct {
	*http.Client
}

嵌入指针可以避免复制并共享实例,但零值 Service{}Client 为 nil,调用提升的方法可能 panic。若零值必须可用,更适合嵌入值或显式初始化。

不能嵌入命名指针类型,也不能直接嵌入类型参数。嵌入字段仍必须满足规范规定的类型形式。

9.3 方法提升不等于继承

go
type Logger struct{}
func (Logger) Log(string) {}

type App struct {
	Logger
}

var _ interface{ Log(string) } = App{}

嵌入会影响方法集:S*S 对嵌入 T*T 的方法提升规则不同。实践中不要凭感觉判断,用编译期断言验证:

go
var _ io.Reader = (*MyType)(nil)

若外层定义同名方法,它会遮蔽提升的方法;可以通过完整路径调用内层方法。两个同层级嵌入类型提供同名字段或方法时,选择器会产生歧义,必须显式写路径。

9.4 组合的两种风格

嵌入适合表达“外层有意暴露内层能力”。普通命名字段适合只把内层当实现细节:

go
type Store struct {
	db *sql.DB // 不把 sql.DB 的整个方法面提升给调用者
}

不要为了少写几个转发方法就嵌入大型类型。嵌入 *sql.DBhttp.Server 之类会把许多方法无意间变成公开 API,也让未来替换实现更困难。

10. 结构体标签与反射

标签是附着在字段上的字符串字面量,通常供反射驱动的库读取:

go
type Profile struct {
	ID       int64  `json:"id"`
	NickName string `json:"nickname,omitempty"`
	Password string `json:"-"`
}

标签本身不会触发任何行为,它的含义由 encoding/json 等读取标签的包定义。多个工具使用的标签可以放在同一个原始字符串中:

go
Email string `json:"email" validate:"required,email" db:"email"`

可以通过反射读取:

go
t := reflect.TypeOf(Profile{})
field, _ := t.FieldByName("NickName")
name, ok := field.Tag.Lookup("json")
fmt.Println(name, ok)

Get 无法区分“键不存在”和“值为空”,需要区分时用 Lookup。标签字符串遵循 reflect.StructTag 约定,键值要正确加引号;go vet 能发现一部分格式错误。

标签也是结构体类型身份的一部分。只相差标签的匿名结构体不是同一类型;规范在显式转换结构体时有“比较底层结构时忽略标签”的专门规则,但直接赋值不能据此放宽。不要依赖这种细节做 API 设计。

关于 JSON 还有几个常见边界:

  • 未导出字段不会被 encoding/json 编码;
  • omitempty 的“空”规则由包定义,不等于业务上的“未提供”;
  • 值字段通常无法区分零值和缺失,可使用指针、自定义类型或额外状态;
  • 嵌入字段的 JSON 行为有自己的冲突选择规则,应写测试固定外部协议。

11. 不可复制类型与复制锁

从语言上说,大多数结构体都能赋值;从语义上说,有些值使用后绝不能复制。典型成员包括:

  • sync.Mutexsync.RWMutexsync.Oncesync.Cond
  • 含内部自引用或运行时状态的类型;
  • 表示唯一资源所有权的封装,如文件、连接或取消函数。
go
type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[key]
	return v, ok
}

Cache 一旦开始使用,再复制就会复制锁,却可能共享同一个 map,于是两个不同的锁保护同一份数据,数据竞争随之出现。因此:

  • 使用指针接收者;
  • 构造和传递 *Cache
  • 不按值放进接口、容器或函数参数;
  • 运行 go vet,它的 copylocks 检查能发现常见复制。

可以嵌入 noCopy 风格的标记帮助静态检查,但这只是工具约定,不是语言级禁止。真正的保证仍来自 API 设计和代码审查。

12. 泛型结构体

结构体可以有类型参数:

go
type Pair[A, B any] struct {
	First  A
	Second B
}

p := Pair[string, int]{First: "age", Second: 18}

方法在接收者位置重新声明对应类型参数:

go
func (p Pair[A, B]) Swap() Pair[B, A] {
	return Pair[B, A]{First: p.Second, Second: p.First}
}

这里 AB 是接收者声明引入的名字,不必和类型定义处同名,但位置和数量要对应。Go 1.26 仍不支持“方法自己新增一组类型参数”,泛化应放在接收者类型或普通泛型函数上。

约束决定字段可用操作:

go
type Box[T comparable] struct {
	Value T
}

func (b Box[T]) Equal(other Box[T]) bool {
	return b.Value == other.Value
}

不要因为能泛型化就创建抽象容器。若 CustomerIDOrderID 在领域上不同,清晰的已定义类型往往比 Entity[ID, Payload] 更容易维护。

13. API 与领域建模

13.1 传值还是传指针

没有“结构体一律传指针”的规则。判断顺序可以是:

  1. 是否要表达可选或共享身份?
  2. 是否要修改调用方的值?
  3. 是否包含不可复制字段?
  4. 复制成本是否被剖析证明重要?
  5. 零值和 nil 的语义是否清楚?

配置、坐标、金额、时间区间等值对象适合按值;数据库连接、缓存、聚合根、服务对象适合指针。为了少复制几个字节而引入 nil、别名共享和堆逃逸,未必更快。

13.2 避免布尔字段堆积

go
type Job struct {
	Running bool
	Done    bool
	Failed  bool
}

这允许同时出现 Running && Done。更稳妥的模型是单一状态:

go
type JobState uint8

const (
	JobPending JobState = iota
	JobRunning
	JobDone
	JobFailed
)

结构体设计的重点不是把数据库列原样搬进内存,而是尽量让非法状态难以表示。

13.3 输入、领域、持久化分层

HTTP 请求 DTO、领域对象和数据库行往往有不同变化原因。一个带满 jsondbvalidate 标签的万能结构体看似省事,长期会把外部协议、业务不变量和存储结构绑死。边界明确的中大型项目应分别定义并显式转换。

14. 性能与并发

结构体性能问题通常来自访问模式,而不只是大小:

  • 连续的 []T 局部性好,但复制或扩容会搬运整个值;
  • []*T 复制指针便宜并保持身份,但增加分配、GC 扫描和缓存跳转;
  • 按值返回不必然分配,编译器可能内联并留在栈上;
  • 按指针返回也不必然更快,逃逸到堆会增加 GC 压力。

使用 go test -bench . -benchmem 比凭直觉选择。需要查看逃逸时可用:

bash
go test -gcflags=all=-m=2 ./...

结构体也不会自动获得并发安全。两个 goroutine 写不同字段,在字段确实不重叠时不一定构成同一内存位置的数据竞争,但类型的不变量、复合读取、缓存一致性和未来重构都会让这种做法脆弱。通常由一个锁保护一组相关状态,或通过 channel 把状态所有权交给单个 goroutine。

值接收者也不是并发快照:若字段含 map、切片或指针,副本仍可能读到并发修改的底层数据。

15. 与 Java 对照

GoJava 中较接近的概念关键差异
structrecord / value objectGo 默认可逐字段复制,没有类继承
*T对象引用Go 指针显式,不能做指针算术
方法接收者this接收者类型可为值或指针,方法写在类型外
嵌入委托 + 部分成员提升不是 extends,没有子类型关系
未导出字段package-private/private可见性只由标识符首字母和包决定
构造函数constructor/factory只是普通函数,可不提供
结构体标签annotation标签只是字符串,行为由库解释
零值默认字段值Go 强调类型零值尽量可直接使用

Java 开发者最需要放下的是“变量都是对象引用”这一直觉。Go 中 time.Time、数组和普通结构体经常按值流动;是否共享必须从字段与指针层次判断。

16. 常见误区

  1. 把嵌入当继承。 提升了字段或方法,不代表外层可以替代内层。
  2. 认为结构体复制就是深复制。 切片、map、指针和接口内部仍会共享。
  3. 所有方法都用指针接收者。 小型值类型会因此丢失 T 的方法集能力,并引入 nil。
  4. 为了“性能”把所有字段改成指针。 分配、GC 和间接访问可能更贵。
  5. 复制含锁的结构体。 两把锁可能保护同一份引用数据,go vet 应纳入 CI。
  6. 依赖固定内存布局做序列化。 对齐、字节序和 ABI 都不是外部协议。
  7. 无脑嵌入大型依赖。 被提升的方法会成为外层 API,后续难以收回。
  8. 认为未导出字段一定不会泄漏。 公开方法、格式化、序列化和返回引用都可能暴露状态。
  9. reflect.DeepEqual 定义业务相等。 结构相等与领域相等不是一回事。
  10. 值接收者等于线程安全。 引用型字段照样共享,并发访问仍需同步。

17. 速查表

需求写法或建议
声明类型type User struct { ID int64 }
创建值u := User{ID: 1}
创建指针p := &User{ID: 1} 或 Go 1.26 的 new(User{ID: 1})
零值var u User
匿名结构体struct{ N int }{N: 1}
嵌入type S struct { T }
标签Field string `json:"field"`
值方法func (v T) M() {}
指针方法func (v *T) M() {}
编译期接口检查var _ io.Reader = (*T)(nil)
查看布局unsafe.SizeofAlignofOffsetof
检查复制锁go vet ./...
并发竞争检查go test -race ./...

接收者选择可以先按语义判断:需要修改原值、包含锁、体积较大或代表稳定身份时,使用指针;小型、不可变且复制含义清楚的值对象,可以使用值。几条经验发生冲突时,业务语义应当优先于微小的性能差异。

可运行示例

这一节从接收者开始,继续验证嵌入与复制语义。所有代码都可以在仓库根目录直接运行。

示例一:值接收者与指针接收者

先看结果为什么不同。 调用 Rename 没有改变账户,Deposit 却改变了余额。区别不在点号语法,而在于方法收到的是对象副本还是对象地址。

go
package main

import "fmt"

type Account struct {
	Owner   string
	Balance int
}

func (a Account) Rename(owner string) {
	// 值接收者拿到 Account 的副本,修改只发生在本次调用的局部副本。
	a.Owner = owner
}

func (a *Account) Deposit(amount int) {
	// 指针接收者操作原对象,适合会改变状态的方法。
	// 调用方必须保证同一 Account 没有被多个 goroutine 无同步地修改。
	a.Balance += amount
}

func (a Account) Summary() string {
	// 只读小对象适合值接收者;方法不会取得对象的可变所有权。
	return fmt.Sprintf("%s: %d", a.Owner, a.Balance)
}

func main() {
	account := Account{Owner: "小林", Balance: 100}
	account.Rename("不会生效的名字")
	account.Deposit(50) // 编译器会自动取址,等价于 (&account).Deposit(50)。

	fmt.Println(account.Summary())
}

运行:

bash
go run ./examples/ch09/receivers

预期输出:

text
小林: 150

沿着调用过程看。

  • Rename 的值接收者是 Account 的副本,字段写入随调用结束而消失。
  • Deposit 的指针接收者指向原账户;对可寻址变量调用时,编译器自动补上 &
  • 指针只解决“改哪个对象”,不自动解决并发安全。多个 goroutine 修改同一账户仍需同步。

进一步验证。Rename 改成指针接收者,输出应变成新名字;再把 Deposit 改成值接收者,余额会停留在 100。最后尝试对 Account{...} 临时值调用指针方法,借助编译结果理解“可寻址”限制。

示例二:嵌入是组合,不是继承

嵌入后究竟省略了什么。 嵌入字段的方法可以直接调用,外层字段和内层方法重名时却需要明确选择。这个示例同时展示方法提升和显式选择。

go
package main

import "fmt"

type Logger struct {
	Prefix string
}

func (l Logger) Name() string {
	return "Logger"
}

func (l Logger) Log(message string) {
	fmt.Printf("%s %s\n", l.Prefix, message)
}

type Service struct {
	// 嵌入表示组合,不表示继承。Logger 仍是一个有明确生命周期的字段。
	Logger
	Name string
}

func (s Service) Describe() string {
	// Service.Name 字段遮蔽了 Logger.Name 方法;需要时可显式选择内层对象。
	return fmt.Sprintf("service=%s component=%s", s.Name, s.Logger.Name())
}

func main() {
	service := Service{
		Logger: Logger{Prefix: "[order]"},
		Name:   "checkout",
	}

	// Log 被提升后可以直接调用,但实际执行者仍是内嵌的 Logger。
	service.Log("started")
	fmt.Println(service.Describe())
}

运行:

bash
go run ./examples/ch09/embedding

预期输出:

text
[order] started
service=checkout component=Logger

这里要分清三层关系。

  1. Service 内部真实保存一个 Logger 字段,它有独立、明确的值和生命周期。
  2. Logger.Log 被提升,所以 service.Log(...) 只是更短的选择器写法。
  3. Service.Name 遮蔽了同名的内层成员;service.Logger.Name() 可以明确指定内层。

再改一步。Service 增加自己的 Log 方法,观察外层方法会遮蔽提升方法,但 service.Logger.Log 仍可调用。然后把匿名嵌入改成命名字段,比较两种组合方式在较大代码中的可读性。

示例三:结构体复制不等于深拷贝

复制在哪里停止。 结构体赋值后,普通字段已经独立,修改切片元素却仍会影响原对象。原因是复制发生在 slice header,两份 header 仍可能指向同一个底层数组。

go
package main

import "fmt"

type Profile struct {
	Name string
	Tags []string
}

func (p Profile) Clone() Profile {
	clone := p
	// 结构体赋值只复制 slice 头;这里主动复制底层数组,建立独立所有权。
	clone.Tags = append([]string(nil), p.Tags...)
	return clone
}

func main() {
	original := Profile{Name: "原对象", Tags: []string{"go", "backend"}}

	shallow := original
	shallow.Name = "浅拷贝"
	shallow.Tags[0] = "shared" // 两个 slice 仍指向同一底层数组。

	deep := original.Clone()
	deep.Tags[0] = "independent"

	fmt.Println("original:", original.Name, original.Tags)
	fmt.Println("shallow :", shallow.Name, shallow.Tags)
	fmt.Println("deep    :", deep.Name, deep.Tags)
}

运行:

bash
go run ./examples/ch09/copy-semantics

预期输出:

text
original: 原对象 [shared backend]
shallow : 浅拷贝 [shared backend]
deep    : 原对象 [independent backend]

从字段逐个判断。

  • shallow.Name 是独立字符串字段,修改不会回写原结构体。
  • shallow.Tagsoriginal.Tags 的 header 不同,但都引用同一底层数组。
  • Clone 重新分配并复制元素,让返回对象拥有自己的数组。

继续验证边界。Profile 增加 map[string]string,先用普通赋值复制,再修改 map;它同样共享底层数据。随后在 Clone 中逐项复制 map,形成真正的所有权隔离。

18. 练习

  1. 定义 Money,包含金额最小单位和币种。让零值是否有效成为一个明确设计决定,并说明理由。
  2. 实现包含 []stringmap[string]stringDocument.Clone,再增加指针 value,观察“一层克隆”的边界。
  3. 写两个字段顺序不同但字段集合相同的结构体,用 unsafe.Sizeof 比较本机大小;在 amd64 与 arm64 上查验结果。
  4. 定义一个嵌入类型,让两个内层类型都提供 String(),观察选择器歧义并显式解决。
  5. 分别让 T*T 实现一个接口,用编译期断言验证方法集。
  6. 写一个含 sync.Mutex 的计数器,故意按值复制,运行 go vet,解释报告而不是只修复它。
  7. 为 HTTP 输入、领域对象和数据库行分别建模,写显式转换函数并处理可选字段。
  8. []Large[]*Large 做基准测试,记录分配、遍历速度和 GC 差异。

19. 官方资料

以 Go 官方规范与标准库文档为准,示例面向 Go 1.26。