Go函数介绍与一等公民

2023-10-18 12:22:27 浏览数 (2)

Go函数介绍与一等公民

函数对应的英文单词是 FunctionFunction 这个单词原本是功能、职责的意思。编程语言使用 Function 这个单词,表示将一个大问题分解后而形成的、若干具有特定功能或职责的小任务,可以说十分贴切。函数代表的小任务可以在一个程序中被多次使用,甚至可以在不同程序中被使用,因此函数的出现也提升了整个程序界代码复用的水平

一、Go 函数介绍

所谓函数,就是组织好的,可重复使用的,用于执行指定任务的代码块。

1.1 介绍

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

Go函数也是Go 代码中的基本功能逻辑单元,以下是一些Go函数的基本特点:

  • Go函数以关键字func开始,后面跟着函数名、参数列表和返回类型。
  • 函数名和参数列表一起被称为函数的签名。
  • Go支持多返回值,函数可以返回多个值。
  • 函数可以有零个或多个参数,这些参数是输入函数的值。
  • 函数可以没有返回值,也可以有一个或多个返回值。
  • Go函数是一等公民,可以赋值给变量,传递给其他函数,以及从函数返回。
  • 你可以为函数定义接收者,创建方法(与对象关联的函数)。
  • 函数可以递归调用自身。

1.2 特点

  • 无需声明原型:Go函数无需预先声明原型。你可以直接定义函数,然后在其他地方调用它,不需要提前声明函数的签名。
  • 支持不定变参:Go函数可以接受不定数量的参数,这些参数在函数内部可以通过切片来处理。这是通过在参数列表中使用省略号...来实现的。
  • 支持多返回值:Go函数可以返回多个值,而不仅仅是单一值。这在处理多个返回结果的情况下非常有用。
  • 支持命名返回参数:你可以为函数的返回值命名,这样在函数体内可以直接使用这些变量名,同时也可以提高函数的可读性。
  • 支持匿名函数和闭包:Go支持匿名函数,允许你在函数内部定义其他函数。这还支持闭包,使得内部函数可以访问外部函数的变量。
  • 函数也是一种类型:在Go中,函数也是一种类型,你可以将函数赋值给变量,作为参数传递给其他函数,或从函数返回。
  • 不支持嵌套:Go不支持在同一个包中拥有相同名称的函数,即不允许函数嵌套。
  • 不支持重载:Go不支持函数重载,也就是说,不能有相同函数名但不同参数列表的多个函数。
  • 不支持默认参数:Go不支持为函数参数提供默认值,如果需要不同的参数配置,通常使用函数重载来实现。

二、函数声明

2.1 Go 函数声明

Go语言中定义函数使用func关键字,具体格式如下:

代码语言:javascript复制
func functionName(parameter1 type1, parameter2 type2, ...) (value1 return_type1, value2 return_type2, ...) {
    // 函数体
    return
}

让我解释一下每个部分:

  1. func:这是关键字,用于定义一个函数。
  2. functionName:这是函数的名称。函数名称是标识符,它用于标识函数的名称。遵循Go的标识符命名规则,通常使用驼峰式命名法。在同一个包中,函数名应该是唯一的。遵循Go的导出规则,如果函数名以大写字母开头,它可以在包的外部使用,否则只能在包内使用。
  3. (parameter1 type1, parameter2 type2, ...):这是函数的参数列表。参数列表包括了函数需要接受的参数。参数列表使用圆括号括起来,参数之间使用逗号分隔。每个参数都包括参数名和参数类型。在参数列表中,你可以定义零个或多个参数。还可以使用变长参数,其语法是在参数类型前添加 ...
  4. (value1 return_type1, value2 return_type2, ...):这是函数的返回值列表。返回值列表指定了函数执行后将返回的结果的类型。返回值列表跟在参数列表后面,两者之间用一个空格隔开。你可以声明一个或多个返回值。如果声明了多个返回值,它们应该用括号括起来。通常,Go函数会返回一个或多个值,但如果不需要返回任何值,可以省略返回值列表。
  5. 函数体:这是函数的主体,简称函数体,包括函数执行的代码块。函数体包含在花括号 {} 中。

我们来看个例子,下图是Go 标准库 fmt 包提供的 Fprintf 函数声明:

我们看到 Go 标准库 fmt 包提供的 Fprintf 函数 由五部分组成,逐一拆解为:

  1. func 关键字:函数声明始终以 func 关键字开始,表示函数定义。
  2. 函数名:函数名是 Fprintf,这是该函数的标识符,用于在代码中调用该函数。
  3. 参数列表
    • w io.Writer:这是第一个参数,是一个接口类型 io.Writer。它表示一个用于写入的输出流,可以是文件、网络连接等。
    • format string:这是第二个参数,是一个字符串类型,用于指定输出的格式。
    • a ...interface{}:这是第三个参数,是变长参数,使用了 ... 操作符。这个参数可以接受任意数量的参数,这些参数将根据 format 字符串进行格式化输出。
  4. 返回值列表
    • (n int, err error):这是返回值列表,包括两个返回值。第一个返回值 n 是一个整数,表示写入的字节数。第二个返回值 err 是一个错误类型,用于指示是否在写入时出现了错误。返回值列表不仅声明了返回值的类型,还声明了返回值的名称,这种返回值被称为具名返回值。多数情况下,我们不需要这么做,只需声明返回值的类型即可。
  5. 函数体:函数体包含了实际的代码。在这个示例中,Fprintf 函数创建一个新的打印器(newPrinter()),然后调用 doPrintf 方法执行格式化操作。接下来,它将格式化的内容写入到 w 中,并返回写入的字节数和错误。最后,它释放资源并返回结果。

2.3 函数声明与变量声明形式上的差距

同为声明,为啥函数声明与之前的变量声明在形式上差距这么大呢? 变量声明中的变量名、类型名和初值与上面的函数声明是怎么对应的呢?

这里我们就横向对比一下,把上面fmt标准库的Fprintf的声明等价转换为变量声明的形式看看:

转换后的代码不仅和之前的函数声明是等价的,而且这也是完全合乎 Go 语法规则的代码。对照一下这两张图,你是不是有一种豁然开朗的感觉呢?这不就是在声明一个类型为函数类型的变量吗!

我们看到,函数声明中的函数名其实就是变量名,函数声明中的 func 关键字、参数列表和返回值列表共同构成了函数类型而参数列表与返回值列表的组合也被称为函数签名,它是决定两个函数类型是否相同的决定因素。因此,函数类型也可以看成是由 func 关键字与函数签名组合而成的。

通常,在表述函数类型时,我们会省略函数签名参数列表中的参数名,以及返回值列表中的返回值变量名。比如上面 Fprintf 函数的函数类型是:

代码语言:javascript复制
func(io.Writer, string, ...interface{}) (int, error)

这样,如果两个函数类型的函数签名是相同的,即便参数列表中的参数名,以及返回值列表中的返回值变量名都是不同的,那么这两个函数类型也是相同类型,比如下面两个函数类型:

代码语言:javascript复制
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 类型的一个实例一样。

接着,我们使用复合类型字面值对结构体类型变量进行显式初始化和用变量声明来声明函数变量的形式,把这两种形式都以最简化的样子表现出来,看下面代码:

代码语言:javascript复制
s := T{}      // 使用复合类型字面值对结构体类型T的变量进行显式初始化
f := func(){} // 使用变量声明形式的函数声明

这里,T{}被称为复合类型字面值,那么处于同样位置的 func(){}是什么呢?Go 语言也为它准备了一个名字,叫“函数字面值(Function Literal)”。我们可以看到,函数字面值由函数类型与函数体组成,它特别像一个没有函数名的函数声明,因此我们也叫它匿名函数

到这里,你可能会想:既然是等价的,那我以后就用这种变量声明的形式来声明一个函数吧。万万不可!这里只是为了帮你理解函数声明做了一个等价变换。在 Go 中的绝大多数情况,我们还是会通过传统的函数声明来声明一个特定函数类型的实例,也就是我们俗称的“定义一个函数”。

三、函数的调用

定义了函数之后,我们可以通过函数名()的方式调用函数。

这里简单举个例子,代码如下:

代码语言:javascript复制
func greet(name string) {
    fmt.Println("Hello, "   name)
}

func main() {
    greet("Alice")
    greet("Bob")
}

在这个示例中,greet 函数接受一个字符串参数,但没有返回值。在 main 函数中,我们调用 greet 函数两次,分别传递不同的名字作为参数。函数 greet 的目的是打印一条问候消息,而不返回任何值。

当调用有返回值的函数时,可以不接收其返回值。比如下面的代码:

代码语言:javascript复制
func add(x, y int) int {
    return x   y
}

func main() {
    add(3, 5) // 不接收返回值
    result := add(10, 7) // 接收返回值,将结果存储在 result 变量中
}

在上述示例中,add 函数返回一个整数值,但在第一个调用中,我们没有分配或使用该返回值。在第二个调用中,我们将返回值存储在 result 变量中。

四、参数

4.1 参数介绍

函数参数列表中的参数,是函数声明的、用于函数体实现的局部变量。由于函数分为声明与使用两个阶段,在不同阶段,参数的称谓也有不同。

在函数声明阶段,我们把参数列表中的参数叫做形式参数(Parameter,简称形参),在函数体中,我们使用的都是形参。

  • 形式参数(Parameters):形式参数是函数声明中的参数,它们充当函数体中的局部变量,用于接收函数调用时传递的实际参数的值。形式参数位于函数声明的参数列表中,它们指定了函数在被调用时可以接受的输入。

而在函数实际调用时传入的参数被称为实际参数(Argument,简称实参)。

  • 实际参数(Arguments):实际参数是函数在被调用时传递给形式参数的值。它们位于函数调用的括号内,用于提供函数需要的输入数据。

我们还是继续用Go 标准库fmt标准库的Fprintf为例,下面这张示意图快速帮助你理解形参和实参。

当我们实际调用函数的时候,实参会传递给函数,并和形式参数逐一绑定,编译器会根据各个形参的类型与数量,来检查传入的实参的类型与数量是否匹配。只有匹配,程序才能继续执行函数调用,否则编译器就会报错。

Go 语言中,函数参数传递采用是值传递的方式。所谓“值传递”,就是将实际参数在内存中的表示逐位拷贝(Bitwise Copy)到形式参数中。对于像整型、数组、结构体这类类型,它们的内存表示就是它们自身的数据内容,因此当这些类型作为实参类型时,值传递拷贝的就是它们自身,传递的开销也与它们自身的大小成正比。 但是像 string、切片、map 这些类型就不是了,它们的内存表示对应的是它们数据内容的“描述符”。当这些类型作为实参类型时,值传递拷贝的也是它们数据内容的“描述符”,不包括数据内容本身,所以这些类型传递的开销是固定的,与数据内容大小无关。这种只拷贝“描述符”,不拷贝实际数据内容的拷贝过程,也被称为“浅拷贝”

不过函数参数的传递也有两个例外,当函数的形参为接口类型,或者形参是变长参数时,简单的值传递就不能满足要求了,这时 Go 编译器会介入:对于类型为接口类型的形参,Go 编译器会把传递的实参赋值给对应的接口类型形参;对于为变长参数的形参,Go 编译器会将零个或多个实参按一定形式转换为对应的变长形参。

那么这里,零个或多个传递给变长形式参数的实参,被 Go 编译器转换为何种形式了呢?我们通过下面示例代码来看一下:

代码语言:javascript复制
func myAppend(sl []int, elems ...int) []int {
    fmt.Printf("%Tn", 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 函数就可以获取到传给变长参数的实参个数。

4.2 类型简写

函数的参数中如果相邻变量的类型相同,则可以省略类型,例如:

代码语言:javascript复制
func intSum(x, y int) int {
	return x   y
}

上面的代码中,intSum函数有两个参数,这两个参数的类型均为int,因此可以省略x的类型,因为y后面有类型说明,x参数也是该类型。

4.3 可变参数

可变参数是指函数的参数数量不固定。Go语言中的可变参数通过在参数名后加...来标识。

注意:可变参数通常要作为函数的最后一个参数。

举个栗子:

代码语言:javascript复制
// 函数接收可变参数
// 可变参数在函数体中是切片类型
func intSum2(x ...int) int {
	fmt.Println(x) //x是一个切片
	sum := 0
	for _, v := range x {
		sum = sum   v
	}
	return sum
}

调用上面的函数:

代码语言:javascript复制
ret1 := intSum2()
ret2 := intSum2(10)
ret3 := intSum2(10, 20)
ret4 := intSum2(10, 20, 30)
fmt.Println(ret1, ret2, ret3, ret4) //0 10 30 60

固定参数搭配可变参数使用时,可变参数要放在固定参数的后面,示例代码如下:

代码语言:javascript复制
func intSum3(x int, y ...int) int {
	fmt.Println(x, y)
	sum := x
	for _, v := range y {
		sum = sum   v
	}
	return sum
}

调用上述函数:

代码语言:javascript复制
ret5 := intSum3(100)
ret6 := intSum3(100, 10)
ret7 := intSum3(100, 10, 20)
ret8 := intSum3(100, 10, 20, 30)
fmt.Println(ret5, ret6, ret7, ret8) //100 110 130 160

本质上,函数的可变参数是通过切片来实现的。

五、返回值

Go语言中通过return关键字向外输出返回值

5.1 多返回值

Go语言中函数支持多返回值,多返回值可以让函数将更多结果信息返回给它的调用者,Go 语言的错误处理机制很大程度就是建立在多返回值的机制之上的,函数如果有多个返回值时必须用()将所有返回值包裹起来。

函数返回值列表从形式上看主要有三种:

代码语言:javascript复制
func foo()                       // 无返回值
func foo() error                 // 仅有一个返回值
func foo() (int, string, error)  // 有2或2个以上返回值

如果一个函数没有显式返回值,那么我们可以像第一种情况那样,在函数声明中省略返回值列表。而且,如果一个函数仅有一个返回值,那么通常我们在函数声明中,就不需要将返回值用括号括起来,如果是 2 个或 2 个以上的返回值,那我们还是需要用括号括起来的。

5.2 具名返回值

具名返回值是一种将返回值命名的方式,通常不太常见。它使得在函数体中可以直接使用这些返回值的名称,同时也可以在 return 语句中省略返回值。但在大多数情况下,Go函数声明只声明返回值的类型,而不使用具名返回值。

在函数声明的返回值列表中,我们通常会像上面例子那样,仅列举返回值的类型,但我们也可以像 fmt.Fprintf 函数的返回值列表那样,为每个返回值声明变量名,这种带有名字的返回值被称为具名返回值(Named Return Value)。这种具名返回值变量可以像函数体中声明的局部变量一样在函数体内使用。

在日常编码中,我们究竟该使用普通返回值形式,还是具名返回值形式呢?

Go 标准库以及大多数项目代码中的函数,都选择了使用普通的非具名返回值形式。但在一些特定场景下,具名返回值也会得到应用。比如,当函数使用 defer,而且还在 defer 函数中修改外部函数返回值时,具名返回值可以让代码显得更优雅清晰。

再比如,当函数的返回值个数较多时,每次显式使用 return 语句时都会接一长串返回值,这时,我们用具名返回值可以让函数实现的可读性更好一些,比如下面 Go 标准库 time 包中的 parseNanoseconds 函数就是这样:

代码语言:javascript复制
// $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
}

5.3 空白符

_ 在 Go 中被用作空白符,可以用作表示任何类型的任何值。

在 Go 中,下划线 _ 是一个特殊的标识符,通常用作空白符或占位符。它的主要用途包括:

空白符:当你在声明变量时,如果不想使用这个变量,可以使用下划线 _ 作为占位符,表示你不打算使用它。这可以帮助避免编译器出现未使用的变量警告。

代码语言:javascript复制
x, _ := someFunction() // 使用下划线表示不关心第二个返回值

匿名变量:在某些情况下,你可能只对一个函数的一部分返回值感兴趣,而不关心其他返回值。使用下划线 _ 可以帮助你忽略其他返回值。

代码语言:javascript复制
_, result := someFunction() // 忽略第一个返回值

导入包时的空白符:在导入包时,下划线 _ 可用于匿名导入,表示虽然导入了包,但不会显式使用它,只是为了触发包内的初始化代码。

代码语言:javascript复制
import _ "package_name"

总之,下划线 _ 在Go中是一个非常有用的标识符,用于表示不感兴趣的值或导入包时的匿名导入。这有助于编写更清晰和灵活的代码。

六、函数是“一等公民”

在文章开头介绍,函数在 Go 语言中属于“一等公民(First-Class Citizen)”。要知道,并不是在所有编程语言中函数都是“一等公民”。

那么,什么是编程语言的“一等公民”呢?关于这个名词,业界和教科书都没有给出精准的定义。我们这里可以引用一下 wiki 发明人、C2 站点作者沃德·坎宁安 (Ward Cunningham)对“一等公民”的解释:

如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。拥有“一等公民”待遇的语法元素可以存储在变量中,可以作为参数传递给函数,可以在函数内部创建并可以作为返回值从函数返回。

基于这个解释,我们来看看 Go 语言的函数作为“一等公民”,表现出的各种行为特征。

6.1 特征一:Go 函数可以存储在变量中

按照沃德·坎宁安对一等公民的解释,身为一等公民的语法元素是可以存储在变量中的。其实,这点我们在前面理解函数声明时已经验证过了,这里我们再用例子简单说明一下:

代码语言:javascript复制
var (
    myFprintf = func(w io.Writer, format string, a ...interface{}) (int, error) {
        return fmt.Fprintf(w, format, a...)
    }
)

func main() {
    fmt.Printf("%Tn", myFprintf) // func(io.Writer, string, ...interface {}) (int, error)
    myFprintf(os.Stdout, "%sn", "Hello, Go") // 输出Hello,Go
}

在这个例子中,我们把新创建的一个匿名函数赋值给了一个名为 myFprintf 的变量,通过这个变量,我们便可以调用刚刚定义的匿名函数。然后我们再通过 Printf 输出 myFprintf 变量的类型,也会发现结果与我们预期的函数类型是相符的。

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

Go 函数不仅可以在函数外创建,还可以在函数内创建。而且由于函数可以存储在变量中,所以函数也可以在创建后,作为函数返回值返回。我们来看下面这个例子:

代码语言:javascript复制
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 秒的定时器,并传入了时间到了后要执行的函数。这里传入的就是一个匿名函数:

代码语言:javascript复制
time.AfterFunc(time.Second*2, func() { println("timer fired") })

特征四:拥有自己的类型

在前面我们曾得到过这样一个结论:每个函数声明定义的函数仅仅是对应的函数类型的一个实例,就像 var a int = 13 这个变量声明语句中的 a,只是 int 类型的一个实例一样。换句话说,每个函数都和整型值、字符串值等一等公民一样,拥有自己的类型,也就是我们讲过的函数类型

我们甚至可以基于函数类型来自定义类型,就像基于整型、字符串类型等类型来自定义类型一样。下面代码中的 HandlerFuncvisitFunc 就是 Go 标准库中,基于函数类型进行自定义的类型:

代码语言:javascript复制
// $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 函数拥有难得的灵活性。

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

7.1 应用一:函数类型的妙用

Go 函数是“一等公民”,也就是说,它拥有自己的类型。而且,整型、字符串型等所有类型都可以进行的操作,比如显式转型,也同样可以用在函数类型上面,也就是说,函数也可以被显式转型。并且,这样的转型在特定的领域具有奇妙的作用,一个最为典型的示例就是标准库 http 包中的 HandlerFunc 这个类型。我们来看一个使用了这个类型的例子:

代码语言:javascript复制
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!”这样的文字应答。

我们先来看一下 http 包的函数 ListenAndServe 的源码:

代码语言:javascript复制
// $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,是一个自定义的接口类型,它的源码是这样的:

代码语言:javascript复制
// $GOROOT/src/net/http/server.go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

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

代码语言:javascript复制
func(http.ResponseWriter, *http.Request) does not implement http.Handler (missing ServeHTTP method)

这里,编译器提示我们,函数 greeting 还没有实现接口 Handler 的方法,无法将它赋值给 Handler 类型的参数。现在我们再回过头来看下代码,代码中我们也没有直接将 greeting 传给 ListenAndServe 函数,而是将 http.HandlerFunc(greeting) 作为参数传给了 ListenAndServe。那这个 http.HandlerFunc 究竟是什么呢?我们直接来看一下它的源码:

代码语言:javascript复制
// $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 函数的类型是一致的,这和下面整型变量的显式转型原理也是一样的:

代码语言:javascript复制
type MyInt int
var x int = 5
y := MyInt(x) // MyInt的底层类型为int,类比HandlerFunc的底层类型为func(ResponseWriter, *Request)

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

我们前面讲过,Go 闭包是在函数内部创建的匿名函数,这个匿名函数可以访问创建它的函数的参数与局部变量。我们可以利用闭包的这一特性来简化函数调用,这里我们看一个具体例子:

代码语言:javascript复制
func times(x, y int) int {
  return x * y
}

在上面的代码中,times 函数用来进行两个整型数的乘法。我们使用 times 函数的时候需要传入两个实参,比如:

代码语言:javascript复制
times(2, 5) // 计算2 x 5
times(3, 5) // 计算3 x 5
times(4, 5) // 计算4 x 5

不过,有些场景存在一些高频使用的乘数,这个时候我们就没必要每次都传入这样的高频乘数了。那我们怎样能省去高频乘数的传入呢? 我们看看下面这个新函数 partialTimes

代码语言:javascript复制
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) 的函数,这个过程的逻辑类似于下面代码:

代码语言:javascript复制
timesTwo = func(y int) int {
    return times(2, y)
}

这个时候,我们再看看如何使用 partialTimes,分别生成以 2、3、4 为固定高频乘数的乘法函数,以及这些生成的乘法函数的使用方法:

代码语言:javascript复制
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,我们生成了三个带有固定乘数的函数。这样,我们在计算乘法时,就可以减少参数的重复输入。你看到这里可能会说,这种简化的程度十分有限啊!

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

0 人点赞