跳到主内容

AWK 小传

awk 是一种专事文本解析与处理的解释型编程语言,其解释器与其同名注 1。awk 原始版本发布于 1977 年,后于 1985 年发布第一个增强版本。在这一时期,awk 羽翼渐丰,随后成为 Unix 系统的一个标准(POSIX 标准)组件。目前 Linux 系统配备的 awk 皆为 gawk,隶属 GNU 项目,伊始于 1986 年。

注 1:awk 得名于它的三位开发者 Alfred V. Aho、Peter J. Weinberger 以及 Brian W. Kernighan 名字的首字母。

awk 的设计深受 Unix 系统中的文本检索工具 grep 与文本编辑工具 sed 的启发,其语法借鉴了 C 语言。

模式-动作

「模式-动作」逻辑是 awk 语言的精华所在。awk 认为自身所处理的文本文件里依序存储着一组记录。awk 默认将文件中的每一行视为一条记录注 2,通过模式检索特定记录,而后通过动作修改记录的内容。模式由逻辑表达式或正则表达式构成,而动作则由一组用于分析或处理文本的语句构成。在 awk 解释器看来,模式和动作形成的整体即程序,文件里的每条记录则为程序的输入数据,而动作所产生的结果则为程序的输出数据。

注 2:下文在讲述记录分割符变量 RS 时,再给出让 awk 将多行文本视为一条记录的方法。

例如,对于任意一份文本文件 foo.txt,若仅输出其第三行,可通过 awk 程序

NR == 3 { print $0 }

来实现,其中 NR == 3 为模式,而 print $0 为动作。NR$0 皆为 awk 解释器内部维护的变量,前者用于保存 awk 解释器目前所读入的记录的序号,后者用于保存记录的内容。

将上述 awk 程序以及 foo.txt 作为 awk 解释器的输入数据,即在终端中执行

$ awk 'NR == 3 { print $0 }' foo.txt

则 awk 解释器的输出结果即为 foo.txt 的第三行——awk 解释器默认将其视为第三条记录。为了防止 Shell 误解 awk 程序中的一些成分,必须将后者用单引号拘禁。

上述命令诠释了 awk 解释器、awk 程序以及所处理的文本文件这三者之间的联系。在 awk 的实际应用中,我们需要对所处理的文本文件中的记录有足够的了解,剩下的任务则是编写 awk 程序,即

模式 { 动作 }

awk 解释器会顺序读取所处理的文本文件里的每一条记录,并验证它是否与模式相匹配。凡是与模式相匹配的记录,便会受模式之后的动作的操控。

模式-动作可叠加起来作用于文件的每一条记录。例如,

awk 'NR == 3 { print $0 }; NR == 4 { print $0 }' foo.txt

可以输出 foo.txt 的第 3、4 行。

不妨将 awk 程序里的动作理解为电路,将模式理解为开关。记录与模式匹配时,相当于触动了开关,使得电路得以导通。多条模式-动作,相当于有多个候选的开关及电路。

模式可以为空,例如

$ awk '{ print $0 }' foo.txt

此时,awk 会将动作作用于每一条记录。类似地,动作也可以为空,例如

$ awk 'NR == 3' foo.txt

此时,awk 会输出与模式相匹配的每一条记录,这一动作正是 { print $0 }

模式-动作的逻辑本质上是所有编程语言中的条件分支结构的泛化。Erlang、Racket、OCaml、Haskell、Swift 等语言提供了令其爱好者们引以为傲的模式匹配的语法所反映的也是这一逻辑。现在,知道了早在 1977 年 awk 便已经极为自然地建立了这一逻辑,应当由衷而叹,太阳底下果然没什么新鲜事!

脚本

为了让 awk 解释器能够在读取文件之前以及所有记录处理殆尽之后也能够有所动作,awk 语言提供了 BEGINEND 这两个特殊的模式。因此较为完整的 awk 程序,其结构通常为

BEGIN { 读取文件之前的动作 }
模式 1 { 动作 1 }
模式 2 { 动作 2 }
... ... ...
模式 n { 动作 n }
END { 所有记录处理殆尽之后的动作 }

如此便难以在终端里以命令参数的形式将程序交于 awk 解释器。再者,对于复杂的 awk 程序,若欲重复使用,命令参数的形式也多极为不便。为此,awk 解释器支持以脚本的形式载入程序,亦即可将 awk 程序保存为一份文件,而后让 awk 读取该文件以获得程序。此种文件即 awk 脚本。

awk 解释器可通过 -f 选项载入脚本中的程序。例如,制作一份简单的 awk 脚本 hello-world.awk,

$ cat << 'EOF' > hello-world.awk
BEGIN {
        print "Hello"
}

NR == 5 {
        print $0
}

NR == 6 {
        print $0
}

END {
        print "world!"
}
EOF

然后随意建立一份文本文件 foo.txt,

$ cat << EOF > foo.txt
以指喻指之非指
不若以非指喻指之非指也
以马喻马之非马
不若以非马喻马之非马也
天地一指也
万物一马也
EOF

若执行

$ awk -f hello-world.awk foo.txt

则结果为

Hello
天地一指也
万物一马也
world!

脚本 hello-world.awk 的内容可简化为

BEGIN {
        print "Hello"
}

NR == 5 || NR == 6 {
        print $0
}

END {
        print "world!"
}

还可进一步简化为

BEGIN {
        print "Hello"
}

NR == 5 || NR == 6

END {
        print "world!"
}

|| 是逻辑运算「或」。awk 中基本的逻辑运算符号皆与 C语言同。

在 awk 语言中,; 与换行等价,因此上述内容亦可写为

BEGIN { print "Hello" }; NR == 5 || NR == 6; END { print "world!"}

流程控制语句

awk 提供了 if ... else if ... else 条件分支以及 forwhiledo ... while 等循环结构的语法,用法几近于 C 语言,无需赘述。

正则表达式

若使用正则表达式作为模式,则记录与模式的匹配所表示的逻辑是前者包含着可与后者匹配的文本。

例如,若只输出上一节的 foo.txt 文件中含有「马」的记录,可以用 作为模式,

$ awk '/马/' foo.txt
以马喻马之非马
不若以非马喻马之非马也
万物一马也

awk 解释器输出的是 foo.txt 文件中含有可与正则表达式 匹配的文本的记录。在 awk 语言中,正则表达式的两侧以 / 为界。

下面给出几个略微复杂一点的正则表达式作为模式的例子。只输出 foo.txt 中以 不若 作为开头的记录,

$ awk '/^不若/' foo.txt
不若以非指喻指之非指也
不若以非马喻马之非马也

只输出 foo.txt 中以 作为结尾的记录,

$ awk '/也$/' foo.txt
不若以非指喻指之非指也
不若以非马喻马之非马也
天地一指也
万物一马也

只输出 foo.txt 中含有至少两个 字的记录,

$ awk '/马.*马/' foo.txt
以马喻马之非马
不若以非马喻马之非马也

awk 的正则表达式取自 egrep。egrep 为 grep 的扩展版本,支持的正则表达式较 grep 更为丰富。因此若熟悉 awk 的正则表达式,则自会熟悉 grep/egrep 的用法,反之亦然。

对于正则表达式与记录的匹配,awk 提供了逻辑运算符 ~!~,前者类似 ==,表示匹配,而后者类似 !=,表示不匹配。因此

$ awk '/马.*马/' foo.txt

可写为

$ awk '$0 ~ /马.*马/' foo.txt

还可以写为

$ awk '{ if ($0 ~ /马.*马/) print $0 }' foo.txt

变量

awk 的变量可无需初始化便可使用。例如,

$ awk 'NR == 1 { print a; print a + 1 }' foo.txt

1

即输出了空行和内容为 1 的行。之所以会输出空行,是因为变量 a 未经初始化便在语句「print a」中使用,awk 解释器默认其值为空文本,即 "",从而导致 「print a」输出空行。但是在「print a + 1」中,awk 解释器发现 a 出现于算术表达式中,因此便将其值由空字串转化为数字 0,从而导致「print a + 1」输出内容为 1 的行。

事实上,awk 的变量只有两种类型,文本和数字。awk 解释器会根据变量是否出现于算术表达式之中而对其值为文本还是数字进行推断。

在 awk 程序中,所有的变量皆为全局变量,除非它以函数的参数形式出现。

函数

awk 的函数,其一般形式为

function 函数名(参数 1, 参数 2, ..., 参数 n) {
        函数体
}

函数的调用与 C 同,但函数名与参数列表的左括号之间不能存在空格。

在函数中,除了作为参数的变量,其他所有变量皆能为函数外部可见。例如,

$ cat << 'EOF' > func-test.awk
NR == 1 {
        t = mul(2, 3)
        print "t = " t "; z = " z "; x = " x
}

function mul(x, y) {
        z = x * y
        return z
}
EOF
$ awk -f func-test.awk foo.txt
t = 6; z = 6; x = 

在与模式 NR == 1 相应的动作里,虽未对变量 z 进行赋值,但程序的输出结果却表明其值为 6,这是因为函数 mul 中的变量 z 在函数的外部可见。不过,在动作里调用 mul 时,将 2 赋予函数的参数 x,但动作输出的 x,其值为空文本。因此,若在 awk 程序中对变量的作用域进行限定,唯一的办法是让变量以函数参数的形式出现。

awk 的变量颇类似 Bash,但 Bash 在函数内部可通过关键字 local 将变量的作用域限定在函数之内。不过,awk 的作者在函数的写法上给出了一个建议:可将作用域限定于函数内部的变量置于参数列表尾部,并通过一组空格,使之与函数的参数有所区分。例如,

function mul(x, y,    z) {
        z = x * y
        return z
}

awk 默认将变量作为全局变量的做法,使得编写一个略微复杂一些的程序的过程像是在编筐或织布,全局变量像是纬线,操作变量的语句则像是经线。

数组

awk 提供了数组类型。数组元素可以异构,但并非连续存储于一段内存空间。例如,

a[0] = "abc"; a[1] = 7; a[2] = "马";  a[33] = "三十三"

这个数组,虽然含有下标为 33 的元素,但是并非由 34 个元素构成,而是由 4 个元素构成,而且这 4 个元素在数组中的排列也未必是按照下标的顺序。awk 并未对数组元素的排列给出确切的定义,这主要依赖于 awk 解释器的具体实现。

数组元素在排列上的不确定,意味着 awk 的数组仅支持顺序访问,但不支持随机访问。访问数组中的每个值,可采用 for (下标变量 in 数组) { ... } 语法,例如,

for (i in a) { print a[i] }

若以

for (i = 0; i < n; i++) { print a[i] }

访问数组元素,前提是要保证数组元素的下标的确从 0n

实际上,awk 数组的下标并非数字,而是文本。例如,a[3]a["3"] 皆能访问下标为 3 的元素。Bash 的数组亦如此。

输入与输出

命令

$ awk '程序' 文本文件

$ awk -f 脚本 文本文件

是 awk 程序运行的一般方式。程序所需的外部数据可经由文本文件以记录的形式传入。

若不向 awk 解释器提供文本文件,那么 awk 解释器便会将标准输入(stdin)作为程序所需的外部数据的来源。这意味着可以通过管道向 awk 程序传递记录。例如,以下程序可去除文本 " 白马非马 " 首尾的空白,

$ echo "    白马非马    " | awk '{ match($0, / *([^ ]+) */, a); print a[1] }'

若利用 awk 的默认以空格作为列分隔符并且去除列内前导空白字符的特性,可将上述 awk 程序简化为

$ echo "    白马非马    " | awk '{ print $1 }'

awk 解释器对于每条记录,默认以空白字符作为分割符,将记录内容斩为多段,每一段称为域;awk 会将域的数量存于内置变量 NF。域的内容依次存于 awk 解释器的内置变量 $1$2、...、$n,并将各段内容的前导空白字符(空格或制表符)消除。$0 存储未分割的整条记录。

对记录进行分割,这一特性使得 awk 程序在处理类似矩阵形式的文本表现的简短精悍,经常能以简短的一行程序完成其他编程语言动辄需要数十行代码方能完成的任务。例如,假设文件 emp.txt 内容为

张三  4.00  0
李四  3.75  0
王五  4.00  10
郑六  5.00  20
赵七  5.50  22
孙八  4.25  18

记录了一组雇员的姓名、时薪以及工作时长。现在要制作一份薪水报表,即统计哪些人参与了工作,应发多少钱。若采用 awk 完成该任务,只需

$ awk '$3 > 0 { print $1 "\t" $2 * $3 }' emp.txt

结果可得

王五  40
郑六  100
赵七  121
孙八  76.5

若不仅给出每个人的薪水情况,还要给出总的支出金额,只需

$ awk '$3 > 0 { x = $2 * $3; s += x; print $1 "\t" x }; END {print "\n总额\t" s}' emp.txt
王五  40
郑六  100
赵七  121
孙八  76.5

总额  337.5

awk 解释器的内置变量 RSFS 分别用于设定记录分割符和域分割符,亦即通过设定此二者,能够让 awk 解释器以记录和域的形式理解输入的数据。显然,应当在 awk 解释器读取文件设置 RSFS,因此,它们的设定应当在 BEGIN 模式所对应的动作里进行,例如,

BEGIN { RS = FS = "" }

此时,RSFS 皆为空文本,但二者含义不同,前者表示一个或多个空行作为记录分割符,而后者则以空文本作为域分割符。

对于 Markdown 格式的中文文档,各个段落以一个或多个空行隔开,而各个汉字之间则以空文本隔开。若将 RSFS 设为空文本,那么便可以很容易写出一个统计文档中汉字频率的 awk 程序。例如,统计一份文档中出现最多的十个汉字,只需

BEGIN { RS = FS = "" }
{ 
        # 移除标点符号、数字、英文字母以及空白字符
        gsub(/[.,:;!?(){}'",。:;!?()《》“” ‘’a-zA-Z0-9  ]/, "")
        for (i = 1; i <= NF; i++) count[$i]++ 
}
END { for (i in count) print i, count[i] | "sort -rn -k 2 | head" }

我用这个程序统计的《庄子·逍遥游》中出现最多的十个汉字及出现次数为

之 70
而 55
也 51
不 42
其 35
者 26
为 25
无 24
大 24
有 22

上述的 awk 程序,在实现汉字出现次数的排序以及排序结果的部分输出时,借助了 awk 的 print 函数与 sort 和 head 命令的管道衔接。此举似乎有些胜之不武,但是也没什么不妥,反而显现了 awk 在文本输出方面与 Linux(或其他类 Unix 系统)系统命令行环境的亲和性。

awk 解释器通过 RSFS 理解作为输入的文件。对于 awk 程序的输出,则存在这相应的变量 ORSOFS,awk 解释器通过它们理解如何将程序所得结果输出。

结语

一些繁琐的文本分析方面的问题,通常能够以简短的 awk 程序来解决。有些人由此看到了 awk 之美,有些人看到的则是 awk 之丑。因此,awk 的一行程序,吸引了许多人,也吓走了许多人。

在我看来,awk 不美,不丑,也不老。像大多数依然健在的古老的工具那样,只做一些恰如其分的事,这反而使之难以被取代。

egrep 和 sed 也只做恰如其分之事,前者专事文件检索,后者专事文本编辑。此二者所具有的功能,awk 皆能实现,但 awk 的出现并未取代它们。因为有些任务,用 grep 和 sed 可以更快捷地完成,而用 awk 就有些繁琐。例如,若获取 foo.txt 中至少含有两个 字的行及其序号,可完成这一任务的 awk 命令为

$ awk '/马.*马/ { print NR ":" $0 }' foo.txt

若是用 egrep,只需

$ egrep -n '马.*马' foo.txt

若删除 foo.txt 文件中以 天地万物 开始的行,可完成这一任务的 awk 命令为

$ awk '$0 !~ /^天地/ || '$0 !~ /^万物/' foo.txt > new-foo.txt
$ mv new-foo.txt foo.txt

若是用 sed,只需

$ sed -i '/^天地/d; /^万物/d' foo.txt

功能更强大的事物的出现,并不意味着功能孱弱的事物失去价值,反而可能更为彰显后者的价值。所以,不要随便地就对别人说,「我已习得 python,还有必要再学 awk 吗?」倘若 awk 不能而且无意于取代 egrep 和 sed,那么 python 当如何取代 awk 呢?

本文所展现的 awk 功能约有十之六七,旨在揭示 awk 的主要功用。掌握这些功能,足以胜任常规的文本处理任务。本文中出现的文字注释形式,便是借助 awk 生成,程序为

BEGIN {
    note_pat = "\\\\note{([^}]+)}"
    i = 1
}
{
    # 注解 -> 上标
    current_note = i
    while (x = match($0, note_pat, s)) {
        notes[i] = s[1]
        sub(note_pat, "**<sup>注 " i++ "</sup>**", $0)
    }
    # 输出处理后的段落及注解列表
    print $0
    if (i > current_note) {  # 段后增加注解
        print ""
        it = current_note
        while (it < i) {
            print "> 注 " it ":" notes[it]
            it++
        }
    }
}

有关 awk 的更为详尽的知识可从 awk 的三位开发者撰写的《The AWK Programming Language》一书中获得。这本书虽然写于 1988 年,但其内容依然适于现在的 awk 。此外,这本书在介绍 AWK 的编程示例中,言简意赅地介绍了文本处理、数据库、编译原理、排序以及图的遍历等计算机科学基础知识。

也许每一本讲授编程语言的书,都应该借鉴《The AWK Programming Language》的写法。