Raku++:长篇阅读

一切的起点

我从 Perl 6 时代就开始关注 Raku 语言了。这些年来,我多次尝试为它编写编译器。但每次尝试都以同样的失败告终。Raku 语言非常复杂——你从 say "Hello" 开始编写代码,然后在一晚上内就要处理语法、接口设计、多重调度机制以及各种复杂的结构。最终我总是得出同样的结论:一个人根本无法完成这么复杂的语言的设计工作。

改变的不是语言。而是今天我们有了一种新的帮手。整个 Raku++的编写过程我都没打一行 C++。我描述了我想要的内容,运行了测试,指出了故障,代码就出现了。人类在其中扮演的角色与旧角色不同——你是导演和评论员,而非打字员——但这是一个真实的角色,项目本身就是证明它有效的论证。

从第一天起,目标就既简单又明确:打造一个快速且实用的 Raku 编译器——一个能够立即启动、并且用户真正会经常使用的工具。这不是一个研究原型,也不是概念验证。而是真正可以运行的软件。实际开发工作大约在仓库首次提交之前一周就开始了,而仓库的首次提交时间定在 2026 年 7 月 2 日;最早的提交内容已经是一个可以正常运行的树遍历解释器,而不是最初的草案阶段。

整个项目背后有一条工作规则——Rakudo 是参考,不是来源——但值得明确说明它是如何诞生的。这并不是我们第一天就宣示的原则。事情就是这样发生的:我们从未需要打开乐土的代码,所以也没打开。直到后来我们才明白为什么这是正确的工作方式,并把它作为规则明确表达出来。我们把 Rakudo 视为行为的北极星——"这真的是 Raku 吗?"的答案——但我们从未移植过结构或复制算法。Raku++ 是洁净室:一个手写词典算法、一个带有 Pratt 表达式核心的递归下降解析器,以及一个树行评估器,全部从无到有。

这种独立性之所以可能,是因为 Raku 拥有可执行的规范。我们反复强调的座右铭——

任何能运行 Roast 的编译器都可以称为 Raku 编译器。

——意味着"正确"从来不是"乐藤消息来源所做的"。有两样东西我们完全不用读过 Rakudo:Roast,官方测试套件(~1,464 个 .t 可运行的规范文件),docs.raku.org,散文。它们之间是独立于任何一个运行时描述的语言。

第一批通过考试,第一批数字

最早的作品是核心 MVP 部分:包括第 2 到第 4 章的内容,再加上 Test 模块,这样才能让 Roast 文件能够正常运行。如果一个 Roast 文件只输出 1..40 ,但得分却是 12/40 ,那它就相当于一个购物清单——它准确地指出了 Raku 接下来期望得到的二十八个任务。而如果一个文件完全不产生任何输出,那它很可能只是差一个解析错误就能够解锁整个任务了。

到 7 月 5 日,这个文件已经足够连贯,可以标记 v0.1.0,并且在 1464 个 Roast 文件中完全超过了 252 个。"完全通过"是严格的门槛:文件中的每一条断言都必须是绿色,否则该文件不计入。这个数字——几百个完整文件——是其他所有东西的基准。

然后循环正式开始,从未真正停止:

7 月 6 日 — Raku 6.e 功能、超切片、 HyperWhatever 、发货和列表正确性:252 → 255。

7 月 7 日——真正的正则表达式引擎上线(递归下降解析器,CPS 回溯匹配器)。这是该项目最大的突破:将第五季的简介从几乎无到有变成了成千上万的断言。组件出口范围关闭于 S19,收于 100%;Unicode 数字文字关闭了 S15 文字部分。档案:275,然后是 276(130,866 / 188,224 条陈述)。

7 月 8 日——持续努力:277、278、279、280,每人提出少量断言,且与之前的传球数据相较,而不仅仅是计票数。

7 月 9 日——S16 输入输出工程将其推进至 291;NativeCall( is native C FFI 到 dlsym ,没有 libffi ),账簿间隙修复推至 300(131,320 / 189,081)。

在这里,我们强迫自己诚实地理解"覆盖"的含义,这值得深思,因为很容易被误解。

这些数字究竟代表了什么含义呢?

文件覆盖率——即有多少整份 Roast 文件能通过所有声明——是最严苛的。在一个 200 断言文件中出现一次零星故障,整个文件就会归零。这个数字在早期大约停留在 17%,现在是~30%(1464 个文件中的 440 个)。这是一个覆盖率数字:套房被完全占领了多少。

每次测试的效率——即套件中宣布的每一次测试通过次数——是"运行正确性"的公平标准。标题是:~82%,大约有 159,000 次~194,000 次申报测试。

我们在 docs/COUNTING.md 中记录的微妙之处在于分母并非固定。"声明"意味着任何文件尝试运行的所有测试,包括那些在输出任何结果前就中止的文件——我们会从源端恢复它们的计划计数,并全部计为失败。编译器越好,文件运行距离越长以声明更多测试,分母随覆盖范围增长。我们的通过计数为~82%,对比我们恢复的分母,但与套房完整申报总分相比仅为~77%。我们选择了这首歌作为头条,如果说有什么不够好看的话。文档里的规则是固定的:报告原始数字,引用两个数字,绝不吹嘘。

每场测试的检测率也在明显的上升。7 月 9 日,公开的真实数字约为 57%。Unicode 整合(见下图)将其提升到了~80.6%。 sprintf 的角落案件让这一比例达到了 80.8%。课程和挑战轮将分数提升到~82%。

技术脊梁

上面的弹药跳过了每个弹药要求的机械设备。有几首曲子难度过高,重要性也过高。

数字之塔。Raku 承诺 0.1 + 0.2 == 0.3 等于 True ,因为其小数部分是精确的分数形式,而非浮点数。这意味着所使用的算术运算都是基于精确小数单位 1e9 进行的操作,每个算术运算的结果都是精确的数值 Rat 。如果一个 Rat 的分母超出了 64 位的范围,那么根据 Raku 的规定,该数值会降为 Num 。这种微妙的处理方式只有在 Mandelbrot 渲染开始出现轻微错误时才会显现出来。

Unicode,如果运用得当,确实可以成为整个项目中最强大的领域。正确的字形字符串(UAX #29)、四种规范化形式(NFC/NFD/NFKC/NFKD)、字符名称与属性——所有这些内容都来自 Unicode 字符数据库,并且已经升级到 UCD 17.0 版本。此外,还有来自 DUCET 17.0 的 UCCollation 功能:所有 8,271 个排序一致性测试都通过了。 .chars 通过计算字形数量而非代码点数量的方式来计算字符数量,这种做法在出现错误之前是难以被发现的。而 Raku 语言正是少数几个坚持正确处理这种问题的语言之一。

正则表达式解析引擎。它不是以某种库为包装层的实现——而是一个完全自制的、递归式的正则表达式解析器,它连接了一个持续传递回溯匹配的机制。正则表达式规则就是基于这个引擎构建的。后来,该引擎还增加了运行时插值功能——在匹配过程中可以设置词汇级别的变量,这些变量可以在之后作为模式原子使用;同时,代码断言也可以基于实时匹配的结果进行验证,从而能够解析实际的 YAML 格式数据。

从解释器到编译器。这个项目一直遵循"今天进行解释,稍后实现后端"的原则。而后端终于实现了。关键在于一种克制:Raku++刻意不实现 Raku 中那些会改变语法的部分——没有自定义的语法规则,也没有解析时的操作符定义。这种克制带来了好处——如果解析后的树结构在运行时无法改变,那么就可以在构建时将其转化为 C++代码。因此, rakupp 有四种方式来运行程序:

rakupp program.raku                       # interpret (default)
rakupp --bundle program.raku -o program   # embed source + interpreter
rakupp --aot    program.raku -o program   # parse ahead, embed the AST
rakupp --exe    program.raku -o program   # transpile to C++, compile native

让 --exe 达到真正的准备状态既是重构也是一项功能:将已编译的子从固定的 C++ 参数迁移到统一的 ValueList 调用约定,因此命名参数、可选、默认值、slurpies 和 multi 调度都原生编译。编译器的验证不是针对 Roast,而是通过奇偶校验:编译程序,运行它,在解释器下运行同一程序,断言相同的输出。解释器是编译器的预言机。 -O 标志会将优化内容传递到生成的二进制文件。

自托管。这是一个令人满意的重要里程碑:负责运行 Roast 的脚本语言 tools/run-roast.raku ,其实是用 Raku 编写的,而该脚本的实现则由 rakupp 负责。这个工具能够测量编译器运行时的性能。

超越烤制:那些规格套件从不测试的部件

到了 7 月中旬,Roast 的每小时产出已经越来越低了——不是因为编译器已经完成了,而是因为 Roast 能够测试语言中的细节。它能够发现各种特性。真正的程序需要运行十几个模块来协同工作,还需要处理数据库驱动、大规模字符串处理等问题——这些问题仅仅是规范套件所无法涵盖的。于是,我们又开辟了另一个方向:运行真正的 Raku 软件,然后修复那些出现问题的部分。

covid.observer——是一个功能强大的 Raku 语言 Web 统计生成工具。它能够编译复杂的继承文档、支持带引用参数的正则表达式、处理带字面量的 multi 参数,还能处理哈希与块式的歧义问题。通过一个小巧的 Raku 脚本与 MySQL 数据库进行交互(该脚本通过 mysql 客户端与数据库进行通信),该工具能够实现模块加载、 use lib 操作、输入操作符、超方法调用等功能,并且拥有足够的对象模型来同时处理十几个 CovidObserver::* 模块。现在,它已经可以端到端运行,并生成真正的 HTML 输出了。

《乐程编程语言全集》——一本书长,~1,500 页——是第二本,内容更为深入。其网站生成器用 Raku 编写,其目录通过 YAMLish 读取,这是一种对缩进敏感的 YAML 语法,几乎同时执行所有高级正则表达式特征。正是为了让这种语法解析成为运行时插值匹配器,就推动了上述的运行时插值匹配器。

这个案例告诉我们,虽然可以通过 Roast 的语法来实现某个功能,但结果可能会出错——只有真正的程序才能揭示这种错误。对数组的数值上下文处理、嵌套的哈希表访问、变量对键的关联——所有这些在 Roast 中都能被"传递",但实际上却会产生错误的输出结果。

这些课程片段:3,068 个小型程序

然后有一个更巧妙的想法。这个课程不仅仅有一个生成器——它是由 Raku 语言构成的。每一页都充满了封装好的代码块,每一个代码块都是一个完整、符合语法的 Raku 程序,由人类编写出来以传授某种知识。这种形式与 Roast 不同:它不是以简洁为主,而是以符合语法规则为特色。

于是,我们从英文页面中提取了每一个被围起来的区块,再加上练习文件。然后,我们将这些区块分别运行在 rakupp 和 Rakudo 这两种引擎下——同时关闭了它们的标准输入,并设置了超时时间。最终进行了 3,068 次对比。那些在 Rakudo 引擎下无法运行的部分(如理论片段、输出样本等)被排除了;同样,那些不确定的结果也被排除了。最终,还剩下 148 个真正的差异点,这些点确实是 rakupp 和 Rakudo 两种引擎之间的分歧所在。

我们分两轮解决了这些问题——包括容器和绑定、列表/序列类型说明、关联 gist、连接 gist、数值强制参数、引号副词、正则表达式以及语法规则等方面的问题。经过这一系列的修复后,与原始规范的偏差数量从 148 个减少到了 14 个。完整的修复记录可以在 docs/dev/COURSE-DIVERGENCES.md 中找到。这些问题中,每一个都是 Roast 团队以及那两个大型项目都没有发现的漏洞,因为在此之前,从未有人以我们测试过的格式编写过这些代码片段。

每周挑战:再增加了 10,428 个程序

如果这门课程可以被看作是一个"作品集",那么"每周挑战"就是其中的"消防水管"——它汇集了多年来参与者们所创造出的解决方案。这些解决方案来自成千上万的真实、多样化的拉库编程作品,这些作品是由不同风格、不同创作手法的人共同完成的。我们已经通过相同的标准对 10,428 个解决方案进行了评估。

实际上,只有大约 6,800 个实现是可行的——其余的都被忽略了,因为 Rakudo 本身无法运行那些存在问题的情况(如缺少参数、模块或输入、超时问题,或者行为不确定的情况)。在那些可行的实现中,初步筛选发现了 2,663 个实现,占到了 39%的比例——这些实现的字节码与 Rakudo 的完全相同。随后又进行了十五轮修复工作,每一轮修复都针对的是那些被问题所暴露的缺陷。

  • MAIN 语义、派遣约束、解析尾处理 → 3,295 个相同。

  • 方法-间隙扫描, Any 单项语义。

  • 子集、字面意义上的返回、 ff 的翻转操作,以及 GLR 之后的 map 。

  • 索引位置、IO 严格性、 tr/// 返回 StrDistance 。

  • 解析簇结构、迭代语义、多切片处理、操作名称调用、修饰符的作用域、 min / max 扁平化处理、 is rw 循环参数、转子对。

  • 堆叠式的 zip/交叉元操作、按元素读取项的索引方式、超链接后缀( @w»[0] 、 »++ ),以及将字符串作为单个项进行索引。

  • 从严格性角度来看,整个流程都遵循了相同的标准:基于绑定失败情况下的退出代码、用户标识 sub USAGE 、命令行接口的各种变体,以及与 Rakudo 生成的用法文本完全一致的 $*USAGE 字节序列。

("Post-GLR"指的是大列表重构,即 2015 年对 Raku 列表、数组和序列的扁平化和容器化方式的重新设计。其最显著的规则是 map 及其好友将每个块的结果作为单一元素保留——只有显式的 Slip 拼接到周围的列表中——且裸逗号列表是不可变的 List s,而 @ -sigil 变量是可变的 Array s。完全匹配这些语义是这里和课程中列表输入修复中反复出现的主题。)

该数值同样上升到了 4,056——占所有相关项目的 60%——并且仍在持续增加。每一批数据在统计之前都会通过零回归检验,因此这两个方面相互强化:通过零回归检验的项目数量从 433 个增加到了 440 个,这些项目都来自同一批数据。每一批数据都是 docs/dev/PWC-DIVERGENCES.md 文件中的一行记录。

账本后期浮现出一个值得保留的杠杆洞见:剩余的错配并不均匀分布。六位高产作者占了其中约一半,因为他们各自在数百种解决方案中重复使用一个个人模板——因此,修复一个重复出现的形状在语料库范围内是逐百个清理文件,而非逐个清理。这就是现在作品的形态。

三方面——Roast、真实项目和两个语料库——的模式是,每个领域都有对方覆盖的盲点。Roast 错过了传球组中没有的部分。真正的项目往往会错过它们不使用的东西。语料库发现成语没有人孤立。运行所有这些,并信任当前指出问题的那一个,才是让工作保持诚实的关键。

为什么目标是达到 100%的准确率?为什么这很难实现呢?

目标是让 100%的 Roast 成功。这个目标是合理的,因为 Roast 本身就是完美的象征——能够传递所有 Roast,才算是真正实现了"完整的 Raku"这一理念。而之所以如此困难,是因为存在结构性上的问题,而非偶然因素:

随着你不断攀登,分母会逐渐增大。如上所述,性能的提升意味着更多的文件能够运行得足够好,从而触发更多的测试需求。因此,你追求的目标会逐渐远离你。最后的那段攀登过程与最初的过程完全不同,更加艰难。

"长尾"指的是那些我们故意推迟处理的语法修改核心——在解析时定义的自定义操作符、SLANG 语法、运行时语法编辑等等。正是这些功能的缺失,才使得 --exe 这样的语法成为可能。而重新引入这些功能,同时不放弃即时编译机制,确实是一个真正的设计问题,并非简单的日常任务所能解决。

一些失败是由于代码中的错误修复导致的,这些修复实际上降低了系统的稳定性。很多时候,数值的变动并非出于预期的原因:某个修复问题后,原本通过测试的测试用例反而出现了问题。诚实的做法是保留这些修复措施,然后重新设定基准,这样图表就不会呈现单调的趋势了。

如今的前沿在于大规模文法:通过动作方法和第二种模式文法,将解析后的 YAML 匹配树转化为数据——一组新的小空白,逐一剥离,这就是这里其他部分的构建方式。

在生产环境中运行它

在这一切之中,某个时候,编译器不再是一种我们需要测试的工具,而变成了一种被我们实际使用的工具。

我一直在校对课程——这是另一个故事——这意味着反复运行它的生成器,数百次。发电机是乐。所以它运行在 Rakupp 上,正在生产环境中,重新生成真实网站。这时,表演作品不再是抽象的。Rakudo 大约 150 毫秒后开始;拉库普大约 12 分钟后开始。单次通关这算不了什么。在编辑-再生-查看循环的第 200 次运行中,这就像是打断你思考的工具和不会打断的工具之间的区别。

在这条管道系统中,有一个方面值得特别提及。该课程使用 Pygments 来格式化代码块。Pygments 是一款基于 Python 的高亮工具,像几乎所有高亮工具一样,它通过词法分析来实现高亮显示——即把单词与预定义的模式进行匹配。这本身并无问题,直到某个类中有名为 role 的方法出现时,Pygments 会将 role 标记为关键字。因为从词法分析的角度来看,很难区分方法名和语言关键字。不过,rakupp 解析器能够正确地区分这两者。 rakupp --highlight 输出的 CSS 类与 Pygments 完全相同——课程的样式表保持不变——但 rake 工具能够更精确地分配这些类,其执行时间从 1002 毫秒缩短到了 1001 毫秒。这确实是一个更可靠的替代方案,它消除了项目对第三方实现的依赖。

然后:如果它在浏览器里运行呢?

这个想法就像那些优秀的创意一样,最初看起来很普通。这门课程教授的是 Raku 语言。课程中充满了可以运行的示例代码。如果读者能够直接在页面上运行这些代码,而不需要任何服务器或往返传输的话,那该多好呢?

这个解释器是一个可移植的 C++程序,没有任何依赖项。这正是编译成 WebAssembly 格式的程序的特征。因此, rakujs/ 能够构建出与 src/ 完全相同的解释器——并非重新实现,而是使用相同的 C++代码,通过 Emscripten 技术将其打包成一个 .wasm 模块,该模块可以在浏览器中完全运行。其语义与原生 rakupp 完全相同,因此也符合 Roast 的验证标准,因为 Roast 使用的是 rakupp 架构。 src/ 中的内容没有任何修改;WebAssembly 的构建过程纯粹是简单的添加操作——只增加了 rakupp_run(src) 、一个构建脚本以及一个自包含的编辑器。

它有自己的限制。Emscripten 的 -fwasm-exceptions 在不同浏览器间仍然不均衡,因此版本默认是经典版 -fexceptions ;解释器依赖 C++的控制流异常(每个 return , next , last ),所以这很重要。浏览器栈比原生的更浅,所以深度递归的限制大约在几百帧左右。WASM 运行在 Web Worker 中,因此界面响应迅速——有实时旋转器、流媒体输出、一个正常工作的停止按钮——整个系统大小为 -Oz 。

结果就是 course.raku.org/playground 的 Playground:一个带有示例程序的编辑器、语法高亮、与课程共享的主题切换器,以及在标签页中运行的 Raku。一台从零开始用 C++编写的 Raku,三周内完成,在没有服务器支持的浏览器中运行。

一种怀旧的味道

在示例中的二十多个程序中,有 mandel.raku ——这个用 ASCII 格式展示的曼德布罗特集合的演示程序。这个演示程序是二十年前由 Parrot 公司发布的。当时,人们会展示一个在终端中爬行的小图案,以此来证明一种新语言是真实存在的。

我把它重新输入了代码。第一次运行的速度很慢——明显比 Rakudo 慢得多。这一点很有帮助:它直接指向了引擎中的一个真正的问题。每次对精确的 Rat 进行算术运算时,都会重新进行分数的简化操作(即使用最大公约数进行归一化),而实际上这些操作是没有必要的。因此,一个执行数百万次 Rat 运算的程序,其性能提升也相当于数百万次的性能提升。去除这种不必要的重复操作——同时让 Rat 的分母超过 64 位时,能够按照 Raku 的规定降级为 Num ——这使得渲染速度大大加快,同时也加快了所有依赖有理数运算的其他操作的速度。同样的数学计算,同样的分形算法,现在却能在瞬间完成。这——比任何表格中的百分比都更重要——正是这个项目值得尝试的原因。它让计算机重新具备了那种在你按下 Enter 键时就能立即响应的速度,就像当年小型计算机时代,程序执行得如此迅速,以至于你根本不需要考虑启动时间的问题。

方法,再来一遍

这些都不是来自愿望清单。它来自一个自第一周以来未曾改变的循环:

找出失败的东西——在 Roast、真实程序、文档、语料库中。从剧本和文笔理解乐的真正意思,而不是乐道内心。做出最微小且正确的改变,而不仅仅是让测试变绿的改变。运行整个套装并调配整套设备。即使计数下降,也要保留正确的修正。把那些不显而易见的事情写下来。

只要耐心运行这个循环,配合一个永远不会厌倦的助手,你就会发现一个干净、无依赖的 Raku 实现能走多远。

到目前为止,答案是:比我三周前想象的还要远。

来源、公告以及完整文档请参阅:github.com/ash/rakupp。

评论