Go第五篇之浅析函数

 


 函数是组织好的、可重复使用的、用来实现单一或相关联功能的代码段,其可以提高应用的模块性和代码的重复利用率。

Go 语言支持普通函数、匿名函数和闭包,从设计上对函数进行了优化和改进,让函数使用起来更加方便。
Go 语言的函数属于“一等公民”(first-class),也就是说:

    • 函数本身可以作为值进行传递。
    • 支持匿名函数和闭包(closure)。
    • 函数可以满足接口。

Go语言函数声明#

普通函数需要先声明才能调用。一个函数的声明包括参数和函数名等,编译器通过声明才能了解函数应该怎样在调用代码和函数体之间传递参数和返回参数。

普通函数的声明形式#

Go 语言的函数声明以 func 标识,后面紧接着函数名、参数列表、返回参数列表及函数体,具体形式如下:

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


下面是对各个部分的说明:

  • 函数名:由字母、数字、下画线组成。其中,函数名的第一个字母不能为数字。在同一个包内,函数名称不能重名
  • 1
    包(package)是 Go 源码的一种组织方式,一个包可以认为是一个文件夹,在后续章节中将会详细讲解包的概念
  • 参数列表:一个参数由参数变量和参数类型组成,例如:
    1. func foo( a int, b string )
    其中,参数列表中的变量作为函数的局部变量而存在。
  • 返回参数列表:可以是返回值类型列表,也可以是类似参数列表中变量名和类型名的组合。函数在声明有返回值时,必须在函数体中使用 return 语句提供返回值列表。
  • 函数体:能够被重复调用的代码片段。

参数类型的简写#

在参数列表中,如有多个参数变量,则以逗号分隔;如果相邻变量是同类型,则可以将类型省略。例如:

  1. func add(a, b int) int {
  2. return a + b
  3. }

以上代码中,a 和 b 的参数均为 int 类型,因此可以省略 a 的类型,在 b 后面有类型说明,这个类型也是 a 的类型。

函数的返回值#

Go 语言支持多返回值,多返回值能方便地获得函数执行后的多个返回参数,Go 语言经常使用多返回值中的最后一个返回参数返回函数执行中可能发生的错误。示例代码如下:

  1. conn, err := connectToNetwork()

在这段代码中,connectToNetwork 返回两个参数,conn 表示连接对象,err 返回错误。

Go 语言既支持安全指针,也支持多返回值,因此在使用函数进行逻辑编写时更为方便。

1) 同一种类型返回值#

如果返回值是同一种类型,则用括号将多个返回值类型括起来,用逗号分隔每个返回值的类型。

使用 return 语句返回时,值列表的顺序需要与函数声明的返回值类型一致。示例代码如下:

  1. func typedTwoValues() (int, int) {
  2. return 1, 2
  3. }
  4. a, b := typedTwoValues()
  5. fmt.Println(a, b)

代码输出结果:
1 2

纯类型的返回值对于代码可读性不是很友好,特别是在同类型的返回值出现时,无法区分每个返回参数的意义。

2) 带有变量名的返回值#

Go 语言支持对返回值进行命名,这样返回值就和参数一样拥有参数变量名和类型。

命名的返回值变量的默认值为类型的默认值,即数值为 0,字符串为空字符串,布尔为 false、指针为 nil 等。

下面代码中的函数拥有两个整型返回值,函数声明时将返回值命名为 a 和 b,因此可以在函数体中直接对函数返回值进行赋值。在命名的返回值方式的函数体中,在函数结束前需要显式地使用 return 语句进行返回,代码如下:

  1. func namedRetValues() (a, b int) {
  2. a = 1
  3. b = 2
  4. return
  5. }

代码说明如下:

  • 第 1 行,对两个整型返回值进行命名,分别为 a 和 b。
  • 第 3 行和第 4 行,命名返回值的变量与这个函数的布局变量的效果一致,可以对返回值进行赋值和值获取。
  • 第 6 行,当函数使用命名返回值时,可以在 return 中不填写返回值列表,如果填写也是可行的。下面代码的执行效果和上面代码的效果一样。
    1. func namedRetValues() (a, b int) {
    2. a = 1
    3. return a, 2
    4. }

提示#

同一种类型返回值和命名返回值两种形式只能二选一,混用时将会发生编译错误,例如下面的代码:

  1. func namedRetValues() (a, b int, int)

编译报错提示:

mixed named and unnamed function parameters

意思是:在函数参数中混合使用了命名和非命名参数。

调用函数#

函数在定义后,可以通过调用的方式,让当前代码跳转到被调用的函数中进行执行。调用前的函数局部变量都会被保存起来不会丢失;被调用的函数结束后,恢复到被调用函数的下一行继续执行代码,之前的局部变量也能继续访问。

函数内的局部变量只能在函数体中使用,函数调用结束后,这些局部变量都会被释放并且失效。

Go语言的函数调用格式如下:

返回值变量列表 = 函数名(参数列表)


下面是对各个部分的说明:

  • 函数名:需要调用的函数名。
  • 参数列表:参数变量以逗号分隔,尾部无须以分号结尾。
  • 返回值变量列表:多个返回值使用逗号分隔。


例如,加法函数调用样式如下:

result := add(1,1)

 

示例:将秒转换为具体的时间#

在本例中,使用一个数值表示时间中的“秒”值,然后使用 resolveTime() 函数将传入的秒数转换为天、小时和分钟等时间单位。

将秒解析为时间单位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main
import "fmt"
const (
// 定义每分钟的秒数
SecondsPerMinute = 60
// 定义每小时的秒数
SecondsPerHour = SecondsPerMinute * 60
// 定义每天的秒数
SecondsPerDay = SecondsPerHour * 24
)
// 将传入的“秒”解析为3种时间单位
func resolveTime(seconds int) (day int, hour int, minute int) {
day = seconds / SecondsPerDay
hour = seconds / SecondsPerHour
minute = seconds / SecondsPerMinute
return
}
func main() {
// 将返回值作为打印参数
fmt.Println(resolveTime(1000))
// 只获取消息和分钟
_, hour, minute := resolveTime(18000)
fmt.Println(hour, minute)
// 只获取天
day, _, _ := resolveTime(90000)
fmt.Println(day)
}

代码输出结果:
0 0 16
5 300
1

代码说明如下:

    • 第 7 行,定义每分钟的秒数。
    • 第 10 行,定义每小时的秒数,SecondsPerHour 常量值会在编译期间计算出结果。
    • 第 13 行,定义每天的秒数。
    • 第 17 行,定义 resolveTime() 函数,根据输入的秒数,返回 3 个整型值,含义分别是秒数对应的天数、小时数和分钟数(取整)。
    • 第 29 行中,给定 1000 秒,对应是 16(16.6667 取整)分钟的秒数。resolveTime() 函数返回的 3 个变量会传递给 fmt.Println() 函数进行打印,因为 fmt.Println() 使用了可变参数,可以接收不定量的参数。
    • 第 32 行,将 resolveTime() 函数中的 3 个返回值使用变量接收,但是第一个返回参数使用匿名函数接收,表示忽略这个变量。
    • 第 36 行,忽略后两个返回值,只使用第一个返回值。

示例:函数中的参数传递效果测试#

Go 语言中传入和返回参数在调用和返回时都使用值传递,这里需要注意的是指针、切片和 map 等引用型对象指向的内容在参数传递中不会发生复制,而是将指针进行复制,类似于创建一次引用。

下面通过一个例子来详细了解 Go 语言的参数值传递。请先看完整的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main
import "fmt"
// 用于测试值传递效果的结构体
type Data struct {
complax []int // 测试切片在参数传递中的效果
instance InnerData // 实例分配的innerData
ptr *InnerData // 将ptr声明为InnerData的指针类型
}
// 代表各种结构体字段
type InnerData struct {
a int
}
// 值传递测试函数
func passByValue(inFunc Data) Data {
// 输出参数的成员情况
fmt.Printf("inFunc value: %+v\n", inFunc)
// 打印inFunc的指针
fmt.Printf("inFunc ptr: %p\n", &inFunc)
return inFunc
}
func main() {
// 准备传入函数的结构
in := Data{
complax: []int{1, 2, 3},
instance: InnerData{
5,
},
ptr: &InnerData{1},
}
// 输入结构的成员情况
fmt.Printf("in value: %+v\n", in)
// 输入结构的指针地址
fmt.Printf("in ptr: %p\n", &in)
// 传入结构体,返回同类型的结构体
out := passByValue(in)
// 输出结构的成员情况
fmt.Printf("out value: %+v\n", out)
// 输出结构的指针地址
fmt.Printf("out ptr: %p\n", &out)
}

1) 测试数据类型#

为了测试结构体、切片、指针及结构体中嵌套的结构体在值传递中会发生的情况,需要定义一些结构,代码如下:

1
2
3
4
5
6
7
8
9
10
// 用于测试值传递效果的结构体
type Data struct {
complax []int // 测试切片在参数传递中的效果
instance InnerData // 实例分配的innerData
ptr *InnerData // 将ptr声明为InnerData的指针类型
}
// 代表各种结构体字段
type InnerData struct {
a int
}

代码说明如下:

  • 第 2 行,将 Data 声明为结构体类型,结构体是拥有多个字段的复杂结构。
  • 第 3 行,complax 为整型切片类型,切片是一种动态类型,内部以指针存在。
  • 第 5 行,instance 成员以 InnerData 类型作为 Data 的成员。
  • 第 7 行,将 ptr 声明为 InnerData 的指针类型。
  • 第 11 行,声明一个内嵌的结构 InnerData。

2) 值传递的测试函数#

本节中定义的 passByValue() 函数用于值传递的测试,该函数的参数和返回值都是 Data 类型。在调用中,Data 的内存会被复制后传入函数,当函数返回时,又会将返回值复制一次,赋给函数返回值的接收变量。代码如下:

1
2
3
4
5
6
7
8
// 值传递测试函数
func passByValue(inFunc Data) Data {
// 输出参数的成员情况
fmt.Printf("inFunc value: %+v\n", inFunc)
// 打印inFunc的指针
fmt.Printf("inFunc ptr: %p\n", &inFunc)
return inFunc
}

代码说明如下:

  • 第 5 行,使用格式化的%+v动词输出 in 变量的详细结构,以便观察 Data 结构在传递前后的内部数值的变化情况。
  • 第 8 行,打印传入参数 inFunc 的指针地址。在计算机中,拥有相同地址且类型相同的变量,表示的是同一块内存区域。
  • 第 10 行,将传入的变量作为返回值返回,返回的过程将发生值复制。

3) 测试流程#

测试流程会准备一个 Data 格式的数据结构并填充所有成员,这些成员类型包括切片、结构体成员及指针。通过调用测试函数,传入 Data 结构数据,并获得返回值,对比输入和输出后的 Data 结构数值变化,特别是指针变化情况以及输入和输出整块数据是否被复制,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 准备传入函数的结构
in := Data{
complax: []int{1, 2, 3},
instance: InnerData{
5,
},
ptr: &InnerData{1},
}
// 输入结构的成员情况
fmt.Printf("in value: %+v\n", in)
// 输入结构的指针地址
fmt.Printf("in ptr: %p\n", &in)
// 传入结构体, 返回同类型的结构体
out := passByValue(in)
// 输出结构的成员情况
fmt.Printf("out value: %+v\n", out)
// 输出结构的指针地址
fmt.Printf("out ptr: %p\n", &out)

代码说明如下:

  • 第 2 行,创建一个 Data 结构的实例 in。
  • 第 3 行,将切片数据赋值到 in 的 complax 成员。
  • 第 4 行,为 in 的 instance 成员赋值 InnerData 结构的数据。
  • 第 8 行,为 in 的 ptr 成员赋值 InnerData 的指针类型数据。
  • 第 12 行,打印输入结构的成员情况。
  • 第 15 行,打印输入结构的指针地址。
  • 第 18 行,传入 in 结构,调用 passByvalue() 测试函数获得 out 返回,此时,passByValue() 函数会打印 in 传入后的数据成员情况。
  • 第 21 行,打印返回值 out 变量的成员情况。
  • 第 24 行,打印输出结构的地址。


运行代码,输出结果为:
in value: {complax:[1 2 3] instance:{a:5} ptr:0xc042008100}
in ptr: 0xc042066060
inFunc value: {complax:[1 2 3] instance:{a:5} ptr:0xc042008100}
inFunc ptr: 0xc0420660f0
out value: {complax:[1 2 3] instance:{a:5} ptr:0xc042008100}
out ptr: 0xc0420660c0

从运行结果中发现:

    • 所有的 Data 结构的指针地址发生了变化,意味着所有的结构都是一块新的内存,无论是将 Data 结构传入函数内部,还是通过函数返回值传回 Data 都会发生复制行为。
    • 所有的 Data 结构中的成员值都没有发生变化,原样传递,意味着所有参数都是值传递。
    • Data 结构的 ptr 成员在传递过程中保持一致,表示指针在函数参数值传递中传递的只是指针值,不会复制指针指向的部分。

Go语言函数变量#

Go 语言中,函数也是一种类型,可以和其他类型一样被保存在变量中。下面的代码定义了一个函数变量 f,并将一个函数名 fire() 赋给函数变量 f,这样调用函数变量 f 时,实际调用的就是 fire() 函数,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
package main
import (
"fmt"
)
func fire() {
fmt.Println("fire")
}
func main() {
var f func()
f = fire
f()
}

代码输出结果:
fire

代码说明:

    • 第 7 行,定义了一个 fire() 函数。
    • 第 13 行,将变量f声明为 func() 类型,此时 f 就被俗称为“回调函数”。此时 f 的值为 nil。
    • 第 15 行,将 fire() 函数名作为值,赋给 f 变量,此时 f 的值为 fire() 函数。
    • 第 17 行,使用 f 变量进行函数调用,实际调用的是 fire() 函数。

 

 Go语言字符串的链式处理#

使用 SQL 语言从数据库中获取数据时,可以对原始数据进行排序(sort by)、分组(group by)和去重(distinct)等操作。SQL 将数据的操作与遍历过程作为两个部分进行隔离,这样操作和遍历过程就可以各自独立地进行设计,这就是常见的数据与操作分离的设计。

对数据的操作进行多步骤的处理被称为链式处理。本例中使用多个字符串作为数据集合,然后对每个字符串进行一系列的处理,用户可以通过系统函数或者自定义函数对链式处理中的每个环节进行自定义。

首先给出本节完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main
import (
"fmt"
"strings"
)
// 字符串处理函数,传入字符串切片和处理链
func StringProccess(list []string, chain []func(string) string) {
// 遍历每一个字符串
for index, str := range list {
// 第一个需要处理的字符串
result := str
// 遍历每一个处理链
for _, proc := range chain {
// 输入一个字符串进行处理,返回数据作为下一个处理链的输入。
result = proc(result)
}
// 将结果放回切片
list[index] = result
}
}
// 自定义的移除前缀的处理函数
func removePrefix(str string) string {
return strings.TrimPrefix(str, "go")
}
func main() {
// 待处理的字符串列表
list := []string{
"go scanner",
"go parser",
"go compiler",
"go printer",
"go formater",
}
// 处理函数链
chain := []func(string) string{
removePrefix,
strings.TrimSpace,
strings.ToUpper,
}
// 处理字符串
StringProccess(list, chain)
// 输出处理好的字符串
for _, str := range list {
fmt.Println(str)
}
}

1) 字符串处理函数#

字符串处理函数(StringProccess)需要外部提供数据源:一个字符串切片(list[]string),另外还要提供一个链式处理函数的切片(chain[]func(string)string),链式处理切片中的一个处理函数的定义如下:

func(string)string

这种处理函数能够接受一个字符串输入,处理后输出。

strings 包中将字符串变为小写就是一种处理函数的形式,strings.ToLower() 函数能够将传入的字符串的每一个字符变为小写,strings.ToLower 定义如下:

func ToLower(s string) string

字符串处理函数(StringProccess)内部遍历每一个数据源提供的字符串,每个字符串都需要经过一系列链式处理函数处理后被重新放回切片,参见下面代码。

字符串的链式处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 字符串处理函数, 传入字符串切片和处理链
func StringProccess(list []string, chain []func(string) string) {
// 遍历每一个字符串
for index, str := range list {
// 第一个需要处理的字符串
result := str
// 遍历每一个处理链
for _, proc := range chain {
// 输入一个字符串进行处理, 返回数据作为下一个处理链的输入
result = proc(result)
}
// 将结果放回切片
list[index] = result
}
}

代码说明如下:

  • 第 2 行,传入字符串切片 list 作为数据源,一系列的处理函数作为 chain 处理链。
  • 第 5 行,遍历字符串切片的每个字符串,依次对每个字符串进行处理。
  • 第 8 行,将当前字符串保存到 result 变量中,作为第一个处理函数的参数。
  • 第 11 行,遍历每一个处理函数,将字符串按顺序经过这些处理函数处理。
  • 第 14 行,result 变量即是每个处理函数的输入变量,处理后的变量又会重新保存到 result 变量中。
  • 第 18 行,将处理完的字符串保存回切片中。

2) 自定义的处理函数#

处理函数可以是系统提供的处理函数,如将字符串变大写或小写,也可以使用自定义函数。本例中的字符串处理的逻辑是使用一个自定义的函数实现移除指定go前缀的过程,参见下面代码:

  1. // 自定义的移除前缀的处理函数
  2. func removePrefix(str string) string {
  3. return strings.TrimPrefix(str, "go")
  4. }

此函数使用了 strings.TrimPrefix() 函数实现移除字符串的指定前缀。处理后,移除前缀的字符串结果将通过 removePrefix() 函数的返回值返回。

3) 字符串处理主流程#

字符串处理的主流程包含以下几个步骤:

  1. 准备要处理的字符串列表。
  2. 准备字符串处理链。
  3. 处理字符串列表。
  4. 打印输出后的字符串列表。


详细流程参考下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
// 待处理的字符串列表
list := []string{
"go scanner",
"go parser",
"go compiler",
"go printer",
"go formater",
}
// 处理函数链
chain := []func(string) string{
removePrefix,
strings.TrimSpace,
strings.ToUpper,
}
// 处理字符串
StringProccess(list, chain)
// 输出处理好的字符串
for _, str := range list {
fmt.Println(str)
}
}

代码说明如下:

  • 第 4 行,定义字符串切片,字符串包含 go 前缀及空格。
  • 第 13 行,准备处理每个字符串的处理链,处理的顺序与函数在切片中的位置一致。removePrefix() 为自定义的函数,功能是移除 go 前缀;移除前缀的字符串左边有一个空格,使用 strings.TrimSpace 移除,这个函数的定义刚好符合处理函数的格式:func(string)string;strings.ToUpper 用于将字符串转为大写。
  • 第 20 行,传入字符串切片和字符串处理链,通过 StringProcess() 函数对字符串进行处理。
  • 第 23 行,遍历字符串切片的每一个字符串,打印处理好的字符串结果。

提示#

链式处理器是一种常见的编程设计。Netty 是使用 Java 语言编写的一款异步事件驱动的网络应用程序框架,支持快速开发可维护的高性能的面向协议的服务器和客户端,Netty 中就有类似的链式处理器的设计。

Netty 可以使用类似的处理链对封包进行收发编码及处理。Netty 的开发者可以分为 3 种:第一种是 Netty 底层开发者,第二种是每个处理环节的开发者,第三种是业务实现者,在实际开发环节中,后两种开发者往往是同一批开发者。链式处理的开发思想将数据和操作拆分、解耦,让开发者可以根据自己的技术优势和需求,进行系统开发,同时将自己的开发成果共享给其他的开发者。

 

Go语言匿名函数#

Go 语言支持匿名函数,即在需要使用函数时,再定义函数,匿名函数没有函数名,只有函数体,函数可以被作为一种类型被赋值给函数类型的变量,匿名函数也往往以变量方式被传递。

匿名函数经常被用于实现回调函数、闭包等。

定义一个匿名函数#

匿名函数的定义格式如下:

func(参数列表)(返回参数列表){
    函数体
}

匿名函数的定义就是没有名字的普通函数定义。

1) 在定义时调用匿名函数#

匿名函数可以在声明后调用,例如:

1
2
3
func(data int) {
fmt.Println("hello", data)
}(100)

注意第3行“}”后的(100),表示对匿名函数进行调用,传递参数为 100。

2) 将匿名函数赋值给变量#

匿名函数体可以被赋值,例如:

1
2
3
4
5
6
// 将匿名函数体保存到f()中
f := func(data int) {
fmt.Println("hello", data)
}
// 使用f()调用
f(100)

匿名函数的用途非常广泛,匿名函数本身是一种值,可以方便地保存在各种容器中实现回调函数和操作封装。

匿名函数用作回调函数#

下面的代码实现对切片的遍历操作,遍历中访问每个元素的操作使用匿名函数来实现。用户传入不同的匿名函数体可以实现对元素不同的遍历操作,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import (
"fmt"
)
// 遍历切片的每个元素, 通过给定函数进行元素访问
func visit(list []int, f func(int)) {
for _, v := range list {
f(v)
}
}
func main() {
// 使用匿名函数打印切片内容
visit([]int{1, 2, 3, 4}, func(v int) {
fmt.Println(v)
})
}

代码说明如下:

  • 第 8 行,使用 visit() 函数将整个遍历过程进行封装,当要获取遍历期间的切片值时,只需要给 visit() 传入一个回调参数即可。
  • 第 18 行,准备一个整型切片 []int{1,2,3,4} 传入 visit() 函数作为遍历的数据。
  • 第 19~20 行,定义了一个匿名函数,作用是将遍历的每个值打印出来。


匿名函数作为回调函数的设计在 Go 语言的系统包中也比较常见,strings 包中就有如下代码:

1
2
3
func TrimFunc(s string, f func(rune) bool) string {
return TrimRightFunc(TrimLeftFunc(s, f), f)
}

使用匿名函数实现操作封装#

下面这段代码将匿名函数作为 map 的键值,通过命令行参数动态调用匿名函数,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main
import (
"flag"
"fmt"
)
var skillParam = flag.String("skill", "", "skill to perform")
func main() {
flag.Parse()
var skill = map[string]func(){
"fire": func() {
fmt.Println("chicken fire")
},
"run": func() {
fmt.Println("soldier run")
},
"fly": func() {
fmt.Println("angel fly")
},
}
if f, ok := skill[*skillParam]; ok {
f()
} else {
fmt.Println("skill not found")
}
}

代码说明如下:

  • 第 8 行,定义命令行参数 skill,从命令行输入— skill 可以将空格后的字符串传入 skillParam 指针变量。
  • 第 12 行,解析命令行参数,解析完成后,skillParam 指针变量将指向命令行传入的值。
  • 第 14 行,定义一个从字符串映射到 func()的map,然后填充这个 map。
  • 第 15~23 行,初始化 map 的键值对,值为匿名函数。
  • 第 26 行,skillParam 是一个 *string 类型的指针变量,使用 *skillParam 获取到命令行传过来的值,并在 map 中查找对应命令行参数指定的字符串的函数。
  • 第 29 行,如果在 map 定义中存在这个参数就调用;否则打印“技能没有找到”。


运行代码,结果如下:$ go run main.go --skill=fly

1
2
3
angel fly
$  go run main.go --skill=run
soldier run

Go语言函数类型实现接口#

函数和其他类型一样都属于“一等公民”,其他类型能够实现接口,函数也可以,本节将分别对比结构体与函数实现接口的过程。 首先给出本节完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main
import (
"fmt"
)
// 调用器接口
type Invoker interface {
// 需要实现一个Call方法
Call(interface{})
}
// 结构体类型
type Struct struct {
}
// 实现Invoker的Call
func (s *Struct) Call(p interface{}) {
fmt.Println("from struct", p)
}
// 函数定义为类型
type FuncCaller func(interface{})
// 实现Invoker的Call
func (f FuncCaller) Call(p interface{}) {
// 调用f函数本体
f(p)
}
func main() {
// 声明接口变量
var invoker Invoker
// 实例化结构体
s := new(Struct)
// 将实例化的结构体赋值到接口
invoker = s
// 使用接口调用实例化结构体的方法Struct.Call
invoker.Call("hello")
// 将匿名函数转为FuncCaller类型,再赋值给接口
invoker = FuncCaller(func(v interface{}) {
fmt.Println("from function", v)
})
// 使用接口调用FuncCaller.Call,内部会调用函数本体
invoker.Call("hello")
}
有如下一个接口:
  1. // 调用器接口
  2. type Invoker interface {
  3. // 需要实现一个Call()方法
  4. Call(interface{})
  5. }
这个接口需要实现 Call() 方法,调用时会传入一个 interface{} 类型的变量,这种类型的变量表示任意类型的值。

接下来,使用结构体进行接口实现

结构体实现接口#

结构体实现 Invoker 接口的代码如下:
1
2
3
4
5
6
7
// 结构体类型
type Struct struct {
}
// 实现Invoker的Call
func (s *Struct) Call(p interface{}) {
fmt.Println("from struct", p)
}
代码说明如下:
  • 第 2 行,定义结构体,该例子中的结构体无须任何成员,主要展示实现 Invoker 的方法。
  • 第 6 行,Call() 为结构体的方法,该方法的功能是打印 from struct 和传入的 interface{} 类型的值。

将定义的 Struct 类型实例化,并传入接口中进行调用,代码如下:
1
2
3
4
5
6
7
8
// 声明接口变量
var invoker Invoker
// 实例化结构体
s := new(Struct)
// 将实例化的结构体赋值到接口
invoker = s
// 使用接口调用实例化结构体的方法Struct.Call
invoker.Call("hello")
代码说明如下:
  • 第 2 行,声明 Invoker 类型的变量。
  • 第 5 行,使用 new 将结构体实例化,此行也可以写为 s:=&Struct。
  • 第 8 行,s 类型为 *Struct,已经实现了 Invoker 接口类型,因此赋值给 invoker 时是成功的。
  • 第 11 行,通过接口的 Call() 方法,传入 hello,此时将调用 Struct 结构体的 Call() 方法。

接下来,对比下函数实现结构体的差异。

代码输出如下:
from struct hello

函数体实现接口#

函数的声明不能直接实现接口,需要将函数定义为类型后,使用类型实现结构体。当类型方法被调用时,还需要调用函数本体。
1
2
3
4
5
6
7
// 函数定义为类型
type FuncCaller func(interface{})
// 实现Invoker的Call
func (f FuncCaller) Call(p interface{}) {
// 调用f()函数本体
f(p)
}
代码说明如下:
  • 第 2 行,将 func(interface{}) 定义为 FuncCaller 类型。
  • 第 5 行,FuncCaller 的 Call() 方法将实现 Invoker 的 Call() 方法。
  • 第 8 行,FuncCaller 的 Call() 方法被调用与 func(interface{}) 无关,还需要手动调用函数本体。

上面代码只是定义了函数类型,需要函数本身进行逻辑处理。FuncCaller 无须被实例化,只需要将函数转换为 FuncCaller 类型即可,函数来源可以是命名函数、匿名函数或闭包,参见下面代码:
1
2
3
4
5
6
7
8
// 声明接口变量
var invoker Invoker
// 将匿名函数转为FuncCaller类型, 再赋值给接口
invoker = FuncCaller(func(v interface{}) {
fmt.Println("from function", v)
})
// 使用接口调用FuncCaller.Call, 内部会调用函数本体
invoker.Call("hello")
代码说明如下:
  • 第 2 行,声明接口变量。
  • 第 5 行,将 func(v interface{}){} 匿名函数转换为 FuncCaller 类型(函数签名才能转换),此时 FuncCaller 类型实现了 Invoker 的 Call() 方法,赋值给 invoker 接口是成功的。
  • 第 10 行,使用接口方法调用。
代码输出如下:
1
from function hello

HTTP包中的例子#

HTTP 包中包含有 Handler 接口定义,代码如下:
  1. type Handler interface {
  2. ServeHTTP(ResponseWriter, *Request)
  3. }
Handler 用于定义每个 HTTP 的请求和响应的处理过程。

同时,也可以使用处理函数实现接口,定义如下:
1
2
3
4
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
要使用闭包实现默认的 HTTP 请求处理,可以使用 http.HandleFunc() 函数,函数定义如下:
1
2
3
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}
而 DefaultServeMux 是 ServeMux 结构,拥有 HandleFunc() 方法,定义如下:
1
2
3
4
func (mux *ServeMux) HandleFunc(pattern string, handler func
(ResponseWriter, *Request)) {
mux.Handle(pattern, HandlerFunc(handler))
}
上面代码将外部传入的函数 handler() 转为 HandlerFunc 类型,HandlerFunc 类型实现了 Handler 的 ServeHTTP 方法,底层可以同时使用各种类型来实现 Handler 接口进行处理。

Go语言闭包(Closure)#

闭包是引用了自由变量的函数,被引用的自由变量和函数一同存在,即使已经离开了自由变量的环境也不会被释放或者删除,在闭包中可以继续使用这个自由变量。因此,简单的说:

函数 + 引用环境 = 闭包

同一个函数与不同引用环境组合,可以形成不同的实例,如下图所示。


图:闭包与函数引用

一个函数类型就像结构体一样,可以被实例化。函数本身不存储任何信息,只有与引用环境结合后形成的闭包才具有“记忆性”。函数是编译期静态的概念,而闭包是运行期动态的概念。

其它编程语言中的闭包#

闭包(Closure)在某些编程语言中也被称为 Lambda 表达式。
闭包对环境中变量的引用过程,也可以被称为“捕获”,在 C++11 标准中,捕获有两种类型:引用和复制,可以改变引用的原值叫做“引用捕获”,捕获的过程值被复制到闭包中使用叫做“复制捕获”。
在 Lua 语言中,将被捕获的变量起了一个名字叫做 Upvalue,因为捕获过程总是对闭包上方定义过的自由变量进行引用。
闭包在各种语言中的实现也是不尽相同的。在 Lua 语言中,无论闭包还是函数都属于 Prototype 概念,被捕获的变量以 Upvalue 的形式引用到闭包中。
C++ 与 C# 中为闭包创建了一个类,而被捕获的变量在编译时放到类中的成员中,闭包在访问被捕获的变量时,实际上访问的是闭包隐藏类的成员。

在闭包内部修改引用的变量#

闭包对它作用域上部变量的引用可以进行修改,修改引用的变量就会对变量进行实际修改,通过下面的例子来理解:
1
2
3
4
5
6
7
8
9
// 准备一个字符串
str := "hello world"
// 创建一个匿名函数
foo := func() {
// 匿名函数中访问str
str = "hello dude"
}
// 调用匿名函数
foo()
代码说明如下:
  • 第 2 行,准备一个字符串用于修改。
  • 第 5 行,创建一个匿名函数。
  • 第 8 行,在匿名函数中并没有定义 str,str 的定义在匿名函数之前,此时,str 就被引用到了匿名函数中形成了闭包。
  • 第 12 行,执行闭包,此时 str 发生修改,变为 hello dude。


代码输出:
hello dude

示例:闭包的记忆效应#

被捕获到闭包中的变量让闭包本身拥有了记忆效应,闭包中的逻辑可以修改闭包捕获的变量,变量会跟随闭包生命期一直存在,闭包本身就如同变量一样拥有了记忆效应。
累加器的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main
import (
"fmt"
)
// 提供一个值, 每次调用函数会指定对值进行累加
func Accumulate(value int) func() int {
// 返回一个闭包
return func() int {
// 累加
value++
// 返回一个累加值
return value
}
}
func main() {
// 创建一个累加器, 初始值为1
accumulator := Accumulate(1)
// 累加1并打印
fmt.Println(accumulator())
fmt.Println(accumulator())
// 打印累加器的函数地址
fmt.Printf("%p\n", accumulator)
// 创建一个累加器, 初始值为1
accumulator2 := Accumulate(10)
// 累加1并打印
fmt.Println(accumulator2())
// 打印累加器的函数地址
fmt.Printf("%p\n", accumulator2)
}
代码说明如下:
  • 第 8 行,累加器生成函数,这个函数输出一个初始值,调用时返回一个为初始值创建的闭包函数。
  • 第 11 行,返回一个闭包函数,每次返回会创建一个新的函数实例。
  • 第 14 行,对引用的 Accumulate 参数变量进行累加,注意 value 不是第 11 行匿名函数定义的,但是被这个匿名函数引用,所以形成闭包。
  • 第 17 行,将修改后的值通过闭包的返回值返回。
  • 第 24 行,创建一个累加器,初始值为 1,返回的 accumulator 是类型为 func()int 的函数变量。
  • 第 27 行,调用 accumulator() 时,代码从 11 行开始执行匿名函数逻辑,直到第 17 行返回。
  • 第 32 行,打印累加器的函数地址。

对比输出的日志发现 accumulator 与 accumulator2 输出的函数地址不同,因此它们是两个不同的闭包实例。

每调用一次 accumulator 都会自动对引用的变量进行累加。

示例:闭包实现生成器#

闭包的记忆效应进程被用于实现类似于设计模式中工厂模式的生成器。下面的例子展示了创建一个玩家生成器的过程。

玩家生成器的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main
import (
"fmt"
)
// 创建一个玩家生成器, 输入名称, 输出生成器
func playerGen(name string) func() (string, int) {
// 血量一直为150
hp := 150
// 返回创建的闭包
return func() (string, int) {
// 将变量引用到闭包中
return name, hp
}
}
func main() {
// 创建一个玩家生成器
generator := playerGen("high noon")
// 返回玩家的名字和血量
name, hp := generator()
// 打印值
fmt.Println(name, hp)
}
代码输出如下:
high noon 150

代码说明如下:
  • 第 8 行,playerGen() 需要提供一个名字来创建一个玩家的生成函数。
  • 第 11 行,声明并设定 hp 变量为 150。
  • 第 14~18 行,将 hp 和 name 变量引用到匿名函数中形成闭包。
  • 第 24 行中,通过 playerGen 传入参数调用后获得玩家生成器。
  • 第 27 行,调用这个玩家生成器函数,可以获得玩家的名称和血量。

闭包还具有一定的封装性,第 11 行的变量是 playerGen 的局部变量,playerGen 的外部无法直接访问及修改这个变量,这种特性也与面向对象中强调的封装性类似。

Go语言可变参数#

所谓可变参数,是指参数数量不固定的函数形式。Go 语言支持可变参数特性,函数声明和调用时没有固定数量的参数,同时也提供了一套方法进行可变参数的多级传递。

Go 语言的可变参数格式如下:

func 函数名(固定参数列表, v … T)(返回参数列表){
    函数体
}

以下是对可变参数的函数的说明:
  • 可变参数一般被放置在函数列表的末尾,前面是固定参数列表,当没有固定参数时,所有变量就将是可变参数。
  • v 为可变参数变量,类型为 []T,也就是拥有多个 T 元素的 T 类型切片,v 和 T 之间由...即3个点组成。
  • T 为可变参数的类型,当 T 为 interface{} 时,传入的可以是任意类型。

fmt包中的例子#

可变参数有两种形式:所有参数都是可变参数的形式,如 fmt.Println,以及部分是可变参数的形式,如 fmt.Printf,可变参数只能出现在参数的后半部分,因此不可变的参数只能放在参数的前半部分。

1) 所有参数都是可变参数:fmt.Println#

fmt.Println的函数声明如下:
  1. func Println(a ...interface{}) (n int, err error) {
  2. return Fprintln(os.Stdout, a...)
  3. }
func Println(a ...interface{}) (n int, err error) {
    return Fprintln(os.Stdout, a...)
}
fmt.Println 在使用时,传入的值类型不受限制,例如:
 
  1. fmt.Println(5, "hello", &struct{ a int }{1}, true)
fmt.Println(5, "hello", &struct{ a int }{1}, true)

2) 部分参数是可变参数:fmt.Printf#

fmt.Printf 的第一个参数为参数列表,后面的参数是可变参数,fmt.Printf 函数的格式如下:
 
  1. func Printf(format string, a ...interface{}) (n int, err error) {
  2. return Fprintf(os.Stdout, format, a...)
  3. }
fmt.Printf() 函数在调用时,第一个函数始终必须传入字符串,对应参数是 format,后面的参数数量可以变化,使用时,代码如下:
 
  1. fmt.Printf("pure string\n")
  2. fmt.Printf("value: %v %f\n", true, math.Pi)

遍历可变参数列表——获取每一个参数的值#

可变参数列表的数量不固定,传入的参数是一个切片。如果需要获得每一个参数的具体值时,可以对可变参数变量进行遍历,参见下面代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
  
import (
"bytes"
"fmt"
)
// 定义一个函数, 参数数量为0~n, 类型约束为字符串
func joinStrings(slist ...string) string {
  
// 定义一个字节缓冲, 快速地连接字符串
var b bytes.Buffer
// 遍历可变参数列表slist, 类型为[]string
for _, s := range slist {
// 将遍历出的字符串连续写入字节数组
b.WriteString(s)
}
  
// 将连接好的字节数组转换为字符串并输出
return b.String()
}
  
func main() {
// 输入3个字符串, 将它们连成一个字符串
fmt.Println(joinStrings("pig ", "and", " rat"))
fmt.Println(joinStrings("hammer", " mom", " and", " hawk"))
}
代码输出如下:
pig and rat
hammer mom and hawk

代码说明如下:
  • 第 8 行,定义了一个可变参数的函数,slist 的类型为 []string,每一个参数的类型是 string,也就是说,该函数只接受字符串类型作为参数。
  • 第 11 行,bytes.Buffer 在这个例子中的作用类似于 StringBuilder,可以高效地进行字符串连接操作。
  • 第 13 行,遍历 slist 可变参数,s 为每个参数的值,类型为 string。
  • 第 15 行,将每一个传入参数放到 bytes.Buffer 中。
  • 第 19 行,将 bytes.Buffer 中的数据转换为字符串作为函数返回值返回。
  • 第 24 行,输入 3 个字符串,使用 joinStrings() 函数将参数连接为字符串输出。
  • 第 25 行,输入 4 个字符串,连接后输出。

如果要获取可变参数的数量,可以使用 len() 函数对可变参数变量对应的切片进行求长度操作,以获得可变参数数量。

获得可变参数类型——获得每一个参数的类型#

当可变参数为 interface{} 类型时,可以传入任何类型的值。此时,如果需要获得变量的类型,可以通过 switch 类型分支获得变量的类型。下面的代码演示将一系列不同类型的值传入 printTypeValue() 函数,该函数将分别为不同的参数打印它们的值和类型的详细描述。

打印类型及值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package main
  
import (
"bytes"
"fmt"
)
  
func printTypeValue(slist ...interface{}) string {
  
// 字节缓冲作为快速字符串连接
var b bytes.Buffer
  
// 遍历参数
for _, s := range slist {
  
// 将interface{}类型格式化为字符串
str := fmt.Sprintf("%v", s)
  
// 类型的字符串描述
var typeString string
  
// 对s进行类型断言
switch s.(type) {
case bool: // 当s为布尔类型时
typeString = "bool"
case string: // 当s为字符串类型时
typeString = "string"
case int: // 当s为整型类型时
typeString = "int"
}
  
// 写值字符串前缀
b.WriteString("value: ")
  
// 写入值
b.WriteString(str)
  
// 写类型前缀
b.WriteString(" type: ")
  
// 写类型字符串
b.WriteString(typeString)
  
// 写入换行符
b.WriteString("\n")
  
}
return b.String()
}
  
func main() {
  
// 将不同类型的变量通过printTypeValue()打印出来
fmt.Println(printTypeValue(100, "str", true))
}
代码输出如下:
value: 100 type: int
value: str type: string
value: true type: bool

代码说明如下:
  • 第 8 行,printTypeValue() 输入不同类型的值并输出类型和值描述。
  • 第 11 行,bytes.Buffer 字节缓冲作为快速字符串连接。
  • 第 14 行,遍历 slist 的每一个元素,类型为 interface{}。
  • 第 17 行,使用 fmt.Sprintf 配合%v动词,可以将 interface{} 格式的任意值转为字符串。
  • 第 20 行,声明一个字符串,作为变量的类型名。
  • 第 23 行,switch s.(type) 可以对 interface{} 类型进行类型断言,也就是判断变量的实际类型。
  • 第 24~29 行为 s 变量可能的类型,将每种类型的对应类型字符串赋值到 typeString 中。
  • 第 33~42 行为写输出格式的过程。

在多个可变参数函数中传递参数#

可变参数变量是一个包含所有参数的切片,如果要在多个可变参数中传递参数,可以在传递时在可变参数变量中默认添加...,将切片中的元素进行传递,而不是传递可变参数变量本身。

下面的例子模拟 print() 函数及实际调用的 rawPrint() 函数,两个函数都拥有可变参数,需要将参数从 print 传递到 rawPrint 中。

可变参数传递:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main
  
import "fmt"
  
// 实际打印的函数
func rawPrint(rawList ...interface{}) {
  
// 遍历可变参数切片
for _, a := range rawList {
  
// 打印参数
fmt.Println(a)
}
}
  
// 打印函数封装
func print(slist ...interface{}) {
  
// 将slist可变参数切片完整传递给下一个函数
rawPrint(slist...)
}
  
func main() {
  
print(1, 2, 3)
}
代码输出如下:
1
2
3

对代码的说明:
  • 第 9~13 行,遍历 rawPrint() 的参数列表 rawList 并打印。
  • 第 20 行,将变量在 print 的可变参数列表中添加...后传递给 rawPrint()。
  • 第 25 行,传入 1、2、3 这 3 个整型值进行打印。

如果尝试将第 20 行修改为:
  1. rawPrint("fmt", slist)
rawPrint("fmt", slist)
再次执行代码,将输出:
[1 2 3]

此时,slist(类型为 []interface{})将被作为一个整体传入 rawPrint(),rawPrint() 函数中遍历的变量也就是 slist 的切片值。

可变参数使用...进行传递与切片间使用 append 连接是同一个特性。

Go语言defer(延迟执行语句)#

Go 语言的 defer 语句会将其后面跟随的语句进行延迟处理。在 defer 归属的函数即将返回时,将延迟处理的语句按 defer 的逆序进行执行,也就是说,先被 defer 的语句最后被执行,最后被 defer 的语句,最先被执行。

多个延迟执行语句的处理顺序#

下面的代码是将一系列的数值打印语句按顺序延迟处理,参见演示代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import (
"fmt"
)
func main() {
fmt.Println("defer begin")
// 将defer放入延迟调用栈
defer fmt.Println(1)
defer fmt.Println(2)
// 最后一个放入, 位于栈顶, 最先调用
defer fmt.Println(3)
fmt.Println("defer end")
}
代码输出如下:
defer begin
defer end
3
2
1

结果分析如下:
  • 代码的延迟顺序与最终的执行顺序是反向的。
  • 延迟调用是在 defer 所在函数结束时进行,函数结束可以是正常返回时,也可以是发生宕机时。

使用延迟执行语句在函数退出时释放资源#

处理业务或逻辑中涉及成对的操作是一件比较烦琐的事情,比如打开和关闭文件、接收请求和回复请求、加锁和解锁等。在这些操作中,最容易忽略的就是在每个函数退出处正确地释放和关闭资源。

defer 语句正好是在函数退出时执行的语句,所以使用 defer 能非常方便地处理资源释放问题。

1) 使用延迟并发解锁#

在下面的例子中会在函数中并发使用 map,为防止竞态问题,使用 sync.Mutex 进行加锁,参见下面代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var (
// 一个演示用的映射
valueByKey = make(map[string]int)
// 保证使用映射时的并发安全的互斥锁
valueByKeyGuard sync.Mutex
)
// 根据键读取值
func readValue(key string) int {
// 对共享资源加锁
valueByKeyGuard.Lock()
// 取值
v := valueByKey[key]
// 对共享资源解锁
valueByKeyGuard.Unlock()
// 返回值
return v
}
代码说明如下:
  • 第 3 行,实例化一个 map,键是 string 类型,值为 int。
  • 第 5 行,map 默认不是并发安全的,准备一个 sync.Mutex 互斥量保护 map 的访问。
  • 第 9 行,readValue() 函数给定一个键,从 map 中获得值后返回,该函数会在并发环境中使用,需要保证并发安全。
  • 第 11 行,使用互斥量加锁。
  • 第 13 行,从 map 中获取值。
  • 第 15 行,使用互斥量解锁。
  • 第 17 行,返回获取到的 map 值。

使用 defer 语句对上面的语句进行简化,参考下面的代码。
1
2
3
4
5
6
func readValue(key string) int {
valueByKeyGuard.Lock()
// defer后面的语句不会马上调用, 而是延迟到函数结束时调用
defer valueByKeyGuard.Unlock()
return valueByKey[key]
}
加粗部分为对比前面代码而修改和添加的代码,代码说明如下:
  • 第 6 行在互斥量加锁后,使用 defer 语句添加解锁,该语句不会马上执行,而是等 readValue() 返回时才会被执行。
  • 第 8 行,从 map 查询值并返回的过程中,与不使用互斥量的写法一样,对比上面的代码,这种写法更简单。

2) 使用延迟释放文件句柄#

文件的操作需要经过打开文件、获取和操作文件资源、关闭资源几个过程,如果在操作完毕后不关闭文件资源,进程将一直无法释放文件资源。在下面的例子中将实现根据文件名获取文件大小的函数,函数中需要打开文件、获取文件大小和关闭文件等操作。由于每一步系统操作都需要进行错误处理,而每一步处理都会造成一次可能的退出,因此就需要在退出时释放资源,而我们需要密切关注在函数退出处正确地释放文件资源。参考下面的代码:
  1. // 根据文件名查询其大小
  2. func fileSize(filename string) int64 {
  3. // 根据文件名打开文件, 返回文件句柄和错误
  4. f, err := os.Open(filename)
  5. // 如果打开时发生错误, 返回文件大小为0
  6. if err != nil {
  7. return 0
  8. }
  9. // 取文件状态信息
  10. info, err := f.Stat()
  11. // 如果获取信息时发生错误, 关闭文件并返回文件大小为0
  12. if err != nil {
  13. f.Close()
  14. return 0
  15. }
  16. // 取文件大小
  17. size := info.Size()
  18. // 关闭文件
  19. f.Close()
  20. // 返回文件大小
  21. return size
  22. }
代码说明如下:
  • 第 2 行,定义获取文件大小的函数,返回值是 64 位的文件大小值。
  • 第 5 行,使用 os 包提供的函数 Open(),根据给定的文件名打开一个文件,并返回操作文件用的句柄和操作错误。
  • 第 8 行,如果打开的过程中发生错误,如文件没找到、文件被占用等,将返回文件大小为 0。
  • 第 13 行,此时文件句柄f可以正常使用,使用f的方法 Stat() 来获取文件的信息,获取信息时,可能也会发生错误。
  • 第 16~19 行对错误进行处理,此时文件是正常打开的,为了释放资源,必须要调用 f 的 Close() 方法来关闭文件,否则会发生资源泄露。
  • 第 22 行,获取文件大小。
  • 第 25 行,关闭文件、释放资源。
  • 第 28 行,返回获取到的文件大小。

在上面的例子中加粗部分是对文件的关闭操作。下面使用 defer 对代码进行简化,代码如下:
  1. func fileSize(filename string) int64 {
  2. f, err := os.Open(filename)
  3. if err != nil {
  4. return 0
  5. }
  6. // 延迟调用Close, 此时Close不会被调用
  7. defer f.Close()
  8. info, err := f.Stat()
  9. if err != nil {
  10. // defer机制触发, 调用Close关闭文件
  11. return 0
  12. }
  13. size := info.Size()
  14. // defer机制触发, 调用Close关闭文件
  15. return size
  16. }
代码中加粗部分为对比前面代码而修改的部分,代码说明如下:
    • 第 10 行,在文件正常打开后,使用 defer,将 f.Close() 延迟调用。注意,不能将这一句代码放在第4行空行处,一旦文件打开错误,f将为空,在延迟语句触发时,将触发宕机错误。
    • 第 16 行和第 22 行,defer 后的语句(f.Close())将会在函数返回前被调用,自动释放资源。

Go语言处理运行时错误#

Go 语言的错误处理思想及设计包含以下特征:
  • 一个可能造成错误的函数,需要返回值中返回一个错误接口(error)。如果调用是成功的,错误接口将返回 nil,否则返回错误。
  • 在函数调用后需要检查错误,如果发生错误,进行必要的错误处理。
Go 语言没有类似 Java 或 .NET 中的异常处理机制,虽然可以使用 defer、panic、recover 模拟,但官方并不主张这样做。Go 语言的设计者认为其他语言的异常机制已被过度使用,上层逻辑需要为函数发生的异常付出太多的资源。同时,如果函数使用者觉得错误处理很麻烦而忽略错误,那么程序将在不可预知的时刻崩溃。
Go 语言希望开发者将错误处理视为正常开发必须实现的环节,正确地处理每一个可能发生错误的函数。同时,Go 语言使用返回值返回错误的机制,也能大幅降低编译器、运行时处理错误的复杂度,让开发者真正地掌握错误的处理。

net 包中的例子#

net.Dial() 是 Go 语言系统包 net 即中的一个函数,一般用于创建一个 Socket 连接。
net.Dial 拥有两个返回值,即 Conn 和 error。这个函数是阻塞的,因此在 Socket 操作后,会返回 Conn 连接对象和 error;如果发生错误,error 会告知错误的类型,Conn 会返回空。 根据 Go 语言的错误处理机制,Conn 是其重要的返回值。因此,为这个函数增加一个错误返回,类似为 error。参见下面的代码:
  1. func Dial(network, address string) (Conn, error) {
  2. var d Dialer
  3. return d.Dial(network, address)
  4. }
在 io 包中的 Writer 接口也拥有错误返回,代码如下:
  1. type Writer interface {
  2. Write(p []byte) (n int, err error)
  3. }
io 包中还有 Closer 接口,只有一个错误返回,代码如下:
  1. type Closer interface {
  2. Close() error
  3. }

错误接口的定义格式#

error 是 Go 系统声明的接口类型,代码如下:
  1. type error interface {
  2. Error() string
  3. }
所有符合 Error()string 格式的方法,都能实现错误接口。

Error() 方法返回错误的具体描述,使用者可以通过这个字符串知道发生了什么错误。

自定义一个错误#

返回错误前,需要定义会产生哪些可能的错误。在 Go 语言中,使用 errors 包进行错误的定义,格式如下:
  1. var err = errors.New("this is an error")
错误字符串由于相对固定,一般在包作用域声明,应尽量减少在使用时直接使用 errors.New 返回。

1) errors 包#

Go 语言的 errors 中对 New 的定义非常简单,代码如下:
  1. // 创建错误对象
  2. func New(text string) error {
  3. return &errorString{text}
  4. }
  5. // 错误字符串
  6. type errorString struct {
  7. s string
  8. }
  9. // 返回发生何种错误
  10. func (e *errorString) Error() string {
  11. return e.s
  12. }
代码说明如下:
  • 第 2 行,将 errorString 结构体实例化,并赋值错误描述的成员。
  • 第 7 行,声明 errorString 结构体,拥有一个成员,描述错误内容。
  • 第 12 行,实现 error 接口的 Error() 方法,该方法返回成员中的错误描述。

2) 在代码中使用错误定义#

下面的代码会定义一个除法函数,当除数为 0 时,返回一个预定义的除数为 0 的错误。
  1. package main
  2. import (
  3. "errors"
  4. "fmt"
  5. )
  6. // 定义除数为0的错误
  7. var errDivisionByZero = errors.New("division by zero")
  8. func div(dividend, divisor int) (int, error) {
  9. // 判断除数为0的情况并返回
  10. if divisor == 0 {
  11. return 0, errDivisionByZero
  12. }
  13. // 正常计算,返回空错误
  14. return dividend / divisor, nil
  15. }
  16. func main() {
  17. fmt.Println(div(1, 0))
  18. }
代码输出如下:
0 division by zero

代码说明:
  • 第 9 行,预定义除数为 0 的错误。
  • 第 11 行,声明除法函数,输入被除数和除数,返回商和错误。
  • 第 14 行,在除法计算中,如果除数为 0,计算结果为无穷大。为了避免这种情况,对除数进行判断,并返回商为 0 和除数为 0 的错误对象。
  • 第 19 行,进行正常的除法计算,没有发生错误时,错误对象返回 nil。

示例:在解析中使用自定义错误#

使用 errors.New 定义的错误字符串的错误类型是无法提供丰富的错误信息的。那么,如果需要携带错误信息返回,就需要借助自定义结构体实现错误接口。

下面代码将实现一个解析错误(ParseError),这种错误包含两个内容:文件名和行号。解析错误的结构还实现了 error 接口的 Error() 方法,返回错误描述时,就需要将文件名和行号返回。
  1. package main
  2. import (
  3. "fmt"
  4. )
  5. // 声明一个解析错误
  6. type ParseError struct {
  7. Filename string // 文件名
  8. Line int // 行号
  9. }
  10. // 实现error接口,返回错误描述
  11. func (e *ParseError) Error() string {
  12. return fmt.Sprintf("%s:%d", e.Filename, e.Line)
  13. }
  14. // 创建一些解析错误
  15. func newParseError(filename string, line int) error {
  16. return &ParseError{filename, line}
  17. }
  18. func main() {
  19. var e error
  20. // 创建一个错误实例,包含文件名和行号
  21. e = newParseError("main.go", 1)
  22. // 通过error接口查看错误描述
  23. fmt.Println(e.Error())
  24. // 根据错误接口具体的类型,获取详细错误信息
  25. switch detail := e.(type) {
  26. case *ParseError: // 这是一个解析错误
  27. fmt.Printf("Filename: %s Line: %d\n", detail.Filename, detail.Line)
  28. default: // 其他类型的错误
  29. fmt.Println("other error")
  30. }
  31. }
代码输出如下:
main.go:1
Filename: main.go Line: 1

代码说明如下:
  • 第 8 行,声明了一个解析错误的结构体,解析错误包含有 2 个成员:文件名和行号。
  • 第 14 行,实现了错误接口,将成员的文件名和行号格式化为字符串返回。
  • 第 19 行,根据给定的文件名和行号创建一个错误实例。
  • 第 25 行,声明一个错误接口类型。
  • 第 27 行,创建一个实例,这个错误接口内部是 *ParserError 类型,携带有文件名 main.go 和行号 1。
  • 第 30 行,调用 Error() 方法,通过第 15 行返回错误的详细信息。
  • 第 33 行,通过错误断言,取出发生错误的详细类型。
  • 第 34 行,通过分析这个错误的类型,得知错误类型为 *ParserError,此时可以获取到详细的错误信息。
  • 第 36 行,如果不是我们能够处理的错误类型,会打印出其他错误做出其他的处理。

错误对象都要实现 error 接口的 Error() 方法,这样,所有的错误都可以获得字符串的描述。如果想进一步知道错误的详细信息,可以通过类型断言,将错误对象转为具体的错误类型进行错误详细信息的获取。





Go语言宕机(panic)#

宕机不是一件很好的事情,可能造成体验停止、服务中断,就像没有人希望在取钱时遇到 ATM 机蓝屏一样。但是,如果在损失发生时,程序没有因为宕机而停止,那么用户将会付出更大的代价,这种代价可以是金钱、时间甚至生命。因此,宕机有时是一种合理的止损方法。

手动触发宕机#

Go 语言可以在程序中手动触发宕机,让程序崩溃,这样开发者可以及时地发现错误,同时减少可能的损失。

Go 语言程序在宕机时,会将堆栈和 goroutine 信息输出到控制台,所以宕机也可以方便地知晓发生错误的位置。如果在编译时加入的调试信息甚至连崩溃现场的变量值、运行状态都可以获取,那么如何触发宕机呢?例如下面的代码:
  1. package main
  2. func main() {
  3. panic("crash")
  4. }
代码运行崩溃并输出如下:

panic: crash

goroutine 1 [running]:
main.main()
    F:/src/tester/main.go:5 +0x6b

以上代码中只用了一个内建的函数 panic() 就可以造成崩溃,panic() 的声明如下:
  1. func panic(v interface{})
panic() 的参数可以是任意类型,后文将提到的 recover 参数会接收从 panic() 中发出的内容。

在运行依赖的必备资源缺失时主动触发宕机#

regexp 是 Go 语言的正则表达式包,正则表达式需要编译后才能使用,而且编译必须是成功的,表示正则表达式可用。

编译正则表达式函数有两种,具体如下:

1) func Compile(expr string) (*Regexp, error)#

编译正则表达式,发生错误时返回编译错误,Regexp 为 nil,该函数适用于在编译错误时获得编译错误进行处理,同时继续后续执行的环境。

2) func MustCompile(str string) *Regexp#

当编译正则表达式发生错误时,使用 panic 触发宕机,该函数适用于直接适用正则表达式而无须处理正则表达式错误的情况。

MustCompile 的代码如下:
  1. func MustCompile(str string) *Regexp {
  2. regexp, error := Compile(str)
  3. if error != nil {
  4. panic(`regexp: Compile(` + quote(str) + `): ` + error.Error())
  5. }
  6. return regexp
  7. }
代码说明如下:
  • 第 1 行,编译正则表达式函数入口,输入包含正则表达式的字符串,返回正则表达式对象。
  • 第 2 行,调用 Compile() 是编译正则表达式的入口函数,该函数返回编译好的正则表达式对象和错误。
  • 第 3 和第 4 行判断如果有错,则使用 panic() 触发宕机。
  • 第 6 行,没有错误时返回正则表达式对象。

手动宕机进行报错的方式不是一种偷懒的方式,反而能迅速报错,终止程序继续运行,防止更大的错误产生。不过,如果任何错误都使用宕机处理,也不是一种良好的设计。因此应根据需要来决定是否使用宕机进行报错。

在宕机时触发延迟执行语句#

当 panic() 触发的宕机发生时,panic() 后面的代码将不会被运行,但是在 panic() 函数前面已经运行过的 defer 语句依然会在宕机发生时发生作用,参考下面代码:
  1. package main
  2. import "fmt"
  3. func main() {
  4. defer fmt.Println("宕机后要做的事情1")
  5. defer fmt.Println("宕机后要做的事情2")
  6. panic("宕机")
  7. }
代码输出如下:

宕机后要做的事情2
宕机后要做的事情1
panic: 宕机

goroutine 1 [running]:
main.main()
    F:/src/tester/main.go:8 +0x1a4

对代码的说明:
  • 第 6 行和第 7 行使用 defer 语句延迟了 2 个语句。
  • 第 8 行发生宕机。

宕机前,defer 语句会优先被执行,由于第 7 行的 defer 后执行,因此会在宕机前,这个 defer 会优先处理,随后才是第 6 行的 defer 对应的语句。这个特性可以用来在宕机发生前进行宕机信息处理。


Go语言宕机恢复(recover)#

无论是代码运行错误由 Runtime 层抛出的 panic 崩溃,还是主动触发的 panic 崩溃,都可以配合 defer 和 recover 实现错误捕捉和恢复,让代码在发生崩溃后允许继续运行。

提示#

在其他语言里,宕机往往以异常的形式存在。底层抛出异常,上层逻辑通过 try/catch 机制捕获异常,没有被捕获的严重异常会导致宕机,捕获的异常可以被忽略,让代码继续运行。

Go 没有异常系统,其使用 panic 触发宕机类似于其他语言的抛出异常,那么 recover 的宕机恢复机制就对应 try/catch 机制。

让程序在崩溃时继续执行#

下面的代码实现了 ProtectRun() 函数,该函数传入一个匿名函数或闭包后的执行函数,当传入函数以任何形式发生 panic 崩溃后,可以将崩溃发生的错误打印出来,同时允许后面的代码继续运行,不会造成整个进程的崩溃。

保护运行函数:
  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. )
  6. // 崩溃时需要传递的上下文信息
  7. type panicContext struct {
  8. function string // 所在函数
  9. }
  10. // 保护方式允许一个函数
  11. func ProtectRun(entry func()) {
  12. // 延迟处理的函数
  13. defer func() {
  14. // 发生宕机时,获取panic传递的上下文并打印
  15. err := recover()
  16. switch err.(type) {
  17. case runtime.Error: // 运行时错误
  18. fmt.Println("runtime error:", err)
  19. default: // 非运行时错误
  20. fmt.Println("error:", err)
  21. }
  22. }()
  23. entry()
  24. }
  25. func main() {
  26. fmt.Println("运行前")
  27. // 允许一段手动触发的错误
  28. ProtectRun(func() {
  29. fmt.Println("手动宕机前")
  30. // 使用panic传递上下文
  31. panic(&panicContext{
  32. "手动触发panic",
  33. })
  34. fmt.Println("手动宕机后")
  35. })
  36. // 故意造成空指针访问错误
  37. ProtectRun(func() {
  38. fmt.Println("赋值宕机前")
  39. var a *int
  40. *a = 1
  41. fmt.Println("赋值宕机后")
  42. })
  43. fmt.Println("运行后")
  44. }
代码输出结果:

运行前
手动宕机前
error: &{手动触发panic}
赋值宕机前
runtime error: runtime error: invalid memory address or nil pointer
dereference
运行后

对代码的说明:
  • 第 9 行声明描述错误的结构体,成员保存错误的执行函数。
  • 第 17 行使用 defer 将闭包延迟执行,当 panic 触发崩溃时,ProtectRun() 函数将结束运行,此时 defer 后的闭包将会发生调用。
  • 第 20 行,recover() 获取到 panic 传入的参数。
  • 第 22 行,使用 switch 对 err 变量进行类型断言。
  • 第 23 行,如果错误是有 Runtime 层抛出的运行时错误,如空指针访问、除数为 0 等情况,打印运行时错误。
  • 第 25 行,其他错误,打印传递过来的错误数据。
  • 第 44 行,使用 panic 手动触发一个错误,并将一个结构体附带信息传递过去,此时,recover 就会获取到这个结构体信息,并打印出来。
  • 第 57 行,模拟代码中空指针赋值造成的错误,此时会由 Runtime 层抛出错误,被 ProtectRun() 函数的 recover() 函数捕获到。

panic和recover的关系#

panic 和 defer 的组合有如下特性:
  • 有 panic 没 recover,程序宕机。
  • 有 panic 也有 recover 捕获,程序不会宕机。执行完对应的 defer 后,从宕机点退出当前函数后继续执行。

提示#

虽然 panic/recover 能模拟其他语言的异常机制,但并不建议代表编写普通函数也经常性使用这种特性。
在 panic 触发的 defer 函数内,可以继续调用 panic,进一步将错误外抛直到程序整体崩溃。
如果想在捕获错误时设置当前函数的返回值,可以对返回值使用命名返回值方式直接进行设置。
posted @   鲸鱼的海老大  阅读(12)  评论(0编辑  收藏  举报
编辑推荐:
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
阅读排行:
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架
点击右上角即可分享
微信分享提示
CONTENTS