函数

寒江蓑笠翁大约 16 分钟

函数

在Go中,函数是一等公民,函数是Go最基础的组成部分,也是Go的核心。


声明

函数的声明格式如下

func 函数名([参数列表]) [返回值] {
	函数体
}

声明函数有两种办法,一种是通过func关键字直接声明,另一种就是通过var关键字来声明,如下所示

func sum(a int, b int) int {
	return a + b
}

var sum = func(a int, b int) int {
	return a + b
}

函数签名由函数名称,参数列表,返回值组成,下面是一个完整的例子,函数名称为Sum,有两个int类型的参数ab,返回值类型为int

func Sum(a int, b int) int {
   return a + b
}

还有一个非常重要的点,即Go中的函数不支持重载,像下面的代码就无法通过编译

type Person struct {
	Name    string
	Age     int
	Address string
	Salary  float64
}

func NewPerson(name string, age int, address string, salary float64) *Person {
	return &Person{Name: name, Age: age, Address: address, Salary: salary}
}

func NewPerson(name string) *Person {
	return &Person{Name: name}
}

Go的理念便是:如果签名不一样那就是两个完全不同的函数,那么就不应该取一样的名字,函数重载会让代码变得混淆和难以理解。这种理念是否正确见仁见智,至少在Go中你可以仅通过函数名就知道它是干什么的,而不需要去找它到底是哪一个重载。


参数

Go中的参数名可以不带名称,一般这种是在接口或函数类型声明时才会用到,不过为了可读性一般还是建议尽量给参数加上名称

type ExWriter func(io.Writer) error 

type Writer interface {
	ExWrite([]byte) (int, error)
}

对于类型相同的参数而言,可以只需要声明一次类型,不过条件是它们必须相邻

func Log(format string, a1, a2 any) {
	...
}

变长参数可以接收0个或多个值,必须声明在参数列表的末尾,最典型的例子就是fmt.Printf函数。

func Printf(format string, a ...any) (n int, err error) {
	return Fprintf(os.Stdout, format, a...)
}

值得一提的是,Go中的函数参数是传值传递,即在传递参数时会拷贝实参的值。如果你觉得在传递切片或map时会复制大量的内存,我可以告诉你大可不必担心,因为这两个数据结构本质上都是指针。


返回值

下面是一个简单的函数返回值的例子,Sum函数返回一个int类型的值。

func Sum(a, b int) int {
   return a + b
}

当函数没有返回值时,不需要void,不带返回值即可。

func ErrPrintf(format string, a ...any) {
	_, _ = fmt.Fprintf(os.Stderr, format, a...)
}

Go允许函数有多个返回值,此时就需要用括号将返回值围起来。

func Div(a, b float64) (float64, error) {
	if a == 0 {
		return math.NaN(), errors.New("0不能作为被除数")
	}
	return a / b, nil
}

Go也支持具名返回值,不能与参数名重复,使用具名返回值时,return关键字可以不需要指定返回哪些值。

func Sum(a, b int) (ans int) {
	ans = a + b
	return
}

和参数一样,当有多个同类型的具名返回值时,可以省略掉重复的类型声明

func SumAndMul(a, b int) (c, d int) {
	c = a + b
	d = a * b
	return
}

不管具名返回值如何声明,永远都是以return关键字后的值为最高优先级。

func SumAndMul(a, b int) (c, d int) {
	c = a + b
	d = a * b
    // c,d将不会被返回
	return a + b, a * b
}

匿名函数

匿名函数就是没有签名的函数,例如下面的函数func(a, b int) int,它没有名称,所以我们只能在它的函数体后紧跟括号来进行调用。

func main() {
   func(a, b int) int {
      return a + b
   }(1, 2)
}

在调用一个函数时,当它的参数是一个函数类型时,这时名称不再重要,就可以直接传递一个匿名函数,如下所示

type Person struct {
	Name   string
	Age    int
	Salary float64
}

func main() {
	people := []Person{
		{Name: "Alice", Age: 25, Salary: 5000.0},
		{Name: "Bob", Age: 30, Salary: 6000.0},
		{Name: "Charlie", Age: 28, Salary: 5500.0},
	}

	slices.SortFunc(people, func(p1 Person, p2 Person) int {
		if p1.Name > p2.Name {
			return 1
		} else if p1.Name < p2.Name {
			return -1
		}
		return 0
	})
}

这是一个自定义排序规则的例子,slices.SortFunc接受两个参数,一个是切片,另一个就是比较函数,不考虑复用的话,我们就可以直接传递匿名函数。


闭包

闭包(Closure)这一概念,在一些语言中又被称为Lamda表达式,与匿名函数一起使用,闭包 = 函数 + 环境引用吗,看下面一个例子:

func main() {
	grow := Exp(2)
	for i := range 10 {
		fmt.Printf("2^%d=%d\n", i, grow())
	}
}

func Exp(n int) func() int {
	e := 1
	return func() int {
		temp := e
		e *= n
		return temp
	}
}

输出

2^0=1
2^1=2
2^2=4
2^3=8
2^4=16
2^5=32
2^6=64
2^7=128
2^8=256
2^9=512

Exp函数的返回值是一个函数,这里将称成为grow函数,每将它调用一次,变量e就会以指数级增长一次。grow函数引用了Exp函数的两个变量:en,它们诞生在Exp函数的作用域内,在正常情况下随着Exp函数的调用结束,这些变量的内存会随着出栈而被回收。但是由于grow函数引用了它们,所以它们无法被回收,而是逃逸到了堆上,即使Exp函数的生命周期已经结束了,但变量en的生命周期并没有结束,在grow函数内还能直接修改这两个变量,grow函数就是一个闭包函数。


利用闭包,可以非常简单的实现一个求费波那契数列的函数,代码如下

func main() {
    // 10个斐波那契数
	fib := Fib(10)
	for n, next := fib(); next; n, next = fib() {
		fmt.Println(n)
	}
}

func Fib(n int) func() (int, bool) {
	a, b, c := 1, 1, 2
	i := 0
	return func() (int, bool) {
		if i >= n {
			return 0, false
		} else if i < 2 {
			f := i
			i++
			return f, true
		}

		a, b = b, c
		c = a + b
		i++

		return a, true
	}
}

输出为

0
1
1
2
3
5
8
13
21
34

延迟调用

defer关键字可以使得一个函数延迟一段时间调用,在函数返回之前这些defer描述的函数最后都会被逐个执行,看下面一个例子

func main() {
	Do()
}

func Do() {
	defer func() {
		fmt.Println("1")
	}()
	fmt.Println("2")
}

输出

2
1

因为defer是在函数返回前执行的,你也可以在defer中修改函数的返回值

func main() {
	fmt.Println(sum(3, 5))
}

func sum(a, b int) (s int) {
	defer func() {
		s -= 10
	}()
	s = a + b
	return
}

当有多个defer描述的函数时,就会像栈一样先进后出的顺序执行。

func main() {
	fmt.Println(0)
	Do()
}

func Do() {
	defer fmt.Println(1)
	fmt.Println(2)
	defer fmt.Println(3)
	defer fmt.Println(4)
	fmt.Println(5)
}
0
2
5
4
3
1

延迟调用通常用于释放文件资源,关闭网络连接等操作,还有一个用法是捕获panic,不过这是错误处理一节中才会涉及到的东西。

循环

虽然没有明令禁止,一般建议不要在for循环中使用defer,如下所示

func main() {
	n := 5
	for i := range n {
		defer fmt.Println(i)
	}
}

输出如下

4
3
2
1
0

这段代码结果是正确的,但过程也许不对。在Go中,每创建一个defer,就需要在当前协程申请一片内存空间。假设在上面例子中不是简单的for n循环,而是一个较为复杂的数据处理流程,当外部请求数突然激增时,那么在短时间内就会创建大量的defer,在循环次数很大或次数不确定时,就可能会导致内存占用突然暴涨,这种我们一般称之为内存泄漏。


参数预计算

对于延迟调用有一些反直觉的细节,比如下面这个例子

func main() {
	defer fmt.Println(Fn1())
	fmt.Println("3")
}

func Fn1() int {
	fmt.Println("2")
	return 1
}

这个坑还是非常隐晦的,笔者以前就因为这个坑,半天排查不出来是什么原因,可以猜猜输出是什么,答案如下

2
3
1

可能很多人认为是下面这种输出

3
2
1

按照使用者的初衷来说,fmt.Println(Fn1())这部分应该是希望它们在函数体执行结束后再执行,fmt.Println确实是最后执行的,但Fn1()是在意料之外的,下面这个例子的情况就更加明显了。

func main() {
	var a, b int
	a = 1
	b = 2
	defer fmt.Println(sum(a, b))
	a = 3
	b = 4
}

func sum(a, b int) int {
	return a + b
}

它的输出一定是3而不是7,如果使用闭包而不是延迟调用,结果又不一样了

func main() {
	var a, b int
	a = 1
	b = 2
	f := func() {
		fmt.Println(sum(a, b))
	}
	a = 3
	b = 4
	f()
}

闭包的输出是7,那如果把延迟调用和闭包结合起来呢

func main() {
	var a, b int
	a = 1
	b = 2
	defer func() {
		fmt.Println(sum(a, b))
	}()
	a = 3
	b = 4
}

这次就正常了,输出的是7。下面再改一下,没有闭包了

func main() {
	var a, b int
	a = 1
	b = 2
	defer func(num int) {
		fmt.Println(num)
	}(sum(a, b))
	a = 3
	b = 4
}

输出又变回3了。通过对比上面几个例子可以发现这段代码

defer fmt.Println(sum(a,b))

其实等价于

defer fmt.Println(3)

go不会等到最后才去调用sum函数,sum函数早在延迟调用被执行以前就被调用了,并作为参数传递了fmt.Println。总结就是,对于defer直接作用的函数而言,它的参数是会被预计算的,这也就导致了第一个例子中的奇怪现象,对于这种情况,尤其是在延迟调用中将函数返回值作为参数的情况尤其需要注意。