Wait the light to fall

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-adjectivena-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 是函数式的,letif 是表达式,所以在 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 时,如果参数错误(或没有参数),则调用 USAGEMAIN 的参数以 : 为前缀,是标志。unit sub 意味着在这个声明之后的任何内容都是主程序的一部分,所以不需要 {...}

总结 #

这篇文章展示了懒惰的程序员创建自己的编程语言的方法: 让 Raku 做所有艰苦的工作。

或者用一个 Haku 程序来表达它:

 本真とは  コンパイラを書いて、  プログラムを書いて、  プログラムを走らす  と言う事です。

the truth: write the compiler, write the program, run the program.