函数

函数声明和调用已经在前面的文章中解释过了。当前这篇文章将介绍更多关于函数的概念和细节。

事实上,在Go中,函数是一种一等公民类型。换句话说,我们可以把函数当作值来使用。尽管Go是一门静态语言,但是Go函数的灵活性宛如甚至超越了很多动态语言。

Go中有一些内置函数,这些函数展示在builtinunsafe标准包中。内置函数和自定义函数有很多差别。这些差别将在下面逐一提及。

函数签名(function signature)和函数类型

刚已经提到了,在Go中,函数是一种一等公民类型。一个函数类型的字面表示形式由一个func关键字和一个函数签名字面表示表示形式组成。一个函数签名由一个输入参数类型列表和一个输出结果类型列表组成。参数名称和结果名称可以出现函数签名的字面表示形式中,但是它们并不重要。

func关键字可以出现在函数签名的字面形式中,也可以不出现。鉴于此,我们常常混淆使用函数类型(见下)和函数签名这两个概念。

下面是一个函数类型的字面形式:
func (a int, b string, c string) (x int, y int, z bool)
从前面的函数声明和调用一文中,我们了解到连续的同类型参数和结果可以声明在一块儿。所以上面的字面形式等价于:
func (a int, b, c string) (x, y int, z bool)

参数名称和结果名称并不重要,只要它们不重名即可。上面两个字面形式等价于下面这个:
func (x int, y, z string) (a, b int, c bool)

参数名和结果名可以是空标识符_。上面的字面形式等价于:
func (_ int, _, _ string) (_, _ int, _ bool)

函数参数列表中的参数名或者结果列表中的结果名可以同时省略(即匿名)。上面的字面形式等价于:
func (int, string, string) (int, int, bool) // 标准函数字面形式
func (a int, b string, c string) (int, int, bool)
func (x int, _ string, z string) (int, int, bool)
func (int, string, string) (x int, y int, z bool)
func (int, string, string) (a int, b int, _ bool)

所有上面列出的函数类型字面形式表示同一个(无名)函数类型。

参数列表必须用一对小括号()括起来,即使此列表为空。如果一个函数类型一个结果列表为空,则它可以在函数类型的字面形式中被省略掉。当一个结果列表含有最多一个结果,则此结果列表的字面形式在它不包含结果名称的时候可以不用括号()括起来。
// 这三个函数类型字面形式是等价的。
func () (x int)
func () (int)
func () int

// 这两个函数类型字面形式是等价的。
func (a int, b string) ()
func (a int, b string)

变长参数和变长参数函数类型

一个函数的最后一个参数可以是一个变长参数。一个函数可以最多有一个变长参数。一个变长参数的类型总为一个切片类型。变长参数在声明的时候必须在它的(切片)类型的元素类型前面前置三个点...,以示这是一个变长参数。两个变长函数类型的例子:
func (values ...int64) (sum int64)
func (sep string, tokens ...string) string

一个变长函数类型和一个非变长函数类型绝对不可能是同一个类型。

后面的一节将展示几个变长函数声明和使用的例子。

所有的函数类型都属于不可比较类型

Go类型系统概述一文已经提到了函数类型属于不可比较类型。但是,和映射值以及切片值类似,一个函数值可以和类型不确定的nil比较。(函数值将在本文最后一节介绍。)

因为函数类型属于不可比较类型,所以函数类型不可用做映射类型的键值类型。

函数原型(function prototype)

一个函数原型由一个函数名称和一个函数类型(或者说一个函数签名)组成。它的字面形式由一个func关键字、一个函数名和一个函数签名字面形式组成。

一个函数原型的例子:
func Double(n int) (result int)

换句话说,一个函数原型可以看作是一个不带函数体的函数声明;或者说一个函数声明由一个函数原型和一个函数体组成。

变长函数声明和变长函数调用

普通非变长函数的声明和调用已经在函数声明和调用一文中介绍过了。本节将介绍变长函数的声明和调用。

变长函数声明

变长函数声明和普通函数声明类似,只不过最后一个参数必须为变长参数。一个变长参数在函数体内将被视为一个切片。

// Sum返回所有输入实参的和。
func Sum(values ...int64) (sum int64) {
	// values的类型为[]int64。
	sum = 0
	for _, v := range values {
		sum += v
	}
	return
}

// Concat是一个低效的字符串拼接函数。
func Concat(sep string, tokens ...string) string {
	// tokens的类型为[]string。
	r := ""
	for i, t := range tokens {
		if i != 0 {
			r += sep
		}
		r += t
	}
	return r
}

从上面的两个变长参数函数声明可以看出,如果一个变长参数的类型部分为...T,则此变长参数的类型实际为[]T

事实上,在前面的文章中多次使用过的fmt标准库包中的PrintPrintlnPrintf函数均为变长参数函数。它们的声明大致如下:

func Print(a ...interface{}) (n int, err error)
func Printf(format string, a ...interface{}) (n int, err error)
func Println(a ...interface{}) (n int, err error)

这三个函数中的变长参数的类型均为[]interface{}。此类型的元素类型为interface{},这是一个接口类型。接口类型和接口值将在后面的接口一文中详述。

变长参数函数调用

在变长参数函数调用中,可以使用两种风格的方式将实参传递给类型为[]T的变长形参:
  1. 传递一个切片做为实参。此切片必须可以被赋值给类型为[]T的值(或者说此切片可以被隐式转换为类型[]T)。此实参切片后必须跟随三个点...
  2. 传递零个或者多个可以被隐式转换为T的实参(或者说这些实参可以赋值给类型为T的值)。这些实参将被添加入一个匿名的在运行时刻创建的类型为[]T的切片中,然后此切片将被传递给此函数调用。

注意,这两种风格的方式不可在同一个变长参数函数调用中混用。

下面这个例子展示了一些变长参数函数调用:
package main

import "fmt"

func Sum(values ...int64) (sum int64) {
	sum = 0
	for _, v := range values {
		sum += v
	}
	return
}

func main() {
	a0 := Sum()
	a1 := Sum(2)
	a3 := Sum(2, 3, 5)
	// 上面三行和下面三行是等价的。
	b0 := Sum([]int64{}...) // <=> Sum(nil...)
	b1 := Sum([]int64{2}...)
	b3 := Sum([]int64{2, 3, 5}...)
	fmt.Println(a0, a1, a3) // 0 2 10
	fmt.Println(b0, b1, b3) // 0 2 10
}

另一个展示了一些变长参数函数调用的例子:
package main

import "fmt"

func Concat(sep string, tokens ...string) (r string) {
	for i, t := range tokens {
		if i != 0 {
			r += sep
		}
		r += t
	}
	return
}

func main() {
	tokens := []string{"Go", "C", "Rust"}
	langsA := Concat(",", tokens...)        // 风格1
	langsB := Concat(",", "Go", "C","Rust") // 风格2
	fmt.Println(langsA == langsB)           // true
}

下面这个例子编译不通过,因为两种调用风格混用了。

package main

// 这两个函数的声明见前面几例。
func Sum(values ...int64) (sum int64) {......}
func Concat(sep string, tokens ...string) string {......}

func main() {
	// 下面两行报同样的错:实参数目太多了。
	_ = Sum(2, []int64{3, 5}...)
	_ = Concat(",", "Go", []string{"C", "Rust"}...)
}

更多关于函数声明和函数调用的事实

同一个包中可以同名的函数

一般来说,同一个包中声明的函数的名称不能重复,但有两个例外:
  1. 同一个包内可以声明若干个原型为func ()名称为init的函数
  2. 多个函数的名称可以被声明为空标识符_。这样声明的函数不可被调用。

某些函数调用是在编译时刻被估值的

大多数函数调用都是在运行时刻被估值的。但unsafe标准库包中的函数的调用都是在编译时刻估值的。另外,某些其它内置函数(比如lencap等)的调用在所传实参满足一定的条件的时候也将在编译时刻估值。详见在编译时刻估值的函数调用

所有的函数调用的传参均属于值复制

再重申一次,和赋值一样,传参也属于值(浅)复制。当一个值被复制时,只有它的直接部分被复制了。

不含函数体的函数声明

我们可以使用Go汇编(Go assembly)来实现一个Go函数。Go汇编代码放在后缀为.a的文件中。一个使用Go汇编实现的函数依旧必须在一个*.go文件中声明,但是它的声明必须不能含有函数体。换句话说,一个使用Go汇编实现的函数的声明中只含有它的原型。

某些有返回值的函数可以不必返回

如果一个函数有返回值,则它的函数体内的最后一条语句必须为一条终止语句。Go中有多种终止语句,return语句只是其中一种。所以一个有返回值的函数的体内不一定需要一个return语句。比如下面两个函数(它们均可编译通过):
func fa() int {
	a:
	goto a
}

func fb() bool {
	for{}
}

自定义函数的调用返回结果可以被舍弃,但是某些内置函数的调用返回结果不可被舍弃

自定义函数的调用结果都是可以被舍弃掉的。但是大多数内置函数(除了recovercopy)的调用结果都是不可被舍弃的。调用结果不可被舍弃的函数是不可以被用做延迟调用函数和协程起始函数的,比如append函数。

有返回值的函数的调用是一种表达式

一个有且只有一个返回值的函数的每个调用总可以被当成一个单值表达式使用。比如,它可以被内嵌在其它函数调用中当作实参使用,或者可以被当作其它表达式中的操作数使用。

如果一个有多个返回结果的函数的一个调用的返回结果没有被舍弃,则此调用可以当作一个多值表达式使用在两种场合:
  1. 此调用可以在一个赋值语句中当作源值来使用,但是它不能和其它源值掺和到一块。
  2. 此调用可以内嵌在另一个函数调用中当作实参来使用,但是它不能和其它实参掺和到一块。

一个例子:
package main

func HalfAndNegative(n int) (int, int) {
	return n/2, -n
}

func AddSub(a, b int) (int, int) {
	return a+b, a-b
}

func Dummy(values ...int) {}

func main() {
	// 这几行编译没问题。
	AddSub(HalfAndNegative(6))
	AddSub(AddSub(AddSub(7, 5)))
	AddSub(AddSub(HalfAndNegative(6)))
	Dummy(HalfAndNegative(6))
	_, _ = AddSub(7, 5)

	// 下面这几行编译不通过。
	/*
	_, _, _ = 6, AddSub(7, 5)
	Dummy(AddSub(7, 5), 9)
	Dummy(AddSub(7, 5), HalfAndNegative(6))
	*/
}

注意,在目前的标准编译器的实现中,有几个内置函数破坏了上述规则的普遍性

函数值

本文开头已经介绍了函数类型是Go中天然支持的一种类型。函数类型的值称为函数值。在字面上,函数类型的零值也使用预定义的nil来表示。

当我们声明了一个函数的时候,我们实际上同时声明了一个不可修改的函数值。此函数值用此函数的名称来标识。此函数值的类型的字面表示形式为此函数的原型刨去函数名部分。

注意:内置函数和init函数不可被用做函数值。

任何函数值都可以被当作普通声明函数来调用。调用一个nil函数来开启一个协程将产生一个致命的不可恢复的错误,此错误将使整个程序崩溃。在其它情况下调用一个nil函数将产生一个可恢复的恐慌。

值部一文,我们得知,当一个函数值被赋给另一个函数值后,这两个函数值将共享底层部分(内部的函数结构)。换句话说,这两个函数值表示的函数可以看作是同一个函数。调用它们的效果是相同的。

一个例子:
package main

import "fmt"

func Double(n int) int {
	return n + n
}

func Apply(n int, f func(int) int) int {
	return f(n) // f的类型为"func(int) int"
}

func main() {
	fmt.Printf("%T\n", Double) // func(int) int
	// Double = nil // error: Double是不可修改的

	var f func(n int) int // 默认值为nil
	f = Double
	g := Apply
	fmt.Printf("%T\n", g) // func(int, func(int) int) int

	fmt.Println(f(9))         // 18
	fmt.Println(g(6, Double)) // 12
	fmt.Println(Apply(6, f))  // 12
}

在上例中,g(6, Double)Apply(6, f)是等价的。

在实践中,我们常常将一个匿名函数赋值给一个函数类型的变量,从而可以在以后多次调用此匿名函数。

package main

import "fmt"

func main() {
	// 此函数返回一个函数类型的结果,亦即闭包(closure)。
	isMultipleOfX := func (x int) func(int) bool {
		return func(n int) bool {
			return n%x == 0
		}
	}

	var isMultipleOf3 = isMultipleOfX(3)
	var isMultipleOf5 = isMultipleOfX(5)
	fmt.Println(isMultipleOf3(6))  // true
	fmt.Println(isMultipleOf3(8))  // false
	fmt.Println(isMultipleOf5(10)) // true
	fmt.Println(isMultipleOf5(12)) // false

	isMultipleOf15 := func(n int) bool {
		return isMultipleOf3(n) && isMultipleOf5(n)
	}
	fmt.Println(isMultipleOf15(32)) // false
	fmt.Println(isMultipleOf15(60)) // true
}

Go中所有的函数都可以看作是闭包,这是Go语言编程常常给人一种和动态脚本语言一样灵活的一个重要原因。

遍历函数值(从Go 1.23开始)

从Go 1.23开始,底层类型为下列函数类型的函数值可以使用for-range循环来遍历。

// K和V是特定类型

func(yield func() bool)

func(yield func(V) bool)

func(yield func(K, V) bool)

这样的函数值称为推遍历器(push iterator),常简称为遍历器。

当使用一个for-range循环遍历这样的一个遍历器函数值时,这个遍历器函数值将被调用(一次)并被传入一个隐式创建的yield回调函数。此yield回调函数返回一个bool结果。当它返回false时,这个遍历器函数的调用应该(但并不强求一定)立即退出;否则(当此yield回调函数返回true时),这个遍历器函数应该继续执行,直到自然退出。

下面是一些使用遍历器函数的例子:

package main

import "fmt"

func Loop3(yield func() bool) {
	for range 3 {
		if (!yield()) {
			return
		}
	}
}

func OneDigitNumbers(onValue func(int) bool) {
	for i := range 10 {
		if (!onValue(i)) {
			return
		}
	}
}

func SquareLessThan50(onKeyValue func(int, int) bool) {
	for i := range 8 {
		if (!onKeyValue(i, i*i)) {
			return
		}
	}
}

func main() {
	var n = 0
	for range Loop3 {
		fmt.Print(n)
		n++
	}
	fmt.Println()
	// 输出:012
	
	for i := range OneDigitNumbers {
		fmt.Print(i)
	}
	fmt.Println()
	// 输出:0123456789
	
	for i, ii := range SquareLessThan50 {
		fmt.Printf("%v:%v ", i, ii)
	}
	fmt.Println()
	// 输出:0:0 1:1 2:4 3:9 4:16 5:25 6:36 7:49 
}

上面这些for-range循环和下面这些相应的函数调用是等价的:

func main() {
	var n = 0
	Loop3(func() bool {
		fmt.Print(n)
		n++
		return true
	})
	fmt.Println()
	
	OneDigitNumbers(func(i int) bool {
		fmt.Print(i)
		return true
	})
	fmt.Println()
	
	SquareLessThan50(func(i, ii int) bool {
		fmt.Printf("%v:%v ", i, ii)
		return true
	})
	fmt.Println()
}

是的,我们可以认为Go 1.23引入的遍历函数特性是一个语法糖。

请阅读这篇官方博客文章了解更多关于遍历器的内容。


目录↡

Go101.org网站内容包括Go编程各种相关知识(比如Go基础、Go优化、Go细节、Go实战、Go测验、Go工具等)。后续将不断有新的内容加入。敬请收藏关注期待。

本丛书微信公众号(联系方式一)名称为"Go 101"。二维码在网站首页。此公众号将时不时地发表一些Go语言相关的原创短文。各位如果感兴趣,可以搜索关注一下。

《Go语言101》系列丛书项目目前托管在Github上(联系方式二)。欢迎各位在此项目中通过提交bug和PR的方式来改进完善《Go语言101》丛书中的各篇文章。我们可以在项目目录下运行go run .来浏览和确认各种改动。

本书的twitter帐号为@Golang_101(联系方式三)。玩推的Go友可以适当关注。

你或许对本书作者老貘开发的一些App感兴趣。

The English version of this book is here.
赞赏
(《Go语言101》系列丛书由老貘从2016年7月开始编写。目前此系列丛书仍在不断改进和增容中。你的赞赏是本系列丛书和此Go101.org网站不断增容和维护的动力。)

目录: