方法集与那个指针接收者的坑
上一章说「有对的方法就满足接口」,这一章要给它加一个要命的限定:看是谁的方法集。同一个类型,值 T 和指针 *T 的方法集不一样 —— 用了指针接收者的方法,只进 *T 的方法集,不进 T 的。于是会出现「明明有这个方法、编译器却说你没实现接口」的诡异报错。这是 Go 新手栽得最狠的一个坑,我们把它挖到底。
先复现这个坑
回到上一章的检查器,这次点第二个例子「指针接收者」。代码是这样的:
type Stringer interface {
String() string
}
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ } // 指针接收者(它要改字段)
func (c *Counter) String() string { ... } // 指针接收者
切到第二个例子,看检查器的结论:指针 *Counter 满足 Stringer,但值 Counter 不满足。这就诡异了 —— Counter 明明有 String() 方法啊,怎么就不满足了?如果你写这行代码,编译器会甩你一句劝退的报错:
cannot use Counter{} (value of type Counter) as Stringer value:
Counter does not implement Stringer
(method String has pointer receiver)
括号里那句 method String has pointer receiver 就是答案的全部。但要真懂它,得先搞清「方法集」是什么。
方法集:T 和 *T 是两套
每个类型都有一个方法集(method set)—— 就是「用这个类型能调的方法的集合」。关键规则,背下来:
值类型 T 的方法集:只包含「值接收者」方法。
指针类型 *T 的方法集:包含「值接收者」+「指针接收者」全部方法。
换句话说,指针 *T 的方法集是值 T 的超集。指针啥都能调;值只能调值接收者方法。所以 Counter 的 String() 是指针接收者,就只进了 *Counter 的方法集,没进 Counter 的 —— 于是值 Counter 的方法集里根本没有 String(),它当然不满足 Stringer。
等等,可你在第 8 章不是说过,counter.Inc() 明明能直接调、Go 会自动取地址吗?没错 —— 直接调用方法时,只要 counter 是个可寻址的变量,Go 帮你自动 (&counter).Inc(),畅通无阻。这就是为什么平时你感觉不到值和指针的区别。
但接口满足不走这条自动通道。它严格按方法集来 —— Counter 的方法集里没有 String(),就是没有,编译器不会为了让它满足接口而临时帮它取地址。为什么这么严格?下一节是关键。
根源:接口里的值取不到地址
为什么直接调用能自动取地址,放进接口就不行了?因为一个字:可寻址性(addressability)。
指针接收者方法要能工作,前提是能拿到接收者的地址(不然怎么通过指针改它)。当你写 var c Counter; c.String(),c 是个有名有姓的变量,它有确定的内存地址,Go 能 &c。可当你把一个值塞进接口时:
var s Stringer = Counter{n: 5} // 假设这行能过(其实不能)
回想第 5 章:Counter{n: 5} 是被复制进接口内部的。接口里存的是这个值的一份拷贝,藏在接口的内部结构里,这份拷贝没有一个你能取的、稳定的地址。既然取不到地址,指针接收者方法 String()(它要 &接收者)就没法被调用。Go 干脆在编译期就不让这种赋值发生 —— 与其运行时出问题,不如现在就拦下。
反过来,如果你放进接口的是指针,地址本身就是现成的:
var s Stringer = &Counter{n: 5} // ✅ 放指针,没问题
// 接口里存的是这个指针(一个地址),String() 要的地址它有
指针进接口,接口里存的就是那个地址,指针接收者方法要的东西一应俱全,畅通。所以结论是:只要一个类型有任何指针接收者方法,你就得用它的指针 *T 去满足接口,不能用值 T。
你可能反过来问:值接收者方法,为什么 *T 也能用(在方法集里)?因为从指针拿到值太容易了 —— 有地址 p,解引用 *p 就是值,复制一份喂给值接收者方法即可。所以「值接收者方法」对指针也开放。
反方向难、正方向易,就造成了这个不对称:值方法 → 值和指针都能用;指针方法 → 只有指针能用。记不住规则时,回到这个直觉:从指针总能拿到值(解引用),从值不一定能拿到地址(可能是个临时复制)。
这个坑什么时候咬你
它几乎总是在「把类型塞进接口」的场合出现,而这些场合非常多:
- 赋值给接口变量:
var w io.Writer = myBuf(myBuf 是值,而 Write 是指针接收者)→ 报错,得写&myBuf。 - 传给吃接口的函数:
json.NewEncoder(myBuf)→ 同上。 - 放进接口切片:
[]Stringer{Counter{}, Counter{}}→ 报错。 - 实现标准库接口:你写了个
func (c *Config) UnmarshalJSON(...)(指针接收者,因为要改自己),然后把值Config{}丢给json.Unmarshal—— 悄悄不生效,因为值不满足接口。
症状五花八门,但根因永远是那一句:你在用值,而它需要的方法是指针接收者的。解药也永远一样:用指针(&x 或一开始就持有 *T)。
这就是第 8 章那条「一个类型的方法要么全值、要么全指针」为什么重要。实践中的默认建议:只要一个类型将来可能要满足接口、或者有任何一个方法要改字段,就把它所有方法都写成指针接收者,并且总是用 *T 来传递它。这样方法集是全的(*T 啥都有),永远不会撞上「值不满足接口」的坑。
什么时候可以放心用值接收者?类型很小、纯只读、没有任何指针接收者方法(比如第 8 章的 Celsius、一个 time.Duration 那样的值对象)。除此之外,拿不准就全指针,最省心。
Kotlin 对照
这个坑,在 Kotlin / Java 里压根不存在 —— 因为那边万物皆引用,你手里的对象变量本来就是个「地址」,没有「值 vs 指针」这个分裂,也就没有「方法集不同」这回事。一个对象实现了接口就是实现了,跟你怎么持有它无关。
所以这个坑是 Go 值语义(卷 II 的主题)和结构化接口(上一章)两个特性叠加的产物:正因为值和指针是两种不同的东西(值语义),又正因为接口满足严格按方法集算(结构化),才逼出了「值 T 和指针 *T 满足的接口不一样」这个 Kotlin 人从没见过的现象。
迁移心法:当你看到 does not implement ... (method has pointer receiver) 这句报错,别慌 —— 它几乎总是同一个意思:你手上是个值,加个 & 变成指针就好。把这句报错和「加 &」建立成条件反射,这个卷 III 最大的坑就被你驯服了。
这一章的一句话
值 T 的方法集只含值接收者方法,指针 *T 的方法集含值+指针接收者方法;因为接口里存的是复制、取不到稳定地址,指针接收者方法就用不了,于是「有指针接收者方法的类型,必须用 *T 才满足接口」——遇到那句劝退报错,加个 & 就好;拿不准时,一个类型的方法全用指针接收者最省心。
接口讲到这里,你已经懂了「怎么满足」和「谁能满足」。下一章换个方向:那个能装下一切的接口 —— 空接口 any,以及怎么把它安全地摊开成具体类型。