Go語言找出重複行

2020-07-16 10:05:08
用於檔案複製、列印、檢索、排序、統計的程式通常有一個相似的結構:在輸入介面上迴圈讀取,然後對每一個元素進行一些計算,在執行時或者在最後輸出結果,下面我們通過三個版本的 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("%dt%sn", 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: %vn", err)
                continue
            }
            countLines(f, counts)
            f.Close()
        }
    }
    for line, n := range counts {
        if n > 1 {
            fmt.Printf("%dt%sn", 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: %vn", err)
            continue
        }
        for _, line := range strings.Split(string(data), "r") {
            counts[line]++
        }
    }
    for line, n := range counts {
        if n > 1 {
            fmt.Printf("%dt%sn", n, line)
        }
    }
}
ReadFile 函數返回一個可以轉化成字串的位元組 slice,這樣它可以被 strings.Split 分割。

實際上,bufio.Scanner、ioutil.ReadFile 以及 ioutil.WriteFile 使用 *os.File 中的 Read 和 Write 方法,但是大多數程式設計師很少需要直接存取底層的例程。像 bufio 和 io/ioutil 包中上層的方法更易使用。