方法看起来只是“把函数名写到变量后面”:counter.Increment()。真正决定代码能否编译、状态会不会被修改、类型能否满足接口的,却是点号前面的接收者。它是值还是指针?能不能取地址?方法属于 T 还是 *T 的方法集?嵌入字段的方法为什么有时会被提升,有时又会因为歧义而消失?
这一页从这些边界出发。我们先把方法声明与普通函数放在一起比较,再拆清值接收者、指针接收者和编译器的隐式转换;随后用方法集解释接口与嵌入,用方法值和方法表达式把方法当成函数传递;最后实现一个位集合,并用包级封装把内部表示保护起来。
先记住一句总纲:方法仍然是函数,接收者仍然是参数。点号语法给调用带来便利,但不会把 Go 变成基于类和继承的语言。

普通函数把所有参数都写在函数名后面;方法把一个参数挪到函数名前面,这个额外参数叫接收者。下面两个 Distance 做同一件事,但名称所在的命名空间不同:一个是包级函数,另一个是 Point 的方法。
package main
import (
"fmt"
"math"
)
type Point struct {
X, Y float64
}
func Distance(p, q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
func main() {
p := Point{X: 1, Y: 2}
q := Point{X: 4, Y: 6}
fmt.Println(Distance(p, q)) // 5
fmt.Println(p.Distance(q)) // 5
}p.Distance 是选择器:编译器根据 p 的静态类型和方法名确定目标。结构体字段也使用选择器,例如 p.X。字段和方法共享选择器名称空间,所以 Point 不能同时拥有字段 Distance 和方法 Distance。
接收者名没有 this 或 self 的特殊地位。Go 代码通常用类型名的一两个字母,例如 p Point、c *Counter,并在同一类型的所有方法中保持一致。接收者名会频繁出现,短而稳定比冗长更容易读。
接收者的基础类型必须是在当前包定义的类型,而且不能是指针类型或接口类型。它可以以结构体、切片、整数、字符串、映射或函数为底层类型。下面给命名切片 Path 增加总长度方法:
package main
import (
"fmt"
"math"
)
type Point struct{ X, Y float64 }
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
type Path
Point.Distance 与 Path.Distance 没有重载关系,它们只是不同类型上的同名方法。方法不能直接加到其他包定义的类型上,例如当前包不能为 time.Time 新增方法。需要扩展外部类型时,可以定义一个新的本地类型,或把外部类型作为字段组合进自己的结构体。
动手题:为命名类型 type Celsius float64 写一个 Fahrenheit() float64 方法,并打印 20°C 对应的华氏温度。
调用值接收者方法时,接收者值会像普通参数一样被复制。下面的 Moved 返回新坐标,不修改原值;这种“输入一个值,得到另一个值”的语义很适合小型、天然不可变的值类型。
package main
import "fmt"
type Point struct{ X, Y int }
func (p Point) Moved(dx, dy int) Point {
p.X += dx
p.Y += dy
return p
}
func main() {
original :=
不过,“值接收者”不等于“绝对只读”。如果结构体里含有切片、map 或指针,复制的结构体仍可能指向同一份底层数据。值接收者可以借这条引用修改共享对象。接收者类型的选择应该表达整体语义,不能只看方法体里有没有赋值语句。
方法需要修改接收者本身时,应使用 *T 接收者。指针接收者还适合包含 sync.Mutex 等不可复制状态的类型、大型结构体,以及希望所有方法都围绕同一对象身份工作的类型。
package main
import "fmt"
type Counter struct {
name string
n int
}
func (c *Counter) Increment() { c.n++ }
func (c *Counter) Reset() { c.n = 0 }
func (c *Counter
这里 counter 是可取地址变量,所以编译器允许用 counter.Increment() 代替 (&counter).Increment()。反方向也成立:若 p 是 *T,调用 T 的值接收者方法时,编译器可以隐式解引用。
工程上可以用这组顺序判断接收者:
T 与 *T 的方法集之间来回摇摆。
编译器的便利有前提:要调用 *T 方法,点号左边必须已经是 *T,或者是能取地址的 T。变量、结构体字段、数组元素和切片元素通常可取地址;map 元素、接口中保存的具体值、函数返回的临时值以及多数复合字面量临时值不能取地址。
package main
type Item struct{ N int }
func (i *Item) AddOne() { i.N++ }
func makeItem() Item { return Item{N: 1} }
func main() {
item := Item{N: 1}
item.AddOne() // 可以:item 是可取地址变量
map 需要允许扩容和搬迁元素,因此语言不让程序取得 map 元素地址。若业务经常修改 map 中对象,可以改存 map[string]*Item,也可以采用“取出—修改—写回”的值语义。
改错题:下面代码为什么不能编译?给出两种修改方案。
scores := map[string]Counter{"Go": {}}
scores["Go"].Increment()方法集回答的是“这个类型正式拥有哪些方法”。它直接决定类型能否赋给某个接口,也决定哪些方法表达式合法。
对非接口命名类型 T,核心规则是:
最容易混淆的地方是:一个可取地址的 T 变量可以调用 *T 方法,但这不代表该方法进入了 T 的方法集。调用语法额外允许编译器把 value.M() 改写为 (&value).M();接口赋值不会做这种隐式取址。
package main
import "fmt"
type Counter struct{ n int }
func (c Counter) N() int { return c.n }
func (c *Counter) Increment() { c.n++ }
type Incrementer interface {
Increment()
}

预测题:为什么 var x Incrementer = c 不能靠编译器自动变成 &c?
nil 指针可以成为指针接收者方法的实参。调用时并不会在进入方法前自动解引用;方法体可以先检查接收者,并为 nil 定义含义。链表中把 nil 解释为空表就是一个自然设计:
package main
import "fmt"
type IntList struct {
Value int
Next *IntList
}
// Sum 把 nil *IntList 视为空表。
func (list *IntList) Sum() int {
if list == nil {
return 0
}
return list.Value + list.Next.
这里有三条边界:
nil *T 上调用值接收者方法需要先隐式解引用,会在进入方法体前 panic。list.Value 之前没有检查 list == nil,仍会 panic。nil 没有可供选择器解析的静态类型,不能写 nil.Sum();必须先得到 (*IntList)(nil) 或一个已声明的 *IntList 变量。对 map 这类本身可为 nil 的值也要区分读写:读取 nil map 安全,写入 nil map 会 panic。若方法把 nil map 解释为空集合,文档应明确哪些操作可用,哪些操作要求先初始化。
设计题:为二叉树节点定义 Height()。约定 nil 树高度为 0,单节点树高度为 1。
结构体字段只写类型、不写显式字段名时,这个字段称为嵌入字段。字段仍然真实存在,默认字段名是类型名;提升只是让外层值可以用较短的选择器访问内层字段或方法。
package main
import (
"fmt"
"math"
)
type Point struct{ X, Y float64 }
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
func (
ColoredPoint 并不是 Point 的子类型。要求 Point 参数时,必须传 p.Point,不能直接传 p。调用提升方法时,真正的接收者仍是嵌入字段 p.Point,它不会获得对外层 ColoredPoint 的隐式引用。
外层类型的方法集取决于嵌入的是值 T 还是指针 *T:
嵌入 *T 允许多个外层值共享同一内部对象,但零值中的嵌入指针是 nil。直接调用依赖内部字段的提升方法可能 panic,因此构造函数或调用前检查往往更重要。
选择器按深度解析:先找外层直接声明的字段或方法,再找嵌入一层的成员,然后继续向内。最浅层若只有一个匹配就选它;同一最浅深度出现两个同名成员,选择器就是歧义,必须写出完整路径。
package main
import "fmt"
type FileLogger struct{}
func (FileLogger) Log() { fmt.Println("写入文件") }
type AuditLogger struct{}
func (AuditLogger) Log() { fmt.Println("写入审计流") }
type Service struct {
FileLogger
AuditLogger
若 Service 自己声明 Log,它位于深度 0,会遮蔽两个提升的 Log;内层方法依然可以通过完整路径调用。这不是虚方法覆盖,也没有动态派发,只是选择器按静态规则找到最近的成员。

改写题:缓存同时需要一个互斥锁和一张 map。怎样用嵌入让 cache.Lock() 可用,同时不把它描述成继承?
通常我们在同一行选择并调用方法。只写 p.Distance 而不加调用括号,会得到一个方法值:它已经保存接收者,以后调用时只需要提供剩余参数。
package main
import "fmt"
type Counter struct{ n int }
func (c Counter) Value() int { return c.n }
func (c *Counter) Add(delta int) { c.n += delta }
func main() {
c := Counter
方法值在创建时求值并保存接收者。值接收者保存当时的值副本;指针接收者保存指针,之后仍会操作同一个对象。这条差异在回调延迟执行、循环注册处理器和并发代码中尤其重要。
方法表达式从类型出发,写作 T.M 或 (*T).M。它不绑定具体对象,而是生成一个普通函数值,并把接收者放回第一个显式参数。
package main
import "fmt"
type Point struct{ X, Y int }
func (p Point) Add(q Point) Point {
return Point{p.X + q.X, p.Y + q.Y}
}
func (p *Point) Scale(factor int
方法值适合“固定接收者,稍后调用”,例如把 server.Shutdown 注册成回调。方法表达式适合“固定操作,在许多接收者上执行”,例如根据条件在 Point.Add 与 Point.Sub 之间选择,再遍历一批点。
编程题:给 Point 增加 Sub,然后写 Transform(path []Point, offset Point, op func(Point, Point) Point),让调用者传 Point.Add 或 Point.Sub。
当集合元素是较小的非负整数,而且需要频繁做并集、交集时,可以用一串 uint64 表示集合。每个 uint64 是一个“字”,包含 64 个位置;整数 x 落在第 x/64 个字的第 x%64 位。
例如 x = 130:
130 / 64 = 2;130 % 64 = 2;uint64(1) << 2。添加元素使用按位或,删除使用按位清除,查询使用按位与;并集可以一次合并 64 个候选元素。
package main
import (
"fmt"
"math/bits"
"strconv"
"strings"
)
// BitSet 保存非负整数;零值就是空集合。
type BitSet struct {
words []uint64
}
func (s *BitSet) Has(x int) bool {
if
所有方法统一使用指针接收者。Add、Remove、UnionWith 会修改集合;只读方法也保持同一接收者风格,让 API 的方法集一致。代价是 BitSet 值本身没有 String 方法,交给 fmt 时应传 &a。直接写 a.String() 仍能编译,因为局部变量 a 可取地址,但接口匹配和格式化检查按方法集进行。
零值可用是一项很实在的 API 设计:var s BitSet 可以直接 Add,调用者不必先找构造函数。words 在第一次添加元素时按需增长。
UnionWith 对每个字做一次 |,一次运算同时处理 64 个元素位置。集合密集且上界适中时,这种布局比逐个 map 键操作紧凑;集合极稀疏或整数特别大时,空字会浪费空间,map 或其他稀疏结构可能更合适。
BitSet 内部含切片。直接赋值 copy := original 只复制切片头,两者仍共享底层数组。若 API 提供 Clone,必须复制 words 中的元素:
func (s *BitSet) Clone() *BitSet {
if s == nil {
return nil
}
clone := &BitSet{words: make([]uint64, len(s.words))}
copy(clone.words, s.words)
return clone
}扩展题:实现 DifferenceWith(other *BitSet),把接收者改为只保留“在自己中、但不在 other 中”的元素。
Go 用标识符首字母控制可见性:大写开头的名称从包中导出,小写开头的名称只在包内可见。这条规则同时适用于包级名称、结构体字段和方法。没有 private、protected 这样的访问修饰符。
这也说明了封装的单位是包。同一包内的任意函数都可以访问 Account.balance,无论它是不是 Account 的方法;包外代码则不能直接访问这个字段。
package account
import "fmt"
type Account struct {
owner string
balance int64
history []string
}
func New(owner string, initial int64) (*Account, error) {
if owner == "" {
return nil, fmt.Errorf
这里隐藏字段带来三种直接收益:
Deposit 把金额改成无效状态,方法可以维护不变量。History 返回切片副本也很关键。若直接返回 a.history,调用者虽然不能给字段重新赋值,却能通过切片元素修改内部数组,封装就被一条共享引用绕开了。
Go 的 getter 通常不用 Get 前缀:字段概念是 owner,导出读取方法就叫 Owner();对应写方法若确实需要,可以叫 SetOwner()。导出字段并非错误,配置、数据传输对象、坐标点等简单数据常常适合直接暴露。选择时要问:是否存在需要维护的不变量?内部表示以后是否可能变化?一旦字段成为公共 API,再取消导出会破坏兼容性。
API 设计题:下面哪种类型更适合暴露表示,哪种更适合隐藏表示?说明理由。
range 的 Path。[]uint64 实现、未来可能改成稀疏结构的 BitSet。实现一个 Registry:按名称保存计数器,要求零值可用,Get(name) 只返回当前数值,Increment(name) 能原地更新,Snapshot() 返回与内部状态互不影响的快照。然后回答:map 中应该保存 Counter 还是 *Counter?
读方法相关代码时,可以按固定顺序检查:先看接收者是 T 还是 *T,再看点号左边是否可取地址;涉及接口时改看方法集;涉及嵌入时从最浅深度解析选择器;最后检查暴露出去的切片、map 或指针是否绕过了包的封装边界。
Celsius 是当前包定义的命名类型,所以即使它的底层类型只是 float64,也可以拥有自己的方法。
递归调用 n.Left.Height() 即使 Left 为 nil 也能进入方法体,因为 Height 使用指针接收者并先处理 nil。
匿名结构体含有一个 sync.Mutex 字段,Lock 与 Unlock 从该字段提升。这里是把“锁”和“受保护数据”组合在同一个值里,没有父类或子类关系。复制含锁值会破坏同步语义,因此这类结构应始终通过指针使用,并避免复制。
方法表达式的类型与 op 完全一致:接收者 Point 成为第一个参数,原参数 q Point 成为第二个参数。
yxothermap 保存 *Counter,所以索引结果虽然不可取地址,却已经是指针,可以直接调用指针接收者方法。Registry 在第一次写入时初始化 map,保证零值可用。Snapshot 创建新的 map 并只复制数值,调用者修改快照不会影响内部计数器。