目录

Go-11 结构体与方法

结构体(struct)是 Go 中最重要的复合数据类型,它把一组不同类型的字段聚合成一个整体,是构建业务模型、数据结构乃至整个类型系统的基石。Go 没有类、没有继承,却通过"结构体 + 方法 + 嵌入 + 接口"这四件套,优雅地实现了面向对象的表达力。本章系统梳理结构体的定义、初始化、方法机制、值/指针接收者的选择、嵌入组合、标签反射、比较拷贝以及空结构体的妙用。


1. 结构体定义与初始化

1.1 基本定义

结构体用 type ... struct{} 定义,字段有名字和类型:

package main

import "fmt"

// User 定义一个用户结构体
type User struct {
	ID   int64  // 用户 ID
	Name string // 用户名
	Age  int    // 年龄
}

func main() {
	var u User // 零值结构体:所有字段取各自类型的零值
	fmt.Printf("%+v\n", u) // {ID:0 Name: Age:0}
}

结构体的零值是每个字段都取零值:数值为 0、字符串为 ""、布尔为 false、指针/slice/map/接口为 nil。这一点非常重要——Go 中的零值结构体通常是"开箱即用"的,无需显式构造函数。

1.2 三种初始化方式

package main

import "fmt"

type Point struct {
	X, Y int
}

func main() {
	// 方式一:按字段顺序初始化(必须给全部字段,且顺序一致,不推荐)
	p1 := Point{1, 2}

	// 方式二:具名初始化(推荐,可只写部分字段,未写的取零值)
	p2 := Point{X: 3} // Y 为 0

	// 方式三:先声明后赋值
	var p3 Point
	p3.X = 5
	p3.Y = 6

	fmt.Println(p1, p2, p3) // {1 2} {3 0} {5 6}
}

强烈建议使用具名初始化。按序初始化在字段增删或调整顺序时会静默出错,而且新增字段会导致编译失败(因为字段数不匹配),维护成本高。

1.3 指针初始化 &T{}

&T{} 直接返回结构体指针,是 Go 里最常见的构造惯用法:

package main

import "fmt"

type User struct {
	Name string
	Age  int
}

func main() {
	// &User{} 在堆或栈上分配一个 User,返回其地址
	u := &User{Name: "Tom", Age: 18}
	fmt.Println(u.Name) // Go 自动解引用,等价于 (*u).Name

	// 也可以先 new,再赋值
	u2 := new(User) // 返回 *User,字段为零值
	u2.Name = "Jerry"
	fmt.Println(u2)
}

new(User)&User{} 都返回 *User,区别在于后者可以顺带初始化字段。工程中常用 &User{...} 或封装 NewUser() 工厂函数。

1.4 匿名结构体

无需 type 定义、临时使用一次的结构体,常用于测试用例、临时聚合、JSON 拼装:

package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	// 匿名结构体:类型即字面量,用完即弃
	resp := struct {
		Code int    `json:"code"`
		Msg  string `json:"msg"`
	}{
		Code: 200,
		Msg:  "ok",
	}
	b, _ := json.Marshal(resp)
	fmt.Println(string(b)) // {"code":200,"msg":"ok"}

	// 表驱动测试中的经典用法
	tests := []struct {
		name string
		in   int
		want int
	}{
		{"case1", 1, 2},
		{"case2", 2, 4},
	}
	for _, tt := range tests {
		fmt.Println(tt.name, tt.in, tt.want)
	}
}

1.5 嵌套结构体

结构体的字段本身也可以是结构体:

type Address struct {
	City string
	Zip  string
}

type Company struct {
	Name string
	Addr Address // 命名字段,需通过 c.Addr.City 访问
}

func main() {
	c := Company{
		Name: "Acme",
		Addr: Address{City: "Beijing", Zip: "100000"},
	}
	fmt.Println(c.Addr.City)
}

2. 方法(Method)

2.1 方法定义与 receiver

方法就是带接收者(receiver)的函数。接收者写在 func 关键字和方法名之间:

package main

import "fmt"

type Rectangle struct {
	Width, Height float64
}

// Area 是 Rectangle 的方法,r 是接收者
func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

// Perimeter 周长
func (r Rectangle) Perimeter() float64 {
	return 2 * (r.Width + r.Height)
}

func main() {
	rect := Rectangle{Width: 3, Height: 4}
	fmt.Println(rect.Area())      // 12
	fmt.Println(rect.Perimeter()) // 14
}

2.2 方法与函数的区别

维度 函数 方法
定义 func Foo(...) func (r T) Foo(...)
归属 独立,属于包 绑定到某个类型
调用 Foo(x) x.Foo()
命名空间 同包内不能重名 不同类型可有同名方法
接口实现 无关 方法集决定是否实现接口

方法本质上是语法糖,rect.Area() 可理解为 Rectangle.Area(rect)(方法值/方法表达式,见 2.4)。

2.3 方法可定义在任意本地类型上

接收者不一定是结构体,任何本地定义的类型都能有方法(内置类型和其他包的类型不行):

package main

import "fmt"

// 基于 int 的自定义类型
type MyInt int

func (m MyInt) IsEven() bool {
	return m%2 == 0
}

// 基于 slice 的自定义类型
type IntSlice []int

func (s IntSlice) Sum() int {
	total := 0
	for _, v := range s {
		total += v
	}
	return total
}

func main() {
	var n MyInt = 4
	fmt.Println(n.IsEven()) // true
	fmt.Println(IntSlice{1, 2, 3}.Sum()) // 6
}

2.4 方法值与方法表达式

package main

import "fmt"

type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ }

func main() {
	c := &Counter{}

	// 方法值:绑定了接收者 c,是一个函数值
	f := c.Inc
	f() // 等价于 c.Inc()
	fmt.Println(c.n) // 1

	// 方法表达式:不绑定接收者,接收者作为第一个参数显式传入
	g := (*Counter).Inc
	g(c) // 显式传入 c
	fmt.Println(c.n) // 2
}

2.5 方法集概念

**方法集(method set)**指一个类型所拥有的全部方法的集合。它决定了:

  • 类型能否赋值给某个接口(即是否"实现"了接口);
  • 通过哪种形式(值/指针)能调用哪些方法。

方法集的规则是本章的核心难点,下一节展开。


3. 值接收者 vs 指针接收者

3.1 修改语义的差异

值接收者拿到的是副本,修改不影响原对象;指针接收者拿到的是地址,修改会作用到原对象:

package main

import "fmt"

type Account struct {
	Balance int
}

// 值接收者:修改的是副本,外部看不到变化
func (a Account) DepositByValue(amount int) {
	a.Balance += amount // 只改副本
}

// 指针接收者:修改原对象
func (a *Account) DepositByPointer(amount int) {
	a.Balance += amount
}

func main() {
	acc := Account{Balance: 100}

	acc.DepositByValue(50)
	fmt.Println(acc.Balance) // 100,没变!

	acc.DepositByPointer(50)
	fmt.Println(acc.Balance) // 150,变了
}

3.2 方法集规则(重点)

这是面试高频、也是新手最易踩坑的地方。规则如下:

接收者类型 T 的方法集 指针 *T 的方法集
func (t T) 值方法 ✅ 拥有 ✅ 拥有
func (t *T) 指针方法 ❌ 不拥有 ✅ 拥有

一句话总结:*T 的方法集 = 值方法 + 指针方法;T 的方法集 = 仅值方法。

原因:从一个值 T 无法安全地取得可寻址的指针(比如临时值、map 元素),因此 Go 规定 T 的方法集不包含指针方法。而 *T 显然可以解引用得到值,所以它两者都有。

package main

import "fmt"

type Speaker interface {
	Speak() string
	SetName(string)
}

type Dog struct {
	name string
}

// 值接收者
func (d Dog) Speak() string { return "Woof, I'm " + d.name }

// 指针接收者
func (d *Dog) SetName(n string) { d.name = n }

func main() {
	// *Dog 拥有 Speak 和 SetName,能实现 Speaker
	var s Speaker = &Dog{}
	s.SetName("Rex")
	fmt.Println(s.Speak())

	// var s2 Speaker = Dog{} // 编译错误!
	// Dog 只拥有 Speak,缺少 SetName(指针方法),未实现 Speaker
}

3.3 直接调用时的"自动取址"糖衣

你可能疑惑:既然 Dog{} 不拥有 SetName,为什么下面能编译?

d := Dog{}
d.SetName("Rex") // 竟然可以?

因为 d可寻址的变量,Go 编译器自动帮你改写成 (&d).SetName("Rex")。同理,指针也能自动解引用调用值方法。但这只是直接调用的便利,赋值给接口时不会自动取址——因为接口内部存的是值的拷贝,编译器无法保证其可寻址。这正是 3.2 中 Dog{} 不能赋给 Speaker 的根本原因。

不可寻址的例子(会报错):

type M map[string]Dog

func demo(m M) {
	// m["a"].SetName("x") // 编译错误:map 元素不可寻址,无法自动取址
}

3.4 该用哪种接收者?

决策清单:

  1. 需要修改接收者 → 必须用指针接收者。
  2. 结构体较大(含大数组、很多字段)→ 用指针接收者,避免每次拷贝开销。
  3. 包含不应被拷贝的字段(如 sync.Mutexsync.WaitGroup)→ 必须用指针,拷贝锁会破坏其语义。
  4. 小的、不可变的值类型(如 time.TimePoint)→ 可用值接收者,语义清晰、并发安全。
  5. 一致性原则(最重要):同一类型的所有方法,尽量统一用同一种接收者,不要混用。如果有任何一个方法需要指针接收者,那么其余方法通常也都用指针接收者。
// 反例:混用接收者,容易导致方法集不完整、接口实现失败
type Bad struct{ n int }
func (b Bad) A() {}   // 值
func (b *Bad) B() {}  // 指针
// Bad{} 只有 A,*Bad 有 A、B,混乱

// 正例:统一指针接收者
type Good struct{ n int }
func (g *Good) A() {}
func (g *Good) B() {}

经验法则:拿不准就用指针接收者。这是社区最主流的默认选择。


4. 结构体嵌入与组合

Go 用**组合(composition)**代替继承。嵌入(embedding)是组合的语法支持:把一个类型作为匿名字段放进结构体,其字段和方法会被"提升"到外层。

4.1 匿名字段与字段提升

package main

import "fmt"

type Animal struct {
	Name string
}

func (a Animal) Eat() {
	fmt.Println(a.Name, "is eating")
}

// Dog 嵌入 Animal(匿名字段,只写类型名)
type Dog struct {
	Animal // 嵌入
	Breed  string
}

func main() {
	d := Dog{
		Animal: Animal{Name: "Rex"},
		Breed:  "Husky",
	}

	// 字段提升:可直接访问 d.Name,无需 d.Animal.Name
	fmt.Println(d.Name)  // Rex(提升)
	fmt.Println(d.Animal.Name) // Rex(完整路径也可)

	// 方法提升:Animal 的 Eat 被提升到 Dog
	d.Eat() // Rex is eating
}

提升(promotion):内嵌类型的导出/非导出字段与方法,在外层可以像自己的成员一样直接访问,这就是"提升"。

4.2 嵌入 ≈ “继承”?其实是组合

嵌入看起来像继承,但本质不同:

继承(如 Java):            组合/嵌入(Go):
   Animal                       Dog
     ▲                          ┌──────────────┐
     │ is-a                     │ Animal(anon) │  ← has-a
     │                          │ Breed        │
   Dog                          └──────────────┘
                                方法/字段被"提升",
                                但 Dog 不是 Animal 的子类型

关键区别:

  • Go 中 Dog 不是 Animal 的子类型,不能把 Dog 直接当 Animal 用于类型判断(除非通过接口)。
  • 没有虚函数/多态覆盖那套;重写只是"就近遮蔽"。
  • 组合更灵活:可以嵌入多个类型,可以嵌入接口。

4.3 方法重写(遮蔽)与调用链

外层定义同名方法会遮蔽(override/shadow)内嵌的方法,遵循就近原则

package main

import "fmt"

type Base struct{}

func (b Base) Hello() { fmt.Println("Base.Hello") }
func (b Base) Name()  { fmt.Println("Base.Name") }

type Derived struct {
	Base
}

// 遮蔽 Base.Hello
func (d Derived) Hello() {
	fmt.Println("Derived.Hello")
	d.Base.Hello() // 仍可显式调用被遮蔽的方法(类似 super)
}

func main() {
	d := Derived{}
	d.Hello() // Derived.Hello \n Base.Hello
	d.Name()  // Base.Name(未遮蔽,直接用 Base 的)
}

注意:Go 没有真正的多态覆盖。如果 Base 的某方法内部调用了 Hello(),它调用的永远是 Base.Hello不会动态分派到 Derived.Hello。这与 Java 的虚方法不同,是嵌入不能完全替代继承的关键点。

4.4 多层嵌入与字段冲突

多层嵌入时,提升沿最短路径进行;出现同名字段冲突时,浅层遮蔽深层;同一层级出现冲突则必须显式指定路径,否则编译报错(若被访问)。

package main

import "fmt"

type A struct{ X int }
type B struct{ X int }

type C struct {
	A
	B
	X int // 最外层的 X,遮蔽 A.X 和 B.X
}

func main() {
	c := C{}
	c.X = 1   // 访问最外层 X(就近)
	c.A.X = 2 // 显式访问 A.X
	c.B.X = 3 // 显式访问 B.X
	fmt.Println(c.X, c.A.X, c.B.X) // 1 2 3

	// 若没有最外层 X:
	// type D struct{ A; B }
	// d.X 会编译报错 "ambiguous selector",因为 A.X 和 B.X 同层冲突
}

4.5 组合优于继承(实战案例)

嵌入的真正价值是复用行为、减少样板代码

package main

import (
	"fmt"
	"sync"
)

// 复用一把锁的能力
type SafeCounter struct {
	sync.Mutex // 嵌入 Mutex,提升 Lock/Unlock 方法
	count      int
}

func (c *SafeCounter) Inc() {
	c.Lock()   // 直接用提升的 Lock(来自 sync.Mutex)
	defer c.Unlock()
	c.count++
}

func main() {
	c := &SafeCounter{}
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); c.Inc() }()
	}
	wg.Wait()
	fmt.Println(c.count) // 1000
}

提示:嵌入 sync.Mutex 时它会随外层结构体一起被拷贝,因此这类结构体的方法一定要用指针接收者,且外层禁止值拷贝。若不想把 Lock/Unlock 暴露为外层的公开方法,可改用命名字段 mu sync.Mutex


5. 嵌入接口与嵌入指针

5.1 嵌入接口 —— 装饰器模式

结构体嵌入接口时,只要在构造时塞入一个实现该接口的对象,外层就自动"拥有"了这些方法。这是实现装饰器、包装器的利器:

package main

import "fmt"

// 被装饰的接口
type Reader interface {
	Read() string
}

// 一个基础实现
type fileReader struct{ data string }

func (f fileReader) Read() string { return f.data }

// LoggingReader 嵌入 Reader 接口,实现装饰
type LoggingReader struct {
	Reader // 嵌入接口
	prefix string
}

// 只重写想增强的方法,其余方法透明转发给内嵌的 Reader
func (l LoggingReader) Read() string {
	s := l.Reader.Read() // 调用被装饰对象
	fmt.Println(l.prefix, "read:", s)
	return s
}

func main() {
	base := fileReader{data: "hello"}
	dec := LoggingReader{Reader: base, prefix: "[LOG]"}
	dec.Read() // [LOG] read: hello
}

标准库的 io.ReadWriter 等就是接口嵌接口;sort 包中很多结构体也用嵌入接口实现"只覆盖 Less 一个方法"的技巧。

5.2 嵌入指针

匿名字段也可以是指针类型,字段/方法同样被提升,只是需要保证指针非 nil 再访问:

package main

import "fmt"

type Engine struct{ Power int }

func (e *Engine) Start() { fmt.Println("engine start, power=", e.Power) }

type Car struct {
	*Engine // 嵌入指针
	Brand   string
}

func main() {
	car := Car{
		Engine: &Engine{Power: 200},
		Brand:  "Tesla",
	}
	car.Start()            // engine start, power= 200(方法提升)
	fmt.Println(car.Power) // 200(字段提升)

	// 若 Engine 为 nil,则 car.Start() 会 panic(空指针)
}

嵌入指针的好处是共享同一个底层对象、节省拷贝,常用于依赖注入(多个结构体持有同一个 *sql.DB*Logger 等)。


6. 结构体标签(Tag)

6.1 tag 语法

标签是字段后面的字符串字面量,用反引号包裹,格式为 key:"value" 空格分隔多个:

type User struct {
	ID   int64  `json:"id" gorm:"primaryKey"`
	Name string `json:"name" gorm:"column:user_name;size:64"`
	Age  int    `json:"age,omitempty" validate:"gte=0,lte=150"`
	Pass string `json:"-"` // json:"-" 表示序列化时忽略该字段
}

标签本身对程序运行没有直接影响,它只是元数据,由使用反射的库(如 encoding/jsongormvalidator)在运行时读取并据此决定行为。

常见 json tag 选项:

写法 含义
json:"name" 序列化时字段名用 name
json:"name,omitempty" 值为零值时省略该字段
json:"-" 忽略此字段
json:",omitempty" 保留原字段名,但零值省略
json:"name,string" 把数值/布尔编码为字符串

6.2 用反射读取 tag

标签通过 reflect 包读取,核心是 StructTag.Get(key)StructTag.Lookup(key)

package main

import (
	"fmt"
	"reflect"
)

type User struct {
	ID   int64  `json:"id" gorm:"primaryKey"`
	Name string `json:"name" validate:"required"`
}

func main() {
	t := reflect.TypeOf(User{})
	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		// Get:拿不到返回 ""
		jsonTag := field.Tag.Get("json")
		// Lookup:返回值 + 是否存在,更精确
		gormTag, ok := field.Tag.Lookup("gorm")
		fmt.Printf("字段=%s json=%q gorm=%q(存在=%v)\n",
			field.Name, jsonTag, gormTag, ok)
	}
}

输出:

字段=ID   json="id"   gorm="primaryKey"(存在=true)
字段=Name json="name" gorm=""(存在=false)

6.3 tag 的底层原理

StructTag 本质是 string,其 Get/Lookup 方法就是按 key:"value" 语法做字符串解析。原理简述:

  • reflect.StructField.Tag 类型为 reflect.StructTag(底层是 string)。
  • Lookup 从头扫描,按空格切分出每个 key:"value" 段,匹配 key 后返回引号内的 value。
  • 因此 tag 的格式必须严格key:"value",冒号后紧跟双引号、无多余空格,多个 tag 之间用单个空格分隔。格式错误不会报错,但会导致解析不到(静默失败),是常见坑。

7. 结构体比较与拷贝

7.1 结构体的可比较性

结构体能否用 == 比较,取决于它的所有字段是否都可比较

package main

import "fmt"

type Point struct{ X, Y int }

func main() {
	a := Point{1, 2}
	b := Point{1, 2}
	fmt.Println(a == b) // true:逐字段比较,全等则相等

	// 可作为 map 的 key
	m := map[Point]string{{1, 2}: "origin"}
	fmt.Println(m[Point{1, 2}]) // origin
}

不可比较的情况:字段中包含 slicemapfunc 类型时,整个结构体不可比较,用 ==编译报错

type Bad struct {
	Tags []string // slice 不可比较
}
// Bad{} == Bad{} // 编译错误:invalid operation, struct containing []string cannot be compared

此时需用 reflect.DeepEqual(a, b) 做深度比较(但性能较差,慎用于热路径)。

字段类型 可否用 ==
数值、布尔、字符串、指针、channel
数组(元素可比较)
结构体(字段全可比较)
接口(动态类型可比较) ✅(运行时若动态值不可比较会 panic)
slice、map、func ❌ 编译错误

7.2 值拷贝的深浅

结构体赋值、函数传参都是值拷贝——但拷贝是"浅"的:值类型字段被复制,而指针/slice/map 字段只复制"引用"(指针值),底层数据仍共享。

package main

import "fmt"

type Profile struct {
	Name string   // 值字段,独立拷贝
	Tags []string // 引用字段,浅拷贝共享底层数组
}

func main() {
	a := Profile{Name: "Tom", Tags: []string{"go"}}
	b := a // 值拷贝

	b.Name = "Jerry"     // 只改 b
	b.Tags[0] = "rust"   // 陷阱!a、b 共享底层数组

	fmt.Println(a.Name, a.Tags) // Tom [rust]  ← Tags 被改了!
	fmt.Println(b.Name, b.Tags) // Jerry [rust]
}

内存示意:

a:  Name="Tom"    Tags ─┐
                        ├──► [array: "go"/"rust"]  ← shared
b:  Name="Jerry"  Tags ─┘

7.3 深拷贝的实现

若需要完全独立的副本,必须手动深拷贝引用类型字段:

package main

import "fmt"

type Profile struct {
	Name string
	Tags []string
}

// Clone 手动深拷贝
func (p Profile) Clone() Profile {
	newTags := make([]string, len(p.Tags))
	copy(newTags, p.Tags) // 复制底层数组
	return Profile{
		Name: p.Name,
		Tags: newTags,
	}
}

func main() {
	a := Profile{Name: "Tom", Tags: []string{"go"}}
	b := a.Clone()
	b.Tags[0] = "rust"
	fmt.Println(a.Tags, b.Tags) // [go] [rust] ← 互不影响
}

其他深拷贝手段:

  • 序列化法json.Marshal 后再 Unmarshal,简单通用但慢、且会丢失未导出字段。
  • 反射法:如 github.com/mohae/deepcopy 等库,通用但有性能开销。
  • 手写 Clone:性能最好、最可控,推荐在核心路径使用。

8. 空结构体 struct{}

struct{} 是没有任何字段的结构体,它的实例不占用内存(0 字节),是 Go 里一个精巧的工具。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var s struct{}
	fmt.Println(unsafe.Sizeof(s)) // 0
}

8.1 用途一:实现 Set 集合

map 的 value 用 struct{} 表示"只关心 key 存在与否",比 bool 更省内存、语义更明确:

package main

import "fmt"

func main() {
	set := make(map[string]struct{})

	// 添加元素
	set["a"] = struct{}{}
	set["b"] = struct{}{}

	// 判断存在
	if _, ok := set["a"]; ok {
		fmt.Println("a 存在")
	}

	// 删除
	delete(set, "a")
	fmt.Println(len(set)) // 1
}

对比 map[string]boolstruct{}{} value 不占空间,且避免了 true/false 的歧义(你只关心 key 在不在)。

8.2 用途二:channel 信号

只用作通知、不传数据的 channel,用 chan struct{} 最合适——明确表达"这里只有信号,没有数据":

package main

import (
	"fmt"
	"time"
)

func worker(done chan struct{}) {
	time.Sleep(100 * time.Millisecond)
	fmt.Println("工作完成")
	close(done) // 用关闭 channel 广播信号
}

func main() {
	done := make(chan struct{})
	go worker(done)
	<-done // 阻塞等待信号
	fmt.Println("主协程收到通知")
}

8.3 用途三:仅带方法的类型

当你只需要一组方法、不需要任何状态时,可用空结构体作为方法的载体:

type StringUtil struct{}

func (StringUtil) Reverse(s string) string {
	r := []rune(s)
	for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	return string(r)
}

有趣的底层细节:Go 中所有空结构体实例都指向同一个特殊地址 runtime.zerobase,因此分配再多也不消耗堆内存。


9. 优化建议

9.1 字段对齐(呼应第 10 章)

结构体的大小受内存对齐影响,调整字段顺序可减少 padding。将相同/相近大小的字段聚在一起,通常按字段大小从大到小排列:

// 不良布局:因对齐产生 padding,占 24 字节
type Bad struct {
	a bool  // 1 字节 + 7 填充
	b int64 // 8 字节
	c bool  // 1 字节 + 7 填充
}

// 优化布局:占 16 字节
type Good struct {
	b int64 // 8
	a bool  // 1
	c bool  // 1 (a、c 挤在一起,剩余 6 字节填充)
}

可用 unsafe.Sizeof 验证,或用 fieldalignmentgolang.org/x/tools)工具自动检查。详见第 10 章。

9.2 避免大结构体值传递

大结构体作为参数/返回值/range 变量时会整体拷贝,开销大。热路径应传指针:

type BigStruct struct {
	data [1024]int
}

func processByValue(b BigStruct)  {} // 每次拷贝 8KB
func processByPointer(b *BigStruct) {} // 只拷贝 8 字节指针

// range 也会拷贝:
// for _, v := range bigSlice { ... } // v 是每个元素的副本
// 大元素改用索引访问:for i := range bigSlice { use(&bigSlice[i]) }

9.3 用嵌入减少样板代码

嵌入常用基类可省去大量转发方法。例如实现某个大接口时,嵌入一个提供默认实现的类型,只覆盖需要定制的方法(标准库 sort、gRPC 生成代码的 UnimplementedXxxServer 都用此技巧向前兼容):

type UnimplementedServer struct{}
func (UnimplementedServer) Foo() { panic("not implemented") }
func (UnimplementedServer) Bar() { panic("not implemented") }

// 实际实现只需嵌入 + 覆盖关心的方法
type MyServer struct {
	UnimplementedServer // 自动获得 Foo/Bar 的默认实现
}
func (MyServer) Foo() { /* 真正实现 */ }

9.4 其他要点

  • sync.Mutex/sync.WaitGroup/atomic 等的结构体禁止值拷贝,用 go vet 的 copylocks 检查可捕获。
  • 需要"不可比较"或"禁止拷贝"语义时,可嵌入 [0]func()sync.Mutex 等哨兵字段。
  • 构造复杂结构体优先用工厂函数 NewXxx(),隐藏初始化细节。

10. 高频面试题

Q1:值接收者和指针接收者有什么区别?如何选择?

区别:值接收者操作的是副本,方法内修改不影响原对象;指针接收者操作原对象,可修改其状态。选择原则:① 需要修改接收者 → 指针;② 结构体大 → 指针(避免拷贝);③ 含锁等不可拷贝字段 → 指针;④ 小的不可变值 → 值;⑤ 一致性:同类型方法尽量统一。拿不准就用指针。

Q2:T*T 的方法集有何不同?下面代码为什么编译不过?

type Speaker interface{ SetName(string) }
type Dog struct{ name string }
func (d *Dog) SetName(n string) { d.name = n }
var s Speaker = Dog{} // 编译错误

*T 的方法集包含值方法和指针方法;T 的方法集只包含值方法。SetName 是指针接收者方法,只属于 *Dog 的方法集,Dog 值没有它,故 Dog{} 未实现 Speaker。改为 var s Speaker = &Dog{} 即可。根本原因:接口存的是值的拷贝且不可寻址,不能自动取址调用指针方法。

Q3:为什么 d.SetName()(d 是 Dog 变量)能直接调用,但赋给接口却不行?

因为 d 是可寻址的变量,编译器自动改写成 (&d).SetName()——这是直接调用的语法糖。但赋值给接口时,接口内部存的是值的一份拷贝,编译器无法保证该拷贝可寻址,所以不做自动取址,方法集规则严格生效。

Q4:Go 的嵌入是继承吗?

不是。嵌入是组合,通过字段/方法提升复用行为,但:① 外层类型不是内嵌类型的子类型;② 没有虚方法动态分派——被嵌入类型内部调用某方法时,永远调用自己的版本,不会分派到外层的同名方法;③ 遮蔽只是就近覆盖。Go 提倡"组合优于继承"。

Q5:结构体能用 == 比较吗?作 map 的 key 呢?

当且仅当所有字段都可比较时,结构体可用 == 比较,也能作 map 的 key。若含 slice、map、func 字段则不可比较(编译报错),需用 reflect.DeepEqual。接口字段可比较,但运行时若其动态值不可比较会 panic。

Q6:结构体赋值是深拷贝还是浅拷贝?

浅拷贝:值类型字段独立复制,但 slice/map/指针字段只复制引用,底层数据共享。修改副本的引用字段会影响原对象。需要独立副本要手写 Clone 或用序列化/反射做深拷贝。

Q7:struct tag 的原理是什么?为什么有时不生效?

tag 是字段后的字符串元数据,格式为 `key:"value"`,由反射通过 field.Tag.Get(key) / Lookup(key) 解析。它对运行时无直接影响,仅供 json、gorm 等库读取。不生效常见于:① 格式错误(冒号后没紧跟双引号、多余空格、用了普通引号而非反引号)会静默解析失败;② 字段未导出(首字母小写),反射无法访问。

Q8:空结构体 struct{} 有什么用?占多少内存?

占 0 字节(所有实例共享 runtime.zerobase)。用途:① map[T]struct{} 实现 Set;② chan struct{} 传递纯信号;③ 作为只有方法、无状态的类型载体。相比 bool 更省内存、语义更清晰。

Q9:为什么含 sync.Mutex 的结构体不能值传递?

sync.Mutex 内部有记录锁状态的字段,值拷贝会连同锁状态一起复制,导致副本和原对象各持一份互不相关的锁状态,破坏互斥语义(可能死锁或数据竞争)。所以这类结构体必须用指针接收者和指针传递,go vet 的 copylocks 会检查此类误用。

Q10:new(T)&T{} 有什么区别?

两者都返回 *T 且字段初始化为零值。区别:&T{} 可以在构造时顺带初始化字段(&T{X:1}),更灵活也更常用;new(T) 只能得到全零值对象。工程中几乎都用 &T{} 或工厂函数。


小结

  • 定义与初始化:优先具名初始化和 &T{};零值结构体通常可直接用;匿名结构体适合临时聚合与表驱动测试。
  • 方法与接收者:方法是带 receiver 的函数。值接收者操作副本、指针接收者操作原对象。方法集规则是核心:*T 拥有值方法+指针方法,T 只拥有值方法——这直接决定接口实现能否成立。选择接收者遵循"需改/大/含锁用指针、保持一致、拿不准用指针"。
  • 嵌入与组合:Go 用嵌入实现字段/方法提升,是组合而非继承——无子类型、无动态分派、就近遮蔽。嵌入接口可做装饰器,嵌入指针可共享对象、减少样板代码。
  • tag 与反射:tag 是字段元数据,靠 reflect.StructTag.Get/Lookup 解析,格式必须严格,否则静默失效。
  • 比较与拷贝:字段全可比较才能用 == 和作 map key;赋值是浅拷贝,含引用字段要警惕共享,需要独立副本要深拷贝。
  • 空结构体:0 字节,是 Set、信号 channel、无状态方法载体的理想选择。
  • 优化:字段对齐减小体积、大结构体传指针、用嵌入减少样板、含锁结构禁止值拷贝。

掌握结构体与方法,就掌握了 Go 类型系统的半壁江山。下一章我们将进入接口(interface),看 Go 如何用"鸭子类型 + 隐式实现"构建灵活而安全的抽象。