Raku 基础

on

1. 什么是 Raku

Raku 是一种编程语言。它以自然语言为灵感,设计成易学、易读、易写的语言。它让初学者可以编写 "婴儿Perl",同时也让有经验的程序员可以自由表达,从简练到诗意。

Raku 是渐近类型的。它主要遵循了动态类型化语言的范式,即接受那些在编译过程中无法保证类型安全的程序。然而,与许多动态语言不同的是,它接受并执行类型约束。在可能的情况下,编译器使用类型注解来在编译时做出决定,否则只有在运行时才有可能。

许多编程范式都影响了 Raku 。你可以在 Raku 中编写命令式、面向对象和函数式的程序。声明式编程的功能,如多重分派、子类型、regex 和 grammar 引擎等,都支持声明式编程。

在 Raku 中,大部分的查询都是词法查询,而且语言避免了全局状态。这使得程序的并行和并发执行更加容易,Raku 对高级并发原语的关注也是如此。当你不希望被限制在一个 CPU 内核上时,你会考虑 Promise 和消息队列, 而不是考虑线程和锁。

Raku 作为一门语言,对于 Raku 程序应该被编译还是被解释的问题,Raku 是没有主见的。Raku 作为一种主要的实现, 可以快速编译模块并解释脚本。

1.1. Perl5, 老天鹅

在 2000 年左右,Perl 5 的发展面临着巨大的压力,面临着既要发展又要保持向后兼容的矛盾。

Raku 是释放这种压力的阀门。所有需要打破向后兼容性的扩展建议都被引导到了 Raku 中,使其处于梦幻般的状态,一切皆有可能,没有任何东西是固定的。经过几年的努力,Raku 才进入了一个比较稳固的状态。

在这段时间里,Perl 5 也在不断发展,这两种语言的差异性足够大,以至于大多数 Perl 5 的开发者不再认为 Raku 是一条自然的升级之路,以至于 Raku 并没有试图让 Perl 5 过时(至少不会比它试图让任何其他编程语言过时的程度更高:-),2015 年第一次稳定发布的 Raku 并没有表明对 Perl 5 的支持失效。

Perl 5 是由一个独立的爱好者社区开发的,他们一直关注着 Raku,寻找值得采纳到 Perl 5 中的功能,所以虽然 Perl 5 和 Raku 社区有一定的重叠和交流,但两者都是独立发展的。

1.2. 库的可用性

作为一种相对年轻的语言,Raku 缺乏像 Perl 5 和 Python 这样的语言所提供的成熟的模块生态系统。

为了弥补这个差距,就有了接口,你可以调用用 C、Python、Perl 5 和 Ruby 编写的库。Perl 5 和 Python 的接口足够复杂,你可以编写一个 Raku 的类,它可以子类化用这两种语言编写的类,反之亦然。

因此,如果你喜欢某个特定的 Python 库,你可以通过 Inline::Python 模块将其加载到你的 Raku 程序中。

1.3. 我为什么要使用 Raku?

如果你喜欢动态类型化的编程语言所带来的快速原型开发体验,同时又希望有足够的安全功能来构建大型的、可靠的应用程序,那么 Raku 是个不错的选择。它的渐进式类型让你可以在没有完全了解所涉及的类型的情况下编写代码,以后还可以引入类型约束,防止将来内部和外部 API 被滥用。

Perl 在通过正则表达式(regexes)来处理文本的历史悠久,但比较复杂的 regexes 却有一个很难读和维护的名声。Raku 解决了这一问题,它将 regex 与代码放在同一层次上,允许你把它们像子程序一样命名,甚至可以使用类继承和角色组成等面向对象的特性来管理代码和 regex 重用。由此产生的 grammar 非常强大,也很容易读懂。事实上,Raku 编译器就是用 Raku 的 grammar 来解析 Raku 的源码的!

说到文本,Raku 有惊人的 Unicode 支持。如果你要求用户输入一个数字,而他们输入的数字不是 ASCII 范围内的阿拉伯数字,那么 Raku 仍然可以满足你的要求。如果你处理的是不能用一个单一的 Unicode 代码点来表示的字素,Raku 仍然可以将其作为一个单一的字符来表示。

有更多的技术优势,我可以列举出来,但更重要的是,这套语言的设计是为了让用户使用起来更有趣。其中一个重要的方面就是良好的错误信息。你是否曾对 Python 感到恼火过,因为当出现错误时,通常只给出了 SyntaxError: invalid syntaxError: invalid syntax 语法。例如,这个错误可能来自于忘记了结尾的括号。在这种情况下,Raku 编译器会打印出:

Unable to parse expression in argument list; couldn't find final ')'

这实际上告诉你什么是错的。但这只是冰山一角。编译器可以捕捉到常见的错误,并指出可能的解决方案,甚至还建议对拼写错误进行修复。Raku 社区认为那些 less than awesome, 的错误信息,即 LTA,是值得报告的错误信息,并在提高错误信息的标准上花了很多精力。

最后,Raku 给了你自由,可以用不同的方式和不同的编程范式来表达你的问题域和解决方案。如果核心语言提供的选项还不够多,那么它在设计时就考虑到了可扩展性,让你既可以引入新的面向对象代码的语义,也可以引入新的语法。

1.4. 总结

Raku 是一种灵活的编程语言,它为初学者和专家提供了许多酷炫而方便的功能。它提供了灵活性,类型检查,以及强大的 Unicode 和文本处理支持。

2. 运行 Raku

在我们开始探索 Raku 之前,你应该先有一个环境,在这个环境中你可以运行 Raku 代码。所以你需要安装 Raku,这是目前唯一一个积极开发的 Raku 编译器。或者更好的方法是安装 Rakudo Star,这是一个包含 Rakudo 本身、一些有用的模块和一个可以帮助你安装更多模块的工具的发行版。

安装 Rakudo 本身只给你提供了编译器。它遵循每月发布周期,所以它可以让你跟上最新的发展。当你选择安装 Rakudo Star 时,通常每隔一段时间就会发布一次。三个月后,你会得到一个比较稳定的开发基础,还有一些工具,比如调试器和模块安装程序。你可以使用模块安装程序来利用预装的软件库,这些库既不包含在 Rakudo 本身,也不包含在 Rakudo Star 中。

下面的章节将讨论安装 Rakudo Star 的几个选项。选择任何适合你的方法。

本书中的示例使用 Rakudo 2017.04.03 或 Rakudo Star 2017.04(Rakudo Star 2017.04.03 是建立在 Rakudo 2017.04.03 的基础上),只要它支持 Raku 版本 6.c,就应该与此版本或任何更新的 Rakudo 版本一起工作。

2.1. 安装器

你可以从 https://rakudo.org/downloads 下载安装程序,用于 Mac OS(.dmg) 和 Windows(.msi)。下载后,你可以启动它们,它们会引导你完成安装过程。

预建的 Linux 软件包可以从 https://github.com/nxadm/rakudo-pkg/releases/ 中获得,用于 Debian、Ubuntu、CentOS 和 Fedora 等等。在这两种情况下,请使用 2017.04 版本,以获得与本书中使用的示例的最佳兼容性。

注意,Rakudo 是不可移动的,这意味着你必须安装到安装程序的创建者决定的固定位置。将安装程序移动到不同的目录是不可能的。

在 Windows 上,安装程序(图 2-1)提供了将 C:\rakudo\share\binC:\rakudo\share 添加到你的 PATH 环境中。你应该选择这个选项,因为它允许你执行 Rakudo raku (和模块安装程序代表你安装的程序),而不需要指定完整的路径。

2.2. Docker

在支持 Docker 的平台上,你可以从 docker hub 中拉取一个现有的 Docker 容器:

$ docker pull rakudo-star

然后,你可以使用这个命令获取一个交互式的 Rakudo shell:

$ docker run -it rakudo-star raku:2017.04

但仅靠这个命令是无法执行脚本的,因为容器有自己独立的文件系统。要使容器内的脚本可用,你需要告诉 Docker 使当前的目录对容器可用。

$ docker run -v $PWD:/raku -w /raku -it rakudo-star raku

选项 -v $PWD:/raku 指示 Docker 将当前的工作目录($PWD) 挂载到容器中,在那里它将作为 /raku 可用。为了使相对路径起作用,-w /raku 指示 Docker 将 Rakudo 进程的工作目录设置为 /raku

由于这条命令行开始变得笨重,所以我创建了一个别名(这是 Bash 语法,其他的 shell 可能会有稍微不同的别名机制)。

alias p6d='docker run -v $PWD:/raku -w /raku -it rakudo-star raku'

我把这一行放到了我的 ~/.bashrc 文件中,所以新的 shell 实例有一个 p6d 命令,简称 "Raku docker"。

作为一个简短的测试,看看它是否有效,你可以运行:

$ p6d -e 'say "hi"'
hi

如果你使用 Docker 路由,请使用 p6d 别名而不是 raku 来运行脚本。

2.3. 从源代码创建

要从源码构建 Rakudo Star,你需要安装 make,GNU C 编译器[1] (GCC),或者 clang 和 Perl 5。这个例子安装到 $HOME/opt/rakudo-star 中。

$ wget https://rakudo.org/dl/star/rakudo-star-2017.04.tar.gz
tar.gz
$ tar xzf rakudo-star-2017.04.tar.gz
$ cd rakudo-star-2017.04/
$ perl Configure.pl --prefix=$HOME/opt/rakudo-star --gen-moar
$ make install

你应该有大约 2GB 的内存来做最后一步;构建编译器是一项资源密集型的工作。

你需要在你的 PATH 环境变量中添加到两个目录的路径,一个是 Rakudo 本身,一个是模块安装程序安装的程序。

PATH=$PATH:$HOME/opt/rakudo-star/bin/:$HOME/opt/rakudo-star/share/raku/site/bin

如果你是 Bash 用户,你可以把这一行放到你的 ∼/.bashrc 文件中,让它在新的 Bash 进程中可用。

2.4. 测试你的 Rakudo Star 安装

现在你应该可以在命令行中运行 Raku 程序,并询问 Rakudo 的版本:

$ raku --version
This is Rakudo version 2017.04.2 built on MoarVM version 2017.04
implementing Raku.c.

$ raku -e "say <hi>"
hi

如果所有这些方法都无法产生一个可用的 Rakudo 安装,你应该向友好的 Raku 社区描述你的问题,他们通常可以提供一些帮助。https://raku.org/community/ 描述了与社区互动的方式。

2.5. 文档

Rakudo 本身没有什么文档,因为大部分有趣的资料都是关于 Raku 语言的。但 Rakudo 确实有一个命令行选项的摘要,你可以通过调用 raku --help 来访问。

Raku 语言文档的官方网址是 https://docs.raku.org/,它的目的是提供参考和教程式的资料。其他的好资源可以在 https://raku.org/resources/ 中找到,其中很多是由 Raku 社区的成员创建和维护的。

2.6. 总结

在大多数平台上,你可以从预建的二进制安装程序中安装 Rakudo Star。如果无法使用,可以使用 Docker 映像。最后,Rakudo Star 可以从它的源码中构建 Rakudo Star。

3. 格式化数独字谜

作为 Raku 的温柔介绍,让我们考虑一下我最近在追求自己的一个爱好时遇到的一个小任务。

数独是在一个 9×9 的网格上玩的一种数字放置谜题,它被细分为 3×3 的方块(图3-1)。有的单元格里填上1到9的数字,有的单元格是空的。游戏的目的是填空单元格,使每一行、每一列和每一个 3×3 的单元格中,从1到9的每一个数字都刚好出现一次。

未解的数独字谜

一个有效的数独的存储格式简单来说就是一个81个字符的字符串,0代表空单元格,数字1到9代表预填充单元格。我想解决的任务是把它变成一种更友好的格式。

输入的格式可以是:

000000075000080094000500600010000200000900057006003040001000023080000006063240000

接下来是我们的第一个 Raku 程序。

# file sudoku.p6
use v6;
my $sudoku = '000000075000080094000500600010000200000900057006003040001000023080000006063240000';
for 0..8 -> $line-number {
    say substr $sudoku, $line-number * 9, 9;
}

你可以这样运行:

$ raku sudoku.p6
000000075
000080094
000500600
010000200
000900057
006003040
001000023
080000006
063240000

这里面没有什么神奇的地方,但让我们逐行看一下代码。第一行,以 # 开头,是一个注释,一直延伸到行尾。

use v6;

这一行并不是严格意义上的必须,但也是很好的做法。它声明了你所使用的 Perl 版本,这里是 v6;换句话说,就是任何版本的 Raku 语言。我们可以说得更具体一些,使用 v6.c 来要求使用这里讨论的版本。如果你不小心用 Perl 5 运行了 Raku 程序,你会很高兴你包含了这一行,因为它会告诉你以下内容。

$ perl sudoku.p6
Perl v6.0.0 required--this is only v5.22.1, stopped at sudoku.p6 line 1. BEGIN failed--compilation aborted at sudoku.p6 line 1.

而不是更隐晦的:

syntax error at sudoku.p6 line 4, near "for 0"
Execution of sudoku.p6 aborted due to compilation errors.

第一行有趣的是:

my $sudoku = '00000007500...';

my 声明了一个词法变量,它从声明点到当前作用域的末尾都是可见的。它从声明点开始到当前作用域的末尾都是可见的,也就是说,它可以是当前区块的末尾,如果它在任何区块之外,则是文件的末尾,就像本例中的情况一样。

变量的开头是一个 sigil,这里是 $,sigil 是 Perl 的名词,它让 Perl 有了行噪声的名声,但噪声中也有信号。这个 $ 看起来像一个 S,代表标量。如果你懂得一些数学知识,你会知道标量只是一个单一的值,而不是向量甚至是矩阵。

这个变量开始时并不是空的,因为在它的旁边有一个初始化。它开始时的值是一个字符串的字段,如引号所示。

需要注意的是,除了"它是一个标量"这个非常模糊的符号所暗示的"它是一个标量 "之外,我们不需要声明变量的类型。如果我们愿意,我们可以添加一个类型约束。

my Str $sudoku = '00000007500...';

但在快速原型设计时,我往往会放弃类型约束,因为我往往还不知道代码到底会如何运行。

实际的逻辑发生在接下来的几行中,通过迭代0到8的行数来实现。

for 0..8 -> $line-number {
    ...
}

for 循环具有 ITERABLE BLOCK 的一般结构。这里的 iterable 是一个范围[2],块是一个尖号块。该块以 开头,引入了一个签名。这个签名告诉编译器这个块期望有哪些参数,这里是一个单一的标量,叫做 $line-number

Raku 允许使用破折号 - 或单引号 ' 将多个简单的标识符连接成一个大的标识符。这意味着只要下面的字符是字母或下划线,就可以在标识符中使用。

同样,类型约束是可选的。如果你选择了包含它们,它会是 for 0..8 → Int $line-number { …​ }

$line-number 又是一个词法变量,在签名后的块中可见。块由大括号分隔。

say substr $sudoku, $line-number * 9, 9;

say[3] 和 substr[4] 都是 Raku 标准库提供的函数。

substr($string, $from, $num-chars)$string 中提取一个子字符串。它从一个基于零的索引 $from 开始,取 $num-chars 指定的字符数。哦,在 Raku 中,一个字符是真正的一个字符,即使它是由多个代码点组成的,比如重音罗马字母。

say 然后打印出这个子字符串,然后是一个断行符。

从这个例子中可以看到,函数调用不需要括号,不过如果你想的话,可以加括号。

say substr($sudoku, $line-number * 9, 9);

或甚至:

say(substr($sudoku, $line-number * 9, 9));

3.1. 让数独变得可玩

就像我们现在的脚本输出一样,即使你把它印在纸上,也不能玩出数独的结果。所有这些讨厌的零都会妨碍你真正输入你精心推导出来的数字!

所以,让我们把每个0换成空白,这样你就可以解开谜题了。

# file sudoku.p6
use v6;
my $sudoku = '000000075000080094000500600010000200000900057006003040001000023080000006063240000';
$sudoku = $sudoku.trans('0' => ' ');
for 0..8 -> $line-number {
    say substr $sudoku, $line-number * 9, 9;
}

trans[5] 是 Str 类中的一个方法,它的参数是一个 Pair[6]。创建一个 Pair 的方法是 Pair.new('0', ' '),但是因为它很常用,所以有一个快捷方式,就是用胖箭头形式,。方法 trans 将每一次出现的 pair 的键替换成 pair 的值,然后返回生成的字符串。

说到快捷方式,你还可以把 $sudoku.trans(…​) 缩短为 $sudoku.=$sudoku.trans(…​)。这是一个通用的模式,将返回结果的方法变成了 mutator。

用新的字符串替换,结果是可以玩的,但很难看。

$ raku sudoku.p6
       75
     8 94
    5 6
   1  2
    9  57
   6 3 4
   1   23
   8    6
   6324

一点点 ASCII 艺术让它变得可以忍受:

+---+---+---+
|   | 1 |   |
|   |   |79 |
|  9|   |4  |
+---+---+---+
|   | 4 | 5 |
|   |   | 2 |
| 3 | 29|18 |
+---+---+---+
|  4| 87|2  |
|  7| 2 |95 |
| 5 | 3 | 8 |
+---+---+---+

为了得到垂直的分割线,我们需要将这些线细分为更小的块。而既然我们已经有了一次将字符串分成固定大小的小字符串的机会,那么我们就需要将其封装成一个函数:

sub chunks(Str $s, Int $chars) {
    gather loop (my $idx = 0; $idx < $s.chars; $idx += $chars) {
        take substr($s, $idx, $chars); }
    }

for chunks($sudoku, 9) -> $line {
    say chunks($line, 3).join('|');
}

输出为:

$ raku sudoku.p6
   |   | 75
   | 8 | 94
   |5  |6
 1 |   |2
   |9  | 57
  6|  3| 4
  1|   | 23
  8|   | 6
63 |24 |

但是,它是怎么做到的呢?嗯,sub (SIGNATURE) BLOCK 声明了一个子例程,简称 sub。在这里我声明它要接收两个参数,由于我很容易混淆我调用函数的参数顺序,所以我添加了类型约束,使 Raku 很有可能为我捕捉到这个错误。

gathertake 一起工作是为了创建一个列表, gather 是入口点,每执行一次 take 就会在列表中增加一个元素。所以:

gather {
    take 1;
    take 2;
}

将返回列表 1, 2。这里 gather 作为语句前缀,意味着它收集了循环中的所有取值。

loop 语句的形式为 loop (INITIAL, CONDITION, POST) BLOCK,其工作原理类似于 C 语言和相关语言中的 for 循环。它首先执行 INITIAL,然后在 CONDITION 为真时,先执行 BLOCK,然后执行 POST。

一个子例程返回最后一个表达式[7]的值,这里是上面讨论的 gather loop …​ 构造。

再回到程序中,现在的 for 循环看起来是这样的。

for chunks($sudoku, 9) -> $line {
    say chunks($line, 3).join('|');
}

首先,程序会将整个数独字符串切成 9 个字符的行,然后将每一行切成 3 个字符串,每个字符串的长度为 3 个字符。join[8] 方法把它变成了一个字符串,但在块之间插入了管道符号。

在这一行的开头和结尾还缺少竖直条,这可以通过改变最后一行来很容易地进行硬编码。

say '|', chunks($line, 3).join('|'), '|';

现在输出是:

|   |   | 75|
|   | 8 | 94|
|   |5  |6  |
| 1 |   |2  |
|   |9  | 57|
| 6 | 3 | 4 |
| 1 |   | 23|
| 8 |   | 6 |
| 63|24 |   |

现在, 缺少的只是横线,这并不是太难添加:

my $separator = '+---+---+---+';
my $index = 0;
for chunks($sudoku, 9) -> $line {
    if $index++ %% 3 {
        say $separator;
    }
    say '|', chunks($line, 3).join('|'), '|';
}
say $separator;

这就是:

+---+---+---+
|   |   | 75|
|   | 8 | 94|
|   |5  |6  |
+---+---+---+
| 1 |   |2  |
|   |9  | 57|
|  6|  3| 4 |
+---+---+---+
|  1|   | 23|
| 8 |   |  6|
| 63|24 |   |
+---+---+---+

这里有一些新的方面:if 条件,这在结构上很像 for 循环,还有整除运算符 %% 。在其他编程语言中,你可能知道 % 代表取模,但由于 $number % $divisor == 0 是一个常见的模式,所以 $number %% $divisor 是 Raku 对它的快捷方式。

最后,你可能在 C 语言或 Perl 5 等编程语言中知道 ++ 后缀运算符,它可以将变量增量 1,但返回旧值,所以:

my $x = 0;
say $x++;
say $x;

首先打印0然后打印1。

3.2. 捷径、常量和更多的捷径

Raku是以人类语言为模型的,人类语言内置了某种压缩方案,常用的单词往往很短,常见的结构有快捷方式。 因此,有很多方法可以更简洁地编写代码。 第一个基本上是作弊,因为子块可以用Str类中的内置方法替换,comb:

Raku 是以人类语言为蓝本,人类语言内置了某种压缩方案,其中常用的单词往往比较短,常用的构造物也有快捷键。

因此,有很多方法可以把代码写得更简洁。第一种方法基本上是作弊,因为 sub chunks 可以用 Str 类中的内置方法 `comb`[9] 代替:

# file sudoku.p6
use v6;

my $sudoku = '000000075000080094000500600010000200000900057006003040001000023080000006063240000';
$sudoku = $sudoku.trans('0' => ' ');
my $separator = '+---+---+---+';
my $index = 0;
for $sudoku.comb(9) -> $line {
    if $index++ %% 3 {
        say $separator;
    }
    say '|', $line.comb(3).join('|'), '|';
}
say $separator;

if 条件可以作为语句后缀应用:

say $separator if $index++ %% 3;

除了初始化之外,变量 $index 只使用一次,所以不需要给它起名字。是的,Raku 有匿名变量。

my $separator = '+---+---+---+'; for $sudoku.comb(9) -> $line {
say $separator if $++ %% 3;
    say '|', $line.comb(3).join('|'), '|';
}
say $separator;

由于 $separator 是常量,我们可以将它声明为:

如果你想降低线路噪声因子,你也可以放弃这个sigil,所以恒定的separator ='…​'。 最后,有一个带参数的方法调用的另一种语法:而不是$ obj.method(args),你可以说$ obj.method:args,它将我们带到了小型数独格式化程序的惯用形式:

因为 $separator 是一个常数,所以我们可以把它声明为一个常数。

如果你想降低行噪声系数,也可以放弃 sigil,所以常量 separator='…​'

最后,对于带参数的方法调用,还有另一种语法:你可以使用 $obj.method: args 代替 $obj.method(args),这就给我们引出了成语形式的数独格式化。

# file sudoku.p6
use v6;

my $sudoku = '000000075000080094000500600010000200000900057006003040 001000023080000006063240000';
$sudoku = $sudoku.trans: '0' => ' ';

constant separator = '+---+---+---+';
for $sudoku.comb(9) -> $line {
    say separator if $++ %% 3;
    say '|', $line.comb(3).join('|'), '|';
}
say separator;

这些对 Raku 代码的修改,输出的内容没有变化。

3.3. IO 和其它悲剧

一个实用的脚本不会把输入作为硬编码的字符串字段,而是从命令行、标准输入或文件中读取。

如果你想从命令行读取数独,你可以声明一个叫 MAIN 的子程序,它可以获取所有传入的命令行参数。

# file sudoku.p6
use v6;

constant separator = '+---+---+---+';

sub MAIN($sudoku) {
    my $substituted = $sudoku.trans: '0' => ' ';

    for $substituted.comb(9) -> $line {
        say separator if $++ %% 3;
        say '|', $line.comb(3).join('|'), '|';
    }
    say separator;
}

这就是它如何被调用的:

$ raku-m sudoku-format-08.p6 0000000750000800940005006000100002000009 00057006003040001000023080000006063240000
+---+---+---+
|   |   | 75|
|   | 8 | 94|
|   |5  |6  |
+---+---+---+
| 1 |   |2  |
|   |9  | 57|
|  6|  3| 4 |
+---+---+---+
|  1|   | 23|
| 8 |   |  6|
| 63|24 |   |
+---+---+---+

而且,如果你用错了,甚至可以免费得到一个使用信息,比如省略了参数,就可以得到一个使用信息:

$ raku-m sudoku.p6
Usage:
  sudoku.p6 <sudoku>

你可能已经注意到,最后一个例子中的替换数独字符串使用了一个单独的变量。这是因为函数参数(签名中声明的变量)默认是只读的。与其创建一个新的变量,我还可以写成 sub MAIN($sudoku is copy) { …​}.

经典的 UNIX 程序,如 cat 和 wc,都遵循了从命令行中给出的文件名中读取输入的惯例,如果命令行中没有给出文件名,则从标准输入中读取。

如果你想让你的程序遵循这个惯例, lines() 提供了一个从这两个来源中的任何一个来源读取行的流。

# file sudoku.p6
use v6;

constant separator = '+---+---+---+';

for lines() -> $sudoku {
    my $substituted = $sudoku.trans: '0' => ' ';

    for $substituted.comb(9) -> $line {
        say separator if $++ %% 3;
        say '|', $line.comb(3).join('|'), '|';
    }
    say separator;
}

3.4. 获得创造性

你不会从书本上学习一门编程语言,你必须实际使用它,修修补补它。如果你想在前面讨论的例子基础上进行扩展,我鼓励你尝试用不同的输出格式来制作数独。

SVG[10] 是一种基于文本的矢量图形格式,它提供了渲染数独所需的所有原语:矩形、线条、文本等等。如果你想用较少的精力获得相对较好的输出,你可以使用它。

这是一个数独的 SVG 文件的粗略骨架。

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/ Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="304" height="304" version="1.1" xmlns="http://www.w3.org/2000/svg">
<line x1="0" x2="300" y1="33.3333" y2="33.3333" style="stroke:grey" />
<line x1="0" x2="300" y1="66.6667" y2="66.6667" style="stroke:grey" />
<line x1="0" x2="303" y1="100" y2="100" style="stroke:black; stroke-width:2"/>
<line x1="0" x2="300" y1="133.333" y2="133.333" style="stroke:grey" />
    <!-- more horizontal lines here -->
<line y1="0" y2="300" x1="33.3333" x2="33.3333" style="stroke:grey" />
<!-- more vertical lines here -->
<text x="43.7333" y="124.5"> 1 </text>
<text x="43.7333" y="257.833"> 8 </text>
<!-- more cells go here -->
<rect width="304" height="304" style="fill:none;stroke-width: 1;stroke:black;stroke-width:6"/>
</svg>

如果你有 Firefox 或 Chrome 浏览器或专用的矢量图形程序如 Inkscape[11],你可以用它来打开 SVG 文件(图3-2)。

3.5. 总结

第一个 Raku 的例子介绍了字素、变量和控制流。

你也看到了基本的输入和输出原语,以及 MAIN 子程序,它可以让你轻松地接受命令行参数。

4. 日期时间转换命令行

我偶尔会在工作中使用数据库,它将日期和时间存储为 UNIX 时间戳,也就是 1970-01-01 午夜后的秒数。与数据库的原作者和周围的代码不同,我无法在脑子里进行 UNIX 时间戳和人类可读的日期格式之间的转换,所以我为此写了一些工具。

我们这里的目标是写一个小工具,在 UNIX 时间戳和日期/时间之间来回转换。

$ autotime 2015-12-24
1450915200
$ autotime 2015-12-24 11:23:00
1450956180
$ autotime 1450915200
2015-12-24
$ autotime 1450956180
2015-12-24 11:23:00

4.1. 使用库

日期和时间算术是很难搞好的,同时又很无聊,所以我很乐意把这部分工作交给库来做。

Raku 的核心库中包含了 DateTime[12] (有点受 Perl 5 的同名模块的启发)和 Date[13] (主要是从 Perl 5 的 Date:::Simple 模块中偷来的)。这两个模块将处理实际的转换。我们的重点将是为转换的输入和输出创建一个友好的用户体验。

对于从 UNIX 时间戳到日期或时间的转换,DateTime.new 构造函数就派上了用场。它有一个变体,可以接受一个整数作为 UNIX 时间戳。

$ raku -e "say DateTime.new(1480915200)"
2016-12-05T05:20:00Z

看来我们这个方向就快结束了,对吧?

sub MAIN (Int $timestamp) {
    say DateTime.new($timestamp)
}

我们来运行它:

$ autotime 1450915200
Invalid DateTime string '1450915200'; use an ISO 8601 timestamp (yyyy-mm-ddThh:mm:ssZ or yyyy-mm-ddThh:mm:ss+01:00) instead
  in sub MAIN at autotime line 2
  in block <unit> at autotime line 2

哦,不对,怎么了?似乎 DateTime 构造函数把参数看作是一个字符串,尽管 sub MAIN 的参数声明为 Int,怎么会这样呢?让我们来补充一些调试输出:

sub MAIN(Int $timestamp) {
    say $timestamp.^name;
    say DateTime.new($timestamp)
}

现在用和之前一样的调用运行它,在错误之前多了一行输出:

IntStr

$thing.ˆname 是对 $thing 的元类的一个方法的调用,name 请求它的名称(换句话说,就是类的名称)。IntStr[14]是 Int 和 Str 的一个子类,这就是为什么 DateTime 构造函数合法地认为它是 Str 的原因。在命令行参数传递给 MAIN 之前,解析命令行参数的机制是将命令行中的字符串转换为 IntStr 而不是 Str,以避免在我们想把它当作字符串的时候丢失信息。

长话短说,我们可以通过加一个 + 前缀来强行将参数转化为"实数"整数,这是一般的数值转换机制。

sub MAIN(Int $timestamp) {
    say DateTime.new(+$timestamp)
}

快速测试后发现,现在可以用了:

$ ./autotime-01.p6 1450915200
2015-12-24T00:00:00Z

输出的是 ISO 8601 时间戳格式[15],这可能不是最容易看懂的。对于一个日期(时、分、秒为零时),我们真的只需要日期。

sub MAIN(Int $timestamp) {
    my $dt = DateTime.new(+$timestamp);
    if $dt.hour == 0 && $dt.minute == 0 && $dt.second == 0 {
        say $dt.Date;
    }
    else {
        say $dt;
    }
}

这样看起来更好一点:

$ ./autotime 1450915200
2015-12-24

但是,这个条件就有点笨拙了。真的,写三个和0进行比较的表达式吗?Raku 有一个很好的小功能,可以让你写得更紧凑。

if all($dt.hour, $dt.minute, $dt.second) == 0 {
    say $dt.Date;
}

all(…​) 创建了一个 Junction[16], 一个由其他几个值组成的复合值,它也存储了一个逻辑模式。当你将一个 Junction 与另一个值进行比较时,这个比较会自动应用到 Junction 中的所有值上。if 语句在布尔语境中对 Junction 进行求值,在这种情况下,只有当所有的比较都返回 True 时,才会返回 True

其他类型的 Junction 还有:anyallnoneone。考虑到0是唯一一个在布尔语境中为 false 的整数,我们甚至可以把前面的语句写成:

if none($dt.hour, $dt.minute, $dt.second) {
    say $dt.Date;
}

很好,对吗?

但你不一定需要花哨的语言构造来写出简洁的程序。在这种情况下,从一个略微不同的角度来处理这个问题,会产生更短更清晰的代码。如果 DateTime 对象在不丢失信息的情况下往返转换到 Date 和返回到 DateTime,那它显然是一个 Date:

if $dt.Date.DateTime == $dt {
    say $dt.Date;
}
else {
    say $dt;
}

4.2. DateTime 格式化

对于一个没有解析为一整天的时间戳,我们的脚本的输出目前看起来像这样:

2015-12-24T00:00:01Z

其中的 "Z" 表示 UTC 或 "Zulu" 时区。相反,我希望它是:

2015-12-24 00:00:01

DateTime 类支持自定义格式化,所以我们来写一个:

sub MAIN(Int $timestamp) {
    my $dt = DateTime.new(+$timestamp, formatter => sub ($o) {
            sprintf '%04d-%02d-%02d %02d:%02d:%02d',
                    $o.year, $o.month,  $o.day,
                    $o.hour, $o.minute, $o.second,
    });
    if $dt.Date.DateTime == $dt {
        say $dt.Date;
    }
    else {
        say $dt.Str;
    }
}

现在输出看起来更好看了:

./autotime 1450915201
2015-12-24 00:00:01

如果你想以不同的格式输出,比如 DD.MM.YYY,你可以用你自己的格式字符串替换格式字符串。

语法 formatter ⇒ …​ 在参数的上下文中表示一个命名的参数,这意味着名称而不是参数列表中的位置决定了要绑定到哪个参数。如果有一堆参数的话,这个很方便。

我不喜欢这样的代码了,因为 formatter 是在 DateTime.new(…​) 调用中内联的,我觉得不清楚。

让我们把这个作为一个单独的例程吧:

sub MAIN(Int $timestamp) {
    sub formatter($o) {
        sprintf '%04d-%02d-%02d %02d:%02d:%02d',
                $o.year, $o.month,  $o.day,
                $o.hour, $o.minute, $o.second,
    }
    my $dt = DateTime.new(+$timestamp, formatter => &formatter);
    if $dt.Date.DateTime == $dt {
        say $dt.Date;
    }
    else {
        say $dt.Str;
    }
}

是的,你可以把一个子例程声明放在另一个子例程声明的正文中;子例程只是一个普通的词法符号,就像用 my 声明的变量一样。

在这行中,my $dt = DateTime.new(+$timestamp, formatter ⇒ &formatter);,语法中的 &formatter 指的是将子例程作为一个对象,而不是调用它。

在 Raku 中,formatter ⇒ &formatter 有一个快捷方式: :&formatter。一般来说,如果你想填充一个命名参数,其名称是变量的名称,而其值是变量的值,你可以通过写 :$variable 来创建它。而引申一下,:thingthing ⇒ True 的简写。

4.3. 寻找其他途径

现在,从时间戳到日期和时间的转换工作已经很顺利了,我们来看看另一个方向。我们的小工具需要对输入进行解析,并决定输入是时间戳还是日期,以及可选的时间。

无聊的方法就是使用条件转换。

sub MAIN($input) {
    if $input ~~ / ^ \d+ $ / {
        # convert from timestamp to date/datetime
    }
    else {
        # convert from date to timestamp

    }
}

但我讨厌无聊,所以我想看看更刺激(和可扩展)的方法。

Raku 支持多重分派。这意味着你可以有多个名字相同,但签名不同的子程序。而 Raku 会自动决定调用哪个子程序。你必须通过写 multi sub 而不是 sub 来显式地启用这个功能,这样 Raku 就能为你捕获意外的重声明。

multi sub MAIN(Int $timestamp) {
    sub formatter($o) {
        sprintf '%04d-%02d-%02d %02d:%02d:%02d',
                $o.year, $o.month,  $o.day,
                $o.hour, $o.minute, $o.second,
    }
    my $dt = DateTime.new(+$timestamp, :&formatter);
    if $dt.Date.DateTime == $dt {
        say $dt.Date;
    }
    else {
        say $dt.Str;
    }
}

multi sub MAIN(Str $date) {
    say Date.new($date).DateTime.posix
}

让我们来看看它的表现吧:

$ ./autotime 2015-12-24
1450915200
$ ./autotime 1450915200
Ambiguous call to 'MAIN'; these signatures all match:
:(Int $timestamp)
:(Str $date)
  in block <unit> at ./autotime line 17

不太符合我的设想。问题又来了,整数参数被自动转换为 IntStr,而 Int 和 Str multi(或候选)都接受这个参数。

要避免这个错误,最简单的方法是缩小 Str 候选者接受的字符串种类。经典的方法是使用一个可以大致验证传入参数的 regex。

multi sub MAIN(Str $date where /^ \d+ \- \d+ \- \d+ $ /) {
    say Date.new($date).DateTime.posix
}

事实上,它确实有效,但为什么要重复 Date.new 已经有的验证日期字符串的逻辑?如果你传递一个看起来不像日期的字符串参数,你会得到这样的错误。

Invalid Date string 'foobar'; use yyyy-mm-dd instead

我们可以用这种行为来约束 MAIN multi 候选者的字符串参数:

multi sub MAIN(Str $date where { try Date.new($_) }) {
    say Date.new($date).DateTime.posix
}

这里额外的 try 是因为 where 后面的子类型约束不应该抛出一个异常,只是返回一个假值。

而现在它的工作原理和预期的一样:

$ ./autotime 2015-12-24;
1450915200
$ ./autotime 1450915200
2015-12-24

4.4. 处理时间

唯一需要实现的功能就是将日期和时间转换为时间戳。换句话说,我们要处理像 autotime 2015-12-24 11:23:00 这样的调用:

multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
    my $d = Date.new($date);
    if $time {
        my ( $hour, $minute, $second ) = $time.split(':');
        say DateTime.new(date => $d, :$hour, :$minute, :$second).posix;
    }
    else {
        say $d.DateTime.posix;
    }
}

因为后面的 ? 的存在, 新的第二个参数是可选的。我们在冒号上拆分时间字符串,得到小时、分钟和秒。在写这段代码的时候,我的第一直觉是使用更短的变量名, my ($h, $m, $s) = $time.split(':'), 但这样对 DateTime 构造函数的调用就会是这样的:

DateTime.new(date => $d, hour => $h, minute => $m, second => $s);

所以,构造函数的命名参数让我选择了更多的自解释变量名。

所以,这样就成功了:

./autotime 2015-12-24 11:23:00
1450956180

而且我们还可以检测它的往返:

$ ./autotime 1450956180
2015-12-24 11:23:00

4.5. 系好你的安全带

现在程序的功能已经完成了,我们应该努力去掉一些杂乱无章的东西,再去探索一下 Raku 的一些很棒的功能。

我想利用的第一个特性是隐式变量或主题的特性。快速演示一下:

for 1..3 {
    .say
}

产生如下输出:

1
2
3

没有显式的迭代变量,所以 Raku 隐式地将循环的当前值绑定到一个叫 $_ 的变量上。方法调用 .say$_.say 的一个快捷方式。而且由于有一个子例程在同一个变量上调用了六个方法,所以在这里使用 $_ 是一个很好的视觉优化:

sub formatter($_) {
    sprintf '%04d-%02d-%02d %02d:%02d:%02d',
            .year, .month,  .day,
            .hour, .minute, .second,
}

如果你想在一个词法范围内设置 $_,而不需要借助于函数定义,你可以使用 given VALUE BLOCK 构造:

given DateTime.new(+$timestamp, :&formatter) {
    if .Date.DateTime == $_ {
        say .Date;
    }
    else {
        .say;
    }
}

而且 Raku 还提供了一个对 $_ 变量的条件语句的快捷方式,可以作为一个通用的 switch 语句来使用:

given DateTime.new(+$timestamp, :&formatter) {
    when .Date.DateTime == $_ { say .Date }
    default { .say }
}

如果你有一个只读变量或参数,你可以不用 $ sigil, 虽然你必须在声明时使用反斜线:

multi sub MAIN(Int \timestamp) {
    ...
    given DateTime.new(+timestamp, :&formatter) {
    ...
    }
}

所以现在完整的代码看起来是这样的:

multi sub MAIN(Int \timestamp) {
    sub formatter($_) {
        sprintf '%04d-%02d-%02d %02d:%02d:%02d',
                .year, .month,  .day,
                .hour, .minute, .second,
    }
    given DateTime.new(+timestamp, :&formatter) {
        when .Date.DateTime == $_ { say .Date }
        default { .say }
    }
}

multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
    my $d = Date.new($date);
    if $time {
        my ( $hour, $minute, $second ) = $time.split(':');
        say DateTime.new(date => $d, :$hour, :$minute, :$second).posix;
    }
    else {
        say $d.DateTime.posix;
    }
}

4.6. MAIN 魔法

如果我们在调用 sub MAIN 的时候,如果我们用不符合任何 multi 参数的参数来调用它,那么调用 sub MAIN 的魔力也会给我们提供一个自动的使用信息;比如说调用时不提供参数:

$ ./autotime
Usage:
  ./autotime <timestamp>
  ./autotime <date> [<time>]

我们可以通过在 MAIN subs 之前添加语义注释,为这些用法信息添加一个简短的描述:

#| Convert timestamp to ISO date
multi sub MAIN(Int \timestamp) {
    ...
}

#| Convert ISO date to timestamp
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
    ...
}

现在用法信息变为了:

$ ./autotime
Usage:
  ./autotime <timestamp> -- Convert timestamp to ISO date
  ./autotime <date> [<time>] -- Convert ISO date to timestamp

4.7. 自动化测试

我们已经看到一些代码经过了几次重构的迭代。没有自动测试的重构往往会让我感到不安,所以我其实有一个小的 shell 脚本,用几个不同的参数组合来调用正在开发的脚本,并将其与预期的结果进行比较。

现在让我们来看看在 Raku 本身中编写测试代码的方法。

在 Perl 社区中,将逻辑移到模块中,以便于使用外部测试脚本进行测试是很常见的。在 Raku 中,这仍然很常见,但对于像这样的小工具,我更喜欢用一个包含代码和测试的单一文件,并通过单独的测试命令来运行测试。

为了让测试更容易,我们先把 I/O 和应用逻辑分开:

sub from-timestamp(Int \timestamp) {
    sub formatter($_) {
        sprintf '%04d-%02d-%02d %02d:%02d:%02d',
            .year, .month, .day,
            .hour, .minute, .second,
    }
    given DateTime.new(+timestamp, :&formatter) {
        when .Date.DateTime == $_ { return .Date }
        default { return $_ }
    }
}

sub from-date-string(Str $date, Str $time?) {
    my $d = Date.new($date);
    if $time {
        my ( $hour, $minute, $second ) = $time.split(':');
        return DateTime.new(date => $d, :$hour, :$minute, :$second);
    } else {
        return $d.DateTime;
    }
}

#| Convert timestamp to ISO date
multi sub MAIN(Int \timestamp) {
    say from-timestamp(+timestamp);
}

#| Convert ISO date to timestamp
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
    say from-date-string($date, $time).posix;
}

这个小的重构没了,我们来补充一些测试:

#| Run internal tests
multi sub MAIN('test') {
    use Test;
    plan 4;
    is-deeply from-timestamp(1450915200), Date.new('2015-12-24'), 'Timestamp to Date';

    my $dt = from-timestamp(1450915201);
    is $dt, "2015-12-24 00:00:01", 'Timestamp to DateTime with string formatting';
    is from-date-string('2015-12-24').posix, 1450915200, 'from-date-string, one argument';
    is from-date-string('2015-12-24', '00:00:01').posix, 1450915201, 'from-date-string, two arguments';
}

你可以运行它:

./autotime test
1..4
ok 1 - Timestamp to Date
ok 2 - Timestamp to DateTime with string formatting
ok 3 - from-date-string, one argument
ok 4 - from-date-string, two arguments

输出格式是 Test Anything Protocol (TAP)[17],这是 Perl 社区中的实际标准[18], 但现在其他社区也在使用。对于较大的输出字符串,通过测试套件来运行测试是个好主意。对于我们的四行测试输出来说,这还不是必要的,但如果你想这样做,你可以使用 Perl 5 自带的 prove 程序。

$ prove -e "" "./autotime test"
./autotime-tested.p6 test .. ok
All tests successful.
Files=1, Tests=4, 0 wallclock secs ( 0.02 usr 0.01 sys + 0.23 cusr 0.02 csys = 0.28 CPU)
Result: PASS

在终端中,这甚至会将 "All tests successful" 输出的颜色标注为绿色,这样更容易被发现。而测试失败则用红色标记。

测试是如何工作的?第一行代码使用了一个我们还没有看到的新功能。

multi sub MAIN('test') {

那是什么,在子程序签名中用字面值代替参数?

没错,就是这个。它是下面这段代码的简写:

multi sub MAIN(Str $anon where {$anon eq 'test'}) {

除了它没有声明变量 $anon。所以它是一个 multi 候选项,只能通过提供字符串 'test' 作为唯一参数来调用。

下一行,use Test;,加载 Raku 附带的测试模块[19]。它还会将 Test 默认导出的所有符号导入到当前的词法作用域中。这包括后面要用到的函数 planis、和 is-deeply

plan 4; 声明我们要运行四个测试。这对于检测测试代码中的无计划、提前退出,或者测试代码中的循环逻辑错误导致运行的测试量比计划中的少,是很有用的。如果你不忍心提前计算测试,可以省去 plan 调用,而是在测试完成后再调用 done-testing

is-deeplyis 都是把要测试的值作为第一个参数,预期值作为第二个参数,第三个参数是可选的测试标签字符串。不同的是, is 是将前两个参数作为字符串进行比较,而 is-deeply 使用的是使用 eqv 操作符[20]的深度相等比较逻辑。

还有更多的测试函数可以使用,比如 ok(),它对真参数成功,而 nok(),它对假参数的期望值是假的。你也可以用 subtest 来嵌套测试:

#| Run internal tests
multi sub MAIN('test') {
    use Test; plan 2;

    subtest 'timestamp', {
        plan 2;
        is-deeply from-timestamp(1450915200), Date.new('2015-12-24'), 'Date';;

        my $dt = from-timestamp(1450915201);
        is $dt, "2015-12-24 00:00:01", 'DateTime with string formatting';
    };

    subtest 'from-date-string', {
        plan 2;
        is from-date-string('2015-12-24').posix, 1450915200, 'one argument';
        is from-date-string('2015-12-24', '00:00:01').posix, 1450915201, 'two arguments';
    };
}

每一次对 subtest 的调用都算作一次对外部测试运行的测试,所以 plan 4; 变成了 plan 2;subtest 测试的调用本身就有一个测试标签,然后在一个 subtest 测试内部,你又有一个 plan,并调用测试函数,如下图所示。这在编写自定义测试函数时非常有用,因为它可以执行不同数量的单独测试。

嵌套测试的输出看起来是这样的:

1..2
    1..2
    ok 1 - Date
    ok 2 - DateTime with string formatting
ok 1 - timestamp
    1..2
    ok 1 - one argument
    ok 2 - two arguments
ok 2 - from-date-string

现在,测试套件只报告两个顶级测试的运行(和通过的)测试数量。是的,你可以在子测试中嵌套子测试,如果你真的想这么做的话。

4.8. 总结

我们已经看到了一些 Date 和 DateTime 的算术,但最精彩的部分是多重分派、命名参数、带 where 子句的子类型约束、 given/when 和隐式的 $_ 变量,以及当涉及到 MAIN 子句时,一些严肃的魔法。

最后,我们学习了使用 Raku 自带的 Test 模块进行自动测试。

花点时间阅读一下你到目前为止使用过的文档。看看你是否能在你的代码中找到一个地方把 if 换成 where 语句[21]。一定要利用好 where 引入的词法作用域。

5. 测试 say

在上一章中,我修改了一些代码,让它不产生输出,而是在 MAIN 子例程中做了输出,方便的是没有测试。

改变代码以使其更容易测试是一种合理的做法。但如果你确实要测试通过调用 say 来产生输出的代码,有一个小技巧你可以使用:say 在一个文件句柄上工作,你可以把默认的文件句柄换掉,这个句柄是连接到标准输出的。你可以在它的位置上放一个假文件句柄来代替默认的文件句柄,捕捉向它发出的低级命令,并记录下来,供测试用。

这里有一个现成的模块,IO:::String[22],但为了学习的缘故,我们来看看它是如何工作的。

# function to be tested
sub doublespeak($x) {
    say $x ~ $x;
}

use Test;
plan 1;

my class OutputCapture {
    has @!lines;
    method print(\s) {
        @!lines.push(s);
    }
    method captured() {
        @!lines.join;
    }
}

my $output = do {
    my $*OUT = OutputCapture.new;
    doublespeak(42);
    $*OUT.captured;
};
is $output, "4242\n", 'doublespeak works';

这段代码的第一部分是我们要测试的函数 - sub doublespeak。它用 字符串连接运算符将其参数与自身进行连接。结果被传递给 say

在引擎盖下,say 会做一些格式化处理,然后查找变量 $OUT。sigil 后面的 标志着它是一个动态变量。对动态变量的查找会通过调用栈,在每个栈帧中查找变量的声明,并在每个栈帧中选择第一个找到的变量。

通常情况下,$*OUT 包含一个类型为 IO:::Handle`[23] 的对象,但 `say 函数其实并不关心这个,只要能在这个对象上调用 print 方法就可以了。这就是所谓的鸭子类型化:我们其实并不关心对象的类型,只要它能像鸭子一样呱呱叫就可以了。或者在这个例子中,像鸭子一样打印。

然后是测试模块的加载[24],接下来就是声明要运行多少个测试。

use Test;
plan 1;

你可以省略第二行,在测试之后调用 one-testing。但是,如果测试代码本身有可能出现 bug,没有运行它应该运行的测试,那么事先声明一下预期的测试数量是很好的,这样测试模块或者测试线束就可以抓住这样的错误。

接下来的例子是声明一个类型,我们可以用这个类型来模拟 IO::Handle:

my class OutputCapture {
    has @!lines;
    method print(\s) {
        @!lines.append(s);
    }
    method captured() {
        @!lines.join;
    }
}

class 引入了一个类,而 my 前缀使得名称在词法上有了作用域,就像在 my $var 声明中一样。

has @! 行声明了一个属性,也就是说,对于类 OutputCapture 的每个实例来说,它是一个单独存在的变量。! 标志着它是一个属性。我们可以把它省略掉,但把它放在那里意味着当你读取一个大类时,你总是知道这个名字来自哪里。

属性 @! 行是以 @ 开头的,而不是像我们到目前为止看到的其他变量那样以 $ 开头。@ 是数组变量的标志。

符号

类型约束

默认类型

解释

$

Mu

Any

单个值和对象

@

Positional

Array

整数索引的组合

%

Associative

Hash

字符串或对象索引的组合

&

Callable

你可以调用的对象代码

你现在可能会看到一个趋势:变量或属性名称的第一个字符表示它的粗略类型(scalar、数组、& 表示例程,稍后我们会了解到 % 表示散列),如果第二个字符不是字母,则表示它的范围。我们把这个第二个字符称为 twigil。到目前为止,我们已经看到了动态变量的 * 和属性的 !。还有更多:

Twigil

描述

*

动态作用域变量

!

OO 领域的属性

^

隐式位置参数

:

隐式命名参数

?

编译器提供的常量

=

Pod(文档)变量

我们的例子的倒数第二个块是这样的:

my $output = do {
    my $*OUT = OutputCapture.new;
    doublespeak(42);
    $*OUT.captured;
};

do { …​ } 只是执行大括号内的代码,并返回最后一条语句的值。和 Raku 中的所有代码块一样,它也引入了一个新的词法作用域。 新的作用域在下一行中派上了用场,my $*OUT 声明了一个新的动态变量 $*OUT,但是它只在块的作用域中有效。它是用 OutputCapture.new 来初始化的,它是前面声明的类的一个新实例。new 并不是什么神奇的东西,它只是从 OutputCapture 的超级类中继承过来的。我们没有声明一个,但默认情况下,类会得到类型为 Any[25] 的超级类,它提供了(除此之外)方法 new 作为构造函数。

调用 doublepeak 调用 saysay 又会调用 $*OUT.print。而由于 $*OUT 是这个动态作用域中的 OutputCapture 的一个实例,所以传递给 say 的字符串会落在 OutputCapture 的属性 @!line 中,在这里 $*OUT.captured 可以再次访问它。

最后一行:

is $output, "4242\n", 'doublespeak works';

调用测试模块中的 is 函数。

在良好的测试传统中,这将产生 TAP 格式的输出:

1..1
ok 1 - doublespeak works

5.1. 总结

我们已经看到,say() 使用一个动态作用域变量 $*OUT 作为它的输出文件句柄。为了测试的目的,我们可以用一个我们自己制作的对象来代替,这让我们偶然发现了 Raku 中类是如何编写的。

6. Silent-Cron, 一个 Cron 包裹器

在 Linux 和类似 UNIX 的系统中,一个名为 cron[26] 的程序会在后台定期执行用户定义的命令。它用于系统维护任务,如刷新或删除缓存、旋转和删除旧的日志文件等。

如果这样的命令产生任何输出,cron 通常会发送一封包含输出的电子邮件,以便管理员查看,判断是否需要采取一些行动。

但是,并不是所有的命令行程序都是为使用 cron 编写的。例如,它们可能在成功执行时也会产生输出,并通过非零的退出代码表示失败。或者它们可能会挂起,或者其他方面的错误行为。

为了处理这类命令,我们将开发一个名为 silent-cron 的小程序,它可以封装这类命令,并在退出代码为0时抑制输出。它还允许你指定一个超时,如果时间过长,就会杀死被包装的程序。

$ silent-cron -- command-that-might-fail args
$ silent-cron --timeout=5 -- command-that-might-hang

6.1. 异步运行命令

当你想运行外部命令时,Raku 基本上给你两个选择:run[27],是一个简单的同步接口;Proc::Async[28],是一个异步的、稍微复杂一点的选项。尽管我们在第一次迭代中会省略超时,但我们需要注意的是,在异步接口中实现超时是比较容易的,所以我们将使用这个接口。

sub MAIN(*@cmd) {
    my $proc = Proc::Async.new(|@cmd);
    my $collector = Channel.new;
    for $proc.stdout, $proc.stderr -> $supply {
        $supply.tap: { $collector.send($_) }
    }
    my $result = $proc.start.result;
    $collector.close;
    my $output = $collector.list.join;
    my $exitcode = $result.exitcode;
    if $exitcode != 0 {
        say "Program @cmd[] exited with code $exitcode";
        print "Output:\n", $output if $output;
    }
    exit $exitcode;
}

这里面有一大块新的功能和概念,让我们一点一点地去看一下代码。

sub MAIN(*@cmd) {

首先你应该注意的是 @cmd。变量前面的 表示的是一个 slurpy 参数[29],它之所以这样命名,是因为它可以吞噬任何数量的参数。这个 * 只需要在参数声明中使用。

所以 *@cmd 收集了所有的命令行参数,在数组变量 @cmd 中,第一个元素是要执行的命令,其他元素是传递给这个命令的参数。

my $proc = Proc::Async.new(|@cmd);

下一行用传入的命令创建了一个新的 Proc::Async 实例,但还没有运行任何东西。Proc::Async.new 并不期望我们传送一个数组,但它希望我们传递任意数量的值作为参数。因此,我们在 @cmd 之前使用 | 竖条[30]将数组扁平化,这样我们就可以向 Proc::Async.new 发送多个值,而不是一个数组值。

对于我们的程序,我们需要捕获来自 $proc 的所有输出;因此我们捕获 STDOUT 和 STDERR 流的输出(Linux上的文件句柄1和2),并将其合并为一个字符串。在异步 API 中,STDOUT 和 STDERR 被建模为 Supply 类型的对象[31] ,因此是事件流。由于 Supplies 可以并行地发出事件,因此我们需要一个线程安全的数据结构来收集结果,而 Raku 为此提供了一个 Channel。

my $collector = Channel.new;

为了实际得到程序的输出,我们需要切入 STDOUT 和 STDERR 流:

for $proc.stdout, $proc.stderr -> $supply {
    $supply.tap: { $collector.send($_) }
}

每个 $supply 都会对它接收到的每一个字符串执行块 { $collector.send($_) }。这个字符串可以是一个字符、一行,如果流是缓冲的,也可以是更大的东西。我们所要做的就是通过 send 方法将字符串放入通道 $collector 中。

请注意,前面的代码相当于:

$proc.stdout.tap: { $collector.send($_) }
$proc.stderr.tap: { $collector.send($_) }

在运行一个简单的脚本时,你经常会看到正常输出和错误输出一起打印到终端上。我们的代码是将 STDOUT 和 STDERR 输出交错到 $collector 中,其方式基本相同。

现在这些数据流已经被分接出来,并连接到我们的收集器,我们可以启动程序,等待程序完成:

my $result = $proc.start.result;

Proc::Async.start 执行外部进程并返回一个 Promise[32] Promise 包装了一段可能在另一个线程上运行的代码,有一个状态(PlannedKeptBroken),一旦完成,就会有一个结果。访问结果会自动等待被包装的代码完成。这里的代码是运行外部程序的代码,结果是 Proc[33] 类型的对象(恰好和同步接口中的 run() 函数一样)。

在这一行之后,我们可以确定外部命令已经终止,因此不会再有 $proc.stdout$proc.stderr 的输出。因此,我们可以安全地关闭通道,并通过 Channel.list 访问它的所有元素:

$collector.close;
my $output = $collector.list.join;

最后,是检查外部命令是否成功的时候了。

检查其退出代码 - 并以命令的退出代码退出包装程序:

my $exitcode = $result.exitcode;
if $exitcode != 0 {
    say "Program @cmd[] exited with code $exitcode";
    print "Output:\n", $output if $output;
}
exit $exitcode;

在输出字符串内:

say "Program @cmd[] exited with code $exitcode";

变量 $exitcode 被内插,也就是说,它的名字在运行时被替换为其值。这在双引号字符串"…​ "中会发生,但在单引号字符串'…​'中不会。只有标量变量在"…​ "中才会被插值;其他变量(数组、哈希值、代码对象)只有在后面有某种括号结构时才会被插值。这就是为什么 @cmd 后面有 [] 的原因,我们称其为 Zen slice。返回多个值的数组或哈希索引一般称为切片;例如,@cmd[0,1] 返回前两个值。将索引留空则返回整个数组。

另一种实现插值的方法是在变量上添加一个以括号结尾的方法调用,所以也可以写成:

say "Program @cmd.join(' ') exited with code $exitcode";

有关"…​ "插值的更多深入信息,请参见文档。[34]

6.2. 实现超时

在 Raku 中实现超时的常用方法是使用 Promise.anyof 组合器和定时器:

sub MAIN(*@cmd, :$timeout) {
    my $proc = Proc::Async.new(|@cmd);
    my $collector = Channel.new;
    for $proc.stdout, $proc.stderr -> $supply {
        $supply.tap: { $collector.send($_) }
    }

    my $promise = $proc.start;
    my $waitfor = $promise;
    $waitfor = Promise.anyof(Promise.in($timeout), $promise)
        if $timeout;
    await $waitfor;
}

$proc 的初始化没有改变。但我们不访问 $proc.start.result,而是存储从 $proc.start 返回的承诺。如果用户指定了超时,我们运行这段代码:

$waitfor = Promise.anyof(Promise.in($timeout), $promise)

Promise.in($seconds) 返回一个将在 $seconds 秒内实现的承诺,它基本上和 start { sleep $seconds } 是一样的,但是调度器可以更聪明一点,不要只分配一整条线程来休眠。

Promise.anyof($p1,$p2) 返回一个承诺,只要其中一个参数(也应该是承诺)被实现,就会返回一个承诺。所以,我们要么等待外部程序完成,要么等待 sleep 承诺实现。

使用 await $waitfor; 程序等待承诺被实现(或被打破)。在这种情况下,我们不能像以前那样简单地访问 $promise.result,因为在超时的情况下,$promise(即外部程序的承诺)可能无法实现。所以我们必须先检查承诺的状态,只有这样我们才能安全地访问 $promise.result:

if !$timeout || $promise.status ~~ Kept {
    my $exitcode = $promise.result.exitcode;
    my $output = $collector.list.join;

    if $exitcode != 0 {
        say "Program @cmd[] exited with code $exitcode";
        print "Output:\n", $output if $output;
    }
    exit $exitcode;
}
else {
    ...
}

表达式 $promise.status ~~ Kept 使用智能匹配运算符来检查承诺状态是否与常数 Kept 的状态相同。智能匹配是一个非常通用的运算符,其语义取决于表达式的右侧。对于右侧的数,比较的是数值。对于右侧的类型,则是类型检查。更多内容请参考官方文档。[35]

else { …​ } 分支中,我们需要处理超时的情况。这可能就像打印出一条超时的语句一样简单,当 silent-cron 之后立即退出时,这可能是可以接受的。但是我们可能将来要做更多的事情,所以我们应该杀死外部程序。而如果程序在发出友好的 kill 信号后没有终止,就应该收到一个 kill(9),在 UNIX 系统中,这个 kill(9) 会强行终止程序。

else { $proc.kill;
    say "Program @cmd[] did not finish after $timeout seconds";
    sleep 1 if $promise.status ~~ Planned;
    $proc.kill(9);
    await $promise;
    exit 2;
}

await $promise 返回承诺的结果;这里是一个 Proc 对象。Proc 内置了一个安全特性,如果命令返回的返回值为非零的退出代码,那么在 void 上下文中计算该对象会抛出一个异常。

由于我们在代码中显式处理了非零退出代码,所以我们可以通过将 await 的返回值分配给一个哑变量来抑制这个异常的产生:

my $dummy = await $promise

因为我们不需要这个值,所以我们也可以把它分配给一个匿名变量代替:

$ = await $promise

6.3. 更多关于承诺的信息

如果你在其他语言中处理过并发或并行程序,你可能会遇到线程、锁、mutexes 和其他低级构造。这些东西在 Raku 中也有,但不鼓励直接使用它们。

这些低级原语的问题在于它们不能很好地编译。你可以有两个使用线程的库,它们本身工作得很好,但在同一个程序中组合起来就会导致死锁。或者不同的组件可能会单独启动线程,当几个这样的组件在同一个进程中组合在一起时,可能会导致线程太多,内存消耗大。

Raku 提供了更高级别的原语。你可以使用 start 来异步地运行代码,而不是催生一个线程,由调度器决定在哪个线程上运行。如果有更多的 start 调用请求线程来调度的事情发生了,那么有些线程就会串行运行。

这里有一个非常简单的例子,就是在后台运行计算。

sub count-primes(Int $upto) {
    (1..$upto).grep(&is-prime).elems;
}

my $p = start count-primes 10_000;
say $p.status;
await $p;
say $p.result;

它给出如下输出:

Planned
1229

你可以看到,在启动调用后,主线继续执行,$p 立即有一个值 - 承诺,状态为 Planned

正如我们之前看到的那样,承诺有组合器,anyofallof。你也可以使用 then 方法将动作链接到一个承诺上:

sub count-primes(Int $upto) {
    (1..$upto).grep(&is-prime).elems;
}

my $p1 = start count-primes 10_000;
my $p2 = $p1.then({ say .result });
await $p2;

如果在异步执行代码内部抛出异常,那么承诺的状态就会变成 Broken,调用它的 .result 方法重抛出异常。

为了演示调度器分配任务,让我们考虑一个小的蒙特卡洛模拟来计算 π 的近似值,蒙特卡洛模拟只是一个程序,用随机数来探索一个可能值的空间,得出一个确定性的输出(图6-1)。

在一个正方形中随机放置点时,四分之一圆内的点数与总点数的比值接近 π/4 时

我们生成一对0到1之间的随机数,将其解释为正方形中的点。一个半径为1的四分之一圆的面积为 π/4,因此,如果我们使用足够多的点,那么四分之一圆内随机放置的点与点的总数之比接近 π/4。

sub pi-approx($iterations) {
    my $inside = 0;
    for 1..$iterations {
        my $x = 1.rand;
        my $y = 1.rand;
        $inside++ if $x * $x + $y * $y <= 1;
    }
    return ($inside / $iterations) * 4;
}
my @approximations = (1..1000).map({ start pi-approx(80) });
await @approximations;

say @approximations.map({.result}).sum / @approximations;

该程序异步启动一千次计算,但如果你在运行的时候看一下系统监控工具,你会观察到只有 16 个线程在运行。这个神奇的数字来自于默认的线程调度器,我们可以通过在前面的代码上面提供自己的调度器实例来覆盖它:

my $*SCHEDULER = ThreadPoolScheduler.new(:max_threads(3));

对于像这种蒙特卡洛仿真这样的 CPU 绑定任务,将线程数大致限制在 CPU 内核的数量(可能是虚拟的)上是个好主意;如果很多线程被卡住等待 I/O,那么更高的线程数可以获得更好的性能。

6.4. 可能的扩展

如果你想玩 silent-cron,可以增加一个重试机制。如果一个命令因为外部依赖(如API或NFS共享)而失败,那么这个外部依赖可能需要时间来恢复。因此,你应该添加一个二次幂或指数型的重试机制;也就是说,重试之间的等待时间应该以二次幂(1,2,4,9,16,16,…​)或指数型(1,2,4,8,16,32,…​)的方式增加。

6.5. 重构和自动化测试

在我们在下一章中对 silent-cron 进行更多的扩展之前,是时候重构一下它,并为它写一些测试了。

6.5.1. 重构

在此简单提醒一下,程序是这样的:

sub MAIN(*@cmd, :$timeout) {
    my $proc = Proc::Async.new(|@cmd);
    my $collector = Channel.new;
    for $proc.stdout, $proc.stderr -> $supply {
        $supply.tap: { $collector.send($_) }
    }
    my $promise = $proc.start;
    my $waitfor = $promise;
    $waitfor = Promise.anyof(Promise.in($timeout), $promise)
        if $timeout;
    $ = await $waitfor;
    $collector.close;
    my $output = $collector.list.join;
    if !$timeout || $promise.status ~~ Kept {
        my $exitcode = $promise.result.exitcode;
        if $exitcode != 0 {
            say "Program @cmd[] exited with code $exitcode";
            print "Output:\n", $output if $output;
        }
        exit $exitcode;
    }
    else {
        $proc.kill;
        say "Program @cmd[] did not finish after $timeout seconds";
        sleep 1 if $promise.status ~~ Planned;
        $proc.kill(9);
        $ = await $promise;
        exit 2;
    }
}

这里面有执行外部程序超时的逻辑,也有处理两种可能的结果的逻辑。从可测试性和未来扩展的角度来看,将外部程序的执行情况计入子程序中是有意义的。这段代码的结果不是一个单一的值,我们可能感兴趣的是它产生的输出,退出代码,以及是否超时。

我们可以写一个子例程,返回一个列表或者是这些值的哈希值,但是在这里我选择写一个小类来代替,新的子例程将返回:

class ExecutionResult {
    has Int $.exitcode = -1;
    has Str $.output is required;
    has Bool $.timed-out = False;
    method is-success {
        !$.timed-out && $.exitcode == 0;
    }
}

我们以前见过类,但这个类有一些新的功能。用 .twigil 声明的属性会自动获得一个访问器方法,所以:

has Int $.exitcode;

大致等价于:

has Int $!exitcode;
method exitcode() { $!exitcode }

它允许类的用户从外部访问属性中的值。作为奖励,你还可以从标准构造函数中初始化它,作为一个命名参数,ExecutionResult.new( exitcode ⇒ 42 )。退出代码并不是一个必须的属性,因为我们无法知道一个已经超时的程序的退出代码。所以我们给它一个默认值,如果属性还没有被初始化,我们就给它一个默认值。

输出是一个必填属性,所以我们用 is required 标记为必填属性。这就是一个 trait。trait 是修改其他事物的行为的代码,这里指的是属性的行为。它们会出现在很多地方,例如子程序签名(在参数上复制)、变量声明和类中。如果你试图调用 ExecutionResult.new() 而不指定输出,你会得到这样的错误:

The attribute '$!output' is required, but you did not provide a value for it.

6.5.2. 模拟和测试

现在我们有了一个从假设子例程中返回多个值的方便方法,让我们来看看这个子例程可能是什么样子的:

sub run-with-timeout(@cmd, :$timeout) {
    my $proc = Proc::Async.new(|@cmd);
    my $collector = Channel.new;
    for $proc.stdout, $proc.stderr -> $supply {
        $supply.tap: { $collector.send($_) }
    }
    my $promise = $proc.start;
    my $waitfor = $promise;
    $waitfor = Promise.anyof(Promise.in($timeout), $promise)
        if $timeout;
    $ = await $waitfor;

    $collector.close;
    my $output = $collector.list.join;

    if !$timeout || $promise.status ~~ Kept {
        say "No timeout";
        return ExecutionResult.new(
            :$output,
            :exitcode($promise.result.exitcode),
        );
    }
    else {
        $proc.kill;
        sleep 1 if $promise.status ~~ Planned;
        $proc.kill(9);
        $ = await $promise;
        return ExecutionResult.new(
            :$output,
            :timed-out,
        );
    }
}

Proc::Async[36] 的用法没有变化,但例程不再是在错误发生时产生输出,而是返回 ExecutionResult 对象。

这大大简化了 MAIN 子例程:

multi sub MAIN(*@cmd, :$timeout) {
    my $result = run-with-timeout(@cmd, :$timeout);
    unless $result.is-success {
        say "Program @cmd[] ",
            $result.timed-out ?? "ran into a timeout"
                              !! "exited with code $result.
                              exitcode()";

        print "Output:\n", $result.output if $result.output;
    }
    exit $result.exitcode // 2;
}

这里的一个新的语法特征是三元运算符,CONDITION ?? TRUE-BRANCH !! FALSE-BRANCH,你可能会从其他编程语言如 C 或 Perl5 中知道 CONDITION ? TRUE-BRANCH : FALSE-BRANCH

最后,逻辑上的 defined-or 操作符 LEFT // RIGHT, 如果定义了,则返回 LEFT 边,如果没有,则运行 RIGHT 边并返回其值。它的工作原理与 ||or 中缀运算符一样,只是这些操作符会检查左边的布尔值,而不是检查它们是否被定义。

在 Raku 中,我们区分了定义值和真值。默认情况下,所有的实例都是在布尔语境中,所有类型对象都是 true 和有定义的,而所有类型对象都是 false 和未定义的。

几个内置的类型覆盖了它们认为是真的东西。在布尔语境中,等于0的数字会被计算为 False,空字符串和空容器如数组、散列和集合也是如此。

另一方面,只有内置的 Failure[37] 类型才会重写定义性。

你可以通过实现一个方法 Bool(应该返回 True 或 False),用定义的方法覆盖自定义类型的真值,用定义的方法覆盖定义性。

我们可以通过编写具有定义特性(输出、运行时间、退出代码)的自定义外部命令来开始测试 sub run-with-timeout,但这是相当繁琐的,要想以可靠的、跨平台的方式来做,那是相当麻烦的。因此,我想用一个模拟实现来代替 Proc::Async,并给子例程注入一个方法:

sub run-with-timeout(@cmd, :$timeout, :$executer = Proc::Async) {
    my $proc = $executer.defined ?? $executer !! $executer.new(|@cmd);
    # rest as before

通过查看子例程 run-with-timeout,我们可以快速列出 Proc::Async 实现所需要的方法:stdoutstderrstartkillstdoutstderr 都需要返回一个 Supply.[38] 最简单的方法就是返回一个只发出一个值的 Supply。

my class Mock::Proc::Async {
    has $.out = '';
    has $.err = '';
    method stdout {
        Supply.from-list($.out);
    }
    method stderr {
        Supply.from-list($.err);
    }

Supply.from-list[39] 返回一个 Supply,它将发出所有传递给它的参数;所以这里只是一个字符串。

最简单的 kill 实现,就是什么都不做:

method kill($?) {}

签名中的 $? 是一个没有名字的可选参数($foo?)

只剩下一个方法需要存根:start。它应该是返回一个 Promise,在定义的秒数后,返回一个 Proc 对象或其模拟对象。由于代码只调用 exitcode 方法,所以为它写一个存根很容易:

has $.exitcode = 0;
has $.execution-time = 1;
method start {
    Promise.in($.execution-time).then({
        (class {
            has $.exitcode;
        }).new(:$.exitcode);
    });
}

因为我们在其他地方不需要 mock Proc 类,我们甚至不需要给它起个名字。class { …​ } 创建了一个匿名类,对它的 .new 调用会从它中创建一个新对象。

如前所述,当在 void 上下文中,或者我们在 Raku 中称其为 sink 上下文时,一个带有非零退出代码的 Proc 会抛出一个异常。我们可以通过扩展匿名类来模拟这种行为。

class {
    has $.exitcode;
    method sink() {
        die "mock Proc used in sink context";
    }
}

有了这些准备工作,我们终于可以写一些测试了:

multi sub MAIN('test') {
    use Test;
    my class Mock::Proc::Async {
        has $.exitcode = 0;
        has $.execution-time = 0;
        has $.out = '';
        has $.err = ''; method kill($?) {}

        method stdout {
            Supply.from-list($.out);
        }
        method stderr {
            Supply.from-list($.err);
        }
        method start {
            Promise.in($.execution-time).then({
                (class {
                    has $.exitcode;
                    method sink() {
                        die "mock Proc used in sink context";
                    }
                }).new(:$.exitcode);
            });
        }
    }

    # no timeout, success
    my $result = run-with-timeout([],
        timeout => 2,
        executer => Mock::Proc::Async.new(
            out => 'mocked output',
        ),
    );
    isa-ok $result, ExecutionResult;
    is $result.exitcode, 0, 'exit code';
    is $result.output, 'mocked output', 'output';
    ok $result.is-success, 'success';

    # timeout
    $result = run-with-timeout([],
        timeout => 0.1,
        executer => Mock::Proc::Async.new(
            execution-time => 1,
            out => 'mocked output',
        ),
    );
    isa-ok $result, ExecutionResult;
    is $result.output, 'mocked output', 'output';
    ok $result.timed-out, 'timeout reported';
    nok $result.is-success, 'success';
}

这将通过两种情况来运行,一种是配置了超时,但没有使用(因为模拟外部程序先退出),另一种是超时生效。

6.5.3. 提高可靠性和测定时间

依靠测试中的计时总是没有吸引力。如果时间太短(或一起太慢),你就有在慢的或重载的机器上出现零星测试失败的风险。如果你使用更保守的测试时间间隔,测试会变得非常慢。

有一个模块(不与 Rakudo 一起分发)可以减轻这种痛苦。Test::Scheduler[40]提供了一个具有虚拟化时间的线程调度器,让你可以这样写测试。

use Test::Scheduler;
my $*SCHEDULER = Test::Scheduler.new;
my $result = start run-with-timeout([],
    timeout => 5,
    executer => Mock::Proc::Async.new(
        execution-time => 2,
        out => 'mocked output',
    ),
);
$*SCHEDULER.advance-by(5); $result = $result.result;
isa-ok $result, ExecutionResult; # more tests here

这样安装了一个自定义的调度器,$*SCHEDULER.advance-by(5) 指示它将虚拟时间提前5秒,而不需要等待5秒的实际时间。在写这篇文章的时候(2016年12月), Test::Scheduler 是一个相当新的模块,有一个 bug,导致第二个测试用例不能这样工作。[41]

6.5.4. 安装模块

如果你想试用 Test::Scheduler,你需要先安装它。如果你运行 Rakudo Star,它已经为你提供了 zef 模块安装程序。你可以用它来下载并安装这个模块。

$ zef install Test::Scheduler

如果你没有可用的 zef,可以下载,驱动并使用它:

$ git clone https://github.com/ugexe/zef.git
$ cd zef
$ raku -Ilib bin/zef install.
$ zef install Test::Scheduler

6.6. 总结

我们已经看到了一个运行外部程序的异步 API,以及如何使用 Promises 来实现超时。我们还讨论了如何通过调度器将承诺分配给线程,让你可以启动任意数量的承诺,而不会让计算机超载。

在测试中,我们看到了属性与访问器、三元操作符和匿名类。对线程代码的测试,以及第三方模块的帮助方式也进行了讨论。最后,我们对模块安装程序zef有了一个非常小的窥视。

7. 有状态的 Silent Cron

在上一章中,我们看了一下 silent-cron,它是一个包裹外部程序的封装器,可以在外部程序退出状态为零的情况下使其静音。但为了使其真正实用,它还应该对偶尔发生的故障进行静音。

外部 API 失败,网络拥塞,以及其他事情的发生会使作业无法成功,所以某种重试机制是可取的。如果是 cron 作业,cron 已经负责定期重试作业,所以 silent-cron 应该只是抑制偶尔的错误。另一方面,如果一个作业持续失败,这通常是管理员或开发人员应该研究的事情,所以这是一个值得报告的问题。

为了实现这个功能,silent-cron 需要在独立运行之间存储持久状态。它需要记录当前运行的结果,然后决定故障历史记录是否符合 "偶然性"。

7.1. 持久化存储

存储后端需要写入和检索结构化数据,并通过锁定保护状态文件的并发访问。对于这样的存储后端来说,一个很好的库是 SQLite,[42]它是一个零维护的 SQL 引擎,以 C 语言库的形式提供。它是公共领域的软件,在大多数主流的浏览器、操作系统,甚至一些飞机上[43]都有使用。

Raku 通过 DBIish[44] 提供了 SQLite 的功能,它是一个通用的数据库接口,带有 SQLite、MySQL、PostgreSQL 和 Oracle DB 的后端驱动。要使用它,首先确保 SQLite3 已经安装好,包括头文件。例如,在基于 Debian 的 Linux 系统中,你可以通过 apt-get install libsqlite3-dev 来实现。如果你使用的是 Rakudo Star 发行版,DBIish 已经可用。如果不是,你可以使用其中一个模块安装程序来获取并安装它:zef install DBIish

use DBIish;
my $dbh = DBIish.connect('SQLite', :database('database-file.sqlite3'));

要使用 DBIish 的 SQLite 后端,首先必须通过选择后端并提供连接信息来创建数据库句柄。

$dbh.do('INSERT INTO player (name) VALUES ?', 'John');

SQL中的 ? 是一个占位符,作为独立的参数传递给 do 方法的带外参数,这样可以避免 SQL 注入漏洞等潜在的错误。

查询的工作方式往往是先准备一个语句,返回一个语句句柄。你可以一次或多次执行一条语句,每次执行调用后都可以检索结果行。

my $sth = $dbh.prepare('SELECT id FROM player WHERE name = ?');
my %ids;
for <John Jack> -> $name {
    $sth.execute($name);
    %ids{ $name } = $sth.row[0];
}
$sth.finish;

7.2. 开发存储后端

我们不应该把所有的存储处理代码都塞到子例程 MAIN 中,而是应该仔细考虑为存储后端创建一个有用的 API。起初,我们只需要两块功能:插入一个作业执行的结果,以及检索最近的结果。

因为 silent-cron 可以用来守护同一台机器上的多个 cron 作业,所以我们可能需要一些东西来区分不同的作业,以便其中一个成功的作业不会阻止一个不断失败的作业报错。为此,我们引入了一个作业名称,它可以默认为正在执行的命令(包括参数),但可以在命令行中明确设置。

存储后端的 API 可以看成是这样的。

my $repo = ExecutionResultRepository.new(
    jobname => 'refresh cache',
    statefile => 'silent-cron.sqlite3',
);
$repo.insert($result);
my @last-results = $repo.tail(5);

这个 API 完全不是 SQLite 后端特有的,一个使用纯文本文件的存储后端也可以有完全相同的 API。

让我们来实现这个 API。首先我们需要类和两个属性,这两个属性在前面的使用示例中应该很明显。

class ExecutionResultRepository {
    has $.jobname is required;
    has $.statefile is required;
    # ... more code

为了实现 insert 方法,我们需要连接到数据库并创建相关的表,如果表还不存在的话,那么我们需要连接到数据库并创建相关的表。

has $!db; method !db() {
    return $!db if $!db;
    $!db = DBIish.connect('SQLite', :database($.statefile));
    self!create-schema();
    return $!db;
}

这段代码使用一个私有属性 $!db 来缓存数据库句柄,如果还不存在,则使用一个私有方法 !db 来创建句柄。

私有方法的声明与普通方法一样,只是名称以感叹号开始。要调用一个方法,用方法调用点代替感叹号;换句话说,用 self!db() 代替 self.db()

!db 方法还调用下一个私有方法,即 !create-schema,它创建了存储表和一些索引。

constant $table = 'job_execution';
method !create-schema() {
    $!db.do(qq:to/SCHEMA/);
        CREATE TABLE IF NOT EXISTS $table (
            id          INTEGER PRIMARY KEY,
            jobname     VARCHAR NOT NULL,
            exitcode    INTEGER NOT NULL,
            timed_out   INTEGER NOT NULL,
            output      VARCHAR NOT NULL,
            executed    TIMESTAMP NOT NULL DEFAULT (DATETIME('NOW'))
        );

    SCHEMA
    $!db.do(qq:to/INDEX/);
        CREATE INDEX IF NOT EXISTS {$table}_jobname_exitcode ON
        $table ( jobname, exitcode );
    INDEX
    $!db.do(qq:to/INDEX/);
        CREATE INDEX IF NOT EXISTS {$table}_jobname_executed ON
        $table ( jobname, executed );
    INDEX
}

多行字符串字符最好用 heredoc[45]语法来写。qq:to/DELIMITER/ 告诉 Raku 要完成对当前行的解析,这样你仍然可以关闭方法调用的小括号,并添加语句结束分号。下一行开始字符串字面量,一直到 Raku 在一行上找到分界符为止。字符串字段的每一行的前导空格都会被剥去,缩进的分界符越多越好。

因此:

print q:to/EOS/;
    Not indented
        Indented four spaces
    EOS

产生如下输出:

Not indented
    Indented four spaces

现在我们有了一个工作的数据库连接,知道了数据库表的存在,插入一条新记录就变得简单了。

method insert(ExecutionResult $r) {
    self!db.do(qq:to/INSERT/, $.jobname, $r.exitcode, $r.timed-out, $r.output);
        INSERT INTO $table (jobname, exitcode, timed_out, output)
        VALUES(?, ?, ?, ?)
    INSERT
}

选择最近的记录比较麻烦,部分原因是我们需要将表的行转换为对象。

method tail(Int $count) {
    my $sth = self!db.prepare(qq:to/SELECT/);
        SELECT exitcode, timed_out, output
          FROM $table
          WHERE jobname = ?
          ORDER BY executed DESC
          LIMIT $count
    SELECT
    $sth.execute($.jobname);
    $sth.allrows(:array-of-hash).map: -> %h {
        ExecutionResult.new(
            exitcode  => %h<exitcode>,
            timed-out => ?%h<timed_out>,
            output    => %h<output>,
        );
    }
}

尾部方法中的最后一条语句值得注意一下。$sth.allrows(:array-of-hash) 将数据库中的记录生成为一个散列值列表。这个列表是惰性的,也就是说,它是按需生成的。懒人列表是一个非常方便的功能,因为它允许你用相同的 API 使用迭代器和列表。例如,当读取文件中的 lines 时,你可以写 $handle.lines → $line { …​ },行方法不需要将整个文件加载到内存中,而是可以在访问时读取一行。

$sth.allrows(…​) 是惰性的,后面的 .map 调用也是惰性的,map 通过调用传递给它的代码对象来变换一个个元素。而这也是懒惰地完成的。所以 SQLite 只有在实际访问结果列表中的元素时,才会从数据库文件中检索行。

7.3. 使用存储后端

有了存储 API,就该用了:

multi sub MAIN(*@cmd, :$timeout, :$jobname is copy,
               :$statefile='silent-cron.sqlite3', Int :$tries = 3) {
    $jobname //= @cmd.Str;
    my $result = run-with-timeout(@cmd, :$timeout);
    my $repo = ExecutionResultRepository.new(:$jobname, :$statefile);
    $repo.insert($result);

    my @runs = $repo.tail($tries);

    unless $result.is-success or @runs.grep({.is-success}) {
        say "The last @runs.elems() runs of @cmd[] all failed, the last execution ",
        $result.timed-out ?? "ran into a timeout"
                          !! "exited with code $result.exitcode()";

        print "Output:\n", $result.output if $result.output;
    }
    exit $result.exitcode // 2;
}

现在,一个作业如果连续成功几次,然后连续失败两次,就不会产生任何错误输出;只有连续第三次执行失败,才会产生输出。你可以在命令行中用 --tries=5 来覆盖。

MAIN 子例程使用构造 $var //= EXPR// 代表 defined-OR,,所以如果有定义的值,它会返回左侧的值。否则,它计算并返回 EXPR 的值。结合赋值运算符,只有在变量未定义的情况下,它才会对右边的值进行计算,然后将表达式的值存储在变量中。这是一个确保变量得到一个值的方便方法,甚至可以说是写缓存的一个简短方法。

7.4. 扩展空间

一个系统管理员如果要调查一个 cron 作业失败的原因,可能会对该作业的历史记录感兴趣。你可以实现一个命令,列出最后的作业运行情况、成功或失败、退出代码,或者可能是运行时间等等。

或者你可以研究一个不同的后端。如果你想把状态存储在 JSON 文件而不是 SQLite 中呢?或者两者都启用呢?(提示:你可以使用JSON::Tiny[46]或JSON::Fast[47]模块)。

7.5. 总结

我们已经讨论过 DBIish,这是一个具有可插拔后端的数据库 API,并探讨了用它和 SQLite 来存储持久化数据。在这个过程中,我们还接触到了惰性列表和一种新形式的字符串字库,叫做 heredocs。

8. 回顾 Raku 基础知识

在前几章中,我们讨论了一些例子与 Perl 6 的机理交错的例子,这些例子的作用。在这里,我想把我们到目前为止所接触到的 Perl 6 的知识进行总结和深化,脱离了原来的例子,在这里,我想对这些知识进行总结和深化。

8.1. 变量和作用域

在 Perl 6 中,变量名由一个符号,$@%& 组成,后面是标识符。sigil 意味着一个类型约束,其中 $ 是最一般的类型约束。(默认没有限制),@ 代表数组,% 代表散列(关联数组/映射),& 代表代码对象。

标识符可以包含 -' 字符,只要后面的字符是字母就可以。标识符必须以字母或下划线开头。

my 声明的子程序和变量是词法作用域的。它们从声明的点到当前 {}-闭合块儿(或当前文件,如果声明在 block 之外)的块的结尾都是可见的。子例程参数在子例程的签名和块中是可见的。

在 sigil 和标识符之间的可选的 twigil 可以影响作用域。twigil 标志着一个动态作用域的变量;因此,查找是在当前调用栈中进行的。! 标志着属性,也就是说,每个实例中的变量被附加到一个对象上。

8.2. 子例程

子程序,简称 sub,是一段有自己的作用域的代码,通常也有一个名字。它有一个签名,指定你在调用它的时候要传入什么样的值。

sub chunks(Str $s, Int $chars) {
#         ^^^^^^^^^^^^^^^^^^^^ signature
#   ^^^^^^ name
    gather for 0 .. $s.chars / $chars - 1 -> $idx {
        take substr($s, $idx * $chars, $chars);
    }
}

签名中使用的变量被称为参数,而我们调用你在参数中传递的值。

如果要引用一个子程序而不调用它,可以在它前面加上一个句号(&),比如这样:

say &chunks.name; # Output: chunks

要调用它,只需使用它的名字,后面是参数列表,参数可以放在一对圆括号中:

say chunks 'abcd', 2; # Output: (ab cd)
say chunks('abcd', 2); # Output: (ab cd)

只有在其他构造会干扰子例程调用的情况下,你才需要括号。因此,如果你打算这样写:

say chunks(join('x', 'ab', 'c'), 2);

而省略了内侧的括号:

say chunks(join 'x', 'ab', 'c', 2);

那么所有的参数都会进入 join 函数,只留下一个参数给 chunks 函数。另一方面,省略了外面的一对括号,写成:

say chunks join('x', 'ab', 'c'), 2;

因为这里没有任何歧义。

一个值得注意的情况是,如果你调用一个没有参数的子例程作为 if 条件或 for 循环(或类似的构造)的块,你必须包含括号,因为否则这个块会被解析为函数的参数。

sub random-choice() {
    Bool.pick;
}

# right way:
if random-choice() {
    say 'You were lucky.';
}

# wrong way:
if random-choice {
    say 'You were lucky.';
}

如果你碰巧犯了这个错误,Raku 编译器会尽力去检测它。在前面的例子中,它打印出:

Function 'random-choice' needs parens to avoid gobbling block

而当它试图解析 if-statement 的块时,它没有找到一个。

Missing block (apparently claimed by 'random-choice')

当你有一个叫 MAIN 的子程序时,Raku 会使用它的签名来解析命令行参数,并将这些命令行参数传递给 MAIN。

multi 子例程是指几个名字相同但签名不同的子程序。编译器在运行时根据参数和参数之间的最佳匹配度来决定调用哪个子程序。

8.3. 类和对象

类声明遵循与子程序声明相同的语法模式:关键字 class,后面是名称,后面是花括号中的主体:

class OutputCapture {
    has @!lines;
    method print(\s) {
        @!lines.push(s);
    }
    method captured() {
        @!lines.join;
    }
}

默认情况下,类型名称被覆盖到当前的命名空间;但是,你可以通过在 class 前面添加一个 my 来使其变成词法作用域的。

my class OutputCapture { ... }

创建一个新的实例一般通过在类型对象上调用 new 方法来实现。new 方法继承自所有类型都会得到的隐式父类 Any

my $c = OutputCapture.new;

每个实例的状态都存储在属性中,用 has 关键字声明,如前面的 has @! 行所示。属性始终是私有的,正如 ! twigil 所表示的那样。如果你在声明中使用点 .twigil,你就同时拥有了私有属性 @!line 和只读的公共访问器方法。

my class OutputCapture {
    has @.lines;
    method print(\s) {
         # the private name with ! still works
         @!lines.push(s);
    }
    method captured() {
        @!lines.join;
    }
}
my $c = OutputCapture.new;
$c.print('42');
# use the `lines` accessor method:
say $c.lines; # Output: [42]

当你用点 twigil 声明属性时,你也可以通过命名参数从构造函数中初始化属性,如 OutputCapture.new( lines ⇒ [42] )

私有方法以 ! 开始,只能在类体内部以 self!private-method 的形式调用。

方法基本上只是子程序,有两个区别。第一个区别是, 它们会得到一个名为 self 的隐式参数,其中包含方法被调用的对象(我们称之为调用者)。二是如果调用一个子程序,编译器会在当前的词法作用域以及外部作用域中搜索这个子程序。另一方面,方法调用只在对象的类和超类中查找。

子程序的查找可以在编译时进行,因为词法作用域在运行时是不可更改的,所以编译器对所有的词法符号都有知识。但是,即使在类型约束的情况下,编译器也无法知道对象的类型是否可能是类型约束的子类型,这意味着方法查找必须推迟到运行时进行。

8.4. 并发

Raku 为并发和并行执行提供了高级原语。我们鼓励你用 start 来运行计算,而不是显式地催生新的线程,它返回一个 Promise。[48] 这是一个对象,它承诺未来的计算会产生一个结果。因此,状态可以是 Planned, Kept, 或 Broken。你可以将承诺串联起来,将它们组合起来,然后等待它们。

在后台,一个调度器将这些计算分配给操作系统级线程。默认的调度器是一个线程池调度器,对可使用的线程数量有一个上限。

并行计算之间的通信应该通过线程安全的数据结构来进行。其中最重要的是 Channel[49](线程安全队列)和 Supply[50](Raku 对观察者模式[51]的实现)。Supply 是非常强大的,因为你可以用诸如 mapgrepthrottledelayed,并使用它们的 actor 语义[52]来确保消费者一次只在一个线程中运行。

8.5. 展望

当你了解了本章讨论的主题,并对内置的类型有了一定的了解,你应该已经熟悉了 Raku 的基础知识,并能够编写自己的程序了。

接下来,我们将探讨 Raku 的一个优点:通过 regexes 和 grammar 来解析。

9. 使用正则表达式和 Grammar 解析 INI 文件

你之前可能已经见过 .ini 文件; 它们在微软 Windows 平台上很常见, 但是在其它地方也有发现, 例如 ODBC 配置文件, Ansible 的库文件,[53] 等等。

INI 文件看起来长这样:

key1=value2

[section1]
key2=value2
key3 = with spaces
; comment lines start with a semicolon, and are ; ignored by the parser

[section2]
more=stuff

Raku 提供了用于解析的正则表达式,以及用于结构化和重用正则表达式的 grammar。

你可以使用 Config::INI[54] 模块(在使用 zef install Config::INI 安装之后)来解析 INI 文件,就像这样:

use Config::INI;
my %hash = Config::INI::parse($ini_string);

它在底层使用了正则表达式和 grammar。这里我们将探讨如何编写自己的 INI 解析器。

9.1. 正则表达式基础

正则表达式是一段代码,它作为具有共同结构的字符串的模式。它源于计算机科学中的正则表达式的概念[55],但经过调整,提供了比纯粹的正则表达式所允许的更多的构造,并扩展了一些使其更容易使用的特性。

我们将使用命名的正则表达式来匹配原语,然后使用调用这些命名的正则表达式来构建 INI 文件的解析器。由于 INI 文件没有公认的、正式的语法,所以我们必须边走边写。

让我们从解析值对开始,比如 key1=value1。首先,让我们只考虑 key。它可能包含字母、数字和下划线 _。有一个快捷方式来匹配这样的字符,\w,通过附加一个 + 字符来匹配一个或多个字符:

use v6;

my regex key { \w+ }

multi sub MAIN('test') {
    use Test;
    ok 'abc'     ~~ /^ <key> $/, '<key> matches a simple identifier';
    ok  '[abc]' !~~ /^ <key> $/, '<key> does not match a  section header';
    done-testing;
}

my regex key { \w+ } 声明一个词法上(my)作用域化的正则表达式,称为 key,它可以匹配一个或多个单词字符。

在编程语言中,支持所谓的 Perl 兼容正则表达式(PCRE)的传统由来已久。许多程序设计语言都支持一些偏离 PCRE 的元素,包括 Perl 本身,但大多数程序设计语言中都保留了一些常见的语法元素。Raku 仍然支持其中的一些元素,但在其他方面却有很大的偏差。

这里的 \w+ 和 PCRE 中的一样,但忽略了周围的空格。在测试例程中,'abc' ~~ /^ <key> $/ 中的斜线分隔了一个匿名的正则表达式。在这个正则表达式中,^$ 分别代表匹配字符串的开头和结尾,这在 PCRE 中很熟悉。然而,与 PCRE 不同的是, <key> 子规则调用了前面的命名正则表达式 key。这是 Raku 的一个扩展。在 PCRE 中,正则表达式中的 < 匹配的是字面的 <,在 Raku 中,它引入了一个子规则调用。

一般来说,所有的非单词字符都是为"特殊"语法所保留的,你必须用引号或反斜线才能得到字面意思。例如,在正则表达式中的 \<'<' 与小于号匹配。引号可以应用于一个以上的字符,因此,正则表达式中的 'a+b' 匹配一个 a,后面是加号 +,再后面是 b。单词字符(字母、数字和下划线)总是按字面意思匹配。

9.1.1. 字符类

除了字面值外,字符类也是正则表达式常用的构建块。有许多预定义的字符类,其形式为反斜线,后面是一个小写字母; 例如,\d 匹配一个数字。它的反义词使用大写字母,所以 \D 匹配任何不是数字的字符。

字符类

否定

匹配

\d

\D

一个数字

\w

\W

一个单词字符(字母,数字,下划线)

\s

\S

空格,空白,换行,等等

\h

\H

水平空白

\v

\V

垂直空白

\n

\N

逻辑换行(回车,换行)

.

任意字符

你还可以通过枚举字符或字符范围来构建自己的字符类:

方法

例子

匹配

枚举

<[abc]>

a, b 或 c

否定

←[abc]>

除了 a, b 或 c 的任何东西

范围

<[a..c]>

a, b 或 c

9.1.2. 量词

只匹配一个重复的东西很无聊,所以正则表达式提供了量词。 量词表示前一个正则表达式必须匹配的频率。

量词

匹配多少个字符

*

0..Inf

+

1..Inf

?

0..1

** 1..5

1..5

所以, 对于 ab+ 这个例子来说, 它匹配字符串 ab, abb, aab, 但是不匹配 a。

9.1.3. 备选分支

可选的选项用竖条 | 分隔。例如, \d+ | x 匹配的是一个或多个数字的序列,或者是字符 x

如果一个替代方案有多个路径匹配,Raku 会选择最长的匹配。如果不需要这种行为,那么 || 会取第一个匹配的备选路径。

9.2. 解析 INI 原语

再回到 INI 解析,我们必须思考一个值里面允许使用哪些字符。列出允许的字符似乎是徒劳的,因为我们很可能会忘记一些字符。相反,我们应该思考什么是不允许在值中出现的。换行符当然不允许,因为它们会引入下一个键/值对或章节标题。分号也不允许,因为它们会引入注释。

我们可以将这种排除法表述为否定字符类。←[ \n ; ]> 匹配任何一个既不是换行符也不是分号的字符。注意,在字符类中,几乎所有的字符都会失去其特殊意义。只有反斜线、空格、两个小点和闭合括号才代表着除它们之外的任何东西。在字符类的内部和外部都是如此, \n 匹配一个换行字符,而 \s 匹配一个空格。大写字母与此相反,因此,例如,\S 匹配任何一个非空格字符。

这让我们看到了一个在 INI 文件中匹配一个值的正则表达式版本:

my regex value { <-[ \n ; ]>+ }

这个正则表达式有一个问题:它还匹配了前导和尾部空格,我们不想将其视为值的一部分:

my regex value { <-[ \n ; ]>+ }
if ' abc ' ~~ /<value>/ {
    say "matched '$/'"; # matched ' abc '
}

如果把 Raku 正则表达式限制在计算机科学意义上的普通语言中,我们就得这样做:

my regex value {
    # match a first non-whitespace character
    <-[ \s ; ]>
    [
        # then arbitrarily many that can contain whitespace
        <-[ \n ; ]>*
        # ... terminated by one non-whitespace
        <-[ \s ; ]>
    ]?  # and make it optional, in case the value is only
        # only one non-whitespace character
}

现在你知道为什么人们在提出用正则表达式来解决问题时,会以"现在你有两个问题了"[56]来回应。一个更简单的解决方案是先匹配一个引入的值,然后引入一个约束条件,即第一个或最后一个字符都不能是空格:

my regex value { <!before \s> <-[ \n ; ]>+ <!after \s> }

连带着测试:

is ' abc ' ~~ /<value>/, 'abc', '<value> does not match leading or trailing whitespace';
is ' a'    ~~ /<value>/, 'a',   '<value> matches single non-whitespace too';
ok "a\nb" !~~ /^ <value> $/,    '<value> does not match \n';

<!before regex> 是一个否定的向前查看,也就是说,下面的文本必须不匹配 regex,并且在匹配的过程中不消耗文本。<!after regex> 是否定的向后查看,它试图匹配已经匹配过的文本,而且必须不成功,整个匹配才会成功。

在 Raku 中,当然还有另一种方法可以解决这个问题。如果你把要求表述为"值不能包含换行或分号,并且以非空格开头,以非空格结尾",那么很明显,如果我们在正则表达式中使用 AND 运算符,这个问题就很容易解决了。而事实就是如此:

my regex value { <-[ \n ; ]>+ & \S.* & .*\S }

& 操作符将两个或更多的小的正则表达式定界,这些表达式必须成功地匹配到同一个字符串,才能使整个匹配成功。\S. 可以匹配任何以非空格字符(\S)开头的字符串,后面是任意字符(.),任意次数的 。同样的,.*\S 匹配任何以非空格字符结尾的字符串。

谁会想到匹配一个配置文件中的值这么简单的东西会如此复杂呢?幸运的是,现在我们知道了如何单独匹配键/值对就简单多了。

my regex pair { <key> '=' <value> }

而且这个方法很好用,只要等号周围没有空格就可以了。如果有,我们要分别匹配:

my regex pair { <key> \h* '=' \h* <value> }

\h 匹配的是一个水平的空白,也就是说,一个空白,一个制表符,或者其他 Unicode 为我们准备的类似于空格的东西(比如说,也是非分隔符),但不是换行。

说到换行,在 regex pair 的结尾处匹配一个换行是个好主意,既然我们忽略了空行,那就多匹配几个吧。

my regex pair { <key> \h* '=' \h* <value> \n+ }

是时候写一些测试了:

ok "key=value\n" ~~ /<pair>/, 'simple pair';
ok "key = value\n\n" ~~ /<pair>/, 'pair with blanks';
ok "key\n= value\n" !~~ /<pair>/, 'pair with newline before assignment';

节标题是一个方括号中的字符串,所以字符串本身不应该包含括号或换行:

my regex header { '[' <-[ \[ \] \n ]>+ ']' \n+ }
# and in multi sub MAIN('test'):
ok "[abc]\n"    ~~ /^ <header> $/, 'simple header';
ok "[a c]\n"    ~~ /^ <header> $/, 'header with spaces';
ok "[a [b]]\n" !~~ /^ <header> $/, 'cannot nest headers';
ok "[a\nb]\n"  !~~ /^ <header> $/, 'No newlines inside headers';

最后剩下的原语是注释:

my regex comment { ';' \N*\n+ }

\N 匹配任何不是换行的字符,所以注释只是一个分号,然后是任何东西,直到行尾。

9.3. 把东西组装起来

INI 文件的 section 是标题,后跟一些键/值对或注释行:

my regex section {
    <header>
    [ <pair> | <comment> ]*
}

[…​] 对正则表达式的一部分进行分组,这样,后面的量词 * 应用于整个组,而不仅仅是最后一项。

整个 INI 文件可能由一些初始键/值对或注释组成,然后是一些 section:

my regex inifile {
    [ <pair> | <comment> ]*
    <section>*
}

热心的读者已经注意到,正则表达式中的 [ <pair> | <comment> ]* 部分已经用过两次了,所以最好把它提取成独立的正则表达式:

my regex block   { [ <pair> | <comment> ]* }
my regex section { <header> <block> }
my regex inifile { <block> <section>* }

是时候进行“终极”测试了:

my $ini = q:to/EOI/;
key1=value2

[section1]
key2=value2
key3 = with spaces

; comment lines start with a semicolon, and are
; ignored by the parser

[section2]
more=stuff
EOI

ok $ini ~~ /^<inifile>$/, 'Can parse a full INI file';

9.4. 回溯

在很多程序员看来,正则表达式匹配似乎很神奇。你只需说明模式,然后正则表达式引擎就会为你决定一个字符串是否匹配该模式。虽然实现一个正则表达式引擎是一件很棘手的事情,但基本原理并不难理解。

正则表达式引擎会从左到右浏览正则表达式的各个部分,试图匹配正则表达式的每个部分。它在游标中记录着到目前为止它匹配到的字符串的哪一部分。如果一个正则表达式的某一部分找不到匹配,正则表达式引擎会尝试改变之前的匹配,以占用更少的字符,然后在新的位置重新尝试失败的匹配。

例如,如果你执行了下面这个正则表达式匹配:

'abc' ~~ /.* b/

正则表达式引擎首先计算 .. 可以匹配任何字符。 量词符是贪婪的,这意味着它尽量匹配尽可能多的字符。它最终会匹配整个字符串,即 abc。然后正则表达式引擎会尝试匹配 b,这是一个字面值。由于之前的匹配吞噬了整个字符串,所以匹配 c 和剩余的空字符串失败了。所以之前的正则表达式部分,.,必须放弃一个字符。现在,它匹配了 ab,而 b 的字面值匹配器对 b 和 c 进行了比较,再次失败。所以有一个最后的迭代,. 再次放弃了一个匹配的字符,现在 b 的字面值可以匹配字符串中的第二个字符。

这种来回迭代的方式叫做回溯。当你在字符串中搜索模式时,这是一个很好的功能。但在解析器中,它通常是不可取的。比如说,如果在输入 key2=value2 中的正则表达式 key 匹配了子串 key2,你不希望它只因为正则表达式的下一部分不能匹配到一个更短的子串,那么你就不希望它匹配到更短的子串。

你不希望这样做有三个主要原因。第一是它增加了调试的难度。当人类思考一个文本是如何结构化的时候,他们通常会很快地提交基本的标记化,比如一个词或一个句子的结尾。因此,回溯可能非常不直观。如果你根据哪些正则表达式不匹配产生错误信息,回溯基本上总是导致错误信息非常无用。

第二个原因是,回溯可能会导致意外的正则表达式匹配。例如,你想匹配两个单词,可选地用空格分隔,而你试图将其直接翻译成正则表达式。

say "two words" ~~ /\w+\s*\w+/; # 「two words」

这似乎是可行的:第一个 \w + 与第一个单词匹配,第二个 \w+ 与第二个单词匹配,一切都很好。直到你发现它实际上也匹配一个单词:

say "two" ~~ /\w+\s*\w+/; # 「two」

为什么会这样呢?嗯,第一个 \w+ 匹配了整个单词,\s* 成功地匹配了空字符串,然后第二个 \w+ 匹配失败了,这迫使前两个部分的正则表达式以不同的方式匹配。所以,在第二次迭代中,第一个 \w+ 只匹配了 tw,\s* 匹配了 tw 和 o 之间的空字符串,第二个 \w+ 匹配了 o。然后你会发现,如果两个词没有空格分隔,你怎么能分辨出一个词的结尾和下一个词的开头呢?在禁用回溯的情况下,正则表达式匹配失败,而不是以一种不经意的方式匹配。

第三个原因是性能问题。当你禁用回溯时,正则表达式引擎只需要对每个字符查看一次,或者在有备选项的情况下,对每个分支查看一次。使用回溯,正则表达式引擎可能会陷入回溯循环中,随着输入字符串长度的增加,回溯循环的时间会越长。

要禁用回溯,你只需要在声明中用 token 替换 regex 这个词,或者在 regex 中使用 :ratchet 修饰符。

在 INI 文件解析器中,只有 regex value 需要回溯(虽然前面讨论过的其他形式不需要);其他所有的 regex 都可以安全地切换到 token:

my token key     { \w+ }
my regex value   { <!before \s> <-[\n;]>+ <!after \s> }
my token pair    { <key> \h* '=' \h* <value> \n+ }
my token header  { '[' <-[ \[ \] \n ]>+ ']' \n+ }
my token comment { ';' \N*\n+ }
my token block   { [ <pair> | <comment> ]* }
my token section { <header> <block> }
my token inifile { <block> <section>* }

9.5. Grammar

这种解析 INI 文件的正则表达式的集合,并不是封装和重用的巅峰之作。

因此,我们将探讨 grammar,一种将正则表达式分组成类状结构的特性,以及如何从成功匹配中提取结构化数据。

Grammar 是一个具有一些额外的特性的类,使其适用于解析文本。与方法和属性一起,你可以将正则表达式放入 grammar 中。

这就是 INI 文件解析器在制定为 grammar 时的样子:

grammar IniFile {
    token key     { \w+ }
    token value   { <!before \s> <-[\n;]>+ <!after \s> }
    token pair    { <key> \h* '=' \h* <value> \n+ }
    token header  { '[' <-[ \[ \] \n ]>+ ']' \n+ }
    token comment { ';' \N*\n+ }
    token block   { [<pair> | <comment>]* }
    token section { <header> <block> }
    token TOP     { <block> <section>* }
}

你可以通过调用 parse 方法来解析一些文本,该方法使用 regex 或 token TOP 作为入口点:

my $result = IniFile.parse($text);

除了标准化的入口点之外,grammar 还有更多的优势。你可以像从普通类中继承它,从而为正则表达式带来更多的可重用性。你可以通过在 grammar 中添加方法,将额外的功能与正则表达式组合在一起。Grammar 中还有一些机制可以让你作为一个开发者的生活更加轻松。

其中之一就是处理空白。在 INI 文件中,一般认为横向的空白是不重要的,因为 key=value 和 key = value` 会导致应用程序的配置相同。到目前为止,我们已经通过在 token 对中添加了 \h* 来明确地处理了这个问题。但是,有些地方我们实际上还没有考虑到。比如说,有一个注释不在开头的地方也是可以的。

Grammar 提供的机制是,你可以定义一个叫做 ws 的规则,当你用 rule 代替 token 声明一个 token 时(或者在 regex 中通过 :sigspace 修饰符启用这个功能),Raku 会在 regex 定义中有空白的地方为你插入隐式的 <ws> 调用:

grammar IniFile {
    token ws { \h* }
    rule pair { <key>    '='    <value> \n+ }
    # rest as before
}

对于一个需要解析空白的单一规则来说,这可能不值得,但当有更多的规则时,这样做确实能让空白解析保持在一个位置上,这样做会有好处。

需要注意的是,你应该只解析 token ws 中不重要的空白。在 INI 文件中,换行是重要的,所以 ws 不应该与之匹配。

9.6. 从匹配中提取数据

到目前为止,IniFile grammar 只检查给定的输入是否匹配 grammar。然而,当它匹配时,我们真的希望解析结果能以一个易于使用的数据结构来表示。例如,我们可以将这个例子的 INI 文件

key1=value2

[section1]
key2=value2
key3 = with spaces
; comment lines start with a semicolon, and are
; ignored by the parser

[section2]
more=stuff

范围为嵌套的散列数据结构:

{
_ => {
          key1 => "value2"
      },
      section1 => {
          key2 => "value2",
          key3 => "with spaces"
      },
      section2 => {
          more => "stuff"
      }
}

注意,来自任何 section 之外的键/值对都会在 _ 顶层键中显示。

IniFile.parse 调用的结果是一个 Match[57] 对象,它具有(几乎)提取所需的匹配信息。如果你把 Match 对象变成一个字符串, 它就变成了匹配的字符串。但是,还有更多。你可以像散列一样使用它来从命名的子匹配中提取匹配。因此,如果顶层的匹配对象从

token TOP { <block> <section>* }

产生一个匹配对象 $m,那么 $m<block> 又是一个匹配对象,这个匹配对象来自于 token 块的调用的匹配。而 $m<section> 是来自于重复调用 token section 的匹配对象的列表。所以一个 Match 对象其实就是一棵匹配树(图9-1)。

匹配树
Figure 1. 解析示例 INI 文件的匹配树

我们可以使用此数据结构来提取嵌套的哈希值。 标头标记匹配像“[section1] \ n”这样的字符串,我们只对“section1”感兴趣。 为了到达内部部分,我们可以通过在我们感兴趣的匹配的子规则周围插入一对括号来修改标题:

我们可以遍历这个数据结构来提取嵌套的散列值。header 标记匹配的字符串是 "[section1]\n",我们只对 "section1" 感兴趣。为了进入内部部分,我们可以通过在我们感兴趣的匹配的子正则表达式周围插入一对括号来修改 header:

token header { '[' ( <-[ \[ \] \n ]>+ ) ']' \n+ }
#                   ^^^^^^^^^^^^^^^^^^^^ a capturing group

那是一个捕获组,我们可以通过把 header 的顶层匹配作为数组,访问它的第一个元素,就可以得到它的匹配。这就引出了完整的 INI 解析器。

sub parse-ini(Str $input) {
    my $m = IniFile.parse($input);
    unless $m {
        die "The input is not a valid INI file.";
    }

    sub block(Match $m) {
        my %result;
        for $m<block><pair> -> $pair {
            %result{ $pair<key>.Str } = $pair<value>.Str;
        }
        return %result;
    }

    my %result;
    %result<_> = block($m);
    for $m<section> -> $section {
        %result{ $section<header>[0].Str } = block($section);
    }
    return %result;
}

这种自上而下的方法是可行的,但它需要对 grammar 的结构有非常深刻的理解。这意味着,如果在维护过程中改变结构,你将很难搞清楚如何改变数据提取代码。

Raku 也提供了一个自下而上的方法。它允许你为每个 regex、token 或 rule 编写一个数据提取或 action 方法。Grammar 引擎将匹配对象作为单一参数传递,而 action 方法可以调用例程 make 将结果附加到匹配对象上。结果可以通过匹配对象上的 .made 方法获得。

一旦一个 regex 匹配成功,action 方法的执行就会发生;因此,一个 regex 的 action 方法可以依赖 subregex 调用的 action 方法已经运行。例如,当 rule pair { <key> '=' <value> \n+ } 正在执行时,首先标记 key 匹配成功,其 action 方法立即运行。然后,标记 value 匹配成功,它的 action 方法也会运行。最后,rule pair 本身可以匹配成功,所以它的 action 方法可以依赖 $m<key>.made$m<value>.made,假设匹配结果存储在变量 $m 中。

说到变量,一个 regex 匹配会将其结果隐式地存储在特殊变量 $/ 中,在 action 方法中习惯性地使用 $/ 作为参数。还有一个访问命名的子匹配的捷径:你可以写 $<key>,而不是写 $/<key>。考虑到这个惯例,action 类就变成了:

class IniFile::Actions {
    method key($/) { make $/.Str }
    method value($/) { make $/.Str }
    method header($/) { make $/[0].Str }
    method pair($/) { make $<key>.made => $<value>.made }
    method block($/) { make $<pair>.map({ .made }).hash }
    method section($/) { make $<header>.made => $<block>.made }
    method TOP($/) {
        make {
            _ => $<block>.made,
            $<section>.map: { .made },
        }
    }
}

前两个 action 方法其实很简单。keyvalue 匹配的结果只是匹配的字符串。对于 header,它只是括号内的子字符串。合适的是,pair 返回一个 Pair[58] 对象,由 keyvalue 组成。block 方法从块中的所有行中构造出一个散列。 通过迭代每个 pair 子匹配,提取已经附加的 Pair 对象。在匹配树的上一层,section 接收这个散列,并与从 $<header>.made 中提取的 section 名称配对。最后,顶层的 action 方法收集了 key_ 下的无 section 的 key/value 对以及所有的 section,并以散列形式返回。

在 action 类的每一个方法中,我们只依靠直接调用第一层的 regex 的知识,这些 regex 对应于 action 方法和它们 .made 的数据类型。因此,当你重构一个 regex 时,你也只需要改变对应的 action 方法。没有人需要注意到 grammar 的全局结构。

现在我们只需要告诉 Raku 实际使用 action 类就可以了。

sub parse-ini(Str $input) {
    my $m = IniFile.parse($input, :actions(IniFile::Actions));
    unless $m {
        die "The input is not a valid INI file.";
    }

    return $m.made
}

如果你想用一个与 TOP 不同的规则开始解析(例如,你可能想在测试中这样做),你可以将一个命名的参数规则传递给方法 parse:

sub parse-ini(Str $input, :$rule = 'TOP') {
    my $m = IniFile.parse($input,
        :actions(IniFile::Actions),
        :$rule,
    );
    unless $m {
        die "The input is not a valid INI file.";
    }

    return $m.made
}

say parse-ini($ini).perl;

use Test;

is-deeply parse-ini("k = v\n", :rule<pair>), 'k' => 'v',
    'can parse a simple pair';
done-testing;

为了更好地封装 grammar 中的所有解析功能,我们可以把 parse-ini 变成一个方法:

grammar IniFile {
    # regexes/tokens unchanged as before

    method parse-ini(Str $input, :$rule = 'TOP') {
        my $m = self.parse($input,
            :actions(IniFile::Actions),
            :$rule,
        );
    unless $m {
        die "The input is not a valid INI file.";
    }

    return $m.made
    }
}

# Usage:

my $result = IniFile.parse-ini($text);

为了实现这个功能,类 IniFile::Action 必须在 grammar 之前声明,或者需要在文件顶部用类 IniFile::Action { …​ } 在文件的顶部(用三个点来标记为前向声明)预先声明。

9.7. 生成好的错误信息

好的错误信息对于任何产品的用户体验都是至关重要的。解析器也不例外。考虑一下消息 Square bracket [ on line 3 closed by curly bracket } on line 5,与 Python 的懒散和通用的 SyntaxError: invalid syntaxError 的不同。

除了文本消息之外,知道解析错误的位置对找出错误的原因有很大的帮助。

我们将以我们的 INI 文件解析器为例,探讨如何从 Raku grammar 中生成更好的解析错误信息。

9.7.1. 失败是正常的

在开始之前,我们必须意识到,在一个基于 grammar 的解析器中,即使是在一个整体成功的解析中,regex 不匹配也是很正常的。

让我们回顾一下解析器的一部分。

token block   { [<pair> | <comment>]* }
token section { <header> <block> }
token TOP     { <block> <section>* }

当这个 grammar 与字符串匹配时:

key=value
[header]
other=stuff

然后 TOP 调用 block,即同时调用 paircommentpair 匹配成功,comment 匹配失败。这没什么大不了的。但是由于 token block 中存在一个 * 量c词符,所以它再次尝试匹配 paircomment。都没有成功,但 token block 的整体匹配还是成功了。

一个很好的方法是安装 Grammar::Tracer 模块(zef install Grammar::Tracer),并在 grammar 定义前添加语句 use Grammar::Tracer。这样就会产生调试输出,显示哪些规则匹配,哪些不匹配:

TOP
|  block
|  |  pair
|  |  |  key
|  |  |  * MATCH "key"
|  |  |  ws
|  |  |  * MATCH ""
|  |  |  ws
|  |  |  * MATCH ""
|  |  |  value
|  |  |  * MATCH "value"
|  |  |  ws
|  |  |  * MATCH ""
|  |  |  ws
|  |  |  * MATCH ""
|  |  * MATCH "key=value\n"
|  |  pair
|  |  |  key
|  |  |  * FAIL
|  |  * FAIL
|  |  comment
|  |  * FAIL
|  * MATCH "key=value\n"
|  section
...

9.7.2. 检测有害失败

为了产生良好的解析错误信息,你必须区分预期的和意外的解析失败。正如前文所解释的那样,单一的 regex 或 token 的匹配失败一般不代表输入有问题。但是你可以识别出一些点,你知道一旦 regex 引擎走到这一步,其余的匹配一定会成功。

如果你回想一下 pair:

rule pair { <key> '=' <value> \n+ }

我们知道,如果一个键被解析了,我们真的希望下一个字符是等号。如果不是,那么输入是畸形的。

在代码中,这个写法是这样的:

rule pair {
    <key>
    [ '=' || <expect('=')> ]
    <value> \n+
}

|| 是一个顺序的替代方案,它首先尝试匹配左手边的子正则表达式,如果失败,则只执行右手边的子正则表达式。

所以现在我们要定义 expect:

method expect($what) {
    die "Cannot parse input as INI file: Expected $what";
}

是的,你可以像调用正则表达式一样调用方法,因为在底层, 正则表达式真的是方法。die 会抛出一个异常,所以现在错误的输入 justakey 会产生错误:

Cannot parse input as INI file: Expected =

后面还有一个回溯。这已经比"无效语法"要好,虽然位置还是缺失了。在方法 expect 里面,我们可以通过方法 pos 找到当前的解析位置,这个位置是由 `grammar ` 声明符带来的隐式父类 Grammar[59] 提供的。

我们可以用它来改善一下错误信息:

method expect($what) {
    die "Cannot parse input as INI file: Expected $what at character
    {self.pos}";
}

9.7.3. 提供上下文

对于较大的输入,我们真的要打印行号。要计算这个,我们需要掌握目标字符串,通过方法 target 可以获得目标字符串:

method expect($what) {
    my $parsed-so-far = self.target.substr(0, self.pos);
    my @lines = $parsed-so-far.lines;
    die "Cannot parse input as INI file: Expected $what at line
    @lines.elems(), after '@lines[*-1]'";
}

这使我们从错误信息的"meh"领域进入了相当好的领域。因此:

IniFile.parse(q:to/EOI/);
key=value
[section]
key_without_value
more=key
EOI

现在死于:

Cannot parse input as INI file: Expected = at line 3, after
'key_without_value'

你可以通过在解析失败的位置前后提供上下文来进一步完善 expect 方法。当然,你还需要在 regex 中更多的地方应用 [ thing || <expect('thing')> ] 模式来获得更好的错误信息。

最后,你还可以提供不同类型的错误信息。例如,当解析一个 section header 时,一旦解析了初始的 [ ,你可能不希望出现错误信息是 "expected rest of section header",而是 "malformed section header, at line …​"。

rule pair {
    <key>
    [ '=' || <expect('=')> ]
    [ <value> || <expect('value')>]
    \n+
}
token header {
    '['
     [ ( <-[ \[ \] \n ]>+ )    ']'
         || <error("malformed section header")> ]
}
...
method expect($what) {
    self.error("expected $what");
}

method error($msg) {
    my $parsed-so-far = self.target.substr(0, self.pos);
    my @lines = $parsed-so-far.lines;
    die "Cannot parse input as INI file: $msg at line @lines.
    elems(), after '@lines[*-1]'";
}

由于 Raku 使用 grammar 来解析 Raku 的输入,所以你可以用 Rakudo 自己的 grammar[60] 作为灵感的来源,让更多的方法让错误报告变得更好。

9.7.4. 解析配对配对的捷径

由于这是一个很常见的任务,所以 Raku 的 grammar 有一个特殊的目标匹配语法,用于匹配一对分隔符之间的东西。在 INI 文件的例子中,就是一对括号,它们之间有一个 section 标题。

我们可以把

token header { '[' ( <-[ \[ \] \n ]>+ ) ']' \n+ }

更改为:

token header { '[' ~ ']' ( <-[ \[ \] \n ]>+ ) \n+ }

这不仅在美学上有一个好处,那就是把匹配的分界符放在更近的地方,而且如果除了闭合分界符匹配之外的所有东西都匹配的话,它还为我们调用了一个方法 FAILGOAL。我们可以用这个方法来生成更好的错误信息,以处理匹配对的解析失败:

method FAILGOAL($goal) {
    my $cleaned-goal = $goal.trim;
    $cleaned-goal = $0 if $goal ~~ / \' (.+) \' /;
    self.error("Cannot find closing $cleaned-goal");
}

传给 FAILGOAL 的参数是正则表达式源码的字符串,这个字符串不能匹配闭合分隔符,这里是 ']'(带尾部空格)。我们希望从中提取出错误信息的字面意义的 ] ,因此在方法中间的 regex 匹配。如果这个 regex 匹配成功,那么这个字面符号就在 $/[0] 中,$0 是一个快捷方式。

所有使用 ~ 的解析构造都可以从这样的方法 FAILGOAL 中受益,所以在解析几个不同的引号或括号构造的 grammar 中,写一个方法是值得的。

9.8. 编写你自己的 Grammar

解析是一项必须学习的技能,主要是和你的普通编程技能分开来的。所以我鼓励你从一些小的东西开始,比如 CSV 解析器,或者逗号分隔的值。[61]

即使是像 CSV 这样简单的东西,也会有一些复杂性。例如,你可以允许引号字符串本身就可以包含分隔符,以及允许在引号字符串中使用引号字符的转义字符。

如果你对更深层次的 regexes 有兴趣,我强烈推荐 Jeffrey E. F. Friedl 的《精通正则表达式》(O’Reilly Media, 2008)。这本书并没有处理 Raku 中的正则表达式,但其中的概念可以很好地转化到 Raku 的正则表达式中。

9.9. 总结

Raku 允许将 regex 重用,把它们当作一级公民,允许像普通例程一样命名和调用它们。通过允许在 regex 中使用空格,进一步消除了混乱。

这些功能让你可以写出正则表达式来解析正确的文件格式,甚至是编程语言。Grammar 让你可以结构化、重用和封装正则表达式。

正则表达式匹配的结果是一个匹配对象,它实际上是一棵树,每个命名的子匹配和每个捕获组都有节点。action 方法使解析和数据提取很容易解耦。

为了从解析器中生成良好的错误信息,需要区分预期的匹配失败和意外的匹配失败。顺序替换 || 是一个工具,你可以通过从替换的第二个分支中提出一个异常来将意外匹配失败变成错误消息。

10. 文件和目录使用状态图

你刚买了一个闪亮的 2TB 新盘不久,就已经收到磁盘空间不足的警告。是什么占据了这么多的空间?

为了回答这个问题,并尝试一下数据可视化,让我们写一个小工具,可视化哪些文件占用了多少磁盘空间。在这个过程中,我们还可以探索一些函数式编程的概念。

10.1. 读取文件大小

为了可视化文件的使用情况,我们必须首先递归地读取给定目录中的所有目录和文件,并记录它们的大小。为了得到一个目录中所有元素的列表,我们可以使用 dir 函数,它返回一个 IO::Path 对象的懒惰列表。

我们区分目录和文件,前者可以有子项,后者不能。两者都可以有一个直接的大小,对于目录来说,也可以有一个总的大小,其中包括文件和子目录,递归。

class File {
    has $.name;
    has $.size;
    method total-size() { $.size }
}

class Directory {
    has $.name;
    has $.size;
    has @.children;
    has $!total-size;
    method total-size() {
        $!total-size //= $.size + @.children.map({.total-size}).sum;
    }
}

sub tree(IO::Path $path) {
    if $path.d {
        return Directory.new(
            name     => $path.basename,
            size     => $path.s,
            children => dir($path).map(&tree),
        );
    }
    else {
        return File.new(
            name => $path.Str,
            size => $path.s,
        );
    }
}

递归读取文件树的代码使用了 IO::Path 上的 d 和 s 方法。d 对目录返回 True,对文件返回 False,s 返回大小。

为了检查我们是否有一个合理的数据结构,我们可以写一个简短的例程来递归地打印它,用缩进来表示目录项的嵌套。

sub print-tree($tree, Int $indent = 0) {
    say ' ' x $indent, format-size($tree.total-size), ' ',
    $tree.name;

    if $tree ~~ Directory {
        print-tree($_, $indent + 2) for $tree.children;
    }
}

sub format-size(Int $bytes) {
    my @units = flat '', <k M G T P>;
    my @steps = (1, { $_ * 1024 } ... *).head(6);
    for @steps.kv -> $idx, $step {
        my $in-unit = $bytes / $step;
        if $in-unit < 1024 {
            return sprintf '%.1f%s', $in-unit, @units[$idx];
        }
    }
}

sub MAIN($dir = '.') {
    print-tree(tree($dir.IO));
}

print-tree 这个子程序非常无聊,如果你习惯于递归的话.它打印出当前节点的名称和大小,如果当前节点是一个目录,则递归到每个子节点,并增加缩进。缩进是通过x字符串重复操作符来应用的,当调用为 $string x $count时,会重复 $string $count 次数。它使用 ~~ 智能匹配操作符来执行类型检查;它测试 $tree 是否是一个 Directory。

为了得到一个人类可读的数字大小表示,format- size知道一个由六个单位组成的列表:空字符串表示1,k(千)表示1024,M(兆)表示1024×1024,等等。这个列表存储在数组 @units 中。与每个单位相关联的倍数存储在 @steps 中,它是通过系列运算符…​.,初始化的。其结构为 INITIAL,CALLABLE…​。LIMIT,其中它首先将 CALLABLE 应用于初始值,然后应用于下一个生成的值,以此类推,直到碰到 LIMIT。这里的 limit 是 *,是一个特殊的名词,叫 Whatever,意思是它是无限的。因此,序列操作符返回的是一个懒惰的、可能是无限的列表,而后面的 .head(6) 调用则将其限制为六个值。

为了找到最合适的单位来打印大小,我们必须对数组的值和索引进行迭代,对于 @steps.kv → $idx, $step { .sprintf },从其他编程语言中得知,实际的格式化工作是在点后的一位数字上进行,并附加单位。

10.2. 生成树状图

文件和目录大小的一种可能的可视化是树状图,它将每个目录表示为一个矩形,而目录内的每个文件则表示为目录矩形内的一个矩形。每个矩形的大小与它所代表的文件或目录的大小成正比。

我们将生成一个包含所有这些矩形的 SVG 文件。现代的浏览器支持显示这样的文件,并且还能显示每个矩形的鼠标移动文本。这就减轻了实际标注矩形的负担,这可能是相当麻烦的。

为了生成 SVG,我们将使用 SVG 模块,你可以通过安装:

$ zef install SVG

这个模块提供了一个单一的静态方法,您可以将嵌套的对传递到这个方法中。其值为数组的对被转化为 XML 标签;其他对被转化为属性。作为一个例子,这个 Raku 脚本:

use SVG;
print SVG.serialize(
    :svg[
        width => 100,
        height => 20,
        title => [
            'example',
        ]
    ],
);

产生这种输出:

<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100"
height="20"> <title>example</title>
</svg>

(没有缩进)。xmlns-tags 是由 SVG 模块帮助添加的,是程序识别 SVG 文件的必要条件。

回到树状图(图10-1),一个非常简单的方法是将矩形重新划分为区域,并对每个区域进行水平或垂直的细分,这取决于哪个轴更长。

sub tree-map($tree, :$x1!, :$x2!, :$y1!, :$y2) {
    # do not produce rectangles for small files/dirs
    return if ($x2 - $x1) * ($y2 - $y1) < 20;

    # produce a rectangle for the current file or dir

    take 'rect' => [
        x => $x1,
        y => $y1,
        width  => $x2 - $x1,
        height => $y2 - $y1,
        style  => "fill:" ~ random-color(),
        title  => [$tree.name],
    ];
    return if $tree ~~ File;

    if $x2 - $x1 > $y2 - $y1 {
        # split along the x-axis
        my $base = ($x2 - $x1) / $tree.total-size;
        my $new-x = $x1;
        for $tree.children -> $child {
            my $increment = $base * $child.total-size;
            tree-map(
                $child,
                x1 => $new-x,
                x2 => $new-x + $increment,
                :$y1,
                :$y2,
            );
            $new-x += $increment;
        }
    }
    else {
        # split along the y-axis
        my $base = ($y2 - $y1) / $tree.total-size;
        my $new-y = $y1;
        for $tree.children -> $child {
            my $increment = $base * $child.total-size;
            tree-map(
                $child,
                :$x1,
                :$x2,
                y1 => $new-y,
                y2 => $new-y + $increment,
            );
            $new-y += $increment;
         }
    }
}

sub random-color {
    return 'rgb(' ~ (1..3).map({ (^256).pick }).join(',') ~ ')';
}

sub MAIN($dir = '.') {
    my $tree = tree($dir.IO); use SVG;
    my $width = 1024;
    my $height = 768;
    say SVG.serialize(
        :svg[
            :$width,
            :$height,
            | gather tree-map $tree, x1 => 0, x2 => $width, y1 => 0,
            y2 => $height
        ]
    );
}

生成的文件并不漂亮,由于颜色随机,而且由于一些文件被识别为很窄的矩形。但它确实可以明显地看出,在一个目录中,有一些大文件和许多大多是小文件(恰好是一个仓库的 .git 目录)。在浏览器中查看一个文件,鼠标移过去就会显示出文件的名称。

我们是怎么生成这个文件的?

子树状图调用 take 是为了将元素添加到结果列表中,所以必须在 gather 语句的上下文中调用它,gather { take 1; take 2 } 返回一个由1,2两个元素组成的懒惰列表。但是 take 的调用不一定要发生在 gather 的词法作用域中,它们可以在任何从 gather 直接或间接调用的代码中。我们称之为动态范围。

其余的子树状图主要是直接的。对于每个可以分割剩余矩形的方向,我们计算一个基本单位,表示一个字节应该占据多少像素。这用于将当前的画布分割成更小的画布,并使用这些画布来递归到树状图中。

随机颜色生成使用 ˆ256 创建一个从0到256的范围(排他性),然后 .pick 从这个范围返回一个随机元素。结果是一个随机的 CSS 颜色字符串,如 rgb(120,240,5)。

在子 MAIN 中,gather 返回一个列表,它通常会嵌套在外部数组中。在 gather 之前的管道符号 | 可以防止正常的嵌套,并将列表扁平化到外部数组中。

10.3. 火焰图

前面生成的树状图的缺点是,人脑不善于比较不同长宽比的矩形的大小,特别是当它们的宽度与高度相差很大时(即非常高或非常平的矩形)。火焰图通过将文件大小显示为水平条来防止这种认知错误。垂直排列表示目录和文件在其他目录内的嵌套。缺点是用于可视化文件大小的可用空间较少。

生成火焰图比树状图更容易,因为你只需要在一个方向上进行细分,而每个条形图的高度是固定的。这里设置为15像素。

sub flame-graph($tree, :$x1!, :$x2!, :$y!, :$height!) {
    return if $y >= $height;
    take 'rect' => [
        x => $x1,
        y => $y,
        width => $x2 - $x1,
        height => 15,
        style => "fill:" ~ random-color(),
        title => [$tree.name ~ ', ' ~ format-size($tree.total-size)],
    ];

    return if $tree ~~ File;

    my $base = ($x2 - $x1) / $tree.total-size;
    my $new-x = $x1;

    for $tree.children -> $child {
        my $increment = $base * $child.total-size;
        flame-graph(
            $child,
            x1 => $new-x,
            x2 => $new-x + $increment,
            y => $y + 15,
            :$height,
        );
        $new-x += $increment;
    }
}

我们可以在 sub MAIN 中添加一个开关,根据命令行选项,调用树状图或火焰图。

sub MAIN($dir = '.', :$type="flame") {
    my $tree = tree($dir.IO);
    use SVG;
    my $width = 1024;
    my $height = 768;
    my &grapher = $type eq 'flame'
            ?? { flame-graph $tree, x1 => 0, x2 => $width, y => 0, :$height }
            !! { tree-map    $tree, x1 => 0, x2 => $width, y1 => 0, y2 => $height }
    say SVG.serialize(
        :svg[
            :$width,
            :$height,
            | gather grapher()
        ]
    );
}

由于 SVG 的坐标系将垂直轴的零点置于顶部,这实际上产生了一个倒置的火焰图,有时也被称为冰柱图(图10-2)。

图10-2. 倒置火焰图,其中每个条形图的宽度代表文件/目录的大小,垂直位置代表目录内的嵌套。

这张图是通过调用 dirstat --type=flame src/rakubook/ 生成的。

10.4. 功能重构

在生成树状图和火焰图的代码中,有一个模式出现了三次:根据树中与该区域相关的文件和目录的大小来划分区域。

把这样的常用代码提取到函数中是个好主意,但由于循环里面有自定义的代码是常用代码的一部分,所以稍有阻碍。函数式编程提供了一个解决方案:把自定义代码放在一个单独的函数里面,让通用代码来调用它。

将这种技术应用到树状图火焰图中,就会变成这样。

sub subdivide($tree, $lower, $upper, &todo) {
    my $base = ($upper - $lower ) / $tree.total-size;
    my $var = $lower;
    for $tree.children -> $child {
        my $incremented = $var + $base * $child.total-size;
        todo($child, $var, $incremented);
        $var = $incremented,
    }
}

sub flame-graph($tree, :$x1!, :$x2!, :$y!, :$height!) {
    return if $y >= $height;
    take 'rect' => [
        x => $x1,
        y => $y,
        width  => $x2 - $x1,
        height => 15,
        style => "fill:" ~ random-color(),
        title => [$tree.name ~ ', ' ~ format-size($tree.total-size)],
    ];
    return if $tree ~~ File;
    subdivide( $tree, $x1, $x2, -> $child, $x1, $x2 {
        flame-graph( $child, :$x1, :$x2, :y($y + 15), :$height );
    });
}

sub tree-map($tree, :$x1!, :$x2!, :$y1!, :$y2) {
    return if ($x2 - $x1) * ($y2 - $y1) < 20;
    take 'rect' => [
        x => $x1,
        y => $y1,
        width  => $x2 - $x1,
        height => $y2 - $y1,
        style  => "fill:" ~ random-color(),
        title  => [$tree.name],
    ];
    return if $tree ~~ File;

    if $x2 - $x1 > $y2 - $y1 {
        # split along the x-axis
        subdivide $tree, $x1, $x2, -> $child, $x1, $x2 {
            tree-map $child, :$x1, :$x2, :$y1, :$y2;
        }
    }
    else {
        # split along the y-axis
        subdivide $tree, $y1, $y2, -> $child, $y1, $y2 {
            tree-map $child, :$x1, :$x2, :$y1, :$y2;
        }
    }
}

新引入的子程序 subdivide 需要一个目录树、一个起点和终点,最后是一个代码对象 &todo。对于目录树的每一个子程序,它都会计算出新的坐标,然后调用 &todo 函数。

子程序 flame-graph 中的用法是这样的。

subdivide( $tree, $x1, $x2, -> $child, $x1, $x2 {
    flame-graph( $child, :$x1, :$x2, :y($y + 15), :$height );
});

被传递给 subdivide 的代码对象以 开头,它引入了一个代码块的签名。该代码块递归到 flame-graph 中,增加了一些额外的参数,并将两个位置参数变成了命名参数在途中。

这种重构缩短了代码,并使其整体上更令人愉悦来工作。但树状图和火焰图之间还是有不少重复的地方:两者都有一个初始终止条件,一个矩形的取值,然后再调用一两个函数来进行细分。如果我们愿意把所有的小差异放到小的、独立的函数中,我们可以进一步统一它。

如果我们要把所有这些新函数作为参数传给每个调用,我们会创建一个令人不快的长参数列表。相反,我们可以用这些函数来生成前面的函数 flame-graph 和 tree-map。

sub svg-tree-gen(:&terminate!, :&base-height!, :&subdivide-x!, :&other!) {
    sub inner($tree, :$x1!, :$x2!, :$y1!, :$y2!) {
        return if terminate(:$x1, :$x2, :$y1, :$y2);
        take 'rect' => [
            x => $x1,
            y => $y1,
            width  => $x2 - $x1,
            height => base-height(:$y1, :$y2),
            style  => "fill:" ~ random-color(),
            title  => [$tree.name ~ ', ' ~ format-size($tree.total-size)],
        ];
        return if $tree ~~ File;
        if subdivide-x(:$x1, :$y1, :$x2, :$y2) {
            # split along the x-axis
            subdivide $tree, $x1, $x2, -> $child, $x1, $x2 {
                inner($child, :$x1, :$x2, :y1(other($y1)), :$y2);
            }
        }

        else {
            # split along the y-axis
            subdivide $tree, $y1, $y2, -> $child, $y1, $y2 {
                inner($child, :x1(other($x1)), :$x2, :$y1, :$y2);
            }
        }
    }
}

my &flame-graph = svg-tree-gen
    terminate => -> :$y1, :$y2, | { $y1 > $y2 },
    base-height => -> | { 15 },
    subdivide-x => -> | { True },
    other => -> $y1 { $y1 + 15 },
    ;

my &tree-map = svg-tree-gen
    terminate => -> :$x1, :$y1, :$x2, :$y2 { ($x2 - $x1) * ($y2 - $y1) < 20 },
    base-height => -> :$y1, :$y2 { $y2 - $y1 },
    subdivide-x => -> :$x1, :$x2, :$y1, :$y2 { $x2 - $x1 > $y2 - $y1 },
    other => -> $a { $a },
    ;

现在我们有一个新的函数 svg-tree-gen,它可以返回一个函数。返回函数的行为取决于 svg-tree-gen 收到的四个小函数作为参数。

第一个参数 terminate 决定了内部函数在什么情况下应该提前终止。对于树状图来说,就是当区域低于 20 像素时;对于火焰图来说,就是当当前的 y 坐标 $y1 超过整个图像的高度(存储在 $y2 中)时。svg-tree-gen 总是使用四个命名的参数 x1、x2、y1 和 y2 来调用这个函数,所以 terminate 函数必须忽略 x1 和 x2 的值。它通过添加 | 作为参数来实现这一点,这是一个匿名捕获。这样的参数可以绑定任意的位置参数和命名参数,由于是匿名参数,所以会丢弃所有的值。

第二个配置函数 base-height,确定矩形在基本情况下的高度。对于 flame-graph 来说,它是一个常数,所以配置函数必须丢弃所有的参数,同样用一个 |。对于树状图,它必须返回 $y2 和 $y1 之间的差值,就像重构前一样。

第三个函数决定何时沿 x 轴进行细分。火焰图总是沿 x 轴划分,所以 → | { True } 实现了这一点。我们对树形图的简单方法是沿着较长的轴划分,所以只有当 $x2 - $x1 > $y2 - $y1 时才沿着x轴划分。

第四个也是最后一个函数我们传递给 svg-tree-gen 计算没有被分割的轴的坐标。在 flame-graph 的情况下,那就是在之前的数值上增加条形图的高度,而对于 tree-map 来说,那就是不变的坐标。 所以我们传递身份函数 → $a { $a }

内部函数只需要一个名字,因为我们需要从自身递归调用它;否则一个匿名函数 sub ($tree, :$x1!, :$x2!, :$y1! } 就可以了。

这个重构也统一了 flame-graph 和 tree-map 的参数名称(之前 tree-map 有 :$y2,flame-graph 有 :$height),所以现在调用可以简化为:

my &grapher = $type eq 'flame' ?? &flame-graph !! &tree-map;
say SVG.serialize(
    :svg[
        :$width,
        :$height,
        | do gather grapher $tree, x1 => 0, x2 => $width, y1 => 0, y2 => $height
    ]
);

现在我们已经有了非常紧凑的火焰图和树状图的定义,现在是玩一些参数的好时机。让我们在火焰图中引入一点余量,让其他的增量大于基准高度中的条形高度。

my &flame-graph = svg-tree-gen
    base-height => -> | { 15 },
    other => -> $y1 { $y1 + 16 },
    # rest as before

另一个需要转动的旋钮是将颜色生成改为更确定的东西,并使其可从外部配置。

sub svg-tree-gen(
    :&color=&random-color,
    :&terminate!,
    :&base- height!,
    :&subdivide-x!,
    :&other!) {
    sub inner($tree, :$x1!, :$x2!, :$y1!, :$y2!) {
        return if terminate(:$x1, :$x2, :$y1, :$y2);
        take 'rect' => [
            x => $x1,
            y => $y1,
            width  => $x2 - $x1,
            height => base-height(:$y1, :$y2),
            style  => "fill:" ~ color(),
            title  => [$tree.name ~ ', ' ~ format-size($tree.total-size)],
    }
];
# rest as before
}

例如,我们可以在颜色生成器中保持状态,并在每次迭代中返回稍微不同的颜色。

sub color-range(|) {
    state ($r, $g, $b) = (0, 240, 120);
    $r = ($r + 5) % 256;
    $g = ($g + 10) % 256;
    $b = ($b + 15) % 256;
    return "rgb($r,$g,$b)";
}

状态变量在对同一子程序的调用之间保持其值,它们的初始化只在第一次调用时运行。因此这个函数在每次调用时都会略微增加每个颜色通道的亮度,除了当它达到256时,模数运算符 % 会将它重置回一个小值。

如果我们通过将 color ⇒ &color-range 传递给调用 svg-tree-gen 的函数来将其插入到我们的函数中,我们就可以得到看起来不那么混乱的输出(图10-3和10-4)。

图10-3. 带有确定性颜色生成的树形图

图10-4. 带有确定性颜色生成的火焰图,条形图之间有一个像素的余量。

我们也可以将坐标传递给 &color 例程,这样就可以写一个颜色生成器,生成一个漂亮的渐变。

10.5. 更多的功能编程语言支持

正如你在前面的例子中所看到的,函数式编程通常需要编写大量的小函数。Raku 有一些语言特性,使得编写这种小函数非常容易。

一个常见的任务是编写一个函数,在其参数上调用一个特定的方法,正如我们在这里看到的那样。

method total-size() {
    $!total-size //= $.size + @.children.map({.total-size}).sum;
    #                                        ^^^^^^^^^^^^^
}

可简写为 *.total-size

method total-size() {
    $!total-size //= $.size + @.children.map(*.total-size).sum;
}

这也适用于方法调用链,所以如果 total-size 返回一个小数,你可以写 @.children.map(*.total-size.round),而你想在结果上调用 .round 方法。

还有更多的情况下,你可以用 "Whatever"星号()替换一个表达式来创建一个小函数。要创建一个在其参数上加15的函数,你可以写 + 15 而不是 → $a { $a + 15 }

如果你需要写一个函数,只是调用另一个函数,但传递更多的参数给第二个函数,你可以使用假设的方法。

例如 → $x { f(42, $x } 可以用 &f.assuming(42) 代替。这个方法也适用于命名参数,所以 → $x { f($x, height ⇒ 42 ) } 可以用 &f.assuming(height ⇒ 42) 代替。

10.6. 更多改进

File 和 Directory 这两个类有一些共同的功能,比如大小和名称属性,以及它们都有一个叫做 total-size 的方法。将类的共同行为因子化的一个好方法是将共同行为放到一个角色中。

role Path {
    has $.name;
    has $.size;
    method total-size() { ... }
}

class File does Path {
    method total-size() { $.size }
}

class Directory does Path {
    has @.children;
    has $!total-size;
    method total-size() {
        $!total-size //= $.size + @.children.map(*.total-size).sum;
    }
}

角色在结构上与类相似,在类声明中使用 do 关键字将角色应用到类中。这个角色应用将属性和方法复制到目标类中,但是需要一些额外的编译时检查。其中一个检查是,一个类必须实现像方法 total-size 这样的存根方法,其中的 …​ 作为方法体将其标记为存根。此外,当你将多个角色应用到同一个类中时,除非你在类中实现方法进行歧义,否则会检测到名称冲突并抛出一个错误。

在 Raku 中,角色是代码重用的首选方法(除了授权),因为前面提到的安全特性。

现在 File 和 Directory 有一个共同的角色,你可以使用这个角色作为期待这些类型之一的子程序的类型约束,比如 sub subdivide(Path $tree, $lower, $upper, &todo)

最后,sub MAIN 的类型参数可以有两种可能的值:火焰图的火焰或树图的树。模拟这种行为的数据结构是一个枚举或枚举。

enum GraphType <flame tree>;

sub MAIN($dir = '.', GraphType :$type=flame) {
    my $tree = tree($dir.IO);
    use SVG;
    my $width = 1024;
    my $height = 768;
    my &grapher = $type == flame ?? &flame-graph !! &tree-map;
    say SVG.serialize(
        :svg[
            :$width,
            :$height,
            | do gather grapher $tree, x1 => 0, x2 => $width, y1 => 0, y2 => $height
        ]
    );
}

枚举的值是从零开始的整数,因此用 == 代替 eq 进行比较。你可以通过短标识符(flame)或者通过枚举类型的命名空间 GraphType::flame 来访问一个枚举的可能值。

现在,如果你从脚本中获得帮助信息(通过使用 --help 选项运行它),类型参数会自动被记录下来:--type=<GraphType>(火焰树)。

10.7. 探索!

为了熟悉函数式编程的概念,我鼓励你查看你目前所写的代码,并将近乎重复的代码块重构为一个共同的基础,并将不同的代码换成回调。

更重要的是,尽量找到有意义的抽象。在可视化的例子中,底层原则是分而治之,5你能想出一个通用的分而治之的实现,还能用得上吗?

回想树状图和火焰图,也许你可以把矩形的尺寸逻辑和放置矩形的逻辑分开?

10.8. 总结

函数式编程提供了将普通逻辑提取到独立函数中的技术。所需的行为差异可以被编码在更多的函数中,您可以将这些函数作为参数传递给其他函数。

Raku 支持函数式编程,使函数成为第一类,所以你可以把它们作为普通对象来传递。它还提供了闭包(从函数中访问外部词法变量)和各种快捷方式,使得编写短函数更加愉快。

11. Unicode 搜索工具

每隔一段时间,我都要对一些 Unicode 字符进行识别或研究。在 Perl 5 发行版 App:::Uni[62] 中有一个叫 uni[63] 的工具,由 Audrey Tang 和 Ricardo Signes 开发。

让我们用几行 Raku 代码来重新实现它的基本功能,并以此为契机来谈谈 Raku 中对 Unicode 的支持。

如果你在命令行中给它一个字符,它就会打印出下面的字符描述。

$ uni њ
њ - U+0045a - CYRILLIC SMALL LETTER NJE

如果你给它一个较长的字符串,它将在 Unicode 字符名列表中搜索,并为每个描述与搜索字符串匹配的字符打印出相同的信息。

$ uni third|head -n3
1⁄3 - U+02153 - VULGAR FRACTION ONE THIRD
2⁄3 - U+02154 - VULGAR FRACTION TWO THIRDS
↉  - U+02189 - VULGAR FRACTION ZERO THIRDS

每一行都对应于 Unicode 所说的 "代码点",通常是一个字符本身,但偶尔也会有像 U+00300-COMBINING GRAVE ACCENT 这样的字符,它与一个 U+00061-LATIN SMALL LETTER A 结合起来,就形成了字符 à。

Raku 在 Str 和 Int 类中都提供了一个 uniname 方法,它可以为给定的字符生成 Unicode 代码点名称,可以是直接的字符形式,也可以是代码点编号的形式。 有了这个,第一部分 uni 所需的功能看起来是这样的:

use v6;
sub format-codepoint(Int $codepoint) {
    sprintf "%s - U+%05x - %s\n",
        $codepoint.chr,
        $codepoint,
        $codepoint.uniname;
}

multi sub MAIN(Str $x where .chars == 1) {
    print format-codepoint($x.ord);
}

让我们来看看实际操作:

$ uni ø
ø - U+000f8 - LATIN SMALL LETTER O WITH STROKE

chr 方法将代码点编号转化为字符,ord 方法则相反:换句话说,从字符到代码点编号。

第二部分,在所有 Unicode 字符名中进行搜索,其工作原理是通过粗暴地枚举所有可能的字符,并通过它们的 uniname 进行搜索:

multi sub MAIN($search is copy) {
    $search.=uc;
    for 1..0x10FFFF -> $codepoint {
        if $codepoint.uniname.contains($search) {
            print format-codepoint($codepoint);
        }
    }
}

因为所有的字符名都是大写的,所以搜索项首先用 $search.=uc 来转换为大写,也就是 $search = $search.uc 的简称。默认情况下,参数是只读的,这也是为什么它的声明在这里使用了 is copy 来防止这种情况。

我们也可以用一种更函数式的风格来表述它,而不是这种相当命令式的风格。我们可以把它看成是所有字符的列表,我们将其细化到我们感兴趣的字符,最后按照我们想要的方式格式化它们:

multi sub MAIN($search is copy) {
    $search.=uc;
    print (1..0x10FFFF).grep(*.uniname.contains($search))
                       .map(&format-codepoint)
                       .join;
}

为了更容易识别(而不是搜索)一个以上字符的字符串,一个显式选项可以帮助消除歧义:

multi sub MAIN($x, Bool :$identify!) {
    print $x.ords.map(&format-codepoint).join;
}

Str.ords 返回组成该字符串的代码点列表。有了这个多候选的子例程 MAIN,我们可以做这样的事情,例如:

$ uni --identify øre
ø - U+000f8 - LATIN SMALL LETTER O WITH STROKE
r - U+00072 - LATIN SMALL LETTER R
e - U+00065 - LATIN SMALL LETTER E

11.1. 代码点、字符集和字节

正如前面提到的,并非所有的代码点都是完全成熟的字符。或者换个说法,有些东西,我们在视觉上认为是一个字符,实际上是由几个代码点组成的。Unicode 将这种由一个基本字符和可能的几个组合字符组成的序列称为字符集。

Raku 中的字符串就是基于这些字符集。如果你用 $str.comb 得到一个字符串中的字符列表,或者用 $str.substr.substr(0,4) 提取一个子串,对一个字符串进行正则表达式匹配,确定长度,或者对一个字符串进行任何其他操作,那么这个单位总是字符集。这最符合我们对字符的直观理解,避免了通过 substrcomb 或类似的操作不小心撕开一个逻辑字符。

my $s = "ø\c[COMBINING TILDE]"; say $s; # Output: ø̃
say $s.chars; # Output: 1

Uni[64] 类型类似于字符串,代表了一个代码点的序列。它在边缘情况下很有用,但不支持与 Str[65] 一样丰富的操作。从 Str 转为 Uni 值的典型方法是使用 NFC、NFD、NFKC 或 NFKD 方法中的一种,这些方法会产生同名的归一化形式的 Uni 值。

在 Uni 值下面,你还可以通过选择一个编码来表示字符串作为字节。如果你想从字符串到字节级,可以调用 encode[66] 方法。

my $bytes = 'Raku'.encode('UTF-8'); # utf8:0x<50 65 72 6c 20 36>

UTF-8 是默认的编码,也是 Raku 读取源文件时假定的编码。其结果是做 Blob[67] 的作用:你可以通过位置索引访问单个字节,比如 $bytes[0]decode 方法[68]可以帮助你将 Blob 转换为 Str。

如果你用 say() 打印出一个 Blob,你会得到一个十六进制字节的字符串表示。访问单个字节会产生一个整数,因此通常会以十进制的形式打印出来。

如果你想打印出 blob 的原始字节,你可以使用 I/O 句柄的 write 方法:

$*OUT.write('Raku'.encode('UTF-8'));

11.2. 数字

Raku 中的数字字面值并不限于我们在英语国家习惯的阿拉伯数字。所有的 Unicode 代码点都允许使用 Decimal_Number(简称 Nd)属性,所以你可以使用东方阿拉伯数字[69]、或其他许多脚本中的数字:

say ٤٢; # 42

对于字符串与数字之间的转换也是如此:

say "٤٢".Int; # 42

对于其他的数字代码点,可以用 unival 方法获得其数值:

say "\c[TIBETAN DIGIT HALF ZERO]".unival;

它输出了 -0.5,同时也说明了如何在字符串字面值中使用代码点的名称。

11.3. 其它 Unicode 属性

类型 Str 中的 `uniprop`[70] 方法默认返回一般类别:

say "ø".uniprop;                           # Ll
say "\c[TIBETAN DIGIT HALF ZERO]".uniprop; # No

返回值需要一定的 Unicode 知识才能理解,或者可以阅读 Unicode 的技术报告44[71]来了解一下具体的细节。Ll 代表 Letter_Lowercase,No 是 Other_Number。这就是 Unicode 所说的 General Category,但是你也可以用 uniprop(或者 uniprop-bool 方法,如果你只对布尔结果感兴趣的话)询问其他属性:

say "a".uniprop-bool('ASCII_Hex_Digit'); # True
say "ü".uniprop-bool('Numeric_Type');    # False
say ".".uniprop("Word_Break");           # MidNumLet

11.4. 排序

当你不限于 ASCII 字符时,字符串的排序就开始变得复杂了。Raku 的排序方法使用 cmp 中缀运算符,它基于代码点编号做一个相当标准的按字母顺序的比较。

如果你需要使用更复杂的整理算法,Rakudo 2017.02 和更新版本提供了 Unicode 排序算法[72] 作为实验性功能。

my @list = <a ö ä Ä o ø>;
say @list.sort;                    # (a o Ä ä ö ø)

use experimental :collation;
say @list.collate;                 # (a ä Ä o ö ø)

$*COLLATION.set(:tertiary(False));
say @list.collate;                 # (a Ä ä o ö ø)

默认的 sort 认为任何带标点符号的字符都比 ASCII 字符大,因为它们在代码点列表中就是这样出现的。另一方面,collate 知道带标点符号的字符直接属于其基本字符之后,这在每一种语言中并不完美,但在内部是一个很好的折中方案。

对于基于拉丁文的脚本,主要的排序标准是字母表,其次的是变音符号,第三种是大小写。因此,$*COLLATION.set(:tertiary(False)) 使得 .collate 忽略了大小写,不再强制小写字符排在大写字符之前。

在写这篇文章的时候,Raku 还没有实现特定语言的排序。

11.5. 总结

Raku 非常重视英语以外的其他语言,并不遗余力地帮助用户使用这些语言和它们所使用的字符。

这包括将字符串建立在字符集而不是代码点上,支持数字中的非阿拉伯数字,以及通过内置方法访问 Unicode 数据库的大部分内容。

12. 使用 Inine-Python 和 Matplotlib 画图

偶尔会遇到 git 仓库,我想知道它们的活跃度以及主要开发者是谁。

让我们开发一个可以绘制提交历史的脚本,并探索如何在 Raku 中使用 Python 模块。

12.1. 提取状态

我们想根据作者和日期绘制提交次数。我们可以通过向 git log 传递一些选项来获取这些信息。

my $proc = run :out, <git log --date=short --pretty=format:%ad!%an>;
my (%total, %by-author, %dates);
for $proc.out.lines -> $line {
    my ( $date, $author ) = $line.split: '!', 2;
    %total{$author}++;
    %by-author{$author}{$date}++;
    %dates{$date}++;
}

run 执行一个外部命令,而 :out 则告诉它捕获命令的输出,使其成为 $proc.out。命令是一个列表,第一个元素是实际的可执行文件,其余是这个可执行文件的命令行参数。

这里 git log 得到的选项是 --date short --pretty=format:%ad!%an,它指示它产生像 2017-03-01!John Doe这样的行。这行可以通过简单的调用 $line.split 来解析:'!',2,它在 the! 上进行分割,并将结果限制为两个元素。然后,我们使用哈希值按作者(%total),按作者和日期(%by-author),最后按日期计算提交次数。在第二种情况下,%by-author{$author} 甚至还不是一个哈希值,我们仍然可以对它进行哈希索引。这要归功于一个叫做 autovivification 的特性,它能在我们需要的地方自动创建("vivifies")对象。使用 ++ 创建整数,{…​} 索引创建哈希,[…​] 索引,.push 创建数组,等等。

要想从这些哈希值中得到提交次数最多的贡献者,我们可以按值对 %total 进行排序。因为这是按升序排序的,所以按负值排序会按降序返回列表。列表中包含 Pair1 对象,我们只需要前五个对象,而且只需要它们的键。

my @top-authors = %total.sort(-*.value).head(5).map(*.key);

对于每个作者,我们可以像这样提取他们的活动日期和提交次数。

my @dates = %by-author{$author}.keys.sort;
my @counts = %by-author{$author}{@dates};

最后一行使用了 slicing,即用列表索引一个哈希,返回一个元素列表。

12.2. 用 Python 进行绘图

Matplotlib 是一个非常通用的库,适用于各种绘图和可视化任务。它基于 NumPy,一个用于科学和数值计算的 Python 库。

Matplotlib 是用 Python 编写的,而且是为 Python 程序编写的,但这并不妨碍我们在 Raku 程序中使用它。

但首先,让我们来看看一个在 x 轴上使用日期的基本绘图示例。

import datetime
import matplotlib.pyplot as plt
fig, subplots = plt.subplots()
subplots.plot(
    [datetime.date(2017, 1, 5), datetime.date(2017, 3, 5),
    datetime.date(2017, 5, 5)],
    [ 42, 23, 42 ],
    label='An example',
)

subplots.legend(loc='upper center', shadow=True)
fig.autofmt_xdate()
plt.show()

要运行这个程序,你必须安装 Python 2.7 和 matplotlib。你可以在基于 Debian 的 Linux 系统上用 apt-get install -y python-matplotlib 来完成。在基于 RPM 的发行版上,如 CentOS 或 SUSE Linux,软件包的名称是一样的。建议 MacOS 用户通过 homebrew 或 macports 安装 Python 2.7,然后使用 pip2 install matplotlibpip2.7 install matplotlib 来获取库。Windows 的安装可能是最简单的,通过 conda5 包管理器,它提供了 Python 和 matplotlib 的预建二进制文件。

当你用 python2.7 dates.py 运行这个脚本时,它将打开一个 GUI 窗口,显示绘图和一些控件,允许你缩放、滚动和将绘图图形写入一个文件 (图 12-1)。

12.3. 缩小差距

Rakudo Raku 编译器自带了一个方便的库,用于调用外来函数,即 "NativeCall",它允许你调用用C写的函数,或者任何具有兼容二进制接口的函数。

Inline::Python 库使用本地调用功能与 Python 的 C API 对话,并提供 Raku 和 Python 代码之间的互操作性。在写这篇文章的时候,这种互操作性在某些地方还是很脆弱的,但是对于 Python 所提供的一些优秀的库来说,还是值得使用的。

要安装 Inline::Python,你必须有一个可用的 C 编译器,然后运行

$ zef install Inline::Python

现在你可以开始在你的 Raku 程序中运行 Python 代码了。

use Inline::Python;

my $py = Inline::Python.new;
$py.run: 'print("Hello, Raku")';

除了运行方法,即接收一串 Python 代码并执行它之外,你还可以通过指定命名空间、要调用的例程和参数列表,使用 call 来调用 Python 例程。

use Inline::Python;

my $py = Inline::Python.new;
$py.run('import datetime');
my $date = $py.call('datetime', 'date', 2017, 1, 31);
$py.call('__builtin__', 'print', $date); # 2017-01-31

您传递给调用的参数是 Raku 对象,例如本例中的三个 Int 对象。Inline::Python 会自动将它们翻译成相应的 Python 内置数据结构。它可以翻译数字、字符串、数组和哈希。尽管由于 Python 2 没有正确区分字节和 Unicode 字符串,Python 字符串在 Raku 中最终会被翻译成缓冲区,但返回值也会被反方向翻译。

Inline::Python 不能翻译的对象在 Raku 端被处理成不透明的对象。你可以把它们传回 Python 例程中(如前面的 print 调用所示),并且你可以调用它们的方法。

say $date.isoformat().decode; # 2017-01-31

Raku 通过方法暴露属性,所以 Raku 没有直接从外部对象访问属性的语法。例如,如果你试图通过正常的方法调用语法来访问 datetime.date 的年份属性,你会得到一个错误。

say $date.year;

死于:

'int' object is not callable

相反,你必须使用内置的 getattr。

say $py.call('__builtin__', 'getattr', $date, 'year');

12.4. 使用桥接来画图

我们需要访问 Python 中的两个命名空间,datetime 和 matplotlib.pyplot,所以让我们从导入它们开始,并编写一些简短的帮助程序。

my $py = Inline::Python.new;
$py.run('import datetime');
$py.run('import matplotlib.pyplot');
sub plot(Str $name, |c) {
    $py.call('matplotlib.pyplot', $name, |c);
}

sub pydate(Str $d) {
    $py.call('datetime', 'date', $d.split('-').map(*.Int));
}

我们现在可以调用 pydate('2017-03-01') 从 ISO 格式化的字符串中创建一个 Python 的 datetime.date 对象,并调用 plotlib 的 plot 函数来访问 matplotlib 的功能。

my ($figure, $subplots) = plot('subplots');
$figure.autofmt_xdate();

my @dates = %dates.keys.sort;

$subplots.plot:
    $[@dates.map(&pydate)],
    $[ %dates{@dates} ],
    label     => 'Total',
    marker    => '.',
    linestyle => '';

Raku 调用 plot('subplots') 对应于 Python 代码 fig,subplots = plt.subplots()。将数组传递给 Python 函数需要一点额外的工作,因为 Inline::Python 将数组扁平化。在数组前面使用一个额外的 $ 标记会使它变成一个额外的标量,从而防止扁平化。

现在我们可以绘制作者的提交次数,添加一个图例,然后绘制结果。

for @top-authors -> $author {
    my @dates = %by-author{$author}.keys.sort;
    my @counts = %by-author{$author}{@dates};
    $subplots.plot:
        $[ @dates.map(&pydate) ],
        $@counts,
        label     => $author,
        marker    =>'.',
        linestyle => '';
}

$subplots.legend(loc=>'upper center', shadow=>True);
plot('title', 'Contributions per day');
plot('show');

当在 zef git 仓库中运行时,它产生的图如图12-2所示。

12.5. 堆叠图

我对这个图还不满意,所以我想探索使用叠加图来呈现同样的信息。在常规图中,每个绘图值的y坐标与其值成正比。在叠加图中,与前一个值的距离与其值成正比。这对于数值加起来的总和也很有意思。

Matplotlib 为这个任务提供了一个名为 stackplot 的方法。与子图对象上的多次绘图调用相反,它需要为所有数据系列提供一个共享的x轴。因此,我们必须为每个 git 提交的作者构造一个数组,其中没有值的日期被设置为0。

这次我们要构造一个数组,每个数组内部有一个作者的值。

my @dates = %dates.keys.sort;
my @stack = $[] xx @top-authors;

for @dates -> $d {
    for @top-authors.kv -> $idx, $author {
        @stack[$idx].push: %by-author{$author}{$d} // 0;
    }
}

现在,绘图变成了一个简单的方法调用,然后是添加标题和显示情节的常用命令。

$subplots.stackplot($[@dates.map(&pydate)], @stack);
plot('title', 'Contributions per day');
plot('show');

结果(再次在 zef 源码库上运行)如图12-3所示。

将其与之前的可视化比较,发现了一个差异:2014年没有提交,但堆叠图却让它看起来是这样的。事实上,如果我们选择线而不是点,之前的图也会显示同样的"另类事实"。这来自于 matplotlib(像几乎所有的绘图库一样)在数据点之间进行线性插值。但在我们的例子中,一个没有数据点的日期意味着该日没有发生任何提交。

为了将这一点传达给 matplotlib,我们必须明确地为缺失的日期插入零值。这可以通过替换:

my @dates = %dates.keys.sort;

用下面这行代码:

my @dates = %dates.keys.minmax;

minmax 方法 10 找到最小值和最大值,并以 Range 的形式返回。11 将 Range 分配给一个数组后,它就变成了一个包含最小值和最大值之间所有值的数组。汇编 @stack 变量的逻辑已经将缺失的值映射为零。

结果看起来好了一些,但还是远远不够完美(图12-4)。

进一步思考这个问题,不应该将不同日子的贡献连接在一起,因为这会产生误导性的结果。Matplotlib 不支持为堆叠图自动添加图例,所以这似乎是一个死胡同。

既然点阵图的效果不是很好,那我们就试试另一种分别表示每个数据点的图:条形图,或者更具体地说,堆叠条形图。Matplotlib 提供了条形图绘制方法,其中命名参数 bottom 可以用来生成叠加。

my @dates = %dates.keys.sort;
my @stack = $[] xx @top-authors;
my @bottom = $[] xx @top-authors;

for @dates -> $d {
    my $bottom = 0;
    for @top-authors.kv -> $idx, $author {
        @bottom[$idx].push: $bottom;
        my $value = %by-author{$author}{$d} // 0;
        @stack[$idx].push: $value;
        $bottom += $value;
    }
}

我们需要自己提供颜色名称,并将条形图的边缘颜色设置为相同的颜色,否则黑色的边缘颜色会主导结果。

my $width = 1.0;
my @colors = <red green blue yellow black>;
my @plots;

for @top-authors.kv -> $idx, $author {
    @plots.push: plot(
        'bar',
        $[@dates.map(&pydate)],
        @stack[$idx],
        $width,
        bottom => @bottom[$idx],
        color => @colors[$idx],
        edgecolor => @colors[$idx],
    );
}
plot('legend', $@plots, $@top-authors);

plot('title', 'Contributions per day');
plot('show');

如果你想进一步改善结果,你可以尝试通过按周或月(或者是 $n 天的时间段)将捐款集中在一起来限制条数。

12.6. Inline::Python 的惯用方法

现在,这些图看起来很有信息量,也很正确,现在是时候探索如何通过 Inline::Python 更好地模拟典型的 Python API 了。

12.6.1. Python API 的类型

Python 是一门面向对象的语言,所以许多 API 都涉及到方法调用,Inline::Python 会帮助我们自动翻译这些方法。

但是对象必须来自某个地方,通常是通过调用一个返回对象的函数,或者通过实例化一个类。在 Python 中,这两种方式在本质上是一样的,因为实例化一个类就像调用一个函数一样。

如果你想进一步改善结果,你可以尝试通过将贡献按周或月(或者可能是 $n 天的周期)归纳在一起来限制条数。

12.7. Inline::Python 的惯用方法

现在,这些图看起来很有信息量,也很正确,现在是时候探索如何通过 Inline::Python 更好地模拟典型的 Python API 了。

12.7.1. Python API 的类型

Python 是一门面向对象的语言,所以许多 API 都涉及到方法调用,Inline::Python 会帮助我们自动翻译这些方法。

但是对象必须来自某个地方,通常是通过调用一个返回对象的函数,或者通过实例化一个类。在 Python 中,这两种方式在本质上是一样的,因为实例化一个类就像调用一个函数一样。

这方面的一个例子 (在 Python 中) 是这样的

from matplotlib.pyplot
import subplots result = subplots()

但 matplotlib 文档往往使用另一种等价的语法。

import matplotlib.pyplot as plt
result = plt.subplots()

这使用子图符号(类或函数)作为模块 matplotlib.pyplot 上的方法,导入语句将其别名为 plt.这是一种更面向对象的语法,用于相同的 API。

12.7.2. 函数 API 的映射

前面的代码示例使用了这段 Perl 6 代码来调用 subplots 符号。

my $py = Inline::Python.new;

sub gen(Str $namespace, *@names) {
    $py.run("import $namespace");

    return @names.map: -> $name {
        sub (|args) {
            $py.call($namespace, $name, |args);
        }
    }
}

my (&subplots, &bar, &legend, &title, &show)
    = gen('matplotlib.pyplot', <subplots bar legend title show>);

my ($figure, $subplots) = subplots();

# more code here

legend($@plots, $@top-authors);
title('Contributions per day');
show();

这使得函数的使用相当不错,但代价是重复了它们的名字。我们可以把这看作是一个特性,因为它允许创建不同的别名,也可以看作是一个错误的来源,当顺序被打乱,或者名字拼写错误时。

如果我们选择创建包装函数,如何避免重复?

这就是 Raku 的灵活性和自省能力的作用。有两个关键部分可以让解决方案更加完善:声明是表达式的事实,以及你可以反省变量的名称。

第一部分意味着你可以写 mysub my ($a, $b),声明变量 $a 和 $b,并调用一个以这些变量为参数的函数。第二部分的意思是,$a.VAR.name 返回一个字符串 '$a',即变量的名称。

让我们结合这个来创建一个包装器,为我们初始化子程序。

sub pysub(Str $namespace, |args) {
    $py.run("import $namespace");

    for args[0] <-> $sub {
        my $name = $sub.VAR.name.substr(1);
        $sub = sub (|args) {
            $py.call($namespace, $name, |args);
        }
    }
}

pysub 'matplotlib.pyplot',
    my (&subplots, &bar, &legend, &title, &show);

这样就避免了名称的重复,但迫使我们在子 pysub 中使用一些低级的 Raku 功能。使用普通变量意味着访问它们的 .VAR.name 的结果是变量的名称,而不是在调用方使用的变量名称。所以,我们不能使用像在

sub pysub(Str $namespace, *@subs)

相反,我们必须使用 |args 来获取 Capture 中其余的参数。这并没有将传递给函数的变量列表扁平化,所以当我们在它们上面迭代时,我们必须通过访问 args[0] 来实现。默认情况下,循环变量是只读的,我们可以通过使用 <-> 而不是 -> 来引入签名来避免。幸运的是,这也保留了调用方变量的名称。

12.7.3. 面向对象的接口

我们可以不暴露函数,也可以创建模拟 Python 模块上方法调用的类型。为此,我们可以在一个类中实现一个方法 FALLBACK,当调用类中没有实现的方法时,Raku 会帮我们调用。

class PyPlot is Mu {
    has $.py;
    submethod TWEAK {
        $!py.run('import matplotlib.pyplot');
    }
    method FALLBACK($name, |args) {
        $!py.call('matplotlib.pyplot', $name, |args);
    }
}

my $pyplot = PyPlot.new(:$py);
my ($figure, $subplots) = $pyplot.subplots;
# plotting code goes here
$pyplot.legend($@plots, $@top-authors);

$pyplot.title('Contributions per day');
$pyplot.show;

类 PyPlot 直接继承自 Raku 类型层次结构的根 Mu,而不是默认的父类 Any(后者又继承自 Mu)。Any 引入了大量 Raku 对象默认得到的方法,由于 FALLBACK 只有在方法不存在时才会被调用,所以这是需要避免的。

TWEAK 这个方法是在对象完全实例化后,Raku 自动为我们调用的另一个方法。全大写的方法名是为了这种特殊用途而保留的。它被标记为子方法,这意味着它不会被继承到子类中。由于 TWEAK 是在每个类的层次上被调用的,如果它是一个普通的方法,一个子类会隐式地调用它两次。需要注意的是,TWEAK 只在 Rakudo 2016.11 及以后的版本中支持。

除了命名空间名称外,PyPlot 类中的 Python 包 matplotlib.pyplot 并没有什么特别之处。我们可以很容易地将其泛化到任何命名空间。

class PythonModule is Mu {
    has $.py;
    has $.namespace;
    submethod TWEAK {
        $!py.run("import $!namespace");
    }
    method FALLBACK($name, |args) {
        $!py.call($!namespace, $name, |args);
    }
}
my $pyplot = PythonModule.new(:$py, :namespace<matplotlib.pyplot>);

这是一个可以代表任何 Python 模块的 Raku 类型。如果我们想要为每个 Python 模块建立一个单独的 Raku 类型,我们可以使用 roles,它可以选择参数化。

role PythonModule[Str $namespace] is Mu {
    has $.py;
    submethod TWEAK {
        $!py.run("import $namespace");
    }
    method FALLBACK($name, |args) {
        $!py.call($namespace, $name, |args);
    }
}

my $pyplot = PythonModule['matplotlib.pyplot'].new(:$py);

使用这种方法,我们可以在 Raku 空间中为 Python 模块创建类型约束。

sub plot-histogram(PythonModule['matplotlib.pyplot'], @data) {
    # implementation here
}

传入除 matplotlib.pyplot 以外的任何封装的 Python 模块都会导致类型错误。

12.8. 总结

我们已经探索了几种在绘图中表示提交发生的方法,并利用 Inline::Python 与一个基于 Python 的绘图库接口。

通过 Raku 元编程,我们可以在 Raku 代码中直接模拟不同类型的 Python API,允许将原始库的文档直接翻译成 Raku 代码。

13. 接下来是什么

如果你读了这本书,你很可能现在已经对 Raku 的基本知识有了扎实的掌握。

本书的例子和讨论涉及到了各种各样的主题。我们从什么是 Raku 开始,以及如何运行 Raku 程序。接下来是 Raku 程序的基本词法结构、变量、控制流和 I/O。更高级的主题包括面向对象、持久化、正则表达式和 grammar、Unicode 支持、并发,最后是通过 Inline::Python 使用外部库。

但要写出成功的 Raku 代码,除了学习语言本身,还有更多的内容。在这最后一章中,我想暗示一些你可能想要学习的主题,以帮助你保持你的代码库的可维护性,并让它成功地呈现在用户面前。

13.1. 扩展你的代码库

当你的代码库增长时,通常建议你把它拆成单独的文件。你可以创建包含你的逻辑的模块,按命名空间和功能来组织。然后这些脚本往往会成为浅层次的入口,解析命令行参数,加载模块,然后调用一个函数或方法来完成实际工作。

在这种情况下,测试被写成单独的脚本,通常在一个名为 t 的目录下,加载和测试相同的模块。

https://docs.raku.org/language/modules 的官方文档中解释了如何编写模块,以及模块安装程序 zef(和其他工具)所依赖的标准目录布局和元数据。

随着你的代码库的增长,类型注解可以帮助你跟踪例程接受哪些参数,以及它们返回哪些参数。我倾向于在公共 API 的签名中使用类型注解。我所说的"公共"指的是可以从模块外访问的例程。在例程中,为了简洁和灵活,我倾向于省略它们。

为了使类型约束更可重用,你可以定义创建 subset 类型。例如,我们已经看到了一个临时类型约束的例子。

multi sub MAIN(Str $date where /^ \d+ \- \d+ \- \d+ $ /) { ... }

相反,你可以创建一个 subset 类型,并多次使用它:

subset DateStr of Str where /^ \d+ \- \d+ \- \d+ $ /;

multi sub MAIN(DateStr $date) { ... }
sub parse-date(DateStr) returns Date { ... }

你可以在一个模块中收集几个这样的类型,并在你需要的地方导入。

13.2. 打包你的应用

为了向用户部署你的应用程序,你通常会把它放在某种自带的存档或包中。

与所需的发布格式无关,起始点总是 zef 模块安装程序所使用的目录布局和元数据,在 https://docs.raku.org/language/modules 可以找到描述。

Raku 打包还在积极开发中,所以我想简单地提到一些你可能会发现值得探索的选项,而不是给出食谱。

13.2.1. 打包为传统的 Raku 模块

传统上,Raku 模块和软件是以 tar 文件[73]的形式发布的,其中包含源代码和一些元数据,如 META6.json 文件。

用户需要安装了 Raku 二进制文件和 zef。然后他们可以解压压缩包,进入新创建的目录,用 zef install 安装软件。

如果你的软件是开源的,你可以通过在 GitHub 上的 Raku 生态系统 git 仓库中发送一个 pull 请求,将其添加到官方的 Raku 生态系统中。拉取请求被接受后(通常只需要几小时或几分钟),用户可以通过 zef 安装你的软件,而不需要自己下载任何软件包。

13.2.2. 使用 Docker 进行部署

传统的 Raku 模块分发依赖于预装的 Raku 编译器,这可能不是所有平台上都有的。

如果你选择用 Docker 镜像分发你的应用,你可以基于 rakudo-star 镜像,只需 zef 将你的应用安装到 Docker 容器中即可。

这是最基本的 Docker 文件,它建立在预先存在的映像上,并从当前工作目录中安装该 Raku 应用程序。

FROM rakudo-star:2017.04
COPY myapp /tmp/install
RUN zef install /tmp/install
ENTRYPOINT ["/usr/share/raku/site/bin/myapp"]

运行 docker build -t myapp . 创建一个 Docker 镜像 myapp,然后你可以将其分发,并包含所有的依赖项。

13.2.3. Windows 安装程序

模块 App::InstallerMaker::WiX[74]可以帮助你创建一个 Windows 的 .msi 安装程序,它可以创建一个 Rakudo、zef和你的应用程序的构建。它需要 Microsoft Visual C++ 构建工具和 WiX。[75],你可以创建一个描述你的应用程序的 YAML 文件,然后运行脚本 make-raku-wix-installer 来创建一个独立的 .msi 文件。

13.3. 结尾感想

Raku 是一门很大的语言,嵌入了一个更大的社区和生态系统。一本这样的书不可能涵盖所有的东西,但希望它能帮助你学习到足够多的编程任务,更重要的是,它能让你兴奋并有动力去探索和学习更多的东西。

14. 创建一个 Web 服务和声明式 API

现在,似乎每一个软件都必须通过网络、云端来实现。

本着这种精神,我们就来看看在 Raku 中使用 Cro 创建网络服务的简单方法,是一套库,它可以让你轻松地编写异步网络客户端和服务。Cro 这个名字来自于一个可怕的双关语:它允许我编写微服务,我的 cro 服务。

在本章后面,我们将看看 Cro 是如何实现其声明式 API 的。

14.1. 12.1 开始使用 Cro

我们将重用第 4 章中的代码,它将 UNIX 时间戳转换为 ISO 格式的日期时间字符串,反之亦然,现在通过 HTTP 将它们公开。

第一部分,将 UNIX 时间戳转换为 ISO 日期,是这样的:

use Cro::HTTP::Router;
use Cro::HTTP::Server;

my $application = route {
    get -> 'datetime', Int $timestamp {
        my $dt = DateTime.new($timestamp);
        content 'text/plain', "$dt\n";
    }
}

my $port = 8080;
my Cro::Service $service = Cro::HTTP::Server.new(
    :host<0.0.0.0>,
    :$port,
    :$application,
);

$service.start;
say "Application started on port $port";
react whenever signal(SIGINT) { $service.stop; done; }

在这个例子中,我们看到了由 Cro::HTTP::Router 模块导出的子程序 route、get 和 content。

route 以一个块作为参数,并返回一个应用程序。在块内,我们可以调用 get(或者其他 HTTP 动词函数,如 post 或 put)来声明路由,当有人通过 HTTP 请求匹配的 URL 时,Cro 为我们调用的代码片段。

这里,路由声明的开头是 get -> 'datetime', Int $timestamp。箭头→引入了一个签名,Cro 将每个参数解释为一个斜线限制的 URL 的一部分。在我们的例子中,与签名相匹配的 URL 是 /datetime/ 后面跟着一个整数,比如 /datetime/1578135634。当 Cro 接收到这样的请求时,它会使用常量字符串 datetime 来识别路由,并将 1578135634 放入变量 $timestamp 中。

将时间戳转换为 DateTime 对象的逻辑在第 4 章中已经很熟悉了,唯一不同的是,我们没有使用 say 将结果打印到标准输出,而是使用 content 函数将其回传给 HTTP 请求者。这是必要的,因为每个 HTTP 响应都需要声明它的内容类型,这样,例如,浏览器就知道是否要将响应渲染为 HTML,还是作为图像等。文本/纯文本内容类型顾名思义,表示的是纯文本,不需要以任何特殊的方式来解释。

后面的代码是经典的管道:它在给定的 TCP 端口(这里是 8080,可以根据自己的喜好随意更改)实例化一个 Cro::HTTP::Server 对象和我们的一个微薄路由的集合,然后告诉它开始服务 HTTP 请求。我们选择了 0.0.0.0 的主机(意思是绑定到所有 IP 地址),这样如果你在 Docker 容器中运行应用程序,就可以从主机上到达。如果你不使用 Docker,使用 127.0.0.1 或 localhost 是比较安全的,因为它不会将应用程序暴露给网络中的其他机器。

最后是 shocker 一行。

react whenever signal(SIGINT) { $service.stop; done; }

signal()函数返回一个 Supply,这是一个异步数据流,以下是进程间的通信信号。signal(SIGINT)具体来说只有当进程接收到 INT 或中断信号时才会发出事件,通常可以通过在终端中按 Ctrl+C 键来创建。

react 通常以其块形式使用,react { …​. },由于它只适用于一条语句,所以在这里缩短了。它在 everever 语句中运行并派发耗材,直到代码调用 done 函数(或者所有流完成,对于信号流来说不会发生)。

所以,在 react 里面,whenever signal(SIGINT) { …​ } 每次接收到 SIGINT 信号时,都会调用…​标记的代码—​在这种情况下,我们会停止 HTTP 服务器,退出 react 构造。

如果你想处理其他信号,比如 SIGTERM(杀系统命令使用的),你可以将 signal(SIGINT)替换为

signal(SIGINT).merge(signal(SIGTERM))

当有人按 Ctrl+C 时,所有这些都是一种复杂的退出程序的方式。

由于 Cro 的异步性,你也可以在这里做其他的事情,比如处理反应块中的其他用品(比如周期性的计时器、文件变化事件流),而 HTTP 服务器正在欢快地运行。

要运行这个,首先用 zef 包管理器安装 cro 和 Cro::HTTP::测试模块。

$ zef install --/test cro Cro::HTTP::Test

其中的 --/test 选项告诉 zef 不要运行模块测试,这两个选项都需要很长的时间,并且需要一些你不可能有的本地基础设施。

如果你使用 Docker 来运行你的 raku 程序,你可以使用镜像 moritzlenz/raku-fundamentals,它建立在 Rakudo Star 上,并包含必要的 cro 模块。如果你走这条路,你还必须使用 docker 的 --expose 命令行选项来使服务在主机上可用;否则,它只能从容器内到达。那么命令行就像这样。

$ docker run --rm --publish 8080:8080 -v $PW/raku -w /raku \
    -it moritzlenz/raku-fundamentals raku datetime.p6

我们可以在命令行上用 curl4 这样的 HTTP 客户端测试服务。

$ curl http://127.0.0.1:8080/datetime/1578135634
2020-01-04T11:00:34Z

14.2. 12.2 扩大服务范围

现在我们已经有了一个简约而有效的服务,我们可以将 ISO 日期时间字符串转换为 UNIX 时间戳。我们只需要看一下路由块,一切都保持不变。这里有一个实现它的方法。

my token date-re {
    ^
    \d**4 '-' \d**2 '-' \d** 2 # date part YYYY-MM-DD
    [
    ' '
    \d**2 ':' \d**2 ':' \d**2  # time
    ]?
    $
}

my $application = route {
    get -> 'datetime', Int $timestamp {
        my $dt = DateTime.new($timestamp);
        content 'text/plain', "$dt\n";
    }

    get -> 'datetime', Str $date_spec where &date-re {
        my ( $date_str, $time_str ) = $date_spec.split(' ');
        my $date = Date.new($date_str);

        my $datetime;
        if $time_str {
            my ( $hour, $minute, $second ) = $time_str. split(':');
            $datetime = DateTime.new( :$date, :$hour, :$minute, :$second );
        }
        else {
            $datetime = $date.DateTime;
        }
        content "text/plain", $datetime.posix ~ "\n";
    }
}

我们先用一个 regex 定义我们要接受的日期时间格式,并将其存储在变量 &date-re 中。然后在路由 { …​ } 块中,我们添加了第二个带有这个签名的 get 调用。

get -> 'datetime', Str $date_spec where &date-re { ... }

这就定义了第二个路由,和之前的 url 类似,/datetime/YYYY-MM-DD HH:MM:SS (其中时间部分是可选的)。这些逻辑同样是复制自第 4 章,所以没有什么惊喜。唯一不同的是,在命令行程序中,命令行分析器为我们分割了日期和时间部分,现在我们通过调用 .split(' ') 来明确分割。

当我们用 curl 或浏览器测试这段代码时,我们需要记住,我们不能直接在 URL 中直接包含空格,而是需要将其转义为%20。启动我们的扩展服务后,我们可以再次调用 curl 进行测试。

$ curl http://127.0.0.1:8080/datetime/2020-01-04%2011:00:34
1578135634

大多数现代 Web 服务倾向于用 JSON 数据来响应,我们可以通过向内容函数传递一个 JSON 可序列化的数据结构(如哈希)来实现。

# in the first route
content 'application/json', {
    input => $timestamp,
    result => $dt.Str,
}
# in the second route
content "application/json", {
    input => $date_spec,
    result => $datetime.posix,
}

14.3. 12.3 测试

测试一个 Web 应用程序有时是一件很痛苦的事情。你必须启动应用服务器,但首先你需要找到一个可以监听的空闲端口,然后你向服务器发出请求,之后再拆掉它。

通过一点点的重组和 Cro::HTTP::测试模块,这一切都可以避免。

对于重组,让我们把对路由的调用放到我们自己的一个子程序中,把服务器的设置放到一个 MAIN 函数中。

sub routes() {
    return route {
        # same route definitions as before
    }
}

multi sub MAIN(Int :$port = 8080, :$host = '0.0.0.0') {
    my Cro::Service $service = Cro::HTTP::Server.new(
        :$host
        :$port,
        application => routes(),
    );
    $service.start;
    say "Application started on port $port";
    react whenever signal(SIGINT) { $service.stop; done; }
}

你可以像以前一样启动 HTTP 服务器,现在的好处是可以通过命令行覆盖端口和主机(服务器监听的 IP)。

我们的目标是测试,所以让我们为其添加另一个 multi MAIN。

multi sub MAIN('test') {
    use Cro::HTTP::Test;
    use Test;
    test-service routes(), {
        test get('/datetime/1578135634'),
            status => 200,
            json => {
                result => "2020-01-04T11:00:34Z",
                input => 1578135634 ,
            };
        test get('/datetime/2020-01-04%2011:00:34'),
            status => 200,
            json => {
                input => '2020-01-04 11:00:34',
                result => 1578135634,
            };

    }
    done-testing;
}

我们来见见我们的新朋友,sub test-service。我们用两个参数来调用它,一个是要测试的路由,另一个是包含我们测试的块。

在这个块里面,get() 例程在没有启动任何服务器的情况下调用相应的路由,并返回一个类型为 Cro::HTTP::Test::TestRequest 的对象。通过测试例程,我们可以检查这个测试响应是否满足我们的期望,这里关于响应代码(状态)和 JSON 响应体。

我们可以通过添加测试命令行参数来运行测试,得到这样的测试输出。

$ raku datetime.p6 test
    ok 1 - Status is acceptable
    ok 2 - Content type is recognized as a JSON one
    ok 3 - Body is acceptable
    1..3
ok 1 - GET /datetime/1578135634
    ok 1 - Status is acceptable
    ok 2 - Content type is recognized as a JSON one
    ok 3 - Body is acceptable
    1..3
ok 2 - GET /datetime/2020-01-04%2011:00:34
1..2

每次调用测试都会在输出中产生一个测试,并为每一个单独的比较产生一个子测试(用缩进表示)。

14.4. 12.4 添加一个网页

我们的迷你网络服务现在已经到了另一个程序可以舒服地通过 JSON 通过 HTTP 与之对话的地步,但这对终端用户来说并不友好。

作为一个可能的用户界面的演示,让我们添加一个可以在浏览器中查看的 HTML 页面。由于我们将通过 JavaScript 触发的 HTTP 请求来处理数据,所以我们可以摆脱静态文件的服务。Cro 为此提供了一个名为 static 的助手,它取代了我们对内容的调用。让我们把这两个路由添加到路由 { …​} 块中。

get -> { static 'index.html'; }
get -> 'index.js' { static 'index.js'; }

第一个签名是空的,所以对应于 / (root)URL,服务于 index.html 文件。第二个是服务于一个名为 index.js 的文件,URL 相同。

静态助手可以做更多的事情,比如服务于整个目录,同时防止恶意的路径遍历,5 但对于我们的案例来说,简单的形式就足够了。

文件 index.html 应该直接放在 raku 脚本的旁边,可以像这样。

<html>
<head>
<title>Datetime to UNIX timestamp conversion</title>
<script
    src="https://code.jquery.com/jquery-3.5.1.min.js"
    crossorigin="anonymous"></script>
<script type="text/javascript" src="/index.js"></script>
</head>

<body>
<h1>Convert UNIX timestamp to ISO datetime or vice versa</h1>
<form>
<label for="in">Input</label>
<input name="in" id="in" placeholder="2014-01-14 10:12:00"></input>
<button id="submit">Submit</button>
</form> <h2>Result</h2>
<ul id="result"></ul>
</body>
</html>

可见的元素只是一些标题、一个输入表单和一个提交按钮,以及一个空的结果列表。

我们将在 index.js 中添加一些 JavaScript 来使其活起来。

$(document).ready(function() { $('#submit').click(function(event) {
var val = $('#in').val();
$.get('/datetime/' + val, function(response) {
            $('#result').append(
                '<li>Input: ' + val +
                ', Result: ' + response['result'] +
                '</li>');
});
        event.preventDefault();
    });
});

这段代码使用 jQuery6 库,订阅按钮上的点击事件。当按钮被按下时,它会从 的输入元素,异步提交到 URL /datetime/,然后将结果作为列表项追加到无序列表(<ul>)中。

这远非完美,因为 Web 应用程序缺少错误处理和视觉吸引力,但它确实说明了如何在你的应用程序中拥有一个漂亮的以机器为中心的 API 端点,然后在上面放一个使用 HTML 和 javascript 的用户界面。

图 12-1. 日期时间转换应用的简约型 Web 前端

有几个项目旨在保持 JavaScript 代码的可组合性和可维护性,比如 Vue.js,angular 和 React。事实上,Cro 文档中附带了一个使用 React 和 Redux 构建单页应用的教程,如果你想更深入地研究这个主题,你应该遵循这个教程。

14.5. 12.5 声明式 API

我们现在可以通过更多的路由、认证 11 等方式来发展我们的应用,但我想提请大家注意 Cro 是如何创建其 API 的。

路由的语法是这样的。

get -> 'datetime', Int $timestamp { ... }

这里的 get 是一个函数调用,所以我们也可以把这个写成

get(-> 'datetime', Int $timestamp { ... });

箭头→引入了一个带有签名的块。其实这是一个块并不重要,一个普通的子程序也可以。

get( sub ('datetime', Int $timestamp) { ... } )

其实,没有必要把子程序的声明放在 get 调用里面。我们可以写成:

sub convert-from-timestamp('datetime', Int $timestamp) {
    my $dt = DateTime.new($timestamp);
    content 'application/json', {
        input => $timestamp,
        result => $dt.Str,
    }
}

sub convert-from-isodate('datetime', Str $date_spec where &date-re) {
    # omitted for brevity
}

my $application = route {
    get &convert-from-timestamp;
    get &convert-from-isodate;
}

get 是一个接受另一个函数作为参数的函数,这一点我们在第 12 章中已经看到了。与这些例子不同的是,get 并不只是调用它所接收的函数,它还会反省函数的签名,以确定何时调用它。

我们也可以这样做。在前面的例子中,你可以写下

my @params = &convert-from-timestamp.signature.params;
say @params.elems;
say @params[0].type;
say @params[0].constraints;
# => 2
# => (Str)
# => all(datetime)

通过 .signature.params 方法链,我们获得了代表每个函数参数的 Parameter 对象列表;我们可以向它们询问类型、附加约束条件(比如第一个参数上使用的字符串 datetime)、变量名以及更多属性。

就像 get 一样,route { …​ } 是一个函数,它在经过一番设置后调用它的参数函数。它设置了一个动态变量,get 会锁定这个变量,这使得 route 能够返回一个对象,这个对象包含了所有对 get 的调用信息,因此也包含了所有的路由。

为了说明这个原理,我们试着写一些函数,让你根据类型写出小的调度器,也就是调用第一个匹配类型的函数。

my &d = dispatcher {
    on -> Str  $x { say "String $x" }
    on -> Date $x { say "Date $x"   }
}

d(Date.new('2020-12-24'));
d("a test");

为了使这个抱负的例子成为一个有效的乐成代码,我们需要两个函数,dispatcher 和 on,它们都以一个可调用的块作为参数,dispatcher 需要声明一个动态变量,on 则执行一些理智性检查,并将其参数添加到动态变量中。

sub dispatcher(&body) {
    my @*CASES;
    body();
    my @cases = @*CASES;
    return sub (Mu $x) {
        for @cases -> &case {
            if $x ~~ &case.signature.params[0].type {
                return case($x)
            }
        }
        die "No case matched $x";
    }
}

sub on(&case) {
    die "Called on() outside a dispatcher block"
        unless defined @*CASES;
    unless &case.signature.params == 1 {
        die "on() expects a block with exactly one parameter"
    }
    @*CASES.push: &case;
}

子 dispatcher 要把它的动态变量的内容复制到一个词法变量 my @cases 中,因为动态变量的作用范围是它所声明的函数的执行时间,所以在函数 dispatcher 返回后它就不存在了。但是 dispatcher 需要内容来做它的工作,在 case 中迭代,并调用第一个匹配的 case。它在返回的匿名函数中做这件事,这样程序员就可以在多个代码位置重复使用匹配器。

在你第一次阅读使用 Cro 的代码时,你可能会认为 route 和 get 构造看起来像语言扩展,相反,它们原来是巧妙地命名为接收其他函数作为参数的函数。你可以在自己的库和框架中使用同样的技术来创建让使用它们的程序员感到自然的接口。

14.6. 12.6 总结

通过 Cro 库,你可以很容易地通过 HTTP 来暴露功能。首先,你在 route { …​} 块中创建一些路由(当有人请求匹配的 URL 时,Cro 会调用这些代码),然后将路由传给 HTTP server 块中创建一些路由(当有人请求匹配的 URL 时,Cro 会调用这些代码),然后将这些路由传递给 HTTP 服务器。你启动服务器,就可以了。

每条路由通过调用 content 函数来传达它的响应,指定内容类型和响应体;JSON 序列化会自动为相应的内容类型发生。

通过静态 HTML 和 JavaScript,可能借助 JavaScript 应用框架,可以创建一个用户界面。

我们也看到了 Cro 是如何通过提供高阶函数(接收其他函数作为参数的函数),并对这些函数的签名进行反省,从而实现其 API 的自然感觉。


7. 也可以使用返回表达式来返回一个值,并立即退出子程序。