1.3. 查找重复的行

文件拷贝、文件打印、文件搜索、文件排序、文件统计类的程序一般都会有比较相似的程序结构:一个处理输入的循环,在每一个输入元素上执行计算处理,在处理的同时或者处理完成之后进行结果输出。我们会展示一个名为dup的程序(duplicate)的三个版本;这个程序的灵感来自于linux的uniq命令,我们的程序将会找到相邻的重复的行。这个程序提供的模式可以很方便地被修改来完成不同的需求。

第一个版本的dup输出标准输入流中的出现多次的行,在行内容前是出现次数的计数。这个程序将引入if表达式,map内置数据结构和bufio的package。

gopl.io/ch1/dup1
// Dup1 prints the text of each line that appears more than
// once in the standard input, preceded by its count.
package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    counts := make(map[string]int)
    input := bufio.NewScanner(os.Stdin)
    for input.Scan() {
        counts[input.Text()]++
    }
    // NOTE: ignoring potential errors from input.Err()
    for line, n := range counts {
        if n > 1 {
            fmt.Printf("%d\t%s\n", n, line)
        }
    }
}

和前面提到的for循环一样,if条件两边也不需要加括号,但是if表达式后的逻辑体的花括号是不能省略的。如果需要的话,像其它编程语言一样,这个if表达式也可以有else部分,这部分逻辑会在if中的条件结果为false时被执行。

map是Go语言内置的key/value型数据结构,这个数据结构能够提供常数时间的存取操作。key支持任意数据类型,只要该类型能够用==运算符来进行比较,string是最常用的key类型。而value类型的可选范围就更广了,基本上什么类型都可以。这个例子中的key都是string类型,value用的是int类型。使用内置make函数来创建空map,但除了创建空map以外,make方法还有别的用处。4.3章会对map进行更深入的讨论。

dup程序每次读取输入的一行,这一行的内容会被当做一个map的key,而其value值会被+1。counts[input.Text()]++这个语句和下面的两句是等价的:

line := input.Text()
counts[line] = counts[line] + 1

不用担心map在未初始化某个key时就去对其进行++操作,Go语言在碰到这种情况时,会自动将其初始化为0,然后再进行操作。

这个例子使用了range来遍历map并打印结果。和上次使用到了range的程序类似,range会返回两个值,一个key和在map对应这个key的value。对map进行range循环时,其迭代顺序是不确定的,从实践来看,很可能每次运行都会有不一样的结果(译注:这是Go语言的设计者有意为之的,因为其底层实现不保证插入顺序和遍历顺序一致,也希望程序员不要依赖遍历时的顺序,所以干脆直接在遍历的时候做了随机化处理,醉了。补充:好像说随机序可以防止某种类型的攻击,虽然不太明白,但是感觉还蛮厉害的),来避免程序员在业务中依赖遍历时的顺序。

程序中用到的bufio package,主要的目的是帮助我们更方便有效地处理程序的输入和输出。这个包最有用的一个特性是Scanner类型,可以简单实现接收输入,或把输入打散成行或者单词;这个工具通常是处理行形式的输入最简单的方法了。

程序中使用短变量声明来创建buffio.Scanner对象:

input := bufio.NewScanner(os.Stdin)

scanner对象从程序的标准输入中读取内容。对input.Scanner的每一次调用都会调入一个新行,并且会自动将其行末的换行符去掉;结果用input.Text()得到。Scan方法在读到了新行的时候会返回true,而在没有新行被读入时,会返回false。

例子中还有一个fmt.Printf,这个函数和C系的其它语言里的那个printf函数差不多,都是格式化输出的方法。fmt.Printf的第一个参数即是输出内容的格式规约,每一个参数如何格式化取决于在格式化字符串里出现的“转换字符”,这个字符串是%号后跟随一个字母。比如%d表示以一个整数的形式来打印一个变量,而%s,则表示以string形式来打印一个变量。

Printf有一大堆这种转换,Go语言程序员把这些叫做verb(动词)。下面的表格列出了常用的动词,当然了不是全部,但基本也够用了。

%d          int变量
%x, %o, %b  分别为16进制,8进制,2进制形式的int
%f, %g, %e  浮点数: 3.141593 3.141592653589793 3.141593e+00
%t          布尔变量:true 或 false
%c          rune (Unicode码点),Go语言里特有的Unicode字符类型
%s          string
%q          带双引号的字符串 "abc" 或 带单引号的 rune 'c'
%v          会将任意变量以易读的形式打印出来
%T          打印变量的类型
%%          字符型百分比标志(%符号本身,没有其他操作)

dup1中的程序还出现了\t和\n的格式化字符串。这些特殊的转义字符在字符串中表示不可见字符。Printf默认不会在输出内容后加上换行符。按照惯例,用来格式化的函数都会在末尾以f字母结尾(译注:f后缀对应format或fmt缩写),比如log.Printf,fmt.Errorf,同时还有一系列对应以ln结尾的函数(译注:ln后缀对应line缩写),这些函数默认以%v来格式化他们的参数,并且会在输出结束后在最后自动加上一个换行符。

很多程序像上面的例子一样从标准输入中读取数据,但输入源有时还可能是一些文件。下面的dup程序从标准输入得到一些文件名,然后用os.Open函数来打开每一个文件获取内容。

gopl.io/ch1/dup2
// Dup2 prints the count and text of lines that appear more than once
// in the input.  It reads from stdin or from a list of named files.
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()]++
    }
    // NOTE: ignoring potential errors from input.Err()
}

os.Open函数会返回两个值。第一个值是一个打开的文件类型(*os.File),这个对象在下面的程序中被Scanner读取。

os.Open返回的第二个值是一个Go语言内置的error类型。如果这个error和内置值的nil(译注:相当于其它语言里的NULL)相等的话,说明文件被成功的打开了。之后文件被读取,一直到文件的最后,文件的Close方法关闭该文件,并释放占用的一切资源。如果err的值不是nil的话,那说明在打开文件的时候出了某种错误。这种情况下,error类型的值会描述具体的问题。我们例子里的简单错误处理会在标准错误流中用Fprintf和%v来格式化该错误字符串。然后继续处理下一个文件;continue语句会直接跳过之后的语句,直接开始执行下一个循环迭代。

我们在本书早期的例子中做了比较详尽的错误处理,当然了,在实际编码过程中,像os.Open这类的函数是一定要检查其返回的error值的;为了减少例子程序的代码量,我们姑且简化掉这些不太可能返回错误的处理逻辑。后面的例子里我们会跳过错误检查。在5.4节中我们会对错误处理做更详细的阐述。

读者可以再观察一下上面的例子,countLines函数是在其声明之前就被调用了。在Go语言里,函数和包级别的变量可以以任意的顺序被声明,并不影响其被调用。(译注:最好还是遵循一定的规范)

再来讲讲map这个数据结构,map是用make函数创建的数据结构的一个引用。当一个map被作为参数传递给一个函数时,函数接收到的是一份引用的拷贝,虽然本身并不是一个东西,但因为他们指向的是同一块数据对象(译注:类似于C++里的引用传递,实际上指针是另一个指针了,但内部存的值指向同一块内存),所以你在函数里对map里的值进行修改时,原始的map内的值也会改变。在我们的例子中,我们在countLines函数中插入到counts这个map里的值,在主函数中也是看得到的。

上面这个版本的dup是以流的形式来处理输入,并将其打散为行。理论上这些程序也是可以以二进制形式来处理输入的。我们也可以一次性的把整个输入内容全部读到内存中,然后再把其分割为多行,然后再去处理这些行内的数据。下面的dup3这个例子就是以这种形式来进行操作的。这个例子引入了一个新函数ReadFile(从io/ioutil包提供),这个函数会把一个指定名字的文件内容一次性调入,之后我们用strings.Split函数把文件分割为多个子字符串,并存储到slice结构中。(Split函数是strings.Join的逆函数,Join函数之前提到过)

我们简化了dup3这个程序。首先,它只读取命名的文件,而不去读标准输入,因为ReadFile函数需要一个文件名参数。其次,我们将行计数逻辑移回到了main函数,因为现在这个逻辑只有一个地方需要用到。

gopl.io/ch1/dup3
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), "\n") {
            counts[line]++
        }
    }
    for line, n := range counts {
        if n > 1 {
            fmt.Printf("%d\t%s\n", n, line)
        }
    }
}

ReadFile函数返回byte类型的slice,这个slice必须被转换为string,之后才能够用strings.Split方法来进行处理。我们在3.5.4节中会更详细地讲解string和byte slice(字节数组)。

在更底层一些的地方,bufio.Scanner,ioutil.ReadFile和ioutil.WriteFile使用的都是*os.File的Read和Write方法,不过一般程序员并不需要去直接了解到其底层实现细节,在bufio和io/ioutil包中提供的方法已经足够好用。

练习 1.4: 修改dup2,使其可以分别打印重复的行出现在哪些文件。