BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

探索JVM上的LISP

| 作者 Per Jacobsson 关注 0 他的粉丝 ,译者 张凯峰 关注 1 他的粉丝 发布于 2008年9月5日. 估计阅读时间: 29 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。

当前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中文站用户讨论组参与我们的线上讨论。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我
社区评论

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT