Prig: like AWK, but uses Go for "scripting"

Ben Hoyt

Prig:像 AWK,但用 Go 來寫「腳本」

原文由 Ben Hoyt 發布,訂閱此部落格

摘要:本文介紹我打造的類 AWK 工具 Prig,它以 Go 作為腳本語言。文中會將 Prig 與 AWK 進行比較,深入探討 Prig 的運作原理,最後簡要介紹 Prig 內建的 SortSortMap(若使用 Go 1.18 以上版本,則會運用 Go 新的泛型功能)。

在最近的一則 Hacker News 留言中,我得知了 Charles Blake 打造的小型文字處理工具 rp,它有點像 AWK,只是改用 Nim 作為腳本語言。之所以可行,是因為 Nim 編譯器速度很快,而且 Nim 語言本身非常精簡,因此很適合用來寫一次性的腳本。

Go 具備上述兩個優點中的其中一項:建置速度很快。它不算是一種精簡的語言,因此不太適合寫一行指令就能搞定的腳本。但話說回來,也沒有糟到哪裡去:Prig 腳本的字元數大約是對應 AWK 版本的兩倍。

Charles 提到,像 Nim 和 Go 這樣編譯速度快的語言,非常適合用來打造這類工具。要實現它,程式碼幾乎簡單到不行:Prig 大約只有 200 行直白的 Go 程式碼,作用就是把使用者在命令列中輸入的「腳本」插入到 Go 原始碼範本中,編譯後再執行產生的執行檔。

在我的 Linux 電腦上,go build 編譯一個只使用標準函式庫的程式大約只需 200 毫秒,因此啟動時間非常合理——幾乎在你放開 Enter 鍵之前,Go 就已經編譯並執行完程式了。相較之下,Nim 讓 rp 在我系統上的啟動時間約為 1.4 秒(若使用 tcc 後端則為 0.8 秒)。

於是我決定用 Go 打造一個與 rp 對等的工具,最後完成了 Prig,顧名思義就是 Processing Records In Go(用 Go 處理記錄)。可以說 Prig 就像 AWK,只是比較挑剔——它對動態型別嗤之以鼻。

Prig 與 AWK 的比較

首先,如果你以前沒用過 AWK,這裡用一段話來介紹。AWK 是一個逐行處理輸入的語言直譯器。首先它會執行可選的 BEGIN 區塊。接著,它會對每一行輸入執行 pattern { action } 區塊:如果該行符合 pattern,就執行對應的 action。如果你沒有指定 pattern,則每一行都會符合;如果你沒有指定 action,預設動作就是印出該行。處理完所有輸入後,它會再執行可選的 END 區塊。我們很快就會看到一些範例。

那麼 Prig 看起來是什麼樣子,與 AWK 相比又如何呢?讓我們來看幾個範例腳本。假設你有一個包含 HTTP 請求行的記錄檔,內容如下:

$ cat logs.txt
GET /robots.txt HTTP/1.1
HEAD /README.md HTTP/1.1
GET /wp-admin/ HTTP/1.0

你想要取出第二個欄位(相對 URL),並為每個請求印出你網站的完整 URL。以下是用 Prig 的做法:

$ prig 'Println("https://example.com" + S(2))' <logs.txt
https://example.com/robots.txt
https://example.com/README.md
https://example.com/wp-admin/

Println 函式其實就是 Go 的 fmt.Println,只是為了效率改用帶緩衝的寫入器。它相當於 AWK 的 print 陳述式。S(i) 函式會以字串形式回傳第 i 個欄位,因此 S(2) 會回傳第二個欄位,就像 AWK 中的 $2 一樣。其餘的語意就只是一般的 Go 語意。

同樣的腳本若用 AWK 寫,則會像這樣:

$ awk '{ print "https://example.com" $2 }' <logs.txt
https://example.com/robots.txt
...

只少了 3 個字元——目前表現還不錯。

接下來情況就開始對 Go 比較不利了。下面是一個同時以 Prig 和 AWK 版本呈現的腳本,它會將最後一個欄位加總,再除以記錄總數,以印出平均值:

$ cat average.txt 
a b 400
c d 200
e f 200
g h 200

$ prig -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))' \
  <average.txt
250

$ awk '{ s += $NF } END { print s / NR }' <average.txt
250

這個腳本在 Prig 中是 60 個字元,在 AWK 中是 35 個字元——幾乎是兩倍長。Go(以及許多靜態型別語言)在這方面就處於劣勢。首先,我們必須把加總變數初始化為 0;在 AWK 中這是隱含完成的。

接著,相比於 AWK 較簡潔的 $NFF(NF()) 多了一層括號。我很早就做了一個設計決定,讓所有 Prig 內建功能都成為函式——一開始我曾把 NFNR 設計成變數,但全部改為函式後,程式碼就能依需求延遲分割欄位(有些簡單的腳本根本不需要)。

還有 float64() 的轉型,加上 NR()Println() 的括號,使得 Prig 在某些情況下看起來有點像 Lisp。AWK 的 print s / NR 看起來肯定順眼多了!

第三個範例會在輸入行包含字串 GETHEAD 時,將該行的第三個欄位乘以 1000(也就是以毫秒為單位)後印出。以下是 Prig 腳本與其對等的 AWK 版本:

$ cat millis.txt 
1 GET 3.14159
2 HEAD 4.0
3 GET 1.0

$ prig 'if Match(`GET|HEAD`, S(0)) { Printf("%.0fms\n", F(3)*1000) }' \
  <millis.txt
3142ms
4000ms
1000ms

$ awk '/GET|HEAD/ { printf "%.0fms\n", $3*1000 }' <millis.txt
3142ms
4000ms
1000ms

Prig 版本是 62 個字元,AWK 是 43 個字元——還算不差。這裡主要的差異在於 AWK 的 /regex/ 快捷寫法。我曾考慮在 Prig 中為此加入特例,但最終還是決定採用簡單、一致的 Go 風格,而不使用捷徑——因此在 Prig 中你必須明確地寫出 ifMatch

接下來是一個較長的範例。這個腳本會統計輸入中每個不重複單字的出現次數,然後依出現頻率由高到低印出單字及其次數。

$ cat words.txt 
The foo barfs
foo the the the

$ prig -b 'freqs := map[string]int{}' \
       'for i := 1; i <= NF(); i++ { freqs[strings.ToLower(S(i))]++ }' \
       -e 'for _, f := range SortMap(freqs, ByValue, Reverse) { ' \
       -e 'Println(f.K, f.V) }' \
       <words.txt 
the 4
foo 2
barfs 1

$ awk '{ for (i = 1; i <= NF; i++) freqs[tolower($i)]++ }
      END { for (k in freqs) print k, freqs[k] | "sort -nr -k2,1" }' \
      <words.txt

這算是相當冗長,特別是在 Prig 中。首先我們初始化一個以單字為鍵的次數對應表(在 AWK 中這同樣是隱含的)。逐行處理的程式碼非常相似,只是在 Go 中因為要加上 strings 套件前綴而稍微囉嗦一點。

兩者的排序方式則大不相同:在 Prig 中,我定義了兩個排序函式,Sort 會接收一個包含整數、浮點數或字串的切片並回傳一個新的已排序切片,而 SortMap 則會回傳一個已排序的鍵值對切片(可選擇依值排序,也可選擇反向排序)。

POSIX AWK 並沒有內建排序功能(只有 Gawk 有),因此我們使用 AWK 的管線重新導向語法,將結果送給 sort 工具處理。我們其實也可以在 Prig 中用 shell 管線達成同樣效果,但這裡是為了展示 SortMap 函式的用法。

就大多數範例而言,AWK 無疑更清晰、更精簡——這也正是 Aho、Weinberger 和 Kernighan 當初為 AWK 設計新語言,而不是直接以 C(或類似語言)為基礎的原因。

另一方面,如果你很熟悉 Go 卻不熟悉 AWK,Prig 或許會對你有幫助。它的速度也明顯更快,因為 Go 會編譯成最佳化的機器碼,而 AWK 則是直譯執行。

以下是一些簡要的效能數據:以上述「統計單字頻率」的範例來說,Prig 的速度大約是 AWK(使用 Gawk)的三倍:Prig 處理一個 43MB 的檔案花了 1.1 秒,Gawk 則花了 3.1 秒。當然,此時我們其實是在比較 Go 與 Gawk(詳見這份效能比較)。

對於像大量數字相加這種受 CPU 限制的工作,Go 當然快得多,在這個範例中大約快了 20 倍(別忘了,在這 274 毫秒中有 200 毫秒是花在編譯上):

$ time gawk 'BEGIN { for (i=0; i<100000000; i++) s+=i; print s }'
4999999950000000

real    0m5.698s
...
$ time ./prig -b 's:=0; for i:=0; i<100000000; i++ { s+=i }; Println(s)'
4999999950000000

real    0m0.274s
...

產生的 Go 程式

prig.go 本身的程式碼非常簡單:大約 200 行 Go 程式碼,其中約三分之一用來解析命令列參數。其餘部分只是把你的腳本放入 Go 原始碼範本中,執行 go build 進行編譯,然後執行產生的結果。

產生的 Go 程式的基本結構就如你所預期的:一些初始化程式碼、「begin」程式碼、一個使用 bufio.Scanner 逐行處理並執行「逐筆記錄」程式碼的迴圈,然後是「end」程式碼。另外還有 Prig 的內建函式。

你可以使用 prig -s 來檢視產生的 Go 原始碼。以下是上述「最後一個欄位的平均值」範例。並非完全逐字呈現;為了簡潔,我省略了未使用的部分:

$ prig -s -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))'
// ... package and import ...
var (
    _output *bufio.Writer
    _record string
    _nr     int
    _fields []string
)

func main() {
    _output = bufio.NewWriter(os.Stdout)
    defer _output.Flush()

    // begin
    s := 0.0

    _scanner := bufio.NewScanner(os.Stdin)
    for _scanner.Scan() {
        _record = _scanner.Text()
        _nr++
        _fields = nil

        // per-record
        s += F(NF())
    }
    if _scanner.Err() != nil {
        _errorf("error reading stdin: %v", _scanner.Err())
    }

    // end
    Println(s / float64(NR()))
}

func Println(args ...interface{}) {
    _, err := fmt.Fprintln(_output, args...)
    if err != nil {
        _errorf("error writing output: %v", err)
    }
}

func NR() int {
    return _nr
}

func S(i int) string {
    if i == 0 {
        return _record
    }
    _ensureFields()
    if i < 1 || i > len(_fields) {
        return ""
    }
    return _fields[i-1]
}

func F(i int) float64 {
    s := S(i)
    f, _ := strconv.ParseFloat(s, 64)
    return f
}

func _ensureFields() {
    if _fields != nil {
        return
    }
    _fields = strings.Fields(_record)
}

func NF() int {
    _ensureFields()
    return len(_fields)
}
// ... other Prig builtin functions ...

請注意我如何將 Prig 內部名稱加上底線前綴,以避免與使用者定義的變數發生名稱衝突。雖然稱不上萬無一失,但對這個使用情境來說已經夠用了。

主迴圈基本上就是你手動用 Go 會寫出的樣子(雖然你可能會用區域變數而非全域變數)。不過,在典型的 Go 中,你可能會在主迴圈內將 F(NF()) 的邏輯直接展開並加上邊界檢查,像這樣:

if len(fields) > 0 {
    last := fields[len(fields)-1]
    f, err := strconv.ParseFloat(last, 64)
    if err == nil {
        s += f
    }
}

在這種情境下,讓 Prig 的 F() 幫你處理邊界檢查就很方便:s += F(NF()) 比那段長達 7 行的冗長程式碼簡單多了。Go 本身比較囉嗦,但加上幾個擺放得宜的輔助函式,就能變得非常精簡!

測試的樂趣

Prig 的測試(位於 prig_test.go)有點非典型,因為它們直接執行 prig 執行檔。有些開發者可能會對此不以為然,但這樣做讓 Prig 保持得更簡單。主要測試採用了「表格驅動測試」,這是 Go 測試的常用手法,你可以在其他地方讀到相關介紹

由於每次測試都要經過 go build 的週期,每個測試都相對較慢(約 200 毫秒),但整個測試套件在我的系統上仍可在 7 到 8 秒內跑完。在 Windows 上則會慢很多,因為啟動新處理程序的成本要高得多。

不過,我做的一件還不錯的事是測試 prig --help 中顯示的範例。在撰寫 Prig 的使用說明訊息時,我在範例中不斷出現小小的打字錯誤,得一直複製貼上到終端機手動測試。

到某個時候我想,何不直接用 go test 自動測試這些範例呢?於是我把命令列範例抽取成獨立的字串,並在 TestExamples 中進行測試。我用一個臨時寫的小型解析器將每個範例命令列轉換為參數列表,然後對其呼叫 prig

這有點類似 Go 優秀的可測試範例,只是對象從 Go 程式碼範例換成了命令列範例。

泛型的實驗

Prig 設計上較困難的部分之一是排序輔助函式,而且我到現在還不太確定自己是否設計對了。API 設計是一個讓程式設計看起來更像藝術而非科學的領域。

無論如何,我最後完成了兩個對於你可能會在 Prig 中使用的資料型別很有用的函式。以下是相當精簡的使用說明所寫的內容:

Sort[T int|float64|string](s []T) []T
  // return new sorted slice; also Sort(s, Reverse) to sort descending
SortMap[T int|float64|string](m map[string]T) []KV[T]
  // return sorted slice of key-value pairs
  // also Sort(s[, Reverse][, ByValue]) to sort descending or by value

在 Go 1.18(應該很快就會發布)中,這些函式運用了新的泛型功能,因此會進行型別檢查並回傳具體的切片型別。由於有可選參數,實際的 Go 函式簽章(以及 KV 型別)定義如下:

type _sortOption int

const (
    Reverse _sortOption = iota
    ByValue
)

func Sort[T int|float64|string](s []T, options ..._sortOption) []T {
    // ... implementation ...
}

type KV[T int|float64|string] struct {
    K string
    V T
}

func SortMap[T int|float64|string](m map[string]T,
        options ..._sortOption) []KV[T] {
    // ... implementation ...
}

Sort 相當單純:它接收一個切片並回傳一個新的已排序切片。預設是由小到大排序,若傳入 Reverse 選項則由大到小排序。我原本可以使用比 intfloat64string 更廣泛的型別集合,但這樣能讓 Prig 保持簡單(對於下面會看到的非泛型版本也是如此)。

SortMap 的 API 設計就稍微棘手一些。你無法直接對 Go 的 map 進行排序,因此需要將它轉換為鍵值對的切片:這就是 KV 型別。你可以依鍵排序(預設),若傳入 ByValue 選項則依值排序。

這一切運作得還不錯,而我那非常有限的 Go 1.18 泛型使用經驗也算是成功了。

但對於我們大多數仍在使用 1.18 之前、不支援泛型的 Go 版本的人來說呢?嗯,我讓同樣的 API 在沒有泛型的情況下也能運作……算是吧。非泛型版本使用 interface{},當然就沒有型別安全可言。而它之所以能在不需要型別轉換的情況下運作,僅僅是因為你通常只是把結果印出來;Print 系列函式本來就透過 interface{} 接受任何型別的參數。

因此,單字統計的範例程式碼在 Go 1.18(有泛型)和 Go 1.17(沒有泛型)上都能同樣正常運作:

for _, f := range SortMap(freqs, ByValue, Reverse) {
    Println(f.K, f.V)
}

Prig 會透過執行 go version 來偵測你安裝的 Go 版本,若是 1.17 或更早版本,就會使用非泛型版本。以下是非泛型版本的 SortSortMap 的定義方式:

func Sort(s interface{}, options ..._sortOption) []interface{} {
    // ... implementation ...
}

type KV struct {
    K string
    V interface{}
}

func SortMap(m interface{}, options ..._sortOption) []KV {
    // ... implementation ...
}

很瘋狂嗎?大概是吧。大多數函式庫絕不可能用這種偷天換日的手法矇混過關,因為這些 API 對許多任務來說根本不相容。但對於 Prig 中的一項實驗而言,這樣做似乎效果還不錯。

結論:值得嗎?

我骨子裡就是個不折不扣的宅客,所以答案是肯定的,我很享受打造 Prig 的過程(大部分是在從基督城飛往法蘭克福的班機上完成的)。我喜歡它的程式碼如此簡單:大約 200 行 Go 程式碼、300 行範本程式碼……以及 400 行測試。所有繁重的工作都交給 Go 及其標準函式庫了!

我真的會用 Prig 嗎?有可能,如果我在處理大型檔案且需要比 AWK 更好的效能時。我也可能會用它來測試 Go 的小片段程式碼——例如,「Printf 的寬度又是怎麼運作的?啊,對了,用 prig 來試試看」:

$ prig -b 'Printf("%3.5s\n", "hi")'
 hi
$ prig -b 'Printf("%3.5s\n", "hello world")'
hello

你該用 Prig 嗎?我不會阻止你!但老實說,你大概還是學無所不在(而且精簡得多)的 AWK 語言會更好。這是一個出色、已有 45 年歷史的工具,到 2022 年仍被廣泛用於文字與資料處理。由 A、W、K 三位原作者合著的 The AWK Programming Language 這本書非常值得一讀。

如果你需要一個用於資料處理的執行檔,例如在沒有安裝 awk 的輕量容器中,你也可能會用到它。對於這類情況,你可以使用 prig -s 印出原始碼,再用 go build 編譯結果,並將執行檔複製到目標環境——不需要其他相依套件。

如果你想將 AWK 整合到你的 Go 程式中,或只是想了解 AWK 直譯器是如何運作的,可以看看我的 GoAWK 專案。

我很樂意聽到你對 Prig 的回饋:如果你有任何改進的想法,或是用其他語言打造了 rp 或 Prig 的變體,歡迎來打聲招呼!

本文章由 muse-spark-1.2-contributor 進行翻譯

留言