AWKGo,一个 AWK 转 Go 编译器
我已经 geek 到把自己都给坑了。
作为 GoAWK(一个用 Go 写的 AWK 解释器)的作者,有一天我突然好奇,把 AWK 程序翻译成 Go 代码会有多难。应该不难吧?于是我就动手试了试。
其实也不算特别难——至少对我的编译器所支持的 AWK 子集来说不难——而且我还复用了 GoAWK 的解析器和大量测试。为了展示我那“极具创意”的命名水平,我把这个编译器叫做 AWKGo。
本文的其余部分会介绍 AWKGo 能做什么、它是如何工作的、我在编写和测试过程中学到的几个有意思的点,并展示一些输出示例。最后还会简单看一下生成代码的性能。
你可以在 GitHub 上查看 AWKGo 的源码。
支持的子集
AWKGo 支持一个实用的 AWK 子集,但在若干方面偏离了 AWK 的语义。大多数小型的 AWK 脚本应该都能直接运行,不过有些需要做些小改动。
支持的功能包括:
BEGIN、END以及模式-动作块,包括区间模式。- 控制结构:
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 声明和末尾的运行时辅助函数我就不贴了,因为每次都一样。
一些没什么看点的公共初始化代码我也会省略。你可以通过我给出的完整代码链接查看,这里只贴最核心的部分——编译器输出的关键内容。
第一个例子是个简单的程序,假设你要扫描 Web 服务器日志,找出对“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 能检测出你在构建一个从字符串到数字的映射。另外,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)”,会遍历一遍代码来确定每个表达式和变量的类型。
如果遇到数字字面量或数学运算,就知道它是数字;如果是字符串字面量或字符串操作,就知道它是字符串。一旦推断器知道了赋值语句右侧的类型,就会把左侧变量也标记为该类型。
下面是类型推断器中的一段代码,展示了实际的工作方式(来自判断表达式类型的函数):
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
...
}类型推断器会对语法树遍历两遍,以确保能捕捉到先使用后赋值的变量类型,例如在 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 的编译器是一个简单的“树遍历器”,它会再次遍历语法树并输出 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)
}
...
}还有不少代码,用于处理模式-动作、控制结构、赋值、像 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))
}辅助函数
对于获取和设置字段(例如 $1)、字符串与数字之间的相互转换,以及实现 match、substr、sub 等内置函数这类操作,需要一个小型的 AWK“运行时”。
这些代码每次都一样,所以我直接把它们作为包含 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 的测试也是表驱动的,由单个 TestAWKGo 函数来驱动。它会解析并编译 AWK 源码,把生成的 Go 代码输出到临时文件,然后用 go run 执行,并将输出与预期结果对比。
我还写了一个小脚本 awkgo/run_tests.sh,它会运行测试,去掉输出中每次运行都会变化的内容(如耗时),并把结果写入 awkgo/tests.txt。
刚开始时,只有少数测试能通过。但随着每个功能的实现或 bug 的修复,通过数慢慢超过了失败数。当你修复一个影响多项测试的问题,看到 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 版本不相上下。我知道它用了字节码编译器,但话说回来,Gawk 也是……一篇有意思的文章题目或许是《Mawk 是如何做到这么快的?》——如果你写了,记得把链接发我!
结论
值得吗?对我来说,绝对值得。我喜欢语言和编译器,做一个简单的三趟编译器是一次不错的体验。我以前也写过简单的编译器,但还没写过带“类型推断”阶段、并编译到静态类型语言的。
有用吗?倒也不见得。如果你追求性能,直接用 Mawk 就好!如果你想用比 AWK 更易维护的语言来写文本处理脚本,那多半一开始就会直接用 Go 这类语言来写。这样写出来的 Go 肯定更地道,效率也可能更高。
不过,如果你手头有一些 AWK 脚本想转换成“真正的程序”,AWKGo 或许是个不错的起点:你可以先把脚本编译成 Go,拿到基本结构,再整理优化并以整理后的版本进行维护。
无论如何,如果你觉得 AWKGo 有意思或有帮助,欢迎告诉我。期待你的反馈!
随机一篇博客
评论
登录后参与讨论