用 Lisp 实现简单数据库的优雅之旅 🛤️

在编程的世界里,语言的选择如同选择一位舞伴,有的轻盈优雅,有的沉稳可靠,而Lisp无疑是其中的优雅舞者之一。尽管诞生于1958年,Lisp依然在现代编程中大放异彩,正如Paul Graham所言:“Lisp是数学,数学永远不会过时。”今天,我们将通过一个简单的例子,来探讨如何用Lisp实现一个基本的数据库,专门用来存储MP3歌曲的信息。

CD 和记录 🎵

首先,我们需要定义我们的数据结构。我们的数据库将包含多条CD记录,每条记录包含以下四个信息:

  • CD标题
  • 艺术家信息
  • 评价信息(满分10分)
  • 是否被烧录(布尔值)

1. 数据结构的定义

在Lisp中,我们可以使用列表(list)和属性表(property list,简称plist)作为数据结构。列表类似于Python中的列表,而属性表则更像Python的字典。我们可以用以下代码定义一个CD记录:

(defun make-cd (title artist rating ripped)
  (list :title title :artist artist :rating rating :ripped ripped))

使用示例:

(make-cd "Roses" "Kathy Mattea" 7 t)

这将返回一个结构化的CD记录:

(:TITLE "Roses" :ARTIST "Kathy Mattea" :RATING 7 :RIPPED T)

2. 录入 CD 记录 📜

接下来,我们需要一个地方来存储这些CD记录。我们可以定义一个全局变量*db*(遵循Lisp的命名约定),并利用PUSH宏来添加新的记录:

(defvar *db* nil)

(defun add-record (cd)
  (push cd *db*))

现在,我们可以将make-cdadd-record结合起来,方便地将新的CD记录添加到数据库中。

3. 数据库的格式化输出 🎉

为了查看数据库中的内容,我们需要一个更友好的输出格式。我们可以使用dolist宏来遍历数据库,并用format函数来格式化输出:

(defun dump-db ()
  (dolist (cd *db*)
    (format t "~{~a:~10t~a~%~}" cd)))

调用(dump-db)后,我们将看到如下格式的输出:

TITLE:    Pork Face
ARTIST:   Laddy
RATING:   9
RIPPED:   T
TITLE:    Roses
ARTIST:   Kathy Mattea
RATING:   7
RIPPED:   T

改进用户交互 💬

使用add-record来添加CD记录显得有些繁琐,因此我们可以编写一个函数来提示用户输入CD信息。以下是一个简单的用户输入函数示例:

(defun prompt-read (prompt)
  (format *query-io* "~a: " prompt)
  (force-output *query-io*)
  (read-line *query-io*))

结合prompt-readmake-cd,我们可以创建一个更友好的用户接口:

(defun prompt-for-cd ()
  (make-cd
   (prompt-read "Title")
   (prompt-read "Artist")
   (or (parse-integer (prompt-read "Rating") :junk-allowed t) 0)
   (y-or-n-p "Ripped [y/n]: ")))

保存和加载数据库 💾

为了防止数据丢失,我们可以将数据库保存到文件中,并能够在下次加载时读取。以下是保存和加载的代码示例:

(defun save-db (filename)
  (with-open-file (out filename :direction :output :if-exists :supersede)
    (with-standard-io-syntax
      (print *db* out))))

(defun load-db (filename)
  (with-open-file (in filename)
    (with-standard-io-syntax
      (setf *db* (read in)))))

查询数据库 🔍

有了数据库,我们当然需要查询的功能。我们可以使用remove-if-not函数来筛选出符合条件的记录:

(defun select (selector-fn)
  (remove-if-not selector-fn *db*))

(defun artist-selector (artist)
  #'(lambda (cd) (equal (getf cd :artist) artist)))

调用示例:

(select (artist-selector "Kathy Mattea"))

更新和删除记录 ✂️

为了使数据库更加灵活,我们还可以添加更新和删除的功能。这可以通过mapcarremove-if等函数实现:

(defun update (selector-fn &key title artist rating (ripped nil ripped-p))
  (setf *db*
        (mapcar
         #'(lambda (row)
             (when (funcall selector-fn row)
               (if title (setf (getf row :title) title))
               (if artist (setf (getf row :artist) artist))
               (if rating (setf (getf row :rating) rating))
               (if ripped-p (setf (getf row :ripped) ripped)))
             row) *db*)))

(defun delete-rows (selector-fn)
  (setf *db* (remove-if selector-fn *db*)))

update 函数的逐行解析

这段代码定义了一个 update 函数,用于更新数据库 *db* 中符合条件的CD记录。该函数使用一个选择器函数(selector-fn)来匹配需要更新的记录,并通过关键字参数提供更新的内容。我们将逐行解释它的功能和关键点。


(defun update (selector-fn &key title artist rating (ripped nil ripped-p))
  • 功能: update 函数的目的是更新数据库中的CD记录。
  • 参数:
  • selector-fn: 一个函数,用于选择需要更新的记录。该函数会传递给 funcall,并对每个CD记录进行过滤。
  • &key: 关键字参数,允许用户指定哪些属性需要更新。包括:
    • title: 新的标题(如果提供)。
    • artist: 新的艺术家名称(如果提供)。
    • rating: 新的评分(如果提供)。
    • (ripped nil ripped-p): 是否已翻录。ripped-p 是一个标志,表示是否传递了 ripped 参数。默认值为 nil
  • 要点:
  • &key 用于定义关键字参数,这在Lisp中是一种便捷的方式来传递可选参数。
  • (ripped nil ripped-p) 定义了一个特殊的关键字参数 ripped,并通过 ripped-p 检查该参数是否实际被传递。

  (setf *db*
    (mapcar
     #'(lambda (row)
         (when (funcall selector-fn row)
           (if title (setf (getf row :title) title))
           (if artist (setf (getf row :artist) artist))
           (if rating (setf (getf row :rating) rating))
           (if ripped-p (setf (getf row :ripped) ripped)))
         row) *db*)))
  • 功能: 该部分的核心是使用 mapcar 函数来遍历 *db* 中的每一条CD记录,并对每一条记录进行更新。如果某条记录符合 selector-fn 的选择条件,则根据传入的关键字参数更新相应的字段。
  • 详细解释:
  1. setf *db*:
    • setf 用于修改全局变量 *db*,将其设置为 mapcar 函数的结果。mapcar 的作用是对 *db* 列表中的每一条记录应用指定的函数。
  2. mapcar:
    • mapcar 用于遍历 *db* 列表,并对每条记录 row 应用一个匿名函数(lambda)。
    • 每次迭代都会将当前的 row 传递给匿名函数,匿名函数根据条件选择是否更新该记录。
  3. lambda 函数:
    • 匿名函数(lambda)接收每一条记录 row,并通过 funcall 调用 selector-fn 来决定是否对该记录进行更新。
    • selector-fn 是一个选择器函数,它接收 row 作为参数,返回 tnil,表示是否需要更新该记录。
  4. when (funcall selector-fn row):
    • funcall 调用 selector-fn,并将 row 作为参数传递。如果 selector-fn 对该 row 返回 t,则进入 when 语句块,执行更新操作。
  5. 属性更新操作:
    • if title (setf (getf row :title) title):
    • 如果 title 参数不为 nil,则使用 setf 更新该记录的 :title 属性为新的 title 值。
    • if artist (setf (getf row :artist) artist):
    • 类似地,如果 artist 参数不为 nil,则更新该记录的 :artist 属性。
    • if rating (setf (getf row :rating) rating):
    • 如果 rating 参数不为 nil,则更新该记录的 :rating 属性。
    • if ripped-p (setf (getf row :ripped) ripped):
    • 如果 ripped-pt,表示用户传递了 ripped 参数,则更新该记录的 :ripped 属性。注意这里是通过 ripped-p 来判断是否传递了 ripped 参数,而不是直接判断 ripped 的值。这允许用户显式地将 ripped 设置为 nilt
  6. row 的返回:
    • 无论记录是否被更新,lambda 函数都会返回 row,并将其包含在新的列表中。mapcar 将这些记录组成一个新的数据库列表。

关键点和难点解析

  • 使用 selector-fn 进行条件选择:
  • 该函数的灵活性体现在它允许用户通过一个选择器函数(selector-fn)来指定需要更新的记录。这个设计非常通用,用户可以传递任意的选择逻辑,从而实现复杂的过滤条件。
  • 关键字参数的使用:
  • 关键字参数(&key)使得函数调用更加灵活。用户可以选择只更新某几个字段,而不必传递所有字段的值。
  • (ripped nil ripped-p) 是一种较为高级的用法,它不仅允许用户传递 ripped 值,还可以检测用户是否传递了这个参数。这使得 ripped 可以显式地设置为 nilt
  • mapcar 的使用:
  • mapcar 是一个常用的函数式编程工具,用于对列表中的每个元素应用一个函数,并返回一个新列表。这里它被用来遍历 *db*,并返回一个更新后的数据库。
  • setfgetf 的使用:
  • setf 是Lisp中的通用赋值操作符,用于修改数据结构中的值。在这里,它被用来更新属性列表(plist)中的值。
  • getf 用于从属性列表中获取指定键的值。通过 setfgetf 的组合,可以修改属性列表的某个键值对。

示例

假设数据库 *db* 中有以下CD记录:

(setq *db* (list (make-cd "Title1" "Artist1" 5 t)
                 (make-cd "Title2" "Artist2" 4 nil)
                 (make-cd "Title3" "Artist3" 3 t)))

现在,我们希望将所有评分为 5 的CD的标题更新为 "New Title",可以这样调用 update 函数:

(update (where :rating 5) :title "New Title")

调用后,数据库中的第一条记录的标题将被更新为 "New Title",而其他记录保持不变。


结论 🎊

通过上述步骤,我们用Lisp成功实现了一个简单的数据库,能够存储、查询、更新和删除CD记录。尽管Lisp的语法可能让初学者感到陌生,但它的优雅与强大在实际应用中会让你感受到编程的乐趣。希望你在探索Lisp的旅程中,能够感受到这位优雅舞伴所带来的无限魅力。

参考文献 📚

  1. 编程之禅. (2021). 用 Lisp 实现一个简单的数据库. Retrieved from 编程之禅
(defun make-cd (title artist rating ripped)
  (list :title title :artist artist :rating rating :ripped ripped))

(defvar *db* nil)

(defun add-record (cd) (push cd *db*))

(defun dump-db ()
  (dolist (cd *db*)
    (format t "~{~a:~10t~a~%~}~%" cd)))


(defun prompt-read (prompt)
  (format *query-io* "~a: " prompt)
  (force-output *query-io*)
  (read-line *query-io*))

(defun prompt-for-cd ()
  (make-cd
   (prompt-read "Title")
   (prompt-read "Artist")
   (OR (parse-integer (prompt-read "Rating") :junk-allowed t) 0)
   (y-or-n-p "Ripped [y/n]: ")))

(defun add-cds ()
  (loop (add-record (prompt-for-cd))
	(if (not (y-or-n-p "Another?[y/n]: ")) (return))))

(defun save-db (filename)
  (with-open-file (out filename
		       :direction :output
		       :if-exists :supersede)
    (with-standard-io-syntax
      (print *db* out))))


(defun load-db (filename)
  (with-open-file (in filename)
    (with-standard-io-syntax
      (setf *db* (read in)))))

(defun select (selector-fn)
  (remove-if-not selector-fn *db*))

(defun where (&key title artist rating (ripped nil ripped-p))
  #'(lambda (cd)
      (and
       (if title (equal (getf cd :title) title) t)
       (if artist (equal (getf cd :artist) artist) t)
       (if rating (equal (getf cd :rating) rating) t)
       (if ripped-p (equal (getf cd :ripped) ripped) t))))

(defun update (selector-fn &key title artist rating (ripped nil ripped-p))
  (setf *db*
	(mapcar
	 #'(lambda (row)
	     (when (funcall selector-fn row)
	       (if title (setf (getf row :title) title))
	       (if artist (setf (getf row :artist) artist))
	       (if rating (setf (getf row :rating) rating))
	       (if ripped-p (setf (getf row :ripped) ripped)))
	     row) *db*)))

(defun delete-rows (selector-fn)
  (setf *db* (remove-if selector-fn *db*)))

这段代码实现了一个简单的CD数据库管理系统,可以添加、查询、更新和删除CD记录,并支持数据库的加载和保存。我们将逐步分析每一个部分的功能和关键点。


1. make-cd: 创建 CD 记录

(defun make-cd (title artist rating ripped)
  (list :title title :artist artist :rating rating :ripped ripped))
  • 功能: make-cd 函数用于创建一个新的CD记录。每个CD记录是一个列表,包含标题(:title)、艺术家(:artist)、评分(:rating)和是否已翻录(:ripped)。
  • 要点:
  • 使用 list 函数创建一个属性列表(property list,简称 plist),其键(以冒号开头,如 :title)是符号,值是函数参数。
  • 属性列表在Lisp中是一种常见的数据结构,使用 getf 函数可以访问键对应的值。

2. *db*: 数据库变量

(defvar *db* nil)
  • 功能: 这是一个全局变量,用于存储所有CD记录。最初它被设置为 nil,表示数据库为空。
  • 要点:
  • *db* 是一个全局变量,遵循Common Lisp中全局变量的命名习惯,即用星号包围变量名。
  • 使用 defvar 定义全局变量,如果该变量已经存在,defvar 不会重新初始化。

3. add-record: 添加记录到数据库

(defun add-record (cd) (push cd *db*))
  • 功能: 将一个CD记录添加到数据库中。
  • 要点:
  • 使用 push 将CD记录添加到 *db* 列表的头部。push 是一个高效的操作,因为它直接修改列表的指向。

4. dump-db: 打印数据库内容

(defun dump-db ()
  (dolist (cd *db*)
    (format t "~{~a:~10t~a~%~}~%" cd)))
  • 功能: 遍历 *db*,逐条打印每个CD记录的属性和值。
  • 要点:
  • dolist 用于遍历数据库中的每个CD记录。
  • format 函数用于格式化输出。这里的 ~{~a:~10t~a~%~} 格式化字符串用于按键值对的形式打印CD的元数据。

5. prompt-read: 从用户输入读取数据

(defun prompt-read (prompt)
  (format *query-io* "~a: " prompt)
  (force-output *query-io*)
  (read-line *query-io*))
  • 功能: 提示用户输入信息,并读取用户输入的字符串。
  • 要点:
  • format 用于显示提示信息。
  • force-output 确保提示信息立即输出,而 read-line 则从用户输入中读取一行文本。

6. prompt-for-cd: 提示用户输入CD信息

(defun prompt-for-cd ()
  (make-cd
   (prompt-read "Title")
   (prompt-read "Artist")
   (or (parse-integer (prompt-read "Rating") :junk-allowed t) 0)
   (y-or-n-p "Ripped [y/n]: ")))
  • 功能: 提示用户输入CD的详细信息,并创建并返回一个CD记录。
  • 要点:
  • 使用 prompt-read 函数提示用户输入标题和艺术家。
  • parse-integer 将用户输入的评分转换为整数,如果输入的内容不是有效的数字,则返回 0(使用了 :junk-allowed 选项)。
  • 使用 y-or-n-p 确认CD是否已经翻录。它是一个用于读取 y/n 回答的函数。

7. add-cds: 循环添加多个CD记录

(defun add-cds ()
  (loop (add-record (prompt-for-cd))
    (if (not (y-or-n-p "Another?[y/n]: ")) (return))))
  • 功能: 支持用户通过循环方式添加多个CD记录,直到用户选择停止。
  • 要点:
  • loop 用于无限循环,直到用户选择不再添加CD。
  • 每次循环调用 add-record 添加新CD,并通过 y-or-n-p 确认是否继续。

8. save-db: 保存数据库到文件

(defun save-db (filename)
  (with-open-file (out filename
               :direction :output
               :if-exists :supersede)
    (with-standard-io-syntax
      (print *db* out))))
  • 功能: 将数据库保存到指定文件中。
  • 要点:
  • with-open-file 打开一个文件进行输出,:if-exists :supersede 表示如果文件存在则覆盖。
  • with-standard-io-syntax 设置标准的I/O语法,确保数据结构可以正确地读/写。
  • print 将数据库的内容输出到文件。

9. load-db: 从文件加载数据库

(defun load-db (filename)
  (with-open-file (in filename)
    (with-standard-io-syntax
      (setf *db* (read in)))))
  • 功能: 从指定文件中加载数据库。
  • 要点:
  • save-db 类似使用 with-open-file 打开文件进行读取。
  • read 函数从文件中读取数据库数据,并使用 setf 将其赋值给 *db*

10. select: 选择符合条件的CD记录

(defun select (selector-fn)
  (remove-if-not selector-fn *db*))
  • 功能: 选择所有满足 selector-fn 条件的CD记录。
  • 要点:
  • remove-if-not 用于过滤列表,保留所有满足 selector-fn 函数返回 true 的元素。

11. where: 构造选择条件

(defun where (&key title artist rating (ripped nil ripped-p))
  #'(lambda (cd)
      (and
       (if title (equal (getf cd :title) title) t)
       (if artist (equal (getf cd :artist) artist) t)
       (if rating (equal (getf cd :rating) rating) t)
       (if ripped-p (equal (getf cd :ripped) ripped) t))))
  • 功能: 根据给定的键(如 titleartistrating 等)构造一个选择函数,用于在 select 函数中进行过滤。
  • 要点:
  • &key 允许参数按关键字传递。
  • 返回一个匿名函数(lambda),该函数根据传递的条件对每个CD记录进行匹配。

12. update: 更新符合条件的CD记录

(defun update (selector-fn &key title artist rating (ripped nil ripped-p))
  (setf *db*
    (mapcar
     #'(lambda (row)
         (when (funcall selector-fn row)
           (if title (setf (getf row :title) title))
           (if artist (setf (getf row :artist) artist))
           (if rating (setf (getf row :rating) rating))
           (if ripped-p (setf (getf row :ripped) ripped)))
         row) *db*)))
  • 功能: 更新数据库中所有符合 selector-fn 条件的CD记录。
  • 要点:
  • 使用 mapcar 遍历 *db*,对每个符合条件的CD记录进行更新。
  • setf 更新属性列表中的对应属性。

13. delete-rows: 删除符合条件的CD记录

  • 功能: 删除所有满足 selector-fn 条件的CD记录。
  • 要点:
  • remove-if 移除列表中符合条件的元素。

(defun delete-rows (selector-fn)
  (setf *db* (remove-if selector-fn *db*)))

总结

这段代码实现了一个简单的CD数据库系统,使用Common Lisp的基本数据结构和控制流来管理数据。代码的关键点包括:

  • 使用属性列表存储CD记录。
  • 提供了增删改查的操作。
  • 使用函数式编程风格(如 selector-fn)进行选择和过滤。

通过这些功能,用户可以灵活地管理和操作CD数据库。

0 0 投票数
Article Rating
订阅评论
提醒
0 评论
最多投票
最新 最旧
内联反馈
查看所有评论
0
希望看到您的想法,请您发表评论x