Skip to content
Go
Go 函数与方法

Go 函数与方法

函数声明、多返回值、变参、闭包、defer、方法定义与值/指针接收者选择指南,外加两个生产代码里绕不开的惯用法:方法表达式与函数选项(functional options)。基线 Go 1.27。


1. 函数声明

// 标准格式:func 名称(参数列表) (返回值列表)
func add(a, b int) int {
    return a + b
}

// 同类型参数可合并声明
func multiply(a, b int, c float64) float64 {
    return float64(a*b) * c
}

// 多返回值:Go 的错误处理就建立在这上面
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// 命名返回值
func split(sum int) (x, y int) {
    x = sum * 4 / 9
    y = sum - x
    return                          // 裸 return(利用命名返回值)
}

注意:命名返回值 + 裸 return 会让可读性下降——阅读者需要向上翻看函数签名才知道返回了什么。仅在极短的函数中使用裸 return(如 func add(a, b int) (sum int) { sum = a + b; return }),长函数中应显式返回。命名返回值更大的价值是给 defer 一个可以修改的落点(见第 5 节)和在反编译/文档里充当"参数名"。

函数类型

// 函数是 Go 的一等公民:可赋值、可传参、可做返回值
type Handler func(w http.ResponseWriter, r *http.Request)
type Comparator func(a, b int) int

var f func(int) int
f = func(x int) int { return x * 2 }

函数类型是实现"策略注入"的基础:把行为当参数传递(排序比较器、中间件、回调),或者定义成具名类型后给它挂方法(http.HandlerFunc 就是给 func(ResponseWriter, *Request) 挂了一个 ServeHTTP 方法,让普通函数变成接口的实现——详见 06-接口与泛型.md)。


2. 参数传递

Go 中所有参数都是值传递——传的是"一份副本",只是有些类型(切片、map、chan)的副本内部包含指向共享数据的指针,所以看起来像引用:

类型 传递内容 可否修改调用方数据
基本类型(int, string, float…) 值的副本 否
数组 整个数组的副本 否
结构体 结构体的副本 否
指针(*T) 指针的副本(底层共享) 是
切片([]T) slice header 的副本(底层数组共享) 可改元素,不可改调用方的 len/cap
Map(map[K]V) map header 的副本(底层共享) 是
Channel 引用的副本(底层共享) 是
func modify(s []int) {
    s[0] = 99                      // 影响调用方(共享底层数组)
    s = append(s, 100)             // 不影响调用方的 s(改的是 header 的副本)
}

最佳实践:切片、map、chan 本身已是引用语义,不需要再传指针。但如果函数内要改变切片的长度(append/re-slice),要么传 *[]T,要么返回新切片——后者是标准库的一致选择(slices.Sort 原地改元素,strings.Split 返回新值)。


3. 变参函数(Variadic)

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

fmt.Println(sum(1, 2, 3))          // 6
fmt.Println(sum())                  // 0

// 展开切片
s := []int{1, 2, 3}
fmt.Println(sum(s...))             // 6

底层机制与一个容易记反的语义

变参参数在函数体内就是一个 []int。但两种调用方式的内存行为不同:

  • 逐个传字面量 sum(1, 2, 3):编译器在栈上分配一个新切片,把元素填进去。
  • 展开传参 sum(s...):按语言规范,切片"原样传递"(passed unchanged)——不复制、不新建,函数拿到的 nums 与调用方的 s 共享同一个底层数组。

后者有个实际后果:变参函数里改元素,调用方看得见。下面程序(go1.27.0 实测):

func bump(nums ...int) {
    for i := range nums {
        nums[i]++
    }
}

func main() {
    s := []int{1, 2, 3}
    bump(s...)
    fmt.Println(s)                 // [2 3 4] —— 调用方的切片被改了
}

所以写变参函数时,若需要修改元素或要把 nums 存起来比本次调用更久,先自己 slices.Clone 一份。同理,s... 展开传参是 O(1) 的——不存在"切片很大导致大量栈空间占用"的问题,这是早期资料里流传的一个讹传。


4. 闭包与匿名函数

// 匿名函数直接调用
func(a, b int) int { return a + b }(1, 2)

// 闭包:捕获外部变量
func counter() func() int {
    count := 0
    return func() int {
        count++                     // 捕获的是 count 这个变量本身,不是它的值
        return count
    }
}

c := counter()
fmt.Println(c())                    // 1
fmt.Println(c())                    // 2
fmt.Println(c())                    // 3

闭包捕获的是变量的引用而非值:只要闭包还活着,被捕获的变量就不会被回收。这既是 counter 能累加的原因,也是下面这个经典陷阱的根源。

循环中的闭包陷阱

// Go 1.21 及之前的语义:整个循环共享一个 i
for i := 0; i < 3; i++ {
    funcs = append(funcs, func() { fmt.Println(i) })
}
for _, f := range funcs { f() }
// 旧版本输出:3 3 3(全部引用同一个变量 i)

// 当年的修正手法:循环体内局部遮蔽
for i := 0; i < 3; i++ {
    i := i                          // 每轮创建新的局部变量
    funcs = append(funcs, func() { fmt.Println(i) })
}

Go 1.22 起循环变量改为每轮迭代一个独立变量,上面的手动 i := i 已经不再需要,新代码直接写即可得到 0 1 2。存量 i := i 无害但冗余。若你的模块 go 指令仍低于 1.22,则保持旧语义,需要手动遮蔽。


5. defer:延迟执行

defer 将函数调用登记到当前函数上,在外层函数返回前执行,多个 defer 按 LIFO(后进先出)顺序运行:

func readFile(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()                  // 确保文件关闭

    return io.ReadAll(f)
}

defer 执行顺序

func demo() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    fmt.Println("main")
}
// 输出顺序:main → 3 → 2 → 1(LIFO 栈)

defer 参数求值时机(关键陷阱)

defer 登记时,被调函数的参数立即求值;真正延迟的只是"调用"这个动作:

func demo() {
    x := 1
    defer fmt.Println(x)             // x 在 defer 语句处被求值为 1 并固化
    x = 2
}
// 输出:1(不是 2!)

// 想拿到"最新值",用闭包捕获变量本身
func demo2() {
    x := 1
    defer func() { fmt.Println(x) }()  // 闭包捕获引用,返回时才读取
    x = 2
}
// 输出:2

defer 与命名返回值

defer 在"返回值已赋值、函数尚未真正返回"之间运行,所以闭包形式的 defer 可以修改命名返回值:

func modify() (result int) {
    defer func() { result++ }()       // defer 可以修改命名返回值
    return 10
}
// 返回 11!

// return 10 的实际执行顺序:
// 1. result = 10(赋值给命名返回值)
// 2. 执行 defer 栈 → result++ → result = 11
// 3. 真正返回 11

这个机制正是"统一在 defer 里包装错误"的写法基础:

func ReadConfig(path string) (cfg *Config, err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("read config %s: %w", path, err)
        }
    }()
    // ... 后续所有 return err 都会经过上面的包装
}

defer 常用场景

// 关闭资源
f, err := os.Open("file.txt")
if err != nil { /* 处理 */ }
defer f.Close()

// 释放锁
mu.Lock()
defer mu.Unlock()

// 捕获 panic
defer func() {
    if r := recover(); r != nil {
        log.Println("recovered:", r)
    }
}()

// 记录函数耗时
func trace(name string) func() {
    start := time.Now()
    log.Printf("enter %s", name)
    return func() {
        log.Printf("exit %s (elapsed: %v)", name, time.Since(start))
    }
}
defer trace("myFunc")()

深入原理:Go 1.14 引入了 open-coded defer 优化——对于"参数在登记点已定、数量固定"的常见 defer,编译器直接在函数栈帧里展开,开销接近一次普通函数调用。日常工程不必为 defer 的性能做取舍;用不用 defer 请优先考虑正确性和可读性。另外 defer trace("myFunc")() 这类写法里,trace("myFunc") 在登记时执行、返回的闭包在函数退出时执行——两层时机正好用来记录耗时。


6. 方法(Method)

方法是带**接收者(receiver)**的函数:

type Counter struct {
    value int
}

// 值接收者:操作的是副本
func (c Counter) Value() int {
    return c.value
}

// 指针接收者:可修改原值
func (c *Counter) Increment() {
    c.value++
}

func (c *Counter) Add(n int) {
    c.value += n
}

调用时编译器做了什么

Go 在方法调用上的自动改写只遵循一条规则:能不能取到接收者需要的地址。变量 c 是可寻址的,所以:

c := Counter{value: 10}

c.Value()                           // 值方法直接调用(编译器把 c 复制一份传入)
c.Increment()                       // c 可寻址,编译器自动改写为 (&c).Increment()
fmt.Println(c.Value())              // 11

// 指针变量同理,方向相反
ptr := &Counter{value: 10}
ptr.Value()                         // 编译器自动解引用:(*ptr).Value(),复制的是解引用后的值
ptr.Increment()

“可寻址"是关键前提:map 元素不可寻址,所以对 map 里的结构体调指针方法会直接编译失败(go1.27 实测报错 cannot call pointer method Increment on Counter):

m := map[string]Counter{"a": {}}
m["a"].Increment()                  // 编译错误:不能对 map 元素取地址

标准处理方式是取出来、改完再放回去:c := m["a"]; c.value++; m["a"] = c;或者 map 直接存 *Counter。

值接收者 vs 指针接收者

维度 值接收者 (c Counter) 指针接收者 (c *Counter)
是否修改原值 否,操作副本 是,修改原值
接收 nil 不可能(值类型不会是 nil) 允许,需在方法开头检查 if c == nil
方法所属的方法集 只进 Counter 的方法集 只进 *Counter 的方法集
接口实现 Counter 和 *Counter 都满足 只有 *Counter 满足
性能 大结构体有复制开销 仅传一个指针(8 字节)
并发安全 天然隔离(副本) 修改共享状态需加锁

方法集的精确规则是:类型 T 的方法集只有值接收者方法;*T 的方法集包含值接收者 + 指针接收者方法。这解释了表中"接口实现"那一行——*T 能满足用值接收者方法定义的接口,反之不行。

最佳实践:

  • 需要修改接收者 → 指针接收者
  • 结构体很大 → 指针接收者(避免复制)
  • 包含 sync.Mutex 等不可复制字段 → 指针接收者
  • 其他情况 → 保持一致性:同一类型的所有方法应统一接收者类型,不要混用。小且只读的类型(类似 time.Time 的用法)可全用值接收者

nil 接收者处理

type Tree struct {
    Value int
    Left  *Tree
    Right *Tree
}

func (t *Tree) Sum() int {
    if t == nil {                   // 处理 nil 接收者
        return 0
    }
    return t.Value + t.Left.Sum() + t.Right.Sum()
}

允许 nil 接收者可以让 API 更优雅(nil 树的和为 0,递归写法也因此省掉 everywhere 的判空),但必须在方法开头显式检查。标准库中也有类似用法。注意别滥用:nil 接收者意味着方法里不能再触碰 t 的字段,且这类方法只有指针接收者才有意义。

方法不能在外部包定义

// 编译错误:不能为非本地类型定义方法
func (s string) Double() string { return s + s }

// 正确:定义新类型包装
type MyString string
func (s MyString) Double() MyString { return s + s }

接收者类型必须和它定义在同一包,且不能是指针类型别名或接口类型。想给别包的类型扩展行为,只有两条路:包一层新类型,或者写普通函数(Go 没有 C# 那种扩展方法)。

Go 1.27:泛型方法解禁

从 Go 1.27 起,方法可以声明自己的类型参数(go1.27 实测编译运行通过):

type Number interface{ ~int | ~int64 | ~float64 }

type Stats struct {
    sum   float64
    count int
}

func (s *Stats) Add[T Number](vals ...T) {
    for _, v := range vals {
        s.sum += float64(v)
        s.count++
    }
}

func main() {
    st := &Stats{}
    st.Add(1, 2, 3)
    st.Add(int64(10))
    fmt.Printf("stats sum: %v count: %d\n", st.sum, st.count)
}
// 输出:stats sum: 16 count: 4

两个仍成立的限制:接口方法不允许带类型参数(写 interface { Do[T any](v T) } 直接编译报 interface method must have no type parameters),泛型方法也不能用来满足接口方法。详见 06-接口与泛型.md。

方法表达式(Method Expression)

方法是挂在类型上的,但有时你需要把它当普通函数用——传给 sort、塞进 map、或者交给只接受 func 的旧 API。方法表达式就是干这个的:从类型上取出方法,得到一个"接收者变成第一个参数"的未绑定函数。

f := (*Counter).Increment  // func(*Counter)     —— 指针接收者方法,经 *T 取
g := Counter.Value         // func(Counter) int  —— 值接收者方法,经 T 取
h := (*Counter).Value      // func(*Counter) int —— *T 的方法集包含值接收者方法,所以也合法

完整可运行示例(go1.27.0 实测):

package main

import "fmt"

type Counter struct{ n int }

func (c Counter) Value() int  { return c.n }
func (c *Counter) Increment() { c.n++ }

func apply(cs []*Counter, op func(*Counter)) {
    for _, c := range cs {
        op(c)
    }
}

func main() {
    inc := (*Counter).Increment // func(*Counter)
    val := Counter.Value        // func(Counter) int
    valPtr := (*Counter).Value  // func(*Counter) int

    c := Counter{}
    inc(&c)
    inc(&c)
    fmt.Println(val(c))         // 2
    fmt.Println(valPtr(&c))     // 2

    bound := c.Value            // 方法值:接收者已绑定
    fmt.Println(bound())        // 2

    gs := []*Counter{{n: 1}, {n: 2}}
    apply(gs, (*Counter).Increment) // 把方法当函数参数传给通用逻辑
    fmt.Println(gs[0].n, gs[1].n)   // 2 3
}

取方法的规则与第 6 节的方法集完全一致:

  • 表达式 T.M:要求 M 在 T 的方法集里,即只能取到值接收者方法,得到的函数签名是 func(T, ...)。用 T 取指针接收者方法会编译失败(go1.27 实测):invalid method expression Counter.Increment (needs pointer receiver (*Counter).Increment)。
  • 表达式 (*T).M:值接收者和指针接收者方法都能取到(*T 的方法集是全集),指针接收者得到 func(*T, ...),值接收者得到 func(*T, ...) 但调用时会自动解引用——统一按第一个参数传 *T。
  • 得到的函数未绑定接收者,每次调用都要把接收者作为第一个实参传进去。

和方法表达式对应但常被混谈的是方法值:x.M(x 是变量)会把接收者绑定进去,c.Value 得到 func() int,适合当回调传出去;方法表达式的 c 部分则留到调用时再给。一句话区分:方法值绑定"调用者”,方法表达式留下"调用者"这个参数。

实际用途不算高频,但出现时很顺手:

  • 把一组方法收进调度表:ops := map[string]func(*Counter){ "inc": (*Counter).Increment }。
  • 喂给只接受 func 的泛型帮助函数或旧 API(如上面的 apply)。
  • 需要"先选方法、后补对象"的批处理场景。

7. 函数选项(Functional Options)

构造函数常常有"一个必填参数 + 一堆可选配置"。Go 没有函数重载,也没有默认参数,标准库给出的出路是:把每个可选项做成一个函数,构造函数接变参 opts ...Option。这个写法被 grpc-go、go-openapi、大多数云厂商 SDK 采用,简称 functional options。

type Client struct {
    baseURL string
    timeout time.Duration
    retries int
    verbose bool
}

// Option 是"改一下配置"的函数类型
type Option func(*Client)

// 每个可选项一个 WithXxx,返回闭包
func WithTimeout(d time.Duration) Option {
    return func(c *Client) { c.timeout = d }
}

func WithRetries(n int) Option {
    return func(c *Client) { c.retries = n }
}

func WithVerbose() Option {
    return func(c *Client) { c.verbose = true }
}

// NewClient:必填参数放前面,可选参数用变收;先设默认值,再逐个应用选项
func NewClient(baseURL string, opts ...Option) *Client {
    c := &Client{baseURL: baseURL, timeout: 30 * time.Second, retries: 3}
    for _, opt := range opts {
        opt(c)
    }
    return c
}

func main() {
    c1 := NewClient("https://api.example.com")
    fmt.Printf("default: timeout=%v retries=%d verbose=%v\n", c1.timeout, c1.retries, c1.verbose)

    c2 := NewClient("https://api.example.com",
        WithTimeout(5*time.Second),
        WithRetries(1),
        WithVerbose(),
    )
    fmt.Printf("custom:  timeout=%v retries=%d verbose=%v\n", c2.timeout, c2.retries, c2.verbose)
}

go1.27.0 实测输出:

default: timeout=30s retries=3 verbose=false
custom:  timeout=5s retries=1 verbose=true

为什么是这种形状:

  • 对比重载:Go 不能重载 NewClient(url) / NewClient(url, timeout) / …;参数每加一个就是一次签名变更,所有调用点跟着改。选项函数则是变参——调用方一个都不传也合法。
  • 对比默认参数:Go 没有 timeout = 30 * time.Second 这种语法,把默认值写死在签名旁反而让"跳过中间参数"做不到了。
  • 对比配置结构体(NewClient(Config{...})):结构体方案其实也常用,字段少、需要序列化/存盘时它更直观;选项方案的优势在调用现场的可读性(只写想改的),以及演进友好——库作者加一个 WithRetryPolicy 不破坏任何已有调用,而且选项内部可以携带逻辑(校验、依赖推导),干巴巴的 struct 字段不行。

生产代码里的几条经验:

  • 选项函数不要导出 Option 的实现细节,用 type Option func(*Client) 这样的不透明函数类型;进阶做法是 type Option interface{ apply(*Client) } 并把 apply 设为未导出方法,这样只有包内能造选项,避免用户往结构体里塞任意闭包。
  • 构造函数内部按"先默认值、后选项覆盖"的顺序写,确保每个字段都有明确默认。
  • 变参只用来收选项,别拿它当"真正的可选参数列表",否则参数一多顺序就没人记得住。

8. 常见陷阱

8.1 defer 参数求值时点

// 错误:认为 defer 会拿到"最新"的值
start := time.Now()
defer log.Printf("elapsed: %v", time.Since(start)) // 打印的是登记那一刻的耗时,始终 ~0s
// time.Since(start) 在 defer 语句执行时立即求值!

// 正确:用闭包,把"读取"推迟到函数退出时
defer func() { log.Printf("elapsed: %v", time.Since(start)) }()

8.2 命名返回值被 defer 修改

func f() (n int) {
    defer func() { n++ }()
    return 0
}
// f() 返回 1,不是 0!

8.3 循环中 defer 泄漏

// 错误:defer 在函数返回时才执行,不是循环迭代时
for _, file := range files {
    f, _ := os.Open(file)
    defer f.Close()                  // 所有文件在函数结束时才关闭,资源泄漏!
}

// 正确:用闭包包一层或用独立函数
for _, file := range files {
    func() {
        f, _ := os.Open(file)
        defer f.Close()
        // 处理文件
    }()
}

8.4 值接收者不能修改原值

func (c Counter) Increment() {       // 值接收者
    c.value++                        // 修改的是副本!
}
c := Counter{value: 10}
c.Increment()
fmt.Println(c.Value())               // 10(没变!)

8.5 方法集与接口实现

type Stringer interface { String() string }
type MyType struct{}

func (m *MyType) String() string { return "MyType" }  // 指针接收者

var s Stringer
s = MyType{}                        // 编译错误!MyType 的方法集里没有 String
s = &MyType{}                       // 正确:*MyType 满足 Stringer

调用 s.String() 时 Go 会自动取地址,但接口赋值看的是方法集,不会自动取地址——这是 T 满足不了、*T 才满足的根源。

8.6 recover 的有效条件

recover 只有在 panic 展开过程中、被 defer 的函数直接调用时才有效。“直接调用"是重点:defer catch() 且 catch 函数体内调 recover() 是有效的(catch 本身就是被 defer 的函数);但被 defer 的函数再往下嵌套调用、recover 放在更深处,就无效。go1.27.0 实测三个场景:

func inner() {
    fmt.Printf("inner sees recover: %v\n", recover())
}

func outer() {
    inner() // outer 是被 defer 的函数;recover 在 inner 里,属于嵌套调用
}

func main() {
    // 场景一:defer 匿名函数,函数体里直接 recover —— 有效
    func() {
        defer func() {
            if r := recover(); r != nil {
                fmt.Println("direct deferred recover:", r)
            }
        }()
        panic("boom1")
    }()
    fmt.Println("继续执行 1")

    // 场景二:defer 一个具名函数,该函数体内直接 recover —— 同样有效
    func() {
        defer inner()
        panic("boom2")
    }()
    fmt.Println("继续执行 2")

    // 场景三:defer outer,outer 调用 inner,recover 在 inner —— 无效,panic 继续上抛
    defer func() {
        fmt.Println("main recovered:", recover())
    }()
    func() {
        defer outer()
        panic("boom3")
    }()
    fmt.Println("不会执行")
}

实测输出:

direct deferred recover: boom1
继续执行 1
inner sees recover: boom2
继续执行 2
inner sees recover: <nil>
main recovered: boom3

工程建议:与其写具名函数去 defer,统一用 defer func() { ... recover() ... }() 的匿名闭包形式,既满足"直接调用”,也符合多数代码审查的直觉。


实战建议

  1. 多返回值的约定要守住:error 放最后一个;if err != nil 紧跟调用,别攒着。
  2. 变参函数把 s... 当"共享底层数组"对待:函数内要改元素或长期持有参数切片,先 slices.Clone。
  3. defer 三件套记牢:LIFO、参数登记时求值、命名返回值可被 defer 修改。耗时统计一律用 defer func(){...}() 或 defer trace("name")()。
  4. 接收者一致性:同一类型不要混用值和指针接收者;有 Mutex、需要修改、结构体大——选指针;纯读的小值类型可以全值。
  5. map 里放结构体时避免指针方法:要么存 *T,要么"取出-修改-放回"。
  6. 方法表达式不常见但值得认识:看到 (*T).M 知道它是"接收者挪到第一个参数的函数",读 grpc/标准库代码时不会卡住。
  7. 构造函数超过 3 个参数:用配置结构体或函数选项二选一;对外 SDK 且未来会加可选项 → 选项模式;配置需要落盘/传输 → 结构体。
  8. recover 写在 defer 的匿名闭包里,不要藏在嵌套函数中;1.27 的泛型方法能力强了,但接口方法仍不接受类型参数,跨包抽象请回看 06-接口与泛型.md。