Skip to content

函数与方法

go
type Counter struct{ n int }

func (c Counter) Inc()  { c.n++ }
func (c *Counter) Add() { c.n++ }

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

Inc 的接收者是 Counter,调用时拷整个结构体,c.n++ 改的是副本。Add 的接收者是 *Counter,拷的是指针,改得到原来那份。编译器允许 c.Add() 这种值上调指针方法——它帮你写成 (&c).Add()。反向不行:指针方法的方法集更大,下一节钉。

函数是一等值,方法是带接收者的函数。参数一律拷贝盒子里的位:int 拷整数,切片拷 header,指针拷地址。没有 C++ 的引用参数,没有 Python 那种「对象引用自动共享」。下面按这条线把函数、闭包、方法集、嵌入、defer 求值顺序写完。接口整篇下一篇再讲,这里只在方法集里碰到它的边。


一、函数是值

1、签名就是类型

go
func add(a, b int) int { return a + b }

var f func(int, int) int = add
fmt.Println(f(1, 2)) // 3

g := func(x int) int { return x + 1 } // 匿名函数
fmt.Println(g(3))

函数类型由参数列表和返回值列表构成,名字不算。func(int, int) int 是一种类型,和 func(a, b int) int 相同。两个函数类型要赋给彼此,参数个数、类型、返回值必须逐位相同,名字和声明顺序里的标识符无关。

go
func apply(n int, fn func(int) int) int {
	return fn(n)
}

func main() {
	fmt.Println(apply(3, func(x int) int { return x * x }))
}

C++ 对应函数指针 / std::function;Python 对应可调用对象。Go 没有函数重载,没有默认参数,没有关键字参数。同名函数在同一包里只能有一份。想「可选参数」就加一个结构体,或把零值当默认。

变参是切片:

go
func sum(ns ...int) int {
	s := 0
	for _, n := range ns {
		s += n
	}
	return s
}

fmt.Println(sum(1, 2, 3))
fmt.Println(sum([]int{1, 2, 3}...))

ns 在函数里就是 []int。调用方写 sum(1,2,3) 时,编译器造一个切片传进来。s... 把已有切片打散。变参必须是最后一个参数。传进去的切片和调用方是否共享底层数组:字面量实参通常是新数组;s... 则共享 s 的 backing array,函数里改 ns[i] 能改到调用方。

2、多返回值、命名返回值

go
func div(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("div by zero")
	}
	return a / b, nil
}

func slurp(path string) (data []byte, err error) {
	f, err := os.Open(path)
	if err != nil {
		return // data 是 nil,err 已经赋过
	}
	defer f.Close()
	data, err = io.ReadAll(f)
	return
}

多返回值是语言级的,不是元组。接收时个数必须对齐,丢掉用 _。命名返回值在函数入口当局部变量声明,零值初始化,裸 return 把它们交出去。公开 API 里裸 return 会让读者去对名字;短函数、错误包装的 defer 才值得用。

命名返回值和 defer 能互相看见:defer 闭包改 err,调用方拿到的是改过的值。这是故意设计,不是副作用。

go
func wrap() (err error) {
	defer func() {
		if err != nil {
			err = fmt.Errorf("wrap: %w", err)
		}
	}()
	return errors.New("boom")
}

3、参数是拷贝

go
func bumpInt(n int)     { n++ }
func bumpPtr(p *int)    { *p++ }
func bumpHead(s []int)  { s[0] = 9; s = append(s, 1) }
func bumpMap(m map[int]int) { m[1] = 1 }

func main() {
	n := 1
	bumpInt(n)
	fmt.Println(n) // 1
	bumpPtr(&n)
	fmt.Println(n) // 2

	s := []int{1, 2, 3}
	bumpHead(s)
	fmt.Println(s) // [9 2 3],append 的 1 没回来

	m := map[int]int{}
	bumpMap(m)
	fmt.Println(m[1]) // 1
}

规则只有一条:拷贝参数盒子。

  • int、结构体、数组:盒子里是数据本身,函数里改看不见。
  • 指针:盒子里是地址,解引用能改到外面。
  • 切片:盒子里是 header,改 s[i] 看得见,append 换头看不见。
  • map / channel / 函数值:盒子里是指针或描述符,改底层结构看得见,把变量本身改成另一个 map 看不见。

C++ 要写 void f(T) / void f(T&) / void f(const T&) 三套。Python 一律传对象引用,n = n + 1 换绑不影响调用方,s.append 却影响。Go 把「盒子里是什么」写在类型上,没有第三种参数传递。

结构体当参数会拷整个结构体。小的、只读的,传值更清楚。要改字段、或结构体很大、或要表达「可空」,传指针。不要「所有结构体都传指针」当风格——time.Time 就是值,sync.Mutex 绝对不能拷,见方法集。

4、没有引用,没有默认参数

go
type Option struct {
	Timeout time.Duration
	Limit   int
}

func load(path string, opt Option) error {
	if opt.Timeout == 0 {
		opt.Timeout = 3 * time.Second
	}
	if opt.Limit == 0 {
		opt.Limit = 100
	}
	_ = path
	return nil
}

零值当默认。需要区分「没传」和「传了 0」时,字段改成指针或 sql.NullInt64 一类。不要学 Python 的 def f(x=None) 在包级变量上放可变默认——Go 没有这个语法,也少了那个坑。

init 不是给业务函数当构造器用的。需要构造,写 NewXxx(...) (*Xxx, error),让调用方看见失败。


二、闭包与循环变量

1、闭包捕获的是变量,不是当时的值

go
func adder(base int) func(int) int {
	return func(n int) int {
		base += n
		return base
	}
}

func main() {
	a := adder(10)
	fmt.Println(a(1)) // 11
	fmt.Println(a(1)) // 12
	b := adder(10)
	fmt.Println(b(1)) // 11,另一份 base
}

内层函数引用了外层的 basebase 的盒子在 adder 返回后还活着,逃逸到堆。每次 adder 调用一份新 base。同一份闭包反复调用,累加的是同一份。

这和 Python 的闭包同类:捕获名字,延迟取值。C++ 的 lambda 要你写 [=] / [&],默认拷还是引用你得选。Go 只有一种:引用变量。想捕获当时的值,进闭包前拷到新盒子:

go
n := 1
x := n // 拷一份
f := func() { fmt.Println(x) }
n = 2
f() // 1

2、Go 1.22 之前:循环共用一个盒子

go
funcs := make([]func(), 0, 3)
for i := 0; i < 3; i++ {
	funcs = append(funcs, func() { fmt.Println(i) })
}
for _, f := range funcs {
	f()
}
  • go.modgo 1.21:打印 3 3 3i 整个循环一个盒子,闭包全部指向它,循环结束时 i==3
  • go.modgo 1.22 或更高:打印 0 1 2。每次迭代新 i

语言版本看模块的 go 行,不是看你机器上 go version。编译 1.21 模块的代码,即使用 1.22 工具链,仍是旧语义。

range 的循环变量同样适用:

go
var fns []func()
for _, v := range []int{10, 20, 30} {
	fns = append(fns, func() { fmt.Println(v) })
}

1.21:三个闭包看到同一个 v,最后是 30。1.22:每次迭代新 v

go func(){ ... }() 把循环变量带进 goroutine,是同一类 bug。1.21 必须写:

go
for i := 0; i < 3; i++ {
	i := i // 新盒子,闭包捕获这个
	go func() { fmt.Println(i) }()
}

1.22 起外层那份 i 每次已经是新的,i := i 不再必须,留着也不错,兼容旧工具链时还能看懂。

C++ 的 for (int i = 0; ...) 始终一颗 i,lambda [&i] 全指它。Python 的 for i in ... 是同一个名字反复换绑,lambda: i 延迟绑定,和 Go 1.21 同类。Go 1.22 把坑填了,新代码按新语义写;读旧代码、看 1.21 模块,按旧语义查。

3、闭包改命名返回值、改外层变量

go
func walk() (n int) {
	defer func() { n++ }()
	return 1 // 先把 1 写入 n,再跑 defer,n 变成 2
}

闭包捕获 n 这个变量。return 1 的语义是「把 1 赋给 n,然后跑 defer,然后真的返回」。defer 里 n++ 看得到。函数与方法这篇不靠这个写技巧代码,但读标准库会碰到。

循环里构造闭包再立刻调用,没有延迟,1.21 也安全:

go
for i := 0; i < 3; i++ {
	func(i int) { fmt.Println(i) }(i) // 参数立刻拷贝
}

参数拷贝是另一条路,和 i := i 等价。defer 一节会把「参数立刻求值」再钉一次。


三、方法:接收者决定拷什么

1、值接收者 vs 指针接收者

go
type Point struct{ X, Y int }

func (p Point) Move(dx, dy int) {
	p.X += dx
	p.Y += dy
}

func (p *Point) MoveP(dx, dy int) {
	p.X += dx
	p.Y += dy
}

func main() {
	p := Point{1, 2}
	p.Move(3, 4)
	fmt.Println(p) // {1 2}
	p.MoveP(3, 4)
	fmt.Println(p) // {4 6}
}

方法就是函数,第一个参数叫接收者,写在 func 和名字之间。(p Point) 等价 func Move(p Point, dx, dy int),调用时拷 p(p *Point) 等价 func MoveP(p *Point, dx, dy int)

选择规则:

  1. 方法要改接收者,必须 *T
  2. 接收者含 sync.Mutexsync.WaitGroup 等不能拷的字段,必须 *T。拷 mutex 会拷锁的状态,vet 会报 copylocks
  3. 只读、结构体很小、值语义(time.Timedecimal),用 T
  4. 同一类型的方法接收者尽量统一。混用能编译,读的人要每次看签名。

nil 指针接收者是合法的 this,方法内部要自己判:

go
func (p *Point) String() string {
	if p == nil {
		return "<nil>"
	}
	return fmt.Sprintf("(%d,%d)", p.X, p.Y)
}

var p *Point
fmt.Println(p.String()) // <nil>,不 panic

值接收者不会收到「没有盒子」——值总是有的。指针接收者可以是 nil,和 C++ 的 this == nullptr 一样要防,但 Go 允许你在方法里把它当正常分支,标准库 (*os.File).Close 对 nil 是空操作一类。

2、调用处的自动取地址 / 解引用

go
p := Point{1, 2}
pp := &p
p.MoveP(1, 1)  // 写成 (&p).MoveP,p 可取地址
pp.Move(1, 1)  // 写成 (*pp).Move
pp.MoveP(1, 1)

值变量可取地址时,指针方法能直接用 . 调。指针变量调值方法,编译器解引用。取不了地址的临时值不行:

go
Point{1, 2}.Move(0, 0)  // 可以:值方法,拷临时值
// Point{1, 2}.MoveP(0, 0) // 编译失败:不能取临时值的地址

map 元素不能取地址,所以 m[k].MoveP() 失败,m[k].Move() 可以(在值方法、且 map 值是 Point 不是指针时)。上一篇刚写过 map 元素不可寻址,这里是它在方法调用上的后果。

3、方法值和方法表达式

go
p := Point{1, 2}
f := p.MoveP          // 方法值:func(dx, dy int),绑死了接收者
f(3, 4)
fmt.Println(p)        // {4 6}

g := (*Point).MoveP   // 方法表达式:func(p *Point, dx, dy int)
g(&p, 1, 1)
fmt.Println(p)        // {5 7}

h := Point.Move       // func(p Point, dx, dy int)
h(p, 0, 0)

方法值把接收者绑进去,类型是去掉接收者之后的函数类型。方法表达式把接收者变回第一个参数。和 Python 的 obj.method / Cls.method 对照:前者绑 self,后者要显式传。C++ 的 std::bind / 成员函数指针更啰嗦,语义同类。

方法值会让接收者逃逸。f := p.MoveP 要把 &p 藏进 fp 不能只活在栈上。热路径上不要随手把方法存进 []func()


四、方法集:谁能赋给接口

1、T 的方法集、*T 的方法集

类型 T 的方法集 = 所有 (T) 接收者的方法。 类型 *T 的方法集 = 所有 (T) 的方法 加上 所有 (*T) 的方法。

go
type S struct{ n int }

func (s S) Val()     {}
func (s *S) Ptr()    {}

var _ interface{ Val() } = S{}   // 可以
var _ interface{ Val() } = &S{}  // 可以
// var _ interface{ Ptr() } = S{} // 编译失败:S 的方法集没有 Ptr
var _ interface{ Ptr() } = &S{}  // 可以

调用处 s.Ptr() 能通过自动取地址成功;赋给接口不行。接口存的是值的一份拷贝(或指针),接口内部不会为了凑方法集去取原变量的地址。S{} 放进接口是拷贝一个 S,这份拷贝没有 Ptr

这是方法集存在的理由:决定「这个值能不能当某个接口用」。具体接口的 tab/data 下一篇展开,这里只把方法集的归属钉死。

go
type Mut interface{ Ptr() }

func call(m Mut) { m.Ptr() }

s := S{}
// call(s)  // 编译失败
call(&s)    // 可以

写 API 时:接口方法会改状态,实现方就用指针接收者,调用方必须传指针。只读方法用值接收者,值和指针都能当接口用。

2、嵌入提升方法,不是继承

go
type Engine struct{}

func (e Engine) Start() { fmt.Println("engine") }
func (e *Engine) Stop() { fmt.Println("stop") }

type Car struct {
	Engine // 匿名字段,不是基类
	Plate  string
}

func main() {
	c := Car{Plate: "A"}
	c.Start() // 提升:c.Engine.Start()
	c.Stop()  // 提升:(&c.Engine).Stop();c 可寻址
	fmt.Println(c.Plate)
}

嵌入把内层字段的名字提升到外层:c.Startc.Engine.Start 的语法糖。外层没有「变成了 Engine 的子类」。Car 不是 Engine,不能把 Car 赋给 Engine 参数。没有虚函数分派到外层重写——外层定义同名方法只是把自己的方法集里那一项换成自己的,内层的还在内层。

go
func (c Car) Start() { fmt.Println("car") }

c := Car{}
c.Start()        // car
c.Engine.Start() // engine

提升的方法,接收者仍是内层那个字段。c.Stop() 改的是 c.Engine,不会把 Car 的其他字段算进接收者。指针 / 值规则按内层类型走。

方法集随嵌入走:

  • Car 的方法集包含 Engine 的值方法(提升后接收者变成 Car 视角的调用,但实现仍转发给字段)。
  • *Car 的方法集包含 Engine*Engine 的方法。
  • 若嵌入的是 *Engine,提升的是 *Engine 的方法集,Car 值上也能调 Stop,因为字段本身是指针。
go
type Bike struct {
	*Engine
}

b := Bike{Engine: &Engine{}}
b.Stop()
var _ interface{ Stop() } = Bike{Engine: &Engine{}} // 可以:字段是指针

C++ 的 class Car : public Engine 是 is-a。Python 的继承走 MRO。Go 是 has-a 加名字提升。没有 super,没有虚分派到外层——外层同名方法要调内层,必须显式 c.Engine.Start()。两个匿名字段提升同名方法且外层不覆盖,调用是编译错误。

字段同样提升:type Server struct { Addr; Name string } 之后 s.Host 就是 s.Addr.Host。外层定义同名字段会挡住内层。嵌入 *Addr 时零值是 nil,提升字段读写会 panic,构造时把指针填上。

提升会让外层碰巧满足内层满足的接口:type wrap struct{ file }fileReadwrap 就能赋给 io.Reader。没有 implements。嵌入是往方法集里塞方法的快捷方式,不是继承声明。下一篇从这里切入。

3、不能拷贝的接收者

go
type Safe struct {
	mu sync.Mutex
	n  int
}

func (s *Safe) Inc() {
	s.mu.Lock()
	s.n++
	s.mu.Unlock()
}

// func (s Safe) Inc() { ... } // 值接收者会拷 mutex,vet: copylocks

sync.Mutexsync.Condsync.WaitGroupsync.Pool 含内部指针,拷贝后两把锁指同一份或指到失效状态。类型里嵌它们,方法一律指针接收者,传参一律指针,赋值不要 a := bgo vetcopylocks 查这个。bytes.Bufferstrings.Builder 文档写明「不要拷贝」:第一次 Write 后再拷,内部切片头和 buffer 脱节。


五、defer:参数立刻求值,调用延后

defer:参数当时求值,函数 LIFO 弹出

1、登记时机、执行时机

go
func f() {
	fmt.Println("A")
	defer fmt.Println("B")
	defer fmt.Println("C")
	fmt.Println("D")
}
// A D C B

执行到 defer 这一行,把调用登记进当前函数的链表。函数返回时(正常 return 或 panic)LIFO 执行。不是离开 { } 就跑。循环里 defer 文件,fd 会堆到函数结束:

go
func slurpAll(paths []string) error {
	for _, p := range paths {
		f, err := os.Open(p)
		if err != nil {
			return err
		}
		defer f.Close() // 所有文件堆到函数结束才关
		_, _ = io.Copy(io.Discard, f)
	}
	return nil
}

抽一层函数,让每次迭代的 defer 在这次调用结束时跑完:

go
func slurpOne(path string) error {
	f, err := os.Open(path)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = io.Copy(io.Discard, f)
	return err
}

C++ 的 unique_ptr / lock_guard 是块结束析构。Python 的 with 是块结束 __exit__。Go 按函数边界。锁的粒度因此往往比 C++ 粗:Lock 之后立刻 defer Unlock(),整段函数持锁,而不是一个小 { }。需要短临界区,抽函数或不用 defer、在手工 Unlock 处写清楚。

2、参数立刻求值

go
func deferArg() {
	x := 1
	defer fmt.Println("arg", x) // 登记时求 x,拷的是 1
	x = 2
}

func deferClose() {
	x := 1
	defer func() { fmt.Println("cls", x) }() // 闭包捕获变量 x
	x = 2
}

defer f(args)args 在执行到 defer 那一行就求完,结果存进 defer 记录。真正调 f 等到返回。defer fmt.Println("arg", x) 打印 1。想看返回时的值,包一层无参闭包。

接收者也是参数:

go
func deferUnlock() {
	var mu sync.Mutex
	mu.Lock()
	defer mu.Unlock() // 登记时确定接收者是这把 mu,返回时 Unlock
}

换指针更阴:

go
func deferPtr() {
	p := &Point{X: 1}
	defer p.MoveP(0, 0) // 接收者指针当时的值拷进 defer 记录
	p = &Point{X: 9}    // 换了指针,defer 仍拿着旧的那份
}

p.MoveP 的接收者在登记时求值,是旧地址。闭包 defer func() { p.MoveP(0, 0) }() 才读返回时的 p

os.Exit 不跑 defer。mainos.Exit(1) 会跳过已登记的 Unlock / Close。入口写成 run() errormain 翻译退出码。

3、defer 和返回值

go
func trace() (n int) {
	defer func() { n++ }()
	return 1
}
// 返回 2

return 1 先写 n=1,再执行 defer,再返回。裸 return、具名返回值、defer 改 err,三位一体。匿名返回值没有名字可捕获:

go
func notThis() int {
	n := 1
	defer func() { n++ }()
	return n // 先把当前 n(1)拷进返回槽,defer 改的是局部 n,返回仍是 1
}

具名和不具名的差别就在「返回槽是不是那个变量」。包装 error 用具名 err;其它情况少用裸 return。

defer 里再 panic,会覆盖外面的 panic,前一个丢掉。defer 里 recover 只接当前 goroutine 这一轮。recover 必须直接写在 defer 的函数里,套一层再调 recover 会变成 nop。这些和接口无关,放到这里只因为 defer 是函数机制。业务失败不要靠 panic;panic 留给真正的程序错误(下标越界、不可恢复的不变式)。

defer 参数求值本身可以有副作用,发生在登记时:defer fmt.Println(f()) 立刻调 f(),返回时才 Println。现代 Go 的 defer 多数编译成栈上记录,热路径上 defer Unlock 可以写;不要在百万次循环体内登记无意义的 defer。


六、函数零值、main

go
var f func()
// f() // panic
if f != nil {
	f()
}

函数值只能和 nil 比,两个非 nil 函数不能 ==。回调字段调用前判 nil。init 没有参数、没有返回值、不能调,失败只能 panic;能返回 error 的初始化写成普通函数从 main 调。os.Exit 不跑 defer,入口仍是 run() error + main 翻译退出码。同一包内函数互相调用不需要前向声明。栈分段增长,递归到几万通常没问题,仍然不要靠无限递归。


七、对照 C++ / Python

C++PythonGo
函数值函数指针 / std::function一等对象一等,签名即类型
参数值 / 指针 / 引用对象引用只拷盒子
重载 / 默认参
方法成员函数,this 是指针self 显式,绑定在实例接收者写在 func 前
改接收者非 const 成员函数改对象属性必须 *T
继承is-a,虚函数类继承,MRO嵌入提升,不是继承
闭包[=] / [&]捕获名字捕获变量;1.22 修了 loopvar
析构 / 清理RAII,块结束with,块结束defer,函数结束

从 C++ 转:丢掉引用参数和虚函数重写,改成指针接收者和接口。从 Python 转:丢掉「对象都是引用」和默认参数,看类型盒子里是值还是头。两边共同的陷阱:Go 的切片参数拷头,append 不写回等于没 append。

方法集比调用语法更严:s.Ptr() 能过,var _ I = s 过不了。把值赋进接口之前,问「这个动态类型的方法集有没有这组方法」。下一篇整件事情从 error 展开。


八、一块能跑的对照实验

保存成 func_method.gogo run func_method.go

go
package main

import (
	"fmt"
	"sync"
)

type Counter struct{ n int }

func (c Counter) Inc()  { c.n++ }
func (c *Counter) Add() { c.n++ }

func bumpSlice(s []int) {
	s[0] = 9
	s = append(s, 1)
}

func adder(base int) func(int) int {
	return func(n int) int {
		base += n
		return base
	}
}

func deferArg() {
	x := 1
	defer fmt.Println("defer arg", x)
	defer func() { fmt.Println("defer cls", x) }()
	x = 2
}

func named() (n int) {
	defer func() { n++ }()
	return 1
}

type Engine struct{}

func (Engine) Start() { fmt.Println("engine start") }

type Car struct{ Engine }

func (Car) Start() { fmt.Println("car start") }

func main() {
	var c Counter
	c.Inc()
	fmt.Println("val recv", c.n) // 0
	c.Add()
	fmt.Println("ptr recv", c.n) // 1

	s := []int{1, 2, 3}
	bumpSlice(s)
	fmt.Println("slice arg", s) // [9 2 3]

	a := adder(10)
	fmt.Println("closure", a(1), a(1)) // 11 12

	funcs := make([]func(), 0, 3)
	for i := 0; i < 3; i++ {
		funcs = append(funcs, func() { fmt.Print(i, " ") })
	}
	fmt.Print("loopvar ")
	for _, f := range funcs {
		f()
	}
	fmt.Println() // 1.22: 0 1 2;1.21: 3 3 3

	deferArg()
	fmt.Println("named", named()) // 2

	var car Car
	car.Start()
	car.Engine.Start()

	f := c.Add
	f()
	fmt.Println("method value", c.n) // 2

	var mu sync.Mutex
	mu.Lock()
	defer mu.Unlock()

	type I interface{ Add() }
	var _ I = &c
	// var _ I = c // 编译失败:Counter 方法集没有 Add
	fmt.Println("ok")
}

对照:值接收者改不动;指针接收者改得动;切片参数改得了下标带不回新头;闭包累加同一份 base;loopvar 按你的 go 行打印;defer 参数是 1、闭包是 2;命名返回值被 defer 加一变成 2;嵌入提升能调 Start,外层同名方法挡住内层,内层还在 car.Engine.Start;方法值绑死接收者;Counter 值赋不进带 Add 的接口,指针可以。

接收者是第一个参数,拷值还是拷地址写在签名上。嵌入只是转发,不是 is-a。defer 的参数在登记那一行就已经冻住。接口为什么能接住「没声明实现」的类型,以及为什么 var err error = (*T)(nil) 不是 nil,下一篇写。