Go 方法集与接收者
解释值接收者和指针接收者如何决定方法集、可调用方法与接口满足关系。
[!info] related notes
Go 方法集(Method Set)与接收者
[!abstract] 一句话理解 方法集(Method Set)决定一个类型“拥有哪些方法”;基本接口的实现按方法集判断,不会因为调用时可自动取地址而扩大。
这个概念为什么出现
很多 Go 初学者都会遇到这种情况:
var c Counter
c.Add() // 可以调用
但是:
var adder Adder = c
却报错:
Counter does not implement Adder
明明 c.Add() 能调用,为什么却不能实现接口?
答案就是:
[!important]
调用规则(Call Rule) ≠ 方法集(Method Set)
编译器允许自动取地址调用方法,但不会修改类型的方法集。
这是 Go 中最容易混淆的地方。
Receiver(接收者)
Go 的方法其实只是一个普通函数,多了一个特殊参数:
func (c Counter) Value() {}
等价理解为:
func Value(c Counter)
如果写成:
func (c *Counter) Add() {}
等价理解为:
func Add(c *Counter)
因此:
值接收者:
Receiver 是 T
指针接收者:
Receiver 是 *T
Method Set(方法集)
先看不含嵌入字段、且不是指针或接口的定义类型:
type T struct{}
Go 规定:
[!tip] Method Set 规则
T 的方法集
仅包含:
func (T) ...
即:
T
└── Value Receiver
*T 的方法集
包含:
func (T)
func (*T)
即:
*T
├── Value Receiver
└── Pointer Receiver
可以记住一句口诀:
对上面的
T:*T包含值与指针接收者方法;T仅包含值接收者方法。
结构体有嵌入字段时还要计算提升方法,例如 struct{ *Inner } 的值类型也可能获得 *Inner 的方法,见 go-embedding-and-composition。不要把口诀套到接口指针 *I 上:它不会继承 I 的接口方法集。
为什么 *T 能调用值方法?
假设:
func (c Counter) Value()
调用:
p := &c
p.Value()
实际上编译器会自动解引用:
(*p).Value()
所以:
*T
↓ 自动解引用
T.Value()
因此:
对这里的
*Counter,两类方法都可调用;若指针为 nil,调用值接收者方法会在解引用时 panic。指针接收者方法能否处理 nil,则由方法体决定。
为什么 T 可以调用指针方法?
如果:
func (c *Counter) Add()
而:
var c Counter
c.Add()
编译器会自动改写为:
(&c).Add()
但是有一个条件:
[!warning]
变量必须是可寻址(addressable)的。
例如:
Counter(1).Add()
编译失败。
因为:
Counter(1)
不是 Go 语言定义的可寻址表达式,不能使用调用时的隐式取址。
可寻址性也解释了常见容器差异:[]Counter 的切片元素可取址,因此 s[0].Add() 合法;map[string]Counter 的元素不可取址,m["x"].Add() 不合法。若 map 存的是 *Counter,则可直接调用指针方法;仍须保证指针满足方法的 nil 契约。
调用规则(Call Rule)
真正发生的是:
c.Add()
↓
(&c).Add()
这是:
语法糖(Syntax Sugar)
而不是:
Method Set 发生变化
所以千万不要混为一谈。
Method Set 与接口
这里讨论只规定方法的基本接口;非基本接口只能用于类型约束,还需要满足其类型集。
假设:
type Counter int
func (c Counter) Value() int
func (c *Counter) Add()
接口:
type Valuer interface {
Value() int
}
判断:
Counter
└── Value()
✔ 满足
*Counter
├── Value()
└── Add()
✔ 满足
如果接口变成:
type Adder interface {
Add()
}
则:
Counter
└── (没有 Add)
✘ 不满足
*Counter
├── Value()
└── Add()
✔ 满足
所以:
var a Adder = Counter(1)
错误。
必须:
var c Counter
var a Adder = &c // Counter 的底层类型是 int,不能写 Counter{}
完整示例(业务场景)
场景:订单浏览次数计数器
商品详情页要统计“被打开次数”:
Value():只读当前次数 → 适合值接收者Add():就地 +1 → 必须指针接收者,否则改的是副本
页面代码里 c.Add() 往往能编译(编译器自动取址),
但把 c(值)赋给 Adder 接口会失败——方法集不含指针方法。
这就是“能调用 ≠ 能赋值给接口”的坑。
package main
import "fmt"
// ViewCounter 模拟“商品浏览次数”。
type ViewCounter int
// Value:只读,值接收者即可。
func (c ViewCounter) Value() int {
return int(c)
}
// Add:要改接收者,必须用指针接收者。
func (c *ViewCounter) Add() {
*c++
}
// 只读能力
type Valuer interface {
Value() int
}
// 可递增能力(方法集要求 *ViewCounter)
type Adder interface {
Add()
}
func main() {
var c ViewCounter // 0
// 调用规则:变量 c 可寻址,编译器自动写成 (&c).Add()
c.Add()
fmt.Println("after direct Add:", c.Value()) // 1
// 值 c 的方法集包含 Value → 可赋给 Valuer
var v Valuer = c
fmt.Println("via Valuer:", v.Value()) // 1
// 值 c 的方法集不包含 Add → 不能 var a Adder = c(编译错误)
// 必须用指针,*ViewCounter 方法集才有 Add
var a Adder = &c
a.Add()
fmt.Println("after interface Add:", c) // 2
}
建议运行:
go run .
期望输出:
after direct Add: 1
via Valuer: 1
after interface Add: 2
结合场景再看三句话
c.Add()能调用,靠的是调用规则自动取址,不是值类型拥有Add。- 接口赋值只看方法集:
ViewCounter不满足Adder,*ViewCounter才满足。 - 要改调用方的计数器值 → 指针接收者。值接收者复制接收者;若其中含 map、slice 或指针,副本仍可能共享并修改底层数据,并不保证深层只读。
Receiver 应该如何选择?
[!tip] 推荐原则
使用值接收者
适用于:
- 小对象
- 不修改状态
- 不可变语义
- 类似
time.Time
例如:
time.Time
使用指针接收者
适用于:
- 修改对象
- 对象较大
- 包含锁(sync.Mutex)
- 避免复制
例如:
bytes.Buffer
保持一致
同一个类型的方法:
最好统一全部使用值接收者,
或者全部使用指针接收者。这是设计惯例,不是语言限制:上面的教学计数器刻意混用以对比方法集;真实类型若需要指针接收者修改状态,通常连只读方法也统一用指针接收者。
避免:
func (T) A()
func (*T) B()
导致接口实现变得难以判断。
易错点
[!warning] 1. 能调用 ≠ 有这个方法
c.Add()
只是自动取址。
并不是:
Counter
拥有:
Add()
[!warning] 2. 接口判断只看 Method Set
不会因为:
c.Add()
能够调用,
接口就认为:
Counter
实现了:
Add()
[!warning] 3. 临时值不能自动取址
例如:
Counter(1).Add()
错误。
因为:
Counter(1)
不是 Go 语言定义的可寻址表达式,不能使用调用时的隐式取址。
总结
[!summary]
可以记住四句话:
- 无嵌入的非指针、非接口定义类型 T 只有值接收者方法。
- 上述 T 的 *T 拥有值方法和指针方法;有嵌入时另算提升方法。
- 基本接口实现按方法集判断;泛型约束还要满足类型集。
- 自动取址只是调用语法糖,不会改变 Method Set。
自测题
概念题
T与*T的方法集分别包含什么?- 为什么
c.Add()能调用,但var a Adder = c可能失败? - 接口满足检查看调用语法糖还是方法集?
代码推理题
type Counter int
func (c *Counter) Inc() { *c++ }
type Incable interface{ Inc() }
func main() {
var c Counter
c.Inc() // ?
// var i Incable = c // ?
var j Incable = &c // ?
_ = j
}
哪些行能编译?为什么?
参考答案
展开
- 对本文无嵌入的非指针、非接口定义类型
T:T仅值接收者方法;*T含值接收者与指针接收者方法。\ - 调用可自动取址;赋值给接口只看方法集,
T不含指针方法。 - 只看方法集。
代码:c.Inc()可(取址调用);var i Incable = c不可;&c可以。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| Spec — Method sets | 规范 | 方法集定义 |
| Code Review Comments — Receiver Type | Wiki | 接收者选择惯例 |
| Effective Go — Methods | 官方 | 方法风格 |
| go-structs-and-methods · go-interfaces · go-interface-values-and-nil | 本库 | 相邻主题 |