Haku a Japanese Programming Language
— 焉知非鱼Haku a Japanese Programming Language
Haku: 一种日语编程语言
Haku 是一种基于文学性日语的自然语言功能编程语言。这篇文章是关于 Haku 在 Raku 中的实现。你不需要懂日语或阅读 Haku 的文档。如果你不熟悉 Raku,你可能想阅读我的快速介绍。
我确实假定你对解析、语法树和代码生成的概念很熟悉。如果你发现你对下面的内容缺乏背景,我推荐 Andrew Shitov 的系列文章《用 Raku 创建编译器》,它采取了一个逐步的方法。
Haku #
Haku 的目标是接近书面日语,因此它是由汉字、平假名和片假名这三种日语书写系统以及日语标点符号组合而成的。没有空格,Haku 不使用阿拉伯(甚至是罗马)数字,也不使用任何运算符。该语言的设计在文档中得到了更详细的解释。
下面是一个小的 Haku 程序的例子(更多的例子请看 repo)。
本とは
「魄から楽まで」を見せる
の事です。
这句话翻译过来就是:
“main is: to show ‘From Haku to Raku’”
而 Raku 版本是这样的:
say 'From Haku to Raku';
字符串"本とは “和 “の事です"表示主程序的开始和结束。“魄から楽まで “是一个字符串常数。“见せる “是打印函数。‘‘を’表示前面的东西是函数的一个参数。示例代码中的换行符是可选的,纯粹是为了方便阅读。Haku 程序是一个没有空白或换行的单一字符串。
这个例子的实际生成的 Raku 代码是这样的:
use v6;
use HakuPrelude;
sub main() {
show('魄から楽まで')
}
main();
为了更接近文学性的日语,Haku 程序可以从右到左竖着写。
の 忘 本 の 条 条 遠 の 物 忘
事 れ と こ を で い こ で れ
で か は と 見 と と 空 る
す け 記 で せ は で と
。 て 憶 す る す は
た は 。 。 g
遠 無
い 、
記
憶
为这个 Haku 程序生成的 Raku 代码同样非常简单。
use v6;
use HakuPrelude;
sub wasureru( \mono) {[]}
sub tooi( \jou) {show(jou)}
sub hon() {
my \kioku = Nil;
wasureru(tooi(kioku))
}
hon();
Haku 是用 Raku 实现的。Haku 编译器是一个源到源的编译器(有时称为转译器),它从 Haku 源生成 Raku 源并执行它。Raku 在许多方面使编写这样的编译器变得容易。
Parsing using Grammars #
我决定用 Raku 来实现 Haku,主要是因为我想使用 Raku 的 Grammar 功能,而且它没有让我失望。Grammar 就像一个类,但它没有方法,而是有 rule 或 token,它们是解析器的构建块。任何 token 都可以在另一个 token 的定义中使用,例如用 <...>
将其括起来。
token adjective {
<i-adjective> | <na-adjective>
}
i-adjective
和 na-adjective
已经被分别定义,adjective
与其中一个相匹配。
我一直喜欢解析器组合器(如 Haskell 中的 Parsec),从某种角度看,Raku 的 grammar 也很相似。它们都是无扫描器,即没有单独的 token 化步骤,而且高度可组合。Parsec 提供的许多功能(如 many
, oneOf
, sepBy
)都可以通过 Raku 的 regex 来实现。
Raku 的 grammar 有几个特点,有助于使 Haku 的解析器易于实现。
Excellent Unicode support #
我认为 Raku 的 Unicode 支持真的很好。例如,由于对 Unicode 块的支持,我可以简单地写出:
token kanji {
<:Block('CJK Unified Ideographs')>
}
而不是把它们全部列举出来(该块中有 92,865 个汉字!)。事实上, <:...>
语法适用于任何 Unicode 属性,而不仅仅是 Blocks。
甚至更好。我有一些汉字被保留为关键词。
token reserved-kanji { '本' | '事' | ... }
为了确保这些被排除在 Haku 的有效汉字之外,我可以简单地使用一个差集。
token kanji {
<:Block('CJK Unified Ideographs') - reserved-kanji >
}
(有一个细节让我感到不安的是,用户定义的字符类的等效语法需要一个显式的 ‘+’: token set-difference { < +set1 - set2> }
)
Tokens 和 rules #
幸运的是,Raku 默认不会假设你想解析可以忽略空白的东西,或者你想在空白处进行标记。如果你想忽略空白,你可以使用 rule
。但是在 Haku 中,不允许不相干的空白(除了某些位置的换行)。所以我在任何地方都使用 token
。(还有 regex
,可以回溯。在 Haku 的语法中我不需要它)。)
Very powerful regexes #
作为一个 lambdacamel,我一直很喜欢 Perl 的 regexes,现在无处不在的 PCREs。然而,Raku 的 regexes 在功能、表现力和可读性方面远远超过了它。
首先,它们是可组合的: 你可以用 regex
类型定义一个命名的 regex,然后用 <...>
语法在随后的 regex 中使用它。另外,设计时的谨慎使它们非常容易使用。例如,一个否定向前查看断言只是 <no> <!before <koto> >
;而尝试顺序交替(||
)和最长令牌匹配交替(|
)的可用性是一个巨大的好处。我非常喜欢的另一件事是使一个字符类不被捕获的能力。
token lambda-expression {
<.aru> <variable-list> <.de> <expression>
}
只有 <variable-list>
和 <expression>
会被捕获,所以很多具体的语法可以在解析时被删除。
通过角色组合 Grammar #
Roles (Ruby 中的 ‘mixins’, Rust 中的 ’traits’) 定义或实现这些接口。 我发现这比同样支持的类继承更适合我的目的。比如说:
role Nouns does Characters {
token sa { 'さ' }
token ki { 'き' }
# 一線 is OK, 一 is not OK, 線 is OK
token noun {
<number-kanji>? <non-number-kanji> <kanji>*
[<sa>|<ki>]?
}
}
role Identifiers
does Verbs
does Nouns
does Adjectives
does Variables
{
token nominaliser {
| <no> <!before <koto> >
| <koto> <!before <desu> >
}
# Identifiers are variables,
# noun-style, verb-style
# and adjective-style function names
token identifier {
| <variable>
| <verb> <nominaliser>?
| <noun> <.sura>?
| <adjective>
}
}
(虽然我希望有一个列表的语法,像 role Identifiers does Verbs, Nouns, Adjectives, Variables {...}
这样的语法。)
Grammar 和 regexes 的内容还有很多。Twitter 上好心的 Raku 朋友向我推荐了 Moritz Lenz 写的 Parsing with Perl 6 Regexes and Grammars 一书,这本书特别是在调试 grammar 和处理错误信息方面非常有用。
使用了角色的抽象语法树 #
我喜欢把抽象语法树(AST)作为代数数据类型来实现,就像在 Haskell 中通常采用的方式。在 Raku 中,一种方法是使用参数化的角色,正如我在之前的文章中解释的那样。大部分的 AST 直接映射到我的 grammar 中每个角色的顶层解析器,例如 lambda 表达式:
role LambdaExpr[@lambda-args, $expr] does HakuExpr {
has Variable @.args = @lambda-args;
has HakuExpr $.expr = $expr;
}
从解析树到抽象句法树 #
Raku 的 grammar 提供了一个非常方便的机制来把解析树变成 AST,那就是 Actions。也就是说,你创建一个类,类中方法的名称与 grammar 中的 token 或 rule 相同。每个方法都获得由 token 创建的 Match 对象($/) 作为位置参数。
例如,从解析树中为 lambda 表达式填充 AST 节点:
method lambda-expression($/) {
my @args = $<variable-list>.made;
my $expr = $<expression>.made;
make LambdaExpr[@args,$expr].new;
}
在 lambda-expression
token 中使用的捕获 token 可以通过符号 $<...>
来访问,这是 $/<...>
的缩写,也就是说,它们是当前匹配对象的命名属性。
在 Haku grammar 中,有几个 token,其匹配对象是从一个备选列表中选择的,例如 expression
token,它列举了任何在 Haku 中属于表达式的东西。对于这种 token,我使用以下代码从组成的 token 中"继承”:
method expression($/) {
make $/.values[0].made;
}
因为每个匹配都是一个 map,其键是捕获的 token 的名称,而且在这种情况下我们知道,只有一个 token 被选中,我们知道相应的 values
列表中的第一个元素将是那个特定 token 的匹配。
代码生成 #
haku.raku
主程序基本上是做这样做的:
my $hon_parse =
Haku.parse($program_str, :actions(HakuActions));
my $hon_raku_code =
ppHakuProgram($hon_parse.made);
使用 Haku grammar 对 Haku 程序字符串进行解析,并使用相应的 HakuActions 类中定义的方法来填充 AST。顶层的解析树节点必须是 $<haku-program>
,这个节点的 made
方法返回 AST 节点 HakuProgram
。例程 ppHakuProgram
是模块 Raku
中的顶层例程,它是 Haku 的 Raku 发射器。(在模块 Scheme
中也有一个 Scheme 发射器)。
所以 ppHakuProgram($hon_parse.made)
漂亮地打印出了 HakuProgram 的 AST 节点,从而将整个 Haku 程序变成 Raku 代码。
我喜欢基于角色的 AST 的原因是,你可以使用 given/when
对一个类型的变体进行模式匹配。
sub ppHakuExpr(\h) {
given h {
when BindExpr { ... }
when FunctionApplyExpr { ... }
when ListExpr { ... }
when MapExpr { ... }
when IfExpr { ... }
when LetExpr { ... }
when LambdaExpr { ... }
...
default {
die "TODO:" ~ h.raku;
}
}
}
对应于 Haku AST 的 Raku 代码是非常直接的,但有几件事值得注意。
- 因为 Haku 的变量是不可变的,我使用了
\
符号,这意味着我不必用符号建立一个变量表。 - 因为 Haku 是函数式的,
let
和if
是表达式,所以在 Raku 中我把它们包在do {}
块中。 - 对于部分应用,我使用
.assuming()
。 - 在 Haku 中,字符串是列表。在 Raku 中它们不是。我创建了一个小型的 Prelude 函数,在该 Prelude 中的列表操作函数使用
given/when
对类型进行模式匹配,看参数是字符串还是列表。
运行生成的 Raku 代码 #
运行生成的 Raku 代码很简单。我把生成的 Raku 代码写到一个模块中并 require
它。生成的代码以调用 hon()
结束,这是 Haku 程序中的主函数,所以这将自动执行程序:
# Write the parsed program to a module
my $fh = 'Hon.rakumod'.IO.open: :w;
$fh.put: $hon_raku_code;
$fh.close;
# Require the module. This will execute the program
require Hon;
Haku 让其他事情变得非常简单,就是创建命令行标志并记录它们的用法:
sub USAGE() {
print Q:to/EOH/;
Usage: haku <Haku program, written horizontally or vertically, utf-8 text file>
[--tategaki, -t] : do not run the program but print it vertically.
[--miseru, -m] : just print the Raku source code, don't execute.
...
EOH
}
unit sub MAIN(
Str $src_file,
Bool :t($tategaki) = False,
Bool :m($miseru) = False,
...
);
当调用 MAIN
时,如果参数错误(或没有参数),则调用 USAGE
。MAIN
的参数以 :
为前缀,是标志。unit sub
意味着在这个声明之后的任何内容都是主程序的一部分,所以不需要 {...}
。
总结 #
这篇文章展示了懒惰的程序员创建自己的编程语言的方法: 让 Raku 做所有艰苦的工作。
或者用一个 Haku 程序来表达它:
本真とは コンパイラを書いて、 プログラムを書いて、 プログラムを走らす と言う事です。
the truth: write the compiler, write the program, run the program.