Appearance
09|函数
运维和后端代码里,同样逻辑的片段经常要在多处复用。把"检查磁盘""读取配置""发送告警"这些动作收成函数后,主流程只负责串起调用顺序,具体实现细节留在各自的函数里。Go 的函数支持多返回值,最常见的模式是"结果 + 错误",这让错误处理从函数的副作用变成了显式契约。
一、函数定义
函数用 func 关键字定义,参数和返回值都带类型:
go
package main
import "fmt"
func add(a int, b int) int {
return a + b
}
func main() {
result := add(10, 20)
fmt.Println(result) // 30
}连续同类型的参数可以合并写法:
go
func add(a, b int) int {
return a + b
}参数列表里 a 是 int,b 也是 int,只在最后一个参数后写一次类型即可。
二、多返回值
Go 函数可以返回多个值。最典型的用法是返回"结果 + 错误":
go
package main
import (
"fmt"
"os"
)
func readConfig(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
func main() {
content, err := readConfig("/etc/hosts")
if err != nil {
fmt.Println("read failed:", err)
return
}
fmt.Println(content)
}调用方必须显式处理 err。忽略错误时,需要用 _ 占位,但这通常是危险信号——_ 意味着"我知道有错误但选择不处理",只有在确实不关心错误结果时才用。
多返回值的顺序约定是:正常结果在前,错误在后。标准库和第三方包都遵循这个约定,自定义函数也应该保持一致。
三、命名返回值
返回值可以预先命名,在函数内部直接赋值,最后裸 return 返回:
go
func divide(a, b float64) (result float64, err error) {
if b == 0 {
err = fmt.Errorf("division by zero")
return // 裸 return,返回 result=0, err=error
}
result = a / b
return
}命名返回值在长函数里能减少 return 语句的重复,但也容易让代码变模糊。短函数用裸 return 尚可;复杂函数里建议显式写出返回值,避免读者需要上下翻找才能知道返回了什么。
四、变参函数
参数数量不固定时,用 ... 表示变参:
go
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(sum(1, 2, 3)) // 6
fmt.Println(sum()) // 0
}变参在函数内部被当成切片处理。已有的切片也可以直接传入,需要在后面加 ...:
go
values := []int{1, 2, 3}
fmt.Println(sum(values...)) // 6变参必须放在参数列表的最后。一个函数只能有一个变参。
五、函数值
Go 里函数是一等公民,可以赋值给变量、作为参数传递、作为返回值:
go
func main() {
check := func(host string) bool {
return host != ""
}
fmt.Println(check("web01")) // true
}这种没有名字的函数叫匿名函数,常用于回调和闭包。闭包能捕获外部变量:
go
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
counter := makeCounter()
fmt.Println(counter()) // 1
fmt.Println(counter()) // 2
}count 在 makeCounter 返回后仍然存活,因为返回的匿名函数还在引用它。闭包适合需要维护状态的回调场景,但也容易因生命周期不清导致内存泄漏。
六、函数作为参数
把函数作为参数传入,能实现策略模式。比如定义一个检查函数类型,主流程只负责遍历目标,具体检查逻辑由调用方决定:
go
type CheckFunc func(string) (bool, error)
func runChecks(targets []string, check CheckFunc) []error {
var errs []error
for _, target := range targets {
if _, err := check(target); err != nil {
errs = append(errs, err)
}
}
return errs
}七、递归
函数调用自身就是递归。递归适合处理树形结构,比如遍历目录。Go 没有尾递归优化,递归过深会导致栈溢出。处理大规模数据时,优先用循环替代递归。
go
func factorial(n int) int {
if n <= 1 {
return 1
}
return n * factorial(n-1)
}八、常见错误
忽略了第二个返回值
go
content, _ := readConfig("/etc/hosts") // 危险:错误被忽略配置文件不存在时,content 是空字符串,后续逻辑可能把空字符串当成正常配置处理,导致更难排查的故障。
返回值顺序写反
多返回值函数如果赋值时顺序写反,编译器不会报错——只要类型匹配就能编译通过:
go
err, content := readConfig("/etc/hosts") // 错误:err 是 string,content 是 error这类错误在运行时会以奇怪的方式暴露,比如对字符串调用 Error() 方法时 panic。
命名返回值的坑
命名返回值在 defer 里修改时,会影响最终返回结果:
go
func check() (result string, err error) {
defer func() {
if err != nil {
result = "failed" // defer 修改了命名返回值
}
}()
// ...
return
}这种写法虽然有效,但增加了理解成本。除非有明确的日志或清理需求,否则不建议在 defer 里修改命名返回值。
九、函数设计建议
| 原则 | 说明 |
|---|---|
| 单一职责 | 一个函数只做一件事,名字能直接描述它的行为 |
| 错误必返 | 可能出错的操作返回 error,不吞异常 |
| 参数越少越好 | 超过 4 个参数时考虑收成结构体 |
| 返回值顺序一致 | 结果在前,错误在后 |
| 避免全局状态 | 需要状态时用结构体或闭包,不用全局变量 |
参数过多的函数读起来费力。比如 checkHTTP(url, timeout, retries, backoff, headers) 有 5 个参数,调用方很容易搞错顺序。改成 checkHTTP(cfg HTTPConfig) 后,参数名自带说明,顺序也不重要了。