Skip to content

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
}

参数列表里 aintb 也是 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
}

countmakeCounter 返回后仍然存活,因为返回的匿名函数还在引用它。闭包适合需要维护状态的回调场景,但也容易因生命周期不清导致内存泄漏。

六、函数作为参数

把函数作为参数传入,能实现策略模式。比如定义一个检查函数类型,主流程只负责遍历目标,具体检查逻辑由调用方决定:

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) 后,参数名自带说明,顺序也不重要了。