卷 III · 接口CH 10深度 10/24

方法集与那个指针接收者的坑

本章卸下「有这个方法就一定能当接口用」

上一章说「有对的方法就满足接口」,这一章要给它加一个要命的限定:看是谁的方法集。同一个类型,值 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超集。指针啥都能调;值只能调值接收者方法。所以 CounterString() 是指针接收者,就只进了 *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 根本不存在的问题

这个坑,在 Kotlin / Java 里压根不存在 —— 因为那边万物皆引用,你手里的对象变量本来就是个「地址」,没有「值 vs 指针」这个分裂,也就没有「方法集不同」这回事。一个对象实现了接口就是实现了,跟你怎么持有它无关。

所以这个坑是 Go 值语义(卷 II 的主题)和结构化接口(上一章)两个特性叠加的产物:正因为值和指针是两种不同的东西(值语义),又正因为接口满足严格按方法集算(结构化),才逼出了「值 T 和指针 *T 满足的接口不一样」这个 Kotlin 人从没见过的现象。

迁移心法:当你看到 does not implement ... (method has pointer receiver) 这句报错,别慌 —— 它几乎总是同一个意思:你手上是个值,加个 & 变成指针就好。把这句报错和「加 &」建立成条件反射,这个卷 III 最大的坑就被你驯服了。

这一章的一句话

值 T 的方法集只含值接收者方法,指针 *T 的方法集含值+指针接收者方法;因为接口里存的是复制、取不到稳定地址,指针接收者方法就用不了,于是「有指针接收者方法的类型,必须用 *T 才满足接口」——遇到那句劝退报错,加个 & 就好;拿不准时,一个类型的方法全用指针接收者最省心。

接口讲到这里,你已经懂了「怎么满足」和「谁能满足」。下一章换个方向:那个能装下一切的接口 —— 空接口 any,以及怎么把它安全地摊开成具体类型。