第12章 拼写检查

本章利用拼写检查,呈现各种 Shell 脚本的不同方面。介绍完 spell 程序后,我们会告诉你如何构建一个简单又好用的拼写检查程序。紧接着再介绍如何利用这个简单的 Shell 脚本,修改手边两个可自由使用的拼写程序的输出,让它看起来就像传统 UNIX 的 spell 程序那样。最后,呈现 awk 写成的拼写检查程序,让读者完整了解这个语言的简单利落。

12.1 spell 程序

spell 程序做的事就是你想的:检查文件里是否有拼写错误。这个程序会读取命令行上指定的所有文件,在标准输出上产生排序后的单词列表,这个列表上的单词不是在它的字典里找不到,就是无法从标准的英文文法应用里派生出来(例如 “words” 派生自 “word”)。有趣的是:POSIX 并未对 spell 进行标准化,在它的文件里是这么说的:

该工具程序对 Shell 脚本或传统应用程序并无用处。spell 是深思熟虑后的设计,但它却忽略了:用户指定的输入如果未伴随完整的字典,则没有技术可识别用户指定的输入文件。

我们不同意上述的第一部分。试想脚本的自动调试与问题报告,有人可能会想要显示类似的这些行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#! /bin/sh -

# probeport --- 简单的问题报告程序

file=/tmp/report.$$
echo "Type in the problem, finish with Control-D."
cat > $file

while true
do
    printf "[E]dit, Spell [C]heck, [S]end, or [A]bort: "
    read choice
    case $choice in
        [Ee]*) $ {EDITOR:-vi} $file
            ;;
        [Cc]*) spell $file
            ;;
        [Aa]*) exit 0
            ;;
        [Ss]*) break    # from loop
            ;;
    esac
done
...
    传送报告

在本章,我们会从各个角度审视拼写检查,因为这部分很有趣,我们有机会以不同的方式解决问题。

12.2 最初的 UNIX 拼写检查原型

以拼写检查为主题的研究报告及书籍已经不少于 300 项(注 1)。在 Jon Bentley 的《Programming Pearls》一书(注 2)里曾提到:Steve Johnson 在 1975 年的某个午后,写出第一版 spell。Bentley 之后略为改造,贡献给 Kernighan 与 Plauger(注 3),该程序以 UNIX 的管道完成,我们可用现代语汇改写如下:

1
2
3
4
5
6
7
prepare filename |
tr A-Z a-z |            # 删除格式化命令
tr -c a-z '\n' |        # 将大写字母对应到小写字母
sort |                  # 删除标点符号
uniq |                  # 将单词依字母顺序排列
comm -13 dictionary -   # 删除重复的单词
                        # 报告不在字典里的单词

这里的 prepare 为过滤程序,它会将所有文件标记(markup)拿掉;最简单的情况,只要用到 cat。我们使用的参数语法是假定 tr 命令为 GNU 版本。

这个管道里唯一我们还未曾提过的程序便是 comm;它是用以比较两个排序后的文件,并选定或拒绝两个文件里共同的行。这里使用 -13 选项,因此它仅输出来自第二个文件(管道的输入)但不在第一个文件(字典)里的行。该输出为拼写异常报告。

comm

语法

comm [ options … ] file1 file2

用途

指出在两个输入文件里,哪些行是只出现在其中一个文件中,或者两个文件里都有出现。

主要选项

  • -1

不要显示第一列(只在 file1 出现的行)

  • -2

不要显示第二列(只在 file2 出现的行)

  • -3

不要显示第三列(两个文件里都有的行)

行为模式

运行读取两个文件,且输入文件必须都已排序。产生的输出共三列:只在 file1 里有的行、只在 file2 里有的行,以及两个文件里都有的行列。这两个文件名其中一个可以是 -,指定 comm 读取标准输入。

警告

它的选项不是直接式的;用户很难记得为了删除一个输出列,要加上一个选项!

Bentley 之后继续讨论贝尔实验室的 Doug McIlroy 于 1981 年所开发的拼写检查程序,包括它的设计与应用、如何将字典存储在小型的内存里,以及为什么检查拼写这么困难,尤其是在像英文这样杂乱无章的语言上。

现代的 spell 为了效率而使用 C 完成。不过,原始的管道仍在贝尔实验室里使用相当长的一段时间。

12.3 改良的 ispell 与 aspell

UNIX 的 spell 支持许多选项,不过有很多是平时用不到的。不过令 spell 的行为模式偏向英式拼法的 -b 选项异常:它会以 “centre” 取代 “center”、以 “colour” 取代 “color” 等(注 4)。要了解其他选项请见使用手册。

其中一个不错的功能就是:你可以提供你本地有效单词的拼写列表。例如,在特定专门领域中的正确拼法,但在 spell 的字典里是不存在的(例如:POSIX)。你可以建立并长期维护自有的有效、但非一般性的单词列表,然后在执行 spell 时使用此列表。指定本地拼写列表的方式,是标明路径名称并将它放在要被检查的文件之前,且前置单个的 + 字符:

1
spell +/usr/local/lib/local.words myfile > myfile.errs

12.3.1 私有拼写字典

我们觉得,在实际上最重要的就是:针对你所写的任何文件都提供私有拼写字典。一个通用于大部分文件的字典并不实用,因为词汇量会变得太大,且无法确切发现错误。“syzygy” 在数学论文里可能是正确的,但在小说里,它或许应为 “soggy” 才对。我们发现,几百万行的技术文件大全配合拼写字典作比较,几乎是每六行就出现一个拼写异常。这告诉我们,拼写异常很常见,这也是这个项目另外必须解决的问题。

关于 spell 还有一些棘手的事:它只能有一个 + 选项,且其字典必须以字典编纂法的方式排序,这是个很粗糙的设计。即 spell 的绝大多数版本,在 locale 变动后,就会失灵(虽然这被认为是个很差的设计,但事实上那只是未预期到 locale 的出现所导致的结果。spell 的代码在很多系统上已使用 20 年以上未作更改,而当底层的程序库被更新以完成 locale 为主的排序时,没有人了解这会造成影响)。举例如下:

1
2
3
4
5
6
$ env LC_ALL=en_GB spell +ihmsayjs.sok < ihmsayjs.bib | wc -l
3674
$ env LC_ALL=en_US spell +ihmsayjs.sok < ihmsayjs.bib | wc -l
3685
$ env LC_ALL=C spell +ihmsayjs.sok < ihmsayjs.bib | wc -l
2163

然而,如果私人字典的排序符合当前 locale 环境,则 spell 可适当运行:

1
2
3
$ env LC_ALL=en_GB sort ihmsayjs.sok > /tmp/foo.en_GB
$ env LC_ALL=en_GB spell +/tmp/foo.en_GB < ihmsayjs.bib | wc -l
2163

问题是默认的 locale 在操作系统版本之间可能有所不同。因此最好的方式便是将 LC_ALL 环境变量设置为与私人字典排序一致,再执行 spell。我们将在下一节提供 spell 已排序字典需求的改写方案。

12.3.2 ispell 与 aspell

这里有两个可以自由取用的拼写检查程序:ispell 与 aspell。ispell 程序为交互模式的拼写检查,它会显示文件,然后将所有拼写错误之处反白,并提供建议的变动。aspell 程序也类似,不过它在英文上提供的建议更正比较好,且其作者希望它最终能取代 ispell。这两个程序都能用于产生拼错单词的简易列表,且因为希望 aspell 能取代 ispell,所以它们使用的选项相同:

  • -l

在标准输出打印拼错的单词列表。

  • -p file

以 file 作为正确单词拼法的个人字典。类似 UNIX spell 里,以 + 起始的私有文件选项。

ispell 的官方网站在 http://ficus-www.cs.ucla.edu/geoff/ispell.html,其源代码可在 ftp://ftp.gnu.org/gnu/non-gnu/ispell/(注 5)找到。aspell 官方网站则为 http://aspell.net/,源代码位于 ftp://ftp.gnu.org/gnu/aspell/

这两个程序都提供基本的批处理拼写检查功能。当然它们同样也共享了坏习惯:产生未排序的结果,且未省略错误单词重复的部分(UNIX 的 spell 没有这两个问题)。因此,我们优秀的 GNU/Linux 厂商便有了 /usr/bin/spell 这样的 Shell 脚本出现:

1
2
3
4
5
#! /bin/sh

# aspell -l 大概地模仿标准 UNIX spell 程序

cat "$@" | aspell -l --mode=none | sort -u

–mode 选项使得 aspell 忽略一些类型的标记,例如 SGML 与 TeX。这里的 –mode=none 表示不做任何的过滤。sort -u 命令则是将排序结果里重复的部分去除,产生 UNIX 老手预期看到的结果。你也可以使用 ispell 作同样的事:

1
cat "$@" | ispell -l | sort -u

有两种方式可以再改进这个脚本,让它可以提供个人字典,像 UNIX 的 spell 那样。第一个替换 spell 脚本的方式如例 12-1。

例 12-1:以 ispell 代替 spell

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#! /bin/sh

# UNIX 的 spell 把 '+ file' 的第一个参数看作是
# 提供私有拼写列表,我们也如法炮制。

mydict=
case $1 in
    +?*)
        mydict=${1#+}    # 去除开头的 +
        mydict="-p $mydict"
        shift
        ;;
esac

cat "$@" | ispell -l $mydict | sort -u

这段代码只是查找起始为 + 的第一个参数,将其存储到变量,截去 + 字符,再放入准备好的 -p 选项、传递给 ispell 引用。

不幸的是:该技巧在 aspell 下无效,因为它要求字典必须为编译后的二进制格式。如要使用 aspell,我们改以 fgrep 手段进行,它可以匹配文件内所提供的多个字符串。我们另加入 -v 选项,要求 fgrep 显示不匹配的行。所以第二种替换 spell 脚本的方式见例 12-2。

例 12-2:以 aspell 取代 spell

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#! /bin/sh

# UNIX 的 spell 把 '+ file' 的第一个参数看作是
# 私有拼写列表的提供,我们也如法炮制。

mydict=cat
case $1 in
    +?*)
        mydict=${1#+}    # 去除开头的 +
        mydict="fgrep -v -f $mydict"
        shift
        ;;
esac

# aspell -l 模仿标准 UNIX spell 程序。
cat "$@" | aspell -l --mode=none | sort -u | eval $mydict

如果你不想要排序私有字典,或不想烦恼不同的 locale 所产生的不同排序方式,那么相同的 fgrep 后续处理技巧也可搭配 UNIX 的 spell 使用。

下一节呈现的是 spell 的 awk 版本,它可以提供功能强大又简化的替代方案,是我们在此所讨论的 spell 替换的另一种选择。

12.4 在 awk 内的拼写检查程序

本节要呈现的是提供检查拼写的程序。即便所有 UNIX 系统都有 spell,有些甚至还有 aspell 或 ispell,但我们的程序兼具了教育性与实用性。本节不但能让你知道 awk 的功能有多强大,还能得到一个适用于所有平台上的程序,只要它有 awk。

我们必须强调检查(checking)与更正(correcting)的差别。后者必须了解内文格式,还需要人为确认,因此完全不适于批处理处理。由网页浏览器与文本处理程序所提供的自动化拼写更正只会让情况变得更糟,因为它们多半是错的,且在你快速输入时进行的二次推断,情况只会进一步恶化。

emacs 文字编辑程序提供三个好的解决方案,可在输入内文期间提供拼写协助:可依需求展开部分单词以动态补齐单词、通过单一按键提出对当前单词的拼写验证需求,以及 flyspell 程序库可用以要求以不那么显眼的颜色标示出可能有误的单词。

只要你能在拼写程序指出错误时认得拼错的部分,那么能够报告可能拼错单词的列表及允许你提供个人的特殊单词列表,非字典的一般单词,会是比较好的拼写检查程序,可减少该报告长度。之后你可以利用这份报告识别错误的部分,修正它们后再重新产生报告(这时应只有正确的单词了),然后将它的内容加入到你私有字典里。由于我们的写作都在处理技术的素材,它时常充满不常见的单词,实际上我们保有私人的与特定文件的补充字典,应用到我们所写的每个文件中。

为引导程序的进行,这里列出几个我们的拼写检查程序预期的设计目标。遵循 ISO 标准的实际,我们使用将会(shall)指出必须做的,而使用应该(should)指出想要做的:

  • 程序将会能够读取文字数据流、隔离单词,以及报告不在已知单词列表[也即,拼写字典(spelling dictionary)]里的单词实体。
  • 将会有一个默认的单词列表,由一个或多个系统字典收集而成。
  • 它将可能取代默认的单词列表。
  • 标准单词列表将有可能由一个或多个用户所提供的单词列表而扩增。该列表在技术性文件上特别有用,例如首字母缩写、术语及专有名词,它们大部分都无法在标准列表里找到。
  • 单词列表将无须排序,这点与 UNIX 的 spell 不同,后者当 locale 变动时,会出现不当的行为模式。
  • 虽然默认单词列表都是英文,但辅以适当的替代性单词列表,程序将可以处理任何语言的文字,只要它是以基础为 ASCII 的字符集(编码为 8 位字节)呈现,以空白字符(whitespace)分隔单词。这消除了难懂语言的难度,例如老挝语(Lao)与泰文(Thai),它们缺乏单词内的空间,因此需要更具扩展性的语意分析才能识别单词。
  • 将忽略字母大小写,让单词列表维持在易于管理的大小,不过异常列表报告时将使用原来的大小写。
  • 将忽略标点符号与数字,但顿点符号(缩写的一撇)将视为字母。
  • 默认的报告将为排序后具有独一无二单词的列表(是无法在结合的单词列表里找到的)以一行一个单词的方式呈现。这是拼写异常列表(spelling exception list)。
  • 将可通过选项增加异常列表报告,并有位置信息,例如文件名与行编号,以利寻找与更正拼错的单词。报告将以位置排序,且当它们在同一位置发现多个异常时,则进一步依异常单词排序。
  • 应支持用户可指定的后缀缩减,让单词列表保持在易于管理的大小。

在本节最后的例 12-4 中,我们会展示满足上述所有目标且做得更多、更完整的程序。由于程序功能相当多,因此本节接下来会辅以简单文字来详述细节,并将程序分段以便说明。

使用的测试输入文件包含了 spell 手册页前几段的内容,程序执行结果大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ awk -f spell.awk testfile

deroff

eqn

ier

nx

tbl

thier

或是指定冗长模式,则为:

1
2
3
4
5
6
7
8
$ awk -f spell.awk --verbose testfile

testfile:7:eqn
testfile:7:tbl
testfile:11:deroff
testfile:12:nx
testfile:19:ier
testfile:19:thier

12.4.1 介绍性注释

程序会从详尽的注释文字开始,不过在这里,我们仅展示介绍与语法部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 实例简单的拼写检查程序,搭配用户可定义的异常列表。
# 内置字典是由标准的 UNIX 拼写字典列表构建而成。
# 不过可以在命令行上覆盖该设置。
#
#
#
#
#
# 语法:
# awk [-v Dictionaries="sysdict1 sysdict2 ..."] -f spell.awk - \

12.4.2 主体

程序的主体只有三行,也就是传统 awk 程序的:初始化、处理与报告:

1
2
3
4
5
BEGIN {initialize()}

{ spell_check_line() }

END {report_exceptions()}

程序文件剩余部分的所有细节将交给字母顺序排列的函数处理,但在本节将以逻辑上的顺序描述它。

12.4.3 initialize()

initialize() 函数处理程序的初始化工作。

变量 NonWordChars 里的正则表达式,是用以删除不想要的字符。与 ASCII 字母与撇号一起,范围在 161 到 255 内的字符都被保留作为单词字符,所以 ASCII 的文件、任何 ISO8859-n 字符集及以 UTF-8 编码的 Unicode,所有都能被处理而无须关心字符集。

128 到 160 间的字符会被忽略,因为在这之间的所有字符集,都作为额外的控制字符与一个无中断的空格。这些字符集里有部分具有一些在 160 以上的非字母字符,但使用它们也增加了我们不想要的字符集依赖性。非字母字符是很少见的,即使有,在我们的程序下,最坏的情况就是在拼写异常报告里偶尔会出现错误。

我们假定将进行拼写检查的文件,与其相关联的字典具有相同字符集编码。如果否,则通过 iconv,将它们转换为一致的编码。

如果所有的 awk 实例都遵循 POSIX,则我们可以这么设置 NonWordChars:

1
NonWordChars = "[^[:alpha:]]"

之后,当前 locale 会决定应忽略哪些字符。不过这种指定方式不具可移植性,因为有很多 awk 实现不支持 POSIX 风格的正则表达式:

在 locale 导入 UNIX 前,我们还是能够以否定单词字符集的方式,赋值给 NonWordChars:

1
NonWordChars = "[^A-Za-z\241-\377]"

然而,在 locale 的出现下,正则表达式里字符范围是按照 locale 类型而被解释,所以其值在各平台间的结果可能不一致。解决方式是改用明白地列举字符的方式,把赋值编写为连续的字符串,并适当地对齐,以利于人工迅速识别否定字符集里的字符。我们使用八进制表示 127 以上的值,因为这么做会比混杂重音字符要来得清楚许多。

initialize() 接下来会识别并载入字典,并处理命令行参数与后缀规则。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function initialize()
{
    NonWordChars = "[" \
        "!" \
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
        "abcdefghijklmnopqrstuvwxyz" \
        "\241\242\243\244\245\246\247\250\251\252\253\254\255\256\257" \
        "\260\261\262\263\264\265\266\267\270\271\272\273\274\275\276\277" \
        "\300\301\302\303\304\305\306\307\310\311\312\313\314\315\316\317" \
        "\320\321\322\323\324\325\326\327\330\331\332\333\334\335\336\337" \
        "\340\341\342\343\344\345\346\347\350\351\352\353\354\355\356\357" \
        "\360\361\362\363\364\365\366\367\370\371\372\373\374\375\376\377" \
        "]"

    get_dictionaries()
    scan_options()
    load_dictionaries()
    load_suffixes()
    order_suffixes()
}

12.4.4 get_dictionaries()

get_dictionaries() 会填入默认系统字典的列表:我们提供的是两个方便取得的文件。用户可以通过提供字典列表作为命令行变量 Dictionaries 的值,或直接使用 DICTIONARIES 环境变量,而使得该默认选择失效。

如果 Dictionaries 为空,我们会查阅环境数组 ENVIRON,并使用其内所设置的值。如果这么做 Dictionaries 依然为空,我们就会提供一个内置列表。该列表的选择必须花点心思,因为在不同的 UNIX 平台间会出现极大差异,而且在文件很小时,此程序所消耗的大部分执行期时间是在载入字典。除此之外,Dictionaries 应包含一个以空白分隔的字典文件名列表,我们会将其切割,并存储在全局性 DictionaryFiles 数组里。这里选择的单词列表是在我们某些系统里 spell 所使用的单词列表(约 25000 条记录),以及 Donald Knuth 提供的一个较大型列表(约 110000 条记录,注 6)。

请留意字典名称是如何被存储的:它们是数组索引(indices),而非数组值(value)。这么设计的理由有二:第一,它可以自动处理提供字典超过一次以上的情况,只有文件名的一个实体被存储;第二,它可易于使用 for (key in array) 循环,迭代经过整个字典列表。无须维护用于计算字典数目的变量。

这是代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function get_dictionaries(    files, key)
{
    if ((Dictionaries == "") && ("DICTIONARIES" in ENVIRON))
        Dictionaries = ENVIRON["DICTIONARIES"]

    if (Dictionaries == "")    # 使用默认的字典列表
    {
        DictionaryFiles["/usr/dict/words"]++
        DictionaryFiles["/usr/local/share/dict/words.knuth"]++
    }
    else    # 使用命令行提供的系统字典
    {
        split(Dictionaries, files)
        for (key in files)
            DictionaryFiles[files[key]]++
    }
}

12.4.5 scan_options()

scan_options() 处理的是命令行。该函数预期会找到选项(-strip 与 / 或 -verbose)、用户字典(以 UNIX spell 传统的开头 + 指定)、后缀规则文件(前置 = 标记)及要作拼写检查的文件。任何的 -v 选项所设置的 Dictionaries 变量都已由 awk 处理,且不在参数数组 ARGV 中。

scan_options() 里的最后一个语句得解释一下:在测试期间,我们发现如果 ARGV 结尾处留有空参数时,nawk 不会读取标准输入,但 gawk 与 mawk 会。因此我们在 ARGC 上减一,直到 ARGV 的结尾有一个非空的参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function scan_options(    k)
{
    for (k = 1; k < ARGC; k++)
    {
        if (ARGV[k] == "-strip")
        {
            ARGV[k] = ""
            Strip = 1
        }
        else if (ARGV[k] == "-verbose")
        {
            ARGV[k] = ""
            Verbose = 1
        }
        else if (ARGV[k] ~ /^=/)    # 后缀文件
        {
            NsuffixFiles++
            SuffixFiles[substr(ARGV[k], 2)]++
            ARGV[k] = ""
        }

12.4.6 load_dictionaries()

这个函数很简单:以 getline 读取每个字典文件的每一行,并将小写形式的单词存储在全局的 Dictionary 数组里。请注意,如果 Dictionary 数组已经包含某个单词,则它的计数会递增。但我们并不需要计数,我们只用它作为存在性的标记。

function load_dictionaries(    file, word)
{
    for (file in DictionaryFiles)
    {
        while ((getline word < file) > 0)
            Dictionary[tolower(word)]++
        close(file)
    }
}

12.4.7 load_suffixes()

后缀规则会带有解释,且为了说明,我们呈现的是传统的英文规则,见例 12-3。我们以正则表达式匹配后缀,每一个单词末端都带有 $ 锚点(anchor)。当后缀被截断时,则必须提供一个替换后缀,例如把 tr + ied 缩短成 tr + y,且时常会有数种可能的替换。

例 12-3:英文的后缀规则:english.sfx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
's              # Jones' -> Jones
's$             # it's -> it
ably$   able    # affably -> affable
ed$     "e      # breaded -> bread, flamed -> flame
edly$   ed      # ashamedly -> ashamed
es$     "e      # arches -> arch, blues -> blue
gged$   g       # debugged -> debug
ied$    ie y    # died -> die, cried -> cry
ies$    ie ies y # series -> series, ties -> tie, flies -> fly
ily$    y ily   # tidily -> tidy, wily -> wily
ing$            # jumping -> jump
ingly$  "ing    # alarmingly -> alarming or alarm
lled$   l       # annulled -> annul
ly$     "       # acutely -> acute
nnily$  n       # funnily -> fun
pped$   p       # handicapped -> handicap
ppings$ p       # dropping -> drop
rred$   r       # deferred -> defer
s$              # cats -> cat
tted$   t       # committed -> commit

因此,后缀规则的最简单规格是以正则表达式进行后缀匹配,紧接着一个以空白字符分隔的替换列表。因为可能的替换之一是空字符串,我们以 "" 表示。如果它是唯一的替换,那我们会省略它。英文是高度不规则且拥有大量外来语的语言,所以有许多后缀规则,且绝对比我们在 english.sfx 里列的还多很多。不过后缀列表仅用于降低错误报告的发生,因为它有效地延展字典大小、不会影响程序的正确运行。

为了便于人类管理后缀规则文件,其规则必须可使用注释扩展以提供它们的应用范例。我们遵循一般 UNIX 的注释实例,也就是从 # 到行结尾都为注释。因此,load_suffixes() 会截去注释与开头、结尾的空白字符,再丢弃空白行。留下来的是正则表达式与零到多个替换的列表,用于其他地方可以调用 awk 内置的字符串替换函数 sub()。该替换列表是以空白分隔的字符串被存储,可供我们稍候在其上应用 split() 内置函数。

后缀替换可使用 & 表示匹配文字,不过我们在 english.sfx 中并未举出此功能的例子。

我们曾考虑让 load_suffixes() 提供正则表达式里的 $ 锚点,但终究推翻这个想法,因为这么可能会限制其他语言所要求的后缀匹配的规格。后缀规则文件必需时时刻刻投入相当的关注,但这个工作只需要在每种语言里做一次就好。

遇到未提供后缀文件的情况时,我们会载入默认的后缀集,具有空的替换值。split() 内置函数有助于缩短该初始化的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function load_suffixes(    file, k, line, n, parts)
{
    if (NSuffixFiles > 0)    # 自文件中载入后缀正则表达式
    {
        for (file in SuffixFiles)
        {
            while ((getline line < file) > 0)
            {
                sub(" *#.*$", "", line)    # 截去注释
                sub("^[ \t]+", "", line)    # 截去开头空白
                sub("[ \t]+$", "", line)    # 截去结尾空白
                if (line == "")
                    continue
                n = split(line, parts)
                Suffixes[parts[1]]++
                Replacement[parts[1]] = parts[2]
                for (k = 3; k <= n; k++)
                    Replacement[parts[1]] = Replacement[parts[1]] " " \
                        parts[k]
            }
            close(file)
        }
    }
    else
        # 载入英文后缀正则表达式的默认表格
    {
        split("'$ 's$ ed$ edly$ es$ ing$ ingly$ ly$ s$ ", parts)
        for (k in parts)
        {
            Suffixes[parts[k]] = 1
            Replacement[parts[k]] = ""
        }
    }
}

12.4.8 order_suffixes()

后缀替换得小心地处理:特别是它应该以 “自第一个匹配到最长(longest-match-first)” 的算法执行。order_suffixes() 采取存储在全局性 Suffixes 数组中的后缀规则列表,并复制一份到 OrderedSuffix 数组,以自 1 起始至 NOrderedSuffix 的整数作为数组索引。

然后,order_suffixes() 会使用简单的冒泡排序,通过递减模式长度,使用最内部循环里的 swap() 函数重新排列 OrderedSuffix 里的记录。swap() 的运行很简单:在其参数数组中,交换元素 i 与 j。该排序技巧的复杂度与被排序元素的数量成平方比,不过 NOrderedSuffix 预期不会那么大,所以该排序不可能耗费如此显著的程序执行期:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function order_suffixes(    i, j, key)
{
    # 以递减的长度排列后缀
    NOrderedSuffix = 0
    for (key in Suffixes)
        OrderedSuffix[++NOrderedSuffix] = key

    for (i = 1; i < NOrderedSuffix; i++)
        for (j = i + 1; j <= NOrderedSuffix; j++)
            if (length(OrderedSuffix[i]) < length(OrderedSuffix[j]))
                swap(OrderedSuffix, i, j)
}

function swap(a, i, j,    temp)
{
    temp = a[i]
    a[i] = a[j]
    a[j] = temp
}

12.4.9 spell_check_line()

我们已介绍完程序必备的起始代码。在程序启动时的第二组模式/操作会调用 spell_check_line() 处理输入数据流的每一行。

第一个工作便是将此行减为一个单词列表。内置函数 gsub() 只需在一行代码中将非文字的数字字符删除,即可完成此任务。产生的单词可以通过 $1、$2、…、$NF 取用,因此只要一个简单的 for 循环即可重复处理这些单词,将它们交给 spell_check_word() 个别处理。

以一般 awk 程序惯例来说,我们避免在函数体里引用匿名式的数字型字段名称,例如 $1,而倾向于将它们限制在较短的操作代码块里。不过有个异常:$k,它是整个程序里唯一这类匿名式引用。为了避免修改时发生的不必要记录重组,我们复制一份到本地变量后,随即截去外部的撇号字符(缩写的一撇),并传送任何非空的结果给 spell_check_word() 进行下一步处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function spell_check_line(    k, word)
{
    gsub(NonWordChars, "")    # 消除非单词字符
    for (k = 1; k <= NF; k++)
    {
        word = $k
        sub("^'+", "", word)    # 截去开头的撇号字符
        sub("'+$", "", word)    # 截去结尾的撇号字符
        if (word != "")
            spell_check_word(word)
    }
}

一旦单词已被认可,则字符特有的特殊处理方式并不见得好。但撇号字符在某些语言里指的只是省略,以及外部引号,其实它是个过载的字符。使用消除它的引文,可以降低最后拼写异常列表里错误报告的数量。

截去撇号字符对荷语来说不可行,它有小部分单词是将撇号置于单词初始位置:’n 对于 een、s 对于 des,以及 ’t 对于 het。这些都是些琐碎细节,你可以通过增加异常字典的方式处理。

12.4.10 spell_check_word()

spell_check_word() 是真正进行处理的地方,不过在大部分情况下,这部分处理很快。如果在全局性 Dictionary 数组里发现小写单词且拼写正确,则我们可立即返回。

如果该单词未出现于单词列表中,即可能是拼写异常。但如果用户要求截去后缀,那么我们就得再做点什么。strip_suffixes() 函数的功能,即用以产生一个或多个相关单词列表,并存储为本地 wordlist 数组的索引。for 循环接着会处理这个列表,如果它发现 Dictionary 数组里有这些单词,则返回。

如果无须截去后缀,或我们未于字典中发现任何替换单词,则该单词确定就是拼写异常了。此时将其写到输出报告并非好的做法,因为我们通常会想要一份排序后没有重复的拼写异常列表。例如 awk 这个单词在本章已出现超过 30 次,但在所有标准 UNIX 拼写字典里都找不到它。因此我们将这个单词存储到全局性 Exception 数组中,当用户要求以冗长模式输出结果时,我们可以在该单词前置一个位置,其由一个冒号终结的文件名与行编号所定义。这种报告的形式在许多 UNIX 工具里很常见,也易于让人们与聪明的文字编辑程序迅速了解。需留意的一点是:虽然字母大小写在字典查找作业里被忽略,但在此报告中会保留原始字母的大小写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function spell_check_word(word,    key, lc_word, location, w, wordlist)
{
    lc_word = tolower(word)

    if (lc_word in Dictionary)    # 可接受的拼写
        return
    else    # 可能的异常
    {
        if (Strip)
        {
            strip_suffixes(lc_word, wordlist)
            for (w in wordlist)
                if (w in Dictionary)
                    return
        }

        location = Verbose ? (FILENAME ":" FNR ":") : ""
        if (lc_word in Exception)
            Exception[lc_word] = location word
    }
}

12.4.11 strip_suffixes()

发现不在字典里的单词,且指定 -strip 选项时,我们会调用 strip_suffixes() 应用后缀规则。其循环是以递减的后缀长度,依次处理后缀正则表达式。如果单词匹配,则后缀会被删除,以便取得根单词。如果无替换的后缀,则单词将存储为 wordlist 数组的索引。否则,我们会将替换列表切分为各个数组成员,并依次附加每个替换到根单词中,将它增加到 wordlist 数组。我们还需在内部循环里处理一个特殊情况,检查是否有特殊的双字符(two-character)字符串 “",这里我们会以空字符串取代。一旦匹配成功,break 语句即离开循环,且函数会将其返回给调用者。否则,循环会继续下一个后缀正则表达式。

我们可以让这个函数针对 wordlist 里存储的单词进行字典查找。我们不这么做是因为这样会将查找与后缀处理混在一起,且程序将很难扩展以显示替换的候选单词(UNIX 的 spell 提供 -x 选项完成此工作:处理每一个可采用后缀的输入单词,它会产生具有相同根的正确拼音单词列表)。

虽然后缀规则足以应付许多印欧语系,但其他语言则完全不需要,更有另外一些语言在单词拼法上需作更复杂的改动,不管是在文法的格、数词或是时态上。对这类语言,最简单的方式似乎就是提供更充分的字典了。

此处为代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function strip_suffixes(word, wordlist,    ending, k, n, regexp)
{
    split("", wordlist)
    for (k = 1; k <= NOrderedSuffix; k++)
    {
        regexp = OrderedSuffix[k]
        if (match(word, regexp))
        {
            word = substr(word, 1, RSTART - 1)
            if (Replacement[regexp] == "")
                wordlist[word] = 1
            else
            {
                split(Replacement[regexp], ending)
                for (n in ending)
                {
                    if (ending[n] == "\"\"")
                        ending[n] = ""
                    wordlist[word ending[n]] = 1
                }
            }
            break
        }
    }
}

12.4.12 report_exceptions()

程序最后的任务,会从最后三组模式/操作开始。report_exceptions() 建立带有命令行选项的 sort 管道处理,视用户是否要求具有唯一性异常单词的完整列表,或报告是否需要带有位置信息的冗长模式。无论是哪一种情况,我们提供了 -f 选项予 sort,令其忽略大小写,还有 -u 选项,取得具唯一性(不重复)的输出行。简单的 for 循环将异常列表输出至管道,最后的 close() 会关闭管道且完成程序。

这是代码:

1
2
3
4
5
6
7
8
function report_exceptions(    key, sortpipe)
{
    sortpipe = Verbose ? "sort -f -t: -u -k1,1 -k2n,2 -k3" : \
                         "sort -f -u -k1"
    for (key in Exception)
        print Exception[key] | sortpipe
    close(sortpipe)
}

例 12-4 包含了完整的代码,完成我们的拼写检查程序。

例 12-4:拼写检查程序

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# 实例简单的拼写检查程序,搭配用户可指定的异常列表。
# 内置字典是以标准的 UNIX 拼写字典列表构建。
# 不过可以在命令行上覆盖它。
#
#
#
#
# awk [-v Dictionaries="sysdict1 sysdict2 ..."] -f spell.awk -- \
#     [=suffixfile1 =suffixfile2 ...] [+dict1 +dict2 ...] \
#     [-strip] [-verbose] [file(s)]
#

BEGIN {initialize()}

{ spell_check_line() }

END {report_exceptions()}

function get_dictionaries(    files, key)
{
    if ((Dictionaries == "") && ("DICTIONARIES" in ENVIRON))
        Dictionaries = ENVIRON["DICTIONARIES"]

    if (Dictionaries == "")    # 使用默认目录列表
    {
        DictionaryFiles["/usr/dict/words"]++
        DictionaryFiles["/usr/local/share/dict/words.knuth"]++
    }
    else    # 使用来自命令行的系统目录
    {
        split(Dictionaries, files)
        for (key in files)
            DictionaryFiles[files[key]]++
    }
}

function initialize()
{
    NonWordChars = "[" \
        "!" \
        "ABCDEFGHIJKLMNOPQRSTUVWXYZ" \
        "abcdefghijklmnopqrstuvwxyz" \
        "\241\242\243\244\245\246\247\250\251\252\253\254\255\256\257" \
        "\260\261\262\263\264\265\266\267\270\271\272\273\274\275\276\277" \
        "\300\301\302\303\304\305\306\307\310\311\312\313\314\315\316\317" \
        "\320\321\322\323\324\325\326\327\330\331\332\333\334\335\336\337" \
        "\340\341\342\343\344\345\346\347\350\351\352\353\354\355\356\357" \
        "\360\361\362\363\364\365\366\367\370\371\372\373\374\375\376\377" \
        "]"

    get_dictionaries()
    scan_options()
    load_dictionaries()
    load_suffixes()
    order_suffixes()
}

function load_dictionaries(    file, word)
{
    for (file in DictionaryFiles)
    {
        while ((getline word < file) > 0)
            Dictionary[tolower(word)]++
        close(file)
    }
}

function load_suffixes(    file, k, line, n, parts)
{
    if (NSuffixFiles > 0)    # 自文件载入后缀正则表达式
    {
        for (file in SuffixFiles)
        {
            while ((getline line < file) > 0)
            {
                sub(" *#.*$", "", line)    # 截去注释
                sub("^[ \t]+", "", line)    # 截去前置空白字符
                sub("[ \t]+$", "", line)    # 截去结尾空白字符
                if (line == "")
                    continue
                n = split(line, parts)
                Suffixes[parts[1]]++
                Replacement[parts[1]] = parts[2]
                for (k = 3; k <= n; k++)
                    Replacement[parts[1]] = Replacement[parts[1]] " " \
                        parts[k]
            }
            close(file)
        }
    }
    else    # 载入英文后缀正则表达式的默认表格
    {
        split("'$ 's$ ed$ edly$ es$ ing$ ingly$ ly$ s$ ", parts)
        for (k in parts)
        {
            Suffixes[parts[k]] = 1
            Replacement[parts[k]] = ""
        }
    }
}

function order_suffixes(    i, j, key)
{
    # 以递减的长度排列后缀
    NOrderedSuffix = 0
    for (key in Suffixes)
        OrderedSuffix[++NOrderedSuffix] = key

    for (i = 1; i < NOrderedSuffix; i++)
        for (j = i + 1; j <= NOrderedSuffix; j++)
            if (length(OrderedSuffix[i]) < length(OrderedSuffix[j]))
                swap(OrderedSuffix, i, j)
}

function report_exceptions(    key, sortpipe)
{
    sortpipe = Verbose ? "sort -f -t: -u -k1,1 -k2n,2 -k3" : \
                         "sort -f -u -k1"
    for (key in Exception)
        print Exception[key] | sortpipe
    close(sortpipe)
}

function scan_options(    k)
{
    for (k = 1; k < ARGC; k++)
    {
        if (ARGV[k] == "-strip")
        {
            ARGV[k] = ""
            Strip = 1
        }
        else if (ARGV[k] == "-verbose")
        {
            ARGV[k] = ""
            Verbose = 1
        }
        else if (ARGV[k] ~ /^=/)    # 后缀文件
        {
            NsuffixFiles++
            SuffixFiles[substr(ARGV[k], 2)]++
            ARGV[k] = ""
        }
    }

    # 如果 ARGV 的结尾有一个空字符串,则减少 ARGC
    while ((ARGC > 0) && (ARGV[ARGC-1] == ""))
        ARGC--
}

function spell_check_line(    k, word)
{
    gsub(NonWordChars, "")    # 消除非单词字符
    for (k = 1; k <= NF; k++)
    {
        word = $k
        sub("^'+", "", word)    # 截去开头的撇号字符
        sub("'+$", "", word)    # 截去结尾的撇号字符
        if (word != "")
            spell_check_word(word)
    }
}

function spell_check_word(word,    key, lc_word, location, w, wordlist)
{
    lc_word = tolower(word)

    if (lc_word in Dictionary)    # 可接受的拼写
        return
    else    # 可能的异常
    {
        if (Strip)
        {
            strip_suffixes(lc_word, wordlist)
            for (w in wordlist)
                if (w in Dictionary)
                    return
        }

        location = Verbose ? (FILENAME ":" FNR ":") : ""
        if (lc_word in Exception)
            Exception[lc_word] = location word
    }
}

function strip_suffixes(word, wordlist,    ending, k, n, regexp)
{
    split("", wordlist)
    for (k = 1; k <= NOrderedSuffix; k++)
    {
        regexp = OrderedSuffix[k]
        if (match(word, regexp))
        {
            word = substr(word, 1, RSTART - 1)
            if (Replacement[regexp] == "")
                wordlist[word] = 1
            else
            {
                split(Replacement[regexp], ending)
                for (n in ending)
                {
                    if (ending[n] == "\"\"")
                        ending[n] = ""
                    wordlist[word ending[n]] = 1
                }
            }
            break
        }
    }
}

function swap(a, i, j,    temp)
{
    temp = a[i]
    a[i] = a[j]
    a[j] = temp
}

12.4.13 回顾拼写检查程序

UNIX 拼写检查程序第一版所使用的就是我们在本章一开始所呈现的管道处理。在文件 The UNIX Heritage Society(注 7)中,可以找到第一版以 C 写成的 UNIX 拼写程序,此即为 1975 Version 6 UNIX 的 typo 命令,约为 350 行的 C 代码。spell 首次出现是在 1979 Version 7 UNIX 版本,大约 700 行的 C 代码。spell 在 1995 4.4 BSD-Lite 的源代码版本中遭删除,推测可能是因为商业机密或者版权上的问题。

现代的 OpenBSD spell 约有 1100 行 C 代码,在它的三个基本字典中各 30 多个单词。

GNU ispell 版本 3.2 约为 13500 行的 C 代码,及 GNU 的 aspell 版本 0.60 则约为 29500 行的 C++ 与 C 代码。这两个程序都已国际化,都附有 10 至 40 种语言的字典。ispell 拥有相当大的英文字典,约 80000 个一般单词,搭配 3750 个左右美语与英语的变化体。aspell 字典就更大了:142000 个英文单词,辅以 4200 个来自美国、英国与加拿大语系的变体。

我们的拼写检查程序 spell.awk 是一个真正卓越的程序,你会觉得幸好有它,如果你重新以其他程序语言编写这样的程序,就会了解 awk 真的比较好。就如同 Johnson 在 1975 年的原始 spell 命令,我们的设计与实例不到一个下午就完成。

约 190 行代码,搭配三组模式/操作的单命令行程序与 11 个函数,它能做到传统 UNIX 的 spell 能做的所有事,甚至更多:

  • 具有 -verbose 选项,程序会报告拼写异常的位置信息。
  • 用户可控制字典,让程序可立即应用于复杂的技术文件及以英文以外的语言所写成的文章上。
  • 用户可定义后缀列表,有助于拼写检查国际化,以及提供用户控制后缀缩减,就像所有平台都提供的一些拼写检查程序一样。
  • 所有相关联的字典与后缀文件都为简单的文本文件,可使用任何文本编辑器修改,且大部分 UNIX 文本工具都能处理。部分拼写检查程序仍保留二进制形式的字典文件,这会使单词列表难以检阅、维护与更新,也很难再用作其他目的。
  • 主要依赖字符集的是,这是低于 128 以下的 ASCII 顺序的初始化假设。尽管不再支持 IBM 大型主机 EBCDIC,European 8-bit 字符集也不会带来什么问题,甚至以多字节 UTF-8 编码的两百万字符 Unicode 字集也可适当处理它,但要确切认知与删除非 ASCII Unicode 的标点符号仍需多费心思。由于多字节字符集的复杂性,且可能在任何地方都需要用到,这部分的功能应另以独立工具实现,作为使用 spell.awk 前的预先过滤程序。
  • 输出排序的顺序,对某些语言来说是个复杂议题,这个操作完全由 sort 命令决定,而该命令又受当前环境的 locale 设置所影响。最好的情况就是:某个独立工具本地化处理排序的复杂问题,这么一来其他软件,包括我们的程序,便无须理会这个争议了。这也就是我们在 1.2 节里所说的:“让别人去做困难的部分”。
  • 虽然是以解释式语言编写,我们的程序算是相当快的了。在 2GHz Pentium 4 的工作站上,使用 mawk,它只需要一秒便能检查本书所有文件的拼写,时间仅为 OpenBSD 的 spell 的 1.3 倍及 GNU ispell 的 2.0 倍。

以执行上的概况来看(见 12.4.14 节),载入字典需花费总时间的 5%,而每 15 个单词就有一个在字典上找不到。加入 -strip 选项会增加约 25% 的执行期及减少相同量的输出大小。每 70 个单词里有一个通过 strip_suffixes() 里的 match() 测试。

后缀支持在这 190 行的代码里约占去 90,所以我们可以说,写这个方便好用的多语言拼写检查程序大约只用了 100 行的 awk 代码。

上述特性列表以及我们的程序,最明显缺乏的功能就是截去文件标记(markup),有些拼写检查提供此功能。我们故意不这么做,是因为它完全违背了 UNIX 的传统:一个(小)工具只做一件事。标记删除其实是很有用的,所以值得另写一个独立的过滤程序,例如 dehtml、deroff、desgml、detex 以及 dexml。这之中,只有 deroff 在大部分 UNIX 系统下找得到,不过其他的实现也只要几行 awk 就办得到。

撇开三个简单的 substr() 调用不谈,我们的程序另缺的就是独立字符的处理。在 C 里这类处理的需求,很多其他语言也是,正是主要的 bug 来源。

该程序只剩这些事待完成:累积适当数量的字典集与其他语言的后缀列表,通过 Shell 脚本包装(wrapper)的提供,让它的用户界面看起来更像传统的 UNIX 程序,还有编写手册页。虽然我们在这里没有将它们呈现出来,不过本书范例程序已提供包装程序与手册页。

12.4.14 awk 程序的性能

我们以几个与 awk 程序性能有关的评论作总结。awk 程序就像其他脚本语言,先被编译为简洁的内部表示,再将该表示在执行期时,通过小型虚拟机器(virtual machine)解释。内置函数是以底层实现语言写成,现行的 C 为通用版本,执行速度等同于本地软件的速度。

程序的性能不单单指计算机上的时间,人们花在上头的时间也算。如果是花一个小时,以 awk 写一个只要执行几秒的程序,相对于以编译语言花数个小时编写、调试所写成的相同程序,结果只是少几秒的执行期,那么人们花在上头的时间就是性能的重点考虑了。对许多软件工具而言,awk 赢在它有大量的手段与方法足以完成任务。

传统像是 Fortran 与 C 这类的编译语言,内层代码会与底层机器语言息息相关,程序设计老手们马上感觉得到孰优孰劣。算法与内存操作的数量、循环嵌套设计有多深,都为重点,且易于计算,并与执行期直接相关。以数字方面的程序为例,一般法则是代码的 10%,耗费 90% 的执行期:而这 10% 的代码就称为热点(hot spot)。像是将最内部循环的常用表达式拉出来,还有重新排列运算以符合存储配置等,这些最佳化的操作,有时可大大促进执行时间。然而,高级语言、使用大量函数调用的语言(例如 Lisp,它的每条语句都是函数)或解释式语言,它们的执行期都较难以估算,也很难识别出它们的热点。

做许多模式匹配的 awk 程序,通常也被该运算的复杂度所限制,其完全是以原始的速度执行。这类程序很少能够通过编译程序,像 C 或 C++ 的重写,再做点什么改善。我们所提及的三种 awk 实例,都各自独立编写而成,且对于特定语句,也有完全不同的执行时间。

由于我们使用 awk 写了很多软件工具,有部分用以处理动辄以 GB 计的数据,执行期性能对我们来说有时是相当重要的。几年前,我们之中有人(NHFB)想做的是 pawk(注 8),它其实是最小型的实例,nawk 的探测版。pawk 会报告语句计数与时间。我们其他人(AR),也自动自发将类似的语句计数支持加入到 GNU 的 gawk,因此,pgawk 已自 3.1.0 版开始成为标准配备。pgawk 会产生输出探测(profile)至 awkprof.out,再搭配带有语句执行计数评注的程序列表。计数很快便会识别出热点。计数为零(或空)即表示代码完全未执行,所以输出探测还可以当作测试涵盖范围(test coverage)的报告。当测试文件是用以验证所有程序语句是否在检测期间都被执行时,这样的报告就很重要了:潜藏于代码中的 bug 可能很少或甚至从未执行过。

精准的执行时间是很难取得的,因为典型的 CPU 计时器(timer)仅能得到每秒 60 到 100 次的核对,这在 GHz 处理器的时代完全不够用。幸好,已有部分 UNIX 系统提供低成本的十亿分之一秒解析计时器,而 pawk 在这些平台上都使用它们。

12.5 小结

原始的拼写检查雏型,展现了 UNIX 软件工具应用的优雅与能力。只花一个下午的时间,就能做出这样一个方便又有用的单一目的程序。在体验过以 Shell 写成的雏型后,再以 C 重写一份在线版本也是常有的事。

私有字典的使用,是 UNIX spell 里一个强而有力的功能。虽然 UNIX 环境后台下的 locale 设置,很可能导出奇怪的行为模式,但字典的使用仍有其价值,事实上本书的各个章节,我们都建立了私有字典,以便管理拼写检查的工作。

可自由取用的 ispell 与 aspell 程序相当大,功能也很强,但却缺乏一些让批处理模式更好用的功能。我们展示过如何封装简单的 Shell 脚本,所以我们可以解决这样的不足,让程序更适于我们的需求。此是 Shell 脚本的最传统用法:取一个几乎能完成所有所需工作的程序,然后稍微修改它的结果,以完成剩下的工作。这也符合我们在软件工具设计原则里所说的:“让别人完成困难的部分”。

最后,awk 拼写检查程序完美展现了该语言的优雅与强大功能。就在某个午后,NHFB 完成了低于 200 行,可以(已经是)正式使用于拼写检查的程序。