使用 mal ,写一个 Lisp 解释器(上)

mal 是 GitHub 上的一个开源项目,这是关于它的简单的介绍:使用75种语言编写一个 Lisp 解释器
这是 mal 语言的语法简介和由 JS 实现的一个在线 repl。

在这篇文章中,我们会依托 mal 提供的步骤说明,讲讲如何实现一个简单的 Lisp 解释器。在步骤说明中介绍的内容,我们不会过多重复。

简单了解解释器

解释器是将一种编程语言的代码逐句解释执行的软件。要实现解释器的功能,至少要实现以下的功能:

输入

读取输入字符串:程序代码是以字符串的形式输入的。

预处理

  • 词法解析:把字符串转化为 token,类似于自然语言中的分词、断句。例如把 (+ 1 2)转化为 (+12)
  • 语法解析:把 token 的序列转化为解释器内部能够理解的数据结构,即抽象语法树(AST)。例如,把由 ( def a ( - (+ 1 2 ) 3 ) ) 组成的序列转化为:
// 示意:
( def a 
  ( - 
    (+ 1 2 )
    3 ))

当然,上面的形式只是个示意,假如实现解释器的语言是 Java,数据结构可能会是一个嵌套的数组,每层数组可能会表示一个运算(例如 def, - 或者 +)。

解释执行

解释器的核心,将抽象语法树解释为目标语言(在本项目里就是你用来实现解释器的语言)的程序,并执行。

输出

将程序运行的结果(可能是字符串、数值或者其他的数据形式)转化为字符串的形式输出。

相比其它语言的解释器,Lisp 解释器的优势是词法解析和语法解析的过程非常简单,因为一个 Lisp 程序本身几乎就是一个抽象语法树了,而像 Java、Swift 之类语法更复杂的语言,词法解析和语法解析的过程会复杂的多。这样,从学习的角度出发,实现一个 Lisp 解释器可以更专注于解释器的核心功能上。

第0步:搭建框架

建立 READ, EVAL, PRINT 三个主要模块,以及把他们连起来的 rep()。
只是搭建一个骨架而已,编码毫无难度。

问题有可能出现在命令行操作和写 Makefile 上,好在用到的也都是基本操作,可以简单看一下教学,如果你用的语言已经由别人实现过,也可以借用别人写好的 Makefile。

参考资料:
The Linux Command Line (英文版)
The Linux Command Line (中文版)
跟我一起写 Makefile
Make 命令教程

第1步:读取和打印

前面讲了解释器的四项工作:输入、预处理、解释执行、输出。这一步完成输入、预处理、和输出部分,其中预处理部分包含在输入中。
tokenizer() 函数负责词法分析。
read_form() 函数负责语法分析。

可能遇到的问题:正则表达式。这个我没有网上资源推荐给你,你可以自己找一下;我使用的是实体书《精通正则表达式》。注意:正则表达式有不同流派,项目 guide 中使用的是 PCRE 。

注意
项目中的任务有的被标识为 optional 或者 deferable。
跳过 optional(可选的)任务不会影响后续任务,但有可能导致单元测试中出现错误。
跳过 deferable (可推迟)的任务可能会导致后面的步骤执行不畅,而且将来返工可能会更麻烦一点,所以建议尽最大努力完成,如果确定要跳过,也请尽早回头补上。
这一步中的 deferable 任务可能显得难一点,如果你要跳过,至少看一眼这项任务都是什么,心理先有个数。

第2步:求值

Lisp 程序需要递归地执行两个相互调用的步骤:求值 eval应用 apply。解释器对一个列表(List)求值,首先要对这个列表的每个元素求值,然后将操作符(第一个元素)应用到被操作数(其它元素)上。

例如,对于列表 (+ a ( + 1 2 ))求值:

  • 需要先分别求值 +a( + 1 2 ),然后将 + 的值应用a( + 1 2 )的值上。
  • + 的求值结果为 "将操作数加到一起的操作";a 如果有定义,它的求值结果就是变量 a 绑定的值;而( + 1 2 )并不能直接得到,需要将求值应用循环( + 1 2 )执行一次。
  • 求值:分别求出 +12 的值,+ 的值已经知道了,12作为整数是自求值对象,对它们求值的结果是它们本身。这样,所有的值都得到了。
  • 应用:将 + 应用到 12 上,得到 3。
  • 回到外层的 List ,假设 a 的值为 5。那么 将 + 应用到 53 上,得到 8。
  • 8 就是这个 List 求值的结果。

在 mal 项目中,基本上 EVAL() 函数负责的是应用的部分,eval_ast()函数负责的是求值的部分。

第3步:环境

在上一步的例子中,有个未解决的问题。解释器是怎么知道变量a的值?更进一步,解释器是怎么知道 + 代表求和的运算的?
在上一步中定义的 repl_env 就相当于一个全局的环境 Environment。解释器如果想知道任何变量(包括函数名)的值,都可以在 repl_env 中查找。但在大多数真实存在的编程语言中,并不是所有的变量都是全局变量,变量是有自己的作用域的。例如:

function foo() {
  var x = 1
  {
    var y = 0
    print(x)
  }
  print(y)
}

上面的实例语言和很多真实的语言一样,使用大括号作为作用域的开始和结束。
对于大多数语言,print(x)会打印 1,因为第一个 print()在自己的作用域中找不到 x 的值,它会继续逐级向上层寻找,在上一层找到 x = 1 ;而print(y) 很可能会报错,因为它找不到 y 的定义。

在 mal 中 let* 会生成新的环境,而 def! 会修改当前的环境。除了全局环境外,每个环境都有它的外层环境。大多数其他语言的工作原理也是类似的,只不过它们实现环境的方法一般会高效的多。

第4步:函数定义和控制流

之前实现的求值和环境组成了一个解释器最核心的部分,而有了这一步实现函数定义和控制流功能后,mal 看起来已经像一个能用的真正的编程语言了。

如何实现定义函数闭包略微有一点烧脑:
以当前环境为外层环境,创建一个新的环境。在新的环境中,函数的每个形参作为键,调用函数使用的实参作为值。将函数体在这个新的环境中求得的值作为返回值。
而上面说的的这一切不是即刻执行的,而是定义在一个闭包之中,直到对这个闭包求值时才会执行。
通过一个简单的例子想一下:

function bar (left, right) {
  return left * right + left
}

上面定义了一个将两个数相乘再加上第一个数的函数,并给这个函数起名字叫 bar,相当于 mal 中的 :

(def! (fn (left right) 
          (函数体...) ) 
      bar)

定义一个函数会保存两个信息:参数列表(left, right) 和函数体 { return left * right + left }。除了这些数据,还要告诉函数的执行者使用函数时怎么继续操作:

  • 在函数体中,把所有形参 (left, right) 替换为实参,例如当执行 bar (3, 5) 时,就是把函数体变成 { return 3 * 5 + 3 }
  • 对替换后的函数体求值就得到了想要的值。

第5步:尾调用优化

递归和迭代是程序设计领域中两个重要的概念。一般来说递归程序更容易设计,但由于大量的递归调用会消耗更多的栈空间,所以在执行时时间和空间效率往往低于程序的迭代版本,而且有可能导致栈溢出。
尾调用优化(尾递归优化)可以将符合特定条件的递归过程转化为迭代过程,这样可以提高程序的性能。
尾调用优化的条件是,外层函数执行的最后一步是调用内层函数,符合这种条件时,解释器可以自动执行尾调用优化。
例子(来源:阮一峰的博客):
写一个求阶乘的函数

function factorial (n) {
  if (n == 1) return 1;
  return n * factorial(n - 1);
}

factorial(5) // 120

上面的函数是一个递归函数,但不是尾递归,因为它的最后一步不是调用factorial(n - 1),而是一个乘法。
把它改写成尾递归的形式:

function factorial(n, total) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}

factorial(5, 1) // 120

这样它就变成了一个可以优化的尾递归函数了。
总结一下,这个求递归函数的核心就是反复地使用 n 和部分积相乘,在第一个例子中是 n * factorial(n - 1),在第二个例子中是 n * total 。程序的其他部分都是用于保证相乘能正确地继续执行和恰当地停止。

按着这个思路,手动把尾递归变成迭代过程:

function factorial(n, total) {
  while ( n > 1 ) {
    total = n * total
    n = n - 1
  }
  return total;
}
factorial(5, 1) // 120

把函数的核心部分用一个 while 循环包裹起来,在合适的时候结束迭代。mal 解释器实现的尾调用优化,大致也是这个原理。

待续。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容