用于文件复制、打印、检索、排序、统计的程序通常有一个相似的结构:在输入接口上循环读取,然后对每一个元素进行一些计算,在运行时或者在最后输出结果,下面我们通过三个版本的 dup 程序,来演示如何使用Go语言来找出输入内容中重复的行,它受 UNIX 的 uniq 命令启发来找到相邻的重复行。
第一个版本的 dup1 程序输出标准输入中出现次数大于 1 的行,前面是次数,这个程序引入 if 语句、map 类型和 bufio 包。
// dup1 输出标准输入中出现次数大于1的行,前面是次数
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
counts := make(map[string]int)
input := bufio.NewScanner(os.Stdin)
for input.Scan() {
counts[input.Text()]++
}
//注意:忽略 input.Err() 中可能的错误
for line, n := range counts {
if n > 1 {
fmt.Printf("%d\t%s\n", n, line)
}
}
}
像 for 一样,if 语句中的条件部分也从不放在小括号里面,但是程序体中需要用到大括号,这里还可以有一个可选的 else 部分,当条件为 false 的时候执行。
map 存储一个键/值对集合,并且提供常量时间的操作来存储、获取或测试集合中的某个元素,键可以是其值能够进行相等(==)比较的任意类型,字符串是最常见的例子,值可以是任意类型,这个例子中,键的类型是字符串,值是 int,内置的函数 make 可以用来新建 map。
每次 dup 从输入读取一行内容,这一行就作为 map 中的键,对应的值递增 1。语句 counts[input.Text()]++ 等价于下面的两个语句:
line := input.Text()
counts[line] = counts[line] + 1
键在 map 中不存在时也是没有问题的,当一个新的行第一次出现时,右边的表达式 counts[line] 根据值类型被推演为零值,int 的零值是 0。
为了输出结果,我们使用基于 range 的 for 循环,这次在 map 类型的 counts 变量上遍历,每次迭代输出两个结果分别是 map 里面一个元素对应的键和值,map 里面键的迭代顺序通常是随机的,每次运行都不一致,这是有意设计的,以防止程序依赖某种特定的序列,此处不对排序做任何保证。
下面讨论 bufio 包,使用它可以简便、高效地处理输入和输出,其中一个最有用的特性是称为扫描器(Scanner)的类型,它可以读取输入,以行或者单词为单位断开,这是处理以行为单位输入内容的最简单方式。
程序使用短变量的声明方式,新建一个 bufio.Scanner 类型 input 变量:
input := bufio.NewScarmer(os.Stdin)
扫描器从程序的标准输入进行读取。每一次调用 input.Scan() 读取下一行,并且将结尾的换行符去掉,通过调用 input.Text() 来获取读到的内容,Scan 函数在读到新行的时候返回 true,在没有更多内容的时候返回 false。
像C语言或其他语言中的 printf 一样,函数 fmt.Printf 从一个表达式列表生成格式化的输出,它的第一个参数是格式化指示字符串,由它指定其他参数如何格式化,每一个参数的格式是一个转义字符、一个百分号加一个字符,例如:%d 将一个整数格式化为十进制的形式,%s 把参数展开为字符串变量的值。
Printf 函数有超过 10 个这样的转义字符,Go 程序员称为 verb,下表列出了一些常用的转义字符:
verb
描述
%d
十进制整数
%x, %o, %b
十六进制、八进制、二进制整数
%f, %g, %e
浮点数:如 3.141593, 3.141592653589793, 3.141593e+00
%t
布尔型:true 或 false
%c
字符(Unicode 码点)
%s
字符串
%q
带引号字符串(如 "abc")或者字符(如 'c')
%v
内置格式的任何值
%T
任何值的类型
%%
百分号本身(无操作数)
程序 dup1 中的格式化字符串还包含一个制表符 \t 和一个换行符 \n,字符串字面量可以包含类似转义序列(escape sequence)来表示不可见字符,Printf 默认不换行。
按照约定,诸如 log.Printf 和 fmt.Errorf 之类的格式化函数以 f 结尾,使用和 fmt.Printf 相同的格式化规则,而那些以 ln 结尾的函数(如 Println)则使用 %v 的方式来格式化参数,并在最后追加换行符。
许多程序既可以像 dup 一样从标准输入进行读取,也可以从具体的文件读取,下一个版本的 dup 程序可以从标准输入或一个文件列表进行读取,使用 os.Open 函数来逐个打开:
// dup2 打印输入中多次出现的行的个数和文本
//它从 stdin 或指定的文件列表读取
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
counts := make(map[string]int)
files := os.Args[1:]
if len(files) == 0 {
countLines(os.Stdin, counts)
} else {
for _, arg := range files {
f, err := os.Open(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "dup2: %v\n", err)
continue
}
countLines(f, counts)
f.Close()
}
}
for line, n := range counts {
if n > 1 {
fmt.Printf("%d\t%s\n", n, line)
}
}
}
func countLines(f *os.File, counts map[string]int) {
input := bufio.NewScanner(f)
for input.Scan() {
counts[input.Text()]++
}
//注意:忽略 input.Err() 中可能的错误
}
函数 os.Open 返回两个值,第一个是打开的文件(*os.File),该文件随后被 Scanner 读取。
第二个返回值是一个内置的 error 类型的值,如果 err 等于 nil,则表示文件成功打开,文件在被读到结尾的时候,使用 Close 函数关闭文件,然后释放相应的资源(内存等)。
另一方面,如果 err 不等于 nil,说明出错了,这时,error 的值为描述错误原因,简单的错误处理是使用 Fprintf 和 %v 在标准错误流上输出一条消息,%v 可以使用默认格式显示任意类型的值;错误处理后,dup 开始处理下一个文件;continue 语句让循环进入下一个迭代。
为了保持示例代码简短,这里对错误处理有意进行了一定程度的忽略。很明显,必须检查 os.Open 返回的错误;但是,我们忽略了使用 input.Scan 读取文件的过程中出现概率很小的错误。我们将标记所跳过的错误检查。
值得注意的是,对 countLines 的调用出现在其声明之前。函数和其他包级别的实体可以以任意次序声明。
map 是一个使用 make 创建的数据结构的引用。当一个 map 被传递给一个函数时,函数接收到这个引用的副本,所以被调用函数中对于 map 数据结构中的改变对函数调用者使用的 map 引用也是可见的。在示例中,countLines 函数在 counts map 中插入的值,在 main 函数中也是可见的。
这个版本的 dup 使用“流式”模式读取输入,然后按需拆分为行,这样原理上这些程序可以处理海量的输入。一个可选的方式是一次读取整个输入到大块内存,一次性地分割所有行,然后处理这些行。接下去的版本 dup3 将以这种方式处理。这里引入一个 ReadFile 函数(从 io/ioutil 包),它读取整个命名文件的内容,还引入一个 strings.Split 函数,它将一个字符串分割为一个由子串组成的 slice。(Split 是前面介绍过的 strings.Join 的反操作。)
我们在某种程度上简化了 dup3:第一,它仅读取指定的文件,而非标准输入,因为 ReadFile 需要一个文件名作为参数;第二,我们将统计行数的工作放回 main 函数中,因为它当前仅在一处用到。
package main
import (
"fmt"
"io/ioutil"
"os"
"strings"
)
func main() {
counts := make(map[string]int)
for _, filename := range os.Args[1:] {
data, err := ioutil.ReadFile(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "dup3: %v\n", err)
continue
}
for _, line := range strings.Split(string(data), "\r") {
counts[line]++
}
}
for line, n := range counts {
if n > 1 {
fmt.Printf("%d\t%s\n", n, line)
}
}
}
ReadFile 函数返回一个可以转化成字符串的字节 slice,这样它可以被 strings.Split 分割。
实际上,bufio.Scanner、ioutil.ReadFile 以及 ioutil.WriteFile 使用 *os.File 中的 Read 和 Write 方法,但是大多数程序员很少需要直接访问底层的例程。像 bufio 和 io/ioutil 包中上层的方法更易使用。