听说你想写个 Lisp 解释器 - read

2021-07-31  本文已影响0人  微微笑的蜗牛

大家好,我是微微笑的蜗牛,🐌。

这个系列的文章,我们将来动手实现一个小型的 Lisp 解释器,使用 Swift 编写。至于写解释器的缘由呢,是因为前些天看到一篇国外的文章,里面比较详细的介绍了如何编写自己的 Lisp 解释器。以前也没有弄过这方面的东西,出于学习的目的,读完文章后,动手实践了一番。

说起来,只读文章和动手写的差别还是相当大的。在读文章时,你会发现大概思路我都懂了,但是细细一想,对有些小细节如何实现还是有点疑惑;而在实践过程中,必须得去弄清楚每个细节(不然就是照抄代码了🤩),从中会发现一些意外并令人惊喜的点,甚至突然有种热血沸腾的感觉。

比如 lambda 的实现,它是一个匿名函数,那如何实现在声明后就立即调用的呢?这个点其实有点困扰我,因为我在实现代码中并没有看到直接调用。后来仔细一调试,才发现其精妙之处。

下面,就让我们开始这段旅程吧~

LISP 简介

Lisp 是一种古老的语言,其全称为 List Processor,它是一种函数式编程语言,该语言的主要数据结构就是 list。现在由它也衍生出了很多变种,比如 Common Lisp、Scheme 等。

Lisp 中开创了一些先驱概念,比如 REPL,读取-求值-循环输出。我们的解释器就构建在该模型之上。

Lisp 的代码组成很简单,只包括 ()字符三种类型。它由原子 atom 或者列表 list 组成。

另外,函数调用也是列表形式:

// f 是函数名,a, b, c 分别是三个参数
(f a b c)

接下来,我们会根据 MicroManual-LISP 的定义来实现它。不过,也不是全部。先来看看 Lisp 中定义的操作符。

操作符

quote

// 返回 x
(quote x)

atom

// 当 x 是 atom 或者是空 list 时返回 true;否则返回空表 ()
(atom x)

equal

// 判断 x,y 是否相等
(equal x y)
// true
(equal a a)

// ()
(equal a ())

car

// x 是一个列表,返回第一个元素
(car x)
// 结果:x
(car (x y))

cdr

// x 是一个列表,返回除第一个元素外,其余的元素组成的列表。也就是剔除掉第一个元素
(car x)
// 结果:(y z)
(car (x y z))

cons

(cons e list)
// (x y z)
(cons (x) (y z))

cond

// p、e 为表达式
(cond (p1 e1) ...(pn en))

具体操作为:

  1. 首先对 p1 求值,如果为 true,则计算 e1 的值返回;否则继续求值 p2。
  2. 若 p2 的值不为 true,继续求值 p3,以此类推,直至到 pn。
  3. 若没有满足条件的 p,则返回空列表 ()。
// second 
(cond ((equal a b) (quote first)) ((atom a) (quote second)))

计算步骤如下:

lambda

// v1~vn 是参数,它的值会被 e1~en 替换,e 是函数体表达式
((lambda (v1 ... vn) e) e1 ... en)

(v1 ... vn) 是参数,e 是函数体表达式,e1 ... en 的对应参数的值。

在计算 e 表达式时,参数会被替换为具体的值,也就是 v1 = e1,...,vn = en

比如下面这个函数:参数为 x,函数体为 (car x),传入参数值为 (c a b)

// 结果 c
((lambda (x) (car x)) (c a b))

将参数值 (c a b) 带入 x,那么最终计算的表达式为:(car (c a b)),结果为 c

defun

// v1~vn 是参数,e 是函数体表达式
(defun test(v1 ... vn) e)

定义 test 方法,带有一个参数 x

(defun test (x) (car x))

调用 test 方法,传入 (a b)

(test (a b))

REPL

全称是 Read - Evaluate - Print Loop,读取-计算-打印循环。

其模型如下图所示:

REPL 模型

我们将按照这个模型进行实现,其中最重要的两步为:

这篇文章,我们主要来介绍 Read 的实现。

Read 又分为两步:

数据结构定义

由于 Lisp 代码就是各种表达式,而它仅仅只有 atom 和 list 两种类型,且 list 内部可嵌套。那么它的数据结构相对简单:

// 表达式定义
public enum SExpr {
        // 原子
    case Atom(String)

        // 列表,可嵌套
    case List([SExpr])
}

Tokenize

识别输入代码,将其分割为一个个的有效 token。上面我们说过,token 只有三种类型,可用枚举来进行定义。

enum Token {
    // 左括号
    case pOpen
    
    // 右括号
    case pClose
    
    // 字符串
    case text(String)
}

而解析 token 的过程,只需逐个遍历每个字符进行处理。

遍历过程中,只会有如下几种情形:

经过这几种情况的处理,我们可以得到一个 token 列表。

举个例子:"(a b c)",得到的 token 列表为:

[.pOpen, .text(a), .text(b), .text(c), .pClose]

Parse

接下来是进行 token 列表的解析,将其结构化为 AST,其实也就是转换为上面定义的 SExpr 的结构。

最主要的就是列表左右括号配对处理,内嵌列表的递归处理。

举一个简单的例子,比如代码:"(a b c)",转换为 AST 后,应是如下结构:

.List([.Atom(a), .Atom(b), .Atom(c)])

如下图所示:

image

再看一个嵌套的例子,比如代码:"(a (b c))",转换为 AST 后,结构如下:

.List([.Atom(a), .List([.Atom(b), .Atom(c)])])

如下图所示:

image

那么看起来规则挺简单的,遇到 list,将其变为 .List;遇到 atom,将其变为 .Atom。正所谓是,逢山开山,遇水淌水。

总结一下转换规则:

那么如何实现呢?

在遍历 token 时,只有如下三种情形:

解析过程如下所示,其中 parser 表示解析函数。

// 入参:token 列表,父节点
// 返回参数:剩余 token 列表,子节点
parser(tokens, parentNode) -> (remainTokens, node)
image

举个例子,比方说:"(a (b))"tokenize 之后的结果为:

[.pOpen, .text(a), .pOpen, .text(b), .pClose]

其解析过程如下图所示。PS:为了表示方便,图中直接使用了字符串,而非 token。

image

这样我们就得到了 SExpr 的结构,图示如下:

image

到此,Read 的过程就结束啦,相关代码可查看 github

总结

这篇文章主要介绍了如何通过词法分析生成 token 列表、解析 token 生成 ast,最终得到 SExpr 的结构。

下一篇文章我们将介绍如何利用 SExpr 结构进行计算,也就是 evaluation 的过程,敬请期待~

参考资料

上一篇 下一篇

猜你喜欢

热点阅读