diff --git a/ch5/ch5-06-1.md b/ch5/ch5-06-1.md index 5ced5d7..323d5e1 100644 --- a/ch5/ch5-06-1.md +++ b/ch5/ch5-06-1.md @@ -1,3 +1,58 @@ ### 5.6.1. 警告:捕獲迭代變量 -TODO +本节,将介绍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本身导致的,而是因为它们都会等待循环结束后,再执行函数值。 diff --git a/ch5/ch5-06.md b/ch5/ch5-06.md index 4908d58..4b09352 100644 --- a/ch5/ch5-06.md +++ b/ch5/ch5-06.md @@ -1,5 +1,243 @@ ## 5.6. 匿名函數 -TODO +拥有函数名的函数只能在包级语法块中被声明,通过函数字面量(function literal),我们可绕过这一限制,在任何表达式中表示一个函数值。函数字面量的语法和函数声明相似,区别在于func关键字后没有函数名。函数值字面量是一种表达式,它的值被成为匿名函数(anonymous function)。 -{% include "./ch5-06-1.md" %} +函数字面量允许我们在使用时函数时,再定义它。通过这种技巧,我们可以改写之前对strings.Map的调用: + +```Go +strings.Map(func(r rune) rune { return r + 1 }, "HAL-9000") +``` + +更为重要的是,通过这种方式定义的函数可以访问完整的词法环境(lexical environment),这意味着在函数中定义的内部函数可以引用该函数的变量,如下例所示: + +```Go +gopl.io/ch5/squares +// 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中。 + +接下来,我们讨论一个有点学术性的例子,考虑这样一个问题:给定一些计算机课程,每个课程都有前置课程,只有完成了前置课程才可以开始当前课程的学习;我们的目标是选择出一组课程,这组课程必须确保按顺序学习时,能全部被完成。每个课程的前置课程如下: + +```Go +gopl.io/ch5/toposort +// 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 + // ... +} +``` + +在topsort中,首先对prereqs中的key排序,再调用visitAll。因为prereqs映射的是数组而不是更复杂的map,所以数据的遍历次序是固定的,这意味着你每次运行topsort得到的输出都是一样的。 topsort的输出结果如下: + +``` +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。 + +```Go +gopl.io/ch5/links +// 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。 + +```Go +gopl.io/ch5/findlinks3 +// 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开始,下面是程序的输出结果: + +```bash +$ 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例子中的课程依赖关系(有向图),个人计算机的文件层次结构(树),你所在城市的公交或地铁线路(无向图)。 + +{% include "./ch5-06-1.md" %} \ No newline at end of file