函数包含连续的执行语句,可以在代码中通过调用函数来执行它们。函数能够将一个复杂的工作切分成多个更小的模块,使得多人协作变得更加容易。另外,函数对它的使用者隐藏了实现细节。这几方面的特性使函数成为多数编程语言的重要特性之一。
我们之前已经见过许多函数,现在让我们更彻底地探究一下函数。本章的运行示例是一个网络爬虫,它是Web搜索引擎的组件之一,负责抓取网页并分析页面包含的链接,将链接指向的页面也抓取下来,循环往复。利用网络爬虫的实现,我们可以更充分地了解到Go语言的递归、匿名函数、错误处理等方面的函数特性。
函数声明
每个函数声明都包含一个名字、一个形参列表、一个可选的返回列表以及函数体:
func name(parameter-list) (result-list) {
body
}
形参列表指定了一组变量的参数名和参数类型,这些局部变量都由调用者提供的实参传递而来。返回列表则指定了函数返回值的类型。当函数返回一个未命名的返回值或者没有返回值的时候,返回列表的圆括号可以省略。如果一个函数既省略返回列表也没有任何返回值,那么设计这个函数的目的是调用函数之后所带来的附加效果。
在下面的hypot函数中:
1
2
3
4
5
6
// x和y是函数声明中的形参,3和4是调用函数时的实参
// 函数返回一个类型为f1oat64的值。
func hypot(x, y float64) float64 {
return math.Sqrt(x * x + y * y)
}
fmt.Println(hypot(3, 4)) // "5"
返回值也可以像形参一样命名。这个时候,每一个命名的返回值会声明为一个局部变量,并根据变量类型初始化为相应的0值。
当函数存在返回列表时,必须显式地以return语句结束,除非函数明确不会走完整个执行流程,比如在函数中抛出宕机异常或者函数体内存在一个没有break退出条件的无限for循环。
在hypot函数中使用到一种简写,如果几个形参或者返回值的类型相同,那么类型只需要写一次。以下两个声明是完全相同的:
1
2
func f(i, j, k int, s, t string) { /* ... */ }
func f(i int, j int, k int, s string, t string) { /* ... */ }
下面使用4种方式声明一个带有两个形参和一个返回值的函数,所有变量都是int类型。空白标识符用来强调这个形参在函数中未使用。
1
2
3
4
5
6
7
8
9
func add(x int, y int) int { return x + y }
func sub(x, y int) (z int) { z = x - y; return } // 比较新鲜的方式
func first(x int, _ int) int { return x }
func zero(int, int) int { return 0 }
fmt.Printf("%T\n", add) // 打印出的类型:"func(int, int) int"
fmt.Printf("%T\n", sub) // "func(int, int) int"
fmt.Printf("%T\n", first) // "func(int, int) int"
fmt.Printf("%T\n", zero) // "func(int, int) int"
函数的类型称作函数签名。当两个函数拥有相同的形参列表和返回列表时,认为这两个函数的类型或签名是相同的。而形参和返回值的名字不会影响到函数类型,采用简写同样也不会影响到函数的类型。
每一次调用函数都需要提供实参来对应函数的每一个形参,包括参数的调用顺序也必须一致。Go语言没有默认参数值的概念也不能指定实参名,所以除了用于文档说明之外,形参和返回值的命名不会对调用方有任何影响。
形参变量都是函数的局部变量,初始值由调用者提供的实参传递。函数形参以及命名返回值同属于函数最外层作用域的局部变量。
实参是按值传递的,所以函数接收到的是每个实参的副本;修改函数的形参变量并不会影响到调用者提供的实参。然而,如果提供的实参包含引用类型,比如指针、slice、map、函数或者通道,那么当函数使用形参变量时就有可能会间接地修改实参变量。
你可能偶尔会看到有些函数的声明没有函数体,那说明这个函数使用除了Go以外的语言实现。这样的声明定义了该函数的签名。
1
2
package math
func Sin(x float64) float64 // 使用了汇编语言实现
递归
函数可以递归调用,这意味着函数可以直接或间接地调用自己。递归是一种实用的技术,可以处理许多带有递归特性的数据结构。在前部分使用递归实现了对一棵树进行插入排序。本节再一次使用递归处理HTML文件。
下面的代码示例使用了一个非标准的包golang.org/x/net/html,它提供了解析HTML的功能。golang.org/x…下的仓库(比如网络、国际化语言处理、移动平台、图片处理、加密功能以及开发者工具)都由Go团队负责设计和维护。这些包并不属于标准库,原因是它们还在开发当中,或者很少被Go程序员使用。
我们需要的golang.org/x/net/htmlAPI如下面的代码所示。函数html.Parse读入一段字节序列,解析它们,然后返回HTML文档树的根节点html.Node。HTML有多种节点,比如文本、注释等。但这里我们只关心表单的元素节点<name key = 'value'>。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package html
type Node struct {
Type NodeType
Data string
Attr []Attribute
FirstChild, Nextsibling *Node
}
type NodeType int32
const (
ErrorNode NodeType = iota
TextNode
DocumentNode
ElementNode
CommentNode
DoctypeNode
)
type Attribute struct {
Key, Val string
}
func Parse(r io.Reader) (*Node, error)
主函数从标准输入中读入HTML,使用递归的visit函数获取HTML文本的超链接,并且把所有的超链接输出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Findlinks1输出从标准输入中读入的HTML文档中的所有链接
package main
import (
"fmt"
"os"
"golang.org/x/net/html"
)
func main() {
doc, err := html.Parse(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "findlinks1: %v\n", err)
os.Exit(1)
}
for _, link range visit(nil, doc) {
fmt.Println(link)
}
}
visit函数遍历HTML树上的所有节点,从HTML锚元素<a href='...'>中得到href属性的内容,将获取到的链接内容添加到字符串slice,最后返回这个slice:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// visit函数会将n节点中的每个链接添加到结果中
func visit(links []string, n *html.Node) []string {
if n.Type == html.ElementNode && n.Data == "a" {
for _, a := range n.Attr {
if a.Key == "href" {
links = append(links, a.Val)
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
links = visit(links, c)
}
return links
}
要对树中的任意节点n进行递归,visit递归地调用自己去访问节点n的所有子节点,并且将访问过的节点保存在FirstChild链表中。
我们在Go的主页运行findlinks,使用管道将本书1.5节完成的fetch程序的输出定向到find1inks。稍稍修改输出,使之更加简洁。
(看代码即可)
注意一个细节:当函数递归调用自己时,被调用的函数会接收到栈的副本。尽管被调用者可能会对slice进行元素的添加、修改甚至创建新数组的操作,但它并不会修改调用者原来传递的元素,所以当被调函数返回时,调用者的栈依旧保持原样。
许多编程语言使用固定长度的函数调用栈;大小在64KB到2MB之间。递归的深度会受限于固定长度的栈大小,所以当进行深度递归调用时必须谨防栈溢出。固定长度的栈甚至会造成一定的安全隐患。相比固定长的栈,Go语言的实现使用了可变长度的栈,栈的大小会随着使用而增长,可达到1GB左右的上限。这使得我们可以安全地使用递归而不用担心溢出问题。
多返回值
一个函数能够返回不止一个结果。我们之前已经见过标准包内的许多函数返回两个值,一个期望得到的计算结果与一个错误值,或者一个表示函数调用是否正确的布尔值。下面来看看怎样写一个这样的函数。
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
func main() {
for _, url := range os.Args[1:] {
links, err := findLinks(url)
if err != nil {
fmt.Fprintf(os.Stderr, "findlinks2: %v\n", err)
continue
}
for _, link := range links {
fmt.Println(link)
}
}
}
// findLinks发起一个HTTP的GET请求,解析返回的HTML页面,并返回所有链接
func findLinks(url string) ([]string, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err // 返回两个值
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("getting %s: %s", url, resp.Status)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}
return visit(nil, doc), nil
}
findLinks函数有4个返回语句,每一个语句返回一对值。前3个返回语句将函数从http和html包中获得的错误信息传递给调用者。第一个返回语句中,错误直接返回;第二个返回语句和第三个返回语句则使用fmt.Errorf(参考7.8节)格式化处理过的附加上下文信息。如果findLinks调用成功,最后一个返回语句将返回链接的slice,且error为空。
我们必须保证resp.Body正确关闭使得网络资源正常释放。即使在发生错误的情况下也必须释放资源。Go语言的垃圾回收机制将回收未使用的内存,但不能指望它会释放未使用的操作系统资源,比如打开的文件以及网络连接。必须显式地关闭它们。
调用一个涉及多值计算的函数会返回一组值。如果调用者要使用这些返回值,则必须显式地将返回值赋给变量。
1
links, err := findLinks(url)
忽略其中一个返回值可以将它赋给一个空标识符。
1
links, _ := findLinks(url) // 忽略的错误
返回一个多值结果可以是调用另一个多值返回的函数,就像下面的函数,这个函数的行为和findLinks类似,只是多了一个记录参数的动作。
1
2
3
4
func findLinksLog(url string) ([]string, error) {
log.Printf("findLinks %s", url)
return findLinks(url)
}
一个多值调用可以作为单独的实参传递给拥有多个形参的函数中。尽管很少在生产环境使用,但是这个特性有的时候可以方便调试,它使得我们仅仅使用一条语句就可以输出所有的结果。下面两个输出语句的效果是一致的。
1
2
3
4
log.Println(findLinks(url))
links, err := findLinks(url)
log.Println(links, err)
良好的名称可以使得返回值更加有意义。尤其在一个函数返回多个结果且类型相同时,名字的选择更加重要,比如:
1
2
3
func size(rect image.Rectangle) (width, height int)
func Split(path string) (dir, file string)
func HourMinSec(t time.Time) (hour, minute, second int)
但不必始终为每个返回值单独命名。比如,习惯上,最后的一个布尔返回值表示成功与否,一个error结果通常都不需要特别说明。
一个函数如果有命名的返回值,可以省略return语句的操作数,这称为裸返回。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// CountWordsAndImages发送一个HTTP GET请求,并且获取文档的
// 字数与图片数量
func CountwordsAndImages(url string) (words, images int, err error) {
resp, err := http.Get(url)
if err != nil {
return // 这里return个啥,因为已经给定了要返回的参数信息哇,第二个括号内的内容
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
err = fmt.Errorf("parsing HTML: %s", err)
return
}
words, images = countWordsAndImages(doc)
return
}
func countwordsAndImages(n *html.Node) (words, images int) { /* ... */ }
裸返回是将每个命名返回结果按照顺序返回的快捷方法,所以在上面的函数中,每个return语句都等同于:
1
return words, images, err
像在这个函数中存在许多返回语句且有多个返回结果,裸返回可以消除重复代码,但是并不能使代码更加易于理解。
错误
有一些函数总是成功返回的。比如,strings.Contains和strconv.FormatBool对所有可能的参数变量都有定义好的返回结果,不会调用失败一尽管还有灾难性的和不可预知的场景,像内存耗尽,这类错误的表现和起因相差甚远而且恢复的希望也很渺茫。
其他的函数只要符合其前置条件就能够成功返回。比如time.Date函数始终会利用年月等构成time.Time,但是如果最后一个参数(表示时区)为nil则会导致宕机。这个宕机标志着这是一个明显的bug,应该避免这样调用代码。
对于许多其他函数,即使在高质量的代码中,也不能保证一定能够成功返回,因为有些因素并不受程序设计者的掌控。比如任何操作I/O的函数都一定会面对可能的错误,只有没有经验的程序员会认为一个简单的读或写不会失败。事实上,这些地方是我们最需要关注的,很多可靠的操作都可能会毫无征兆地发生错误。
因此错误处理是包的API设计或者应用程序用户接口的重要部分,发生错误只是许多预料行为中的一种而已。这就是Go语言处理错误的方法。
如果当函数调用发生错误时返回一个附加的结果作为错误值,习惯上将错误值作为最后一个结果返回。如果错误只有一种情况,结果通常设置为布尔类型,就像下面这个查询缓存值的例子里面,往往都返回成功,只有不存在对应的键值的时候返回错误:
1
2
3
4
value, ok := cache.Lookup(key)
if !ok {
// ..cache[key] 不存在...
}
更多时候,尤其对于I/O操作,错误的原因可能多种多样,而调用者则需要一些详细的信息。在这种情况下,错误的结果类型往往是error。
error是内置的接口类型。后续将通过介绍错误处理揭示更多关于error类型的深层含义。目前我们已经了解到,一个错误可能是空值或者非空值,空值意味着成功而非空值意味着失败,且非空的错误类型有一个错误消息字符串,可以通过调用它的Error方法或者通过调用fmt.Println(err)或fmt.Printf("%v", err)直接输出错误消息:
一般当一个函数返回一个非空错误时,它其他的结果都是未定义的而且应该忽略。然而,有一些函数在调用出错的情况下会返回部分有用的结果。比如,如果在读取一个文件的时候发生错误,调用Red函数后返回能够成功读取的字节数与相对应的错误值。正确的行为通常是在调用者处理错误前先处理这些不完整的返回结果。因此在文档中清晰地说明返回值的意义是很重要的。
与许多其他语言不同,Go语言通过使用普通的值而非异常来报告错误。尽管Go语言有异常机制,这将在5.9节进行介绍,但是Go语言的异常只是针对程序bug导致的预料外的错误,而不能作为常规的错误处理方法出现在程序中。
这样做的原因是异常会陷入带有错误消息的控制流去处理它,通常会导致预期外的结果:错误会以难以理解的栈跟踪信息报告给最终用户,这些信息大都是关于程序结构方面的而不是简单明了的错误消息。
相比之下,Go程序使用通常的控制流机制(比如if和return语句)应对错误。这种方式在错误处理逻辑方面要求更加小心谨慎,但这恰恰是设计的要点。
错误处理策略
当一个函数调用返回一个错误时,调用者应当负责检查错误并采取合适的处理应对。根据情形,将有许多可能的处理场景。接下来我们看5个例子。
首先也最常见的情形是将错误传递下去,使得在子例程中发生的错误变为主调例程的错误。接下来结合上述findlinks函数来说明,如果http.Get失败,findlinks不做任何操作立即向调用者返回HTTP错误:
1
2
3
4
resp, err := http.Get(url)
if err != nil {
return nil, err
}
对比之下,如果调用html.Parse失败,findLinks将不会直接返回HTML解析的错误,因为它缺失两个关键信息:解析器的出错信息与被解析文档的URL。在这种情况下,findLinks构建一个新的错误消息,其中包含我们需要的所有相关信息和解析的错误信息:
1
2
3
4
5
6
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url, err)
}
fmt.Errorf使用fmt.Sprintf函数格式化一条错误消息并且返回一个新的错误值。我们为原始的错误消息不断地添加额外的上下文信息来建立一个可读的错误描述。当错误最终被程序的main函数处理时,它应当能够提供一个从最根本问题到总体故障的清晰因果链,这让我想到NASA的事故调查有这样一个例子:
genesis: crashed: no parachute: G-switch failed: bad relay orientation
因为错误消息频繁地串联起来,所以消息字符串首字母不应该大写而且应该避免换行。错误结果可能会很长,但能够使用grep这样的工具找到我们需要的信息。
设计一个错误消息的时候应当慎重,确保每一条消息的描述都是有意义的,包含充足的相关信息,并且保持一致性,不论被同一个函数还是同一个包下面的一组函数返回时,这样的错误都可以保持统一的形式和错误处理方式。
比如,os包保证每一个文件操作(比如os.Open或针对打开的文件的Read、Write或c1ose方法)返回的错误不仅包括错误的信息(没有权限、路径不存在等)还包含文件的名字,因此调用者在构造错误消息的时候不需要再包含这些信息。
一般地,f(x)调用只负责报告函数的行为f和参数值x,因为它们和错误的上下文相关。调用者负责添加进一步的信息,但是f(x)本身并不会,就像上面函数中URL和html.Parse的关系。
我们接下来看一下第二种错误处理策略。对于不固定或者不可预测的错误,在短暂的间隔后对操作进行重试是合乎情理的,超出一定的重试次数和限定的时间后再报错退出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// WaitForServer尝试连接URL对应的服务器
// 在一分钟内使用指数退避策略进行重试
// 所有的尝试失败后返回错误
func WaitForServer(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
// deadline即为时间间隔
for tries := 0; time.Now().Before(deadline); tries++ {
_, err := http.Head(url)
if err == nil {
return nil // 成功
}
log.Printf("server not responding (%s); retrying...", err)
time.Sleep(time.Second << uint(tries)) // 指数退避策略
return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}
}
第三,如果依旧不能顺利进行下去,调用者能够输出错误然后优雅地停止程序,但一般这样的处理应该留给主程序部分。通常库函数应当将错误传递给调用者,除非这个错误表示一个内部一致性错误,这意味着库内部存在bug。
1
2
3
4
5
// (In function main.)
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
一个更加方便的方法是通过调用log.Fatalf实现相同的效果。就和所有的日志函数一样,它默认会将时间和日期作为前缀添加到错误消息前。
1
2
3
if err := WaitForServer(url); err != nil {
log.Fatalf("Site is down: %v\n", err)
}
默认的格式有助于长期运行的服务器,而对于交互式的命令行工具则意义不大:
2006/01/02 15:04:05 Site is down: no such domain: bad.gopl.io
一种更吸引人的输出方式是自己定义命令的名称作为log包的前缀,并且将日期和时间略去。
1
2
log.SetPrefix("wait: ")
log.SetFlags(0)
第四,在一些错误情况下,只记录下错误信息然后程序继续运行。同样地,可以选择使用log包来增加日志的常用前缀:
1
2
3
if err := Ping(); err != nil {
log.Printf("ping failed: %v; networking disabled", err)
}
并且直接输出到标准错误流:
1
2
3
if err := Ping(); err != nil {
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)
}
所有log函数都会为缺少换行符的日志补充一个换行符。
第五,在某些罕见的情况下我们可以直接安全地忽略掉整个日志:
1
2
3
4
5
6
dir,err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v", err)
}
// 使用临时目录
os.RemoveAll(dir) // 忽略错误,$TMPDIR会被周期性删除
调用os.RemoveAll可能会失败,但程序忽略了这个错误,原因是操作系统会周期性地清理临时目录。在这个例子中,我们有意地抛弃了错误,但程序的逻辑看上去就和我们忘记去处理了一样。要习惯考虑到每一个函数调用可能发生的出错情况,当你有意地忽略一个错误的时候,清楚地注释一下你的意图。
Go语言的错误处理有特定的规律。进行错误检查之后,检测到失败的情况往往都在成功之前。如果检测到的失败导致函数返回,成功的逻辑一般不会放在else块中而是在外层的作用域中。函数会有一种通常的形式,就是在开头有一连串的检查用来返回错误,之后跟着实际的函数体一直到最后。
文件结束标识
通常,最终用户会对函数返回的多种错误感兴趣而不是中间涉及的程序逻辑。偶尔,一个程序必须针对不同各种类的错误采取不同的措施。考虑如果要从一个文件中读取个字节的数据。如果是文件本身的长度,任何错误都代表操作失败。另一方面,如果调用者反复地尝试读取固定大小的块直到文件耗尽,调用者必须把读取到文件尾的情况区别于遇到其他错误的操作。为此,io包保证任何由文件结束引起的读取错误,始终都将会得到一个与众不同的错误一io.EoF,它的定义如下:
1
2
3
4
package io
import "errors"
// 当没有更多输入时,将会返回EOF
var EOF = errors.New("EOF")
调用者可以使用一个简单的比较操作来检测这种情况,在下面的循环中,不断从标准输入中读取字符。
1
2
3
4
5
6
7
8
9
10
11
in := bufio.NewReader(os.Stdin)
for {
r, _, err := in.ReadRune()
if err == io.EOF {
break // 结束读取
}
if err != nil {
return fmt.Errorf("read failed: %v", err)
}
}
// ...使用 r...
除了反映这个实际情况外,因为文件结束的条件没有其他信息,所以io.EOF有一条固定的错误消息EOF。对于其他错误,我们可能需要同时得到错误相关的本质原因和数量信息,因此一个固定的错误值并不能满足我们的需求。
函数变量
函数在Go语言中是头等重要的值:就像其他值,函数变量也有类型,而且它们可以赋给变量或者传递或者从其他函数中返回。函数变量可以像其他函数一样调用。比如:
1
2
3
4
5
6
7
8
9
10
func square(n int) int { return n * n }
func negative(n int) int { return -n }
func product(m, n int) int return { m * n }
f := square
fmt.Println(f(3)) // "9"
f = negative
fmt.Println(f(3)) // "-3"
fmt.Printf("%T\n", f) // "func(int) int"
f = product // 编译错误:不能把类型func(int, int) int 赋给 func(int) int
函数类型的零值是nil(空值),调用一个空的函数变量将导致宕机。
1
2
var f func(int) int
f(3) // 宕机:调用空函数
函数变量可以和空值相比较:
1
2
3
4
var f func(int) int // func(int) int代表一种类型
if f != nil {
f(3)
}
但它们本身不可比较,所以不可以互相进行比较或者作为键值出现在map中。
函数变量使得函数不仅将数据进行参数化,还将函数的行为当作参数进行传递。标准库中蕴含者大量的例子。比如,strings.Map对字符串中的每一个字符使用一个函数,将结果连接起来变成另一个字符串。
1
2
3
4
5
func add1(r rune) rune return { r + 1 }
fmt.Println(strings.Map(add1, "HAL-9000")) // 字符串都进了1位,"IBM.:111"
fmt.Println(strings.Map(add1, "VMS")) // "WNT"
fmt.Println(strings.Map(add1, "Admix")) // "Benjy"
匿名函数
命名函数只能在包级别的作用域进行声明,但我们能够使用函数字面量在任何表达式内指定函数变量。函数字面量就像函数声明,但在func关键字后面没有函数的名称。它是个表达式,它的值称作匿名函数。
函数字面量在我们需要使用的时候才定义。就像下面这个例子,之前的函数调用strings.Map可以写成:
1
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
更重要的是,以这种方式定义的函数能够获取到整个词法环境,因此里层的函数可以使用外层函数中的变量,如下面这个示例所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// squares函数的返回值是一个函数,返回的函数类型是func() int
// 记住,返回的是一个函数类型,这是很关键的,虽然返回的是匿名类型
// 但仍然是返回了一个无参,但返回int类型的函数
func squares() func() int {
var x int
return func() int { // 这是一个匿名函数
x++
return x * x
}
}
func main() {
f := squares() // 这里并未执行,f是一个函数类型
fmt.Println(f()) // 1
fmt.Println(f()) // 4
fmt.Println(f()) // 9
fmt.Println(f()) // 16
}
函数squares返回了另一个函数,类型是func() int。调用squares创建了一个局部变量x而且返回了一个匿名函数,每次调用squares都会递增x的值然后返回x的平方。第二次调用squares函数将创建第二个变量x,然后返回一个递增x值的新匿名函数。
这个求平方的示例演示了函数变量不仅是一段代码还可以拥有状态。里层的匿名函数能够获取和更新外层squares函数的局部变量。这些隐藏的变量引用就是我们把函数归类为引用类型而且函数变量无法进行比较的原因。函数变量类似于使用闭包方法实现的变量,Go程序员通常把函数变量称为闭包。
我们再一次看到这个例子里面变量的生命周期不是由它的作用域所决定的:变量x在main函数中返回squares函数后依旧存在(虽然x在这个时候是隐藏在函数变量f中的)。
下面这段展示拓扑排序的示例代码是匿名函数在递归中的运用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func topoSort(m map[string][]string) []string {
var order []string // 顺序
seen := make(map[string]bool) // 一门课是否修过
var visitAll func(items []string) // 定义变量,为函数类型的变量
visitAll = func(items []string) { // 匿名函数具体的行为,因为需要递归,所以需要先命名
for _, item := range items {
if !seen[item] {
seen[item] = true // 标记已修课程
visitAll(m[item]) // 递归
order = append(order, item)
}
}
}
var keys []string
for key := range m { // 遍历的是键吗
keys = append(keys, key)
}
sort.Strings(keys) // 将键排序
visitAll(keys)
return order // 返回最终的修课顺序
}
警告:捕获迭代变量
在这一节,我们将看到Go语言的词法作用域规则的陷阱,有时会得到令你吃惊的结果。我们强烈建议你先理解这个问题再进行下一节的阅读,因为即使是有经验的程序员也会掉入这些陷阱。
假设一个程序必须创建一系列的目录之后又会删除它们。可以使用一个包含函数变量的slice进行清理操作。(这个示例中省略了所有的错误处理逻辑。)
1
2
3
4
5
6
7
8
9
10
11
var rmdirs []func() // 函数数组
for _, d := range tempDirs() {
dir := d // 注意,这一行是必需的
os.MkdirA11(dir, 0755) // 也创建父目录
rmdirs = append(rmdirs, func() { os.RemoveAll(dir) })
}
// ...这里做一些处理...
for _, rmdir := range rmdirs {
rmdir() // 清理
}
你可能会奇怪,为什么在循环体内将循环变量赋给一个新的局部变量dir,而不是在下面这个略有错误的变体中直接使用循环变量dir。
1
2
3
4
5
var rmdirs []func()
for _, dir := range tempDirs() {
os.MkdirAll(dir, 0755)
rmdirs append(rmdirs, func(){ os.RemoveAll(dir) }) // 不正确
}
这个原因是循环变量的作用域的规则限制。在上面的程序中,dir在for循环引进的一个块作用域内进行声明。在循环里创建的所有函数变量共享相同的变量: 一个可访问的存储位置,而不是固定的值。dir变量的值在不断地迭代中更新,因此当调用清理函数时,dir变量已经被每一次的for循环更新多次。因此,dir变量的实际取值是最后一次迭代时的值,并且所有的os.RemoveAll调用最终都试图删除同一个目录。
我们经常引入一个内部变量来解决这个问题,就像dir变量是一个和外部变量同名的变量,只不过是一个副本,这看起来有些奇怪却是一个关键性的声明。
这样的隐患不仅仅存在于使用range的for循环里。在下面的循环中也面临由于无意间捕获的索引变量i而导致的同样问题。
1
2
3
4
5
6
7
8
9
var rmdirs []func()
dirs := tempDirs()
for i := 0; i < len(dirs); i++ {
os.MkdirAll(dirs[i], 0755) // OK
rmdirs = append(rmdirs, func() {
// 不正确,因为索引变量i在不断更新,最后仍然会变成一个值....(感觉像是Go的设计缺陷)
os.RemoveA11(dirs[i])
})
}
在go语句和defer语句的使用当中,迭代变量捕获的问题是最频繁的,这是因为这两个逻辑都会推迟函数的执行时机,直到循环结束。但是这个问题并不是由go或者defer语句造成的。
变长函数
变长函数被调用的时候可以有可变的参数个数。最令人熟知的例子就是fmt.Printf与其变种。Printf需要在开头提供一个固定的参数,后续便可以接受任意数目的参数。
在参数列表最后的类型名称之前使用省略号…表示声明一个变长函数,调用这个函数的时候可以传递该类型任意数目的参数。
1
2
3
4
5
6
7
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
上面这个sum函数返回零个或者多个int参数。在函数体内,vals是一个int类型的slice。调用sum的时候任何数量的参数都将提供给vals参数。
1
2
3
fmt.Println(sum()) // "0"
fmt.Println(sum(3)) // "3"
fmt.Println(sum(1, 2, 3, 4))// "10"
调用者显式地申请一个数组,将实参复制给这个数组,并把一个数组slice传递给函数。
上面的最后一个调用和下面的调用的作用是一样的,它展示了当实参已经存在于一个slice中的时候如何调用一个变长函数:在最后一个参数后面放一个省略号。
1
2
va1ues := []int{1,2,3,4}
fmt.Println(sum(values...)) // "10"
尽管...int参数就像函数体内的slice,但变长函数的类型和一个带有普通slice参数的函数的类型不相同。
1
2
3
4
func f(...int) {}
func g([]int) {}
fmt.Printf("%T\n", f) // "func(...int)"
fmt.Printf("%T\n", g) // "func([]int)"
变长函数通常用于格式化字符串。下面的errorf函数构建一条格式化的错误消息,在消息的开头带有行号。函数的后缀f是广泛使用的命名习惯,用于可变长Printf风格的字符串格式化输出函数。
1
2
3
4
5
6
7
func errorf(linenum int, format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "Line %d: ", linenum)
fmt.Fprintf(os.Stderr, format, args...)
fmt.Fprintln(os.Stderr)
}
linenum, name := 12, "count"
errorf(linenum, "undefined: %s", name) // "Line 12: undefined: count"
interface{}类型意味着这个函数的最后一个参数可以接受任何值,后续将解释它的用法。
延迟函数调用
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
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// Check Content-Type is HTML (e.g., "text/html; charset=utf-8").
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
resp.Body.Close()
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
fmt.Println(n.FirstChild.Data)
}
}
forEachNode(doc, visitNode, nil)
return nil
}
观察resp.Body.Close()调用,它保证title函数在任何执行路径下都会关闭网络连接,包括发生错误的情况。随着函数变得越来越复杂,并且需要处理更多的错误情况,这样一种重复的清理动作会造成之后的维护问题。Go语言的defer机制(延迟函数调用)让这些工作变得更简单。
语法上,一个defer语句就是一个普通的函数或方法调用,在调用之前加上关键字defer。函数和参数表达式会在语句执行时求值,但是无论是正常情况下,执行return语句或函数执行完毕,还是不正常的情况下,比如发生宕机,实际的调用推迟到包含defer语句的函数结束后才执行。defer语句没有限制使用次数;执行的时候以调用defer语句顺序的倒序进行。
defer语句经常使用于成对的操作,比如打开和关闭,连接和断开,加锁和解锁,即使是再复杂的控制流,资源在任何情况下都能够正确释放。正确使用defer语句的地方是在成功获得资源之后。在下面的title函数,一个推迟的调用替换了先前的resp.Body.Close()调用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close() // 延迟函数调用,保证这段代码在函数结束后(return后),会被执行
ct := resp.Header.Get("Content-Type")
if ct != "text/html" && !strings.HasPrefix(ct, "text/html;") {
return fmt.Errorf("%s has type %s, not text/html", url, ct)
}
doc, err := html.Parse(resp.Body)
if err != nil {
return fmt.Errorf("parsing %s as HTML: %v", url, err)
}
// ...
return nil
}
同样的方法可以使用在其他资源(包括网络连接)上,比如关闭一个打开的文件:
1
2
3
4
5
6
7
8
9
10
package ioutil
func ReadFile(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
return ReadAll(f)
}
或者解锁一个互斥锁:
1
2
3
4
5
6
7
var mu sync.Mutex
var m = make(map[string]int)
func lookup(key string) int {
mu.Lock()
defer mu.Unlock()
return m[key]
}
defer语句也可以用来调试一个复杂的函数,即在函数的入口和出口处设置调试行为。下面的bigSlowOperation函数在开头调用trace函数,在函数刚进入的时候执行输出,然后返回一个函数变量,当其被调用的时候执行退出函数的操作。以这种方式推迟返回函数的调用,我们可以使用一个语句在函数入口和所有出口添加处理,甚至可以传递一些有用的值,比如每个操作的开始时间。但别忘了defer语句末尾的圆括号,否则入口的操作会在函数退出时执行而出口的操作永远不会调用!
1
2
3
4
5
6
7
8
9
10
11
12
func bigSlowOperation() {
// 别忘记这对圆括号,因为trace返回的是一个函数类型,因此需要调用
defer trace("bigSlowOperation")()
// ...这里是一些处理...
time.Sleep(10 * time.Second) // 通过休眠仿真慢操作
}
func trace(msg string) func() {
start := time.Now()
log.Printf("enter %s", msg)
return func() { log.Printf("exit %s (%s)", msg, time.Since(start)) }
}
每次调用bigSlowOperation,它会记录进入函数入口和出口的时间与两者之间的时间差。(我们使用time.sleep来模拟一个长时间的操作。)
延迟执行的函数在trace函数中的return语句之后执行,并且可以更新函数的结果变量。因为匿名函数可以得到其外层函数作用域内的变量(包括命名的结果),所以延迟执行的匿名函数可以观察到函数的返回结果。
考虑下面的函数double:
1
2
3
func double(x int) int {
return x + x
}
通过命名结果变量和增加defer语句,我们能够在每次调用函数的时候输出它的参数和结果。
1
2
3
4
5
6
7
func double(x int) (result int) {
defer func() { fmt.Printf("double(%d) = %d\n", x, result) }() // 又是有一对圆括号
return x + x
}
_ = double(4)
// 输出:
// "doub1e(4)=8"
这个技巧的使用相比之前的double函数来说有些过了,但对于有很多返回语句的函数来说很有帮助。
延迟执行的匿名函数能够改变外层函数返回给调用者的结果:
1
2
3
4
5
func triple(x int) (result int) {
defer func() { result += x } ()
return double(x)
}
fmt.Println(triple(4)) // "12"
因为延迟的函数不到函数的最后一刻是不会执行的。要注意循环里defer语句的使用。
下面的这段代码就可能会用尽所有的文件描述符,这是因为处理完成后却没有文件关闭。
1
2
3
4
5
6
7
8
for _, filename := range filenames {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close() // 注意:可能会用尽文件描述符
// ...处理文件f...
}
一种解决的方式是将循环体(包括defer语句)放到另一个函数里,每此循环迭代都会调用文件关闭函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
for _, filename := range filenames {
if err := doFile(filename); err != nil {
return err
}
}
func doFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
// ...处理文件f...
}
下面这个例子是改进过的fetch程序,将HTTP的响应写到本地文件中而不是直接显示在标准输出中。它使用path.Base函数获得URL路径最后的一个组成部分作为文件名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Fetch下载URL并返回本地文件的名字和长度
func fetch(url string) (filename string, n int64, err error) {
resp, err := http.Get(url)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
local := path.Base(resp.Request.URL.Path)
if local == "/" {
local = "index.html"
}
f,err := os.Create(local)
if err != nil {
return "", 0, err
}
n, err = io.Copy(f, resp.Body) // 关闭文件,并保留错误消息
if closeErr := f.Close(); err == nil {
err = closeErr // 没有错误才报告关闭文件时的错误
}
return local, n, err
}
现在应该熟悉延迟调用的resp.Body.close了。在这个例程中,如果试图使用延迟调用f.Close去关闭一个本地文件就会有些问题,因为os.Create打开了一个文件对其进行写入、创建。在许多文件系统中,尤其是NFS,写错误往往不是立即返回而是推迟到文件关闭的时候。如果无法检查关闭操作的结果,就会导致一系列的数据丢失。然而,如果io.Copy和f.close同时失败,我们更加倾向于报告io.Copy的错误,因为它发生在前,更有可能告诉我们失败的原因是什么。
宕机
Go语言的类型系统会捕获许多编译时错误,但有些其他的错误(比如数组越界访问或者解引用空指针)都需要在运行时进行检查。当Go语言运行时检测到这些错误,它就会发生宕机。
一个典型的宕机发生时,正常的程序执行会终止,goroutine中的所有延迟函数会执行,然后程序会异常退出并留下一条日志消息。日志消息包括宕机的值,这往往代表某种错误消息,每一个goroutine都会在宕机的时候显示一个函数调用的栈跟踪消息。通常可以借助这条日志消息来诊断问题的原因而不需要再一次运行该程序,因此报告一个发生宕机的程序bug时,总是会加上这条消息。
并不是所有宕机都是在运行时发生的。可以直接调用内置的宕机函数;内置的宕机函数可以接受任何值作为参数。如果碰到不可能发生的状况,宕机是最好的处理方式,比如语句执行到逻辑上不可能到达的地方时:
1
2
3
4
5
6
7
8
switch s := suit(drawcard()); s {
case "Spades": // ...
case "Hearts": // ...
case "Diamonds": // ...
case "clubs": // ...
default:
panic(fmt.Sprintf("invalid suit %q", s)) // 宕机了吗
}
设置函数的断言是一个良好的习惯,但是这也会带来多余的检查。除非你能够提供有效的错误消息或者能够很快地检测出错误,否则在运行时检测断言条件就毫无意义。
1
2
3
4
5
6
func Reset(x *Buffer) {
if x == nil {
panic("x is ni1") // 没必要
}
x.elements = nil
}
尽管Go语言的宕机机制和其他语言的异常很相似,但宕机的使用场景不尽相同。由于宕机会引起程序异常退出,因此只有在发生严重的错误时才会使用宕机,比如遇到与预想的逻辑不一致的代码;用心的程序员会将所有可能会发生异常退出的情况考虑在内以证实bug的存在。强健的代码会优雅地处理预期的错误,比如错误的输入、配置或者I/O失败等;这时最好能够使用错误值来加以区分。
考虑函数regexp.Compile,它编译了一个高效的正则表达式。如果调用时给的模式参数不合法则会报错,但是检查这个错误本身没有必要且相当烦琐,因为调用者知道这个特定的调用是不会失败的。在此情况下,使用宕机来处理这种不可能发生的错误是比较合理的。
由于大部分的正则表达式是字面量,因此regexp包提供了一个包装函数regexp.MustCompile进行这个检查:
1
2
3
4
5
6
7
8
9
10
package regexp
func Compile(expr string) (*Regexp, error) { /* ... */ }
func MustCompile(expr string) *Regexp {
re, err := Compile(expr)
if err != nil {
panic(err)
}
return re
}
包装函数使得初始化一个包级别的正则表达式变量(带有一个编译的正则表达式)变得更加方便,如下所示:
1
var httpSchemeRE = regexp.MustCompile(`^https?:`) // "http:"或"https:"
当然,MustCompile不应该接收到不正确的值。前缀Must是这类函数一个通用的命名习惯,比如4.6节介绍的template.Must。
MustCompile是Go语言中regexp包提供的一个函数,用于编译正则表达式;- 该函数接受一个字符串参数
str,表示要编译的正则表达式字符串,然后返回一个已经编译好的*Regexp类型的正则表达式对象。 - 与普通的
Compile函数不同的是,MustCompile在编译正则表达式时不返回错误,而是在编译失败时直接触发panic。 - 因此,
MustCompile适用于那些我们确定一定能够成功编译的正则表达式,如果编译失败会导致程序无法正常运行的情况下使用。
当宕机发生时,所有的延迟函数以倒序执行,从栈最上面的函数开始一直返回至main函数,如下面的程序所示:
1
2
3
4
5
6
7
8
9
func main() {
f(3)
}
func f(x int) {
fmt.Printf("f(%d)\n", x+0/x) // panics if x == 0则发生宕机
defer fmt.Printf("defer %d\n", x)
f(x - 1)
}
运行的时候,程序会输出下面的内容到标准输出。
f(3)
f(2)
f(1)
defer 1
defer 2
defer 3
当调用f(0)的时候会发生宕机,会执行三个延迟的fmt.Printf调用。之后,运行时终止了这个程序,输出宕机消息与一个栈转储信息到标准错误流(输出内容有省略)。
panic:runtime error: integer divide by zero
main.f(0)
src/gopl.io/ch5/defer1/defer.go:14
main.f(1)
src/gopl.io/ch5/defer1/defer.go:16
main.f(2)
src/gopl.io/ch5/defer1/defer.go:16
main.f(3)
src/gopl.io/ch5/defer1/defer.go:16
main.main()
src/gopl.io/ch5/defer1/defer.go:10
之后会看到,函数是可以从宕机状态恢复至正常运行状态而不让程序退出。
runtime包提供了转储栈的方法使程序员可以诊断错误。下面代码在main函数中延迟printstack的执行:
1
2
3
4
5
6
7
8
9
func main() {
defer printstack()
f(3)
}
func printstack() {
var buf [4096]byte
n := runtime.Stack(buf[:], false)
os.Stdout.Write(buf[:n])
}
下面的额外信息(同样经过简化处理)输出到标准输出中:
goroutine 1 [running]:
main.printstack()
src/gopl.io/ch5/defer2/defer.go:20
main.f(0)
src/gopl.io/ch5/defer2/defer.go:27
main.f(1)
src/gopl.io/ch5/defer2/defer.go:29
main.f(2)
src/gopl.io/ch5/defer2/defer.go:29
main.f(3)
src/gopl.io/ch5/defer2/defer.go:29
main.main()
src/gopl.io/ch5/defer2/defer.go:15
Go语言的宕机机制可以延迟执行的函数在栈清理之前调用,因此runtime.Stack能够输出函数栈信息。
恢复
退出程序通常是正确处理宕机的方式,但也有例外。在一定情况下是可以进行恢复的,至少有时候可以在退出前理清当前混乱的情况。比如,当Web服务器遇到一个未知错误时,可以先关闭所有连接,这总比让客户端阻塞在那里要好,而在开发过程中,也可以向客户端汇报当前遇到的错误。
如果内置的recover函数(恢复)在延迟函数的内部调用,而且这个包含defer语句的函数发生宕机,recover会终止当前的宕机状态并且返回宕机的值。函数不会从之前宕机的地方继续运行而是正常返回。如果recover在其他任何情况下运行则它没有任何效果且返回nil。
为了说明这一点,假设我们开发一种语言的解析器。即使它看起来运行正常,但考虑到工作的复杂性,还是会存在只在特殊情况下发生的bug。我们在这时会更喜欢将本该宕机的错误看作一个解析错误,不要立即终止运行,而是将一些有用的附加消息提供给用户来报告这个bug。
1
2
3
4
5
6
7
8
func Parse(input string) (s *Syntax, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("internal error: %v", p)
}
}()
// ...解析器...
}
Parse函数中的延迟函数会从宕机状态恢复,并使用宕机值组成一条错误消息;理想的写法是使用runtime.Stack将整个调用栈包含进来。延迟函数则将错误赋给err结果变量,从而返回给调用者。
对于宕机采用无差别的恢复措施是不可靠的,因为宕机后包内变量的状态往往没有清晰的定义和解释。可能是对某个关键数据结构的更新错误,文件或网络连接打开而未关闭,或者获得了锁却没有释放。长此以往,把异常退出变为简单地输出一条日志会使真正的bug难于发现。
从同一个包内发生的宕机进行恢复有助于简化处理复杂和未知的错误,但一般的原则是,你不应该尝试去恢复从另一个包内发生的宕机。公共的API应当直接报告错误。同样,你也不应该恢复一个宕机,而这段代码却不是由你来维护的,比如调用者提供的回调函数,因为你不清楚这样做是否安全。
举个例子,net/http包提供一个Web服务器,后者能够把请求分配给用户定义的处理函数。与其让这些处理函数中的宕机使得整个进程退出,不如让服务器调用recover,输出栈跟踪信息,然后继续工作。但是这样使用会有一定的风险,比如导致资源泄露或使失败的处理函数处于未定义的状态从而导致其他问题。
出于上面的原因,最安全的做法还是要选择性地使用recover。换句话说,在宕机过后需要进行恢复的情况本来就不多。可以通过使用一个明确的、非导出类型作为宕机值,之后检测recover的返回值是否是这个类型(后面会看到这个例子)。如果是这个类型,可以像普通的error那样处理宕机;如果不是,使用同一个参数调用panic以继续触发宕机。
有些情况下是没有恢复动作的。比如,内存耗尽使得Go运行时发生严重错误而直接终止进程。