Go 函数

Go 函数的声明、多返回值、命名结果、可变参数、defer 交互、函数值与闭包入口,以及参数按值复制描述符的统一模型。

#type / concept #status / growing #tech / dev #resource / go

[!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

结合场景再看四个关注点

  1. 命名结果服务 defer,不是为了炫技
    splitEvenly 失败时 defer 能根据 err != nil 做统一清理。

  2. 函数是值
    op := splitEvenly 后可以像普通变量一样传递、替换策略。

  3. ...T 在体内是 []T,调用侧用 slice... 展开
    对应报表“样本个数不固定”的需求。

  4. 参数始终按值复制
    复制的可能是完整数据,也可能是指向共享底层结构的描述符(见语义笔记)。

核心概念与准确模型

声明形态

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)

规则要点:

  1. 返回值是有序元组,类型固定。
  2. 调用方可用 _ 丢弃不需要的结果。
  3. 多返回值不能当作单一“元组类型”随意赋值给一个变量(除非再包一层 struct)。
  4. 工程上最常见约定是最后一个返回值表示错误(惯例,不是语法强制)。

命名结果(named results)

命名结果在函数签名中预先声明返回变量,零值初始化,可用裸 return

func parse(s string) (n int, err error) {
	// 使用 n、err;最后 return 即可
	return
}

适用场景:

  • 返回值语义需要文档化(quotrem
  • defer 需要读取/修改命名结果(例如统一包装错误)

滥用代价:

  • 作用域变大,中途赋值难追踪
  • return 在长函数里可读性差

[!tip] 命名结果的纪律 短函数、需要 defer 改返回值时用命名结果;长业务函数优先显式 return a, b

可变参数(variadic)

func Println(a ...any) (n int, err error) // 标准库形态

模型:

  1. ...T 只能出现在参数列表最后
  2. 函数体内 a 的类型是 []T
  3. 调用:f(1,2,3)f(slice...)
  4. 传入的是切片描述符(及元素可见窗口),不是“引用传递魔法”。

参数传递:永远是值复制

Go 没有另一套“引用传参”调用约定。见官方 FAQ:Why are maps, slices, and channels references while arrays are values?pass by value

传入类型复制的是什么函数内修改对调用方的影响
intstruct 小值完整副本改参数不影响原变量
*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 顺序。与函数强相关的点:

  1. 参数在 defer 注册时求值;闭包体则在执行时跑。
  2. 命名结果可在 defer 中被修改,从而改变最终返回值。
  3. 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() {}

方法 = 带接收者的函数。接收者同样按值复制(指针接收者复制的是指针值)。方法集如何决定接口满足,见 方法集与接收者

边界与限制

  1. 没有默认参数、没有函数重载(靠不同名字或可选配置 struct)。
  2. 没有可选命名实参;常用 functional options 或配置对象。
  3. 多返回值不是一等元组类型,不能直接 var t = f() 再当通用 pair 传递(除非自定义类型)。
  4. 可变参数只在末位;不能 f(...int, string)
  5. main / init 有固定签名约束(见程序结构笔记)。
  6. 递归受栈限制;极深递归应改迭代或显式栈。
  7. 跨包导出:首字母大写才导出(见 包与可见性)。

常见误区

[!warning] 误区 1:多返回值 = 异常机制 error 是值。不检查 err 只是逻辑 bug,不是运行时自动抛出。

[!warning] 误区 2:命名返回值总是更优雅 长函数里裸 return 会让读者无法就地看到返回了什么;优先可读性。

[!warning] 误区 3:nums ...int 是引用传递 函数内拿到的是切片描述符副本。改元素可能影响调用方底层数组;重新 append 到新数组则不一定。

[!warning] 误区 4:defer 闭包里的参数“当然是返回时的值” defer f(x) 在注册时求值 xdefer func(){ use(x) }() 则读取执行时刻的 x(捕获变量)。

[!warning] 误区 5:函数值随便比较 除与 nil 外,函数值比较规则受限;不要用函数值当 map 键。

工程实践

  1. 签名先于实现:参数少而明确;配置多时用 Options struct。
  2. 错误在末位do() (T, error);不要吞错。
  3. 小函数 + 早返回:配合 流程控制 的 guard clause。
  4. 可变参数用于“同质列表”:日志、选项拼接、append 风格 API;不要把业务必需参数做成 variadic 隐藏 arity。
  5. 需要改调用方状态时显式指针:不要假装“Go 自动引用”。
  6. 资源获取后立刻 defer 释放,缩短泄漏窗口。
  7. 测试友好:纯函数易测;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?

答案
  1. 通常打印 [9](若 cap 不足,append 换新数组不影响调用方长度;s[0]=9 改的是共享底层数组元素)。关键:描述符被复制,元素存储可能共享;append 赋给的是函数内局部描述符。
  2. 需要 defer 根据成功/失败修改 err、或返回值名本身就是 API 文档时。
  3. variadic 可写 sum(1,2,3)sum(slice...)[]int 版本必须显式传切片。
  4. 复制的是 map 句柄(指向运行时结构的值),不是“把整个哈希表深拷贝一份”;写入同一底层 map。见 FAQ pass-by-value。

依据与延伸

创建于 2026/6/20 更新于 2026/7/15