1. 第一天 – Raku 鬼精灵: 圣诞节实用指南
看看他们!同事、朋友和亲近的家人都在开心地笑着。他们都在享受着使用 Raku 的 6.c “圣诞”版编程的乐趣。给力的并发原语, 核心文法, 还有非常棒的对象模型。它让我印象深刻!
但是等一下… 就一秒。我有个想法。一个可怕的想法。我想到了一个鬼主意! 我们可以在他们的"圣诞"上捣乱。需要的只有一点花招。哈哈哈哈哈哈!!
欢迎来到 2017 年的 Raku 圣诞日历!每天,从今天直到圣诞节,我们都会有一篇很赞的关于 Raku 的博客推送到你面前。
今天,我们会展示我们淘气的一面并且故意地做些淘气的事情。确实,这有点用,但是淘气点更快乐。我们开始吧!
1.1. But True does False
你听过 but
操作符吗?一个好玩的东西:
say True but False ?? 'Tis true' !! 'Tis false';
# OUTPUT: «Tis false»
my $n = 42 but 'forty two';
say $n; # OUTPUT: «forty two»
say $n + 7; # OUTPUT: «49»
它是一个中缀操作符,它首先拷贝它左边的对象,然后把它右边提供的 role 混进这个拷贝中:
my $n = 42 but role Evener {
method is-even { self %% 2 }
}
say $n.is-even; # OUTPUT: «True»
say $n.^name; # OUTPUT: «Int+{Evener}»
上面的前俩个例子中的那些不是 roles。but
操作符有种便捷的写法:如果 but 右边的东西不是 role,它就会给你创建一个!那个 role 只会有一个方法,以右侧对象的 ^name
命名,并且那个方法只会简单地返回那个给定的对象。因此,这…
put True but 'some boolean'; # OUTPUT: «some boolean»
等价于:
put True but role {
method ::(BEGIN 'some boolean'.^name) {
'some boolean'
}
} # OUTPUT: «some boolean»
.^name
在我们的字符串上返回 Str
, 因为它是一个 Str
对象:
say 'some boolean'.^name; # OUTPUT: «Str»
所以那个 role 提供了一个叫做 Str
的方法, 在非 Str
对象上调用该方法以获取字符串值的输出, 使我们的布尔值变成修改过的字符串化的表示。
举个例子,字符串 0
在 Rakudo Raku 中是 True
但是在 Perl 5 中是 False
。使用 but
操作符, 我们能修改字符串的行为,让它表现的像 Perl 5 版本那样:
role Perl5Str {
method Bool {
nextsame unless self eq '0';
False
}
}
sub perlify { $^v but Perl5Str };
say so perlify 'meows'; # OUTPUT: «True»
say so perlify '0'; # OUTPUT: «False»
say so perlify ''; # OUTPUT: «False»
Perl5Str
这个 role 提供了供 so
子例程调用的 .Bool
方法。在这个方法里面,我们使用 nextsame
子例程重新分派了原来的 .Bool
方法,除非那个字符串是 0
, 那时我们仅仅返回 False
。
but
操作符有一个兄弟: does
中缀操作符。它们的行为相似,但是它不拷贝。
my $o = class { method stuff { 'original' } }.new;
say $o.stuff; # OUTPUT: «original»
$o does role { method stuff { 'modded' } };
say $o.stuff; # OUTPUT: «modded»
程序中的某些东西是全局可访问的,而在有些实现(例如 Rakudo)中,某些常量被缓存了。这意味着我们可以在程序的不同部分变得很淘气,而那些圣诞节的庆祝者们甚至不知道发生了什么!
假如我们覆写了 prompt
子例程的读方法会怎么样?他们喜欢圣诞节?我们来给他们一些圣诞树:
$*IN does role { method get { "🎄 {callsame} 🎄" } }
my $name = prompt "Enter your name: ";
say "You entered your name as: $name";
# OUTPUT
# Enter your name: (typed by user:) Zoffix Znet
# You entered your name as: 🎄 Zoffix Znet 🎄
即使我们把代码粘贴到模块中该覆盖也会起作用。 我们也可以把它提升一个档次,弄乱枚举和缓存的常量,虽然这个顽皮的举动可能将无法跨越模块边界和其他特定实现的缓存失效:
True does False;
say 42 ?? "tis true" !! "tis false";
# OUTPUT: «tis true»
到目前为止,这还没有达到想要的效果,但是让我们试着把我们的数字强制为 Bool
值:
True does False;
say 42.Bool ?? "tis true" !! "tis false";
# OUTPUT: «tis false»
我们做到了! 而现在,对于最后的格林奇 - 值得接触,我们将混淆数字计算的数值结果。 Rakudo 缓存 Int
常量。 当用不同类型的数字计算时,Infix +
运算符也使用 internal-ish-ish .Bridge
方法。 所以,让我们重写常量上的 .Bridge
来返回一些奇怪的东西:
BEGIN 42 does role { method Bridge { 12e0 } }
say 42 + 15; # OUTPUT: «57»
say 42 + 15e0; # OUTPUT: «27»
这是善意的邪恶,肯定会毁了圣诞节,但这只是开始…
1.2. Wrapping It Up
use soft;
sub foo { say 'in foo' }
&foo.wrap: -> | {
say 'in the wrap';
callsame;
say 'back in the wrap';
}
foo;
# OUTPUT:
# in the wrap
# in foo
# back in the wrap
我们使用 use soft
编译指令来防止不必要的内联,否则这些内联会干扰我们的包装。然后,我们使用一个我们想要包装成一个名词的例程,通过它和 &
sigil 来使用它,并调用带有一个Callable
的 .wrap
方法。
给定的 Callable
的签名必须与包装的例程(或其 proto
原型,如果它是一个 multi)兼容;否则我们将无法正确调度程序并使用参数调用包装器。在上面的例子中,我们只是使用匿名的 Capture
(|
)来接受所有可能的参数。
在 Callable
里面,我们有两个 say
调用,并使用 callsame 例程来调用下一个可用的调度候选者,这正好是我们原来的例程。这很方便,因为我们试图在包装器中按照它的名字来调用 foo
,我们将从头开始调度,导致无限的调度循环。
IO::Handle.^lookup('print').wrap: my method (|c) {
my &wrapee = nextcallee;
wrapee self, "🎄 Ho-ho-ho! 🎄\n";
wrapee self, |c
};
print "Hello, World!\n";
# OUTPUT:
# 🎄 Ho-ho-ho! 🎄
# Hello, World!
在这里,我们从 IO::Handle 类型中获取 .print 方法,然后包装它。我们希望在方法内部使用 self
,所以我们使用独立的方法(my method …
)来代替块或子例程。我们想使用 self
的原因是能够调用我们包装的方法来打印我们的 Christmassy 消息。因为我们的方法是分离的,callwith 和相关的例程将需要与其他参数一起自我馈送,以确保我们继续分派给正确的对象。
在 wrap 中,我们使用 nextcallee
例程来获得原始的方法。如果它是一个 multi
,我们将得到 proto
,而不是一个与原始参数最匹配的特定候选者,所以相比传统的例程,下一个 candidate ordering 候选排序在 wrap 中略有不同。我们把 nextcallee
放到一个变量中,因为我们想多次调用它,调用它将例程从调度栈中移出。在第一个调用中,我们打印了我们的 Christmass 信息,而在第二个调用中,我们只是 slip 我们的原始参数的 Capture(|c
),完成了原来想要发生的调用。
感谢 .wrap,我们可以改变甚至完全重新定义子程序和方法的行为,当你的朋友尝试使用它们的时候肯定会很快乐。哈哈哈!
1.3. 看不见的斗篷
我们到目前为止所玩的技巧是非常可怕的,但它们太明显,太…明显。 由于 Raku 具有极好的 Unicode 支持,所以我认为我们应该搜索大量的 Unicode 字符来获得一些有趣的恶作剧。 特别是,我们正在寻找不是空白的隐形字符。 我们的目的只有一个就足够了,但是这四个在我的电脑上是相当隐蔽的:
[] U+2060 WORD JOINER [Cf]
[] U+2061 FUNCTION APPLICATION [Cf]
[] U+2062 INVISIBLE TIMES [Cf]
[] U+2063 INVISIBLE SEPARATOR [Cf]
Raku 支持可以由任何字符组成的自定义术语和操作符,除了空格之外。 例如,这是我的专利耸肩操作符:
sub infix:<¯\(°_o)/¯> {
($^a, $^b).pick
}
say 'Coke' ¯\(°_o)/¯ 'Pepsi';
# OUTPUT: «Pepsi»
这是一个由非标识字符组成的术语(我们也可以在定义中使用真实的字符):
sub term:«\c[family: woman woman boy boy]» {
'♫ We— are— ♪ faaaamillyyy ♬'
}
say 👩👩👦👦;
# OUTPUT: «♫ We— are— ♪ faaaamillyyy ♬»
用我们看不见的非空白字符,我们可以使无形的操作符和术语!
sub infix:«\c[INVISIBLE TIMES]» { $^a × $^b }
my \r = 42;
say "Area of the circle is " ~ πr²;
# OUTPUT: «Area of the circle is 5541.76944093239»
让我们来创建一个 Jolly
模块,它将导出一些不可见的术语和操作符。 然后我们把它们撒在我们的 Christmassy 朋友的代码中:
unit module Jolly;
sub term:«\c[INVISIBLE TIMES]» is export { 42 }
sub infix:«\c[INVISIBLE TIMES]» is export {
$^a × $^b
}
sub prefix:«\c[INVISIBLE SEPARATOR]» (|)
is looser(&[,]) is export
{
say "Ho-ho-ho!";
}
我们对术语和中缀操作符使用了相同的字符。 这很好,因为 Raku 对操作符有相当严格的期望,反之亦然,所以它会知道我们什么时候使用该术语或何时使用中缀操作符。 下面是由此产生的 Grinch 代码,以及它产生的输出:
say 42;
# OUTPUT:
# 1764
# Ho-ho-ho!
这将确保调试的乐趣! 以下是该行代码中的字符列表,供您查看我们使用隐形好东西的位置:
.say for 'say 42;'.uninames;
# OUTPUT:
# INVISIBLE SEPARATOR
# LATIN SMALL LETTER S
# LATIN SMALL LETTER A
# LATIN SMALL LETTER Y
# SPACE
# DIGIT FOUR
# DIGIT TWO
# INVISIBLE TIMES
# INVISIBLE TIMES
# SEMICOLON
1.4. Ho-Ho-Ho
圣诞节时的生产力下降到停滞状态。 人们心中都有节日和新年。 在所有代码中看到大量的 TODO 注释并不让我感到惊讶。 但是如果我们能够发现并投诉他们呢? 只要有人感到懒惰,没有什么比 Grinch 更像编程了!
Raku 有俚语。 这是一个实验性的功能,目前还没有一个官方支持的接口,但是,对于我们的目的来说,它会做的很好。
使用俚语,可以在词法上改变 Raku 的文法,并引入语言特性和行为,就像 Raku 核心开发者一样:
BEGIN $*LANG.refine_slang: 'MAIN',
role SomeExtraGrammar {
token term:sym<meow> {
'This is not a syntax error'
}
},
role SomeExtraActions {
method EXPR (Mu $/) {
say "Parsed expression: " ~ $/;
nextsame
}
}
This is not a syntax error;
say 'hehe'
# OUTPUT:
# Parsed expression: This is not a syntax error
# Parsed expression: 'hehe'
# Parsed expression: say 'hehe'
# hehe
俚语功能的“实验性”部分主要在于不得不依靠 core Grammar 和 core Actions 的结构;目前没有官方保证这些将保持不变,这使得俚语变得脆弱。
对于我们调皮的 Grinchy 技巧,我们将修改注释的行为,如果我们读取代码来追踪调用 the comment token 的代码,我们会发现它实际上是重新定义的 ws token 的一部分,正如您可能从每天都知道的 Raku 文法除其他外,负责语法规则中的空白匹配。
这个问题稍微复杂一些,因为 ws
是一个基石标记,与 comp_unit
,statementlist
和 statement
一起,它不能在 mainline(例程和块之外的代码)中修改。原因是在使用这些令牌的股票版本解析主线之后,俚语被加载。statement
token 内的标记甚至可以在 mainline 中更改,因为 statement
标记会 reblesses 文法,但是 ws
不会获得如此的奢侈。
既然我们已经开始深入到底了……足够的话了!我们来写代码吧:
BEGIN $*LANG.refine_slang: 'MAIN', role {
token comment:sym<todo> {
'#' \s* 'TODO' ':'? \s+ <( \N*
{ die "Ho-ho-ho! I think you were"
~ " meant to finish " ~ $/ }
}
}
sub business-stuff {
# TODO: business stuff
}
# OUTPUT:
# ===SORRY!===
# Ho-ho-ho! I think you were meant to finish business stuff
我们使用 BEGIN phaser 在编译时进行俚语修改,因为我们试图影响如何进一步编译。
我们添加了一个新的 proto
标记: comment:sym<todo>
到核心 Raku 文法,匹配类似于常规注释匹配的内容,除了它还寻找我们的 Christmassy 朋友决定离开的 TODO
。 \N*
原子捕获用户在 TODO
之后键入的字符串,匹配捕获标记指示编译器将存储在 $/
变量中的匹配对象内的捕获文本中的以前匹配的东西排除在外。
在 token 的末尾,我们简单地使用一个代码块来告诉用户完成他们的 TODO 的消息。 很狡猾!
由于我们宁愿用户不注意我们的诡计,让我们将俚语粘贴到目标代码将要加载的模块中。 我们只是稍微调整一下原来的代码:
# File: ./Jolly.pm6
sub EXPORT {
$*LANG.refine_slang: 'MAIN', role {
token comment:sym<todo> {
'#' \s* 'TODO' ':'? \s+ <( \N*
{ die "Ho-ho-ho! I think you were"
~ " meant to finish " ~ $/ }
}
}
Map.new
}
# File: ./script.p6
use lib <.>;
use Jolly;
sub business-stuff {
# TODO: business stuff
}
# OUTPUT:
# ===SORRY!===
# Ho-ho-ho! I think you were meant to finish business stuff
我们希望俚语在脚本的编译时运行,而不是在模块中,所以我们删除了 BEGIN
phaser,而是将代码固定在 sub EXPORT 中,在脚本编译过程中使用该模块时运行。 Map.new
就是我喜欢在 EXPORT
sub 中写 {}
,以表示我们不希望导出任何符号。 在我们的脚本中,我们现在只需要使用模块,俚语被激活。真棒!
1.5. 结论
今天,我们开始淘气的 Grinches 2017 年的 Raku 的降临日历和搞乱用户的程序。 我们使用 but`和 `does
操作符来改变对象。 包装的方法和子程序与我们的自定义例程,实现额外的功能。 做出隐形术语和操作符。 甚至突变语言本身来做我们的竞标。
在接下来的 23 天里,我们会看到更多的 Raku Advent 文章,所以一定要回头看看。 也许,到这一切的尽头,我们的 Grinchy 心将长大三个尺寸…
-Ofun
2. 第二天-Raku: 符号, 变量和容器
今天,我们将学习什么是容器,以及如何使用它们,但是首先,我希望你暂时忘记你对 Raku 的符号和变量的所有知识或怀疑,特别是如果你来自 Perl 5 的背景。 忘记一切。
2.1. 把钱拿出来
在 Raku 中,变量以 $
符号为前缀,用绑定运算符(:=
)赋值。 像这样:
my $foo := 42;
say "The value is $foo"; # OUTPUT: «The value is 42»
my $ordered-things := <foo bar ber>;
my $named-things := %(:42foo, :bar<ber>);
say "$named-things<foo> bottles of $ordered-things[2] on the wall";
# OUTPUT: «42 bottles of ber on the wall»
.say for $ordered-things; # OUTPUT: «foobarber»
.say for $named-things; # OUTPUT: «bar => berfoo => 42»
了解这一点,你可以写出各种各样的程序,所以如果你开始觉得有太多的东西需要学习,记住你不需要一次学习所有东西。
2.2. 我们祝你有一个愉快的列表圣诞
让我们试着用我们的变量做更多的事情。 想要更改列表中的值并不罕见。 到目前为止我们的表现如何呢?
my $list := (1, 2, 3);
$list[0] := 100;
# OUTPUT: «Cannot use bind operator with this left-hand side […] »
尽管我们可以绑定到变量,但是如果我们试图绑定到某个值,我们会得到一个错误,无论这个值是来自 List
还是只是一个字面值:
1 := 100;
# OUTPUT: «Cannot use bind operator with this left-hand side […] »
这就是为什么列表是不可变的。 然而,这是一个实现愿望的季节,所以我们希望有一个可变的 List
!
我们需要掌握的是一个 Scalar
对象,因为绑定操作符可以使用它。 顾名思义,一个 Scalar
存储一个东西。 你不能通过 .new
方法实例化一个 Scalar
,但是我们可以通过声明一些词法变量来得到它们。 不需要费心给他们的名字:
my $list := (my $, my $, my $);
$list[0] := 100;
say $list; # OUTPUT: «(100 (Any) (Any))»
输出中的 (Any)
是容器的默认值(稍后一点)。 上面,似乎我们设法在 List
创建后将一个值绑定到列表的元素上,我们不是吗? 确实我们做了,但是…
my $list := (my $, my $, my $);
$list[0] := 100;
$list[0] := 200;
# OUTPUT: «Cannot use bind operator with this left-hand side […] »
绑定操作用一个新的值(100
)代替 Scalar
容器,所以如果我们试图再次绑定,我们又回到了原来的方括号那个,试图绑定到一个值,而不是一个容器。
我们需要一个更好的工具。
2.3. That’s Your Assignment
绑定运算符有一个表亲:赋值运算符(=
)。 我们不用一个绑定操作符替换我们的 Scalar
容器,而是使用赋值操作符来赋值或者“存储”我们在容器中的值:
my $list := (my $ = 1, my $ = 2, my $ = 3);
$list[0] = 100;
$list[0] = 200;
say $list;
# OUTPUT: «(200 2 3)»
现在,我们可以从一开始就指定我们的原始值,并且可以随时用其他值替换它们。 我们甚至可以变得时髦,并在每个容器上放置不同的类型约束:
my $list := (my Int $ = 1, my Str $ = '2', my Rat $ = 3.0);
$list[0] = 100; # OK!
$list[1] = 42; # Typecheck failure!
# OUTPUT: «Type check failed in assignment;
# expected Str but got Int (42) […] »
这有些放纵,但有一件事可以使用类型约束:$list
变量。 我们将其限制为 Positional
角色,以确保它只能保持 Positional
类型,就像 List
和 Array
:
my Positional $list := (my $ = 1, my $ = '2', my $ = 3.0);
不知你咋想的,但是这对我来说看起来非常冗长。 幸运的是,Raku 有语法来简化它!
2.4. Position@lly
首先,让我们摆脱变量的显式类型约束。 在 Raku 中,您可以使用 @
而不是 $
作为符号来表示您希望变量受到角色 Positional
的类型约束:
my @list := 42;
# OUTPUT: «Type check failed in binding;
# expected Positional but got Int (42) […] »
其次,我们将使用方括号来代替圆括号来存储我们的 List
。 这告诉编译器创建一个 Array
而不是一个 List
。 Array
s 是可变的,它们将每个元素自动粘贴到 Scalar
容器中,就像我们在前一节中手动操作一样:
my @list := [1, '2', 3.0];
@list[0] = 100;
@list[0] = 200;
say @list;
# OUTPUT: «[200 2 3]»
我们的代码变得更短了,但我们可以折腾更多的字符。 就像赋值给`$-sigiled 变量而不是绑定一样,你可以赋值给
@` -sigiled 变量来获得一个自由的 Array
。 如果我们切换到赋值,我们可以完全摆脱方括号:
my @list = 1, '2', 3.0;
好,简洁。
类似的想法背后是 %
- 和 &
符号化的变量。 %
sigil 意味着 Associative
角色的类型约束,并为赋值提供相同的快捷方式(给你一个 Hash
),并为这些值创建 Scalar
容器。 对于角色 Callable
和赋值的 &
-sigiled 变量类型 - 行为类似于 $
sigils,给出一个可以修改其值的自由 Scalar
容器:
my %hash = :42foo, :bar<ber>;
say %hash; # OUTPUT: «{bar => ber, foo => 42}»
my &reversay = sub { $^text.flip.say }
reversay '6 lreP ♥ I'; # OUTPUT: «I ♥ Raku»
# store a different Callable in the same variable
&reversay = *.uc.say; # a WhateverCode object
reversay 'I ♥ Raku'; # OUTPUT: «I ♥ PERL 6»
2.5. The One and Only
之前我们知道赋值给 $
-sigiled 变量会给你一个免费的 Scalar
容器。 由于标量,顾名思义,只包含一个东西……如果你把一个 List
放到 Scalar
中会发生什么? 毕竟,当你试图这样做的时候,宇宙仍然没有被扼杀:
my $listish = (1, 2, 3);
say $listish; # OUTPUT: «(1 2 3)»
这样的行为可能使 Scalar
看起来似乎是一个用词不当,但它确实把整个列表视为一个东西。 我们可以通过几种方式观察其差异。 我们来比较绑定到 $
-sigiled 变量的 List
(所以不包含 Scalar
)和赋值给 $
-sigiled 变量(自动 Scalar
容器)的 List
:
# Binding:
my $list := (1, 2, 3);
say $list.perl;
say "Item: $_" for $list;
# OUTPUT:
# (1, 2, 3)
# Item: 1
# Item: 2
# Item: 3
# Assignment:
my $listish = (1, 2, 3);
say $listish.perl;
say "Item: $_" for $listish;
# OUTPUT:
# $(1, 2, 3)
# Item: 1 2 3
.perl
方法给了我们一个额外的见解,并在第二个 List
之前显示了一个 $
,以表明它在 Scalar
中是集装箱化的。 更重要的是,当我们用 for
循环迭代我们的 List
s 时,第二个 List
结果只有一个迭代:整个 List
作为一个项目! Scalar
没有辜负它的名字。
这种行为不仅仅是学术上的兴趣。 回想一下,Array
s(和 Hash
es)为它们的值创建 Scalar
容器。 这意味着如果我们嵌套东西,即使我们选择一个单独的列表或散列在里面存储着 Array
(或 Hash
),并试图迭代它,它将只被视为一个单一的项目:
my @stuff = (1, 2, 3), %(:42foo, :70bar);
say "List Item: $_" for @stuff[0];
say "Hash Item: $_" for @stuff[1];
# OUTPUT:
# List Item: 1 2 3
# Hash Item: bar 70
# foo 42
my @stuff = (1, 2, 3), %(:42foo, :70bar);
say flat @stuff;
# OUTPUT: «((1 2 3) {bar => 70, foo => 42})»
-> *@args { @args.say }(@stuff)
# OUTPUT: «[(1 2 3) {bar => 70, foo => 42}]»
正是这种行为可以将 Raku 初学者推上墙,特别是那些来自 Perl 5 自动展平语言的人。然而,现在我们知道为什么会出现这种行为,我们可以改变它!
2.6. Decont
如果 Scalar
容器是罪魁祸首,我们所要做的就是删除它。 我们需要将我们的列表和哈希值去容器化,或者简称为 “decont”。 在你的 Raku 之旅中,你可以找到几种方法来完成这个工作,但是为此设计的一个方法就是 decont methodop(<>
):
my @stuff = (1, 2, 3), %(:42foo, :70bar);
say "Item: $_" for @stuff[0]<>;
say "Item: $_" for @stuff[1]<>;
# OUTPUT:
# Item: 1
# Item: 2
# Item: 3
# Item: bar 70
# Item: foo 42
它很容易记住:它看起来像一个被挤压的盒子(一个被踩踏的容器)。 在通过索引到 Array
中检索我们的容器化项目之后,我们附加了 decont 并从 Scalar
容器中移除了内容,导致我们的循环遍历它们中的每个项目。
如果您希望一次去除 Array
中的每个元素,只需使用超运算符(»
,或 >>
,如果您更喜欢使用 ASCII)就可以使用 decont:
my @stuff = (1, 2, 3), %(:42foo, :70bar);
say flat @stuff»<>;
# OUTPUT: «(1 2 3 bar => 70 foo => 42)»
-> *@args { @args.say }(@stuff»<>)
# OUTPUT: «[1 2 3 bar => 70 foo => 42]»
my @stuff := (1, 2, 3), %(:42foo, :70bar);
say flat @stuff;
# OUTPUT: «(1 2 3 bar => 70 foo => 42)»
-> *@args { @args.say }(@stuff)
# OUTPUT: «[1 2 3 bar => 70 foo => 42]»
2.7. 不要让它溜走
当我们在这里的时候,值得注意的是,当他们想要执行 decont(我们不是在传递参数给 Callable
的时候使用它)时,许多人使用 *slip*运算符(|
):
my @stuff = (1, 2, 3), (4, 5);
say "Item: $_" for |@stuff[0];
# OUTPUT:
# Item: 1
# Item: 2
# Item: 3
虽然它可以完成工作,但可能会引入微妙的 bugs,这些 bug 可能很难追查到。 尝试在这里找到一个,在一个程序中迭代了一个无限的非负整数列表,并打印那些素数:
my $primes = ^∞ .grep: *.is-prime;
say "$_ is a prime number" for |$primes;
有问题的部分是我们的 |
slip 操作符。 它将我们的 Seq
转换成一个 Slip
,这是一个 List
类型,并且保存我们已经消耗的所有的值。 如果您希望在 htop
中看到增长,那么这个程序的修改版本会更快地增长:
# CAREFUL! Don't consume all of your resources!
my $primes = ^∞ .map: *.self;
Nil for |$primes;
让我们再试一次,但是这次使用 decont 方法 op:
my $primes = ^∞ .map: *.self;
Nil for $primes<>;
my $primes := ^∞ .map: *.self;
Nil for $primes;
2.8. I Want Less
如果你讨厌符号,Raku 会得到一些你可以微笑的东西:无符号的变量。 只要在声明中加一个反斜杠的前缀,表示你不想要讨厌的符号:
my \Δ = 42;
say Δ²; # OUTPUT: «1764»
my \Δ = my $ = 42;
Δ = 11;
say Δ²; # OUTPUT: «121»
一个更常见的地方,你可能会看到这样的变量是作为例程的参数,在这里,这意味着你想把 is raw
trait 应用到参数上。 这在 +
positional slurpy 参数的含义也是存在的(不需要反斜杠),如果它是 is raw
的,意味着你将不会得到不需要的 Scalar
容器,因为它是一个 Array
,因为它具有 @
sigil:
sub sigiled ($x is raw, +@y) {
$x = 100;
say flat @y
}
sub sigil-less (\x, +y) {
x = 200;
say flat y
}
my $x = 42;
sigiled $x, (1, 2), (3, 4); # OUTPUT: «((1 2) (3 4))»
say $x; # OUTPUT: «100»
sigil-less $x, (1, 2), (3, 4); # OUTPUT: «(1 2 3 4)»
say $x; # OUTPUT: «200»
2.9. Defaulting on Default Defaults
容器提供的一个很棒的功能是默认值。 你可能听说过在 Raku 中,`Nil`表示缺少一个值,而不是一个值。 容器默认值就是它的作用:
my $x is default(42);
say $x; # OUTPUT: «42»
$x = 10;
say $x; # OUTPUT: «10»
$x = Nil;
say $x; # OUTPUT: «42»
一个容器的默认值是使用 is default
trait 给它的。 它的参数是在编译时计算的,每当容器缺少一个值时,就使用结果值。 由于 Nil
的工作是表明这一点,因此将 Nil
分配到容器中将导致容器包含其默认值,而不是 Nil
。
my @a is default<meow> = 1, 2, 3;
say @a[0, 2, 42]; # OUTPUT: «(1 3 meow)»
@a[0]:delete;
say @a[0]; # OUTPUT: «meow»
my %h is default(Nil) = :bar<ber>;
say %h<bar foos>; # OUTPUT: «(ber Nil)»
%h<bar>:delete;
say %h<bar> # OUTPUT: «Nil»
容器的默认值有一个默认的默认值:容器上的显式类型约束:
say my Int $y; # OUTPUT: «(Int)»
say my Mu $z; # OUTPUT: «(Mu)»
say my Int $i where *.is-prime; # OUTPUT: «(<anon>)»
$i.new; # OUTPUT: (exception) «You cannot create […]»
如果没有明确的类型约束,默认的默认值是一个 Any
类型的对象:
say my $x; # OUTPUT: «(Any)»
say $x = Nil; # OUTPUT: «(Any)»
请注意,您可能在可选参数的例程签名中使用的默认值不是容器默认值,将 Nil
分配给子例程参数或分配给参数不会使用签名中的默认值。
2.10. 自定义
如果容器的标准行为不适合您的需求,您可以使用 Proxy
类型创建自己的容器:
my $collector := do {
my @stuff;
Proxy.new: :STORE{ @stuff.push: @_[1] },
:FETCH{ @stuff.join: "|" }
}
$collector = 42;
$collector = 'meows';
say $collector; # OUTPUT: «42|meows»
$collector = 'foos';
say $collector; # OUTPUT: «42|meows|foos»
每当从容器中读取一个值时,FETCH``Callable
被调用,这可能比直接看到的次数多出现一次:在上面的代码中,当容器通过调度和例程这两个调用渗透时,FETCH``Callable
被调用 10 次。 Callable
被调用一个单一的位置参数:Proxy
对象本身。
我们希望 STORE
和 FETCH
Callable
共享 @stuff
变量,所以我们使用 do
statement prefix 和一个代码块来很好地包含它。
我们将我们的 Proxy
绑定到一个变量,其余的只是正常的变量用法。输出显示我们的自定义容器提供的改变过的行为。
class Foo {
has $!foo;
method foo {
Proxy.new: :STORE(-> $, Int() $!foo { $!foo }),
:FETCH{ $!foo }
}
}
my $o = Foo.new;
$o.foo = ' 42.1e0 ';
say $o.foo; # OUTPUT: «42»
2.11. 这就是全部,伙计
那关于这一切。 在 Raku 中你将会看到的剩下的动物是 “twigils”:名称前带有两个符号的变量,但是就容器而言,它们的行为与我们所介绍的变量相同。 第二个符号只是表示附加信息,如变量是隐含的位置参数还是命名参数…
sub test { say "$^implied @:parameters[]" }
test 'meow', :parameters<says the cat>;
# OUTPUT: «meow says the cat»
…或者该变量是私有属性还是公共属性:
with class Foo {
has $!foo = 42;
has @.bar = 100;
method what's-foo { $!foo }
}.new {
say .bar; # OUTPUT: «[100]»
say .what's-foo # OUTPUT: «42»
}
然而,这是另一天的旅程。
3. 第三天 – LetterOps with Raku
3.1. 规模
“规模!规模就是一切!“。
当圣诞老人的声音传到他们身上时,精灵散落在四面八方。
“这个 operation 是为三十四个孩子准备的?现在我们有无数的!大人也送信!“
小精灵 Buzzius 站了出来,喷出“但现在我们有电脑!”,又回到他精灵的追求。
“他们有什么好处?请告诉我,如果我仍然需要阅读每一封信,我该怎么办?“。
小精灵 Diodius 短暂地从藏身处抬起头,说:“告诉孩子们发一封文字信”。
圣诞老人停止了叫喊,并抓住了他有胡子的下巴。 “我可以做到这一点”。早期的儿童采用者就像这样发了一封信。
Dear Santa: I have been a good boy so I want you to bring me a collection of scythes and an ocean liner with a captain and a purser and a time travel machine and instructions to operate it and I know I haven't been so good at times but that is why I'm asking the time machine so that I can make it good and well and also find out what happened on July 13th which I completely forgot.
“我能做到吗?”。圣诞老人重复自己。他必须从单线混乱中提取一份礼物清单。例如,除以 and
。
当然,使用 Raku 可以使用 $þ
作为变量,甚至可以使用) our $ᚣ= True
作为标准,这是他最喜欢的语言。在一行中,您可以获得所有块,如下所示:
[ "Dear Santa: I have been a good boy so I want you to bring me a collection of scythes", "an ocean liner with a captain", "a purser", "a time travel machine", "instructions to operate it", "I know I haven't been so good at times but that is why I'm asking the time machine so that I can make it good", "well", "also find out what happened on July 13th which I completely forgot.\n" ]
/\s* «and» \s*/
regexp 使用了 `and`s 并且移除了空格,创建了一组句子。这些句子可能包含或不包含客户希望圣诞老人带来的东西。这让圣诞老人又一次咆哮起来。 “规模和结构!我们需要扩展,我们需要结构!“
3.2. Markdown 来拯救
“马修斯投了出来。”每个人都知道 Markdown。这是文字,为结构引入了几个标志。“
奥克斯正在努力晋升为第二级的精灵,他说。 “用 Elvish-est 的语言,榆树。你知道,这是精灵,但对于一封信“
“我可以做到这一点,”圣诞老人说。精灵喜欢他可以做的方法。所以他安装了所有东西,并做了这个小程序
圣诞老人安静了约 30 秒。然后再次听到了他的咆哮。
“永远,你听到我说话了吗?我从来不想再听到复活节兔子或其他邪恶生物的这种产卵。“
离屏幕最近的那些精灵观察到大量的红色,但不是很好的红色,没有任何类似于工作代码。所以他们给了鲁道夫一张便条(红红的鼻子驯鹿)一张便条,他用一只小鹿角忠实地扛着它。
“那么我们应该回到 Raku 吗?”
3.3. 使用 Raku 处理 Markdown
圣诞老人发现了 Text::Markdown
,他立即安装了该模块:
zef install Text::Markdown
它有 Text,它有 Markdown,它承诺处理它,这是他所需要的。所以他向他的客户群通报说,如果你希望这个人在你的烟囱里拿着一个装有好东西的麻袋,那么今年就需要降价了。
早期的采用者再一次回答了这个问题
# Dear Santa
I have been a good boy so I want you to bring me a collection of
scythes and an ocean liner with a captain and a purser and a time
travel machine and instructions to operate it and I know I haven't
been so good at times but that is why I'm asking the time machine so
that I can make it good and well and also find out what happened on
July 13th which I completely forgot.
那么,这是降价,是不是?这是妥善处理和所有。 “正确处理一封信很重要”,圣诞老人大声说道,大声说道,只是让鲁道夫惊呆了,鲁道夫是唯一一个挂着的人。 “它给结构。让我们检查一下信件是否有这个“。
“哇!”圣诞老人说。然后,“哇”。只需几行代码即可阅读并理解文档的结构,另一行则检查是否至少有一个是标题。如果是这样,它会表示真。这是真的。
圣诞老人很高兴一小会儿。他抓住了鲁道夫脖子的后背,这让他感到惊讶。然后他停止了这样做。鲁道夫抬起头来,只是稍微支撑了他的后腿,感到不快。
3.4. 需要更多的结构
圣诞老人发现了这封信:
# Dude
=== Welll...
I have been a naughty person
=== Requests
Well...
妥善处理和一切,他不能浪费他的时间与不好的人。规模。和资源。资源只能用于好人,而不是坏人。坏人不好,就是这样。所以回到编码,鲁道夫溜走寻找地衣糖或任何东西,他出示了这样的:
圣诞老人对第二个标题之后的段落提取技巧以及他能够很好地使用他所喜爱的 Thorn 信件这一事实感到自豪。他还喜欢函数式编程,在 Lisp 中咬牙切齿。所以他创建了这个最初是假的翻转开关,但是当它正在处理的元素是一个标题并且其级别是两个时,翻转开关。他也很高兴他可以用标记的文本顶部的分层结构来做这种事情。
此外,他可以检查该标题(行为)与下一行之间的任何段落中是否出现“好”字。而且任何一个都很酷。其中一个段落提到的很好就足够了。最后一行将首先返回一个布尔值数组,如果其中一个包含好的话,它最终将会声明为 True。否则为假。适合从坏的方面挑选好的东西。
圣诞老人很开心。 -ier。但仍然。
3.5. 这里的玩具是重要的
所以他真正想要的是玩具清单。在再次请求改变信件格式,他可以做的,因为他是圣诞老人,每个人都希望他的圣诞节免费的东西,他开始接收这种结构的信件:
# Dear Santa
=== Behavior
I have been a good boy
=== Requests
And this is what I want
- scythes
- an ocean liner with a captain and a purser
- a time travel machine and instructions to operate it
他们在结构上的自发性缺乏。而且结构很好。你可以得到一个请求列表:
这实际上是链表列表处理表达式的一个不清晰的列表。在这之前的这句话有一个列表提及几乎一样坏。但让我们看看那里发生了什么。
首先在列表中,我们仅使用正则表达式和东西来获取请求标题后面的内容。我们本可能已经把它归结为对 Str 的转变,但是我们已经失去了结构。结构很重要,圣诞老人永远不会厌倦这一点。接下来,我们只提取那些实际上是列表的元素,将所有绒毛都取出来。
而事实恰恰是,结构太多这样的事情。该列表包含具有元素的元素。
那或 Text::Markdown 可以做一个大改造。这篇文章的作者正在将他的特别愿望清单放在这里。
3.6. 还没有
但几乎。我们有这个名单,现在圣诞老人发现像时间旅行机器和星期一这样的事情。他不能在精灵工厂订购周一。他必须阅读每一件事情。但不用担心。这也可以照顾到:
简单来说,这个程序会遍历愿望清单中保存的项目清单,并检查产品性能。它是一种产品吗?它走了。你是在问上周五晚上,你完全错过了什么?它不,也不敢浪费圣诞老人的时间,男孩。
这件事的要点在于使用全新的 Wikidata::API 模块的 Wikidata 查询。此模块只是将内容发送到 Wikidata API 并将其作为对象返回。相信与否,这就是 SPARQL 查询的作用:将项目名称插入到查询中,进行查询,并在返回的元素数量不为零时返回 true。产品在你的指尖!在几行代码中!现在,他可以将所有东西链接在一起,并从包含此信件的信件中获取
- Morning sickness
- Scythe
- Mug
只有你们可以从当地,市中心,妈妈和流行商店订购的其中两件,这是圣诞老人实际上偷偷购买所有东西的地方,因为他大量购买,并且他得到了很好的交易。
圣诞老人微微一笑,精灵,驯鹿和几只海雀在那里没有任何理由就爆发出大声的欢呼声。然后,他们往下看
3.7. 包起来
圣诞老人和 Raku 是一个很好的比赛,因为他们都是在圣诞节的时候来的。圣诞老人发现你可以自己做很多有用的事情,或者使用最近可用的优质模块之一。
不过,这位作者在给圣诞老人的信中将包括一些帮助,以继续介绍由他维护的这篇文章中使用的两个模块,这些模块需要更多有经验的编码人员进行测试,扩展或者重新编写。但他很高兴地看到,使用 Raku 可以直接完成处理给圣诞老人的信件等世俗和略微神圣的事情。你也应该这样做。
这篇文章的代码和样例可以从 GitHub 获得。也是这个文本。帮助和建议非常受欢迎。
4. 第四天-使用 Grammars 进行解析
下面是从 Parsing with Raku Regexes and Grammars: A Recursive Descent into Parsing 这本书里面提取出来的一章, 作者是 Moritz Lenz, 由 Apress Media 出版社出版。版权经过允许。
这本书马上就要出版了。至少该书的电子版这个月应该可以购买, 纸质版的可以在亚马逊 预定了。原本最迟会在 2018 年元月发出, 但是幸运的是, 圣诞节你就可以看到了。
下面你会看到第九章, 使用 Grammars 进行解析。前面的章节详细探讨了创建正则表达式块儿、正则表达式怎么和 Raku 代码进行交互、匹配对象、正则力学、常用正则技术,还有重用和组合正则。你可以通过阅读正则表达式官方文档来获取更多关于正则的背景。
后面的章节涵盖了 action 类和对象, 怎么报告高质量的解析错误, Unicode 支持, 最后还有三个案例研究。
现在, 尽情享受吧!
Grammar 是众人皆知的用于解析的瑞士军刀。
在本章中,我们将更详细地探讨它们。 最重要的是,我们将讨论如何利用他们的威力。
4.1. 理解 Grammars
Grammars 实现了自顶向下的解析方法。 入口点,通常是 TOP
regex 正则表达式,它知道粗粒度的结构,并调用下降到繁复细节的更深一步的正则表达式。 也会涉及到递归。 例如,如果解析算术表达式,则操作符可以是一对括号内的任意表达式。
这是一个自顶向下的结构,或者更确切地说是一个递归下降分析方法。 如果不涉及回溯,我们称之为*预测分析法*,因为在字符串中的每个位置,我们确切地知道我们在寻找什么 - 我们可以预测下一个 token 将会是什么(即使我们只能预测它可能是一组可选分支的其中之一)。
结果匹配树在结构上完全对应于 grammar 中正则表达式的调用结构。 让我们考虑解析一个只包含运算符 *
,`+`和用于分组的括号的算术表达式:
grammar MathExpression {
token TOP { <sum> }
rule sum { <product>+ % '+' }
rule product { <term>+ % '*' }
rule term { <number> | <group> }
rule group { '(' <sum> ')' }
token number { \d+ }
}
say MathExpression.parse('2 + 4 * 5 * (1 + 3)');
从 Grammar 本身,你已经可以看到递归的可能性:sum
调用 product
,product
调用 term
,term
调用 group
,group
再次调用 sum
。 这允许解析任意深度的嵌套表达式。
解析上面的例子产生下面的匹配对象:
⌜2 + 4 * 5 * (1 + 3)⌟
sum => ⌜2 + 4 * 5 * (1 + 3)⌟
product => ⌜2 ⌟
term => ⌜2 ⌟
number => ⌜2⌟
product => ⌜4 * 5 * (1 + 3)⌟
term => ⌜4 ⌟
number => ⌜4⌟
term => ⌜5 ⌟
number => ⌜5⌟
term => ⌜(1 + 3)⌟
group => ⌜(1 + 3)⌟
sum => ⌜1 + 3⌟
product => ⌜1 ⌟
term => ⌜1 ⌟
number => ⌜1⌟
product => ⌜3⌟
term => ⌜3⌟
number => ⌜3⌟
如果你想知道某个特定的数字是如何解析的,你可以通过查找当前行上缩进较少的行来追踪路径。 例如,数字 1
由 token number`解析,调用自 `term
,再调用自 product
,以此类推。
我们可以通过从 token number
引发异常来验证这一点:
token number {
(\d+)
{ die "how did I get here?" if $0 eq '1' }
}
这确实显示了回溯中的调用链,其中最直接的上下文是:
how did I get here?
in regex number at bt.p6 line 9
in regex term at bt.p6 line 5
in regex product at bt.p6 line 4
in regex sum at bt.p6 line 3
in regex group at bt.p6 line 6
in regex term at bt.p6 line 5
in regex product at bt.p6 line 4
in regex sum at bt.p6 line 3
in regex TOP at bt.p6 line 2
in block <unit> at bt.p6 line 13
这个语法只使用 tokens 和 rules,所以不涉及回溯,而 grammar 是一个预测分析法。 这是相当典型的。 没有回溯或在几个地方有回溯时, 许多 grammars 都工作正常。
4.2. 递归下降分析法和优先级
MathExpression
grammar 有两个结构相同的 rules:
rule sum { <product>+ % '+' }
rule product { <term>+ % '*' }
但是, 我们也可以写成:
rule expression { <operator>+ % <term> }
token operator { '*' | '+' }
或者甚至使用前一章讨论的 proto token
构造来解析不同的操作符。我选择第一种更重复的方法的原因是它使匹配结构对应于运算符 *
和 +
的优先级。
当计算数学表达式 1 + 2 * 5
时,数学家和大多数编程语言首先计算 2 * 5
,因为 *
运算符的优先级高于 +
。然后将结果代入表达式,成为 1 + 10
,最后得到 11
。
当用 grammar 的第一个版本解析这样的表达式的时候,解析树的结构表示这个分组:它具有 - 作为最高级 - 单个 sum,操作数是 1
和 2 * 5
。
这是有代价的:对于每个优先级我们需要一个单独的 rule 和名字,并且所产生的结果匹配对象的嵌套层级, 每个优先级至少有一级。而且,稍后增加更多的优先级并不是微不足道的,而且很难通用。如果您不愿意接受这些成本,则可以使用具有单个 token 的平级模型来解析所有运算符。如果您需要能反映优先级的结构,则可以编写代码将列表转换为树。这通常被称为运算符优先级解析器。
4.3. 左递归和其他陷阱
为了避免无限递归,你必须注意,每个可能的递归循环至少将游标位置推进了一个字符。在 MathExpression
grammar 中,唯一可能的递归循环是 sum
→product
→term
→group
→sum
,并且 group
只有在消耗了一个初始开口圆括号 (
时才匹配。
如果递归不消耗字符,则它被称为*左递归*,并且需要特殊的语言支持, 这个 Raku 并不支持。一个例子是:
token a { <a>? 'a' }
它本该与正则表达式 a+
匹配相同的输入,但是却无限循环而不前进。
避免左递归的一个常用技巧是有一个可以按照从通用(这里是 sum
)到特定(number
)顺序排序正则表达式的结构。当正则表达式偏离该顺序时(例如 group `调用 `sum
),你只需要关心并检查消耗的字符。
无限循环的另一个潜在来源是在量词化能匹配空字符串的正则表达式时。在解析允许某些内容为空的语言时可能会发生这种情况。例如,在 UNIX shell 中,你可以在给变量赋值的时候把右侧置空:
VAR1=value
VAR2=
在为 UNIX shell 命令编写 grammar 时,编写一个可能匹配空字符串的 token string { \w* }
可能会很冒险。 在允许多于一个字符串字面值的情况下,<string>+
就会挂起,因为实际的正则表达式 [\w*]+
试图无限次地匹配一个零宽度的字符串。
一旦你意识到了这个问题,解决方案就变得非常简单:将 token 更改为不允许空字符串(token string { \w+ }
),并显式地处理允许空字符串的情况:
token assignment {
<variable> '=' <string>?
}
4.4. 始于简单
即使 grammar 是自上而下工作的,但是开发的时候最好开自下而上。 一开始,grammar 的总体结构往往是不明显的,但是你通常知道*末端* token:那些能直接匹配文本而不需要调用其他 subrules 的 token。
在前面的解析数学表达式的例子中,你可能一开始不知道如何安排解析 sums 和 products 的 rules,但你很可能知道必须在某个时候解析数字,所以一开始你可以这样写:
grammar MathExpression {
token number { \d+ }
}
这并不是很多,但也不是很复杂,这是程序员有时在遇到新问题领域时克服挑战的一种很好的方式。 当然,一旦你有了 token,就可以开始写一些测试了:
grammar MathExpression {
token number { \d+ }
}
multi sub MAIN(Bool :$test!) {
use Test;
plan 2;
ok MathExpression.parse('1234', :rule<number>),
'<number> parses 1234';
nok MathExpression.parse('1+4', :rule<number>),
'<number> does not parse 1+4';
}
现在,您可以以自己的方式创建更复杂的表达式:
grammar MathExpression {
token number { \d+ }
rule product { <number>+ % '*' }
}
multi sub MAIN(Bool :$test!) {
use Test;
plan 5;
ok MathExpression.parse('1234', :rule<number>),
'<number> parses 1234';
nok MathExpression.parse('1+4', :rule<number>),
'<number> does not parse 1+4';
ok MathExpression.parse('1234', :rule<product>),
'<product> can parse a simple number';
ok MathExpression.parse('1*3*4', :rule<product>),
'<product> can parse three terms';
ok MathExpression.parse('1 * 3', :rule<product>),
'<product> and whitespace';
}
在测试的早期包含空白是值得的。 上面的例子看起来是无害的,但最后那个测试实际上失败了。 没有 rule 匹配 1
和 *
之间的空格。 在 <number>
和 +
量词之间的正则表达式中添加一个空格使测试再次通过,因为空格插入了一个隐式的 <.ws>
调用。
如果你从最简单的开始,尽快抓住这些细节,就很容易理解。 如果不是从上到下写下整个 grammar,你就会花很多时间去调试为什么一些看起来很简单的东西会导致解析失败, 比如额外的空格。
4.5. 组装完整的 Grammars
一旦你为词法分析编写了基本的 tokens,你可以进行合并。 通常,tokens 不会在匹配的边界处解析空白,因此组合它们的 rules 会这样做。
在上一节的 MathExpression
示例中,rule product
直接地调用了 number
, 即使我们现在知道最终版本使用了一个中间步骤,也就是 rule term
,它也可以解析用圆括号围起来的表达式。 引入这个额外的步骤不会使我们为 product
编写的测试失效,因为它在早期版本中匹配的字符串仍然匹配。 从处理语言子集的 grammar 开始,引入更多层是自然发生的,稍后将扩展。
4.6. 调试 Grammars
对于正则表达式或 Grammar,有两种失败模式:它们可以匹配,当它不应该匹配(误报)时,或者它应该匹配(错误否定)时可能匹配失败。通常,误报更容易理解,因为您可以检查生成的匹配对象,并查看哪些正则表达式匹配了字符串的哪一部分。
有一个方便的工具来调试误报:Grammar::Tracer
模块。如果将模块加载到包含 grammar 的文件中,则运行该 grammar 会生成诊断信息,以帮助您找出匹配出错的位置。
请注意,这只是开发人员的诊断工具; 如果你想给终端用户更好的错误信息,请阅读第 11 章的改进建议。
您需要安装 Raku 的 Grammar::Debugger
模块,其中还包含 Grammar::Tracer
。如果您使用 moritzlenz/raku-regex-alpine
的 docker 镜像,这已经为您完成了。如果您通过其他方法安装了 Raku,则需要在命令行上运行:
zef install Grammar::Debugger
如果尚未安装 zef
,请按照 zef GitHub 页面 上的安装说明进行操作。
让我们来看一下 TadeuszSośnierz 写的 Raku 模块 Config::INI。 它包含以下 grammar(这儿稍微重新格式化了):
grammar INI {
token TOP {
^ <.eol>* <toplevel>? <sections>* <.eol>* $
}
token toplevel { <keyval>* }
token sections { <header> <keyval>* }
token header { ^^ \h* '[' ~ ']' $<text>=<-[ \] \n ]>+
\h* <.eol>+ }
token keyval { ^^ \h* <key> \h* '=' \h* <value>? \h*
<.eol>+ }
regex key { <![#\[]> <-[;=]>+ }
regex value { [ <![#;]> \N ]+ }
token eol { [ <[#;]> \N* ]? \n }
}
假设我们想知道为什么它不解析下面的一段输入文本:
a = b
[foo]
c: d
所以, 在该 grammar 之前, 我们插入下面这一行:
use Grammar::Tracer;
之后,添加一小段调用该 grammar 的 .parse
方法的代码:
INI.parse(q:to/EOF/);
a = b
[foo]
c: d
EOF
这产生了一个可观的,但相当丰富的输出。
每个条目由一个正则表达式的名称组成,比如 TOP
或者 eol
("end of line" 的缩写),后面跟着它调用的正则表达式的缩进后的输出。 每个正则表达式后面都有一个包含星号()和
MATCH
后跟正则表达式匹配到的字符串片段这样的行; 如果正则表达式失败,则 号后面跟的是
FAIL
。
让我们一块一块地查看输出,即使它成块地出现:
TOP
| eol
| * FAIL
| toplevel
| | keyval
| | | key
| | | * MATCH "a "
| | | value
| | | * MATCH "b"
| | | eol
| | | * MATCH "\n"
| | | eol
| | | * FAIL
| | * MATCH "a = b\n"
| | keyval
| | | key
| | | * FAIL
| | * FAIL
| * MATCH "a = b\n"
这告诉我们,TOP
调用了 eol
,它没有匹配。 由于 eol
的调用是用 *
量化的,所以这不会导致 TOP
的匹配失败。 TOP
然后调用了 key
, 匹配到文本 "a", 调用 value
, 匹配到文本 "b"。 然后 eol
正则表达式继续匹配换行符,在第二次尝试时失败(因为在一行中没有两个换行符)。 这会导致初始的 keyval
token 匹配成功。 第二次调用 keyval
匹配很快(在调用 key
中)。 然后,toplevel
token 的匹配成功进行,消耗了字符串 "a = b \ n"。
到目前为止,这一切看起来都和预期的一样。 现在我们来看看第二部分的输出:
| sections
| | header
| | | eol
| | | * MATCH "\n"
| | | eol
| | | * FAIL
| | * MATCH "[foo]\n"
| | keyval
| | | key
| | | * MATCH "c: d\n"
| | * FAIL
| * MATCH "[foo]\n"
TOP
接下来调用 sections
,其中 token header
成功匹配了字符串 "[foo] \ n"
。 然后,keyval
调用 key
,它匹配了 "c: d\n"
整行。 等等,这是不对的,是吗? 我们可能期望 key
只匹配 c
。 我当然不希望它匹配最后的换行符。 输入中缺少等号会导致 regex 引擎永远不会调用 regex value
。 但是由于 keyval
再次用星号 *
量词进行量化,因此调用正则表达式 sections
的匹配成功地匹配了 header "[foo]\n"
。
Grammar::Tracer
输出的最后一部分如下所示:
| sections
| | header
| | * FAIL
| * FAIL
| eol
| * FAIL
* FAIL
从这里开始都是 FAIL
。第二次调用 sections
再次尝试解析 header,但其下一个输入仍然是 "c: d\n"
,所以失败了。正如 token TOP
中字符串末尾的锚点 $
一样,在 parse
方法中总体匹配失败。
所以我们已经知道正则表达式 key
匹配整行 c: d\n
,但是因为没有等号(=
)跟在后面,所以 token keyval
解析不了这一行。由于没有其他正则表达式(特别是没有 header
)匹配它,这是匹配失败的地方。
从这个例子中你可以看到,Grammar::Tracer
使我们能够精确定位解析失败发生的位置,尽管我们必须仔细查看它的输出以找到它。在终端中运行时,会自动获取彩色输出,其中 FAIL
为红色,MATCH
为绿色背景,token 名称以粗体白色(而不是通常的灰色)输出。这样可以更容易地从底部扫描(失败的匹配通常会留下一条红色的 FAIL
),直到尾部成功的匹配,然后在匹配和失败之间的边界附近查看。
由于调试带来了巨大的精神负担,而且 Grammar::Tracer
的输出趋向于快速增长,所以通常建议将失败的输入减少到最小的样本。在上述情况下,我们可以删除输入字符串的第一行,并保存十行 Grammar::Tracer
输出来查看。
4.7. 解析空白和注释
如前所述,解析无关紧要的空格的惯用方法是调用 <.ws>
,通常隐式地使用 rule 中的空格。 默认的 ws
实现 <!ww>\s*
对许多语言都适用,但是它有其局限性。
在数量惊人的文件格式和计算机语言中,也有 <.ws>
占用的空白是有意义的。 这些包括 INI 文件(换行符通常表示一个新的键/值对),Python 和 YAML(缩进用于分组),CSV(换行符表示新记录)以及 Makefile(缩进要求是制表符)。
在这些情况下,最好的做法在你自己的 grammar 中重写 ws
来匹配只有不重要的空格。 让我们来看看第二个简约的 INI 解析器,它是从上一节中描述的独立开发的:
grammar INIFile {
token TOP { <section>* }
token section {
<header>
<keyvalue>*
}
rule header {
'[' <-[ \] \n ]>+ ']' <.eol>
}
rule keyvalue {
^^
$<key>=[\w+]
<[:=]>
$<value>=[<-[\n;#]>*]
<.eol>
}
token ws { <!ww> \h* }
token eol {
\n [\h*\n]*
}
}
它解析简单的 INI 配置文件就像这样:
[db]
driver: mysql
host: db01.example.com
port: 122
username: us123
password: s3kr1t
注意这个 grammar 如何使用两条路径来解析空格:自定义的 ws
token 只匹配水平空白(空格和制表符),单独的 eol
token 匹配(significant)换行符。 eol
token 还吞噬了只包含空格的更多行。
如果语言支持注释,并且不希望它们出现在解析树中,则可以使用 ws
token 或 eol
(或其等价物)来解析它们。 哪一个取决于哪里允许注释。 在 INI 文件中,它们只允许出现在键值对之后,或者它们自己单独占一行,所以 eol
将是合适的地方。 相比之下,SQL 允许在每个允许空格的地方进行注释,所以在 ws
中解析它们是很自然的:
# comment parsing for SQL:
token ws { <!ww> \s* [ '--' \N* \n ]* }
# comment parsing for INI files:
token eol { [ [ <[#;]> \N* ]? \n ]+ }
4.8. 保存状态
一些更有趣的数据格式和语言要求解析器存储事物(至少暂时)以便能够正确地解析它们。 一个恰当的例子是 C 编程语言,另一个例子是受其语法启发的(例如 C ++和 Java)。 这样的语言允许表单类型 variable = initial_value 的变量声明,如下所示:
int x = 42;
这是有效的语法,但只有当第一个单词是一个类型名称。 相反,这将是无效的,因为 x 不是一个类型:
int x = 42;
x y = 23;
从这些例子中可以清楚地看到,解析器必须有它所知道的所有类型的记录。 由于用户也可以在他们的代码文件中声明类型,解析器必须能够更新这个记录。
许多语言还要求在引用符号(变量,类型和函数)之前进行声明。 这也需要语法来跟踪已经声明的内容和没有的内容。 这个已经声明的记录(以及什么是一个类型,也可能不是其他元信息)被称为符号表。
我们不考虑解析完整的 C 语言,而是考虑一种极简主义语言,它只允许分配数字列表和变量给变量:
a = 1
b = 2
c = a, 5, b
如果我们不强加声明规则,写一个语法是很容易的:
grammar VariableLists {
token TOP { <statement>* }
rule statement { <identifier> '=' <termlist> \n }
rule termlist { <term> * % ',' }
token term { <identifier> | <number> }
token number { \d+ }
token identifier { <:alpha> \w* }
token ws { <!ww> \h* }
}
现在我们要求变量只能在赋值之后才能使用,所以下面的输入将是无效的,因为在第二行中没有声明 b 的地方:
a = 1
c = a, 5, b
b = 2
为了维护一个符号表,我们需要三个新的元素:符号表的声明,一些代码,当赋值语句被解析时,将一个变量名添加到符号表中,最后检查一个变量是否已经被声明 我们在一个术语列表中遇到它:
grammar VariableLists {
token TOP {
:my %*SYMBOLS;
<statement>*
}
token ws { <!ww> \h* }
rule statement {
<identifier>
{ %*SYMBOLS{ $<identifier> } = True }
'=' <termlist>
\n
}
rule termlist { <term> * % ',' }
token term { <variable> | <number> }
token variable {
<identifier>
<?{ %*SYMBOLS{ $<identifier> } }>
}
token number { \d+ }
token identifier { <:alpha> \w* }
}
在令牌 TOP 中,:my%* SYMBOLS 声明一个变量。 正则表达式中的声明以冒号(:)开始,以分号(;)结尾。 在它们之间,它们看起来像 Raku 中的正常声明。%sigil 表示该变量是一个散列 - 一个字符串键到值的映射。 *使它成为一个动态变量 - 一个变量,不仅限于当前范围,而且对于从当前范围调用的代码(或正则表达式,也是代码)也是可见的。 由于这是一个非常大的范围,所以在大写字母中选择一个变量是自定义的。
第二部分,在符号表中添加一个符号,发生在规则声明中:
rule statement {
<identifier>
{ %*SYMBOLS{ $<identifier> } = True }
'=' <termlist>
\n
}
大括号内是常规的(非正则表达式)Raku 代码,所以我们可以使用它来操作哈希%* SYMBOLS。 表达式$ <identifier>访问变量 name2 的捕获。 因此,如果此规则解析变量 a,则此语句将设置%* SYMBOLS {'a'} = True。
代码块的位置是相关的。 把它放在调用 termlist 之前意味着当术语列表被解析时变量已经是已知的,所以它接受像 a = 2,a 这样的输入。 如果我们首先调用 termlist,这种输入被拒绝。
说到拒绝,这部分发生在令牌变量。 term 现在调用新的标记变量(以前它直接称为标识符),并且变量验证该符号是在之前声明的:
token term { <variable> | <number> }
token variable {
<identifier>
<?{ %*SYMBOLS{ $<identifier> } }>
}
你可能还记得在前面的例子中,<?{…}>执行一段 Raku 代码,如果它返回一个假值,则解析失败。 如果$ <identifier>不在%SYMBOLS 中,这正是发生的情况。 在这个时候,令牌的非回溯性是很重要的。 如果被解析的变量是 abc,并且变量 a 在%* SYMBOLS 中,则回溯将尝试<identifier>的较短匹配,直到它碰到 a,然后成功 3。
由于在标记 TOP 中声明了%* SYMBOLS,所以当您尝试从语法外调用除 TOP 之外的其他规则时,必须复制此声明。 没有像我的%* SYMBOLS ;,一个像这样的调用声明
VariableLists.parse('abc', rule => 'variable');
dies with:
Dynamic variable %*SYMBOLS not found
4.9. 使用动态变量实现词法作用域
许多编程语言都有一个词汇范围的概念。 范围是程序中符号可见的区域。 如果范围仅由文本的结构(而不是程序的运行时功能)决定,我们称之为范围词法。
范围通常可以嵌套。 在一个作用域中声明的变量在这个作用域中是可见的,在所有的内部嵌套作用域中(除非内部作用域声明了一个名称相同的变量,在这种情况下,内部声明隐藏了外部作用域)。
回到列表和作业的玩具语言,我们可以引入一对花括号来表示一个新的范围,所以这是有效的:
a = 1
b = 2
{
c = a, 5, b
}
但下一个例子是无效的,因为它只在内部范围内声明 b,所以它在外部范围内是不可见的:
a = 1
{
b = 2
}
c = a, 5, b
为了在语法中实现这些规则,我们可以利用一个重要的观察:语法中的动态范围对应于它分析的文本中的词法范围。 如果我们有一个正则表达式块来解析范围的分隔符以及范围内的事物,那么它的动态范围就局限于它所调用的所有正则表达式(直接或间接),这也是它的范围 匹配输入文本。
我们来看看如何实现动态范围:
grammar VariableLists {
token TOP {
:my %*SYMBOLS;
<statement>*
}
token ws { <!ww> \h* }
token statement {
| <declaration>
| <block>
}
rule declaration {
<identifier>
{ %*SYMBOLS{ $<identifier> } = True; }
'=' <termlist>
\n
}
rule block {
:my %*SYMBOLS = CALLERS::<%*SYMBOLS>;
'{' \n*
<statement>*
'}' \n*
}
rule termlist { <term> * % ',' }
token term { <variable> | <number> }
token variable {
<identifier>
<?{ %*SYMBOLS{ $<identifier> } }>
}
token number { \d+ }
token identifier { <:alpha> \w* }
}
这个语法的前一个版本有一些变化:规则语句已被重命名为声明,新的规则语句分析声明或块。
- 所有有趣的位都发生在块规则中。 该行:my%* SYMBOLS = CALLERS
-
<%* SYMBOLS>; 声明一个新的动态变量%* SYMBOLS 并用该变量的前一个值初始化它。 CALLERS :: <%* SYMBOLS>通过调用者和调用者的调用者等查找变量%* SYMBOLS,从而查找对应于外部作用域的值。 初始化创建散列的副本,以便对一个副本的更改不会影响其他副本。
让我们来看看当这个语法解析下面的输入时会发生什么:
a = 1
b = 2
{
c = a, 5, b
}
在前两行之后,%* SYMBOLS 的值为{a ⇒ True,b ⇒ True}。 当规则块解析第三行的开放大括号时,它会创建%* SYMBOLS 的副本。 第四行的 c 的声明将对 c ⇒ True 插入到%* SYMBOLS 的副本中。 在规则块解析最后一行的结束大括号之后,它将成功退出,并且%* SYMBOLS 的副本将超出范围。 这给我们留下了早期版本的%* SYMBOLS(只有键 a 和 b),当 TOP 退出时,它们超出了范围。
通过显式符号表进行范围确定
使用动态变量来管理符号表通常工作得很好,但是有一些边缘情况下更明确的方法效果更好。 这样的边缘情况包括那些符号太多以至于复制变得非常昂贵的情况,或者必须检查多于最顶端的范围的情况,或者复制符号表是不切实际的。
因此,可以为符号表编写一个类(在最简单的情况下,它使用一个数组作为范围的堆栈),在进入和离开范围时,在声明一个变量时,以及为了检查一个变量是否为 在一个范围内已知:
class SymbolTable {
has @!scopes = {}, ;
method enter-scope() {
@!scopes.push({})
}
method leave-scope() {
@!scopes.pop();
}
method declare($variable) {
@!scopes[*-1]{$variable} = True
}
method check-declared($variable) {
for @!scopes.reverse -> %scope {
return True if %scope{$variable};
}
return False;
}
}
grammar VariableLists {
token TOP {
:my $*ST = SymbolTable.new();
<statement>*
}
token ws { <!ww> \h* }
token statement {
| <declaration>
| <block>
}
rule declaration {
<identifier>
{ $*ST.declare( $<identifier> ) }
'=' <termlist>
\n
}
rule block {
'{' \n*
{ $*ST.enter-scope() }
<statement>*
{ $*ST.leave-scope() }
'}' \n*
}
rule termlist { <term> * % ',' }
token term { <variable> | <number> }
token variable {
<identifier>
<?{ $*ST.check-declared($<identifier>) }>
}
token number { \d+ }
token identifier { <:alpha> \w* }
}
SymbolTable 类具有私有数组属性@!作用域,它使用包含单个空散列的列表进行初始化。输入一个作用域意味着在这个数组的顶部推一个空的散列,当离开这个作用域的时候,它会通过 pop 方法调用再次被删除。变量声明将其名称添加到最顶端的散列@ @ scopes [* - 1]。
检查变量的存在不能只考虑最顶端的散列,因为变量被继承到内部作用域。在这里,我们以相反的顺序遍历所有的范围,从最内层到最外层的范围。遍历的顺序与简单的布尔检查无关,但是如果您需要查找与该变量相关的信息,则遵守此顺序以引用正确的顺序非常重要。
令牌 TOP 创建类 SymbolTable 的新对象,声明调用声明方法,令牌变量调用方法检查声明。规则块在解析语句列表之前调用进入范围,之后保留范围。这个工作,但只有当语句列表可以被成功解析;如果不是,规则块在管理调用离开范围之前失败。
对于这种情况,Raku 有一个安全特性:如果在 LEAVE 语句前添加一个语句,那么在例程退出时,Raku 可以在所有可能的情况下调用它(即使抛出异常)。由于 LEAVE 相位器只能在正则代码中使用,而不能在正则表达式中使用,所以我们需要将正则表达式包装在一个方法中:
method block {
$*ST.enter-scope();
LEAVE $*ST.leave-scope();
self.block_wrapped();
}
rule block_wrapped {
'{' \n*
<statement>*
'}' \n*
}
现在我们拥有与动态变量相同的鲁棒性,并且以更多的代码和更多的努力为代价,可以更灵活地向符号表添加额外的代码。
4.10. 总结
Raku 的 Grammar 是编写递归下降解析器的一种声明方式。 如果没有回溯,他们就是可预测的; 在每一个时刻,我们都知道我们想要的 token 列表。
Grammar 的递归性带来了左递归的风险,即递归路径不消耗任何字符的情况,从而导致无限循环。
尽管 Grammar 是自上而下的,但是他们通常是从下到上写出来的:从词法分析开始,然后转向解析更大的结构。
复杂语言成功和精确的解析需要额外的状态。 我们已经看到了如何在 grammar 中使用动态变量来保存状态,它们的作用域如何对应于输入的词法作用域,以及如何将符号表写入并集成到 grammars 中。
1、就像一把瑞士军刀一样,但是功能更强大。
2、在这一点上,identifier
不会解析其周围的空白是至关重要的。 因此,token 不关心空白的原则和调用这些 token 的 rules 解析空白。
3、在这种情况下,这将是无害的,因为没有其他 rule 可以匹配变量的其余部分,导致解析错误。 但是在更复杂的情况下,这种无意的回溯会导致语法维护人员非常困惑的错误。
5. 第五天 - 使用 Raku 签名解构参数
在许多其他关键的 Raku 特性中,我认为 Signatures 是众多"杀手级"特性之一。 它们的功能如此丰富而强大,我怀疑关于如何使用它们可以写一整本书。 我想探索一下我原来忽略但是非常珍惜的一些特定功能。
您可能已经看到了基本的子程序签名:
sub myfunc($x, $y, $z) {...}
它给函数声明了 3 个标量参数, 并在函数体里面给了它们 $x, $y, $z 的名字。
太简单了。
你可以更有爱心, 给它们加上指定的类型:
sub myfunc(Str $x, Int $y, Rat $z) {...}
你可以使用笑脸符号 :D
让参数值是有定义的:
sub myfunc(Str:D $x, Int:D $y, Rat:D $z) {...}
还有很多其它花哨的说明符你可以使用,在这里我不深入了。
但是如果你的参数更复杂呢? (不是 Complex - 虽然它也起作用..)
For example, you might want to restrict a specific parameter to a Positional argument like an Array, or an Associative one like a Hash using the respective sigils, @ or %. 例如,你可能想要将特定的参数限制为像 Array 这样的 Positional 参数,或者使用相应的 @ 或 % 符号将这个参数限制为像 Hash 这样的关联参数。
sub myfunc(%h) {...}
现在我可以使用一个散列来调用该函数:
myfunc(%( a => 1, b => 'this', c => 2.2));
如果我想验证那些特定的字段是否存在,我可以把代码放在函数的顶部来做到这一点:
sub myfunc(%h) {
die "a must be an Int" unless %h<a> ~~ Int;
die "b must be a Str" unless %h<b> ~~ Str;
die "c must be a Rat" unless %h<c> ~~ Rat;
}
如果我还想简化引用那些字段的方式,我可以将它们赋值给其他变量:
sub myfunc(%h) {
die "a must be an Int" unless %h<a> ~~ Int;
die "b must be a Str" unless %h<b> ~~ Str;
die "c must be a Rat" unless %h<c> ~~ Rat;
my $a = %h<a>;
my $b = %h<b>;
my $c = %h<c>;
}
有点无聊,对吗?
Perl 签名参数解构来拯救你了! 我们可以在子例程签名自身中做所有的事情 - 只要放一个子签名(sub-signature)在后面即可。
sub myfunc(%h (Int :$a, Str :$b, Rat :$c)) {...}
5.1. 解构 JSON
相当不错,但如果你有更复杂的东西呢?
假如说一块儿有嵌套结构的 JSON,某些部分可能缺失了, 它们需要默认值, 等等。
use JSON::Fast;
my $item = from-json(q:to/END/);
{
"book" : {
"title" : "A Christmas Carol",
"author" : "Charles Dickens"
},
"count" : 12,
"tags" : [ "christmas", "santa"]
}
END
q:to/END/
是一个 Raku heredoc,它直接在文本中直到 END,然后我们可以使用 JSON::Fast 的 from-json()
将其解析为 perl 中的数据结构。 你可以在函数签名中描述整个 JSON 结构,以便接收以下内容:
sub myfunc(% (:%book (Str:D :$title, Str:D :$author), Int :$count,
:@tags ($first-tag, *@other-tags)) )
{...}
现在,在函数体中,我可以将这些部分引用为 $title
,$author
,$count`和 `@tags
。 为了方便起见,我还将标签分成了 $first-tag
和 @other-tags
。
5.2. 在块儿中使用签名
当然,签名对于子程序来说是幻想的,但是你也可以在块儿(Block)中使用签名和解构。 假设你有一个上面的 JSON 条目的数组,并希望通过一个 for
循环遍历它们? 只需在 for
的尖号块中使用解构签名即可:
for @itemlist -> % (:%book (Str:D :$title, Str:D :$author), Int :$count,
:@tags ($first-tag, *@other-tags))
{
say "$title, $author, $count, @tags[], $first-tag, @other-tags[]"
}
注意在这种情况下,我甚至不需要散列本身,所以我省略了散列的名称,仅使用 %
作为匿名散列(关联)。
5.3. 你甚至可以解构对象!
你有没有试过遍历一组对象,你所做的第一件事是调用一些访问器来获取一些属性? 当然,你可以使用 .attribute
和 主题化的迭代器,但是使用子签名,你可以做更多。
class Book {
has $.title;
has $.author;
has $.count;
has @.tags;
}
my @booklist =
Book.new(title => 'A Christmas Carol',
author => 'Charles Dickens',
count => 12,
tags => <ghost christmas>),
Book.new(title => 'A Visit from St. Nicholas',
author => 'Clement Clarke Moore',
count => 4,
tags => <santa christmas>);
for @booklist -> Book $b (:$title,:$author, :$count, :@tags) {
say "$title, $author, $count, @tags[]";
}
如果您想检查类型或定义,或设置默认值,您都可以在签名中正确地执行。 如果您不喜欢对象属性的名称,则可以使用别名来重命名它们, 你开心就行。
5.4. 结论
我发现解构参数在与数据库查询结果和 JSON 交互中非常有用。 您可以使用任何其他签名特性,包括指定类型,定义,可选性,默认值,使用别名重命名,使用子集约束或“where”从句,slurpies等。
节日快乐!
6. 第六天-Raku 书籍
7. 第七天 – 测试所有的东西
Raku 与其大姐姐 Perl 5 一样,具有很悠久的测试传统。当您安装任何 Perl 模块时,安装程序通常会运行该模块的测试套件。当然,作为新兴的 Raku 模块作者,您需要创建自己的测试套件。或者,也许你会在创建模块之前勇于创建测试套件。这实际上有几个好处,其中最主要的是你的第一个用户,甚至在它被写之前。
但在实际代码之前,我想提一下我经常使用的两个 shell 别名 -
alias 6='raku -Ilib'
alias 6p="prove -e'raku -Ilib'"
这些别名使我可以快速运行测试文件,而不必去安装我的代码。如果我在项目目录中,我可以运行
$ 6 t/01-core.t
ok 1 - call with number
ok 2 - call with text
ok 3 - call with formatted string
1..3
它会告诉我我运行了哪些测试以及它们是否全部通过。就像它的大姐姐 Perl 5 一样,Raku 使用 't/' 目录作为测试文件,并按照惯例使用后缀 '.t' 来区分测试文件和软件包或脚本。它还有一个内置的单元测试模块,我们在上面使用。如果我们正在测试 sprintf() 内部,它可能看起来像
use Test;
ok sprintf(1), 'call with number';
ok sprintf("text"), 'call with text';
ok sprintf("%d",1), 'call with formatted string';
done-testing;
ok 和 done-testing 功能会自动导出给我们。我在这里使用规范的 Raku 风格,而不是太依赖括号。在这种情况下,我确实需要使用圆括号来确保 sprintf() 不会"认为""空调用"是它的参数。
OK 只需要两个参数,你想要测试的真实性,以及一个可选的消息。如果第一个参数是任何评估为 True 的东西,则测试通过。否则……你知道。该消息只是描述测试的文本。它纯粹是可选的,但当测试失败时它可以很方便,因为您可以在测试文件中搜索该字符串并快速找到问题。不过,如果你像作者一样,行号更有价值,所以当你看到的时候
not ok 1 - call with number
# Failed test 'call with number'
# at test.t line 4
ok 2 - call with text
ok 3 - call with formatted string
1..3
在您的测试中,您可以立即跳转到测试文件的第 4 行并开始编辑以找出问题所在。当你的测试文件变得越来越大时,这会变得更有用,例如我正在编写的 Common Lisp 版本(格式)的测试,每个测试文件超过 200 个测试并且不断增长。
最后,完成测试只是告诉测试模块我们已经完成了测试,没有更多的测试来了。当你刚刚开始时,这很方便,你不断尝试你的 API,添加和更新测试。没有测试计数器来更新每次或任何其他机制来跟踪。
当然,这是可选的,但其他工具可能会在最后使用 '1..3' 来证明您的测试实际上已经完成。 Jenkins 的单元测试和其他系统也可能需要这个工具。
7.1. It depends…
你对’是’的定义是什么。如果你只关心某件事情的真实性,好的测试是好的,但有时你需要深入一点。 Raku 就像它的大姐姐一样可以帮助你。
is 1 + 1, 2, 'prop. 54.43, Principia Mathematica';
不只是检查你的测试的真实性,它会检查它的价值。虽然你可以很容易地写这个
ok 1 + 1 == 2, 'prop. 54.43, Principia Mathematica';
使用是使你的意图明确,你关注的是表达式 1 + 1 是否等于 2; 与同一语句的 ok 版本一样,目前还不清楚您是在测试 '1 + 1' 部分还是 '==' 运算符。
这两个测试本身可能占据您测试需求的 80%,处理基本列表和哈希时相对安全,如果您真的需要复杂的测试,那么它的大姐姐正在站在脚边,准备处理复杂的哈希阵列组合。
7.2. 懒惰和不耐烦
有时你会有一个巨大的字符串,你只需要检查一下它。
ok 'Lake Chargoggagoggmanchauggagoggchaubunagungamaugg' ~~ 'manchau', 'my side';
你当然可以在这里使用~~运算符。就像'1 + 1 == 2’一样,但是你的意图可能并不明确。你可以使用类似的方法来明确你的意图。
like 'Lake Chargoggagoggmanchauggagoggchaubunagungamaugg',
/manchau/, 'my side';
并没有~~悬在你的船边。
7.3. 晾干
在美丽的 Lake Chargoggagoggmanchauggagoggchaubunagungamaugg 度过一段时间后,你可能想把你的衣服拧干。测试文件往往会增长,特别是回归测试。你可能会发现自己写作
is sprintf( "%s", '1' ), '1', "%s formats numbers";
is sprintf( "%s", '⅑' ), '⅑', "%s formats fractions";
is sprintf( "%s", 'Ⅷ' ), 'Ⅷ', "%s formats graphemes";
is sprintf( "%s", '三' ), '三', "%s formats CJKV";
这很好,复制和粘贴(特别是从 StackOverflow)是一个悠久的传统,没有错。不过考虑一下,当你使用 "%d" 而不是 "%s" 添加更多测试时会发生什么情况,并且由于所有这些字符串都是数字,因此您只需复制并粘贴该块,将 "%s" 更改为 "%d",然后继续。
is sprintf( "%s", '1' ), '1', "%s formats numbers';
# ...
is sprintf( "%d, '1' ), '1', "%d formats numbers';
# ...
所以现在你已经有了两组测试,名称相同。而不是编辑所有新的 "%d" 测试,如果我们不必首先重复自己的话,这会不会很好?
subtest '%s', {
is sprintf( "%s", '1' ), '1', "formats numbers";
is sprintf( "%s", '⅑' ), '⅑', "formats fractions";
is sprintf( "%s", 'Ⅷ' ), 'Ⅷ', "formats graphemes";
is sprintf( "%s", '三' ), '三', "formats CJKV";
};
现在你只需要在两个地方而不是三个地方进行编辑。如果这激发了您对测试的兴趣,我鼓励您在我的个人网站上查看 测试所有事情以获得更高级的测试范例和更高级的 Raku 代码。另外不要忘记关注明天的 Raku Advent 发布!
谢谢你,快乐的黑客!
DrForr 又名 Jeff Goff,Perl Fisher
8. 第八天 - Adventures in NQP Land: Hacking the Rakudo Compiler
对旧圣诞节经典"圣诞节十二天"的道歉,我给你一个 Raku 版本的第一行:
在圣诞节的第一天,我真正的爱给了 pod 树上的 Perl 表格……
但是我得到的表格不是很漂亮!
8.1. 背景
我与 Raku 的第一次真正联系是在 2015 年春天,当时我决定检查它的状态,发现它已经准备好迎接黄金时段。在获得了该语言的一些经验之后,我开始在我可以提供帮助的地方贡献文档。我对文档的第一个贡献是清理其中没有很好呈现的表格。在我对本地主机上的 pod 表进行实验期间,我尝试了下表格:
=begin table
-r0c0 r0c1
=end table
这导致 Raku 抛出一个丑陋的, LTA(非常搓)的异常消息:
"===SORRY!=== Cannot iterate object with P6opaque representation"
我解决了这个问题,但它让我感觉不爽,所以我开始调查 pod 和 tables 的内部。这导致我在 github.com/rakudo/src/Raku/Pod.nqp 中发现了问题的根源。
事实上,许多 pod 表格问题的真正问题最终都出现在该文件中。
8.2. Not Quite Perl (NQP)
nqp 是用于构建 Rakudo Raku 编译器的中间语言。它的 git 仓库在 这里。本文的其余部分是关于修改 rakudo 编译器中的 nqp 代码,其仓库地址在 这里。 Rakudo 在 这里也有一个网站。
在走得太远之前,我首先阅读有关 Rakudo 和 NQP 的可用信息:
-
Jonathan Worthington’s (JWs) 的幻灯片课程 Rakudo and NQP Internals
然后我开始通过编写和运行一些这样的小型 nqp 文件来练习 nqp 编码(文件 "hello.nqp"):
say("Hello, world!");
当它被执行时,会给出预期的结果:
$ nqp hello.nqp
Hello, world!
请注意,say()
是不需要 nqp::
前缀的少数 nqp opcodes 之一。
8.3. 进入战壕
rakudo/src/Raku/Pod.nqp
文件中包含的 Raku::Pod
类的用途是将 pod grammar 匹配并将它们转换为 rakudo/src/core/Pod.pm
中的 Raku pod 类定义,供 Raku 领地上的渲染者进一步处理。对于表格,表示以 Raku 文档设计中描述的任何合法 pod 格式表示的内容概要 S26,Raku 测试套件规范和 Raku 文档必须转换为 Raku Pod::Block::Table 类如文件 rakudo/src/core/Pod.pm 中所述,使用此格式的对象:
configuration information
a header line with N cells
M content lines, each with N cells
我希望 nqp 表格 pod 处理功能非常强大,能够自动修复某些格式问题(给作者一个警告),或者抛出一个异常(优雅)并提供问题的详细信息,以便作者修复 pod 输入。
8.4. 工作区和工具
我需要两个克隆版本库:rakudo 和 roast。我还需要在 github 上复刻那些相同的 git 仓库,所以我可以为我的更改创建 pull 请求(PR)。我在 CPAN 模块 App::GitGot 中找到了非常方便的 Perl 5 工具。使用 got 允许我轻松设置所有四个仓库。 (请注意,got 得要求其目标 repo 不存在于所需的本地目录或用户的 github 帐户中。)配置完成后,我去了一个合适的目录以包含两个 repos 并执行以下操作:
got fork https://github.com/rakudo/rakudo.git
got fork https://github.com/raku/roast.git
这导致了一个子目录 rakudo 和 roast 包含克隆仓库和 rakudo 和 roast github 帐户上的新复刻。在 rakudo 目录中,可以看到用于轻松创建 PR 的默认设置:
$ git remote -v
origin git@github.com:tbrowder/rakudo.git (fetch)
origin git@github.com:tbrowder/rakudo.git (push)
upstream https://github.com/rakudo/rakudo.git (fetch)
upstream https://github.com/rakudo/rakudo.git (push)
在 roast 仓库中有类似的结果。
接下来,我将 roast 仓库作为 rakudo 的子目录("rakudo/t/spec")重命名,所以它作为本地 rakudo 的一个子集。
最后,我创建了几个 bash 脚本,以便于在本地 repo 目录中配置 rakudo 进行安装,设置环境并运行测试:
-
rakudo-local-config.sh
-
run-table-tests.sh
-
set-rakudo-envvars.sh
(请参阅 https://github.com/tbrowder/nqp-tools 上提到的所有脚本。)
要完成本地工作环境,您需要安装一些本地模块,以便您必须更改路径并安装 zef 安装程序的本地副本。在 rakudo 目录中执行以下步骤(来自 @Zoffix 的建议):
git clone https://github.com/ugexe/zef
export PATH=`pwd`/install/bin:$PATH
cd zef; raku -Ilib bin/zef install .
cd ..
export PATH=`pwd`/install/share/raku/site/bin:$PATH
zef install Inline::Perl5
然后安装您需要的其他模块,例如:
zef install Debugger::UI::CommandLine
zef install Grammar::Debugger
8.5. Hacking
现在开始黑客入侵。准备好构建时,执行:
make
make install
make install
步骤非常关键,否则,在我们设置的本地环境中,将不会找到新的 Raku 可执行文件。
调试于我来说很费力,每次重建需要大约三分钟。调试器(raku-debug-m)会非常有用,但我无法安装所需的 Debbugger::UI::CommandLine
模块,因此它可以被本地安装的 raku-debug-m
识别。我使用的主要方法是插入 print 语句,并使用 raku 的 --ll-exception
选项。值得注意的是,这位作者是一位 Raku 新手,犯了很多错误,并且并不总是记得修复,因此有了这篇文章。 (注意我可能会使用调试工具,但在我开始的时候,我没有要求帮助,也没有提供上面提供的建议。)
8.6. 测试
不言而喻,一个好的 PR 将包括对变化的测试。我总是创建一个与我的 rakudo 分支同名的 roast 分支。然后我提交了两个 PR,我指的是 rakudo PR 中的 toast PR,反之亦然。我注意到 toast PR,它需要伴生 rakudo PR 通过所有测试。
见参考文献 5 了解更多有关专门测试脚本的详细信息,以进行欺骗和其他深奥测试事宜。
8.7. 文档
我尝试将我的修复程序保留在最新的 Raku pod 表格文档中。
8.8. NQP 经验教训
-
LTA 错误消息是生活中的事实,例如,"无法调用此对象…",这可能是由很多事情造成的,包括拼写错误的标识符(提交 NQP 问题,早期报告可能不会很快修复)。
-
确保所有 nqp 操作码都有
nqp::
前缀(除了少数内置函数) -
在 nqp 专用沙箱中练习新代码。
8.9. 成功!
现在我已经接受并合并了两个主要的 Raku POD(和 toast)PR,并且我正在研究一个更"容易"的,我将在本周完成。 这些 PR 是:
1.Rakudo PR#1240 这个 Rakudo PR 为 RT#124403,#128221,#132341,#13248 和#129862 提供了修复程序。它伴随着 toast PR#353。
这个 PR 允许上面的问题表格被正确渲染。它还添加了有问题的表的警告,添加了 Rakudo 环境变量 RAKUDO_POD6_TABLE_DEBUG 以帮助用户调试表(请参阅文档,用户调试),并允许具有空列的短行正确呈现。
2.Rakudo PR#1287 这个 Rakudo PR 为 Rakudo repo 问题#1282 提供了一个解决方案。它伴随着 roast PR#361。 (请注意,roast PR#361 尚未合并。)
这个 PR 允许表格视觉列分隔符('|')和('+')作为单元格数据通过在 pod 源中转义它们。
8.10. 总结
-
Raku pod 相对于 Perl 5 来说是一个很大的改进,但它还没有完全实现。
-
在 Rakudo Perl 的内部工作是有益的(并且很有趣),但是准备让你的手变脏!
-
Raku 社区是一个很好的团队。
-
我喜欢 Rakudo Raku。
圣诞快乐,Hacking 快乐!
8.11. 参考
-
JWs Raku debugger Advent article
-
JWs Rakudo debugger module Debugger::UI::CommandLine
-
JWs grammar debugger module Grammar::Debugger
8.12. 附录
POD 工具
-
raku –doc=MODULE # where ‘MODULE’ is ‘HTML’, ‘Text’, 或其它合适的模块
-
p6doc
-
raku –ll-exception
8.13. 主要的 Raku POD 渲染器
-
Pod::To::Text (part of the rakudo core)
9. 第九天 – HTTP and Web Sockets with Cro
礼物不仅仅是圣诞节的时候才有。今年夏天,在瑞士 Perl 工作室 - 精美地坐落在阿尔卑斯山 - 我有幸透露了 Cro。 Cro 是一组用于在 Raku 中构建服务的库,以及一些用于 stub,run 和跟踪服务的开发工具。 Cro 主要关注使用 HTTP(包括 HTTP/2.0)和 Web 套接字构建服务,但可以提供对 ZeroMQ 的早期支持,并计划在未来推出一系列其他选项。
9.1. 响应式管道
Cro 遵循 Perl 的设计原则,使简单的事情变得简单,并且让困难的事情变得可能。就像 Git 一样,Cro 可以被认为是具有瓷器(使简单的事情变得简单)和管道(使困难的事情成为可能)。管道水平由组成管道的组件组成。这些组件具有不同的形状,例如源,传输和下沉。这是一个将 HTTP 请求转换为 HTTP 响应的转换:
use Cro;
use Cro::HTTP::Request;
use Cro::HTTP::Response;
class MuskoxApp does Cro::Transform {
method consumes() { Cro::HTTP::Request }
method produces() { Cro::HTTP::Response }
method transformer(Supply $pipeline --> Supply) {
supply whenever $pipeline -> $request {
given Cro::HTTP::Response.new(:$request, :200status) {
.append-header: "Content-type", "text/html";
.set-body: "Muskox Rocks!\n".encode('ascii');
.emit;
}
}
}
}
现在,让我们用一个 TCP 监听器,一个 HTTP 请求解析器和一个 HTTP 响应序列化器来编写它:
use Cro::TCP;
use Cro::HTTP::RequestParser;
use Cro::HTTP::ResponseSerializer;
my $server = Cro.compose:
Cro::TCP::Listener.new(:port(4242)),
Cro::HTTP::RequestParser.new,
MuskoxApp,
Cro::HTTP::ResponseSerializer;
这将返回一个 Cro::Service,我们现在可以启动,并在 Ctrl + C 时停止:
$server.start;
react whenever signal(SIGINT) {
$server.stop;
exit;
}
运行。然后 curl
它。
$ curl http://localhost:4242/
Muskox Rocks!
不错。但是如果我们想要一个 HTTPS 服务器呢?如果我们有方便的关键和证书文件,这只是一个用 TLS 监听器替换 TCP 监听器的例子:
use Cro::TLS;
my $server = Cro.compose:
Cro::TLS::Listener.new(
:port(4242),
:certificate-file('certs-and-keys/server-crt.pem'),
:private-key-file('certs-and-keys/server-key.pem')
),
Cro::HTTP::RequestParser.new,
MuskoxApp,
Cro::HTTP::ResponseSerializer;
运行。然后 curl -k
它。
$ curl -k https://localhost:4242/
Muskox Rocks!
和中间件?这只是构成管道的另一个组成部分。或者,从另一个角度来看,对于 Cro,一切都是中间件。即使请求解析器或响应序列化器可以很容易地被替换,如果需要的话(这听起来像是一件奇怪的事情需要,但这实际上是实现 FastCGI 会涉及的)。
所以,这就是克罗的方式。它还需要大量的样板才能在此级别上工作。带上瓷器!
9.2. HTTP 服务器,简单的方法
Cro::HTTP::Server 类摆脱了构建 HTTP 处理管道的样板。从前面的例子变成:
use Cro;
use Cro::HTTP::Server;
class MuskoxApp does Cro::Transform {
method consumes() { Cro::HTTP::Request }
method produces() { Cro::HTTP::Response }
method transformer(Supply $pipeline --> Supply) {
supply whenever $pipeline -> $request {
given Cro::HTTP::Response.new(:$request, :200status) {
.append-header: "Content-type", "text/html";
.set-body: "Muskox Rocks!\n".encode('ascii');
.emit;
}
}
}
}
my $server = Cro::HTTP::Server.new: :port(4242), :application(MuskoxApp);
$server.start;
react whenever signal(SIGINT) {
$server.stop;
exit;
}
这里没有魔法;它真的只是一个更方便的方式来组成一条管线。虽然这只是对 HTTP / 1. *的节省,但 HTTP / 2.0 管道涉及更多的组件,而支持这两者的管道仍然更为复杂。相比之下,配置 Cro::HTTP::Server 可以轻松地完成支持 HTTP / 1.1 和 HTTP / 2.0 的 HTTPS:
my %tls =
:certificate-file('certs-and-keys/server-crt.pem'),
:private-key-file('certs-and-keys/server-key.pem');
my $server = Cro::HTTP::Server.new: :port(4242), :application(MuskoxApp),
:%tls, :http<1.1 2>;
9.3. 通向幸福的途径
Cro 中的 Web 应用程序最终总是将 HTTP 请求转换为 HTTP 响应的转换。然而,想要以完全相同的方式处理所有请求的情况非常罕见。通常,不同的 URL 应该路由到不同的处理程序。输入 Cro::HTTP::Router:
use Cro::HTTP::Router;
use Cro::HTTP::Server;
my $application = route {
get -> {
content 'text/html', 'Do you like dugongs?';
}
}
my $server = Cro::HTTP::Server.new: :port(4242), :$application;
$server.start;
react whenever signal(SIGINT) {
$server.stop;
exit;
}
路由块返回的对象执行 Cro::Transform 角色,这意味着它可以很好地与 Cro.compose(…)配合使用。然而,使用路由器编写应用程序会更方便一些!让我们看看更仔细一点:
get -> {
content 'text/html', 'Do you like dugongs?';
}
在这里,get 是说这个处理程序只处理 HTTP GET 请求。尖头块的空签名意味着不需要 URL 段,所以该路由仅适用于/。然后,而不是必须做一个响应对象实例,添加一个头,并编码一个字符串,内容函数完成这一切。
路由器是为了利用 Raku 签名而建立的,同时也可以让 Raku 程序员感觉自然。路由段通过声明参数来建立,而文字串段恰好匹配:
get -> 'product', $id {
content 'application/json', {
id => $id,
name => 'Arctic fox photo on canvas'
}
}
使用 curl 进行快速检查表明,它还负责为我们序列化 JSON:
$ curl http://localhost:4242/product/42
{"name": "Arctic fox photo on canvas","id": "42"}
JSON 正文序列化程序由内容类型激活。这是可能的,也很简单,可以实现并插入自己的身体序列器。
想要捕获多个网址段? Slurpy 参数也可以工作,这对于服务静态资产时可以很方便地与静态结合使用,也许深层次的多级目录:
get -> 'css', *@path {
static 'assets/css', @path;
}
可选参数适用于可能提供或可能不提供的段。使用子集类型来限制允许的值也可以。 Int 只接受 URL 段中的值以整数形式解析的请求:
get -> 'product', Int $id {
content 'application/json', {
id => $id,
name => 'Arctic fox photo on canvas'
}
}
命名参数用于接收查询字符串参数:
get -> 'search', :$query {
content 'text/plain', "You searched for $query";
}
这将填充在这样的请求中:
$ curl http://localhost:4242/search?query=llama
You searched for llama
这些也可以是类型约束和/或需要的(命名参数在 Raku 中默认是可选的)。 Cro 路由器试图帮助你做好 HTTP,方法是给出一个 404 错误来匹配一个 URL 段,405(方法不允许),当段匹配但是使用了错误的方法时,400 当方法和段很好时,但查询字符串有问题。通过使用 is 标头并且是 cookie 特征的命名参数也可以用于接受和/或限制标头和 cookie。
路由器将所有路由编译成 Raku 语法,而不是一次一个地浏览路由。这意味着路线将使用 NFA 进行匹配,而不是一次一个地突破。此外,这意味着应用 Raku 最长的文字前缀规则,因此:
get -> 'product', 'index' { ... }
get -> 'product', $what { ... }
即使您按照相反的顺序编写了这些请求,它们总是会优先选择这两项中的第一项作为/ product / index 的请求:
get -> 'product', $what { ... }
get -> 'product', 'index' { ... }
9.4. 中间件变得更容易
有趣的是,HTTP 中间件只是一个 Cro::Transform,但如果 Cro 是所有产品的话,那么写起来会不太有趣。令人高兴的是,有一些更简单的选择。路径块可以包含块之前和之后的块,这些块将在块中的任何路由处理之前和之后运行。因此,可以将 HSTS 标头添加到所有响应中:
my $application = route {
after {
header 'Strict-transport-security', 'max-age=31536000; includeSubDomains';
}
# Routes here...
}
或者对没有授权标头的所有请求使用 HTTP 403 Forbidden 进行响应:
my $application = route {
before {
unless .has-header('Authorization') {
forbidden 'text/plain', 'Missing authorization';
}
}
# Routes here...
}
其行为如下所示:
$ curl http://localhost:4242/
Missing authorization
$ curl -H"Authorization: Token 123" http://localhost:4242/
<strong>Do you like dugongs?</strong>
9.5. 这只是一个供应链(Supply chain)
所有的 Cro 实际上只是构建一系列 Raku Supply 对象的一种方式。尽管中间件块之前和之后都很方便,但将中间件作为转换编写,无论何时使用语法,都可以访问 Raku 电源的全部功能。因此,如果您需要使用会话令牌进行请求并对会话数据库进行异步调用,并且只有发出请求才能进行进一步处理(或者重定向到登录页面),则可以这样做 - 阻止其他请求(包括同一连接上的请求)。
事实上,Cro 完全是根据更高级别的 Raku 并发功能构建的。没有明确的线程,也没有明确的锁定。相反,所有并发都是以 Raku Supply 和 Promise 的形式表示的,并且由 Raku 运行时库决定,以便在多个线程上扩展应用程序。
9.6. 哦,和 WebSockets?
事实证明,Raku 提供的地图非常适合网络套接字。事实上,很好,Cro 在 API 方面的增加相对较少。以下是一个(过度)简单的聊天服务器后端的外观:
my $chat = Supplier.new;
get -> 'chat' {
# For each request for a web socket...
web-socket -> $incoming {
# We start this bit of reactive logic...
supply {
# Whenever we get a message on the socket, we emit it into the
# $chat Supplier
whenever $incoming -> $message {
$chat.emit(await $message.body-text);
}
# Whatever is emitted on the $chat Supplier (shared between all)
# web sockets), we send on this web socket.
whenever $chat -> $text {
emit $text;
}
}
}
}
请注意,这样做需要使用 Cro::HTTP::Router::WebSocket;导入提供网络套接字功能的模块。
9.7. 综上所述
这只是对 Cro 所提供的内容的一瞥。没有空间讨论 HTTP 和 Web 套接字客户端,用于存根和运行项目的 cro 命令行工具,提供用于执行相同操作的 Web UI 的 Cro Web 工具,或者如果您将 CRO_TRACE = 1 粘贴到您的环境中,您可以获得大量有关请求和响应处理的多汁调试细节。
要了解更多信息,请查看 Cro 文档,其中包括关于构建单页应用程序的教程。如果你有更多的问题,最近在 Freenode 上创建了#cro IRC 频道
10. 第十天 – Wrapping Rats
沿着烟囱向下是一件危险的事情。
烟囱可能很窄,很高,有时候建造得不够好。
今年,圣诞老人想要做好准备。因此,他正在将烟囱检查与交付礼物结合起来。
烟囱检查涉及确保每层砖都处于正确的高度; 即砂浆层的高度是一致的,并且砖的高度也是一致的。
例如,对于 2¼” 高的砖和厚度为 ⅜” 的砂浆,测量序列应该如下所示:
🎅
─██─
||
layer total
░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░░
2¼ ░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░░
░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░░
⅜ ‾‾???
░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░
2¼ ░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░
░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░
⅜ ‾‾5⅝
░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░
2¼ ░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░
░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░
⅜ ‾‾3
░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░
2¼ ░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░
░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░
⅜ _____________________________________‾‾⅜
这个计划是让精灵们下降到烟囱的底部,手中拿着卷尺,然后回来,确保每个砖块的顶部恰好位于卷尺上的正确位置。
一个名叫猫王的特殊精灵已经自己写了一个程序来帮助完成计算这个高度序列的任务。
因为懒惰,猫王甚至不想添加上述任何分数,并希望程序完成所有工作。他也不想花费精力去找出每层高度的公式。幸运的是,他正在使用 Raku,它将 unicode 分数转换为有理数(类型为 Rat
),并且还有一个序列运算符(…
),它根据前几项计算出算术序列。
所以,猫王在程序中的第一个片段看起来像这样:
my @heights = 0, ⅜, 3, 5+⅝ ... *;
say @heights[^10].join(', ')
这给了他需要的前 10 个高度:
0, 0.375, 3, 5.625, 8.25, 10.875, 13.5, 16.125, 18.75, 21.375
虽然这是正确的,但很难使用。卷尺只有几分之一英寸,而不是小数。猫王真正想要的输出是分数。
幸运的是,他知道使用 join
将 Rat`s 转换为字符串,通过调用 `Rat
类的 Str
方法完成将 Rat
转换为 Str
。因此,通过修改 Rat.Str
的行为,他认为可以使输出更漂亮。
他决定这样做的方式是包装(wrap
) Str
方法(又名使用装饰器模式),如下所示:
Rat.^find_method('Str').wrap:
sub ($r) {
my $whole = $r.Int || "";
my $frac = $r - $whole;
return "$whole" unless $frac > 0;
return "$whole" ~ <⅛ ¼ ⅜ ½ ⅝ ¾ ⅞>[$frac * 8 - 1];
}
换句话说,当把 Rat
字符串化时,除非有小数部分,否则返回整个部分。然后将小数部分视为八分之一数,并将其用作数组中的索引以查找正确的 unicode 分数。
他将这一点与他的第一个程序结合起来,以获得这样的高度:
0, ⅜, 3, 5⅝, 8¼, 10⅞, 13½, 16⅛, 18¾, 21⅜
“万岁!” 他想。 “正是我需要的。”
圣诞老人看了看这个程序,并说:“猫王,这很聪明,但还不够。虽然大多数砖块的尺寸是 ⅛ 的倍数,但砂浆水平可能并非如此。你也可以让你的程序处理这些情况吗?“
“当然”,猫王苦笑着说。然后他将这一行添加到他的包装函数中:
return "$whole {$frac.numerator}⁄{$frac.denominator}"
unless $frac %% ⅛;
使用“可被整除”操作符(%%
),以确保分数可以平分为八分之一,并且如果不是只显式地打印分子和分母。然后,对于 ⅕” 厚的砂浆,序列为:
my @heights = 0, ⅕,
⅕ + 2+¼ + ⅕,
⅕ + 2+¼ + ⅕
+ 2+¼ + ⅕ ... *;
say @heights[^10].join(', ');
0, 1⁄5, 2 13⁄20, 5 1⁄10, 7 11⁄20, 10, 12 9⁄20, 14 9⁄10, 17 7⁄20, 19 4⁄5
“实际上”,圣诞老人说,“现在在我看来,也许这没有用 - 卷尺只有十六分之一英寸,所以最好四舍五入到十六分之一英寸。”
!img
猫王加了一个 round
调用来结束:
Rat.^find_method('Str').wrap:
sub ($r) {
my $whole = $r.Int || '';
my $frac = $r - $whole;
return "$whole" unless $frac > 0;
my $rounded = ($frac * 16).round/16;
return "$whole" ~ <⅛ ¼ ⅜ ½ ⅝ ¾ ⅞>[$frac * 8 - 1] if $rounded %% ⅛;
return "$whole {$rounded.numerator}⁄{$rounded.denominator}";
}
这给了他:
0, 3⁄16, 2⅝, 5⅛, 7 9⁄16, 10, 12 7⁄16, 14⅞, 17¼, 19 13⁄16
他向 Elvira 精灵展示了他的程序,他说:“真是巧合,我写了一个几乎完全一样的程序!除此之外,我也想知道砖层的底部在哪里。我无法使用序列运算符来完成此操作,因为它不是算术级数,但是我可以使用 lazy gather 和匿名有状态变量!就像这样:
my \brick = 2 + ¼;
my \mortar = ⅜;
my @heights = lazy gather {
take 0;
loop { take $ += $_ for mortar, brick }
}
Elvira 的程序产生了:
0, ⅜, 2⅝, 3, 5¼, 5⅝, 7⅞, 8¼, 10½, 10⅞
即砖层的顶部和底部:
\ 🎅 /
██
||
layer total
░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░░
2¼ ░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░░
░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░░
⅜ ‾‾8¼
░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░‾‾7⅞
2¼ ░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░
░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░
⅜ ‾‾5⅝
░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░‾‾5¼
2¼ ░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░
░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░
⅜ ‾‾3
░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░‾‾2⅝
2¼ ░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░
░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░
⅜ _____________________________________‾‾⅜
‾‾0
有了他们的程序在手,精灵检查了烟囱,圣诞老人在另一个节日期间没有受伤。
11. 第十一天-Raku 中所有的星号
在今年的 Raku Advent Calendar 中, 雪花被今天的博客文章承包了。 我们将检阅使用了 字符的结构。 在 Raku 中,根据上下文的不同,您可以叫它星星(或者,如果你愿意的话,可以叫它星号)或者 *whatever。
Raku 不是一个隐秘的编程语言, 在许多方面它的语法比 Perl 5 更加一致。另一方面,有些地方需要花时间来开启对语法的信心。
让我们看看 的不同用法,从最简单的开始,旨在了解最烧脑的例如
** *
。
前两种用法很简单,不需要太多的讨论:
11.1. 1. 乘法
单个星号用于乘法。严格来讲, 这是一个中缀操作符 infix:<*>
, 它的返回值为 Numeric
。
say 20 * 18; # 360
11.2. 2. 幂
两个星号 是幂操作符。再次, 这是一个中缀操作符
infix:<
>
, 它返回 Numeric
结果, 计算两个给定值点幂。
say pi ** e; # 22.4591577183611
正则表达式中同样也使用了两个标记( 或
*
),它们表示不同的东西。 Raku 的一个特点是它可以很容易地在不同的语言之间切换。 正则表达式和 grammar 都是这样的内部语言的例子,其中同样的符号在 Raku 中可能意味着不同的含义。
11.3. 3. 零或多次重复
*
号量词这个语法条目和 Perl 5 中点行为类似: 允许原子的零次或多次重复。
my $weather = '*****';
my $snow = $weather ~~ / ('*'*) /;
say 'Snow level is ' ~ $snow.chars; # Snow level is 5
当然, 我们还在这儿看到了同一个字符的另一种用法, *
字面量。
11.4. 4. Min 到 Max 次重复
两个 **
号是另一个量词的一部分,它指定了最小和最大重复次数:
my $operator = '..';
say "'$operator' is a valid Raku operator"
if $operator ~~ /^ '.' ** 1..3 $/;
在这个例子中,预计这个点会被重复一次,两次或三次; 不多也不少。
让我们超前一点儿,以 Whatever
符号的角色(剧场中的角色,而不是 Raku 的面向对象编程)使用星号:
my $phrase = 'I love you......';
say 'You are so uncertain...'
if $phrase ~~ / '.' ** 4..* /;
范围的第二个端点是打开的,这个正则表达式接受所有其中包含四个点以上的短语。
11.5. 5. 吞噬参数
在子例程签名的数组参数之前的星号意味着吞噬参数 - 将单独的标量参数吞噬进单个数组中。
list-gifts('chocolade', 'ipad', 'camelia', 'raku');
sub list-gifts(*@items) {
say 'Look at my gifts this year:';
.say for @items;
}
哈希也允许吞噬参数:
dump(alpha => 'a', beta => 'b'); # Prints:
# alpha = a
# beta = b
sub dump(*%data) {
for %data.kv {say "$^a = $^b"}
}
请注意,与 Perl 5 不同的是,如果您省略函数签名中的星号,代码将无法编译,因为 Raku 就是说一不二:
Too few positionals passed; expected 1 argument but got 0
11.6. 6. 吨吨吨吨吨吨吨
**@
也能工作,但是当你传递数组或列表的时候请注意其中的区别。
带一颗星星:
my @a = < chocolade ipad >;
my @b = < camelia raku >;
all-together(@a, @b);
all-together(['chocolade', 'ipad'], ['camelia', 'raku']);
all-together(< chocolade ipad >, < camelia raku >);
sub all-together(*@items) {
.say for @items;
}
目前,无论参数列表传递的方式如何,每个礼物都被单独打印了出来。
chocolade
ipad
camelia
raku
chocolade
ipad
camelia
raku
chocolade
ipad
camelia
raku
带俩颗星星:
keep-groupped(@a, @b);
keep-groupped(['chocolade', 'ipad'], ['camelia', 'raku']);
keep-groupped(< chocolade ipad >, < camelia raku >);
sub keep-groupped(**@items) {
.say for @items;
}
这一次,@items
数组只有两个元素,反映了参数的结构类型:
[chocolade ipad]
[camelia raku]
或
(chocolade ipad)
(camelia raku)
11.7. 7. 动态作用域
*
twigil,引入了动态作用域。 动态变量和全局变量很容易搞混淆,所以最好测试下面的代码。
sub happy-new-year() {
"Happy new $*year year!"
}
my $*year = 2018;
say happy-new-year(); # 输出 Happy new 2018 year!
如果你省略了星号, 那么代码就运行不了:
Variable '$year' is not declared
更正它的唯一方法是将 $year
的定义移到函数定义的上面。 使用动态变量 $*year
,函数被调用的地方定义了结果。 $*year
变量在子例程的外部作用域中是不可见的,但是在动态作用域内是可见的。
对于动态变量,将新值赋给现有变量还是创建新变量并不重要:
sub happy-new-year() {
"Happy new $*year year!"
}
my $*year = 2018;
say happy-new-year();
{
$*year = 2019; # New value
say happy-new-year(); # 2019
}
{
my $*year = 2020; # New variable
say happy-new-year(); # 2020
}
11.8. 8. 编译变量
Raku 提供了许多伪动态常量, 例如:
say $*PERL; # Raku (6.c)
say @*ARGS; # Prints command-line arguments
say %*ENV<HOME>; # Prints home directory
11.9. 9. All methods
.*
postfix 伪操作符调用给定名称的所有方法,名称可以在给定的对象中找到,并返回一个结果列表。 在微不足道的情况下,你会得到一个学术上荒诞不羁的代码:
6.*perl.*say; # (6 Int.new)
带星号的代码与不带星号代码有些不同:
pi.perl.say; # 3.14159265358979e0 (notice the scientific
# format, unlike pi.say)
.*
postfix 的真正威力来自于继承。 它有时有助于揭示真相:
class Present {
method giver() {
'parents'
}
}
class ChristmasPresent is Present {
method giver() {
'Santa Claus'
}
}
my ChristmasPresent $present;
$present.giver.say; # Santa Claus
$present.*giver.join(', ').say; # Santa Claus, parents
一个星号就差别很大!
现在,到了 Raku 最神秘的部分。接下来的两个概念,Whatever
和 WhateverCode
类,很容易混淆在一起。 让我们试着做对吧。
11.10. 10. Whatever
单个星号 *
能表示任何东西(Whatever
)。 Whatever
在 Raku 中是一个预定义好的类, 它在某些有用的场景下引入了一些规定好的行为。
例如,在范围和序列中,最后的 *
表示无穷大。 我们今天已经看到了一个例子。 这是另一个:
.say for 1 .. *;
这个单行程序具有非常高的能量转换效率,因为它产生了一个递增整数的无限列表。 如果你要继续,请按 Ctrl + C
。
范围 1 .. *
与 1 .. Inf
相同。 您可以清楚地看到,如果您跳转到 Rakudo Raku 源文件并在 src/core/Range.pm 文件的 Range
类的实现中找到如下定义:
multi method new(Whatever \min,Whatever \max,:$excludes-min,:$excludes-max){
nqp::create(self)!SET-SELF(-Inf,Inf,$excludes-min,$excludes-max,1);
}
multi method new(Whatever \min, \max, :$excludes-min, :$excludes-max) {
nqp::create(self)!SET-SELF(-Inf,max,$excludes-min,$excludes-max,1);
}
multi method new(\min, Whatever \max, :$excludes-min, :$excludes-max) {
nqp::create(self)!SET-SELF(min,Inf,$excludes-min,$excludes-max,1);
}
这三个 multi 构造函数描述了三种情况:* ..
, .. $n
和 $n .. *
,它们被立即转换为 -Inf .. Inf
,-Inf .. $n
和 $n .. Inf
。
作为一个圣诞故事,这里有一个小小的插曲,表明
*
不仅仅是一个Inf
。 有两个到 src/core/Whatever.pm 的提交:
my class Whatever { multi method ACCEPTS(Whatever:D: $topic) { True } multi method perl(Whatever:D:) { '*' } + multi method Numeric(Whatever:D:) { Inf } }
几周之后, 在 2015 年 10 月 23 日,"* no longer defaults to Inf",这是为了保护其他 dwimmy 情况下的扩展性:
my class Whatever { multi method ACCEPTS(Whatever:D: $topic) { True } multi method perl(Whatever:D:) { '*' } - multi method Numeric(Whatever:D:) { Inf } }
回到我们更实际的问题,让我们创建自己的使用 whatever 符号 *
的类,。 下面是一个简单的例子,它带有一个接收 Int
值或者 Whatever
的 multi-方法。
class N {
multi method display(Int $n) {
say $n;
}
multi method display(Whatever) {
say 2000 + 100.rand.Int;
}
}
在第一种情况下,该方法只是打印该值。 第二种方法是打印一个在 2000 到 2100 之间的随机数。 因为第二种方法的唯一参数是 Whatever
,所以签名中不需要变量。
下面是你如何使用这个类:
my $n = N.new;
$n.display(2018);
$n.display(*);
第一个调用回显它的参数,而第二个调用打印某些随机的东西。
Whatever
符号可以作为一个裸的 Whatever
。 假如,你创建一个 echo
函数,并将 *
传递给它:
sub echo($x) {
say $x;
}
echo(2018); # 2018
echo(*); # *
这一次,没有魔术发生,该程序打印一个星号。
现在我们正处在一个四两拨千斤的节骨眼上。
11.11. 11. WhateverCode
最后, 我们来谈谈 WhateverCode
。
取一个数组然后打印出它的最后一个元素。如果你使用 Perl 5 的风格来做, 你会键入 @a[-1]
那样的东西。在 Raku 中, 那会产生错误:
Unsupported use of a negative -1 subscript
to index from the end; in Raku please
use a function such as *-1
编译器建议使用一个函数, 例如 *-1
。它是函数吗?是的, 更准确的说, 它是一个 WhateverCode
块:
say (*-1).WHAT; # (WhateverCode)
现在, 打印数组的后半部分:
my @a = < one two three four five six >;
say @a[3..*]; # (four five six)
数组的索引的范围是 3 .. *
。 Whatever
作为 range 的右端意味着从数组中取出所有剩余的元素。 3 .. *
的类型是 Range
:
say (3..*).WHAT; # (Range)
最后,减少一个元素。 我们已经看到,要指定最后一个元素,必须要使用诸如 *-1
的函数。 在 range 的右端可以做同样的事情:
say @a[3 .. *-2]; # (four five)
在这个时候,发生了所谓的 Whatever-柯里化
,Range
变成了 WhateverCode
:
say (3 .. *-2).WHAT; # (WhateverCode)
WhateverCode
是一个内置的 Raku 类名称; 它可以很容易地用于方法分派。 让我们更新上一节中的代码,并添加一个方法变体,它需要一个 WhateverCode
参数:
class N {
multi method display(Int $n) {
say $n;
}
multi method display(Whatever) {
say 2000 + 100.rand.Int;
}
multi method display(WhateverCode $code) {
say $code(2000 + 100.rand.Int);
}
}
现在,参数列表中的星号要么落入 display(Whatever)
, 要么落入 display(WhateverCode)
:
N.display(2018); # display(Int $n)
N.display(*); # display(Whatever)
N.display(* / 2); # display(WhateverCode $code)
N.display(* - 1000); # display(WhateverCode $code)
我们再来看看 display
方法中的签名:
multi method display(WhateverCode $code)
$code
参数被用作方法内的函数引用:
say $code(2000 + 100.rand.Int);
该函数需要一个参数,但它会去哪里? 或者换句话说,函数体是什么,在哪里? 我们将该方法调用为 N.display(* / 2)
或 N.display(* - 1000)
。 答案是 * / 2
和 * - 1000
都是函数! 还记得编译器关于使用诸如 *-1
之类的函数的提示吗?
这里的星号成为第一个函数参数,因此 * / 2
相当于 {$^a / 2}
,而 *-1000
相当于 {$^a - 1000}
。
这是否意味着可以在 $^a
的旁边使用 $^b
? 当然! 使 WhateverCode
块接受两个参数。 你如何指出其中的第二个? 毫不惊喜,再用一个星号! 让我们将 display
方法的第四个变体添加到我们的类中:
multi method display(WhateverCode $code
where {$code.arity == 2}) {
say $code(2000, 100.rand.Int);
}
这里,使用 where
块来缩小调度范围,只选择那些有两个参数的 WhateverCode
块。 完成此操作后,方法调用中将允许含有两个雪花:
N.display( * + * );
N.display( * - * );
这些调用定义了用于计算结果的函数 $code
。 所以,N.display(* + *)
背后的实际操作如下:2000 + 100.rand.Int
。
需要更多的雪花吗? 多添加点星星:
N.display( * * * );
N.display( * ** * );
类似地, 里面实际的计算是:
2000 * 100.rand.Int
和
2000 ** 100.rand.Int
恭喜! 你现在可以像编译器那样毫不费力地解析 * ** *
结构了。
11.12. 作业
到目前为止,Raku 给了我们很多圣诞礼物。 让我们回过头来做一下练习并回答一下问题:下面代码中的每个星号在意味着什么?
my @n =
((0, 1, * + * ... *).grep: *.is-prime).map: * * * * *;
.say for @n[^5];
D’哦。 我建议我们从转换代码开始来摆脱所有的星号,并使用不同的语法。
序列运算符 …
之后的 *
意味着无限地生成序列,所以用 Inf
来代替它:
((0, 1, * + * ... Inf).grep: *.is-prime).map: * * * * *
生成器函数中的两个星号 * + *
可以用一个带有两个显式参数的 lambda 函数来替换:
((0, 1, -> $x, $y {$x + $y} ... Inf).grep:
*.is-prime).map: * * * * *
现在,简单的语法交替。 用带圆括号的方法调用替换 .grep
。 它的参数 .is-prime
变成一个代码块,并且星号被替换为默认变量 $_
。 请注意,代码使用 时不需要花括号。
(0, 1, -> $x, $y {$x + $y} ... Inf).grep({
$_.is-prime
}).map: * * * * *
最后,与 .map
相同的技巧:但是这次这个方法有三个参数,因此,你可以编写 {$^a * $^b * $^c}
而不是 * * * * *
,这里是新的 完整程序的变体:
my @n = (0, 1, -> $x, $y {$x + $y} ... Inf).grep({
$_.is-prime
}).map({
$^a * $^b * $^c
});
.say for @n[^5];
现在很明显,代码打印了三个斐波那契素数组积的前五个。
11.13. 附加题
在教科书中,最具挑战性的任务是用 *
标记的。 这里有几个由你自己来解决。
-
1. Raku 中的
chdir('/')
和&*chdir('/')
有什么区别? -
2. 解释下面的 Raku 代码并修改它以展示其优点:
.say for 1 … **
。
❄❄❄
今天就这样了。 我希望你喜欢 Raku 的强大功能和表现力。今天,我们只谈到了一个 ASCII 字符。 想象一下,如果考虑到该语言在当今编程语言中提供了最好的 Unicode 支持,Raku 的 Universe 是多么的庞大。
今天享受 Raku,并传播这个词! 请继续关注 Raku Advent Calendar; 更多的文章正在等待你的关注,明天就要来了。
13. 第十三天 - 使用 Raku 挖掘维基百科
13.1. 介绍
大家好!
今天,让我介绍一下如何用 Raku 挖掘维基百科的 Infobox。
维基百科信息框在自然语言处理中扮演着非常重要的角色,并且有许多应用程序可以利用维基百科信息框:
-
构建知识库(例如 DBpedia[0])
-
排名属性的重要性[1]
-
问答[2]
其中,我将重点讨论信息框提取问题,并演示如何使用 Grammars 和 Actions 解析信息框的复杂结构。
13.2. Grammar 和 Actions 难学吗?
不,他们不是!
你只需要知道五件事情:
-
Grammar
-
token 是最基础的一个。你通常使用它。
-
rule 让空白符有意义。
-
regex 让匹配引擎回溯。
-
Actions
-
make 准备一个对象用于返回当 made calls on it。
-
made 在它的调用者身上调用并返回准备好的对象。
欲了解更多信息, 请查看: https://docs.raku.org/language/grammars
13.2.1. 什么是 Infobox?
你有没有听过 "Infobox" 这个词?
对于那些没听说过的人,我会简单地解释一下。
理解信息框的一个简单方法是使用一个真实的例子:
!img
你可以看到,信息框会在页面的右上方显示页面主题的属性-值对儿。例如, 在这个例子中, 它说 Raku 的设计者 (ja: 設計者)是 Larry Wall(ja: ラリー・ウォール)。
欲了解更多信息, 请查看: https://en.wikipedia.org/wiki/Help:Infobox
13.2.2. 第一个例子: Raku
首先要说的是,我将使用日文维基百科而不是英文维基百科演示解析技术。
主要原因是解析日文维基百科是我的$ dayjob :)
第二个原因是我想要展示 Raku 如何轻松地处理 Unicode 字符串。
然后,让我们开始解析 Raku 文章中的信息框!
用 wiki 标记写的文章的代码是:
{{Comp-stub}}
{{Infobox プログラミング言語
|名前 = Raku
|ロゴ = [[Image:Camelia.svg|250px]]
|パラダイム = [[マルチパラダイムプログラミング言語|マルチパラダイム]]
|登場時期 = [[2015 年]]12 月 25 日
|設計者 = [[ラリー・ウォール]]
|最新リリース = Rakudo Star 2016.04
|型付け = [[動的型付け]], [[静的型付け]]
|処理系 = [[Rakudo]]
|影響を受けた言語 = [[Perl|Perl 5]], [[Smalltalk]], [[Haskell]], [[Ruby]]
|ライセンス = [[Artistic License 2]]
|ウェブサイト = [https://raku.org/ Raku.org]
}}
{{プログラミング言語}}
'''Raku'''(パールシックス)は、[[ラリー・ウォール]]により設計された[[オブジェクト指向]][[スクリプト言語]]である。
Rakuは、[[2000 年]]に[[Perl]]の次期メジャーバージョンとして設計が始められ、[[2015 年]]12 月 25 日に公式のRaku 正式安定版がリリースされた。しかし、言語仕様は現在のPerl (Perl 5)と互換性がなく、既存のPerl 5のソフトウェアをRaku 用に「アップグレ
ード」するのは極めて困難である。したがって現在はPerl 5とRakuは別の言語であると考えられており、RakuはPerl 5の次期バージョンではないとされている。換言すれば、RakuはPerl 5から移行対象とはみなされていない。
代码有三个有问题的部分:
-
信息框块后面有多余的元素,例如模板
{{プログラミング言語}}
和以'''Raku'''
开头的主句。 -
我们必须区分三种类型的 token: 锚点文本(例如:
), 原始文本(例如:
Rakudo Star 2016.04
), 网站链接 (例如:[https://raku.org/ Raku.org]
)。 -
信息框不从文章的顶部位置开始。在这个例子中,
{{Comb-stub}}
在文章的顶部。
好的,接下来我将演示如何按照 Grammar,Actions,Caller(即调用 Grammar 和 Actions 的代码部分)的顺序来解决上述问题。
13.2.3. Grammar
解析信息框的 Grammar 代码如下:
grammar Infobox::Grammar {
token TOP { <infobox> .+ } # (#1)
token infobox { '{{Infobox' <.ws> <name> \n <propertylist> '}}' }
token name { <-[\n]>+ }
token propertylist {
[
| <property> \n
| \n
]+
}
token property {
'|' <key=.key-content> '=' <value=.value-content-list>
}
token key-content { <-[=\n]>+ }
token value-content-list {
<value-content>+
}
token value-content { # (#6)
[
| <anchortext>
| <weblink>
| <rawtext>
| <delimiter>
]+
}
token anchortext { '[[' <-[\n]>+? ']]' } # (#2)
token weblink { '[' <-[\n]>+? ']' } # (#3)
token rawtext { <-[\|\[\]\n、\,\<\>\}\{]>+ } # (#4)
token delimiter { [ '、' | ',' ] } # (#5)
}
-
问题 1 的答案:
-
使用
.+
来匹配多余的部分。(#1) -
问题 2 的答案:
-
准备 3 种类型的 tokens: 锚文本(#2), 网站链接(#2), 和原始文本(#4)。
-
tokens 可能被分隔符(例如:
,
)分割, 所以准备分割符 token。(#5) -
将 token 值-内容表示为四个 token 的任意长度序列(即,锚点文本,网站链接,原始文本,分隔符)。(#6)
-
问题 3 的答案:
-
没有特别的事情要提及。
13.2.4. Actions
Actions 的代码如下:
class Infobox::Actions {
method TOP($/) { make $<infobox>.made }
method infobox($/) {
make %( name => $<name>.made, propertylist => $<propertylist>.made )
}
method name($/) { make ~$/.trim }
method propertylist($/) {
make $<property>>>.made
}
method property($/) {
make $<key>.made => $<value>.made
}
method key-content($/) { make $/.trim }
method value-content-list($/) {
make $<value-content>>>.made
}
method value-content($/) { # (#1)
my $rawtext = $<rawtext>>>.made>>.trim.grep({ $_ ne "" });
make %(
anchortext => $<anchortext>>>.made,
weblink => $<weblink>>>.made,
rawtext => $rawtext.elems == 0 ?? $[] !! $rawtext.Array
);
}
method anchortext($/) {
make ~$/;
}
method weblink($/) {
make ~$/;
}
method rawtext($/) {
make ~$/;
}
}
-
问题 2 的解决方法:
-
使 token value-content 由三个键组成:anchortext,weblink 和 rawtext。
-
问题 1 和 3 的解决方法:
-
没有特别的事情要提及。
13.2.5. Caller
Caller 部分的代码如下:
my @lines = $*IN.lines;
while @lines {
my $chunk = @lines.join("\n"); # (#1)
my $result = Infobox::Grammar.parse($chunk, actions => Infobox::Actions).made; # (#2)
if $result<name>:exists {
$result<name>.say;
for @($result<propertylist>) -> (:$key, :value($content-list)) { # (#3)
$key.say;
for @($content-list) -> $content {
$content.say;
}
}
}
shift @lines;
}
-
问题 3 的解决方法:
-
逐行阅读文章并制作一个包含当前行和最后一行之间的行的块。 (#1)
-
如果解析器确定:
-
该块不包含信息框,它返回一个未定义的值。接收未定义值的好方法之一是使用
$
符号。(#2) -
该块包含信息框,它返回一个定义的值。使用
@()
contextualizer 并迭代结果。(#3) -
问题 1 和 2 的解决方法:
-
没有特别的事情要提及。
13.2.6. 运行解析器
你准备好了吗? 是时候运行第一个例子了!
$ raku parser.p6 < raku.txt
プログラミング言語
名前
{anchortext => [], rawtext => [Raku], weblink => []}
ロゴ
{anchortext => [[[Image:Camelia.svg|250px]]], rawtext => [], weblink => []}
パラダイム
{anchortext => [[[マルチパラダイムプログラミング言語|マルチパラダイム]]], rawtext => [], weblink => []}
登場時期
{anchortext => [[[2015 年]]], rawtext => [12 月 25 日], weblink => []}
設計者
{anchortext => [[[ラリー・ウォール]]], rawtext => [], weblink => []}
最新リリース
{anchortext => [], rawtext => [Rakudo Star 2016.04], weblink => []}
型付け
{anchortext => [[[動的型付け]] [[静的型付け]]], rawtext => [], weblink => []}
処理系
{anchortext => [[[Rakudo]]], rawtext => [], weblink => []}
影響を受けた言語
{anchortext => [[[Perl|Perl 5]] [[Smalltalk]] [[Haskell]] [[Ruby]]], rawtext => [], weblink => []}
ライセンス
{anchortext => [[[Artistic License 2]]], rawtext => [], weblink => []}
ウェブサイト
{anchortext => [], rawtext => [], weblink => [[https://raku.org/ Raku.org]]}
我们看到的例子可能对您来说太简单了。让我们挑战更难的!
13.2.7. 第二个例子: 阿尔伯特爱因斯坦
作为第二个例子,我们来解析 阿尔伯特爱因斯坦的信息框。
用 wiki 标记写的文章的代码是:
{{Infobox Scientist
|name = アルベルト・アインシュタイン
|image = Einstein1921 by F Schmutzer 2.jpg
|caption = [[1921 年]]、[[ウィーン]]での[[講義]]中
|birth_date = {{生年月日と年齢|1879|3|14|no}}
|birth_place = {{DEU1871}}<br>[[ヴュルテンベルク王国]][[ウルム]]
|death_date = {{死亡年月日と没年齢|1879|3|14|1955|4|18}}
|death_place = {{USA1912}}<br />[[ニュージャージー州]][[プリンストン (ニュージャージー州)|プリンストン]]
|residence = {{DEU}}<br />{{ITA}}<br>{{CHE}}<br />{{AUT}}(現在の[[チェコ]])<br />{{BEL}}<br />{{USA}}
|nationality = {{DEU1871}}、ヴュルテンベルク王国(1879-96)<br />[[無国籍]](1896-1901)<br />{{CHE}}(1901-55)<br />{{AUT1867}}(1911-12)<br />{{DEU1871}}、{{DEU1919}}(1914-33)<br />{{USA1912}}(1940-55)
| spouse = [[ミレヴァ・マリッチ]] (1903-1919)<br />{{nowrap|{{仮リンク|エルザ・アインシュタイン|en|Elsa Einstein|label=エルザ・レーベンタール}} (1919-1936)}}
| children = [[リーゼル・アインシュタイン|リーゼル]] (1902-1903?)<br />[[ハンス・アルベルト・アインシュタイン|ハンス
・アルベルト]] (1904-1973)<br />[[エドゥアルト・アインシュタイン|エドゥアルト]] (1910-1965)
|field = [[物理学]]<br />[[哲学]]
|work_institution = {{Plainlist|
* [[スイス特許庁]] ([[ベルン]]) (1902-1909)
* {{仮リンク|ベルン大学|en|University of Bern}} (1908-1909)
* [[チューリッヒ大学]] (1909-1911)
* [[プラハ・カレル大学]] (1911-1912)
* [[チューリッヒ工科大学]] (1912-1914)
* [[プロイセン科学アカデミー]] (1914-1933)
* [[フンボルト大学ベルリン]] (1914-1917)
* {{仮リンク|カイザー・ヴィルヘルム協会|en|Kaiser Wilhelm Society|label=カイザー・ヴィルヘルム研究所}} (化学・物理学研究所長, 1917-1933)
* [[ドイツ物理学会]] (会長, 1916-1918)
* [[ライデン大学]] (客員, 1920-)
* [[プリンストン高等研究所]] (1933-1955)
* [[カリフォルニア工科大学]] (客員, 1931-33)
}}
|alma_mater = [[チューリッヒ工科大学]]<br />[[チューリッヒ大学]]
|doctoral_advisor = {{仮リンク|アルフレート・クライナー|en|Alfred Kleiner}}
|academic_advisors = {{仮リンク|ハインリヒ・フリードリヒ・ウェーバー|en|Heinrich Friedrich Weber}}
|doctoral_students =
|known_for = {{Plainlist|
*[[一般相対性理論]]
*[[特殊相対性理論]]
*[[光電効果]]
*[[ブラウン運動]]
*link:E=mc<sup>2</sup>[[E=mc2|質量とエネルギーの等価性]]
*[[アインシュタイン方程式]]
*[[ボース分布関数]]
*[[宇宙定数]]
*[[ボース=アインシュタイン凝縮]]
*[[EPRパラドックス]]
*{{仮リンク|古典統一場論|en|Classical unified field theories}}
}}
| influenced = {{Plainlist|
* {{仮リンク|エルンスト・G・シュトラウス|en|Ernst G. Straus}}
* [[ネイサン・ローゼン]]
* [[レオ・シラード]]
}}
|prizes = {{Plainlist|
*{{仮リンク|バーナード・メダル|en|Barnard Medal for Meritorious Service to Science}}(1920)
*link:1921[[ノーベル物理学賞]]
*link:1921[[マテウチ・メダル]]
*link:1925[[コプリ・メダル]]
*link:1926[[王立天文学会ゴールドメダル]]
*link:1929[[マックス・プランク・メダル]]
}}
|religion =
|signature = Albert Einstein signature 1934.svg
|footnotes =
}}
{{thumbnail:begin}}
{{thumbnail:ノーベル賞受賞者|1921 年|ノーベル物理学賞|光電効果の法則の発見等}}
{{thumbnail:end}}
'''アルベルト・アインシュタイン'''<ref group="†">[[日本語]]における表記には、他に「アル{{Underline|バー}}ト・アインシュine|バー}}ト・アイン{{Underline|ス}}タイン」([[英語]]の発音由来)がある。</ref>({{lang-de-short|Albert Einstein}}<ref ɛrt ˈaɪnˌʃtaɪn}} '''ア'''ルベルト・'''ア'''インシュタイン、'''ア'''ルバート・'''ア'''インシュタイン</ref><ref group="†"taɪn}} '''ア'''ルバ(ー)ト・'''ア'''インスタイン、'''ア'''ルバ(ー)'''タ'''インスタイン</ref><ref>[http://dictionary.rein Einstein] (Dictionary.com)</ref><ref>[http://www.oxfordlearnersdictionaries.com/definition/english/albert-einstein?q=Albert+Einstein Albert Einstein] (Oxford Learner's Dictionaries)</ref>、[[1879 年]][[3 月 14 日]] - [[1955 年]][[4 月 18 日]])ツ]]生まれの[[理論物理学者]]である。
正如你所看到的,这里有五个新问题:
-
一些模板
-
包含换行符;并且
-
是嵌套的(例如.
{{nowrap|{{仮リンク|…}}…}}
)
-
-
某些 attribute-value 对是空的。
-
attribute-value 对的一些 value-sides
-
包含中断标签;并且
-
由不同类型的 token 组成(例如,anchortext 和 rawtext)。所以你需要添加位置信息来表示 tokens 之间的依赖关系。
-
我将按照 Grammar,Actions 的顺序展示如何解决上述问题。
Caller 的代码与前一个相同。
13.2.8. Grammar
Grammar 代码如下:
grammar Infobox::Grammar {
token TOP { <infobox> .+ }
token infobox { '{{Infobox' <.ws> <name> \n <propertylist> '}}' }
token name { <-[\n]>+ }
token propertylist {
[
| <property> \n
| \n
]+
}
token property {
[
| '|' <key=.key-content> '=' <value=.value-content-list>
| '|' <key=.key-content> '=' # (#4)
]
}
token key-content { <-[=\n]>+ }
token value-content-list {
[
| <value-content> <br> # (#6)
| <value-content>
| <br>
]+
}
token value-content-list-nl { # (#1)
[
| <value-content> <br> # (#7)
| <value-content>
| <br>
]+ % \n
}
token value-content {
[
| <anchortext>
| <weblink>
| <rawtext>
| <template>
| <delimiter>
| <sup>
]+
}
token br { # (#5)
[
| '<br />'
| '<br/>'
| '<br>'
]
}
token template {
[
| '{{' <-[\n]>+? '}}'
| '{{nowrap' '|' <value-content-list> '}}' # (#3)
| '{{Plainlist' '|' \n <value-content-list-nl> \n '}}' # (#2)
]
}
token anchortext { '[[' <-[\n]>+? ']]' }
token weblink { '[' <-[\n]>+? ']' }
token rawtext { <-[\|\[\]\n、\,\<\>\}\{]>+ }
token delimiter { [ '、' | ',' | ' ' ] }
token sup { '<sup>' <-[\n]>+? '</sup>'}
}
-
问题 1.1 的解决方法:
-
创建 token value-content-list-nl,它是 value-content-list token 的换行符分隔版本。使用 修改量词
%
来表示这种序列是很有用的。 (#1) -
创建 token 模板。在这一个中,定义一个代表 Plainlist 模板 的序列。 (#2)
-
问题 1.2 的解决方法:
-
使 token 模板能够调用 token value-content-list。此修改触发递归调用并捕获嵌套结构,因为 token value-content-list 包含 token 模板。 (#3)
-
问题 2 的解决方法:
-
在 token property 中,定义一个 value-side 为空的序列(即以'='结尾的序列)。 (#4)
-
问题 3.1 的解决方法:
-
创建 token br(#5) - 让 token br 遵循两个 token 中的 token value-content:
-
token value-content-list (#6)
-
token-content-list-nl(#7)
13.2.9. Actions
Action 代码如下:
class Infobox::Actions {
method TOP($/) { make $<infobox>.made }
method infobox($/) {
make %( name => $<name>.made, propertylist => $<propertylist>.made )
}
method name($/) { make $/.trim }
method propertylist($/) {
make $<property>>>.made
}
method property($/) {
make $<key>.made => $<value>.made
}
method key-content($/) { make $/.trim }
method value-content-list($/) {
make $<value-content>>>.made
}
method value-content($/) {
my $rawtext = $<rawtext>>>.made>>.trim.grep({ $_ ne "" });
make %(
anchortext => $<anchortext>>>.made,
weblink => $<weblink>>>.made,
rawtext => $rawtext.elems == 0 ?? $[] !! $rawtext.Array,
template => $<template>>>.made;
);
}
method template($/) {
make %(body => ~$/, from => $/.from, to => $/.to); # (#1)
}
method anchortext($/) {
make %(body => ~$/, from => $/.from, to => $/.to); # (#2)
}
method weblink($/) {
make %(body => ~$/, from => $/.from, to => $/.to); # (#3)
}
method rawtext($/) {
make %(body => ~$/, from => $/.from, to => $/.to); # (#4)
}
}
-
问题 3.2 的解决方法:
-
调用 make 时,分别使用 Match.from 和 Match.to 来获取匹配开始位置和匹配结束位置。 (#1〜#4)
13.2.10. 运行解析器
该跑了!
$ raku parser.p6 < einstein.txt
Scientist
name
{anchortext => [], rawtext => [{body => アルベルト・アインシュタイン, from => 27, to => 42}], template => [], weblink => []}
image
{anchortext => [], rawtext => [{body => Einstein1921 by F Schmutzer 2.jpg, from => 51, to => 85}], template => [], weblink => []}
caption
{anchortext => [{body => [[1921 年]], from => 97, to => 106} {body => [[ウィーン]], from => 107, to => 115} {body => [[講義]], from => 117, to => 123}], rawtext => [{body => , from => 96, to => 97} {body => での, from => 115, to => 117} {body => 中, from => 123, to => 124}], template => [], weblink => []}
birth_date
{anchortext => [], rawtext => [{body => , from => 138, to => 139}], template => [{body => {{生年月日と年齢|1879|3|14|no}}, from => 139, to => 163}], weblink => []}
birth_place
{anchortext => [], rawtext => [{body => , from => 178, to => 179}], template => [{body => {{DEU1871}}, from => 179, to => 190}], weblink => []}
{anchortext => [{body => [[ヴュルテンベルク王国]], from => 194, to => 208} {body => [[ウルム]], from => 208, to => 215}], rawtext => [], template => [], weblink => []}
death_date
{anchortext => [], rawtext => [{body => , from => 229, to => 230}], template => [{body => {{死亡年月日と没年齢|1879|3|14|1955|4|18}}, from => 230, to => 263}], weblink => []}
death_place
{anchortext => [], rawtext => [{body => , from => 278, to => 279}], template => [{body => {{USA1912}}, from => 279, to => 290}], weblink => []}
{anchortext => [{body => [[ニュージャージー州]], from => 296, to => 309} {body => [[プリンストン (ニュージャージー州)|プリンストン]], from => 309, to => 338}], rawtext => [], template => [], weblink => []}
residence
{anchortext => [], rawtext => [{body => , from => 351, to => 352}], template => [{body => {{DEU}}, from => 352, to => 359}], weblink => []}
{anchortext => [], rawtext => [], template => [{body => {{ITA}}, from => 365, to => 372}], weblink => []}
{anchortext => [], rawtext => [], template => [{body => {{CHE}}, from => 376, to => 383}], weblink => []}
{anchortext => [{body => [[チェコ]], from => 400, to => 407}], rawtext => [{body => (現在の, from => 396, to => 400} {body => ), from => 407, to => 408}], template => [{body => {{AUT}}, from => 389, to => 396}], weblink => []}
{anchortext => [], rawtext => [], template => [{body => {{BEL}}, from => 414, to => 421}], weblink => []}
{anchortext => [], rawtext => [], template => [{body => {{USA}}, from => 427, to => 434}], weblink => []}
nationality
{anchortext => [], rawtext => [{body => , from => 449, to => 450} {body => ヴュルテンベルク王国(1879-96), from => 462, to => 481}], template => [{body => {{DEU1871}}, from => 450, to => 461}], weblink => []}
{anchortext => [{body => [[無国籍]], from => 487, to => 494}], rawtext => [{body => (1896-1901), from => 494, to => 505}], template => [], weblink => []}
{anchortext => [], rawtext => [{body => (1901-55), from => 518, to => 527}], template => [{body => {{CHE}}, from => 511, to => 518}], weblink => []}
{anchortext => [], rawtext => [{body => (1911-12), from => 544, to => 553}], template => [{body => {{AUT1867}}, from => 533, to => 544}], weblink => []}
{anchortext => [], rawtext => [{body => (1914-33), from => 582, to => 591}], template => [{body => {{DEU1871}}, from => 559, to => 570} {body => {{DEU1919}}, from => 571, to => 582}], weblink => []}
{anchortext => [], rawtext => [{body => (1940-55), from => 608, to => 617}], template => [{body => {{USA1912}}, from => 597, to => 608}], weblink => []}
spouse
{anchortext => [{body => [[ミレヴァ・マリッチ]], from => 634, to => 647}], rawtext => [{body => , from => 633, to => 634} {body => (1903-1919), from => 653, to => 664}], template => [], weblink => []}
{anchortext => [], rawtext => [], template => [{body => {{nowrap|{{仮リンク|エルザ・アインシュタイン|en|Elsa Einstein|label=エルザ・レーベンタール}} (1919-1936)}}, from => 670, to => 754}], weblink => []}
children
{anchortext => [{body => [[リーゼル・アインシュタイン|リーゼル]], from => 771, to => 793}], rawtext => [{body => , from => 770, to => 771} {body => (1902-1903?), from => 793, to => 806}], template => [], weblink => []}
{anchortext => [{body => [[ハンス・アルベルト・アインシュタイン|ハンス・アルベルト]], from => 812, to => 844}], rawtext => [{body => (1904-1973), from => 844, to => 856}], template => [], weblink => []}
{anchortext => [{body => [[エドゥアルト・アインシュタイン|エドゥアルト]], from => 862, to => 888}], rawtext => [{body => (1910-1965), from => 888, to => 900}], template => [], weblink => []}
field
{anchortext => [{body => [[物理学]], from => 910, to => 917}], rawtext => [{body => , from => 909, to => 910}], template => [], weblink => []}
{anchortext => [{body => [[哲学]], from => 923, to => 929}], rawtext => [], template => [], weblink => []}
work_institution
{anchortext => [], rawtext => [{body => , from => 949, to => 950}], template => [{body => {{Plainlist|
* [[スイス特許庁]] ([[ベルン]]) (1902-1909)
* {{仮リンク|ベルン大学|en|University of Bern}} (1908-1909)
* [[チューリッヒ大学]] (1909-1911)
* [[プラハ・カレル大学]] (1911-1912)
* [[チューリッヒ工科大学]] (1912-1914)
* [[プロイセン科学アカデミー]] (1914-1933)
* [[フンボルト大学ベルリン]] (1914-1917)
* {{仮リンク|カイザー・ヴィルヘルム協会|en|Kaiser Wilhelm Society|label=カイザー・ヴィルヘルム研究所}} (化学・物理学研究所長, 1917-1933)
* [[ドイツ物理学会]] (会長, 1916-1918)
* [[ライデン大学]] (客員, 1920-)
* [[プリンストン高等研究所]] (1933-1955)
* [[カリフォルニア工科大学]] (客員, 1931-33)
}}, from => 950, to => 1409}], weblink => []}
alma_mater
{anchortext => [{body => [[チューリッヒ工科大学]], from => 1424, to => 1438}], rawtext => [{body => , from => 1423, to => 1424}], template => [], weblink => []}
{anchortext => [{body => [[チューリッヒ大学]], from => 1444, to => 1456}], rawtext => [], template => [], weblink => []}
doctoral_advisor
{anchortext => [], rawtext => [{body => , from => 1476, to => 1477}], template => [{body => {{仮リンク|アルフレート・ク
ライナー|en|Alfred Kleiner}}, from => 1477, to => 1516}], weblink => []}
academic_advisors
{anchortext => [], rawtext => [{body => , from => 1537, to => 1538}], template => [{body => {{仮リンク|ハインリヒ・フリ
ードリヒ・ウェーバー|en|Heinrich Friedrich Weber}}, from => 1538, to => 1593}], weblink => []}
doctoral_students
Nil
known_for
{anchortext => [], rawtext => [{body => , from => 1627, to => 1628}], template => [{body => {{Plainlist|
*[[一般相対性理論]]
*[[特殊相対性理論]]
*[[光電効果]]
*[[ブラウン運動]]
*link:E=mc<sup>2</sup>[[E=mc2|質量とエネルギーの等価性]]
*[[アインシュタイン方程式]]
*[[ボース分布関数]]
*[[宇宙定数]]
*[[ボース=アインシュタイン凝縮]]
*[[EPRパラドックス]]
*{{仮リンク|古典統一場論|en|Classical unified field theories}}
}}, from => 1628, to => 1861}], weblink => []}
influenced
{anchortext => [], rawtext => [{body => , from => 1877, to => 1878}], template => [{body => {{Plainlist|
* {{仮リンク|エルンスト・G・シュトラウス|en|Ernst G. Straus}}
* [[ネイサン・ローゼン]]
* [[レオ・シラード]]
}}, from => 1878, to => 1968}], weblink => []}
prizes
{anchortext => [], rawtext => [{body => , from => 1978, to => 1979}], template => [{body => {{Plainlist|
*{{仮リンク|バーナード・メダル|en|Barnard Medal for Meritorious Service to Science}}(1920)
*link:1921[[ノーベル物理学賞]]
*link:1921[[マテウチ・メダル]]
*link:1925[[コプリ・メダル]]
*link:1926[[王立天文学会ゴールドメダル]]
*link:1929[[マックス・プランク・メダル]]
}}, from => 1979, to => 2181}], weblink => []}
religion
Nil
signature
{anchortext => [], rawtext => [{body => Albert Einstein signature 1934.svg, from => 2206, to => 2241}], template => [], weblink => []}
footnotes
Nil
13.2.11. 结论
我演示了信息框的解析技术。如果您有机会将 Wikipedia 用作 NLP 的资源,我强烈建议您创建自己的解析器。它不仅会加深你对 Raku 的理解而且还会加深关于维基百科知识的理解。
再见!
13.2.12. 引文
[0] Lehmann,Jens 等人。 “DBpedia—一种从维基百科中提取的大型多语言知识库。”Semantic Web 6.2(2015):167-195。
[1]阿里,Esraa,Annalina Caputo 和 SéamusLawless。 “使用学习排序的实体属性排名”。
[2]莫拉莱斯,阿尔瓦罗等人。 “学会回答维基百科信息框的问题。”2016 年自然语言处理实证方法会议论文集。 2016 年
13.2.13. License
所有来自维基百科的资料都是根据 Creative Commons Attribution-ShareAlike 3.0 Unported License 授权使用的。
-
Itsuki 丰田,日本的网页开发人员。
14. 第十四天-在 Raku 中构建和测试 Big Grammars
Raku Grammars 很棒,但在项目中使用它们会是什么样呢?在圣诞节前和圣诞节后,我的经历是一个令人心酸的故事。你可以在 这里找到版本库。我不是来自计算机科学背景,所以也许它看起来很简陋,但是当我学习 Raku Grammars 时,这是我的困难和胜利。
14.1. 第一根火柴
就像卖火柴的小女孩一样,我们的故事发生在圣诞节前。卖火柴的小女孩的任务是在圣诞节前夕销售一捆火柴棍(实际上是新年,我确实回去读了那个故事。圣诞节更适合 Raku),而我的任务是从 Modelica 模型中提取注释渲染为矢量图形。现在,Modelica 是一个非常棒的面向对象的建模语言,除了提及其附录中包含一个具体语法部分的非常好的 规范文档(pdf)之外,我将完全理解它。仔细阅读本节,我意识到“语法元符号”和“词法单位”看起来像我最近读过的一篇博客文章中的 Raku Grammars,并且急于尝试。
来自 Modelica 的示例具体语法:
class-definition :
[ encapsulated ] class-prefixes
class-specifier
Raku rule 的示例:
rule class_definition {
[<|w>'encapsulated'<|w>]?
<class_prefixes>
<class_specifier>
}
这就像卖火柴的小女孩划第一颗火柴一样,第一次看到了一个超越她现实的奇妙世界。一个温暖的小炉子。然后它熄灭了。
它非常接近,我把它放到了一个文本编辑器中,并且用一些 Raku 的东西替换了不是 Raku 的部分,以查看它是否会运行。它没有运行。我砍掉了它,我指出了不同的位来解决更小的块。无处不在的空白符号,正则表达式,标记,规则。我能够解析某些部分,其他部分神秘地没有起效。回顾过去,这一定很糟糕。与此同时,我们一起破解传统的正则表达式来提取注释,并将我的 Grammar 放在架子上。
14.2. 第二根火柴
不久之后,发布了 Grammar::Profiler 和 Grammar::Debugger,并且我受到启发,决定再试一试。我被授予了对我的规则出乎意料表现的很好的见解。我能够比以前更深入地理解 grammar。第二支火柴一直亮着,我有一场盛宴。然后它熄灭了。
在调试器中,我陷入了回溯的深渊。分析器一直运行,因为它一次又一次地陷入泥潭。我能够走得更远,但最终遇到了一堵墙。成功似乎非常接近,但我自己的经历中有太多缺失的部分,并且有文档让我度过难关。
14.3. 第三根火柴
时间流逝,圣诞节来了。我有了新的职位,有时间做个人项目。我有不断改进的 Grammar 文档来指导我。我已经阅读了使用遗留代码高效工作的书。这足以让我再次迎难而上。
14.4. 面向对象
这对我来说是最大的突破。当我从文档中了解到 Tokens,rules 和 regex 都是有趣的外观方法时,我突然发现了所有的东西。当我回到家时,我立即检查我是否可以重写 TOP,并检查是否可以将 Grammar 方法变为 role。两人都很愉快地工作,而且我在做生意。我可以把它分成块,而不是一个单一的,全有或全无的 grammar。这极大地改进了代码的组织和可测试性。
其中一个特别突出的问题是,我能够将 Grammar 整齐地分解成与 Modelica 规范中相应的角色。
lib
----Grammar
--------Modelica
------------LexicalConventions.pm6
------------ClassDefinition.pm6
------------Extends.pm6
------------ComponentClause.pm6
------------Modification.pm6
------------Equations.pm6
------------Expressions.pm6
--------Modelica.pm6
Unit testing: one layer at a time
面向对象开辟了一个明智的单元测试方案,并通过将 Modelica 的部分内容传递到语法中,使我摆脱了临时测试的无稽之谈。您可以像继承其他任何类一样继承和重写语法。这允许您分别测试每个规则或标记,将您的语法分割为一口大小的层。您只需使用要测试的规则或标记覆盖 TOP,并使用占位符方法覆盖任何依赖关系。
Expressions.pm6 中表达式的定义:
rule expression {
[
<|w>'if'<|w> <expression> <|w>'then'<|w> <expression> [
<|w>'elseif'<|w> <expression> <|w>'then'<|w> <expression>
]*
<|w>'else'<|w> <expression>
]
||
<simple_expression>
}
这里我们看到表达式取决于它自己和 simple_expression。为了测试,我们用一个占位符替换了通常的 simple_expression 规则。在这种情况下,它只是匹配字符串’simple_expression'。
从 Expressions.t 覆盖测试语法:
grammar TestExpression is Grammar::Modelica {
rule TOP {^ <expression> $}
rule simple_expression { 'simple_expression' }
}
ok TestExpression.parse('simple_expression');
...
当你可以分离代码中有问题的部分时,回归测试也会更加愉快,并创建一个专门针对它的重写语法。
14.5. <|w> 是你的好帮手
在我的第一次努力中,试图让 Modelic¥留字等正常工作的东西是我“存在的一些障碍”之一。在找到单词边界匹配标记 <|w> 后,这个改变了。当我在每边击打一个时,它可以工作,无论是在空白区还是标点符号旁边。
从 ComponentClause.pm6:
rule type_prefix {
[<|w>[ 'flow' || 'stream' ]<|w>]?
[<|w>[ 'discrete' || 'parameter' || 'constant' ]<|w>]?
[<|w>[ 'input' || 'output' ]<|w>]?
}
14.5.1. Token, rule and regex
现在有很好的文档,但是我也会简要介绍一下我的经验。我发现规则和它的:sigspace ¥术是大多数时候最好的选择。令牌在需要严格控制格式的情况下很有用。
正则表达式用于回溯。对于 Modelica,我发现它是无益的,可能是因为它被设计成单通口语。令牌和规则在我认为我需要的地方工作。所有的单元测试都在我将它们删除后通过,并且语法成功了四个 Modelica 标准库文件。只有在需要时才使用它。
14.5.2. 以开始结束
另一个让我感到沮丧的是类定义语法。 Modelica 使用形式 some_identifier …结束 some_identifier 的类。如何确保在开始和结束时使用相同的标识符对我来说很麻烦。幸运的是,Raku 允许您在语法方法中使用捕获。下面的(<IDENT>)捕获将填充 $0,然后可以用它来确保我们的 long_class_specifier 以适当的标识符结束。
rule long_class_specifier {
[(<IDENT>) <string_comment> <composition> <|w>'end'<|w> $0 ]
||
[<|w>'extends'<|w> (<IDENT>) <class_modification>? <string_comment> <composition> <|w>'end'<|w> $0 ]
}
Integration Testing: lighting all the matches at once
在我的单元测试全部过去后,我感到有点不安。当然,它可以解析我设计的测试案例,但它对真正的 Modelica 会如何呢?颤抖的手,我从他的 Modelica 电子书中提供了一些 Michael Tiller 的示例代码。有效!没有摆弄我忽略的微妙东西,没有有趣的解析错误或永恒的回溯。只是成功。
现在,星星偶尔会对齐。奇迹确实发生。充分巧妙的单元测试可以非常好地预防错误。我已经有足够的时间来验证了。回顾 Damian Conway 的演讲,我决定针对整个 Modelica 标准库运行它。并不是所有的 CPAN,但 305 个文件都比我迄今尝试过的仅仅两个示例模型要好。
我编写了脚本,将它指向了 Modelica 目录,并将它解雇了。它通过图书馆搅动,喘息一下。 150 次失败。现在这是熟悉的领域。经过几次迭代后,当我在 parse_modelica_library 分支上运行它时,我的性能下降到了 66 次。我只是通过一个失败的文件,找出有问题的代码,并为其编写回归测试。
所以,最后小火柴女郎点燃了她捆绑的其余部分。然后,她死了。不要死,但可以同时点亮所有 305 场比赛,例如 /parseThemAll.p6:
#!raku
use v6;
use Test;
use lib '../lib';
use Grammar::Modelica;
plan 305;
sub light($file) {
my $fh = open $file, :r;
my $contents = $fh.slurp-rest;
$fh.close;
my $match = Grammar::Modelica.parse($contents);
say $file;
ok $match;
}
sub MAIN($modelica-dir) {
say "directory: $modelica-dir";
die "Can't find directory" if ! $modelica-dir.IO.d;
# modified from the lovely docs at
# https://docs.raku.org/routine/dir
my @stack = $modelica-dir.IO;
my @files;
while @stack {
for @stack.pop.dir -> $path {
light($path) if $path.f && $path.extension.lc eq 'mo';
@stack.push: $path if $path.d;
}
}
# faster to do in parallel
@files.race.map({light($_)});
}
我会看到在圣诞节前我能说服多少。那么也许我会弄清楚如何编写一些规则来构建 QAST。
圣诞节快乐!
15. 第十五天 - 带有 Promise 的简单网络爬虫
15.1. 承诺,承诺
去年夏天,我申请了一项编程工作,面试官要求我编写一个程序来抓取给定的域,只在该域中的链接之后,找到它引用的所有页面。我被允许以任何语言编写程序,但我选择使用 Go 语言执行任务,因为这是该公司使用的主要语言。这对于并发编程来说是一个理想的任务,并且 Go 具有非常好的现代化功能,即使有些低级别的并发支持。网络蜘蛛中的主要工作是执行与在域中发现的唯一锚链接相同的次数,即在每个页面上执行 HTTP GET 并解析页面文本以获取新链接。这个任务可以并行安全地完成,因为没有可能(除非你做得很糟糕),任何调用爬取代码都会干扰其他任何调用。
Go 和 Raku 的创造者受到安东尼霍尔爵士 1978 年的开创性工作“沟通顺序过程”的启发,但值得注意的是,Raku 代码更加简洁,因此更容易隐藏到博客文章中。事实上,Go 设计者总是将他们的结构称为“并发原语”。 Go 为我的作业应用程序编写的并发 spider 代码大约有 200 行,而在 Raku 中大小不到这个大小的一半。
下面我们来看看如何在 Raku 中实现一个简单的 Web 爬虫。内置的 Promise 类允许您启动,调度和检查异步计算的结果。所有你需要做的就是给 Promise.start 方法一个代码引用,然后调用 await 方法,这会阻塞,直到 promise 完成执行。然后您可以测试结果方法以确定承诺是否已被保留或中断。
您可以通过将其保存到本地文件中来运行本文中的代码,例如网络 spider.p6。如果您希望抓取 https 网站,请使用 zef 安装 HTML::Parser::XML 和 HTTP::UserAgent 以及 IO::Socket::SSL。我会提醒你,SSL 支持目前看起来有点狼狈,所以最好坚持 http 站点。Raku 程序中的 MAIN 子程序存在时表示一个独立程序,这就是执行开始的地方。 MAIN 的参数表示命令行参数。我编写了这个程序,以便默认情况下它会抓取 Perlmonks 站点,但是您可以覆盖它,如下所示:
$ raku web-spider.p6 [–domain=http://example.com]
简单的 Raku 域蜘蛛
use HTML::Parser::XML;
use XML::Document;
use HTTP::UserAgent;
sub MAIN(:$domain="http://www.perlmonks.org") {
my $ua = HTTP::UserAgent.new;
my %url_seen;
my @urls=($domain);
loop {
my @promises;
while ( @urls ) {
my $url = @urls.shift;
my $p = Promise.start({crawl($ua, $domain, $url)});
@promises.push($p);
}
await Promise.allof(@promises);
for @promises.kv -> $index, $p {
if $p.status ~~ Kept {
my @results = $p.result;
for @results {
unless %url_seen{$_} {
@urls.push($_);
%url_seen{$_}++;
}
}
}
}
# Terminate if no more URLs to crawl
if @urls.elems == 0 {
last;
}
}
say %url_seen.keys;
}
# Get page and identify urls linked to in it. Return urls.
sub crawl($ua, $domain, $url) {
my $page = $ua.get($url);
my $p = HTML::Parser::XML.new;
my XML::Document $doc = $p.parse($page.content);
# URLs to crawl
my %todo;
my @anchors = $doc.elements(:TAG<a>, :RECURSE);
for @anchors -> $anchor {
next unless $anchor.defined;
my $href = $anchor.attribs<href>;
# Convert relative to absolute urls
if $href.starts-with('/') or $href.starts-with('?') {
$href = $domain ~ $href;
}
# Get unique urls from page
if $href.starts-with($domain) {
%todo{$href}++;
}
}
my @urls = %todo.keys;
return @urls;
}
15.2. 结论是
并发编程总是会有很多陷阱,从竞争状态到资源匮乏和死锁,但我认为很显然,Raku 已经使得这种编程形式更容易被大家接受。
16. 第十六天 - Raku 性能改进
在英国,我们缺乏感恩节给圣诞节带来了新的一年,感谢和反思。为此,我想围绕Raku性能的状态放置一些我已经坐了一段时间的零碎片断,这些片断强调了这个过程需要付出多少努力。我不确定更广泛的编程社区对正在发生的努力的速度和数量表示赞赏。
我不是核心开发人员,但自2010年推出Rakudo *之后,我一直是Raku的低级用户。通常情况下,已经进入Rakudo的努力被未知的努力所掩盖。人们重新审视Rakudo Raku时尤其如此,他可能会想象下一个圣诞节将会如何。但是Raku在历史上证明,在下一个圣诞节之前,事情总会有所改善,无论您选择哪个圣诞节,
回到2014年的圣诞节,我写了一篇关于为什么我认为Raku能够完成生物信息学工作的出色文章。那篇文章中没有提到的是,为什么在Rakudo上实现Raku根本没有准备好去做任何严肃的生物信息学。表演真的没有!我在Raku中的第一次尝试(当Parrot虚拟机完全使用时)让我执行了几十分钟的简单操作,我期望它是毫秒级的性能。这很遗憾,因为我没有跟踪时间。但这当然不是一个好起点。
然而,快速转发到2014年和MoarVM,我觉得自己写这篇来临邮件感觉很舒服,因为我知道在作为用户的4年中有多少改进。而且,所有的发展都是在完成语言定义和正确的实施。然而,我是一直在等待perf到达那里的用户。我认为大部分时间到了。为此,我要感谢所有核心开发者所付出的巨大的日常努力。观看它展现出令人难以置信的动力。对我来说,这个圣诞节是圣诞节的目标,它已经到来。 👏🏻🎊
我一直在为我的BioInfo模块运行和计时测试,这些模块对生物序列数据进行了多年的基本操作。它以非常糟糕的方式做到了这一点。在紧密循环中分配和丢弃哈希时出现了很多错误等等。但是我已经将这些代码留给了现在 - 在五年多的时间里。悄悄地进行私人基准测试,偶尔鼓励在IRC频道看到大幅飞跃的努力。 Sub 10s是一个很大的!它从30/40秒突然发生。在我暗示IRC一个地方,我的代码在分析时特别慢,这是一次跳跃!
这是一个长期观点,如果我放大去年的这一年,可以看到,如果时间不是很长,整个系数的性能仍然在提高。
请记住,所有这些配置文件都不是来自Rakudo编译器的发布版本,而是来自当天的HEAD。所以偶尔会有一些奇怪的表现回归,正如你上面看到的,通常不会留下来发布。
发生什么了?情况如何变好?有几个原因。 Raku中的许多算法选择和核心功能都已经在源代码级别(更晚些时候)逐步和积极地进行了优化。但支持Rakudo的MoarVM虚拟机的优化能力也得到了提高,并且可以降低到原生代码和内联专用版本的代码。这部分得益于2014年以来Rakudo Raku提供的-profile选项,它提供了所有这些信息。
在上面关于MoarVM如何处理我编译过的Raku测试的代码框的情节中,应该很清楚的是,自从今年夏天以来,有相当多的框架被JIT编译,解释较少,并且几乎所有专用框架(橙色)结束原生JIT(绿色)。如果您想了解更多有关“spesh”MoarVM代码专门工具的最新工作,您可以在他的博客上阅读Jonathan Worthington的4篇文章。 Baart Weigmans还有一篇博客概述了他在JIT编译器方面的工作,最近还谈到了许多尚未登陆的新功能,希望能让许多新开发人员加入并帮助改进JIT。所以如果这对你来说是一件有趣的事情,我建议你查看一下上面的链接。
所以这是我的基准和我的目标,其中大部分是围绕数据结构创建和解析。但是,数字作品等其他内容呢?那也保持了吗?没有任何人推动,就像我推动我对事情可以改进的地方的看法。答案是肯定的!
曾几何时,早在2013年,一位名叫Tim King的绅士就开始对Raku中的素数感兴趣.Tim对他发现的性能颇为不满。正确如此。他从以下漂亮的代码开始:
通过定义一个素数的交叉点找到任何素数,真是一个不错的优雅解决方案!但是蒂姆惊讶地发现联赛很慢,上面的代码让他看到了前1000个素数。今天,超级高级代码需要0.96s。
对于基于联结的代码的缓慢程度,蒂姆继续做更标准的迭代方法感到不满。 Tim在这些帖子后不久就从网上消失。但他留下了我继续留下的遗产。他的主要基准测试代码和我对时间结果的适应性可以在这个要点中找到。以下是另一张图表,其中显示了每个超过100次试验找到前1000个素数所需的平均时间。 2015年的垂直线是较高的标准偏差。
再次以最近的放大视图(最新的数据点让我担心一点,我以某种方式搞砸了……)
上面的收敛到一个点,是启动和停止Rakudo运行时和MoarVM的开销。发现素数并不是它曾经的努力,它比Rakudo的开始稍微慢一些。无论您选择的代码解决方案的级别和优雅程度如何,至少要快一个数量级。
好吧,我们已经看到MoarVM获得了一些闪亮的新运动部件。但是像Liz,jnthn,Zoffix以及最近在字符串Samcv世界中开发人员已经付出了巨大的努力,以改进MoarVM和Rakudo在算法上实际上正在做的事情。
旁注:我相信我根本不会做大多数其他开发人员的正义,特别是在这篇文章中忽略了JVM的努力。我建议每个人都去,并检查提交日志,看看有多少人现在参与使Rakudo更快,更好,更强大。我确定他们想在本文的底部看到您的感谢!
因此,节省你一份查看提交日志的工作我已经做了一些挖掘,看看自上个圣诞节以来与提高性能有关的提交。 N%或Nx更快的东西。如下所示:
3c6277c77 Have .codes use nqp::codes op. 350% faster for short strings
ee4593601 Make Baggy (^) Baggy about 150x faster
这两项承诺将以一年的核心发展时间表推动编程项目的发展。但是,今年,它们仅仅是数百次提交中的两次。
下面是一些提交数量的直方图以及他们提到的性能的百分比和x乘数的增加。你可以用上面的代码自己grep日志。在2016年有一些更令人兴奋的收益值得检查。
这仅仅是2017年的性能提升承诺,几乎每天都会有更多的降落。这甚至不包括许多来自Zoffix授予的I / O性能收益,因为它们在之前/之后并不总是基准。 2016年同样密集,一些疯狂的> 1000倍的改进。今年只有十个左右提交,提高40倍!看到这真是令人印象深刻。至少对我来说。我认为这对项目的许多人来说并不明显,他们正在完成多少。记住这些是单数提交。有些甚至在一年中复合改进!
我会把它留在这里。但是真的很感谢核心开发者,你们所有人。这是一个很棒的观看和等待体验。但现在是时候在2018年继续使用一些Raku代码了!终于圣诞节了。
17. 第十七天 - 关于消息传递
17.1. 为什么要传递消息
当我第一次开始考虑写今年的 Advent 文章时,我反思我在过去的十二个月里并没有真正写过大量的 Raku,与往年相比,我似乎写了大量的模块。我一直在做的事情(至少在我的日常工作中)正在考虑和实施大量使用某些消息传递系统的应用程序。所以我认为将这些想法引入 Raku 会很有趣。
作为一种“胶水语言”,Perl一直享有盛誉,Raku 具有与之竞争的功能,最显着的是响应式和并发功能,因此非常适合创建基于消息的集成服务。
17.2. 传递什么信息
现在我的脚下就是优秀的企业集成模式,尽管它现在已经有近15年的历史了,但我仍然建议任何有兴趣(或工作于)该领域的人。然而,它是一个重量级的书(字面上,它在硬书中的重量接近一点五公斤),所以我用它来提醒自己不要试图在这个主题上详尽无遗,以免这会变成一本书本身。
如果你想亲自尝试一下这些例子,你将需要访问一个 RabbitMQ 代理(它可以作为大多数操作系统发行版的包),但是你可以使用 Docker Image,它看起来工作得很好。
您还需要安装 Net::AMQP,这可以通过以下方式完成:
zef install Net::AMQP
在示例中,我将使用 RabbitMQ 服务器的默认连接详细信息(即代理正在本地主机上运行,并且默认 guest
处于活动状态),如果您需要提供不同的详细信息,则可以更改 Net::AMQP 的构造函数以反映适当的值:
my $n = Net::AMQP.new(
host => 'localhost',
port => 5672,
login => 'guest',
password => 'guest',
vhost => '/'
);
一些示例可能需要其他模块,但我会在介绍时介绍它们。
17.3. 强制性的你好,世界
RabbitMQ实现了由AMQP v0.9规范描述的丰富的代理体系结构,由ActiveMQ实现的最新的v1.0规范取消了大部分规定的代理语义,以至于它基本上是一种不同的协议,它共享一个类似的电线格式。
发送消息(生产者)的最简单可能的例子可能是:
use Net::AMQP;
my $n = Net::AMQP.new;
await $n.connect;
my $channel = $n.open-channel(1).result;
my $exchange = $channel.exchange.result;
$exchange.publish(routing-key => "hello", body => "Hello, World".encode);
await $n.close("", "");
- 这演示了RabbitMQ和Net
-
AMQP的大部分核心功能。
首先你会注意到许多方法返回一个Promise,它将大部分保留在实际的返回值中,这反映了代理的异步性质,它发送(大多数情况但不是全部)确认消息(AMQP说法中的方法,)当操作在服务器上完成时。
这里的连接建立到代理的网络连接并且协商某些参数,如果网络连接失败,提供的凭证不正确或者服务器拒绝某个其他连接,则返回一个Promise,如果成功或失败,它将保留一个真值原因。
- 开放通道打开一个逻辑代理通信通道,在这个通道中交换消息,您可以在应用程序中使用多个通道。当服务器确认后,返回的Promise将保留在初始化的Net
-
AMQP :: Channel对象中。
- 通道对象上的交换方法返回一个Net
-
AMQP :: Exchange对象,在AMQP模型中,所有消息都发布到交换机上,根据交换机的定义,代理可以将消息路由到一个或多个队列由此消息可能被另一客户消耗。在这个简单的例子中,我们将使用默认交换(名为amq.default。)
发布方法是在交换对象上调用的,它没有返回值,因为它只是简单的触发和遗忘,代理不会确认收到和交付,否则队列与发布消息的行为是分离的。顾名思义,路由密钥参数是由代理用来确定将消息路由到哪个队列(或多个队列)。在这个例子中使用默认交换的情况下,交换的类型是直接的,这基本上意味着消息传递到具有与路由密钥匹配的名称的队列中的一个消费者。正文总是一个Buf,并且可以是任意长度,在这种情况下,我们使用的是编码字符串,但它可以同样编码为JSON,MessagePack或BSON blob,无论适合消费应用程序。事实上可以提供内容类型和内容编码参数,如果应用程序的设计需要它,消息将传递给消费者,但代理本身完全不知道有效内容的内容。还有其他可选参数,但在这个例子中不需要。
当然,我们也需要阅读我们发布的消息(消费者):
use Net::AMQP;
my $n = Net::AMQP.new;
my $connection = $n.connect.result;
react {
whenever $n.open-channel(1) -> $channel {
whenever $channel.declare-queue("hello") -> $queue {
$queue.consume;
whenever $queue.message-supply.map( -> $v { $v.body.decode }) -> $message {
say $message;
$n.close("", "");
done();
}
}
}
}
在这里,我们使用的是一个命名队列,而不是像我们在制作人那样在交易所进行操作;如果队列尚不存在,declare-queue将导致队列被创建,并且代理默认将该队列绑定到默认交换,“绑定”实质上意味着发送到交换的消息可以被路由到队列取决于交换类型,消息的路由键以及可能来自消息的其他元数据。在这种情况下,默认交换的“直接”类型将导致消息被路由到与路由密钥相匹配的队列(如果存在的话,如果消息不存在,消息将被无声地丢弃)。
当您准备好开始接收消息时调用消费方法,它将返回一个Promise,该Promise将与“消费者标签”一起保存,该标签将消费者唯一标识给服务器,但由于我们不需要它,因此我们可以忽略它。
- 一旦我们调用了消费(并且代理发送了确认),那么路由到我们队列的消息将作为Net
-
AMQP :: Queue :: Message对象发送到由消息供应返回的Supply,但是因为我们对这个例子中的消息元数据不感兴趣映射被用来创建具有消息的解码体的新的Supply;这是安全的,因为在这种情况下,您可以保证您将接收utf-8编码,但是在真实世界的应用程序中,如果您不控制发送者,您可能希望在处理身体方面更强壮一些(当与第三方应用程序集成时通常是这种情况)。发布消息时提供的内容类型和内容编码在Message对象的headers属性(一个Hash)中可用,但它们不是必需的因此您可能需要考虑适合您的应用的替代方案。
在这个例子中,连接被关闭,并且在接收到第一条消息之后退出响应,但实际上您可能需要删除这些行:
$n.close("", "");
done();
从内到外,如果你想退出一个信号例如添加:
whenever signal(SIGINT) {
$n.close("", "");
done();
}
在反应区的最高层。但是,如果您选择退出程序,则应始终在连接对象上调用close,因为这会在代理日志中引发警告消息,如果不这样做,可能会使管理服务器的人感到不安。
我们当然可以用类似的方式在生产者示例中使用反应语法,但是它会增加冗长的好处,但是在一个更大的程序中,例如,您可能正在处理一个Supply,它可以很好地工作很好:
use Net::AMQP;
my $supply = Supply.from-list("Hello, World", "Bonjour le monde", "Hola Mundo");
my $n = Net::AMQP.new;
react {
whenever $n.connect {
whenever $n.open-channel(1) -> $channel {
whenever $channel.exchange -> $exchange {
whenever $supply.map(-> $v { $v.encode }) -> $body {
$exchange.publish(routing-key => "hello", :$body );
LAST {
$n.close("", "");
done();
}
}
}
}
}
}
17.4. 一些更有用的东西
你可能会认为“这一切都很好,但这不是我不能做的事情,比如说,一个HTTP客户端和一个小型Web服务器”,好吧,你得到可靠的排队,未读消息的持久性等等,但是,对于简单的应用程序来说,它可能会被过度杀死,直到您添加了将消息发送给多个可能未知的消费者的需求为止。这种模式是使用“扇出”交换类型,它将向绑定到交换的所有队列传递消息。
在这个例子中,我们需要声明自己的队列,以便我们可以指定类型,但是生产者不会变得更加复杂:
use Net::AMQP;
my $n = Net::AMQP.new;
my $con = await $n.connect;
my $channel = $n.open-channel(1).result;
my $exchange = $channel.declare-exchange('logs', 'fanout').result;
$exchange.publish(body => 'Hello, World'.encode);
await $n.close("", "");
这里唯一的区别是我们使用声明交换而不是在通道上交换来获得我们发送消息的交换,这样做的好处是使交换在指定类型的代理上创建已经存在,这在这里很有用,因为我们不需要依赖事先创建的交换(使用命令行工具rabbitmqctl或通过web管理界面),但它同样返回一个Promise,它将与Exchange交换目的。您可能还注意到,这里的路由密钥没有被传递给发布方法,这是因为对于扇出交换,路由密钥被忽略,并且消息被传递到绑定到交换机的所有消耗队列。
消费者代码与我们的原始消费者同样不存在差异:
use Net::AMQP;
my $n = Net::AMQP.new;
my $connection = $n.connect.result;
react {
whenever $n.open-channel(1) -> $channel {
whenever $channel.declare-exchange('logs', 'fanout') -> $exchange {
whenever $channel.declare-queue() -> $queue {
whenever $queue.bind('logs') {
$queue.consume;
whenever $queue.message-supply.map( -> $v { $v.body.decode }) -> $message {
say $*PID ~ " : " ~ $message;
}
}
whenever signal(SIGINT) {
say $*PID ~ " exiting";
$n.close("", "");
done();
}
}
}
}
}
交换的声明与生产者示例中声明的方式相同,这非常方便,因此您不必担心启动程序的顺序,第一次运行将创建队列,但是如果您在消费者启动之前运行生产者,发送的消息将被丢弃,因为默认情况下没有路由它们。这里我们还声明了一个没有提供名称的队列,这会创建一个“匿名”队列(该名称由代理组成),因为队列的名称在此路由消息中不起作用案件。
您可以提供一个队列名称,但如果名称重复,那么这些消息将以“先到先得”的方式路由到具有相同名称的队列,这可能不是预期的行为(尽管可能并可能有用。)
同样在这种情况下,队列必须明确地绑定到我们已经声明的交易所,在第一个例子中,默认交易所的绑定是由代理自动执行的,但在大多数情况下,您将不得不在队列上使用绑定交易所的名称。与许多方法一样,绑定返回一个Promise,当代理确认操作已完成时将保留Promise(尽管在这种情况下,值不重要)。
您应该能够根据需要启动尽可能多的消费者,并且他们都将按照发送的顺序接收所有消息。当然,在真实世界的应用程序中,消费者可能是用各种不同语言编写的完全不同的程序。
17.5. 保持主题
一种常见模式是一组消费者,他们只对发布到特定交易所的某些消息感兴趣,其典型例子可能是记录系统,其中有专门针对不同日志级别的消费者。 AMQP提供了一种话题交换类型,允许通过生产者提供的路由密钥上的模式匹配将消息路由到特定的队列。
最简单的生产者可能是:
use Net::AMQP;
multi sub MAIN(Str $message = 'Hello, World', Str $level = 'application.info') {
my $n = Net::AMQP.new;
my $con = await $n.connect;
my $channel = $n.open-channel(1).result;
my $exchange = $channel.declare-exchange('topic-logs', 'topic').result;
$exchange.publish(routing-key => $level, body => $message.encode);
await $n.close("", "");
}
这应该从前面的例子中相当清楚,除了在这种情况下,我们将交换声明为主题类型,并且还提供将由代理用于匹配消费队列的路由密钥。
消费者代码本身又与前面的例子非常相似,只不过它会在命令行上列出一些用于匹配发送到交换机的路由密钥的模式:
use Net::AMQP;
multi sub MAIN(*@topics ) {
my $n = Net::AMQP.new(:debug);
unless @topics.elems {
say "will be displaying all the messages";
@topics.push: '#';
}
my $connection = $n.connect.result;
react {
whenever $n.open-channel(1) -> $channel {
whenever $channel.declare-exchange('topic-logs', 'topic') -> $exchange {
whenever $channel.declare-queue() -> $queue {
for @topics -> $topic {
await $queue.bind('topic-logs', $topic);
}
$queue.consume;
my $body-supply = $queue.message-supply.map( -> $v { [ $v.routing-key, $v.body.decode ] }).share;
whenever $body-supply -> ( $topic , $message ) {
say $*PID ~ " : [$topic] $message";
}
}
}
}
}
}
这里基本上与前面的消费者示例的唯一区别是(除了提供给交换声明的类型)该主题提供给绑定方法。该主题可以是一个简单模式,其中#将匹配任何提供的路由密钥,并且行为将与扇出交换相同,否则*可以在绑定主题的任何部分用作通配符,以匹配任何字符在这个例子中,在这个例子中,应用程序*将匹配使用路由关键字application.info或application.debug发送的消息。
如果有多于一个队列使用相同的模式绑定,则它们的行为也会像绑定到扇出交换机一样。如果绑定模式既不包含哈希也不包含星号字符,那么队列的行为就好像它被绑定到一个直接交换的那个名称的队列一样(也就是说它将有先到先服务基础。)
17.6. 但是,生命比AMQP更重要
当然。 Raku反应模型的优点在于可以将上面提到的供应商提供的各种源集成到您的生产者代码中,并且类似地,消费者可以将消息推送到另一个传输机制。
我很高兴地发现,当我想到这个例子的时候,下面的工作是正常的:
use EventSource::Server;
use Net::AMQP;
use Cro::HTTP::Router;
use Cro::HTTP::Server;
my $supply = supply {
my $n = Net::AMQP.new;
my $connection = $n.connect.result;
whenever $n.open-channel(1) -> $channel {
whenever $channel.declare-queue("hello") -> $queue {
$queue.consume;
whenever $queue.message-supply.map( -> $v { $v.body.decode }) -> $data {
emit EventSource::Server::Event.new(type => 'hello', :$data);
}
}
}
};
my $es = EventSource::Server.new(:$supply);
my $application = route {
get -> 'greet', $name {
content 'text/event-stream; charset=utf-8', $es.out-supply;
}
}
my Cro::Service $hello = Cro::HTTP::Server.new:
:host, :port, :$application;
$hello.start;
react whenever signal(SIGINT) { $hello.stop; exit; }
- 这是EventSource
-
Server中的示例的变体,您当然可以修改它以使用上面讨论的任何交换类型。它应该适用于第一个例子中的生产者代码。而且(如果你是这么说服的话),你可以用一小段node.js代码(或者在一些面向浏览器的javascript中)来消费事件:
var EventSource = require('eventsource');
var event = process.argv[2] || 'message';
console.info(event);
var v = new EventSource(' http://127.0.0.1:10000');
v.addEventListener(event, function(e) {
console.info(e);
}, false);
17.7. 把它包起来
在输入第一段之后,我总结道,在一篇短文中,我永远无法做到这个主题正义,所以我希望你认为这是一个开胃菜,我不认为我会永远找到时间来写书,它可能值得。但是我确实有基于 RabbitMQ 教程的所有示例,因此请检查并随意贡献。
18. 第十八天 - Raku 支持的工作流
保持流畅的编码可能是一个挑战。分心和讨厌的句法错误是潜在的流量瓶颈。
然后是7 +/- 2短期内存限制,我们都必须耍弄。与计算机不同,我们不能仅仅增加更多的硬件来增加大脑工作内存缓冲区的大小 - 至少目前还没有。保持流量需要管理这个缓冲区以避免井喷。幸运的是,我们有电脑帮助。
自计算开始以来,使用计算机扩展记忆的想法一直存在。早在1945年,Vannevar Bush就设想了一种Memex(MEMory EXtender),这是一种“扩大了对个人记忆的贴心补充”。
在2017年,卑微的文本文件可以像一个穷人的memex。该文本文件包含三个部分的时间轴:过去,现在和下一个。这有点像改变日志,但也有未来。过去的部分会随着时间的推移填满,包含完成的任务和信息供以后召回。现在部分可帮助您专注于手头的任务,而下一部分将排队完成将来要完成的任务。
任务通过三种状态:do(+ next),done(!now)和done(-past)。
为了保持畅通,你有时需要快速回忆一些事情,记下将来要做的事情,并专注于现在的进步。保留一个123.do文件可以帮助您减轻编码时的认知负担。
123.do文件的格式很简单,因此您可以直接使用文本编辑器对其进行破解,并使用此Raku语法进行描述。
这是驱动它的Raku命令行模块。
安装它只需:
shell> zef install Do123
shell> 123 +7 Merry Christmas
shell> 123 +13 Happy New Year
19. 第十九天 - Raku 的语言独立验证规则(LIVR)
我刚刚将 LIVR 移植到了 Raku。在 Raku 中编写代码非常有趣。而且,LIVR 的测试套件让我能够在 Raku 的 Email::Valid 模块中发现 bug,而在 Rakudo 中则发现另一个 bug。更有趣的是,不仅仅实现了一个模块,而且还帮助其他开发人员进行了一些测试:)
什么是 LIVR? LIVR 代表“语言独立验证规则”。所以,它就像 “Mustache” ,但在验证的世界。所以,LIVR 由以下几部分组成:
LIVR 有如下语言的实现:
-
Perl 5 (LIVR 2.0) available at CPAN, 维护者 @koorchik
-
Raku (LIVR 2.0) available at CPAN, 维护者 @koorchik
-
JavaScript (LIVR 2.0) available at npm, 维护者 @koorchik
-
PHP (LIVR 2.0) available at packagist, 维护者 @WebbyLab
-
Python (LIVR 2.0) available at pypi, 维护者 @asholok
-
Java (LIVR 2.0), 维护者 @vlbaluk
我会在这里给你一个关于LIVE的简短介绍,但是对于细节,我强烈推荐阅读这篇文章 “LIVR – Data Validation Without Any Issues”
19.1. LIVR 介绍
数据验证是一项非常普遍的任务。我确信每个开发者都会一次又一次面对它。尤其是,当您开发Web应用程序时,这一点很重要。这是一条通用规则 - 绝对不要相信用户的输入。看起来,如果任务如此普遍,应该有大量的图库。是的,但它是很难找到一个理想的。有些库做了太多事情(如 HTML 表单生成等),其他库很难扩展,有些没有分层数据支持等。
而且,如果您是一名 Web 开发人员,则可能需要在服务器和客户端上进行相同的验证。
在 WebbyLab 中,我们主要使用 3 种编程语言 - Perl,JavaScript,PHP。因此,对我们来说,重用跨语言的类似验证方法是理想的选择。
因此,决定创建一个可以跨不同语言工作的通用验证器。
19.1.1. 验证器要求
在尝试了大量的验证库之后,我们对我们想要解决的问题有了一些想法。以下是验证器的要求:
-
规则是声明式并独立于语言的。因此,验证规则只是一个数据结构,而不是方法调用等。您可以对其进行转换,在对其他数据结构进行更改时进行更改
-
每个字段的任何数量的规则
-
验证器应该返回所有字段的错误。例如,我们想突出显示表单中的所有错误
-
剪掉所有没有描述验证规则的字段。 (否则,你不能依赖你的验证,如果验证器不符合这个属性,总有一天你会遇到安全问题)
-
可以验证复杂的层次结构。特别适用于 JSON APIs
-
易于描述和理解验证
-
返回可理解的错误代码(既不是错误消息也不是数字代码)
-
易于实现自己的规则(通常你会在每个项目中有几个)
-
规则应该能够改变结果输出(例如,“trim”,“nested_object”)
-
多用途(用户输入验证,配置验证等)
-
Unicode 支持
19.1.2. LIVR规范
由于该任务设置为独立于编程语言(某种胡须/句柄的东西)创建验证器,但在数据验证领域内,我们从规范的组成开始。
规范的目标是:
-
标准化数据描述格式。
-
描述每个实现必须支持的最小验证规则集。
-
标准化错误代码。
-
成为所有实现的单个基本文档。
-
具有一组测试数据,可以检查实现是否符合规范。
-
基本思想是验证规则的描述必须看起来像数据方案,并且尽可能与数据类似,但是使用规则而不是值。
该规范可在 http://livr-spec.org/ 获得。
这是基本的介绍。更多细节在我上面提到的文章中。
19.2. LIVR和Raku
让我们玩得开心,玩一段代码。我将通过几个例子,并在每个例子后提供一些内部细节。所有示例的源代码都可以在 GitHub 上找到
首先,从 CPAN 安装 Raku 的 LIVR 模块
zef install LIVR
示例1:注册数据验证
use LIVR;
# Automatically trim all values before validation
LIVR::Validator.default-auto-trim(True);
my $validator = LIVR::Validator.new(livr-rules => {
name => 'required',
email => [ 'required', 'email' ],
gender => { one_of => ['male', 'female'] },
phone => { max_length => 10 },
password => [ 'required', {min_length => 10} ],
password2 => { equal_to_field => 'password' }
});
my $user-data = {
name => 'Viktor',
email => 'viktor@mail.com',
gender => 'male',
password => 'mypassword123',
password2 => 'mypassword123'
}
if my $valid-data = $validator.validate($user-data) {
# $valid-data is clean and does contain only fields
# which have validation and have passed it
$valid-data.say;
} else {
my $errors = $validator.errors();
$errors.say;
}
那么,如何理解规则?
这个想法很简单。每条规则都是一个散列. key - 验证规则的名称。value - 一个参数数组。
例如:
{
name => { required => [] },
phone => { max_length => [10] }
}
但如果只有一个参数,则可以使用较短的形式:
{
phone => { max_length => 10 }
}
如果没有参数,则可以将规则的名称作为字符串传递:
{
name => 'required'
}
您可以在数组中给字段传递一个规则列表:
{
name => [ 'required', { max_length => 10 } ]
}
在这种情况下,规则将陆续应用。因此,在这个例子中,首先,“required” 规则将被应用,“max_length” 之后,并且只有当 “required” 成功通过时。
这里是 LIVR 规范的细节。
你可以在这里找到标准规则的列表。
例2:分层数据结构的验证
use LIVR;
my $validator = LIVR::Validator.new(livr-rules => {
name => 'required',
phone => {max_length => 10},
address => {'nested_object' => {
city => 'required',
zip => ['required', 'positive_integer']
}}
});
my $user-data = {
name => "Michael",
phone => "0441234567",
address => {
city => "Kiev",
zip => "30552"
}
}
if my $valid-data = $validator.validate($user-data) {
# $valid-data is clean and does contain only fields
# which have validation and have passed it
$valid-data.say;
} else {
my $errors = $validator.errors();
$errors.say;
}
这个例子中有趣的是什么?
-
模式(验证规则)形状与数据形状非常相似。例如,读取比 JSON Schema 容易得多。
-
看起来 “nested_object” 是一种特殊的语法,但它不是。验证器在 “required”,“nested_object”,“max_length” 之间没有任何区别。所以,核心非常小,您可以轻松地使用自定义规则引入新功能。
-
通常你想重用复杂的验证规则,比如 “address”,并且可以使用别名来完成。
-
您将收到分层错误消息。例如,如果您错过 city 和 name,错误对象将显示
{name ⇒'REQUIRED',address ⇒ {city ⇒'REQUIRED'}}
19.2.1. 别名
use LIVR;
LIVR::Validator.register-aliased-default-rule({
name => 'short_address', # names of the rule
rules => {'nested_object' => {
city => 'required',
zip => ['required', 'positive_integer']
}},
error => 'WRONG_ADDRESS' # custom error (optional)
});
my $validator = LIVR::Validator.new(livr-rules => {
name => 'required',
phone => {max_length => 10},
address => 'short_address'
});
my $user-data = {
name => "Michael",
phone => "0441234567",
address => {
city => "Kiev",
zip => "30552"
}
}
if my $valid-data = $validator.validate($user-data) {
# $valid-data is clean and does contain only fields
# which have validation and have passed it
$valid-data.say;
} else {
my $errors = $validator.errors();
$errors.say;
}
如果你愿意,你可以只为你的验证器实例注册别名:
use LIVR;
my $validator = LIVR::Validator.new(livr-rules => {
password => ['required', 'strong_password']
});
$validator.register-aliased-rule({
name => 'strong_password',
rules => {min_length => 6},
error => 'WEAK_PASSWORD'
});
示例3:数据修改,流水线 有规则可以做数据修改。以下是他们的列表:
-
trim
-
to_lc
-
to_uc
-
remove
-
leave_only
-
default
你可以在这里阅读细节。
用这种方法,你可以创建某种管道。
use LIVR;
my $validator = LIVR::Validator.new(livr-rules => {
email => [ 'trim', 'required', 'email', 'to_lc' ]
});
my $input-data = { email => ' EMail@Gmail.COM ' };
my $output-data = $validator.validate($input-data);
$output-data.say;
这里重要的是什么?
-
正如我之前提到的,对于验证器来说,任何规则都没有区别。它以同样的方式处理 “trim”,“default”,“required”,“nested_object”。
-
规则一个接一个地应用。规则的输出将被传递给下一个规则的输入。这就像一个 bash 管道
echo ' EMail@Gmail.COM ' | trim | required | email | to_lc
-
$input-data
永远不会改变$output-data
是验证后使用的数据。
示例4:自定义规则
您可以使用别名作为自定义规则,但有时这还不够。编写自己的自定义规则绝对没问题。你可以用自定义规则做几乎所有事情。
通常,我们在每个项目中都有 1-5 个自定义规则。此外,您可以将自定义规则组织为单独的可重用模块(甚至可以将其上传到 CPAN)。
那么,如何为 LIVR 编写自定义规则?
这里是’strong_password’的例子:
use LIVR;
my $validator = LIVR::Validator.new(livr-rules => {
password => ['required', 'strong_password']
});
$validator.register-rules( 'strong_password' => sub (@rule-args, %builders) {
# %builders - are rules from original validator
# to allow you create new validator with all supported rules
# my $validator = LIVR::Validator.new(livr-rules => $livr).register-rules(%builders).prepare();
# See "nested_object" rule implementation for example
# https://github.com/koorchik/raku-livr/blob/master/lib/LIVR/Rules/Meta.pm6#L5
# Return closure that will take value and return error
return sub ($value, $all-values, $output is rw) {
# We already have "required" rule to check that the value is present
return if LIVR::Utils::is-no-value($value); # so we skip empty values
# Return value is a string
return 'FORMAT_ERROR' if $value !~~ Str && $value !~~ Numeric;
# Return error in case of failed validation
return 'WEAK_PASSWORD' if $value.chars < 6;
# Change output value. We want always return value be a string
$output = $value.Str;
return;
};
});
查看更多示例的现有规则实现:
示例5:Web 应用程序
LIVR 适用于 REST API。通常,很多 REST API 在返回可理解的错误方面存在问题。如果您的 API 用户将收到 HTTP 错误 500,它不会帮助他。更好的时候,他会得到类似的错误:
{
"name": "REQUIRED",
"phone": "TOO_LONG",
"address": {
"city": "REQUIRED",
"zip": "NOT_POSITIVE_INTEGER"
}
}
而不仅仅是“服务器错误”。
所以,让我们试着做一个带有两个端点的小型 Web 服务:
-
GET /notes → get list of notes
-
POST /notes → create a note
您需要为其安装 Bailador:
zef install Bailador
我们来创建一些服务。我更喜欢带有 “run”模板方法的服务中的 “Command”模式。
我们将有 2 项服务:
-
Service::Notes::Create
-
Service::Notes::List
服务使用示例:
my %CONTEXT = (storage => my @STORAGE);
my %note = title => 'Note1', text => 'Note text';
my $new-note = Service::Notes::Create.new(
context => %CONTEXT
).run(%note);
my $list = Service::Notes::Create.new(
context => %CONTEXT
).run({});
有了上下文,你可以注入任何依赖关系。 “run” 方法接受用户传递的数据。
以下是创建笔记服务的源代码:
use Service::Base;
my $LAST_ID = 0;
class Service::Notes::Create is Service::Base {
has %.validation-rules = (
title => ['required', {max_length => 20} ],
text => ['required', {max_length => 255} ]
);
method execute(%note) {
%note<id> = $LAST_ID++;
$.context<storage>.push(%note);
return %note;
}
}
和 Service::Base 类:
use LIVR;
LIVR::Validator.default-auto-trim(True);
class Service::Base {
has $.context = {};
method run(%params) {
my %clean-data = self!validate(%params);
return self.execute(%params);
}
method !validate($params) {
return $params unless %.validation-rules.elems;
my $validator = LIVR::Validator.new(
livr-rules => %.validation-rules
);
if my $valid-data = $validator.validate($params) {
return $valid-data;
} else {
die $validator.errors();
}
}
}
“run” 方法保证所有过程都被保留:
-
数据已经过验证。
-
“execute” 仅在验证后才会调用。
-
“execute” 将只收到干净的数据。
-
在验证错误的情况下引发异常。
-
在调用“execute”之前可以检查权限。
-
可以执行额外的工作,如缓存验证器对象等。
这是完整的工作示例。
运行应用程序:
raku app.pl6
创建一个 note:
curl -H "Content-Type: application/json" -X POST -d '{"title":"New Note","text":"Some text here"}' http://localhost:3000/notes
检查验证:
curl -H "Content-Type: application/json" -X POST -d '{"title":"","text":""}' http://localhost:3000/notes
获取notes列表:
curl http://localhost:3000/notes
19.3. LIVR 链接
我希望你会喜欢 LIVR。我会很感激任何反馈。
20. 第二十天 - 宏的进阶
你好!
请允许我,在出现日历的这一天,一个小切线。我不会直接谈论一个很酷的熟练的 Raku 特性。相反,我会打开一个小窗口,讨论可能会发生什么 - 希望在某些时候!
如果你像我一样,在 Rakudo 上继续了几年的进步,你在版本中经常看到这一点:>一些不太有效的功能包括:> - 高级宏
那么,这究竟意味着什么? Raku 确实有宏,但它们目前的限制超出了人们通常想要做的。这并不是说它们目前是无用的,它们仍然是有用的,从前几年出现的其他帖子到 OO::Monitor 使用宏来提前报告拼写错误。
输入 007. 007 是“具有宏观许可的小型实验语言”。这是什么意思?!这是一种用于对宏进行调试和实验的语言,因此当他们被集成到 Raku 中时,他们的设计就已经准备好并经过战斗测试。
那么,它有什么? 007 试图模仿 Raku 的“强大”部分,因此我们不会为完全不同的语言设计宏。这意味着阶段,中缀操作员,(MOP 和正则表达式的要点)。
它是什么样子的? 007 的核心就是喜欢 Raku.然而,它的确存在一些问题。让我们来看看你想写的最重要的片段:FizzBuzz。注意:此博客帖子中的所有代码片段都是可执行的 007 代码,而不是 Raku 代码。
my n = 1;
while n <= 100 {
if n %% 15 {
say("FizzBuzz");
}
else if n %% 3 {
say("Fizz");
}
else if n %% 5 {
say("Buzz");
}
else {
say(n);
}
n = n + 1;
}
什么?你不在乎吗?很明显,我确实答应过你的宏。我们将看看一个简单的宏“name”,它返回最后一个索引对象的名称。
macro name(expr) {
if expr ~~ Q::Postfix::Property {
expr = expr.property;
}
if expr !~~ Q::Identifier {
throw new Exception {
message: "Cannot turn a " ~ type(expr) ~ " into a name"
};
}
return quasi { expr.name };
}
my info = {
foo: "Bond",
bar: {
baz: "James Bond"
},
};
say(name(info)); # info
say(name(info.foo)); # foo
say(name(info.bar.baz)); # baz
所以,你可能会在这里 “WAT”。你是对的 - 这个要点缺少一些解释。宏的一个最重要的功能是访问 AST(抽象语法树)。宏需要能够混淆代码的结构(如 Lisp),而不是代码文本(如 C)。 Q ::类型是代表程序形状的标准化类型。他们并不特别需要表示编译器/解释器如何考虑代码,但他们需要保持稳定,因为我们正在编写我们的代码 - 我们的宏 - 针对这种内省 API。
在这个代码示例中,我们使用了两种 Q 类型:表示点访问的 Q::Postfix::Property 和表示标识符的 Q::Identifier。首先,我们检查我们是否有财产。如果是这种情况,我们提取点右侧的内容(记住,a.b.c 是(a.b).c)。然后我们检查我们是否结束了一个标识符(而不是一个数字),并打印出来。这是例如我们如何才能实现 C# 的操作符名称,而不必为语言添加任何内容!
几天前,masak++
发布了一篇名为“三年过去”的博客文章,标志着 007 的第三个生日。虽然有些地区仍然非常粗糙,但它看起来越来越像一种可用的语言,日复一日。
接下来我们要看的是正在解析的实现。下面是它的外观:(这个例子适用于 PR,但是现在使用特殊外壳):
macro statement:() is parsed(/"whoa!"/) {
return quasi @ Q::Statement {
say("whoa!");
}
};
whoa!;
这也可能是我们希望他们在 Raku 中看起来……或者不是?讨论仍在进行中!鼓励你加入自行车……讨论:-)。这种语言还很年轻,需要大量的丰富功能,以及它的高级功能和简单功能。
在我向你提供大量的元乐趣之前,这里是 007 想要达到目前坐落在分支中的一个里程碑:实现 infix:<ff>
作为库的一部分(如果你不确定,Raku 文档如果在这里适用),而不是语言的一部分。代码在这里!
# our infix macro takes a lhs (left hand side) and a rhs (right hand side).
macro infix:<ff>(lhs, rhs) is tighter(infix:<=>) {
my active = False; # our current value when starting
return quasi {
if {{{lhs}}} {
active = True; # if the bit on the left is true, we switch to active mode
}
my result = active; # the result we are returning
if {{{rhs}}} {
active = False; # if the bit on the right is true, we switch to inactive mode
}
result; # return the result stored *before* the rhs ran.
};
}
my values = ["A", "B", "A", "B", "A"];
for values -> v {
if v == "B" ff v == "B" {
say(v);
}
else {
say("x");
}
}
这就是今天!如果您需要更多,请随时查看教程或示例文件夹。如果你想了解意愿,我们也有路线图。
21. 第二十一天 - 数独与 Junctions 和集合
Raku 中有许多核心元素为您提供强大的工具,以简洁而强大的方式完成任务。其中两个是具有许多特征的联结和集合,但也是截然不同的。为了演示这些功能,我将介绍如何将它们用于一个简单的问题,Sudoku 拼图。
21.1. 数独:进修
所以对于那些不知道数独谜题的人来说,它是一个 9 乘 9 的网格,它提供了一些填充了数字 1-9 的单元格。目标是填充数字在 1 和 9 之间的所有单元格,所以没有任何行,列或子广场具有多于一个的数字。
有几种方法来表示一个数独谜题,我个人最喜欢的是 9×9 嵌套数组,例如:
my @game = [
[4,0,0,0,0,0,0,0,0],
[0,9,0,3,4,6,0,5,0],
[5,8,0,0,9,0,0,0,6],
[0,4,0,8,1,3,0,0,9],
[0,0,0,5,0,4,0,0,0],
[8,0,0,6,2,9,0,4,0],
[3,0,0,0,5,0,0,6,2],
[0,5,0,9,3,2,0,8,0],
[0,0,0,0,0,0,0,0,1]
];
在这种情况下,没有赋值的单元格被赋值为 0,这样所有的单元格都有一个赋值给它们的整数值。使用这种格式要记住的主要事情是你需要使用@game [$ y] [$ x]而不是@game [$ x] [$ y]来引用单元格,
21.2. Junctions:量子逻辑测试
在 Raku 中使用 Junction 的最简单方法之一是逻辑测试。 Junction 可以表示您想要测试的值的选择。例如 :
if ( 5 < 1|10 < 2 ) { say "Spooky" } else { say "Boo" }
Spooky
因此,这不仅证明了操作符链(经验丰富的程序员可能已经看起来很困惑),而且对于 5 <10 和 1 <2,任何连接点(1 | 10)的计算结果都为 True。这样,连接点可以非常已经很强大了,当你为它们分配一个变量容器时,它变得非常有趣。
我们希望能够在我们的数独游戏中做出的一个测试就是看它是否已满。我的意思是每个单元格的赋值都大于 0.完整的拼图可能无法正确完成,但每个单元格都有一个猜测。另一种方法是,没有任何单元格的值为 0.因此,我们可以定义一个 Junction 并将其存储在一个标量变量中,我们可以在任何时候测试它以查看拼图是否已满。
my $full-test = none( (^9 X ^9).map(-> ($x,$y) {
@game[$y][$x];
} ) );
say so $full-test == 0;
False
在这种情况下,游戏中仍然有 0 个数字,因此看看$ full-test 是否等于 0,结果为 False。请注意,如果没有将结果强制转换为布尔值,只有当所有这些值都为 False 时,才会得到等于 0 的单元格的细分。
还要注意使用^ 9 和 X 运算符来生成 0 到 8 的两个范围,然后使用这两个 9 个字符的列表的叉积来列出所有可能的 X,Y 坐标的列表。这就是这种强大的简单性,这是我喜欢 Raku 的原因之一。但我离题了。
这种方法的优点是,一旦你定义了 Junction,你就不需要修改它。如果您更改存储在数组中的值,那么连接将会查看新的值(注意,这仅适用于更新单个单元格,如果用新的数组替换整个子数组,您将打破连接点)。
所以这是一个简单的使用连接点,因此存储一个可以重复使用的多变量测试。但是当你意识到连接点中的值本身就是连接点时,它会变得更有趣。
让我们看看更复杂的测试,如果拼图中的每一行,每列和每个数字中只有一个,则拼图就完成了。为了做这个测试,我们需要三个帮助函数。
subset Index of Int where 0 <= * <= 8;
sub row( Index $y ) {
return (^9).map( { ( $_, $y ) } );
}
sub col( Index $x ) {
return (^9).map( { ( $x, $_ ) } );
}
multi sub square( Index $sq ) {
my $x = $sq % 3 * 3;
my $y = $sq div 3 * 3;
return self.square( $x, $y );
}
multi sub square( Index $x, Index $y ) {
my $tx = $x div 3 * 3;
my $ty = $y div 3 * 3;
return ( (0,1,2) X (0,1,2) ).map( -> ( $dx, $dy ) {
( $tx + $dx, $ty + $dy )
} );
}
因此,我们在这里定义一个索引作为 0 到 8 之间的值,然后定义我们的子索引来返回一个列表列表,其中子列表是一对 X 和 Y 索引。请注意,我们的平方函数可以接受一个或两个位置参数。在单个参数中,我们定义了 0 在左上角然后从左到右,8 是右下角的子广场。两个参数版本给出了给定单元格(包括它自己)的正方形单元格列表。
所以在这些地方我们可以为每一行,列和方块定义我们的一个()列表。一旦我们拥有了它们,我们就可以将它们放入一个全部()连接点。
my $complete-all = all(
(^9).map(
{
|(
one( row( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) ),
one( col( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) ),
one( square( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) )
)
}
)
);
一旦我们进行了测试,看看这个难题是否完整,那就很简单了。
say [&&] (1..9).map( so $complete-all == * );
False
在这里,我们测试 1 到 9 的每个可能的单元格值,对于交叉点,在每种情况下,如果所有的一个()连接仅包含一个值,则这将为真。然后,我们使用[]缩减元运算符来链接这些结果以给出最终的真/假值(如果所有结果均为真,否则为真)。再次,这个测试可以在您向单元格添加值时重新使用,并且只有在拼图完成且正确时才会返回 True。
我们再一次将复杂的测试归结为一行代码。我们的$ complete-all 变量需要定义一次,然后在会话的其余部分有效。
这种嵌套联结测试可以达到很多级别,最后一个例子是如果我们想测试当前的难题是否有效。我的意思是它没有完成,但它没有任何重复的数字和行,列或方块。我们可以再次为此创建一个 Junction,对于每一行,每列或每个方块,如果其中一个或没有一个单元格设置为每个可能的值,则它是有效的。因此,我们创建的 Junction 类似于$ complete-全部。
$valid-all = all(
(^9).map(
{
|(
one(
none( row( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) ),
one( row( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) )
),
one(
none( col( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) ),
one( col( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) )
),
one(
none( square( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) ),
one( square( $_ ).map( -> ( $x, $y ) {
@game[$y][$x]
} ) )
)
)
}
)
);
有效性测试与完整性测试基本相同。
say [&&] (1..9).map( so $valid-all == * );
True
除了在这种情况下我们的谜题是有效的,所以我们得到一个真实的结果。
21.3. 集合:对象的集合
虽然结点对测试值很有用,但如果我们想尝试解决这个难题,它们就不那么有用。但是 Raku 有另一种类型的集合,可以派上用场。套装(及其相关类型的手袋和混合物)可让您收集物品,然后对其进行数学设定操作,以找出不同套装之间的互动方式。
作为一个例子,我们将定义一个可能的函数,它返回给定单元格可能的值。如果单元格具有设置的值,我们将返回空列表。
sub possible( Index $x, Index $y, @game ) {
return () if @game[$y][$x] > 0;
(
(1..9)
(-)
set(
( row($y).map( -> ( $x, $y ) {
@game[$y][$x]
} ).grep( * > 0 ) ),
( col($x).map( -> ( $x, $y ) {
@game[$y][$x]
} ).grep( * > 0 ) ),
( square($x,$y).map( -> ( $x, $y ) {
@game[$y][$x]
} ).grep( * > 0 ) )
)
).keys.sort;
}
在这里,我们发现数字 1 到 9 与由给定单元格所在的行,列和平方值组成的集合之间的差异。我们使用 grep 忽略具有 0 值的单元格。 As Sets 将他们的细节存储为无序的键/值对,我们得到这些键,然后对它们进行排序以保持一致性。请注意,这里我们使用的是运算符的 ascii( - )版本,我们也可以使用 Unicode 版本。
我们可以将该集合定义为来自行,列和平方的每个结果的并集,并且结果将是相同的。在这种情况下,我们也使用 square 的两个参数版本。
应该指出的是,这是可能值最简单的定义,没有附加的逻辑进行,但即使这个简单的结果,我们也可以做最简单的求解算法。如果是这种情况,我们会在网格中的每个单元格中循环,如果它有 1 个可能的值,我们可以将该值设置为该值。在这种情况下,我们将循环,获取要设置的单元列表,然后遍历列表并设置值。如果要设置的列表为空或拼图完成,则停止。
my @updates;
repeat {
@updates = (^9 X ^9).map( -> ($x,$y) {
($x,$y) => possible($x,$y,@game)
} ).grep( *.value.elems == 1 );
for @updates -> $pair {
my ( $x, $y ) = $pair.key;
@game[$y][$x] = $pair.value[0];
}
} while ( @updates.elems > 0 &&
! [&&] (1..9).map( so $complete-all == * ) );
因此,我们列出了对的列表,其中关键是 x,y 坐标,值是可能的值。然后我们删除所有那些没有一个价值的东西。这一直持续到没有找到具有单个可能值的细胞或者谜题已完成为止。
找到解决方案的另一种方法是获得只出现在给定,行,列或方块的一组可能性中的值。例如,如果我们有以下可能性:
(1,2,3),(2,3,4),(),(),(4,5),(),(),(2,3,4),()
1 和 5 只在每行出现一次。我们可以利用对称集合差分算子和算子链来得到它。
say (1,2,3) (^) (2,3,4) (^) () (^) () (^) (4,5) (^) () (^) () (^) (2,3,4) (^) ()
set(1 5)
当然,在这种情况下,我们可以在列表中使用简化元运算符
say [(^)] (1,2,3),(2,3,4),(),(),(4,5),(),(),(2,3,4),()
set(1 5)
所以在这种情况下,算法很简单(在这种情况下,我只是覆盖行,列和方形代码基本相同)。
my @updates;
for ^9 -> $idx {
my $only = [(^)] row($idx).map( -> ( $x,$y ) {
possible($x,$y,@game)
} );
for $only.keys -> $val {
for row($idx) -> ($x,$y) {
if $val (elem) possible($x,$y,@game) {
@updates.push( ($x,$y) => $val );
}
}
}
}
然后,我们可以遍历与上面类似的更新数组。结合这两种算法可以自己解决大量的数独难题并简化其他难题。
请注意,我们必须进行两次传球,首先我们得到我们正在查找的数字,然后我们必须查看每一行并找出数字出现的位置。为此,我们使用(elem)运算符。集合也可以使用关联引用来引用,例如:
say set(1,5){1}
True
21.4. 关于对象的说明
因此,迄今为止所有的例子都使用了基本整数。但是没有任何东西阻止你在连接和集合中使用对象。有几件事要记住,虽然,集合使用===身份运算符进行测试。大多数对象都不能通过身份检查,除非你已经克隆了它们或者已经定义了 WHICH 方法以便能够进行比较。
对于数独谜题,您可能需要创建一个 CellValue 类,用于存储该数字是否为谜题中的初始值之一。如果你这样做,尽管你需要覆盖 WHICH 并使其返回 Cell 的 Integer 值。只要你在这种情况下身体检查技术无效(两个不同的 CellValues 可能具有相同的值,但不会是同一个对象),那么你可以将它们放入集合中。
我希望你已经发现了这个有趣的东西,Junctions 和 Sets 是 Raku 的许多不同部分中的两个,它们可以帮助你轻松完成复杂的任务。如果您对代码感兴趣,可以使用以下基于对象的版本进行安装:
zef install Game::Sudoku
22. 第二十二天 - Raku.d 的特性
所以我们就是这样。 Rakudo Raku 第一次正式发布两年后,或者更准确的说是 6.c。自从马特奥茨从那时起就开始关注性能的提升之后,圣诞老人认为要对此进行对比,描述自那时起实施的 6.d 的新功能。因为有很多,圣诞老人不得不做出选择。
22.1. 在创建时调整对象
您创建的任何课程现在都可以使用 TWEAK 方法。在新的类的新实例的所有其他初始化完成之前,这个方法将被调用。一个简单的,有点人为的例子,其中一个类 A 有一个属性,默认值是 42,但如果在创建对象时指定了默认值,它应该更改该值:
class A {
has $.value = 42;
method TWEAK(:$value = 0) { # default prevents warning
# change the attribute if the default value is specified
$!value = 666 if $value == $!value;
}
}
# no value specified, it gets the default attribute value
dd A.new; # A.new(value => 42)
# value specified, but it is not the default
dd A.new(value => 77); # A.new(value => 77)
# value specified, and it is the default
dd A.new(value => 42); # A.new(value => 666)
22.2. 并发性改进
Rakudo Raku 的并发功能在引擎盖下看到了许多改进。其中一些暴露为新功能。最显着的是 Lock::Async(一个返回 Promise 的非阻塞锁)和原子操作符。
在大多数情况下,您不需要直接使用它们,但是如果您正在编写使用并发功能的程序,那么您可能知道原子操作符。经常发生的逻辑错误,特别是如果你在 Pumpking Perl 5 中使用线程,是因为在 Rakudo Raku 中没有对共享变量的隐式锁定。例如:
my int $a;
await (^5).map: {
start { ++$a for ^100000 }
}
say $a; # something like 419318
那么为什么没有显示 500000?原因是我们有 5 个线程同时递增相同的变量。由于增量由读步骤,增量步骤和写步骤组成,因此一个线程与另一个线程同时执行读取步骤变得非常容易。因此失去了一个增量。在我们有原子操作符之前,做上述代码的正确方法是:
my int $a;
my $l = Lock.new;
await (^5).map: {
start {
for ^100000 {
$l.protect( { ++$a } )
}
}
}
say $a; # 500000
这会给你正确的答案,但速度至少要慢 20 倍。
现在我们有了原子变量,上面的代码就变成了:
my atomicint $a;
await (^5).map: {
start { ++⚛$a for ^100000 }
}
say $a; # 500000
这非常类似于原始(不正确)的代码。这至少是使用 Lock.protect 的正确代码的 6 倍。
22.3. Unicode goodies
太多了,太多了。例如,现在可以使用≤,≥,≠作为 Unicode 版本的⇐,> =和!=(完整列表)。
您现在还可以通过指定字形的 Unicode 名称来创建字形,例如:
say "BUTTERFLY".parse-names; # 🦋
或者在运行时创建 Unicode 名称字符串:
my $t = "THUMBS UP SIGN, EMOJI MODIFIER FITZPATRICK TYPE";
print "$t-$_".parse-names for 3..6; # 👍🏼👍🏽👍🏾👍🏿
或者整理而不是仅仅排序:
# sort by codepoint value
say <ä a o ö>.sort; # (a o ä ö)
# sort using Unicode Collation Algorithm
say <ä a o ö>.collate; # (a ä o ö)
或者使用 unicmp 而不是 cmp:
say "a" cmp "Z"; # More
say "a" unicmp "Z"; # Less
或者您现在可以使用任何 Unicode 数字匹配变量($ 1 为$ 1),负数(-1 为-1)和基数基数(:3(“22”)为 3(“22”))。
圣诞老人认为 Rakudo Raku 拥有世界上任何编程语言的最佳 Unicode 支持!
22.4. 跳过值
您现在可以在 Seq 和 Supply 上调用.skip 跳过正在生成的多个值。与.head 和.tail 一起,这给了你 Iterables 和 Supplies 充足的操作性。
顺便说一下,.head 现在也会带一个 WhateverCode,所以你可以指明除了最后 N 以外的所有值(例如.head(* - 3)会给你除了最后三个以外的所有值)。 .tail 也是如此(例如.tail(* - 3)会为您提供除前三个之外的所有值)。
对迭代器角色的一些补充使得迭代器可以更好地支持.skip 功能。如果一个迭代器可以更有效地跳过一个值而不是实际产生它,它应该实现 skip-one 方法。派生于此的是可以由迭代器提供的跳过至少和跳过至少拉一个方法。
使用.skip 查找第 1000 个素数的示例:
say (^Inf).grep(*.is-prime)[999]; # 7919
与
say (^Inf).grep(*.is-prime).skip(999).head; # 7919
后者的 CPU 效率略高一些,但更重要的是内存效率更高,因为它不需要保留内存中的前 999 个素数。
22.5. Of Bufs and Blobs
Buf 变得更像一个 Array,因为它现在支持.push,.append,.pop,.unshift,.prepend,.shift 和.splice。它也变得更像 Str,增加了一个 subbuf-rw(类似于.substr-rw),例如:
my $b = Buf.new(100..105);
$b.subbuf-rw(2,3) = Blob.new(^5);
say $b.perl; # Buf.new(100,101,0,1,2,3,4,105)
您现在也可以使用给定数量的元素和模式来分配 Buf 或 Blob。或者用.reallocate 改变 Buf 的大小:
my $b = Buf.allocate(10,(1,2,3));
say $b.perl; # Buf.new(1,2,3,1,2,3,1,2,3,1)
$b.reallocate(5);
say $b.perl; # Buf.new(1,2,3,1,2)
22.6. 测试,测试,测试!
Test.pm 的计划子例程现在还采用可选的:skip-all 参数来指示文件中的所有测试都应该跳过。或者您可以拨打救助中止测试运行,将其标记为失败。或者将 PERL6_TEST_DIE_ON_FAIL 环境变量设置为真值,以指示您希望测试一旦第一次测试失败就立即结束。
22.7. 这是怎么回事
您现在可以通过调用 Kernel.cpu-cores 来反思计算机中 CPU 内核的数量。程序启动后使用的 CPU 数量在 Kernel.cpu-usage 中可用,但您可以使用 VM.osname 轻松检查操作系统的名称。
就好像这还不够,还有一个新的遥测模块,您需要在需要时加载,就像测试模块一样。遥测模块提供了许多可直接使用的基元,例如:
use Telemetry;
say T<wallclock cpu max-rss>; # (138771 280670 82360)
它显示自程序启动以来的微秒数,所用 CPU 的微秒数以及调用时正在使用的内存数量。
如果你想得到你的程序中发生的事情的报告,你可以使用管理单元,并在程序完成时显示报告。例如:
use Telemetry;
snap;
Nil for ^10000000; # something that takes a bit of time
结果将显示在 STDERR 上:
Telemetry Report of Process #60076
Number of Snapshots: 2
Initial/Final Size: 82596 / 83832 Kbytes
Total Time: 0.55 seconds
Total CPU Usage: 0.56 seconds
No supervisor thread has been running
wallclock util% max-rss
549639 12.72 1236
--------- ------ --------
549639 12.72 1236
Legend:
wallclock Number of microseconds elapsed
util% Percentage of CPU utilization (0..100%)
max-rss Maximum resident set size (in Kbytes)
如果你想要每秒 1 次的程序状态,你可以使用 snapper:
use Telemetry;
snapper;
Nil for ^10000000; # something that takes a bit of time
结果:
Telemetry Report of Process #60722
Number of Snapshots: 7
Initial/Final Size: 87324 / 87484 Kbytes
Total Time: 0.56 seconds
Total CPU Usage: 0.57 seconds
No supervisor thread has been running
wallclock util% max-rss
103969 13.21 152
101175 12.48
101155 12.48
104097 12.51
105242 12.51
44225 12.51 8
--------- ------ --------
559863 12.63 160
Legend:
wallclock Number of microseconds elapsed
util% Percentage of CPU utilization (0..100%)
max-rss Maximum resident set size (in Kbytes)
还有更多选项可用,例如以.csv 格式获取输出。
22.8. MAIN 函数
您现在可以通过设置%* SUB-MAIN-OPTS 中的选项来修改 MAIN 参数的处理方式。默认的 USAGE 消息现在可以在 MAIN 中作为$ * USAGE 动态变量使用,所以如果你愿意,你可以改变它。
22.9. 嵌入 Raku
两个新功能使嵌入 Rakudo Raku 更易于处理: 现在可以设置&* EXIT 动态变量来指定调用 exit()时要执行的操作。
将环境变量 RAKUDO_EXCEPTIONS_HANDLER 设置为“JSON”将引发 JSON 中的异常,而不是文本,例如:
$ RAKUDO_EXCEPTIONS_HANDLER=JSON raku -e '42 = 666'
{
"X::Assignment::RO" : {
"value" : 42,
"message" : "Cannot modify an immutable Int (42)"
}
}
22.10. 礼品袋的底部
在翻看仍然相当完整的礼品袋的同时,圣诞老人发现了以下较小的惊悚片:
本地字符串数组现在实现(我的 str @a) IO::CatHandle 允许您将多个数据源抽象为单个虚拟 IO::Handle parse-base()执行 base()的相反操作
22.11. 赶上雪橇的时间
圣诞老人想留下来告诉你更多有关已添加的内容,但是没有足够的时间来做到这一点。如果您真的想了解新功能的最新情况,您应该查看 Changelog 中的 Additions 部分,这些部分随每个 Rakudo 编译器版本一起更新。
所以,明年再来抓你!
来自美好的祝福
23. 第二十三天 - Raku 高尔夫
啊,圣诞节!还有什么比和你的朋友与家人一起坐在桌子旁边玩高尔夫球代码还好呢! …等等,什么?
哦,对,这还不是圣诞节。但是你可能想要为它做好准备!
如果你还没有注意到,有一个不错的网站可以玩高尔夫球代码:https://code-golf.io/。这个网站很酷的地方是,它不仅仅只支持 perl 6!在撰写本文时,它还支持其他 6 种语言。嗯…
无论如何,因为我在那个网站的成绩还不错,我会分享一些我觉得最好的解决方案。所有的 trickety-hackety,unicode-cheatery 和 mind-blowety。在我们看来,也许我们甚至会看到即使在代码高尔夫中,perl 6 也非常简洁易读。也就是说,如果你很难将你的圣诞愿望放在一张卡片上,那么可能会放得下一行 perl 6 代码。
我不会提供完整的解决方案,不会破坏你的圣诞乐趣,但我会给你足够的提示,以提出有竞争力的解决方案。
这个圣诞节我就是想让你得到一些乐趣。所以你先下载一份 rakudo,以确保你可以跟随。稍后我们会有一些南瓜派,我们会做一些颂歌。如果您在运行 perl 6 时遇到任何问题,可以在 freenode 上加入 #raku 频道以获得一些帮助。这就是说,https://code-golf.io/ 本身为你提供了一个很好的编辑器来编写和评估你的代码,所以应该没有问题。
23.1. 一些基本的例子
让我们以帕斯卡三角形任务为例。我听到了,我听到了!圣诞节前的数学,这太残酷了。残忍,但很有必要。
只有一个你必须知道的基本技巧。如果从 Pascal 三角形中取出任何一行,将它移动一个元素,然后用原始行对结果进行 zip-sum,就会得到下一行!
所以如果你有一行数字:
1 3 3 1
你所做的只是把它移到右边:
0 1 3 3 1
并将其与原始行相加:
1 3 3 1
+ + + +
0 1 3 3 1
=
1 4 6 4 1
就是如此容易!所以我们还是写代码吧:
for ^16 { put (+combinations($^row,$_) for 0..$row) }
你看!简单的很!
哦……等等,这有一个完全不同的解决方案。好吧,让我们来看看:
.put for 1, { |$_,0 Z+ 0,|$_ } ... 16
输出:
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1
1 6 15 20 15 6 1
1 7 21 35 35 21 7 1
1 8 28 56 70 56 28 8 1
1 9 36 84 126 126 84 36 9 1
1 10 45 120 210 252 210 120 45 10 1
1 11 55 165 330 462 462 330 165 55 11 1
1 12 66 220 495 792 924 792 495 220 66 12 1
1 13 78 286 715 1287 1716 1716 1287 715 286 78 13 1
1 14 91 364 1001 2002 3003 3432 3003 2002 1001 364 91 14 1
1 15 105 455 1365 3003 5005 6435 6435 5005 3003 1365 455 105 15 1
啊哈! 我们做到了。 所以究竟是怎么回事儿? 那么,在 perl 6 中,你可以用一个非常简单的语法创建 2,4,8 … ∞
这样的序列。 通常你会让它自己计算序列,但你也可以提供一个代码块来求值。 这太棒了! 在其他语言中,你经常需要一个带有状态变量的循环,而序列操作符为你做了所有的事情! 这个功能可能单独需要一篇文章或𝍪。
其余的只是一个 for 循环和 put 调用。 这里唯一的技巧就是理解它使用的是列表,所以当你指定序列的端点时,它实际上是检查元素的数量。 另外,您需要用 |
来展平列表。
如果删除空格并应用本文中提到的所有技巧,这应该会让您的字符数为 26。 这相当有竞争力。
同样,其他任务通常有相当直接的解决方案。 例如,对于 Evil NUmbers,你可以写这样的东西:
.base(2).comb(~1) %% 2 && .say for ^50
删除一些空格,应用一些技巧,你几乎就达到要求了。
我们再举一个例子:Pangram Grep。在这里我们可以使用 set操作符:
‘a’..‘z’ ⊆ .lc.comb && .say for @*ARGS
基本上,几乎所有的 perl 6 解决方案看起来都是真正的代码。这是额外的-1角色,需要额外的眼睛疼痛,但你没有来这里听简洁,对吧?是时候变脏了。
23.2. Numbers
让我们来谈谈数字吧! 1 ² ③ ٤ ⅴ ߆… 咳嗽。 你看,在 perl 6 中,任何数字字符(具有相应的数值属性)都可以在源代码中使用。 该功能的目的是让我们得到一些好处,如 ½ 和其他整洁的东西,但这意味着,而不是写 50 你可以写 ㊿。 有些高尔夫平台会以 UTF-8 编码来计算字节数,所以看起来你没有赢得任何东西。 但是 1000000000000
和 ` 𖭡` 呢? 在任何情况下,code-golf.io 都可以识别 unicode,所以这些字符的长度都是 1。
所以你可能会想,你能用这种方式写出哪些数字? 你试试看:
-0.5 0.00625 0.025 0.0375 0.05 0.0625 0.083333 0.1
0.111111 0.125 0.142857 0.15 0.166667 0.1875 0.2
0.25 0.333333 0.375 0.4 0.416667 0.5 0.583333 0.6
0.625 0.666667 0.75 0.8 0.833333 0.875 0.916667 1
1.5 2 2.5 3 3.5 4 4.5 5 5.5 6 6.5 7 7.5 8 8.5 9 10
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
45 46 47 48 49 50 60 70 80 90 100 200 300 400 500
600 700 800 900 1000 2000 3000 4000 5000 6000 7000
8000 9000 10000 20000 30000 40000 50000 60000 70000
80000 90000 100000 200000 216000 300000 400000
432000 500000 600000 700000 800000 900000 1000000
100000000 10000000000 1000000000000
这意味着,例如,在某些情况下,如果需要否定结果,则可以节省 1 个字符。有很多方法可以使用,我只会提到一个特定的情况。其余的你自己去找,以及如何找到可用于任何特定值的实际字符(提示:循环所有 0x10FFFF 字符并检查它们的 `.unival`s)。
例如,当打高尔夫时,你想略去不必要的空白,所以也许你会想写一些这样的东西:
say 5max3 # ERROR
这当然不起作用,我们也不能责怪编译器没有解决这个混乱问题。但是,试试这个:
say ⑤max③ # OUTPUT: «5»
哇噢!这在许多其他情况下会起作用。
23.3. 条件
如果有很好的高尔夫语言,但不是 Raku. 我的意思是,看看这个:
puts 10<30?1:2 # ruby
say 10 <30??1!!2 # perl 6
Raku 的三元操作符不仅多需要俩个字符,而且 <
运算符周围还要有一些必要的的空白字符!他们有什么问题,对吧?他们怎么敢设计一种无代码高尔夫语言?!
那么,我们可以通过某些方法解决它。其中之一是链式操作符。例如:
say 5>3>say(42)
如果 5≤3,则不需要进行其他比较,因此它不会运行它。这样我们可以节省至少一个字符。在略有相关的说明中,请记住,junctions 也可能派上用场:
say ‘yes!’ if 5==3|5
当然,不要忘记 unicode 操作符:≥
,≤
,≠
。
23.4. 打字很难,让我们使用一些预定义的字符串!
你不会相信这有时是多么的有用。想要打印所有棋子的名字?好:
say (‘♔’…‘♙’)».uniname».words»[2]
# KING QUEEN ROOK BISHOP KNIGHT PAWN
这仅仅节省了几个字符,但有时可以将解决方案的大小减半。但是,不要停在那里,想想错误消息,方法名称等。你还能挽回什么?
23.5. Base 16? Base 36? Nah, Base 0x10FFFF!
其中一项任务告诉我们打印 φ 的前1000个小数位。那么,这很容易!
say ‘1.6180339887498948482045868343656381177203091798057628621354486227052604628189024497072072041893911374847540880753868917521266338622235369317931800607667263544333890865959395829056383226613199282902678806752087668925017116962070322210432162695486262963136144381497587012203408058879544547492461856953648644492410443207713449470495658467885098743394422125448770664780915884607499887124007652170575179788341662562494075890697040002812104276217711177780531531714101170466659914669798731761356006708748071013179523689427521948435305678300228785699782977834784587822891109762500302696156170025046433824377648610283831268330372429267526311653392473167111211588186385133162038400522216579128667529465490681131715993432359734949850904094762132229810172610705961164562990981629055520852479035240602017279974717534277759277862561943208275051312181562855122248093947123414517022373580577278616008688382952304592647878017889921990270776903895321968198615143780314997411069260886742962267575605231727775203536139362’
是的!!!
好吧,这需要 1000 多个字符……当然,我们可以尝试计算它,但这不完全符合圣诞节的精神。我们想作弊。
如果我们看一下 polymod 的文档,有一点提示:
my @digits-in-base37 = 9123607.polymod(37 xx *); # Base conversion
嗯……这样就给了我们任意数量的数字。我们可以走多高?那么,这取决于我们想要存储数字的形式。鉴于 code-golf.io 计算码位,我们可以使用base 0x10FFFF(即使用所有可用的码位)。或者,在这种情况下,我们将使用base 0x10FFFE,因为:
☠☠☠⚠⚠⚠ WARNING! WARNING! WARNING! ⚠⚠⚠☠☠☠
THIS WILL MAKE YOUR COMPUTER IMPLODE!
UNICODE STRINGS ARE SUBJECT TO NORMALIZATION SO YOUR
DATA WILL NOT BE PRESERVED. HIDE YOUR KIDS, HIDE YOUR
WIFE. HIDE YOUR KIDS, HIDE YOUR WIFE. HIDE YOUR KIDS,
HIDE YOUR WIFE. AND HIDE YOUR HUSBAND.
☠☠☠⚠⚠⚠ WARNING! WARNING! WARNING! ⚠⚠⚠☠☠☠
当应用于我们的常数时,它应该给出这样的东西:
𢍟𪱷𢕍𭙪𝧒𘝞뎛磪끝寡𥵑𱫞𛰑𗋨䨀𰌡𧮁𦅂嗟𧽘𱀉𫍡𪏸𐂇ந𰑕𨬎𦔏o
我们如何翻转操作?在其中一次 squashathons 我找到一张关于我以前不知道的功能的票。基本上,票据说 Rakudo 正在做它不应该做的事情,这当然是我们下一次会被滥用的事情。但现在我们处于相对理智的范围之内:
say ‘1.’,:1114110[‘o𦔏𨬎𰑕ந𐂇𪏸𫍡𱀉𧽘嗟𦅂𧮁𰌡䨀𗋨𛰑𱫞𥵑寡끝磪뎛𘝞𝧒𭙪𢕍𪱷𢍟’.ords]
请注意,字符串必须相反。除此之外,它看起来非常好。 192 个字符包括解码器。
这对于打印另外可计算的常量来说并不是一个好主意,但考虑到解码器的长度和数据的相对密集的打包率,这在其他任务中很方便。
23.6. 所有的好事都必须结束;可怕的事情 - 更是如此
这是关于这篇文章的。更多代码高尔夫技巧我已经启动了这个存储库:https://github.com/AlexDaniel/6lang-golf-cheatsheet
希望能在 https://code-golf.io/ 上看到你!无论是否使用 perl 6,我都希望看到我提交的所有内容都被打败了。
24. 第二十四天 - 解魔方
24.1. 介绍
我在圣诞节的愿望清单上有一个速度魔方,我真的很兴奋。 :)我想分享一些 Raku 代码的热情。
我在 89 年从高中毕业,所以我恰好是在青少年时期拥有魔方的合适年龄。我记得试图在巴士上炫耀,让我的时间缩短到不到一分钟。我在 80 年代从当地的一家玩具店拿到了一本小册子,其中展示了一个关于如何解决我记忆的立方体的算法。我再也没有这本小册子了。多年来我一直坚持,但从来没有达到竞争水平。
在过去的几个月里,YouTube 根据我对立体声频道的兴趣,向我推荐了一些立方体视频;看到世界纪录在 5 秒以内,使我一分钟的旧时间看起来很慢。
我所讲过的每个人都可以解决魔方问题,他们使用的算法与我所学的算法不同,而在立体魔法中讨论的方法却与众不同。不过,这种先进的版本似乎被定期制定世界记录的人们普遍使用。
拾取这个算法并不难,我找到了几个视频,尤其是描述如何解决最后一层的视频。这样做了几天之后,我将步骤转录为几个笔记,其中列出了步骤列表,以及每个步骤的关键部分:所需的方向,然后是该步骤的各个转弯。然后,我可以参考我的笔记本的一个页面,而不是一个 30 分钟的视频,并且在几天后,记住了以下步骤:能够从记谱法移动到仅仅做这些移动是一个很大的加速。
一周后,我能够在两分钟内使用新方法可靠地解决问题;退后一步,但在休息时间里一周的努力并不坏。从那以后(几个星期后),我一直下到 1:20 以下。再次,这是初学者的方法,没有任何先进的技术,而且我可以在不查看立方体的情况下完成各个算法步骤。 (尽管如此,我仍然有很长的路要走。)
24.2. 符号
关于移动符号的快速注释 - 考虑到您将立方体的一边保持在顶部,一边朝向您,相对边是:
L (Left) R (Right) U (Up) D (Down) F (Front) B (Back)
如果在步骤中看到一个单独的字母,如 B,则表示顺时针转动该面(相对于立方体的中心,而不是您)。如果你在信里加了一个'',那就意味着逆时针方向,所以 R’会让最上面的部分下来,而 R 会让底部的部分出现。
此外,您可能必须翻转两次,写成 U2; (顺时针或逆时针无关紧要,因为它从起点开始为 180º。)
24.3. 算法
我正在使用的初学者算法有以下基本步骤:
1.白色十字架 2.白色拐角 3.第二层 4.黄色十字架 5.黄色边缘 6.黄色拐角 7.定位黄色拐角
如果您对每个步骤的具体内容感到好奇,您可以浏览 Rubik 的 wiki 或上面链接的 YouTube 视频。该算法的更高级版本(由 Jessica Fridrich 提供的 CFOP)允许您合并步骤,具有处理特定立方体状态的特定“快捷方式”,或者解决任何颜色作为第一面,而不仅仅是白色。
24.4. 设计一个模块
当我开始研究这个模块时,我知道我希望能够以某种熟悉算法的人熟悉的方式展示每一步所需的位置,并且让各个步骤也是自然的,就像是:
F.R.U.Rʼ.Uʼ.Fʼ
我也希望能够转储立方体的现有状态;现在作为文本,但最终也能够将其与视觉表示相结合,
我们需要能够判断立方体是否已解决;我们需要能够检查相对于当前方向的棋子,并且能够改变我们的方向。
由于我要开始渲染立方体状态的能力,然后快速添加转向两侧的能力,我选择了一个内部结构,使其变得相当容易。
24.5. 代码
github 上提供了该模块的最新版本。这里介绍的代码来自最初的版本。
Raku 允许您创建 Enumerations,因此您可以在代码中使用实际的单词而不是查找值,所以让我们从一些我们需要的内容开始:
enum Side «:Up('U') :Down('D') :Front('F') :Back('B') :Left('L') :Right('R')»;
enum Colors «:Red('R') :Green('G') :Blue('B') :Yellow('Y') :White('W') :Orange('O')»;
有了这个语法,我们可以直接在我们的代码中使用 Up,并且它的关联值是 U.
我们需要一个类,以便我们可以存储属性并拥有方法,所以我们的类定义具有:
class Cube::Three {
has %!Sides;
...
submethod BUILD() {
%!Sides{Up} = [White xx 9];
%!Sides{Front} = [Red xx 9];
...
}
}
我们有一个属性,一个叫做%.Sides 的 Hash;每个键对应于其中一个 Enum 边。该值是 Colors 的 9 元素数组。数组上的每个元素对应于立方体上的一个位置。默认情况下,顶部的白色和正面的红色将在此处显示颜色和单元格位置,并带有数字和颜色。 (白色,红色是前面)
W0 W1 W2
W3 W4 W5
W6 W7 W8
G2 G5 G8 R2 R5 R8 B2 B5 B8 O2 O5 O8
G1 G4 G7 R1 R4 R7 B1 B4 B7 O1 O4 O7
G0 G3 G6 R0 R3 R6 B0 B3 B6 B0 B3 B6
Y0 Y1 Y2
Y3 Y4 Y5
Y6 Y7 Y8
我添加的第一种方法是做每个脸部顺时针转动。
method F {
self!rotate-clockwise(Front);
self!fixup-sides([
Pair.new(Up, [6,7,8]),
Pair.new(Right, [2,1,0]),
Pair.new(Down, [2,1,0]),
Pair.new(Left, [6,7,8]),
]);
self;
}
这个公共方法调用两个私有方法(用!表示);一个顺时针方向旋转一个侧面,第二个取对的列表,其中键是一个侧面,并且该值是一个位置列表。如果您想象顺时针旋转立方体的顶部,您可以看到位置正在从一个换成另一个。
请注意,我们从方法中返回自己;这允许我们按照原始设计中的方式调用方法调用。
单面的顺时针旋转显示正在传递的原始面,并使用阵列切片来改变原始面的顺序。
# 0 1 2 6 3 0
# 3 4 5 -> 7 4 1
# 6 7 8 8 5 2
method !rotate-clockwise(Side \side) {
%!Sides{side}[0,1,2,3,5,6,7,8] = %!Sides{side}[6,3,0,7,1,8,5,2];
}
要为移动添加其余的符号,我们添加一些简单的包装方法:
method F2 { self.F.F; }
method Fʼ { self.F.F.F; }
F2 只需要移动两次; F’作弊:3 个权利左转。
在这一点上,我必须确保我的回合正在做他们应该做的事情,所以我添加了一个 gist 方法(当一个对象用 say 输出时被调用)。
say Cube::Three.new.U2.D2.F2.B2.R2.L2;
W Y W
Y W Y
W Y W
G B G R O R B G B O R O
B G B O R O G B G R O R
G B G R O R B G B O R O
Y W Y
W Y W
Y W Y
gist 的源代码是:
method gist {
my $result;
$result = %!Sides{Up}.rotor(3).join("\n").indent(6);
$result ~= "\n";
for 2,1,0 -> $row {
for (Left, Front, Right, Back) -> $side {
my @slice = (0,3,6) >>+>> $row;
$result ~= ~%!Sides{$side}[@slice].join(' ') ~ ' ';
}
$result ~= "\n";
}
$result ~= %!Sides{Down}.rotor(3).join("\n").indent(6);
$result;
}
有几件事要注意:
-
使用.rotor(3)将 9 单元阵列分解为 3 个 3 单元列表。
-
.indent(6)预先在 Up 和 Down 两边加上空格。
-
(0,3,6)>> + >> $ row,这会增加列表中的每个值
这个要点非常适合逐步检查,但为了调试,我们需要一些更紧凑的东西:
method dump {
gather for (Up, Front, Right, Back, Left, Down) -> $side {
take %!Sides{$side}.join('');
}.join('|');
}
这将按照特定的顺序在边上迭代,然后使用 gather take 语法收集每一边的字符串表示形式,然后使用|将它们连接在一起。现在我们可以编写像:
use Test; use Cube::Three;
my $a = Cube::Three.new();
is $a.R.U2.Rʼ.Uʼ.R.Uʼ.Rʼ.Lʼ.U2.L.U.Lʼ.U.L.dump,
'WWBWWWWWB|RRRRRRRRW|BBRBBBBBO|OOWOOOOOO|GGGGGGGGG|YYYYYYYYY',
'corners rotation';
这实际上是算法最后一步中使用的方法。通过这个调试输出,我可以拍摄一个原始的立方体,自己动手,然后将生成的立方体状态快速转录成字符串进行测试。
虽然计算机不一定需要旋转立方体,但如果我们可以旋转立方体,则会更容易遵循该算法,因此我们为六个可能的旋转中的每一个添加一个,例如:
method rotate-F-U {
self!rotate-clockwise(Right);
self!rotate-counter-clockwise(Left);
# In addition to moving the side data, have to
# re-orient the indices to match the new side.
my $temp = %!Sides{Up};
%!Sides{Up} = %!Sides{Front};
self!rotate-counter-clockwise(Up);
%!Sides{Front} = %!Sides{Down};
self!rotate-clockwise(Front);
%!Sides{Down} = %!Sides{Back};
self!rotate-clockwise(Down);
%!Sides{Back} = $temp;
self!rotate-counter-clockwise(Back);
self;
}
当我们将立方体从正面转到向上时,我们将左侧和右侧旋转到位。由于细胞的方向随着我们改变面部而改变,因为我们从面部到面部复制细胞,我们也可能需要旋转它们以确保它们最终以正确的方向朝向。和以前一样,我们返回自我以允许方法链接。
在我们开始测试时,我们需要确保我们可以知道何时立方体已解决;我们不关心立方体的方向,所以我们验证中心颜色与脸上的所有其他颜色相匹配:
method solved {
for (Up, Down, Left, Right, Back, Front) -> $side {
return False unless
%!Sides{$side}.all eq %!Sides{$side}[4];
}
return True;
}
对于每一侧,我们使用一侧的所有颜色的交界处与中心细胞进行比较(总是位置 4)。我们很早就失败了,只有通过所有方面才能成功。
接下来,我添加了一种方法来搅乱魔方,所以我们可以考虑实施一种解决方法。
method scramble {
my @random = <U D F R B L>.roll(100).squish[^10];
for @random -> $method {
my $actual = $method ~ ("", "2", "ʼ").pick(1);
self."$actual"();
}
}
这需要六个基本方法名称,挑选一堆随机值,然后挤压它们(确保连续没有模糊),然后选取前 10 个值。然后,我们可能会添加一个 2 或'。最后,我们使用间接方法语法按名称调用各个方法。
最后,我准备好开始解决问题了!这就是事情变得复杂的地方。初学者方法的第一步经常被描述为直观的。这意味着它很容易解释……但不容易编码。所以,扰流警报,截至本文发布时,解决的第一步就完成了。对于第一步的完整算法,请查看链接的 github 网站。
method solve {
self.solve-top-cross;
}
method solve-top-cross {
sub completed {
%!Sides{Up}[1,3,5,7].all eq 'W' &&
%!Sides{Front}[5] eq 'R' &&
%!Sides{Right}[5] eq 'B' &&
%!Sides{Back}[5] eq 'O' &&
%!Sides{Left}[5] eq 'G';
}
...
MAIN:
while !completed() {
# Move white-edged pieces in second row up to top
# Move incorrectly placed pieces in the top row to the middle
# Move pieces from the bottom to the top
}
}
请注意非常具体的检查,看看我们是否完成;我们使用一个词汇子来弥补复杂性 - 虽然我们在这里有一个相当内部的检查,但是我们可以看到,我们可能想要将这个抽象描述为可以说“这个边缘部分是正确的”。首先,我们将坚持单个单元格。
目前解决十字架的内容长达 100 多行,所以我不会经历所有的步骤。这是“简单”部分
my @middle-edges =
[Front, Right],
[Right, Back],
[Back, Left],
[Left, Front],
;
for @middle-edges -> $edge {
my $side7 = $edge[0];
my $side1 = $edge[1];
my $color7 = %!Sides{$side7}[7];
my $color1 = %!Sides{$side1}[1];
if $color7 eq 'W' {
# find number of times we need to rotate the top:
my $turns = (
@ordered-sides.first($side1, :k) -
@ordered-sides.first(%expected-sides{~$color1}, :k)
) % 4;
self.U for 1..$turns;
self."$side1"();
self.Uʼ for 1..$turns;
next MAIN;
} elsif $color1 eq 'W' {
my $turns = (
@ordered-sides.first($side7, :k) -
@ordered-sides.first(%expected-sides{~$color7}, :k)
) % 4;
self.Uʼ for 1..$turns;
self."$side1"();
self.U for 1..$turns;
next MAIN;
}
}
在真正的立方体上进行这一部分时,您可以旋转立方体而不考虑侧面部件,然后将十字架放在适当位置。为了让算法更“友好”一点,我们让这些中心保持在这个位置;我们将上侧旋转到位,然后将单个侧面旋转到顶部位置,然后将上侧旋转回原始位置。
这里有一些有趣的代码是.first(…,:k)语法,它说找到匹配的第一个元素,然后返回匹配的位置。然后,我们可以在有序列表中查找事物,以便计算双方的相对位置。
请注意,解决方法只调用公共方法来转动立方体;虽然我们使用原始自省来获取立方体状态,但我们只使用“合法”的方式来解决问题。
有了这个方法的完整版本,我们现在用这个程序来解决白十字:
use Cube::Three;
my $cube = Cube::Three.new();
$cube.scramble;
say $cube;
say '';
$cube.solve;
say $cube;
它在给定这组移动的情况下产生这个输出(F’B2B2LR’U’RF’D2B2)。首先是争夺战,然后是解决白十字的版本。
W G G
Y W W
Y Y Y
O O B R R R G B O Y Y B
R G O B R R G B G W O B
Y B B R O W G G G W W O
W W O
Y Y O
B R R
Y W W
W W W
G W R
O G W O R Y B B G R O G
Y G G R R B R B Y R O G
O O R Y O W O O R W Y B
G G B
B Y Y
Y B B
这个例子打印出用来进行争夺的动作,显示乱数立方体,“解决”这个难题(在撰写本文时,它只是白色的十字),然后打印出立方体的新状态。
请注意,随着我们的进一步发展,这些步骤变得不那么“直观”,并且根据我的估计,编码更容易。例如,最后一步需要检查四个部分的方向,必要时旋转立方体,然后执行 14 步移动。 (在上面的测试中显示)。
希望我对 Cubing 和 Raku 的喜爱让你期待你的下一个项目!
对于未来的读者,我将在模块解决完成后的评论中注明。
25. 第二十五天 - 圣诞奖金 - 并发 HTTP 服务器实施和 scripter 的方法
首先,我想强调 Jonathan Worthington 在 Rakudo Raku 和 IO::Socket::Async 中的工作。谢谢 Jon!
我喜欢制作脚本;编写组织良好的动作序列,获得结果并对它们进行处理。
当我从 Raku 开始时,我发现了一个壮观的生态系统,我可以按照自己喜欢的方式实践我的想法:脚本方式。其中一个想法是实现一个小型的 HTTP 服务器来玩玩。查看与 Raku,HTTP 和套接字相关的其他项目和模块,我发现背后的作者是具有面向对象编程经验的程序员。
25.1. Raku 范式
Raku 支持三种最流行的编程范式:
-
面向对象
-
函数式
-
过程式
我认为,当你设计一个将会增长的应用程序或服务时,面向对象的范式是很好的,它会做许多不同的事情并且会有很多变化。但我不喜欢那些变化太大,会有很多变化的东西;这就是为什么我喜欢使用原生过程式方法的脚本,因为它能够快速提升简单性和有效性。我喜欢小(一步一步)但能快速完成伟大东西的事物。
函数式范式在我看来非常棒;你可以使用一个函数,并像 var 一样使用它,以及其他令人惊讶的事情。
25.2. Raku Supplies 就像一个 V12 引擎
在我开始将 rakuintro.com 翻译成 西班牙语后不久,我开始使用 Raku。看看 Raku 的文档,我发现了 Raku 巨大的并发潜力。 Raku 在 并发方面比我想象的更加强大。
我使用 Raku 的 HTTP 服务器的思想始于 Raku Supplies(具有多个订阅者的异步数据流),具体来说就是 IO::Socket::Async类。所有的套接字管理,数据传输和并发性实际上都是自动且易于理解的。制作并玩一玩小并发但强大的服务是极好的。
基于 IO::Socket::Async 文档的示例,我开始在 mini-http-cgi-server 项目中实现一个支持 pseudoCGI 的小型 HTTP 服务器,并且按照我的预期工作。当我得到我想要的东西时,我很满意,我离开了这个项目一段时间。我不喜欢事情发展太多。
但之后,为马德里 Perl Workshop 2017 做了一次演讲(感谢 马德里 Perl Mongers 和 巴塞罗那 Perl Mongers 团队为这次活动提供的支持),我有足够的动力去做更实际的事情,让网络前端编码人员可以完成他们的工作并且与 Raku 正在等待的后端进行交流。一方面是典型的公共 html 静态结构,另一方面是一个包含多个 web 服务的 Raku 模块,用于等待来自前端人员的 web 请求。
然后 Wap6 诞生了(Web App Raku)。
25.3. Wap6 的结构
我喜欢 Wap6 实现的 Web 应用程序的结构:
-
public
-
webservices
公共文件夹包含友好的前端东西,比如静态 html,javascript,css 等,也就是前端开发者空间。 webservices 文件夹包含后端的东西:一个 Raku 模块,包括每个 webservice 的一个函数。
相同的文件夹级别包含解决方案入口点,一个 Raku 脚本,其中包括初始化服务器参数,其中包含路由和 webservices 之间的映射:
my %webservices =
'/ws1' => ( &ws1, 'html' ),
'/ws2' => ( &ws2, 'json' )
;
正如你所看到的,不仅路由被映射到相应的 webservices,而且还指定 webservice 的返回内容类型(content-type )(如 HMTL 或 JSON)。也就是说,在 Web 浏览器中键入 http://domain/ws1,ws1 函数会返回具有相应内容类型的响应数据,我们将在稍后看到。
所有到 webservices 的路由都在 %webservices 散列中,并通过其他有用的命名参数传递给主函数 wap:
wap(:$server-ip, :$server-port, :$default-html, :%webservices);
25.4. Wap6 的核心
wap 函数位于 Wap6 使用的核心 lib 模块的外面,并包含并发和优雅的 V12 引擎:
react {
whenever IO::Socket::Async.listen($server-ip,$server-port) -> $conn {
whenever $conn.Supply(:bin) -> $buf {
my $response = response(:$buf, :$current-dir, :$default-html, :%webservices);
$conn.write: $response.encode('UTF-8');
$conn.close;
}
}
}
这是一个三分(react – whenever – IO::Socket::Async)响应式,并发和异步的上下文。当传输从 Web 客户端($conn)到达时,它将被放置在 bin 类型的新 Supply $buf ($conn.Suply(:bin))中,$buf 和 %webservices 哈希等其他内容被发送到运行 HTTP 逻辑的响应函数。最后,响应函数的返回被写回到 Web 客户端。
响应函数(也位于核心库 lib 中)包含 HTTP 解析器的东西:它将传入数据(HTTP 实体)分割为头和主体,它执行验证,它需要基本的 HTTP 头信息,如方法(GET 或 POST)和 URI(统一资源标识符),它确定所请求的资源是 webservice(来自 webservices 文件夹)还是静态文件(来自公共文件夹),从资源中获取数据(来自静态文件或 webservice)并返回到 wap 函数以将响应写入 Web 客户端,如我们以前所见。
25.5. Webservices
响应函数验证 $buf 并从请求头中提取 HTTP 方法,可以是 GET 或 POST(我认为将来它不会支持更多的 HTTP 方法)。使用 GET 方法时,它将 URL 参数(如果有的话)放入 $get-params。 POST 方法的情况下,它将主体请求放入 $body。
然后是时候检查 Web 客户端是否请求了 webservice。 $get-params 包含了 URI 并用 URI 模块提取,最终结果放在 $path:
given $path {
when %webservices{"$_"}:exists {
my ( &ws, $direct-type ) = %webservices{"$_"};
my $type = content-type(:$direct-type);
return response-headers(200, $type) ~ &ws(:$get-params, :$body);
}
..
}
如果 %webservices 哈希中存在 $path,则客户端需要一个 webservice。然后它从 %webservices 散列(是的,我也喜欢函数式范式:-))和对应的内容类型中提取相应的 webservice 可调用函数 &ws。然后它使用 $get-params 和请求 $body 参数调用 webservice 函数 &ws。最后它返回连接的 HTTP 响应实体:
-
具有状态 HTTP 200 OK 和给定内容类型(来自内容类型函数)的响应头。
-
webservice 输出。
可调用 webservice &ws 可以是 ws1,位于 webservices 文件夹的 Raku 模块中:
sub ws1 ( :$get-params, :$body ) is export {
if $get-params { return 'From ws1: ' ~ $get-params; }
if $body { return 'From ws1: ' ~ $body; }
}
在这个演示上下文中,webservice 简单地返回输入,即 $get-params(当 GET)或 $body(POST 时)。
25.6. 当客户端请求静态文件时
放弃所有其他可能性后,如果客户端请求公用文件夹中托管的静态文件(如 html,js,css 等),则:
given $path {
..
default {
my $filepath = "$current-dir/public/$path";
my $type = content-type(:$filepath);
return response-headers(200, $type) ~ slurp "$current-dir/public/$path";
}
}
它返回包含匹配内容类型和请求文件内容的响应头。
这就是所有的了!以脚本过程式方式使用并发 Web 服务:Wap6。
25.7. 结语
我很满意 Wap6 的结果。我并不假装它增长很多,但我总是想继续添加更多功能:SSL 支持(完成!),会话管理(进行中),Cookie,文件上传等。
Raku 为表执行并发网络操作提供了非常强大的方法:IO::Socket::Async,一个杰作。另外,使用 Raku,您可以根据需要混合使用面向对象,过程式和函数式范式。借助这些功能,您可以设计一个并发异步服务并快速实现。
如果您希望在 Raku 生态系统中使用 HTTP 服务和并发性更严肃的方法,请看看 Cro,它代表了一个很好的机会,可以将 Raku 作为 HTTP 服务空间中的强大实体。Jonathan Worthington 在同样的 Advent Calendar 的第九天写的就是 关于 Cro。
同时,我将继续使用 Wap6,以脚本的方式,贡献 Raku 生态系统,并从世界上最好的编程人员那里学习,我的意思是:Perl 和 Raku 程序员,当然:-)
26. 第一天 - 移植 Vigilance,将 Raku 与标准工具集成在一起
大家好,今天我们将采用基础设施脚本并将其从 Perl 5 移植到 Raku.本文基于 James Clark 的一对帖子,你可以在这里找到:
此脚本用于创建和验证 MD5 总和。 这些是 128 位值,可用于验证数据完整性。 虽然 MD5 已经被证明在防范恶意行为者方面不安全,但它对于检测磁盘损坏仍然很有用。
Raku 生态系统正在发展,其中包含多种工具,这些工具可以从 Perl 5 CPAN 移植,也可以替代。 我将介绍原始脚本和移植的几个方面,并说明我为什么要进行一些特定的更改。 希望这会鼓励你出去移植你自己的小脚本。
26.1. Shebang 和导入
Perl 5 版本使用一些基础设施和一些实用程序来处理 Unicode 并使命令行输出更好:
#!/usr/bin/perl -CSDA
use strict;
use warnings;
use utf8;
use Encode qw/encode_utf8 decode_utf8/;
use Getopt::Long;
use Digest::MD5;
use Term::ANSIColor;
use Term::ProgressBar;
use File::Find;
use File::Basename;
use Data::Dumper;
Raku 默认启用了警告和限制,并且内置了 Unicode 支持,因此我们可以将其保留。 Data::Dumper 也已经实现,它具有非常有用的 IO 功能。 将所有这些加在一起我们可以得到一个非常精益的标头:
use Digest::MD5;
use Terminal::ANSIColor;
use Terminal::Spinners;
26.2. 命令行选项
Perl 5 有许多用于处理命令行参数的很棒的模块,在我们使用 Getopt::Long 的原始脚本中:
# Define our command-line arguments.
my %opts = ( 'blocksize' => 16384 );
GetOptions(\%opts, "verify=s", "create=s", "update=s", "files", "blocksize=s", "help!");
在 Raku 中,我们可以直接在 MAIN 方法中定义命令行选项。 我们使用多个调度来根据传递的参数来控制脚本的执行:
multi MAIN (Str :$create, *@files where { so @files }) { ... }
multi MAIN (Str :$update, *@files) { ... }
multi MAIN (Str :$verify, *@files) { ... }
multi MAIN (*@files where { so @files }) { ... }
这也意味着我们不必定义帮助选项/sub,因为我们可以文档化我们的 MAIN 子例程,因此:
#| Verify the MD5 sums in a file that conforms to md5sum output:
#|
multi MAIN (Str :$verify, *@files) { ... }
您可能已经注意到 Raku 版本没有定义 blocksize 选项,我将回过头来看看。
26.3. IO: 读写文件
我们将校验和存储在一个文件中,其中每一行的格式都与 GNU coreutils 中的 md5sum 程序的输出相同:32 个十六进制数字,两个空格和文件名。
一些基本的 IO,我们使用正则表达式来解析每一行。 使用有意义的空格有助于保持每个正则表达式相当简洁:
sub load_md5sum_file
{
my ($filename) = @_;
my @plan;
open(my $fh, '<:utf8', $filename) or die "Couldn't open '$filename' : $!\n";
my $linenum = 0;
while (my $line = <$fh>) {
chomp $line;
$linenum++;
if ($line =~ /^(?\p{ASCII_Hex_Digit}{32}) (?.*)$/) {
# Checksum and filename compatible with md5sum output.
push @plan, create_plan_for_filename($+{filename}, $+{md5});
} elsif ($line =~ /^(?\p{ASCII_Hex_Digit}{32}) (?.*)$/) {
# Checksum and filename compatible with md5sum's manpage but not valid for the actual program.
# We'll use it, but complain.
print STDERR colored("Warning: ", 'bold red'), colored("md5sum entry '", 'red'), $line, colored("' on line $linenum of file $filename is using only one space, not two - this doesn't match the output of the actual md5sum program!.", 'red'), "\n";
push @plan, create_plan_for_filename($+{filename}, $+{md5});
} elsif ($line =~ /^\s*$/) {
# Blank line, ignore.
} else {
# No idea. Best not to keep quiet, it could be a malformed checksum line and we don't want to just quietly skip the file if so.
print STDERR colored("Warning: ", 'bold red'), colored("Unrecognised md5sum entry '", 'red'), $line, colored("' on line $linenum of file $filename.", 'red'), "\n";
push @plan, { error => "Unrecognised md5sum entry" };
}
}
close($fh) or die "Couldn't close '$filename' : $!\n";
return @plan;
}
Raku 允许我们验证我们是否通过签名传递了实际存在的文件。 此外,我们用 grammar 替换正则表达式,如果需要,我们可以在脚本的不同位置使用该 grammar:
grammar MD5SUM {
token TOP { <md5> <spacer> <filehandle> }
token md5 { <xdigit> ** 32 }
token spacer { \s+ }
token filehandle { .* }
}
sub load-md5sum-file (Str $filehandle where { $filehandle.IO.f }) {
my MD5Plan @plans;
PARSE: for $filehandle.IO.lines(:close) -> $line {
next PARSE if !$line; # We don't get worked up over blank lines.
my $match = MD5SUM.parse($line);
if (!$match) {
say $*ERR: colored("Couldn't parse $line", $ERROR_COLOUR);
next PARSE;
}
if (!$match<filehandle>.IO.f) {
say $*ERR: colored("{ $match<filehandle> } isn't an existing file.", $ERROR_COLOUR);
next PARSE;
}
if ($match<spacer>.chars == 2) {
@plans.push(MD5Plan.new($match<filehandle>.Str, $match<md5>.Str));
}
else {
say $*ERR: colored("'$line' does not match the output of md5sum: wrong number of spaces.", $WARNING_COLOUR);
@plans.push(MD5Plan.new($match<filehandle>.Str, $match<md5>.Str));
}
}
return @plans;
}
写出数据非常相似:
sub save_md5sum_file
{
my ($filename, @plan) = @_;
my $fh;
unless (open($fh, '>:utf8', $filename)) {
...
}
foreach my $plan_entry (@plan) {
next unless $plan_entry->{correct_md5} && $plan_entry->{filename};
print $fh "$plan_entry->{correct_md5} $plan_entry->{filename}\n";
}
close($fh) or die "Couldn't close '$filename' : $!\n";
}
值得注意的是,Raku 默认以 Unicode 格式写入文件:
sub save-md5sum-file (Str $filehandle, @plans) {
my $io = $filehandle.IO.open: :w;
WRITE: for @plans -> $plan {
next WRITE unless $plan.computed-md5 && $plan.filehandle;
$io.say("{ $plan.computed-md5 } { $plan.filehandle }");
}
$io.close;
}
26.4. 获得 MD5 校验和
Perl 5 版本的 Digest::MD5 使用了相当多的 XS 来提高性能。 XS 中包含了以块的形式添加数据以进行整体解析的方法。 这允许我们使用 ProgressBar 向用户展示用户等待时的进度:
sub run_md5_file
{
my ($plan_entry, $progress_fn) = @_;
# We use the OO interface to Digest::MD5 so we can feed it data a chunk at a time.
my $md5 = Digest::MD5->new();
my $current_bytes_read = 0;
my $buffer;
$plan_entry->{start_time} = time();
$plan_entry->{elapsed_time} = 0;
$plan_entry->{elapsed_bytes} = 0;
# 3 argument form of open() allows us to specify 'raw' directly instead of using binmode and is a bit more modern.
open(my $fh, '<:raw', $plan_entry->{filename}) or die "Couldn't open file $plan_entry->{filename}, $!\n";
# Read the file in chunks and feed into md5.
while ($current_bytes_read = read($fh, $buffer, $opts{blocksize})) {
$md5->add($buffer);
$plan_entry->{elapsed_bytes} += $current_bytes_read;
$plan_entry->{elapsed_time} = time() - $plan_entry->{start_time};
&$progress_fn($plan_entry->{elapsed_bytes});
}
# The loop will exit as soon as read() returns 0 or undef. 0 is normal EOF, undef indicates an error.
die "Error while reading $plan_entry->{filename}, $!\n" if ( ! defined $current_bytes_read);
close($fh) or die "Couldn't close file $plan_entry->{filename}, $!\n";
# We made it out of the file alive. Store the md5 we computed. Note that this resets the Digest::MD5 object.
$plan_entry->{computed_md5} = $md5->hexdigest();
}
Raku 版本使用纯 Perl 并且缺少添加功能,因此我使用微调器而不是进度条。 我们还需要专门设置我们的编码,以避免在将二进制数据读取为 Unicode 时出现的错误:
sub calc-md5-sum (MD5Plan $plan) {
my $md5 = Digest::MD5.new;
print "Calculating MD5 sum for { $plan.filehandle } "; # We need some space for the spinner to take up.
# I like 'bounce', so I need 6 spaces for the spinner
# + an extra one to separate it from the filehandle.
my Buf $buffer = $plan.filehandle.IO.slurp(:close, :bin);
my $decoded = $buffer.decode('iso-8859-1');
my $spinner = Spinner.new(type => 'bounce');
my $promise = Promise.start({
$md5.md5_hex($decoded)
});
until $promise.status {
$spinner.next;
}
say ''; # Add a new line after the spinner.
$plan.computed-md5 = $promise.result;
}
26.5. 结束之前的思考
我没有在我的系统上使用 Raku 版本因为 Digest::MD5 的低性能,在我的系统上我用 md5sum 调用替换它。 其他可能性是使用 Inline::Perl5 和 Perl 5 版本的 Digest::MD5,或使用惊人的 Raku 原生调用接口来运行 C 实现。 我希望这篇文章能激发您将一些自己的 Perl 5 脚本移植到 Raku,或者至少为您提供一些命令行交互的技巧。
27. 第二天 – Like 6 Perls in a Pod: document everything
圣诞节即将到来,圣诞老人很沮丧。 他的收件箱被来自全国各地的男孩和女孩的来信塞爆了。
但,
这些信是写给圣诞老人的吗? 是否通过签名正确识别了孩子,以便将礼物送给对的人而不是给其他可能不值得的人?他们是针对圣诞老人的,而不是那些冒名顶替者,复活节兔子,或者更糟糕的是,三个所谓的 - 我不知道为什么 - 来自东方的智者? 最糟糕的是,他个人是否必须通过他的王室和神圣的自我来检查所有这些东西?
没有。
Raku 以下面的方式来救援:
unit grammar Santa-Letter;
token TOP { <dear> \v+ <paragraph> [\v+ <paragraph>]* \v+ <signature>\v*}
token paragraph { <superword>[ \h+ <superword>]+ }
token superword { <word> | <enhanced-word> }
token word { \w+ }
token enhanced-word { <word> [\,|\.|\:] }
token dear { Dear \h+ [S|s]anta [\,|\:]? }
token signature { \h+ \w+ \h* \w* }
该单位向圣诞老人宣布一封致敬的信,其后是一个或多个段落,最后是一个签名,其前面应有一个水平的空格,如 \h
所示。
像这样的信件:
Dear Santa:
This year I have been a really good boy, I have been in all Squashathons.
So I want a plush Camelia studded with diamonds.
JJ
一个简单的脚本将使用该 grammar 并在单封信中获取签名:
use Santa-Letter;
sub MAIN ( Str $file = "letter.txt" ) {
my $letter =$file.IO.slurp;
my $parsed = Santa-Letter.parse($letter);
say $parsed<signature>.trim;
}
这很好,很不错,但圣诞老人需要将这些数据与信件和索引一起提供给北极的 CRM,同时他不得不与贸易战给他们造成严重破坏的供应商打交道…所以他叫上他最亲密的 IT 精灵,来跟他一起做事。
演讲结束后,IT 精灵站在那里,他的耳朵在颤抖。
"什么?",圣诞老人咆哮道。 当然是以神圣的方式。
耳朵的尖变红了,并伴随着颤抖的辐射热量,使小冰柱融化并落到地上。
"你可以阅读消息来源,对吧?"
鲁道夫被冰柱融化的噪音惊醒,因为那是他的超级能量之一,介入。
27.1. 大多数人都可以阅读源代码,但每个人都可以阅读文档。
鲁道夫说。
"而且每个人都应该写下这些文件",他劝告道,他的头部前面有红色的鼻子。
圣诞老人嘟嚷着,但最终检查了他的 Santa-Letter grammar 的主分支并开始着手研究它。 当然,使用 Pod 6
27.2. Pod 6 stands for "Plain Old documentation for Raku"
它(显然)不是首字母缩略词。 Pod6 是一个帮助 Raku 编码人员编写文档的 DSL。 它是一种标记语言,它使用 =
来启动命令和段落级标记。 我们会做到这一点,但目前,Santa 意识到最好的事情之一是它如何与 Raku 本身集成。 因此,他对检查程序进行了第二次迭代:
#| This reads a letter file
sub MAIN ( Str $file = "letter.txt" ) {
my $letter =$file.IO.slurp;
my $parsed = Santa-Letter.parse($letter);
say $parsed<signature>.trim;
}
在注释中有一个有趣的标志,|
。 该标志将其与注释背后的代码联系起来。 在这种情况下,它是 MAIN 子例程。
圣诞老人将该程序发布到了生产环境。 IT 精灵试图运行该程序,
./get-signed.p6 --help
它得到了:
Usage:
./get-signed.p6 [] -- This reads a letter file
"有文档比没有文档更好",他想。 但这还不够。 他完全使用自由软件进入北极票务系统,并要求提供更多文档并将任务分配给圣诞老人。 圣诞老人大声抗议,但顺从了。
#|{ This reads a letter file in text format.
With no arguments, it will read the C<letter.txt> file.
}
sub MAIN ( Str $file = "letter.txt" ) {
my $letter =$file.IO.slurp;
my $parsed = Santa-Letter.parse($letter);
say $parsed<signature>.trim;
say $=pod[0].perl;
}
当使用 --help
调用时,这会打印相同的消息。 这是文档。 运行时:
raku --doc get-signed.p6
它打印了:
sub MAIN(
Str $file = "letter.txt",
)
This reads a letter file in text format. With no arguments, it will read the C file.
所以 Raku 理解注释和附加到它的代码,并自动打印两者。 记录例程就像这样简单。
此外,当在实际文件上运行时,最后一句被踢了,它打印出来:
Pod::Block::Declarator.new(WHEREFORE => sub MAIN (Str $file = "letter.txt") { #`(Sub|81308800) ... }, config => {}, contents => [])
与其他语言中用于注释的其他 DSL 不同,例如 Perl 5 中的 Markdown 或 Pod 本身,Pod 6 不仅是用于注释的 DSL,它还是 Raku 本身的一部分,因此,它由 Raku 解析器解释,其内部结构可用于 $=pod
变量中的内省。 在这种情况下,注释是一个 Pod::Block::Declarator
,该数据结构包含 WHEREFORE
键,其中包含声明的函数和注释。 但是,contents
和 config
为空。 他们不应该这样做。
更重要的是,注释中使用的一点点实际格式不起作用。 更不用说实际模块没有真正文档化。 现在是圣诞老人不高兴了。
27.3. 给模块添加文档
在编写实际代码之前,编写文档可能是您应该做的第一件事。 文档适用于模块客户端,但首先,它是作者的指南,模块应该做什么以及应该如何做的路线图。 如上所述,使用 Pod 6 可以很容易地记录单个方法或例程; 但是,模块的大图片视图也很方便。 这里是 Santa-Letter
的 Pod:
=begin pod
=head1 NAME
Santa-Letter - A grammar for letters to Santa for the L<Raku Advent Calendar|https://rakuadvent.wordpress.com>
=head1 SYNOPSIS
Parses letters formatted nicely and written by all good kids in the world.
=end pod
方便地放在文件的末尾,当用 raku -doc Santa-Letter.pm6
调用时,或简单地 raku --doc Santa-Letter
如果它
已安装,甚至 p6doc Santa-Letter
如果是 raku/doc
的
在场,会写出类似的东西:
NAME
Santa-Letter - A grammar for letters to Santa for the Raku Advent
Calendar
SYNOPSIS
Parses letters formatted nicely and written by all good kids in the
world.
但是你会注意到这种类型的输出已经消除了一段标记。 L
创建链接,但显然只有在输出格式支持时才这样做。 那么让我们试试其中一个:
raku --doc=HTML Santa-Letter.pm6
将输出大量代码,其中包括以下行:
Santa-Letter - A grammar for letters to Santa for the Raku Advent Calendar
清楚地显示链接的输出。
事实上,此命令将使用 Pod::To::HTML
模块将 Pod 数据结构转换为 HTML。 使用任何其他东西将调用相应的模块,并且生态系统上有许多可用的 模块。 例如,Pod::To::Pager
将使用系统的分页使东西更美观。
raku --doc=Pager Santa-Letter.pm6
会输出这个
此外,该文档遵循所有模块中使用的约定。 NAME
应描述名称和简短的 oneliner,告诉模块的内容,而 SYNOPSIS
包含更长的描述。 虽然这很好,但真正的文档应包含示例。
=begin code
use Santa-Letter;
say Santa-Letter.parse("Dear Santa\nAll I want for Christmas\nIs you\n Mariah");
=end code
示例包含在代码块中,从 Pod6 的角度来看,它们是 Pod::Block::Code
对象。 实际上,这是一件好事。 让我们将这一小段代码添加到我们的 grammar 中:
our $pod = $=pod[0];
Grammar 是类,它们具有类作用域的变量。 我们无法导出 $=pod
变量以避免与其他人发生冲突,但我们可以导出它,然后在我们的程序中使用它:
say $Santa-Letter::pod.perl;
或者,甚至更好, 安装 Data::Dump
并写下这样的东西:
say Dump( $Santa-Letter::pod, :indent(4), :3max-recursion );
它使用我们声明的 pod
类变量, 并且它是这样打印的:
这个树可以称为 POM(Pod 对象模型),除了与每个块一起使用的已知的 name
和 config
元数据外,还包括同一级别的 Pod6 块数组。 每个人都有通用属性和特定属性,例如标题中的级别。 无论如何,有趣的是我们作为示例使用的代码本身可以作为 Pod::Block::Code
对象的内容。
圣诞老人想,"哼哼"。 我们可以做得更好。 我们真的可以检查包含的代码是否有效吗? 我们可以! 我们来扩展一下 SYNOPSIS
部分:
=head1 SYNOPSIS
Parses letters formatted nicely and written by all good kids in the world.
=begin code
use Santa-Letter;
say Santa-Letter.parse("Dear Santa\nAll I want for Christmas\nIs you\n Mariah");
=end code
You can also access particular elements in the letter, as long as they are included on the grammar
my $letter="Dear Santa,\nI have not been that good.\nJust a paper clip will do\n Donald"
say Santa-Letter.parse($letter)<signature>
Also
=for code :notest :reason("Variable defined above")
say "The letter signed by ", Santa-Letter.parse($letter),
" has ", Santa-Letter.parse($letter).elems, " paragraphs";
=end pod
代码可以在 Pod 中以不同方式表示。 第一个是已知的; 第二个使用缩进,即 Markdown,来表示同一件事情。 我们也可以使用 =for
作为段落块,在这种情况下使用代码类型声明,并将继续直到下一个空白行。 这是一种不需要 =end
指令的缩写方式。 但是还有更多的东西:配置变量 :notest :reason("Variable defined above")
。 这些配置变量是任意的,我们可以添加任意多个。 他们将转到块的 config
属性,我们可以使用它们。 这正是我们将在此脚本中处理代码示例的内容:
for $Santa-Letter::pod.contents -> $block {
next if $block !~~ Pod::Block::Code;
if $block.config<notest> {
say "→ Block\n\t"~ $block.contents
~ "\n\t❈ Not tested since \'" ~ $block.config<reason> ~ "\'";
} else {
my $code = $block.contents.join("");
say "→ Block\n\t"~ $block.contents;
try {
EVAL $code;
}
if ( $! ) {
say "\n\t✘ Produces error \"$!\"", "\n" xx 2;
} else {
say "✔ is OK\n";
}
}
}
正如我们在上面的结构中看到的那样,contents
属性将包含一个第一级 Pod 块的数组,在我们的例子中包括我们想要求值的所有三个块(或者可能不包括)。 跳过非代码块(但也可以检查拼写)。 我们在这里做了两件有趣的事情:我们通过 $block.config
检查配置中的 notest
标志,如果是这种情况我们打印一些注释,但是如果它应该被测试,那么它是 EVAL`ed(我们需要使用 `MONKEY-SEE-NO-EVAL
指令。
圣诞老人在文档上运行它,瞧瞧!
→ Block
my $letter="Dear Santa,\nI have not been that good.\nJust a paper clip will do\n Donald"
say Santa-Letter.parse($letter)
✘ Produces error "Two terms in a row across lines (missing semicolon or comma?)"(
)
他立刻感到高兴和谦卑。 一个简单的分号破坏了示例的质量。 它始终是分号。 他把分号放回到示例中,模块文档以快速的颜色通过了测试。
28. 第三天 – Raku – 跳转到那儿
jmp 是一个简单的基于终端的前端到您最喜欢的代码搜索工具(例如,rgrep,ag,ack,git grep 等)。 它会显示一个搜索结果列表,您可以在跳转到编辑文件之前快速浏览(例如,vim,nano,comma等)。
它的工作原理如下:
!img
最近我重构了 jmp,以便在 Terminal::Print 模块的帮助下改进用户界面。 Terminal::Print 提供了一个方便的二维网格,用于在终端屏幕上绘图。 还有一个用于异步处理用户输入的模块。
这是 jmp 代码,只要用户按下某个键,它就会响应:
my $in-supply = decoded-input-supply;
my $timer = Supply.interval(1).map: { Tick };
my $supplies = Supply.merge($in-supply, $timer);
react {
whenever $supplies -> $_ {
when Tick {}
when 'CursorUp' { self.pager.cursor-up; }
when 'CursorDown' { self.pager.cursor-down; }
when 'CursorRight' | 'PageDown' { self.pager.next; }
when 'CursorLeft' | 'PageUp' { self.pager.previous; }
when 'x' | 'X' { self.pager.exit-page; }
when 'e' | 'E' {
self.pager.edit-selected($!editor);
}
when $_ ~~ Str and $_.ord == 13 {
# the user pressed ENTER
self.pager.edit-selected($!editor);
}
}
}
此代码设置用户输入事件的异步 supply,并且只要事件触发(例如,用户按下 PageUp),它就会做出反应。 但是如果用户一次按下很多键会发生什么? 如何以有序的方式更新终端屏幕?
由于 Jonathan Worthington 的 OO::Monitors 模块和 monitor 关键字,解决方案在 Terminal::Print::Grid 中找到。 这确保了一次只有一个线程可以在 grid 对象的方法中:
use OO::Monitors;
unit monitor Terminal::Print::Grid;
制作自己的 Raku 驱动的命令行工具是学习该语言的好方法。 如果您需要终端接口,请查看 Terminal::Print。 为了加速命令行工具,将代码放在模块中是一个很好的技巧,因此 Raku 可以预编译它以加快启动时间(例如,CLI.pm)。
shell> zef install jmp # or zef upgrade jmp
shell> jmp config # set up jmp to use your tools
shell> jmp find sub MAIN # find files containing "sub MAIN"
随着我们越来越接近圣诞节,请留意更多的命令行工具会被打开。
29. 第四天 - 献给新年的 Raku Pod 新功能
29.1. 介绍
Rakudo NQP 文件包含解析 Raku 输入文件并将其转换为正在运行的 Raku 程序的代码。 本文将重点介绍最近使用 Rakudo NQP 文件时的经验所学到的一些细节。 这项工作涉及实现一些尚未实现的(NYI)Raku POD 功能,我希望尽快合并这些更改。
29.2. 准备
使用的 NQP 文件保存在 https://github.com/rakudo/rakudo/src/Raku 的 git 存储库中。 有关我的开发设置和工作流的更多背景信息,请参阅 https://rakuadvent.wordpress.com/2017/12/08/ 上的 2017 年 Raku Advent 条目。
29.3. 背景
在我实现 NYI POD 功能的过程中,我已经给我添加到 Rakudo 仓库中的文档添加了注释: rakudo/docs/rakudo-nqp-and-pod-notes.md。我更新它,因为我发现了可能没有记录的新内容或者可能不容易找到其文档。该文件还包含一份完整的清单,通过我的计算,NYI POD 功能。以下是我已经工作了几个月的 NYI POD 功能列表,我希望在今年或新年初完成(以及每个功能的 roast 测试):
-
NYI: %config :numbered 对于段落或分隔的 POD 块,使用 '#' 别名
-
NYI: POD 数据块
-
NYI: 以 defn 块术语格式化代码
缺少的项目描述在由 Damian Conway 博士撰写的精美制作的 概要 S26中,Larry Wall 是多产的得力男人 - 世界知名的 Perl 专家和著名的 Perl 作者。(请注意,现在很少有人在积极研究 POD,我的 NYI 功能列表可能不完整. S26 写得非常密实,如果不高度集中就不容易理解。我花了不少时间试图实现一个我认为已被描述的功能但我误读了文档!)
受许多因素的影响, 这项工作比我预期的时间更长,因为我将简要讨论,希望它可以帮助未来的开发人员。
29.4. Rakudo NQP grammar 和 actions: 学到的东西
29.4.1. Match 对象
在 token 上完成一个 grammar 匹配会产生一个匹配对象。 如果 token 具有与该 token 同名的 action 操作方法,则使用匹配对象作为隐式或显式参数调用该 action 方法。 按照惯例,'$/' 用作显式参数,但可以使用另一个名称(不要这样做!)。 我不建议依赖隐式参数。 如果需要,可以添加其他参数。
请注意,随着解析的继续,匹配数据将保留在匹配对象中,因为它在其他 token 和方法中使用。
29.4.2. 断言
断言在 POD 处理中发现的动态 grammar 中很重要。 在主匹配期间,通常必须选择几种匹配路径。 调试错误使用给我带来很多麻烦的一个例子是在定义分隔文本块的 token 内。
触发问题的测试用例是文件’b.t':
=begin pod
text
=end pod
my $o = $=pod[0];
say $o;
当我对它运行 raku 时,我得到了
$ ./raku b.t
Preceding context expects a term, but found infix = instead.
Did you make a mistake in Pod syntax?
at /usr/local/people/tbrowde/mydata/tbrowde-home-bzr/raku/raku-repo-forks/rakudo/b.t:1
------> =begin ⏏pod
不是很有帮助! 然后我尝试了:
$ ./raku b.t --ll-exception b.t
Preceding context expects a term, but found infix = instead.
Did you make a mistake in Pod syntax?
at SETTING::src/core/Exception.pm6:57 (./CORE.setting.moarvm:throw)
from src/Raku/World.nqp:4955 (blib/Raku/World.moarvm:throw)
from gen/moar/Raku-Grammar.nqp:301 (blib/Raku/Grammar.moarvm:typed_panic)
from gen/moar/Raku-Grammar.nqp:3609 (blib/Raku/Grammar.moarvm:)
...more files and line numbers...
更没用了! 我尝试手动调查列出的文件,并且无法很好地解密代码以获得线索。
然后我尝试了另一个似乎有效的类似测试用例,文件’b2.t':
=begin table
text
=end table
my $o = $=pod[0];
say $o;
当我对它运行 raku 时,我得到了
$ ./raku b2.t
Pod::Block::Table
text
成功了!
但是这个失败的测试案例导致我几周尝试各种调试技术,直到最后,再次查看 Grammar.nqp 中的 delimited token 并在心里计算每个子匹配组正在做什么。 然后我仔细查看了包含断言的这个组:
[
# defn-line is all text to the newline
<?{ ~$<type> eq 'defn' }> # <== assertion: this is a 'defn' type
\s* <defn-line>
]
在 delimited 块 token 定义中,该组是顺序的而不是备选分支的一部分,必须匹配或全部 token 失败。不幸的是,失败的结果是 LTA , 对于这种情况是例外(这在 NQP 中并不常见,并且在其中工作的危险之一),并且我在寻找原因的过程中犯了太长时间的错误。欺骗我的一件事就是认为在一个没有得到满足的小组中的断言就像是'?'量词意味着忽略失败的匹配。在我仔细研究之后,我认为绝对不是这样!该组是否匹配,因此如果不匹配是可接受的,则量词必须在那里。
当我将 delimited token 的代码与 delimited_table 块 token 的起作用代码(之前我曾做过很多次)进行比较时,我看到 delimited_table 块中的同一匹配组具有'?'量词。在我给 delimited 块 token 中的组添加'?'后, 坏的测试用例再次起作用!
29.4.3. 调试
对我来说最有用的 grammar 和 actions 调试技术是经典的:print 语句用于显示执行期间变量的值。该方法取决于哪种文件类型以及希望显示的值。以下是一些例子:
-
1、显示匹配对象的内容:
method do-foo($/) {
say("DEBUG: dumping method 'do-foo' match:");
say($/.dump);
}
-
2、显示 grammar 匹配期间的结果
token blah {
\h* $<tok> = [ foo | bar ] # <== note '=' instead of ':='
{ say("DEBUG: \$<tok> value: '{$<tok>}'"); }
}
请注意,say 语句位于由花括号定义的块内。另请注意,即使在 NQP 源文件中,grammar 中使用的匹配对象的赋值运算符('=')也不是绑定运算符(':=')。
29.4.4. 动态变量
grammar 和 action 大量使用动态变量(带有 *
twigil 的变量,例如 $*IN-DEFN-BLOCK)。当需要在解析树中深入更改变量时,它们显示了它们的多功能性,并且该值在该解析的剩余部分(调用者)和子解析操作期间保持不变。
29.4.5. make, made 和 ast
尽管在所有已发表的 Raku 书籍中都有解释,但 grammar 和 action 中使用的术语 "make","made" 和 "ast"一直让我很困惑。感谢 Raku 作者 Moritz Lenz 对 IRC#raku-dev 的问题的进一步解释和回答,他们更清楚了。
基本上,在 action 方法中,使用 make
会将当前值分配给匹配对象的 .ast
属性(或其别名 .made
)和方法的名字。因此,给出以下方法:
method do-foo($/) {
my $val = 6;
make $val;
}
或可选地:
method do-foo($/) {
$/.ast := 6;
}
我们以后可以用这些惯用法中的一个来获得这个值:
say("do-foo.ast = {$<do-foo>.ast}"); # output: 6
say("do-foo.ast = {$<do-foo>.made}"); # output: 6
选择属性名称 .ast
是误导性的,因为它通常是指抽象语法树(AST),但在这种情况下,它与 AST 无关(尽管它可能具有 QAST 节点或任何其他类型的 NQP 对象值)。
请注意,分配给 .ast
属性的任何值都可能在 grammar 或 action 的稍后阶段被覆盖或删除。
29.4.6. 推迟生成 QAST 节点
有时在现有 grammar 中过早生成 QAST 节点阻止了正确的 POD 功能实现。一个例子是 POD 块的%config 部分,它具有稍后解析所需的一些值。我正在做的部分工作需要重新编写%config 匹配代码,因此在父对象(通常是 POD 类)的所有部分都已根据需要进行计算或构建之前,不会生成 QAST 节点。
29.4.7. 隔离 POD-only 代码
当前的 grammar 和 grammar action 代码是复杂的,并且有些谜题,因为插入了块并且超过 15 年没有再次触及。因此,很难避免合并冲突与大而必要的变化。核心开发人员提出的一个建议是帮助将 POD 代码与其他代码分开,这就是创建一个与其他现有方言类似的单独 POD 方言(子语言)。我曾经认为这将是一个有用的改变,但现在,在理解了更多的代码后,创建一个单独的 POD 方言似乎并不是特别有利。但是,将所有 POD-only 代码移动到封闭类或 grammar 块的末尾将有助于在个人合并重叠代码时最小化版本控制意外和冲突。
因此,几个星期前我抓住机会(1)询问了几个关键开发人员,如 @lizmat 和 @jnthn,如果他们对该计划没有问题,(2)创建并测试这样的更改作为拉取请求(PR),(3)合并相当大的 PR。不幸的是,这一重大变化令一些开发人员感到意外,并在 IRC#raku-dev 上引发了一些惊讶的评论和投诉!幸运的是,发布经理 @AlexDaniel 运用了他惯用的外交和 git 代码讽刺风度,因为他让人群平静下来,并演示了改变实际上只是一个简单(但很大)的代码转换。所以我即将推出的 PR 不应该导致合并问题,因为我所知道的其他人都不会在同一个区域工作。
您可以通过在每个文件中搜索 POD-ONLY 来查看 Grammar.nqp 和 Actions.nqp 中 POD-only 代码的起点,您会发现:
#================================================================ # POD-ONLY CODE HANDLERS #================================================================ # move ALL Pod-only [grammar|action] objects here
29.5. 总结
我逐渐了解了如何改进 Rakudo Raku grammar 和实现一些 NYI POD 功能的 actions,我希望尽快交付它们。在工作期间,我从困难的方式学到了许多课程,并希望我对 POD 解析的黑暗角落有所了解。
从任何主要编码项目中拿走的最后一课:为合并提交制作,测试和提交小的(即有限的)更改!我在 POD 特征的有时弯曲的解析路径中被包裹起来,我做了太多的改变,并且不能轻易地撤消它们。我希望我不要重蹈覆辙。
我希望你和你的 Rakuish 圣诞快乐和新年快乐,并且用 Charles Dickens 的 Tiny Tim(圣诞颂歌)不朽的话来说,"上帝保佑我们,每一个人!"
30. 第五天 - 变量
这么简单的事,不是吗? 变量是一个保存着值的名字。
有时候,它持有的值可能会被另一个值所取代 - 因此就是名字。 (根据外科医生的说法,没有经常变化的变量应该看医生,并要求被诊断为常数。)
虽然它们很容易掌握,而且基本上每种语言都有它们,但我今天的目标是让你相信变量实际上非常棘手。 好的方式! 我的目的是让你被这篇博文绊倒,茫然,喃喃自语"我以为我知道变量,但我真的不知道……"。
接近最后,实验语言 007也将会出现,我考虑变量这么多完全是这种语言的过错。
30.1. 左还是右?
变量奇怪的第一种方式是它们以两种完全不同的方式使用。
my $x = "Christmas";
say("Merry " ~ $x); # reading
$x = "Easter"; # writing
有时我们使用变量来读取值,有时我们使用它们来写一个值。但在这两种情况下,语法看起来完全一样!一些较旧的语言(例如 Forth)实际上对这两种用法有不同的语法,我喜欢它们。但是这样的惯例似乎并没有幸存到现代。
相反,我们通过语法位置来区分这两种用法。如果你在赋值的左侧,那么你就被写了。否则,你正在被读取。
在文献中,这两种用途分别称为 lvalues 和 rvalues。分别为"左"和"右"。
Rvalues 非常正常,与我们对变量的一般考虑方式相对应;他们只是计算它们包含的值。然而,Lvalues 很奇怪。它们更像是盒子,你可以把东西放入(或内存位置?引用?),或者如果不是盒子本身,那么分离的能力放入其中。如果 lvalues 有一个类型,它看起来像 (T) → void
,接受 T
但不返回任何东西的东西。
30.2. 参数
变量对现代编程至关重要。 还有一个原则表明它们完全没有必要。
那就对了! Tennent 的通信原则! (我知道你在想什么。不,我说的不是那个 Tennant。
这个原则主要指向一种在程序中重写所有变量声明的方法,因此它们是参数声明。 一个例子应该足以展示一般原则:
# Before
my $veggie = "potato";
say "$veggie, and that's all I have to say about that!";
# After
(-> $veggie {
say "$veggie, and that's all I have to say about that!";
})("potato");
看看变量声明如何变成参数声明,相应的赋值转变为参数?有经验的(或者我应该说是饱受争吵蹂躏的)JavaScript 开发人员将这种结构视为 IIFE。
由于我们总能进行这种转换,因此我们并不需要变量。只有参数。我主要是告诉你这个,所以你可以有点特别感谢你不必用参数编写你的代码。
30.3. 动态范围
在 Raku 中,只要变量范围偏离词法范围,变量就会产生额外的"twigil"(sigil 之后的可选符号)。这些替代范围中最重要的可能只是动态范围。
同样,我们最好用一个例子来说明差异:
my $lexical = "mainline";
my $*dynamic = "mainline";
sub foo() {
my $lexical = "foo";
my $*dynamic = "foo";
bar();
}
sub bar() {
say $lexical; # "mainline"
say $*dynamic; # "foo"
}
foo();
忘记处女座和 Saggitarius 以及其他占星术的迹象。对于你更深层次的个性而言,值得做的唯一区别就是你是在做词法查找还是动态查找。毕竟,只有两种人。
无论我们喜不喜欢,查找都是一个过程。我们给了一个名字,然后我们去找相应的值。我知道,这令人沮丧。但无论如何,让我们这样做,看看它在哪里。
对于 $lexical
,通过查看程序文本本身来进行查找。该变量是否定义在我们所在的最小范围内,那个 bar
sub? (事实并非如此。)然后我们向外走,直到周围的范围 - 这最终成为整个计划的范围。它是在那里定义的吗?是!真幸运的是我们从查找中获得胜利,其值为 "mainline"
。
$*dynamic
- 请注意名字中的星号?我告诉过你有占星术! - 我们也从最里面的范围,bar
sub 开始,并在那里寻找定义。 (我们找不到。)但现在发生了一些不同的事情。我们不会向外跟随块结构,而是向上跟随调用链。谁调用给我们? foo
。这就是我们的目标。那里有定义吗?是!所以我们已经完成并且成功了。
从历史的角度来看,动态查找是"明显的",大多数语言最初都有它。词汇查找只是逐渐证明了它的价值,现在已成为流行病。 Perl 5 实际上跨越了这段历史,而我的变量是词法,但较旧的我们/包变量是动态的。这就是你在历史发生时从身边得到的东西。
在 Raku 中,我们也通过禁止术语"父范围"来履行自己的职责。在一个词法和动态查找的世界里,它太混乱了。相反,我们更喜欢术语 OUTER
(用于词法查找)和 CALLER
(用于动态查找)。
如果可能的话,Raku 中的一些结构(例如 return
和 next
)会尝试词法,但如果找不到任何词汇周围的东西来"附加",则会回归到动态。这种类型的行为似乎没有真正的学术术语,所以 Raku 的概要称它为"lexotic"。
30.4. 宏里面的变量
还在我这儿?礼包。 我们来谈谈宏。
use experimental :macros;
macro moo {
my $counter = 0;
quasi {
say ++$counter;
}
}
for ^10 {
moo;
}
这是一个简单的宏,只是将代码中的 ++$counter
注入到 for
循环中。该程序将在各行上打印从 1 到 10 的所有数字。
很好,但……怎么样?请注意,宏扩展代码引用 $counter
,但词法查找(如上所述)将找不到在周围词法范围内声明的变量。但是,这个程序仍然有效,或者更确切地说,是有效的。
那么使程序运作的基本原则是什么呢?事实证明,通过一个非常幸运的偶然事件,在宏体内定义的变量可以被"无法统一"并被左值替换。注入的代码说 $counter` 实际上看起来更像是 `☐
,其中 ☐
代表那个(代表不可代理的)左值。
我知道这是一件小事,但是当我最终把它放在一起时我很高兴。事实上,我很高兴我把它写成 github iuuse,只是为了确保细节都能解决。请继续关注此项的实现,从而保持卫生。
(对于那些在家中保持分数的人来说,卫生宏是 本拨款申请中的里程碑 D3。)
需要明确的是 - 这更像是一种实现意图。 Raku(和 007)尚未实现完全卫生。但是,拥有明确的前进道路令人振奋。
无论如何,这是变数。他们很可爱,有点奇怪,但最后我们很高兴他们在那里。快乐的历险。☺
31. 第六天 - 懒惰精灵与勤劳精灵
对圣诞老人来说,圣诞节总是一年中最忙碌的时刻。 幸运的是,圣诞老人有很多帮手。 他们总是做一些小工作和家务,只是为了创造最好的假日季节体验!
Object::Delayed 模块为圣诞老人的快乐精灵添加了两个非常有趣的精灵! 他们的名字是 slack
和 catchup
!
31.1. Lazy slack
那个懒散的(slack
)精灵确实非常懒惰。 懒散(slack
)精灵不会做任何事情,直到你真的需要他去做。 虽然人们可以认为这是精灵中非常糟糕的性格特征,但它也是一种非常生态的特征。 人们可以认为这个懒散的(slack
)精灵是他们所有人中最环保的精灵! 你有多少次要求精灵为你做点事情,然后却没用过那个精灵辛苦工作的结果? 即使它只是到处移动的被回收的电子,但仍然需要能量来移动它们! 特别是, 如果那些电子被用来告诉其他精灵做一些遥远的事情,就像在外部数据库中一样!
use Object::Delayed;
my $dbh = slack { DBIish.connect(...) }
这就是你需要的 $dbh
变量,它只在实际需要时才与数据库建立连接。 当然,如果你想对该数据库进行查询,那么也可以使其懒惰!
use Object::Delayed;
my $dbh = slack { DBIish.connect(...) }
my $sth = slack { $dbh.prepare(...) }
由于语句句柄也是懒惰的,因此在实际需要之前它实际上不会进行查询准备。
use Object::Delayed;
my $dbh = slack { DBIish.connect(...) }
my $sth = slack { $dbh.prepare(...) }
# lotsa program
if $needed {
$sth.execute; # opens database handle + prepares query
}
因此,如果 $needed
为 true,调用 .execute
函数将使 $sth
成为一个真正的 statemement 句柄,因为它使 $dbh
成为真正的数据库句柄。 那不是很好吗? 因为如果你不需要它,所有进行查询准备的精灵都可以做其他的事情,而建立数据库连接的精灵也可以做其他事情。 更不用说数据库中的精灵们根本不知道你最初计划建立一个数据库连接!
当然,如果您确实需要数据库连接,那么告诉数据库的精灵们您已经完成了这一点总是一个好主意。 在 Raku 中,这不会自动发生,因为圣诞老人不会跟踪每个精灵的行为。 圣诞老人喜欢委派责任! 当您离开需要数据库句柄的代码部分时,通常会告诉数据库精灵您已完成的工作。
LEAVE .disconnect with $dbh;
LEAVE
精灵的特殊之处在于,当你离开被称为 LEAVE
精灵的街区时,它将完成被告知要做的事情。 在这种情况下,如果 $dbh
被定义,则在 $
上调用 .disconnect
方法:with
精灵不仅测试是否定义了给定值,还设置 $
。
但是,但是,不会检查 $dbh
是否实际定义了与数据库的连接? 不,这个懒散的精灵足够聪明,如果你问的是某个东西是 .defined
,还是真或假,它实际上不会开始为你做这项工作。 这与 catchup
精灵有什么不同!
31.2. 尽力追赶
如果懒惰精灵是圣诞老人雇佣的最环保的精灵,那么 catchup
肯定是最红的精灵。 因为你总是试图赶上 catchup
精灵。 但是追赶精灵似乎只是非常勤奋。
当你告诉 catchup
精灵做某事时,catchup
精灵会立即找到另一个精灵去做实际的工作并告诉你它完成了。 最有可能的不是。 当你真正想要使用你要求 catchup
精灵做的结果时,有两种可能性:如果另一个精灵完成并且结果可用,你将立即从 catchup
精灵那里得到它。 如果其他精灵尚未完成,它将让你等到另一个精灵完成:它会迫使你赶上! 那看起来怎么样?
use Object::Delayed;
my $foo = catchup { sleep 5; "Merry" } # sleep is just
my $bar = catchup { sleep 9; "Christmas" } # another word
my $baz = catchup { sleep 8; "everyone" } # for baking
say "$foo $bar, $baz!";
say "Took { now - INIT now } seconds";
# Merry Christmas, everybody!
# Took 9.008 seconds
在这里,catchup
精灵有另外 3 个精灵正在制作那些带有甜味硬壳釉的精美烘焙刻字,每个字母花费大约一秒钟。 如果只有一个精灵这样做,它至少需要 5 + 9 + 8 = 22 秒。 感谢 catchup
精灵,只用了 9 秒多一点! 快了两倍多!
当然,如果所有其他精灵都在忙着做其他事情,那实际上可能需要一点时间而不是超过 9 秒。 或者甚至超过 22 秒,如果其他精灵正在处理更重要的事情,而不是用正确的玻璃烘焙字母。 所以你的精灵里程可能会有所不同。 你不想过度劳累你的精灵,也不要太久。 几秒钟应该没问题。
31.3. Use the right elf
如果你想尽可能的绿色,请使用 slack
精灵。 如果你想要它,并且你现在想要它(嗯,尽快),那么如果你能够合理地确定有足够的其他精灵来完成实际的工作,那么使用 catchup
精灵是一个选择!
参与此博客文章的所有精灵们都欢呼! 你真的非常确定没有任何快速,慢速或任何其他精灵以任何方式受到伤害。
32. 第七天 – 细胞自动机
今天的降临日历帖子涉及 Cellular Automata。
什么是细胞自动机?我很高兴你问!它们是由几个部分组成的系统:由细胞组成的一种场或"世界",每个细胞可以在任何点处的一组状态,描述每个细胞可见的细胞的"邻域" ,以及一套规则,用于管理一个单元将其状态改变为什么状态以及其邻域中所有单元的状态。
当然,这是一个非常抽象的描述,所以让我举一些个别部分的例子,希望能让你了解你在细胞自动机中看到的内容:
在典型的世界中,你可能会发现细胞像串珠一样排列,或者像国际象棋或中国跳棋板上的字段。您还可以组成更多奇特的配置:任何二维场都可以映射到任何表面,例如 斯坦福兔子
你可以在野外找到的状态集是"从 0 到 n 的数字","这里有细菌","黑白颜色"(或更多)。由于您基本上可以将任何信息表示为"数字",并且允许任意数量的状态,因此还可以存在表示"此时此单元格中有多少粒子在上升,下降,向左或向右移动的状态? "作为整数或甚至浮点数。
邻域可以被认为是细胞"连接在一起"的模式。典型的社区将是"前面的一个,后面的一个"的"串珠"字段,以及"北,东,南,西"或"北,东北,东,东南,南,西南,西,西北"对于棋盘场 - 这两个分别是冯诺依曼附近和摩尔附近。
管理每个单元的邻域中的状态的一组规则将导致哪个状态转到其他状态可被视为特定元胞自动机的核心。
在今天的降临日历中,我们将探索您可能称之为最简单的自动机。我们将字段串起来像字符串上的珠子,我们将尝试一个或两个和一些不同的状态集。
为了让家里的人感兴趣,我将为您提供链接,让您在浏览器中运行示例代码,或在家中使用您的本地 raku 编译器!
32.1. 为学习而做
让我们开始,然后:
首先,我们需要什么?世界上必须有存储空间,需要一些代码来获得一个符合条件的邻居,以及一些代码来计算一个单元的下一个状态,给定它自己的状态和邻居的状态。最重要的是,我们想看看发生了什么,所以我们也有一些代码。
在确定每个单元可以具有哪些状态之后,我们将知道什么存储适合于我们的世界。使用 8 位整数数组将允许我们从任何不超过 255 个单独状态的状态集中进行选择。不过,让我们现在共计 3 个州。我们可以随心所欲地初始化世界,但是将每个字段设置为随机有效状态是一个很好的起点。另一个是将一个状态的单个单元放在中间,并使每个其他单元具有不同的状态。
constant number-of-states = 3;
constant field-width = 60;
my int8 @field;
sub init-field {
@field = (^number-of-states).roll(field-width);
}
显示字段非常简单,具体取决于我们使用的输出。 这是一段可以在任何控制台中运行的代码,下面是一个 6pad 的链接,它在 pad 的 HTML 部分输出很少的彩色方块。
sub output-a-row {
for @field {
# Using the unicode characters "Light shade", "Medium shade", and "Dark shade"
# and printing each field twice so they look square rather than slim and tall.
print ["\x2591", "\x2592", "\x2593"][$_] x 2
}
say "";
}
init-field;
output-a-row;
在 浏览器中运行此代码。 你将不得不等待几秒钟来获得当前相当大的 raku 编译器在 javascript 中。
32.2. 走在前面
从单个行到单元格的自动机的模拟运行需要一次完成一大堆部分。
根据他们的定义,细胞自动机将同时推进其所有细胞。 我们当然不会去云端获得拥有与我们领域中的单元一样多的 cpu 内核的机器。 我们将通过一个简单的循环遍历所有字段来解决"根据它们的相邻单元格在上一步中计算每个单元格的下一步"。
对此的直接方法是在每个步骤之后有一个额外的字段来放置计算结果并将结果复制到"真实"字段数组中。 让我们尝试一下。
sub simulate-step {
my int8 @output;
for ^field-width -> $x {
# do some calculations here
}
@field = @output;
}
让我们看看我们需要什么来进行计算:新状态将取决于邻域和单元本身。 我们可能会选择最明显的社区:一个小区,它是该领域的前身和继承者。 但等等,第一个和最后一个细胞会发生什么? 让我们假装他们有一个额外的邻居,它总是处于 0 状态。这样
sub simulate-step {
my int8 @output;
for ^field-width -> $x {
my $left = $x - 1 < 0 ?? 0 !! @field[$x - 1];
my $middle = @field[$x];
my $right = $x + 1 >= field-width ?? 0 !! @field[$x + 1];
# do some calculation with $left, $middle, and $right
# then push the result into @output
}
@field = @output;
}
所以,我们终于到了我们需要决定我们的细胞自动机实际上应该首先做什么的地方。 但是,当我们甚至没有赋予"0,1 和 2"状态的含义时,我们应该如何弄清楚它应该做什么?
答案很简单! 从字面上理解这一切。
32.3. 制作东西
我的意思是,我们目前并不关心细胞自动机的作用,只要看起来不错。 那么为什么不预先通过滚动一些想象中的骰子来预先决定应该发生什么呢?
为此目的,它有助于知道有多少可能的"配置"甚至是单元及其邻居所在的。幸运的是,这很简单。 您可以将三个单元格想象为由三位数组成的数字,并且每个数字都允许为 0,1 或 2。
000 001 002
010 011 012
020 021 022
100 101 102
110 111 112
120 121 122
200 201 202
210 211 212
220 221 222
如果我没有陷入困境,那就是左,中,右单元组成的所有可能性。 就像四位二进制数可以是 2⁴数之一一样,这个三位三进制数可以是 3³。 这意味着我们只需要在 0 到 2 之间选择 3³ 个数字,上面表格中每个数字一个。
这样做真的很愉快!
my int8 @lookup-table = (^number-of-states).roll(3³);
并且给定 $left
,$middle
和 $right
变量,我们可以将第一个与 9 相乘,第二个与 3 相乘,并将三者相加以获得查询表中的索引:
sub simulate-step {
my int8 @output;
for ^field-width -> $x {
my $left = $x - 1 < 0 ?? 0 !! @field[$x - 1];
my $middle = @field[$x];
my $right = $x + 1 >= field-width ?? 0 !! @field[$x + 1];
my $index = $left * 9 + $middle * 3 + $right;
@output.push(@lookup-table[$index]);
}
@field = @output;
}
运行这个已经让我们看起来很闪亮。 我们需要做的就是连接潜艇:
constant number-of-states = 3;
constant field-width = 60;
my int8 @field;
sub init-field {
@field = (^number-of-states).roll(field-width);
}
init-field;
sub output-a-row {
for @field {
# Using the unicode characters "Light shade", "Medium shade", and "Dark shade"
# and printing each field twice so they look square rather than slim and tall.
print ["\x2591", "\x2592", "\x2593"][$_] x 2
}
say "";
}
my int8 @lookup-table = (^number-of-states).roll(3³);
sub simulate-step {
my int8 @output;
for ^field-width -> $x {
my $left = $x - 1 < 0 ?? 0 !! @field[$x - 1];
my $middle = @field[$x];
my $right = $x + 1 >= field-width ?? 0 !! @field[$x + 1];
my $index = $left * 9 + $middle * 3 + $right;
@output.push(@lookup-table[$index]);
}
@field = @output;
}
for ^100 {
simulate-step;
output-a-row;
}
结果在某些时候看起来非常有趣! 当然,我们需要通过随机查找表获得幸运。 如果你有很多无趣的东西,我喜欢这里的一个:
my int8 @lookup-table = <0 0 2 0 0 0 1 2 0 0 1 1 2 1 1 2 1 1 1 0 1 2 2 0 2 1 1>;
这里是 6pad的链接,您可以在浏览器中试用它。
第三,这是我的机器的截图,以防您在移动设备上阅读或其他无法运行 raku 的内容。
!img
32.4. 改变
现在我们的模拟器完成了它应该做的事情,让我们通过一些调整获得一些乐趣。
首先,让我们看看增加不同状态数量需要做些什么:
constant number-of-states = 4;
# the size of the lookup table should be based on the number of states
my int8 @lookup-table = (^number-of-states).roll(number-of-states³);
sub output-a-row {
for @field {
# add unicode character "Full block" for the fourth state
print ["\x2591", "\x2592", "\x2593", "\x2588"][$_] x 2
}
say "";
}
并且计算也需要基于状态数进行计算:
my $index = $left * number-of-states * number-of-states
+ $middle * number-of-states
+ $right;
那已经是它了! 到目前为止,甚至都不是很难。
32.5. 改变邻居
现在这个更有趣了。 更改邻域将需要我们的计算循环来为索引计算获取更多变量,并且查找表也将再次更改其大小。
让我们回到 3 个状态而不是 4 个状态,用一个只有一个单元格的单元替换邻域:我们将采用单元格的前任及其后继者,但忽略单元格本身。 然后我们添加了前任的前任和后继者的继任者:
# three states, but four neighbors
constant number-of-states = 3;
constant number-of-neighbors = 4;
# ...
# exponentiate number-of-states with number-of-neighbors, like
# you would to get a number-of-neighbors number in base number-of-states.
my int8 @lookup-table = (^number-of-states).roll(number-of-states ** number-of-neighbors);
sub simulate-step {
my int8 @output;
for ^field-width -> $x {
my $leftleft = $x <= 1 ?? 0 !! @field[$x - 2];
my $left = $x == 0 ?? 0 !! @field[$x - 1];
my $right = $x == field-width - 1 ?? 0 !! @field[$x + 1];
my $rightright = $x >= field-width - 2 ?? 0 !! @field[$x + 2];
# many multiplications later ...
my $index = $leftleft * number-of-states * number-of-states * number-of-states
+ $left * number-of-states * number-of-states
+ $right * number-of-states
+ $rightright;
@output.push(@lookup-table[$index]);
}
@field = @output;
}
!img
这是试用它的 6pad
可悲的是,它似乎只是让输出变得更加混乱。
32.6. 优化机会?
目前,代码是高性能和可读性之间的折衷。 它也可能看起来像这样:
for (0, |@field, 0).rotor(3 => -2) -> ($left, $middle, $right) {
my $index = :3[$right, $middle, $left];
}
虽然我的直觉告诉我,这会明显变慢。
但是我们可以使代码更快一点,甚至不会牺牲太多的可读性!
有一件事我们的计算循环太多了:数组访问! 连续三次访问每个单元格:一旦它变为 $right
,再次变为 $middle
,另一次变为 $left
。
那么我们怎样才能做得更好呢? 我想到的第一件事是让变量 $left
,$middle
和 $right
在迭代之间保持不变并通过以下方式移动单元格值:
my $left = 0;
my $middle = @field[0];
my $right = @field[1];
for ^field-width -> $x {
my $index = $left * number-of-states * number-of-states
+ $middle * number-of-states
+ $right;
@output.push: @lookup-table[$index];
$left = $middle;
$middle = $right;
$right = $x + 1 >= field-width ?? 0 !! @field[$x + 1];
}
很酷,我们甚至已经摆脱了 $x
vs field-width 的检查! 但是还有另一件事情一遍又一遍地发生,我们可以做一点点简单。 我们可以让 $left
,$middle
和 $right
变量已经保存了添加所需的确切值:
my $left = 0;
my $middle = @field[0] * 3;
my $right = @field[1];
for ^field-width -> $x {
my $index = $left + $middle + $right;
@output.push: @lookup-table[$index];
$left = $middle * 3;
$middle = $right * 3;
$right = $x + 1 >= field-width ?? 0 !! @field[$x + 1];
}
我认为看起来很整洁!
32.7. 其他变化?
我遇到的一种细胞自动机是每个细胞都有机会在每一步上进行计算的细胞自动机,否则只需保持其状态一步。 让我们看看它是如何实现的:
constant probability = 0.75e0;
my $left = 0;
my $middle = @field[0] * 3;
my $right = @field[1];
for ^field-width -> $x {
if rand < probability {
my $index = $left + $middle + $right;
@output.push: @lookup-table[$index];
}
else {
@output.push: $middle;
}
$left = $middle * 3;
$middle = $right * 3;
$right = $x + 1 >= field-width ?? 0 !! @field[$x + 1];
}
较低的概率很容易被发现,因为它们会使得到的图像看起来垂直拉伸。 较高的概率可以导致完全规则的模式保持大部分完整,但在某些时候可以在一两个点被分解。
这是给你的截图!
!img
32.8. 这有用吗?
细胞自动机通常是非常通用的,甚至非常简单的自动机也可以处理通用计算,如"规则 110"。还有更复杂的自动机,如 Von Neumann 的能够自我复制的机器和 WireWorld,它已被用来构建 一台可以计算素数并在七段显示器上显示它们的小机器。
非常令人惊讶的是,有一台 图灵机带有一个由非常受欢迎的生命游戏构建的文字磁带,并且可能更令人惊讶的是,它可以计算并显示 生命游戏的生命游戏配置。
总而言之,我发现细胞自动机是一个引人入胜的话题。在这篇文章中几乎没有提到二维细胞自动机,但除了本节已经提到的那些之外,还有许多有趣的自动机。
实施方面,您很可能不会使用 CPU 代码来模拟细胞自动机。至少,你不会使用遍历每个单独单元的循环 - 请参阅奇妙的 HashLife 算法,该算法将世界切换为经常出现的越来越大的块,并立即执行许多全世界的步骤。否则,您很可能会在 GPU 上模拟 CA,当每个单元的代码运行相同时,它会提供极高的并行度。
感谢您通过这个非常长的帖子陪伴我!
我希望我甚至可以唤醒对细胞自动机的奇妙和广阔世界的空洞兴趣!
每个人都有一个可爱的十二月!
33. 第八天 — 让你的 Raku grammar 紧凑一点
欢迎来到今年的 Raku Advent Calendar 的第 8 天!
Grammars 是使 Raku 成为一种优秀编程语言的众多因素之一。 我甚至不会尝试预测轮询的结果,以便在 grammars,Unicode 支持,并发功能,超运算符或集合语法之间进行选择,或者选择 Whatever star。 谷歌发现了自己在互联网上发布的最好的 Raku 功能列表。
无论如何,今天我们将讨论 Raku grammars,我将分享一些技巧,用于使 grammars 更紧凑。
33.1. 1.拆分 actions
假设您正在编写 grammar 来解析 Perl 的变量声明。 您希望它与以下语句匹配:
my $s; my @a;
它们都声明了一个变量,因此我们可以制定一个通用规则来解析这两种情况。 下面是完整的程序:
grammar G {
rule TOP {
<variable-declaration>* %% ';'
}
rule variable-declaration {
| <scalar-declaration>
| <array-declaration>
}
rule scalar-declaration {
'my' '$' <variable-name>
}
rule array-declaration {
'my' '@' <variable-name>
}
token variable-name {
\w+
}
}
class A {
has %!var;
method TOP($/) {
dd %!var;
}
method variable-declaration($/) {
if $<scalar-declaration> {
%!var{$<scalar-declaration><variable-name>} = 0;
}
elsif $<array-declaration> {
%!var{$<array-declaration><variable-name>} = [];
}
}
}
G.parse('my $s; my @a;', :actions(A.new));
我不解释这个程序的每一点; 如果您有兴趣,可以在最近的 Amsterdam.pm 会议上观看 80 分钟的视频。
现在感兴趣的对象是规则 variable-declaration
及其相应的 action。
该规则包含两个选项:是否声明了标量或数组。 该 action 还在选项之间进行选择,并使用 if-else
块执行该 action 操作。 Raku 允许你省略布尔条件周围的括号,但是,整个结构仍然很大。 例如,想想如果添加哈希声明,则需要添加另一个 elsif
分支。
为每个子分支分别采取 action 操作会更清楚:
method scalar-declaration($/) {
%!var{$<variable-name>} = 0;
}
method array-declaration($/) {
%!var{$<variable-name>} = [];
}
现在,每个方法的主体包含单行代码,你可以立即看到它正在做什么。 更不用说它变得不那么容易出错了。
在我们继续讨论下一个技巧之前,你可能需要实现另一个优化:my
关键字出现在任一声明中,因此请使用非捕获括号并将公用字符串从它们之中移出:
rule variable-declaration {
'my' [
| <scalar-declaration>
| <array-declaration>
]
}
rule scalar-declaration {
'$' <variable-name>
}
rule array-declaration {
'@' <variable-name>
}
33.2. 使用 multi 方法
让我们改进 grammar 以允许使用目标语言进行赋值:
my $s; my @a; $s = 3; $a[1] = 4;
请注意,赋值是以 Perl 5 样式完成的,数组元素为 sigil。 有了这个,可以使用以美元开头的单个规则来完成赋值:
grammar G {
rule TOP {
[
| <variable-declaration>
| <assignment>
]
* %% ';'
}
# . . .
rule assignment {
'$' <variable-name> <index>? '=' <value>
}
rule index {
'[' <value> ']'
}
token value {
\d+
}
}
因此,assignment
action 操作必须推断出它目前正在处理的赋值类型。
同样,您可以使用我们的老朋友,action 操作中的 if-else
块。 根据索引的存在,您可以确定这是一个简单的标量还是数组的元素:
method assignment($/) {
if $<index> {
%!var{$<variable-name>}[$<index><value>] = +$<value>;
}
else {
%!var{$<variable-name>} = +$<value>;
}
}
此代码也可以轻松简化,但这次使用 multi 方法:
multi method assignment($/ where !$<index>) {
%!var{$<variable-name>} = +$<value>;
}
multi method assignment($/ where $<index>) {
%!var{$<variable-name>}[$<index><value>] = +$<value>;
}
where
子句允许 Raku 决定哪个候选方法在给定情况下更适合。
另请注意在第二个 multi 方法中如何使用 <value>
键两次。 <value>
的每个条目指的是目标代码的不同部分:一个用于索引值,另一个用于右侧值。
33.3. 3. 让 Perl 完成这项工作
有时,Perl 可以为我们完成工作,特别是如果你想实现 Perl 熟悉的东西。 例如,让我们在赋值中允许不同类型的数字:
my $a; my $b; $a = 3; $b = -3.14;
在 grammar 中引入浮点数比较容易:
token value {
| '-'? \d+
| '-'? \d+ '.' \d+
}
您想添加其他类型的数字,请参阅 perl.com 上的文章。 现在,我们可以用上面两个选项限制 grammar,因为这足以阐明这个技巧。
如果您使用更改运行代码,您可能会对获得所需结果感到惊讶。 两个变量都接收值:
Hash %!var = {:a(3), :b(-3.14)}
在这两种情况下,都触发了相同的 action 操作:
multi method assignment($/ where !$<index>) {
%!var{$<variable-name>} = +$<value>;
}
在赋值的右侧,我们看到 +$<value>
,这是从 Match 对象转换为数字的类型。 grammar 将 3
或 -3.14
放在 $<value>
中,两者都作为字符串。 +
这个一元运算符尝试将字符串转换为数字。 两个字符串都是有效数字,因此 Raku 不会抱怨。
自己编写代码将字符串转换为数字会更加困难,因为需要考虑数值的所有不同形式。 要了解 Raku 知道的其他格式,请查看 Raku grammar 中 numish
标记的定义:
token numish {
[
| 'NaN' >>
| <integer>
| <dec_number>
| <rad_number>
| <rat_number>
| <complex_number>
| 'Inf' >>
| $<uinf>='∞'
| <unum=:No+:Nl>
]
}
如果您在自己的 grammar 中允许任何上述类型,Perl 将能够为您转换它们。
33.4. 4. 使用 multi-rules 和 multi-tokens
它不仅是方法,也可以是 multi-things。 grammar 的规则和标记也是方法,您也可以创建它们的多个变体。
让我们更新我们的 grammar,以允许在赋值的右侧使用数学表达式:
my $a; $a = 6 + 5 * (4 - 3);
这里的新问题是解析表达式并处理运算符优先级和括号。 您可以通过以下方式描述任何表达式:
1、表达式是由 +
或 -
分隔的项的序列。
2、上一个规则中的任何项都是由 *
或 /
分隔的项的序列。
3、括号内的任何内容都是另一个表达式,因此请转到规则 1。
话虽如此,您最终会得到以下 grammar 变更:
grammar G {
# . . .
rule assignment {
'$' <variable-name> <index>? '=' <expression>
}
multi token op(1) {
'+' | '-'
}
multi token op(2) {
'*' | '/'
}
rule expression {
<expr(1)>
}
multi rule expr($n) {
<expr($n + 1)>+ %% <op($n)>
}
multi rule expr(3) {
| <value>
| '(' <expression> ')'
}
# . . .
}
这里,rules 和 tokes 都是 multi 方法,它采用反映表达式深度的单个整数值。 操作符也是如此:在第一级,你期望 +
和 -
,在第二级 - *
和 /
。
不要忘记 Raku 中的 multi 方法(以及 multi-subs)可以基于常量进行调度,这就是为什么你可以, 例如, 使用你在 multi token op(2)
中看到的签名。
expr($n)
规则通过 expr($n + 1)
递归定义。 $n
达到 3 时递归停止,Raku 选择最后一个候选 multi rule expr(3)
。
让我懒惰,并使用以前的建议让 Perl 计算表达式:
multi method assignment($/ where !$<index>) {
use MONKEY-SEE-NO-EVAL;
%!var{$<variable-name>} = EVAL($<expression>);
}
一般来说,我建议只在神奇的圣诞节期间使用 EVAL
。 在今年余下的时间里,请自己计算表达式并使用抽象语法树和 make
和 made
方法对儿保存部分结果。 例如,请参阅此处的 示例。
我还建议一些额外的阅读,以便更好地了解如何使用 multi
和 proto
关键字:
此时此刻,令人惊叹的 Raku grammar 之旅就要结束了。 你可以在 GitHub 上找到今天帖子的完整例子。 祝你读完其余的 Perl Advent Calendars,祝你愉快!
34. 第九天 - Raku 中的常量
我自豪地告诉人们在 伦敦 Perl 工作室前一天我写了我的第一个 Raku 程序(也就是一个能工作的程序)。 所以,JJ 说:"为什么不为 Raku 写一个 Advent 日历帖?"
我名下只有区区一个程序,我该写些什么呢? 嗯……我在 Perl 5 中创作了 Astro::Constants,那么将它迁移到 Raku 有多难?
话匣子打开就关不上了,我给你讲一个 Perl 5 模块作者在 Raku 的领地中徘徊的故事。
如果在 第 5 天你被"诊断"为常数,那么你正好需要今天的帖子。
我们习惯使用变量来计算东西和持有东西。 随着我们获得更多"东西",总量会发生变化。 常量是那些永远不会改变的值。 像一天中的秒数或光速。 当然,我们在计算中使用它们就像使用变量一样,但是你不想通过赋值意外地改变常量
$SPEED_LIGHT = 30;
甚至意外地当你打算测试它是否等于某个值时,就像这样
if ( $SECONDS_IN_A_DAY = $seconds_waited) {
当你真正想的是
if ( $SECONDS_IN_A_DAY == $seconds_waited) {
在这些情况下,你希望编译器说"对不起,戴夫。 恐怕我不能这样做。" Perl 编译器关闭了。 如果你尝试运行第一行,你会得到
Cannot modify an immutable Num (299792458)
in block at im_sorry_dave.p6 line 12
单击此处获取 完整解释
34.1. 如何制作一个常数
要使变量保持不变,请使用 constant
关键字声明它。
constant $tau = pi * 2;
嘿! sigil 是可选的,所以我可以使用我最喜欢的样式进行常量声明:
my constant SPEED_LIGHT is export = 2.99792458e8;
为什么要使 List 保持不变? 一年中的月份是列表吧, 它阻止这样:
my constant @months = ;
...
@months[9] = 'Hacktober'; # changing a name
push @months, 'Hogmannay'; # we'd all like more time after Christmas
如果你尝试其中任何一个,你得到
Cannot modify and immutable Str (D) # error for the assignment
# or
Cannot call 'push' on an immutable 'List' # error for the push
顺便说一句,tau
,pi
,e
和 i
都是 Raku 中的内置常量,以及它们的 Unicode 等价物,τ
,π
和 𝑒
。 似乎你可以使用 无 sigil 变量获得相同的行为但是今天暂且不表。
34.2. 从模块导出常量
如果你要在代码中反复使用相同的常量,那么将它们放在一个模块(一个单独的文件)中并将其加载到程序中是有意义的。 现在我不得不在这里加一些土鳖编程,但这对我有用,我会尽力解释它的能力。
use v6;
unit class Astro::Constants:ver<0.0.1>:auth;
my constant SPEED_LIGHT is export = 2.99792458e8;
...
第 1 行 - 轻松入手。use v6;
告诉编译器这是一个 Raku 程序。等等!我不需要它。这只是编写程序的一个习惯。我可以摆脱它。
第 2 行
第 N 行 my
词汇范围; constant
使其成为只读; SPEED_LIGHT
是常量的名称; is export
允许常量 在模块外部使用,即在代码中使用; 2.99792458e8
只是 Perl 表达 2.99×10⁸
的方式。
…为了完整起见,加上版本方法和一些 文档来完成模块怎么样:
method ver { v0.0.1 }
=begin pod
=head1 DESCRIPTION
A set of constants used in Physics, Chemistry and Math
=end pod
将常量放入模块的一个副作用是它们 在编译时计算。它应该使您的代码运行得更快,但编译后的模块仍然存在。 这对于常量非常有用,但如果您的模块包含您可能想要更改的内容,则需要重新编译它。
34.3. 在程序中使用模块
一旦你有了一个模块,你如何在程序中使用它?
在这个例子中,我创建了一个目录 mylib/Astro
,并将该模块放在一个名为 mylib/Astro/Constants.pm6
的文件中。 这是我的程序:
use v6;
use lib ;
use Astro::Constants;
say "speed of light =\t", SPEED_LIGHT;
它起作用了! 解释一下前 3 行:use v6
说使用 Raku; use lib
表示把路径添加到库搜索路径; use Astro::Constants
表示在库路径中搜索文件 Astro/Constants.pm6
并加载它。
34.3.1. 我必须做这一切吗? ……不。
为什么要重新发明轮子? 前面提到的 JJ 有 以前的常量形式,但你需要一个包管理器来完成它的安装工作。 在 Fedora 28 中,使用 dnf install rakudo-zef
来安装包管理器 zef。 然后,您可以搜索任何处理常量的模块。运行
zef search Constants
将为您提供至少 15 个在生态系统中注册的软件包,并非所有软件包都是你正在寻找的软件包。您可以立即开始使用 zef install Math::Constants
并使用 JJ 的模块,或者您可以使用搜索来查看我是否已经找到时间上传我的尝试(当时可能名为 *Physics::Constants),即将于 2019 年发布。
34.4. 最后,对代码维护进行了一些注释
对我来说,代码维护是科学编程中最重要的考虑因素。想想那位走进美学院门口的新科学专业的学生,并在第 1 天交给你维护的代码。在第 20 天保证,他们被要求做出改变。为了他们的缘故,我喜欢为了清晰而不是简洁而写作,因为科学中存在如此多的重载符号。因此,我对将符号投入计算时很谨慎。也许我什么都不担心,但找到答案的唯一方法就是去做,看看是否有害。
我现在发生的一种可能性是能够指定你所指的常数。这个组成的例子看起来有点像 Python。可能值得偷窃。
import GRAVITATIONAL as G;
...
$F_between_two_bodies = G * $mass1 * $mass2 / $distance**2;
我将在圣诞节阅读 Raku Deep Dive,我会告诉你我明年的表现。
快乐地搞科学!
35. 第十天 - 跳转, 开启你的工作流
这是另一个版本的 jmp 供你在圣诞节前解开。
jmp 是一个命令行工具,用于搜索成堆的代码,然后快速跳转到 $EDITOR
。这有助于 保持流动。
然而,计算机编程具有许多潜在的 流阻塞器。要在编码时保持流状态,通常需要在其他情况下快速跳转(jmp)到一行代码:修复错误,运行测试,检查日志文件,检查 git 状态等. jmp 也可以帮助加速这些任务吗?
幸运的是,重构 jmp 以容纳这些额外的使用场景相对容易。最新版本的 jmp 现在的工作原理如下:
!img
在命令前加上 jmp 将导致命令再次执行,并且其输出会被吞噬并被分页。
#| find matching lines
method find-files-in-command-output ($command) {
# execute the command
my $shell-cmd = shell $command, :out, :err;
# join STDOUT and STDERR
my $result = join("\n", $shell-cmd.out.slurp,
$shell-cmd.err.slurp);
# don't actually look for filenames just yet
# do that lazily on demand by the user
return $result.lines.map({
JMP::File::HitLater.new(context => $_)
});
}
jmp 为每一行创建结果提示,并对结果进行分页。然后,如果需要,您可以快速浏览输出并将 jmp 导入文本编辑器(请参阅 jmp config 以将其更改为您最喜欢的编辑器)。
速度对命令行工具很重要。jmp 只在用户选择编辑特定行时查找文件名, 而不是提前扫描每一行的文件名。这是懒惰地解析查找文件的行的代码:
submethod find-file-path {
given self.context {
# matches Perl 5 error output (e.g., at SomePerl.pl line 12)
when /at \s (\S+) \s line \s (\d+)/ {
proceed unless self.found-file-path($/[0], $/[1]);
}
# matches Raku error output (e.g., at SomeRaku.p6:12)
when /at \s (<-[\s:]>+) ':' (\d+)/ {
proceed unless self.found-file-path($/[0], $/[1]);
}
# matches Raku error output (e.g., SomeRaku.p6 (Some::Raku):12)
when /at \s (<-[\s:]>+) '(' \S+ ')' ':' (\d+)/ {
proceed unless self.found-file-path($/[0], $/[1]);
}
# more file finding patterns HERE - PR's welcome?
# go through each token
default {
for self.context.words -> $token {
# keep trying to set the file path
proceed if self.found-file-path($token);
}
}
}
}
when 块匹配不同类型的错误格式(例如,Perl 5 和 Raku)以提取文件名和行号。 proceed 语句对于移动到下一个 when 块非常有用。
这意味着你可以跳转(jmp)到工作流程中出现错误的任何位置:例如命令行的输出,测试输出,日志文件等。
要升级到 jmp 的第 3 版:
shell> zef upgrade jmp
shell> zef install jmp # install the jmp command line tool
shell> jmp config # set up jmp to use your tools
shell> jmp to sub MAIN # find files containing "sub MAIN"
圣诞节前还有更多工具可以打开。在第 17 天再次与你联系,获取有助于您的非编码工作流的工具。
36. 第十一天 - 使用 Raku 测试你的时刻表
这几乎是在北极附近的精灵小学冬季学期结束之时。对于精灵而言,敏锐的人物头脑非常重要,而小精灵的数学老师 Hopper 女士希望确保他们在任期的倒数第二天保持他们的算术技能。(学期的最后一天保留用于观看电影和玩耍 - 很嗨皮)。
小精灵刚刚学会了他们的时间表(乘法表),最多 12 个,但他们并不像他们所想的那样擅长,其中一些人在圣诞节前将在玩具工作坊帮忙,那时候他们可能需要快速告诉大精灵有多少特定类型的玩具。
幸运的是,Elf Hopper 是一个非常聪明的精灵,拥有出色的数学和编码能力 - 自己。所以她起了一个快速的控制台应用程序来运行小精灵的学校颁发的 Perlix 6.0 boxen。
该程序允许小精灵通过运行它们在 2 至 12 次表中测试自己,或者如果它们提供单个数字参数,他们可以尝试任何他们喜欢的乘法表。
my $fixednum;
my %score;
my @exits = <exit quit>;
$fixednum = @*ARGS[0] || 0;
put "Type the answer, or quit or exit to end the test.";
loop {
my $coefficient = (2..12).pick;
my $number = $fixednum || (2..12).pick;
my $answer = prompt ( "$coefficient × $number = " );
my $rightanswer = $coefficient × $number;
last if $answer.lc ~~ any @exits;
if $answer == $rightanswer {
put "Correct!";
%score<correct>++;
} else {
put "Sorry, that wasn't right! The answer is $rightanswer";
}
%score<total>++;
}
if %score<total>:exists {
my $pc = 100 * %score<correct> / %score<total>;
put "You scored %score<correct> out of %score<total>, i.e. ", sprintf "%2.2f%%.", $pc;
}
Elf Hopper 向其它小精灵解释代码如下。
"可爱的小精灵们!这是关于程序如何工作的一些背景知识。
我在顶部附近添加了 use v6;
,以便代码也可以在 Perl 5 下 运行,它将自动使用 Raku 仿真器。
您将看到程序在特殊的 @*ARGS
数组的命令行上运行时选择一个可选参数。这是 Perl 5 的 @ARGV
数组的 Raku 等价物。
在 Raku 中,数组,数组元素和数组切片总是使用 @
sigil,与 Perl 5 不同,其中各个数组元素使用 $
sigil。同样,哈希和哈希元素现在总是带有 %
sigil,无论是整个哈希,它的切片还是单个元素都在使用。
那里还有另一个符号,星号 'twigil',*
。这表明 @*ARGS
是一个动态特殊变量。
prompt
和 loop
关键字是 Raku 中的新功能,两者都是令人钦佩的名字!
-
prompt
只返回用户输入的值,在本例中为变量。 -
loop
是 Raku 的新块控制关键字之一。像这样的简单循环块只是创建了一个无限循环,可以由程序员以显式的方式结束,例如在满足条件时使用last
关键字;或者例如由用户手动终止程序。 -
或者,循环可以采用三个参数,并且表现得像传统的 C 风格
for
循环。 (在 Raku 中,小精灵,for
只用于迭代列表或其他容器。)
在循环内部,小精灵,你可以看到范围对象。一切都是 Raku 中的一个对象,所以我可以在范围上调用 pick
方法来返回一个随机数。(好吧,无论如何,这是一个非加密安全的伪随机数!)
any
关键字将 @exits
数组转换为 Raku 中许多新的有用数据结构之一:一个 Junction。这使得使用 smartmatch 运算符 ~~
直截了当地找到一个数组元素。last
关键字退出循环,如 Perl 5 中所示。
Junction 是一种新类型的容器或列表,允许进行许多最有用的比较,例如 any
,all
,one
或 none
,我们在 Perl 5 中使用 grep
或 List::Util
等模块进行比较,但它使它们成为可能键入更容易,并允许它们同时执行!这样做的一个副作用是 junction 是无序的:但它们主要是为了产生单个真值或假值,所以通常都可以。如果你想快速想要根据一些特定值的简短列表检查一个值,那么 junction 就很棒。但是如果需要的话,它们还能够让你匹配更大的值集。
在最后一个代码段落的顶部,您将看到 :exists
应用于 %score<total>
哈希键。:exists
是一个 Raku 副词!副词通常会修改方法的工作方式。:exists
是下标副词。它是一个副词,因为它改变了读取哈希键时发生的事情:不是返回值,而是查明值是否存在。这通常是使用 Perl 5 类中关注的那些熟悉的已定义方法的更好的替代方法。
当然,exists
测试就是为了确保在用户第一次退出程序时不会出现错误。
为什么我使用锯齿形符号来引用哈希键?好吧,花括号 {}
是散列键的标准下标运算符,如 Perl 5. 但是,大多数情况下你可能想要使用尖括号/锯齿形符号,因为它们提供单字键的自动引用。用于在 Perl 5 中执行此操作的简单 {}
括号,但在 Raku 中不再执行此操作。需要引起括号内的字符串。
在 Raku 中,放置了用于向终端输出文本的标准命令。这将输出以下列表项,然后是换行符。say
和 print
仍然可用; say
和 put
这两个都打印到标准输出并添加换行符; print
不会追加换行符。
如果你使用这个程序以及知道它是如何工作的,小精灵,你将有选择和知识,是否,何时以及如何在你自己的头脑中执行乘法,以及什么时候最好让 Raku -有动力的电脑做它。您的圣诞节作业是让您自己熟悉 https://docs.raku.org 上的 Raku 文档。"
小精灵开始使用乘法测试程序,试图超越对方以获得最高的完美分数。他们发现它如此令人上瘾,以至于比赛甚至会加时到在学校的最后一天,当时他们应该正在观看’精灵'!
37. 第十二天 - 构建灵活的 grammar
圣诞老人夫人写了一个基础的 Grammar,以配合 GDPR 无知精灵从世界各地收集的有关今年 naughty 或 nice 的人的简单列表。
每个记录都是一个名称,后跟一个标签,后跟一个地址,然后是一个标签,然后是 naughty 或 nice 的评估,然后用换行符结束。
Batman 1 Batman Street, Gotham Nice
Joker 5 Joker Street, Arkham Naughty
Riddler 12 Riddler Street, Arkham Naughty
Robin 2 Robin Street, Gotham Nice
她希望将 naughty 的人排除在一个列表中,将 nice 的人过滤到另一个列表中,因为 Krampus 将在今年处理所有 naughty 的人。
S.夫人用这样的 grammar 开头:
grammar naughty-nice-list {
token TOP { <details>+ } # Find one or more records made up of name, address, assessment (defined below)
token details { <name> <address> <assessment> } # Find the elements from below, in this order
token name { .*? \t } # Find any characters up to the earliest tab
token address { .*? \t } # Find any characters up to the earliest tab
token assessment { Naughty|Nice \n } # Find either 'Naughty' or 'Nice' followed by a newline
}
并在列表上调用它,如下所示:
naughty-nice-list.parsefile("./list.txt");
但是,当然,她必须做一些事情将细节放入单独的列表中。
为此,她创建了一个 action 动作类:
class santa-list-actions {
has %!filtered-lists; # Create a private hash for this class
method show { return %!filtered-lists } # Create a method to return our hash to the user
# This method is automatically called when the token with the same name makes a match
method details ($/) {
# Create an array of just the name and address matches converted to strings
my @details.push($<name>.Str, $<address>.Str);
# Push the @details array into an array accessed with the 'Naughty' or 'Nice' key
# Note the curly braces to interpolate { $ } instead of .
# Otherwise we would get literally what we typed for the hash key.
%!filtered-lists{ $<assessment>.Str }.push(@details);
};
};
她这样使用:
my $actions = santa-list-actions.new;
naughty-nice-list.parsefile("./list.txt", actions=>$actions); # As Mrs S. called the object 'actions', the same as the keyword, she could write :$actions instead of actions=>$actions
my %hash-naughty-nice = $actions.show;
圣诞老人非常开心,她现在有一个哈希表,其中包含 'Naughty' 和 'Nice' 的键,每个键都包含一系列每个人的详细信息。
但是钓鱼洞里总是有一只北极熊爪子,尽管有圣诞老人的保证,来自世界各地的精灵们并不只是 "Naughty" 或 "Nice"。他们用自己的语言说出来!
圣诞老人特别问过,但圣诞老人坚持不懈。只有 'Naughty' 或 'Nice'。但有些列表看起来像这样。
Batman 1 Bat Street, Gotham Nice
Joker 5 Joker Street, Arkham Naughty
Riddler 12 Riddler Street, Arkham Naughty
Robin 2 Robin Street, Gotham Nice
El Gato Negro 1 Gato Street, South Texas Bueno
Victor Mancho 3 Mancho Street, New York City Malo
圣诞老人简单地认为只是对新词进行硬编码,但她知道这不是懒惰的时候。世界各地都有精灵,她需要能够进化的东西。
所以,为了现在调用她的脚本,她创建了两个数组并将它们传递给 grammar:
my @nice = ['Nice', 'Bueno'];
my @naughty = ['Naughty', 'Malo'];
naughty-nice-list.parsefile("./list.txt", args=>(@nice, @naughty), actions=>$actions);
她改变了这样的 grammar 来使用新的数组:
grammar naughty-nice-list {
token TOP (@*nice-words, @*naughty-words) { <details>+ } # Create dynamic arrays with the passed in arrays, available throughout the grammar
token details { <name> <address> <assessment> }
token name { .*? \t }
token address { .*? \t }
token assessment { @*naughty-words|@*nice-words \n } # Find either a word from the naughty-words array or from the nice-words array followed by a newline
}
但是 S.太太意识到她现在最终会在她的 action 动作类中的哈希表中创建许多不同的键。 键将是 'Nice','Naughty','Bueno' 或 'Malo',因为这些将是 $
可能拥有的匹配单词(未来有更多可能出现)。
因此,她进行了另一项更改,为评估令牌命名语法中的潜在匹配:
token assessment { $<Naughty>=@*naughty-words|$<Nice>=@*nice-words \n } # Mrs S. has now added names to the potential matches
在 action 动作类中,必须进行更改以适应这种情况。使用 make
和 made
,圣诞老人将存储相应匹配的名称:
class santa-list-actions {
has %!filtered-lists;
method show { return %!filtered-lists };
method details ($/) {
my @details.push($<name>.Str, $<address>.Str);
%filtered-lists{ $<assessment>.made }.push(@details); # This will now use the value from 'assessment.made' as the key, rather than the match in 'assessment.Str'
};
method assessment ($/) {
if $<Naughty> { # If the named pattern 'Naughty' matched...
make "Naughty" # ... set assessment.made to "Naughty"
} elsif $<Nice>; { # Or if the named pattern 'Nice' matched...
make "Nice" # ... set assessment.made to "Nice"
};
};
};
一旦圣诞老人将数据捕获到她自己的哈希中,她就可以轻松地检查出今年已经被马洛的 Victor Mancho 将其列入正确的列表:
say %hash-naughty-nice<Naughty>[2][0]; # Produces the output 'Victor Mancho'
所以现在,圣诞老人可以将 "Naughty" 或 "Nice" 的任何新翻译添加到相关数组,而不会触及 grammar。
圣诞老人发现自己对 Raku grammar 的灵活性非常满意。圣诞老人对这个问题的研究起初不那么重要……但是她知道她在确保每个人都能得到一个礼物或者在这个圣诞节的窗户上扔蛋的方法上做得很好。
38. 第十三天 - 使用 Cro 和 Debian 从头构建 Web 服务
我和圣诞老人谈过,他说他不知道如何在 Debian 上安装 Cro,所以我对自己说:我要帮助他。
如果您对 Apache 等 Web 服务器有一些经验,并且您已经听说过 Raku 强大的并发/响应方面,那么您肯定有兴趣了解 Cro 服务。这篇文章的受众是具有 Debian 基本经验的人,或者在 Raku 新手…就像圣诞老人一样。
Cro 是一个 Raku 模块,它提供了轻松构建反应式和并发服务所需的一切,例如:Web 服务器。
在这篇文章中,我们将看到如何在 Debian 中安装 Cro,这是最受欢迎的 Linux 发行版之一。然后,我将解释 Cro 的演示示例。
我们将使用 Debian 9,64 位(Stretch),我们将在安装后启动它。
38.1. 安装 Rakudo Raku 编译器
Rakudo 是 Cro 模块运行的当前 Raku 编译器。安装 Rakudo 的常规方法是安装 Rakudo Star,但我更喜欢快速的方式:rakudo-pkg ……怎么样?只需从此 repo 下载并安装相应的 Debian 软件包。在我们的例子中,是来自 Debian 9, 64 位的 .deb 文件。
使用 Debian 中的 root 用户,我们可以在 root home 中为 Rakudo 创建一个包文件夹,进入这个目录,下载 Debian 9, 64 位的当前 Rakudo 包,并安装它。就我而言:
mkdir ~/rakudo-packages && cd $_
wget https://github.com/nxadm/rakudo-pkg/releases/download/v2018.10-02/rakudo-pkg-Debian9_2018.10-02_amd64.deb
dpkg -i rakudo-pkg-Debian9_2018.10-02_amd64.deb
Rakudo 运行时编译器和相关工具现在安装在 /opt/rakudo-pkg 文件夹中。在 export PATH 行之前,让所有用户在 /etc/profile 文件中插入以下行:
PATH=$PATH:/opt/rakudo-pkg/bin
最后运行:
source /etc/profile
为所有用户重新加载 Debian 配置文件。
输入 raku -v:
raku -v
This is Rakudo version 2018.10 built on MoarVM version 2018.10
implementing Raku.c.
欢迎来到 Rakudo Raku!
38.2. 安装 Cro 服务
Cro 是 Raku 模块,Zef 是已经安装的当前 Raku 模块管理器。我们来安装 Cro 吧!
首先,我们将安装一些 Cro 包依赖项,例如 git 来连接和下载来自 Cro 相关存储库的文件,libssl-dev 以提供对安全套接字层的支持,build-essential 用于构建在安装期间 Cro 所使用的一些依赖项:
apt-get install git libssl-dev build-essential
如果您位于仅允许 Web 流量(端口 80 和 443)的防火墙下,它将阻止 git 协议使用的端口,并且 Cro 安装将失败。要避免这种情况,请键入:
git config --global url."https://".insteadOf git://
这告诉 git 使用 https 而不是 git 协议来连接 git 远程仓库。
现在我们准备用 Zef(及其所有依赖项)安装 Cro:
zef install cro
如果在测试阶段安装失败,请尝试:
zef install --/test cro
如果 Cro 安装正确完成,Cro 就准备好了。
38.3. Cro 实战
让我们继续演示脚本。创建一个工作区文件夹,输入它并将下面的代码粘贴到名为 server.p6 的新文件中:
use Cro::HTTP::Router;
use Cro::HTTP::Server;
my $application = route {
get -> 'greet', $name {
content 'text/plain', "Hello, $name!";
}
}
my Cro::Service $hello = Cro::HTTP::Server.new(:host<0.0.0.0>, :port<10000>, :$application);
$hello.start;
react whenever signal(SIGINT) { $hello.stop; exit; }
这个脚本有 4 个部分:
"use"使路由和服务类可用于下面。
$application 是包含我们的 Web 应用程序的路由逻辑的对象,接收数据并将数据从客户端分发到我们的服务器。在这种情况下,get →'greet',$name 映射来自客户端 Web 浏览器的传入 URL /greet/Ramiro,在对象 $name 中推送 Ramiro。然后将代码转换为花括号 {},返回响应 HTTP 请求头 content 'text/plain' 和 HTTP 请求体 Hello, Ramiro! 到客户端 Web 浏览器。在一个严肃的应用程序中,在这部分中将会调用应用程序本身,并且它将等待响应,如示例所示。
$hello 是一个服务对象,它使传入的数据通过新的侦听套接字传递给我们的 $application。它有 3 个参数,:host<0.0.0.0> 是服务启动的 localhost, :port<10000> 是用于监听传入数据的端口,$application 是我们的 Web 应用程序。 下面的行 $hello.start 开始侦听。
react whenever 等待按下 CTRL-C 时停止 Web 服务。
现在是从 shell 运行 Web 服务的时刻:
raku server.p6
现在您需要知道服务器主机的当前 IP 地址,尝试使用 ip addr。我的 Ip 地址是: 192.168.1.48。
然后,从同一网络中的其他主机,打开 Web 浏览器并键入(在我这儿):
http://192.168.1.48:10000/greet/Ramiro
答案应该是 Hello, Ramiro!
38.4. 总结
从 Debian 的新安装开始,我们已经了解了如何安装 Cro 并运行演示脚本。现在,您已准备好继续使用 Cro 文档,并发现 Raku 可提供的最有前途的并发/异步服务平台。
我希望在阅读这篇文章并查看 Cro 文档后,圣诞老人可以将他的网站迁移到 Cro Services。
39. 第十四天 - 使用 Raku 设计(小)航天器
39.1. 寻找共同点
大家好!
那些日子我花了一些时间在基础部件上工作,揭示了可能的惊喜,Raku 的 LDAP(轻量级目录访问协议)实现。
然而,现在谈论这个还为时尚早,所以我现在将有一些神秘的封面覆盖这个话题,因为我们有另一个 - 宇宙飞船!
航天器和 LDAP 之间的共同点是:LDAP 规范使用一种称为符号的符号 ASN.1
,它允许使用特定的文本语法定义抽象类型,并在 ASN.1
编译器的帮助下,为特定的编程语言创建类型定义,以及什么是更多:此类型值的编码器和解码器,可以将您的值序列化为某些数据,例如,可以通过网络发送并在另一台计算机上很好地解析。
通过这种方式,您可以轻松地在应用程序中获得跨平台类型。编码器和解码器可以自动生成,不仅针对某些指定的编码格式,而且针对整个范围的二进制(例如 BER
,PER
和其他)和文本(例如 SOAP
)编码格式。
因此,为了完成工作,我必须至少实现 ASN.1
Raku 中的一些子集- 不是完整的规范,这很大,只关注 LDAP 规范中使用的功能。
"这听起来很有趣,但我们的宇宙飞船在哪里!?",你可能会问。事实证明,这种 Rocket
类型是您在 ASN.1 Playground 网站上看到的第一件事,它让您可以免费访问 ASN.1
编译器,它可以作为参考!
39.2. ASN.1
和限制
这是花哨的代码:
World-Schema DEFINITIONS AUTOMATIC TAGS ::=
BEGIN
Rocket ::= SEQUENCE
{
name UTF8String (SIZE(1..16)),
message UTF8String DEFAULT "Hello World" ,
fuel ENUMERATED {solid, liquid, gas},
speed CHOICE
{
mph INTEGER,
kmph INTEGER
} OPTIONAL,
payload SEQUENCE OF UTF8String
}
END
让我们快速浏览一下这个定义:
-
Rocket
是一个SEQUENCE
- 一组某类型的有序值,可以看作是异构列表/数组或类。 -
场
name
和message
有UTF8String
型,这是肯定的,一种字符串表示的ASN.1
。字段 name 已应用长度限制,(SIZE(1..16))
和message
具有指定的默认值DEFAULT "Hello World"
。 -
字段
fuel
有ENUMERATED
类型:它只是一个可供选择的标签枚举。 -
字段
speed
是一个CHOICE
,它是一种特殊类型,它描述了一个字段,该值可以是指定类型之一。不同的是ENUMERATED
,价值不仅仅是标签。OPTIONAL
如你所知,关键字意味着如果不存在,该字段可能会被省略。 -
字段
payload
是一个SEQUENCE
,但指定了类型。这意味着我们可以根据需要在这里拥有尽可能多的UTF8String
值。
这里我们将应用两个重要的限制:
-
我们将使用
Basic Encoding Rules(BER)
- 将ASN.1
类型编码指定为特定字节序列的规则。如上所述,有不同的格式,但我们将使用这一种。
Basic Encoding Rules
标准是基于一个所谓的"TLV 编码"的事情-的类型的值被编码为字节序列表示:" Ť AG"," 大号 ength"和" V 传递类型的某些值的 ALUE"。让我们更仔细地看一下……以相反的顺序!
"值"是包含值的字节表示的部分。每种类型都有自己的编码模式(例如,INTEGER
编码方式不同 UTF8String
)。
"长度"是表示"值"部分中的字节数的数字。这允许我们很好地处理增量解析(通常也是!)。它也可以具有"未知"值,这允许我们以未知的长度流式传输数据,但我们将把它放在一边。
"标签"简单地说是一个字节或一些字节,我们可以用它来确定我们手头有什么类型。其确切值由标记规则的数量("标记模式")确定,并且存在好的或更差的不同模式。
并且,如果您已经等待某些段落的第二个限制,那么它是:
我们将 IMPLICIT
在这里使用 BER 的类型标记模式。正如您所猜测的那样,EXPLICIT
标记模式也同时存在 AUTOMATIC
(在上面的 Rocket 示例中使用)。
考虑到这一点,我们需要将 ASN.1
上面的类型更改为:
World-Schema DEFINITIONS IMPLICIT TAGS ::=
BEGIN
Rocket ::= SEQUENCE
{
name UTF8String (SIZE(1..16)),
message UTF8String DEFAULT "Hello World" ,
fuel ENUMERATED {solid, liquid, gas},
speed CHOICE
{
mph [0] INTEGER,
kmph [1] INTEGER
} OPTIONAL,
payload SEQUENCE OF UTF8String
}
END
注意 IMPLICIT TAGS
用于代替字段中的 AUTOMATIC TAGS
和 [$n]
字符串 speed
。
如果你看一下这个模式,事实证明,这是,其实,暧昧,因为 mph
和 kmph
都有 INTEGER
型。因此,如果我们 INTEGER
从字节流中读取了一个,它是 mph
值还是 kmph
值?如果我们谈论宇宙飞船,它会产生巨大的变化!
为了避免这种混淆,使用了特殊的标签,这里我们指定了我们想要的标签,因为与 AUTOMATIC
模式不同,IMPLICIT
它不适用于我们。
39.3. 逐步建设。问题答案。
那么,我们可以用 Raku 中的所有功能做什么呢?虽然编译器可能很有趣,但是可以通过可扩展的方式编译成 Raku,并且包含了奇特的功能?必须有一些更简单的东西。
比方说,我们有一个适用于航天器的脚本。当然,我们需要一个类型来表示一个,特别是一个类,让我们称之为 Rocket
:
class Rocket {}
当然,我们想知道一些有关它的数据:
class Rocket {
has $.name;
has $.message is default("Hello World");
has $.fuel;
has $.speed;
has @.payload;
}
如果我们必须使我们的 Rocket
定义更明确,那么我们指定一些类型:
enum Fuel <Solid Liquid Gas>;
class Rocket {
has Str $.name;
has Str $.message is default("Hello World");
has Fuel $.fuel;
has $.speed;
has Str @.payload;
}
现在它开始提醒我们一些事情……
-
Str
类似UTF8String
,只是我们不能离开它这样,因为ASN.1
我们不仅有UTF8String
,而且BIT STRING
,OCTET STRING
和其他字符串类型。 -
Fuel
枚举类似于ENUMERATED
类型。 -
@.payload
中的@
符号告诉我们,这将是一个序列,而且Str
指定其元素的类型。 -
但是虽然有一些类似的观点,但从我们
ASN.1
的观点来看,我们没有足够的数据。让我们一步一步解决这些问题!
我们怎么知道这完全 Rocket 是
ASN.1
序列类型?
通过应用角色:class Rocket does ASNSequence
。
我们怎么知道确切的字段顺序?
通过实现此角色的存根方法:method ASN-order { <$!name $!message $!fuel $!speed @!payload> }
我们怎么知道这
$.speed
是可选的?
我们只是应用它的特征!Traits 允许我们在代码部分上执行自定义代码,特别是 Attributes
。例如,虚构的 API 可以是这样的:has $.speed is optional
。
我们怎么知道 $.speed 是多少?
由于 CHOICE
类型是"特殊的",但仍然是一流的(例如,你可以使它递归),我们需要在这里发挥作用:ASNChoice
来救援。
我们怎么知道 `ASN.1` 我们的 Str 类型是什么类型的字符串?
我们来写吧 has Str $.name is UTF8String;
。
我们如何指定字段的默认值?
虽然 Raku 已经具有内置 is default
特性,但对我们来说不好的是我们无法"很好地"检测到它。因此,我们必须引入另一个自定义特征,以满足我们的目的并应用内置特征:has Str $.message is default-value("Hello World");
让我们在一个包中回答所有这些问题:
role ASNSequence { #`[ Elves Special Magic Truly Happens Here ] }
role ASNChoice { #`[ And even here ] }
class SpeedChoice does ASNChoice {
method ASN-choice() {
# Description of: names, tags, types specificed by this CHOICE
{ mph => (0 => Int), kmph => (1 => Int) }
}
}
class Rocket does ASNSequence {
has Str $.name is UTF8String;
has Str $.message is default-value("Hello World") is UTF8String;
has Fuel $.fuel;
has SpeedChoice $.speed is optional;
has Str @.payload is UTF8String;
method ASN-order { <$!name $!message $!fuel $!speed @!payload> }
}
值可能类似于:
my $rocket = Rocket.new(
name => 'Falcon',
fuel => Solid,
speed => SpeedChoice.new((mph => 18000)),
payload => [ "Car", "GPS" ]);
答案越多,问题就越多
对于这个微小的例子(另一方面,它已经 ASN.1
展示了许多特性),实际上,我们需要在我们的应用程序中使用这个类的实例,并可能根据需要对其进行编码和解码。
那么精灵们对我们的数据秘密做了什么?让我们在下一篇文章中找到答案!
40. 第十五天 - 使用 Raku 构建(小型)航天器
40.1. 炫耀长耳朵
在 上一篇文章中,我们遇到了某种特殊精灵的魔力:
enum Fuel <Solid Liquid Gas>;
class SpeedChoice does ASNChoice {
method ASN-choice { { mph => (1 => Int), kmph => (0 => Int) } }
}
class Rocket does ASNSequence {
has Str $.name is UTF8String;
has Str $.message is default-value("Hello World") is UTF8String;
has Fuel $.fuel;
has SpeedChoice $.speed is optional;
has Str @.payload is UTF8String;
method ASN-order { <$!name $!message $!fuel $!speed @!payload> }
}
my $rocket = Rocket.new(
name => 'Falcon',
fuel => Solid,
speed => SpeedChoice.new((mph => 18000)),
payload => [ "Car", "GPS" ]);
my $rocket-bytes = ASN::Serializer.serialize($rocket, :mode(Implicit));
#`[ Result:
Blob.new(
0x30, 0x1B, # Outermost SEQUENCE
0x0C, 0x06, 0x46, 0x61, 0x6C, 0x63, 0x6F, 0x6E, # NAME, MESSAGE is missing
0x0A, 0x01, 0x00, # ENUMERATED
0x81, 0x02, 0x46, 0x50, # CHOICE
0x30, 0x0A, # SEQUENCE OF UTF8String
0x0C, 0x03, 0x43, 0x61, 0x72, # UTF8String
0x0C, 0x03, 0x47, 0x50, 0x53); # UTF8String
]
say ASN::Parser.new(:type(Rocket)).parse($rocket-bytes) eqv $rocket; # Certainly true!
40.2. 类型,类型,类型
有些事情是不言而喻的(或者对于我来说,用了无数个小时来看精灵如何玩魔法)
# 1
role ASNSequence {
# every descendant has to fulfill this important vow!
method ASN-order {...}
}
# 2
role ASNChoice {
has $.choice-value;
# if you have to choose, choose wisely!
method ASN-choice() {...}
method ASN-value() { $!choice-value }
method new($choice-value) { $?CLASS.bless(:$choice-value) }
}
# 3
role ASN::StringWrapper {
has Str $.value;
# Don't do this at home. :]
method new(Str $value) { self.bless(:$value) }
}
# UTF8String wrapper
role ASN::Types::UTF8String does ASN::StringWrapper {}
# Yes, it is _this_ short
multi trait_mod:(Attribute $attr, :$UTF8String) is export { $attr does ASN::Types::UTF8String }
-
第一个是一个简单的角色,它允许我们强制执行
ASN-order
方法 -
第二个是持有 CHOICE 实际值的角色,并强制执行用户必须描述可能选项的方法
-
第三个描述了一个特性,如
is UTF8String
,它为属性添加一个角色,这将在以后帮助我们,并提供角色本身以及一些包装代码
与第三部分表达的方式相同,可以表达 OPTIONAL
,DEFAULT
"traits"和其他字符串类型。
40.3. 进步,进化,序列化!
通过一系列规则可以做什么来序列化事物?鉴于 Basic Encoding Rules
对不同类型的值有不同的处理方式(如果你思考一下就不会觉得太奇怪!)以及一个类型可以嵌套在另一个类型中的事实,更不用说是递归的了?我觉得它可能不太难实现。 Raku 的 multi-dispatch 正派上用场!
一般来说,事情如下:
class ASN::Serializer {
...
# like this:
multi method serialize(ASNSequence $sequence, Int $index = 48, :$debug, :$mode = Implicit) { ... }
# or this:
multi method serialize(Int $int is copy where $int.HOW ~~ Metamodel::ClassHOW, Int $index = 2, :$debug, :$mode) { ... }
multi method serialize($enum-value where $enum-value.HOW ~~ Metamodel::EnumHOW, Int $index = 10, :$debug, :$mode) { ... }
# or even this:
multi method serialize(Positional $sequence, Int $index is copy = 16, :$debug, :$mode) { ... }
...
描述该领域所有内容的规则是:
-
对于复杂类型,必须引入一个 has,如
ASNStructure
,迭代其内容,逐个序列化内部,并正确加入它。在一天结束时,对于每个这样的Serializer
程序都具有已知的属性类型或者可以基于特征应用的角色(方便!)推断它,可以具有属性的值(或者如果属性是可选的并且可以省略则可以跳过该属性) ,可以包装/解包基于Str
的类型 - 所有这些都允许一个序列化类型 -
对于简单类型,可以根据给定的规则对其进行序列化
-
对于一些方便的"特殊情况",例如像
@.foo
那样的属性,需要推断发生了什么(在这种情况下,它将是SEQUENCEOF
类型)并正确地序列化它
除了带有值的第一个参数外,还有三个参数:
-
$index
整数派上用场,特别是对于 BER 特定的索引 -
$debug
flag 启用调试输出(当调试一些二进制协议时,这非常有用!) -
将来可能会使用
$mode
值来支持IMPLICIT
以外的标记模式。
40.4. 如果有时间进行编码,总会有时间进行解码
什么是解析器?如果序列化程序是"向后解析器",那么解析器就是……是的,它是一个向后的序列化器!但是这是什么意思?通常,序列化器接收一些 A 并产生一些给定形式的 B。并且解析器获取给定形式的一些 B 并产生一些 A。
假设有人知道正在解析的确切类型:
my $parser = ASN::Parser.new(type => Rocket);
say $parser.parse($rocket-ber); # Yes, here goes our rocket!
如果要解析此 Buf
内容,则必须指定其类型,就像下面这样:
multi method parse(Blob $input, ...) {
...
self.parse($input, $!type, ...);
}
这个方法不知道它所解析的类型,但它调用了它的朋友:parse($input, SomeCoolType, …)
超出了传递的内容和它可以得到的类型。如果知道了类型,多重分派将很乐意为我们提供必要的解析实现。对于简单的类型。对于复杂的类型。对于"特殊"类型。有了 Raku,任何一天都会发生便利的奇迹!
让我们再看一眼:
# Details and basic indentation are omitted for clarity
...
multi method parse(Buf $input is rw, ASNSequence $type, :$debug, :$mode) {
# `$type` here is, really, not a value, but a Type Object. As `ASN-order` is defined on
# type, there are no problems with gathering necessary info:
my @params = do gather {
for $type.ASN-order.kv -> $i, $field {
# Here be dragons! Or, rather, MOP is used here!
}
}
# A-a-and a ready object of a type our parser has no clue about is returned.
# Yes, it is kind of neat. :)
$type.bless(|Map.new(@params));
}
事实上,更简单的类型更简单,就像这样:
multi method parse(Buf $input is rw, $enum-type where $enum-type.HOW ~~ Metamodel::EnumHOW, :$debug, :$mode) {
say "Parsing `$input[0]` out of $input.perl()" if $debug;
$enum-type($input[0]);
}
但是,必须保持规则,以表明错误,并做各种"无聊"的事情,而不是"必要"的事情。虽然 Raku 允许我们在这个区域使用一些不错的技巧,但在圣诞节前看它并不是太感兴趣。
40.5. What o’clock? Supply o’clock!
如果你已经厌倦了所有这些与 ASN.1
相关的东西,我有一个好消息:它已经快结束了。 \O/
虽然所有这些"类型是我的一等公民而我很酷"技巧很有趣,但还有一个技巧可以展示,虽然是相关的,但却有点完全不同。
ASN.1
解析器应该是增量的。更重要的是,它必须是非常明确的,因为人们可以使用未知长度的值。可以做些什么来快速使我们的解析器增量?我们快点做吧:
class ASN::Parser::Async {
has Supplier::Preserving $!out = Supplier::Preserving.new;
has Supply $!values = $!out.Supply;
has Buf $!buffer = Buf.new;
has ASN::Parser $!parser = ASN::Parser.new(type => $!type);
has $.type;
method values(--> Supply) {
$!values;
}
method process(Buf $chunk) {
$!buffer.append: $chunk;
loop {
# Minimal message length
last if $!buffer.elems < 2;
# Message is incomplete, good luck another time
last unless $!parser.is-complete($!buffer);
# Cut off tag, we know what it is already in this specific case
$!parser.get-tag($!buffer);
my $length = $!parser.get-length($!buffer);
# Tag and length are already cut down here, take only value
my $item-octets = $!buffer.subbuf(0, $length);
$!out.emit: $!parser.parse($item-octets, :!to-chop); # `!to-chop`, because "prefix" is already cut
$!buffer .= subbuf($length);
}
}
method close() {
$!out.done;
}
}
它可以像这样使用:
my $parser = ASN::Parser::Async.new(type => Rocket);
$parser.values.tap({ say "I get a nice thing!"; });
react {
whenever $socket.data-arrived -> $chunk {
$parser.process($chunk);
LAST { $parser.close; }
}
}
这是所有必须添加的,以使这种 Parser
增量为这个最小的情况。
当然,正如你可以猜到的那样,我正在写的东西有点过于具体,不仅仅是我的想象力,不仅是精灵,而是一群完整的冒险者(他们也可以处理一些二进制的东西!)。该实现已在 ASN::BER 仓库中提供。虽然它可能是一个非常早期的 alpha 版本,有许多东西甚至还没有计划好,并且有很长的篇幅可以用来改善这个模块的整体状态,它已经对我有用了解我的工作前面提到的半秘密。仓库肯定会打开建议,错误报告(甚至可能是 hug 报告),因为还有大量工作要做,但这是另一个故事了。
祝您度过愉快的一天,并确保在圣诞假期休息好!
41. 第十六天 - 检查你的列表俩次
41.1. 从命令行了解 Raku
这是 Sniffles the Elf 的大好机会!在丝带矿山经过多年的苦差事后,他们终于被提升到了清单管理部门。作为一名闪亮的新助理尼斯名单审核员,Sniffles 正在走向重要时刻。
在 Sniffles 到达的第一天,他们的新老板格伦布尔先生正等着他。"好人清单管理很麻烦,当有人在服务器上洒了牛奶和饼干时,我们的数据被意外删除了。我们一直在忙着检查列表,我们忘了检查备份!现在我们必须从头开始重建一切!裁员后,我们有点人手不足,所以由你来挽救这一天。"
Sniffles,特别勤劳,津津乐道于这个问题。经过一些研究,他们意识到他们需要的所有数据都可用,他们只需要收集它。
他们的朋友在丝带矿山中,一位名叫 Hermie 的自称口述历史学家一直在谈论 Raku 有多么伟大。Sniffles 决定尝试一下。
41.2. 就像拔牙?
Sniffles 首先用一种新语言抛出标准的第一个脚本:
use v6.d;
say "Nice List restored!!!";
该脚本运行并尽职尽责地打印出消息。距离圣诞节还有几天了,是时候认真对待 Raku 文档了。
稍微浏览一下 Sniffles 的 Raku 命令行界面实用程序 页面。他们喜欢它描述的 MAIN
这个特殊子程序的外观。
say 'Started initializing nice lister.';
sub MAIN() { say "Nice List restored!!!" }
say 'Finished initializing nice lister.';
产生:
Started initializing nice lister. Finished initializing nice lister. Nice List restored!!!
好吧,至少那是他们的启动代码。Sniffles 抛弃了初始化消息,它们只是噪音。但他们确信这个 MAIN
函数必须有更多的技巧才能让 Hermie 如此兴奋。
回到文档…检查了 Y 分钟学会 X 语言的 Raku 页面。MAIN
接近尾声的额外部分是金矿!Sniffles 对这个念头打了个寒颤。
"好的,所以如果我们提供 `MAIN 子 ` 程序签名,Raku 会为我们处理命令行解析。更好的是,它会自动生成帮助内容,"他们对自己嘟囔道。
sub MAIN (
:$list-of-all-kids,
:$naughty-list
) { ... }
产生:
$ nice-list
Usage:
nicelist [--list-of-all-kids=<Any>] [--naughty-list=<Any>]
运行脚本得到:
Stub code executed
in sub MAIN at foo line 1
in block <unit> at foo line 1
真棒。
但是开关名称有点长。由于 TheNorthPole.io 是一个专门的商店,Sniffles 认为他们可能不得不输入一堆。呸。如果您可以添加一些解释性文字,更短的名称将没有问题。Raku 支持使用 POD6 标记进行文字编程,因此可以轻松添加注释。
#| Rebuild the Nice List
sub MAIN (
:$all, #= path to file containing the list of all children
:$naughty #= path to file containing the Naughty List
) { ... }
产生:
Usage:
nicelist [--all=<Any>] [--naughty=<Any>] -- Rebuild the Nice List
--all=<Any> path to file containing the list of all children
--naughty=<Any> path to file containing the Naughty List
Sniffles 印象深刻,但他们知道参数验证是编写 CLI 的另一部分,可能会变得乏味。"Raku 最近为我做了什么?"他们想知道。
41.3. 一种强大的,沉默的类型
Raku 有一个渐进式 类型系统,包括编译和运行时类型检查。渐进类型允许 Sniffles 到目前为止忽略类型检查。他们添加了一些类型,看看发生了什么。
Sniffles 使用 类型 smiley定义了 Str 的子集,该类型使用 whatevercode 来验证给定路径上是否存在文件。
subset FilePath of Str:D where *.IO.f;
#| Rebuild the Nice List
sub MAIN (
FilePath :$all, #= path to file containing the list of all children
FilePath :$naughty #= path to file containing the Naughty List
) { ... }
他们运行这个脚本:
$nice-list --naughty=naughty.kids --all=notAFile.bleh Usage: nice-list [--all=<FilePath>] [--naughty=<FilePath>] -- Rebuild the Nice List
--all=<FilePath> path to file containing the list of all children --naughty=<FilePath> path to file containing the Naughty List
Sniffles 在没有争论和其他一些无效方式的情况下再次运行脚本。每次捕获无效输入并自动显示使用消息。 "非常好,"Sniffles 想道,"事实上,错误报告仍然很糟糕。如果你抛出一个参数就好像传入一个丢失的文件一样,你会得到相同的结果。"
41.4. 精灵类型不匹配 - 弥补改进的错误处理
"Ugh! How do I get around this problem?" Sniffles shuffled around the docs some more. Multiple Dispatch and slurpy parameters. They added another subset and a couple of new definitions of MAIN:
subset FileNotFound of Str:D where !*.IO.f();
multi sub MAIN (
FilePath :$all, #= path to file containing the list of all children
FilePath :$naughty #= path to file containing the Naughty List
) { ... }
multi sub MAIN (
FileNotFound :$all,
*%otherStuff
) {
die "List of all children file does not exist";
}
multi sub MAIN (
FileNotFound :$naughty,
*%otherStuff
) {
die "Naughty List file does not exist";
}
他们得到了:
Usage: nice-list [--all=<FilePath>] [--naughty=<FilePath>] -- Rebuild the Nice List nice-list [--all=<FileNotFound>] [--naughty=<FilePath>] nice-list [--all=<FilePath>] [--naughty=<FileNotFound>]
--all=<FilePath> path to file containing the list of all children --naughty=<FilePath> path to file containing the Naughty List
哪个工作完美…除了现在他们在使用中有错误生成条目!双翘。Sniffles 返回到 CLI 界面上的文章。将正确的特征添加到 MAIN 潜艇将使它们从自动生成的使用中消失:
multi sub MAIN (
FileNotFound :$all,
*%otherStuff
) is hidden-from-USAGE {
die "List of all children file does not exist";
}
一团糟不见了!
41.5. 我们不会去,直到我们得到一些!
Grumble 先生走了过来,他停下来看着 Sniffles 的屏幕。"那里有趣的工作,Sniffles。我们需要那个脚本,我们昨天需要它。哦,我们需要它能够审核现有的 Nice List 并重建一个。我们也需要这个。看到你。"在 Sniffles 眨眼之前他消失了。
Sniffles 认为,做一个爬行的功能比被迫吃无花果布丁更好。他们添加了这些命令:
#| Rebuild the Nice List
multi sub MAIN (
'build',
FilePath :$all, #= path to file containing the list of all children
FilePath :$naughty #= path to file containing the Naughty List
) { ... }
#| Compare all the lists for correctness
multi sub MAIN (
'audit',
FilePath :$all, #= path to file containing the list of all children
FilePath :$naughty, #= path to file containing the Naughty List
FilePath :$nice, #= path to file containing the Nice List
) { ... }
"好极了,"他们想,"但你必须像这样运行脚本 nicelist --all=foo --naughty=bar build
。可怕。"
my %*SUB-MAIN-OPTS =
:named-anywhere, # allow named variables at any location
;
"它被修复了!" Sniffles 在座位上跳起来了。
Usage: nicelist build [--all=<FilePath>] [--naughty=<FilePath>] -- Rebuild the Nice List nicelist audit [--all=<FilePath>] [--naughty=<FilePath>] [--nice=<FilePath>] -- Compare all the lists for correctness
--all=<FilePath> path to file containing the list of all children --naughty=<FilePath> path to file containing the Naughty List --nice=<FilePath> path to file containing the Nice List
41.6. 跑步者走上了这条路。
好的,现在 Sniffles 拥有一个完美的框架来构建一个优秀的实用程序脚本。是时候实际写出实际的东西了。Sniffles 知道他们真的打算雪橇这个项目。
很快,Snuffles 发现 Raku 的功能集帮助他们制作了一个功能强大,正确的脚本。他们创建了一个 Child 类,在其上 定义了身份操作,编写了一个用于加载列表数据的简洁 CSV 解析器和一个报告函数。内置的 Set 数据类型提供了操作符,可以轻松查找不合适的条目,甚至更容易重建 Nice List。
一旦 完成,他们就恢复了 Nice List,并向 Grumbles 先生及其他团队发送了一封部门电子邮件,宣布他们取得了成功。当格罗布尔斯先生看到脚本有多好,它的用法和错误检查,仅此一次,他辜负了他们的期望。
为了表彰他们的辛勤工作和机智,Sniffles 被要求在圣诞老人最新工作室的开幕处剪彩。
42. 第 17 天 - 通往幸福的编译之路
如果我们选择接受它,我们的任务就是解决 SEND + MORE = MONEY
代码中的问题。不,请等一下,让我这样说吧:
S E N D
+ M O R E
-----------
M O N E Y
它意味着相同,但是这样放置它更具 视觉冲击力),特别是因为我们很多人在学校这样做了。
基本规则很简单。
-
每个字母代表 0 到 9 之间的数字。
-
字母代表*不同的*数字; 两个字母可能不共享相同的数字。
-
前导数字(在我们的拼图中,
S
和M
)不能为零。如果为零他们就不会是前导数字!
鉴于这些限制因素,上述难题有一个独特的解决方案。
我鼓励你找到解决方案。写一点代码,坚持一下!在这篇文章中,我们会这样做,但后来(关键)*不满足*于此,并最终陷入嵌套玩偶的情况,其中代码编写代码直到出现真正整洁的东西。结论将拼出最终目标-坚持不住了,我被实时地通过多个委员会获悉,正确的说法是" *一个*终极愿景" -为了 Raku。
我们开工吧。
42.1. Marcus Junius Brute Force(The Younger)
我们当天的第一语言及其相应的解决方案是 Raku 本身。这里没有技巧; 我们只是像愤怒的公牛一样向问题域冲去,尝试一切。事实上,我们确保不要在这个问题上耍小聪明,只是尝试尽可能直接地表达解决方案。
for 0..9 -> int $d {
for 0..9 -> int $e {
next if $e == $d;
my int $y = ($d + $e) % 10;
my int $_c1 = ($d + $e) div 10;
for 0..9 -> int $n {
next if $n == $d;
next if $n == $e;
next if $n == $y;
for 0..9 -> int $r {
next if $r == $d;
next if $r == $e;
next if $r == $y;
next if $r == $n;
next unless ($_c1 + $n + $r) % 10 == $e;
my int $_c2 = ($_c1 + $n + $r) div 10;
for 0..9 -> int $o {
next if $o == $d;
next if $o == $e;
next if $o == $y;
next if $o == $n;
next if $o == $r;
next unless ($_c2 + $e + $o) % 10 == $n;
my int $_c3 = ($_c2 + $e + $o) div 10;
for 1..9 -> int $s {
next if $s == $d;
next if $s == $e;
next if $s == $y;
next if $s == $n;
next if $s == $r;
next if $s == $o;
for 1..9 -> int $m {
next if $m == $d;
next if $m == $e;
next if $m == $y;
next if $m == $n;
next if $m == $r;
next if $m == $o;
next if $m == $s;
next unless ($_c3 + $s + $m) % 10 == $o;
my int $_c4 = ($_c3 + $s + $m) div 10;
next unless $_c4 % 10 == $m;
say "$s$e$n$d + $m$o$r$e == $m$o$n$e$y";
}
}
}
}
}
}
}
你又看到了,它不*漂亮*,但它起作用了。这是你母亲警告你的那种缩进程度。不过,如果你问我,我更讨厌缩进。对于我们需要扫描其搜索空间的每个变量,我们都有一个。(只有有 Y
我们才能走捷径。)
虽然这是今天猛烈冲击的迂回而已,但 MJD 曾 在博客上发表过关于此事的博客,然后我 也在博客上写了这篇文章。从某种意义上说,这些博客文章非常关注"删除缩进"。今天的帖子是我三年后的想法。
42.2. 我让路径遍历少了(以及所有其他路径)
我们的第二语言仍然主要是 Raku,但有一个简洁的假象扩展名 amb
,但是拼写为(令人回味)←
。它摆脱了所有显式 for
循环和缩进层级。
my $d <- 0..9;
my $e <- 0..9;
guard $e != any($d);
my $y = ($d + $e) % 10;
my $_c1 = ($d + $e) div 10;
my $n <- 0..9;
guard $n != any($d, $e, $y);
my $r <- 0..9;
guard $r != any($d, $e, $y, $n);
guard ($_c1 + $n + $r) % 10 == $e;
my $_c2 = ($_c1 + $n + $r) div 10;
my $o <- 0..9;
guard $o != any($d, $e, $y, $n, $r);
guard ($_c2 + $e + $o) % 10 == $n;
my $_c3 = ($_c2 + $e + $o) div 10;
my $s <- 1..9;
guard $s != any($d, $e, $y, $n, $r, $o);
my $m <- 1..9;
guard $m != any($d, $e, $y, $n, $r, $o, $s);
guard ($_c3 + $s + $m) % 10 == $o;
my $_c4 = ($_c3 + $s + $m) div 10;
guard $_c4 % 10 == $m;
say "$s$e$n$d + $m$o$r$e == $m$o$n$e$y";
这种解决方案更短,更紧凑,并且感觉不那么"聒噪",并且只是通过摆脱 for
循环来加重。(我怀疑这与人们有时提到的那些命令性的声明谱有关。我们对循环不是那么感兴趣,只看到它完成了。)
我知道它不会完全弥补 Raku 没有 amb
运算符并且 guard
在核心中(甚至在模块空间中)实现的事实,但是这里有一个简短的脚本将上述程序转换为今天的第一个版本:
my $indent = 0;
constant SPACE = chr(0x20);
sub indent { SPACE x 4 * $indent }
for lines() {
when /^ my \h+ ('$' \w) \h* '<-' \h* (\d+ \h* '..' \h* \d+) ';' $/ {
say indent, "for $1 -> int $0 \{";
$indent++;
}
when /^ guard \h+ ('$' \w) \h* '!=' \h* 'any(' ('$' \w)+ % [\h* ',' \h*] ')' \h* ';' $/ {
say indent, "next if $0 == $_;"
for $1;
say "";
}
when /^ guard \h+ ([<!before '=='> .]+ '==' <-[;]>+) ';' $/ {
say indent, "next unless $0;";
}
when /^ my \h+ ('$' \w+) \h* '=' \h* (<-[;]>+) ';' $/ {
say indent, "my int $0 = $1;";
}
when /^ \h* $/ {
say "";
}
when /^ say \h+ (<-[;]>+) ';' $/ {
say indent, $_;
}
default {
die "Couldn't match $_";
}
}
while $indent-- {
say indent, "\}";
}
但我们也不会就此满意。哦,不。
42.3. 在方程式中思考
第三种语言将我们进一步引入声明,摆脱了所有仅仅表明变量应该是不同项的 `guard ` 从句。
ALL_DISTINCT
$d in 0..9
$e in 0..9
$n in 0..9
$r in 0..9
$o in 0..9
$s in 1..9
$m in 1..9
$y = ($d + $e) % 10
$_c1 = ($d + $e) div 10
($_c1 + $n + $r) % 10 == $e
$_c2 = ($_c1 + $n + $r) div 10
($_c2 + $e + $o) % 10 == $n
$_c3 = ($_c2 + $e + $o) div 10
($_c3 + $s + $m) % 10 == $o
$_c4 = ($_c3 + $s + $m) div 10
$_c4 % 10 == $m
我们现在完全处于 约束编程领域,如果不提这一点,是不诚实的。我们已经抛弃了 Raku 的必要方面,我们只关注描述我们正在解决的问题的约束。
上述程序最重要的方面是我们赋值时。即使这主要是一种优化,在我们知道我们可以直接计算变量的值而不是搜索变量的情况下。
即使在这种情况下,我们也可以转换回以前的解决方案。不过,我现在会省略这样一个翻译。
我将在结论中回到这种语言,因为它在很多方面证明了,这是最有趣的一种。
42.4. 第四语言
到目前为止,我们还有哪些必要的复杂性可以剥离?具体而言,这些方程式来自前一解决方案中指定的位置?我们怎样才能更简洁地表达它们?
我想你会喜欢这个。第四种语言只是表达了这样的搜索:
S E N D
+ M O R E
-----------
M O N E Y
等一下,为什么又来?是的,你没有看错。这个问题最具声明性的解决方案只是问题规范本身的 ASCII 布局!当问题域和答案域如此相遇时,难道你不喜欢它吗?
从这个布局上,我们可以再次转换回约束编程解决方案,从手动算法中编写方程式,以便我们在学校学习。
因此,我们不仅不需要编写那些加重 for
循环的东西; 如果我们足够顽强,我们可以从问题到解决方案一直生成代码。我们只需找到合适的语言就可以了。
42.5. 结论
我对 007 的探索使我思考了上述事情:翻译程序。Raku 已经很好地公开了编译过程的一部分:解析。我们可以在用户空间和 Raku 工具链中使用 grammars。
我开始相信我们需要对编译管道的所有方面都这样做。在这里,让我把它作为口号或声明:
当我们带来操作文本/数据的所有功能也可以向内转到编译过程本身时,Raku 将充分发挥其潜力。
我在不同语言之间编写(或想象)的那些翻译器,他们在压力下工作,但他们也很脆弱,有点浪费。问题在很大程度上是我们一直下降到文本。我们应该在 AST 级别执行此操作,其中所有结构都可用。
这种思想转变所带来的收益不容小觑。这是我们在 Raku 中找到 Lispy 启蒙的地方。
例如,带方程的第三种语言不必盲目地翻译成代码。它可以被*优化*,方程式篡改成更窄和更精确的方程式。从 维基百科可以看出,有可能做到如此优秀,以至于一旦程序运行就没有剩下的搜索。
我的梦想:能够进行上述转换,而不是在文本文件之间,而是在 Raku 中的*俚语*之间。并且能够进行优化步骤。一切都没有离开语言的舒适。
43. 第十八天 - 一棵 AVG 格式的圣诞树
圣诞树是一种传统的象征,可以追溯到欧洲四百多年前,所以对于一篇关于创造圣诞树图像的出现文章来说,这可能更好。
树的典型,简化的表示是几个尺寸逐渐减小的三角形,彼此叠加并且具有小的重叠,因此使用计算机程序很容易创建。
在这里,我将使用可缩放矢量图形(SVG)绘制图像,如上所述,它似乎非常适合任务。
43.1. 关于 SVG 并创建它
SVG 是一种 XML 文档格式,它将图像描述为点之间的一组矢量,它具有线条和形状的基元,并提供所描述对象的样式。
也许最简单的 SVG 文档是这样的:
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="100"
height="100">
<g>
<rect x="5" y="5" width="90" height="90" stroke="black" fill="green" />
</g>
</svg>
这描述了侧面 90 的绿色填充正方形(单元基本上是抽象的并且相对于显示器的尺寸,因为图像的可缩放特性意味着它们可能不等同于例如像素。)
现在我们可以在程序中使用一些变量插值打印出 XML,但是对于比上面的例子更复杂的事情,这可能会非常繁琐且容易出错。幸运的是 Raku 有一个方便的 SVG模块,它负责从描述它的数据结构中实际创建格式良好的 XML 文档。因此,我们的示例矩形可以使用以下内容创建:
use SVG;
say SVG.serialize(
svg => [
width => 100, height => 100,
:g[:rect[:x<5>, :y<5>, :width<90>, :height<90>, :stroke<black>, :file<green>]],
],
);
本质上,参数 serialize
是一组嵌套的 Pairs:其中 value 是标量类型,键和值用于形成 XML 属性,其中值是 List of Pairs,这将创建一个以键命名的 XML 元素,列表中的每个对都被解释为如上所述,从而允许以简单的声明方式构建复杂文档。
所以我们可以通过构造适当的数据结构来生成我们的示例圣诞树,但是因为我们的图像中可能至少有四个对象(三个三角形和一个用于树干的矩形)及其相关属性,这可能会非常不合适如果我们想改变某些东西,很难改变。
所以…
43.2. 我们抽象吧!
为了使我们的 SVG 生成更加灵活并为未来的代码重用开辟了机会,创建一组代表我们可能想要使用的 SVG 原语的类并抽象出将要生成的数据结构可能是有意义的。序列化为 XML。
所以让我们从可以生成原始矩形示例的东西开始:
use SVG;
class SVG::Drawing {
role Element {
method serialize() {
...
}
}
has Element @.elements;
has Int $.width;
has Int $.height;
class Group does Element {
has Element @.elements;
method serialize( --> Pair ) {
g => @!elements.map( -> $e { $e.serialize }).list;
}
}
class Rectangle does Element {
has Int $.x;
has Int $.y;
has Int $.width;
has Int $.height;
has Str $.stroke;
has Str $.fill;
method serialize( --> Pair) {
rect => [x => $!x, y => $!y, width => $!width, height => $!height, stroke => $!stroke, fill => $!fill ];
}
}
method serialize( --> Str ) {
SVG.serialize(svg => @!elements.map(-> $e { $e.serialize }).list);
}
}
如果要运行此示例,则应将其保存为 SVG/Drawing.pm
当前目录。
这给出了一个类来描述我们的图像作为一个整体,并协调数据结构的创建,这些数据结构将被序列化为我们的 SVG 文档,并且每个类都用于我们在原始示例中使用的 g
(Group)和 rect
(Rectangle)基元所以我们可以这样做:
use SVG::Drawing;
my SVG::Drawing $drawing = SVG::Drawing.new(elements => [
SVG::Drawing::Group.new(elements => [
SVG::Drawing::Rectangle.new(x => 5, y => 5, width => 100, height => 100, stroke => "black", fill => "green" )
]);
]);
say $drawing.serialize;
生成与第一个类似的文档。
您可能已经注意到了 Element`stubbed 方法的作用 `serialize
:这是为了描述基本类所需的接口,以便收集基本类对象的类可以取决于 serialize
它们何时到来时序列化这些收集的对象。生成 XML 文档。从一开始就添加它可以更容易,更可靠地添加类来描述绘图的新基元。
43.3. 让我们延伸!
因此,除非我们有兴趣用相互叠加的不同大小的正方形制作圣诞树的相当现代主义的表示,否则我们需要一种创建我们需要的三角形的方法。幸运的是,SVG 提供了许多从一组坐标点创建任意闭合形状的方法,但我们将使用最简单的方法,polygon
它有一个属性 points
,它是以逗号分隔的顶点坐标的空格分隔列表。形状,最后一个连接到第一个以关闭形状。
我们将使用一个新的 Polygon 类来描述 polygon
原语:
use SVG;
class SVG::Drawing {
role Element {
method serialize() {
...
}
}
has Element @.elements;
has Int $.width;
has Int $.height;
class Group does Element {
has Element @.elements;
method serialize( --> Pair ) {
g => @!elements.map( -> $e { $e.serialize }).list;
}
}
class Rectangle does Element {
has Int $.x;
has Int $.y;
has Int $.width;
has Int $.height;
has Str $.stroke;
has Str $.fill;
method serialize( --> Pair) {
rect => [x => $!x, y => $!y, width => $!width, height => $!height, stroke => $!stroke, fill => $!fill ];
}
}
class Point {
has Int $.x;
has Int $.y;
method Str( --> Str ) {
($!x, $!y).join: ',';
}
}
class Polygon does Element {
has Str $.stroke;
has Str $.fill;
has Point @.points;
method serialize( --> Pair ) {
polygon => [ points => @!points.join(' '), fill => $!fill, stroke => $!stroke ];
}
}
method serialize( --> Str ) {
SVG.serialize(svg => @!elements.map(-> $e { $e.serialize }).list);
}
}
除了我们新的 Polygon 类之外,还有一个 Point 类描述了多边形顶点的坐标:Str
提供的方法是为了简化 serialize`Polygon 类方法的实现,因为 `@.points
属性的元素将被字符串化为他们加入了 serialize
。
所以现在我们可以生成类似外观的图像,但是以不同的方式构造,例如:
use SVG::Drawing;
my SVG::Drawing $drawing = SVG::Drawing.new(elements => [
SVG::Drawing::Group.new(elements => [
SVG::Drawing::Polygon.new(stroke => "black", fill => "green", points => [
SVG::Drawing::Point.new(x => 5, y => 5),
SVG::Drawing::Point.new(x => 105, y => 5),
SVG::Drawing::Point.new(x => 105, y => 105),
SVG::Drawing::Point.new(x => 5, y => 105)
])
]);
]);
say $drawing.serialize;
这将生成一个 XML 文档,如:
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<g>
<polygon points="5,5 105,5 105,105 5,105" fill="green" stroke="black" />
</g>
</svg>
所以现在我们几乎拥有了绘制 Chritmas 树所需的一切,但在这一点上,值得退一步,展示对未来自我(或者其他可能需要处理代码的人)的爱。
43.4. 一个重构点
当我们创建新的 Polygon 类时,我们复制了 S.stroke
和 $.fill
属性,并安排它们以类似于 Rectangle 类的方式进行序列化。如果我们赶时间这可能是有意义的,这些是他们可能被使用的唯一地方,但是当我们阅读 SVG 文档时,很明显它们可以应用于许多 SVG 原语,因此重构是有意义的。现在,在我们添加任何可能需要它们的类之前。
最明显的方法是创建一个包含属性的新角色,并提供一个方法,该方法返回表示序列化中属性的对列表:
use SVG;
class SVG::Drawing {
role Element {
method serialize() {
...
}
}
role Styled {
has Str $.stroke;
has Str $.fill;
method styles() {
( stroke => $!stroke, fill => $!fill ).grep( { .value.defined } );
}
}
has Element @.elements;
has Int $.width;
has Int $.height;
class Group does Element {
has Element @.elements;
method serialize( --> Pair ) {
g => @!elements.map( -> $e { $e.serialize }).list;
}
}
class Rectangle does Element does Styled {
has Int $.x;
has Int $.y;
has Int $.width;
has Int $.height;
method serialize( --> Pair) {
rect => [x => $!x, y => $!y, width => $!width, height => $!height, |self.styles ];
}
}
class Point {
has Int $.x;
has Int $.y;
method Str( --> Str ) {
($!x, $!y).join: ',';
}
}
class Polygon does Element does Styled {
has Point @.points;
method serialize( --> Pair ) {
polygon => [ points => @!points.join(' '), |self.styles ];
}
}
method serialize( --> Str ) {
SVG.serialize(svg => @!elements.map(-> $e { $e.serialize }).list);
}
}
所以现在我们有一个双重好处,我们可以添加一个新的样式类而不必复制属性,而且我们可以添加我们可能想要的新样式属性,而无需更改消耗类。
通过一些额外的工作,我们可能失去了从 the 中的角色调用方法的需要 serialize
,比如说,使用属性上的特征,这将允许我们选择要序列化的属性,但我将把它当作一个随着圣诞节的到来,我们仍然没有树。
43.5. 一个进一步的抽象
现在我们处于一个很好的位置来创建我们的圣诞树,因为我们需要的三角形只是一个多边形的三面形状,但我们想要不止一个并且顶点的计算将是相当重复,加上,因为我为了简单而任意选择使用等边三角形,其他两个角的坐标可以从顶点和边长度的坐标计算,所以如果我们有一个三角类它可以自我计算,我们只需关注自己的大小和位置:
use SVG;
class SVG::Drawing {
role Element {
method serialize() {
...
}
}
role Styled {
has Str $.stroke;
has Str $.fill;
method styles() {
( stroke => $!stroke, fill => $!fill ).grep( { .value.defined } );
}
}
has Element @.elements;
has Int $.width;
has Int $.height;
class Group does Element {
has Element @.elements;
method serialize( --> Pair ) {
g => @!elements.map( -> $e { $e.serialize }).list;
}
}
class Rectangle does Element does Styled {
has Int $.x;
has Int $.y;
has Int $.width;
has Int $.height;
method serialize( --> Pair) {
rect => [x => $!x, y => $!y, width => $!width, height => $!height, |self.styles ];
}
}
class Point {
has Numeric $.x;
has Numeric $.y;
method Str( --> Str ) {
($!x, $!y).join: ',';
}
}
class Polygon does Element does Styled {
has Point @.points;
method serialize( --> Pair ) {
polygon => [ points => @.points.join(' '), |self.styles ];
}
}
class Triangle is Polygon {
has Point $.apex is required;
has Int $.side is required;
method points() {
($!apex, |self.base-points);
}
method base-points() {
my $y = $!apex.y + self.get-height;
(Point.new(:$y, x => $!apex.x - ( $!side / 2 )), Point.new(:$y, x => $!apex.x + ( $!side / 2 )));
}
method get-height(--> Num ) {
sqrt($!side**2 - ($!side/2)**2)
}
}
method dimensions() {
( height => $!height, width => $!width ).grep( { .value.defined } );
}
method serialize( --> Str ) {
SVG.serialize(svg => [ |self.dimensions, |@!elements.map(-> $e { $e.serialize })]);
}
}
这需要在其他地方进行一些小的改动。在 Int
作为三角形的顶点的计算结果可能不是整数(或我们会风了一个靠不住的三角形,如果我们 roumded 他们)还点的属性是放宽到数字 serialize
多边形的方法是改变使用访问器方法 points
而不是直接使用属性,因此可以在我们的 Triangle 类中过度使用以计算三角形基线的附加点。
计算本身只使用一些初级几何来确定基线到顶点的高度,使用毕达哥拉斯定理得到两个基线点的 y 坐标,x 坐标是两侧边长的一半。顶点 x 坐标。
此外,当我测试这个时,我注意到我之前没有实现高度和宽度属性的序列化,我们已经离开它,因为矩形没有超出默认绘图区域,但是三角形做了,因此没有显示。
无论如何,现在我们可以用最少的代码绘制一个三角形:
use SVG::Drawing;
my SVG::Drawing $drawing = SVG::Drawing.new(
elements => [
SVG::Drawing::Group.new(elements => [
SVG::Drawing::Triangle.new(stroke => "black", fill => "green",
apex => SVG::Drawing::Point.new(x => 100, y => 50),
side => 50,
)
])
],
height => 300,
width => 200
);
say $drawing.serialize;
这将在足够大的空间中提供一个漂亮的绿色等边三角形来绘制我们的树。
43.6. 最后是我们的树
现在我们有了创建简单树的组成部分的方法,因此我们可以将它们与一个相对简单的脚本放在一起:
use SVG::Drawing;
my SVG::Drawing $drawing = SVG::Drawing.new(
elements => [
SVG::Drawing::Group.new(elements => [
SVG::Drawing::Triangle.new(stroke => "green", fill => "green",
apex => SVG::Drawing::Point.new(x => 100, y => 50),
side => 50,
),
SVG::Drawing::Triangle.new(stroke => "green", fill => "green",
apex => SVG::Drawing::Point.new(x => 100, y => 75),
side => 75,
),
SVG::Drawing::Triangle.new(stroke => "green", fill => "green",
apex => SVG::Drawing::Point.new(x => 100, y => 100),
side => 100,
),
SVG::Drawing::Rectangle.new(stroke => "brown",
fill => "brown",
x => 90,
y => 185,
width => 20,
height => 40),
])
],
height => 300,
width => 200
);
say $drawing.serialize;
我通过反复试验选择了形状的大小和位置,它可能更科学地完成。
无论如何,这会产生这样的 XML:
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
height="300"
width="200">
<g>
<polygon points="100,50 75,93.30127018922192 125,93.30127018922192" stroke="green" fill="green" />
<polygon points="100,75 62.5,139.9519052838329 137.5,139.9519052838329" stroke="green" fill="green" />
<polygon points="100,100 50,186.60254037844385 150,186.60254037844385" stroke="green" fill="green" />
<rect x="90" y="185" width="20" height="40" stroke="brown" fill="brown" />
</g>
</svg>
这是一个合理的程式化圣诞树,用户代码最少。
由于我们设计模块的方式,我们已经把自己放在一个好的地方进一步扩展它,比如说,一个 Circle 类可以用来轻松地为我们的树添加彩色小玩意。
SVG 是一个非常丰富的规范,具有大量基元,可满足大多数绘图需求,我们只实现了绘制树所需的最小值,但这可以扩展为支持您想要的任何类型的绘图。
44. 第十九天 - 交互式桌面应用
我是地下城与龙等角色扮演游戏的忠实粉丝。这些游戏中的大多数都有屏幕来帮助你隐藏你在运行游戏时所做的事情,并为你提供游戏中使用的一些图表,以减少书中的内容。
我的游戏收藏很广泛而且我宁愿使用我的笔记本电脑不仅隐藏在后面并跟踪信息,而且我还可以自动化骰子和图表使用。虽然我可以用文本编辑器和命令行魔法拼凑一些东西,但我宁愿拥有一些时髦的桌面应用程序,我可以向人们展示。
输入 GTK::Simple是 Linux Gnome 桌面使用的 gtk3 UI 库的包装器,但也可以在 Windows 和 Mac 上使用。该库通过 Native Call 的强大功能为你提供了一个简单易用的界面,让你可以创建简单的桌面应用程序。
44.1. 骰子滚动
由于历史原因,角色扮演游戏大多数倾向于选择使用基于柏拉图固体的骰子。骰子的标准组合是 4,6,8,10,12,20,并且通过组合 2 个 10 面骰子 100 的骰子。骰子可以多次滚动,用于写入的标准符号是" xdy ",其中 "x" 是掷骰子的数量,"y" 是掷骰子的大小。单个骰子在开始时跳过 1,例如"roll a d6"意味着掷出六面骰子。
有趣的是,在 Raku 中制作 "d" 运算符非常简单:
sub infix: ( UInt $count, UInt $size ) { (1..$size).roll($count) }
sub prefix: ( UInt $size ) { 1 d $size }
请注意,你需要将数字与空格分开,并且 "d" 运算符或编译器会混淆。
我想要的是一个骰子滚轮应用程序,它提供了选择滚动标准骰子组的选项。现在我不会看到一些游戏使用的不同骰子,或者修改滚动,很多游戏都使用这些骰子。我想看看每个掷骰子,因为这可能很重要,具体取决于系统。如果可能的话,我也想要总数。
44.2. 简单的 GTK::Simple
GTK::Simple 的基本用法很简单。创建一个应用程序,添加内容,放入一些事件处理程序,然后离开。
首先,我们创建我们的应用程序,如下所示:
# Get the GTK::Simple libraries
use GTK::Simple;
use GTK::Simple::App;
# Create the main app
my $app = GTK::Simple::App.new( title => "Dice Roller" );
# Start the app running
$app.run;
但…。这不是很有趣:
!空
44.3. 网格布局
要在应用程序中布局小部件,我们有各种选项,但建议使用的是网格。网格布局从左上角的 0,0 开始并根据需要延伸的小部件。
正是在这一点上,我尝试构建一个应用程序,我打了一个墙。网格选项很好,我在下面的最后一个例子中使用它但是当我尝试的时候它没有按照我的预期工作。我仍然可以得到一个简单的网格,所以显示它的工作,但似乎需要更多的学习。无论如何这里是一个基本网格:
# Get the GTK::Simple libraries
use GTK::Simple;
use GTK::Simple::App;
# Create the main app
my $app = GTK::Simple::App.new( title => "Grid" );
$app.set-content(
GTK::Simple::Grid.new(
# Grid key is [x,y,height,width]
[0,0,1,1] => GTK::Simple::Button.new( label => "a" ),
# A Button is a simple push button with a label
[0,1,1,1] => GTK::Simple::Button.new( label => "b" ),
[0,2,1,1] => GTK::Simple::Button.new( label => "c" ),
[1,0,1,3] => GTK::Simple::Button.new( label => "d" ),
)
);
$app.border-width = 10;
# Start the app
$app.run;
这个产生:
!格
44.4. 交互
这很整洁,但按钮还没有做任何事情。为此,我们需要事件处理程序。GUI 应用程序需要事件驱动才能对用户操作做出反应,幸运的是 Raku 具有处理 Supplies 形式的事件的功能。每个按钮都有一个名为 clicked 的 supply,它可以附加一个 tap 处理程序。
事件处理程序可以执行各种操作,包括操作其他 UI 对象。例如 :
# Get the GTK::Simple libraries
use GTK::Simple;
use GTK::Simple::App;
# Create the main app
my $app = GTK::Simple::App.new( title => "Grid" );
$app.set-content(
GTK::Simple::Grid.new(
# As we want to refer to our buttons later we assign them
# to variables
[0,0,1,1] => my $b1 = GTK::Simple::Button.new( label => "Push Me" ),
[1,1,1,1] => my $b2 = GTK::Simple::Button.new( label => "---" ),
[2,2,1,1] => my $b3 = GTK::Simple::Button.new( label => "---" ),
[3,3,1,1] => my $b4 = GTK::Simple::Button.new( label => "---" ),
)
);
# The sensitive flag controls whether you can click on the button
$b2.sensitive = False;
$b3.sensitive = False;
$b4.sensitive = False;
# In the
$b1.clicked.tap( {
# $_ is the clicked button. Turn it off
.sensitive = False;
# Change the label on the next button
$b2.label = "Now Me!";
# Make it clickable
$b2.sensitive = True
} );
# Leaving on one line to cut down on space
$b2.clicked.tap( { .sensitive = False; $b3.label = "Me Next"; $b3.sensitive = True } );
$b3.clicked.tap( { .sensitive = False; $b4.label = "Me! Me!"; $b4.sensitive = True } );
# App.exit closes the app.
$b4.clicked.tap( { $app.exit } );
$app.border-width = 10;
# Start the app
$app.run;
这使得:
!纽扣
44.5. 把它们放在一起
有了这个和另一个小部件,Label 给了我们一些文本,我们可以把骰子滚动应用程序放在一起:
# Get the GTK::Simple libraries
use GTK::Simple;
use GTK::Simple::App;
# Define our `d` operator
sub infix: ( UInt $count, UInt $size ) { (1..$size).roll($count) }
# Create the main app
my $app = GTK::Simple::App.new( title => "Dice Roller" );
# Output Box : Define here so the buttons can access it.
my $output = GTK::Simple::Label.new( text => 'Roll : ');
# Ouput box updater.
sub roll( $label, $count, $size ) {
my @roll = $count d $size;
$label.text = "Roll : {@roll.join(" + ")} = {@roll.sum}";
}
# Create a grid and put the output box at the bottom filling the width
my @grid = ( [0,6,7,1] => $output );
# Track our depth in tthe grid
my $y = 0;
# Loop through counts
for (1..6) -> $count {
# Track our postion along the grid
my $x = 0;
# Loop through standard dice sizes
for (4,6,8,10,12,20,100) -> $size {
# Standard labelling
my $label = $count > 1 ?? "{$count}d{$size}" !! "d{$size}";
# Create our button
my $button = GTK::Simple::Button.new(label => $label);
# Buttons get a supply which emit when they are clicked
# Assign our roll function with the current count and size to it
# Note we do it in a block so it's not called right now but when
# the button is clicked
$button.clicked.tap(
{ roll( $output, $count, $size ) }
);
# Put the button in the valid place in the grid taking up one space
@grid.push( [$x,$y,1,1] => $button );
$x++;
}
$y++
}
# Create a grid object and assign it to the app.
$app.set-content(
GTK::Simple::Grid.new( |@grid )
);
$app.border-width = 10;
# Start the app running
$app.run;
看起来像(这里滚动 3d6):
!骰子辊
44.6. 最后的想法
考虑到我今天早上没有触及 GTK::Simple,我对我的最终结果非常满意。我认为我可以构建许多其他游戏工具。此外,我可能会参与模块本身的工作,尝试将更多的 GTK 功能添加到其中,并添加一些文档。
尽管如此,使用 GTK::Simple 还是很容易使桌面应用程序在 Raku 中遇到特殊的问题,而且代码并不多。
45. 第二十天 - Raku 中的命题组合
来临是一个激动人心的时刻,是一个期待的时刻。不仅对我们人类而言 - 正是精灵变得最具创造力的时候。今天,我想在圣诞节压力下休闲一些时间来报道礼品包装领域正在开展的一些开创性工作。即使你没有预料到任何消息,这份报告仍然可以帮助你改进你的技术,因为 - 我不必提醒你 - 圣诞节快到了。
你知道哪个小孩子最喜欢吗?大礼物。因此,Northpole 的现有扩大研究实验室的任务是寻找实用的方法来扩大礼物。现在,"大"可能意味着多种事物。我承认,第 6 单元弯曲的意思了一点,但他们的工作是迄今为止最有趣的:他们增加了音量的礼物,通过增加的礼品盒的尺寸。
"你如何包装 6 维礼物?"是管理层提出的有效问题。就好像天才击中了 UX 精灵的回应:«只需将它包裹起来,从每个三维视角看起来都像是一个正常的礼物»(他们实际上从 高斯中学到了一个技巧,但谁也不想被视为天才偶尔?)。管理层感到满意,资金安全,数学精灵喜欢 UX 精灵提出的复杂性降低 - 只是制造技术还没有到那里生产那些高维盒子。因此他们决定在 Raku 中进行编程,因为在等待技术赶上 100 年语言时,最好使用什么?
"让我们开始工作,"他们说,并且他们得到了工作。
45.1. 你如何以数学方式包装礼物?
我在实验室逗留期间真正带回家的是,只有很多社会可接受或物理上可能的方式来包装礼物。首先,你需要一个礼物,然后一个盒子。你必须把礼品包装在盒子周围。这些步骤非常自然,精灵认为这是理所当然的。对于他们来说,"礼物"是礼品包装盒内的礼物。"包装"的挑战,以及将包装工与艺术大师分开的关键点,就是丝带和蝴蝶结发挥作用。你认为这应该够容易吗?好吧,再想一想!
45.2. 一个立方体卡罗尔
此设置中的礼物由 n 维立方体建模,或简称为"n 立方体"。n-cube 是一个非常好的东西,因为它的所有面都是较小尺寸的立方体。计算机科学家喜欢它,因为它的顶点实际上只是长度为 n 的 `0`s 和 `1`s 的串。在这里,我们关心 n 立方体的二维面或"正方形"。通常的三维立方体有六个正方形,正如你从骰子中所知道的那样。
精灵采取的方法将这些方块视为变量并分配给它们
-
没有如果的礼品盒,这部分上有唯一的礼品包装,因为我们同意需要,
-
色带,如果有两端礼品丝带运行,或
-
弓如果在立方体的这一侧弓或循环。
是时候看一些代码了:
#|« A square on the n-cube with wrapping.
In the n-cube there are 3*(n choose 2)*2**(n-2) WrapSquare variables,
one for every square and every kind of value that can be assigned to it.
»
class WrapSquare is Cube::Face does Propositional::Variable {
has $.kind is required;
method WHICH {
ValueObjAt.new: "WrapSquare|$!kind|{callsame}"
}
# ...
}
此片段告诉您这 WrapSquare
是多维数据集的一个面,其中 Cube::Face
该类实现了我们期望从多维数据集表面执行的大部分功能。它也是一种变量,并且具有一个 $.kind
属性,该属性将保存字符串 □
,■
或者 !🎀
,取决于为该正方形分配了哪个值(没有,功能区或弓形)。
有一些整洁的运算符:
multi prefix: (Str $s) {
WrapSquare.new: :kind<□>,
Cube::Face.from-word($s)
}
multi prefix: (Str $s) {
WrapSquare.new: :kind<■>,
Cube::Face.from-word($s)
}
multi prefix: (Str $s) {
WrapSquare.new: :kind<🎀>,
Cube::Face.from-word($s)
}
这让你 WrapSquare
通过写作来制作 ■<0**010>。该字符串 0**010 指定 6 维立方体中的正方形:让其中的 *
符号为通配符,它们在 0
和 1
之间变化。然后你得到四个二进制字符串 000010
,001010
,010010
和 011010
。回想一下,长度为 n(这里 n = 6)的二进制字符串是 n-cube 的顶点,这四个碰巧绑定了它的二维面。这些 WrapSquare
文字将在以后全部出现。
经过所有这些解释后 WrapSquare
,我几乎可以听到你心中想要满足的痒:"为什么会这样 WrapSquare
Variable
Propositional
?"
45.3. SAT-a-Clause 的令人满意的故事
回想一下 UX 精灵的建议是:«只需将其包裹起来,从每个三维视角看起来都像一个正常的礼物»。虽然这听起来非常简单,但它会产生一个非常复杂的问题。
"有多少种方式来包装 n 维礼物?"可能是精灵们问自己的第一个问题。UX 精灵要我们做的是选择一个合适的三维包装,其中精灵已经是专家,对于 n 立方体表面上的每个三维立方体,但是所有这些三维包装都适合在高维立方体。n 立方体有½n⋅(n-1)⋅2ⁿ-2 平方但是通过选择 3d 包裹,你可以选择½n⋅(n-1)⋅(n-2)⋅2ⁿ-2 平方,这是(n -2)你有变量的选择次数。原因是 3 立方体在 n 立方体中彼此共享正方形,就像在 3 维中一样,骰子的侧面具有共同的立方体边缘。
UX 精灵创建的问题是为 n-cube 上的 3-cube 选择 3d 包装,只要它们具有公共方形,它们就彼此兼容。但这真的有多糟糕?在尺寸 4 中,您有 24 个正方形,因此 3²= 282,429,536,481 种选择包装的方式。如果您是随机进行的,那么您找到 UX 认可包装的机会甚至不是 0.0000007%。正如我们将要看到的那样,正好有 1848 个正确的包装。
事实证明,礼品包装可以变成这种可满足性问题的一个例子。为简单起见,精灵决定为 n-cube 的每个方块分配三个布尔变量。他们主张这个广场是否装饰有□,■或🎀。这些变量中的一个必须是真的。然后,他们编码要求,每个 3 立方体必须包含一个适当的 3d 包装,并且所选择的包装在 n 立方体中兼容为命题公式。他们把这个公式的成分称为*giftoid 公理* - 包装高维礼品的规则。
有些求解器比找到一个令人满意的任务更进一步(或确定找不到一个任务的不可能性):他们可以返回这些任务的确切数量,甚至可以列出所有这些任务。正是精灵需要的东西,幸运的是,Raku 可以使用命题演算和 SAT 求解器。
SAT 求解器的输入是 Conjunctive Normal Form 中的布尔公式。该 Propositional
模块有一个特别好的实现,虽然我可能有偏见:
method NNF {
self.rewrite(
( ^:p ⇔ ^:q ) => { ($:p ⇒ $:q) ∧ ($:q ⇒ $:p) },
( ^:p ⇒ ^:q ) => { ¬$:p ∨ $:q },
(¬(^:p ∨ ^:q)) => { ¬$:p ∧ ¬$:q },
(¬(^:p ∧ ^:q)) => { ¬$:p ∨ ¬$:q },
(¬¬^:p) => { $:p },
)
andthen .squish
}
method CNF {
self.NNF.rewrite(
(^:p ∨ (^:q ∧ ^:r)) => { ($:p ∨ $:q) ∧ ($:p ∨ $:r) },
((^:q ∧ ^:r) ∨ ^:p) => { ($:p ∨ $:q) ∧ ($:p ∨ $:r) },
)
andthen .squish
}
该 CNF
方法首先将公式转换为中间形式,称为 否定范式,然后将其转换为 CNF。这两种方法都使用模块的中心齿轮之一,该 rewrite
方法。顾名思义,它重写了一个基于规则的公式,这些规则作为成对给出,例如 (^:p ⇔ ^:q ) ⇒ { ($:p ⇒ $:q) ∧ ($:q ⇒ $:p) }
。关键是一个公式对象,这里只是 ⇔
两个变量的等价,^:p
并且 ^:q
在整个公式内是模式匹配的。"限量印记",在变量前面表明上的两侧发现子式 ⇔
操作应当帽捕获的原始内部 命名参数 p
和 q
它们被传递到对 fatarrow 右侧的代码块,以确定 ⇔
表达式的替换是什么。在这种情况下,等价被两个含义所取代 - 这个重写规则实现了定义 ⇔
。上面的下一条规则实现了 ⇒
。的定义。实际上,如果你想要一个 NNF,必须消除这两个符号。
重写引擎执行所有列出的重写,直到找不到更多匹配项。现在,逻辑学家会告诉你,如果你这样做,你会将任何命题公式变成 CNF。这应该足够内部。让我们公理化礼物吧!
45.4. 比你想知道的 3D 礼品包装更多
看到所有的高级成分落到实处,每个人都兴奋不已,我不得不停下来问:«基本情况怎么样?你如何包装 3D 礼物?»。问这个精灵和他们的眼睛开始发光。这是他们的第二天性,甚至是研究精灵。这些是每个精灵在学校学到的规则:
-
只是包装:没有丝带或蝴蝶结的礼物是好的,但永远不要忘记礼品包装,
-
胶合弓:它可以有一个单面粘在一边,没有色带,
-
色带:如果你使用色带,你必须把它包在盒子周围的"腰带"上,
-
蝴蝶结腰带:你可以将蝴蝶结融入蝴蝶结腰带,
-
一个弓:你可能只使用一个弓或没有,
-
消歧:如果所有方面都有缎带,就必须有弓。
最后一个公理与其他公理不同。它不是小学包装表的一部分,后来被致力于高维包装的精灵们发现。(也许有一天会进入课程?)在谈论色带时,"立方体的方块"公式是一种简化,因为有两种方法可以将色带垂直或水平地包裹在立方体的给定方格中。在只有一条皮带的情况下,带状皮带公制用于定义色带的方向。类似地,实际上有三种方法可以围绕立方体包裹色带,这样所有方法都会导致每个边都被色带触摸,即每种方式都可以从三个方向中挑选出两个带。因此,立方体的这种"包裹"是模糊的,必须禁止。
该 Propositional
包可以采取不将任何对象 Propositional::Variable
作为式中的变量的作用。如上所示,通常的逻辑连接符被重载,因此您可以在 Raku 程序中编写公式,就像在纸上一样。仅要求变量角色是一个重要的设计决策,并具有一些巧妙的含义。例如,重写捕获 ^:p
我们之前看到的,也是 Propositional::Variable
在智能匹配时特别表现的对象。
在精灵的情况下,Variable
是 WrapSquare
类和允许任意对象作为变量的另一个优点显示自己:任意变量可以有任意方法或操作符作用于它们。精灵用它来完成另一个*复杂性的减少*。他们只需要对 3 立方体的一个角进行公理化,然后使用 3 立方体的 对称组进行处理。这个群体动作将公理化的角落移动到立方体的每个角落,因此连接该动作的轨道给出了 3d 礼品包装的完全公理化。现在我们同意这听起来很棒,让我们看看它是如何在 Raku 中完成的。(*注意:*公理化涉及 逻辑连接词你可能想要熟悉并遵循上面人类可读的公理。)
multi axioms ($n = 3) {
my \φ = .CNF with [∧] gather {
take □<**0> ∨ ■<**0> ∨ 🎀<**0>;
take □<**0> ⇒ ¬(■<**0> ∨ 🎀<**0>);
take ■<**0> ⇒ ¬(□<**0> ∨ 🎀<**0>);
take 🎀<**0> ⇒ ¬(□<**0> ∨ ■<**0>);
我们选择二维面 0
作为公理化的特定角落。有三个关联布尔变量,即 □<
0>
,■<0>
和 !🎀<
0>
(方便,他们也可以被称为是在 Raku 的代码,这要归功于我们的 WrapSquare
构造函数运算符)。要具有明确定义的包装,必须至少设置这三个变量中的一个。
公理说必须设置三个变量*中的至少*一个。下一个公理规则如果碰巧是活跃的(意味着广场上只有礼品包装),那么()它也不能()是真的,或者()也被设置。任何令人满意的任务,即 SAT 求解器将为我们找到的最终公式将实现这个公理,因为我们采取了一个大的 AND 超过块。
您被邀请将其他公式追溯到包装表:
take ■<**0> ⇒ (■<**1> ∨ 🎀<**1>);
take (■<**0> ∧ ■<**1>) ⇒ (■<*0*> ∨ 🎀<*0*> ∨ ■<0**> ∨ 🎀<0**>);
take (🎀<**0> ∧ ■<**1>) ⇒ (■<*0*> ∨ ■<0**>);
take (🎀<**0> ∧ (■<*0*> ∨ ■<0**>)) ⇒ ■<**1>;
take 🎀<**0> ⇒ ¬(🎀<**1> ∨ 🎀<*0*> ∨ 🎀<*1*> ∨ 🎀<0**> ∨ 🎀<1**>);
}
现在我们对 3 立方体的一个角进行了公理化,我们采用了超八面体组。这可以通过所谓的二元性的组合来实现,该二元性是 &postfix:<°>
操作者实现的 Cube::Face
,并且 &infix:<⤩>
操作者存在的立方体的轴的排列。
my \ψ = φ.rewrite(:1ce,
(^:s(WrapSquare)) => { $:s° }
);
return [∧] gather for (1,2,3).permutations -> \π {
take (φ ∧ ψ).rewrite(:1ce,
(^:s(WrapSquare)) => { $:s ⤩ π }
);
这个 rewrite
方法再次闪耀在这里。捕获变量可以使用 smartmatcher 进行约束,例如类型 WrapSquare
。它们只匹配并捕获匹配约束的内容。因此,上述重写规则仅对公式中的变量起作用,并且它们只执行它 :1ce
- 因为否则重写引擎会一遍又一遍地重写相同的变量,因为它们在每次迭代中都会重新匹配。
你注意到了什么吗?看起来精灵们忘记了消歧公理。但是 - 它没有被遗忘。它已经是对称的,不必参与上面的对称化过程。如果确实如此,就会不必要地重复。它来了:
LAST take ¬(■<**0> ∧ ■<**1> ∧ ■<*0*> ∧ ■<*1*> ∧ ■<0**> ∧ ■<1**>);
}
}
最后,三维礼品包装公理是完整的。精灵们兴高采烈地跳舞。
45.5. 把它包起来
让我们总结一下。为了包装更高维度的礼物,我们将三维礼物包裹起来并将包装拼凑在一起。为了使三维包裹物公理化,在其一个角落周围进行公理化并转动立方体并重复该过程就足够了,因此立方体的每个角落都是一个公理化的角落。这给出了描述所有正确包装的布尔公式。
但是,嘿,我们还没完成!giftoids 和 SAT 求解器在哪里?为了使 n-giftoids 公理化,上面构造的 3 立方公理化必须在 n 立方体的每个 3 面复制。另一个 Cube::Face
操作员出现在这里,再次出现在 rewrite
。它将一个正方形嵌入到 n 立方体的三个面中,正如我们所需要的:
multi axioms ($n where * > 3) {
my \Φ = axioms;
[∧] gather for Faces($n, 3) -> \Δ {
take Φ.rewrite(:1ce,
(^:s(WrapSquare)) => { $:s ↗ Δ }
)
}
}
使用 SAT 工具 Propositional
,我们现在可以获得 3-4G 和 5-Giftoids 的实数:
say count-sat Giftoid::axioms(3), :now
#= OUTPUT: 28
say count-sat(Giftoid::axioms(3), :now)
#= OUTPUT: 1848
say count-sat(Giftoid::axioms(3), :now)
#= OUTPUT: 58213276
为了感受 SAT 求解者所做的惊人工作,考虑到它 28
在 729 种可能性中找到了 3-giftoids 的数量,1848
在 282429536481 种可能性中找到了 4-giftoids 的数量,以及可能 58213276
总共为 147808829414345923316083210206383297601 可能性的 5-giftoids。
当你自己尝试上面的代码示例时,你应该知道的是 SAT 求解器,特别是计数器,是非常需要内存的。5-giftoid 计数需要 5:14 处理器分钟,笔记本电脑上有 4 GiB RAM,不是没有交换,但它可以在笔记本电脑上完成!大多数求解器允许限制时间和内存使用,但 Raku 模块中尚未实现求解器配置。
绝对可行的是获得一个 3-giftoids 列表,其中 3-cube 的所有六个方块的赋值按特定顺序列出:
.put for all-sat(Giftoid::axioms).map({ Giftoid.new: n => 3, deco => $_ })
#=« OUTPUT:
■■■🎀■■
□□■🎀■■
■■■■🎀■
■■■■■🎀
■■🎀■■■
■🎀■■■■
□□■■🎀■
□□🎀■■■
□□■■■🎀
□□■■■■
■■□□■🎀
■■□□🎀■
...
»
或者确定固定维度的 giftoids 中的平均弓箭数,尽管你不会对这些弓箭走得太远:
sub mean-bows ($n) {
my ($sum, $count);
all-sat(Giftoid::axioms($n)).map({
$sum += +.keys.grep(*.kind eq <🎀>);
$count++;
});
$sum / $count;
}
say mean-bows(3);
#= OUTPUT: 0.857143
say mean-bows(4);
#= OUTPUT: 2.766234
预算精灵谨慎地提出一个问题:«如果你任意增加 giftoids 的维度,这是否意味着保持有限?毕竟,弓是最昂贵的……»
我会让你在假期里思考这个问题。快乐的包装。
46. 第二十一天 - 一个红色的圣诞老人
这一年即将结束,我们有很多值得庆祝的事情!与家人和朋友相比,庆祝今年年底更好的方式是什么?为了帮助实现这一目标,在我家,我们决定开办秘密圣诞老人游戏!所以,我的目标是写一个秘密圣诞老人计划!这就是我可以使用这个名为 Red 的精彩项目的地方。
Red是一个仍在开发中的raku的*ORM* (对象关系模型),尚未作为模块发布。但它正在增长,而且接近发布。
因此,让我们创建我们的第一张桌子:一张桌子,用于存储参与我们的秘密圣诞老人的人。代码:
use Red;
model Person {
has UInt $.id is serial;
has Str $.name is column;
has Str $.email is column{ :nullable };
}
my $*RED-DB = database "SQLite";
Person.^create-table;
Person.^create: :name<Fernando>, :email<fco@aco.com>;
Person.^create: :name<Aline>, :email<aja@aco.com>;
Person.^create: :name<Fernanda>;
Person.^create: :name<Sophia>;
.say for Person.^all.grep(*.email.defined).map: *.name;
我们创建*模型的方式*是使用模型特殊单词。一个*模型*仅仅是延伸的正常类红::型号 ,具有MetamodelX ::红::型号的对象作为它的 元类。 Red不会向您的模型添加任何未明确创建的方法。因此,要与*数据库*进行交互,您应该使用*元类*。
但是让我们继续吧。
代码创建一个名为 Person*的新*模型。此*模型*表示的*表*的名称将与模型名称相同:"Person"。如有必要,您可以使用特征更改表的名称 (例如 :)。is table<…>
model Person is table<another_name> {…}
该*模型*有 3 个*属性*:
-
$ .name有一个
is column
特征 ; -
$ .email有
is column{ :nullable }
; -
和$ .id有一个
is serial
。这意味着同样的is column{ :id, :auto-increment }
。
Red默认 使用*非空*列,因此如果要创建可以为空的列,则应使用
is column{ :nullable }
。
因此*Person*上的所有属性都是*列*。在 is serial
(我指的是 :id
一部分)意味着它是表的主键。
之后,它为结果设置*动态变量*($*RED-DB
)database "SQLite"
。该数据库 *子*收到*司机*的名字和它期望的参数。
在这种情况下,它使用*SQLite* 驱动程序,如果您不传递任何参数,它将使用它作为 内存 数据库。如果要使用名为*secret-santa.db* 的文件作为数据库文件,则可以执行此操作 database "SQLite", :database<secret-santa.db>
。或者,如果您想使用本地*Postgres*,只需使用 database "Pg"
。 Red 使用变量 $*RED-DB
来知道要使用的数据库。
好的,现在让我们创建*表*!正如我之前所说,红没有添加任何*方法*你没有明确要求。因此,要创建*表,使用*元类 ' 方法。Person.^create-table
是你如何创建*表*。
这将运行:
CREATE TABLE person(
id integer NOT NULL primary key AUTOINCREMENT,
name varchar(255) NOT NULL,
email varchar(255) NULL
)
现在我们应该插入一些数据。我们用另一个*meta 方法*(.^create
)来做到这一点。该 .^create
元方法*预期相同*参数 .new
的期望。每个*命名参数*都将设置一个具有相同名称的 属性。 .^create
将创建一个新的*Person*对象,将其保存在 数据库中(with .^save: :insert
),然后返回它。
它运行:
INSERT INTO person(
email,
name
) VALUES(
'fco@aco.com',
'Fernando'
)
每个*模型*都有一个*ResultSeq*。这是代表每一个序列*行*的*表*。我们可以 用(或)得到它的*ResultSeq*。ResultSeq*有一些方法可以帮助您从*表中*获取信息,例如: 将过滤*行(就像在普通*Seq 中一样*),但它不会在内存中执行此操作,它会返回带有该过滤器集的新 ResultSeq。检索其*迭代器时*,它使用*ResultSeq*上设置的所有内容运行SQL查询 。.all
.
rs
.grep
在我们的示例中,Person.^all.grep(*.email.defined).map: *.name
将运行如下查询:
SELECT
person.name
FROM
person
WHERE
email IS NOT NULL
它会打印:
Fernando
Aline
好的,我们有一个代码可以保存谁进入我们的秘密圣诞老人游戏。但每个人都想要不同的礼物。我们怎么知道每个人的意愿?
让我们修改代码,使其为参与秘密圣诞老人的每个人保存心愿单:
use Red;
model Person { ... }
model Wishlist {
has UInt $!id is serial;
has UInt $!wisher-id is referencing{ Person.id };
has Person $.wisher is relationship{ .wisher-id };
has Str:D $.name is column is required;
has Str $.link is column;
}
model Person is rw {
has UInt $.id is serial;
has Str $.name is column;
has Str $.email is column;
has Wishlist @.wishes is relationship{ .wisher-id }
}
my $*RED-DB = database "SQLite";
Wishlist.^create-table;
Person.^create-table;
my \fernando = Person.^create: :name<Fernando>, :email<fco@aco.com>;
fernando.wishes.create: :name<Comma>, :link<https://commaide.com>;
fernando.wishes.create: :name("raku books"), :link<https://rakubook.com>;
fernando.wishes.create: :name("mac book pro"), :link<https://www.apple.com/shop/buy-mac/macbook-pro/15-inch-space-gray-2.6ghz-6-core-512gb#>;
my \aline = Person.^create: :name<Aline>, :email<aja@aco.com>;
aline.wishes.create: :name("a new closet"), :link<https://i.pinimg.com/474x/02/05/93/020593b34c205792a6a7fd7191333fc6--wardrobe-behind-bed-false-wall-wardrobe.jpg>;
my \fernanda = Person.^create: :name<Fernanda>, :email<faco@aco.com>;
fernanda.wishes.create: :name("mimikyu plush"), :link<https://www.pokemoncenter.com/mimikyu-poké-plush-%28standard-size%29---10-701-02831>;
fernanda.wishes.create: :name("camelia plush"), :link<https://farm9.static.flickr.com/8432/28947786492_80056225f3_b.jpg>;
my \sophia = Person.^create: :name<Sophia>, :email<saco@aco.com>;
sophia.wishes.create: :name("baby alive"), :link<https://www.target.com/p/baby-alive-face-paint-fairy-brunette/-/A-51304817>;
say "\n{ .name }\n{ .wishes.map({" { .name } => { .link }" }).join("\n").indent: 3 }" for Person.^all
它打印:
Fernando
Comma => https://commaide.com
raku books => https://rakubook.com
mac book pro => https://www.apple.com/shop/buy-mac/macbook-pro/15-inch-space-gray-2.6ghz-6-core-512gb#
Aline
a new closet => https://i.pinimg.com/474x/02/05/93/020593b34c205792a6a7fd7191333fc6--wardrobe-behind-bed-false-wall-wardrobe.jpg
Fernanda
mimikyu plush => https://www.pokemoncenter.com/mimikyu-poké-plush-%28standard-size%29---10-701-02831
camelia plush => https://farm9.static.flickr.com/8432/28947786492_80056225f3_b.jpg
Sophia
baby alive => https://www.target.com/p/baby-alive-face-paint-fairy-brunette/-/A-51304817
现在我们有一个新的 模型 愿望清单 ,它引用了一个名为*withlist*的表 。它 $!id
作为 ID, $!name
并 $!link
为列,也有一些新的东西! has UInt $!wisher-id is referencing{ Person.id };
是一样 has UInt $!wisher-id is column{ :references{ Person.id } };
,这意味着它是一个*列*,这是一个 外键 引用 ID 的人*的*列。它也有 has Person $.wisher is relationship{ .wisher-id };
它不是一个*列*,这是一个"虚拟"。在 $ 印记 意味着有 只有 1好心人˚F 或愿望。并 is relationship
期待一个 Callable 这将获得一个 模型。如果它是 标量 ,它将接收当前 模型 作为唯一参数。所以,在这种情况下,它将是 愿望清单。该 relationsip 的回报 可赎回 必须是*列*引用其他一些*列*。
让我们看看这个表是如何创建的:
CREATE TABLE wishlist(
id integer NOT NULL primary key,
name varchar(255) NOT NULL,
link varchar(255) NULL,
wisher_id integer NULL references person(id)
)
如您所见,没有 创建*wisher*列。
该 人 模式 也发生了变化!现在它有一个 @.wishes
关系(has Wishlist @.wishes is relationship{ .wisher-id }
)。它使用 @ sigil, 因此每个 人 都可以拥有多个愿望。 传递的 Callable*将接收*Positional 属性*的类型 ( 在此情况下为*Wishlist),并且必须返回引用其他列的列。
创建的表与以前相同。
我们之前创建了一个新的 Person : my \fernando = Person.^create: :name<Fernando>, :email<fco@aco.com>;
现在我们可以使用*关系*(愿望)来创建一个新的愿望()。这为 Fernando 运行以下 SQL 创建了一个新的愿望:fernando.wishes.create: :name<Comma>, :linkhttps://commaide.com
INSERT INTO wishlist(
name,
link,
wisher_id
) VALUES(
'Comma',
'https://commaide.com',
1
)
你看过了吗? wisher_id
是 1 … 1 是费尔南多的身份。一旦你创建了 Fernando 的.wishes()的愿望 ,它已经知道它属于 Fernando。
然后我们为我们创造的每个人定义愿望。
然后我们遍历 数据库中的每个 Person(Person.^all
)并打印其名称并循环该人的意愿并打印其名称和链接。
哦,我们可以拯救谁参与……得到他们想要的东西……但是平局?我应该送谁礼物?为此,我们再次更改程序:
use lib <lib>;
use Red;
model Person { ... }
model Wishlist {
has UInt $!id is id;
has UInt $!wisher-id is referencing{ Person.id };
has Person $.wisher is relationship{ .wisher-id };
has Str:D $.name is column is required;
has Str $.link is column;
}
model Person is rw {
has UInt $.id is id;
has Str $.name is column;
has Str $.email is column;
has UInt $!pair-id is referencing{ ::?CLASS.^alias.id };
has ::?CLASS $.pair is relationship{ .pair-id };
has Wishlist @.wishes is relationship{ .wisher-id }
method draw(::?CLASS:U:) {
my @people = self.^all.pick: *;
for flat @people.rotor: 2 => -1 -> $p1, $p2 {
$p1.pair = $p2;
$p1.^save;
}
given @people.tail {
.pair = @people.head;
.^save
}
}
}
my $*RED-DB = database "SQLite";
Wishlist.^create-table;
Person.^create-table;
my \fernando = Person.^create: :name<Fernando>, :email<fco@aco.com>;
fernando.wishes.create: :name<Comma>, :link<https://commaide.com>;
fernando.wishes.create: :name("raku books"), :link<https://rakubook.com>;
fernando.wishes.create: :name("mac book pro"), :link<https://www.apple.com/shop/buy-mac/macbook-pro/15-inch-space-gray-2.6ghz-6-core-512gb#>;
my \aline = Person.^create: :name<Aline>, :email<aja@aco.com>;
aline.wishes.create: :name("a new closet"), :link<https://i.pinimg.com/474x/02/05/93/020593b34c205792a6a7fd7191333fc6--wardrobe-behind-bed-false-wall-wardrobe.jpg>;
my \fernanda = Person.^create: :name<Fernanda>, :email<faco@aco.com>;
fernanda.wishes.create: :name("mimikyu plush"), :link<https://www.pokemoncenter.com/mimikyu-poké-plush-%28standard-size%29---10-701-02831>;
fernanda.wishes.create: :name("camelia plush"), :link<https://farm9.static.flickr.com/8432/28947786492_80056225f3_b.jpg>;
my \sophia = Person.^create: :name<Sophia>, :email<saco@aco.com>;
sophia.wishes.create: :name("baby alive"), :link<https://www.target.com/p/baby-alive-face-paint-fairy-brunette/-/A-51304817>;
Person.draw;
say "{ .name } -> { .pair.name }\n\tWishlist: { .pair.wishes.map(*.name).join: ", " }" for Person.^all
现在Person 有两个新*属性* ($!pair-id和$ .pair)和一个新方法(draw)。 $!pair-id 是一个 引用 同一个*表* (Person)上 的字段id的*外键*,因此我们必须使用 别名 ()。另一个是使用该*外键*的*关系* ($ .pair)。.^alias
新方法(平局)是神奇发生的地方。它使用方法 .pick:\* 在普通的 Positional 上将洗牌。它在这里做同样的事情,查询:
SELECT
person.email , person.id , person.name , person.pair_id as "pair-id"
FROM
person
ORDER BY
random()
一旦我们有了洗牌列表,我们就会使用 .rotor 来获取两个项目并返回一个,所以我们保存每个人给予下一个人的那一对,并且列表中的最后一个人将给第一个人。
这是我们最终代码的输出:
Fernando -> Sophia
Wishlist: baby alive
Aline -> Fernanda
Wishlist: mimikyu plush, camelia plush
Fernanda -> Fernando
Wishlist: COMMA, raku books, mac book pro
Sophia -> Aline
Wishlist: a new closet
作为奖励,让我们看一下 Red 将要跟随的曲目。这是当前的工作代码:
use Red;
model Person {
has UInt $.id is id;
has Str $.name is column;
has Str $.email is column{ :nullable };
}
my $*RED-DB = database "SQLite";
Person.^create-table;
Person.^create: :name<Fernando>, :email<fco@aco.com>;
Person.^create: :name<Aline>, :email<aja@aco.com>;
Person.^create: :name<Fernanda>;
Person.^create: :name<Sophia>;
.say for Person.^all.map: { "{ .name }{ " => { .email }" if .email }" };
这是它运行的 SQL:
SELECT
CASE
WHEN (email == '' OR email IS NULL) THEN name
ELSE name || ' => ' || email
END
as "data"
FROM
person
它打印
Fernando => fco@aco.com
Aline => aja@aco.com
Fernanda
Sophia
47. 第二十二天 - 测试 Cro HTTP API
47.1. 测试 Cro HTTP API
今年我花了大量的工作时间用于构建一些 Raku 应用程序。经过为 Raku 编译器和运行时开发贡献代码十年之后,最终使用它来提供解决实际问题的生产解决方案感觉很棒。我还不确定在我创建的 IDE 中编写代码,使用我设计的 HTTP 库,由我实现大部分的 编译器编译,并在我扮演架构师的 VM上运行,是否会使我成为世界上最差的"尚未发明"的案例,或者只是真正的全栈。
无论我在做什么,我都非常重视自动化测试。每一次通过测试我都知道有东西能工作了 - 当我改进有问题的软件时,我不会破坏这些测试。即使使用自动化测试,也会发生错误,但是添加测试来弥补错误至少意味着我将来会犯下*不同的*错误,这可能有点可以原谅。
我目前正在处理的系统中的大多数代码和复杂性都在其域对象中。这些是通过使用 Cro 实现的 HTTP API 实现的 - 与系统的其他部分一样,此 HTTP API 具有自动化测试。他们使用我的一个旧模块 Test::Mock
- 以及今年发布的新模块,Cro::HTTP::Test
。在今天的 Advent 文章中,我将讨论我如何一起使用它们,结果我觉得非常讨人喜欢。
47.1.1. 一个示例问题
这是 advent 日历,所以当然我需要一个足够节日化的例子问题。对我而言,中欧圣诞时间的亮点之一是圣诞市场,有许多都坐落在美丽的历史城市广场上。除了香肠和热葡萄酒之外,我们还需要在广场上吗?当然,这是一棵高大帅气的圣诞树!但如何找到最好的树?好吧,我们通过建立一个系统来提供互联网帮助,他们可以提交他们认为可能适合的圣诞树的建议。什么可能出错?
可以 PUT 到路由 /trees/{latitude}/{longitude}
以在该位置提交候选圣诞树。预期的有效负载是带有树的高度( height
) 的 JSON blob,以及 10-200 个文本字符的描述(description
),解释为什么这棵圣诞树太棒了。如果同一位置已经提交了圣诞树,则应返回 409 Conflict
响应。如果圣诞树被接受,那么将生成一个简单的 200 OK
响应,并带有一个 JSON 格式的主体描述该圣诞树。
同一 URI 的 GET 将返回相关树的描述,而 GET /trees
将返回已提交的树,最高的圣诞树排第一个。
47.1.2. 可测性
回到高中,科学课肯定是我最喜欢的。我们不时地做实验。当然,每个实验都需要编写 - 包括之前的计划,结果和对它们的分析。规划中最重要的部分之一是关于如何确保"公平测试":我们如何试图控制我们还未尝试测试的所有事情,以便我们可以信任我们的观察并从中得出结论?
软件测试涉及大致相同的思考过程:我们如何运用我们感兴趣的组件,同时控制它们运行的上下文?有时,我们很幸运,我们正在测试纯粹的逻辑:它不依赖于我们提供给它的东西以外的任何东西。事实上,我们可以在这方面*创造自己的运气*,发现我们系统中可以是纯函数或不可变对象的部分。从我正在研究的当前系统中获取示例:
-
我们有一个由一堆规范文件构建的对象模型。 构建它的过程非常复杂,包括一系列健全性 检查,一些图形算法等等。但结果是 一堆*不可变的对象*。一旦建成,它们永远不会改变。 测试很简单:丢出一堆测试输入,并检查它是否 构建了预期的对象。
-
我们有一个计算器的小语言。 语言中表达式使用的数据作为参数传递给计算器, 然后我们可以检查结果是否符合预期。因此,计算器 是一个*纯函数*。
因此,为可测试性做的第一件事就是找到可以像这样的系统部分并以这种方式构建它们。唉,并非所有事情都如此简单。HTTP API 通常是可变状态的网关,数据库操作等。此外,良好的 HTTP API 会将域级别的错误条件映射到适当的 HTTP 状态代码。我们希望能够在我们的测试中创建这样的情况,以便覆盖它们。这是一个类似 Test::Mock
工具入场的地方, 但要使用它,我们需要以一种对测试友好的方式考虑我们的 Cro 服务。
47.1.3. 打桩服务
对于那些刚接触 Cro 的人,让我们来看看我们可以编写的最低限度,以便启动和运行 HTTP 服务,提供有关树的一些假数据。
use Cro::HTTP::Router;
use Cro::HTTP::Server;
my $application = route {
get -> 'trees' {
content 'application/json', [
{
longitude => 50.4311548,
latitude => 14.586079,
height => 4.2,
description => 'Nice color, very bushy'
},
{
longitude => 50.5466504,
latitude => 14.8438714,
height => 7.8,
description => 'Really tall and wide'
},
]
}
}
my $server = Cro::HTTP::Server.new(:port(10000), :$application);
$server.start;
react whenever signal(SIGINT) {
$server.stop;
exit;
}
但是,这不是一个能够测试我们路由的好方法。更好的方法是将路由放入 lib/BestTree.pm6
模块中的子例程中
unit module BestTree;
use Cro::HTTP::Router;
sub routes() is export {
route {
get -> 'trees' {
content 'application/json', [
{
longitude => 50.4311548,
latitude => 14.586079,
height => 4.2,
description => 'Nice color, very bushy'
},
{
longitude => 50.5466504,
latitude => 14.8438714,
height => 7.8,
description => 'Really tall and wide'
},
]
}
}
}
并从脚本中使用它:
use BestTree;
use Cro::HTTP::Server;
my $application = routes();
my $server = Cro::HTTP::Server.new(:port(10000), :$application);
$server.start;
react whenever signal(SIGINT) {
$server.stop;
exit;
}
现在,如果我们有一些东西可以用来测试 route
块做正确的事情,我们可以使用(use
)这个模块,继续我们的测试。
47.1.4. 存储、模型等
然而,还有另一个问题。我们的圣诞树服务将在一些数据库中存储树信息,并执行各种规则。这个逻辑应该去哪里?
我们有许多方法来安排这段代码,但最关键的是,这种逻辑并不属于我们的 Cro 路由处理程序。他们的工作是在域对象和 HTTP 世界之间进行映射,例如将域异常转换为适当的 HTTP 错误响应。那个映射是我们想要测试的。
所以,在我们继续之前,让我们来定义一些这些东西的外观。我们将有一个 BestTree::Tree
代表树的类:
class BestTree::Tree {
has Rat $.latitude;
has Rat $.longitude;
has Rat $.height;
has Str $.description;
}
我们将使用一个 BestTree::Store
对象。我们实际上不会将此作为此帖的一部分来实现; 这将是我们在测试中假装的东西。
class BestTree::Store {
method all-trees() { ... }
method suggest-tree(BestTree::Tree $tree --> Nil) { ... }
method find-tree(Rat $latitude, Rat $longitude --> BestTree::Tree) { ... }
}
但是我们如何安排事情以便我们可以控制路由使用的存储,以进行测试?一个简单的方法是使它成为我们 routes
子程序的参数,这意味着它将在 route
块中可用:
sub routes(BestTree::Store $store) is export {
...
}
47.1.5. 获取树的清单
现在我们准备开始编写测试了!让我们存根测试文件:
use BestTree;
use BestTree::Store;
use Cro::HTTP::Test;
use Test::Mock;
use Test;
# Tests will go here
done-testing;
我们使用 BestTree
,它包含我们想要测试的路由,以及:
-
Cro::HTTP::Test
,我们将用它来轻松编写我们的路由测试 -
Test::Mock
,我们将用它来伪造存储 -
Test
,我们并不严格需要,但有权访问subtest
将 让我们产生更有条理的测试输出
接下来,我们将在测试中使用几个树对象:
my $fake-tree-a = BestTree::Tree.new:
latitude => 50.4311548,
longitude => 14.586079,
height => 4.2,
description => 'Nice color, very bushy';
my $fake-tree-b = BestTree::Tree.new:
latitude => 50.5466504,
longitude => 14.8438714,
height => 7.8,
description => 'Really tall and wide';
这是第一次测试:
subtest 'Get all trees' => {
my $fake-store = mocked BestTree::Store, returning => {
all-trees => [$fake-tree-a, $fake-tree-b]
};
test-service routes($fake-store), {
test get('/trees'),
status => 200,
json => [
{
latitude => 50.4311548,
longitude => 14.586079,
height => 4.2,
description => 'Nice color, very bushy'
},
{
latitude => 50.5466504,
longitude => 14.8438714,
height => 7.8,
description => 'Really tall and wide'
}
];
check-mock $fake-store,
*.called('all-trees', times => 1, with => \());
}
}
首先,我们伪造一个 BestTree::Store
,无论何时 all-trees
被调用,都将返回我们指定的伪数据。然后我们使用 test-service
,传递 route
用假存储创建的块。随后的块内的所有 test
调用都将针对该 route
块执行。
请注意,在这里我们不必担心运行 HTTP 服务来托管我们要测试的路由。实际上,由于 Cro 的管道架构,我们很容易就可以使用 Cro HTTP 客户端,连接其 TCP 消息输出以将它想要的数据发送到 Raku Channel
中,然后将这些数据推送到服务管道的 TCP 消息的输入管道中,反之亦然。这意味着我们一路测试到发送和接收的字节,但实际上不必命中本地网络堆栈。(旁白:您也可以使用 `Cro::HTTP::Test`URI,这意味着如果您真的想要启动测试服务器,或者甚至想针对在不同进程中运行的其他服务编写测试,您可以这样做。)
该 test
程序规定了测试案例。它的第一个参数描述了我们希望执行的请求 - 在这种情况下,是一个到 /trees
的 get
。然后,命名参数指定响应的外观。该 status
检查将确保我们取回了预期的 HTTP 状态代码。该 json
检查实际上是一个里面有俩个:
-
它检查 HTTP 的 content-type 是否为 JSON
-
它检查反序列化为提供的 JSON 的正文(如果你不想 测试它的每一个,在那里传递一个块,应该计算为
True
)
如果这就是我们所做的,并且我们运行了测试,我们会发现它们神秘地通过了,即使我们还没有编辑我们的 route
块的 get
处理程序来实际使用存储!为什么?因为事实证明我很懒,并且使用我之前的小服务器示例中的数据作为我的测试数据。不用担心:为了使测试更强大,我们可以添加一个对 check-mock
的调用,然后断言我们的假存储确实调用了一次 all-trees
方法,并且没有传递参数。
这让我们通过正确实现处理程序来使测试通过:
get -> 'trees' {
content 'application/json', [
$store.all-trees.map: -> $tree {
{
latitude => $tree.latitude,
longitude => $tree.longitude,
height => $tree.height,
description => $tree.description
}
}
]
}
47.1.6. 得到一棵树
下一次测试的时间:获得一棵树。这里有两种情况需要考虑:一个是树是在哪里找到的,以及树是在哪里找不到的。这是对树是在哪里找到的情况的测试:
subtest 'Get a tree that exists' => {
my $fake-store = mocked BestTree::Store, returning => {
find-tree => $fake-tree-b
};
test-service routes($fake-store), {
test get('/trees/50.5466504/14.8438714'),
status => 200,
json => {
latitude => 50.5466504,
longitude => 14.8438714,
height => 7.8,
description => 'Really tall and wide'
};
check-mock $fake-store,
*.called('find-tree', times => 1, with => \(50.5466504, 14.8438714));
}
}
现在运行它失败了。事实上,status
代码检查首先失败,因为我们还没有实现路由,因此得到 404,而不是预期的 200. 所以,这是一个让它通过的实现:
get -> 'trees', Rat() $latitude, Rat() $longitude {
given $store.find-tree($latitude, $longitude) -> $tree {
content 'application/json', {
latitude => $tree.latitude,
longitude => $tree.longitude,
height => $tree.height,
description => $tree.description
}
}
}
从其他路由来看,这部分看起来有些熟悉,不是吗?所以,有了两次通过测试,让我们继续重构:
get -> 'trees' {
content 'application/json',
[$store.all-trees.map(&tree-for-json)];
}
get -> 'trees', Rat() $latitude, Rat() $longitude {
given $store.find-tree($latitude, $longitude) -> $tree {
content 'application/json', tree-for-json($tree);
}
}
sub tree-for-json(BestTree::Tree $tree --> Hash) {
return {
latitude => $tree.latitude,
longitude => $tree.longitude,
height => $tree.height,
description => $tree.description
}
}
测试通过,我们知道我们的重构很好。但是等一下,如果那里没有树怎么办?在这种情况下,存储将返回 Nil
。我们想把它映射到 404.这是另一个测试:
subtest 'Get a tree that does not exist' => {
my $fake-store = mocked BestTree::Store, returning => {
find-tree => Nil
};
test-service routes($fake-store), {
test get('/trees/50.5466504/14.8438714'),
status => 404;
check-mock $fake-store,
*.called('find-tree', times => 1, with => \(50.5466504, 14.8438714));
}
}
事实上,由于我们在路由块中没有考虑这种情况,因此失败了, 返回 500 错误码。令人高兴的是,这个很容易处理:把 given
变成 with
,它检查我们得到了一个已定义的对象,然后添加一个 else
并生成 404 Not Found 响应。
get -> 'trees', Rat() $latitude, Rat() $longitude {
with $store.find-tree($latitude, $longitude) -> $tree {
content 'application/json', tree-for-json($tree);
}
else {
not-found;
}
}
47.1.7. 提交一棵树
最后但并非最不重要的是,让我们测试建议新树的路由。这是成功的情况:
subtest 'Suggest a tree successfully' => {
my $fake-store = mocked BestTree::Store;
test-service routes($fake-store), {
my %body = description => 'Awesome tree', height => 4.25;
test put('/trees/50.5466504/14.8438714', json => %body),
status => 200,
json => {
latitude => 50.5466504,
longitude => 14.8438714,
height => 4.25,
description => 'Awesome tree'
};
check-mock $fake-store,
*.called('suggest-tree', times => 1, with => :(
BestTree::Tree $tree where {
.latitude == 50.5466504 &&
.longitude == 14.8438714 &&
.height == 4.25 &&
.description eq 'Awesome tree'
}
));
}
}
大部分都很熟悉,除了这次 check-mock
调用看起来有点不同。Test::Mock
让我们用两种不同的方式测试参数: Capture
(我们到目前为止)或者 Signature
。这个 Capture
案例非常适用于所有简单情况,我们只处理无聊的值。但是,一旦我们进入引用类型,或者如果我们实际上并不关心确切的值并且只是想断言我们关心的事情,签名就会让我们灵活地做到这一点。这里,我们使用一个 where
子句来检查路由处理程序构造的树对象是否包含预期的数据。
这是执行此操作的路由处理程序:
put -> 'trees', Rat() $latitude, Rat() $longitude {
request-body -> (Rat(Real) :$height!, Str :$description!) {
my $tree = BestTree::Tree.new: :$latitude, :$longitude,
:$height, :$description;
$store.suggest-tree($tree);
content 'application/json', tree-for-json($tree);
}
}
请注意 Cro 如何让我们使用 Raku 签名来构建请求体。在一行中,我们说过:
-
请求正文必须具有高度和描述
-
我们希望高度是一个
Real
数字 -
我们希望描述是一个字符串
如果其中任何一个失败,Cro 将自动为我们产生 400 不良请求。事实上,我们可以编写测试来覆盖它 - 以及一个新的测试,以确保冲突将导致 409。
subtest 'Problems suggesting a tree' => {
my $fake-store = mocked BestTree::Store, computing => {
suggest-tree => {
die X::BestTree::Store::AlreadySuggested.new;
}
}
test-service routes($fake-store), {
# Missing or bad data.
test put('/trees/50.5466504/14.8438714', json => {}),
status => 400;
my %bad-body = description => 'ok';
test put('/trees/50.5466504/14.8438714', json => %bad-body),
status => 400;
%bad-body<height> = 'grinch';
test put('/trees/50.5466504/14.8438714', json => %bad-body),
status => 400;
# Conflict.
my %body = description => 'Awesome tree', height => 4.25;
test put('/trees/50.5466504/14.8438714', json => %body),
status => 409;
}
}
这里的主要新事物是我们使用 computing
而不是带有 mocked
的 returning
。在这种情况下,我们传递一个块,它将被执行。(然而,该块不会获取方法参数。如果我们想要获取这些参数,则有第三个选项,overriding
, 其中我们可以获取参数并编写一个假的方法体。)
以及如何处理?通过使我们的路由处理程序捕获并映射类型化的异常:
put -> 'trees', Rat() $latitude, Rat() $longitude {
request-body -> (Rat(Real) :$height!, Str :$description!) {
my $tree = BestTree::Tree.new: :$latitude, :$longitude,
:$height, :$description;
$store.suggest-tree($tree);
content 'application/json', tree-for-json($tree);
CATCH {
when X::BestTree::Store::AlreadySuggested {
conflict;
}
}
}
}
47.1.8. 结束思考
有了 Cro::HTTP::Test
,现在有一种很好的方法可以在 Raku 中编写 HTTP 测试。结合可测试的设计,也许是一个类似的模块 Test::Mock
,我们也可以将我们的 Cro 路由处理程序与其他所有东西隔离开来,从而简化测试。
我们的路由处理程序中的逻辑相对简单; 通常是小样本问题。然而,即使在这里,我发现旅程中有价值,而不仅仅是在目的地。为 HTTP API 编写测试的行为让我置身于任何将调用 API 的人的心中,这可能是一个有用的观点。经验还告诉我们,测试"太简单到失败"最终会导致错误:我可能会认为我犯得太聪明了。纪律有很长的路要走。在哪个方面,我现在会受到纪律处分,不时地从键盘上休息一下,然后去享受圣诞市场。-Ofun!
48. 第二十三天 - Blin,很快就到圣诞节了!
两年前我已经在出现一篇 advent 文章里提到过 Bisectable,但自那时以来发生了很多变化,所以我觉得是时候简要介绍一下 bisectable
机器人和它的朋友们了。
首先,让我们来定义正在解决的问题。有时会 发生提交引入意外更改行为(错误)。通常我们称之为回归,在某些情况下,找出错误并修复它的最简单方法是首先找到引入回归的提交。
Rakudo 2015.12 和 2018.12 之间有 9000 个提交,尽管它不*超过 9000*,但仍然很多。
幸运的是,我们不需要测试所有修改。假设行为不是一直来回变化,我们可以使用 二分法查找。
48.1. git bisect
和二分法查找
基本上,给定任何提交范围,我们在范围的"中间"取一个提交提交并测试它。如果它是"坏"或者它显示"新"(现在是不正确的)行为,那么我们就可以抛弃我们范围的后半部分(因为我们知道更改必须在该提交之前发生或完全在该提交之后)。同样,如果它是"好"(或"旧"),我们会扔掉另一半。因此,我们只需检查 log n
个修改(≈13),而不是测试所有 9000 次提交。
Git 附带了 git bisect
为您实现二分法查找逻辑的命令。你所要做的就是给它一些起点,然后对于每次提交它跳转过去,告诉它是好还是坏。如果你做了足够多次,它会告诉你哪个提交有问题。
这一切都很好,但有两个问题。
48.2. 问题❶:跳过
让我们想象一下 2 + 2
用来返回的情况 4
(正确!),但现在返回 42
(……也正确,但不完全对)。
所以你启动了 bisection 过程,git 在修改之间跳转,你测试它们。如果它是 4
那么 good
(或 old
),如果它是 42
那么它是 bad
(或 new
)。但后来你偶然发现了这种行为:
> 2 + 2
Merry Christmas!
… 怎么办?显然,那个具体修改有点特殊。我们无法判断我们的错误是否存在,我们根本无法知道。是的,它不会打印 4
,但我们正在寻找一个非常具体的错误,因此它也不会被归类为"新"行为。当然,我们可以抛硬币并随机标记为 old
或者 new
,并希望圣诞节奇迹……但是有 50%的概率(如果我们只看到其中一个)将二分法查找转移到错误的方向。
对于这些情况,git 提供了一个特殊 skip
命令。
如果你是手动测试,那么处理这些修改就有点简单(只要你记得你应该跳过(skip
)他们)。但是,由于问题❷,很多人都倾向于使用 git bisect run
脚本自动化过程。也可以使用脚本跳过修改(使用退出代码 125
),但是如何确定应该跳过哪些修改并不是那么明显。
48.3. 问题❷:构建时间
让我们用乐观的数字 13 来估计我们要测试的修改量。请记住,它不包括我们必须跳过的提交,以及可能需要测试的其他额外构建。
构建 rakudo 所需的时间因硬件而异,但我们乐观地说,在特定的提交中构建 rakudo 需要 2 分钟时间并对其进行测试。
13 × 2 = 26 (minutes)
那不是很方便,对吧?如果在此过程中出现问题……你重新开始,然后等待。
48.4. Bisectable
有没有人想过为每一次提交构建 rakudo,以便你可以快速运行 git bisect?
该想法的成本效益分析受到了迅速质疑:
AlexDaniel:你认为未来二分法将会很普遍吗?
我提供了非常详细的理由:
perlpilot:是的
三天后,机器人加入了频道。这些反应非常有趣:
哇 哦 OooOOOoooh Cooooool
当时我们很少知道结果会怎样。即使我不知道它会变得多么有用。快进 2 年:
关于提交的大小:我尽量保持它们尽可能小,并且尽可能地包含它们,以便 在这种意义上更容易二分,bisectable6 也改变了我编码的方式 还有:bisectable6 让我更少担心我提交的更改 因为它通常限制很多地点寻找以修复问题,他们可以在几分钟而不是几小时内修复 或至少很快显示原因(所以短时间修复可能意味着 revert) \ o /
但它并不总是完美的。引入机器人大约一小时后,它被用于其目的:
bisect:
try { NaN.Rat == NaN; exit 0 }; exit 1
moritz:(2016-05-02)https://github.com/rakudo/rakudo/commit/949a7c7
但是,由于一个 off-by-one,它返回了错误的提交。实际提交是 e2f1fa7,而 949a7c7是其父级。
老实说,那时机器人非常糟糕。例如,它完全依赖于退出代码,所以你不能只是把 2 + 2
抛给它并期望它检查输出。最终,实施了不同的模式,现在机器人首先检查起点上的行为(例如 2015.12 和 HEAD),并确定执行二分法的最佳策略。例如,如果信号不同(例如 SEGV),则它基于信号一分为二。如果信号相同,但退出代码不同,则使用退出代码。如果无法使用其他所有内容,则使用输出进行一分为二。
请记住,如果 raku
无法构建二进制文件,则可以检查二分法。这意味着在大多数情况下,您不需要添加自己的逻辑来跳过。它不仅将二分法时间从几十分钟缩短到几秒钟,而且还提供更可靠/正确的结果。
48.5. 存储
一段时间之后,提交范围扩展到 2014.01
…… HEAD
,意味着所有提交都是从第一个有关 Moar 的 Rakudo 发布开始的。目前它有超过 17000 个构建。它可能听起来很多,但每个 rakudo 安装只需≈28MB,这不是*太多*。拥有几 TB 存储空间可以让您在未来几年内继续使用。
话虽如此,我的服务器上没有那么奢侈。它有一个 120 GB SSD 的 RAID,因此整个事情不仅要适应这么小的空间,而且还应该为系统的其余部分留出足够的空间。
48.6. 更多机器人,更好
在 Bisectable 发布后不久,人们看到了其他工具的机会。想要在特定提交上运行一些代码吗?当然,这有 一个机器人。想下载预构建的 rakudo 存档而不是浪费你自己的 cpu 时间吗?是的,还有另一个机器人。想要绘制一些关于 rakudo 的信息吗?当然有一个机器人!
机器人一直持续增加直到我们有了共计 17 个机器人!有些人认为这些机器人应该停止这样的倍增,也许人们是正确的。但我想重点是,现在很容易在 Whateverable 上为开发人员创建更多工具,这当然很棒。
48.7. 好的,现在怎么样?
因此,bisectable 可以在很短的时间内将数千个提交一分为二。它占用的存储空间非常小,并且用户不需要完全理解二分过程。既然二分是免费且容易的,我们可以做更多吗?
48.8. 是的,Blin!
你可能听说过 Toaster。Toaster 是一种尝试在两个或多个修订版中安装生态系统中的每个模块的工具。例如,假设最后一个版本是 2018.12,发布经理即将从主 HEAD 中删除 rakudo 版本。然后,您可以在 2018.12
和 master
上运行 toaster,它会显示哪些模块用于干净安装,但不再做。
这给了我们 Rakudo 可能出错的信息,但并没有告诉我究竟是什么。鉴于此帖主要是关于 Bisectable,你可能会猜到这是怎么回事。
48.9. Blin 项目- 重新发明了 Toasting
Blin 是 Rakudo 版本的质量保证工具。它用于在 rakudo 中查找回归,但与 Toaster 不同,它不仅告诉哪些模块不再可安装,还将 rakudo 一分为二,以找出导致问题的提交。当然,它是围绕 Whateverable 构建的,因此额外的功能不会花费太多(甚至不需要很多代码)。作为奖励,它生成了 很好的图形来可视化问题如何从模块依赖性传播(虽然这不是很常见)。
Blin 的一个重要特性是它只尝试安装每个模块一次。因此,如果模块 B 依赖于模块 A,A 将被测试并安装一次,然后重新用于 B 的测试。因为这个过程是并行化的,您可能想知道它是如何实现的。基本上,它使用被低估的 react/whenever
功能:
# slightly simplified
react {
for @modules -> $module {
whenever Promise.allof($module.depends.keys».done) {
start { process-module $module, … }
}
}
}
对于每个模块(我们现在有超过 1200 个),它会创建自己的 whenever
块,在满足其依赖关系时触发。在我看来,这是 Blin 中主要逻辑的全部实现,其他一切只是粘合剂以获得 Whateverable 和 Zef 协同工作以实现我们所需要的,+一些输出生成。
在某种程度上,Blin 对我们为 Rakudo 做质量保证的方式没有太大的改变。Toaster 已经能够给我们一些基本的信息(尽管速度较慢),以便我们可以开始调查,而在过去,我知道将奇怪的东西(例如带有依赖关系的完整模块)推入二分法。只是现在它变得更容易了,当 The Day到来时,我不会因机器人滥用而受到惩罚。
48.10. 未来
Whateverable和 Blin一起有 243 个未解决的问题。这两个项目都非常有用,而且非常有用,但正如我们所说,它们不是很棒。大多数问题相对容易和有趣,但它们需要时间。如果您有任何帮助,或者您想维护这些项目,请随时这样做。如果你想基于 Whateverable 构建自己的工具(我们可能需要很多!),请参阅这个 hello world gist。
🎅🎄, 🥞
49. 第二十四天 - 使用 Raku 进行主题建模
嗨,大家好。
今天,让我介绍 Algorithm::LDA。 该模块是用于主题建模的 Latent Dirichlet Allocation(即 LDA)实现。
49.1. 介绍
什么是 LDA?LDA 是一种流行的无监督机器学习方法。 它模拟文档生成过程,并将每个文档表示为主题的混合。
那么,"混合主题"是什么意思呢?图 1 显示了一篇文章,其中一些单词以三种颜色突出显示:黄色,粉红色和蓝色。关于遗传学的词语用黄色标出; 关于进化生物学的文字用粉红色标出; 有关数据分析的文字标有蓝色。想象一下,本文中的所有单词都是彩色的,然后我们可以将这篇文章表示为主题(即颜色)的混合。
图 1 :( 此图片来自"概率主题模型"。(David Blei 2012)) ![图。1](https://camo.githubusercontent.com/464a19ca7cf15ea83e712cd8145afacc46c55cae/68747470733a2f2f7065726c36616476656e742e66696c65732e776f726470726573732e636f6d2f323031382f31322f73637265656e73686f742d66726f6d2d323031382d31322d31302d30302d31342d33312e706e673f773d363830)
好的,那么我将在下一节中演示如何使用 Algorithm::LDA。
49.2. 建模报价
在本文中,我们将探索 Wikiquote。Wikiquote 是一个提供源代码报价的云源平台。 通过使用 Wikiquote API,我们获得用于 LDA 估计的报价。之后,我们执行 LDA 并绘制结果。 最后,我们使用生成的模型创建信息检索应用程序。
49.2.1. 初步
Wikiquote API
Wikiquote 具有 动作 API,提供获取 Wikiquote 资源的方法。 例如,您可以按如下方式获取主页面的内容:
$ curl "https://en.wikiquote.org/w/api.php?action=query&prop=revisions&titles=Main%20Page&rvprop=content&format=json"
上述命令的结果是:
{"batchcomplete":"","warnings":{"main":{"*":"Subscribe to the mediawiki-api-announce mailing list at <https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce> for notice of API deprecations and breaking changes. Use [[Special:ApiFeatureUsage]] to see usage of deprecated features by your application."},"revisions":{"*":"Because \"rvslots\" was not specified, a legacy format has been used for the output. This format is deprecated, and in the future the new format will always be used."}},"query":{"pages":{"1":{"pageid":1,"ns":0,"title":"Main Page","revisions":[{"contentformat":"text/x-wiki","contentmodel":"wikitext","*":"\n
{{Main page header}}
\n
{{Main Page Quote of the day}}
\n</div>\n\n \n{{Main Page Selected pages}}\n{{Main categories}}\n\n\n \n{{New pages}}\n{{Main Page Community}}\n\n\n\n==Wikiquote's sister projects==\n{{otherwiki}}\n\n==Wikiquote languages==\n{{Wikiquotelang}}\n\n__NOTOC__ __NOEDITSECTION__\n{{noexternallanglinks:ang|simple}}\n[[Category:Main page]]"}]}}}}
WWW
WWW通过 Zoffix Znet 是它提供了易于使用的 API,包括获取和解析 JSON 非常简单的库。
例如,正如 README 所说,您可以轻松地按 jget(URL)<HASHKEY>
样式获取内容:
say jget('https://httpbin.org/get?foo=42&bar=x')<args><foo>;
要安装 WWW:
zef install WWW
Chart::Gnuplot
Chart::Gnuplot by titsuki 是 gnuplot的绑定。
要安装 Chart::Gnuplot:
$ zef install Chart::Gnuplot
在本文中,我们使用此模块; 但是,如果你不熟悉 gnuplot,有很多选择:SVG::Plot,Graphics::PLplot,通过 Inline::Python 调用 matplotlib 函数。
来自 NLTK 的 Stopwords
NLTK 是一个用于自然语言处理的工具包。 不仅是 API,它还提供语料库。 您可以通过"70"获得英语的停用词。关键词语料库 ":http://www.nltk.org/nltk_data/
49.2.2. 练习 1:获取报价并创建已清理的文档
一开始,我们必须从 Wikiquote 获取引用并创建干净的文档。
本部分的主要目标是根据以下格式创建文档:
<docid> <personid> <word> <word> <word> ...
<docid> <personid> <word> <word> <word> ...
<docid> <personid> <word> <word> <word> ...
整个源代码是:
use v6.c;
use WWW;
use URI::Escape;
sub get-members-from-category(Str $category --> List) {
my $member-url = "https://en.wikiquote.org/w/api.php?action=query&list=categorymembers&cmtitle={$category}&cmlimit=100&format=json";
@(jget($member-url)<query><categorymembers>.map(*<title>));
}
sub get-pages(Str @members, Int $batch = 50 --> List) {
my Int $start = 0;
my @pages;
while $start < @members {
my $list = @members[$start..^List($start + $batch, +@members).min].map({ uri_escape($_) }).join('%7C');
my $url = "https://en.wikiquote.org/w/api.php?action=query&prop=revisions&rvprop=content&format=json&formatversion=2&titles={$list}";
@pages.push($_) for jget($url)<query><pages>.map({ %(body => .<revisions>[0]<content>, title => .<title>) });
$start += $batch;
}
@pages;
}
sub create-documents-from-pages(@pages, @members --> List) {
my @documents;
for @pages -> $page {
my @quotations = $page<body>.split("\n")\
.map(*.subst(/\[\[$<text>=(<-[\[\]|]>+?)\|$<link>=(<-[\[\]|]>+?)\]\]/, { $<text> }, :g))\
.map(*.subst(/\[\[$<text>=(<-[\[\]|]>+?)\]\]/, { $<text> }, :g))\
.map(*.subst("[", "[", :g))\
.map(*.subst("]", "]", :g))\
.map(*.subst("&", "&", :g))\
.map(*.subst(" ", "", :g))\
.map(*.subst(/:i [ \<\/?\s?br\> | \<br\s?\/?\> ]/, " ", :g))\
.grep(/^\*<-[*]>/)\
.map(*.subst(/^\*\s+/, ""));
# Note: The order of array wikiquote API returned is agnostic.
my Int $index = @members.pairs.grep({ .value eq $page<title> }).map(*.key).head;
@documents.push(%(body => $_, personid => $index)) for @quotations;
}
@documents.sort({ $^a<personid> <=> $^b<personid> }).pairs.map({ %(docid => .key, personid => .value<personid>, body => .value<body>) }).list
}
my Str @members = get-members-from-category("Category:1954_births");
my @pages = get-pages(@members);
my @documents = create-documents-from-pages(@pages, @members);
my $docfh = open "documents.txt", :w;
$docfh.say((.<docid>, .<personid>, .<body>).join(" ")) for @documents;
$docfh.close;
my $memfh = open "members.txt", :w;
$memfh.say($_) for @members;
$memfh.close;
首先,我们获得 "Category:1954births" 页面中列出的成员。我选择了 Raku 设计师诞生的那一年:
my Str @members = get-members-from-category("Category:1954_births");
其中,get-members-from-category
通过维基语录 API 获取成员:
sub get-members-from-category(Str $category --> List) {
my $member-url = "https://en.wikiquote.org/w/api.php?action=query&list=categorymembers&cmtitle={$category}&cmlimit=100&format=json";
@(jget($member-url)<query><categorymembers>.map(*<title>));
}
接下来,调用 get-pages
:
my @pages = get-pages(@members);
get-pages
获取给定标题(即成员)页面的子例程:
sub get-pages(Str @members, Int $batch = 50 --> List) {
my Int $start = 0;
my @pages;
while $start < @members {
my $list = @members[$start..^List($start + $batch, +@members).min].map({ uri_escape($_) }).join('%7C');
my $url = "https://en.wikiquote.org/w/api.php?action=query&prop=revisions&rvprop=content&format=json&formatversion=2&titles={$list}";
@pages.push($_) for jget($url)<query><pages>.map({ %(body => .<revisions>[0]<content>, title => .<title>) });
$start += $batch;
}
@pages;
}
其中 @members[$start..^List($start + $batch, +@members).min]
是一段长度 $batch
,并且切片的元素由百分比编码 uri_escase
和联合 %7C
(即,编码的管道符号百分比)。
在这种情况下,结果之一 $list
是
Mumia%20Abu-Jamal%7CRene%20Balcer%7CIain%20Banks%7CGerard%20Batten%7CChristie%20Brinkley%7CJames%20Cameron%20%28director%29%7CEugene%20Chadbourne%7CJackie%20Chan%7CChang%20Yu-hern%7CLee%20Child%7CHugo%20Ch%C3%A1vez%7CDon%20Coscarelli%7CElvis%20Costello%7CDaayiee%20Abdullah%7CThomas%20H.%20Davenport%7CGerardine%20DeSanctis%7CAl%20Di%20Meola%7CKevin%20Dockery%20%28author%29%7CJohn%20Doe%20%28musician%29%7CF.%20J.%20Duarte%7CIain%20Duncan%20Smith%7CHerm%20Edwards%7CAbdel%20Fattah%20el-Sisi%7CRob%20Enderle%7CRecep%20Tayyip%20Erdo%C4%9Fan%7CAlejandro%20Pe%C3%B1a%20Esclusa%7CHarvey%20Fierstein%7CCarly%20Fiorina%7CGary%20L.%20Francione%7CAshrita%20Furman%7CMary%20Gaitskill%7CGeorge%20Galloway%7C%C5%BDeljko%20Glasnovi%C4%87%7CGary%20Hamel%7CFran%C3%A7ois%20Hollande%7CKazuo%20Ishiguro%7CJean-Claude%20Juncker%7CAnish%20Kapoor%7CGuy%20Kawasaki%7CRobert%20Francis%20Kennedy%2C%20Jr.%7CLawrence%20M.%20Krauss%7CAnatoly%20Kudryavitsky%7CAnne%20Lamott%7CJoep%20Lange%7CAng%20Lee%7CLi%20Bin%7CRay%20Liotta%7CPeter%20Lipton%7CJames%20D.%20Macdonald%7CKen%20MacLeod
请注意,get-pages
子例程使用哈希上下文相关器 %()
来创建哈希序列:
@pages.push($_) for jget($url)<query><pages>.map({ %(body => .<revisions>[0]<content>, title => .<title>) });
在那之后,我们调用 create-documents-from-pages
:
my @documents = create-documents-from-pages(@pages, @members);
create-documents-from-pages
从每个页面创建文档:
sub create-documents-from-pages(@pages, @members --> List) {
my @documents;
for @pages -> $page {
my @quotations = $page<body>.split("\n")\
.map(*.subst(/\[\[$<text>=(<-[\[\]|]>+?)\|$<link>=(<-[\[\]|]>+?)\]\]/, { $<text> }, :g))\
.map(*.subst(/\[\[$<text>=(<-[\[\]|]>+?)\]\]/, { $<text> }, :g))\
.map(*.subst("[", "[", :g))\
.map(*.subst("]", "]", :g))\
.map(*.subst("&", "&", :g))\
.map(*.subst(" ", "", :g))\
.map(*.subst(/:i [ \<\/?\s?br\> | \<br\s?\/?\> ]/, " ", :g))\
.grep(/^\*<-[*]>/)\
.map(*.subst(/^\*\s+/, ""));
# Note: The order of array wikiquote API returned is agnostic.
my Int $index = @members.pairs.grep({ .value eq $page<title> }).map(*.key).head;
@documents.push(%(body => $_, personid => $index)) for @quotations;
}
@documents.sort({ $^a<personid> <=> $^b<personid> }).pairs.map({ %(docid => .key, personid => .value<personid>, body => .value<body>) }).list
}
其中 .map(.subst(/\\[$<text>=(←[\[]|>+?)\|$<link>=(←[\[\]|]>+?)\]\]/, { $<text> }, :g))
和 .map(
.subst(/\[\[$<text>=(←[\[\]|]>+?)\]\]/, { $<text> }, :g))
是隐藏命令,提取文本以显示和删除文本,以便从锚文本进行内部链接。例如, 被缩减为
Perl
。有关更多语法信息,请参阅:[https]://docs.raku.org/language/regexes#Named_captures或https://docs.raku.org/routine/subst
经过一些清理操作(.eg,.map(*.subst("[", "[", :g))
)后,我们提取引号线。
.grep(/^*←[*]>/)
查找以单星号开头的行,因为大多数引号都出现在这种行中。
接下来,.map(.subst(/^\\s+/, ""))
删除每个星号,因为星号本身不是每个报价的组成部分。
最后,我们保存文档和成员(即标题):
my $docfh = open "documents.txt", :w;
$docfh.say((.<docid>, .<personid>, .<body>).join(" ")) for @documents;
$docfh.close;
my $memfh = open "members.txt", :w;
$memfh.say($_) for @members;
$memfh.close;
49.2.3. 练习 2:执行 LDA 并可视化结果
在上一节中,我们保存了已清理的文档。 在本节中,我们使用文档进行 LDA 估计并将结果可视化。 本部分的目标是绘制文档主题分布并编写主题词表。
整个源代码是:
use v6.c;
use Algorithm::LDA;
use Algorithm::LDA::Formatter;
use Algorithm::LDA::LDAModel;
use Chart::Gnuplot;
use Chart::Gnuplot::Subset;
sub create-model(@documents --> Algorithm::LDA::LDAModel) {
my $stopwords = "stopwords/english".IO.lines.Set;
my &tokenizer = -> $line { $line.words.map(*.lc).grep(-> $w { ($stopwords !(cont) $w) and $w !~~ /^[ <:S> | <:P> ]+$/ }) };
my ($documents, $vocabs) = Algorithm::LDA::Formatter.from-plain(@documents.map({ my ($, $, *@body) = .words; @body.join(" ") }), &tokenizer);
my Algorithm::LDA $lda .= new(:$documents, :$vocabs);
my Algorithm::LDA::LDAModel $model = $lda.fit(:num-topics(10), :num-iterations(500), :seed(2018));
$model
}
sub plot-topic-distribution($model, @members, @documents, $search-regex = rx/Larry/) {
my $target-personid = @members.pairs.grep({ .value ~~ $search-regex }).map(*.key).head;
my $docid = @documents.map({ my ($docid, $personid, *@body) = .words; %(docid => $docid, personid => $personid, body => @body.join(" ")) })\
.grep({ .<personid> == $target-personid and .<body> ~~ /:i << perl >>/}).map(*<docid>).head;
note("@documents[$docid] is selected");
my ($row-size, $col-size) = $model.document-topic-matrix.shape;
my @doc-topic = gather for ($docid X ^$col-size) -> ($i, $j) { take $model.document-topic-matrix[$i;$j]; }
my Chart::Gnuplot $gnu .= new(:terminal("png"), :filename("topics.png"));
$gnu.command("set boxwidth 0.5 relative");
my AnyTicsTic @tics = @doc-topic.pairs.map({ %(:label(.key), :pos(.key)) });
$gnu.legend(:off);
$gnu.xlabel(:label("Topic"));
$gnu.ylabel(:label("P(z|theta,d)"));
$gnu.xtics(:tics(@tics));
$gnu.plot(:vertices(@doc-topic.pairs.map({ @(.key, .value.exp) })), :style("boxes"), :fill("solid"));
$gnu.dispose;
}
sub write-nbest($model) {
my $topics := $model.nbest-words-per-topic(10);
for ^(10/5) -> $part-i {
say "|" ~ (^5).map(-> $t { "topic { $part-i * 5 + $t }" }).join("|") ~ "|";
say "|" ~ (^5).map({ "----" }).join("|") ~ "|";
for ^10 -> $rank {
say "|" ~ gather for ($part-i * 5)..^($part-i * 5 + 5) -> $topic {
take @($topics)[$topic;$rank].key;
}.join("|") ~ "|";
}
"".say;
}
}
sub save-model($model) {
my @document-topic-matrix := $model.document-topic-matrix;
my ($document-size, $topic-size) = @document-topic-matrix.shape;
my $doctopicfh = open "document-topic.txt", :w;
$doctopicfh.say: ($document-size, $topic-size).join(" ");
for ^$document-size -> $doc-i {
$doctopicfh.say: gather for ^$topic-size -> $topic { take @document-topic-matrix[$doc-i;$topic] }.join(" ");
}
$doctopicfh.close;
my @topic-word-matrix := $model.topic-word-matrix;
my ($, $word-size) = @topic-word-matrix.shape;
my $topicwordfh = open "topic-word.txt", :w;
$topicwordfh.say: ($topic-size, $word-size).join(" ");
for ^$topic-size -> $topic-i {
$topicwordfh.say: gather for ^$word-size -> $word { take @topic-word-matrix[$topic-i;$word] }.join(" ");
}
$topicwordfh.close;
my @vocabulary := $model.vocabulary;
my $vocabfh = open "vocabulary.txt", :w;
$vocabfh.say($_) for @vocabulary;
$vocabfh.close;
}
my @documents = "documents.txt".IO.lines;
my $model = create-model(@documents);
my @members = "members.txt".IO.lines;
plot-topic-distribution($model, @members, @documents);
write-nbest($model);
save-model($model);
首先,我们加载已清理的文档并调用 create-model
:
my @documents = "documents.txt".IO.lines;
my $model = create-model(@documents);
create-model
通过加载给定文档来创建 LDA 模型:
sub create-model(@documents --> Algorithm::LDA::LDAModel) {
my $stopwords = "stopwords/english".IO.lines.Set;
my &tokenizer = -> $line { $line.words.map(*.lc).grep(-> $w { ($stopwords !(cont) $w) and $w !~~ /^[ <:S> | <:P> ]+$/ }) };
my ($documents, $vocabs) = Algorithm::LDA::Formatter.from-plain(@documents.map({ my ($, $, *@body) = .words; @body.join(" ") }), &tokenizer);
my Algorithm::LDA $lda .= new(:$documents, :$vocabs);
my Algorithm::LDA::LDAModel $model = $lda.fit(:num-topics(10), :num-iterations(500), :seed(2018));
$model
}
$stopwords
来自 NLTK 的一组英语停用词在哪里(我提到了初步部分),并且 &tokenizer
是一个自定义标记器 Algorithm::LDA::Formatter.from-plain
。标记器转换给定句子如下:
-
1. 通过空格拆分句子并生成令牌列表。
-
1. 用小写字符替换标记的每个字符。
-
1. 删除停用词列表中存在的令牌或分类为符号或标点符号的单长令牌。
Algorithm::LDA::Formatter.from-plain
创建数字原生文档(即,文档中的每个单词被映射到其对应的词汇表 id,并且该 id 由 C int32 表示)和来自文本列表的词汇表。
在 Algorithm::LDA
使用上述数值文档创建实例后,我们可以通过启动 LDA 估计 Algorithm::LDA.fit
。在此示例中,我们将主题数设置为 10,将迭代次数设置为 100,将 srand 的种子设置为 2018。
接下来,我们绘制文档主题分布。在此绘图之前,我们加载已保存的成员
my @members = "members.txt".IO.lines;
plot-topic-distribution($model, @members, @documents);
plot-topic-distribution
使用 Chart::Gnuplot 绘制主题分布:
sub plot-topic-distribution($model, @members, @documents, $search-regex = rx/Larry/) {
my $target-personid = @members.pairs.grep({ .value ~~ $search-regex }).map(*.key).head;
my $docid = @documents.map({ my ($docid, $personid, *@body) = .words; %(docid => $docid, personid => $personid, body => @body.join(" ")) })\
.grep({ .<personid> == $target-personid and .<body> ~~ /:i << perl >>/}).map(*<docid>).head;
note("@documents[$docid] is selected");
my ($row-size, $col-size) = $model.document-topic-matrix.shape;
my @doc-topic = gather for ($docid X ^$col-size) -> ($i, $j) { take $model.document-topic-matrix[$i;$j]; }
my Chart::Gnuplot $gnu .= new(:terminal("png"), :filename("topics.png"));
$gnu.command("set boxwidth 0.5 relative");
my AnyTicsTic @tics = @doc-topic.pairs.map({ %(:label(.key), :pos(.key)) });
$gnu.legend(:off);
$gnu.xlabel(:label("Topic"));
$gnu.ylabel(:label("P(z|theta,d)"));
$gnu.xtics(:tics(@tics));
$gnu.plot(:vertices(@doc-topic.pairs.map({ @(.key, .value.exp) })), :style("boxes"), :fill("solid"));
$gnu.dispose;
}
在这个例子中,我们绘制了 Larry Wall 的引文的主题分布("虽然 Perl 口号是不仅仅有一种方法可以做到这一点,但我还是犹豫了 10 种方法来做某事。"):
!img
在绘图之后,我们称之为 write-nbest
:
write-nbest($model);
在 LDA 中,XXX 表示的主题表示为单词列表。write-nbest
写一个降价风格的主题词分配表:
sub write-nbest($model) {
my $topics := $model.nbest-words-per-topic(10);
for ^(10/5) -> $part-i {
say "|" ~ (^5).map(-> $t { "topic { $part-i * 5 + $t }" }).join("|") ~ "|";
say "|" ~ (^5).map({ "----" }).join("|") ~ "|";
for ^10 -> $rank {
say "|" ~ gather for ($part-i * 5)..^($part-i * 5 + 5) -> $topic {
take @($topics)[$topic;$rank].key;
}.join("|") ~ "|";
}
"".say;
}
}
结果是:
topic 0 |
topic 1 |
topic 2 |
topic 3 |
topic 4 |
would |
scotland |
black |
could |
one |
it’s |
country |
mr. |
first |
work |
believe |
one |
lot |
law |
new |
one |
political |
play |
college |
human |
took |
world |
official |
basic |
process |
much |
need |
new |
speak |
business |
don’t |
must |
reacher |
language |
becomes |
ever |
national |
five |
every |
good |
far |
many |
car |
matter |
world |
fighting |
us |
road |
right |
knowledge |
topic 5 |
topic 6 |
topic 7 |
topic 8 |
topic 9 |
apple |
united |
people |
like |
*/ |
likely |
war |
would |
one |
die |
company |
states |
i’m |
something |
und |
jobs |
years |
know |
think |
quantum |
even |
would |
think |
way |
play |
steve |
american |
want |
things |
noble |
life |
president |
get |
perl |
home |
like |
human |
going |
long |
dog |
end |
must |
say |
always |
student |
small |
us |
go |
really |
ist |
正如你所看到的那样,引用"虽然 Perl Slogan 不仅仅是一种方法,我还有 10 种方法可以做某事。"包含 "one","way" 和 "perl"。这就是为什么这个引用主要由主题 8 组成的原因。
对于下一节,我们按 save-model
子程序保存模型:
sub save-model($model) {
my @document-topic-matrix := $model.document-topic-matrix;
my ($document-size, $topic-size) = @document-topic-matrix.shape;
my $doctopicfh = open "document-topic.txt", :w;
$doctopicfh.say: ($document-size, $topic-size).join(" ");
for ^$document-size -> $doc-i {
$doctopicfh.say: gather for ^$topic-size -> $topic { take @document-topic-matrix[$doc-i;$topic] }.join(" ");
}
$doctopicfh.close;
my @topic-word-matrix := $model.topic-word-matrix;
my ($, $word-size) = @topic-word-matrix.shape;
my $topicwordfh = open "topic-word.txt", :w;
$topicwordfh.say: ($topic-size, $word-size).join(" ");
for ^$topic-size -> $topic-i {
$topicwordfh.say: gather for ^$word-size -> $word { take @topic-word-matrix[$topic-i;$word] }.join(" ");
}
$topicwordfh.close;
my @vocabulary := $model.vocabulary;
my $vocabfh = open "vocabulary.txt", :w;
$vocabfh.say($_) for @vocabulary;
$vocabfh.close;
}
49.2.4. 练习 3:创建报价搜索引擎
在本节中,我们创建一个报价搜索引擎,它使用上一节中创建的模型。 更具体地说,我们创建了基于 LDA 的文档模型(Xing Wei 和 W. Bruce Croft 2006),并创建了一个可以搜索报价的 CLI 工具。(注意,"token" 和 "word" 这两个词在本节中是可互换的)
整个源代码是:
use v6.c;
sub MAIN(Str :$query!) {
my \doc-topic-iter = "document-topic.txt".IO.lines.iterator;
my \topic-word-iter = "topic-word.txt".IO.lines.iterator;
my ($document-size, $topic-size) = doc-topic-iter.pull-one.words;
my ($, $word-size) = topic-word-iter.pull-one.words;
my Num @document-topic[$document-size;$topic-size];
my Num @topic-word[$topic-size;$word-size];
for ^$document-size -> $doc-i {
my \maybe-line := doc-topic-iter.pull-one;
die "Error: Something went wrong" if maybe-line =:= IterationEnd;
my Num @line = @(maybe-line).words>>.Num;
for ^@line {
@document-topic[$doc-i;$_] = @line[$_];
}
}
for ^$topic-size -> $topic-i {
my \maybe-line := topic-word-iter.pull-one;
die "Error: Something went wrong" if maybe-line =:= IterationEnd;
my Num @line = @(maybe-line).words>>.Num;
for ^@line {
@topic-word[$topic-i;$_] = @line[$_];
}
}
my %vocabulary = "vocabulary.txt".IO.lines.pairs>>.antipair.hash;
my @members = "members.txt".IO.lines;
my @documents = "documents.txt".IO.lines;
my @docbodies = @documents.map({ my ($, $, *@body) = .words; @body.join(" ") });
my %doc-to-person = @documents.map({ my ($docid, $personid, $) = .words; %($docid => $personid) }).hash;
my @query = $query.words.map(*.lc);
my @sorted-list = gather for ^$document-size -> $doc-i {
my Num $log-prob = gather for @query -> $token {
my Num $log-ml-prob = Pml(@docbodies, $doc-i, $token);
my Num $log-lda-prob = Plda($token, $topic-size, $doc-i, %vocabulary, @document-topic, @topic-word);
take log-sum(log(0.2) + $log-ml-prob, log(0.8) + $log-lda-prob);
}.sum;
take %(doc-i => $doc-i, log-prob => $log-prob);
}.sort({ $^b<log-prob> <=> $^a<log-prob> });
for ^10 {
my $docid = @sorted-list[$_]<doc-i>;
sprintf("\"%s\" by %s %f", @docbodies[$docid], @members[%doc-to-person{$docid}], @sorted-list[$_]<log-prob>).say;
}
}
sub Pml(@docbodies, $doc-i, $token --> Num) {
my Int $num-tokens = @docbodies[$doc-i].words.grep({ /:i^ $token $/ }).elems;
my Int $total-tokens = @docbodies[$doc-i].words.elems;
return -100e0 if $total-tokens == 0 or $num-tokens == 0;
log($num-tokens) - log($total-tokens);
}
sub Plda($token, $topic-size, $doc-i, %vocabulary is raw, @document-topic is raw, @topic-word is raw --> Num) {
gather for ^$topic-size -> $topic {
if %vocabulary{$token}:exists {
take @document-topic[$doc-i;$topic] + @topic-word[$topic;%vocabulary{$token}];
} else {
take -100e0;
}
}.reduce(&log-sum);
}
sub log-sum(Num $log-a, Num $log-b --> Num) {
if $log-a < $log-b {
return $log-b + log(1 + exp($log-a - $log-b))
} else {
return $log-a + log(1 + exp($log-b - $log-a))
}
}
在 beggining,我们加载保存的模型和准备 @document-topic
,@topic-word
,%vocabulary
,@documents
,@docbodies
,%doc-to-person
和 @members
my \doc-topic-iter = "document-topic.txt".IO.lines.iterator;
my \topic-word-iter = "topic-word.txt".IO.lines.iterator;
my ($document-size, $topic-size) = doc-topic-iter.pull-one.words;
my ($, $word-size) = topic-word-iter.pull-one.words;
my Num @document-topic[$document-size;$topic-size];
my Num @topic-word[$topic-size;$word-size];
for ^$document-size -> $doc-i {
my \maybe-line = doc-topic-iter.pull-one;
die "Error: Something went wrong" if maybe-line =:= IterationEnd;
my Num @line = @(maybe-line).words>>.Num;
for ^@line {
@document-topic[$doc-i;$_] = @line[$_];
}
}
for ^$topic-size -> $topic-i {
my \maybe-line = topic-word-iter.pull-one;
die "Error: Something went wrong" if maybe-line =:= IterationEnd;
my Num @line = @(maybe-line).words>>.Num;
for ^@line {
@topic-word[$topic-i;$_] = @line[$_];
}
}
my %vocabulary = "vocabulary.txt".IO.lines.pairs>>.antipair.hash;
my @members = "members.txt".IO.lines;
my @documents = "documents.txt".IO.lines;
my @docbodies = @documents.map({ my ($, $, *@body) = .words; @body.join(" ") });
my %doc-to-person = @documents.map({ my ($docid, $personid, $) = .words; %($docid => $personid) }).hash;
接下来,我们 @query
使用选项设置 :$query
:
my @query = $query.words.map(*.lc);
之后,我们计算 P(query|document)
基于 Eq 的概率。前面提到的 9 篇文章(注意我们使用对数来避免不流动并将参数 mu 设置为零)并对它们进行排序。
my @sorted-list = gather for ^$document-size -> $doc-i {
my Num $log-prob = gather for @query -> $token {
my Num $log-ml-prob = Pml(@docbodies, $doc-i, $token);
my Num $log-lda-prob = Plda($token, $topic-size, $doc-i, %vocabulary, @document-topic, @topic-word);
take log-sum(log(0.2) + $log-ml-prob, log(0.8) + $log-lda-prob);
}.sum;
take %(doc-i => $doc-i, log-prob => $log-prob);
}.sort({ $^b<log-prob> <=> $^a<log-prob> });
Plda
为每个主题添加给定文档概率(即 lnP(主题 |theta,文档))和单词给定主题概率(即 lnP(word | phi,topic))的对数主题,并将它们加起来 .reduce(&log-sum);
:
sub Plda($token, $topic-size, $doc-i, %vocabulary is raw, @document-topic is raw, @topic-word is raw --> Num) {
gather for ^$topic-size -> $topic {
if %vocabulary{$token}:exists {
take @document-topic[$doc-i;$topic] + @topic-word[$topic;%vocabulary{$token}];
} else {
take -100e0;
}
}.reduce(&log-sum);
}
而且 Pml
(ml 表示最大似然)计数 $token
并将其标准化为文档中的总标记(注意,此计算也在日志空间中进行):
sub Pml(@docbodies, $doc-i, $token --> Num) {
my Int $num-tokens = @docbodies[$doc-i].words.grep({ /:i^ $token $/ }).elems;
my Int $total-tokens = @docbodies[$doc-i].words.elems;
return -100e0 if $total-tokens == 0 or $num-tokens == 0;
log($num-tokens) - log($total-tokens);
}
好的,那就让我们执行吧!
查询 "perl":
$ raku search-quotation.p6 --query="perl"
"Perl will always provide the null." by Larry Wall -3.301156
"Perl programming is an *empirical* science!" by Larry Wall -3.345189
"The whole intent of Perl 5's module system was to encourage the growth of Perl culture rather than the Perl core." by Larry Wall -3.490238
"I dunno, I dream in Perl sometimes..." by Larry Wall -3.491790
"At many levels, Perl is a 'diagonal' language." by Larry Wall -3.575779
"Almost nothing in Perl serves a single purpose." by Larry Wall -3.589218
"Perl has a long tradition of working around compilers." by Larry Wall -3.674111
"As for whether Raku will replace Perl 5, yeah, probably, in about 40 years or so." by Larry Wall -3.684454
"Well, I think Perl should run faster than C." by Larry Wall -3.771155
"It's certainly easy to calculate the average attendance for Perl conferences." by Larry Wall -3.864075
查询 "apple":
$ raku search-quotation.p6 --query="apple"
"Steve Jobs is the"With phones moving to technologies such as Apple Pay, an unwillingness to assure security could create a Target-like exposure that wipes Apple out of the market." by Rob Enderle -3.841538
"*:From Joint Apple / HP press release dated 1 January 2004 available [http://www.apple.com/pr/library/2004/jan/08hp.html here]." by Carly Fiorina -3.904489
"Samsung did to Apple what Apple did to Microsoft, skewering its devoted users and reputation, only better. ... There is a way for Apple to fight back, but the company no longer has that skill, and apparently doesn't know where to get it, either." by Rob Enderle -3.940359
"[W]hen it came to the iWatch, also a name that Apple didn't own, Apple walked away from it and instead launched the Apple Watch. Certainly, no risk of litigation, but the product's sales are a fraction of what they otherwise might have been with the proper name and branding." by Rob Enderle -4.152145
"[W]hen Apple wanted the name "iPhone" and it was owned by Cisco, Steve Jobs just took it, and his legal team executed so he could keep it. It turned out that doing this was surprisingly inexpensive. And, as the Apple Watch showcased, the Apple Phone likely would not have sold anywhere near as well as the iPhone." by Rob Enderle -4.187223
"The cause of [Apple v. Qualcomm] appears to be an effort by Apple to pressure Qualcomm into providing a unique discount, largely because Apple has run into an innovation wall, is under increased competition from firms like Samsung, and has moved to a massive cost reduction strategy. (I've never known this to end well, as it causes suppliers to create unreliable components and outright fail.)" by Rob Enderle -4.318575
"Apple tends to aggressively work to not discover problems with products that are shipped and certainly not talk about them." by Rob Enderle -4.380863
"Apple no longer owns the tablet market, and will likely lose dominance this year or next. ... this level of sustained dominance doesn't appear to recur with the same vendor even if it launched the category." by Rob Enderle -4.397954
"Apple is becoming more and more like a typical tech firm â€" that is, long on technology and short on magic. ... Apple is drifting closer and closer to where it was back in the 1990s. It offers advancements that largely follow those made by others years earlier, product proliferation, a preference for more over simple elegance, and waning excitement." by Rob Enderle -4.448473
"[T]he litigation between Qualcomm and Apple/Intel ... is weird. What makes it weird is that Intel appears to think that by helping Apple drive down Qualcomm prices, it will gain an advantage, but since its only value is as a lower cost, lower performing, alternative to Qualcomm's modems, the result would be more aggressively priced better alternatives to Intel's offerings from Qualcomm/Broadcom, wiping Intel out of the market. On paper, this is a lose/lose for Intel and even for Apple. The lower prices would flow to Apple competitors as well, lowering the price of competing phones. So, Apple would not get a lasting benefit either." by Rob Enderle -4.469852 Ronald McDonald of Apple, he is the face." by Rob Enderle -3.822949
"With phones moving to technologies such as Apple Pay, an unwillingness to assure security could create a Target-like exposure that wipes Apple out of the market." by Rob Enderle -3.849055
"*:From Joint Apple / HP press release dated 1 January 2004 available [http://www.apple.com/pr/library/2004/jan/08hp.html here]." by Carly Fiorina -3.895163
"Samsung did to Apple what Apple did to Microsoft, skewering its devoted users and reputation, only better. ... There is a way for Apple to fight back, but the company no longer has that skill, and apparently doesn't know where to get it, either." by Rob Enderle -4.052616
"*** The previous line contains the naughty word '$&'.\n if /(ibm|apple|awk)/; # :-)" by Larry Wall -4.088445
"The cause of [Apple v. Qualcomm] appears to be an effort by Apple to pressure Qualcomm into providing a unique discount, largely because Apple has run into an innovation wall, is under increased competition from firms like Samsung, and has moved to a massive cost reduction strategy. (I've never known this to end well, as it causes suppliers to create unreliable components and outright fail.)" by Rob Enderle -4.169533
"[T]he litigation between Qualcomm and Apple/Intel ... is weird. What makes it weird is that Intel appears to think that by helping Apple drive down Qualcomm prices, it will gain an advantage, but since its only value is as a lower cost, lower performing, alternative to Qualcomm's modems, the result would be more aggressively priced better alternatives to Intel's offerings from Qualcomm/Broadcom, wiping Intel out of the market. On paper, this is a lose/lose for Intel and even for Apple. The lower prices would flow to Apple competitors as well, lowering the price of competing phones. So, Apple would not get a lasting benefit either." by Rob Enderle -4.197869
"Apple tends to aggressively work to not discover problems with products that are shipped and certainly not talk about them." by Rob Enderle -4.204618
"Today's tech companies aren't built to last, as Apple's recent earnings report shows all too well." by Rob Enderle -4.209901
"[W]hen it came to the iWatch, also a name that Apple didn't own, Apple walked away from it and instead launched the Apple Watch. Certainly, no risk of litigation, but the product's sales are a fraction of what they otherwise might have been with the proper name and branding." by Rob Enderle -4.238582
49.3. 结论
在本文中,我们探索了 Wikiquote,并使用 Algoritm::LDA 创建了一个 LDA 模型。 之后我们构建了一个信息检索应用程序。
感谢您阅读我的文章!下次见!
49.4. 引文
-
Blei,David M."Probabilistic topic models。"ACM 55.4(2012)的通讯:77-84。
-
Wei,Xing 和 W. Bruce Croft。"基于 LDA 的文档模型,用于临时检索。"第 29 届年度国际 ACM SIGIR 研究与开发信息检索会议论文集。ACM,2006。
50. 第二十五天 - 以数之名
这个学期学期我参加了我的第一个校对课程,题为"数学证明研讨会简介"。在学习了其他数学课程(微积分,矩阵代数等)之后,我觉得我没有那么多的数学基础,到目前为止,我所做的只是纯粹的计算数学,到处撒上了一些证明。回想起来,我发现课程非常有趣,并且学习不同的定理及其证明,主要来自数论,给了我一个新的数学视角。
你可能会问,"这与 Raku 有什么关系?"。正如我所提到的,课堂上讨论的大多数证明或家庭作业都与数论有关。如果 Raku 和数论有一个共同点就是它们的可访问性。类似于数论的内容如何具体和熟悉,Raku 对初学者来说非常平易近人。事实上,鼓励初学者写出所谓的"婴儿 Perl"。
似乎他们分享的另一件事是他们的浩瀚。例如,在 Raku 中可以找到许多运算符,而在数论中,可以找到从偶数到可爱数字的过多不同类型的数字。在大多数情况下,这些数字很容易理解,如果有一个数字的定义,那么很容易检查该类别中是否包含给定的整数。例如,素数正式定义如下:
如果整数 p> 1 的唯一正数除数为 1 且 p 为 p,则称 p 为素数,或简称为素数。否则,称整数 p 为合数。
通过使用这个定义,我们可以非常简单地弄清楚某个数字是否是素数。例如,在前十个正整数中,2,3,5 和 7 是素数。对于小数字来说这是微不足道的,但是用更大的数字手工完成它会很快变得单调乏味。这就是 Raku 的用武之地。Raku 提供了许多构造/函数,即使它们不能简化工作,它们也可以简化它。例如,考虑到素数的定义,我们可以轻松实现在 Raku 中测试素数的算法:
sub isPrime( $number ) {
return $number > 1 if $number ≤ 3;
loop (my $i = 2; $i² ≤ $number; $i++) {
return False if $number %% $i;
}
return True;
}
请记住,这不是关于编写高性能代码。如果代码以这种方式拒绝,那么它将是优秀的,但它不是目标。我的目的是展示初学者在 Raku 中表达数学结构的容易程度。值得一提的是,Raku 已经包含了 is-prime
测试素数的子程序(或方法)。然而,尽管对于素数这是正确的,但对于你可能遇到的另一种类型的数字可能并非如此,例如阶乘,因子或甚至加泰罗尼亚数字。在这种情况下,Raku 会很有帮助。
在了解了不同类型的数字后,我开始寻找一些奇特的数字,看看如何使用 Raku 实现它们。在这个过程中,我发现这个 有用的网站列出了一堆数字,它们的定义和一些例子。从所有这些中,我选择了四种类型的数字,这些数字并非愚蠢地难以实现(我仍然在编写 Perl 宝宝!😅),同时足以说明一些 Raku 构造。另一方面,我避免了那些可能过于简单的事情。
让我们开始于…
50.1. 友善的数字
Amicable 数字是一对数字,也称为友好数字,每个数字的等分部分添加给另一个数字。
sub aliquot-parts( $number ) {
(^$number).grep: $number %% *;
}
sub infix:<amic>( $m, $n ) {
$m == aliquot-parts($n).sum &&
$n == aliquot-parts($m).sum;
}
say 12 amic 28; # False, 12 and 28 aren't amicables.
say 220 amic 284; # True, 220 and 284 are though.
数字的等分部分是排除数字本身的因素。为了找到数字的等分部分,我创建了一个子程序 aliquot-parts
,用于 1..^$number
创建从 1 到 $numbers
(不包括)的数字列表。随后对该列表进行了追踪,以找出列表中均匀分割的那些数字 $number
。在这个片段中,它是通过使用中缀运算符实现的 %%
,True
如果第一个操作数可被第二个操作数整除,则返回该操作符。否则,它返回 False
。第二个操作数代表前面提到的列表中的任何数字,所以我使用过 *
,在这种情况下,它被称为*任何星形,*并在表达式上创建一个闭包 $number %% *
。因此,子程序中的整个表达式相当于 (^$number).grep: { $number %% $_ };
。最后,子程序返回 $number
排除 $number
自身的因子列表。
为了确定两个数字是否友好,我们可以只使用一个子程序。但是,Raku 允许创建新的运算符,这些运算符*只是具有有趣名称的子程序*,我就是这么做的。我创建了中缀运算符(意思是两个操作数之间)amic
,True
如果两个数字是友好的,则返回。否则,False
。如你所见,创建新运算符的语法很简单:关键字 sub
,后跟运算符的类型(前缀,中缀,后缀等),引用构造内的运算符名称,预期参数和代码块。
50.2. Factorion
因子是一个自然数,等于给定基数中其数字的阶乘的总和。
subset Whole of Int where * ≥ 0;
sub postfix:<!>( Whole $N --> Whole ) {
[*] 1..$N;
}
sub is-factorion( Whole $number --> Bool ) {
$number == $number.comb.map({ Int($_)! }).sum
}
say is-factorion(25); # False
say is-factorion(145); # True
回想一下,通常用数字 N 表示的阶乘 N!
是产品 1 x 2 x … x N
。例如,3! = 1 x 2 x 3 = 6
。在代码片段中,我创建了 postfix 运算符 !
以返回整数操作数的阶乘。因此 say 3!;
,在代码片段和打印中工作得很好 6
。计算阶乘是如何直接的:范围 1..$N
创建一个从 1 到 $N
(包括)的数字列表然后我使用 […]
,这被称为 reduce 元运算符,运算符 *
减少创建的列表 1 x 2 x … $N
,有效地给了我阶乘的 $N
。Raku 中有许多运算符,元运算符 […]
可以与其中许多运算符一起使用。
至于因子,我想知道一个数字是否是一个因子,所以我创建了一个采用整数并返回一个布尔值的子程序。Raku 逐渐输入,因此它允许显式输入变量,指定子的返回类型等。我决定键入子程序的参数和子程序的返回类型。
关于友好数字的部分,我对子程序的论点非常自由。但是,在这里,我决定遵循阶乘的定义,只允许整数,因此定义和使用该 Whole
类型。在 Raku 中,运算符 subset
使用基类型声明一个新类型。但是,如果我没有使用该 where
条款,那么我最终只会使用另一个名称,这个 Int
类型将是多余的。所以我使用该 where
子句来约束对所需输入的任何赋值的类型。在这种情况下,赋值给类型的变量 Whole
。
使用 is-factorion`sub,我使用该方法 `comb
分解 $number
成数字,然后用 map
它们找到各自的阶乘并总结它们。子返回 True`if `$number
等于其数字的阶乘的总和。False
否则返回。
50.3. 循环数
循环数是具有N 个数字的数字,当乘以时
1, 2, 3, …, N
,以不同的顺序产生相同的数字。
sub is-cyclic( Int $n --> Bool ) {
for 1..$n.chars -> $d {
return False if $n.comb.Bag != ($n * $d).comb.Bag;
}
return True;
}
say is-cyclic(142857); # True
say is-cyclic(95678); # False
在这里,我创建了 is-cyclic
一个采用整数并返回布尔值的子程序。我使用 for
循环遍历数字位数(第 1 个,第 2 个等)并使用它们乘以每次迭代中的数字。然后我使用之前看到的 comb
方法,然后使用该 Bag
方法。在 Raku 中,a Bag
是不同元素的不可变集合,没有特定顺序,其中每个元素按集合中的副本数加权。这是我需要的那种结构,因为只有数字的数字及其数量很重要,而不是它们的顺序,并且 Bag
完全实现了这一点。False
如果行李不具有相同的数字或具有相同的数字但是加权不同,则子程序返回。除此以外,True
返回,表示数字的循环。
50.4. 快乐的数字
幸福数字由以下过程定义:从任何正整数开始,将数字替换为其在十进制数字中的数字的平方和,并重复该过程,直到该数字等于 1(它将保留的位置),或者它在一个不包括 1 的循环中无休止地循环。
sub is-happy( $n is copy ) {
my $seen-numbers = :{};
while $n > 1 {
return False if $n ∈ $seen-numbers;
$seen-numbers{$n} = True;
$n = $n.comb.map(*²).sum
}
return True;
}
say is-happy(7); # True
say is-happy(2018); # False
在完成定义中描述的过程之后,一个快乐的数字结束等于 1.另一方面,一个非快乐的数字跟随一个到达循环的序列,该序列 4, 16, 37, 58, 89, 145, 42, 20, 4,…
不包括 1.有了这个事实,我创建了散列 $seen-numbers
到跟踪这些数字。如 while 循环所示,该过程一次又一次地重复,同时 $n
大于 1 或直到看到数字。这里突出的线是包含符号∈的线。在集合论中,如果元素 p 是集合 A 的成员(或元素),则它由 p∈A 表示,这正是在此处测试的内容。如果数字 $n
是散列的元素,则子返回 False
。否则,它返回 True
,表示数字的幸福。
50.5. 摘要
在这篇文章中,我略微进行了逐步打字,如何定义一个新的运算符,使用 subset
关键字 set
和 bag
数据结构进行子类化。正如你可能已经意识到的那样,Raku 提供了许多可以促进许多不同任务的构造。在这种情况下,我希望以更加程序化的方式表达数字的定义。你可能会完全不同,但你可以放心,Raku 可以让你的工作更轻松,更有乐趣。
嗯……这就是所有人!圣诞节快乐,新年快乐!
51. 第一天 - 来自 Perl 的 Raku
51.1. 介绍
自 2015 年年中以来,我一直在使用 Raku(Perl 6 的新名称),真的很感谢它为程序员提供的出色功能,这些程序员可能是懒惰的,非接触式的打字员,业余爱好者,老 Perl 爱好者,非并行用户或想成为黑客的,其中包括:
-
kebab-case 名称
-
无括号的控制语句
-
简易类构造
-
词法块变量
-
丰富的内置例程
-
使用原生的 unicode
-
强大的、易于使用的函数签名
但它的一个特色非核心模块最近确实对我有帮助,所以,我把它作为今年送给我的一份惊喜的 Raku 礼物:Raku 模块 Inline::Perl5,由 Stefan Seifert 编写(IRC#raku: 'nine'; Github: 'niner')。
注意:目前,我只在 Debian Linux 主机上使用 Raku,所以如果你在 Windows 或 Mac OSX 上遇到以下任何问题,我无法提供帮助。
51.2. 背景
我形容自己是一个务实的程序员(即不加修饰地尽快完成工作),除了在大学期间接受过主框架、批处理工作时代的编程训练外,几乎没有接受过正规的编程训练,后来在美国空军的最后一份工作中,也没有接受过正规的编程训练。详见本 文档)。
在 1993 年中被我的最后一个民用雇主(另一个美国国防部承包商,我于 2016-01-11 从该雇主处退休)雇用后不久,我发现了 Perl 4,并发现它是我创建我们的小型地方办公室所需的工具的理想语言,使我们的小型办公室从一个密集的手工流程转向一个更自动化的流程(我当时大量使用 C,Perl 是我自 Basic 以来使用的第一个解释语言)。多年来,我最终转到了 Perl 5(比我应该用的要晚得多),并继续发展和改进我公司的软件工具箱(大部分是我利用自己的时间在家里写的),现在是在 Redhat Linux 计算机上,全部用 Perl 写的,其中包括自动生成图像和文档制作(用 Perl 写 PostScript 转换为 PDF)。制作出来的文档使我的分析师团队能够看到标准的结果图、表格和其他生成的指标,从而有更多的时间,可以轻松地写出他们的详细分析,然后纳入最终产品。
此外,我还建立了其他个人使用的产品,包括集成数据库的个性化日历、带标签制作器的圣诞卡地址数据库和几个网站。总之,我家里有很多老的以及最新的 Perl 代码!。
51.3. 2015 和 Raku
我一直希望看到 Raku(Perl 6)能尽快到来,因为现有的语言似乎有些笨拙,但 CPAN 及其出色的模块作者,尤其是 Damian Conway,帮助减轻这种麻烦。
因此,当我在 2015 年年中查看 Raku 的进展,看到即将发布的初始稳定版时,我非常高兴地加入了 -Ofun。我立即开始尝试将我的一些 Perl 产品转换为纯 Raku,首先是在我个人项目中对我很重要的一些 CPAN 模块。首先是 Perl 的 Geo::Ellipsoid,这是一次真正的学习经历,花费的时间比我想象的要长得多。最终,我向 CPAN 发布了八个纯 Raku 模块。
51.4. 快进到 2019 年
当我今年开始把自己的工具移植到 Raku 上时,真正的乐趣开始了。当我构建最初的工具时,大部分都是在匆忙中完成的,几乎没有时间进行思考和设计,也很少进行测试,当然也没有测试套件。因此,我有很多丑陋的代码放在那里,准备移植到 Raku 上。套用 Dr. Strangelove[参考文献 1]的话,我不再担心这些乱七八糟的东西,而是开始着手 Raku 的移植工作。
51.5. 第一部分: 初步测试
我首先从移植模块开始,然后再移植使用这些模块的程序,但发现在许多情况下这太费力了。我遇到的问题有:缺乏签名、大量使用 GOTO、在长主程序(也就是脚本)中使用全局变量和大量子程序等等。
所以我终于在今年决定尝试使用 Inline::Perl5 来简化我的工作。我把我的移植过程改为:
-
把 Perl 程序中现有的子程序移到 Perl 模块中。
-
确保 Perl 程序在第 1 步后继续按照预期工作。
-
将现在短得多的 Perl 程序移植到 Raku 中,这个任务比以前简单多了。
在我认真开始之前,我创建了一个 Raku 脚本来查找我所有的 Perl 文件(使用 File::Find 和正则表达式 /['.pm'|'.pl']$/
),逐行读取它们,然后再把它们写出来,看看用 Raku 处理它们是否有任何问题,当然,我确实做到了:在我的一些非常老的代码中(20 世纪 90 年代中期),我得到了关于畸形 utf8 的错误,就像这样:
ERROR: something failed in file 'make_color_book.pl': Malformed UTF-8
我尝试了几种方法来隔离坏的行,包括使用 *nix
工具 od
,但那太慢了,而且用 vim 进行目测也不一定有效。(由于我是远程工作,我没有来得及使用 Emacs 或 comma,所以我不知道这是否会有帮助。) 幸运的是,我在使用一组有限的文件进行测试时偶然发现了一个窍门,当我在程序中使用这个片段时:
try { my $string = slurp $infile }
if $! {
note "Error attempting to slurp file '$infile'";
note "$!";
}
检测到 UTF-8 错误,我会得到一个错误信息,如:
Error attempting to slurp file 'PP.pm'
Malformed UTF-8 at line 179 col 66
这使我能够很容易地看到原始文件中的问题字符,并将其改为有效的 UTF-8。
51.6. 将 Perl 模块同时导入 Perl 和 Raku 中
在修改现有的 Perl 模块,使其既能被 Perl 使用,又能被 Raku 使用时,我发现了两个最后的问题,它们是重叠的。
在 Perl 程序和它们的 Perl 模块中,当程序的子程序被移到一个现有的或新的 Perl 模块中时,如何处理全局变量集的丢失?
如何将 Perl 模块中的子程序和变量导出到 Perl 和 Raku 程序中?
51.6.1. 问题 1: 全局变量
Inline::Perl5 目前并没有描述访问变量的方法,当然,这种做法根本不推荐,但是,在作者(Stefan Seifert)的帮助下,我找到了一个方法。我们先从一个同时用于 Perl 和 Raku 程序的 Perl 模块开始,比如说 P5.pm,它的样子是这样的(文件 P5.pm):
package P5;
use feature 'say';
use strict;
use warnings;
#| The following module does NOT affect exporting to Raku, it only
#| affects exporting to Perl programs. See program `usep5.pl` for
#| examples.
use Perl6::Export::Attrs; #= [from CPAN] by Damian Conway
our $VERSION = '1.00';
#| Always exported (no matter what else is explicitly or implicitly
#| requested):
our %h :Export(:MANDATORY);
our $pa :Export(:MANDATORY);
#| Export $pb when explicitly requested or when the ':ALL' export set
#| is requested.
our $pb :Export(:DEFAULT :pb);
#| Always exported:
sub set_vars :Export(:MANDATORY) {
%h = ();
$h{a} = 2;
$pa = 3;
$pb = 5;
}
#| Always exported (no matter what else is explicitly or implicitly
#| requested):
sub sayPA :Export(:MANDATORY) {
say " \$pa = $pa";
}
#| Always exported:
sub sayPB :Export(:DEFAULT :sayPB) {
say " \$pb = $pb";
}
#| Always exported:
sub sayH :Export(:MANDATORY) {
foreach my $k (sort keys %h) {
my $v = $h{$k};
say " key '$k', value '$v'";
}
}
1; #= mandatory true return
51.6.2. 问题 2: 导出全局变量
正如在 P5.pm
模块中所指出的,Perl6::Export::Attrs
所提供的导出信息只供使用 P5.pm
的 Perl 代码使用(它不会影响使用 P5.pm
的 Raku 程序)。然而,在任何 Perl 模块中插入 "use Perl6::Export::Attrs;",可以大大简化导出任务,无需大量的 Perl 代码。人们不一定非要使用它,但我强烈推荐使用它。还有一个好处是,最终将 Perl 模块移植到 Raku 上会更容易。
51.6.3. 使用 P5 模块的 Perl 程序
可以在 Perl 程序中像这样访问 Perl 模块中的对象(文件 use5.pl):
use feature 'say';
use strict;
use warnings;
use lib qw(.);
use P5 qw($pb sayPB); # <== notice the explicit requests
set_vars;
my %h = %P5::h;
say "Current globals in P5:";
foreach my $k (sort keys %h) {
my $v = $h{$k};
say " key '$k', value '$v'";
}
sayPA();
sayPB();
say << "HERE";
修改 P5 中的当前全局变量
\$P5::h{a} = 3
\$P5::h{c} = 5 # a new key/value pair
\$P5::pa = 4
\$P5::pb = 6
HERE
$P5::h{a} = 3;
$P5::h{c} = 5;
$P5::pa = 4;
$P5::pb = 6;
say "Revised globals in P5:";
sayH();
sayPA();
sayPB();
51.6.4. 使用模块 P5 的 Raku 程序
而在 Raku 程序中可以像这样访问 Perl 模块的对象(文件 use5.raku):
#| IMPORTANT
#| Notice no explicit use of Inline::Perl5, but it
#| must be installed.
use lib:from '.'; #= Must define the Perl lib location with this syntax.
use P5:from; #= Using the Perl module.
#| IMPORTANT
#| =========
#| Bind the hash variable so we can modify the hash.
#| For access only, use of the '=' alone is okay.
set_vars;
my %h := %*PERL5;
say "Current globals in P5:";
for %h.keys.sort -> $k {
my $v = %h{$k};
say " key '$k', value '$v'";
}
sayPA();
sayPB();
say qq:to/HERE/;
Modify current globals in P5:
\%h = 3
\%h = 5 # a new key/value pair
\$P5::pa = 4
\$P5::pb = 6
HERE
%h = 3;
%h = 5;
#| IMPORTANT
#| Need this syntax to access or modify a scalar:
$P5::pa = 4;
$P5::pb = 6;
say "Revised globals in P5:";
sayH();
sayPA();
sayPB();
这三个测试文件都一起工作,为我的真实代码提供了工作蓝图。
51.7. 第二部分: 使用真实代码
在本节中,我将使用我的一个项目中的文件:我的大学班级网站(见这里)。我在 2009 年开始做这个网站,并经常对它进行添加和维护,所以它有很多残缺的 Perl 代码。我已经创建了一个 Github 仓库,其中包含了我将在下面的讨论中使用的代码。你可以像这样克隆它:
$ git clone https://github.com/tbrowder/raku-advent-extras.git
我将使用的代码在 raku-advent-extras/2019/ 目录下。这段代码应该是经过消毒的,所以不会显示任何非公开的信息,而且它也不会完全发挥作用,但是主脚本 manage-website.pl,如果在没有任何参数的情况下执行,应该会一直运行。游戏开始吧。
51.7.1. 查找全局变量
在我的实际 Perl 模块中使用上面的语法例子,我首先将 Perl 程序中明显标记的全局变量移到一个新的 Perl 模块中,用一个字母命名,以方便使用,如 G.pm(代表 Global)。例如,在主程序中找到一个变量 $start_time
,我会把它改名为 $G::start_time
,并把它放到 G.pm 模块中,作为我们的 $start_time
。
然后我重复行使程序,在每次运行时找到更多的全局变量,将它们添加到模块中,以此类推,直到找到所有的 globals。
在定义了 Perl 全局变量之后,第一个真正要使用的文件是程序文件 manage-web-site.pl,以及两个 Perl 模块 PicFuncs.pm 和 G.pm,在本文的其余部分将使用它们。为了达到一个共同的起点,在 git repo 中:
$ git checkout stage-0
并确保主脚本运行时不带参数:
$ ./manage-web-site.pl
Usage: ./manage-web-site.pl -gen | -cvt [-final][-useborder][-usepics][-debug][-res=X]
[-force][-typ=X][-person][-stats][-warn]
Options:
[...snip...]
现在开始一个新分支:$ git checkout -b stage-1
。
51.7.2. 阶段 1:将主程序中的所有子程序移到一个新的 Perl 模块中。
这时我打算把 manage-web-site.pl 中的所有 Perl subs 移动到一个新的模块 OtherSubs.pm 中。我会一次一个,执行程序看看是否有问题,一直到所有(或大部分)子程序都存放在新的 Perl 模块中。步骤如下:
-
创建 OtherSubs.pm
-
在程序中添加使用 OtherSubs
-
删除副引号(不需要)
-
将子例程 Build_web_pages 移到 OtherSubs.pm 中。
-
执行 manage-web-site.pl: PROBLEMS WITH GLOBAL VARS!!
我缺少以下符号:
Global symbol "$CL_HAS_CHANGED"...
Global symbol "$CL_WAS_CHECKED"...
Global symbol "$GREP_pledge_form"...
Global symbol "$USAFA1965"...
Global symbol "$USAFA1965_tweetfile"...
Global symbol "$debug"...
Global symbol "$dechref"...
Global symbol "$force_xls"...
Global symbol "$real_xls"...
在我解决了这个问题之后,我继续移动子,并解决新的全局变量,直到所有的子都被移动。在每个子被成功移动后,你应该会看到一条提交信息。我停止了一个子离开程序文件,子 zero_modes,因为它是选项处理的一部分,通常不应该在一个模块中。
51.7.3. 阶段 2:将 Perl 程序移植到 Raku 中
在这部分,我从 stage-1 分支开始了一个新的分支: $ git checkout -b stage-2
。
我相信每个 Raku 程序员都会以不同的方式将 Perl 程序移植到 Raku 中,但以下是我的一般配方。
-
将现有的程序,在本例中是 manage-web-site.pl,复制到一个等价的 Raku 名下,- manage-web-site.raku(见下面的注释 1 和 2)。
-
执行 manage-web-site.pl:PROBLEMS!
我得到了以下错误:
===SORRY!===
Could not find feature at line 3 in:
inst#/home/tbrowde/.perl6
inst#/usr/local/rakudo.d/share/perl6/site
inst#/usr/local/rakudo.d/share/perl6/vendor
inst#/usr/local/rakudo.d/share/perl6
ap#
nqp#
perl5#
然后我发现了一个我在一般过程中没有解决的问题:全局符号冲突。当我试图使用 Raku 版本的模块 Geo::Ellipsoid,而一些 Perl 版本也在使用它时,就发生了这种情况。我通过在程序文件中注释掉 Raku 版本并使用 Perl 版本解决了眼前的问题。
在解决了这个问题之后,我继续删除或替换使用过的模块,处理更多的全局变量,查找或忽略缺失的子程序,用 =begin comment/=end comment
替换 =pod/=cut
,删除不需要的圆括号,使用 Raku 惯用法(例如,Raku 的 'for' 与 Perl 的 'foreach'),并修复问题,直到所有问题都得到解决。在每个问题成功解决后,你应该会看到一条提交信息。我还试图在工作时清理代码。
51.7.4. 阶段 3:整理 Raku 程序
最后,程序 manage-website.raku 运行(没有输入参数),没有错误。这时我检查出了一个 stage-3 分支,用于清理一下程序:git checkout -b stage-3
。我删除了很多注释,并删除了圆括号。我还从使用模块中删除了子程序文件中现在没有实际使用的模块,在子程序被移动后。此外,我还把帮助系统做得更干净了一些。我留下一个明显的 Raku 功能,作为用户的练习:在选项选择的丑陋的 if/else
块中,改为使用 Raku 的 when
块。
我们从一个有大约 6600 行丑陋 Perl 代码的 manage-web-site.pl 文件开始,完成了一个 Raku 版本,manage-web-site.raku。只有不到 800 行,看起来更干净。我们还没有完成移植:我们还得测试每个选项的正常功能(我相信一定会有龙🐉!)。理想情况下,我们还会在这个过程中加入测试。但我们还没有所有必要的内容,所以我们就到此为止了(不过,请在第 9 天跟随我在本篇文章的第二部分中了解我的下一步工作)。
51.8. 总结
您已经看到了一种将 Perl 代码轻松移植到 Raku 的方法,我希望它能帮助那些正在考虑转向 Raku 的人看到,它可以通过较小的步骤反复完成,而不是花费大量的时间。第 9 天这篇文章的第 2 部分将尝试迈出下一步,将一个 Perl 模块转换为 Raku,并让它同时被 Perl 和 Raku 调用者使用。
我 ❤️❤️ Raku!😊。
🎅圣诞快乐🎅和🥂祝大家新年快乐🎉,用查尔斯-狄更斯的 Tiny Tim 的不朽名言,愿✝"上帝保佑我们,每一个人!"✝[参考文献 2] ✝"上帝保佑我们,每一个人!
51.8.1. 附录
笔记
其实我是在 stage-1 分支的时候不小心开始重命名文件的,抱歉。
'.raku' 的文件扩展名是社区公认的 Raku 可执行程序的约定。然而,在可预见的将来,它的使用(在 *nix
系统上)取决于是否安装了 Rakudo 编译器和其他两个条件之一。(1)用户的程序文件用 chmod x
标记为可执行文件(2)以 raku myprog.raku
的方式执行程序。希望不久的某一天,当 Rakudo 编译器的可执行文件以 raku 的形式出现,并且安装在你的系统上时,将上面指令中的 perl6 替换为 raku。(Windows 和 Mac 用户将不得不从其他来源获得他们的指令。)
参考文献
电影(1964 年)。"奇异博士"或:"我是如何学会停止担心和爱上炸弹的" (见 Imdb.com)
《圣诞颂歌》是查尔斯-狄更斯(1812-1870)的短篇小说,狄更斯是维多利亚时代著名的流行作家,他的众多作品包括《匹克威克文件》、《奥利弗-特威斯特》、《大卫-科波菲尔》、《荒凉山庄》、《伟大的期望》和《双城记》。
使用的 Raku 模块(用 zef 安装)。
-
Inline::Perl5
CPAN 使用的 Perl 模块(与 cpanm 一起安装)
-
Perl6::Export::Attrs
52. 第二天 - Cro::HTTP CRUD 指南
大家好!
今天我们将通过本篇教程来介绍使用 Cro 编写一个简单的 CRUD 服务。不耐烦的朋友,源码链接在文章最后。
52.1. 为什么我要看这段文字和代码?
-
Cro::HTTP 的用法, 用于服务器端应用的认证+授权和 CRUD 资源的服务
-
Cro::WebApp 模板的用法
-
Cro::HTTP::Test 的用法
-
设置服务:Docker、nginx 反向代理
52.2. 我今天为什么要看别的东西?
-
使用了一个过于简化的模拟内存数据库。使用任何你认为合适的工具来获得可靠的解决方案。
-
对于服务器端应用来说,项目复杂度被降到最低:客户端没有智能 javascript,没有用户友好的 UX 模式。
-
这篇文章涵盖了很多基础知识,并不是针对有经验的用户。
52.3. 我们开始吧
因此,我们要写一个博客聚合器。
用户可以注册、登录和注销。他们可以创建新的帖子、查看帖子、编辑和删除他们的帖子。
让我们开始使用 Cro 命令行工具来存根一个新的项目:
➜ CommaProjects> cro stub http rest-blog ./rest-blog
Stubbing a HTTP Service 'rest-blog' in './rest-blog'...
首先,请提供更多信息。
Secure (HTTPS) (yes/no) [no]:
Support HTTP/1.1 (yes/no) [yes]:
Support HTTP/2.0 (yes/no) [no]:
Support Web Sockets (yes/no) [no]:
➜ CommaProjects> cd rest-blog/
像往常一样,我们还想为我们的项目初始化一个 git 仓库:
$ git init
$ git add .
$ git commit -m 'Initial commit'
让我们看看创建的存根的结构:
-
lib 目录包含了应用程序本身的源码。目前,它只有一个声明了单个路由的示例路由。
-
META6.json 包含我们项目的描述。
-
service.raku 描述了如何启动我们的服务。默认情况下,它在环境变量指定的主机和端口上启动 Cro 服务器,并为请求提供服务,直到用户发送 Ctrl-C。
要启动该应用程序,可以直接运行 service.raku,但更灵活的是编辑 .cro.yml 文件,该文件描述了这个项目由一个或多个服务组成。在那里,service.raku 被指定为入口点的路径,所以 Cro 命令行工具会根据配置为你运行脚本。
我们来试一试吧:
➜ rest-blog git:(master) ✗ cro run .
▶ Starting rest-blog (rest-blog)
🔌 Endpoint HTTP will be at http://localhost:20000/
📓 rest-blog Listening at http://localhost:20000
📓 rest-blog [OK] 200 / - ::1
服务启动后,你可以在浏览器中访问 localhost://20000 并查看 Cro 的 Lorem Ipsum。
一切准备就绪后,那我们就来深入了解一下。
52.4. 数据库
我们先来编写 Blog::Database 类。我们在新目录 lib/Blog 中创建文件 Database.pm6,这样完整路径就是 lib/Blog/Database.pm6。如果你使用的是 Comma IDE,这个过程就更简单了。不要忘记在 META6.json 文件的 provides 部分添加新条目。我们将处理用户和帖子:
#| A mock in-memory database.
class Blog::Database {
has %.users;
has %.posts;
...
}
如你所见,用户和帖子被定义为哈希。其内容将是:
-
用户包含:用户 ID、用户名、密码
-
帖子包含:帖子 ID、标题、正文、作者 ID 和创建日期
对于用户,我们需要一种方法来添加用户(注册),通过 ID(从会话中)或用户名(登录时)来获取用户。这里就不一样了:
method add-user(:$username, :$password) {
my $id = %!users.elems + 1;
%!users{$id} = { :$id, :$username, :$password }
}
multi method get-user(Int $id) { %!users{$id} }
multi method get-user(Str $username) { %!users.values.first(*<username> eq $username) }
我们使用当前的哈希大小来生成新的 ID,getters 是作为哈希的琐碎操作来实现的。
帖子是我们的 CRUD 资源,所以我们希望有更多的方法:
-
Create
method add-post(:$title, :$body, :$author-id) {
my $id = %!posts.elems + 1;
%!posts{$id} = { :$id, :$title, :$body, :$author-id, created => now }
}
-
Read
method get-post(UInt $id) { %!posts{$id} }
-
Update
method update-post($id, $title, $body) {
%!posts{$id}<title> = $title;
%!posts{$id}<body> = $body
}
-
Delete
method delete-post($id) { %!posts{$id}:delete }
有了这些,我们就可以继续了。
52.5. 授权与认证
关于授权与认证的话题,有很多文章都有解释,这里我们就从 Cro 用户的角度看看它的工作原理。
首先,我们需要定义一个 Session 类。会话在服务器端保存着用户当前的数据。对于每个新的客户端,我们的服务都会创建一个新的会话对象,并给客户端发回一个特殊的 “key”(会话 ID),并说:“这是你的 session key,你可别把它丢到某个地方!”。这样一来,客户对其特定的会话一无所知,但它知道如何说:“我想要这个页面,哦,对了,这是你给我的钥匙,也许会有更多糖果只给我!”。
服务器知道如何将 key 与对应到特定的会话对象,并且可以根据其数据决定如何处理这个请求。
让我们在 Blog::Session 中定义一个非常简单的会话类:
use Cro::HTTP::Auth;
class Blog::Session does Cro::HTTP::Auth {
has $.user-id is rw;
method logged-in { $!user-id.defined }
}
subset LoggedIn of Blog::Session is export where *.logged-in;
我们的类必须要 does Cro::HTTP::Auth
角色,才能被 Cro 识别为会话持有者类。我们还将用户的 ID 存储在一个属性中,并提供了一个方法来检查用户是否已经登录:如果用户有 ID,那么这绝对不是什么匿名潜伏。
我们还为创建的类型提供了一个方便的子集(LoggedIn
是 Blog::Session
的子集,其中 logged-in
方法返回 True)。
设置 “key” 有不同的方式(cookie,headers 等),Cro 也支持各种设置(内存存储,持久化存储,redis 存储,可以添加更多),但为了简单起见,我们将使用内存、cookie 为基础的会话支持。
那么,其次,我们如何启用呢?我们的服务器从网络上接收到一个请求,对其进行解析,然后传递处理,再发回一个响应。在这中间的某个环节,我们需要添加一些东西:
-
对于新用户,创建一个会话,并在响应中加入“这是你的 key,勇敢的人!”。
-
对于有 key 的用户,检索会话,并告诉路由“这是用户的会话数据!”
我们可以在很多地方添加这样一个软件在中间工作,例如中间件。
第一个“正常”的地方是服务器级,第二个“正常”的地方是路由级。它们有不同的利弊,但是这次我们要到 service.raku 中,给我们的服务器添加一个:
...
application => routes(),
before => [
Cro::HTTP::Session::InMemory[Blog::Session].new(
expiration => Duration.new(60 * 15),
cookie-name => 'XKHxsoOwMNdkRrgqVFaB');
],
after => [
...
不要忘记导入我们的 Blog::Session
类。
除了递给 Cro::HTTP::Server
构造函数的其他选项(如主机、端口和要服务的应用程序),我们在参数前指定包含我们要应用的中间件列表。我们配置 Cro::HTTP::Session::InMemory
时,将我们的会话类作为类型参数,并说“我想使用这种类型的会话对象”。我们还指定了 cookie 的名称和过期时间,这样用户就需要再次登录。每当用户发出新的请求时,过期时间都会重新设置,所以积极浏览网站的用户不会看到突然出现的"登录"页面。
我们为什么要在服务器级而不是路由级添加呢?这是一个惊喜工具,以后会对我们有所帮助!
当我们在 service.raku
中的时候,创建一个应用程序范围的数据库并将其传递给我们的路由会很方便。
创建一个新的 Blog::Database
对象,并将其传递给 routes
子例程,同时将其签名打上补丁,使其有一个参数。在更复杂的应用中,我们可以在这里连接到一个持久化的数据库,做各种检查等。
现在终于可以编写一些路由代码了!
52.6. 路由:原理
在我们的应用中,我们有两个模块,即 Auth
和 Blog
,分别负责认证和博客功能。尽管它们本身并不大,但为了便于演示,我们会把它们分成不同的模块。
正如一篇关于 Cro 方法的文章中所描述的那样,用 Cro::HTTP
构建的 Web 应用程序只是一条从“网络输入”到“网络输出”的双向管道。所有的底层业务如解析都已经为用户完成了。
当管道建立起来之后(这是用 service.raku 入口点中的 Cro::HTTP::Server
用法来完成的),中间件也到位了,我们应用的“核心”就是路由。
从高层次的角度讲,路由就是一种接受请求并发出响应的东西。
只要满足约束条件,我们就可以用任何合适的方式编写路由,但对于大多数应用来说,使用一个方便的 route
子程序和一堆辅助子程序就足以完成任务了。
正如你在我们的存根项目中看到的那样,我们的 Blog::Routes
模块已经包含一个单一的示例路由,该路由为我们之前看到的虚拟对象提供服务。
为了使我们的应用更有用,我们将添加更多的路由。关于 API 的详细描述请参考 Cro::HTTP::Router 文档。
52.7. 路由:开始
我喜欢我的模块保持有序。由于我们写的是一个博客应用,自然博客路由器应该在 Blog::Routes
模块中,但存根只用 Routes
来迎接我们。只要将文件移动到新的目录下,调整 META6.json
数据即可(如果使用 Comma,则直接拖拽即可)。
现在,我们来调整一下它的内容:
use Cro::HTTP::Router;
sub routes($db) is export {
route {
after { redirect '/auth/login', :see-other if .status == 401 };
get -> 'css', *@path {
static 'static-content/css', @path
}
}
}
我们用几行代码替换了默认路由。
用 block 调用 after
子程序,在路由层面增加了一个新的中间件。对于每一个响应,block 都会被执行,它是一个主题,中间件会检查响应的状态码。如果是 401(Unauthorized),我们设置一个重定向到我们的(未来)登录页面。
第二个子程序调用是定义路由,它将服务于静态内容—我们的 CSS 文件。为了让我们的 HTML 页面看起来不那么悲伤,我们将使用 Bootstrap 工具包,所以我们在项目的根目录下创建 static-content/css
目录,并在那里添加 bootstrap.min.css
文件。这个文件可以从 官方的 Bootstrap 框架页面、各种 CDN 服务或者任何你可能想要的样式服务方式获得。当然,布局是由你自己决定的,没有什么必要。
52.8. 路由:授权
让我们为与授权相关的路由创建一个新的路由。
创建 Blog::Routes::Auth
模块,并声明 auth-routes
子程序,该子程序返回路由调用的结果。
use Cro::HTTP::Router;
sub auth-routes(Blog::Database $db) is export {
route {
# Routes will be here!
}
}
它暂时没有路由,但我们已经可以把它加入到我们的"主"路由中。让我们把它添加到 Blog::Routes
模块中。
use Blog::Routes::Auth;
sub routes(Blog::Database $db) is export {
route {
...
include auth => auth-routes($db);
...
}
}
为了包含一个路由,我们使用 include
,这应该很容易记住!
如果这个调用看起来像个魔术,我们可以把它改写成:
include(auth => auth-routes($db));
这只是一个带有命名参数的调用。key
可以是一个字符串或一个字符串列表,并为包含的路由的每个路由定义一个前缀。值只是调用我们的 auth-routes
,创建一个新的路由。
我们还传递了 $db
参数,因为我们当然希望在新路由的路由中使用我们的模型。
在跳转到路由的实现之前,我们还有一个问题需要看…
52.9. Cro::WebApp 模板
Cro::HTTP 不是一个 web 框架。但它可以成为一个框架。怎么做?
它给了你响应 HTTP 请求的能力,而不是用它自己决定的"如何"来束缚你。
-
你想对你的数据进行建模吗?只要建模就好了,不管你想用什么方式。
-
你想为你的用户提供 HTML 服务吗?只要准备好它,无论你想要什么方式。
-
你想处理请求和响应吗?那就交给 Cro::HTTP 吧!
我们还没有讨论的是 HTML 模板。事实上,除了从用户那里获取请求数据之外,我们还需要在之前用一些漂亮的页面来迎接他们。要做到这一点,我们将使用 Cro::WebApp
模块。
它是一个语法接近 Raku 的模板引擎,因此需要一些时间来适应它。在阅读模板代码之前,非常建议先浏览一下 它的文档页面。
模板代码由于种种原因(没有人喜欢枯燥的 HTML,大家更不喜欢模板),特意没有包含在这篇文章中,但在代码仓库中可以找到。
52.10. 路由: 授权的反击
我们的注册页面 URL 将看起来像 /auth/register
。它接受 GET 和 POST 请求。最后是代码。
sub auth-routes(Blog::Database $db) is export {
route {
get -> Blog::Session $session, 'register' {
template 'register.crotmp', { :logged-in($session.user-id.defined), :!error };
}
post -> Blog::Session $session, 'register' {
request-body -> (:$username!, :$password!, *%) {
with $db.get-user($username) {
template 'register.crotmp', { error => "User $username is already registered" };
} else {
$db.add-user(:$username, :password(argon2-hash($password)));
redirect :see-other, '/auth/login';
}
}
}
...
}
}
第一次调用 get 会创建一个处理程序,用于 GET 请求到 /auth/register
URL。auth
部分在这个路由中是一个默认的前缀,因为我们把它指定为一个命名参数。
它调用 Cro::WebApp
模块中的 template
,用第二个参数中指定的数据来渲染我们的模板。处理程序块的第一个参数 $session
与 URL 部分无关,指定这个处理程序需要一个会话对象来处理这个用户。
第二条路由是对同一 URL 的 POST 请求。它使用 request-body
将表单数据解包到变量中。接下来的几行检查用户是否已经存在,在这种情况下会呈现一个错误,否则就创建一个新用户。不要忘记对密码进行哈希处理! 当新用户账户被创建后,我们设置一个重定向到登录页面。
request-body
很聪明,可以不做任何改变地根据内容类型解析请求数据,无论是 json、普通表单、multipart 表单数据还是任何你可以实现处理的内容类型。
登录页面非常相似:GET 返回一个模板,POST 收集数据并处理,但有一个变化。
post -> Blog::Session $session, 'login' {
request-body -> (:$username!, :$password!, *%) {
my $user = $db.get-user($username);
with $user {
if (argon2-verify($_<password>, $password)) {
$session.user-id = $_<id>;
redirect :see-other, '/';
} else {
template 'login.crotmp', { :!logged-in, error => 'Incorrect password.' };
}
} else {
template 'login.crotmp', { :!logged-in, error => 'Incorrect username.' };
}
}
}
虽然几乎所有的东西都是相似的,因此并不难理解,但我们可以看到这个路由处理程序实际上是使用 $session
对象在登录时分配一个用户 ID。
其他的事情都不需要做,Cro::HTTP 会把这个 session 保存在一个存储空间里,在这个用户的下一次请求中,只要传递了 session key,处理程序就可以检查这个用户是否已经登录,如果是,ID 是什么。
其他的东西都是典型的:request-body
来解析表单、模板、重定向和 Raku 代码。
至于注销,代码也很短。
get -> Blog::Session $session, 'logout' {
$session.user-id = Nil;
redirect :see-other, '/';
}
在这里,我们可以随意擦除会话对象数据,然后重定向。
52.11. 路由:博客
除了写无聊的模板,现在我们应该有一个简单的应用程序,具有创建新用户和登录的功能。
但是当用户被重定向到我们网站的索引页时,一个可悲的错误欢迎他们。让我们让它变得更受欢迎吧!
这就需要一个新的模块,Blog::Routes::Blog
。
再次,将其纳入我们的主路由中,用一段简单的代码:
use Blog::Routes::Blog;
...
include blog-routes($db);
请注意,我们没有传递一个命名参数。原因是,虽然我们希望与博客相关的路由在 /blog
前缀下服务,但这个路由也会处理索引页,/
,没有前缀。相反,我们可以在后面做一个简单的技巧。
在索引页,我们显示所有用户的帖子。首先,我们需要在 Blog::Database
中定义一个方法来收集我们需要的所有信息。
method get-posts {
%!posts.values.map({
$_<username> = %!users{$_<author-id>}<username>;
$_;
}).sort(*.<created>);
}
虽然看起来有点神秘,其实我们只是模仿 SQL JOIN 子句,因为我们想在帖子中显示作者的用户名,而不仅仅是 ID。
可以这样理解。
-
对于
%!posts
hash, take all values ⇒ -
对于每个值,也就是哈希值本身,添加一个新的 item ⇒
-
item 的键是用户名,item 的值是由帖子记录中存储的
author-id
键获得的%!users
项的 username 值 ⇒ -
我们不使用显式返回,隐式返回一个块执行的最后结果。由于新的哈希键的赋值会返回赋值项的值而不是哈希值,所以我们需要一个单一的
$_;
来返回哈希值 ⇒ -
按创建日期对所有条目进行排序
有了这些,我们就可以为索引页写一个处理程序。唉,那里没有什么有趣的东西在等着我们。
get -> Blog::Session $session {
my $user = $session.logged-in ?? $db.get-user($session.user-id) !! {};
$user<logged-in> = $session.logged-in;
my $posts = $db.get-posts.map({
$_<created> = Date.new($_<created>).Str;
$_;
});
template 'index.crotmp', { :$user, :$posts };
}
有了可用的会话对象和强大的数据库,我们收集数据并将其推送到一个模板中。很好!
由于我们现在已经有了 CRUD 的 R 部分,我们需要计划剩下的部分(这次不是 REST!):创建、编辑和删除。
每个操作的 URL 将以 /blog
为前缀。我们是否需要创建另一个路由模块来避免为每个路由处理程序写出这个恼人的前缀?也许是,但也许不是。在这种情况下,我们就内联 include
吧。或者是内联 include?
不管它是什么方式。
include <blog> => route {
get -> ...
post -> ...
}
由于我们刚刚调用了我们的 *-routes
子程序,所以我们可以直接省略这层间接,牺牲四个空格的缩进。
(顺便说一下,*-routes
的命名方案的使用没有义务,但它很容易记忆和使用)
看完注册路由处理程序,创建帖子的那个是典型的:get 会用表单服务一个模板,而 post 会用 request-body 解析表单,做一个调用 DB 保存帖子,并做一个重定向。
接下来的两条路线是更新和删除。我们把它们写出来。
post -> LoggedIn $session, UInt $id, 'update' {
with $db.get-post($id) -> $post {
if $post<author-id> == $session.user-id {
request-body -> (:$title!, :$body!) {
$db.update-post($id, $title, $body);
redirect :see-other, '/';
}
} else {
forbidden;
}
} else {
not-found;
}
}
post -> LoggedIn $session, UInt $id, 'delete' {
with $db.get-post($id) -> $post {
if $post<author-id> == $session.user-id {
$db.delete-post($id);
redirect :see-other, '/';
} else {
forbidden;
}
} else {
not-found;
}
}
注意我们使用 LoggedIn
子集作为 $session
对象的类型。在路由请求的过程中,它的 session 对象将被检查是否符合要求(在本例中,用户是否登录),如果不符合要求,将形成 Unauthorized response。
现在仔细看这段代码,我是看到它来了…
当在罗马的时候,做罗马人做的事,他们说,而且,事实上,当在 Raku 中写代码时,这种疯狂的模板数量是荒谬的!我要求神和女神,甚至是圣诞老人自己,我们希望并能做得比这更好。我要求诸神和女神,甚至是圣诞老人本人,我们想要并且能够做得更好!而这些都是由可怕的贡献者所带来的语言和库。
而来自全球各地的了不起的贡献者给我们带来的语言和库,让我们让它变得更整洁吧。
#| A helper for executing code blocks
#| only on posts one can access
sub process-post($session, $id, &process) {
with $db.get-post($id) -> $post {
if $post<author-id> == $session.user-id {
&process($post);
} else {
forbidden;
}
} else {
not-found;
}
}
我们取一个会话,帖子的 $id
和要做的操作。如果帖子存在,检查该用户是否有权限修改它。一切正常?执行代码! 有问题吗?通知用户!
现在我们可以把上面的 POST 路由重新写成:
post -> LoggedIn $session, UInt $id, 'update' {
process-post($session, $id, -> $ {
request-body -> (:$title!, :$body!) {
$db.update-post($id, $title, $body);
redirect :see-other, '/';
}
});
}
post -> LoggedIn $session, UInt $id, 'delete' {
process-post($session, $id, -> $ {
$db.delete-post($id);
redirect :see-other, '/';
});
}
即使是现在,我也想和 Santa 讨论一下,是否值得把重定向调用的因素纳入我们的 helper 子程序。我的回答是:没有。
希望在这里采取的观点是,可以灵活地将处理请求的逻辑因子化。还有应用程序中的角色。还有 cookie。Om-nom-nom。
52.12. 将 nginx 设置为反向代理
比方说,你想把你的应用程序隐藏在 nginx 反向代理后面。无论是负载均衡、免费缓存还是其他原因,都有理由这么做。因为我们做的应用可以用它的原生工具来服务,所以不需要做那么多配置来实现。前提是你的服务器上要安装 nginx。
接下来,你用 Cro 命令行工具运行器运行它,有了工作的端口,你就可以修改 nginx 配置中的服务器部分(最简单的情况下,GNU/Linux 系统上的位置是 /etc/nginx/nginx.conf
)。
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://localhost:20000/;
}
...
}
下一步,使用 nginx -t
命令检查结果配置是否正确,并使用 nginx -s reload
重新加载服务器。
鉴于你的应用程序已经启动并运行,你应该能够访问 localhost 并看到主页面。
还可以做很多其他的事情:写一个单元,以便在故障或机器重启的情况下轻松管理你的服务,你的 nginx 配置可能会更有趣,还可以添加 HTTPS 支持(强烈推荐),因为我们的服务有 auth 片段,通过纯 HTTP 发送密码是很危险的。
52.13. 构建 docker 镜像
所以服务是很酷的,但现在大家谈论的是 Docker 和 Kubernetes。介意把你的应用容器化吗?想个好听的名字,然后在你的项目根目录下用它执行这个命令。
docker build -t $my-cool-app-name-here
这就是全部! 一个容器就为你准备好了,你可以按照自己的意愿来管理它。
52.14. 结束语
在这个比较长的教程中,我们讨论了一些基本的话题。
-
中小型 Cro 应用的结构。
-
一般的授权和认证部分以及具体的实现实例。
-
常用的路由处理程序的实现。
-
你的应用程序的服务和部署。
当然,还有更多的功能以及很酷的技巧,然而这远远超出了这篇已经很长的文章。
包括模板在内的完整源码可以在 这里获得。
恭喜你完成本教程! 在 12 月到来之际,祝你喝上一杯热饮,度过美好的一天。
53. 第三天 - 栈帧规约
53.1. 什么是栈帧?
对于那些不熟悉栈的人来说,它是你的程序使用的一点内存。它的速度很快,但有限。
每当你调用一个过程(函数、方法… 命名是一件很复杂的事情),你的程序就会在栈上得到一点存储空间,我们称之为帧。
栈帧被用来存储参数、局部变量、临时存储以及一些关于调用上下文的信息。
这意味着,如果你有一个递归过程调用,你的程序会不断地要求栈帧,直到你最终返回一个值, 内存被释放出来。
53.2. 一个快速而简单的例子
让我们举一个基本递归排序算法的标准例子:
sub factorial (Int $n --> Int) {
$n == 0 ?? 1 !! $n * factorial($n - 1)
}
这是一个非常简单的递归示例,通常我们在这段代码中不必担心栈帧的堆积。也就是说,这是一个很好的起点,可以展示如何减少堆积。
GOTO reduction:
这种减少栈帧堆积的方式对大多数人来说应该是很熟悉的,这就是程序化编程处理递归的方式。
这种模式最基本的实现是这样的。
sub factorial (Int $n is copy --> Int) {
my Int $result = 1;
MULT:
$result *= $n;
$n--;
goto MULT if $n > 0;
return $result;
};
GOTO 在 Raku 中还没有实现,但应该相当明显,我们可以很容易地用一个现有的关键词来代替它:
sub factorial (Int $n is copy --> Int) {
my Int $result = 1;
while $n > 0 {
$result *= $n;
$n--;
}
return $result;
}
不过这确实违背了试图使用递归的目的。因此,Raku 提供了 samewith
这个关键词:
sub factorial (Int $n --> Int) {
$n == 0 ?? 1 !! $n * samewith($n - 1);
}
这下好了,递归而不至于产生上千个栈帧。虽然我还是觉得我们缺少了一些东西…
53.3. 蹦床
蹦床是功能编程中的一种设计模式。 与普通的 GOTO 式换算相比,它有点复杂,但在正确的人手中,它可以非常强大。
蹦床模式背后的基本原理如下:
-
我们可以期望用我们正在计算的值做一些事情。
-
我们可以将我们的 TODO 传递到计算值的函数中。
-
我们可以让我们的函数生成自己的延续。
sub trampoline (Code $cont is copy) {
$cont = $cont() while $cont;
}
所以我们给 trampoline
传递一个函数。该函数被调用。这个函数有选择地返回一个后续函数。只要我们得到一个后续函数,我们就会继续调用它并分配结果,直到我们完成。
这就需要对 factorial
函数进行一下改写。
sub factorial (Int $n, Code $res --> Code) {
$n == 0 ?? $res(1) !! sub { factorial($n - 1, sub (Int $x) { $res($n * $x) }) }
}
要解开那个堆积的函数堆:
-
如果
$n
是 0, 我们就可以继续进行下去. -
否则我们返回一个匿名函数,再次调用
factorial
。 -
前面的步骤一直传播到我们到达 0,在这里我们得到用 1 调用的结果。
-
这就将之前的
$n
与 1 相乘,并将结果向后传播。 -
最后,结果被传播到最外层的块,并被传递到延续中。
那么,我们会使用 trampoline
的方式如下:
trampoline(sub { factorial($n, sub (Int $x) { say $x; Nil }) });
又是一堆纠缠不清的函数要解包。
我们向 trampoline
发送一个匿名函数 调用一个数字 $n
的 factorial
和一个匿名的 continuation
。
阶乘的延续是说阶乘的结果,然后停止(Nil)。
53.4. 奖励回合
为什么你要用 trampoline
来做一些可以用普通的循环来做的事情呢?
sub factorial-bounce (Int $n --> Code) {
sub { factorial($n, sub ($x) { say $x, factorial-bounce($x) }) }
}
54. 第四天 - 不被 tripscodes 绊倒
大家好,今天我们要看的是三联码的实现,三联码是一种用于在互联网上匿名签署帖子的散列法。
有不同的算法可以做到这一点,但我们感兴趣的是一种生成非安全的老式三联码的算法。
54.1. 那么它是什么呢?
假如有一个网站允许在匿名的情况下留言。无需注册,无需登录,无需用户名。
你回复一个帖子,然后一个人在回复你的回复。你开始了一段对话。你知道你的帖子是你的。但其他用户呢?你是在和同一个人说话,还是一群孩子在玩弄你的把戏?不知道! 在某些情况下,为了解决这种混乱,可以使用一个三联码。
这个想法很简单:随着你的帖子,你可以传递你想要的昵称和密码。网站会把一个密码和哈希值转换成一个三联码。在显示帖子时,三联码会附在信息上,所以你可以确定这是同一个知道密码的人。当然,没有人要求人们宣称自己帖子的作者身份,但我们暂且不说,因为我们对一个实现方式很感兴趣。
54.2. 手边的例子
实现这个算法只需要一个子程序。然而我们需要一种方法来测试我们的三联码。让我们为你的 tripcode
子程序定义一些测试规则。
use Test;
is tripcode('a'), '!ZnBI2EKkq.';
is tripcode('¥'), '!9xUxYS2dlM';
is tripcode('\\'), '!9xUxYS2dlM';
is tripcode('»'), '!cPUZU5OGFs';
is tripcode('?'), '!cPUZU5OGFs';
is tripcode('&'), '!MhCJJ7GVT.';
is tripcode('&'), '!QfHq1EEpoQ';
is tripcode('!@#heheh'), '!eW4OEFBKDU';
54.3. Raku 代码
现在我们来看看算法。
-
转义 HTML 字符
-
将所有字符转换为 CP932 编码。对于无法转换的字符,使用
?
符号 -
将产生的字节解码为 UTF8
-
为我们的哈希生成盐。要做到这一点,将
H.
字符串添加到我们的解码字符串中(因为它可能是空的!),取第二个和第三个字符。接下来,用点来代替任何"奇怪"的字符(在 ASCII 术语中,任何代码低于 46 (.) 而高于 122 (z) 的字符)。 -
将一些非单词字符(:;<=>?@[\\]^_`)翻译成 ABCDEFGabcdef。
-
使用 UNIX 函数 crypt 与解码后的字符串和我们得到的盐,并采取了它的最后 10 个字符。
-
这就是全部了!
有相当多的步骤,但让我们看看如何在 Raku 中编写这样一个任务。
让我们从一个子例程声明开始。
sub tripcode($pass is copy) {
}
我们将在原地修改 $str
变量,所以参数的复制特性将帮助我们防止"传递的 Str 值是不可变的"错误。
接下来,对 HTML 进行转义:
sub tripcode($pass is copy) {
$pass .= trans(['&', '<', '>'] => ['&', '<', '>']);
}
通过 trans
方法,我们可以用"从左到右"的对应关系替换字符串中的字符,所以 &
被替换为 &
,<
被替换为 <
等等。
接下来的事情-与 Windows 932 共舞。
$pass .= trans(['&', '<', '>'] => ['&', '<', '>']);
$pass = ([~] $pass.comb.map({ (try .encode('windows-932')) // Buf.new(0x3F) })).decode;
让我们想象一下,一步步写出这些行。
# split $pass into single characters
$pass.comb
# for every character in the list resultring from `comb` method call
$pass.comb.map({ })
# try to encode it into the encoding we want
$pass.comb.map({ try .encode('windows-932') })
# when `try` returned `Nil`, use `//` operator which means `If the left side is not defined,`use the right side
$pass.comb.map({ ((try .encode('windows-932')) // Buf.new(0x3F) })
# Use [~] unary metaoperator, which is a shortcut for "join this array using this operator to join two single elements"
([~] $pass.comb.map({ (try .encode('windows-932')) // Buf.new(0x3F) }))
# At last, decode the resulting buffer and assign it to the variable
$pass = ([~] $pass.comb.map({ (try .encode('windows-932')) // Buf.new(0x3F) })).decode;
现在我们需要为我们的哈希生成一些盐。
my $salt = "{$pass}H.".substr(1, 2).subst(/<-[. .. z]>/, '.').trans(':;<=>?@[\\]^_`' => 'ABCDEFGabcdef');
首先,我们在密码中添加 H.
部分,然后使用 substr
调用取第二个和第一个字符。 请注意,第二个调用是 subst
,它将 regex 范围外的任何字符用点来代替。在这里,substr
是 substring
的简称,而 subst
是 substitution
的简称。 然后就是我们的 trans
方法了。
作为接下来的事情,我们需要调用 UNIX 加密函数。幸运的是,我们不需要实现它! 在 Raku 的生态系统中,已经有一个由 Jonathan Stove++
编写的 Crypt::Libcrypt 模块。让我们安装它吧。
zef install Crypt::Libcrypt
现在,我们可以导入这个模块,并在我们的服务中使用 crypt
子例程。最后一行很简单:
'!' ~ crypt($pass, $salt).substr(*-10, 10);
我们不需要编写显式的 return
语句,因为块的最后一条语句被视为其返回值。调用 crypt
子例程和我们的老朋友 substr
,第一个参数看起来很有趣。第二个参数照例是我们想要的字符数,而第一个参数是一个表达式, 用的是 Whatever Star。在调用时,substr
调用者的长度会被传递到这个微型代码块中,因此将其翻译为 ’foo'.substr('foo'.chars() - 10, 10)(但里面更聪明)。
梳理好一切,我们得到一个完整的定义。
sub tripcode($pass is copy) {
$pass .= trans(['&', '<', '>'] => ['&', '<', '>']);
$pass = ([~] $pass.comb.map({ (try .encode('windows-932')) // Buf.new(0x3F) })).decode;
my $salt = "{$pass}H.".substr(1, 2).subst(/<-[. .. z]>/, '.').trans(':;<=>?@[\\]^_`' => 'ABCDEFGabcdef');
'!' ~ crypt($pass, $salt).substr(*-10, 10);
}
检查:
> perl6 tripcode.p6
ok 1 -
ok 2 -
ok 3 -
ok 4 -
ok 5 -
ok 6 -
ok 7 -
ok 8 -
成功了,我们准备的所有检查都通过了!由于我们只用了四行代码就成功地实现了这个算法,现在是时候补充一些热饮了。祝你有个愉快的一天!
55. 第五天 - 模块化 Raku 命令行应用
圣诞节前三个星期,圣诞老人的工作坊一团糟。精灵们四处奔波,试图将一切准备就绪,看起来一切都不会。
圣诞老人一走进去,他就被一群不幸的精灵包围,所有人立刻抱怨。
"这个系统太慢了!"一个管道说道:"仅仅打印出一个帮助文件就需要一秒钟。"
"并且帮助文件是错误的!它已经过时了。
"我刚刚发现增加一个将孩子从顽皮的孩子转移到尼斯的命令,除了别的事情之外,都会默默地死去!!!"第三个声音非常骚扰,属于一个名单很长的精灵。
圣诞老人很担心,他一年中的大部分时间都在与许多开发精灵合作,以更新其较旧的系统以使用 Raku,他们认为一切都在进行中。所有不同的模块在测试中都运行得非常好,并且管理该系统的单个命令是他真正没有参与的唯一部分。
他四处寻找负责该项目的精灵 Snowneth,但没有看到他。一连串的问题,圣诞老人发现 Snowneth 几个月前得了小儿流感,当时他仍然生病。
圣诞老人因为没有跟上事情而踢自己,但还有很多事情要做,他记下了便条,然后在 Snowneth 进行检查。现在找出问题出在哪里,为什么没人告诉他!
他打开笔记本电脑,以测试模式试用了主要的系统服务:
helper -?
几秒钟后,系统给了他一个命令行提示符……没别的。
helper -h
这次,他得到了一个文档页面,对其进行了快速扫描,他可以看到许多命令被错误地记录下来,并且至少丢失了两个最新的命令。由于害怕他会看到他打开了 git 日志,所有提交都由他认识的多个名称完成。分配给 Snowneth 团队的初级 Dev-elf。
他关上笔记本电脑,去找咖啡机。
几个小时,然后喝咖啡,圣诞老人和许多年轻的,看上去很担心的精灵挤在一个小办公室里。圣诞老人打开了笔记本电脑,并在他们面前打开了服务脚本的代码,其中有 1000 行。
到现在为止,圣诞老人发现发生了什么事,斯诺思病了,每个人都以为别人会任命新的团队负责人。同时,大三学生竭尽所能完成这项工作。圣诞老人花了一点时间屏住呼吸,并写下了声音。
"首先,我必须道歉。我敢肯定,你们所有人都承受着很大的压力,并且你们都竭尽所能完成这项至关重要的工作。"
大三学生振作起来,到现在,他们都已经收到了这样的信息:他们的工作导致车间运行缓慢,甚至可能取消圣诞节!他们原本期望被大吼大叫,甚至被送去嘲弄驯鹿。 一个陷入困境的小精灵的传统工作。
"我们可以了解以后发生的情况,现在我们需要尽快修复此代码,并畅通无阻。我希望大家都能帮助我完成这项工作。"
到现在,三年级学生在点点头和微笑的时候,圣诞老人很高兴,他应该早已陷入困境,他希望通过让他们参与清理工作,他们可以重新获得信心。
"对,那么我们有什么问题?"
每个人都说了出来,他们很快就列出了一个清单:
-
任何命令启动缓慢
-
某些命令输入验证不正确
-
过时的文档
还有其他几件事,但似乎有三件事是主要的。
"好。这么慢的启动。我认为这很明显吧?"
所有的小学生都看着他,然后一个人举起了手。
"不是吗?你怎么看?无需举手,只需大声说…反过来,不要互相喊叫。"
"是因为脚本太长了吗?我想我读到一个 Raku 脚本在您调用时已编译?如果我们使脚本更短,它将运行得更快?"
"虽然可以,但复杂程度降低可能是一个更好的目标。我们有很多 if 子句之类。要记住的重要一点是,模块代码是预编译的,而主脚本不是。因此,我们将尽可能使用模块。"
初中生点了点头。然后一个人说了出来。
"但是我们使用的是模块,它们都位于脚本顶部,但是我们必须确定人们正在调用的命令,然后确定要传递的参数。然后……"圣诞老人举起了手。
"我可以看到那颤抖。我认为那是我们应该开始的地方。"他指着屏幕上的一段代码。 "谁能告诉我这是怎么回事。"
use Workshop::ListController::Nice;
my $command = @*ARGV[0];
if ( $command ~~ 'list' ) {
my $list-command = @*ARGV[1];
if ( $list-command ~~ 'add' ) {
if ( @*ARGV[2] ~~ 'nice' ) {
Workshop::ListController::Nice.add-child( @*ARGV[3] );
}
等等。 大三学生看着它,互相交谈。 Sniffy 再次开口。
"这有点复杂,它不是以这种方式开始的,但是随着我们添加命令的增加,它变得更大。 而且我们也不会检查孩子的名字是否有效"
"是真的,但我想知道你为什么读 @*ARGS?"
他们的脸上有些困惑。
"为什么我们没有 MAIN 子程序?"
仍然很混乱。
"好。 你们都去查一下。 我会举一个例子。"
当精灵们进入 Raku 文档网站并开始搜索时,他迅速打字。 当他听到他们的惊叹声升起,然后逐渐安静时,他转身离开了键盘。
use Workshop::ListController::Nice;
multi sub MAIN( "list", "add", "nice", ValidChildName $child ) { Workshop::ListController::Nice.add-child( @*ARGV[3] ); }
multi sub MAIN( "list", "add", "nice", Str $invalid-child ) { die "Invalid childname {$invalid-child}"; }
初中同学鼓掌。
"因此,我们可以将 Raku multi 调度与 MAIN 一起使用,以将我们的所有命令创建到相当细致的级别,并进行类型检查。 我认为这是一个不错的起点。 我希望我们能使脚本中的所有内容全部消失。"
第二天,情况看起来更好,脚本启动速度更快,输入验证问题也已解决。 每个人都感觉好多了。 圣诞老人甚至有时间去看 Snoweth 并确保他没事。
"好吧…现在让我们看看。"
他指着文件中剩余的最大子程序,它从此开始。
multi sub MAIN( :$h where so * ) {
say "Usage:";
say " helper list add nice [child] : Adds a child to the Nice list."
say " helper stable rota list : Lists the current rota for cleaning the stables."
一只精灵跳到她的脚上,眼睛闪闪发光。
" Gimlina,我相信您对此有一些想法。"
"我们应该使用声明符块!"圣诞老人笑着说,他昨天不得不抑制小精灵的热情,以使团队专注于手头的任务。 他微笑着点了点头。
"继续。"
"如果在子例程和参数中添加声明符块,则会免费获得预生成的文档。 只要代码更改,它就会更新。"
"你能示范吗?"
她笑了,提出了一些密码。
#| Add a child to the nice list
multi sub MAIN( "list", "add", "nice",
ValidChildName $child #= Child name to add
) {
Workshop::ListController::Nice.add-child( @*ARGV[3] );
}
multi sub MAIN( "list", "add", "nice", Str $invalid-child ) is hidden-from-USAGE { die "Invalid childname {$invalid-child}"; }
"所以 #| 是做什么的?"
"将障碍物固定在其后的任何东西上。 #= 将块附加到前一项。"
"还有 is hidden-from-USAGE 呢?"
她很快打出了:
helper -?
Usage:
helper list add nice [child] -- Add a child to the nice list
Child name to add
圣诞老人点了点头,其余的大三学生都欢呼雀跃。 Gimlina 看起来很高兴,因为他们都转向向帮助程序脚本添加文档。
情况看起来更好,研讨会听起来更加快乐,并且文档进展顺利。当他在一个模块存储库中看到一个合并请求时,圣诞老人正坐在大三学生那里检查最后的事情。他看上去很困惑,他们已经被冻结了一个星期的代码,只能提出紧急的错误修复,并且除了帮助程序脚本之外,他没有其他问题。他再次诅咒自己,以为也许他确实需要一些产品经理精灵。
当他打开合并请求时,他的眼睛睁大了,他转向一个安静的初级人员,他们很聪明,但往往不说话,只是低着头做事。
"嗯? Workshop::ListController::Nice 模块中的 MR 是什么?"
按照他的要求,等待他们过来,他看了看代码,现在他的眼睛已经睁大了,看起来好像从他的脑袋里鼓出来了。
#| Add a child to the nice list
multi sub MAIN is export(:MAIN) ( "list", "add", "nice",
ValidChildName $child #= Child name to add
) {
Workshop::ListController::Nice.add-child( @*ARGV[3] );
}
multi sub MAIN( "list", "add", "nice", Str $invalid-child ) is hidden-from-USAGE is export(:MAIN) { die "Invalid childname {$invalid-child}"; }
"好吧,我在想先生,"精灵以惊人的声音从他旁边说。 "您说模块代码是预编译的。 因此,如果我们将 MAIN 子目录移入模块中,则会对其进行预编译。"
"模块团队可以管理自己的命令行界面和文档!"圣诞老人高兴地叫道,埃里微笑着点了点头。
当天的其余时间很忙,其他所有开发人员(他们一直希望在假期后的积压之战之前冻结一个月的代码,好几个月)被告知新计划。
到最后,帮助程序脚本包含了很长的模块使用语句和一个单独的功能。
use Workshop::ListController::Nice :MAIN;
#| Display the help
multi sub MAIN( :h($help) where so * ) {
say $*USAGE;
}
圣诞老人看着它,然后看着 Snowneth,后者终于在当天下午将它带回了车间。 他耸了耸肩。
"我不知道-? 我已经习惯了-h。 不能伤害对吗?"
圣诞老人点了点头,他们去帮忙在车间工作。
56. 第六天 - 在你的 Raku(仓库)中添加一些(GitHub)动作
经过一段时间的测试后,GitHub 动作终于在 2019 年 11 月向公众推出。它们很快就变得无处不在,并且与 GitHub 最近发布的其他发行版(包(和容器)注册表)结合在一起。
我们可以通过 Raku 模块充分利用它们。好吧,看看如何。
56.1. 我们可以使用一些动作
操作是由存储库中的事件触发的脚本。原则上,您或程序与存储库进行交互时所做的任何操作都可能触发操作。当然,这包括 git 操作,这些操作基本上包括推送到存储库,还包括从 Wiki 中的更改到对拉取请求添加审阅的各种事件。
那你能做什么呢? GitHub 创建了一个包含一些基本工具链以及您选择的语言解释器和编译器的容器。从根本上讲,您拥有的是一个容器,您可以在其中运行由事件触发的脚本。
GitHub 操作位于您存储库中 .github/workflows 目录内的 YAML 文件位置。让我们开始第一个:
name: "Merry Christmas"
on: [push]
jobs:
seasonal_greetings:
runs_on: ubuntu-latest
steps:
- name: Merry Xmas!
run: echo Merry Xmas!
该脚本非常简单。它包含一个工作,只有一个步骤。让我们一点一点地走:
-
我们给它起一个名字,“圣诞快乐”。该名称将显示在您的动作列表中。
-
on 是将触发此操作的事件列表。我们将只列出一个事件。
-
jobs 是一个数组,其中包含将顺序运行的作业列表。
-
每个作业都将具有自己的键,该键将用于引用它(以及存储变量,稍后将对此进行更多介绍),并且可以在自己的环境中运行,您必须选择该环境。我们将使用 ubuntu-latest,这是一个仿生盒子,但还有其他选择(稍后会详细介绍)。
-
job 有一系列步骤,每个步骤都有一个名称,然后是一系列命令。 run 将在该特定步骤中定义的任何环境下运行;在这种情况下,一个简单的 shell 脚本将显示 Merry Xmas!
由于我们已通过 on 命令指示每次在推送到存储库时都运行,因此“操作”标签将显示 运行它的结果,就像这样。如果没有问题,怎么办,因为它只是一个脚本,所以它将显示绿色的复选标记并产生结果:
这些步骤形成一种管道,每个步骤都可以产生输出或更改将在下一步中使用的环境; 这意味着您可以创建仅处理输入并为输出产生某些结果的管道操作,例如此操作
name: "One step up"
on: [push]
jobs:
seasonal_greetings:
runs-on: ubuntu-latest
steps:
- name: Pre-Merry Xmas!
env:
greeting: "Merry"
season: "Xmas"
run: |
sentence="$greeting $season!"
echo ::set-env name=SENTENCE::$sentence
- name: Greet
id: greet
run: |
output=$(python -c "import os; print(os.environ['SENTENCE'])")
echo ::set-output name=printd::$output
- name: Run Ruby
env:
OUTPUT: ${{ steps.greet.outputs.printd }}
run: /opt/hostedtoolcache/Ruby/2.6.3/x64/bin/ruby -e "puts ENV['OUTPUT']"
此操作的第一步(代号为 "Pre-Merry Xmas!")通过 env 声明了几个环境变量。我们将用一个句子整理它们。但是要点在于:GitHub Actions 使用元句,其前面带有 ::
,这些元句被打印以输出并解释为下一步的命令。在这种情况下,::set-env
设置环境变量。
下一步展示了 Python 的使用,这是该环境中的另一个默认工具。实际上,它与 Node 一起包含在每个环境中;您可以使用其默认版本,也可以将版本设置为操作变量。此步骤还使用类似的机制(而不是环境变量)来设置可用于下一步的输出。
与 Python 不同,Ruby 在路径中没有可用的默认版本。但是,这只是找到路径的问题,您可以像在这里一样使用它。此步骤还使用上一步骤的输出。 GHA 具有上下文,在这种情况下为步骤上下文,可用于访问先前步骤的输出。 steps.greet.outputs.printd 访问其 ID 被迎接的步骤的上下文(我们在此处通过 id 键声明了该步骤),并且由于我们声明输出为 printd,所以 output.printd 将使用该名称检索输出。动作环境中没有上下文,这就是为什么我们需要首先将其分配给环境变量的原因。输出将 看起来像这样,它将使用绿色的复选标记,并在原始日志中显示输出,如果您单击步骤名称,则将其显示出来。
如果您像我一样长期使用 Perl,那么您会错过的。 Ruby,Python,Node,流行语言,足够公平。但是 Perl 在 Ubuntu 16.04 的基础安装中。即使我们可以使用该环境,它似乎也已被淘汰。我们必须去哪里使用 Perl?到 Windows 环境。让我们用它来创建一个礼貌的机器人,在您创建或编辑问题时向您致意:
name: "We 🎔 Perl"
on:
issues:
types: [opened, edited, milestoned]
jobs:
seasonal_greetings:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2-beta
- name: Maybe greet
id: maybe-greet
env:
HEY: "Hey you!"
GREETING: "Merry Xmas to you too!"
BODY: ${{ github.event.issue.body }}
TOKEN: ${{ secrets.GITHUB_TOKEN}}
ISSUE: ${{ github.event.issue.number }}
run: |
$output=(perl utils/printenv.pl)
$body= @{ 'body'= $output } | ConvertTo-Json
$header= @{
'Authorization'="token $ENV:TOKEN"
}
Invoke-RestMethod -Uri "https://api.github.com/repos/JJ/raku-advent-calendar-article-2019/issues/$ENV:ISSUE/comments" -Method 'Post' -Body $body -Headers $header
首先检查 on 命令,该命令被设置为在每次创建,编辑或分配里程碑事件时将其触发,由于某种原因,该操作被称为里程碑。
您在上方看到的主要区别是,该操作将在运行该环境的环境中存在于 Windows 的最新版本中。但是接下来,我们将看到另外一件有趣的事情:它们可以简单地发布在 GitHub 上,并且可以重复使用。此签出操作将执行其说明:签出回购代码,默认情况下不可用。我们实际上不会对代码进行任何检查,但是我们需要创建的小 Perl 脚本。稍后对此进行更多讨论。
下一步是在创建,更改或等待问题里程碑时实际运行的步骤。我们声明了两个不同的环境变量:一个用于注释未提及 "Merry" 的问题,另一个用于注释。但是,接下来发生的事情是:我们可以使用问题正文,它可以作为上下文变量使用:github.event.issue.body。下一个变量是魔术键,它为 GitHub API 打开了大门。无需上传它或任何东西,它就可以为您准备好了,GitHub 会跟踪它并将其隐藏在任何出现的位置。我们还将需要发行号对此进行评论,并将其存储在 $ISSUE 变量中。
接下来,执行操作。我们将使用以下微型脚本,使用奇妙的 Perl 正则表达式来检查体内是否存在单词 Merry:
print( ( ($ENV{BODY} =~ /Merry/) == 1)? $ENV{GREETING} : $ENV{HEY});
The next few PowerShell commands are, by far, the most difficult part of this article.
我们运行脚本,以便将结果捕获并存储在变量中。 接下来的命令将创建 PowerShell 哈希,然后 $body 转换为 JSON。 通过使用 Invoke-RestMethod,我们使用 GitHub API 创建注释,其中包含里程碑式或任何其他问题的问候。
如上图所示,有几条注释:一个是创建时的注释,另一个是检查该图像。
但是,上次我们检查的是 Raku 降临日历,对吗? 我们要我们的 Raku!
56.2. 在 GitHub actions 中使用 Raku
上次我检查时,Raku 并不是在任何环境中都可用的非常有限的语言之一。 但是,这并不意味着我们不能使用它。 在 Windows 中使用 Chocolatey 的情况下(或通过 curl 或任何其他命令下载),可以使用可以安装的任何东西升级操作。 我们还将使用它来进行真实的测试。 虚拟,但真实。 所有动作实际上要么成功要么失败。 您可以将其用于进行测试。 看看这个动作:
name: "We 🎔 Raku"
on: [push, pull_request]
jobs:
test:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2-beta
- name: Install and update
run: |
cinst rakudostar
$env:Path = "C:\rakudo\bin;C:\rakudo\share\perl6\site\bin;$env:Path"
refreshenv
zef test .
正在使用此脚本进行测试:
use Test;
constant $greeting = "Merry Xmas!";
constant $non-greeting = "Hey!";
is( greet( "Hey", $greeting, $non-greeting), $non-greeting, "Non-seasonal salutation OK");
is( greet( "Merry Xmas!", $greeting, $non-greeting), $greeting, "Seasonal salutation OK");
done-testing;
sub greet( $body, $greeting, $non-greeting ) {
($body ~~ /M|m "erry"/)??$greeting!!$non-greeting;
}
这里的 regex 使用 Raku 语法来执行与上一个 Perl 脚本大致相同的操作,但让我们集中于上面的操作。 它运行三个 PowerShell 命令,其中一个使用 Chocolatey 安装 Rakudo Star,然后设置命令路径并刷新它,以便可以在最后一个命令, 即通常的 zef test .
中使用它。
Rakudo Star 自 3 月以来未更新; 新的更新即将推出,但同时,Windows/GitHub Actions/Rakudo 的组合并不是真正的最佳选择,因为捆绑的 zef 版本已损坏,无法在 GitHub action 中进行更新。
这个 测试需要相当长的时间。 您必须每次都下载并安装 Raku,如果您需要安装任何其他模块,它就无法工作。 幸运的是,还有更多的方法可以做到这一点。 认识 Raku 容器。
56.3. 使用 dockerized 动作
GitHub 动作可以在两个不同的环境中创建。 其中一个叫做 node12,实际上可以运行任何操作系统,另一个是 docker,这是 Linux 专有的。
这些容器将在运行时构建,然后执行,命令直接在容器内部执行。 默认情况下,将像往常一样运行容器的 ENTRYPOINT。 以前,我们使用动作/签出来签出存储库; 这些官方行动可以辅以我们自己的行动; 在这种情况下,我们将使用 Raku 容器操作,您也可以在 Actions markecplace 中检出该操作。
这个动作基本上包含一个 Dockerfile,这个是:
FROM jjmerelo/alpine-perl6:latest
LABEL version="4.0.2" maintainer="JJ Merelo <jjmerelo@GMail.com>"
# Set up dirs
ENV PATH="/root/.rakudobrew/versions/moar-2019.11/install/bin:/root/.rakudobrew/versions/moar-2019.11/install/share/perl6/site/bin:/root/.rakudobrew/bin:${PATH}"
RUN mkdir /test
VOLUME /test
WORKDIR /test
# Will run this
ENTRYPOINT raku -v && zef install --deps-only . && zef test .
该 Dockerfile 所做的只是建立系统 PATH 和可用于测试的入口点。 它没有任何特定于操作的内容。
它使用最基本的 Alpine Raku 容器,这是整个 Raku 测试容器系列的基础。
但是,让我们回到动作所在的位置,即动作。
name: "We 🎔 Ubuntu, Docker and Raku"
on: [push, pull_request]
jobs:
adventest:
runs-on: ubuntu-latest
name: AdvenTest
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Runs tests
uses: JJ/raku-container-action@master
甜美而简单,对吗?
是的,我忍不住打电话给 Advent Calendar AdvenTest 测试。
它使用正式的检出操作检出存储库,然后运行测试,这是在该操作中创建的 Dockerfile 中的默认命令。 如果有,还将安装生态系统依赖性。
这需要多长时间? 短短 30 秒,或其他人的四分之一。
56.4. 告诉我更多!
GitHub 上的行动充满了可能性(有时也是痛苦的世界)。 容器化工具意味着您将能够使用自己喜欢的语言(即 Raku )在存储库和整个世界上工作,可以从任何类型的事件(交互性或定期性)中启动操作; 例如,您可以每周安排测试,或者在清除测试后开始部署。
如果您喜欢 Travis 或 CircleCI 等 CI 工具,那么您会喜欢 GitHub 上的动作。 在您的 Raku 存储库中充分利用它们。
57. 第七天 - 使用 Raku 解析 Firefox 的 user.js(第一部分)
正确配置 Firefox 并进行配置的最简单方法之一, 可通过 Firefox 配置文件中的 user.js 文件在设备之间进行同步,而无需第三方服务。 这是一个简单的 JavaScript 文件,通常包含 user_pref 函数调用的列表。 今天,我将向您展示如何使用 Raku 编程语言的 Grammars 来解析 user.js 文件的内容。 明天,我将在此处创建的基础上进行扩展,以允许人们以编程方式与 user.js 文件进行交互。
57.1. 格式
首先让我们看一下文件的格式。 举例来说,让我们使用我自己的 user.js 中的启动页面配置设置。
user_pref("browser.startup.homepage", "https://searx.tyil.nl");
来看一下,我们可以将一行解构为以下元素:
-
函数名称:在我们的例子中,几乎总是字符串 user_pref;
-
开口括号;
-
参数列表,以 ; 分隔。
-
闭合支架;
-
一个以 ; 结束语句。
我们还可以看到字符串参数括在 " 中。JavaScript 中不引用整数,布尔值和 null 值,因此我们也需要考虑这一点。但是现在让我们将其放在一边,首先获取示例 行解析。
57.2. 设置测试场
我发现开始编写语法的最简单方法之一就是编写一个小的 Raku 脚本,我可以执行该脚本以查看事情是否正常运行,然后逐步扩展语法。 起始情况如下所示。
grammar UserJS {
rule TOP { .* }
}
sub MAIN () {
my @inputs = ('user_pref("browser.startup.homepage", "https://searx.tyil.nl");');
for @inputs {
say UserJS.parse($_);
}
}
运行此脚本应产生一个包含完整测试字符串的 Match 对象。
「user_pref("browser.startup.homepage", "https://searx.tyil.nl");」
「和」标记表示我们有一个 Match 对象,在这种情况下,它表示语法正确地解析了输入。 这是因为我们首先使用的占位符 .*。 我们的下一步将是在 .* 前面添加规则,直到该特定位不再匹配为止,并且我们已经为 user.js 文件的所有部分定义了明确的规则。
57.3. 添加第一条规则
由于该示例以静态字符串 user_pref 开头,因此我们首先将其与语法进行匹配。 由于这是函数的名称,因此我们将在语法中添加一个名为 function-name 的规则,该规则只需匹配静态字符串即可。
rule function-name {
'user_pref'
}
接下来,此规则需要与 TOP 规则合并,因此将实际使用它。 规则对空格不敏感,因此您可以对 TOP 规则重新排序,以将我们要查找的所有元素依次放置。 从长远来看,这将使它更具可读性,因为随着我们的继续,将会涉及更多内容。
rule TOP {
.*
}
现在运行脚本将产生比以前更多的输出。
「user_pref("browser.startup.homepage", "https://searx.tyil.nl");」
function-name => 「user_pref」
第一行仍然相同,即完全匹配。 它仍然匹配所有内容,这很好。 如果不是,则匹配失败,并且返回 Nil。 这就是为什么我们将 .* 保留在末尾的原因。
不过这次有一条额外的线。 此行显示具有匹配项的函数名规则,且匹配项为 user_pref。 这符合我们的期望,因为我们告诉它匹配该字面值,确切的字符串。
57.4. 解析参数列表
下一个要匹配的部分是参数列表,该列表包含一个左括号,一个用于匹配的右括号以及它们之间的许多参数。 让我们再制定一条规则来解析这部分。 现在可能有点天真,我们将在以后进行改进。
rule argument-list {
'('
.+
')'
}
当然,TOP 规则也需要扩展以包括此规则。
rule TOP {
.*
}
运行脚本将产生另一行,表明参数列表规则与整个参数列表匹配。
「user_pref("browser.startup.homepage", "https://searx.tyil.nl");」
function-name => 「user_pref」
argument-list => 「("browser.startup.homepage", "https://searx.tyil.nl")」
现在我们知道此基本规则有效,我们可以尝试对其进行改进以使其更加准确。 如果我们可以从中得到一个参数列表,并且不包括方括号,将会更加方便。 卸下支架是比较容易的部分,所以我们首先来做。 您可以使用 <(
和 )>
标记来指示匹配结果应分别在何处开始和结束。
rule argument-list {
'('
')'
}
您可以看到脚本的输出现在没有在参数列表匹配项中显示括号。 现在,要列出参数,最简单的方法是创建一个附加规则以匹配单个参数,然后将匹配作为参数的分隔符。 我们可以为此使用 % 运算符。
rule argument-list {
'('
<( + % ',' )>
')'
}
rule argument {
.+
}
但是,当您尝试运行此命令时,只会看到 Nil 作为输出。
57.5. 调试 grammar
没有任何工具的语法调试非常麻烦,因此我不建议您尝试这样做。 取而代之的是,我们使用一个使它变得更容易的模块:Grammar::Tracer。 这将显示有关语法如何匹配所有内容的信息。 如果您使用 Rakudo Star,那么您已经安装了此模块。 否则,您可能需要安装它。
zef install Grammar::Tracer
现在,您可以通过在脚本顶部在 grammar 声明之前添加 use Grammar::Tracer
在脚本中使用它。 现在运行脚本将产生一些内容,然后您会看到 Nil。
TOP
| function-name
| * MATCH "user_pref"
| argument-list
| | argument
| | * MATCH "\"browser.startup.homepage\", \"https://searx.tyil.nl\");"
| * FAIL
* FAIL
看到这个,您可以看到一个参数正在匹配,但是它太贪心了。 它匹配直到行尾的所有字符,因此参数列表不再匹配右括号。 要解决此问题,我们必须将参数规则更新为较少贪婪。 目前,我们只是匹配双引号中出现的字符串,因此让我们更改规则以使其更准确地匹配。
rule argument {
'"'
<( +? )>
'"'
}
该规则匹配以 " 开头的字符,然后是不是 " 的任何字符,然后是另一个 "。" 还再次使用了 <(
和 )>
以使周围的字符 " 不以结果结尾。 如果再次运行该脚本,您将看到参数列表包含两个参数匹配项。
「user_pref("browser.startup.homepage", "https://searx.tyil.nl");」
function-name => 「user_pref」
argument-list => 「"browser.startup.homepage", "https://searx.tyil.nl"」
argument => 「browser.startup.homepage」
argument => 「https://searx.tyil.nl」
由于没有问题,我现在暂时忽略 Grammar::Tracer 的输出。 通常,我建议您一直呆在那里,直到您对自己的语法完全满意为止,这样您就可以立即查看开发过程中出了什么问题。
57.6. 语句的结尾
现在,剩下的要在 TOP 规则中明确匹配的是语句终止符 ;
。 这可以替换 .*
,因为它是字符串的最后一个字符。
rule TOP {
';'
}
最终的 grammar 应如下所示。
grammar UserJS {
rule TOP {
';'
}
rule function-name {
'user_pref'
}
rule argument-list {
'('
<(
')'
}
rule argument {
'"'
<( )>
'"'
}
}
现在,这里的问题是它还很幼稚。 它不会处理字符串中的双引号,也不会处理布尔值或整数。 当前的语法也无法匹配多行。 所有这些问题都可以解决,有些问题比其他问题容易。 明天回到这里学习方法!
58. 第八天 - 使用 Raku 解析 Firefox 的 user.js(第二部分)
昨天,我们做了一个简短的 Grammar,可以解析 Firefox 使用的 user.js 的一行。 今天,我们将添加一些测试用例,以确保我们要匹配的所有内容都能正确匹配。 另外,可以扩展 Grammar 以匹配多行,因此我们可以让 Grammar 在单次调用中解析整个 user.js 文件。
58.1. 添加更多测试
要开始匹配其他参数类型,我们应该扩展 MAIN 中定义的测试用例。 让我们添加几个测试用例以匹配 true,false,null 和整数值。
my @inputs = (
'user_pref("browser.startup.homepage", "https://searx.tyil.nl");',
'user_pref("extensions.screenshots.disabled", true);',
'user_pref("browser.search.suggest.enabled", false);',
'user_pref("i.have.no.nulls", null);',
'user_pref("browser.startup.page", 3);',
);
我建议也更新 for 循环,以指示当前正在尝试匹配的输入。 假如不匹配,如果我们将其打印出来,将更容易看到哪个输出属于哪个输入。
for @inputs {
say "\nTesting $_\n";
say UserJS.parse($_);
}
如果您现在运行脚本,您会看到只有第一个测试用例实际上是工作的,而其他参数上的测试用例都失败了。 让我们修复每个测试,从顶部开始。
58.2. 匹配其他类型
为了使各种类型的匹配变得容易,让我们介绍一下 原型正则表达式。 这将有助于将所有内容分成较小的可管理块。 我们还要将 argument 规则重命名为 constant,这将更恰当地描述我们将要与之匹配的东西。 在添加新功能之前,让我们看一下重写后的结构。
rule argument-list {
'('
<( <constant>+ % ',' )>
')'
}
proto rule constant { * }
rule constant:sym {
'"'
<( <-["]>+? )>
'"'
}
如您所见,我给了常量名为字符串 string
的 sym 副词。 这使我们很容易看出它与常量字符串有关。 现在,我们还可以轻松添加其他常量类型,例如布尔值。
rule constant:sym {
| 'true'
| 'false'
}
这将匹配裸词 true 和 false。 仅添加此内容并再次运行脚本将向您显示接下来的两个测试用例正在运行。 添加 null 类型同样简单。
rule constant:sym {
'null'
}
现在,我们需要通过第 5 个测试用例的就是解析数字。 在 JavaScript 中,一切都是浮点数,因此我们在 Grammar 上也要坚持这一点。 我们接受一个或多个数字,可以选择在其后接一个点和另一组数字。 当然,我们还应该在它们前面允许 -
或 +
。
rule constant:sym {
<[+-]>? \d+ [ "." \d+ ]?
}
58.3. 找出一些极端情况
看来我们现在可以匹配所有重要的类型。 但是,有些允许的情况尚无法解决。 一个大的当然是一个包含 " 的字符串。如果我们为此添加一个测试用例,则在运行脚本时我们会看到它失败。
my @inputs = (
...
'user_pref("double.quotes", "\"my value\"");',
);
要解决此问题,我们需要回到 constant: sym
,并更改规则以考虑转义的双引号。 我们可以更改它以查找不直接跟 \
的任何字符,而不是寻找不是 " 的任何字符,因为那样会使它转义。
rule constant:sym {
'"'
<( .*? <!after '\\'> )>
'"'
}
58.4. 解析多行
现在看来我们已经能够处理 Firefox 可能向我们抛出的所有不同 user_pref 值,是时候更新脚本以解析整个文件了。 让我们将当前输入的内容移至 user.js,并更新 MAIN 子例程以读取该文件。
sub MAIN () {
say UserJS.parse('user.js'.IO.slurp);
}
现在运行脚本将在 STDOUT 上打印一个 Nil 值,但是如果您仍然启用了 Grammar::Tracer,您还会注意到它没有任何抱怨。 全都是绿色!
这里的问题是,目前仅指示 TOP 规则解析单个 user_pref 行,但是我们的文件包含多个此类行。 UserJS Grammar 的 parse 方法期望与被告知要解析的整个字符串匹配,这将导致 Gramamr 最终失败。
因此,我们需要更改 TOP 规则以允许多行匹配。 最简单的方法是将当前内容包装到一个组中,并在其中添加一个量词。
rule TOP {
[
<function-name>
<argument-list>
';'
]*
}
现在,它匹配所有行,并再次正确提取 user_pref 语句的值。
58.5. 任何注释?
还有另一个需要说明的极端情况:评论。 在 user.js 文件中允许使用这些文件,并且当在线查找此类文件以进行预设配置时,它们通常会大量使用它们。 在 JavaScript 中,注释以 //
开头,直到行尾。
我们将为此使用 token 而不是 rule,因为这不能为我们处理空格。 换行符是一个空白字符,对于注释表示其结束很重要。 此外,TOP 规则还需要进行一些小的改动才能接受注释行。 为了保持可读性,我们应该将匹配组的当前内容移到它自己的规则上。
rule TOP {
[
| <user-pref>
| <comment>
]*
}
token comment {
'//'
<( <-[\n]>* )>
"\n"
}
rule user-pref {
<function-name>
<argument-list>
';'
}
现在,您还应该能够解析注释。 无论它们是单独运行还是在 user_pref 语句之后,都无关紧要。
58.6. 使其成为一个对象
如果您以后无法轻松使用它,解析数据有什么用。 因此,让我们利用语法操作将 Match 对象转换为 UserPref 对象列表。 首先,让我们声明类的外观。
class UserPref {
has $.key;
has $.value;
submethod Str () {
my $value;
given ($!value) {
when Str { $value = "\"$!value\"" }
when Num { $value = $!value }
when Bool { $value = $!value ?? 'true' !! 'false' }
when Any { $value = 'null' }
}
sprintf('user_pref("%s", %s);', $!key, $value);
}
}
一个简单的类,包含一个键和一个值,以及一些将其转换为在 user.js 文件中可用的字符串的逻辑。 接下来,创建一个 Action 类来制作这些对象。 动作类就像任何常规类一样。 您需要注意的是将方法命名为与语法中使用的规则相同的名称。
class UserJSActions {
method TOP ($/) {
make $/.map({
UserPref.new(
key => $_[0].made,
value => $_[1].made,
)
})
}
method constant:sym ($/) {
make (~$/ eq 'true' ?? True !! False)
}
method constant:sym ($/) {
make +$/
}
method constant:sym ($/) {
make Any
}
method constant:sym ($/) {
make ~$/
}
}
值方法将在 user.js 中看到的值转换为 Raku 类型。 TOP 方法映射所有已解析的 user_pref 语句,并将每个语句转换为 UserPref 对象。 现在剩下的就是将 UserJSActions 类添加为 MAIN 中的解析调用的 Action 类,并使用其值。
sub MAIN () {
my $match = UserJS.parse('user.js'.IO.slurp, :actions(UserJSActions));
say $match.made;
}
现在我们也可以使用它来做事。 例如,我们可以按字母顺序对所有 user_pref 语句进行排序。
sub MAIN () {
my $match = UserJS.parse('user.js'.IO.slurp, :actions(UserJSActions));
my @prefs = $match.made;
for @prefs.sort(*.key) {
.Str.say
}
}
按字母排序可能有点无聊,但是现在您有各种各样的可能性,例如过滤掉某些选项或注释,或者合并来自多个来源的多个文件。
我希望这是使用 Raku 极其强大的语法来解析整个其他编程语言的有趣旅程!
58.7. 完整代码
parser.pl6
class UserPref {
has $.key;
has $.value;
submethod Str () {
my $value;
given ($!value) {
when Str { $value = "\"$!value\"" }
when Num { $value = $!value }
when Bool { $value = $!value ?? 'true' !! 'false' }
when Any { $value = 'null' }
}
sprintf('user_pref("%s", %s);', $!key, $value);
}
}
class UserJSActions {
method TOP ($/) {
make $/.map({
UserPref.new(
key => $_[0].made,
value => $_[1].made,
)
})
}
method constant:sym ($/) {
make (~$/ eq 'true' ?? True !! False)
}
method constant:sym ($/) {
make +$/
}
method constant:sym ($/) {
make Any
}
method constant:sym ($/) {
make ~$/
}
}
grammar UserJS {
rule TOP {
[
| <user-prefix>
| <comment>
]*
}
token comment {
'//' <( <-[\n]>* )> "\n"
}
rule user-pref {
<function-name>
<argument-list>
';'
}
rule function-name {
'user_pref'
}
rule argument-list {
'('
<( <constant>+ % ',' )>
')'
}
proto rule constant { * }
rule constant:sym {
'"'
<( .*? <!after '\\'> )>
'"'
}
rule constant:sym {
| 'true'
| 'false'
}
rule constant:sym {
'null'
}
rule constant:sym {
<[+-]>? \d+ [ "." \d+ ]?
}
}
sub MAIN () {
my $match = UserJS.parse('user.js'.IO.slurp, :actions(UserJSActions));
my @prefs = $match.made;
for @prefs.sort(*.key) {
.Str.say
}
}
user.js
// Comments are welcome!
user_pref("browser.startup.homepage", "https://searx.tyil.nl");
user_pref("extensions.screenshots.disabled", true); //uwu
user_pref("browser.search.suggest.enabled", false);
user_pref("i.have.no.nulls", null);
user_pref("browser.startup.page", +3);
user_pref("double.quotes", "\"my value\"");
59. 第九天 - 容器链
如果您从事的是企业,那么现在您可能已经听说过容器。它们既可以描述为类固醇上的可执行文件,也可以被描述为类固醇上的可执行文件,是将应用程序运送到任何地方的一种好方法,或者可以将其存储起来并随时随地使用。这些有点可执行文件称为映像,您可以在许多称为注册表的地方找到它们,从 Docker Hub 开始,最近由 GitHub Container Registry 和 Quay.io 或 RedHat 容器目录等其他地方加入。最后的这些即将到来,您必须将它们添加到默认配置中。无论如何,大多数容器都已在 Docker Hub 中注册。
而且,由于它们是某种可执行文件,因此它们是体系结构和操作系统特定的。 Docker Hub 标志着其架构和操作系统,其中 Linux 是最常见的。但是,只要 Docker 守护程序在 Linux 虚拟机中运行,您就可以在任何地方运行 Linux 映像。这也是 Mac 中的默认配置。
当然,有很多容器可以与 Raku 一起使用,即使我们还没有真正可以称之为官方的容器。我将用我 自己的,因为我对它们更加熟悉。但是,例如,有(Tilil)每晚提供的这些 镜像 ,或有点官方的 Rakudo Star 镜像,这些图片自从去年 3 月更新 Rakudo Star 以来就没有更新过。
让我们从基本图片开始,沙皇尼基小俄罗斯小娃娃。 由于它要放在里面,因此我们需要使其变得很小。 这是 jjmerelo/alpine-perl6:
FROM alpine:latest
LABEL version="2.2" maintainer="JJMerelo@GMail.com" perl6version="2019.11"
# Environment
ENV PATH="/root/.rakudobrew/versions/moar-2019.11/install/bin:/root/.rakudobrew/versions/moar-2019.11/install/share/perl6/site/bin:/root/.rakudobrew/bin:${PATH}" \
PKGS="curl git perl" \
PKGS_TMP="curl-dev linux-headers make gcc musl-dev wget" \
ENV="/root/.profile" \
VER="2019.11"
# Basic setup, programs and init
RUN mkdir /home/raku \
apk update && apk upgrade \
&& apk add --no-cache $PKGS $PKGS_TMP \
&& git clone https://github.com/tadzik/rakudobrew ~/.rakudobrew \
&& echo 'eval "$(~/.rakudobrew/bin/rakudobrew init Sh)"' >> ~/.profile \
&& eval "$(~/.rakudobrew/bin/rakudobrew init Sh)"\
&& rakudobrew build moar $VER \
&& rakudobrew global moar-$VER \
&& rakudobrew build-zef\
&& zef install Linenoise App::Prove6\
&& apk del $PKGS_TMP \
&& RAKUDO_VERSION=`sed "s/\n//" /root/.rakudobrew/CURRENT` \
rm -rf /root/.rakudobrew/${RAKUDO_VERSION}/src /root/zef \
/root/.rakudobrew/git_reference
# Runtime
WORKDIR /home/raku
ENTRYPOINT ["raku"]
该映像是在 Raku 2019.11 发布之后的上周创建的,这是第一个实际称为 Raku 的映像,也称为可执行文件 Raku。
您首先看到的是 FROM,它声明了该容器内的小容器。我们使用的是 Alpine Linux,这是在容器技术人员社区之外鲜为人知的发行版,它使用了一些技巧来避免膨胀文件的数量以及容器的大小。该映像总共将少于 300 MB,而与 Debian 或 Ubuntu 等效的映像将是原来的两倍。这意味着下载所需的时间减少了一半,这正是我们所需要的。
因为也有这样的东西:现实的容器,空的时候,可以用俄制的美元放入另一个容器中,这样就不会占用太多空间。容器也会发生类似的情况。它们的构建使各层相互重叠,内层通常是一个操作系统。让我们看看其余的。
接下来的标签只是可以通过检查从图像中提取的标签或元数据。没那么重要。
但是,在所有第一个中,ENV 块有点,它定义了将在整个俄罗斯娃娃积木中使用的 PATH。其余变量主要在构建图像时使用。它们还将有助于使其具有某种通用性,以便我们可以更改变量的值并获得新版本;我们将其放入 VER。
到目前为止,尚未进行任何构建,但是在这个庞大的 RUN 语句中,我们下载了 rakudobrew,将其用于构建 VER 中包含的版本,将该版本设置为默认版本,安装 zef 和我们将要使用的几个模块需要,然后删除其余外部玩偶中不再需要的东西,以使整个东西变小。
最后,在设置工作目录之后,我们定义一个入口点,它是真正的可执行文件内部可执行文件。可以使用该容器代替该命令,以便可以使用此可执行文件来完成 raku 可以执行的任何操作。例如,让我们运行以下程序:
my @arr;
my ($a, $b) = (1,1);
for ^5 {
($a,$b) = ($b, $a+$b);
@arr.push: ($a.item, $b.item);
say @arr
};
say @arr;
我们将为容器化的 Raku 命名:
alias raku-do='docker run --rm -t -v `pwd`:/home/raku jjmerelo/alpine-perl6'
我们可以使用以下命令运行上面的程序:
raku-do itemizer-with-container.p6
但是您可以更进一步。 创建此 shell 脚本并将其放在路径中:
#!/bin/bash
docker run --rm -t -v `pwd`:/home/raku jjmerelo/alpine-perl6 $@
然后,可以在 shebang 行中使用它:!/usr/bin/env raku-do.sh。 这将创建一个一次性图像,将临时创建该图像以运行脚本,然后将其丢弃(这是该行中的 --rm
)。 当前目录(pwd)将作为 /home/raku 的别名,请记住,我们的工作目录,这意味着容器内的 raku 将在此处看到它。 你看? 有了这个,你可以在安装了 docker 的任何地方运行 raku。 如今,到处都有。
但是,让我们以此为基础。 容器广泛用于测试,因为您无需构建和安装即可将所有内容放入单个容器中,然后立即下载并用于测试。 实际上,这就是让我成为一名集装箱运输商的原因,这需要花费 20 分钟的时间来酿造 rakudo,并对每个模块进行几秒钟的测试。 在该基本容器之后,我创建了一个 jjmerelo/test-perl6。 这里是:
FROM jjmerelo/alpine-perl6:latest
LABEL version="4.0.2" maintainer="JJ Merelo <jjmerelo@GMail.com>"
# Set up dirs
RUN mkdir /test
VOLUME /test
WORKDIR /test
# Will run this
ENTRYPOINT perl6 -v && zef install --deps-only . && zef test .
实际上,这本身就是简单性:唯一更改的是入口点和工作目录。 它没有直接运行 raku 编译器,而是做了两件事:安装运行测试所需的依赖项,然后发出 zef test。 运行测试。
真正加快了测试速度。 通过以下方式将其放入您的.travis.yml 文件:
language: minimal
services:
- docker
install: docker pull jjmerelo/test-perl6
script: docker run -t -v $TRAVIS_BUILD_DIR:/test jjmerelo/test-perl6
而且您很高兴。 整个过程耗时一分半钟,如果您使用基于 rakudobrew 的官方 Travis 图像,则要花费 20 多分钟。
俄语玩偶并不止于此:jjmerelo/perl6-test-openssl 包括安装 OpenSSL 所需的其他 Alpine 软件包。 并且基于 jjmerelo/perl6-doccer,它包含了测试 Raku 文档所需的一切。
您应该亲自尝试一下。 如果您在测试模块时甚至只有几个其他模块要下载,那么只需从 test-perl6 映像中构建并获得自己的模块即可! 您不仅可以节省时间,还可以节省计算时间,从而节省能源。
60. 第十天 - 急转弯
圣诞老人有一种特殊待遇:如果愿意的话,可以传情。即将于 2020 年 1 月出版的《将 Perl 迁移到 Raku》一书中一章的一部分。
60.1. 优化注意事项
如果您是经验丰富的 Perl 程序员,那么您(可能无意中)学到了一些技巧,可以更快地执行 Perl 程序。其中一些习语在 Raku 中起反作用。本章将介绍其中的一些,并提供 Raku 中的其他惯用语。
60.2. 祝福的哈希 Vs.对象
Perl 中的对象通常由祝福的哈希组成。如我们之前所见,这意味着需要为它们创建访问器。这意味着更高级别的间接和开销。如此多的 Perl 程序员“知道”该对象基本上是一个哈希,并直接访问经过祝福的哈希中的键。
因此,许多 Perl 程序员决定完全放弃创建对象,而只使用哈希。如果您只是将散列用作键和关联值的存储库,那么从功能上来说还可以。但是在 Raku 中,从性能的角度来看,最好实际使用对象。以以下示例为例,该哈希使用两个键/值创建:
# Raku
for ^1000000 { # do this a million times
my %h = a => 42, b => 666;
}
say now - INIT now; # 1.4727555
现在,如果我们使用具有两个属性的对象,则速度快了四倍:
# Raku
class A {
has $.a;
has $.b;
}
for ^1000000 {
my $obj = A.new(a => 42, b => 666);
}
say now - INIT now; # 0.3511395
但是,您可能会争辩说,访问散列中的键要比调用访问器来获取值更快?
不。 在 Raku 中使用存取器也更快。 比较这段代码:
# Raku
my %h = a => 42, b => 666;
for ^10000000 { # do this ten million times
my $a = %h;
}
say now - INIT now; # 0.4713363
为:
# Raku
class A {
has $.a;
has $.b;
}
my $obj = A.new(a => 42, b => 666);
for ^10000000 {
my $a = $obj.a;
}
say now - INIT now; # 0.36870995
请注意,使用访问器也更快,尽管速度虽然不快,但仍然非常快。
那么,为什么在 Raku 中访问器方法更快? 好吧,这真的是因为 Raku 能够优化对象在列表中的属性,并且很容易在内部用数字索引。 而对于哈希查找,必须先对字符串进行哈希处理,然后才能对其进行查找。 这不仅需要按索引查找,还需要做更多的工作。
当然,与所有基准测试一样,这只是时间的快照。 Raku 将继续进行优化工作,这可能会在将来改变这些测试的结果。 因此,如果您想确保某些优化是一种有效的方法,请务必进行自我测试,但前提是您确实真正有兴趣从 Raku 代码中压缩性能。 记住,过早的优化是万恶之源!
圣诞老人希望您喜欢它。
61. 第十一天 - 使用 libarchive 打包
分发实物礼物需要将它们包装成包装,但是假设您要分发数字礼物。 您如何使用 Raku 来帮助您打包? 输入 Libarchive!
61.1. 将文件简单包装到包中
让我们仅将两个文件 myfile1 和 myfile2 打包到一个 package.zip 文件中。 (Libarchive 就像为 cds 或 dvds 轻松创建 tar 文件,cpio,rar 甚至 iso9660 映像一样。)
use Libarchive::Simple;
given archive-write('package.zip') {
.add: 'myfile1', 'myfile2';
.close;
}
对于那些不熟悉的人来说,这种非常简单的语法有点奇怪……这是写同一件事的一种更“传统”的方式:
use Libarchive::Write;
my $handle = Libarchive::Write.new('package.zip');
$handle.add('myfile1', 'myfile2');
$handle.close;
有什么区别? Libarchive::Simple 提供了一些速记例程,用于访问各种 Libarchive 功能。 其中之一是 archive-write(),与 Libarchive::Write.new() 相同。
第二个示例从 new() 获取返回值,并将其存储在变量 $handle
中。 然后,我们对该变量调用两个方法以添加文件并关闭文件。
给定的语句通过对该变量进行主题化(即将其存储在主题变量 $_ 中),使此操作变得更加简单。 由于 $_ 可以用作方法调用的默认对象,因此在调用方法时我们不需要显式引用它。
.add('myfile1') is equivalent to $_.add('myfile1')
但是括号发生了什么? 调用方法时的另一个小写法-您可以在冒号前面加上冒号,而不必在括号中加上参数:
.add: 'myfile1';
真好! 我喜欢用 Raku 编程!
61.2. 通过智能匹配打包一堆文件
dir() 是帮助包装的便捷例程。 它将返回目录的 IO::Path 对象的惰性列表。 碰巧的是,Libarchive add 可以像输入文件名一样轻松地使用 IO::Path。
given archive-write('package.zip') {
.add: 'mydir', dir('mydir');
.close;
}
请注意,我们首先添加了目录本身,然后使用 dir() 获取 mydir 中的文件列表,该列表也被添加。 如果您不包括目录本身,则该目录将不包含在软件包中。 根据您的格式和解压缩程序,大多数情况下该方法都可以正常工作,但是最好包含该目录以确保按照您希望的方式创建该目录。
dir 有一个额外的功能-它可以通过将字符串与:test 参数智能匹配来过滤目录。 让我们只包含 jpeg 文件,使它们以 .jpg
或 .jpeg
结尾:
given archive-write('package.zip') {
.add: 'mydir', dir('mydir', test => /:i '.' jpe?g $/);
.close;
}
诸如 File::Find 或 Concurrent::File::Find 之类的生态系统模块可以通过将整个层次结构递归添加到包中来轻松生成甚至更复杂的文件列表。
61.3. 打包时即时创建文件
您不仅限于添加现有文件。 您可以使用 write() 方法动态地为软件包生成文件。 您可以将内容指定为 Str,Blob 或 IO::Handle 或 IO::Path 来获取内容。
given archive-write('package.zip') {
.write: 'myfile', q:to/EOF/;
Myfile
------
A special file for a special friend!
EOF
.close;
}
在这里,我们使用了一种特殊的 Raku 报价结构,称为 heredoc。
q:to/EOF/
表示使用后续的行直到 EOF 标记,并将它们放入包文件中包含的名为 "myfile" 的文件的内容中。 作为一个友好的好处,终止符的缩进量会自动从每行删除到引用的行。 多么方便!
61.4. 流式打包而不是制作文件
使用软件包制作文件非常好,但是我有一个网站希望从中返回我的自定义 CD 映像-为什么要使用临时文件呢? 只需实时输出流!
在此示例中,我们将打包文件作为 iso9660 文件(用于 CD 刻录机的映像)流式传输到 STDOUT,但是您也可以流式传输到其他程序。
given archive-write($*OUT, format => 'iso9660') {
.add: 'myfile1', 'myfile2', 'mydir', dir('mydir');
.close;
}
通常,可以从后缀上根据指定的文件名推断格式,但是由于我们正在流传输,因此没有文件名,因此必须指定格式。$*OUT
是一个特殊的文件句柄,将自动为您打开以写入 STDOUT。
将该图像刻录到 CD 上并进行安装,您将看到指定的文件。 太简单!
Libarchive 具有许多很酷的功能,需要花几天的时间才能浏览完所有功能,但是我希望这个小介绍能引起您打包东西的胃口。 Raku 具有出色的语法,功能和表达能力,可轻松与此类库进行交互。
打包自己的东西,无论是物理的还是数字的,都可以玩得开心!
62. 第十二天 - 在 Raku 中制作一个简单的机器人
在 Raku 中制作 IRC 机器人是非常简单的,这要感谢 IRC::Client。它允许你在 20 行代码中创建一个非常简单的机器人。有一个插件系统,允许在多个机器人之间轻松地重用代码,添加自定义功能可以像在匿名类中添加一样简单。
那么,让我们开始吧!
62.1. 安装你的依赖关系
Raku 使用 zef 作为标准的模块安装程序,如果你正在阅读这篇文章,我假设你已经有了它。用 zef 安装 IRC::Client,你就可以开始使用了。
zef install IRC::Client
62.2. 设置机器人
要设置机器人,我们需要有一个要使用的昵称,一个要连接的服务器和一个要加入的频道列表。为了使它更容易运行, 下面是一个可以从你的 shell 运行的程序,我将使用一个 MAIN 子例程。
use IRC::Client;
sub MAIN () {
IRC::Client.new(
nick => 'raku-advent',
host => 'irc.darenet.org',
channels => < #advent >,
).run;
}
让我们把这个文件保存在一个叫 bot.raku 的文件中,然后运行它。
raku bot.raku
这将运行,如果你在 channels 中指定的频道中,你应该在很短的时间内看到机器人加入。然而,程序本身似乎并没有提供任何输出。如果能显示出它在做什么,将会非常方便,尤其是在开发过程中。这可以通过启用调试模式来实现。在 new 方法调用中加入这个功能,使其看起来如下。
IRC::Client.new(
nick => 'raku-advent',
host => 'irc.darenet.org',
channels => < #advent >,
debug => True,
).run;
如果你现在重新启动应用程序,你会看到突然有很多输出,展示了机器人正在接收和发送响应的 IRC 命令。现在我们需要做的就是添加一些功能。
62.3. 让机器人工作
如前所述,机器人的功能是使用插件添加进来的。这些可以是任何实现正确方法名的类。现在,我们将坚持使用 irc-to-me,这是一个方便的方法,只要在私人消息中对机器人说话,或者在频道中直接对机器人说话,就会触发这个方法。
这里最简单的例子就是简单地让它回复你发给机器人的消息。我们通过在新方法调用中添加一个匿名类作为插件来实现。
IRC::Client.new(
nick => 'raku-advent',
host => 'irc.darenet.org',
channels => < #advent >,
debug => True,
plugins => [
class {
multi method irc-to-me ($e) {
$e.text
}
}
],
).run;
当你重启机器人,并在 IRC 上与它对话时,你会看到它用你发给它的相同的信息来回应你。
<@tyil> raku-advent: hi
<raku-advent> tyil, hi
<@tyil:> raku-advent: how are you doing
<raku-advent> tyil, how are you doing
62.4. 增加一些真正的功能
所以,你已经看到了一个简单的 IRC 机器人是多么容易上手,只需十几行。让我们添加两个你可能希望你的机器人支持的功能。
为了方便起见,我将只涵盖实现这些功能的类,而不是整个 IRC::Client.new
块。
62.4.1. 正常运行时间
首先,让我们让机器人能够显示其运行的时间。为此,我会让它响应人们向它询问"正常运行时间"。我们又可以使用 irc-to-me
的方便方法来实现。毕竟,我们可能不希望它在每次有人讨论正常运行时间时都做出回应,只有当机器人被直接问到这个问题时才会做出回应。
在 Raku 中,有一个特殊的变量叫 $*INIT-INSTANT
,其中包含了程序启动瞬间的 Instant。我们可以用它来轻松获得程序运行的持续时间。
class {
multi method irc-to-me ($ where *.text eq 'uptime') {
my $response = "I've been alive for";
my ($seconds, $minutes, $hours, $days, $weeks) =
(now - $*INIT-INSTANT).polymod(60, 60, 24, 7);
$response ~= " $weeks weeks" if $weeks;
$response ~= " $days days" if $days;
$response ~= " $hours hours" if $hours;
$response ~= " $minutes minutes" if $minutes;
$response ~= " $seconds seconds" if $seconds;
$response ~ '.';
}
}
现在,每当你向机器人询问正常运行时间时,它都会以人类友好的正常运行时间通知做出回应。
<@tyil> uptime
<@tyil> raku-advent: uptime
<raku-advent> tyil, I've been alive for 5 minutes 8 seconds.
62.4.2. 用户积分
大多数频道都有一个机器人来跟踪用户的积分,或者说是业力,有时也被称为业力。已经有一个模块为我们做了这个工作,叫做 IRC::Client::Plugin::UserPoints
。除了安装它并将其添加到插件列表中,我们不需要做太多事情。
zef install IRC::Client::Plugin::UserPoints
一旦这一切完成,这个模块就可以在你的代码中使用了,你需要用 use
语句导入它,你可以直接把它放在 use IRC::Client
行下。
use IRC::Client;
use IRC::Client::Plugin::UserPoints;
现在,在插件列表中,将其添加为一个新条目。
plugins => [
IRC::Client::Plugin::UserPoints.new,
class {
...
},
],
这个插件可以让机器人对 !scores
, !sum
以及每当一个昵称使用 ` 后缀获得分数时做出响应,例如,`tyil
。
<@tyil> raku++
<@tyil> raku++
<@tyil> !scores
<raku-advent> tyil, « raku » points: main: 2
62.5. 寻找插件
所有在社区上分享的 IRC::Client 的插件都有 IRC::Client::Plugin::
的前缀,所以你可以在 modules.raku.org 上搜索,找到要使用的插件。当然,你也可以轻松地将自己的插件添加到生态系统中去!
62.6. 收尾
正如你所看到的,通过一些非常简单的代码,你可以使用 Raku 编程语言为你的 IRC 社区添加一些有趣或重要的工具。试试吧,享受一下乐趣,并与他人分享你的想法。
63. 第十三天 - 一点 Rust 和 Raku
A Little R&R
63.1. 介绍
Raku 是一种非常不错的语言。 多才多艺,富有表现力,快速,敏捷。 我有时遇到的唯一问题是它可能会有点慢。 幸运的是,可以通过 NativeCall 接口轻松解决,这使得在 Raku 程序中轻松调用 C 代码成为可能。 现在,和 C 语言一样好,它是一种相当古老的语言,但有一些限制。 可以填补其空白的一种较新的语言称为 Rust。 我将展示一些 Raku 与 Rust 交谈的例子。
63.2. FFI
可以使用 FFI 标准从其他语言调用 Rust 代码。 FFI 代表“外部功能接口”,并允许您将 Rust 库导出为标准共享库(Linux 上的.so 文件或 Windows 上的.dll)。 这是通过在 Cargo.toml 中添加以下部分来完成的:
[lib]
crate-type = ["cdylib"]
添加此部分后,使用 Cargo 构建库时,您将在 target/debug 或 target/release 文件夹中找到库。 另外,请确保添加 libc 依赖项以访问标准 C 类型。
63.3. Primitives
我们可以使用与 C 中相同的原始类型:数字(和字符)和数组。
63.3.1. Numbers 和 chars
Rust:
#[no_mangle]
pub extern fn addition(a: u32, b:32) -> u32 {
a + b
}
Raku:
use NativeCall;
sub addition(uint32, uint32) returns uint32 is native('foo') { * }
注意 #[no_mangle]
,这会使函数的名称在最终库文件中保持不变。 尽管 Rust 具有标准化的名称修饰功能(与 C++
相反,名称修饰依赖于平台),但仍然可以使用原始名称来调用函数。
63.3.2. 数组和字符串
Rust:
use std::ffi::CStr;
use std::os::raw::c_char;
#[no_mangle]
pub unsafe extern fn count_chars(s: *const c_char) -> u32 {
CStr::from_ptr(s).to_str().unwrap().chars().count() as u32
}
#[no_mangle]
pub extern fn lunch() -> *mut c_char {
let c_string = CString::new("🌮🍚").expect("CString::new failed");
c_string.into_raw()
}
#[no_mangle]
pub unsafe extern fn free_lunch(ptr: *mut c_char) {
let _ = CString::from_raw(ptr);
}
Raku:
sub count_chars(Str is encoded('utf8')) returns uint32 is native ('foo') { * }
sub lunch() returns CArray[uint8] is native('foo') { * }
sub free_lunch(CArray[uint8]) is native('foo') { * }
Rust 对 UTF-8 具有一流的支持,使其非常适合 Raku。 使用 CString 还可以保证在字符串的末尾添加一个空字节,因此您可以通过循环获取有效字节,直到 AT-POS()值等于 0…如果,也就是说,您选择返回一个数组而不是填充 它。
63.3.3. 结构体
Rust:
use std::mem::swap;
#[repr(C)]
pub struct Point {
x: f32,
y: f32,
}
impl Point {
fn print(&self) {
println!("x: {}, y: {}", self.x, self.y);
}
}
#[no_mangle]
pub unsafe extern "C" fn flip(p: *mut Point) {
swap(&mut (*p).x, &mut (*p).y);
(*p).print();
}
Raku:
class Point is repr('CStruct') {
has num32 $.x;
has num32 $.y;
}
sub flip(Pointer[Point]) is native('./librnr.so') { * }
sub flipper {
my Point $p .= new(x => 3.Num, y => 4.Num);
say "x: ", $p.x, ", y: ", $p.y;
flip(nativecast(Pointer[Point], $p));
}
Rust 将对象分为结构(我们都熟悉)和特质,它们在 Raku 中就像角色一样。
63.3.4. Concurrency
Rust:
#[no_mangle]
pub extern "C" fn multithread(count: i32) {
let threads: Vec<_> = (1..8)
.map(|id| {
thread::spawn(move || {
println!("Starting thread {}", id);
let mut x = 0;
for y in 0..count {
x += y;
println!("Thread {}, {}/{}: {}", id, y, count, x);
}
})
})
.collect();
for t in threads {
t.join().expect("Could not join a thread!");
}
}
Raku:
sub multithread(int32) is native('./librnr.so') { * }
sub multi-multithread {
my @numbers = (3_000..50_000).pick(10);
my @promises;
for @numbers -> $n {
push @promises, start {
multithread($n);
True;
};
}
await Promise.allof(@promises);
}
Rust 和 Raku 都具有一流的并发支持。 这使您可以轻松地调整程序以获得最佳性能。
63.4. 结语
这些是在展望未来时最有前途的两种语言 Rust 和 Raku 之间交互的一些示例。 如果您觉得这很有趣,请务必阅读 Andrew Shitov 的“每日一门语言”文章。 感谢您的阅读和节日快乐。
64. 第十四天 - 超越类型之外之 Rakudo MOP
圣诞节到了!如果没有庆祝活动这部分,圣诞节就不会是圣诞节。因此,让我们唱歌吧。
我们可以简单地将颂歌制作成一个大字符串,但这还不够。颂歌是一首歌,通常在每节经文之间都有合唱。如果我们将其存储为一个字符串,那么我们将重复自己。最重要的是,人们并不完美。他们可能会忘记颂歌的诗句,甚至只是一行诗句。我们需要一种代表颂歌的类型。这可能是一首歌,但由于我们只关心颂歌,因此将其抽象出来还为时过早。
现在,为了使它变得更有趣,让我们在不制作任何类型的实例的情况下进行处理。所有圣诞节颂歌的所有行为都将由类型对象处理。这将使用 Unrestantiable REPR 强制执行。
一开始,我们可能会有一个 Christmas::Carol 角色:
role Christmas::Carol is repr<Uninstantiable> {
proto method name(::?CLASS:U: --> Str:D) {*}
proto method verse(::?CLASS:U: Int:D --> Seq:D) {*}
proto method chorus(::?CLASS:U: --> Seq:D) {*}
proto method lyrics(::?CLASS:U: --> Seq:D) {*}
method sing(::?CLASS:U: --> ::?CLASS:U) {
.say for @.lyrics;
self
}
}
然后,由代表特定颂歌的类完成此操作:
class Christmas::Carol::JingleBells does Christmas::Carol {
multi method name(::?CLASS:U: --> 'Jingle Bells') { }
multi method verse(::?CLASS:U: 1 --> Seq:D) {
lines q:to/VERSE/
Dashing through the snow
In a one-horse open sleigh
O'er the fields we go
Laughing all the way
Bells on bobtails ring
Making spirits bright
What fun it is to ride and sing
A sleighing song tonight
VERSE
}
multi method verse(::?CLASS:U: 2 --> Seq:D) {
lines q:to/VERSE/
A day or two ago
I thought I'd take a ride
And soon, Miss Fanny Bright
Was seated by my side
The horse was lean and lank
Misfortune seemed his lot
He got into a drifted bank
And then we got upset
VERSE
}
multi method verse(::?CLASS:U: 3 --> Seq:D) {
lines q:to/VERSE/
A day or two ago
The story I must tell
I went out on the snow
And on my back I fell
A gent was riding by
In a one-horse open sleigh
He laughed as there I sprawling lie
But quickly drove away
VERSE
}
multi method verse(::?CLASS:U: 4 --> Seq:D) {
lines q:to/VERSE/
Now the ground is white
Go it while you're young
Take the girls tonight
And sing this sleighing song
Just get a bobtailed bay
Two forty as his speed
Hitch him to an open sleigh
And crack, you'll take the lead
VERSE
}
multi method chorus(::?CLASS:U: --> Seq:D) {
lines q:to/CHORUS/
Jingle bells, jingle bells
Jingle all the way
Oh, what fun it is to ride
In a one-horse open sleigh, hey
Jingle bells, jingle bells
Jingle all the way
Oh, what fun it is to ride
In a one-horse open sleigh
CHORUS
}
multi method lyrics(::?CLASS:U: --> Seq:D) {
gather for 1..4 {
take $_ for @.verse($_);
take "";
take $_ for @.chorus;
take "" if $_ != 4;
}
}
}
不过,这种方法存在问题。如果您想保留一系列圣诞颂歌以邻里颂歌怎么办?
use Christmas::Carol::JingleBells;
use Christmas;:Carol::JingleBellRock;
use Christmas::Carol::DeckTheHalls;
use Christmas::Carol::SilentNight;
# And so on...
这不太好! 您无需知道谁写了圣诞节颂歌就可以唱歌。最重要的是,没有人想到符号的圣诞颂歌。他们以他们的名字来思考他们。为了有效地表示它们,我们需要使其能够使用其名称查找圣诞节颂歌,同时还可以引入可以以这种方式同时查找的新颂歌。我们应该怎么做?
我们在这里使用的方式需要对 Raku 中的类型如何工作进行一些解释。
64.1. 元对象协议
类可能包含三种不同类型的方法声明。你最常看到的两个是公共方法和私有方法:
class Example {
method !private-method(|) { ... }
method public-method(|) { ... }
}
您可以进行方法声明的第三种类型,这是类所独有的(这是一个谎言,但这仅是用 Rakudo 编写 Raku 时就是这种情况),方法名称的前缀是 ^.
通常使用 .^
调度运算符来调用这些函数,当您需要自检一个对象(.^name
, .^method
等)时,通常会看到它们。但是,这些行为完全不像您期望的方法那样。让我们看一下使用 .^
调度运算符调用此类型的方法时,invocant 和参数是什么:
class Example {
method ^strange-method(\invocant: |params --> List:D) {
(invocant, params)
}
}
say Example.^strange-method;
# OUTPUT:
# (Perl6::Metamodel::ClassHOW+{<anon>}.new \(Example))
哇,哇,WTF? 为什么在第一个参数中声明此类方法而不是在其调用方中声明类? 该对象最终成为其发起者,那又是什么呢?它是从哪里来的呢?
在对此进行解释之前,首先我们需要对元对象协议(MOP)是什么有所了解。MOP 是 Rakudo 特有的功能,通过它可以实现 Raku 中可能存在的所有对象的行为。这些基于类(类型的类型)(例如类,角色和语法)实现。任何类型的行为都是由称为元对象的高阶工作(HOW)驱动的。这些通常是某种元类的实例。例如,类的 HOW 由 Metamodel::ClassHOW 元类创建。
可以对任何给定对象的 HOW 进行自省。怎么做? 你怎么问? 怎么样? 怎么样!? 当然,通过调用 HOW!
role Foo { }
say Foo.HOW.^name; # OUTPUT: Metamodel::ParametricRoleGroupHOW
HOW 的方法称为元方法,这些方法用于处理类型可以支持的各种行为。元方法处理的某些类型的行为示例包括类型名称,属性,方法,继承,参数化和类型检查。由于其中大多数不是特定于任何一种的功能,因此通常会通过 metarol 将这些功能混合到 metaclass 中。例如,Metamodel::Naming 元角色可以处理任何可以命名的类型的命名。
那么前面提到的第三种方法声明? 实际上并没有为类声明方法; 相反,它声明了一种混合了该类 HOW 的元方法,类似于使用元醇的方法。.^
调度运算符只是一个糖,用于使用该对象作为其第一个参数来调用该对象的元方法,该元方法在大多数情况下会接受。例如,这两个元方法调用是等效的:
say Int.^name; # OUTPUT: Int
say Int.HOW.name(Int); # OUTPUT: Int
元方法是我们将仅用于实现类型的圣诞节颂歌的工具。
64.2. 传播欢乐
首先,不要让圣诞节:: Carol 角色通过圣诞节颂歌班来完成,而是让我们的颂歌角色混合到 Christmas::Carol 类中。在本课程中,我们将像扮演角色一样,对圣诞节颂歌应该具有的方法进行存根,但是除此之外,还将保留圣诞节颂歌的词典,以其名字命名。
我们可以使用 add_carol 元方法存储颂歌:
my constant %CAROLS = %();
method ^add_carol(Christmas::Carol:U, Str:D $name, Mu $carol is raw --> Mu) {
%CAROLS{$name} := $carol;
}
现在,我们可以将角色标记为颂歌,如下所示:
role Christmas::Carol::JingleBells { ... }
Christmas::Carol.^add_carol: 'Jingle Bells', Christmas::Carol::JingleBells;
但是,这不是供人们使用的出色 API。特质可以做到,因此可以从角色的声明中进行处理。让我们为此制作一个颂歌特质:
multi sub trait_mod:<is>(Mu \T, Str:D :carol($name)!) {
Christmas::Carol.^add_carol: $name, T
}
现在,我们可以将角色定义为像这样的颂歌:
role Christmas::Carol::JingleBells is carol('Jingle Bells') { ... }
为了做到这一点,我们可以按名称提取颂歌,我们只需将 Christmas::Carol 类设置为参数即可。这可以通过给它一个参数化元方法来完成,给定名称,该方法将使用我们知道的任何颂歌创建一个 Christmas::Carol mixin:
method ^parameterize(Christmas::Carol:U $this is raw, Str:D $name --> Christmas::Carol:U) {
self.mixin: $this, %CAROLS{$name}
}
现在,我们可以使用颂歌名称通过参数 Christmas::Carol 来检索圣诞节颂歌。但是,返回的 mixin 类型的名称是什么?
say Christmas::Carol['Jingle Bells'].^name;
# OUTPUT: Christmas::Carol+{Christmas::Carol::JingleBells}
有点难看。让我们在参数化过程中重置混音的名称:
method ^parameterize(Christmas::Carol:U $this is raw, Str:D $name --> Christmas::Carol:U) {
my Christmas::Carol:U $carol := self.mixin: $this, %CAROLS{$name};
$carol.^set_name: 'Christmas::Carol[' ~ $name.perl ~ ']';
$carol
}
这给我们的叮当铃颂歌取了圣诞节的名字:: Carol [“Jingle Bells”]。好多了。
让我们添加最后一种方法:颂歌。这将返回圣诞节已知的颂歌的名称列表:: Carol:
method ^carols(Christmas::Carol:U --> List:D) {
%CAROLS.keys.list
}
这样,我们的 Christmas::Carol 类就完成了:
class Christmas::Carol is repr<Uninstantiable> {
proto method name(::?CLASS:U: --> Str:D) {*}
proto method chorus(::?CLASS:U: --> Seq:D) {*}
proto method verse(::?CLASS:U: Int:D --> Seq:D) {*}
proto method lyrics(::?CLASS:U: --> Seq:D) {*}
method sing(::?CLASS:U: --> ::?CLASS:U) {
.say for @.lyrics;
self
}
my constant %CAROLS = %();
method ^add_carol(Christmas::Carol:U, Str:D $name, Mu $carol is raw --> Mu) {
%CAROLS{$name} := $carol;
}
method ^carols(Christmas::Carol:U --> List:D) {
%CAROLS.keys.list
}
method ^parameterize(Christmas::Carol:U $this is raw, Str:D $name --> Christmas::Carol:U) {
my Christmas::Carol:U $carol := self.mixin: $this, %CAROLS{$name};
$carol.^set_name: 'Christmas::Carol[' ~ $name.perl ~ ']';
$carol
}
}
multi sub trait_mod:<is>(Mu \T, Str:D :carol($name)!) {
Christmas::Carol.^add_carol: $name, T
}
现在,这一切都很棒,但是对我们的原始代码有何改进? 通过以这种方式定义颂歌,我们不再需要知道颂歌的歌唱符号,也不再需要知道哪个模块甚至首先宣布了颂歌。只要我们知道 Christmas::Carol 类存在,我们就知道我们导入的所有模块的所有颂歌碰巧都知道。
这意味着可以有一个模块定义颂歌的集合:
use Christmas::Carol::JingleBells;
use Christmas::Carol::JingleBellRock;
use Christmas::Carol::DeckTheHalls;
use Christmas::Carol::SilentNight;
unit module Christmas::Carol::Collection;
在另一个模块中,我们可以使用此模块进行另一个收集,并定义更多颂歌:
use Christmas::Carol::Collection;
use Christmas::Carol::JingleBells::BatmanSmells;
unit module Christmas::Carol::Collection::Plus;
然后,我们可以导入此文件,并轻松唱歌,除了这个新模块添加的名称外,还按名称演唱所有原始模块的颂歌:
use Christmas::Carol;
use Christmas::Carol::Collection::Plus;
Christmas::Carol[$_].sing for Christmas::Carol.^carols;
在这一点上,您可能会想:“难道您不可以使用实例编写与此功能相同的代码吗?”。你说得对!这表明,尽管使用 Rakudo 时有一种用于处理类型的协议,但任何给定类型的行为并不是特别独特。它主要由可以完全控制的元对象驱动。
仅使用类中的元方法声明,您就可以扩大或覆盖支持继承的任何类型的行为。这与 MOP 允许您对类型所做的事情相去甚远!与 MOP 配合使用的更高级的功能将需要更多的解释,最好再待一段时间。
65. 第十五天 - 圣诞老人有太多蛋酒了
我们距离圣诞节只有一个多星期了,圣诞老人正在向他的小精灵们发送最后的礼物清单。不幸的是,圣诞老人的蛋酒太多了,所以他发给自己的精灵的清单……不是最大的。看看其中的一些:
Johnny
- 4 bsaeball gluvs
- 2 batts
- 2 ballz
Mary
- 3 fancee dols
- 1 dressss
- 1 bbaskebtall
圣诞老人设法以某种方式保留了我们通常可以使用正则表达式处理的良好格式,因此精灵开始认真研究一种良好的 grammar:
grammar Santa'sList {
rule TOP { <kid's-list>+ }
rule kid's-list { <name> <gift>+ }
rule gift { '-' <quantity> <item> }
token name { <-[\n]> }
token quantity { <.digit>+ }
token item { <.alpha>+ % \h+ }
}
尽管精灵们认为他们可以尝试弄清他在一个动作对象中的含义,但他们认为创建一个不仅可以在 grammar 中,而且可以在任意正则表达式中重用的 token 将更加有趣!
他们想制作一个新的叫做 <fuzzy>
的 token,可以以某种方式捕获圣诞老人的醉酒涂鸦(我们可以称他的打字列表为涂鸦吗?)。但是正则表达式语法实际上不允许进行任何类型的模糊匹配。但是这里 Raku 的引擎得以救援。因此,首先他们在 token 内部创建了一个代码块。代码块通常仅用 { 🦋 } 来定义,但是由于它们需要定义匹配是否成功,因此选择了条件块而不是 <?{ 🦋 }> 条件块,这不仅会运行代码,而且如果条件块也将失败块返回 false-y 值。
token fuzzy {
(<.alpha>+ % \h+)
<?{
# «ö» code here
}>
}
在开始编写代码之前,他们做了另外两件事。首先,他们将捕获命名以便于维护。其次,他们意识到他们确实需要以某种方式将可能的玩具清单放入 token 中。因此,他们向 token 添加了签名以将其传递。
token fuzzy(**@toys) {
$<santa's-text>=(<.alpha>+ % \h+)
<?{
# «ö» code here
}>
}
现在他们可以开始编写代码了。他们会采用圣诞老人的文字,并将其与每个可能的玩具进行比较,然后确定哪个是最接近的玩具:
token fuzzy(**@toys) {
$<santa's-text>=(<.alpha>+ % \h+)
<?{
my $best = @toys
.map({ $^toy, qgram($toy,$<santa's-text>.Str)})
.sort( *.tail )
.tail;
say "Santa meant to write {$best[0]}";
}>
}
他们使用的 Q-gram 函数为每个单词创建 N-gram,并将它们进行比较以查看它们有多少共同点。通过测试,他们发现 N 的最佳值(每个子串的长度)约为平均长度的一半。Raku 编写 Q-gram 函数的工作方式非常简单:
#| Generate space-padded N-grams of length n for string t.
sub ngrams = -> \t, \n {
my \s = (' ' x n - 1) ~ t ~ (' ' x n - 1);
do for ^(t.chars + n) { s.substr: $_, n }
}
#| Calculate Q-gram score using bag operations
sub qgram (\a, \b) {
my \q = (a.chars + b.chars) div 4;
my \aₙ = ngrams(a,q).BagHash;
my \bₙ = ngrams(b,q).BagHash;
(aₙ ∩ bₙ) / (aₙ ∪ bₙ) # Coefficient de communauté de Jaccard
}
Raku 让精灵只用两行干净的代码就可以计算出 N-gram,然后再用另外四行易于阅读的代码来使用它们来计算两个字符串之间的 Jaccard-index。
将其放回到 grammar 中,他们得出以下结论:
grammar Santa'sList {
rule TOP { <kid's-list>+ }
rule kid's-list { <name> <gift>+ }
rule gift { '-' <quantity> <item> }
token name { <-[\n]> }
token quantity { <.digit>+ }
token item { <fuzzy(@gifts)> }
token fuzzy { … }
sub ngrams { … }
sub qgrams { … }
}
这是一种非常方便的格式,但是仍然存在一个重要的问题。他们如何获得最匹配的文本? 如果他们匹配并要求,例如 $<kid’s-list>[0]<gift>[0]<item>
,那么他们只会得到圣诞老人原来难以辨认的混乱。他们可以执行一个动作,但是需要对动作进行解析,这意味着 fuzzy token 与语法解析的繁琐联系在一起。在这里可以正常工作,但是…不可重用。
但是精灵擅长包装和包裹。他们决定制作一个包装了 fuzzy token 的包装,以便以 DWIM 方式轻松访问圣诞老人的原始版本和经更正的版本。不过,此"包"不能与包或模块一起声明,因为打包过程需要使用特殊的 sub EXPORT。它们的基本过程如下所示:
sub EXPORT {
# Make the fuzzy token in the elve's factory
my token fuzzy (*@words) { … }
# Wrap it in wrapping paper (apply a role) so it's prettier (easier to use)
&fuzzy.wrap( … )
# Ship out (export) the wrapped version
%( '&fuzzy' => &fuzzy )
}
精灵需要的任何其他特殊工具都可以包含在 EXPORT 块中,例如 Q-gram 和 N-gram 函数。那么,他们实际上将如何包装? 首先,他们设计文件,即参数化角色,它将覆盖 .Str 以提供干净/更正的值,但还提供对 .fuzz 函数的访问以允许访问较旧的值:
role Fuzzy[$clear,$fuzz] {
method Str { $clear }
method fuzz { $fuzz }
}
现在,包装后的函数可能如下所示:
&fuzzy.wrap(
sub (|) {
my $match = callsame;
# Failed match evals to false, and is just passed along
# Successful match gets Fuzzy role mixed in.
$match
?? $match but Fuzzy[$match.??, $match.??]
!! $match
}
);
有一个小问题。它们在 token 中进行的计算结果不可用。他们认为涉及向具有特征的 fuzzy token 添加新参数的一种解决方案是原始的,以便可以将值传递回去,但这就像老 C++
精灵会做的那样。不,圣诞老人的 Raku 精灵有一个更好的主意:动态变量。他们制作了其中的两个,并重构了原始的 fuzzy 方法以分配给他们:
my token fuzzy(**@toys) {
$<santa's-text>=(<.alpha>+ % \h+)
<?{
my $best = @toys
.map({ $^toy, qgram($toy,$<santa's-text>.Str)})
.sort( *.tail )
.tail;
$*clear = $best[0];
$*fuzz = ~$<santa's-text>;
}>
}
&fuzzy.wrap(
sub (|) {
my $*fuzz;
my $*clear;
my $match = callsame; # sets $match to result of the original
$match
?? $match but Fuzzy[$*clear, $*fuzz]
!! $match
}
);
他们用一些值进行了测试,并且一切顺利,直到没有找到一个项目:
"I like the Norht Pole" ~~ /I like the $<dir>=<fuzzy: <North South>> Pole/;
say $<dir>.clear; # --> "North"
say $<dir>.fuzz; # --> "Norht"
"I like the East Pole" ~~ /I like the $<dir>=<fuzzy: <North South>> Pole/;
say $<dir>.clear; # --> "North"
say $<dir>.fuzz; # --> "East"
发生了什么? 精灵们意识到他们的 token 无论如何都匹配。这是因为 <?{ 🦋 }>
块仅在返回假值时才会失败。最后一个语句是字符串的赋值,实际上几乎总是真实的。为了解决这个问题,他们在区块末尾添加了一个简单的条件,如果 Q-gram 得分不够高则失败。
my token fuzzy(**@toys) {
$<santa's-text>=(<.alpha>+ % \h+)
<?{
my $best = @toys
.map({ $^toy, qgram($toy,$<santa's-text>.Str)})
.sort( *.tail )
.tail;
$*clear = $best[0];
$*fuzz = ~$<santa's-text>;
# Arbitrary but effective score cut off.
$best[1] > 0.33
}>
}
这样,他们就完成了,并且能够处理圣诞老人的可怕打字。
当然,精灵们仍然可以做出很多改进,以使他们的 fuzzy token 更加有用。在他们使用完之后(将蛋酒从圣诞老人那里拿走了,所以他们不再需要它了),他们对其进行了抛光,以便为所有人带来欢乐。
这样,我还可以宣布 Regex::FuzzyToken 的发布。要使用它,就像精灵一样,并在 grammar 或任何其他代码中进行操作,例如说使用 Regex::FuzzyToken,token fuzzy 将被导入到你当前的作用域中。它具有一些额外的功能,因此请查看其自述文件以获取有关某些选项的信息。
尽管不是每个人都会使用或需要 fuzzy token,但我希望这能在创建可以通过编程更好地定义的 token 以及其他酷 Raku 功能(例如 Bag 运算符,动态变量和参数化角色)时展示一些有趣的可能性。
66. 第十六天 - Raku 加持的工作面跳转
圣诞老人确保他的小精灵可以快速进出工作场所。我希望计算机编程也一样!有时需要花费一些时间才能遍历代码库,以查找真正的工作需要在哪里进行。
jmp 是 Raku 提供动力的命令行实用程序,我用于搜索和跳转大型代码库和命令输出。它为您最喜欢的 $code-searching
工具(例如,ag,git grep,ack 等)提供了一个终端前端,因此您可以快速跳入您最喜欢的 $EDITOR
(例如,vim,代码,emacs 等)。
它是这样的:
跳入大型代码库时,我经常需要访问许多不同的文件和存储库。有时这会打击我的短期记忆缓冲区。为了保持流程顺畅,我在整个代码中留下了面包屑式的注释(即“#COAL”)。稍后,当我需要返回文件位置的界面时,我会进行一次 jmp 搜索以查找 “COAL”。
shell> jmp to COAL
可以,但是这是手动过程,需要先清除所有 COAL 标记。jmp 可以在这里提供更多帮助吗?
如果 jmp 自动记住我在代码库中访问过的地方,这样就很酷了,那么我就不需要留下 COAL 标记了。听起来还需要更多 Raku!
为此,jmp 需要一个内存:
# keep a file-based record of the searches and jmps
class JMP::Memory {
has $.max-entries = 100; # keep a record of the last 100 jmps
has $!file;
has @!latest-jmps;
#| get a list of the most recent JMP::File::Hits
method get-recent-jmps ($last-n-jmps) { ... }
#| write the memory file
method save ($jmp-command, $hit) { ... }
submethod TWEAK { ... }
}
因此,让我们填写这个类。
TWEAK 是一种特殊的子方法,用于帮助在创建实例后延迟完成对象的构建。并非所有对 jmp 的调用都需要重新调用内存,因此最好避免处理历史记录文件。$!file
和 @!latest-jmps
封装为 私有属性,因此将来可以根据需要轻松更改。
submethod TWEAK {
$!file = $*HOME.add('.jmp.hist').path;
return unless $!file.IO.e;
return without my $history = from-json($!file.IO.slurp);
@!latest-jmps = $history.List;
}
方便的全局 $*HOME IO::Path 对象有助于为 jmp 历史记录文件(例如 ~/.jmp.hist
)创建跨平台位置。如果文件存在,我们将文件一口吃掉,然后借助 JSON::Tiny::from-json 将 json 有效内容解析为 jmp 记录列表。
每当发生搜索命中时,我们都需要保存 jmp 命令和选定的命中:
#| write the memory file
method save ($jmp-command, $hit) {
# for each incoming hit - we record two entries - the jmp command
my %jmp-record = %(
current-directory => $*CWD.path,
jmp-command => $jmp-command,
);
# and the selected destination
my %hit-record = %(
line-number => $hit.line-number,
file-path => $hit.file-path,
full-path => $hit.full-path,
matching-text => $hit.matching-text,
);
@!latest-jmps.unshift(%hit-record);
@!latest-jmps.unshift(%jmp-record);
my @hits = @!latest-jmps;
@!latest-jmps = @hits.head($!max-entries);
# dump to disk
$!file.IO.spurt(to-json(@!latest-jmps));
}
当用户不带任何参数调用 jmp 时,将显示最近的 jmp 列表。
method get-recent-jmps ($last-n-jmps) {
# return a list of JMP::File::Hits
my @recent-jmps;
for @!latest-jmps.head($last-n-jmps) -> %hit {
my $hit = %hit<jmp-command>:exists
?? JMP::Memory::Command.new(|%hit)
!! JMP::Memory::Hit.new(|%hit);
@recent-jmps.push($hit);
}
return @recent-jmps;
}
for 循环遍历先前记录的 jmp 命令和选定的搜索命中。这是 JMP::Memory 的完整源代码。
现在,jmp 可以帮助您快速返回编码界面,而不会一团糟。
shell> zef install jmp
shell> zef upgrade jmp # if you've installed it before
期待在 2020 年使用更多由 Raku 提供动力的公用事业。
圣诞快乐!
67. 第十七天 - 迷宫机
我记得在学校的时候,我曾经玩过游戏,可以帮助鼠标尽可能快地到达迷宫。甚至当我完成大学学习时,我仍然想知道如何创建一个迷宫。那些制造迷宫的人必须超级聪明。
直到最近我才决定解开迷宫创作的奥秘。我问我的朋友谷歌,想知道是什么,给我提供了多种语言的大量解决方案。有些非常难于遵循,而有些则并非如此。深入了解算法后,我决定以我的第一语言(即 Perl)来做。我花了很长时间将算法转换为 Perl 脚本。
众所周知,我仍然是 Raku 的新手。值得庆幸的是,在 Raku 查询方面,我有很多支持。我第一次决定成为 Raku Advent Calendar 的一员。多亏了 JJ Merelo,我才有空位。那时,我不知道该怎么办。但是我知道我有很多可以转化为 Raku 的想法。
您猜对了,我为 Raku Advent Calendar 选择了我最喜欢的迷宫制作器脚本。在 Twitter 上许多人的帮助下,我在 Raku 准备了我的第一稿。但这并没有按预期创建隧道。我去找朋友 Scimon Proctor 寻求指导。他帮助我完成了 Raku 魔术,现在一切顺利。
在我将其交给 JJ Merelo 之前,是时候做一些家务了。花了大约 30 分钟的时间,我准备好了我的最终草案,如下所示:
use v6;
my %opposite-of = ( 'north' => 'south', 'south' => 'north', 'west' => 'east', 'east' => 'west' );
my @directions = %opposite-of.keys;
sub MAIN(Int :$height = 10, Int :$width = 10) {
my $maze;
make-maze( 0, 0, $maze, $height, $width );
say render-maze( $maze, $height, $width );
}
#
#
# METHODS
sub make-maze( $x, $y, $maze is rw, $height, $width ) {
for ( @directions.pick(@directions) ) -> $direction {
my ( $new_x, $new_y ) = ( $x, $y );
if ( 'east' eq $direction ) { $new_x += 1; }
elsif ( 'west' eq $direction ) { $new_x -= 1; }
elsif ( 'south' eq $direction ) { $new_y += 1; }
else { $new_y -= 1; }
if not-visited( $new_x, $new_y, $maze, $height, $width ) {
$maze[$y][$x]{$direction} = 1;
$maze[$new_y][$new_x]{ %opposite-of{$direction} } = 1;
make-maze( $new_x, $new_y, $maze, $height, $width );
}
}
}
sub not-visited( $x, $y, $maze, $height, $width ) {
# check the boundary
return if $x < 0 or $y < 0;
return if $x > $width - 1 or $y > $height - 1;
# return false if already visited
return if $maze[$y][$x];
# return true
return 1;
}
sub render-maze($maze, $height, $width) {
my $as_string = " " ~ ( "_ " x $width );
$as_string ~= "\n";
for ( 0 .. $height - 1 ) -> $y {
$as_string ~= "|";
for ( 0 .. $width - 1 ) -> $x {
my $cell = $maze[$y][$x];
$as_string ~= $cell<south> ?? " " !! "_";
$as_string ~= $cell<east> ?? " " !! "|";
}
$as_string ~= "\n";
}
return $as_string;
}
67.1. What next?
我正在考虑将其转换为 Raku 库,类似于 Games::Maze。 我正在研究它,希望圣诞节前可以准备好它。
如果您认为可以以任何方式进行改进,请与我分享。 我很想听听您的意见,并从您的经验中学到东西。 然后,同样喜欢 Raku 的 Perl Fan 来尝试一下。
68. 第十八天 - 我的并发 raku 程序在做什么?
Raku 使在程序中引入一些并行性变得容易-至少在解决方案适合时。在构建并发应用程序时,其异步编程功能也很出色。在过去的一年中,我喜欢在工作中同时使用这两种方法。
但是,我还发现缺少某些东西。在 Raku 中构建想要的并行和并发事物相对简单。但是,一旦它们被构建,我就很难推理它们在做什么。例如:
-
对于数据并行操作,实现了什么程度的并行性?
-
哪些任务并行执行,又有没有机会进一步并行执行任务?
-
异步工作流的不同阶段花在哪里?例如,如果 HTTP 请求可能触发后台工作,随后又导致通过 WebSocket 发送消息,那么这些步骤之间的时间在哪里?
-
鉴于 Cro Web 应用程序也是异步管道,请求处理时间在哪里?中间件是否令人吃惊地吞噬了很多时间?
首先,我开始放入一些时序代码,这些结果将结果写到控制台。当然,如果要保留在代码中,则必须以后再删除它,或者使用环境变量来保护它。即使到那时,它产生的数据也很难解释。所有这些都感觉相当低效。
因此,Log::Timeline 诞生了。使用它,我了解了很多有关我的应用程序行为的信息。最棒的是,我不仅得到了我所提问题的答案,而且还得到了一些我什至没有考虑过的问题。在这篇出现的帖子中,我将向您展示如何使用该模块。
68.1. 试一试吧
我们可能会使用 Log::Timeline 的第一种方法实际上根本不涉及使用模块,而是使用一些已经使用该模块进行记录的库。自从 Cro::HTTP 完成此操作以来(自 0.8.1 开始),我们可以通过构建 Cro HTTP 应用程序来初步了解 Log::Timeline。
除了产生日志事件的内容以外,我们还需要某种方式来查看日志。当前,Log::Timeline 输出有两种模式:以 JSONLines 格式写入文件或通过套接字发送它们。其中的第二个由 Comma(Raku IDE)中的日志查看器使用,这意味着我们可以在应用程序运行时实时查看输出。
因此,假设我们已经安装了 Cro,我们可以:
-
在逗号中创建一个新的 Cro Web 应用程序项目(工作原理非常类似于在命令行中使用 cro stub)
-
创建“交叉服务”运行配置(在“运行”菜单上选择“编辑配置”)
-
在“运行”菜单上,选择“运行’service.p6”并显示时间轴”
该服务将启动,并在控制台中显示它正在运行的 URL(可能是 http://localhost:20000)。如果我们向它发出请求,然后转到“时间轴”选项卡,我们将看到该请求已记录(实际上,在浏览器中执行该请求,然后可能会记录两个请求,因为会自动请求一个 favicon.ico)。可以扩展请求以查看如何处理请求中的时间。
Cro 可以并行处理对 HTTP 应用程序的请求。实际上,它既是并发的(由于使用了异步 I/O)又是并行的(请求处理的所有步骤都在 Raku 线程池上运行)。因此,如果我们使用 Apache Benchmark 程序一次发送 100 个请求,则希望发送 3 个,那么我们希望看到这样的指示,即最多可以并行处理 3 个请求。这是命令:
ab -n 100 -c 3 http://localhost:20000/
我们确实看到了并行性:
同样,如果我们最多处理 10 个并发请求:
ab -n 100 -c 10 http://localhost:20000/
然后,我们将看到以下内容:
再仔细一点,我们看到 “Process Request” 任务被记录为 Cro 模块的 HTTP Server 类别的一部分。但是,还不止这些:tasks(一段时间内发生的事情)也可以记录数据。例如,HTTP 请求方法和目标也被记录:
我们可能还会注意到,请求以交替的阴影显示。这是为了使我们能够区分两个任务,如果它们“背靠背”地发生而没有时间间隔(或者至少在我们的缩放级别上不可见)。
68.2. 向应用程序添加 Log::Timeline 支持
如果我们想将 Log::Timeline 支持添加到我们自己的应用程序中,那么我们可以更多地了解其行为,该怎么办? 为了说明这一点,我们将其添加到 jsonHound 中。这是一个查看 JSON 文件并确保它们符合一组规则的应用程序(它是为检查路由器配置的安全性而构建的,但原则上可以使用更多的规则)。
运行 jsonHound 时,有两个步骤:
-
加载用 Raku 编写的规则
-
根据规则检查每个指定的 JSON 文件; 如果有多个文件,将并行检查它们
我们将为每个任务创建一个 Log::Timeline 任务。他们进入 JsonHound::LogTimelineSchema 模块。模块中的代码如下所示:
unit module JsonHound::Logging;
use Log::Timeline;
class LoadRules does Log::Timeline::Task["jsonHound", "Run", "Load Rules"] {
}
class CheckFile does Log::Timeline::Task["jsonHound", "Run", "Check File"] {
}
首先,我们使用 Log::Timeline 模块。然后,我们为每个执行 Log::Timeline::Task 角色的任务创建一个类(还有一个 Event 角色,可用于记录在特定时间发生的事件)。
接下来,我们需要修改程序以使用它们。首先,在我们要添加日志记录的代码中,我们需要使用我们的任务模式:
use JsonHound::LogTimelineSchema;
现在我们可以开始了。加载规则集如下所示:
my $*JSON-HOUND-RULESET = JsonHound::RuleSet.new;
my $rule-file = $validations.IO;
CompUnit::RepositoryRegistry.use-repository:
CompUnit::RepositoryRegistry.repository-for-spec(
$rule-file.parent.absolute);
require "$rule-file.basename()";
我们将其包装如下:
JsonHound::Logging::LoadRules.log: {
my $rule-file = $validations.IO;
CompUnit::RepositoryRegistry.use-repository:
CompUnit::RepositoryRegistry.repository-for-spec(
$rule-file.parent.absolute);
require "$rule-file.basename()";
}
当我们没有任何日志输出运行时,该块将照常执行。但是,如果要输出套接字,那么它将在该块的执行开始时和结束时通过套接字发送一条消息。
每个文件的分析如下所示:
.($reporter-object) for @json-files.race(:1batch).map: -> $file {
# Analysis code here
}
也就是说,我们获取 JSON 文件,然后并行映射它们。每个产生一个结果,然后我们与报告者调用。确切的细节无关紧要;我们真正需要做的就是将分析包含在我们的任务中:
.($reporter-object) for @json-files.race(:1batch).map: -> $file {
JsonHound::Logging::CheckFile.log: {
# Analysis code here
}
}
方便地,log 方法沿该块的返回值传递。
到目前为止,我们所做的一切都会奏效,但我们可以迈出更好的一步。如果我们查看日志输出,则可能会看到一个 JSON 输入文件,该文件需要花费很长时间来处理,但我们不知道它是哪个文件。我们可以通过选择命名数据,用我们选择的任何数据注释日志条目。
.($reporter-object) for @json-files.race(:1batch).map: -> $file {
JsonHound::Logging::CheckFile.log: :$file, {
# Analysis code here
}
}
至此,我们已经准备好! 在逗号中添加运行配置并使用时间线查看器运行它之后,我们得到如下图:
68.3. 未来
尽管 Log::Timeline 已经可以提供一些有趣的见解,但仍有许多其他功能可以使用。当前正在进行的工作使用新的 MoarVM API 订阅 GC 事件并记录它们。这意味着可以可视化 GC 的运行时间以及花费的时间,并将其与正在发生的其他事件相关联。
我还想展示各种 Rakudo 级别的事件,这些事件可能会很有趣,可以在时间线上看到。例如,有可能提供有关锁定等待时间或等待时间或供应争用时间的信息。其他想法正在绘制文件或其他资源的打开和关闭时间,这反过来可能有助于发现资源泄漏。
当然,仅因为我们可以记录的范围很广,并不意味着它们都有用,并且记录本身也有开销。LogTimelineSchema 命名约定的使用期待进一步的功能:能够自省所有可用的事件和任务集。然后,在逗号中,我们将提供一个 UI 来选择它们。
68.4. 最后…
在解决程序问题上,我们花费了不小的时间来找出程序运行时发生的情况。好的工具可以为了解程序的行为提供一个窗口,并且在某些情况下指出了我们甚至可能没有考虑的事情。Log::Timeline 是一个很小的模块,而可视化工具并没有花很长时间来实现。但是,在有用信息方面的回报使它成为我今年最有价值的东西之一。希望您也觉得它有用。
69. 第十九天 - Raku 中的函数式编程
我最近看了一个非常棒的视频,即 40 分钟的《函数式编程》,我认为那真的很好。这些年来,我已经完成了一些函数式编程,但是我知道很多人都觉得它背后的想法很混乱。
所以我要说的第一件事就是观看该视频,这真的很有帮助。
我会等你。
好了吗?好的,很酷,所以我确定你已经掌握了核心概念,函数式编程可以分为三大部分。
-
纯函数
-
不可变数据结构
-
桥接系统
那么将这些想法引入 Raku 代码有多么容易? 你会惊讶地知道"真的"将是该问题的答案吗?我认为不是,让我们依次浏览每个部分。
69.1. 纯函数
因此,纯函数不会引起任何副作用。对于任何给定的输入,它总是提供相同的输出。Raku 包含一个 is pure 的 trait,你可以用它来标记代码块以表示它将返回相同的值,并且编译器可以以此为提示来用常量替换调用。通常,如果你使用标记纯函数,那么你也不会造成任何副作用(因为你的代码只能被调用一次)。
Raku 不仅有多种方法可以从命名的 subs sub name($n) {"Hello $n"}
到匿名 sub ($n) {"Hello $n"}
生成代码块,再到尖的代码块 → $n {"Hello $n"}
,最后只是普通块 blocks {"Hello $_"};
所有这些都可以调用或分配给变量,并在其他子程序中使用。
Raku 还允许你使用一些更复杂的函数创建技术来进行函数组合和计算。虽然这些与视频无关,但它们很酷,所以我要讲题了。忍受我。
69.2. 函数组合
函数组合使我们可以利用 f(g(x)) 对于 x 的给定值始终返回相同值的想法。因此,我们可以创建一个新的函数 h,其中 h(x) == f(g(x))。合成运算符(∘或 o)允许使用定义 h,而不必正式包装函数:
my &h = &f ∘ &g; # or my &h = &f o &g;
现在当你调用 h(x) 时,它和调用 f(g(x)) 是一样的。函数组合可以让你创建复杂的函数链,你可以很容易地传递给其他函数。
69.3. 柯里化
柯里化函数是当你想使用一个函数并生成一个由部分调用原始函数组成的新函数时的…
好,让我们解释一下。可以说,我有一个 greeting 函数,它需要像这样的问候和名称:
sub greeting( Str $greeting, Str $name ) {
return "$greeting $name!"
}
好。现在,我们想要一个仅使用名称的函数,问候语将设置为 “Hello”。我们可以做这样的事情:
sub hello( Str $name ) {
greeting( "Hello", $name );
}
或者可能创建一个闭包。
my &hello = -> $greeting {
-> $name {
greeting($greeting, $name)
}
}("Hello");
但是 Raku 有一个内置的方法,该函数使用一个函数来 assuming 这一点,从而简化了这一过程:
my &hello = &greeting.assuming("Hello");
很好。但是,如果我们想假设一个更高的值呢? 这是一个简单的不具名的提问者,我们可以利用像这样的任何星号。
my &greet-sam = &greeting.assuming(*, "Sam");
现在我们可以调用 greet-sam("Hi")
并返回 "Hi Sam!"。
通过组合函数组成和柯里化,你可以从简单,易于测试的零件中创建复杂的功能。
69.4. 不可变数据结构
my $foo := 10;
my \foobar = 12;
constant bar = 11;
同样默认情况下,当你创建实例的 Point 类实例时,所有 Raku 对象都是不可变的:
class Point {
has Num $.x;
has Num $.y;
}
你可以创建一个 Point 实例,并在创建时分配 x 和 y 值,但是一旦创建,就无法对其进行修改。当然,你可以使用 is rw 创建可变对象,但是如果你想进行函数式编程,那么就已经准备就绪。
因此,Raku 为我们提供了不可变数据所需的一切。
70. 第二十天 - perl 到 raku 的代码转换
剧透警报!
当我开始撰写这篇由两部分组成的文章时,我很高兴地没有意识到 Raku 社区中的类似话题。但是,当我于 12 月 9 日星期一第一次阅读 Elizabeth Mattijsen 的 Raku Weekly Blog 时,我被这一事实惊醒,并看到著名的 Perl 和 Raku 专家 Jeff Goff 写了一个由多个部分组成的系列,内容涉及移植非常复杂的 Perl 模块,到 Raku。对我而言,唯一可以节省的地方是这些帖子都是技术性的,它的读者对象是认真的 Raku 黑客,他们希望为现有的最复杂的 Perl 模块生成本机 Raku 代码:强烈推荐有经验的程序员,他们喜欢反向编程,工程及其所有苦难!因此,我要向 Jeff 致意,继续在我那古老的 Perl 花园里漫步,那里充满了新手,自学成才的 Perl。
还有一点说明:Jeff 正在移植的模块的作者 John McNamara 是我最喜欢的 Perl 模块的作者,他的出色模块使 Perl 用户可以将 Microsoft Excel 切片和切块,这对我来说是我的救星。这些年来,我与 John 进行了多次电子邮件讨论,他是一个善良而又很有才华的人,为 Perl 用户做出了巨大贡献。非常感谢,约翰!
70.1. 介绍
在本文中,我们将继续将旧的 Perl 代码移植到 Raku。为了遵循第 2 部分,您应该已经阅读了第 1 部分中的内容。确保从 Github 克隆了练习代码,因此您可以继续进行,因为许多实际的移植问题和解决方案在 N 阶段的每个分支中均以 git commits 的形式显示。
我们移植冒险的下一步是开始将 Perl 模块转换为 Raku。从 Perl 到 Raku 的过渡期间,我们将努力将每个 Perl 模块复制(移植)到 Raku。
在上一篇文章中,我们继续确保在将其所有 Perl 子例程移至 Perl 模块时,转换后的程序可以运行(不带参数)。现在,当我们移植 Perl 模块时,我们还将检查驱动程序的实际操作。我们希望在继续处理旧代码的过程中会发现更多挑战,所以让我们深入研究吧!
70.2. 阶段 2:我们离开的地方
在第 1 部分的结尾,我说过我将对驱动程序进行另外一件事(manage-web-site.raku):将 if/else 块替换为 when 块,所以我现在要做。请进入 2019 年的练习存储库目录,并确保您处于第二阶段的分支中,并且未提交任何更改。
为了使用 when 块,我们必须使用隐式主题变量($_)而不是 $arg
变量。但是,此刻我们仍然需要它,因此我们将其从循环变量中删除,并在块的顶部临时声明它(请参见注释 1):my $ arg = $_
。现在,让我们再次执行程序,这次使用 -h
(帮助)选项:
$ ./manage-web-site.raku -h
Use of uninitialized value of type Any in numeric context
in block at ./manage-web-site.raku line 184
Use of uninitialized value of type Any in numeric context
in block at ./manage-web-site.raku line 185
Use of uninitialized value of type Any in numeric context
in block at ./manage-web-site.raku line 186
No such method 'Int' for invocant of type 'Any'
in block at ./manage-web-site.raku line 186
以下是违规行:
# lines 181-187:
my $arg = $_; # <= new decl of $arg, set to topic variable's value
my $val;
my $idx = index $arg, '='; # = 0) { # <= this is where the problem first surfaces
$val = substr $arg, $idx+1; # <= and then here
$arg = substr $arg, 0, $idx; # <= and here
}
发生的事情是 Perl 和 Raku 之间的另一种语法变化浮出水面:如果找不到该指针,Perl 中的索引例程将返回 “-1”,但是在 Raku 中它返回未定义,因此我们现有的 Perl 整数测试会引发错误。解决方案是测试返回值的定义性。像在 Perl 中一样,我们不能仅使用 if 来测试零,因为这是一个有效值,但事实并非如此。因此,我们将代码更改为:
# lines 184-187:
if $idx.defined { # <= this is the only change needed
$val = substr $arg, $idx+1;
$arg = substr $arg, 0, $idx;
}
经过几次清理后,我们准备开始移植模块。确保您的 git repo 在分支 stage-2>
上,并且没有任何未提交的更改就干净了。然后执行:
$ git checkout -b stage-3
70.3. Porting a Perl module
首先,让我们看一下我们将遇到的一些常见问题:
-
将 Perl 的导出方法转换为 Raku 更简单的语法
-
将 Perl 样式的调用转换为 Raku 的函数签名
-
将 Perl 的 foreach 循环转换为 Raku 的
@arr → {
循环 -
将 Perl 的
for (my $i …) {…}
循环转换为 Raku 的loop (…) {…}
肯定会有更多问题,而且我敢肯定,我所做的更改可能不是对现代 Perl 更为了解的人可能做出的更改,但请记住,我的许多老 Perl 都早于现代 Perl,而我并不总是 是时候改进代码了,但是只是在可行的解决方案上获得了初步的收获。
在介绍旧的 Perl 之前,我们将介绍处理导出和签名的工作示例。以下 Perl 模块(P5.pm)使用 Damian Conway 的 Perl6::Export::Attrs 模块,以简化和类似 Raku 的导出处理。注意参数传递的三种类型。
package P5;
use feature 'say';
use strict;
use warnings;
# This module does NOT affect exporting to Raku, it only affects
# exporting to Perl programs. See program `usep5.pl` for examples.
use Perl6::Export::Attrs; # [from CPAN] by Damian Conway
our $VERSION = '1.00';
# Always exported:
# sub passing all args via @_ array:
sub sayAB :Export(:MANDATORY) {
my $a = shift;
my $b = shift;
$b = 0 if !defined $b;
say "\$a = '$a'; \$b = '$b'";
}
# Export sayABC when explicitly requested or when the ':ALL' export is set
# sub passing args via a hash:
sub sayABC :Export(:sayABC) {
my $href = shift;
my $a = $href->{a};
my $b = $href->{b};
my $c = $href->{c};
$a = 0 if !defined $a;
$b = 0 if !defined $b;
$c = 0 if !defined $c;
say "\$a = '$a'; \$b = '$b'; \$c = '$c'";
}
# Always exported:
# sub pass rw args via a ref
sub changeC :Export(:MANDATORY) {
my $cref = shift;
my $newc = shift;
$${cref} = $newc;
}
1; # mandatory true value
以下 Perl 程序(usep5.pl)显示了如何使用三种类型的参数传递。它还显示了如何使用限制导出的 sub (sayABC)
。
use feature 'say';
use strict;
use warnings;
use lib qw(.);
use P5 qw(:sayABC);
sayAB(1); # using the @_ for passing arguments
sayABC({a=>1, b=>2}); # using a hash for passing arguments
my $c = 1;
say "\$c = $c";
changeC(\$c, 2); # using a reference to pass a read/write variable
say "\$c = $c";
We exercise the Perl script:
$ ./usep5.pl
$a = '1'; $b = '0'
$a = '1'; $b = '2'; $c = '0'
$c = 1
$c = 2
然后,我们看到 Perl 脚本和模块的 Raku 版本演示了获得相同输出结果所需的更改(但请参见注释 2)。
unit module P6;
#| Always exported:
#| ported from a Perl sub passing all args via @_ array:
sub sayAB($a, $b = 0) is export {
say "\$a = '$a'; \$b = '$b'";
}
#| Export &sayABC when explicitly requested or when the ':ALL' export is set
#| ported from a Perl sub passing args via a hash:
sub sayABC(:$a, :$b, :$c = 0) is export(:sayABC) {
say "\$a = '$a'; \$b = '$b'; \$c = '$c'";
}
#| Always exported:
#| ported from a Perl sub passing a read/write arg:
sub changeC($c is rw, $newc) is export {
$c = $newc;
}
use lib ;
use P6 :ALL; #= <= ensures all exportable object are exported
sayAB 1;
sayABC :b, :a;
my $c = 1;
say "\$c = $c";
changeC $c, 2;
say "\$c = $c";
执行 Raku 脚本应该会显示同样的结果。
$ ./usep6.raku
$a = '1'; $b = '0'
$a = '1'; $b = '2'; $c = '0'
$c = 1
$c = 2
瞧!希望您可以看到 Raku 模块的版本比 Perl 的更干净,更简单。
为了引导我们进入要移植的第一个模块,我们将在驱动程序中执行 main 选项:
$ ./manage-web-site.raku -gen
Collecting names by CS...
Unable to open file 'usafa-template1-letter.ps': No such file or directory
in method call-args at /usr/local/rakudo.d/share/perl6/site/sources/ACCE801FB16076DAD1F96BE316DBFEDD148902C8 (Inline::Perl5) line 430
in sub at /usr/local/rakudo.d/share/perl6/site/sources/ACCE801FB16076DAD1F96BE316DBFEDD148902C8 (Inline::Perl5) line 935
in block at ./manage-web-site.raku line 449
如果我们看一下显然引起故障的行(449),我们将看到以下内容:
build_montage(%CL::mates, $genS); #= <= in module ./PicFuncs.pm5
并且我们看到模块 PicFuncs.pm5 是入口点。因此,我们将选择该模块开始并将其复制到 PicFuncs.pm6。我们在最喜欢的编辑器中打开新文件,然后看到我们可以立即进行一些更改(从第一行开始):
-
将 package 更改为 unit 模块
-
删除下一个文件代码行
-
将所有 sub 行更改为:Export(:DEFAULT) is export
-
将 sub build_montage 移动到模块顶部,以方便一次移植一个 sub
-
删除 Perl 代码以在文件底部获得真正的返回
现在让我们看一下我们注意到的其他一些事情:
-
该模块正在使用 Perl 格式的 G.pm,因此我们将不得不下一次转换它,或者更改我们在 PicFuncs.pm6 模块中使用它的方式。我选择将 G.pm 模块转换为 G.pm6
-
为了减轻 port,我们想使用
=finish
pod 功能在第一个 sub 之后结束 PicFuncs.pm6 模块 -
我们必须更改在 manage-web-site.raku 文件中使用 PicFuncs 模块的方式
您可能会猜到不久,如果不将其余的 Perl 模块转换为 Raku,我们将无法走得更远。但是,趋向于上面的列表…
为了检查进度,我们再次执行驱动程序:
$ ./manage-web-site.raku -gen
SORRY!=== Error while compiling PicFuncs.pm (PicFuncs)
This appears to be Perl 5 code. If you intended it to be Perl 6 code, please use a Perl 6 style declaration like "unit package Foo;" or "unit module Foo;", or use the block form instead of the semicolon form.
at PicFuncs.pm (PicFuncs):1
------> package PicFuncs;⏏
哇,看起来我们需要尝试将两种类型的模块分开,因为名称使 Raku 感到困惑。我将首先尝试在驱动程序中重新排列一些代码。
70.3.1. use 语句和模块搜索
请注意,Perl 和 Raku 处理 usestatement 的方式有所不同。在 Perl 中,按照以下顺序在模块中搜索模块:在 use lib
语句中定义的路径,在环境变量 PERL5LIB 和 PERLLIB 中定义的路径(按该顺序),最后在 @INC
数组中定义的路径中。在任何路径列表中,单个路径都用冒号(':')分隔,这与操作系统路径分隔符一致。
Raku 与众不同。首先,我们不能在模块中使用 use 语句,因为它将在使用前进行预编译,并且无法对 use 语句进行预编译。其次,Raku 使用按以下顺序搜索的路径:在 use lib 语句中定义的路径,在环境变量 PERL6LIB 中定义的路径以及在 Raku 安装期间定义的路径。在任何路径列表中,单个路径都用逗号分隔,与 Raku 中的列表一致。这是此测试环境在我的计算机上的搜索路径(PERL6LIB = foo,bar):
$ perl6 -e 'use lib ; use MyModule'
===SORRY!===
Could not find MyModule at line 1 in:
file#/home/tbrowde/raku-advent/raku-advent-2019
file#/home/tbrowde/raku-advent/raku-advent-2019/foo
file#/home/tbrowde/raku-advent/raku-advent-2019/bar
inst#/home/tbrowde/.perl6
inst#/usr/local/rakudo.d/share/perl6/site
inst#/usr/local/rakudo.d/share/perl6/vendor
inst#/usr/local/rakudo.d/share/perl6
ap#
nqp#
perl5#
70.3.2. Resume porting…
现在再试一次:
$ ./manage-web-site.raku -gen
===SORRY!===
Unsupported use of 'foreach'; in Perl 6 please use 'for'
at PicFuncs.pm6 (PicFuncs):48
foreach⏏ my $cs (@cs) {
Other potential difficulties:
To pass an array, hash or sub to a function in Perl 6, just pass it as is.
For other uses of Perl 5's ref operator consider binding with +++::=+++ instead.
Parenthesize as \(...) if you intended a capture of a single variable.
at PicFuncs.pm6 (PicFuncs):23
U65::get_keys_by_sqdn(\⏏%sqdn, $mref);
现在,我们首先看到 Perl 和 Raku 之间的某些循环差异。我将从更改循环开始。关于 Perl 的警告,原因是 for (my $i = 0; $i < $max; $i) {...}`。直接翻译为 Raku 的方法是:`loop (my $i = 0; $i < $max; $i) {…}
,但其中有一个惊喜。循环索引变量(在 Perl 中的循环范围内)在 Raku 中的循环括号的外部范围内!因此,我养成了重写 Perl 循环的习惯,以强调索引变量的适当范围以及更好的指针来指示重复声明的可能问题:
my $i;
loop (my $i = 0; $i < $max; ++$i) {...}
现在我们再次从执行测试开始……哎呀,另一个语法问题:
$ ./manage-web-site.raku -gen
===SORRY!===
Unsupported use of @{$sqdn{$cs}; in Perl 6 please use @($sqdn{$cs) for hard ref or @::($sqdn{$cs) for symbolic ref
at PicFuncs.pm6 (PicFuncs):55
my @n = @{$sqdn{$cs}⏏};
Other potential difficulties:
To pass an array, hash or sub to a function in Perl 6, just pass it as is.
For other uses of Perl 5's ref operator consider binding with ::= instead.
Parenthesize as \(...) if you intended a capture of a single variable.
at PicFuncs.pm6 (PicFuncs):23
U65::get_keys_by_sqdn(\⏏%sqdn, $mref);
Perl 数组和散列与 Raku 的散列之间存在巨大差异。我也倾向于这些……另一个问题:
$ ./manage-web-site.raku -gen
===SORRY!=== Error while compiling PicFuncs.pm6 (PicFuncs)
Unsupported use of ->(), ->{} or ->[] as postfix dereferencer; in Perl 6 please use .(), .[] or .{} to deref, or whitespace to delimit a pointy block
at PicFuncs.pm6 (PicFuncs):137
------> my $fname = $mref->{⏏$c}{file};
这个问题来自于签名和将参数传递给调用方的子对象的差异,如前面在简单示例中所讨论的。我将仔细研究签名,看看我们是否可以帮忙。我首先要看一下之前看过的驱动程序中的调用路径:
build_montage(%CL::mates, $genS);
我们看到两个参数:哈希和表观标量。我们使用参考将第 1 部分中的内容从其原始 Perl 语法更改为 Raku。因此,我们目前在主叫方很好。返回到被叫子,其中前几行如下所示(注释被删除):
sub build_montage is export {
my $mref = shift @_; # \%CL::mates
my $cs = shift @_;
我们将其更改为:
sub build_montage(%mates, $cs) is export {
并进行其他必要的更改…经过大量更改之后,我们开始遇到缺少的例程:
$ ./manage-web-site.raku -gen
===SORRY!=== Error while compiling PicFuncs.pm6 (PicFuncs)
Undeclared routines:
convert_single_pic_to_eps used at line 159
insert_logo used at lines 287, 297
insert_pictures used at line 279
这看起来像是个不错的停留地点。就像我之前说的,我们可能需要转换几乎整个项目,或者至少转换驱动脚本使用的那些模块以及它们使用的所有其他模块。我检查了第 4 阶段的分支,并做了更多的内务处理和整理工作,但我又将移植的工作留给了另一天。
70.4. 总结
在这两篇文章中,您已经看到了一种简化将 Perl 代码移植到 Raku 的方法,并且我希望它们可以帮助那些考虑迁移到 Raku 的人看到可以以较小的步骤迭代地完成它,而不用花费大量的时间。对我而言,结果包括以下代码:
-
更具视觉吸引力(更清洁,更整洁)
-
易于维护
-
更容易看到需要改进的地方
如果您有兴趣查看其余的转换,请在 IRC 频道 #raku 上执行.ask tbrowder,然后我将继续插入我们一直在使用的存储库(它也将对我有所帮助!)。快乐乐天
我❤️❤️Raku! 😊
Christmas 圣诞快乐🎅和🎅新年快乐🎉所有人,并可能✝“上帝保佑我们,每一个人!”✝[Ref。1]
70.5. 附录
70.5.1. 笔记
-
我不确定在循环中更改 topic 变量的值是否会使 when 块继续按预期工作。幸运的是。
-
在 Raku 中使用各种模块导出选项时,存在一些细微的(对我而言)问题。此处文档中的当前讨论(请参阅五个 Notes 的有序列表)并未详尽地描述导出选项的所有可能组合,因此,当我(使用这些注释的人)写信时,我看到一条错误消息我认为这些新的条件组合具有误导性。因此,我首先怀疑是 Rakudo 错误,但现在怀疑它是 LTA(少于真棒)错误消息。请密切关注 Rakudo 问题#3341,以寻求解决方案。
70.5.2. 参考文献
圣诞颂歌,查尔斯·狄更斯(1812-1870)的短篇小说,查尔斯·狄更斯(Charles Dickens)(1812-1870 年),维多利亚州著名作家,作品很多,包括 Pickwick Papers,Oliver Twist,David Copperfield,Bleak House,Great Expectations 和两个城市的故事。
71. 第二十一天 - 搜索红色礼物
精灵雪花石膏雪花球正在寻找礼物,招募给他在北极的秘密圣诞老人上绘制的人。 他很荣幸能画圣诞老人! 给每个人都送礼物的人该怎么办? 因此,他在互联网上搜索一些他知道圣诞老人想要的关键字:
-
自动信件阅读器
-
耐穿靴子
-
红色雪橇配件
-
不会在风中飞扬的红色帽子
-
红色外套
-
红色
等一下!Red 会有:api<2> 吗?雪花石膏雪球已经阅读了有关 Raku 的 ORM。 但是,似乎新的:api<2> 版本将其带入了一个新的高度。
而已! 我将给圣诞老人一个 Red:api<2> PoC 作为礼物! 我知道他一直在与 Raku 一起玩,并且我认为将 NiceList 模型上所有 SQL 字符串集合更改为一组精心制作的 ORM 类会很棒。
在阅读文档时,Snowball 得知创建第一个模型非常容易:
use Red:api<2>;
unit model Child;
has UInt $!id is id;
has Str $.name is column;
has Str $.country is column;
他开始使用 Red:api<2> 并创建一个新模型,该模型代表具有 3 列(ID,名称和国家/地区)的表子级。 就这么简单。
Alabaster 现在可以仅连接到数据库,创建表,然后开始插入子代:
use Red:api<2>;
red-defaults default => database "SQLite";
Child.^create-table: :unless-exists;
Child.^create: :name<Fernanda>, :country<England> ;
Child.^create: :name<Sophia>, :country<England> ;
Child.^create: :name<Dudu>, :country<Scotland>;
Child.^create: :name<Rafinha>, :country<Scotland>;
Child.^create: :name<Maricota>, :country<Brazil> ;
Child.^create: :name<Lulu>, :country<Brazil> ;
并列出所有创建的孩子:
.say for Child.^all.sort: *.name;
这样就可以运行这个查询:
SELECT
child.id, child.name, child.country
FROM
child
ORDER BY
child.name
它打印出:
Child.new(name => "Dudu", country => "Scotland")
Child.new(name => "Fernanda", country => "England")
Child.new(name => "Lulu", country => "Brazil")
Child.new(name => "Maricota", country => "Brazil")
Child.new(name => "Rafinha", country => "Scotland")
Child.new(name => "Sophia", country => "England")
如果需要的话,圣诞老人可以按国家对孩子进行分类。
my %by-country := Child.^all.classify: *.country;
并发现哪些国家有儿童登记。
say %by-country.keys;
那会运行:
SELECT
DISTINCT(child.country) as "data_1"
FROM
child
And that would return:
(England Scotland Brazil)
If he needs to get all children from England:
.say for %by-country<England>;
这会运行:
SELECT
child.id, child.name, child.country
FROM
child
WHERE
child.country = ?
-- BIND: ["England"]
这会返回:
Child.new(name => "Fernanda", country => "England")
Child.new(name => "Sophia", country => "England")
很好用! 那如何存储礼物呢?有没有办法把孩子要求的东西按年份储存起来?
# Gift.pm6
use Red:api<2>;
unit model Gift;
has UInt $!id is serial;
has Str $.name is column{ :unique };
has @.asked-by-year is relationship( *.gift-id, :model<ChildAskedOnYear> );
method child-asked-on-year(UInt $year = Date.today.year) {
@!asked-by-year.grep(*.year == $year)
}
method asked-by(UInt $year) {
self.child-asked-on-year(|($_ with $year)).map: *.child
}
# Child.pm6
use Red:api<2>;
unit model Child;
has UInt $!id is id;
has Str $.name is column;
has Str $.country is column;
has @.asked-by-year is relationship( *.child-id, :model<ChildAskedOnYear> );
method asked(UInt $year = Date.today.year) {
@!asked-by-year.grep: *.year == $year
}
# ChildAskedOnYear.pm6
use Red:api<2>;
unit model ChildAskedOnYear;
has UInt $!id is serial;
has UInt $.year is column = Date.today.year;
has UInt $!child-id is referencing(*.id, :model<Child>);
has UInt $!gift-id is referencing(*.id, :model<Gift>);
has $.child is relationship( *.child-id, :model<Child> );
has $.gift is relationship( *.gift-id, :model<Gift> );
雪花石膏雪花球认为,他可以获得所需的所有信息。 创建新礼物很容易!
for <doll ball car pokemon> -> $name {
Gift.^create: :$name;
}
搜索怎么样? 雪花石膏雪球写道:
.say for Gift.^all
并返回所有礼物。 但是,如果我们只想要以“ll”结尾的礼物怎么办?
.say for Gift.^all.grep: *.name.ends-with: "ll"
这将运行如下查询:
SELECT
gift.id, gift.name
FROM
gift
WHERE
gift.name like '%ll'
Snowball 想知道是否有可能找到孩子的要求:
.say for Child.^find(:name<Fernanda>).asked.map: *.gift
这会运行:
SELECT
child_asked_on_year_gift.id, child_asked_on_year_gift.name
FROM
child_asked_on_year
LEFT JOIN gift as child_asked_on_year_gift ON child_asked_on_year.gift_id = child_asked_on_year_gift.id
WHERE
child_asked_on_year.child_id = ? AND child_asked_on_year.year = 2019
如果我们想知道去年的礼物怎么办?
.say for Child.^find(:name<Fernanda>).asked(2018).map: *.gift
SELECT
child_asked_on_year_gift.id, child_asked_on_year_gift.name
FROM
child_asked_on_year
LEFT JOIN gift as child_asked_on_year_gift ON child_asked_on_year.gift_id = child_asked_on_year_gift.id
WHERE
child_asked_on_year.child_id = ? AND child_asked_on_year.year = '2018'
我们如何知道每个礼物应制作多少?
say ChildAskedOnYear.^all.map(*.gift.name).Bag
SELECT
child_asked_on_year_gift.name as "data_1", COUNT('*') as "data_2"
FROM
child_asked_on_year
LEFT JOIN gift as child_asked_on_year_gift ON child_asked_on_year.gift_id = child_asked_on_year_gift.id
GROUP BY
child_asked_on_year_gift.name
Red 的文档位于 https://fco.github.io/Red/ 上,此处使用的一些示例可以在 https://github.com/FCO/Red/blob/join/examples/xmas/index.p6 上找到。
72. 第二十二天 - 当然是课程
您可能没有听说过我的 Perl 6 课程,也不会怪我。
这是一段漫长的旅程。
它始于 2018 年 9 月,我在奥斯陆的北欧 Perl 研讨会上以 45 + 45 分钟的时间介绍了 Perl 6。第一次在会议上进行演讲…
我得到了积极的反馈,并且想知道是否可以在此基础上发展。全面课程的想法已经成熟,我首先开始编写随附的教科书。
这本书和课程旨在作为 Raku 的入门指南,供已经熟悉编程的人使用。
我向里加的 PerlCon 2019 推荐了课程,他们接受了。组织者要求我推广它,结果是我的 Perl 6 博客 Perl 6 Musings(在绝对完美的地址 "perl6.eu" 上)。
不幸的是,这没有解决,并且由于参加人数太少而取消了该课程。
Beginning Raku, 1. Edition (December 2019) Pages: 370 File size: ~ 11 Mbyte (pdf)
我免费赠送这本书的第一版。我保留印刷和出售书籍的权利。您可以自由分发 pdf 文件或进行打印。您也可以自由分发印刷版,但可能不会因此获得报酬。
随意使用代码示例,无论它们是您自己的作品,还是您自己工作的灵感。可以很好地添加属性,但这不是必需的。
我会很感激您的反馈,请在此书的 Github 页面上发送,或通过电子邮件发送至该书中显示的地址。如果收到需要更新的反馈,我打算出版该书的修订版。
72.1. 未完待续
下一门课程《高级 Raku》继续到此结束。由于本书仅供参考,我选择为这两个课程制作一本名为《 Raku Explained》的组合书。下半部分("高级 Raku" 部分)尚未完成,但是我已经发布了初步的目录和索引,以便您可以看到整本书的内容。
Raku Explained, v0.01 (December 2019) Pages: 30 (Table of Contents & Index only) File size: ~ 5 Mbyte (pdf)
我也对第二部分中的主题反馈感兴趣(第 18 – 32 章)。
73. 第二十三天 – A Raku Advent Helper
73.1. 介紹
从 2016 年开始,我每年都会写 Raku Advent 的文章,要想把源文件可靠地转化到 Raku Advent WordPress (WP) 网站上,不被 WP 改掉一些东西,对我来说一直是个难题。然后,菜单很糟糕,编辑也很麻烦。在这篇文章中,我希望展示如何改善这种情况。
73.2. 背景介绍
不幸的是,今年 Raku 的大改名发生在年底,没有太多的时间来准备一个新的 Raku Advent 网站。因此,主题的选择和调整,对实际 Raku Advent 网站链接的混乱,以及不幸的文章取消,都是通常比较顺利的过程中的皱纹。然而,我们计划在 2020 年降临季之前改进网站,也会更早地得到承诺,更早地得到具体的草案。同时,在这篇匆匆准备的替身文章中,我会详细介绍一下我们希望提供的一些帮助。
73.3. 文章创作
从第一次使用 WordPress 开始,我就发现了这些让我在使用 WP 时很尴尬的地方。
-
编辑窗口太小
-
编辑时明显的滞后时间,增加了手指的失误。
-
在网站时区(TZ)中输入所需的时间表时间,但看到它显示在你的本地 TZ 中(很少或没有提示你看到的是什么)[见注 1]。
-
混乱的编辑上下文和小组件的位置
我相信我对 WP 的大部分问题都是自己造成的,但我确实更喜欢更像 TeX 的文档制作工作流程。
73.4. 前几年
往年我都是用 Gihub 风格的 markdown 创建文章,手动(在我的 Emacs 编辑器的协助下)将每段文字转换为单行、长行,然后发布在 Github 的 gist 中。之后,我使用由 @zoffix[注 2]开发并由 @SimonProctor 修改的工具 p6advent-md2html.p6,从 Github 的 markdown 表示中提取 html,从而得到一个很好的代码块高亮。最后,将该 html 复制并粘贴到 WP 中,并设置好发布计划。这个原始过程在这里有概述。
-
用 Github 喜欢的 Markdown 文本写文章
-
将每个段落折叠成一个长行
-
将源码粘贴到 Github 的 gist 中。
-
使用现有的 Advent 工具将生成的 html 表示提取到自己的本地计算机上。
-
复制 html 并粘贴到所选 WP 编辑器的空白 html 视图中。
-
查看成品并检查错误
如果发现错误:
-
在 WP 编辑器中改正错误
或
-
纠正源头的错误
-
再次重复步骤 2 至 6
这个过程在第一次通过的时候还算不错,但是当不可避免的发现错误的时候,就只能选择在 WP 上手动编辑,或者修改源码,再走一遍整个过程! 这两种选择都不是很好。
73.5. 2019 年的降临目标:减少 WP 的痛苦
今年我决定帮助我的文章创作情况,所以我创建了一个 Raku 工具来消除一些问题。从今天起,它就可以对外开放了。
$ zef install RakuAdvent::WordPress
该模块提供了 make-wp-input
这个工具。所以我今年的新步骤:
-
用原始 html 写文章
-
运行我的新 Advent 工具(
make-wp-input
),将源码格式化为可接受的 html。 -
复制 html 并粘贴到所选 WP 编辑器的空白 html 视图中。
-
查看成品并检查和纠正错误。
如果发现错误:
-
在 WP 编辑器中改正错误
或者,最好是:
-
纠正源头的错误
-
再次重复步骤 2 至 4
因此,在我的新流程中,我已经省去了几个步骤,但我仍然必须将我干净的 WP 源码复制/粘贴到 WordPress 编辑器中-但这是因为我没有利用 WordPress 和 Github 的可用 API 来做这些繁琐的工作。
然而,尽管有其他的限制,这个新的工具在简化文章中使用实时代码示例方面起到了巨大的帮助。在我写文章的沙盒中,我在自己的文件中创建代码示例,然后在文章中加入
<!-- insert file-name lang -->
行在需要的位置。这样一来,我就可以编辑实战代码,并进行测试,以确保它的工作,但不必使用该代码更改源码。
73.6. 给 Raku 作者的提示
以下是我在为 Raku Advent 开发文章时发现的一些有用的想法。
-
请看这个 视频中对 WP 日程安排的帮助。
-
利用您默认的个人 WP 网站进行实验。
-
查看打印出来的 PDF 成品,并检查和纠正错误(这对我来说是一个非常好的方式,让我在闲暇时与男朋友"在火炉旁"喝着蛋奶酒,看我的文章😊;关于优秀的
html-to-pdf
转换器,见参考文献 1)。
73.7. 愿望清单
以下是我希望在新的一年里用 make-wp-input
做的一些事情。
-
将 html 源码转换为 Github 风格的 markdown
-
处理 html 表格
-
允许在源 html 中的段落通过文本上下的空行或在文本之前的行上加上一个结束标签或在文本之后的行上加上一个开始标签来识别。
-
使用 Github 的 API[参考文献 2]来操作 markdown 源到 Github gist,并从中获取 html 结果。
-
使用 WordPress 的 API[参考文献 3]来操作自己在 WordPress 上的文章(包括设置或更新发布日程)。
而以下是我希望社区能够为乐降临网站做的一些事情(或者至少是一致的)。
-
改进主题和代码风格。
-
使用旧的 Perl 6 Advent 主题?
-
在年初注册文章时段,并在 Raku Advent 网站上以预定的形式开始文章(至少是骨架形式)。
73.8. 总结
今年 Raku 社区的变化很大,尤其是改名之后,还没有全部完成。一个仍需努力的领域是改进新的 Raku Advent 网站。我们也希望能更方便地创建和发布 Raku Advent 的文章,以及获得更多的参与。请注意,2020 年的日程表已经开放,所以你可以提前拿到你的档期,避免最后一分钟的购物,呃,Raku Advent!
我❤️Raku!😊
祝大家圣诞快乐,新年快乐,祝福大家!
73.9. 附录
73.9.1. 笔记
-
我已经向 WordPress 提交了一个问题,以帮助在日程表中识别时区。
-
前面带@的名字是 IRC 或 Github 的别名。
73.9.2. 参考文献
-
wkhtmltopdf (作为 Debian 软件包提供)
73.9.3. 使用的 Raku 模块
RakuAdvent::WordPress (v.0.0.2)
74. 第二十四天 -《Raku 之鬼灵精》第二部:稳住阵脚
在 2017 年,鬼灵精毁了圣诞节,展示了一些顽皮的事情,你可以用 Raku 的功能。不幸的是,虽然他的心脏在那一年增长了三个尺寸,但不止一个鬼灵精! 这个鬼灵精今年会做一些额外的淘气事,从 JavaScript 社区中获得一些灵感。
你可能听说过 JSFuck,它是一个允许你只使用 [
, ]
, (
, )
, +
, 和 !
等字符编写任何 JavaScript 代码的工具。这是只有在 JavaScript 这样的语言中才能实现的事情,对吧?这并不完全正确! 为了证明这一点,让我们把它移植到 Raku 中。由于不能使用完全相同的字符集来实现,我们的限制是在翻译的代码中只能使用非字母数字的 ASCII 字符,并且不能使用字符串的字元。
74.1. 生成元语
我们需要做的第一件事是找到一种方法来生成一些元语。我们感兴趣的来自 JavaScript 的是布尔函数、数字和字符串;任何其他类型的元语都可以通过其他方式来表示。这些主要是通过对空数组的类型转换来生成的,这在 Raku 中也恰好可以做到。
True 和 False 可以在 Raku 中使用 "!" 前缀操作符生成,类似于在 JavaScript 中的生成方式:
say ![]; # OUTPUT: True
say !![]: # OUTPUT: False
结合 +
前缀操作符,我们可以生成任何整数,这在 JavaScript 中也是如此。
say +[]; # OUTPUT: 0
say +![]; # OUTPUT: 1
say +![] + +![]; # OUTPUT: 2
在 JavaScript 中,+
也恰好用于串联字符串。当与两个空数组一起使用时,+
会将两个数组胁迫为字符串,并将它们连接起来,从而得到一个空字符串。在 Raku 中,+
并没有这样的行为,所以我们需要使用 ~
操作符来代替。
say (~[]).perl; # OUTPUT: ""
不过不为空的字符串怎么办?在 JavaScript 中,字符串是可迭代的,它允许在字符串化空数组以外的值时使用某些字符。但在 Raku 中却不是这样的。是时候开始发挥创意了。
字符串位元运算符允许您在字符串中的代码点上执行与数字相同的位元运算。使用 ~^
infix 操作符,我们可以生成一个给定 0 和 0 的空字符。
say ord +[] ~^ +[]; # OUTPUT: 0
不过我们不能仅用 ~+
, ~|
, 和 ~^
运算符很容易地生成我们需要的字符。有一种方法可以利用那个空字符来实现,但是我们首先需要一个小写的字母。如果我们使用一个 regex,我们可以从 "True" 中抓取字母 "e"。
say ~(![] ~~ /...(.)/)[+[]]; # OUTPUT: e
利用这两个字符的无限序列,我们可以生成 ASCII 中的大部分字符。
my Str:D @chars = (+[] ~^ +[]...~(![] ~~ /...(.)/)[+[]]...*);
say @chars[65..90]; # OUTPUT: (A B C D E F G H I J K L M N O P Q R S T U V W X Y Z)
say @chars[97..122]; # OUTPUT: (a b c d e f g h i j k l m n o p q r s t u v w x y z)
现在我们可以生成字符串 "&chr" 中的字符,下一步之后我们就可以生成任何 Unicode 字符串。
74.2. 计算代码
大多数可以生成的 JavaScript 代码都依赖于 Function 构造函数才能工作。使用它,你可以在运行时任意生成一个函数。据我所知,在 Raku 中不使用 &EVAL
是不可能生成这样的代码的。不过,如果要使用它,我们需要解决一个问题。
我们可以用 &EVAL
来使用字符串字面值就可以了。
say EVAL "'Hello, world!'"; # OUTPUT: Hello, world!
但如果我们试图用它来使用一个在编译时未知的值,我们就会得到一个关于我们正在做的事情的安全影响的异常警告,告诉我们使用 MONKEY-SEE-NO-EVAL 指令。
say EVAL my $ = "'Hello, world!'"; # Throws X::SecurityPolicy::Eval
在我们的情况下,这不是很好!我们不能在没有字母数字字符的情况下设置这个指令。是时候调皮一下了。如果我们尝试使用间接符号查找的 &EVAL
会发生什么?
say ::('&EVAL')(my $ = "'Hello world!'"); # OUTPUT: Hello, world!
很好!我们可以使用间接的符号查找来生成任何 Unicode 码点的字符串。除此以外,使用间接符号查找,我们还可以调用 &chr
例程为任何 Unicode 码点生成一个字符串。结合起来,这允许我们翻译任何有效的 Raku 代码。
74.3. 稳住阵脚
我们已经准备好开始为 JSFuck 的移植写代码了。这将是一个简单的脚本,它将一些 Raku 代码作为输入并输出其翻译。所有使用的子程序(除了 &MAIN
)都是 纯的。现在, 让我们给这个移植起个好听点的名字, 而不是显而易见的选择, 叫它 Hold Your Horses。
我们的第一个子程序将是 &from-uint
, 它将翻译数字。我们可以直接将 1 加到 0 中, 直到得到我们要找的数字, 但这将为较大的代码点产生大量的代码。我们可以缩短产生的代码的一个方法是,如果我们将数字表示为质数的乘积。这可以通过将大于 5 的质数表示为质数乘积的和来进一步缩短。
use Prime::Factor;
sub from-uint(UInt:D $x, Int:D $remainder = 0 --> Str:D) is pure {
proto sub translate(UInt:D --> Str:D) is pure {*}
multi sub translate(0 --> '+[]') { }
multi sub translate(1 --> '+![]') { }
multi sub translate(UInt:D $x --> Str:D) {
join ' + ', '+![]' xx $x
}
if $x <= 5 {
my Str:D $translation = $x.&translate;
$translation ~= ' + ' ~ $remainder.&from-uint if $remainder;
$translation
} elsif $x.is-prime {
from-uint $x - 1, $remainder + 1
} else {
my Str:D $translation = $x.&prime-factors».&from-uint.fmt: '(%s)', ' * ';
$translation ~= ' + ' ~ $remainder.&from-uint if $remainder;
$translation
}
}
现在我们可以实现 &from-str
,它将解析用户输入的代码。这需要将给定代码中的每一个代码点映射到一个 Hold Your Horses 编号,如果在其范围内,可以通过查找前面字符序列中的一个字符来完成,否则可以调用 &chr
。由于我们每次看到被它包含的字符时都会用到这个序列,所以会被我们的下一个子程序存储在 $_ 中。由于翻译一个代码点可能是相当密集的,所以让我们使用实验性的 is cached 特性与我们的帮助子程序一起处理这个问题,以避免对任何给定的代码点进行一次以上的翻译。
use experimental :cached;
sub from-str(Str:D $code --> Str:D) is pure {
my Int:D constant LIMIT = 'z'.ord.succ;
proto sub translate(UInt:D --> Str:D) is pure is cached {*}
multi sub translate(UInt:D $codepoint where 0..^LIMIT --> Str:D) {
sprintf '.[%s]', $codepoint.&from-uint
}
multi sub translate(UInt:D $codepoint where LIMIT..* --> Str:D) {
sprintf '::(%s)(%s)',
'&chr'.ords».&translate.join(' ~ '),
$codepoint.&from-uint
}
sprintf '::(%s)(%s)',
'&EVAL'.ords».&translate.join(' ~ '),
$code.ords».&translate.join(' ~ ')
}
现在我们可以实现 &hold-your-horses
,它将处理用户输入的全部代码翻译。这需要做的就是在调用 &from-str
之前将前面的序列存储在 $_ 中。
sub hold-your-horses(Str:D $code --> Str:D) is pure {
Qc:to/TRANSLATION/.chomp
$_ := (+[] ~^ +[]...~(![] ~~ /...(.)/)[+[]]...*);
{$code.&from-str};
TRANSLATION
}
加入 &MAIN
后,我们的脚本就完成了。
use v6.d;
use experimental :cached;
use Prime::Factor;
unit sub MAIN(Str:D $code) {
say hold-your-horses $code
}
sub from-uint(UInt:D $x, Int:D $remainder = 0 --> Str:D) is pure {
proto sub translate(UInt:D --> Str:D) is pure {*}
multi sub translate(0 --> '+[]') { }
multi sub translate(1 --> '+![]') { }
multi sub translate(UInt:D $x --> Str:D) {
join ' + ', '+![]' xx $x
}
if $x <= 5 {
my Str:D $translation = $x.&translate;
$translation ~= ' + ' ~ $remainder.&from-uint if $remainder;
$translation
} elsif $x.is-prime {
from-uint $x - 1, $remainder + 1
} else {
my Str:D $translation = $x.&prime-factors».&from-uint.fmt: '(%s)', ' * ';
$translation ~= ' + ' ~ $remainder.&from-uint if $remainder;
$translation
}
}
sub from-str(Str:D $code --> Str:D) is pure {
my Int:D constant LIMIT = 'z'.ord.succ;
proto sub translate(UInt:D --> Str:D) is pure is cached {*}
multi sub translate(UInt:D $codepoint where 0..^LIMIT --> Str:D) {
sprintf '.[%s]', $codepoint.&from-uint
}
multi sub translate(UInt:D $codepoint where LIMIT..* --> Str:D) {
sprintf '::(%s)(%s)',
'&chr'.ords».&translate.join(' ~ '),
$codepoint.&from-uint
}
sprintf '::(%s)(%s)',
'&EVAL'.ords».&translate.join(' ~ '),
$code.ords».&translate.join(' ~ ')
}
sub hold-your-horses(Str:D $code --> Str:D) is pure {
Qc:to/TRANSLATION/.chomp
$_ := (+[] ~^ +[]...~(![] ~~ /...(.)/)[+[]]...*);
{$code.&from-str};
TRANSLATION
}
现在,这真的有用吗?为了简明扼要,我们可以说,如果说 "Hello, world! 👋"可以翻译并运行。
bastille% raku hold-your-horses.raku 'say "Hello, world! 👋"' > hello-world.raku
bastille% raku hello-world.raku
Hello, world! 👋
完美!这是脚本的输出。
$_ := (+[] ~^ +[]...~(![] ~~ /...(.)/)[+[]]...*);
::(.[(+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![] + +![]) * ((+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![]) + +![])] ~ .[(+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) + +![]) + +![])] ~ .[(+![] + +![] + +![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])])(.[(+![] + +![] + +![] + +![] + +![]) * ((+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) + +![]] ~ .[((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![])] ~ .[(+![] + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![]] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![])] ~ .[(+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![])] ~ .[((+![] + +![]) * (+![] + +![] + +![]) + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) + +![])] ~ .[(+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) * (+![] + +![] + +![] + +![] + +![])] ~ .[(+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![])] ~ ::(.[(+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![] + +![]) * (+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![]) * (+![] + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) + +![])] ~ .[(+![] + +![]) * (+![] + +![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])])((+![] + +![] + +![] + +![] + +![]) * (+![] + +![] + +![] + +![] + +![]) * ((+![] + +![]) * ((+![] + +![]) * ((+![] + +![]) * (+![] + +![] + +![] + +![] + +![]) + +![]) + +![]) + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) * (+![] + +![] + +![]) + +![])) ~ .[(+![] + +![]) * ((+![] + +![]) * (+![] + +![]) * (+![] + +![]) * (+![] + +![]) + +![])]);
74.4. 收尾工作
Raku 是一门相当大的语言,它有广泛的功能。这些功能可以用一些非常有趣的方式结合起来。在这里,利用类型强制、字符串位运算符、正则表达式、序列、间接符号查找和 &EVAL
的漏洞的组合,我们今年又可以做一个淘气的精灵,从 JavaScript 中移植 JSFuck。如果你很想说有些东西是不可能用 Raku 写的,别急,只要有合适的工具,很有可能做到。
75. 第一天 - 为什么 Raku 是 Advent Code 的理想语言?
现在是12月, 到了我最喜欢的两个科技界传统的时候了:Raku Advent Calendar 和 Advent of Code。这两个节日传统有相当多的共同点 - 它们都从12月1日一直持续到圣诞节, 并且每天都会在活动期间发布新的东西。具体来说, Raku Advent Calendar 会发布一篇关于 Raku 编程语言的新博文, 而 Advent of Code 则会发布一个新的编程挑战 - 可以用任何语言解决。
(在这篇文章中, 我将把"代码降临"称为 "AoC" - 不要与美国政治家 AOC 混淆, 据我所知, 他并没有用 Raku 编程)。
对我来说, Raku 和 AoC 是科技降临季的巧克力和花生酱:每一个都是单独的好东西, 但它们结合起来会更好。如果你的唯一目标是解决 AoC 挑战, 那么 Raku 是一种很好的语言;另一方面, 如果你的唯一目标是学习 Raku, 那么解决 AoC 挑战是一种很好的方式。这篇文章将解释 Raku 和 AoC 是如何如此契合的, 然后提供一些资源来帮助我们大家开始解决 AoC 挑战。
75.1. 什么是 Raku?(你为什么要关心?)
由于 Raku 是一种相对较新的编程语言, 至少你们中的一些人可能对它不熟悉, 也不知道为什么它值得学习。Raku 是众所周知的很难用一句话来描述的, 但这是我的观点。
Raku 是一种简洁的, 富有表现力的, 积极的多范式语言, 具有强推断类型, 内置的并发性, 丰富的元编程, 以及一流的字符串处理和模式匹配。
这在实践中意味着, 我发现自己越来越多地接触到 Raku。当遇到几乎所有的问题时, 我一直认为 Raku 是一种能让我以最清晰、最快速、最优雅的方式解决它的语言。(唯一的例外是, 如果解决我的问题需要编译语言的原始速度或低资源使用。但即使在这种相对罕见的情况下, 我可能会用 Rust 编写代码中性能关键的部分, 而用 Raku 编写其他部分, 以利用这两种语言的良好配合。)
75.2. 为什么 Raku 非常适合解决 AoC 挑战?
我相信, Raku 很适合解决许多不同的问题, 但它绝对是代码降临挑战的绝佳选择。为了解释原因, 我将介绍一下我们希望从理想的 AoC 语言中得到什么。然后, 我会介绍一个去年第一个 AoC 挑战的 Raku 解决方案, 并将其与我们的理想进行比较。(Spoiler: they’re really similar!)
当思考理想的 AoC 语言时, 我想到的 AoC 的第一个特点是, 它是一系列小的, 基本上自成一体的谜题, 而不是一个大型项目。这说明理想的语言应该是简明扼要、低样板的。当建立一个大型项目时, 处理许多行的样本是很烦人的, 但可以忍受, 但如果在 AoC 的每一天都重复这样做, 那就糟糕多了。而且, 由于我们将分享我们的代码, 保持简洁将有助于其他人看到我们的逻辑, 而不会被内务细节所干扰。
我们可以说得更具体一些:AoC 挑战赛通常会提供文本输入, 并寻找文本输出;它们通常也会提供几个测试用例, 有助于制作一个可行的解决方案。因此, 除了在一般情况下简明扼要之外, 我们理想的语言还应该为脚本和测试提供低样板的解决方案。
也许 AoC 最显著(当然也是最有趣的!)的特点是它的社区驱动和教育性:数以千计的程序员都在或多或少地解决相同的难题, 然后将他们的解决方案发布到 Advent of Code 子 reddit 上。从阅读其他解决方案 - 包括不同语言的解决方案 - 中学到的东西和你自己解决难题一样多, 甚至更多, 这是非常常见的。这意味着我们理想中的语言应该是可读的、优雅的, 即使对于没有太多语言经验的人来说也是如此。
当然, 虽然能够写出让不熟悉语言的人也能欣赏的代码是件好事, 但我们的大部分教学和学习将来自于将我们的解决方案与同一种语言的其他解决方案进行比较。毕竟, 同一语言中的不同解决方案往往会显示出做出不同权衡的方法, 并能帮助我们扩展编程工具集。或者至少在我们的语言中不同的解决方案是, 嗯, 不同的情况下会发生这种情况:如果我们的语言将每个人都推向一个单一的, 明显的解决方案, 那么这种学习的空间就会少很多。所以我们理想的语言应该提供不止一种解决任何特定挑战的方法。
我相信我还可以继续说下去, 但似乎我们已经有了一个很好的清单。总而言之, 我们正在寻找一种语言, 这种语言要简洁、低模板, 特别是对于脚本和测试来说, 并且允许为每个挑战提供多种不同的可读性和优雅的解决方案。(请注意, 这个列表是关于寻找最好的语言来学习和享受 AoC。如果你的目标是在 AoC 排行榜上名列前茅, 那么 Raku 出色的字符串处理功能仍然会让它成为一个很好的选择。但是, 从现实的角度来看, 如果这是你的目标, 你应该选择任何你最熟悉的语言。)
现在我们知道了在理想的语言中我们会想要什么, 让我们来看看去年第一个挑战的 Raku 解决方案。
75.3. AoC 2019 Raku 第1天
你可以阅读完整的问题描述来了解所有的细节, 但简短的版本是, 这个挑战要求我们根据飞船的质量, 对发射飞船所需的燃料进行一些不同的计算。具体来说, 在第一部分中, 我们被告知:
要找出一个模块所需的燃料, 取其质量, 除以3, 四舍五入, 再减去2。
因为 Raku 有整数除法运算符, 所以这几乎是小菜一碟。
sub fuel($mass) { +$mass div 3 - 2 }
第二部分要求我们进行类似的计算, 但这次要考虑到我们要添加的燃料所增加的额外质量。
就像取模一样, 燃料本身也需要燃料 - 取它的质量, 除以3, 四舍五入, 再减去2。然而, 该燃料也需要燃料, 该燃料也需要燃料, 以此类推。
为了解决这部分问题, 我们可以使用第一部分的 fuel 函数来计算我们需要多少燃料, 然后将我们的初始结果加到我们需要的新质量的燃料量上。
multi total-fuel($mass) { fuel($mass).&{$_ + .&total-fuel} }
第二部分还告诉我们:
任何需要负燃料的质量都应该被视为需要零燃料。
同样, Raku 提供了一个强大的功能, 使这个问题变得简单:在这种情况下, 强大的功能是 Raku 针对函数签名中的运行时值进行模式匹配的能力。
multi total-fuel($mass where fuel($mass) ≤ 0) { 0 }
有了这三行代码, 我们基本上就解决了这个挑战。当然, 我们希望不仅能够对单个数字进行计算, 而且能够对我们的整个输入进行计算(在这个挑战中, 输入的形式是一个文本文件, 每行有不同的数字)。我们还希望我们的脚本是可执行的, 并能公开一个带有用户友好描述和—帮助文本的 CLI。这个 CLI 应该允许用户选择我们的脚本是解决挑战的第一部分还是第二部分。
幸运的是, Raku 让我们只需要三行额外的代码和一个 shebang 注释就可以添加所有这些细节。到目前为止, 我们的完整解决方案如下:
unit sub MAIN( #= Solve the 2019 AoC day 01 puzzle
Bool :$p2 #={ Solve p2 instead of p1 (the default)} );
sub fuel($mass) { +$mass div 3 - 2 }
multi total-fuel($mass) { fuel($mass).&{$_ + .&total-fuel} }
multi total-fuel($mass where fuel($mass) ≤ 0) { 0 }
say lines.map($p2 ?? &total-fuel !! &fuel).sum;
(第2行和第3行中的注释产生了 - 帮助文档。)
这个挑战还提供了7个测试用例, 我们也许应该把它们包括进去。当在一个较大的 Raku 项目中工作时, 标准的方法是将我们的测试分成一个单独的文件。但我们要使用脚本, 如果放弃单文件的简单性, 那就太可惜了。所以, 我们不使用单独的文件, 而是使用我之前在博客中介绍过的一种技术, 使用 Raku 的条件编译将我们的测试包含在一个文件中, 而不需要在每次运行脚本时执行它们。
使用这种技术, 我们得到的最终代码(包括测试)是这样的:
unit sub MAIN( #= Solve the 2019 AoC day 01 puzzle
Bool :$p2 #={ Solve p2 instead of p1 (the default)} );
sub fuel($mass) { +$mass div 3 - 2 }
multi total-fuel($mass) { fuel($mass).&{$_ + .&total-fuel} }
multi total-fuel($mass where fuel($mass) ≤ 0) { 0 }
say lines.map($p2 ?? &total-fuel !! &fuel).sum;
# Tests (run with `raku --doc -c $FILE`)
DOC CHECK { use Test;
subtest 'Part 1', { fuel(12).&is: 2;
fuel(14).&is: 2;
fuel(1_969).&is: 654;
fuel(100_756).&is: 33_583; }
subtest 'Part 2', { total-fuel(14).&is: 2;
total-fuel(1_969).&is: 966;
total-fuel(100_756).&is: 50_346; }
}
75.4. 将 Raku 与理想的 AoC 语言进行比较
那么, Raku 在我们之前提出的指标上表现如何呢?好吧, 就简洁和低模板而言, 这段代码似乎做得还不错。的确, 它远没有达到最大程度的简洁;它比我去年在 Dyalog APL#Dyalog_APL 中解决这个挑战时想出的方案要长7倍左右。) 不过, 程序的代码只有6行, 7个测试用例的代码只有9行, 我还是会给它打分, 认为它是高度简洁的。作为对比, 一个强势的 Rust 解法在方案上用了27行代码, 测试上用了17行代码。
而在支持脚本和测试的同时, 又消除了样板代码, Raku 的代码就非常理想了。开头的 shebang 一行是样板代码, 但对于用任何语言创建一个独立的脚本都是必不可少的。除了这一行, 唯一可以说是模板的部分是使用测试行和 unit sub MAIN 行。这些行给我们提供了一个经过测试的脚本和一个完整的文档化的 CLI - 这是 Rust 和上面链接的 APL 例子都没有提供的。考虑到我们得到的回报, 我准备给这个解决方案打满分, 因为它支持脚本和测试而不依赖模板。
判断 Raku 解决方案的可读性和优雅程度当然涉及到更多的主观性。而且, 可以说, 懂 Raku 的人最没有资格判断代码对于不懂 Raku 语言的程序员的可读性。也就是说, 在我看来, 这个解决方案的可读性足够强, 一个非 Raku 程序员也可以遵循它—同时仍然利用了足够多的 Raku 的聪明特性, 使其变得优雅。一个非 Raku 程序员可能不知道 div
操作符, 但可以从上下文和合理的假设中找出它, 它必须做一些不同于/操作符的事情。fuel($mass).&{ $_ + total-fuel($_) }
这一行也可能会让一个非 Raku 程序员慢下来—特别是如果他们没有遇到 Perl 中的 $_ 主题变量的话。但是, 我相信他们也能很快地从上下文中找出答案。而且, 一旦他们明白了, 我打赌他们会很欣赏重用(而不是重新计算)fuel($mass)
值的优雅, 而不需要创建和命名一个临时值。
最后一个对非 Raku 程序员来说可能会很陌生的片段是 $mass where fuel($mass) ≤ 0
部分。但是, 在我看来(诚然有失偏颇!), 这个片段既清晰又立即优雅 - 它如此完美地抓住了"只有当 $mass 小于或等于0时才调用这个函数"的意图, 这是大多数其他编程语言无法用函数签名表达的。我知道优雅是看人下菜碟, 但这个片段和整个脚本都非常符合我的定义。
我们的最后一个标准 - Raku 是否提供了不止一种解决这个挑战的方法—从单一的解决方案中本质上是无法判断的。但在 Raku 中, 不管它指的是什么, 总是有不止一种方法。一个相对较小的改变就是向 Raku 的类型系统靠拢。因为 Raku 有如此强大的类型推断能力, 所以可以像写动态类型语言一样来写。但是 Raku 有一个强大的类型系统, 允许你约束你的函数接受的类型。
例如, 在当前的代码中, 我们有函数 sub fuel($mass) { +$mass div 3 - 2 }
。这个函数可以接受任何类型的增量 (随后用 +
操作符将其转换为数字类型), 但不保证其返回类型。这种灵活性是很好的 - 当我们从 stdin 传递输入时, 它可以让我们用 Str 来调用 fuel, 而当我们递归调用它时, 则用 Int 来调用。如果我们想要更多的类型安全(在一个较长的程序中, 我们很可能会这样做), 我们可以像这样确定参数和返回类型。
sub fuel(Int $mass --> Int) { $mass div 3 - 2 }
由于这个函数只接受 Int, 所以我们不需要在函数体里面加上 +
, 但是我们需要在调用 fuel
之前对输入进行解析。我们可以用很多方法来实现;我可能会在我们的 lines
管道中添加一个 .map(+*)
方法调用。
另一种选择是使用强制类型约束, 在安全性和灵活性之间取得一些平衡。这将导致 sub fuel(Int() $mass -→ Int)
的签名。Int()
部分约束函数接受一个可以被转换为 Int 的类型, 并自动执行转换。使用这个签名, 我们可以避免使用 +$mass
或 .map(+*)
。
添加类型安全只是我们可以用不同的方式来解决这个问题的一种方法。我们也可以使用一个带有计算终点的序列 (例如, $_, total-fuel($_) ...^ * ≤ 0) 来替代我们目前使用的 where 块。或者我们可以使用 reduce(无论是作为方法调用还是使用 +_(reduction_metaoperators[reduce 元操作符])来代替单独的 map
和 sum
步骤。或者我们可以通过使用 gather 和 take 来进一步远离 map
。简而言之, 即使是像这样简单的问题, 我们也有大量的方法可以使用 Raku - 这还没有提到我们可以使用 Raku 强大的、内置的并发支持来并行化我们的解决方案的方法, 如果出于某种原因, 我们想要提升性能的话。无论你个人如何解决 AoC 挑战, 我敢打赌, 你可以通过看看人们在 Raku 中提出的各种其他解决方案来学习一些东西。
我知道, 我们能从一个 AoC 挑战中得出多少结论是有限的 - 尤其是到12月, 挑战的难度一般都会增加, 这意味着第一天的挑战并不那么有代表性。我知道, 并不是每个人都会认同我关于什么是适合 AoC 的语言的定义。尽管如此, 我希望我们对 AoC 挑战赛的详细研究已经足以说服你, Raku 是一种很有前途的语言, 可以用于 AoC。如果你至少已经了解一些 Raku 语言, 我希望你会同意, 通过 AoC 挑战赛来学习是一个很好的选择(既可以提高 Raku 语言的水平, 也可以分享你的知识)。如果你还不了解 Raku, 我希望你至少能把 AoC 作为一个学习的机会。
如果是这样, 我有一些好消息。AoC 是学习 Raku 的绝佳途径。
75.5. 为什么 AoC 是学习 Raku 的好方法?
要想知道为什么 AoC 是学习 Raku 的好方法, 我想从另一个问题开始:学习 Raku 有多难?
对于这个问题, 有两种不同的观点。从一个角度来看, Raku 非常难学 - 几乎和 Scheme 容易学的方式完全一样。著名的 Scheme 几乎没有语法;你可以很容易地坐下来, 一次就能学会 Scheme 的全部语法。(当然, 掌握这门语言需要的时间要长得多)。
Raku 几乎占据了相反的极端:它大量使用语法。我不认为有什么特别原则性的方法来衡量不同编程语言的"大小", 但《Y分钟内学会X》系列指南可能会提供一个大致的近似值。对比《Learn X in Y minutes》(其中X=CHICKEN Scheme)指南和《Learn X in Y minutes》(其中X=Raku)指南, 我看到 Raku 的指南大约有7倍的行数;我可以理解为什么有人会得出Y的值在这两个公式中很不一样的结论。
而且语法并不是 Raku 是一种大型语言的唯一方式。我之前形容 Raku 是"积极的多范式";一个有影响力的评论称 Raku 是"多范式, 也许是全范式"。不管你怎么说, 很明显, 你可以用很多不同的方式来写 Raku。你可以用程序化的方式来写, 如果你愿意的话, 可以用显式 for
循环来完成。它还对函数式编程提供了一流的支持(包括对函数类型、抽象数据类型以及其他高级函数式特性的支持, 而这些特性是多范式语言经常从其函数式工具箱中省略的)。你也可以编写纯面向对象的 Raku, 事实上, 该语言本身就是从 OO 的角度构建的。Raku 还从数组编程、约束编程和数据流编程中借鉴了许多思想, 它支持并发编程、通用编程和极强的自省和元编程。
真正掌握 Raku 并不仅仅意味着了解它的所有广泛的语法, 它甚至不意味着熟悉 Raku 支持的许多不同的范式。要想完全精通 Raku, 需要有判断力来决定哪种范式最适合今天的问题, 并能自如地正确应用该范式。从这个角度来看, Raku 是一门极难学的语言 - 虽然这样做的回报让它值得。
但还有另一种观点说, Raku 其实很容易学。
诚然, 如果你试图在没有上下文的情况下一次性学会所有 Raku 的语法是很难的。但你不应该这样做, 就像一个说英语的人试图通过坐下来用德语词典来学习德语一样。学习一门语言的所有语法似乎是一件合理的事情, 唯一的原因是有些编程语言是如此的微不足道, 以至于一次全部学习的方法不会立即造成灾难性的后果。但这并不意味着这种方法就是一个好主意。
相反, 学习 Raku 的方法是学习足够的知识, 然后开始使用它, 并在你走的时候学习更多的知识。从这个角度来看, Raku 的大量语法与语言的难易程度无关 - 你并不是想一次性学会它, 所以你开始学习的那点语法是一半还是只有 20% 都无所谓。而 Raku 的多范式特性实际上使 Raku 更容易倾斜而不是更难:因为 Raku 支持这么多不同的范式, 你可以从你最初最舒服的任何 Raku 子集开始, 然后从那里扩展出来。
现在, 你可能已经明白了为什么我们花了这么长的时间来讨论学习 Raku 是容易还是困难:如果你试图以学习 Scheme 的方式来学习 Raku, 你将会陷入艰难的困境。但是, 如果你采取更零碎的方法, 学习 Raku 就容易多了。而这种零敲碎打的方法也完美地映射到了通过《代码降临》学习 Raku 上。
具体来说, 要想用零敲碎打的方法取得成功, 你需要在一系列小项目上下功夫, 即使你只知道语言的一角, 每个项目也是可以管理的。它们需要是风险相对较低的项目 - 因为你还不知道解决一个问题的所有不同方法, 所以有时候你很有可能会实现一个次优的解决方案。而且, 最重要的是, 你需要在一个能让你看到其他方法的上下文中完成它们。从面向对象的 Raku 子集开始, 并从你的舒适区之外逐渐添加技术, 是学习 Raku 的好方法, 但只有当你真正做到 "逐渐添加技术"的部分, 它才会有效。如果你陷入了困境, 你就学不到那么多东西, 而看到其他人使用不同的技术来更优雅地解决同样的问题是避免那种困境的最好方法。
简而言之, 如果你想在 Raku 中取得更好的成绩, 最好的方法莫过于通过尽可能多的"代码降临"挑战, 将你的解决方案与其他 AoC 解决方案(在 Raku 和其他语言中)进行比较和对比, 并批判性地思考各种方法的优缺点。
75.6. 让我们一起努力吧
为了让我们更容易找到彼此的" Raku 代码降临"解决方案, 我创建了一个可以容纳所有解决方案的 GitHub 仓库:codeections/advent-of-raku-2020。我将有意保持这个仓库的最小化 - 如果 Raku 不是那么善于避免模板, 我会添加一个设置 AoC 解决方案的模板。但是, 考虑到 Raku 所需要的模板很少, 我计划将 Repo 简单地作为我们解决方案的主机和相关资源的链接。
如果你希望你的解决方案被包含在内, 请提交一个 PR, 添加一个 $your-name
文件夹, 其中包含你的解决方案(细节在 README 中)。当然, 将你的解决方案发布到 Advent of Raku repo 并不妨碍你将其发布到其他你想发布的地方, 无论是你自己的网站、Raku 子 reddit 还是主 AoC 子 reddit。
请随时将你的解决方案添加到回帖中, 甚至 - 特别是!如果你不确定你的解决方案是否符合你的要求, 也可以将其添加到回帖中。- 如果你不确定你是否有时间完成所有的 AoC 挑战, 或者你是 Raku 的新手, 不确定你是否会在第一天就坚持使用这门语言, 请将你的解决方案添加到仓库中。毕竟, 所有这一切的目的都是为了共同学习, 我们可以通过将具有尽可能广泛的观点和背景的人聚集在一起来达到最好的目的。
我还注册了一个 Raku 社区的私人排行榜, 你可以通过登录, 按照这个链接, 然后输入代码 407451-52d64a27 进入(如果有必要, 我可以修改这个代码, 更安全地分发, 但我预计不会有任何问题)。尽管有"排行榜"这个名字, 但我认为这并不是一个竞争性的排名, 更多的是一种跟踪谁在参与的方式 - 就我个人而言, 我并不打算急于完成挑战, 也不打算在任何基于时间的指标上提高我的分数(事实上, 考虑到我的时区和我通常的日程安排, 我怀疑我是否会在大多数谜题发布几个小时后才开始)。
我很期待看到你的解决方案;我相信我们可以从彼此身上学到很多东西。我也期待着讨论我们所采取的不同方法, 不管是在 GitHub 问题上, 还是在 Raku 或 AoC 子 reddits上, 或是在 #raku IRC 频道上(我会尽量关注所有这些频道)。祝大家好运, 愿我们都有一个 -Ofun Advent。
76. 第二天 - Perl 已死, Perl 和 Raku 长存
Perl已死, 是一个完全错误的备忘录。Perl 并没有死, 只是对一些程序员来说, 它已经死了。复杂的 regexes?Sigils?有不止一种方法(TMTOWTDI)?有时候, 当程序员在野外遇到 Perl 时, 他们的反应是恐惧。他们喊道:"WTF!?"。但恐惧不一定是 Perl 的杀手。如果你花时间去看透 Perl 的不完美, 走过学习曲线, 就会有丰富的回报。Perl 是一种不完美的语言, 但它的实用性和表现力, 30多年来, 它帮助程序员完成了工作。
当 Larry Wall 在 Perl 社区的帮助下设计Raku 时, 他修正了 Perl 的大部分不完美, 并将 Perl 的 DNA 加倍发扬光大。Perl 重视实用主义、表现力和鞭策, Raku 也是如此。当你可以用 twigils($!, %!, @!等)获得双倍的乐趣时, 为什么要停留在 sigils($@%)上呢?
然而, 对于一些程序员来说, 仅仅是看到 twigil 就会产生恐惧。就像 Perl 一样, Raku 的表达能力是一把双刃剑 - 有可能让其他程序员止步不前。Raku 程序员的 "DWIM"(do what I mean)可能会成为另一个程序员的 "WAT!?"
76.1. 流畅的无畏代码
我们为两个受众编写程序:人类和计算机。应该首先考虑的是人类。如果我不能在一周内理解自己的代码, 我的同事们还有什么希望?幸运的是, 我们可以帮助自己, 也可以帮助对方, 在 Raku 学习曲线上有一个平稳的旅程。
学习 Raku 永远不会枯燥无味, 但你上一次在学习 Raku 时遇到坎坷是什么时候?这是你帮助自己和他人的机会。你可以在你的代码中留下一条感同身受的评论, 贡献一些文档, 写一篇博文, 做一次演讲, 在 StackOverflow 上提问和回答问题等等。
编程的乐趣就是在完成事情的同时, 为自己和对方找到流。不需要 Raku 摇滚明星。
76.2. 在学习曲线上冲浪
也许你还没有开始学习 Raku?现在是将 Raku 添加到你的工具箱的最佳时机。以下是我发现的一些有用的学习资源, 我希望你也能做到。
首先, 有一个精辟简洁的 Raku 介绍, 其中包括如何安装 Raku 的说明。Raku 解释器本身也非常有用。如果你的 Raku 程序包含错误, Raku 通常会建议修复它们的方法。
对于来自其他语言的程序员, RosettaCode 展示了不同语言的编码解决方案。准备好被 Raku 运算符的表现力所惊喜吧。Raku 的表现力通常会导致更少的代码行数(LLOC)。
你的第一个 Raku 程序的一个想法是翻译一个你熟悉的不同语言的程序。这里有一些从其他语言翻译到 Raku 的有用指南。Perl、Python、Ruby、Haskell 和 Javascript。
关于 Raku 的书籍清单越来越多, 还有一个选择流程图。这里是一个选择:
搜索与 Raku 相关的问题通常会指向 docs.raku.org 的官方文档或 StackOverflow 上的 Raku 答案。
当你想了解更多关于特定子主题的信息, 或者深入了解 Raku 更深层次的设计理念时, 请查看 Jonathan Worthington 的清晰演示和解释。
最后, 如果你在某些事情上被卡住了, 或者只是想分享学习 Raku 的乐趣, freenode 上的 #raku IRC 频道是友好和欢迎的。
Perl 和 Raku 都是任何程序员工具箱中的有用工具:不需要害怕, 只要记得帮助代码流动就可以了。
Perl 和 Raku 万岁。
圣诞快乐!
77. 第三天 - 使用 Raku 进行文化编程
不同的编程语言社区有不同的文化。有些更务实, 有些更理想化。有的非常强调让代码对于任何加入现有项目的人来说都是彻底可读可懂的, 有的则更喜欢写出清晰而深入的文档。
Raku, 继承了 Perl 最好的部分之一, 有一个写出很棒文档的社区。
77.1. 什么是文化编程?
文化编程是对文档的另一种看法。在 Literate Programming 中, 我们不以代码为核心元素, 而是围绕它来编写文档, 我们编写的文档包含了我们程序的基本部分。通过这种方式, 我们将代码融入到我们的自然语言中, 使设计的基本思想变得清晰。通过这种方式, 我们也自然而然地开始明确思考我们的程序需要执行哪些操作来完成我们设定的任务。
文化编程不能和文档生成混为一谈, 它不仅仅是拥有一个文档齐全的程序, 文档华丽地围绕着代码, 并嵌入到代码中, 而是拥有一个关于程序的文档, 而程序本身也被嵌入其中。正如 Lewis Carroll 在《爱丽丝梦游仙境》中让疯帽子说的那样。
你可以说"我看到了吃的东西"和"我看到什么我就吃什么"是一回事!
77.2. 什么是 Org-mode?
Org-mode 是 Emacs Lisp 解释器(可以说是文本编辑器)中的一种文本编辑模式。当启用 Org 模式时, 人们可以使用一种特殊的标记类型编辑文本, 称为 Org。与 Markdown 类似, 它支持基本的文本功能, 如标题, 基本的文本属性, 如粗体或斜体文本, 嵌入式超链接, 嵌入式代码块等。
然而, 由于 Emacs 平台的通用性, Org 模式已经扩展了一个被称为 "Babel" 的设施, 它允许作者执行源代码块。各种语言都是开箱即用的, 不幸的是, 尽管 Raku 已经很老了, 但仍然是一种相当年轻的语言, 不在其中。为了解决这个问题, 我写了一个名为 ob-raku 的软件包, 它扩展了 Babel 的功能, 将 Raku 添加到支持的语言中。
77.3. 使用 Ob-Raku
要在 Emacs 中使用 ob-raku, 你需要下载这个软件包(简单的从 GitHub 上克隆或者下载其中一个发布的 tar 包), 并将其添加到 Emacs 的加载路径中。这可以通过环境变量来完成:
export EMACSLOADPATH="/path/to/ob-raku:$EMACSLOADPATH"
或者你可以更改你配置文件中的路径 (可能是 ~/.emacs 或 ~/.emacs.d/init.el):
(add-to-list 'load-path "/path/to/ob-raku")
添加路径后, 您可以将 Raku 添加到 Babel 可以使用的语言列表中, 就像这样。
(org-babel-do-load-languages
'org-babel-load-languages
'((c . t)
(emacs-lisp . t)
; ...
(raku . t)))
随着 Raku 加入到列表中, 你可以像这样创建一个 Raku 代码块。
#+BEGIN_SRC raku
"!dlroW ,olleH".flip
#+END_SRC
您可以通过将光标放在代码块中或代码块的末尾, 使用菜单栏或输入 C-c C-c(这是 Emacs 的 Ctrl+c Ctrl+c 的符号)来运算代码块。运算的结果将被添加到代码块之后。
77.4. 连接代码块
不幸的是, 由于缺乏对 ob-raku 的会话支持, 这意味着在一个代码块中声明的函数不能在其他代码块中使用。尽管如此, 运算一个代码块的结果可以作为另一个代码块的参数, 将它们串联起来。因此, 当编辑一个 .org
文件时, 我们可以写以下内容。
让我们用 Raku 做一个列表。
#+NAME: nested-list
#+BEGIN_SRC raku
my @a = (("A", "B"), ("C", "D"))
#+END_SRC
我们也可以包括其他语言的结果。
#+NAME: elisp-list
#+BEGIN_SRC emacs-lisp :results vector
'(1 2)
#+END_SRC
而现在我们将使用我们刚才定义的列表。
#+NAME: crosser
#+HEADER: :var a=nested-list() b=elisp-list()
#+BEGIN_SRC raku
my @crossed = @a X @b
#+END_SRC
当你运算 crosser 块时, Babel 将运算嵌套列表和 elisp-list 块, 这两个块都返回列表, 并将它们分配给 @a 和 @b 变量。由此产生的交叉列表将在 crosser 块的下方返回。
77.5. 编辑说明
这篇文章是在2020年2月底起草的, 在 Daniel Sockwell 写出关于用 Pod6 进行扫盲编程的优秀文章之前。这篇文章不会提到用 Pod6 来做 Literate Programming, 而是谈谈我写的一个 Emacs 包, 在 Emacs Org-mode 中使用 Raku。
写这篇文章的时候, Emacs 中的 Raku REPL 交互还在进行中, 多亏了 Matías Linares, 它后来得到了巩固。
77.6. 结论和感谢
虽然 ob-raku 的功能仍然有限, 但任何已经使用它来编写文档的人现在都可以实现 Raku 代码, 从而显示出用我们可爱的通用语言表达的可重复的数据, 一旦会话支持落地, Org-mode 内置的纠缠设施应该会给社区带来真正的 Literate Programming。
如果没有 raku-mode 的努力, ob-raku 的创建是不可能的, 所以我想感谢每一个参与该项目的人的辛勤工作。虽然我们的社区规模不大, 但有一些充满激情的人在努力改进我们每天使用的工具, 这让我们的工作更加愉快, 从而帮助我们继续前进, 使我们的小天堂变得最好。
78. 第四天 - 使用 Raku grammar 解析 Clojure 命名空间形式
有一天, 我开始想, 是否有可能解析 Clojure 命名空间形式, 并生成现实世界中 Clojure 项目中使用的各种命名空间的依赖关系图。虽然这是最初的动机, 但我最终还是掉进了 Raku grammar 的兔子洞, 并且在学习如何使用它们的过程中度过了一段愉快的时光。我很高兴你能和我一起重温那段旅程。
78.1. 背景介绍
78.1.1. Grammar
非正式地说, grammar 可以被认为是一套描述语言的规则。有了这些规则, 人们就可以有意义地解析(理解或解构成语法成分)一段文字。事实证明, 这在计算机中是一项常见的任务。我们需要经常将程序从一种语言翻译成另一种语言。这就是编译器的工作。在能够翻译之前, 编译器需要知道根据语言的 grammar, 原始程序是否甚至是有效的。
虽然我们已经在理论上解释了什么是 grammar, 但 Raku grammar 帮助我们将抽象的 grammar 建模为一个编程结构(grammar
关键字及其相邻的助手), 我们可以使用它来执行解析任务。理解这种区别是很重要的。
一级 grammar 被认为是 Raku 的革命性功能之一。通常情况下, 你会发现 grammar 是作为一个库或一个独立的工具, 但 Raku 已经全盘接受了它, 并拥有一个强大的 grammar 实现, 使大多数解析任务变得轻而易举。
78.1.2. Clojure
Clojure 是一种现代 lisp, 恰好是我在 $DAYJOB
中使用的语言。在大多数 Clojure 文件的顶部, 都有一个导入各种内部和外部命名空间的形式。这样我们就可以把我们的项目整齐地组织成许多独立的文件, 而不必把它们都放到一个大文件中。我们很快就会设计一个能够解析这些命名空间形式的 grammar。
78.1.3. Grammar::Tracer
Grammar::Tracer 有助于找出你的 grammar 不匹配的地方, 对于调试来说是非常宝贵的。在运行这些代码示例之前, 请确保你安装了 Grammar::Tracer。
78.2. 让我们开始做饭吧
78.2.1. 一个微不足道的例子
Clojure 命名空间是一个 Lisp 形式, 正如预期的那样, 它以开括号开始, 以闭括号结束。让我们为一个最简单的 Clojure 形式 - 空列表写一个 grammar。
()
grammar EmptyLispForm {
token TOP { <lparen> <rparen> }
token lparen { '(' }
token rparen { ')' }
}
这是我们能写的最简单的 grammar 之一。解析总是从 TOP 标记开始, 并从那里递归到各个组件标记。我们只有两个这样的标记 lparen
和 rparen
, 用来表示左圆括号和右圆括号。玩玩 trivial.raku, 看看我们如何解析这个。
78.2.2. 热身: 用 Raku grammar 来回答一个老生常谈的面试问题
问题:写一个检查小括号是否平衡的程序。
比如说:
() ;; balanced
(()) ;; balanced
(()(())) ;; balanced
(()(((()))) ;; unbalanced
grammar BalancedParens {
token TOP { <balanced-paren> }
token balanced-paren { <lparen> <balanced-paren>* <rparen> }
token lparen { '(' }
token rparen { ')' }
}
很有可能我把这段话写得太啰嗦了, 但还是只有六行相当易读的文字。
现在, 在时间压力下, 哪种可能性更大?编码堆栈并处理边缘情况, 还是使用 grammar 与 Grammar::Tracer 的威力来快速破解一个声明式解决方案。
事实证明, 我们刚刚解决了编写现实世界 grammar 中最棘手的一个方面, 那就是处理嵌套结构。作为程序员, 我们知道当我们看到嵌套结构时, 我们知道我们将不得不以某种形式处理递归。
你可以在控制台上玩玩 balanced-parens.raku 程序, 观察一下 grammar 是如何解析的。
注意:事实证明, 还有更好的方法来解析嵌套结构, 但现在这样就可以了。
78.2.3. 解析我们的第一个命名空间形式
让我们试着解析一下这个简单的命名空间声明。
;; 1 3
| |
;; (ns my-own.fancy.namespace)
| |
;; 2 4
虽然这很简单, 但在处理更复杂的命名空间形式时, 它将是一个重要的构件。让我们把它分解成四个组件词条。我们可以看到 open 和 close parens, 我们看到 ns, 命名空间 my-own.fancy.namespace, 最后是 close paren。就是这样! 让我们用一个 grammar 来处理这些单独的部分。
grammar SimpleNs {
token TOP { <simple-ns> }
# 1 2 3 4
# | | | |
token simple-ns { <lparen> <ns-keyword> <ns-name> <rparen> }
token ns-keyword { 'ns' }
token ns-name { <.ns-name-component>+ % '.' }
token ns-name-component { ( <.alnum> | '-' )+ }
token lparen { '(' }
token rparen { ')' }
}
在这边我们可以看到, 我们已经将其翻译成了简单的 Raku grammar。你可以说定义 simple-ns 根本不需要, 我们可以直接把它放在 top 中, 但不管怎样。
我们需要在这里处理一下 regexes 的问题。在各种风味的 regexes 中, +
通常意味着一个或多个。|
的含义与你所期望的略有不同, 但你可以查看 regex 文档来了解 |
和 ||
之间的所有细节区别。通俗地说, 我们是说一个命名空间组件, 即两个点之间的东西, 是由一个或多个字母字符或连字符组成的。现在, 如果有一个规则说一个命名空间必须以字母字符开始, 而不是以数字开始, 那么 grammar 会变得更复杂一些, 但这是一个教育学的例子, 所以我们不会太迂腐。
我希望眼尖的读者能指出一些问题。
<.alnum> 是在哪里定义的, 为什么前面有一个点?
alnum 是预定义的。它前面有一个 .
的原因是, 我们对捕捉每个字母不感兴趣, 因为它的层次太低。我们感兴趣的是捕获一个顶层的标记, 比如 ns-name, 而不是每个单独的字符。通过在不同的标记中添加和删除一个点来玩玩代码示例, 看看 Grammar::Tracer 的输出有什么不同。
% 是什么意思?
这是一个非常有用的方便方法, 用来描述在一堆其他事物之间穿插的模式。例如, 我们可以有一个像 foo.bar.baz 这样的命名空间, 或者我们可以有一个 192.168.0.1 的 IPv4 地址, 其中整数用点来分隔。
token ns-name { <.ns-name-component>+ % '.' }
这意味着 ns-name 是由至少一个 ns-name-component’s(用 +
表示)和 .
(用 % 表示)分开。
好了, 我想这应该可以了吧?让我们看看当我们运行这段代码的时候会发生什么吧
不, 这并不奏效。正如 Grammar::Tracer 告诉我们的那样, 我们没有考虑到 ns
后面的空间。在传统的编译器理论中, 有一个标记化的过程, 在这个过程中, 一些轻量级的 regexes 被用来将程序分离成它的组件词素, 并在解析器接管之前丢弃所有无关的空间。然而, 在这里我们不会这样做, 我们将在 grammar 本身中处理这个问题。现在, 可以争论这是否是一个好的决定, 但这是另一个讨论。让我们增加一些留白的空间, 看看会发生什么。在构建完整的 Clojure NS 语法时, 我让自己陷入了这样的境地:在我认为我们应该允许可选择的空白字符的地方, 我自由地使用 <.ws>*
表示零或更多的空白字符, 就像你在现实世界的程序中所期望的那样。
token simple-ns { <lparen> <ns-keyword> <.ws> <ns-name> <rparen> }
有了这个小小的补充, 我们现在就可以解析简单的命名空间形式了。你可以玩玩 simple-ns.raku。
78.2.4. 让我们让生活变得更加困难吧
好了, 现在我们已经掌握了诀窍, 那么我们来看一个更现实一点的命名空间形式。
(ns my-amazing.module.core
(:require [another-library.json.module :as json]
[yet-another.http.library :as http]))
这是一个现实的命名空间形式, 我们将通过增加对 :require
形式的支持来解析, 在这个形式下, 其他库被导入并被赋予一个简短的昵称。
这能做到吗?当然可以。
grammar RealisticNs {
token TOP { <realistic-ns> }
token realistic-ns { <lparen>
<ns-keyword> <.ws> <ns-name> <.ws>
<require-form>
<rparen> }
token ns-keyword { 'ns' }
token ns-name { <.ns-name-component>+ % '.' }
token ns-name-component { ( <.alnum> | '-' )+ }
token require-form { <lparen>
<require-keyword> <ws>? <ns-imports>
<rparen> }
token require-keyword { ':require' }
token ns-imports { <ns-import>+ % <.ws> }
token ns-import { <lsquare>
<ns-name> <.ws> ':as' <.ws> <ns-nickname>
<rsquare> }
token ns-nickname { <.alnum>+ }
token lsquare { '[' }
token rsquare { ']' }
token lparen { '(' }
token rparen { ')' }
}
目前还没有太可怕的东西。我们可以看看 grammar 是如何发展的。在顶层, 在 realistic-ns 中, 我们增加了一个额外的 token, 叫做 <require-form>
, 我们稍后将细节具体化。我们可以通过这种方式来管理复杂性, 这样我们就有能力根据需要放大和缩小细节。
78.2.5. 使用解析后的数据
现在我们已经能够解析数据了, 我们需要利用我们解析的数据。这就是 Actions 的作用。
当我们进行 RealisticNs.parse(…) 时, 会返回一个与 RealisticNs grammar 对应的 Match 对象。虽然我们可以通过查询该对象来获取我们所需要的数据片段, 但使用 Action 来建立我们真正感兴趣的数据就不那么麻烦了。
给定, 一个命名空间, 我们要提取出来。
Namespace name Imported namespaces Imported namespace nicknames
简单的原则是, 对于我们感兴趣的 token, 我们创建一个同名的 Action 方法。当 Match 对象正在建立时, 当 token 被匹配时, token 的 Action 方法就会运行。Grammar 是自上而下解析的, 但数据是由 Action 方法以自下而上的方式建立起来的。
class RealisticNsActions {
has $!ns-name;
has $!imported-namespaces = SetHash.new;
has $!ns-nicknames = SetHash.new;
method TOP($/) {
make {
ns-name => $!ns-name,
ns-imports => $!imported-namespaces,
ns-nicknames => $!ns-nicknames
}
}
method ns-name($/) {
$!ns-name = $/.Str;
}
method imported-ns-name($/) {
$!imported-namespaces{$/.Str}++;
}
method ns-nickname($/) {
$!ns-nicknames{$/.Str}++;
}
}
在这里, 我们创建了一个 RealisticNsActions 类, 并创建了一些方法, 在这些方法中, 我们想对与之相关的数据做一些事情。我们根本不需要触及语法定义(这让它保持了干净)。我们需要做的唯一额外的事情, 就是在解析时, 我们需要像这样传递 Actions 对象, 它指示 Raku 在看到这些标记时运行这些标记 Action 方法。
sub MAIN() {
my $s = RealisticNs.parse(slurp("realistic.clj"), actions => RealisticNsActions.new);
say $s.made;
}
在一个 Actions 类中, TOP 方法可以用来生成最终的 payload, 我们可以通过调用 make 方法来访问。关于 make 和 made 的更多信息, 官方的 Raku grammar 教程已经说得很清楚了。简而言之, 使用 make 创建的任意有效载荷可以通过使用 made 来访问。
当我们运行这个程序时, 我们看到的是这样的。
{ns-imports => SetHash(another-library.json.module
yet-another.http.library),
ns-name => my-amazing.module.core,
ns-nicknames => SetHash(http json)}
正如预期的那样, 我们可以看到解析后的数据被创建在一个漂亮的 HashMap 中, 但是如果知道 yet-another.http.library 在命名空间中的昵称是 http, 那不是更好吗?这就是我们在刚写的 Actions 类中遇到的一个设计问题。我们在一个较低的层次上构建了有效载荷。
我们需要更多的结构来获得我们想要的 namespace → namespace-nickname 映射。快速浏览一下语法告诉我们, 我们可以在 ns-import 级别找到它, 因为它的子标记是 import-ns-name 和 ns-nickname, 而这两个是我们想要的数据片段。
我们为 ns-import 写一个 Action 方法吧!
class RealisticNsActions {
has $!ns-name;
has $!imported-namespaces = SetHash.new;
has %!ns-nicknames;
method TOP($/) {
make {
ns-name => $!ns-name,
ns-imports => $!imported-namespaces,
ns-nicknames => %!ns-nicknames
}
}
method ns-name($/) {
$!ns-name = $/.Str;
}
method ns-import($match) {
#say $match;
my $imported-ns-name = $match<imported-ns-name>.Str;
my $ns-nickname = $match<ns-nickname>.Str;
$!imported-namespaces{$imported-ns-name}++;
%!ns-nicknames{$imported-ns-name} = $ns-nickname;
}
}
这就导致了输出:
{ns-imports => SetHash(another-library.json.module
yet-another.http.library),
ns-name => my-amazing.module.core,
ns-nicknames => {another-library.json.module => json,
yet-another.http.library => http}}
现在我们已经掌握了所有我们需要的信息。你可以玩玩 realistic-ns-with-actions.raku。
78.2.6. 在 Action 类中匹配对象
成功解析的结果是一个 Match 对象。这包含了整个层级化的解析结构。
无论我们在 ns-import 方法中做了什么, 我们都可以在更高的层次上做, 但该层次的 Match 对象需要更多的查询。这是因为该方法会将其"视图"接收到完整的 Match 对象中, 即 TOP 方法会有整个 Match 对象, 而 ns-import 方法会有一个更受限制的视图, 利用这个视图我们可以很容易地提取出 import-ns-name 和 ns-nickname。这可能不会马上有意义, 但在与 Match 打交道一段时间后, 你会发现在尽可能低的层次上提取出有用的信息是多么的有意义, 这样可以更容易地进行查询。在最上层, 为了提取出 ns-nickname, 我们不得不查询 realistic-ns → require-form → ns-imports → ns-import → ns-nickname, 这至少可以说是很麻烦的, 而且因为有多个这样的情况, 所以会有一个 ns-import 的数组。
为了直观的看到每个 Action 方法中发生了什么, 可以适当的添加一个 say $match
或者 say $/
, 看看那个层次的结构是什么。
78.2.7. 开发风格
Raku 目前还没有 Lisp 程序员习惯的全功能 REPL 环境。这只是一些需要解决的问题。
Raku REPL 可以用来快速测试简短的单行代码片段, 主要是作为最简单的安全检查, 但对于任何比这更复杂的东西, 它都会变得笨重。
为了解决这个问题, 我使用了一种 TDD 方法, 我将一个真实世界的 Clojure 项目, 并在所有的 Clojure 文件上运行(快速发展的)语法。每一个"正确"的变化, 解析的文件数量就会增加, 而每一个"错误"的变化, 解析的文件数量就会减少。
78.2.8. 接下来的步骤
有了我们目前已经解决的问题, 要解析现实世界中的 Clojure 命名空间声明也不是什么难事。例如, 我们已经添加了对使用 :require 形式导入 Clojure 命名空间的支持。同样, 我们也可以添加对 :import 形式的支持, 我们使用该形式导入 Java 库。同样的迭代方法可以用来解析越来越复杂的代码。
最后的 Clojure NS grammar, 我已经能够用它来解析数百个 Clojure 文件。使用这个 grammar 来生成依赖图是一个留待日后再谈的故事。你可能会注意到, 我必须处理大量的 grammar 变化和可选的空白。我相信我们已经将实现的核心提取到了我们已经详细讨论过的易于理解的 grammar 中。
78.2.9. 注意事项(还有几个大的)
有可能当一个语法被不恰当地指定时, 程序就会挂掉。发生的情况类似于病态的回溯。这并不是只有 Raku grammar, 或者一般的 grammar 才会出现这种情况。一个写得不好的 regex 也会有同样的效果, 所以在把一个 regex/grammar 放到一个高危应用的热路径上之前, 必须意识到这种偶然性。有一些高调的死后案例讨论了写得不好的 regex 是如何让网络应用瘫痪的。
在处理 regex 时, 通常的建议是避免使用 .*(任何字符的任何数字), 而在与之匹配的内容上要有更多的限制, 这个建议对于 grammar 也是适用的。尽可能的限制性, 在不可避免的时候放松某些东西。在最终的代码中, 我能够解析上百个真实的 Clojure 命名空间声明, 但在每隔一段时间演化 grammar 的过程中, 我确实遇到了几次这种行为, 通过按照所述调整 grammar 来补救。能够可靠地修复它, 是很难的, 但时间久了, 你就能直观地做到这一点。
在生产系统中, 另一个需要警惕的是, 即使语法规范得很好, 一个恶意用户会不会制作一个触发这种病态行为的输入?只要有足够的动机, 任何事情都有可能发生, 所以所有的赌注都是不存在的。
Grammar 比正则表达式更强大(https://en.wikipedia.org/wiki/Chomsky_hierarchy)。传统的警告, 不要用 regexes 来解析它不够强大的东西, 在这里并不适用, 假设 grammar 写得很好, 但一个写得很好的 grammar 就像一个无 bug 的程序一样难以捉摸。我指的不是语言原始设计中的 grammar, 而是你为了解析某件事情而写的 grammar。如果不能正确处理角句, 不能充分处理错误, 你最终会得到一个脆弱的、令人沮丧的、难以调试的程序。
78.3. 结束语
现在我们已经到了尾声, 我希望这又是一篇漂浮在互联网上的文章, 它能帮助初出茅庐的 Rakoons(Rakoons 能飞吗?思考)了解 Raku grammar 如何成为一个强大的工具, 来解决任何可能遇到的解析任务。
78.4. 参考文献
-
Grammar 教程在帮助我掌握 Raku grammar 方面是非常宝贵的。这篇文章也可以看作是同样风格的另类教程。
-
Parsing: a timeline 是一篇很长的文章, 但完全值得一读。
78.5. 片尾字幕
-
多年来所有的 Raku 设计者和贡献者。
-
Jonathan Worthington, 感谢他创建了 Grammar::Tracer。
-
所有友好的 Rakoons (@JJ), 他们帮助审核了本文。
79. 第五天 - Raku 和 Pakku
79.1. 兴趣爱好
有一天, 我一觉醒来, 决定要把学习编程作为一种爱好。对于一个除了写过几个 Bash 脚本之外并不了解的人来说, 像函数式编程、面向对象编程甚至是类这个词本身就很神秘。
79.2. 迷茫
当开始做研究以寻找从哪里开始着手时, 很快我就迷失在网上大量的信息中, 但我还是想开始学习, 是时候选择一门编程语言了。
编程语言那么多, 决定学哪种语言对我来说是个难题, 主要是根据技术知识来比较语言是不可能的。
79.3. 一个天真的计划, 但是成功了!
我决定尽可能多地阅读不同语言的代码, 试图解决同一个问题, 并记录下哪些代码容易推理, 我喜欢什么, 不喜欢什么。罗塞塔代码让我很容易比较不同语言对同一问题的解决方案。
我开始看那里的代码, 顺着列表阅读不同语言的代码, 不关注语言名称, 只关注代码。所有的代码看起来都很好, 大多数代码在我看来都是这样或那样的相似。
在列表深处, 我注意到了一些乍一看很奇怪的代码。"那是什么!" 我问自己。盯着看了几秒钟, 再看第二眼, 代码看起来不奇怪了, 但现在看起来很奇怪! "这些冒号和箭头是干什么的!" 我又问自己。第三次看代码, 不知道为什么, 代码看起来很熟悉, 然后很漂亮, 很容易上手(即使不知道冒号和箭头是干什么的)。我很想知道那个语言的名字, 是 Raku! (其实当时是 Perl6 :-))
这种模式重复着不同问题的解决方法, 大多数语言在某一方面看起来都很相似, 只有 Raku 代码在我看来是很好很吸引人的。于是, 我决定学习 Raku, 并着手阅读《Y 分钟学会 Raku》和文档。
79.4. Pakku
79.4.1. Ofun
享受着目前在 Raku 中学到的东西, 我想写一个包管理器, 既好玩又好学。对于像我这样的编程新手来说, 编写一个包管理器是很有挑战性的, 但我还是开始了, 因为我知道这会很有趣。在一天结束的时候, Raku 是 - 好玩的。
79.4.2. 了解 Pakku
Raku 的软件包管理器, 设计时考虑到了简单性。
Camelia
我希望 Pakku 能像 Raku 的吉祥物 Camelia 一样, 简单、快速、轻盈、多彩。
连字符
对我来说, 运行命令就像和程序交流一样, 所以我希望发给程序的命令尽可能地接近人们用来互相交流的句子, 没有连字符, 没有双连字符……
例如, 与其这样写:
program delete --from=somewhere --recurse dirname
我不如这样写:
program delete from somewhere recurse dirname
并且程序应该负责识别命令、子命令、子命令的选项;还有 DWIM, 比如上面的 dirname
换成 recurse
, 程序应该把第一个 recurse
解析为选项, 最后一个解析为要删除的目录, 并覆盖其他边缘情况。
这些是 Pakku 命令的例子。
pakku add to site MyModule
pakku list repo site details
pakku pretty please dont remove from home MyModule
Pipeline
Pakku 命令的处理方式简单易懂, 就像流水线中的各个阶段。
让我们来看看 Pakku 是如何安装一个模块的。
阶段1
1- Pakku 收到要安装的模块规格(模块名称或本地路径) 2- 检查指定模块是否已经安装(满足)或需要强制安装 3- 尽量满足规范, 这意味着获取模块的元信息, 如果需要的话, 按照正确的顺序进行安装
@spec
==> map( -> $spec { Spec.new: $spec } )
==> grep( -> $spec { $force or not self.satisfied: :$spec } )
==> map( -> $spec { self.satisfy: :$spec } )
==> map( -> $dep {
my @dep = self.get-deps( $dep, :$deps );
@dep.append: $dep unless $deps ~~ <only>;
@dep;
} )
...
...
==> my @meta;
阶段2
1- 下载分发档案并解压 2- 准备好取回的分发目录, 由 CompUnit::Repository::Installation 进行安装
@meta
==> map( -> $meta {
my $prefix = $!fetcher.fetch: $meta.recman-src;
$meta.to-dist: :$prefix;
} )
==> my @dist;
阶段3
1- 如果需要的话, 建立分发 2- 测试分发, 如果需要 3- 添加分发
@dist
==> map( -> $dist {
$!builder.build: :$dist if $build;
$!tester.test: :$dist if $test;
$*repo.add: :$dist, :$force unless $!dont;
🦋 "ADD: 「$dist」" unless $!dont;
} );
你可能已经注意到, 我多次使用了 =⇒ feed 操作符(将左边的结果作为最后一个参数传递给下一个(右边)例程)。我使用它的原因是它很适合这个管道类比。
Pakku 输出
Pakku 日志有7层
-
0(silent)--没有任何输出。
-
1 (trace) 🤓 - 如果你想看所有的东西。
-
2 (debug) 🐞 - 调试一些问题。
-
3 (info ) 🦋 – Camelia delivers important things
-
4(warn)🔔 - 只有当一些警告发生时才会出现。
-
5 (error) ❌ - 当错误是你所关心的!
-
6 (fatal) 💀 - 你可能不喜欢在运行 Pakku 时看到这个, 我也不喜欢。
日志符号和信息的颜色可以在 pakku.cnf
文件中进行修改和自定义。
你可能会看到的日志信息示例及其意义。
🦋 PRC: 「 ... 」 → Start processing...
🐞 SPC: 「 ... 」 → Processing spec
🐞 MTA: 「 ... 」 → Processing meta
🤓 FTC: 「 ... 」 → Fetch URL
🐞 BLD: 「 ... 」 → Start building dist
🦋 BLT: 「 ... 」 → Built dist successfully
🐞 TST: 「 ... 」 → Start testing dist
🦋 TST: 「 ... 」 → Tested dist successfully
🦋 ADD: 「 ... 」 → Added dist successfully
💀 MTA: 「 ... 」 → No valid meta obtained for spec
💀 BLD: 「 ... 」 → Bulding dist failed
💀 TST: 「 ... 」 → Testing dist failed
💀 CNF: 「 ... 」 → Config file error
💀 CMD: 「 ... 」 → Could not understand command
79.4.3. 时间机器(WIP)
Pakku timemachine 不仅可以穿越时空到未来, 还可以穿越到过去。
Pakku timemachine 可以保存乐库的当前状态, 存储每个 Raku 安装的发行版列表, 并且可以在需要的时候随时回到这个状态(例如在重新安装 Raku 之后)。
这项工作仍在进行中, 不过当你读到这里时, 这项功能可能已经准备就绪。
# list available events
pakku timemachine
# save the current repos state to event name "xmas"
pakku timemachine event xmas
# restore the repo state (by removing or adding distributions)
pakku timemachine travel yesterday
pakku timemachine travel 12012020
79.4.4. 截图
列表中详细介绍了分发的情况
79.4.5. 试试 Pakku
Pakku 目前运行在 Linux 系统上(未来将支持更多的操作系统), 试试吧, 你可能会喜欢它。
79.4.6. 谢谢你
感谢您的阅读。祝您使用 Raku 愉快!
80. 第六天 - 声明式 API, 用 Raku 轻松搞定
Raku 的 API 往往很容易阅读, 例如, 命名的参数减轻了对方法调用中参数顺序的记忆。
但有时一个库的作者会在此基础上制作出特别漂亮的声明式 API。其中一个例子是 Cro, 一个用于编写基于 HTTP 的服务的框架, 它允许你写一些东西, 如:
my $application = route {
get -> 'greet', $name {
content 'text/plain', "Hello, $name!";
}
}
来声明你的路由, 也就是 Cro 为你调用的回调, 当用户请求符合路由引入的模式的 URL 时, Cro 会为你调用哪些回调, 在这个例子中, /greet/fido 等等。
今天, 我们将探讨使这种声明式 API 工作的机制, 也就是自然读取的 API 和尽可能少使用样板代码的 API。
我们将探讨如何为你编写的库启用类似的接口。
80.1. 声明式 API 基础
上面的例子依赖于几个主要的想法。
-
像
route
、get
和content
这样的裸词只是名称相同的函数, 你可以简单地用它们的名称和空格来调用它们。语句的其他部分被解释为这些函数的参数。 -
在
route { … }
中,{ … }
是一个 Block, 也就是像函数一样的一段代码。 -
同样,
→ 'greet', $name { … }
是一个块, 这个块有一个显式签名('greet', $name) 部分。库代码可以反省这个签名, 也就是找到参数的名称 ($name) 和常量字符串 'greet' 的值。 -
还有一种无形的机制, 将 get 与外面的
route { … }
块绑定。
最后一点需要再解释一下。在 Cro 中, 你可以有多个独立的 route { }
块, 像这样。
my $app1 = route {
get -> 'meet' { content 'text/plain', 'Nice to see you' }
}
my $app2 = route {
get -> 'greet' { content 'text/plain', 'Oh hai' }
}
Cro 如何知道 meet
回调属于 $app1
, greet
属于 $app2
?route
子程序需要调用传递给它的块来找出它声明的回调, 所以它需要向块中注入某种上下文。做到这一点的方法是通过动态作用域变量。
在 Raku 中, 你可以通过在 sigil 后面声明一个带 * 的变量来实现。
sub outer(&callback) {
my @*DYNAMIC;
callback();
return @*DYNAMIC.list;
}
sub inner() {
@*DYNAMIC.push(42);
}
say outer(&inner);
这里 sub outer 声明了一个动态变量 @*DYNAMIC。在 outer 完成之前, 所有被调用的都可以看到这个变量, 包括 inner 的内部, 它被绑定到参数 &callback
上。因此, 代码打印 [42]
。
如果你看看 Cro::HTTP 对 sub route 的定义, 你可以看到它使用的技巧基本相同, 只是用一个空的 RouteSet 而不是一个空的数组来初始化动态变量。
80.2. 实用化
假设你正在写一个观察目录树的库, 你可以配置它将飞翔同步到另一个当地, 或者根据某些属性自动删除它们, 或者当某些文件改变时调用你的代码。
你想提供一个额外的厉害的, 像这样的声明式 API。
my $syncer = directory 'Documents', {
watch name => /.*/, -> $file { say "File $file changed" }
delete name => /\.swp/;
delete name => /\.swo/;
delete age_days => * > 5;
sync extension => 'txt';
}
要让这个例子编译, 你只需要用适当的签名声明 directory, delete, sync 和 watch 这四个函数。
sub delete(*%conditions) {}
sub sync(*%conditions) {}
sub watch(&callback, *%conditions) {}
sub directory(Str $path, &callback) {}
当然, 你还需要在一个数据结构中捕获条件和回调, 这样你的假设库就可以用它做一些事情。
这可以是一个用于存储动作类型的枚举, 以及一个用于条件和可选回调的类。
enum Sync::Action <Delete Sync Watch>;
class ConditionalRule {
has Sync::Action $.action is required;
has %.conditions;
has &.callback;
}
加上一个存储目录和 ConditionalRule 对象列表的类。
class Sync::Spec {
has Str $.path;
has ConditionalRule @.rules;
method add(ConditionalRule $r) { @.rules.append: $r }
}
最后, 我们需要将开始的四个函数具体化。directory 创建一个 Sync::Spec 对象, 然后调用它的回调。
sub directory(Str $path, &callback) {
my $*SYNC = Sync::Spec.new(:$path);
callback;
return $*SYNC;
}
其他三个需要创建新的 ConditionalRule 对象, 并将其添加到 $*SYNC 中。
sub delete(*%conditions) {
$*SYNC.add: ConditionalRule.new:
:action(Sync::Action::Delete),
:%conditions,
}
sub sync(*%conditions) {
$*SYNC.add: ConditionalRule.new:
:action(Sync::Action::Sync),
:%conditions,
}
sub watch(&callback, *%conditions) {
$*SYNC.add: ConditionalRule.new:
:action(Sync::Action::Sync),
:%conditions,
:&callback,
}
这是一个恼人的模板, 但它允许漂亮界面的用户放弃所有的模板。
一旦你把所有这些拼凑在一起, directory 就会返回一个 Sync::Spec 对象, 这个对象包含了所有必要的知识, 为假设的 syncer 库提供燃料。
剩下的就是真正实现它了。这个任务远远超出了本文的范围 - 留给读者去做, 如果你选择了这些的话。
但是, 等等, 我们还没有完全完成, 因为当有人滥用我们整洁的小 API 时。如果你只是在目录块之外调用 delete, 你就会得到 Dynamic variable $*SYNC not found 的错误信息, 这并不值得我们向往的精彩。
幸运的是, 我们可以很容易地改进这一点。
sub delete(*%conditions) {
die 'delete outside a directory { } block'
unless defined $*SYNC;
$*SYNC.add: ConditionalRule.new:
:action(Sync::Action::Delete),
:%conditions,
}
-
并类推其他三个动作。同样是更多的模板, 符合 Raku 代表用户折磨实现者的座右铭。
81. 第七天 - 使用 Sparrow 混合 Bash 和 Raku
在这篇文章中, 我将向你展示一个可以有效地混合 Bash 脚本和 Raku 语言使用 Sparrow。
Sparrow 的理念是 - 选择最适合你的领域的语言, 让 Raku 在高层次上协调你的代码。
让我们开始吧。
81.1. 安装 Sparrow
Sparrow 作为一个 Raku 模块, 所以必须使用 zef 包管理器来安装它。
zef install --/test Sparrow6
一旦安装了 Sparrow, 你会在 PATH 中得到一个 s6 实用程序, 这样你就可以执行与 Sparrow 相关的任务。
s6 --help
81.2. 创建一个 Bash 任务
你先用一个 Bash 脚本做一些有用的工作。要求是把脚本命名为 task.bash。
让我们从一个简单的例子开始。
task.bash
echo "hello from Bash"
现在让我们用 Sparrow 运行这个脚本。
s6 --task-run .
[sparrowtask] :: run sparrow task .
[sparrowtask] :: run thing .
[.] :: hello from Bash
所以脚本执行后, 我们可以看到一个输出。
如果就此打住就太傻了, 有什么理由只通过 Sparrow 来运行 Bash 脚本呢?
最 exiting 的部分来了。继续看下去。
81.3. 处理输入参数
比如说, 我们想把一些输入参数传递给一个 Bash 脚本。用 Bash 处理输入参数可能会很麻烦, 但用 Sparrow 就不会。让我们更新一下我们的脚本。
echo "hello from $(config language)"
现在我们可以带着参数运行这个脚本。
s6 --task-run .@language=Bash
[sparrowtask] :: run sparrow task .@language=Bash
[sparrowtask] :: run thing .
[.] :: hello from Bash
简单吗?我们不需要在 Bash 中创建参数解析器。它默认是由 Sparrow 来完成的!我们甚至可以为输入参数设置默认值(如果没有明确地传递任何参数, 则应用默认值)。
我们甚至可以为输入参数设置默认值(如果没有明确地传递任何参数, 就会被应用)。
让我们创建一个名为 config.yaml 的 YAML 格式文件, 它将包含所有的默认参数。
config.yaml
language: Shell
然后再运行这个脚本, 不需要参数。
s6 --task-run .
[sparrowtask] :: run sparrow task .
[sparrowtask] :: run thing .
[.] :: hello from Shell
所以默认参数 Shell 是从 config.yaml 文件中应用的。
要通过命令行传递多个参数, 请使用逗号分隔符。
s6 --task-run .@language=Raku,version=2020.11
81.4. 将 Bash 脚本作为 Raku 函数运行
Sparrow 的使用方法非常有趣, 它可以将同一个 Bash 脚本作为一个 Raku 函数来运行。
让我们创建一个名为 run.raku 的 Raku 场景, 它相当于上面的命令行。
run.raku
use Sparrow6::DSL;
task-run ".", %(
language => "Raku"
);
让我们使用 Raku 来运行 run.raku 脚本。
raku run.raku
[.] :: hello from Raku
我们有同样的输出。
因此, Sparrow 允许把脚本当作函数来运行, 这非常有趣。根据不同的上下文, 我们可以将相同的代码作为命令行或 Raku 上的函数来运行。
81.5. 检查 STDOUT
有时候, 当我为命令行工具编写测试时, 我想验证一些脚本的输出是否包含某些行。
就像 Linux 的人会使用 | grep
结构来检查一个命令是否产生给定的输出一样。
Sparrow 为这种操作提供了一个等效的方法, 叫做任务检查。
让我们在同一个目录下用 Bash 脚本创建一个名为 task.check 的文件。
task.check
regexp: Bash || Shell || Perl || Python || Raku
让我们通过运行 run.raku 脚本来应用任务检查。
raku run.raku
[.] :: hello from Raku
[task check] stdout match True
正如大家所看到的, Sparrow 已经验证了该脚本输出至少有五个词中的一个。
Bash, Shell, Perl, Python 或 Raku.
如果任务检查失败, Sparrow 会通过抛出一个适当的异常来通知用户。
让我们通过传递一个 Basic 语言参数(对不起, Basic 用户 🙂)来了解一下。
s6 --task-run .@language=Basic
[.] :: hello from Basic
[task check] stdout match False
=================
TASK CHECK FAIL
echo $?
2
因此, 如果一个任务检查失败, 会导致无零执行代码, 通知整个脚本以失败告终。
81.6. 脚本钩子
熟悉 git 源码控制系统的人都知道, 它允许定义脚本钩子—在数据被发送到远程服务器之前, 小任务会被执行。这些任务可以包括, 例如, 单元测试或 linters。如果你想在发送实际数据到服务器之前确保代码的更改是有效的和正确的, 这是很方便的。
同样的方式, Sparrow 允许为用户脚本定义钩子。这些钩子—也是在主脚本之前被执行的 Sparrow 任务。
让我们来看看这个例子, 我们有主脚本 - task.bash 和触发额外任务的钩子, 名为 task/triggers/http/task.bash。
为了实现这一点, Sparrow 需要一个专门的 dir tasks/, 所有的钩子脚本都存在其中。
mkdir tasks/triggers/http
tasks/triggers/http/task.bash
curl 127.0.0.1:3000 -o /dev/null -s && echo "I am triggered"
还有一个名为 hook.bash 的专门的 bash 文件, 运行一个 hook 脚本, 使用 run_task 函数。
hook.bash
run_task triggers/http
现在让我们来运行它。
s6 --task-run .
[sparrowtask] :: run sparrow task .
[sparrowtask] :: run thing .
[.] :: I am triggered
[.] :: hello from Shell
[task check] stdout match True
在这种情况下, 我们在运行主脚本(只是打印出 "hello from Shell")之前, 先打一个 URL(curl 127.0.0.1:3000)。
总的来说, hooks 提供了一种将大的 Bash 脚本分割成小的独立脚本的方法。
它甚至可以在主脚本和附加脚本之间传递参数。让我们把一个名为 param 的参数传递给 tasks/triggers/http/task.bash 脚本。
nano hook.bash
run_task triggers/http param value
钩子脚本读取传递给它的参数作为 $param 变量。
tasks/triggers/http/task.bash
curl 127.0.0.1:3000 -o /dev/null -s && echo "I am triggered. You passed me param: $param"
$ s6 --task-run .
[sparrowtask] :: run sparrow task .
[sparrowtask] :: run thing .
[.] :: I am triggered. You passed me param: value
[.] :: hello from Shell
[task check] stdout match True
81.7. 包装好东西
最后, 最后但并非最不重要的 Sparrow 的功能是打包。
打包允许用户通过使用 Sparrow 插件机制来发布他们的脚本。
一个脚本可以被打包并上传到 Sparrow 仓库 - 远程服务器, 由它向用户分发脚本。
让我来演示一下我们如何一步步做到这一点。
81.8. 初始化 Sparrow 仓库
首先, 我们需要为我们的 Sparrow 仓库创建一个内部文件结构, 所有的插件都将被存储在这里。
这可以通过简单的 Sparrow cli 命令来完成。这个命令只有一个参数 - 仓库文件系统根目录的路径。
s6 --repo-init ~/repo
[repository] :: repo initialization
[repository] :: initialize Sparrow6 repository for /home/melezhik/repo
81.9. 创建一个插件
一旦 Sparrow 仓库被初始化, 让我们将 Bash 脚本转换成一个 Sparrow 插件。要做到这一点, 我们需要创建一个名为 sparrow.json 的插件元文件, 其中包含了所有包的详细信息。
这个文件应该以 JSON 格式编写, 并放在我们有 Bash 脚本的同一个目录下。
sparrow.json
{
"name" : "hello-language",
"version" : "0.0.1",
"description" : "hello language plugin"
}
元文件结构非常简单, 不言自明. 最起码的参数是插件名称、插件版本和简短的描述。
现在让我们使用 s6 cli 将插件上传到 Sparrow 仓库。
s6 --upload
[repository] :: upload plugin
[repository] :: upload hello-language@0.0.1
该命令将所有插件文件归档并复制到仓库文件系统中。
81.10. 运行网络服务器
为了让一个插件能够为终端用户提供服务, 我们需要旋转一个web服务器来为仓库文件提供服务, 它可以是任何一个web服务器, 唯一的要求是它必须有一个仓库根目录的文件根。它可以是任何一个web服务器, 唯一的要求是它必须有一个版本库根目录的文件根。下面是一个 caddy http 服务器的例子。
caddy --root ~/repo --listen=192.168.0.1
81.11. 安装插件
现在, 用户可以通过使用 Sparrow 命令行来安装和运行一个插件。
首先, 让我们设置一个远程 Sparrow 仓库, 并获取仓库索引文件。这个操作类似于 Linux 用户使用标准 Linux 包管理器时的操作(例如 Debian:apt-get update)。
export SP6_REPO=http://192.168.0.1
s6 --index-update
[repository] :: update local index
[repository] :: index updated from file://home/melezhik/repo/api/v1/index
仓库设置好后, 使用 s6 安装并运行插件。用户可以选择命令行方式。
s6 --plg-run hello-language@language=Python
[repository] :: install plugin hello-language
[repository] :: installing hello-language, version 0.000001
[task] :: run plg hello-language@language=Python
[task] :: run thing hello-language
[hello-language] :: I am triggered. You passed me param: value
[hello-language] :: hello from Python
[task check] stdout match True
或 Raku API:
run.raku
use Sparrow6::DSL;
task-run "hello language", "hello-language", %(
language => "Raku"
)
raku run.raku
[hello language] :: I am triggered. You passed me param: value
[hello language] :: hello from Raku
[task check] stdout match True
81.12. 不同的分发协议
Sparrow 资源库支持各种协议的脚本发布, 包括 http、https、rsync 和 ftp。
81.12.1. 公共的 Sparrow 资源库 - Sparrowhub.io
Sparrow 官方资源库是 sparrowhub.io - 包含了很多真实插件的例子, 可以方便地解决日常开发者的任务。快来看看吧!它也是一个很好的方法来解决日常开发任务。这也是熟悉 Sparrow 的一个好方法。
81.13. 结束语
正如大家所看到的, Sparrow 为使用普通 Bash 脚本的人提供了很多功能。
通过合理的Raku代码量, 我们可以有效地开发、管理和发布Bash脚本。
因此, Sparrow 允许用 Bash 风格来做 Bash 更合适的事情, 同时又有 Raku 作为伟大的粘合剂和协调工具。如果你觉得 Sparrow 有趣, 请访问 Sparrow GH 页面, 作为文档、示例和依赖项目链接的来源。
圣诞快乐
82. 第八天 - Raku 网页模板引擎: 提升解析性能
82.1. 现代 Raku 网页模板引擎
模板引擎基本上提供了在静态文件(模板)中进行有效元数据插值的工具。在网络应用程序运行时, 引擎解析并将变量替换为实际内容值。最后, 客户端得到一个由模板生成的HTML页面, 其中所有的元数据(变量、语句、表达式)已经被处理。
Raku 生态系统有几个现代模板引擎。Template::Mojo(最后一次提交于2017年6月12日), Template::Mustache(最后一次提交于2020年7月25日—它还活着!), Template6(最后一次提交于2020年11月20日—积极维护), Template::Classic(最后一次提交于2020年4月11日), Template::Toolkit(由 @DrForr 编写, 不幸的是它现在闲置了)和 HTML::Template(最后一次提交于2016年10月28日)。
另外还有方便的多模块适配器 Web::Template - 一个简单的抽象层, 为不同的模板引擎提供一致的 API。
你应该选择什么引擎呢?我的标准是:项目应该是活着的, 并且是 Rakudo Star Bundle 发行版的一部分。好吧, Template::Mustache
就是无辜被选中的那个。
82.2. 什么是网页模板?
网页模板是带有附加元数据(标记)的 HTML 文档, 将由模板引擎处理—在简单的模板中, 元数据是通过变量来呈现的(例如, Template6 模板引擎将变量插值为 [% varname %]
, Template::Mustache 插值为 {{ varname }}
)。在网页模板处理后, 所有的元数据变量都被替换为实际的内容值。
Сomposite web 模板包括与其他模板的链接(绑定)。例如, 规范的 Mustache 模板可以用 {{> template_name }}
执行导入。(参见 partials)。顺便说一下, 可以在导入的模板中使用链接, 所以可以接受递归 partials。
带有逻辑的网页模板(内联程序)使用扩展的元标记。我们可以在模板中编写简单的布局管理程序。Template6 引擎成功地"执行"了像 [% for item in list %][% item %]\n[% end %]
或 [% if flag %]foo[% else %]bar[% end %]
这样的结构。
大多数 web 应用程序的常规做法—使用简单的复合模板进行模板化。我们只使用变量和导入依赖关系(页眉、页脚、注释块、反馈表单等)。所有的扩展逻辑(可以用内联程序实现的)应该尽量排除, 或者放到 web 应用层。
在本文中, 我们将考虑最简单的情况 - 带变量的 web 模板(没有导入, 没有内联逻辑)。
82.3. 性能方面
如上所述, 我选择模板引擎的标准是项目支持和可访问性。但在现实生活中, 重要的标准是性能。不管项目是非常活跃的, 还是包含在所有已知的发行版中—如果客户等待几秒钟的页面渲染, 我们就必须寻找另一个模块或解决方案。
所以, 为了测试性能, 我使用了 Pheix CMS 的嵌入式模板作为一个最简单的模板。在我看来—如果模板引擎能够轻松处理, 我们就可以进入下一步的测试 - 比如内联程序。
注:模板有14个变量, 其中有2个变量使用了扩展的 Mustache 语法 {{{ varname }}}
。三个大括号是告诉引擎跳过我们要替换成的内容块里面的转义。
测试套件基于处理脚本, 其中使用了帮助模块 Pheix::View::TemplateM 的 render() 方法。源代码非常简单, 并尽可能地接近文档指南。我们对 render()
方法进行了分析, 并测量了执行时间(不考虑编译、加载、对象初始化等时间)。此外, 我们使用自动 bash 助手循环运行 tmpl-mustache.raku 100 次, 并计算平均运行时间。
测试是在 MacBook Unibody Core2Duo 2.6 GHz, 8Gb 内存平台上进行的(就像中性能的VPS一样)。
mustache 渲染时间:1.8555348秒
换句话说, 如果 web 应用像经典的 CGI 实例一样工作(没有缓存, 没有可用的worker, 没有代理 - 每次运行都是从头开始), 请求至少会在2秒内呈现(网络延迟+占空比+模板化[+服务器资源节流])。实际上这个时间可能会超过10秒(几个并行客户端的情况)- 绝对不好。
对 Template6 进行同样的测试。
template6 render time: 0.5481035 sec
82.4. 第一次优化
关于优化的第一个想法是"好吧, 看来考虑过的模块对于这个任务来说是很沉重的 - 让我们写一些简单的东西"。而我基于通用正则表达式, 实现了自己的 render()
方法。
method fast_render(Str :$template is copy, :%vars) returns Str {
for %vars.keys -> $key {
if $key ~~ /tmpl_timestamp/ {
$template ~~ s:g/ \{\{\{?: <$key>: \}\}\}?: /%vars{$key}/;
}
else {
$template ~~ s/ \{\{\{?: <$key>: \}\}\}?: /%vars{$key}/;
}
}
$template;
}
结果:
regexpr render time: 0.2721529
与 Template6 相比, 性能提升了2倍, 与 Template::Mustache 相比提升了6倍。
82.5. 第二次优化
接下来的想法是:"好吧, 让我们把 HTML 模板解析成树状, 然后替换/替代所需的块"。我已经使用 XML 模块来完成这个任务。
这种方法需要一个有点棘手的模板:由于模板被解析到树上, 我们需要解决块的插值。在Template6或Template::Mustache的情况下, 我们使用元标记, 但它在XML验证上失败了。
好吧, 我把 XML 标记添加到基本模板中, 而不是元标记。
-
specific tags: <pheixtemplate variable="tmpl_pagetitle"></pheixtemplate>;
-
specific attributes:
-
<link href="resources/skins/akin/css/pheix.css" pheix-timestamp-to="href" rel="stylesheet" /> — this means the timestamp value will be concatenated to string from href attribute;
-
<meta name="keywords" content="" pheix-variable-to="content" pheix-variable="tmpl_metakeys" /> — this means the tmpl_metakeys value will be inserted to content attribute.
此外, 特定的标签应该有预建的 HTML 代码和与现有树节点的绑定, 特定的属性只需要定义—这在初始化步骤中就可以直接完成。
my %tparams =
title => {
name => 'tmpl_pagetitle',
new => make-xml('title', "This is the page title"),
existed => Nil,
value => q{}
},
mkeys => {
name => 'tmpl_metakeys',
new => Nil,
existed => Nil,
value => 'This is meta.keywords data'
},
...
;
for %tparams.keys -> $k {
%tparams{$k}<existed> =
$xml.root.elements(:TAG<pheixtemplate>, :variable(%tparams{$k}<name>), :RECURSE, :SINGLE);
if !%tparams{$k}<existed> {
%tparams{$k}<existed> = $xml.root.elements(:pheix-variable(%tparams{$k}<name>), :RECURSE, :SINGLE);
}
}
时间戳块的收集:
my @timestampto = $xml.root.elements(:pheix-timestamp-to(* ~~ /<[a..z]>+/), :RECURSE);
并由(尽量琐碎的)处理:
for (@timestampto) {
my Str $attr = $_.attribs<pheix-timestamp-to>;
if $attr {
$_.set($attr, ($_.attribs{$attr} ~ q{?} ~ now.Rat));
%report<timestamps>++;
}
}
可变标签的处理方法是:
for %tparams.keys -> $k {
if %tparams{$k}<new> {
%tparams{$k}<existed>.parent.replace(%tparams{$k}<existed>, %tparams{$k}<new>);
%report<variables>++;
}
else {
my Str $attr = %tparams{$k}<existed>.attribs<pheix-variable-to>;
if $attr {
%tparams{$k}<existed>.set($attr, %tparams{$k}<value>);
%report<variables>++;
}
}
}
爽!让我们运行脚本。
$ raku html-2-xml.raku
# processing time: 0.15519139
# added 6 timestamps
# replaced 8 variables
100次迭代的平均结果。
xml render time: 0.1550534 sec
这与自定义 RegExpr 相比, 性能提高了2倍, 与 Template6 相比提高了x4, 与 Template::Mustache exploding_head 相比提高了12倍。
82.6. HTML::Template
规范用法(遵循准则)
为了好玩, 我测量了被遗忘的老 HTML::Template 模块的性能。测试源:模板、脚本(将注释 Pheix::View::TemplateH 切换为 Pheix::View::TemplateH2)、辅助模块。
100 次迭代的平均结果。
htmltmpl 渲染时间:0.1911648秒
嗯, 这是相当快的开箱。
82.7. 让它 ~x100快
100次迭代的平均结果。
htmltmpl 渲染时间:0.0021661秒
这与典籍使用相比, 性能提升了100倍嗒嗒嗒。
82.8. 还需要更多的提升吗?
现代Web开发技术涉及到后台模板引擎的负载均衡。
常见的技术是基于在服务器和客户端之间分配模板渲染任务的思想:根据服务器的负载情况, 由后台完全渲染模板, 或者后台只做快速生成。它拉出模板文件, 并将其内容与要替换的数据(用JSON表示)连接起来。通常这些数据会作为 <script></script>
标签内的 JavaScript 代码添加到模板的末尾。
这种方法在我们使用 Template::Mustache 作为后端主要模板引擎的情况下可以有效。服务器端渲染的模板变量标记为 {{ varname }}
或 {{ varname }}}
, 客户端渲染的变量标记为 { props.varname }
。所以, 这就是我们获得模板源码的一致性和基本语义完整性的方式。
82.9. 结束语
82.10. 总结
本帖考虑的所有资料来源均可在 https://gitlab.com/pheix-research/templates。最后的成绩是(以秒为单位)。
1. htmltmpl pre-parse render time: 0.0021661
2. xml render time: 0.1550534
3. htmltmpl native render time: 0.1911648
4. regexpr render time: 0.2721529
5. template6 render time: 0.5481035
6. mustache render time: 1.8555348
82.11. Raku 驱动的网络应用
我相信, 我们可以使用 Raku 作为网络编程语言。我们可以创建快速、反应灵敏、可扩展的 Raku 驱动的后端。另一方面, 它需要更多的实践和时间:结合不同的技术和方法, 我们可以获得更多的性能改进。可悲的是 - 生态系统仍然是原始的, 当我们想在我们的项目中使用一些模块时, 我们应该对它进行配置, 与类似的模块进行比较, 也许可以进行分叉和调整。乐观的事情—我们可以让我们的 Raku 驱动的 web 应用快速工作, 这样就可以发布到生产中。
83. 第九天 - 使用 NativeCall 获取 Windows 的内存
Raku NativeCalls 提供了一种与遵循 C 调用惯例的动态库进行交互的方式, 对于从操作系统中获取内存使用情况等信息非常有用。
在本文中, 我们将看到如何从 Windows 系统中获取内存使用情况。
83.1. MEMORYSTATUSEX C++ 结构
Win32 API 提供了 MEMORYSTATUSEX 结构。
typedef struct _MEMORYSTATUSEX {
DWORD dwLength;
DWORD dwMemoryLoad;
DWORDLONG ullTotalPhys;
DWORDLONG ullAvailPhys;
DWORDLONG ullTotalPageFile;
DWORDLONG ullAvailPageFile;
DWORDLONG ullTotalVirtual;
DWORDLONG ullAvailVirtual;
DWORDLONG ullAvailExtendedVirtual;
} MEMORYSTATUSEX, *LPMEMORYSTATUSEX;
包含了当前内存的信息, 以字节为单位, 包括:
-
总物理内存:ullTotalPhys
-
可用的物理内存:ullAvailPhys
这两个成员将允许我们通过从 ullTotalPhys 中减去 ullAvailPhys 来计算内存使用量。
MEMORYSTATUSEX 结构有 dwLength
成员, 在调用填充 MEMORYSTATUSEX
结构的函数 GlobalMemoryStatusEx 之前, 需要设置这个成员。
83.2. 用 NativeCall 声明 MEMORYSTATUSEX 类
Raku Nativecalls 使用 repr('CStruct')
trait 成一个声明类来与 C 结构交互。我们可以声明这个类来使用 C++ MEMORYSTATUSEX 结构, 如下所示。
class MEMORYSTATUSEX is repr('CStruct') {
has uint32 $.dwLength is rw;
has uint32 $.dwMemoryLoad;
has uint64 $.ullTotalPhys;
has uint64 $.ullAvailPhys;
has uint64 $.ullTotalPageFile;
has uint64 $.ullAvailPageFile;
has uint64 $.ullTotalVirtual;
has uint64 $.ullAvailVirtual;
has uint64 $.ullAvailExtendedVirtual;
};
C++ 和 Raku 之间的成员类型映射很重要。
-
Raku 类型 uint32 相当于 C++ Win32 的 DWORD 类型。
-
Raku 类型 uint64 相当于 C++ Win32 的 DWORDLONG 类型。
$.dwLength
成员是 is rw
(读写), 用于以后设置结构的大小。
83.3. GlobalMemoryStatusEx 的 C++ 函数
这个 C++ 函数使用 LPMEMORYSTATUSEX 指针类型作为参数填充 MEMORYSTATUSEX 结构。
BOOL GlobalMemoryStatusEx( LPMEMORYSTATUSEX lpBuffer )
Raku 不使用指针来执行对这个函数的 Native Call, 而是使用一个具有 native 特性的 Raku 函数, 我们将在下面看到。
83.4. 构建本地函数
Raku Native Calls 允许使用 C++ GlobalMemoryStatusEx 函数, 具体如下。
use NativeCall;
sub GlobalMemoryStatusEx(MEMORYSTATUSEX) is native('Kernel32') returns int32 { * };
-
use NativeCall 加载 Raku NativeCall 上下文。
-
GlobalMemoryStatusEx 是函数名, 必须与
C++
中的原始名称相同。 -
MEMORYSTATUSEX 是我们之前声明的类的名称, 用于与 `C++` 结构交互, 该结构将包含我们需要的成员(
$.ullTotalPhys
和$.ullAvailPhys
)。实际上, 这个类作为指针, 进入C++
GlobalMemoryStatusEx 函数的参数。 -
trait
is native('Kernel32')
公开了包含 GlobalMemoryStatusEx 函数的 Win32 库。 -
这个函数返回一个由 int32 类型表示的 bool 值。
83.5. 获取结果
要使用 MEMORYSTATUSEX 类与 GlobalMemoryStatusEx Native 函数, 我们需要将它实例化在一个对象中, 例如 $data
。
my MEMORYSTATUSEX $data .=new;
另外, 别忘了传递结构体(或 $data
对象)的大小, 设置 $data.dwLength
成员的当前大小。结构的大小就是 $data
对象的大小, 我们可以通过函数 nativesizeof 来获取。
$data.dwLength = nativesizeof($data);
现在我们准备好填充 MEMORYSTATUSEX 结构($data
对象), 以 $data
对象为参数调用 GlobalMemoryStatusEx Native 函数。
GlobalMemoryStatusEx($data);
$data
对象的作用就像 C++ LPMEMORYSTATUSEX 指针一样。
最后, 我们需要的结果在 $data
对象的成员值中。
my $memoryUsage = $data.ullTotalPhys - $data.ullAvailPhys;
say "Current Memory Usage: $memoryUsage Bytes.";
在这里, 你可以看到这个应用在 Raku 模块中的例子。
正如我们在这个例子中所看到的, Raku Native Calls 的使用允许通过它的动态库将 Raku 的功能扩展到操作系统的世界。此外, 通过对不同的操作系统进行适当的调用, 我们可以创建可以在任何操作系统上工作的应用程序。
更多关于 Raku Native 调用接口的信息请参见 Raku 文档。
84. 第十天 - 我的10条 Raku 性能戒律
84.1. 1. 您将使用的剖析器
没有借口忽视它, 它的使用非常简单。
只要用 raku --profile=myprofile.html foo.raku
运行剖析器, 然后在你最喜欢的浏览器中打开生成的 HTML 文件(例如 firefox myprofile.html &
)。
这是一个概述, 你可以在剖析报告中拥有什么。
甚至更好的是, 你可以使用 Moarperf 与 SQL 配置文件:使用 raku --profile=myprofile.sql foo.raku
和后 raku -I .services.p6 myprofile.sql
。
"SQL 配置文件"是一种与 moarperf 兼容的配置文件的输出格式。
下面是 MoarPerf 的一个面板的概述。
"GC" 指的是 Garbage Collection, 这是虚拟机在清理/拆分内存时的一种内部机制。
你可以检查一些运行时间是否很长, 运行是否过于频繁, 最后还可以检查"单元"的管理方式(项目有一个"生命期", 称为"代际垃圾收集", 项目从一个空间移动到另一个空间并改变其"状态"
如果你想在调用图中得到更多的信息, 不要使用命名循环(MYLOOP: 无济于事), 而是使用 subs !
sub myloop() {
...
...
}
比如这个工作实例。
my $result = 42;
sub mybody($i) {
if $i % 2 {
$result += 2;
}
}
sub myloop() {
USELESS: for (0..1000) -> $i {
mybody($i);
}
}
myloop();
say $result;
"sub 技巧"会产生一些开销(调用栈), 但可以帮助你调查。
84.2. 2. 崇尚的原生型
它到处都写着, 用大写字母写着, 使用原生类型来执行! 😀。
这是真的, 它给你提供了极快的 Raku 脚本! (但你必须严格要求自己)
为了让你信服, 从隐式类型开始 。
my @a = ();
for (1..10_000_000) -> $item {
@a.push($item);
}
这很慢:
# real 0m11.073s
# user 0m10.614s
# sys 0m0.520s
然后改成我们要 push 的项目的原生类型。
my @a = ();
for (1..10_000_000) -> int $item {
@a.push($item);
}
稍微好一点, 但还是不好, 因为只有项目被声明为原生 int。
# real 0m9.007s
# user 0m8.469s
# sys 0m0.600s
最后是"完整"的原生 int 版本(容器+项)。
my int @a = ();
for (1..10_000_000) -> int $item {
@a.push($item);
}
这表现非常非常好 。
# real 0m0.489s
# user 0m0.454s
# sys 0m0.105s
(y轴是取自时间报告的秒数, 我用 amcharts 画图)
在分配方面, 这是发生了什么。
如果你想知道什么是 BOOTHash, 它是一个低级的哈希类, 在一些内部结构中, 比如说用来给方法传递参数。
要知道是什么产生了 BOOTHash 分配(SPOILER: @a.push($item)
), 你可以点击查看 。
84.3. 3. 在spesh(和JIT)的力量下, 你会相信
Spesh 和 JIT 是 MoarVM 的优化。
Spesh 更像是试图将方法/属性翻译成便宜的版本。
JIT 的意思是 Just-In-Time 编译。它被 MoarVM 用来将"热"代码编译成二进制(而不是字节码)。
看似简单的循环:
for (1..1_000_000_000) -> $item {
使用默认的选项快速运行:
# real 0m6.818s
# user 0m6.843s
# sys 0m0.024s
在没有 JIT 的情况下, 运行速度会慢一些(MVM_JIT_DISABLE=1)。
# real 0m22.555s
# user 0m22.562s
# sys 0m0.028s
在没有 JIT 也没有 spesh 的情况下, 运行速度慢了很多(MVM_JIT_DISABLE=1, MVM_SPESH_DISABLE=1, MVM_SPESH_INLINE_DISABLE=1, MVM_SPESH_OSR_DISABLE=1)。
# real 5m21.953s
# user 5m21.434s
# sys 0m0.164s
原理是什么?
注意, 调用框架每次都移动到不同的优化类别。
84.4. 4. 在优化中, 你会相信(再次)
这次我们玩的是空循环, 这个要注意。
看一下这3种不同类型的循环迭代器声明。
完全没有类型
for (1..1_000_000_000) -> $item { }
没有声明迭代器或 Int
for (1..1_000_000_000) { }
# Or
for (1..1_000_000_000) -> Int $item { }
原生类型迭代器
for (1..1_000_000_000) -> int $item { }
每个人都在分配不同的对象(有时是 Int, 甚至有时什么都没有)。
如你所见, 分配数并不是实际的循环迭代次数, 优化器已经做得很好了。
记住这是一个空循环, 如果你在正文中使用 $item
, 那么分配数会增加很多 !
这就是为什么(空循环+优化器), 在启用优化后, 它们的表现竟然都是一样的。
但是当优化被关闭时, 就会是另一个故事了。
非显式类型 (for (1..1_000_000_000) → $item { }
) :
# real 5m1.962s
# user 5m1.592s
# sys 0m0.152s
显式的 Int 对象类型 (for (1..1_000_000_000) → Int $item { }
) :
# real 3m32.763s
# user 3m32.647s
# sys 0m0.076s
原生的 int (for (1..1_000_000_000) → int $item { }
) :
# real 2m18.874s
# user 2m18.787s
# sys 0m0.037s
84.5. 5. 各种各样的循环, 你都会倍加珍惜一样。
*
或仇恨, 这取决于
基本 loop?
loop (my int $i = 0; $i < 1000; $i++) { }
或 foreach
与一个 range(这里也许有一些 allocs?) ?
for (1..1000) -> int $item { }
选择你喜欢的, 他们做的不一样, 但似乎执行几乎相同的!😀。
84.6. 6. 从一些不好的内置中你会隐藏
对于原生类型, raku 的表现非常好, 甚至比几个竞争对手还要好。
但另一方面, 有些内置类型的表现就是比其他类型差。unshift 就是这种情况。
my int @a = ();
for (1..16_000_000) -> int $item {
@a.unshift($item);
}
unshift 相对来说很快就会有不好的表现。
# 500 000
# real 0m0.197s
# user 0m0.218s
# sys 0m0.037s
# 1 000 000
# real 0m0.209s
# user 0m0.223s
# sys 0m0.040s
# 2 000 000
# real 0m0.400s
# user 0m0.436s
# sys 0m0.036s
# 4 000 000
# real 0m1.076s
# user 0m1.087s
# sys 0m0.053s
# 8 000 000
# real 0m3.712s
# user 0m3.697s
# sys 0m0.072s
# 16 000 000
# real 0m14.544s
# user 0m14.430s
# sys 0m0.192s
如果我们比较一下 push…
my int @a = ();
for (1..4_000_000) -> int $item {
@a.push($item);
}
# 500 000
# real 0m0.216s
# user 0m0.258s
# sys 0m0.021s
# 1 000 000
# real 0m0.209s
# user 0m0.231s
# sys 0m0.032s
# 2 000 000
# real 0m0.224s
# user 0m0.223s
# sys 0m0.057s
# 4 000 000
# real 0m0.249s
# user 0m0.259s
# sys 0m0.045s
# 8 000 000
# real 0m0.410s
# user 0m0.360s
# sys 0m0.108s
# 16 000 000
# real 0m0.616s
# user 0m0.596s
# sys 0m0.108s
总体思路是"优选性能好的内建"。
另一个例子是所有与 regex 相关的代码。
第一段代码非常慢, 因为 ~~
:
my $n = 123;
my $count = 0;
for (1..10_000_000) {
if $^item ~~ /^$n/ {
$count++;
}
}
# real 1m12.583s
# user 1m12.189s
# sys 0m0.064s
只需将每个 ~~
替换为 starts-with 以大幅提高性能 。
my $n = 123;
my $count = 0;
for (1..10_000_000) {
if $^item.starts-with($n) {
$count++;
}
}
# real 0m3.440s
# user 0m3.470s
# sys 0m0.044s
最后一个想法是从这个很酷的演讲中偷来的 🙂
84.7. 7. 比起 JVM, 你会更喜欢 MoarVM
参考编码
for (1..10000000) -> Int $item { }
在 Rakudo + MoarVM 中运行。
# real 0m0.388s
# user 0m0.287s
# sys 0m0.047s
对比 Rakudo + JVM :
# real 0m21.290s
# user 0m32.255s
# sys 0m0.588s
不使用 JVM 的额外好理由。
-
JVM 的支持是不完整的 (来自 rakudo 新闻)
-
JVM 的启动时间要长得多
84.8. 8. 对于大整数, 你不会处理
我们有性能处罚的大数。
for (1..2147483646) { }
# real 0m15.421s
# user 0m15.429s
# sys 0m0.040s
相比之下
for (1..2147483647) { }
# ^
# real 17m1.909s
# user 16m59.627s
# sys 0m0.396s
这一点在这个代码示例中表现得尤为突出(对于非常小的跃迁, 会有巨大的惩罚) :/。
同样, 这个特殊的例子是不公平的(我报告了一个关于它的问题), 但你得到的想法是, 处理大 int(甚至是本地类型)不会比处理小 int 的成本相同……
84.9. 9. 更好的算法, 你会一直搜索
虚拟机、编译器、优化……对于糟糕的代码逻辑来说, 没有什么能帮得上忙 !
远离昂贵的内建或贪婪的分配算法, 认为循环内的一切都要优化。
84.10. 10. 你永远不应该忘记的海森堡效应
可悲的是, 剖析可以使改变.的执行行为。
-
你的执行行为
-
你的执行时间(有时会慢10倍…)
拥有线程也会让剖析器感到困惑(报告高达 90% 的时间都花在了垃圾收集上…)。这是一种典型的问题, 例如, 如果你试图安装和使用 Unix 信号来中断正在运行的剖析会话, 你可能会遇到这种问题。
84.11. 结束语
Raku 与它的剖析器是一个非常酷的性能优化的游乐场 🙂。
根据我的看法, 在2020年, 还有一些地方需要改进(optims), 但原生类型已经可以让你获得往往比竞争对手好很多的性能, 这是非常好的。
85. 第十一天 - 圣诞老人用类进行微调
85.1. 序幕
一天早上, 圣诞老人在 iPad 上浏览电子贸易杂志时, 看到最新的《O’Reilly 编程通讯》中提到的一篇文章, 说到 COBOL 是多么古老的编程语言, 至今仍被世界上大部分商业软件所使用。
他已经意识到了这一点, 因为他的业务在几百年来一直走在大企业实践的最前沿, 他对自己的最大限度自动化的玩具工厂的尖端效率非常自豪。
自从 2015 年圣诞节正式发布拉里-沃尔的新 Raku 以来, 他一直在密切关注(充满了闪烁)Raku, 并决定是时候将 Raku 的使用纳入他的新五年计划中了。(毕竟, 他思索着, 它应该是"百年语言"。)他很快就召集了他的 IT 员工领导开会, 让他们开始行动。
85.2. 一堂关于类的课
而 Raku 有一个简单易用但功能强大的类构造语法。例如, 看看这个简单的例子, 一个 Circle 类具有以下特点。
-
构造后不可改变
-
用户在施工中输入半径或直径。
-
施工期间计算面积
-
施工时计算周长
-
如果不输入半径和直径, 就会产生错误。
-
$ cat circle-default
-
class Circle {
has $.radius;
has $.diam;
has $.area = $!radius.defined
?? ( $!diam = $!radius * 2; pi * $!radius ** 2 )
!! $!diam.defined
?? ( $!radius = $!diam * 0.5; pi * $!radius ** 2 )
!! die "FATAL: neither radius nor diam are defined";
has $.circum = $!radius.defined
?? ( $!diam = $!radius * 2; pi * $!radius * 2 )
!! $!diam.defined
?? ( $!radius = $!diam * 0.5; pi * $!radius * 2 )
!! die "FATAL: neither radius nor diam are defined";
}
say "== enter radius";
my $radius = 3;
my $c = Circle.new: :$radius;
say "radius: {$c.radius}";
say "diam: {$c.diam}";
say "area: {$c.area}";
say "circum: {$c.circum}";
say "== enter diam";
my $diam = 6;
$c = Circle.new: :$diam;
say "radius: {$c.radius}";
say "diam: {$c.diam}";
say "area: {$c.area}";
say "circum: {$c.circum}";
你注意到了什么构造?复杂的默认生成处理?
更复杂的几何图形会怎样?会变得更糟吧?
如何处理它们?是的, 有一些子方法可以帮助你。BUILD 和 TWEAK
我不会让你厌烦这些血淋淋的细节, 但你可以在"文档"中阅读所有关于它们的内容, 我稍后会向你介绍。
相反, 我建议直接跳到使用 TWEAK。它是在圣诞节发布后不久加入的, 因为它减轻了创建不可改变的实用类的负担。
看看使用 TWEAK 重写的 Circle 类。
-
$ cat circle-tweak
class Circle {
has $.radius;
has $.diam;
has $.area;
has $.circum;
submethod TWEAK {
# Here we have access to all attributes and their values entered
# in the new method!
if $!radius.defined {
$!diam = $!radius * 2
}
elsif $!diam.defined {
$!radius = $!diam * 0.5
}
else {
die "FATAL: neither radius nor diam are defined"
}
$!area = pi * $!radius ** 2;
$!circum = pi * $!radius * 2;
}
}
say "== enter radius";
my $radius = 3;
my $c = Circle.new: :$radius;
say "radius: {$c.radius}";
say "diam: {$c.diam}";
say "area: {$c.area}";
say "circum: {$c.circum}";
say "== enter diam";
my $diam = 6;
$c = Circle.new: :$diam;
say "radius: {$c.radius}";
say "diam: {$c.diam}";
say "area: {$c.area}";
say "circum: {$c.circum}";
在这两个简短的例子中, 通过 "wc" 比较类定义代码, 得到。
$ wc circle-default-class-only circle-tweak-class-only
14 90 541 circle-default-class-only
19 59 430 circle-tweak-class-only
33 149 971 total
默认类版本的行数确实少了, 但比 TWEAK 版本的字数和字符多了。TWEAK 版本不仅字数少了点, 我觉得维护和扩展起来也方便多了。为什么要优化, 因为清晰度更重要?还记得世界著名的计算机科学家和数学家 Donald Knuth 博士的名言:"过早的优化是万恶之源"。
现在我们来看一个类子方法的实际案例。我们正在为出版部门重写我们的页面排版软件。大家可能知道, 我们现在已经开始使用 David Warring 的精美的 Raku PDF 模块直接编写 PDF, 但我们也用 PostScript 做了很多自动化的文档制作。在这两种情况下, 我们都使用约定的方式将页面对象(文本、图像等)的位置描述为 x,y 坐标的二维参考, 默认原点在页面的左下角, 正 x 轴和 y 轴分别指向右边和上方。
今天的课堂练习, 你们分成两个小精灵小组, 想出一个乐类来描述页面上的矩形区域, 将包含文字或图片。你们在高中时都学过几何学, 但也许需要复习一下。
长方形是一个对边平行的四边形(一种四边形的平面图形), 相邻的边互成直角。相邻的边可以有不同的长度。注意我们不会认为边长为零的矩形有效。
一个自由浮动的矩形可以由它的宽度和长度精确定义。在正交 x,y 平面上的固定矩形, 它的一条对角线必须由它的两个端点的坐标或一个端点和对角线与正 x 轴之一的角度来定义。
我们类的要求如下。
-
通过默认的新方法实现一致性后的不变性。
-
由左下角和(1)右上角或(2)它的宽度和高度定义。
在我们的练习中, 观察以下约束条件。
-
矩形的边缘总是平行于 x 或 y 轴的
-
矩形的边长有限
你的作品至少应该有必要的属性来定义和定位你的类。你还应该有代码来显示你的类的实例的创建。
请注意, 当我设计我的 Box 类的版本时, 我同时为它写了一个测试。然后我在继续的过程中对每一个都进行了完善, 直到我对这两个类都满意为止。这个测试实际上是指定了我的设计, 就像 Raku 语言一样, 它是由其广泛的测试套件定义的, 被称为 roast。我将用该测试检查你的工作。
你可以开始, 将有几分钟的时间来完成任务。完成后请举手—第一个完成的小组将获得一根糖果棒棒糖。
…
好了, A 组展示你们的类:
-
$ cat BoxA.rakumod
class Box is export {
;
;;;
;
;;;
;;;;;
;;;;;;;
;;has$.h;
;;;;;;;;;;;
;;;has$.urx;;
;;;;;;;;;;;;;;;
;;;has$.ury;;;;;;
;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;has$.w;;;
;;;has$.llx;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;has$.lly;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;
;;;;;;;
method width {
$!urx - $!llx
}
}
嗬, 嗬, 嗬!真是个小 ASCII 艺人, 不是吗?让我们看看 Python 能不能超越它! 现在让我们试试…
-
$ cat testA.raku
use lib '.';
use BoxA;
my ($llx, $lly, $urx, $ury) = 0, 0, 2, 3;
my $o = Box.new: :$llx, :$lly, :$urx, :$ury;
say $o.width;
$ raku testA.raku
2
嗯, 我至少看到一个问题。你已经添加了所有的属性, 但宽度方法依赖于可能尚未初始化的属性。如果用户这样做了呢。
-
$ cat testA2.raku
use lib '.';
use BoxA;
my ($llx, $lly, $w, $h) = 0, 0, 2, 3;
my $o = Box.new: :$llx, :$lly, :$w, :$h;
say $o.width;
$ raku testA2.raku
Use of uninitialized value of type Any in numeric context
in method width at BoxA.rakumod (BoxA) line 24
0
轰!我们得到了一个无效的答案加上一个错误信息! 如何修改你的代码来正确处理宽度和长度? 避免出现异常?另一组请将该代码进行相应修改。
还有把 ASCII 艺术去掉, 不然驯鹿会以为是什么好吃的东西, 吼吼吼!
有谁知道?是的, C 组, 请展示你的解决方案。
-
$ cat BoxC.rakumod
unit module BoxC;
class Box is export {
has$.llx;
has$.lly;
has$.urx;
has$.ury;
has$.w;
has$.h;
method width {
if $!urx.defined {
$!urx - $!llx
}
elsif $!w.defined {
$!w
}
}
}
还是同样的测试:
-
$ cat testC.raku
use lib '.';
use BoxC;
my ($llx, $lly, $w, $h) = 0, 0, 2, 3;
my $o = Box.new: :$llx, :$lly, :$w, :$h;
say $o.width;
$ raku testA2.raku
2
好的, 很好。但为什么我们不利用 Raku 的默认方法来显示公共属性的值呢?我们不应该添加我们自己的宽度方法, 或者任何其他方法。有什么想法吗?
好了, 圣诞节的朋友们, 圣诞老人的时间快到了, 我必须马上离开你们。另外, 我知道的并不比你们多, 你们要想知道血淋淋的细节, 就得去挖关于类构造的"文档"。这里和这里有两个地方特别提到了 BUILD 和 TWEAK 以及它们的区别。
也可以在 IRC 频道 #raku 上寻求更有经验的 Rakoons(Raku 用户的友好社区)的帮助。
大家做得很好! 我也不会给你们留下未完成的任务。
我是一个务实的程序员和生意人, "实际类建设的底线是"切入正题", 使用 TWEAK 子方法, "处理好生意"。
请看下面 APPENDIX 中我的最终解决方案。这是我对一个实用的、健壮的、不可改变的类的想法, 借助 TWEAK 子方法。正如他们在 IRC 上所说的, "YMMV"(你的里程可能会有所不同)。
现在我要给班上的每个人发糖果棒和糖李子了, 嗬, 嗬, 嗬!
祝大家圣诞快乐, 也祝你们和家人新年快乐! 乐在其中, 乐在其中, 乐在其中!
85.3. 注意事项
对于本文的灵感, 我感谢我的朋友 JJ Merelo 和他的新书 Raku Recipes(上面提到)。
圣诞老人的灵感来自于观看 1994 年蒂姆-艾伦主演的精彩电影《圣诞老人》的重播。(请注意, 还有续集《圣诞老人 2》, 于 2002 年上映, 《圣诞老人 3:逃亡的圣诞老人》, 于 2006 年上映)。如果你从来没有听说过圣诞老人的北极人口被估计在 100 万以上的精灵, 我指给你看今年的电影《圣诞编年史 2》, 并提出一个问题:如果没有一个比亚马逊、联邦快递、UPS 和美国邮政加起来还要大的资源运作, 你认为数百万儿童如何能在圣诞节被圣诞老人留下礼物? 附录
下图所示的最终解决方案也可以在 Github 的这个 repo 中找到。在该目录下运行 'make', 运行 verbose 测试的结果是。
RAKULIB=lib prove -v --exec=raku t/*.t
t/test-box.t ..
ok 1 - begin testing incomplete new args, all tests die-ok
ok 2 -
ok 3 -
ok 4 -
ok 5 -
ok 6 -
ok 7 -
ok 8 - end testing incomplete new args
ok 9 - builds ok
ok 10 - builds ok
ok 11 - given w,h calculate urx,ury
ok 12 -
ok 13 - given urx,ury calculate w,h
ok 14 -
ok 15 - given urx,ury and w,h use w,h
ok 16 -
ok 17 -
ok 18 -
ok 19 - invalid: llx > urx (this and all following tests die-ok)
ok 20 - invalid: lly > ury
ok 21 - invalid: w <= zero
ok 22 - invalid: h <= zero
1..22
ok
All tests successful.
Files=1, Tests=22, 0 wallclock secs ( 0.01 usr 0.01 sys + 0.34 cusr 0.03 csys = 0.39 CPU)
Result: PASS
模块文件 Box.rakumod:
unit class Box;
# must define all two:
has $.llx;
has $.lly;
# must define one of the two:
has $.urx;
has $.w;
# must define one of the two:
has $.ury;
has $.h;
submethod TWEAK {
# check mandatory attrs
my $err = 0;
my $msg = "FATAL: class Box undefined attr(s):\n";
if not $!llx.defined {
++$err;
$msg ~= "\$llx\n";
}
if not $!lly.defined {
++$err;
$msg ~= "\$lly\n";
}
if not $!urx.defined and not $!w.defined {
++$err;
$msg ~= "\$urx and \$w\n";
}
if not $!ury.defined and not $!h.defined {
++$err;
$msg ~= "\$ury and \$h\n";
}
die $msg if $err;
# h vs ury
# h has precedence over ury
if $!h.defined {
$!ury = $!lly + $!h;
}
elsif $!ury.defined {
$!h = $!ury - $!lly;
}
# w vs urx
# w has precedence over urx
if $!w.defined {
$!urx = $!llx + $!w;
}
elsif $!urx.defined {
$!w = $!urx - $!llx;
}
$msg = "FATAL: class Box has invalid attr(s):\n";
# ensure urx > llx
if $!urx < $!llx {
++$err;
$msg ~= "\$llx > \$urx\n";
}
# ensure ury > lly
if $!ury < $!lly {
++$err;
$msg ~= "\$lly > \$ury\n";
}
# ensure w > 0
if $!w <= 0 {
++$err;
$msg ~= "\$w <= zero\n";
}
# ensure h > 0
if $!h <= 0 {
++$err;
$msg ~= "\$h <= zero\n";
}
die $msg if $err;
}
测试文件的内容:
use Test;
use Box;
my $o;
# assign values to be used for all class attrs
my $llx = 0;
my $lly = 0;
my $urx = 1;
my $ury = 2;
my $w = 3;
my $h = 4;
# object should die during construction if required attrs aren't provided
dies-ok {$o = Box.new: :$llx, :$lly, :$urx}, "begin testing incomplete new args, all tests die-ok";
dies-ok {$o = Box.new: :$llx, :$lly, :$ury};
dies-ok {$o = Box.new: :$lly, :$urx, :$ury};
dies-ok {$o = Box.new: :$llx, :$urx, :$ury};
dies-ok {$o = Box.new: :$llx, :$lly, :$w};
dies-ok {$o = Box.new: :$llx, :$lly, :$h};
dies-ok {$o = Box.new: :$lly, :$w, :$h};
dies-ok {$o = Box.new: :$llx, :$w, :$h}, "end testing incomplete new args";
# builds ok with expected args
lives-ok {$o = Box.new: :$llx, :$lly, :$urx, :$ury}, "builds ok";
lives-ok {$o = Box.new: :$llx, :$lly, :$w, :$h}, "builds ok";
$o = Box.new: :$llx, :$lly, :$w, :$h;
is $o.urx, 3, "given w,h calculate urx,ury";
is $o.ury, 4;
$urx = 1;
$ury = 2;
$o = Box.new: :$llx, :$lly, :$urx, :$ury;
is $o.w, 1, "given urx,ury calculate w,h";
is $o.h, 2;
# test precedence of h,w over ury,urx
$llx = 1;
$lly = 2;
$urx = 2;
$ury = 3;
$w = 4;
$h = 5;
$o = Box.new: :$llx, :$lly, :$urx, :$ury, :$w, :$h;
is $o.w, 4, "given urx,ury and w,h use w,h";
is $o.urx, 5;
is $o.h, 5;
is $o.ury, 7;
# ensure urx > llx
$llx = 3;
$urx = 2;
$lly = 2;
$ury = 3;
dies-ok {$o = Box.new: :$llx, :$lly, :$urx, :$ury;}, "invalid: llx > urx (this and all following tests die-ok)";
# ensure ury > lly
$llx = 1;
$urx = 2;
$lly = 2;
$ury = 1;
dies-ok {$o = Box.new: :$llx, :$lly, :$urx, :$ury;}, "invalid: lly > ury";
# ensure w > zero
$llx = 1;
$urx = 1;
$lly = 2;
$ury = 1;
dies-ok {$o = Box.new: :$llx, :$lly, :$urx, :$ury;}, "invalid: w <= zero";
# ensure h > zero
$llx = 1;
$urx = 2;
$lly = 1;
$ury = 1;
dies-ok {$o = Box.new: :$llx, :$lly, :$urx, :$ury;}, "invalid: h <= zero";
done-testing;
86. 第十二天 - 那种快乐的感觉
当我们谈论测量时间时, 我们可能会想到许多不同的方法来测量许多不同的东西。但从原则上讲, 我想我们可以把它们分为两大类。
-
我们可以测量与之前的事件相关的时间, 例如, 看看自那时以来已经过去了多少时间(比如你数到100需要多长时间);或
-
我们可以测量时间到某个未来的事件, 例如看看在这之前是否发生了一些事情(比如是否该把蛋糕从烤箱里拿出来了)。
这些场景已经很常见了, Raku 把这些场景都涵盖了也就不足为奇了。
86.1. 从过去算起
例如, 我们可以这样测量一件事花了多长时间。
my $start = now;
my $prime = (^∞).grep(*.is-prime)[999]; # Do something slow...
say "Took { now - $start } seconds to find the 1000th prime: $prime";
# OUTPUT:
# Took 0.9206044 seconds to find the 1000th prime: 7919
86.2. 数到未来
要对未来的事件进行"倒计时", 我们需要能够代表一个事件, Raku 也有这个功能。它叫做 Promise, 它代表的是一个尚未结束的过程的结果。从某种意义上说, Promise 是一个占位值, 是运行时的一个承诺, 当某个待处理的进程结束后, 我们就可以找到它的结果。
这可能听起来很抽象, 这是因为…它确实是抽象的。不像 Instant或 Duration, 它们代表了时间的特定方面, Promise 可以代表该过程的任何结果, 不管是什么过程。这个结果可以是时间的某一瞬间, 但也可以是网页的文字, 或者是一些复杂的数学运算的结果, 或者任何东西, 真的。
为了说明这一点, 我们可以使用其中一个 Promise 对象来测量我们在时间耗尽之前可以找到的质数。
my $one-second-passed = Promise.in: 1;
my @primes = gather for (^∞).grep: *.is-prime {
.take;
last if $one-second-passed;
}
await $one-second;
say "Found { @primes.elems } primes in 1 second";
# OUTPUT:
# Found 1057 primes in 1 second
我们创建一个 Promise, 它将在一秒钟内被保留, 作为我们的超时, 我们使用 gather
和 take
在循环中填充我们的质数列表。
在向列表中添加一个新的质数后, 我们检查 Promise 是否被保留, 如果是, 我们就停止。
我们可以用目前所提到的东西覆盖很多简单的案例, 很多时候像这些解决方案可能就足够了。但有些情况下, 这些基本工具就显得不够用了。
86.3. 移动的门柱
比方说, 现在的任务不是测量你数到100所需的时间, 而是要数出你能在规定的时间内数到100的次数。
或者, 如果你想要一个更现实的例子, 比如想一想每30秒发送一次心跳的服务器的连接。如果过了这个时间还没有收到心跳, 我们就想关闭这个连接。
或者是一个需要向外部服务批量发送请求的进程。我们希望在每个请求产生后, 最多等待一秒钟, 然后再发射一个批次, 并继续等待下一个请求。
这些方案是我们上面讨论的两种情况的组合。
-
它们从过去的一个点开始测量:你开始一个新的计数, 或收到一个心跳, 或生成一个请求的点。
-
它们朝向未来的一个点进行测量:当你没有时间完成计数时, 或当我们决定没有心跳时, 或当我们决定是时候发送新的请求批时。
这里的关键区别在于, 这些情况下的"最后期限"并不是固定的:如果我们确实在规定时间内收到了新的心跳, 那么倒计时就会被重置, 我们会从头开始计算。
86.4. 承诺和馅饼皮
事实证明, 目前 Promise 的设计使得这种表示方式有点尴尬, 因为它们表示的是一个待定过程的结果的占位符。这意味着它们对该进程没有直接控制权。而如果我们希望它们适用于最多的场景, 就应该是这样的。
考虑一下上面这段天真的代码版本。
my @primes;
await Promise.anyof(
Promise.in(1),
start { # See below for why this is a bad idea
@primes.push: $_ for (^∞).grep: *.is-prime;
},
);
say "Found { @primes.elems } primes in 1 second";
# OUTPUT:
# Found 1057 primes in 1 second
这段代码将产生与上面那段相同的输出, 但它的行为并不相同。关键的区别(和问题)是, 这段代码永远不会停止向 @primes
推送元素。[1] 这是因为由 start
关键字启动的进程将继续运行, 只要它能运行, 即使我们不再关注。
幸运的是, Raku 是一门为扩展而生的语言, 而恰好 Timer::Breakable 的形式就存在这个问题的解决方案。
Timer::Breakable 可以理解为一种 Promise, 就像馅饼皮一样, 是用来打破的[2], 有了它, 我们就可以解决这个移动门柱的问题。
use Timer::Breakable;
my $disconnect = Promise.new;
my $heartbeat = Supply.interval(0.5).grep: { Bool.pick }
my Timer::Breakable $timeout;
react {
whenever $disconnect { done }
whenever $heartbeat {
say '-- THUMP --';
.stop with $timeout;
$timeout = Timer::Breakable.start: 0.75, {
say 'No heartbeat! Disconnecting...';
$disconnect.keep;
}
}
}
# OUTPUT:
# -- THUMP --
# -- THUMP --
# -- THUMP --
# -- THUMP --
# -- THUMP --
# No heartbeat! Disconnecting...
86.5. 一人之下万人之上
还有一种情况是我们上面提到的例子, 我们还没有完全讲到的:批处理的情况。
从根本上来说, 这和我们刚才看的那个并没有什么不同, 不同的是, 我们并不是完全断开连接, 而是在时间耗尽的时候, 我们要做的是处理这批物品, 然后继续等待, 这次是下一批物品。
这就涉及到 Promise 的另一个局限性:它们代表的是一个待处理的结果。或者说, 换句话说, 它们可以被遵守或被打破, 但只能是一次。
然而, 在这种特殊情况下, 这给我们带来了一个问题。事实上, 我们已经在上面的片段中解决了这个问题:每当我们收到一个新的心跳, 我们就必须创建一个新的 $timer
。我们不能重置它。
由于 Timer::Breakable 包裹着 Promise, 所以它继承这个特性也就不足为奇了。
86.6. 每种需求都有供应
Supply 是一个异步数据流, 可以被我们程序中的多个观察者使用。我们已经用了一个来表示我们的不规则心跳系列, 这次我们将添加一个来表示准备处理的批次流。
use Timer::Breakable;
my $batcher = Supplier.new;
my $batch = $batcher.Supply;
my $stream = Supply.interval(0.5).grep: { Bool.pick }
my Timer::Breakable $timeout;
my @batch;
react {
whenever $batch {
say "Received a batch: { @batch.join: ' ' }";
@batch = ();
}
whenever $stream {
say "Queuing $_";
@batch.push: $_;
.stop with $timeout;
$timeout = Timer::Breakable.start: 0.75, {
$batcher.emit: True
}
}
}
# OUTPUT:
# Queuing 0
# Received a batch: 0
# Queuing 2
# Received a batch: 2
# Queuing 8
# Queuing 9
# Queuing 10
# Received a batch: 8 9 10
# ...
这将触发该批次的处理, 得到重置, 我们可以等待下一批。
为此, 我们需要一个表示定时事件流的东西(像 Supply), 每个事件都可以被取消(像 Timer::Breakable)。
像 Timer::Stopwatch 这样的东西。
86.7. 今天晚上绕着走
上面的例子可以用 Timer::Stopwatch 重新编写, 避免了很多信号管理的模板。
use Timer::Stopwatch;
my $stream = Supply.interval(0.5).grep: { Bool.pick }
my Timer::Stopwatch $timer .= new;
my @batch;
react {
whenever $timer { # This implictly listens to $timer.Supply
say "Received a batch: { @batch.join: ' ' }";
@batch = ();
}
whenever $stream {
say "Queuing $_";
@batch.push: $_;
$timer.reset: 0.75;
}
}
# OUTPUT:
# Queuing 1
# Queuing 2
# Received a batch: 1 2
# Queuing 6
# Received a batch: 6
# Queuing 8
# Queuing 9
# Queuing 10
# Received a batch: 8 9 10
# ...
这里的区别是, 我们有一个 whenever
直接监听定时器(它隐含地调用 .Supply
在它身上等待它的内部 Supply), 还有一个单独的监听流中的事件。当一个新的事件通过流到达时, 我们将其添加到出 @batch
并重置定时器。
这应该会让这样的情况表示得更简单, 对于更复杂的情况, 它还包括了确定重置一个定时器是否中断了正在运行的定时器的方法。它可以像这个例子一样, 作为一个倒计时器使用, 也可以作为一个开放式定时器使用。就像它环绕着一个 Supply 来代表经过它的事件流一样, 它也环绕着一个 Promise 来代表定时器的寿命, 它可以不可逆地停止。
以前使用过 Go 的开发者可能会发现它的接口很熟悉, 因为 Timer::Stopwatch 是为了模仿 Go 的 time.Timer
的大部分接口而设计的。事实上, 它已经被证明是非常有效的将在 Go 中编写的行为移植到更简单的 Raku 代码中, 合理地利用我们一直在讨论的 Raku 类所提供的能力。
86.8. 对 Raku 的感觉
在我开始认真编写 Raku 之前, 我经常会花一些时间在这里和那里浏览文档, 对似乎无穷无尽的类型和类的景观感到惊奇, 这些类型和类代表了一系列在我看来非常微妙的差异。我不知道自己是否有足够的知识来自信地决定 Map、Bag 或 Hash 是否是正确的工具(更不用说 SetHash 或 BagHash了)。
事实证明, 在写 Timer::Stopwatch 时, 我学到的最有启发性的东西之一就是, 虽然 Raku 很庞大, 但并不是难以管理的。而且它的多功能性意味着你可以使用你熟悉的工具, 如果你愿意的话, 还可以花时间去探索新事物。这毕竟是有不止一种方法的本质。
当你这样做的时候, 你也会感觉到不同的类型是为了代表什么, 更重要的是, 他们不是什么。也就有了对 Raku 本身的感觉。我慢慢的觉得我开始明白什么是 Raku 的感觉。
如果你刚开始接触 Raku, 我在本文中提到的概念可能会显得很混乱。它有时可能看起来像有太多的选择, 太多的可能性, 这可能是吓人的。我知道对我来说是这样的。
但我敢保证, 乐乐从外面看感觉更大, 而一旦进入, 它的大小感觉就不像威胁, 而更像是一种邀请。
谁知道在下一次点击鼠标之后, 还有什么在等着呢?
只有时间会告诉我们。
87. 第十三天 - 帮助 Github Action 的小精灵们
作为一个 Raku 编程语言模块的开发者, 你有时会对你所使用的工具感到惊讶。在这种情况下, 你真的被 Shoichi Kaji 的优秀的 App::Mi6 工具最近的一次更新所惊讶。在升级之后, 它开始在新的发行版中添加一个 .github/workflows/test.yml
文件。而这又导致 Github 在每次提交后使用 Github Actions 测试发行版。这很好, 尤其是当它发现问题的时候!
那么这个文件是由什么组成的呢?
name: test
on:
push:
branches:
- '*'
tags-ignore:
- '*'
pull_request:
jobs:
raku:
strategy:
matrix:
os:
- ubuntu-latest
- macOS-latest
- windows-latest
raku-version:
- 'latest'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: Raku/setup-raku@v1
with:
raku-version: ${{ matrix.raku-version }}
- name: Install Dependencies
run: zef install --/test --test-depends --deps-only .
- name: Install App::Prove6
run: zef install --/test App::Prove6
- name: Run Tests
run: prove6 -l t
里面有相当多的 YAML! 但要点基本明确:在最新的 Ubuntu/MacOS/Windows 操作系统上运行该模块的测试, 并使用最新的 Raku 版本。看到你的模块自动获得持续集成支持是多么的容易, 真的是太好了。
当然, 作为模块开发者, 如果一个模块的测试失败了, 你会收到一个通知(在这种情况下是一封电子邮件)。我就是这样发现, 很多依靠 NativeCall 调用 C 库函数的模块, 如果依靠 POSIX 语义, 在 Windows 上根本就会失败。
87.1. 喜欢绿灯
看到绿色总是好的, 比如 Hash::LRU 的测试结果。但有件事让你们印象深刻:测试时间超过5分钟?从每一步的时间来看, "安装 App::Prove6"这一步用了3分多钟! 而模块的实际测试只运行了2秒。看起来有很多的开销, 特别是当一个模块没有任何外部依赖的时候。
现在, 当我在本地测试模块时, 我通常是这样做的:
raku -Ilib t/01-basic.t
# or whatever test-file that shows a problem
为什么要这样做呢?嗯, 其实是因为这样可以在测试文件中直接添加任何调试代码, 在出现故障的情况下, 更容易追踪到错误。而如果有执行错误, --ll-exception
通常也会被添加到调用中, 以获得更显而易见的回溯, 比如这样:
raku -Ilib --ll-exception t/01-basic.t
# make sure we get a *full* backtrace
然而, 如果持续集成测试出现执行错误, 那么你通常不会得到完整的回溯, 这往往无助于追踪问题。尤其是当你无法在本地重现问题的时候。
87.2. 让事情变得更快、更好、更经济
那么, 为什么不把这个手动工作流程嵌入一个漂亮的脚本, 并将其添加到发行版中呢?并确保只有那个脚本才会被运行?这似乎是一个很容易实现的想法。而事实也的确如此。这个脚本(称为 run-test)基本上变成了(为了这篇博文而略微缩短了)。
my @failed;
my $done = 0;
for "t".IO.dir(:test(*.ends-with: '.t' | '.rakutest')).map(*.Str).sort {
say "=== $_";
my $proc = run "raku", "--ll-exception", "-Ilib", $_, :out, :merge;
if $proc {
$proc.out.slurp;
}
else {
@failed.push($_);
if $proc.out.slurp -> $output {
say $output;
}
else {
say "No output received, exit-code $proc.exitcode()";
}
}
$done++;
}
if @failed {
say "FAILED: {+@failed} of $done:";
say " $_" for @failed;
exit +@failed;
}
say "\nALL {"$done " if $done > 1}OK";
而 YAML 文件的最后6行被改成。
- name: Run Tests
run: raku run-tests
测试 Hash::LRU 模块的总时间通常降到一分钟以下。这节省了大量的时间和 CPU 周期! 当然, 如果有需要安装的依赖关系, 节省的时间会更少。但更短的周转时间, 以及在出错时看到完整的回溯, 对你们的帮助是绝对的。而且作为奖励, 现在在本地的测试也变得更加简单和干净, 尤其是在一切顺利的情况下。
Welcome to Rakudo(tm) v2020.11.
Implementing the Raku(tm) programming language v6.d.
Built on MoarVM version 2020.11.
Testing Hash-LRU
=== t/01-basic.t
ALL OK
那么你是否应该把这个测试文件复制到你的发行版中, 并相应地调整 Github Actions YAML 呢?也许是这样的。但也许最好是找出最适合你作为模块开发者的方法。并在这个想法的基础上进行改进。也许从复制运行 run-tests 开始, 然后根据自己的喜好进行调整。无论什么对您来说都是最好的!
87.3. Raku 新手?
如果您是 Raku 的新手, 您可能会喜欢一些关于 run-tests
脚本实际作用的解释。所以, 这里就开始了:
my @failed;
my $done = 0;
设置一个 @failed
数组, 用于保存因某种原因而失败的测试文件的名称, 以及一个 $done
计数器, 用于记录已完成的测试文件的数量。
for "t".IO.dir(:test(*.ends-with: '.t' | '.rakutest')).map(*.Str).sort {
如果你是 Raku 的新手, 这可能是最难理解的。它所做的, 基本上是寻找 "t" 目录下的 "t".IO, 然后开始寻找扩展名为 ".t" 或 ".rakutest" 的文件 .dir(:test(*.ends-with: '.t' | '.rakutest')), 将产生的 IO::Path
对象改为 string.map(*.Str)
, 然后对这些文件进行排序, 并在它们之间循环 for … {
say "=== $_";
my $proc = run "raku", "--ll-exception", "-Ilib", $_, :out, :merge;
显示正在测试哪个测试文件 say "===$_";
并运行实际的实际测试文件运行 "raku", "--ll-exception", "-Ilib", $_, 并确保它的 STDOUT 和 STDERR 输出成为一个单一的流 :out
, :merge
; 并把产生的 Proc 对象放到 my $proc
中。
if $proc {
$proc.out.slurp;
}
如果测试文件的运行成功, if $proc
, 那么只需要吃掉所有的输出, 不要对它做任何事情 $proc.out.slurp
。
else {
@failed.push($_);
if $proc.out.slurp -> $output {
say $output;
}
else {
say "No output received, exit-code $proc.exitcode()";
}
}
如果不成功否则, 那么将失败的测试文件的名字添加到失败的测试列表中 @failed.push($_)。如果有任何输出, if $proc.out.slurp, 将其存储在一个变量 -> $output 中, 并将其显示给世界 say $output。如果没有其他输出, 用 exitcode 让世界知道没有输出, +++say "No output received, exit-code $proc.exitcode()"。
$done++;
}
记住我们已经做了一个测试文件, 不管成功与否 $done++
。
if @failed {
say "FAILED: {+@failed} of $done:";
say " $_" for @failed;
exit +@failed;
}
如果有任何测试文件失败, if @failed
, 告诉世界有多少失败的 say "FAILED: {+@failed} of $done:", 并显示失败的测试文件的名称 say " $_" for @failed
, 然后退出脚本表示错误状态 exit +@failed
与 TAP 协议一致。
say "\nALL {"$done " if $done > 1}OK";
如果我们做到了这里, 已经全部 OK 了, 所以用文件的数量来显示, 但只有当它是一个以上的 "$done " if $done > 1
。
87.4. 结束语
只要你稍加努力, 就能让自己和 Github Action 的精灵们更轻松。而且也要多考虑环境, 因为太多精灵太辛苦了, 对环境不好!
88. 第十四天 - 编写更快的 Raku 代码, 第一部分
去年, 在 Perl 的土地上, 我讨论了我试图优化表达式解析器性能的结果, 它是我的基于 Perl 的 Fortran 源到源编译器的一部分。表达式解析器将代表编程语言(在我的例子中是 Fortran)中表达式的字符串转化为称为解析树的数据结构, 编译器使用它来进一步分析和生成代码。
最近我写了不少 Raku 代码, 但到目前为止我还没有研究过它的性能。出于好奇, 我决定在 Raku 中重写并优化这个 Fortran 表达式解析器。
88.1. 表达式解析
我通俗地称之为表达式解析器, 其实就是一个词法器和解析器的结合体:它把一串源码变成一个树状的数据结构, 这个结构表达了表达式的结构和其组成成分的目的。例如如果表达式是 2*v+1
, 那么表达式解析器的结果将是一个数据结构, 它将顶层表达式识别为与整数常数 1 的乘法和整数常数 2 与变量 v 的乘法。
那么我们如何在 Raku 中构建一个快速的表达式解析器呢?在文章的第一部分, 我将探讨一些需要考虑的选择和权衡。在后续的文章中, 我将讨论表达式解析器的实际实现。
88.2. Raku 性能测试
Raku 文档中有一个关于性能的页面, 提供了很好的一般性建议。但是对于我的需求来说, 我没有找到关于我可能必须做出的具体权衡的答案。所以我创建了一些简单的测试案例来了解更多。我使用了建立在 MoarVM 2020.09 版本上的 Raku 版本, 也就是我运行测试时的最新版本, 但对于稍早和稍晚的版本, 结果应该是相当相似的, 至少在新的 RakuAST 模型完成之前是这样, 因为这可能会对性能产生很大的影响。
我使用一系列不同情况的小测试台测试性能, 由命令行参数控制, 使用时间命令获得墙钟时间, 并取 5 次运行的平均值。比如说
$time raku test_hash_vs_regex.raku 1
办法不止一种, 但只有一种才是最快的办法
解析涉及到将字符串转化为其他数据结构, 所以要对数据结构以及将字符串转化为数据结构并对其进行操作的方法做出很多决定。下面是一些影响编译器设计决策的性能比较结果。我很想知道它们在 Raku 中是否会有不同的结果。
88.2.1. 哈希键测试比 regexp 匹配更快
Fortran 代码基本上是由一个语句列表组成的, 其中可以包含表达式, 在我的编译器中, 语句解析器用一个 hashmap 给每个语句都贴上一次标签。每一行解析后的代码都会以原始字符串 $src_line
与这个哈希图的对子形式存储, 称为 $info
。
my $parsed_line = [ $src_line, $info ];
存储在 $info
中的标签和值取决于语句的类型。先验地不清楚使用 regex 匹配 $src_line
中的模式是比查找 $info
中的相应标签快还是慢。所以我测试了哈希键测试与 regexp 匹配的性能, 使用了一些真正的 FORTRAN 77 代码, 一个 READ I/O 调用, 在 $info
中标记为 ReadCall。
my $str = lc('READ( 1, 2, ERR=8, END=9, IOSTAT=N ) X');
my $info = {};
if ($str~~/read/) {
$info<ReadCall> = 1;
}
my $count=0;
constant NITERS = 10_000_000;
if CASE==1 {
for 1..NITERS -> $i {
# regexp
if ($str~~/read/) {
$count+=$i;
}
}
} elsif CASE==2 {
for 1..NITERS -> $i {
# hash lookup
if ($info<ReadCall>:exists) {
$count+=$i;
}
}
} elsif CASE==3 {
for 1..NITERS -> $i {
# overhead
$count+=$i;
}
}
如果没有 if 条件(CASE==3), for 1..NITERS 循环在我的笔记本上需要 3 秒。所以实际的条件运算中, 哈希键存在性检查需要 2 秒, regexp 匹配需要 50 秒。
结果是:测试哈希键比简单的 regexp 匹配快 25 倍。所以我们用一些内存来换取计算:我们用一个 regexp 识别一次语句, 并将识别标签存储在 $info
中以备后续传递。
88.2.2. 解析树的快速数据结构:整数与字符串的比较
解析表达式的数据结构的选择很重要。由于我们需要一个树状的有序数据结构, 所以必须是一个对象或者一个列表状的数据结构。但是对象在很慢, 所以我使用了嵌套数组。
['+',
['*',
2,
['$','v']
],
1
]
如果你不需要在上面做很多工作的话, 这种数据结构是可以的。然而, 由于每个节点都用字符串标记, 所以针对节点类型的测试是一个字符串比较。仅仅针对一个常量字符串或整数进行测试是不够好的, 因为编译器可能会把这个优化掉。所以我进行了如下测试, 以确保 $str
和 $c
在每次迭代时都能得到一个新的值。
if CASE==1 { # 7.3 - 5.3 = 2 s net
for 1 .. NITERS -> $i {
# string equality
my $str = chr($i % 43);
if $str eq '*' {
$count+=$i;
}
}
}
elsif CASE==2 { # 3.3 - 3.1 = 0.3
for 1..NITERS -> $i {
# int equality
my $c = $i % 43;
if $c == 42 {
$count+=$i;
}
}
} elsif CASE==3 { # 5.3
for 1..NITERS -> $i {
# string equality overhead
my $str = chr($i % 43);
}
} elsif CASE==4 { # 3.1
for 1..NITERS -> $i {
# int equality overhead
my $c = $i % 43;
}
}
我根据循环迭代器填充字符串或整数, 然后与一个常量字符串或整数进行比较。通过减去赋值所花费的时间(情况 3 和 4), 我得到了比较的实际时间。
在我的笔记本上, 有字符串比较的版本净耗时 2s, 整数比较耗时 0.3s, 所以做字符串比较比做整数比较至少要慢 5 倍。因此我的数据结构使用的是整数标签。另外, 我给常量贴上标签, 这样我可以给字符串、整数和实数常量贴上不同的标签, 也是因为这样一来, 所有的节点都是数组。这样就避免了要测试一个节点是数组还是标量, 这是个很慢的操作。
所以, 这个例子就变成了 .NET。
[ 3,
[ 5,
[ 29, '2' ],
[ 2, 'v' ]
],
[ 29, '1' ]
]
可读性较差, 但速度较快, 易于扩展。在下面的内容中, 我所说的解析树就是这个数据结构。
结果是:字符串比较比做整数比较至少慢 5 倍。
88.2.3. 自定义树遍历更快
我测试了使用高阶函数进行解析树遍历(递归下降)的成本。基本上, 这是使用高阶函数的通用遍历之间的选择, 高阶函数采取对解析树节点进行操作的任意函数。
sub _traverse_ast_with_action($ast_, $acc_, &f) {
my $ast=$ast_; my $acc=$acc_;
if <cond> {
$acc=&f($ast,$acc);
} else {
$acc=&f($ast,$acc);
for 1 .. $ast.elems - 1 -> $idx {
(my $entry, $acc) =
_traverse_ast_with_action($ast[$idx],$acc, &f);
$ast[$idx] = $entry;
}
}
return ($ast, $acc);
}
或自定义遍历。
sub _traverse_ast_custom($ast_, $acc_) {
my $ast=$ast_; my $acc=$acc_;
if <cond> {
$acc=< custom code acting on $ast and $acc>;
} else {
$acc=< custom code acting on $ast and $acc>;
for 1 .. $ast.elems - 1 -> $idx {
(my $entry, $acc) =
_traverse_ast_custom($ast[$idx],$acc);
$ast[$idx] = $entry;
}
}
return ($ast, $acc);
}
对于我的编译器中解析树数据结构的情况, 高阶实现的时间是自定义遍历的两倍多, 所以为了性能, 这不是一个好的选择。因此我在解析器中不使用高阶函数, 但在后面的重构通证中会使用。
结果是:高阶递归的实现所花费的时间是自定义遍历的两倍以上。
88.2.4. 处理列表的最快方法
在我的编译器中, Fortran 程序的内部表示是一个 [ $src_line, $info ]
对的列表, 而 $info
哈希则以嵌套数组的形式存储解析树。所以在列表和数组中迭代是影响性能的一个主要因素。
Raku 有几种方法来迭代一个类似列表的数据结构。我测试了其中的六种方法, 如下。
constant NITERS = 2_000_000;
if CASE==0 { # 6.2 s
# map
my @src = map {$_}, 1 .. NITERS;
my @res = map {2*$_+1}, @src;
} elsif CASE==1 { # 7.9 s
# for each elt in list
my @res=();
my @src=();
for 1..NITERS -> $elt {
push @src, $elt;
}
for @src -> $elt {
push @res, 2*$elt+1;
}
} elsif CASE==2 { # 6.2 s
# for with index
my @res=();
my @src=();
for 0..NITERS-1 -> $idx {
my $elt=$idx+1;
@src[$idx] = $elt;
}
for 0..NITERS-1 -> $idx {
my $elt=@src[$idx];
@res[$idx] = 2*$elt+1;
}
} elsif CASE==3 { # 11.0
# loop (C-style)
my @res=();
my @src=();
loop (my $idx=0;$idx < NITERS;++$idx) {
my $elt=$idx+1;
@src[$idx] = $elt;
}
loop (my $idx2=0;$idx2 < NITERS;++$idx2) {
my $elt=@src[$idx2];
@res[$idx2] = 2*$elt+1;
}
} elsif CASE==4 { # 3.7 s
# postfix for with push
my @src = ();
my @res=();
push @src, $_ for 1 .. NITERS;
push @res, 2*$_+1 for @src;
} elsif CASE==5 { # 3.5 s
# comprehension
my @src = ($_ for 1 .. NITERS);
my @res= (2*$_+1 for @src);
}
最快的方法是使用列表理解(情况 5, 3.5 秒), 紧随其后的是后缀式 for(情况 4, 3.7 秒)。C 式循环构造(情况 3)是最慢的(11 秒)。映射版本与基于索引的 for 循环的表现相同(都是 6.2 s)。有点奇怪的是, 基于列表的 for 循环, 可能是最常见的循环构造, 却比这两种构造慢(7.9 秒)。
结果是:列表理解最快, 几乎是 for 循环或地图的两倍。C 式循环非常慢。
88.3. 到目前为止的结论
通过这组比较多样的实验, 我们了解到以下几点。
-
测试哈希键的速度是 regexp 匹配的 25 倍 所以匹配一次并存储到哈希中去.
-
字符串比较比做整数比较至少慢 5 倍, 所以如果你在乎速度, 请优先选择整数比较。
-
高阶实现递归下降的时间是自定义遍历的两倍以上。所以复制粘贴比抽象出来的快。
-
列表理解最快, 几乎是 for-loop 或 map 的两倍。C 式循环非常慢。所以要想最快的列表迭代, 就用理解式。
除了最后一条, 这些结论和 Perl 的结论是一样的。在后续的文章中, 我们会看解析字符串的性能和表达式解析器的最终设计。
所有测试的代码都可以在我的 GitHub repo 中找到。
89. 第十五天 - 用 Physics::Navigation 找到回家的路
所以, 鲁道夫一直在担心, 在经过疲惫不堪的飞行后, 如何把圣诞老人和其他驯鹿送回北极, 去看望地球上所有(乖巧的)孩子。
他听过一个传言, 说由于地心熔铁的预演, 北极点一直在移动, 每年它都会绕着位于真正北极点的圣诞老人的工作间蠕动一下。
幸好他参加过导航技能课程, 了解了如何利用经纬度组合来指定地球仪上的位置。然而, 这些似乎都是糊弄人的, 因为它们很像, 却又不同。鲁迪需要的是一种组织导航的方法, 以确保他不会把它们混为一谈。更好的是, 他与拉里是好朋友, 他知道他可以信任 Raku 的类型系统, 让他回家。事实上, Raku 有很多方法可以让驯鹿|开发者的生活变得更好, 在 https://www.raku.org, 了解更多。
让我们看看他是怎么做的。
use Physics::Unit;
use Physics::Measure;
class NavAngle is Angle {
has Unit $.units is rw where *.name eq '°';
multi method new( Str:D $s ) {
my ($decimal, $compass) = NavAngle.defn-extract( $s );
my $type;
given $compass {
when <N S>.any { $type = 'Latitude' }
when <E W>.any { $type = 'Longitude' }
when <M T H>.any { $type = 'Bearing' }
default { nextsame }
}
::($type).new( value => $nominal, compass => $compass );
}
method defn-extract( NavAngle:U: Str:D $s ) {
# handle degrees-minutes-seconds <°> is U+00B0 <′> is U+2032 <″> is U+2033
unless $s ~~ /(\d*)\°(\d*)\′?(\d*)\″?\w*(<[NSEWMTH]>)/ { return 0 };
my $deg where 0 <= * < 360 = $0 % 360;
my $min where 0 <= * < 60 = $1 // 0;
my $sec where 0 <= * < 60 = $2 // 0;
my $decimal = ( ($deg * 3600) + ($min * 60) + $sec ) / 3600;
my $compass = ~$3;
say "NA extracting «$s»: value is $deg°$min′$sec″, compass is $compass" if $db;
return($decimal, $compass)
}
method Str {
my ( $deg, $min ) = self.dms( :no-secs );
$deg = sprintf( "%03d", $deg );
qq{$deg° $.compass}
}
}
# real code at https://github.com/p6steve/raku-Physics-Navigation (work in progress)
所以 Rudi 创建了一个 NavAngle 类, 通过编写 'NavAngle is Angle' 来继承 Physics::Unit 提供的 Angle 类, 并创建了一些通用方法, '知道' <N S> 是纬度, <E W> 是经度。还有 <M T H> 的概念, 代表 Bearing(稍后再谈)。在这里你还可以看到, Raku 有一个非常灵活的开关, 使用 'given-when-default' 关键字来指定控制流。
这个新类"有"一个属性定义 - $.units
。Raku 的 $.twigil 表示这是一个公共属性, 并自动提供访问器 get 和 set 方法, 不需要额外的代码。所以当你要设置值时, 'where' 约束会检查 $.units.name
是否等于 '°'。这样我们就可以强制执行我们的 NavAngle 对象是以度 '°' 来指定的, 并防止使用其他可用的角度单位, 如弧度或克。
这里展示了其他几个不错的 Raku 功能:(i) '::($type)' 名称插值允许将类型作为变量处理, 并以编程的方式进行操作;(ii) 参数捕获'( Str:D $s )' 检查函数参数的类型和定义性;(iii) '= $1 // 0' 组合测试定义性, 从而分配一个默认值。Rudolph 很高兴地看到所有这些工具都能很好地融合在一个可理解的语言语法中。
89.1. 纬度和经度
现在基础知识已经到位, Rudolph 可以轻松地使用继承来定义 Latitude 和 Longitude 子类。
class Latitude is NavAngle {
has Real $.value is rw where 0 <= * <= 90;
has Str $.compass is rw where <N S>.any;
}
class Longitude is NavAngle {
has Real $.value is rw where 0 <= * <= 180;
has Str $.compass is rw where <E W>.any;
}
约束条件已经调整 - 现在子类有自己的 $.value
和 $.compass
属性 - 以反映每个子类的不同值限制。方括号相当于 ('N', 'S') - 它们的键入速度更快, 因为你不需要在每个单词周围使用引号。
Rudolph 可以通过使用标准的 Raku 构造函数创建一个新的 Latitude 类实例来设置他的纬度位置:
my $lat = Latitude.new( value => 45, compass => <N> );
say ~$lat; #OUTPUT 43° N`
但这是相当长篇大论, 他不耐烦回家了。好消息是, 他可以创建一个 Raku 自定义运算符, 让他轻松地从引号字符串中指定和初始化新实例。在这种情况下, 他决定使用 unicode 双鱼座’表情' - ♓️ …。
multi infix:<♓️> ( Any:U $left is rw, Str:D $right ) {
$left = NavAngle.new( $right );
}
现在, 他可以迅速地马不停蹄地输入自己的坐标。
my $lat ♓️ <55°30′30″S>; say ~$lat; # OUTPUT 55°30.5 S
my $long <♓️> <45°W>; say ~$long; # OUTPUT 45° W
89.2. Magnetic vs. True North
现在他知道自己在哪里了, 鲁道夫可以设定一个航向, 驶向北极的家。但是, 等等, 他如何调整他的指南针上的磁北和真北, 他的目的地之间的差异?
鲁道夫有另一个技巧在他(鹿角)的袖子。
class CompassAdjustment { ... } # predeclare since we want to refer to this class before we write it
# keyword 'our' used to declare package-wide variables
our $variation = 0; # optional variation (Compass-Adjustment)
our $deviation = 0; # optional deviation (Compass-Adjustment)
#| Bearing embodies the identity 'M = T + Vw', so...
#| Magnetic = True + Variation-West [+ Deviation-West]
class Bearing is NavAngle {
has Real $.value is rw where 0 <= * <= 360; # must be between 0 and 360 degrees
has Str $.compass where <M T>.any; # either Magnetic or True
method M { # output method always returns the Magnetic Bearing
if $.compass eq <M> { return( self ) }
else { return( self + ( $variation + $deviation ) ) }
}
method T { # output method always returns the True Bearing
if $.compass eq <T> { return( self ) }
else { return( self - ( $variation + $deviation ) ) }
}
sub check-same( $l, $r ) { # can only add/subtract where both are Magnetic or both are True
if $r ~~ CompassAdjustment {
return
}
if ! $l.compass eq $r.compass {
die "Cannot combine Bearings of different Types!"
}
}
# these math methods override the ones provided by Physics::Measure::Angle
# they handle the custom +/- infix operators defined in Physics::Measure
# they extend the (grand)parent methods with logic to handle the $.compass attributes
method add( $r is rw ) {
my $l = self;
check-same( $l, $r );
$l.value += $r.value;
$l.compass( $r.compass );
return $l
}
method subtract( $r is rw ) {
my $l = self;
check-same( $l, $r );
$l.value -= $r.value;
$l.compass( $r.compass );
return $l
}
}
# now we can finally write our CompassAdjustment class
# we had to wait until now since is also is a child of Bearing
class CompassAdjustment is Bearing {
has Real $.value is rw where -180 <= * <= 180; # the adjustment is up to 180 degrees either way
# we override the parent compass accessors since we want to provide extra logic for <W E>
# we want add/subtract to add W variations and subtract E variations
# we do this by storing the value as a signed Real and negating the return value when its <E>
multi method compass { # get compass
given $.value {
when * >= 0 { return <W>, 0 }
when * < 0 { return <E>, 1 }
}
}
multi method compass( Str $compass ) { # set compass
given $compass {
when <W> { } #no-op
when <E> { $.value = -$.value }
default { die "Compass-Adjustment must be <W E>.any" }
}
}
}
现在, 设置好罗盘变化后, 鲁道夫就可以输入他们的磁力罗盘读数, 并取回 Bearing to True North。
$Physics::Navigation::variation = CompassAdjustment.new( value => 7, compass => <W> );
my $bear <♓️> <43°30′30″M>;
say ~$bear; # OUTPUT 43°30.5 M
say ~$bear.T; # OUTPUT 43°37.5 T
圣诞老人甚至可以通过做加法或减法改变航向的方位来转向右舷或左舷。根据对象类型的不同, +/-
的非标准行为可能会令人惊讶 - 这是 ♓️ unicode 运算符的一个好处…它们作为一个警告, 警告语言突变潜伏在这些代码区域。
如果圣诞老人带了满满一雪橇不需要的铁质圣诞礼物(自行车、攀岩架、Meccano 套装等等)回家, 那么可以用 $Physics::Navigation::deviation
设置来解决这个问题。
最后, 圣诞老人、鲁道夫和其他驯鹿们可以在工作了一整夜之后, 在家里的火堆旁休息一下他们疲惫的骨头。
祝所有人(以及任何)p6steve 圣诞快乐。(p6 的发音是"物理学")
90. 第十六天 - 编写更快的 Raku 代码, 第二部分
这是关于在 Raku 中编写表达式解析器的后续文章。在上一篇文章中, 我解释了背景, 看了一些与解析的数据结构和处理它们的方式有关的基本性能比较:列表、解析树、递归下降和迭代。
在这篇文章中, 我们将看看各种处理字符串的方式的性能, 然后看看它是如何在表达式解析器中结合起来的。
90.1. 字符串处理:正则表达式、字符串比较还是列表操作?
我们应该如何解析乐乐中的表达式字符串?传统的构建表达式解析器的方法是使用有限状态机, 每次消耗一个字符(如果需要的话, 可以用一个或多个字符 look-ahead), 并跟踪字符串的识别部分。这在 C 等语言中是非常快的, 但在 Raku 中, 我不太确定, 因为在 Raku 中, 一个字符实际上是一个长度为 1 的字符串, 所以每一次针对字符的测试都是一次字符串比较。另一方面, Raku 有一个成熟的正则表达式引擎。然而另一种方法是把字符串变成一个数组, 然后用列表操作进行解析。很多可能性有待测试。
constant NITERS = 100_000;
my $str='This means we need a stack per type of operation and run until the end of the expression';
my @chrs = $str.comb;
if (CASE==0) { # 5.8 s
for 1 .. NITERS -> $ct {
# map on an array of characters
my @words=();
my $word='';
map(-> \c {
if (c ne ' ') {
$word ~= c;
} else {
push @words, $word;
$word='';
}
}, @chrs);
push @words, $word;
}
} elsif CASE==1 { # 2.7 s
for 1 .. NITERS -> $ct {
# while with index through a string
my @words=();
my $str='This means we need a stack per type of operation and run until the end of the expression';
while my $idx=$str.index( ' ' ) {
push @words, $str.substr(0,$idx);
$str .= substr($idx+1);
}
push @words, $str;
}
} elsif CASE==2 { # 11.7 s
for 1 .. NITERS -> $ct {
# while on an array of characters
my @words=();
my @chrs_ = @chrs;
my $word='';
while @chrs_ {
my $chr = shift @chrs_;
if ($chr ne ' ') {
$word~=$chr;
} else {
push @words, $word;
$word='';
}
}
push @words, $word;
}
} elsif CASE==3 { # 101 s
for 1 .. NITERS -> $ct {
# while on a string using a regexp
my @words=();
my $str='This means we need a stack per type of operation and run until the end of the expression';
while $str.Bool {
$str ~~ s/^$<w> = [ \w+ ]//;
if ($<w>.Bool) {
push @words, $<w>.Str;
}
else {
$str ~~ s/^\s+//;
}
}
}
} elsif CASE==4 { # 64 s
for 1 .. NITERS -> $ct {
# reduce on an array of characters
my \res = reduce(
-> \acc, \c {
if (c ne ' ') {
acc[0],acc[1] ~ c;
} else {
( |acc[0], acc[1] ),'';
}
}, ((),''), |@chrs);
my @words = |res[0],res[1];
}
对于基于列表的版本, 开销是 1.6s;对于基于字符串的版本, 开销是 0.8s。
结果是相当惊人的。显然, 到目前为止, regexp 版本是最慢的。这是一个惊喜, 因为在我的 Perl 实现中, regexp 版本的速度是次佳选择的两倍。从其他的实现来看, 基于字符串的 FSM 使用 index 和 substr 方法是目前最快的, 在没有开销的情况下, 它需要 1.9s s, 比 regexp 版本快了 50 多倍。基于地图的版本排在第二位, 但速度几乎是其两倍。令人惊讶的是, 实际上有点失望的是基于 reduce 的版本, 它的工作原理和基于 map 的版本一样, 但工作在不可变的数据上, 也非常慢, 64 s。
在任何情况下, 选择是很明确的。通过不减少字符串, 而是在字符串中移动索引, 可以使最快的版本稍微快一些(1.6 s 而不是 1.9 s)。然而, 对于完整的解析器, 我希望拥有 trim-leading 和 start-with 方法的便利性, 所以我选择消耗字符串。
90.2. 一个更快的表达式解析器
字符串解析和数据结构(带整数标识符的嵌套数组, 见第一篇文章)的选择做好了, 我们来关注一下整个算法的结构。理论上的细节就不多说了, 我们使用的是有限状态机, 所以基本的方法是循环通过一些状态, 并在每个状态下执行一个特定的动作。在 Perl 版本中, 这很简单, 因为我们使用正则表达式来识别标记, 所以大部分的状态转换都是隐式的。我想保持这种结构, 所以我用比较、索引和子串操作来模拟 regexp s/// 操作。
my $prev_lev=0;
my $lev=0;
my @ast=();
my $op;
my $state=0;
while (length($str)>0) {
# Match unary prefix operations
# Match terms
# Add prefix operations if matched
# Match binary operators
# Append to the AST
}
匹配规则和操作非常简单(我使用 <pattern>
和 <integer>
作为实际值的占位符)。以下是 Perl 版本, 供参考。
prefix operations:
if ( $str=~s/^<pattern>// ) { $state=<integer>; }
terms:
if ( $str=~s/^(<pattern>)// ) { $expr_ast=[<integer>,$1]; }
operators:
$prev_lev=$lev;
if ( $str=~s/^<pattern>// ) { $lev=<integer>; $op=<integer>; }
在 Raku 版本中, 我使用了 given/when 结构, 它和 if 语句一样快, 但更整洁一些。
prefix operations:
given $str {
when .starts-with(<token>) {
.=substr(<length of token>);
$state<integer>; }
terms:
given $str
when .starts-with(<token start>) {
$expr_ast=[<integer>,$term]; }
operators:
given $str {
when .starts-with(<token>) {
.=substr(<length of token>);
$lev=<integer>;
$op=<integer>;
}
需要匹配的一个比较复杂的模式是标识符后跟一个开头的小括号, 并带有可选的空格。使用正则表达式, 这种模式是:
if $str ~~ s:i/^ $<token> = [ [a .. z] \w*] \s* \( // {
my $var=$<token>.Str;
...
}
在没有正则表达式的情况下, 我们首先使用 'a' le .substr(0,1).lc le 'z'
检查 'a' 和 'z' 之间的字符。如果匹配, 我们将其从 $str
中删除, 并将其添加到 $var
中。然后我们进入一个 while 循环, 只要有字母数字或 '_' 字符就可以。然后我们去掉所有的空格, 并测试 '('。
when 'a' le (my $var = .substr(0,1)).lc le 'z' {
my $idx=1;
my $c = .substr($idx,1);
while 'a' le $c.lc le 'z' or $c eq '_'
or '0' le $c le '9' {
$var~=$c;
$c = .substr(++$idx,1);
}
.=substr($idx);
.=trim-leading;
if .starts-with('(') {
...
}
}
另一种复杂的模式是浮点数的模式。在 Fortran 中, 这种模式更加复杂, 因为子模式 .e
可以是浮点常量的一部分, 也可以是等价运算符 .eq.
的一部分。此外, mantissa 和指数之间的分隔符不仅可以是 e, 还可以是 d 或 q, 所以正则表达式相当复杂。
if (
(
!($str~~rx:i/^\d+\.eq/) and
$str~~s:i/^([\d*\.\d*][[e|d|q][\-|\+]?\d+]?)//
)
or
$str~~s:i/^(\d*[e|d|q][\-|\+]?\d+)//
) {
$real_const_str=$/.Str;
}
在没有正则表达式的情况下, 实现方法如下。我们首先检测 0 到 9 之间的字符或点。然后我们尝试匹配尾数、分隔符、符号和指数。后面三个是可选的;如果它们不存在, 并且尾数不包含点, 我们就匹配了一个整数。
when '0' le .substr(0,1) le '9' or .substr(0,1) eq '.' {
my $sep='';
my $sgn='';
my $exp='';
my $real_const_str='';
# first char of mantissa
my $mant = .substr(0,1);
# try and match more chars of mantissa
my $idx=1;
$h = .substr($idx,1);
while '0' le $h le '9' or $h eq '.' {
$mant ~=$h;
$h = .substr(++$idx,1);
}
$str .= substr($idx);
# reject .eq.
if not ($mant.ends-with('.') and .starts-with('eq',:i)) {
if $h.lc eq 'e' | 'd' | 'q' {
# we found a valid separator
$sep = $h;
my $idx=1;
$h =.substr(1,1);
# now check if there is a sign
if $h eq '-' or $h eq '+' {
++$idx;
$sgn = $h;
$h =.substr($idx,1);
}
# now check if there is an exponent
while '0' le $h le '9' {
++$idx;
$exp~=$h;
$h =.substr($idx,1);
}
$str .= substr($idx);
if $exp ne '' {
$real_const_str="$mant$sep$sgn$exp";
$expr_ast=[30,$real_const_str];
} else {
# parse error
}
} elsif index($mant,'.').Bool {
# a mantissa-only real number
$real_const_str=$mant;
$expr_ast=[30,$real_const_str];
}
else { # no dot and no sep, so an integer
$expr_ast=[29,$mant];
}
} else { # .eq., backtrack and carry on
$str ="$mant$str";
proceed;
}
}
最后一个关于如何处理模式的例子是比较和逻辑运算符中的空白。Fortran 有 <dot word dot>
形式的运算符, 例如 .lt.
和 .xor.
。但恼人的是, 它允许在点和字之间留白, 例如 .lt.
和 .xor.
使用正则表达式, 这当然很容易处理, 例如。
if $str~~s/^\.\s*ge\s*\.//) {
$lev=6;
$op=20;
}
我检查一个以点开始的模式, 并且在下一个点之前包含一个空格。然后, 我使用 trans
删除该子串中的所有空格, 并用这个修剪后的版本替换原始字符串。
when .starts-with('.') and .index( ' ' )
and (.index( ' ' ) < (my $eidx = .index('.',2 ))) {
# Find the keyword with spaces
my $match = .substr(0, $eidx+1);
# remove the spaces
$match .= trans( ' ' => '' );
# update the string
$str = $match ~ .substr( $eidx+1);
proceed;
}
90.3. 结论
总的来说, Raku 中优化后的表达式解析器与 Perl 版本还是非常接近的。关键的区别在于 Raku 版本没有使用正则表达式。通过上面的例子, 我想说明如何使用 Raku 内置的一些字符串操作来编写与正则表达式 s/// 操作功能相同的代码。
-
substr : substring
-
index:字符串中的一个子串的位置。
-
trim-leading : 条形前导空格。
-
starts-with
-
ends-with
-
trans : 用于使用 ' ' ⇒ '' 模式删除空白。
-
lc:用于范围测试, 而不是同时测试大写和小写。
-
le, lt, ge, gt: 用于非常方便的范围比较, 例如 'a' le $str le 'z' 。
当然, 由此产生的代码要长得多, 但可以说比正则表达式更易读, 目前速度快了四倍。
所有测试的代码都可以在我的 GitHub repo 中找到。
91. 第十七天 - 成为 Raku 中的时间领主
我大半辈子都住在一个时区边界的几分钟之内。我们区分时间的方式并不是用"东部"和"中部"时间的官方名称。不, 我们用的是更亲切(然而, 也更酷)的术语"快时"和"慢时"。知道你说的是哪个区域是非常重要的, 因为很多人像我母亲一样住在一个区域, 而在另一个区域工作。
当我开始研究在 Raku 中使用来自通用语言数据仓库(或 CLDR)的数据来实现国际化的 DateTime 格式化时, 我得出了一个相当令人惊讶的认识。Raku 并不理解时区。当然, DateTime 对象有 .timezone 方法, 但它只是 .offset 的别名, 用来计算与 GMT 的偏移。
我曾在一年中不同时间实行夏令时的国家生活过, 我的家人也在我自己的区域内不实行夏令时的地方生活过, 并且知道有些奇怪的地方与 GMT 有 30 分钟甚至 45 分钟的偏移, 我知道时区可能很复杂。
91.1. 宇宙之大, 浩浩荡荡, 错综复杂, 荒诞不经
有一个巨大的数据库, 简称 tz, 是一个巨大的时区数据库, 从什么时候发生过渡, 什么时候夏令时什么时候进退, 偏移, 一切。不像 Unicode 代码图, Raku 不包括这个作为其核心的一部分, 因为它的频繁更新和固有的不稳定性(yay政治家)。OTOH, 可能部分原因是它起源于现实生活中的 XKCD 漫画, 它确实包含了一些非常酷的老式程序员的思考, 我完全主张我们把它带回来(你最后一次看到一个代码库在他们的标题中引用文献是什么时候?Knuth?)
除了数据库之外, 还有一个标准的代码库 - 如果你使用的是 *nix
机器, 你的电脑上很可能就有一个 - 用来转换各种不同表示方式的时间, 同时考虑到时区。它是用 C 语言编写的, 所以它的可移植性很强。
我们可以采取简单的方式, 使用 NativeCall(一种在 Raku 中直接调用编译后的 C 代码的方式)来传递数据。但那有什么意思呢?相反, 我移植了代码。毕竟, 这个算法相当简单, 由大量的常量和一些基本的数学, 一些二进制搜索和一对条件组成, 但没有什么是在任何语言中不能完成的。很简单。
但是一旦完成了这些, 还是有一个问题。我们如何让 DateTime 理解时区?
91.2. 掌握时间
如上所述, Raku 的 DateTime 除了知道 GMT 偏移量是什么之外, 并不真正理解时区。我也许可以做一个新的 DateTimeTZ 类, 让人们在需要理解时区的模块中使用, 比如日期/时间格式化器, 但那样的话, 我就需要花很多时间来确保我的代码在两者之间的强转, 不接受/返回错误的, 而且…是的, 那会很烦人。另外, 即使我把它做成 DateTime 的一个子类, 因为大多数 DateTime 方法都会返回新的 DateTime 对象, 我需要覆盖几乎每一个方法, 即使这样, 如果有其他模块手动创建一个 DateTime, 时区信息也会丢失。
另一个选择是 augment DateTime, 给它一个新的 .timezone-id
和 .is-dst
方法。Augmenting 是在类的原始声明之外为其添加方法或属性的过程。但是看一个时间是不可能知道它的时区 ID 是什么的。虽然北美和南美通过偏移共享时区, 但它们的名称不同(夏令时的调整方式也不同)。我可以尝试推断, 就像 Intl::UserTimezone 所做的那样, 但那最多只能适用于用户当前地区的时区, 而且最终还是需要用户以某种方式来指定。另外, 当你增强一些东西时, 你会破坏预编译。Raku 预编译模块是为了减少启动时间, 这意味着使用时区与任何大型模块都会破坏你的启动时间, 特别是当你使用几个非常大的模块时。
必须有一个更好的解决方案。这个解决方案涉及到两个解决方案:一个是非常常见的, 一个是比较罕见的。
91.3. 添加一抹晃晃悠悠的 timey wiminess
首先需要做的是创建一个可以混入的角色, 也就是应用于一个类。角色传统上是用来描述或修改行为的(它们类似于 Java 的接口), 但它们也可以为现有的类添加额外的信息。角色还可以很好地允许类型检查, 就像基类一样, 所以通过在每个 DateTime 中混入一个角色, 应该不会有任何兼容性问题。一个简单的 Timezone 角色可能是这样的:
role TimezoneAware {
has $.olson-id;
has $.is-dst;
}
我的意思是, 这可以工作。我需要能够设置这些, 而且我宁愿不要用公共实例化方法来污染东西, 因为角色不能像类那样传递属性, 但它们可以被参数化。这可能是一种滥用, 但我们最终可以用这样的方法来实现…
role TimezoneAware[$tz-id,$dst] {
method olson-id { $tz-id }
method is-dst { $dst }
}
现在我们有办法让 DateTime 知道它的时区, 但是…我们如何应用它?要求用户手动说明 DateTime.new(…) does TimezoneAware[…]
会变得非常乏味, 尤其是他们无法控制可能从这些对象中创建的 DateTime 对象(因为 DateTime 是不可改变的, 任何调整, 比如 .later
会创建一个新的 DateTime 对象, 而这个对象不会有 mixin)。
91.4. 永远不要丢弃任何东西, Harry
我们可以通过使用 wrap
例程来实现这个功能(而且不需要放弃预编译!)。封装允许我们在调用时捕捉它, 并在必要时进行干预。
一个简单的封装程序, 只是让我们知道有东西被调用了。
Foo.^find_method('bar').wrap(
method (|c) {
say "Called 'bar' with arguments ", c;
my $result = callsame;
say " --> ", $result;
$result;
}
);
只要有人在 Foo 对象上调用 .bar
, Raku 就会输出参数是什么, 以及新做的对象, 而且还是返回, 这样就不会干扰程序流程。因为我们可以获得原始的结果, 然后对它做一些事情, 所以我们有机会混入我们的角色, 让它影响每一个被创建的 DateTime, 只要说 $result does TimezoneAware[…]
。
在使用这种技术时, 我发现了一个小问题, 这是由于 DateTime 的 .new
是一个多方法。使用 callsame(会把我们传给原始的 DateTime)会使用所有原始的参数, 这使得我们不可能添加新的参数, 比如 :daylight
或 :dst
或任何我们想调用的参数, 因为原始方法会拒绝它们。
不过如果我们使用 callwith, 我们可以删除这些额外的参数, 甚至可以在我们的时区处理要求的情况下进行修改(最终确实如此)。但是由于 wrap
与多方法的交互方式, 我们最终还是会再次调用被包装的方法!这也是我们的一个问题。当我在测试时, 我偶尔会得到一个应用了两三次作用的 DateTime。这可不好。
解决方法出奇的简单。当再次调用封装的方法时, 我只需要使用 callsame 来获得原始版本。但是, 我怎么知道我是否在第一次调用它呢?回想一下, 我们不能在添加参数的同时仍然使用 callsame!)Raku 的动态变量来救场了。在封装方法的开始, 我们做一个快速检查, 看看我们是要原始的还是封装的。
DateTime.^find_method('new').wrap(
method (|c) {
return (callsame) if $*USE-ORIGINAL;
...
}
);
不幸的是, 乐道编译的方式意味着我们实际上不能设置这个变量, 因为 my $*USE-ORIGINAL
必然会在后面出现。但是, 如果你还没有猜到, Raku 有一个解决方案, 我们知道这个变量会在调用链的某个地方。通过使用 psuedo-package CALLERS, 我们可以在调用链的上游找到这个变量, 而不会导致编译器在我们的作用域中安装它的符号。
DateTime.^find_method('new').wrap(
method (|c) {
return (callsame) if CALLERS::<$*USE-ORIGINAL>;
...
}
);
的确, 如果有人使用这个相同的名字, 可能会有问题, 因为 CALLERS 一直在调用链的上游。也许可以只用 CALLER::CALLER::<$*USE-ORIGINAL>
, 但使用 CALLER::
的次数可能并不太一致。对于实际的模块, 我选择了一个更不可能的名字 $*USE-ORIGINAL-DATETIME-NEW
。魔法变量不好, 我知道, 但晦涩难懂应该绰绰有余。
91.5. 维度超越论是荒谬的(但它是有效的)
callsame、callwith 等的一个问题是, 它们是在当前方法上工作的, 这就更难把事情做出来。有一些方法可以解决这个问题, 但我最终发现把所有逻辑都包含在一个方法中是最简单的。
为了完全模仿多方法, 而不调用我自己的子方法, 我使用了捕获和签名符。注意封装方法的签名为 |c
, 它将所有的参数收集到 c
中, 并允许对其进行检查。由于有两种方法可以有效地创建一个 DateTime, 让我们先解决最简单的一种:从一个数字中创建。
...
if c ~~ :(Instant:D $, *%)
|| c ~~ :(Int:D $, *%) {
my $posix = c.list.head;
$posix = $posix.to-posix if $posix ~~ Instant;
my $tz-id = c.hash<tz-id> // 'Etc/GMT';
my $time = localtime get-timezone-data($tz-id), $posix;
my $*CALL-ORIGINAL = True;
return callwith(self, $posix, :timezone($time.gmt-offset))
but TimezoneAware[$tz-id, $time.is-dst];
}
localtime 的结果是(目前)一个 Raku 等同于几乎所有 *nix
系统和时间库中使用的老的和无处不在的 tm 结构。因为我们已经有了 POSIX 时间, 所以我们只需要传入新的"时区"和混合角色就可以了。
不过有一个小麻烦。现在考虑一下下面的问题。
say DateTime.new(now).WHAT; # DateTime+{TimeZoneAware[…]}
呃… 这是一个名副其实的口水。有什么办法可以改变吗?事实证明, 有。我不会说你有必要应该这样做, 但是我们要尽可能的在后台。在返回之前, 我们要这样存储变量。
my $result = callwith( … ) but TimezoneAware[…];
$result.^name = 'DateTime';
return $result
Et violà, 它看起来完全正常, 除了它有那些额外的方法。我们的新 DateTime 将通过一个旧的方法, 即使有人进行基于名称的比较(当然, 他们应该使用可能使用 .isa
或 smartmatching, 这将不改变名称)。
现在我们解决第二种方法, 这是从被赋予离散时间单位。有一个 gmt-from-local 例程, 它将上述 tm 结构和一个时区一起接收, 并试图将两者协调起来, 以得到一个 POSIX 时间(如果你要求我在某一天的 2:30 前进……我们会有问题)。一旦我们有了 POSIX 时间, 那么我们就可以像以前一样创建东西。我不会告诉你所有这种类型的创建方式, 但是很容易想象(或者你可以在代码中查看)。
对于 new 之外的方法, 并没有太多的工作需要做。像 .day
, .month
等, 应该都是一样的, 因为原来的 DateTime 能理解GMT偏移。
重要的是 to-timezone, 我们需要做的, 其实就是把它包起来, 然后调用 .new(self, :$timezone)
。
91.6. 生活依赖于变化和更新
包裹实际上是一件非常普遍的事情:你无法在词法上对它进行范围化处理, 所以如果我们在 INIT(脚本启动时发射的 phaser)处进行包裹, 它的效果从一开始就会在全球范围内显现。除了……有两个 phaser 会在 INIT 之前发射, 它们是 BEGIN 和 CHECKER。它们是 BEGIN 和 CHECK, 它们在编译阶段发射, 我们无法触及它们。如果有人在这些区块中创建一个 DateTime, 它仍然是一个普通的 DateTime, 没有我们的 mixin。请考虑以下内容。
my $compile-time = BEGIN DateTime.new: now;
我们应该如何处理这个问题?如果以后用上了, 就不会有用户可能依赖的属性了。我们该如何帮助解决这个问题呢?
首先, 如果用户调用 .day
, 不会有区别, 所以没有问题。但是如果用户调用, 比如说, olson-id, 我们就麻烦了。没有这样的方法。或者有吗?
Raku 对象有一个特殊的(psuedo)方法, 叫做 FALLBACK, 当一个未知的方法被调用时, 它就会被调用。如果还没有添加回落, 我们就不能把它包起来(例如 .^find_method('FALLBACK').wrap(…)
)。尽管如此, 同样的 HOW 给了我们 ^find_method
, 也给了我们 ^add_fallback
, 尽管它的语法有点棘手。
例如, 对于 Olson ID 方法, 我们可以做如下操作。
INIT DateTime.^add_fallback:
anon sub condition ($invocant, $method-name --> Bool ) { $method-name eq 'olson-id' }
anon sub calculator ($invocant, $method-name --> Callable) { method { … } };
如果条件子返回 True, 那么计算器中返回的方法就会被运行。现在, 即使这些老式的 DateTime 对象中的一个设法坚持下来, 我们也可以做一些事情。但我们能做什么呢?事实证明, 有很多…取决于。
我们可以尝试运行一组新的计算。如果同样老式的 DateTime 让我们定期调用它的方法, 那么我们就会浪费大量的 CPU 周期。相反, 我们实际上可以替换(或者…再生)这个对象! 虽然 rw
这个特质是相当著名的, 但鲜为人知的是, 它可以存在于调用者身上!唯一的问题是, 我们需要在调用者身上有一个新的特质。唯一的问题是, 我们需要为调用者提供一个标量容器, 这可以通过给它一个 sigil 来实现。
method ($self is rw: |c) {
$self = …
}
不过有一个小问题。如果 DateTime 不在一个容器中(例如, 它是一个常量), 我们不仅卡住了, 而且上面的方法会出错, 因为 is rw
需要一个可写的容器。在这种情况下, 我们需要回退到每次重新计算。代价不大。但是我们怎么能知道呢?或者让它工作, 因为上面的方法在不可写的容器下会出错?简单的答案:multi
方法。神奇的是, 如果你有两个相同的方法, 但对于 trait is rw
, 那么 dispatch 会优先选择 is rw
用于可写容器, 而另一个用于不可写。
multi method foo ($self is rw: |c) {
self = … # upgrade for faster calls later
}
multi method foo ($self: |c) {
calculate-with($self) # slower style here
}
问题是你不能传递一个 multi
方法。事实上, multi
方法只能在类声明里面正确声明和引用。解决方法是在 wrap
的括号外做一个 multi sub, 然后在 wrap
时用它的 sigiled self 来引用它。
proto sub foo (|) { * }
multi sub foo ($self is rw, |c) {
self = … # ^ notice the comma, subs don't have invocants,
} # but they're passed as the first argument
multi sub foo ($self, |c) {
calculate-with($self)
}
….wrap(&foo);
包裹、多重调度、一级函数, 太多的东西都在发生, 但我们避免了破坏预编译, 并设法不使用任何一个 MONKEY-TYPING(https://docs.raku.org/language/pragmas#index-entry-MONKEY-TYPINGpragma) 🙂。
91.7. 蝴蝶结很酷
还有很多其他的小细节可以给用户。其中一个主要的问题是方法和参数的名称。在上面的写法中, 我已经使用了一些名字, 但还可以使用其他名字。例如, 使用 .is-dst
与 .dst
来确定给定的时间是否在夏令时, 是否有什么本质上的好处?而在 is-
问题之外, 我们应该使用 dst、daylight, 还是像世界上大部分地区一样, 使用 summer-time?
抓取时区名称也会出现类似的问题。虽然 stock DateTime 有一个 .offset
方法来获取 GMT 偏移量, 但它也从 .timezone
提供了完全相同的信息。替代品可以是, 我上面用的, tz-id, timezone-id, 或者 olson-id(Olson 发明了做数据库时使用的 ID)。其实, 在这个问题上, 我已经作弊了, 略微作弊了。通过使用异构的 IntStr, 我们可以让一些东西在数字和字符串的上下文中发挥不同的功能。所以我们可以重写 timezone 返回 self.offset 但 self.timezone-id, 可能会给大家带来不错的 DWIM 功能。
一个看起来相当明显的是 .tz-abbr
, 它给出了 EST 或 PDT 这样的信息, 用于格式化。没有什么比让方法名来体现它所提供的东西更合适的了 🙂。
当创建一个 DateTime 时, 也可以指定一个格式。默认的格式遵循 ISO 标准, 但很多人发现, 例如 "CEST" 比 "+02:00" 更容易识别为欧洲标准。是否应该改变默认的格式?这是我还没有得出结论的一个问题。默认格式提供了一个标准的格式, 但是由于它可以改变, 所以没有理由让人期望(或者更重要的是, 依赖)它总是产生相同的字符串。
这些问题看似微不足道, 但 Raku 引以为豪的是, 核心和模块开发人员的文化是真正的打磨, 让事情变得简单。从代码的可读性、与其他模块和核心语言的集成、功能等一切都会得到考虑, Raku 尤其适合让开发者有能力去做对他们和用户都最好的事情。
91.8. 不要眨眼。眨都不眨一下
虽然我之前简单地提到过, 但值得重复的是, 为什么 Raku 本身不包含支持时区的开箱。时区不是固定的。政府和政治是对时区的定时的摇摆不定。我在美国, 就因为今天我预计夏令时从3月14日开始, 并不意味着国会或美国交通部长(!)明天不能改变事情。或者我的州, 独立于它周围的州, 可能会在那之前完全选择退出夏令时。世界上所有其他国家都会重复这样的做法。
任何有内置支持的东西都需要非常稳定(Unicode 字符数据库), 或者提前提供变化的提示(闰秒)。这是因为大多数人不会使用最先进的发行版。哼, 苹果还在发布2013年的 Perl 5.18.4 呢! 仅在2013年就有八次数据库更新, 从那时起到今天又有四十五次更新。即使是得到苹果更多更新爱的Python, 从苹果分发的最新版本(2.7.16)开始, 也有十二次更新。
这就是模块的闪光点:只要有更新, 就使用 zef 或其他模块管理器升级 DateTime::Timezones, 用户就可以随时保持更新。在维护方面, 我已经创建了一个脚本, 使整个更新过程自动化, 我只需要手动更改模块的版本号和更新文档。这也意味着, 如果我因为某些原因没有更新模块, 本地用户可以轻松地在本地自行更新数据库, 而对数据库变幻莫测的工作原理一无所知。
91.9. 事件二
如前所述, 我之所以参与这个项目, 是因为我的工作是将 CLDR 数据引入 Raku, 特别是格式化日期/时间。它所考虑的一件事是支持非格里高利历, 其中一些历法在计算方式上与格里高利历有很大不同。有一些像 Jean Forget 这样的人正在为 Raku 做这些工作, 但是他们目前作为他们自己的独立类存在, 不能与内置的 Date 和 DateTime 类互换。没有什么可以阻止任何人进一步扩展 DateTime 与上述方法, 以添加一个新的属性日历, 可以设置为 gregorian 或 hebrew 或 persian。这比我们这里的工作要复杂一些, 因为一些时间计算是硬编码到 DateTime 中的, 这将需要相当多的额外工作, 但这是在可能性的范围内。
我们的 Perl 兄弟想象了不同的模块, 这些模块共享共同的属性, 但是通过工作, 应该可以让一个 DateTime 在 Raku 中统治所有的模块(等等, 我正在改变文化参考点, 哎呀)。只有…嗯, 时间, 呃, 会告诉我们。
92. 第十八天 - 带类型的 Raku, 第二部分: 驯化状态
当我几年前开始学习 Raku 的时候, 第一个让我印象深刻的功能之一就是它的类型系统。这是一个我觉得被忽视的时候。一开始我发现这一点相当难以理解, 但我发现依靠严格的类型可以使代码更简单、更健壮, 随着时间的推移可以更好地应对变化。我将用国际象棋来演示这一点, 但首先要介绍一些基础知识。
92.1. 介绍类型和种类
在 Raku 中, 任何对象的类型都可以用 WHAT 来反省。
say 42.WHAT; # OUTPUT: (Int)
say WHAT 42 | 24; # OUTPUT: (Junction)
虽然我们想做这个事情的时候, 往往对它的类型名称比类型对象本身更感兴趣。
say 42.^name; # OUTPUT: Int
类型检查是对象的 HOW 所定义的行为之一。
say 42.HOW.^name; # OUTPUT: Perl6::Metamodel::ClassHOW
在类型理论的行话中, 这将是一种类型, 或类型的一种。
92.2. 运行时类型检查
偶尔会有需要手动进行类型检查的时候, 比如调试时。对类型对象进行智能匹配时, 默认情况下会执行类型检查。
say 42 | 24 ~~ Junction; # OUTPUT: True
say 42 | 24 ~~ Int; # OUTPUT: True
然而, 我们正在进行智能匹配;~~
可以有任何行为, 这取决于 RHS 的 ACCEPTS 方法是如何表现的。虽然这可以允许智能匹配对象与类型对象的连接, 但有时需要进行更多的类型检查。在这些情况下, Metamodel::Primitives.is_type 将会发挥作用。
say Metamodel::Primitives.is_type: 42 | 24, Junction; # OUTPUT: True
say Metamodel::Primitives.is_type: 42 | 24, Int; # OUTPUT: False
92.3. 类型化变量
任何变量、参数或属性都可以选择提供类型, 通常作为变量名称的前缀。对于 $-sigilled 变量, 表示其值的类型;对于 @-sigilled 变量, 表示列表值的类型;对于 %-sigilled 变量, 表示哈希值的类型;对于 &-sigilled 变量, 表示例程返回值的类型。特别是对于%-sigilled 变量, 可以在变量名后用大括号给出一个额外的键类型。
my Int $x = 0;
my Str @ss = <sup lmao>;
my Num %ns{Str} = :pi(π);
my True &is-cool = sub is-cool(Cool $x --> True) { };
另外, 值类型也可以使用 of
trait 来指定。
my $x of Int = 0;
my @ss of Str = <sup lmao>;
my %ns{Str} of Num = :pi(π);
my &is-cool of True = sub is-cool(Cool $x --> True) { };
等等, True 怎么会是一个有效的类型?True 是一个 Bool 枚举值, 它是一个 Cool 常量, 可以像类型一样使用, 具有 ACCEPTS 语义。字符串和数字字符也属于这一类。我们在这里给出一个作为返回值, 所以我们不需要从例程中显式返回任何东西, 因为它总是会返回 True。
92.4. 带定义的类型变量
Raku 提供了一种使用类型笑脸来限制类型值的方法, 这种类型笑脸被放在一个变量的类型之后。
my Int:U $type; # Contains an Int type object by default.
my Int:D $value = 42;
my Int:_ $nullish;
$nullish = $value;
:U 笑脸表示未定义的类型对象; :D 笑脸表示定义的值(或实例); :_ 笑脸表示非此即彼。
当一个类型没有给出类型笑脸时, 它们将默认使用 :_ 语义。这可以使用变量和属性实用名词(参数是 NYI)来定制。例如, 没有笑脸的类型可以被定义, 以使变量类型化的行为更类似于 Haskell 的类型, 比如这样。
use variables :D;
use attributes :D;
当涉及到 :U 和 :_ 变量的类型检查时, Nil 是一个例外。它不能被绑定到一个用这些笑脸符号类型化的变量上, 但是当用这些笑脸符号赋值或从例程中返回时, 你会得到它的类型对象而不是 Nil 本身。失败也是一个空类型, 失败可以从其他 :U/:_ 类型的例程中返回。
确定类型可以帮助防止常见的、恼人的运行时错误, 这些错误与把类型对象当作实例处理有关, 或者反之亦然。例如, 你可能已经看到过这样的警告。
put my $warning; # OUTPUT:
# Use of uninitialized value $message of type Any in string context.
# Methods .^name, .raku, .gist, or .say can be used to stringify it to something meaningful.
# in block <unit> at -e line 1
发出这个警告是 Mu 对类型对象的 Str 强转的默认行为。给变量一个 :D 类型可以帮助防止它的出现。
92.5. 用类对数据进行分组
如果我们要把一个棋子作为数据来表示, 我们可能会有一个颜色和一个棋子的类型。问题是我们如何键入这个类型?我们可能有一个 Str:D 的 Array:D 开始。
my Str:D @piece = 'white', 'pawn';
my Str:D $colour = @piece[0];
my Str:D $type = @piece[1];
不过 0 和 1 并不是颜色和类型的好名字。如果我们要把混合类型的数据分组, 我们就会陷入用一个比我们打算让它们有的更不具体的类型来打字。我们要的是类型! 我们可以把我们的数据捆绑在一起, 用一个类来对所有这些数据进行类型化。
class Chess::Piece {
has Str:D $.colour is required;
has Str:D $.type is required;
}
$.color 和 $.type 是 Chess::Piece 类的公共属性。属性是在 has 作用域内声明的, 公共属性有 . twigil, 私有属性有 ! twigil。因为我们对这些有 :D 类型化, 所以它们要么是必须的, 要么有一个默认值。此时我们不能对它们的值进行任何假设, 所以我们将这些标记为必填。
就像在传统的面向对象语言中一样, 可以用数据来构造一个类来产生一个值。新方法是我们可以用来做这件事的默认方法, 它接受与类的公共属性相对应的命名参数。
my Chess::Piece:D $pawn = Chess::Piece.new: :colour<white>, :type<pawn>;
我的棋子是棋子……因为我们有 Chess::Piece 作为 $pawn 的类型, 所以有基于方法调用的赋值语法糖, 我们可以用它来减少这个冗余。
my Chess::Piece:D $pawn .= new: :colour<white>, :type<pawn>;
my Str:D $colour = $pawn.colour;
my Str:D $type = $pawn.type;
我们可以用类似于方法调用的语法来访问 $pawn 的公共属性。事实上, 这些都是方法调用。Raku 在涉及到类的时候, 并不区分公共数据、私有数据和保护数据, 它总是私有的。我们所说的公有属性, 其实是带有自动生成 getter 方法的私有属性。
棋子可以存在于棋盘上的一个方块内, 方块带有颜色。这可能也是一个类。
class Chess::Square {
has Str:D $.colour is required;
has Chess::Piece:_ $.piece is rw;
}
my Chess::Square:D $a1 .= new: :colour(Black);
$square.piece .= new: :colour(White), :type(Rook);
我们给 Chess::Square 一个 rw $.piece 属性。is rw
是一个特性, 它使公共属性的 getter 更像是 getter 和 setter 的组合, 允许从 Chess::Square 外部对属性进行赋值。
我们将需要另一个类来处理棋盘本身。这将跟踪一格格的方块。
class Chess::Board {
has Chess::Square:D @.squares[8;8];
submethod BUILD(::?CLASS:D: --> Nil) { ... }
}
这封装了一个 @.squares 多维数组, 有 8 个文件(列)的 8 个行列(行)。我们支点了一个 BUILD 子方法, 它将在板子构造完成后初始化 @!squares。作为一个子方法, 它不会被任何潜在的子类继承。作为一个 …
存根, 当调用时将会失败(我们还没有准备好实现这个)。
通常我们需要的是方法, 而不是子方法。公共方法可以使用方法例程声明器来声明, 而不是子方法;私有方法的声明方式与公共方法相同, 但名称前缀为 !
;Raku 中没有保护方法。
在一个类的作用域内, 我们总会有 $?CLASS 和::?CLASS 符号作为它的别名, ::?CLASS 可以作为一种类型化, 而 $?CLASS 在其他情况下会比较好。在 BUILD 的签名中, :
前面的类型是一个调用类型。这是一个像其他参数一样的参数, 但我们没有给它起名字, 因为已经有一个默认的符号, 通常已经足够好了:self。
92.6. 用 Enums 关闭集合
目前我们用字符串来表示棋子的颜色和类型。字符串是用来表示文字的。在用这种方式表示时, 任何文本都可以被赋予颜色或类型, 但我们只能将黑色或白色作为棋子的有效颜色, 而只能将卒、车、主教、马、皇后和王作为类型。换句话说, 我们得到的值是人类可以理解的, 但计算机在这里并不能像我们希望的那样解释它们。我们可以用枚举来更好地表示这些。
enum Chess::Colour <White Black>;
enum Chess::Type <Pawn Rook Bishop Knight Queen King>;
class Chess::Piece {
has Chess::Colour:D $.colour is required;
has Chess::Type:D $.type is required;
}
class Chess::Square {
has Chess::Colour:D $.colour is required;
has Chess::Piece:_ $.piece is rw;
}
my Chess::Square:D $a1 .= new: :colour(Black);
$ai.piece .= new: :colour(White), :type(Bishop);
现在只能使用我们打算的棋子的颜色和类型。
默认情况下, 一个枚举的值列表中的索引将作为它的值, 同时还有一个键与枚举值的名称。
say White; # OUTPUT: White
say White.key; # OUTPUT: White
say White.value; # OUTPUT: 0
至于枚举值本身, 它们将是其枚举类型的实例, 并将始终等同于它们的值, 然而它们也将带有枚举类型化。
say White == 0; # OUTPUT: True
say White ~~ Enumeration:D; # OUTPUT: True
Enumeration 类型是导致 White 被 &say 输出为 White 而不是 0 的原因, 例如。
默认的索引值在棋子颜色和类型的情况下工作得很好, 但不是所有的情况。枚举可以有任何类型的值, 不过它的值在编写时只能是实例。这是默认情况下从枚举的值中推断出来的, 但当给出一个作用域声明符(my、our、unit 等)时, 可以明确地表示出来。
my Int enum Chess::Colour <White Black>;
92.7. 用 subset 约束类型和值
当移动一个棋子时, 我们需要把它的文件和等级的元组(在 Chess::Board 的 @.squares 中的索引)作为程序的参数。我们可以在 Chess::Position 类中用 Int:D 来表示这些参数。
class Chess::Position {
has Int:D $.file;
has Int:D $.rank;
}
但就像颜色和类型一样, 这太宽泛了;我们只想允许 0-7 的整数。我们也许可以用一个 a-h 的 Int 枚举来表示文件, 但我们没有任何符号来给出等级。我们可以使用 subset 来将其中一个的有效值限制在我们想要的范围内。
subset Chess::Index of Int:D where ^8;
class Chess::Position {
has Chess::Index $.file is required;
has Chess::Index $.rank is required;
}
这就通过运行时类型检查将 Int 限制在 0-7 的范围内。这里, Int:D 是我们子集的 refinee, ^8
是它的 refinement。对 Chess::Index 的智能匹配将首先对 LHS 和它的 refinee 进行类型检查, 如果成功的话, 再对 LHS 和 refinement 进行智能匹配。虽然我通常会在类型中包含一个类型笑脸, 但当 refinee 已经包含 :D 时, 写 Chess::Index:D 是多余的。
我们可以使用 where 子句以更特别的方式来写这个子集。这个变量声明大致相当于一个 Chess::Index 类型的变量。
my Int:D $file where ^8 = 0;
然而, 当用裸露的 where 子句代替像这样的显式子集时, 如果类型检查失败, 我们会得到一个不太可读的异常。
92.8. 总结
到目前为止, 我们有少量的国际象棋类型。虽然我们有与它们相关联的状态的类型安全表示, 但我们没有什么行为实现方式。在这一点上我们没有实现任何行为, 因为我们的类型是有缺陷的;我们对类的依赖会使我们最终得到的代码复杂化。我们首先关注 Raku 类型系统中限制性较强的方面, 这样我们就可以充分地利用更自由的方面, 这将是下一部分的重点。
93. 第十九天 - 带类型的 Raku, 第二部分: 驯化行为
在上一部分中, 我声称类型可以允许更流畅、更健壮的代码, 然后为国际象棋写了一堆限制性的类型, 不允许出现这种情况:
subset Chess::Index of Int:D where ^8;
class Chess::Position {
has Chess::Index $.file is required;
has Chess::Index $.rank is required;
}
enum Chess::Colour <White Black>;
enum Chess::Type <Pawn Bishop Rook Knight Queen King>;
class Chess::Piece {
has Chess::Colour:D $.colour is required;
has Chess::Type:D $.type is required;
}
class Chess::Square {
has Chess::Colour:D $.colour is required;
has Chess::Piece:_ $.piece is rw;
}
class Chess::Board {
has Chess::Square:D @.squares[8;8];
submethod BUILD(::?CLASS:D: --> Nil) { ... }
}
93.1. 多重分派的分支
在修正我们写的类型之前, 我们需要理解一个关键的概念, 但是我们不能在不先修正类型的情况下用国际象棋作为例子。相反, 我们将使用我的 Trait::Traced 模块中的一个小助手例程作为例子。
当渲染预强化的跟踪输出时, 可以在跟踪中出现的对象可以以几种不同的方式渲染:异常将被渲染为红色的异常名称, 失败将被渲染为黄色的异常名称, 而其他任何东西将被渲染为其 gist。我们可以写一个 &prettify
例程, 其条件是围绕值参数的类型, 我们假设它是 Any:_
。
sub prettify(Any:_ $value --> Str:D) {
if $value ~~ Exception:D {
"\e[31m$value.^name()\e[m"
} elsif $value ~~ Failure:D {
"\e[33m$value.exception.^name()\e[m"
} else {
$value.gist
}
}
我们有基于智能匹配语义的条件, 会影响我们最终得到的值, 所以也许我们有很多 when, 而不是很多 if。我们将用 given/when 模式重写这个。
sub prettify(Any:_ $value --> Str:D) {
given $value {
when Exception:D { "\e[31m$value.^name()\e[m" }
when Failure:D { "\e[33m$value.exception.^name()\e[m" }
default { $value.gist }
}
}
我们正在编写一个例程, 这个例程有可能为程序持续时间内发生的每一个跟踪事件而被调用;那个给定的块引入了一个我们不需要的额外作用域, 这带来的开销在这种情况下是不可接受的。因为我们已经有了一个块来工作( &prettify
本身), 如果我们把 $value
重命名为 $_, 我们就可以摆脱这个问题。
sub prettify(Any:_ $_ --> Str:D) {
when Exception:D { "\e[31m$_.^name()\e[m" }
when Failure:D { "\e[33m$_.exception.^name()\e[m" }
default { $_.gist }
}
但这读起来不是很好。当我们围绕着例程的参数类型来设置条件时, 将例程表示为不是一个例程, 而是多个具有不同签名的例程就成为一种可能。
multi sub prettify(Any:_ $value --> Str:D) { $value.gist }
multi sub prettify(Exception:D $exception --> Str:D) { "\e[31m$exception.^name()\e[m" }
multi sub prettify(Failure:D $failure --> Str:D) { "\e[33m$failure.exception.^name()\e[m" }
这里的每个 multi 都是 &prettify 例程的 dispatchee。当这个例程被调用时, 将选择具有给定参数类型检查的最特定签名的 dispatchee 并被调用, 如果没有匹配的, 将抛出一个类型检查异常。因为我们没有自己定义一个, 所以将为我们生成处理这个问题的 proto 例程。当我们想要更具体一点的东西时, 这个例程会用 :(|) 作为签名。
proto sub prettify(Any:_ --> Str:D) {*}
这就约束了 &prettify 的第一个参数的类型, 对其所有的 dispatchees 都是 Any:_。写 {*}
的地方在于当这个被调用时, 将得到的多调用;因为在这种情况下我们要做的就是这个, 所以我们可以把这个写在例程体的地方。
在 subset 的帮助下, 多重分派可以在程序中以一种更可扩展和可测试的方式来表示更简单的 if、when 或 with 分支。不过, 在某些情况下, 我们有办法避免让这些分支在运行时发生。
93.2. 共享角色
回到国际象棋, 我们的 Chess::Piece 和 Chess::Square 类型并不尽如人意, 因为我们将它们的 $.color 和 $.type 定义为属性, 所以我们会对每一步棋的相关条件进行评估。因为我们将它们的 $.color 和 $.type 定义为属性, 我们会在每一步棋中评估与之相关的条件。当我们确切地知道特定颜色和类型的棋子如何移动, 以及棋盘上每个方块的颜色是什么时, 这是不必要的。
从 Chess::Piece 开始, 我们或许可以取消定义与这些相关行为的 $.color 和 $.type 属性, 但这样做, 我们就失去了对 Chess::Piece 属性的直接访问。在这里用类创建一个层次化的类型系统是矫枉过正的。当我们想要共享而不是扩展行为时, 角色会是一个更合适的类型, 或者在本例中, 几个类型。
role Chess::Piece { }
role Chess::Piece[White, Pawn] {
method colour(::?CLASS:_: --> White) { }
method type(::?CLASS:_: --> Pawn) { }
}
role Chess::Piece[Black, Pawn] {
method colour(::?CLASS:_: --> Black) { }
method type(::?CLASS:_: --> Pawn) { }
}
role Chess::Piece[Chess::Colour:D $colour, Bishop] {
method colour(::?CLASS:_: --> Chess::Colour:D) { $colour }
method type(::?CLASS:_: --> Bishop) { }
}
role Chess::Piece[Chess::Colour:D $colour, Rook] {
method colour(::?CLASS:_: --> Chess::Colour:D) { $colour }
method type(::?CLASS:_: --> Rook) { }
}
role Chess::Piece[Chess::Colour:D $colour, Knight] {
method colour(::?CLASS:_: --> Chess::Colour:D) { $colour }
method type(::?CLASS:_: --> Knight) { }
}
role Chess::Piece[Chess::Colour:D $colour, Queen] {
method colour(::?CLASS:_: --> Chess::Colour:D) { $colour }
method type(::?CLASS:_: --> Queen) { }
}
role Chess::Piece[Chess::Colour:D $colour, King] {
method colour(::?CLASS:_: --> Chess::Colour:D) { $colour }
method type(::?CLASS:_: --> King) { }
}
角色是混合类型。我们可以声明任意数量的同名角色来组成一个角色组, 只要它们的类型参数不同且不重叠即可。有了两个类型参数, 我们就可以建立颜色和棋子类型之间的行为关系。第一个角色是一个例外, 它将包含有关 Chess::Square 中没有棋子的行为。我们在这里的类型类似于一个多重分派例程。事实上, 我们确实有多重分派, 只是发生在类型级别。
类似于类的 $?CLASS 和 ::?CLASS 符号, 我们有 $?ROLLE 和 ::?ROLLE 符号, 我们可以用来引用外部角色类型。角色除了参数化之外, 如果没有类在某个地方的帮助, 就不能做太多的事情, 所以我们最后还是会得到 $?CLASS 和 ::?CLASS 符号。在角色的上下文中, 这些都是通用类型, 可以由任何一个类来填充, 它最终会被混入其中。
作为混入类型, 角色的方法和属性并不真正属于角色本身, 而是属于它最终被混入的类。然而, Chess::Piece 是一个比较少见的情况, 我们实际上没有任何类可以将类型混入其中。这没关系, 因为角色是可以被惩罚的。默认情况下, 当我们试图在角色上调用 new 这样的方法时, 并不是对角色本身进行调用, 而是通过将角色混入一个空类而产生的双关。这种行为使得角色在大多数情况下可以像类一样使用。
和 Chess::Piece 一样, Chess::Square 的行为将以可预测的方式取决于它的颜色, 所以我们也有一个类型参数。
role Chess::Square[White] {
has Chess::Piece:_ $.piece is rw = Chess::Piece.^pun;
method colour(::?CLASS:_: --> White) { }
}
role Chess::Square[Black] {
has Chess::Piece:_ $.piece is rw = Chess::Piece.^pun;
method colour(::?CLASS:_: --> Black) { }
}
我们将方块的默认棋子改为 Chess::Piece 的双关语, 因为 Chess::Piece 指的是整个角色组, 而不是我们这里所说的单个双关语。
93.3. 再来一次总结
我们的类型已经整理好了, 现在我们可以从 Chess::Board.BUILD 设置一个棋盘了。
class Chess::Board {
# ...
submethod BUILD(::?CLASS:D: --> Nil) {
@!squares = |(
(flat (Chess::Square[Black].new, Chess::Square[White].new) xx 4),
(flat (Chess::Square[White].new, Chess::Square[Black].new) xx 4),
) xx 4;
@!squares[0;0].piece = Chess::Piece[White, Rook].new;
@!squares[0;1].piece = Chess::Piece[White, Knight].new;
@!squares[0;2].piece = Chess::Piece[White, Bishop].new;
@!squares[0;3].piece = Chess::Piece[White, Queen].new;
@!squares[0;4].piece = Chess::Piece[White, King].new;
@!squares[0;5].piece = Chess::Piece[White, Bishop].new;
@!squares[0;6].piece = Chess::Piece[White, Knight].new;
@!squares[0;7].piece = Chess::Piece[White, Rook].new;
@!squares[1;$_].piece = Chess::Piece[White, Pawn].new for ^8;
@!squares[6;$_].piece = Chess::Piece[Black, Pawn].new for ^8;
@!squares[7;0].piece = Chess::Piece[Black, Rook].new;
@!squares[7;1].piece = Chess::Piece[Black, Knight].new;
@!squares[7;2].piece = Chess::Piece[Black, Bishop].new;
@!squares[7;3].piece = Chess::Piece[Black, King].new;
@!squares[7;4].piece = Chess::Piece[Black, Queen].new;
@!squares[7;5].piece = Chess::Piece[Black, Bishop].new;
@!squares[7;6].piece = Chess::Piece[Black, Knight].new;
@!squares[7;7].piece = Chess::Piece[Black, Rook].new;
}
}
这时, 如果我们能看到我们在做什么就好了。我们将在相关类型中为 gist 方法定义 dispatchees, 从 Chess::Piece 开始。
role Chess::Piece {
multi method gist(::?CLASS:U: --> ' ') { }
}
role Chess::Piece[White, Pawn] {
# ...
multi method gist(::?CLASS:D: --> '♙') { }
}
role Chess::Piece[Black, Pawn] {
# ...
multi method gist(::?CLASS:D: --> '♟︎') { }
}
role Chess::Piece[Chess::Colour:D $colour, Bishop] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
my constant %BISHOPS = :{ (White) => '♗', (Black) => '♝' };
%BISHOPS{$colour}
}
}
role Chess::Piece[Chess::Colour:D $colour, Rook] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
my constant %ROOKS = :{ (White) => '♖', (Black) => '♜' };
%ROOKS{$colour}
}
}
role Chess::Piece[Chess::Colour:D $colour, Knight] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
my constant %KNIGHTS = :{ (White) => '♘', (Black) => '♞' };
%KNIGHTS{$colour}
}
}
role Chess::Piece[Chess::Colour:D $colour, Queen] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
my constant %QUEENS = :{ (White) => '♕', (Black) => '♛' };
%QUEENS{$colour}
}
}
role Chess::Piece[Chess::Colour:D $colour, King] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
my constant %KINGS = :{ (White) => '♔', (Black) => '♚' };
%KINGS{$colour}
}
}
Chess::Square 可以用彩色的括号将其棋子的 gist 包裹起来。
role Chess::Square[White] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
"\e[37m[\e[m$!piece.gist()\e[37m]\e[m"
}
}
role Chess::Square[Black] {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
"\e[30m[\e[m$!piece.gist()\e[30m]\e[m"
}
}
而 Chess::Board 可以把这些粘合在一起:
class Chess::Board {
# ...
multi method gist(::?CLASS:D: --> Str:D) {
@!squares.rotor(8).reverse.map(*».gist.join).join($?NL)
}
}
my Chess::Board:D $board .= new;
say $board;
现在我们可以看到一些结果。
bastille% raku chess.raku
[♜][♞][♝][♚][♛][♝][♞][♜]
[♟︎][♟︎][♟︎][♟︎][♟︎][♟︎][♟︎][♟︎]
[ ][ ][ ][ ][ ][ ][ ][ ]
[ ][ ][ ][ ][ ][ ][ ][ ]
[ ][ ][ ][ ][ ][ ][ ][ ]
[ ][ ][ ][ ][ ][ ][ ][ ]
[♙][♙][♙][♙][♙][♙][♙][♙]
[♖][♘][♗][♕][♔][♗][♘][♖]
现在让我们来使用 Chess::Position。我们将给它一个显式的 new 方法, 让它更容易使用, 并给它一个 gist 候选, 以棋手能读懂的方式表达它的输出。
class Chess::Position {
# ...
method new(::?CLASS:_: Int:D $rank, Int:D $file --> ::?CLASS:D) {
self.bless: :$rank, :$file
}
multi method gist(::?CLASS:D: --> Str:D) {
my constant @RANKS = <a b c d e f g h>;
@RANKS[$!rank] ~ $!file + 1
}
}
如果我们返回一个选手所有可能的移动的偏移列表。
role Chess::Piece[Chess::Colour:D $colour, Knight] {
# ...
method moves(::?CLASS:D: --> Seq:D) {
gather {
take slip (-2, 2) X (-1, 1);
take slip (-1, 1) X (-2, 2);
}
}
}
然后我们可以在 Chess::Board 中借助 feed 运算符和 v6.e 的 ||
前缀运算符来 grep 这些有效的棋步。
use v6.e.PREVIEW;
class Chess::Board {
# ...
method moves(::?CLASS:D: Chess::Index $rank, Chess::Index $file --> Seq:D) {
gather with @!squares[$rank;$file].piece -> Chess::Piece:D $piece {
$piece.moves
==> map({ $rank + .[0], $file + .[1] })
==> grep((Chess::Index, Chess::Index))
==> grep({ not .?colour ~~ $piece.colour given @!squares[||$_].piece })
==> map({ Chess::Position.new: |$_ })
==> slip()
==> take()
}
}
}
my Chess::Board:D $board .= new;
say $board.moves: 0, 1;
现在我们可以看看在 a2 的白马在第一回合可以做出什么动作。
bastille% raku chess.raku
(c1 c3)
94. 第二十天 - 野外的 Raku
很久以前, 圣诞老人收到了一个名为 AGRAMMON 的网络应用程序的功能请求, 这个程序是由他的一个分包商 Oetiker+Partner AG 的精灵们用当时的 Perl 5 开发的。当圣诞老人要求负责这个应用程序的小精灵开始工作时, 小精灵建议进行一些重构, 因为这个应用程序已经有近10年的历史了, 并且经常被扩展。
由于前一年的圣诞奇迹, 即 Perl 6c 的发布, 小精灵建议, 与其在网页应用的 Perl 后台添加另一个功能, 不如用 Perl 6 重写是一个大胆而恰当的举措。原因是, 这个应用程序使用了一种特别开发的格式来描述它的功能, 而非程序员。小精灵认为, 还有什么比 Perl 6 的 grammar 更好的重写解析器的选择呢?果然, 新的 AGRAMMON 将会是第 6 版。
当圣诞老人问什么时候能完成重写工作时, 小精灵的回答是"圣诞节前"。而在 Perl 6 的土地上, 当重写终于要投入生产时, 后台已经用 Raku 实现了。
94.1. AGRAMMON
如今, 大多数人都知道农业对气候的负面影响, 即甲烷和一氧化二氮(强温室气体)的排放和森林砍伐。鲜为人知但也是重要的环境问题是氨(NH3)和一氧化二氮(NOx)的排放。对于农业生产来说, NH3 是主要的气态污染物, 而 NOx 则是次要的。
这些排放物的主要来源是农场动物的排泄物, 主要来自牛、猪和家禽。液态和固态粪便都含有氮化合物, 如尿素。当这些排泄物沉积在农场表面, 随后储存在粪便仓库, 以及作为肥料施用于田间时, 这些化合物会发生分解。
除了对环境造成负面影响外, 这些排放物还会造成粪便中氮(N)的大量损失, 要么导致农场生产力下降, 要么必须用矿物质肥料来补偿损失, 而这对农民来说是额外的成本。仅在瑞士, 每年就损失了约4万吨氮, 约占粪便中氮含量的30%。
为了解决这些问题, 人们研究了氨挥发的过程, 制定了减排方案, 并尽可能在受控条件下测量效果。然而, 由于控制条件难以在农场规模实施, 减排措施的效果以及排放总量可以通过模型计算进行模拟。AGRAMMON 是一个工具, 可以促进单个农场规模的模拟。这种计算也可以通过模拟"典型农场类型", 使用平均工艺类型和累积的动物数量、储存设施和施肥量, 在区域范围内进行。下图为模型模拟的过程。
Agrammon 模型根据 N-flux(质量流模型)计算氨损失。对于住房/堆场、粪库、施肥和其他来源的阶段, 损失是通过使用排放率作为系统中总氨氮(TAN)的比例来计算的。对于浆料库, 采用每平方米浆料库表面积的排放率。重要的生产变量, 如饲料、外壳系统、浆料库的覆盖物或减排应用系统, 作为修正系数被考虑到排放率中。
94.2. 应用程序
AGRAMMON 是一个典型的网络应用, 数据存储在 PostgreSQL 数据库中, 网络前端使用 Qooxdoo 框架的 JavaScript 实现, 后端使用 Raku。物理和化学过程不是直接在后台实现的, 而是如前所述用一种非程序员友好的自定义"语言", 描述(用户)输入、模型参数、计算和输出(结果)。
每个过程被分解成更小的子过程, 每个子过程都在自己的文件中描述, 包括文档和对适当科学来源的引用。下面是这样一个文件的小例子。
*** general ***
author = Agrammon Group
date = 2008-03-30
taxonomy = Livestock::DairyCow::Excretion
+short
Computes the annual N excretion of a number of dairy cows as a function of the
milk yield and the feed ration.
+description
这个过程计算了一些奶牛的年N排泄量(总氮和 Nsol(尿素加上测得的总氨氮)), 作为牛奶产量和供应的饲料日粮的函数。氮吸收量增加所产生的氮盈余主要以 Nsol 的形式在尿液中排出。因此, 增加的氮排泄量的80%被添加到 Nsol 部分。
*** input parameters ***
+dairy_cows
type = integer
validator = ge(0)
++labels
en = Number of animals
de = Anzahl Tiere
fr = Nombre d'animaux
++units
en = -
++description
Number of dairy cows in barn.
++help
+++en
<p>Actual number of animals
in the barn.</p>
+++de ...
+++fr ...
*** technical parameters ***
+standard_N_excretion
value = 115
++units
en = kg N/year
de = kg N/Jahr
fr = kg N/an
++description
Annual standard N excretion for a
dairy cow according to
Flisch et al. (2009).
*** external ***
+Excretion::CMilk
+Excretion::CFeed
*** output ***
+n_excretion
print = 7
++units
en = kg N/year
de = kg N/Jahr
fr = kg N/an
++formula
Tech(standard_N_excretion)
* Val(cmilk_yield, Excretion::CMilk)
* Val(c_feed_ration,Excretion::CFeed)
* In(dairy_cows);
++description
Annual total N excreted by a specified
number of animals.
在当前版本的 AGRAMMON 模型中, 有133个这样的模型文件, 31,014 行。从这些文件中, 后端可以生成
-
模型的PDF文档(允许在文件中使用 LaTeX 格式)。
-
使用用户的输入数据进行实际的模型模拟。
-
前端可以渲染的 Web GUI 的描述。
结果以表格形式在网络GUI中显示(显示数据的各种子集, 这些子集也可以在模型文件中定义)。
AGRAMMON 的一个特殊实例被一个地区政府机构用于评估当地农场改造对环境的影响和批准各自的建筑申请。为此, 计划中的改造前后的氨气排放必须由申请人模拟, 并且可以直接提交到该机构的 AGRAMMON 账户, 包括通过电子邮件通知该机构并附上 PDF 报告。
94.3. Raku 后端
到目前为止, 重构后的后台由59个 .pm6 模块/包组成, 共6,942行, 并由38个.t文件中的5,854行测试所覆盖。它使用了 META6.json 文件下面摘录的13个Raku模块。
"depends": [
"Cro::HTTP",
"Cro::HTTP::Session::Pg",
"Cro::OpenAPI::RoutesFromDefinition",
"Cro::WebApp::Template",
"DB::Pg",
"Digest::SHA1::Native",
"Email::MIME",
"LibXML:ver<0.5.10>",
"Net::SMTP::Client::Async",
"OO::Monitors",
"Spreadsheet::XLSX:ver<0.2.1+>",
"Text::CSV",
"YAMLish"
],
"build-depends": [],
"test-depends": [
"App::Prove6",
"Cro::HTTP::Test",
"Test::Mock",
"Test::NoTabs"
],
这些模块可以在 Raku 模块目录中找到。请注意, Spreadsheet::XLSX 是专门为这个项目实现的。作为一个副作用, 就在昨天, 我们的专家小精灵(见下图)提交了一个关于 Spreadsheet::XLSX 中使用的 LibXML 的 pull 请求, 导致了2倍的性能提升。
说到实际的实现, 虽然我们勇敢的小精灵在 grammar、解析器、甚至 Perl 6 / Raku方面都没有太多经验, 但他还是很聪明地请了一个真正的专家小精灵来做这件事。这个小精灵完成了后台实现的大部分重任, 并帮助我们的小精灵提供建议, 并对他自己实现的部分进行代码审查。
请注意, 这次重写的目标是将模型实现的大部分语法和前台保持原样, 所以所有次优的设计决定的责任完全在我们的主要小精灵身上, 同时也要为不完美的实现细节负责。
94.4. AGRAMMON 中使用的一些 Raku 功能
在本节中, 我们将介绍 AGRAMMON 中使用的一些 Raku 特性。这并不是为了给专家们提供一个硬核的技术解释, 而是作为一种手段来给对 Raku 感兴趣的人提供一种体验。
大多数的代码例子都是直接从当前的实现中提取的, 有时例子会略微缩短, 留下与概念无关的代码。GitHub 上有许多原始模块的链接。由于 AGRAMMON 仍在开发中, 这些链接可能指向模块的最新版本。
实际 AGRAMMON 的"可执行文件"只有三行字(其中只有两行是 Raku)。
#!/usr/bin/env raku
use lib "lib"
use Agrammon::UI::CommandLine;
这就利用了 Rakudo(这里使用的 Raku 实现)有一个非常好的预编译功能, 它对于在程序第一次运行后最大限度地减少(仍然不可忽视的)启动时间非常有用。
这个模块包含了 AGRAMMON 应用程序的主要功能, 可以在命令行中使用。
使用方法
Running ./bin/agrammon.pl6 gives the following output:
Usage:
./bin/agrammon.pl6 web <cfg-filename> <model-filename> [<technical-file>] -- Start the web interface
./bin/agrammon.pl6 [--language=<SupportedLanguage>] [--prints=<Str>] [--variants=<Str>] [--include-filters] [--include-all-filters] [--batch=<Int>] [--degree=<Int>] [--max-runs=<Int>] [--format=<OutputFormat>] run <filename> <input> [<technical-file>] -- Run the model
./bin/agrammon.pl6 [--variants=<Str>] [--sort=<SortOrder>] dump <filename> -- Dump model
./bin/agrammon.pl6 [--variants=<Str>] [--sort=<SortOrder>] latex <filename> [<technical-file>]
./bin/agrammon.pl6 create-user <username> <firstname> <lastname> -- Create Agrammon user
<cfg-filename> configuration file
<model-filename> top-level model file
[<technical-file>] optionally override model parameters from this file
See https://www.agrammon.ch for more information about Agrammon.
subset ExistingFile of Str where { .IO.e or note("No such file $_") && exit 1 }
#| Start the web interface
multi sub MAIN(
'web',
ExistingFile $cfg-filename, #= configuration file
ExistingFile $model-filename, #= top-level model file
ExistingFile $technical-file? #= override model parameters from this file
) is export {
my $http = web($cfg-filename, $model-filename, $technical-file);
react {
whenever signal(SIGINT) {
say "Shutting down...";
$http.stop;
done;
}
}
}
请注意, 参数 $technical-file 被尾部的 ? 标记为可选, 因此使用信息也用 [ ] 括起来将该参数标记为可选。
上述代码示例中的第一行定义了一个数据类型为 Str 的 subset ExistingFile, 即那些引用本地现有文件的字符串。如果用非存在文件的文件名 foo.cfg 调用, 程序会以 No such file foo.cfg 的消息中止。
使用信息中还显示了命令行中对:
-
从命令行(运行)以批处理模式运行模型。
-
通过转储模型结构来显示模拟流程。
-
生成模型文档(latex);
-
和为网络应用创建用户账户(create-user)。
-
并在最后列出那些在上面所示的 sub MAIN 源中有注释"附加"的参数。
sub web()
这个子例程被调用来启动 Web 服务, 如上面使用消息的第一行所示。
sub web(Str $cfg-filename, Str $model-filename, Str $technical-file?) is export {
# initialization
# ...
my $model = timed "Load model from $module-path/$module.nhd", {
load-model-using-cache($*HOME.add('.agrammon'), $module-path, $module, preprocessor-options($variants));
}
my $db = DB::Pg.new(conninfo => $cfg.db-conninfo);
PROCESS::<$AGRAMMON-DB-CONNECTION> = $db;
my $ws = Agrammon::Web::Service.new(:$cfg, :$model, :%technical-parameters);
# setup and start web server
my $host = %*ENV<AGRAMMON_HOST> || '0.0.0.0';
my $port = %*ENV<AGRAMMON_PORT> || 20000;
my Cro::Service $http = Cro::HTTP::Server.new(
:$host, :$port,
application => routes($ws),
after => [
Cro::HTTP::Log::File.new(logs => $*OUT, errors => $*ERR)
],
before => [
Agrammon::Web::SessionStore.new(:$db)
]
);
$http.start;
say "Listening at http://$host:$port";
return $http;
}
sub run()
sub run (IO::Path $path, IO::Path $input-path, $technical-file, $variants, $format, $language, $prints,
Bool $include-filters, $batch, $degree, $max-runs, :$all-filters) is export {
# initialization
# ...
my $rc = Agrammon::ResultCollector.new;
my atomicint $n = 0;
my class X::EarlyFinish is Exception {}
race for $ds.read($fh).race(:$batch, :$degree) -> $dataset {
my $my-n = ++⚛$n;
my $outputs = timed "$my-n: Run $filename", {
$model.run(
input => $dataset,
technical => %technical-parameters,
);
}
# create output
# ...
}
虽然上面已经展示了 web 服务的启动, 但这里我们看到了一个使用 Edument 的 Cro 服务中的 Cro::HTTP::Router 设置 AGRAMMON 的 REST 接口路由的例子。
use Cro::HTTP::Router;
use Cro::OpenAPI::RoutesFromDefinition;
use Agrammon::Web::Service;
use Agrammon::Web::SessionUser;
subset LoggedIn of Agrammon::Web::SessionUser where .logged-in;
sub routes(Agrammon::Web::Service $ws) is export {
my $schema = 'share/agrammon.openapi';
my $root = '';
route {
include static-content($root);
include api-routes($schema, $ws);
...
after {
forbidden if .status == 401 && request.auth.logged-in;
.status = 401 if .status == 418;
}
}
}
sub static-content($root) {
route {
get -> {
static $root ~ 'public/index.html'
}
...
}
}
sub api-routes (Str $schema, $ws) {
openapi $schema.IO, {
# working
operation 'createAccount', -> LoggedIn $user {
request-body -> (:$email!, :$password!, :$key, :$firstname, :$lastname, :$org, :$role) {
my $username = $ws.create-account($user, $email, $password, $key, $firstname, $lastname, $org, $role);
content 'application/json', { :$username };
CATCH {
note "$_";
when X::Agrammon::DB::User::CreateFailed {
not-found 'application/json', %( error => .message );
}
when X::Agrammon::DB::User::AlreadyExists
| X::Agrammon::DB::User::CreateFailed {
conflict 'application/json', %( error => .message );
}
}
}
}
...
}
后者使用的是(简略的)OpenAPI 定义。
openapi: 3.0.0
info:
version: 1.0.0,
title: OpenApi Agrammon,
paths:
/create_account:
post:
summary: Create new user account
operationId: createAccount
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- email
- password
properties:
email:
description: User's email used as username
type: string
firstname:
description: Firstname
type: string
lastname:
description: Lastname
type: string
responses:
'200':
description: Account created.
content:
application/json:
schema:
type: object
required:
- username
properties:
username:
type: string
'404':
description: Couldn't create account
content:
application/json:
schema:
$ref: "#/components/schemas/CreationFailed"
'409':
description: User already exists
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
为了记录 AGRAMMON 的计算结果, 可以通过使用 Cro::WebApp::Template 模块首先创建一个 LaTeX 文件来创建模型输入和模拟结果的 PDF 报告。虽然是为生成 HTML 页面而量身定做的, 但对于我们的目的来说, 它的效果相当好。该模块确实对适合 HTML 的输入数据进行了转义, 但是, 在模块外实现了对 LaTeX 中具有特殊意义的字符进行简单的转义。
sub latex-escape(Str $in) is export {
my $out = $in // '';
$out ~~ s:g/<[\\]>/\\backslash/;
$out ~~ s:g/(<[%#{}&$|]>)/\\$0/;
$out ~~ s:g/(<[~^]>)/\\$0\{\}/;
# this is a special case for Agrammon as we use __ in
# the frontend at the moment for indentation in the table
$out ~~ s:g/__/\\hspace\{2em\}/;
$out ~~ s:g/_/\\_/;
return $out;
}
一个额外的功能是用来美化化学分子式的。
sub latex-chemify(Str $in) is export {
my $out = $in // '';
$out ~~ s:g/NOx/\\ce\{NO_\{\(x\)\}\}/;
$out ~~ s:g/(N2O|NH3|N2|NO2)/\\ce\{$0\}/;
return $out;
}
这些函数使用简单的正则表达式替换。更通用的 LaTeX 特殊字符的处理需要将类似 LaTeX::Encode Perl 模块的东西移植到 Raku。另外, Inline::Perl5 也可以用来利用 Perl 模块。
这个代码片段显示了 LaTeX 文件是如何创建的:
%data<titles> = %titles;
%data<dataset> = $dataset-name // 'NO DATASET';
%data<username> = $user.username // 'NO USER';
%data<model> = $cfg.gui-variant // 'NO MODEL';
%data<timestamp> = ~DateTime.now( formatter => sub ($_) {
sprintf '%02d.%02d.%04d %02d:%02d:%02d',
.day, .month, .year,.hour, .minute, .second,
});
%data<version> = latex-escape($cfg.gui-title{$language} // 'NO VERSION');
%data<outputs> = @output-formatted;
%data<inputs> = @input-formatted;
%data<submission> = %submission;
template-location $*PROGRAM.parent.add('../share/templates');
my $temp-dir = $*TMPDIR.add($temp-dir-name);
my $source-file = "$temp-dir/$filename.tex".IO;
my $latex-source = render-template('pdfexport.crotmp', %data);
$source-file.spurt($latex-source, %data);
用 %data hash 和 pdfexport.crotmp 这样的模板文件调用 render-template。
\nonstopmode
\documentclass[10pt,a4paper]{article}
\begin{document}
\section*{<.titles.report>}
\section{<.titles.data.section>}
\begin{tabular}[t]{@{}l@{\hspace{2em}}p{7cm}}
\textbf{<.titles.data.dataset>:} & <.dataset>\\
\textbf{<.titles.data.user>:} & <.username>\\
\textbf{Version:} & <.model>\\
\end{tabular}
\section{<.titles.outputs>}
<@outputs>
<?.section>
<!.first>
\bottomrule
\end{tabular}
</!>
\subsection{<.section>}
\noindent
\rowcolors{1}{LightGrey}{White}
\begin{tabular}[t]{lllrl}
\toprule
</?>
<!.section>
& & <.label> & <.value> & <.unit>\\
</!>
</@>
\bottomrule
\end{tabular}
\end{document}
作为参数。然后使用 spurt 函数将生成的 LaTeX 源代码写入文件。
如果您不熟悉 LaTeX, 上述模板可能看起来有点神秘, 但相关的部分是类似于 HTML 的标签, 如 <.title.report> 访问传递给 render-template 的哈希数据结构的值, <@output> … </@> 是这个数据结构中被迭代的数组, 或者是条件 <?.section>…</?> 或 <!.section>… </!>. 详情请参考 Cro::WebApp::Template 模块的文档。
然后使用外部程序 lualatex 和内置的 Proc::Async 类将 LaTeX 文件渲染成 PDF 文件。
# setup temp dir and files
my $temp-dir = $*TMPDIR.add($temp-dir-name);
my $source-file = "$temp-dir/$filename.tex".IO;
my $pdf-file = "$temp-dir/$filename.pdf".IO;
my $log-file = "$temp-dir/$filename.log".IO;
# create PDF, discard STDOUT and STDERR (see .log file if necessary)
my $exit-code;
my $signal;
my $reason = 'Unknown';
my $proc = Proc::Async.new: :w, '/usr/bin/lualatex',
"--output-directory=$temp-dir", '--no-shell-escape', '--', $source-file, ‘-’;
react {
# discard any output of the external program
whenever $proc.stdout.lines {
}
whenever $proc.stderr {
}
# save exit code and signal if program was terminated
whenever $proc.start {
$exit-code = .exitcode;
$signal = .signal;
done; # gracefully jump from the react block
}
# make sure we don't end up with a hung-up lualatex process
whenever Promise.in(5) {
$reason = 'Timeout';
note ‘Timeout. Asking the process to stop’;
$proc.kill; # sends SIGHUP, change appropriately
whenever Promise.in(2) {
note ‘Timeout. Forcing the process to stop’;
$proc.kill: SIGKILL
}
}
}
# write appropriate error messages if program didn't terminate sucessfully
if $exit-code {
note "$pdf-prog failed for $source-file, exit-code=$exit-code";
die X::Agrammon::OutputFormatter::PDF::Failed.new: :$exit-code;
}
if $signal {
note "$pdf-prog killed for $source-file, signal=$signal, reason=$reason";
die X::Agrammon::OutputFormatter::PDF::Killed.new: :$reason;
}
# read content of PDF file created in binary format for further use
my $pdf = $pdf-file.slurp(:bin);
# remove created files if successful, otherwise keep for debugging
unlink $source-file, $pdf-file, $aux-file, $log-file unless %*ENV<AGRAMMON_KEEP_FILES>;
一个带有多个 whenever 块的 react 块用于处理来自异步运行的外部程序的事件, 以避免阻塞原本已经异步的后端。
带类型的异常用于处理外部进程中发生的错误。
在这里, 我们使用 Spreadsheet::XLSX 创建模拟结果和用户输入的 Excel 导出。这个模块允许从 Raku 读取和写入 XLSX 文件。目前的功能并不完整, 但实现了 AGRAMMON 所需要的功能。请随时提供拉动请求或资金来实现额外的功能。
# get data to be shown
my %data = collect-data();
# ...
my $workbook = Spreadsheet::XLSX.new;
# prepare sheets
my $output-sheet = $workbook.create-worksheet('Results');
my $input-sheet = $workbook.create-worksheet('Inputs');
my $timestamp = ~DateTime.now( formatter => sub ($_) {
sprintf '%02d.%02d.%04d %02d:%02d:%02d',
.day, .month, .year, .hour, .minute, .second,
});
# add some meta data to the sheets
for ($output-sheet, $input-sheet) -> $sheet {
$sheet.set(0, 0, $dataset-name, :bold);
$sheet.set(1, 0, $user.username);
$sheet.set(2, 0, $model-version);
$sheet.set(3, 0, $timestamp);
}
# set column width
for ($output-sheet, $input-sheet) -> $sheet {
$sheet.columns[0] = Spreadsheet::XLSX::Worksheet::Column.new:
:custom-width, :width(20);
$sheet.columns[1] = Spreadsheet::XLSX::Worksheet::Column.new:
:custom-width, :width(32);
$sheet.columns[2] = Spreadsheet::XLSX::Worksheet::Column.new:
:custom-width, :width(20);
$sheet.columns[3] = Spreadsheet::XLSX::Worksheet::Column.new:
:custom-width, :width(10);
}
# add input data to sheets
my $row = 0;
my $col = 0;
my @records := %data<inputs>;
for @records -> %rec {
$input-sheet.set($row, $col+2, %rec<input>);
$input-sheet.set($row, $col+3, %rec<value>, :number-format('#,#'), :horizontal-align(RightAlign));
$input-sheet.set($row, $col+4, %rec<unit>);
$row++;
}
# add output data to sheets
# ...
这个例子展示了各种 Raku 基础知识。
-
%data, %rec 是哈希变量。与 Perl 相反, 在 Raku 中, 当访问变量的元素时, sigils 不会改变。
-
for ($output-sheet, $input-sheet) → $sheet { …. } 和 for @records → %rec { … } 是对列表的循环, 每个循环都使用尖号块语法将当前元素分配给循环作用域内的一个变量。
-
my $timestamp = ~DateTime.now( formatter => sub ($_) { .... } ...) 使用内置的 DateTime 方法创建一个时间戳, 使用 操作符将其强制成一个字符串。这个字符串由未命名的匿名子例程 sub ($_) { ... } 进行格式化。使用主题变量 $ 作为参数, 在这个变量上调用 DateTime 类的各种方法, 只需在前面加上一个
.
。例如,.year
只是$
.year
的快捷方式。
如上所述, 模拟的 PDF 报告可以直接从网络应用中邮寄给某些 AGRAMMON 用户。首先, 使用 Email::MIME 模块创建一个multi-parrt 的 MIME 信息。
# create PDF attachment
my $attachment = Email::MIME.create(
attributes => {
'content-type' => "application/pdf; name=$filename",
'charset' => 'utf-8',
'encoding' => 'base64',
},
body => $pdf,
);
# create main body part
my $msg = Email::MIME.create(
attributes => {
'content-type' => 'text/plain',
'charset' => 'utf-8',
'encoding' => 'quoted-printable'
},
body-str => 'Attached please find a PDF report from a AGRAMMON simulation,
);
# build multi-part Email
my $from = 'support@agrammon.ch';
my $to = 'foo@bar.com';
my $mail = Email::MIME.create(
header-str => [
'to' => $to,
'from' => $from,
'subject' => 'Mail from AGRAMMON'
],
parts => [
$msg,
$attachment,
]
);
然后使用基于 Promise 的 Net::SMTP::Client::Async 模块将此消息发送给邮件的收件人。
# asynchronously send Email via AGRAMMON's SMTP server
with await Net::SMTP::Client::Async.connect(:host<mail.agrammon.ch>, :port(25), :!secure) {
# wait for SMTP server's welcome response
await .hello;
# send message
await .send-message(
:$from,
:to([ $to ]),
:message(~$mail),
);
# terminate connection on exit
LEAVE .quit;
# catch exceptions and emit user friendly error message
CATCH {
when X::Net::SMTP::Client::Async {
note "Unable to send email message: $_";
}
}
}
Unicode 运算符
我最喜欢在 AGRAMMON 中使用的代码片段展示了在 Raku 源代码中使用 Unicode 代码码点。
-
my $my-n = ++⚛$n; 是递增一个 atomicint 类型的变量。
-
$var-print.split(',') ∩ @print-set 给出了两个集合的交集。
虽然 Unicode 也可以用于其他目的, 例如用于数值, 如 ⅓、𝑒、π 或 τ, 或者用于变量名 my $Δ = 1;, 但它们作为运算符的使用肯定会使代码更易读(比较 ∩ 与 (&) )。
除了使用适当的数学符号外, 这种强大的运算符的存在本身就是 Raku 的一大特点, 并且比在其他编程语言中实现这种运算的代码要短得多。
94.5. 解析器和编译器
最后, 说一下用于处理上图所示 AGRAMMON 模型文件的解析器和编译器。Agrammon::ModuleParser 是解析模型文件的顶层元素。
use v6;
use Agrammon::CommonParser;
grammar Agrammon::ModuleParser does Agrammon::CommonParser {
token TOP {
:my $*TAXONOMY = '';
:my $*CUR-SECTION = '';
<.blank-line>*
<section>+
[
|| $
|| <.panic('Confused')>
]
}
proto token section { * }
token section:sym<general> {
<.section-heading('general')>
[
| <option=.single-line-option>
| <option=.multi-line-str-option('+')>
| <.blank-line>
]*
}
token section:sym<external> {
<.section-heading('external')>
[
| <.blank-line>
| <external=.option-section>
]*
}
token section:sym<input> {
<.section-heading('input')>
[
| <.blank-line>
| <input=.option-section>
]*
}
token section:sym<technical> {
<.section-heading('technical')>
[
| <.blank-line>
| <technical=.option-section>
]*
}
token section:sym<output> {
<.section-heading('output')>
[
| <.blank-line>
| <output=.option-section>
]*
}
token section:sym<results> {
<.section-heading('results')>
[
| <.blank-line>
| <results=.option-section>
]*
}
token section:sym<tests> {
<.section-heading('tests')>
[
| <.blank-line>
| <tests=.option-section>
]*
}
}
它处理模型文件各部分章节的解析, 使用 Agrammon::CommonParser 模块的各种元素, 如:
token section-heading($title) {
\h* '***' \h* $title \h* '***' \h* \n
{ $*CUR-SECTION = $title }
}
token option-section {
\h* '+' \h* <name> \h* \n
[
| <.blank-line>
| <option=.single-line-option>
| <option=.subsection-map>
| <option=.multi-line-str-option('++')>
]*
}
token single-line-option {
\h* <key> \h* '=' \h*
$<value>=[[<!before \h*'#'>\N]*]
\h* ['#'\N*]?
[\n || $]
}
token blank-line {
| \h* \n
| \h* '#' \N* \n
| \h+ $
}
请参考本教程或其他资源来了解这些概念的更多信息。
如果你想了解更多关于(真实世界的)AGRAMMON 解析器/编译器, 你可以看看 Agrammon::Formula::Parser、Agrammon::Formula::Builder、Agrammon::ModuleBuilder、Agrammon::TechnicalParser、Agrammon::TechnicalBuilder 和 Agrammon::LanguageParser 模块中的其他解析器元素, 后者是一个简单的基于非 grammar 的函数。
最后, 作为最近的新增功能, AGRAMMON 还在 Agrammon::Preprocessor 中得到了一个 C 风格的预处理器, 用于有条件地包含或排除模型的部分, 使用的语法如下。
?if FOO
...
?elsif BAR
...
?else
...
?endif
和可选的 ?elsif
和 ?else
部分。关键字也可以被否定, 比如 ?if !FOO
。
94.6. 那么, 哪个圣诞节?
好吧, 正如你在 2018 年瑞士Perl研讨会上的这个演讲中所看到的那样, 最初的计划并没有完全实现, 主要是因为另一个项目被赋予了更高的优先级(这是一个非常糟糕的决定, 但这是另一个很长的故事)。
我们曾希望在本文出现之前, AGRAMMON 6 就能部署并投入生产, 并且几乎成功了。所有的关键功能都已经到位, 还需要一点点的打磨。对于我们勇敢的小精灵来说, 最大的欣慰之一是, 用 Perl 和 Raku 后端使用相同的模型和输入值进行的测试计算得到了相同的结果。由于这些实现不仅是用两种不同的语言完成的, 而且是由不同的程序员用完全不同的架构完成的, 这让我们对它们的正确性产生了很大的信任。
此外, 客户已经对模型文件进行了相当广泛的重构, 目前正在验证模型计算和基于 Raku 的网络应用的功能。
94.7. 结束语
Raku 可以用于生产吗?绝对是的!在 Raku 中已经交付了一些较小的客户项目。
虽然我们已经交付了一些在 Raku 中实施的较小的客户项目, 但 AGRAMMON 6 将是 Oetiker+Partner AG 的第一个使用 Raku 后台的公开访问的网络应用, 我们希望未来有更多的项目。在这个项目上与我们的同事合作是一件非常愉快的事情, 我们也要感谢我们的客户和合作伙伴给我们这个机会。
而最重要的成果。圣诞老人现在又多了一个能在未来的 Raku 项目中工作的小精灵。Raku 是一门非常丰富的语言, 毫无疑问, 对于"它"的任何定义, "有不止一种方法可以做"。虽然没有必要一下子就学会所有的东西, 但在附近有一些专家知识, 提出问题并了解更优雅的、通常是非常简洁的可用选项肯定是有帮助的。Raku 社区对新人非常友好和欢迎(甚至对偶尔出现的巨魔也是如此)。
Raku 本身也非常努力地帮助程序员不把自己射到脚下。错误信息往往是非常有帮助的, Comma IDE 所提供的问题报告或建议也是如此(而且随着每一个版本的发布, 它们变得越来越多)。所以, 请继续潜心研究吧!
95. 第二十一天 - 精灵的故事、角色以及圣诞老人的企业
让我们认真点吧。毕竟, 我们是成年人, 知道圣诞老人的全部真相:他是一个表演者, 他是圣诞老人家族企业的最高管理者。没有人知道他的具体职位, 因为我们不能忘记圣诞老人夫人, 她在管理公司的份额至少是相等的。反正这个职位与我们的故事无关。但重要的是, 经营这样一个庞大的企业需要很多技能。更何况这个企业本身也是一场巨大的表演, 从过去几十年人类历史上拍摄的《圣诞老人》等纪录片中可以发现。
经营"北极公司"最难的部分是什么?物流?是的, 但是有了雪橇的魔力, 有了驯鹿, 有了圣诞夜, 这个任务就不难完成了。制造?这个任务已经委托给了小型的外包公司, 比如乐高、任天堂, 以及全球其他几十家公司。
还剩下什么?就是员工。精灵们。而且, 天哪!, 你试过组织他们吗?不要想尝试, 除非你有一个备份的形式, 由礼貌的人员服务的软垫房间, 有可靠的药丸供应, 你会在那里度过你稀缺的假期。这是个不人道的任务, 因为当一个人把成千上万, 甚至上百万的雄心勃勃的舞台明星(没有精灵会认为自己是二线演员!)聚集在一起时, 每个人的能量相当于一个小型核反应堆…你知道…。
圣诞老人是如何管理的?当然, 他们的心胸开阔, 宽容度超出了一般人的理解。但是, 要想建立一个成功的企业, 这肯定是不够的! 所以, 一定是有秘诀的, 是商业结构和节目共同的东西。而我认为是做好了角色分配, 把精灵个性的布朗运动变成了自组织结构。
95.1. 精灵
基本上, 精灵就是:
class Elf is FairyCreature {...}
不知道为什么, 很多人都不喜欢这个定义, 但只要, 很多人类还不认为自己是猿类的一种, 我们又有什么资格去评价他们呢?同样, 有些精灵认为仙女是古板过时的, 和他们这些现代生物没有关系。
不过, 我说的是题外话…
上述定义是高度简化的。因为如果我们开始遍历 FairyCreature 类的子树, 我们会发现其中有很多不同的物种, 比如独角兽、妖精、小精灵等等等等。显然, 一定有其他的东西, 定义了这种差异。一些能够为每一种特殊生物提供足够具体的属性的东西。如果我们扩展 Elf 类的定义, 我们会看到这样的行:
class Elf is FairyCreature {
also does OneHead;
also does UpperLimbs[2];
also does LowerLimbs[2];
also does Magic;
...
}
我必须在这里忏悔。我没有被允许看完整的来源。当我要求进入仙女库时, 得到的回答是:"嘿, 如果我们把所有的东西都透露出来, 那就不像魔法了!" 所以, 这里有些代码是真实的, 有些已经被猜出来了。我不会告诉你哪个是哪个, 毕竟, 让我们保持它的魔力吧!
每一行都是一个 role, 定义了一个属性, 或者是一个特性, 或者是一个通用精灵的固有行为(别把它和球形牛搞混了)。所以, 当我们看到这样一行代码的时候, 我们就会说: Elf does Magic; 或者, 换句话说:类 Elf 会消耗角色 Magic。
很抱歉没有给 Raku 新人详细解释什么是角色, 希望这里的链接能对大家有所帮助。对于懂 Java 的人来说(我是恐龙, 我不懂), 角色有点类似于接口, 但更好。它可以定义属性和方法, 注入到消耗类中;可以要求定义某些方法;还可以指定该类将消耗哪些其他角色, 以及其他类的继承。
事实上, 由于精灵物种的复杂性, 它们所做的角色数量太多, 这里就不一一列举了。一般情况下, 当一个类只消耗其中的几个角色时, 换一种方式写代码就可以了。
class DisgustingCreature is FairyCreature does Slimy does Tentacles[13] { .... }
但角色太多的话最好还是用前缀 also
把角色放进类体中。
95.2. 当圣诞老人雇用一个精灵
如果说精灵是由圣诞老人雇佣的, 这可能是不正确的。事实上, 我们知道, 他们都是专门为北极公司工作的。然而, 我的想法是这样的, 在历史上的某个时刻, 确实发生了雇佣。让我们试着想象一下, 它是如何发生的。
不管是这样还是那样, 都有一个特殊的角色。
role Employee {...}
而且还有一个问题:我们的类 Elf 已经是组成的, 而且是不可改变的。而且, 每个精灵都是该类的一个对象! 或者, 用 Raku 语言说同样的话:$pepper-minstix.defined && $pepper-minstix ~~ Elf
。好吧, 问题出在哪里呢?如果我们尝试 $pepper-minstix.hire(:position("security officer"), :company("The North Pole Inc."), …)
, 会发生一个很大的 boom!, 因为对类型为 'Elf' 的调用者没有这样的方法 'hire'。当然, boom!是意料之中的, 因为很久很久以前, 精灵和工作就像圣诞节和夏天一样兼容!但后来有了圣诞老人。但后来有了圣诞老人。而他们所做的事情叫做把角色混入一个对象。
$pepper-minstix does Employee;
从外部来看, 该操作将 Employee 角色的内容添加到对象的左手边, 使所有角色的属性和方法在该对象上可用。在内部, 它创建了一个新的类, 原来的 $pepper-minstix.WHAT
是唯一的父类;并且消耗了 Employee 这个角色。最终, 在 does 操作符之后, 比如说 $pepper-minstix.WHAT
将输出类似于 (Elf+{Employee}) 的东西。这就是现在 $pepper-minstix
变量所持有的对象的新类。
这样一个生活上的重大变化让精灵们开心了许多! 作为一个快乐的生物, 他们现在有了一个很好的机会与所有的孩子们分享他们的快乐, 有时不仅仅是孩子们。不过唯一让他们担心的是。要知道, 要找到两个一模一样的人真的不可能, 更何况没有两个一模一样的精灵。但是工作?岂不是在某种程度上把他们都拉平了?圣诞老人如果不和新朋友分享这些烦恼, 他们就不是圣诞老人了。为了了解他们的解决方案, 我们来看看 Employee 角色对我们有什么作用。其中我们最感兴趣的是以下几行。
has $.productivity;
has $.creativity;
has $.laziness;
has $.position;
has $.speciality;
has $.department;
has $.company;
为了简单起见, 我在代码段中没有使用类型化的属性, 虽然它们在真正的原始代码中是有的。例如, $.lazyyness
属性是一个系数, 其中用于计算咖啡或蛋奶酒休息时间的公式。这个公式的核心是这样的。
method todays-cofee-breaks-length {
$.company.work-hours * $.laziness * (1 + 0.2.rand)
}
因为感觉到自己对孩子们的责任, 精灵们同意限制自己的最大懒惰程度。因此这个属性的完整定义是这样的。
has Num:D $.laziness where * < 0.3;
如果有人认为最高限额太高, 那么他们心里就没有圣诞精神! 圣诞老人都很高兴, 我们为什么不高兴呢?我个人肯定他的满意是很好理解的, 因为他自己的最大值是在接近 0.5 的地方, 但是 - 嘘!- 我们还是保密吧。
有了所有这些特征, 圣诞老人想找到一种方法, 将它们设置为尽可能多样化的组合, 尽可能。而这就是他们想出的类似于这样的东西。
role Employee {
...
method my-productivity {...}
method my-creativity {...}
method my-laziness {...}
submethod TWEAK {
$!productivity //= self.my-productivity;
$!creativity //= self.my-creativity;
$!laziness //= self.my-laziness;
}
}
现在要由精灵自己定义方法来设置相应的特性。不过他们中的大多数人都可以接受为此而提出的特殊角色。
role RandomizedEmployee {
method my-productivity { 1 - 0.3.rand }
method my-creativity { 1 - 0.5.rand }
method my-laziness { 1 - 0.3.rand }
}
现在的招聘过程采取了以下形式。
$pepper-minstix does Employee, RandomizedEmployee;
但是, 等等!我们还有三个属性没有填完! 是的, 因为这些都是留给圣诞老人来填补的。他们知道什么样的工人, 哪里最需要。因此, 最终版本的雇佣代码更像是:
$pepper-minstix does Employee(
:company($santas-company),
:department($santas-company.department("Security")),
:position(HeadOfDepartment),
:speciality(GuardianOfTheSecrets),
...
),
RandomizedEmployee;
有了这一行, Raku 的 mixin 协议做了以下工作。
-
创建一个新的 mixin
-
设置用命名参数定义的属性
-
调用角色的构造函数 TWEAK
-
返回一个新的 employee 对象
因为大家都知道这件事将是一次性的冒险, 因为精灵们绝不会放过自己的新老板, 所以代码是一种效率和编码速度的权衡。不过, 还是用了一些有趣的技巧, 但讨论这些技巧已经超出了这个故事主线。对于这里提到的问题, 我想很多读者都能找到自己的解决方案。
我则继续说说发生在不久前的一个故事…
95.3. 当时间太少的时候
那是 12 月最疯狂的一天, 圣诞老人夫人紧急出差。已经忙于邮件和电话的圣诞老人先生, 在物流和包装部门有了额外的工作, 这些工作通常是由他的妻子处理的。他不可能跳过这些工作, 否则在圣诞之夜出问题的风险就太高了。要想按时到达各地, 唯一的办法就是减少打电话的次数。它的意思是叫精灵接待员用 "Santa is not available" 的信息来回答。
圣诞老人叹了口气。他几乎可以看到和听到小精灵带着深深的遗憾盯着他问道。"尼古拉斯, 你是在让我撒谎吗?" 哦, 不!他当然不会问, 但是…
但是?, but! 毕竟, 即使圣诞的时空魔法在一年中的其他日子里无法使用, 圣诞老人还是可以做其他种类的把戏的! 所以, 他的做法是这样的。
role FirewallishReceptionist {
has Bool $.santa-is-in-the-office;
has Str $.not-available-message;
method answer-a-call {
if $.santa-is-in-the-office {
self.transfer-call: $.santas-number;
}
else {
self.reply-call: $.not-available-message,
:record-reply,
:with-marry-christmas;
}
}
}
my $strict-receptionist =
$receptionist but FirewallishReceptionist(
:!santa-is-in-the-office,
:not-available-message(
"Unfortunately, Santa is not available at the moment."
~ ... #`{ the actual message is longer than this }
)
);
$company.give-a-day-off: $receptionist;
$company.santa-office-frontdesk.assign: :receptionist($strict-receptionist);
想象一下, 当前台接待员看到自己的副本在他的办公桌上占据了一席之地时, 他是多么的惊讶啊! 不过休息一天是一天, 他并不怎么反对将自己的懒惰系数应用到当天的其他工作中去……
至于圣诞老人本人, 他从来没有真正为自己那天的行为感到骄傲。即使是以拯救圣诞节的名义需要这样做。此外, 克隆人的存在也造成了后来一些尴尬的局面, 尤其是当两个精灵都想做同样的工作, 同时还共享一些数据结构的时候。不过这就另当别论了…。
95.4. 当新魔法有帮助时
这个季节, 你看到精灵们的身影了吗?他们总是很严谨地坚持着圣诞时尚的最新趋势:丰富的色彩, 穗子, 都是有趣的, 快乐的! 然而今年真的很特别!
这一切都要从春天的末尾说起。圣诞老人坐在他的椅子上, 享受着他服务的最后一个圣诞节应有的休息。生意不像通常在秋末那样需要那么多的关注。所以, 他坐在壁炉旁, 喝着巧克力, 读着新闻。虽然新闻远不是圣诞老人休养生息的最好部分(没有关于 2020 年的消息!)。最终, 圣诞老人收起平板电脑, 从巨大的杯子里深深地抿了一口, 大声说道。"是时候把他们的帽子换掉了!" 不知道是什么原因促使他得出这个结论, 但从这一刻起, 精灵们知道, 一种新的时尚即将到来!
圣诞老人想实现的想法是在精灵帽上加装 WiFi 连接和 LED 灯, 并让 LED 灯闪烁着从北极公司的本地服务器上获得的图案。以下是他的出发点。
role WiFiConnect {
has $.wifi-name is required;
has $.wifi-user is required;
has $.wifi-password is required;
submethod TWEAK {
self.connect-wifi( $!wifi-name, $!wifi-user, $!wifi-password );
}
}
role ShinyLEDs {
submethod TWEAK {
if self.test-cirquits {
self.LED( :on );
}
if self ~~ WiFiConnect {
self.set-LED-pattern: self.fetch( :config-key<LED-pattern> );
}
}
}
class ElfCap2020 is ElfCap does WiFiConnect does ShinyLEDs {...}
请注意, 我在这里不包括类的主体, 因为它太大, 不适合本文。
但尝试编译代码的结果是。
Method 'TWEAK' must be resolved by class ElfCap2020 because it exists in multiple roles (ShinyLEDs, WiFiConnect)
"哦, 果然如此!" - 圣诞老人自言自语地抱怨道。并在类中添加了一个 TWEAK 子方法。
submethod TWEAK {
self.R1::TWEAK;
self.R2::TWEAK;
}
这让编译器很是高兴。于是 ElfCap2020.new 就出现了一个新的、令人惊讶的有趣的帽子实例! "嗬嗬嗬!" - 圣诞老人忍不住开心的笑了起来。开始为公司所有员工生产新帽子的时候了;而这时才发现, 新帽子的量产需要众多第三方供应商和制造商的协调努力, 没有办法在圣诞节到来之前为所有人配备新玩具。
圣诞老人会放弃吗? 不, 他从来不会! 如果我们尝试将老式的盖子现代化呢?只需要那么多的 LED 和控制器, 按时处理应该是可行的!
适应这个世界的行动! 只要设计得好, 应该不难。
$old-cap does (WiFiConnect(:$wifi-name, :$wifi-user, :$wifi-password), ShinyLEDs);
然后…Boom! 方法 'TWEAK' 必须由类 ElfCap+{WiFiConnect,ShinyLEDs} 解析, 因为它存在于多个角色中(ShinyLEDs,WiFiConnect)。
圣诞老人叹了口气。毫无疑问, 这是预料之中的。因为 does 创建了一个隐式的空类, 当编译器试图将两个角色的子方法安装到类中时, 这两个子方法会发生冲突。一个死胡同?不可能! 圣诞老人喜欢快乐的结局! 而且他知道该怎么做。他知道有一个新版本的 Raku 语言正在开发中。它还没有发布, 但可以用 Rakudo 编译器进行测试, 如果在编译单元的最开始使用 v6.e.PREVIEW, 这通常是一个文件。
另外, 圣诞老人知道, 新语言版本带来的一个变化是, 无论如何, 它都会将子方法保留在它们被声明的地方。这意味着, 以前一个子方法从一个角色复制到消耗它的类中, 现在它将保持是角色的唯一属性。而且, 现在语言本身会处理类继承层次中的所有元素, 包括角色, 并调用它们的构造函数和/或析构子方法(如果有的话)。
不知道这意味着什么?请看下面的例子。
use v6.e.PREVIEW;
role R1 {
submethod TWEAK { say ::?ROLE.^name, "::TWEAK" }
}
role R2 {
submethod TWEAK { say ::?ROLE.^name, "::TWEAK" }
}
class C { };
my $obj = C.new;
$obj does (R1, R2);
# R1::TWEAK
# R2::TWEAK
显然, 在现代化脚本的开头添加使用 v6.e.PREVIEW, 使得 $old-cap does (WiFiConnection, ShinyLEDs);
行可以如期工作!
此外, 切换到 Raku 6.e 也使得 ElfCap2020 类也不需要子方法 TWEAK, 如果它的唯一功能是分派到角色 TWEAK 的话。虽然说实话, 圣诞老人还是保留了下来, 因为他需要在建造的时候做一些调整。但好在他不需要那么担心所有类组件组合在一起的小细节。
于是任务就这样解决了。在第一阶段, 所有的旧帽子都进行了现代化改造, 并在本季开始前做好准备, 圣诞节的准备工作占用了北极公司所有剩余的业余时间。现在, 新的帽子将在没有额外的麻烦的情况下生产, 并在 2021 年的季节准备好。节约的时间圣诞老人用来改装 WiFiConnection 和 ShinyLEDs, 让他们也能用在他的雪橇上。当被安全部门告知, 额外的照明让雪橇的伪装变得更加困难, 如果有可能的话, 圣诞老人只是耸了耸肩, 回答道。"你们会成功的, 我相信你们!" 他们确实做到了, 但那是另外一个故事…
95.5. 幸福终点
说到北极, 总是很难分辨真假, 也很难把魔法和科学分开。但毕竟, 自然法则告诉它, 任何足够先进的技术都与魔法无异。通过 Raku, 我们试图把一点好的魔法带入这个生活。要知道, 支持 Raku 的不是别人, 正是圣诞老人一家自己!真是太让人惊讶了。
圣诞快乐, 新年快乐!
96. 第二十二天 - 无点编程的意义何在?
他取了一个新的名字, 大部分是出于通常的原因, 也有一些不寻常的原因, 其中最重要的是名字对他很重要。 - 帕特里克-罗斯福斯, 《风之名》。
如果你是一个程序员, 很有可能名字对你也很重要。赋予变量和函数是写好代码的基本原则之一, 提高名字的质量是重构低质量代码的第一步。而如果你们都是程序员, 并且对 Raku(2019 年由 "Perl 6" 改名而来)一点都不陌生, 那么你们就更能体会到名字的力量和重要性。
这使得无点编程的吸引力 - 它主张删除你代码中的许多名字 - 有点神秘。考虑到好的名字是多么有用, 你很难理解为什么要消除它们。
无点编程(有时也被称为隐性编程)的倡导者提出的一些论点也不一定能帮助我们理解。例如, 一位无点编程的支持者说。
有时, 特别是在涉及高阶函数的抽象情况下, 为切线参数提供名称会使你正在做的事情所依据的数学概念变得模糊不清。在这些情况下, 无点符号可以帮助消除你代码中的这些干扰。
这并没有错, 但也并不完全有用;当读到这句话时, 我发现自己在想"有时候;好吧, 什么时候;在抽象的情况下;好吧, 什么样的情况下?" 而且好像不止我一个人有类似的问题, 正如黑客新闻的置顶评论所示。鉴于这样的论点, 我一点也不奇怪, 许多程序员对无点编程的否定, 基本上和维基百科的做法一样:根据维基百科的说法, 无点编程"具有理论意义", 但会使代码"不必要地晦涩难懂"。
这种观点 - 虽然是可以理解的 - 既是错误的, 我相信也是非常不幸的。以无点风格编程可以使代码的可读性大大提高;正确地进行编程, 它可以使代码不那么晦涩, 而不是更加晦涩。在这篇文章的剩余部分, 我将尽可能具体地解释用较少的名字编码的好处。为了让自己保持诚实, 我还会将一个简短的程序重构为无点风格(代码将使用 Raku, 但之前和之后的版本对非 Raku 程序员来说都应该是容易理解的)。最后, 我将通过指出 Raku 的"有不止一种方法"的理念使你更容易写出清晰、简洁、无点的代码(如果你想的话)的一些方法来结束。
96.1. 无指向性的基本点
我以前说过, 名字很重要, 我是认真的。我的主张是 G.K.切斯特顿(或他的狗)如果关心写好代码的话可能会提出的主张:我们应该减少使用名字, 不是因为名字不重要, 而是恰恰因为名字是多么重要。
让我们退后一分钟。为什么名字首先有助于写出清晰的代码?sub f($a, $b) 可能会告诉你, 你有一个接受两个参数的函数 - 但它让你完全不知道这个函数是做什么的, 也不知道这些参数起什么作用。但只要我们添加名字, 一切都会变得更加清晰:sub days-to-birthday($person, $starting-date)。突然间, 我们对函数的作用有了更好的认识。当然, 这并不是一个完美的想法;特别是, 我们可能会有一些问题, 这些问题可以通过在代码中添加类型来回答(Raku 支持的东西)。但不可否认的是, 名字给我们的代码增加了信息。
所以, 如果添加名称增加了信息, 就会让你的代码更清晰, 更容易理解, 对吗?嗯, 当然…在一定程度上是这样。但这是同样的思路, 导致了一页又一页的贷款"披露", 每一个都是为了给你提供更多关于贷款的信息。尽管有这些意图, 但凡是面对过一叠和埃菲尔铁塔差不多大小的文件的人都可以证明, 这些额外信息的累积效果是让读者感到困惑, 并掩盖了重要的细节。代码中过多的名称也会落入同样的陷阱:即使每个名称在技术上增加了信息, 但过多的名称的累积效应是混淆而不是清晰。
这里用不同的语言表达了同样的想法:名字给你的代码增加的不仅仅是额外的信息, 还有额外的强调。而关于强调的事情 - 无论是来自于粗体、全大写还是命名 - 当它被过度使用时, 就会失去其力量。给所有的东西都起个名字和用 ALL-CAPS 写作是同一种错误。基本上, 不要成为这个家伙。
<Khassaki>: HI EVERYBODY!!!!!!!!!!
<Judge-Mental>: try pressing the Caps Lock key
<Khassaki>: O THANKS!!! ITS SO MUCH EASIER TO WRITE NOW!!!!!!!
<Judge-Mental>: f**k me
source (加上了 expurgation, 主要是为了有借口使用 expurgation 这个词)。
我相信, 使用无点编程技术写出名字较少的代码的根本好处是, 它可以让剩下的名字更加突出 - 这让它们传达的信息比海量的名字更多。
96.2. 什么叫"理解"一行代码?
你理解这行 Raku 代码吗?
$fuel += $mass
让我们想象一下, 一个非常文字化的程序员 - 我们称他们为 Literal Larry - 会如何回应。当然, Literal Larry 并不是指 Raku 的创始人 Larry Wall。多年来, Larry 可能会被指责为各种风格上的缺陷, 但从未被指责为过度文字化)。)
字面意思的 Larry 可能会说:"我当然明白这行是干什么的!有一个 $fuel 变量, 它是由 $mass 变量的值递增的。还有比这更明显的吗?"。但是我对 Larry 的回答是:"你只是告诉了我这一行说了什么, 但没有告诉我它做了什么。如果不了解更多围绕那句话的上下文, 事实上, 我们无法知道那句话的作用。要理解那一行 - 诚然是简单的! - 这一行的理解需要我们在脑海中保持其他行的上下文。更糟糕的是, 因为它是根据另一个变量的值来改变一个变量的值, 所以理解它需要我们跟踪可突变状态 - 这是增加一段代码复杂性的最快方法之一。"
这就奠定了我关于无点式编码的第二个主张。它通常会减少你脑海中理解任何一行代码所需要的上下文/状态的数量。无点式代码在两个方面减少了对上下文/状态的依赖:首先, 在我们完全消除一些命名变量的程度上, 那么我们显然不再需要在头脑中跟踪这些变量的状态。不太明显(但可以说更重要)的是, 无指向性的风格自然会促使你限制变量的范围, 并减少任何时候需要跟踪的变量数量。(在下面的例子中, 你会看到这一点。)
96.3. 一个无指向性的例子
尽管我们的讨论尽可能地实用, 但我担心它已经有点偏离了具体的领域。让我们通过编写一些实际的代码来弥补这个问题吧! 我将以标准的程序风格来介绍一些代码, 将它重构成一种更无点的风格, 并讨论我们从变化中得到了什么。
但是, 我们应该从哪里得到我们的之前的代码呢?它需要写得像模像样 - 我和 Literal Larry 的交流可能已经足够一篇文章的草拟了, 我不希望你认为重构后的版本只是因为原版太糟糕而有所改进。同时, 它不应该是伟大的惯用 Raku 代码, 因为那意味着使用了足够多的 Raku 的超能力, 降低了代码的可访问性(我想解释后面的代码是怎么回事, 但不想陷入对前面的教学)。它的长度也应该恰到好处 - 太短了, 我们就看不到减少上下文的优势;太长了, 我们就没有空间去详细讲解了。
幸运的是, Raku 文档中提供了完美的前文代码:Raku 代码 101。这个简单的脚本并不是惯用法的 Raku, 它是一个只使用 Raku 语法的最基本的程序, 可以完成真正的(虽然是最小的)工作。下面是该页面如何描述脚本的任务。
假设你举办了一场乒乓球比赛。裁判会以 Player1 Player2 | 3:2 的格式告诉你每场比赛的结果, 这意味着 Player1 以 3 比 2 的比分战胜了 Player2。你需要一个脚本来总结每个选手赢了多少场比赛和多少局, 以确定总冠军。
输入的数据(存储在一个叫 scores.txt 的文件中)是这样的。
Beth Ana Charlie Dave
Ana Dave | 3:0
Charlie Beth | 3:1
Ana Beth | 2:3
Dave Charlie | 3:0
Ana Charlie | 3:1
Beth Dave | 0:3
第一行是选手名单。随后的每一行都记录了一场比赛的结果。
我相信这段代码应该是清晰易懂的, 即使是没有看过任何 Raku 的程序员。我要为那些真正没有看过 Raku(或者 Perl)的人提供一个提示, 那就是 @
表示一个变量是类似数组的, %
表示它是类似 hashmap 的, 而 $
则表示所有其他变量。如果其他语法给你带来麻烦, 请查看文档中的完整演练。
这里是 101 版本。
use v6.d;
# start by printing out the header.
say "Tournament Results:\n";
my $file = open 'scores.txt'; # get filehandle and...
my @names = $file.get.words; # ... get players.
my %matches;
my %sets;
for $file.lines -> $line {
next unless $line; # ignore any empty lines
my ($pairing, $result) = $line.split(' | ');
my ($p1, $p2) = $pairing.words;
my ($r1, $r2) = $result.split(':');
%sets{$p1} += $r1;
%sets{$p2} += $r2;
if $r1 > $r2 {
%matches{$p1}++;
} else {
%matches{$p2}++;
}
}
my @sorted = @names.sort({ %sets{$_} }).sort({ %matches{$_} }).reverse;
for @sorted -> $n {
my $match-noun = %matches{$n} == 1 ?? 'match' !! 'matches';
my $set-noun = %sets{$n} == 1 ?? 'set' !! 'sets';
say "$n has won %matches{$n} $match-noun and %sets{$n} $set-noun";
}
好吧, 那是相当快的。它使用我的来声明 13 个不同的变量;让我们看看如果我们声明 0 会是什么样子。 不过在我开始之前, 有一点要注意:我说过上面的代码不是习惯性的 Raku, 下面的代码也不会是。我会在让代码更默契的地方大量引入 Raku 的语法, 但我还是会避开一些我平时会使用的形式, 这些形式与以更无点的风格重构代码无关。我也不会做一些我平时会包含的不相关的改动(比如删除可突变状态)。最后, 这段代码也不同于典型的 Raku(至少我的写法), 它非常狭窄。我通常的目标是将行长控制在 100 个字符以下, 但因为我希望这段代码在任何屏幕上都能读懂, 所以这些行从未超过 45 个字符。
清了清嗓子, 我们就开始吧。我们的第一步和 101 代码差不多;我们打开文件, 然后遍历这些行。
open('scores.txt')
==> lines()
你已经可以看到我们将使用的关键语法之一来采用无点样式:==>, 即 feed 操作符。这个操作符从 open('score.txt') 中获取结果, 并将其作为最终参数传递给 lines()。(这与在 open('scores') 上调用 .lines() 方法类似, 但不完全相同。最重要的是, ==> 将一个值作为最后一个参数传递给下面的函数;调用一个方法更接近于将一个值作为第一个参数传递)。)
现在我们要处理的是输入文件中所有行的列表 - 但实际上我们并不需要所有的行, 因为有些行是无用的(对我们来说)头行。我们将用与命令行相同的方式来解决这个问题:使用 grep 来限制我们关心的行。在本例中, 这意味着只限制那些有 " | " (空格-管道-空格) 分隔符的行, 这种分隔符在所有有效的输入行中都会出现。
==> grep(/\s '|' \s/)
顺带说几句语法说明:首先, Raku 显然对正则表达式有一流的支持。其次, 也许更令人惊讶的是, 请注意, Raku regexes 默认对空白敏感;/'foo' 'bar'/ 匹配的是 'foobar', 而不是 'foo bar'。最后, Raku regexes 要求非字母字符在匹配之前必须用 ' 括起来。
在使用 grep 来限制我们所关心的行之后, 我们要处理的是一个类似 Ana Dave | 3:0 的行的序列。我们的下一个任务是将这些行转换成更容易被机器读取的东西。由于我们刚刚学习了 regex 语法, 所以我们继续使用这种方法。
==> map({
m/ $<players>=( (\w+) \s (\w+) )
\s '|' \s
$<sets-won>=((\d+) ':' (\d+) )/;
[$<players>[0], $<sets-won>[0]],
[$<players>[1], $<sets-won>[1]]
})
这使用了我们上面介绍的 Regex 语法, 并在上面增加了一些内容。最重要的是, 我们现在对我们的捕获组进行了命名:我们有一个名为玩家的捕获组, 它捕获的是 |
字符前的两个空格分隔的玩家名字。显然我们的比赛只识别单字名的玩家, 这个限制在 101 代码中也是存在的)。而 set-won
命名的捕捉组则提取出 :
限定的集合结果。
一旦我们捕获了该场比赛的名字和分数, 我们就将正确的分数与正确的名字关联起来, 并将结果创建一个 2×2 矩阵/嵌套数组。
但实际上, 我们还没有完全完成我们在这个地图里面想要做的所有事情 - 我们已经赋予了每行内元素的顺序意义, 但行本身的顺序目前是没有意义的。让我们通过对我们返回的数组进行排序来解决这个问题, 让赢家总是在前面。
[$<players>[0], $<sets-won>[0]],
[$<players>[1], $<sets-won>[1]]
==> sort({-.tail})
加上这个, 我们的代码到目前为止是:
open('scores.txt')
==> lines()
==> grep(/\s '|' \s/)
==> map({
m/ $<players>=( (\w+) \s (\w+) )
\s '|' \s
$<sets-won>=((\d+) ':' (\d+) )/;
[$<players>[0], $<sets-won>[0]],
[$<players>[1], $<sets-won>[1]]
==> sort({-.tail})
})
在这一点上, 我们已经将我们的输入行处理成数组;我们已经从像 Ana Dave | 3:0 这样的东西变成了像以下这样的东西
[ [Ana, 3],
[Dave, 0] ]
现在是时候开始将我们的独立数组组合成一个代表整个比赛结果的数据结构了。就像现在的大多数语言一样, Raku 用 reduce 来实现这个功能(有些语言也叫同样的操作 fold)。我们将使用 reduce 从我们的嵌套数组列表中构建一个单一的 hashmap。然而, 在我们这样做之前, 我们需要添加一个适当的初始值来减少到(这里, 一个空的 Hash)。
Raku 为我们提供了六种方法 - 包括在调用 reduce 时指定一个初始值, 就像在现代 JavaScript 中一样。我将以不同的方式来完成同样的事情, 一方面是因为它更有趣, 另一方面是因为它可以让我在短短的 10 个字符中向你介绍 5 种有用的语法, 这可能是某种记录。下面是这一行。
==> {%, |$_}()
好了, 这里面装了很多东西! 让我们一步步来。{…} 是 Raku 的匿名块(即 lambda)语法。所以 {…}()
通常会创建一个匿名块, 然后在没有任何参数的情况下调用它。然而, 正如我们上面所说, ==> 会自动将其左手边的返回值作为最终参数传递给右手边。所以, ==> {...}() 用输入到 ==> 中的值来调用这个块。
由于这个代码块没有指定签名(稍后会有更多的说明), 所以它根本没有任何命名的参数;相反, 代码块被调用的任何值都被放置在主题变量中 - 用 $_ 来访问。将我们目前所拥有的东西放在一起, 我们可以展示一个复杂的(但简洁的!)无为的方式:==> {$_}()。该表达式将一个值送入块中, 将该值加载到主题变量中, 然后什么也不做就返回。
然而, 我们的行做了一些事情—毕竟, 我们的行中还剩下 4 个字符和 2 个新概念! 从左边开始, 我们有 % 字符, 你可能认识它, 它是表示一个变量是类似哈希的符号(如果我们是技术性的)。就像这样, 它可以有效地创建一个空的哈希值 - 我们也可以用 Hash.new、{} 或 %() 来实现, 但我最喜欢这里的 %。而我们已经使用过的 ,
操作符, 无需赘述, 它可以将其参数合并成一个列表。
下面是一个使用我们目前所学语法的例子。
[1, 2, 3] ==> {0, $_}()
这将从 0 和 [1, 2, 3] 中建立一个列表。具体来说, 它将建立一个两元素的列表;第二个元素是数组 [1, 2, 3]。这并不是我们想要的, 因为我们想要在现有列表的前面添加 %, 而不是创建一个新的更多的嵌套列表。
你可能已经猜到了, 我们剩下的最后一个字符 - |
- 为我们解决了这个问题。这个 Slip 操作符是我最喜欢的 Raku 的聪明之处。(好吧, 反正是前 20 名 - 有很多竞争者!) |
操作符将一个列表转换为一个 Slip, 正如文档中所说的那样, 它是"一种自动扁平化的列表"。在实践中, 这意味着 Slip 会合并到列表中, 而不是成为其中的单个元素。回到我们之前的例子。
[1, 2, 3] ==> {0, |$_}()
产生了四元素列表 (0, 1, 2, 3), 而不是我们不加 |
的两元素列表 (0, [1, 2, 3])。
把所有这些放在一起, 我们现在就可以很容易地理解我们一直在谈论的那行大约 15 个字符(!)的代码了。回想一下, 我们刚刚使用 map 将我们的行列表转换为一个 2×2 矩阵的列表。如果我们把它们打印出来, 我们会看到类似的东西。
( [ [Ana, 3],
[Dave, 0] ],
...
)
当我们把这个数组输入到 {%, |$_}() 块中时, 我们把它和空的哈希值一起放入一个列表中, 最后得到的是这样的结果。
( {},
[ [Ana, 3],
[Dave, 0] ],
...
)
有了这一行简短而密集的代码, 我们可以继续调用 reduce。就像在许多其他语言中一样, 我们将传入一个函数, 让 reduce 将我们的值合并成一个单一的结果值。我们将使用我们刚刚介绍的块语法来完成这项工作 (看, 我们在这一行上花费的时间已经开始得到回报了!)。因此, 它将看起来像这样。
==> reduce( {
# Block body goes here
})
在填写这个正文之前, 我们先说一下签名(我告诉过你它很快就会出现)。正如我们讨论过的, 当你没有为一个代码块指定签名时, 所有传递给代码块的参数都会被加载到主题变量 $_ 中。我们可以通过对主题变量的操作/索引来做任何我们需要做的事情, 但这可能会变得非常啰嗦。幸运的是, 我们可以通过将参数名放在 -> 和开头的 {
之间来指定一个块的签名。因此, -> $a, $b { $a + $b } 是一个正好接受两个参数并返回其参数之和的块。
在我们的例子中, 我们知道要减少的第一个参数将是我们正在建立的哈希值, 以跟踪比赛中的总胜利, 第二个将是代表下一场比赛结果的 2×2 数组。这就给了我们一个签名, 即
==> reduce(-> %wins, @match-results {
# Block body goes here
})
那么, 我们如何填写正文呢?好吧, 由于我们之前对数组进行了排序, 我们现在调用 @match-results, 我们知道第一行包含赢得最多局数的人(因此也是比赛)。更具体地说, 第一行的第一个元素包含了那个人的名字。所以我们想要第一行的第一个元素—也就是说, 如果我们的数组是 2D 布局的话, 这个元素会在(0, 0)处。幸运的是, Raku 支持直接索引到多维数组, 所以访问这个名字就像 @match-results[0;0] 一样简单。这就意味着我们可以更新我们的哈希值来计算比赛的获胜者。
%wins{@match-results[0;0]}<matches>++;
处理集合的方法非常相似—最大的区别是我们在 @match-results 的两行中都进行迭代, 而不是索引到第一行。
for @match-results -> [$name, $sets] {
%wins{$name}<sets> += $sets;
}
请注意上面的 -> [$name, $sets] 签名。这显示了 Raku 对解构赋值的强大支持, 这是避免显式赋值语句的另一个关键工具。-> [$a, $b] 告诉 Raku, 该块接受一个有两个元素的单数组, 并为每个元素赋名。它相当于写 -> @array { my $a = @array[0]; my $b = @array[1]; ... }。
(如果使用解构赋值来避免赋值的想法在无点风格方面感觉像是作弊, 那么请保持这种想法, 因为当我们到了这个例子的结尾时, 我们会回到这个问题上。)
在我们的 reduce 块的最后, 我们需要返回我们一直在构建的%wins 哈希。把所有的东西放在一起, 我们就得到了:
==> reduce(-> %wins, @match-results {
%wins{@match-results[0;0]}<matches>++;
for @match-results -> [$name, $sets] {
%wins{$name}<sets> += $sets;
}
%wins
})
此时, 我们已经建立了一个包含所有我们需要的信息的哈希值, 我们已经完成了对输入的处理。具体来说, 我们的哈希包含了比赛中每个选手名字的键;每个键的值都是一个哈希, 显示了该选手的总比赛和局数胜利。它看起来有点像这样。
{ Ana => { matches => 2,
sets => 8 }
Dave => ...,
...
}
这包含了我们所需要的所有信息, 但不一定是生成输出的最简单的形式。具体来说, 我们希望按照特定的顺序打印结果(获胜者优先), 但我们的数据是以哈希形式存在的, 而哈希本身是无序的。因此 - 就像经常发生的那样—我们需要将我们的数据从最适合处理输入数据的形状重塑为最适合生成输出的形状。
在这里, 这意味着要从哈希的哈希值变成哈希值的列表。我们的做法是, 首先将我们的哈希转化为一个键值对的列表, 然后将这个列表映射成一个哈希列表。在该映射中, 我们需要将球员的名字(之前存储在外哈希键中的信息)添加到内哈希中—如果跳过这一步, 我们就不知道哪些分数和哪些球员在一起。
下面是这一步骤的样子。
==> kv()
==> map(-> $name, %_ { %{:$name, |%_} })
我要顺便指出, 我们的 map 使用了解构赋值和 |
slip 操作符来构建我们的新哈希。这一步之后, 我们的数据看起来就像:
( { name => "Ana",
matches => 2,
sets => 8 }
...
)
这个列表本身并不像哈希那样无序, 但我们还没有给它安排任何有意义的顺序。现在我们就这样做。
==> sort({.<matches>, .<sets>, .<name>})
==> reverse()
请注意, 这保留了原始代码中有点古怪的排序顺序:按比赛胜负排序, 从高到低;按比赛胜负分出平局;按反向字母顺序分出平局胜负。
至此, 我们已经将所有的输出数据组织好了, 剩下的就是格式化打印了。当打印我们的输出时, 我们需要使用正确的单数/复数词缀 - 也就是说, 我们不想说某人赢了 "1 局"或 "5 局"。
让我们写一个简单的辅助函数来为我们处理这个问题。很明显, 我们可以写一个函数来测试我们是否需要单数或复数的词缀, 但相反, 让我们利用这个机会来看看另外一个让我们更容易编写无点代码的 Raku 特性:根据调用方式执行不同操作的多重函数。
我们想要的函数应该接受一个键值对, 并在相关的值为 1 时返回键的单数版本;否则, 它应该返回键的复数版本。让我们先用 proto 语句说明我们函数的所有版本有什么共同点。
proto kv-affix((Str, Int $v) --> Str) {{*}}
关于那个 proto 语句有几件事要知道。这是我们第一次在代码中加入类型约束, 它们的工作原理和你所期望的一样. kv-affix 只能用字符串作为它的第一个参数和一个整数作为它的第二个参数来调用(这可以防止我们用错误的顺序调用它的键和值, 例如). 它还保证返回一个字符串。此外, 请注意我们可以使用一个类型(这里是 Str)来重构, 而不需要声明一个变量 - 这对于我们想在一个类型上进行匹配而不需要使用值的情况来说非常方便。
最后, 请注意, proto 是完全可选的;事实上, 我认为我不一定会在这里使用 proto。但是, 如果我们不讨论 Raku 对类型约束的支持, 我会觉得很失职, 一般来说, 它对编写无点代码相当有帮助(即使我们今天还没有真正需要它)。
接下来, 让我们处理一下我们需要返回键的单数版本的情况。
multi kv-affix(($_, 1)) { S/e?s$// }
正如你所看到的, Raku 让我们可以用字面值来重构/模式匹配 - 这个版本的 multi 只会在调用 kv-affix 时以 1 作为第二个参数时才会被调用。另外, 请注意我们正在将第一个参数重构为 $_, 即特殊的主题变量。设置 topic 变量不仅可以让我们在不给它命名的情况下使用这个变量, 而且还可以启用 Raku 为当前主题保留的所有工具。如果我们想要这些工具而不需要解构到 topic 变量中, 我们也可以用 with 或 given 来设置 topic)。
将主题设置为我们正在修改的键在这里很有帮助, 因为它让我们可以使用 S/// 非破坏性替换操作符。这个操作符将一个 regex 与主题进行匹配, 然后返回替换匹配部分的字符串的结果。在这里, 我们匹配 0 或 1 个 e’s(e?), 后面是一个 's', 然后是字符串的结尾($)。然后, 我们将 's' 或 'es' s替换为无, 有效地修剪了字符串中的复数词缀。
最后的 multi 候选者是微不足道的。它只是说当前一个多候选者不匹配时(也就是值不是 1 时), 返回未修改的复数键。
multi kv-affix(($k, $)) { $k }
(当我们不需要关心参数的类型或值时, 我们使用 $
作为参数的占位符。)
有了这三行代码, 我们现在有了一个小助手函数, 它将为我们提供键的正确单数/复数版本。老实说, 我不确定这里是否真的值得使用 multi。这可能是一个简单的三元条件—类似 sub kv-affix(($_, $v)) { $v ≠ 1 ?? $_ !! S/e?s$// } - 可能会做得更简洁, 更清楚。但这样一来, 我们就没有理由再去讨论 multis 了, 因为那些东西实在是太有趣了。
无论如何, 现在我们有了我们的辅助函数, 格式化我们输出的每一行是相当琐碎的。在下面, 我将使用老式的 C 风格的 sprintf 来做, 但是如果你喜欢其他的东西, Raku 提供了许多其他的格式化文本输出的选项。
==> map({
"%s has won %d %s and %d %s".sprintf(
.<name>,
.<matches>, kv-affix(.<matches>:kv),
.<sets>, kv-affix(.<sets>:kv )
)})
而当我们将每一行输出的格式化后, 最后一步就是添加相应的头, 连接我们的输出行, 并打印出全部内容。
==> join("\n", "Tournament Results:\n")
==> say();
然后我们就完成了。
96.4. 运算我们的无点重构
让我们来看看整个代码, 谈谈它是如何进行的。
use v6.d;
open('scores.txt')
==> lines()
==> grep(/\s '|' \s/)
==> map({
m/ $<players>=( (\w+) \s (\w+) )
\s '|' \s
$<sets-won>=((\d+) ':' (\d+) )/;
[$<players>[0], $<sets-won>[0]],
[$<players>[1], $<sets-won>[1]]
==> sort({-.tail}) })
==> {%, |$_}()
==> reduce(-> %wins, @match-results {
%wins{@match-results[0;0]}<matches>++;
for @match-results -> [$name, $sets] {
%wins{$name}<sets> += $sets;
}
%wins })
==> kv()
==> map(-> $name, %_ { %{:$name, |%_} })
==> sort({.<matches>, .<sets>, .<name>})
==> reverse()
==> map({
"%s has won %d %s and %d %s".sprintf(
.<name>,
.<matches>, kv-affix(.<matches>:kv),
.<sets>, kv-affix(.<sets>:kv) )})
==> join("\n", "Tournament Results:\n")
==> say();
proto kv-affix((Str, Int) --> Str) {{*}}
multi kv-affix(($_, 1)) { S/e?s$// }
multi kv-affix(($k, $)) { $k }
那么, 我们可以对这段代码说些什么呢?好吧, 32 行代码, 它比 101 版本要长(而且, 尽管这些行很短, 但按字符数计算, 它也更长)。所以这个版本并没有因为简洁而赢得任何奖项。但这从来不是我们的目标。
那么, 它在我们最初的目标 - 减少赋值上的表现如何呢?好吧, 如果我们和 Literal Larry 沟通, 我们可以说它的赋值语句为零;它从不使用 my $name = 'value' 或类似的语法给变量赋值。相比之下, 101 代码使用 my 给变量赋值的次数超过十次。所以, 从字面上看, 我们成功了。
但是, 正如我们已经指出的那样, 忽略解构赋值感觉非常像作弊。同样, 在 regex 中使用命名的捕获, 本质上也是一种赋值/命名的形式。所以, 如果我们采用包容的赋值观点, 101 代码有 15 个赋值, 而我们重构后的代码有 6 个, 所以有很大的下降, 但完全不是数量级的差异。
但试图通过计算赋值语句来评估我们的重构, 可能一开始就是一个愚蠢的错误。我真正关心的是 - 我想你也关心的是 - 我们的代码是否清晰。在某种程度上, 这本质上是主观的, 取决于你个人的熟悉程度和喜好 - 在我看来, ==> {%, |$_}() 是非常清晰的。也许, 在我们花了 3 个段落来讨论这一行之后, 你可能会同意;也可能不同意 - 我怀疑我说的任何进一步的话都不会改变你的想法。所以, 从我的角度来看, 重构后的代码看起来更清晰了。
但清晰不完全是一个主观的问题。我认为重构后的代码在客观上更加清晰 - 而且正是无指向性风格应该提倡的方式。早在这篇文章的开头, 我就声称编写默写代码有两个主要的好处:它在你的代码中提供了更好的强调, 它减少了你需要在脑海中持有的上下文量, 以理解代码的任何特定部分。让我们逐一来看一下这些。
在强调方面, 有一个问题我喜欢问:在全局程序(或模块)作用域有哪些标识符?这些标识符得到了最多的强调;在一个理想的世界里, 它们将是最重要的。在重构后的代码中, 全局范围内根本没有变量, 只有一个项目:kv-affix 函数。这个函数在全局范围内是合适的, 因为它是全球适用的(事实上, 如果这个程序成长起来, 它甚至可以成为一个单独模块的候选对象)。
相反, 在 101 代码中, 全局范围的变量是 $file、@names、%matches、%sets 和 @sorted。至少其中大部分都是纯粹的实现细节, 不值得如此重视。而有些(虽然这渗入了下文讨论的"上下文"点)在全局范围内简直令人困惑。全球范围内, @names 指的是什么?那 %matches 呢?(如果我告诉你 Match 是一个 Raku 类型, 你的答案会改变吗?) %sets 呢?(也是一种 Raku 类型)。当然, 你可以说这些名字选得不好, 我也不一定不同意。但是想出好的变量名是出了名的难, 而在全局范围内想出清晰的名字就更难了 - 概念冲突的机会更多。
为了真正强调最后一点, 请看一下重构后的最后一行代码。
multi kv-affix(($k, $)) { $k }
如果 $k 这个名字发生在全局上下文中, 那它将是彻头彻尾的不可捉摸。它可能是一个迭代变量 (老派程序员倾向于从 i 开始, 然后转向 j 和 k)。它可能代表开尔文度, 或者奇怪的是, 代表库仑常数。或者它可以是任何东西, 真的。
但因为它的范围比较有限, 所以意思很清楚。这个函数接收一个键值对(通常在 Raku 中用 .kv 方法或 :kv 副词生成), 并命名为 kv-affix。考虑到这些环境, $k 代表 "key" 一点都不神秘。将项目保留在全局范围之外, 既能提供更好的强调, 又能提供一个不那么混乱的上下文来评估不同名称的含义。
我声称无点代码的第二个大的好处是, 它减少了你需要在脑海中持有的上下文/状态的数量, 以理解任何给定的代码。比较这两个脚本也支持这一点。看看 101 代码的最后一行。
say "$n has won %matches{$n} $match-noun and %sets{$n} $set-noun";
在头脑中评估这一行需要你知道 $n(上面 3 行定义)、$match-noun(上面 2 行)、$set-noun(1 行)、%sets(24 行)和 %matches(25 行)的值。考虑到这个脚本是多么的简单, 这是一个需要跟踪的大量状态!
相比之下, 重构后的代码的等价部分为:
"%s has won %d %s and %d %s".sprintf(
.<name>,
.<matches>, kv-affix(.<matches>:kv),
.<sets>, kv-affix(.<sets>:kv) )
评估这个表达式的值只需要你知道主题变量的值(定义在上面一行)和纯函数 kv-affix(定义在下面 3-5 行)。这并不异常:重构后的代码中每个变量的定义都不超过最后使用它的地方的 5 行。
(当然, 以无点风格编写代码既不足以也没有必要限制变量的范围。但正如这个例子所说明的那样—我的其他经验也证明了这一点—它肯定有帮助。)
96.5. Raku 支持实用的(而不是纯粹的)无点编程
一个真正的无点编程爱好者可能会反对重构后的代码, 理由是它不够默契。尽管避免了明确的赋值语句, 但它还是相当广泛地使用了命名的函数参数和反结构赋值;它并不纯粹。
尽管如此, 重构后的代码位于一个实用主义的中间地带, 我发现这个中间地带非常有成效:它是无指向性的, 足以获得这种风格的许多清晰、上下文和强调的好处, 而不害怕使用一两个名字来增加清晰度。
而这个中间地带正是乐乐的闪光点(至少在我看来是这样的!)。完全可以把乐写成各种不同的风格, 而且很多风格丝毫不失分寸)。)
下面是一些支持实用型无点编程的 Raku 特性(大部分, 但不是全部, 我们在上面看到了)。
如果你已经是一个 Raku 专家, 我希望这个列表和这篇文章能给你一些其他方法的想法。如果你是 Raku 的新手, 我希望这篇文章能让你兴奋地探索一些 Raku 可以扩展你编程的方式。如果你对写 Raku 代码完全不感兴趣 - 好吧, 我希望你会重新考虑, 但即使你不感兴趣, 我也希望这篇文章给你一些思考, 并给你留下一些想法, 让你在你选择的语言中尝试。
97. 第二十三天 - 面向圣诞的设计和实现, 第一部分
每年到了1月8日北极开学的时候, 在每一个版本的圣诞送礼精神都巡视过后, 圣诞老人需要坐下来安排北极社区学院的课程。这些精灵们需要持续的教育, 他们需要真正学习那些新奇的玩具, 除了行业工具和技能之外。
另外, 让这些精灵们在这一年里都忙于一些实际有用的事情, 也是一件好事, 以免他们开始发明一些实用的笑话, 然后互相玩弄。
既然有一百多万的精灵, 那么全国政协就很庞大了。但是分配课程到教室也是个大问题。报名开课后, 他们就互相讨论什么是终极吹风课, 哪门课打雪仗赢了就给你加分。所以, 你不能随便充值报名:每年都要查看可用教室, 然后匹配到最够用的教室。
下面是可用教室。
Kris, 33
Kringle, 150
Santa, 120
Claus, 120
Niklaas, 110
Reyes Magos, 60
Olentzero, 50
Papa Noël, 30
他们以世界各地的送礼神灵命名, 其中最大的班级显然叫 Kringle。在任何一年, 这可能是报名期结束后的报名情况。
Woodworking 101, 130
Toymaking 101, 120
Wrapping 210, 40
Reindeer speed driving 130, 30
ToyOps 310, 45
Ha-ha-haing 401, 33
他们喜欢《木工 101》, 因为它是入门级的, 而且他们可以保留一年中做的任何任务。另外, 你还可以得到所有的木头刨花, 用于在你的炉子里燃烧, 这在一个常年寒冷的地方是非常有用的。
于是圣诞老人就制作了这个脚本来处理, 用了一点无点编程, Perl 就是 Perl, 是 Perl 和 Raku 这两种姐妹语言的鞭策和刁难。
sub read-and-sort( $file where .IO.e ) {
$file.IO.lines
==> map( *.split( /","\s+/) )
==> sort( { -$_[1].Int } )
==> map( { Pair.new( |@_ ) } )
}
say (read-and-sort( "classes.csv") Z read-and-sort( "courses.csv"))
.map( { $_.map( { .key } ).join( "\t→\t") } )
.join( "\n" )
该子程序读取给定名称的文件, 检查之前是否存在, 用逗号将其分割, 按数量递减排序, 然后从其中创建一个 Pair。另一个命令使用 Z 操作符将两个列表按照精灵数量递减的顺序压缩在一起, 并生成一个和这个列表一样的列表。
Kringle → Woodworking 101
Santa → Toymaking 101
Claus → ToyOps 310
Niklaas → Wrapping 210
Reyes Magos → Ha-ha-haing 401
Olentzero → Reindeer speed driving 130
所以克林格讲堂得到了木工, 从那里开始往下走。Kris 和 Papa Noël 教室什么都没有得到, 已经被淘汰了, 但却被保留在那里用于课外活动, 比如唱颂歌和折纸。
所有这一切都很好, 而它:只要你还记得哪里是文件, 脚本做了什么, 没有什么改变名称或容量, 文件不会丢失。但这些都是很多的如果, 而且圣诞老人也不再年轻了。
事实上, 也不会变老。
所以圣诞老人和它的 ToyOps 团队将需要一个更系统的方法来处理这个调度, 从需求中创建一个面向对象的应用程序。在学习了 TWEAKS和角色的所有知识后, 现在是时候站在后面, 从一开始就把它用到工作上了。
97.1. 敏捷调度
弥漫在北极的寒冷让一切都变得不那么敏捷。不过不用担心, 当我们在那里为 IT 运营创造一些东西的时候, 我们仍然可以做到敏捷。首先我们需要的是用户故事。谁想创建一个时间表, 它是什么?所以我们坐下来把它们写下来。
-
[US1] 作为 NPCC 的院长, 鉴于我有一个教室的列表(以及它们的容量)和课程的列表(以及它们的注册人数), 我想以最好的方式将教室分配给课程。
好的, 我们在这里得到了一些工作, 所以我们可以应用一点领域驱动设计。我们有几个实体, 教室和课程, 还有几个值对象:单教室和单课程。让我们去写它们。当然, 使用 Comma。
用类来定义类是很自然的事情。但看着这两个定义好的类, 圣诞老人也说不出哪个是哪个。说到底, 有名字有容量的东西就是有名字有容量的东西。这就要求我们按照 DRY(不要重复)原则, 将常用代码进行保理。
另外, 我们上面有一个原型, 几乎可以说, 不管我们用什么教室和课程, 如果能用同样的方式来排序, 我们的生活会更轻松。所以, 我们最好的办法可能是用常见的行为分拆出一个角色。我们做一个问题出来。和美国差不多, 但是主角要做程序员。
-
作为一个程序员, 我需要用同样的方法, 按容量对教室和课程进行分类。
就叫 Capped 吧, 就像有一定的容量一样。因为这两个对象会有自带的方法 - capacity, 所以我们可以调用它来进行排序。我们上面的例子表明, 我们需要用几个元素创建一个对象, 所以这又是一个问题。
-
作为一个程序员, 我需要使用位置参数建立一个对象, 这些参数是字符串。
所以最后我们的角色会有这些东西。
unit role Cap;
has Str $!name is required;
has Int $!capacity is required;
submethod new( Str $name, Int $capacity ) {
self.bless( :$name, :$capacity )
}
submethod BUILD( :$!name, :$!capacity ) {};
method name() { $!name }
method capacity() { $!capacity }
再加上方便的 name 和 capacity 的访问器, 在保持这些私有物的同时, 也意味着它们是不可改变的。值对象是简单地得到一个值的东西, 没有太多的业务逻辑。
我们已经可以创建一个函数来完成教室/课程列表的排序, 也就是 Caps, 但在 OO 设计中, 我们应该尽量把原来问题中的实体放到类中(从中衍生出对象)。这些实体将是真正做重活的实体。
如果我们能创造出还算相同的东西, 那就太好了, 因为我们将能统一地处理它们。但是有一个难题:其中一个将包含一个课程列表, 另一个将包含一个教室列表。它们都是做 Cap 的, 所以原则上我们可以创建一个类来承载 Cap 的列表。但是这个控制器类会有一些业务逻辑:它将创建该类的对象;我们不能简单地使用 Roles 来创建组成它们的类。所以我们将使用一个 curried Role, 一个参数化的角色, 使用我们将实例化的角色作为参数。这将是 Cap-List。
unit role Cap-List[::T];
has T @!list;
submethod new( $file where .IO.e ) {
$file.IO.lines
==> map( *.split( /","\s+/) )
==> map( { T.new( @_[0], +@_[1] ) } )
==> sort( { -$_.capacity } )
==> my @list;
self.bless( :@list );
}
submethod BUILD( :@!list ) {}
method list() { @!list }
这段代码很熟悉, 和我们上面所做的类似, 只是我们把创建对象和排序列表换了一下, 我们用 .capacity 对列表进行排序。我们创建一个列表并将其祝福(bless)到对象中。从中, 我们创建了几个类。
unit class Classroom-List does Cap-List[Classroom];
unit class Course-List does Cap-List[Course];
我们不需要更多的逻辑, 这就是全部。基本上是同样的事情, 同样的业务逻辑, 但我们以类型安全的方式工作。我们也已经测试了整个事情, 所以我们已经冻结了 API, 并保护它免受未来的进化。圣诞老人也会同意的。
所以我们就快完成了。让我们用这个来写赋值函数。
my $courses = Course-List.new( "docs/courses.csv");
my $classes = Classroom-List.new( "docs/classes.csv");
say ($classes.list Z $courses.list )
.map( { $_.map( { .name } ).join( "\t→\t") } )
.join( "\n" );
这将返回和我们之前一样的东西。但我们已经把所有的业务逻辑(排序, 以及其他任何我们可能想要的东西)都隐藏在对象囊中。
97.2. 但是, 我们有吗?
其实不然。赋值也应该被封装在某个类中, 并进行彻底的测试。不过, 这要留待下次再谈。
98. 第二十四天 - 面向圣诞的设计和实现, 第二部分
我们(重新)的出发点是这个用户故事。
[US1] 作为一个 NPCC 院长, 我有一个教室列表(及其容量)和课程列表(及其注册人数), 我想以最好的方式将教室分配给课程。
于是我们就得出了这个脚本。
my $courses = Course-List.new( "docs/courses.csv");
my $classes = Classroom-List.new( "docs/classes.csv");
say ($classes.list Z $courses.list )
.map( { $_.map( { .name } ).join( "\t→\t") } )
.join( "\n" );
但这并不能真正解决这个问题。每一个用户故事都必须通过一套测试来解决。但是, 好吧, 用户故事一开始就挺模糊的。"以最好的方式"可以是任何东西。所以可以说, 我们所做的方式, 确实是最好的方式, 但是没有测试, 我们真的不能说。所以我们重新表述一下 US。
[US1] 作为 NPCC 的院长, 既然我有一份教室的清单(及其容量)和课程的清单(及其招生人数), 我就想把教室分配给课程, 让所有的课程都没有教室, 所有的课程都适合在一个教室里上课。
这是我们可以坚持的。但当然, 脚本是不能测试的(好吧, 可以, 但这是另一个故事)。所以, 让我们给这个脚本上点课吧。
98.1. 躲开它与列表
其实, 在上面的脚本中, 有一些东西并没有真正的切入。在原脚本中, 你把几个列表压缩在一起。在这里, 你需要调用 .list 方法来实现同样的目的。但是对象还是一样的, 对吗?把两个对象压缩在一起不应该是可以的, 也是简单的吗?另外, 这就要求类的客户端要知道实际的实现。一个对象应该尽可能的隐藏它的内部结构。让我们把这个问题作为一个问题来解决
作为一个程序员, 我希望持有课程和教室的对象在 "zipping" 的上下文中表现得像一个列表一样。
圣诞老人揉着自己的胡子, 想着如何把这个事情做出来。Course-List 对象, 嗯, 就是那种精确的对象。它们包含了一个列表, 但是, 它们怎么能表现为一个列表呢?而且, "在 zipping 上下文中", 什么才是精确的列表。
长话短说, 他想明白了, 一个 "zipping context" 其实是迭代两个列表中的每一个成员, 依次把它们放在一起。所以我们需要把对象做成 Iterable。幸运的是, 这一点你在 Raku 中绝对可以做到。通过混合角色, 你可以让对象以某种其他方式表现出来, 只要你有这样的机制。
unit role Cap-List[::T] does Iterable;
has T @!list;
submethod new( $file where .IO.e ) {
$file.IO.lines
==> map( *.split( /","\s+/) )
==> map( { T.new( @_[0], +@_[1] ) } )
==> sort( { -$_.capacity } )
==> my @list;
self.bless( :@list );
}
submethod BUILD( :@!list ) {}
method list() { @!list }
method iterator() {@!list.iterator}
关于原来的版本, 我们只是混入了 Iterable 的角色, 实现了一个迭代器方法, 在 @!list 属性上返回迭代器。然而, 这并不是我们在 "zipping 上下文"中唯一需要的东西。这就引出了一个关于 Raku 容器和绑定的小题材。
98.2. 容器和 containees
El cielo esta entablicuadrillado, ¿quién lo desentablicuadrillará? El que lo entablicuadrille, buen entablicuadrillador será. — 西班牙语绕口令, 通俗地翻译为 “The sky is tablesquarebricked, who will de-trablesquarebrick it? The tablesquarebrickalyer that tablesquaresbricks it, good tablesquarebrickalyer will be.
值得一看的是 Zoffix Znet 的这篇 Advent 旧文, 关于什么是绑定, 什么是赋值, 在 Raku 世界里。绑定本质上是用另一个名字来调用一个对象。如果你把一个对象绑定到一个变量上, 那么这个变量的行为就会和这个对象完全一样。反之亦然。
my $courses := Course-List.new( "docs/courses.csv");
我们只是用另一个名字来调用这个绑定的右侧, 这样更短, 更方便。我们可以调用任何方法, 也可以通过在其上调用 for 来把这个"放在 zipping 上下文中"。
.name.say for $courses;
会返回:
Woodworking 101
Toymaking 101
ToyOps 310
Wrapping 210
Ha-ha-haing 401
Reindeer speed driving 130
正如你所看到的, "zipping 上下文"与(尚未被记录的)可迭代上下文 完全相同, 当与 for 一起使用时, 它也会被调用(或胁迫对象进入, 不管你喜欢什么)。for $courses 实际上会调用 $courses.iterator, 返回它包含的列表的迭代器。
这其实并不是离题, 这完全是题外话。然而, 我不得不离题, 以解释在我们使用正常赋值的情况下会发生什么, 如:
my $boxed-courses = Course-List.new( "docs/courses.csv");
赋值是 Raku 中一个很好很奇特的东西。就像上面提到的文章所说的那样, 它把一个对象框进一个容器里。你不能轻易地把任何一种东西框进 Scalar 容器里, 所以, Procusto 风格的它需要以某种方式把它装进容器里。但无论你怎么想, 事实上, 与之前不同的是, $boxes-courses 并不是一个 Course-List 对象, 它是一个 Scalar 对象, 它将一个 Course-List 对象进行了标量化, 或者说项目化。你需要什么来去掉它的标量化呢?简单的调用它的 de-cont 操作符, $boxed-courses<>, 它可以解开容器并给你里面的东西。
98.3. 调度器类
好了, 回到我们的常规日程……r。
再次强调, 不要让我们只想按部就班地做事。我们需要创造一个问题来解决
-
作为一个程序员, 我需要一个类来创建时间表, 给定几个带有课程和类的文件。
圣诞老人很乐意证明这样的事情。
use Course-List;
use Classroom-List;
unit class Schedule;
has @!schedule;
submethod new( $courses-file where .IO.e,
$classes-file where .IO.e) {
my $courses := Course-List.new($courses-file);
my $classes := Classroom-List.new($classes-file);
my @schedule = ($classes Z $courses).map({ $_.map({ .name }) });
self.bless(:@schedule);
}
submethod BUILD( :@!schedule ) {}
method schedule() { @!schedule }
method gist {
@!schedule.map( { .join( "\t⇒\t" ) } ).join("\t");
}
它不仅可以安排课程, 你只要说出来就可以使用。它还经过了测试, 所以你知道无论如何它都会工作。就这样, 我们可以结束用户故事了。
但是, 我们可以吗?
98.4. 用脚本收尾
圣诞老人对这个新应用真的很满意。他只需要写这个小的主脚本。
use Schedule;
sub MAIN( $courses-file where .IO.e = "docs/courses.csv",
$classes-file where .IO.e = "docs/classes.csv") {
say Schedule.new( $courses-file, $classes-file)
}
这是直奔主题的:这里是文件, 这里是时间表。但是, 除此之外, 它是经过测试的, 为突发事件做了准备, 实际上还可以进行扩展, 以考虑到突发事件(如果你无法将精灵装入某个类目会怎样?如果你需要考虑到其他限制条件, 比如不先装最大的, 而是先装smuggest怎么办?你可以直接改变算法, 甚至不用改变这个主脚本。你其实并不需要。
raku -Ilib -MSchedule -e "say Schedule.new( | @*ARGS )" docs/courses.csv docs/classes.csv
使用库搜索路径的命令行开关(-I)和加载模块(-M), 你可以只写一个语句, 将参数和扁平化, 使它们成为方法的签名。
做完这些, 圣诞老人坐在他最喜欢的扶手椅上, 享受着一杯桶装蛋酒, 看着每一部正在流传的圣诞老人主题电影, 直到下一个 NPCC 学期开始。
99. 第一天 - Raku 中的异步和并发入门
从今天之后的23天里, 在我们进入这个降临日历之前, 我想确保介绍一下我将要涉及的基本概念。我把它称为 "Raku Async & Concurrency Advent Calendar", 但这些术语是什么意思呢, 它们又是如何应用于 Raku 的呢?
这个 Advent Calendar 的前提是你对 Raku 有基本的了解。如果你不了解 Raku, 但你熟悉另一种语言, 我相信你可能会跟得上。然而, 这是针对中高级开发者的, 所以我推荐你先从这些资源中学习 Raku。
-
Perl 6 入门(Raku 是以前称为 Perl 6 的语言)
99.1. 异步编程
Async 是异步编程的简称, 因为异步(asynchronous)的输入很麻烦。异步编程是一种编程风格, 对函数的调用与其返回是脱节的。通常, 我们会写一个这样的函数:
sub double($x) { $x * 2 }
my $val = double(21);
如果我们想把这段代码变成异步的, 我们可以尝试这样做:
sub double($x) { sub () { $x * 2 } }
my $promise = double($x);
# more code
# ...
# more code
my $val = $promise.();
这是一个愚蠢的人为的例子, 但重点是我们实际上要到后来才会收到值。这种编程风格主要是在你知道你需要完成一些工作, 但不需要立即得到结果的时候有用。当你可能需要在此期间做其他工作时, 这种方式尤其有用。
99.1.1. 什么时候才有意义?
举几个例子说明异步可能会有帮助的情况。
-
所有基于套接字的客户端/服务器应用程序都必须是异步的。对于 TCP 来说, 在服务器上, 你建立一个监听套接字, 然后当客户端连接时, 你会收到一个连接的套接字, 你可以在这个套接字上使用读写操作交换数据。而 TCP 客户端则是请求一个连接, 然后等待服务器接受连接。在等待的过程中, 客户端可以继续进行工作。
-
一个实时处理日志数据的程序通常有几个处理步骤, 每一行都要经过。它等待数据的到达。它解析每一行数据, 从中获取有趣的信息。它对数据进行过滤, 以决定它是否对当前任务有用。它对数据进行计算。这些操作中的每一项都可以在异步编程链中执行, 其中每一步只要有一定量的数据准备好了, 就会执行这些操作。
异步只是一种编程风格, 它适合于你, 软件开发者, 决定它适合的地方。有些问题比其他问题更具有内在的异步性, 但你也可以同步地构建它们。当它适合你的时候就使用异步(async)。
99.1.2. 工具有哪些?
99.1.3. 它是什么样子的呢?
那么 Raku 中的异步编程是什么样的呢?这里有一个基本的计数程序, 但我们不使用循环, 而是每秒钟输出一个数字:
react {
whenever Supply.interval(1) -> $n {
say $n;
}
}
这在功能上等同于运行:
for 1...* -> $n {
say $n;
sleep 1;
}
如你所见, 语法非常简单, 希望容易上手。你甚至可以认为它算是同步的, 而不会遇到太多麻烦。Raku 中的异步编程的一个目标是让那些不完全理解它的人至少能理解发生了什么。
99.2. 并发编程
我在大学里上过一门关于并发编程的课。恐怕我没有很好地掌握这个主题就通过了这门课。然而, 我认为部分原因是因为它是一个很难真正理解的概念, 通过掀开引擎盖, 看看所有的运动部件。不过, 也有一部分原因是因为这门课用数学术语来教授并发性, 而我发现实用术语更容易理解。
并发编程仅仅是同时运行程序的各个部分的行为。程序中可以同时运行的部分称为线程。你可以把线程视为程序可以在其中运行其代码的通道。例如, 每个线程可能使用不同的起始数据运行同一个程序代码。或者, 一个程序可以有一个线程用于更新图形界面, 一个线程用于与网络上的其他程序对话, 一个线程用于对接收到的数据进行本地处理。
99.2.1. 裸机线程
分道运行可以通过多种方式实现, 包括:
-
在单 CPU/单核计算机上, CPU 会将需要同时进行的代码交错执行。代码实际上并不是同时运行, 而是每个程序的每个线程都有时间在 CPU 上运行一些指令, 然后进行上下文切换, 运行另一个线程。
-
在多 CPU/多核计算机上, 这既意味着在每个 CPU 核心上交错运行代码, 也意味着在每个核心上同时在不同的硬件上运行各种程序和线程。
无论如何, 你都会遇到一个问题, 即任何给定线程中的代码可能只完成了任务的一小部分, 因此你需要确保线程之间安全地交互。这就是所谓的"线程安全代码"。
99.2.2. 线程安全
构建线程安全代码有几种基本的编程风格。我在这里从最喜欢到最不喜欢的风格来介绍它们:
-
任何无状态的代码或者只有自身内部状态变化的代码本质上都是线程安全的。只有当代码需要访问其他代码可能使用的数据时, 它才会变得不安全。
-
通常情况下, 可以将代码写成内部是无状态的, 但在完成执行时执行状态更改。只要某些内容以安全的方式同步状态更新, 这种数据转换就是线程安全的。执行转换的代码本身不必对安全性做出任何特殊规定。
-
对于必须在执行线程之间共享某些可变状态的情况, 每次读取和写入数据时都必须格外小心, 以确保这些更改不会破坏状态。
Raku 提供了专门的工具来处理这些情况。每当编写并发程序时, 都应该尽可能地瞄准前两种编码风格。这些是最容易理解和维护的。然而, 第三种选择有时是最好的
99.2.3. Raku 之道
在 Raku 中, 我们不倾向于直接使用线程。取而代之的是, 我们调度代码块异步运行。代码的实际运行方式取决于所使用的调度程序。Rakudo 上的默认调度程序会将代码块调度到主线程之外的单独线程上运行。[4]
调度要运行的块的主要方法是使用 start
块, 类似于下面的示例。
start {
# Subtask 1
}
start {
# Subtask 2
}
# Main task
我个人将根据这种方式或通过其他方式安排的工作称为"任务"(task)或"子任务"(subtask), 这取决于上下文。不过这不是 Raku 主义, 所以要注意其他人的术语可能会有所不同。
总之, 主要的一点是, 并发是指同时运行你的代码的两个不同部分。
99.3. 并行编程
我们在本日历中经常使用的第三个术语是并行编程。并行编程与并发性非常相似, 但主要面向并行处理数据。例如, 如果你有大量的整数, 你想通过一个函数来运行, 你可以为对这些整数中的每一个整数都并发地执行该操作。这是并行编程。
它是与异步(async)或并发是一个独立的术语, 原因有二。其一, 并行程序不一定是并发或异步的, 其二, 有一些与并行编程相关的特殊优化, 它们不一定是通用并发或异步的一部分。
例如, 这是 Raku 中的一个小型并行程序:
my @doubles = (1...10_000_000).hyper.map(* * 2);
当这个程序运行时, Raku 会安排任务在多个线程中运行, 对范围内的所有值进行迭代, 并将所有1000万个值全部分批翻倍。把工作分为几批, 然后选择如何调度, 这就是并行编程本身的特殊主题。这个程序可能是并发的, 但它不是异步的。我们也可以异步地进行并行编程, 但在这个例子中我们并没有。
99.4. 编程编程
所以, 当我们把这些术语放在一起的时候, 我们会发现一个程序可能具有所描述的三种属性中的任何一种。它可能是并发的, 异步的和并行的。它可能不具备这些特性。它可能只是其中的一个或两个, 任意组合。这些都是不同的编程风格。
Raku 提供的工具旨在使你的代码在异步、并发和并行地编写时更容易阅读。它还旨在鼓励你以一种固有的线程安全的方式编写你的代码, 允许你在多个线程中同时操作时安全地转换状态。
我希望在接下来的23天里, 我将更好地让你决定何时拥抱Raku的每一个特性, 以及如何以一种让你的代码更快, 同时又易于人类解析和理解的方式进行。
干杯。
100. 第二天 - Promise
在 Raku 中, Promise 代表了异步任务之间通信的最简单的高级语言功能。它们很像人与人之间的承诺。例如, 我可能会答应我的儿子, 我会帮助他完成学业。当我帮助他的时候, 我就会遵守这个承诺。或者, 如果由于某种原因, 我未能帮助他, 我就会违背这个承诺。Raku 中 的 Promise 也是如此。当一个值到达的时候, 返回一个值的承诺(Promise)就会被遵守。如果发生了错误, 阻止了值的到达, 那么返回值的承诺(Promise)就会被打破。
所以, 让我们来看看 Raku 中的基本承诺:
my $promise = start {
my $nth = 0;
my $nth-prime;
for 1..* -> $number {
$nth++ if $number.is-prime;
if $nth == 10_000 {
$nth-prime = $number;
last;
}
}
$nth-prime;
}
await $promise.then({ say .result });
上面的代码使用 start 块开始查找第 10000 个素数。这个块返回一个 Promise 对象。这个对象以三种状态之一存在(你可以用 .status
方法检查)。初始状态为 Planned 状态, 然后它进入两种最终状态中的一种。如果无法得到一个结果(通常是因为发生了异常), 则进入 Broken 状态, 而当结果可用时, 则进入 Kept 状态。一旦处于 Kept 状态, .result
方法将立即返回被保存的 Promise
的值。
可以使用 .then
方法将 Promise 链在一起。这是通过添加另一个块来实现的, 该块在第一个块保留后立即开始。新的块将被赋予前一个 Promise 对象作为参数, 该方法返回一个新的 Promise, 该 Promise 将包含下一个块的结果。
上面代码中的 start
块将计算安排在默认线程池中的下一个未使用的线程上运行, 并返回一个 Promise 对象。[5] 我们使用 .then()
来输出计算的 .result
, 只要它可用。
最后, 我们有一个 await 语句, 该语句使主线程暂停, 直到值变得可用。如果没有这条语句, 我们的程序会在计算完成之前结束。
await
语句还允许一个被破坏的 Promise 传递异常。考虑一下这段代码:
my $promise = start { die 'bad stuff' }
sleep 1;
say 'something';
上面的代码会休眠 1 秒钟, 并打印 "something" 到标准输出。然而, 异常永远不会被接收到。这是因为尽管异常会导致 Promise 被破坏, 但我们根本没有看到 Promise 的结果。我们可以在 Promise 上添加一个 await
, 准备好接收该值, 任何抛出的导致 Promise 被破坏的异常都会被接收。
my $promise = start { die 'bad stuff' }
sleep 1;
say 'something';
await $promise;
CATCH {
default {
say "ERROR: $_.message()";
}
}
这段代码和之前做的事情完全一样, 但是在 "something" 后面也会输出 "ERROR: bad stuff"。一定要确保在 start
块内部或者在另一个以这种方式接收 Promise 的块中处理你的异常, 否则你可能最终会遇到奇怪的意外问题。
这些都是 Raku Promise 的基本要素。
干杯。
101. 第三天 - Supply 块
当你的 Raku 应用程序中流过需要在线程间安全地访问的数据流时, 你需要 Supply。今天, 我们将讨论一种特殊的使用供应的方式, 即 supply 块。如果你熟悉序列, 也就是 Seq 对象, supply
块的工作方式与之非常相似, 但是允许你在数据到来时拉动它, 并在这期间轻松地做其他事情。
multi a(1) { 1 }
multi a(2) { 2 }
multi a($n where $n > 2) { a($n - a($n-1)) + a($n - a($n-2)) }
my $hofstadter-generator = supply {
for (1 ... *).map(-> $n { a($n) }) -> $v {
emit $v;
}
}
react {
whenever $hofstadter-generator -> $v {
note $v;
}
whenever Supply.interval(1) {
say "Waiting...";
}
}
所以我们这里有三段代码:
至于我们的中心主题, supply
块, 它返回一个可以被"分接"(tapped)的 Supply
对象。通过调用 .tap
或 .act
方法, 或者在 whenever
块中作为 react
或另一个 supply
块的一部分使用。
在 supply
块的情况下, 我们得到的这种 Supply
对象被称为"按需供应"(on demand Supply)。这意味着每当 Supply
被分接(tap)时, 与该块相关的代码都会被运行。每当遇到 emit
时, 传递给 .tap
或 whenever
语句的块都会被运行, 并给出作为参数发出的值(在上面的示例代码中命名为 $v
)。执行一直持续到给 supply 的块退出或到达 done
语句, 这将导致 supply
块退出(这个操作与 last
for 循环非常相似)。
例如, 如果我们希望我们的序列在迭代 100 次后自动结束, 我们可以这样重写我们的 supply
:
my $stopping-hofstadter-generator = supply {
for (1 ... *).map(-> $n { a($n) }) -> $v {
emit $v;
done if $v > 100;
}
}
在使用 supply
块时, 要记住一个非常重要的因素, 即 emit
命令会在每个分接(tap
)完成工作之前进行阻塞。[7]这意味着该 supply
块"支付"其分接的计算时间。这种延迟提供了一种反压形式, 防止生成的 Supply
运行速度超过分接(tap)的处理速度。因此,如果你想让你的事件流以光速运行, 并且不在乎工具处理是否能跟上, 你需要确保分接(tap)立即启动(start
)新的任务来运行, 以避免阻塞, 或者你想要一个不同于普通 Supply
的机制来处理工作负载。
干杯。
102. 第四天 - Channel
在 Raku 中, Channel 是一个异步的数据队列。你可以将数据送入队列的一端, 并在另一端安全地接收数据, 即使是在多个线程参与的情况下。
让我们考虑一下哲学家用餐问题的一个变体:我们有五个哲学家在桌子上喝汤。他们不互相交谈, 因为他们太忙于思考哲学问题。然而, 只有 2 个汤勺。每当一位哲学家想喝一口汤时, 她都需要获得一把汤勺。幸运的是, 每位哲学家都愿意分享汤勺, 并在每次舀完后将勺子放在桌子的中央。
我们可以这样来模拟这个问题:
my $table = Channel.new;
my @philosophers = (^5).map: -> $p {
start {
my $sips-left = 100;
while $sips-left > 0 {
my $spoon = $table.receive;
say "Philosopher $p takes a sip with the $spoon.";
$sips-left--;
sleep rand;
$table.send($spoon);
sleep rand;
}
}
}
$table.send: 'wooden spoon';
$table.send: 'bamboo spoon';
await Promise.allof(@philosophers);
在这里, 我们有五个任务运行在五个线程中, 每个任务都在争夺两个汤勺资源中的一个。他们每人将各喝 100 口汤。运行这个程序会得到 500 行类似于这样的输出:
...
Philosopher 0 takes a sip with the wooden spoon.
Philosopher 2 takes a sip with the bamboo spoon.
Philosopher 3 takes a sip with the wooden spoon.
Philosopher 1 takes a sip with the bamboo spoon.
Philosopher 4 takes a sip with the bamboo spoon.
Philosopher 2 takes a sip with the bamboo spoon.
Philosopher 0 takes a sip with the wooden spoon.
Philosopher 1 takes a sip with the wooden spoon.
Philosopher 0 takes a sip with the wooden spoon.
...
代码本身非常简单。我们启动了(start
)五个线程, 每个线程代表一个哲学家。每个哲学家都调用 .receive
接收下一个可用的勺子。该方法将一直阻塞, 直到有汤勺可用为止。哲学家喝一口汤, 然后使用 .send
将勺子放回到桌子上供其他人使用。最终, 哲学家喝完了 100 口, 并且由 start
返回的 Promise 会被保留下来。
主线程通过使用 .send
将两个汤勺放在桌子上来启动这个过程。然后, 它使用 await
来保持程序运行, 直到所有的哲学家完成任务。
就 CPU 而言, 通道的开销非常低。发送者将不会等待接收者。接收者可以使用 .receive
来阻塞, 直到队列中有可用的内容, 也可以使用 .poll
来检查条目而不阻塞。成本转移到内存中。通道必须在内部存储所有已发送的项, 直到它们被接收, 并且队列将继续增长, 直到程序用完内存。
因此, 当你有资源或消息要分发, 但不在任务之间共享时, 通道是很有用的。或者当你只需要进行点对点的通信时。作业队列、资源池和点对点任务通信是通道非常适合解决的那种问题的好例子。
干杯。
103. 第五天 - 线程
警告! 我们现在正在深入了解 Raku 的内部深度。线程是一种低级的 API, 几乎所有的应用程序都应避免使用线程(Thread)。然而, 如果你的特定应用程序需要直接访问线程(Thread), 那么它就是为你准备的。[8]
在 Raku 中, Thread
类的使用是直截了当的, 并且看起来与你熟悉其他语言中的线程工具的预期非常相似:
my $t = Thread.start:
name => 'Background task',
:app_lifetime,
sub {
if rand > 0.5 { say 'weeble' }
else { say 'wobble' }
},
;
say "Starting $t.id(): $t.name()";
say "Main App Thread is $*THREAD.id(): $*THREAD.name()";
$t.finish; # wait for the thread to stop
给 Thread.start
方法一些代码来运行, 然后你就离开了。name
和 app_lifetime
选项是可选的。如果 app_lifetime
为 False
(这是默认值), 当主应用程序线程终止时, 该线程将被终止。如果设置为 True
, 只要这个线程在运行, 应用程序就会继续运行。正常情况下, 只有应用程序的主线程才有这个权限。
所有代码, 都在一个线程内运行。你的代码可以使用名为 $*THREAD
的动态变量访问它正在运行的线程。这对于调试时拉出 .id
, 帮助了解一个任务此时正在哪个线程中运行是很有帮助的。
当你想暂停当前线程以等待另一个线程完成时, 你可以使用 .finish
方法(或者你可以使用 .join
, 它是 .finish
的同义词)。
另一种运行线程的方法是使用 .new
和 .run
的组合。这与 .start
类似, 但代码必须作为命名参数传递给 .new
:
my $t2 = Thread.new:
name => 'Another task',
code => sub {
loop {
say 'stuff';
sleep 1;
}
},
;
# The thread does not start until we...
$t2.run;
# And then we'd better wait for it or we'll exit immediately
$t2.finish;
在大多数情况下, 我都会在降临日历中提到线程, 以此来描述代码在其中运行的“通道”。然而, 我会不时地使用 $*THREAD.id
来帮助说明代码确实在不同的线程中运行。否则, 我一般会直接忽略 Thread 对象。
几乎所有的 Raku 程序都应该坚持使用 start
块或 Promise.start
来启动在另一个线程上运行的任务。只有在真正需要的时候, 你才应该直接利用 Thread
, 对于大多数 Raku 开发人员来说, 可能永远不会使用或接近 Thread。
干杯。
104. 第六天 - Raku 调度器
在 Raku 中, 大量的面向并发的编码都依赖于 Scheduler 的使用。许多异步操作依赖于虚拟机在运行时创建的默认调度程序。你可以通过名为 $*SCHEDULER 的动态变量来访问它。
调度程序(Scheduler)的最重要功能是 .cue
方法。使用代码引用调用该方法将安排工作(work)的执行。调度程序的类型将决定其具体含义。
也就是说,这是一个低级接口,你可能不应该在大多数代码中调用 .cue
。最好的做法是依靠高级工具, 比如 start
,并构造一个 Promise 供你监视工作(work)。
每个调度程序都提供了三种方法:
-
.uncaught_handler
是一个访问器,它返回一个例程,或者可以将其设置为例程,只要调度程序抛出异常, 而任务代码本身不对其进行处理,就会调用该例程。如果未提供处理程序,而提示任务抛出了异常,则应用程序将在这个异常上退出。如果你使用高级并发工具, 如start
块,则将永远不会使用.uncaught_handler
,因为它们各自提供了自己的异常处理。 -
.cue
方法用于将任务添加到调度中。调度程序将在资源允许的情况下执行该任务(取决于调度程序的操作方式)。 -
.loads
方法返回一个整数,该整数表示调度程序当前的负载。这是对当前作业队列大小的指示。
所以,你可以建立一个非常简单的调度程序,就像这样:
class MyScheduler does Scheduler {
method cue(&code, Instant :$at, :$in, :$every, :$times = 1; :&catch) {
sleep $at - now if $at && $at > now;
sleep $in if $in;
for ^$times {
code();
CATCH {
default {
if &catch {
catch($_);
}
elsif self.uncaught_handler {
self.uncaught_handler.($_);
}
else {
.throw;
}
}
}
sleep $every if $every;
}
class { method cancel() { } }
}
# We don't really queue jobs, so always return 0 for the load
method loads(--> 0) { }
}
这和 CurrentThreadScheduler
的功能有些类似。
Rakudo 内置了两个调度程序:
-
ThreadPoolScheduler 就是通常默认的
$*SCHEDULER
。当它被构造时,你可以设置它允许同时使用的线程数量。然后,它管理一个线程池,并将在这些线程上调度提示任务。随着任务的完成, 释放线程,下一个任务将被调度运行。任务可以和这个调度程序同时运行。当.cue
返回时,任务可能尚未开始。返回对象的.cancel
方法可用来请求取消某个任务的工作(work)。 -
CurrentThreadScheduler 是一个备用的调度程序。它基本上只是立即执行任务,并在任务完成后返回。返回的取消对象有一个
.cancel
方法,但是它是一个空操作,因为在调度程序返回时, 工作总是已经完成。
许多异步方法,比如 Promise 上的 .start
方法,会接收一个名为 :scheduler
的参数,你可以在其中传递一个自定义的调度程序。一般来说,你可以坚持使用默认的调度程序。对调度程序最常见的调整可能是改变线程池中的线程数,或者在某些情况下切换到使用当前的线程调度程序。你有可能不需要做这两件事。而且,而如果你需要一些奇特的东西,那么定义自己的调度程序也是合理的。需要考虑的事情。
干杯。
105. 第七天 - React 块
react
块本身可以运行任何你想要的代码, 再加上一个或多个 whenever
块。块中的代码会运行一次, 当 done
子例程被调用时, 或者当所有与 whenever
块相关联的对象完成时(即所有的承诺都被保留或破坏, 所有的供应已退出或关闭, 所有的通道已失败或关闭), 块将退出。
除了语法之外, 关于 react
块, 需要注意的关键一点是, 块内的所有代码总是会像单线程一样运行(即, 可能会使用多线程, 但绝不会让这个块内的任何代码并发运行)。
我们来看一个 react
块的例子:
my $commands = Channel.new;
my $input = Supplier.new;
my $output = Supplier.new;
my $quit = Promise.new;
react {
print '> ';
start loop { $input.emit: $*IN.getc }
whenever $input.Supply.lines.map({ .trim }) {
when /^add \s+ (\d+) \s+ (\d+)$/ { $commands.send: ('add', +$0, +$1) }
when /^sub \s+ (\d+) \s+ (\d+)$/ { $commands.send: ('sub', +$0, +$1) }
when 'quit' | 'exit' { $quit.keep }
default { $output.emit: 'syntax error' }
}
whenever $commands -> @command {
multi doit('add', Int $a, Int $b) { $a + $b }
multi doit('sub', Int $a, Int $b) { $a - $b }
$output.emit: doit(|@command);
}
whenever $output.Supply { .say; print '> ' }
whenever $quit {
say 'Quitting.';
done;
}
}
这个程序提供了一个小型的交互式 shell, 可以执行加法和减法运算。运行时, 你可以按以下方式使用它:
> add 4 5
9
> sub 10 7
3
> exit
Quitting.
如你所见, 这段代码使用了几个 Supply
对象, 一个 Channel
和一个 Promise
来操作的。他们中的每一个都以预期的方式与 whenever
工作。run
块内的所有代码都像在单线程中运行一样(尽管并没有特别保证只使用单线程, 只是没有代码会并发运行)。
然而, 在这种情况下, 从单任务中运行而不并发确实存在一个问题。即使你使用 .Supply
方法来异步获取数据, $*IN
文件句柄也只执行阻塞读取。因此, 我们必须在后台线程中运行的任务中拉取输入, 这就是为什么我们在读取字符输入的循环之前放了一个 start
。如果没有这个并发任务, 我们就必须多敲几次 Return 键, 以便其他 whenever
子句也有机会运行。
也就是说, 我们可以把其它的 whenever
块的工作移到单独的并发任务中, 然后把每个任务都拉到这个 react
块中, 这样就能很好地工作了。react
块的目标是用一种简单明了的语法来同步异步工作。我认为它的工作做得很好。
干杯。
106. 第八天 - Lock 类
在 Raku 中编写并发代码时, 我们要避免任务之间共享数据。这是因为不共享数据的代码是自动安全的, 并且不必担心与其他代码的相互依赖性。所以, 在可能的情况下, 你应该通过 Supply
、Promise
和 Channel
对象来完成你的工作, 然后通过中央线程将它们同步在一起。这样, 所有状态变化都是安全的。
不过, 这并不总是实用的。有时候, 在不同线程上运行的任务之间共享可以变化的数据确实更有效率。然而, 这样的共享本质上是不安全的。
例如, 这里有一个在 Raku 中非常简单的多线程应用程序, 它不是线程安全的, 会导致错误的结果:
my $x = 0;
my @p = (
start for ^100 { $x++; sleep rand/10; },
start for ^100 { $x++; sleep rand/10; },
);
await Promise.allof(@p);
say $x;
我们可能期望 $x
的值是 200, 但它不太可能是 200。它几乎可以肯定会更低。这是因为简单的 $x++
操作需要:
-
读取
$x
的值。 -
在
$x
读取的值上加一。 -
将计算出的值存储回
int $x
。
如果碰巧第一个任务执行了步骤1和2, 然后第二个任务在第一个任务完成步骤3之前执行了步骤1到3, 那么至少有一个增量操作会丢失。在每个任务的 100 次迭代过程中, 我预计会有5到10次写入丢失, 并且每次运行都有可能给出稍微不同的答案。这就是在并发性方面不安全的含义。
对于这个特殊的程序, 上面的三个步骤构成了一个关键部分。关键部分是一段必须依次执行的代码, 如果它要完全正常工作的话。在 Raku 代码本身中, 关键部分只是每个循环中的 $x++
语句。
确保代码中的关键部分以线程安全的方式处理的一种机制是使用互斥锁。Raku 提供 Lock 类, 可以用于这个目的。
当你创建一个 Lock 对象时, 你可以 .lock
或 .unlock
代码中的锁。如果任何其他代码在没有调用 .unlock
的情况下, 在同一个对象上调用了 .lock
, 那么调用 .lock
方法将阻止你的代码继续运行。一旦持有锁的线程调用 .unlock
, 另一个等待锁释放的线程将被允许继续运行。
在上面的示例中, 我们可以将其修改如下, 使之成为线程安全的:
my $x = 0;
my $lock = Lock.new;
my @p = (
start for ^100 { $lock.lock; $x++; $lock.unlock; sleep rand/10; },
start for ^100 { $lock.protect: { $x++ }; sleep rand/10; }
);
await Promise.allof(@p);
say $x;
我没有提到 .protect
, 但它的作用和调用 .lock
, 运行给定的块, 然后运行 .unlock
相同。不过这样做的好处是, 如果块里面出现了问题, 它可以彻底的让 .unlock
调用发生。在上面我们使用 .lock
和 .unlock
的第一个循环中, 有可能因为抛出异常而导致锁被永久锁定。使用 .protect
可以自动避免这种风险, 所以它是使用 Lock
的首选方式。
在结束之前, 我想提一下锁的几个负面因素。首先, 锁的性能不是很好。它们易于实现且易于使用, 但是互斥会带来较高的性能成本。你可能希望确保少量使用锁, 并且仅将其用于保护关键部分。
另一个主要缺点是, 当使用锁时, 你可以获得线程安全, 但可能会增加死锁的风险。我已经提到了一个死锁风险:一个任务导致错误, 使一个锁无法被解锁。这个锁现在是死锁, 没有任务可以接管它。当涉及到多个锁时, 死锁的风险可能会更加微妙, 并且非常难以发现。与供应(Supply)或承诺(Promise)不同, 锁(Lock)是不能安全地组合的。这意味着两个使用锁来保护自身安全的库在一起使用时, 如果不小心, 可能会出现死锁。
尽管有这些缺点, 锁对于使代码线程安全是很有用的东西。在这个降临日历的后面, 我们将使用锁来演示如何创建线程安全的数据结构。
干杯。
107. 第九天 - Atomic Integer
什么比锁更快?比较和交换(CAS)。现代 CPU 有多个核心。因此, 所有的现代 CPU 都必须拥有执行绝对原子操作的工具, 以便让这些多核一起工作。这些操作的其中之一就是比较与交换(compare-and-swap) 或 cas
操作。
抽象地讲, cas 操作需要三个参数, 一个要修改的变量、一个给定的值和一个新的值。只有当变量所持有的当前值等于给定值时, 变量才会被设置为被修改后的新值。而这是以单次操作的方式进行的, 保证不受任何其他并发操作的干扰。在这一过程中, 系统会设置一个标志来标记操作的成功或失败。如果该变量的值与预期值不同, 则该变量保持不变, 操作失败。为了完成一个原子变化, 你重复运行一个计算, 并以 cas
操作结束, 直到操作成功为止。这听起来可能不是很有效率, 但事实证明, 在大多数情况下, 它比锁更快。
为了演示 atomicint
的一个可能的用法, 让我们首先考虑一下 ATM 问题。有两个人有一个银行账户。假设在一天开始的时候, 这个账户里有 1000 美元。其中一个人在市中心的 ATM 机上取款 100 元, 另一个人在机场的 ATM 机上存款 250 元。在一天结束时, 我们显然希望新的余额是 1150 美元。但是, 如果这两笔交易同时发生, 那就不能保证了, 除非我们采取一些谨慎的措施来保证这一点。
让我们先用天真的方法写出我们的代码。
my Int $balance = 1000;
start { $balance = $balance - 100 } # one
start { $balance = $balance + 250 } # two
say $balance;
不幸的是, 现在可能会出现余额与 1150 美元不同的情况。这是因为如果这两个任务实际上同时运行, 它们执行的操作可能会像这样交错进行。
-
块一读取 1000 美元的余额。
-
块二读取 1000 美元的余额。
-
块一从 1000 中减去 100, 得到 900。
-
块二从 1000 中加上 250, 得到 1250。
-
块一将余额设置为 900。
-
块二将余额设置为 1250。
相反, 我们可以使用 cas
操作来依次执行操作。所以, 如果我们使用 atomicint
重写上面的代码, 我们最终会得到这样的结果:
my atomicint $balance = 1000;
start { $balance ⚛️= ⚛️$balance - 100 } # one
start { $balance ⚛️= ⚛️$balance + 250 } # two
say $balance;
这里的结果将始终是预期的 1150。让我们假设这两个任务和之前一样同时运行, 但最后的分配是一个比较和交换操作, 而不是一个常规的集。其结果会是这样的。
-
块一读取 1000 美元的余额。
-
块二读取 1000 美元的余额。
-
块一从 1000 中减去 100, 得到 900。
-
块二在 1000 的基础上加 250, 得到 1250。
-
块一执行比较和交换
$balance
从 1000到900, 成功了。 -
块二执行比较和交换
$balance
从1000到1250, 失败了。 -
块二读取的
$balance
为900。 -
块二在 900 的基础上加上 250, 得到 1150。
-
块二执行比较和交换
$balance
从900到1150, 成功了。
出现额外的步骤 7-9 是因为在 cas
操作中, 解决失败需要重复操作直到成功。除非对单个变量的写入争夺程度是极端的, 否则这不应该导致任何任务无限期地失败。
如果你不喜欢代码中的表情符号, 那也没关系。有一个 Texas 函数用于执行原子表情符操作符提供的每一个操作。atomicint
提供了以下操作:
-
atomic-assign 或 ⚛️=
-
atomic-fetch 或 ⚛️ 前缀, 用于执行原子读取值
-
atomic-fetch-inc 或 ⚛️++ 后缀
-
atomic-fetch-dec 或 ⚛️– 后缀
-
atomic-fetch-add 或 ⚛️+=
-
atomic-fetch-sub 或 ⚛️-=
-
atomic-inc-fetch 或 ++⚛️ 前缀
-
atomic-dec-fetch 或 –⚛️ 前缀
-
cas
让我们快速考虑一下 cas
函数本身, 它是实现其他运算符的基础。这个操作允许你实现任何涉及 atomicint
比较和交换的操作。它有两种基本形式, 为了演示它们是如何工作的, 我们可以使用这两种形式重新实现上面的 ATM 问题。
首先, 考虑一下这个使用 cas
函数的程序。
my atomicinc $balance = 1000;
sub update-balance($change-by) {
my $new-balance;
loop {
my $old-balance = $balance;
$new-balance = $balance + $change-by;
if cas($balance, $old-balance, $new-balance) == $old-balence {
last;
}
}
return $new-balance;
}
start update-balance(-100); # one
start update-balance(250); # two
say $balance;
这在功能上是相同的, 虽然比上面的操作更啰嗦。这基本上是让你直接访问 cas
操作本身, 并让你控制重试的次数和执行的具体操作。这个功能的工作原理是这样的, 不过整个操作是原子式的。
multi sub cas(atomicint $target is rw, int $expected-value, int $new-value --> int) {
my int $seen = $target;
if $seen == $expected-value {
$target = $new-value;
}
return $seen;
}
这意味着它将在每个情况下都返回它所看到的值, 但当它看到的值与你所预期的值一致时, 它将改变存储在目标中的值。
这个方法还有第二个版本, 它可以帮助简化你的代码, 为你执行循环。如果要再写一次 ATM 问题, 我们也可以这样写。
my atomicint $balance = 1000;
sub update-balance($change-by) {
cas $balance, -> $seen-value { $seen-value + $change-by }
}
start update-balance(-100); # one
start update-balance(250); # two
say $balance;
这大大缩短了实现这个操作所需的代码。它通过在循环中反复执行给定的操作, 直到 cas
操作成功。在这种情况下, cas
函数是这样定义的, 但同样是在适当的地方使用原子操作。
multi sub cas(atomicint $target is rw, &operation) {
loop {
my int $seen = $target;
my $new-value = operation($seen);
if $seen == $target { # still equal?
$target = $new-value;
}
return $new-value;
}
}
请注意, cas
子程序的另一种选择是接受一个函数, 返回被设置的新值, 而接受两个整数的 cas
则返回被看到的值。也就是说, 这种第二种且紧凑的形式将返回值像赋值操作一样处理, 但另一种形式的工作方式不允许这样处理。
我真的很想和大家分享如何在 Raku 中的任何标量值上使用 cas
, 但这篇文章已经很长了。我将在这个降临节稍后介绍。
干杯。
108. 第十天 - 分而治之模式
现在我们来考虑一下如何解决并发性的一个大问题。如果你有一个需要处理大量数据的算法问题, 你想最大限度地提高可用 CPU 核心的负载, 以尽快处理这些数据。为了演示我们如何在 Raku 中实现这一点, 我们将考虑康威的《生命游戏》, 在一个有效的无限棋盘上进行。[9]
让我们从定义康威的《生命游戏》开始, 以防你之前没有接触过它。生命游戏是英国数学家约翰-康威发明的一种模拟游戏。它是在一个简单的网格上进行的, 每个方格被称为一个细胞。在一个给定的回合中, 每个细胞可能要么是活的, 要么是死的。每个细胞有 8 个邻居, 分别是上、下、左、右和 4 条对角线上的细胞。为了确定一个细胞在下一个回合的状态, 你使用当前细胞的状态和它的邻居从当前回合使用这些规则执行以下检查。
-
任何有少于两个邻居的活细胞都会死亡。
-
任何有两个或三个邻居的活细胞继续生存。
-
任何有三个以上邻居的活细胞都会死亡。
-
任何有三个邻居的死细胞复活。
如果你想了解更多的细节, 你应该看看维基百科上关于康威的《生命的游戏》的文章。
我已经在一个漂亮的长程序中实现了康威的《生命游戏》, 并有图形(或文本)输出和所有的内容。然而, 它大约有 400 行代码, 所以我不打算在这里包含所有的代码。你可以在我的 github 上查看这个项目的持续发展。
模拟器有一个名为 Game::Life::Player
的角色, 它定义了对玩家对象的要求。这个对象负责执行游戏规则。具体来说, .next-turn-for
方法被赋予了一个当前棋盘的不可变的副本, 一组边界, 和一个可改变的下一个棋盘的副本来写入。它负责根据刚才提到的规则将当前棋盘变成下一回合的棋盘。
下面是 Game::Life::Player::Basic
实现中的一个副本, 这基本上是最简单的方法了。
role Game::Life::Player {
...
method next-turn-for-cell(
Int:D $x,
Int:D $y,
Board:D $current,
Board:D $next,
) {
# Is the cell currently live?
my $live = $current.cell($x, $y);
# How many live neighbors does it currently have?
my $neighbors = [+] $current.neighbors($x, $y);
# If alive and has too many or too few neighbors, die.
if $live && !(2 <= $neighbors <= 3) {
return $next.kill($x, $y);
}
# if dead and has the right number of neighbors, come to life.
elsif !$live && $neighbors == 3 {
return $next.raise($x, $y);
}
else {
return Nil;
}
}
}
class Game::Life::Player::Basic does Game::Life::Player {
...
method next-turn-for(
Int:D $l,
Int:D $t,
Int:D $r,
Int:D $b,
Board:D $current,
Board:D $next,
) {
for $l..$r -> $x {
for $t..$b -> $y {
self.next-turn-for-cell($x, $y, $current, $next);
}
}
}
}
实现方法是简单地迭代边界内的每个细胞, 并在其上运行 .next-turn-for-cell
。这个方法是在 .role
中实现的, 只是实现了适用于单个细胞的规则。很简单。[10]
即使是对于相对较小的游戏场, 单块迭代也会花费很长的时间。为了改善这种情况, 我们可以将工作划分成合理大小的块, 并在一个单独的任务中处理每个块。在多线程的情况下, 我们应该可以将做工作所需的时间缩短到 N 的倍数, 其中 N 是可用于计算的核心数。在现实中, 你得到的时间会比这少一些, 但我们应该绝对能够通过这种方式提高速度。
我们可能怎么做呢?这里有一个可能的解决方案, 确保我们永远不会处理大于 20 乘 20 的块, 使得连续进行大约 400 次计算。获得最大的效率需要一些调整, 所以一个给定的系统可能会用不同的数字做得更好, 但你懂的。
这里有一个 parallel-next-turn-for
的实现, 是 Game::Life::Player::DivideAndConquer
玩家类的一部分。
class Game::Life::Player::DivideAndConquer is Game::Life::Player::Basic {
...
method parallel-next-turn-for(
Int:D $l,
Int:D $t,
Int:D $r,
Int:D $b,
Board:D $current,
Board:D $next,
) {
my @jobs = gather {
if $r - $l > 20 {
my $m = ceiling($l + ($r - $l)/2);
#dd $l, $m, $r;
take start self.parallel-next-turn-for($l, $t, $m - 1, $b, $current, $next);
take start self.parallel-next-turn-for($m, $t, $r, $b, $current, $next);
}
elsif $b - $t > 20 {
my $m = ceiling($t + ($b - $t)/2);
#dd $t, $m, $b;
take start self.parallel-next-turn-for($l, $t, $r, $m - 1, $current, $next);
take start self.parallel-next-turn-for($l, $m, $r, $b, $current, $next);
}
else {
take start self.next-turn-for($l, $t, $r, $b, $current, $next);
}
}
await Promise.allof(@jobs);
}
}
这与之前的输入相同, 但如果列数太多, 无法处理, 我们就按列数将工作减半。如果列是合理的, 但行数太多, 我们就把工作按行数减半。如果大小刚刚好, 我们就使用我们从 Game::Life::Player::Basic
继承的 next-turn-for
。
无论我们是分成两个任务, 还是只做某一部分细胞的工作, 我们都会使用 start
块来调度工作, 然后等待结果。这样方式的细分意味着我们创建了一个任务的层次结构, 可以再细分, 再细分。然后, Raku 调度器会在线程可用时调度任务运行。
在我的 2015 年的 Macbook Pro 上, 游戏在顺序运行时, 使用 100% 的 CPU, 在 35 秒左右的时间内跑完 200 个回合的 Gosper 滑翔机枪。同样的程序在并行运行时, 使用接近 300% 的 CPU 运行约 20-25 秒。如果不是我的渲染任务偶尔也要用 CPU 重绘图形窗口, 可能会更高。但那又有什么乐趣呢?
所以, 这就是当你有多个核心可用时可以采用的并发模式, 而且算法适合被拆分成几个部分。
干杯。
109. 第十一天 - 并行的 Map-Reduce 模式
在函数式编程中, Map-reduce 是一种常见的解决问题的方法。你有一个列表项,你要对这些项进行迭代处理,然后你把这个集合进行汇总。我们之所以称之为map-reduce,是因为迭代步骤是将值映射成新的值,而汇总步骤是减少值的数量。
在 Raku 中,map-reduce 是一种常见的编程模式。
my $fibonacci = (1, 1, * + * ... *);
my $double-sum = $fibonacci[^100].grep(*.is-prime).map(* * 2).reduce(* + *);
这就用序列运算符创建了一个 Seq。这就是经典的斐波那契序列。然后我们取 Fibonacci 的前 100 个元素,过滤掉任何非质数,将质数加倍,然后将数值相加。
这是一个奇怪的操作,但展示了你会用 map-reduce 模式执行的任务。我们取一个数据序列(本例中是斐波那契序列的前 100 个元素), 我们对数据进行过滤,只保留质数,将其值加倍,然后将其相加,得到最终的和。
在这种情况下,答案并不难计算,而且可能是瞬时的,但如果我们需要对前 4000 个数字进行这种操作呢?在今天的典型系统中,这很可能需要几秒钟的时间。由于 .grep
和 .map
必须对每个值进行迭代过滤和转换,所以我们没有特别的理由必须对每个值依次进行这些操作。[11]
Raku 提供的工具可以让你以不同的方式并行化这个任务,只需做一点小小的改变。考虑一下这个变化。
my $fibonacci = (1, 1, * + * ... *);
my $double-sum = $fibonacci[^100].race.grep(*.is-prime).map(* * 2).reduce(* + *);
通过在开头插入 .race
,我们告诉 Raku 以并行方式执行操作。它将把任务分成几个部分,在不同的任务中运行这些部分,这些任务将被调度到不同的线程上。在我的系统中,该操作的运行速度比第一个快2到3倍。
Raku 提供了几种不同的策略来并行化 map-reduce 任务。
这些操作中的每一个都会接收一个 :batch
和 :degree
参数,以备你想自定义工作的分解和执行方式。Raku 试图选择合理的默认值,但是当你需要获得更好的性能时,为你的特定设置调整这些参数可能会带来一些改进。
:degree
选项选择要启动多少个 worker。对于 CPU 绑定的工作,一般来说,最好选择一个与可用的 CPU 核数相等的数字。没有理由选择更高的数值,因为在这种情况下,你永远不会同时运行超过那么多的工作。但是,如果工作涉及到在磁盘或网络上的等待,你的代码很可能会暂停几毫秒或更长时间等待 IO。在这些情况下,明智的做法是将 :degree
增加到数倍于 CPU 的数量,以考虑到等待时间。
:batch
选项决定如何分解工作。当要做的工作速度很快时,一个大数字是有用的。这将使你的吞吐量保持在较高的水平。当工作时间较长或你想尽快得到每个结果时,一个小的数字,甚至是1,都是合理的。
所以,考虑到这一点,我们可以这样进一步调整上面的工作。
my $fibonacci = (1, 1, * + * ... *);
my $double-sum = $fibonacci[^4000].race(:batch(1000), :4degree).grep(*.is-prime).map(* * 2).reduce(* + *);
在这种情况下,调优在我的 4 核笔记本上并没有太大的区别,但是当你的系统上有超过 4 个核心的时候,调优很可能会有一些帮助。
所以,任何时候,当你有一个任务需要对项进行迭代和操作的时候,如果你有足够的 CPU 时间来加快它们的速度,可以考虑在你的代码中使用 .hyper
或 .race
。
干杯。
110. 第十二天 - 保留你的线程或不保留
在 Raku 中, 有不同的方法来暂停你的代码。最简单也是最明显的方法是使用 sleep:
my $before = now;
sleep 1;
my $after = now - $before;
say $after;
假设你的系统目前没有被拖累, $after
的输出应该是一个非常接近 1 的数字。不是很刺激:你这一秒钟什么都没做。呜呜。呜呜。
然而, 在实际代码中, 你有时确实需要暂停一下。比如说, 你可能正试图发送一封邮件, 结果失败了。在发送邮件时, 如果它能快速到达是很好的, 但最终还是要达。因此, 在排队发送邮件的时候, 你要观察是否有错误。当它们发生时, 你要在放弃之前继续尝试很长时间。然而, 你不希望不断地尝试。你需要在重试之间暂停一下。
如果我在内存中构建一个天真的实现(而不是一个更合理的磁盘队列), 我可以这样做。
start {
my $retries = 10;
my $success;
for ^10 -> $retry {
$success = sendmail();
last if $success;
sleep 60 * 2 ** $retry;
}
die "Never could send that email." unless $success;
$success;
}
在这段代码中, 你有一个 sendmail
函数, 你认为它可以正确地发送一封邮件。你将尝试发送 10 次。你检查是否成功, 然后你使用一个指数级延长的时间间隔休眠, 在接下来的 18 个小时里, 你会把重试的时间分散开来。在这之后, 你会放弃。为了避免在长达 18 小时内阻断正常工作的进程, 你在 start
块中运行了整个过程。每当发送邮件时, 返回的 Promise 都会被保留, 如果邮件失败, 则会被破坏(broken)。
不过, 有一个问题。这段代码将一个线程阻塞长达 18 小时。而线程是一种非常有限的资源。默认的线程池里最多有 64 个线程。这可不是什么好事。这意味着我们的进程仍然可以工作, 但这个线程被锁住了, 做了一大堆的事情。线程是昂贵的资源, 这样一来, 线程的使用成本会很高。如果你每 15 分钟要发一次以上的邮件, 你就会用完线程。
如何解决这个问题呢?你可以重新配置 Raku, 使用资源池中的线程更多的调度器, 但线程的目标是做事情。你为什么要浪费一个线程什么都不做, 除非你真的没有工作让他们做。
你可以用一种方式来解决这个问题, 释放你的线程继续工作, 并暂停任务。await
语句是让你的代码告诉 Raku, "如果你需要的话, 你可以把我的线程收回来。" 所以我们把上面的代码改成这样。
start {
my $retries = 10;
my $success;
for ^10 -> $retry {
$success = sendmail();
last if $success;
await Promise.in(60 * 2 ** $retry);
}
die "Never could send that email." unless $success;
$success;
}
现在代码会在规定的时间内进入休眠状态, 但线程被释放出来供 Raku 重用, 这意味着你的应用程序不会在下一次需要同时给 65 个人发垃圾邮件的时候, 在邮件服务器瘫痪的情况下等待 18 个小时。
这在一般情况下都是正确的, 而不仅仅是为了暂停休眠。任何时候, 只要你使用了 await
(只要你的 Raku 的版本支持规格 6.d 或更高的版本), 你的 Raku 将能够重用该线程, 如果被等待的东西还没有准备好。
干杯。
111. 第十三天 - 监控模式
今天我想讨论一下使用锁来使对象线程安全。也就是说, 通过采用一个简单的模式来锁定对对象的访问, 可以有效地保证每次只有一个线程可以访问对象的任何部分。因此, 这就保证了对象的状态永远不会被破坏, 即使是在多个线程试图并发访问对象的的时候。
111.1. 面向对象设计
在讨论共享的、可变对象的并发性之前, 我们先来考虑一下是什么才是设计良好的一般对象。在面向对象设计中, 一个新手的错误是把对象看作是信息的容器。当这样设计时, 对象会根据它们所包含的数据进行映射, 并且通过一系列的 getter 和 setter 来暴露这些数据是很有诱惑力的。然而, 一个设计良好的对象将包含状态作为封装功能的一种手段, 而这些功能需要该状态来操作。它通过允许其他对象通过方法向它发送消息来执行那些可能更新内部状态的操作来实现这一点。
举个拗口的例子, 如果我们要构建一个只有 push
操作的链表, 我们可以构建一个这样的简单列表:
class LinkedListRaw {
has $.data;
has $.next is rw;
}
my $list = LinkedListRaw.new(data => 1);
$list.next = LinkedListRaw.new(data => 2);
$list.next.next = LinedListRaw.new(data => 3);
而一个更好的设计应该是这样的:
class LinkedList {
has $.data;
has $!next;
method !tail() {
my $here = self;
loop { return $here without $here.next; $here = $here.next }
}
method push($data) {
self!tail.next = LinkedList.new(:$data);
}
}
my $list = LinkedList.new(data => 1);
$list.push: 2;
$list.push: 3;
现在, 我之所以花点时间提到良好的面向对象设计实践, 是因为我们现在要考虑的监控器模式依赖于设计良好的对象才有效。(我之所以提到这一点, 也是因为不良的面向对象设计实践甚至在原本优秀的工程师中也很普遍)。
111.2. 监控(Monitors)
当你有一个无状态的系统, 或者是一个系统的状态是基于转换不可变对象的系统时, 并发是最容易的。例如, 如果你只需要将计算结果从一个线程传递到另一个线程, 那么每个阶段都很容易地保留当前值的副本, 转换该值, 并将新的副本传递给下一个阶段。
然而, 有状态对象可能会带来挑战, 因为当另一个线程开始对对象进行新的操作时, 对对象状态的改变可能只在一个线程中被部分应用。如果我们不保护我们的对象一次执行多个状态变化, 或者在状态变化只完成一部分时, 不保护我们的对象被读取, 那么我们的代码就不会是线程安全的。你不应该从多个线程同时使用这样的对象。(大多数内置的 Raku 对象就是这样的对象!)
如果你处于复制对象状态不切实际的情况下, 你需要在线程之间共享状态, 一个非常简单的解决方案是使用监控模式(monitor pattern)。下面是我们在使用监控模式之前的一个线程安全版本的只推链表。
class LinkedListSafe {
has $.data;
has $!next;
has Lock $!lock .= new;
method !tail() {
my $here = self;
loop { return $here without $here.next; $here = $here.next }
}
method push($data) {
$!lock.protect: {
self!tail.next = LinkiedListSafe.new(:$data);
}
}
}
就是这样。这就是整个监控模式, 只是使用一个 Lock 来保护每个公共方法的所有c代码, 这些方法从对象的可变状态中读取或写入。虽然这很简单, 但采用这种模式也有几个缺点。
如果对状态变化的争夺很大, 这通常是一个低性能的解决方案。例如, 如果很多线程会频繁地对这个链表进行多次 push
, 那么性能就不会很好。
在每个部分的周围添加 $!lock.protect.protect: { … }
, 做起来很繁琐, 而且在开发过程中容易忘记。
为了改善第一种情况, 请确保你的监控器只包含与封装对象状态相关的代码。创建非监控器的次要对象, 这些对象不是任何计算和动作的监控器, 也不是任何其他无状态工作的监控器。
对于第二种情况, 我推荐使用 Jonathan Worthington 的一个模块。他写了一个工具来自动实现监控器模式。如果你安装了 OO::Monitors, 你可以把上面的链表改写成:
use OO::Monitors;
monitor LinkedListMonitor {
has $.data;
has $!next;
method !tail() {
my $here = self;
loop { return $here without $here.next; $here = $here.next }
}
method push($data) {
self!tail.next = LinkiedListSafe.new(:$data);
}
}
monitor
是一个实现监控模式的类(class
)。每个方法都会自动为你加锁保护。
如果你需要使一个有状态的对象线程安全, 并且你希望有一个简单的机制来实现, 那么这是一个合理的模式。不过如果性能是首要考虑的问题, 那么监控对象可能不适合你。最后, 要知道, 这种模式完全依赖于深思熟虑的 OO 设计来工作。
干杯。
112. 第十四天 - 比较并交换标量
之前, 我讨论了比较和交换操作, 作为对 atomicint 变量进行的操作。这只是冰山一角。虽然大多数原子表情符号 ⚛️ 运算符只适用于 atomicints
, 但 cas
函数、atomic-fetch
(或前缀 ⚛️ 运算符)和 atomic-assign
(或 ⚛️= 运算符)都可以用于任何一种 Scalar 变量。
首先, 我们需要确定我们知道什么是 Scalar。在 Raku 中, 每个变量名都与一个容器相关联。如果你想知道它是如何工作的, 我推荐你阅读关于容器的语言文档。对于我们的目的来说, 只要说几乎所有以 $
魔符开头的常规变量都被一个 Scalar 所包含就足够了。如果你做了一些特殊的事情来初始化这样的变量, 那么它可能没有 Scalar 容器。
这里有一个快速的例子, 应该足够说明我们的目的了。
# Any typical $ sigil variable represents a Scalar container
my $value = 42;
# Each index of an array is normally a Scalar container
my @array;
@array[0] = 10;
# Binding directly to Int, so this is NOT a Scalar.
my $constant := 100; # NOT Scalar
# Binding direclty on an array index is also NOT a Scalar.
@array[1] := 20; # NOT Scalar
# Proxy containers are NOT Scalar containers
my $special := Proxy.new(
FETCH => method () { 10 }
STORE => method ($v) { 10 }
)
如果你试图在非 Scalar 容器上使用 cas, Raku 会抛出一个类似于"Proxy 容器不知道如何进行原子比较和交换"的异常, 所以在大多数情况下, 出错的位置应该很明显。
够了。如何使用它呢?让我们试着举个例子。
my $atomic-string = '';
start {
loop {
cas $atomic-string, -> $v {
if $v.ends-with('A') { "$vB" }
else { $v }
}
sleep rand;
}
}
start {
loop {
cas $atomic-string, -> $v {
if $v eq '' || $v.ends-with('B') { "$vA" }
else { $v }
}
sleep rand;
}
}
start {
loop {
given ⚛️$atomic-string {
if .ends-with('B') && .chars %% 10 { .say }
}
sleep rand;
}
}
sleep 10;
这是个没什么用的程序, 但它展示了你可以做的事情。我们只有一个常规变量, 存储一个空字符串来开始。然后我们有三个任务同时运行。第一个任务使用 cas
操作查看字符串是否以 "A" 结尾, 如果是, 则添加一个 "B"。第二个任务使用 cas
操作查看字符串是否为空或以 "B" 结尾, 如果是, 则添加 "A"。第三种操作只有在字符串以 "B" 结尾并且长度是 10 的倍数时才会输出。它运行 10 秒后退出。这是一个效率很低的程序, 在我的笔记本上运行一次, 输出的结果是这样的。
ABABABABAB
ABABABABAB
ABABABABAB
ABABABABABABABABABAB
ABABABABABABABABABABABABABABAB
本质上, 每个 Scalar 内部都可以访问一个 atomicint, 它是用来在改变时锁定标量的。这些可以比使用 Lock 对象更有效率。当对某些数据的访问竞争很激烈时, cas
很可能会输, 因为每个试图处理数据的线程都会忙于等待。然而, 当对该项的争夺较低, 而且你不需要高效地阻塞和恢复线程时, cas
操作可以更高效。这可能需要对每种方法进行一些 AB 测试, 以确定哪种方法对你的特定情况最有效。
这个月晚些时候, 我计划更详细地演示一下如何使用这种 cas
操作来实现无锁的数据结构。所以我们很快就会再次回到这个话题。
干杯。
113. 第十五天 - 拆解异步问题
在编写异步代码时, 我经常面临的一个挑战就是想办法把问题合理的分解。我应该把代码分解到什么程度?我到底要走多少步?我如何处理分支或分叉的任务?我如何处理我所创建的相互依赖关系?我在这篇文章中希望给出一些我所学到的准则和一些工具来回答这些问题。
这篇降临日历的文章将集中在异步问题上。在日历的后面, 我再来考虑同样的问题, 重点是并发问题。
113.1. 异步问题的特殊性
这个问题与更多传统的编程问题相比, 性质不同, 但实际实质并无不同。就像你在编写软件时, 需要用函数、方法和子程序将软件分解成几块一样, 你在处理异步问题时也大体如此。
那么, 是什么特殊的"特征"让这些异步问题与众不同。那么, 是什么让异步程序成为异步的呢?就是调用和结果之间的分离。你发起工作, 只要有两个因素为真, 你处理结果的代码就会工作。
你准备好了处理工作, 并且结果是可用的。因此, 异步问题的特殊性在于, 你要以这样的方式来工作, 以确保这两个条件尽可能多地为真, 从而使你的代码在结果准备好处理的那一刻, 就可以处理处理结果的工作。
这是你在看分解异步问题时唯一需要特别考虑的地方。否则, 你的编程问题都是典型的编程问题。说到这里, 现在让我们考虑一下 Raku 中各种类型的异步编码方法的一些实际考虑。
113.2. 使用 react 块
我的第一条实用建议是, 每当你需要将你的工作聚集在一起时, 总是使用 react 块。react
块是协调多个异步进程一起工作的完美场所。
举个例子, 我使用 Raku 程序来静态地渲染这个网站。我为此开发的工具有一个名为 build-loop
的模式, 它监视文件的变化, 并在这些变化发生时重建网站。在生产中, 它监控一个套接字, 每当同步工具检测到主 git repo
有变化时, 它就会被 ping。在开发中, 它使用 IO::Notification 来监视磁盘上的变化, 并且还运行一个微型 Web 服务器, 这样我就可以以模拟部署系统的方式来服务这些文件。
它有一个主 react
块, 看起来是这样的。
react {
my $needs-rebuild = True;
with $notify-port {
whenever IO::Socket::Async.listen('127.0.0.1', $notify-port) -> $conn {
# manage a connection to set $needs-rebuild on ping
}
}
with $server-port {
whenever IO::Socket::Async.listen('127.0.0.1', $server-port) -> $conn {
# micro-web server for developer mode here
}
}
whenever Supply.interval($interval, :$delay) {
if $needs-rebuild {
$needs-trigger--;
build-site();
}
once {
# configure IO::Notification to set $needs-rebuild on change
}
}
}
我省略了很多细节, 但这应该能让你有个感觉。我能够协调不同的手段, 通过这些手段, 我可以发现导致网站重建的变化。我有一个工具, 可以把重建的次数控制在只有 $interval
秒的频率, 这样就一组变化就不会无休止地重新触发构建。我可以同时运行一个小型的 Web 服务器, 在开发者模式下为内容提供服务。而且我是在同一个事件循环中使用单线程来完成这一切的。
好的地方在于, 对于一个 react
内的每一个 whenever
, 我们可以共享变量和状态, 而不必担心线程安全问题。这些块可能在同一个线程上运行, 也可能不在同一个线程上运行, 但无论如何, Raku 保证它们不会并发运行。
因此, react 块非常适合将各种任务协调在一起。几乎我写的每一个异步程序都会在某个地方有一个这样的主循环。如果任务具有很强的独立性, 我可以为每组任务设置一个事件循环, 而 react
块则在 start 中运行。例如, 我可以用事件循环来处理图形更新, 用另一个事件循环来运行网络后端。
113.2.1. 首选管道
每当你分解问题时, 你往往会选择创建一个 Supplier 对象, 并将数据输入其中, 或者是管道化。如果你可以管道化, 那么你就应该管道化。最简单的管道的例子是 Supply 上的 .map 方法。
my $original = Supply.interval(1);
my Supply $plus-one = $original.map(* + 1);
这样做大大简化了你的处理过程。它清楚地展示了一个任务对前一个任务的依赖性。它很容易阅读和遵循。它将为你省去许多麻烦。
关于其他类似的内置映射函数, 请参阅 Supply 的文档。我最喜欢的一个是 .lines, 它可以把一个发出字符串的 Supply 变成一个由换行符分解的字符串列表。
Cro 服务平台将这种平台的概念正式化为转换。几乎整个系统都是一个从请求到响应的管道, 其中每一步都将输入转化为接近最终输出的一步。这是一种非常强大的处理异步处理(async processing)的手段。
113.3. 让任何事情都成为供应
如果你正在构建一个列表, 你可以使用 supply 块来制作这个列表。这对于非琐碎的流程或者需要经常重复使用 Supply
的时候, 效果最好。
my $primes = supply {
for 1...* -> $n {
emit $n if $n.is-prime;
}
}
my $primes = (1...*).grep(*.is-prime).Supply;
后一个例子在功能上等同于第一个例子, 在我看来, 更容易阅读和理解。然而, 当你需要生成一个可重用的 Supply
或基于非琐碎逻辑的 Supply
时, 请准备好 supply
。
113.4. 使用 Supplier 进行拆分和连接
当你有一组对象进来需要不同的处理时, 你可以在这里插入一个 if
语句来处理每一种情况, 或者你也可以将这些项重新发射到不同的流中进行处理。如果处理的情况不复杂, 可以考虑为每种类型的处理使用一个单独的 Supplier
对象。然后, 如果有必要的话, 再使用一个 Supplier
将这些流重新连接起来。
这类似于决定是否对一个有多种解决方案的问题使用单独的子程序。不使用单独的子程序(sub
), 可以使用单独的 whenever
来代替。
考虑一下这个问题, 我们有一个合并的日志, 我们想把错误对象和访问对象区别对待。
react {
my Supplier $emitter .= new;
my Supplier $error .= new;
my Supplier $access .= new;
whenever $emitter.Supply { .say }
whenever $error.Supply -> %e {
$emitted.emit: "%e<timestamp> Error %e<code>: %e<message>";
}
whenever $access.Supply -> %a {
$emitted.emit: "%a<timestamp> Access: %a<url>";
}
whenever $log.Supply.lines -> $line {
given $line.&from-json {
when so .<type> eq 'error' { $error.emit: $_ }
when so .<type> eq 'access' { $access.emit: $_ }
default { die "invalid log type" }
}
}
}
拆分和连接可以更好的原因是, 它可以更容易阅读和理解, 因为每个 whenever
都集中在一个任务上。在一个分支涉及较长的进程, 而另一个分支涉及较短的进程的情况下, 它还可以让你考虑如何最好地分别优化每个任务。
113.5. 按需供应与现场供应
你应该知道各种供应(supply)之间的区别。它们之间的区别有些微妙, 可以在一定程度上互换使用。
-
使用
Supplier
类创建一个实时供应(live supply)。有一个单一的事件流, 这些事件是由关联的Supply
对象上的当前分接器(tap)接收的。如果没有分接器(tap), 则不处理这些事件。如果有 N 个分接器(tap),Supplier
对象的 .emit 方法会阻塞, 直到每个分接器(tap)处理完该事件。 -
使用
supply
块或通过调用列表上的.Supply
方法来创建按需供应(on-demand supply)。Supply
的每个分接器(tap)实际上是一个独立的进程, 从头到尾接收由该供应对象生成的所有项。生成供应中每个项的代码都会被运行, 再次在supply
块中发射, 直到单次分接(tap)完成。
从本质上说, 实时供应(live supply)使用的是扇出式(fan-out)架构, 而按需供应(on-demand supply)在行为上其实只是 Seq
的一个变种。我认为按需供应只是一个适配器(adapter), 使返回序列的函数与 whenever
块一起工作。
113.6. 避免 Supplier::Preserving
还有就是 Supplier::Preserving。有人认为这是两种类型之间的中间地带。然而, 这个对象的语义与现场供应(live supply)的语义完全相同, 但有一个例外:当没有分接器(tap)时, 这个对象会缓冲发出的事件, 并立即将这些对象转储到第一个出现的分接器(tap)中。
因此, 它主要是在开始发射前很难初始化分接器(tap)的情况下的一种便利。比如说:
my Supplier::Preserving $msg .= new;
$msg.emit($_) for ^10;
$msg.Supply.tap: { .say };
即使在向 $msg
发射后发生了分接(tap), 程序也会打印出从1到10的数字。
问题是 Supplier::Preserving
有相关的风险, 比如第一次分接(tap)时, 内存膨胀或在旧数据上长时间的迭代。相反, 你应该更倾向于使用 Supplier
, 并在发射之前确保所有的分接(tap)都到位。
my Supplier $msg .new new;
$msg.Supply.tap: { .say }
$msg.emit($_) for ^10;
或者只是能够在开始的时候错过一些。有些情况下, 你可能真的想用 Channel 来代替。
有些情况下, Supplier::Preserving
是很方便的, 所以根据需要利用它。我只是发现当我偷懒的时候, 它是适当的引导的一个简单的拐杖, 但在大多数情况下, 随着时间的推移, 它让我很烦。
113.7. 拆分长期运行的 whenever 块
对于你的任务来说, 什么是合理的, 可能会有所不同, 但请记住, 在 react
块内运行的代码, 一个 whenever
块会阻塞所有其他块的运行。react
块实际上只是在老式事件循环的薄薄一层外衣, 在这里任何子任务都会饿死其他任务的处理时间。
例如, 考虑一下我在上面提到的 build-loop
工具的 react
块。当 build-site()
例程运行时, 我的 Web 服务器无法刷新。这样可以吗?
-
这是一个开发过程, 所以我可以容忍 Web 服务器运行过程中的一些奇怪现象。
-
我是唯一的开发者。
-
这意味着我的网站要等到网站建设完成后才能刷新。
-
我更愿意等待, 只看到新鲜的内容。
听起来, 这对我来说是个胜利。
在生产中, 我是不会容忍的。在网络内容方面, 如果要花费超过几毫秒的时间来构建, 那么现在的旧内容几乎总是比最新鲜的内容要好。在这种情况下, 我会设置一个单独的 Web 服务器线程。在这种特殊情况下, 根本没有应用服务器, 只有静态内容, 所以没有必要。
这就是你在设计 whenever
块时必须要做的那种权衡。如果一个 whenever
块运行时间过长, 其他块就会被推迟。如果这是件坏事, 那就把那个 whenever
块分成一系列较小的 whenever
块, 把它们链在一起。在一些运行时间较长的过程中, 每当你完成一个 whenever
块的时候, 都是一个潜在的饥饿任务轮到它的机会。
如果这样的任务仍然是个问题, 你可能需要通过 start
块把它移到另一个线程中。
113.8. 批量短任务
113.9. 避免休眠
如果你在一个 react
块中, 你不希望调用 sleep, 除非你的目的是阻止当前线程的所有执行。否则, 你最好使用 await 来暂停你的任务。如果你这样做, 你的 react
块可以继续处理事件, 直到 await
完成。如果你需要在若干秒内添加一个 await
, 你可以这样做。
await Promise.in(10); # sleep 10 seconds
113.10. 谨防死锁
尽管 Raku 的接口是可以组合的, 但如果你使用不当, 仍然有可能导致死锁。react
块内的任何东西都保证以顺序的方式运行。这意味着, 如果你期望两个 whenever
块能够同时运行, 那么当代码突然停止时, 你会很失望。我之所以提到这一点, 是因为我时不时会遇到这个问题。即使我知道 react
块执行的是单线程一次的规则, 但我还是会时不时地想象多个 whenever
块可以同时运行。
如果你真的需要这样, 这很容易解决。只要在 whenever
块里面放一个 start
块, 就可以让两段代码同时运行。
113.11. 结论
暂时到此为止。几天后, 我们将再来讨论这个话题, 但不是异步, 而是考虑分工并发处理的准则。
干杯。
114. 第十六天 - 信号量
信号量(semaphore)是一个使用标志发送消息的系统。哦, 等等, 这就是计算机之外的信号量(semaphore)。在计算机中, 信号量(semaphore)就像一种锁, 被获取 N 次后就会被锁住。这对于你有 N 个项的资源, 当你知道有资源可用的时候, 想要快速分配, 然后立即阻止, 直到资源被释放出来的情况下, 是很有用的。Raku 为此提供了一个内置的 Semaphore 类。
class ConnectionPool {
has @.connections;
has Semaphore $!lock;
submethod BUILD(:@!connections) {
$!lock .= new(@!connections.elems);
}
method use-connection() {
$!lock.acquire;
pop @!connections;
}
method return-connection($connection) {
push @!connections, $connection;
$!lock.release;
}
}
在这里, 我们有一个连接池, 在这里我们可以快速、安全地从连接堆栈中拉取条目。但是, 一旦最后一个连接被拉出, .use-connection
方法就会阻塞, 直到使用 .return-connection
返回一个连接。
还有一个额外的 .try_acquire 方法可以用来代替 .acquire, 它返回一个决定成败的 Bool。例如, 我们可能会有一个用于按键的缓冲区, 如果缓冲区填满了, 我们希望它失败, 而不是继续存储按键事件。
class KeyBuffer {
has UInt $.size;
has UInt $!read-cursor = 0;
has UInt $!write-cursor = 0;
has byte @!key-buffer;
has Semaphore $!buffer-space;
has Semaphore $!lock .= new(1);
submethod BUILD(UInt :$!size) {
@!key-buffer = 0 xx $!size;
$!buffer-free .= new($!size);
}
method !increment-cursor($cursor is rw) {
$cursor++;
$cursor %= $!size;
}
method store(byte $key) {
$!buffer-space.try_acquire or die "buffer is full!"
$!lock.acquire;
LEAVE $!lock.free;
@!key-buffer[ $!write-cursor ] = $key;
self!increment-cursor($!write-cursor);
}
method getc(--> byte) {
my $result = 0;
$!lock.acquire;
LEAVE $!lock.release;
if $!read-cursor != $!write-cursor {
$result = @!key-buffer[ $!read-cursor ];
self!increment-cursor($!read-cursor);
$!buffer-space.release;
}
$result;
}
}
这个数据结构使用了两个信号量。其中一个名为 $!lock
, 和 Lock 的工作方式一样, 用来保护关键部分, 并确保它们是原子的。另一个叫 $!buffer-space
, 用于确保当缓冲区填满时, 写操作失败。
如你所见, 我们使用 .try_acquire
方法从 Semaphore
中获取资源。如果该方法返回 False
, 我们会抛出一个异常, 让调用者知道操作失败。如果该方法返回 True
, 那么我们就获得了向缓冲区添加另一个条目的权限。当我们从缓冲区中读取时, 我们仍然使用 .release 来再次标记可用的空间。
我之所以用 Semaphore
来做互斥锁, 是因为它可以用这种方式, 这就是我们要讨论的问题。然而让, Lock 或者 Lock:::Async 的 protect
方法在这里可能是更好的选择, 因为你不需要小心翼翼地确保 .release
被调用, 因为 .protect
块为你处理了这个问题。也就是说, LEAVE phaser 是确保 .release
被调用的好方法, 因为无论块如何退出, LEAVE phaser 都会被调用(也就是说, 即使是在异常情况下也会运行)。
需要注意的是, 如果在上面的 .getc
方法中, 在 $!read-cursor
被增量后, 但在 $!buffer-space.release
被调用之前, 如果发生了异常, 你可能会让缓冲区处于一个糟糕的状态, 它不再有那么多的空间。因此, 一个可能值得做的改进是确保 if
块中的异常被捕获并处理, 如果这种异常是可能的。
一般要记住的是, 每当处理并发性的时候, 看似微不足道的边缘情况很容易变得很重要。有时会以不可预见的方式变得很重要。
干杯。
115. 第十七天 - 比较 react 与 tap
在 Raku 中, 我们有几种基本的方式来获取从 Supply 发出的事件, 这就引出了一个问题, 那就是每一种方式之间有什么区别?我想通过创建一个带有几个间隔的 react 块来回答这个问题, 然后用 tap 来模拟同样的基本功能。
让我们从我们的基础 react
块开始:
sub seconds { state $base = now; now - $base }
react {
say "REACT 1: {seconds}";
whenever Supply.interval(1) {
say "INTERVAL 1-$_: {seconds}";
done if $_ > 3;
}
say "REACT 2: {seconds}";
whenever Supply.interval(0.5) {
say "INTERVAL 2-$_: {seconds}";
}
say "REACT 3: {seconds}";
}
seconds
例程只是一个辅助工具, 为我们提供从块开始到工作的时间(以秒为单位)。这个代码块的输出通常会类似于这样。
REACT 1: 0.0011569
REACT 2: 0.0068571
REACT 3: 0.008015
INTERVAL 1-0: 0.0092906
INTERVAL 2-0: 0.0101116
INTERVAL 2-1: 0.5103139
INTERVAL 1-1: 1.007995
INTERVAL 2-2: 1.022309
INTERVAL 2-3: 1.5124228
INTERVAL 1-2: 2.0137509
INTERVAL 2-4: 2.014717
INTERVAL 2-5: 2.517795
INTERVAL 1-3: 3.016291
INTERVAL 2-6: 3.0182612
INTERVAL 2-7: 3.521018
INTERVAL 1-4: 4.0182113
那么它是什么意思呢?嗯, 首先要注意的是, react
块本身的所有代码都会先运行。也就是说, 它运行所有的命令, 包括每个 whenever
块来注册每个 Supply
的事件 tap, 但还不运行代码。一旦 react
块完成运行, 它就会进行阻塞, 直到所有的 whenever
块都运行完毕或者是遇到 done
语句。这时, 所有的供应都会被解开(untapped), 继续执行。
顺便说一下, 如果你想在一个 react
块完成后让一个块运行(或者说一个供应块), 你可以使用特殊的 CLOSE phaser。LEAVE phaser 会在 react
块中的代码完成 设置 react
后立即退出。
除此以外, 必须注意的是, 与 react
块相关的一切都只会按顺序运行。Raku 并没有承诺在一个线程中运行, 但它承诺一个 react
块里面的两部分代码不会并发运行。这包括第一次运行通过执行 react
块本身, 以及执行 react
向供应发射值的 whenever
块。
那么, 我们应该如何使用 .tap
来实现这种行为呢?我们可以这样做。
sub seconds { state $base = now; now - $base }
REACT: {
say "REACT 1: {seconds}";
my $ready = Promise.new;
my $mutex = Lock.new;
my $finished = my $done = Promise.new;
my $interval1 = Supply.interval(1).tap: {
await $ready;
$mutex.protect: {
say "INTERVAL 1-$_: {seconds}";
$done.keep if $_ > 3;
}
}
$finished .= then: -> $p {
$interval1.close;
}
say "REACT 2: {seconds}";
my $interval2 = Supply.interval(0.5).tap: {
await $ready;
$mutex.protect: {
say "INTERVAL 2-$_: {seconds}";
}
}
$finished .= then: -> $p {
$interval2.close;
}
say "REACT 3: {seconds}";
$ready.keep;
await $finished;
}
这与 react
块的实际工作类似, 但多了几个手动步骤。首先, 我们必须准备几个承诺(Promise)。$ready
Promise 是保留在 "react" 块的结尾, 以释放 tap 进行工作。$done
Promise 是我们保留主线程的地方, 直到执行完成。
我还没有实现如果所有的供给都完成了, 就自动保留 $done
的额外逻辑。这样做可以通过为每个 tap 创建另一个 Promise 来实现, 当 tap done 块被执行时, 这个Promise将被保留。可以为所有这些 Promise.allof() 承诺附加一个 .then 块。我把解决这个问题作为一个练习留给读者。
另一个主要的新增功能是 $mutex
lock 对象。这可以防止各个 tap 块同时运行。
这应该就足够了。这可能不是最有效的解决方案, 但它确实展示了 react
块给你带来的额外帮助。你可能会注意到 tap 版本的速度稍微快了一点。这并不奇怪。这个 tap
版本不像 react
块那样精心组织。因此, 如果多花几毫秒的时间对你的代码很重要, 你可以考虑直接使用 tap 和其他工具来实现你的异步协调代码, 而不是使用 react
块。但是, 要注意的是, react
块很可能通过为你做那些繁琐的小细节, 为你的调试省去了一大堆麻烦。
还有最后一点, act 方法的文档中说, 它的工作原理和 tap 一样, 但给定的代码一次只由一个线程执行。我真的不清楚这到底是什么意思, 因为这个基本保证也是 tap 所固有的。这是因为在所有的 tap 运行完毕之前, Supply
无法继续进行另一个发出的消息。在实践中, 分接(tap)也都是同步运行每个消息的。在我所有的工作中, 我还没有发现任何证据表明在一个给定的 Supply
上的 tap 会同时运行。无论如何, 如果有人能继续在 Reddit 上发表这个帖子, 并解释一下 tap 和 act 之间的实际区别是什么, 我将不胜感激。
干杯。
116. 第十八天 - Supply 反压
在 Raku 中, Supply 是线程之间发送消息的主要工具之一。从 Supply
的结构方式来看, 很明显, 它为一个或多个任务提供了一种向多个接收方任务发送事件的手段。然而, 不那么明显的是, Supply
会给发送者带来一定的成本。
考虑一下这个程序。
my $counter = Supplier.new;
start react whenever $counter.Supply {
say "A pre-whenever $_";
sleep rand;
say "A post-whenever $_";
}
start react whenever $counter.Supply {
say "B pre-whenever $_";
sleep rand;
say "B post-whenever $_";
}
start for 1...* {
say "pre-emit $_";
$counter.emit($_);
say "post-emit $_";
}
sleep 10;
这里我们有三个任务在运行, 每个任务都在一个独立的线程中。我们让主程序在10秒后退出。前两个线程接收来自 $counter.Supply
的消息。第三个线程向这个 Supply
提供一连串的整数。你可能会想, 最后的任务会在传递事件的过程中进行竞赛, 但如果是这样, 那你就错了。
考虑一下这个程序的输出。
pre-emit 1
A pre-whenever 1
A post-whenever 1
B pre-whenever 1
B post-whenever 1
post-emit 1
pre-emit 2
A pre-whenever 2
A post-whenever 2
B pre-whenever 2
B post-whenever 2
post-emit 2
注意到一个模式了吗?即使前两个线程有一个随机等待, 第三个线程根本没有等待, 但第三个线程会被阻塞, 直到其他两个线程都完成。这种行为都是一样的, 无论 Supply
是如何被 tap 的, 这种行为都是一样的, 也就是说, 无论你是使用 whenever 块还是调用 .tap
, 都没有关系。
因此, 如果你想让你的发射器尽可能快地爆破事件, 你需要确保尽快写完 tap, 或者考虑不同的解决方案, 比如使用一个 Channel, 它将任务内存中排队任务, 只要监听该 channel 的线程有时间处理它们, 它们就会得到处理。
只要在使用 Supply
的时候注意这个背压成本就可以了。发送者总是要付出代价的。
117. 第十九天 - 无锁的线程安全结构
正如我之前在这个日历中多次说过的, 最好避免在运行的线程之间共享状态。然而, 这里又是另一种共享状态的方法, 当你需要这样做的时候。
几天前, 我们考虑将监视器作为创建线程安全对象的一种机制。让我们考虑一下下面的监视器。
class BankBalanceMonitor {
has UInt $.balance = 1000;
has Lock $!lock .= new;
method deposit(UInt:D $amount) {
$!lock.protect: { $!balance += $amount };
}
method withdraw(UInt:D $amount) {
$!lock.protect: { $!balance -= $amount };
}
}
后天我们考虑了比较与交换(compare-and-swap)操作, 也就是 cas
, 以及如何在 Raku 中与任何标量变量一起使用它。通过使用 cas
, 我们实际上可以在完全不使用锁的情况下创建线程安全对象。
因此, 我们可以把上面的类重写成这样的无锁数据结构。
class BankBalanceLockFree {
has UInt $.balance = 1000;
method deposit(UInt:D $amount) {
cas $!balance, -> $current { $current + $amount };
}
method withdraw(UInt:D $amount) {
cas $!balance, -> $current { $current - $amount };
}
}
就是这样。同样的保护措施, 但现在我们用标量 CAS
操作代替了。这可以比锁定更有效率。但为什么呢?
因为每次遇到锁的时候, 开始和结束都会有成本。再加上每一个关键的部分都是一个瓶颈, 多线程系统必须暂时变成单线程。而 CAS
没有特别昂贵的操作, 但可能会导致关键部分多次重跑。
让我们考虑一下我们系统中两个变量的极端情况: 竞争和运行时间。竞争(contention)是一个通用术语, 描述了一次需要在关键部分工作的线程数量。这里的运行时间描述的是在关键部分内运行操作所需的时间。
如果一个操作有较低的竞争性和较短的运行时间, 那么 CAS
几乎可以肯定会有更好的性能。锁在开始和结束时有很高的开销, 而 CAS
几乎不会有开销。在低竞争性的情况下, 我们可能要时不时地重复一个操作, 但操作的速度很快, 所以没有关系。
如果一个操作的竞争性很高, 运行时间很短, CAS
还是很有可能胜出。你可能最终会有一两个线程不得不多次重复操作, 但随着线程数量的增多, 一个锁对单线程瓶颈的执行并不能很好的扩展。
如果一个操作的竞争性低, 运行时间长, 那么 CAS
可能是个败笔。如果关键部分真的需要几百毫秒甚至更长的时间, 那么重复的成本可能会更高。不过, 可能值得进行 A/B 测试, 看看哪种方法会赢。
如果一个操作具有高竞争性和长运行时间, 锁可能会赢。然而, 在这一点上, 你的操作是否真的可以跨多线程扩展就不太清楚了。在许多竞争线程上长时间锁定的瓶颈, 基本上会使你的应用沦为单线程。也许是时候考虑如何加快操作速度, 或者用不涉及共享状态的方式进行操作了。
干杯。
118. 第二十天 - 拆解并发问题
今天这篇文章的目标是考虑什么时候要同时运行你的任务, 以及如何做到这一点。我不打算为此给出任何规则, 因为一次有效的规则可能在下一次就不管用了。相反, 我将专注于分享一些我从个人经验中学到的准则。
118.1. 牢记你的承诺
每当你使用并发的时候, 你要紧紧抓住相关的 Promise 对象。它们几乎都是重新合并(rejoin)你的任务, 使主线程等待你的并发任务完成等等的最佳方式。
我在代码中看到的一个常见模式是这样的。
my $gui-task = start { ... }
my $console = start { ... }
my $jobs = start { ... }
await Promise.allof($gui-task, $console, $jobs);
就像这样, 我有三个任务在三个不同的线程中运行, 主线程一直保持到三个任务完成。
这个 await
也是你要添加 CATCH
块的地方, 因为其他线程的异常会在这个时候重新合并(rejoin)到调用线程中。
118.2. 主线程是特殊的
当你编写并发程序时, 要注意主线程是特殊的。它不会被安排运行任务, 只要它在做某事或等待某事, 你的程序就会继续运行。只要主线程一退出, 你的其他任务就会立即被收割并退出。
118.3. 倾向于用单线程进行输入或输出
避免线程之间共享文件句柄或套接字。每次只有一个线程可以读取或写入一个句柄。确保你安全地做到这一点的最简单的方法是将该活动保持在一个线程中。在一个多线程程序中, 任何线程都可能输出到标准输出或标准错误, 我经常调用类似下面的模式。
my Supplier $out .= new;
my Supplier $err .= new;
start {
react {
whenever $out { .say }
whenever $err { .note }
}
}
start {
for ^10_000 { $out.emit: $_ }
}
start {
for ^10_000 { $out.emit: $_ }
}
如果你不采用这样的模式, 你的程序可能仍然可以工作, 但你的输出可能会出现一些奇怪的怪现象。
118.4. Raku 数据结构本质上并不安全
与上一节中说的输入和输出类似, 请注意, 大多数 Raku 数据结构都不是线程安全的。如果你想跨线程使用一个数据结构, 你必须使用一些策略来使该访问线程安全。一些可行的策略是。
无论你做什么, 都不要认为对对象的访问是线程安全的, 除非线程安全是对象设计的明确部分。
118.5. 在每个 GUI 事件循环或窗口中使用任务
如果你的应用程序有一个 GUI, 你几乎肯定需要一个单独的线程来管理 GUI 的输入和输出。大多数 GUI 库已经有一个内置的事件循环, 你想把它作为一个任务在一个单独的循环中运行。你可能需要为整个 GUI 设置一个任务, 也可能需要为每个窗口设置一个单独的任务。
118.6. 批量小任务
你并不总是希望每个动作都有一个任务。有些动作实在是太琐碎了, 而执行时间又太短, 无法管理。什么是合理的任务规模, 其实取决于你和你的执行环境。只是要注意, 当涉及的处理是琐碎的时候, 分批运行你的任务往往是比以微小的片段运行更好的策略。
118.7. 将较大的任务分解成较小的任务
有些并发任务, 你只想在 CPU 时间可用时连续运行, 或者每当有事件出现时就触发。然而, 单次运行时间较长的任务有时可以从被分解成较小的任务中受益。可以同时运行的任务数量有限, 将它们分解可以帮助确保 CPU 保持忙碌。
将任务分解的一个简单方法是在自然位置插入 await
语句。从 Raku v6.d 开始, 你可以有效地将任务转化为协程(coroutine), 通过使用 await
暂停, 直到 socket 准备好了, 更多的数据进来了, 有信号从 Promise 或 Channel 等处到达, 等等。记住, 任何时候 Raku 遇到 await
, 都是 Raku 在为当前任务所使用的线程上的另一个任务安排工作的机会。
118.8. 小心线程的限制
可用的线程数量是有限的。如果你的任务有可能运行大量的任务, 请花点时间考虑如何分解任务。限制任务之间的依赖数量将使你的程序能够有效地扩展, 而不会耗尽资源。
任何时候, 你的任务必须暂停输入或出于任何原因, 确保用 await
来做, 将确保最大数量的线程准备好工作。
118.9. 避免休眠
我认为 sleep 是有害的。相反, 最好使用 await Promise.in(…)], 因为这让 Raku 能够将当前线程重用到另一个任务中。只有当你故意想在暂停期间锁定一个线程时才使用 sleep
。我在这个临终日历中使用 sleep 主要是因为它比较熟悉。在实践中, 我一般只在主线程上使用它。
118.10. 结论
这些建议中的大部分内容与分解异步问题的建议重叠。我希望这能在编写并发程序时提供一些有用的指导。
干杯。
119. 第二十一天 - 并行循环执行
迭代很慢。如果在循环中要处理 N 件事, 则循环将需要 N 次迭代来处理。太慢了。不过有时候这是解决问题的唯一方法。
例如, 让我们考虑这样的情况: 我们有一个 JSON 日志, 并且想要一个命令来读取每一行, 解析该日志的 JSON, 并对其进行汇总以显示时间戳和消息。
use JSON::Fast;
my $log-file = 'myapp.log'.IO;
for $log-file.lines -> $line {
my %data = from-json($line);
say "%data<timestamp> %data<message>";
}
如果你的系统上有多个核心(都 2019 年了谁还没有多核呢?), 其实你可以通过一些小小的更改来加快这个速度:
use JSON::Fast;
my $log-file = 'myapp.log'.IO;
race for $log-file.lines -> $line {
my %data = from-json($line);
say "%data<timestamp> %data<message>";
}
race 前缀添加到任何循环中都会导致项在可用核心上尽可能快地被迭代。在我的机器上, 对于一个只有这两个字段的 10000 行的短日志, 其结果是节省了大约 25% 的时间。然而, 这也带来了一个后果:原来的行的顺序不再保留。在某些情况下, 这可能并不重要, 但在另一些情况下却很重要。
现在, 我们可以使用另一个保留顺序的前缀, 叫做 hyper
。然而, 在这种特殊情况下, 它是行不通的。为什么呢?因为 hyper
只能保证结果按顺序输出, 但这里我们是在代码运行时输出结果。这是每当使用这些关键字时要非常小心的地方。
但是, 这很容易解决。你只需要消除副作用, 让你的 for
循环发挥作用:
use JSON::Fast;
my $log-file = 'myapp.log'.IO;
my $output-lines = hyper for $log-file.lines -> $line {
my %data = from-json($line);
"%data<timestamp> %data<message>";
}
.say for @$output-lines;
现在, 我们从并行解析 JSON 行中获得了大部分的加速, 但是我们可以按照与原始文件相同的顺序输出。这样做的原因是, 带有 hyper
或 race
前缀的 for
循环的输出就像 do
一样:结果是一个我们可以迭代的序列。在这种情况下, 它是一个 HyperSeq
, 它确保 Raku 正确处理多线程部分。
干杯。
120. 第二十二天 - Asynchronous Socket
有什么比套接字通信更异步的?当两个程序需要相互交谈时, 往往是来自世界不同地区不同网络上的不同计算机, 你可以使用套接字进行连接。无论是 HTTP 服务器还是某些自定义协议, 你都可以使用 IO::Socket::Async 来实现该通信的两边。
让我们考虑一个简单的计算器服务。它侦听 TCP 连接。当连接建立后, 它通过连接接收一行行的输入, 并将每一行解析为一个简单的数学计算, 比如 2+2
或 6*7
。
我们可以把服务器写成这样。
react {
whenever IO::Socket::Async.listen('127.0.0.1', 3456) -> $conn {
whenever $conn.Supply.lines -> $line {
if $line ~~ m:s/$<a> = [ \d+ ] $<op> = [ '+' | '-' | '*' | '/' ] $<b> = [ \d+ ]/ {
my $a = +$<a>;
my $b = +$<b>;
my $r = do given "$<op>".trim {
when '+' { $a + $b }
when '-' { $a + $b }
when '*' { $a + $b }
when '/' { $a + $b }
default { "Unknown Error"; }
}
$conn.print("$r\n");
}
else {
$conn.print("Syntax Error\n");
}
}
}
}
现在, 嵌套的 whenever
块可能看起来有些奇怪, 但这很好。你可以随时通过这种方式在 react
内添加更多 whenever
块。
外层的 whenever
侦听新的连接对象。它在这里唯一的工作就是将连接注册为服务器的另一个 whenever
块。要知道, 使用这种策略确实意味着你要异步处理所有的连接, 就像从一个线程来的一样。一个更具可扩展性的解决方案可能是为每个到达的连接使用一个 start
块(看起来像这样):
start react whenever $conn.Supply.lines -> $line { ... }
继续前进, 内部的 whenever
会在每个连接到达时监视它的输入行。每当客户端发送了一行输入, 它就会接收到一条消息。这段代码会解析这一行, 执行表达式(或发现错误), 并返回结果。
很简单。
我们调用 listen
在指定的地址和端口号上建立一个侦听的 TCP 套接字。这将返回一个 Supply, 它将发出已连接的 IO::Socket::Async 对象。每当从关联的客户端发送时, 就可以使用连接对象上的 Supply
方法来获取文本(或通过 :bin
选项获取字节)。你可以使用 write
和 print
方法来发送字节和文本。
客户端也可以使用 IO::Socket::Async
来编写。下面是一个使用我们的表达式服务器来计算斐波那契序列的客户端:
my ($a, $b) = (0, 1);
say "$a";
say "$b";
await IO::Socket::Async.connect('127.0.0.1', 3456).then: -> $connection {
given $connection.result -> $conn {
$conn.print("$a + $b\n");
react {
whenever $conn.Supply.lines -> $line {
$a = $b;
$b = +$line;
say "$b";
$conn.print("$a + $b\n");
}
}
}
}
连接客户端时, 我们使用带有 IP 地址或服务器主机名的 connect
方法进行连接。该方法返回一个 Promise, 一旦建立连接, 该 Promise 就会被保留。这个 Promise 的结果是一个连接的 IO::Socket::Async
对象, 它的使用方法和服务器完全一样, 其中 Supply
返回文本或字节, 而 write
和 print
用于发送文本或字节。
干杯。
121. 第二十三天 - 异步锁
Raku 实际上提供了两个不同的锁定类。Lock 对象提供了一个非常标准的锁机制。当使用了 .lock
和 .unlock
或者调用了 .protect
时, 你会得到一段代码, 这段代码暂停直到锁释放, 在保持锁的同时运行, 然后释放锁, 这样其他可能在锁上等待的代码就可以运行。
然而, Lock
类的工作方式会阻塞当前线程。正如我在前面的降临日历中所指出的那样, 线程的目的是做事情, 所以阻止线程运行就是阻止线程实现自己的目的。幸运的是, 有一个解决方案。
如果你想要锁定, 但又不想在等待锁释放的时候烧掉线程, 可以考虑使用 Lock::Async 代替 Lock
。它的工作原理与 Lock
非常相似, 但 .lock
方法不会阻塞。相反, 它返回一个 Promise, 当锁被释放时, 这个 Promise 将被保留。等待该 Promise 的代码将以一种允许 Raku 为另一个任务重用当前线程的方式暂停。
class SafeQueue {
has @!queue;
has Lock::Async $!lock .= new;
method enqueue($value) {
await $!lock.lock;
push @!queue, $value;
$!lock.unlock;
}
method dequeue(--> Any) {
$!lock.protect: { shift @!queue }
}
}
上面的代码演示了 .lock
和 .unlock
以及 .protect
的使用。你应该总是优先选择 .protect
, 因为如果在获取锁后发生异常, 上面 enqueue
中的代码可能会让锁永远保持。从你的程序的角度来看, .protect
的行为介于 Lock
和 Lock::Async
之间, 但在内部会对 .lock
方法执行一个 await
。这意味着代码所运行的线程将被释放出来, 以便被另一个正在等待调度的任务使用。
干杯。
122. 第二十四天 - 异步进程间通信
标题里有很多大词。简单来说, 就是在后台运行一个程序, 并在输入和输出可用时与之交互。Raku 中用于完成这项工作的工具叫做 Proc::Async。如果你曾经处理过试图与外部进程安全通信、写入输入和从输出和错误流中读取的痛苦, 并对此感到厌恶, 我想你会喜欢 Raku 内置的工具。
首先, 让我们来构思一个问题。让我们做一个外部程序, 它把字符串作为输入, 并将其反转, 并在标准输出上输出。同时, 它在标准错误上报告给定的字符串是否为回文。我们可以这样写这个程序。
for $*IN.lines -> $line {
my $rline = $line.flip;
say $rline;
note ($rline eq $line);
}
为了让大家明白, 对于这个样本输入。
spam
slap
tacocat
我们将得到这样的输出。
maps
False
pals
False
tacocat
True
True
和 False
这两行是标准错误, 其他行为标准输出。清楚了吗?很好。
接下来, 为了完成我们的构思问题, 我们需要与这个程序进行交互, 只需写出一条消息, 比如 tacocat is a palindrome!
。因为每当我们看到一个回文的时候就会很兴奋。否则我们什么都不想输出。让我们使用 Proc:::Async
来与我们的另一个程序进行交互, 我们称之为 palindromer
, 以示欢喜。
react {
my $palindromes = Supplier.new;
my $p = Proc::Async.new: './palindromer', :w;
my @rlines;
my @palindrome-checks;
# Echo our own input
whenever $*IN.Supply.lines -> $line {
$p.say: $line;
# Let palindromer know when we run out of input
LAST { $p.close-stdin }
}
# Watch for the reverse lines on palindromer's standard output
whenever $p.stdout.lines -> $rline {
if @palindrome-checks.shift -> $is-palindrome {
$palindromes.emit: $rline;
}
else {
push @rlines, $rline
}
}
# Watch for the True/False output from palindromer's standard error
whenever $p.stderr.lines -> $is-palindrome {
if @rlines.shift -> $rline {
$palindromes.emit: $rline if $is-palindrome eq 'True';
}
else {
push @palindrome-checks, $is-palindrome;
}
}
# PALINDROMES ARE EXCITING!
whenever $palindromes.Supply -> $palindrome {
say "$palindrome is a palindrome!";
}
# Quit when palindromer quits
whenever $p.start { done }
}
现在, 如果我们把和之前一样的输入管道到我们的新程序中, 我们应该得到这样的输出。
tacocat is a palindrome!
我们的代码处理了标准输出和标准错误不同步的潜在问题, 通过使用队列来积累两边的额外值。我们将发现的 palindromes 送入到一个名为 $palindromes
的中央供应中, 这样我们就可以有一个地方来打印我们发现的 palindromes。
在使用 Proc:::Async
时要注意的几个关键点。
-
始终要把 .start 调用放在标准输出和标准错误的 tap 之后。否则, 可能会出现漏行的问题(即在你侦听之前发出的行)。顺便说一下, 如果 Raku 检测到你这样做了, 它就会警告你。这个方法会返回一个 Promise, 当程序结束时, 这个 Promise 会被保留。
-
确保你在完成输入时使用 .close-stdin 来确保其他程序知道你已经完成了。
干杯。
start
会把任务安排在下一个可用的线程上, 因为在 Raku 中一般都是安全的。
Thread
对象不一定代表一个特定的操作系统线程, 但它应该能让你尽可能地接近实现。