mirror of
https://github.com/gopl-zh/gopl-zh.github.com.git
synced 2025-09-12 06:41:33 +00:00
This section introduces anonymous functions in Go, explaining their syntax, usage, and implications in terms of closures and lexical environments. It also discusses common pitfalls related to variable capture in loops and provides examples of breadth-first search and topological sorting.
306 lines
13 KiB
Markdown
306 lines
13 KiB
Markdown
## 5.6. 匿名函数
|
||
|
||
拥有函数名的函数只能在包级语法块中被声明,通过函数字面量(function literal),我们可绕过这一限制,在任何表达式中表示一个函数值。函数字面量的语法和函数声明相似,区别在于func关键字后没有函数名。函数值字面量是一种表达式,它的值被称为匿名函数(anonymous function)。
|
||
|
||
函数字面量允许我们在使用函数时,再定义它。通过这种技巧,我们可以改写之前对strings.Map的调用:
|
||
|
||
```Go
|
||
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
|
||
```
|
||
|
||
更为重要的是,通过这种方式定义的函数可以访问完整的词法环境(lexical environment),这意味着在函数中定义的内部函数可以引用该函数的变量,如下例所示:
|
||
|
||
<u><i>gopl.io/ch5/squares</i></u>
|
||
```Go
|
||
// squares返回一个匿名函数。
|
||
// 该匿名函数每次被调用时都会返回下一个数的平方。
|
||
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"
|
||
}
|
||
```
|
||
|
||
函数squares返回另一个类型为 func() int 的函数。对squares的一次调用会生成一个局部变量x并返回一个匿名函数。每次调用匿名函数时,该函数都会先使x的值加1,再返回x的平方。第二次调用squares时,会生成第二个x变量,并返回一个新的匿名函数。新匿名函数操作的是第二个x变量。
|
||
|
||
squares的例子证明,函数值不仅仅是一串代码,还记录了状态。在squares中定义的匿名内部函数可以访问和更新squares中的局部变量,这意味着匿名函数和squares中,存在变量引用。这就是函数值属于引用类型和函数值不可比较的原因。Go使用闭包(closures)技术实现函数值,Go程序员也把函数值叫做闭包。
|
||
|
||
通过这个例子,我们看到变量的生命周期不由它的作用域决定:squares返回后,变量x仍然隐式的存在于f中。
|
||
|
||
接下来,我们讨论一个有点学术性的例子,考虑这样一个问题:给定一些计算机课程,每个课程都有前置课程,只有完成了前置课程才可以开始当前课程的学习;我们的目标是选择出一组课程,这组课程必须确保按顺序学习时,能全部被完成。每个课程的前置课程如下:
|
||
|
||
<u><i>gopl.io/ch5/toposort</i></u>
|
||
```Go
|
||
// prereqs记录了每个课程的前置课程
|
||
var prereqs = map[string][]string{
|
||
"algorithms": {"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 systems"},
|
||
"operating systems": {"data structures", "computer organization"},
|
||
"programming languages": {"data structures", "computer organization"},
|
||
}
|
||
```
|
||
|
||
这类问题被称作拓扑排序。从概念上说,前置条件可以构成有向图。图中的顶点表示课程,边表示课程间的依赖关系。显然,图中应该无环,这也就是说从某点出发的边,最终不会回到该点。下面的代码用深度优先搜索了整张图,获得了符合要求的课程序列。
|
||
|
||
```Go
|
||
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绑定,我们也无法递归调用该匿名函数。
|
||
|
||
```Go
|
||
visitAll := func(items []string) {
|
||
// ...
|
||
visitAll(m[item]) // compile error: undefined: visitAll
|
||
// ...
|
||
}
|
||
```
|
||
|
||
在toposort程序的输出如下所示,它的输出顺序是大多人想看到的固定顺序输出,但是这需要我们多花点心思才能做到。哈希表prepreqs的value是遍历顺序固定的切片,而不再是遍历顺序随机的map,所以我们对prereqs的key值进行排序,保证每次运行toposort程序,都以相同的遍历顺序遍历prereqs。
|
||
|
||
```
|
||
1: intro to programming
|
||
2: discrete math
|
||
3: data structures
|
||
4: algorithms
|
||
5: linear algebra
|
||
6: calculus
|
||
7: formal languages
|
||
8: computer organization
|
||
9: compilers
|
||
10: databases
|
||
11: operating systems
|
||
12: networks
|
||
13: programming languages
|
||
```
|
||
|
||
让我们回到findLinks这个例子。我们将代码移动到了links包下,将函数重命名为Extract,在第八章我们会再次用到这个函数。新的匿名函数被引入,用于替换原来的visit函数。该匿名函数负责将新连接添加到切片中。在Extract中,使用forEachNode遍历HTML页面,由于Extract只需要在遍历结点前操作结点,所以forEachNode的post参数被传入nil。
|
||
|
||
<u><i>gopl.io/ch5/links</i></u>
|
||
```Go
|
||
// Package links provides a link-extraction function.
|
||
package links
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"golang.org/x/net/html"
|
||
)
|
||
// Extract makes an HTTP GET request to the specified URL, parses
|
||
// the response as HTML, and returns the links in the HTML document.
|
||
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 // ignore bad URLs
|
||
}
|
||
links = append(links, link.String())
|
||
}
|
||
}
|
||
}
|
||
forEachNode(doc, visitNode, nil)
|
||
return links, nil
|
||
}
|
||
```
|
||
|
||
上面的代码对之前的版本做了改进,现在links中存储的不是href属性的原始值,而是通过resp.Request.URL解析后的值。解析后,这些连接以绝对路径的形式存在,可以直接被http.Get访问。
|
||
|
||
网页抓取的核心问题就是如何遍历图。在topoSort的例子中,已经展示了深度优先遍历,在网页抓取中,我们会展示如何用广度优先遍历图。在第8章,我们会介绍如何将深度优先和广度优先结合使用。
|
||
|
||
下面的函数实现了广度优先算法。调用者需要输入一个初始的待访问列表和一个函数f。待访问列表中的每个元素被定义为string类型。广度优先算法会为每个元素调用一次f。每次f执行完毕后,会返回一组待访问元素。这些元素会被加入到待访问列表中。当待访问列表中的所有元素都被访问后,breadthFirst函数运行结束。为了避免同一个元素被访问两次,代码中维护了一个map。
|
||
|
||
<u><i>gopl.io/ch5/findlinks3</i></u>
|
||
```Go
|
||
// breadthFirst calls f for each item in the worklist.
|
||
// Any items returned by f are added to the worklist.
|
||
// 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)...)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
就像我们在章节3解释的那样,append的参数“f(item)...”,会将f返回的一组元素一个个添加到worklist中。
|
||
|
||
在我们网页抓取器中,元素的类型是url。crawl函数会将URL输出,提取其中的新链接,并将这些新链接返回。我们会将crawl作为参数传递给breadthFirst。
|
||
|
||
```go
|
||
func crawl(url string) []string {
|
||
fmt.Println(url)
|
||
list, err := links.Extract(url)
|
||
if err != nil {
|
||
log.Print(err)
|
||
}
|
||
return list
|
||
}
|
||
```
|
||
|
||
为了使抓取器开始运行,我们用命令行输入的参数作为初始的待访问url。
|
||
|
||
```Go
|
||
func main() {
|
||
// Crawl the web breadth-first,
|
||
// starting from the command-line arguments.
|
||
breadthFirst(crawl, os.Args[1:])
|
||
}
|
||
```
|
||
|
||
让我们从 https://golang.org 开始,下面是程序的输出结果:
|
||
|
||
```
|
||
$ go build gopl.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
|
||
http://research.swtch.com/gotour
|
||
```
|
||
|
||
当所有发现的链接都已经被访问或电脑的内存耗尽时,程序运行结束。
|
||
|
||
**练习5.10:** 重写topoSort函数,用map代替切片并移除对key的排序代码。验证结果的正确性(结果不唯一)。
|
||
|
||
**练习5.11:** 现在线性代数的老师把微积分设为了前置课程。完善topSort,使其能检测有向图中的环。
|
||
|
||
**练习5.12:** gopl.io/ch5/outline2(5.5节)的startElement和endElement共用了全局变量depth,将它们修改为匿名函数,使其共享outline中的局部变量。
|
||
|
||
**练习5.13:** 修改crawl,使其能保存发现的页面,必要时,可以创建目录来保存这些页面。只保存来自原始域名下的页面。假设初始页面在golang.org下,就不要保存vimeo.com下的页面。
|
||
|
||
**练习5.14:** 使用breadthFirst遍历其他数据结构。比如,topoSort例子中的课程依赖关系(有向图)、个人计算机的文件层次结构(树);你所在城市的公交或地铁线路(无向图)。
|
||
|
||
### 5.6.1. 警告:捕获迭代变量
|
||
|
||
本节,将介绍Go词法作用域的一个陷阱。请务必仔细的阅读,弄清楚发生问题的原因。即使是经验丰富的程序员也会在这个问题上犯错误。
|
||
|
||
考虑这样一个问题:你被要求首先创建一些目录,再将目录删除。在下面的例子中我们用函数值来完成删除操作。下面的示例代码需要引入os包。为了使代码简单,我们忽略了所有的异常处理。
|
||
|
||
```Go
|
||
var rmdirs []func()
|
||
for _, d := range tempDirs() {
|
||
dir := d // NOTE: necessary!
|
||
os.MkdirAll(dir, 0755) // creates parent directories too
|
||
rmdirs = append(rmdirs, func() {
|
||
os.RemoveAll(dir)
|
||
})
|
||
}
|
||
// ...do some work…
|
||
for _, rmdir := range rmdirs {
|
||
rmdir() // clean up
|
||
}
|
||
```
|
||
|
||
你可能会感到困惑,为什么要在循环体中用循环变量d赋值一个新的局部变量,而不是像下面的代码一样直接使用循环变量dir。需要注意,下面的代码是错误的。
|
||
|
||
```go
|
||
var rmdirs []func()
|
||
for _, dir := range tempDirs() {
|
||
os.MkdirAll(dir, 0755)
|
||
rmdirs = append(rmdirs, func() {
|
||
os.RemoveAll(dir) // NOTE: incorrect!
|
||
})
|
||
}
|
||
```
|
||
|
||
问题的原因在于循环变量的作用域。在上面的程序中,for循环语句引入了新的词法块,循环变量dir在这个词法块中被声明。在该循环中生成的所有函数值都共享相同的循环变量。需要注意,函数值中记录的是循环变量的内存地址,而不是循环变量某一时刻的值。以dir为例,后续的迭代会不断更新dir的值,当删除操作执行时,for循环已完成,dir中存储的值等于最后一次迭代的值。这意味着,每次对os.RemoveAll的调用删除的都是相同的目录。
|
||
|
||
通常,为了解决这个问题,我们会引入一个与循环变量同名的局部变量,作为循环变量的副本。比如下面的变量dir,虽然这看起来很奇怪,但却很有用。
|
||
|
||
```Go
|
||
for _, dir := range tempDirs() {
|
||
dir := dir // declares inner dir, initialized to outer dir
|
||
// ...
|
||
}
|
||
```
|
||
|
||
这个问题不仅存在基于range的循环,在下面的例子中,对循环变量i的使用也存在同样的问题:
|
||
|
||
```Go
|
||
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]) // NOTE: incorrect!
|
||
})
|
||
}
|
||
```
|
||
|
||
如果你使用go语句(第八章)或者defer语句(5.8节)会经常遇到此类问题。这不是go或defer本身导致的,而是因为它们都会等待循环结束后,再执行函数值。
|
||
|