使用 Raku 正则表达式和 Grammars 进行解析

on

1. 什么是正则表达式和 Grammar?

我们会接触到各种各样的结构化数据: 电话号码、电子邮件地址、邮政地址、信用卡号码等等。

正则表达式是一种声明性的编程结构, 用来描述这些数据格式。正则表达式可以让你搜索数据, 确保输入的数据确实是真实的格式, 甚至可以提取相关的组件, 如邮政地址的邮政编码, 或日志文件条目中的时间戳。

当你需要读取和验证更复杂的结构时, 比如程序设计语言, 或者像 XML 这样的标记语言, 你可以把多个正则表达式组合成 grammar。Grammar 的作用不仅仅是简单地组合正则表达式。它们还提供生成良好的错误信息和在分析输入文本时跟踪状态的基础设施。

1.1. 使用场景

1.1.1. 搜索

正则表达式的一个常见用途是在大量数据中搜索感兴趣的模式, 例如在日志文件中查找某些消息, 在 URL 中查找特定的信息, 或者在文本中查找电话号码。在写这篇文章的时候, 我的 .bash_history 中大约有 6% 的条目涉及到使用正则表达式进行搜索。

regular_expressions

许多命令行工具都支持某些正则表达式方言, 允许你搜索文件名、文件内容、日志、捕获的网络流量以及几乎所有你能想到的东西。大多数现代编程语言也可以很容易地使用正则表达式, 使其成为一种无处不在、不可或缺的搜索工具。

1.1.2. 验证

大多数应用程序都面临着不可信的用户输入。特别是 Web 应用程序面临着大量的不可信输入。这些输入必须在应用任何进一步的逻辑或将其存储在例如数据库中之前进行验证。

正则表达式是验证的第一步。他们使检查数字等简单的事情变得简单, 并验证输入的最小长度和最大长度。同时, 它们允许程序员进行更精确、更复杂的检查。

Web 应用程序员只需要提供一个正则表达式, 并将其与输入字段关联。然后, Web 框架可以根据所有已配置的正则表达式来验证表单输入, 并自动为终端用户生成错误信息, 这样 Web 应用程序员就不需要处理拒绝输入和重新生成表单的工作流程。

1.1.3. 解析

单独使用正则表达式不太适合解析复杂的输入数据。然而, Raku 添加了功能, 使其非常适合这项任务。这些扩展包括易于使用的回溯控制和通过命名正则表达式获得的可组合性。

一个成功的正则表达式匹配, 其结果是一个匹配对象, 它包含了所有必要的元数据, 用于从解析后的文本中提取出感兴趣的部分。 还有一些功能, 可以很容易地将匹配对象转换为抽象语法树或 AST, 一种适合在解析器之外使用的数据结构。

1.2. 正则或正则表达式

正则表达式的理论基础来自于计算机科学, 它描述了一种形式语言和自动机或形式机的层次结构, 可以识别这些语言。这些语言中, 最有限的一种语言被称为正则语言[1]决定特定字符串是否使用常规语言需要固定的内存量和每个固定数量的计算步骤字符。

正则表达式是一种编写正则语言的形式。随着从理论计算机科学中这些概念的发展, 它们是简约的, 只允许字面量, 替换(|), 括号进行分组, 以及Kleene star[2](*)表示零次或多次重复。

早期的文本处理工具, 如 grep, sedawk 都接受了正则表达式的概念, 并加入了许多方便的功能, 比如可以编写 [az] 而不是 a|b|c|d|e …​.。他们提供了预定义的*字符类*、字符集、例如字母、数字、空白字符等等。他们还增加了捕获功能, 帮助提取正则表达式匹配的特定部分的字符串。

后来的实现增加了一些超出正则语言所允许的功能, 因此需要一个单独的单词。这些实现也是为了便于使用而不是理论构造的极简主义而进行优化。现在, 我们在谈论到编程语言和库中的实用(而且更强大的)实现时, 倾向于使用正则表达式。

1.3. Raku 正则表达式有什么特别之处

为了继续上一节的历史课程, Perl 是最早将正则表达式纳入其核心功能的通用编程语言之一。它从早期的正则表达式实现中借鉴了语法, 并对其进行了扩展, 使正则表达式变得更加强大和有用。很快, Perl 的特殊版本的正则表达式就成为了事实上的标准。所以很多名为兼容 Perl 的正则表达式(PCRE)的库被创建出来, 这样其他软件可以在他们自己的实现中使用 "Perl 正则表达式"。

遗憾的是, 在使正则表达式如此有用的过程中, Perl 给几乎所有的 ASCII 字符都赋予了特殊的的含义(除了按字面匹配的字符)。并且, 随着更多新的、更强大的正则表达式功能被创造出来, 这就导致了在继续保持与现有的正则表达式语法向后兼容的同时, 还需要使用一些晦涩的字符序列来实现新的功能。(?⇐pattern) 就是一个很好的例子。

Raku 正则表达式清理了这种历史上的语法包袱。它们通过允许无处不在的空白, 引入了关于哪些字符是特殊字符, 哪些不是特殊字符的规则, 以及最重要的是, 它们有一个简单、可扩展的语法, 可以通过名字来调用其他正则表达式, 从而提高来可读性。

虽然大多数语言将正则表达式视为字符串或特殊对象, 但是 Raku 正则表达式是代码; 当正则表达式在 grammar 中组合在一起时, 就像方法一样。这让你可以自由地将所有你在编程语言中习惯你的管理和重用代码的技术应用到正则表达式中: 命名空间、类、角色[3]、继承等等。

组合正则表达式的能力使得 Raku 不仅仅能解析简单的字符串格式。你可以写出使用许多小的正则表达式来解析复杂的文件格式的 grammar。实际上, Rakudo 编译器本身就使用了 grammar 来解析 Raku 源代码。

2. Raku 入门

通过阅读本书, 你可能会捡起一些东西, 了解一些基本的概念; 但如果你的目标是流畅和更深层次的理解, 你应该自己运行这些例子, 修改它们, 并进行试验。

为此, 你首先需要安装 Raku 编译器, 版本为 2017.05 或更新版本。之后, 我们将讨论如何使用它进行正则表达式实验。

如果你不愿在计算机上安装软件, 也可以使用一个在线服务来运行你的代码。在写这篇文章的时候, https://glot.io/new/rakuhttps://tio.run/#raku 支持在浏览器中运行 Raku 代码。你也可以在 https://raku.org/resources/ 上查看类似服务的最新列表。

2.1. 安装 Raku

Raku 编译器有两种: 编译器本身和 Rakudo Star。后者是一个包含编译器、zef 模块安装程序、文档和一些模块的发行版。

出于我们的目的, 你需要编译器和 zef。安装 Rakudo Star 为你提供了这两样东西, 但如果 Rakudo Star 安装程序不适合你, 或者你更喜欢更精简的安装, 你可以只安装编译器, 并根据文档安装 zef[4]

以下是安装 Raku 的一些选项。

2.1.1. 使用原生方式安装 Rakudo Star

Rakudo Star 下载页面 http://rakudo.org/downloads/star/ 为 Windows 和 Mac OS 提供了二进制安装程序。你只需打开下载文件即可安装它们。

2.1.2. 二进制 Linux 包

Rakudo OS Packages[5] 仓库包含如何在 CentOS、Debian、Fedora 和 Ubuntu 系统上获取和使用 Raku 软件包的说明。它们带有编译器和一个用来安装 zef 的脚本; 对于我们的目的来说已经足够了。

2.1.3. 基于 Docker 的安装

在支持 Docker 的平台上, 你可以通过一条命令获得一个预构建的轻量级镜像, 其中包含 Raku 编译器以及 zef:

$ docker pull moritzlenz/raku-regex-alpine

这个 Docker 镜像包含 Raku 以及一些模块, 这些模块可以让你更容易地使用正则表达式和 grammar。

一旦你拉取了这个镜像, 你就可以用它来执行单行程序, 具体方法如下:

$ docker run -it moritzlenz/raku-regex-alpine -e 'say "hi"'

由于 Docker 容器在自己隔离的世界中运行, 所以你需要采取额外步骤来使容器中的脚本文件可用。例如, 如果你想执行一个脚本 search.p6, 你可以像这样运行它:

$ docker run -it -v $PWD:/raku -w /raku \
     moritzlenz/raku-regex-alpine search.p6

这很不方便, 所以用 bash 别名(或 shell 脚本)可以帮助你:

$ alias p6d="docker run -it -v $PWD:/raku -w /raku
     moritzlenz/raku-regex-alpine"

在这之后, 执行脚本就变得简单多了:

$ p6d search.p6

一般情况下, 本书假定存在 raku 可执行文件。如果你使用 docker 镜像, 那么在所有命令中用 p6d 替换 raku。

2.2. 使用 Raku

你可以通过运行 raku --version 来验证你的 Raku 安装是否正常, 应该打印出类似这样的东西:

This is Rakudo version 2017.05-315-g160de7e built on MoarVM version 2017.05-25-g62bc54e
implementing Raku.c.

如果你自己搞不定, 可以向 Raku 社区[6]寻求帮助。

一旦成功了, 你可以通过运行不带参数的 raku 启动一个简单的交互式 shell:

$ raku
To exit type 'exit' or '^D'
> say "Hello, world";
Hello, world
> exit

在这个 shell 中, 你可以输入几行 Raku 代码, 这些代码会立即被执行。这对于测试正则表达式非常有用:

> say "Hello, world" ~~ / \w+ /
⌜Hello⌟

(如果你还不明白这里到底发生了什么, 不要担心; 下一章会详细解释。)

然而如果提示符返回这样的东西:

$ raku
You may want to `zef install Readline` or `zef install
Linenoise` or use rlwrap for a line editor.
To exit type 'exit' or '^D'
>

你应该按照问候信息中的建议, 安装它提到的一个模块。这样你就能访问和编辑你的命令的历史记录了:

$ zef install Linenoise
===> Searching for: Linenoise
===> Searching for missing dependencies: LibraryMake
...
===> Installing: Linenoise:ver('0.1.1'):auth('Rob Hoelz')

当然, 你也可以使用 raku 来执行脚本文件, 只需在命令行中添加脚本文件即可:

$ raku greet.p6
Hello, world

假设你在当前工作目录下有一个文件 greet.p6, 其中包含了 say "Hello, world";

2.3. 获取代码示例

本书中使用的代码示例的源代码可以在 GitHub 上找到:

$ git clone https://github.com/apress/perl-6-regexes-and-grammars.git

如果你没有 git, 你也可以从 https://github.com/apress/perl-6-regexes-and-grammars/archive/master.zip 下载一个包含源代码的压缩包。

我鼓励你运行这些示例脚本, 修改并运行它们。这是比单纯阅读本书更深入学习这些主题的最好方法。

2.4. 使用 Raku 的第一步

为了有效地使用 Raku 中是正则表达式, 你需要了解一下他们所嵌入的语言。

Raku 是一门自由形式的语言, 所以你可以随意缩进你的代码。然而, 有些情况下, 是否有空格是很重要的: doit(1, 2, 3) 调用带有 3 个参数的函数 doit, 而 doit (1, 2, 3) 只用单个参数来调用同一个函数, 这个参数是一个 3 个值的列表。

语句之间用分号(;)字符隔开, 你也可以在程序的最后一行放一个分号。注释以井号字符(#) 开头, 并一直延伸到行尾:

say "Hello, World";         # Output: Hello, World
say( 2 * 21 );              # Output: 42

2.4.1. 变量和值

变量是用关键字 my 来声明的, 并以 sigil 符号开头, 这告诉我们一些关于变量的一般类型:

my $x = 1;
my @capitals = 'Algiers', 'Tirana', 'Berlin', 'Tokio';
my %populations =
      Algiers => 3_500_000,
      Berlin  => 3_700_000,
      Tirana  => 353_400,
      ;

$ 代表标量变量 - 通常是持有单一值的变量。作比之下, @ 表示数组, 一个线性的值的集合, 可以通过一个从零开始的索引来访问元素。在上一个代码块的上下文中, @capitals[0] 返回 'Algiers'。

哈希(其他语言称之为字典或映射)将键(字符串)与值关联起来。你可以通过 {} 索引操作符来查找一个键的值, 并使用几个有用的方法:

say %populations{'Algiers'};    # Output: 3500000
say %populations.keys.sort;     # Output: (Algiers Berlin Tirana)
say %populations.values.sum;    # Output: 7553400

2.4.2. 字符串

由于正则表达式作用在字符串上, 我们就应该多说说它们。

字符串是由 Unicode 字符组成的序列。[7]我们已经看到了一些由单引号 '' 和双引号 "" 包围的字符串的示例。 主要区别在于, 双引号可以实现转义序列, 如 \n 是换行符(换行)和变量插值, 即用变量的值替换变量名:

my $name = 'Larry';
say "Hello, $name";         # Output: Hello, Larry
say 'Hello, $name';         # Output: Hello, $name

然而, 即使在单引号内, 也可以用反斜线转义单引号来插入单引号。出于同样的原因, 在这样的字符串中必须使用双倍的反斜线:

say 'a quote: \' a backslash: \\';
# Output: a quote: ' a backslash: \

Here-documents 提供了一种编写多行字符串的简洁方法:

my $macbeth = q:to/END/;       # need to put the ; on this line!
When shall we three meet again?
In thunder, lightning, or in rain?
    When the hurlyburly's done,
    When the battle's lost and won.
END
# normal Raku code resumes here

你可以选择自己的分隔符, 但习惯上将其全部大写, 以便更容易找到 here-document 的结束位置。

2.4.3. 控制结构

像循环和分支这样的控制结构都遵循相同的基本模式, KEYWORD EXPRESSION { BLOCK }, 而且 if 结构也允许附加 elsifelse 语句:

for 1, 2, 3 {
    say $_;
}
if 1 > 2 {
    say "No Way";
}
elsif 1 == 2 {
    say "Still no chance";
}
else {
    say "This runs";
}

这个代码产生如下输出:

1
2
3
This runs

for 循环块内, 当前值存储在特殊变量 $_ 中。如果你想使用不同的变量, 可以使用下面的语法, 这就是所谓的尖号块:

for 1, 2, 3 -> $value {
    say $value;
}

这在任何可以使用一个块的位置都可以使用, 也可以扩展到多个参数:

my $callback = -> $x, $y { $x + $y };
say $callback(1, 2);                    # Output: 3

2.4.4. 函数、类和方法

函数或子程序是一段带有形式参数列表以及可选的返回值的代码:

sub double($x) {
    return 2 * $x;
}
say double(2);         # Output: 4

如果没有运行 return 语句, 那么返回值是最后一个表达式的值 , 所以我们可以把它写成 sub double($x) { 2 * $x }

你可以选择为参数(和变量)声明一个类型:

sub double(Numeric $x) { 2 * $x }

类可以有每个对象的存储, 称为属性, 而代码是附加到对象上的方法:

class Point {
    has $.x;
    has $.y;
    method magnitude() {
        return sqrt($.x * $.x + $.y * $.y);
    }
}
my $p = Point.new( x => 5, y => 2 );
say $p.x;                            # Output: 5
say $p.magnitude();                  # Output: 5.3851648071345

2.4.5. 了解更多关于Raku

有很多资源可以深入学习 Raku。

最后但并非最不重要的是, Raku 的官方文档 https://docs.raku.org 提供了介绍性和参考资料, 可以让你搜索内置的类型、函数、方法和操作符。

2.5. 总结

我们已经看到了几种安装 Raku 编译器的方法。 安装完毕之后, 就可以通过运行不带参数的 raku 启动一个交互式的 Raku shell, 或者 raku script.p6 来执行 Raku 程序。

我们还探索了我们的第一个简单的 Raku 程序。接下来我们将直接开始编写我们的第一个正则表达式。

3. 正则表达式构造块

说了这么多, 准备了这么多, 是时候看一些实际的正则表达式了。

3.1. 字面量

正则表达式中最简单的元素是字面量: 即完全匹配自己的字符串。例如, 正则表达式 /perl/ 匹配任何包含 p,e,r 和 l 字符的字符串, 它们的顺序正好是这样的。因此, 字符串 "properly" 从第四个字符开始匹配此正则表达式(properly), 但字符串 "superficial" 不匹配, 因为它在 per 和 l 之间包含了其他字符(ficia)。

仅由字母, 下划线 _ 和数字组成的字面量不需要任何特殊的语法。只需将字母和数字写为正则表达式的一部分写成字面量即可。其他字面量必须用引号括起来, 要么使用单引号要么使用双引号: /'2016-12-24'//"2016-12-24"/ 类似, 两者都与字符串 2016-12-24 匹配。由于这两个破折号既不是字母也不是数字, 所以需要成为引号字符串的一部分, 才能从字面上匹配。单引号和双引号对儿在插值方面的行为有所不同: 变量和花括号 {…​} 分隔的代码块在双引号中被插值, 所以下面的正则表达式都匹配字符串 "3":

$_ = 3; # the string to be matched against
my $x = 3;
say "yes" if /"$x"/;      # Output: yes
say "yes" if /"{1 + 2}"/; # Output: yes

而正则表达式 /'{1 + 2}'/ 只匹配字面字符串 {1 + 2}

在 Raku 正则表达式中, 默认情况下会忽略空格, 所以 /perl/, /pe rl/, 和 / p e r l/ 都是等价的(尽管后两个产生一个警告), 而且匹配的字符串完全相同。在引号里面, 空格是重要的, 所以 /"pe rl"/ 与字符串 properly 不匹配, 因为该字符串中没有空格字符, 但引号内的字符串有。

3.2. 元字符与字面量

正如我们在上一节中看到的那样, 正则表达式中的字母数字字符与它们自身匹配。所有其他字符都可能有一些特殊的含义。 我们已经看到 '" 是特殊的: 它们包围着被引用的字符串。

我们称这种"特殊"字符为元语法或元字符。 反斜线 \ 使字面量字符变得容易, 反之亦然。例如, /d/ 匹配字面的 d, 但 /\d/ 可以匹配单个数字。另一方面, 正则表达式中的 + 修饰前一个字符, 但是 /\+/ 匹配的是一个加号, 也就是 +。唯一的例外是 # 字符, 它不能通过反斜线前缀来匹配字面意思, 必须用引号代替。

并非所有元字符都有特殊的语义含义。例如, 感叹号(!)没有元语义, 并且 Raku 产生一个适当的错误信息:

$ raku -e '/!/'
===SORRY!=== Error while compiling -e
Unrecognized regex metacharacter ! (must be quoted to match literally)

3.3. Anchors

正则表达式会搜索整个字符串以查找匹配项。有时这不是你想要的。锚点只在字符串中的某些位置进行匹配, 从而将正则表达式匹配锚定在该位置。

最常见的锚点是 ^$。它们分别匹配字符串的开头和结尾。正则表达式 /^go/ 匹配以字母 g 开头的字符串 , 例如 gown。相比之下, /go$/ 匹配所有以字母 o 结尾, 其前面由 g 开头的字符串, 例如 tango。 最后, 同时使用这两个锚点的正则表达式, /^go$/ 只匹配字符串 go。

由于空格通常被忽略, 我们可以将这些正则表达式写为 / ^ go /, / go $ / , 或 / ^ go $ /

但是 / g ^ // $ g / 这两个正则表达式无法匹配任何字符串, 因为没有一个字符串在其开始之前或结束之后都有一个字符。一般来说, 如果你写出这样的正则表达, Raku 不会发出警告或抱怨, 尽管未来的版本可能会产生警告。如果你需要一个永远不可能匹配的正则表达式, 可以使用 <!>, 它可以达到同样的效果, 但更加明确。

字符串可以由多行组成; 锚点 ^^$$ 分别匹配行的开头和结尾。$$ 锚的特殊之处在于, 它匹配换行符之前, 如果没有尾随换行符, 则匹配字符串的末尾:

# Start-of-line positions that ^^ matches:
"⏏ab\n⏏de"
"⏏ab\n⏏de\n"

# End-of-line positions that $$ matches:
"ab⏏\nde⏏"
"ab⏏\nde⏏\n"

锚点是零宽度的正则表达式元素。因此他们不会"消耗"输入字符串中的字符, 也就是说, 它们不会推进正则表达式引擎尝试匹配的当前位置。一个好的心理模型是, 它们在输入字符串的两个字符之间(或在输入字符串的第一个字符之前, 或在输入字符串的最后一个字符之后)进行匹配。

Raku 中内置了更多的锚(表 3-1)。

Table 1. Raku 正则表达式锚点。⏏ 标记锚点匹配的位置

锚点

描述

样例

^

字符串开头

"⏏some\nlines"

^^

行的结尾

"⏏some\n⏏lines"

$

字符串结尾

"some\nlines⏏"

$$

行的结尾

"some⏏\nlines⏏"

<<

左单词边界

"⏏some ⏏words"

«

左单词边界

"⏏some ⏏words"

>>

右单词边界

"some⏏ words⏏"

»

右单词边界

"some⏏ words⏏"

<?wb>

任意单词边界

"⏏some⏏ ⏏words⏏!!"

<!wb>

非单词边界

"s⏏o⏏m⏏e w⏏o⏏r⏏d⏏s!⏏!"

<?ww>

单词内

"s⏏o⏏m⏏e w⏏o⏏r⏏d⏏s!!"

<!ww>

非单词内

"⏏some⏏ ⏏words⏏!⏏!⏏"

单词边界是字母数字字符组和下划线(_)以及任何其他字符之间的边界。例如, 字符串 "some-words_here" 就包含这些单词边界: "⏏some⏏-⏏words_here⏏"。注意, 在这种情况下 _ 字符的周围没有边界。它被当作一个字符来处理。

3.4. 预定义字符类

如果你必须拼写出每个要匹配的字符, 那么正则表达式就非常受限了。而字符类则放宽了这一要求。例如, 点(.) 代表任何一个字符。

你可以使用它来解决填字游戏, 效果非常好。假设你正搜索一个五个字母的单词, 你知道第二个字母是 e, 最后两个是 rl。这几乎可以是任何字符串, 对吧?

一旦有了单词列表, 你就可以用正则表达式 / ^ .e.rl $ / 来测试每个单词, 这样可以大大减少需要考虑的单词数量。

有些 Linux 发行版会自带一个文件 /usr/share/dict/words, 其中包含一个英文单词表, 每行一个单词。搜索这样一个单词列表只需一个非常短的脚本:

for '/usr/share/dict/words'.IO.lines -> $word {
    say $word if lc($word) ~~ / ^ .e.rl $ /;
}

在我的系统(Ubuntu 16.04)上, 这只产生两行输出:

Pearl
pearl

它们只是在大小写上有区别, 所以对于填字游戏, 它们是一样的。

这个脚本是如何工作的? 代码 '/usr/share/dict/words'.IO 创建了一个 IO::Path 对象, 并在其上调用 .lines 方法返回文件中的一系列行。for 循环会在这些行上进行迭代, 这些行也正好是单词。

命令 say $word 打印单词, 然后跟着一个换行符, 但是只有满足 if 后缀的条件才打印。lc($word) 返回小写的 $word, 最后一行的其余部分检查小写单词是否与正则表达式 / ^ .e.rl $ / 匹配。

由于字符类非常有用, 所以 Raku 有很多字符类。有些字符类由反斜线和一个小写字母组成。在这些情况下, 大写版本是否定意义的(表 3-2)。

Table 2. Raku 正则表达式预定义字符类

字符类

描述

匹配样例

不匹配样例

.

任意字符

a, 4,

\d

数字

1, ٤

a,

\D

非数字

a,

1, ٤

\w

单词字符

a, 4,

+, /, " "

\W

非单词字符

+, /, " "

x, 4, ٤

\s

空白符

" ", "\t"

a, -, 4

\S

非空白符

a, -, 4

" ", "\t"

\n

逻辑换行符

"\n", "\c[LINE SEPARATOR]"

"\t",

\N

非换行符

"\t", +, a

"\n"

\h

水平空白

" ", "\t"

"\n", a, 4

\H

非水平空白

"\n", a, 4

" ", "\t"

所有这些分类都考虑了完整的 Unicode 字符序列; 所以 \d 不只是匹配 0 到 9 的数字, 还匹配所有脚标和变体的数字, 例如 ٤ - ARABIC-INDIC DIGIT FOUR。在支持 Unicode 9 版本的 Rakudo 2017.05 中, 有 580 个不同的字符与 \d 字符类匹配。

如果要找出所有匹配某个字符类的字符, 你可以迭代所有 Unicode 字符。下面是一个例子, 它列出了所有匹配 \n 的字符:

for 0..0x1FFFF -> $c {
    if chr($c) ~~ /\n/ {
        printf "U+%05X - %s\n", $c, $c.uniname
}

这会产生如下输出:

U+0000A - <control-000A>
U+0000B - <control-000B>
U+0000C - <control-000C>
U+0000D - <control-000D>
U+00085 - <control-0085>
U+02028 - LINE SEPARATOR
U+02029 - PARAGRAPH SEPARATOR

chr($c)[10] 将代码点编号 $c 转换为它背后的实际字符。printf[11] 是一个例程, 用于打印根据模板格式化的输出, uniname[12] 返回 Unicode 字符数据库中字符的名称。

3.4.1. 用户自定义字符类

你也可以指定自己的字符类, 而不是只依赖于 Raku 正则表达式中的字符类。最简单的方法是在 <[]> 之间枚举它们:

say 'perl' ~~ / <[aeiou]>/; # Output: ⌜e⌟

在字符类中, 非字母数字字符会失去其特殊含义, 所以 / <[ " ']> / 匹配单引号或双引号。例外的是空格, 它们被简单地忽略, 和结束方括号 ] (它结束字符类, 所以必须使用反斜线转义才能成为字符类的一部分)以及反斜线。要匹配开口或闭合方括号, 请使用正则表达式 / <[ [ \] ]> /

你可以在自定义字符类中包含预定义的字符类: / <[ \w $ - ]> / 匹配单个字符, 这个字符可以是一个单词字符(\w)、连字符或美元符号。

字符范围可以简化连续字符的列表。例如, / <[ 0..9 a..f A..F ]> 匹配十六进制字符, 换句话说, 它匹配这些字符中的任何一个: 0123456789abcdefABCDEF。

你可以通过在字符类前面添加一个减号( - )来否定字符类。因此, 要匹配除双引号之外的任何字符, 可以使用 / <-[ " ]> /。这是一个更通用语法的特例, 可以让你混合和匹配肯定和否定的字符类以及预定义的字符类:

/ <[\d]-[78]+[abc]> /

此正则表达式可以匹配除 7 和 8 之外的任何数字, 但它也匹配字符 a, b 和 c。

最后, 单个字符的反斜杠转义符在字符类中的作用就像在双引号字符串中那样, 所以 <[ \c[ CHARACTER TABULATION] x ]><[ \t x ]> 都匹配字母 x 或制表符。

3.4.2. Unicode 属性

Raku 还通过引入 Unicode 属性和类别[13]提供对字符类的访问。要在正则表达式中使用 Letter 属性, 你可以写 <:Letter> 以匹配作为字母的单个字符, 或 <:!Letter> 作为它的否定 - 也就是说, 一个不是字母的单个字符。其中一些属性有简短形式, 如 <:L> 表示 <:Letter>

这些属性不限于你目前正阅读的拉丁字母, 而是包括整个 Unicode 字符数据库。所以 <:Letter> 可能与希腊文, 西里尔文或希伯来文字母或来自任何具有字母概念的脚本的字母相匹配 (表 3-3)。

Table 3. 从通用类别中选出的 Unicode 属性

属性

短形式

例子

Letter

L

aΦЊ

Uppercase_Letter

Lu

Lowercase_Letter

Ll

xή

Mark

M

Number

N

83⁄4

Decimal_Number

Nd

8

Symbol

S

$÷϶

Math_Symbol

Sm

+±∄

Punctuation

P

!@_

3.5. 量词

到目前为止, 我们看到的正则表达式都匹配固定数量的字符。这种情况即将改变。量词控制其前面的正则表达式元素(原子)的匹配频次, 因此允许可选和重复的元素。

+ 量词匹配它前面的原子一次或多次。例如, / ^ a+ $ / 匹配字符串 a, aa, aaa, aaaa, 等等。

量词的绑定非常紧密, 比正则表达式元素的连接更紧密 , 因此 / ab+ / 匹配 ab, abb, abbb 等。你可以把它写成 / a [b+] / 以消除任何歧义。

如果你想匹配 ab, abab, ababab, 你可以使用引号或括号来强制 + 应用于多个字符: / [ab]+ // 'ab'+ / 都匹配这些字符串。前者比较通用, 因为它不仅适用于字面量, 也适用于其他的正则表达式。/ [\d+ ',']+ / 匹配一个数字列表, 每个数字后面跟一个逗号。

还有更多的量词, 如表 3-4 所示。

Table 4. Raku 正则表达式量词

量词

最少匹配

最多匹配

?

0

1

*

0

infinite

+

1

infinite

**4

4

** 4..20

4

20

* 4..

4

infinite

最一般的形式是 ** RANGE 量词, 其中 RANGE 可以是 MIN..MAX, 表示有上限的范围, 或者 MIN .. * 表示没有上限的重复:

/ ^ a ** 4 $ /;     # Matches exactly 4 a's
/ ^ a ** 2..4 $ /;  # Matches between 2 and 4 a's
/ ^ a ** 5..* $ /;  # Matches at least 5 a's

3.5.1. 贪婪和节俭量词

量词在默认情况下是贪婪的: 它们会尽可能多地匹配。

你可以通过对一个有多种可能匹配方式的字符串应用正则表达式来观察这种行为:

say "<a> b <c>" ~~ /"<" .+ ">"/; # Output: ⌜<a> b <c>⌟

这里的正则表达式与整个字符串匹配, 从第一个 < 到最后一个 >。如果这不是你想要的, 你可以在量词上添加一个问号(?)来控制量词的贪婪性:

say "<a> b <c>" ~~ /"<" .+? ">"/; # Output: ⌜<a>⌟

Raku 社区把这个版本的量词称为节俭量词, 尽管这个名字在一般的正则表达式文献中并不普遍。还有人称之为懒惰量词。

在一般量词的情况下, ? 出现在范围之前, 就像这样: **?1..5

3.5.2. 带分隔符的量词

解析由固定分隔符连接的列表项是一项常的见任务。你可以使用 / <thing> [<separator> <thing>]* / 或者如果允许尾随的分隔符, 则使用 / <thing> [<separator> <thing>]* <separator>? /。 如果要允许空列表, 则需要进行更多修改。

Raku 提供了一个快捷方式: /<thing>+ % <separator>/, 可以匹配一个由 <separator> 分隔的 <thing> 列表。如果允许尾随分隔符, 只需将 % 更改为 %% 即可。这适用于任何量词。因此, <thing>* % <separator> 也可以匹配空字符串。

例如, 用逗号分隔的数字列表可以被解析为:

say '1,24,5' ~~ / [\d+]* % ',' /; # Output: ⌜1,24,5⌟

当你把节俭量词与分隔符功能结合起来时, 请把节俭量词 ? 放在前面:

say '1,24,5' ~~ / [\d+]*? % ',' /; # Output: ⌜⌟

3.6. 析取

"Hi," "Hello," 和 "Hey" 可能都是英语中被普遍接受的问候语。 匹配这种问候语的正则表达式必须接受这三个选项中的任何一个:

/ Hi | Hello | Hey /

竖条 | 分割分词中的备选项或分支。在这个例子中, 这些分支不一定要像这个例子中的字面意思; 他们可以是任何一个正则表达式。

Raku 就是这样, 正则表达式分词有两种形式。单个垂直条形式与产生较长匹配的分支相匹配。如果两个或多个匹配的长度相同, 那么以字面量开始的分支就会获胜, 所以对于正则表达式 /a.|../ 匹配字符串 ab, 第一个备选项比第二个备选项更符合。

如果你将垂直条加倍, 则从左到右依次进行选择, 并且, 第一个匹配的备选项胜出:

say 'aab' ~~ / a+ | \w+ /;  # Output: ⌜aab⌟
say 'aab' ~~ / a+ || \w+ /; # Output: ⌜aa⌟

在前面的示例中, a+ 匹配 aa, 而 \w+ 可以匹配整个字符串 aab。在 | 的情况下, | 作为析取运算符, 更长的匹配胜出, 因此该正则表达式匹配整个字符串。对于 ||, 第一部分, a+ 匹配成功, 所以甚至不需要尝试第二个分支。

当你编写备选项时, 你可以让第一个分支留白, Raku 会忽略这个空的分支。这纯粹是出于审美的原因, 所以你可以这样写:

/
    | first branch
    | second branch
    | third branch
/

而不是在视觉上有点不太平衡的这种写法:

/
      first branch
    | second branch
    | third branch
/

3.7. 连接

你可以把 ||| 看作逻辑或。但是 AND 运算符呢? 大多数的正则表达式实现都会省略它, 但 Raku 却没有。它被拼写成了 &, 就像分词一样, 有一个顺序变体 &&。当我们在后面的章节中讨论你正则表达式的副作用时, &&& 之间的区别就会很明显。

当你有一个正则表达式并想要进一步地约束它时, & 运算符很有用。举例来说, 你可能正在文本文档中搜索电话号码, 而你已经有一个电话号码的正则表达式了, 但你记得你要查找的号码中, 有一个 17 的序列。你可以使用此正则表达式搜索这样的号码:

/ <phonenumber> & .* 17 .* /

& 连词的分支必须匹配字符串的同一部分 , 所以这里 17 用 .* 拉伸右分支的匹配到左分支的匹配来填充。

你可以使用同样的技巧来排除某些字符的匹配。例如, 一个不包含数字 9 的电话号码可写为:

/ <phonenumber> & <-[9]>* /

3.8. 零宽断言

你怎么能实现自己的锚点呢?

到目前为止, 我们讨论过的正则表达式结构不允许你这样做, 因为他们都需要消耗字符来决定是否匹配。

零宽度断言将另一个正则表达式变为锚点, 使它们不消耗输入字符串中的任何字符。它们有两种变体: 向前查看和向后查看断言。[14]

向前查看断言被拼写为 <?before regex>, 它将正则表达式转换为锚点。因此, 如果你想匹配数字后跟一个单位 (例如 KB 表示千字节或 MB 表示兆字节), 但不匹配单位本身, 你可以用这样的正则表达式来匹配:

say 'up to 200 MB' ~~ / \d+ <?before \s* <[kMGT]>? B > /;
# Output: ⌜200⌟

在这里, 正则表达式 \s* <[kMGT]>? B 匹配可选的空格, 然后是单位 B、KB、MB、GB 或 TB。周围的 <?before …​> 使只有当单位的正则表达式匹配时, 匹配才会成功。请注意, 这个断言没有推进字符串中正则表达式的位置, 因此匹配单元的字符串不包含在整个正则表达式所匹配的字符串中。[15]

你可以使用 ! 代替 ? 来否定向前查看。所以要匹配一个数字, 其后面不是单位, 你可以使用正则表达式:

say 'up to 200 MB' ~~ / \d+ <!before \s* <[kMGT]>? B > /;
# Output: ⌜20⌟

在这种情况下, 正则表达式只匹配字符串 20, 因为它是一个后面不直接跟随单位的数字。如果你根本不想让它匹配这个输入字符串, 你可以在这个数字周围使用单词边界断言:

say 'up to 200 MB' ~~ / « \d+ » <!before \s* <[kMGT]>? B > /;
# Output: Nil

我们也可以编写从当前位置向后查看的正则表达式, 而不是向前看字符串。匹配一个后面跟随逗号的数字, 你可以这样写:

say '200,50' ~~ / <?after \, > \d+ /; # Output: ⌜50⌟
say '200,50' ~~ / <!after \, > \d+ /; # Output: ⌜200⌟

使用正向和反向向前查看和向后查看断言, 你可以重新实现内置的锚点,[16] 你也可以编写自己的锚点。例如, ^ 匹配字符串的开头, 也可以写成 <!after .>; 只有在没有字符出现在它之前时才匹配。同样地, 左单词边界 «, 可以写成 <!after \w> <?before \w>。左单词边界就可以写成 <!after \d> <?before \d>

需要注意的是, <!after \w><?after \W> 虽然相似, 但不尽相同。不同之处在于, <!after \w> 匹配字符串的开头, 而 <?after \W> 不是, 因为它需要匹配一个非单词字符。

3.9. 总结

正则表达式的基本构建块是字面值、字符类、锚点、量词、分词(备选项)和连词(重叠)。他们构成了使用 Raku 正则表达式进行检索、验证和解析的基础。

4. 正则表达式和 Raku 代码

现在你已经知道了正则表达式的基本构件, 我们将探讨在 Raku 代码中使用正则表达式的不同方法。

4.1. 智能匹配

智能匹配操作符 ~~ 是一个通用的比较运算符, 它右侧的对象决定了比较的语义。 例如, 如果右侧的对象是数字, 则 ~~ 就会进行数值比较。如果右侧的对象是一个类型, 则检查左侧的对象是否为该类型(或其子类型)。

我们特别感兴趣的是, 右侧是正则表达式的情况。 在这种情况下, 智能匹配操作符将左侧解释为字符串, 并使用该正则表达式搜索匹配:

my $str = "If I had a hammer, I'd hammer in the morning";
say $str ~~ /h.mm\w*/; # Output: ⌜hammer⌟
say $str ~~ /hummer/;  # Output: Nil

如果匹配失败, 则匹配的返回值为 Nil, 或者如果匹配成功则返回一个匹配对象[17]如果在布尔值上下文中使用匹配对象 (比如 if 语句的条件), 则它的计算结果为 True (而 Nil 计算为 False); 在字符串上下文中(或通过调用 .Str 方法将其强制转换为字符串上下文时), 它计算为与正则表达式匹配到的字符串部分。从 say 输出的角括号( ) 表示匹配对象。

除了智能匹配之外, 你还可以使用 .match[18] 方法在字符串中搜索匹配项:

my $str = "If I had a hammer, I'd hammer in the morning";
say $str.match(/h.mm\w*/); # Output: ⌜hammer⌟

4.2. 引号形式

到目前为止, 我们已经看到由两个斜杠 /…​/ 分隔的正则表达式, 但是还有其他的方法来编写正则表达式。这些都是等价的:

/ a.b /;
rx/ a.b /;
rx{ a.b };
rx! a.b !;
regex { a.b }

如果你使用 rx 编写正则表达式, 则可以使用几乎所有的非单词字符作为分隔符(除了空格、冒号(:)和井号(#)之外)。

前面介绍的形式返回一个 regex 类型的对象, 你可以将其存储在变量中, 或者在智能匹配操作中使用。

此外, 还有一个使用 m(意为 match)而不是 rx 的形式。 它不是返回正则表达式对象, 而是立即将正则表达式与特殊变量 $_ 匹配:

$_ = 'abc';
if m/b./ {
    say "match";
}

由于智能匹配运算符 ~~ 自动将 $_ 设置为表达式的左侧, 所以在智能匹配中可以使用 m/…​/:

say "abc" ~~ m/b/; # Output: ⌜b⌟

4.3. 修饰符

修饰符改变了正则表达式的工作方式。它们分为两类。如果你把正则表达式看作是被编译为字节码[19]的小程序, 那么有些修饰符会影响编译, 有些修饰符则会影响你调用程序的方式。

例如, :global 修饰符(短形式 :g)属于后一类; 它指示正则表达式搜索字符串中所有不重叠的匹配:

my $str = "If I had a hammer, I'd hammer in the morning";
say $str.match(:global, /h.mm\w*/).join('|');
# Output: hammer|hammer

第一种类别的一个例子是 :ignorecase(简称 :i), 它指示正则表达式引擎忽略大小写, 因此正则表达式中的大写字符可以与小写的等效字符匹配, 反之亦然[20]:

say 'Hello, world'.match(/:i hello/); # Output: ⌜Hello⌟

如前文所示, 影响到正则表达式编译的修饰符可以在正则表达式本身内部使用。影响编译后的正则表达式行为的修饰符总是在外部使用。

由于 m/…​/ 形式是立即匹配的, 所以你也可以给它添加会影响正则表达式的运行时行为的修饰符:

say ('abc' ~~ m:g/./).elems; # Output: 3

这里 m:g/./ 返回一个含有三个匹配项的列表, 所以 .elems 方法返回数字 3。(见表 4-1。)

Table 5. 影响正则表达式编译行为的修饰符或它的一部分

长形式

短形式

描述

:ignorecase

:i

匹配时忽略字母大小写

:ignoremark

:m

匹配时忽略重音或组合字符

:sigspace

:s

把空白视为重要的

:ratchet

:r

禁用回溯

:ignoremark:m 修饰符允许你只用基本字符进行匹配, 而忽略变音符号和其他装饰字符的标记。在 :m 的作用下, /a/ 可以匹配 a, à, á, â, ã, ä, å, ā 和其他 22 个字符。

:sigspace 修饰符使 grammar 匹配中的空格与字符串中的空格匹配, 尽管不一定是一对一的映射。:ratchet 修饰符禁用回溯, 所以如果正则表达式引擎成功地以一种方式匹配字符串, 它不会再尝试以另一种不同的方式匹配。我们稍后将更详细地讨论这两个修饰符。

这些修饰符也可以放在正则表达式的中间, 或者通过方括号组 […​] 组来限制正则表达式的一部分:

/ ab :i cd /;   # match only the cd case-insensitively
/ [:i ab] cd /; # match only the ab case-insensitively

你还可以通过在冒号后面添加感叹号来禁用修饰符。所以最后一个正则表达式可以写成 /:i ab :!i cd/。 (见表4-2。)

Table 6. 影响编译正则表达式运行使行为的修饰符

长形式

短形式

描述

:global

:g

找到所有不重叠的匹配

:overlap

:ov

:exhaustive

:ex

找到所有可能的匹配

:continue(5)

:c(5)

从位置 5 开始搜索

:pos(5)

:p(5)

从位置 5 开始并锚定

:nth(5)

返回第 5 个匹配

所有接收参数的形式(此处为 5)也可以接受变量作为他们的参数。从字符串的开头位置从零开始计数, 就像使用 substr[21] 进行字符串索引一样。

如果你用 :global 搜索多个匹配项, 则第二个匹配的搜索从第一个匹配结束的地方开始。使用 :overlap, 第二个匹配的搜索从第一个匹配的下一个字符开始, 因此这两个匹配的匹配字符串可以重叠。:exhaustive 修饰符找到所有可能的匹配, 即使有几个匹配都从同一个位置开始。

如果你使用不带参数的 :continue:pos 修饰符, 它们将默认为上一个匹配离开时的位置。

4.4. comb 和 split

如果你想在一个字符串中找到正则表达式出现的所有次数, 但你只是对结果字符串感兴趣(而不是对匹配对象感兴趣), 你可以使用 comb[22] 方法找出它们:

my @numbers = "1308 5th Avenue".comb(/\d+/);
say @numbers;       # Output: [1308 5]

相反, 如果你只对字符串中不匹配正则表达式的部分感兴趣 , 那么 split[23] 方法适合你:

my ($city, $area, $popul) = 'Berlin;891.8;3671000'.split(/';'/);
say $area;          # Output: 891.8

在这里, 正则表达式只包含一个字面量; 在这种情况下, 你可以直接将字面字符串传递给 split, 就像 'Berlin; 891.8; 3671000'.split(';'); 那样。

combsplit 方法都接收第二个可选的参数 limit:

my ($city, $rest) = 'Berlin;891.8;3671000'.split(';', 2);
say $city;          # Output: Berlin
say $rest;          # Output: 891.8;3671000

split 方法对于解析简单文件格式很有用, 例如逗号分隔的不带引号的文件。

4.5. 替换

你不仅可以用正则表达式来匹配文本, 还可以用正则表达式来转换文本。

subst[24] 方法可以用另一个字符串替换正则表达式所匹配的字符串部分。在最简单的情况下, 替代者是一个字符串常量:

say '42 eur'.subst( rx:i/ « eur » /, '€'); # Output: 42 €

如果替代者是空字符串, 则替换将删除匹配的字符串:

say '42 €'.subst(/\s+/, '');               # Output: 42€

与正则表达式匹配一样, 修饰符和替换可以一块儿用。例如, :global 替换正则表达式匹配的所有出现过的字符串(而不仅仅是第一个, 即没有修饰符时):

say '1 2 3'.subst(/\s+/, '');              # Output: 12 3
say '1 2 3'.subst(:g, /\s+/, '');          # Output: 123

如果你希望替换的字符串取决于匹配到的值, 你可以传递子程序或块作为替换部分, 它接收匹配对象作为其参数:

say "9 of spades".subst(/\d+/, -> $m { $m + 1 });
    # Output: 10 of spades

在这种替换块中, 像 $0, $1 等匹配变量(下一章会有更多介绍)都不起作用, 除非你明确声明匹配变量 $/ 作为这个块的参数:

say "9 of spades".subst(/(\d+)/, -> $/ { $0 + 1 });
    # Output: 10 of spades

还有一种语法上的变体, 用于原地修改变量的替换。

my $var = '1 2 3';
$var ~~ s:g/ \s+ //;
say $var;                                  # Output: 123

注意, 在这种情况下, 第一个斜线和第二个斜线(/)之间的部分是一个正则表达式, 但第二个斜线和第三个斜线之间的部分是一个字符串。这意味着在第一部分中, 空格被忽略, 但在第二部分中空格是有关的。在替换字符串内部, 你可以使用 $0$1$2 等等, 用于引用捕获(有关捕获的更多信息, 请参阅下一章):

my $var = '"fantastic", she said';
$var ~~ s:g/ \" (.*?) \" /«$0»/;
say $var;           # Output: «fantastic», she said

如果你使用 […​]{…​} 来分隔正则表达式, 那么替换部分也可以是 Raku 表达式:

my $ad = 'Buy now! USD 10 per book. Prices double soon to 20.';
$ad ~~ s:g[ \d+ ] = 2 * $/;
say $ad;

其中 $/ 指的是匹配对象。注意, 在这种情况下, 赋值运算符是在右侧之前使用的。

这会产生如下输出:

Buy now! USD 20 per book. Prices double soon to 40.

当你替换零宽度匹配时, 替换就变成了插入操作:

my $input = "It's just a jump to the left.
And then a step to the right.";

$input ~~ s:g/ <?before jump> /⇑/;
$input ~~ s:g/ <?before left> /←/;
$input ~~ s:g/ <?after right> /→/;

say $input;

这里的 <?before …​><?after …​> 将它们内部的正则表达式变成零宽度的正则表达式, 所以替换命令并不会替换匹配的文本(如 "jump"), 而是在匹配之前或之后插入替换部分 :

It's just a ⇑jump to the ←left.
And then a step to the right→.

你可以把普通匹配和零宽度匹配结合起来。例如, 这个语句将后面跟着单位 MB 或 GB 的所有数字都替换为 500:

my $input = '2 links with 75MB each';
say $input.subst(:g, / \d+ <?before <[MG]> B>/, 500);
    # Output: 2 links with 500MB each

4.6. 跨越代码和正则表达式边界

正则表达式和普通的 Raku 代码编译成相同的字节码, 而你可以将 Raku 代码和正则表达式混合使用。

最明显的交互方式是将 regex 对象存储在变量, 然后在普通代码中使用它们:

my $word-regex = /\w+/;
say "Hello, world" ~~ $word-regex;    # Output: ⌜Hello⌟

这样, 你可以为正则表达式起个名字, 同时也可以做所有其他可以用变量做的事情: 把它们放到数据结构中, 然后从函数中返回, 等等。

反过来也可以。变量可以成为正则表达式的一部分:

my $audience = 'world';
my $greeting = 'Hello';

if "Hello, world" ~~ / $greeting ', ' $audience / {
    # this branch is executed
}

如果一个变量包含一个字符串, 它总是按字面意思匹配; 如果它包含正则表达式, 则作为正则表达式匹配。

如果要把变量的内容解释为正则表达式, 你必须将它包含在尖括号中:

my $audience = "\\w+";
my $greeting = 'Hello';

if "Hello, world" ~~ / $greeting ', ' <$audience> / {
    # this branch is executed
}

此示例还演示了反斜杠必须在引用的字符串中加倍 - 本例中是赋值给变量 $audience - 但不是正则表达式。在双引号字符串中, 反斜杠引入了一个转义序列(例如 \n 表示换行符, \t 表示制表符)。使用语法 <$audience> 可以将变量 $audience 的内容解释为正则表达式。

在正则表达式中使用数组变量相当于将数组中的每个元素作为备选项进行匹配; 因此

my @numbers = 'one', 'two', 'three';
my $regex = / @numbers /;

等价于这样写:

my $regex = / [ 'one' | 'two' | 'three' ] /

除了一点, 使用数组变量时, 用程序化的方式提供值比较容易。

最后, 你可以在正则表达式中包含 Raku 代码块, 只需将其嵌入到花括号中即可。这对于在执行正则表达式时建立数据结构(如符号表)很有用 。

例如, 我们可以计算字符串中数字出现的次数:

my $count = 0;
my $str = "between 23 and 42 numbers";

if $str ~~ / [ \d+ { $count++ } \D* ]+ / {
    say $count;     # Output: 2
}

这里块 { $count++ }, 它将变量 $count 增量 1, 在正则表达式的 \d+ 部分之后执行。虽然这是一个有点构造化的用例, 我们将在后面的章节中看到正则表达式代码块的实际应用。

正则表达式中代码块的另一个很好的用例是添加 print 语句用于调试, 以查看正则表达式是如何匹配的。

这里我们尝试将浮点数与正则表达式匹配:

say '1.0e42' ~~ / ^ \d+ ['.' \d+]?  [e|E \d+]? $ /;

这个正则表达式匹配失败了, 要看这个正则表达式匹配到什么程度, 你可以删除使片段可选的问号, 然后添加一些代码块来显示增量进度:

'1.0e42' ~~ / ^
    \d+        { say "integer: '$/'" }
    ['.' \d+]  { say "decimal place: '$/'" }
    [e|E \d+]  { say "exponent: '$/'" }
    $ /;

这产生如下输出:

integer: '1'
decimal place: '1.0'
exponent: '1.0e'

我们可以看到这个正则表达式的最后一部分只匹配了 e, 而不是 e42。 仔细观察就会发现 [e|E \d+] 中的备选项延伸到了 \d+, 而这并不是本意。添加一个分组级别, [ [e|E] \d+ ], 修复该正则表达式。

这些代码块使用了特殊变量 $/, 它包含了匹配(或者至少是部分匹配, 就其进展而言)。

这些代码块的执行纯粹是为了它们的副作用。你可以使用 <?{…​}> 形式来影响正则表达式匹配。如果该代码块中的代码返回 false 值, 则匹配失败[25]:

my $one-byte = / ^ \d ** 0..3 $ <?{ $/.Int <= 255 }> /;
for 0, 100, 255, 256, 1000 -> $num {
    if $num ~~ $one-byte {
        say $num;
    }
}

此示例的正则表达式匹配 0 到 255 之间的数字(例如, 可能用于验证 IPv4 地址), 并且只打印 0、100 和 255 这些数字。

$/.Int 调用将匹配的字符串转换为整数, 并且当且仅当该数字最多为 255 时比较 ⇐ 255 才返回 True

Table 7. 把 Raku 代码嵌入正则表达式的方法

语法

描述

{ CODE }

运行 Raku 代码, 对正则表达式匹配没有影响

<?{ CODE }>

匹配要成功, 则代码需要返回 true 值

<!{ CODE }>

匹配要成功, 则代码需要返回 false 值

<{ CODE }>

代码的结果被解释为正则表达式

<$STRING>

将 $STRING 解释为正则表达式源代码

4.7. 总结

在本章中, 我们已经看到了如何在 Raku 代码中使用正则表达式, 反之亦然。你可以将正则表达式存储在变量中, 也可以在正则表达式中使用变量。

在下一章中, 我们将探讨如何从正则表达式匹配中提取信息或其中的部分信息。

5. 从正则表达式匹配中提取数据

当你使用正则表达式来验证输入时, 只要知道给定的字符串是否匹配正则表达式就可以了。在其他很多情况下, 我们想要从字符串和正则表达式匹配中提取更多的数据。

比如说, 如果你想解析一个 INI 文件(Microsoft Windows 上常见的配置文件格式), 你可能会对节标题和节内的键/值对感兴趣; 但是, 节标题周围的方括号这样的东西我们就不感兴趣了。

5.1. 位置捕获

你可以通过指示一个正则表达式来捕获一些数据, 通过在正则表达式的一部分的周围加上一对圆括号来提取相关数据。然后, 这部分正则表达式匹配的字符串就会被单独提供:

if "Hello, world" ~~ / (\w+) ', ' (\w+) / {
    say "Greeting: $0";         # Output: Greeting: Hello
    say "Audience: $1";         # Output: Audience: world
}

捕获从左到右, 从零开始编号。 第一对括号中的正则表达式匹配的字符串可以保存在特殊变量 $0 中, 第二对括号中的正则表达式匹配的字符串可保存在特殊变量 $1 中, 以此类推。

由于这些捕获的编号取决于它们在正则表达式中的相对位置, 我们称它们为位置捕获。

5.2. 匹配对象

每个成功的正则表达式匹配的结果是一个 Match[26] 类型的对象。 当你用智能匹配运算符 ~~ 匹配一个正则表达式时, 这个正则表达式对象也可以在特殊变量 $/ 中使用。

匹配对象提供了大量关于正则表达式匹配的信息: $/.orig 包含了正则表达式匹配的字符串, $/.from$/.to 包含了匹配的开始和结束位置, 在字符串上下文中使用它(或通过在它身上显式调用 .Str 方法)返回匹配的字符串。

但还有更多: 当你像使用数组一样使用匹配对象时, 你可以从捕获中访问匹配项。$0 实际上是 $/[0] 的别名 , $1 实际上是 $/[1] 的别名, 等等。

捕获本身也是 Match 对象:

my $input = "There are 9 million bicycles in Beijing";

if $input ~~ /(\d+) \s+ (\w+)/ {
    say $0.^name;    # Output: Match
    say $1;          # Output: ⌜million⌟
    say $1.from;     # Output: 12
    say $1.to;       # Output: 19
}

5.2.1. 捕获嵌套

如果在其他捕获中嵌套捕获, 则匹配对象的结构对应于捕获的嵌套:

'abcdef' ~~ /(.) (b(c)(d(e))) /;
say $0.Str;         # Output: a
say $1.Str;         # Output: bcde
say $1[0].Str;      # Output: c
say $1[1].Str;      # Output: de
say $1[1][0].Str;   # Output: e

匹配对象实际上是一个匹配对象的树。

正如你在前面的示例中所看到的那样, 捕获的编号是按每一层嵌套的方式进行的; 在这个正则表达式中, 有五对圆括号, 但其中只有两对是顶层的, 所以匹配成功后只有 $0$1, 没有 $2 或更高的级别。相反, 每一级的嵌套还引入了从 0 开始的匹配编号。这与 Perl 5 和 PCRE 语义不同, 在 Perl 5 和 PCRE 语义中, 匹配是连续编号的。

5.2.2. 量词化捕获

当捕获受制于一个量词时, 匹配对象中对应的捕获就变成了匹配对象中的数组[27]:

if "127.0.0.1" ~~ /(\d+)**4 % "."/ {
    say $0.elems;       # Output: 4
    say $0[3].Str;      # Output: 1
}

唯一的例外是 ? 量词, 它不会从捕获中产生数组。[28]相反, 当它匹配一次时, 它的捕获是一个匹配对象, 如果它匹配零次, 则为 Nil

5.3. 命名捕获

当你有超过两个或三个以上的捕获时, 你可能会忘记哪个编号指向哪个捕获组。为了减轻这种负担, 并使代码在重构时更加健壮, 你可以使用命名捕获来代替:

my $str = 'Hello, World';
if $str ~~ / $<greeting>=[\w+] ', ' $<audience>=[\w+] / {
    say $<greeting>.Str;        # Output: Hello
    say $<audience>.Str;        # Output: World
}

$<thename> 在正则表达式内部和外部都指向命名捕获。在正则表达式内部, 你可以给它分配一个名字, 然后在正则表达式外面你可以访问相应的匹配对象。在正则表达式外部, $<thename> 实际上是对匹配对象的命名访问的快捷方式, $/<thename>$/{'thename'}。这种访问命名捕获的模式与访问哈希元素的语法相同。

如果在同一个正则表达式中使用了两次(或更频繁地)相同的名字, 那么捕获再次成为一个数组。

在后面的章节中, 我们将更详细地讨论命名正则表达式。对于现在, 创建命名捕获的更简单方法就是按名称调用正则表达式就足够了:

my regex byte {
    \d ** 1..3
    <?{ $/.Int <= 255 }>
}

my $str = '127.0.0.1';
if $str ~~ / ^ <byte> ** 4 % '.' $ / {
    for $<byte>.list -> $byte {
        say $byte.Str;
    }
}

这里 <byte> 调用命名正则表达式 byte, 并自动捕获由此同名子规则 byte 所匹配到的字符串。你可以使用语法 <alias=originalname> 来重命名捕获:

my $str = 'Hello, World';
my regex word { \w+ };

if $str ~~ /<greeting=word> ', ' <audience=word>/ {
    say $<greeting>.Str;        # Output: Hello
    say $<audience>.Str;        # Output: World
}

5.4. 反向引用

反向引用允许你访问正则表达式内部的捕获, 匹配正则表达式的前一部分捕获的字符串。这里有几个可能有用的案例, 比如在文本中搜索意外重复的单词时。第一次尝试将是这样的正则表达式: / (\w+) \s+ $0 / 这个正则表达式匹配一个单词(\w+)并将其捕获到 $0 中, 后面是空格(\s+), 然后再跟着原始单词($0)。然而这不符合预期。用它来匹配字符串 the next thing, 它会匹配 t t, 因为 \w+ 很乐意只匹配一个字符。

为了使它工作, 我们可以通过包括单词字边界断言: / « (\w+) \s+ $0 » / (记住 « 匹配左单词边界而 » 匹配右单词边界)来限制匹配整个单词。

现在, 如果你把它与字符串 "the quick brown fox jumps over the the lazy black dog" 进行测试, 它就会正确地匹配两个 "the"。

反向引用也适用于命名捕获; 前面的例子可以写成 /« $<word>=\w+ \s+ $<word> »/

反向引用的另一个常见用例是查找类似于引号的字符串, 只要两端都是一样的, 你不用关心实际的引用字符。

请注意, 反向引用绝对超出了常规计算机科学中定义的语言的范畴; 你需要更强大的形式主义来实现。

5.4.1. 跑个题: 带反向引用的原始性测试

本节与任何实际应用无关, 但会让你深入了解人们用正则表达式所做的那些疯狂的事情。如果你只是出于实用的原因, 你可以放心地跳过这一部分。

质数是一个整数, 只能被数字 1 和它本身整除。

你可以使用正则表达式来检验一个数字是否为质数。[29]首先, 我们必须通过创建一个由相同字符组成的字符串(例如 a)来编码这个数字, 其中字符串中的字符数与数字相同。因此 2 将被编码为 aa, 5 将被编码为 aaaaa, 以此类推。在 Raku 中, 我们可以使用字符串重复运算符 x 来执行此操作:

my $encoded = 'a' x 5;

然后, 我们尝试找到一个大于 1 的因子, 使这个数字均匀地被分割。在我们的编码字符串中, 这是一个由至少有两个字符组成的子串, 当重复次数足够多时(但至少一次)时, 就能重新实现原始编码字符串:

for 2..15 -> $number {
    my $encoded = 'a' x $number;
    if $encoded ~~ / ^ (a ** 2..*) $0+ $ / {
        say "$number is not a prime, a factor is ", $0.chars;
    } else {
        say "$number is a prime";
    }
}

这产生如下输出:

2 is a prime
3 is a prime
4 is not a prime, a factor is 2
5 is a prime
6 is not a prime, a factor is 3
7 is a prime
8 is not a prime, a factor is 4
9 is not a prime, a factor is 3
10 is not a prime, a factor is 5
11 is a prime
12 is not a prime, a factor is 6
13 is a prime
14 is not a prime, a factor is 7
15 is not a prime, a factor is 5

例如, 如果 $number 为 6, $encoded 就会变成 "aaaaaa"。当第一组(a * 2..)与三个 a(aaa)匹配时, 正则表达式引擎就会找到匹配。相应地, $0+ 产生另外三个 a, 总共六个 a。 在质数的情况下, 正则表达式引擎无法找到这样的分区, 导致匹配失败。

由于 ** 量词是贪婪的, 因此正则表达式引擎首先找到最大的质数因子, 因此它将 5 识别为 10 的质数因子。如果你把它改成 **? 量词(因此正则表达式变为 / ^ (a *? 2..) $0+ $ /), 它找到最小的质因数。

注意, 这是一种非常低效的测试质数的方法, 因为正则表达式引擎没有针对这种任务进行优化, 使用了一个非常低效的数字表示法, 甚至必须尝试所有可能的因子, 甚至是那些很容易被更智能的算法排除的因子。

尽管如此, 正则表达式引擎还是能在几秒钟内测试出多达五万个数字。Raku 有一种内置的方法来测试质数, 速度更快, 占用内存更少:

say 50101.is-prime; # Output: True

5.5. 重温匹配对象

如前所述, 成功的正则表达式匹配会返回匹配对象。除了匹配的子字符串的位置和长度之外, 匹配对象还存储了所有位置捕获和命名捕获。要获取所有的位置捕获的列表, 可以调用 .list`方法。同样的, `.hash 方法也会将命名捕获作为一个散列返回。散列是一种数据结构, 具有从名称或键到值的映射; Python 称它为 dict 或字典, JavaScript 称之为对象。

当使用 say 函数打印匹配对象时, 输出的是以 ⌜⌟ 分割的匹配字符串:

say "abc" ~~ /../; # Output: ⌜ab⌟

如果正则表达式包含了捕获, 则会在单独的行中打印出来, 并缩进一个空格。因此

say "abc" ~~ /.(.)(.)/

产生输出:

⌜abc⌟
 0 => ⌜b⌟
 1 => ⌜c⌟

其中 0 和 1 是捕获的索引。嵌套捕获会产生更多的缩进, 所以 "abc" ∼∼ /.(.(.))/ 打印出:

⌜abc⌟
 0 => ⌜bc⌟
  0 => ⌜c⌟

因此, 命名捕获仅以其名称来区分:

"abc" ∼∼ /.(.$<char>=[.])/

打印:

⌜abc⌟
0 => ⌜bc⌟
char => ⌜c⌟

5.6. 总结

捕获允许你从成功的正则表达式匹配中提取数据。你可以通过在正则表达式的一部分的周围添加一对圆括号, 使用 $<name>=[…​] 语法, 或者通过调用一个命名的正则表达式, 来指示正则表达式引擎捕获数据: <name>

正则表达式匹配和捕获都产生匹配对象; 最上面的匹配对象会变成一棵子匹配树。反向引用允许你精确地匹配一个正则表达式的前一部分的匹配对象, 你可以滥用该功能来构建一个简单的质数测试。

6. 正则表达式技术性细节

正则表达式有时候会显得很神奇。它们往往有不同的方式来匹配字符串。匹配项是如何被找到的?哪一个匹配项先被找到?

本章通过解释正则表达式引擎的使用机制来揭开它神秘的面纱。理解这些机制是编写健壮的正则表达式的关键, 它可以满足你的需求, 不多也不少。

我们的讨论因 Raku 正则表达式中实现的两种不同范式而略显复杂: 带有限状态机的声明式匹配和更强大的回溯引擎。

6.1. 带状态机的匹配

当计算机科学家谈论正则语言时, 他们还描述了一种有效识别特定字符串是否属于正则语言的机制。在 Raku 中, 如果字符串与正则表达式匹配, 那么这只是简单地表示。

6.1.1. 确定性状态机

用于识别匹配字符串的机制是有限状态机。它是一组带有箭头的状态集。每个箭头都用单个字符标记。机器从输入字符串中单独读取每个字符, 并使用与标签相同的箭头作为当前的字符。如果没有标有当前字符的箭头, 匹配立即失败。

有些状态被标记为接受状态。在输入字符串中的最后一个字符被读取后, 如果最终状态为接受状态, 则匹配成功。否则就会失败(图 6-1)。

state mechine
Figure 1. 一个简单的确定性有限自动机。可接受状态 A1 用两个圆标记

考虑一下这个例子中的自动机。起始状态是 A0, 从那开始, 它需要读取字符 a 来进入接受状态 A1。然后, 它可以读取一个 b, 然后再读一个 c, 回到接受状态, 所以它接受的字符串与正则表达式 / ^ a [bc]* $ / 相同。[30]我们称这它为自动机 A。

对于每个状态, 最多只有一个箭头携带某个特定字符离开该状态。因此, 该机器一次只处于一种状态, 而我们称其为确定性有限自动机, 简称 DFA。

DFA 可以在代码中非常有效地实现 。对于每个状态, 你只需要一个查找表, 将输入的字符映射到下一个状态, 这意味着你只需要在输入字符串的每个字符上花费固定的步数。

如前面的自动机所演示的那样, 你可以使用它匹配字面量, 你可以用反向的箭头来实现 * 量词。它还支持备选分支, 尽管它们可能比较麻烦。

首先, 让我们修改原来的自动机, 以匹配 / ^ a [bca]* $ / (图 6-2)。

state mechinne
Figure 2. 一个简单的匹配正则表达式 / ^ a [bca]* $ / 的自动机

我们称这个自动机为 B。

现在, 我们要构建一个自动机来匹配 A 或 B 匹配的字符串。我们可以通过创建一个同时运行两个自动机的自动机来实现。这两个自动机的起始状态是 A0 和 B0, 所以我们把新的起始状态叫做 A0B0。从这个起始状态开始, 读取一个 a 就会使自动机 A 从 A0 移动到 A1, 使自动机 B 从 B0 移动到 B1, 所以下一个状态是 A1B1。由于原始状态中至少有一个是接受状态, 所以 A1B1 也是接受状态(图 6-3)。

first step
Figure 3. 图 6-3 匹配 A 或 B 的部分构造自动机, 第一步

第三种状态也是非常明显的: 从 A1 或 B1 中读取 a b, 我们就可以分别移动至 A2 和 B2(图 6-4)。

图 6-4 匹配 A 或 B 的部分构造自动机, 第二步

现在开始变得有趣了: 在读取字符 c 时, A 转换为状态A1, 但 B 转换为状态 B0。对此我们需要一个状态, 我们称之为 A1B0。因为 A1 是接受状态, 所以 A1B0 也是接受状态(图 6-5)。

图 6-5 匹配 A 或 B 的部分构造自动机, 第三步

如果下一个字符是 a, 会发生什么? 自动机 A 拒绝了输入, 自动机 B 移动到 B1 状态。因此, 我们可以在这儿复制自动机 B(图 6-6)。

图 6-6 匹配 A 或 B 的部分构造自动机, 第四步

同样, 从状态 A1B0 中读取字符 b 使自动机 B 失败, 并将自动机 A 移动到状态 A2 中。因此, 我们可以将状态 A1 和 A2 复制到新的自动机中(A0 从那里是无法到达的), 我们得到了最终的结果(图 6-7)。

图 6-7 构造完全的匹配 A 或 B 的自动机

这种机制被称为 幂集构造[31], 因为新的状态集就是源自动机的幂。

在我们的例子中, 生成的自动机有九个状态, 而 A 和 B 各只有三个状态。如果我们必须建立几个自动机的析取, 在最坏的情况下, 所得到的自动机的状态数与每个输入自动机的状态数的乘积成正比。或者换句话说, 状态的数量可以随着我们要建模的正则表达式的字符数成指数增长。

6.1.2. 非确定性状态机

这种状态爆炸限制了 DFA 在正则表达式匹配代码中的实用性。相反, 正则表达式实现通常使用非确定性有限自动机, 简称 NFA。NFA 允许用同一个字符标记多个箭头, 以区分不同的状态。NFA 的非确定性部分是, 正则表达式引擎必须猜测要跟随哪个箭头, 或者它只是跟随所有的箭头, 这就导致了机器可以同时处于一个以上的状态。

如果你把状态和标记的箭头视为一个游戏板, 开始匹配就会把一个芯片置于初始状态。当你读取一个输入字符时, 你按照所有相关的箭头, 在每个目标状态上放置一个芯片。最后, 你把之前的状态中的筹码去掉。

为了使 NFA 更容易构造, 人们通常允许所谓的 epsilon(ε)过渡。NFA 遵循这些箭头而不消耗一个输入字符。或者换个说法: 如果从状态 C0 到 C1 有一个 ε 过渡, 只要在 C0 上放置一个芯片放, 就会在 C1 上放置第二个芯片。

通过这些ε过渡, 创建两个自动机的分离变得简单了。你只需添加一个新的起点 C0, 然后从它开始添加 ε 过渡到你所组合的自动机的起点(图 6-8)。瞧, 就这样:

确定性和非确定性有限自动机是同样强大。对于每一个 NFA, 可以构造一个完全相同的字符串匹配的DFA。 反过来, 每一个 DFA 也是一个 NFA, 因为 NFA 的规则比 DFA 更宽松。

从正则表达式构建 NFA 通常比构建相应的 DFA 要快得多(而且可以使用更少的内存)。在运行时, NFA 可能需要推进多个状态(想想前面比喻中的芯片), 所以在最坏的情况下, 它需要采取的步骤数与每个输入字符的 NFA 状态数成比例。

Raku 使用 NFA 来匹配我们到目前为止讨论过的大部分正则表达式特性, 包括字素、量词、连词和分词以及字符类(只是分词的方便语法)。 NFA 不能处理检索符特征是顺序性的分词(||)和连词(&&), 锚点、反向引用、代码块和代码断言, 以及我们稍后讨论的一些特性: 带有显式回溯控制的正则表达式、递归、向前查看和向后查看。

6.2. 正则表达式控制流

在正则表达式引擎无法使用 NFA 的情况下, 或者理解 NFA 并不能提供很多关于正则表达式如何匹配的见解, 那么理解一般正则表达式的控制流程是有用的。

第一条规则是正则表达式引擎总是从字符串的开头开始, 并优先选择最左边的匹配。

第二条规则是贪婪规则: 正则表达式是从左到右进行计算的, 每一个匹配到可变字符数的部分都会尝试匹配它能匹配到的最长子串。[32]

因此, 如果你有一个正则表达式 / \w+ .* / 并将其与字符串 abcd 匹配, 那么 \w+ 会匹配整个字符串, 使 .* 成功匹配空字符串。\w+ 只匹配第一个字符, 剩余的 .* 同样允许匹配, 但不会发生, 因为这违反了正则表达式最左边的部分尽量匹配的规则。

如果你试图用一个简单的正则表达式 / \" .* \"/ 来匹配一个带引号的字符串, 那么它可能比你预期的要匹配的多:

my $str = 'Amanda sighed. "It was madness", she said. '
    ~ '"Sheer madness"';
if $str ~~ / \" .* \" / {
    say $/.Str;
}

这产生如下输出:

"It was madness", she said. "Sheer madness"

因为 * 并不在意你不没有打算在第一个引号的边界之外匹配; 它只是尽可能多地匹配, 同时使使整个匹配成功。

6.3. 回溯

贪婪规则并不总是在第一次尝试时就会产生匹配。 在前面的例子中, .* 首先匹配了所有能匹配的东西, 包括输入字符串中的最后一个引号。但是在正则表达式中的最后一个引号没有匹配到, 导致了失败。

正则表达式引擎没有放弃, 而是开始回溯: 它回到了之前的量词(. 中的 ), 并使其少重复一次匹配。只有这样, 正则表达式中的引号才能找到输入字符串中的引号, 使整个正则表达式匹配成功。

如果有多个正则表达式元素可以以不同的方式匹配, 那么回溯会尝试从最后一个元素开始尝试所有的选项, 如果它们全部都失败了, 那么回溯就会从倒数第二个元素尝试另外一个选项。然后探索该配置中最后一个元素的所有变体, 并继续尝试从之前的元素中搜索匹配。如果你把它看成是搜索树, 那就是深度优先搜索。

让我们考虑一个带有两个可变部分的正则表达式, 结合搜索, 让我们一块儿看看:

'mabracadabra' ~~ / (.+a) (.*) $0 /;

这开始时, 第一组 (.+ a) 匹配完整的字符串; 第二组 (.) 匹配空的字符串; 最后一部分, $0 匹配失败。因此, 回溯开始了, 它要求第二组匹配更少的字符, 但它不能; 所以它又回到了第一组, 要求它匹配更少的字符。现在, 第一组匹配了 mabracada, . 匹配了剩下的三个字符。但是, 最后一组仍然没有匹配成功, 所以 .* 放弃了另一个字符, 最后一组继续失败。如此循环, 直到第一组只匹配了两个字符, ma。 即使在这种配置下, 其余的正则表达式也无法成功匹配。正则表达式引擎没有选项了, 所以它采用了最后的机制: 它尝试在第二个字符的左边一个位置开始匹配。

从第二个字符开始, 第一个组再次尝试匹配整个剩余的字符串, abracadabra。我们可以通过添加在正则表达式的第二部分后面添加一个代码块来直观的说明:

'mabracadabra' ~~ /(.+a) (.*) { say "0: '$0'; 1: '$1'" } $0 /;
0: 'mabracadabra'; 1: ''
0: 'mabracada'; 1: 'bra'
0: 'mabracada'; 1: 'br'
0: 'mabracada'; 1: 'b'
0: 'mabracada'; 1: ''
0: 'mabraca'; 1: 'dabra'
0: 'mabraca'; 1: 'dabr'
0: 'mabraca'; 1: 'dab'
0: 'mabraca'; 1: 'da'
0: 'mabraca'; 1: 'd'
0: 'mabraca'; 1: ''
0: 'mabra'; 1: 'cadabra'
0: 'mabra'; 1: 'cadabr'
0: 'mabra'; 1: 'cadab'
0: 'mabra'; 1: 'cada'
0: 'mabra'; 1: 'cad'
0: 'mabra'; 1: 'ca'
0: 'mabra'; 1: 'c'
0: 'mabra'; 1: ''
0: 'ma'; 1: 'bracadabra'
0: 'ma'; 1: 'bracadabr'
0: 'ma'; 1: 'bracadab'
0: 'ma'; 1: 'bracada'
0: 'ma'; 1: 'bracad'
0: 'ma'; 1: 'braca'
0: 'ma'; 1: 'brac'
0: 'ma'; 1: 'bra'
0: 'ma'; 1: 'br'
0: 'ma'; 1: 'b'
0: 'ma'; 1: ''
0: 'abracadabra'; 1: ''
0: 'abracada'; 1: 'bra'
0: 'abracada'; 1: 'br'
0: 'abracada'; 1: 'b'
0: 'abracada'; 1: ''
0: 'abraca'; 1: 'dabra'
0: 'abraca'; 1: 'dabr'
0: 'abraca'; 1: 'dab'
0: 'abraca'; 1: 'da'
0: 'abraca'; 1: 'd'
0: 'abraca'; 1: ''
0: 'abra'; 1: 'cadabra'
0: 'abra'; 1: 'cadabr'
0: 'abra'; 1: 'cadab'
0: 'abra'; 1: 'cada'
0: 'abra'; 1: 'cad'

在尝试了 46 种不同的配置后, 它找到了一个匹配项: 从第二个字符开始, (.+ a) 匹配 abra, (.*) 匹配 cad, $0 再次匹配 abra。

从它们匹配的内容来看, 你可以清楚地看到, 正则表达式后面的部分比前面的部分变化更快。该正则表达式的起始位置的推进速度更慢, 事实上是最缓慢的变化。

正则表达式引擎永远不会回溯到零宽度断言(向前查看和向后查看的断言)。对于这些, 只看它们是否匹配, 而不看它们是如何匹配的, 因为成功的匹配从来不会消耗输入字符串中的任何字符(它匹配空字符串)。

6.4. 为什么要避免回溯

回溯是使正则表达式起作用的秘密魔力。它可以让计算机代表你努力工作, 并在输入字符串中找到匹配项。

但, 在某些情况下, 回溯会代码更大的伤害; 因此, Raku(以及其他一些正则表达式实现)提供了禁用回溯的方法, 或者减少回溯的工作量。

6.4.1. 性能

一个原因是性能: 回溯可能是计算密集型的, 如果你已经知道某个匹配(或匹配的一部分)会失败, 告诉计算机少做一些工作就会加快速度。

一个简单的例子是这个正则表达式匹配:

"aaaaaa" ~~ /^ (a+) <[bcd]> /;

你可以看到这个匹配失败了, 因为输入字符串中没有 b、c 或 d。然而, 正则表达式引擎还不够聪明, 不知道这一点, 它必须这样搜索才能达到最佳性能。相反, 它首先尝试匹配输入中的所有 a, 然后是再少匹配一个 a, 以此类推, 然后再宣告失败。同样的, 我们可以通过一个嵌入的代码块来跟踪它的进度。 比如说 "aaaaaa" ∼∼ /^(a+) { say $0 } b/ 产生这样的输出:

⌜aaaaaa⌟
⌜aaaaa⌟
⌜aaaa⌟
⌜aaa⌟
⌜aa⌟
⌜a⌟
Nil

在某些方面比计算机更聪明, 我们可以告诉它永远不要放弃它匹配的任何东西, 来帮助它。比如 说 "aaaaaa" ∼∼ /^(a+:) { say $0 } b/ 产生更短的输出:

⌜aaaaaa⌟
Nil

量词后面的冒号(:)告诉它不要回溯这个量词。它不仅适用于所有的量词, 也适用于分词。这个量词与回溯控制冒号一起称为占有式量词, 因为这个量词永远不会放弃它曾经的拥有的东西。

第二种禁用回溯的方法是使用 :ratchet(或 :r) 修饰符, 你也可以在组 […​] 或捕获 (…​) 中使用。 在这种情况下, 修饰符的效果从使用点延伸到组的闭合括号。

6.4.2. 正确性

回溯有时会导致意外的匹配结果。之所以会发生这种情况, 是因为正则表达式的一部分按你所希望的方式匹配, 然后后面的一部分未能匹配。然后第一部分开始回溯, 并以一种意想不到的方式匹配。

Jan Goyvaerts 在正则表达式 gulu 博客上分享了一个例子[33], 这里转述了这个例子, 并根据 Raku 正则表达式语法进行了调整。

当你尝试匹配一对 HTML 标记时, 如字符串 <a href="…​">some text</a>, 你可以使用像这样的正则表达式:

my $html-re = rx:ignorecase{
    '<' $<tag>=[ <[a..z]>+ ] # opening tag
         <-[ > ]>*           # attributes within opening tag
    '>'
         ( .* )              # content between opening and
                             # closing tags
    '</' $<tag> '>'
};

say 'more text <a href="..:">link text</a> bla' ~~ $html-re;

这似乎是可行的, 但错误地匹配不匹配的标签, 如 <abc> </a>。这里的正则表达式引擎首先尝试 $<tag> 为 abc, 但没有找到匹配, 然后回溯。在第二次迭代中, 它尝试了 ab, 失败了, 第三次迭代成功了 $<tag> 是 a。

有人可能会说, 这个正则表达式有缺陷, 它匹配了开头的 HTML 标签之后, 它应该有一个词的边界断言。但是, 尽管如此, 我们对正则表达式匹配的直觉往往与量词的贪婪性相吻合, 而不是与回溯可能产生的更细微的匹配相吻合。

在编写正则表达式或解析器时, 问问自己关于单个元素的问题: 如果这部分正则表达式找到了匹配, 然后稍后又失败了, 那么是否我想让它尝试不同的匹配? 想象一下, 你正在为一种编程语言编写一个解析器, 编写一个与标识符匹配的正则表达式。 这个匹配是否应该放弃一两个字符? 可能不会。在这种情况下, 最好加上一个冒号(:)来防止它回溯: / $<tag>=[ <[a..z]>+: ] /

6.5. 节俭量词和回溯

正如前面一章中提到的, 有节俭量词或惰性量词, 尝试尽可能少地匹配。这些量词总是会回溯, 即使是在启用了 ratchet 修饰符的情况下也是如此。

当一个贪婪的量词试图尽可能多地匹配时, 即使不回溯, 也会通过应用量化的正则表达式元素, 看看是否匹配。另一方面, 节俭量词总是从以最小的匹配数量开始, 只有回溯才能让它匹配更多的元素。因此, 如果不进行回溯, / a ?? // a *?/ 只匹配空字符串, / a +?/ 只匹配单个 a, 以此类推。

6.6. 最长 token 匹配

| 的离散项或替代项更喜欢与最长的字符串匹配的分支。我们称之为最长的令牌匹配。

这在解析时非常重要, 因为它与我们自己直觉上识别文本的方式是一致的。例如, Raku 的表达式 $var` 可以被解析为前缀运算符 ` (将后面的变量增量1), 后面是变量 $var。或者, 它可以解析为 +(+$var), 将前缀 + 运算符(将其参数转换为数字)应用到 $var, 然后再将相同的运算符应用到结果中。Raku 语法是基于 regexes, 它知道更喜欢第一个变体(单个 ` 运算子), 因为当试图解析前缀运算符时, 它产生的匹配时间最长。footnote:[这是另一种你不希望回溯的情况:一旦解析器识别了 ` 运算符, 你不希望它改变主意, 把这两个字符解释为不同的运算符。]

为了能够有效地匹配最长的替代变量, Raku 会确定一个 regex 的哪些部分构成了声明性前缀。然后构造一个 NFA 来匹配这个声明性前缀。一旦完成后, 一般的正则表达式引擎的其他部分就会启动并继续进行, 直到找到下一个声明式前缀。

声明性前缀被限制在NFA可以建模的元素中:字元、字符类、分词、连词、连接词和贪婪量化器。在命名的regex调用的情况下, 命名的regex的声明性前缀会被合并到调用的regex中, 除非它会导致递归。

我们可以通过插入一个代码块 {}(它不是声明性的)来限制替代的长度来说明这一点(图6-9)。

say "abc" ~~ /ab | a.* /;        # Output: ⌜abc⌟
say "abc" ~~ /ab | a {} .* /;    # Output: ⌜ab⌟

这里是正则表达式ab | a。*, 第二个分支匹配整体 string, 比第一个分支匹配的长。

通过插入空块 {}, 最长的令牌匹配是 有效地限于正则表达式ab | 一个。现在第一个分支ab产生了 较长的匹配(因为第二个分支的声明部分 只匹配“a”), 正则表达式引擎承诺做出这个决定, 只匹配“ab”。仅当后来的正则表达式元素无法匹配时(和 启用了回溯)正则表达式引擎会重新考虑这个决定。

6.7. 总结

计算机科学为我们提供了有效匹配常规语言的工具通过有限自动机。即便如此, 常规语言只是其中的一部分正则表达式可以描述什么。其余的是使用回溯处理, 它最喜欢最左边的匹配, 而不是更长的匹配匹配较短的匹配。

7. 正则表达式技术

前面几章介绍了许多你可以用来构建你的正则表达式的构造, 并解释它们的工作原理。但是, 仅仅这些并不能告诉你如何构造你的正则表达式以获得最佳结果。本章将提供这样的指导。

7.1. 了解你的数据格式

这可能是显而易见的, 但还是经常被忽视。为了能够为某些数据格式编写正则表达式, 你需要知道它的规则。也就是说, 如果有规则的话。如果没有, 你可能需要足够多的输入数据来制定规则。并对数据进行测试。然后, 你需要知道验证的背景。

7.1.1. 明确的数据格式

我们先来谈谈定义数据格式的情况。即使你认为自己对数据格式相当了解, 也值得去阅读规范。通常情况下, 有一些很少使用的边缘情况可能是值得考虑的。

例如, 像 johndoe 这样的字符串(没有任何 @ 符号)是一个有效的电子邮件地址, 可以用于本地发送。如果你要写一个电子邮件地址的正则表达式来进行输入验证, 是否应该允许? 答案取决于具体情况。如果你正在开发一个纯粹在公司内部使用的应用程序, 那么允许它可能是有意义的。而在公共 Web 应用程序中, 就不那么合理了。

保持电子邮件地址, 你知道本地部分可以被引起来吗? 或者域部分可以是 IP 地址, 还可以有方括号? 是的, "somebody"@[93.184.216.34]some+body@[IPv6:2001:db8::1] 都是有效的电子邮件地址。

有些数据格式有其格式规范。例如 JSON, 即 JavaScript Object Notation, 它的主页上有解析规则[34] 在这种情况下, 重复作者为创建这些规则所做的工作, 并简单地将它们翻译为 Raku 语法, 往往是一种实用的选择。

7.1.2. 探索数据结构

如果你没有现成的规范, 那么你必须来遵守自己的规则。提出这些规则的最好方法, 就像科学方法一样: 首先你提出一个假设, 然后再进行测试。你反复迭代, 直到你对结果满意为止。

测试关于数据格式的假设可以采用几种形式。如果你知道有另一个程序处理相同的数据格式, 你可以修改现有的数据格式的文件, 然后用另一个程序加载它们们。如果这不是一个选项, 你应该尝试收集尽可能多的现实世界中的数据, 以相同的格式, 然后通过这个语料库来检验你的假设。

让我们假设你想写一个解析 INI 文件的正则表达式, 它是 Microsoft Windows 平台上常用的配置格式。

在它的基本形式, 它看起来像这样(例子取自维基百科 INI 文件页面[35]):

; last modified 1 April 2001 by John Doe
[owner]
name=John Doe
organization=Acme Widgets Inc.
[database]
; use IP address in case network name resolution
; is not working
server=192.0.2.62
port=143
file="payroll.dat"

规则看起来很简单: 文件由一系列章节组成。每节以方括号中的章节名开头, 并包含由等号(=)分隔的键/值对。空行和以分号(;)开头的行将被忽略。

现在是时候提出更多问题了:

  • 章节列表可以是空的吗(也就是说, 空文件是否是有效的 INI 文件)?

  • 章节可以为空吗(不包含任何键/值对)?

  • 是否允许在键/值对后面加上注释? 例如, 如果你写了 port=443; 只有一个没有被防火墙阻止, 注释是否是值的一部分?

  • 哪里允许空格? [database] 或 port=443 是否有效?

  • 章节名中允许的是什么? 例如空格或开口括号? 那行的分隔符呢?

  • 键也是如此。键中是否允许使用破折号(-)?

  • 值的规则是什么? 它们会否延伸到行的末尾吗? 他们是否停止在注释标记处? 如果下一行不是注释、键/值对或章节标记, 它们是否可以是多行的? 是否允许空值?

  • 是否允许使用转义序列(例如制表符的转义序列 \t)? 如果允许, 在哪里? 仅仅在值中, 还是在键或章节标题中?

  • 是否支持引用, 如果是, 在哪里引用?

  • 是否允许在章节名称、键和值中使用非 ASCII 字符?

这些是分析纯文本数据格式时的典型问题。当你研究这些问题的答案时, 你可以把它们变成你的正则表达式的测试用例。

没有固定规范的数据格式通常有几种变体, 并且实现方式也不一样, 他们所接受的内容也不一样。在这种情况下, 你必须决定你是仅针对一个变体, 还是针对一个共同的子集, 或者是所有变体的超集。

7.2. 考虑无效输入

当你编写正则表达式时, 不要只考虑那些正则表达式应该匹配的字符串。对他们不应该匹配的字符串要同等重视。一般来说, 正则表达式应该拒绝的字符串比正则表达式应该接受的字符串多, 这使得这些字符串既重要又难于思考。

你可以做的一件事是写否定测试; 也就是说, 用不应该匹配的输入进行测试。另一个是当你看到 .* 或 .+ 这样的项时, 要停下来, 多想一想。这些词太宽泛了, 以至于大多数时候都是错误的。大多数数据格式都不包含"然后, 无论如何"这样的子句。 例如, 如果你解析到行尾的注释, /'#' .*/ 是错的, 因为它可以通过断行来匹配; /'#' \N* \n? / 是比较好的正则表达式。如果你解析类似于 C 的注释, / * ... * /, 正则表达式 / '/*' .* '*/' 也匹配整个字符串 /* abc */ de */, 因为 .* 贪婪地匹配了字符串中间的结尾注释标记 */。

7.3. 使用锚点

如果你写的正则表达式是为了匹配整个字符串, 请记住锚定 ^$ 以确保它确实匹配整个字符串。如果不是的话, 请考虑一下正则表达式匹配的边界。如果你试图匹配一个单词, 后面不跟着一个点(.), 那么正则表达式 /\w+ <!before '.'> / 可以匹配一个部分单词:

say "supercalifragilisticexpialidocious."
        ~~ / \w+ <!before '.'> /;
# Output: ⌜supercalifragilisticexpialidociou⌟

这里的 \w+ 实际上匹配的字符是比实际的单词少一个, 所以 <!before '.'> 可以成功匹配。

如果这样的行为不是你想要的, 你可以添加一个断言, 比如单词边界, / \w+ » <!before '.'> /。 因为 \w+ 暗示单词的一部分已经被匹配了, 所以你也可以写 / \w+ <!before \w> <!before '.'> /

断言的内容是一个正则表达式, 所以在这里我们必须引用点(或转义为 <!before \.>), 否则这个点会展开它的特殊含义, 以匹配任何字符。

7.4. 匹配引用的字符串

许多数据格式包括带引号的字符串。他们的定义特征是, 在两个(或更多的)分隔字符 - 引号之间允许更多的(通常几乎所有的)字符。

我们已经在 Raku 中看到过引用的字符串, 在 Raku 的正则表达式本身中, 可能会出现一些非单词字符, 而这些字符在正则表达式中是保留的。 常见的数据交换格式, 如 CSV、JSON 和 YAML 也包含引号。CSV-逗号分隔的值格式 - 有一个分隔符(默认为逗号(,); 因此而得名), 在类似于表格的结构中分隔列。如果列值本身包含逗号, 你必须能够将逗号与分隔符区分开来, 这通常是通过引号来实现的。

一个 CSV 文件, 包含列 a, 然后是 b, c, 最后是 d 列(即三列), 可以写成:

a,"b,c",d

其中 "b, c" 是带引号的字符串。

如前所述, / \" .* \" / 不是解析引号字符串的有效方法 , 因为 .* 可以匹配断开的结尾引号。如果输入的内容是:

a,"b,c",d,"e,f"

那么被引用的字符串的正则表达式实际上会与输入中的两个被引用的字符串以及中间的列匹配。

一个不那么幼稚的方法是写成 / \" .*? \" /, 这样可以将引用的字符串限制在最短的匹配范围内。这个方法是可行的, 但前提是没有任何东西迫使正则表达式以不同的方式回溯和匹配:

say '"a,b","' ~~ / ^ \" .*? \" $ /; # Output: ⌜"a,b","⌟

这里的输入是一个不平衡的引号字符串, 中间有第三个引号字符。第一次尝试只匹配了字符串中的 "a, b" 部分, $anchor 没有匹配, 因此回溯开始, 下一次尝试匹配整个字符串。

这通常不是理想的行为。一种更稳健的方法是放弃正则表达式中的点, 并更努力地思考引号字符串中允许的内容: 这很容易用否定的字符类来实现:

say '"a,b","' ~~ / \" <-["]>* \" /;             # Output: ⌜"a,b"⌟
say '"a,b","' ~~ / ^ \" <-["]>* \" $ /;         # Output: Nil

现在这只匹配一个平衡的引号字符串。

7.4.1. 引用带有转义序列的字符串

我们的挑战并未就此结束。在前面的 CSV 示例中, 我们现在可以处理包含分隔字符的列, 但由于引号字符获得了特殊的含义, 你不能轻易拥有包含引号字符的列。

解决这个问题最常见的方法是引入一个转义字符, 通常是反斜线(\)。如果你在引号字符或反斜线之前加上一个反斜线, 那么第二个字符就失去了其它的特殊意义。 因此, 如果你想在 CSV 列中包含 she said "hey, ho" 这个字符串, 你必须把它写成 "she said \"hey, ho\""

使用反斜杠作为转义字符的工作变得更加复杂, 因为反斜线在 Raku(以及大多数其他编程语言)中也有特殊的含义。所以, 在普通的字符串文字中, 你必须将反斜线加倍, 才能产生一个反斜线:

say "a\\b"; # Output: a\b

你可以在 Raku 中关闭这种行为, 在字符串前面加上一个大写的 Q, 这样就可以关闭所有的转义序列:

say Q"a\b"; # Output: a\b say Q"a\\b"; # Output: a\\b

正则表达式没有这种模式, 所以你必须在正则表达式中使用双倍的反斜线来匹配字面的反斜线。

说到带转义字符的引号, 现在有两种情况需要考虑在引号字符串中使用: 无意义的常规字符和转义序列。常规字符是指除了引号或反斜线以外的所有字符, 字符类 <-["\\]> 描述了这一点。转义序列是一个反斜杠, 后面是引号或反斜线。用正则表达式的术语来说, 那就是 \\ <["\\]>。许多数据格式也允许在反斜线后面加上其他字符, 在这种情况下, 序列简化为 \\。(记住, 点可以匹配任何字符)。

将这两种情况结合在一起, 就可以得到这样的字符序列:

my regex quoted {
    \" # opening quote
    [
        <-[ " \\ ]> # regular character
        | \\ .      # escape sequence
    ]*
    \" # closing quote
}

如果你花了一分钟或更长的时间来阅读这本正则表达式, 不要担心。这是个需要拆解和理解的地方, 但这将是本书中涉及最多的正则表达式。 my regex quoted {…​} 构造声明一个命名正则表达式作为词法变量。稍后, 我们将学习 grammar, 在 garmmar 中我们可以省略 my

7.5. 测试正则表达式

正则表达式是一种代码。它们是声明性, 而不是程序性的, 这意味着在编写正则表达式时, 你要指定它匹配的是什么文本, 而通常不是如何匹配。但是, 它们仍然是代码, 你应该为代码编写测试, 以获得对他们按预期工作的信心, 并且能够改变他们而不用担心破坏其他使用它们的代码。

Raku 自带了一个测试模块, 可以很容易地编写这样的测试。 下面是上一节中的正则表达式的小测试套件:

my regex quoted {
    \" # opening quote
    [
            <-[ " \\ ]> # regular character
         | \\ .         # escape sequence
    ]*
    \" # closing quote
}

my @should-match =
    Q<"abc">,
    Q<"abc\\">,
    Q<"ac\\def\"ef">,
    ;

my @should-not-match =
    Q<abc>,
    Q<"abc"def">,
    Q<"ab\\"cdef">,
    ;

use Test;
plan 6;

for @should-match -> $s {
    ok $s ~~ / ^ <quoted> $ /,
        "Successful match of string $s";
}

for @should-not-match -> $s {
    nok $s ~~ / ^ <quoted> $ /,
        "Successful rejection of string $s";
}

让我们来详细探讨一下。一开始是我们熟悉的正则表达式声明。然后代码声明并初始化两个数组变量, @should-match@should-not-match

第一个包含了我们希望正则表达式匹配的字符串; 第二个变量包含了正则表达式不应该匹配的字符串的例子。

然后, 测试开始了。use Test; 导入测试模块,[36] 它提供了一些在脚本中进一步使用的函数。plan 是其中之一, plan 6; 告诉测试模块即将有六个测试用例。

接下来是两个 for 循环, 用于检查示例字符串。ok 是一个测试函数, 当使用真值作为其第一个参数时, 它可以使测试成功; 而假值则使测试失败。这里的第一个参数是锚定的正则表达式匹配的结果, $s ~~ / ^<quoted>$ /。第二个参数是测试的标签。在第二个循环中, 使用了测试函数 nok 来代替, 它的逻辑和 ok 相反: 它期望第一个参数是假值(如正则表达式匹配失败)。

当你运行这个脚本时, 它会产生这样的输出:

$ raku quotes-with-escapes.p6
1..6
ok 1 - Successful match of string "abc"
ok 2 - Successful match of string "abc\\"
ok 3 - Successful match of string "ac\\def\"ef"
ok 4 - Successful rejection of string abc
ok 5 - Successful rejection of string "abc"def"
ok 6 - Successful rejection of string "ab\\"cdef"

如果你改变了前三个示例字符串中的一个使正则表达式匹配失败, 你就会得到一个像这样的失败测试输出:

$ raku quotes-with-escapes.p6
1..6
ok 1 - Successful match of string "abc"
not ok 2 - Successful match of string "abc\\"a
# Failed test 'Successful match of string "abc\\"a'
# at quotes-with-escapes.p6 line 26
ok 3 - Successful match of string "ac\\def\"ef"
ok 4 - Successful rejection of string abc
ok 5 - Successful rejection of string "abc"def"
ok 6 - Successful rejection of string "ab\\"cdef"
# Looks like you failed 1 test of 6

对于文件中的许多测试, 甚至是许多测试文件, 有一个所有测试输出的总结是很有帮助的。通常与 Perl 5 捆绑在一起的 prove 程序可以理解测试的输出(以测试任何协议格式[37])并打印出一个简短的摘要:

$ prove -e raku quotes-with-escapes.p6
quotes-with-escapes.p6 .. ok
All tests successful.
Files=1, Tests=6, 1 wallclock secs ( 0.02 usr  0.00 sys +  0.24
cusr 0.01 csys = 0.27 CPU)
Result: PASS

如果测试失败, 它将重点放在失败的测试上, 这样更容易修复:

$ prove -e raku quotes-with-escapes.p6
quotes-with-escapes.p6 .. 1/6
# Failed test 'Successful match of string "abc\\"a'
# at quotes-with-escapes.p6 line 26
# Looks like you failed 1 test of 6
quotes-with-escapes.p6 .. Dubious, test returned 1 (wstat 256,0x100) Failed 1/6 subtests

Test Summary Report
-------------------
quotes-with-escapes.p6 (Wstat: 256 Tests: 6 Failed: 1)
  Failed test: 2
  Non-zero exit status: 1
Files=1, Tests=6, 0 wallclock secs ( 0.01 usr 0.01 sys 0.23 cusr 0.01 csys = 0.26 CPU)
Result: FAIL

prove 甚至在终端输出中增加了颜色, 绿色表示"所有测试成功", 红色表示"测试失败", 让状态一目了然。

在最初开发完你的正则表达式之后, 你很可能想用它们来做一些测试以外的事情。在这种情况下, 你可以将测试提取到一个子程序中, 只有当你用一个特殊命令行参数调用你的脚本时才会运行它们, 比如 --test:

my regex quoted {
    \" # opening quote
    [
        <-[ " \\ ]> # regular character
       | \\ .       # escape sequence
    ]*
    \" # closing quote
}

multi sub MAIN(Bool :$test!) {
    my @should-match =
        Q<"abc">,
        Q<"abc\\">,
        Q<"ac\\def\"ef">,
        ;

    my @should-not-match =
        Q<abc>,
        Q<"abc"def">,
        Q<"ab\\"cdef">,
        ;

    use Test;
    plan 6;

    for @should-match -> $s {
        ok $s ~~ / ^ <quoted> $ /,
            "Successful match of string $s";
    }

    for @should-not-match -> $s {
        nok $s ~~ / ^ <quoted> $ /,
            "Successful rejection of string $s";
    }
}

multi sub MAIN($input) {
    if $input ~~ / ^ <quoted> $ / {
        say "$input is a quoted string";
    }
    else {
        say "invalid input: $input";
        exit 1;
    }
}

在这个例子中, multi sub MAIN 引入了一个名为 MAIN 的子例程。Raku 会自动为你调用子程序 MAIN, 并将命令行参数转换为这个函数的参数。multi 意味着可以有一个以上的子例程。Raku 会调用参数中最适合的候选参数。:$test! 是一个命名参数, 因为前面的冒号(:)和后面的感叹号(!)使它成为必需的。

只有通过命名参数测试, 才能调用这个候选参数。这是它在命令行中的样子:

$ raku quote-checker --test
1..6
ok 1 - Successful match of string "abc"
ok 2 - Successful match of string "abc\\"
ok 3 - Successful match of string "ac\\def\"ef"
ok 4 - Successful rejection of string abc
ok 5 - Successful rejection of string "abc"def"
ok 6 - Successful rejection of string "ab\\"cdef"

或者,如果你想使用 prove 测试套件。

$ prove -e "" "raku quote-checker.p6 --test"
raku examples/quote-checker.p6 --test .. ok
All tests successful.
Files=1, Tests=6,  0 wallclock secs ( 0.01 usr  0.00 sys 0.26 cusr  0.02 csys = 0.29 CPU)
Result: PASS
----

第二个候选程序, 用 multi MAIN($input) 声明, 是程序的"正常"执行路径。它需要一个位置参数, 在命令行中是一个不是选项的字符串(所以不以减号(-)字符开头)。它检查参数是否是一个引号字符串, 并打印出相应的信息:

$ raku quote-checker '"yes"'
"yes" is a quoted string
$ raku quote-checker no
invalid input: no

shell 使用了一层引号, 这就是为什么在第一次调用时, 我在双引号字符串周围使用了单引号。这在标准的 POSIX shell 和 bash 中都可以使用; 其他的 shell 可能需要你使用不同的引号来运行这个例子。

7.6. 总结

当我们在为一个数据格式编写正则表达式时, 经常会发现我们对这个数据格式的了解比我们原先想象的要少。我们可以尝试找到一个正式规范来回答我们问题, 或者我们可以使用实验的方法, 结合我们对数据格式的问题目录, 来进行实验。

我们还讨论了在哪些正则表达式应该匹配的边界上使用断言, 以及巧妙地使用否定字符类来可靠地解析引用的字符串, 即使是在有转义字符的情况下也是如此。

测试可以让我们确信, 我们编写的正则表达式的匹配度不会超过也不会低于我们的期望, 我们探索了一种简单的方法来编写自动测试。

8. 重用和组合正则表达式

Raku 提供了很好的工具来组合正则表达式, 从而使其可重用。这激励着程序员像对待普通代码一样, 精心地组织和测试他们的正则表达式。

8.1. 命名正则表达式

给正则起名字, 并能够通过名称来引用这些正则, 是实现可组合性和重用正则表达式的第一步。

正如我们之前在一些例子中看到的那样, 你可以给正则表达式起名字, 就像你给变量、子程序等起名字一样。这就把正则表达式提升到了和语言中其他构造一样的高度, 使抽象化成为可能。

my regex byte {
    \d ** 1..3
    <?{ $/.Int <= 255 }>
}

my $str = '127.0.0.1';
say $str ~~ / ^ <byte> ** 4 % '.' $ /;

这里的 my regex byte { …​ } 声明了一个名为 byte 的正则表达式。regex 前面的关键字决定了作用域: my 是词法作用域的。 如果省略 my 关键字, 则声明一个附加到 grammar 上的正则表达式。稍后会有更多介绍。

Raku 中的正则表达式是代码对象, 就像子例程或方法一样。就像例程一样, 你可以通过在正则表达式的名字前加上一个 & 符号来引用正则表达式[38]:

say &byte.^name;   # Output: Regex
say &byte ~~ Code; # Output: True

与一般的子例程不同, 你不能直接调用正则表达式; 它需要一个游标对象用于跟踪其进度。这就是你尝试之后得到的结果:

$ raku -e 'my regex a { . }; a("x")'
No such method '!cursor_start' for invocant of type 'Str'

相反, 你可以使用智能匹配来调用正则表达式, 就像使用匿名正则表达式那样:

my regex a { . };
say "x" ~~ &a; # Output: ⌜x⌟

当然也可以从一个匿名的正则表达式内部调用它:

my regex a { . }; "x" ~~ / <a> /;

通过尖括号语法 <a> 来调用一个命名正则表达式, 会创建一个和被调用的正则表达式同名的命名捕获, 这里是 a。如果你想避免捕获, 你可以将其写为 <&a>

如果你想让捕获的名字不同, 则可以将其写为 <b=&a>。 这会生成一个命名捕获 b, 但会调用名为 a 的正则表达式。如果你写的是 <b=a>, 那么同一个捕获在名字 a 和名字 b 下都可用。还有更多方法可以调用命名正则表达式(表 8-1)。

Table 8. 调用命名正则表达式的语法形式

例子

描述

捕获

<a>

命名正则表达式调用

a

<&a>

非捕获命名正则表达式调用

(none)

<b=a>

带别名的命名正则表达式调用

a,b

<b=&a>

重命名的命名正则表达式调用

b

<?a>

命名正则表达式调用作为向前查看

(none)

<!a>

命名正则表达式调用作为否定向前查看

(none)

如果开口 < 后的第一个字符是一个单词字符, 那么就会产生一个与该单词同名的命名捕获。任何形式的标点符号 - 不管是用于向前查看的 ? 还是用于普通调用的 &, 都会抑制捕获。

8.1.1. 词法分析与回溯控制

在传统的解析文献中, 分析一段文本通常会经历两个阶段: 词法分析, 也称为标记化, 然后是实际的解析。

词法分析将一段文本分解为 token, 即带有分类标签的小块文本。例如, 如果我们要写一个小计算器, 输入 2 * (3 + 5) 就会产生一个像这样的 token 流:

2    number
*    product
(    opening parenthesis
3    number
+    addition
5    number
)    closing parenthesis

这些 token 最初是一个线性列表; 然后解析步骤将它们转换为一个树, 我们可以用来对表达式进行求值:

  *
  /\
  2 +
    /\
    3 5

这种两步法对于简单的东西, 比如基本的数学表达式, 效果很好, 但是当解析具有上下文相关的子语言时, 往往会失败。如果你要为 Raku 编写一个解析器, 主语言的词法分析与引用的字符串内部的词法分析是非常不同的, 这与正则表达式内部的词法分析有很大的区别。

为了适应这种情况, Raku 提供了关键字 token, 它引入了一个关闭回溯的正则表达式。我们的想法是, 词法分析应该足够简单, 一旦你决定了如何切割并标记一个 token, 你就不希望根据后来的输入来放弃这个决定。因此, 不需要回溯。

通过在普通的正则表达式匹配中进行词法分析, 我们就有了决定应用何种标记化所需的所有上下文。

本章的第一个例子涉及一个正则表达式 byte, 这个例子足够简单, 我们通常不需要回溯。因此, 我们可以重写它来代替使用 token:

my token byte {
    \d ** 1..3
    <?{ $/.Int <= 255 }>
}
my $str = '127.0.0.1';
say $str ~~ / ^ <byte> ** 4 % '.' $ /;

my token byte { …​ } 的作用与在正则表达式 my regex byte { :r …​} 中明确地禁用回溯是一样的。然而, 它的使用频率很高, 以至于它应该有自己的语法。

请注意, 我们有时使用 regex 这个词来指代使用 token 关键字声明的正则表达式。

8.2. 空白

回到标记化一个简单的数学表达式的例子, 我们忽略了一个重要的细节:输入中包含了 token 之间的空白, 而 token 流之间不包含空白。词法分析通常会丢弃无关紧要的空白。

并非所有的空白都是无关紧要的。 你不希望写出 "Hello, World", 并让它变成 Hello, World。 许多语言对空白做了区分, 即 token 之间的空白在 token 之间是无关紧要的, 但 token 内的空白(如引号字符串)是重要的。

然而, 它比这更微妙:在 SQL, Perl, Python, JavaScript 等语言中, 当一个类似于单词的 token 后跟着一个非单词的 token 时, 你可以省略空白, 或者反过来(例如 a+ba + b 是相同的)。 然而, 禁止将两个类似于单词的 token 连接在一起而不留任何空白。 相反, 如果两个 token 之间存在空白, 那么空白的数量就无所谓了。[39]

例如, 下面这两个 SQL 语句生成相同的解析树:

SELECT username,first_login FROM account;
SELECT
       username,
       first_login
FROM   account;

相反, 下面是一个语法错误, 因为它连接了类似于单词的 token 而不带任何空格:

SELECTusername,first_loginFROMaccount;

Raku 定义了一个名为 ws 的正则表达式, 它是 whitespace 的缩写, 它按照前面的规则解析空白:任意数量的空白(空格、制表符、换行符, …​…​), 但至少有一个空白, 除非它位于单词边界。或者用代码表示, regex ws { <!ww> \s* }(其中 <!ww> 匹配除了单词内之外的任何地方)[40]

还有一个快捷方式可以帮助你避免在代码中到处使用显式的 <ws><.ws> 调用。[41] 如果使用关键字 rule 而不是 regextoken 来声明正则表达式, 那么 Raku 会在你的正则表达式中使用空格的地方插入隐式的 <.ws> 调用。这与在正则表达式中使用 :sigspace:s 修饰符具有相同的效果。

由于 ws 的定义使用了术语 \s*, 所以正则表达式中的单个空白可以匹配字符串中的任何数量的空白, 包括制表符、空格、垂直制表符等。

再次考虑我们的数学表达式, 我们可以编写以下正则表达式来匹配两个数字之和的最简单情况:

my token number { \d+ }
my rule  sum    { <number> '+' <number> }

say '1+2'   ~~ / ^ <sum> $ /;
say '1 + 2' ~~ / ^ <sum> $ /;

第二行相当于写为:

token sum { <number> <.ws> '+' <.ws> <number> <.ws> },

但可读性更强。最后两行都匹配成功了, 唯一的区别是它们匹配的空白:

⌜1+2⌟
 sum => ⌜1+2⌟
  number => ⌜1⌟
  number => ⌜2⌟
⌜1 + 2⌟
 sum => ⌜1 + 2⌟
  number => ⌜1⌟
  number => ⌜2⌟

插入的隐式的 <.ws> 调用并不完全适用于正则表达式中每一个空白的出现。特别是正则表达式开头的空白, 像在 :r:s 这样的修饰符之后的空白,或者在开口花括号或开口圆括号之后的空白, 还有 &, &&|| 之后的空白, 不会被替换为隐式 <.ws> 调用。一般的概念是, 规则解析匹配内部和匹配结尾的空白, 但不解析匹配开头的空白。

如果你使用 rule, 你必须意识到空白是重要的, 并且原子和它的量词之间的空白也有影响:

say "a a" ~~ rule { a+ }
say "a a" ~~ rule { a + }

第一行打印的是「a」,而第二行打印的是「a a], 包括了最后一个 a。a 和 + 之间的空白被解释为 <.ws> 调用, 所以第二个 rule 等同于:

token { [a <.ws>]+ }

最后, 你需要注意的是, 就像正则表达式中的任何部分一样 隐式调用 ws 也会使你的匹配失败:

say 'ab' ~~ rule { a b } # Output: Nil

这里 a 和 b 之间的隐式 <.ws> 调用不匹配, 所以规则 a 作为整体也不匹配。

8.3. Grammars

命名正则表达式提供了第一层抽象, 但我们往往需要更多层的抽象。大多数高级编程语言都有模块、命名空间、类, 甚至三者都有, 以管理函数和方法的重用。

Raku 也提供了这些更高级别的抽象, 并使它们可用于正则表达式中。Grammar 是一个类, 它提供了一些调用正则表达式的工具。那么正则表达式是这个 grammar 中的方法:

grammar IPv4Address {
    token byte {
        \d ** 1..3
        <?{ $/.Int <= 255 }>
    }

    token TOP {
        <byte> ** 4 % '.'
    }
}

my $str = '127.0.0.1';
if IPv4Address.parse($str) {
    say join ', ', $<byte>.list;
        # Output: 127, 0, 0, 1
}

第一个 grammar 是用 grammar 关键字声明的, 后面是 grammar 的名称 IPv4Address。grammar 是一种自动添加类型为 Grammar[42] 的父类, 并提供了一些方法, 如 parsesubparse 等。

grammar 的主体由花括号分割。 在主体里面, 我们使用 regextoken 关键字声明正则表达式。请注意, 我们省略了声明前面的 my 声明符, 因为现在正则表达式是作用域属于 grammar 的方法。

parse 方法调用名为 TOP 的正则表达式, 并隐式将其锚定到字符串的开头和结尾。相比之下, subparse 方法仅锚定到字符串的开头。parsesubparse 方法都接收一个可选的, 命名参数规则, 你可以用它来调用 TOP 以外的正则表达式:

say IPv4Address.subparse($str, :rule<byte>);        # Output: ⌜127⌟
say IPv4Address.parse($str, :rule<byte>);           # Output: Nil

这里 IPv4Address.parse($str, :rule<byte>) 失败了, 因为正则表达式 <byte> 不能匹配整个输入字符串, 并且 parse 隐式将正则表达式的结尾锚定到字符串的末尾。相反, subparse 匹配输入字符串的第一个数字。

在 grammar 内部, 调用同一 grammar 的另一个正则表达式, 其作用和外部一样; 带尖括号 :<byte>。但如果你想抑制捕获, 你必须使用一个点代替 & 符号: <.byte>。重命名也是一样, 因此 <octect=.byte> 会调用正则表达式 byte, 但产生一个名为 octect 的捕获。点(.)的使用类似于方法调用语法, 它也使用点号。

8.4. 使用 Grammars 重用代码

Raku 提供了两种重用面向对象代码的方法:继承和角色组合。因为 grammar 实际上是类, 所以这两种机制也适用于 grammar。

8.4.1. 继承

继承是一种特殊化的形式。如果你有一个通用的 grammar, 然后想一个经过一些调整的变体, 那么这个调整后的变体可以继承更通用的 grammar。

例如, 考虑 SQL 的 grammar, 即 结构化查询语言[43]。这种 grammar 是有标准的, 但有些实现是方言。MySQL 就是这样的一个例子, 它使用反引号而不是双引号来引用表名和列名。然后, MySQL 方言的 grammar 将从标准的 SQL grammar 中继承, 并覆盖用于解析表名和列名的正则表达式。

为了说明这个想法, 让我们只关注解析 SQL 列名。根据 SQL 标准规定, 它们要么是标识符, 要么是双引号字符串:

grammar StandardSQL {
    regex TOP {
        'SELECT' \s+ <name>
    }
    regex name {
          <identifier>
        | <quoted_name>
    }
    regex quoted_name {
        \" <-["]>+ \"
    }
    regex identifier {
        « <:alpha> \w* »
    }
}

say StandardSQL.parse('SELECT salary');
say StandardSQL.parse('SELECT "monthly salary"');

这两个解析都成功了, 并且它们产生了这样的输出:

⌜SELECT salary⌟
  name => ⌜salary⌟
   identifier => ⌜salary⌟
⌜SELECT "monthly salary"⌟
 name => ⌜"monthly salary"⌟
  quoted_name => ⌜"monthly salary"⌟

如前所述, MySQL 使用反引号而不是双引号来引用标识符。与其重复整个 grammar, 我们可以编写一个继承自 StandardSQL 的 grammar MysqlSQL, 只覆盖正则表达式 quoted_name:

grammar MysqlSQL is StandardSQL {
    regex quoted_name {
        \` <-[`]>+ \`
    }
}
say MysqlSQL.parse('SELECT `monthly salary`');

这里 grammar 声明中的文本 "is StandardSQL" 定义了从 StandardSQL grammar 中继承。继承意味着, 如果在子类中调用一个正则表达式或方法, 而在子类中没有定义, 则会调用父类中同名的方法。

8.4.2. 角色组合

角色 - 在其他编程语言中也被称为 trait - 是指可以复制到类或 grammar 中的一块函数(例如方法和正则表达式)。这种复制称为组合。

角色组合非常适合从更小的, 独立的部分组装一个 grammar。例如, 解析数字和引号字符串是相当普遍的, 在解析 SQL、JSON 和几种类型的配置文件时, 这两个可能都是必要的。解析数字也适用在解析数学表达式:

role ParseInteger {
    token unsigned { <[0..9]>+ }
    token signed   { ['+' | '-']? <unsigned> }
}

role ParseFloat does ParseInteger {
    token escale { <[eE]> <unsigned> }
    token float {
        $<sign>=<[+-]>?
        [
          $<coeff> = [ <[0..9]>* '.' <unsigned> ] <escale>?
        | $<coeff> = [               <unsigned> ] <escale>
        ]
    }
}

grammar Sum does ParseFloat {
    token number { <signed> | <float> }
    rule TOP { <number> '+' <number> }
}

grammar JSON does ParseFloat {
    token value {
          <signed>
        | <float>
          # more options go here
    }
    # rest of the grammar here
}

say Sum.parse('2 + -4');

这个例子显示了两个包含正则表达式的角色: role ParseInteger 有两个 token, 一个用于解析不带任何符号的整数(token unsigned), 一个用于解析可能带有 +- 符号的整数。第二个角色, ParseFloat 通过声明 does ParseInteger 来使用第一个角色 。然后, 它声明一个 token float 来解析浮点数。

与继承不同, 角色组合在编译时检测名称冲突, 并强制你解决这些冲突。这使得多个角色可以安全地编译到同一个类中, 或者多个角色可以安全地编译到一个角色中。

8.5. Proto Regexes

假设你想为 JSON[44](也称为 JavaScript 对象表示法) 编写一个 grammar。json.org 的主页肯定会告诉你, 当解析一个值时, 你期望的是一个字符串、数字、对象、数组或字面量字符串、true、false 或 null。没问题, 你可以这样写:

grammar JSON {
    token value {
        | <string>
        | <number>
        | <object>
        | <array>
        | 'true'
        | 'false'
        | 'null'
    }
    # more tokens and rules go here
}

这很管用, 但它的可扩展性并不是很好。假设你想解析一个扩展的 JSON 方言, 例如添加 2015-12-24 这样的无引号字符串形式的日期类型。你可以通过继承上一个例子中暗示的 JSON grammar 来实现, 编写一个 token date 来解析日期, 然后你必须覆盖 token value, 把父 grammar 中的所有备选项都列出来, 然后再列出你自己的。

这种重复不仅烦人, 容易出错, 而且还会使得同一个 grammar 无法进行多次修改。如果别人想编写另一个扩展, 添加不同类型的新值(比如说引用类型), 他们的扩展不会意识到你的扩展, 所以用户不能轻易地将它们混合在一起。

Raku 提供了一个解决这个问题的方法: proto regexes。原型正则表达式是一个正则表达式的集合, 它们一起构成了一个很大的备选项, 就像每个备选项都是由一个 | 来分隔一样。下面是我们将 JSON grammar 的一部分写成了原型正则表达式:

grammar JSON {
    proto token value {*};
    token value:sym<string> { <string> }
    token value:sym<number> { <number> }
    token value:sym<object> { <object> }
    token value:sym<array>  { <array>  }
    token value:sym<true>   { <sym>    }
    token value:sym<false>  { <sym>    }
    token value:sym<null>   { <sym>    }
    # more tokens and rules go here
}

proto token {*} 引入一个带有名称 value 的原型正则表达式(或 token)。这就指示 Raku 将有更多的名称为 value 的正则表达式, 并加上一些小的补充。这些小的补充的形式是: sym <SOMETHING>, 帮助你和编译器将这些正则表达式分开。这种正则表达式的主体可以是你所知道的任何东西, 所以我们可以在这里实现, 而不是在 token value:sym<number> 里面调用 token number:

token value:sym<number> {
    '-'?
    [ 0 | <[1..9]> <[0..9]>* ]
    [ \. <[0..9]>+ ]?
    [ <[eE]> [\+|\-]? <[0..9]>+ ]?
}

在每个单独候选项中, :sym<…​> 里面的名字可用作特殊符号 <sym> 使用。所以不要写成:

token value:sym<null> { 'null' }

我们可以这样写:

token value:sym<null> { <sym> }

以避免名称的重复。

让我们把这个例子稍作删减, 变成可运行的代码:

grammar JSONValue {
    proto token value {*};
    token value:sym<true>  { <sym> }
    token value:sym<false> { <sym> }
    token value:sym<null>  { <sym> }
    token TOP { <value> }
}

这是一个非常简约的 grammar, 只解析 JSON 值 truefalsenull。我们的扩展使其解析 ISO 8601 格式的日期, 可以是一个添加了候选 token 的简单角色:

role DateValue {
token value:sym<date> {
        <[0..9]>**4 '-' <[0..9]>**2 '-' <[0..9]>**2
    }
}

我们不需要在角色中声明 proto token value, 因为它会被混合到包含此声明的 grammar 中。

为了使用 DateValue 扩展, 我们可以基于 JSONValue 和角色生成一个 grammar:

grammar JSONValueWithDate is JSONValue does DateValue { };

for <true null 2015-12-24 42> -> $str {
    say $str, ': ', ?JSONValueWithDate.parse($str);
}

这使用了 JSONValue 的继承, 也使用了 DateValue 的角色组合来生成一个新的 grammar, 现在可以解析 truefalsenulldate 值。它产生了这样的输出:

true: True
null: True
2015-12-24: True
42: False

正则表达式外的 ? 运算符强制使用布尔上下文, 这就是为什么我们在输出中看到 TrueFalse, 而不是 Match 对象和 Nil

我们可以避免显式地创建 grammar JSONValueWithDate, 而只需使用 but 操作符就地组合一个匿名的 grammar:

for <true null 2015-12-24 42> -> $str {
    say $str, ': ', ?(JSONValue but DateValue).parse($str);
}

but 操作符在左侧创建一个类型或对象的副本, 对其应用角色, 并返回结果。

这两种方法都可以用来给 grammar 添加多个扩展:

role IntegerValue {
    token value:sym<integer> { <[0..9]>+ }
}

my $grammar = (JSONValue but DateValue) but IntegerValue;

for <true null 2015-12-24 42> -> $str {
    say $str, ': ', ?$grammar.parse($str);
}

现在, 由 JSONValueDateValueIntegerValue 组成的 grammar 可以解析所有四个示例输入字符串。扩展角色不需要考虑其他角色; 它们是可以自由组合的。

解析 JSON 子集的例子来自 JSON::Tiny[45] 模块, 该模块使用可管理的 Raku grammar[46] 解析 JSON。我们越来越接近于能够创建具有许多实际应用的可扩展 grammar。

8.6. 总结

Raku 提供了命名正则表达式以方便重用。为了简化词法分析, tokenrule 关键字禁用了回溯, rule 关键字还会隐式解析空白。

由于正则表达式可以用作方法, 所以可以用面向对象编程中的强大技术来管理和重用正则表达式。Grammar 对正则表达式进行分组、继承和角色组合使它们可以被重用。

最后, proto regexes 确保了扩展可以以自然的方式进行, 而不会干扰 grammar 中相同位置的其他潜在扩展。

9. 使用 Grammar 进行解析

Grammar 是众所周知的用于解析的瑞士军刀[47]

在本章中, 我们将对它们进行更详细的探讨。最重要的是, 我们将讨论如何利用它们的力量。

9.1. 理解 Grammar

Grammar 实现了一种自上而下的解析方法。解析的入口点, 通常是正则表达式 TOP, 了解粗粒度结构和调用更多的正则表达式, 从而进入血淋淋的细节。递归也会涉及其中。例如, 如果你解析一个数学表达式, 则项可以是一对括号内的任意表达式。

这是一种自上而下的结构, 或者更准确地说是一种递归下降解析器[48]。如果不涉及回溯, 我们将其称为预测性解析器, 因为在字符串中的每个位置, 我们都知道我们要找的是什么 - 我们可以预测下一个 token 是什么(即使我们只能预测它可能是一组候选者之一)。

所得到的匹配树在结构上完全对应于 grammar 中的正则表达式的调用结构。让我们考虑一下解析一个数学表达式, 它只包含运算符 *, + 和用于分组的括号:

grammar MathExpression {
    token TOP    { <sum> }
    rule sum     { <product>+ % '+' }
    rule product { <term>+ % '*' }
    rule term    { <number> | <group> }
    rule group   { '(' <sum> ')' }
    token number { \d+ }
}

say MathExpression.parse('2 + 4 * 5 * (1 + 3)');

从 grammar 本身来看, 你已经可以看到递归的潜力: sum 调用 product, product 调用 term, term 调用 group, term 再调用 sum。这样就可以对任意深度的嵌套表达式进行解析。

运行前面的示例, 会产生以下匹配对象:

「2 + 4 * 5 * (1 + 3)」
 sum => 「2 + 4 * 5 * (1 + 3)」
  product => 「2 」
   term => 「2 」
    number => 「2」
  product => 「4 * 5 * (1 + 3)」
   term => 「4 」
    number => 「4」
   term => 「5 」
    number => 「5」
   term => 「(1 + 3)」
    group => 「(1 + 3)」
     sum => 「1 + 3」
      product => 「1 」
       term => 「1 」
        number => 「1」
      product => 「3」
       term => 「3」
        number => 「3」

如果你想知道某个特定的数字是如何解析的, 可以按照路径回溯, 查找当前行上面少缩进的行, 比如说, 数字 1 是通过 token number 解析的, 从 term 调用, 从 product 调用, 以此类推。

我们可以通过从 token number 中引发异常来验证:

token number {
    (\d+)
    { die "how did I get here?" if $0 eq '1' }
}

这确实显示了回溯中的调用链, 最直接的上下文在最上面:

how did I get here?
  in regex number at bt.p6 line 9
  in regex term at bt.p6 line 5
  in regex product at bt.p6 line 4
  in regex sum at bt.p6 line 3
  in regex group at bt.p6 line 6
  in regex term at bt.p6 line 5
  in regex product at bt.p6 line 4
  in regex sum at bt.p6 line 3
  in regex TOP at bt.p6 line 2
  in block <unit> at bt.p6 line 13

这个 grammar 只使用 token 和 rule, 所以没有涉及回溯, 而且这个 grammar 是一个预测性解析器。这是相当典型的。许多 grammar 在不涉及回溯的情况下, 或者只涉及到几个地方的回溯, 都能正常工作。

9.1.1. 递归下降解析和优先级

MathExpression grammar 有两个结构相同的规则:

rule sum { <product>+ % '+' }
rule product { <term>+ % '*' }

相反, 我们可以这样写:

rule expression { <operator>+ % <term> }
token operator { '*' | '+' }

甚至使用上一章中讨论过的 proto token 构造来解析不同的运算符。我之所以选择第一种比较重复的方法, 是因为它使匹配结构与运算符 *+ 的优先级相对应。

在计算数学表达式 1 + 2 * 5 时, 数学家和大多数编程语言都会首先计算 2 * 5, 因为 * 运算符的优先级比 + 更高。然后再将结果代入表达式中, 导致 1 + 10, 最后得到 11 作为结果。

当用第一版 grammar 解析这样的表达式时, 解析树的结构表达了这种的分组: 它的顶层是一个单一的 sum, 操作数为 12 * 5

这是做是有代价的: 我们需要为每个优先级分别设置一个单独的规则和名称, 而且由此产生的匹配对象的嵌套, 每个优先级至少有一个级别。此外, 以后再增加更多的优先级也不是一件小事, 而且很难用通用的方式来做。如果你不愿意接受这些成本, 你可以使用扁平化的模型, 用一个单一的 token 来解析所有的运算符。如果这时你需要一个结构以反映优先级的方式, 你可以编写将列表转换为树的代码。这通常称为运算符优先级解析器[49]

9.1.2. 左递归和其它陷阱

为了避免无限递归, 你必须注意每一个可能的递归循环使游标位置前进至少一个字符。在 MathExpression grammar 中, 唯一可能的递归循环是 sum → product → term → group → sum, 而 group 只有在消耗一个初始的开括号 ( 时才匹配。

如果递归不消耗字符, 则称为左递归, 需要特殊的语言支持, 这是 Raku 所不提供的。一个典型的例子是:

token a { <a>? 'a' }

它本可以与正则表达式 a+ 匹配相同的输入, 但是无限循环而不处理。

一个常见的避免左递归的技术是有一个结构, 在这个结构中, 你可以将正则表达式从通用(这里是 sum)到特定(number)进行排序。你只需要小心翼翼, 当一个正则表达式偏离这个顺序时, 检查消耗的字符(例如, group 调用 sum)。

另一个潜在的无限循环的来源是当你量词化一个可以匹配空字符串的化正则表达式时。这可能发生在解析一个实际允许空的语言时。例如, 在 UNIX shell 中, 你可以通过潜在的右侧留空来分配变量:

VAR1=value
VAR2=

在为 UNIX shell 命令编写 grammar 时, 可能会很想写一个可能匹配空字符串的 token string { \w* }。在允许有一个以上的字符串文字的情况下, <string>+ 可能会挂起, 因为有效的正则表达式 [\w*]+ 会无限次地尝试匹配一个零宽度的字符串。

一旦你意识到这个问题, 解决方法很简单: 更改 token, 使其不允许空字符串(token string { \w+ }), 并明确地处理允许空字符串的情况:

token assignment {
    <variable> '=' <string>?
}

9.2. 从简单开始

即使 grammar 是从上而下地工作, 但开发一个 grammar 也是自下而上地工作。Grammar 的整体结构通常从一开始并不明显, 但你通常对终端 token 有一个很好的想法: 那些直接匹配文本而不调用子规则的 token。在前面解析数学表达式的例子中, 你可能从一开始就不知道如何安排解析 sumproduct 的规则, 但很可能你知道你在某个时候必须解析一个数字, 所以你可以从写作开始:

grammar MathExpression {
    token number { \d+ }
}

这样做虽然不多, 但也不是很复杂, 这也是克服程序员在遇到新问题领域的挑战时有时会遇到的写作障碍的好方法。当然, 只要你有了 token, 就可以开始写一些测试了:

grammar MathExpression {
    token number { \d+ }
}

multi sub MAIN(Bool :$test!) {
    use Test;
    plan 2;
    ok MathExpression.parse('1234', :rule<number>),
        '<number> parses 1234';
    nok MathExpression.parse('1+4', :rule<number>),
        '<number> does not parse 1+4';
}

现在, 你可以开始构建更复杂的表达式:

grammar MathExpression {
    token number { \d+ }
    rule product { <number>+ % '*' }
}

multi sub MAIN(Bool :$test!) {
    use Test;
    plan 5;
    ok MathExpression.parse('1234', :rule<number>),
        '<number> parses 1234';
    nok MathExpression.parse('1+4', :rule<number>),
        '<number> does not parse 1+4';
    ok MathExpression.parse('1234', :rule<product>),
        '<product> can parse a simple number';
    ok MathExpression.parse('1*3*4', :rule<product>),
        '<product> can parse three terms';
    ok MathExpression.parse('1 * 3', :rule<product>),
        '<product> and whitespace';
}

在测试的早期就加入空格是值得的。前面的例子看起来很无辜, 但最后的测试实际上是失败的。在 1* 之间没有匹配空格的规则。在 <number>+ 量词之间的正则表达式中添加一个空格, 会使测试再次通过, 因为空格插入了一个隐式的 <.ws> 调用。

如果你开始时真的很简单, 并尽快抓住它们, 这些细微之处就很容易被抓住。相反, 如果你屈服于从上到下写下整个 grammar 的诱惑, 你可能会花很多时间来调试为什么一些看似简单的东西, 比如一个额外的空格会导致解析失败。

9.3. 组装完整的 Grammar

一旦你写出了基本的用于词法分析的 token, 你就可以进展到组合 token 了。通常, token 不会在其匹配的边界处解析空格, 所以组合它们的规则就是这样做的。

在上一节中的 MathExpression 例子中, 规则 product 直接调用 number, 尽管我们现在知道最终版本使用了一个中间步骤 - 规则术语, 它也可以解析括号中的表达式。引入这个额外的步骤并不会使测试无效。

我们已经编写了 product, 因为它在早期版本中匹配的字符串仍然匹配。当你开始使用一个处理语言的子集的 grammar 时, 引入更多的层数就会自然而然地发生, 而这个子集是你后来来扩展的。

9.4. 调试 Grammar

正则表达式或 grammar 有两种失败模式: 当它不应该匹配时, 它匹配了(误报), 或者当它应该匹配时, 它没有匹配(漏报)。通常, 误报模式比较容易理解, 因为你可以检查结果的匹配对象, 看看哪一个正则表达式匹配了字符串的哪一部分。

有一个方便的工具来调试漏报: Grammar::Tracer 模块。如果你在一个包含 grammar 的文件中加载该模块, 运行该 grammar 会产生诊断信息, 可以帮助你找出匹配出错的地方。

请注意, 这只是为开发者提供的诊断工具; 如果你想给终端用户提供更好的错误信息, 请阅读第十一章的改进建议。

你需要安装 Raku 模块 Grammar::Debugger, 其中也包含 Grammar::Tracer。如果你使用的是 moritzlenz/raku-regex-alpine docker 镜像, 这已经为你完成了。如果你通过其它方式安装了 Raku, 你需要在命令行上运行:

zef install Grammar::Debugger

如果尚未安装 zef, 请按照 zef 在 GitHub 页面的安装说明进行安装[50]

让我们来看看 Tadeusz Sośnierz 所编写的 Raku 模块 Config::INI[51]。它包含以下 grammar[52](这里稍作修改):

grammar INI {
    token TOP      {
                        ^
                        <.eol>*
                        <toplevel>?
                        <sections>*
                        <.eol>*
                        $
                   }
    token toplevel { <keyval>* }
    token sections { <header> <keyval>* }
    token header   { ^^ \h* '[' ~ ']' $<text>=<-[ \] \n ]>+ \h* <.eol>+ }
    token keyval   { ^^ \h* <key> \h* '=' \h* <value>? \h* <.eol>+ }
    regex key      { <![#\[]> <-[;=]>+ }
    regex value    { [ <![#;]> \N ]+ }
    token eol      { [ <[#;]> \N* ]? \n }
}

假设我们想了解为什么它不能解析下面这段输入文本:

a= b
[foo]
c: d

所以, 在 grammar 之前, 我们插入一行:

use Grammar::Tracer;

然后, 添加一小段代码, 调用该 grammar 的 .parse 方法:

INI.parse(q:to/EOF/);
a= b
[foo]
c: d
EOF

这样会产生一个相当大但信息量很大的输出。

每个条目都由一个正则表达式的名称组成, 比如 TOPeol(表示行的尽头), 然后是它所调用的正则表达式的缩进输出。在每一个正则表达式后面都有一个星号(*)和 MATCH(匹配), 后面跟着正则表达式匹配的字符串片段, 如果正则表达式失败则为 FAIL

让我们逐条看一下输出, 即使它是以一个大块的形式出现。

这告诉我们, TOP 调用了 eol, 但匹配失败了。对 eol 的调用使用 * 来量词化, 这不会导致 TOP 的匹配失败。 然后 TOP 调用 key, 匹配文本 "a" value 匹配文本 "b"。然后 eol 正则表达式继续匹配换行符, 第二次尝试失败(因为一行中没有两个换行符)。这会导致初始的 keyval token 匹配成功。第二次调用 keyval 匹配很快就成功了(在调用 key 的时候)。然后, token toplevel 的匹配成功进行, 消耗了字符串 "a = b\n"。

到目前为止, 这一切看起来都和预期的一样。现在我们来看看第二大块的输出:

TOP 接下来调用 section, 其中 token header 成功匹配了字符串 "[foo]\n"。然后, keyval 调用 key, 匹配了整行 "c:d\n"。等等, 这不对劲吧, 我们可能会期望 key 只匹配 c。我当然不会想到它会匹配结尾的换行符。因为输入中没有等号, 导致正则表达式引擎根本不会调用正则表达式 value。但由于 keyval 又是用星 * 量词进行量化, 所提调用的正则表达式部分的匹配成功只是匹配到了标题 "[foo]\n"。

下面是 Grammar::Tracer 输出的最后一部分:

从这里开始就是 FAILs。第二次调用 section 再次尝试解析一个标题, 但它的下一个输入仍然是 "c:d\n", 所以它失败了, 就像 token TOP 中的字符串末尾锚点 $ 一样, 方法解析中的整体匹配失败。

所以我们已经知道正则表达式 key 匹配了整行 c:d\n, 但是由于后面没有等号(=), token keyval 无法解析这一行。由于没有其他的正则表达式(特别是没有 header)与之匹配, 这就是匹配失败的地方。

从这个例子中我们可以看到, 尽管我们必须仔细查看它的输出来定位, 但 Grammar::Tracer 可以让我们确定解析失败的位置。当你在终端上运行它时, 你会自动得到彩色的输出, FAIL 是红色的, MATCH 是绿色的, 而 token 名则是白色的(而不是通常的灰色)输出。这使得从底部(失败的匹配通常会留下红色的 FAIL)到后面的成功匹配, 然后在匹配和失败之间的边界附近寻找。

由于调试会带来很大的心理负担, 而且 Grammar::Tracer 的输出往往会迅速增长, 所以一般建议将失败的输入减少到最小的样本。在前面描述的案例中, 我们可以删除输入字符串的第一行, 并保存十行 Grammar::Tracer 的输出来查看。

9.5. 解析空白和注释

如前所述, 解析不重要的空白的常用方法是调用 <.ws>, 通常是通过在规则中使用空白处来隐式地解析。默认的 ws 实现, <!ww>s*, 适用于多种语言, 但它有其局限性。

在很多文件格式和计算机语言中, 有大量的空白空间会被 <.ws> 占据。这些文件包括 INI 文件(其中换行通常表示一个新的键/值对)、Python 和 YAML(其中缩进用于分组)、CSV(其中换行表示新的记录)和 Makefile(其中缩进需要使用制表符)。

在这些情况下, 最好的做法是在自己的 grammar 中覆盖 ws, 只匹配不重要的空白。让我们来看看第二个最小化的 INI 解析器, 它是在上一节中描述的基础上独立开发的。

grammar INIFile {
    token TOP { <section>* }
    token section {
        <header>
        <keyvalue>*
    }
    rule header {
        '[' <-[ \] \n ]>+ ']' <.eol>
    }
    rule keyvalue {
        ^^
        $<key>=[\w+]
        <[:=]>
        $<value>=[<-[\n;#]>*]
        <.eol>
    }
    token ws { <!ww> \h* }
    token eol {
        \n [\h*\n]*
    }
}

这个解析器可以解析像这样的简单的 INI 配置文件:

[db]
driver: mysql
host: db01.example.com
port: 122
username: us123
password: s3kr1t

请注意这个 grammar 是如何使用两个路径来解析空白的: 一个自定义的 ws token, 它只匹配水平的空白和制表符, 另一个单独的 token eol, 它匹配(重要的)断行符。eol token 也会吞噬其他仅由空格组成的行。

如果一种语言支持注释, 而你不希望它们出现在你的解析树中, 你可以在你的 ws 标记中, 或者在 eol(或你的等价物)中解析它们。具体是哪一种, 要看注释在什么地方被允许。在 INI 文件中, 它们只允许在键/值对之后或单独的一行中出现, 所以 eol 是最合适的地方。相比之下, SQL 允许在每一个允许空格的地方都有注释, 所以在 ws 中解析它们是很自然的。

# comment parsing for SQL:
token ws { <!ww> \s* [ '--' \N* \n ]* }

# comment parsing for INI files:
token eol { [ [ <[#;]> \N* ]? \n ]+ }

9.6. 保存状态

一些比较有趣的数据格式和语言需要解析器来存储东西(至少是暂时的), 以便能够正确解析它们。C 语言和其他受其语法启发的语言(如 C++ 和 Java)就是一个例子。这些语言允许变量声明的形式为变量=初始值, 就像这样。

int x = 42;

这是有效的语法, 但前提是第一个单词是类型名。相反, 这将是无效的, 因为 x 不是一个类型:

int x = 42; x y = 23;

从这些例子中可以清楚地看出, 解析器必须有一个它所知道的所有类型的记录。因为用户也可以在代码文件中声明类型, 所以解析器必须能够更新这个记录。

许多语言还要求在引用符号(变量、类型和函数)之前必须声明这些符号。这也需要 grammar 来记录哪些符号已经声明了, 哪些没有声明。这条记录了什么已经声明了什么(以及什么是类型或不是类型, 可能还有其他的元信息)的记录被称为符号表。

与其解析完整的 C 语言编程语言, 不如让我们考虑一个最小化的语言, 它只允许对数字列表和变量进行赋值。

a= 1
b= 2
c = a, 5, b

如果我们不强加声明规则, 那么写一个 grammar 就相当容易了:

grammar VariableLists {
    token TOP        { <statement>* }
    rule statement   { <identifier> '=' <termlist> \n }
    rule termlist    { <term> * % ',' }
    token term       { <identifier> | <number> }
    token number     { \d+ }
    token identifier { <:alpha> \w* }
    token ws         { <!ww> \h* }
}

现在我们要求只有在变量被赋值后才能使用, 那么下面的输入将是无效的, 因为 b 在第二行中没有声明, 但在第二行中使用了 b。

a= 1
c = a, 5, b
b= 2

为了维护一个符号表, 我们需要三个新的元素: 符号表的声明, 一些代码, 当赋值被解析后, 将变量名添加到符号表中, 最后是检查我们在术语表中遇到变量时, 是否已经声明了变量。

grammar VariableLists {
    token TOP {
        :my %*SYMBOLS;
        <statement>*
    }
    token ws { <!ww> \h* }
    rule statement {
        <identifier>
        { %*SYMBOLS{ $<identifier> } = True }
        '=' <termlist>
        \n
    }
    rule termlist { <term> * % ',' }
    token term { <variable> | <number> }
    token variable {
        <identifier>
        <?{ %*SYMBOLS{ $<identifier> } }>
    }
    token number { \d+ }
    token identifier { <:alpha> \w* }
}

在 token TOP 中, :my %SYMBOLS 声明了一个变量。正则表达式中的声明以冒号(:)开始, 以分号(;)结束。在这之间, 它们看起来就像 Raku 中的普通声明一样。%sigil 表示变量是一个散列 - 字符串键与值之间的映射。而 使它成为一个动态变量 - 一个不限于当前作用域的变量, 也可以被当前作用域中调用的代码(或者是正则表达式, 也是代码)看到。因为这是一个非常大的作用域, 所以习惯上选择大写字母的变量。

第二部分, 将符号添加到符号表中, 在规则语句中发生。

rule statement {
    <identifier>
    { %*SYMBOLS{ $<identifier> } = True }
    '=' <termlist>
    \n
}

在花括号里面是普通(非正则表达式)的 Raku 代码, 所以我们可以用它来处理 %*SYMBOLS 这个散列。表达式 $<identifier> 访问变量名称的捕获[53]。因此, 如果这条规则解析了变量 a, 那么这条语句将 %*SYMBOLS{'a'} 设置为 %*SYMBOLS{'a'}。= True

代码块的位置是相关的。把它放在 termlist 调用之前, 意味着在解析 termlist 时已经知道这个变量, 所以它接受像 a = 2, a 这样的输入, 如果我们先调用 termlist, 这样的输入会被拒绝。

说到拒绝, 这部分发生在 token variable 中, term 现在调用新的 token variable(之前直接调用 identifier), 变量验证之前已经声明了标识符。

token term { <variable> | <number> }
token variable {
    <identifier>
    <?{ %*SYMBOLS{ $<identifier> } }>
}

你可能还记得在前面的例子中, <?{…​}> 执行了一段 Raku 代码, 如果返回的值为 false, 则解析失败。如果 $<identifier> 不在 %*SYMBOLS 中, 就会出现这种情况。这时, token 的非回溯性就很重要了。如果被解析的变量是 abc, 而变量 a 在 %*SYMBOLS 中, 那么回溯将尝试对 <identifier> 进行更短的匹配, 直到匹配到 a, 然后成功。[54]

因为 %*SYMBOLS 是在 token TOP 中声明的, 所以当你尝试从 grammar 之外调用 TOP 以外的规则时, 你必须重复这个声明。如果没有像 my %*SYMBOLS; 这样的声明, 那么像:

VariableLists.parse('abc', rule => 'variable');

死于:

Dynamic variable %*SYMBOLS not found

9.6.1. 使用动态变量实现词法作用域

许多编程语言都具有词法作用域的概念。作用域是指程序中可以看到一个符号的区域。如果作用域完全由文本的结构决定(而不是程序的运行时特征), 我们称作用域为词法范围。

作用域通常是可以嵌套的。在一个作用域中声明的变量在这个作用域中, 以及所有内部的、嵌套的作用域中都是可见的(除非一个内部的作用域声明了一个同名的变量, 在这种情况下, 内部的声明隐藏了外部的变量)。

回到列表和赋值的玩具语言, 我们可以引入一对大括号来表示一个新的作用域, 这样就有效了。

a= 1
b= 2
{
    c = a, 5, b
}

但是下一个例子是无效的, 因为它只在内部作用域中声明了 b, 所以在外部作用域中是不可见的:

a = 1
{
    b = 2
}
c = a, 5, b

为了在 grammar 中实现这些规则, 我们可以利用一个重要的观察: grammar 中的动态作用域对应于它所解析的文本中的词法作用域。如果我们有一个正则表达式块, 它既可以解析一个作用域的分界符, 也可以解析该作用域内的东西, 那么它的动态作用域就被限制在它所调用的所有正则表达式(直接和间接), 这也是它在输入文本中匹配的词法作用域。

下面我们来看看如何实现动态作用域。

grammar VariableLists {
    token TOP {
        :my %*SYMBOLS;
        <statement>*
    }
    token ws { <!ww> \h* }
    token statement {
        | <declaration>
        | <block>
    }
    rule declaration {
        <identifier>
        { %*SYMBOLS{ $<identifier> } = True; }
        '=' <termlist>
        \n
    }
    rule block {
        :my %*SYMBOLS = CALLERS::<%*SYMBOLS>;
        '{' \n*
            <statement>*
        '}' \n*
    }
    rule termlist { <term> * % ',' }
    token term { <variable> | <number> }
    token variable {
        <identifier>
        <?{ %*SYMBOLS{ $<identifier> } }>
    }

    token number { \d+ }
    token identifier { <:alpha> \w* }
}

这个 grammar 的前一个版本有一些变化: 规则 statement 已重命名为 declaration, 新的规则语句解析声明或块。

所有有趣的部分都发生在块规则中。这一行 :my %*SYMBOLS = CALLERS::<%*SYMBOLS>; 声明了一个新的动态变量 %*SYMBOLS, 并以该变量的前一个值初始化它。CALLERS::<%*SYMBOLS> 通过调用者, 以及调用者的调用者等查找变量 %*SYMBOLS, 从而查找与外域对应的值。初始化会创建一个散列的副本, 这样对一个副本的改变不会影响到其他副本。

让我们来看看当这个 grammar 解析下面的输入时, 会发生什么情况。

a= 1 b= 2
{
    c = a, 5, b
}

在前两行之后, %*SYMBOLS 的值为 {a => True, b => True}。当规则块解析第三行的开头的大括号时, 它创建了一个 %*SYMBOLS 的副本。第四行的 c 的声明将 c ⇒ True 插入到 %*SYMBOLS 的副本中。在规则块解析了最后一行的结尾大括号之后, 它成功地退出了, %*SYMBOLS 的副本也退出了作用域。这样, 我们就剩下了早期版本的 %*SYMBOLS (只有键 a 和 b), 当 TOP 退出时, 它就会超出作用域。

9.6.2. 通过显式符号表确定作用域

使用动态变量来管理符号表通常效果很好, 但在某些边缘情况下, 使用更明确的方法效果更好。这样的边缘情况包括那些有很多符号, 以至于复制成本过高, 或者必须检查最上面的范围以上的符号表, 或者由于其他原因复制符号表是不切实际的。

因此, 你可以为你的符号表写一个类(在最简单的情况下, 它使用一个数组作为作用域的堆栈), 并在进入和离开作用域时、声明变量时以及检查作用域中的变量是否为已知变量时, 显式地调用它的方法。

class SymbolTable {
    has @!scopes = {}, ;
    method enter-scope() {
        @!scopes.push({})
    }
    method leave-scope() {
        @!scopes.pop();
    }
    method declare($variable) {
        @!scopes[*-1]{$variable} = True
    }
    method check-declared($variable) {
        for @!scopes.reverse -> %scope {
            return True if %scope{$variable};
        }
        return False;
    }
}

grammar VariableLists {
    token TOP {
        :my $*ST = SymbolTable.new();
        <statement>*
    }
    token ws { <!ww> \h* }
    token statement {
        | <declaration>
        | <block>
    }
    rule declaration {
        <identifier>
        { $*ST.declare( $<identifier> ) }
        '=' <termlist>
        \n
    }
    rule block {
        '{' \n*
            { $*ST.enter-scope() }
            <statement>*
            { $*ST.leave-scope() }
        '}' \n*
    }
    rule termlist { <term> * % ',' }
    token term { <variable> | <number> }
    token variable {
         <identifier>
         <?{ $*ST.check-declared($<identifier>) }>
    }
    token number { \d+ }
    token identifier { <:alpha> \w* } }

SymbolTable 有一个私有数组属性 @!scopes, 它被初始化为一个包含单个空散列 {} 的列表。进入一个作用域意味着在这个数组之上推送一个空的散列, 离开作用域时, 通过调用 pop 方法再次将其删除。变量声明将其名称添加到最上面的散列中, @!scopes[*-1]

检查变量的存在, 不能只考虑最上面的散列, 因为变量是继承到内部作用域的。这里, 我们按相反的顺序, 从最内侧的作用域到最外侧的作用域, 遍历所有的作用域。遍历的顺序对于一个简单的布尔检查来说并不相关, 但是如果你需要查询与变量相关的信息, 一定要坚持这个顺序来引用正确的变量。

Token TOP 创建一个类 SymbolTable 的新对象, 声明调用 declare 方法, token 变量调用方法 check-declared。规则块在解析语句列表之前调用 enter-scope, 之后调用 left-scope。这样做是可行的, 但前提是语句列表能够解析成功;如果不能, 规则块在调用 leave-scope 之前就会失败。

Raku 对这种情况有一个安全特性: 如果你在语句前缀上加上 LEAVE, Raku 会在例程退出时为你调用它, 在所有可能的情况下(即使抛出异常)。由于 LEAVE phasers[55] 只在正则代码中起作用, 而不在正则表达式中起作用, 所以我们需要将正则表达式封装在方法中。

method block {
    $*ST.enter-scope();
    LEAVE $*ST.leave-scope();
    self.block_wrapped();
}

rule block_wrapped {
    '{' \n*
        <statement>*
    '}' \n*
}

现在, 我们拥有与动态变量的方法一样的健壮性, 并且有了更多的灵活性, 可以在符号表中添加额外的代码, 但代价是要付出更多的代码和更多的努力。

9.7. 总结

Raku 的 grammar 是一种声明性的方式, 可以写出递归下降的解析器。在没有回溯的情况下, 它们是预测性的;在每一个点上, 我们都知道该期待什么样的 token 表。

Grammar 的递归性质带来了左递归的风险, 这种情况下, 递归路径不消耗任何字符, 因此会导致无限循环。

尽管 grammar 的自上而下的性质, 但编写 grammar 通常是自下而上: 从词法分析开始, 然后再往上解析更大的结构。

复杂的语言需要额外的状态来实现成功和精确的解析。我们已经看到了如何使用动态变量来保存状态。

在 grammar 中, 它们的作用域如何与输入中的词法作用域相对应, 以及如何编写符号表并将其集成到 grammar 中。

10. 从匹配中提取数据

到目前为止, 我们已经花了很大力气来解析各种文件格式, 并且我们努力的结果是一个匹配对象, 如果匹配失败则结果为 Nil

在大多数情况下, 知道匹配成功与否只是第一步, 我们想从成功的匹配中提取有用的数据。

原则上, 我们可以检查生成的匹配对象并提取所有必要的数据。如果它没有提供足够的解析度, 我们可以添加捕获使其解析粒度更加精细化。这种方法是可行的, 但它的写法往往令人沮丧, 结果也很脆弱。

但在研究解决方案之前, 我们先来谈谈这个问题。我们从一个成功的正则表达式匹配或 grammar 解析中获得的匹配对象是一棵解析树。它的结构直接由 grammar 的结构决定, 并且在解析树中找到的信息都是关于所匹配到的字符串的, 以及它们在输入字符串中的位置。

我们之所以要解析一个字符串, 通常是为了对其内容进行一些处理。如果输入是一个 INI 文件, 我们想要提取节(section)和他们的键/值对; 如果是编程语言的源代码, 我们可能要检查它的一些特征(如 linter), 或者把它编译到一个较低级的格式。通常, 这个处理步骤, 不管是什么, 不应该直接绑定到解析树上, 而应该绑定到一个更抽象的内容表示。这种表示称为抽象语法树, 简称 AST。

根据使用情况, AST 的外观会有很大的不同。对于 JSON 解析器来说, AST 可以直接是被序列化成 JSON 字符串的数据结构, 所以是数组、散列、字符串、数字和布尔的混合物。对于编程语言来说, 你通常会从一个自定义类的集合中构造出一个 AST, 这些类可以捕获你所关心的输入的所有方面; 这些 AST 类通常还包含引用输入行号的注释, 这压根错误消息就可以正确地识别错误的位置。

10.1. Action 对象

Grammar 和匹配对象自带两个特性, 为提取数据提供了更有弹性和可扩展性的方法。第一个比较简单是, 是你可以将任意数据结构附加到 Match[56] 对象上, 随后通过 .made 属性访问它:

if 'abc' ~~ /\w/ {
    $/.make({'a' => 'bc'});
    say $/.made;      # Output: {a => bc}
}

如果匹配对象在特殊变量 $/ 中, 也可以调用 make DATA:

if 'abc' ~~ /\w/ {
    make {'a' => 'bc'};
    say $/.made;     # Output: {a => bc}
}

你大概可以看到这一点: 你可以构建一个数据结构, 它将成为最终 AST 的一部分, 然后用 make 将其附加到匹配对象上。

你要在哪里做呢? 一个选择是内联代码对象, { …​ }, 但这将 grammar 与 AST 生成代码紧密地耦合在一起。这就是第二个特性的用武之地: action 对象。

action 对象是你传递给 grammar 上的 .parse.subparse 调用的对象。从此以后, 每当一个命名正则表达式成功匹配时, 正则表达式引擎为你调用一个方法; 它搜索和该正则表达式同名的方法, 如果存在同名方法, 就用匹配对象作为参数调用它。 如果这样的方法不存在, 则不会发生任何事情, 也不会产生错误。

下面是是一个在解析数学表达式时对其进行求值的 action 示例:

grammar MathExpression {
    token TOP    { <sum> }
    rule sum     { <product>+ % '+' }
    rule product { <term>+ % '*' }
    rule term    { <number> | <group> }
    rule group   { '(' <sum> ')' }
    token number { \d+ }
}

class MathEvalAction {
    method TOP($/) {
        make $<sum>.made;
    }
    method sum($/) {
        make [+] $<product>».made;
    }
    method product($/) {
        make [*] $<term>».made;
    }
    method term($/) {
        make $/.values[0].made;
    }
    method group($/) {
        make $<sum>.made;
    }
    method number($/) {
        make $/.Int;
    }
}

my $match = MathExpression.parse(
    '4 + 5 * (1 + 3)',
    actions => MathEvalAction.new,
);

say $match.made;       # Output: 24

你应该已经从上一章中熟悉了 MathExpression grammar 了; 新的东西是另一个类 MathEvalAction, 对于 grammar 中的每个正则表达式, 它都有一个方法与之对应。每个方法都以 $/ 作为唯一参数, 将匹配对象绑定到 $/。它调用 make 来附加内容到这个匹配对象上。

这个类的想法是, 每个方法都会给当前匹配对象附加一个与当前匹配对象对应的数字。因此, 如果它解析字符串 2 *(1 + 4), 它就会将数字 2 附加到解析了 2 的匹配对象上。它对数字 1 和 4 做同样的操作, 然后将 sum 的结果 5 附加到解析了 1 + 4 的匹配对象上, 然后再次将数字 5 附加到解析了 (1 + 4) 的匹配对象上, 最后它对 product 进行求值, 并将数字 10 附加到最上面的匹配对象上。

从下往上读, number 方法通过调用匹配对象上的 Int 方法将字符串转换为数字。它将得到的整数附加到匹配对象上。匹配 group 只需从 $<sum> 中获取匹配对象中的附加值, 并将其附加到自己的匹配对象上。

术语 rule 与两个备选项中的一个匹配。$/.values 返回一个所有匹配对象的列表, 它总是一个元素。make $/.values[0].made 因此将从任何一个匹配中的附加值传播, 并将其附加到自己的匹配对象上。

规则 product 解析由星号分隔的项的列表。同名的 action 方法取每个项的附加值, 并将它们相乘。$<term>».made 中的 ». 语法在列表 $<term> 的每个元素上调用 made 方法, 并返回一个包含所有结果的列表。如果你无法用键盘打出 » 字符, 可以改用 >>, 这样这就变成了 $<term>>>.made

[] LIST 在 LIST 的每个元素之间插入 运算符, 从而计算出所有这些值的乘积。

由于规则 sumproduct 具有相同的结构, 所以它们的 action 方法也是如此。sum 使用 [+] 来创建下一级的匹配对象的所有数字之和。

最后, token TOP 只是调用 sum, 所以 action 类中的方法 TOP 只是传递附加在 $<sum> 上的值。

下面是一个匹配树, 在右边的列中注解了每个 .made 的值:

sum => ⌜4 + 5 * (1 + 3)⌟          24
 product => ⌜4 ⌟                   4
  term => ⌜4 ⌟                     4
   number => ⌜4⌟                   4
 product => ⌜5 * (1 + 3)⌟         20
  term => ⌜5 ⌟                     5
   number => ⌜5⌟                   5
  term => ⌜(1 + 3)⌟                4
   group => ⌜(1 + 3)⌟              4
    sum => ⌜1 + 3⌟                 4
     product => ⌜1 ⌟               1
      term => ⌜1 ⌟                 1
       number => ⌜1⌟               1
     product => ⌜3⌟                3
      term => ⌜3⌟                  3
       number => ⌜3⌟               3

在每个分支中, 缩进最多的匹配是那些 action 方法先被调用的匹配, 所以 numberterm 之前调用, termproduct 之前调用, productsum 之前调用。 这种排序保证了每个 action 方法都可以依赖于子匹配上的 .made 属性的存在。

编写 action 方法只需要知道相应的正则表达式捕获的内容以及它们的 .made 属性包含的内容就可以了。在 action 中, 我们不需要深入研究匹配树的嵌套结构。如果以这种方式构建 action, 则可灵活应对 grammar 的变更。每当你修改正则表达式时, 你都可以放心, 你只需要触及与之相对应的 action 方法就可以了, 你永远不必担心另一个 action 方法会依赖于你刚才触及的正则表达式。

当你为 proto token 编写 action 方法时, 你应该注意, action 方法是针对成功匹配的候选者调用的, 但不是针对整个 proto 的。所以, 如果你的 grammar 包含了如下行:

proto token value {*};
token value:sym<string> { <string> }
token value:sym<number> { <number> }

那么 grammar 引擎可能会为你调用一个名为 value:sym<string>value:sym<number> 的方法, 但不是 value 方法。

10.2. 使用 Action 对象构建 AST

上一节说明了 action 对象和方法的机制, 它构建一个类似于 AST 的东西, 但它不是树。因为树是解析成功后的最常见结果, 所以我想介绍另一个 action 类, 在解析过程中创建一个抽象的语法树, 同样的 grammar 类在解析过程中会创建一个抽象的语法树。

为了省去单独编写一个树类的麻烦, 我们将使用一个嵌套数组来表示树。树的第一个元素是运算符; 其他元素是操作数。因此, 对于表达式 1 + 2 + 3, 结果是 ['+', 1, 2, 3]。由于嵌套表达式用嵌套括号表示, 所以我们不需要运算符来表示一组圆括号: 因此, 表达式 2 (3 + 4) 被表示为 ['', 2, ['+', 3, 4]]

让我们来看看 action 类, 以及如何使用它:

class MathASTAction {
    method reduce($op, @list) {
        return @list[0] if @list.elems == 1;
        return [$op, |@list];
    }

    method TOP($/) {
        make $<sum>.made;
    }
    method sum($/) {
        make self.reduce('+', $<produce>».made);
    }
    method product($/) {
        make self.reduce('*', $<term>».made);
    }
    method term($/) {
        make $/.values[0].made;
    }
    method group($/) {
        make $<sum>.made;
    }
    method number($/) {
        make $/.Int;
    }
}

say $match = MathExpression.parse(
    '4 + 5 * (1 + 3)',
    actions => MathASTAction.new,
);
say $match.made.perl;

这段代码(连同上一节中的 grammar 定义)产生的输出是:

["+", 4, ["*", 5, ["+", 1, 3]]]

TOPtermgroupnumber 的 action 方法与前面讨论的 action 类中的 action 方法相同。有趣的部分是 sumproduct 的 action 方法。我们来看看后者, 这两个方法的代表:

method reduce($op, @list) {
    return @list[0] if @list.elems == 1;
    return [$op, |@list];
}

method product($/) {
    make self.reduce('*', $<term>».made);
}

如果我们只使用 make ['', |$<term>».made] 作为 product 方法的主体, 那么 1 + 2 的 AST 会以 ['+', ['', 1], ['*', 2]] 的形式出现, 因为每个 sum 都解析一个 product 表达式。我们不希望出现额外的单元素乘法, 所以我们做了一个特殊情况。如果只解析了一个项, 那么只返回该项。否则, 返回一个由运算符组成的数组, 然后返回所有项的 AST。

由于这种特殊情况使用了两次(对于 sumproduct), 所以将其分解成一个方法是有意义的, 我称这个方法为 reducereduce 方法使用语法 [$op, |@list] 来创建一个数组 $op@list 的元素相连接。| 扁平化 @list; 如果我们不使用它, 结果将是 ['+', [1, 2]] 而不是 ['+', 1, 2]

10.3. 在 Action 对象中保存状态

上一节中的 action 类是一个方法的集合, 但他们并不保留任何状态。可以说, 它们不是"真正的"对象。实际上, 我们可以把 action 类上的 .new 调用移除, 直接把类传递给 grammar, 这样例子就可以像以前一样工作了。状态是正在构建的 AST, 它存储在匹配对象的 .made 属性中。

这是一种常见的模式, 但不一定要这么做。作为 action 对象的对象是普通的 Raku 对象, 你可以用它做任何你想做的事情。你可以在它们中存储数据, 甚至可以在多次调用 grammar 的 .parse 方法时重用同一个 action 对象。

读到这里, 你可能会想知道我们为什么要费尽心思地去做这些事情。在前面的例子中, 在动态变量中保存符号表, 而不是使用 action 对象。这里的区别在于, 符号表影响了解析的结果; 引用一个未声明的变量使匹配失败。Grammar 是要独立于 action 对象的, 所以你可以修改甚至交换 action 类而不影响 grammar。

下面是 grammar 的一个例子, 它解析单个变量赋值, 并将这些变量收集到一个散列中:

class VariableCollection {
   has %.definitions;
    method TOP($/) {
        %.definitions{ $<identifier> } = $<termlist>.made;
    }
    method number($/)     { make $/.Int }
    method identifier($/) { make $/.Str }
    method termlist($/)   { make $<term>».made }
    method term($/)       { make $/.values[0].made }
}

grammar VariableAssignment {
    rule TOP         { \s* <identifier> '=' <termlist> }
    rule termlist    { <term> * % ',' }
    token term       { <identifier> | <number> }
    token number     { \d+ }
    token identifier { <:alpha> \w* }
    token ws         { <!ww> \h* }
}

my $actions = VariableCollection.new;

my @lines = 'a = 1', 'c = a, 5, b', 'b = 2';

for @lines -> $line {
    unless VariableAssignment.parse($line, :$actions) {
        die qq[Invalid input: "$line"];
    }
}

say $actions.definitions;
        # Output: {a => [1], b => [2], c => [a 5 b]}

在这里例子中, has %.define 定义了一个公共属性; 一个附加在对象上的状态, 可以从外部访问。语法: $actionsactions ⇒ $actions 的简写。

这种使用有状态的 action 对象和一次一项地解析部分输入的模式对流处理非常有用; 如果你在接收数据时, 需要在收到完整的输入之前访问结果, 或者如果你的系统连续运行, 并且有一个不断的输入流, 那么这种模式非常有用。

10.4. 总结

如果你为你的 grammar 的 parse 调用提供一个 action 对象, 那么就会调用与正则表达式同名的方法。你可以用它来从你的 grammar 中构建一个抽象语法树。此外, 你可以在这个 action 对象中保留状态, 并使用这种机制来解析零散但相关的输入。

11. 生成好的解析错误消息

当正则表达式匹配或 grammar 解析失败时, 它会返回 Nil。如果这是一个没有预期的失败, 我们可以告知用户某种形式的输入(也许是配置文件)是无效的。

对于简单的输入, 如电子邮件地址或 URL, 这部分二进制信息可能是令人满意的。而对于一个较大的配置或源文件, 我们真的希望给用户更多的信息。为什么输入是无效的?如果我们不能回答这个问题, 那么错误在哪里?

或者换个说法:当解析失败的时候, 我们要确定解析失败的位置, 可能是解析失败的原因, 并生成一个好的错误信息。

11.1. 探索问题

在 Raku 中, 没有一个通用的机制可以产生良好的解析错误信息, 我不知道怎么会有这样的机制。原因是, 即使是在成功的解析运行中, 个别的正则表达式和 token 失败也是很正常的。

让我们只考虑一下早期的 grammar VariableLists 中的两行:

rule  termlist { <term> * % ',' }
token term     { <identifier> | <number> }

假设这与字符串 1,a 相匹配, token term 首先尝试将 identifiernumber 与字符串匹配。identifier 正则表达式匹配失败了, 但是 number 匹配成功了。然后, termlist 本身匹配了逗号, 再次轮到 term。这一次, identifier 分支成功匹配了字符串 a, number 匹配失败了。最后, termlist 尝试匹配下一个逗号, 也失败了, 但整个 termlist 匹配成功。

在这个例子中, 我们已经看到了三个失败的匹配作为成功匹配的一部分。那么这样的"正常"失败的匹配与致命性地导致整个解析失败的匹配有什么区别呢?

从技术上讲, 一个致命的失败的正则表达式是指失败后一直向上传播到 TOP 的正则表达式。但这并不能帮助我们创建出好的错误信息。当它失败时, 我们还不知道它是否会一直向上传播。而一旦它传播到 regex TOP, 就来不及从失败的匹配中提取有用的信息了。

对匹配失败的不同视角可以提供更多的启示。基本上有两种情况下, 匹配会出错。一种情况是当我们开始解析某个东西时, 我们只能匹配到它的第一部分, 而不能匹配到其他部分。在数学表达式的情况下, 这可能是一个开头的括号, 而没有相应的结尾括号, 或者是没有在任何表达式后面跟加号。第二种情况是预期有一个或多个备选方案, 但都不匹配。例如, 一个 JSON 解析器可能期待一个对象、数组、数字或字符串, 但输入的字符是 $ - 在引用的字符串以外的 JSON 中, 这个字符根本不是有效的 JSON。

11.2. 断言

顺序替代, 可以让你创造出好的错误信息。一般的模式是这样的。

rule group {
    '(' <sum>
    [')' || { die "Cannot find closing ')'" } ]
}

这个正则表达式试图匹配关闭的括号。如果这样做不成功, 它不是默默地失败, 而是使用一个代码块 {…​} 使用内置的 die 函数抛出一个异常。异常会中止解析, 如果解析方法的调用者没有捕捉到异常, 程序会退出, 并返回一个匹配不成功的返回代码。为了成为改进错误报告的有效工具, 我们必须将这种模式应用到更多的地方。

只在最琐碎的输入中描述错误就足够了。在任何多行输入中, 我们需要指出错误的位置;否则, 用户将很难修复问题。

我们可以通过匹配对象 $/ 来获取位置, 然后用它来计算行号。因为这是我们在相当多的地方都会用到的东西, 所以最好把这个逻辑提取到一个方法中。然后我们可以用与调用正则表达式相同的语法来调用这个方法, 只是在参数周围加上一对括号。

grammar MathExpression {
    token TOP           { <sum> }
    rule sum            { <multiplication>+ % '+' }
    rule multiplication { <term>+ % '*' }
    rule term           { <number> | <group> }
    rule group {
              '(' <sum>
            [ ')' || <error("no closing ')'")> ]
    }

    token number        { \d+ }

    method error($msg)  {
        my $parsed = self.target.substr(0, self.pos);
        my $line-no = $parsed.lines.elems;
        die "Cannot parse mathematical expression: "
            ~ "$msg at line $line-no";
    }
}

say MathExpression.parse("(\n1");

它以前是:

[ ')' || {die "Cannot find closing ')'" } ]

现在是:

[ ')' || <error("no closing ')'")> ]

注意, 我们用调用自定义错误方法代替了直接调用 die 函数。这个方法有几个小技巧来确定行号:.target 方法返回 grammar 当前匹配的字符串, self.pos 方法提取当前的解析位置。self.pos 相当于 $/.to, 只是后者只在解析结束时, 或者是在捕获的时候才会起作用。

这些方法是从哪里来的?通过声明一个类型为 grammar, 它自动获得父类 Grammar[57](提供 parsesubparse 方法), 而后者又继承自 Cursor[58], Cursor 提供了 .target.pos 方法。

Str.lines[59] 方法将一个字符串分解成一个包含行数的列表, 在这个列表上调用 .elems 会产生行数。对于一个没有任何行的字符串, 这个方法会产生行数。请注意, 即使是最铁杆的 C 语言程序员也会计算从 1 开始的行数(而不是0), 所以从 lines.elems 中的数字符合我们的行数目的。

示例代码的输出如下:

Cannot parse mathematical expression: no closing ')' at line 2

错误报告正确地报告为第2行的错误, 因为输入字符串中的 \n 引入了断行。

11.3. 改进后的位置报告

我们在上一节中看到的 error 方法是一个很好的第一近似法, 但我们可以做的事情还有更多, 以提高它的精度。

第一件事很简单。在许多情况下, 一个隐式的 <.ws> ` 调用可以匹配 `[ <something> || <error(…​)>] 块之前的空格。在 grammar 中, ws 可以匹配换行, 这可能会把报告的错误位置往下移, 这与我们对错误位置报告的直观理解不符。

我们可以通过删除 $parsed 字符串中的尾部空格来弥补这个问题:

my $parsed = self.target.substr(0, self.pos)\
             .trim-trailing;

另一个改进策略是在错误消息中提供一些上下文。例如, Raku 解析器会在错误位置周围打印出源代码, 并在错误位置上标记一个弹出符号 ⏏。

我们在自己的 grammar 中也可以这样做。

method error($msg) {
    my $parsed = self.target.substr(0, self.pos)\
                 .trim-trailing;
    my $context = $parsed.substr($parsed.chars - 10 max 0)
          ~ '⏏' ~ self.target.substr($parsed.chars, 10);
    my $line-no = $parsed.lines.elems;
    die "Cannot parse mathematical expression: $msg\n"
        ~ "at line $line-no, around " ~ $context.perl
        ~ "\n(error location indicated by ⏏)\n";
}

这个版本的 error 方法构造了一个上下文字符串, 其中包含了错误位置前后的十个字符(虽然它使用 $chars max 0 来防止负索引)。它产生的输出是这样的。

Cannot parse mathematical expression: no closing ')' at line 1, around "(1 + 2⏏"
(error location indicated by ⏏)

或使用输入字符串 "1 + 2 5", 你得到:

Cannot parse mathematical expression: no closing ')' at line 1, around "(1 + 2⏏ 5"
(error location indicated by ⏏)

11.4. 高水位标记

到目前为止讨论过的技术依赖于手动插入 || <error(…​)> 片段到 grammar 中。如果你想避免这种情况 - 牺牲一点错误信息的清晰度, 你可以使用另一种技术。即使不是这样, 你也可以将它与显式错误报告结合起来使用。

就像每当海平面或河流的水位达到一个新的记录时, 高水位标志就会被推高一点, 我们可以记录下我们的 grammar 达到的最远的位置。我们可以通过一个动态变量来做到这一点, 如果 self.pos 大于变量的当前值, 我们将其设置为 self.pos。这个高水位标志就构成了我们错误位置报告的基础。

一个方便的地方是 ws token, 因为它已经在很多地方被调用了, 而且它是一个自然的原始 token 的分界符。每当正则表达式位置达到一个新的高水位标记时, 我们还可以记录下调用 ws 的规则名称。这些信息可以通过 callframe[60] 内置的子例程获得。

method ws() {
    if self.pos > $*HIGHWATER {
        $*HIGHWATER = self.pos;
        $*LASTRULE = callframe(1).code.name;
    }
    callsame;
}

callame 函数从父类(在语法中是 Grammar)调用 ws 方法, 传递的参数与当前收到的方法相同。换句话说, 你在这里看到的 ws 方法包装了默认的 ws 正则表达式。我们可以通过调用这里的 grammar 中的任何其他命名的正则表达式, 例如用 self.customws 方法来委托给 grammar 中的任何其他命名的正则表达式。

这个 ws 方法假设动态变量 $*HIGHWATER$*LASTRULE 已在某处声明。如果我们在 token TOP 中这样做(如前面的例子所示), 我们遇到了一个问题: 一旦解析了失败, 变量不再可用于报告错误。再次一个包装器来拯救: 我们可以将所有 grammar 都有的 parse 方法包装起来:

method parse($target, |c) {
    my $*HIGHWATER = 0;
    my $*LASTRULE;
    my $match = callsame;
    self.error($target) unless $match;
    return $match;
}

签名中的语法 |c 可以捕获所有剩余的参数, 包括命名参数和位置参数。这确保了像 MathExpression.parse($string, rule ⇒ 'multiplication') 这样的调用可以继续工作。callsame 然后将相同的参数传递给原始解析方法。最关键的部分是匹配失败时调用 error 方法。当然, error 方法还需要一些摆弄才能使用 $*HIGHWATER$*LASTRULE 动态变量。

method error($target) {
    my $parsed = $target.substr(0, $*HIGHWATER)\
                 .trim-trailing;
    my $line-no = $parsed.lines.elems;
    my $msg = "Cannot parse mathematical expression";
    $msg ~= "; error in rule $*LASTRULE" if $*LASTRULE;
    die "$msg at line $line-no";
}

这个 error 方法使用 $*HIGHWATER 代替 self.pos 来确定解析失败的位置, 而 $*LASTRULE 则用于报告最后一次解析规则的进展。

这里还有一个值得注意的变化:error 方法不是访问 self.target, 而是获取目标字符串作为参数。这是由于 grammar 力学中的一个细节, 我们到目前为止已经略过了。当你调用 SomeGrammar.parse(…​) 时, 这是对类型对象 SomeGrammar 的方法调用。但是 grammar 引擎需要在某个地方保留状态, 所以方法 parse 会创建一个 grammar 的实例 - 这与你可以在正则表达式调用的方法内部的 self 中访问的实例是一样的。这个细节很重要, 因为解析完成之后, 方法 parse 才调用 error, 所以实例就没有了。试图调用 self.targetself.pos 时, 会死掉, 并打印消息 Cannot look up attributes in a MathExpression type object

这种基于高水标的错误报告正确地识别了 1+ 为未完成的和, 1* 为未完成的乘法, 以及 (1 为不完整的组表达式。

11.5. 解析器组合器和 FAILGOAL

解析匹配的定界符对与它们之间的一些内容, 这很常见, 所以 Raku 有一个特殊的语法。与其这样写:

token group {
    '(' <sum> ')'
}

不如写成:

token group {
    '(' ~ ')' <sum>
}

这里的 ~ 是一个解析器组合器, 它是对下面的 token 进行重新排列。在这个例子中, 视觉上的好处并不大, 但如果分隔内容的正则表达式较长, 那么把分隔符靠得更近一些就好了。

更重要的是, 如果找不到闭合的分隔符, 正则表达式引擎会调用一个叫做 FAILGOAL 的方法。在默认的实现中, 这只是使匹配失败。我们可以覆盖这个方法来产生一个错误信息。

grammar MathExpression {
    token TOP           { <sum> }
    rule sum            { <multiplication>+ % '+' }
    rule multiplication { <term>+ % '*' }
    rule term           { <number> | <group> }
    rule group          { '(' ~ ')' <sum> }
    token number        { \d+ }

    method FAILGOAL($goal) {
        my $cleaned = $goal.trim;
        self.error("No closing $cleaned");
    }

    method error($msg) {
        my $parsed = self.target.substr(0, self.pos)\
                     .trim-trailing;
    my $context = $parsed.substr($parsed.chars - 10 max 0)
        ~ '⏏' ~ self.target.substr($parsed.chars, 10);
    my $line-no = $parsed.lines.elems;
    die "Cannot parse mathematical expression: $msg\n"
         ~ "at line $line-no, around " ~ $context.perl
         ~ "\n(error location indicated by ⏏)\n";
    }
}

MathExpression.parse('(1');

FAILGOAL 方法的参数是正则表达式的源代码。匹配结束分隔符的部分。trim[61] 方法删除了周围的空白, 其输出结果被送入本章前面提到的 error 方法的一个版本中。

输出结果如下:

Cannot parse mathematical expression: No closing ')' at line 1, around "(1⏏"
(error location indicated by ⏏)

11.6. 使用哪种技术?

对于哪种类型的项目应该使用哪种错误报告技术, 没有硬性规定。唯一的规则是, 实际使用你的 grammar 的人越多, 你就越应该在错误信息上下功夫。

你也可以把所有这些技术结合起来:在你需要的地方使用 [<expected> || <error('…​')>] 模式, 在你需要的地方放一个 FAILGOAL 方法, 在你没有明确安装断言的情况下, 使用高水标记技术来获得一个好的错误位置, 以获得更好的错误报告。

在本章后面的例子中, 你可能会觉得不适应, 因为在本章后面的例子中, 现在 grammar 的源码有一半以上的内容是专门用来报错的。但我们一开始就使用了一个相当简单的 grammar, 这就人为地缩小了 grammar 的规模。

此外, 大部分的错误报告代码都是相当通用的, 可以被分解成一个角色, 并在许多 grammar 中重复使用。这就是 Grammar::ParseError[62] 模块所提供的功能。

最后, 我想说的是, 好的解析错误是足够重要的, 值得这样的关注。[63]

11.7. 总结

从失败的解析过程中生成良好的错误信息并不容易, 因为失败的正则表达式是解析过程中正常的一部分。因此, 只有在某一时刻, 我们决定提交一个解析结果, 并在该承诺没有成立时产生错误消息, 我们才能产生错误消息。

这个承诺可以是显式的, 即在其第二个分支中触发异常的顺序选择的形式。或者, 我们可以在规则解析空白时, 使用高水标记方法, 使其隐式化。

最后, 解析器组合器允许你匹配一段被分隔符封住的输入, 并提供了一个钩子, 当闭合的分隔符不匹配时, 可以改善错误信息。

12. Unicode 和自然语言

计算机处理的文本往往分为两类:一类是要被人类使用的东西(如散文),另一类是要被软件使用的东西(机器代码和加密文件)。

到目前为止,我们所研究的数据格式都是介于两者之间:它们被制作成可以被软件毫不含糊地理解,但仍然可以被人类读取和写入。

在这一章中,我们将简要地探讨一下隐藏在自然语言领域中的复杂性 - 文本的目的是为了人类的消费,但仍然是以一系列的字节存储。

本章不是自然语言处理的介绍,它的目的是讨论处理多语言输入时必须注意的问题。

12.1. 书写系统

书面英语是以字母表为基础的,通常是一组字母对应于音素(口语中的元素)。

Abjads 或辅音字母的结构类似,但只包含辅音。可能有元音标记,也可能没有元音标记,在说单词的时候,这些标记表明元音应该插入哪里,但这些标记一般不表明应该插入哪个元音。阿拉伯语和希伯来语都属于这一类,一些古代北非和西亚的文字也是如此。

音节表是一种书写系统,每个音节都有自己的字符。一个著名的例子是平假名,它是日语书写系统的一个组成部分。

相比之下,表意文字传达的是一种思想或意义。在中国的书写系统中,它们往往与音节字结合在一起,形成了几千个字符。

不同的书写系统有几个地方会影响到程序员如何处理文字处理:你不能认为文字实际上是由字母组成的。一般来说,文本分割(将文本分割成单词、句子和其他单位的过程)是针对特定语言的。并非所有语言和书写系统都使用空格来分隔单词,甚至根本就不使用空格来分隔单词。有关更多信息,请参阅 Unicode 技术报告中关于文本分割的内容[64]

内置的字符类如 \d\w 可以匹配所有脚本中的字符,而不仅仅是拉丁文脚本。这意味着,如果你使用 \d+ 来匹配一个数字,它可以匹配 42 和 ٤٢(这是东阿拉伯语数字[65])。虽然这可能会让人感到惊讶,但将字符串 "٤٢" 转换为数字就像我们的"正常"阿拉伯数字一样。

12.2. 字节、代码点、字素和字形

计算机将文本存储为一系列的字节。UTF-8、UTF-32 或 ISO-8859-1 等编码描述了一个字节或一串字节如何映射到一个代码点。

代码点是 Unicode 联盟对人类语言中使用的字符的描述。它由一个介于 0 到约 110 万之间的数字,以及使用大写 ASCII 字符的字符描述组成,如 GREEK SMALL LETTER OMICRON。Unicode 属性数据库还记录了更多的信息,如该字符是否是字母、小写字母、是典型的从左至右书写的一部分等等。

12.2.1. 字素簇

不是每个代码码点都对应于一个完整的字符。有些语言允许基数字与组合字符自由组合。你可能对法语中使用的尖音符和重音符很熟悉,如 "née"(出生)或 "après"(后)。为了与其他传统的编码兼容,é 和 è 字符作为独立的代码点存在,也就是所谓的预组合字符。但它们也可以写成基本字符 e,后面跟着 COMBINING ACUTE ACCENTCOMBINING GRAVE ACCENT

在法语中,尖音和重音只作用在几个基础字符上,但在其他书写系统中,这种限制并不适用。例如,在印度次大陆常见的梵文文字中,元音可以写成组合字,应用到辅音基音上。在这种情况下,并非所有可能的基音和组合字符的组合都是作为 Unicode 编码的预组合点存在。

这意味着,读者可以将一个字符识别为一个字符的东西可能会被存储为多个代码点 - 通常是一个基础字符和一个或多个组合字符。我们称其为字素簇,或简称为字素。

在 Raku中,字符串的基本单位是字素簇。如果你通过调用字符串的 chars 方法来询问字符串的字符数,答案就是字素簇的数量:

say "e\c[COMBINING GRAVE ACCENT]".chars; # Output: 1

\c[NAME] 语法在一个双引号的字符串中插入了一个按名字命名的代码点。

在这种情况下,恰好有一个预组合字符存在,但这对 Raku 来说并不是必须的,因为它被认为是一个单个字素;因此,如果你选择 x 作为基础字符,就不存在预组合字符,Raku 仍然把它归类为单个字符。

在正则表达式中也不例外:任何能够匹配单个字符的构造,如字符类,都可以匹配一个字素簇。

if "e\c[COMBINING GRAVE ACCENT]" ~~ / ^ . $ / {
    say "A single grapheme cluster";
}

当文本被渲染时,无论是为了打印或在屏幕上显示,渲染引擎可以选择将多个字母组组合成单个字形(可以显示的形状)。例如,在英语中,字母 f 和 i 的序列可能会被渲染成一个字形,这是一个包含两个字符的单字形,通常比两个字符分开的字形要窄。

要在匹配正则表达式时忽略组合字符,可以使用 :ignoremark 修饰符; 简称为 :m。因此, /:ignoremark uber/ 匹配字符串 "über"。当你怀疑用户在输入二进制符号时可能会有问题,但仍然想给他们提供包含这些符号的搜索结果时,这个方法很有用。

12.2.2. 字形

渲染过程中选择字形的过程与输出介质和字体有很大的关系,因此 Raku 在字形层面上没有提供内置的字符串处理。

你应该知道,即使在单间距字体中,中文、日文和韩文也不能装进一个正体字的空间,而是要占用两倍的水平空间。全宽字是指(典型的拉丁文)字符被拉伸到与汉字相同的宽度。在解析缩进块或水平对齐的表格时,你可能需要考虑字符的宽度。

主要的启示是,即使是使用字素簇和单间距字体,你也不能对字符的宽度做太多的假设。

12.2.3. Unicode 属性

Unicode 中的字元数量庞大,因此在任何指定的类別中,如字母、数字、标点、数学符号等,要列举出所有的字元是不切实际和容易出错的。相反,你可以指定你要匹配一个给定类别中的字符。这些是由 Unicode 联盟定义的字符类。

Unicode 属性是用冒号(:)来写的,后面的属性名称放在角括号内。例如,<:Letter> 可以匹配任何字母 - 在编写时,总共有 63409 个代码点。新的 Unicode 版本可以进一步扩大这个总数。

你可以通过使用 +- 自由组合 Unicode 属性,因此 <:Letter+:Digit> 可以匹配一个字母或一个数字。你还可以将基于属性的字符类与枚举结合起来。如果你想匹配一个没有 e 的单词,你可以用正则表达式 /<:Letter-[eE]>+/ 来实现。

字母、数字、标点符号等字符的分类称为常规类别,你可以使用 uniprop 方法查询字符所属的 Unicode 类别:

say "a".uniprop; # Output: Ll

这里 Ll 是 Lowercase_Letter 的简写。全部可能的值都可以在官方文档中找到。[66]

还有更多的类别可以查询:

say "a".uniprop('Script');         # Output: Latin
say "a".uniprop('Block');          # Output: Basic Latin

类别和属性的相同组合可以在一个正则表达式中使用;/<:Block('Basic Latin'>)>/ 匹配 "Basic Latin" 块中的任何字符。

在字素簇的情况下,uniprop 方法和正则表达式匹配的属性都适用于基础字符,因此,字符串 x\c[COMBINING TILDE] 可以成功地被 <:Letter> 匹配,但不能被 <:Mark> 匹配,因为 <:Mark> 是标识组合标记代码点的类别。

12.3. 总结

自然语言和书写系统的多样性意味着我们对语言的许多假设(通常是隐含的)并不成立:不是每一个逻辑或视觉字符都是一个单一的代码点,不是所有的单词都是由字母组成的,也不是所有的单词都是由空格分隔的,这只是其中的几个例子。

Raku 提供了一些安慰,它将字素簇作为单个字符来处理,并在字符类中提供了对 Unicode 属性匹配的支持。

13. 案例学习

前面几章已经让大家掌握了 grammar 的工作原理、关于错误报告和数据提取的知识。

让我们来看看一些现实世界中的输入格式,以及我们如何解析它们。

这些案例研究的主要限制是大小:用 grammar 分析像 SQL 这样的语言是相当平易近人的,但由于语言的浩瀚无边,是个大任务。你很可能不会喜欢读 40 页关于它的文章。

我们将探讨三种格式的解析,重点是不同的技巧。S-表达式是一种简单但相当典型的数据序列化格式,有少量的whitepace意义。然后,我们将探讨解析数学表达式,但方法与前面章节的例子略有不同。我们使用一个运算符优先级解析器来保持 grammar 的平面化。最后,一个类似于 Python 的微型语言可以教我们如何解析基于缩进的格式。

13.1. S 表达式

符号表达式(Symbolic Expressions),也就是 S-Expressions,是 Lisp 程序设计语言中使用的一种树形嵌套列表的记号。在 S-表达式中,列表是用括号分隔的,列表中的元素用空格分隔。

(a b (c d))

对应于 Raku 数据结构:

["a", "b", ["c", "d"]]

列表元素通常被称为原子。原子可以是一个未引用的标识符,通常可以包含各种标点符号。例如 (+ 1 2) 就是 1+2 的 Lisp 表达式。你可以使用双引号来引用包含空格的原子。

在 Lisp 编程语言大家庭中的大多数方言中,大多数方言都使用 S-表达式来处理程序代码和数据结构。但是,它们在 S-表达式中允许的内容是不同的。有的允许使用浮点字面量,有的则在标识符中允许使用哪些字符。

13.1.1. 解析 S 表达式

让我们从解析原子开始。原子可以是一个整数,比如 12345,一个标识符,比如 lisp,或者是一个引号字符串"像这样"。在一个可扩展的 grammar 中,这样的备选列表应该作为一个 proto token 来实现。

grammar S-Expression {
    proto token atom {*}
    token atom:sym<identifier> {
        <[ a..z A..Z =*: ]>
        <[ a..z A..Z 0..9 _ =*: ]>*
    }
    token atom:sym<integer> { <[+-]>? <[0..9]>+           }
    token atom:sym<string>  { '"' ~ '"' <string_contents> }
    token string_contents   { [ | <-[\\"]>+ | \\ . ]*     }
}

匹配一个潜在的有符号整数是非常简单的。

S-表达式标识符的难点不在于如何解析它们,而在于决定在其中允许什么。这取决于被解析的 Lisp 方言。在前面的例子中,第一个字符允许使用拉丁字母和符号 =, *, 和 :;后面的任何字符也允许使用数字和下划线。这样的构造使得整数可以毫不含糊地被解析为整数,并且不能作为标识符来解析。

这种设置不允许单一的 +- 作为标识符来匹配,如果简单地将其添加到第一个字符类中,就会产生歧义。如果我们想允许这样做,我们可以把它作为一个单独的分支来处理。

token atom:sym<identifier> {
    | <[ a..z A..Z =*: ]> <[ a..z A..Z 0..9 _ =*: ]>*
    | <[+-]>
}

解析字符串遵循前面一章中的模式:外面的两个引号和中间的字符串内容。这里的字符串内容是通过一个单独的规则来解析的,以使数据提取更容易。字符串内容包括"常规"允许的字符(除了反斜线或双引号之外的所有字符),或者是一个转义序列:一个反斜线之后是一个单一的任意字符。

你可能已经注意到 Raku 允许你在标识符中使用连字符或减号(-),我之前在类名 S-Expression 中使用过,但在正则表达式 string_contents 的名称中没有使用。 - 字符有一个潜在的歧义。<-alpha-hexdigit> 可能是一个被否定的字符类 alpha-hexdigit,或者匹配任何既不是 alpha 也不是 hexdigit 的东西。

Raku 消除了第一种可能性中的歧义,但完全避免这种情况似乎仍然值得

现在我们有了一些语法规则,我们应该测试一下核心功能。

use Test;

my %atoms =
    integer    => ('1', '01234', '-23', '+12'),
    identifier => ('abc', '=', '*_*'),
    string     => ('""', '"abc"', Q'"abc\"def"', Q'"\\"'),
    ;

my %not-atoms =
    identifier => ('', '_'),
    string     => ('', '"""', Q'"\"',),
    ;

for %atoms.keys.sort -> $atom {
    for %atoms{$atom}.list -> $test {
        ok S-Expression.parse($test,
            rule => "atom:sym<$atom>";
            "Parsing '$test' as atom $atom";
            ),
    }
}

for %not-atoms.keys.sort -> $atom {
    for %not-atoms{$atom}.list -> $test {
        nok try {S-Expression.parse($test,
            rule => "atom:sym<$atom>") },
            "Not parsing '$test' as atom $atom";
    }
}
done-testing;

我们设置了一些原子应该匹配的输入字符串(%atoms)和一些不应该匹配的字符串(%not-atoms)。接下来,我们对这些例子进行迭代,然后用例子字符串和相应的规则调用 S-Expression.parse,并对我们期望被解析的字符串调用 ok,对我们期望不匹配的字符串调用 nok(ok的否定)。

对于我们期望解析失败的情况,测试代码将 S-Expression.parse 的调用封装在一个 try {…​} 块中。这并不是绝对必要的,因为我们没有高级错误报告功能,不会导致抛出异常。然而,这样的东西可能会在以后添加,我们不应该通过导致测试失败来惩罚未来的改进。

最后,测试代码不是在开始时使用通常的 plan NUMBER;,而是在结束时使用 done-testing;。这就减轻了我们自己计算测试的需要,但代价是降低了检测能力,如果我们计划中的任何测试失败了,我们就可以进行检测。

测试都通过了,我们就可以进入更高级别的部分了。要解析一个列表,我们需要一对括号,其中包含一个有空格分隔的原子列表。

token expression {
    '(' ~ ')' [<atom>* % \s+]
}

现在我们也可以处理嵌套列表的情况, 其中 atom 是一个子列表:

token atom:sym<expression> { <expression> }

最后我们指定一个 S-表达式是一个表达式列表, 可选择在中间、前面和后面留有空格:

token TOP { \s* [<expression> \s*] * }

这是第一枪,很可能并不完全有效。你可能已经注意到,所有的正则表达式都是 token 而不是 rule。这是因为空白是重要的(它可以分隔原子),而使用 rule 有可能会不经意间用掉这个重要的空白。因此,这个 grammar 错过了应该允许使用空格的情况,但里面还没有任何东西可以解析。解决这个问题的好方法是编写测试并运行它们。

my @tests = '()', '(abc)', ' (abc) ', '( abc )',
            '(1)', '(+1)', '(-1)', '( () ( ) )';

for @tests -> $t {
    ok S-Expression.parse($t), "can parse '$t'";
}

这使两个测试失败了:

not ok 20 - can parse '( abc )'
not ok 24 - can parse '( () ( ) )'

我们可以通过在表达式 token 中添加更多的空白解析来解决这个问题:

token expression {
    \s* '(' ~ ')' [\s* <atom>* % \s+ \s*]
}

下面是 grammar 的全部内容:

grammar S-Expression {
    token TOP                  { \s* [<expression> \s*] *    }
    token expression           {
        \s* '(' ~ ')' [\s* <atom>* % \s+ \s*]
    }
    proto token atom {*}
    token atom:sym<expression> { <expression>                }
    token atom:sym<identifier> {
        <[ a..z A..Z =*:+- ]>
        <[ a..z A..Z 0..9 _ =*:+- ]>*
    }
    token atom:sym<integer>   { <[+-]>? <[0..9]>+            }
    token atom:sym<string>    { '"' ~ '"' <string_contents>  }
    token string_contents     { [ | <-[\\"]>+ | \\ . ]*      }
}

13.1.2. 数据提取

对应于 S 表达式语法的动作类大多是 直截了当。只有两件事与众不同。首先是区分 S 表达式(a)和(“a”)是有用的, 换句话说, 在标识符和带引号的字符串之间。保持这一点 区别, action 类从此类返回一个 Identifier 对象:

与 S-表达式 grammar 对应的 action 类大多是直截了当的。只是有两点有点出格。第一个是区分 S-表达式 (a) 和 ("a"),换句话说,就是区分标识符和引号字符串是有用的。为了保持这种区分,action 类从这个类中返回一个 Identifier 对象:

class Identifier { has $.str }

这可以通过这样的 action 方法来实现:

method atom:sym<identifier>($/) {
    make Identifier.new(str => $/.Str);
}

第二个要考虑的问题是如何处理引号字符串中的反斜线字符。在 S-Expression 的实现中似乎有一个共识,即 \\ 是一个反斜线,而 \" 是一个双引号字符;但是,反斜线后面有另一个不同的字符,其语义上并没有达成一致。在这里,我们将简单地去掉反斜线,所以 \\ 变成 \\a 变成 a:

method string_contents($/) {
    make $/.Str.subst(:global, / \\ (.) /, -> $/ { $0 });
}

用于整数的 action 方法将匹配转换为整数,而其他方法只是传递其捕获的 .made 值:

class Identifier { has $.str }

class S-Actions {
    method TOP($/)                  {
        make $<expression>».made;
    }
    method expression($/)           {
        make $<atom>».made;
    }
    method atom:sym<expression>($/) {
        make $<expression>.made;
    }
    method atom:sym<identifier>($/) {
        make Identifier.new(str => $/.Str);
    }
    method atom:sym<integer>($/)    {
        make $/.Int;
    }
    method atom:sym<string>($/)     {
        make $<string_contents>.made;
    }
    method string_contents($/)      {
        make $/.Str.subst(:global, / \\ (.) /, -> $/ { $0 });
    }
}

我们来写一个简短的测试:

my $m = S-Expression.parse(
    Q'((a "b") 23 "ab \\cd")',
    actions => S-Actions.new,
);
ok $m, 'Can parse S-Expression with action method';

is-deeply $m.made,
    [[[Identifier.new(str => "a"), "b"], 23, "ab \\cd"],],
    "correct data extracted";

这个测试通过了,所以是时候把它封装成一个子例程了,这个子例程将成为 S-Expression 解析器的公共 API:

sub parse-s-expression(Str $input) {
    my $m = S-Expression.parse($input,
        actions => S-Actions.new);
    unless $m {
        die "Cannot parse S-Expression";
    }
    return $m.made;
}

为了简洁起见,我们不会为这个 grammar 改进错误消息的生成;第十一章中讨论的任何技术都可以在这里使用。

13.2. 数学表达式和运算符优先级解析器

如果你拥有的只是一把锤子,那么一切看起来都像钉子一样[67]。 如果你在工作中使用一个闪亮的新工具,比如正则表达式和 grammar,你往往会忽略了使用其他工具来解决问题的选项。

本着这种精神,我想重温一下之前的数学表达式解析的例子,这次在 grammar 中做的少一些,更多的部分在随附的代码中。Grammar 的这次迭代并没有为每个优先级引入一个新的解析级别,而是完全忽略了优先级,只是简单地解析了一个备选项和运算符的列表。然后,action 方法使用运算符优先级表来创建相应的树。这被称为运算符优先级解析器,也简称为 OPP。

这种方法有两个主要的优点。它使得在两个现有的级别之间插入一个优先级更容易,而且 grammar 的扁平化结构使得解析速度更快。

13.2.1. 一个简单的运算符优先级解析器

Grammar 的核心部分是将表达式解析为一个由项组成的列表,并以中缀运算符[68]分隔:

rule expression { <term> + % <infix> }

要使中缀运算符列表具有可扩展性, 它必须是一个 proto token:

proto token infix   { * }
token infix:sym<*>  { <sym> }
token infix:sym</>  { <sym> }
token infix:sym<+>  { <sym> }
token infix:sym<->  { <sym> }
token infix:sym<**> { <sym> }

除了常见的四种运算符外,这个列表中还包括了 **,它常用于求幂,而且比起乘法,它的优先级更高。

term 包括了数字,这不会让你感到惊讶,但我们也可以解析 term 中括号内的子表达式,以及像 cos(0)sqrt(4) 这样的函数调用。

proto token term { * }
token term:sym<integer> {
    <[+-]>? <[0..9]>+
}
rule term:sym<parenthesized> {
    '(' ~ ')'<expression>
}
rule term:sym<function> {
    <name=.identifier> '(' ~ ')' <expression>
}
token identifier { <[a..z]>+ }

是时候把这一切合在一块儿了:

use Grammar::ErrorReporting;

grammar MathExpression does Grammar::ErrorReporting {
    rule TOP { <.ws> <expression> }
    rule expression { <term> + % <infix> }
    proto token infix   { * }
    token infix:sym<*>  { <sym> }
    token infix:sym</>  { <sym> }
    token infix:sym<+>  { <sym> }
    token infix:sym<->  { <sym> }
    token infix:sym<**> { <sym> }

    proto token term { * }
    token term:sym<integer> {
        <[+-]>? <[0..9]>+
    }
    rule term:sym<parenthesized> {
        '(' ~ ')' <expression>
    }
    rule term:sym<function> {
        <name=.identifier> '(' ~ ')' <expression>
    }
    token identifier { <[a..z]>+ } }

Grammar::ErrorReporting 是一个模块,它提供了一个具有花哨的位置输出的错误方法,同时还安装了一个 FAILGOAL 方法,当 ~ 表达式失败时,会触发 FAILGOAL 方法。如果你使用的是 moritzlenz/perl6-regex-alpine Docker 镜像,这个模块已经包含了。否则,你可以通过在命令行上运行如下命令来安装它:

zef install Grammar::ErrorReporting

这个 grammar 现在可以产生一个扁平化的解析树。例如,输入字符串 1 + 2 * 5 产生以下匹配对象:

⌜1 + 2 * 5⌟
 expression => ⌜1 + 2 * 5⌟
  term => ⌜1⌟
   infix => ⌜+⌟
    sym => ⌜+⌟
   term => ⌜2⌟
   infix => ⌜*⌟
    sym => ⌜*⌟
   term => ⌜5⌟

现在,我们需要编写一个运算符优先级解析器,将平面数组 [1, '+', 2, '', 5] 变成一个按优先级分组的树,所以是 [1, '+', [2, '', 5], 或者更好的是 ['+', 1, ['*', 2, 5]]

问题的关键是,每当我们第一次看到一个运算符时,我们不知道它相邻的运算符是否真的属于这个运算符(就像 2*5 的情况一样),或者下一个运算符的优先级更高,并且偷了右边的操作数。

我们可以通过先把一个运算符和它左边的操作数放到堆栈中来解决这个问题。当我们访问下一个运算符时,如果第二个运算符的优先级比第一个运算符低,我们可以把上一个运算符和它的操作数从栈中删除,把它变成一个表达式,然后继续把第二个运算符放在栈中。

如果第二个运算符的优先级较高,我们只需将第二个运算符放在堆栈上即可。当输入列表结束后,我们可以应用还原步骤,直到堆栈上只剩下一个元素。这就是这个算法在代码中的样子。

class MathActions {
    method opp-parse(@tokens) {
        sub opp-reduce(@stack) {
            my ($term1, $op, $term2) = @stack.splice(*-3, 3);
            @stack.push([$op, $term1, $term2]);
        }

        my %prec = '+' => 1, '-' => 1,
                   '*' => 2, '/' => 2,
                   '**' => 3;

        my @stack = @tokens[0];
        for @tokens[1..*] -> $op, $term {
            while @stack > 2
                    && %prec{$op} <= %prec{@stack[*-2]} {
                opp-reduce(@stack);
            }
            @stack.push($op, $term);
        }
        opp-reduce(@stack) while @stack > 1;
        return @stack[0];
   }
}

方法 opp-parse 期待一个 token 列表,从一个项目(term)开始,然后是一个中缀运算符,以此类推,其他每一个 token 都是一个运算符。

第一个元素是一个子程序 opp-reduce,它实现了简化步骤:从堆栈中取出最后两个项和它们之间的运算符,然后将它们作为数组添加回来。因此,如果堆栈中的元素有 1, '+', 2, '', 4,那么调用这个函数将其修改为 [1,'+',['+',['',2,4]]],第二次调用将其进一步修改为 ['+',1,['*',2,4]]]

%`prec 是一个哈希值,它将每个中缀运算符映射到一个数字优先级,其中较高的数字对应的运算符与参数的绑定更紧密。

随后,@stack 被初始化为第一个项(来自 @tokens[0]),然后循环运行在其余的 token 上,每次运行两个元素。只要堆栈至少有三个元素(因此至少有一个运算符),并且当前运算符的优先级比前一个运算符低,代码就会调用 opp-reduce 函数。在任何情况下,当前运算符和项都会被添加到堆栈中。

当所有的初始 token 都被访问过后,剩下的就是重复简化步骤,直到堆栈上只剩下一个元素。这就是我们感兴趣的解析树。

注意,通过解析 token term 中的括号表达式,我们不需要把括号作为运算符优先级解析器中的运算符来处理。更多的传统实现只需要使用词法分析和运算符优先级解析器来检查和处理小括号对。

相比之下,其余的 action 方法都相当直接。

method TOP($/) { make $<expression>.made }
method expression($/) {
    my @tokens = $/.caps.map({.value.made});
    make self.opp-parse(@tokens);
}
method infix:sym<*>($/)  { make ~$<sym> }
method infix:sym</>($/)  { make ~$<sym> }
method infix:sym<+>($/)  { make ~$<sym> }
method infix:sym<->($/)  { make ~$<sym> }
method infix:sym<**>($/) { make ~$<sym> }

method term:sym<integer>($/) { make $/.Int }
method term:sym<parenthesized>($/) {
    make $<expression>.made;
}
method term:sym<function>($/) {
    make [$<name>.made, $<expression>.made];
}
method identifier($/) { make $/.Str }

这里唯一有趣的地方是表达式方法。记住,方法 opp-parse 希望项与运算符交错,但规则 expression 的匹配会产生两个数组,$<term>$<infix>。我们可以自己做交织,但 Raku 提供了一个更好的解决方案: Match 类中的方法 caps[69] 会按照字符串中出现的顺序返回所有的捕获,这正是我们想要的交错顺序。该方法的 caps 返回的是封装在 Pair[70] 对象中的捕获,所以我们需要调用 .value 来到达 match 对象,然后调用 .made 来访问附加在 match 上的抽象语法树 (AST)。 map 方法对 cap 返回的所有元素都是这样做的。

同样的,我们可以把对 grammar 的调用封装成一个子例程。

sub parse-math-expression(Str $input) {
    my $match = MathExpression.parse($input,
        actions => MathActions.new);
    die "Cannot parse input" unless $match;
    return $match.made;
}

并写一些测试。这里仅举一个例子:

use Test;
plan 1;

is-deeply parse-math-expression('1 + 2 * 3**4 * 5 + 6'),
    ["+", ["+", 1, ["*", ["*", 2, ["**", 3, 4]], 5]], 6],
    'correct parse tree from nested expression';

13.2.2. 更灵活的方法

一个可能的解决方法是创建某种注册表,通过它,扩展可以添加一个运算符,但问题就更深了。有几个用例,我们需要更多关于运算符的元数据。其中一个例子是,如果解析器能够处理不同的关联性。如果一个运算符出现两次或更多(或者在同一个表达式中出现了相同优先级的不同运算符),那么表达式 1 OP 2 OP 3 可以被解释为 (1 OP 2) OP 3 (左结合)或 1 OP (2 OP 3) (右结合)。对于求和 + 和乘法 * 运算符,优先级不重要。对于减法 - 和除法 /,典型的结合性是左结合,但对于指数运算,大多数编程语言都假定为右结合。我们需要一种方法来存储运算符的结合性和优先级。

还有其他类型的运算符。后缀运算符站在(后)项之后。例如,阶乘运算符(!)代表所有正整数的乘积,直到并包括它后面的数字。举例说明一下。6!6*5*4*3*2*1 或 720。现在,我们需要关于运算符类型的元信息,而且由于后缀运算符打破了关于交替项和运算符的规则[71],所以运算符优先级解析器也需要一种方法来可靠地区分运算符和项。

为了解决这些困难,常用的方法是将所有关于运算符的信息收集到一个对象中。然后,运算符优先级解析器可以做一个类型检查,看看某物是否是一个运算符,然后询问它的优先级、结合性和类型(中缀、后缀和前缀)。更重要的是,不需要再有一个中心位置来存储所有类型。当你为 grammar 创建一个扩展角色时,你也为 action 类创建了一个扩展角色,而 action 类又反过来为新引入的运算符返回一个对象。

最小化的方法(还没有处理结合性或其他类型的运算符)是这样的。

class Operator {
    has Str $.symbol is required;
    has Numeric $.precedence is required;
    method new(Str $symbol, Numeric $precedence) {
        self.bless(:$symbol, :$precedence);
    }
}

然后, 运算符的 action 方法返回 Operator 对象:

method infix:sym<+>($/)  { make Operator.new(~$<sym>, 1) }
method infix:sym<*>($/)  { make Operator.new(~$<sym>, 2) }
method infix:sym<**>($/) { make Operator.new(~$<sym>, 3) }
# more action methods go here

opp-parse 方法现在可以作为对象访问运算符,因此可以直接使用它们的优先级进行比较:

for @tokens[1..*] -> $op, $term {
    opp-reduce(@stack)
        while @stack >= 3
           && $op.precedence <= @stack[*-2].precedence;
    @stack.push($op, $term);
}

比较 $op.precedence ⇐ @stack[*-2].precedence 是针对左结合性的运算符的;当你把小于或等于()改为严格的小于(<)时,你会得到右结合性运算符的行为。如果你想更多的探索运算符优先级解析器, 你可以在 Operator 类中添加一个结合属性,给指数运算符以右优先级,并改变比较方式来考虑结合性。表达式 2 3 4 应该是 ['' 2, ['', 3, 4]]]。如果你觉得很冒险,你甚至可以尝试在 grammar 和运算符优先级解析器中添加后缀运算符。

13.3. Pythonesque, 一种基于缩进的语言

Python、YAML、CoffeeScript 等编程语言和数据格式都使用缩进的方式来编码程序或数据的结构。例如,这个 Raku 程序:

if 1 < 2 {
    say "Inside the branch";
}
say "outside the branch";

在 Python 3 中会是这样的:

if 1 < 2:
    print("Inside the branch")
print("outside the branch")

在一个以冒号(:)结束的行之后,下一行必须比上一行缩进更多。所有与第二行有相同缩进的行都属于这个块。如果一行的前导空格比前一个区块少,那么这就完成了所有开放的区块的前导空格较多的区块。

Python 或 YAML 的完整 grammar 在这本书中会有太多的内容,所以让我们来探讨解析基于缩进的语言背后的概念,而不需要考虑大多数不相关的复杂问题。

我们在这里要解析的语言是 Python 的一个小子集,我给它取名 Pythonesque。它包含了包含数字、变量和常用的中缀运算符的表达式,以及带有缩进体的 if 语句。if 语句可以嵌套。

下面的代码是用 Pythonesque 编写的一个小程序示例。

a= 1
if a:
    x= 1
    y= 2
    if x + 1 < 3:
        z=x+ y
    a=z* 4
b= 5

最上层开始时缩进为零,第一层缩进至少使用了一个空格(这里是四个空格),第二层必须比第二层进一步缩进(这里是十个空格)。

13.3.1. Pythonesque 的语法

我们会把表达式解析得相当简约:

grammar Pythonesque {
    token ws { \h* }
    rule expression  { <term> + % <operator>  }
    token term       { <identifier> | <number>}
    token number     { \d+                    }
    token identifier { <:alpha> \w*           }
    token operator   {
         <[-+=<>*/,]>| '==' | '<=' | '>=' | '!='
    }
    # more rules go here later
}

在现实世界的用例中,我们很可能会对项和运算符都使用 proto token;在这里,我们只使用最低限度的解析表达式。如果你想强制执行运算符优先级,你可以使用前面案例研究中的运算符优先级解析器。

因为在 Pythonesque 中,空白是重要的,所以 ws 只匹配水平空白(我们允许在 token 之间匹配;也就是说,你可以把 a=1 写成 a = 1)。尽管如此,我们只能在确保行的起始处的空白被不同的规则匹配时才能做到这一点。

token line {
    ^^ ( \h* ) { self.handle_indentation($0) }
    <statement> $$ \n*
}
proto token statement { * }
token statement:sym<expression> {
    <expression>
}

这里的核心概念是,一行的输入包括前导缩进(长度可能为 0,因此用 \h*),然后是一个语句,最后是行末 $$,后面是一个或多个换行符。需要注意的是,必须检查前导空格的数量。如果与前一行相同,那么一切都没有问题。如果符合外侧作用域的缩进量,内侧作用域必须完成。如果都不匹配,那就说明我们发现了一个错误。要做这个检查,我们需要一个当前活动的(尚未完成的)缩进级数组。

token TOP {
    :my @*INDENTATION = (0,);
    <line>*
    $
}
method handle_indentation($match) {
    my $current = $match.Str.chars;
    my $last = @*INDENTATION[*-1];
    if $current > $last {
        die "Inconsistent indentation: expected "
          ~ "at most $last, got $current spaces";
    }
    elsif $current < $last {
        my $idx = @*INDENTATION.first(:k, $current);
        if defined $idx {
            for $idx + 1 .. @*INDENTATION.end {
                @*INDENTATION.pop;
            }
        }
        else {
            die "Unexpected indentation level: $current.";
        }
    }
}

token TOP 初始化一个动态变量 @*INDENTATION,以 0 作为其第一个元素。下一步,我们将实现向数组的末尾添加元素,但现在我们只假设它包含每个缩进级的空格数。当我们解析这个输入时:

if a:
    x= 1
    if x + 1 < 3:
        z=x+ y

数组 @*INDENTATION 包含数字 0、4和10。如果当前的缩进是10个空格(由 $match.Str.chars 决定;更复杂的分析可能会将制表符的权重与空格的权重不同,或者禁止混合制表符和空格),则不会发生任何事情。如果当前有更多的前导空格,这一定是一个错误,因为只有在 if 语句之后才允许增加缩进。

如果当前的缩进程度小于 @*INDENTATION 中记录的最后一个缩进程度,我们必须在 @*INDENTATION 中搜索当前值。这就是 my $idx = @*INDENTATION.first(:k, $current); 的作用。first[72] 方法首先搜索第一个符合某个标准的数组元素,这里的标准是等于 $current。用 :k 修饰符,它返回该数组元素的索引。如果没有找到,则返回一个未定义的值,if defined $idx 检查触发,抛出一个异常。如果找到了,当前的缩进值就是前一个作用域的缩进值。我们然后需要关闭所有更深的缩进的内部作用域,这就是 @*INDENTATION.pop 对每个这样的作用域所做的。

当我们解析一个 if 语句时,我们知道它之后的行必须比当前的行有更多的缩进,但还不知道缩进多少。我们可以通过设置第二个变量,或者通过将一个特殊的值推送到 @*INDENTATION 中,来提示这个事实。这个哨兵值[73]可以是一个非有效的缩进值(比如-1或0.5),或者是一个单独的类型。

grammar Pythonesque {
my class NEW_INDENTATION { }
rule statement:sym<if> {
        'if'  <expression> ':'
        { @*INDENTATION.push(NEW_INDENTATION) }
    }
    # rest of the grammar here
}

现在我们要在方法 handle_indentation 中处理 NEW_INDENTATION 的情况:

handle_indentation:

method handle_indentation($match) {
    my $current = $match.Str.chars;
    my $last = @*INDENTATION[*-1];
    if $last ~~ NEW_INDENTATION {
        my $before = @*INDENTATION[*-2];
        if $current > $before {
            @*INDENTATION[*-1] = $current;
        }
        else {
            die "Inconsistent indentation: expected "
              ~ "more than $before, got $current spaces";
        }
    }
    elsif $current > $last {
        die "Inconsistent indentation: expected "
          ~ "at most $last, got $current spaces";
    }
    # rest of the method as before
}

这标志着我们的 grammar 可以成功解析本节开始时给出的例子,同样重要的是,拒绝解析包含无效缩进的例子。

然而,如果你尝试着为这个 grammar 编写 action 方法,你很快就会发现,这是个不合理的困难,因为这个 grammar 并不传达一个解析过的作用域何时开始或结束。因此,action 方法需要查看一行开始时的空白量,然后使用 @*INDENTATION (或维护自己的版本) 以重构当前和外部作用域的图像。这是一个令人遗憾的重复工作,也是一个相当高的耦合量。 的动作方法到语法的内部。

一个更干净的解决方案是提供某种 API,在 grammar 进入或离开作用域时向 action 对象指示。要做到这一点,一个很好的技巧是调用总是匹配零字符的规则作为一种标记。

token enter_scope { <?> }
token leave_scope { <?> }
token TOP {
    :my @*INDENTATION = (0,);
    <.enter_scope>
    <line>*
    $
    <.leave_scope>
}

token enter_scopeleave_scope 中的 construst<?> 是一个空的零字符断言,它总是成功地匹配空字符串。

除了 token TOP 的调用外,我们还需要在方法 handle_indentation 中插入一个调用 self.enter_scope(),在它遇到 NEW_INDENTATION 标记的地方,当一个作用域完成后,再加上一个调用 self.leave_scope(),这样就伴随着执行 @*indentation.pop() 的行。

method handle_indentation($match) {
    my $current = $match.Str.chars;
    my $last = @*INDENTATION[*-1];
    if $last ~~ NEW_INDENTATION {
        my $before = @*INDENTATION[*-2];
        if $current > $before {
            @*INDENTATION[*-1] = $current;
            self.enter_scope();
        } ...

       elsif $current < $last {
           my $idx = @*INDENTATION.first(:k, $current);
           if defined $idx {
               for $idx + 1 .. @*INDENTATION.end {
                   @*INDENTATION.pop;
                   self.leave_scope();
                }
            }
        ...

现在 action 对象有了一个可以挂接的地方,让它们可以合理地处理语句嵌套。

13.3.2. Action 对象

当我第一次为 Pythonesque 写 action 对象时,我把所有的东西都做成了数组:if 语句是一个数组,第一个元素是字符串 "if",第二个元素是条件,第三个元素是 if 语句的主体。表达式也是数组,所以第二个元素也是一个数组。我很快就把生成的 AST 给弄丢了。

为了解决这个问题,我把表达式保存为数组,但把操作符、变量和 if 语句移到单独的类中。

class Operator {
    has Str $.action is required;
}
class Variable {
    has Str $.name is required;
}
class If {
    has $.condition;
    has @.block;
}

这样的设置完成后,简单语句的 action 方法就不会很奇怪了。

class Pythonesque::Actions {
    # more methods go here
    method statement:sym<if>($/) {
        make If.new(condition => $<expression>.made);
    }
    method statement:sym<expression>($/) {
        make $<expression>.made;
    }
    method expression($/) { make $/.caps».value».made.Array }
    method identifier($/) { make Variable.new(name => $/.Str) }
    method term($/)       { make $/.caps[0].value.made; }
    method number($/)     { make $/.Int }
    method operator($/)   { make Operator.new(action => $/.Str) }
}

注意方法语句 :sym<if> 中的 if 对象是如何用条件初始化的,而不是用主体初始化的。这是因为在 statement:sym<if> 运行时,它还没有被解析。

为了使语句的嵌套正确,我们需要维护一个作用域的堆栈。最底层的元素(或索引0,如果以数组的形式实现)是最外层的作用域,最上面的元素(或索引 *-1)对应于当前的作用域。

当我们离开一个作用域(除了最外层的作用域,也称为"主线")时,它属于一个 if 语句,而这个 if 语句是最外层的作用域中最后一个被解析的语句。所以我们需要将当前的作用域去掉堆栈的作用域,并将其设置为 if 语句的主体。

class Pythonesque::Actions {
    has @.scopes;
    method TOP($/) {
        make @.scopes[0];
    }
    method line($/) {
        @.scopes[*-1].push($<statement>.made)
    }
    method enter_scope($/) { @.scopes.push([]) }
    method leave_scope($/) {
        if @.scopes > 1 {
            my $last = @.scopes.pop;
            @.scopes[*-1][*-1].block = $last.list;
       }
    }
    # rest of the class goes here as before
}

用不同的例子字符串进行测试,发现这一点大部分情况下都是可行的,但是在一个有趣的角落里失败了:如果输入的最后一行至少有两层缩进,那么在 AST 中只有第一层的缩进。比如说,这个输入:

if 1:
    if 2:
        x= 2

产生这个 AST:

[If.new(condition => [1], block => [])

其中缺少第二个 if 语句及其主体。由于 if-objects 的主体在 leave_scope 调用中被填充了,所以 leave_scope 没有被调用是合理的。在 action 方法 enter_scopeleave_scope 中添加调试 say 语句就证实了这一点:对于前面的输入,enter_scope 被调用了三次(主线和内部作用域各调用一次),而 leave_scope 只被调用一次。

leave_scope 可以从两个地方被调用:从 token TOP(有效)和方法 handle_indentation 调用。这样错误就更清楚了:只有在调用 handle_indentation 的时候,才会调用 leave_scope,而当调用 handle_indentation 的缩进值小于当前级别时,才会调用 leave_scope。但是,当末尾没有未缩进的行被解析时,就不会发生这样的调用 handle_indentation。我们可以通过在结尾处人为地插入这样的调用来修复这个 bug。

token TOP {
    :my @*INDENTATION = (0,);
    <.enter_scope>
    <line>*
    $

    # leave all open scopes:
    { self.handle_indentation('') }
    <.leave_scope>
}

有了这个修复,所有的测试都通过了,我们可以认为这个实验是成功的。你可以在 GitHub[74] 上查看完整的代码,包括测试(以及一些小的改进,使错误条件的测试更加容易)。

13.4. 总结

本章已经向你展示了现实世界中用于处理现实世界数据的解析器的元素:递归、运算符优先级解析器,以及基于缩进级别的结构解析。最后一个例子还展示了在递归和代码之间的相互作用中的取舍。

在本书的整个过程中,你已经了解了关于 Raku 的基本知识,Raku 正则表达式的构建模块,正则表达式和 Raku 代码如何相互作用,以及捕获,它提供了一种从正则表达式匹配中提取信息的方法。

本书中关于正则表达式机制和技巧的章节已经让你了解了正则表达式的工作原理,以及如何使用它们来解析常见的数据格式。语法允许你结构化和重用重构 grammar。action 对象机制方便了对结果匹配的访问,而关于错误报告的讨论则说明了当匹配失败时该怎么做。

有了前几章的背景知识和本章的应用,你应该能够编写自己的 grammar 和解析器了。在此,我想用 Perl 的创造者、Perl 6 的发明者 Larry Wall 的一句话来告诫大家:要有适量的乐趣!在这里,我想说的是,你应该能在本章中找到自己的乐趣。


3. 其他编程语言用 "Traits" 一词来表示 Raku 中角色的概念。
7. Raku 希望你的程序文件是 UTF-8 编码的。
8. www.apress.com/us/book/9781484228982
9. www.packtpub.com/application-development/perl-6-deep-dive
14. 严格来说, 锚点也是零宽断言, 既可以向前查看, 也可以向后查看。
15. 如果这些对你来说没有意义, 不要担心;在后面的章节中讨论了匹配对象和正则表达式技术性细节之后, 就会比较清楚了。
16. 应该是没有这个必要。但还是要说, 有这个能力就更好了。
19. 实际上他们就是
20. 从技术上讲, 还有标题大小写, 在标题中, 每个字的第一个字母的大写时, 比如在标题中, 也有特殊规定。修饰符 :ignorecase 也可以使用标题大小写。
25. 或者至少它试图以不同的方式进行匹配; 详情见第六章中关于回溯的讨论。
28. Raku 的早期版本为所有量词化的捕获创建了一个数组捕获, 即使是使用了 ? 量词符。这让大多数用户感到困惑。
29. 这个素数测试通常归功于 Abigail, 一个多产的 Perl 黑客。例如参见 http://neilk.net/blog/2000/06/01/abigails-regex-to-test-for-prime-numbers/。
30. 有限状态机形式假定你总是想匹配整个字符串, 因此使用了锚点。如果你只想在一个字符串中搜索, 你必须在 regex 后面加上 .*
32. 对于贪婪或节俭的量词, 它试图匹配它能匹配的最短的子串。
38. Raku 有一个名为 byte 的内置类型, 所以如果省略了 & 符号, 你会不小心引用这个类型。
39. 在 Python 中, 这个问题就更复杂了, 因为行的开头的空格是重要的, 除非是在表达式里面。
40. <!ww> 匹配除了单词内之外的任何地方
41. 记住, <.ws> 中的前导点会导致 regex 不被捕获。因为 ws 例程都是关于我们并不特别在意的空白, 不捕捉它的匹配是有意义的。
47. 就像瑞士军刀, 但威力更大。
53. 在这一点上, 标识符不解析其周围的空白是至关重要的。因此, 令牌不关心空白的原则, 以及调用这些令牌的规则解析空白。
54. 在这种情况下, 这种情况是无害的, 因为没有其他的规则可以匹配到变量的其他部分, 但还是会导致解析错误。但是, 在更复杂的情况下, 这种不经意的回溯可能会导致 grammar 的维护者感到非常困惑的错误。
63. 可悲的是, 许多关于解析的学术文献都忽略了这一方面, 大概是因为很难量化错误信息的质量。
67. 亚伯拉罕-马斯洛之后, 1966 年。见 https://en.wikipedia.org/wiki/ Law_of_the_instrument.
68. 中缀运算符是指在两个项之间的运算符, 如 2*4 中的星号 *。
71. 例如,表达式 3! + 2 有一个项,后面是后缀运算符,再后面也是后缀运算符。