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

方法:函数长在类型上

本章卸下「方法必须写在 class 里」

接口是这一卷的主角,但要讲透接口,得先把「方法」这块地基铺平。Go 的方法和你熟的很不一样:它写在类型外面,本质只是「第一个参数很特别的函数」;而且你能给几乎任何自定义类型加方法,连给 int 起的别名都行,不限于 struct。这一章把方法讲清楚,顺便埋下值接收者 vs 指针接收者这个后面要引爆的伏笔。

方法接收者命名类型值/指针接收者

方法就是带接收者的函数

回顾第 6 章那个形状:方法写在 struct 外面,用一个接收者挂上去。

type Rect struct {
    W, H float64
}

// (r Rect) 是接收者。这就是 Rect 的一个方法
func (r Rect) Area() float64 {
    return r.W * r.H
}

现在把它和一个普通函数放一起看,你会发现它们几乎是一回事:

// 方法写法
func (r Rect) Area() float64 { return r.W * r.H }

// 等价的函数写法
func Area(r Rect) float64 { return r.W * r.H }

方法只是把第一个参数挪到了函数名前面的括号里。就这么点区别。r.Area()Area(r) 做的是同一件事 —— 后者甚至真的能写(叫方法表达式)。所以 Go 里方法没有什么神秘的,它就是一种语法上把函数关联到某个类型的方式,让你能写出 r.Area() 这种读起来自然的点调用。

◆ 为什么这个「小」区别是大事

「方法只是第一个参数特别的函数」听着像抠字眼,但它是 Go 整套设计的地基。因为方法没被关进类型定义里,Go 才能做到:给任何类型(不止 struct)加方法、把数据和行为解绑、以及最重要的 —— 让「一个类型有哪些方法」这件事,成为一个可以被接口从外部观察和匹配的东西

换句话说,正因为方法是「挂」上去的、而不是「焊死」在类里的,第 9 章那种「不用声明就自动满足接口」才成为可能。记住这个因果,它是理解 Go 接口的钥匙。

你能给几乎任何类型加方法 —— 包括 int

这是让 OOP 背景的人惊讶的一点:方法的接收者不必是 struct。只要是你在自己包里定义的命名类型,都能挂方法,哪怕它底层只是个 float64[]string

// 给 float64 起个名字,就能给它加方法
type Celsius float64

func (c Celsius) ToFahrenheit() Celsius {
    return c*9/5 + 32
}

// 给切片起个名字,也能加方法
type Names []string

func (n Names) Contains(s string) bool {
    for _, x := range n {
        if x == s { return true }
    }
    return false
}

c := Celsius(100)
fmt.Println(c.ToFahrenheit())   // 212

type Celsius float64 定义了一个新类型,它的底层是 float64,但它是一个独立的类型(不能和普通 float64 直接混用,得显式转换)。这么做的好处是:你可以用类型系统区分「一个温度」和「一个随便的浮点数」,还能给「温度」这个概念挂上专属方法。这就是 Go 版的「值对象」/ 领域类型 —— 不用为了加个方法就包一个 struct。

✎ 一个约束:只能给「自己包的类型」加方法

Go 有条规矩:你只能给在当前包里定义的类型加方法。你不能给 int、给标准库的 time.Time、给别人包里的类型直接挂方法。这正是第 1 章账本里「扩展函数换了做法」说的那件事 —— Kotlin 的扩展函数能给任何类(包括 String、List)加方法,Go 不行。

想给外部类型加行为?你得先用 type MyTime time.Time 起个自己的别名,或者把它嵌进自己的 struct(第 12 章)。这个限制是为了避免「谁都能给任何类型偷偷加方法」带来的混乱 —— 在一个大代码库里,「这个类型的方法都定义在它自己包里」是个很值钱的保证。

值接收者 vs 指针接收者

现在到了本章最重要、也是后面要引爆的地方。接收者可以是 (r Rect),也可以是指针 (r *Rect)。回想第 5 章的值语义,你应该已经猜到区别了:

// 值接收者:r 是调用者的一份复制。改 r 改不到原对象
func (r Rect) Scale(f float64) {
    r.W *= f    // 白改!改的是副本
}

// 指针接收者:r 指向原对象。改 r 就是改原对象
func (r *Rect) Scale(f float64) {
    r.W *= f    // 真的改到了
}

规则和普通函数传参一模一样,因为方法本来就是函数:值接收者拿到的是复制,改不到原件;指针接收者拿到的是地址,能改原件。所以判断用哪个,第一条就是:

  • 方法要修改接收者 → 必须用指针接收者 *T,不然改的是副本。
  • 接收者是大结构体,即使不改也常用指针,省掉每次调用的复制开销。
  • 小的、只读的 → 值接收者,简单安全。

有个贴心处理你可能没注意:上面 func (r *Rect) Scale 明明要指针,但你可以直接 rect.Scale(2)(rect 是个值,不是指针),Go 会自动帮你取地址变成 (&rect).Scale(2)。反过来,指针调用值方法也自动解引用。所以日常写起来,你常常感觉不到这个区别 —— 直到第 10 章,它会在接口那里突然不再自动,变成一个让无数人栽跟头的坑。

⚠ 一个类型的方法,接收者要么全值、要么全指针

惯例(也几乎是必须):同一个类型的所有方法,接收者类型要保持一致 —— 要么都用值、要么都用指针,别混着来。一旦你有任何一个方法需要指针接收者(比如它要改字段),通常就把这个类型的所有方法都改成指针接收者,保持一致。为什么这么讲究,第 10 章讲方法集时你会彻底明白 —— 混用会让「这个类型到底满不满足某个接口」变得混乱。先记住这条规矩:选定值或指针,就全类型统一。

方法不是必须的 —— 有行为才加

最后提醒一句,别把 OOP 那套「什么都要包成对象、什么都要有方法」的习惯带过来。Go 里很多 struct 就是纯数据,一个方法都没有(比如一个只用来传 JSON 的 Request 结构体)。方法是用来表达「这个类型能做什么行为」的,没有行为就别硬加。数据归数据,行为归行为 —— 这是第 6 章「数据和行为被掰开了」的延续,也是下一章接口的前提:接口只关心行为,不关心数据。

Kotlin 对照

⇄ 类内方法 vs 挂载方法

Kotlin 的方法住在 class 里,和字段封装在一起,还能用 private / protected 分层、能被子类 override。方法是类的「内部成员」,和类的身份、继承体系深度绑定。你还有扩展函数,能给任何类挂方法 —— 但那本质是静态派发的语法糖。

Go 的方法住在类型外面,靠接收者松散挂载。它能挂到任何命名类型上(不止 struct),但只能挂到本包定义的类型上。没有 private 方法修饰符(还是大小写),没有 override(没有继承)。方法和类型的关系是「关联」而非「从属」。

最需要重新校准的直觉是值/指针接收者 —— Kotlin 里 this 永远是对象引用,改字段天然生效。Go 里你得主动选:要改字段就用指针接收者。忘了加 *、用值接收者去改字段,是从 Kotlin 来的人最常见的「怎么没改成」bug,和上一章传参那个坑是同一个根:值语义

这一章的一句话

Go 的方法只是「第一个参数(接收者)很特别的函数」,写在类型外面、能挂到任何本包命名类型上(不止 struct);值接收者拿到复制、指针接收者拿到原件,要改就用指针,而且一个类型的方法要么全值、要么全指针 —— 这个一致性,是下一章接口的关键前提。

地基铺好了。下一章是这整本书的心脏 —— 接口。Go 的接口和你在 Java / Kotlin 见过的 interface 是两种完全不同的东西,我们会用一台真的检查器把它拆开给你看。