探索 JVM 上的 LISP

阅读数:3720 2008 年 9 月 4 日

当前 Java 领域最激动人心的事情莫过于可允许其它编程语言运行于 Java 虚拟机上。围绕 JRuby、Groovy、Scala 还有 Rhino(JavaScript 引擎) 的讨论已经甚嚣尘上。可为什么要墨守陈规呢?如果你真的想跳出主流,投身于一种与 Java 截然不同的的语言,Lisp 就不失为一种很好的选择。现在已有几种可运行于 JVM 上的 Lisp 程序设计语言的开源实现,准备好开始我们的探索之旅吧!

Lisp 有什么值得研究呢?首先,作为已有 50 年历史的语言,它促成许多被我们今日视为理所当然的观念。if-then-else 结构、早期的面向对象和带垃圾回收的自动内存管理的尝试都来源于此。目前 Java 程序员的热点话题——词汇闭包(Lexical Closure),最初的探索也是七十年代在 Lisp 中展开的。除此以外,Lisp 还具备其它许多语言至今都未采用的特性,这些出色的思想必将在未来引起复兴潮流。

本文的目标读者是有意了解 Lisp 的 Java 开发人员。我们将在接下来的内容中讨论当前可以用在 JVM 上的不同 Lisp 方言 (dialect),令你快速了解 Lisp 程序设计工作机理和其独特之处,文章的最后会演示如何将 Lisp 代码与 Java 系统进行整合。

目前存在许多可用于不同平台的 Lisp 系统,有免费的也有商业的。对于想要开始探索 Lisp 的 Java 用户,不离开 JVM 是首选,这样的话起步很容易,还可以很方便的使用所有自己熟悉的 Java 库和相关工具。

Common Lisp 和 Scheme

Lisp 有两种主要方言(dialect):Common Lisp 和 Scheme。虽然设计理念大体相似,但是它们的差别仍然足够引起孰优孰劣的激烈争论。

Common Lisp 是 1991 年完成的 ANSI 标准。统一了几种早期 Lisp 的理念,是可用于多种应用开发的大型环境,其最为著名的应用是人工智能。而 Scheme 产生于学术界,特意进行了精简化设计,经验证是一种很好的语言,既可用于计算机科学教学,又可以作为嵌入式脚本语言。你还可能会遇到其它一些比较有名的 Lisp:小型的特定于应用的 DSLs,如 Emacs Lisp 或 AutoCAD 的 AutoLISP。

上面提到的两种主要方言(dialect)在 JVM 上都有相应的实现,相较而言 Schemes 的实现要成熟一些。Armed Bear Common Lisp(www.armedbear.org/abcl.html)非常彻底的实现了 Common Lisp 标准,但它存在一个问题,如果你没有安装别的 Common List 系统,就不能构建分发版本,这对新手可能是个困难。

在 Scheme 方面,两个主要的产品是 Kawa(www.gnu.org/software/kawa)和 SISC(www.sisc-scheme.org——the Second Interpreter of Scheme Code)。在这篇文章的例子当中,我们会用到 Kawa,它实际上是个框架,能创造可编译成 Java 字节码的新语言。Scheme 只是它的实现之一。顺便说一句,Kawa 的创建者 Per Bothner 目前就职于 Sun,主要从事 JavaFX 项目的编译器方面的工作。

另外一个值得一提的竟争对手是 Clojure(clojure.sourceforge.net)。这是一种新的语言,其 Lisp 方言(dialect)介于 Scheme 和 Common Lisp 之间。它是直接为 JVM 量身打造的,因此在上面提到的所有 Lisp 当中,有着最为清晰 Java 整合方案。它还具有其它一些激动人心的特性,例如内建的支持并发和事务内存。Clojure 目前仍然处于探索测试阶段,因此在它基础上构建程序还有些为时尚早,但它绝对是一个值得关注的项目。

读取—求值—打印—循环

我们先来安装 Kawa。它的分发版是一个单独的 Jar 文件,可以直接通过链接ftp://ftp.gnu.org/pub/gnu/kawa/kawa-1.9.1.jar下载。得到该 Jar 包后,就把它加进你的类路径上,这样你就可以通过运行如下命令启动 REPL 了:

  java kawa.repl

#|kawa:1|#

该命令启动了 Kawa,并显示一个提示符。这其中究竟有何奥妙呢?REPL(READ-EVAL-PRINT-LOOP)意思是读取—求值—打印—循环,这是与运行中的 Lisp 系统进行交互的方式——它“读取”你的输入,进行“求值”运算后,“打印”计算结果,如此反复“循环”。开发 Lisp 程序的方式,与我们开发 Java 程序时所遵循的“写代码、编译、运行”的周期不同。Lisp 程序员需要激励他们的 Lisp 系统,保持它的运行状态,这样就令编译和运行时的界限模糊起来。在 REPL 中,函数和变量在执行过程中都是可以修改的,代码也是动态解释和编译的。

先来做点简单的事情:把两个数字加到一起。

  #|kawa:1|# (+ 1 2)

3

这是 Lisp 表达式的典型结构或者说“格式”。语法都是一致的:表达式总被放在一对圆括号内,因为用的是前缀符号,所以“+”号要放在两个参量前。再来一个复杂点的结构,把几个格式嵌套在一起,建立一个树状结构:

  #|kawa:2|# (* (+ 1 2) (- 3 4))

-3

Scheme 的内建函数以同种机理工作:

  #|kawa:3|# (if (> (string-length "Hello world") 5)

(display "Longer than 5 characters"))

Longer than 5 characters

上面程序中,用一个 if 语句来检查某一特定字符串的长度是否超过 5 个字符,如果像例子中的那样检查结果为真,就会执行紧随其后的表达式,该语句将会打印一条提示信息。注意这里的缩进只是为了增加可读性,如果你愿意的话,可以在一行内写下所有的语句。

Lisp 代码用的这种括号密集(parenthesis-heavy)的风格也称为“S 表达式(s-expressions)”。它可兼作定义结构化数据的通用方法,就像 XML 一样。Lisp 有很多内建的函数,你可以很方便的应用 S 表达式格式操纵数据,这种便利转而促成 Lisp 的另外一个强大优势:既然语法是如此简单,那么编写产生、修改代码的程序也要比其它语言简单得多。当我们演示宏(macros)的例子时,会了解到更多类似情况。

函数

Scheme 通常被看做是函数式程序设计语言大家庭中的一员。与面向对象领域不同,Scheme 抽象的主要手段是函数和它操纵的数据,而不是类和对象。在这里,你所做的每一件事,实际上都是调用一些带有参数、能够返回运行结果的函数。你可以通过 define 关键字来创建函数:

  #|kawa:4|# (define (add a b) (+ a b)) 

以上代码定义了一个 add 函数,它接收 a 和 b 两个参数。函数体简单地执行加法(+)计算后自动返回执行结果。注意这里没有静态的类型声明,所有的类型检查都在运行时进行,这同其它动态语言中的方式并无二致。

定义了上面函数后,你可以很简单的在 REPL 中调用它:

  #|kawa:5|# (add 1 2)   3 

在 Scheme 的世界里,函数是一等公民,它可以像 Java 中的对象一样被传递,这开启了一些非常有趣的可能性。下面我们将创建一个函数,它接收一个参数,并使它的值增加一倍:

  #|kawa:6|# (define (double a) (* a 2)) 

然后通过调用 list 函数定义一个包含三个数字的列表:

  #|kawa:7|# (define numbers (list 1 2 3)) 

下面是最令人兴奋的部分:

  #|kawa:8|# (map double numbers)   (2 4 6) 

此处调用了带有两个参数的 map 函数:一个参数是个函数,另外一个参数是个列表(list)。map 函数将遍历列表中的每个元素,将其作为参数调用所提供的函数,最后将所得结果组成一个新列表 (list),正如我们在 REPL 中所看到的。这是可以实现 Java 中 for 循环功能的更加函数化的方法。

LAMBDAS

还有一个比较方便的地方在于可以利用 lambda 关键字定义匿名函数,这与 Java 匿名内部类工作机制类似。重新写上面的例程,跳过中间定义 double 函数那一段,map 语句可写成如下形式:

  #|kawa:9|# (map (lambda (a) (* 2 a)) numbers)

(2 4 6)

定义仅返回 lambda 的函数也是有可能的,经典教科书中的例程会这样写:

  #|kawa:10|# (define (make-adder a) (lambda (b) (+ a b)))

#|kawa:11|# (make-adder 2)

#

上面的语句都做些什么事情呢?首先定义了一个名为 make-adder 函数,它带有一个参数 a,返回一个匿名函数,该匿名函数要接收另外一个参数 b。当调用发生时,匿名函数会计算 a 与 b 的和。

执行 (make-adder 2)——或者通俗的说“给我一个函数,可以把 2 加到传给它的参数上”,REPL 将显示一些代码,它实际上是把 lambda 过程作为一个字符串打印出来,要用这个函数你还可以这样写:

  #|kawa:12|# (define add-3 (make-adder 3))

#|kawa:13|# (add-3 2)

5

此处最为重要的事情在于 lambda 作为一个闭包执行。把它“封装”起来,保持它被创建时对作用范围内变量的引用。(make-adder 3) 调用后,作为返回结果的 lambda 保有 a 的值,当 (add-3 2) 执行时,它计算 3+2 的值,并返回预期的 5。

宏(MACROS)

到目前为止所看到的特性都和我们在比较新的动态语言中发现的相类似,例如 Ruby,它也允许你使用匿名块处理对象收集,正如我们在前面用 lambda 和 map 函数所做的一样。所以,现在让我们来转变一下方向,看看独属于 Lisp 的特性:宏(macros)。

Scheme 和 Common Lisp 都有宏系统。人们在提到 Lisp 时总说它是“可编程的程序设计语言”,其实指得就是这个。有了宏,你实际上就可以和编译器建立关联,重新定义语言本身。此时 Lisp 统一的语法才真正开始挥效用,所有的事情都变得有趣起来。

举个简单的例子,我们可以看一下循环。在 Scheme 语言中,最初并没有定义循环,典型的对某集合进行迭代的方式是使用 map 或者递归函数调用。多亏有一个编译器小窍门——尾调用优化递归(tail-call optimizations recursion)——可以采用而不必担心会挤爆栈。下面将介绍一个非常灵活的 do 命令并应用它来执行一个循环,实现的程序如下:

(do ((i 0 (+ i 1)))

((= i 5) #t)

(display "Print this "))

上面程序中定义了一个索引变量 i,初始化为 0,设置按照增量 1 迭代增长。当表达式 (= i 5) 的值为真时,循环中止,返回 #t(它和 Java 中的布尔值 true 相当)。在循环里我们只是打印了一个字符串。

如果我们所需要做的只是一个简单的循环,上面这个例子就有很多冗余的公式化代码了。在很多情况下更可取的应当是简单直接的实现方式:

(dotimes 5 (display "Print this")) 

多亏了宏(macros),才有可能适当地使用称为 define-syntax 函数,把关于 dotimes 的特殊语法添加进语言:

(define-syntax dotimes

(syntax-rules ()

((dotimes count command) ; Pattern to match

(do ((i 0 (+ i 1))) ; What it expands to

((= i count) #t)

command))))

执行上述命令可以告诉系统,任何对 dotimes 的调用都要被特别对待。Scheme 将用我们定义的语法规则匹配一个模式,并在将结果送到编译器之前将其展开。在这个例子中,模式是 (dotimes count command),它被转换为标准的 do 循环。

在 REPL 中执行该语句,你会得到如下结果:

#|kawa:14|# (dotimes 5 (display "Print this "))

Print this Print this Print this Print this Print this #t

上述例子之后必然产生两个问题。第一,为什么我们需要使用宏(macro)?用一个常规的函数不能做这些事情么?答案是“不可以”。任何对函数的调用实际上在开始之前都会触发对它所有参数的求值操作,在上面的例子中就不会发生这种情况。比方说,你怎样处理 (do-times 0 (format #t "Never print this")) 呢?当求值需要被延迟时,只有宏(macro)才能完成这个功能。

其次,我们在宏里用了变量 i,如果在 command 表达式中碰巧有一个变量取相同的名字,这会不会产生冲突呢?这点不必担心,Scheme 的宏以“卫生”著称。编译器会自动检测并熟知如何处理这样的命名冲突,对程序员是完全透明的。

了解到这些情况后,试想一下在 Java 中添加你自己的循环结构,这近乎不可能。也可以说,不是非常可能,毕竟编译器是开源的,所以你可以自由下载并恰当使用,但这真的是一个不太现实的选择。在其它动态语言中,闭包可以给你多些自由,对语言按照自己的习惯做些改动,但是仍然存在这种情况:他们的结构并没有足够灵活和强大到可以让你自由调整语法的程度。

这种能力就是为什么每当元编程语言或特定领域语言被提及时,Lisp 总是以胜利者姿态出现的原因。Lisp 程序员长期以来一直是彻头彻尾的“自底向上编程(bottom-up programming)”的冠军,因为当语言本身已经被调节为适合你的问题领域时,障碍会少许多。

在 Java 中调用 Scheme 代码

将别的语言运行在 JVM 之上的一个主要好处是,不管代码用何种语言写成,都可与现存的应用进行整合。因此很容易想象,可以用 Scheme 来模型化一些复杂的具有易变趋势的业务逻辑,然后将它嵌入一个比较稳定的 Java 框架中。规则引擎 Jess(www.jessrules.com)是一个很好的范例,它运行于 JVM 之上,但是用自己的类 Lisp 语言来声明规则。

但是让不同的程序设计语言以一种界限清晰的方式协同工作还是一个棘手的问题,尤其是像 Java 和 Lisp 这样存在天壤之别的语言。如何做这种整合并没有标准,所有活跃在 JVM 上的方言都以不同的方式处理着问题。Kawa 对于 Java 整合的支持相对较好,所以在下面的例子中,我们将继续用它来研究怎样用 Scheme 代码来定义一个 Swing GUI。

在 Java 程序中运行 Kawa 代码是很简单的:

import java.io.InputStream;

import java.io.InputStreamReader;

import kawa.standard.Scheme;

public class SwingExample {

public void run() {

InputStream is = getClass().getResourceAsStream("/swing-app.scm");



InputStreamReader isr = new InputStreamReader(is);

Scheme scm = new Scheme();



try {

scm.eval(isr);

} catch (Throwable schemeException) {

schemeException.printStackTrace();

}

}

public static void main(String arg[]) {



new SwingExample().run();

}

}

在这个例子中,首先会在类路径上寻找包含 Scheme 程序的叫做 swing-app.scm 的文件,然后创建解释程序 kawa.standard.Scheme 的实例,调用它来解释文件中内容。

Kawa 还不支持在 Java 1.6 中引入的 JSR-223 规定的脚本 APIs(javax.scripting.ScriptEngine 等),如果你需要能做这种事情的 Lisp,最好的选择应该是 SISC。

在 Scheme 中调用 Java 库

在我们开始写大型 Lisp 程序之前,是时候找个比较合适的编辑器了,否则光是验证括号匹配的工作就够让人发疯了。最受欢迎的选择之一肯定是 Emacs,毕竟它可用自己的 Lisp 方言进行编程,不过对于 Java 开发者继续使用 Eclipse 可能更舒服些。如果你是这种情况就需要在工作开始之前先安装一个免费的 SchemeScript 插件。你可以在这个网站找到它。这里还有一个称为Cusp 的插件,可以用于 Common Lisp 的开发。

现在,我们可以来看一下 swing-app.scm 的具体内容,以及用 Kawa 定义一个简单的 GUI 都需要做什么样的工作。这个例子将会打开一个带有按钮(button)的 frame,按钮点击一次后它就会被禁用。

 (define-namespace JFrame )

(define-namespace JButton )

(define-namespace ActionListener )

(define-namespace ActionEvent )

(define frame (make JFrame))



(define button (make JButton "Click only once"))

(define action-listener



(object (ActionListener)

((action-performed e :: ActionEvent) ::

(*:set-enabled button #f))))

(*:set-default-close-operation frame (JFrame:.EXIT_ON_CLOSE))



(*:add-action-listener button action-listener)

(*:add frame button)

(*:pack frame)

(*:set-visible frame #t)

最初几行用 define-namespace 命令为将要用到的 Java 类定义缩略名,这同 Java 的 import 声明功能类似。

然后定义了 frame 和 button,利用 make 函数可以创建 Java 对象。创建 button 时,我们提供一个字符串作为参数传给构造函数,Kawa 可以很智能的将它翻译成需要的 java.lang.String 对象。

现在让我们跳过 ActionListener 的定义,先来看一下最后 5 行代码。这里的符号 *: 用于触发对象中的方法。例如,(*:add frame button) 的功能就等同于 frame.add(button)。你要注意到 Scheme 特有的,可以自动将方法名从 Java 中的骆驼拼写风格转换为小写的以连字符分隔单词。例如,set-default-close-operation 将被转换为 setDefaultCloseOperation。这里另外一个细节是:. 可被用来访问静态域,(JFrame:.EXIT_ON_CLOSE) 等同于 JFrame.EXIT_ON_CLOSE。

现在来回头看一下 ActionListener。这里用 object 函数创建了一个实现了 java.awt.event.ActionListener 接口的匿名类,action-performed 函数被用来调用 button 上的 setEnabled(false) 方法。此时还需要添加些信息可以让编译器知道 action-performed 是 ActionListener 接口中定义的 void actionPerformed(ActionEvent e) 的实现。早先我们曾经说过,正常情况下在 Scheme 中并不需要类型,但是此时,当与 Java 协同工作时,编译器就需要多知道一些信息。

当你有了这两个文件后,编译 SwingExample.java,并且确认将编译后的类和 swing-app.scm 文件放到类路径上,接下来就可运行 java SwingExample 来看看 GUI 的效果。你同样也可以用 load 函数: (load "swing-app.scm") 在 REPL 中执行文件中的代码,这开启了动态操纵 GUI 构件的先河。例如,你可以通过在 REPL 中执行 (*:set-text button "New text") 来快速更改 button 上的文字,而且可以立即看到修改结果生效。

当然,这个例子只是想简单的演示如何从 Kawa 中调用 Java,无论如何它都不是你能想象中的最优质的 Scheme 代码。如果你确实想要在 Scheme 中定义一个大型 Swing UI,那你最好提升一点抽象级别,用一些精选的函数和宏来隐藏凌乱的整合代码。

资源

真心希望我的文章能引起你对 Lisp 的些许兴趣。请相信我,还有大量有待探索的东西。如果想了解更多内容可以查看下列资源:

关于作者

Per Jacobsson 是位于洛杉矶的 eHarmony.com 的软件架构师,应用 Java 已有 10 年历史,近两年成为 Lisp 的狂热爱好者。你可以通过pjacobsson.com与他取得联系。

查看英文原文:Exploring LISP on the JVM


志愿参与 InfoQ 中文站内容建设,请邮件至editors@cn.infoq.com。也欢迎大家到InfoQ 中文站用户讨论组参与我们的线上讨论。