AWKGo, an AWK-to-Go compiler

Ben Hoyt

AWKGo,一个 AWK 转 Go 编译器

原文由 Ben Hoyt 发布,订阅该博客

我已经 geek 到把自己都给了。

作为 GoAWK(一个用 Go 写的 AWK 解释器)的作者,有一天我突然好奇,把 AWK 程序翻译成 Go 代码会有多难。应该不难吧?于是我就动手试了试。

其实也不算特别难——至少对我的编译器所支持的 AWK 子集来说不难——而且我还复用了 GoAWK 的解析器和大量测试。为了展示我那“极具创意”的命名水平,我把这个编译器叫做 AWKGo。

本文的其余部分会介绍 AWKGo 能做什么、它是如何工作的、我在编写和测试过程中学到的几个有意思的点,并展示一些输出示例。最后还会简单看一下生成代码的性能。

你可以在 GitHub 上查看 AWKGo 的源码

支持的子集

AWKGo 支持一个实用的 AWK 子集,但在若干方面偏离了 AWK 的语义。大多数小型的 AWK 脚本应该都能直接运行,不过有些需要做些小改动。

支持的功能包括:

  • BEGINEND 以及模式-动作块,包括区间模式。
  • 控制结构:ifelseforwhilebreak 等。
  • 输入的自动字段切分($1$2 等)。
  • 使用 printprintf 输出——通过 Go 的 bufio 包实现,带缓冲、速度很快。
  • 标量和数组变量的读取与赋值,以及像 x+=10 这样的复合赋值和 x++ 这类自增/自减操作。
  • 常用特殊变量,如 FSNFOFS
  • 与 AWK 一致的自动类型转换:在字符串上下文中使用数字时会自动转为字符串,反之亦然。
  • 常见的一元和二元运算符,包括 ~(正则匹配),还有三元条件表达式,例如 n!=1?"s":""——只要两个分支返回相同类型就能正常工作。
  • 大多数内置函数:数学函数、splitsprintfsubsubstr 等。

而以下功能则暂支持:

  • 动态类型: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。并非不能支持这些不太常见的写法,只是我还没来得及做。
  • 一些特殊变量,如 ARGCFILENAME。还有一些特殊变量如 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)、字符串与数字之间的相互转换,以及实现 matchsubstrsub 等内置函数这类操作,需要一个小型的 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 的执行时间。下表展示了每种实现三次运行中的最佳成绩,按从慢到快排序:

版本耗时(秒)
goawk6.59
awk6.27
gawk5.73
mawk2.88
awkgo0.33

前三种解释器的表现相近,其中 Gawk 稍微领先一些。mawk 作为解释器已经非常快了!但编译后的 Go 代码当然更快,速度大约是它的 9 倍。

作为对比,手写的 Go 版本(使用局部变量而非全局变量,并用 int 而非 float64)在 0.098 秒内就能跑完,又快了 3 倍多。

而对于执行 I/O 的程序(公平地说,AWK 通常就是用来干这个的),提升就只有一点点了。上面展示的“统计词频”程序,在把《钦定版圣经》拼接 10 份的文件上运行(输出重定向到 /dev/null),结果如下:

版本耗时(秒)
awk4.56
gawk3.55
goawk3.16
mawk1.23
awkgo0.98

在解释器中,mawk 再次遥遥领先,几乎与编译后的 AWKGo 版本不相上下。我知道它用了字节码编译器,但话说回来,Gawk 也是……一篇有意思的文章题目或许是《Mawk 是如何做到这么快的?》——如果你写了,记得把链接发我!

结论

值得吗?对我来说,绝对值得。我喜欢语言和编译器,做一个简单的三趟编译器是一次不错的体验。我以前也写过简单的编译器,但还没写过带“类型推断”阶段、并编译到静态类型语言的。

有用吗?倒也不见得。如果你追求性能,直接用 Mawk 就好!如果你想用比 AWK 更易维护的语言来写文本处理脚本,那多半一开始就会直接用 Go 这类语言来写。这样写出来的 Go 肯定更地道,效率也可能更高。

不过,如果你手头有一些 AWK 脚本想转换成“真正的程序”,AWKGo 或许是个不错的起点:你可以先把脚本编译成 Go,拿到基本结构,再整理优化并以整理后的版本进行维护。

无论如何,如果你觉得 AWKGo 有意思或有帮助,欢迎告诉我。期待你的反馈!

本文章由 muse-spark-1.2-contributor 进行翻译

评论