Common Lisp 初学者快速入门指导

百般思念 提交于 2019-12-10 03:30:50

Common Lisp 初学者快速入门指导

V 0.90

目录

一、简单介绍

写作本文的缘起:我也是一名 Common Lisp 的初学者,在对照着各种教程学习 Common Lisp 的过程中,发现有不少细节都需要自己去摸索,比如对开发环境的进一步配置(推荐使用开发环境--LispBox 作为一个一键式开发环境,大大降低了许多不熟悉 Emacs 的初学者的学习和使用门槛,不过遗憾的是它已经停止更新了,现在 LispBox 中各软件的版本偏低,如果想要使用最新版本的 Common Lisp 实现,就需要自己去动手配置了),包括更新版本、支持中文符号名称、自定义函数名始终高亮显示等等,诸如此类很多细节,都需要自己摸索、尝试和验证,这个过程不可避免会花费一些时间。

我觉得只要有一个人经历过这种摸索就可以了,其他人完全可以借鉴他现成的经验,否则每个人都去做重复的摸索,是一种相当大的浪费,所以就不揣冒昧,把自己学习过程中的一些经验和体会记录下来,希望能为其他 Common Lisp 初学者节省一些时间。

学习任何知识,都不能仅仅把它们当做知识,更重要的是要把它们在实际编程实践中应用起来,持有这样的学习观念才不至于让你变成学究式的活字典,对于程序员来说这一点尤其重要,你学习的任何语言知识,一定要在实际的程序编写过程中不断练习、不停实践,纸上得来终觉浅,绝知此事须躬行。

1、本文目标

写作本文的目标是希望能为 Common Lisp 初学者提供一份简单易懂、容易上手、学习结合(学为学,习为实践)的初学者教程。

【说明】:Lisp 家族相当庞大, Common Lisp 是其中的一个分支,具体的分类我就不在这里赘述了,建议初学者可以到 wiki 百科去了解,当然,如果你和我一样,也在看冰河(田春)翻译的《实用 Common Lisp 编程》这本书,那么直接阅读书中的前面章节也可以对此有一个大致的了解。

我一向认为,学习任何知识体系都要遵循从易到难、从简单到复杂的规律,就像 Lisp 的迭代式开发一样,最初出现在程序员手中的版本是一个很不完善,但实现了基本核心功能的原型系统,然后再经过反复的迭代开发来逐步完善,直到把粗糙的原型系统变成可用的工程系统。

学习一门复杂艰深、体系庞杂的程序语言--Common Lisp 同样要遵循这个规律,这份教程也尽量遵循这个规律来编写,也就是开始时不会涉及太深入的概念,只会提到一些基本概念,而这些基本概念也会以很直观易懂的描述方式来表述,不会用到任何可能令初学者疑惑难解的术语,目的就是让初学者对 Common Lisp 程序迅速建立一种感性认识跟理解,依据这些知识可以迅速读懂其他开源作者写的 Common Lisp 程序。

所以,本文的表述可能不是那么严谨,比如本文会有这样的表述:

“Lisp 程序由 S-表达式(符号表达式)组成,S-表达式是列表或者单个原子”
“列表是由 0 个或者更多的原子或者内部列表组成,原子或者列表之间由空格分隔开,并由括号括起来。
列表可以是空的”

初学者看到这个表述就会对 Lisp 的列表建立一种初步直观的印象,可以据此识别程序中使用的列表,也可以合法地构造出自己使用的列表,这样初学者就可以很迅速地入门了。

我不会在这里说什么

“列表本质上是由 cons(点对)构成的,点对表示的列表是这个样子(OR NULL CONS),点对不仅可以构成列表,还可以构成树、集合和查询表”

虽然说这种表述更确切,但是我觉得这种表述明显会让初学者感到复杂和困惑,这些内容应该放在初学者入门之后继续深入学习的过程中逐步去了解的。

我发现之前看过的两本教程在开始章节部分都不约而同地采用了非常直观易懂的描述方式来讲解列表,而把 点对cons 的讲解放到了后续章节,看来大家都采取了同样的讲解策略。

因此,后续就不再一一详细解释了,本文提到的所谓的 Lisp 基本概念都是针对初学者的入门阶段的,等初学者真正入了门,再进一步深入学习时,同时初学者也对 Common Lisp 的实现机制有了更深入的了解时,自然会发现在这里了解到的基本概念会有更底层、更确切的表述,那就用那些更确切的表述来更新你脑海中这些入门阶段学到的直观易懂的基本概念吧。

当然,为了避免不必要的误解,我也会适当加一些说明,比如 Emacs Lisp 和 Common Lisp 的读取求值机制不太一样,Emacs Lisp 使用 Lisp 解释器进行读取和求值;而 Common Lisp 则使用 R-E-P-L 机制,要分为读取器(R)和求值器(E),读取器处理的是 S-表达式,而求值器处理的则是经过读取器处理输出的一些特殊的 S-表达式:Lisp形式--form。

正如《实用 Common Lisp 编程》的作者所说 “难道在确定一门语言真正有用之前就要先把它所有的细节都学完吗?”,尤其在面对 Common Lisp 这样一门体系异常庞大的语言时,初学者在开始阶段不可能也没必要深究它所有的细节,先学一点最简单的基础知识---而凭借这些基础知识又足够支撑你去写一些最简单的程序,然后让你的简单程序跑起来,这就是一个很好的开始了。

2、适用读者

本文适用的读者群体就是 Common Lisp 的初学者,他们从来没有用 Lisp 写过程序,对于 Lisp 的分类一无所知,也不清楚用什么开发工具来运行 Common Lisp 程序,但是忽然对 Common Lisp 产生了兴趣,想学习一下,大致来说就是那些对于 Lisp 的认知停留在:“Lisp 是一门专门用于 AI 的学术性程序语言,不适合用来做商业开发” 这个程度的读者----这也是我在看《黑客与画家》之前对 Lisp 的认识。

我推荐的参考书,就是建议在学习过程中备在案头,可以随时查阅那种:

初学者阶段:

《实用 Common Lisp 编程》 --比较适合初学者的常备工具书,不仅有对 Common Lisp 的精彩讲解,更有非常实用、贴切的例程进行参考

《ANSI Common Lisp》中文版 --基本读物,可以作为额外的参考补充

《GNU Emacs Lisp 编程入门》 --此书专门讲 Emacs Lisp ,和 Common Lisp 具体细节不太一样,不过建议能对照着看看,会有意想不到的收获,尤其是其中的一些基本概念很接近;

《On Lisp 中文版》 --此书属于扩展阅读,有大量的代码实例,重点放在传授一种编程思想,主要探讨 Common Lisp 的函数和宏,建议读冰河翻译的中文版,因为纠正了原文的一些代码错误,初学者在了解一些基本概念之后就可以看懂这个了,推荐

[《Google Common Lisp 风格》] 1 --该文档涉及的范围比较广,建议先大致浏览,学到哪里再细看哪里的相关章节

3、迭代式学习

迭代式学习:本文尝试使用一种名为迭代式学习的方式进行内容,也就是说按照从前到后的写作顺序,最前面出现的都是非常基本的知识和操作,读者可以边阅读、边理解、边实践,可以迅速从最简单的部分入手,然后再以这部分比较简单的知识为基础,不断展开新的稍微难一点的内容,这部分的学习内容同样需要遵循 边阅读、边理解、边实践 的原则,等把这部分难度有所提升的内容掌握后,就到了更难一点的内容,继续按照 边阅读、边理解、边实践 的原则进行:

初级难度==》边阅读、边理解、边实践
二级难度==》边阅读、边理解、边实践
三级难度==》边阅读、边理解、边实践
. . . . . .
超级难度==》边阅读、边理解、边实践

看到这里,有些对 Lisp 略有所知的朋友想必明白了,这不就是 Lisp 的迭代开发模式 Read-Eval-Print-Loop :REPL 的变形吗?哈,恭喜你看穿了,就是这样,经过一段时间使用 REPL 迭代方式的 Lisp 程序写作,我发现这种探索性的渐进式迭代开发方式非常适合用来从无到有、从简单到复杂构建一个全新的系统。

一种全新的知识体系也是这样一个未知的需要渐进探索的大系统,人类的认知过程应该遵循这种从易到难、知行合一(理论+实践)的渐进循环方式,这样每个学习阶段你都能感觉到进步,所有的反馈都是正面的,它既为你带来成就感,又能激励你接受难度渐增的挑战而期待更多的成就感,这种认知方式不会因为难度太高让你产生挫折感,进而丧失继续学习的兴趣,正所谓:学而时习之,不亦乐乎(我的理解是:学习了理论知识然后去实践中应用它,是多么有趣啊!)。

对于初学者而言,一定要多看、多想、多试,千万不要怕出错,事实上,现在错误就是下一轮迭代的起点,从另一个角度来说:如果你能把错误提示信息里的各类错误全部都尝试一遍,那你对这门语言也掌握得差不多了!

所以,初学者要要勇于思考、勇于尝试、勇于犯错!


【小提示】:

用一个简单的文本文件把每次出错的信息记录下来,后面如果解决了就把解决方法也记录一下,养成这种学习习惯,你会受益匪浅。


在这里,对于初学者而言, Common Lisp 体系经过多年的发展,就是这样一种全新而复杂的知识体系,有很多内容相互关联,个人自学起来难以下手,想要掌握这个知识体系,最好的办法就是 REPL 迭代式学习,当然,这里的难度迭代式教程写作也是我个人的一种思考和探索,目前还没有得到什么实际验证,是否可行还不一定,不过我们可以一起在这里尝试一下,反正也没什么损失。:)

4、本章内容小结

  • 写作本文的目标是希望能为 Common Lisp 初学者提供一份简单易懂,容易上手的初学者教程。
  • 本文适用的读者群体就是对 Lisp 了解很少但是希望尽快开始学习、实践一门 Lisp 语言的初学者。
  • 本文尝试的迭代式学习就是学习内容循序渐进,学习过程反复迭代的一种学习方法。

二、快速上手

学习任何一门程序语言都不应该仅仅停留在理论阶段,更重要的是实践,有些概念可能看半天文字讲解都不得要领,但是一写成代码,到计算机上跑一遍就比什么讲解都清楚了。

因此对于一门语言的初学者而言,动手实践是非常重要的,但是不幸的事实是:配置开发环境是绝大多数初学者不得不面临的一个难题,要么是需要自己配置编译参数、自己编译版本,要么是需要定制各种配置文件,而且常常会有一些莫名其妙的错误提示,让初学者的第一个程序夭折,说老实话,这些知识点在初学者入门之后根本不算什么,都是常识,但是在没有入门之前,那就是天大的障碍,现在网络比较发达,很多类似问题都可以上网搜索,以前网络没这么发达的时候,初学者遇到这种问题那真是痛苦…

所以如果能有一个一键式的开发环境那是多么幸福的事情,这样初学者就可以迅速进入状态,避免无关的干扰,快速上手!

1、推荐开发环境 Lispbox

在这里,Lisp 初学者们有福了,有一个非常简单的一键式开发环境 LispBox 等着大家使用(虽然目前 LispBox 已经停止更新,不过托开源之福,我们可以自己更新版本),这个开发环境在各种主流平台都提供了对应的版本,目前支持:

 MS-Windows
 Mac OSX
 Linux

我使用了 MS-Windows 和 Mac OSX 下的版本,就目前使用情况来看,还是比较满意的,因此我会强烈推荐初学者使用这个开发环境,它不需要你做任何配置,把压缩包下载回来,解压后直接双击可执行文件就可以运行,没有任何障碍。

LispBox 下载地址:

http://gigamonkeys.com/lispbox/
这个地址是《实用 Common Lisp 编程》的作者提供的,包括了写给即将学习 Lisp 的新手们的一段话,大家可以看看。

http://common-lisp.net/project/lispbox/
这里是 LispBox 的正式下载地址

2、开发环境简要介绍

LispBox 实际上是把 Emacs、Slime、Clozure CL 以及 QuickLisp 集成到一起,关于 LispBox 更详细具体的介绍可以参考我以前写的文章: 就不再这里重复了。

等初学者对 LispBox 熟悉一些后,就可以自己修改配置来使用其他 Common Lisp 实现了,比如加入 SBCL

扩展阅读

使用 LispBox 做开发环境就相当于选择了 Emacs 作为编辑器、选择 Slime 作为交互界面,那么一定要熟悉 Emacs 和 Slime 的各种快捷键,这不仅会让你的学习开发过程事半功倍,更让你有一种高效率、不间断键盘作业的享受。

建议参考读物:

《GNU Emacs Lisp 编程入门》 -- 让你了解 Emacs 工作的机制,明白那些插件是怎么工作的

[《Slime 用户手册》] 2 -- 建议看帝归翻译的中文版,省时省力,全面介绍了 Slime 的快捷键

3、第一个简单的 Lisp 程序

开发环境启动后会进入一个 REPL 界面,我们可以直接在 CL-USER> 后面输入 Lisp 代码,然后敲击回车运行代码

; SLIME 2012-11-12
CL-USER> 

第一个程序就沿用传统,向世界打个招呼吧:

CL-USER> (print "hello,world!")

"hello,world!" 
"hello,world!"
CL-USER> (format t "你好,  世界!")
你好,  世界!
NIL
CL-USER> 

这里其实用了两个函数,一个是 print 函数,一个是 format 函数,都是输出内容到屏幕。

不过在 Common Lisp 中更常用 format 函数来输出到屏幕多一些,可以把它跟 C 语言的 printf 函数对照着来看,注意一下 format 中的那个参数 “t”,代表的是标准输出流:*standard-output* ,也就是说如果在 t 的位置换一个参数,我们也可以把这段问候语发送到任何一个指定的输出流上。

(format t "你好,  世界!")

这个结构就是一个列表,用括号包围,里面共有 3 个元素,这些元素用空格分隔,不过双引号里的空格作为字符串内容处理,不起分隔作用,可以很明显地看出,format 属于比较特殊的符号,它就是一个函数名,后面的两个元素都是它的参数。

OK,是不是很简单,就跟 Lisp 世界发出了第一声问好!

(为什么 print 输出了两遍呢?说实话我也不清楚,要不自己去查查资料,然后把答案反馈给我 :) )

4、为程序增加些复杂性

有朋友说了,这个程序太简单了,而且如果我想重复问好怎么办?难道每都把这段代码拷贝、粘贴吗?那好,让我们把这段代码写成一个函数 hi ,这样,每次问好时只需要输入 (hi) 就可以了。

Common Lisp 程序中直接调用函数时一般要用括号把函数名括起来,比如 (hi)

我们就直接在 REPL 界面来编辑刚才输入的内容,可是刚才已经执行过这段代码了,现在的 CL-USER> 提示符后面是空的,有朋友说:我就是不喜欢来回拷贝,希望能有一个快捷键来列出我输入过的历史命令,没问题。

Emacs 中查询历史命令的快捷键是 M-p ,这里的大写 M 表示 Alt 键,M-p 就是同时按下 Alt 键 和 p 键
M-p 是向上翻
M-n 是向下翻

这样就可以把你在 REPL 中输入过的历史命令一一查看了,言归正传,历史命令找回来了,可是光标跑到最后了,我们需要把光标移动到最前面,我明白,你不想操作鼠标移动光标,希望有移动光标的快捷键,没问题:

C-a 是把光标移动到行首的快捷键,这里大写的 C 表示 Ctrl 键,C-a 就是同时按下 Ctrl 键 和 a 键
C-e 是把光标移动到行尾的快捷键

恩,看来 Emacs 的键盘快捷键操作起来果然很流畅,那我们继续把代码修改为函数:

CL-USER> (defun hi () 
   (format t "你好,世界!"))
HI
CL-USER> (hi)
你好,世界!
NIL

这里涉及到自定义函数的知识点,我假设大家都学过 C 语言,那么我们可以猜测一下 defun 的语法结构:

首先是括号,然后是 defun ,表示开始定义函数,再后面是 hi ,是我们自定义的函数名称,后面的空括号应该是参数吧,不过因为我们这一段程序没有使用参数,所以是空的,接着是函数体,也就是这个函数具体执行的操作,这个函数体要用括号括起来,最后再用一个括号和最前面的括号对应,把所有的内容括起来。 这里我们发现 REPL 在遇到回车换行时,它不会按行处理,而是按括号来处理,所以你可以增加任意个回车换行,只要没有输入跟第一个左括号匹配的右括号,它都不会认为你的输入结束,只有当你所有的左括号都有一个对应的右括号来匹配时,REPL 才会认为你输入的内容结束了。

这里给一个 defun 函数定义的标准语法形式吧:

(defun name (parameter*)
	"可选的函数描述"
	body-form*)
	
parameter* 表示 0 个或者多个 parameter,这里的 * 是正则式语法符号,表示 0 个或多个
body-form* 表示 0个或多个 body-form

对应中文就是这样:

(defun 函数名 (参数*)
	"可选的函数描述"
	形式体*)

这里解释一下 body-form 这个概念,Common Lisp 定义了两个语法黑箱,前者叫读取器,将文本转化为 Lisp 对象,后者叫求值器,它用前者定义好的 Lisp 对象来实现语言的语义,我们知道直接输入到读取器中的 Lisp 程序是由 S-表达式组成的,而求值器则定义了一种构建在 S-表达式(符号表达式)之上的 Lisp形式--form 的语法。

所有的字符序列都是合法的 S-表达式

这一点意味着你可以把任意的字符序列交给 Lisp 的读取器来处理,你可以定义任意的语法格式来作为你的程序的文本输入---当然,这需要你做一些相关的设置,不过我们还是建议初学者先了解、熟悉大家都习惯的 Lisp 语法形式,等你真正学会了,就可以创造自己的程序语言了,是不是听起来很鼓舞斗志?

但是并非所有的 S-表达式 都是合法的 Lisp形式--form

举个例子就清楚了,(hi) 和 ("hi") 都是合法的 S-表达式,但是 (hi) 是一个合法的 Lisp形式--form,而 ("hi") 就不是一个合法的 Lisp形式--form,因为字符串作为列表的第一个元素对于 Lisp形式--form 而言是没有意义的。

说了这么多,其实主要是讨论什么才是函数定义中的 body-form(形式体)的合法形式,想搞清这个问题,又不愿意多想的话,就到环境上去试验吧,把你能想到的各种形式都试验一下。:)


留个小作业:

如果函数定义中有多个形式体,应该如何去写?建议大家自己上机试验。


  • 函数调用的特殊情况

既然有一般情况,那就有特殊情况,另一种对函数的调用方式是间接调用,就是把函数1作为参数传递给另一个函数,由另一个函数间接调用函数1,具体到我们这个例子就是用另一个函数间接调用 hi,接下来就介绍这两个函数:funcallapply,它们需要使用这种形式:

CL-USER> (funcall #'hi)
你好,世界!
NIL
CL-USER> (apply #'hi nil)
你好,世界!
NIL
CL-USER> 

感兴趣的朋友可以试着分析一下 funcallapply 的区别,另外也可以试着执行一下下面这两种形式,看看会有什么错误提示,从这些错误提示信息也可以了解一些 Common Lisp 的内部处理机制。

犯错尝试:
错误1:	(apply #'hi)
错误2:	(funcall #'(hi))
错误3:	(apply #'(hi))

上面我们提到一个组合符号 #' ,它由两个符号组成,前面是井号 # ,紧跟着是单引号 ' ,这个组合符号 #' 等价于函数 function,前者是后者的“语法糖”,不过两者的使用形式有些区别:

CL-USER> (funcall (function hi))
你好,世界!
NIL
CL-USER>

【小提示】:

在这些不起眼的处理细节上多想想区别,多试试错误,多看看错误提示信息,有助于我们更好的理解 Common Lisp 的内部处理机制。


关于 funcall 和 apply 更具体的应用场景就不在这里详述了,建议大家阅读《实用 Common Lisp 编程》和 《ANSI Common Lisp》中的相关章节来做更深入的了解。

5、这么好的程序一定要保存起来

哈,经过这么一番学习,终于完成了我们的第一个函数,于是有人就说:这么好的函数能不能保存起来,免得下次想调用它还得重新输入这么多字符,没问题:

可以先拷贝程序文本,这里说一下 Emacs 下的拷贝粘贴快捷键:

Mac 系统
拷贝是 Command 键 和 c 键同时按下
粘贴是 C-y : Ctrl 键 和 y 键 同时按下
是不是感觉有些奇怪,没关系,如果不适应的话可以自己修改配置文件,或者修改 slime.el 文件来重新定义

MS-Windows 系统
拷贝是  M-w :Alt 键 和 w 键 同时按下
粘贴是  C-y :Ctrl 键 和 y 键 同时按下

然后创建新文件:

使用如下快捷键
C-x C-f 就是先同时按下 Ctrl 键 和 x 键,然后全部松开,再同时按下 Ctrl 键 和 f 键,再松开,Emacs 屏幕底部会显示如下:
Find file: ~/
默认保存在当前用户目录下,Mac系统是 /Usrs/admin/

你输入要保存具体要保存的目录,我的文件保存在 ~/ECode/Markdown-doc/hi.lisp

可以使用 TAB 键来自动补全,这样就不必一个个手工输入了

我输入的文件路径和名称如下:

Find file: ~/ECode/Markdown-doc/hi.lisp

注意文件名后缀要保存为 .lisp 代表这个文件是 Common Lisp 程序。

Emacs 也有一种用来定制编辑器的 Lisp 语言,叫做 Emacs Lisp,这种文件的后缀是 .el 或 .emacs

OK,输入上述这些之后,回车,Emacs 就会创建一个名为 hi.lisp 的 Lisp 源程序文件,放在 ~/usrs/admin/Ecode/Markdown-doc/ 目录下。

注意,这时这个文件还是一个空文件,把我们之前拷贝好的程序内容,粘贴到这个新建的空文件里。

然后就是执行文件保存的快捷命令了:

Mac 系统
C-x C-s
或者 Command-s

MS-Windows 系统

很好,到现在为止,你已经成功地写出了第一个程序,并且对这个程序做了一些扩展,然后又成功地把它保存了起来,那么接下来就要提到如何加载它了,我们可以使用 load 函数来进行加载。

这时又有朋友发现了,我们刚才使用的 REPL 界面不见了,被新开启的 hi.lisp 的文本编辑界面所取代了,我想继续回到刚才那个 REPL 界面该怎么办?有多种快捷方法可以调出刚才的 REPL 界面,我们先说一种最适合一边在文本编辑界面写代码,一边用 REPL 来调试的的调用方法,快捷键如下:

C-c C-z 可以直接调出一个关联到当前文本编辑界面的 REPL 窗口

为什么说特别适合调试呢?比如,你在文本编辑区写了一段函数代码,想立刻看看这段代码的执行情况,那你可以把光标放在这个函数代码段内的任意一个位置,然后输入快捷键:

C-c C-y 把光标所在区域的函数名称发送到对应的 REPL 进程中,非常方便调试代码

这个函数名称就自动跑到 REPL 去了,是这个样子:

CL-USER> (hi )

看看连括号都没拉下,而且函数名后面还自动加了个空格预防你一旦需要有参数输入,然后直接回车就可以在 REPL 中调试你刚写好的函数了,是不是很方便?

好了,函数在 REPL 中调试过了,你也看到了执行效果,觉得还需要再加点什么,于是又想切换回到文本编辑缓冲区了,那么快捷键如下:

C-x o 先同时按下 Ctrl 键 和 x 键,松开,再按下 o 键

这样就又切换回刚才的文本编辑缓冲区了。

这里我开始使用缓冲区(buffer)这个名词,缓冲区是 Emacs 编辑器的一个概念,文本编辑窗口是一个缓冲区,REPL 是一个缓冲区,消息事件也是一个缓冲区,不同的缓冲区可以来回切换,缓冲区的屏幕布局也可以通过快捷键来设置:

C-x 1 当前缓冲区占据整个 Emacs 窗口,其他缓冲区全部放到后台;
C-x 0 关闭当前缓冲区
C-x 2 在当前缓冲区上方新打开一个缓冲区
C-x 3 在当前缓冲区右方新打开一个缓冲区

最后再介绍一个超级有用的快捷键:查看标准函数、标准宏源代码,我们知道 Common Lisp 的很多实现都是开源的,包括我们推荐使用的 CCL,这就意味着我们可以查看其源代码,既便于深入理解,也便于学习模仿,比如对于自定义函数的宏 defun ,我们想查看它的源代码,想了解它的具体实现细节,可以把光标放在 defun 上,然后按 M-.

M-. 	同时按 Alt 键 和 点键 . ,查看当前光标所在位置的函数的源代码

大家可能注意到我介绍了不少的 Emacs 快捷命令,这正是我想要大力推荐的,就我的使用经验而言,这些快捷操作能够极大地提升 Emacs 开发环境下编程、调试的效率,所以希望初学者能熟悉这些快捷操作,其实多用几次就熟悉了,慢慢就习惯了,不知不觉工作效率就提高了,可以早点完成工作了,可以早点下班回家了,于是有了更多的自由时间,可以多看看书、多运动运动、多陪陪家人、多发展下个人的兴趣爱好……然后你的整个人生就改变了 :)

美好的未来真值得期待啊,现在让我们言归正传,继续讨论如何加载 Lisp 源程序,有多种方式可以加载,我们先介绍在 REPL 界面的方式:

CL-USER> (load "~/ecode/markdown-doc/hi.lisp")
#P"/Users/admin/ECode/Markdown-doc/hi.lisp"
CL-USER> (hi)
你好,世界!
NIL
CL-USER> 

[说明  #P"/Users/admin/ECode/Markdown-doc/hi.lisp" 这种返回形式表示返回结果是一个路径对象]

悲剧了,精心编写的问候语成了一堆乱码,是什么原因?该怎么办呢?原因也简单,所有的文件操作函数(比如 load open等)默认的字符编码格式类型都是 :latin-1 ,这种编解码类型对应英文字符,遇到中文内容自然就乱码了。

没错,你没看错,我也没写错,这个类型名称就是以冒号打头的一个符号,这种类型的符号在 Lisp 中被称为关键字 keyword ,对这种类型的符号求值会得到冒号后面的符号名称,从现在开始初学者要逐步适应 Lisp 对符号的使用习惯。

而我们的中文使用的编码恰恰不是这个,而是 :utf-8 ,那么就需要手工指定了,如下:

CL-USER> (load "~/ecode/markdown-doc/hi.lisp" :external-format :utf-8)
#P"/Users/admin/ECode/Markdown-doc/hi.lisp"
CL-USER> (hi)
你好,世界!
NIL
CL-USER> 

好了,问题解决了,可是有些朋友觉得很麻烦,每次加载文件都得输入一长串额外的参数,这里介绍一个稍微简单点的办法,可以通过修改系统的全局变量 *default-external-format* 来把默认的文件格式改成我们需要的 :utf-8 ,先查看一下当前的值,如下:

CL-USER> *default-external-format*
:UNIX
CL-USER> 

setq 函数修改,第一个参数是要修改的全局变量,第二个参数是希望修改成的值,修改然后查看:

CL-USER> (setq *default-external-format* :utf-8)
:UTF-8
CL-USER> *default-external-format*
:UTF-8
CL-USER> 

说明:全局变量 *default-external-format* 在 CCL 和 CLisp 中可以用,但是在 SBCL 中不支持,因此如果你的编程环境是 SBCL 的话,那么想要支持中文就需要每次手动指定编码格式了---SBCL 是否有类似的全局变量?我不太清楚,知道的朋友可以指点一下。

之所以介绍使用 setq 函数,是因为这个函数在 Common Lisp 和 Emacs Lisp 中都可以使用,都可以用于赋值,也就是说你可以在 Emacs 的配置文件中使用 setq 这个函数来修改一些全局配置量,而且 setq 在 Common Lisp 中更是一个特殊操作符,据说现代风格一般使用宏 setf 来实现赋值功能,setf 封装了对 setq 的调用。更详细的使用方法可以查询 HyperSpec 。

好了,再试一下,看看效果:

CL-USER> (load "~/ecode/markdown-doc/hi.lisp")
#P"/Users/admin/ECode/Markdown-doc/hi.lisp"
CL-USER> (hi)
你好,世界!
NIL
CL-USER> (setq *default-external-format* :utf-8)
:UTF-8
CL-USER> (load "~/ecode/markdown-doc/hi.lisp")
#P"/Users/admin/ECode/Markdown-doc/hi.lisp"
CL-USER> (hi)
你好,世界!
NIL
CL-USER>

修改生效!击掌庆祝一下!

赋值语句 setq 的各种例子:

CL-USER> (setq a (print "hello world!"))

"hello world!" 
"hello world!"
CL-USER> (setq a '(hello world!))
(HELLO WORLD!)
CL-USER> (setq b (print "hello world!"))

"hello world!" 
"hello world!"
CL-USER> b
"hello world!"
CL-USER> (setq c '())
NIL
CL-USER> c
NIL

CL-USER> (setq c ())
NIL
CL-USER> c
NIL
CL-USER> (setq c ( ))
NIL
CL-USER> c
NIL
CL-USER> (setq c (    ))
NIL
CL-USER> c
NIL
CL-USER> (setq c (  nil  ))
; Evaluation aborted on #<TYPE-ERROR #x302000CF368D>.
CL-USER> (setq c (nil))
; Evaluation aborted on #<TYPE-ERROR #x302000BF2BBD>.
CL-USER> (setq c '(nil))
(NIL)
CL-USER> c
(NIL)
CL-USER> (setq c (nil))
; Evaluation aborted on #<TYPE-ERROR #x302000C812CD>.
CL-USER> 


【补充说明】事实上字符编解码会涉及一系列知识点,我这里只是大致提一下,只简单介绍部分用法,不做详细讲解,感兴趣的朋友可以自己搜索相关资料,好好看看,把这些弄懂了基本上就清楚在各种不同场景下程序支持中文的机制了。

6、补充阅读:让程序支持中文符号

中文符号使用场景

写到这里,就自然而然地涉及到了在程序中使用中文符号的话题,就我的理解,在程序中对中文符号的使用可分为如下场景:

  • 中文符号作为字符串来使用

这个是最常见的一种使用方式,也是最容易实现的一种方式,我们前面的问好程序 hi 就是把中文当字符串使用的

  • 中文符号作为自定义变量名称、自定义函数名称、自定义宏名称来使用

这个需要对开发环境做一些配置,因为你的 Lisp 的读取器也好,求值器也好,都需要专门指定编解码来识别双字节的中文,而且使用 Slime 这种交互接口,还涉及一个客户端和服务端通信的编解码,我们接下来也主要讲解在这种方式下使用中文符号需要进行的配置工作

  • 中文符号作为语法关键字来使用

这种场景下连 “if” 这样的关键字都可以写成中文 “如果” 了,这样使用中文符号的结果是:源程序完全由中文、数字和其他符号组成,也就是说你可以自由地使用中文进行编程,需要编译器识别这些关键字,因此这种使用方式需要做更进一步的配置,幸运的是 Lisp 可以通过一些简单的设置完美地支持。

我正在投入的一个开源项目:【开源母语编程】 就是希望能在这方面做一些开拓性的工作,提供一个试验性质的平台,可以让对此感兴趣的开发者以此项目为基础继续深入研究。

第二种场景的配置方式

需要修改 Emacs 的配置文件,在 LispBox 环境中是通过 lispbox.el 文件进行配置的,在该文件中增加如下内容:

(set-language-environment "utf-8")
(set-buffer-file-coding-system 'utf-8)
(set-terminal-coding-system 'utf-8)
(set-keyboard-coding-system 'utf-8)
(set-selection-coding-system 'utf-8)
(set-default-coding-systems 'utf-8)
(set-clipboard-coding-system 'utf-8) 

(setq ansi-color-for-comint-mode t)
(setq-default pathname-coding-system 'utf-8)  
(setq default-process-coding-system '(utf-8 . utf-8))  
(setq locale-coding-system 'utf-8)
(setq file-name-coding-system 'utf-8) 
(setq default-buffer-file-coding-system 'utf-8)  
(setq slime-net-coding-system 'utf-8-unix)

(modify-coding-system-alist 'process "*" 'utf-8)  
(prefer-coding-system 'utf-8)

说实话,除了少数几条配置的作用比较明确,我也不是很确定上述这些配置具体哪条会起什么作用,大家就根据名称自己顾名思义一下吧,当然如果有人能够详细整理一下每条的确切作用共享出来,那就最好不过了。 :)

修改完成后,重启 LispBox,你的开发环境就支持使用中文字符做变量名和函数名了===没错,我的环境就是这样设置的,现在我们再把刚才那个问好函数修改完善一下,增加下面这个函数:

(defun 你好 ()
	(format t "你好,世界!--我使用了中文函数名称!")) 

然后使用 C-c C-k 在文本编辑缓冲区完成编译,再使用 C-c C-y 把函数名称发送到 REPL 进程上的输入区域,回车,全部显示如下:

CL-USER> 
;Compiling "/Users/admin/ECode/Markdown-doc/hi.lisp"
CL-USER> (你好 )
你好,世界!--我使用了中文函数名称!
NIL
CL-USER> 

哈,恭喜你,终于可以使用中文来自定义函数名称了,话说我实在是受够那些无比冗长的英文函数名称了。

英文是一种一维的线性文字,适合听觉处理而不适合视觉处理,想表达清楚一个含义,必须使用长长的一串字母组合,而中文的优势就是它是一种非线性的二维文字,相对来说更适合视觉处理(其实中文也适合听觉处理,虽然有不少同音字,但是根据上下文可以清晰确定其确切含义)。

一般来说:2个中文字符占据4个字节,4个字节对应4个英文字母,4个英文字母所能表述的内容跟2个中文字符所能表述的内容一对照就逊色很多了,在不影响清晰表达的前提下,用中文来做函数名可以比英文缩短一半以上。

当然也有其他类型的使用不同字节的中文编码,还有可变字节的编码,这些就不一一细述了,感兴趣的朋友可以自行查阅相关资料。

正是基于上述原因,我建议写代码时更多使用中文字符,这样可以有效缩短你源代码文件的长度,而且你的项目越大,这种效果越明显,越有利于节能减排。 :)

【注意】:
切记!千万不要在中文输入状态下输入各种符号,比如汉字的圆括号--》 () ,如果在程序中误用了汉字符号,也就是全角符号,会产生编译错误!!因为半角符号和全角符号对应的内部编码是不同的!!因此 Common Lisp 程序代码中所有的中文字符以外的其他标点符号必须使用英文方式输入,也就是要使用半角符号,而不是全角符号。

这里的中文字符以外的符号指的是半角括号、逗号、单引号、反引号、双引号等符号,使用本教程的 .emacs 配置文件,会自动把中文符号和英文符号设置为不同的颜色:

半角的英文符号一律为蓝色
全角的中文符号一律为绿色

可以来这里下载 Emacs 配置文件:

https://github.com/FreeBlues/PwML/blob/master/.emacs

7、本章内容小结

  • 初学者最好使用一键式开发环境,可以节省很多不必要的投入,推荐 LispBox

  • 各种 Emacs 快捷键使用技巧可以大幅提升你的工作效率

  • 几种特殊键的代表符号:

    • C Ctrl 键
    • M Meta 键(也就是 Alt 键)
    • Command Command 键 ( Mac 机型的特殊键)
  • 交互编程最重要的几个快捷命令:

    • C-c C-z 从代码编辑区切换到 REPL 区;
    • C-c C-y 把正在编写的函数名称发送到 REPL 区进行调试;
    • C-x o 从 REPL 区 切换到代码编辑区;
    • M-p 在 REPL 区查找历史命令,向前翻页
    • M-n 在 REPL 区查找历史命令,向后翻页
    • M-. 同时按 Alt 键 和 点键 . ,查看当前光标所在位置的函数的源代码
  • 忽然发现这章的小结写不下去了,因为内容比较散,就留给感兴趣的读者自己去做小结,写完了可以发给我,我们共同署名更新 :)

三、适用于初学者的 Lisp 基本概念

本章内容主要是对 Common Lisp 的程序结构和语法形式做一个直观易懂的描述,希望能在初学者脑海里迅速建立一些关于 Common Lisp 程序的初步观念,帮助初学者对于 Common Lisp 这门程序语言尽快有一个粗略的印象。

在建立这种粗略印象后,再结合前一章讲解过的在开发环境上调试程序的实践知识,初学者就拥有了理论 + 实践的双重工具(当然是尚不完善的理论),具备了继续深入学习的基础,能够以最小障碍跨越 Common Lisp 入门阶段,有了这个基础,初学者就可以真正自由地去探索 Common Lisp 那复杂博大的体系(确实够复杂,”标准函数“就有978个,再加上各种不同实现的一些函数就更多了)。

当初学者学到这种程度,再回过头来看这些入门阶段的基本概念,可能觉得它们的表述不够确切、不够严谨----看来时机已经成熟,新世界即将在你面前打开:

欢迎进入新世界,尼欧!

既然你已经能够从更深层次来对 Common Lisp 进行理解和表述,那就把这些入门阶段的基本概念都刷新一遍吧! (忽然发现,“尼欧” 的中文拼音 ”niou“ 在搜狗输入法中会对应到汉字“牛”,哈,估计之前没有人发现过这个神秘巧合吧!--“牛”字正规的汉语拼音应该是 “niu”)

就像大多数人最初学习数学时,数学老师会告诉你:对负数开平方是非法的,但是一旦学到复数的层次,负数也可以开平方了,而且还有特别的含义--旋转量,处于不同阶段对于知识的理解自然也不尽相同,这是一种螺旋式上升(迭代开发的另一种表述),在哲学上这就是否定之否定。

【补充一句:】

初学者首次阅读本章时,本章内容能看懂多少算多少,不必过于深究,其实有个大概的印象就可以了,然后就可以直接开始第四章的学习+实践,在实践的过程中可能有些概念就自然而然地理解了。毕

1、Lisp 程序由 S-表达式组成,表达式是列表或单个原子

基本概念:Common Lisp 程序在语言处理器的不同处理阶段有不同的形式体现,在读取器中识别的是 S-表达式,在求值器中识别的是 Lisp形式--form ,S-表达式的基本元素是列表(list)和原子(atom)。关于 S-表达式 和 Lisp形式 form 的区别之前我们稍微讨论过一些。

对于初学者来说,Common Lisp 程序就是用无数括号括起来的各种符号表达式,括号里的 S-表达式 可以有这么几种组合:纯粹的原子,纯粹的列表,列表和原子,如下:

(原子1 原子2 原子3)
(列表1 列表2 列表3)
(原子1 列表1 原子2 原子3 列表2)

但是前面也说了,并非所有的 S-表达式 都是合法的 Lisp形式--form ,这里我们似乎可以给 Lisp形式--form 下一个简单的定义:

能够在 REPL 求值器里正常求值的 S-表达式 才是合法的 Lisp形式--form

有了这个定义,我们就可以直接在 REPL 中试验了,把你想要验证的 S-表达式 输入到 REPL 中,然后回车

理论上说,lisp程序形式可以由任何符号形式组成,不过对于初学者而言,暂时还没必要深究这些,就老老实实地使用括号语法吧。

关于 Lisp 括号的笑话:话说一名黑客冒死窃取到美国核弹控制程序的最后一页,打开一看,满满一页右括号。。。

:)

2、Lisp 中的列表是什么样的?

《GNU Emacs Lisp 编程入门》中是这么说的:

“列表由 0 个或者更多的原子或者内部列表组成,原子或者列表之间由空格分隔开,并由括号括起来。列表可以是空的”。

《实用 Common Lisp 编程》中是这么说的:

 “S-表达式的基本元素是列表和原子。列表由括号所包围,并可包含任何数量的由空格所分隔的元素。列表元素本身也可以是
S-表达式--也就是原子嵌套的列表”
 
 “任何原子(非列表或空列表)都是一个合法的 Lisp形式”

这就是列表的句法规则(syntax)

3、Lisp 中的原子又是什么样的?

《GNU Emacs Lisp 编程入门》中是这么说的:

原子是多字符的符号(如 forward-paragraph)、单字符符号(如 + 号)、双引号之间的字符串、或者数字。

这里补充一下,Emacs Lisp 和 Common Lisp 的原子概念有所不同,Common Lisp 中有一个名为 atom 的函数,可以用来判断是否原子,使用方式如下:

CL-USER> (atom 'sss)
T
CL-USER> (atom (cons 1 2))
NIL
CL-USER> (atom nil)
T
CL-USER> (atom '())
T
CL-USER> (atom 3)	
T
CL-USER> (atom +)
NIL
CL-USER> (atom "qwert qwer")
T
CL-USER> (atom -)
NIL
CL-USER>

实际试验一下就会发现,在 Common Lisp 中,单字符符号,如 + 号,是不被判断为原子类型的。

(atom object) 等价于 (typep object 'atom) 等价于
(not (consp object)) 等价于
(not (typep object 'cons)) 等价于
(typep object '(not cons))

说明:(typep object 'atom) 这条语句的含义是: object 是否为类型 atom,typep 就是一个关于类型的谓词判断函数,因为 atom 既是一个 函数,又是一种 类型 ,在这条语句中 atom 作为 类型 来使用。

4、Lisp 中求值的概念:对数字、符号、字符串和列表求值

前面一再提到“求值”,那么什么是求值?在这一点上 Emacs Lisp 和 Common Lisp 的差异较大,前者相对简单,使用解释方式,后者相对复杂,既可以使用解释方式,也可以采用编译方式,很多实现都采用编译方式。

《Emacs Lisp 编程入门》中的描述如下:

当 Lisp 解释器处理一个表达式时,这个动作被称作“求值”。我们称,解释器计算表达式的值。
对数字求值就是它本身
对双引号之间的字符串求值也是其本身
当对一个符号求值时,将返回它的值
当对一个列表求值时,lisp解释器查看列表中的第一个符号以及绑定在其上的函数定义。然后这个函数定义中的指令被执行。(这里指的是列表中第一个符号是一个函数的场景)

《实用 Common Lisp 编程》中给出一种便于理解讨论的描述如下 :

为了便于讨论,你可以将求值器想象成一个函数,它接受一个句法良好定义的 Lisp形式 作为参数并返回一个值,我们称之为这个形式的值。当然,当求值器是一个编译器时,情况会更加简化一些----在那种情况下,求值器被给定一个表达式,然后生成在其运行时可以计算出相应值的代码。

原子可分为符号和所有其他类型,符号在作为 Lisp形式 被求值时会被视为一个变量名,并且会被求值为该变量的当前值(符号宏 symbol macro 有不同的求值方式,不过新手可以暂不不去理会)。

所有非符号类型的原子,比如数字和字符串,都是自求值对象,这就意味着当这样的表达式被传递给我们假想的求值函数时,它会简单地直接返回自身。

当我们开始考虑列表的求值方式时,事情变得更加有趣了。所有合法的列表形式均以一个符号开始,但是有三种类型的列表形式,它们会以三种相当不同的方式进行求值。为了确定一个给定的列表是哪种形式,求值器必须检测列表开始处的那个符号是(列表的第一个符号)什么类型:是函数、宏、还是特殊操作符的名字。如果该符号尚未定义,比如说当你正在编译一段含有对尚未定义函数的引用代码时,它会被假设成一个函数的名字。这三种类型的形式称为函数调用形式、宏形式和特殊形式。

简单地说,你在 REPL 中输入一个 Lisp 形式--form,然后敲回车,就启动了一个求值过程,如果你输入的是一个符号原子,那么 Lisp 会把其当做一个变量处理,返回该变量的当前值,如果你输入的是一个非符号原子(自求职对象),那么 Lisp 会直接返回该对象自身。

这里再对“对自求值对象求值时,它会简单地返回自身”补充一点说明:

《实用 Common Lisp 编程》中提到:

对于一个给定类型的数字来说,它可以有多种不同的字面表示方式,所有这些都将被 Lisp 读取器转化成相同的对象表示。例如,你可以将整数 10 写成 10、20/2、#xA 或是其他形式的任何数字,但读取器将把所有这些转化成同一个对象。当数字被打印回来时,比如在 REPL中,它们将以一种可能与输入该数字时不同的规范化文本语法被打印出来。如下:

CL-USER> 10
10
CL-USER> 20/2
10
CL-USER> #xa
10
CL-USER> 

5、对列表中函数调用形式、宏形式和特殊形式求值

《GNU Emacs Lisp 编程入门》中是这么说的:

当对一个函数求值时总是返回一个值(除非得到一个错误消息)。另外,它也可以完成一些被称作附带效果的操作。在许多情况下,一个函数的主要目的是产生一个附带效果。

《实用 Common Lisp 编程》中说得更详细一些 :

函数调用形式的求值规则很简单 ,对以 Lisp形式 存在的列表其余元素进行求值并将结果传递到命名函数中(也就是列表的第一个元素)。
当列表的第一个元素是一个由特殊操作符所命名的符号时(简单说就是一个特殊操作符),表达式的其余部分将按照该操作符的规则进行求值。
先说一下宏,宏是一个以 S-表达式 为其参数的函数,并返回一个 Lisp形式,然后对其求值并利用该值取代宏形式。
宏形式求值过程包括两个阶段:首先,宏形式的元素不经求值即被传递到宏函数里;其次,由宏函数所返回的形式(称其为展开式)按照正常的求值规则进行求值。

6、单引号的特殊作用--构建宏的基础

《GNU Emacs Lisp 编程入门》中是这么说的:

单引号告诉lisp解释器返回后续表达式的书写形式,而不是像没有单引号那样对其求值。

《实用 Common Lisp 编程》中是这么说的:

单引号是 quote 语句的语法糖。

也就是说,形如 '(1 2 3) 的语句实际上就是 (quote (1 2 3 )),实际执行效果一样,如下:

CL-USER> (quote (1 2 3 ))
(1 2 3)
CL-USER> '(1 2 3)
(1 2 3)
CL-USER> 

现在我们使用的 SBCL 和 CCL 中,都把单引号设置为 quote 的语法糖,也就是说,在这两种实现中,我们可以很方便地用单引号来代替 quote ,通过上面的例子可以很清楚地看到,使用语法糖可以简化代码,所以我们推荐初学者在代码中多使用语法糖。

Common Lisp 实际上提供了修改这种对应关系的宏,也就是说你可以为 quote 设置其他不同的符号来做语法糖,不过对于常见的程序开发来说,没必要修改,而且要尽量避免修改这种对应关系。

7、本章内容小结

  • Lisp 程序由 S-表达式组成,表达式是列表或单个原子
  • 列表由 0 个或者更多的原子或者内部列表组成,原子或者列表之间由空格分隔开,并由括号括起来。列表可以是空的
  • 原子是多字符的符号(如 forward-paragraph)、单字符符号(如 + 号)、双引号之间的字符串、或者数字
  • 对数字求值就是它本身(自求值对象都直接返回其自身)
  • 对双引号之间的字符串求值也是其本身(自求值对象都直接返回其自身)
  • 当对一个符号求值时,将返回它的值(作为变量看待,则返回该变量的当前值)
  • 当对一个列表求值时,lisp解释器查看列表中的第一个符号以及绑定在其上的函数定义。然后这个函数定义中的指令被执行
  • 单引号告诉lisp解释器返回后续表达式的书写形式,而不是像没有单引号那样对其求值
  • 参量是传递给函数的信息。除了作为列表的第一个元素的函数之外,通过对列表的其余元素求值来计算函数的参量(这里的参量就是我们常说的参数,《GNU Emacs Lisp 编程入门》统一把它翻译成参量,我感觉参量的翻译更准确一些,不过因为用习惯了参数,后面我还是使用参数)
  • 当对一个函数求值时总是返回一个值(除非得到一个错误消息)。另外,它也可以完成一些被称作附带效果的操作。在许多情况下,一个函数的主要目的是产生一个附带效果(不过纯函数式编程是希望杜绝这种附带效果的)

【说明】:这10条小结是 《GNU Emacs Lisp 编程入门》第一章总结出来的,该书主要讲解 Emacs Lisp 的基本概念和应用,客观地说 Emacs Lisp 跟 Common Lisp 虽然都是从 Lisp 演化而来,但还是存在着很大差异的,不过我在学习过程中发现对于 Common Lisp 初学者而言,可以拿 Emacs Lisp 一起来对照学习,尤其是对一些基本概念的理解,非常有帮助,而且如果 Common Lisp 初学者选择了 Emacs 作为开发环境,那你肯定需要熟悉 Emacs Lisp 的使用,否则就无法充分利用 Emacs 的高效作业。因此专门拿了一个章节来介绍 Emacs Lisp 的这些基本概念,希望初学者们能从中得到助益。

四、一个简单实践:文本数据库-藏书阁

本章以一个 Common Lisp 的实际例程为主要内容,开发过程也尽量采用最适合 Common Lisp 的逐步完善、反复迭代的开发方式。 这个例子基本上全部代码都照搬了《实用 Common Lisp 编程》中的第三章“实践:简单的数据库”的内容,不过我按照自己的讲解方式稍作修改,看看这种讲述风格是否能被大家接受,同时把 CD 数据库改为书籍数据库,因为我觉得对于喜欢阅读的中国读者来说,书籍数据库可能更实用一些。

在此要感谢作者 Peter Seibel 和译者 田春 的努力,为我们提供了这么好的教程,如果作者或译者对我大量直接引用他们二位的例程有异议,请告知,我会删除重写一个例程--不过还是希望能得到二位的同意 :)。

摘录一句作者 Peter Seibel 的原文:

“本章的重点和意图也不在于讲解如何用 Lisp 编写数据库,而在于让你对 Lisp 编程有个大致的印象,并能看到即便相对简单的 Lisp 程序也可以有着丰富的功能”

1、项目简单说明

很多朋友都喜欢买实体书阅读,久而久之家里的藏书就越来越多,占满了书架,不得不把一些旧书打包到箱子里放到床下,但是有时忽然想查找某本书的内容时才发现一时找不到这本书了,于是只好翻箱倒柜一个箱子一个箱子查看,最后终于找到了,但是费了半天劲,而且搞得满身灰尘,然后坐在电脑前小憩时忽然发现原来 F:\ 盘的电子书目录里有这本书的电子版,嘿,是不是觉得特别坑爹。

很好,我们这个小型项目就是教你如何去建立一个藏书数据库,把你的所有藏书信息都输入到电脑里,包括书名、作者、内容简介、价格、购买时间、实体书保存位置、是否有电子版、电子版保存位置等等信息,同时提供查询的功能,可以根据关键字进行检索,拥有这样一个藏书数据库是不是会提高你对藏书的使用效率呢?

那就让我们一起开始吧!

2、定义你的原型数据结构

我们已经了解了自己的软件需求,那么接下来就是选择相应的数据结构了,需要选择一种方式来表示每一条数据库记录,而每条数据库记录要包含上述提到的内容:

(书名、作者、内容简介、购买时间、价格、实体书保存位置、是否有电子版、电子版保存位置)

上面的内容怎么看怎么像个列表啊,把分隔每个项目的顿号去掉换成空格,它不就是一个列表吗?

(书名 作者 内容简介 购买时间 价格 实体书保存位置 是否有电子版 电子版保存位置)

不过为了简化程序输入,我们把上述列表项目稍作缩减,程序中使用如下列表:

(书名 作者 价格 是否有电子版)

既然天意让它看起来这么像一个列表,那我们就使用列表作为基本的数据结构。

列表知识:我们可以使用 list 函数来生成一个列表,正常执行后,它会返回一个尤其参数组成的列表,如下:

CL-USER> (list 1 2 3 4)
(1 2 3 4)
CL-USER> 

不过鉴于我们希望在使用每条记录的每个字段时都能有对该字段的一个明确的描述,而不是必须使用数字索引来访问,所以我们选择一种被称为属性表(property list,plist)的列表,这种列表中的第1个元素用来描述第2个元素,第3个元素用来描述第4个元素,以此类推,第奇数个元素都是用来描述相邻的第偶数个元素的,换句话说就是:从第一个元素开始的所有相间元素都是一个用来描述接下来那个元素的符号(原文引用 :)),在 plist 里奇数个元素的写法使用一种特殊的符号--关键字符号(keyword)。

关键字符号是任何以冒号开始的名字,例如---》    :书名

下面是一个使用了关键字符号作为属性名的示例 plist :

CL-USER> (list :书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 t)
(:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T)
CL-USER> 

这里要提到一个属性表的函数 getf ,它可以根据一个 plist 中的某个字段名(属性名)来查询对应的属性值,如下所示,我们想要查询刚才建立的 plist 中的 :书名 属性名所对应的属性值:

CL-USER> (getf (list :书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 t) :书名)
"人间词话"
CL-USER> 

如果想查 :作者 是什么,输入如下:

CL-USER> (getf (list :书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 t) :作者)
"王国维"
CL-USER> 

有了上述这些基本知识,我们就可以写出一个简单的名为 建立书籍信息 的函数了,它以参数的形式接受 4 个属性字段,然后返回一个代表该书的 plist:

(defun 建立书籍信息 (书名 作者 价格 是否有电子版)
	(list :书名 书名 :作者 作者 :价格 价格 :是否有电子版 是否有电子版)) 

我们定义了这个函新数,函数名是 建立书籍信息 ,跟在函数名后面的是形参列表,这个函数有 4 个形参,分别是: 书名、作者、价格、是否有电子版,这个示例中的函数体只有一个 Lisp形式--form ,这个唯一的 Lisp形式 就是对函数 list 的调用。当函数 建立书籍信息 被调用时,传递给该调用的 4 个实参将被绑定到形参列表中的变量上。比如为了建立刚才那本《人间词话》的书籍信息,我们可以这样调用这个函数:

CL-USER> (建立书籍信息 "人间词话" "王国维" 100 t)
(:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T)
CL-USER>  

列表实际上有 8 个元素,第奇数个元素是 字段名 ,第偶数个元素是 字段值,奇数位置的 字段名 使用关键字符号表示(以冒号打头),偶数位置的 字段值 则根据实际类型来选择 字符串数字布尔值 来表示。

为了例程书写方便,我们只使用了 4 个字段来记录书籍信息,但是对于一本书来说,4 个字段的信息当然有些不足,后续大家可以自行增加其他字段。

3、正式开工:首先是数据录入模块

只有一条记录的数据库对应一个 plist 列表,只能记录一本书的信息,显然无法满足我们的实际需求,因此我们准备使用全局变量来记录多个列表的信息,每个列表就是一条记录,保存一本书的信息。

在 Common Lisp 中,全局变量的命名约定是名字前后各加一个星号 * ,这样的形式:

*书籍数据库*

全局变量可以使用宏 defvar 来定义,初值为 nil,如下:

(defvar *书籍数据库* nil)

我们可以使用宏 push 来为全局变量 *书籍数据库* 增加新的记录,但是这里希望能稍微做得抽象一些,于是就要定义一个函数: 增加记录,具体定义如下:

(defun 增加记录 (书籍信息)
	 (push 书籍信息 *书籍数据库*))

很好,现在就可以把两个函数结合在一起使用了,先用 建立书籍信息 建立一条书籍信息的记录,该函数返回一个 plist,再把这个 plist 作为函数 增加记录 的输入参数,由函数 增加记录 把该条数据添加到用全局变量 *书籍数据库* 中。

CL-USER> (增加记录 (建立书籍信息 "人间词话" "王国维" 100 t))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> (增加记录 (建立书籍信息 "说文解字" "许慎" 100 t))
((:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T) 
 (:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> (增加记录 (建立书籍信息 "难忘的书与插图" "汪家明" 38 t))
((:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 

为什么每次执行完后,会把整个数据库的内容都返回呢?因为每次执行这段增加记录的代码实际上执行的是 push 这个宏,而 push 所修改的全局变量 *书籍数据库* ,其实是这样一个大列表 ((plist1) (plist2) (pist3)),push 会把它正在修改的变量的新值返回,对 push 来说,它修改的变量就是这个大列表---它每次在里面增加一个小列表,因此每次执行后都会把它修改后的大列表整个返回。

4、其次是数据显示模块

此时我们可以在 REPL 中输入全局变量 *书籍数据库* 来查看它的当前值如下:

CL-USER> *书籍数据库*
((:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER>

但是很显然,这种查看输出的方式有些凌乱,我们可以新一个名为 转储显示 的函数来把数据库内容稍微整理一下显示格式,然后再输出,希望效果如下:

书名:            难忘的书与插图
作者:            汪家明
价格:            38
是否有电子版:        T

书名:            说文解字
作者:            许慎
价格:            100
是否有电子版:        T

书名:            人间词话
作者:            王国维
价格:            100
是否有电子版:        T

该函数如下所示:

(defun 转储显示 ()
	(dolist (单条书籍记录 *书籍数据库*)
		(format t "~{~a: ~20t~a~%~}~%" 单条书籍记录)))

该函数的工作原理是使用 dolist 宏在 *书籍数据库* 的所有元素上循环,依次绑定每个元素到变量 书籍字段信息 上,然后再用 format 函数打印出每个 书籍字段信息 的值。

这里稍微介绍一下 format 的语法,它就像 C 语言中的函数 printf 一样,使用格式控制字符来实现格式化输出。

format 函数的第一个实参是它的输出目的地,这里是 t ,是一个简称,表示标准输出流 *standard-output* ,它的第二个实参是一个格式字符串,格式字符串也是一个用双引号引起来的字符串,为了区别于一般的字符串,它使用 ~ 符号来标识格式指令(类似于 printf 函数的格式指令 %)。

下面针对函数 转储显示 中使用的这条 format 进行解析:

(format t "~{~a: ~20t~a~%~}~%" 单条书籍记录)))

首先明确一点,所有的格式指令都以 ~ 符号开始,各指令具体含义如下所示:

~{ 		format 的循环语法,表示下一个对应的实参是一个列表的开始,然后 format 会在该列表上进行循环操作,处理位于 ~{ 和 ~} 之间的指令,每轮循环处理多少个实参取决于 ~{ 和 ~} 之间有多少个对应实参的指令,执行多少轮循环取决于 “单挑书籍记录” 中的元素的个数(确切说:循环轮数 = 元素个数 除以 每轮循环处理实参个数),所以可以通过使用 ~{ 和 ~} 来实现循环,
~}		同上,和 ~{ 配合使用
~a		美化指令,该指令对应一个实参,会把这个实参的显示形式输出为更适合阅读的形式,具体说就是形如 :书名 的关键字在输出时会被去掉冒号,形如 "人间词话" 的字符串在输出时会被去掉双引号
~t 		表示制表指令,不对应实参,只移动光标,~20t 告诉 format 把光标向后移动 20 列
~%		表示换行,不对应实参

另外要注意	格式指令字符串中所有的非格式指令均以原样输出,比如 ~a 后面的冒号 : 和空格就直接原样输出

再对照我们上述的代码,就比较清楚了,首先是一个大循环:

(dolist (单条书籍记录 *书籍数据库*)
	( 。。。)))

每轮大循环都从 *书籍数据库* 里取出一条记录,把该条记录的内容赋值给(绑定到)变量 单条书籍记录 ,其内容如下:

(:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T)

然后再进入函数 format 内的小循环,我们看到在表示小循环的 ~{ 和 ~} 之间,有多个格式指令,但是只有两个 ~a 指令需要对应两个实参,其他格式指令分别用于移动光标和换行,所以每轮小循环处理两个字段,像这个例子就是:

1)当 format 看到 ~{ 就进入第一轮小循环;

2)先处理 :书名"人间词话" 这两个字段,第一个 ~a 指令把 :书名 字段的冒号 : 去掉,输出 书名

直观演示一下:

CL-USER> (format t "~{~a: ~20t~a~%~}" (list :书名 "人间词话"))
书名:                 人间词话
NIL
CL-USER> 

3)紧跟着 ~a 指令的 冒号空格 原样输出;

换个符号,把第一个 ~a 后面的冒号空格换成 ====》试试:

CL-USER>  (format t "~{~a====》 ~20t~a~%~}" (list :书名 "人间词话"))
书名====》             人间词话
NIL
CL-USER>

4)指令 ~20t 则把光标右移20列;

把 ~20t 换成 ~50t 试试:

CL-USER> (format t "~{~a: ~50t~a~%~}" (list :书名 "人间词话"))
书名:                                               人间词话
NIL
CL-USER>

5)第二个 ~a 指令把 "人间词话" 字段的双引号 "" 去掉,输出 人间词话

6)指令 ~% 则执行换行;

7)然后 format 看到 ~} ,知道本轮循环结束;

8)此时因为 单条书籍记录 中还剩下后面 6 个字段,于是启动第二轮小循环;

9)这次处理 :作者"王国维" 这两个字段;

10)接下来的处理跟上述的处理类似 。。。

11)第三轮小循环处理 :价格100 这两个字段;

11)一直到第四轮小循环,处理完 :是否有电子版T 这两个字段;

12)这时 单条书籍记录 的所有元素都已经完成处理,就结束小循环,执行最后的 ~% ,换行。

然后就是下一轮大循环,再从 *书籍数据库* 里取出第二条记录,然后把第二条记录的内容赋值给变量 *书籍数据库* ,然后就再次进入小循环,。。。就这样反复循环,直到把 *书籍数据库* 中的所有记录都循环一遍,这时就完成了大循环。

看了上述的分析,你就会发现,其实那个大循环并不是一定要有的,完全可以把所有的循环操作都放在 format 中处理,让 format 直接在 *书籍数据库* 这个大列表上循环处理其中每个小列表中的字段信息,代码如下:

(defun 转储显示 ()
	(format t "~{~{~a: ~20t~a~%~}~%~}" *书籍数据库*)))

修改很简单,首先是在 format 原来的格式字符串最外围增加一对 ~{ 和 ~} ,其次就是循环的对象由原来的 单条书籍记录 改为 *书籍数据库* ,两种形式都可以,不过就我个人而言,比较推荐第一种,因为看起来更清晰,更具备可读性。

5、充分发挥迭代的优势:改进一下输入方式

程序写到这里,已经能够接受信息输入、把信息储存到数据库、显示数据库的信息到屏幕,可以说初具规模了,可是有些朋友可能会觉得那个输入方式的界面太不友好,什么提示也没有,而且如果一旦需要大量输入时,这种操作不太方便,也可能出错,所以提出希望能在这里迭代一下---把旧的输入函数改造成一个更好用的、有提示的输入界面,很好,下面我们先写一个带提示信息的输入接口:

(defun 提示输入 (提示信息)
	(format *query-io* "~a: " 提示信息)
 	(force-output *query-io*)
 	(read-line *query-io*))

首先使用 format 产生一个提示,然后用 force-output 保证在不同 Common Lisp 实现中都表现出相同的效果---确保 Lisp 在打印提示信息前不会等待用户输入换行。

然后使用函数 read-line 来读取单行文本。变量 *query-io* 是一个含有关联到当前终端的输入流的全局变量。

把这个 提示输入 函数和我们前面的 建立书籍信息 函数组合起来,构造出一个新函数,每次输入前都会提示应该输入哪个字段,如下:

(defun 提示书籍信息 ()
	(建立书籍信息 
		(提示输入 "书名")
		(提示输入 "作者")
 	    (提示输入 "价格")
	    (提示输入 "是否有电子版[y/n]")))

这样在输入每个字段都增加一个对应的字段内容提示信息,用起来就不容易输错了。

在这里《实用 Common Lisp 编程》的作者专门提及用户输入验证的问题,并据此对 价格 字段和 是否有电子版 字段的输入函数做了针对性的修改。

我觉得作者在此处的讲解表现出相当不凡的专业素养!每一个初学者都应该把这一页内容反复理解,尽量培养自己的这种对于用户输入验证精益求精的态度,这是评价一个软件是究竟是一个玩具软件还是商用级别的工业软件的关键标准!

养成这种良好的习惯,你编写的哪怕是一个最小规模的的软件从一开始就不会因为用户的各种错误输入而意外崩溃,健壮性是非常、非常重要的!

而这种细节习惯的养成也将会节省你大量的返工时间---虽然在开始时要多花一些时间去考虑各种输入场景,不过我建议如果做商业开发可以把用户输入验证这部分功能代码做成统一的模块,由专人负责维护,其他人直接重用就行了,这样可以兼顾健壮性和效率,如果是个人开发者也可以专门投入一定时间把这一部分模块化,以后每次直接使用就可以了。

健壮的格式如下:

(defun 提示书籍信息 ()
	(建立书籍信息 
		(提示输入 "书名")
		(提示输入 "作者")
 	    (or (parse-integer (提示输入 "价格") :junk-allowed t) 0)
		(y-or-n-p "是否有电子版[y/n]: ")))

小作业:

:价格 字段在上面的输入数据验证中是当做整数处理的,实际实际的价格非常可能不是整数,现在虽然大多数图书标价都是整数,但是一打折不就有小数出来了?所以这里真正需要的是能满足整数和小数的输入验证,就当做作业,自己去思考怎么验证吧。

上面修改后的输入函数可以很好地提示和验证,但是有个问题,就是每输入一本书的信息就需要执行一次,批量输入时岂不是很繁琐?那我们就把它作成循环的输入接口好了。

批量输入代码如下:

(defun 批量输入 ()
	(loop (增加记录 (提示书籍信息))
 		(if (not (y-or-n-p "还要继续输入下一本书籍的信息吗?[y/n]: ")) (return))))

执行效果如下:

CL-USER> (批量输入 )
书名: 血色黄昏
作者: 老鬼
价格: 25
是否有电子版[y/n]: n

还要继续输入下一本书籍的信息吗?[y/n]:  (y or n)  n

NIL
CL-USER>

6、保存和加载已经录入的数据

我们上面建立的数据库依赖于全局变量 *书籍数据库* ,所有的数据库信息都储存在内存里,一旦重启 Common Lisp 环境,这些数据就全部丢失了,因此为了能在重启后保持数据库不丢失,我们准备把建立在内存里的数据库以一个文本文件的形式保存到硬盘上,这样就可以在重启后加载这个文本文件形式的数据库到内存,避免了每次都要重新输入的烦恼,代码如下:

(defun 保存数据库 (带路径的保存文件名)
	(with-open-file (文件绑定变量 带路径的保存文件名
		       			  :direction :output
	       				  :if-exists :supersede)
		(with-standard-io-syntax
  			(print *书籍数据库* 文件绑定变量))))

该函数的实参 带路径的保存文件名 是一个含有用户打算用来保存数据库的文件名字符串,在 MS-Windows 和 Mac OSX 操作系统上,应该携带文件路径,比如在 Mac OSX 系统下应该这样调用:

CL-USER> (保存数据库 "~/ecode/markdown-doc/book-db.txt")
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n")
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 

在 MS-Windows 系统下应该这样调用:

CL-USER> (保存数据库 "F://ecode//markdown-doc//book-db.txt")

至于数据库文件名,我这里使用了 book-db.txt ,之所以选择保存为 txt 格式的文件,是为了方便查看其内容,随便找一个文本编辑器就可以打开 txt 文本文件,其实你可以使用任何一种后缀名,甚至可以自定义一种后缀名,专门作为这个程序的数据库文件格式。

这里用到了 print 函数,它会将 Lisp对象 打印成一种可以被 Lisp读取器 读回来的形式。

这段代码的具体操作就是:

1)首先,宏 with-open-file 根据我们输入的参数 带路径的保存文件名 打开一个文件,然后将文件流绑定到 文件绑定变量 上;

2)接着会执行一组表达式,就是这个:

(with-standard-io-syntax
  			(print *书籍数据库* 文件绑定变量)

3)这组表达式执行的操作如下:宏 with-standard-io-syntax 确保对函数 print 的一致性使用---有些特定的变量的值可能会影响函数 print 的行为,现在由宏 with-standard-io-syntax 把这些特定变量全部设置为标准值,代码 (print *书籍数据库* 文件绑定变量) 则把 *书籍数据库* 的内容打印到 文件绑定变量 ,因为 文件绑定变量 绑定到了我们新打开的文件上,所以实际上就把 *书籍数据库* 的内容写入到文件中了;

4)执行完这组表达式,再由宏 with-open-file 关闭文件。

看到这里就会发现这个宏 with-open-file 有个好处,就是不需要我们手动关闭文件,它会自动关闭,非常环保啊,以后一定要多用这个宏! :)

既然写好了保存函数,那就再写一个加载函数,代码如下:

(defun 加载数据库 (带路径的加载文件名)
	(with-open-file (文件绑定变量 带路径的加载文件名)
		(with-standard-io-syntax
  			(setf *书籍数据库* (read 文件绑定变量)))))

加载代码的操作跟保存代码相反,不过使用了类似的宏和函数,就不再详述了。

值得注意的是这两个函数 printread

print 可以打印 Lisp对象,以一种 Lisp读取器 可以读取的形式。

read 可以从流中读入数据,它使用与 REPL 相同的 读取器,可以读取我们在 REPL 提示符下输入的任何表达式。

7、查询数据库

有了方便、友好的批量输入函数,意味着我们的藏书数据库中的书籍信息记录可能会越来越多,这样如果每次使用函数 转储查看 想查看有哪些书时就不得不面对满屏的信息了,是不是感觉不太方便?记得好像有本书,讲的主题就是《数量一多,一切就都不一样了》,我们也遇到了第一个瓶颈---数量带来的麻烦。

那就想办法解决这个小瓶颈吧,再次进入我们的迭代流程,我们需要实现的就是一个能够按照给出条件进行筛选查找的函数,比如你这次查书籍数据库只是想找一找 王国维 写的书,换句话说,就是根据 :作者 "王国维" 这组数据进行查找,写成函数就是:

(查找 :作者 "王国维")

Common Lisp 提供了这样一个函数 remove-if-not ,它有两个参数,第一个参数是一个 谓词,第二个参数是一个 列表,它会返回一个仅包含 原始列表 中匹配该 谓词 的所有元素的新列表,举个简单的数字例子,有一个由一些自然数组成的列表,我们想把其中所有的偶数取出来,如下所示:

CL-USER> (remove-if-not #'evenp '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)
CL-USER> 

这里的 谓词 是函数 evenp ,当它的参数是偶数时返回真,符号 #' 在前面提到过,是一个表示后续符号是函数的符号,等价于函数 function ,表示要把函数 evenp 作为参数,也可以写成这样:

CL-USER> (remove-if-not (function evenp) '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)
CL-USER> 

如果没有函数 evenp,或者你不知道这个函数,也可以自己写一个匿名函数,如下:

CL-USER> (remove-if-not #'(lambda (x) (= 0 (mod x 2)))  '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)
CL-USER> 

在这里我们首次提到了 匿名函数,它是这样一种形式:lambdadefun 的语法非常接近,lambda 后面紧跟着形参列表,然后是函数体。

也就是说,我们现在需要写的函数 查找 ,它会去逐条对比数据库中的记录,遇到 :作者 字段的字段值为 "王国维" 时就返回真,前面介绍过的 getf 函数,现在可以拿来使用了,可以用它来获取 单条记录:作者 字段的值,也就是列表 (:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T) 中的第二个元素,语句如下:

(getf 单条记录 :作者)

实际上等价于这条语句:

(getf (:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T) :作者)

再用一个比较函数 equal 拿它返回的值跟一个我们输入的包含作者名字的字符串参数进行比较,比如我们想拿作者名字是 "王国维" 的字符串进行比较,代码如下:

(equal (getf 单条记录 :作者) "王国维")

那么完整的代码如下:

CL-USER> (remove-if-not #'(lambda (单条记录) (equal (getf 单条记录 :作者) "王国维")) *书籍数据库*)
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 	

我们可以把上面这段代码包装一下,做成一个可以用 作者 作为输入参数的的函数里,如下:

(defun 用作者名查找 (查找字符串-作者)
	(remove-if-not 
	   #'(lambda (单条记录) 
   			(equal (getf 单条记录 :作者) 查找字符串-作者)) 
       *书籍数据库*))

这个函数涉及到 Common Lisp 的一个据说是比较有趣的特性---闭包,不过奇怪的是我从没觉得闭包有什么特别。。。

这样我们完成一个可以通过 作者 来查询的函数,但是很可能你还会需要通过 价格 查询、通过 书名 查询、通过任意一个字段查询,怎么办呢?难道要把这些函数都写一遍吗?感觉好像有很多重复代码,先写一个通过书名查询的函数看看:

(defun 用书名查找 (查找字符串-书名)
	(remove-if-not 
	   #'(lambda (单条记录) 
   			(equal (getf 单条记录 :书名) 查找字符串-书名)) 
       *书籍数据库*))

果然不出所料,整个函数体中只有匿名函数体中这条语句 (getf 单条记录 :书名) 里的 :书名 跟上一个函数的 :作者 不一样。

那么很显然,针对每个字段编写这么一个函数是一种比较愚蠢的行为,我们现在还只有 4 个字段,编 4 个基本类似的查找函数还勉强行得通,可如果将来扩展到 100 个字段怎么办? 难道要编 100 个极其相似的查找函数?

这种疯狂、低效的行为我们是绝不提倡的,那么就想办法把这个功能再抽象一下,用一种通用的方法来实现,因为上述两段代码唯一的区别在匿名函数,我们可以把匿名函数抽象出来。

假设用 根据?查找函数 这个名称来代替匿名函数,其中的 ? 可以换成 书名作者 乃至任何一个字段,我们再定义一个通用的 查找 函数,它以函数 *根据?查找函数 为参数,伪码如下:

(defun 查找 (根据?查找函数)
	(remove-if-not 
		根据?查找函数 
		*书籍数据库*))

对比一下,就会发现这个通用的函数 查找 的结构跟前面的函数 用作者名查找用书名查找 基本一样,唯一不同的地方就是用 根据?查找函数 替换了原来的匿名函数 #'(lambda (单条记录) (equal (getf 单条记录 :书名) 查找字符串-书名))

为什么 remove-if-not 的第一个参数 根据?查找函数 没有使用 #' ?因为对于函数 remove-if-not 来说,它不希望得到一个固定的名为 根据?查找函数 的函数,实际上它也无法得到这个函数,因为这个函数不存在,符号 根据?查找函数 只是一个变量,是一个用于参数传递的变量,这个变量先接收一个匿名函数保存起来,再把它保存的匿名函数作为函数 查找 的实参传递给函数 查找 的相应位置。

当你真正执行函数 查找 时,你还是会在它的输入参数(也就是那个匿名函数)前加上 #' ,如下:

CL-USER> (查找 #'(lambda (单条记录) (equal (getf 单条记录 :作者) "王国维")))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 

不过不加 #' 也可以,如下:

CL-USER> (查找 (lambda (单条记录) (equal (getf 单条记录 :作者) "王国维")))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 

这是因为 Common Lisp 对于匿名函数 lambda 的处理机制如此,不带 #'lambda 匿名函数,当它出现在一个会被求值的上下文时,会被展开成一个带 #'lambda 匿名函数,比如 (lambda () 42) 会被展开成 #'(lambda () 42)

想要深究的朋友可以尝试一下这个【错误试验】

感觉这种调用方法看起来有些不太清爽,长长的一串,我们再用一个函数把匿名函数也包装一下,如下:

(defun 选择器-选作者 (作者)
	#'(lambda (单条记录) 
   		(equal (getf 单条记录 :作者) 作者)))

现在对函数 查找 的调用看起来清爽多了,如下:

CL-USER> (查找 (选择器-选作者 "王国维"))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 

那么接下来就要定义其他的选择器函数了,比如 选择器-书名选择器-价格 等等,可这些工作同样会有大量重复代码,于是我们希望继续抽象,把共同的部分提炼出来,干脆搞一个通用的选择器函数生成器,它可以根据传递的参数,自动生成可用于不同字段甚至字段组合的选择器函数。

这个选择器函数生成器需要我们增加一点关于函数的知识储备:关键字形参 &key

目前我们所使用过的函数都是比较简单的形参列表,形参和实参一一对应地进行绑定,函数定义了几个形参,在调用时就必须输入几个实参,否则就会报错。但是很多时候,我们都希望函数能够提供一种灵活的参数输入方式,比如可以指定对特定参数的输入,同时有些参数如果没有输入就由函数自动设置一个默认值。

关键字形参 &key 可以实现上述这些需求,它与普通形参的唯一区别就是在形参列表开始处有一个 &key ,如下:

(defun 示例函数 (&key a b c ) (list a b c))

执行效果如下:

CL-USER> (defun 示例函数 (&key a b c ) (list a b c))
示例函数
CL-USER> (示例函数 :a 1 :b 2 :c 3)
(1 2 3)
CL-USER> (示例函数 :c 3 :b 2 :a 1)
(1 2 3)
CL-USER> (示例函数 :c 3 :a 1)
(1 NIL 3)
CL-USER> (示例函数 )
(NIL NIL NIL)
CL-USER> 

还可以判断某个形参的值是从实参传进去的还是由函数自己指定的

。。。。。

好,知识储备更新完毕,现在继续研究我们的通用选择器函数生成器,我们给它起个名字就叫 筛选条件 ,它应该可以接受对应于我们的书籍记录字段的 4 个关键字形参,然后生成一个选择器函数,我们希望是这样的形式:

(查找 (筛选条件 :作者 "王国维"))
(查找 (筛选条件 :书名 "人间词话"))

具体的代码如下:

(defun 筛选条件 (&key 书名 作者 价格 (是否有电子版 nil 是否有电子版-p))
	#'(lambda (单条记录)
  		(and 
   			(if 书名           
   				(equal (getf 单条记录 :书名) 书名) t)
   			(if 作者           
   				(equal (getf 单条记录 :作者) 作者) t)
   			(if 价格           
   				(equal (getf 单条记录 :价格) 价格) t)
   			(if 是否有电子版-p  
   				(equal (getf 单条记录 :是否有电子版) 是否有电子版) t))))

这个函数根据你输入的参数来构造匿名函数,首先判断某个参数是否有输入,如果有就生成该字段的选择器函数,如果没有就不生成该字段的选择器函数,而且不论是否的返回一个匿名函数,匿名函数的返回是。

仔细分析就会发现函数 筛选条件 中有两种参数,一种是需要显式输入的 关键字形参: 书名、作者等查询条件,这些参数由函数 筛选条件 接收,然后传递给其内部的匿名函数 Lambda 对应的位置,另一种就是匿名函数 lambda 的形参 单条记录,在这里看不到有明显的传递,因为函数 查找 包装了函数 remove-if-not,层次关系如下:

(remove-if-not (筛选条件 (关键字形参) (lambda (单条记录) 匿名函数体)) *书籍数据库*)

所以形参 单条记录 实际是通过函数 remove-if-not 提供的变量---列表 *书籍数据库* 传递的,它会在列表的元素上挨个循环,也就是说会把列表中的所有元素依次绑定到 单条记录 上,所以看起来这个参数的传递不是很直观

执行效果如下:

1、根据作者查找:

CL-USER> (查找 (筛选条件 :作者 "王国维"))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 


2、根据作者和书名组合查找:

CL-USER> (查找 (筛选条件 :作者 "王国维" :书名 "人间词话"))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 
	

3、根据作者和是否有电子版组合查找:

CL-USER> (查找 (筛选条件 :作者 "王国维" :是否有电子版 T))
((:书名 "人间词话" :作者 "王国维" :价格 100 :是否有电子版 T))
CL-USER> 

4、如果不输入任何筛选条件,会是什么结果呢?也就是这样:  (查找 (筛选条件 ))
如果你能自行在头脑里把这个结果推出来,那就说明你对这个函数的逻辑真正理解了,也真正明白 remove-if-not 函数的处理逻辑,一时搞不清楚也没关系,到环境里跑一下程序就清楚了。

你可以自己试试在 remove-if-not 后面使用 #'根据?查找函数 ,然后在调用函数 查找 时不在它的输入参数前加 #' ,看看结果如何,试错代码如下:

用于试错的函数定义:

(defun 查找 (根据?查找函数)
	(remove-if-not 
		#'根据?查找函数 
		*书籍数据库*))

实际上如果这样定义这个函数,那个作为实参的匿名函数是没办法正确传递到我们所期望的位置上的,你试着先编译一下新定义,再带一个匿名函数实参执行一次 查找 函数就知道了。

用于试错的函数调用:

(查找 (lambda (单条记录) (equal (getf 单条记录 :作者) "王国维")))

(查找 #'(lambda (单条记录) (equal (getf 单条记录 :作者) "王国维")))

在我使用的 Clozure CL 环境下,这两种调用方式都无法正确传递实参。

8、更新记录

经过持续的努力,我们获得了相当完美的通用函数 查看筛选条件 ,现在已经具备编写一个所有数据库都需要的重要函数--更新 函数了,在关系数据库查询语言 SQL 中,它一般叫 update更新 函数在数据库中的作用非常大,有了它我们可以方便地修改部分数据,而不需要把错误的记录先删除再重新输入。

因为有了前面准备的基础,我们可以很迅速地整理出 更新 函数的思路:

使用一个通过参数传递的选择器函数来选取需要更新的记录,再使用关键字形参来指定需要改变的值。

代码如下:

(defun 更新记录 (根据?查找函数 &key 书名 作者 价格 (是否有电子版 nil 是否有电子版-p))
	(setf *书籍数据库*
		(mapcar
		 	#'(lambda (单条记录)
     			(when (funcall 根据?查找函数 单条记录)
       				(if 书名           
	   					(setf (getf 单条记录 :书名) 书名))
       				(if 作者           
	   					(setf (getf 单条记录 :作者) 作者))
       				(if 价格           
	   					(setf (getf 单条记录 :价格) 价格))
       				(if 是否有电子版-p  
	   					(setf (getf 单条记录 :是否有电子版) 是否有电子版)))
     			 单条记录) 
 		    *书籍数据库*)))

这段代码的主体部分由这两个函数:setfmapcar 的具体代码组成,简单说就是先用 mapcar 在原来的数据库变量 *书籍数据库* 的基础上生成一个结构完全相同,但是部分字段值发生更新的新列表作为返回值,伪码如下:

(mapcar #'把后面的列表参数中的指定字段按照指定值进行更新  *书籍数据库*)==>新列表

然后再使用赋值函数 setf 把这个新列表赋值给全局变量 *书籍数据库* ,伪码如下:

(setf  *书籍数据库* mapcar返回的新列表)

注意函数 mapcar 使用的那个匿名函数 lambda ,它执行的操作实际也是一个小循环,每次从全局变量 *书籍数据库* 列表中取一条记录,执行这条语句:

(when (funcall 根据?查找函数 单条记录) 。。。

如果记录有效则返回真值(因为我们在这里调用的 根据?查找函数 函数是 筛选条件,所以也就是根据你输入的筛选参数针对每条 单条记录 进行对照查找),继续执行内部的判断、赋值语句。

实例如下:如果用户在 筛选条件 函数中输入了关键字形参 :书名 的实参值 "人间词话" 作为筛选参数,然后在后面更新字段的关键字形参中输入 :价格 的实参值 24,也就是说用户输入的筛选条件为 :书名 = "人间词话",更新字段为 :价格 ,更新值为 24, 形如:

(更新记录 (筛选条件 :书名 "人间词话") :价格 24)

则根据用户的输入值更新该条记录中对应的字段值。

大致说一下 mapcar 这个函数,这是一个操作列表的函数,它的返回结果也是一个列表,它的第一个参数是一个函数,后续的参数都是列表。

它利用第一个函数参数对指定列表的对应元素进行操作,如果后续参数是一个数字列表,它可以给列表中每个元素加个1,然后返回新列表:

CL-USER> (mapcar #'(lambda (x) (+ 1 x)) (list 1 2 3 4 5 6 7 8 9))
(2 3 4 5 6 7 8 9 10)
CL-USER> 

如果后续参数是两个长度相同的列表,它也可以把这两个长度相同的数字列表中的每个元素相加求和,把所有的和作为新列表中的元素,然后返回新列表:

CL-USER> (mapcar #'+ (list 1 2 3 4 5 6 7 8 9) (list 2 3 4 5 6 7 8 9 10))
(3 5 7 9 11 13 15 17 19)
CL-USER> 

看了这两个例子大家想必都清楚了:mapcar 的第一个参数是一个函数,后续的参数类型由这个被调用的函数决定。

【小作业】试着用 **mapcar** 把一个数字列表中所有元素求和,然后返回和值:

开始编写这个程序时我们输入的第一条关于王国维的 《人间词话》的记录,那个价格 100 其实是不确切的,现在我们查到了正确的价格,是 24 ,希望能修改一下数据库,正好试试我们刚写好的 更新 函数,执行效果如下:

CL-USER> (更新 (筛选条件 :作者 "王国维") :价格 24)
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n")
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 24 :是否有电子版 T))
CL-USER> 

虽然执行之后的返回结果已经显示成功修改了数据库,不过我们还是可以再用 查找 函数单独看一下:

CL-USER> (查找 (筛选条件 :作者 "王国维"))
((:书名 "人间词话" :作者 "王国维" :价格 24 :是否有电子版 T))
CL-USER> 

显示没问题,我们已经成功修改了这条记录的价格字段的内容!再次庆祝一下!

顺便再写一个删除记录的函数 删除记录

(defun 删除记录 (根据?查找函数)
	(setf *书籍数据库*
		(remove-if 根据?查找函数 *书籍数据库*)))

这里使用了一个跟函数 remove-if-not 形式类似的一个函数 remove-if ,在它所返回的列表中,所有匹配谓词的元素都被删除。

有的朋友可能会拿这个 删除记录 函数跟上一个 更新 函数进行比较,发现 删除记录 函数的形参中没有那些关键字形参,分别如下:

(defun 更新记录 (根据?查找函数 &key 书名 作者 价格 (是否有电子版 nil 是否有电子版-p))

(defun 删除记录 (根据?查找函数)

比较一下这两个函数的实际调用代码就清楚了:

(更新记录 (筛选条件 :作者 "王国维") :价格 24)

(删除记录 (筛选条件 :作者 "王国维"))

前者需要输入两次关键字形参,第一次是为 筛选条件 函数准备的,用来筛选出符合条件的记录,第二次是为更新内容准备的,用来取代记录中原来的值。

后者只需要输入一次关键字形参,而且被包装在 筛选条件 函数里了,不需要在函数 删除记录 的定义中出现,因为你调用 筛选条件 时自然会输入它需要的关键字形参。

下意识地觉得这个 删除记录 的函数是一个危险的函数,尤其当它的 筛选条件 不带任何参数的时候,所以我们在试验这个函数之前,先把我们辛辛苦苦输入的数据库保存一下,就用我们前面完成的函数 保存数据库 ,代码如下:

CL-USER> (保存数据库 "~/ecode/markdown-doc/book-db.txt")
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n")
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 24 :是否有电子版 T))
CL-USER> 

做好了万全的准备,开始试验新的危险函数,先不带任何筛选条件试试,如下:

CL-USER> (删除记录 (筛选条件))
NIL
CL-USER> *书籍数据库*
NIL
CL-USER> 

果然预感成真,内存里的全局变量 *书籍数据库* 的内容被彻底清空了,好在我们有文件备份,先把它恢复,如下:

CL-USER> (加载数据库 "~/ecode/markdown-doc/book-db.txt")
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n") 
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T) 
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T) 
 (:书名 "人间词话" :作者 "王国维" :价格 24 :是否有电子版 T))
CL-USER> *书籍数据库*
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n") 
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T) 
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 24 :是否有电子版 T))
CL-USER> 

非常好,数据库信息又全部恢复了,这次再尝试一下带筛选条件删除记录,就删除 :作者"王国维" 的记录,如下:

CL-USER> (删除记录 (筛选条件 :作者 "王国维"))
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n") 
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T))
CL-USER> (查找 (筛选条件 :作者 "王国维"))
NIL
CL-USER> 

很好,干净利落地删掉了这条记录,函数的基本测试通过,收工,准备下一节,即将登场的可是 Common Lisp 的一个非常重要的特性---宏!

9、再次迭代:用宏来消除重复

太激动人心了,终于写到最后一节,实际上我们这个小型的藏书数据库程序已经基本完成了,在正式开始本节内容前先把我们写过的代码做一个简单的回顾,全部的代码如下:

(defun 建立书籍信息 (书名 作者 价格 是否有电子版)
	(list :书名 书名 :作者 作者 :价格 价格 :是否有电子版 是否有电子版)) 

(defvar *书籍数据库* nil)

(defun 增加记录 (书籍信息)
	(push 书籍信息 *书籍数据库*))

(defun 转储显示 ()
	(dolist (单条书籍记录 *书籍数据库*)
		(format t "~{~a: ~20t~a~%~}~%" 单条书籍记录)))

(defun 提示输入 (提示信息)
	(format *query-io* "~a: " 提示信息)
	(force-output *query-io*)
	(read-line *query-io*))

(defun 提示书籍信息-旧版 ()
	(建立书籍信息 
		(提示输入 "书名")
		(提示输入 "作者")
		(提示输入 "价格")
		(提示输入 "是否有电子版[y/n]")))

(defun 提示书籍信息 ()
	(建立书籍信息 
		(提示输入 "书名")
		(提示输入 "作者")
		(or (parse-integer (提示输入 "价格") :junk-allowed t) 0)
		(y-or-n-p "是否有电子版[y/n]: ")))

(defun 批量输入 ()
	(loop (增加记录 (提示书籍信息))
 		(if (not (y-or-n-p "还要继续输入下一本书籍的信息吗?[y/n]: ")) (return))))


(defun 保存数据库 (带路径的保存文件名)
	(with-open-file (文件绑定变量 带路径的保存文件名
	       			:direction :output
	       			:if-exists :supersede)
	(with-standard-io-syntax
  		(print *书籍数据库* 文件绑定变量))))

(defun 加载数据库 (带路径的加载文件名)
	(with-open-file (文件绑定变量 带路径的加载文件名)
		(with-standard-io-syntax
  	(setf *书籍数据库* (read 文件绑定变量)))))

(defun 用作者名查找 (作者名)
	(remove-if-not 
		#'(lambda (单条记录) 
   			(equal (getf 单条记录 :作者) 作者名)) 
	*书籍数据库*))

(defun 查找 (根据?查找函数)
	(remove-if-not 
		根据?查找函数 
	*书籍数据库*))

(defun 选择器-选作者 (作者)
	#'(lambda (单条记录) 
   		(equal (getf 单条记录 :作者) 作者)))

(defun 筛选条件 (&key 书名 作者 价格 (是否有电子版 nil 是否有电子版-p))
	#'(lambda (单条记录)
  		(and 
   			(if 书名           
   				(equal (getf 单条记录 :书名) 书名) t)
   			(if 作者           
   				(equal (getf 单条记录 :作者) 作者) t)
   			(if 价格           
   				(equal (getf 单条记录 :价格) 价格) t)
   			(if 是否有电子版-p  
   				(equal (getf 单条记录 :是否有电子版) 是否有电子版) t))))

(defun 更新记录 (根据?查找函数 &key 书名 作者 价格 (是否有电子版 nil 是否有电子版-p))
	(setf *书籍数据库*
		(mapcar
 			#'(lambda (单条记录)
     			(when (funcall 根据?查找函数 单条记录)
       				(if 书名           
	   					(setf (getf 单条记录 :书名) 书名))
       				(if 作者           
	   					(setf (getf 单条记录 :作者) 作者))
       				(if 价格           
	   					(setf (getf 单条记录 :价格) 价格))
       				(if 是否有电子版-p  
	   					(setf (getf 单条记录 :是否有电子版) 是否有电子版)))
     			单条记录) 
 		*书籍数据库*)))

(defun 删除记录 (根据?查找函数)
	(setf *书籍数据库*
		(remove-if 根据?查找函数 *书籍数据库*)))

不知不觉中已经写了这么多代码,真有成就感啊!

原型系统已经完成了,现在就在原型系统的基础上针对我们已经完成的程序做进一步的分析和优化:

前面在写函数 筛选条件 时,我们为了避免每针对一个字段都写一个对应的选择器函数而做了一些有益的抽象,写了一个选择器生成器函数 筛选条件 ,避免了一定程度的代码重复,但是在 筛选条件 的代码中实际上还是不可避免地出现不少重复,我们必须为所有打算列为筛选条件的字段都写一条类似的语句放在 筛选条件 的函数体中,如下:

(if 书名 (equal (getf 单条记录 :书名) 书名) t)

如果有 100 个准备列为筛选条件的字段就需要写出 100 条这样的语句,如下:

(if 字段1 (equal (getf 单条记录 :字段1) 字段1) t)
(if 字段2 (equal (getf 单条记录 :字段2) 字段2) t)
(if 字段3 (equal (getf 单条记录 :字段3) 字段3) t)
(if 字段4 (equal (getf 单条记录 :字段4) 字段4) t)
。。。。。
(if 字段100 (equal (getf 单条记录 :字段100) 字段100) t)

是不是发现我们前面所做的抽象还不是很彻底?这样的代码不仅会造成重复,而且在编译之后的执行代码中会产生多条无用的分支判断---你写多少个 if 它就会生成多少个分支判断,哪怕你最终调用时只带一个筛选参数,它也会把其余 99 条分支判断一一遍历。

也就是说我们花了太多力气去检查用户是否输入某个关键字形参。

这种无用的分支判断带来的不仅是代码的重复,更有性能上的损失,当然体现我们这个小程序上可能前后性能差异很小,不过我们一会儿可以稍微度量一下,Common Lisp 提供了简单的性能分析函数 time 可以用来做这种对比,真正对性能感兴趣的朋友也可以用不同方式试着写一个 100 个字段的数据库,比较一下。

言归正传,现在开始考虑如何把 筛选条件 做得更抽象一些,只生成我们实际执行的代码,根本不去生成那些可能执行、但是没有执行的代码。

让我们从用户调用函数 筛选条件 时输入的调用形式入手看看,用户可能会输入如形式的调用代码:

(查找 (筛选条件 :作者 "王国维" :是否有电子版 T))

我们目前已经实现的代码是这样的:

(查找
	#'(lambda (单条记录)
  		(and 
   			(if 书名           
   				(equal (getf 单条记录 :书名) 书名) t)
   			(if 作者           
   				(equal (getf 单条记录 :作者) 作者) t)
   			(if 价格           
   				(equal (getf 单条记录 :价格) 价格) t)
   			(if 是否有电子版-p  
   				(equal (getf 单条记录 :是否有电子版) 是否有电子版) t))))

但是我们实际只需要执行这样的代码即可:

(查找 
	#'(lambda(单条记录)
		(and 
			(equal (getf 单条记录 :书名) 书名)
			(equal (getf 单条记录 :是否有电子版) t))))

对比发现,后者比前者少了 4 条 if 判断,而且代码的处理逻辑看起来也更清晰了。

很好,我们希望每次都能根据用户输入的筛选字段来生成必要的代码,而不是把所有的可能性都一一列举,然后傻乎乎地一个分支一个分支跑一遍。

这个优化目标如果在 C 语言里提出,我觉得实现起来会比较困难,可能为此编写的辅助代码都要大大超过我们整个程序了,没准你为此增加的辅助代码可以编一个小型专用编译器出来了---也可能因为我自己的 C 语言水平比较有限,反正我暂时想不出什么既简单又有效的 C 算法,当然这么比较可能确实对 C 不太公平,C++倒是可以考虑考虑。

注意了,这里 Common Lisp 的一个非常非常重要的特性终于在万众瞩目中登场了--- 宏 Macro,毫不夸张地说,我学习 Common Lisp 有一多半的原因就是因为它的宏,强大到逆天的能力!

什么叫强大到逆天的能力?我们知道,计算机程序语言中,天大地大,规则最大,所有的程序语言都要服从它们的语法规则,否则编译器就直接把你咔嚓掉了,根本没机会运行,也没法运行。

但是 Common Lisp 却不一样,因为它有 ,Common Lisp 的 赋予程序员改写规则的能力,所有的 Common Lisp 程序员都可以按照自己的想法去创造自己的规则!就好比世间万物都要进入生死轮回,你却掌握了生死簿,可以逆天改命!

其实说实话,有些人真要用汇编或者 C 去实现这样的功能,也不是不可能,只不过没那么方便而已,而且当你真的成功了,你会发现,你自己写了一个 Common Lisp 的新实现 :)

写到这里,大家可以把前面的 8 节内容都看做是专门为这一节而做的铺垫,我也会尽量用我自己的理解来讲述 这一利器,我个人的看法:

对于初学者来说,Common Lisp 的其他特性可以暂时放着,慢慢去熟悉,但是 宏 一定要从开始就理解、就学会,然后再在不断的编程实践中去运用,这样才会真正改变你的编程思维!

我们知道,编程语言的发展,其实就是抽象程度被不断提升的过程,从机器语言到汇编语言还只是简单的从机器指令到助记符的对应,但是很快宏汇编就开始提出抽象,各种高级语言分别提供各种不同角度、不同层次的抽象能力。

所谓的抽象就是提取那些共性的东西,然后用一种通用的形式去表述,Common Lisp 中的 机制的本质也是如此,所以我们也没有必要把它看得有多么艰难,被它吓住,只要是程序中发现有共性的代码,都可以以各种形式抽象成 ,最简单的就是内容重复的代码,这个很好判断,我们这次打算优化的内容就是这种类型的代码。

很容易看出,我们的 筛选条件 函数中最多的重复就是这种代码,如下:

(equal (getf 单条记录 :书名) 书名)

抽象一下就是:

(equal (getf 单条记录 字段名) 字段值)

先做成最简单的抽象---函数化,我们把它编写成一个根据输入字段名和字段值返回表达式的函数,因为表达式本身只是列表,因此可以先构思成这样的伪码:

(defun 域值->表达式 (域 值)
	(list equal (list getf 单条记录 域) 值)

说明一下,这个定义使用的语法是错误的,因为 Common Lisp 遇到 这种符号形式没有出现在列表首位时会去求值,这个没问题,因为你在实际调用时会把实际的参数值传给 这两个形参,但是对于列表中出现的其他类似的符号形式,如 equalgetf单条记录,它也会去求值,这就麻烦了,马上就会出错,不信你就把这段代码拷贝到 REPL 中去执行一下,看看结果如何。

不过我们在前面的基本概念中学过:防止 Common Lisp 对一个符号求值的办法就是在符号前面加一个单引号 ' 所以真正行得通的代码如下:

(defun 域值->表达式 (域 值)
	(list 'equal (list 'getf '单条记录 域) 值)

一般来说,写这种代码,只有函数的形参会希望被求值,其他的符号都不希望会求值,也就是说除了形参,其他符号都需要加一个单引号,是不是觉得很麻烦?

还好我们还有一种反过来的方法---先设置对整个表达式不求值,然后再设置对少数几个符号求值,这就是反引号 `(键盘位置:ESC键下方,TAB键上方,数字键1的左方)的作用,在一个表达式前面放一个反引号可以避免对整个表达式求值,在表达式中的子表达式前放一个逗号 , 可以只让该子表达式求值,因此可以写成更好的形式,把 list 函数也去掉了,如下:

(defun 域值->表达式 (域 值)
	`(equal (getf 单条记录 ,域) ,值)

执行效果如下:

CL-USER> (域值->表达式 :作者 "王国维")
(EQUAL (GETF 单条记录 :作者) "王国维")
CL-USER> 

很好,跟我们设想的一模一样,不过有个小问题,实际调用 筛选条件 函数时输入的形参可能不止这一对,所以我们这里需要一个函数,它能够从一个列表中成对地提取元素,分别作为 来使用,并且需要收集在每对参数上调用 域值->表达式 函数生成的结果,最后再把这些结果用一个 and 函数封装起来,这样就实现了对 筛选条件 函数的全面改写。

实现刚才提到的这些功能需需要使用一点新知识 loop 宏,先使用再解释,代码如下:

(defun 域值->列表 (域值参数对列表)
	(loop while 域值参数对列表
   		collecting (域值->表达式 (pop 域值参数对列表) (pop 域值参数对列表))))

执行效果如下:

CL-USER> (域值->列表 '(:作者 :书名))
((EQUAL (GETF 单条记录 :作者) :书名))
CL-USER> (域值->列表 '(:作者 "王国维" :书名 "人间词话"))
((EQUAL (GETF 单条记录 :作者) "王国维") (EQUAL (GETF 单条记录 :书名) "人间词话"))
CL-USER> 

非常好,距离我们的目标又近了一步,现在要做的就是把函数 域值->列表 返回的列表用 and 函数封装起来,具体来说就是把它构造出来的所有 equal 语句都用 and 组装起来,最后再放入一个匿名函数中,代码如下:

(defmacro 筛选条件 (&rest 域值参数对列表)
	`#'(lambda (单条记录) 
   			(and ,@(域值->列表 域值参数对列表))))

为避免跟前面定义过的同名函数发生冲突,建议在编译前先把前面的 筛选条件 函数改名为 筛选条件-函数版本

这里初步解释一下 Common Lisp 的 的一些基础知识---凭借这些基础知识可以实现非常强悍的抽象功能。

Common Lisp 的 和我们曾经学过的 C 语言的 是两个完全不同的概念,后者只是一些简单的替换。

首先是符号 ,@ 它会把紧挨着它的列表表达式的括号去掉,并把这个列表表达式的元素插入到外围的列表中,《实用 Common Lisp 编程》中的表述为: ,@ 会将接下来的表达式(必须求值成一个列表)的值嵌入到其外围的列表里,看看例子就明白了:

CL-USER> `(and ,(list 1 2 3))
(AND (1 2 3))
CL-USER> `(and ,@(list 1 2 3) 4 5)
(AND 1 2 3 4 5)
CL-USER> 

的另一个重要基础知识是剩余形参符号 &rest ,当参数列表里带有 &rest 时,一个函数或宏可以接受任意数量的实参,所有这些实参都将被收集到一个列表中,并且会成为那个 &rest 后面的参数所对应的变量的值成。

还有一个必须提到的关于 的函数 macroexpand-1 它会把一个宏调用展开,也就是说它执行的参数是一个合法的宏调用,包括宏名和必须的参数,它返回的结果正是这个宏未来执行时所生成的实际代码,我们完成一个新的 定义之后,想要看看它是否能按我们预期的方式工作,就可以用这个函数来检查。

执行效果如下:

CL-USER> (macroexpand-1 '(筛选条件 :作者 "王国维" :书名 "人间词话"))
#'(LAMBDA (单条记录) 
	(AND 
		(EQUAL (GETF 单条记录 :作者) "王国维")
		(EQUAL (GETF 单条记录 :书名) "人间词话")))
T
CL-USER> 

看起来不错,用到我们的 查找 函数中实际试一下, 如下:

CL-USER> (查找 (筛选条件 :作者 "王国维"))
NIL
CL-USER>

怎么没查到?难道写错了?看看数据库的数据

CL-USER> *书籍数据库*
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n")
 (:书名 "难忘的书与插图" :作者 "汪家	明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T))
CL-USER>

哦,原来那条记录被我们删除了,换个筛选条件试试

CL-USER> (查找 (筛选条件 :作者 "老鬼"))
((:书名 "血色黄昏" :作者 "老鬼" :价格 "25" :是否有电子版 "n"))
CL-USER> 

大功告成!我们成功地实现了根据用户输入的具体筛选条件动态生成执行代码的抽象,避免了一大堆无用的分支。

不过且慢,虽然宏 筛选条件 实现了高度抽象,好像函数 更新记录 也存在类似的问题,目前只能更新 4 个指定字段,如果字段一多就要写很多对应的分支,让我们继续沿用刚才的分析方法,针对 更新记录 再做一次抽象优化,这也是我们对自己所学内容的一个尝试和检验。

分析的顺序依旧是自底向上,先模拟一个用户输入的函数调用场景,假设语句如下:

(更新 (筛选条件 :作者 "老鬼") :价格 55 :是否有电子版 T)

那么对应的实际执行的代码如下:

(更新记录 (筛选条件 ) 更新字段 更新值
	(setf *书籍数据库*
			(mapcar 
			 #'(lambda (单条记录)
			 	 (when (funcall 筛选条件 单条记录)
			 	 	(progn
			 	 	   (setf (getf 单条记录 :价格) 55)
			 	 	   (setf (getf 单条记录 :是否有电子版)  T))
			 	 单条记录)
			 *书籍数据库*)))

同样地,先抽象一个 更新域值->表达式 的辅助函数出来,如下:

(defun 更新域值->表达式 (域 值)
  `(setf (getf 单条记录 ,域) ,值))

执行效果如下:

CL-USER> (更新域值->表达式 :价格 55)
(SETF (GETF 单条记录 :价格) 55)
CL-USER> 

很好,再抽象一个可以处理域值参数对列表的辅助函数出来,如下:

(defun 更新域值->列表 (域值参数对列表)
	(loop while 域值参数对列表
   		collecting (更新域值->表达式 (pop 域值参数对列表) (pop 域值参数对列表))))

执行效果如下:

CL-USER> (更新域值->列表 '(:价格 55 :是否有电子版 T))
((SETF (GETF 单条记录 :价格) 55) (SETF (GETF 单条记录 :是否有电子版) T))
CL-USER> 

非常好,现在开始构造我们的宏 更新记录 ,代码如下:

(defmacro 更新记录 (根据?查找函数 &rest 待更新域值对列表)
	`(setf *书籍数据库*
		(mapcar
 			#'(lambda (单条记录)
     			(when (funcall ,根据?查找函数 单条记录)
       				(progn ,@(更新域值->列表 待更新域值对列表))
     		单条记录) 
 		 *书籍数据库*))))

执行效果如下:

CL-USER> (macroexpand-1 '(更新记录 (筛选条件 :作者 "老鬼") :价格 55 :是否有电子版 t))
(SETF *书籍数据库* 
	(MAPCAR #'(LAMBDA (单条记录) 
				(WHEN (FUNCALL (筛选条件 :作者 "老鬼") 单条记录) 
					(PROGN (SETF (GETF 单条记录 :价格) 55) 
				   		   (SETF (GETF 单条记录 :是否有电子版) T)) 
		 	 单条记录)) 
	 *书籍数据库*))
T
CL-USER> 

试验一下效果如何:

CL-USER> (更新记录 (筛选条件 :作者 "老鬼") :价格 55)
((:书名 "血色黄昏" :作者 "老鬼" :价格 55 :是否有电子版 "n") NIL NIL)
CL-USER> *书籍数据库*
((:书名 "血色黄昏" :作者 "老鬼" :价格 55 :是否有电子版 "n") NIL NIL)
CL-USER> 

坏了,这条记录是更新了,但是其他另外两条记录却消失了,看来有些地方出错了,很显然,函数 mapcar 返回了这样的结果

(待更新记录 nil nil)

那么它为什么要把其余两条不满足筛选条件的记录设置为空值呢?看来问题还是出在匿名函数,经过检查,发现有一个括号位置搞错了,正确的代码应如下:

(defmacro 更新记录 (根据?查找函数 &rest 待更新域值对列表)
	`(setf *书籍数据库*
		(mapcar
 			#'(lambda (单条记录)
     			(when (funcall ,根据?查找函数 单条记录)
       				(progn ,@(更新域值->列表 待更新域值对列表)))
     		单条记录) 
 		 *书籍数据库*)))

再次试验一下,先展开,检查展开形式,一切正常:

CL-USER> (macroexpand-1 '(更新记录 (筛选条件 :作者 "王国维") :价格 55 :是否有电子版 t))
(SETF *书籍数据库* 
	(MAPCAR #'(LAMBDA (单条记录) 
				(WHEN (FUNCALL (筛选条件 :作者 "王国维") 单条记录) 
					(PROGN (SETF (GETF 单条记录 :价格) 55) 
						   (SETF (GETF 单条记录 :是否有电子版) T))) 
			单条记录) 
	*书籍数据库*))
T
CL-USER> 

再执行一次更新操作,结果如下:

CL-USER> (更新记录 (筛选条件 :作者 "老鬼") :价格 55)
((:书名 "血色黄昏" :作者 "老鬼" :价格 55 :是否有电子版 "n")
 (:书名 "难忘的书与插图" :作者 "汪家明" :价格 38 :是否有电子版 T)
 (:书名 "说文解字" :作者 "许慎" :价格 100 :是否有电子版 T)
 (:书名 "人间词话" :作者 "王国维" :价格 24 :是否有电子版 T))
CL-USER> 

这次好了,没有任何问题,现在我们终于可以说大功告成了!

其实那个函数 progn 不是必须的,可以去掉,还可以少些括号,减少犯错误的几率,写成如下的形式:

(defmacro 更新记录 (根据?查找函数 &rest 待更新域值对列表)
	`(setf *书籍数据库*
		(mapcar
 			#'(lambda (单条记录)
     			(when (funcall ,根据?查找函数 单条记录)
        			,@(更新域值->列表 待更新域值对列表))
     		单条记录)
 		*书籍数据库*)))

现在经过我们再次抽象定义出来的新 更新记录 ,不仅消除了重复代码,避免执行空分支的判断,而且可以更新任意字段,不论数据库的字段怎么调整,我们的代码都不需要做任何修改就能够适应。而这正是我不惜花费大量篇幅希望能让初学者领略到的东西,经过最后对两个函数 筛选条件更新记录 的重写,我们初步体会到 Common Lisp 语言强大的

【程序扩展】

有些人比较喜欢更深入的探索,比如这段程序,经过我们的一再优化,基本的功能已经实现了,不过还有不少方向值得去扩展,下面我列两个出来,供感兴趣的朋友研究参考:

1、对数据库字段的扩充,可以增加我们一开始讨论时设想到的那些字段;
2、和智能终端结合,现在的智能手机都有条码扫描的功能,而且提供了相应的操作函数,每本书都有一个 ISBN 条码,根据
这个条码可以在网络上获取该书的很多信息,这样就不需要我们一一输入了,能减轻很多工作量;

10、本章内容小结

  • 简单函数
  • 非常基本的数据结构:列表 的使用
  • 以函数作为参数的函数 funcall mapcar
  • 宏的抽象可以带来更紧凑、更高效、更通用的代码

五、跨越初学者阶段

经过上述 4 个章节的学习和实践,,如果前述每段例程代码你都完全理解了,我相信作为初学者的你,不仅可以顺利启动开发环境,学会不少提升开发、调试效率的快捷操作,而且也掌握了 Common Lisp 语言的一些基本概念和用法,以及部分高级内容---,应该具备继续深入探索 Common Lisp 其他特性的能力了,恭喜你!

1、其实说实话我也是初学者…

其实我也是跟大家一样的初学者,开始只是想以教程的形式总结一下学过的内容,结果不知不觉把这个新手教程写了这么多,有不少细节在自己单独看书的时候其实没想那么多,但是一旦开始写教程的时候,才发现这些以前没有想过的地方,所以我觉得初学者在学习 Common Lisp 时,如果能尝试着把自己学到的内容以教程的形式写出来,这样不仅可以及时地对学过的内容进行总、复习,同时也可以自己在试着表述的过程中发现以往学习的疏漏,另外还可以让其他初学者多一份参考学习的资料。

因此疏漏、错误在所难免,希望能其他朋友能不吝赐教,指出谬误之处,我核实后会刷新版本,同时你的名字也会出现在 “贡献者列表” 中。

一个人的力量终究是有限的,希望有更多的人共同参与进来

2、HyperSpec:Common Lisp 有哪些“标准库函数”?

学过其他编程语言的朋友,在了解了 Common Lisp 初步知识后,肯定会有一个疑问:Common Lisp 有哪些 “标准库函数” ?如何去查询?这也是我当初的一个疑问,因为 Common Lisp 不这么叫,它的称呼是 “扩展符号”,写在 HyperSpec 中,由 LispWorks 维护,有在线版,也可以下载回去慢慢查,一共有 978 个,建议初学者把它下载回来经常翻翻,因为可以看到这些 “标准库函数” 的源代码,它们绝大多数也是用 Common Lisp 写的。

HyperSpec 下载地址: http://www.lispworks.com/documentation/HyperSpec/Front/X_AllSym.htm

这里再重复一遍最最简单的查看 Common Lisp 的 “标准库函数” 源代码的办法,当然,你得先知道函数名,在 REPL 或者在 编辑区 里输入函数名,然后把光标移动到函数名上面,按如下快捷键:

M-.   Alt 键  和 点键 .  Emacs就会自动把该函数或宏的源文件打开

其实 Emacs 里还有不少快捷键可以查询某个函数相关的信息,不过我觉得那些帮助信息其实不如看代码清楚,所以就不多介绍了,感兴趣就自己去查吧。

3、如何查找各种具体实现(如SBCL、CCL、LispWorks)的帮助信息

目前的 LispBox 里只使用了一种 Common Lisp 实现 Clozure CL 作为编译器,但是其他实现也各有其独到之处,比如 SBCL,比如 LispWorks,还有 Allegro CL,这些不同实现在其网站都有相关的文档,包括了最权威的帮助信息。 各实现官网地址如下: Clozure CL: http://ccl.clozure.com/ SBCL: http://www.sbcl.org LispWorks: http://www.lispworks.com/ Allegro CL: http://www.franz.com/products/allegrocl/

如果遇到问题,首选是看文档,看看官方的 FAQ 里有没有你的问题,其次是使用搜索引擎看看有没有其他人遇到类似问题,如果这两步都没有找到相关的答案,那就到 Email-List 或者论坛去提问,不过个人感觉 Email-List 上高人更集中,毕竟你不可能把所有的论坛都逛遍,我有几个问题就是在 Email-List 求助,然后得到其他朋友的帮助而解决的。

在 Email-List 上求助一定要详细描述你的问题,最好同时把你搜索答案的过程和结果也描述一下,否则别人就算想帮你也无从下手,另外就是一些简单的问题、或者自己可以通过搜索找到答案的问题就不要在 Email-List 上提了,自己从来不肯动脑子、只知道要完整解决方案的伸手党是最不受欢迎的。

最后就是希望所有的初学者都能养成帮助别人的习惯,高手、新手是相对的,你从一个高手那里获得了帮助,同时有些比你学习晚的新手也可以从你这里得到帮助,这样正能量才能流通起来,最终受益的是网络上的每个使用者---也就是你我。

4、更深一步的学习

如果你认真地把这份教程从头读到尾,而且自己验证了其中所有的例程,也理解了所有的代码,那你就可以开始更进一步的学习了,具体的建议我就不提了,只提几个原则性的:

  • 1)、以自己的兴趣为主,对哪个方面感兴趣就先学哪个方面的;
  • 2)、学了什么内容一定要试着写写相关的代码,用写代码来验证你是否真正掌握;
  • 3)、阅读学习的时间和写代码验证的时间至少为 1:1 (这是我对自己粗略的估计,因人而异,不过一定要保证构思代码、编写代码的时间);
  • 4)、学习过程最好能有所记录---包括犯的错误;
  • 5)、学完一个小阶段后要有所总结,而且最好能把自己的总结分享出来;

5、本章内容小结

  • 貌似本章自己就是一个小结性质的章节 :)

六、贡献者列表

因为本教程作者也是一名初学者,所以本文必然会有各种错漏,因此希望更多的初学者或高手能参与进来,共同完善此教程,此教程会放在 GitHub 上(https://github.com/FreeBlues/PwML/blob/master/Common%20Lisp%20初学者快速入门指导.md),方便大家查阅,当然有什么建议也可以直接在 oschina.net 上对本文提出评论,这里专门开辟一个章节来列出参与发现错误的朋友,表示感谢!

1、修订记录

  • 2013-02-17 初稿完成
  • 2013-04-21 修改更新第三章
  • 2013-05-11 修改更新第一章
  • 2013-05-17 全面修改更新--作为发布版,版本号 V0.9

2、贡献者列表

以参与先后顺序列出各位贡献者,先把我的名字列出来,做个表率 :)

  • 贡献者:
    • FreeBlues
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!