Containers in Raku

楽土

Containers in Raku

在编程语言中,变量是将一个特定的值与编译器已知的名称关联起来的一种方式。例如,以变量声明及其赋值为例,my $x = "Hello"。这就把值 42 和名字 $x 联系起来了,虽然这对大多数意图和目的来说是正确的,但在 Raku 中这并不是整个故事。

在 Raku 中,当编译器遇到变量声明 my $x 时,编译器会把它注册到某个内部符号表中。符号表一般提供了按名称查找对象的功能,并允许编译器从程序源代码中提到它的名称来获得一个对象。在 my $x="Hello" 的例子中,变量 $x 的词条垫(lexical pad)条目是一个指向 Scalar 类型的对象的指针,作为对象 “Hello” 的容器。形象地讲,可以用以下方式表示。

Table        Container       Value
-------       +---+      +---------+
$x | *   ---> | * | ---> | "Hello" |
-------       +---+      +---------+

在图中,符号表中的单元格(标有 *)与名称 $x 相关联。这就是所谓的绑定。名字经常被绑定到作为其他对象容器的对象上(例如,Scalar 作为对象 “Hello” 的容器),但不一定总是这样,我们将在下一节中看到。目前,只要说使用符号 $ 的名字利用了 Scalar 容器,可以绑定到任何对象上就够了。

Item 容器

我们先说说无标号变量。在 Raku 中,无符号变量是一个没有符号的变量,它的值没有被容器化。它们是通过使用转义字符 \ 来声明的,我们通过使用绑定操作符 (:=) 来关联一个值。比如说:

my \y := 42;
say y; #=> «42␤»

形象地讲,可以这样描述:

Table             Value
-------        +---------+
y | *   --->   | "Hello" |
-------        +---------+

无符号变量与它们的值有别名,因此它们不会被绑定到作为其值容器的对象上。这就是名称 y 的情况。名称直接与值 42 绑定,因此在变量名称和绑定值之间没有容器。另一种解释是,现在 y42 都代表了字面值 42。这意味着,当一个名字被绑定到一个值之后,你不能再把它重新绑定到其他东西上。

42 := 45; #= Cannot use bind operator with this left-hand side
y := 45;  #= Cannot use bind operator with this left-hand side

上面的原因是,代表整数 42 的对象永远不会改变为代表另一个不同的对象。在 Raku 中,值类型(及其实例对象),如 Int(如 42)和 Str(如 "Hello")是不可改变的。

当一个值被绑定到一个变量后,我们如何使他可变呢?首先,我们可以尝试掌握一个 Scalar 容器,作为变量名和值之间的代理。我们不能通过 new 方法来实例化一个 Scalar,但是符号 $ 是这个任务的最佳选择;Scalar 容器类型默认与符号 $ 相关联。我们将一个词法的 Scalar 容器绑定到变量上,而不是字面值。

my \z := my $;
say z;   #=> «(Any)␤»
z := 42; # Cannot use bind operator with this left-hand side

然而,这并没有太大的帮助,从 Cannot use bind operator... 错误就可以看出。当我们声明变量 z 的时候,$ 的值已经被绑定到了它上面,而试图重新绑定变量会导致和之前一样的错误。希望现在已经清楚了,但原因是我们试图重新绑定到一个值(即 Scalar 容器),我们不能这样做。然而,我们离目标不远了;我们想要的是能够替换一个容器的值,而不是替换绑定到变量的容器。这就是赋值运算符(=)的作用,它是绑定运算符(:=)的近亲。

与绑定运算符不同的是,绑定运算符使变量和它的绑定值成为同一种东西,而赋值运算符则是将右边的值放到赋值的左边的容器中。

我们再试一下。我们不尝试绑定到已经声明的变量,而是对它进行赋值。

my \z := my $;

z = 42;
say z; #=> «42␤»

z = 45;
say z; #=> «45␤»

如上所示,我们现在可以分配一个值,也可以用其他值替换它。我们甚至可以给容器添加一个类型约束。

my \z := my Int $; # Only values of type Int
z = 1;     # OK!
z = 'T';   # Error: Type check failed in assignment;
           # expected Int but got Str ("One")

scalar 符号

我们已经创建了一个变量,我们可以给它赋值,然后通过赋值来改变它。然而,这似乎是一个繁琐的过程,因为我们必须声明一个无符号的变量,然后为其绑定一个 Scalar 容器。如前所述,以符号 $ 为前缀的变量会自动为其创建 Scalar 容器,这些变量被称为 scalar 变量。

my $x;
$x = 42;

my Int $y;
$y = $x * 2;

my Str $z;
$z = 'Hello';

我们也可以绑定 $-sigilled 变量,在这种情况下,不会创建 Scalar 容器,变量是绑定值的别名。在这种状态下,$-sigilled 变量很像无符号变量。前面没有提到,但事实上,无论使用绑定(:=)还是赋值(=),无符号变量总是绑定的。

my $t := 1; # bind 1 to $t, no Scalar container created

my \u := 2; # bind 2 to u
my \v  = 2; # bind 2 to v

更多关于 Scalar 容器的东西

Scalar 容器将所有的操作委托给它们所包含的值。每当你使用容器时,它们就会使用其中的值,而且对于大多数目的来说,它们都是隐藏的,在普通使用 Raku 时是看不到的。例如,给定:

my $nvalue := "hello"; # binding, no Scalar container
my $cvalue  = "hello"; # assignment, Scalar container

say $nvalue;              #=> «hello␤»
say $cvalue;              #=> «hello␤»

say $nvalue.substr(0, 2); #=> «he␤»
say $cvalue.substr(0, 2); #=> «he␤»

你很难分辨出 $nvalue$cvalue 之间的区别。除非你利用容器所提供的功能,如对变量进行赋值、向函数传递 rw 对象等,否则你不会注意到它们的存在(或者没有)。为了检查一个变量是否与值或容器绑定,我们可以使用内省伪方法 VAR,如果有的话,它会返回底层的 Scalar 对象。

my $nvalue := "hello";
my $cvalue  = "hello";

say $nvalue.VAR;       #=> «Str␤»
say $cvalue.VAR;       #=> «Str␤», yep containers work hard to stay hidden.

say $nvalue.VAR.^name; #=> «Str␤»
say $cvalue.VAR.^name; #=> «Scalar␤»

到目前为止,我们已经能够将一个单一的值和一个容器绑定到一个变量上。那么将一个值的列表绑定到一个变量上呢?在 Raku 中,我们使用逗号和/或分号来声明一个值的字面量列表(List)。

my $evens := 2, 4, 8;
say $evens;              #=> «(2 4 8)␤» 
say $evens.VAR;          #=> «(2 4 8)␤»
say $evens.VAR.^name;    #=> «List␤»

say $evens[0];           #=> «2␤»
say $evens[0].VAR;       #=> «Int␤»
say $evens[0].VAR.^name; #=> «Int␤»

$even     := 3; # Cannot use bind operator with this left-hand side
$evens[2] := 6; # Cannot use bind operator with this left-hand side 
$evens.push(6); # Cannot use bind operator with this left-hand side

正如你所看到的,试图重新绑定一些值会给我们带来一个错误,不管这个值是来自 List 还是字面值。此外,我们不能通过添加新的值来修改列表。这就是 List 在 Raku 中是如何做到不可改变的;无论是列表本身还是它的值都不能被突变。

为了创建一个元素可以突变的列表,我们可以用一个单一的值来复制之前的过程:将列表中的每个槽(或者至少是那些我们需要突变的槽)容器化,然后将整个列表绑定到变量上。因此,我们最终得到的不是一个字面值的列表,而是一个可以分配给的 Scalar 对象的列表。

my $evens := my $ = 2, my $ = 4, my $ = 8;
say $evens;              #=> «(2 4 8)␤»
say $evens.VAR.^name;    #=> «List␤»
say $evens[0].VAR.^name; #=> «Scalar␤»
say $evens[0];           #=> «2␤»
$evens[2] = 6;           # OK
$evens.push(7);          # Cannot call 'push' on an immutable 'List'

我们已经将列表中的每一个项目容器化,而不是列表本身。因此,虽然我们可以突变它的每个元素,但不能修改列表。

每一个 Scalar 对象也可以被类型限制。

my $evens := (my Int $ = 2, my Int $ = 4, my Int $ = 8);
$evens[2] = 6;      # OK!
$evens[1] = 'four'; # Typecheck failure

到目前为止,我们只给容器添加了类型约束,如 2"Hi",然而我们可以为 List 做同样的事情。对于像 $evens 这样的变量,我们可能只需要它持有一个由其索引访问的事物列表。在 Raku 中,有 Positional 角色来完成这个任务。这和 ListArray 等类型实现的角色是一样的,这些类型支持使用运算符 [] 进行索引。我们可以约束 Scalar 容器,将 List 分配给它,然而我们不希望 List 容器化。相反,我们约束 List 所绑定的变量。

# This works
my Positional $evens := (my Int $ = 2, my Int $ = 4, my Int $ = 8);

# This doesn't work
my Positional $odds := 3;
# Type check failed in binding; expected Positional but got Int (2)

my Positional[Int] $ints := 3;
# Type check failed in binding; expected Positional[Int] but got Int (3)

变量 $evens 最终是一个 Positional 变量,其值是一个具有可变项的列表。值得重复的是,列表本身是不可变的,只有它的元素是可以变的。例如,你既不能从列表中添加也不能删除元素。

可调用的符号

对于支持通过操作符 () 调用的事物,& 符号意味着一个 Callable 类型约束,它提供了同样的赋值快捷方式,它给你一个 Callable,并为值创建一个 Scalar。与 $ 符号类似,这个符号也支持类似项目的赋值。

my &b := { $^x };
say &b.VAR;       #=> «-> $x { #`(Block|94463244345248) ... }␤»
say &b.VAR.^name; #=> «Block␤»

my &c = { $^x };
say &c.VAR;       #=> «-> $x { #`(Block|94463244345248) ... }␤»
say &c.VAR.^name; #=> «Scalar␤»

聚合容器

位置标志

在上一节中,声明一个 Positional 变量,其值是一个带有可变项的列表,看起来非常的啰嗦。幸运的是,Raku 提供了一些语法来简化它。

首先,我们不需要显式地用 Positional 来约束变量的类型。我们可以使用符号 @ 来代替符号 $ 来表示变量有一个 Positional 类型约束。

my @evens := 2 ;   # Type check failed in binding;
                   # expected Positional but got Int (2)

my Int @ints := 1; # Type check failed in binding;
                   # expected Positional[Int] but got Int (2)

my @odds := 1,;    # a single-element list,
                   # notice the trailing comma

其次,我们将用方括号包围我们的逗号分隔的列表。这告诉编译器要创建一个 Array 而不是 List。与 List 不同的是,Array 本身是可以突变的,它的每个元素都是可以突变的,因为它们都被自动放入 Scalar 容器中,就像我们在上一节中手动做的那样。顺便说一下,我们可以用括号包围我们的列表,但只有在需要分组的情况下才需要;在 Raku 中,逗号而不是括号可以创建列表。

my @evens := [2, 4, 8];

say @evens.VAR;          #=> «[2 4 8]␤»
say @evens.VAR.^name;    #=> «Array␤»
say @evens[0].VAR.^name; #=> «Scalar␤»

@evens[2] = @evens[2] - 2; # OK!
@evens[3] = @evens[2] + 2; # OK!
@evens[4] = @evens[2] + 2; # OK!

say @evens; #=> «[2 4 6 8 10]␤»

我们的代码变得短了很多,但我们仍然可以通过使用赋值而不是绑定来多折腾几个字符。正如前面所演示的那样,赋值到 $-sigilled 变量时,可以免费为你提供一个 Scalar 容器。在赋值给 @-sigilled 变量时也是如此,它免费为你提供了一个 Array 容器。如果我们切换到赋值,我们之前的代码可以通过完全放弃方括号而变得更短。我们知道他们指示编译器创建一个 Array,但在执行赋值时,这已经由 @ 完成了。

my @evens = 2, 4, 8;

say @evens.VAR;          #=> «[2 4 8]␤»
say @evens.VAR.^name;    #=> «Array␤»
say @evens[0].VAR.^name; #=> «Scalar␤»

字面数组是用数组构造函数 [] 来创建的,但是如果你像前面演示的那样将其赋值给一个 []-sigilled 变量,就不需要这样做了。

say [2, 4, 8].VAR;          #=> «[2 4 8]␤»
say [2, 4, 8].VAR.^name;    #=> «Array␤»
say [2, 4, 8][0].VAR;       #=> «2␤»
say [2, 4, 8][0].VAR.^name; #=> «Scalar␤»

关联符号

到目前为止,我们所做的一切都可以应用于 % 符号变量。% 符号意味着通过操作符 {} 为基于名称的查找提供关联类型约束,并且它为赋值提供了同样的快捷方式,它为变量提供了一个 Hash 容器,并为每个值创建了 Scalar 容器。

my %h := Map.new('a', 1, 'b', 2);
say %h.VAR;          #=> «Map.new((a => 1, b => 2))␤»
say %h.VAR.^name;    #=> «Map␤»
say %h<a>.VAR.^name; #=> «Int␤»

%h<a> = 12;          # Cannot modify an immutable Int (1)...

Map 之于 Hash,就像列表之于数组;Map 是不可改变的,Map 本身和它的值都不能突变。这是因为 Map 不对其值进行容器化。不像 List 有一个简单的 list 构造函数,没有 map 构造函数,所以我们必须实例化 Map 类。

为了得到一个可突变的 Map(称为 Hash),我们可以将 Map 赋值给 %-sigilled变量,这类似于将 List 赋值给 @-sigilled 变量以得到一个 Array

my %h = Map.new('a', 1, 'b', 2);
say %h.VAR;          #=> «{a => 1, b => 2}␤»
say %h.VAR.^name;    #=> «Hash␤»
say %h<a>.VAR.^name; #=> «Scalar␤»

%h<a> = 12;          # OK!
%h<c> = 25;          # OK!

可以使用哈希构造函数 %() 来创建字面哈希值,但是如果你要分配给一个 %-sigilled 变量,就不需要这样做。

say %('a', 1, 'b', 2).VAR;          #=> «{a => 1, b => 2}␤»
say %('a', 1, 'b', 2).VAR.^name;    #=> «Hash␤»
say %('a', 1, 'b', 2)<a>.VAR;       #=> «1␤»
say %('a', 1, 'b', 2)<a>.VAR.^name; #=> «Scalar␤»

容器化

一路走来,我们了解到对 $-sigilled 变量的赋值可以给你一个免费的 Scalar 容器。这既适用于单个值(如一个数字、一个字符串等),也适用于集合值(如一个数字列表、一个字符串列表等)。

对于 $-sigilled 变量来说,整个列表(或任何集合类型)的赋值仍然是一个单一的东西,即一个列表,它将其视为单一的东西。通过比较一个绑定到 $-sigilled变量的 List(在这种情况下,不涉及 Scalar)和一个分配到 $-sigilled 变量的 List(在这种情况下,会自动创建一个 Scalar 容器),可以最好地证明这一点。

# Binding
my $list := (1, 2, 3);
say $list.raku;            #=> «(1, 2, 3)␤»
say "Item: $_" for $list;  #=> «Item: 1␤Item: 2␤Item: 3␤»

# Assignment
my $list = (1, 2, 3);
say $list.raku;            #=> «$(1, 2, 3)␤»
say "Item: $_" for $list;  #=> «Item: 1 2 3␤»

raku 方法给我们提供了一个额外的洞察力,它向我们展示了第二个 List,在它前面有一个 $,以表示它被容器化在一个 Scalar 中。更重要的是,当我们用 for 循环迭代我们的 List 时,第二个 List 的结果只是一次迭代:整个 List 作为一项。Scalar 容器名不虚传;无论项的复杂程度如何,标量变量都能容纳一项目。

回想一下,数组(和哈希)为它们的每个单独的值创建 Scalar 容器。这意味着,如果我们嵌套东西,即使我们选择存储在数组(或哈希)内部的单个列表或哈希,并尝试在其上进行迭代,它也会被视为只是一个单项。

my @things = (2, 4, 6), %(:France<Paris>, :Peru<Lima>);

say @things[0];           #=> «$(2, 4, 6)␤»
say @things[0].VAR.^name; #=> «Scalar␤»
say @things[1];           #=> «${:France("Paris"), :Peru("Lima")}␤»
say @things[1].VAR.^name; #=> «Scalar␤»

这证明了 Scalar 容器的一致性。单项,无论其结构如何,在 Scalar 容器内仍然是单项。例如,当你尝试扁平化一个 Array 的元素或将它们作为参数传递给一个 slurpy 参数时,就会出现这种行为。

my @things = (2, 4, 6), %(:France<Paris>, :Peru<Lima>);
my @mixed = 2, 4, 6, :France<Paris>, :Peru<Lima>;

say flat @things;    #=> «((2, 4, 6), {France => 'Paris', Peru => 'Lima'})␤»
say flat @things[0]; #=> «(2 4 6)␤»
say flat @things[1]; #=> «{France => 'Paris', Peru => 'Lima'}␤»

my &p = -> *@args { @args };

say p @things; #=> «(2 4 6)␤{France => 'Paris', Peru => 'Lima'}␤»
say p @mixed;  #=> «2 4 6 France => Paris Peru => Lima␤»

绑定与赋值

从目前的讨论来看,下面的内容可能都很明显,但还是值得在这里总结一下。

每当你将一个右侧实体绑定到一个变量上时,就会跳过容器(ScalarArray 等)的创建,直接将实体绑定到变量上,而不管变量的 sigil 可能暗示了什么。在绑定操作中,sigil 给出的关于变量的唯一东西就是可以绑定到它的值的类型。sigil @ 意味着 Positionitional 角色,只有实现该角色的类型(ListArrayRangeBuf)的值才能被绑定到一个 @ 符号变量上。另一方面,符号 % 意味着关联角色,只有实现这个角色的类型(Hash, Map 等)的值才能被绑定到一个以 % 为标志的变量上。标号 & 意味着可调用角色。至于 $,任何值都可以绑定到 $-sigilled变量上,除非该变量被进一步约束。无标号变量没有标号,因此它们的绑定值并不限制于某些类型。

# anything goes with $
my $anythinga         := 1;                # OK!
my $anythingb         := 1, 2;             # OK!
my $anythingc         := {A => 1, B => 2}; # OK!

my \anythinga         := 1;                # OK!
my \anythingb         := 1, 2;             # OK!
my \anythingc         := {A => 1, B => 2}; # OK!

# only positionals for @
my @only-positionala  := 1;                # Error
my @only-positionalb  := 1, 2;             # OK!
my @only-positionalc  := {A => 1, B => 2}; # Error

# only associatives for %
my %only-associativea := 1;                # Error
my %only-associativeb := 1, 2;             # Error
my %only-associativec := {A => 1, B => 2}; # OK!

另一方面,赋值1)提示编译器创建一个自动容器,这个容器由变量的 sigil 决定,2)在容器中存储任何值,最终3)将容器与变量绑定。

因此,在赋值过程中,sigil 的责任是双重的。

限制可以绑定到变量上的值的类型。对于符号 @ 来说,这意味着只有实现 Positional 角色的值才能绑定到 @ 符号变量上。

指示编译器为 sigil 创建相应的容器。对于 sigil $,这意味着创建 Scalar 容器。而对于 sigil @,则是创建 Array 容器。

去容器化

前面我们看到了 Scalar 是如何影响扁平化一个 Array 元素的行为,或者将它们作为参数粘贴到一个 slurpy 参数上。在这些情况下,我们希望对我们的列表和哈希进行去容器化,这可以通过使用去容器化方法(<>)来实现。

my @things = (2, 4, 6), %(:France<Paris>, :Peru<Lima>);
.say for @things[0];     #=> «(2 4 6)␤»
.say for @things[0]<>;   #=> «2␤4␤6␤»

.say for @things[1];     #=> «{France => 'Paris', Peru => 'Lima'}␤»
.say for @things[1]<>;   #=> «France => 'Paris'␤Peru => 'Lima'␤»

要对一个数组中的每个元素进行去容器化,我们可以用超运算符 » 简单地对去容器化方法进行超运算。

my @things = (2, 4, 6), %(:France<Paris>, :Peru<Lima>);
say flat @things»<>; #=> «2 4 6 France => Paris Peru => Lima␤»

my &p = -> *@args { @args };
say p @things»<>;    #=> «2 4 6 France => Paris Peru => Lima␤»

尽管如此,我们本可以通过简单地将最上层的列表绑定到变量上,来避免 Array 对我们的列表和哈希进行容器化。列表不会将它们的项目放入容器中,所以没有什么需要解容器化的。

my @things := (2, 4, 6), %(:France<Paris>, :Peru<Lima>);
say flat @things; #=> «2 4 6 France => Paris Peru => Lima␤»

my &p = -> *@args { @args };
say p @things;    #=> «2 4 6 France => Paris Peru => Lima␤»

创建自定义容器

可以通过使用 Proxy 类来创建自定义容器。这个类有两个方法,允许你设置一个钩子,每当从容器中获取一个值(FETCH)或设置一个值(STORE)时,这个钩子就会执行。

我们使用构造函数 new 方法创建 Proxy 对象,并为 FETCHSTORE 方法提供两个命名参数。

  • 每当从容器中读取一个值时,FETCH Callable 就会被调用。Callable 被调用时只有一个位置参数:Proxy 对象本身。

  • 每当一个值被存储到容器中时(即在进行赋值时),STORE Callable 就会被调用。Callable 的第一个位置参数是 Proxy 对象本身,第二个参数是给出要存储的值。

下面的例子说明了如何创建一个 Proxy 对象,该对象可以存储分配给它绑定的变量的所有偶数,并在读取变量的内容时返回它们。为了避免 Proxy 对象的 Scalar 容器化,我们将它绑定到变量上;或者我们可以使用一个无符号变量。

sub collect-evens {
    my @evens;
    Proxy.new:
        STORE => method ($proxy: UInt \new) { @evens.push(new) if new %% 2 },
        FETCH => method ($proxy: ) { @evens  },
    ;
}

my $evens := collect-evens();
$evens = $_ for 0..10;

say $evens;           #=> [0 2 4 6 8 10]
say $evens.VAR;       #=> $[0 2 4 6 8 10]
say $evens.VAR.^name; #=> Proxy

鸣谢

如果没有下面这些写法,我是不可能写出这篇文章的。事实上,这篇文章本身主要是对那些文章中讨论的内容的复述,所以一定要读一读。

comments powered by Disqus