Wait the light to fall

Roles or When One Is Many

焉知非鱼

Roles or When One Is Many

让 Raku 与 Perl 相当不同的一点是,Raku 避免了魔法。有几个地方人们可以说,“它神奇地发生了”。但仔细看一下,通常会发现行为背后有相当好解释的机制。这就像看魔术师的把戏:我们总是知道有解释,而且它们肯定是符合逻辑的。

因此,我有个小把戏给你。看一下代码,告诉我:你在这里看到了多少个角色?

role R[::T] { has T $.a; }
class C does R[Int] { }

直观的答案当然是 1,这也是事实。但这里的部分技巧是术语的替换:在使用"角色"一词的地方,更准确的术语应该是"角色类型对象"。现在,试着猜出正确答案。而且,要确定的是,它不止一个。

Raku 魔术是如何不神奇的 #

Raku 的最大优点之一,随着时间的推移,我越来越学会重视,就是它做任何事情都要保持逻辑性。有时这并不意味着要有直觉。有些行为一开始甚至可能使初学者感到困惑。但是,在解释时,逻辑通常是相当有说服力的。Raku 提供的一套广泛的内省工具,通常对理解它有很大帮助。在这篇文章中,我将尝试以"魔术师"的方式演示如何使用其中的一些工具来将一只兔子变成许多只。

我还将在很大程度上依赖于 Rakudo 对 Raku 的实现,它是基于 NQP 的,这使得在某些情况下相当容易看到 Raku 语法的幕后。顺便说一下,这也是 Raku 中的魔法量处于可忽略不计的水平的另一个原因。你们当中有多少人,我的读者,曾经研究过 Perl 或任何你最喜欢的语言的来源?如果我为自己回答,那么就是一个词:从未。尽管 C 语言是我多年来的首选语言。但现在我会坚持要求你在你的主目录下的某个地方做 git clone https://github.com/rakudo/rakudo.git,所有其他项目都放在那里。然后,只要你遇到问题,答案很可能就在 Rakudo 项目的 src/Perl6/Metamodel 目录下的一个文件里。

四位一体 #

我不得不谷歌一下这个词。从第一部《黑客帝国》电影开始,“三位一体"对我来说就很熟悉了,但对这一行中的其他词却不熟悉。是的,这个词就是这个棘手问题的答案。Raku 角色是四位一体。这篇文章将一步一步地告诉你为什么。

在这一点上,我想提醒的是,反省和乐库元模型的一般知识将是非常有益的。一些信息可以在本周期的前几篇文章中找到,一些可以在Raku文档中找到。

第1步: 多重性 #

让我们从最简单的自省开始。

raku -e 'role R[::T] { }; say R.WHAT'
(R)

不要和 ‘⇒’ 字符混淆,这只是我最喜欢的命令行提示。

注意,我们只用方括号来声明角色,而不是对它调用方法。还要注意的是,这个角色报告自己只是 R;同样,没有涉及方括号。

接下来,你可能已经知道,在 Raku 中,同一个角色有可能有不同的变体。

raku -e 'role R[::T] { }; role R { }; say R.WHAT'
(R)

我们有两个声明,但仍然只使用 R 来调用 WHAT

让我们换个角度,看看这个角色是如何实现的。

raku -e 'role R[::T] { }; say R.HOW.^name'
Perl6::Metamodel::ParametricRoleGroupHOW

注意名字中的 Group 部分。新手可能会对这个词感到困惑,只要他们只使用一个角色的变体。但是当他们到了本节第二个例子的时候,事情就开始变得比较清楚了。让我再把它们变得更加混乱。

role R[::T] { method foo { 42 } }
role R { }
say R.^lookup('foo');

你希望这段代码能输出什么?根据文档,在一个类上这样做会得到相当可预测的结果。

class Foo { method foo { } }; 
say Foo.^lookup("foo"); # foo

现在,忘记这个经验吧。因为对于上面的例子中的 R,我们会得到 (Mu),意思是没有找到方法!而对于 R,我们会得到 (Mu)

在这一点上,我想退一步说。如果你读了 Raku 的文档或书,做了关于角色和参数化的部分,有一个细节可能会让你觉得相当熟悉。如果这也是我要指出的"东西”,那么你就不会错了:参数化是关于参数的;有了参数,就有了签名!"。现在这段代码一定是完全有意义的。

role R[Int:D $a, Str:D $b] { ... }

角色声明中被方括号括起来的部分是一个签名,这有另一层意思,我将在后面再谈。

不幸的是,我在写这篇文章的时候有点超时,它应该在涉及到几个更基本的主题之后再完成。出于这个原因,我为下面的一点离题而道歉。

多重分派 #

人们可以在 Raku 文档中找到这一部分另一节阐述了其语法和功能。但我想简单地谈谈这个功能的内部实现。让我们从一个基本的声明开始。

proto foo(|) {*}
multi foo(Int:D $i) {}
multi foo(Str:D $s) {}
say &foo.raku; # proto sub foo (|) {*}

正如你所看到的,raku 方法只报告了 proto。另外,如果我们对 &foo 调用 is_dispatcher 方法,它将返回 True。好的,但是这两个 multi 在哪里,当我们调用 foo("bar") 时会发生什么?用两句话来说,Raku 首先会找到 proto 方法。如果它通过检查 is_dispatcher 的返回值识别出它是这样的,那么它就会通过调用 &foo.candidates 来获取已知的候选列表。

say &foo.candidates.map(*.raku).join("\n");
# multi sub foo (Int:D $i) { #`(Sub|140560053018928) ... }
# multi sub foo (Str:D $s) { #`(Sub|140560053019072) ... }

然后,它试图将提供的参数与每个候选者的签名绑定。如果绑定成功,则调用该候选程序(如果没有找到,则抛出一个异常)。

显然,在现实生活中,事情要复杂得多,但我们还不需要知道这些……

回到多重角色的问题上 #

有时我对不能在文章的纯文本中递归到一个子主题感到奇怪。就把这一节的标题看成是上一节的 return 语句……啊,算了!不说了。

好吧,我的观点是什么,就是要讲述多重调度的故事?当我们看到 R.HOW 在类名中报告了一个 Group,就可以和多重调度实现中的 proto 相提并论了。事实上,我们调用 HOW 方法的类型对象 R 是一个伞状的实体,在其共同的名字下代表了角色的所有变体。而且,当我们把 R[Int] 应用于一个类时,实际发生的过程是一种多重分派,Raku 试图把方括号中的参数与角色候选者的签名相匹配。类似于我们如何列出 &foo 的候选者,我们也可以列出 R 的候选者。

``raku say R.^candidates.map(*.^name).join(", “); # R, R


唯一不同的是,这次我们使用了一个元方法 `.^candidates`。

在这一点上,还有一个谜团没有被揭开。还记得使用 `.^lookup` 的那个例子吗?为什么它不能找到这个方法?

`Perl6::Metamodel::ParametricRoleGroupHOW` 所支持的类型对象并不是一个我们可以实际使用的角色。它既没有方法也没有属性。然而,在某些情况下,我们可能希望它假装是一个成熟的角色。为了做到这一点,它选择了一个候选角色作为默认角色,然后在其上重新分配外部请求。当有一个没有签名的候选者时(如我们的 `role R {}`),它就成为隐式默认。否则,第一个声明的有签名的候选者就会成为默认的候选者。

回到我们的例子,`R.^lookup('foo')` 失败了,因为 `role R {}` 没有声明一个有这个名字的方法。

### 第二步: 候选者

直奔主题,让我们对候选者本身进行自省。

```raku
say R.^candidates.map({ .HOW.^name }).join(", ");

这看起来一定很熟悉,只是我们加入了.HOW的调用。下面是我们用它得到的东西。

Perl6::Metamodel::ParametricRoleHOW, Perl6::Metamodel::ParametricRoleHOW

它看起来也很熟悉,除了……是的,在类名称中没有 Group,我想欢迎我们的第二种角色! 实际上,我们已经知道了。如果我像这样挥舞着我的手,让我的观众分心,用…

哎呀,最后一句话应该是落在另一个窗口里的! 对于你,我的观众,我还有一行代码。

role R {}.HOW.^name.say; # Perl6::Metamodel::ParametricRoleHOW

BTW,这是一个很好的例子,说明无处不在的 Raku 概念,即所有东西都是一个对象。甚至声明也是;而且,只是为了好玩。

{ say "foo" }.^name.say; # Block

但是我分心了…

所以,这里真正重要的是,当我们声明一个角色时,Raku 为我们创建一个 Perl6::Metamodel::ParametricRoleHOW 类的实例。每个声明都有一个独特的类的实例支持,它负责持有角色类型对象的每个细节。例如,要想知道它是否可以被参数化,可以这样做。

sub is_parameterized(Mu \r --> Bool) {
    ? r.^signatured
}
say is_parameterized(role R[::T] {}); # True
say is_parameterized(role R {}); # False

请注意,由于 signatured 是在 NQP 中实现的方法,它不知道高级类型,并返回0或1。有时情况会变得更糟糕。我上面提到的查找元方法实际上返回 nqp::null(),这是一种 VM 级的对象。它决不能出现在 Raku 上。因此语言把它变成了 Mu,这是最基本的 Raku 类。

关于 Perl6::Metamodel::ParametricRoleHOW,在这一点上没有什么可说的。但我们稍后会回到它上面去。

第三步: 不确定性 #

为了更接近我们的第三种角色,我们从下面这个片段开始。

role R1[::T Stringy, ::V] { method foo { "stringy" } }
role R1[::T Numeric, ::V] { method foo { "numeric" } }
my \r = role R2[::T] does R1[Int, T] { }

让我们自省一下 R1

# We know there is only one role, 
# hence .head for prettier output
say r.^roles.map({ .^name ~ " of " ~ .HOW.^name }).head;
# R1[Int,T] of Perl6::Metamodel::CurriedRoleHOW

输出显示了两个明显的变化。首先,角色名称现在报告了它的参数。第二,元对象现在属于 Perl6::Metamodel::CurriedRoleHOW 类。这是 Rakudo 在幕后做的另一种"魔法”,我将在本节中披露。

在上面的例子中,R2 声明最引人注目的特点是什么?事实上,在它消耗 R1 的地方,我们只知道第一个参数,而第二个参数仍然是一个通用参数。为了表示这种情况,我们对角色的了解是不完整的,Rakudo 使用了柯里化的角色。

从起源的角度来看,柯里化角色与前两种角色的关键区别在于,没有办法声明一个角色。柯里化只能是一个组的参数化的结果。而且,实际上,我很清楚,正式的组在 Raku 语法中并没有表示。但是只要它作为第一个角色声明的结果出现,我们就可以说它是由它产生的。而柯里化则完全由参数化产生。

也许有些令人惊讶,但在所有参数都为编译器所熟知的情况下,也可以发现柯里化的存在。

say R2[Str].HOW.^name; # Perl6::Metamodel::CurriedRoleHOW

部分原因是,当我们使用这样的角色时,我们所需要的也许是一些内省、类型检查或任何其他不需要具体对象的操作。比如说。

sub foo(R1[Int, Str] $a) {...}

我们在这里需要的是 foo 参数,以通过对 R1[Int, Str] 的类型检查。因为柯里化会帮我们完成这个工作,所以 Rakudo 在这里使用它。

say &foo.signature.params[0].type.HOW.^name;
# Perl6::Metamodel::CurriedRoleHOW

这是因为:

say R2[Str] ~~ R1[Int, Str]; # True
say R2[Int] ~~ R1[Int, Str]; # False

但还有一个主要原因。它将在下一节中披露。

第4步: 具体性 #

任何角色的命运都是被一个类所吞噬。(BTW,这里的双关语也不例外。)现在是时候考虑这最后阶段了。

role R1[::T] { }
role R2[::T] { }
role R3 { }
class C does R1[Int] does R2[Str] does R3 { }

通过反省类,我们会遇到所有的老朋友。

say C.^roles
     .map({ .^name ~ " of " ~ .HOW.^name })
     .join("\n");
# R3 of Perl6::Metamodel::ParametricRoleGroupHOW
# R2[Str] of Perl6::Metamodel::CurriedRoleHOW
# R1[Int] of Perl6::Metamodel::CurriedRoleHOW

有趣的是,我们在这里发现了不同种类的角色的混合。其原因是浮在上面的:与 R3 相反,另外两个角色是参数化的。

但由于我喜欢迷惑听众,所以我要告诉你:这些其实不是这个类所建立的角色!这是另一个操作。

当然,这是另一种操作。完整的短语必须是使用这个。“不是直接使用的角色”。

当我们尝试另一种方法时,情况就会大不相同。

say C.^mro(:roles)
     .map({ .^name ~ " of " ~ .HOW.^name })
     .join("\n");
# C of Perl6::Metamodel::ClassHOW
# R3 of Perl6::Metamodel::ConcreteRoleHOW
# R2 of Perl6::Metamodel::ConcreteRoleHOW
# R1 of Perl6::Metamodel::ConcreteRoleHOW

.^roles.^mro 的区别在于,前者为我们提供了用于声明类的内容;而后者则为我们提供了实际构建的内容。

正如 HOW 类的名字所暗示的,我们现在处理的是角色的具体表现。换句话说,这是一种所有细节都知道的角色,它们是为这个特定的类专门设计的。这里强调的是目的:这个过程被称为特化;而 specialize 是实现它的元模型方法的名称。

我还想提醒你一下上一节的最后一句话。为什么每当人们使用 R[Int] 或类似形式的角色参数化时,他们都要处理一个柯里化的角色,原因是完全的特化需要角色被消耗的类。稍后我将说明原因。

我们现在可以退一步,概述一下角色的生命周期。

  1. 一个 Perl6::Metamodel::ParametricRoleGroupHOW 被创建。
  2. 一个 Perl6::Metamodel::ParametricRoleHOW 被创建并添加到该组。
  3. 一个类被声明并 does 这个角色。编译器尝试对角色进行参数化,如果需要参数化,Perl6::Metamodel::CurriedRoleHOW 就会被创建;否则就会使用原来的 Perl6::Metamodel::ParametricRoleHOW
  4. 参数化的结果被添加到类的角色列表中。
  5. 当类被组成时,所有在上一步中添加的角色都被赋予了各自的参数和类的类型对象。在这一点上,我们得到了由 Perl6::Metamodel::ConcreteRoleHOW 支持的角色类型对象,或者换句话说,角色具体化。
  6. 这些具体化被添加到类中。
  7. 具体化是通过将其属性和方法迁移到类的类型对象中来应用的。

值得注意的是,具体化被保留为独立的实体,与它们所产生的角色分开。这就是我们在上面通过使用 .^roles.^mro 进行内省观察到的。它们也可以使用 .^concretizations 元模型方法来访问。

say C.^concretizations
     .map({ .^name ~ " of " ~ .HOW.^name })
     .join("\n");
# R3 of Perl6::Metamodel::ConcreteRoleHOW
# R2 of Perl6::Metamodel::ConcreteRoleHOW
# R1 of Perl6::Metamodel::ConcreteRoleHOW

在这一点上,有两个相当大的主题仍然故意不清楚:一个角色候选人是如何被选择的? 以及特化是做什么的?第一个问题我也许可以或多或少地完整地介绍一下。第二个问题对本文来说太复杂了,但有几个关键点绝对值得一提。

第1a步: 选择 #

震惊一个无辜的读者在媒体中非常流行。虽然我勉强算是个记者,但只要我把这篇文章称为文章–我有什么资格打破规则?所以,请坐好,握紧你的大脑。

我们开始了……准备好了没有……真相即将揭晓!"。

角色是一种例程。

很好,开始了。我说了!我一直想说的!

说真的,正如经常发现的关于点击率的新闻一样,这并不完全是真的,但有一点是真的。我想让你考虑一个例子。

role R {
    say "inside the role";
}
module Foo {
    say "inside Foo";
}
class C {
    say "inside the class";
}
# inside Foo
# inside the class

我们只看到两行输出,让我们了解到类的声明与模块的行为是一样的。但不是角色。让我们在这个例子中再加一行。

R.^candidates[0].^body_block.(C);
# inside the role

为什么是这样,为什么我把 C 作为一个参数传递,我将在下面关于特化的部分尝试回答。

现在我建议对主体块进行内省,但首先要在上述片段中增加一个角色的变体。

my \r = role R[::T, ::V Numeric] { }
say r.^body_block.raku;
# multi sub (::$?CLASS ::::?CLASS Mu $, ::T Mu $, ::V Numeric $) { #`(Sub|94052949943024) ... }

现在有印象了吗?sub 前面的 multi 这个词说明了一切,我现在的工作已经减少到所需的最小的措辞。

当编译器建立一个角色组时,它也创建了一个多调度例程。在内部,它被称为选择器。在每一个新添加的参数化角色中,它的主体块(实际上是一个 multi sub)都会被抽取出来,并作为一个多重调度候选程序添加到选择器中。现在,当人们在他们的代码中写下像 R[Int, Str] 这样的东西时,编译器会做一个类似于选择多重调度例程候选者的过程。基于进程提供的主体块候选,它选择了该块所属的角色。

所以,现在当我们提到角色签名时,一定会有更多的意义。因为它是一个签名,作为一个事实。如果我把一个角色声明 role R[::T, ::V] {} 以某种更适合人类程序员阅读的方式重新表述,它可能看起来像。

声明一个候选角色 R,其主体为块sub (::T, ::V) {...}

很好,我们现在已经解决了这个问题。但是,我细心的读者,是不是有什么事情困扰着你?上面的"类似"一词是不是意味着某种……呃……惊喜?好吧,不幸的是,候选的选择并不遵循完整的多重调度协议,因为它缺乏对命名参数的支持。这是由于类型参数化的低级实现的限制。这意味着下面的两个声明被认为是相同的。

role R[Int, Bool :$foo] {...}
role R[Int, Str:D :$bar] {...}

希望当新的分派机制到达 Raku 时,情况会有所改变,但我在此不做任何承诺。

与此同时,你仍然可以使用这些名字,只是不要依靠它们来唯一地识别你的角色候选人。

一个黑魔法塞恩斯 #

这其实与选择候选者无关,但我不能忍受不给你看一些棘手的东西。此外,在许多小说和童话故事中,黑魔法是一种可以让你实现目标的东西,但有一个附加的价格标签。有时这个标签是相当血腥的,但这不是我的情况。实际上,我的目标和代价是一样的:我想用不同的东西来吸引你。

这就是要施的法术。

use nqp;
my \r = role R[::T, ::V Numeric] { }
class C { }
my \tenv = r.^body_block().(C, Str, Int);
my \ctx = nqp::atpos(tenv, 1);
my \iter = nqp::iterator(ctx);
while iter {
    my \elem = nqp::shift(iter);
    say nqp::iterkey_s(elem), " => ", nqp::iterval(elem);
}

只要主体块是一个例程,显然我们可以自己调用它。为了理解剩下的几行和所有使用的 nqp:: ops,人们需要参考 NQP ops 文档。

总之,“咒语"产生的输出可能看起来像这样。

::?CLASS => (C)
$?ROLE => (R)
T => (Str)
$?CONCRETIZATION => (Mu)
$?PACKAGE => (R)
::?PACKAGE => (R)
V => (Int)
::?ROLE => (R)
$?CLASS => (C)
$_ => (Mu)

用两个字来说,角色体块返回一个包含两个元素的数组。第二个元素是符号名称到其具体值的映射。也就是说,在 => 箭头左侧的键中,你可以很容易地从角色签名中发现我们的 TV 类型捕获;以及编译器常量,如 ::?CLASS 和其他。

总的来说,代码返回的东西被称为内部类型环境,并被用于另一种广泛采用的机制,即泛型实例化。但这个话题肯定远远超出了本文的目的。这里确实值得一提的是,所有包含在环境中的符号实际上都是角色体词法。例如,如果我们把我的 FOO = 42 加入到主体中,那么上面的输出就会有以下一行加入到其中。

FOO => 42

另外,看着这些符号,你现在甚至可以更好地理解为什么角色的特化需要一个类来消耗它。你下次在做类似的事情时可能会考虑到这一点。

method foo(::?CLASS:D: |) {...}

最后我想指出的一点是 $?CONCRETIZATION 符号,它还没有被记录下来。它只在角色主体和角色方法中可用,并且当它可用时被绑定到角色的具体化中。这个符号主要是用于自省的目的。

步骤4a: 特化 #

所以,我们有一个候选者。我们知道具体的参数。我们知道消耗它的类。因此,我们确实知道了一切,可以进行特化,并得到具体化,最终将这个角色纳入消费它的类中。

正如我在上面已经提到的,特化是一个相当复杂的过程。在 Rakudo 元模型的实现中,它分布在几个源文件中,并涉及到一些其他的内部机制,如通用实例化,我在上面也暗示过。我最好不要深究其中的细节,而是专注于主要的阶段。那些真正好奇的人可以从 Rakudo 编译器源文件 src/Perl6/Metamodel/ParametricRoleHOW.nqp 中的方法特化开始。

特化一个新的角色,首先要创建一个 Perl6::Metamodel::ConcreteRoleHOW 的新实例和相应的具体角色类型对象。然后调用 body block 来获得一个类型环境结构。我将重点介绍一下这个。像往常一样,我们先举一个例子。

role R {
    say "inside the role, class is ", ::?CLASS.^name;
    say "class is composed? ", ::?CLASS.^is_composed ?? "yes" !! "no";
}
class C1 does R { }
class C2 does R { }
# inside the role, class is C1
# class is composed? no
# inside the role, class is C2
# class is composed? no

我们在这里观察到的是,角色主体已经被调用了两次,它知道它所应用的类,而且这个类还没有被组成(我在另一篇文章中介绍了一些关于类生命周期的信息)。另外,正如我已经提到的,具体化在这一点上存在。

role R {
	say $?CONCRETIZATION.^name; # R
}

但它还是空的。

say $?CONCRETIZATION.^attributes.elems; # 0
say $?CONCRETIZATION.^methods.elems;    # 0

而且,很明显,没有组成。

say $?CONCRETIZATION.^is_composed ?? "yes" !! "no"; # no

所有这些使得角色体成为一个好地方,可以在角色被实际消费时做需要做的事情。

现在,有了所有必要的信息,元模型通过实例化原始参数化或柯里化角色的属性和方法,并将它们安装到新创建的具体化中来最终完成专业化。例如这个片段的例子。

role R[::T] { has T $.attr }
class C R[Str] { }

如果我们转储原始角色和具体化的属性,我们可能会看到类似于下面的输出。

role attr: (Attribute|94613946040184 T $!attr)
concretization attr: (Attribute|94613946043184 Str $!attr)

当用属性和方法完成时,任何被消耗的角色都会被实例化和具体化。例如,对于这个声明。

role R1[::T, ::V] does R2[::T] { ... }

R2 的具体化将被添加到 R1 的具体化中,然后 R2 的具体化将被添加到 R1 的具体化中。

最后,如果有任何父类添加到角色中,它们也会被实例化和添加。

当所有上述准备工作完成后,我们的具体化就被组成了。它现在已经准备好被添加到它的消费类中。

故事就这样结束了。

偿还债务 #

知道很久以前的承诺最终得到了兑现,真的让人松了一口气。不幸的是,为了涵盖这个主题,我已经跳过了其他一些更基本的主题。例如,让读者更好地了解多重调度、类型对象的组成,以及 Rakudo、NQP 和后端虚拟机是如何相互作用的,这对读者是有益的。如果我写了足够多的文章,并考虑把这些材料编成一本书,那么由这段文字组成的章节将被放在离书的开头更远的地方。

不管怎么说,我已经尽了最大的努力,远离那些还未被提及的概念,希望你在这里找到有用的信息。

原文链接: https://vrurg.github.io/arfb-publication/07-roles-or-when-one-is-many/