AWKGo,一個 AWK 轉 Go 的編譯器
我宅到把自己都給 nerd-sniped 了。
身為用 Go 寫成的 AWK 直譯器 GoAWK 的作者,有一天我突發奇想,把 AWK 程式翻譯成 Go 程式碼會有多難。應該不會太難吧?於是我決定試試看。
結果也沒有特別難——至少對我的編譯器所支援的那個 AWK 子集來說不難——而且我還能重用 GoAWK 的解析器和許多測試。為了展現我那充滿創意的命名能力,我把這個編譯器叫做 AWKGo。
接下來的文章會說明 AWKGo 能做什麼、它如何運作、我在撰寫與測試過程中學到的幾個有趣收穫,並展示一些輸出範例。我也會簡單看一下產生出來的程式碼效能如何。
你可以在 GitHub 上查看 AWKGo 原始碼。
子集
AWKGo 支援一個實用的 AWK 子集,但在幾個地方偏離了 AWK 的語意。大多數小型的 AWK 腳本應該都能正常運作,不過有些會需要做點小修改。
以下是支援的功能:
BEGIN、END以及 pattern-action 區塊,包含範圍模式。- 控制結構:
if、else、for、while、break等等。 - 輸入資料的自動欄位分割(
$1、$2等)。 - 使用
print與printf輸出——透過 Go 的bufio套件進行緩衝,速度很快。 - 純量與陣列變數,包含使用與賦值,以及像
x+=10這類複合賦值和x++這類遞增/遞減運算。 - 常用的特殊變數,例如
FS、NF和OFS。 - 如同 AWK 的自動型別轉換:如果在字串情境中使用數值,會自動轉為字串,反之亦然。
- 常見的一元與二元運算子,包含
~(正規表示式匹配)。還有三元條件運算子,例如n!=1?"s":""——只要兩個分支回傳相同型別就能運作。 - 大多數內建函式:數學函式、
split、sprintf、sub、substr等等。
而以下是不支援的部分:
- 動態型別:AWK 有自己的一套動態型別,而 Go 是靜態型別。因此在 AWKGo 中,如果你把變數設為字串,它就必須維持字串;如果設為數值,就必須維持數值。
- 數值字串:與上述相關,當 AWK 從使用者輸入讀到一個看起來像數值的內容時,會被視為「數值字串」,可同時當作字串或數值使用。AWKGo 會盡量做對的事,但一旦它判定某個東西是字串(或數值),就必須維持該型別。
- 空值:在 AWK 中,未設定的變數是「null」,輸出時會顯示為空字串。相對地,AWKGo 對未設定的數值變數會輸出
0。 - 使用者自訂函式:以 AWK 的標準來說,這通常只會在大型腳本中使用,而且在沒有動態型別的情況下實作起來有點麻煩。
- 非字面量的
printf格式字串:你可以寫像printf("%s %d", k, v)這樣的形式,但不能寫printf(fmt, k, v)。後者在簡單腳本中很少見。 - 輸出重新導向:AWKGo 不支援像
print "foo" >"out.txt"這樣的形式,也不支援getline。 - 不存在的陣列元素:在 POSIX 中,參照不存在的陣列元素應該會建立該元素。我覺得這是個很糟的功能,而且無論如何,我想遵循 Go 的
map語意。 - 某些形式的複合賦值,像是
x = y+=2。並非不能支援這些較少見的語法,只是我還沒做到。 - 某些特殊變數,例如
ARGC和FILENAME。另外,像NF這類特殊變數在 AWKGo 中是唯讀的。
範例輸出
AWKGo 的輸出長什麼樣子?就像許多「轉譯器」一樣,輸出既不漂亮也不道地。有些結構轉譯得不錯,有些就沒那麼好。我們來看三個範例,從簡單到稍微複雜一點。
我不會列出開頭的 import 宣告,也不會列出結尾包含的執行時期輔助函式,因為這些每次都一樣。
我也會省略一些沒什麼特別的共通設定程式碼。你可以在我提供的完整程式碼連結中看到它們,但在這裡我只會擷取有趣的部分——也就是編譯器輸出的核心。
第一個範例是個簡單的程式,你可能會用它來掃描網頁伺服器的記錄檔,找出對「about」頁面的請求並印出第 4 個欄位:
/about/ { print $4 }它會被編譯成:
func main() {
_output = bufio.NewWriter(os.Stdout)
defer _output.Flush()
_scanner = bufio.NewScanner(os.Stdin)
// A few lines of setup code elided...
for _scanner.Scan() {
_lineNum++
_line = _scanner.Text()
_fields = _splitHelper(_line, FS)
// The is the heart of the translated code
if _re1.MatchString(_line) {
fmt.Fprintln(_output, _getField(4))
}
}
if _scanner.Err() != nil {
fmt.Fprintln(os.Stderr, _scanner.Err())
os.Exit(1)
}
}
var (
_re1 = regexp.MustCompile("about")
)你可以在 GitHub 上的完整輸出中看到 _splitHelper 和 _getField 這類輔助函式是如何定義的,不過在這裡它們基本上就相當於 strings.Fields(_line) 和 _fields[3]。
注意我們是如何在最外層使用 Go 的 regexp.MustCompile 函式來預先編譯正規表示式字面量(_re1)。
如你所見,這個範例的輸出還算易讀,跟你手寫的 Go 程式碼不會差太多。因為在 AWK 中 I/O 處理(以及大多數變數)都是全域狀態,為了簡單起見,我在 Go 中也將它們定義為全域變數。
第二個範例比較複雜,但仍是相當貼近現實的例子:統計文字檔中不重複單字的出現次數,然後印出單字及其次數:
{
for (i = 1; i <= NF; i++)
counts[tolower($i)]++
}
END {
for (k in counts)
print k, counts[k]
}它會被編譯成以下內容(GitHub 上的完整輸出):
func main() {
// Common setup code elided...
counts = make(map[string]float64)
for _scanner.Scan() {
_lineNum++
_line = _scanner.Text()
_fields = _splitHelper(_line, FS)
// The action (first for loop)
for i = 1.0; i <= float64(len(_fields)); i++ {
counts[strings.ToLower(_getField(int(i)))]++
}
}
// Error handling elided...
// The END for loop
for k = range counts {
fmt.Fprintln(_output, k, _formatNum(counts[k]))
}
}AWKGo 只認得兩種型別:字串和數值,所以它不會偵測到第一個 for 迴圈其實可以用整數就好——所有數值都被定義為 float64。這是用簡單的方式來對應 AWK 的語意,但顯然如果你直接用 Go 寫,會用 int。
注意 AWK 的關聯陣列操作是如何轉成相當道地的 Go map 用法。AWKGo 會偵測到你正在建立一個字串對數值的 map。另外,tolower 會直接對應到 strings.ToLower。
第三個範例,我們來編譯一個稍微 tricky 一點的東西:
$1+0 == $2 { x += ++n }
END { print x }它會被編譯成以下內容(GitHub 上的完整輸出):
func main() {
// Common setup code elided...
for _scanner.Scan() {
_lineNum++
_line = _scanner.Text()
_fields = _splitHelper(_line, FS)
if _numToStr(_strToNum(_getField(1))+0.0) == _getField(2) {
x += func() float64 { n++; return n }()
}
}
// Error handling elided...
fmt.Fprintln(_output, _formatNum(x))
}這大概不是個特別有用的程式,但它展示了 AWKGo 幾個有趣的地方,我們下面會再深入討論:有時候我們需要用像 +0 這樣的寫法來推一把 AWKGo 的型別推斷,強制把某個運算式轉為數值(或字串)。輸出也展示了我們如何轉譯像遞增這類在 AWK 中是運算式、在 Go 中卻是陳述式的東西。更多細節在後面說明。
型別
AWK 曾被稱為「stringly typed」。不過這說法有點貶義,實際上它幾乎是靜態型別的:除了像「數值字串」這類少數邊界情況,大多數運算式的型別在編譯時期就能判斷出來——而且完全不需要明確的型別宣告。
我其實認為,只要對語言做幾個小調整,AWK 的型別就能在編譯時期完全確定下來,語言本身搞不好也會因此變得更好。「數值字串」就是學 AWK 時最讓人搞不清楚的難點之一。
無論如何,AWKGo 的工作就是嘗試把動態型別的 AWK 轉成靜態型別的 Go。為此,有一個「typer」會執行一次遍歷,來決定 AWK 程式碼中每個運算式和變數的型別。
如果是數值字面量或數學運算,我們就知道它是數值。如果是字串字面量或字串運算,我們就知道它是字串。一旦 typer 知道了賦值右側的型別,就會把左側的變數也標記為該型別。
以下是 typer 中的一段程式碼,展示實際運作方式(來自判斷運算式型別的函式):
func (t *typer) expr(expr Expr) (typ valueType) {
switch e := expr.(type) {
case *FieldExpr:
t.expr(e.Index)
return typeStr
case *UnaryExpr:
t.expr(e.Value)
return typeNum // all unary operators yield num
case *BinaryExpr:
t.expr(e.Left)
t.expr(e.Right)
if e.Op == CONCAT {
return typeStr
}
return typeNum // all binary operators except CONCAT yield str
case *InExpr:
for _, index := range e.Index {
t.expr(index)
}
t.expr(e.Array)
return typeNum
case *NumExpr:
return typeNum // number literal
case *StrExpr:
return typeStr // string literal
...
}Typer 會對語法樹走訪兩次,以確保我們能偵測到在首次使用之後才被賦值的變數型別,例如在 while (i<5) i++ 中,i++ 是賦值,而 i<5 是使用。
如前面所提,有時候在使用 AWKGo 時,你需要用像 n+0(加零)這類無作用的運算式來強制把某個運算式轉為數值,或用 s ""(串接空字串)來強制轉為字串。如果你要比較像上面 $1 == $2 這種「數值字串」時,就需要這麼做,因為 AWKGo 不知道該把它們當字串還是數值來比較。所以你必須告訴它:要嘛寫 $1+0 == $2 來當數值比較,要嘛寫 $1 "" == $2 來當字串比較。對許多 AWK 腳本而言,你比較的對象本身就是已知型別的運算式,所以不需要用到這個技巧。
另一種需要明確轉換的情況,是當你直接把欄位指派給變數而沒有經過任何運算時。這種情況下,AWKGo 會把像 $1 這類欄位當作字串,所以如果你寫 n = $1; n++,它會告訴你 variable "n" already set to str, can't set to num。你得改寫成 n = $1+0; n++ 來強迫它判斷為數值。
編譯器
AWKGo 的編譯器是一個簡單的「tree walker」(樹狀走訪器),它會再次走訪語法樹,並把 Go 程式碼印到輸出。這很繁瑣,但一點也不花俏。以下是一小段範例,展示編譯運算式的函式的一部分:
func (c *compiler) expr(expr Expr) string {
switch e := expr.(type) {
case *NumExpr:
if e.Value == float64(int(e.Value)) {
return fmt.Sprintf("%d.0", int(e.Value))
}
if math.IsInf(e.Value, 0) {
panic(errorf("number literal out of range"))
}
return fmt.Sprintf("%g", e.Value)
case *StrExpr:
return strconv.Quote(e.Value)
case *FieldExpr:
return "_getField(" + c.intExpr(e.Index) + ")"
case *VarExpr:
switch e.Scope {
case ScopeSpecial:
return c.special(e.Name, e.Index)
case ScopeGlobal:
return e.Name
default:
panic(errorf("unexpected scope %v", e.Scope))
}
case *RegExpr:
return fmt.Sprintf("_boolToNum(%s.MatchString(_line))", c.regexLiteral(e.Regex))
case *BinaryExpr:
return c.binaryExpr(e.Op, e.Left, e.Right)
case *IncrExpr:
exprStr := c.expr(e.Expr) // will be an lvalue (VarExpr, IndexExpr, FieldExpr)
if e.Pre {
// Change ++x expression to:
// func() float64 { x++; return x }()
return fmt.Sprintf("func() float64 { %s%s; return %s }()",
exprStr, e.Op, exprStr)
} else {
// Change x++ expression to:
// func() float64 { _t := x; x++; return _t }()
return fmt.Sprintf("func() float64 { _t := %s; %s%s; return _t }()",
exprStr, exprStr, e.Op)
}
...
}還有不少程式碼,包含對 pattern-action、控制結構、賦值、像 tolower() 這類內建函式等的處理,不一而足。
一個有趣的問題是如何處理像 x++(如上所示)這類在 AWK 中是運算式、可以當作值來使用,但在 Go 中卻是頂層陳述式的結構。為了解決這個問題,我們使用了上面展示的技巧,把 x++ 轉成對匿名函式的立即呼叫——這個函式讓我們可以回傳值,同時也允許在函式本體中使用 Go 的陳述式。
舉例來說,y = x++ 編譯後的版本(拆成多行)會是:
y = func() float64 {
_t := x // temp variable to store current x
x++
return _t
}()不過,當遞增或賦值運算式被當作陳述式使用時,編譯器會使用一種「最佳化」。在這些情況下,因為運算式的值並沒有被使用(我們只關心它的副作用),我們可以將其簡化為一般的 Go 賦值或遞增陳述式。
例如,{ x++; print x } 會被編譯成直截了當的 Go:
x++
fmt.Fprintln(_output, _formatNum(x))我在編譯器中取的一個捷徑,就是不管空白和多餘的括號。Go 編譯器不在乎少了空白或多了括號,而在上面的範例中,我只是把輸出拿去跑 gofmt -r '(x) -> x',它會格式化程式碼並使用重寫規則來移除不必要的括號。
所以我完全不需要嘗試把 AWK 的運算子優先順序轉換成 Go 的。我只是在每個二元或一元運算周圍都加上括號,然後讓 gofmt 去整理。例如,AWK 程式 BEGIN { print 1+2*3 } 會被編譯成:
func main() {
// Common setup code elided...
fmt.Fprintln(_output, _formatNum((1.0 + (2.0 * 3.0))))
}但經過上述 gofmt 指令處理後,我們會得到:
func main() {
// Common setup code elided...
fmt.Fprintln(_output, _formatNum(1.0+2.0*3.0))
}輔助函式
還需要一個小型的 AWK「執行時期」來處理像是取得與設定欄位(例如 $1)、字串與數值之間的轉換,以及實作像 match、substr 和 sub 這類內建函式。
這些每次都一樣,所以我只是把它們當作包含 Go 原始碼的多行字串放在 helpers.go 中。每個名稱都以底線作為前綴,以避免名稱衝突(我知道這不是萬無一失,但已經夠用了)。
例如,下面顯示了用來取得與設定欄位(分別是 $i 和 $i = s)的輔助函式。在手寫的 Go 程式中,我大概會避免對 _line 和 _fields 這類東西使用全域變數,但在 AWK 中,那些狀態本來就是全域的,所以這樣翻譯是合理的。
func _getField(i int) string {
if i < 0 || i > len(_fields) {
return ""
}
if i == 0 {
return _line
}
return _fields[i-1]
}
func _setField(i int, s string) {
if i == 0 {
_line = s
_fields = _splitHelper(_line, FS)
return
}
for j := len(_fields); j < i; j++ {
_fields = append(_fields, "")
}
_fields[i-1] = s
_line = strings.Join(_fields, OFS)
}
func _splitHelper(s, fs string) []string {
var parts []string
if fs == " " {
parts = strings.Fields(s)
} else if s == "" {
// NF should be 0 on empty line
} else if utf8.RuneCountInString(fs) <= 1 {
parts = strings.Split(s, fs)
} else {
parts = _reCompile(fs).Split(s, -1)
}
return parts
}測試
我本來就有一整批來自 GoAWK 的測試,用來確保直譯器的各個面向都正常運作。我把它們複製到 awkgo 目錄中,並稍作修改,讓它們透過 AWKGo 執行並執行產生的結果。
和原始測試一樣,AWKGo 的測試是以表格驅動(table-driven)的方式撰寫,由單一的 TestAWKGo 函式來驅動。它會解析並編譯 AWK 原始碼,將 Go 程式碼輸出到暫存檔。接著使用 go run 來執行,並將輸出與預期結果進行比對。
我還寫了一個小腳本 awkgo/run_tests.sh,它會執行測試,從輸出中去除像執行時間這類每次執行都會變動的資訊,並將輸出寫入 awkgo/tests.txt。
一開始只有少數測試能通過。但隨著每實作一項功能或修掉一個錯誤,慢慢地,通過的數量開始超過失敗的數量。當你修掉一個影響多項測試的問題,看到 tests.txt 的 diff 中大量失敗消失時,真的很有成就感。
當我完成想實作的功能後,我就畫了一條線,把剩下的測試註解掉,讓 go test 可以在沒有失敗的情況下執行。
效能
AWKGo 程式的效能,與用 AWK 直譯器執行同一個程式,或是手寫的 Go 程式相比如何?
嗯,這要看程式。那些在緊密迴圈中做數學運算的程式會快上許多。我們來用下面這個 AWK 單行指令,它會把 0 到 100,000,000 的整數加總:
BEGIN { for (i=0; i<100000000; i++) s += i; print s }我們來比較 AWKGo 版本與 GoAWK、Gawk、mawk 以及最初的 Kernighan awk 的執行時間。我列出的是每個版本三次執行中的最佳成績,並依從最慢到最快排序:
| 版本 | 時間(秒) |
| goawk | 6.59 |
| awk | 6.27 |
| gawk | 5.73 |
| mawk | 2.88 |
| awkgo | 0.33 |
前三個直譯器的速度都差不多,其中 Gawk 稍微領先。mawk 作為直譯器來說非常快!但當然,編譯後的 Go 程式碼又比它快了約 9 倍。
作為對照,手寫的 Go 版本——改用區域變數而非全域變數,並用 int 而非 float64——執行時間為 0.098 秒,又再快了 3 倍多。
會執行 I/O 的程式(公平地說,這通常才是你使用 AWK 的目的)就只快了一點點。上面展示過的「統計單字頻率」程式,在把《欽定版聖經》重複串接 10 倍的檔案上執行(並將輸出導向 /dev/null),結果如下:
| 版本 | 時間(秒) |
| awk | 4.56 |
| gawk | 3.55 |
| goawk | 3.16 |
| mawk | 1.23 |
| awkgo | 0.98 |
Mawk 再次大幅領先其他直譯器,幾乎與編譯後的 AWKGo 版本不相上下。我知道它使用了 bytecode 編譯器,不過 Gawk 也是……一篇有趣的文章題目會是:「Mawk 是如何達到這種速度的?」如果你寫了,記得把連結傳給我!
結論
值得嗎?對我來說,幾乎肯定值得。我喜歡語言和編譯器,做一個簡單的三階段編譯器是個不錯的經驗。我以前也寫過簡單的編譯器,但還沒寫過那種包含「型別推斷」階段、並編譯到靜態型別語言的。
有用嗎?倒也沒有。如果你想要效能,直接用 Mawk 就好!而如果你想要用比 AWK 更易維護的語言來寫文字處理腳本,你大概一開始就會直接用 Go 之類的語言來寫。這樣你幾乎肯定會得到更道地的 Go,而且效率大概也會更高。
不過,如果你手邊剛好有一些 AWK 腳本想轉成「真正的程式」,AWKGo 或許是個不錯的起點:你可以先把腳本編譯成 Go 來取得架構,然後再整理、維護整理後的版本。
無論如何,如果你覺得 AWKGo 有趣或有用,歡迎告訴我。期待你的回饋!
隨機一篇部落格
留言
登入後參與討論