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

第9章 方法

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

Receiver

(1) 是什么

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

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

  • 方法本质是普通函数的语法糖。func (r T) Method() 在编译后等价于 func T.Method(r T, ...)
  • 方法信息记录在类型的元数据中:有方法的类型会关联一张方法表,接口转换和动态分发通过方法表查询实现。
  • 简化伪代码(runtime.type 与方法表,简化):
type _type struct {
    size       uintptr       // 类型大小
    ptrdata    uintptr       // 包含指针的前缀字节
    hash       uint32        // 类型 hash,用于接口查表
    equal      func(unsafe.Pointer, unsafe.Pointer) bool // 相等判定
    name       string        // 类型名
    // ... 其它字段
}

type uncommontype struct {
    pkgpath name            // 包路径
    mcount  uint16          // 方法数
    moff    uint16          // 方法表偏移
}

type method struct {
    name nameOff            // 方法名偏移
    mtyp typeOff            // 方法类型偏移
    ifn  textOff            // 接口调用入口(itab 用)
    tfn  textOff            // 直接调用入口
}

逐字段解释:

  • _type.size / ptrdata:GC 与内存分配需要知道类型大小和指针分布。
  • _type.hash:接口类型断言时通过 hash 加速 itab 查找。
  • uncommontype.mcount / moff:定义该类型有多少方法以及方法表在类型元数据中的偏移;只有有方法的类型才有 uncommontype
  • method.name / mtyp:方法名与方法签名。
  • method.ifn:通过接口调用时的入口地址(动态分发)。
  • method.tfn:直接 t.Method() 调用时的入口地址(静态分发)。

(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()rT 类型的值拷贝。方法内对 r 字段的修改不影响调用方。

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

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

(3) 工程实践与常见坑

  • 小且不需修改的值类型(time.Time、geometry.Point)适合用值接收者,避免逃逸。
  • 误用值接收者导致状态修改丢失:
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 用值接收者会带来拷贝开销,且可能让 c.Inc() 看起来像修改实则无效,是常见 bug 源头。

指针接收者

(1) 是什么

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

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

  • 指针接收者等价于 func (*T).Method(r *T, ...),receiver 是一个指针值(8 字节),不会拷贝整个 T
  • 对于 t.Method() 调用指针接收者方法,若 t 可寻址(如变量、slice 元素),编译器自动取址 &t;若 t 不可寻址(如 map 值、字面量),编译报错。
  • 指针接收者方法集属于 *TT 的方法集不包含它。

(3) 工程实践与常见坑

  • 经验法则:类型只要有任一指针接收者方法,所有方法都应使用指针接收者(保持一致性,避免方法集混乱)。
  • 大 struct 必用指针接收者,避免每次方法调用拷贝整个 struct。
  • 不可寻址值无法调用指针接收者方法:
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 接收者,那么凡是把它当 interface 用的地方都必须传 &t。这是接口设计时常见的“API 坑”,建议在团队规范中统一约定。

嵌入字段

(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)。

  • 嵌入字段不可与外层字段同名(外层会遮蔽内层)。
  • 嵌入 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 时,方法集取决于 *Inner 的方法集,Outer*Outer 通常都能实现 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 无关。

本章小结

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