用Python编写一个简单的Lisp解释器的教程

WBOY
Release: 2016-06-10 15:16:20
Original
1390 people have browsed it

本文有两个目的: 一是讲述实现计算机语言解释器的通用方法,另外一点,着重展示如何使用Python来实现Lisp方言Scheme的一个子集。我将我的解释器称之为Lispy (lis.py)。几年前,我介绍过如何使用Java编写一个Scheme解释器,同时我还使用Common Lisp语言编写过一个版本。这一次,我的目的是尽可能简单明了地演示一下Alan Kay所说的“软件的麦克斯韦方程组” (Maxwell's Equations of Software)。

Lispy支持的Scheme子集的语法和语义

大多数计算机语言都有许多语法规约 (例如关键字、中缀操作符、括号、操作符优先级、点标记、分号等等),但是,作为Lisp语言家族中的一员,Scheme所有的语法都是基于包含在括号中的、采用前缀表示的列表的。这种表示看起来似乎有些陌生,但是它具有简单一致的优点。 (一些人戏称”Lisp”是”Lots of Irritating Silly Parentheses“——“大量恼人、愚蠢的括号“——的缩写;我认为它是”Lisp Is Syntactically Pure“——“Lisp语法纯粹”的缩写。) 考虑下面这个例子:
 

/* Java */
if(x.val() > 0) {
 z = f(a*x.val() + b);
}
/* Scheme */
(if (> (val x) 0)
 (set! z (f (+ (* a (val x)) b))))
Copy after login

注意上面的惊叹号在Scheme中并不是一个特殊字符;它只是”set!“这个名字的一部分。(在Scheme中)只有括号是特殊字符。类似于(set! x y)这样以特殊关键字开头的列表在Scheme中被称为一个特殊形式 (special form);Scheme的优美之处就在于我们只需要六种特殊形式,以及另外的三种语法构造——变量、常量和过程调用。

201543100222974.jpg (639×717)

在该表中,var必须是一个符号——一个类似于x或者square这样的标识符——number必须是一个整型或者浮点型数字,其余用斜体标识的单词可以是任何表达式。exp…表示exp的0次或者多次重复。

更多关于Scheme的内容,可以参考一些优秀的书籍 (如Friedman和Fellesein, Dybvig,Queinnec, Harvey和Wright或者Sussman和Abelson)、视频 (Abelson和Sussman)、教程 (Dorai、PLT或者Neller)、或者参考手册。

语言解释器的职责
一个语言解释器包括两部分:

1、解析 (Parsing):解析部分接受一个使用字符序列表示的输入程序,根据语言的语法规则对输入程序进行验证,然后将程序翻译成一种中间表示。在一个简单的解释器中,中间表示是一种树结构,紧密地反映了源程序中语句或表达式的嵌套结构。在一种称为编译器的语言翻译器中,内部表示是一系列可以直接由计算机 (作者的原意是想说运行时系统——译者注) 执行的指令。正如Steve Yegge所说,“如果你不明白编译器的工作方式,那么你不会明白计算机的工作方式。”Yegge介绍了编译器可以解决的8种问题 (或者解释器,又或者采用Yegge的典型的反讽式的解决方案)。 Lispy的解析器由parse函数实现。

2、执行:程序的内部表示 (由解释器) 根据语言的语义规则进行进一步处理,进而执行源程序的实际运算。(Lispy的)执行部分由eval函数实现 (注意,该函数覆盖了Python内建的同名函数)。

下面的图片描述了解释器的解释流程,(图片后的) 交互会话展示了parse和eval函数对一个小程序的操作方式:

201543100330753.jpg (636×81)

这里,我们采用了一种尽可能简单的内部表示,其中Scheme的列表、数字和符号分别使用Python的列表、数字和字符串来表示。

执行: eval
下面是eval函数的定义。对于上面表中列出的九种情况,每一种都有一至三行代码,eval函数的定义只需要这九种情况:

def eval(x, env=global_env):
 "Evaluate an expression in an environment."
 if isa(x, Symbol):    # variable reference
  return env.find(x)[x]
 elif not isa(x, list):   # constant literal
  return x    
 elif x[0] == 'quote':   # (quote exp)
  (_, exp) = x
  return exp
 elif x[0] == 'if':    # (if test conseq alt)
  (_, test, conseq, alt) = x
  return eval((conseq if eval(test, env) else alt), env)
 elif x[0] == 'set!':   # (set! var exp)
  (_, var, exp) = x
  env.find(var)[var] = eval(exp, env)
 elif x[0] == 'define':   # (define var exp)
  (_, var, exp) = x
  env[var] = eval(exp, env)
 elif x[0] == 'lambda':   # (lambda (var*) exp)
  (_, vars, exp) = x
  return lambda *args: eval(exp, Env(vars, args, env))
 elif x[0] == 'begin':   # (begin exp*)
  for exp in x[1:]:
   val = eval(exp, env)
  return val
 else:       # (proc exp*)
  exps = [eval(exp, env) for exp in x]
  proc = exps.pop(0)
  return proc(*exps)
 
isa = isinstance
 
Symbol = str

Copy after login

eval函数的定义就是这么多…当然,除了environments。Environments (环境) 只是从符号到符号所代表的值的映射而已。一个新的符号/值绑定由一个define语句或者一个过程定义 (lambda表达式) 添加。

让我们通过一个例子来观察定义然后调用一个Scheme过程的时候所发生的事情 (lis.py>提示符表示我们正在与Lisp解释器进行交互,而不是Python):

lis.py> (define area (lambda (r) (* 3.141592653 (* r r))))
lis.py> (area 3)
28.274333877
Copy after login

当我们对(lambda (r) (* 3.141592653 (* r r)))进行求值时,我们在eval函数中执行elif x[0] == 'lambda'分支,将(_, vars, exp)三个变量分别赋值为列表x的对应元素 (如果x的长度不是3,就抛出一个错误)。然后,我们创建一个新的过程,当该过程被调用的时候,将会对表达式['*', 3.141592653 ['*', 'r', 'r']]进行求值,该求值过程的环境 (environment) 是通过将过程的形式参数 (该例中只有一个参数,r) 绑定为过程调用时所提供的实际参数,外加当前环境中所有不在参数列表 (例如,变量*) 的变量组成的。新创建的过程被赋值给global_env中的area变量。

那么,当我们对(area 3)求值的时候发生了什么呢?因为area并不是任何表示特殊形式的符号之一,它必定是一个过程调用 (eval函数的最后一个else:分支),因此整个表达式列表都将会被求值,每次求值其中的一个。对area进行求值将会获得我们刚刚创建的过程;对3进行求值所得的结果就是3。然后我们 (根据eval函数的最后一行) 使用参数列表[3]来调用这个新创建的过程。也就是说,对exp(也就是['*', 3.141592653 ['*', 'r', 'r']])进行求值,并且求值所在的环境中r的值是3,并且外部环境是全局环境,因此*是乘法过程。

现在,我们可以解释一下Env类的细节了:

class Env(dict):
 "An environment: a dict of {'var':val} pairs, with an outer Env."
 def __init__(self, parms=(), args=(), outer=None):
  self.update(zip(parms,args))
  self.outer = outer
 def find(self, var):
  "Find the innermost Env where var appears."
  return self if var in self else self.outer.find(var)
Copy after login

注意Env是dict的一个子类,也就是说,通常的字典操作也适用于Env类。除此之外,该类还有两个方法,构造函数__init__和find函数,后者用来为一个变量查找正确的环境。理解这个类的关键 (以及我们需要一个类,而不是仅仅使用dict的根本原因) 在于外部环境 (outer environment) 这个概念。考虑下面这个程序:

(define make-account
 (lambda (balance)
 (lambda (amt)
  (begin (set! balance (+ balance amt)) balance))))
(define a1 (make-account 100.00))
(a1 -20.00)
Copy after login

每个矩形框都代表了一个环境,并且矩形框的颜色与环境中最新定义的变量的颜色相对应。在程序的最后两行我们定义了a1并且调用了(a1 -20.00);这表示创建一个开户金额为100美元的银行账户,然后是取款20美元。在对(a1 -20.00)求值的过程中,我们将会对黄色高亮表达式进行求值,该表达式中具有三个变量。amt可以在最内层 (绿色) 环境中直接找到。但是balance在该环境中没有定义:我们需要查看绿色环境的外层环境,也就是蓝色环境。最后,+代表的变量在这两个环境中都没有定义;我们需要进一步查看外层环境,也就是全局 (红色) 环境。先查找内层环境,然后依次查找外部的环境,我们把这一过程称之为词法定界 (lexical scoping)。Procedure.find负责根据词法定界规则查找正确的环境。

剩下的就是要定义全局环境。该环境需要包含+过程以及所有其它Scheme的内置过程。我们并不打算实现所有的内置过程,但是,通过导入Python的math模块,我们可以获得一部分这些过程,然后我们可以显式地添加20种常用的过程:

def add_globals(env):
 "Add some Scheme standard procedures to an environment."
 import math, operator as op
 env.update(vars(math)) # sin, sqrt, ...
 env.update(
  {'+':op.add, '-':op.sub, '*':op.mul, '/':op.div, 'not':op.not_,
  '>':op.gt, '<':op.lt, '>=':op.ge, '<=':op.le, '=':op.eq,
  'equal&#63;':op.eq, 'eq&#63;':op.is_, 'length':len, 'cons':lambda x,y:[x]+y,
  'car':lambda x:x[0],'cdr':lambda x:x[1:], 'append':op.add, 
  'list':lambda *x:list(x), 'list&#63;': lambda x:isa(x,list),
  'null&#63;':lambda x:x==[], 'symbol&#63;':lambda x: isa(x, Symbol)})
 return env
 
global_env = add_globals(Env())
Copy after login

PS1: 对麦克斯韦方程组的一种评价是“一般地,宇宙间任何的电磁现象,皆可由此方程组解释”。Alan Kay所要表达的,大致就是Lisp语言使用自身定义自身 (Lisp was “defined in terms of Lisp”) 这种自底向上的设计对软件设计而言具有普遍的参考价值。——译者注

解析 (Parsing): read和parse

接下来是parse函数。解析通常分成两个部分:词法分析和语法分析。前者将输入字符串分解成一系列的词法单元 (token);后者将词法单元组织成一种中间表示。Lispy支持的词法单元包括括号、符号 (如set!或者x) 以及数字 (如2)。它的工作形式如下:

>>> program = "(set! x*2 (* x 2))"
 
>>> tokenize(program)
['(', 'set!', 'x*2', '(', '*', 'x', '2', ')', ')']
 
>>> parse(program)
['set!', 'x*2', ['*', 'x', 2]]
Copy after login

有许多工具可以进行词法分析 (例如Mike Lesk和Eric Schmidt的lex)。但是我们将会使用一个非常简单的工具:Python的str.split。我们只是在 (源程序中) 括号的两边添加空格,然后调用str.split来获得一个词法单元的列表。

接下来是语法分析。我们已经看到,Lisp的语法很简单。但是,一些Lisp解释器允许接受表示列表的任何字符串作为一个程序,从而使得语法分析的工作更加简单。换句话说,字符串(set! 1 2)可以被接受为是一个语法上有效的程序,只有当执行的时候解释器才会抱怨set!的第一个参数应该是一个符号,而不是数字。在Java或者Python中,与之等价的语句1 = 2将会在编译时被认定是错误。另一方面,Java和Python并不需要在编译时检测出表达式x/0是一个错误,因此,如你所见,一个错误应该何时被识别并没有严格的规定。Lispy使用read函数来实现parse函数,前者用以读取任何的表达式 (数字、符号或者嵌套列表)。

tokenize函数获取一系列词法单元,read通过在这些词法单元上调用read_from函数来进行工作。给定一个词法单元的列表,我们首先查看第一个词法单元;如果它是一个')',那么这是一个语法错误。如果它是一个'(‘,那么我们开始构建一个表达式列表,直到我们读取一个匹配的')'。所有其它的 (词法单元) 必须是符号或者数字,它们自身构成了一个完整的列表。剩下的需要注意的就是要了解'2‘代表一个整数,2.0代表一个浮点数,而x代表一个符号。我们将区分这些情况的工作交给Python去完成:对于每一个不是括号也不是引用 (quote) 的词法单元,我们首先尝试将它解释为一个int,然后尝试float,最后尝试将它解释为一个符号。根据这些规则,我们得到了如下程序:

def read(s):
 "Read a Scheme expression from a string."
 return read_from(tokenize(s))
 
parse = read
 
def tokenize(s):
 "Convert a string into a list of tokens."
 return s.replace('(',' ( ').replace(')',' ) ').split()
 
def read_from(tokens):
 "Read an expression from a sequence of tokens."
 if len(tokens) == 0:
  raise SyntaxError('unexpected EOF while reading')
 token = tokens.pop(0)
 if '(' == token:
  L = []
  while tokens[0] != ')':
   L.append(read_from(tokens))
  tokens.pop(0) # pop off ')'
  return L
 elif ')' == token:
  raise SyntaxError('unexpected )')
 else:
  return atom(token)
 
def atom(token):
 "Numbers become numbers; every other token is a symbol."
 try: return int(token)
 except ValueError:
  try: return float(token)
  except ValueError:
   return Symbol(token)

Copy after login

最后,我们将要添加一个函数to_string,用来将一个表达式重新转换成Lisp可读的字符串;以及一个函数repl,该函数表示read-eval-print-loop (读取-求值-打印循环),用以构成一个交互式的Lisp解释器:

def to_string(exp):
 "Convert a Python object back into a Lisp-readable string."
 return '('+' '.join(map(to_string, exp))+')' if isa(exp, list) else str(exp)
 
def repl(prompt='lis.py> '):
 "A prompt-read-eval-print loop."
 while True:
  val = eval(parse(raw_input(prompt)))
  if val is not None: print to_string(val)
Copy after login

下面是函数工作的一个例子:

>>> repl()
lis.py> (define area (lambda (r) (* 3.141592653 (* r r))))
lis.py> (area 3)
28.274333877
lis.py> (define fact (lambda (n) (if (<= n 1) 1 (* n (fact (- n 1))))))
lis.py> (fact 10)
3628800
lis.py> (fact 100)
9332621544394415268169923885626670049071596826438162146859296389521759999322991
5608941463976156518286253697920827223758251185210916864000000000000000000000000
lis.py> (area (fact 10))
4.1369087198e+13
lis.py> (define first car)
lis.py> (define rest cdr)
lis.py> (define count (lambda (item L) (if L (+ (equal? item (first L)) (count item (rest L))) 0)))
lis.py> (count 0 (list 0 1 2 3 0 0))
3
lis.py> (count (quote the) (quote (the more the merrier the bigger the better)))
4
Copy after login

Lispy有多小、多快、多完备、多优秀?

我们使用如下几个标准来评价Lispy:

*小巧:Lispy非常小巧:不包括注释和空白行,其源代码只有90行,并且体积小于4K。(比第一个版本的体积要小,第一个版本有96行——根据Eric Cooper的建议,我删除了Procedure的类定义,转而使用Python的lambda。) 我用Java编写的Scheme解释器Jscheme最小的版本,其源代码也有1664行、57K。Jscheme最初被称为SILK (Scheme in Fifty Kilobytes——50KB的Scheme解释器),但是只有计算字节码而不是源代码的时候,我才能保证 (其体积) 小于该最小值。Lispy做的要好得多;我认为它满足了Alan Kay在1972年的断言:他声称我们可以使用“一页代码”来定义“世界上最强大的语言”。

bash$ grep "^\s*[^#\s]" lis.py | wc
  90  398 3423
Copy after login

*高效:Lispy计算(fact 100)只需要0.004秒。对我来说,这已经足够快了 (虽然相比起其它的计算方式来说要慢很多)。

*完备:相比起Scheme标准来说,Lispy不是非常完备。主要的缺陷有:
(1) 语法:缺少注释、引用 (quote) / 反引用 (quasiquote) 标记 (即'和`——译者注)、#字面值 (例如#\a——译者注)、衍生表达式类型 (例如从if衍生而来的cond,或者从lambda衍生而来的let),以及点列表 (dotted list)。
(2) 语义:缺少call/cc以及尾递归。
(3) 数据类型:缺少字符串、字符、布尔值、端口 (ports)、向量、精确/非精确数字。事实上,相比起Scheme的pairs和列表,Python的列表更加类似于Scheme的向量。
(4) 过程:缺少100多个基本过程:与缺失数据类型相关的所有过程,以及一些其它的过程 (如set-car!和set-cdr!,因为使用Python的列表,我们无法完整实现set-cdr!)。
(5) 错误恢复:Lispy没有尝试检测错误、合理地报告错误以及从错误中恢复。Lispy希望程序员是完美的。

*优秀:这一点需要读者自己确定。我觉得,相对于我解释Lisp解释器这一目标而言,它已经足够优秀。

真实的故事

了解解释器的工作方式会很有帮助,有一个故事可以支持这一观点。1984年的时候,我在撰写我的博士论文。当时还没有LaTeX和Microsoft Word——我们使用的是troff。遗憾的是,troff中没有针对符号标签的前向引用机制:我想要能够撰写“正如我们将要在@theoremx页面看到的”,随后在合适的位置撰写”@(set theoremx \n%)” (troff寄存器\n%保存了页号)。我的同伴,研究生Tony DeRose也有着同样的需求,我们一起实现了一个简单的Lisp程序,使用这个程序作为一个预处理器来解决我们的问题。然而,事实证明,当时我们用的Lisp善于读取Lisp表达式,但在采用一次一个字符的方式读取非Lisp表达式时效率过低,以至于我们的这个程序很难使用。

在这个问题上,Tony和我持有不同的观点。他认为 (实现) 表达式的解释器是困难的部分;他需要Lisp为他解决这一问题,但是他知道如何编写一个短小的C过程来处理非Lisp字符,并知道如何将其链接进Lisp程序。我不知道如何进行这种链接,但是我认为为这种简单的语言编写一个解释器 (其所具有的只是设置变量、获取变量值和字符串连接) 很容易,于是我使用C语言编写了一个解释器。因此,戏剧性的是,Tony编写了一个Lisp程序,因为他是一个C程序员;我编写了一个C程序,因为我是一个Lisp程序员。

最终,我们都完成了我们的论文。

整个解释器

重新总结一下,下面就是Lispy的所有代码 (也可以从lis.py下载):

# -*- coding: UTF-8 -*-
# 源代码略。以下纯属娱乐。。。
# 你想看完整源代码?真的想看?
# 真的想看你就说嘛,又不是我编写的代码,你想看我总不能不让你看,对吧?
# 真的想看的话就参考上述下载地址喽。。。LOL
Copy after login

Related labels:
source:php.cn
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template
About us Disclaimer Sitemap
php.cn:Public welfare online PHP training,Help PHP learners grow quickly!