乍听之下,不无道理;仔细揣摩,胡说八道

0%

序言

在绝大多数互联网行业开发者看来,Prolog 不是一门会被用在本职开发工作中的语言。更多的时候,谈论起 Prolog,人们联想到的往往是诸如“逻辑编程”、“人工智能”等词语,将它与 SQL 放在一起,视为一种 DSL,而非像 Java、Python 这样的通用编程语言。因此,我一直很好奇能否使用 Prolog 来开发一些更偏向于业务系统的程序。

答案是肯定的。基于 SWI-Prolog 这个实现和它的标准库,我开发出了一个简单的短链服务,验证了 Prolog 的确可以满足开发一个业务系统的各种功能需求。

Prolog 基础知识

由于本文的大多数读者对 Prolog 应当是比较陌生的,因此在开始讲解如何用它开发一个 WEB 应用之前,必须稍作科普,介绍一下 Prolog 的基础知识,包括但不限于:

  1. Prolog 程序的基本结构;
  2. 运行 Prolog 脚本;
  3. 编译 Prolog 程序。

Hello World

Prolog 是一门语言而不是一个具体的解释器或者编译器,为了可以运行 Prolog 脚本或编译源代码,我选择使用 SWI-Prolog。有了它,就可以运行经典的 Hello World 程序了

1
2
3
4
:- initialization(main, main).

main(_) :-
format("Hello, world!~n").

假设上述源代码被保存在文件hello_world.pl中,那么执行它的命令如下

1
swipl ./hello_world.pl

可以看到它打印出了所期望的文本

hello_world的效果

现在让我来稍微介绍一下上述代码中的细节。:- initialization(main, main).是一个给 SWI-Prolog 的“指示”,可以理解为其声明了程序启动后的入口是一个叫做main的函数。而

1
2
main(_) :-
format("Hello, world!~n").

则是函数main的定义,它调用内置的函数format来打印一个字符串到标准输出。

编译 Prolog 程序

利用 SWI-Prolog 可以像运行 Python 脚本一般来运行 Prolog 程序,当然,也可以像 C 程序一样将其从文本形态的源代码编译为一个独立的可执行文件。仍然以前文的源文件hello_world.pl为例,编译的命令如下

1
swipl --stand_alone=true -o hello_world -c hello_world.pl

效果如下图所示

编译prolog程序

在 C 语言中,被编译的程序的入口是约定俗成的,即函数main。而由于在文件hello_world.pl中用指令:- initialization(main, main).

Prolog 的使用

在开发 WEB 服务的过程中,还会遇到许多与 WEB 无关的、Prolog 自身在其它领域的应用知识,例如:

  1. 如何读写磁盘文件;
  2. 如何处理 JSON 格式的数据;
  3. 如何读写 MySQL;
  4. 如何读写 Redis;

因此在这一章节中,将会分别介绍在 Prolog 中如何做到上面的这些事情。

读取磁盘文件

要读取磁盘文件的全部内容,可以使用 SWI-Prolog 的库提供的函数read_file_to_string/3。假设要读取的文件为/tmp/demo.txt,其内容如下

1
2
3
4
5
6
7
Shopping List:

- Milk
- Bread
- Eggs
- Apples
- Coffee

那么read_file_to_string/3的用法如下

1
2
3
4
5
6
7
:- use_module(library(readutil)).

:- initialization(main, main).

main(_) :-
read_file_to_string("/tmp/demo.txt", String, []),
format("file content is: ~s~n", [String]).

这样就可以将读到的文件内容完全打印到控制台上,如下图所示

读取磁盘文件

写入磁盘文件

如果要将数据写入到磁盘文件中——例如,在每次处理完请求后记录日志,那么可以使用函数write。以将前文中的字符串Hello, world!写入到文件中为例,示例代码如下

1
2
3
4
5
6
7
:- initialization(main, main).

main(_) :-
LogContent = "Hello, world!",
open("/tmp/access.log", write, Stream),
write(Stream, LogContent),
close(Stream).

效果如下图所示

写入磁盘文件

解析 JSON 格式

JSON 已经是应用最广泛的数据交互格式之一了,因此如果一门语言要能够投产于业务系统的开发,必然离不开对 JSON 数据的处理能力。假设要处理的 JSON 数据如下

1
2
3
4
5
6
7
8
9
{
"mysql": {
"driver_string": "DRIVER={MySQL ODBC 8.0 Driver};String Types=Unicode;password=1234567;port=3306;server=mysql;user=root"
},
"redis": {
"hostname": "redis",
"port": 6379
}
}

这些内容存储在文件/tmp/config.json中,那么下列代码会取出其中的叶子节点来输出

1
2
3
4
5
6
7
8
9
10
11
12
:- use_module(library(http/json)).

:- initialization(main, main).

main(_) :-
ConfigPath = "/tmp/config.json",
read_file_to_string(ConfigPath, String, []),
% 按照 JSON 格式反序列化为字典类型的数据。
atom_json_dict(String, JSONDict, []),
format("mysql.driver_string = ~s~n", [JSONDict.mysql.driver_string]),
format("redis.hostname = ~s~n", [JSONDict.redis.hostname]),
format("redis.port = ~d~n", [JSONDict.redis.port]).

上文中的函数atom_json_dict将字符串类型的变量String反序列化为变量JSONDict。从这里可以看到,SWI-Prolog 为字典类型提供一个中缀操作符.,使得我们可以像在多数主流语言中引用类的成员变量一般,用简单的语法来获取字典内的字段——即JSONDict.mysql.driver_string这样的代码。

上面的代码的运行效果如下图所示

解析JSON字典的效果

自动导入的库

如果分别查看函数read_file_to_stringatom_json_dict的文档(分别在这里这里),就会发现前者的页面上写着can be autoloaded,而后者没有

自动导入的提示

所以前文关于read_file_to_string的例子中,即便不写上:- use_module(library(readutil)).,也是可以正常调用的

不需要导入的例子

事实与全局变量

在将磁盘上的配置文件的内容加载到内存中后,最好可以将其赋值为一个全局变量以便在所有的函数中访问到。要做到这一点,可以利用 Prolog 的一个特性:事实。

在很多 Prolog 的入门教程中,都会介绍经典的、如何用 Prolog 来回答两个人是否为某种关系的例子。例如,在这个教程中,就给出了如何判断两个人是否为朋友的示例,如下图所示

判断是否为朋友的例子

其中,像

1
2
3
4
friend(john, julia).
friend(john, jack).
friend(julia, sam).
friend(julia, molly).

这样的代码就是 Prolog 中的“事实”。其中,friend在 Prolog 中被称为“谓词”,也就是前文中一直提到的函数。因此,如果想要定义一个全局变量,可以:

  1. dynamic/1声明一个只有一个参数的“动态”谓词;
  2. asserta/1新增一个事实;
  3. 在别的位置,用通常的归一语法就可以绑定全局的值到一个变量上了。

就像下面这样子

1
2
3
4
5
6
7
8
9
:- initialization(main, main).

% 声明为动态的以便允许使用 asserta 修改。
:- dynamic odbc_driver_string/1.

main(_) :-
asserta(odbc_driver_string("This is a global variable")),
odbc_driver_string(DriverString),
format("DriverString = ~s~n", [DriverString]).

效果如下

用事实来定义全局变量

在 Docker 中运行 Prolog

在之后的例子中,我还会介绍如何使用 Prolog 来读写数据库。但在摸索的过程中,我发现在 macOS 上无法运行成功,只有在 Docker 内才可行,因此这一节将会先介绍如何在 Docker 中运行 Prolog。

以前文中的 Hello World 程序为例,在已经有了源文件hello_world.pl的前提下,准备如下的hello_world.dockerfile文件

1
2
3
4
5
6
7
8
9
10
FROM swipl:stable

COPY . /app/
WORKDIR /app

# 编译源代码
RUN swipl --goal=main --stand_alone=true -o hello_world -c hello_world.pl \
&& cp ./hello_world /bin/

CMD [ "hello_world" ]

然后基于这份配置来构建镜像

1
docker build -f ./hello_world.dockerfile -t hello_world .

如果无法拉取镜像docker.io/library/swipl:stable,可以先通过 DaoCloud 下载,然后替换掉标签

1
2
docker pull docker.m.daocloud.io/library/swipl:stable
docker tag b84634ddb907 docker.io/library/swipl:stable # 此处的镜像 ID b84634ddb907 是在我的机器上的效果。

然后就可以运行这个镜像了

1
docker run hello_world

效果如下图所示

用docker运行Prolog代码

序言

在目前我参与开发的代码仓库中,当需要使用 Redis 时,基本上用的都是 Redis 集群。因此,我在办公电脑上也搭建了一个 Redis 集群,以便让我为这些仓库编写的单元测试能成功运行起来。

尽管 Redis 官方提供了部署集群的指引,但这要求团队内的每位成员都要依次执行如下操作:

  1. 用 Homebrew 安装 Redis;
  2. 创建 6 个目录;
  3. 创建 6 份redis.conf配置文件;
  4. 启动 6 个redis-server进程;
  5. redis-cli创建集群。

为了可以让更多的人愿意执行单元测试,必须要降低部署 Redis 集群的操作难度。因此,本文旨在提供一种基于 Docker 的、一键部署 Redis 集群的办法。

启动单个 Redis 容器

Redis 集群需要至少 6 个实例,那么首先要解决的问题便是如何启动单个 Redis 容器。从前文提到的部署集群的指引中可以知道,要启动一个集群模式的 Redis 实例,所需要的配置文件redis.conf的内容如下

1
2
3
4
5
port 8000
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes

参考官方的 redis 镜像的文档的方式,让redis-server读取来自于宿主机的配置文件,示例代码如下

1
2
# 选项 --rm 使该容器在退出后(例如按下 ctrl-c)可以被删除,毕竟这里只是先做个演示,不需要留下它。
sudo docker run --rm -v "`pwd`:/usr/local/etc/redis" redis redis-server "/usr/local/etc/redis/redis.conf"

效果如下图所示

启动单个 Redis 容器

启动 6 个 Redis 容器

接下来以相似的方式启动全部的 6 个 Redis 容器。首先创建 6 个目录

1
2
# 这里我用 Redis 监听的端口号作为目录名,但这并非强制要求。
mkdir 8000 8001 8002 8003 8004 8005

然后在每一个目录中都创建配置文件redis.conf

1
2
3
4
5
6
7
8
9
10
for port in 8000 8001 8002 8003 8004 8005 ;
do
cat << EOF > "./${port}/redis.conf"
port ${port}
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
EOF
done

最后启动它们

1
2
3
4
5
6
7
8
9
# 创建出名为 some-network 的容器间网络。
docker network create some-network

for port in 8000 8001 8002 8003 8004 8005 ;
do
# --name 选项让这 6 个容器拥有确定的且不同的主机名,以便之后可以在 redis-cli 中指定它们。
# --network 选项让这 6 个容器处于同一个网络下,以便集群内的节点可以互相通信。
docker run --name "some-redis-${port}" --network some-network --rm -d -v "`pwd`/${port}:/usr/local/etc/redis" redis redis-server '/usr/local/etc/redis/redis.conf'
done

效果如下图所示

启动 6 个 Redis 容器

创建集群

此时 6 个运行中的 Redis 实例还不构成一个集群,还需要执行redis-cli的命令--cluster create才行。

1
2
# 给 redis-cli 的选项 --cluster-yes 使其默认接受集群内节点的分配情况。这在脚本执行、无法通过标准输出敲入 yes 的时候很有用。
docker run --network some-network --rm -i -t redis redis-cli --cluster create some-redis-8000:8000 some-redis-8001:8001 some-redis-8002:8002 some-redis-8003:8003 some-redis-8004:8004 some-redis-8005:8005 --cluster-replicas 1 --cluster-yes

但命令--cluster create并非幂等的,只能在创建集群的时候使用一次。因此,如果希望通过脚本一键搭建、启动集群,则必须在创建前先检查集群是否曾经被创建过。借助redis-cli的命令--cluster check可以实现

1
2
3
4
5
6
7
docker run --network some-network --rm -i -t redis redis-cli --cluster check some-redis-8000:8000 | grep 'All 16384 slots covered.' > /dev/null
if [[ "$?" == '0' ]]; then
echo "Redis 集群已经创建好了。"
else
echo "开始创建 Redis 集群。"
docker run --network some-network --rm -i -t redis redis-cli --cluster create some-redis-8000:8000 some-redis-8001:8001 some-redis-8002:8002 some-redis-8003:8003 some-redis-8004:8004 some-redis-8005:8005 --cluster-replicas 1 --cluster-yes
fi

创建成功后,只要新的容器也使用网络some-network,就可以读写集群中的数据了

1
docker run --network some-network --rm -i -t redis redis-cli -c -h some-redis-8000 -p 8000

效果如下图所示

读写 Redis 集群

总结

我将上面的内容集成到了redis_cluster这个 GitHub 仓库中,只需要克隆到本地并执行脚本start.sh即可,效果如下图所示

一键启动 Redis 集群

全文完。

从 Ruby 的 method_missing 到杂鱼 Common Lisp

在 Ruby 中当调用一个对象不存在的方法时,会触发解释器调用该对象的method_missing方法。例如下面的代码

1
2
3
4
5
6
7
8
9
# -*- encoding: UTF-8 -*-
class A
def method_missing(m, *args, &block)
puts 'now you see me'
end
end


A.new().demo()

运行到方法调用demo()时,由于该方法未定义,于是解释器会转而调用方法method_missing,并将相同的方法名(即demo)、参数列表等传递给它。其运行结果便是在标准输出中打印出now you see me这句话。

在 Python 中有method_missing的等价物——__getattr__方法。与 Ruby 不同的是,调用不存在的方法对于 Python 解释器而言,只是一次寻常的AttributeError异常,然后解释器会调用对象的__getattr__方法。与前文的 Ruby 代码类似的写法如下

1
2
3
4
5
6
7
8
9
class A:
def __getattr__(self, name):
def replacement(*args, **kwargs):
print('now you see me')

return replacement


A().demo()

利用__getattr__可以实现一个透明缓存。例如,假设有一个类Slow,它提供了ab,以及c等几个比较耗时的方法。那么可以实现一个类Cached,由它来代理对Slow类的实例方法的调用、将结果缓存起来加速下一次的调用,再返回给调用方,示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import json
import time


class Slow:
def a(self):
time.sleep(1)
return 2

def b(self):
time.sleep(1)
return 23

def c(self):
time.sleep(1)
return 233


class Cached:
def __init__(self, slow: Slow):
self._slow = slow

self._cache = {}

def __getattr__(self, name):
f = getattr(self._slow, name)
def replacement(*args, **kwargs):
key = json.dumps([args, kwargs])
if key in self._cache:
return self._cache[key]

v = f(*args, **kwargs)
self._cache[key] = v
return v

return replacement


def run_and_timing(f, label):
begin_at = time.time()
v = f()
duration = time.time() - begin_at
print('%s 耗时 %s 秒' % (label, duration))


if __name__ == '__main__':
cached = Cached(Slow())
run_and_timing(lambda: cached.a(), '第一次')
run_and_timing(lambda: cached.a(), '第二次')

在我的机器上运行的结果为

1
2
第一次 耗时 1.0018281936645508 秒
第二次 耗时 2.8848648071289062e-05 秒

在 Common Lisp 中有没有与__getattr__对应的特性呢?有的,那便是广义函数slot-missing。但可惜的是,它并不适用于调用一个不存在的方法的场景,因为在 Common Lisp 中方法并不属于作为第一个参数的实例对象,而是属于广义函数的(即 Common Lisp 不是单派发、而是多派发的,可以参见这篇文章)。所以调用一个不存在的方法不会导致调用slot-missing,而是会调用no-applicable-method。如下列代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
(defgeneric demo-gf (a)
(:documentation "用于演示的广义函数。"))

(defclass A ()
())


(defclass B ()
())


(defmethod demo-gf ((a A))
(format t "这是类 A 的实例方法。~%"))


(defmethod no-applicable-method ((gf (eql #'demo-gf)) &rest args)
(declare (ignorable args gf))
(format t "now you see me"))


(defun main ()
(let ((a (make-instance 'B)))
(demo-gf a)))


(main)

假设上述代码保存在文件no_applicable_method_demo.lisp中,可以像下面这样运行它们

1
2
$ ros run --load ./no_applicable_method_demo.lisp -q
now you see me

当代码运行到(demo-gf a)时,由于没有为广义函数demo-gf定义过参数列表的类型为(B)的方法,因此 SBCL 调用了广义函数no-applicable-method,后者有applicable 的方法,因此会调用它并打印出now you see me

如果想利用这一特性来实现透明缓存,那么必须:

  1. 为每一个需要缓存的广义函数都编写其no-applicable-method方法;
  2. 手动检查参数列表的第一个参数的类型是否为特定的类。

如下列代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
(defgeneric a (a))
(defgeneric b (a))
(defgeneric c (a))


(defclass Slow ()
())


(defclass Cached ()
((cache
:accessor cached-cache
:initform (make-hash-table :test #'equal))
(slow
:accessor cached-slow
:initarg :slow)))


(defmethod a ((a Slow))
(sleep 1)
2)
(defmethod b ((a Slow))
(sleep 1)
23)
(defmethod c ((a Slow))
(sleep 1)
233)


(defmethod no-applicable-method ((gf (eql #'a)) &rest args)
(let ((instance (first args)))
(if (typep instance 'Cached)
(let ((slow (cached-slow instance))
(key (rest args)))
(multiple-value-bind (v foundp)
(gethash key (cached-cache instance))
(if foundp
v
(let ((v (apply gf slow (rest args))))
(setf (gethash key (cached-cache instance)) v)
v))))
(call-next-method))))

在我的机器上运行的结果为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CL-USER> (time (a *cached*))
Evaluation took:
1.001 seconds of real time
0.001527 seconds of total run time (0.000502 user, 0.001025 system)
0.20% CPU
2,210,843,642 processor cycles
0 bytes consed

2
CL-USER> (time (a *cached*))
Evaluation took:
0.000 seconds of real time
0.000015 seconds of total run time (0.000014 user, 0.000001 system)
100.00% CPU
29,024 processor cycles
0 bytes consed

2

如果想要让透明缓存对函数bc也起作用,则需要重新定义bc各自的no-applicable-method方法。通过编写一个宏可以简化这部分重复的代码,示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(defmacro define-cached-method (generic-function)
"为函数 GENERIC-FUNCTION 定义它的缓存版本的方法。"
(let ((gf (gensym))
(args (gensym)))
`(defmethod no-applicable-method ((,gf (eql ,generic-function)) &rest ,args)
(let ((instance (first ,args)))
(if (typep instance 'Cached)
(let ((slow (cached-slow instance))
(key ,args))
(multiple-value-bind (v foundp)
(gethash key (cached-cache instance))
(if foundp
v
(let ((v (apply ,gf slow (rest ,args))))
(setf (gethash key (cached-cache instance)) v)
v))))
(call-next-method))))))

然后就可以直接用这个新的宏来为函数abc定义相应的带缓存的方法了,示例代码如下

1
2
3
(define-cached-method #'a)
(define-cached-method #'b)
(define-cached-method #'c)

用函数b演示一下,效果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
CL-USER> (time (b *cached*))

Evaluation took:
1.003 seconds of real time
0.002518 seconds of total run time (0.001242 user, 0.001276 system)
0.30% CPU
2,216,371,640 processor cycles
334,064 bytes consed

23
CL-USER> (time (b *cached*))
Evaluation took:
0.000 seconds of real time
0.000064 seconds of total run time (0.000063 user, 0.000001 system)
100.00% CPU
135,008 processor cycles
0 bytes consed

23

全文完。

在 Lisp 中使用 reader macro 支持 JSON 语法

什么是 reader macro?

Reader macro 是 Common Lisp 提供的众多有趣特性之一,它让语言的使用者能够自定义词法分析的逻辑,使其在读取源代码时,如果遇到了特定的一两个字符,可以调用相应的函数来个性化处理。此处所说的“特定的一两个字符”,被称为 macro character,而“相应的函数”则被称为 reader macro function。举个例子,单引号'就是一个 macro character,可以用函数get-macro-character来获取它对应的 reader macro function。

1
2
3
CL-USER> (get-macro-character #\')
#<FUNCTION SB-IMPL::READ-QUOTE>
NIL

借助单引号,可以简化一些代码的写法,例如表达一个符号HELLO本身可以写成这样。

1
2
CL-USER> 'hello
HELLO

而不是下面这种等价但更繁琐的形式。

1
2
CL-USER> (quote hello)
HELLO

Common Lisp 中还定义了由两个字符构成的 reader macro,例如用于书写simple-vector字面量的#(。借助它,如果想要表达一个依次由数字 1、2、3 构成的simple-vector类型的对象,不需要显式地调用函数vector并传给它 1、2、3,而是可以写成#(1 2 3)

支持 JSON 语法后有什么效果?

合法的 JSON 文本不一定是合法的 Common Lisp 源代码。例如,[1, 2, 3]在 JSON 标准看来是一个由数字 1、2、3 组成的数组,但在 Common Lisp 中,这段代码会触发 condition。(condition 就是 Common Lisp 中的“异常”、“出状况”了)

1
2
3
4
5
6
7
8
9
10
11
CL-USER> (let ((eof-value (gensym)))
(with-input-from-string (stream "[1, 2, 3]")
(block nil
(loop
(let ((expr (read stream nil eof-value)))
(when (eq expr eof-value)
(return))

(print expr))))))

[1 ; Evaluation aborted on #<SB-INT:SIMPLE-READER-ERROR "Comma not inside a backquote." {1003AAD863}>.

这是因为按照 Common Lisp 的读取算法,左方括号[和数字 1 都是标准中所指的 constituent character,它们可以组成一个 token,并且最终被解析为一个符号类型的对象。而紧接着的字符是逗号,它是一个 terminating macro char,按照标准,如果不是在一个反引号表达式中使用它将会是无效的,因此触发了 condition。

假如存在一个由两个字符#J定义的 reader macro、允许开发者使用 JSON 语法来描述紧接着的对象的话,那么就可以写出下面这样的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CL-USER> (progn
(print #jfalse)
(print #jtrue)
(print #j233.666)
(print #jnull)
(print #j[1, 2, [3], [4, 5]])
(print #j{"a": [1, 2, 3]})
(print (gethash "a" #j{"a": [1, 2, 3]})))

YASON:FALSE
YASON:TRUE
233.666d0
:NULL
#(1 2 #(3) #(4 5))
#<HASH-TABLE :TEST EQUAL :COUNT 1 {1003889963}>
#(1 2 3)
#(1 2 3)

显然,用上述语法表示一个哈希表,要比下面这样的代码简单得多

1
2
3
4
5
CL-USER> (let ((obj (make-hash-table :test #'equal)))
(setf (gethash "a" obj) #(1 2 3))
obj)

#<HASH-TABLE :TEST EQUAL :COUNT 1 {1003CB7643}>

如何用 reader macro 解析 JSON?

Common Lisp 并没有预置#J这个 reader macro,但这门语言允许使用者定义自己的 macro character,因此前面的示例代码是可以实现的。要自定义出#J这个读取器宏,需要使用函数set-dispatch-macro-character。它的前两个参数分别为构成 macro character 的前两个字符,即#J——其中J即便是写成了小写,也会被转换为大写后再使用。第三个参数则是 Lisp 的词法解析器在遇到了#J时将会调用的参数。set-dispatch-macro-character会传给这个函数三个参数:

  1. 用于读取源代码的字符输入流;
  2. 构成 macro character 的第二个字符(即J);
  3. 非必传的、夹在#J之间的数字。

百闻不如一见,一段能够实现上一个章节中的示例代码的set-dispatch-macro-character用法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
(set-dispatch-macro-character
#\#
#\j
(lambda (stream char p)
(declare (ignorable char p))
(let ((parsed (yason:parse stream
:json-arrays-as-vectors t
:json-booleans-as-symbols t
:json-nulls-as-keyword t)))
(if (or (symbolp parsed)
(consp parsed))
(list 'quote parsed)
parsed))))

set-dispatch-macro-character的回调函数中,我是用了开源的第三方库yason提供的函数parse,从输入流stream中按照 JSON 语法解析出一个值。函数parse的三个关键字参数的含义参见这里,此处不再赘述。由于 reader macro 的结果会被用于构造源代码的表达式,因此如果函数parse返回了符号或者cons类型,为了避免被编译器求值,需要将它们“引用”起来,因此将它们放到第一元素为quote的列表中。其它情况下,直接返回parse的返回值即可,因此它们是“自求值”的,求值结果是它们自身。

尾声

本文我借助了现成的库yason来解析 JSON 格式的字符串,如果你对如何从零开始实现这样的 reader macro 感兴趣的话,可以参考这篇文章

全文完。

使用 call/cc 实现计数循环

什么是计数循环

计数循环就是从一个数字$i$开始一直遍历到另一个数字$j$为止的循环过程。例如,下面的 Python 代码就会遍历从 0 到 9 这 10 个整数并逐个打印它们

1
2
for i in range(10):
print(i)

如果是在 C 语言中实现同样的功能,代码会更显著一些

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main(int argc, char *argv[])
{
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
}

return 0;
}

在 C 语言的例子中,显式地指定了计数器变量i从 0 开始并且在等于 10 的时候结束循环,比之 Python 版本更有循环的味道。

拆开循环计数的语法糖

使用 C 语言的while语句同样可以实现计数循环,示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

int main(int argc, char *argv[])
{
int i = 0;
while (i < 10) {
printf("%d\n", i);
i++;
}

return 0;
}

如果将while也视为ifgoto的语法糖的话,可以进一步将计数循环写成更原始的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

int main(int argc, char *argv[])
{
int i = 0;
label0:
if (i >= 10) {
goto label1;
}
printf("%d\n", i);
i++;
goto label0;
label1:

return 0;
}

Common Lisp 中的 go 与续延

在 Common Lisp 中也有与 C 语言的goto特性相近的 special form,那就是tagbodygo。使用它们可以将 C 代码直白地翻译为对应的 Common Lisp 版本

1
2
3
4
5
6
7
8
9
10
(let ((i 0))
(tagbody
label0
(when (>= i 10)
(go label1))

(format t "~D~%" i)
(incf i)
(go label0)
label1))

聪明的你一定已经发现了,此处的第二个符号label1其实是丝毫不必要的,只要写成下面的形式即可

1
2
3
4
5
6
7
(let ((i 0))
(tagbody
label0
(when (< i 10)
(format t "~D~%" i)
(incf i)
(go label0))))

这个形式不仅仅是更简单了,而且它暴露出了一个事实:label0所表示的,其实就是在将变量i绑定为 0之后要执行的代码的位置。换句话说,它标识了一个续延(continuation)。

用 call/cc 重新实现计数循环

如果你用的语言中支持 first-class 的续延,那么便可以用来实现计数循环,例如233-lisp。在 233-lisp 中,提供了特殊操作符call/cc来捕捉当前续延对象,这个名字借鉴自 Scheme。借助这个操作符,即便没有tagbodygo,也可以实现计数循环。

用callcc模拟计数循环

在上面的代码中,call/cc捕捉到的续延就是“赋值给局部变量i”。在将这个续延k保存到变量next之后,用 0 初始化变量i。之后只要i还小于 10,就将它打印到标准输出,并启动保存在了变量next中的续延,回到给变量i赋值的地方。此时传递给续延的参数为(+ i 1),就实现了变量i的自增操作。当(< i 10)不再成立时,也就不会启动续延“回到过去”了,至此,进程结束。

在 233-lisp 中,将dotimes作为一个内置的宏用call/cc实现了一遍,参见这里,其代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(defun expand-dotimes-to-call/cc (expr)
"将 DOTIMES 语句 EXPR 编译为等价的 CALL/CC 语句。"
(assert (eq (first expr) 'dotimes))
(destructuring-bind ((var count-form) &rest statements)
(rest expr)
(let ((a (gensym))
(count-form-result (gensym))
(next (gensym)))
`(let ((,count-form-result ,count-form)) ; 由于目前 LET 只支持一个绑定,因此这里要写多个 LET。
(let ((,next 0)) ; 由于 233-lisp 中尚未支持 NIL,因此这里填个 0
(let ((,var (call/cc (k)
(progn
(setf ,next k)
0)))) ; 计数循环从 0 开始。
(if (< ,var ,count-form-result)
(progn
,@statements
(,next (+ ,var 1)))
0))))))) ; 由于目前没有 NIL,因此返回一个数字 0 来代替。

变量count-form-resultnext分别表示在宏展开后的代码中的计数上限和被捕捉的续延。之所以让它们以(gensym)的方式来命名,是为了避免多次求值count-form表达式,以及避免存储续延的变量名恰好出乎意料地与statements中的变量名冲突了,这也算是编写 Common Lisp 的宏时的最佳实践了。

后记

直接用call/cc来一个个实现 Common Lisp 中的各种控制流还是太繁琐了,更好的方案是用call/cc先实现tagbodygo,然后再用后两者继续实现do,最后用do分别实现dolistdotimes。当然了,这些都是后话了。