Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第11章 方法

引言:方法让 Go 用“接收者 + 函数”的形式为类型绑定行为,本章剖析值/指针接收者的语义差异、方法集规则与嵌入字段带来的方法提升,揭示 Go 组合优于继承的设计哲学。

本章语言语义基线为 Go 1.26,类型元数据快照基于 Go 1.26.4;方法集与提升规则是语言契约,abi.Type 等布局是 gc 实现细节。

Receiver

(1) 是什么

Go 中方法是带“接收者”参数的函数。语法 func (r T) Method()(r T) 称为 receiver,相当于把 r 作为函数的第一个隐式参数。

(2) 为什么这样设计 / 底层实现

  • 在语言层可以用方法表达式观察显式 receiver:T.Method 的函数类型以 T 作为第一个参数。编译器仍可能生成 wrapper、内联或去虚拟化,不能把一种 lowering 当作 ABI。
  • 当前 gc 实现把方法描述与类型元数据关联;构造接口、反射和动态分发会使用相关元数据。下面是 Go 1.26.4 internal/abi 的删减快照:
type Type struct {
    Size_    uintptr
    PtrBytes uintptr
    Hash     uint32
    TFlag    TFlag
    Align_   uint8
    Kind_    Kind
    Equal    func(unsafe.Pointer, unsafe.Pointer) bool
    GCData   *byte
    Str      NameOff
    // ...
}

type UncommonType struct {
    PkgPath NameOff
    Mcount  uint16
    Xcount  uint16
    Moff    uint32
}

type Method struct {
    Name NameOff
    Mtyp TypeOff
    Ifn  TextOff
    Tfn  TextOff
}

逐字段解释:

  • Size_ / PtrBytes / GCData:描述大小与指针布局,供分配、GC 等使用。
  • Hash:类型哈希,可避免在类型哈希表路径重复计算;它不是某个值的内容哈希。
  • Mcount / Xcount / Moff:方法总数、导出方法数及方法数组偏移。字段名和宽度都属于内部版本实现。
  • Name / Mtyp:方法名与不含 receiver 的方法类型;Ifn / Tfn 是当前实现的接口与普通调用入口偏移。

(3) 工程实践与常见坑

  • 接收者变量名约定为类型首字母小写(如 c Clients Server),全代码风格统一。
  • 不要把接收者当 this / self,避免带 OOP 思维包袱。
  • 接收者类型必须在本包定义,无法为 int、[]byte 等内置类型或其它包的类型添加方法——这是为了保证方法表的封闭性,也避免全局命名空间污染。
package main

// type MyInt int
// func (m MyInt) Double() MyInt { return m * 2 } // OK:MyInt 在本包定义
// func (i int) Double() int { return i * 2 }     // 编译错误:无法为其它包的类型添加方法

值接收者

(1) 是什么

func (r T) Method() 中 receiver 具有独立的 T 值语义。直接替换 r 或它的普通值字段不影响调用方;若 T 含 pointer、map、slice 等字段,通过副本中的引用修改共享对象仍可能被调用方观察。

(2) 为什么这样设计 / 底层实现

  • 值接收者等价于 func T.Method(r T, ...),调用时把 receiver 按值传递(语义同函数参数)。
  • 对于 t.Method(),编译器直接以 t 为参数;对于 (&t).Method() 调用值接收者方法,编译器自动解引用 *(&t) = t
  • 方法集规则:类型 T 的方法集包含所有值接收者方法;*T 的方法集包含 T*T 接收者的所有方法。

(3) 工程实践与常见坑

  • 小型、语义上像值且方法不需要改变其身份的类型(如 time.Time)常适合值接收者。是否逃逸仍由调用点和编译器决定。
  • 误用值接收者导致状态修改丢失:
package main

import "fmt"

type Counter struct{ n int }

func (c Counter) Inc() { c.n++ } // 值接收者,无效!

func main() {
    var c Counter
    c.Inc()
    c.Inc()
    fmt.Println(c.n) // 0
}

应改为指针接收者:

package main

import "fmt"

type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ } // 指针接收者,修改生效

func main() {
    var c Counter
    c.Inc()
    c.Inc()
    fmt.Println(c.n) // 2
}
  • 值接收者可被 T*T 同时调用,这是 Go 的语法糖;但接口实现仍受方法集约束(见“方法集”小节)。
  • 大 struct 的值接收者具有整值复制语义,但实际成本可能被内联或拷贝消除改变。先根据可变性与身份选择 receiver,热点再测量。

指针接收者

(1) 是什么

func (r *T) Method()r 是指向 T 的指针。方法内可修改 T 的字段,调用方可见。

(2) 为什么这样设计 / 底层实现

  • 指针接收者传递 *T 指针值,不具有复制整个 T 的语义;当前 64 位实现的指针通常为 8 字节,32 位通常为 4 字节。
  • 对于 t.Method() 调用指针接收者方法,若 t 可寻址(如变量、slice 元素),编译器自动取址 &t;若 t 不可寻址(如 map 值、字面量),编译报错。
  • 指针接收者方法集属于 *TT 的方法集不包含它。

(3) 工程实践与常见坑

  • 同一基础类型通常保持 receiver 风格一致,尤其是可变类型、含 sync.Mutex/原子值或不应复制的类型;混用并非语言错误,但会改变 T*T 的接口实现集合。
  • 大小只是选择因素之一。需要共享身份、修改 receiver 或禁止复制时用指针;小型不可变值常用值 receiver;不确定的性能差异用 benchmark 验证。
  • 不可寻址值无法调用指针接收者方法:
package main

type Foo struct{ x int }

func (f *Foo) Set(v int) { f.x = v }

func main() {
    m := map[string]Foo{"a": {}}
    // m["a"].Set(1) // 编译错误:cannot call pointer method on m["a"]
    _ = m
}

因为 map 值不可寻址(map 内部可能 rehash 导致地址变化)。解决办法是取出来改完再放回:

package main

type Foo struct{ x int }

func (f *Foo) Set(v int) { f.x = v }

func main() {
    m := map[string]Foo{"a": {}}
    f := m["a"]
    f.Set(1)
    m["a"] = f
    _ = m
}
  • nil 指针接收者是合法的,方法内需处理:
package main

import "fmt"

type List struct {
    next *List
    val  int
}

func (l *List) Len() int {
    if l == nil {
        return 0
    }
    return 1 + l.next.Len()
}

func main() {
    var l *List
    fmt.Println(l.Len()) // 0,不 panic
}

提示:对 nil 接口调用方法会 panic;对 nil 指针接收者调用方法不会(前提是方法内不解引用字段)。这是 Go 方法集设计中一个常被忽略的细节。

方法集

(1) 是什么

方法集 (method set) 是与类型关联的全部方法的集合。T*T 有不同的方法集,方法集决定一个类型是否实现了某接口。

(2) 为什么这样设计 / 规则

Go 规范定义:

类型方法集
T所有值接收者方法
*T所有值接收者方法 + 所有指针接收者方法

由此衍生接口实现规则:

  • T 实现接口 I,则 T*T 都实现 I(因为 *T 的方法集 ⊇ T 的方法集)。
  • 若只有 *T 实现接口 I,则 T 不实现 I

举例:

package main

import "fmt"

type Animal interface {
    Sound() string
}

type Dog struct{}

func (d Dog) Sound() string { return "woof" } // 值接收者

type Cat struct{}

func (c *Cat) Sound() string { return "meow" } // 指针接收者

func speak(a Animal) { fmt.Println(a.Sound()) }

func main() {
    speak(Dog{})  // OK:Dog 实现 Animal
    speak(&Dog{}) // OK:*Dog 也实现 Animal
    speak(&Cat{}) // OK:*Cat 实现 Animal
    // speak(Cat{}) // 编译错误:Cat 未实现 Animal(缺少 Sound 方法)
}

(3) 工程实践与常见坑

  • 设计 API 时若返回 T,调用方无法直接当 I 用(如果 I*T 实现)。
  • T 类型的值放入 map/slice of interface 时,若方法集不匹配会编译错误,初学者易困惑。
  • 一个常见的反例:把 T 类型的值赋给接口(接口由 *T 实现):
package main

type Saver interface{ Save() }

type File struct{}

func (f *File) Save() {}

func save(s Saver) {}

func main() {
    var f File
    // save(f)  // 编译错误:File does not implement Saver (method Save has pointer receiver)
    save(&f) // OK
}

提示:若接口所需方法只存在于 *T 的方法集中,赋给该接口的动态值必须是 *T(它可以来自 &t、构造函数或其他指针表达式),不能直接使用 T 值。API 返回 T 还是 *T 应与这一契约一致。

嵌入字段

(1) 是什么

Go struct 允许“匿名字段”(embedded field,又称嵌入字段):字段只有类型,无字段名。

type Inner struct{ X int }
type Outer struct{ Inner } // Inner 是嵌入字段

(2) 为什么这样设计 / 底层实现

  • 嵌入字段是 Go 实现“组合优于继承”的核心机制——不是继承,而是把一个类型作为字段嵌入,外层 struct 自动获得其字段访问权与方法。
  • 字段名:嵌入字段的字段名就是其类型名(去包名)。o.Inner.X 可简写为 o.X
  • 内存布局:嵌入字段就是一个普通字段,按声明顺序占用 struct 的内存。Outer 的内存布局等价于:
type Outer struct {
    Inner Inner
}

只是访问语法不同(编译器允许省略中间字段名)。

  • 内嵌接口也是合法的(如 type MyReader struct{ io.Reader }),此时 struct 持有一个接口字段,可方便地“装饰”某个接口实现。

(3) 工程实践与常见坑

  • 嵌入 mutex 是常见模式:
package main

import "sync"

type Cache struct {
    sync.Mutex
    data map[string]string
}

func (c *Cache) Get(k string) string {
    c.Lock()
    defer c.Unlock()
    return c.data[k]
}

func (c *Cache) Put(k, v string) {
    c.Lock()
    defer c.Unlock()
    c.data[k] = v
}

func main() {
    c := &Cache{data: make(map[string]string)}
    c.Put("a", "1")
    _ = c.Get("a")
}

提示:嵌入 sync.Mutex 是值嵌入,每个 Cache 实例有独立锁;若改为 *sync.Mutex 指针嵌入则多个实例共享同一把锁,需谨慎初始化。复制一个已嵌入 sync.Mutex 的 struct 会触发 go vet 警告(copylocks)。

  • 嵌入字段名与同层其他字段重名是编译错误:嵌入字段的字段名就是其类型名,若外层再显式声明同名字段(如同时有嵌入 Inner 和字段 Inner Inner),编译报 Inner redeclared
  • 嵌入类型内部的字段/方法与外层字段/方法同名则是合法的遮蔽:浅者优先,选择器 o.X 取外层,内层仍可通过全路径 o.Inner.X 访问(实测:外层 X intInner.X 共存编译通过)。
  • 嵌入 interface(如 io.Reader)便于 mock:测试时可替换为 fake 实现。
  • 嵌入字段类型可以是 T*T,两者方法集不同(见“方法提升”小节)。

方法提升

(1) 是什么

嵌入字段的方法会自动“提升 (promoted)”到外层 struct——外层 struct 看起来拥有这些方法,无需显式转发。

(2) 为什么这样设计 / 提升规则

  • 提升让组合具备接近继承的表达力,但本质是“编译期字段访问 + 方法转发的语法糖”。
  • 提升规则(Go 规范):
    • 嵌入字段 Inner 的方法 M 被提升到 Outer:调用 o.M() 等价于 o.Inner.M()
    • Inner 的方法以值接收者定义,则 Outer*Outer 都获得该方法。
    • Inner 的方法以指针接收者定义,则只有 *Outer 获得该方法(除非 Outer 嵌入的是 *Inner)。
    • 若同名方法在多个嵌入字段中存在,外层必须显式调用 o.InnerA.M(),否则编译错误(歧义)。

举例:

package main

import "fmt"

type Base struct{ name string }

func (b Base) Name() string       { return b.name }      // 值接收者
func (b *Base) SetName(n string)  { b.name = n }         // 指针接收者

type Derived struct{ Base } // 嵌入值

func main() {
    d := Derived{}
    d.SetName("hi")       // *Derived 获得指针接收者方法(编译器自动取址)
    fmt.Println(d.Name()) // *Derived 也获得值接收者方法
}

若改为嵌入指针:

package main

import "fmt"

type Base struct{ name string }

func (b Base) Name() string      { return b.name }
func (b *Base) SetName(n string) { b.name = n }

type Derived struct{ *Base } // 嵌入指针

func main() {
    d := Derived{Base: &Base{}}
    d.SetName("hi")
    fmt.Println(d.Name())
}

注意:嵌入指针时,外层 struct 必须显式初始化内层指针,否则 d.Name() 会因 nil 解引用 panic。

(3) 工程实践与常见坑

  • 歧义方法:嵌入两个含同名方法的类型,外层不显式指定则编译错误。
package main

type A struct{}

func (A) M() {}

type B struct{}

func (B) M() {}

type C struct {
    A
    B
}

func main() {
    var c C
    // c.M()    // 编译错误:ambiguous selector c.M
    c.A.M()     // OK
    c.B.M()     // OK
}
  • 字段遮蔽:外层定义同名字段会遮蔽内层,访问时取外层;但内层仍可通过全路径访问 o.Inner.X
  • 嵌入字段方法集对接口实现的影响:嵌入 Inner(值)且 Inner 实现接口 I,则 Outer/*Outer 都实现 I;嵌入 *Inner 时,按规范 Outer*Outer 的方法集总是同时包含 *Inner 的全部提升方法(规范:嵌入 *S 时,T*T 的方法集都包含接收者为 S*S 的提升方法),因此二者都实现 I
  • 嵌入字段无法实现“重写 (override)”——外层定义同名方法只是新增一个方法,调用嵌入字段方法时仍走嵌入字段自身的行为,不会动态分派到外层:
package main

import "fmt"

type Inner struct{}

func (i Inner) Hello() string { return "inner: " + i.who() }
func (i Inner) who() string   { return "I am Inner" }

type Outer struct{ Inner }

func (o Outer) who() string { return "I am Outer" }

func main() {
    o := Outer{}
    fmt.Println(o.Hello()) // "inner: I am Inner",不是 "inner: I am Outer"
}

注意:Go 没有继承,也就没有真正的多态重写;想要“运行时多态”必须用接口。o.Hello() 被提升为 Inner.Hello,其 receiver 是 o.Inner(一个 Inner 值),内部调用 i.who() 走的是 Inner.who,与 Outer.who 无关。

方法值与方法表达式

(1) 是什么

方法可以脱离调用点变成普通函数值,有两种形式:

  • 方法值(method value)f := t.Method,receiver 已绑定,f() 直接调用。
  • 方法表达式(method expression)g := T.Method(*T).Method,receiver 变成第一个显式参数,g(t) 调用。

(2) 语义与坑

方法值在绑定时求值并保存 receiver。对值接收者方法,保存的是 receiver 的副本,之后修改原值不影响已绑定的方法值:

package main

import "fmt"

type T struct{ n int }

func (t T) Get() int   { return t.n }
func (t *T) Set(n int) { t.n = n }

func main() {
    t := T{n: 1}
    f := t.Get // 绑定时拷贝 t
    t.n = 2
    fmt.Println(f()) // 1,不是 2

    g := T.Get        // 方法表达式:func(T) int
    fmt.Println(g(t)) // 2,调用时才传 receiver

    h := (*T).Set // func(*T, int)
    h(&t, 3)
    fmt.Println(t.n) // 3
}

(以上输出在 go1.26.4 实测为 1 2 3。)指针接收者的方法值 p.Set 绑定的是指针,之后通过它的修改对外可见。

(3) 工程实践

  • http.HandleFunc("/x", s.handleX) 这类注册回调是方法值最常见的用法;注意绑定的 receiver 生命周期会随函数值延长,与闭包捕获同理。
  • 方法表达式适合把同一方法应用于一批 receiver(如 slices.SortFunc 的比较器),或在表驱动测试中参数化 receiver。
  • 值接收者方法值的“绑定时拷贝”与 defer 参数求值时机是同一类语义:defer t.Get() 也在 defer 语句处拷贝 receiver。

泛型类型的方法

泛型类型的方法在 receiver 中携带类型参数,参数名可与定义处不同,但个数与约束由类型定义决定:

type Stack[T any] struct{ items []T }

func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    v := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return v, true
}

语言限制:方法不能声明 receiver 类型参数之外的新类型参数func (s *Stack[T]) Map[U any](f func(T) U) *Stack[U] 是编译错误;需要额外类型参数时改用包级泛型函数 func MapStack[T, U any](s *Stack[T], f func(T) U) *Stack[U]。这一限制与接口匹配、实例化模型有关,详见第8章 现代类型系统与泛型

泛型类型实例化后(如 Stack[int]),其方法集规则与普通类型完全一致,同样区分 T*T

本章小结

  • 方法是带接收者的函数,本质是 T.Method(r, ...) 的语法糖。
  • 值接收者拷贝 receiver,指针接收者共享 receiver;选择哪种要看类型大小、是否修改、是否一致。
  • 方法集规则:T 只含值接收者方法,*T 含全部;这决定了接口实现的边界。
  • 嵌入字段 + 方法提升是 Go 组合优于继承的体现,但无动态分派,无重写。
  • 嵌入 mutex、嵌入接口是工程上高频用法,需注意值/指针嵌入的语义差异与方法集影响。
  • 方法值在绑定时拷贝 receiver,方法表达式把 receiver 显式化为首个参数;泛型类型的方法共享 receiver 的类型参数,不能新增自己的类型参数。