Ohm:用两百行 JavaScript 创造你自己的编程语言

2016-09-25
10分钟阅读时长

解析器是一种超级有用的软件库。从概念上简单的说,它们的实现很有挑战性,并且在计算机科学中经常被认为是黑魔法。在这个系列的博文中,我会向你们展示为什么你不需要成为哈利波特就能够精通解析器这种魔法。但是为了以防万一带上你的魔杖吧!

我们将探索一种叫做 Ohm 的新的开源库,它使得搭建解析器很简单并且易于重用。在这个系列里,我们使用 Ohm 去识别数字,构建一个计算器等等。在这个系列的最后你将已经用不到 200 行的代码发明了一种完整的编程语言。这个强大的工具将让你能够做到一些你可能过去认为不可能的事情。

为什么解析器很困难?

解析器非常有用。在很多时候你可能需要一个解析器。或许有一种你需要处理的新的文件格式,但还没有人为它写了一个库;又或许你发现了一种古老格式的文件,但是已有的解析器不能在你的平台上构建。我已经看到这样的事发生无数次。 Code 在或者不在, Data 就在那里,不增不减。

从根本上来说,解析器很简单:只是把一个数据结构转化成另一个。所以你会不会觉得你要是邓布利多校长就好了?

解析器历来是出奇地难写,所面临的挑战是绝大多数现有的工具都很老,并且需要一定的晦涩难懂的计算机科学知识。如果你在大学里上过编译器课程,那么课本里也许还有从上世纪七十年传下来的技术。幸运的是,解析器技术从那时候起已经提高了很多。

典型的,解析器是通过使用一种叫作 形式语法 formal grammar 的特殊语法来定义你想要解析的东西来创造的,然后你需要把它放入像 BisonYacc 的工具中,这些工具能够产生一堆 C 代码,这些代码你需要修改或者链接到你实际写入的编程语言中。另外的选择是用你更喜欢的语言亲自动手写一个解析器,这很慢且很容易出错,在你能够真正使用它之前还有许多额外的工作。

想像一下,是否你关于你想要解析的东西的语法描述也是解析器?如果你能够只是直接运行这些语法,然后仅在你需要的地方增加一些 挂钩 hook 呢?那就是 Ohm 所可以做到的事。

Ohm 简介

Ohm 是一种新的解析系统。它类似于你可能已经在课本里面看到过的语法,但是它更强大,使用起来更简单。通过 Ohm, 你能够使用一种灵活的语法在一个 .ohm 文件中来写你自己的格式定义,然后使用你的宿主语言把语义加入到里面。在这篇博文里,我们将用 JavaScript 作为宿主语言。

Ohm 建立于一个为创造更简单、更灵活的解析器的多年研究基础之上。VPRI 的 STEPS program (pdf) 使用 Ohm 的前身 Ometa 为许多特殊的任务创造了专门的语言(比如一个有 400 行代码的平行制图描绘器)。

Ohm 有许多有趣的特点和符号,但是相比于全部解释它们,我认为我们只需要深入其中并构建一些东西就行了。

解析整数

让我们来解析一些数字。这看起来会很简单,只需在一个文本串中寻找毗邻的数字,但是让我们尝试去处理所有形式的数字:整数和浮点数、十六进制数和八进制数、科学计数、负数。解析数字很简单,正确解析却很难。

亲自构建这个代码将会很困难,会有很多问题,会伴随有许多特殊的情况,比如有时会相互矛盾。正则表达式或许可以做的这一点,但是它会非常丑陋而难以维护。让我们用 Ohm 来试试。

用 Ohm 构建的解析器涉及三个部分: 语法 grammar 语义 semantics 测试 tests 。我通常挑选问题的一部分为它写测试,然后构建足够的语法和语义来使测试通过。然后我再挑选问题的另一部分,增加更多的测试、更新语法和语义,从而确保所有的测试能够继续通过。即使我们有了新的强大的工具,写解析器从概念上来说依旧很复杂。测试是用一种合理的方式来构建解析器的唯一方法。现在,让我们开始工作。

我们将从整数开始。一个整数由一系列相互毗邻的数字组成。让我们把下面的内容放入一个叫做 grammar.ohm 的文件中:

CoolNums {
   // just a basic integer
   Number = digit+
}

这创造了一条匹配一个或多个数字(digit)叫作 Number 的单一规则。 意味着一个或更多,就在正则表达式中一样。当有一个或更多的数字时,这个规则将会匹配它们,如果没有数字或者有一些不是数字的东西将不会匹配。“数字(digit)”的定义是从 0 到 9 其中的一个字符。digit 也是像 Number 一样的规则,但是它是 Ohm 的其中一条构建规则因此我们不需要去定义它。如果我们想的话可以推翻它,但在这时候这没有任何意义,毕竟我们不打算去发明一种新的数。

现在,我们可以读入这个语法并用 Ohm 库来运行它。

把它放入 test1.js:

var ohm = require('ohm-js');
var fs = require('fs');
var assert = require('assert');
var grammar = ohm.grammar(fs.readFileSync('src/blog_numbers/syntax1.ohm').toString());

Ohm.grammar 调用将读入该文件并解析成一个语法对象。现在我们可以增加一些语义。把下面内容增加到你的 JavaScript 文件中:

var sem = grammar.createSemantics().addOperation('toJS', {
    Number: function(a) {
        return parseInt(this.sourceString,10);
    }
});

这通过 toJS 操作创造了一个叫作 sem 的语法集。这些语义本质上是一些对应到语法中每个规则的函数。每个函数当与之相匹配的语法规则被解析时将会被调用。上面的 Number 函数将会在语法中的 Number 规则被解析时被调用。 语法 grammar 定义了在语言中这些代码是什么, 语义 semantics 定义了当这些代码被解析时应该做什么。

语义函数能够做我们想做的任何事,比如打印出故障信息、创建对象,或者在任何子节点上递归调用 toJS。此时我们仅仅想把匹配的文本转换成真正的 JavaScript 整数。

所有的语义函数有一个内含的 this 对象,带有一些有用的属性。其 source 属性代表了输入文本中和这个节点相匹配的部分。this.sourceString 是一个匹配输入的串,调用内置在 JavaScript 中的 parseInt 函数会把这个串转换成一个数。传给 parseInt10 这个参数告诉 JavaScript 我们输入的是一个以 10 为基底(10 进制)的数。如果少了这个参数, JavaScript 也会假定以 10 为基底,但是我们把它包含在里面因为后面我们将支持以 16 为基底的数,所以使之明确比较好。

既然我们有一些语法,让我们来实际解析一些东西看一看我们的解析器是否能够工作。如何知道我们的解析器可以工作?通过测试,许多许多的测试,每一个可能的边缘情况都需要一个测试。

使用标准的断言 assert API,以下这个测试函数能够匹配一些输入并运用我们的语义把它转换成一个数,然后把这个数和我们期望的输入进行比较。

   function test(input, answer) {
     var match = grammar.match(input);
     if(match.failed()) return console.log("input failed to match " + input + match.message);     
     var result = sem(match).toJS();
     assert.deepEqual(result,answer);
     console.log('success = ', result, answer);
    }

就是如此。现在我们能够为各种不同的数写一堆测试。如果匹配失败我们的脚本将会抛出一个例外。否则就打印成功信息。让我们尝试一下,把下面这些内容加入到脚本中:

    test("123",123);
    test("999",999);
    test("abc",999);

然后用 node test1.js 运行脚本。

你的输出应该是这样:

success =  123 123
success =  999 999
input failed to match abcLine 1, col 1:
> 1 | abc
      ^
Expected a digit

真酷。正如预期的那样,前两个成功了,第三个失败了。更好的是,Ohm 自动给了我们一个很棒的错误信息指出匹配失败。

浮点数

我们的解析器工作了,但是它做的工作不是很有趣。让我们把它扩展成既能解析整数又能解析浮点数。改变 grammar.ohm 文件使它看起来像下面这样:

CoolNums {
  // just a basic integer
  Number = float | int
  int    = digit+
  float  = digit+ "." digit+
}

这把 Number 规则改变成指向一个浮点数(float)或者一个整数(int)。这个 | 代表着“或”。我们把这个读成“一个 Number 由一个浮点数或者一个整数构成。”然后整数(int)定义成 digit+,浮点数(float)定义成 digit+ 后面跟着一个句号然后再跟着另一个 digit+。这意味着在句号前和句号后都至少要有一个数字。如果一个数中没有一个句号那么它就不是一个浮点数,因此就是一个整数。

现在,让我们再次看一下我们的语义功能。由于我们现在有了新的规则所以我们需要新的功能函数:一个作为整数的,一个作为浮点数的。

var sem = grammar.createSemantics().addOperation('toJS', {
    Number: function(a) {
        return a.toJS();
    },
    int: function(a) {
        console.log("doing int", this.sourceString);
        return parseInt(this.sourceString,10);
    },
    float: function(a,b,c) {
        console.log("doing float", this.sourceString);
        return parseFloat(this.sourceString);
    }
});

这里有两件事情需要注意。首先,整数(int)、浮点数(float)和数(Number)都有相匹配的语法规则和函数。然而,针对 Number 的功能不再有任何意义。它接收子节点 a 然后返回该子节点的 toJS 结果。换句话说,Number 规则简单的返回相匹配的子规则。由于这是在 Ohm 中任何规则的默认行为,因此实际上我们不用去考虑 Number 的作用,Ohm 会替我们做好这件事。

其次,整数(int)有一个参数 a,然而浮点数有三个:abc。这是由于规则的 实参数量 arity 决定的。 实参数量 arity 意味着一个规则里面有多少参数。如果我们回过头去看语法,浮点数(float)的规则是:

  float  = digit+ "." digit+

浮点数规则通过三个部分来定义:第一个 digit+.、以及第二个 digit+。这三个部分都会作为参数传递给浮点数的功能函数。因此浮点数必须有三个参数,否则 Ohm 库会给出一个错误。在这种情况下我们不用在意参数,因为我们仅仅直接攫取了输入串,但是我们仍然需要参数列在那里来避免编译器错误。后面我们将实际使用其中一些参数。

现在我们可以为新的浮点数支持添加更多的测试。

test("123",123);
test("999",999);
//test("abc",999);
test('123.456',123.456);
test('0.123',0.123);
test('.123',0.123);

注意最后一个测试将会失败。一个浮点数必须以一个数开始,即使它就是个 0,.123 不是有效的,实际上真正的 JavaScript 语言也有相同的规则。

十六进制数

现在我们已经有了整数和浮点数,但是还有一些其它的数的语法最好可以支持:十六进制数和科学计数。十六进制数是以 16 为基底的整数。十六进制数的数字能从 0 到 9 和从 A 到 F。十六进制数经常用在计算机科学中,当用二进制数据工作时,你可以仅仅使用两个数字表示 0 到 255 的数。

在绝大多数源自 C 的编程语言(包括 JavaScript),十六进制数通过在前面加上 0x 来向编译器表明后面跟的是一个十六进制数。为了让我们的解析器支持十六进制数,我们只需要添加另一条规则。

  Number = hex | float | int
  int    = digit+
  float  = digit+ "." digit+
  hex    = "0x" hexDigit+
  hexDigit := "0".."9" | "a".."f" | "A".."F"

我实际上已经增加了两条规则。十六进制数(hex)表明它是一个 0x 后面一个或多个十六进制数字(hexDigits)的串。一个十六进制数字(hexDigit)是从 0 到 9,或从 a 到 f,或 A 到 F(包括大写和小写的情况)的一个字符。我也修改了 Number 规则来识别十六进制数作为另外一种可能的情况。现在我们只需要另一条针对十六进制数的功能规则。

    hex: function(a,b) {
        return parseInt(this.sourceString,16);
    }

注意到,在这种情况下,我们把 16 作为基底传递给 parseInt,因为我们希望 JavaScript 知道这是一个十六进制数。

我略过了一些很重要需要注意的事。hexDigit 的规则像下面这样:

  hexDigit := "0".."9" | "a".."f" | "A".."F"

注意我使用的是 := 而不是 =。在 Ohm 中,:= 是当你需要推翻一条规则的时候使用。这表明 Ohm 已经有了一条针对 hexDigit 的默认规则,就像 digitspace 等一堆其他的东西。如果我使用了 =, Ohm 将会报告一个错误。这是一个检查,从而避免我无意识的推翻一个规则。由于新的 hexDigit 规则和 Ohm 的构建规则一样,所以我们可以把它注释掉,然后让 Ohm 自己来实现它。我留下这个规则只是因为这样我们可以看到它实际上是如何进行的。

现在,我们可以添加更多的测试然后看到十六进制数真的能工作:

test('0x456',0x456);
test('0xFF',255);

科学计数

最后,让我们来支持科学计数。科学计数是针对非常大或非常小的数的,比如 1.8×10^3。在大多数编程语言中,科学计数法表示的数会写成这样:1.8e3 表示 18000,或者 1.8e-3 表示 .018。让我们增加另外一对规则来支持这个指数表示:

    float  = digit+ "." digit+ exp?
    exp    = "e" "-"? digit+

上面在浮点数规则末尾增加了一个指数(exp)规则和一个 ?? 表示没有或有一个,所以指数(exp)是可选的,但是不能超过一个。增加指数(exp)规则也改变了浮点数规则的实参数量,所以我们需要为浮点数功能增加另一个参数,即使我们不使用它。

    float: function(a,b,c,d) {
        console.log("doing float", this.sourceString);
        return parseFloat(this.sourceString);
    },

现在我们的测试可以通过了:

test('4.8e10',4.8e10);
test('4.8e-10',4.8e-10);

结论

Ohm 是构建解析器的一个很棒的工具,因为它易于上手,并且你可以递增的增加规则。Ohm 也还有其他我今天没有写到的很棒的特点,比如调试观察仪和子类化。

到目前为止,我们已经使用 Ohm 来把字符串翻译成 JavaScript 数,并且 Ohm 经常用于把一种表示方式转化成另外一种。然而,Ohm 还有更多的用途。通过放入不同的语义功能集,你可以使用 Ohm 来真正处理和计算东西。一个单独的语法可以被许多不同的语义使用,这是 Ohm 的魔法之一。

在这个系列的下一篇文章中,我将向你们展示如何像真正的计算机一样计算像 (4.85 + 5 * (238 - 68)/2) 这样的数学表达式,不仅仅是解析数。

额外的挑战:你能够扩展语法来支持八进制数吗?这些以 8 为基底的数能够只用 0 到 7 这几个数字来表示,前面加上一个数字 0 或者字母 o。看看针对下面这些测试情况是够正确。下次我将给出答案。

test('0o77',7*8+7);
test('0o23',0o23);


via: https://www.pubnub.com/blog/2016-08-30-javascript-parser-ohm-makes-creating-a-programming-language-easy/

作者:Josh Marinacci 译者:ucasFL 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出