The AWK book's 60-line version of Make

Ben Hoyt

AWK 书中 60 行实现的 Make

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

在 Aho、Weinberger 和 Kernighan 合著的经典著作《The AWK Programming Language》中,第 7 章末尾用几页篇幅展示了一个简化版的 Make 工具——全部代码仅用一页 AWK 就写成了。

在展开之前,先提一下,第二版的 AWK 图书将于下月出版。Brian Kernighan 对其做了出色的更新,最引人注目的是一章关于探索性数据分析的新内容,并为此给 AWK 加入了完善的 CSV 支持。我很荣幸受邀审阅了第二版的草稿。

即使在 2023 年,AWK 在数据探索方面依然表现出色,尤其是新增的 --csv 选项。CSV 模式也已加入到 Gawk(GNU AWK)——AWK 安装最广泛的版本中。我自己的 GoAWK 实现早已具备完善的 CSV 支持,我也已添加 --csv 选项以与其他版本保持一致。

第二版中依然保留了这个 Make 程序,不过为了提高可读性加入了一些“空格与花括号”——这让代码从 50 行增加到了 62 行,改动见此

本文将介绍这个 Make 程序,以说明 AWK 不仅擅长写一行命令,也完全可以作为脚本语言来使用——至于是否应该这么做,则是另一回事。

接下来,我会对比同一个程序用 Python 实现会是什么样子,并简要讨论在这类任务中何时该选 AWK、何时该选 Python。

不用多说,这纯粹是一次学习练习(对我和读者而言),并非推荐大家真的用它来构建项目!

原始 AWK 版本

第二版是这样引入这个 Make 程序的。(顺便说一句,我觉得这里用“target”这个词容易让人困惑——我认为用“source”或“dependency”会更合适。)

本节将开发一个简易的更新程序,它仿照 Unix 的 make 命令,基于上一节介绍的深度优先搜索技术。

要使用这个更新器,必须明确描述系统由哪些组件构成、它们之间如何相互依赖,以及构建它们需要哪些命令。我们假设这些依赖和命令都存放在一个名为 makefile 的文件中,其中包含一系列形如下面的规则

name:   t1 t2 ... tn
        commands

规则的第一行是依赖关系,表明程序或文件 name 依赖于目标 t1t2、…、tn,其中每个 ti 都是一个文件名或另一个 name。每条依赖关系之后可以跟一行或多行 commands,列出生成 name 所需的命令。下面是一个 makefile 示例,对应一个包含两个 C 文件 a.cb.c 和一个 yacc 文法文件 c.y 的小型程序,这是典型的程序开发场景。

prog:   a.o b.o c.o
        gcc a.o b.o c.o -ly -o prog
a.o:    prog.h a.c
        gcc -c prog.h a.c
b.o:    prog.h b.c
        gcc -c prog.h b.c
c.o:    c.c
        gcc -c c.c
c.c:    c.y
        yacc c.y
        mv y.tab.c c.c
print:
        pr prog.h a.c b.c c.y

第一行表明 prog 依赖于目标文件 a.ob.oc.o。第二行说明 prog 是通过 C 编译器命令 gcca.ob.oc.o 以及 yaccy 链接生成 prog。下一条规则(第三行)指出 a.o 依赖于目标 prog.ha.c,并通过编译这些目标来生成;b.o 亦然。文件 c.o 依赖于 c.c,而 c.c 又依赖于 c.y,后者需要用 yacc 解析器生成器来处理。最后,名称 print 不依赖任何目标;按照约定,对于没有目标的名称,make 总会执行其关联动作,在此例中就是用 pr 命令打印全部源文件。

makefile 中的依赖关系可以用图来表示:只要存在一条依赖规则,左边是 x,右边的目标中有一个是 y,就在节点 x 到节点 y 之间连一条边。对于没有目标的规则,则会创建一个左侧名称对应的无后继节点。对于上面的 makefile,依赖图如下:

                  prog                 print
                /   |   \
               /    |    \
            a.o    b.o    c.o
           /   \  /   \      \
          /     \/     \      \
        a.c   prog.h   b.c     c.c
                                |
                               c.y

当然,这是一个高度简化的 Make 版本,但依然具备输出、依赖和构建命令这些核心概念。

在了解其工作原理之前,我在下方附上了完整源代码,内容与 AWK 图书第二版中完全一致。点击加粗文字可展开代码,或直接跳到“工作原理”一节查看详细讲解。

AWK 书中的 Make 程序(完整源代码)。
BEGIN {
    while (getline <"makefile" > 0) {
        if ($0 ~ /^[A-Za-z]/) {  #  $1: $2 $3 ...
            sub(/:/, "")
            if (++names[nm = $1] > 1)
                error(nm " is multiply defined")
            for (i = 2; i <= NF; i++) # remember targets
                slist[nm, ++scnt[nm]] = $i
        } else if ($0 ~ /^\t/) {      # remember cmd for
            cmd[nm] = cmd[nm] $0 "\n" #   current name
        } else if (NF > 0) {
            error("illegal line in makefile: " $0)
        }
    }

    ages()      # compute initial ages

    if (ARGV[1] in names) {
        if (update(ARGV[1]) == 0)
            print ARGV[1] " is up to date"
    } else {
        error(ARGV[1] " is not in makefile")
    }
}

function ages(      f,n,t) {
    for (t = 1; ("ls -t" | getline f) > 0; t++)
        age[f] = t         # all existing files get an age
    close("ls -t")

    for (n in names)
        if (!(n in age))   # if n has not been created
            age[n] = 9999  # make n really old
}

function update(n,   changed,i,s) {
    if (!(n in age))
        error(n " does not exist")
    if (!(n in names))
        return 0
    changed = 0
    visited[n] = 1
    for (i = 1; i <= scnt[n]; i++) {
        if (visited[s = slist[n, i]] == 0)
            update(s)
        else if (visited[s] == 1)
            error(s " and " n " are circularly defined")
        if (age[s] <= age[n])
            changed++
    }
    visited[n] = 2
    if (changed || scnt[n] == 0) {
        printf("%s", cmd[n])
        system(cmd[n])  # execute cmd associated with n
        ages()          # recompute all ages
        age[n] = 0      # make n very new
        return 1
    }
    return 0
}

function error(s) { print "error: " s; exit }

工作原理

书中有对程序工作原理的说明,但这里我会用自己的话来解释,重点关注我觉得有意思的部分。

BEGIN 块是这类程序的主入口。与大多数隐式从标准输入读取行的 AWK 程序不同,这个程序用显式的 getline 循环来读取 makefile

BEGIN {
    while (getline <"makefile" > 0) {
        if ($0 ~ /^[A-Za-z]/) {  #  $1: $2 $3 ...
            sub(/:/, "")
            if (++names[nm = $1] > 1)
                error(nm " is multiply defined")
            for (i = 2; i <= NF; i++) # remember targets
                slist[nm, ++scnt[nm]] = $i
        } else if ($0 ~ /^\t/) {      # remember cmd for
            cmd[nm] = cmd[nm] $0 "\n" #   current name
        } else if (NF > 0) {
            error("illegal line in makefile: " $0)
        }
    }
    ...
}

getline <filename 是一种重定向写法,首次执行时会打开 makefile,并逐行读取直至结束。如果当前行($0)以字母开头(/^[A-Za-z]/),则被视为 name: targets 形式的规则。

sub(/:/, "") 调用会去掉当前行中的冒号(在双参数形式的 sub 中,$0 是隐含参数)。

接下来我们通过检查 names 数组来确保该规则尚未定义过。AWK 中的数组实际上是关联数组,也就是早期对键值映射的称呼。

内部的 for 循环将每个 target(即依赖)加入 slist / scnt 数据结构。这其实是一个映射的列表,但为了绕开 AWK 不支持嵌套集合的限制,被展平成了这种形式。循环体非常简洁:

for (i = 2; i <= NF; i++)
    slist[nm, ++scnt[nm]] = $i

这段循环遍历每个依赖:即该行中从第 2 个字段到 NF(该行的字段数)的每一个字段 $i

对每个依赖,先将当前规则(nm)对应的源数量 scnt[nm] 加一,然后把依赖 $i 存入 slist,以名称和计数作为多键索引。AWK 通过将多个键用 SUBSEP 分隔符(默认为 "\x1c")拼接成一个键,来模拟多维或多键数组。

循环结束后,以 prog 为例,slistscnt 会呈现如下形态:

slist
    a.o,1:  prog.h
    a.o,2:  a.c
    b.o,1:  prog.h
    b.o,2:  b.c
    c.c,1:  c.y
    c.o,1:  c.c
    prog,1: a.o
    prog,2: b.o
    prog,3: c.o

scnt
    a.o:  2
    b.o:  2
    c.c:  1
    c.o:  1
    prog: 3

回到上层,如果一行以制表符开头,则它是命令,于是将其追加到对应名称的命令字符串中:

cmd[nm] = cmd[nm] $0 "\n"

否则,如果该行不是空行(NF > 0),则视为 makefile 错误。

最后,在 while 循环中读完 makefile 后,我们调用 ages() 计算当前目录下所有文件的“新旧程度”,然后调用 update(ARGV[1]) 去更新命令行传入的规则:

BEGIN {
    ...
    ages()      # compute initial ages

    if (ARGV[1] in names) {
        if (update(ARGV[1]) == 0)
            print ARGV[1] " is up to date"
    } else {
        error(ARGV[1] " is not in makefile")
    }
}

ages 函数开始变得有意思了:

function ages(      f,n,t) {
    for (t = 1; ("ls -t" | getline f) > 0; t++)
        age[f] = t         # all existing files get an age
    close("ls -t")

    for (n in names)
        if (!(n in age))   # if n has not been created
            age[n] = 9999  # make n really old
}

参数名 fnt 前面空了一大段,用来表明它们实际上是局部变量,而非预期的实参。这是 AWK 的一个怪癖(Kernighan 对此也感到后悔):定义局部变量的唯一方式就是把它们写成函数参数,而当调用时传入的实参数量少于形参数量时,多余的参数会取默认值(数值默认为 0,字符串默认为 "")。因此你在 AWK 函数定义中会经常看到这种多余的空格。

接下来的一点很巧妙:AWK 支持类似 shell 的 | 语法,将某个程序的输出通过管道一次一行地用 getline 读入变量(这里是 f)。ls -t 命令会按修改时间从新到旧列出当前目录下的文件。

在将每个文件的“年龄”赋给 age[f] 的循环之后,我们调用 close 关闭 ls -t 管道,以避免打开过多文件句柄。

最后,我们遍历所有规则名,给尚未创建的文件的 age[n] 赋一个任意的大数,假装它们非常“旧”,从而需要被更新。

接下来是递归的 update 函数,这里是算法的核心所在:

function update(n,   changed,i,s) {
    if (!(n in age))
        error(n " does not exist")
    if (!(n in names))
        return 0
    changed = 0
    visited[n] = 1
    for (i = 1; i <= scnt[n]; i++) {
        if (visited[s = slist[n, i]] == 0)
            update(s)
        else if (visited[s] == 1)
            error(s " and " n " are circularly defined")
        if (age[s] <= age[n])
            changed++
    }
    visited[n] = 2
    if (changed || scnt[n] == 0) {
        printf("%s", cmd[n])
        system(cmd[n])  # execute cmd associated with n
        ages()          # recompute all ages
        age[n] = 0      # make n very new
        return 1
    }
    return 0
}

同样,注意参数列表:n 是预期的实参(要更新的名称),而 changed,i,s 则是局部变量。

在完成初始检查后,我们通过从 slist[n, 1] 遍历到 slist[n, scnt[n]] 来循环依赖列表。如果该依赖尚未访问过,就通过递归调用 update 对依赖图进行深度优先遍历,看看是否需要先更新该依赖:

if (visited[s = slist[n, i]] == 0)
    update(s)

递归的终止条件是顶部附近的 if (!(n in names)) return 0 语句。当要更新的文件不在规则名列表中时,我们就停止——这对应依赖图中的叶子节点。

语句 if (age[s] <= age[n]) changed++ 会在任一依赖比当前正在更新的文件更新时,将 changed 计数加一。

遍历循环结束后,如果任一依赖或子依赖发生了变化,我们就用 system() 执行关联命令,重新计算所有文件的年龄,并向调用者返回 1,表示确实进行了更新。

scnt[n] == 0 这一条件处理的是被更新规则未指定任何依赖的情况,就像示例中的 print 规则。此时总是重新执行其命令。

就这样!一个用一页 AWK 实现的极简 Make 就完成了。

Python 版本

出于兴趣,我把书中的 AWK 版 Make 移植到了 Python,代码附在下方。同样,点击加粗文字即可展开程序。

我移植的 Python 版 Make 程序(完整源代码)。
import os, re, sys

slist = {}  # slist[name] is list of rule's sources
cmd = {}    # cmd[name] is shell command to run for rule

def main():
    for line in open('makefile'):
        if re.match('[A-Za-z]', line):
            line = line.replace(':', '')
            fields = line.split()
            nm = fields[0]
            if nm in slist:
                error(f'{nm} is multiply defined')
            slist[nm] = fields[1:]    # remember targets
        elif line.startswith('\t'):   # remember cmd for current name
            cmd[nm] = cmd.get(nm, '') + line
        elif line.strip():
            error(f'illegal line in makefile: {line}')
    if sys.argv[1] in slist:
        if not update(sys.argv[1]):
            print(sys.argv[1], 'is up to date')
    else:
        error(f'{sys.argv[1]} is not in makefile')

def mtime(n):
    try:
        return os.stat(n).st_mtime
    except FileNotFoundError:
        return 0  # mark as old if it doesn't exist

def update(n, visited={}):
    ntime = mtime(n)
    if n not in slist and ntime == 0:
        error(f'{n} does not exist')
    if n not in slist:
        return 0
    changed = False
    visited[n] = 1
    for s in slist.get(n, []):
        if s not in visited:
            update(s)
        elif visited[s] == 1:
            error(f'{s} and {n} are circularly defined')
        if mtime(s) > ntime:
            changed = True
    visited[n] = 2
    if changed or len(slist.get(n, [])) == 0:
        print(cmd[n], end='')
        os.system(cmd[n])  # execute cmd associated with n
        return 1
    return 0

def error(msg):
    print('error:', msg, file=sys.stderr)
    sys.exit(1)

if __name__ == '__main__':
    main()

它在结构上与原版 AWK 版本非常相似,不过我做了两处简化,我认为这样更易于理解:

  1. 更简单的数据结构,避免 slist / scnt 的别扭写法——在 Python 中我们只需使用字典套列表即可。(查看 diff。
  2. 更直接地通过 os.stat() 获取文件修改时间(mtime)来判断新旧,而不是使用 ls -t 的技巧。这也省去了 age 映射和 ages 函数。(查看 diff。

这并非有意为之,但即使算上 import 语句和 if __name__ == '__main__' 这一固定写法,它也只有 58 行代码——基本上与 AWK 程序长度相当。

在编写 Python 版本时,我意识到也可以用类似方式简化 AWK 版本:

  1. 在概念上,直接将 slist 存为 AWK 数组更为简单:一个键值映射,键是规则名,值是以空格分隔的依赖列表字符串(就像在 makefile 中那样)。需要时可以用 split 将依赖字符串转为列表(一个从 1 到依赖数量的数组)。这样就完全不需要 scntnames 了。(查看 diff。
  2. 与 Python 版本类似,我们可以直接通过调用外部命令 stat 来获取 mtime,而不是用 ls -t 按年龄顺序列出所有文件。我使用了 stat --format %y 来实现。我认为这是 GNU 扩展,因此可移植性不如 ls -t,但它更简单,也避免了重新计算 age 数组的需要。(查看 diff。

更新:Volodymyr Gubarkov 指出,stat 版本“增加了多次外部进程调用”,他说得很对。它或许更直接,但速度明显更慢。

顺带一提,修改后的版本比原版少了四行。我觉得更简单的 slist 更清晰,也更喜欢直接获取 mtime 的方式,尽管我也意识到 stat --format 缺乏可移植性是个缺点(macOS 上的 stat 看起来很不一样)。

结论

AWK 版 Make 程序是一段精巧的代码,展示了 AWK 即使对于中等规模的脚本也是一门非常实用的语言。

不过,Python 在处理这类任务时无疑是更优雅的语言:它拥有更丰富的数据类型、像 os.stat 这样更趁手的工具,以及无需怪异语法就能使用的局部变量。

我认为 AWK 非常出色,但它就应该留在它最擅长的领域:探索性数据分析和一行式数据提取脚本。

作为早已具备原生 CSV 支持的 GoAWK 的作者,我尤其高兴地看到 Kernighan 的“one true AWK”和 Gawk 都以 --csv 选项的形式获得了完善的 CSV 支持。Kernighan 对 AWK 的更新很快就会被合并,Gawk 也将在即将发布的 5.3.0 版本中加入这一功能

你也可以在 GitHub 上查看我的 awkmake 仓库,其中包含了 AWK 书中 Make 程序与我的 Python 版本的完整源码,以及一个基于书中示例的可运行示例项目。

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

评论