这一节目标是完成统计相同行出现的次数。题目来源于 gopl 一书。
举个例子,下面一段文本:
// input 文件
hello
Jack
Allen
hello
Allen
Allen
最终我们的程序应该能统计出 hello 出现 2 次,Jack 出现 1 次,Allen 出现 3 次。为了统一输出的格式,希望得到下面的结果:
2 hello
1 Jack
3 Allen
即第 1 列是出现的次数,第 2 列是文本内容。为了再增加一点难度,对于出现少于一次的就不输出了。最终结果应该像下面这样:
2 hello
3 Allen
当然了,打印的顺序无关紧要。
明确目标后,开始吧。
1. 思路
如果是 C 语言,可能不太好写是吧。如果是 C++,你可以使用 std::map<std::string, int>
这样的数据结构来做。但是我们需要使用 Go 语言啊,得使用 Go 语言提供的 map 这种数据结构。
另外 for 循环啊 if 这种也跑不了了。还记得上一节里学过的 for 循环吗?那和 C 语言里的 for 循环格式几乎一样。
为了方便分析,还是先看一下代码吧。
// demo01.go
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
counts := make(map[string]int) // 初始化一个 map
inputScanner := bufio.NewScanner(os.Stdin) // 创建一个 Scanner 对象
for inputScanner.Scan() { // 这个 for 相当于 C 语言的 while
counts[inputScanner.Text()]++
}
// key 是字符串,value 是该串出现的次数
for key, value := range counts {
if value > 1 {
fmt.Printf("%3d %s\n", value, key)
}
}
}
2. 演示
新的程序路径是 gogo/src/gopl/tutorial/dup/demo01.go
,来运行一下看看吧!
// input 文件就是文章最开始给的那一段文本
$ cat input | go run demo01.go
图1 运行结果
值得一提的是,这里我运行了 3 次,发现最后一次打印的顺序和前 2 次不一样。后面再解释。
3. 程序分析
3.1 go 语言里的 map
可以看到 go 语言声明 map 的方式很独特,如果是 C++,你肯定是这样声明 map<string, int>
,但是 go 使用的方法是 map[string]int
,没事没事,习惯就好。
方括号里的自然是 key 的类型了,方括号后面的是 int 表示的是 value 的类型。 另一点很奇怪的是,代码里为啥不直接这样写:
var counts map[string]int
不如就先改成这样,再来看看结果:
图2 不使用 make 后的结果
看到了吧,程序报错了,引发恐慌(panic)了都。具体原因是我们试图往一个『空』(nil) 的 map 中添加值。
在这里 counts 有点类似于『引用』的概念,你使用 var counts map[string]int
声明了一个指向了 nil 的变量。它有点类似于 Java 里的引用的概念。
// go
var counts map[string]int
// java
Map<String, int> counts = null;
在 go 语言里,数据类型分为四大类(简单了解):基础类型、复合类型、引用类型和接口类型(basic types, aggregate types, reference types and interface types)。类型系统将在后面详细介绍。
在这里,我们把 map 这种类型归属于『引用』类型。
好了,知道上面的解释后,我们就知道为什么使用 make
函数了,make
是 go 语言的内置函数,它可以创建一个不为 nil
的 map
.
3.2 NewScanner 是何物
学习一门语言最好的方法就是查文档,这里推荐一个在线文档,可以查阅各种语言:http://devdocs.io/
从前面的程序可以看出来,NewScanner
应该是 bufio
包导出的一个函数。来查阅一下:
图3 NewScanner 函数
有同学会吐槽了,这是什么鬼。好吧,是有点操之过急。不过为了能理解本文的程序,还是作一下类比解释。
函数 NewScanner
接口一个类型为 io.Reader
的变量,在没有讲接口前,我们『姑且』认为它是一个抽象接口类(其实本来就是)。这个函数返回一个类型为 Scanner
对象的指针(牛逼了,go 语言也有指针的概念)。另外,我们『姑且』认为 Scanner
相当于 C++/Java 中 class 的概念,也就是说声明了一个类,class Scanner
。
好了,点到为止。再说一下 os.Stdin
,它是 os.File
类型的指针,看起来它可能是实现了 io.Reader 这个接口的方法(没有学面向对象语言的同学我对不起你了)。os.Stdin
就是标准输入,和 C++ 的 std::cin
差不多。
再来看看 Scanner
,NewScanner
函数返回的结果就是 Scanner
对象的指针。在我们的程序里,我们使用了该对象的两个方法,一个是 Scan
方法,另一个是 Text
方法。看文档,可以看到『类』Scanner 声明方法的格式也很奇特,以 Scan 方法为例,在 C++ 是这样声明的 bool Scanner::Scan()
,但是在 go 中是这样的:func (*Scanner) Scan() bool
。后面还会详细介绍,这里就先了解一下。
- Scanner::Scan
说的简单点,这个函数就是个迭代器,每运行一次,就读取一行到当前缓冲区中。如果所有行都读完了,这个函数返回 false
.
图4
Scanner
的
Scan
方法
- Scanner::Text
而这个函数,把当前缓冲区的数据返回
图5
Scanner
的
Text
方法
最终,有了下面这些代码。
for inputScanner.Scan() {
counts[inputScanner.Text()]++
}
值得一说的是,counts
对于不存在于其中的 key
,直接调用 counts[key]++
不会出事吗?这是 go 语言很特殊的地方,对于不存在的 key
调用 counts[key]++
,它会先将 counts[key]
初始化为 value 类型的零值,在我们这里 value 的类型是 int
,所以零值就是 0;接下来再执行 ++
操作。
另外,在这里这个 for 循环,和 C 语言里的 while 循环用法是一样的。
3.3 第三种 for 循环
这种 for 循环只有在一些更高级的语言里才会出现,比如 C++ 的 for (auto e : list)
,还有 python 的 for e in list
。这种 for 一般都称之为 range-based-for,即基于范围的 for 循环,它更加智能。
在 go 里也有这样的用法,就如代码中的那样:
for key, value := range counts {
if value > 1 {
fmt.Printf("%3d %s\n", value, key)
}
}
在这里,range
是 go 语言的一个关键字,配合 for 来使用。每一次循环,都会返回一对 <key, value>
,直到遍历完所有键值对。
还记得刚刚图 1 中的结果吗?程序每次运行,遍历的顺序可能都不一样。这是 go 有意而为之,就是防止你误以为遍历的结果是有顺序的,所以才加了随机特性。
3.4 if 语句
if
语句和 C 语言的一样,只是没有括号而已。
4. 关于代码风格
go 语言对代码风格采取了非常强硬的措施,什么左花括号是否必须另起一行的撕逼行为永远不会在 go 中出现。go 语言硬性限制左花括号必须紧跟在语句后面。
类似的还有导入包时,包导入路径必须要按照字母表顺序排列等。
严格的限制的好处就是防止了无意义的撕逼。
go 语言提供了格式化代码的工具。使用方法:
$ go fmt hello.go
5. 总结
进一步熟悉 go 代码,慢慢产生感觉。