《Go 语言编程入门》4.2 方法与值/指针接收者

给 Task 加上 Complete() 与 Rename() 后,接收者的选择成了绕不开的问题。本节讲清值接收者与指针接收者的语义差异、方法集与接口满足规则、自动取址与不可寻址的坑、nil 指针接收者的安全性,并给出 TaskAPI 里每个方法的接收者决策依据。

4.2 方法与值/指针接收者

4.1 让 Task 成了一个语义清晰的值类型,但它仍然只会被外部函数摆弄:toggle(t *Task) 这样的命令式函数散落在 main.go 里,调用方必须知道 Task 的内部结构才能操作它。本节把行为收回类型自身——给 Task 加上 Complete() 和 Rename(),让「完成任务」「改名」成为 Task 的能力,而不是外部代码的特权。

本节把 TaskAPI 推进到「领域对象拥有行为」:Task 获得 Complete() 与 Rename() 两个方法,同时确定每个方法的接收者类型,为 5.1 的 TaskStore 接口铺路。

4.2.1 方法的定义

方法就是「带接收者的函数」。声明方式是在 func 和函数名之间插入接收者列表:

type Task struct {
	ID    int64
	Title string
	Done  bool
}

// 指针接收者:可以修改接收者指向的对象
func (t *Task) Complete() {
	t.Done = true
}

// 指针接收者:同样需要修改 Title
func (t *Task) Rename(title string) {
	t.Title = title
}

调用时接收者被放在点号左边,看起来像面向对象的方法调用:

func main() {
	t := Task{ID: 1, Title: "写稿"}
	t.Complete()
	t.Rename("写 Go 书稿")
	fmt.Printf("%+v\n", t) // {ID:1 Title:写 Go 书稿 Done:true}
}

这里有一个关键区别:方法不是定义在类型内部的。上面两个方法在语法上写在与 Task 类型声明平级的包级位置,接收者只是把它绑定到 Task 上。所以 Go 不存在「类」这个封闭单元——一个类型的方法可以分散在包的多个文件里,只要在同一个包内即可。

4.2.2 值接收者 vs 指针接收者

这是 Go 最容易出错的语义之一。核心规则只有一条:值接收者操作副本,指针接收者操作本体。

type Counter struct{ n int }

func (c Counter) IncVal() { c.n++ } // 改副本,外部看不到
func (c *Counter) IncPtr() { c.n++ } // 改本体,外部能看到
func (c Counter) Get() int { return c.n }

func main() {
	c := Counter{}
	c.IncVal()
	fmt.Println(c.Get()) // 0:副本被改,本体没动
	c.IncPtr()
	fmt.Println(c.Get()) // 1:本体被改
}

实测输出:

0
1

所以 Complete() 和 Rename() 都必须用指针接收者——它们的目的就是改变 Task 的状态,用值接收者改的只是一个马上被丢弃的副本,编译器不会报错,但行为完全错误。这是新手最常静默翻车的一类 bug:代码能编译、能运行、就是不生效。

那什么时候用值接收者?当方法只读不写,且类型本身较小(复制开销可忽略)时。比如给 Task 加一个只读的格式化方法:

func (t Task) String() string {
	state := "待办"
	if t.Done {
		state = "已完成"
	}
	return fmt.Sprintf("#%d %s [%s]", t.ID, t.Title, state)
}

String() 不修改任何字段,用值接收者反而更安全:它明确承诺「我不会动你传进来的对象」。

4.2.3 方法集与接口满足

接收者类型还决定了一个类型能实现哪些接口。规则如下:

类型值类型的方法集指针类型的方法集
T 的值接收者方法包含包含
T 的指针接收者方法不包含包含

换句话说:指针接收者的方法只属于指针。这条规则在第 5 章会直接决定 MemStore 能否满足 TaskStore 接口:

type TaskStore interface {
	Create(title string) (Task, error)
}

// Create 用指针接收者,所以只有 *MemStore 满足 TaskStore
func (m *MemStore) Create(title string) (Task, error) { /* ... */ }

var _ TaskStore = (*MemStore)(nil) // 编译期通过
// var _ TaskStore = MemStore{}     // 编译错误:MemStore 不满足

因为 MemStore 内部维护 map 和计数器,Create 必须修改状态,自然要用指针接收者——于是接口变量里存的必然是指针。这是「接收者选择」和「接口设计」之间真实存在的耦合,也是 5.1 要展开的话题。

4.2.4 自动取址与不可寻址的坑

Go 允许用值直接调用指针接收者方法,前提是这个值是可寻址的(addressable)。编译器会自动插入 &:

c := Counter{}
c.IncPtr() // 编译器改写为 (&c).IncPtr(),合法

但并非所有值都可寻址。map 的元素、接口里的值、函数返回值都不可寻址,对它们调用指针接收者方法会编译失败:

m := map[string]Counter{"a": {}}
m["a"].IncPtr()
// 编译错误:cannot call pointer method IncPtr on Counter

正确写法是取出来、改完、再放回去:

v := m["a"]
v.IncPtr()
m["a"] = v
fmt.Println(m["a"].Get()) // 1

实测这段是能跑的,输出 1。这个坑在项目里会以更隐蔽的形式出现:如果 TaskStore 内部把 Task 存进 map[int64]Task,那么 store.tasks[id].Complete() 会直接编译失败——因为 map 元素不可寻址。解决方式是在 store 层用「取出 → 修改 → 存回」,或者干脆存 *Task。第 5 章的 MemStore 会演示这两种取舍。

4.2.5 方法值与方法表达式

除了直接调用,方法还能被「取值」和「表达式化」,这在需要把方法当回调传递时很有用。

方法值:t.Rename 是一个绑定了接收者的函数值。

type Adder struct{ base int }

func (a Adder) Add(x int) int { return a.base + x }

a := Adder{base: 10}
f := a.Add   // 方法值:接收者已绑定
fmt.Println(f(5)) // 15

方法表达式:Adder.Add 是未绑定接收者的函数,接收者变成第一个参数。

g := Adder.Add // 方法表达式:接收者作为普通参数传入
fmt.Println(g(Adder{base: 20}, 5)) // 25

实测两段分别输出 15 和 25。方法值捕获的是接收者的当前副本(值接收者)或指针(指针接收者),理解这一点能避免「回调里改了对象但外面没变」的困惑。

4.2.6 nil 指针接收者是安全的

一个容易被忽视的事实:指针接收者方法可以被 nil 指针调用,只要方法内部不无条件解引用接收者。

func (t *Task) IsZero() bool {
	return t == nil || (t.ID == 0 && t.Title == "")
}

func main() {
	var p *Task
	fmt.Println(p.IsZero()) // true:nil 也安全
}

实测输出 true。这里 t == nil 先被判断,|| 的短路让后面的 t.ID 根本不会在 nil 上求值,所以不会 panic。这个模式在项目里有真实用途:如果某天 TaskStore.Get 返回 *Task(nil 表示不存在),调用方就能先安全地调用 IsZero() 再决定是否解引用。

反过来,如果方法无条件写 return t.ID == 0,那么 p.IsZero() 会在 nil 指针上解引用并 panic。所以「nil 接收者安全」不是自动的,而是方法作者刻意处理的结果。

4.2.7 项目落地:Task 的方法集

综合以上,第 4 章给 Task 定的方法是:

// Complete 把任务标记为已完成。指针接收者:修改本体。
func (t *Task) Complete() { t.Done = true }

// Rename 修改任务标题。指针接收者:修改本体。
func (t *Task) Rename(title string) { t.Title = title }

// String 返回人类可读表示。值接收者:只读,不改状态。
func (t Task) String() string {
	state := "待办"
	if t.Done {
		state = "已完成"
	}
	return fmt.Sprintf("#%d %s [%s]", t.ID, t.Title, state)
}

决策依据可以总结成一张表,作为项目后续新增方法时的判据:

问题选指针接收者选值接收者
方法要修改接收者?是否
类型较大(复制昂贵)?是否
类型含 sync.Mutex 等不可复制字段?是否
需要统一方法集(多数方法已用指针)?是否
纯只读的小类型?否是

最后一行「统一方法集」是工程上的经验法则:同一个类型的方法尽量用同一种接收者。如果 Task 既有指针接收者方法又有值接收者方法,那么 Task 和 *Task 的方法集就不一致,接口满足关系会变得难以预测。项目里 Task 的规则是:改状态用指针,纯读取用小类型值接收者,其余一律指针。

4.2.8 小结与检查清单

  • 改状态的方法一律用指针接收者
  • 只读的小类型可用值接收者,并意识到方法集差异
  • 对 map 元素、接口值调用指针方法前,先确认可寻址
  • 同类型方法尽量统一接收者,避免方法集分裂
  • 记住值接收者拿到的是副本,不是引用
  • 指针接收者方法可被 nil 指针调用,但方法内要显式处理 nil

下一节换一个维度:Task 之间的关系。我们会用嵌入(embedding)让一个 Subtask 复用 Task 的字段与方法,并厘清嵌入与具名嵌套的本质区别。

阅读导航:上一节:4.1 结构体定义与初始化 · 下一节:4.3 组合与嵌入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练