Raku 深入研究

on

1. 什么是 Raku

在这一章中,我们将研究 Raku 出现的原因,并跟踪了解 Perl 语言的发展历史。你会看到一些自 Perl 5 以来发生的变化,你会看到一些例子,你会学习如何下载和使用编译器,在哪里找到文档,以及如何在 Raku 中运行你的第一个程序。

在这一章中,我们将介绍以下内容:

  • Raku 的起源

  • 与 Perl 5 的区别

  • Raku 资源

  • 编译器

  • 使用 Rakudo Star

  • 编写我们的 Hello World 程序

1.1. Raku 起源

Raku 是 Perl 家族中的一种编程语言。Perl 本身出现于 1987 年,从那时起,它就在不断发展:目前的稳定版本是 2017年5月发布的5.26。2000年,Perl 的创造者 Larry Wall 提出要开始研究该语言的下一个版本- Raku 。

这有几个原因。首先,一种语言应该继续发展,以反映开发者的新要求。第二,它可能会改变非 Perl 社区对 Perl 的看法。5.0 版本出现在 1993 年,尽管如此,Perl 语言仍在继续发展。当时的主要版本号仍然是 5,在很多人看来,这意味着 Perl 从 1993 年开始就停滞不前了。新的主要版本更新会刷新人们的认知。

这个想法是让 Raku 成为"Perl 社区的重写"。拉里要求社区分享他们想要改变的 Perl 的部分。变更请求导致了 361 份 RFC (请求评论)文档,这些文档发布在 https://raku.org/archive/rfc 中。这些文件仅具有历史意义。

随后,人们对各种提案进行了系统的分析,按相似的主题进行了分组,并作为一系列概要发布。这些文档背后的命名和编号原则是为了保持 Programming Perl 一书的章节结构。

后来,概要再次被归纳和解释为一组名为 Apocalypses 和 Exegeses 的文档。所有这些文件今天都可以在 http://design.raku.org 上找到,但同样的,它们并不是语言的最终规范,只是历史文献的集合。

关于 Raku 的另一个重要想法是关于编译器的创建方式。在 Perl 5 中,语言规则是由单一可用的编译器间接定义的。一些 bug,或者是编译器的不明显的行为,可能会被认为是语言标准的一部分。在 Raku 中,决定有一个明确的语言规范,没有参考编译器。可以有不止一个编译器。对它们的主要要求是实现规范并通过测试集。

1.2. 与 Perl 5 的区别

让我们简单地看一下在通往 Raku 的路上发生的一些变化。在下面的章节中,你会看到一些 Perl 5 和 Raku 中的代码示例。这些例子的目的是为了让你对 Perl 5 到 Raku 的转变有一个大致的了解,但你不需要了解其中的每一个细节。所有关于 Raku 语法的细节将在本书的后面讲解。

1.2.1. 符号

对于初学 Perl 的人来说,最难的一个东西就是 sigil。sigil 是 Perl 中变量名称前面的一个字符,表示变量的结构类型。例如,$ 代表标量值,@ 代表数组,% 代表散列。

当你访问数组或散列的元素时,问题就出现了。让我们以下面这几行 Perl 5 斐波那契数组的前几个数为例。

my @fibonacci = (0, 1, 1, 2, 3, 5, 8, 13);
print $fibonacci[4];

首先,创建一个 @fibonacci 数组。变量的名称中包含了 @ 字符作为标志。在第二行中,我们访问该数组中的一个元素,这次我们使用另一个标号 $。这是因为数组中的单个元素是标量,而标量使用 $ 作为符号。对于学习 Perl 的人来说,符号的这个小变化对理解 Perl 语言的基础知识是个大问题。

在 Raku 中,符号是统一的,是变量名的一部分。无论你是访问数组中的一个元素,还是访问整个数组,都不能单独改变它。上面的例子在 Raku 中是这样的:

my @fibonacci = (0, 1, 1, 2, 3, 5, 8, 13);
print @fibonacci[4];

在这两行中,@fibonacci 数组和它的 @fibonacci[4] 元素都使用了相同的符号。这种方法对于初学者来说,更加一致,也更容易。

1.2.2. 签名

在 Perl 5 中,你必须通过使用内置的 shift 函数或者从默认的 @_ 数组中提取函数的参数值。

让我们用下面的例子来看看,一个计算两个参数之和的函数。在 Perl 5 中,你必须做一些额外的工作来获取实际传递的参数。

首先,用 Perl 5 中的 shift 来获取参数值:

sub add {
    my $x = shift;
    my $y = shift;
    return $x + $y;
}

然后,通过使用 @_ 数组:

sub add {
    my ($x, $y) = @_;
    return $x + $y;
}

与许多其他编程语言不同的是,不可能直接声明一个函数的形式参数列表。例如,你在 C 语言或 C++ 中就是这样做的。

int add(int x, int y) {
    return x + y;
}

在 Perl 5 中,可以借助于原型来限制参数的数量和结构类型。这里的符号是用来告诉 Perl 参数的类型。上面的加法函数在 Perl 5 中可能是这样的:

sub add($$) {
    my ($x, $y) = @_;
    return $x + $y;
}

使用函数原型会使编译器在使用不同数量的参数(例如,一个或三个参数而不是两个)时产生抱怨,但你仍然需要自己获取它们的值。

Perl 5.20 引入了函数签名。所以,现在,你可能会从一次性声明参数中受益。下面的代码给出了这样一个例子。$x$y 参数都在函数头中声明:

use v5.20;

use feature qw(signatures);
no warnings qw(experimental::signatures);

sub add($x, $y) {
    return $x + $y;
}

say add(4,5);

你会注意到,你需要在脚本中提到 Perl 5.20 的最小版本号来指示 Perl 使用 Perl 5.20 的功能。你还会注意到,你必须通过单独的指令激活相应的功能。但是,更重要的是,由于签名是一个实验性的功能,你必须手动关闭警告信息,才能得到一个干净的输出。

在 Raku 中,函数签名从一开始就被允许使用,所以你可以直接使用它。

# This is  Raku
sub add($x, $y) {
    return $x + $y;
}

事实上,Perl 5.20 中的签名是将 Raku 的功能从 Raku 中移植到 Perl 5 中的一个例子,所以尽管 Raku 是 Perl 5 的下一个版本,但 Perl 5 中的一些元素在 Raku 中仍然得到了一些设计上的元素,使 Perl 变得更好。

1.3. 类

为了让用户体验更好,让我们来看看另一个重要的例子,看看 Raku 中 Perl 语法的变化在哪里。

传统上,在 Perl 5 中,面向对象编程是借助于所谓的被祝福的散列来完成的。对象中的数据成员是散列的元素,而这个散列的被祝福的引用可以用来调用类的实例上的方法。下面的例子告诉你如何在 Perl 5 中定义一个类并创建一个类的实例。

package MyClass;

sub new {
    my ($class) = @_;
    my $this = {
        counter => 0
    };
    bless $this, $class;
    return $this;
}

sub inc {
    my ($this) = @_;
    $this->{counter}++;
    return $this->{counter};
}

1;

到目前为止,名为 MyClass 的类有两个方法 - new,用于创建一个新的实例,和 inc,用于递增计数器并返回新值。在处理 Perl 5 的类时,不要忘了在模块的末尾返回一个真值,这就是文件最后一行中的1的目标。

在主程序中,你可以通过创建一个实例并在变量上调用方法来使用 MyClass,方法如下:

use MyClass;

my $var = MyClass->new;

print $var->inc, "\n";
print $var->inc, "\n";
print $var->inc, "\n";

在 Perl 5 中实现面向对象的东西是另一个障碍,因为对于新手来说,他们可能有过在其他语言中使用类的经验,但对 Perl 5 创建类的方式感到困惑。

对于使用过其他面向对象编程语言的开发人员来说,Raku 中的类对他们来说更加熟悉。

这就是在 Raku 中定义同样的类的方法,如上例所示:

class MyClass {
    has $!counter;

    method inc() {
        $!counter++;
        return $!counter;
    }
}

如你所见,整个类是在这对花括号内定义的。它的数据成员是用 has 关键字显式声明的,不需要在文件的最后返回 1。

现在,创建一个类的对象,然后像我们之前在 Perl 5 中的例子一样,将内部计数器增量三次。在 Raku 中就是这样做的。

my $var = MyClass.new;

say $var.inc;
say $var.inc;
say $var.inc;

暂时不要把注意力放在细节上,因为这一切都会在后面的章节中解释。

到目前为止,我们已经看到了三个例子,希望改进 Perl 5 的语法。

要查看更多关于 Perl 5 和 Raku 之间的变化的例子,你可以参考 Raku 的文档 docs.raku.org/language.html,其中有几篇文章以 "Perl 5 到 Raku 指南"为标题,专门讨论这个主题。

5to6-nutshell

Perl 5 到 Raku 指南 - 一言以蔽之,我怎么做我以前做的事?

5to6-perlfunc

Perl 5 到 Raku 指南 — 函数

5to6-perlop

Perl 5 到 Raku 指南 - 操作符

5to6-perlsyn

Perl 5 到 Raku 指南 - 语法

5to6-perlvar

Perl 5 到 Raku 指南 - 特殊变量

1.4. 与 Perl 5 的兼容性

现有的 Raku 编译器在不修改源代码的情况下,无法执行 Perl 5 程序。Perl 5 和 Raku 有时被称为姐妹语言。两者有着相同的 Perl 精神,在很多情况下,可以将程序从 Perl 5 转换为 Raku。

Perl 5 的最大优势之一是 CPAN(Perl 综合档案网络)。它包含了无数的模块,适用于大量的领域。最有可能的是,你的任务已经被 CPAN 的一些作者解决了。要想在 Raku 中使用这些有用的遗产,你可能想使用 Inline::Perl5 模块,它允许在不修改源代码的情况下使用现有的 Perl 5 模块。

例如,让我们以 Perl 5 中最流行的模块之一 Text::CSV 为例,将其嵌入到 Raku 中:

use Inline::Perl5;
use Text::CSV:from<Perl5>;

my $csv = Text::CSV.new;

$csv.parse('First name,Last name');
say $csv.fields.join("\t");

$csv.parse('Astrid,Lindgren');
say $csv.fields.join("\t");

Inline::Perl5 启用后, :from<Perl5> 后缀将从 Perl 5 模块目录中加载 Text::CSV 模块。这个模块必须作为一个普通的 Perl 5 模块从 CPAN 中安装。

程序的其余部分使用 $csv 对象,它是 Text::CSV 的一个实例。注意,你必须遵循 Raku 的语法,因此,比如说,不要用 Text::CSV→new 来创建对象,而是用 Text::CSV.new。调用解析方法也是一样的:在 Perl 5 中是 $csv→parse() ,而在 Raku 中则是用点号: $csv.parse() 。在 Raku 中如何处理对象,请看第八章的"面向对象的编程"。

幸运的是,在 Raku 中已经有一个 Text::CSV 模块。你可以在 http://modules.raku.org 页面上找到它。使用 Inline::Perl5 对于那些在 CPAN 上还没有等价物或替代物的模块,使用 Inline::Perl5 可以非常有用。例如,下面的例子来自于模块文档中的例子显示了如何连接到数据库(当然,你需要安装 PostgreSQL 来测试这个例子):

use Inline::Perl5;
use DBI:from<Perl5>;

my $dbh = DBI.connect('dbi:Pg:database=test');
my $products = $dbh.selectall_arrayref(
    'select * from products', {Slice => {}}
);

Inline::Perl5 模块可在 https:/​/​github.​com/​niner/​Inline-Perl5 上找到。

1.5. Raku 资源

Raku 有着悠久的历史,在那段时间里,有很多文档被创造出来,比如语言的想法、规范草案、编译器文档等。很多爱好者写了很多关于 Raku 的文章和博文。其中有些已经过时了,并不能反映语言的现状。在这一章中,我将给大家列举一些最新的资料,这些资料都是最新的,也是大家在使用 Raku 的实践中应该用到的。

1.6. 文档

Raku 编程语言文档的主要入口是 Raku 网站的文档部分(http:/​/​docs.raku.​org)。它包含了几个部分,全面地描述了 Raku 中的类型、操作符和内置类。由于 Raku 语言还在发展中,有时你可能会发现文档中有些地方没有反映出语言的现状。在这种情况下,你可以咨询语言开发者社区或者从测试套件中查看文件。

1.7. 测试套件

Raku 的测试套件,叫做 Roast,可以在 perl6/roast 仓库中找到。它包含了成千上万的测试,涵盖了 Raku 的许多角落。如果你想了解用 Raku 编写程序的方法,这个测试套件也是个不错的地方。它有时可能是一个长篇大论,但很多测试都是从各个可能的角度来检查功能。

在 Roast 中,这些测试被归类到目录中,目录中的名称如 S32-io 等。这些名称对应着 Synopses 的编号,并被分成了主题部分。例如,对于 Synopses 11 "编译单元",测试中存在三个目录- S11-compunits、S11-modules 和 S11-repository。

1.8. STD.pm

STD.pm 是一个巨大的文件,描述了正式的 Raku 的 grammar。 Raku 的 grammar 是用 Raku 本身编写的。在资源库中包含了这个语法和 viv 工具,它可以将语法翻译成 Perl 5 的代码。我们之所以提到 STD.pm 语法,是因为对于那些想深入挖掘语法内部结构的读者来说,可能会有兴趣。在本书的其余部分,我们将以 Raku 中的代码实例为基础来解释这个语法。

1.9. 社区

Raku 的开发者们传统上使用IRC进行交流。你也可以加入 #raku 频道,在线询问有关该语言的问题或执行 Raku 的某个片段。 要加入该频道,请按照 页面上列出的说明进行操作。

如果你想在 IRC 中运行代码,请参考 rakudo bot 如下。

<me> rakudo: say "Hello, World!"
<+camelia> rakudo-moar cb8fa0: OUTPUT: «Hello, World!␤»

在输出中,可以看到 Rakudo 默认使用 MoarVM 后端。字符串 程序打印出来的数据会显示在 OUTPUT 关键字之后。

慎重使用这个功能,因为你的请求结果会被整个房间的人看到,也会被记录下来。最好的用例是当你发现 bug 或看到与文档中所说的不同的结果时,显示编译器的行为。 Raku 的开发者们总是在IRC频道中,他们会给你提供建议,指出错误的地方,或者会致力于修复 bug,并使文档正确。

如果你在 Facebook 上,请访问 Raku 小组。

在线下,你会在各种会议上看到很多热爱 Raku 的人。去参加 Perl 大会(以前叫YAPC,(Yet Another Perl Conference)),它 每年都会在欧洲和美国举行。你可以在 theperlconference.org 和脸书上找到更多关于他们的信息。多年来,在 OSCON 和 FOSDEM 等大型开源会议上都有 Perl 的工作展位。还有很多地方性的会议、研讨会和当地的小组会议。在脸书上找到离你所在的地方最近的小组。

1.10. 编译器

在 Raku 的开发过程中,产生了许多编译器。其中有的只是一个测试一些想法的游乐场,有的则是比较成熟的。其中最重要的,我们应该提到以下四个项目。

  • Parrot

  • Pugs

  • Perlito

  • Rakudo

之前有更多的人尝试过创建 Raku 编译器,但都不太成功,或者说不太完善。我们先看前面的列表,看看每个项目的亮点,然后重点介绍一下Rakudo,也就是今天应该使用的编译器。

1.10.1. Parrot

Parrot是第一个旨在成为 Raku 编译器基础的虚拟机。该语言最初的设计建议是将源代码编译成字节码,由虚拟机执行。Parrot的目标是创建一个适合处理 Raku 的所有需求的虚拟机,从简单的数据类型(如整数),到更复杂的结构(如类),都可以在对象上调用方法并遵循对象层次结构。

这个项目可以在 parrot.org 上找到。过了一段时间后,Parrot 开始支持其他编程语言,比如 Lua 或 Python,虚拟机也变得不那么专注于 Raku 本身。例如,Ponie 项目就是试图创建一个可以使用 Parrot 执行 Perl 5 程序的编译器。

Parrot 成为另一个项目 Rakudo 内部的虚拟机之一。不过,在说 Rakudo 之前,让我们沿着历史的轨迹来谈谈 Pugs。

1.10.2. Pugs

Pugs(Perl 用户高尔夫系统)是一个用 Haskell 编写的 Raku 编译器。它由一个单独的开发者在 2005 年开始,很快就吸引了更多的人加入到团队中来。Pugs 是当时最成熟的编译器。这个项目的编译难度很大,非常耗时,执行速度很低,但编译质量和 Raku 规范的覆盖面都很突出。

到今天为止,Pugs 在 Raku 中的主要作用是庞大的测试套件。它最初是为了测试 Pugs 本身而创建的,但后来变成了 Raku 的官方测试套件。一个自称是 Raku 编译器的编译器必须通过测试套件的测试。

Pugs 已经不再开发了,但它的源代码可以在 GitHub 上找到: github.com/perl6/Pugs.hs

1.10.3. Perlito

Perlito 是另一个非常有趣的例子,它是一个构建 Raku 编译器的项目。它的目标是将 Perl 5 或 Raku 交叉编译到这些语言中的一种 - JavaScript、Java、Go、Python、Ruby 或 Lisp。你可以在 fglock/Perlito 中找到这个项目的版本库。

Perlito 提供了一个 Web 接口,可以在浏览器中编译 Raku 的子集。它将 Raku 中的代码编译成 JavaScript,并立即执行。这个页面可以在 fglock.github.io/Perlito/perlito/perlito6.html 中找到。这个项目只涵盖了 Raku 的部分规范,但它仍然可以用来创建各种在线教育系统,包括 Perl 5 和 Raku 。

1.10.4. Rakudo

Rakudo 是一个最初建立在 Parrot 虚拟机上的编译器。后来,它开始使用 Java 虚拟机(JVM),但最后 Parrot 的开发者创建了自己的虚拟机 MoarVM(Metamodel on a Runtime Virtual Machine(www.moarvm.org))。目前,JVM 的支持有限,主要的虚拟机是 MoarVM。

Rakudo 本身就是一个 Raku 编译器。对我们来说,最有用的编译器是 Rakudo Star,它是一个包括编译器以及一些 Raku 模块和一些命令行工具(如模块安装程序)的发行版。本书中,我们将使用 Rakudo Star 编译器来运行 Raku 中的程序。

Rakudo 的网站是 rakudo.org

1.11. 使用 Rakudo Star

Rakudo 是目前最完整的编译器。它支持 Raku 语言中最大的子集,如果说 Rakudo 是学习 Raku 的唯一编译器,那是不会错的。

1.11.1. 下载和安装 Rakudo Star

在电脑上安装乐酷星有几种方法。你可以下载源码并编译它,或者下载一个安装程序为你的平台。Rakudo Star 适用于所有主要平台,即 Windows(32位和64位版本)、Mac OS X 和Linux。

Rakudo Star 的主要下载页面是 。在该页面上,你将找到不同平台的 Rakudo Star 发行版的最新版本的链接,以及关于如何安装的说明。

在 Windows 上,这个过程非常简单。只需下载最新版本的 MSI 安装程序,运行它,并按照说明进行操作。

在 Mac OS X 上,你可以下载一个 .dmg 安装程序,或者使用 brew 管理器,如下所示。

$ brew install rakudo-star

在 Linux 上,你必须从源文件中安装 Rakudo Star。

安装完 Rakudo Star 后,你会在其 bin 目录下找到 perl6 可执行文件。确保将该目录的路径添加到你的系统范围内的 PATH 变量中,这样你就可以从任何位置输入 perl6。

在本书的其余部分中,我们将假设 Rakudo Star 已经安装了,我们将使用 perl6 可执行文件来运行程序。

1.12. 命令行选项

Rakudo Star 的 Raku 编译器接受了一些命令行选项。让我们来看一下其中的一些选项。

1.12.1. -c 命令

-c 命令行检查程序的语法并退出。它还可以运行程序中的 BEGIN 和 CHECK 块,这在本书后面的第2章 "编写代码"一节 "Phasers" 中讨论过。如果你只想检查代码中是否有语法错误而不想执行,这个命令行选项是很有用的,BEGIN 和 CHECK 代码块中的代码是个例外。

在正确编程的情况下,它会打印出以下输出。

Syntax OK

如果有编译时错误,编译将在第一个错误处停止,并在控制台中显示,并提到发现错误的行号。

错误信息包含了错误的描述,并通过弹出字符()表示代码中的确切位置。如果你的控制台支持颜色,那么在弹出字符之前的代码片段是绿色的,其余部分是红色的。

下面是一个程序漏掉了字符串的结尾引号的例子。

say "Hello;

运行它来检查语法,如下所示:

$ perl6 -c err.pl

程序没有编译,编译器打印出来的结果是这样的。

===SORRY!=== Error while compiling /Users/ash/code/err.pl
Unable to parse expression in double quotes; couldn't find final '"' at /Users/ash/code/err.pl:2
------> <BOL> <EOL>
       expecting any of:
           argument list
           double quotes
           term

1.12.2. --doc命令

--doc(注意是双连字符)命令行提取程序中的文档并打印出来。这里指的是所谓的 Pod 文档。我们将在第二章"编写代码"中介绍 Pod 的语法。

让我们来看看这个包含了文档的小程序。

=begin pod
=head1 Hello, World program
=item This program prints "Hello, World!"
=end pod

say "Hello, World!";

用 --doc 命令行选项运行它,如下所示。

$ perl6 --doc pod.pl

它将只打印文档的部分内容。代码本身不会被执行。

Hello, World program
      * This program prints "Hello, World!"

1.12.3. -e 命令

-e 选项允许你在命令行中传递整个程序。这对于只做几个动作的短程序很有用,或者是在检查 Raku 中的工作原理时进行小测试。

用引号中包含的程序运行它。

$ perl6 -e'say "Hello"'

而这就是你会看到的结果。

Hello

1.12.4. -h 和 --help 命令

-h 和 --help 命令会打印出包含可用命令行选项列表的文本。

1.12.5. -n 命令

命令行选项 -n 会创建一个循环,这样程序就会对每一行提交到程序输入的文本执行一次。

例如,它可以是一个单行的实用程序,从 STDIN 输入中打印出字符串的第一个字母。

perl6 -n -e'print $_.substr(0, 1)' < file.txt

它将打印出由 file.txt 中行的第一个字符组成的行。

1.12.6. -p 命令

-p 命令行选项的作用与前面描述的 -n 选项类似,但它也会在每行的末尾打印出默认变量 $_ 的值。我们将在下面的章节中看到默认变量的含义。

1.12.7. -I 和 -M 命令

-I 和 -M 选项用于将模块加载到程序中。模块的名称被传递到 -M 选项中,如果需要的话,模块的路径应该在 -I 选项中传递。

1.12.8. -v 和 --version 命令

-v 和 --version 选项可以打印出当前 Raku 编译器的版本,如下所示。

$ perl6 -v

在写这篇文章的时候,我使用的是 Rakudo Star 2017.01 版,输出是这个样子的。

This is Rakudo version 2017.01 built on MoarVM version 2017.01 implementing  Raku .c.

这里除了版本本身之外,重要的是用来执行 Raku 的虚拟机(MoarVM,如前文所示)和 Raku 语言规范的版本(本例中是6.c)。

Rakudo Star 的版本方案使用的是发行版的年月日。Rakudo 正在快速发展,所以定期检查 rakudo.org 网站以获得更新。

1.12.9. --stagestats 命令

--stagestats 是一个命令行选项,它比我们前面描述的其他命令行选项更具有 Rakudo 的特性。它打印了编译器在编译和执行程序的不同阶段所花费的时间。

输出结果的不同,取决于你是在运行程序还是用 -c 命令行选项检查它的语法。让我们先来看看当使用 -c 选项时打印出了什么。

$ perl6 --stagestats -c hello.pl

产出情况如下:

Stage start : 0.000
Stage parse : 0.107
Stage syntaxcheck: Syntax OK

如果没有 -c 选项,你会看到更多的统计数据,因为程序不仅会被编译,还会被执行,如图所示。

$ perl6 --stagestats hello.pl

程序的常规输出被打印出来:

Stage start       : 0.000
Stage parse       : 0.327
Stage syntaxcheck : 0.000
Stage ast         : 0.000
Stage optimize    : 0.003
Stage mast        : 0.008
Stage mbc         : 0.000
Stage moar        : 0.000
Hello, World!

1.12.10. 编写我们的 "Hello World" 程序

至此,由于我们已经安装了 Rakudo Star 编译器,现在是时候在 Raku 中创建第一个程序了。它将打印出 Hello, World!并退出。

这个程序其实很简单。你所需要的只是一行,唯一的指令就是调用内置的 say 函数。它获取字符串,将其打印到控制台,并在后面添加一行新的指令。

这就是整个程序的样子。

say 'Hello, World!'

将代码保存到文件中,比如说 hello.pl,然后将其传递给编译器,如下所示。

$ perl6 hello.pl

它将编译程序并立即执行。结果就是在屏幕上显示出想要的字符串。

Hello, World!

请注意,输出结束时有新的一行。这就是内置的 say 函数的行为。另外,我们还可以使用另一种方法来打印输出,使用内置的 print 函数。与 say 不同的是,它不会在输出的结尾添加新的行,所以你必须通过添加特殊符号 \n 来完成。

print "Hello, World!"

注意,这次使用了一对双引号。与单引号相比,双引号对特殊字符(如 \n)的处理方式不同。在双引号内,\n 会转换为一个新的行字符。这在单引号中是不会发生的,在这种情况下,\n 将作为两个字符的序列出现在屏幕上,\ 和 n。

因为程序只包含一行代码,所以没有必要用分号结束。不过,你可以一直这样做。

say "Hello, World!";

这个程序所产生的输出与之前完全一样。

1.13. 总结

在这一章中,我们简单地了解了 Raku 是一种什么样的编程语言,以及它与它的姐妹语言 Perl 5 的区别。我们了解了 Raku 的历史,以及 Raku 编译器的不同项目。最后,我们安装了当今最好的编译工具 Rakudo Star,并创建了第一个 "Hello, World!" 程序。

在下面的章节中,我们将研究如何组织程序中的代码。

2. 编写代码

本章将为你编写 Raku 代码做好准备。在我们研究变量、数据类型、面向对象和其他重要主题之前, 让我们先来了解一下 Raku 中的源代码是如何组织的。这里有很多与其他编程语言不同的元素, 值得我们花时间去熟悉它。

在本章中, 我们将涵盖以下主题:

  • 使用 Unicode

  • 空白和反空白

  • 注释

  • 创建 Pod 文档

  • Phaser

  • 简单的输入和输出

2.1. 使用 Unicode

默认的假设是 Raku 程序的源代码使用 UTF-8。它给你了整个字符的力量, 而不用担心它是否会起作用。例如, 在 Perl 5 中, 你必须添加一些特殊的指令来通知解释器你在源代码中使用了非 ASCII 字符。在 Raku 中, 这就简单多了。

首先, 在字符串中可以自由地使用 Unicode 字符。例如, 让我们尝试一下希腊语和中文的一些词组, 如下面的代码行所示:

say 'C = 2πr';     # Circumference of a circle
say '日 + 月 = 明'; # 'Sun' and 'Moon' give 'bright'

前面的两行代码会按照预期打印出相应的字符串:

C = 2πr
日 + 月 = 明

另外, 也可以用它们的名字来引用 Unicode 代码点。例如, 考虑下面这行代码:

say "Raku is \c[FLEXED BICEPS]";

前面这行代码用肌肉表情符号打印出以下输出:

Raku is 💪

在 Raku 中使用 Unicode 并不限于字符串、内容。变量和函数的名称中也可以使用 ASCII 以外的字符。让我们回到第一章《什么是 Raku》中的 add($x, $y) 函数的例子, 用希腊字母重命名它和它的参数:

sub Σ($α, $β) {
    return $α + $β;
}

say Σ(8, 9); # 17

此外, 一些 Unicode 字符可以用来表达简单的操作, 而不是更多的常规代码。例如, 这里是有理分数的字符:

say ½;     # prints 0.5
say ½ + ¼; # 0.75

非 ASCII Unicode 数字也是允许的, 但是在没有特殊需求的情况下, 在普通代码中使用这些数字也许不是一个好主意:

say ୪૨; # prints 42

可以用诸如 ² 或 ³ 这样的上标而不是调用函数来计算数字的幂, 如下面的几行代码所示:

say 7²; # 49
say 2⁷; # 128

另一个使用 Unicode 字符的例子是集合上的数学运算符, 例如 ∈ 或 ∪。

在下一个例子中, 我们使用了 Unicode 版本的简单算术运算符, 而不是传统上使用的常规 */ 字符:

say 10 × 4;  # 40
say 100 ÷ 4; # 25

在 Raku 程序中, 你可能经常会看到一些其他 Unicode 字符。我们来看一下其中最常见的几个。

法语引号 « » 可以用来代替一对引号来创建列表。例如, 在下面的代码中, @array 可以得到斐波纳契数的序列。我们已经在第一章《Raku 是什么》中看到过这个例子。让我们先把它更新为使用引用语法来创建数组, 如下面的几行代码所示:

my @fibonacci = <0 1 1 2 3 5 8 13>;
say @fibonacci[4]; # 3

我们将在第三章《处理变量和内置数据类型》中详细介绍这种语法, 不过, 现在让我们来看看如何使用法语引号来包围值列表:

my @fibonacci = «0 1 1 2 3 5 8 13»;
say @fibonacci[4]; # 3

对于单行程序(主要在 -e 命令行选项中执行的短程序), Unicode 引号可能会很有帮助, 因为它们有助于避免嵌套单引号的问题。你可以在下面的例子中看到它是如何工作的:

$ raku –e'say ‘Hello, World!’'

这里用一对 Unicode 引号(‘ 和 ’)来代替单引号(')。

在使用正则表达式和 grammar 时(我们将在第十一章《正则表达式》和第十二章《Grammar》中讨论它们), 你经常会看到一对方角括号, 你可以在代码中自由地使用它来引用字符串, 如下面的代码所示:

say ⌈Hello, World!⌋;

在 Raku 中, 可以用 Unicode 字符来表示语言的元素, 它和用 ASCII 形式编写的等价。这些 ASCII 字符有时被称为德克萨斯字符。下表列出了 Unicode 和 ASCII 版本中最常见的结构的对应关系:

Unicode

ASCII 等价物

在 Raku 中的意思

« »

<< >>

这些引用一组数组元素, 也用于超运算符中(参见第四章,《使用运算符》)。

‘’ “„”

' ' " " "

这些单引号和双引号用于引用字符串。诸如 n 这样的特殊字符在双引号中被插值。

⌈ ⌋

Q/ /

这个语法用于创建不带插值的字符串。

× ÷

* /

这些是乘法和除法的算术运算符。

-

例如, 减号, 用作运算符(注意, Unicode 中有一个单独的 MINUS SIGN 字符, 其代码为 0x2212)。

有几个预定义的数学计算常数 - π, e`和 `τ。还有一个单独的符号来表示无穷大 。所有这些符号在 ASCII 表示法中也有对应的符号:

符号

ASCII 等价物

注释

π

pi

这是 π 的值(3.14159…​)。

𝑒

e

这是 𝑒 的值(2.71828)。注意它的 Unicode 表示是 0x1D452 字符, 而不是 ASCII 字符 e。

τ

tau

τ 的值为 2π(6.283185…​)。

Inf

无穷大总是比任何数都大

键入 Unicode 字符可能是一项单独的任务。当然, 你可以随时从谷歌或维基百科上复制字符, 但在这种情况下, 你会失去效率。请参考 Raku 在线文档 docs.raku.org/language/unicode_entry 中输入 Unicode 字符的页面。它包含了如何在不同的编辑器和 IDE 中输入 Unicode 字符的详细说明。

2.2. 空白和反空白

正如我们刚才看到的那样, Raku 程序可以在传统的 ASCII 集之外集中使用 Unicode 字符。这也适用于空白。空白是指程序元素之间的空隙, 传统上用空格(ASCII 代码 0x20)、制表符(0x09)和换行符(在 Unix 中是一个单一的换行符 0x0A, 在 Windows 中是一系列的两个字符, 回车符 0x0D 和换行符 0x0A)来表示。Raku 扩展了空白的概念, 在代码的每一个允许使用普通空白的地方都接受 Unicode 空白。当你使用现有的代码时要小心, 因为某些原因, 这些代码中充满了 Unicode 字符。

Raku 中的空白字符集包含具有如下 Unicode 属性之一的字符:

  • Zs: 分隔符, 空白

  • Zl: 分隔符, 行

  • Zp: 分隔符, 段落

你可以在 https:/​/​en.wikipedia.​org/​wiki/​Whitespace_​character 页面列出的类别中找到完整的字符列表。其中有普通空白、垂直和水平制表符、换行符, 不可分空格和窄空格。

在更大的范围内, Raku 允许程序按照程序员的要求进行格式化。另一方面, 关于空白可能出现的地方, 有一些规则, 在编写 Raku 程序时应该遵循这些规则。

如果语言不允许在代码中的某个特定位置有空格, 但是你希望将程序格式化, 使其更宽敞, 你可以添加所谓的 unspace。这是一个以反斜杠开始的序列, 紧接在前一段代码之后, 后面是一个或多个空格字符。它类似于 Unix 命令行指令末尾的反斜杠, 用于继续下一行。

让我们来看看最重要的情况, 语言中关于空白的规则很严格, 可能与你的习惯相冲突。

第一个例子是函数调用。在 Raku 中, 函数的参数周围不需要圆括号, 但只要你使用括号, 函数名和左开口圆括号之间就不能有空格。检查下面的三个调用:

say add 4, 5;   # OK, no parentheses
say add(4, 5);  # OK, no space
say add (6, 7); # Error

前两行是正确的, 而最后一行产生了编译时错误, 如下所示:

Too few positionals passed; expected 2 arguments but got 1

这个错误消息听起来可能会有误导性, 但请记住, 在 Raku 中, 你可以向函数传递数组。在这种情况下, 编译器不能保证正确理解程序员的意图。add (6, 7) 结构可以解释为调用一个带单一参数的函数, 这个参数是一个双元素数组——(6,7)

如果你仍然喜欢将参数列表和函数名称直观地分开, 可以在它们之间放置一个 unspace, 如下所示:

say add\ (6, 7);

现在它正在编译, 没有任何抱怨。在 unspace 内部使用换行符也是允许的; 考虑以下例子:

say add\
(6, 7);

也可以采用不同的格式化方式, 将开口圆括号与函数名留在同一行, 如下所示:

say add(
    6, 7
);

当你需要传递许多参数时, 这种方法可能会很方便, 例如, 给每个参数添加注释:

say add(
    6, # first argument
    7  # second argument
);

我们将在第六章的《子例程》中更多地讨论函数。不过, 现在, 让我们回到组织源代码的方法上。

2.3. 注释

在第一章《Raku 是什么》中, 我们已经看到了两种编写程序的方法 - 一是在 -e 命令行选项中传递, 二是将代码保存在文件中。当然, 所有大型程序都是保存在文件中。在本节中, 我们将看到 Raku 如何通过添加注释来帮助程序员更好地组织代码。

注释是源代码的一部分, 编译器会忽略这些内容。注释的目的是为了提供有关该程序本身的额外信息。好的注释的例子是对所用算法的解释, 或变量的目的, 或函数的输入参数的描述。

Raku 提供了两种方法, 通过使用注释和所谓的 Pod 来保存源代码中面向人类的额外信息。首先, 我们来研究一下注释。

注释是源代码中的片段, 编译器不会将其视为直接执行的指令。在 Raku 中, 有三种方式可以留下注释:

  • 单行注释,

  • 多行注释,

  • 嵌入式注释。

让我们来详细研究一下它们。

2.3.1. 单行注释

使用 字符将单行注释与程序源代码分开。编译器会跳过 符号后面的所有内容, 直到当前行结束。

在下面的例子中, 我们把第一章的《Raku 是什么》中的 'Hello, World!' 程序作为例子, 并在其中添加了一个单行注释:

say 'Hello, World!'; # prints 'Hello, World!'

这是一个完全正确的 Raku 程序。它有一个内置的 say 函数的调用和一个关于它的注释。字符串中从 # 字符开始的部分是一个单行注释。

如果你运行这个程序, 它会打印出 Hello, World!。仅此而已。它和没有注释的程序的作用完全一样。

单行注释也可以占用单独的一行代码。例如, 让我们在同一程序中添加更多的注释:

 # This is a program in Raku.
# It prints the 'Hello, World!' string.
# To run it, install Rakudo Star and
# run it from the command-line:
# raku ./hello.pl

say 'Hello, World!'; # it prints the string

# The program ends here.

这也是一个完全有效的 Raku 程序。从业务逻辑的角度来看, 与前面的例子一样, 程序没有变化, 但是从以后维护的角度来看, 它变得更好了, 因为它解释了它的作用。

使用单行注释的另一种常见做法是暂时隐藏代码段。例如, 在调试过程中, 你想禁用某些操作。你可以通过在这行代码的开头加上 # 字符来代替删除这行代码。以下面几行代码为例:

say 'Hello, World!';
# print "Hello, World!\n";

有时, 你会做相反的事情, 即在程序中添加额外的打印指令, 以查看不同变量的值, 并在代码生产就绪之前注释掉这些指令。

下面是一个例子, 说明你如何打印传递给函数的值。我使用了第一章《什么是 Raku》中的加法例子:

sub add($x, $y) {
    say "x = $x, y = $y";
    return $x + $y;
}

这个程序只是简单地打印了加法的结果, 但如果你取消了子例程代码的第一行注释, 它也会以人类可读的格式打印出 $x$y 变量的值。如下面的代码所示:

x = 4, y = 5
9

2.3.2. 多行注释

虽然单行注释可以用来提供大块的文档, 但必须在每行中放置 # 字符会使注释本身难以维护。例如, 如果你修改了注释的文本, 你可能也想把整个段落重新整理, 使所有行的都差不多长, 使整个注释在视觉上更有吸引力。

在 Raku 中, 支持多行注释。多行注释的语法如下。它从 ` 序列开始(与单行注释中的 字符相同, 后面是一个反引号符号)。然后注释的主体部分进入。它必须用一对括号括起来。

例如, 可以这样使用花括号:

#`{This program in Raku
prints the 'Hello, World!' string}

say 'Hello, World!';

这里, 注释位于源代码的两行中, 但没有必要用 # 字符标记每一行。

还可以使用其它的环绕字符对。例如, 圆括号或方括号:

 #`(A multi-line comment
placed between pair of parentheses)

#`[Another multi-line comment,
this time in square brackets]

虽然注释的目的首先是为了让人类阅读, 但编译器必须理解注释的起始和结束的位置。在前面的例子中, 注释正文的结束字符是由 #` 序列后的相应字符定义的。

如果它是一个开口花括号({), 那么编译器就会扫描下面的文本, 并寻找对应的闭合字符, 也就是本例中的 } 字符。这也意味着你不能在注释的文本中使用闭合花括号, 因为它将被视为注释的结尾。

在注释中使用这样的字符的方法之一是使用不同的括号对。例如, 如果整个注释被嵌入在一对圆括号中, 那么在该注释的正文中使用闭合花括号是安全的, 如下所示:

#`(A multi-line comment
in parentheses and it contains the } character inside
it)

另外, 如果使用相同字符的平衡字符对也不会有问题。例如, 考虑一下下面这段代码:

#`(Function add(x, y) adds two numbers
and returns their sum)
sub add($x, $y) {
    return $x + $y;
}

这里, 注释使用的是 #`(...) 括号对, 但它里面又包含了另一对括号: add(x, y)。在这种情况下, 程序是正确的, 它会根据程序员的意图结束注释。

第二种允许在注释中使用相同字符的方法是使用一个以上的字符序列来标记注释。

例如, 一对双括号就可以这样操作:

#`{{Two characters at the beginning
let us easily include the closing } brace, for
example}}

另一个好的方法是使用不同字符的组合。闭合字符序列应该与开口字符序列相仿, 如下所示:

#`([Another way of having
a closing ] character inside the comment])

最后, 单行注释可能出现在多行注释里面。在这种情况下, 他们将只是其中的一部分, 如下面的例子所示:

#`{If you want to print the value of the variable $x,
find the following line in the code:
# say $x
and uncomment it.}

2.3.3. 嵌入式注释

Raku 中的嵌入式注释是使用多行注释的语法, 但放在主代码内部的注释。与单行注释不同的是, 嵌入式注释不会传播直到行尾, 并且可以由闭合字符结束。

让我们以 add 函数中的嵌入式注释为例进行演示, 如下所示:

sub add($x, $y) {
    return $x + #`(this is numeric addition) $y;
}

#`(this is numeric addition) 注释告诉读者, + 运算符希望它的操作数(例子中的变量 $x$y )是数字值(例如, 与字符串的连接不同)。整个注释被嵌入到 $x + $y 表达式中。注释结束后, 普通代码流继续进行。编译器会忽略注释, 因此, 该行在语法上仍然是正确的。

嵌入式注释应该尽可能简短, 以使整个代码更容易阅读。在调试程序的过程中, 用它来给出一些小的解释, 或者在调试程序的过程中暂时停用某个代码片段。

2.3.4. 创建 Pod 文档

Pod 是 Raku 中的子语言, 用于编写文档。它可以被认为是注释的扩展版本, 它允许表达内容的功能更多。Raku 中的 Pod 是由 Perl 5 中 POD(Plain Old Documentation)演变而来。在 Raku 中, Pod 这个名字不是缩写, 因此不需要大写。在本节中, 我们将研究 Pod 标记语言的语法。

Pod 内容与 Raku 程序本身放在同一个源文件中。一个 Pod 小节是一系列包含一些文本信息的行。编译器在看到 Pod 部分的开始和结束标记时, 会在解析 Pod 和 Raku 之间进行切换。Pod 语法是为了表达文档的语义而设计的, 也是为了帮助将文档组织得更有条理一些。

我们来看看如何创建一个 Pod 块。有几种类型的 Pod 块, 也有几种创造 Pod 块的方法。

2.3.5. =begin / =end Pod 块

Pod 块以 = 符号开始, 该符号应该是该行中的第一个非空格字符(因此, 你不能像在同一行中的单行注释-下一个代码一样开始一个 Pod 块)。

这个块用 =begin=end 指令来标记, 每个指令必须有一个 Raku 标识符来描述 Pod 块中的数据类型。有几个预定义的标识符, 它们可以完全小写, 也可以完全大写。请看几个最有用的 Pod 块的例子。

=begin pod
This program is the first program in Raku.
=end pod

say 'Hello, World!'

这里, Pod 块以 =begin pod 开头, 并以 =end pod 结束。块之外的所有内容都是一个普通的 Raku 程序。

如果只是运行程序, 那么 Pod 块就会被忽略。将程序保存到一个文件中, 然后用命令运行, 方法如下:

$ raku pod.pl
Hello, World!

在第一章《什么是 Raku》中我们考察了 Rakudo Star 编译器支持的不同命令行选项。现在是时候使用其中的一个 --doc 来看看编译器是如何从程序中提取出 Pod 文档, 并在不执行程序本身的情况下打印出来的:

$ raku --doc pod.pl
This program is the first program in Raku.

在前面的例子中, 块的类型是 pod。还有其他类型的 Pod 块。

table 类型创建一个带有表的 Pod:

=begin table
    Language    Year of appearance
    C           1973
    C++         1983
    Perl        1987
    Raku      2000
=end table

你可以自己在源代码中对表做一些最低限度的格式化, 但是 Pod 解析器和格式化程序(我们的例子中是 Rakudo 的一部分)会做一些额外的工作来很好地显示表。如果你用 --pod 命令行选项运行程序, 表格就会是这样:

$ raku --doc pod.pl
Language  Year of appearence
C         1973
C++       1983
Perl      1987
Raku    2000

请注意, 缩进丢失了, 并且表的行从行的开头打印。另一个变化是列之间有两个空格, 而我们在源代码中有更多空格。

该表可以选择包含一个标题, 你可以使用所谓的副词语法将其放在 =begin 行中, 如下面的例子所示:

=begin table :caption<History of Programming Languages>
Language    Year of appearance
C           1973
C++         1983
Perl        1987
Raku      2000
=end table

标题将被打印在表格的上方:

$ raku --doc pod.pl
History of Programming Languages
Language  Year of appearance
C         1973
C++       1983
Perl      1987
Raku    2000

在我们进一步研究 Raku 中其他类型的 Pod 块之前, 让我们先来学习一下其他类型的 Pod 块的声明语法:

  • 缩写块

  • 段落块

在缩写块中, 开头 = 符号后面紧跟着 Pod 块类型的名字。不再需要闭合的 =end 指令, Pod 块的结尾将以空行或另一个 Pod 块的开始来表示。

下面是另一个使用缩写语法的表块的例子:

=table
    Language    Year of appearance
    C           1973
    C++         1983
    Perl        1987
    Raku      2000

say 'Ok';

带有表格的 Pod 块在空行之前结束。在这之后, 编译器又切换回解析 Raku 代码。

在段落块中, 使用 =for 指令开始 Pod 部分, 后面是标识符。因此, 最后一个例子可能以 =table=table 开始。

这种语法对于内容通常较短的其他类型的 Pod 块来说比较自然。例如, 标题或列表项。在这种情况下, 块的内容会在同一行中提供, 紧接在开口指令之后, 就像下面的例子所示, 它反映了当前章节的部分内容:

=head1 Writing Code
=head2 Comments in Raku
=item One-line comments
=item Multi-line comments
=item Embedded comments

使用 --doc 命令行选项提取文档, 会产生以下输出:

Writing Code
  Comments in Raku
  * One-line comments
  * Multi-line comments
  * Embedded comments

它包含了两层标题和一个简单的项目列表。

2.4. phaser

在 Raku 中创建程序时, 要明白控制程序的流程比简单地按照代码指令来执行要麻烦一些。有一些特殊类型的代码块会在编译器在编译和执行过程的不同阶段自动调用。这些代码块称为 phaser

我们在第一章《Raku 是什么》中谈论编译器的 -c 命令行选项时提到了其中两个, BEGINCHECK。现在, 我们来看看剩下的部分。

从语法上讲, phaser 是用花括号括起来的代码块, 其前面是 phaser 的名称。下表总结了 Raku 中存在的不同的 phaser。有些 phaser 是在编译时执行, 然后再编译和执行程序的其余部分。有些是在运行时调用的。

Phaser 名

执行阶段

动作

BEGIN

编译时

该块是在主程序编译之前调用的

CHECK

编译时

该块是在编译完成后, 编译阶段即将停止时调用的

INIT

运行时

该块是在程序编译完成并准备运行时调用的

END

运行时

该块是在程序执行完毕, 准备退出时调用的

让我们扩展一下 'Hello, World!' 程序并为它添加一些 phaser:

BEGIN {
    say 'BEGIN 1';
} END {
    say 'END';
}

say 'Hello, World!';

BEGIN {
    say 'BEGIN 2';
}
CHECK {
    say 'CHECK';
}
INIT {
    say 'INIT';
}

这段代码产生以下输出:

BEGIN 1
BEGIN 2
CHECK
INIT
Hello, World!
END

在这个例子中, 请注意 phaser 块的几个特点。这里有两个 BEGIN 块, 它们是按照它们在源代码中出现的顺序执行的。另外, 块的实际位置并不是很重要。例如, END 块位于主程序之前, 但在主程序之后执行。同样, 第二个 BEGIN 块和 CHECKINIT 块位于主程序之后, 但在主程序之前被调用。

Phaser 是很好的候选者, 它可以在程序即将开始或结束时做一些工作。例如, 你可以用 BEGIN 块检查程序是否在正确的环境中运行。在 END 块中, 你可以在程序退出前关闭所有打开的文件或打印一些东西到日志中。

在第十章《处理异常》中, 我们将使用另外两个 phaser-CATCHCONTROL

在 Raku 中还有很多 phaser, 它们在程序执行过程中帮助组织钩子, 比如当程序流进入或离开某个代码块时, 会调用 ENTERLEAVE。关于这些 phaser 的详细描述, 请参考文档页面 docs.raku.org/language/phasers

2.4.1. 简单的输入和输出

在前面的例子中, 我们使用内置的 printsay 函数将一些东西打印到控制台(说得更严格一点, 是指程序附带的标准输出)。在本节中, 你将学习如何从标准输入中执行基本的读取。这基本上是程序如何把你输入的东西拿到控制台上。

为了读取输入, 可以直接使用一些函数而无需加载任何模块。它们列在下面的表格中:

函数

它是什么

get

这是从输入中读取一行并返回

lines

这将返回包含来自于标准输入的数据行的行的列表

slurp

这将返回一个包含整个输入的字符串

当你需要逐行解析输入数据时, 可以使用 getline 函数。例如, 如果你知道输入的结构, 你可以根据需要调用 get, 或者创建一个循环并迭代 lines 返回的数组。

slurp 函数可以一次完成工作。比如说, 你可以用它把所有东西从输入复制到输出。下面这个程序就是这样做的:

print slurp;

还有另一个有用的函数: prompt。使用它可以同时进行两个操作: 该函数在屏幕上打印一个文本信息, 并返回用户输入的字符串。这个函数可以阻止程序的执行, 直到用户用换行完成输入。

让我们用一个计算圆的周长的例子程序来演示一下 prompt 函数的工作。下面的程序向用户请求半径, 然后打印出结果。

say 'The circumference is ',
    tau * prompt 'Enter the radius > ';

程序首先打印出提示消息 Enter the radius >, 然后等待用户输入一个数字并按 Enter 键。然后, say 函数会打印出另一条消息, The circumference is,, 并把输入值乘以 τ 后得到的值加在后面, 正如我们本章的使用 Unicode 部分中看到的那样, 等于 2π。我们可以在下面的代码中看到:

$ raku circumference.pl
Enter the radius > 12
The circumference is 75.398223686155

我们故意没有引入任何变量(我们将在后面的第三章《使用变量和内置数据类型》中讨论)。请注意, 实际输出是从右到左: 首先是来自 prompt 的消息, 然后是 say 打印的文本。

更复杂的输入和输出, 以及文件的处理, 我们将在第九章《输入和输出》中讨论。

2.5. 总结

在本章中, 我们研究了 Raku 程序的组织方式。源代码是用 Unicode 编写的, 在语言的语法中, 有很多 Unicode 元素可用来使程序更有表现力。我们还研究了如何创建和使用注释, 从单行注释到可能包含程序文档的更大的 Pod 块。我们还研究了如何将源代码中的部分放在不同的 phaser 来改变程序的流程。最后, 你还学会了一种获取用户输入的方法。

现在, 我们准备创建真正的 Raku 程序了。在下一章中, 我们将讨论 Raku 中的数据类型以及如何使用变量。

3. 变量和内置数据类型

Raku 是一种渐进类型的语言。这意味着你不需要指明你所创建的变量的类型:你可以自由地使用同一个变量来存储不同类型的数据。但是,你也可以创建一个类型变量,在这种情况下,编译器会检查该变量的用法,并确保该变量只用于该类型允许的操作。

在这一章中,我们将先了解 Raku 的内置类型,然后再学习如何使用变量:

  • 内置数据类型

  • 类型层次结构

  • 变量

  • 标量、数组和散列

  • 数据类型的面向对象属性

  • 简单和复合数据类型

3.1. 使用变量

在任何编程语言中,变量都被命名为内存块,可用于存储和检索值。 在 Raku 中,变量是一个容器,可以容纳一种类型的值,这些值可以是语言中内置的,也可以是用户创建的。

3.1.1. 声明变量

每一个变量在程序中使用之前都必须先声明。你不需要在程序开始时就声明所有的变量。从实际的角度来看,声明的位置可以(也应该)尽可能地靠近它第一次使用的地方。这样做的最实际的原因是为了让变量的可见性更好—​如果你过早地声明,就会迫使程序的读者去思考这个变量的用途;另一方面,如果你在代码中做了修改,如果变量声明的位置不靠近它使用的地方,就有很大的几率会忘记去掉。

要声明一个变量,请使用 my 关键字,如下所示:

my $x;

可以在初始化的同时声明一个变量:

my $x = 42;

Raku 还定义了常量的概念。这些变量的值只能在初始化器中设置一次。要创建一个常量,请使用 constant 关键字,如下所示:

constant $C = 10;

不能给常量指定一个新的值。

现在,让我们来看看 Raku 中都有哪些变量可用。

3.1.2. Raku 中的变量容器

变量容器有三种基本类型: 标量、数组和散列。首先,你将学习如何在代码中使用它们的基础知识。然后,在本章后面的《使用内置数据类型》一节中,我们将深入了解该语言中可用的数据类型。

容器的结构类型由一个特殊的字符 sigil 表示。它总是在变量名之前,在很多情况下,它可以被认为是变量名的一部分。

变量名是一个标识符。标识符是由字母字符、数字、下划线字符和连字符组成的字符串。第一个字符不能是数字或连字符。字母数字字符是 Unicode 意义上的,因此,与连字符一起使用,可以创建非常有表现力的变量名。标识符是区分大小写的。

在下面的章节中,你将看到关于变量命名的例子。注意, 变量总是以 sigil 开头,而裸标识符可以是函数名或类名,我们将在本书的其他章节中看到。

3.1.3. 标量

标量是一个可以保留单个值的容器,例如整数、字符串或对象。

标量变量使用 $ sigil。我们在前面的章节中已经看到过一些例子,这里再举几个例子。注意,同一个标量变量,如果没有显式地声明数据类型,可以在不同时刻托管不同类型的值:

my $x = 42;
say $x;
my $y = $x * 2;
say $y;

$x = 'Hello, World!';
say $x;

(当然,在程序流中最好不要改变数据的类型。)

在双引号中的字符串内部,标量变量被插值, 并以其当前值代替。在下面的程序中,将一个方程的计算过程以字符串的形式打印出来:

my $a = 3;
my $b = 4;
my $c = sqrt($a * $a + $b * $b);

say "If the legs of a right triangle are $a and $b, ";
say "then the hypotenuse is $c.";

此代码打印出以下输出:

If the legs of a right triangle are 3 and 4, then the hypotenuse is 5.

现在,让我们进入下一个类型的变量-数组。

3.1.4. 数组

数组变量可以托管多个值。这些值可以是相同类型的,也可以是不同类型的。数组通常用来保存数据项的列表。

在 Raku 中, 数组的前缀是 @ sigil。要访问数组中的元素,需要使用方括号的后缀对儿。例如,@a 数组的第二个元素是 @a[1]。 注意,索引从 0 开始。

我们来看看如何创建一个整数数组:

my @odd_numbers = 1, 3, 5, 7, 9, 11;

另外,也可以使用圆括号或角括号。下面的两个数组与前一个数组相同:

my @array2 = (1, 3, 5, 7, 9, 11);
my @array3 = <1 3 5 7 9 11>;

当使用 say 内置的函数打印时,Raku 将数组的内容用方括号打印出来,如下所示:

say @odd_numbers; # [1 3 5 7 9 11]

下面是另一个包含混合类型数据的数组的例子:

my @array = 1, 'two', 3E-2;

这里所有的元素都是不同类型的(整数、字符串和浮点值),但是通过索引可以很容易地访问它们:

say @array[0]; # 1
say @array[1]; # two
say @array[2]; # 0.03

让我们进一步了解一下 Raku 中数组提供的可能性。

数组类型的方法

Raku 中的数组实际上是 Array 类的一个对象。使用类是第八章《面向对象编程》中的一个主题。到目前为止,我们将讨论如何在 Raku 程序中访问数组的不同属性。

要获取数组的长度,可以调用 elems 方法,如下所示:

my @a = 1, 3, 5;
say @a.elems; # 3

pushpopappend 这三个方法修改数组: push 在数组的末尾添加一个新元素; pop 获取最后一个元素,将其从数组中删除,然后返回它; append 在末尾添加新元素,与 push 不同的是,append 可以添加多个新元素。让我们来看看下面这个程序的输出:

my @a = 1, 3, 5;

@a.push(7);
say @a; # [1 3 5 7]

say @a.pop; # 7
say @a; # [1 3 5]

my @b = 9, 11;
@a.append(@b);
say @a; # [1 3 5 9 11]

另外,也可以使用函数代替方法。前面的程序可以用不同的方式来编写,如下所示:

my @a = 1, 3, 5;

push @a, 7;
say @a; # [1 3 5 7]

say pop @a; # 7
say @a; # [1 3 5]
my @b = 9, 11;
append @a, @b;
say @a; # [1 3 5 9 11]

接下来的一组,unshiftshift`和 `prepend,是与 pushpopappend 相辅相成的三种方法。unshift 方法将一个元素添加到数组的开头; shift 方法删除并返回第一个元素; prepend 方法将新元素添加到开头。下面的代码块演示了使用这些方法的效果:

my @a = 1, 3, 5;

@a.unshift(7);
say @a; # [7 1 3 5]

say @a.shift; # 7
say @a; # [1 3 5]

my @b = 9, 11;
@a.prepend(@b);
say @a; # [9 11 1 3 5]

splice 方法将数组切割成三部分,并有选择地用新的列表可替换中间的部分。splice 方法的前两个参数是将被删除或替换的第一个元素的索引和该片段的长度。例如,考虑一下下面的代码:

my @even = 2, 4, 6, 8, 10, 12, 14, 16, 18, 20;
@even.splice(4, 3);
say @even; # [2 4 6 8 16 18 20]

这里,将从原始数组中删除三个索引为 4、5 和 6 的元素。

在下一个例子中,同样的元素被替换为值 100 和 200:

my @even = 2, 4, 6, 8, 10, 12, 14, 16, 18, 20;
@even.splice(4, 3, (100, 200));
say @even; # [2 4 6 8 100 200 16 18 20]

替换的长度不需要与被删除部分的长度相同。

3.1.5. 散列

在数组中,索引是从 0 开始的整数。在 Raku 中, 散列是另一种结构类型的数据,它可以视为数组, 其索引是字符串。

散列使用 % sigil。不同编程语言中的散列的其他名称还有关联数组、字典或词典以及映射。当你需要把几个值放在一起时, 散列是非常有用的。例如,看看下面的代码片段:

my %city =
    name => 'London',
    country => 'gb',
    latitude => 51.52,
    longitude => 0,
    area => 1577,
    inhabitants => 8_700_000;

散列的元素是对儿,反过来又是两个东西 - 键和值。在这个例子中,%city 散列的键是 namecountry 等,它们的值是 London`和 `gb

在这种赋值中,代码的布局可以改变,使键和值对齐,就像你在这里看到的那样:

my %city =
    name        => 'London',
    country     => 'gb',
    latitude    => 51.52,
    longitude   => 0,
    area        => 1577,
    inhabitants => 8_700_000;

在赋值中,可以用括号将散列对包围起来,如下所示:

my %city = (
    name => 'London',
    country => 'gb',
    latitude => 51.52, longitude => 0,
    area => 1577,
    inhabitants => 8_700_000);

当印散被打印出来(say %city)时,它会显示在一对花括号中,如下面的几行代码所示:

{area => 1577, country => gb, inhabitants => 8700000, latitude => 51.52, longitude => 0, name => London}

如果有相同名称的键,那么最后一个键获胜。考虑一下下面的散列创建:

my %city =
    name => 'London',
    name => 'Paris';
say %city;

这个程序只打印 {name ⇒ Paris}

本节的信息已经足够我们继续学习 Raku 中的类型。

散列类的方法

我们来看看散列有哪些方法。

首先,keysvalues 这两个方法,返回包含了散列的所有键和值的列表(严格来说是序列)。

my %capitals =
    Spain => 'Madrid',
    Italy => 'Rome',
    France => 'Paris';

my @countries = %capitals.keys;
my @cities = %capitals.values;

say @countries; # [France Italy Spain]
say @cities;    # [Paris Rome Madrid]

kv 方法返回的是一个包含键和值的列表:

say %capitals.kv; # (France Paris Italy
                  #  Rome Spain Madrid)

类似的方法是 pairs,返回一个对儿的列表(对儿是包含一个键和一个值的数据类型):

say %capitals.pairs; # (France => Paris
                     #  Italy => Rome
                     #  Spain => Madrid)

要反转这些对儿,使用 antipairs 方法,如下所示:

say %capitals.antipairs; # (Paris => France
                         #  Rome => Italy
                         #  Madrid => Spain)

散列的大小,实际上就是其中的对儿的数量,由 elems 方法返回, 如下所示:

say %capitals.elems; # 3

3.1.6. 命名约定

Raku 并没有强迫用户遵循任何特定的变量命名规则。不过,最好还是遵循一般的常识性方法。变量名可能只有一个字母那么短,但也可以是描述性的,并且包含很多单词。

在循环中,或者在一些计算中,单字母名称是最好的选择,因为在循环中,所有提到的变量都是紧凑的,并且在屏幕上可以清楚地看到。当然,单字母名称可以使用小写字母和大写字母。虽然在 Raku 中没有标准,但在文档中常量和伪常量都使用大写的名字;例如,请查看 link:https:/​/​docs.​perl6.​org/​language/ variables#Compile-​time_​variables[]。

下面是一些单字母名称的例子:

constant $N = 100;
my $n = prompt('Enter a number: ');

say "You entered $n";
say 'This number is too big' if $n > $N;

对于较长的名字,有几种选择。要么以小写字母或大写字母开头,要么整个名字大写。同样,大写的名字,比如 $MAXIMUM,即使你不使用 constant 关键字,也最好用大写的名字来表示常量。一般来说,更偏爱小写的名字。让我们重写前面的程序,使其使用较长的变量名:

constant $MAXIMUM = 100;
my $value = prompt('Enter a number: ');
say "You entered $value";
say 'This number is too big' if $value > $MAXIMUM;

在很多情况下,甚至需要更长的名字。在这种情况下,有几种方法可以用两个或更多的单词来构造名称。首先,可以使用所谓的驼峰式的大小写名称,例如 $userValue$valueFromInput。其次,下划线字符是一个很好的候选者,可以将名字中的部分连接起来-$user_value$value_from_input;这种风格被称为 snake case。最后,Raku 允许用破折号来表示多余的名字,例如 $user-value$MAXIMUM-VALUE(kebab case)。在这种情况下,- 字符不是减号运算符,而是名称的一部分。所以,$uservalue$userValue$user_value$user_value$user-value 是四个不同的名字。考虑一下下面的代码片段:

constant $MAXIMUM-VALUE = 100;
my $entered-value = prompt('Enter a number: ');
say "You entered $entered-value";
say 'This number is too big'
    if $entered-value > $MAXIMUM-VALUE;

选择自己的风格,并尽量在整个程序中保持一致。

3.1.7. 类型化的变量

在前面的例子中,托管在变量容器中的内容类型是由分配给变量的值来定义的:

my $x;      # Declaring a variable as a container.
$x = 2;     # Now it contains an integer.
$x = 'Two'; # But now it keeps a string.

Raku 允许你通过将变量容器的类型与变量声明一起指定,使变量容器的类型变得严格:

my Int $x = 2;

这里,$x 变量只能接受整数。例如,如果试图将其分配给一个字符串,会导致以下错误:

$x = 'Two'; # Type check failed in assignment to $x;
            # expected Int but got Str ("Two")

同样,Raku 允许在同一个数组中使用不同类型的元素:

my @a = (1, 'two', 3.0);

声明一个带类型的数组,会使其元素成为带类型的值。这意味着你不能给它分配另一种类型的值,如下面的例子所示:

my Int @a;
@a = 1, 2, 3;
say @a;
@a[2] = 'Two';

最后的赋值会导致类型检查错误:

Type check failed in assignment to @a; expected Int
but got Str ("Two")
in block <unit> at typed-arr.pl line 7

类型化的变量可以使用任何内置类型或用户定义的类。在下一节中,我们将讨论 Raku 中默认的数据类型。在第八章《面向对象编程》中,你将学习如何创建自己的类。

3.2. 使用简单的内置数据类型

Raku 自带了许多不同的内置类型,这些类型涵盖了一些常见的东西,如布尔、整数、字符串等,但也提供了一些不常见的数据类型。我们将在本节中介绍这些类型。为了演示这些内置类型,我们将使用 say 函数将它们打印到控制台,就像我们在 'Hello, World! 例子中所做的那样。

e8 290655.jpg@596w 1l

层次结构是用两种类型的项来建立的:角色和类。角色是用椭圆画的,而类是矩形框。角色类似于某些编程语言中的接口。在本章中,我们不会重点讨论什么是角色或类的细节。你可以在第八章《面向对象编程》中详细了解。现在,我们将假设你对面向对象编程有了一些基本的理解,并且能够理解数据类型的层次结构。

在本章下面的章节中,我们将对你在实践中可能会用到的主要数据类型进行梳理。

3.2.1. 整数数据类型

在 Raku 中,Int 类型的值是一个整数值。该值可以容纳正数和负数,也可以容纳零,而且语言不限制数值的大小。它可以小到一个字节,例如,看看下面的例子:

say 42;

它也可以是任意精度的,如下所示:

say 239874637819093248768900298372340;

在前面的例子中,使用了普通的十进制符号。Raku 允许使用其他的基数;例如,十六进制的值可以使用 16。要创建一个除 10 以外的整数基数,可以使用所谓的副词记号,如下例所示:

say :16<D0CF11E>;

这将打印出 218951966,这是 :16<D0CF11E> 整数的十进制表示。

以同样的方式,你将用其他基数创建值。考虑一下下面的例子:

say :8<755>;
say :2<10101>;

前面的两行代码会分别打印出 49321

基数值不一定是 2 的次方。其他在 2 到 36 之间的整数值也是允许的,例如,考虑以下几行代码:

say :5<342>;
say :30<102spqr>;

在第一个例子中,基数是 5,因此,在表示数的时候可以用 0 到 4 的数字来表示。值 :5<342> 对应于十进制形式的 97

在第二个例子中,我们可以自由使用更多的"数位",即 30 位。这些数字是常规的阿拉伯数字 0 到 9,后面是 20 个从 a 到 t 的拉丁字母。:30<102spqr> 的十进制值是 731399307

你可能已经注意到,在前面的例子中,有些字母数字是大写的,有些是小写的。对于 Raku 来说,没有什么区别;当整数数字包含字母字符时,不区分大小写。所以,:16<D0CF11E>:16<d0cf11e>,以及 :30<102spqr>:30<102spqr> 是等价的。

大小写不敏感间接地定义了整数允许的基数范围;由于我们有 10 个阿拉伯数字和 26 个拉丁字母,因此它们的组合给出了 36 个不同的字符。

在 Raku 中,长整型数值(也就是有很多位数的整数)可以用下划线字符来拼写成数字组。该功能最直接的目的是提供了将数字分割成三位数组的方法。例如,考虑一下下面这行代码:

say 75_926_028;

在这里,75_926_028 的记号,无非是给出了 75926028 这个数字,但它让我们清楚地看到这个数字是 7500 万,926 个千,加上 28。对于编译器来说,没有什么区别,两个数字同样容易读懂。而对于人类来说,把一个数字拆成一组,就更容易读懂了。

严格来说,你不受限于拆分数字的方式。也就是说,下面的格式在形式上是正确的:

say 2_12_85_06;

这种格式可能适用于电话号码,但不适用于普通整数。

但是,你不能在一行中出现两个下划线。数字也不能以下划线开头或结尾。下面的三次尝试将无法编译:

say 20__17;

此代码将产生编译器错误:

Only isolated underscores are allowed inside numbers
say _2017;
say 2017_;

这两种情况下的错误信息比较短,编译器只是这样说的:

Confused
Int 类型的方法

Raku 中的数据类型由许多内置类表示,这意味着可以对这些类的对象调用方法。语言的面向对象的特性在第八章《面向对象编程》中已经介绍过了,但是,暂时还不能回避其中的一些元素。

在本节中,将只列出最有趣的方法。可以在文档页面 https:/​/​docs.raku.​org/​type/​Int 上获得完整的可能性列表。

现在最基本的是,最重要的是,方法的调用是使用值本身或包含相应类型值的变量上的点来调用。在下一节中,你会看到方法的调用方式,马上就可以看到。

使用 chr 方法转换为字符

可以将整数值转换为相应的字符。其对应关系由 Unicode 代码点定义。

如果是 256 以下的值,则与 ASCII 表一致。考虑一下下面的代码片段:

say 65.chr; # prints A

更高的值会从 Unicode 表中产生字符,如下所示:

say 8594.chr; # →

使用十六进制表示法也可以得到同样的结果,如下所示:

say 0x2192.chr; # →
检查数字是否为质数

is-prime 方法返回一个布尔值,告诉我们这个数是否是质数,如下所示:

say 10.is-prime; # False
say 11.is-prime; # True

在大数上执行 is-prime 方法可能会很慢。

生成一个随机数

rand 方法返回一个介于 0 和给定值之间的随机数。请注意,这个方法是从 Real 角色继承而来的(请参考《使用简单的内置数据类型》一章中开头的图表),而返回值是一个浮点数:

say 100.rand;

运行这段代码几次后,会打印出不同的值,就像你在这里看到的那样:

70.1530942429978
57.2150256026057
13.7542877975353
94.6395293813437
获取值的符号

sign 方法(也继承自 Real 角色)返回 -11,这取决于值的符号。考虑一下下面的代码片段:

say 42.sign;  # 1
say -42.sign; # -1

在 0 上调用该方法的结果是 0,如下所示:

say 0.sign; # 0

sign 方法也适用于无穷大的值:

say Inf.sign;  # 1
say (-∞).sign; # -1
计算值的平方根

要计算值的平方根,请在其上调用 sqrt 方法:

say 9.sqrt; # 3

这个例程在 Raku 中也被定义为一个独立的内置函数。考虑一下下面的代码片段:

say sqrt(9); # 3

由于 sqrt 方法继承自 Numeric 角色,所以结果是一个浮点值:

say 10.sqrt; # 3.16227766016838
获取下一个值和前一个值

predsucc 这两个方法分别返回整数参数的前一个值和下一个值:

say 42.pred; # 41
say 42.succ; # 43

这个方法也适用于非整数值的加减法,当它们加减 1 时,如下一个片段所示:

say pi.pred;    # 2.14159265358979
say (3/4).succ; # 1.75

这些方法的名称来自于 predecessor 和 successor 这两个单词。

获取绝对值

abs 方法返回绝对值。在下面的例子中,该方法是在一个变量上调用的:

my $x = -42;
say $x.abs; # 42

要在一个值上调用它,需要在负值上加上括号。否则,将对调用 abs 方法的结果应用一元减法运算符(详见第四章《使用运算符》)。

3.2.2. Rat 类型

在 Raku 中,有一个特殊的 Rat 类型来存储有理数。在很多情况下,当你处理浮点数时,Rat 会被使用。在内部,Rat 值由一对整数表示, 即分子和分母。因此,任何 Rat 数都是一个等于除法 N/D 的有理数。整数的分子部分是一个可以任意长的值。分母部分是一个 64 位的整数。

只要你有一个带小数点的常数,没有指数部分就会出现 Rat 值。请考虑下面的例子:

say 3.14;

3.14 这个字面值在这里创建了一个 Rat 值。

还有另一种语法来创建 Rat 值:像除法那样使用斜线, 并且可以选择在一对角括号中加上一个数字,如下所示:

say 1/2;
say <1/2>;

另外,你可以用 Unicode 字符来表示分数。例如,下面这一行将创建一个等于 0.5 的 Rat 数:

say ½;

Rat 值的内部结构为精确计算提供了一种奇妙的能力。与许多其他语言不同的是,在 Raku 中,浮点运算使用的是精度有限的 IEEE 数字,而在 Raku 中, Rat 数的使用有助于避免在处理小数时出现四舍五入的错误,或者是在处理两个数字非常接近的数字时,无法使用 IEEE 表示法进行精确比较。

在下面的例子中,我们将使用 Rat 数:

say 1/2 + 1/4 + 1/8 + 1/16;
say 0.1 + 0.2 - 0.3;

最后一个例子很有意思,因为它在 Raku 中打印出了 0。Raku 使用 Rat 进行计算,并在内部将这些值处理为 1/102/103/10。因此,0.1 + 0.2 - 0.3 的总和等于 1/10 + 2/10 - 3/10,结果 Rat 值为 0/10,也就是 0。在其他许多使用浮点数的语言中,包括 Perl 5,同样的计算不会产生 0。结果会很小,但仍然不是零;例如,5.55111512312578e-17

使用 Rat 进行精确计算的优势是显而易见的。例如,在财务计算中,你可以使用 Rat 数来避免四舍五入的错误。(不过,在很多情况下,在财务计算中,你可以使用整数,并以分值为单位进行计算;因此,不要把 9.99 欧元作为浮点数,而是用 999 分来操作。)

Rat 类型的方法

Rat 方法有一些特定的方法和一些从其基类或角色继承的方法,如 RealNumeric。其中一些方法已经在 Int 类型的方法一节中讨论过了。

获取值的 Raku 表示法

raku 方法返回一个可以表示 Rat 值的字符串,这个字符串可以表示 Raku 在源码中的理解。结果可以包含一个小数点或斜线,这取决于哪种方法更有利于精确地表示值。

考虑一下几个例子,就能体现出前面的想法:

my $x = 1/3;
say $x.perl; # <1/3>

my $y = 1/2;
say $y.perl; # 0.5

更有甚者,下面的代码是有效的 Raku 代码:

say 10/20.raku; # 0.5
转换为 Int 值

要将 Rat 值转换为整数值,调用 Int 方法。这就是类型转换的一般原理:数据类型定义的方法,其名称重复了其他数据类的名称。

my $x = 10/3;
say $x.Int; # 3

在这个程序中,$x.Int 的结果是3,但你要记住,赋值中的 10/3 不是除法,而是表示 Rat 数的一种方法。同样的,也可以用更明确的形式来表达,如下所示:

my $x = <10/3>;
获取分子和分母

要得到一个 Rat 值的两部分,可以使用 numeratordenominator 方法。我们来看看上一节中的值的例子,看看它们是如何工作的:

my $x = 10/3;
say $x.numerator;   # 10
say $x.denominator; # 3
四舍五入的方法

有四种不同的方法可以将 Rat 值转换为整数:roundceilingfloortruncate

round 方法根据数学定义对数值进行四舍五入:数值向最接近的整数进位。我们可以从下面的代码片段中看到:

say 3.14.round; # 3
say 2.71.round; # 3

(注意,第一个小点分隔了数字的小数部分,而第二个小点是方法调用)。

负值也会被四舍五入,使结果是最接近的整数。我们可以在下面的代码片段中看到:

say (-3.14).round; # -3
say (-2.71).round; # -3

truncate 方法只是将小数部分切掉,不考虑符号,如下所示:

say 3.14.truncate; # 3
say 2.71.truncate; # 2

say (-3.14).truncate; # -3
say (-2.71).truncate; # -2

最后,ceilingfloor 方法将数字四舍五入到下一个或上一个整数,如下所示:

say 3.14.ceiling; # 4
say 3.14.floor;   # 3

say (-2.71).ceiling; # -2
say (-2.71).floor;   # -3
方法 pred 和 succ

这两种方法的工作原理与它们对整数值的工作原理类似。整数部分返回、递增或递减,而浮点部分保持不变,如下所示:

say 3.14.pred; # 2.14
say 3.14.succ; # 4.14

3.2.3. Numeric 数据类型

Num 类型用于存储浮点值。它对应于 C 语言中的双精度。

注意,在 Raku 中,只有当数值字面值用科学符号拼写时,Num 值才会被创建。也就是说,值的 E 部分必须存在。

因此,在下面的例子中,只有小数点的数字将是 Rat 类型的:

say 3.14;    # Rat
say 123.456; # Rat
say 0.9;     # Rat

下面的数字表示相同的值,但都属于 Num 类型,因为它们在定义中使用了指数部分:

say 3.14E0;    # Num
say 1.23456E2; # Num
say 9E-1;      # Num

请记住,Num 值使用的是 IEEE 的二进制格式,所以它们的精度是有限的,而 Rat 类型的数字将其分子和分母为保存为两个整数。

Num 值中,有一个突出的 NaN 值,它代表的是 Not a Number。

Num 与 Numeric 和 Real 的比较

正如你在类型层次结构图中看到的那样,有些节点被放置在椭圆中而不是矩形框中。这些都是角色。角色提供了一些继承于类的接口。我们将在第八章《面向对象编程》中更详细地讨论角色。

Numeric 角色为我们提供的一些方法有: RealIntRatNumBool,用于将值转换为其他数据类型;loglog10exprootsabssqrt 用于相应的数学计算;以及 precsucc 对。

Real 角色类给了我们以下方法: randsignroundfloorceilingtruncate

如果你想深入挖掘并查看所有类之间的联系,请参考文档页面 https:/​/​docs.raku.​org/​type.​html

3.2.4. 枚举

枚举是用于定义数据类型,例如,某些概念的可能值。例如,交通灯的颜色有三个值:

enum TrafficLight <red yellow green>;

这些值的名字在 Raku 中会被知道,因此你可以直接在程序中使用它们。例如:

say red;

这段代码会打印出该值的名称:

red

在这个例子中,red、yellow 和 green 的实际值对我们来说并不重要,但 Raku 会给它们分配递增的整数值。

say red + yellow + green; # 6

这个程序相当于 say `0 + 1 + 2

当这些值很重要的时候,那么你可以显式指定它们,就像我们在下一个例子中所做的那样:

enum Floors (
    garage => -1, ground-floor => 0,
    first => 1, second => 2);

我们将在下一节中看到 Raku 中 Boolean 类型定义中的枚举的一个例子。

3.2.5. Boolean 数据类型

Bool 是一个布尔数据类型,提供两个值。TrueFalse。从技术上讲,这是一个枚举的两个值:

enum Bool <True False>

布尔类型的用法很简单。我们将在第五章《控制流》中看到更多布尔数据类型的用法。

布尔类型的方法

Bool 类型有一些方法,我们已经看到了 IntRat 数据的使用,但它们的行为可能略有不同。

使用 pred 和 succ

predsucc 这两种方法有一个特点,就是可用值的范围非常有限。你不应该期望值是循环的。这些方法的结果显示在下面这段代码的注释中:

say True.pred;  # False
say True.succ;  # True

say False.pred; # False
say False.succ; # True
生成随机布尔值的方法

有两个方法,pickroll,这两个方法都可以用或不用参数来调用。这些方法必须对类名本身进行调用,而不是对它的值或变量进行调用。

当调用 pickroll 没有参数时,它们会返回一个随机值,要么是 True,要么是 False。我们可以在下面的代码片段中看到这一点:

say Bool.pick;
say Bool.roll;

当用整数参数调用这些方法时,会生成一个随机值的列表。整数参数定义了列表中元素的数量,但在此基础上,pick 方法增加了它的限制,只返回唯一的值,对于 Bool 类来说,这个值不超过两个。比较类似的调用结果,如下所示:

say Bool.pick(4); # (False True) or (True False)
say Bool.roll(4); # e.g. (False True False False)

3.2.6. 字符串数据类型

在 Raku 中,字符串由 Str 数据类型表示。一个字符串是一个 Unicode 字符的序列。考虑到下面的代码片段:

my $str = 'Hello, World!';
say $str;

让我们直接进入 Str 类的方法。

Str 类的方法

重要的是要始终记住,所有的语义都与 Unicode 的规则一致。例如,这意味着,将一个字符串转换为大写字母将改变相应的字符,即使它们需要超过一个字节的内存。

转换寄存器

有很多方法可以改变字符串中字母的寄存器。第一组包含简单的 lcuc,它将所有字符转换为小写或大写。考虑下面的代码片段:

say 'String'.lc; # string
say 'String'.uc; # STRING

其他四个方法比较复杂。

fc 方法将一个字符串转换为所谓的折叠大小写。它的目的是用于字符串比较。例如,将这三种方法在字符串上调用的输出与德文字母 ß 进行比较,ß 的大写字母是 SS,但在折叠大小写中被转换为 ss。请考虑以下代码片段:

say 'Hello, Straße!'.lc; # hello, straße!
say 'Hello, Straße!'.uc; # HELLO, STRASSE!
say 'Hello, Straße!'.fc; # hello, strasse!

(请记住,自2017年6月起,德语正式有了 ß 的大写版本,我们可以在这里 link:https:/​/​en.​wikipedia.​org/​wiki/​Capital_ %E1%BA%9E[] 了解一下。方法的行为可能会改变。)

tc 方法将一个字符串转换为所谓的标题大小写,其中字符串的第一个字母是大写。

say 'hey, you'.tc; # Hey, you

请注意,如果字符串中已经包含了大写字母,它们将保持原样。

say 'dear Mr. Johnson'.tc; # Dear Mr. Johnson

使用 tclc 方法将所有其他字母转换为小写:

say 'HI THERE!'.tclc; # Hi there!

wordcase 方法将每个单词的第一个字母大写,其余的字母小写:

say 'hello WORLD'.wordcase; # Hello World
切割字符串的方法

这两种方法-chopchomp-有相似的名字,但对它们所处理的字符有不同的敏感性。chop 方法切断了字符串的最后一个字符。只有当最后一个字符是换行符时,chomp 方法才会将其切掉。

say "Text\n".chomp; # Text
say "Text\n".chop; # Text

say "Text".chomp; # Text
say "Text".chop; # Tex

另一组方法-trimtrim-leadingtrim-trailing-切断字符串开头和/或结尾的空格。请看下面的代码片段:

my $s = ' word '.trim;
say "[$s]"; # [word]

$s = ' word '.trim-leading;
say "[$s]"; # [word ]

$s = ' word '.trim-trailing;
say "[$s]"; # [ word]
检查字符串内容的方法

Str 类定义了一对方法-starts-withends-with-检查字符串的开头或结尾是否包含一个给定的子串,并返回一个布尔值。考虑下面的例子,它显示了这些方法的行为:

say 'Hello, World'.starts-with('Hello'); # True
say 'Hello, World'.starts-with('World'); # False
say 'Hello, World'.ends-with('Hello');   # False
say 'Hello, World'.ends-with('World');   # True

可以使用正则表达式来代替 starts-withends-with; 详情请参考第十一章,《正则表达式》。

另一组函数-indexrindexindices-查找子串并返回它的位置。index 方法找到子串最左边的出现,rindex 从字符串的末尾开始搜索,而 index 则返回子串所有出现的索引列表。

my $town = 'Baden-Baden';

say $town.index('Baden');   # 0
say $town.rindex('Baden');  # 6
say $town.indices('Baden'); # (0 6)

值得注意的是,虽然 rindex 方法是从字符串的末尾搜索,但它返回的是一个从左到右计算的字符索引。

字符串的长度

要获得字符串的长度,像下面这样调用 chars 方法:

say 'Düsseldorf'.chars; # 10
翻转字符串

flip 方法返回的字符串中,所有字符的顺序都是相反的,如下所示:

say 'Rose'.flip; # esoR

3.2.7. 复数

在 Raku 中,有 Complex 内置类型来呈现复数。

复数有两个部分,实数和虚数,并使用以下语法:

my $x = 3+4i;
my $y = -5i;

它不需要明确地写出实数部分,但输出总是包含它:

say $x; # 3+4i
say $y; # -0-5i
say $z; # 0+1i

创建 Complex 数的另一种方法是调用构造函数(我们将在第八章,《面向对象编程》中讨论构造函数),如下所示:

my $n = Complex.new(4, 5);
say $n; # 4+5i
复数数据类型的方法

其中有些方法我们已经很熟悉了。这些方法是 roundceilingfloortruncate,它们同时改变了复数值的实部和虚部。让我们简单地看一下其他的方法。

获取实部和虚部

这两个方法-reim-返回复数的实部和虚部,如下所示:

my $z = 4+5i;

say $z.re; # 4
say $z.im; # 5

虚数部分的返回不包含变量 i

reals 方法返回一个包含这两个值的列表,如下所示:

my $z = 4+5i;
say $z.reals; # (4 5)

3.3. 操作日期和时间的数据类型

Raku 提供了对日期和时间的内置支持,这非常方便,因为日期-时间的计算并不容易(你需要考虑闰年、额外的秒数、时区、日历校正等)。

我们将涉及两个类: DateDateTime

3.3.1. 使用 Date 类

Date 类表示日期-由年、月和日这三个数字组成的集合。要创建一个新的日期,使用这三个值调用一个构造函数:

my $date = Date.new(2017, 7, 19);
say $date; # 2017-07-19

要创建一个基于今天日期的变量, 使用 today 方法, 如下所示:

my $today = Date.today;
say $today; # 2017-07-17

要克隆一个日期,调用 clone,如下所示:

my $date2 = $today.clone;

日期的各个部分可以从日期的明确命名方法中获得,如下所示:

say $date.year;  # 2017
say $date.month; # 7
say $date.day;   # 19

另外(这已经是一个很好的奖励了),Date 可以计算一周的哪一天(周一是 1,周日是 7):

say $date.day-of-week; # 3 (Wednesday)

还有 day-of-monthday-of-year 方法:

say $date.day-of-month; # 19 (same as $date.day)
say $date.day-of-year;  # 20

有一些方法可以帮助计算周数:

say $date.week;        # (2017 29)
say $date.week-number; # 29
say $date.week-year;   # 2017

对于一月的周数,weekweek-year 方法返回的年份可能是上一年或下一年,这取决于这一天属于哪一周。例如,以 2019 年的最后一天和 2020 年的第一天为例。2019年12月31日是星期二,2020年1月1日是星期三。两天都属于同一周,所以 week-year 方法返回 2020。检查下面程序的输出,以了解该方法的工作原理:

my $d1 = Date.new(2019, 12, 31);
say $d1.day-of-week; # 3
say $d1.year; # 2019
say $d1.week-year; # 2020

my $d2 = Date.new(2020, 1, 1);
say $d2.day-of-week; # 4
say $d2.year; # 2020
say $d2.week-year; # 2020

有一个有趣的 weekday-of-month 方法,可以返回这个月在给定日期之前的这个星期的这一天的出现次数。例如,2017年7月19日是2017年7月的第三个星期三:

say $date.weekday-of-month; # 3

earlierlater 方法的帮助下,日期计算非常简单:

say $today.later(days => 2);   # 2017-07-19
say $today.later(months => 2); # 2017-07-21

要得到昨天和明天的日期,使用 predsucc 方法,我们已经在数字数据类型中看到过:

say $today.pred; # 2017-07-18
say $today.succ; # 2017-07-20

3.3.2. 使用 DateTime 数据类型

DateTime 数据类型的使用与 Date 类型的工作非常相似。在 DateTime 对象中,出现了处理时间的新字段。我们可以在下面的代码片段中看到:

my $dt = DateTime.new(
    year => 2017,
    month => 7,
    day => 19,
    hour => 1,
    minute => 46,
    second => 48);

say $dt;        # 2017-07-19T01:46:48Z

say $dt.year;   # 2017
say $dt.month;  # 7
say $dt.day;    # 19

say $dt.hour;   # 1
say $dt.minute; # 46
say $dt.second; # 48

要创建一个新的 DateTime 对象并将其设置为当前时刻,使用 now 构造函数,如这里所示:

my $dt = DateTime.now;
say $dt; # 2017-07-19T01:44:00.301537+02:00

hh-mm-ssyyyy-mm-dd 方法为时间和日期生成格式化的字符串:

say $dt.yyyy-mm-dd; # 2017-07-19
say $dt.hh-mm-ss;   # 01:45:44

打印秒数时要小心。second 方法返回一个包含分数秒的浮点数。要获得一个整数值,请使用 `whole-second ` 方法。

3.4. 总结

在本章中,我们看了 Raku 中内置数据类型的概述,并学习了如何使用变量。最重要的事实是,Raku 中的变量是不同的内置数据类型类的实例。这些类的细节位于第八章《面向对象编程》中,但要在 Raku 中成功创建和使用变量,需要一些面向对象编程的元素。

在本章的第一部分,你学习了变量容器的三种结构类型-标量、数组和散列,并研究了它们的主要方法。在第二部分中,我们深入了解了不同的数据类型,如整数、有理数、浮点数、字符串、日期和时间。

在下一章中,我们将继续研究 Raku 程序中的流程控制。

4. 使用运算符

运算符是语言语法的元素, 它们对操作数执行操作并返回结果。Raku 是一门拥有几十个运算符的语言。其中有些是继承自 Perl 5 的(直接或经过修改), 有些则是专门为 Raku 而发明的。在常规运算符集合之上, Raku 还定义了所谓的元运算符和超运算符, 它扩展了常规运算符的含义, 用于处理一组值。

在本章中, 我们将介绍以下主题:

  • 运算符分类

  • 一元运算符

  • 二元运算符

  • 三元运算符

  • 按位运算符

  • 其他运算符

  • 运算符优先级

  • 替换元运算符

  • 赋值元运算符

  • 否定元运算符

  • 反转元运算符

  • 创建超运算符

  • 超运算符的类型

  • 化简超运算符

  • 交叉超运算符

  • Zip 超运算符

  • 顺序超运算符

4.1. 运算符分类

首先, 让我们提醒自己在谈论运算符时需要的一些基本术语。考虑一个简单的例子:

my $a = 10;
my $b = 20;
my $c = 0;
$c = $a + $b;
say $c; # 30

我们专注于下面这行代码:

$c = $a + $b;

在这里, 我们告诉编译器执行两个操作 - 首先, 计算 $a$b 变量的总和, 其次, 将结果分配给第三个变量, 即 $c。在这个例子中, 有两个运算符 - +=。运算符由它们的单字符名字表示。在本例中, 选择这些名称是为了复制数学中相应的运算符。稍后, 我们将看到其他运算符的例子, 这些运算符不仅仅是一个字符。例如, 它们可以是一个由两个或三个非字母符号组成的序列, 如 >= 运算符。或者, 它们可以是一个字符串标识符, 例如-cmpeq

4.1.1. 运算符的类别

在上一节中, 我们看到了一个 + 运算符的例子, 它需要两个参数。还有许多其他运算符与 + 类似。例如, 是乘法运算符。和 + 运算符一样, 运算符也是接受两个参数并返回一个值。

my $c = $a * $b;

这种运算符称为中缀运算符, 或简称中缀。这种运算符的操作数通常被称为左操作数和右操作数。因为这种运算符接收两个参数, 所以也常被称为二元运算符。

另一种运算符只需要一个参数。这些运算符被称为一元运算符。一元运算符的一个典型例子是一元减法。在下面的例子中, 这个运算符否定了它的参数值:

my $a = 10;
my $b = -$a;
say $b; # prints -10

注意, 这个运算符与二元减法运算符使用相同的字符, 但程序员和编译器都可以区分这两者:

my $a = 10;
my $b = -$a;     # unary minus, $b becomes -10
my $c = $a - $b; # binary subtraction, $c is 20

不同的一元运算符可以放在参数之前或之后。例如, ++ 运算符有两种形式 - 前缀和后缀。下面的例子演示了这两种形式:

my $a = 10;
++$a; # prefix operator ++
$a++; # postfix operator ++

运算符的位置(它可以放在参数之前或之后)会改变它的含义。

到目前为止, 我们认识了中缀、前缀和后缀运算符。在 Raku 中还有两类运算符。

环缀运算符是另一种一元运算符。与一元运算符 - 不同的是, 环缀运算符由两个互补部分组成, 例如括号。环缀运算符唯一的操作数是放在这俩个括号之间的, 例如 [$a] 结构使用 [] 环缀运算符, 将 $a 作为参数。

最后, 还有后环缀运算符。它们需要两个操作数, 语法如下-操作数1[操作数2]。后环缀运算符最实用的例子之一是函数调用。我们已经看到过几次了-add($a, $b)

下面我们以 + 运算符符号为例, 总结一下运算符的类别:

类别

语法

中缀

操作数1 + 操作数2

前缀

+操作数

后缀

操作数+

环缀

(操作数)

后环缀

操作数1[操作数2]

4.1.2. 运算符作为函数

运算符对其参数执行一些操作。运算符的参数称为操作数。在前面的例子中, + 运算符接受两个操作数 $a$b= 运算符也接受两个操作数 - 它期望将其右边操作数的值赋值给左侧变量。

在任何编程语言中, 运算符只是一个方便的语法解决方案, 它使程序更具表现力, 并且可以通过函数调用来替换。例如, 在前面的例子中, 你写的是 $c = $a + $b, 但你也可以通过调用我们在第一章《Raku 是什么》中的 add 函数来做同样的事情。让我们重写前面的例子:

my $a = 10;
my $b = 20;
my $c = 0;
$c = add($a, $b);
say $c; # 30

sub add($a, $b) {
    return $a + $b;
}

当然, add 函数本身也使用了 + 运算符, 但我们在这里无法避免, 因为在 Raku 中没有更多的低级函数用于加法。这个例子的目的是为了说明, 运算符可以一直被当作接受几个参数并返回一个值的函数, 但你并不能直接调用它们, 而是通过一个好看的运算符。

在 Raku 中, 你可以在使用运算符时使用函数式。为此, 请使用带有运算符类别名的关键字, 后面是冒号, 运算符本身则放在角括号中。然后, 像使用函数一样传递参数。下面以 + 中缀运算符为例进行了演示:

my $a = 10;
my $b = 20;
my $c = infix:<+>($a, $b); # same as $c = $a + $b
say $c; # 40

现在, 我们来讨论一下 Raku 提供的运算符的类别。 而现在, 是时候逐一研究这些运算符了。

4.2. Raku 中的运算符

在 Raku 中, 有几十种内置的运算符。为了使概述更有条理, 我们将它们按照前面几节中描述的类别进行分组:

  • 中缀运算符

  • 后缀运算符

  • 环缀运算符

  • 后环缀运算符

在下面的小节中, 我们将对 Raku 中的运算符进行研究, 这些运算符被分成了几类。在每一个类别中, 运算符的排列顺序都是按降序排列的。

4.2.1. 中缀运算符

中缀运算符可能是该语言中最常用的运算符。它们也是最直观的运算符。

赋值运算符

= 运算符是赋值运算符。它用于将其右侧操作数的值赋值给左侧的变量。在最简单的情况下, 运算符的使用是这样的:

my $a;
$a = 42;

该操作并不只限于标量。数组、散列或类的实例(我们将在第八章《面向对象编程》中讨论类)的赋值也如预期一样。

my @a = <10 20 30>;
my @b = @a;

这里, 赋值运算符被使用了两次, 首先是初始化 @a 数组, 然后将其值赋给第二个数组 @b

乘法和除法运算符

*/ 运算符是乘法和除法运算符。必要时, 它们的操作数会被转换为数字类型。请看下面的例子:

say 10 * 20;
say "10" * "20";

两行代码均打印 200。尽管在第一行中, 乘法运算符的操作数都是数字(更准确地说, 是 Int 类型)。在第二行中, 我们试图对包含数字的字符串进行乘法运算。Raku 为我们转换了字符串, 使其成为数字, 然后 * 运算符完成了乘法工作。

*/ 运算符也适用于浮点数和复数(因为 NumComplex 类型都实现了 Numeric 角色):

say pi * e;             # 8.53973422267357
say (10+3i) * (2-3.3i); # 29.9-27i

如前面的例子所示, 复数相乘时要注意。为了得到正确的结果, 你应该把复数的各部分用括号括起来。如果你省略了它们, 编译器将解释这个表达式:

say 10 + 3i * 2 - 3.3i; # 10+2.7i

解释为如下:

say 10 + (3i * 2) - 3.3i;

在算术表达式中, */ 的优先级高于 +- , 因此所有的计算都是根据算术规则的顺序进行的。考虑以下代码块:

say 10 + 3 * 6.3 - 3; # 25.9

如果你将两个整数值相除, 那么结果是 Rat 类型, 而不是 Num 类型。要得到 Num 值, 至少有一个操作数必须是 Num, 如下所示:

say (1 / 2).WHAT;
say (1 / 2.3).WHAT;

say (1e1 / 2.3).WHAT;
say (1e1 / 2.3e-2).WHAT; # Num
say (1 / 2.3e-2).WHAT;   # Num

在非 ASCII 空间中, */ 运算符具有等价的记法; 你可以使用 ×÷ 符号来代替:

my $a = 100;
my $b = 25;
say $a × $b; # 2500
say $a ÷ $b; # 4
加法和减法运算符

+- 运算符是加法和减法运算符。操作数必须是数字类型。

关于这些运算符, 没有什么好说的; 他们的行为是不言自明的, 如下所示:

my $a = 10;
say $a + 3;  # 13

my $b = 20;
say $b - $a; # 10;

如果与 */ 等组合, 则 +- 运算符的优先级较低, 因此适用标准的算术规则。

当可以将字符串转换为数字(整数或浮点数)时, 编译器将进行转换, 并且 +- 运算符将使用两个数字作为操作数。请看下面的例子:

my $str = "42";
say $str - 2; # 40

由于这些运算符希望它们的参数是数字型的, 你不能使用 + 运算符来连接字符串。天真的将两个字符串相加所引发的异常将被编译器捕获。以下面的代码为例:

my $str1 = "Hello";
my $str2 = "World";
say $str1 + $str2; # Error

如果你编译这段代码, 则会得到一个运行时错误:

Cannot convert string to number: base-10 number must begin with valid digits or '.' in '⏏Hello' (indicated by ⏏)
     in block <unit> at add-str.pl line 3

请注意, 即使字符串以数字开头, 也不会发生字符串转换, 例如, 字符串 "10 Hello" 导致另一个错误信息:

Cannot convert string to number: trailing characters after number in '10⏏< Hello' (indicated by ⏏)

为了正确地转换为数字类型, 字符串必须包含一个数字, 而不包含其它内容。虽然允许使用空格, 但如下例所示:

my $str1 = " 10 ";
my $str2 = " 20 ";
say $str1 + $str2; # 30

要连接字符串, 请使用 ~ 运算符。

可以使用 Unicode 减号来代替 - 运算符。在终端上可能没有太大的视觉差异, 但字符的代码点是不同的-这里的 0x2D - 你可以从键盘输入 Unicode MINUS SIGN: 0x2212:

my $a = 20;
my $b = 30;

say $a - $b; # ASCII
say $a − $b; # Unicode
取模运算符

% 是模运算符。它返回其操作数除法的余数, 如下所示:

say 100 % 3; # 1
say 10 % 3;  # 1
say 5 % 3;   # 2

模运算 $a % `$b 的结果相当于下面这个冗长的表达式:

$a - $b * floor($a / $b);

这里, floor 是将值向下舍入的函数。以前面的一个例子 - 10 % 3 为例。它的结果意味着 3 可以从 10 中减去几次, 直到剩下1, 1 小于 3, 因此不能再减。

传统上, 模运算符用于整数操作数, 但它仍然可以用于有理数和浮点数。这些类型的值可以被 % 运算符接受, 而不需要类型转换。我们来看看下面的例子:

say 10 % 3.3;   # 0.1 (Rat numbers)
say 10E1 % 3E0; # 1 (same as 100 % 3 but with Num operands)
整除运算符

运算符 %% 被称为整除运算符。它告诉我们左操作数是否可以被右操作数整除而没有余数。

例如, 10 除以 3 的整除, 余数为 1, 因此, %% 运算符将返回 false。如果将 12 除以 3, 则没有余数, 结果为真, 如下所示:

say 10 %% 3; # False
say 12 %% 3; # True

$a %% $b 的结果与下面的比较相同:

($a % $b) == 0

它可以用来检查循环中的条件, 我们将在第五章《控制流》中更详细地了解循环。例如, 想要每 1000 次迭代打印一次消息, 可以写下面一段代码:

for (0 .. 100_000) {
    say $_ if $_ %% 1000;
    # do some work
}

它可以打印 1000、2000、3000 等, 这样可以看到程序的进度, 但不会让输出的数字过多。

整除和模运算符

这对运算符, divmod, 是对 /% 运算符的整数模拟。 divmod 运算符把它们的操作数当作 Int 值, 结果也是一个整数。

我们来看看几个例子:

say 100 div 3; # 33
say 10 div 3;  # 3
say 10 div 5;  # 2

mod 运算符返回整除的余数, 如下所示:

say 10 mod 3; # 1

在将非整数操作数传递给 divmod 运算符之前, 必须明确地进行转换。否则, 就会出现编译时错误, 如下所示:

$ raku -e'say 10 div 3.3'
Cannot resolve caller infix:<div>(Int, Rat); none of these signatures
match:
  (Int:D a, Int:D b)
  (int $a, int $b --> int)
  in block <unit> at -e line 1

该错误消息告诉我们, 编译器看到 div 运算符需要一个 IntRat 操作数, 而它只期望在那里看到 Intint

按位运算符

以加号开头的三个运算符, 即 +&, +|+^, 是位运算符, 对操作数进行 AND、OR 和 XOR 运算。操作数必须可以转换为:

say 1024 +| 512; # 1536
say 512 +| 512;  # 512

say 1024 +& 512; # 0
say 512 +& 512;  # 512

say 1024 +^ 512; # 1536
say 512 +^ 512;  # 0

在操作数的相应位上独立地进行位运算。

如果操作数不是整数, 则通过调用 .Numeric.Int 方法将它们转换为整数。因此, 首先, 操作数被转换为 Numeric 值, 然后再转换为 Int 值。从实际角度来看, 这意味着, 浮点值将被截断。下面的例子与前面的例子进行了比较:

say 512.67 +| 512;  # 512
say 512.67 +& 512;  # 512
整数移位运算符

+<+> 运算符是整数移位运算符。他们将其整数操作数的位按第二个操作数所指示的距离向左和向右移动。考虑以下代码行作为例子:

say 512 +< 2;  # 2048
say 2048 +> 2; # 512
字符串逻辑运算符

这些运算符是对字符串的逻辑运算符。他们以 ~ 字符开头, 遵循字符串操作使用波浪线的一般思想。&|~^ 运算符分别进行 AND、OR 和 XOR 运算。

在进行位运算之前, 两个操作数都要转换为字符串表示形式(如有必要)。然后, 在相应的位上执行操作。

让我们考虑一个如何使 ASCII 字母字符变为小写的例子。在 ASCII 中, 小写字母和大写字母之间的代码差值是 32(十六进制记法的 0x20)。所以, 要使字母小写, 就要对其执行 ~| 操作, 0x20 即空格的代码, 将第五位设置为 1:

say 'A' ~| ' '; # a

使用 ~^ 运算符, 你可以组织行为来改变大小写, 如下例所示:

say 'a' ~^ ' '; # A
say 'A' ~^ ' '; # a

在实际操作中, 最好避免使用 ASCII 码位的技巧。

布尔逻辑运算符

这些运算符是布尔值的 AND、OR 和 XOR 运算符。下面的代码示例列出了操作数的所有可能组合的整个表格:

say True ?| True;   # True
say True ?| False;  # True
say False ?| True;  # True
say False ?| False; # False

say True ?& True;   # True
say True ?& False;  # False
say False ?& True;  # False
say False ?& False; # False

say True ?^ True;   # False
say True ?^ False;  # True
say False ?^ True;  # True
say False ?^ False; # False
最大公约数和最小公倍数运算符

gcdlcm 运算符计算给定两个数的最大公约数和最小公倍数。这些运算符通常不包含在许多其他语言内置的运算符列表中。然而, 在 Raku 中, 你不需要包含任何库就可以使用它们。考虑以下使用 gcdlcm 运算符的例子:

my $a = 20;
my $b = 30;

say $a gcd $b; # Prints 10
say $a lcm $b; # Prints 60

注意, 语法要求 gcdlcm 名称都作为运算符使用, 而不是作为函数使用。下面的代码是不正确的:

say gcd($a, $b);
say lcm($a, $b);

它会产生编译错误, 如下所示:

===SORRY!=== Error while compiling
/Users/ash/Books/Packt/code/operators/gcd.pl
Undeclared routines:
  gcd used at line 4
  lcm used at line 5. Did you mean 'lc'?

对于素数, lcm 运算符返回 1, 因为没有其它的除数, 如下所示:

say 17 gcd 31; # 1

lcm 运算符返回的数等于其操作数中素数的乘积, 如下所示:

say 17 lcm 31; # 527

当然, 对于其它数字来说, 情况并非如此。请看下面的例子:

say 20 lcm 40; # 40
字符串重复运算符

x 二元运算符是字符串重复运算符。它重复一个字符串给定的次数, 如下例所示:

my $string = 'Developers ';
say $string x 5;

这段代码将包含 $string 变量初始值的字符串打印了五次。 很明显, 原始值没有改变。

要修改字符串并将结果保存在同一个变量中, 请使用运算符的赋值形式:

$string x= 2;
say $string;

现在, $string 值的长度是之前的两倍。

列表重复运算符

运算符 xx 是列表重复运算符。它在视觉上和意识形态上都与 x 运算符相似, 但它适用于列表。请看下面的例子:

my @data = (10, 20);
my @big_data = @data xx 100;
say @big_data;

这里, @data 数组将重复 100 次, @big_data 变量将包含 100 份。

小心不要把 xxx 运算符混在一起。如果你使用 x 而不是 xx, 那么编译器不会警告你, 而是将参数视为字符串, 并执行字符串连接, 而不是重复一个数组。

字符串连接运算符

~ 运算符将两个字符串连接起来, 如下所示:

say 'a' ~ 'b'; # ab

如果操作数不是字符串, 则在操作前将其转换为字符串:

say 10 ~ 20;

这将打印 1020 字符串。

具有赋值形式的 ~ 在某些应用程序中也很有用:

my $string = 'Hello, ';
$string ~= 'World!';
say $string; # Hello, World!
junction 运算符

这三个运算符创建了 junction。我们已经见过最简单的 junction 形式, 即在保存变量中同时有多个值:

my $odd = 1 | 3 | 5 | 7 | 9;
my $value = 5;
say 'Value is odd' if $value == $odd;

此代码打印出 Value is odd, 因为 $value 变量中的值是 $odd junction 的其中之一。运算符 | 创建一个所谓的any junction

运算符 & 创建了一个 all junction, 其中所有的值必须是非空的。请看下面的代码片段:

my $a = 3;
my $b = 4;

my $both = $a & $b;
say 'ok' if $both; # ok

最后, ^ 运算符创建了一个 one junction, 其中只有一个操作数必须被计算为真。请看下面的代码片段:

my $c = 'OK';
my $d = '';

my $one = $c ^ $d;
say 'ok' if $one; # ok

重要的是, 使用 any, allone 中的任一运算符创建的值都是 junction; 你可以通过调用 WHAT 方法来查看它们的类型, 如下所示:

say $one.WHAT; # (Junction)

不要将 &, |^ 运算符与布尔运算符 - &&, ||^^ 混淆。

does 运算符

does 运算符将一个角色混入到一个对象中。我们将在第八章《面向对象编程》中讨论混入的问题。总之, 请看一个简单的例子:

class Animal {}
role Barking {
    method bark() {
        say "Bow-wow!";
    }
}

my $dog = Animal.new();
$dog does Barking;
$dog.bark();

这里, 首先将 $dog 变量创建为 Animal 类的一个实例。然后, 将 Barking 角色的行为附加到 $dog 实例上。之后, $dog 变量可以 bark()

but 运算符

but 运算符将一个角色混入一个对象, 类似于 does 运算符的做法。but 运算符不会修改对象本身, 总是返回一个新的对象。此外, but 还允许我们使用已经实例化的对象, 如下面的例子所示:

my $value = 0 but True;
say 'It is true' if $value;

现在 $value 在布尔上下文中变为 True, 而它仍然包含纯零值。

在角色混合的情况下, 同一个对象开始表现为属于不同类型的对象的行为, 这视情况而定。请看下面的代码片段:

role Barking {
    method bark() {
        say "Bow-wow!";
    }
}

my $dog = 14 but Barking;
say $dog;    # 14
$dog.bark(); # Bow-wow!

$dog 变量被打印成它的数值, 但也可以对它调用 bark 方法。

内省后发现, 现在的变量是两者的组合:

say $dog.WHAT;

此命令打印出 (Int+{Barking})

通用比较运算符

cmp 是一个通用的比较运算符。它的通用性允许比较数字和字符串数据。请看下面的代码片段:

say 10 cmp 2;     # 2 is less than 10
say "10" cmp "2"; # but "2" is more than "10"

Order 枚举的三个可能值之一的返回值是 LessSameMore。前面的程序打印了以下输出:

More
Less

如果操作数的类型不同, 则按以下方式转换为同一类型:

say 5 cmp "5"; # Same

当你用 cmp 比较对儿时,它们的比较方式是先比较键, 再比较值。请看下面的例子, 我们创建了三个不同的键和值的对儿, 并以不同的组合进行比较:

my $a = alpha => '2';
my $b = beta => '1';
my $c = alpha => '1';

say $a cmp $b; # Less
say $a cmp $c; # More

$a cmp $b 的情况下, 它们的键是可以按字母顺序排序的, 而值则不重要。在 $a cmp $c 的情况下, 两个键都是相等的, 所以每对键的值也被检查。

字符串比较运算符 leg

leg 运算符的名字来自于单词 lessequalgreater。它将两个操作数作为字符串进行比较。如果值不是字符串, 则先将其字符串化。

say 10 leg 2;     # Less
say "10" leg "2"; # Less
say 5 leg "5";    # Same

结果是 Order 枚举的其中一个值(注意, 尽管运算符名字里有e, 但操作数的相等性返回 Same 值)。

实数比较运算符

这是一个比较运算符, 必​​要时可以将其操作数转换为 Real 类型。下面的例子演示了我们用 cmpleg 运算符对相同数据进行比较的结果。

say 10 <=> 2;     # More
say "10" <=> "2"; # More
say 5 <=> "5";    # Same
范围创建运算符

这组二元运算符用于创建范围。两个操作数定义了范围的左右边界。^ 字符的存在表明了相应的边界是开放的; 因此, 它不包括给定的数字。

运行下面的例子来看看它是如何工作的:

.say for 1 .. 5;   # prints the numbers: 1, 2, 3, 4, 5
.say for 1 ..^ 5;  # 1, 2, 3, 4
.say for 1 ^.. 5;  # 2, 3, 4, 5
.say for 1 ^..^ 5; # 2, 3, 4

你可以根据范围运算符周围的空格选择自己的风格。因此, 1…​^51 ..^ 5 都是可以接受的。但是, 不可能在运算符的字符之间插入空格, 例如 1 . . ^5

相等和不相等运算符

两个运算符 ==!= 比较两个操作数是否是数值相等的。在 Raku 中, 定义了这些运算符的一些变体, 以便它们能够正确地处理不同类型的操作数, 如下所示:

say 'Equal' if 10 == 10;
say 'Not equal' if 3.14 != pi;

如果需要, 两个操作数都要先转换为 Numeric 值:

say 'Also equal' if "10" == 10;

!= 运算符有一个 Unicode 同义词 - :

say 'Not equal' if e ≠ pi;

作为一个有趣的例子, 你也可以测试 !≠ 运算符, 它的构造类似于 != 运算符的组合方式 - 感叹号否定下一个字符。这在 Raku 中是可行的, 但在实践中要避免使用它; 应使用传统的 == 来代替。

要比较字符串, 可以使用本章后面介绍的 eqne 运算符。

数值比较运算符

<, , , >, >= 运算符集用于两个操作数的数值比较。如果操作数不是数值, 则将其转换为 Real。看一下下面的代码:

say 10 < 2;
say "10" < "2";

在本例中的两种情况下, 比较的结果都是 False

两个 Unicode 运算符 >= ASCII 形式的同义词, 如下所示:

say 10 ≤ 10; # True
say 20 ≥ 10; # True
字符串比较运算符 eq 和 ne

eqne 运算符比较两个字符串并返回一个布尔值。

say 'abc' eq 'abc'; # True
say 'abc' ne 'def'; # True

非字符串操作数在比较前被转换为字符串, 如下所示:

say 13 eq '13'; # True

要比较数字, 请使用 ==!= 运算符。

其他字符串比较运算符

这个字符串比较运算符的集合分别执行 greater(gt), greater or equal(ge), later(lt)less or equal 操作。这些运算符的工作对象是字符串, 所以必要时操作数会被转换为 Str 类型。返回值是一个布尔值。

say 'a' lt 'b';
say 'beer' le 'water';

say 'z' gt 'x';
say 'stone' ge 'paper';

在前面所有的例子中, 结果都是 True

before 和 after 运算符

根据操作数的顺序, beforeafter 运算符返回 TrueFalse。在 Raku 中, 这些运算符是存在于不同类型参数的 multi 函数。它们可以很好地处理数字和字符串数据。

让我们来看看这些例子, 比较字符串和数字会得到相反的结果:

say 10 before 2; # False
say 10 after 2;  # True

say "10" before "2"; # True
say "10" after "2";  # False

与一般的比较 cmp 运算符不同, beforeafter 排序运算符返回一个布尔值。

相等性测试运算符

eqv 运算符测试两个操作数是否相等。该术语假定两个操作数的类型相同, 并包含相同的值。下面的例子展示了该运算符的工作原理。

两个整数值是等价的:

my $a = 42;
my $b = 42;
say $a eqv $b; # True

如果其中一个值是另一种类型, 比如说是字符串, 那么结果就是 False, 即使这个值可以转换为相同的整数:

my $a = 42;
my $c = "42";
say $a eqv $c; # False

eqv 运算符适用于数组, 如以下代码所示:

my @a = 1, 2, 3;
say @a eqv [1, 2, 3]; # True

而且, 对于更复杂的数据结构, 比如, 嵌套的数组。考虑以下代码段:

my @b = [[1, 3], [2, 4]];
say @b eqv [[1, 3], [2, 4]]; # True
值恒等运算符

=== 运算符是值恒等运算符。对于标量值, 它给出的结果与 eqv 运算符相同的结果 - 当操作数的类型和值都相同时, 它就会返回真, 正如你在这里看到的那样:

my $a = 42;
my $b = 42;
say $a === $b; # True

这是另一个使用字符串和整数的例子:

my $a = 42;
my $c = "42";
say $a === $c; # False

对于类来说, 如果两个操作数都指向同一个对象, 则 === 运算符返回 True, 如下例所示:

class O {
}

my $o1 = O.new();
my $o2 = O.new();
say $o1 === $o2; # False: same class but different objects

my $o3 = $o1;
say $o1 === $o3; # True: the same object

更多关于类的内容请参见第八章《面向对象编程》。

绑定检查运算符

如果两个操作数都绑定在同一个变量上, 或者更准确地说, 绑定到同一个容器上, 那么绑定检查运算符返回真。

在 Raku 中, 绑定意味着另一个变量指向同一个容器, 你可以用两个名字来改变它的值。下面的例子就说明了这一点:

my $a = 42;
my $b := $a;
$b = 30;
say $a; # 30

在这里, 使用 $b 别名来更改放在 $a 变量中的值。对于这样的名字, =:= 运算符返回真:

say $a =:= $b; # True
智能匹配运算符

~~ 运算符是智能匹配运算符。它对不同类型的操作数进行不同类型的比较。

my $int = 10;
say $int ~~ 10; # True

my $str = 'str';
say $str ~~ 'str'; # True

say $str ~~ /^ str $/; # ⌈str⌋

从这个测试程序的输出可以看出, ~~ 运算符的结果并不总是布尔值。

在内部, 用智能匹配运算符构造的 $a ~~ $b 相当于调用 $b.ACCEPTS($a)ACCEPTS 方法是 Raku 中为所有类型定义的内置方法。前面的三个智能匹配操作可以用下面的方式重写:

say 10.ACCEPTS($int);
say 'str'.ACCEPTS($str);
say /^str$/.ACCEPTS($str);

4.2.2. 近似相等运算符

这是编程语言中最不寻常的运算符之一。在 Raku 中, =~= 运算符比较近似相等的值。

如果操作数之间的差值小于 $*TOLERANCE 变量的值, 则近似比较的结果为 True。它的默认值为 1E-15

让我们来看看 pi 值的两个近似值:

say pi =~= 3.14159265358979323846;
say pi =~= 3.14;

第一个返回 True, 而第二个返回 False, 因为它不够准确。

布尔逻辑运算符

这些运算符是布尔逻辑运算符, 执行 AND、OR 和异或 OR 等操作。

对于布尔操作数, 结果要么是 True, 要么是 False, 唯一的例外是 True ^^ True 表达式, 它返回的是 Nil

下面的一组例子展示了布尔操作数的所有可能组合:

say False && False; # False
say True  && True;  # True
say True  && False; # False
say False && True;  # False

say False || False; # False
say True  || True;  # True
say True  || False; # True
say False || True;  # True

say False ^^ False; # False
say True  ^^ True;  # Nil
say True  ^^ False; # True
say False ^^ True;  # False

对于其他类型的操作数, 它们要么返回一个布尔值, 要么返回操作数之一。让我们逐一研究一下这些运算符。

运算符 && 返回第一个操作数, 在布尔上下文中, 它可以被当作一个 False 值, 如果布尔上下文中的所有操作数都是 True, 则返回最后一个操作数。例如, 42 && 14 表达式的结果是 14。这里, 两个操作数都是 True, 因此, 运算后返回第二个操作数。

考虑这些例子:

say 42 && 14;          # 14
say 0 && 14;           # 0
say 'Karl' && 'Marta'; # Marta

my $text;
say $text && 'default text'; # (Any)
say 'default text' && $text; # (Any)

0 && 14 表达式中, 第一个操作数是 False, 所以 && 运算符立即返回, 而不计算第二个操作数。第二个表达式有两个操作数都是 True; 因此, 结果是 Marta。最后, 在最后两个表达式中返回一个未定义的字符串。

同样, || 运算符返回第一个为 True 的操作数。如果所有操作数恰好都是 False, 则返回最后一个操作数。让我们来看看下面的例子, 它们使用的操作数与我们在前面测试 && 运算符时使用的操作数相同:

say 42 || 14;          # 42
say 0 || 14;           # 14
say 'Karl' || 'Marta'; # Karl

my $text;
say $text || 'default text'; # default text
say 'default text' || $text; # default text

对于非布尔操作数的 ^^ 运算符的逻辑有点棘手。如果只有一个 True 操作数, 那么就返回这个操作数。如果没有, 则返回最后一个操作数。如果有一个以上的 True 操作数, 则返回 Nil 值。

say 42 ^^ 14;          # Nil
say 0 ^^ 14;           # 14
say 'Karl' ^^ 'Marta'; # Nil

my $text;
say $text ^^ 'default text'; # default text
say 'default text' ^^ $text; # default text

所有的树状运算符都可以被链接起来使用, 例如, 选择第一个可接受的值或取默认值:

my $name = '';
my $first_name = '';
say $name || $first_name || 'No name'; # No name

这些运算符具有短路语义, 当数值确定时, 应该停止计算操作数。

Defined-or 运算符

// 运算符被称为 defined-or 运算符。它返回定义的第一个操作数。// 最明显的用例是为输入数据提供默认值, 如下例所示:

my $planet;

# Some code that may change the value of $planet.
# $planet = 'Mars';

say $planet // 'Earth';

// 运算符也是短路运算符。

minimum 和 maximum 运算符

minmax 运算符分别返回最小或最大操作数。 为了比较这些值, 这两个运算符使用了与 cmp 运算符相同的语义。请看下面的代码片段:

say 10 min 2;     # 2
say "10" min "2"; # 10

say 10 max 2;     # 10
say "10" max "2"; # 2

请注意, 在 Raku 中, 有 minmax 函数可以做相同的事情, 但使用的是函数调用语法:

say min(2, 10); # 2
say max(2, 10); # 10

say min("2", "10"); # 10
say max("2", "10"); # 2

运算符和函数都可用来查找两个以上的最小值和最大值:

say 10 min 20 min 30; # 10
say max(10, 20, 30);  # 30
pair 创建运算符

运算符创建对。它将左操作数作为键, 右操作数作为值。

my $pair = 'key' => 'value';

创建的对象的类型是 Pair

在使用 运算符时, 如果键通过了 Raku 中对标识符的限制, 则可以不加引号。请看下面的例子:

my $pair1 = alpha => 1;
my $pair2 = beta => 2;
逗号运算符

逗号为提供的操作数创建一个列表。在下面的例子中, 这个列表被保存在数组变量中:

my $a = 10;
my $b = 20;
my $c = 30;

my @a = $a, $b, $c;
调用分隔符

: 运算符看起来不像普通的中缀运算符。它是在方法调用中用来分隔调用者参数的。这个调用中的方法调用看起来就像一个普通函数的调用。让我们在一个简单的例子中看到这一点。

首先, 我们将调用 $string 上的 index 方法。

my $string = 'Hello, World!';
my $pos = $string.index('W');
say $pos; # 7

同样的效果可以通过以下几行代码来实现:

my $pos = index($string: 'W');
say $pos; # 7

正如你所看到的, $string变量是作为索引例程的第一个参数传递的, 并且与第二个参数之间用冒号分开。

zip 运算符

Z 运算符的工作原理就像一个拉链, 从给定的两个数组中创建一个新的数组。新数组中的元素是由操作数中的元素拾取的, 因为拉链连接了它的项。

Z 运算符的行为可以在下面的例子中清楚地看到:

my @odd = 1, 3, 5, 7, 9;
my @even = 2, 4, 6, 8, 10;
my @all = @odd Z @even;
say @all;

这个程序打印以下列表, 其中包含基于@odd和@even两个数组中元素的嵌套列表:

[(1 2) (3 4) (5 6) (7 8) (9 10)]

如果其中一个数组操作数的长度不同, Z 运算符的结果将包含与最短操作数相同的元素。

交叉运算符

交叉运算符 X 创建其操作数元素的所有可能组合。比较这个运算符在我们在 Z 运算符的例子中使用的相同数据上的工作情况。

my @odd = 1, 3, 5, 7, 9;
my @even = 2, 4, 6, 8, 10;
my @all = @odd X @even;
say @all;

这一次, 产生的数组要大得多, 如下所示:

[(1 2) (1 4) (1 6) (1 8) (1 10) (3 2) (3 4) (3 6) (3 8) (3 10) (5 2) (5 4)
(5 6) (5 8) (5 10) (7 2) (7 4) (7 6) (7 8) (7 10) (9 2) (9 4) (9 6) (9 8)
(9 10)]
序列运算符

作为一个中缀运算符, 这三个点就是序列运算符。Raku 包含了一些内置的魔法, 可以实现你的意思。让我们来看看几个使用 …​ 运算符的例子:

say 5 ... 10;
say 'a' ... 'f';

前面的两行打印出以下序列:

(5 6 7 8 9 10)
(a b c d e f)

…​ 操作的结果是一个序列。不要将这个运算符与创建范围的 …​ 运算符混合使用。

如果你把结果分配给一个列表, 那么这两个运算符可以互换:

my @a = 5...10;
my @b = 5..10;
say @a; # [5 6 7 8 9 10]
say @b; # [5 6 7 8 9 10]

序列运算符可以展示更复杂的行为:

my @squares = 1, 2, 4 ... 64;
say @squares;

在这个例子中, 使用了模式, …​ 运算符创建了平方序列。

[1 2 4 8 16 32 64]

序列运算符能理解算术和几何序列, 如下面的例子:

say 1, 2 ... 10;    # Arithmetic
                    # (1 2 3 4 5 6 7 8 9 10)

say 1, 2, 4 ... 32; # Geometric
                    # (1 2 4 8 16 32)

使用 …​ 运算符的另一个有趣的例子是通过用 Whatever(*)字符呈现公式来生成斐波那契数的方法, 正如你可以在这里看到的那样:

my @fib = 0, 1, * + * ... *;
say @fib[0..10];

上面的代码创建了一个惰性列表 @fib, 其元素将按需计算。 前十个数字被打印出来, 如下所示:

(0 1 1 2 3 5 8 13 21 34 55)
绑定运算符

这两个运算符创建了绑定。绑定是同义词, 可以用来代替原来的变量名来访问它们的值。

我们在关于 =:= 运算符的章节中看到了使用 := 运算符的例子。

第二种形式, ::=, 创建一个只读绑定。目前, 它还没有在 Rakudo 中实现。

优先级较低的逻辑运算符

andor 这两个运算符在语义上等同于逻辑上的 &&|| 中缀运算符, 但优先级较低。

这些低优先级的运算符是特殊情况和错误断言的完美搭配, 例如:

my $value = prompt('Enter a small value> ');
$value < 10 or die 'Too big';
say 'OK, thanks';

这里, 只有当断言 $value<10 失败时, 才会执行 or 的右侧。

数据管道运算符

=⇒⇐= 运算符传递值的方式与类 Unix 命令行 shell 中 | 管道运算符传递数据的方式类似。

请看下面的例子:

my @a = (10...0 ==> grep {$_ > 5} ==> sort);
say @a;

在这里, @a 数组的创建有三个步骤:首先, 用 …​ 运算符生成从 10 到 0 的序列, 然后将数值传递给 grep 函数, 选择 5 以上的数字。之后, 这些值进入 sort 方法。

这个程序的结果是一个 6 到 10 之间的整数的排序列表:

[6 7 8 9 10]

⇐= 运算符以相反的方向组织数据流:

my @b = (sort() <== grep {$_ > 5} <== 10...0);
say @b; # [6 7 8 9 10]

请注意, 在这种情况下, 排序调用后面的括号是必须的, 因为, 否则, Raku 编译器会试图将 ⇐= 运算符的开头解释为 < …​ > 列表的开口引号。

4.2.3. 三元运算符 ?? !!

?? !! 运算符是 Raku 中唯一的三元运算符。它也被称为条件运算符。它需要三个操作数-一个条件和两个值。如果条件被计算为 True, 那么第二个操作数将作为操作的结果返回。否则, 将返回第三个操作数。

say pi < 3 ?? 'Less than 3' !! 'More than 3';

在这个例子中, pi < 3 的条件是 False, 所以第二个字符串 More than 3 被打印出来。

三元运算符可以(小心地)用来测试一个以上的条件。请看下面的例子:

my $value = rand;
say $value;

say $value < 0.3 ?? '0.0 to 0.3'
 !! $value < 0.5 ?? '0.3 to 0.5'
 !! $value < 0.7 ?? '0.5 to 0.7'
 !!                 '0.7 to 1.0';

在这种情况下, 代码的格式化应该有助于你理解开发者的想法。

4.2.4. 前缀运算符

Raku 中的下一组运算符是前缀运算符的集合。前缀运算符只需要一个操作数, 并且放在代码中的前面。

自增和自减运算符 ++ 和 — 

这些是前缀运算符, 用来递增和递减数值。

这些运算符的前缀形式首先改变变量的值, 然后返回结果。

my $n = 42;
++$n;
say $n;   # 43
say --$n; # 42

在 Raku 中, ++-- 运算符都具有后缀运算符的形式(参见本章后面的《后缀运算符》一节中的例子)。

布尔转换运算符

? 前缀运算符是布尔强制运算符。它将其操作数转换为一个布尔值。

该运算符的行为非常直接。让我们来看看下面的例子, 了解一下它的用法:

say ?4; # True
say ?0; # False

say ?'abc'; # True
say ?'';    # False

my $var = 'Hello, World!';
say ?$var; # True

my $undefined_var;
say ?$undefined_var; # False

my $empty_str = '';
say ?$empty_str; # False
布尔否定运算符

! 前缀是布尔否定运算符。它将操作数的布尔值反转。

say !True; # False
say !'';   # True
say !0;    # True
say !42;   # False
数值转换运算符

+ 前缀运算符将操作数转换为一个数值。

say +True; # 1
say +42;   # 42
say +'42'; # 42

my $var = '42';
say +$var; # 42

请注意, 你不能使用 + 前缀运算符从一个包含额外字符的字符串中解析数字:

# my $text = '12 volts';
# say +$text;

上面的代码会产生以下错误:

Cannot convert string to number: trailing characters after number
数字否定运算符

- 前缀运算符否定其数值操作数。如果操作数不是数字, 则先将其转换为数字。

my $var = 42;
say -$var; # -42

say -"42"; # -42
字符转换运算符

~ 运算符作为前缀运算符, 将操作数转换为字符串。对于简单的数据类型, 该运算符的作用是可以预测的:

my $var = 42;
my $str = ~$var;
say $str.WHAT; # (Str)

对于复杂的数据, 字符串转换可能会返回对象的地址:

class X { }
my $x = X.new;
say ~$x; # X(140372183360608)

要改变这种默认行为, 你可能需要定义自己的转换器(我们将在第八章, 《面向对象编程》中讨论 gist 方法)。

二元互补二元否定运算符

+^ 运算符做操作数的二元互补二元否定, 先将操作数转换为整数值, 如下面的代码所示:

say +^42; # -43

my $neg = -42;
say +^$neg; # 41

say +^0;  # -1
say +^-1; # 0
布尔转换和否定运算符

?^ 运算符将其操作数转换为布尔值并将其否定。其结果与简单的 ! 前缀运算符的结果相同。

say ?^True; # False
say ?^'';   # True
say ?^0;    # True
say ?^42;   # False
upto 运算符

^ 运算符被称为 upto 运算符。它创建了一个从0到操作数的整数值的范围:

say ^5;

my $right = 5;
say ^$right;

在前面的代码中, Raku 以 ^5 的形式打印了范围。

让我们在循环中使用这个范围。

say $_ for ^3;

这个单行程序打印出 0、1 和 2。因此, 范围不包括它的上边界。

temp 运算符

temp 运算符临时用一个新的值替换一个变量。请看下面的例子:

my $var = 1;

f(); # 1

{
    f(); # 1
    temp $var = 2;
    f(); # 2
}

f(); # 1

sub f() {
    say $var;
}

f 子程序打印全局变量 $var 的值。初始变量的值是 1, 因此, f 子程序的第一次调用打印的是 1。

然后, 我们在一对花括号之间有一个代码块。f 的第二次调用和之前一样使用 $var 的保存值。然后, 它的值被临时设置为 2, 所以, f 的第三次调用打印出 2。

从代码块中退出后, 临时值的范围结束, 恢复 $var 的原始值。所以, f 的第四次调用又打印出 1。temp 关键字与 my 关键字不同, 因为它没有创建一个局部变量。如果把代码改成使用 my, 那么 f 子程序仍然会使用全局变量 $var, 而全局变量是没有变化的。

...
{
    f(); # 1
    my $var = 2;
    f(); # 1
}
...
let 运算符

let 运算符为变量设置一个新的值。它的主要特点是, 如果代码块失败, 能够恢复原始值。

请看下面的例子。$var 变量在一对花括号之间的代码块内被设置为一个新的值。该变量在代码块结束后被打印出来。由于没有异常, 程序打印新值-2, 如下所示:

my $var = 1;

{
    let $var = 2;
}

say $var; # 2

如果这个块由于某种原因死掉了, 那么变量将保持原来的值。由 die 引起的异常, 会被 CATCH 块捕获。新的值, 2 就会丢失, 程序打印出 1, 如你所见:

my $var = 1;

try {
    let $var = 2;
    die;
}
CATCH {

}

say $var; # 2

如果你需要组织某种事务, 以确保所有的更改只在没有例外的情况下进行, 那么 let 运算符是非常方便的。

not 运算符

如果需要的话, not 运算符将其操作数转换为布尔型, 并否定其值。

say not 42;    # False
say not False; # True

这个运算符的优先级比 ! 运算符低。

so 运算符

so 运算符将操作数转换为布尔值并返回, 如下例所示:

say so 0;    # False
say so 42;   # True
say so True; # True

? 运算符比 so 运算符有更高的优先级。

so 例程也作为 Mu 类的一个方法存在, 可以像 $var.so 一样对变量进行调用。(类的讨论在第八章《面向对象编程》中进行, Mu 是 Raku 类层次结构中最上面的一个类。) 所以, 下面的代码可以理解为一句英文短语 'If something is so, then do the following':

my $smth = True;
if $smth.so {
    say 'True';
}

4.2.5. 后缀运算符

后缀运算符集包含一些紧跟在操作数后面的运算符。

面向对象的后缀运算符

在 Raku 中, 有一组后缀运算符, 用于对象。例如, 我们已经使用了 . 运算符来调用对象上的方法, 例如, 整数或字符串。

say 42.Str; # 42
say 'Hello'.WHAT # (Str)

say 'UP'.lc; # up

我们将在第八章, 《面向对象编程》中研究其他与 OOP 相关的后缀运算符。这些运算符如下-.&.=.^.?.+.*.:.::

自增和自减运算符

后缀形式的自增和自减运算符首先返回变量的值, 然后改变它们的值。我们可以从下面的代码中看到这一点:

my $n = 42;
$n++;
say $n;   # 43
say $n--; # 43

将下面例子的输出与本章前面前缀运算符部分所示的程序进行比较。

4.2.6. 环缀运算符

环缀运算符不像普通的运算符, 比如 +.。环缀运算符包含两个包围操作数的部分。本节介绍的四种运算符, 是利用不同种类的两个括号对儿建立的。

引号单词运算符

引号单词运算符 < > 使用角括号之间以空格分隔的数据创建一个列表。

下面的例子打印了一个包含三个元素的列表:

say <a b c>; # (a b c)

不需要在 < > 运算符内引用元素。现在, 让我们把创建的数组保存在一个变量中, 看看它的内容:

my @a = < 1-3 two 3+6 four 5/7 >;
say @a.elems;
say @a.join('|');

在这个程序中, 有五个元素被放入 @a 数组中, 如下所示:

5
1-3|two|3+6|four|5/7

元素之间用空格隔开, 所以像 1-35/7 这样的结构被视为字符串。

分组运算符

分组运算符是一对小括号-( )。例如, 它对数学表达式中的元素进行分组。

空括号对创建一个空列表:

say ().WHAT; # (List)
散列或块创建运算符

这对h花括号({ })既可以创建一个空的散列, 也可以创建一个代码块。Raku 根据它在花括号之间看到的代码来决定做什么。

在下面的例子中, 你可能会看到 Raku 会按照你的意思去做。

空括号或键值对的列表会创建一个散列, 如下面的代码所示:

say {}.WHAT;               # (Hash)
say {a => 1, b => 2}.WHAT; # (Hash)

而一些可执行的代码, 引用占位符, 或默认变量会创建一个代码块:

say {say 1}.WHAT;     # (Block)
say {$^x * $^y}.WHAT; # (Block)
say {$_}.WHAT;        # (Block)
say {$_ => 1}.WHAT;   # (Block)

请注意, 由于 Raku 在散列和块语法中都使用了相同的花括号, 所以可能会发生一些混淆, 比较一下下面两个例子:

say {$_ => 1}.WHAT; # (Block)
say {a => 1}.WHAT;  # (Hash)

如果这很明显, 括号之间的代码是可执行的, 比如包含分号, 分隔语句, Raku 就会制作一个块:

say {;a => 2}.WHAT; # (Block)

另外, 如果有隐式或显式参数的引用, 那么这就是一个块:

say {$^a => 2}.WHAT; # (Block)

占位符 $^a 和默认变量 $_ 都表明这是块签名的一部分。

另外, %() 语法也可以用来创建一个散列:

say %('a', 2).WHAT; # (Hash)

试图使用 $_ 变量可能会导致一个错误, 这取决于内容。例如, 下面这行会产生一个错误, 如果代码是孤立的, 那么下面的代码行就会生成 Use of uninitialized value $_ of type Any in string context 的错误:

say %($_, 2).WHAT;

在定义了 $_ 的情况下, 将无误地创建一个散列:

for 1..5 {
    say %($_ => 2 * $_).WHAT; # (Hash)
}

4.2.7. 后环缀运算符

在 Raku 中, 后环缀运算符是后缀运算符, 它使用成对的字符来环绕操作数的其余部分。

位置访问运算符

一对方括号 [ ] 组织对操作数的位置元素的访问。最简单的情况是对一个数组进行索引, 如下所示:

my @a = <1 3 5 7 9>;
say @a[2]; # 5

可以通过一个索引列表来请求一个以上的元素:

say @a[1, 2, 3]; # (3 5 7)

范围是另一个选择元素的好候选者。

元素访问运算符

下一组运算符包括 { }< ><< >>« »

主运算符 { } 用于访问一个散列值:

my %h = alpha => 'a',
        beta => 'b',
        gamma => 'c';

say %h{'beta'}; # b

[ ] 运算符类似, { } 接受多个键:

say %h{'alpha', 'beta'}; # (a b)

在前面的例子中, 引用了 %h 的键。后环缀运算符 < > 允许我们避免像 < > 环缀那样引用:

say %h<beta>;       # b
say %h<alpha beta>; # (a b)

<< >> 运算符和它的 Unicode 同义词 « », 将操作数作为双引号中的字符串进行插值, 如你在 这里看到的那样:

my $name = 'gamma';
say %h«$name»;   # (c)
say %h<<$name>>; # (c)
调用运算符

( ) 是一个运算符, 它在第一个操作数上调用一个函数或方法。操作数放在括号中, 作为参数传递:

say 'Hello'.substr(1, 3); # ell

在这里, ( ) 后环缀运算符接收三个操作数-substr 方法的名称和两个整数, 1 和 3。

虽然这段代码看起来并没有使用运算符, 但 Raku 仍然将一对括号视为一种特殊类型的运算符, 即后环缀运算符。

4.3. Raku 中的元运算符

到目前为止, 我们已经介绍了许多对常规操作数—​值、变量、对象等进行操作的运算符。在 Raku 中, 还有另一种运算符-在运算符上进行操作的运算符。这些运算符被称为元运算符。我们将在下面的章节中研究它们。除了一些例外, 每个元运算符都可以接受任何普通运算符来创建一个新的操作, 并遵循一定的规则。元运算符也可以与用户定义的运算符一起工作, 我们将在本章后面的用户定义运算符一节中讨论这些运算符。

4.3.1. 赋值元运算符

赋值元运算符采用 op= 形式, 其中 op 是 Raku 中可用的运算符之一。

例如, 以中缀 + 运算符为例, 它的默认形式是将两个操作数相加, 然后返回结果。

my $a = 10;
my $b = 20;
my $c = $a + $b;
say $c; # 30

在这个例子中, $c 变量接收 $a$b 之和, 保持不变。

在元运算符形式中, += 运算符改变左操作数, 并将结果存储在其中, 如这里所示:

my $d = 10;
my $e = 20;
$e += $d;
say $e; # 30

$a += $b 表达式总是等同于下面的 $a = $a + $b 形式。

赋值元运算符与许多其他中缀运算符一起工作。请看下面的例子:

my $x = pi;
$x *= 2/3;
say $x;

4.3.2. 否定元运算符

否定元运算符 ! 是用 !op 形式的感叹号与布尔运算符配合使用。例如, 这里是与智能匹配运算符 ~~ 相结合创建的否定元运算符:

say 'Hello' ~~ /o/;  # ⌈o⌋
say 'World' !~~ /x/; # True

World 中没有 x, 所以 !~~ 运算符返回 True

4.3.3. 反转元操作器

反转元运算符 R 接收一个运算符, 并创建一个新的运算符, 其中操作数的顺序被改变。

例如, 以中缀 - 运算符为例。它从第一个操作数中减去第二个操作数, 如下所示:

say 20 - 10; # 10

现在, 让我们使用元运算符 R-, 看看它有什么变化:

say 20 R- 10; # -10

我们看到, 结果就像操作数被交换了一样。元运算符与两个操作数 $a Rop $b 的操作相当于 $b op $a

4.3.4. 化简元运算符

化简运算符 [op] 应用于一个列表时, 对其后的每一对元素执行 op 运算。换句话说, 列表被注册并在它们之间插入 op 符号。

让我们在下面的例子中研究一下这个元运算符:

my @a = (1, 2, 3);
say [+] @a; # 6

[+] @a 表达式相当于下面的表达式:

say @a[0] + @a[1] + @a[2];

我们再来看另一个例子-[*]。这个元运算符可以用来计算阶乘:

say [*] 1..5; # 120

4.3.5. 交叉元运算符

交叉元运算符 Xop 接受两个列表, 并将运算符应用于列表中元素的每一个可能的组合上。

操作的结果是另一个列表。请看下面的例子, 两个列表中各有三个数字:

my @x = (1, 2, 3);
my @y = (4, 5, 6);
say @x X+ @y;

这段代码打印了一个有六个元素的列表, 每个元素都是 @x@y 两个元素之和:

(5 6 7 6 7 8 7 8 9)

让我们使用字符串连接(~), 并在 X~ 操作中使用相同的 @x@y 数组:

say @x X~ @y;

在这种情况下, 每一对数字都会被转换为一对字符串, 然后将它们连接起来。你可以看到 X 元运算符如何挑选操作数的元素:

(14 15 16 24 25 26 34 35 36)

4.3.6. Zip 元运算符

zip 元运算符 Zop 对其列表操作数进行"压缩", 并将运算符操作应用于操作数的对应元素。

让我们在相同的数据上尝试使用 zip 元运算符, 就像我们使用交叉元运算符一样:

my @x = (1, 2, 3);
my @y = (4, 5, 6);
say @x Z+ @y;
say @x Z~ @y;

该程序打印两个列表, 每个列表有三个元素:

(5 7 9)
(14 25 36)

4.3.7. 超运算符

Raku 中的超运算符将运算应用于列表操作数的每个元素上。它们既适用于一元运算符, 也适用于二元运算符, 并使用 <<>> 符号, 以及它们的同义词 «»

让我们通过实例来探讨超运算符:

my @a = 1..10;
@a = @a <<+>> 3;
say @a; # [4 5 6 7 8 9 10 11 12 13]

在第一个例子中, @a 数组的每个元素都加了 3。在 <<+>> 超运算符的左边, 是一个有十个元素的数组。在右侧, 我们有一个标量值。这个值被加到左边数组的所有元素上。

在这个例子中, 相同的 <<+>> 运算符被用于两个长度相同的数组上:

my @x = (1, 2, 3);
my @y = (4, 5, 6);
say @x <<+>> @y; # [5 7 9]

结果是一个有三个元素的新数组;每个元素是源数组中相应元素的和。

<<+>> 的 ASCII 形式可以使用引号重写:

say @x «+» @y; # [5 7 9]

角括号的方向定义了如果其中一个操作数比另一个操作数短, 如何克隆操作数。

让我们来看看下面两个数组上的不同组合:

my @short = (1, 2);
my @long = (3, 4, 5, 6);

首先, 使用与我们之前使用的相同的运算符:

say @short <<+>> @long; # [4 6 6 8]

这里重复了两次 @short 数组, 这样整个 @long 数组就有足够的元素。这段代码相当于将两个相同长度的数组相加:

say (1, 2, 1, 2) <<+>> (3, 4, 5, 6);

现在, 使用 <<+<< 运算符, 如下所示:

say @short <<+<< @long; # [4 6 6 8]

同样, @short 数组也是重复的。

像这样把箭头反过来:

say @short >>+>> @long; # [4 6]

数组不再重复, 结果只包含两个元素。@long 数组的其余元素则被忽略。

最后, 让我们试试 >>+<< 运算符:

say @short >>+<< @long;

超运算符的形状告诉我们, 两个操作数都不能克隆。在这种情况下, 会出现以下错误:

Lists on either side of non-dwimmy hyperop of infix:<+> are not of the same length

也可以用一元运算符创建一个超运算符。例如, 用后缀自增:

my @d = 1..5;
@d>>++;
say @d; # [2 3 4 5 6]

@d 数组中的每一个元素都会使用 >>++ 超运算符递增 1。

如果使用 ++ 的前缀形式, 同样的例子会是这样的:

my @d = 1..5;
++<<@d;
say @d; # [2 3 4 5 6]

4.3.8. 用户定义的运算符

Raku 允许创建新的运算符。与 C++ 不同的是, 新的运算符并不局限于预定义的现有运算符列表。你可以自由地给运算符命名, 并选择新的字符组合。

一个用户定义的运算符应该属于前面提到的类别之一, 如中缀、前缀或环缀等。

让我们从创建一个新的后缀运算符-+% 开始, 它可以计算两个数字操作数之和, 但结果不超过 100:

sub infix:<+%>($a, $b) {
    my $sum = $a + $b;
    return $sum < 100 ?? $sum !! 100;
}

定义一个运算符类似于创建一个子例程, 但它的名称应该包含类别名和运算符本身。

现在是测试刚刚创建的 +% 运算符的时候了:

say 10 +% 20; # 30
say 40 +% 70; # 100

另一个有表现力的例子是阶乘运算符。在数学中, 阶乘是由值后的感叹号来表示的。在 Raku 中, 通过用户定义的后缀运算符也可以做到这一点:

sub postfix:<!>($n) {
    [*] 1..$n
}

say 5!; # 120

后缀运算符只需要一个操作数-$n。在运算符的主体中, 我们使用化简操作来计算一个阶乘。

在 Raku 中, 用户定义的运算符获得了与内置运算符相同的权限。这尤其意味着, 在定义了一个新的运算符之后, 你可以将它与许多元运算符组合使用。

例如, +% 运算符得到 +%= 形式, 可以直接使用, 如下所示:

my $var = 50;

$var +%= 30;
say $var; # 80

$var +%= 30;
say $var; # 100

或者, 也可以用化简运算符, 如下所示:

say [+%] 1..10; # 55
say [+%] 1..50; # 100

在 Raku 的设计中, 并没有打算让用户定义的运算符变得神秘。所以, 你可以想出更好的名字, 包括一些描述性的字符串标识符, 如下所示:

sub postfix:<Factorial>($n) {
    [*] 1..$n
}

say 5Factorial; # 120

4.4. 总结

在这一长章中, 我们谈到了 Raku 中的运算符。运算符有几类, 如中缀、前缀 、后缀、环缀和后环缀。我们讨论了每一类的运算符。然后, 我们看了元运算符和超运算符如何在内置运算符的基础上创建新的运算符。最后, 你学会了如何创建用户定义的运算符, 这些运算符将自然地嵌入到你的程序语言中。

到目前为止, 我们已经介绍了 Raku 语法的所有基础知识。在下一章中, 我们将进入使用子例程组织代码的下一个层次。

5. 控制流

在本章中, 我们将讨论 Raku 中控制程序流的主要内容。大多数程序不只是一个指令列表, 而是应该对用户的输入做出反应, 根据计算出的数据做出决策等等。

在本章中, 我们将涉及以下主题:

  • 代码块和 do 关键字

  • 有条件的检查

  • 循环

  • 中断循环体

  • 使用 gather 和 take 方法收集数据

  • 设定主题

  • 只执行一次代码

5.1. 了解代码块和变量作用域

在上一章中, 我们讨论了变量, 它们是你在程序中使用的命名实体。与许多编程语言一样, 在 Raku 中, 名字在其作用域内可见, 而在作用域外则不可见。

以一个简单的程序为例, $name 变量只使用一次, 如下所示:

my $name = 'Mark';
say "Hello, $name!";

该变量在用于打印问候语后可以重复使用:

my $name = 'Mark';
say "Hello, $name!";

$name = 'Carl';
say "Hello, $name!";

这样做的原因是两个打印语句都位于同一个作用域中, 而且 $name 变量在那里是可见的。

在 Raku 中, 块是位于一对大括号内的一段代码。一个代码块可以创建自己的作用域。因此, 在代码块中声明的变量只有在它内部可见。

下面的程序将无法编译:

{
    my $name = 'Mark';
    say "Hello, $name!";
}

$name = 'Carl'; # Error here
say "Hello, $name!";

下面的错误信息告诉我们, 在 $name 被分配给一个新值的地方, 没有声明这个名字的变量:

===SORRY!=== Error while compiling /Users/ash/code/control-flow/3.pl
Variable '$name' is not declared
at /Users/ash/code/control-flow/3.pl:6
------> <BOL>   $name = 'Carl';

为了使名称再次可见, 在外层作用域中声明如下:

my $name;

{
    $name = 'Mark';
    say "Hello, $name!";
}

$name = 'Carl';
say "Hello, $name!";

在这个演示中, 创建单独的代码块没有太大的意义。当我们在本章后面讲到例如条件时, 或者在第六章子程序时, 我们会看到代码块的更多有用的应用, 代码块是用来保存用户定义函数的主体。

5.1.1. do 关键字

do 关键字用于执行一个代码块。上一节的例子可以按以下方式重写:

my $name;

do {
    $name = 'Mark';
    say "Hello, $name!";
}

$name = 'Carl';
say "Hello, $name!";

当代码块不能成为一个独立的表达式时, 显式使用关键字的必要性是显而易见的, 如下一个例子所示:

my $name;
$name = 'Carl';
$name and do {say "Hello, $name!"};

在这里, 借助于 and 关键字(见第四章,《使用运算符), 程序会检查 $name 是否被定义, 如果是, 则打印出问候语。

5.2. 有条件的检查

根据条件进行决策是编程中的基本需求之一。if 关键字会根据布尔测试的结果, 改变程序的流程。请看以下代码:

my $x = 5;
if $x < 10 {
    say "$x < 10"; # 5 < 10
}

在这个例子中, 你可以看到使用 if 关键字的语法。关键字后面是一个布尔条件 $x < 10, 后面是花括号中的代码块。与 Perl 5 不同的是, 条件周围的圆括号是不必要的。

只有当条件值为 True 时, 这段代码才会被执行。

if 语句可以由 else 分支来完成, 当条件为 False 时, 该分支将接管控制权:

my $x = 11;
if $x < 10 {
    say "$x < 10";
}
else {
    say "$x >= 10"; # 11 >= 10
}

在给定值 $x 的情况下, 程序执行代码, 然后是 else 块。

当你需要更细化的分支时, 可以将 if-else 检查链接起来。这里, 连带的 elsif 关键字就派上用场了:

my $x = 10;
if $x < 10 {
    say "$x < 10";
}
elsif $x == 10 {
    say "$x == 10"; # 10 == 10
} else {
    say "$x > 10";
}

elsif 分支包含另一个布尔表达式, 当第一个 if 测试的条件为 False 时, 将对其进行检查。

同一程序可以使用不同的布尔测试组合重新编写, 举例说明:

my $x = 10;
if $x < 10 {
    say "$x < 10";
}
elsif $x > 10 {
    say "$x > 10";
} else {
    say "$x == 10";
}

你必须小心不要误写成 if 而不是 elsif。如果你这样做, 你会创建两个独立的 if 检查, 这两个检查都可以触发它们的, 如下面的例子所示。

下面这段代码检查变量是否小于5或小于10:

my $x = 3;
if $x < 5 {
    say 'x < 5';
}
elsif $x < 10 {
    say 'x < 10';
} else {
    say 'x >= 10';
}

第一个条件 $x < 5True, 所以只执行第一个代码块, 程序打印 x < 5

现在, 让我们用 if 代替 elsif:

my $x = 3;
if $x < 5 {
    say 'x < 5';
}
if $x < 10 {
    say 'x < 10';
}
else {
    say 'x >= 10';
}

在这种情况下, 有两个 if 块。else 块只存在于第二个 if 中。这两个条件, $x < 5$x < 10, 现在都是 True, 因此, 程序打印下面两行:

x < 5
x < 10

另外, 确保在需要 if-elsif-else 链的地方不要使用两个 else if 关键字而不是一个 elsif。Raku 会抱怨, 因为它希望在 else 关键字之后找到代码块:

my $x = 3;
if $x < 5 {
    say 'x < 5';
}
else if $x < 10 {
    say 'x < 10';
}
else {
    say 'x >= 10';
}

编译以以下错误结束:

===SORRY!=== Error while compiling /Users/ash/ifelseif.pl
In Raku, please use "elsif" instead of "else if"

当然, 也可以使用嵌套的 if-else 语句来解决这个问题, 这可能有点丑, 而且还引入了另一层没有必要的嵌套代码, 在下面的例子中可以看到:

my $x = 3;
if $x < 5 {
    say 'x < 5';
}
else {
    if $x < 10 {
        say 'x < 10';
    }
    else {
        say 'x >= 10';
    }
}

5.3. 使用循环

循环结构有助于组织重复的操作。在 Raku 中, 有几个不同的选项可以用来创建循环。让我们从类似于传统的 C 风格的循环开始。

5.3.1. 循环周期

loop 关键字期望有三个元素来控制循环体的数量或重复次数。请看下面的代码片段:

loop (my $c = 0; $c < 5; $c++) {
    say $c;
}

在本例中, $c 是循环迭代的计数器。这个变量是在循环关键字-my $c = 0 之后立即声明并初始化的, 如果条件 $c < 5True, 则执行循环的主体。循环迭代后, 执行 $c++ 语句, 使计数器递增, 循环重复。只要 $c 等于 5, 条件就不再是 True 了, 循环就停止了。所以, 整个程序打印从0一直到4的数字, 包括 4。

循环头中的某些甚至全部部分可能会被省略。例如, 如果在循环前初始化计数器变量, 如下所示:

my $c = 0;
loop (; $c < 5; $c++) {
    say $c;
}

如果增量发生在主体内部。我们可以考虑以下代码:

my $c = 0;
loop (; $c < 5; ) {
    say $c;
    $c++;
}

请注意, 尽管缺失了部分, 但还是需要分号。

最后, 如果没有给出参数, 循环就会变成无限循环, 你有责任停止它, 如下所示:

my $c = 0;
loop (;;) {
    say $c;
    $c++;
}

如果是空头, 则不需要分号, 如下所示:

my $c = 0;
loop {
    say $c;
    $c++;
}

循环头的各部分可以包含多条指令, 用逗号分隔。例如, 这里是一个有两个变量的循环:

loop (my $x = 0, my $y = 10; $x < 5 && $y > 5; $x++, $y--) {
    say "$x $y";
}

这个程序将 $x 变量递增, 并将 $y 递减。输出结果是这样的:

0 10
1 9
2 8
3 7
4 6

现在, 让我们进入 for 循环。

5.3.2. for 循环

for 循环可以说是一种比较 Perlish 的组织循环的方式。它不需要计数器, 并且在一个数据列表上进行迭代。

看一个例子。这里, 列表是一个奇数整数的数组:

my @data = 1, 3, 5, 7, 9, 11;
for @data {
    say $_;
}

$_ 变量是默认变量, 也叫主题。它取当前迭代中的一个元素的值。因此, for 循环会打印 @data 数组中的数字。

你可以对前面的程序执行两个重要的修改。

首先, 如果循环的主体是一条语句, 可以用后缀形式重写循环, 如这里所示:

my @data = 1, 3, 5, 7, 9, 11;
say $_ for @data;

这里, 内置函数 say 以 $_ 为参数。也可以在主题上调用一个同名的方法:

$_.say for @data;

如你所见, 可以省略默认变量, 使程序更短:

.say for @data;

这是一个真实的 Raku 的例子。

第二个变化是明确地引入了主题变量。在这种情况下, 你给它起了一个名字。语法很简单, 可以从下面的例子中理解:

my @data = 1, 3, 5, 7, 9, 11;
for @data -> $x {
    say $x;
}

变量是在 箭头后面声明的。你不需要在这里使用 my 关键字。这种格式有一个优点, 就是它可以从 @data 中取多个值。例如, 如果要在每次迭代时取两个值, 可以像下面这样声明两个变量:

my @data = 1, 3, 5, 7, 9, 11;
for @data -> $x, $y {
    say "$x + $y = ", $x + $y;
}

如你所见, 这个程序可以打印出三个和:

1 + 3 = 4
5 + 7 = 12
9 + 11 = 20

在元素数量为奇数的情况下, 会出现异常, 如下所示:

Too few positionals passed; expected 2 arguments but got 1

带箭头的结构(在下面的代码中)被称为尖号块。它相当于一个匿名函数, 接受命名参数, 这就是为什么在错误信息中提到参数的原因。我们将在第六章《子程序》中看到更多的内容。

为了正确处理缺失的数据, 可以使用如下的默认值。

my @data = 1, 3, 5, 7, 9, 11, 13;
for @data -> $x, $y = -1 {
    say "$x, $y";
}

如你所见, 在这种情况下, 在最后一次迭代时, $y 的值将被设置为 -1:

1, 3
5, 7
9, 11
13, -1

5.3.3. 使用 while、until 和 repeat

通过 whileuntilrepeat 这三个关键字, 你可以创建由一定条件定义的重复次数的循环。我们先从最简单的情况开始, 用一个裸关键字 whileuntil:

my $letter = 'a';
while $letter ne 'd' {
    say $letter;
    $letter++;
}

只要条件 $letter ne 'd'True, 就会重复执行 while 循环的主体。在这里, 对变量的控制是在循环的主体中完成的, 而其头部只控制条件。如果在进入循环前条件为 False, 则不会执行主体, 如下例所示:

my $letter = 't';
while $letter le 'd' { # 't' is not less or equal then 'd';
    say $letter;       # body is not executed.
    $letter++;
}

until 关键字的行为与 while 相反。循环体被执行, 直到条件变为 True:

my $letter = 'a';
until $letter eq 'd' {
    say $letter;
    $letter++;
}

在前三次迭代过程中, $letter eq 'd' 条件为 False, 所以主体被执行。只要 $letter 变成 d, 循环就会停止。

正如你所看到的, 并不能保证循环的主体会被执行哪怕一次。如果条件最初是 False(在 while 的情况下)或 True(在 until 的情况下), 那么循环主体的代码就会被跳过。

repeat 关键字将条件检查移动到主体的末尾, 因此它将至少被执行一次。这个关键字与 whileuntil 一起使用。

my $letter = 'a';
repeat {
    say $letter;
    $letter++;
} while $letter ne 'd';

这个循环打印出字母 a、b、c, while 子句中的条件为 False 三次, 之后变为 True

现在, 让我们同时修改条件和变量的初始值, 然后再次运行这个循环:

my $letter = 't';
repeat {
    say $letter;
    $letter++;
} while $letter le 'd';

这次打印的是字母 t。与裸 while 循环不同的是, repeat while 循环在检查条件之前执行其主体。

注意, while 子句可以放在代码块之前, 如下所示:

my $letter = 't';
repeat while $letter le 'd' {
    say $letter;
    $letter++;
}

在这种情况下, 与后缀 while 子句没有区别。

同样, unit 关键字也可以和 repeat 一起使用。repeat until 循环至少执行一次。如果条件为 False, 则继续运行代码块, 直到条件变为 True

my $letter = 'a';
repeat until $letter eq 'd' {
    say $letter;
    $letter++;
}

或者, 使用后缀形式。请看下面的代码片段:

my $letter = 'a';
repeat {
    say $letter;
    $letter++;
} until $letter eq 'd';

这两个程序都会打印出a、b、c, 选择其中一个定位条件的变体, 并在实践中尝试坚持使用, 至少在项目里面是这样。

5.3.4. 中断循环

循环的执行不仅可以用初始条件控制, 还可以从循环体本身控制。有三个关键字-nextlastredo。当它们与使用 if 的条件检查一起使用时, 会变得更加有用。

last 关键字只是中断循环。让我们看一个例子, 在打印一个大于某个预定义阈值的值后, 循环就会中断:

my @data = 3, 1, 7, 12, 50, 2, 14;
for @data -> $x {
    last if $x > 42;
    say $x;
}
say 'Done.';

请注意, 这里的 if 是以后缀的形式使用的-它的行为和下面的代码一样:

if $x > 42 {
    last;
}

前几次迭代照常执行, 但当 $x 的当前值变成 50, 使得 $x > 42 的条件为 True 时, 循环停止, 执行到循环后的代码(如果有的话)。程序打印出以下几行:

3
1
7
12 Done.

注意, 代码块中位于最后一条指令之后的代码, 也是在 if 条件变为 True 之前执行的。只要 $x 变量得到 50 的值, 循环就会被中断。

另一个关键字 next, 跳过循环体的其余部分, 开始另一次迭代。例如, 让我们打印1到10之间的偶数:

for 1..10 -> $x {
    next if $x % 2;
    say $x;
}

如果条件 $x % 2 是 1, 这个循环不会打印一个数字, 而这个条件是转换为 True 的。它发生在偶数上, 因此, say 只接收通过过滤器的奇数, 正如你可以在下面的输出中看到的那样:

2
4
6
8
10

最后, redo 关键字从当前位置重新启动循环。与 nextlast 一样, 它跳过循环主体的其余部分, 但不影响循环计数器。

for 1..5 -> $n {
    my $r = rand;
    say "Trying $r";
    redo if $r < 0.5;

    say $n + $r;
}

这个简单的程序演示了 redo 关键字的用法。程序会生成一个0和1之间的随机数, 只有当这个数大于0.5时才会使用它。在所有其他情况下, 循环迭代会重新开始。

使用标签

让我们考虑一个嵌套循环的例子:

for 1..5 -> $x {
    for 1..5 -> $y {
        say "$x * $y = ", $x * $y;
    }
}

这个程序打印了1到5的数字的乘法表。如果在某些时候, 我们想跳过给定的 $x 的表的其余部分, 继续计算 $x 的下一个值, 怎么办?直接在循环内使用 next$y 的使用, 只会影响到内循环。为了确保 next 语句是修改外循环的执行, 使用 X_LOOP:

X_LOOP: for 1..5 -> $x {
    for 1..5 -> $y {
        next X_LOOP if $y == $x;
        say "$x $y = ", $x $y;
    }
}

这里的标签是一个大写的标识符 X_LOOP, 后面是冒号。它在下一条语句中被提及, 所以编译器明白下一次迭代应该从标有 X_LOOP 的循环开始。

标签也可以用于其他类型的循环, 例如, 用于 untilwhile 循环。

5.4. 执行代码一次

在 Raku 中, 有趣的是, 可以只执行部分主体一次。例如, 在下一个循环中, 应该有四次迭代完成, 但第一条消息只打印一次:

for 1, 2, 3, 4 {
    once {
        say 'Looping from 1 to 4';
    }
    say "Current value is $_";
}

上面的代码打印了以下输出:

Looping from 1 to 4
Current value is 1
Current value is 2
Current value is 3
Current value is 4

once 关键字后的代码块只执行了一次。

这也适用于其他类型的循环, 例如, loop 循环。看一下下面的代码片段:

loop (my $c = 1; $c != 5; $c++) {
    once say 'Looping from 1 to 4';
    say "Current value is $c";
}

请注意, 如果你只有一条指令要执行一次, 就不需要花括号。

once 关键字不仅适用于循环。它可以用在代码的任何其他部分, 例如, 在子程序内部。我们将在下一章第六章《子程序》中详细讨论子程序。现在, 这里有一个简单的例子, 它可以打印整数的平方, 并在函数 f 的第一次调用时向我们问好:

sub f($x) {
    once say 'Hi!';
    say $x * $x;
}

f(1);
f(2);
f(3);

程序的输出如下:

Hi!
1
4
9

5.5. 使用 gather 和 take 收集数据

用 Raku 中的一对关键字-gathertake 就可以很有表现力地组织数据列表的准备工作。要了解其工作原理, 最简单的方法就是看看下面的例子:

my @data = gather {
    take 'a';
    take 'b';
}

say @data;

gather 关键字后的代码块返回一个序列, 保存在 @data 数组中。序列中的元素由 take 关键字提供。所以, 在 @data 中会有两个元素, 如你所见:

[a b]

让我们考虑一个更大的例子。它包含一个二维整数矩阵和一个指令列表。指令是 leftrightupdown 四个方向, 还有一个命令-take-it。你应该从矩阵的中心开始, 然后根据指令移动当前的位置, 如果指令告诉你, 你就应该拾取数字。

my @matrix = (
    [ 8, 10, 3, 16, 11],
    [ 4, 13, 5, 1, 6],
    [20, 9, 0, 15, 19],
    [14, 2, 24, 7, 23],
    [21, 17, 18, 12, 22],
);

my ($x, $y) = 2, 2; # Starting position
my @instructions = <down down take-it
                    left up up take-it
                     right right up up take-it>;

my @result = gather {
    for @instructions -> $step {
        if $step eq 'up'         {$y--}
        elsif $step eq 'down'    {$y++}
        elsif $step eq 'right'   {$x++}
        elsif $step eq 'left'    {$x--}
        elsif $step eq 'take-it' {take @matrix[$y][$x]}
    }
}

say @result; # [18 9 16]

代码的主要部分是 gather 关键字的代码块。它包含了对 @instructions 的循环, 根据当前的命令, 它要么改变当前位置的坐标, 要么使用 take 关键字取数, 如下所示:

take @matrix[$y][$x]

代码完成后, @result 数组将包含根据给定的 @instructions 选择的三个数字。

5.5.1. 用 given 设置主题

在上一节的例子中, 我们使用了链式 if-elsif 结构。让我们再看一次:

if $step eq 'up'         {$y--}
elsif $step eq 'down'    {$y++}
elsif $step eq 'right'   {$x++}
elsif $step eq 'left'    {$x--}
elsif $step eq 'take-it' {take @matrix[$y][$x]}

可以清楚地看到, 所有的分支都包含相同的代码, 将 $step 变量的当前值与预定义的值进行比较。虽然简单明了, 但这并不是进行这种比较的最优雅方式。

在一些语言中, 如 C和 C++, switchcase 关键字有助于重新组织 if-else 链。在 Raku 中, 我们使用 givenwhen。前面的代码可以用下面的方式重写:

given $step {
    when 'up'      {$y--}
    when 'down'    {$y++}
    when 'right'   {$x++}
    when 'left'    {$x--}
    when 'take-it' {take @matrix[$y][$x]}
}

这里发生的事情是, given$step 变量作为当前主题。这意味着它现在可以通过默认变量 $_ 使用。如果你在给定的代码块中打印, 你可以清楚地看到它, 如这里所示:

given $step {
    say $_;

    when 'up' {$y--}
    ...
}

when 里面, 主题变量与给定的值进行智能匹配, 换句话说, when 'up' 相当于 if $_ ~~ 'up'

在第一个关键字 when 找到匹配后, 其他的就不测试了。例如, 尝试在最后一个关键字 when 之前打印一些东西, 如下所示:

given $step {
    when 'up'    {$y--}
    when 'down'  {$y++}
    when 'right' {$x++}
    when 'left'  {$x--}
    say "Can only be 'take-it': $step";
    when 'take-it' {take @matrix[$y][$x]}
}

如你所见, 打印指令只有在没有一个方向指令被捕获时才会被访问:

Can only be 'take-it': take-it
Can only be 'take-it': take-it
Can only be 'take-it': take-it
[18 9 16]

由于 when 关键字执行的是智能匹配操作, 所以创建条件的方法比较多。例如, 可以直接测试变量的类型, 如下一段代码所示:

my @data = 1, 'two', 3, 'four', [1, 2];
for @data {
    when Int {
        say "$_ is an integer";
    }
    when Str {
        say "$_ is a string";
    }
    default {
        say "$_ is something else"
    }
}

在这个程序中, @data 数组包含不同类型的元素-整数和字符串。两个 when 语句根据类型名测试主题, 并打印出下面两个字符串中的一个。

1 is an integer
two is a string
3 is an integer
four is a string
1 2 is something else

如果没有触发任何一个 when 块, 则会执行可选的默认块。

你可能已经注意到, 在最后一个例子中没有 given 关键字。这是因为主题已经被 for 循环设置了, 没有必要再设置一次。

相反, 如你所见, given 关键字可以单独用来设置默认变量:

given 'John' {
    .say # prints $_, which is 'John'
}

given 'John' {
    say "Hello, $_"; # prints 'Hello, John'
}

5.6. 总结

在本章中, 我们介绍了 Raku 为传统的程序化编程提供的控制流。我们谈到了执行代码块, 并使用关键字 ifelsifelsif 进行决策。我们还谈到了不同的循环-基本循环、for 循环、以及带有前置条件或后置条件的 repeatuntilwhile 循环。然后, 我们看了使用 gather-take 对来收集数据, 以及 Raku 借助 givenwhen 关键字来处理主题的方式。

Raku 不仅仅能够使用程序化的编程风格。你将在第十三章《并发编程》、第十四章《函数式编程》和第十五章《反应式编程》中找到更多关于其他范式的信息。

同时, 在下一章中, 我们将讨论组织代码和子程序的另一个层次。

6. 子例程

子例程是编程的基本概念之一。它们有助于组织结构更好的代码, 也易于重用。Raku 为子例程提供了极大的支持, 并提供了许多有趣的相关特性。在 Raku 中, 子例程通常被称为 subs

在本章中, 我们将介绍以下几个主题:

  • 创建子例程

  • 调用一个子例程

  • 带类型的参数

  • 签名属性

  • 通过值或引用来传递参数

  • 运算符作为子例程

  • 对子例程的引用

  • 重载子例程和多重分派

  • 匿名子例程和 lambda

  • 可变占位符

6.1. 创建和调用子例程

sub 关键字创建一个子例程。一个典型的子例程有一个名称、一个形式参数列表和一个主体。然而, 名称和参数列表都是可选的。在第一章, 《什么是 Raku》中, 我们已经创建了一个子例程来使两个数字相加。让我们在这里回忆一下:

sub add($x, $y) {
    return $x + $y;
}

这里, add 是名称, 稍后将用于调用一个子例程。后面是子例程的参数列表-($x, $y)。子例程的主体用一对花括号括起来-{return $x + $y;}

要调用一个子例程, 请再次使用名称, 并在括号中传递实际参数:

my $a = 17;
my $b = 71;
my $sum = add($a, $b);
say "Sum of $a and $b is $sum"; # Sum of 17 and 71 is 88

子例程有两种方式可以返回一个值。第一种我们刚刚在 add 函数中看到过。如你所见, 它使用了一个显式的 return 关键字:

return $a + $b;

return 调用之后, 子例程停止执行。在 return 语句之后的任何额外代码都不会被执行。

在许多情况下, 当返回值在子例程体的最后一行计算时, return 关键字不是必须的。最后的计算值将是子例程的返回值。考虑到这一点, 我们对 add 函数进行如下修改:

sub add($x, $y) {
    $x + $y
}

函数的用法没有区别。注意, 在代码块的末尾不需要分号。在像前文这样的简单函数中, 这是一件好事, 可以让代码更轻快一些。

不是每个子例程都必须返回一个值。它可能会产生一些副作用, 例如, 写到数据库或从子例程体中立即打印。在 Raku 中, 并不像在 Pascal 中那样区分函数和程序。要创建一个不返回结果的子例程, 只需使用没有参数的 return 语句, 或者省略整个 return 语句本身。例如, 让我们修改 add 子例程, 让它自己打印结果。在这种情况下, 最好也给子例程重新命名, 使其名称真正反映出子例程的作用:

sub print_sum($x, $y) {
    say "$x + $y = ", $x + $y;
}

print_sum(10, 30); # 10 + 30 = 40

在学习 Raku 时, 要额外注意函数名与开头括号之间的空格。从第二章《编写代码》中, 我们知道在调用一个函数时, 子例程名和左括号之间不允许有空格。不过在很多情况下, 根本不需要括号。add 4, 5 的调用与 add(4, 5) 的行为完全一样。你应该设计自己的策略来处理函数调用中如何使用括号的问题。

另一个可选部分是参数列表。如果子例程不需要它们, 你可以使用空括号或者完全省略它们。在下面的例子中, 两种风格都使用了, 不过在一个程序中最好坚持使用其中的一种:

sub width() {
    1.30
}
sub height {
    2.40
}

say width() * height; # Don't follow this practice

6.1.1. 默认值

有时, 一个函数, 特别是当它需要许多参数时, 可以假设一些值是默认值。在这种情况下, 调用代码可以省略默认值。要指定一个参数的默认值, 可以在函数签名中的 = 号后面添加值:

sub add($x, $y = 1) {
    return $x + $y;
}

通过这个函数, 可以调用带有一个或两个参数的函数, 如下例所示:

say add(5, 6); # 11
say add(5);    # 6

请注意, 如果一个参数没有默认值(或者没有声明为可选参数-请看下一节, 可选参数), 你不能简单地省略它:

sub add($x, $y) {
    return $x + $y;
}

say add(5, 6); # OK
say add(5);    # Error

在编译时发生以下错误:

===SORRY!=== Error while compiling add.pl
Calling add(Int) will never work with declared signature ($x, $y)
at add.pl:6

一个函数的参数后面不能有其他的参数, 这些参数有默认值, 所以, 所有有默认值的参数必须放在参数列表的最后。否则, 编译器将无法理解哪些位置参数被传递给子例程。

6.1.2. 可选参数

Raku 的子例程还允许使用可选的参数。这些参数在签名中用问号表示。要检查参数是否被传递, 请使用内置的 defined 函数, 如下例所示:

sub greet($name, $greeting?) {
    say((defined $greeting) ?? "$greeting, $name!" !! "$name!");
}

greet('John');                 # John!
greet('John', 'Good morning'); # Good morning, John!

6.2. 命名参数

到目前为止, 我们已经使用了一些子例程, 这些子例程接受了一些参数, 它们的意义是由它们在参数列表中的位置来定义的;这些参数被称为位置参数。在 Raku 中, 参数也可以用名字来传递。命名参数可以出现在函数调用中的不同位置。

考虑一个函数的例子, 这个函数根据购买的物品数量和它们的价格计算总金额, 并打印出总价值。如果使用常规的位置参数, 该函数可以是这样的:

sub register($item-name, $item-price, $quantity) {
    my $total = $item-price * $quantity;
    my $plural-ending = $quantity > 1 ?? 's' !! '';
    say "$quantity $item-name$plural-ending cost €$total";
}

register('Book', 30, 1); # 1 Book cost €30
register('Book', 30, 5); # 5 Books cost €150

该子例程需要三个参数, 对于最终用户来说, 要记住它们的顺序可能会有问题。让我们通过给参数命名来避免这个问题:

sub register(:$item-name, :$item-price, :$quantity) {
    my $total = $item-price * $quantity;
    my $plural-ending = $quantity > 1 ?? 's' !! '';
    say "$quantity $item-name$plural-ending cost €$total";
}

register(item-name => 'Book', item-price => 30, quantity => 1);
register(item-name => 'Book', item-price => 30, quantity => 5);

命名参数在函数签名中以冒号为前缀。当调用一个函数时, 值是以它们的名字和值的对来传递的。有两种方法可以创建一个对-要么使用 箭头(如上例所示), 要么使用冒号(如下例所示):

register(:item-name('Book'), :item-price(30), :quantity(1));
register(:item-name('Book'), :item-price(30), :quantity(5));

如果在变量中包含了函数参数的值, 这些变量的名称与命名参数的名称相同, 那么可以使用与子例程定义中使用的相同语法来传递这些值, 如下面的代码所示:

my $item-name = 'Book';
my $item-price = 30;
my $quantity = 3;

register(:$item-name, :$item-price, :$quantity); # 3 Books cost €90

位置参数、可选参数和命名参数, 以及带有默认值的参数, 都可以在同一个子例程中使用。一般的规则是位置参数在前, 可选参数和默认参数在后。如果碰巧你的函数需要的参数太多, 也许是时候重新考虑一下方法了, 比如引入类(我们将在第八章《面向对象编程》中讨论), 或者使用多重分派, 如本章后面的多重分派部分所述。

6.2.1. 参数 trait

一个接受参数的子例程在其主体中使用这些参数。默认情况下, 子例程的参数是只读的值, 不能在子例程内部修改值, 如下面的代码所示:

sub f($a) {
    $a = 0;
}

my $x = 10;
f($x);

这将导致以下错误:

Cannot assign to a readonly variable ($a) or a value

有几种方法可以克服这个问题, 这取决于你打算如何使用修改后的值。如果修改后的参数只在子例程内部需要, 那么就创建一个它的副本, 如这里所示:

sub f($a) {
    my $b = $a;
    $b = 0;
    say "b = $b";
}

my $x = 10;
f($x); # b = 0

为了避免创建临时变量, 最好通过附加 is copy trait 来标记子例程的参数, 如下所示:

sub f($a is copy) {
    $a = 0;
    say "a = $a";
}

my $x = 10;
f($x);  # a = 0
say $x; # 10

Raku 提供了另一种可能性-is rw trait-如果你想修改作为参数传递给子例程的原始变量。在这种情况下, 子例程之外的变量在调用后会有一个新的值:

sub f($a is rw) {
    $a = 0;
}

my $x = 10;
f($x);
say $x; # 0

使用 is rw trait 时, 不可能向子例程传递一个常量, 因为程序将无法修改以下内容:

sub f($a is rw) {
    $a = 0;
}

f(5); # Error: Parameter '$a' expected a writable container,
      #        but got Int value

最后, 检查一下不太常用的特质-is raw。它的行为与 is rw trait 有些相似, 但不同的是它可以绑定变量和常量。考虑以下两个子的例子-一个使用 is rw trait, 另一个使用 is raw 参数;两个子例程都不修改其参数:

sub f($a is rw)  {}
sub g($a is raw) {}

当你向它传递一个变量时, 这两个函数都能正常工作, 如下所示:

my $x = 10;
f($x);
g($x);

然而, 如果你传递一个常量, 也就是一个没有变量容器的值, is rw trait 将阻止它被子例程接受。f(5) 调用会发出以下错误:

Parameter '$a' expected a writable container, but got Int value

用常数调用 g 函数还是可以的, 当然, 你不能在子例程里面修改常数:

sub g($a is raw) {
    $a = 0;
}

g(5); # Error: Cannot assign to an immutable value

你可以使用一种替代的语法, 用反斜杠代替标量符号, 而不是附加 is raw trait, 如下面的代码所示:

sub q(\a) {
    a++;
}

my $x = 10;
q($x);
say $x; # 11

在 Raku 编译器的源代码中可以找到反斜线的参数。我们将在本章后面的创建运算符一节中看到一些例子。

6.2.2. 吞噬参数

Raku 的伟大之处在于它允许在函数签名中传递数组和散列值, 这意味着数组是作为一个单一的值传递的, 而不是作为一个值的列表。请看下面这个简单的例子, 它说明了如何修改 add 函数来返回一个数组中所有元素的总和:

sub add(@arr) {
    [+] @arr
}

my @a = <10 20 30>;
say add(@a); # 60

[+] 结构是 + 运算符的化简形式, 详见第四章《使用运算符》。它返回 @arr 数组中所有元素的总和, 这是子例程的唯一参数。

你可以放心地在数组后添加更多的参数到子例程中。让我们创建一个函数来计算一个数组中前 $n 个元素的和:

sub sum_first(@a, $n) {
    [+] @a[0..$n - 1]
}

my @a = (1..10);
say sum_first(@a, 5); # 1 + 2 + 3 + 4 + 5 = 15

$n 参数不会与 @a 数组的内容混合。

如果我们尝试使用之前创建的 add 函数来获取一个数组中两个值组成的元素之和呢?

sub add($x, $y) {$x + $y}

my @a = (4, 5);
say add(@a);

这段代码无法编译:

===SORRY!=== Error while compiling slurpy.pl
Calling add(Positional) will never work with declared signature ($x, $y)
at slurpy.pl:4
------> say add(@a);

子例程期望一个数组, 但得到两个标量, 这是错误的。为了让 add 函数接受一个数组的值, 应该将数组进行扁平化处理。也就是说, 当传递给函数时, 一个数组变成了它的值的列表。扁平化数组是通过在数组前加一个竖直条来实现的, 如下所示:

sub add($x, $y) {$x + $y}

my @a = (4, 5);
say add(|@a); # 9

现在考虑相反的情况-你将一个标量列表传递给一个接受数组的函数, 在这种情况下, 你应该将数组参数声明为 slurpy。它将消耗这些标量并将它们累积到一个单一的变量中。在下一个例子中, 我们将进行演示。slurpy 参数的前缀是星号, 如下面的代码行所示:

sub add(*@a) {[+] @a}

say add(3, 4, 5, 6); # 18

由于 slurpy 数组会消耗参数列表的其余部分, 所以它应该是子例程签名中的最后一个参数。

6.2.3. 参数占位符

即使没有子例程的签名, 子例程仍然可以接受和使用参数。Raku 定义了所谓的占位符, 也就是子例程内部带 ^ twigil 的变量。 我们可以在下面的代码中看到这一点:

sub greet {
    say "Hello, $^name!";
}

greet('Mark'); # Hello, Mark!

在这段代码中, $^name 变量取函数调用时传递的字符串的值。该值成为子例程的一个只读参数。

如果有一个以上的参数, 它们的顺序对应于占位符的字母顺序:

sub subtract {
    $^b - $^a
}
say subtract(10, 8); # -2

这里的值10和8位于 $^a$^b 变量中。

当使用占位符时, 函数的签名不能与占位符的数量和类型冲突。所以, 在前面的例子中, 你不能用空括号来定义函数, 如 greet() {…​}subtract() {…​}

6.3. 类型约束

在 Raku 中, 你不需要声明一个变量的类型, 但如果你想的话, 你可以这样做。同样的规则也适用于子例程的参数和它的返回值。

6.3.1. 类型化的参数

在本章前面的章节中, 我们没有说过关于 add 函数的 $a$b 参数的类型。子例程的代码因为使用了 + 运算符, 所以假设参数应该是数字型的。调用一个有两个字符串作为参数的函数, 例如, add('Hello', 'World'), 会产生以下运行时错误:

Cannot convert string to number: base-10 number must begin with valid
digits or '.' in ' Hello' (indicated by   )

这个异常发生在运行时。虽然编译器看到你传递了两个字符串, 但它并没有检查是否为该类型的两个参数定义了 + 操作。在编译时可以通过指定子例程参数的类型来防止这类错误, 如下所示:

sub add(Int $x, Int $b) {
    return $x + $b;
}

用整数调用子例程是可行的。用字符串调用子例程会引起编译时错误:

===SORRY!=== Error while compiling add.pl
Calling add(Str, Str) will never work with declared signature (Int $x, Int $b)

在子例程的声明中, 参数列表称为签名。错误信息告诉我们, 我们要调用的函数的签名与程序中定义的函数的签名不一致。即需要调用一个名称为 add, 签名为 (Str, Str) 的函数。代码中包含了一个名为 add 的子例程, 但它的签名是不同的。(Int $x, Int $b)。参数本身的名称在签名检查中并不重要。

类型约束可以做得更严格。为了防止子例程接受未定义的值, 可以在类型名后添加 :D trait, 如下所示:

sub add(Int:D $x, Int:D $b) {
    return $x + $b;
}

现在只有定义的(因此名称 :D)值才能通过类型检查:

my $a;
my $b = 10;

# add($a, $b); # Run time error because $a is not defined.

add($b, 20);   # Fine. Both operands are Int:D

6.3.2. 返回类型

从子例程返回的值的类型也可以归属于特定的类型约束。在 Raku 中, 有三种方法可以做到这一点。

第一种方法是签名末尾的一个箭头:

sub add(Int $x, Int $y --> Int) {
    return $x + $y;
}

这个方法是最通用的。它不仅允许指定返回值的类型, 还允许给出一个显式常量:

sub funky_add(Int, Int --> 100) {}

这个函数将始终返回 100, 不管参数值如何。在下一节多重子例程中, 我们将看到一个更有用的返回常量的应用。

另一种指定返回类型的方法是在子例程的签名和主体之间使用 of 关键字, 如这里所示:

sub add(Int $x, Int $y) of Int {
    return $x + $y;
}

最后, 返回类型可以放在子例程的名字前面, 类似于定义变量类型的方式。注意, 在这种情况下, 你需要将子例程声明为 my:

my Int sub add(Int $x, Int $y) {
    return $x + $y;
}

从子例程返回的实际值必须是指定的类型。如果不是, 就会出现运行时错误。让我们打破我们的 add 函数, 返回除法的结果, 而不是加法:

sub add(Int $x, Int $y --> Int) {
    return $x / $y;
}

say add(1, 2);

代码会被编译, 但无法执行:

Type check failed for return value; expected Int but got Rat (0.5)
    in sub add at return.pl line 1
    in block <unit> at return.pl line 5

请注意, 即使结果可以在不丢失数据的情况下转换为整数值, 也会发生同样的错误, 就像 add(10, 2) 调用一样。这里的结果类型仍然是 Rat, 而不是 Int

6.4. Multi Subs

签名是子例程的一个重要属性, 它不仅有助于检查参数的类型, 而且 Raku 还使用它来控制传递的参数数量。例如, 声明一个用于求和的函数, 它需要三个参数, 但只用两个参数来调用它:

sub add($x, $y, $z) {
    return $x + $y + $z;
}

say add(1, 2);

这个程序不起作用。再一次, 签名是我们的朋友:

===SORRY!=== Error while compiling add.pl
Calling add(Int, Int) will never work with declared signature ($x, $y, $z)
at add.pl:5

因此, 我们看到, 当决定调用哪个函数时, Raku 会考虑到参数的数量、类型以及子例程的名称。程序员可以通过创建不同版本的函数来受益于这个特性, 这些函数共享相同的名称。它们之间的区别将通过它们的签名来解决。

现在让我们把两个变体的 add 函数放在一起(这次我会用不同的格式)。为了告知编译器你的意图是要创建多个变体, 请添加 multi 关键字:

multi sub add($x, $y)     {$x + $y}
multi sub add($x, $y, $z) {$x + $y + $z}

现在可以用两个或三个参数调用 add 函数, 如下所示:

say add(1, 2);    # 3
say add(1, 2, 3); # 6

现在编译器没有理由抱怨。它只会调用多重子例程中的一个, 这取决于调用中使用的参数数量。

同样, 类型化的参数也可以用来选择多重子例程。让我们使使用 add 名来添加字符串成为可能。为此, 可以使用一个连接运算符(~)。让我们创建两个名称相同而签名不同的函数:

multi sub add(Int $x, Int $y) {$x + $y}
multi sub add(Str $x, Str $y) {$x ~ $y}

现在可以写出如下代码:

say add(4, 2);     # 6
say add('4', '2'); # 42

针对这些调用, 会有不同的函数被调用。而且, 尽管 '4' 和 '2' 字符串只包含数字, 但它们首先是 Str 类型的值。

类型化的参数可以用于更精细地分离函数的不同变体。例如, 让我们为 IntRatNum 值创建三个函数:

multi sub f(Int) {say 'f(Int)'}
multi sub f(Rat) {say 'f(Rat)'}
multi sub f(Num) {say 'f(Num)'}

f(10);   # f(Int)
f(20/2); # f(Rat)
f(1E1);  # f(Num)

这个例子只是为了演示 Raku 如何选择多重子例程的正确候选者。这些函数并没有对参数做任何有用的事情, 我甚至没有给参数起一个名字。从输出中可以看到(见前面代码中的注释), 这三次调用指的是三个不同的多重子例程。

使用 multi, sub 关键字可以省略, 如下面的代码所示:

multi f(Int) {say 'f(Int)'}
multi f(Rat) {say 'f(Rat)'}
multi f(Num) {say 'f(Num)'}

多重子例程可以做得更多。不仅可以根据不同的类型, 还可以根据不同的值, 进行多重分派。例如, 让我们创建一个子例程, 接受 Num 的值, 并创建一个单独的子例程, 只应该为 Pi 的值触发:

multi sub f(pi) {say 'The value of Pi is well-known!'}
multi sub f(Num $n) {say "Value is $n"}

f(pi); # The value of Pi is well-known!
f(e);  # Value is 2.71828182845905

这种方法对于其他类型的数据也能很好地使用;比如说, 字符串。

最后, 多重子例程对于用户创建的类型是理想的。我们将在第八章《面向对象编程》中讨论如何使用类。所以, 让我们创建一个子类型, 并将值分成两组。

我们创建两个子例程, 其中一个子例程只接受一个 Str 参数, 另一个子例程只接受长度短于十个字符的字符串:

multi sub message(Str $str) {
    '<p>' ~ $str ~ '</b>'
}
multi sub message(Str $str where {$str.chars < 10}) {
    q{<p class="large">} ~ $str ~ q{</p>}
}

say message('Hi!');
say message('The weather is fine today');

程序打印带有信息的 HTML 代码, 但增加了一个 CSS 类, 使短文本的字体变大。

<p class="large">Hi!</p>
<p>The weather is fine today</p>

6.4.1. 一个例子

为了总结我们对多重子例程的认识, 我们来创建一个斐波那契数递归计算的例子。在这段代码中, 我们还将使用类型约束:

multi sub fibonacci(0 --> 0) {}
multi sub fibonacci(1 --> 1) {}
multi sub fibonacci(Int $n --> Int) {
    return fibonacci($n - 1) + fibonacci($n - 2);
}

my @fib;
push @fib, fibonacci($_) for 1..10;
@fib.join(', ').say;

多重子例程在这里用来引导递归的 fibonacci($n - 1) + fibonacci($n - 2) 公式, 适用于 $n 小于2的值。 fibonacci 子例程的前两个变体响应于0和1的值。 我们将在子例程主体中不返回一个整数, 而是使用箭头语法在签名中指定返回值, 如这里所示:

multi sub fibonacci(0 --> 0) {}
multi sub fibonacci(1 --> 1) {}

6.5. 嵌套的子例程

子例程可以被嵌套。换句话说, 你可以在另一个子例程中定义一个子例程。让我们在下一个例子中看看, 它列出了常规英语动词的现在时态形式:

sub list_verb_forms($verb) {
    sub make_form($base, $pronoun) {
        my $form = $base;
        # Adds the 's' ending for he, she, and it.
        # The check uses a regular expression.
        # We cover regular expressions in Chapter 11, Regexes.
        $form ~= 's' if $pronoun ~~ /^ [ he | she | it ] $/;

        return "$pronoun $form";
    }

    my @pronouns = <I we you he she it they>;

    for @pronouns -> $pronoun {
        say make_form($verb, $pronoun);
    }
}

list_verb_forms('read');

如你所见, 这个程序的结果正是我们想要的:

I read
we read
you read
he reads
she reads
it reads
they read

list_verb_forms 函数迭代 @pronouns 数组中的代词列表, 并为每个代词调用 make_form 子例程。因为我们在代码中不需要第二个子例程, 所以将它的作用域限制在第一个子例程的主体中是符合逻辑的。

将一个子例程嵌套在另一个子例程中, 使得它对代码的其余部分不可见。你不能在 list_verb_forms 的主体之外的任何地方调用 make_form 子例程:

===SORRY!=== Error while compiling /Users/ash/code/nested.pl
Undeclared routine:
    make_form used at line 20

6.6. 创建运算符

Raku 中的运算符是子例程。在大多数情况下, 运算符子都是多重子例程。例如, 考虑一下 + 运算符。它的语义是将两个值相加, 而这两个值又可以是不同类型的。你可以要求 Raku 将两个整数、浮点数或复数相加。或者, 在同一个调用中, 操作数可能是不同类型的, 比如说, 在相加一个复数和一个整数时。同样的 + 运算符也能很好地用于代表日期的类型。为了实现所有这些灵活性, Raku 使用了多重子例程。

让我们简单地潜入 Rakudo 的源代码中, 搜索一下 + 运算符的几个定义:

multi sub infix:<+>(Int:D \a, Int:D \b)
multi sub infix:<+>(Num:D \a, Num:D \b)

multi sub infix:<+>(Complex:D \a, Complex:D \b)
multi sub infix:<+>(Complex:D \a, Num(Real) \b)
multi sub infix:<+>(Num(Real) \a, Complex:D \b)

multi sub infix:<+>(Date:D $d, Int:D $x)
multi sub infix:<+>(Int:D $x, Date:D $d)

Rakudo 编译器部分是用 Raku 本身编写的, 所以你可以很容易理解这些摘录中的函数头。

在 Raku 中, 还可以创建一个自定义的运算符, 可以在你的代码中使用。此外, 还可以扩展程序中创建的类型的运算符的行为。

例如, 如果你想让 + 运算符来连接字符串, 只需为字符串操作数定义它的行为即可, 如这里所示:

multi sub infix:<+>(Str $a, Str $b) {
    $a ~ $b
}

say 'Hello, ' + 'World!';

很可能你还需要考虑操作数类型的其他可能组合。想象一下, 我们正在创建打印错误信息的代码, 并希望使用 + 运算符来附加一个数字, 如下所示:

say 'Error at line ' + 5;

这样做是行不通的, 因为没有任何候选的 + 运算符可以接受一个字符串和一个整数。其中一个选择是进行类型转换:

say 'Error at line ' + ~5;

现在我们有两个字符串, 编译器将选择 infix:<+>(Str, Str) 运算符。

为了使开发者的工作更容易, 最好为运算符定义更多的变体, 使其使用变得直观。所以, 让我们把 infix:<+>(Str, Int) 添加到我们的集合中:

multi sub infix:<+>(Str $a, Int $b) {
    $a ~ ~$b
    # Or: $a ~ $b.Str
}

现在 'Error at line '+5 代码变得有效。

也许你还需要定义一个运算符, 将整数作为左操作数, 将字符串作为右操作数:

multi sub infix:<+>(Int $a, Str $b) {
    ~$a ~ $b
}

say 5 + ' errors';

正如我们所看到的, 创建新的运算符是一项非常负责任的任务。你应该思考和预测可能的用例, 并定义相应的子例程。

在前面的例子中, 我们使用了 Raku 中已经存在的一个运算符符号。在你的程序中, 你也可以定义一个新的运算符, 自己选择运算符的样子。下面的例子定义了一个名为 plus 的中缀运算符, 它将两个整数相加, 类似于 + 运算符的作用。

sub infix:<plus>(Int $x, Int $y --> Int) {$x + $y}

say 10 plus 20; # 30

当定义新的运算符时, 请确保它们的语义对你的代码用户来说是清晰的。例如, 如果你为字符串操作数定义了 + 运算符, 可能会引起混淆, 如下例所示:

multi sub infix:<+>(Str $a, Str $b) {
    $a ~ $b;
}

say "4" + "9";

如果没有重新定义的中缀运算符, "4""9 "返回 13, 因为两个操作数都被转换为 `Numeric` 值。如果定义了 `infix:<>(Str, Str)`, 程序将打印 49, 因为操作数是字符串, 而且有一个中缀子例程接受两个字符串参数。

6.7. 传递函数作为参数

Raku 中的函数可以作为参数传递给其他函数。一个典型的例子是排序算法, 它需要一个函数来比较两个值。根据数据类型的不同, 可以是不同的函数, 知道如何比较该类型的值。

我们来看看下面这个小小的例子:

sub less($a, $b) {$a < $b}
sub more($a, $b) {$a > $b}

sub compare($a, $b, $f) {
       $f($a, $b)
}

say compare(10, 20, &less); # True
say compare(10, 20, &more); # False

主代码调用 compare 子例程, 有三个参数-两个整数和一个函数的引用-&less&more。名字前的 & 符号告诉我们此时不应该调用函数(记住, 在 Raku 中, 调用函数时不需要括号)。

compare 函数里面, 第三个参数 $f 是一个函数的引用。现在你可以通过附加一个参数列表来调用被引用的函数-$f($a, $b)。这将等同于调用 less($a, $b)more($a, $b)

$f 参数的类型是 Sub; 我们可以在子例程签名中添加一个类型约束来强制使用:

sub compare($a, $b, Sub:D $f) {
    $f($a, $b)
}

注意, $f $a, $b 的调用不会被编译, 因为 $f 不是一个子例程的名字。这里的括号是 .() 后环缀调用-$f.($a, $b) 的缩写形式。

6.8. 匿名子例程

一个没有名字的子例程被称为匿名子例程。你不能用名字来调用它, 但仍然可以通过一个句柄来运行它, 例如, 这个句柄存储在一个变量中。普通子例程的所有属性, 如签名和主体, 其定义方式与普通子例程相同。

在下面的代码中, 我们将创建一个匿名子例程, 并将其保存在 $add 变量中;在签名前需要一个空格:

my $add = sub ($x, $y) {$x + $y}
say $add(10, 20); # 30

Raku 允许混合使用常规子例程和匿名子例程。anon 关键字可以在常规子例程的基础上创建一个匿名子例程, 所以仍然可以用它的名字来调用它。首先, 看看这个子例程, 它既可以作为匿名子例程使用, 也可以用它的名字来调用:

my $add = sub add ($x, $y) {$x + $y}
say $add(1, 2); # using anonymous sub
say add(3, 4); # using regular sub call by name

现在, 让我们使用 anon 关键字:

my $add = anon sub add($a, $b) {$a + $b}
say $add(3, 4);  # ok
# say add(3, 4); # won't compile

通过 $add 变量调用函数是可以的, 而试图通过函数名来调用函数甚至不能被编译-Raku 不会把 add 名放在本地符号表中, 因为 anon 明确禁止这样做。

除了匿名子例程, 你还可以使用匿名代码块。它们被称为尖号块, 因为它们使用了一个箭头, 如本例所示:

my $add = -> $x, $y {$x + $y};
say $add(7, 8); # 15

匿名子例程和尖号块的区别在于它们返回的对象类型不同。子例程的类型是 Sub, 而尖号块则创建一个 Block:

my $sub = sub ($x) {$x * 2};
my $block = -> $x {$x * 2};

say $sub.^name;   # Sub
say $block.^name; # Block

在 Raku 对象系统的层次结构中, Sub 类是 Routine 类的子类, Routine 类是 Block 的子类。Routine 类为我们提供了额外的功能, 比如添加特征或使子成为一个多重子例程。

另外, 请注意, 尖号块的参数并没有列在括号里。万一你添加了它们, Raku 会认为你在调用块时要传递一个数组。这一点在下面的例子中得到了证明:

my $add = -> ($x, $y) {$x + $y};
my @a = <5 6>;
say $add(@a);

传递两个参数会导致以下异常:

my $add = -> ($x, $y) {$x + $y};
say $add(5, 6); # Error: Too few positionals passed;
                # expected 2 arguments but got 0 in sub-signature

6.9. 总结

子例程, 或者说 subs, 是 Raku 的基石之一。在本章中, 你学会了如何创建和使用子例程。我们详细研究了子例程参数的属性, 例如通过复制传递参数、允许读写参数、定义默认值或提供可选参数。你看了 slurpy 参数和扁平化数组, 学会了如何约束两个参数的类型, 然后返回值。

除此之外, 我们还讨论了子例程的其他应用。即, 我们谈到了用 multi 关键字重载子例程, 看了如何在语言中创建和嵌入新的运算符, 以及如何将函数传递给其他函数。最后, 我们还简单地看了一下匿名函数和尖号代码块。

在下一章, 我们将讨论模块, 这是 Raku 中封装代码的下一个层次。

7. 模块

在上一章中, 我们讨论了子例程。子程序使代码更易读, 也更容易重用。

本章将涵盖以下主题:

  • 在模块中组织代码

  • 加载模块

  • 导出名称

  • 内省

  • 模块位置

  • 安装和卸载模块

7.1. 创建模块

Raku 中模块的作用是将代码保存在不同的文件中。它可能是由你自己开发的几个函数组成的简单库, 也可能是一个由外部公司开发的大类集合。无论如何, 如果你使用了一个模块, 你就可以获得前人的工作能力, 并且有一个接口来实现这个功能。

在本章中, 我们将讨论用模块组织代码, 并在程序中使用模块。

让我们创建我们的第一个模块, 让我们以前面几章中开拓的数字加法的简单任务为例, 比如在第2章《编写代码》中。

所以, 我们有一个加法函数, 用于将两个数字相加, 以及使用该函数的代码:

sub add($a, $b) {
    return $a + $b;
}

my $a = 10;
my $b = 20;
my $sum = add($a, $b);
say $sum; # 30

我们目前的目标是将 add 函数的代码放到一个独立的模块中, 然后在主程序中使用该模块。

Raku 的模块通常被保存在以 .pm 为扩展名的文件中(这代表着 Perl 模块)。如果由于某种原因, 你想强调 Raku 的用法, 以区别于 Perl 5, 你可以使用 .pm6 扩展名; 在这种情况下, 你最好使用 .p6 作为 Raku 程序的扩展名。

让我们为这个例子创建一个文件, 并将函数的代码复制到这个文件中。然而, 这还不够。模块必须要有一个名字, 这个名字在 unit module 指令中提到, 放在文件的开头。

unit module Add;

sub add($a, $b) {
    return $a + $b;
}

模块的名称和对应的文件名最好保持一致。在我们的案例中, Add 模块位于文件 Add.pm 中。在本章后面的 Rakudo 如何存储模块一节中, 你会看到 Rakudo 如何管理文件名和模块名之间更复杂的关系。

现在, 让我们尝试使用该模块, 并重写主程序, 使其从模块中调用一个函数。

use Add;

my $a = 10;
my $b = 20;
my $sum = add($a, $b);
say $sum;

注意这个文件的开头有一个指令 use Add;。为了满足这个要求, Raku 会找到并加载相应的模块文件。

但我们的代码还不能工作。让我们试着执行它, 然后再试着定位错误。

$ raku main.pl
===SORRY!===
Could not find Add at line 1 in:
    /Users/ash/.raku
    /Applications/Rakudo/share/raku/site
    /Applications/Rakudo/share/raku/vendor
    /Applications/Rakudo/share/raku
    CompUnit::Repository::AbsolutePath(140518513192432)
    CompUnit::Repository::NQP(140518512012576)
    CompUnit::Repository::Perl5(140518512012616)

如你所见, 编译器无法找到 Add.pm 文件。它试图在几个被认为是默认的目录中搜索, 但失败了。由于该文件保存在和 main.pl 文件所在的同一个目录中, 我们可以帮助编译器, 使用命令行选项 -I 给它提供位置。

$ raku -I . main.pl

现在, 模块文件找到了, 但我们仍然得到一个错误:

===SORRY!=== Error while compiling /Users/ash/code/modules/main.pl Undeclared routine:
add used at line 5. Did you mean 'dd'?

现在的问题是, 编译器不知道 add 这个名字。我们在模块里有一个带这个名字的函数, 但使用该模块的代码却看不到它。为了解决这个问题, 我们必须通知编译器这个名字是导出的, 可以在模块之外使用。在函数中附加 is export trait:

unit module Add;

sub add($a, $b) is export {
    return $a + $b;
}

有了这个, 我们终于可以运行程序, 得到理想的结果。

$ perl6 -I . main.pl
30

如果没有 is export 特性, 模块的任何函数都会被隐藏起来, 可以作为模块内部的内部机制使用, 不会暴露在你的代码中。这有助于解决程序中不同部分的函数之间的名称冲突, 同时也允许更好地与模块接口—它只导出真正需要导出的函数。

另外, 你可以在主代码中使用 -M 选项来请求加载模块, 而不是在主代码中明确的使用 use Add; 指令:

$ perl6 -M Add -I . main.pl
30

为了简化命令和避免重复指令, 可以通过 PERL6LIB 环境变量传递模块的路径:

$ export PERL6LIB=.
$ perl6 main.pl

多个目录之间必须用逗号隔开:

$ export PERL6LIB=/Users/ash/lib,/Users/ash/code/modules
$ perl6 main.pl

7.2. 使用模块

在上一节中, 我们创建了一个简单的模块, 其中有一个函数, 并在程序中使用了这个模块。在本节中, 我们将探讨 Raku 如何加载模块及其函数的其他方法。

有四个关键词, 我们要探讨的是 useneedrequirdimport。它们都是在加载模块的上下文中使用的, 但它们的行为方式有些不同。

使用一个模块至少有两个条件:第一, 必须找到并编译模块文件;第二, 模块中的名字(如子程序或变量)对程序来说应该是可见的。

7.2.1. need 关键字

need 关键字在编译时加载一个模块, 但不会从模块中导入名称。加载一个模块也意味着其中的所有指令都将被执行。同时, BEGIN 块也将被运行。让我们在模块中添加一些打印指令, 看看它是如何改变输出的。

下面是新的模块。

unit module Add;

say 'Start';

sub add($a, $b) is export {
    return $a + $b;
}

BEGIN {
    say 'This is Add.pm';
}

在主程序中, 我们这次使用 need 而不是 use, 如下所示:

need Add;

my $a = 10;
my $b = 20;
my $sum = add($a, $b);
say $sum;

因为模块中的函数没有导入到程序中, 所以不会出现模块本身的输出, 不过, 模块本身的输出会出现(BEGIN 模块是先触发的, 就像普通程序一样)。

$ perl6 -I . main.pl
This is Add.pm
Start
===SORRY!=== Error while compiling /Users/ash/code/modules/main.pl Undeclared routine:
add used at line 5. Did you mean 'dd'?

7.2.2. import 关键字

import 关键字从模块中导入具有 export 特征的名称。

结合 need, import 完成了从模块中获取功能的过程。让我们来更新程序并运行它。

need Add;
import Add;

say add(10, 20);

这一次, 一切正常, 程序打印出了 add 函数返回的结果。考虑一下下面的代码:

$ perl6 -I . main.pl
This is Add.pm
Start
30

注意, 光有 import 是不足以加载模块的。它应该遵循 need 指令。

needimport 都是在编译时发生的。这意味着, 在程序编译后, 这些指令在主程序之前执行。从实际的角度来说, 这意味着你不应该考虑这些指令在程序中的位置。例如, 下面的程序仍然可以工作, 而 needimport 对位于代码的末尾。

say add(10, 20); # 30

need Add;
import Add;

7.2.3. use 关键字

use 关键字实际上是作为 needimport 的组合来工作的。同样的, 它在编译时工作, 所以 use 指令的实际位置并不重要(但请看本章后面的《作用域》部分)。

这个关键字是加载模块的最简单、最直接的方法。

use Add;

say add(10, 20); # 30

一旦模块被加载, use 就会自动从模块中导入名称, 它们就会在程序的其他部分中变得可用。

7.2.4. require 关键字

最后一个关键字, require, 在运行时加载一个模块。因此, 顺序很重要。为了从模块中访问函数, 我们现在需要完全限定的名称。反过来, 要想让模块使用它的函数, 就必须借助于 our 关键字, 对它们进行不同的作用域化处理。

下面的代码就是新版的文件 Add.pm

unit module Add;

our sub add($a, $b) {
    return $a + $b;
}

而这里有一个程序, 需要该模块, 并使用其全名引用 add 函数:

require Add;

say Add::add(10, 20); # 30

注意, 完全限定的名称是在 :: 的帮助下构造的。在下一个例子中, 我们将使用存储在一个变量中的模块名, 这个模块名可能会在程序的其他部分中以某种方式改变, 然后才需要这个模块。

my $module = 'Add.pm';
# ...
require $module '&add';
say add(10, 20); # 30

这里, add 例程是从 Add.pm 模块中导入的。它应该在那里标记为 is export

unit module Add;

our sub add($a, $b) is export {
     return $a + $b;
}

7.2.5. 作用域

上面列举了四个加载模块的指令, 以及导入名称的是如何被作用域化的, 例如, 在主代码中的函数内部, 可以对其进行作用域化。在这种情况下, 它们的行为被限制在给定的作用域内。

例如, 如果 add 函数只在某些函数内部被需要, 就不需要全局加载模块, 如下面的例子所示:

say do_calc();

sub do_calc {
    use Add;
    return add(10, 20);
}

要知道, 虽然导出的名称的作用域只限于 do_calc 函数, 但在编译时仍然会加载模块。让我们修改一下程序和模块, 看看会发生什么情况。

在程序中添加一个简单的 say 指令:

say 'Starting a program';
say do_calc();

sub do_calc {
    use Add;
    return add(10, 20);
}

以同样的方式, 将其添加到模块中:

unit module Add;

say 'Starting a module';

sub add($a, $b) is export {
    return $a + $b;
}

现在, 运行程序, 确认程序启动执行前, 模块已经加载完毕。

$ raku -I . main.pl
Starting a module
Starting a program
30

如果你想有条件地加载一个模块, 使其只在特定条件下或在特定的代码分支中发生, 请使用 require 关键字。在这种情况下, 你可以根据程序的流程轻松地加载所需的模块。考虑一下上一个程序的变体。

say 'Starting a program';
say do_calc();

sub do_calc {
    require Add;
    return Add::add(10, 20);
}

在这种情况下, 输出会不一样(别忘了在模块中的函数前加上 our)。

$ raku -I . main.pl
Starting a program
Starting a module
30

7.2.6. 更多关于 is export 的信息

我们在前面的章节中看到, is export 特征的使用并不局限于导出子例程。

导出变量

可以从模块导出变量:

unit module Credentials;

our $username is export = 'alpha'

使用这个变量就像使用常规变量一样简单:

use Credentials;

say "User = $username";

7.2.7. 选择性导出

有时, 一个模块可能提供了大量的功能, 而程序只使用了其中的一部分。在这种情况下, 你可以用这样的方式来组织一个模块, 以便只导出有限的名称集合。Raku 提供了一种标记名称的机制, 如下面所示的模块示例。

unit module Math;

sub add($a, $b) is export(:plusminus) {
       return $a + $b;
}

sub subtract($a, $b) is export(:plusminus) {
    return $a - $b;
}

sub mul($a, $b) is export(:muldiv) {
    return $a * $b;
}

sub div($a, $b) is export(:muldiv) {
    return $a / $b;
}

在这个模块中, 有两个函数的集合:两个用 :plusminus 标签, 两个用 :muldiv 标签。

在导入模块时, 使用该标签选择要导入的名称。例如, 让我们只导入 addsubtract 这两个函数。

use Math :plusminus;

say add(10, 20);
say subtract(20, 10);

另外两个函数, muldiv, 在导入时将无法使用。要允许使用这两个函数, 请用不同的标签重新导入模块, 或者如下面的例子所示, 列出所有需要的标签。

use Math :plusminus, :muldiv;

say add(10, 20);
say subtract(20, 10);

say mul(1, 2);
say div(5, 2);

有三个预定义的标签--:ALL:DEFAULT:MANDATORY

:ALL 标签会导入所有带任何 is export trait的名字。这包括一个没有标签的裸露的 trait, 一个带有命名标签的 trait 或者一个带有 :ALL 标签的 trait。例如, 你可以用一个具有相同效果的单行来代替上一个例子中的两个使用行。

use Math :ALL;

带有 :DEFAULT 标签的 use 指令会加载这些名称, 这些名称是用 is export 特征创建的, 没有任何标签。

最后, 为了强制导入一个名称, 无论导入方法如何, 在模块中标记为 is export(:MANDATORY)

7.3. 内省

Raku 模块包含一个机制, 它允许你获得关于模块内容的信息。获取这种元信息被称为内省。

以上一节 Math.pm 模块为例, Math.pm 模块在上一节《更多关于 is export 的信息》中的内容为例。这样我们就可以列举出该模块导出的所有方法。

use Math;

say Math::EXPORT::.keys;

这里指的是默认的 EXPORT 子例程, 是编译器为我们生成的。sub 返回一个 EXPORT 类型的对象, 它实现了 Perl6::Metamodel::PackageHOW 接口。我们不会深入研究这个理论, 只限于调用一个有用的方法 keys, 它可以给我们提供模块中可用的标签列表。

(plusminus muldiv ALL)

有了标签的列表, 我们可以对其进行迭代, 得到属于它们的子例程列表。

use Math;

say Math::EXPORT::plusminus::.keys;
say Math::EXPORT::muldiv::.keys;
say Math::EXPORT::ALL::.keys;

这个程序打印出以下三行, 每一个标签一个。

(&add &subtract)
(&mul &div)
(&mul &div &add &subtract)

所以, 你可以清楚地看到 :plusminus 标签对应的是 addsubtract 函数, :muldiv 标签对应的是 muldiv, 而 :ALL 标签则给出了所有导出的函数列表。同样的, 如果我们把子例程标记为 is export :DEFAULTis export :MANDATORY, 我们可以用同样的方法请求它们的列表。

现在, 让我们继续在 Raku 中自动化管理模块的过程。

7.4. 使用 zef

请注意, 本节介绍的是 Rakudo Star 发行版特有的工具。在编写本文的时候, Rakudo 是市场上唯一的生产就绪的 Raku 编译器。如果你刚好使用其他编译器, 请查看它们的文档, 了解如何安装模块。

Rakudo Star 发行版自带了一个名为 zef 的模块管理工具。请注意, 从 Rakudo Star 2017.01 版本开始, 它就已经是发行版的一部分了。早期的版本包括另一个工具 panda, 现在已经过时了。

zef 是一个用 Raku 编写的命令行工具。安装 Rakudo Star 后, 它的可执行文件会在 Rakudo/share/perl6/site/bin 目录下。Rakudo 安装程序还修改了 PATH 环境变量, 使其包含了一个正确的路径, 可以到达该发行版的可执行文件的目录。

这个工具使用的是 Raku 模块的生态系统, 目前还在积极开发中。要了解更多信息, 请访问 modules.raku.org 页面, 其中包含了数百个适用于 Raku 和 zef 的模块列表。

要获得 zef 的帮助, 请使用 -h 命令行选项运行它。

zef -h

让我们来探讨一下最有用的命令。

7.4.1. 安装模块

要安装一个模块, 请使用 install 命令, 并给出模块名称, 例如:

$ zef install XML

在安装一个模块之前, 你可以通过要求 Raku 6用 -M 命令行选项加载模块来快速检查你是否已经安装了它:

$ perl6 -M XML -e1

如果没有这样的模块, 你会收到一条错误消息, 列出 Raku 在试图找到该模块时扫描的文件夹。

===SORRY!===
   Could not find XML at line 1 in:
   /Users/ash/.perl6
   /Applications/Rakudo/share/perl6/site
   /Applications/Rakudo/share/perl6/vendor
   /Applications/Rakudo/share/perl6

那么, 我们就来安装吧。如果一切顺利, 模块就会安装完毕, 你就可以马上开始使用了。安装过程的输出可能看起来像下面这样。

$ zef install XML
===> Searching for: XML
===> Fetching: XML
===> Testing: XML:ver('0.0.2'):auth('Timothy Totten')
t/comments.t .......... ok
t/emitter.t ........... ok
t/example.t ........... ok
t/make.t .............. ok
t/namespaces.t ........ ok
t/open-xml.t .......... ok
t/parser.t ............ ok
t/preamble.t .......... ok
t/proxies.t ........... ok
t/query-methods.t ..... ok
t/query-positional.t .. ok
All tests successful.
Files=11, Tests=127, 9 wallclock secs
Result: PASS
===> Testing [OK] for XML:ver('0.0.2'):auth('Timothy Totten')
===> Installing: XML:ver('0.0.2'):auth('Timothy Totten')

在这个例子中, 我们安装了 XML 模块, 它不需要任何依赖关系。输出中显示了安装过程的不同阶段—​首先, zef 寻找分布式文件的位置, 然后下载, 测试, 最后安装到正确的位置。

zef 的任务是在互联网上找到分布式文件的位置, 然后下载并解压。该实用程序可以理解几种格式, 下面从文档中摘录的内容就很好地证明了这一点。

首先, 可以通过模块的名称来搜索, 也可以通过作者或版本号来搜索。

$ zef install CSV::Parser
$ zef install "CSV::Parser:auth<tony-o>:ver<0.1.2>"
$ zef install "CSV::Parser:ver('0.1.2')"

那么, 可以通过本地路径来安装模块:

$ zef install ./Perl6-Net--HTTP

如果 zef 不能推断出模块的 URL(可能会发生, 例如, 如果模块没有被列在 modules.raku.org 上), 那么可以显式地指定 URL。

$ zef -v install git://github.com/ugexe/zef.git
$ zef -v install https://github.com/ugexe/zef/archive/master.tar.gz
$ zef -v install https://github.com/ugexe/zef.git@v0.1.22

请注意, zef 会显示它正在安装的模块的全部细节, 以及它的版本和作者。

===> Installing: XML:ver('0.0.2'):auth('Timothy Totten')

其他模块可能需要依赖, 而 zef 会尽量满足它们。例如, 安装 XML::XPath 会导致安装一些其他模块。

$ zef install XML::XPath
===> Searching for: XML::XPath
===> Searching for missing dependencies: Test::META
===> Searching for missing dependencies: META6
===> Searching for missing dependencies: JSON::Class
===> Searching for missing dependencies: JSON::Marshal, JSON::Unmarshal
===> Searching for missing dependencies: JSON::Name
===> Fetching: XML::XPath
===> Fetching: Test::META
===> Fetching: META6
===> Fetching: JSON::Class
===> Fetching: JSON::Unmarshal
===> Fetching: JSON::Marshal
===> Fetching: JSON::Name
...

install 命令也可以接受完全限定的模块名称, 所以如果你需要安装某个版本, 就说清楚。

$ zef install "XML:ver('0.0.2'):auth('Timothy Totten')"

zef 还支持从指定的 URL 或文件中安装。

7.4.2. 搜索模块

在安装模块之前, 最好先看看还有哪些其他模块存在, 或者出现了哪些其他版本的模块。请使用 search 命令:

$ zef search Time
===> Found 14 results

然后是一个表格, 显示所有找到的模块及其版本和简要说明。

7.4.3. 卸载模块

要卸载模块, 请使用 uninstall 命令:

$ zef uninstall XML
===> Uninstalled from /Applications/Rakudo/share/perl6/site XML:ver('0.0.2'):auth('Timothy Totten')

s如果一个模块是和它的依赖项一起安装的, 那么它们不会被移除。例如, 让我们尝试卸载 XML::XPath 模块, 我们在本章前面的《安装模块》一节中安装了这个模块。

$ zef uninstall XML::XPath
===> Uninstalled from /Applications/Rakudo/share/perl6/site XML::XPath:ver('0.9.0')

仅此而已。其他的依赖模块, 比如说 JSON:::Class, 还在那里。

$ perl6 -MJSON::Class -e'say 1'
1

7.4.4. zef 命令摘要

zef 是 Raku 的默认模块管理器。下表总结了它提供的命令。

命令

描述

install

按名称或路径安装特定模块

uninstall

卸载指定的发行版

test

在给定模块的路径上运行测试

fetch

获取和提取模块的源码

build

在给定模块的路径中运行 Build.pm。

look

Fetch, 然后在模块的路径中进行 shelling。

update

更新版本库的软件包索引

search

显示给定条件的可能的发行版候选列表

info

显示详细的发行版信息

list

列出已知的现有发行版情况

list --installed

列出已安装的发行版

rdepends

根据给定的模块直接列出所有的发行版情况

locate

查询已安装的模块信息

smoke

在现有模块上运行 smoke 测试

关于如何使用 zef 的更多信息可以在 github.com/ugexe/zef 项目页面上找到。

7.4.5. Rakudo 如何存储模块

Raku 中的模块由三个参数来引用:模块名、作者名和模块版本。传统上, 在 Perl 5 中, 模块名是直接映射到文件系统中的, 但在Raku 中, 我们需要处理三个维度。在这一节中, 我们将看一看 Rakudo 和 zef 存储模块的目录, 并跟踪模块的参数信息。

让我们来看看 Rakudo 是如何在文件系统中保存模块的, 以 XML::XPath 模块为例。

在 Rakudo 的安装目录中(在 macOS 上是 /Applications/Rakudo), 你会发现以下四个目录。

bin
include
lib
share

模块位于 share 目录的树内。每个模块可能至少有三个文件。首先, 在 share/perl6/site/dist 目录下有一个 JSON 文件, 其中包含了模块和其他模块的描述, 这些模块和其他模块一起安装在同一个发行版中。这些文件的名称是一些基于哈希的标识符。对于我电脑上的 XML::XPath 版本, 这个是 1DB52FD58FC401775EFFF9619F334A566BAA495F。

我们来看看里面的情况。这个文件比较大, 所以我们在这里就不完全复制, 只限于最明显的几行。

{
  "id" : "1DB52FD58FC401775EFFF9619F334A566BAA495F",
  "name" : "XML::XPath",
  "files" : { },
  "api" : null,
  "support" : { },
  "source-url" : "git://github.com/ufobat/p6-XML-XPath.git", "resources" : [ ],
  "build-depends" : [ ],
  "auth" : null,
  "provides" : {
    "XML::XPath::ExprOperator::UnaryMinus" : {
        "lib/XML/XPath/ExprOperator/UnaryMinus.pm6" : {
            "cver" : "2017.01",
            "file" : "669A66B0DACE378D3507F21305B3A5AE0030D1E8",
            "time" : null
        }
    },
    "XML::XPath::ExprOperator::Div" : {
        "lib/XML/XPath/ExprOperator/Div.pm6" : {
            "cver" : "2017.01",
            "file" : "3D8EAACC880CA211F6E2D99C3AC0F2B0F64BA267",
            "time" : null
        }
    },
...
  "Str" : "XML::XPath:ver<0.9.0>:auth<>:api<>",
  "depends" : [
    "XML",
    "Test::META"
   ],
  "license" : null,
  "ver" : "0.9.0",
  "description" : "XPath perl6 library",
  "test-depends" : [ ],
  "identity" : "XML::XPath:ver('0.9.0')"
}

正如你所看到的, 它包含了一些关于模块的基本元信息, 比如名称和源地址。在文件的最后, 我们可以看到依赖关系列表- XML 和 Test::META。提供块列出了与之相连的其他模块。例如, 我们看到 XML::XPath::ExprOperator::UnaryMinus 模块位于名为 669A66B0DACE378D3507F21305B3A5AE0030D1E8 的文件中。同样, 我们可以找到关于 XML::XPath 文件本身的数据。

"XML::XPath" : {
    "lib/XML/XPath.pm6" : {
      "cver" : "2017.01",
      "file" : "E7C0BBCF69DD5CBC21DBD7027015325F83FADE11",
      "time" : null
    }
}

的确, 在 share/perl6/site/source/E7C0BBCF69DD5CBC21DBD7027015325F83FADE11 这个位置, 我们看到了 XML:::XPath 的源代码。

use XML;
use XML::XPath::Actions;
use XML::XPath::Grammar;
use XML::XPath::Utils;

class XML::XPath {
    has $.document;
    has %.registered-namespaces is rw;

    submethod BUILD(:$file, :$xml, :$document) {
           my $doc;
           if $document {
               $doc = $document;
           }
    ...

但这还不是全部。和源文件一起, Rakudo 还保留了一个预编译的模块版本。它存储在共享 perl6/site/precomp 目录内的一个子目录下的同名文件中。

对于开发者来说, 没有必要详细了解上述结构。如果你使用 Raku 的包管理器, 它将会处理好内部的所有细节。

7.5. 总结

在本章中, 我们经历了使用模块工作的主要步骤。首先, 我们看到了如何创建一个模块, 以及如何告诉编译器在哪里可以找到它。然后, 我们研究了加载模块和导入模块名称的不同方法。最后, 我们来到了 Rakudo 专用的模块管理工具 zef, 用它来安装和卸载模块, 并考察了 Rakudo 用于保存模块到磁盘上的内部存储。

在下一章, 第八章, 面向对象的程序设计, 我们将讲到类, 类与模块有一些共同的元素, 即代码如何在单独的文件中定位。

8. 面向对象编程

面向对象编程(OOP)是现代编程语言最需要的功能之一。在 Raku 中,Perl 早期版本 OOP 的工作方式已经被完全重新设计了。在本章中,我们将学习如何在 Raku 中创建类和处理对象。这会涉及到以下内容:

  • 创建类

  • 类(读写、公共、私有、状态属性)

  • 类方法(公共方法和私有方法)

  • 继承(继承自类、重写方法、子方法、多重继承)

  • 角色

  • 内省

  • 后缀方法运算符

8.1. 创建类

在 Raku 中,类是语言设计中不可或缺的一部分。要创建一个类,请使用 class 关键字。类的主体, 包含了类的定义, 它被放在一对花括号之间。

让我们从创建一个使用类的程序开始。我们将从一个空的 House 类开始:

class House { }

类名以大写字母开头是一种很好的做法。这也符合 Raku 中的惯例。它的类型也是以同样的方式调用的,对比一下 - IntStrArray 等。

前面的代码声明了一个类 House 并定义了它的主体。目前,主体是空的,但你已经可以使用这个定义来创建该类的实例(或者,使用其他术语,创建该类型的对象)。

在某种程度上,类型这两个术语是可以互换的。例如,你可以把字符串当作 Str 类的实例,或只是 Str 对象,或者说是 Str 类型的变量。

所以,创建一个新房子:

my $house = House.new;

House 类名上调用的 new 方法是类构造函数。它由 Raku 为你的每一个新类创建的。如果你愿意,你可以在 new 的调用中附加一对空括号:

my $house = House.new();

$house 变量是标量变量。它承载着一个 House 类型的对象。为了确认这一点,在变量上调用 WHAT 方法:

say $house.WHAT;

它打印出 (House)。

有了一个类,我们可以创建另一个实例,比如 $house2:

my $house2 = House.new;
say $house.WHAT; # (House)

让我们创造一条空房子的街道:

my @street;
push @street, House.new for 1..5;

这里,@street 数组获得了 5 个 House 类型的对象。让我们通过在每个数组元素上调用 WHAT 方法来检查它:

say $_.WHAT for @street;

正如我们所看到的那样,为了托管对象,我们使用了相同类型的变量来托管对象,对单个对象使用标量,对多个集合使用数组。对于 Raku,$house 是一个容器,你可以用任何类型的数据来填充,不管是 Int 还是 House 对象。

我们刚才创建的两个 house 实例是不同的对象。可以使用 === 运算符来检查对象是否是同一个对象:

say $house === $house2; # False

另一方面,两个 house 的内容是相同的。eqv 运算符检查对象的内部结构:

say $house eqv $house2; # True

我们的下一步要做的就是用有用的元素来填充 House 类。

8.2. 使用属性

在上一节中,我们创建了 House 类,它不包含任何内容。真实的房子确实有一些参数,比如地址、面积(平方米)、房间数、高度等。所有这些都可以用 Raku 来表达。

让我们开始在类中添加细节。我们从最简单的元素, 房间数开始。这个参数可以通过附加在 House 类型对象上的整数值来描述。在 Raku 中,这样的数据元素被称为属性,并使用 has 关键字声明,如下例所示:

class House {
    $.rooms;
}

这里做了什么? House 类获得了属性 $.rooms。这是一个属于类对象的标量值。注意美元符号之后的点。这是一个描述属性的访问级别的 twigil; 我们将在本节后面讨论它。

现在,尝试创建一个房子,就像我们在上一节中所做的那样:

my $house = House.new;

这一次,这个对象与我们在《创建类》的部分不同。它包含了一个属性-$.rooms。这个属性可以使用点语法来读取,就像在 Java 或其他语言中访问对象属性一样。

say $house.rooms;

因为我们没有将这个属性设置为任何值,所以程序告诉我们这个属性是空的:

(Any)

为了给属性设置一个初始值,可以使用构造函数参数。不要像 House.new 那样创建一个实例,而是将命名参数传递给它。

my $house = House.new(rooms => 2);

如果你现在打印 $.rooms 的值,它将返回值 2:

say $house.rooms; # 2

房屋在房间数量上可能会有所不同。让我们在程序中反映这一点:

my $house = House.new(rooms => 2);
say $house.rooms;  # 2

my $house2 = House.new(rooms => 4);
say $house2.rooms; # 4

变量 $house$house2$.rooms 属性被初始化为不同的值,并与对象一起保留。

现在,这两个房子不仅是不同的对象,而且因为内容不同,所以也不相等。

say $house === $house2; # False
say $house eqv $house2; # False

我们是通过访问 $.house 属性来打印房间的数量。对于给定对象 $house,获取其属性 $.rooms 的值的语法是 $house.rooms

为了设置该值,我们使用一个命名参数传递给构造函数-House.new(rooms ⇒ 2)

不要忘记,Raku 不允许在方法名和开口的括号之间留白。

8.2.1. 读写属性

$.rooms 属性的值是在创建对象 $house 时设置的。如果我们以后需要改变它的值怎么办?当然,在真实的房屋中,房间数发生变化的情况很少见,但这种情况还是有可能发生,比如说在新房主拿下房子后,改变了房子的平面图。

天真地试图设定一个新的值就会失败:

class House {
    has $.rooms ;
}

my $house = House.new(rooms => 2);
$house.rooms = 3; # Fails here
say $house.rooms;

在赋值时,发生运行时错误,程序被终止:

Cannot modify an immutable Int in block <unit> at house.pl line 7

$.room 属性是不可变的,无法更改。这是类属性的默认行为。

要使属性是可变的,换句话说,要允许写到属性,必须用 rw trait 声明。

class House {
    has $.rooms is rw;
}

现在,修改是允许的,而且没有发生异常:

my $house = House.new(rooms => 2);
say $house.rooms; # 2

$house.rooms = 3;
say $house.rooms; # 3

读写属性可以作为左值与其他运算符一起使用,例如:

my $house2 = House.new(rooms => 3);
$house2.rooms++;
say $house2.rooms; # 4

这样的属性可以被任何能访问 $house 的代码所改变。在很多情况下,对对象内部属性的访问必须受到限制。在下一节中,我们将讨论在 Raku 中如何做到这一点。

在我们进一步学习类的方法之前,让我们用更多的字段来扩展类:

class House {
    has $.rooms   is rw;
    has $.area    is rw;
    has $.height  is rw;
    has $.address is rw;
}

现在,该实例包含四个数据属性,可以在构造函数中初始化:

my $house = House.new(
    rooms   => 2,
    area    => 100,
    height  => 4,
    address => '22, rue du Grenier-Saint-Lazare, 75003, Paris, France',
);

在最后一对参数后面加上一个逗号是很好的。这不是必须的,但就像创建散列一样,它可以使添加新元素的过程更容易。

现在,所有的属性都被初始化了。如果你打印对象,默认的字符串化机制会列出所有的属性及其值。

say $house;
House.new(rooms => 2, area => 100, height => 4, address => "22, rue duGrenier-Saint-Lazare, 75003, Paris, France")

在下一节中,我们将讨论缩小可以存储在属性中的数据类型。

8.2.2. 带类型的属性

我们继续讨论 House 类的内容,现在要考虑的是可以存储在属性中的值。

在前面的例子中,我们把它们设置成一些有意义的值,但如果你放了一些无意义的数据,语言是不会抵挡的;比如,我们不小心把地址放到了 $.area 属性中,就会有一个地址。

my $house = House.new(
    rooms   => 2,
    area    => 'Calle Velázquez 57, 28001 Madrid, Spain', height => 4.0,
    address => 100.0,
);

想象一下,现在你要计算出一个房间的平均面积:

say $house.area / $house.rooms;

这是不可能的,因为 $.area 属性包含一个字符串,而不是一个数字。编译器会报告一个运行时错误:

Cannot convert string to number: base-10 number must begin with valid digits or '.'

为了防止在类属性中存储错误类型的数据,Raku 提供了一种类型化属性的机制。这个想法与类型化变量相同,在下面的代码片段中可以看到。

class House {
    has Int  $.rooms   is rw;
    has Real $.area    is rw;
    has Real $.height  is rw;
    has Str  $.address is rw;
}

现在,House 类的每个属性都是带有类型的。编译器知道,房间数是整数,面积和高度是浮点数,地址是字符串。试图存储错误的数据会被编译器阻止:

Type check failed in assignment to $!area; expected Rat but got Str ("Calle Velázquez 57, ...)

异常也会在运行时发生,但你不能对对象做什么,因为它甚至不会被创建。

这不是你可以对类属性施加的所有限制。以 $.room 属性为例,它可以只是一个整数,但也应该是正整数。它可以是一个整数,但也应该是一个正整数。Raku 允许你使用 where 关键字指定子集的值。

class House {
    has Int  $.rooms   is rw where {$_ > 0};
    has Real $.area    is rw where {$_ > 0};
    has Real $.height  is rw where {$_ > 3};
    has Str  $.address is rw where {$_ ne ''};
}

现在所有的属性都使用了 Raku 内置的子集类型。$.rooms$.area 属性的值都应该是正数,房子的高度必须至少是3米,地址不能是空字符串。

现在,如果构造函数中传递的值不符合条件,就会出现运行时异常。例如,如果地址是空的:

Type check failed in assignment to $!address; expected <anon> but got Str ("")

现在让我们仔细看看 $.address 属性。

8.2.3. 使用其他类作为数据类型

在前面的代码中,房屋的地址是一个自由文本字符串。在更复杂的应用程序中,最好将地址保存为一组不同的字段 - 国家,城镇,邮政编码,街道名称和门牌号码。

其中一个可能的实现是在 House 类中添加更多的属性:

class House {
    has Int  $.rooms      is rw where {$_ > 0};
    has Real $.area       is rw where {$_ > 0};
    has Real $.height     is rw where {$_ > 3};

    has Int  $.husenumber is rw where {$_ ne ''};
    has Int  $.zipcode    is rw where {$_ > 0};
    has Str  $.country    is rw where {$_ ne ''};
    has Str  $.town       is rw where {$_ ne ''};
    has Str  $.street     is rw where {$_ ne ''};
}

这个方法很管用,但不是最好的解决方案。地址的详细信息可以保存在一个单独的属性中。要定义地址的内部结构,我们引入另一个类:

 class Address {
    has Str $.housenumber is rw where {$_ ne ''};
    has Str $.zipcode     is rw where {$_ ne ''};
    has Str $.country     is rw where {$_ ne ''};
    has Str $.town        is rw where {$_ ne ''};
    has Str $.street      is rw where {$_ ne ''};
}

属性的类型被有意地选择为字符串, 以允许门牌号, 如 3A 和以 0 开头或包含字母或空格的邮政编码,如伦敦的 WC2B 4PH。有了这个,House 类可以包含 Address 类型的属性:

class House {
    has Int     $.rooms   is rw where {$_ > 0};
    has Real    $.area    is rw where {$_ > 0};
    has Real    $.height  is rw where {$_ > 3};
    has Address $.address is rw;
}

最后,让我们创建一个 House 对象:

my $house = House.new(
       rooms   => 2,
       height  => 4,
       area    => 100,
       address => Address.new(
           housenumber => '31A',
           zipcode  => '00194',
           country  => 'Italy',
           town     => 'Rome',
           street   => 'Via Dante',
       ),
);

代表地址的对象是在需要 House 对象的 $.address 属性的时候, 用自己的构造函数 Address.new 创建的。

关于类属性, 我们需要学习的东西还有很多,但在继续学习之前,我们必须先介绍一下类方法。所以,下一节是关于类方法的介绍,之后我们将在《更多关于属性》一节中回到属性的介绍。

8.3. 使用方法

在 OOP 中,对象不仅要保存数据,还要执行某些操作。在 Raku 中,数据保存在属性中, 而动作是通过方法来完成的。

方法类似于普通的 subs,但定义在类的内部。它们可以使用对象属性中的数据来进行工作。

继续上一节中的 Address 类。地址的详细信息被保存在单独的属性中。这对于创建一个干净、结构化的表示方式是很好的,但是在某些情况下,我们需要将所有的数据一起使用。例如,让我们打印一个格式化的地址,放在信封上:

class Address {
    has Str $.housenumber;
    has Str $.zipcode;
    has Str $.country;
    has Str $.town;
    has Str $.street;
}

my $address = Address.new(
    housenumber => '10',
    zipcode => '1020',
    country => 'Country',
    town    => 'Town',
    street  => 'Street',
);

print qq:to/ADDRESS/;
    {$address.street} {$address.housenumber}
    {$address.zipcode} {$address.town}
    {$address.country}
ADDRESS

Address 类包含一些数据元素,在字符串插值中使用这些元素来显示他们的值。注意,你需要使用花括号,这样 Raku 就可以理解点是名称的一部分,后面是属性名。这个程序的结果就是打印成三行的地址:

Street 10
1020 Town
Country

我们的任务完成了,但是试想一下给另一个地址打印另一个信封标签。为了避免代码重复,我们必须将格式化字符串放到一个子例程中。为了让它更完美,地址的格式必须成为 Address 类的一部分。让我们把 full_address 方法添加到类中:

class Address {
    has Str $.housenumber;
    has Str $.zipcode;
    has Str $.country;
    has Str $.town;
    has Str $.street;
    method full_address() {
        return qq:to/ADDRESS/;
            $.street $.housenumber
            $.zipcode $.town
            $.country
        ADDRESS
    }
}

在方法内部,可以使用 $.street$.zipcode 等名称来访问对象属性, 以此类推。

让我们把重点放在这个重要的方面。在类代码之外,访问属性需要一个对象。例如,如果我们通过调用新的构造函数创建一个对象:

class Address {
    has $.street is rw;
}
my $address = Address.new;

然后,为了访问 $address 属性的字段,我们使用点语法:

$address.street = 'Ramblas';
say $address.street;

正如我们之前已经看到的,另一个地址将保留其自己的 $.street 属性值:

my $address2 = Address.new;
$address2.street = 'Calle de Alcalá';

say $address2.street; # Calle de Alcalá
say $address.street;  # Ramblas

现在,让我们对方法中的属性进行处理。我们创建了两个方法,一个用于设置 $.street 的新值,另一个用于读取它:

class Address {
    has $.street is rw;

    method get_street() {
        return $.street;
    }

    method set_street($new_street) {
        $.street = $new_street;
    }
}

所以,这个类现在提供了 get_street()set_street() 两个方法,然后我们马上使用它们:

my $address = Address.new;
$address.set_street('Ramblas');

my $address2 = Address.new;
$address2.set_street('Calle de Alcalá');

say $address.get_street();  # Ramblas
say $address2.get_street(); # Calle de Alcalá

在方法内部,该属性被称为 $.street。在 $address$address2 实例中, 设置和获取属性值的代码是一样的。

在类方法内部,我们没有看到任何提及 $address$address2 的代码,但 Raku 知道该使用哪个对象,因为这些方法都是为这些对象调用的。当编译器看到像 $address.get_street() 这样的构造时,该方法会接收到一个指向对象的指针。我们没有明确地看到它,但你可以用下面的方法来重写它,这类似于对常规子程序的调用:

set_street($address: 'New value');
say get_street($address:);

这个对象现在被作为第一个参数传递给 set_streetget_street 例程。我们要调用方法的对象被称为 invocant。我们在第四章《操作符的使用》中讨论了 : 操作符。在传统的语法中,例如 `$address.get_street(),调用对象仍然被传递给方法,但不是显式的。

8.3.1. 私有方法

要隐藏属性,你需要将 twigil 更改为 .!。同样,可以用同样的感叹号将方法设为私有来隐藏方法。私有方法不能在对象上调用,它们只能从类的其他方法中使用。请看下面的例子:

class X {
  method !a() {
    say 'Private method';
  }

  method b() {
    say 'Public method';
    self!a();
  }
}

my $x = X.new();

这个类有两个方法,ab。其中第一个方法被声明为私有方法, 所以试图将其调用为 $x.a() 会导致运行时错误:

No such method 'a' for invocant of type 'X'

b 方法是公共的, 因此可以被调用:

$x.b();

在内部,这个方法在 self 关键字 - self!a() 的帮助下调用私有方法 aself 指向当前正在处理的对象,因此它与外部程序中的 $x 是一样的。你也可以使用 self 来调用公共方法-self.b(),但它是多余的,应用只用于解决名称冲突。

现在,在我们讲完了类方法,让我们回到属性,看看在与方法有关的地方,我们可以了解到哪些新的信息。

8.4. 更多关于属性的信息

本章我们从类属性这一节开始,但属性的一些特性与方法密切相关,这也是我们中断了一下,现在可以继续讲属性的原因。

8.4.1. 公共和私有属性

在前面的代码示例中,类属性是用点符号声明的-$.rooms$.street。这个位置上的点表示该属性是公共的, 可以被不属于该类的代码访问。

还有另一个 twigil,!,表示属性是私有的。这意味着读取或更改属性值的唯一方法是通过方法访问它。

让我们回到 House 类, 把它的方法的所有 twigils 都改成 !:

class House {
    has $!rooms;
    has $!area;
    has $!height;
}

创建房屋的方法和以前一样:

my $house = House.new(
    rooms  => 2,
    area => 100,
    height => 3,
);

但是,现在无法读取属性的值。试图获取 $house.rooms 的值会失败:

No such method 'rooms' for invocant of type 'House'

$house!rooms 也不例外:

Private method call to rooms must be fully qualified with the package containing the method

出现这种情况是因为 !twigil 将属性标记为私有属性,不能从类外访问。不过我们可以从方法的代码中处理它们。让我们创建 get_roomsset_rooms 方法来获取和设置房间数:

class House {
    has $!rooms;
    has $!area;
    has $!height;
    method get_rooms() {
        return $!rooms;
    }

    method set_rooms($new_value) {
        $!rooms = $new_value;
    }
}

再次,创建一个具有一些初始值的新房子:

my $house = House.new(
    rooms => 2,
    area => 100,
    height => 3,
);

现在用新的方法, 先修改房间数,然后再打印:

$house.set_rooms(3);
say $house.get_rooms(); # 3

在面向对象编程理论中,隐藏对象属性被称为封装。Raku 为此使用了 ! twigil。这个属性就变成了一个私有属性。

8.4.2. 自动的 setter 和 getter 方法

其实, 我们在本章前面的点语法就是我们在本章中使用的设置和读取属性的点语法,是使用 Raku 为我们创建的 getter 和 setter 方法的一种语法上的欺骗。让我们一步步来研究它。

首先,用 $. 创建一个简单的类属性:

class X {
    has $.y;
}

my $x = X.new(y => 1);
say $x.y; # 1

$x 对象有一个名为 y 的属性。这是一个公共属性(因为.twigil) 并且可以从外部访问。在幕后,Raku 创建了一个同名的方法 y,它返回 $.y 的值。所以,$x.y 实际上是调用了那个不可见的方法。

对比一下这个例子和它的修改,在这个例子中,方法 y 被显式定义了:

class X {
    has $.y;

    method y() {
        return 2;
    }
}

my $x = X.new(y => 1);
say $x.y; # 2

调用代码 $x.y 没有改变,但现在打印的值却不一样了,因为我们的方法重新定义了与编译器创建的同名的方法。

现在,让我们通过将值赋给 $x.y 来设置值。当然,这个属性必须用 is rw trait 声明。

class X {
    has $.y is rw;
}

my $x = X.new(y => 1);
$x.y = 2;
say $x.y; # 2

这次,编译器创建一个 setter 方法,当我们给 $.y 属性分配一个新的值时,这个方法就会被调用。我们可以通过创建一对 multi 方法来模仿整个画面。multi 方法是指共享相同名称但有不同签名的方法。在其他语言中,这个概念也被称为函数重载或方法重载。它们类似于我们在第6章《子例程》中介绍的 multi 子例程。

class X {
    has $.y is rw;

    multi method y() {
        return $!y;
    }

    multi method y($value) {
        $!y = $value;
    }
}

my $x = X.new(y => 1);
$x.y(2);
say $x.y(); # 2

在这段代码中,我们只使用显式 multi 方法 y()y($value) 来更改 $.y 属性的值。

注意,在这些方法内部,$.y 属性使用了 ! twigil。当你使用 ! twigil 设置或获取属性时,Raku 会直接访问它的属性,而不使用自动生成的 getter 和 setter 方法。所以,即使你有一个公共属性 $.y,用圆点声明,最好的做法是在类方法中直接访问 $!y

最后,让我们再来看一下访问公共变量和 is rw trait 之间的区别。在下面的代码中,$.x 属性是公共的,但没有声明为 is rw:

class C {
    has $.x;

    method set() {
        $!x = 4;
    }
}

虽然不能从主程序中设置新值,但还是可以从方法中修改属性:

my $c = C.new;
$c.set();
say $c; # 4

这个属性是公共的,所以编译器为它生成一个 getter 方法,但不是读写属性,所以没有创建 setter。

8.4.3. 类属性

假设现在我们正在建造一条街道,并想给任何新对象赋予门牌号。为了简单起见,让我们暂时删除除了 House 中的 $.addressAddress 中的 $.housenumber 以外的所有属性。

class Address {
    has Int $.housenumber is rw;
}

class House {
    has Address $.address is rw;
}

下一步是一个构建 house 的循环, 将房屋保存在 @street 数组中:

my @street;
   for 1..10 {
       push @street, House.new(
           address => Address.new(
               housenumber => @street.elems + 1
           )
) }

为了增加门牌号,我们按以下方式使用 @street 的大小:

@street.elems + 1

这样可以确保每个新的房子都会得到一个数字,这个数字大于所有现有房子的数量。要看到这一点,遍历数组以打印数字:

say $_.address.housenumber for @street;

当然,用一个外部计数器来记录被创建的房子并不是什么大问题,但有一个备用的方法-- Address 类本身就可以告诉我们有多少个对象被创建。

我们将使用所谓的类属性,这些属性属于类,而不属于类的实例。这意味着所有的对象都共享同一个类属性。这与常规属性不同—​每个对象都会收到自己的容器。在其他编程语言中,类属性也被称为静态数据成员。

所以,这里是这样的代码,Address 类配备了类属性 $last_assigned_number,这是一个在类内部用 my 关键字声明的整数值。

class Address {
    my Int $last_assigned_number = 0;
    has Int $.housenumber is rw;

    method assign_next() {
        $last_assigned_number++;
        $.housenumber = $last_assigned_number;
    }
}

class House {
    has Address $.address is rw;
}

创建 assign_next 方法以执行必要的操作以增加值 class 属性 $last_assigned_number 并将其分配给实例属性 $.housenumber。 让我们修改用于生成房屋数组的循环以使用 assign_next 方法:

assign_next 方法的创建是为了使类属性 $last_assigned_number 的值递增,并将其赋值给实例属性 $.houseenumber

让我们修改生成房屋数组的循环,使用 assign_next 方法。

my @street;
for 1..10 {
    my $house = House.new(
        address => Address.new()
    );
    $house.address.assign_next();
    push @street, $house;
}

say $_.address.housenumber for @street;

该程序打印出了从 1 到 10 的 10 个数字。如你所见,我们需要做的就是在 Address 对象上调用方法。现在所有的计算都是由方法来完成的,而不是使用类的代码。

8.5. 类方法

在前面的例子中,我们使用类属性来保存类的所有实例之间共享的数据。我们使用的是一个方法,它是用这个属性来工作的。

类属性的概念也可以投射到方法上。在 Raku 中,类可以包含类方法,这些方法是使用 sub 关键字定义的。这样的方法可以访问所有的类属性,但不接受对该对象的隐式自引用。考虑一个有两个类的例子:

class Address {
    my Int $last_assigned_number = 0;
    has Int $.housenumber is rw;

    our sub get_next() {
        return ++$last_assigned_number;
    }
}

class House {
    has Address $.address is rw;
}

get_next 类方法也是使用 our 关键字声明的。这是必要的,因为我们想从外部代码中访问这个方法。默认情况下,作用域将只限于这个类。

现在,进行循环设计的下一次迭代以生成街道:

my @street;
for 1..10 {
    my $house = House.new(
        address => Address.new()
    );
    $house.address.housenumber = Address::get_next();
    push @street, $house;
}

say $_.address.housenumber for @street;

这里的主要区别在于我们分配门牌号的方式。get_next 方法不能在 Address 类的实例上调用,因此编译器不接受表达式 $house.address.get_next。对称的是,你不能在方法内部访问自变量。

get_next 方法必须使用类名-Address::get_next() 来调用。它改变了 $last_assigned_number 计数器的值。计数器也是一个不属于 Address 类的任何特定实例的变量。实际上,即使在创建任何对象之前,Address:::get_next() 方法也可以被调用:

say Address::get_next(); # 1
say Address::get_next(); # 2

现在是时候详细了解一下 Raku 中的面向对象设施了。

8.6. 继承

面向对象编程的下一个特性是继承。在这一节中,我们讨论继承和Raku 中的相关主题。

8.6.1. 从类中继承

在 OOP 中继承意味着创建一个新类,它扩展了另一个已经存在的类。 最简单的继承形式是两个类的子类-父类对继承。

在前面的章节中,我们创建了 House 类。让我们用它作为另一个概念的父类。我们将创建一个 ModernHouse 类,它是一个带有太阳能屋顶板的 House。我们在本章前面创建的裸的 House,包含四个属性—​房间数、面积、高度和地址。在我们前面的例子中,地址属性是一个 Address 对象,但在这一节中,我们将保持简单,假设地址是一个字符串:

class House {
    has $.rooms;
    has $.area;
    has $.height;
    has $.address;
}

对于 ModernHouse 来说,另一个属性, 就是太阳能电池板所产生的功率:

class ModernHouse is House {
    has $.power;
}

现在的 ModernHouse 有 5 个属性,其中 4 个来自于 House 类, 还有一个是 ModernHouse 定义中添加的属性。从用户的角度来看,ModernHouse 类的所有属性都是平等的,你初始化并使用这些属性, 就像下面的例子中清楚地看到的那样, 它们都是 ModernHouse 类中定义的属性:

my $house = ModernHouse.new(
    rooms   => 5,
    area    => 150,
    height  => 5,
    address => '...',
    power   => 200,
);

say $house.area;  # 100
say $house.power; # 200

在不同的编程语言中,对参与继承的类有不同的术语。House 类可以被称为基类、父类或超类。ModernHouse 类既可以是派生类,也可以是孩子类或者是子类。

在 Raku 的文档中,使用了父类和子类的术语。

8.6.2. 使用子类实例作为基类的对象

ModernHouse 类型的对象也是 House。考虑一个函数 f, 它接收一个参数并认为它是 House 类型的对象。函数的签名对该参数施加了一个限制:

sub f(House $h) {
    say "There are {$h.rooms} rooms in this house.";
}

现在,让我们创建两个不同的房子, 用它们来调用函数:

my $house = House.new(rooms => 2);
my $modern_house = ModernHouse.new(rooms => 3, power => 100);

f($house);
f($modern_house);

这段代码可以完美地工作, 并打印出预期的字符串:

There are 2 rooms in this house.
There are 3 rooms in this house.

这个函数只使用了在父类中定义的属性,因此可以很容易地处理子类的对象,因为它也包含了所需的属性。

如果我们反其道而行之-创建一个期望子类对象的函数,并传递一个基类的对象给它呢?

sub f2(ModernHouse $h) {
    say "This house generates {$h.power} kWh.";
}

f2($modern_house);

到目前为止,它起作用,因为对象的类型没有区别:

This house generates 100 kWh.

f2 函数正在访问 $.power 属性,在 House 类中没有这个属性。如果你调用该函数并将 $house 变量传递给它,会出现错误。

Type check failed in binding to parameter '$h'; expected ModernHouse but got House (House.new(rooms => 2,...)

考虑另一个使用类型化变量的例子。在下一个例子中,@street 数组是一个 House 类型的对象的数组:

my House @street;
push @street, ModernHouse.new(rooms => 3, power => 100);
push @street, House.new(rooms => 2);

这两个房子都成功地被添加到了 @street 数组中,因为它们都与 House 类型兼容。如果循环遍历,就可以读取 House 类的属性,这些属性也会在 ModernHouse 类型的对象中出现。

f($_) for @street;

如果对 @street 数组进行更严格的声明,即如果我们使用 ModernHouse 类型作为数组元素的类型,那么编译器将不接受 House 类型的对象:

my ModernHouse @street;
push @street, ModernHouse.new(rooms => 3, power => 100);
# push @street, House.new(rooms => 2);

如果取消注释最后一行,程序会类型检查错误停止:

Type check failed in assignment to @street; expected ModernHouse but got House (House.new(rooms => 2,...)

8.6.3. 重写方法

在很多情况下,子类的方法应该和父类中的方法有不同的反应。这里我们来谈谈重新定义或重写方法的重要概念。

让我们继续以 HouseModernHouse 为例, 实现计算取暖所需的电费的方法。为了突出主题, 我们简化了类和计算成本的方法,假设成本与房子的面积成正比, 我们将其简化为类和方法。

在这两个类中, 我们定义了一个名为 energy_cost 的方法,每个类的计算方法都是不同的:

class House {
    has $.area;

    method energy_cost() {
        return 0.8 * $!area;
    }
}

class ModernHouse is House {
    has $.power;

    method energy_cost() {
        return 0.3 * $.area;
    }
}

请注意使用面积值的微小区别。在 House 类中,可以用 $!area 语法将其引用为本地属性。而在 ModernHouse 类中,我们必须使用生成的 getter $.area

现在,创建两个实例, 并打印出成本:

my $house = House.new(area => 100);
my $modern_house = ModernHouse.new(area => 100, power => 150);

say $house.energy_cost();        # 80
say $modern_house.energy_cost(); # 30

每个对象都使用了自己的 energy_cost 方法变体, 这并不奇怪。

当我们在同一个集合中保留不同类型的房屋时,这种行为就更加有趣了。在下一个例子中,我们把两个不同的房子放入 @street 数组中:

my House @street;

push @street, House.new(area => 100);
push @street, ModernHouse.new(area => 100, power => 150);

然后,我们对数组进行迭代, 并在循环变量上调用 energy_cost 方法:

say $_.energy_cost() for @street;

程序打印出来的输出与前一个程序完全相同。这意味着我们面对的是多态行为—​每个对象都知道自己属于哪个类,并调用正确的方法。

多态行为可能更加复杂。让我们重新组织一下上一个例子的代码,引入 tariff_coef 方法,这个方法将用于计算成本。

class House {
    has $.area;

    method tariff_coef() {
        return 0.8;
    }

    method energy_cost() {
        return self.tariff_coef() * $!area;
    }
}

class ModernHouse is House {
    has $.power;

    method tariff_coef() {
        return 0.3;
    }
}

现在,energy_cost 方法只在基类中定义了。所以,ModernHouse 类的一个实例将使用该方法。但是,在 energy_cost 方法里面是调用 tarif_coef 方法,Raku 会根据对象的类型找到正确的实现。这在下面的代码中得到了证明。

my $house = House.new(area => 100);
my $modern_house = ModernHouse.new(area => 100, power => 150);

say $house.energy_cost();        # 80
say $modern_house.energy_cost(); # 30

再次,方法解析的行为表明,它的工作原理与预期的一样。

请注意,方法 energy_cost 必须是一个公共方法,否则不会被继承。

8.6.4. 子方法

我们已经看到,一个子类会接收基类定义的所有公共方法。在某些情况下,这并不是我们所希望的。将方法私有化也不一定是一个解决方案,因为你可能想在基类的对象上调用它。

Raku 允许使用所谓的子方法。它们是不继承的。让我们通过一个小例子来了解一下它们。

class Parent {
    method meth() {
        say 'meth()';
    }

    submethod submeth() {
        say 'submeth()';
    }
}

class Child is Parent {
}

现在,创建两个对象:

my $o1 = Parent.new;
my $o2 = Child.new;

在 Parent 类中,可以在该类型的对象上调用两种方法:

$o1.meth(); # meth()
$o1.submeth(); # submeth()

Child 类中,只有 meth 方法可用:

$o2.meth(); #meth()

禁止调用 submeth 的子方法:

No such method 'submeth' for invocant of type 'Child'

8.6.5. 多重继承

在 Raku 中,允许多重继承。多重继承意味着子类是由一个以上的父类派生出来的。请看下面的骨架示例:

class P1 {
    method p1() {
        say 'p1()';
    }
}

class P2 {
    method p2() {
        say 'p2()';
    }
}

class C is P1 is P2 {
    method c() {
        say 'c()';
    }
}

两个父类 P1P2,定义了方法 p1p2。子类 C 是由 P1P2 两个父类派生出来的:

class C is P1 is P2 { ... }

这意味着 C 类的实例接收到了三个类中任何一个类的所有方法:

my $c = C.new;
$c.p1();
$c.p2();
$c.c();

多重继承是一种强大的技术,但它也会给出隐藏的名称冲突。设想一下,在前面的例子中,有另一个类 P,它是 P1 和 P2 的基类:

class P {
    has $!count;
    method get_count() {
        return $!count++;
    }
}

class P1 is P {
    method p1() {
        say 'p1()';
    }
}

class P2 is P {
    method p2() {
        say 'p2()';
    }

    method get_count() {
        return -1;
    }
}

Class C is P1 is P2 {
    method c() {
        say 'c()';
    }
}

P 类和 P2 类都有自己的 get_count 方法的实现。在 P 类中,这个方法使用的是属性,每次调用时返回一个递增的数字,在 P2 类中,返回值总是 -1。

C 类对象从 P1P2 派生出来,间接从 P 类中调用 get_count 方法时,会发生什么情况呢?

my $c = C.new;
say $c.get_count();
say $c.get_count();
say $c.get_count();

一方面,C 类中的 get_count 方法是通过 P1P 类派生出来的。另一方面,同名的方法是通过 P2 派生出来的。Raku 选择从 P2 派生的方法,因为它更接近 C 类,所以程序打印 -1 三次。

如果你想了解更多关于 Raku 在多重继承中解决名称冲突的方法,可以参考 C3 线性化方法解析顺序-https://en.wikipedia.org/wiki/C3_linearization

到此为止,我们已经走过了经典的面向对象编程的主要概念。Raku 还支持角色这个新概念,我们接下来要讲的是角色这个新概念。

8.7. 使用角色添加对象和类

角色是现代 OOP 中的另一种机制。角色就像类的一个外部部分,它被附加到现有的对象或类中,提供一些额外的属性和方法。在一些编程语言中,角色与接口非常接近。

让我们拿一个房子来做一个浮动的房子。为了简单起见,House 类只有一个属性,即房子的面积。Floating 角色有一个属性,可以保存浮动房子的重量,如果房子太重,会下沉,则返回一个布尔值的方法:

class House {
    has $.area is rw;
}

role Floating {
    has $.weight is rw;

    method is_sinking() {
        return $!weight > 500 * $.area;
    }
}

在语法上,创建角色的唯一区别是用关键字 role 来代替关键字 class

从现在开始,有两种方法来应用角色。首先,让我们接收一个已经存在的房子,并对其应用一个角色。在这个例子中,使用构造函数 House.new 创建了房子,并使用 does 关键字附加了一个角色:

my $floating_house = House.new does Floating;
$floating_house.area = 100;
$floating_house.weight = 10_000;

say $floating_house.is_sinking(); # False

正如你所看到的,$.weight 属性和 Floating 角色中的 is_sinking 方法都成为了 $floating_house 对象的可用方法。

该对象得到一个复合类型 House+{Floating}:

say $floating_house.WHAT; # (House+{Floating})

在第二种方法中,先创建一个新的类。FloatingHouse 类是从 House 派生出来的,并导入 Floating 角色。要连接一个角色,同样使用 does 关键字:

class FloatingHouse is House does Floating {
}

my $floating_house = FloatingHouse.new;
$floating_house.area = 100;
$floating_house.weight = 100_000;

say $floating_house.is_sinking(); # True

该程序的行为与前一例相同,但对象的类型不同,不包含任何角色的痕迹:

say $floating_house.WHAT; # (FloatingHouse)

使用角色在某些方面与类继承非常接近。在某些情况下,这两种方法可能同样有效。下面是在继承和角色之间选择的经验法则。

当你可以说 A is B 的时候,你就从 B 继承 A;当你可以说 A does B 的时候,你就应用角色。

例如,狗是一种动物,所以你从 Animal 继承 Dog。但狗会叫,所以你应用了角色 Bark:

class Dog is Animal does Bark { ... }

考虑另一个例子,可以使用上面定义的 Floating 角色。我们是创建一个浮动的餐厅。餐厅也是一个房子,它也是浮动的。所以,层次结构可能是这样做的:

class House {
    has $.area is rw;
}

class Restaurant is House {
    has $.seats is rw;
}

role Floating {
    has $.weight is rw;
    method is_sinking() {
        return $!weight > 500 * $.area;
    }
}

这里,Floating 角色和我们在上一个例子中用的浮动房屋的角色完全一样:

my $restaurant = Restaurant.new does Floating;
$restaurant.seats = 30;
$restaurant.area = 100;
$restaurant.weight = 10_000;

现在 $restaurant 变量是 Restaurant+{Floating} 类型的对象,可以使用 is_sinking 方法:

say $restaurant.is_sinking(); # False

在这里,我们就不说创建类的层次结构和应用角色了,看看 Raku 是如何帮助考察这些对象的内部结构的。

8.8. 使用内省学习更多

Raku 对象系统有一个内置的内省机制,通过这个机制,你可以看到手中的这个特定对象可以做什么,它实现了哪些类,可以使用哪些方法等等。

在前几章中,我们已经使用了内省机制之一-WHAT 方法。它返回的类型对象,包含了现在位于容器中的对象的类型信息。我们在这一章中讨论了内省,但是你应该记住,在 Raku 中,很多其他的简单变量,比如字符串或整数,也是对象。

例如,你可以这样看一个字符串和一个整数的类型。程序会打印出字符串化的 WHAT 方法所返回的内容:

say 'string'.WHAT; # (Str)
say 42.WHAT;       # (Int)

对于用户定义的类,WHAT 方法给出了该类的名称:

class C {
}
my $c = C.new;
say $c.WHAT; # (C)

HOW 方法返回一个 Perl6::Metamodel::ClassHOW 类的对象。这是 Raku 中所谓的元对象模型的一部分,它负责处理 Raku 中的对象及其属性和行为。我们在此不再深入学习元对象协议(MOP),只看一下它提供的两个有用的方法-namemro

name 方法返回的是类的名称。注意,WHAT 方法返回的是一个类型对象,当我们打印出来的时候,它是以 (ClassName) 的格式字符串化的,而 Perl6::Metamodel::ClassHOW 类的 name 方法返回的是一个字符串。这就是 name 方法的调用方式:

say $c.HOW.name($c); # C

在给定的变量 $c 上,方法 HOW 被调用。它返回一个对象,在这个对象上调用 name 方法,并将变量 $c 作为参数。这种冗余是在 Raku 开发者考虑到未来的一些计划而做的。为了实用的目的,使用另一种更简单的语法比较容易。

say $c.^name; # C

HOW.mro,简称 ^mro,方法(名字代表方法解析顺序)返回一个显示类层次结构的列表。它可以用来了解如何解决名称冲突。

例如,这里有几个类的子类-父类关系:

class A {}
class B is A {}
class C is A {}
class D {}
class E is D is B is C {}

^mro 方法可以在类名和类的对象上调用:

say E.^mro;

my $e = E.new;
say $e.^mro;

在这两种情况下,将打印以下字符串:

((E) (D) (B) (C) (A) (Any) (Mu))

如果在复杂的层次结构中出现困难的关系,可以调用这个方法,看看 Raku 内部是怎么看的。

8.9. 方法后缀运算符

在第四章《使用运算符》中,我们并没有涉及到特殊的后缀运算符集,这与面向对象编程有关。现在是时候填补这个空白了。本节中描述的运算符是语法结构,但它们都可以被认为是后缀运算符。

在对象上调用一个方法时,会用到点运算符。我们在本章中已经多次使用过它:

class A {
    method m() {
        return 42;
    }
}

my $o = A.new; # calling the 'new' method
say $o.m();    # calling the 'm' method

如果该方法不存在,比如说,如果你调用 $o.n(),则调用失败:

No such method 'n' for invocant of type 'A'

为了防止异常的产生,.? 形式的方法调用运算符可以提供帮助:

say $o.?m(); # 42
say $o.?n(); # Nil

一个现有的方法像往常一样被调用,而非现有的方法的调用则返回 Nil,程序继续进行。

.+.* 操作符用于调用所有给定名称的方法。当你有一个类的层次结构时,这可能很有用。考虑一下下面的程序:

class A {
    method m() {
        return 'A::m';
    }
}

class B is A {
    method m() {
        return 'B::m';
    }
}

my $o = B.new;

m 方法在父类和子类中都有定义,所以 $o.m() 的调用被路由到 B 类的方法:

say $o.m(); # B::m

.+ 方法调用所有的方法, 并返回一个结果列表:

my @result = $o.+m();
say @result; # [B::m A::m]

正如你所看到的,调用 $o.+m() 会导致按 $o.^mro 的顺序调用 m 个方法(详见上一节《内省》)。

如果方法名称未知,就会发生异常。例如,不能调用 $o.+n():

No such method 'n' for invocant of type 'B'

.* 操作符的工作原理类似于 .+ 操作符,但允许尝试调用一个不存在的方法:

say $o.*m(); # (B::m A::m)
say $o.*n(); # ()

对于 n 方法,会返回一个空的结果列表。为了记住这些操作符,你可以将 + 与正则表达式中使用的相应的量词的语义进行比较(参见第十一章,Regexes)。 + 表示至少应该有一个带这个名字的方法,而 允许任意数量的方法,包括 0。

现在让我们看看在同一个例子中,我们如何从基类中调用方法。有一个 .:: 操作符,可以用来完全限定调用方法的名称。

say $o.A::m(); # A::m

这里,$o 变量是 B 类的一个对象,但在 .:: 操作符的帮助下,父类的方法 A::m 被调用。

8.10. 总结

在本章中,我们学习了 Raku 中的面向对象支持。我们经历了创建一个类,为其添加属性和方法,以及使方法和类数据为公共或私有。然后,我们讨论了类的层次结构和使用角色的另一种方法,以及如何使用 Raku 的内置设施来内省对象。在一组例子中,我们考察了许多处理复杂对象的技巧。最后,我们还列出了后缀方法操作符,利用这些操作符,你可以创建更多的通用和健壮的程序。

在下一章中,我们将介绍异常。在 Raku 中,它们是以类为基础的,所以本章的知识对更好地理解异常会有很大的帮助。

9. 输入和输出

本章主要介绍输入和输出,这主要是基于 Raku 中的 IO::Handle 类。一般计算机程序都会与用户进行通信。它可能是控制台应用程序中的输入和输出,或者是读取配置文件,或者是将结果保存在磁盘的文件中。在本章中,我们将讲述 Raku 中的输入和输出设施。

本章将涉及以下主题:

  • 标准输入和输出

  • 使用文件

  • 分析文件和目录的属性

  • 读取输入流的方法

  • 写入输出流的方法

  • 格式化输出

9.1. 标准输入和输出

在前面的章节中,我们已经创建了许多打印到控制台并从中读取数据的程序。让我们温习一下第二章《编写代码》中的一些知识,创建一个询问用户姓名并向用户问好的程序:

my $name = prompt 'What is your name? ';
say "Hello, $name!";
note "Greeted $name at " ~ time;

在这里,prompt 函数会打印消息,并等待用户输入一个字符串。该字符串被保存在 $name 变量中,之后用双引号插值在一个字符串中。note 函数打印调试信息,并记录向人问好的时间。

在这个程序中,Raku 使用了两个标准的通信通道,即标准输入流(简称 stdin)和标准输出流(stdout)。这些都是默认的流,它们接收用户的输入并接受程序打印的内容。另一个通道,我们在第二章《编写代码》中已经提到过,是用于打印错误信息和警告的流,即标准错误输出(stderr)。

在 Linux 系统中,POSIX 标准决定了文件描述符的数字为 0、1、2 的分别是 stdout、stdin 和 stderr。在 Raku 中,有三个具有动态作用域的特殊变量,$*OUT$*IN$*ERR,默认情况下是附加在这些通道上的。

printwarn 等内置函数使用的是 $*OUT$*IN$*ERR 的值。下表显示了函数和通道之间的对应关系:

函数

输入或输出方向

输入或输出流

print

output

$*OUT

say

output

$*OUT

prompt

output

$*OUT

prompt

input

$*IN

note

output

$*ERR

warn

output

$*ERR

$*OUT$*IN$*ERR 变量是 IO::Handle 类的实例。让我们来探索一下它。

IO::Handle 类代表一个打开的文件或一个输入/输出流。在 Raku 中,这个类实现了 IO 角色。在本节中,我们将讨论 IO::Handle 类和 IO 角色给予程序员的最有用的方法。

我们将学习的内容既适用于标准输入/输出流,也适用于处理文件。

9.2. 使用文件和目录

在 Raku 中,以及在许多其他语言中,都是通过文件句柄来处理文件的。当你打开一个文件时,你就会得到文件句柄;之后,你就会使用句柄向文件写入或从文件中读取。所有其他的操作,如刷新缓冲区或关闭文件,也都是通过句柄来完成的。 ==== 打开文件

要打开一个文件,使用 open 函数(它由 IO 角色提供,但也可以作为一个简单的内置函数使用)。它接收文件的路径和一些可选的参数。返回值是一个文件句柄,如下所示:

my $fh = open '/etc/passwd';

默认情况下,文件是以只读模式打开的。可以显式地使用命名参数传递模式名称。上面的例子相当于下面的代码:

my $fh = open '/etc/passwd', :r;

下表列出了打开文件的可能模式:

参数

描述

:r

只读模式。这是默认模式。

:w

只写模式。文件不存在则创建,文件存在则覆盖。

:rw

可读可写模式。文件不存在则创建,文件存在则覆盖。

:a

追加模式。文件不存在则创建,文件存在把数据追加到现有文件的末尾。

根据打开文件的模式,IO::Handle 类中的一组方法将可用。例如,即使文件句柄仍然是通用的 IO::Handle 类的实例, 你也不能向一个使用 :r 选项打开的文件写入。在这种情况下, 将抛出一个 X::AdHoc 异常:

my $fh = open '/etc/passwd', :r;
try {
    $fh.say('Hello'); # Attempt to write to a read-only file
}
say $!.^name;         # X::AdHoc, see details in Chapter 10, Exceptions

open 函数还接受一些配置参数,如下表所示:

参数

描述

:bin

以二进制模式打开文件。

:enc('encoding')

给文件关联给定的编码。请看写入到流一节中的例子。

:chomp

如果 :chomp 被设置为 True,那么按行读取时,新行符会被截断(参见从流中读取一节)

9.2.1. 关闭文件

要关闭一个文件,调用文件句柄上的 close 方法:

my $fh = open '/etc/passwd';
# .... read from file
$fh.close;

9.2.2. 测试文件和目录属性

IO 角色提供了一些单字母方法来检查文件和目录的不同指标。返回值为布尔值。下表中列出了这些方法:

方法

描述

e

检查路径是否存在

d

检查路径是否为已存在的目录

f

检查路径是否为已存在的文件

l

检查路径是否为符号链接

r

检查路径是否可访问(因此,已设置读取位)

w

检查路径是否可写(已设置写位)

x

检查路径是否可执行(已设置执行位)

rw

检查路径是否可读和可写

rwx

检查是否为路径设置了所有 r,w 和 x 位

s

检查文件是否为非空

z

检查文件大小是否为零

以这些方法为例。由于这些方法是在 IO 角色中定义的,我们要访问它们,首先要对变量调用 IO 方法,变量可以代表路径。例如,它可以是一个字面字符串或包含文件或目录路径的变量。另外,你也可以在文件句柄上调用 IO 方法。

我们来看几个例子。检查文件是否存在:

my $path = '/etc/passwd';
say "File $path exists" if $path.IO.e;

检查目录是否存在:

if '/Users'.IO.d {
    say '/Users is a directory';
}

请注意,d 方法只适用于目录。如果路径存在, 但它是一个文件,那么该方法返回 False:

say 'Not a directory' unless '/etc/passwd'.IO.d;

要检查路径是否存在,使用 e 方法。其结果不取决于路径是目录还是文件:

say 'File or directory exists' if '/'.IO.e;

前面所有测试文件和目录属性的方法都可以使用不同的语法,使用智能匹配运算符和副词结构-以冒号开头的结构,如 :e。下面的例子给出了一个如何做到这一点的想法;两行代码都是一样的:

say 'Exists' if 'data.txt'.IO.e;
say 'Exists' if 'data.txt'.IO ~~ :e;

9.2.3. 操纵文件

path 方法, 可用于 IO::Handle 类型的对象, 返回一个 IO::Path 对象,这对于处理磁盘上的物理文件很方便。IO::Path 类为程序员提供了一些方法来重命名、移动或删除文件。这些方法也是作为内置函数存在的,因此你不需要获取或创建 一个 IO::Path 对象来操纵磁盘上的文件。成功后,它们会返回 True 值。如果出现错误,可能会抛出一个异常。

在下面的表格中,我们总结了最常用的处理文件路径的函数:

函数

描述

例子

copy

复制文件

copy 'data.txt', 'data-copy.txt';

rename

重命名文件

rename 'old.txt', 'new.txt';

move

移动文件(将文件复制为新文件然后删除原文件)

move '/old/path/to/file', '/new/path/to/file';

unlink

删除文件(与 move 不同,它不复制文件)

unlink 'secret.txt';

symlink

创建符号链接

symlink 'target.txt', 'existing-file.txt';

chmod

更改文件权限

chmod 0o755, 'prog.pl'; Notice the octal notation

有了 IO 对象,你可能会获得该文件的一些特征。我们来简单研究一下它们。

mode 方法返回路径的访问模式位。看一个例子:

say '/etc/passwd'.IO.mode;

这段代码打印出的值如 0644。请注意,这个方法返回的是一个 IntStr 类型的对象,我们在本书中至今没有涉及。这是一个双值,在字符串上下文中,它包含了八进制值 "0644" 的字符串表示,而在整数上下文中,它是一个整数值 420,如下例所示:

say '/etc/passwd'.IO.mode.Str; # 0644
say '/etc/passwd'.IO.mode.Int; # 420

其他三个方法,modifiedaccessedchanged,返回路径的相应时间属性。返回值是一个 Instant 类型的对象。如果要得到纪元值或 Date 对象,另外调用 IntDate 方法,如下面的例子所示:

say '/etc/passwd'.IO.modified;      # Instant:1383139040
say '/etc/passwd'.IO.modified.Int;  # 1383139040
say '/etc/passwd'.IO.modified.Date; # 2013-10-30

9.2.4. 使用目录

IO::Path 类有一些处理目录的方法。我们将在本节中讨论它们。同样,这些例程可以作为方法和独立的子例程访问。

chdir 函数改变当前的工作目录。当前路径可以从 $*CWD 变量中读取。该值是 IO 类型的。要想得到字符串,可以使用 Str 方法或添加 ~ 前缀将其进行字符串化,如下面的代码所示:

say $*CWD.Str; # /Users/ash/code, for example
chdir '/tmp';
say ~$*CWD;    # /tmp

创建和删除目录是通过 mkdir 和 `rmdir 例程完成的。在创建目录时,可以通过一个可选的参数来设置权限模式:

mkdir 'data';
mkdir 'data/secret', 0o400;

rmdir 例程只有在目录为空的情况下才起作用:

mkdir 'temp';
# ... do something
rmdir 'temp';

dir 函数以 IO 对象列表的形式返回目录的内容。这就是你可以列出当前工作目录的方法:

my @dir = dir;
say $_.Str for @dir;

要指定目录的路径,可以将其作为参数传递,或者用字符串创建一个 IO 对象:

my @root_dir = dir('/');
my @temp_dir = '/tmp'.IO.dir;

现在,我们知道了如何处理文件和目录,我们继续讨论读写数据的方法。

在接下来的章节中,我们将讨论 IO::Handle 类中读写数据的方法。为简单起见,许多代码示例都使用标准的输入和输出。虽然,它们将与 open 函数返回的 $fh 文件句柄一起工作。

9.3. 从流中读取

IO::Handle 类为我们提供了许多不同的方法来读取流。我们已经在第二章《编写代码》中的"简单的输入和输出"一节中看到了一些。在这里,我们将详细讨论它们,并看到其他的替代方法。

9.3.1. 读取一行

我们从 get 方法开始,该方法从输入流中读取一行。例如,要从标准输入中读取一行,调用 $*IN 实例上的方法,如下面的例子所示:

my $line = $*IN.get;
say $line;

程序等待你输入一些文本。当这一行完成并按下 'Enter' 键后,get 方法将控制权返回给程序,然后将这一行打印到屏幕上。另外,你也可以使用命令行解释器的设施来重定向输入流,并将文件的内容传递给程序:

$ raku get.pl < get.pl
my $line = $*IN.get;

这个时候,程序就会打印出自己的第一行。

代码中的 $*IN.get 结构相当于 get 的裸调用:

my $line = get;
say $line;

当你对一个文件进行处理时,使用打开文件的文件句柄,就像我们刚才对 $*IN 进行的处理一样:

my $fh = open 'data.txt', :r;
my $line = $fh.get;
say $line;

在运行这个程序之前,先创建一个新的文件 data.txt,并在其中放入一些文本。如果文件不存在,$fh 句柄将被设置为 Failure 对象,下面对 get 方法的调用将引发一个错误(更多关于异常和失败的内容,请参见第十章《异常的处理》):

Failed to open file /Users/ash/code/data.txt: no such file or directory
  in block <unit> at open.pl line 1

9.3.2. 读取字符

要获取单个字符,请使用 getc 方法:

my $ch = $*IN.getc;
say $ch;

getc 方法阻止程序执行,直到流中出现一个字符。如果流中没有剩余的字符,则返回一个 Any 的空值。在布尔上下文中,它是 False,所以它可以用于循环的条件。让我们创建一个程序,逐个读取输入的字符,并将它们分别打印在单独的一行上。

while my $ch = $*IN.getc {
    say $ch;
}

getc 方法在处理 Unicode 字符时非常聪明。为了演示这种行为,让我们创建一个文本文件 text.txt,并在其中放入一个 u 字符。然后,将文件传给程序并读取该字符:

$ raku getc.pl < text.txt
u

这是一个单字节的字符,来自 ASCII 子空间。现在,让我们使用一个不同的字符,比如说,拉脱维亚语u,上面有一个横线: ū。在 Unicode 中,这个字符被称为 LATIN SMALL LETTER U WITH MACRON,代码点编号为 0x016B。在 UTF-8 编码中,这个字符由两个字节组成:0xC5 和 0xAB。所以,如果你把这个字符保存在文件中,它的大小将是两个字节。现在针对这个文件运行程序:

$ raku getc.pl < text.txt
ū

正如我们所见,Raku 设法理解文件的开头是两个字节代表一个 UTF-8 字符。

现在,getc 的任务更复杂一些。这次我们将使用字符的分解版本。在 UTF 编码中,像 ū 这样的字符可以存储为两个元素的序列: 代码为 0x0075(与在 ASCII 中相同)的 LATIN SMALL LETTER U,和 COMBINING MACRON(0x0304)。

让我们把它保存在一个文件中。其中一种方法是使用 Raku,打印相应的字节,并将输出结果重定向到一个文件中。这就是用单行程序来实现的方法:

$ raku -e'print "u"; print 0x0304.chr' > text.txt

要打印字符而不是整数值,调用 chr 方法。0x0304.chr。 现在该文件包含三个字节:75 CC 84。把它传给我们的程序:

$ raku getc.pl < text.txt
ū

程序中只有一次对 getc 的调用,而且它打印出了正确的字符。Raku 在看到一个有效的 ASCII 字符 u 后并没有立即停止,而是试图验证下面的字节是否仍然是组合字符 Unicode 表示的一部分。

现在,让我们把工作做得更复杂,构造一个不存在的字符,比如说 u 上面有一个双波浪线,下面有一个"逗号"。在 Unicode 中没有这个字符的代码点,但它仍然可以用三个元素来构造-字母本身和两个组合部分-COMBINING ALMOST EQUAL TO ABOVE (0x034C)COMBINING CEDILLA (0x0327)。准备文本文件:

$ raku -e'print "u"; print 0x034C.chr; print 0x0327.chr' > text.txt

现在,这三个元素以 UTF-8 编码的形式存在于五个字节中-75 CD 8C CC A7。不过,Raku 还是把它当作一个字符来读,你可以通过再次运行程序来证明:

$ raku getc.pl < text.txt
u̧͌

要一次读取多个字符,请使用 readchars 方法。它的工作原理类似于 getc 方法,但返回的是一个包含字符的字符串。读取的最大字符数是作为参数传递的:

my $str = $*IN.readchars(12); # read 12 characters from standard input
say $str;

注意,要重现本节中的示例,你需要一个支持 Unicode 的终端。

9.3.3. 惰性读取

IO::Handle 类定义了一些惰性读取的方法。这里的惰性是指 Raku 应该在程序真正需要另一部分数据的时候执行实际的读取。所以它不应该立即读取整个文件。

lines 方法返回一个行的列表。下面是一个将其输入复制到输出的短程序的例子:

.say for lines;

这可以用更传统的语法重新写成另一种形式:

for $*IN.lines -> $line {
    say $line;
}

调用 $*IN.lines 会返回一个输入行的数组。例如我们可以直接将其保存在一个变量中,然后用于打印:

my @lines = $*IN.lines;
.say for @lines;

一个重要的事情是, lines 方法会从行尾删除换行符。所以,如果你需要重现它,请使用 say 函数在输出的末尾打印换行。

lines 方法接受一个整数参数来表示要读取的最大行数:

.say for $*IN.lines(3); # prints the first 3 lines from input

另一种从输入流中读取逻辑数据的方法是使用 words 方法。它的工作原理与前面描述的 lines 方法类似,但将输入拆分成单词而不是行。分隔符是一连串的空格。考虑一个例子:

.say for $*IN.words;

这个程序将输入的每个单词打印在单独的一行上。

split 方法概括了一种读取逻辑元素的方法,并允许我们指定一个用于分隔元素的拆分器。例如,下面这段代码教你如何用冒号分隔输入:

.say for $*IN.split(':');

现在,提供一行来自 /etc/passwd 文件的内容,例如, 程序会将它的各个部分打印出来:

$ cat /etc/passwd | grep nobody | raku split.pl
nobody
*
-2
-2
Unprivileged User
/var/empty
/usr/bin/false

comb 方法会返回一个列表, 列出所有在输入流中找到的匹配结果。对于匹配,使用的是正则表达式。我们将在第十一章《正则表达式》中讨论正则表达式,但这里有一个从输入中提取所有数字的简单例子:

my @numbers = $*IN.comb(/\d+/);
say @numbers.join(', ');

下面的输入演示了这个程序是如何工作的。在你输入文本后,程序会打印出一个逗号分隔的列表,其中包括它找到的整数。粗体字的一行是你输入的内容:

$ raku comb.pl
There are 3 points in a triangle, 4 points in a square,
and 5 points in a star.
3, 4, 5

9.3.4. eof 方法

只有当文件中还有数据时,从文件中读取才有意义。要检查文件或流是否还包含数据,可以使用 eof 方法,当到达文件末尾时,该方法返回 False:

my $fh = open 'data.txt';
if $fh && !$fh.eof { # Only if file exists and has something to read
    my $line = $fh.get;
    say $line;
}

9.4. 写入流

在本节中,我们将研究 IO::Handle 类提供的流写入方法。

9.4.1. print 函数

我们将从简单的 print 函数开始。基本上,它的用法很明显。它把文本打印到流中。如果是标准输出,使用裸的 print 函数或 $*IN.print 方法。如果你对一个文件进行操作,请使用其文件句柄。

下面的程序创建了一个名为 hello.txt 的文件,并将一个字符串写入其中:

my $fh = open 'hello.txt', :w; # Open a file for writing
$fh.print('Hello, World');     # Print to the file
$fh.close;                     # Close the file so that the data is saved

如果文件已存在,则将重写该文件,之前的所有内容都将丢失。如果需要将新的输出附加到现有文件中,请使用 :a 追加模式:

my $fh = open 'hello.txt', :a; # Open in append mode
$fh.print('!');                # Now the file contains 'Hello, World!'
$fh.close;

close 方法关闭文件。实际上,这并不需要手动完成,因为一旦文件句柄离开了它的作用域,Raku 就会立即关闭文件。

open 函数的 :enc 命名参数设置文件的编码。看一下下面的代码。它打开了两个文件并打印出相同的字符串:

my $str = 'ä';

my $fh1 = open 'enc-latin1.txt', :w, enc => 'Latin1';
$fh1.print($str);
$fh1.close;

my $fh2 = open 'enc-utf-8.txt', :w, enc => 'UTF-8';
$fh2.print($str);
$fh2.close;

现在,看看这个程序创建的文件的大小:

$ ls -la enc-*.txt
-rw-r--r-- 1 ash ash 1 Mar 16 07:59 enc-latin1.txt
-rw-r--r-- 1 ash ash 2 Mar 16 07:59 enc-utf-8.txt

正如预期的那样,其中一个文件是用 Latin-1 编码编写的。ä 字符在这种编码中很适合,所以文件内容是一个带有该字符代码的单字节:

$ hexdump enc-latin1.txt
0000000 e4
0000001

第二个文件使用 UTF-8 编码,同一个字符需要两个字节:

$ hexdump enc-utf-8.txt
0000000 c3 a4
0000002

在前面的例子中,两种编码都能够表示写入到文件的字符。如果所选的编码不能支持,就会出现运行时错误。在下一个程序中,我们试图将一个 Unicode 笑脸写到以 Latin-1 编码打开的文件中:

my $fh = open 'smiley.txt', :w, :enc('Latin1');
$fh.print(0x263a.chr); # The WHITE SMILING FACE character
$fh.close;
say 'OK?';

程序在尝试向文件写入时退出,程序的其它部分没有执行:

Error encoding Latin-1 string: could not encode codepoint 9786
  in block <unit> at enc2.pl line 2

请注意,在前面的程序中,使用了两种不同的语法选项来为函数的命名参数传递值-enc ⇒ 'UTF-8':enc('Latin1')。两种形式都是等价的; 你可以选择你更喜欢的一种。

9.4.2. say 方法

乍一看,say 方法的工作原理和 print 方法一样,在末尾添加一个换行符。但这不是全部的真相。在内部,say 会调用打印对象上的 gist 方法来获得它的文本表示。

对于字符串和整数等数据类型,它们的文本形式是直接的。在下面的例子中,printsay 的输出不会有什么区别:

my $str = 'String';
print $str, "\n";
say $str;

my $int = 42;
print $int, "\n";
say $int;

对于比较复杂的数据结构,这两种方法的行为是不同的。该程序的输出显示在注释中:

my @array = <10 20 30>;
print @array, "\n"; # 10 20 30
say @array;         # [10 20 30]

my %hash = alpha => 1, beta => 2, gamma => 3;
print %hash, "\n";  # alpha   1
                    # beta 2
                    # gamma   3
say %hash;          # {alpha => 1, beta => 2, gamma => 3}

9.4.3. gist 方法的使用示例

对于用户定义的类,可以创建一个 gist 方法, 按需要准备输出。让我们在下面的例子中尝试一下。

我们创建了一个存储化学公式的类。我们的目标是允许创建一个纯 ASCII 格式的化学公式,然后将其打印出来,使数字索引显示为下标。

class Chemical {
    has $.formula;
    method gist {
        my $output = $!formula;
        $output ~~ s:g/(<[0..9]>)/{(0x2080+$0).chr}/;
        $output;
    }
}

Chemical 类有一个数据成员 $.formula,它将原始的 ASCII 公式保存为一个字符串。gist 方法将其转换为带有下标的字符串。我们用正则表达式进行替换。正则表达式在第十一章《正则表达式》中有详细介绍。现在,只要知道下面这行代码将从 0 到 9 的所有数字替换为它的下标版本就足够了。为了得到下标数字的代码值,请用公式中数字值加上 SUBSCRIPT ZERO Unicode 字符的代码点值:

$output ~~ s:g/(<[0..9]>)/{(0x2080+$0).chr}/;

现在,是时候使用这个类了。在循环中,创建了几个实例来测试不同的情况: 一个简单的公式、一个带括号的公式和一个带两位数字索引的公式:

for < H2O Al2(SO4)3 Al6O13Si2 > {
    my $chem = Chemical.new(formula => $_);
    say $chem;
}

程序的输出如下:

H₂O
Al₂(SO₄)₃
Al₆O₁₃Si₂

如果你使用 print 函数而不是 say,那么输出将是这样的:

Chemical(140226845929544)
Chemical(140226845929664)
Chemical(140226845929704)

这个输出包含了类的名称和变量在内存中的位置地址。为了使输出对最终用户更有用,请为其类定义 gist 方法, 并使用 say 函数来"打印"对象。

9.4.4. printf 方法

printf 方法以给定的格式打印值。它与 C 和 C++ 标准库的中的 printf 函数大多相同。这个方法的第一个参数是一个描述格式的字符串,其余的参数是以格式字符串中的 % 字符为起点,代替指令的值。

在很多情况下,可以通过字符串插值来代替实现格式化。例如,下面两行会产生相同的输出:

my $temperature = 25.6;
printf("Temperature is %g °C\n", $temperature);
say "Temperature is $temperature °C";

在下表中,列出了主要的格式化指令。

9.4.5. 字符和字符串

我们先从打印文本数据开始:

指令

描述

%%

% 字符

%c

一个字符

%s

一个字符串

下面是一些上表中一些指令的例子:

printf "The percent sign: %%\n";
printf "Character %c\n", 167;
printf "String %s\n", 'Hello, World';

该程序打印出如下行:

The percent sign: %
Character §
String Hello, World

注意 %c 指令将相应的参数作为一个字符, 而不是当作一个整数。

9.4.6. 整数

有几个不同的指令用于不同格式的整数:

指令

描述

%b

二进制表示的整数

%d 或 %i

有符号十进制整数

%u

无符号十进制整数

%o

八进制格式的整数

%x

十六进制格式的无符号整数

%X

与 %x 相同但是以大写字母表示

让我们用不同的格式打印相同的数字:

printf "Binary: %b, decimal: %d, octal: %o\n", 10, 10, 10;
printf "Hexadecimal: %x, uppercased: %X\n", 10, 10;

该程序的输出是这样的:

Binary: 1010, decimal: 10, octal: 12
Hexadecimal: a, uppercased: A

%u 指令期望的是一个无符号整数,所以编译器如果看到负数就会引发错误:

$ raku -e'printf "%u", -10'
negative value '-10' for %u in sprintf
Directive u not applicable for type Int

9.4.7. 浮点数字

对于浮点数,请使用以下格式之一:

指令

描述

%e

以科学计数法表示的浮点数

%E

和 %e 相同但是大写字母 E 表示指数部分

%f

一个浮点数

%g

%e 或 %f(取其一)

在下面的例子中,pi 的值是以不同的格式打印的:

printf "%e, %E\n", pi, pi;
printf "%f, %g\n", pi, pi;

结果就是这个样子:

3.141593e+00, 3.141593E+00
3.141593, 3.14159

%g 格式是最"以人为本"的格式 - 它以有限的精度显示浮点数,并在显示非常大和非常小的数字时切换到科学记数法:

printf "%g\n", 0.000001; # 1e-06
printf "%g\n", 0.1;      # 0.1
printf "%g\n", 1;        # 1
printf "%g\n", 10;       # 10
printf "%g\n", 10000000; # 1e+07

在格式化字符串后传递的参数数量必须与其中的指令数量一致。否则会发生 X::Str::Sprintf::Directives::BadType 异常:

$ raku -e'printf "%c", 1, 2'
Your printf-style directives specify 1 argument, but 2 arguments were
supplied

有关格式化字符串的详细描述,请参阅以下文档页面: docs.raku.org/type/Str#sub_sprintf

9.5. 总结

在本章中,我们讨论了 Raku 中可用的输入和输出设施。IO::Handle 类提供了处理标准输入和输出流以及使用相同接口的文件的通用方法。我们讨论了如何创建文件以及 如何测试文件和目录的不同属性, 并研究了各种读写方法。

在处理文件时,有时可能会遇到特殊情况; 我们已经在本章中看到了一些例子。在下一章,我们将详细地讨论 Raku 中的异常。

10. 异常

在前两章中, 我们谈到了面向对象的编程, 以及使用对象实现的输入和输出。在这一章中, 我们继续与对象打交道, 并将讨论 Raku 中的另一个领域, 它的实现广泛使用了类, 并且具有庞大的层次结构。

异常是指程序进入到无法继续运行的状态的情况。有些异常是由程序设计中的缺陷引起的, 有些则是由于外部因素引起的, 如磁盘故障或与数据库的连接中断。在这种情况下, 异常并不是什么非同寻常的事情, 必须停止程序, 而是一种处理错误并继续执行的方法。

在本章中, 我们将讨论程序可能面临的异常情况。此外, 我们还将看到程序员如何防止异常情况的发生。

本章将涉及以下主题:

  • try

  • CATCH phase 块捕捉异常

  • Exception

  • 抛出和再抛出的例外情况

  • Failure 类和软故障

  • 使用类型化异常

  • 创建自定义异常

10.1. try 块

我们先说一个最简单的异常, 除零。运行下面的单行程序:

say 1 / 0;

程序中断并打印出以下错误信息:

Attempt to divide 1 by zero using div
  in block <unit> at zero-div.pl line 1

Actually thrown at:
  in block <unit> at zero-div.pl line 1

我们不能被零除。注意, 错误信息还包含了程序的堆栈跟踪。由于我们没有使用任何模块或有任何函数调用, 堆栈跟踪很短。

现在, 让我们在有除法的行之前和之后做一些其他的操作, 它失败了:

say 'Going to divide 1 by 0';
say 1 / 0;
say 'Division is done';

在运行时, 因为除零而出现异常。所以, 程序执行第一行并打印第一条信息。然后, 发生异常, 程序终止。没有更多的东西会被执行, 而且永远不会到达最后一行。

现在让我们通过从外部输入值来改变内置值。让用户输入要除的数字:

my $a = prompt 'Enter dividend > ';
my $b = prompt 'Enter divisor > ';
my $c = $a / $b;
say "The result of $a / $b is $c.";
say 'Done.';

如果你运行这个程序, 它会要求输入两个数字, 然后打印出它们的除法结果。用一些非零的值试试:

$ raku division.pl
Enter dividend > 10
Enter divisor > 2
The result of 10 / 2 is 5.
Done.

现在尝试输入 0 作为除数。马上, 你会得到一个异常:

$ perl6 division.pl
Enter dividend > 10
Enter divisor > 0
Attempt to divide 10 by zero using div
  in block <unit> at division.pl line 4

Actually thrown at:
  in block <unit> at division.pl line 4

这种行为可能不是程序最想要的结果。我们应该让程序更加稳定, 这样它就不会依赖来自外部的错误数字。

在 Raku 中, 代码中有问题的部分可能会被放到 try 块中。在我们的例子中, 这样的代码部分就是有除法操作的那一行:

my $c = $a / $b;

让我们把它和打印结果的那行一起放在 try 块里:

my $a = prompt 'Enter dividend > ';
my $b = prompt 'Enter divisor > ';
try {
    my $c = $a / $b;
    say "The result of $a / $b is $c.";
}
say 'Done.';

请注意, 在这个程序中, 我们在 try 块之后还有一些代码。

用与之前相同的输入值运行它。首先, 用非零的数字:

$ perl6 division.pl
Enter dividend > 10
Enter divisor > 5
The result of 10 / 5 is 2.
Done.

程序的表现和引入 try 块之前的表现完全一样。

现在, 用零来试试:

$ perl6 division.pl
Enter dividend > 10
Enter divisor > 0
Done.

就是这样。这里有三件事情需要注意。首先, 我们没有看到关于非法除法的错误信息。第二, 字符串 The result of …​ 没有被打印出来。第三, try 块后面的代码被执行并打印出来。

这里发生了什么?在 try 块中, 像之前一样发生了一个异常, 但它并没有停止程序的执行。try 块向我们隐瞒了异常的事实。当然, 我们失去了带有结果的输出, 但我们保留了继续执行程序的能力。

10.1.1. $! 变量

现在让我们看看如何了解是否发生了错误。在 Raku 中, 有一个特殊的变量, $!, 叫做错误变量。如果发生了异常, $! 就会包含一个异常。让我们用它来检查是否一切正常:

my $a = prompt 'Enter dividend > ';
my $b = prompt 'Enter divisor > ';
try {
    my $c = $a / $b;
    say "The result of $a / $b is $c.";
}

if $! {
    say 'Failure!';
}
else {
    say 'All fine.';
}
say 'Done.';

我们添加了测试 if $!。它把 $! 变量放在一个布尔上下文中处理。实际上, 变量在发生异常时的值是一个 Exception 类型的值。我们将在本章后面的"异常对象"一节中看到它。现在, 只要知道在布尔上下文中, Exception 给出一个 True 值就足够了。如果没有异常, $! 变量将包含 Any 类型的对象, 在布尔上下文中是 False

如果运行更新后的程序, 并传递零作为除数, 程序就会打印出 Failure! 消息:

$ perl6 division.pl
Enter dividend > 10
Enter divisor > 0
Failure!
Done.

10.2. 软故障

在前面的例子中, try 块包含了数学计算和打印结果的指令。在实际的程序中, 这些操作往往是分开的。让我们重新编写程序, 使其在一个单独的子程序中对数字进行除法操作:

my $a = prompt 'Enter dividend > ';
my $b = prompt 'Enter divisor > ';
my $c = calculate($a, $b);
say 'Now ready to print';
say "The result of $a / $b is $c.";
say 'Done.';

sub calculate($a, $b) {
    return $a / $b;
}

现在危险的操作发生在 calculate 函数内部, 而结果则在函数外部使用。

用应该引起异常的值运行程序:

$ perl6 division.pl
Enter dividend > 10
Enter divisor > 0
Now ready to print
Attempt to divide 10 by zero using div
  in block <unit> at 06.pl line 7

Actually thrown at:
  in block <unit> at 06.pl line 7

仔细检查输出结果。在出现异常信息之前, 屏幕上出现了一行 Now ready to print。在打印的时候, 计算已经完成, 非法除法已经发生, 但程序还在运行, 只有在即将打印结果时才会失败。

出现这种情况是因为 Raku 允许软失败。软失败是一个未抛出的异常。除法的结果只用在我们打印 $c 值的那一行。在此之前, 没有任何东西阻止了程序, 它就像没有发生错误一样打印消息。

了解了软故障, 我们就会得出以下结论。如果你使用 try 块来防止程序终止 , 你应该把它放在即将使用不可能的结果的地方周围(比如用 say 函数)。下面的修改可以做到这一点:

try {
    say "The result of $a / $b is $c.";
}

围住除法本身是不够的:

sub calculate($a, $b) {
    my $result;
    try {
        $result = $a / $b;
    }
    return $result;
}

这里, 在 try 块之后, $! 变量包含了 Any 对象, 因为失败还没有发生。

10.3. CATCH phaser

在本章前面, 我们使用 try 块来捕获异常。如果异常发生在 try 块内部, 它就会设置 $! 变量, 你可以在后面检查。

在 Raku 中, 这不是处理异常的唯一方法。让我们回到之前的程序, 但这次我们将使用 CATCH 块:

my $a = prompt 'Enter dividend > ';
my $b = prompt 'Enter divisor > ';
my $c = $a / $b;
say "The result of $a / $b is $c.";
say 'Done.';

CATCH {
    say 'Exception caught!';
}

运行这个程序:

$ perl6 division.pl
Enter dividend > 10
Enter divisor > 0
Exception caught!
Attempt to divide 10 by zero using div
  in block <unit> at 07.pl line 4

Actually thrown at:
  in block <unit> at 07.pl line 4

一旦零除法发生并使用了它的结果, CATCH 块就会被触发. CATCH 块是 Raku 中的 phaser 之一, 我们在第二章《编写代码》中讨论过。当发生异常时, 编译器会将执行结果传递给这个块, 到目前为止还没有人处理它。

再一次注意到, 异常不是发生在除以零的那一刻, 而是发生在结果被打印到控制台的那一刻。

如果我们把有问题的代码放在 try 块里面, CATCH 块就不会被运行, 这在我们下面的程序变体中可以看到:

my $a = prompt 'Enter dividend > ';
my $b = prompt 'Enter divisor > ';
my $c  = $a / $b;
try {
    say "The result of $a / $b is $c.";
}
say 'Done.';

CATCH {
    say 'Exception caught!';
}

在这个程序中, CATCH 块的信息和异常信息都不会被打印出来。try 块隐藏了异常并设置了 $! 变量。CATCH 块没有被启动, 因为异常已经被处理了。

到目前为止, 我们已经介绍了处理异常的基本方法。现在是时候深入一点, 看看 Raku 是如何使用 Exception 类实际处理异常的。

10.4. Exception 对象

在 Raku 中, 异常是通过从 Exception 类派生出来的类的对象来处理的, 这些对象包含了所有关于异常的必要信息, 包括一些文本描述和堆栈跟踪(在 Raku 中, 它被称为backtrace)。

Raku 在出现异常时, 会创建一个异常对象。我们在本章前面已经看到了一个这样的例子-在试图打印非法数学运算结果的过程中, 错误变得明显。现在, 让我们使用 die 关键字自己产生一个异常。

die 关键字会抛出一个致命的异常并终止程序。一个典型的用法是, 例如, 如果程序不能打开一个文件或加载一个对程序其余部分至关重要的资源, 就停止程序:

my $fh = open 'filename.txt' or die 'File not found';

如果没有这样的文件, 布尔上下文中的 $fh 变量为 false, 然后执行 or 运算符的第二个分支。

die 函数接受一个文本信息。考虑一个例子, 其中 die 是无条件调用的:

say 'Start';
die 'Error message';
say 'Stop';

这个短程序打印出以下内容:

Start
Error message
in block <unit> at die.pl line 2

同样, 它工作得很好, 直到出现异常, 之后就停止工作。die 函数接受了文本信息, 它和回溯信息一起被打印出来。这是一个程序员如何生成自己的错误信息的例子。为错误提供良好的描述, 有助于理解出错背后的原因。

要处理这个异常, 可以使用 trycatch 块。try 块抑制了错误信息, 但允许程序继续运行:

say 'Start';
try {
    die 'Error message';
}
say 'Stop';

异常发生后, 这个程序不会退出:

$ perl6 die.pl
Start
Stop

使用 CATCH 代码块, 我们捕捉到异常, 但程序终止:

say 'Start';
die 'Error message';
say 'Stop';

CATCH {
    say 'Caught';
}

程序的输出如下:

$ perl6 die.pl Start
Caught
Error message
  in block <unit> at die.pl line 2

这就是我们前面看到的, 但这里涉及到的东西比较多, 我们要研究一下。

当进入 CATCH 块时, Raku 会把异常对象放到默认变量 $_ 中。利用它来分析原因并做出相应的反应。让我们打印一些调试信息, 以便我们看到 $_ 和 $! 变量发生了什么。在下面的代码中, ^name 方法返回变量的类名:

say 'Start';
die 'Error message';
say 'Stop';

CATCH {
    say '== $_.^name ==';
    say $_.^name;
    say '== $_ ==';
    say $_;
    say '== $! ==';
    say $!;
    say 'Caught';
}

程序的结果显示了一些有趣的细节:

$ perl6 die.pl
Start
== $_.^name ==
(X::AdHoc)
== $_ ==
Error message
 in block <unit> at 10.pl line 2

== $! ==
Nil
Caught
Error message
  in block <unit> at 10.pl line 2

Actually thrown at:
  in block <unit> at 10.pl line 2

$_ 中的对象是 X::AdHoc 类的一个实例。ad hoc 异常是一个从 Exception 类派生出来的类型的例子。Raku 在响应 die 的调用时创建了这样一个对象。X:: 命名空间被习惯性地用于异常类。

然后, 当我们通过将 $_ 变量传递给 say 函数来打印时, 就会打印出实际的错误信息:

Error message
  in block <unit> at 10.pl line 2

请注意, $! 变量是空的, 它包含了 Nil 值。

最后, 编译器打印来自 CATCH 块的信息, 打印错误信息和回溯信息, 并终止程序。

10.4.1. 抛出异常

现在我们可以开始研究异常对象了。Exception 基类定义了 throw 方法, 你可以用它来抛出一个异常。为了简单起见, 让我们从 X::AdHoc 异常开始。

在下面的程序中, 用 new 方法显式创建了一个异常, 并立即用 throw 方法抛出:

say 'Start';
X::AdHoc.new.throw;
say 'Stop';

CATCH {
    say 'Caught';
}

程序的输出是我们所熟悉的:

Start
Caught
Unexplained error
  in block <unit> at throw.pl line 2

我们没有提供任何错误信息, 程序打印了默认的字符串-Unexplained error

要使用消息, 请在创建异常对象时使用 payload 命名参数:

X::AdHoc.new(payload => 'My error message').throw;

10.4.2. 从异常中恢复

正如我们所看到的, CATCH 块停止了程序的执行。这并不总是最好的策略。在 Raku 中, 异常对象(一个类型来自 Exception 类的对象)可以将控制权返回到代码中发生错误的地方。

为了实现这一点, 调用异常对象上的 resume 方法, 如下面的例子所示:

say 'Start';
X::AdHoc.new(payload => 'My error message').throw;
say 'Stop';

CATCH {
    say 'Caught';
    .resume;
}

.resume 行调用了默认变量 $_ 的方法。这相当于显式调用 $_.resume。

这时, 程序并没有退出, 而是在出现异常后继续工作:

Start
Caught
Stop

在这种情况下, 错误信息不会被打印出来, 类似于 try 块的工作方式。例如, 从一个不存在的文件中恢复读取是没有意义的。另一方面, 当进行一系列计算时, 即使其中一个计算结果除以零, 继续计算也是有用的。

10.4.3. 类型化的异常

在 Raku 中预定义的 X:: 命名空间中还有很多类。要查看内置异常的完整列表, 请访问以下页面-docs.raku.org/type-exceptions.html。你可以为你的特定异常创建自己的类。我们首先看看如何在 CATCH 块中区分不同类型的异常。

让我们创建一个程序, 试图将当前的工作目录改为一个不存在的目录。在 Raku 中, 你可以使用 chdir 函数来改变目录:

chdir '/non-existing/directory';

输出结果显示如下错误信息:

Failed to change the working directory to '/non-existing/directory': does not exist
  in block <unit> at chdir.pl line 1

现在, 让我们通过调用 CATCH 块中的 $_ 变量的 ^name 方法来查看异常的类型:

chdir '/non-existing/directory';
CATCH {
    say $_.^name;
}

它将告诉我们 $_ 变量包含一个 X::IO::Chdir 类的对象。

现在, 让我们利用这些知识, 针对不同的特殊情况做出不同的操作。让我们构造一个程序, 它首先改变目录, 然后抛出一个特殊的异常。

为了区分不同异常的路径, 我们将使用 when 关键字, 将 $_ 变量与给定类型进行匹配:

chdir '/non-existing/directory';
X::AdHoc.new.throw;

CATCH {
    when X::AdHoc {
        say 'Ad hoc exception';
        .resume;
    }
    when X::IO::Chdir {
        say 'Non-existing directory';
        .resume;
    }
}

运行这个程序, 确认两个异常都被正确捕获了:

Non-existing directory
Ad hoc exception

CATCH 块中, 有两个 when 分支, 每个分支都有一个我们要处理的异常类型。重要的是要意识到, 在找到并执行了 when 块之后, CATCH 块会返回控制权并忽略该点之后的所有代码。这就是为什么 resume 方法要放在 when 块的任何一个地方。如果你把它放在整个 CATCH 块的最后一次, 那么只有在发生了其他类型的异常, 并且没有一个 when 块被满足的情况下, 该代码才会被到达。

10.4.4. 重新抛出异常

有时, 异常处理程序不能处理异常, 在这种情况下, 它可以通过调用 rethrow 方法再次抛出异常。该异常将被默认的异常处理程序捕获。

请看下面的例子:

say 1 / 0;
CATCH {
    when X::Numeric::DivideByZero {
        say 'Division by zero caught';
        .rethrow;
    }
}

这里, X::Numeric::DivideByZero 异常是在试图打印除法 1 / 0 的结果后被捕获的, 然后重新抛出一个异常, Raku 打印出相应的错误信息并终止程序:

Division by zero caught
Attempt to divide 1 by zero using div
  in block <unit> at div0.pl line 1

Actually thrown at:
  in block <unit> at div0.pl line 1

10.5. Failure 对象

让我们创建一个程序, 尝试打开一个不存在的文件, 并从其中读取第一行:

my $f = open 'dummy.txt';
say $f.get;

这个程序会引发一个异常:

Failed to open file /Users/ash/code/exceptions/dummy.txt: no such file or directory
in block <unit> at 14.pl line 1

请注意, 只有在试图从一个文件中读取数据后, 才会发生异常。仅仅打开一个文件并不会产生错误, 它只是将 $f 文件处理程序设置为 Failure 对象。

失败对象是 Exception 对象的一个封装器。异常本身可以通过 exception 方法到达:

my $f = open 'dummy.txt';
say $f.exception;

它打印错误信息:

Failed to open file /Users/ash/code/exceptions/dummy.txt: no such file or directory

你可以在布尔上下文中测试失败对象, 例如, 在打开一个文件后立即测试:

my $f = open 'dummy.txt';
say 'File not found' unless $f;

要查看失败是否已经被处理, 使用 handled 方法。在下面的例子中, 这个方法在引发异常的方法周围的 try 块之前和之后被调用两次:

my $f = open 'dummy.txt';
say $f.handled; # False
try {
    say $f.get;
}
say $f.handled; # True

当程序流离开 try 块时, 异常的状态就会变为 handled

10.6. 创建自定义异常

在前面的章节中, 我们已经看到 Raku 中的异常使用了面向对象的方法, 这尤其有助于区分 CATCH 块中的不同异常。

在这一节中, 我们将创建一个自定义的异常, 它和 X:: 命名空间中的其他内置类一样, 顺利地集成到 Raku 系统中。如果你没有任何特殊要求, 可以在相同的命名空间中创建你的自定义异常类。

例如, 让我们和 X::Lift::Overload 异常一起创建一个 Lift 类, 当有太多的人进入电梯时, 就会触发这个异常:

class Lift {
    class X::Lift::Overload is Exception {
        method message {
            'Too many people!'
        }
    }

    has $.capacity = 5;
    has $!people;
    method enter(Int $n = 1) {
        $!people += $n;
        X::Lift::Overload.new.throw if $!people > $!capacity;
    }
}

我们不需要 Lift 类之外的异常类, 所以最好限制作用域, 在主类中定义异常。

X::Lift::Overload 类扩展了 Exception 类, 必须至少提供 message 方法, 这样异常处理程序才能打印出错误信息。

List 类中, 有两个数据成员, $.capacity$!people, 用于保存默认的容量和电梯中的实际人数。enter 方法增加了这个数字, 并检查是否已经达到容量。如果有更多的人进来, 就会抛出一个异常。

现在, 让我们创建一个 Lift 类的实例, 让几个人进入电梯:

my $lift = Lift.new(capacity => 4);
$lift.enter();
$lift.enter(3);
$lift.enter(2);

在第三次调用 enter 后, 电梯里将有5个人, 最大容量为4人。于是, X::Lift::Overload 异常被引发, 程序退出时出现以下错误信息:

Too many people!
in method enter at lift.pl line 12 in block <unit> at lift.pl line 20

10.7. 总结

在这一章中, 我们学习了如何在 Raku 中处理异常。我们详细研究了通过使用语言的不同机制-try 块和 CATCH phaser 来抛出、捕获和隐藏异常的方法。我们谈到了软故障, 也就是推迟异常, 只有在真正不可避免的情况下才会抛出。此外, 我们还演示了如何使用面向对象的方法来处理不同类型的异常, 以及如何创建一个自定义的异常。

11. 正则表达式

正则表达式是 Perl 中最有价值的功能之一。在 Raku 中, 正则表达式被重新设计, 使其更加规范和强大。这个术语也发生了变化, 正则表达式现在更多的时候被称为regexes。在这一章当中, 我们将对 regexes 的语法元素进行全面的梳理。

本章将涉及以下主题:

  • 与正则表达式匹配

  • 字面量

  • 字符类

  • 量词

  • 备选

  • 分组

  • 捕获和命名捕获

  • 命名正则表达式

  • Match 对象

  • 断言

  • 副词

  • 替换

11.1. 与正则表达式匹配

正则表达式描述了文本的模式。它们为我们提供了一种语言, 我们可以在其中表达文本的结构。

请看一个例子。电话号码是一个数字序列。sequence of digits 这个短语可以写成 \d+。如果我们考虑到电话号码可以用空格和破折号来写, 那么我们不得不说, 电话号码是用空格或破折号分隔的数字序列。这已经是一个比较复杂的正则表达式了, 可以有不同的写法, 根据我们的严格程度, 比如说我们是否允许两个空格写在一起, 或者破折号后面可以跟一个空格, 或者一组数字可以由一个数字组成。

让我们把它形式化为 (\d || \s || \-), 也就是不止一个数字(\d)或空格(\s)或破折号(\-)。这里的双竖直条在这里代表"", + 意味着不止一个。最后, 国际电话号码可以用 + 号作为前缀, 这个是可选的。所以, 我们最终的电话号码的正则表达式是 \+? (\d || \s || \-)。

这个正则表达式并不完美。在本章的后面, 我们将努力使它变得更好, 更稳健。但是, 让我们从这个开始, 进行第一次匹配。在 Perl 中, 字符串与正则表达式相比较被称为匹配。要与一个正则表达式进行匹配, 需要使用双波浪线运算符。正则表达式本身被放置在一对斜线中:

say 'OK' if '+31 645-23-10' ~~ /\+? (\d || \s || \-)+/;

这个程序打印出 OK, 这意味着带有电话号码的字符串与该正则表达式匹配。让我们尝试一些文本:

say 'OK' if 'phone' ~~ /\+? (\d || \s || \-)+/;

在这个字符串中, 没有正则表达式需要的任何字符, 程序也没有打印出任何字符。此正则表达式不匹配。

字符串和正则表达式都可以放在变量中, 这两个变量将被相互匹配:

my $phone = '+31 645-23-10';
my $re = /\+? (\d || \s || \-)+/;
say 'OK' if $phone ~~ $re;

除了 / …​ / 之外, 正则表达式可以使用其他围绕的符号。如果正则表达式包含许多斜线, 例如用于解析 URL 的正则表达式, 这可能很有用。在这种情况下, 你需要在正则表达式之前加上前缀字符 mrx 。下面的例子都是等效的:

/ \d+ /
m/ \d+ /
m{ \d+ }
m| \d+ |

要创建一个将被放入变量中的正则表达式, 请使用 rx:

my $phone = '+31 65 253-45-93';
my $re = rx/\+? (\d || \s || \-)+/;
say 'OK' if $phone ~~ $re;

分隔符可以不一样, 例如, 一对花括号:

my $re = rx{\+? (\d || \s || \-)+};

使用 mrx 创建一个直接在匹配中使用的正则表达式:

say 'Not OK' unless 'phone' ~~ m/\+? (\d || \s || \-)+/;

为了否定匹配的结果, Raku 提供了一个不同的运算符, !~~。请选择一个更容易理解的运算符。

say 'Not OK' if 'phone' !~~ rx/\+? (\d || \s || \-)+/;

从前两个例子可以看到, unless~~ 的组合相当于 if!~~

现在, 既然我们知道了如何将字符串与正则表达式匹配, 并制作了我们的第一个正则表达式, 让我们详细学习一下正则表达式。

11.2. 字面量

正则表达式的语法是 Raku 中的一种小语言。由于要表达的东西很多, 所以它使用一些字符来传达意思。字母、数字和下划线代表它们自身, 没有任何特殊意义。这些字符可以按原样使用, 如下面的例子所示:

my $name = 'John';
say 'OK' if $name ~~ /John/; # OK

my $id = 534;
say 'OK' if $id ~~ /534/; # OK

如果正则表达式中的字符串包含其他字符, 比如说空格, 则应该小心处理。其中一种可能是引用整个字符串:

my $name = 'Smith Jr.' ;
say 'Junior' if $last-name ~~ /' Jr'/; # Junior

正则表达式中的文字字符串 ' Jr' 包含一个必须存在于变量 $name 中的空格。

另一种方法是使用特殊字符, 用反斜线作为前缀。使用 \s 匹配空格:

my $name = 'Smith Jr.' ;
say 'Junior' if $name ~~ /\sJr/; # Junior

Raku 中的空格在默认情况下是被忽略的。这个事实可以被利用来为正则表达式添加一些空气。对比一下我们之前使用的不使用空格的正则表达式, 和使用空格的正则表达式的等价物:

原始正则表达式

带空格的正则表达式

/John/

/ John /

/' Jr'/

/ 'Jr' /

/\sJr/

/ \s Jr /

/\+?(\d

\s

\-)+/

/ \+? (\d

\s

\-)+ /

11.3. 字符类

正则表达式中的字符类是一个特殊的序列, 它可以匹配某些给定的字符集中的字符。例如, 在上一节中, 我们已经使用了一个字符类 \s, 它与 ASCII 空格以及其一些其他的空白字符(例如制表符)匹配。让我们来探讨一下 Raku 正则表达式中的字符类。

11.3.1. .(点)字符

一个非常简单的字符, 只是一个点, 可以和字符串中的任何字符匹配。当你不关心两个部分之间的某些字符时, 通常会使用这种方法。例如, 下面的代码将与一个字符串中 a 和 d 之间有任意两个字符的字符串进行匹配:

say 'OK' if 'abcd' ~~ / a . . d /; # OK
say 'OK' if 'aefd' ~~ / a . . d /; # OK
say 'OK' if 'a*^d' ~~ / a . . d /; # OK

在前两个例子中, 每个点都与其中一个字母匹配。在第三个例子中, 两个点都匹配一个空白字符。

11.3.2. 反斜线字符类

有一组预定义的字符类, 它们以反斜线开头, 后跟一个小写或大写的字母。大写字母的版本与小写字母语义相反, 并且否定了与字符类匹配的字符集。下表包含了反斜线字符类的概述:

字符类

否定

描述

\s

\S

空白

\t

\T

制表

\h

\H

水平空白

\v

\V

垂直空白

\n

\N

换行

\d

\D

数字

\w

\W

单词字符

现在我们就来详细考察一下所有的字符类。

11.3.3. \s 和 \S 字符

我们已经看到了这个字符类的一个例子- \s, 用于匹配空格字符。它的大写字母对应的字符类, \S, 则与之相反 - 它与除了空格以外的任何字符匹配。 让我们来看一个例子:

my $str = 'Hello, World!';
say 'OK' if $str ~~ / \s World /; # OK
say 'OK' if $str ~~ / Hello\S /;  # OK

两个正则表达式都是匹配的。第一个例子中的 \s 与单词之间的空格匹配。第二个例子中的 \S 与逗号匹配。

字符类 \s 是其它空格字符类的组合 - \h\v 组合而成的, 在下面的章节中会介绍。这些字符类还包括单个字符, 如 \t (水平制表符, 0x09)或 \r(换行, 0x0A)。

11.3.4. \t 和 \T 字符

\t\T 分别与制表符和非制表符匹配。想象一下, 你有一行用制表符分隔的数据, 你想把它放到一个数组中。下面的代码可以帮你完成:

my $data = "John\tSmith\t1970";
my @data = $data.split(/\t/);

print qq:to/OUT/
Name          = @data[0]
Last name     = @data[1]
Year of birth = @data[2]
OUT

在这里, 我们在 $data 字符串上调用 split 方法, 并传递给它一个包含单个 \t 字符类的正则表达式, 它应该与制表符匹配。这样, 它将源数据行分成三部分, 并把它们放到 @data 数组中。

qq:to/OUT/ 构造是 heredoc 的开始, 它在第二次出现的标签 OUT 处结束。双 qq 需要在 heredoc 内部进行变量插值。 这种方法可以更容易地准备模板, 以便按照所需的布局输出数据:

$ raku name-split.pl
Name          = John
Last name     = Smith
Year of birth = 1970

11.3.5. \h 和 \H 字符

这些字符类的小写版本与水平空格相匹配(大写版本分别否定匹配结果)。

在常见的空格和制表字符中, 还有许多其他的水平空格, 例如, 在 HTML 中被标记为   的非换行空格。

下表列出了当前定义的所有属于该字符类的字符:

Unicode 代码点

字符名

0x9

CHARACTER TABULATION

0x20

SPACE

0xA0

NO-BREAK SPACE

0x1680

OGHAM SPACE MARK

0x180E

MONGOLIAN VOWEL SEPARATOR

0x2000

EN QUAD

0x2001

EM QUAD

0x2002

EN SPACE

0x2003

EM SPACE

0x2004

THREE-PER-EM SPACE

0x2005

FOUR-PER-EM SPACE

0x2006

SIX-PER-EM SPACE

0x2007

FIGURE SPACE

0x2008

PUNCTUATION SPACE

0x2009

THIN SPACE

0x200A

HAIR SPACE

0x202F

NARROW NO-BREAK SPACE

0x25F

MEDIUM MATHEMATICAL SPACE

0x3000

IDEOGRAPHIC SPACE

这张表中有很多字符, 你可能从来没有用过, 但看了这张表, 你可以想象到 Raku 对于 Unicode 和空格的要求是多么严格。

11.3.6. \v 和 \V 字符

\v 和 \V 字符类代表了垂直空格和非垂直空格的字符。与水平空格集相比, Raku 知道的垂直空格要少得多, 但它仍然涵盖了整个不同的 Unicode 符号, 如下列所示:

Unicode 代码点

字符名

0xA

LINE FEED

0xB

LINE TABULATION

0xC

FORM FEED

0xD

CARRIAGE RETURN

0x85

NEXT LINE

0x2028

LINE SEPARATOR

0x2029

PARAGRAPH SEPARATOR

11.3.7. \n 和 \N 字符

\n 字符与逻辑上的新行匹配。\N 字符则相反, 它与任何不是新行的字符匹配。

匹配新行是很棘手的, 因为在不同的操作系统中, 不同的逻辑行之间有不同的约定。在类 Unix 系统中, 行之间由单个 \r 字符(代码为0x0A)分隔。在 Windows 中创建的文件中, 新行分隔符是 CARRIAGE RETURN(0x0A) 和 LINE FEED(0x0D) 两个字符的组合。Raku 的 \n 字符可以匹配其中的任何一个。

让我们通过下面的例子来证明这一点:

my $unix-str = "Hello,\rWorld!";
my $windows-str = "Hello,\r\nWorld!";

my @unix-lines = $unix-str.split(/\n/);
my @windows-lines = $windows-str.split(/\n/);

say @unix-lines.join('//');
say @windows-lines.join('//');

这里, 有两个带有不同新行分隔符的字符串。然后, 我们用相同的正则表达式 /\n/ 将这两行拆分开。程序的输出结果显示, 这两行被分割成了相同的部分:

Hello,//World!
Hello,//World!

11.3.8. \d 和 \D 字符

\d 字符类与一个数字匹配。这里的数字被理解为 Number 类别中 Unicode 字符。在传统的阿拉伯数字 0-9 中, 还有来自其他字母和脚本的数字。这些字符的整个列表是详尽的, 但让我们举几个其他数字的例子:

  • 阿拉伯文-印度文: ٠ ١ ٢ ٣ ٤ ٥ ٦

  • Nko (来自西非的从右到左的字母): 梵文 (印度和尼泊尔): ० १ २ ३ ४

  • 孟加拉语: ০ ১ ২ ৩ ৪

  • 数学粗体: 𝟎 𝟏 𝟐 𝟑 𝟒

  • 数学双倍体: 𝟘 𝟙 𝟚 𝟛 𝟜

这些数字中的任何一个数字都会与 \d 匹配:

$ raku -e'say "OK" if "   " ~~ /\d/'
OK

11.3.9. \w 和 \W 字符

\w 字符类与可以作为单词的一部分的字符匹配, 即字母、数字和下划线符号匹配。\W 与其他所有除字面、数字和下划线的字符匹配。这里的字母在是在 Unicode 意义上理解的 - 这些是 Letter 类别中的字符。

例如, \w 总是与任何希腊字母匹配:

$ raku -e'say "OK" if "λ" ~~ /\w/'
OK

就像我们在上一节中提到的数字一样, 你必须准备好 \w 将与你所熟悉的语言中的许多字符匹配。

11.3.10. 字符类

字符类是一种请求与给定字符列表进行匹配的机制。例如, 要匹配十六进制数字, 我们需要匹配一个十进制数字 0 到 9 和六个字母 a 到 f(也包括它们的大写变体 A 到 F)的字符。

在 Raku 的正则表达式中, 这可以写成一个字符类 <[0..9 a..f A..F]>。让我们把这个正则表达式应用到大写的拉丁字母列表中:

for 'A'..'Z' {
    .print if /<[0..9 a..f A..F]>/;
}

这将打印出字符串 ABCDEF, 其中包含与给定的正则表达式匹配的字母。

字符类也可以包括反斜线序列。在电话号码正则表达式中, 我们可以使用一个字符类来匹配数字、空格或连字符:

/ \+? <[\d\s\-]>+ /;

让我们继续 Raku 正则表达式引擎中内置的其他字符类。

11.3.11. 预定义的子规则

Raku 中的正则表达式包括一些预定义的子规则, 这些子规则也是字符类, 并与反斜线字符类部分相交。语法上, 子规则是角括号中的名字。下表总结了这些子规则:

子规则

意义

<alpha>

字母符号和_

<alnum>

与 \w 相同

<digit>

与 \d 相同

<lower>

小写字符

<upper>

大写字符

<space>

空格, 与 \s 相同

<blank>

水平空格, 与 \h 相同

<cntrl>

控制字符

<punct>

标点符号

<graph>

与 <alnum> + <punct> 相同

<print>

可打印字符, 与 <alnum> + <space> 相同, 不带 <punct>

在 Raku 中, 预定义子规则是正则表达式的有效部分, 可以和其他字面量字符类一起使用在正则表达式的任何地方。在下面的例子中, 我们检查字符串是否包含一个数字后跟一个字母:

my $regex = / <digit> <alpha> /;

say 'Match'    if '3a' ~~ $regex;    # Match
say 'No match' if 'abcd' !~~ $regex; # No match
say 'No match' if 678 !~~ $regex;    # No match

前面提到的命名子规则并不是唯一根据 Unicode 属性选择字符的方法 。在下一节中, 我们将看到如何直接使用 Unicode 类别。

11.3.12. 使用 Unicode 属性

Unicode 中的字符属于不同的类别, 例如字母或数字或标点符号。类别可以有额外的细化级别, 例如字母可以是小写或大写。

Raku 中的正则表达式为我们提供了一个基于 Unicode 类别的字符类机制 。要创建这样的一个类, 请使用一对包含冒号的角括号, 后面是类别名称的大写字母。要指定子类别, 请添加相应的小写字母。

例如, 匹配字母的字符类是 <:L>, 而匹配大写字母的字符类是 <:Lu>。在下面的例子中, 我们用这些字符类来匹配几个字母:

for <A a B b Ω ω 1 2 * ^ > -> $char {
    say "$char ~~ <:L>"  if $char ~~ / <:L> /;
    say "$char ~~ <:Lu>" if $char ~~ / <:Lu> /;
}

这段代码循环遍历一个有不同字符的列表, 并报告它们是否匹配 <:L><:Lu>:

A ~~ <:L>
A ~~ <:Lu>
a ~~ <:L>
B ~~ <:L>
B ~~ <:Lu>
b ~~ <:L>
Ω ~~ <:L>
Ω ~~ <:Lu>
ω ~~ <:L>

从输出中可以看到, 所有的字母都通过了 <:L> 过滤器, 大写字母与 <:Lu> 字符类相匹配, 非字母字符在两个测试中都不匹配。

在 Raku 中, 与 Unicode 相关的字符类有两个名称, 分别是短名称和长名称, 可以互换。可以像这样使用长名称编写相同的代码:

for <A a B b Ω ω 1 2 * ^ > -> $char {
    say "$char ~~ <:L>"  if $char ~~ / <:Letter> /;
    say "$char ~~ <:Lu>" if $char ~~ / <:Uppercase_Letter> /;
}

下表列出了所有与 Unicode 类别对应的字符类。在此表中, 单字母类别后面是两个字母的子类别。请注意, 有些字母组合不能直接从类别名称中扣除; 例如, Unicode 中的 Punctuation 字符类, Open 类别的字符类被称为 Ps, 因此它在 Raku 中是 <:Ps>:

Short name

Long name

Category

Comment

<:L>

<:Letter>

Letter



<:Ll>

<:Lowercase_Letter>

Letter, lowercase

a, b 等等 

<:Lu>

<:Uppercase_Letter>

Letter, uppercase

A, B 等等 

<:Lt>

<:Titlecase_Letter>

Letter, titlecase

Ligatures such as Lj 

<:Lm>

<:Modifier_Letter>

Letter, modifier

某些变音符号, 例如 ̃ 

<:Lo>

<:Other_Letter>

Letter, other

来自希伯来语, 例如 ݢ 或阿拉伯语, 例如 א, 的字母 

<:M>

<:Mark>

Mark

<:Mn>

<:Nonspacing_Mark>

Mark, nonspacing

重音符, 例如 ̀ 或 ́

 <:Mc>

<:Spacing_Mark>

Mark, spacing combining

某些组合字符 

<:Me>

<:Enclosing_Mark>

Mark, enclosing

符号, 围绕其他符号, 如旧的西里尔标志 1000: ҈ 

<:N>

<:Number>

Number



<:Nd>

<:Decimal_Number>

Number, decimal digit

0, 1, 2 等等 

<:Nl>

<:Letter_Number>

Number, letter

例如, 罗马数字(像 VIII 那样的单独字符) 

<:No>

<:Other_Number>

Number, other

其他与数字相关的字符, 如 1⁄4

 <:P>

<:Punctuation>

Punctuation



<:Pc>

<:Connector_Punctuation>

Punctuation, connector

例如, 字母上方的圆弧: ⁀ 

<:Pd>

<:Dash_Punctuation>

Punctuation, dash

像破折号这样的符号: — 

<:Ps>

<:Open_Punctuation>

Punctuation, open

开口符号对, 例如 [ 或 (, 等等 

<:Pe>

<:Close_Punctuation>

Punctuation, close

闭合符号对, 例如 ] 或 ), 等等 

<:Pi>

<:Initial_Punctuation>

Punctuation, initial quote

起始引号字符, 如 « 

<:Pf>

<:Final_Punctuation>

Punctuation, final quote

闭合引号字符, 如 »

<:Po>

<:Other_Punctuation>

Punctuation, other

许多传统的标点字符如 ! 或 ?

<:S>

<:Symbol>

Symbol

<:Sm>

<:Math_Symbol>

Symbol, math

数学符号: +, ±, × 等等

<:Sc>

<:Currency_Symbol>

Symbol, currency

货币符号: ¤, €, $ 等等

<:Sk>

<:Modifier_Symbol>

Symbol, modifier

变音符号, 例如 ̧ 或 ̈

<:So>

<:Other_Symbol>

Symbol, other

不属于其他类别的符号: ©, ® 等等

<:Z>

<:Separator>

Separator

<:Zs>

<:Space_Separator>

Separator, space

各种类型的空白

<:Zl>

<:Line_Separator>

Separator, line

行分隔符的唯一符号, 代码为 0x8232

<:Zp>

<:Paragraph_Separator>

Separator, paragraph

段落分割符的唯一符号, 代码为 0x2029

<:C>

<:Other>

Other

<:Cc>

<:Control>

Other, control

控制字符, 例如 BELL

<:Cf>

<:Format>

Other, format

不同的格式字符

<:Cs>

<:Surrogate>

Surrogate

少量代理字符

11.3.13. 字符类算术

Raku 提供了强大的功能, 允许你使用集合运算创建新的字符类。这些是 - 用于并集的 +|, 用于差集的 -, 用于交集的 &, 用于异或运算的 ^。此外, 还可以使用 -! 来否定字符类。

让我们来看一些例子。首先, 创建一个同时匹配小写字母和数字的合并类:

for <a A 3> -> $char {
    say "$char is a lowercase letter or a digit"
        if $char ~~ / <:Ll + :Nd> /;
}

该程序打印匹配的字符 a3。大写的 A 不匹配, 因为它既不是小写字母也不是数字。

在另一个例子中, 我们通过从所有字母的集合中减去小写字母来重新发明大写字母:

for <a A 3> -> $char {
    say "$char is an uppercase letter" if $char ~~ / <:L - :Ll> /;
}

现在, 取所有的小写字母并去掉所有的元音:

for 'a'..'z' -> $char {
    say "$char is consonant" if $char ~~ / <:Ll - [aoeiu]> /;
}

(如果你想用其他语言做实验, 请小心这个方法, 因为 <:Ll> 包括了英文字母以外的字母。)

如果你需要匹配除给定字符以外的任何字符, 减法就很有用了。例如, 这就是你如何匹配英语字母表中, 不是原始拉丁语, 不包含一些现代英文字母的任何内容:

for 'A'..'Z' -> $char {
    say "$char is pure Roman" if $char ~~ / <[A .. Z] - [GJUWY]> /;
}

要否定一个字符类, 在其前面加上一个减号:

say 'OK' if 'x' ~~ / <-[abcdef]>/; # OK

可以使用感叹号来否定 Unicode 预定义字符类:

say 'OK' if 'x' ~~ / <:!Lu>/; # OK

字符类本身匹配单个字符。为了使正则表达式更强大, 让我们研究一下量词。

11.4. 使用量词创建可重复模式

量词修饰它前面的原子并要求特定次数的重复。原子是一个字符或字符类, 或字符串字面量或组(我们稍后将在本章的"使用捕获提取子字符串”一节中讨论组)。

+ 量词允许前一个原子被重复一次或多次。例如, 正则表达式 /a+/ 可以与单个字符 a 匹配, 也可以与包含两个字符 aa, 或三个字符 aaa 或更多的字符 aaaaaa 的字符串匹配。但是, 它不会与完全不包含 a 字符的字符串匹配。

* 量词允许任意数量的重复, 包括零次。因此, /a*/ 正则表达式与诸如 bdef、abc 或 baad 等字符串匹配。当然, 单个 /a*/ 可能没有那么好用; * 量词的更自然的用例是在其他子字符串之间, 比如 /ab*c/。这个正则表达式与 ac、abc 或 abbc 匹配。

? 量词要求一个原子要么出现一次, 要么不出现。考虑正则表达式 /colou?r/, 它与英式和美式拼写中的单词 colour 和 color 相匹配。

也可以使用后面跟着重复次数或范围的 ** 量词来要求给定的重复次数。例如, /'a' 'b' ** 3..4 'c' / 匹配包含 bbb 或 bbbb 的子字符串, 例如, abbbc 匹配, 但不匹配 abbc 或 abbbbbbbc。在 ^ 的帮助下, 可以排除范围的边缘 - /'a' 'b' ** 3..^10 'c'/ 将与包含 3 到 9 个后续字母 a 的子字符串匹配。开区间, 例如 b ** ^10(0 到 9 个 b)或 b ** 3..* (三次或三次以上) 也是允许的。

还有另一对量词, %%%, 它们的使用方式有点不同。他们与 +, ?, * 和 ** 量词一起工作, 并要求在重复的序列中用一个分隔符来分隔, 分隔符出现在 %%% 的右边。在 %% 的情况下, 分隔符也可以出现在最后一个重复元素之后。请看下面的例子:

say 1 if 'a,b,c,d' ~~ / \w+ % ',' /;
say 1 if 'a,b,c,d' ~~ / \w ** 2..3 % ',' /;
say 1 if 'a,b,c,d' ~~ / \w ** 2..3 %% ',' /;

该代码与第一种情况下的所有四个字母-a, b, c, d 匹配, 与第二种情况下的前三个字母-a,b,c 匹配。在最后一种情况下, 只匹配三个字母, 但是 c 和 d 之间的逗号也会被消耗掉-a,b,c,

11.4.1. 贪婪

上面描述的量词默认情况下是贪婪的。这意味着他们会尽可能多地匹配源字符串中的字符。例如, 在匹配 'bbb' ~~ /b*/ 中, 所有的三个字符都会被正则表达式消耗掉。同样的, + 量词也会尽可能多的消耗重复字符。

有时候这种行为并不理想。考虑一个用于从 HTML 标签中选择属性的正则表达式。从给定的字符串 <a href="index.html" class="menu"> 中, 我们要提取属性的值, 即引号内的字符串。试图创建这样一个正则表达式, 如 / \" .* \" / 将提取第一个引号和最后一个引号之间的整个子串- 即 "index.html" class="menu"。这是因为 * 量词不想在第一个属性值的末尾停止, 而是继续消耗字符。

它更进一步, 甚至通过最后一个引号和闭合角括号。在这之后, 就不会剩下任何字符了, 但正则表达式希望与双引号匹配。所以, 正则表达式引擎会执行回溯, 将消耗的字符返回到字符串中, 直到正则表达式被满足或者失败。

为了防止量词的贪婪性, 请在其后添加一个问号:

/ \" .*? \" /

现在, 它只匹配第一个属性值(包括引号) - "index.html"

注意, 在正则表达式中, " 字符应该被转义 - \"。正如我们在*字面量*一节中学到的那样, 只有字母数字字符和下划线符号与他们自身匹配。另外, 我们也可以通过在一对单引号内放入双引号来创建一个引号字符串:

/ '"' .*? '"' /

反贪婪行为可以应用于任何量词, 甚至可以应用于 ?。在这种情况下, 如果可能的话, 修改后的量词 ?? 将尝试不匹配任何东西。因此, 如果是字符串 abc 针对 /ab?/ 正则表达式进行测试, 那么它的子串 ab 将被匹配。对于非贪婪的 /ab??/ 正则表达式, 只有 a 会匹配。当然, 如果我们修改正则表达式, 使 b?? 必须与某些东西匹配, 就像 /ab??c/ 那样, 那么它就会这样做。

11.5. 使用捕获提取子字符串

只匹配正则表达式还不够。如果没有提取出与正则表达式模式一致的子字符串的能力, 那么正则表达式的真正力量不完整的。 将字符串的一部分保存在特殊变量中, 这就是所谓的捕获

11.5.1. 捕获组

在 Raku 中, 捕获是通过将正则表达式的一部分放到圆括号中来实现的。圆括号在正则表达式中具有双重含义。我们已经看到了在电话号码中用括号来分组的用法。

让我们继续以提取 HTML 属性的值为例。我们现在要打印出这些值。所以, 我们需要创建一个正则表达式并标记出我们想要提取的数据的边界。将捕获的数据被放入变量 $0, $1 等变量中。数字索引从零开始, 对应于正则表达式中捕获括号的顺序编号:

my $str = q{<a href="index.html" class="menu">};
$str ~~ / \" (.*?) \" .* \" (.*?) \" /;
say $0;
say $1;

运行这段代码, 看看它打印出了什么:

⌈index.html⌋
⌈menu⌋

的确, 我们得到了我们想要的 HTML 属性的值。它们包含了 $str 变量的子字符串, 这些子字符串与圆括号中的正则表达式 - (.*?) 中的部分相匹配。因为它们出现了两次, 所以有两个变量被填充了。

作为一个侧面的说明, 此时我们可以说, 非贪婪量词并不总是唯一的表达方式。而不是说"在双引号前尽量少取一些字符", 我们可以要求"尽量夺取一些不是引号的字符", 并使用否定字符类:

my $str = q{<a href="index.html" class="menu">};
$str ~~ / \" (<-[\"]>+) \" .* \" (<-[\"]>+) \" /;
say $0;
say $1;

同样, 即使是在字符类内部, 引号也必须转义。

11.5.2. Match 对象

你可能已经注意到, 在前面的例子的输出中, 真实的子字符串显示在一对方角括号之间。这是因为变量 $0$1 的内容不是一个裸字符串, 而是一个 Match 类型的对象。在使用 sayprint 函数打印时, Match 对象也是以这种方式格式化的。

$0, $1 等变量实际上是完整形式 $/[0], $/[1] 等快捷方式的变体。$/ 变量就是 Match 对象。它是接收正则表达式匹配结果的默认变量。它包含了所有的捕获字符串, 以及与整个正则表达式匹配的子字符串。要获取单独的捕获, 需要使用诸如 $/[1]$1 的索引。

那么, 让我们打印上一个例子中 $/ 的值:

my $str = q{<a href="index.html" class="menu">};
$str ~~ / \" (.*?) \" .* \" (.*?) \" /;
say $/;

我们将得到以下内容:

⌈"index.html" class="menu"⌋
 0 => ⌈index.html⌋
 1 => ⌈menu⌋

第一部分 ⌈"index.html" class="menu"⌋ 包含了正则表达式匹配的整个子字符串。接下来是一些与捕获圆括号相匹配的索引元素。

当匹配对象在字符串内插值时, 打印时不加括号:

'April 2017' ~~ / (\d+) /;
say "Year is $0";

在这里, $0 将被字符串化, 输出将是 Year is 2017

11.5.3. 命名捕获

当正则表达式有一个或两个以上的捕获组, 并且如果正则表达式中存在着其他的备选项时, 那么捕获就会变得更加棘手。例如, 考虑以下正则表达式:

my $re = rx/ (<[a..z]>+) || (<[A..Z]>) (\d) /;

它包含两个备选项, 但每个分支中的捕获组的编号是不同的。(下一节将详细描述备选分支。)

现在, 提供与 <[a..z]+> 或与 <[A..Z]>\d 正则表达式匹配的字符串:

'letter' ~~ $re;
'A5'     ~~ $re;

第一次匹配后, 将只定义 $0。打印 $1 将得到 Nil。在第二例子中, 两个变量都会将包含一个值。也不容易推断出每种情况下正则表达式匹配的是哪一部分。如果两个备选项的捕获数量相同, 那么直接检查有定义的变量的数量的方法就不起作用了。

Raku 允许你给捕获起名字。下面的例子显示了用于命名捕获的语法。

my $re = rx/ $<type>=(<[a..z]>+) ||
             $<letter>=(<[A..Z]>) $<size>=(\d)
           /;

'letter' ~~ $re;
say $/;

'A5' ~~ $re;
say $/;

在这个例子中, 我们还看到了格式化长正则表达式的方法, 使其更容易阅读, 更符合逻辑。

检查一下输出结果:

⌈letter⌋
 type => ⌈letter⌋
⌈A5⌋
 letter => ⌈A⌋
 size => ⌈5⌋

你可以看到, Match 对象现在有了命名对, 而不是数字索引。这些名称也可以像把 $/ 变量当作散列一样使用:

'letter' ~~ $re;
say $<type>;

'A5' ~~ $re;
say $<letter>;
say $<size>;

<$name> 记法是 $/<name> 的简写。

11.6. 在正则表达式中使用备选项

让我们再次看看我们原生的匹配电话号码的正则表达式:

rx/ \+? (\d || \s || \-)+ /

圆括号内的竖直条将组内的不同变体分开。它可以是 \d, 或者是 \s, 或者是 \- 。在正则表达式上下文中, 这就是所谓的备选。相应地, 不同的变体也被称为备选项

在 Raku 中, 正则表达式中有两种形式的备选分隔符-单个竖直条 | 和双竖直条 ||。使用单个竖直条, 最长的变体总是获胜。在双竖直条中, 第一个匹配的备选项获胜。

在电话号码的例子中, 每个备选项的长度正好是一个符号。所以, 在这里, ||| 之间没有任何区别。在其他情况下, 运算符的选择可能会大大改变结果。

例如, 拿下面的例子中的两个正则表达式为例, 将形容词 big 的形式与它们进行匹配:

for <big bigger biggest> -> $form {
    say "Testing '$form'";

    $form ~~ / big | bigger | biggest /;
    say $/;

    $form ~~ / big || bigger || biggest /;
    say $/;
}

这个程序的输出是:

Testing 'big'
⌈big⌋
⌈big⌋
Testing 'bigger'
⌈bigger⌋
⌈big⌋
Testing 'biggest'
⌈biggest⌋
⌈big⌋

分析输出, 我们可以看到, 使用单个竖直条的正则表达式, 每次都选择最长的备选项 - big、bigger、biggest。使用另一个带双竖条的正则表达式, 第一个匹配的总是胜出 - big, 其他所有的变体都没有尝试过。你可以玩一个这个代码, 改变正则表达式中备选项的顺序, 看看它是如何改变这个程序的行为的。

例如, 如果你把变体按长度的相反顺序列出, 那么两个正则表达式的输出都是类似的:

$form ~~ / big | bigger | biggest /;
say $/;

$form ~~ / biggest || bigger || big /;
say $/;

备选项通常只是正则表达式的一部分。在我们的例子中, 有一个序列 \+? 用于匹配电话号码中的可选加号。这并不属于备选项列表的一部分。为了标记备选的边界, 请使用括号。

括号也会创建一个原子, 这个原子随后被 + 量词修饰, 它被应用于括号内的正则表达式的整个部分。

当括号只用于分组而不需要捕获时, 请使用方括号:

my $phrase = 'Eat an apple, please';

$phrase ~~ / ( apple || pear ) /;
say $0;

say 'Healthy' if $phrase ~~ / [ apple || pear ] /;

在这里, 第一个例子提取了 $parse 中提到的水果, 而第二个匹配只检查字符串中是否包含两个想要的单词中的一个, 并不保存在任何地方。在第二个匹配之后, $0 变量将包含 Nil

11.7. 用锚点定位正则表达式

在许多情况下, 必须在字符串中应用一个正则表达式, 使其开头与字符串的开头重合。例如, 如果电话号码中包含 + 字符, 它只能出现在第一个位置。

Raku 正则表达式具有所谓的锚点-特殊字符, 它可以将正则表达式锚定到字符串或逻辑行的开头或结尾。

11.7.1. 匹配行或字符串的开头和末尾

让我们修改一下电话号码的正则表达式, 使其与包含潜在电话号码的整个字符串匹配:

/ ^ \+? <[\d\s\-]>+ $ /;

这里, ^ 是匹配字符串开头的锚点, 它不会消耗任何字符。在正则表达式的另一端, $ 要求正则表达式的结尾与字符串的结尾匹配。因此, 一个有效的电话号码, 比如说 +49 20 102-14-25 会通过过滤器, 而像 124 + 35 - 36 这样的数学表达式则不会。

为了提高可见性, 可以在代码中把锚点写在单独的行中:

my $rx = /
    ^
        \+?
        <[\d\s\-]>+
    $
/;

say 'OK'     if '+49 20 102-14-25' ~~ $rx; # OK
say 'Not OK' if '124 + 35 - 36'   !~~ $rx; # Not OK

^$ 都匹配字符串的边界(字符串作为变量)。如果你需要匹配逻辑行(如果字符串中包含几行由 \n 分隔的行), 请使用另一对锚点 - ^^$$

在下一个例子中, 我们要选择一个菠萝的颜色:

my $fruits = "yellow banana\ngreen pineapple\nred apple";

$fruits ~~ / (\w+) \s pineapple $$ /;
say $0;

这个代码打印 green, 因为 (\w+) 与菠萝行中的那个单词匹配。行尾锚点 ` 匹配到了那一行的末尾。这个结果不取决于水果清单中的行的顺序。如果使用单个 `$` 而不是 `, 那么只有当 green pineapple 位于整个字符串的末尾时, 正则表达式才会匹配。

11.7.2. 匹配单词边界

要匹配单词边界, 请使用以下锚点之一:

锚点

描述

w>

任何单词边界

<<

单词的开头

>>

这些锚点匹配单词的边界, 不消耗字符。例如, /<|w> apple / 匹配 apple 但是不匹配 pineapple

<|w> 锚点有一个与之相反的对, <!|w>, 它与任何不是单词边界的东西匹配。这个锚点也不消耗字符, 所以 / o <!|w> p / 匹配 opera

要更精确地指定边界, 可以使用 <<>>:

my @words = 'fourty-four' ~~ m:g/ << four /;
say +@words;

@words = 'fourty-four' ~~ m:g/ four >> /;
say +@words;

在这些例子中, 第一个匹配会找到两个单词, 而在第二个尝试中, 只找到一个以 four 结尾的单词。

11.8. 使用断言进行向前查看和向后查看

操纵正则表达式流的另一个主题是断言。在匹配过程中, 该模式不消耗源字符串中的字符。断言有助于在当前位置做一些检查, 而不会吃掉字符。

在 Raku 的正则表达式中有两种类型的断言 - 向前查看向后查看。每一种断言都可以被否定。在下面的表格中, 列出了所有可能的组合:

正向断言

否定断言

向前查看

<?before X>

<!before X>

向后查看

<?after X>

<!after X>

被放置在正则表达式中, 向前查看断言 <?before X> 检查在这个位置后面跟着的字符是否是 X。如果是这样, 那么断言就成功了, 正则表达式引擎继续工作。其他断言的行为遵循相同的逻辑考虑, 例如:

'Etiquette' ~~ / (.*?) <?after 'qu'> (e .*) /;
say $/;

它打印出这个结果:

⌈Etiquette⌋
 0 => ⌈Etiqu⌋
 1 => ⌈ette⌋

讨论中的单词被拆成两部分。该规则在 e 处分开, 其后是 ququ 这两个字符已被第一个 .*? 捕获块消耗掉了, 但向前查看断言仍然能够查看源字符串中是否有 qu 这个序列存在。

11.9. 使用副词修饰正则表达式

副词是正则表达式修饰符。它们是冒号前缀字母, 可以改变正则表达式的行为。

副词以两种形式存在 - 短形式长形式 - 并出现在正则表达式的前面, 例如:

say 'OK' if 'ABCD' ~~ m:i/ abcd /;

注意, 在这个例子中, 当副词应用于整个正则表达式时, 需要使用 mrx。另外, 副词可以放在正则表达式里面。在这种情况下, 它从它出现的位置开始起作用。这在下一节中关于 :i 副词的例子中得到了证明。

下表列出了所有的副词:

短形式

长形式

描述

:i

:ignorecase

匹配字符是大小写不敏感的

:s

:sigspace

空白是重要的

:p(N)

:pos(N)

从位置 N 开始

:g

:global

全局匹配

:c

:continue

在前一个匹配之后继续

:r

:ratchet

禁用回溯

:ov

:overlap

重叠匹配

:ex

:exhaustive

找到所有可能的匹配

让我们浏览列表并逐一检查每个副词。

11.9.1. i(:ignorecase)

这是最简单的正则表达式副词。它可以让正则表达式不区分大小写。因此, m:i/X/m:i/x/ 这两个正则表达式副词中的每一个都会成功地与 xX 匹配:

my $rx = rx:i/hello/;
say 'Matches' if 'Hello, World!' ~~ $rx;

:i 副词在正则表达式里面时, 只有其后面的部分是不区分大小写的:

say 'No match' if 'HeLLO, World!' !~~ /he :i llo/;
say 'Matches'  if 'HeLLO, World!'  ~~ /He :i llo/;

要停掉该副词的行为, 请使用否定形式:

say 'Matches' if 'HeLLo, World!' ~~ /He :i ll :!i o/;

捕获括号和非捕获括号限制了副词的作用域:

say 'Not OK' if $str !~~ / (:i hello)\, \s world /;
say 'OK' if $str ~~ / [:i hello]\, \s World /;

在这些例子中, :i 只影响第一个单词。

11.9.2. :s(:sigspace)

我们已经多次看到, 在正则表达式中, 额外的空格会被忽略, 而且它们经常被用来使正则表达式更易读。但是在某些情况下, 特别是当正则表达式应该与带空格的字符串匹配时, 最好禁用这个特性, 要求空格按照字面意思匹配。

在下面的例子中, 我们从日期中提取出天、月份和年份这三个部分。由于原始的面向人的字符串中存在空格, 所以我们需要在正则表达式中对其进行处理。默认情况下, 空格是被忽略的, 在正则表达式中应该在预计有空格的地方包含 \s:

my $date = '19 April 2017';
$date ~~ / (\d+) \s (\w+) \s (\d+) /;

say "Year = $2, month = $1, day = $0";

使用 :s 副词, 正则表达式里面的字面值空格将与字符串匹配:

my $date = '19 April 2017';
$date ~~ m:s/ (\d+) (\w+) (\d+) /;

say "Year = $2, month = $1, day = $0";

Raku 的好处是(有些可能令人困惑), 正则表达式周围的空格仍然会被忽略。在上面的例子中, 我们看到第一个斜线之后和最后一个斜线之前都有空格。这些空格不需要匹配。

11.9.3. :p(:pos)

带有 :p:pos 副词的正则表达式从副词的参数指定的位置匹配字符串。从下面的例子中可以清楚地看到这个行为。

默认情况下, 正则表达式从字符串的开头开始匹配:

'pineapple' ~~ / (\w+) /;
say $0; # pineapple

由于 \w+ 的贪婪性, 整个字符串会被消耗掉并匹配。让我们试试跳过几个字符, 并将正则表达式应用到相同的字符串上:

'pineapple' ~~ m:p(4)/ (\w+) /;
say $0; # apple

这次, 只匹配 apple 子串。

:p 副词的索引也表现得像锚点。类似于 ^ 将正则表达式绑定到字符串的开头, :p(N) 副词将正则表达式绑定到给定的位置。比较下面的两个匹配:

'pineapple' ~~ m:p(4)/ (a\w+) /;
'pineapple' ~~ m:p(3)/ (a\w+) /;

其中第一个成功了, 因为它在字符串的第四个位置找到了 a。第二个匹配, 当它在第三个位置看到 e 时立即失败了。

11.9.4. :g(:global)

:g 副词用于全局匹配。正则表达式将被应用到字符串上几次, 每次从上一次匹配停止的位置开始。

例如, 让我们把一个句子分成单独的单词:

my @words = 'Hello, World!' ~~ m:g/ (\w+) /;
say join ';', @words; # Hello;World

还记得本章的 Match 对象一节中提取 HTML 属性的例子吗? 为了得到两个值, 这个正则表达式包含两个相同模式的副本:

my $str = q{<a href="index.html" class="menu">};
$str ~~ / \" (.*?) \" .* \" (.*?) \" /;

为了避免这种情况, 并使正则表达式更通用, 可以使用全局匹配:

my $str = q{<a href="index.html" class="menu">};
$str ~~ m:g/ \" .+? \" /;
say ~$/;

这个程序会打印出 "index.html" "menu", 它们是从字符串中提取的两个匹配元素。~$/ 语法对 Match 对象进行了字符串化; 这个动作相当于在双引号字符串中对对象进行插值, 就像我们之前所做的那样。

:i 修饰符不同, 你不能把 :g 放在正则表达式里面。

11.9.5. :c(:continue)

:c 副词要求从上一个位置继续。

考虑一下上一节中关于 :g 副词的例子。我们可以只匹配几次, 而不是全局匹配:

my $str = q{<a href="index.html" class="menu">};

$str ~~ m/ \" .+? \" /;
say ~$/;

$str ~~ m:c/ \" .+? \" /;
say ~$/;

如果没有 :c, 第二个匹配将从字符串的开头开始, 它会返回与第一个匹配相同的结果。如果有了 :c, 它将以相同的字符串继续匹配, 所以第二个属性将被捕获。

这个副词可以接收一个索引作为参数。在这种情况下, 相应的正则表达式匹配将从给定位置开始。这在下面的例子中得到了证明:

my $str = q{<div class="menu"><div class="item">};
$str ~~ m:c(10)/ 'class="' .*? '"' /;
say ~$/;

正则表达式被应用于从第 10 个字符开始的字符串。在这种情况下, 第一个潜在的匹配被跳过, 程序找到第二个类:

class="item"

11.9.6. :r(:racate)

这个副词在正则表达式中禁用回溯。在贪婪一节中, 我们已经看到, 当一个贪婪量词消耗了太多字符后, 正则表达式引擎是如何回滚的, 以便用更少的字符再做一次尝试。:r 副词不会让这种情况发生。它解释了 :ratchet 这个名字的含义 - 它只会往前走。

例如, 在下一个例子中, 创建的正则表达式是为了查找所有以 0 结尾的数字:

for 1..100 {
    .say if / \d+ 0 /;
}

这个代码打印出 10、20 等四舍五入的数字。如果使用 :r, 什么都不会被打印出来, 因为 \d+ 会消耗掉数字中的所有数字, 而且 :r 没有留下与 0 匹配的空间。

11.9.7. :ov(:overlap)

:ov 副词改变了正则表达式应用于字符串的方式, 这样, 所有重叠的、在每个位置上最长的匹配都会被找到。

让我们以查找 pi 的值内的所有以 1 开头和结尾的所有数字为例来说明这个问题:

my $pi =
   '3.1415926535897932384626433832795028841971693993751058209749445923078164';
my @a = $pi ~~ m:g/1.*?1/;
say ~@a;

该代码打印的值如下:

141 1971 10582097494459230781

你可能会注意到, 它在原始值的不同部分中找到了序列, 而且它们是不相交的。

现在, 让我们添加 :ov 副词。要在一个已经有副词的正则表达式中添加另一个副词, 只需将其附加到前一个副词上即可:

my $pi =
   '3.1415926535897932384626433832795028841971693993751058209749445923078164';
my @a = $pi ~~ m:g:ov/1.*?1/;
say ~@a;

这一次的输出是不一样的:

141 15926535897932384626433832795028841 1971 1693993751 10582097494459230781

每个下一个值与前一个值共享相同的字符 1。结果包含了前一个例子中的所有值, 但也包括介于两者之间的值, 这些值也匹配 1.*?1 模式。

如果我们去掉反贪婪量词, 那么 :ov 副词的性质就更加明显了。在这种情况下, 正则表达式 m:g:ov/1.*1/ 在每一个位置都会返回最长的匹配, 在这里它看到的是 1。当它到达字符串的末尾时, 子匹配会越来越短:

14159265358979323846264338327950288419716939937510582097494459230781 159265358979323846264338327950288419716939937510582097494459230781 19716939937510582097494459230781 16939937510582097494459230781 10582097494459230781

如果我们把模式改成这个模式呢?

my @a = $pi ~~ m:g:ov/1.*?2/;

将它应用到 $pi 后, 程序打印出以下行:

141592 1592 19716939937510582 16939937510582 10582

这一次, 重叠性更强 - 比如说 1592 这个字符串, 完全包含在第一个匹配 141592 中。

11.9.8. :ex(:exhaustive)

这个副词尽会尽可能多的找到子串, 考虑到所有的可能性, 包括重叠值和不同长度的子串。这在一定程度上类似于 :ov 副词的正则表达式行为, 但并不选择最长的匹配。

让我们用同样的模式 /1.*1/pi 值上测试这个副词(但这次我们将取一个较短的字符串):

my $pi = '3.141592653589793238462643383279502884197169';
my @a = $pi ~~ m:g:ex/1.*1/;
say ~@a;

为了节省一点输出空间, 我们采取了一个较短的版本:

1415926535897932384626433832795028841971 1415926535897932384626433832795028841 141 15926535897932384626433832795028841971 15926535897932384626433832795028841 1971

作为练习, 使用另一个具有非贪婪量词 m:g:ex/1.*?1/ 的正则表达式尝试相同的值。

11.10. 使用正则表达式替换和修改字符串

使用正则表达式来匹配字符串, 通常可以从给定的数据中提取一些信息。另一个常见的任务是用不同的字符替换文本中的部分内容。在 Raku 中, s 内置函数就可以完成这个任务。

它需要两个参数, 即一个正则表达式和一个替换。将正则表达式应用于源字符串, 并且模式被匹配时, 匹配的字符串部分会被替换为第二个参数。

考虑一个简单的例子:

my $str = 'Its length is 10 mm';
$str ~~ s/<<mm>>/millimeters/;
say $str; # Its length is 10 millimeters

这里的正则表达式 /[mm]/ 与单词 mm 匹配。第二部分告诉我们要用测量单位的全名来替换它。替换就地发生, 并且修改了原始字符串。

传统上, s 使用斜线作为分隔符, 但也可以使用不同的字符。请看前面的代码中的替换示例:

$str ~~ s|<<mm>>|millimeters|;
$str ~~ s;<<mm>>;millimeters;;

在第二个例子中, 最后两个分号的意思是不同的 - 其中一个是正则表达式和替换部分的分隔符, 而另一个是 Raku 表达式的分隔符。

在替换部分中, 替换文本可以使用变量插值:

my $str = 'Its length is 10 mm';
my $standard-length = 7;
$str ~~ s/\d+/$standard-length/;
say $str; # Its length is 7 mm;

s/// 的第一部分中捕获的值也可用于替换:

my $date = '20070419';
$date ~~ s/ (\d ** 4) (\d\d) (\d\d) /$2.$1.$0/;
say $date;

s 中的正则表达式将日期分割为年、月、日三部分, 并以不同的顺序组合成不同的替换模式。

11.11. 总结

在本章中, 我们讨论了 Raku 中的正则表达式。它们与 Perl 5 中的正则表达式有许多共通之处, 但也提供了许多令人着迷的新东西。我们研究了构建正则表达式和匹配文本的方法, 学会了如何通过编写自定义字符类或内置字符类来扩展正则表达式引擎的功能。我们还研究了 Raku 在 Match 对象中存储结果的方法, 以及如何使用正则表达式在字符串中进行替换。

在下一章中, 我们会遇到一个更强大的工具, 它极大地扩展了正则表达式, 即 grammar。

12. Grammars

Raku 带来了一个非常有用和强大的机制来实现正则表达式—grammar。

Grammar 是 Raku 中的一种迷你语言, 允许你描述其他语言的规则(包括 Raku 本身)。使用 grammar, 创建解析器、翻译器或领域特定语言(DSL)或编程语言的编译器, 甚至是人类语言的解析器都非常容易。

在本章中, 我们将通过为其创建 Raku 编译器的子集来学习 Raku 的 grammar。本章将介绍以下主题:

  • 创建 grammar

  • grammar 的元素 - rule 和 token

  • TOP 规则

  • 空白处理

  • 解析文本

  • 使用 Action

  • 使用抽象语法树(AST)

本章假定你熟悉正则表达式。如果你还没有读过第11章, 正则表达式, 现在正是这样做的合适时机。另外, 了解在 Raku 中组织类是必需的, 这包含在第8章面向对象的编程中。

12.1. 创建 grammar

与正则表达式一样, grammar 定义了一些规则来从给定的文本中提取信息。正则表达式的一个典型应用是在文本块中找到片段, 并将其分分割成有意义的片段, 例如, 查找电子邮件或检查其格式是否正确。Grammar 有一个更大的目标 - 他们的任务通常是阅读整个文本并理解所有的内容。例如, 如果将 grammar 应用于以某种编程语言编写的源代码 , grammar 必须检查其有效性, 并创建该程序的语法树。这种区别仍然是一种约定 - grammar 可以解析小的文本部分, 就像正则表达式可以用来分析大的文本部分一样。

Raku 中 grammar 的语法就像定义一个类一样。Grammar 从 grammar 关键字开始:

grammar G {
}

这个 grammar 是空的, 无法应用到文本中。我们必须添加起始规则, 这将是 grammar 的入口点。

Grammar 包含了 ruletoken。我们将在本章中详细讨论它们, 但目前我们需要创建主规则, 也就是 grammar 应用于文本的第一个规则:

grammar G {
    rule TOP {
        .*
    }
}

如你所见, 规则类似于类中的方法。与方法不同的是, 规则在其主体块中包含了正则表达式。此示例中的 TOP 规则匹配了模式 .*, 它实际上匹配了所有的东西。这就足够了, 首先我们要创建一个最小的 grammar, 看看如何将其应用到文本上。TOP 是一个预定义的名字, 你可以给 grammar 中的第一个规则起一个名字。

正如本章开头提到的, 我们将为Raku 的小型子集创建一个解析器(为了简单起见, 我们将忽略真正的 Raku grammar 中的一些边缘情况)。因此, 我们的第一个 grammar 要解析的文本可能是这样的:

my $text = 'my $x;';

要使用我们的 grammar G 来解析 $text, 可以调用 parse 方法对其进行解析:

my $result = G.parse($text);
say $result;

其结果是一个复杂的 grammar 对象, 其中包含了被匹配的文本。在我们这个简单的例子中, TOP 规则消耗了整个文本, 字符串化的值看起来像这样:

「my $x」

应用一个 grammar 至少有三个目标:

  1. 检查源文本在文法上是否正确。

  2. 将文本分割成语法元素。

  3. 根据语言规则执行 action。

在本章中, 我们将对这三个部分进行编程, 但我们的第一个任务是学习如何检查文本是否与 grammar 匹配。

12.1.1. 匹配 grammar

我们的示例程序将在后面的章节中逐行解析, 看起来是这样的:

my $x;
$x = 5;
say $x; # 5

my $y;
$y = $x;
say $y; # 5

my $z;
$z = $x + $y;
say $z; # 10

这是一个有效的 Raku 程序, 我们必须创建解析和执行这个程序的 grammar。

让我们把参考程序保存在一个单独的文件中, 比如说 refer.pl, 使用 parsefile 方法而不是 parse 方法来解析它:

my $result = G.parsefile('refer.pl');
say $result;

这个程序打印了整个文件的内容, 因为此 TOP 规则仍然匹配它得到的一切。为了确保 grammar 解析整个文件, 让我们添加锚点来绑定文本的开头和结尾:

 grammar G {
    rule TOP {
        ^ .* $
    }
}

你可以在规则中随意使用空格以使正则表达式更清晰:

rule TOP {
    ^
        .*
    $
}

下一个子目标是分别解析每一行代码。准确地说, grammar 不应该解析源文本的行, 而应该解析用分号分隔的指令。解析器不应该依赖于代码中添加了多少个空格和换行符。

现在我们要用 grammar 来形式化刚才所说的内容。源程序是一个用分号分隔的语句列表。让我们修改一下 TOP 规则来表达:

rule TOP {
    ^
        (.*? ';')*
    $
}

我们从最小的程序开始:

my $x;
$x = 5;
say $x;

如果你查看打印 $result 的程序的输出, 你会看到整个文件的内容, 后面是像这样的单独指令:

⌈my $x;
$x = 5;
say $x;⌋
 0 => ⌈my $x;⌋
 0 => ⌈
$x = 5;⌋
 0 => ⌈
say $x;⌋;

输出结果有点乱, 但我们可以看到, 源文件中的每一条语句都被放到了 $result 中的一个单独的元素中, 这与 Match 对象是如何包含匹配字符串与正则表达式匹配的部分相似。

提取的片段包含了前导空格, 我们可以通过在 grammar 中允许它们的存在, 从而很容易地将其抑制:

rule TOP {
    ^
        [\s* (.*? ';')]*
    $
}

为避免不必要的捕获, 使用了方括号。现在的输出看起来更清晰了:

⌈my $x;
$x = 5;
say $x;⌋
 0 => ⌈my $x;⌋
 0 => ⌈$x = 5;⌋
 0 => ⌈say $x;⌋

引用程序中的所有语句都被捕获了; 我们只需要让 grammar 忽略注释就可以了:

rule TOP {
    ^
        [\s* (.*? ';') ['#' <-[\n]>* ]? ]*
    $
}

正则表达式的附加部分 ['#' <-[\n]>* ]? 可以找到以 # 字符开头的可选子字符串, 并一直到行尾(换句话说, 它们包含非 \n 字符)。

TOP 规则的正则表达式越来越复杂, 所以是时候把它分成几个部分了, 以使整个 grammar 更具可读性和可维护性。

12.2. 使用 rule 和 token

Raku 中的 grammar 提供了一种非常有用的方法, 可以将 grammar 元素分成若干部分。让我们用它来说明 grammar 元素。

复杂的正则表达式 \s* (.*? ';') ['#' <-[\n]>* ]? 包含两个部分 - 提取语句的正则表达式和用于注释的正则表达式。我们将它们提取成单独的规则。单个 rule 描述了 grammar 中的一小部分, 可以引用其他规则。请看下面的例子:

grammar G {
    rule TOP {
        ^
            [ <statement> \s* <comment>? ]*
        $
    }
    rule statement {
        .*? ';'
    }
    rule comment {
        '#' <-[\n]>*
    }
}

现在 TOP 规则更加清晰了, 你马上就能看到程序是一连串的语句, 后面有可选的注释(我们的 grammar 不允许没有语句的注释)。

到目前为止, grammar 已经完全解析了 refer.pl 文件, 但我们可以更进一步。我们可以提取语句, 接下来的任务是理解它们。现在让我们逐行解析文件, 添加新的行和可以解析语句的 grammar 规则一起加入到文件中。你可以通过将源文本嵌入到我们的主文件中来完成:

my $prog = q:to/END/;
my $x;
END

my $result = G.parse($prog);
say $result;

第一行 my $x 包含一个变量声明语句。由于我们从只能解析变量声明的 grammar 开始, 这里是修改后的 statement 规则:

rule statement {
    <variable-declaration> ';'
}

变量声明是由 my 关键字和变量组成的序列:

rule variable-declaration {
    'my' <variable>
}

这个 variable-declaration 规则包含两个部分, 一个是字面字符串 'my', 另一个是对规则 <variable> 的引用。我们没有明文规定这两部分之间的空格。Raku 中的规则会关心这些空格。因此, 规则可以解析在 my 和变量之间包含一个、两个或多个空格的变量声明。即使是以下字符串也可以被正确解析 - my$x

为了描述一个变量, 我们创建一个 token。token 就像 rule 一样, 但不允许元素之间有空格。所以一个有效的变量名应该是一个没有空格的字符串:

token variable {
    <sigil> <identifier>
}

sigil 既可以是标量 sigil $, 也可以是数组 sigil @。虽然在本章开头的示例程序中我们没有数组, 但让我们在后面要用的 grammar 中先准备一下, 以便在以后的工作中使用这些标量和数组:

token sigil {
    '$' | '@'
}

最后, 描述一个标识符:

token identifier {
    <alpha> <alnum>*
}

在这个 grammar 描述的语言中, 标识符是以字母开头的字母数字字符序列。

使用前面列出的 ruletoken, grammar 解析我们的引用程序的第一行, 它看起来长这样:

⌈my $x;
⌋
 statement => ⌈my $x;
⌋
 variable-declaration => ⌈my $x⌋
  variable => ⌈$x⌋
   sigil => ⌈$⌋
    identifier => ⌈x⌋
     alpha => ⌈x⌋

输出显示了这个程序的解析树。缩进有助于更好地理解结构。在最上层, 我们看到程序 my $x 包含了一个语句 my $x, 它是一个变量声明。变量是 $x, 它包括一个符号 $ 和一个以字母 x 开头的标识符 x

对于更长的变量名, 比如 @array, 解析树将包含所有与 identifier 规则中的 <alnum>* 部分匹配的字母:

variable-declaration => ⌈my @array⌋
 variable => ⌈@array⌋
  sigil => ⌈@⌋
  identifier => ⌈array⌋
   alpha => ⌈a⌋
   alnum => ⌈r⌋
   alnum => ⌈r⌋
   alnum => ⌈a⌋
   alnum => ⌈y⌋

让我们解析文件的第二行, 其中包含了赋值:

my $prog = q:to/END/;
my $x;
$x = 100;
END

assignment 也是一个 statement, 所以要解析它, statement 规则必须知道什么是 assignment:

rule statement {
    [
        | <variable-declaration>
        | <assignment>
    ]
    ';'
}

新的规则包含了由竖直条分隔的备选项列表; 一对方括号将备选项分组, 但不捕获文本。实际上, 第一个竖直条不是必须的, 但加上它可以使代码看起来更有条理。

assignment 规则的第一种方法仅包括数值赋值:

rule assignment {
    <variable> '=' <value>
}
token value {
    <number>
}
token number {
    <digit>+
}

这时, grammar 解析的程序如下:

my $x;
$x = 100;

第二个语句根据 grammar 进行解析, 形成了以下解析树:

statement => ⌈$x = 100;
⌋
 assignment => ⌈$x = 100⌋
  variable => ⌈$x⌋
  sigil => ⌈$⌋
  identifier => ⌈x⌋
   alpha => ⌈x⌋
  value => ⌈100⌋
   number => ⌈100⌋
    digit => ⌈1⌋
    digit => ⌈0⌋
    digit => ⌈0⌋

这个程序的第三行是 say $x;。让我们把这种语句称为 say-function, 并为它实现规则:

rule say-function {
    'say' <variable>
}

如你所见, 这非常简单, 因为我们已经有了一个解析变量的规则。最后, 必须将这个新的规则添加到 statement 规则中的备选列表中:

rule statement {
    [
        | <variable-declaration>
        | <assignment>
        | <say-function>
    ]
    ';'
}

语句被成功解析:

statement => ⌈say $x;
⌋
 say-function => ⌈say $x⌋
  variable => ⌈$x⌋
   sigil => ⌈$⌋
   identifier => ⌈x⌋
    alpha => ⌈x⌋

现在我们休息一下, 让我们的编译器不仅可以解析程序, 还可以执行程序。这里, action 就应运而生了。

12.3. 使用 action

Grammar 本身并不只是解析源文本还从中提取数据片段。要使程序执行代码, 需要 action。Grammar 中的 action 是 Raku 中的代码片段, 当 grammar 成功地解析了一条 rule 或一个 token 时, 就会触发 action。

让我们看一下 variable-declaration 规则:

rule variable-declaration {
    'my' <variable>
}

当 grammar 找到源文本中的序列 my $x 时, 该规则就满足了。这时, 你可以添加一个 action:

rule variable-declaration {
    'my' <variable> {say 'Declaring a variable'}
}

action 可以是这样的简单警报, 但它也可能是更复杂的代码, 作为对变量声明的反应而执行。

为了使 action 正常执行, 它需要知道变量的类型(是否包含 $@ 符号)和它的名称。Action 可以访问反应解析片段的当前状态的 Match 对象。可以在 Match 对象中通过名称找到命名子规则; 例如, $<variable> 返回字符串 $x

要深入挖掘, 就拿 Match 对象中的嵌套元素来说吧:

rule variable-declaration {
    'my' <variable> {
        say 'Declaring ' ~
            ($<variable><sigil> eq '$'
             ?? 'a scalar variable'
             !! 'an array') ~
             '"' ~ $<variable><identifier> ~ '"';
    }
}

运行带有两个变量声明的程序的代码:

my $prog = q:to/END/;
my $x;
my @array;
END

G.parse($prog);

它打印了以下内容:

Declaring a scalar variable "x"
Declaring an array "array"

这证明了 grammar 理解了程序, action 得到了正确的变量名和变量类型。

只要我们能够区分标量和数组, 我们就可以把它们的值保存下来, 以便在未来使用。为此, 定义两个全局变量:

my %scalar;
my %array;

这些散列的键对应于变量名。我们将在 grammar 的 action 中填充存储:

rule variable-declaration {
    'my' <variable> {
        given $<variable><sigil> {
            when '$' {
                %scalar{$<variable><identifier>} = 'undefined';
            }
            when '@' {
                %array{$<variable><identifier>} = 'undefined';
            }
        }
    }
}

在程序结束之前, 我们先来打印一下变量存储的内容:

say %scalar;
say %array;

输出告诉我们变量被成功找到, 并在存储中为它们创建了插槽:

{x => undefined}
{array => undefined}

思考下一步的问题。自然, 把值赋给变量就好了。在上面的代码中, 我们使用 given/when 选择器将标量代码和数组代码分开。如果我们想添加对散列的支持呢? 添加一个新的 when 分支是没问题的, 但是我们必须在所有与变量相关的 action 中添加类似的分支。

其中一个解决方案是要求 grammar 区分变量的类型:

token variable {
    | <scalar-variable>
    | <array-variable>
}
token scalar-variable {
    '$' <identifier>
}
token array-variable {
    '@' <identifier>
}

另一个解决方案是把变量存储成为单个变量 %var, 并将其用作二维散列:

my %var;
# .. .
rule variable-declaration {
    'my' <variable> {
        %var{$<variable><sigil>}{$<variable><identifier>} =
            'undefined';
    }
}

这样, %var 容器将得到以下内容:

{$ => {x => undefined}, @ => {array => undefined}}

要给一个变量赋值, 应该写一个 action。Grammar 已经有了赋值规则, 因此添加一个 action 是一件很容易的事情:

rule assignment {
    <variable> '=' <value> {
        %var{$<variable><sigil>}{$<variable><identifier>} =
            ~$<value>;
    }
}

在 action 内部赋值的左侧, 我们看到的代码和我们之前已经在 %var 存储中访问变量的代码一样。右侧的表达式需要额外注意。

目前裸的 $<value> 包含了 G 类型的对象。要使它成为字符串, 需要使用字符串强制操作符(前缀 ~)。这样, 它将被字符串化, 变量得到我们想要保存在那里的值:

{$ => {x => 100}}

为了确认一切正常, 让我们解析一个使用两个标量变量的程序:

my $prog = q:to/END/;
my $alpha;
$alpha = 50;
say $alpha;

my $beta;
$beta = 60;
say $beta;
END

G.parse($prog);
say %var; # {$ => {alpha => 50, beta => 60}}

现在, 让我们把注意力转移到实现 say 函数上。这个任务应该不难, 因为我们已经在其他规则和 action 中拥有了所有的代码片段。只要把它们组合在一起就可以了:

rule say-function {
    'say' <variable> {
        say %var{$<variable><sigil>}{$<variable><identifier>};
    }
}

现在, 编译器理解了三种语法结构 - 声明一个变量, 给变量赋值, 以及打印标量变量的内容。作为功课, 你可以实现错误处理, 当程序想使用一个未声明的变量或仍然包含未定义的值的变量时, 能处理错误。

这个 grammar 已经相当完善了, 但要给它添加越来越多的功能并不是很困难。每个新功能通常需要修改现有规则和 action, 或者添加新的规则和 action。

12.4. 使用抽象语法树属性

目前, G grammar 只有当整数值被赋值给一个变量时才会解析构造:

$x = 100;

让我们来看看如何为下面的赋值添加支持:

$x = $y;

$x = 100 这样的结构解析规则使用了以下规则:

rule assignment {
    <variable> '=' <value> { . . . }
}

在等号的右侧, 我们看到一个 value, 我们可以用一个更一般的项, 即 expression 来代替。最后, 表达式可以是任何一种语言所能理解的表达式, 比如 10, $x, 10 + 3 或者 $x + $y 等等。让我们一步一步地接近这一点。首先, 介绍一下 expression 规则。问题是, 我们要把表达式的值返回给进行赋值的 action。

为了保存临时值, Raku grammar 提供了抽象语法树(AST)的属性。要保存这个值, 可以使用 $/.make 方法。要获得该值, 请使用 $/.made$/.ast 方法(他们是同义词):

rule assignment {
    <variable> '=' <expression> {
        %var{$<variable><sigil>}{$<variable><identifier>} =
            $<expression>.made;
    }
}
rule expression {
    | <value> {
          $/.make(~$<value>)
      }
    | <variable> {
          $/.make(%var{$<variable><sigil>}{$<variable><identifier>})
    }
}

我们使用 $/.make 方法传递的值是附加在解析树的节点上的属性。这些值不会在执行 action 后消失, 其他规则的 action 通过 $/ 变量仍然可以访问该值。

例如, 在前面的代码中, expression 规则的第一个分支中解析的字符串化值被附加到相应的节点 - $/.make(~$<value>)。随后, 这个值被用于 assignment action $<expression>.made 中。我们在第 11 章"正则表达式"中已经看到, $<expression> 是完整表达式 $/<expression> 的简称。

当 grammar 遇到像 $x = $y 这样的赋值时, 表达式规则的第二个分支变为活动状态。在这种情况下, 它从 %var 存储中获取变量的值, 并将其放到 AST 属性中。这时, 我们的编译器可以处理下面的程序:

my $x;
$x = 100;

my $y;
$y = $x;
say $y; # 100

12.4.1. 处理表达式

在很多以解析不同编程语言的源代码为目的的 grammar 中, 其中一个核心部分就是处理表达式。我们已经介绍了 expression 规则, 它可以理解简单的表达式, 如 100$x

下面我们继续介绍表达式规则, 教编译器用 + 运算符来解析和计算表达式。我们先从一个简单的情况开始, 当两个操作数都是整数字面量时:

rule expression {
    | <value> '+' <value> {
          $/.make($<value>[0].ast + $<value>[1].ast)
      }
    | <value> {
          $/.make(~$<value>)
      }
    | <variable> {
          $/.make(%var{$<variable><sigil>}{$<variable><identifier>})
      }
}

该规则得到了一个新的分支 <value> '+' <value>, 它使用了两个同名的规则。在这个 action 中, 这两个操作数是从数组的两个元素 - $<value>[0]$<value>[1]ast(或 make)属性中提取出来的。

另一个变化应该是在 value token 中进行的。到现在为止, 我们还没有在 AST 中保存任何东西。如果不这样做的话, 那么进一步的解析将不得不处理复杂的结构而不是简单的值。所以直接把它添加到树上就可以了:

token value {
    <number> {$/.make(+$<number>)}
}

这里的前缀 + 用于将值转换为数值类型。

下一步是允许变量, 这样我们就可以解析像 3 + $x 这样的表达式了。

正如我们之前看到的那样, expression 规则可以表示变量的值。所以, 让我们用它来代替 expression 规则中的第二个 value:

rule expression {
    | <value> '+' <expression> {
          $/.make($<value>.ast + $<expression>.ast)
      }
    | <value> {
          $/.make(~$<value>)
      }
    | <variable> {
          $/.make(%var{$<variable><sigil>}{$<variable><identifier>})
      }
}

另外, 更新 variable 规则以在 AST 中保存变量的值:

token variable {
    <sigil> <identifier> {
        $/.make(%var{$<sigil>}{$<identifier>})
    }
}

这就是将变量用作 + 运算符的第二个操作数所需的全部内容。现在的 grammar 是这样解析程序的:

my $x;
my $y;

$x = 3;
$y = 4 + $x;

say $y; # 7

+ 运算符左侧允许变量是比较困难的。问题是, 到目前为止, 我们在可能期待变量的地方使用了 expression 规则:

rule expression {
    | <value> '+' <expression>
    ...
}

只将左侧的 value 更改为 expression 是不行的:

rule expression {
    | <expression> '+' <expression>
    ...
}

这种变化导致了无限递归 - 要了解表达式是什么, 你需要解析解析表达式, 它是表达式加表达式, 以此类推。一种可能的解决方案是隐式列出第一个可以是操作数的选项:

rule expression {
    | <value> '+' <expression> {
          $/.make($<value>.ast + $<expression>.ast)
      }
    | <variable> '+' <expression> {
          $/.make($<variable>.ast + $<expression>.ast)
      }
    | <value> {
          $/.make(~$<value>)
      }
    | <variable> {
          $/.make(%var{$<variable><sigil>}{$<variable><identifier>})
      }
}

更好的方法是引入另一个规则, 即 term, 既可以是 value 也可以是 variable:

rule expression {
    | <term> '+' <expression> {
          $/.make($<term>.ast + $<expression>.ast)
      }
    | <value> {
          $/.make(~$<value>)
      }
    | <variable> {
          $/.make(%var{$<variable><sigil>}{$<variable><identifier>})
      }
}
rule term {
    | <value> {$/.make($<value>.ast)}
    | <variable> {$/.make($<variable>.ast)}
}

目前可以做些什么呢? 可以用 Raku 语言的微小子集中的(已经相当复杂的)程序进行解析和执行:

my $x;
my $y;

$x = 3;
$y = 4 + $x;
say $y; # 7

my $z;
$z = $x + $y;
say $z; # 10

my $a;
$a = $z + 5;
say $a; # 15

作为奖励, 该程序还可以解析涉及 + 运算符的复杂表达式:

my $b;
$b = $a + $x + $y + $z + 7;
say $b; # 42

我们没有做任何特别的事情来让这一点得以实现, 但 grammar 将表达式拆分成简单的表达式, 比如 $a + $x, 计算出值, 然后更进一步。每走一步, 就会执行具有两个操作数的简单运算。

12.5. 使用 action 类

Grammar 越复杂, action 就越复杂。在我们目前的 grammar 中, 几乎每一个 ruletoken 都有一个 action。即使大多数的 action 只是一两行代码, 但由于 Raku 的代码与 grammar 语言混合在一起, 使得阅读代码变得困难重重。对代码进行格式化也成为了一个难题, 因为你需要添加更多的空格来正确地缩进代码。在本节中, 我们将看看 Raku 提供了哪些方法来解决这个问题。

所有的 action 都可以移动到一个单独的类中。因此, 完整的 grammar 包含一个 grammar 本身和一个 action 类。Grammar 的 rule 与 token 之间的对应关系, 只需给 action 类的方法起相同的名字就可以实现。让我们将 grammar 转换为使用拆分的方法。

首先, 为 action 创建一个类, 并将其传递给解析器:

 grammar G {
    ...
}
class A {
    ...
}
...

G.parse($prog, :actions(A));

现在, 将 action 代码从 grammar 的 rule 或 token 中移动到 action 类的单独方法中。

variable 规则为例:

token variable {
    <sigil> <identifier> {
        $/.make(%var{$<sigil>}{$<identifier>})
    }
}

这个代码应分成 grammar 规则和 action 两个部分:

grammar G {
    ...
    token variable {
        <sigil> <identifier>
    }
    ...
}
class A {
    ...
    method variable($/) {
        $/.make(%var{$<sigil>}{$<identifier>})
    }
    ...
}

由于代码现在被放置在 grammar 之外, 我们必须传递 $/ 变量作为方法的参数。这个参数可以使用任何其他的名称, 但 $/ 似乎是最常见和最常规的选择。

用同样的方式, 其他的 action 也可以被提取出来并放到 action 类中。我们不会花时间来描述那些重复的代码变化(你可以在本章末尾看到最终的代码), 而是直接看一下具有备选项的规则。这个 grammar 中有两个这样的规则, 即 expressionterm

考虑一下 term 规则:

 rule term {
    | <value> {$/.make($<value>.ast)}
    | <variable> {$/.make($<variable>.ast)}
}

这里我们有两个不同的 action, 但我们只能在 action 类中添加一个方法。

至少有三种解决方法。第一, 可以把这个规则拆分成两个规则, 从而产生两个不同的 action。第二, 我们可以分析 $/ 变量的内容, 并执行其中的一个分支:

method term($/) {
    if $<value> {
        $/.make($<value>.ast)
    }
    elsif $<variable> {
        $/.make($<variable>.ast)
    }
}

但是 Raku 给了我们一个更好的选择 - 使用 multi 方法并使其对 $/ 参数中的数据敏感:

multi method term($/ where $/<value>) {
    $/.make($<value>.ast)
}
multi method term($/ where $/<variable>) {
    $/.make($<variable>.ast)
}

在方法的签名中, 创建了一个子类型。multi 方法的每个变体都会根据不同分支的匹配规则来调用。我们在第 6 章的 Multi subs 中用了这个技巧。

在把所有的 action 移动单独的类中后, 把全局变量 %var 移到类中也是明智的做法。目前, 整个程序的骨架看起来是这样的:

grammar G {
    ...
}

class A {
    my %var;
    ...
}

G.parse($prog, :actions(A));

现在的 %var 存储是类属性, 它属于类, 而不属于类的实例。实际上, 我们并没有创建任何 A 的实例, G.parse 方法接收的是类的名字。这对于某些应用来说可能没有问题, 但是为了确保变量存储不保留之前运行的解析器的值, 最好是把 %var 作为私有属性:

class A {
    has %!var;
    ...
}

类需要先被实例化。否则, 就不会为 %var 属性分配内存。parse 方法也接受 action 类的实例:

G.parse($prog, :actions(A.new));

12.6. 完整的程序

通过学习 Raku 中的 grammar, 我们取得了很多收获。试想一下, 我们创建的程序可以解析另一个用 Raku 编写的程序!

虽然还有很多需要改进的地方, 但你一定能做到。例如, 你可以从实现对数组的支持开始。

以下是我们在本章中创建的编译器的完整代码:

grammar G {
    rule TOP {
        ^
            [ <statement> \s* <comment>? ]*
        $
    }
    rule statement {
        [
            | <variable-declaration>
            | <assignment>
            | <say-function>
        ]
        ';'
    }
    rule comment {
        '#' <-[\n]>*
    }
    rule variable-declaration {
        'my' <variable>
    }
    token variable {
        <sigil> <identifier>
    }
    token sigil {
        '$' | '@'
    }
    token identifier {
        <alpha> <alnum>*
    }
    rule assignment {
        <variable> '=' <expression>
    }
    rule expression {
        | <term> '+' <expression>
        | <value>
        | <variable>
    }
    rule term {
        | <value>
        | <variable>
    }
    token value {
        <number>

    }
    token number {
        <digit>+
    }
    rule say-function {
        'say' <variable>
    }
}

class A {
    has %!var;
    method variable-declaration($/) {
        %!var{$<variable><sigil>}{$<variable><identifier>} =
            'undefined';
    }
    method variable($/) {
        $/.make(%!var{$<sigil>}{$<identifier>})
    }
    method assignment($/) {
        %!var{$<variable><sigil>}{$<variable><identifier>} =
            $<expression>.ast;
    }
    method value($/) {
        $/.make(+$<number>)
    }
    method say-function($/) {
        say %!var{$<variable><sigil>}{$<variable><identifier>};
    }
    multi method term($/ where $/<value>) {
        $/.make($<value>.ast)
    }
    multi method term($/ where $/<variable>) {
        $/.make($<variable>.ast)
    }
    multi method expression($/ where $/<term>) {
        $/.make($<term>.ast + $<expression>.ast)
    }

    multi method expression($/ where $/<value>) {
        $/.make(~$<value>)
    }
    multi method expression($/ where $/<variable>) {
        $/.make(%!var{$<variable><sigil>}{$<variable><identifier>})
    }
}

my $prog = q:to/END/;
my $x;
$x = 5;
say $x;# 5
my $y;
$y = $x;
say $y; # 5
my $z;
$z = $x + $y;
say $z; # 10
my $sum;
$sum = 10 + 12 + $x + $y + $z;
say $sum; # 42
END

G.parse($prog, :actions(A.new));

12.7. 总结

在本章中, 我们讨论了 grammar, 这是 Raku 领先的新功能。Grammar 允许为自己的特定领域的语言构建解析器, 并且已经内置在语言中, 因此不需要外部模块就可以开始使用。

使用 Raku 子集的编译器的例子, 我们创建了一个 grammar, 并查看了它的元素 - rule 和 token。后来我们用 action 对 grammar 进行了更新, 最后将 action 移到一个单独的类中, 使代码更易于维护、更干净。

在接下来的章节中, 我们将讨论 Raku 中的并发, 反应式和函数式编程。

13. 并发编程

Raku 是一种完全在二十一世纪创造的语言。它自带对一些基本概念的内置支持并不奇怪, 这使得创建支持并行和并发编程的应用程序变得很容易。

在本章中, 我们将介绍以下内容。

  • Junction

  • 线程

  • Promise

  • Channel

13.1. Junction

Junction 是 Raku 可以并行工作的最简单的例子之一。在编写本书时的 Rakudo 版本中, 这个功能还没有完全实现。

Junction 是一个可以同时保留许多值的值。考察一下下面的代码:

my $j = 1 | 3 | 5;
say 'OK' if $j == 3;
say 'Not OK' if $j != 2;

变量 $j 是一个 junction, 它持有三个奇数, 1, 35。你可以将 $j 与整数进行比较, 如果该值是 junction 所存储的值之一, 那么可以得到布尔值 True。在与 3 进行比较时, 结果是 True, 而与 2 进行的第二个比较则失败了。

13.1.1. 自动线程化

现在尝试将 junction 传递给一个接收标量的函数:

sub f($x) {
    say $x;
    return $x;
}

say 'OK' if f(1 | 3 | 5) == 3;
say 'Not OK' if f(1 | 3 | 5) != 2;

这个行为很容易理解 - 分别对 junction 的每个值执行该函数。然后, 该函数的返回值将用作 junction 的值。

前面的代码的工作原理与下面的代码相同, 其中函数接收单个标量值:

say 'OK' if f(1) | f(3) | f(5) == 3;
say 'Not OK' if f(1) | f(3) | f(5) != 2;

把 junction 操作移动到函数参数之外, 称作自动线程化。从理论上讲, 上一个例子中的代码可以并行执行。

现在, 让我们进入下一个主题, 看看如何显式地创建线程。

13.2. 线程

在 Raku 中, 有一个 Thread 类, 它负责创建和运行线程。要查看你现在在哪个线程中, 请使用 $*THREAD 伪常量:

say $*THREAD;

它返回一个 Thread 类的值, 并且它的默认字符串化表示是一个包含标识符和线程名称的字符串:

Thread #1 (Initial thread)

不要依赖线程标识符的特定值, 因为即使是主线程, 标识符也可能不一样。

13.2.1. 开启线程

在本节和下面的章节中, 我们将研究 Thread 类的方法。不过, 我们将从 start 方法开始, 它创建一个线程并开始执行。

在下面的例子中, 我们创建了三个线程。每个线程都接收一个名称和一个代码块。代码块在每个线程中做同样的工作, 并且只打印 $*THREAD 变量的值, 这个变量的值在不同的线程中是不同的:

say $*THREAD;

my $t1 = Thread.start(name => 'Test 1', sub {say $*THREAD});
my $t2 = Thread.start(name => 'Test 2', sub {say $*THREAD});
my $t3 = Thread.start(name => 'Test 3', sub {say $*THREAD});

say $t1.WHAT;
say $t2.WHAT;
say $t3.WHAT;

运行该程序, 看看它打印出了什么。你的输出可能与下面的片段不同:

Thread #1 (Initial thread)
Thread #4 (Test 1)
Thread #5 (Test 2)
(Thread)
Thread #6 (Test 3)
(Thread)
(Thread)

如你所见, 程序从四个不同的线程中打印 - 初始线程 #1 和我们创建的三个线程。他们得到标识符 345。Rakudo 开发人员告诉我, 线程 #2 可能是在启动时被虚拟机使用的。同样, 这些数字的主要属性是唯一的, 但不一定按顺序排列。

另外注意, 不同线程的输出是重叠的。多运行这个程序几次, 很可能会得到不同的结果。

线程是在 Thread.start 被调用的那一刻创建的, 然后执行时又回到主线程。最简单的查看方法是在用作线程代码块的子例程中嵌入不同的延迟。

在下面的程序中, 创建了三个匿名(意思是不保存在变量中)的线程。它们的名字不同, 以及它们的延迟和在它们的主体中产生的输出也不同:

say $*THREAD;

Thread.start(
    name => 'Sleep 3 seconds',
    sub {
        say $*THREAD;
        sleep 3;
        say 1;
    }
);

Thread.start(
    name => 'Sleep 2 seconds',
    sub {
        say $*THREAD;
        sleep 2;
        say 2;
    }
);

Thread.start(
    name => 'Sleep 1 second',
    sub {
        say $*THREAD;
        sleep 1;
        say 3;
    }
)

运行它, 这就是你将在控制台中得到的东西:

Thread #1 (Initial thread)
Thread #4 (Sleep 3 seconds)
Thread #5 (Sleep 2 seconds)
Thread #6 (Sleep 1 second)
3
2
1

在程序启动后, 立即打印了前四行, 而其余的延迟打印 - 每个数字在延迟 1 秒后打印。所以, Thread.start 创建一个线程并退出, 而该线程与主程序(以及其他线程)并行地执行。

由于线程是并行工作的, 所以无法预测它们会以何种顺序产生副作用(例如打印到控制台)。看看这个程序, 它创建了两个线程, 每个线程打印五个数字。第一个线程打印从 1 到 5 的数字, 而第二个线程打印从 11 到 15 的数字:

Thread.start(sub {
    .say for 1..5;
});

Thread.start(sub {
    .say for 11..15;
});

这两个线程都是并行执行的。实际的实现, 无论是将代码分布到处理器的不同核上, 还是通过为线程分配专用的时间原子来初始化线程, 语言规范中都没有定义, 所以在 Raku 中创建线程时, 不应该指望这两种实现。

将这个程序运行几次, 可以看到每次结果都是不一样的:

1
11
12
2
3
13
4
5
14
15

第二个线程也可能比第一个线程先开始打印:

11
1
2
12
13
3
14
15
4
5

使用 Thread.start 方法很容易, 但在某些情况下, 你可能希望对线程的创建和运行有更精细的控制。

13.2.2. 创建并运行新线程

要创建一个线程对象, 请使用 Thread 类的构造函数。它以相应的命名参数 namecode 接收线程名称和代码块:

my $t = Thread.new(
    name => 'My thread',
    code => sub {
        say 'Hi there!';
    }
);

该线程现在已经创建, 但没有激活。要运行它, 必须调用 run 方法:

$t.run();

执行下面的例子, 检查一下屏幕上出现的行的顺序:

my $t = Thread.new(
    name => 'My thread',
    code => sub {
        say 'Start';
        sleep 2;
        say 'End';
    }
);

say 'Before';
$t.run();
say 'After';

线程一运行, 它就会打印出 StartEnd 这两条消息, 间隔 2 秒的延迟:

Before
After
Start
End

可以将主程序推迟到线程完成工作后再进行。使用 finish 方法:

say 'Before';
$t.run();
$t.finish();
say 'After';

程序会等到线程代码块完成工作后, 才从下一条指令开始继续执行:

Before
Start
End
After

finish 方法有一个同义词 join - $t.join(), 它的作用与 $t.finish() 完全相同。

13.2.3. id 和 name 方法

在《启动新线程》一节中, 我们已经看到了一些关于如何给新线程分配标识符的例子。在 Thread 类中, 有一个方法可以返回 id:

say $*THREAD;

my $t1 = Thread.start(sub {});
my $t2 = Thread.start(sub {});
my $t3 = Thread.start(sub {});

say $t1.id();
say $t2.id();
say $t3.id();

该程序可能的输出之一是这样的:

3
4
5

如果你想在主线程中保留一些跟踪信息, 比如说, 可以使用线程标识符。

另一种识别线程的方法是使用名称。名称是你在创建线程时通过 name 参数给线程分配的字符串标签:

my $t1 = Thread.start(name => 'My thread one', sub {});
my $t2 = Thread.start(name => 'My thread two', sub {});
my $t3 = Thread.start(name => 'My thread three', sub {});

say $*THREAD.name();
say $t1.name();
say $t2.name();
say $t3.name();

主线程的名称是 Initial thread:

Initial thread
My thread one
My thread two
My thread three

名称不需要是唯一的, 所以你可以自由选择任何你想要的任何名称。

13.2.4. 将线程对象打印为字符串

Thread 类的 Str 方法通过 say 函数定义了当线程对象被打印时的行为:

my $t1 = Thread.start(name => 'My thread one', sub {});
my $t2 = Thread.start(name => 'My thread two', sub {});
my $t3 = Thread.start(name => 'My thread three', sub {});

say $*THREAD;
say $t1;
say $t2;
say $t3;

默认字符串包含线程的编号及其名称(如果定义了的话)。

Thread #1 (Initial thread)
Thread #3 (My thread one)
Thread #4 (My thread two)
Thread #5 (My thread three)

在这个例子中, 所有的线程都有不同的 ID(它们总是不同的)和不同的名称(这是由程序员定义的)。

13.3. 终身线程

在创建一个新线程时, 可以设置 app_lifetime 属性, 要求该线程活到主程序结束。否则, 它会在其主体执行完后被终止。要添加这个标志, 可以将其添加为 :app_lifetime 或者通过显式传递 True 值给构造函数-app_lifetime ⇒ True:

Thread.new(
    name => 'Long thread',
    code => sub {
        say 'OK';
    },
    :app_lifetime,
).run().join();

say 'Done';

重要的是等待线程(使用 finishjoin 方法)。否则, 主线程可能会在该线程返回之前停止执行。

13.3.1. 在 Raku 中使用锁

Raku 提供了一种机制, 确保代码的特定部分只由单个线程执行。如果有其他线程想从这段代码中访问变量, 应该等到它被解锁后再执行。

要封存关键代码, 请使用 Lock 类的 protect 方法。请看下面这个 Raku 文档中的例子:

my $x = 0;
my $l = Lock.new;
await (^10).map: {
    start {
        $l.protect({ $x++ });
    }
}
say $x; # OUTPUT: «10␤»

我们将在本章后面的工厂方法一节中讲到 await 函数。

关键代码的保护方式是, 每次只能有一个线程访问 $x 计数器。

不建议直接使用锁, 因为它们提供了太低级的接口。相反, 使用 Promise、Channel 和 Supply。我们会在本章后面讨论前两个概念, 而 Supply 将在第十五章的《反应式编程》中讨论。

13.4. Promise

在上一节中, 我们创建了一些并行运行的代码块。Promise 有助于查看这些代码块的完成状态。在 Raku 中, 承诺是由 Promise 类处理的。

13.4.1. 创造一个承诺

要创建一个承诺, 只需调用 Promise 类的构造函数即可:

my $promise = Promise.new();

创建好的对象什么也还没有做。在本章后面的工厂方法一节中, 我们将看到如何创建一个执行一些代码的承诺。同时, 让我们看看承诺所拥有的属性。

13.4.2. 承诺的状态

承诺的力量在于, 它们既可以被遵守, 也可以或破坏, 你可以跟踪他们。通过调用 Promise.new 创建的新承诺, 既不会被遵守也不会被破坏。它的状态是 Planned。要查看状态, 请调用 status 方法:

my $promise = Promise.new();
say $promise.status(); # Planned

Promise 类还为我们提供了一对方法, keepbreak, 可以把承诺的状态更改为 KeptBroken。这在下面的例子中得到了证明, 其中一个承诺被标记为 Kept, 而第二个承诺被强制为 Broken:

my $promise1 = Promise.new();
my $promise2 = Promise.new();

$promise1.keep();
$promise2.break();

say $promise1.status();
say $promise2.status();

输出是:

Kept
Broken

现在, 在我们知道如何改变承诺的状态以及如何读取状态之后, 我们要用承诺来并行地执行代码。

13.4.3. 工厂方法

让我们从一个简单的程序开始, 用一个代码块创建一个承诺, 打印出一些东西并退出。Promise.start 方法创建了一个代码块并返回一个承诺。注意, 返回的值是一个承诺, 而不是一个线程。使用 await 函数等待直到该承诺的代码块完成:

my $promise = Promise.start({
    say 'I am a promise';
});
await $promise;

这个程序会等待, 直到代码块打印出消息,然后退出:

I am a promise

在前面的例子中, start 例程作为 Promise 类的一个方法被调用。另外, 也可以通过一个独立的 start 函数来创建承诺:

my $promise = start {
    say 'I am a promise';
};
await $promise;

注意, 代码块周围没有圆括号。

让我们修改一下前面的例子, 创建一个只是休眠一秒钟的承诺。承诺被创建后, 首次立即打印其状态。这里的一秒延迟是很重要的, 以确保在我们在下一行检查承诺的状态之前, 承诺没有完成它的工作。

然后, await 等待, 直到承诺的代码完成后, 第二次检查承诺的状态:

my $promise = Promise.start({
    sleep 1;
});

say $promise.status;
await $promise;
say $promise.status;

这是程序打印的内容:

Planned
Kept

对于时间的处理, 有另一个工厂方法, Promise.in, 可以用它来代替 startsleep 的组合。它创建了一个承诺, 在给定的秒数之后, 这个承诺的状态就变为保留:

my $promise = Promise.in(2);
await $promise;
say 'Done';
say $promise.status; # Kept

这个程序创建并等待一个承诺。2 秒后, 承诺状态变为保留, 程序继续运行。之后, 承诺的状态就会变成 Kept

13.4.4. 承诺的结果

承诺的另一个有趣的特性是, 它们可以返回一个结果。这个结果是由代码块计算出来的, 最后计算出来的值就是返回的结果。考虑一下下面的例子:

my $promise = Promise.start({
    sleep 1;
    'Result'; # no return keyword!
});

await $promise;
say $promise.result;

这里, 代码块返回一个字符串, 即 Result, 然后在 $promise 变量上调用 result 打印出承诺的结果。

从逻辑上讲, 只有在承诺被遵守后, 结果才是可用的。在前面的例子中, 使用了显式的 await。实际上, 这是多余的, 因为 result 方法要等到代码完成后再调用。因此, 代码可以简化成下面这样:

my $promise = Promise.start({
    sleep 1;
    'Result';
});

say $promise.result; # waits

13.4.5. 组合承诺

在实际的程序中, 可以使用不止一个承诺。将不同的承诺组合在一起, 可以给出非常有表现力的方式来编码不同部分代码之间的复杂关系。

让我们从一个简单的例子开始, 使用独立的 start 函数创建三个承诺:

say 'Start';
await
    start {sleep 2; say 2;},
    start {sleep 3; say 3;},
    start {sleep 1; say 1;};
say 'Done';

目标是暂停程序, 直到所有的承诺都被遵守。从这个例子中可以看到, await 函数接受一个承诺列表, 然后等待, 直到所有的承诺都完成。程序的输出看起来是这样的:

Start
1
2
3
Done

每个承诺都会创建一个单独的线程, 通过打印 $*THREAD 变量可以清楚地看到:

await
    start {say $*THREAD;},
    start {say $*THREAD;},
    start {say $*THREAD;};

每个 start 都创建了它自己的线程:

Thread #3
Thread #5
Thread #4

13.4.6. 承诺被遵守或被破坏后执行代码

Promise 类有 then 方法, 可以用来绑定承诺被遵守或被破坏后要执行的代码。实际上, 这种方法会创建并返回一个新的承诺, 在初始承诺状态改变后, 这个新的承诺会被运行:

my $promise = Promise.in(1);
my $next = $promise.then({
    say 'Done';
});
await $next;

这个程序在启动 1 秒后打印出 Done。第一个承诺, 保存在 $promise 变量中, 在给定的时间延迟后状态变为保留。然后, then 创建另一个承诺, 它保存在 $next 变量中。要加入到主程序中, 需要等待 $next 打印输出, 这样就完成了。

13.4.7. anyof 和 allof 方法

Promise 类中的两个方法, anyofallof, 在任何一个承诺被遵守或全部被遵守时, 都会创建一个新的承诺。这两个方法接受一个承诺列表。

让我们用下面的例子来说明一下 anyof 方法的工作, 该方法是检查长期运行的代码是否执行时间过长:

my $timeout = Promise.in(2).then({
       say 'Timeout'
});
my $long_code = start {
    sleep 3;
    say 'Work done';
};

await Promise.anyof($timeout, $long_code);
say 'Continuing';

这创造了两个承诺。$timeout 这个在创建承诺 2 秒后被遵守。$long_code 模仿慢的那部分代码, 执行时间比计时器能等待的时间更长。然后, 这两个承诺会被传递给 Promise.anyof 方法, 该方法会返回另一个承诺, 在发生超时或执行长时间运行的代码时, 这个承诺会被遵守。

用不同的延迟组合运行这个程序, 看看这个程序的不同结果。

13.5. Channel

Channel 是一种通信手段, 可以用来将数据从一段代码传递给另一段代码。Channel 的好处在于它是线程兼容的, 因此不同的线程之间可以相互对话。在本节中, 我们将学习如何在 Raku 中使用 Channel。

13.5.1. 基本用例

Channel 是由 Channel 类定义的。要创建新的通道变量, 请调用构造函数:

my $channel = Channel.new;

现在我们可以用 send 方法向通道发送数据, 用 receive 方法接收数据:

my $channel = Channel.new;
$channel.send(42);

my $value = $channel.receive();
say $value;

这个程序做了一件小事 - 它把值发送到通道中, 并立即读取它。该程序打印出了通过通道的值 42

现在, 让我们修改一下程序, 在程序中引入第二个线程, 这样, 通道被填充到这个线程中, 结果在主程序中读取:

my $channel = Channel.new();
start {
    $channel.send(42);
};

my $value = $channel.receive;
say $value;

执行的结果和以前一样, 打印出了 42, 而传递值的逻辑完全不同。

receive 方法一直等待, 直到通道有足够的数据可以读取。如果我们给线程代码添加一个延迟, 那么程序将暂停, 直到线程向通道发送数据:

my $channel = Channel.new();
start {
    sleep 1;
    $channel.send(42);
};

my $value = $channel.receive;
say $value;

上两个例子的唯一区别在于调用 $channel.send 方法之前的延迟。

13.5.2. 等还是不等?

我们在上一节中已经看到, 如果通道中没有数据, 那么 Channel 类的 receive 方法就会停滞。

poll 方法也从通道中读取数据, 但不会阻塞程序的执行。如果没有任何数据可读取, 那么它就会立即返回一个空值。让我们再修改下上一个例子, 从通道中读取 5 次, poll 调用之间有 1 秒的延迟:

my $channel = Channel.new();

start {
    sleep 3;
    $channel.send(42);
};

for 1..5 {
    my $value = $channel.poll;
    say $value;
    sleep 1;
}

该程序打印出以下输出:

(Any)
(Any)
(Any)
42
(Any)

前三次尝试无法获得任何值, 因为之前有一个 3 秒的延迟, 所以没有任何值被发送到 $channel。在第四次迭代时, 值是可用的, 可以被读取。在被读取之后, 该值被从通道中删除, 因此下一次的 poll 调用再次返回一个空值。

13.5.3. 关闭通道

close 方法关闭一个通道。这意味着不会再向它添加任何数据。要检查通道是否关闭, 请调用 close 方法:

my $channel = Channel.new();
say 'Open' unless $channel.closed(); # Open

$channel.close();
say 'Closed' if $channel.closed();   # Closed

close 方法的返回值不是布尔值。相反, 该方法返回一个承诺, 在通道关闭后, 这个承诺会被遵守:

my $channel = Channel.new();
say $channel.closed().status(); # Planned

$channel.close();
say $channel.closed().status(); # Kept

例如, 可以使用承诺来运行一些响应于关闭通道的代码, 如下例所示:

my $channel = Channel.new();

my $promise = $channel.closed();
$promise.then({
    say 'Channel is closed';
});

say 'Before calling close()';
$channel.close();
say 'After calling close()';

运行这个程序的输出可能是这样的:

Before calling close()
After calling close()
Channel is closed

因为承诺是在一个单独的线程中执行的, 所以可能会不可预知地以不同的顺序打印行。如果我们在主线程中打印字符串之前添加一个小的延迟, 就可以看到这一点了:

say 'Before calling close()';
$channel.close();
sleep 1;
say 'After calling close()';

在这种情况下, $promise 的代码将在第二个 say 指令之前完成:

Before calling close()
Channel is closed
After calling close()

13.5.4. 通道作为队列

当多个值被送入通道时, 它们实际上成为队列。所以第一个添加到通道中的值将是从通道中接收到的第一个值。

由于通道是线程安全的, 所以没有人限制我们要向通道写入或从通道中读取的线程数量。下面的例子演示了通道是如何在几个线程之间共享的。

该程序打印出从 0 到 10 的数字的平方。这些数字首先被发送到通道中:

my $channel = Channel.new();
$channel.send($_) for 0..10;
$channel.close;

发送完所有数字之后, 通道就关闭了。在下一阶段, 通过调用 start 函数创建三个线程(别忘了, 这个函数通过创建一个承诺间接创建一个线程)。在每个线程中, 无限循环尝试从同一个通道中接收值:

my @readers;
for 1..3 {
    push @readers, start {
        while 1 {
            my $value = $channel.poll;
            last if $value === Any;
            say "$value2 = " ~ $value * $value;
        }
    };
}

这里使用的是非阻塞的 poll 方法, 从通道读取值。如果队列被消费掉, 返回的值为空, 则循环被打破, 线程完成。

在退出主程序之前, 我们必须等待所有的承诺都被遵守:

await @readers;

每个线程都会计算出它能从通道中读取的值的平方:

0² = 0
2² = 4
1² = 1
3² = 9
5² = 25
4² = 16
6² = 36
7² = 49
8² = 64
10² = 100
9² = 81

将程序程序几次, 看看它产生的输出值是否不同。当然, 每次运行时计算出的数值都是一样的, 但输出行的顺序可能会有所不同。该通道以和我们将数字发送给它的相同的顺序返回数字。线程是并发的, 所以有些线程会先于其他线程打印出结果, 并选取下一个值。尽管线程的工作顺序不同, 但该通道只打印出数字一次。

13.6. 总结

在这一章中, 我们简要地讨论了 junction 的并行性, 其余的时间我们深入研究了线程、承诺和通道 - Raku 中实现并行和并发特性的机制。使用它们对于开发人员是相当容易的, 并且不需要手动操作底层机制来确保线程执行时不会发生冲突。

14. 函数式编程

Raku 是一种多范式编程语言。在前几章中, 我们主要使用了传统的命令式编程。在这一章中, 我们将讨论 Raku 的函数式编程风格。

本章将介绍这些主题:

  • 函数式编程原理

  • 使用递归重写传统程序

  • 化简操作

  • 高阶函数, lambda 和 whatever 代码块

  • 使用 Feed 运算符管道输送数据

  • 闭包、柯里化和动态作用域

  • 惰性列表和无限列表以及序列生成器

在详细介绍之前, 我们先谈谈什么是函数式编程。

14.1. 什么是函数式编程

函数式编程是使用一系列函数进行计算的方式。这里所说的函数是指从数学意义上的函数, 而不是 Raku 中的子例程。函数式编程的一个非常重要的原则是, 函数必须没有副作用。特别是, 这意味着变量必须是不可变的 - 禁止指定一个新的值。

本章中我们将讨论的所有主题都是前面的限制的结果。重要的是要认识到, 比如说, lambda 函数并不是函数式编程的核心本质, 它只是遵循无副作用这一主要原则的方式之一, 比如说改变一些影响函数结果的全局变量。

让我们取一个函数 f($x), 用同样的参数调用它两次。第二次调用会不会和第一次调用的结果相同一样呢? 在函数式编程中, 要求一个函数如果用相同的参数调用, 总是返回相同的结果。

下面是这样一个函数的例子, 返回 $x + 1:

sub f($x) {
    return $x + 1;
}

say f(5); # 6
say f(5); # 6

say f(5) == f(5); # True

两个调用都返回相同的结果。同时, 比较 f(5) == f(5) 结果为 True。这个程序可能已经被认为是用函数式风格编写的程序。

引入一个变量并不违背原则:

sub f($x) {
    return $x + 1;
}

my $a = 5;

say f($a); # 6
say f($a); # 6

say f($a) == f($a); # True

$a 变量在初始化期间只获得一次值。之后就再也不会改变。

现在, 让我们修改函数的参数(你需要使用 is rw trait):

sub f($x is rw) {
    $x += 1;
    return $x;
}

第一次调用具有相同原始值 $a 的函数将返回与以前相同的结果。虽然在函数调用后, $a 的值发生了变化, 第二次调用 f($a) 无法返回相同的结果。条件 f($a) == f($a) 已经不再是 True 了:

my $a = 5;

say f($a); # 6
say f($a); # 7

say f($a) == f($a); # False

在这里, 函数产生了一个修改参数的副作用, 这是由于它被声明为可读可写的参数, 改变了主代码中变量的值。

另一种产生副作用的方法是在函数内部使用全局变量。考察下面这个函数:

my $step = 0;

sub f($x) {
    $step++;
    return $x + $step;
}

现在函数的参数没有变化, 但是函数被调用两次后, 返回的结果不一样。

现在, 仔细看一下 f 函数的三个变体。在第一个变体中, 函数的主体没有涉及到赋值。在第二个和第三个例子中, 函数参数或全局变量被重新赋值了。虽然没有显式地使用 = 运算符, 但 $x += 1$step++ 结构相当于下面的赋值:

$x = $x + 1;
$step = $step + 1;

在这两种情况下, 这就是打破 f($a) == f($a) 条件的根源。

函数本身不一定会改变一个全局值。在函数的两次调用之间, 全局值可以被其他代码修改:

my $step = 1;

sub f($x) {
    return $x + $step;
}

say f(5); # 6
$step = 2;
say f(5); # 7

虽然函数似乎是可预测的, 但它的工作环境影响着它的工作。从某种意义上说, 变量重新赋值在程序中引入了时间的概念。在不同时间里, 同样的调用 f(5) 会返回不同的结果:

my $t1 = f(5);
$step = 2;
my $t2 = f(5);

say $t1 == $t2; # False

Raku 并不禁止设置变量的新值, 但在用函数式编程风格编写程序时, 应该只对变量进行一次初始化, 并应避免任何新的赋值。在下一节中, 你将看到如何修改传的统程序, 以遵循函数式编程的原则。

14.2. 使用递归

本章中我们的下一个程序是一个简单的循环, 可以打印从 10 到 15 的数字并计算出他们的总和。我们先打印一下数字。正如我们在第 5 章《控制流》中看到的那样, 在 Raku 中, 有不同的循环方式。在它们之间进行选择, 可以把我们已经引导到函数式编程的方向上了。

loop 循环需要一个 loop 计数器:

my $sum = 0;
loop (my $n = 10; $n <= 15; $n++) {
    $sum += $n;
}
say $sum; # 75

在这个程序中, 有两个变量会改变它们的值 - $n$sum。很容易摆脱 $n 计数器, 从而给它重新分配一个值:

my $sum = 0;
for 10..15 {
    $sum += $_;
}
say $sum;

现在, 我们使用 $_ 变量代替 $n, 实际上 for 循环可以使用显式循环变量:

my $sum = 0;
for 10..15 -> $n {
    $sum += $n;
}
say $sum;

这段代码和带 loop 的程序的区别在于, 现在变量 $n 只存在于一次循环迭代中。它在进入循环主体之前被赋值, 在循环运行后被撤回。在第一个例子中, 它在每一次迭代中都在递增。

其中一项赋值消失了。那么我们如何避免修改 $sum 变量呢? 由于它的性质, 它看起来是不可避免的-它应该在循环数字的同时累积总和。答案是 - 使用递归。递归给我们提供了和我们刚才看到的 $n 变量一样的技巧。与其使用一个单一的全局 $sum 变量来保存程序的状态, 不如在每次改变总和的时候创建一个新的变量。更有甚者, 还可以完全去掉那个变量, 如下面的例子所示:

sub sum($min, $max) {
    if ($min == $max - 1) {
        return $min + $max;
    }
    else {
        return $min + sum($min + 1, $max);
    }
}

say sum(10, 15); # 75

让我们仔细看一下这个程序。首先, 这里没有保存状态的变量 - 没有循环计数器 $n, 也没有保存中间结果的 $sum 变量。我们不创建一个关于如何计算和的指令-从这个数字开始, 而是假设这是一个和;递增这个数字, 把它加到之前的和上;我们描述一下和的实际含义-和就是当前的数字加上之前所有数字的总和。

sum 函数调用自身, 每次调用的参数都不一样。当然, 在某个时候停止递归是很重要的, 这就是为什么有测试 $min == $max - 1, 当值是我们想要相加的最后两个值时, 它就变成了 True。这个程序已经是一个函数式的程序了:它对和的定义进行了编码, 它不保留状态, 它使用函数来实现目标。

最后, 让我们使用一些 Raku 的语法元素, 使程序更加紧凑:

sub sum($min, $max) {
    return $min == $max - 1
        ?? $min + $max
        !! $min + sum($min + 1, $max);
}

say sum(10, 15);

另外, 这里不需要 return 关键字, 因为 Raku 会取函数中的最后一个计算值。最后一个分号也可以省略:

sub sum($min, $max) {
    $min == $max - 1
        ?? $min + $max
        !! $min + sum($min + 1, $max)
}

另一个转换是将相似的部分从两个表达式中移出:

sub sum($min, $max) {
    $min + ($min == $max - 1 ?? $max !! sum($min + 1, $max))
}

让我们就此打住, 看看 Raku 还能给我们带来哪些令人着迷的选择。

14.3. 使用化简

在上一节中, 我们计算了 10 到 15 之间的数字之和。这个程序, 经过一些变换, 就变成了下面这个程序的等价物:

say 10 + (11 + (12 + (13 + (14 + 15))));

这里的每一对括号对应于 sum 函数的递归调用。函数的调用在这里用它的实现来代替。这也是无状态方法限制的后果之一。如果函数依赖于程序的状态, 那么在不知道反射不同时刻的状态值的情况下, 就不可能用函数调用替换成它的实现。

因为这里的括号不会改变任何执行顺序, 所以我们把它们去掉:

say 10 + 11 + 12 + 13 + 14 + 15;

我们在这里看到的是一个由 + 运算符分隔的 10 到 15 之间的所有值的列表。我们已经在第四章《使用运算符》中的化简元运算符一节中见过了。整个结构可以用下面这个简单的代码来代替:

say [+] 10..15;

这也是一个函数式风格的程序。比较一下它和前文中的一个例子的大小。

化简运算符 [+] 在任意数组中也能完美地工作:

my @a = 10, 11, 100, 101, 1000, 1001;
say [+] @a; # 2223

现在, 我们已经接近了函数式编程中的另一个重要概念-高阶函数。

14.4. 高阶函数和 lambda

刚才我们在上一节中看到的 [+] 化简运算符就是根据需要执行 + 运算符的动作, 将提供的数据中的所有元素加相加。

在 Raku 中, 有另外一种进行化简操作的方法。有一个内置的函数 reduce, 期望有一个代码块来执行这个操作。首先, 我们将使用我们在第二章《编写代码》中所创建的函数 add($a, $b):

sub add($a, $b) {
    return $a + $b;
}

say reduce &add, 10..15;

reduce 函数将函数的引用作为第一个参数, 并展平值的列表。在 &add 中, 函数名称前面的 & 符号告诉 Raku 这不是函数调用, 而是对函数的代码引用。

reduce 函数是高阶函数的一个例子。它的一个参数是另一个函数。在 Raku 中, 函数是一等对象, 这意味着可以像常规变量一样, 将其作为参数传递给其他函数, 就像你用常规变量一样简单。从高阶函数中调用的函数有时也称为回调函数。

由于 add 函数只在 reduce 函数中使用, 所以可以内联, 直接在需要它的地方创建它:

say reduce sub add($a, $b) {return $a + $b}, 10..15;

现在很明显的是, add 的名称没有添加任何值, 可以省略, 从而使该函数成为匿名函数:

say reduce sub ($a, $b) {return $a + $b}, 10..15;

在这种情况下, 匿名函数也被称为 lambda 函数。它具有普通子例程的所有属性, 只是它既没有名字, 也不能从程序中的其它地方调用。整个函数的定义是内联的。

现在让我们看看 Raku 如何在语法上帮助简化创建 lambda。首先, sub 块可以用尖号块来代替:

say reduce -> $a, $b {$a + $b}, 10..15;

匿名函数的参数列在 箭头之后列出, 你不需要用括号括起来。另外要注意的是, 使用尖号块时, 不能使用 return 关键字(编译器会产生一个错误: Attempt to return outside of any Routine)。实际上, 根本不需要 return, 因为函数要做的就是计算一个表达式, 其结果就是函数的结果。

此外, 即使是参数也不是这种函数的必要元素。它们可以用花括号内的占位符变量来代替(参见第六章《子例程》中的参数占位符一节):

say reduce {$^a + $^b}, 10..15;

在 Raku 中, 有更多的内置函数可以作为高阶函数。这些函数有 mapgrepsort。它们中的每个函数都可以接收一个可选的代码块或对现有函数的引用作为第一个参数。让我们来看几个例子。

map 函数为序列中的每个元素发起一个回调。其结果是一个新的列表, 其中包含了使用回调函数的代码对每个元素的单独映射:

say map {.uc}, 'a'..'d'; # (A B C D)

grep 函数也为每个元素调用它的回调, 但只复制那些回调函数返回 True 值的元素:

say grep {$_ > 10}, 1..15; # (11 12 13 14 15)

在这两个例子中, 都使用了 $_ 变量: 对于 map, 它是隐式的, 因为为 .uc$_.uc 的快捷方式。

对于 sort 函数, 就有点复杂了, 因为回调函数需要两个参数。最简单的方法是使用占位符:

say sort {$^b <=> $^a}, 10..15; # (15 14 13 12 11 10)

该代码块允许将数字按相反的顺序进行排序。

14.4.1. WhateverCode 块

现在, 既然我们已经使用了 sort, grep, mapreduce 等代码块, 那么现在是时候使用 Raku 中所谓的 WhateverCode 类型了。它涉及到了(*)星号并创建一个代码块, 可以和我们之前使用的任何代码块一样使用。

例如, 你可以编写 *.uc 代替 {.uc}。下面的两行代码是等价的:

say map {.uc}, 'a'..'d'; # (A B C D)
say map *.uc, 'a'..'d';  # (A B C D)

同理, 这就是 WhateverCode 块可以用来替换 grepsort 示例中的匿名代码的方式:

say grep * > 10, 1..15; # (11 12 13 14 15)

say sort * <=> *, <11 12 10 13 15 14>; # (10 11 12 13 14 15)

在第二个例子中, 有两个星号, 分别对应于前面使用的 $^a$^b 占位符参数。使用 * 时, 不需要用花括号来创建一个块。

14.5. 管道数据和 feed 运算符

grep, map, reducesort 函数是如此的强大和简单易用, 以至于(和其他类似的用户定义的高阶函数一起), 它们可以在传统的命令式编程中通过循环来组织的领域中处理许多实际任务。

通常情况下, 你需要在另一个函数返回的结果上调用其中的一个函数。考虑一个例子, 用街道上的房屋列表来举例。其中有些房子需要粉刷, 但你只需要选择那些在街道的偶数一侧的房子, 这些房子的外墙是红色的, 是五年前装修过的。这个任务就是要知道自己需要多少油漆。

让我们假设房子的属性信息都包含在这样的数据结构中:

my @street = (
       {
           number          => 1,
           renovation_year => 2000,
           storeys         => 4,
           colour          => 'green',
           width           => 20,
       },
       {
           number          => 2,
           renovation_year => 2014,
           storeys         => 6,
           colour          => 'red',
           width           => 10,
       },

       # . . .
);

很明显, 应该创建某种过滤器。要么它应该检查每一栋房子, 并决定它是否通过了所有的条件, 要么它可以一步一步地筛选出符合条件的房子。比如说, 让我们从所选取的双数边的房子开始:

my @houses-to-paint = grep {$_<number> %% 2}, @street;

grep 函数逐一接收 @street 数组中的所有元素。在每次迭代中, 元素可以通过 $_ 主题变量访问。只有那些在计算 $_<number> %% 2 条件后返回非零结果的元素才会被传递给 @houses-to-paint 数组。

同样, 可以添加基于颜色和装修年份的过滤:

my @houses-to-paint =
    grep {$_<renovation-year> < 2012},
    grep {$_<colour> eq 'red'},
    grep {$_<number> %% 2},
    @street;

不要被第一个 grep 中的巧合对 > < 所迷惑。每个角括号都有自己的功能, 编译器完全可以理解。

最后, 我们需要计算出表面积, 以了解需要多少油漆。假设每平方米需要 0.7 升油漆, 每层楼高 3 米:

my $paint-volume = 0.7 * [+] map {$_<width> * $_<storeys> * 3}, @houses-to-paint;

这里用 map 函数来换算房子的表面积。用 [+] 化简运算符将所有的值相加, 得到总的结果。

现在我们可以把所有的代码合并到一条语句中:

say
    0.7 *
    [+]
        map  {$_<width> * $_<storeys> * 3},
        grep {$_<renovation-year> < 2013},
        grep {$_<colour> eq 'red'},
        grep {$_<number> %% 2},
    @street;

你可以在这里提出自己的缩进方式。

如果房子是用对象来存储, 而不是用散列来存储, 那就更好了:

class House {
    has $.number;
    has $.renovation-year;
    has $.storeys;
    has $.colour;
    has $.width;
    method area {
        return $!width * $!storeys;
    }
}

my @street = (
    House.new(
           number          => 1,
           renovation_year => 2000,
           storeys         => 4,
           colour          => 'green',
           width           => 20),
    House.new(
           number          => 2,
           renovation_year => 2014,
           storeys         => 6,
           colour          => 'red',
           width           => 10),

       # . . .
);

在这种情况下, mapgrep 函数的代码块可能看起来更简单:

say
    0.7 *
    [+]
        map  {.area * 3},
        grep {.renovation-year < 2013},
        grep {.colour eq 'red'},
        grep {.number %% 2},
    @street;

现在看起来已经大功告成了。一会儿我们会把它做得更好, 但现在要再次注意, 没有任何赋值操作符可以改变变量的值。实际上, 整个程序中的变量并不多。

上一个例子中的数据流是由下而上的链式 mapgrep。首先, @street 被过滤, 找到双数的房子, 然后挑选红色的房子, 然后只挑选老房子, 然后计算他们的表面积。Raku 允许我们用 =⇒⇐= 运算符以从上到下的方式来组织代码。在第四章《使用运算符》中的数据管道运算符一节中简要介绍了 feed 运算符。这是你如何使用它们重写链的方法:

say 0.7 * (@street ==>
    grep {.number %% 2} ==>
    grep {.colour eq 'red'} ==>
    grep {.renovation-year < 2013} ==>
    map  {.area * 3} ==>
    reduce {$^a + $^b});

反向 ⇐= 运算符改变了数据的流向。

在这个例子中, 链的结果是一个数。如果你得到数组, 你甚至可以在链的末端定义变量。例如, 代替传统的赋值(这里需要圆括号):

my @even-red-houses =
    (@street ==> grep {.number %% 2} ==> grep {.colour eq 'red'});

my 声明放在末尾:

@street ==> grep {.number %% 2} ==>
grep {.colour eq 'red'} ==> my @even-red-houses;

为了减少花括号的数量, 可以使用 Whatever 块:

@street ==> grep *.number %% 2 ==>
grep *.colour eq 'red' ==> my @even-red-houses;

正如我们已经看到的那样, Raku 提供了许多不同的方法来尽可能地表达你的想法。

再次, 你看到没有涉及到变量, 但这并不意味着函数式编程中禁止使用变量。所不希望的只是修改它们。在下一节中, 我们将谈一谈我们还能用变量做什么。

14.6. 操作作用域

在本节中, 我们将学习三种可以改变变量作用域的技术。它们是闭包、柯里化和动态作用域。

14.6.1. 闭包

在 Raku 中, 词法变量存在于其作用域内。闭包是可以扩展这个作用域的函数, 让你访问词法值, 而这些值在定义闭包的地方是可用的。让我们考虑一个将当前状态保存在变量中的计数器的例子。

首先, 不涉及闭包。计数器的值被保存在一个全局变量中:

my $counter = 0;

sub next-number() {
    return $counter++;
}

say next-number(); # 0
say next-number(); # 1
say next-number(); # 2

每次调用 next-number 函数时, 都会返回一个递增的整数。

现在, 我们的目标是隐藏 $counter 变量, 使其不能直接被 next-number 的用户访问。这就有了闭包。首先, 将变量和函数都放在另一个函数里面, 这样就为它们创建了一个词法作用域:

sub new-counter() {
    my $counter = 0;

    sub next-number() {
        return $counter++;
    }
}

现在不能在 new-counter 函数之外访问 $counter, 而在 next-number 子例程中仍然可以访问。$counter 变量和 next-number 子例程都是本地的, 它们的作用域等于 new-counter 的主体。

虽然, new-counter 子例程返回的是 next-number 子例程, 并且它可以保存在一个变量中:

my $c = new-counter();

$c 中存储的内容的类型是 Sub:

say $c.WHAT; # (Sub)

这意味着 $c 是可调用的并且可以用作函数:

say $c(); # 0
say $c(); # 1
say $c(); # 2

每次调用都会改变闭包内捕获的 $counter 变量, 因此 $c() 的调用会像预期的那样返回递增的数字。

my $a = new-counter();
my $b = new-counter();

say $b(); # 0
say $b(); # 1
say $a(); # 0
say $b(); # 2
say $a(); # 1

你可以创建另一个独立的计数器, 它的内部将包含另一个容器, 为它的 $counter 提供另一个容器:

my $c = new-counter();
say $c;

如你所见, $a$b 计数器保存调用之间的状态, 互不影响。

在前面的代码中, 子例程被保存在标量变量中, 前缀为 $ sigil。如果这样做, 总是要添加圆括号来表示函数调用。如果不带圆括号, 就会打印内存中的函数地址:

sub next-number () { #`(Sub|140645269514272) ... }

这就是你在输出中得到的结果:

sub next-number () { #`(Sub|140645269514272) ... }

大家都知道, 对于普通函数可以省略空括号, 比如说 new-counter:

my $cntr = new-counter;
say $cntr(); # 0
say $cntr(); # 1

Raku 有一个单独的 sigil, &, 用于保存实现 Callable 接口的值的容器。这里就不细说了, 如果你有兴趣, 请参考 文档。编译器会把带有 & sigil 的变量被当作可以调用的对象来处理, 所以空的圆括号甚至是 sigil 本身都可以省略掉, 如下一个程序所示:

my &d = new-counter();
say &d(); # 0
say d();  # 1
say d;    # 2

裸的 dd() 和完整形式的 &d() 都是一样的调用。

最后要说明的是关于 new-counter 函数中的子例程是如何返回的。说得更严格一点, 可以用引用隐式返回, 如下面的片段所示:

sub new-counter() {
    my $counter = 0;
    sub next-number() {
        return $counter++;
    }

    return &next-number;
}

这里使用的 & 符表示这不是函数调用而是 sub 本身。

14.6.2. 柯里化

柯里化是一种减少函数参数数量的技术, 它通过创建一个封装函数来代替原来函数的一些预定义值来减少函数的参数数量。

让我们看看一个简单函数的例子, 这个函数带有两个参数:

sub greet($type, $name) {
    return "$type, $name!";
}

假设现在我们要选择一个默认的问候语; 函数调用将包含重复的参数:

say greet('Hello', 'Liza');
say greet('Hello', 'John');
say greet('Hello', 'Carl');

可以通过创建一个单独的函数 hello 来解决这个问题, 只需调用需要的参数 greet 即可:

sub hello($name) {
    return greet('Hello', $name);
}

Raku 为我们提供了简单的语法:

my &hello = &greet.assuming('Hello');

assuming 方法创建了一个新的可调用子例程, 实际上是一个带有给定的第一个参数的 greet 子例程。现在, hello 是一个新的可调用对象的名称, 它接受一个参数, 可以作为函数使用:

say hello('Liza');
say hello('John');
say hello('Carl');

柯里化在某种程度上类似于使用子例程的默认参数, 但有两个主要区别。首先, 默认参数只能出现在签名的末尾。第二, 使用柯里化, 可以创建多个默认参数。例如, 可以从同一个 greet 函数中创建另一个替代问候语:

my &hi = &greet.assuming('Hi');

say hi('Liza');
say hi('John');
say hi('Carl');

现在让我们来看看如何处理一个带有命名参数的函数。下面是一个修改后的 greet 函数:

sub greet(:$type, :$name) {
    return "$type, $name!";
}

创建特定的 hello 版本很容易:

sub hello($name) {
    return greet(:type('Hello'), :$name);
}

因此, :$type 参数得到预定义的值-:type('Hello'), 而第二个参数 :$name 则直接使用其原始名称传递。结果是一个连接了问候语的字符串:

say hello('John'); # Hello, John!

最后, 让我们再来看看另一种扩展变量作用域的方法。

14.6.3. 动态作用域

动态作用域使用 * twigil, 例如, 动态标量变量 $*a。与普通的局部变量不同, 动态变量可以在函数中使用, 它可以从变量的当前作用域中调用。看下面这个例子:

sub f() {
    $*a++;
}

my $*a = 1;
f();
say $*a; # 2

这里, $*a 是主程序中的一个动态变量。它的初始化值为 1。当 f 函数被调用时, 会改变同一个变量的值, 因此该程序打印出 2。问题是 $*a 并没有在 f 函数内部声明。编译器会在函数被调用的作用域中搜索这个名称。

在前面的例子中, 可以用一个简单的全局变量来代替动态变量:

my $a = 1;
f();
say $a;

sub f() {
    $a++;
}

使用动态变量, 你可以在不同的上下文中访问不同的容器。例如, 如果从另一个函数中调用 f 函数怎么办?

sub g() {
    my $*a = 10;
    f();
    say $*a; # 21
}

在这种情况下, f 将修改在 g 中初始化的变量。同样的情况也会发生在该作用域内包含动态变量的任何其他调用上:

sub f() {
    $*a++;
}

sub g() {
    my $*a = 10;
    f();
    say $*a;
}

sub h() {
    my $*a = 20;
    f();
    say $*a;
}

g(); # 11
h(); # 21
g(); # 11

在下一节中, 我们将讨论创建更高级的函数, 例如迭代器。

14.7. 创建并使用迭代器

迭代器是一种强大的技术, 可按需提供数据, 避免手动计数。这些函数每次调用时, 都会返回某个序列的下一个元素。在上一节中, 我们已经创建了迭代器 new-counter, 它可以生成递增的整数。让我们做一个更复杂的东西:

sub make-iter(@data) {
    my $index = 0;
    sub {
        return @data[$index++];
    }
}

my &iter = make-iter(<red green blue orange>);

say iter; # red
say iter; # green
say iter; # blue
say iter; # orange

make-iter 函数得到一个数组, 将 $index 位置安装为 0, 并返回一个子例程作为迭代器。下次调用 iter 对象时, 它返回当前位置的值, 并将内部指针移动到下一个元素。当数据用完后, 会返回 Nil

sub make-factorial-iter() {
    my $n = 1;
    my $f = 1;

    sub {
        $f *= $n++;
        return $f;
    }
}

my &iter = make-factorial-iter();
say iter for 1..5;

迭代器也可以根据一定的规则生成序列。例如, 这里有一个迭代器, 每次调用它时, 都会返回下一个阶乘数:

sub make-factorial-iter() {
    my $n = 1;
    my $f = 1;

    sub {
        $f *= $n++;
        return $f;
    }
}

my &iter = make-factorial-iter();
say iter for 1..5;

该程序会打印出 5 个数字 - 1 到 5 的阶乘数。注意, 该算法没有使用递归或循环, 只需要最少的必要操作来计算下一个值。它总是使用之前计算出的 $n - 1 的值。

现在我们再来说说另一个话题, 惰性计算的数据。

14.8. 惰性和无限列表

这个带阶乘的例子, 只要计算机的内存限制允许, 就可以生成数字。虽然我们可能想计算, 比如说 100 的阶乘, 但程序会在我们真正需要这个值的时候才会进行计算。如果结果还不需要, 就不会花费任何计算资源。这就是惰性计算背后的思想。

在 Raku 中, …​ 操作符可以创建一个序列。最简单的情况看起来与创建序列的方法类似。在下一个例子中, 我们将创建一个普通的数组:

my @a = 1...100;
say @a.elems;

@a 数组被立即创建, 它得到了所有的 100 个元素, 即从 1 到 100 的整数:

say @a[0];  # 1
say @a[1];  # 2

say @a[98]; # 99
say @a[99]; # 100

相反, 使用 lazy 关键字创建的惰性序列, 不会填充数组:

my @b = lazy 1...100;

试图通过调用 @b.elems 来获取它的大小, 会产生一个错误:

Cannot .elems a lazy list

虽然可以获取这个数组的元素:

say @b[0]; # 1
say @b[1]; # 2

在数组末尾请求元素也会生成相应的值:

say @b[98]; # 99
say @b[99]; # 100

最后, 当数组结束, 请求一个额外的元素时, 返回空值 Any。此时, 数组不再是惰性的, elems 方法返回数组的大小。要检查一个数组是否是惰性的, 可以使用 is-lazy 方法:

say @b.is-lazy; # True

say @b[100];    # (Any)
say @b.elems;   # 100
say @b.is-lazy; # False

Raku 还允许我们创建序列, 其上边界是无限的:

my @c = 1 ... Inf; # or 1 ... ∞

say @c[0];    # 1
say @c[1000]; # 1001

由于无法到达这样的序列的末尾, 所以 @c.is-lazy 将始终保持为 True

当你给它提供一个算术或几何级数的例子时, …​ 序列运算符可以生成更复杂的序列:

my @arithm = 1, 3 ... 11;
say @arithm; # [1 3 5 7 9 11]

my @geom = 2, 4, 8 ... 256;
say @geom; # [2 4 8 16 32 64 128 256]

my @float = 3.14, 3.15 ... 3.19;
say @float; # [3.14 3.15 3.16 3.17 3.18 3.19]

要创建一个无限序列, 请使用 Inf* 作为序列的右端:

my @inf = 1, 2 ... Inf;
say @inf[0..5]; # (1 2 3 4 5 6)

这个程序立即打印出结果, 而不需要等到数组被无限列表填满。

最后, 可以使用自定义的生成器来计算下一个值:

my @cubes = {state $n; $n++; $n ** 3} ... Inf;
say @cubes[0..5]; # (1 8 27 64 125 216)

生成代码块是利用 state 变量来记录生成的数字的。

14.9. 总结

在本章中, 我们讨论的是函数式编程。Raku, 虽然不是函数式编程语言, 但它包括的元素可以实现这类语言的许多特性。我们讨论了递归和化简, 讨论了高阶函数、lambda 函数和 WhateverCode 代码块(那些使用 * 来要求 Raku 做你想要的事情的代码块)。我们创建了一些使用使用数据管道、闭包、柯里化和动态作用域的例子。最后, 我们谈到了无限列表和惰性列表, 以及如何生成它们。

下一章的主题是反应式编程, 这是 Raku 支持的另一种编程范式。

15. 反应式编程

在上一章中, 我们讨论了函数式编程。Raku 是一种多范式的语言, 它内置了对这方面的支持。在这一章中, 我们讨论了反应式编程, 也称为函数反应式编程或事件驱动编程。同样, Raku 的内核也支持这种风格的编程, 开箱即用。

本章将讨论以下主题:

  • 反应式编程的概念

  • 按需供应和实时供应

  • 过滤和转换数据流

15.1. 什么是反应式编程

在过程式编程中, 程序列出了变量的指令, 当变量得到一个特定的值, 或者说当一个代码块被执行时, 程序就会列出指令。例如, 一个变量将另外两个整数变量值的和作为它的值:

$z = $x + $y;

如果 $x$y 在这个赋值后被改变, 那么 $z 的值就不会改变。另一个例子 - 函数返回值被赋值给一个变量:

$area = area-of-circle($r);

虽然从代码中可以清楚地看到, 它计算出了给定半径的圆的面积, 但如果改变了 $r 变量, 就必须手动更新 $area 的值。

而反应式编程的目的就是要改变这种依赖值的"静态"行为。许多计算机程序和网页的交互式界面就是反应式编程的很好的例子。想象一下一个在线计算器, 你输入两个值, 在页面上的另一个地方立即得到结果。或者, 你输入一个半径, 圆的面积就会被重新计算并显示出来。让我们看看 Raku 是如何处理这个问题的。

有两个主类可以处理大部分你所需要的反应式编程所需要的东西-Supply, 这是一个异步数据流, 以及 Supplier, 这是一个工厂, 用于供应的变体之一, 即实时供应(live supplies)。还存在- 按需供应(on-demand supplies), 我们先来介绍一下。

15.2. 按需供应

供应的数据流包含两个部分 - 发出数据的供应者和接收数据的阀门。Raku 的反应式编程模型是一个线程安全的观察者设计模式实现。

让我们使用 supply 关键字创建第一个按需供应:

supply {
    emit($_) for 'a'..'e';
}

这里有 supply, 但它还没有发出任何数据, 因为没有需求。如果你在循环中添加一条打印指令, 就可以很容易看到这一点:

supply {
    for 'a'..'e' {
        emit($_);
        say "Emitted $_";
    }
}
sleep 2;

这个程序在 2 秒后就默默地退出了。

为了使供应者产生数据, 我们需要创建一个 tapsupply 块返回一个 Supply 类型的值, 你可以在上面调用 tap 方法来传递响应于发出的数据将被执行的代码:

supply {
    emit($_) for 'a'..'e';
}.tap({
    .say;
});

这一次, 程序打印出了几行从 ae 的字母。让我们在"调试器"程序中打开一个阀门, 看到它是否真的执行了发射块:

supply {
    for 'a'..'e' {
        say "Emitting $_";
        emit($_);
    }
}.tap({
    say "Tap received $_";
});

实际上, 现在两个 say 函数都被调用了:

Emitting a
Tap received a
Emitting b
Tap received b
Emitting c
Tap received c
Emitting d
Tap received d
Emitting e
Tap received e

在一个供应者上连接一个以上的分接头是没有问题的。每一个分接头都能接收到相同的数据:

my $supply = supply {
    emit($_) for 'a'..'e';
}

$supply.tap({
    say "Tap 1 got $_";
});

$supply.tap({
    say "Tap 2 got $_";
});

与通道不同(见第十三章,《并发编程》), 分接头不会竞争获得发送的值。根据需要, 供应者可以提供任意数量的分接头。注意, 这也意味着分接头不作为并行进程工作。如果给第一个分接头添加一个小的延迟, 就可以清楚地看到这一点了:

$supply.tap({
    say "Tap 1 got $_";
    sleep 0.5;
});
$supply.tap({
    say "Tap 2 got $_";
});

在输出中你会看到, 从 a 到 e 的所有值将会先到达第一个分接头, 然后再送达第二个分接头:

Tap 1 got a
Tap 1 got b
Tap 1 got c
Tap 1 got d
Tap 1 got e
Tap 2 got a
Tap 2 got b
Tap 2 got c
Tap 2 got d
Tap 2 got e

15.2.1. 用 suppile 生成数据

在上一节中, 使用 emit 方法将值发送到供应。对于每一个数据项, 都要进行单独的调用。供应可以自己生成数据。Supply 类的 interval 方法会以给定的时间间隔发出数据。在下面的例子中, 它每隔 300 毫秒就会生成一个不断递增的数字:

Supply.interval(0.3).tap({
    say $_;
});
sleep 5;

每当触发一个分接时, 都会得到一个不断递增的整数。第一个值是 0。因此, 上面的程序将打印出从 0 到 16 的数字。

这里需要调用 sleep 函数, 可以看到分接接收到的头几个结果。如果不调用它, 程序就会立即停止。

顺便说一下, 如果你想用一个命名变量代替 $_, 就用一个带参数的尖号块:

Supply.interval(0.5).tap( -> $x {say $x});
sleep 2;

interval 方法也接受第二个参数, 即第一个数据项发出前的延迟, 单位为秒:

Supply.interval(1, 2).tap({
    .say;
});
sleep 4;

现在程序在 2 秒后开始打印数字。初始延迟并不影响供应产生的序列。这个程序也是从 0 开始打印(并以 1 结束, 因为当程序有一个工作的分接时, sleep 函数允许 2 秒的休眠)。

分接可以随时关闭。分接是 Tap 类的对象, 它具有 close 方法。它的用法在下面的程序中可以证明:

my $supply = Supply.interval(0.3);
my $tap = $supply.tap({
    .say;
});

sleep 1;
$tap.close;
sleep 2;

在第一秒内打印完前几个数字后, 分接被关闭, 之后该程序只需再等待几秒钟, 就什么也不打印了。

如果没有分接, 则供应不会产生新的数据。让我们在下面的例子中创建分接之前暂停, 以查看其中的行为:

my $supply = Supply.interval(0.3);

sleep 2;
my $tap = $supply.tap({
    .say;
});
sleep 2;

分接连接前的延迟比潜在数据生成的间隔时间长。在 2 秒内, 可能会生成一些数字, 但程序仍然打印出从 0 开始的数字。

当一个分接连接到已经在为另一个分接生成数据的供应上时, 这个原理也是有效的:

my $supply = Supply.interval(0.5);

say "Tap 1\t| Tap 2";
say '_' x 15;

$supply.tap({
    say "$_\t|";
});

sleep 2;

$supply.tap({
    say "\t| $_";
});

sleep 3;

这里的两个分接器是独立工作的。这就是程序输出的样子:

Tap 1 | Tap 2
_______________
0     |
1     |
2     |
3     |
      | 0
4     |
      | 1
5     |
      | 2
6     |
      | 3
7     |
      | 4
8     |
      | 5
9     |

这两个分接器都接收从零开始的序列。interval 方法是一种创建按需供应的工厂方法:

my $supply = Supply.interval(10);
say $supply.WHAT; # (Supply)

在创建供应时, 有一个替代的语法 - 我们将在下一节讨论。

15.2.2. react 和 whenever 关键字

在 Raku 中, 有一些特殊的关键字用于按需供应。不用显式地创建 Supply 类的对象, 而是使用 react 关键字。在这种情况下, 用 whenever 块来代替创建分接器:

react {
    whenever Supply.interval(0.5) {
        .say;
    }
}

该程序每隔 0.5 秒打印一次数字。注意到与前面的例子的主要区别。使用 react, 在创建一个分接器后, 不需要调用 sleep 或以某种方式控制程序的生命周期。该程序会无限运行, 直到你用 Ctrl + C 退出。

要以编程的方式打破循环, 可以调用 done 函数:

react {
    whenever Supply.interval(0.5) {
        .say;
        done if $_ > 3;
    }
}

这次, 程序只打印出几秒钟的数字。

15.2.3. 使用列表作为 supply 数据的来源

Supply 类提供了一个特殊的方法, from-list, 它接收一个列表, 并从该列表中发送元素作为发出的数据项:

my $supply = Supply.from-list('a'..'e');
$supply.tap({
    .say;
});

或者, 可以使用 react-whenever 结构:

react {
    whenever Supply.from-list('a'..'e') {
        .say;
    }
}

在这两种情况下, 程序会立即将所有的元素流向分接器, 由分接器打印出这些元素。

interval 方法类似, from-list 创建了一个供应对象:

my $supply = Supply.from-list(1..10);
say $supply.WHAT; # (Supply)

现在是时候说说另一种类型的供应了, 即实时供应。

15.3. 实时供应

无论有多少个分接器, 实时供应都会产生数据。与按需供应不同的是, 如果没有打开的分接器, 发射的数据仍然会产生, 只是它会消失。只要打开分接器, 它就会从那一刻开始接收数据; 所有的历史记录都会丢失。

要创建一个实时供应, 请调用 Supplier 类的构造函数。必须在供应上连接一个分接器, 由 Supply 工厂方法返回。这些都在下面的例子中显示了:

my $supplier = Supplier.new;

$supplier.Supply.tap({
    say $_;
});

$supplier.emit($_) for 'a'..'e';

你可能会对 SupplySupplier 类的存在感到困惑。Supplier 类是生产实时供应的工厂。

让我们看看实时供应是如何流式传输数据的, 以及在没有打开分接器的情况下会发生什么。在下面的程序中, 实时供应在由 start 关键字创建的独立线程中生成数据。实际上, start 创造了一个承诺(见第十三章, 《并发编程》), 因此它伴随着 await 关键字等待, 直到全部完成:

my $supplier = Supplier.new;

my $emitter = start {
    for 'a'..'e' {
        sleep 1;
        $supplier.emit($_);
    }
}

sleep 3;
$supplier.Supply.tap({
    say $_;
});

await $emitter;

$emitter 承诺在程序开始后每隔一秒就会发布一次从 'a'…​'e' 范围的数据。3 秒后, 分接器被创建。从那一刻起, 分接开始从供应中获取值, 程序打印出 c、b 和 e。前三个数据片丢失了(没有打开的分接器接收它们)。请注意, 无论是供应还是分接器都没有排队记录历史数据。

如果有一个以上的分接器连接, 实时供应和按需供应都会平均分配数据:

my $supplier = Supplier.new;
my $supply = $supplier.Supply;

$supply.tap({
    say "Tap 1 got $_";
});

$supply.tap({
    say "Tap 2 got $_";
});

$supplier.emit(10.rand);
$supplier.emit(10.rand);

这个程序发出两个随机数, 这两个随机数都会落在这两个分接器中:

Tap 1 got 3.49754022030442
Tap 2 got 3.49754022030442
Tap 1 got 0.196464185630715
Tap 2 got 0.196464185630715

Raku 中的反应式编程是线程安全的。例如, 让我们创建一个程序, 在这个程序中, 所有的供应和分接器都在各自的线程中执行:

my $supplier = Supplier.new;

start {
    $supplier.Supply.tap({
        say "Tap 1 got $_";
    })
}

start {
    $supplier.Supply.tap({
        say "Tap 2 got $_";
    })
}

start {
    sleep 1;
    $supplier.emit(42);
}

sleep 2;

它完美地工作 - 两个分接器都接收到供应发出的值:

Tap 2 got 42
Tap 1 got 42

只要我们有线程, 并且它们并行地工作, 那么程序的输出可能会有所不同, 这取决于哪个分接器先得到数据:

Tap 2 got 42
Tap 1 got 42

要了解更多关于 SupplySupplier 类方法的信息, 请参阅 docs.raku.org 网站的文档。

15.3.1. 过滤和转换数据流

Supply 类的对象具有 grepmap 方法, 可以用来过滤流中的数据, 类似于同名的内置函数。grepmap 方法都会创建一个新的 Supply 对象, 你可以在它上面连接分接器。

请考虑以下示例:

my $supply = Supply.interval(0.3);

my $filtered = $supply.grep(* %% 2);

$filtered.tap({
    .say;
});

sleep 3;

我们在前面的按需供应一节中有一个类似的程序。这一次, 另一个供应, $filtered, 被嵌入到数据流中。它是由在原始的 $supply 上调用的 grep 方法创建的。

过滤器本身是由 WhateverCode* %% 2 实现的。现在只有奇数才会从 $supply 传递到 $filtered

对于其他的, $supply$filtered 对象都是常规的按需供应, 你可以根据需要给它们附加任意数量的分接器:

my $supply = Supply.interval(0.3);
my $filtered = $supply.grep(* %% 2);

$supply.tap({
    "Unfiltered tap got $_".say;
});

$filtered.tap({
    "Filtered tap 1 got $_".say;
});

$filtered.tap({
    "Filtered tap 2 got $_".say;
});

sleep 3;

该程序会生成以下输出, 其中包含所有三个分接器的反应:

Filtered tap 1 got 0
Unfiltered tap got 0
Filtered tap 2 got 0
Unfiltered tap got 1
Unfiltered tap got 2
Filtered tap 1 got 2
Filtered tap 2 got 2
Unfiltered tap got 3
Unfiltered tap got 4
Filtered tap 1 got 4
Filtered tap 2 got 4
Unfiltered tap got 5
Unfiltered tap got 6
Filtered tap 1 got 6
Filtered tap 2 got 6
Unfiltered tap got 7
Unfiltered tap got 8
Filtered tap 1 got 8
Filtered tap 2 got 8
Unfiltered tap got 9

正如你所看到的, 发出的数字是递增的, 而过滤后的供应只接收偶数值。

另一个方法是 map, 对数据进行变换, 并返回变换后的流的新供应。考虑一下下面的例子, 将 interval 供应所生成的所有数字都转换为2次方:

Supply.interval(0.3).map(* ** 2).tap(*.say);
sleep 2;

这里, 为了简洁起见, 使用了两个 *。如果你喜欢更冗长的风格, 可以使用 $_ 变量:

Supply.interval(0.3).map({
    $_ ** 2
}).tap({
    .say
});

sleep 2;

这个程序会按预期的那样, 打印出前几个整数的平方。

15.4. 总结

在本章中, 我们讨论了 Raku 中的反应式编程。这种范式在语言核心中得到了支持, 所以不需要外部模块就可以开始编程。供应是本章的主角 - 我们涵盖了两种不同类型的供应, 按需供应和实时供应。我们有许多将分接器连接到供应上的例子, 看到了数据流是如何组织的, 也看到了数据流是如何过滤的。

这是本书的最后一章。在十五章中, 我们从最基础 Raku 知识出发, 通过面向对象的方法和并发编程到更高阶的功能, 例如函数式编程和反应式编程。Raku 自然地嵌入了所有这些范式。毫无疑问, 15 年多的发展历程, 为整个语言的质量和成熟度增加了不少价值。

本书大约花费了半年时间, Rakudo 发布了 3 个主要的版本。不得不承认, 编译器的质量非常高, 在过去的2到3年时间里, 我从来没遇到过崩溃或怪异的行为。在和各种 Perl 会议的与会者聊天时, 我发现越来越多的人对 Raku 感兴趣, 说现在一切都好用了。你可以下载编译器, 它开箱即用, 提供了 Raku 所拥有的大量功能。

现在语言本身的版本是 6.c. 字母 c 在这里代表圣诞节。有很长一段时间, 有人宣布 Raku 将在圣诞节前完成, 但没有提到具体的年份。终于, 6.c 标准在 2015 年的圣诞节前成为了现实。2017 年晚些时候或 2018 年初, 新版本的语言将发布。你可以在 Larry Wall 在 2017年8月阿姆斯特丹 Perl 大会上发表的主题演讲中找到更多细节 - https://youtube/E5t8qaAGw9w

作为本书的作者, 我希望我的读者能喜欢这门语言, 并开始在实践中使用这门语言。你使用这门语言越多, 你就越了解它的潜力是多么巨大。现在, 我们正处于 Raku 新时代的开端。