实用zhlisp编程03:简单的数据库

  • 0

实用zhlisp编程03:简单的数据库

Category:中文学习 Tags : 

第3章 实践:一个简单的数据库

很明显,在你可以用 Lisp 构建真实软件之前,必须先学会这门语言。但是请想想看——你可能会觉得:“‘实用 Common Lisp 编程’ 难道不是反语吗?难道在确定一门语言真正有用之前就要先把它所有的细节都学完吗?” 因此我先给你一个小型的可以用 Common Lisp 来做的例子。本章里编写一个简单的数据库用来记录 CD 光盘。在第 27 章里,为我们的流式 MP3 服务器构建一个 MP3 数据库还会用到类似的技术。事实上,它可以被看成是整个 MP3 软件项目的一部分——毕竟,为了有大量的 MP3 可听,对我们所拥有并需要转换成 MP3 的 CD 加以记录是很有用的。

在本章,我只介绍足以使你理解代码工作原理的那些 Lisp 特性,但细节方面不会解释太多。目前你不需要执著于细节——接下来的几章将以一种更加系统化的方式介绍这里用到的所有 Common Lisp 控制结构以及更多内容。

关于术语方面,本章将讨论少量 Lisp 操作符。第4章将学到 Common Lisp 所提供的三种不同类型的操作符:函数、宏,以及特殊操作符。对于本章来说,你并不需要知道它们的区别。尽管如此,在提及操作符时我还是会适时地说成是函数、宏或特殊操作符,而不会笼统地用“操作符”这个词来表示。眼下你差不多可以认为函数、宏和特殊操作符是等价的。1

另外请记住我不会在这个继 “你好,世界” 后写的首个程序中亮出所有最专业的 Common Lisp 技术来。本章的重点和意图也不在于讲解如何用 Lisp 编写数据库,而在于让你对 Lisp 编程有个大致的印象,并能看到即便相对简单的 Lisp 程序也可以有着丰富的功能。

3.1 CD和记录

为了记录那些需要转换成 MP3 的 CD,以及哪些 CD 应该先进行转换,数据库里的每条记录都将包含 CD 的标题和艺术家信息,一个关于有多少用户喜欢它的评级,以及一个表示其是否已经被转换过的标记。因此,首先需要一种方式来表示一条单一的数据库记录(也就是一张 CD)。Common Lisp 提供了大量的数据结构可供选择——从简单的四元素列表到基于 Common Lisp 对象系统(CLOS)的用户自定义类。

眼下你只能选择该系列里最简单的方法-使用列表。你可以使用 列表 函数来生成一个列表,如果正常执行的话,它将返回一个由其参数所组成的列表。

CL-USER> (列表 1 2 3)
  (1 2 3)

还可以使用一个四元素列表,将列表中的给定位置映射到记录中的给定字段。然 而,使用另一类被称为属性表(property list)或简称 plist 的列表甚至更方便。属性表是这样一种列表:从第一个元素开始的所有相间元素都是一个用来描述接下来的那个元素的符号。目前我不会深入讨论关于符号的所有细节,基本上它就是一个名字。对于用来命名 CD 数据库字段的名字,你可以使用一种特殊类型的符号——关键字(keyword)符号。关键字符号是任何以冒号开始的名字,例如 :foo。下面是一个使用了关键字符号 :a、:b 和 :c作为属性名的示例 plist:

CL-USER> (列表 :a 1 :b 2 :c 3)
(:A 1 :B 2 :C 3)

注意,你可以使用和创建其他列表时同样的 列表 函数来创建一个属性表,只是特殊的内容使其成为了属性表。

真正令属性表便于表达数据库记录的原则是在于函数 读符号值 ,其接受一个 plist 和一个符号,并返回 plist 中跟在那个符号后面的值,这使得 plist 成为了穷人的哈希表。当然 Lisp 有真正的哈希表,但 plist 足以满足当前需要,并且可以更容易地保存在文件里——后面将谈及这点。

CL-USER> (读符号值 (列表 :a 1 :b 2 :c 3) :a)
1
CL-USER> (读符号值 (列表 :a 1 :b 2 :c 3) :c)
3

理解了所有这些知识,你就可以轻易写出一个 录入音乐 函数了,它以参数的形式接受 4 个字段,然后返回一个代表该 CD 的 plist。

(函数 录入音乐 (标题 艺术家 评分 转格式)
(列表 :标题 标题 :艺术家 艺术家 :评分 评分 :转格式 转格式))

单词 函数  告诉我们上述形式正在定义一个新函数,函数名是 录入音乐。跟在名字后面的是形参列表,这个函数拥有四个形参:标题艺术家评分,和转格式。形参列表后面的都是函数体。本例中的函数体只有一个形式,即对 列表 的调用。当 录入音乐 被调用时,传递给该调用的参数将被绑定到形参列表中的变量上。例如,为了建立一个关于火箭少女101 的名为 卡路里 的 CD 的记录,你可以这样调用 录入音乐

CL-USER> (录入音乐 "卡路里" "火箭少女101" 7 t)
(:标题 "卡路里" :艺术家 "火箭少女101" :评分 7 :转格式 T)

3.2 录入CD

只有单一记录还不能算是一个数据库,需要一些更大的结构来保存记录。出于简化目的,使用列表似乎也还不错。同样出于简化目的,也可以使用一个全局变量 *db*,它可以用 空值全局变量  宏来定义。名字中的星号是 Lisp 的全局变量命名约定。2

(空值全局变量 *db* nil)

可以使用 添加  宏为 *db* 添加新的项。但稍微做得抽象一些可能更好,因此可以定义一个函数 添加记录 来给数据库增加一条记录。

(函数 添加记录 (cd) (添加 cd *db*))

现在可以将 添加记录录入音乐 一起使用,来为数据库添加新的 CD 记录了。

CL-USER> (添加记录 (录入音乐 "卡路里" "火箭少女101" 7 t))
((:标题 "卡路里" :艺术家 "火箭少女101" :评分 7 :转格式 T))
CL-USER> (添加记录 (录入音乐 "三生三世" "张杰" 8 t))
((:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T)
(:标题 "卡路里" :艺术家 "火箭少女101" :评分 7 :转格式 T))
CL-USER> (添加记录 (录入音乐 "明天过后" "张杰" 9 t))
((:标题 "明天过后" :艺术家 "张杰" :评分 9 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T)
(:标题 "卡路里" :艺术家 "火箭少女101" :评分 7 :转格式 T))

那些每次调用 添加记录 以后 REPL 所打印出来的东西是返回值,也就是函数体中最后一个表达式添加 所返回的值,并且 添加 返回它正在修改的变量的新值。因此你看到的其实是每次新记录被添加以后整个数据库的值。

3.3 查看数据库的内容

无论何时,在 REPL 里输入 *db* 都可以看到 *db* 的当前值。

CL-USER> *db*
((:标题 "明天过后" :艺术家 "张杰" :评分 9 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T)
(:标题 "卡路里" :艺术家 "火箭少女101" :评分 7 :转格式 T))

但这种查看输出的方式并不令人满意,可以用一个 转储数据库 函数来将数据库转储成一个像下面这样的更适合人类阅读习惯的格式。

标题:         明天过后
艺术家:       张杰
评分:         9
转格式:       T

标题:         三生三世
艺术家:       张杰
评分:         8
转格式:       T

标题:         卡路里
艺术家:       火箭少女101
评分:         7
转格式:       T

该函数如下所示:

(函数 转储数据库 ()
  (列表循环 (cd *db*)
    (格式 t "~{~a:~10t~a~%~}~%" cd)))

该函数的工作原理是使用 列表循环 宏在 *db* 的所有元素上循环,依次绑定每个元素到变量 cd 上。而后使用 格式 函数打印出每个 cd的值。

无可否认,这个 格式 调用多少显得有些晦涩。尽管如此,但 格式 却并不比 C 或 Perl 的 printf 函数或者 Python 的 string-% 操作符更复杂。第 18 章将进一步讨论 格式 的细节,目前我们只需记住这个调用就可以了。第 2 章所述,格式 至少接受两个实参,第一个是它用来发送输出的流,t 是标准输出流(*standard-output*)的简称。

格式  的第二个参数是一个格式字符串,内容既包括字面文本,也包括那些告诉 格式 如何插入其余参数等信息的指令。格式指令以 ~ 开始(就像是 printf 指令以 % 开始那样)。格式 能够接受大量的指令,每一个都有自己的选项集。 但目前我将只关注那些对编写 转储数据库 有用的选项。

~a 指令是一个有审美观的指令,它消耗一个实参,然后将其输出成人类可读的形式。这使得关键字被渲染成不带前导冒号的形式,而字符串也不再有引号了。例如:

CL-USER> (格式 t "~a" "张杰")
张杰
NIL

或是:

CL-USER> (格式 t "~a" :标题)
标题
NIL

~t 指令用于制表。~10t 告诉 格式  产生足够的空格,以确保在处理下一个 ~a 之前将光标移动 10 列。~t 指令不消耗任何参数。

CL-USER> (格式 t "~a:~10t~a" :艺术家 "张杰")
艺术家:   张杰
NIL

现在事情变得稍微复杂一些了。当 格式  看到 ~{ 的时候,下一个被消耗的实参必须是一个列表。格式 在列表上循环操作,处理位于 ~{ 和 ~} 之间的指令,同时在每次需要时,从列表上消耗掉尽可能多的元素。在 转储数据库 里,格式 循环将在每次循环时从列表上消耗一个关键字和一个值。~% 指令并不消耗任何实参,而只是告诉 格式 来产生一个换行。然后在 ~} 循环结束以后,最后一个 ~% 告诉 格式 再输出一个额外的换行,以便在每个 CD 的数据之间产生一个空行。

从技术上来讲,也可以使用 格式 在整个数据库本身上循环,从而将我们的 转储数据库 函数变成只有一行。

(函数 转储数据库 ()
(格式 t "~{~{~a:~10t~a~%~}~%~}" *db*))

这件事究竟是酷还是恐怖,完全看你怎么想。

CL-USER> (转储数据库)

3.4 改进用户交互

尽管我们的 添加记录 函数在添加记录方面做得很好,但对于普通用户来说却仍显得过于 Lisp 化了。并且如果他们想要添加大量的记录,这种操作也并不是很方便。因此你可能想要写一个函数来提示用户输入一组 CD 信息。这就意味着需要以某种方式来提示用户输入一条信息,然后读取它。下面让我们来写这个。

(函数 提示输入 (提示)
(格式 *query-io* "~a: " 提示)
(强制输出 *query-io*)
(读行 *query-io*))

你用老朋友 格式 来产生一个提示。注意到格式字符串里并没有 ~%,因此光标将停留在同一行里。对 强制输出 的调用在某些实现里是必需的,这是为了确保 Lisp 在打印提示信息之前不会等待换行。

然后就可以使用名副其实的 读行 函数来读取单行文本了。变量 *query-io* 是一个含有关联到当前终端的输入流的全局变量(通过星号命名约定你也可以看出这点来)。强制输出 的返回值将是其最后一个形式,即调用 读行 所得到的值,也就是它所读取的字符串(不包括结尾的换行)。

你可以将已有的 录入音乐 函数跟 提示输入 组合起来,从而构造出一个函数,可以从依次提示输入每个值得到的数据中建立新的 CD 记录。

(函数 提示输入音乐 ()
(录入音乐
(提示输入 "标题")
(提示输入 "艺术家")
(提示输入 "评分")
(提示输入 "转格式 [y/n]")))

这样已经差不多正确了。只是 提示输入 总是返回字符串,对于 标题 艺术家  字段来说可以,但对于 评分 转格式 字段来说就不太好了,它们应该是数字和布尔值。花在验证用户输入数据上的努力可以是无止境的而这取决于所要实现的用户接口专业程度。目前我们倾向于一种快餐式办法,你可以将关于评级的那个 提示输入 包装在一个 Lisp 的 解析整数 函数里,就像这样:

(解析整数 (提示输入 "评分"))

不幸的是,解析整数 的默认行为是当它无法从字符串中正确解析出整数,或者字符串里含有任何非数字的垃圾时直接报错。不过,它接受一个可选的关键字参数 :junk-allowed,可以让其适当地宽容一些。

(解析整数 (提示输入 "评分") :junk-allowed t)

但还有一个问题:如果无法在所有垃圾里找出整数的话,解析整数 将返回 NIL 而不是整数。为了保持这个快餐式的思路,你可以把这种情况当作 0 来看待。Lisp 的  宏就是你在此时所需要的。它与 Perl、Python、Java 以及 C 中的 “短路” 符号 || 很类似,它接受一系列表达式,依次对它们求值,然后返回第一个非空的值(或者空值,如果它们全部是空值的话)。所以可以使用下面这样的语句:

(或 (解析整数 (提示输入 "评分") :junk-allowed t) 0)

来得到一个缺省值 0。

修复 Ripped 提示的代码就更容易了,只需使用 Common Lisp 的 是否 函数:

(是否 "转格式 [y/n]: ")

事实上,这将是 提示输入音乐 中最健壮的部分,因为 是否 会在你输入了没有以 y、Y、n,或者 N 开始的内容时重新提示输入。

将所有这些内容放在一起,就得到了一个相当健壮的 提示输入音乐 函数了。

(函数 提示输入音乐 ()
(录入音乐
(提示输入 "标题")
(提示输入 "艺术家")
(或 (解析整数 (提示输入 "评分") :junk-allowed t) 0)
(是否 "转格式 [y/n]: ")))

最后可以将 提示输入音乐 包装在一个不停循环直到用户完成的函数里,以此来搞 定这个 “添加大量 CD” 的接口。可以使用 循环 宏的一种简单形式,它不断执行一个表达式体,最后通过调用 返回 来退出。例如:

(函数 添加音乐组 ()
(循环 (添加记录 (提示输入音乐))
(判断 (非 (是否 "需要添加下一条信息吗? [y/n]: ")) (返回))))

现在可以使用 添加音乐组 来添加更多 CD 到数据库里了。

CL-USER> (添加音乐组)
标题: 最美的期待
艺术家: 周笔畅
评分: 6
转格式  [y/n]: y
需要添加下一条信息吗?  [y/n]: y
标题: 微微一笑很倾城
艺术家: 杨洋
评分: 10
转格式  [y/n]: y
需要添加下一条信息吗?  [y/n]: y
标题: 我们不一样
艺术家: 大壮
评分: 9
转格式  [y/n]: y
需要添加下一条信息吗?  [y/n]: n
NIL

3.5 保存和加载数据库

用一种便利的方式来给数据库添加新记录是件好事。但如果让用户不得不在每次退出并重启 Lisp 以后再重新输入所有记录,他们是绝对不会高兴的。幸好,借助用来表示数据的数据结构,可以相当容易地将数据保存在文件里并在稍后重新加载。下面是一个 保存数据库 函数,它接受一个文件名作为参数并且保存当前数据库的状态:

(函数 保存数据库 (文件名字)
(打开文件 (out 文件名字
:direction :output
:if-exists :supersede)
(with-standard-io-syntax
(打印 *db* out))))

打开文件宏会打开一个文件,将文件流绑定到一个变量上,执行一组表达式,然后再关闭这个文件。它还可以保证即便在表达式体求值出错时也可以正确关闭文件。紧跟着 打开文件 的列表并非函数调用而是 打开文件语法的一部分。它含有用来保存要在 打开文件 主体中写入的文件流的变量名,这个值必须是文件名,紧随其后是一些控制如何打开文件的选项。这里用:direction :output 指定了正在打开一个用于写入的文件,以及用 :if-exists :supersede 说明当存在同名的文件时想要覆盖已存在的文件。

一旦已经打开了文件,所需做的就只是使用 (打印 *db* out) 将数据库的内容打印出来。跟 格式 不同的是,打印 会将 Lisp 对象打印成一种可以被 Lisp 读取器读回来的形式。宏 WITH-STANDARD-IO-SYNTAX 可以确保那些影响 打印 行为的特定变量可以被设置成它们的标准值。当把数据读回来时,你将使用同样的宏来确保 Lisp 读取器和打印器的操作彼此兼容。

保存数据库 的实参应该是一个含有用户打算用来保存数据库的文件名的字符串。该字符串的确切形式取决于正在使用什么操作系统。例如,在 Unix 系统上可能会这样调用 保存数据库

CL-USER> (保存数据库 "~/my-cds.db")
((:标题 "我们不一样" :艺术家 "大壮" :评分 9 :转格式 T)
(:标题 "微微一笑很倾城" :艺术家 "杨洋" :评分 10 :转格式 T)
(:标题 "最美的期待" :艺术家 "周笔畅" :评分 6 :转格式 T)
(:标题 "明天过后" :艺术家 "张杰" :评分 9 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T)
(:标题 "卡路里" :艺术家 "火箭少女101" :评分 7 :转格式 T))

在 Windows 下,文件名可能会是 “c:/my-cds.db” 或 “c:\\my-cds.db”。4

你可以在任何文本编辑器里打开这个文件来查看它的内容。所看到的东西应该和直接在 REPL 里输入 *db* 时看到的东西差不多。

将数据加载回数据库的函数也是差不多的样子:

(函数 加载数据库 (文件名字)
(打开文件 (in 文件名字)
(with-standard-io-syntax
(赋值 *db* (读 in)))))

这次不需要在 打开文件的选项里指定 :direction 了,因为你要的是默认值 :input。并且与打印相反,这次要做的是使用函数  来从流中读入。这是与 REPL 使用的相同的读取器,可以读取你在 REPL 提示符下输入的 Lisp 表达式。但本例中只是读取和保存表达式,并不会对它求值。WITH-STANDARD-IO-SYNTAX 宏再一次确保  使用和 保存数据库 在打印数据时相同的基本语法。

赋值 宏是 Common Lisp 最主要的赋值操作符。它将其第一个参数设置成其第二个参数的求值结果。因此在 加载数据库 里,变量 *db* 将含有从文件中读取的对象,也就是由 保存数据库 所写入的那些列表组成的列表。需要特别注意一件事——加载数据库 会破坏其被调用之前 *db* 里面的东西。因此,如果已经用 添加记录 或者 添加音乐组 添加了尚未用 保存数据库 保存的记录,你将失去它们。

(加载数据库 "~/my-cds.db")

3.6 查询数据库

有了保存和重载数据库的方法,并且可以用一个便利的用户接口来添加新记录, 很快就会出现足够多的记录,但你并不想为了查看它里面有什么而每次都把整个 数据库导出来。比如说,你可能希望能够通过类似下面的查询

(搜索 :艺术家 "张杰")

来获得艺术家 张杰 的所有记录的列表。这又证明了当初选择用列表来保存记录是明智的。

函数 不匹配删除 接受一个谓词和一个原始列表,然后返回一个仅包含原始列表中匹配该谓词的所有元素的新列表。换句话说,它删除了所有不匹配该谓词的元素。然而,不匹配删除 并没有真的删除任何东西——它会创建一个新列表,而不会去碰原始列表。这就好比是在一个文件上运行 grep。谓词参数可以是任何接受单一参数并能返回布尔值的函数——除了 NIL 代表假以外其余的都代表真。

举个例子,假如要从一个由数字组成的列表里抽出所有偶数来,就可以像下面这样来使用 不匹配删除

CL-USER> (不匹配删除 #'evenp '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)

这里的谓词是函数 偶数,当其参数是偶数时返回真。那个有趣的 #' 记号是 “获取函数,其名如下” 的简称。没有 #' 的话,Lisp 将把 evenp 作为一个变量名来对待并查找该变量的值,而不是将其看作函数。

你也可以向 不匹配删除 传递一个匿名函数。例如,如果 偶数 不存在,你也可以像下面这样来写前面给出的表达式:

CL-USER> (不匹配删除 #'(lambda (x) (= 0 (余 x 2))) '(1 2 3 4 5 6 7 8 9 10))
(2 4 6 8 10)

在这种情况下,谓词是下面这个匿名函数:

(表达式 (x) (= 0 (余 x 2)))

它会检查其实参与 2 取模时等于 0 的情况(换句话说,就是偶数)。如果想要用匿名函数来抽出所有的奇数,就可以这样写:

CL-USER> (不匹配删除 #'(lambda (x) (= 1 (余 x 2))) '(1 2 3 4 5 6 7 8 9 10))
(1 3 5 7 9)

注意,lambda 并不是函数的名字——它只是一个表明你正在定义匿名函数的指示器。但除了缺少名字以外,一个 表达式 表达式看起来很像一个 函数:单词 lambda 后面紧跟着形参列表,然后再是函数体。

为了用 不匹配删除从数据库里选出所有 张杰 的专辑,需要可以在一条记录的艺术家字段是 “张杰” 时返回真的函数。请记住,我们之所以选择 plist 来表达数据库的记录是因为函数 读符号值 可以从 plist 里抽出给定名称的字段来。因此假设 cd 是保存着数据库单一记录的变量的名字,那么可以使用表达式 (读符号值 cd :艺术家) 来抽出艺术家名字来。当给函数 内同 赋予字符串参数时,可以逐个字符地比较它们。因此 (内同 (读符号值 cd :艺术家) “张杰”) 将测试一个给定 CD 的艺术家字段是否等于 “张杰”。所需做的只是将这个表达式包装在一个 表达式 形式,里从而得到一个匿名函数,然后传递给 不匹配删除

CL-USER> (不匹配删除 #'(lambda (cd) (内同 (读符号值 cd :艺术家) "张杰")) *db*)
((:标题 "明天过后" :艺术家 "张杰" :评分 9 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T))

现在假设要将整个表达式包装进一个接受艺术家名字作为参数的函数里,可以写成这样:

(函 搜索艺术家 (艺术家)
(不匹配删除
#'(lambda (cd) (内同 (读符号值 cd :艺术家) 艺术家))
*db*))

这个匿名函数的代码直到其被 不匹配删除 调用才会运行,它是如何访问到变量 艺术家 的。在这种情况下,匿名函数不仅使你免于编写一个正规函数,而且还可以编写出一个其部分含义(艺术家 值)取自上下文环境的函数。

以上就是 搜索艺术家。尽管如此,通过艺术家来搜索只是你想要支持的各种查询方法的一种,还可以编写其他几个函数,诸如 搜索标题搜索评分搜索标题和艺术家, 等等。但它们之间除了匿名函数的内容以外就没有其他区别了。换个做法,可以做出一个更加通用的 搜索 函数来,它接受一个函数作为其实参。

(函数 搜索 (搜索内容)
(不匹配删除 搜索内容 *db*))

但是 #' 到哪里去了?这是因为你并不希望 不匹配删除 在此去使用一个名为 搜索内容 的函数。它应该使用的是一个作为 搜索 的实参传递到变量 搜索内容 里的匿名函数。不过,在对 搜索 的调用中,#' 还是会出现。

CL-USER> (搜索 #'(lambda (cd) (内同 (读符号值 cd :艺术家) "张杰")))
((:标题 "明天过后" :艺术家 "张杰" :评分 9 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T))

但这样看起来相当粗糙。所幸可以将匿名函数的创建过程包装起来。

(函数 搜索艺术家名 (艺术家名)
#'(lambda (cd) (内同 (读符号值 cd :艺术家) 艺术家名)))

这是一个返回函数的函数,并且返回的函数里引用了一个似乎在 搜索艺术家名 返回以后将不会存在的变量。 尽管它现在可能看起来有些奇怪,但它确实可以按照你所想象的方式来工作——如果用参数 “张杰” 调用 搜索艺术家名,那么将得到一个可以匹配其 :artist 字段为 “张杰” 的 CD 的匿名函数,而如果用 “我们不一样” 来调用它,就将得到另一个匹配 :artist 字段为 “我们不一样” 的函数。所以现在可以像下面这样来重写前面的 搜索 调用了:

CL-USER> (搜索 (搜索艺术家名 "张杰"))
((:标题 "明天过后" :艺术家 "张杰" :评分 9 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 8 :转格式 T))

现在只需要用更多的函数来生成选择器了。但正如不想编写 搜索标题 搜索评分 等雷同的东西那样,你也不会想去写一大堆长相差不多每个字段写一个的选择器函数生成器。那么为什么不写一个通用的选择器函数生成器呢?让它根据传递给它的参数,生成用于不同字段甚至字段组合的选择器函数?完全可以写出这样一个函数来,不过首先需要快速学习一下关键字行参(keyword parameter)的有关内容。

目前写过的函数使用的都是一个简单的形参列表,随后被绑定到函数调用中对应的实参上。例如,下列函数

(函数 临时 (a b c) (列表 a b c))

有三个形参,ab 和 c,并且必须用三个实参来调用。但有时可能想要 编写一个可以用任何数量的实参来调用的函数,关键字形参就是其中一种实现方 式。使用关键字形参的 foo 版本可能看起来是这样的:

(函数 临时 (&key a b c) (列表 a b c))

它与前者唯一的区别在于形参列表的开始处有一个 &key。但是,对这个新 foo 的调用方法将是截然不同的。下面这些调用都是合法的,同时在 ==> 的右边给出了相应的结果。

(临时 :a 1 :b 2 :c 3)  ==> (1 2 3)
(临时 :c 3 :b 2 :a 1)  ==> (1 2 3)
(临时 :a 1 :c 3)       ==> (1 NIL 3)
(临时)                 ==> (NIL NIL NIL)

正如这些示例所显示的,变量 ab 和 c 的值被绑定到了跟在相应的关键字后面的值上。并且如果一个特定的关键字在调用中没有指定,那么对应的变量将被设置成 NIL。关于关键字形参如何指定以及它们与其他类型形参的关系等诸多细节在此不矛赘述,不过你还需要知道其中一点。

正常情况下,如果所调用的一个函数没有为特定关键字形参传递实参,该形参的值将为 NIL。但,有时你可能想要区分作为实参显式传递给关键字形参的 NIL 和作为默认值的 NIL。为此,在指定一个关键字形参时,可以将那个简单的名称替换成一个包括形参名、默认值和另一个称为 supplied-p 形参的列表。这个 supplied-p 形参可被设置成真或假,具体取决于实参在特定的函数调用里是否真的被传入相应的关键字形参中。下面是一个使用了该特性的 临时 版本:

(函数 临时 (&key a (b 20) (c 30 c-p)) (列表 a b c c-p))

之前给出同样的调用现在会产生下面的结果:

(临时 :a 1 :b 2 :c 3)  ==> (1 2 3 T)
(临时 :c 3 :b 2 :a 1)  ==> (1 2 3 T)
(临时 :a 1 :c 3)       ==> (1 20 3 T)
(临时)                 ==> (NIL 20 30 NIL)

通用的选择器函数生成器 哪里有 是一个函数——如果你熟悉 SQL 数据库的话,就会逐渐明白为什么叫它 哪里有 了。它接受对应于我们的 CD 记录字段的四个关键字形参,然后生成一个选择器函数,后者可以选出任何匹配 哪里有 子句的 CD。例如,它可以让你写出这样的语句来:

(搜索 (哪里有 :艺术家 "张杰"))

或是这样:

(搜索 (哪里有 :评分 10 :转格式 T))

该函数看起来是这样的:

(函数 哪里有 (&key 标题 艺术家 评分 (转格式 nil 转格式-p))
#'(lambda (cd)
(与
(判断 标题    (内同 (读符号值 cd :标题)  标题)  t)
(判断 艺术家   (内同 (读符号值 cd :艺术家) 艺术家) t)
(判断 评分   (内同 (读符号值 cd :评分) 评分) t)
(判断 转格式-p (内同 (读符号值 cd :转格式) 转格式) t))))

这个函数返回一个匿名函数,后者返回一个逻辑 ,而其中每个子句分别来自我们 CD 记录中的一个字段。每个子句会检查相应的参数是否被传递进来,然后要么将其跟 CD 记录中对应字段的值相比较,要么在参数没有传进来时返回 t,也就是 Lisp 版本的逻辑真。这样,选择器函数将只在 CD 记录匹配所有传递给 哪里有 的参数时才返回 t。注意到需要使用三元素列表来指定关键字形参 转格式,因为你需要知道调用者是否实际传递了 :转格式 nil,意思是“选择那些 转格式 字段为 nil 的 CD”,或者是否它们将 :转格式 整个扔下不管了,意思是 “我不在乎那个 转格式 字段的值”。

3.7 更新已有的记录——哪里有再战江湖

有了完美通用的 搜索哪里有 函数,是时候开始编写下一个所有数据库都需要的特性——更新特定记录的方法了。在 SQL 中,更新 命令被用于更新一组匹配特定 哪里有 子句的记录。这听起来像是个很好的模型,尤其是当已经有了一个 哪里有 子句生成器时。事实上,更新 函数只是你已经见过的一些思路的再次应用:使用一个通过参数传递的选择器函数来选取需要更新的记录,再使用关键字形参来指定需要改变的值。这里主要出现的新内容是对 映射函数 函数的使用,其映射在一个列表上(这里是 *db*),然后返回一个新的列表,其中含有在原来列表的每个 元素上调用一个函数所得到的结果。

(函数 更新 (搜索内容 &key 标题 艺术家 评分 (转格式 nil 转格式-p))
(赋值 *db*
(映射函数
#'(lambda (行)
(如果真 (函数调用 搜索内容 行)
(判断 标题    (赋值 (读符号值 行 :标题) 标题))
(判断 艺术家   (赋值 (读符号值 行 :艺术家) 艺术家))
(判断 评分   (赋值 (读符号值 行 :评分) 评分))
(判断 转格式-p (赋值 (读符号值 行 :转格式) 转格式)))
行) *db*)))

这里的另一个新内容是 赋值 用在了诸如 (读符号值 行 :标题) 这样的复杂形式上。第 6 章将细致地讨论 赋值,目前只需知道它是一个通用的赋值操作符,可被用于对各种 “位置” 而不只是对变量进行赋值即可。(赋值读符号值 具有相似的名字,但这纯属巧合,两者之间并没有特别的关系。)眼下知道执行 (赋值 (读符号值 行 :标题) 标题) 以后的结果就可以了:由 所引用的 plist 将具有紧跟着属性名 :标题 后面的那项变量 标题 的值。有了这个 更新 函数,如果你觉得自己真的很喜欢 张杰,并且他们的所有专辑的评级应该升到 11,那么可以对下列形式求值:

CL-USER> (更新 (哪里有 :艺术家 "张杰") :评分 11)
NIL

这样就可以了。

CL-USER> (搜索 (哪里有 :艺术家 "张杰"))
((:标题 "明天过后" :艺术家 "张杰" :评分 11 :转格式 T)
(:标题 "三生三世" :艺术家 "张杰" :评分 11 :转格式 T))

甚至可以更容易地添加一个函数来从数据库里删除行。

(函数 删除行 (搜索内容)
(赋值 *db* (匹配删除 搜索内容 *db*)))

函数 匹配删除 的功能跟 不匹配删除 正好相反,在它所返回的列表中,所有确实匹配谓词的元素都被删掉的。和 不匹配删除 一样,它不会实际地影响传入的那个列表,但是通过将结果重新保存到 *db* 中,删除行 事实上改变了数据库的内容。

CL-USER> (删除行 (哪里有 :标题 "最美的期待"))

3.8 消除重复,获益良多

 

目前所有的数据库代码,支持插入、选择、更新,更不用说还有用来添加新记录和导出内容的命令行接口,只有 50 行多点儿。总共就这些。10

不过这里仍然有一些讨厌的代码重复,看来可以在消除重复的同时使代码更为灵活。我所考虑的重复出现在 哪里有 函数里。哪里有 的函数体是一堆像这样的子句,每字段一个:

(判断 标题 (内同 (读符号值 cd :标题) 标题) t)

眼下情况还不算太坏,但它的开销和所有的代码重复是一样的:如果想要改变它的行为,就不得不改动它的多个副本。并且如果改变了 CD 的字段,就必须添加或删除 哪里有 的子句。而 更新 也需要承担同样的重复。最令人讨厌的一点在于,哪里有 函数的本意是去动态生成一点儿代码来检查你所关心的那些值,但为什么它非要在运行期来检查 标题 参数是否被传递进来了呢?

想象一下你正在试图优化这段代码,并且已经发现了它花费太多的时间检查 标题哪里有 的其他关键字形参是否被设置了。如果真的想要移除所有这些运行期检查,则可以通过一个程序将所有这些调用 哪里有 的位置以及究竟传递了哪些参数都找出来。然后就可以替换每一个对 哪里有 的调用,使用一个只做必要比较的匿名函数。举个例子,如果发现这段代码:

(搜索 (哪里有 :标题 "三生三世" :转格式 t))

你可以将其改为:

(搜索 #'(lambda (cd)
(与 (内同 (读符号值 cd :标题) "三生三世")
(内同 (读符号值 cd :转格式) t))))

注意到这个匿名函数跟 哪里有 所返回的那个是不同的,你并非在试图节省对 哪里有 的调用,而是提供了一个更有效率的选择器函数。这个匿名函数只带有在这次调用里实际关心的字段所对应的子句,所以它不会像 哪里有 可能返回的函数那样做任何额外的工作。

你可能会想象把所有的源代码都过一遍,并以这种方式修复所有对 哪里有 的调用,但你也会想到这样做将是极其痛苦的。如果它们有足够多,足够重要,那么编写某种可以将 哪里有 调用转化成你手写代码的预处理器就是非常值得的了。

使这件事变得极其简单的 Lisp 特性是它的宏(macro)系统。我必须反复强调,Common Lisp 的宏和那些在 C 和 C++ 里看到的基于文本的宏,从本质上讲,除了名字相似以外就再没有其他共同点了。C 预处理器操作在文本替换层面上,对 C 和 C++ 的结构几乎一无所知;而 Lisp 宏在本质上是一个由编译器自动为你运行的代码生成器。 当一个 Lisp 表达式包含了对宏的调用时,Lisp 编译器不再求值参数并将其传给函数,而是直接传递未经求值的参数给宏代码,后者返回一个新的 Lisp 表达式,在原先宏调用的位置上进行求值。

我将从一个简单而荒唐的例子开始,然后说明你应该怎样把 哪里有 函数替换成一个 哪里有 宏。在开始写这个示例宏之前,我需要快速介绍一个新函数:倒序,它接受一个列表作为参数并返回一个逆序的新列表。因此 (倒序 ‘(1 2 3)) 的求值结果为 (3 2 1)。现在让我们创建一个宏:

(宏 向后 (反向列表) (倒序 反向列表))

函数和宏的主要词法差异在于你需要用 而不是 函数 来定义一个宏。 除此之外,宏定义包括名字,就像函数那样,另外宏还有形参列表以及表达式体,这些也与函数一样。但宏却有着完全不同的效果。你可以像下面这样来使用这个宏:

CL-USER> (向后 ("你好,世界" t 格式))
你好,世界
NIL

它是怎么工作的? REPL 开始求值这个 向后 表达式时,它认识到 向后 是一个宏名。因此它保持表达式 (“你好,世界” t 格式) 不被求值。这样正好,因为它不是一个合法的 Lisp 形式。REPL 随后将这个列表传给 向后 代码。向后 中的代码再将列表传给 倒序,后者返回列表 (格式 t “你好,世界”)向后 再将这个值传回给 REPL,然后对其求值以顶替最初表达式。

这样 向后 宏就相当于定义了一个跟 Lisp 很像(只是反了过来)的新语言,你随时可以通过将一个逆序的 Lisp 表达式包装在一个对 向后 宏的调用里来使用它。而且,在编译了的 Lisp 程序里,这种新语言的效率就跟正常 Lisp 一样高,因为所有的宏代码即用来生成新表达式的代码,都是在编译期运行的。换句话说,编译器将产生完全相同的代码,无论你写成 (向后 (“你好,世界” t 格式)) 还是 (格式 t “你好,世界”)

那么这些东西又能对消除 哪里有 里的代码重复有什么帮助呢?情况是这样的:可以写出一个宏,它在每个特定的 哪里有 调用里只生成真正需要的代码。最佳方法还是自底向上构建我们的代码。在手工优化的选择器函数里,对于每个实际在最初的 哪里有 调用中引用的字段。来说,都有一个下列形式的表达式:

(内同 (读符号值 cd 区域) )

那么让我们来编写一个给定字段名及值并返回表达式的函数。由于表达式本身只是列表,所以函数应写成下面这样:

(函数 比较反向列表 (区域 值)    ; 错误
(列表 内同 (列表 读符号值 cd 区域) 值))

但这里还有一件麻烦事:你知道,当 Lisp 看到一个诸如 区域 这样的简单名字不作为列表的第一个元素出现时,它会假设这是一个变量的名字并去查找它的值。这对于 区域 来说是对的,这正是你想要的。但是它也会以同样的方式对待 内同读符号值 以及 cd,而这就不是你想要的了。尽管如此,你也知道如何防止 Lisp 去求值一个形式:在它前面加一个单引号。因此如果你将 比较反向列表 写成下面这样,它将如你所愿:

(函数 比较反向列表 (区域 值)
(列表 '内同 (列表 '读符号值 'cd 区域) 值))

你可以在 REPL 里测试它。

CL-USER> (比较反向列表 :评分 10)
(内同 (读符号值 CD :评分) 10)
CL-USER> (比较反向列表 :标题 "三生三世")
(内同 (读符号值 CD :标题) "三生三世")

其实还有更好的办法。当你一般不对表达式求值,但又希望通过一些方法从中提取出确实想求值的少数表达式时,你真正需要的是一种书写表达式的方式。但还可以某种提取出其中的你确实想求值的少数表达式来。当然,确实存在这样一种方法。位于表达式之前的反引号 (`)、可以像引号那样阻止表达式被求值。

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

不同的是,在一个反引用表达式里,任何以逗号开始的子表达式都是被求值的。请注意下面第二个表达式中逗号的影响。

`(1 2 (+ 1 2))        ==> (1 2 (+ 1 2))
`(1 2 ,(+ 1 2))       ==> (1 2 3)

有了反引号,就可以像下面这样书写 比较反向列表 了。

(函数 比较反向列表 (区域 值)
`(内同 (读符号值 cd ,区域) ,值))

现在如果回过头来看那个手工优化的选择器函数,就可以看到其函数体是由每字段/值对应于一个比较表达式组成的,它们全被封装在一个 表达式里。假设现在想让 where 宏的所有实参排成一列传递进来,你将需要一个函数,可以从这样的列表中成对提取元素,并收集在每对参数上调用 比较反向列表 的结果。为了实现这个函数,就需要使用一点儿高级 Lisp 技巧——强有力的 循环 宏。

(函数 用列表比较 (区域)
(循环 while 区域
collecting (比较反向列表 (移除前 区域) (移除前 区域))))

关于 循环 全面的讨论被放到了第 22 章,目前只需了解这个 循环 表达式刚好做了你想做的事:当 区域 列表有剩余元素时它会保持循环,一次弹出两个元素,将它们传递给 比较反向列表,然后在循环结束时收集所有返回的结果。移除前 宏所执行的操作与往 *db* 中添加记录时所使用的 PUSH 宏的操作。

现在需要将 用列表比较 所返回的列表封装在一个 和一个匿名函数里,这可以由 哪里有 宏本身来实现。使用一个反引号来生成一个模板,然后插入 用列表比较 的值,很简单。

(宏 哪里有 (&rest 子列表)
`#'(lambda (cd) (与 ,@(用列表比较 子列表))))

这个宏在 用列表比较 调用之前使用了 , 的变体 ,@。这个 ,@ 可以将接下来的表达式(必须求值成一个列表)的值嵌入到其外围的列表里。你可以通过下面两个表达式看出 , 和 ,@ 之间的区别:

`(与 ,(列表 1 2 3))   ==> (与 (1 2 3))
`(与 ,@(列表 1 2 3))  ==> (与 1 2 3)

也可以使用 ,@ 在列表的中间插入新元素。

`(与 ,@(列表 1 2 3) 4) ==> (与 1 2 3 4)

哪里有 宏的另一个重要特性是在实参列表中使用 &rest。和 &key 一样,&rest 改变了解析参数的方式。当参数列表里带有 &rest 时,一个函数或宏可以接受任意数量的实参,它们将被收集到一个单一列表中,并成为那个跟在 &rest 后面的名字所对应的变量的值。因此如果像下面这样调用 哪里有 的话。

(哪里有 :标题 "三生三世" :转格式 t)

那么变量 子列表 将包含这个列表。

(:标题 "三生三世" :转格式 t)

这个列表被传递给了 用列表比较,其返回一个由比较表达式所组成的列表。可以通过使用函数 展开宏 来精确地看到一个 哪里有 调用将产生出哪些代码。如果传给 展开宏 一个代表宏调用的形式,它将使用适当的参数来调用宏代码并返回其展开式。因此可以像这样检查上一个 哪里有调用:

CL-USER> (展开宏 '(哪里有 :标题 "三生三世" :转格式 t))
#'(lambda (CD)
(与 (内同 (读符号值 CD :标题) "三生三世")
(内同 (读符号值 CD :转格式) T)))
T

看起来不错。现在让我们实际试一下。

CL-USER> (搜索 (哪里有 :标题 "三生三世" :转格式 t))
((:标题 "三生三世" :艺术家 "张杰" :评分 11 :转格式 T))

它成功了。并且事实上,新的 哪里有 宏加上它的两个助手函数还比老的 哪里有 函数少了一行代码。并且新的代码更加通用,再也不需要理会我们 CD 记录中的特定字段了。

3.9 总结

现在,有趣的事情发生了。你不但去除了重复,而且还使得代码更有效且更通用了。这通常就是正确选用宏所达到的效果。这件事合乎逻辑,是因为宏只不过是另一种创建抽象的手法——词法层面的抽象,以及按照定义通过更简明地表达底层一般性的方式所得到的抽象。现在这个微型数据库的代码中只有 录入音乐提示输入音乐 以及 添加音乐组 函数是特定于 CD 及其字段的。事实上,新的 哪里有 宏可以用在任何基于 plist 的数据库上。

尽管如此,它距离一个完整的数据库仍很遥远。你可能会想到还有大量需要增加的特性,包括支持多表或是更复杂的查询。第 27 章将建立一个具备这些特性的 MP3 数据库。

本章的要点在于快速介绍少量 Lisp 特性,展示如何用它们编写出比 “你好,世界” 更有趣一点儿的代码。在下一章里,我们将对 Lisp 做一个更加系统的概述。

1然而,在我继续之前,忘记C预处理器中实现的#define-style“宏”是非常重要的。Lisp宏是一个完全不同的野兽。

2使用全局变量也有一些缺点 – 例如,一次只能有一个数据库。在第27章中,随着更多的语言,你将准备建立一个更灵活的数据库。在第6章中,您还将看到,在Common Lisp中使用全局变量比在其他语言中更灵活。

3格式指令中最酷的~R指令之一。曾经想知道如何用英文单词说出一个非常大的数字?Lisp知道。评估这个:

(格式 nil “~r” 1606938044258990275541962092)

你应该回来(包装易读):

“one octillion six hundred and six septillion nine hundred and thirty-eight sextillion forty-four quintillion two hundred and fifty-eight quadrillion nine hundred and ninety trillion two hundred and seventy-five billion five hundred and forty-one million nine hundred and sixty-two thousand and ninety-two”

4 Win判断循环ws实际上理解文件名中的正斜杠,即使它通常使用反斜杠作为目录分隔符。这很方便,因为否则你必须编写双反斜杠,因为反斜杠是Lisp字符串中的转义字符。

5 表达式这个词在Lisp中使用是因为早期与表达式演算有关,这是一种为研究数学函数而发明的数学形式。

6在其封闭范围内引用变量的函数的技术术语是闭包,因为函数“关闭”变量。我将在第6章中更详细地讨论闭包。

7请注意,在Lisp中,判断表单与其他所有表单一样,是一个返回值的表达式。它实际上更像是?:Perl,Java和C中的三元运算符(),因为这在这些语言中是合法的:

some_临时变量 = some_boolean ? 值1 : 值2;

虽然这不是:

some_临时变量 = 判断 (some_boolean) 值1; else 值2;

因为在那些语言中,判断是一种陈述,而不是一种表达。

8您需要使用名称删除行 而不是更明显的名称,de变量e因为Common Lisp中已经有一个函数被调用DE变量E。Lisp包系统为您提供了一种处理此类命名冲突的方法,因此您可以根据需要使用名为de变量e的函数。但我还没准备好解释包裹。

9如果您担心此代码会造成内存泄漏,请放心:Lisp是发明垃圾收集的语言(以及针对该问题的堆分配)。旧值使用的内存*db*将被自动回收,假设没有其他人持有对它的引用,这些代码都没有。

10我的一个朋友曾经为一个编程工作面试一位工程师并问他一个典型的面试问题:你怎么知道一个函数或方法何时太大?好吧,候选人说,我不喜欢任何比我头脑更大的方法。你是说你不能把所有的细节都记在脑子里?不,我的意思是我把头靠在我的显示器上,代码不应该比我的头大。

11检查关键字参数是否已通过的成本不太可能是对性能的可检测阻力,因为检查变量是否NIL会相当便宜。在另一方面,通过返回的这些功能哪里有 都将是正确的在任何的内部循环的中间 搜索,更新或删除行电话,因为他们必须在每个数据库条目调用一次。无论如何,为了说明的目的,这将是必须的。

12宏也由解释器运行 – 但是,当您考虑编译代码时,更容易理解宏的重点。与本章中的其他内容一样,我将在以后的章节中更详细地介绍这一点。


Leave a Reply

搜索

分类目录

公 告

本网站学习论坛:

www.zhlisp.com

lisp中文学习源码:

https://github.com/zhlisp/

欢迎大家来到本站,请积极评论发言;

加QQ群学习交流。