函数:请叫我“一等公民”
在 Go 语言中,函数是唯一一种基于特定输入,实现特定任务并可返回任务执行结果的代码块(Go 语言中的方法本质上也是函数)。如果忽略 Go 包在 Go 代码组织层面的作用,我们可以说 Go 程序就是一组函数的集合,实际上,我们日常的 Go 代码编写大多都集中在实现某个函数上。
一.Go 函数与函数声明
函数对应的英文单词是 Function,Function 这个单词原本是功能、职责的意思。编程语言使用 Function 这个单词,表示将一个大问题分解后而形成的、若干具有特定功能或职责的小任务,可以说十分贴切。函数代表的小任务可以在一个程序中被多次使用,甚至可以在不同程序中被使用,因此函数的出现也提升了整个程序界代码复用的水平。
在 Go 中,我们定义一个函数的最常用方式就是使用函数声明。我们以 Go 标准库 fmt 包提供的 Fprintf 函数为例,看一下一个普通 Go 函数的声明长啥样:
我们看到一个 Go 函数的声明由五部分组成,我们一个个来拆解一下。
第一部分是关键字 func,Go 函数声明必须以关键字 func 开始。
第二部分是函数名。函数名是指代函数定义的标识符,函数声明后,我们会通过函数名这个标识符来使用这个函数。在同一个 Go 包中,函数名应该是唯一的,并且它也遵守 Go 标识符的导出规则,也就是我们之前说的,首字母大写的函数名指代的函数是可以在包外使用的,小写的就只在包内可见。
第三部分是参数列表。参数列表中声明了我们将要在函数体中使用的各个参数。参数列表紧接在函数名的后面,并用一个括号包裹。它使用逗号作为参数间的分隔符,而且每个参数的参数名在前,参数类型在后,这和变量声明中变量名与类型的排列方式是一致的。
另外,Go 函数支持变长参数,也就是一个形式参数可以对应数量不定的实际参数。Fprintf 就是一个支持变长参数的函数,你可以看到它第三个形式参数 a 就是一个变长参数,而且变长参数与普通参数在声明时的不同点,就在于它会在类型前面增加了一个“…”符号。关于函数对变长参数的支持,我们在后面还会再讲。
第四部分是返回值列表。返回值承载了函数执行后要返回给调用者的结果,返回值列表声明了这些返回值的类型,返回值列表的位置紧接在参数列表后面,两者之间用一个空格隔开。不过,上图中比较特殊,Fprintf 函数的返回值列表不仅声明了返回值的类型,还声明了返回值的名称,这种返回值被称为具名返回值。多数情况下,我们不需要这么做,只需声明返回值的类型即可。
最后,放在一对大括号内的是函数体,函数的具体实现都放在这里。不过,函数声明中的函数体是可选的。如果没有函数体,说明这个函数可能是在 Go 语言之外实现的,比如使用汇编语言实现,然后通过链接器将实现与声明中的函数名链接到一起。
看到这里,你可能会问:同为声明,为啥函数声明与之前学过的变量声明在形式上差距这么大呢? 变量声明中的变量名、类型名和初值与上面的函数声明是怎么对应的呢?
为了让更好地理解函数声明,也给我们后续的讲解做铺垫,这里我们就横向对比一下,把上面的函数声明等价转换为变量声明的形式看看:
转换后的代码不仅和之前的函数声明是等价的,而且这也是完全合乎 Go 语法规则的代码。对照一下这两张图,你是不是有一种豁然开朗的感觉呢?这不就是在声明一个类型为函数类型的变量吗!
我们看到,函数声明中的函数名其实就是变量名,函数声明中的 func 关键字、参数列表和返回值列表共同构成了函数类型。而参数列表与返回值列表的组合也被称为函数签名,它是决定两个函数类型是否相同的决定因素。因此,函数类型也可以看成是由 func 关键字与函数签名组合而成的。
通常,在表述函数类型时,我们会省略函数签名参数列表中的参数名,以及返回值列表中的返回值变量名。比如上面 Fprintf 函数的函数类型是:
func(io.Writer, string, ...interface{}) (int, error)
这样,如果两个函数类型的函数签名是相同的,即便参数列表中的参数名,以及返回值列表中的返回值变量名都是不同的,那么这两个函数类型也是相同类型,比如下面两个函数类型:
func (a int, b string) (results []string, err error)
func (c int, d string) (sl []string, err error)
如果我们把这两个函数类型的参数名与返回值变量名省略,那它们都是func (int, string) ([]string, error),因此它们是相同的函数类型。
到这里,我们可以得到这样一个结论:每个函数声明所定义的函数,仅仅是对应的函数类型的一个实例,就像var a int = 13这个变量声明语句中 a 是 int 类型的一个实例一样。
我们把这两种形式都以最简化的样子表现出来,看下面代码:
s := T{} // 使用复合类型字面值对结构体类型T的变量进行显式初始化
f := func(){} // 使用变量声明形式的函数声明
这里,T{}被称为复合类型字面值,那么处于同样位置的 func(){}是什么呢?Go 语言也为它准备了一个名字,叫“函数字面值(Function Literal)”。我们可以看到,函数字面值由函数类型与函数体组成,它特别像一个没有函数名的函数声明,因此我们也叫它匿名函数。匿名函数在 Go 中用途很广
二.函数参数的那些事儿
函数参数列表中的参数,是函数声明的、用于函数体实现的局部变量。由于函数分为声明与使用两个阶段,在不同阶段,参数的称谓也有不同。在函数声明阶段,我们把参数列表中的参数叫做形式参数(Parameter,简称形参),在函数体中,我们使用的都是形参;而在函数实际调用时传入的参数被称为实际参数(Argument,简称实参)。为了便于直观理解,我绘制了这张示意图,你可以参考一下:
当我们实际调用函数的时候,实参会传递给函数,并和形式参数逐一绑定,编译器会根据各个形参的类型与数量,来检查传入的实参的类型与数量是否匹配。只有匹配,程序才能继续执行函数调用,否则编译器就会报错。
Go 语言中,函数参数传递采用是值传递的方式。所谓“值传递”,就是将实际参数在内存中的表示逐位拷贝(Bitwise Copy)到形式参数中。对于像整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,因此当这些类型作为实参类型时,值传递拷贝的就是它们自身,传递的开销也与它们自身的大小成正比。
但是像 string、切片、map 这些类型就不是了,它们的内存表示对应的是它们数据内容的“描述符”。当这些类型作为实参类型时,值传递拷贝的也是它们数据内容的“描述符”,不包括数据内容本身,所以这些类型传递的开销是固定的,与数据内容大小无关。这种只拷贝“描述符”,不拷贝实际数据内容的拷贝过程,也被称为“浅拷贝”。
不过函数参数的传递也有两个例外,当函数的形参为接口类型,或者形参是变长参数时,简单的值传递就不能满足要求了,这时 Go 编译器会介入:对于类型为接口类型的形参,Go 编译器会把传递的实参赋值给对应的接口类型形参;对于为变长参数的形参,Go 编译器会将零个或多个实参按一定形式转换为对应的变长形参。
那么这里,零个或多个传递给变长形式参数的实参,被 Go 编译器转换为何种形式了呢?我们通过下面示例代码来看一下:
func myAppend(sl []int, elems ...int) []int {
fmt.Printf("%T\n", elems) // []int
if len(elems) == 0 {
println("no elems to append")
return sl
}
sl = append(sl, elems...)
return sl
}
func main() {
sl := []int{1, 2, 3}
sl = myAppend(sl) // no elems to append
fmt.Println(sl) // [1 2 3]
sl = myAppend(sl, 4, 5, 6)
fmt.Println(sl) // [1 2 3 4 5 6]
}
我们重点看一下代码中的 myAppend 函数,这个函数基于 append,实现了向一个整型切片追加数据的功能。它支持变长参数,它的第二个形参 elems 就是一个变长参数。myAppend 函数通过 Printf 输出了变长参数的类型。执行这段代码,我们将看到变长参数 elems 的类型为[]int。
这也就说明,在 Go 中,变长参数实际上是通过切片来实现的。所以,我们在函数体中,就可以使用切片支持的所有操作来操作变长参数,这会大大简化了变长参数的使用复杂度。比如 myAppend 中,我们使用 len 函数就可以获取到传给变长参数的实参个数。
三.函数支持多返回值
和其他主流静态类型语言,比如 C、C++ 和 Java 不同,Go 函数支持多返回值。多返回值可以让函数将更多结果信息返回给它的调用者,Go 语言的错误处理机制很大程度就是建立在多返回值的机制之上的。
函数返回值列表从形式上看主要有三种:
func foo() // 无返回值
func foo() error // 仅有一个返回值
func foo() (int, string, error) // 有2或2个以上返回值
如果一个函数没有显式返回值,那么我们可以像第一种情况那样,在函数声明中省略返回值列表。而且,如果一个函数仅有一个返回值,那么通常我们在函数声明中,就不需要将返回值用括号括起来,如果是 2 个或 2 个以上的返回值,那我们还是需要用括号括起来的。
在函数声明的返回值列表中,我们通常会像上面例子那样,仅列举返回值的类型,但我们也可以像 fmt.Fprintf 函数的返回值列表那样,为每个返回值声明变量名,这种带有名字的返回值被称为具名返回值(Named Return Value)。这种具名返回值变量可以像函数体中声明的局部变量一样在函数体内使用。
那么在日常编码中,我们究竟该使用普通返回值形式,还是具名返回值形式呢?
Go 标准库以及大多数项目代码中的函数,都选择了使用普通的非具名返回值形式。但在一些特定场景下,具名返回值也会得到应用。比如,当函数使用 defer,而且还在 defer 函数中修改外部函数返回值时,具名返回值可以让代码显得更优雅清晰。
再比如,当函数的返回值个数较多时,每次显式使用 return 语句时都会接一长串返回值,这时,我们用具名返回值可以让函数实现的可读性更好一些,比如下面 Go 标准库 time 包中的 parseNanoseconds 函数就是这样:
// $GOROOT/src/time/format.go
func parseNanoseconds(value string, nbytes int) (ns int, rangeErrString string, err error) {
if !commaOrPeriod(value[0]) {
err = errBad
return
}
if ns, err = atoi(value[1:nbytes]); err != nil {
return
}
if ns < 0 || 1e9 <= ns {
rangeErrString = "fractional second"
return
}
scaleDigits := 10 - nbytes
for i := 0; i < scaleDigits; i++ {
ns *= 10
}
return
}
四.函数是“一等公民”
这个特点就是,函数在 Go 语言中属于“一等公民(First-Class Citizen)”。要知道,并不是在所有编程语言中函数都是“一等公民”。
那么,什么是编程语言的“一等公民”呢?关于这个名词,业界和教科书都没有给出精准的定义。我们这里可以引用一下 wiki 发明人、C2 站点作者沃德·坎宁安 (Ward Cunningham)对“一等公民”的解释:
如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。
基于这个解释,我们来看看 Go 语言的函数作为“一等公民”,表现出的各种行为特征。
特征一:Go 函数可以存储在变量中
按照沃德·坎宁安对一等公民的解释,身为一等公民的语法元素是可以存储在变量中的。其实,这点我们在前面理解函数声明时已经验证过了,这里我们再用例子简单说明一下:
var (
myFprintf = func(w io.Writer, format string, a ...interface{}) (int, error) {
return fmt.Fprintf(w, format, a...)
}
)
func main() {
fmt.Printf("%T\n", myFprintf) // func(io.Writer, string, ...interface {}) (int, error)
myFprintf(os.Stdout, "%s\n", "Hello, Go") // 输出Hello,Go
}
在这个例子中,我们把新创建的一个匿名函数赋值给了一个名为 myFprintf 的变量,通过这个变量,我们便可以调用刚刚定义的匿名函数。然后我们再通过 Printf 输出 myFprintf 变量的类型,也会发现结果与我们预期的函数类型是相符的。
特征二:支持在函数内创建并通过返回值返回
Go 函数不仅可以在函数外创建,还可以在函数内创建。而且由于函数可以存储在变量中,所以函数也可以在创建后,作为函数返回值返回。我们来看下面这个例子:
func setup(task string) func() {
println("do some setup stuff for", task)
return func() {
println("do some teardown stuff for", task)
}
}
func main() {
teardown := setup("demo")
defer teardown()
println("do some bussiness stuff")
}
这个例子,模拟了执行一些重要逻辑之前的上下文建立(setup),以及之后的上下文拆除(teardown)。在一些单元测试的代码中,我们也经常会在执行某些用例之前,建立此次执行的上下文(setup),并在这些用例执行后拆除上下文(teardown),避免这次执行对后续用例执行的干扰。
在这个例子中,我们在 setup 函数中创建了这次执行的上下文拆除函数,并通过返回值的形式,将这个拆除函数返回给了 setup 函数的调用者。setup 函数的调用者,在执行完对应这次执行上下文的重要逻辑后,再调用 setup 函数返回的拆除函数,就可以完成对上下文的拆除了。
从这段代码中我们也可以看到,setup 函数中创建的拆除函数也是一个匿名函数,但和前面我们看到的匿名函数有一个不同,这个不同就在于这个匿名函数使用了定义它的函数 setup 的局部变量 task,这样的匿名函数在 Go 中也被称为闭包(Closure)。
闭包本质上就是一个匿名函数或叫函数字面值,它们可以引用它的包裹函数,也就是创建它们的函数中定义的变量。然后,这些变量在包裹函数和匿名函数之间共享,只要闭包可以被访问,这些共享的变量就会继续存在。显然,Go 语言的闭包特性也是建立在“函数是一等公民”特性的基础上的,后面我们还会讲解涉及到闭包的内容。
特征三:作为参数传入函数
既然函数可以存储在变量中,也可以作为返回值返回,那我们可以理所当然地想到,把函数作为参数传入函数也是可行的。比如我们在日常编码时经常使用、标准库 time 包的 AfterFunc 函数,就是一个接受函数类型参数的典型例子。你可以看看下面这行代码,这里通过 AfterFunc 函数设置了一个 2 秒的定时器,并传入了时间到了后要执行的函数。这里传入的就是一个匿名函数:
time.AfterFunc(time.Second*2, func() { println("timer fired") })
特征四:拥有自己的类型
通过我们前面的讲解,你可以知道,作为一等公民的整型值拥有自己的类型 int,而这个整型值只是类型 int 的一个实例,其他作为一等公民的字符串值、布尔值等类型也都拥有自己类型。那函数呢?
在前面讲解函数声明时,我们曾得到过这样一个结论:每个函数声明定义的函数仅仅是对应的函数类型的一个实例,就像var a int = 13这个变量声明语句中的 a,只是 int 类型的一个实例一样。换句话说,每个函数都和整型值、字符串值等一等公民一样,拥有自己的类型,也就是我们讲过的函数类型。
我们甚至可以基于函数类型来自定义类型,就像基于整型、字符串类型等类型来自定义类型一样。下面代码中的 HandlerFunc、visitFunc 就是 Go 标准库中,基于函数类型进行自定义的类型:
// $GOROOT/src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)
// $GOROOT/src/sort/genzfunc.go
type visitFunc func(ast.Node) ast.Visitor
到这里,我们已经可以看到,Go 函数确实表现出了沃德·坎宁安诠释中“一等公民”的所有特征:Go 函数可以存储在变量中,可以在函数内创建并通过返回值返回,可以作为参数传递给其他函数,可以拥有自己的类型。通过这些分析,你也能感受到,和 C/C++ 等语言中的函数相比,作为“一等公民”的 Go 函数拥有难得的灵活性。
那么在实际生产中,我们怎么才能发挥出这种灵活性的最大效用,帮助我们写出更加优雅简洁的 Go 代码呢?接下来,我们就看几个这方面的例子。
五.函数“一等公民”特性的高效运用
应用一:函数类型的妙用
Go 函数是“一等公民”,也就是说,它拥有自己的类型。而且,整型、字符串型等所有类型都可以进行的操作,比如显式转型,也同样可以用在函数类型上面,也就是说,函数也可以被显式转型。并且,这样的转型在特定的领域具有奇妙的作用,一个最为典型的示例就是标准库 http 包中的 HandlerFunc 这个类型。我们来看一个使用了这个类型的例子:
func greeting(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Welcome, Gopher!\n")
}
func main() {
http.ListenAndServe(":8080", http.HandlerFunc(greeting))
}
这我们日常最常见的、用 Go 构建 Web Server 的例子。它的工作机制也很简单,就是当用户通过浏览器,或者类似 curl 这样的命令行工具,访问 Web server 的 8080 端口时,会收到“Welcome, Gopher!”这样的文字应答。我们在 09 讲曾讲过使用 http 包编写 web server 的方法,但当时我没有进一步讲解其中的原理,这一节课中我们就补上这一点。
我们先来看一下 http 包的函数 ListenAndServe 的源码:
// $GOROOT/src/net/http/server.go
func ListenAndServe(addr string, handler Handler) error {
server := &Server{Addr: addr, Handler: handler}
return server.ListenAndServe()
}
函数 ListenAndServe 会把来自客户端的 http 请求,交给它的第二个参数 handler 处理,而这里 handler 参数的类型 http.Handler,是一个自定义的接口类型,它的源码是这样的:
// $GOROOT/src/net/http/server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
这里,编译器提示我们,函数 greeting 还没有实现接口 Handler 的方法,无法将它赋值给 Handler 类型的参数。现在我们再回过头来看下代码,代码中我们也没有直接将 greeting 传给 ListenAndServe 函数,而是将http.HandlerFunc(greeting)作为参数传给了 ListenAndServe。那这个 http.HandlerFunc 究竟是什么呢?我们直接来看一下它的源码:
// $GOROOT/src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
通过它的源码我们看到,HandlerFunc 是一个基于函数类型定义的新类型,它的底层类型为函数类型func(ResponseWriter, *Request)。这个类型有一个方法 ServeHTTP,然后实现了 Handler 接口。也就是说http.HandlerFunc(greeting)这句代码的真正含义,是将函数 greeting 显式转换为 HandlerFunc 类型,后者实现了 Handler 接口,满足 ListenAndServe 函数第二个参数的要求。
另外,之所以http.HandlerFunc(greeting)这段代码可以通过编译器检查,正是因为 HandlerFunc 的底层类型是func(ResponseWriter, *Request),与 greeting 函数的类型是一致的,这和下面整型变量的显式转型原理也是一样的:
type MyInt int
var x int = 5
y := MyInt(x) // MyInt的底层类型为int,类比HandlerFunc的底层类型为func(ResponseWriter, *Request)
应用二:利用闭包简化函数调用
我们前面讲过,Go 闭包是在函数内部创建的匿名函数,这个匿名函数可以访问创建它的函数的参数与局部变量。我们可以利用闭包的这一特性来简化函数调用,这里我们看一个具体例子:
func times(x, y int) int {
return x * y
}
在上面的代码中,times 函数用来进行两个整型数的乘法。我们使用 times 函数的时候需要传入两个实参,比如:
times(2, 5) // 计算2 x 5
times(3, 5) // 计算3 x 5
times(4, 5) // 计算4 x 5
不过,有些场景存在一些高频使用的乘数,这个时候我们就没必要每次都传入这样的高频乘数了。那我们怎样能省去高频乘数的传入呢? 我们看看下面这个新函数 partialTimes:
func partialTimes(x int) func(int) int {
return func(y int) int {
return times(x, y)
}
}
这里,partialTimes 的返回值是一个接受单一参数的函数,这个由 partialTimes 函数生成的匿名函数,使用了 partialTimes 函数的参数 x。按照前面的定义,这个匿名函数就是一个闭包。partialTimes 实质上就是用来生成以 x 为固定乘数的、接受另外一个乘数作为参数的、闭包函数的函数。当程序调用 partialTimes(2) 时,partialTimes 实际上返回了一个调用 times(2,y) 的函数,这个过程的逻辑类似于下面代码:
timesTwo = func(y int) int {
return times(2, y)
}
这个时候,我们再看看如何使用 partialTimes,分别生成以 2、3、4 为固定高频乘数的乘法函数,以及这些生成的乘法函数的使用方法:
func main() {
timesTwo := partialTimes(2) // 以高频乘数2为固定乘数的乘法函数
timesThree := partialTimes(3) // 以高频乘数3为固定乘数的乘法函数
timesFour := partialTimes(4) // 以高频乘数4为固定乘数的乘法函数
fmt.Println(timesTwo(5)) // 10,等价于times(2, 5)
fmt.Println(timesTwo(6)) // 12,等价于times(2, 6)
fmt.Println(timesThree(5)) // 15,等价于times(3, 5)
fmt.Println(timesThree(6)) // 18,等价于times(3, 6)
fmt.Println(timesFour(5)) // 20,等价于times(4, 5)
fmt.Println(timesFour(6)) // 24,等价于times(4, 6)
}
你可以看到,通过 partialTimes,我们生成了三个带有固定乘数的函数。这样,我们在计算乘法时,就可以减少参数的重复输入。你看到这里可能会说,这种简化的程度十分有限啊!
你可以看到,通过 partialTimes,我们生成了三个带有固定乘数的函数。这样,我们在计算乘法时,就可以减少参数的重复输入。你看到这里可能会说,这种简化的程度十分有限啊!
版权声明:
作者:淘小欣
链接:https://blog.taoxiaoxin.club/219.html
来源:淘小欣的博客
文章版权归作者所有,未经允许请勿转载。

共有 0 条评论