匿名函数
拥有函数名的函数只能在包级语法块中被声明,通过函数字面量(function literal),我们可绕过这一限制,在任何表达式中表示一个函数值。函数字面量的语法和函数声明相似,区别在于 func 关键字后没有函数名。函数值字面量是一种表达式,它的值被称为匿名函数(anonymous function)。
函数字面量允许我们在使用函数时,再定义它。通过这种技巧,我们可以改写之前对 strings.Map 的调用:
strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000")
更为重要的是,通过这种方式定义的函数可以访问完整的词法环境(lexical environment),这意味着在函数中定义的内部函数可以引用该函数的变量,如下例所示:
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/squares/main.go[]
函数 squares 返回另一个类型为 func() int 的函数。对 squares 的一次调用会生成一个局部变量 x 并返回一个匿名函数。每次调用匿名函数时,该函数都会先使 x 的值加 1,再返回 x 的平方。第二次调用 squares 时,会生成第二个 x 变量,并返回一个新的匿名函数。新匿名函数操作的是第二个 x 变量。
squares 的例子证明,函数值不仅仅是一串代码,还记录了状态。在 squares 中定义的匿名内部函数可以访问和更新 squares 中的局部变量,这意味着匿名函数和 squares 中,存在变量引用。这就是函数值属于引用类型和函数值不可比较的原因。 Go 使用闭包(closures)技术实现函数值,Go 程序员也把函数值叫做闭包。
通过这个例子,我们看到变量的生命周期不由它的作用域决定:squares 返回后,变量 x 仍然隐式的存在于 f 中。
接下来,我们讨论一个有点学术性的例子,考虑这样一个问题:给定一些计算机课程,每个课程都有前置课程,只有完成了前置课程才可以开始当前课程的学习;我们的目标是选择出一组课程,这组课程必须确保按顺序学习时,能全部被完成。每个课程的前置课程如下:
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/toposort/main.go[]
这类问题被称作拓扑排序。从概念上说,前置条件可以构成有向图。图中的顶点表示课程,边表示课程间的依赖关系。显然,图中应该无环,这也就是说从某点出发的边,最终不会回到该点。下面的代码用深度优先搜索了整张图,获得了符合要求的课程序列。
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/toposort/main.go[]
当匿名函数需要被递归调用时,我们必须首先声明一个变量(在上面的例子中,我们首先声明了 visitAll),再将匿名函数赋值给这个变量。如果不分成两步,函数字面量无法与 visitAll 绑定,我们也无法递归调用该匿名函数。
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 。
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/links/links.go[]
上面的代码对之前的版本做了改进,现在 links 中存储的不是 href 属性的原始值,而是通过 resp.Request.URL 解析后的值。解析后,这些连接以绝对路径的形式存在,可以直接被 http.Get 访问。
网页抓取的核心问题就是如何遍历图。在 topoSort 的例子中,已经展示了深度优先遍历,在网页抓取中,我们会展示如何用广度优先遍历图。在第8章,我们会介绍如何将深度优先和广度优先结合使用。
下面的函数实现了广度优先算法。调用者需要输入一个初始的待访问列表和一个函数 f 。待访问列表中的每个元素被定义为 string 类型。广度优先算法会为每个元素调用一次 f 。每次 f 执行完毕后,会返回一组待访问元素。这些元素会被加入到待访问列表中。当待访问列表中的所有元素都被访问后,breadthFirst 函数运行结束。为了避免同一个元素被访问两次,代码中维护了一个 map 。
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/findlinks3/findlinks.go[]
就像我们在章节3解释的那样,append 的参数 “f(item)…”,会将 f 返回的一组元素一个个添加到 worklist 中。
在我们网页抓取器中,元素的类型是 url 。crawl 函数会将 URL 输出,提取其中的新链接,并将这些新链接返回。我们会将 crawl 作为参数传递给 breadthFirst 。
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/findlinks3/findlinks.go[]
为了使抓取器开始运行,我们用命令行输入的参数作为初始的待访问 url 。
Unresolved include directive in modules/ROOT/pages/ch5/ch5-06.adoc - include::example$/ch5/findlinks3/findlinks.go[]
让我们从 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 例子中的课程依赖关系(有向图)、个人计算机的文件层次结构(树);你所在城市的公交或地铁线路(无向图)。
警告:捕获迭代变量
本节,将介绍 Go 词法作用域的一个陷阱。请务必仔细的阅读,弄清楚发生问题的原因。即使是经验丰富的程序员也会在这个问题上犯错误。
考虑这样一个问题:你被要求首先创建一些目录,再将目录删除。在下面的例子中我们用函数值来完成删除操作。下面的示例代码需要引入 os 包。为了使代码简单,我们忽略了所有的异常处理。
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 。需要注意,下面的代码是错误的。
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 ,虽然这看起来很奇怪,但却很有用。
for _, dir := range tempDirs() {
dir := dir // declares inner dir, initialized to outer dir
// ...
}
这个问题不仅存在基于 range 的循环,在下面的例子中,对循环变量 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]) // NOTE: incorrect!
})
}
如果你使用 go 语句(第八章)或者 defer 语句(5.8节)会经常遇到此类问题。这不是 go 或 defer 本身导致的,而是因为它们都会等待循环结束后,再执行函数值。