JSR223是一种Java标准,它为JVM上的非Java语言(主要是脚本语言)提供了一组标准接口。
目前几乎所有JVM脚本语言都会提供一个JSR223的实现,甚至对于groovy这种编译到字节码的语言也做了一套实现(虽说groovy确实就是脚本语言)。使用该实现最大的好处即:它是标准。
这套接口其实非常普通,如果你是一种JVM语言的编写者,并且想做一个REPL程序,那么一定会设计出类似的接口。所以与其自己设计一套API让别人适配,还不如自己适配这套接口,这样他人使用也会更加方便。
下面我会从我的视角说一说这套接口。刚才提到,这套接口在做REPL程序时会被用到,所以,首先谈谈何为REPL。
REPL
何谓REPL?Read Eval Print Loop,即:读取输入,解析运行,打印结果,继续循环。
现代编程语言通常都会提供一个REPL,例如scala、groovy、haskell、nodejs、python等。
使用REPL可以非常方便的执行一些语句,以此来尝试一些没有用过的功能,而不必单独开一个文件编写代码。Java在9版本时也会推出jshell,提供一个java的REPL环境。
通常REPL会读入一行或多行代码,在输入完成后立即编译或者解析执行这段代码,然后将执行结果打印在屏幕上,最后继续等待用户输入新的代码。在这过程中,老代码的执行结果将被保存在上下文中,例如变量的赋值、方法的定义等。在后续的代码中可以读取或调用之前定义的变量或方法。
此外,REPL还可能提供一些更高级的功能,例如显示一个变量的类型(例如scala、haskell的:t
);或者提供一些只有REPL中才可能出现的功能,例如退出REPL、重置REPL的上下文等。
例如scala的REPL:
1 | Welcome to Scala 2.12.1 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_121). |
接口设计
最基本的,REPL需要能够编译,或者解析源代码,所以至少会有一个接口,专门处理解析相关的工作,我们不妨称之为Evaluator。
Evaluator要做的事情即:输入源代码、编译/解析、执行、返回结果。所以,如果用Java来写,可能会是这样:
1 | // Evaluator 版本1 |
其实,如果实现了这个接口,那么REPL的最基础部分已经完成了。加上一堆循环、输入、输出的控制代码,REPL就出炉了。但是这么搞肯定不会有人去使用,毕竟功能太单一了,别说高级功能,甚至连上下文都没办法保存,打了半天字才定义的一个方法,后续连使用都不行。
所以,我们至少还得加上上下文存储。
由于我们写的是JVM语言,那么上下文中的对象应当能够被Java读取(不一定要能任意使用,但是至少要能够正常的取出来,正常的赋值和传递),所以我们需要一个接口定义上下文。
通常来说,语言会有一些作用域,无论是js的函数链式作用域,还是c系的块式作用域,还是let expression,总之通常都会有作用域这一说。特别是对于脚本语言,会有一大堆“pre defined”的对象或函数,例如nodejs中的module
、php中的$GLOBALS
等。
对于编译型的语言,这种pre defined会相对少一些,因为设计和实现编译型的语言工作量相比脚本语言稍高一些,也会尽可能让语言自洽。例如scala的
println
,实际上是自动的在所有源代码中,对某个标注库中的类做了import static
。
既然有作用域的区分,那么我们可以定义Scope
接口,来存储本作用域下的变量和函数。
1 | interface Scope { |
前面提到了作用域的层级。那么,若要让作用域分层,通常有两种做法:
在Scope实现中保存父级作用域的引用,当做取操作时,首先检查本作用域中是否存在该变量,若没有,则返回父级作用域中的该变量。若已经是顶级作用域(没有父级作用域了),那么再返回
null
,或其他表示不存在的值。在更高的一个层级而非Scope本身做这件事情:用数字表示作用域级别,数字越大则作用域级别越高。在寻找时从低级别的作用域开始,向高级别作用域进行查询,直到找到值,或者全部作用域都搜过为止。
由于接口没有办法决定实现,且第一种的实现,说实话确实代码量有点多,所以综合考虑,使用第二种方式定义接口。我们可以将拥有“管理作用域”功能的实例称为上下文。
1 | interface Context { |
上述接口,定义了获取、设置、列举作用域的方法,以及用于获取和存放值的方法。
接下来我们把上下文塞进Evaluator里面。
1 | // Evaluator 版本2 |
如此一来,我们的Evaluator就有了保存上下文的能力,在REPL程序中,也可以针对上下文中保存的内容实现一些高级指令。
到这里,实现一个REPL需要的接口已经全部定好了。不过,repl很可能需要提供重置上下文的功能,这里最简单的做法是:使用setContext替换掉当前上下文。不过对于支持多线程的语言来说,若REPL的E步骤正在进行,直接替换上下文很可能会使其状态不一致。
所以最好重新生成一个新的Evaluator。重新生成时可以直接new,但是初始化过程就要再写一遍代码。其实我们可以写个Factory,用来生成Evaluator,在每次需要新Evaluator时直接调用Factory的函数即可。
1 | interface EvaluatorFactory { |
到此,一个维护性比较好的接口已经出炉了。当然,对于一个REPL程序来说可能用不到这样的设计,但是,不管是像上面这样做到职责单一,还是把所有东西混在一起,这几个接口包含的内容,是一个拿的出手的REPL必须全部实现的、比较基础的功能。上面这样的接口设计,也是比较直观,功能清晰,且易于实现的。
JSR 223
JSR 223作为一个调用脚本语言的接口框架,自然要比“实现REPL”支持更多的功能、定义更多的规则。
上面的Evaluator
,即脚本的执行者,在JSR 223中被称作ScriptEngine
。EvaluatorFactory
,即用于创建脚本执行者的工具,被称作ScriptEngineFactory
。定义上下文的接口,被称作ScriptContext
,定义作用域的接口,被称作Bindings
(没错,末尾有个s)。
JSR 223的想法是,需要将脚本语言中的对象,和Java能够获取的对象,做一个绑定,所以在我的思路中的作用域,被称作了Bindings。
由于JSR 223是一个框架,所以除了接口之外,还有一些做实事的类,例如ScriptEngineManager
,用于加载脚本语言、获取和初始化ScriptEngine等事情。
JSR 223 规范的具体文档可以在JCP官网下载:这个链接,规范比较清晰,且整体结构和我思路中的Evaluator极其相似,所以本文不再对其做过多说明。
Latte-lang JSR 223 支持
Latte-lang是我之前写的一个编译型语言,它也支持在运行时编译代码并执行,所以我也做了一份JSR223实现。
之前没有想过要做完善的脚本支持(毕竟它是编译型的),所以只提供了一套非常简易的脚本执行库。直到阅读JSR223规范后,才发现,JSR223做的事情竟然和我之前实现的Evaluator做的事情完全一致。正好,之前的Evaluator实现得有点乱,于是我用JSR223的接口重写了Evaluator,顺便也提供了标准支持。
在Java中调用Latte-lang可以这么写:
1 | ScriptEngine engine = new ScriptEngineManager().getEngineByName("Latte-lang"); |
看起来还是挺方便的~