Go 函数
Go 函数的声明、多返回值、命名结果、可变参数、defer 交互、函数值与闭包入口,以及参数按值复制描述符的统一模型。
[!info] 关联笔记
Go 函数
这个概念为什么会出现
几乎所有语言都用“函数”组织可复用行为。Go 的特别之处不在花哨语法,而在签名即契约:
- 错误是普通返回值,不是隐式异常通道
- 多个返回值是一等公民
- 参数、返回值、方法接收者统一走按值复制
- 函数本身是一等值,可赋值、可传参、可返回
若只把函数当成“写逻辑的括号”,会在多返回值、命名结果、defer 与可变参数上反复踩坑。正确心智是:函数边界是数据所有权与错误传播的边界。
[!abstract] 一句话理解 Go 函数用清晰签名表达输入输出;多返回值承载结果与错误;一切参数按值复制——复制的可能是完整数据,也可能是指向共享底层结构的描述符。
最小可运行示例
先把示例放进两个小业务场景,再看代码:
场景 A:计价服务里的“安全除法”
订单均摊、单价换算时经常要做除法。
除数为 0 是可预期失败,应返回 error,而不是让进程 panic。
命名返回值让失败路径的 defer 能看见最终 err,做统一收尾(记日志、释放临时资源等)。
场景 B:报表标签拼接(可变参数 + 函数值)
运营后台要把多段指标合成一行摘要,例如 gmv=12。
指标个数不固定 → 用 ...int;
有时还要把“同一种运算”当作策略传来传去 → 函数是一等值。
package main
import (
"errors"
"fmt"
)
// splitEvenly 模拟“把总额 amount 均摊到 n 份”。
//
// 业务意图:
// 1. n==0 时不能除,返回明确错误给调用方展示/重试;
// 2. 成功则返回每份整数商(演示用,忽略余数);
// 3. 失败时在退出前打一行清理/审计日志。
//
// 教学点:
// - 多返回值 (quot, err):结果与错误并列;
// - 命名结果:defer 里能读到最终 err;
// - 可预期失败用 error,不用 panic。
func splitEvenly(amount, n int) (quot int, err error) {
// 先登记 defer:无论后面怎么 return,都会在退出前执行。
defer func() {
// 只在失败路径做“收尾提示”;成功路径安静返回。
// 真实项目里可能是 metrics、span.End、临时文件删除等。
if err != nil {
fmt.Println("cleanup after failed split")
}
}()
if n == 0 {
// 命名返回值 err 被赋上错误;defer 能观察到它非 nil。
return 0, errors.New("division by zero")
}
// 成功:显式返回商与 nil。
return amount / n, nil
}
// formatMetric 模拟“把若干样本汇总成一条标签”。
//
// 业务意图:prefix 是指标名,nums 是不固定个数的样本。
// 教学点:
// - nums ...int 必须在参数列表最后;
// - 函数体内 nums 的类型就是 []int;
// - 调用方既可写 formatMetric("gmv", 1,2,3),也可 slice... 展开。
func formatMetric(prefix string, nums ...int) string {
total := 0
for _, n := range nums {
total += n
}
return fmt.Sprintf("%s=%d", prefix, total)
}
func main() {
// --- 场景 A:均摊成功 ---
if q, err := splitEvenly(10, 2); err != nil {
fmt.Println("split err:", err)
} else {
fmt.Println("each share:", q) // 5
}
// --- 场景 A:除零失败,观察 defer 清理 ---
if _, err := splitEvenly(10, 0); err != nil {
fmt.Println("split err:", err)
}
// --- 函数值:把“均摊算法”当成可替换策略 ---
// 业务上可能是 map[string]func(...) 或依赖注入进来的计算器。
op := splitEvenly
fmt.Println(op(9, 3)) // 3 <nil>
// --- 场景 B:可变参数 ---
fmt.Println(formatMetric("gmv", 1, 2, 3)) // gmv=6
more := []int{4, 5}
// more... 把切片展开成可变参数列表(不会“再包一层切片”)。
fmt.Println(formatMetric("orders", more...)) // orders=9
}
建议运行:
go run .
期望输出:
each share: 5
cleanup after failed split
split err: division by zero
3 <nil>
gmv=6
orders=9
结合场景再看四个关注点
-
命名结果服务 defer,不是为了炫技
splitEvenly失败时 defer 能根据err != nil做统一清理。 -
函数是值
op := splitEvenly后可以像普通变量一样传递、替换策略。 -
...T在体内是[]T,调用侧用slice...展开
对应报表“样本个数不固定”的需求。 -
参数始终按值复制
复制的可能是完整数据,也可能是指向共享底层结构的描述符(见语义笔记)。
核心概念与准确模型
声明形态
func Name(param type, more type) resultType { ... }
func Name(a, b int) (int, error) { ... } // 多返回值
func Name(a, b int) (sum int, err error) { ... } // 命名结果
func Name(nums ...int) int { ... } // 可变参数(须在末位)
同类型相邻参数可合并写法:a, b int。
多返回值(multi-return)
多返回值是 Go 错误处理与“结果 + 状态”的语法基础:
value, err := doWork()
n, ok := m[key]
v, ok := x.(T)
规则要点:
- 返回值是有序元组,类型固定。
- 调用方可用
_丢弃不需要的结果。 - 多返回值不能当作单一“元组类型”随意赋值给一个变量(除非再包一层 struct)。
- 工程上最常见约定是最后一个返回值表示错误(惯例,不是语法强制)。
命名结果(named results)
命名结果在函数签名中预先声明返回变量,零值初始化,可用裸 return:
func parse(s string) (n int, err error) {
// 使用 n、err;最后 return 即可
return
}
适用场景:
- 返回值语义需要文档化(
quot、rem) defer需要读取/修改命名结果(例如统一包装错误)
滥用代价:
- 作用域变大,中途赋值难追踪
- 裸
return在长函数里可读性差
[!tip] 命名结果的纪律 短函数、需要
defer改返回值时用命名结果;长业务函数优先显式return a, b。
可变参数(variadic)
func Println(a ...any) (n int, err error) // 标准库形态
模型:
...T只能出现在参数列表最后。- 函数体内
a的类型是[]T。 - 调用:
f(1,2,3)或f(slice...)。 - 传入的是切片描述符(及元素可见窗口),不是“引用传递魔法”。
参数传递:永远是值复制
Go 没有另一套“引用传参”调用约定。见官方 FAQ:Why are maps, slices, and channels references while arrays are values? 与 pass by value。
| 传入类型 | 复制的是什么 | 函数内修改对调用方的影响 |
|---|---|---|
int、struct 小值 | 完整副本 | 改参数不影响原变量 |
*T | 指针值(地址) | 可改指向的对象;改指针变量本身不影响调用方指针 |
[]T slice | 描述符 (ptr,len,cap) | 可改共享底层数组元素;append 可能换新数组 |
map[K]V | 运行时句柄 | 可增删改同一 map |
chan T | 通道描述符 | 同一通道上收发 |
func | 函数值 | 调用同一底层函数/闭包 |
细节与变异表见 值、指针与引用语义。
函数值(function values)
函数类型形如 func(int) (int, error):
- 可赋值给变量、作字段、作参数/返回值
- 零值是
nil;对nil函数值调用会 panic - 函数值可比较的规则很窄(通常只与
nil比较更稳妥)
var op func(int, int) int
op = func(a, b int) int { return a + b }
fmt.Println(op(1, 2))
闭包(brief)
函数字面量可捕获外层变量,形成闭包。捕获的是变量(可读写),不是截图式拷贝。循环变量、goroutine 与 defer 时机是常见陷阱——系统展开见 Go 闭包,此处只建立入口:函数值 + 环境。
defer 与函数的基本交互
defer 把调用注册到当前函数返回前执行,LIFO 顺序。与函数强相关的点:
- 参数在
defer注册时求值;闭包体则在执行时跑。 - 命名结果可在
defer中被修改,从而改变最终返回值。 defer常用于释放资源、统一日志、包装错误。
func read() (err error) {
f, err := open()
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
// ...
return nil
}
完整语义见 defer / panic / recover。
方法不是另一种函数语法宇宙
func (t T) M() {}
func (t *T) N() {}
方法 = 带接收者的函数。接收者同样按值复制(指针接收者复制的是指针值)。方法集如何决定接口满足,见 方法集与接收者。
边界与限制
- 没有默认参数、没有函数重载(靠不同名字或可选配置 struct)。
- 没有可选命名实参;常用 functional options 或配置对象。
- 多返回值不是一等元组类型,不能直接
var t = f()再当通用 pair 传递(除非自定义类型)。 - 可变参数只在末位;不能
f(...int, string)。 main/init有固定签名约束(见程序结构笔记)。- 递归受栈限制;极深递归应改迭代或显式栈。
- 跨包导出:首字母大写才导出(见 包与可见性)。
常见误区
[!warning] 误区 1:多返回值 = 异常机制
error是值。不检查err只是逻辑 bug,不是运行时自动抛出。
[!warning] 误区 2:命名返回值总是更优雅 长函数里裸
return会让读者无法就地看到返回了什么;优先可读性。
[!warning] 误区 3:
nums ...int是引用传递 函数内拿到的是切片描述符副本。改元素可能影响调用方底层数组;重新append到新数组则不一定。
[!warning] 误区 4:
defer闭包里的参数“当然是返回时的值”defer f(x)在注册时求值x;defer func(){ use(x) }()则读取执行时刻的x(捕获变量)。
[!warning] 误区 5:函数值随便比较 除与
nil外,函数值比较规则受限;不要用函数值当 map 键。
工程实践
- 签名先于实现:参数少而明确;配置多时用
Optionsstruct。 - 错误在末位:
do() (T, error);不要吞错。 - 小函数 + 早返回:配合 流程控制 的 guard clause。
- 可变参数用于“同质列表”:日志、选项拼接、
append风格 API;不要把业务必需参数做成 variadic 隐藏 arity。 - 需要改调用方状态时显式指针:不要假装“Go 自动引用”。
- 资源获取后立刻
defer释放,缩短泄漏窗口。 - 测试友好:纯函数易测;I/O 边界用接口注入(后续接口笔记)。
实验
在本地建 func_lab.go,逐项观察:
package main
import "fmt"
func addOne(n int) { n++ }
func addOnePtr(n *int) { *n++ }
func appendOne(s []int) {
s = append(s, 1)
_ = s
}
func appendOneRet(s []int) []int {
return append(s, 1)
}
func setMap(m map[string]int) { m["x"] = 1 }
func main() {
a := 1
addOne(a)
fmt.Println("int after addOne:", a) // 1
addOnePtr(&a)
fmt.Println("int after addOnePtr:", a) // 2
s := make([]int, 0, 2)
appendOne(s)
fmt.Println("slice after appendOne:", s, "len", len(s))
s = appendOneRet(s)
fmt.Println("slice after appendOneRet:", s)
m := map[string]int{}
setMap(m)
fmt.Println("map:", m)
// defer 参数求值时机
x := 10
defer fmt.Println("defer arg:", x)
x = 20
fmt.Println("before return, x:", x)
}
预期建立的直觉:
- 纯
int副本互不影响 - 指针可改原值
- 只
append不接返回值,调用方len可能不变 - map 句柄共享,写入可见
defer fmt.Println(x)打印的是注册时的10
总结
| 主题 | 记住 |
|---|---|
| 角色 | 签名即契约;多返回值承载结果与错误 |
| 传递 | 一律按值;描述符/指针可指向共享数据 |
| 命名结果 | 服务 defer 与文档化;勿滥用裸 return |
| 可变参数 | 末位 ...T,体内是 []T |
| 函数值 | 一等公民;nil 可调用即 panic |
| 闭包 | 捕获变量;细节见 go-closures |
| defer | 注册时参数求值;返回前 LIFO 执行 |
自测题
1. 下面代码打印什么?为什么?
func f(s []int) {
s[0] = 9
s = append(s, 2)
}
func main() {
s := []int{1}
f(s)
fmt.Println(s)
}
2. 命名返回值在什么场景下明显优于匿名返回值?
3. func sum(nums ...int) 与 func sum(nums []int) 在调用方式上差在哪里?
4. 为什么说“Go 没有引用传递”却仍能在函数里改 map?
答案
- 通常打印
[9](若cap不足,append换新数组不影响调用方长度;s[0]=9改的是共享底层数组元素)。关键:描述符被复制,元素存储可能共享;append赋给的是函数内局部描述符。 - 需要
defer根据成功/失败修改err、或返回值名本身就是 API 文档时。 - variadic 可写
sum(1,2,3)与sum(slice...);[]int版本必须显式传切片。 - 复制的是 map 句柄(指向运行时结构的值),不是“把整个哈希表深拷贝一份”;写入同一底层 map。见 FAQ pass-by-value。
依据与延伸
- Go Spec — Function types
- Go Spec — Function declarations
- Go Spec — Calls
- Go Spec — Passing arguments to
...parameters - Go Spec — Defer statements
- Go Spec — Return statements
- Effective Go — Functions
- Effective Go — Multiple return values
- Effective Go — Defer
- FAQ — Why pass by value?
- A Tour of Go — Functions
- A Tour of Go — Multiple results
- A Tour of Go — Named return values
- A Tour of Go — Function values
- A Tour of Go — Function closures