1.3. 查找重复的行

文件拷贝、文件打印、文件搜索、文件排序、文件统计类的程序一般都会有比较相似的程序结构:处理输入的一个循环,在每一个输入元素上执行计算处理,在处理的同时或者处理完成之后进行结果输出。我们会展示一个叫dup程序的三个版本;这个程序的灵感来自于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表达式后的逻辑体的花括号是不能省略的。如果需要的话,像其它语言一样,这个表达式也可以有else部分,这部分逻辑会在if中的条件结果为false时被执行。

map是go语言内置的key/value数据结构,这个数据结构能够提供常数时间的存储、获取、测试操作。key可以是任意数据类型,只要该类型能够用==来进行比较,string是最常用的key类型。而value类型的范围就更大了,基本上什么类型都是可以的。这个例子中的key都是string类型,value用的是int类型。我们用内置make函数来创建一个空的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的循环来打印结果,这次range是被用在map这个数据结果上。这一次的情况和上次比较类型,range会返回两个值,一个key和在map对应这个key的value。对map进行range循环时,其顺序是不确定的,从实践来看,很可能每次运行都会有不一样的结果(译注:这是go的设计者有意为之的,因为其底层实现不保证插入顺序和遍历顺序一致,而希望程序员不要依赖遍历时的顺序,所以干脆直接在遍历的时候做了随机化处理,醉了),来避免程序员在业务中依赖遍历时的顺序。

然后轮到我们例子中的bufio这个package了,这个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 code point),go语言里特有的Unicode字符类型
%s          string
%q          quoted string "abc" or rune 'c'
%v          会将任意变量以易读的形式打印出来
%T          打印变量的类型
%%          字符型百分比标志(不确定) literal percent sign (no operand)

dup1中的程序还包含了一个\t和\n的格式化字符串。在字符串中会以这些特殊的转义字符来表示不可见字符。Printf默认不会在输出内容后加上换行符。按照惯例,用来格式化的函数都会在末尾以f字母结尾,比如log.Printf,fmt.Errorf,同时还有一系列对应以ln结尾的函数,这些函数默认以%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,之后才能够用string.Split方法来进行处理。我们在3.5.4节中会更详细地讲解string和byte slice(字节数组)。

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

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