Lisa Language

The introduction of Lisa.

Nov 17, 2019

见习魔法师

What is Lisa?

Lisa 是用Scala写的一门Lisp方言及其解释器,运行在JVM上。 因为是Lisp的方言,Lisa是动态类型的。 Lisa的语法受到了Lisp和Elixir的启发,和两者有许多相似之处(抄了很多)。 我们知道,一门解释性的语言的性能是很弱的(尤其是动态类型的语言),因此Lisa不是为了性能而生的,她在一开始是作为一门JVM上的胶水语言而设计的,为了能方便调用Scala/Java编写的API。我们知道,一个正常人很难喜欢上用Lisp系语言写工业代码,因此,Lisa不是为了成为一门工业级代码而设计的,甚至不是为了让人类编写代码而设计的。那你猜猜是谁要去编写Lisa代码呢?

本文是Lisa的一个简要文档,如果你有Lisp或Erlang/Elixir的背景的话,你应该能很快理解。

基本类型

  • 1 => Int
  • 2.3 => Float
  • "s" => String
  • false => Bool
  • :atom => Atom

复合类型

Seq, List

序列和列表可以通过内建的函数seqlist构造。

  • list: 构造一个 List
  • seq: 构造一个 scala.collection.immutable.Vector

(seq 1 2 3) => #Scala(Vector(1, 2, 3))

(.[0] s) => 访问序列的 [0] 元素,同理,.[1]访问[1]元素……

你也可以使用nth来访问序列里的元素。如果下表越界,会抛出一个IndexOutOfBoundsException(nth s 1) 访问序列1元素(第二个元素)。同时,你也可以传入第三个参数,为不存在该元素时的默认值。另外,get函数会在当元素不存在的时候返回()

在大部分lisp方言中,()'() 是相等的,lisa也是如此。在大多数情况,()会被认为是一个空列表,但是()'()不是同一个引用。因此,你可以使用same-reference?来区分他们。

列表和代码

在Lisa中,列表(List)和代码其实没有本质区别,列表可以是可执行的代码,代码也可以是一个列表。列表会被求值(evaluate),直到成为环境中不可再规约的值。 可以通过引用(quote)来避免对一个列表进行求值。quote在语法上就是相应的表达式前加上'。 存在两种quoting:

  • Quoting 避免代码的求值,在表达式前加'
  • Quassi Quoting 避免部分代码的求值,允许部分代码被求值,语法为在表达式前加`'。

为了在QuassiQutation中插入被求值的代码,需要使用UnQuote操作,语法是在表达式前加~。 为了在一个list中拍平另一个list,需要使用UnquoteSplicing操作,语法是在表达式前加~...

例如:

(define a 1) ; Defined a variable a to 1
'(define a 1) ; A list contains 'define 'a and 1
'(1 2 3 4) ; A list contains 1 2 3 and 4.
(1 2 3 4) ; Oops, can not apply 1 to '(2 3 4)
`'(~a 2 3 4) ; '(1 2 3 4) because a is bound to 1
`'(1 ~...(range 2 4) 4) ; '(1 2 3 4) flatten (2 3) to the list.

Quote 在宏中发挥了至关重要的作用,宏的目标就是运行时生成代码,并插入被调用的地方求值。

记录类型(Record)

记录类型和Map十分相似。

可以通过 record 函数构造. (record 'a 1 'b 2) => 构建一个记录,类似于{a: 1, b: 2} record的键可以是一个字符串,也可以是一个symbol,同样值的stringsymbol代表着同样的键。 记录 sa属性可以通过以下方式访问:

  • (.a s) 以Java/Scala 访问对象属性的方式
  • (s 'a) 以Lisa调用对象方法的方式。
  • (get s "a") 通过get函数

get函数可以用以下方式调用: (get map key) (get map key not-found)

  • map 是一个record
  • key 是一个键
  • not-found 是record没有这个键时返回的值,默认为()

(record-contains? record key) 检查一个记录里面有无指定的键,返回一个Bool。 (record-updated record key value) 更新一个记录。

数值计算

和Elixir/Python一样,整数和小数都是高精度的。同时,Lisa支持有理数运算。

(/ 2 3) ; => 2/3
(* (/ 1 2) (/ 3 4) (/ 5 6)); => 5/16
(* (/ 5 16) 0.1) ; => 0.03125
(+ (/ 1 2) (/ 3 4)); => 5/4
(int (/ 5 4)); => 1

(< 2 3 4) ;=> true
(< 2 4 3) ;=> false, because 4 is greater than 3
(<= 1 1 1 1) ; => true

语法

Scheme语法基本一致。

(define n 0) ; 声明一个变量绑定
(define (cons x y) ; 定义一个函数
    (lambda (m) (m x y)))
(define car ; 等同于声明一个lambda表达式值。
    (lambda (m)
        (m (lambda (x y) x))))

(define (fact n)
    (define (go n x) ; 在函数内定义函数不会影响外部作用域。(函数会引入一个新的作用域)
        (println! "Computing:" n) ; 使用 println! 来显示若干字符串,用空格分割。
        (if (= n 0) x (go (- n 1) (* n x)))); 函数中最后一个表达式是返回值。
    (go n 1)); 调用一个内部定义的函数。

(define (fib n)
    (cond ((< n 2) n)
        (else (+ (fib (- n 1)) (fib (- n 2))))))

(let ((a 1) (b 2)) (+ a b)) ; <=>
((lambda (a b) (+ a b)) 1 2)

特殊变量

由于和Java的互操作性,点(.)开头的变量有特殊的含义,表示访问这个对象的某属性或者调用方法。

(.length "hello!"); <=> "hello!".length => 6
(.startsWith "Lisa" "L" );  =>"Lisa".startsWith("L")
(.[1] (seq 1 2 3)) ; => (new int[] {1, 2, 3})[1] => 2

函数参数模式匹配

和Elixir相同的是,函数形参赋予实参的时候,是通过模式匹配实现的。

倘如我们写一个阶乘函数:

(define (fact 0) 1)
(define (fact n) (* n (fact (- n 1))))
甚至不需要解释,这个表达式就像是数学公式的直译。

这种函数在Lisa中被称为多态函数,每一个情况被称为分支(branch)。

在模式匹配中,相同的变量代表相同的值(对于闭包或者函数,则是代表相同的对象)。 比如写相等函数可以这么写:

(define (equals? x x) true)
(define (equals? x y) false)
和Elixir一样,分支从定义开始从上往下匹配,因此可能遇到部分分支永远无法匹配的状况,因此Lisa要求先定义边界情况。 而且仅仅定义的函数在同一个作用域(scope)下有同名的函数时,才会生成多态函数,否则会生成全新的函数去覆盖(shadow)。 这样设计是为了防止在函数中定义函数时,因为外部已经定义好的函数的影响从而发生一些意料之外的事情。

特别地,当你想要匹配在上下文中相同的变量时,可以使用反引号

(define x 0)
(define (zero? `x`) true) ; 仅仅匹配`x` (0)
(define (zero? x) false) ; 匹配任意值

Pattern Matching Guard

模式匹配最后一个参数可以是守卫,代表匹配到的参数必须满足的要求。形如(when <predicate>),这里的when可以是:

  • when 代表这个表达式必须成功被执行,否则返回失败。
  • when? 表示如果该表达式无法执行,就忽略这个分支,往下面定义的同名函数中寻找匹配。

when?在标准库中常被用来判断类型(如果一个对象满足某种约束,他就属于那个类型)。

(define (fact 0) 1) ; Nothing interesting.
(define (fact n (when (> n 0))) (* n (fact (- n 1)))) ; 当n > 0的时候才会匹配
(fact 5) ; => 120
(fact -5) ; => No matching procedure to apply

尾调用优化

Lisa能进行尾调用优化。当一个函数的最后一个表达式是调用另外一个函数的时候,栈深度不会增加。

注意:和大部分具有尾调用优化的语言不同的是,如果最后一个表达式是if的话,不会对该函数进行尾调用优化。因为if语句在Lisa中不是一个控制语句而是一个表达式。

例如:

(define (overflow-fact n)
    (define (f n x) 
        (if (= n 0) x (f (- n 1) (* n x)))) ; 这里不会进行尾递归优化
    (f n 1))

(overflow-fact 1000) ; => Boom!

幸运的是,在大多数情况下,因为Lisa存在模式匹配,你不大需要用到ifcond

(define (fact n)
    (define (f 0 x) x)
    (define (f n x) (f (- n 1) (* x n))) ; 最后一个语句是调用一个函数,此处会进行尾递归优化。
    (f n 1))

(fact 1000) ; => 这里不会栈溢出

匹配一个列表

序列可以通过seq构造,也能通过(<rep1> <rep2> ...)来匹配。 可以用(... args)匹配剩余的所有元素,并将其绑定到args参数。 当剩余参数只是一个symbol的时候,(... args)可以用...args替换。

(define (sum-list (head (... tail))) (+ head (sum-list tail)))
(define (sum-list ()) 0)

相同地,如果要将一个列表的值拍平传给一个函数,也可以使用...。 例如:

(define ls '(1 2 3 4 5))
(+ ...ls) ; 15
(+ (... (map ls string))) ; "12345"

Ranges

范围(Range)代表了一些可迭代的数字,可以用rangerange/inclusive函数构造。 可以传入构造的区间和步长。

(define 1-5 (range 1 6)) ; [1, 6)
(define 1-5-odds (range 1 6 2)) ; [1, 6),步长为2

(define 1-5 (range/inclusive 1 5))
(define 1-5-odds (range/inclusive 1 5 2))

(list ...1-5) ; (1 2 3 4 5)

副作用

Lisa支持直接改变一个可变变量的值。不同的是,你需要通过define-mutable!宏声明一个可变变量。 如果当前上下文中存在同名变量,该变量会被初始化为相同的值,否则初始化为()

你可以通过set!宏来改变可变变量的值,但是你不能改变不可变变量的值。

(define (make-counter init)
    (define-mutable! init)
    (lambda ()
        (set! init (+ init 1))
        init))
(define c (make-counter 0))
(c); => 1
(c); => 2
(set! c 3) ; set! Error: Can not assign an immutable value c.

定义宏

在Lisa中,定义一个宏在形式上类似于定义一个具名函数。(define-macro (name args*) body*)。宏的每一个参数按照原本的样子,不经过计算被传入。 宏最后应当返回一个被引用的表达式,该表达式会在被调用的地方求值。

(define-macro (unless predicate consequence alternative)
    `'(if ~predicate ~alternative ~consequence))

; It is also possible to define a polymorphic macro using pattern matching.

(define-macro (reversed-apply (f x y))
    `'(~f ~y ~x))
(define-macro (reversed-apply (f x))
    `'(~f ~x))

(reversed-apply (- 2 3)) ; => (- 3 2) => 1
(reversed-apply (- 2)) ; => -2

正因为宏参数的解析也是通过模式匹配,因此被引用的符号可以直接被视为关键词。

(define-macro (is a 'equals 'to b) `'(= ~a ~b))
(define-macro (is a 'not 'equals 'to b) `'(if (is ~a equals to ~b) false true)) 

(is 1 equals to 1) ;=> (= 1 1) => true
(is 1 not equals to 2) ;=> (if (is 1 equals to 2) false true) => true

注意,虽然在宏定义中,模式匹配守卫还是可以使用的,但是因为这些表达式没有被计算,因此你不能获取他们真实的值。虽然你可以用eval来获取计算后的值,但是在宏被展开后可能会有意想不到的重复计算。

定义短语

在Lisa中,你可以定义一些短语。这些短语可以被用作隐式转换或者自定义语法等功能。

(define-phrase (args*) body*)
等价于
(define-macro (`&__PHRASE__` args*) body*)

&__PHRASE__宏会在当前表达式没有合法的结果,并且定义了&__PHRASE__宏的时候被自动应用。

注意和宏定义不同的是,在新的作用域中使用define-phrase 定义一个新的宏不会覆盖之前的值,相反会创建一个新的分支。

(define-phrase (a '+ b) `'(+ ~a ~b)) ; <==>
(define-macro (`&__PHRASE__` a '+ b) `'(+ ~a ~b))

(3 + 2) ; 会被转换为 
(`&__PHRASE__` 3 + 2) ; 因为 3 不能应用到 (+ 2) 上

; 更加通用的版本(使用守卫确保了中缀的操作符是callable的)
(define-phrase (a f b (when (callable? f))) `'(~f ~a ~b))
; callable? 的定义参见 prelude.lisa.

("hello" .startsWith "h") ; => (.startsWith "hello" "h") => true

语法糖

匿名函数字面量

匿名函数可以通过在表达式前面加上 & 来创建。 其中以 # 开头的,后续跟着数字的变量 被视为形参。通过其后面的编号排序。 单独的#被视为#0

&(+ #1 #2) ; 等价于
(lambda (#1 #2) (+ #1 #2))

&(* # #) ; ==>
(lambda (#) (* # #))

&(- #3 #1) ; ==>
(lambda (#1 #3) (- #3 #1)) ; 和Elixir不同的是,Lisa不会检查缺失的编号。
第二种匿名函数字面量是为了限制不定参函数的元数(arity),比如+。 语法形如 &<func_name>/<arity>. &+/2 会被解释成 (lambda (arg0 arg1) (+ arg0 arg1)),限制元数可以让函数柯里化,因为定参的函数可以通过length获取元数。 注意 如果不给匿名函数指定形参,他会被解释成常函数,该函数接受任意参数并返回定值。例如: &1 等价于 (lambda ((... _)) 1).

Java互操作

在大多数情况下,Lisa可以很处理Scala库定义的数据结构,因为他们会被自动转换为Lisa对象。如果要转换Java的容器,使用from-java函数。除了使用点开头的变量来访问对象的方法以外,你还可以使用new来想Clojure一样调用构造方法。如果你十分想念doto宏,你可以自己写一个,如:

(define-macro (doto ex (... ops))
    (define sym (gen-sym))
    (define ops-with-obj 
        (map ops (lambda ((fn (... args))) (cons fn (cons sym args)))))
    `'(let ()
        (define ~sym ~ex)
        ~...ops-with-obj
        ~sym))

这样,你可以这么写代码:

(define array-list (doto (new ArrayList) (.add 1) (.add 2))) ; WrappedScalaObject[ArrayList]
(define wrapped-list (from-java array-list)) ; WrappedScalaObject[Iterable]
(define lisa-list (iter wrapped-list)) ; '(1 2)

null

Lisa不欢迎null,但是为了Java却必须使用null,因此lisa提供了一个字面量null,代表了jvm中null的概念,在lisa代码中不推荐使用,除非在和Java交互。

因为null在lisa中是字面量,你可以根据pattern matching性质,编写拒绝null的函数。

(define (not-null null) (panic! "Argument should not be null."))
(define (not-null x) x)

代码文档

Lisa约定函数的第一行如果是一个字符串字面量,则该字符串就是该函数的文档。 你可以通过help函数来查看函数的文档。

逻辑编程(实验性功能)

最后

听起来Lisa像这篇文章中的一种实现,其实实际的顺序恰恰相反。作为一门玩具语言,Lisa的性能实际上并不好,直接解释AST,没有字节码的屑语言。但是我好像找到了她的发展方向。

Q&A

为什么这个文章有如此浓重的翻译腔,却没有指出原作者呢?

翻译自己写的东西都能翻译得那么烂确实是我的问题,但是这有指出的必要吗?

我是一个初学者,请问对学习Lisa有什么建议吗?

别学。

计划(坑)

  • 中文文档 (本文章)
  • 编译成Javascript
  • 尾递归优化 (完成,详见#8)
  • 编写UT (部分完成 #9
  • Code as Data, Data as Code (#10
  • 变量声明 (PR 10)
  • 参数按名调用
  • 访问Java静态类属性,方法 (完成,见PR#11
  • 反射调用构造函数 new(同PR#11)
  • 给予是否转换参数的控制权限 (在PR#10中,实现了object/module粒度的控制,之后会实现方法甚至参数粒度的控制) 在PR#11中,实现了method粒度的控制。

r{{ changeExtraDisplaySettings({blurMainImage: true}) }}