go基础12-函数:请叫我“一等公民”

117 阅读10分钟

在 Go 语言中,函数是唯一一种基于特定输入,实现特定任务并可返回任务执行结果的代码块(Go 语言中的方法本质上也是函数)。如果忽略 Go 包在 Go 代码组织层面的作用,我们可以说 Go 程序就是一组函数的集合,实际上,我们日常的 Go 代码编写大多都集中在实现某个函数上。

Go 函数与函数声明

image.png

第一部分是关键字 func,Go 函数声明必须以关键字 func 开始。

第二部分是函数名。函数名是指代函数定义的标识符,函数声明后,我们会通过函数名这个标识符来使用这个函数。在同一个 Go 包中,函数名应该是唯一的,并且它也遵守 Go 标识符的导出规则,也就是我们之前说的,首字母大写的函数名指代的函数是可以在包外使用的,小写的就只在包内可见。

第二部分是函数名。函数名是指代函数定义的标识符,函数声明后,我们会通过函数名这个标识符来使用这个函数。在同一个 Go 包中,函数名应该是唯一的,并且它也遵守 Go 标识符的导出规则,也就是我们之前说的,首字母大写的函数名指代的函数是可以在包外使用的,小写的就只在包内可见。

另外,Go 函数支持变长参数,也就是一个形式参数可以对应数量不定的实际参数。Fprintf 就是一个支持变长参数的函数,你可以看到它第三个形式参数 a 就是一个变长参数,而且变长参数与普通参数在声明时的不同点,就在于它会在类型前面增加了一个“”符号。关于函数对变长参数的支持,我们在后面还会再讲。

第四部分是返回值列表。返回值承载了函数执行后要返回给调用者的结果,返回值列表声明了这些返回值的类型,返回值列表的位置紧接在参数列表后面,两者之间用一个空格隔开。不过,上图中比较特殊,Fprintf 函数的返回值列表不仅声明了返回值的类型,还声明了返回值的名称,这种返回值被称为具名返回值。多数情况下,我们不需要这么做,只需声明返回值的类型即可。

最后,放在一对大括号内的是函数体,函数的具体实现都放在这里。不过,函数声明中的函数体是可选的。如果没有函数体,说明这个函数可能是在 Go 语言之外实现的,比如使用汇编语言实现,然后通过链接器将实现与声明中的函数名链接到一起。没有函数体的函数声明是更高级的话题了,你感兴趣可以自己去了解一下,我们这里还是先打好基础。

image.png 转换后的代码不仅和之前的函数声明是等价的,而且这也是完全合乎 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)

到这里,我们可以得到这样一个结论:每个函数声明所定义的函数,仅仅是对应的函数类型的一个实例,就像var a int = 13这个变量声明语句中 a 是 int 类型的一个实例一样。

函数参数的那些事儿

函数参数列表中的参数,是函数声明的、用于函数体实现的局部变量。由于函数分为声明与使用两个阶段,在不同阶段,参数的称谓也有不同。在函数声明阶段,我们把参数列表中的参数叫做形式参数(Parameter,简称形参),在函数体中,我们使用的都是形参;而在函数实际调用时传入的参数被称为实际参数(Argument,简称实参)。为了便于直观理解,我绘制了这张示意图,你可以参考一下:

image.png

Go 语言中,函数参数传递采用是值传递的方式。所谓“值传递”,就是将实际参数在内存中的表示逐位拷贝(Bitwise Copy)到形式参数中。对于像整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,因此当这些类型作为实参类型时,值传递拷贝的就是它们自身,传递的开销也与它们自身的大小成正比。

但是像 string、切片、map 这些类型就不是了,它们的内存表示对应的是它们数据内容的“描述符”。当这些类型作为实参类型时,值传递拷贝的也是它们数据内容的“描述符”,不包括数据内容本身,所以这些类型传递的开销是固定的,与数据内容大小无关。这种只拷贝“描述符”,不拷贝实际数据内容的拷贝过程,也被称为“浅拷贝”。

不过函数参数的传递也有两个例外,当函数的形参为接口类型,或者形参是变长参数时,简单的值传递就不能满足要求了,这时 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]
}

也就说明,在 Go 中,变长参数实际上是通过切片来实现的。所以,我们在函数体中,就可以使用切片支持的所有操作来操作变长参数,这会大大简化了变长参数的使用复杂度。比如 myAppend 中,我们使用 len 函数就可以获取到传给变长参数的实参个数。

函数支持多返回值

函数是“一等公民”

特征一: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
}

特征二:支持在函数内创建并通过返回值返回。

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 函数中创建的拆除函数也是一个匿名函数,但和前面我们看到的匿名函数有一个不同,这个不同就在于这个匿名函数使用了定义它的函数 setup 的局部变量 task,这样的匿名函数在 Go 中也被称为闭包(Closure)。

闭包本质上就是一个匿名函数或叫函数字面值,它们可以引用它的包裹函数,也就是创建它们的函数中定义的变量。然后,这些变量在包裹函数和匿名函数之间共享,只要闭包可以被访问,这些共享的变量就会继续存在。显然,Go 语言的闭包特性也是建立在“函数是一等公民”特性的基础上的,后面我们还会讲解涉及到闭包的内容。

特征三:作为参数传入函数。

特征四:拥有自己的类型。

// $GOROOT/src/net/http/server.go
type HandlerFunc func(ResponseWriter, *Request)

// $GOROOT/src/sort/genzfunc.go
type visitFunc func(ast.Node) ast.Visitor

函数“一等公民”特性的高效运用

应用一:函数类型的妙用

func greeting(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Welcome, Gopher!\n")
}                    

func main() {
    http.ListenAndServe(":8080", http.HandlerFunc(greeting))
}

我们先来看一下 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)
}

我们还没有系统学习接口类型,你现在只要知道接口是一组方法的集合就好了。这个接口只有一个方法 ServeHTTP,他的函数类型是func(http.ResponseWriter, *http.Request)。这和我们自己定义的 http 请求处理函数 greeting 的类型是一致的,但是我们没法直接将 greeting 作为参数值传入,否则编译器会报错:

func(http.ResponseWriter, *http.Request) does not implement http.Handler (missing ServeHTTP method)

这里,编译器提示我们,函数 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)

应用二:利用闭包简化函数调用。

func times(x, y int) int {
  return x * y
}
times(2, 5) // 计算2 x 5
times(3, 5) // 计算3 x 5
times(4, 5) // 计算4 x 5
func partialTimes(x int) func(int) int {
  return func(y int) int {
    return times(x, y)
  }
}
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)
}

不是的。这里我只是举了一个比较好理解的简单例子,在那些动辄就有 5 个以上参数的复杂函数中,减少参数的重复输入给开发人员带去的收益,可要比这个简单的例子大得多。

此文章为3月Day12学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。