Wait the light to fall

itch-scratch

焉知非鱼

在我过去写的一些博客中, 我经常遇到 Raku 不能如我所愿的情况。这是件小事儿, 但是像许多其它的小事儿一样, 它不经常发生但确一直刺激到我。

在我之前写的东西中, 我使用了一些 Raku 代码完美地阐述了这一点。我需要为 @values 数组中的每个值创建一个对象, 如果 @values 为空, 则创建一个特殊的对象:

for @values Z $label,"",* -> ($value, $label) {
    Result.new:
        desc  => "$label ($param.name() = $value)",
        value => timed { $block($value) },
        check => { last if .timing > TIMEOUT }
}
if !@values {
    Result.new:
        desc  => $label,
        value => timed { $block(Empty) }
}

几乎在同一时间, 在其它我所写的代码中(不在博客里的), 我需要完全相同的结构…处理数组中的每个元素, 如果数组中没有元素, 则做一些不同的处理:

for @errors -> $error {
    note $error if DEBUG;
    LAST die X::CompilationFailed.new( :@errors );
}
if !@errors {
    note 'Compilation complete' if DEBUG;
    return $compilation;
}

这只是两个令人惊讶的常见情况的例子:需要在一个列表中迭代…或者如果列表为空,就做一些特殊的事情。换句话说:如果循环不迭代,就做这个代替。

我还可以用其他几种方法来写这些循环。例如,我可以在 for 的前面加上 do,从而将循环转换成一个表达式。然后我可以附加一个 or 和特殊情况代码,这样如果第一个 dofalse,代码就会被执行,如果循环根本不迭代,就会发生这种情况:

do for @values Z $label,"",* -> ($value, $label) {
    Result.new:
        desc  => "$label ($param.name() = $value)",
        value => timed { $block($value) },
        check => { last if .timing > TIMEOUT }
} or
    Result.new:
        desc  => $label,
        value => timed { $block(Empty) };


do for @errors -> $error {
    note $error if DEBUG;
    LAST die X::CompilationFailed.new( :@errors );
} or do {
    note 'Compilation complete' if DEBUG;
    return $compilation;
}

它确实有效。并且它消除了 @values@errors 的重复测试, 但是它除了使代码的可读性变差之外, 还不够美观。

我可以通过把 “if-there-were-no-iterable-values” 测试提到顶部来提高程序的可读性, 像这样:

if @values {
    for @values Z $label,"",* -> ($value, $label) {
        Result.new:
            desc  => "$label ($param.name() = $value)",
            value => timed { $block($value) },
            check => { last if .timing > TIMEOUT }
    }
}
else {
    Result.new:
        desc  => $label,
        value => timed { $block(Empty) }
}


if @errors {
    for @errors -> $error {
        note $error if DEBUG;
        LAST die X::CompilationFailed.new( :@errors );
    }
}
else {
    note 'Compilation complete' if DEBUG;
    return $compilation;
}

…但这只是强调了需要在前两行中两次测试迭代数组的状态的荒谬性。

然而,它确实提出了一个更简洁的解决方案:一个消除重复的解决方案,并最大化可读性。这个解决方案的唯一缺点是在标准的 Raku 中是不可能的。

这个解决方案是:for 循环应该能够有一个 else 块!

当前面的 ifwhen 块不执行时,一个 else 块就会执行。同样地,应该可以在 for 循环中附加一个 else 块,这样当前面的循环块不执行时,else 块就会执行。

如果在 Raku 中可以做到这一点,那么我的两段代码就可以简化为:

for @values Z $label,"",* -> ($value, $label) {
    Result.new:
        desc  => "$label ($param.name() = $value)",
        value => timed { $block($value) },
        check => { last if .timing > TIMEOUT }
}
else {
    Result.new:
        desc  => $label,
        value => timed { $block(Empty) }
}


for @errors -> $error {
    note $error if DEBUG;
    LAST die X::CompilationFailed.new( :@errors );
}
else {
    note 'Compilation complete' if DEBUG;
    return $compilation;
}

虽然 for ... else 不是合法的 Raku 语法(或语义), 但那只是个非常小的问题。

但是, 就像平时那样, 这在 Raku 中就不算事儿。

为了解决这个问题, 我们仅需要重新定义 for 关键字……

为了替换 for 的标准定义,我们需要告诉编译器两件事:新的定义是什么样的,以及它如何工作。换句话说,我们需要定义如何识别新的 for 语法,以及如何将新的语法转换成编译器可以优化和执行的操作码的"抽象语法树"。当然,我们还需要告诉编译器使用这些新组件而不是标准组件。

在 Raku 中,我们用来解释代码的任何部分的语法和语义被称为"子语言",简称"slang",一个典型的 Raku 程序由许多方言编织而成:主 Raku 子语言、Pod 文档子语言、字符串子语言、regex 子语言等。实现这些不同的活动子语言的对象可以通过一个编译时变量:$*LANG 获得。

在这个例子中,我们需要增强主乐俚语,所以我们为扩展的 for 语法创建了一个带有新语法规则的角色,然后将该新语法混入现有语法中。同样,我们需要扩展编译器在遇到新语法时采取的动作,所以我们创建第二个角色,指定这些动作,之后将其混入现有的动作中。

新的 grammar 规则的最简单的形式看起来像这样:

# 在可组合角色中封装一个新的 grammar 组件...
role ForElse::Grammar {

    # 替换 'for' 语法...
    rule statement_control:sym<for> {
          <sym>   <xblock(2)>
        [ 'else'  <pblock(0)> ]?
    }
}

我们声明将包含新语法规则的角色 ForElse::Grammar,然后声明规则本身。该规则的名称是: statement_control:sym<for>, 它告诉编译器它是一个语句级的控制结构,由符号 for 引入。在规则的正文中,我们首先匹配该符号 <sym>,然后是一个"表达式块"(<xblock(2)>)。表达式块只是匹配一个非选项表达式的简写,后面是一个非选项块。传入调用 xblock 的 2 告诉子规则,它所匹配的块必须包含某种主题变量(因为 for 循环总是设置一个主题变量,我们不妨在解析源代码时强制执行)。

在解析完 for 组件后,我们现在要允许一个可选的 else,所以我们将其指定为一个字面('else'),之后我们期待一个参数化的块(<pblock(0)>)。零参数告诉子规则,不期望 else 块有一个主题变量。然后我们将整个 else 语法用非捕获括号([...])包裹起来,并使其成为可选的(?)。

请注意,我们不需要为 <xblock><pblock> 指定规则。它们的规则已经在标准的 Raku 语法中定义了,我们最终会在其中添加新的 statement_control:sym<for> 规则。

这条两行规则足以解析每一个有效的 for...else,但它也会成功解析其他几个无效的构造。所以我们接下来加入少量的额外组件来防止这种情况的发生。该规则的扩展版本是这样的。

rule statement_control:sym<for> {
    <sym><.kok> {}
    <.vetPerl5Syntax>
    <xblock(2)> {}
    [ 'else' <elseblock=pblock> ]?
}

rule vetPerl5Syntax {
    [ <?before 'my'? '$'\w+\s+'(' >
        <.typed_panic: 'X::Syntax::P5'> ]?
    [ <?before '(' <.EXPR>? ';' <.EXPR>? ';' <.EXPR>? ')' >
        <.obs('C-style "for (;;)" loop', '"loop (;;)"')> ]?
}

<.kok> 子规则的调用是用来检查,在匹配了最初的 'for' 之后,这三个字符是否真的构成了一个可以的关键字。例如,如果 'for' 后面跟着一个 =>,那么它就不是 for 循环的开始,而是一个对的关键字。同样,如果 'for' 后面紧跟着一个开头的小括号,那么它就不是一个 for 循环的开始,而是对某个名为 &for 的函数的调用。<.kok> 子规则(再次从标准的 Raku 语法中继承)做了各种 lookaheads 来检查这些情况和其他边缘情况,如果发现任何情况就会失败。

调用 <.kok> 后的空括号 ({}) 是为了表示该部分规则中最长标记匹配的结束。这里的问题是,其他人可能会定义其他语句控制符号,想要修改代码,而语法需要知道,如果其中有两个或多个符号匹配,应该选择哪个符号。一般来说,当考虑一个regex或规则中的一组备选方案时,Raku 会选择匹配最长子串的备选方案(而不是像 Perl 5 中那样,选择第一个匹配的备选方案)。这就是所谓的"最长标记匹配"或简称 LTM。

当语法试图在我们的新 for...other 语法和(比如)别人的 for...other 语法之间做出决定时,我们不希望它仅仅因为我们的语法匹配的字符数多而选择我们的语法。我们希望它选择哪种语法更合适。所以我们需要阻止 LTM 评估器考虑整个匹配,而只考虑关键字。有几种方法可以发出 “LTM结束"的信号,但最短和最简单的方法就是在规则中插入一个空代码块(即 {})。这就是我们在这里所做的。

规则的下一个补充是对 <.vetPerl5Syntax> 子规则的调用。

rule vetPerl5Syntax {
    [ <?before 'my'? '$'\w+\s+'(' >
        <.typed_panic: 'X::Syntax::P5'> ]?
    [ <?before '(' <.EXPR>? ';' <.EXPR>? ';' <.EXPR>? ')' >
        <.obs('C-style "for (;;)" loop', '"loop (;;)"')> ]?
}

添加这个调用是因为标准的 Raku 语法总是特别仔细地观察 for 循环,以确保有人没有不小心误用了两种旧的 Perl 5 语法中的一种。如果子规则向前看,发现在 for 循环之后紧接着有一个 my 和/或一个变量,然后开括号(<?before 'my'? '$'\w+ \s+ '(' >),它就会断定它看到的是一个 Perl 5 for,并抛出一个X::Syntax::P5 异常。如果它往前看,发现一对括号,其中包含三个由分号分隔的表达式 (<?before '(' <.EXPR>? ';' <.EXPR>? ';' <.EXPR>? ')' >),它的结论是,它看到的是一个Perl 5 C-style 的 for 循环,并警告用户用一个循环来代替它。

最后,我们修改对 <pblock(0)> 的调用,像这样。<elseblock=.pblock(0)>。这将导致由 <pblock(0)> 调用的任何匹配都被存储在 ’elsblock’ 键下,而不是 ‘pblock’ 键下。这将随后改善我们的 else 处理代码的可读性。

一旦这些额外的检查和平衡到位,我们的 statement_control:sym<for> 规则就可以添加到当前的俚语中了。如果我们这样做,编译器现在就能识别 for...else 构造,但我们不会看到它这样做的有用效果。这是因为我们还没有告诉它如何将新的 for...else 语法转换成可执行的操作码。

为了告诉它这些,我们声明了第二个角色(这样我们以后就可以把它混入现有的编译器动作中)。在这个角色中,我们指定了一个适当名称的方法,然后编译器将在每次使用我们新的 statement_control:sym<for> 规则成功解析时自动调用这个方法。

# Encapsulate new actions for new 'for' syntax...
role ForElse::Actions {
    use nqp;
    use QAST:from;

    # Utility function...
    sub lookup(Mu \match, \key) {
        nqp::atkey(
            nqp::findmethod(match, 'hash')(match),
            key
        ).?ast
    }

    # New actions when a 'for' is parsed...
    method statement_control:sym (Mu $match) {
        my $forloop := callsame;
        if lookup($match, 'elseblock') -> $elseblock {
            match.make:
                QAST::Op.new: :op<unless>, $forloop,
                QAST::Op.new: :op<call>,   $elseblock
        }
    }
}

在我们的新动作角色中,我们要做的第一件事就是加载 nqp 和 QAST 模块的设施。NQP 是 Raku 的 “Not Quite Perl"子集,大部分的 Raku 编译器都是用它编写的。QAST 是 “Quisquous 抽象语法树”,表示操作码和参数,所有的 Raku 代码都是在编译器中被还原的。由于我们正在有效地升级编译器以处理我们的新语法,我们将需要通过 NQP 命令来访问这些语法组件。而且,为了实现我们的新行为,我们需要建立一个合适的 QAST 结构。

首先,我们建立一个简单的实用函数(lookup),它从语法中获取一个模式匹配,并试图从该匹配中检索特定命名子匹配的抽象语法树。请注意,因为这段代码将被插入到编译器中,所以它不能依赖于通常的 Raku 数据结构和访问方法。相反,我们需要使用底层的 NQP 访问函数。在这种情况下,实用程序首先定位提取匹配对象的哈希类成分的函数(nqp::findmethod(match, 'hash')),然后在匹配数据结构((match))上调用该哈希提取函数,然后对产生的哈希进行键查找(nqp::atkey(..., key)),然后尝试检索与该匹配相关的抽象语法树(...?ast)。

一旦我们有了这种从语法匹配中提取特定成分的能力,我们就可以写一个方法,把 for.. else 匹配的各个部分拉出来,并把它们重新排列成一个合适的 QAST 表示。这个方法必须与它所处理的匹配规则同名,所以我们声明:

method statement_control:sym<for> (Mu $match) {...}

该方法将相应语法规则产生的匹配对象作为唯一的参数。我们声明该参数的类型为 Mu(整个 Raku 层次结构的根类型),因为它是一个 NQP 对象,而且 Raku 类型系统不会以其他方式传递它。请注意,我们不能从 $match 参数中省略类型声明,因为那样的话,它将默认为类型为 Any,这在这种情况下就太特殊了。

一旦我们有了 match 对象,为了把解析后的 match 变成合适的 QAST 对象,我们需要做的第一件事就是转换 for 组件。但是标准的 Raku 解析器已经知道如何做到这一点,所以我们可以通过调用 callsame 告诉它回到以前的行为。

那个重发的调用将返回一个代表 for 循环的 QAST 对象,并且还将安装同一个 QAST 对象作为 $match 对象的新抽象语法树。由于我们可能需要覆盖该行为(如果涉及到一个 else),我们保留 for 循环的 QAST 对象,通过将其别名为 $forloop

my $forloop := callsame;

然后我们需要发现解析器是否真的找到了一个 else 块,我们通过使用 $match object(lookup($match, 'elseblock')) 来寻找一个名为 ’elseblock’ 的捕获。如果在 for 之后有一个 else,我们需要建立一个 QAST 结构,只有在 for 循环没有执行的情况下才执行 else 块。用伪代码来说,就是:

unless forloop
  call elseblock

而在QAST中也完全一样:

QAST::Op.new: :op<unless>, $forloop,
QAST::Op.new: :op<call>,   $elseblock

也就是说,我们建立一个新的 QAST unless 操作(QAST::Op.new::op<unless>),并传递给它两个必要的操作数。

一个 QAST 对象,代表要测试的条件。

一个 QAST 对象,表示如果该条件为 false,该怎么做。

在这种情况下,第一个参数(条件)是我们从 callsame 得到的 QAST 对象;实现整个 for 循环的 QAST 对象(即$forloop)。 第二个参数(要做什么)是一个新的QAST对象,实现了对 else 块的调用(即 QAST::Op.new::op<call>, $elseblock)。

一旦我们建立了那个 QAST 结构,我们只需将它安装为原始匹配的抽象语法树($match.make:...)。

就这样了。当扩展语法中新的 statement_control:syn<for> 规则成功匹配时,编译器将调用扩展动作中等价的 statement_control:syn<for> 方法,将解析后的语法转换为实现扩展行为的 QAST。

当然,前提是我们真的扩展了语法和它的动作。这一点我们还没有做到。

但是,就像 Raku 中的大多数事情一样,实际扩展语法和它的行为并不难做到。我们的目标是拥有一个模块(我们称它为: Slang::ForElse),它可以将我们的新方言安装到任何使用它的词法范围内。

{
    use Slang::ForElse;

    for @values {
        .say;
    }
    else {
        say 'No values';
    }
}

该模块需要修改 $*LANG 对象来安装一个增强的主语法和相应的扩展动作。具体来说,该模块需要调用 $*LANG 对象的 .define_slang 方法,向其传递要修改的俚语的名称(在本例中:“MAIN” 方言),以及要为该方言安装的新语法和动作。

我们需要在每次使用该模块时调用 .define_slang 方法。所以我们应该把调用放在模块的 EXPORT 子程序中。像这样:

sub EXPORT () {

    $*LANG.define_slang:
        "MAIN",
        $*LANG.slangs<MAIN>         but ForElse::Grammar,
        $*LANG.slangs<MAIN-actions> but ForElse::Actions;

    return hash();
}

这个子程序调用 $*LANG 对象的 .define_slang 方法,要求它更新 “MAIN” 方言的定义。第二个参数是要安装的语法,它只是当前的 “MAIN"语法($*LANG.slangs<MAIN>),但其中混入了新的 for..else 语法(but ForElse::Grammar)。第三个参数是要安装的 actions 对象,它只是当前的 “MAIN-actions” 对象 ($*LANG.slangs<MAIN-actions>),但其中混入了我们新的 for..else actions。

混合在一起 (but ForElse::Actions)。

最后,我们必须确保 EXPORT 子程序返回一个空的哈希值 (hash()),以告诉编译器我们实际上并没有导出任何东西。

这就是我们的工作。在不到 25 行的时间里,我们修改了 Raku 的语法和语义,增加了一个"缺失"的结构。用其他方式扩展或修改语言来挠其他的痒痒,也就不难了。而且因为每一次扩展或修改都是以词汇的方式进行的,通过将新的行为混合到现有的slangs中,这些额外的功能很可能会相互很好地发挥。

例如,我们可以同时加载 Slang::ForElseSlang::SQL,然后写:

use Slang::ForElse;
use Slang::SQL;

sql drop table if exists stuff;

sql create table if not exists stuff (
    id  integer,
    sid varchar(32)
);

for @ids {
    sql insert into stuff (id, sid)
        values (?, ?); with ($_, ('g'..'Z').pick(16).join);
}
else {
    sql insert into stuff (id, sid)
        values (?, ?); with (99, 'default');
}

sql select * from stuff order by id asc; do -> $row {
    "{$row<id>}\t{$row<sid>}".say;
};

定义和部署词法范围的 slangs 的能力使 Raku 具有高度的未来性。任何我们在最初的设计中忘记添加到 Raku 中的东西(比如 for..else!)都可以在以后需要时轻松添加。

而且 slangs 也有可能使 Raku 与其他工具高度互操作。例如,Raku 有可能成为最终的"胶水语言”,通过允许我们切换到那些看起来很像其他编程语言的 slangs,只要这些语言可能更方便编码:

for @values -> \value {
    use Slang::Python;
    from math import floor, sqrt

    def fac(n):
        step = lambda x: 1 + (x<<2) - ((x>>1)<<1)
        maxq = long(floor(sqrt(n)))
        d = 1
        q = n % 2 == 0 and 2 or 3
        while q <= maxq and n % q != 0:
            q = step(d)
            d += 1
        return q <= maxq and [q] + fac(n//q) or [n]

    print(fac(value)))
}
else {
    use Slang::Ruby;
    require 'io/console'
    print "No values. Continue?"

    loop do
        case $stdin.getch
            when "Y" then return
            when "N" then break
            else print "\rNo values. Continue? [YN]"
        end
    end
}

不幸的是,这些特殊的方言模块还不存在,但是 Python 和 Ruby (以及Perl 5和Lua、Scheme、Go和C)的全功能 Raku 接口已经存在,所以为它们中的每一个编写完全集成的方言将只是一个简单的元编程问题。)

Damian