5.1 函数声明
5.1.1 函数声明的基本语法
每个函数声明,都包含了一个函数名 、一个形参列表 、一个可选的返回列表 、一个函数体
func name(parameter-list) (return-list) {
body
}
函数声明 基本语法 | |
形参与返回值 | 形参列表,指定了一组变量的参数名和参数类型 |
形参是函数的局部变量,这些形参都由调用者提供的实参传递而来 | |
返回列表,指定了函数返回值的类型 | |
当函数返回一个未命名的返回值,或者函数没有返回值的时候,返回列表的圆括号可以省略 | |
如果一个函数既没有返回列表,也没有任何返回值,那么这个函数的目的是调用函数所带来的附加效果 | |
代码示例 | 在下面的 hypot 函数中: func name( x , y float64 ) float64 { return math.Sqrt( x*x + y*y ) } fmt.Println( hypot( 3 , 4 ) ) // " 5 " x 和 y 是函数声明中的形参,3 和 4 是调用函数时的实参,并且函数返回一个类型为 float64 的值 |
形参与返回值 | 返回值可以像形参一样命名; 每个命名的返回值会声明为一个局部变量,并根据变量类型初始化为对应的零值 |
当函数存在返回列表时,必须显式地以 return 语句结束; 除非函数明确不会走完整个执行流程,比如在函数中抛出宕机异常或函数体内存在一个没有 break 退出条件的无限 for 循环 | |
如果几个形参或者返回值的类型相同,那么类型标识符只需要写一次 | |
代码示例 | 以下两个声明是完全相同的: func f(i , j , k int , s , t string) { /* ... */ } func f(i int , j int , k int , s string , t string) { /* ... */ } |
匿名变量 | 空白标识符 " _ " 用来强调这个形参在函数中未被使用 |
代码示例 | 下面使用 4 种方式声明一个带有两个形参和一个返回值的函数,所有变量都是 int 类型 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 以外的语言实现 | |
这样的声明定义了该函数的签名 package math func Sin(x float64) float64 // 使用汇编语言实现 |
5.2 递归
递归的含义 | 函数可以递归调用,这意味着函数可以直接或间接地调用自己 |
递归,是一种实用的技术,可以处理许多带有递归特性的数据结构 | |
下面使用递归处理 HTML 文件 | |
下面的代码示例使用了一个非标准的包 golang.org/x/net/html ,它提供了解析 HTML 的功能; golang.org/x/... 下的仓库(比如网络 、国际化语言处理 、移动平台 、图片处理 、加密功能以及开发者工具)都由 Go 团队负责设计和维护;这些包并不属于标准库,原因是它们还在开发当中,或者很少被 Go 程序员使用 | |
我们需要的 golang.org/x/net/html API 如下面的代码所示; 函数 html.Parse 读入一段字节序列,解析它们,然后返回 HTML 文档树的根节点 html.Node ;HTML 有多种节点,比如文本 、注释等; 此处,我们只关心表单的 "元素节点 <name key='value'> " | |
类型声明 | golang.org/x/net/html 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 ) |
main 函数 | 主函数从标准输入中读入 HTML ,使用递归的 visit 函数获取 HTML 文本的超链接,并且把所有的超链接输出 |
gop1.io/ch5/findlinks1 package main import ( "fmt" "os" "golang.org/x/net/html" ) func main() { doc , err := html.Parse(os.Stdin) // 标准输入进入 Parse 函数进行解析 if err != nil { // 判断解析函数执行是否出错 fmt.Fprintf(os.Stderr , "findlinks1 :%v\n" , err) os.Exit(1) } for _ , link := range visit(nil , doc) { // 获取 HTML 文本的超链接 fmt.Println(link) // 输出超链接 } } | |
visit 函数 | visit 函数遍历 HTML 树上的所有节点,从 HTML 锚元素 <a href='...'> 中得到 href 属性的内容,将获取到的链接内容添加到字符串 slice ,最后返回这个 slice |
// 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 链表中 | |
下面的程序使用递归遍历所有 HTML 文本中的结点树,并输出树的结构;当递归遇到每个元素时,会将元素标签压入栈,然后输出栈 | |
func main() { doc,err := html.Parse(os.Stdin) if err != nil { fmt.Fprintf(os.Stderr,"outline:%v\n",err) os.Exit(1) // 解析出错则退出程序 } outline(nil,doc) } func outline(stack []string,n *html.Node) { if n.Type == html.ElementNode { stack = append(stack,n.Data) // 把标签压入栈 fmt.Println(stack) } for c := n.FirstChild;c != nil;c = c.NextSibling { outline(stack,c) } } | |
尽管 outline 会将元素压栈但不会出栈; 当 outline 递归调用自己时,被调用的函数会接收到栈的副本,所以 outline 无法修改调用者的栈 | |
函数调用栈 | 许多变成语言使用固定长度的函数调用栈;大小在 64KB 到 2MB 之间; 递归的深度会受限于固定长度的栈大小,所以当进行深度递归调用时必须严防 “栈溢出” ;固定长度的栈甚至会造成一定的安全隐患 相比固定长的栈 ,Go 语言的实现使用了可变长度的栈,栈大小随使用而增长,可达 1GB 左右的上限;安全使用栈,不必担心溢出问题 |
5.3 多返回值
5.3.1 多返回值的概念
一个函数能够返回不止一个结果
说明
之前已经见过标准包内的许多函数返回两个值 :一个期望得到的计算结果与一个错误值,错误值也可以是一个表示函数调用是否正确的布尔值
下面来看看怎样写一个这样(能返回多个返回值)的函数 :
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 将处理过的附加上下文信息格式化
如果 findLinks 调用成功,最后一个返回语句将返回链接的 slice ,且 error 为空
注意 :
我们必须保证 resp.Body 正确关闭,使得网络资源正常释放;即使在发生错误的情况下,也必须释放资源
Go 语言的垃圾回收机制将回收未使用的内存,但垃圾回收机制不会释放未使用的操作系统资源,比如打开的文件以及网络连接;程序员必须手动 、显式地关闭(释放)这些操作系统资源
调用一个涉及多值计算的函数会返回一组值;如果调用者要使用这些返回值,则必须显式地将返回值赋给变量(用变量接收返回值)
links, err := findLinks(url)
忽略其中一个返回值,可以将其赋给一个空标识符
links, _ := findLinks(url) // 忽略错误
返回一个多值结果可以调用另一个多值返回的函数
就像下面的函数,这个函数的行为与 findLinks 类似,只是多了一个记录参数的动作
func findLinksLog(url string) ([]string, error) {
log.Printf("findLinks %s", url) // 多了这行记录参数的打印
return findLinks(url)
}
一个多值调用可以作为单独的实参,传递给拥有多个形参的函数;尽管很少在生产环境中使用,但是这个特性有时候可以方便调试,仅仅使用一条语句就可以输出所有的结果
下面两个输出语句的效果是一致的
log.Println(findLinks(url))
links, err := findLinks(url)
log.Println(links, err)
良好的名称可以可以使得返回值更加有意义
尤其是在一个函数返回多个结果且类型相同时,名字的选择更加重要
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 语句的操作数,这称为 "裸返回"
//
//
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 // 省略返回值,但不能省略 return 关键字
}
words, images = countWordsAndImages(doc)
return // 省略返回值,但不能省略 return 关键字
}
func countWordsAndImages(n *html.Node) (words, images int) { /* ... */ }
裸返回,是将每个命名返回结果按照顺序返回的快捷方法;
所以在上面的函数中,每个 return 语句都等同于 :
return words, images, err
像在这个函数中存在许多返回语句且有多个返回结果,裸返回可以消除重复代码,但是并不能使代码更加易于理解,所以应该尽量少用裸返回
5.4 错误
5.4.1 错误处理基础
有些函数总是成功返回的;比如,strings.Contains 和 strconv.FormatBool 对所有可能得参数变量都有定义好的返回结果,不会调用失败 ---- 尽管还有灾难性的和不可预知的场景,像内存耗尽,这类错误的表现和起因相差甚远而且恢复的希望也很渺茫
其他的函数只要符合其前置条件就能够成功返回;比如,time.Date 函数始终会利用年 、月等构成 time.Time ,但是如果最后一个参数(表示时区)为 nil 则会导致宕机;这个宕机,标志着这是一个明显的 bug ,应该避免这样调用代码
对于许多其他函数,即使在高质量的代码中,也不能保证一定能够成功返回,因为有些因素并不受程序设计者的掌控;比如,任何操作 I/O 的函数都一定会面对可能得错误;事实上,这些地方才使我们最需要关注的,而很多可靠的操作都可能会毫无征兆地发生错误
因此,错误处理是包的 API 设计或者应用程序用户接口的重要部分,发生错误只是许多预料行为中的一种而已;这就是 Go 语言处理错误的方法
当函数调用发生错误时,返回一个附加的结果作为错误值;习惯上,将错误值作为最后一个结果返回
如果错误只有一种情况,结果通常设置为布尔类型
value, ok := cache.Lookup(key)
if ok != nil {
// ... cache[key] 不存在 ...
}
更多时候,尤其对于 I/O 操作,错误的原因可能多种多样,而调用者则需要一些详细的信息;在这种情况下,错误的结果类型往往是 error
error 是内置的接口类型
一个错误可能是空值或者非空值;空值表示成功而非空值表示失败
非空的错误类型有一个错误消息字符串,可以通过调用 err 的 Error 方法或者通过调用 fmt.Println(err) 或 fmt.Printf("%v",err) 直接输出错误消息
通常,当一个函数返回一个非空错误时,该函数返回的其他结果都是未定义的而且应该忽略;
然而,有一些函数在调用出错的情况下还是会返回部分有用的结果;比如,如果在读取一个文件的时候发生错误,调用 Read 函数后返回 "成功读取的字节数" 和相对应的错误值
正确的行为,通常是在调用者处理错误前,先处理这些不完整的返回结果;因此,在文档中清晰地说明返回值的意义是很重要的
不同于其他编程语言,Go 语言通过使用普通的值而非异常来报告错误;尽管 Go 语言有异常机制,但 Go 语言的异常机制只是针对程序 bug 导致的预料外的错误,异常不能作为常规的错误处理方法出现在程序中
原因 :
异常会陷入带有错误消息的控制流去处理它,通常会导致预期外的结果 :错误会以难以理解的栈跟踪信息报告给最终用户,而这些信息大多是关于程序结构而不是简单明了的错误消息
Go 程序使用常见的控制流机制(比如 if 和 return 语句)应对错误;这种方式在错误处理逻辑方面要求更加小心谨慎,但这恰恰是设计的要点
5.4.2 错误处理策略
如果当一个函数调用返回一个错误时,调用者应当负责检查错误并采取合适的处理来应对;根据情形,可以有多种处理方法
策略一:
首先最常见的处理方法,是将错误传递下去,使得在子例程中发生的错误变为主调例程的错误
示例 :
之前讨论过的 findLinks 函数的示例;如果调用 http.Get 失败,findLinks 不做任何操作,立即向调用者返回这个 HTTP 错误
resp, err := http.Get(url)
if err != nil {
return nil, err
}
相比之下,如果调用 html.Parse 失败,则 findLinks 不会直接返回 HTML 解析的错误,因为缺失了两个关键信息 :解析器的出错信息 、被解析文档的 URL ;此时,findLinks 重新构建了一个新的错误消息,其中包含我们需要的所有相关信息和解析的错误信息 :
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 函数处理时,它应当能够提供一个从最根本问题到总体故障的清晰因果链
小结 :
设计一个错误消息的时候应当慎重,确保每一条消息的描述都是有意义的,包含充足的相关信息,并且保持一致性;不论被同一个函数还是同一个包下面的一组函数返回时,这样的错误都可以保持统一的形式和错误处理方式
比如,os 包保证每一个文件操作(比如 os.Open 或针对打开的文件的 Read 、Write 或 Close 方法)返回的错误不仅包括错误的信息(没有权限 、路径不存在等),还包含文件的名称等;因此,调用者在构造错误消息的时候不需要再包含这些信息(比如文件名称)
通常,函数 f(x) 调用只负责报告函数的行为 f 和参数值 x ,因为它们和错误的上下文相关;调用者负责添加进一步的信息,但是 f(x) 本身并不会,就像上面函数中 URL 和 html.Parse 的关系
策略二:
对于不固定或者不可预测的错误,在短暂的间隔后对操作进行重试也是合理的,超出一定的重试次数和限定的时间后,再报错退出
// WaitForServer 尝试连接 URL 对应的服务器
// 在一分钟内使用指数退避策略进行重试
// 所有的尝试失败后返回错误
func WaitForServer(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
for tries := 0; tiem.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 f,t.Errorf("server %s failed to respond after %s", url, timeout)
}
策略三:
如果依旧不能顺利进行下去,调用者能够输出错误,然后优雅地停止程序;但是,这样的处理通常应该留给主程序去执行
通常库函数应当将错误传递给调用者,除非这个错误表示了一个内部一致性错误,这意味着库内部存在 bug
// (In function main.)
if err := WaitForServer(url); err != nil {
fmt.Fprintf(os.Stderr, "Site is down: %v\n", err)
os.Exit(1)
}
一个更加方便的方法,是通过调用 log.Fatalf 实现相同的效果;就和所有的日志函数一样,它默认会将时间和日期作为前缀添加到错误消息前
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 gop1.io
一种更吸引人的输出方式,是自己定义命令的名称作为 log 包的前缀,并且将日期和时间略去
log.SetPrefix("wait: ")
log.SetFlags(0)
策略四:
在一些错误情况下,只记录下错误信息然后程序继续运行;同样地,可以选择使用 log 包来增加日志的常用前缀 :
if err := Ping(); err != nil {
log.Printf("ping failed: %v; networking disabled", err)
}
并且直接输出到标准错误流 :
if err := Ping(); err != nil {
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)
}
(所有 log 函数都会为缺少换行符的日志补充一个换行符)
策略五:
在某些罕见的情况后下,可以直接安全地忽略掉整个日志 :
dir, err := ioutil.TimeDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v", err)
}
// ... 使用临时目录 ...
os.RemoveAll(dir) // 忽略错误,$TMPDIR 会被周期性删除
调用 os.RemoveAll 可能会失败,但程序忽略了这个错误,原因是操作系统会周期性清理临时目录;在这个例子中,我们有意地抛弃了错误,但程序的逻辑看上去就像我们忘记了处理一样
要习惯考虑到每一个函数调用可能发生的出错情况,当有意地忽略一个错误的时候,清楚地注释一下当时的意图
总结 :
Go 语言的错误处理有特定的规律;进行错误检查后,检测到失败的情况往往都在成功之前4
如果检测到的失败导致函数返回,成功的逻辑一般不会放在 else 块中,而是在外层的作用域中
函数会有一种通常的形式,就是在函数体开头有一连串的检查用来返回错误,之后跟着实际的函数体一直到最后
5.4.3 文件结束标识
通常,最终用户会对函数返回的多种错误感兴趣,而不是中间涉及的程序逻辑;偶尔,一个程序必须针对不同种类的错误采取不同的措施
考虑如果要从一个文件中读取 n 个字节的数据;如果 n 文件本身的长度,任何错误都代表操作失败;如果调用者反复地尝试读取固定大小的块,直到文件耗尽,调用者必须把读取到文件尾的情况区别于遇到其他错误的操作;为此,io 包保证任何由文件结束引起的读取错误,始终都将会得到一个与众不同的错误 -- io.EOF ,它的定义如下 :
package io
import "errors"
// 当没有更多输入时,将会返回 EOF
var EOF = errors.New("EOF")
调用者可以使用一个简单的比较操作来检测这种情况,在下面的循环中,不断从标准输入中读取字符
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" ;对于其他错误,我们可能需要同时得到错误相关的本质原因和数量信息,因此一个固定的错误值并不能满足我们的需求
5.5 函数变量
函数在 Go 语言中是头等重要的值(一等公民):就像其他值,函数变量也有类型,而且函数变量可以赋给普通变量 、传递给函数 、从其他函数中返回
函数变量可以像其他函数一样调用(Python 也是这样),比如 :
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(空值),调用一个空的函数变量会导致宕机
所谓空函数变量,就是未绑定任何函数的变量;函数变量的类型在绑定函数时才确定
var f func(int) int
f(3) // 宕机:调用空函数
函数变量可以与空值进行比较 :
var f func(int) int
if f != nil {
f(3)
}
函数变量本身不可比较,所以不可以互相进行比较或者作为键值出现在 map 中
函数变量使得函数不仅将数据进行参数化,还将函数的行为当作参数进行传递;标准库中含有大量的例子,比如 strings.Map 对字符串中的每一个字符使用一个函数,将结果连接起来变成另一个字符串
func add1(r rune) rune { return r + 1 }
// 函数变量作为参数,被传递
fmt.Println(strings.Map(add1, "HAL-9000")) // "IBM.:111"
fmt.Println(strings.Map(add1, "VMS")) // "WNT"
fmt.Println(strings.Map(add1, "Admix")) // "Benjy"
5.2 节中的 findLinks 函数使用了一个辅助函数 visit ,该函数访问了 HTML 文档中所有的节点,而后对每一个节点进行操作;使用函数变量,可以将每个节点的操作逻辑从遍历树形结构的逻辑中分开;
下面通过不同的操作重用该遍历逻辑:
// forEachNode 调用 pre(x) 和 post(x) 遍历以 n 为根的树中的每个节点 x
// 两个函数是可选的
// pre 在子节点被访问前(前序)调用
// post 在访问后(后序)调用
func forEachNode(n *html.Node, pre, post func(n *html.Node)) {
if pre != nil {
pre(n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
forEachNode(c, pre, post)
}
if post != nil {
post(n)
}
}
这里 forEachNode 函数接受两个函数作为参数,一个在当前节点的所有子节点都被访问前调用,另一个则在之后(所有子节点都被访问之后);这样的代码组织给调用者提供了很多的灵活性;比如,函数 startElement 和 endElement 输出 HTML 元素的起始和结束标签,如 <b>...</b>:
var depth int
func startElement(n *html.Node) {
if n.Type == html.ElementNode {
fmt.Printf("%*s<%s>\n", depth*2, "", n.Data)
depth++
}
}
func endElement(n *html.Node) {
if n.Type == html.ElementNode {
depth--
fmt.Printf("%*S</%s>\n", depth*2, "", n.Data)
}
}
这两个函数巧妙地利用 fmt.Printf 来缩进输出;%*s 中的 * 号输出带有可变数量空格的字符串;输出的宽度和字符串则由参数 depth*2 和 "" 提供
如果对 HTML 文档调用 forEachNode 函数,比如:
forEachNode(doc, startElement, enElement)
可以使之前的 outline 函数得到一个更加直观的输出
$./outline2 http://gop1.io
<html>
<head>
<meta>
</meta>
<title>
</title>
<style>
</style>
</head>
<body>
<table>
<tbody>
<tr>
<td>
<a>
<img>
</img>
...
5.6 匿名函数
命名函数只能在包级别的作用域中进行声明,但我们能够使用 "函数字面量" ,在任何表达式内指定函数变量;
函数字面量就像函数声明,但在 func 关键字后面没有函数的名称(除了函数名称可以省略,剩下的如关键字 func 、参数列表 、返回列表,均不可以省略);函数字面量是一个表达式,它的值称作 "匿名函数"( lambda )
函数字面量在需要使用的时候才定义
就像下面这个例子,之前的函数调用 strings.Map 可以写成 :
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
更重要的是,以这种方式定义的函数能够获取到整个词法环境;即,里层的函数可以使用外层函数中的变量(在 Python 中,也有类似的用法,称作闭包)
示例如下:
函数 squares 返回另一个函数,类型是 func() int ;调用 squares 创建了一个局部变量 x 而且返回了一个匿名函数(函数字面量),每次调用 squares 都会递增 x 的值然后返回 x 的平方;第二次调用 squares 函数将会创建第二个(局部)变量 x ,然后返回一个递增 x 值的新匿名函数
这个求平方的示例演示了函数变量不仅是一段代码,还可以拥有状态;内层的匿名函数能够获取和更新外层 squares 函数的局部变量;这些隐藏的变量引用导致我们把函数归类为引用类型,同时导致了函数变量无法比较
函数变量类似于使用 "闭包" 方法实现的变量,Go 程序员通常把函数变量称为 "闭包"
我们再一次看到在这个例子里面,变量的生命周期不是由其作用域所决定的 :变量 x 在 main 函数中返回 squares 函数后依旧存在,虽然 x 在这个时候是隐藏在函数变量 f 中的
// squares 函数返回一个函数,后者包含下一次要用到的平方数
// the next square number each time it is called
func squares() func() int {
var x int
return func() int {
x++
return x * x
}
}
func main() {
f := squares()
fmt.Println(f()) // "1"
fmt.Println(f()) // "4"
fmt.Println(f()) // "9"
fmt.Println(f()) // "16"
}
在下面这个与学术课程相关的匿名函数例程中,考虑学习计算机科学课程的顺序,需要计算出学习每一门课程的先决条件;先决课程在下面的 prereqs 表中已经给出,其中给出了学习每一门课程必须提前完成的课程列表关系
// 反映了所有课程和先决课程的关系
var prereqs = map[string][]string{
"algorithm": {"data structures"},
"calculus": {"linear algebra"}:
"compilers": {
"data structures",
"formal languages",
"computer organization",
},
"data structures": {"discrete math"},
"databases": {"data structures"},
"discrete math": {"intro to programming"},
"formal languages": {"discrete math"},
"networks": {"operating system"},
"operating system": {"data structures", "computer organization"},
"programming languages": {"data structures", "computer organization"},
}
这样的问题是我们所熟知的拓扑排序;概念上,先决条件的内容构成一张有向图,每一个节点代表每一门课程,每一条边代表一门课程所依赖另一门课程的关系;图是无环的:没有节点则可以通过图上的路径回到它自己;我们可以使用深度优先的搜索算法计算得到合法的学习路径,如以下代码所示:
func main() {
for i, course := range topoSort(prereqs) {
fmt.Printf("%d:\t%s\n", i+1, course)
}
}
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
}
当一个匿名函数需要进行递归,在这个例子中,必须先声明一个变量然后将匿名函数赋给这个变量;如果将两个步骤合并成一个声明,函数字面量将不能存在于 visitAll 变量的作用域中,这样就不能递归地调用自己了
visitAll := func(items []string) {
// ...
visitAll(m[item]) // compile error: undefined: visitAll
// ...
}
下面是拓扑排序的程序输出;它是确定的结果,得到令人满意的结果并不容易;这里,prereqs 的值都是 slice 而不是 map ,所以它们的迭代顺序是确定的并且我们在调用最初的 visitAll 之前将 prereqs 的键值进行了排序
1: intro to programming
2: discrete math
3: data structures
4: algorithm
5: linear algebra
6: calculus
7: formal languages
8: computer organization
9: compilers
10: databases
11: operating system
12: networks
13: programming languages
回到 findLinks 例子。由于在第 8 章还需要用到它,因此我们将解析链接的函数 links.Extract 移动到它自己的包中。我们将原本的 visit 函数替换为匿名函数,并直接放到存放链接的 slice 之后,然后用 forEachNode 函数处理递归。因为 Extract 函数只需要 pre 函数,所以把 post 部分的参数填 nil
// link 包提供了解析链接的函数
package links
import (
"fmt"
"net/http"
"golang.org/x/net/html"
)
// Extract 函数向给定 URL 发起 HTTP GET 请求
// 解析 HTML 并返回 HTML 文档中存在的链接
func Extract(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)
}
var links []string
visitNode := func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
for _, a := range n.Attr {
if a.Key != "href" {
continue
}
link, err := resp.Request.URL.Parse(a.Val)
if err != nil {
continue // 忽略不合法的 URL
}
links = append(links, link.String())
}
}
}
forEachNode(doc, visitAll, nil)
return links, nil
}
在这个版本中,我们并不是直接把 href 原封不动地添加到存放链接的 slice 中,而是将它解析成基于当前文档的相对路径 resp.Request.URL 。结果的链接是绝对路径的形式,非常适用于调用函数 http.Get
网页爬虫的核心是解决图的遍历。拓扑排序的示例展示了深度优先遍历;对于网络爬虫,我们使用广度优先遍历
下面的示例函数展示了广度优先遍历的精髓;调用者提供一个初始列表 worklist ,它包含要访问的项和一个函数变量 f 用来处理每一个项;每一个项用字符串来识别;函数 f 将返回一个新的项列表,其中包含需要新添加到 worklist 中的项;breadthFirst 函数将在所有节点项都被访问后返回;它需要维护一个字符串集合用来保证每个节点只访问一次
// breadthFirst 对每个 worklist 元素调用 f
// 并将返回的内容添加到 worklist 中,对每一个元素,最多调用一次 f
// f is called at most once for each item
func breadthFirst(f func(item string) []string, worklist []string) {
seen := make(map[string]bool)
for len(Worklist) > 0 {
items := worklist
worklist = nil
for _, item := range items {
if !seen[item] {
seen[item] = true
worklist = append(worklist, f(item)...)
}
}
}
}
参数 " f(item)... " 将会把 f 返回的列表中的所有项添加到 worklist 中
在爬虫里,项节点都是 URL ;我们提供 crawl 函数给 breadthFirst 以输出 URL ,解析链接然后将它们返回,标记为已访问
func crawl(url string) []string {
fmt.Println(url)
list, err := links.Extract(url)
if err != nil {
log.Print(err)
}
return list
}
为了让爬虫开始工作,我们使用命令行参数指定开始的 URL
func main() {
// 开始广度优先遍历
// 从命令行参数开始
breadthFirst(crawl, os.Args[1:])
}
我们从 https://golang.org 开始爬取网页;下面是一些输出的链接 :
$ go build gop1.io/ch5/findLinks3
$ ./findlinks3 https://golang.org
https://golang.org/
https://golang.org/doc/
https://golang.org/pkg/
https://golang.org/project/
https://code.google.com/p/go-tour/
https://golang.org/doc/code.html
https://www.youtube.com/watch?v=XCsL89YtqCs
https://research.swtch.com/gotour
https://vimeo.com/53221560
整个过程将在所有可到达的网页被访问或者内存耗尽时结束
警告:捕获迭代变量
在这一节,我们将看到 Go 语言的词法作用域规则的陷阱,有时会得到令人吃惊的结果;我们强烈建议你先理解这个问题再进行下一节的阅读,因为即使是有经验的程序员也会调入这些陷阱
假设一个程序必须创建一系列的目录之后又会删除这些目录;可以使用一个包含函数变量的 slice 进行清理操作(这个示例中省略了所有的错误处理逻辑)
var rmdirs []func()
for _, d := range tempDirs() {
dir := d //
os.MkdirAll(dir, 0755) //
rmdirs = append(rmdirs, func() { os.RemoveAll(dir) }) // 这里有一个匿名函数
}
// ... 这里做一些处理 ...
for _, rmdir := range rmdirs {
rmdir() // 清理
}
你可能会奇怪,为什么在循环体内将循环变量赋给一个新的局部变量 dir ,而不是在下面这个略有错误的变体中直接使用循环变量 dir
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 变量是一个和外部变量同名的变量,只不过是一个副本,这看起来有些奇怪却是一个关键性的声明 :
for _, dir := range tempDir() {
dir = dir // 声明内部 dir ,并以外部 dir 初始化
// ...
}
这样的隐患不仅仅存在于使用 range 的 for 循环里;下面的循环中也面临由于无意间捕获的索引变量 i 而导致的同样问题
var rmdirs []func()
dirs := tempDirs()
for i := 0; i < len(dirs); i++ {
os.MkdirAll(dirs[i], 0755) // OK
rmdirs = append(rmdirs, func() {
os.REmoveAll(dirs[i]) // 不正确
})
}
在 Go 语句(参考第 8 章)和 defer 语句的使用当中,迭代变量捕获的问题是最频繁的,这是因为这两个逻辑都会推迟函数的执行时机,直到循环结束;但是这个问题并不是由 go 或 defer 语句造成的
5.7 变长函数
"变长函数" 被调用的时候可以有可变的参数个数
最令人熟知的例子就是 fmt.Printf 与其变种;Printf 需要在开头提供一个固定额参数,后续便可以接受任意数目的参数
变长函数声明语法 :
在参数列表最后的类型名称之前使用省略号 " ... " 表示声明一个变长函数,调用这个函数的时候可以传递该类型任意数目的参数
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
上面这个 sum 函数返回零个或者多个 int 参数;在函数体内,vals 是一个 int 类型的 slice ;调用 sum 的时候任何数量的参数都将提供给 vals 参数
fmt.Println(sum()) // "0"
fmt.Println(sum(3)) // "3"
fmt.Println(sum(1, 2, 3, 4)) // "10"
调用者显式地申请一个数组,将实参复制给这个数组,并把一个数组 slice 传递给函数;
下面所展示的两种调用方式是一样的;当实参已经存在于一个 slice 中的时候,如何调用一个变长函数 :在最后一个参数后面放一个省略号
// 方式一
fmt.Println(sum(1, 2, 3, 4))
// 方式二
// 当实参已经存在于一个 slice 中,调用变长函数 :在最后一个参数后面放一个省略号
values := []int{1, 2, 3, 4}
fmt.Println(sum(values...))
尽管 " ...int " 参数就像函数体内的 slice ,但变长函数的类型和一个带有普通 slice 参数的函数的类型不相同
func f(...int) {}
func g([]int) {}
fmt.Printf("%T\n", f) // "func(...int)"
fmt.Printf("%T\n", g) // "func([]int)"
变长函数通常用于格式化字符串;
下面的 errorf 函数构建一条格式化的错误消息,在消息的开头带有行号;函数的后缀 f 是广泛使用的命名习惯,用于可变长 Printf 风格的字符串格式化输出函数
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{} 类型意味着这个函数的最后一个参数可以接受任何值
5.8 延迟函数调用
findLinks 示例使用 http.Get 的输出作为 html.Parse 的输入;如果请求的 URL 是 HTML ,那么函数 findLinks 一定能正常工作,但是许多页面包含图片 、文字和其他文件格式;如果让 HTML 解析器去解析这类文件,则可能发生意料外的状况
下面的程序获取一个 HTML 文档然后输出它的标题;title 函数检测从服务器端回的 Content-Type 头部,如果文档不是 HTML 则返回错误
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
// 检查 Content-Type 是 HTML(如 "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
}
下面是稍稍编辑后的命令行会话示例 :
$ go build gop1.io/ch5/title1
$ ./title1 http://gop1.io
The Go Programming Language
$ ./title1 https://golang.org/doc/effective_go.html
Effective Go - The Go Programming Language
$ ./title1 https://golang.org/doc/gopher/frontpahe.png
title: https://golang.org/doc/gopher/frongpage.png
has type image/png, not text/html
观察重复的 resp.Body.Close() 调用,它保证 title 函数在任何执行路径下都会关闭网络连接,包括发生错误的情况;随着函数变得越来越复杂,并且需要处理更多的错误情况,这样一种重复的清理动作会造成之后的维护问题;我们看看 Go 语言的 defer 机制怎样让这些工作变得更简单
defer 语句基本语法 :
(1). 语法上,一个 defer 语句就是一个普通的函数或方法调用
(2). 在调用函数或方法前加上关键字 defer
defer 语句的作用 :
(1). 函数和参数表达式会在语句执行时求值
(2). 无论是正常情况,如执行 return 语句或函数执行完毕;还是不正常情况,如发生宕机;实际的那个函数或方法调用,都会 "推迟" 到包含 defer 语句的函数结束后才执行
defer 语句的使用注意点 :
(1). defer 语句没有使用次数限制
(2). 执行的时候,根据调用 defer 语句顺序的倒序进行
(3). defer 语句经常用于成对的操作,比如打开和关闭(文件句柄),连接和断开,加锁和解锁;即使是再复杂的控制流,资源在任何情况下都能够正确释放;
(4). 正确使用 defer 语句的地方是在成功获得资源之后
下面的 title 函数,一个推迟的调用替换了先前的 resp.Body.Close() 调用
func title(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
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
}
同样的方法可以使用在其他资源(包括网络连接)上,比如关闭一个打开的文件 :
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)
}
或者解锁一个互斥锁 :
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 语句末尾的圆括号,否则入口的操作会在函数退出时执行,而出口处的操作永远不会调用!
func bigSlowOperation() {
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 来模拟一个长时间的操作)
$ go build gop1.io/ch5/trace
$ ./trace
2015/11/18 09:53:26 enter bigSlowOperation
2015/11/18 09:53:36 exit bigSlowOperation (10.000589217s)
延迟执行的函数在 return 语句之后执行,并且可以更新函数的结果变量
因为匿名函数可以得到其外层函数作用域内的变量(包括命名的结果),所以延迟执行的匿名函数可以观察到函数的返回结果
示例 :考虑下面的函数 double
func double(x int) int {
return x + x
}
通过命名结果变量和增加 defer 语句,我们能够在每次调用函数的时候输出函数的参数和结果
func double(x int) (result int) {
defer func() { fmt.Printf("double(%d) = %d\n", x, result) }()
}
_ = double(4)
// 输出 :
// "double(4) = 8"
这个技巧的使用相比之前的 double 函数来说有些过了,但对于有很多返回语句的函数来说很有帮助
延迟执行的匿名函数,能够改变外层函数返回给调用者的结果
func triple(x int) (result int) {
defer func() { result += x }()
return double(x)
}
fmt.Println(triple(4)) // "12"
因为延迟的函数不到函数的最后一刻是不会执行的
要注意循环里 defer 语句的使用
示例 :下面的这段代码就可能会用尽所有的文件描述符,这是因为处理完成后却没有关闭文件
for _, filename := range filenames {
f, err := op.Open(filename)
if err != nil {
return err
}
defer f.Close() // 注意:可能会用尽文件描述符
// ...处理文件 f...
}
一种解决的方式是将循环体(包括 defer 语句)放到另一个函数里,每次循环迭代都会调用文件关闭函数
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 路径最后的一个组成部分作为文件名
// 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(Network File System),写错误往往不是立即返回而是推迟到文件关闭的时候;如果无法检查关闭操作的结果,就会导致一系列数据丢失;然而,如果 io.Copy 和 f.Close 同时失败,我们更倾向于报告 io.Copy 的错误,因为它发生在前,更有可能告诉我们失败的原因是什么
5.9 宕机
Go 语言的类型系统会捕获许多编译时错误,但有些其他的错误(比如数组越界访问或者解引用空指针)都需要在运行时进行检查;当 Go 语言运行时检测到这些错误,(程序)就会发生 "宕机"
一个典型的宕机发生时,正常的程序执行会终止,goroutine 中的所有延迟函数会执行,然后程序会异常退出并留下一条日志消息;日志消息包括宕机的值,这往往代表某种错误消息,每一个 goroutine 都会在宕机的时候显示一个函数调用的栈跟踪消息;通常可以借助这条日志消息来诊断问题的原因而不需要再一次运行该程序,因此报告一个发生宕机的程序 bug 时,总是会加上这条消息
并不是所有宕机都在运行时发生的;可以直接调用内置的宕机函数;内置的宕机函数可以接受任何值作为参数;如果碰到 "不可能发生" 的状况,宕机是最好的处理方式,比如语句执行到逻辑上不可能到达的地方时 :
switch s := suit(drawCard()); s {
case "Spades": // ...
case "Hearts": // ...
case Diamonds": // ...
case "Clubs": // ...
default:
panic(fmt.Sprintf("invalid suit %q", s)) // 宕机了吗
}
设置函数的断言是一个良好的习惯,但是这也会带来多余的检查,除非你能够提供有效的错误消息或者能够很快地检测出错误,否则在运行时检测断言条件就毫无意义
func Reset(x *Buffer) {
if x == nil {
panic("x is nil") // 没必要
}
x.Elements = nil
}
尽管 Go 语言的宕机机制和其他语言的异常很相似,但宕机的使用场景不尽相同;
由于宕机会引起程序异常退出,因此只有在发生严重的错误时才会使用宕机,比如遇到与预想的逻辑不一致的代码
用心的程序员会将所有可能发生异常退出的情况考虑在内以证实 bug 的存在;强健的代码会优雅地处理 "预期的" 错误,比如错误的输入 、配置或者 I/O 失败等;这时最好能够使用错误值来加以区分
考虑函数 regexp.Compile ,它编译了一个高效的正则表达式;如果调用时给的模式参数不合法则会报错,但是检查这个错误本身没有必要且相当繁琐,因为调用者知道这个特定的调用是不会失败的;在此情况下,使用宕机来处理这种不可能发生的错误才是更合理的选择
由于大部分的正则表达式是字面量,因此 regexp 包提供了一个包装函数 regexp.MustCompile 进行这个检查 :
package regexp
func Compile(expr string) (*Regexp, error) { /* ... */ }
func MustCompile(expr string) *Regexp {
re, err := Compile(expr)
if err != nil {
panic(err)
}
return re
}
包装函数使得初始化一个包级别的正则表达式变量(带有一个编译的正则表达式)变得更加方便,如下所示 :
var httpSchemeRE = regexp.MustCompile(`^https?:`) // "http:"或"https:"
当然,MustCompile 不应该接收到不正确的值;前缀 Must 是这类函数一个通用的命名习惯,比如前面介绍的 template.Must
当宕机发生时,所有的延迟函数以倒序执行(先声明的最后执行),从栈最上面的函数开始一直返回至 main 函数,如下面的程序所示:
func main() {
f(3)
}
func f(x int) {
fmt.Printf("f(%d)\n", x+0/x) // panic 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/gop1.io/ch5/defer1/defer.go.14
main.f(1)
src/gop1.io/ch5/defer1/defer.go.16
main.f(2)
src/gop1.io/ch5/defer1/defer.go.16
main.f(3)
src/gop1.io/ch5/defer1/defer.go.16
main.main()
src/gop1.io/ch5/defer1/defer.go.10
之后会看到,函数是可以从宕机状态恢复至正常运行状态而不让程序退出
runtime 包提供了转储栈的方法,使程序员可以诊断错误
下面的代码在 main 函数中延迟 printStack 的执行 :
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/gop1/ch5/defer2/defer.go:20
main.f(0)
src/gop1/ch5/defer2/defer.go:27
main.f(1)
src/gop1/ch5/defer2/defer.go:29
main.f(2)
src/gop1/ch5/defer2/defer.go:29
main.f(3)
src/gop1/ch5/defer2/defer.go:29
main.main()
src/gop1/ch5/defer2/defer.go:15
熟悉其他语言的异常机制的读者可能会对 runtime.Stack 能够输出函数栈信息感到吃惊,因为栈应该已经不存在了;但事实上,Go 语言的宕机机制让延迟执行的函数在栈清理之前调用
5.10 恢复
退出程序通常是正确处理宕机的方式,但也有例外
在一定情况下是可以进行恢复的,至少有时候可以在退出前清理当前混乱的情况;比如,当 Web 服务器遇到一个未知错误时,可以先关闭所有连接,这总比让客户端阻塞在那里要好,而在开发过程中,也可以向客户端汇报当前遇到的错误
如果内置的 recover 函数在延迟函数的内部调用,而且这个包含 defer 语句的函数发生宕机,recover 会终止当前的宕机状态并且返回宕机的值;函数不会从之前宕机的地方继续运行而是正常返回;如果 recover 在其他任何情况下运行则它(recover)没有任何效果且返回 nil
为了说明这一点,假设我们开发一种语言的解析器;即使它看起来运行正常,但考虑到工作的复杂性,还是会存在只在特殊情况下发生的 bug ;我们在这时会更喜欢将本该宕机的错误看作一个解析错误,不要立即终止运行,而是将一些有用的附加消息提供给用户来报告这个 bug
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 来继续触发宕机
示例 :下面的例子是 title 程序的变体,如果 HTML 文档包含多个 <title> 元素则会报错;如果这样,程序会通过调用 panic 并传递一个特殊的类型 bailout 作为参数退出递归
// soleTitle 返回文档中第一个非空标题元素
// 如果没有标题则返回错误
func soleTitle(doc *html.Node) (title string, err error) {
type bailout struct{}
defer func() {
switch p := recover(); p {
case nil:
// 没有宕机
case bailout{}:
// "预期的" 宕机
err = fmt.Errorf("multiple title elements")
default:
panic(p) // 未预期的宕机:继续宕机过程
}
}()
// 如果发现多余一个非空标题,退出递归
forEachNode(doc, func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "title" &&
n.FirstChild != nil {
if title != "" {
panic(bailout{}) // 多个标题元素
}
title = n.FirstChild.Data
}
}, nil)
if title == "" {
return "", fmt.Errorf("no title element")
}
return title, nil
}
延迟的处理函数调用 recover ,检查宕机值,如果该值是 bailout{} 则返回一个普通的错误;所有其他非空的值则说明是预料外的宕机,这时处理函数使用这个值作为参数调用 panic ,忽略 recover 的作用并且继续之前的宕机状态
有些情况下是没有恢复动作的;比如,内存耗尽使得 Go 运行时发生严重错误而直接终止进程