一次尝试:一种基于Common Lisp的简易单词本命令行工具

发布时间 2023-09-05 21:27:59作者: suspended-monitor

绪论

背景

英语的学习给现代中国学生带来了极大的挑战。学习英语的一种常规做法是记录纸质笔记。然而,常规的纸质笔记具有书写慢、不易修改的特点……(编不下去了)。为了简化英语单词笔记记录、查看的操作,本文基于一种简单的数据管理方法,提出一种新型单词本,即lisp-dictionary命令行工具。该新型单词本兼具简易数据管理以及简易CLI的特点。

其中,关于简易数据管理,《实用Common Lisp编程》进行过讨论。而关于用户交互,《Land of Lisp》则进行了相关介绍。

数据结构的构造与基本操作

这是程序中最为核心、基础的部分,比较容易实现。

单个单词的数据结构

设计储存单个单词的数据结构如下,以构造函数的形式给出:

(defun create-word (spell)
  (copy-list `(:spell ,spell
               :n nil :v nil
               :adj nil :adv nil
               :prep nil)))

该数据结构为含有键的列表。储存的信息包括:单词的拼写,以及单词的名词、动词、形容词、副词、介词词义。根据网络信息,英语中单词的词性有十种之多。但实际使用中,英语单词的词性以少数几种居多。这里选取了其中最为常见的5种词性,并且假定在单词本使用中,多数情况下只涉及该5种词性。

列表数据结构与主要功能

本文选择简易的列表作为储存单词的数据结构,实现一种简易的数据管理。

主要构造的函数如下:

  1. add-word: 向字典中添加新构造的单词
  2. set-word: 将特定单词的词性(关键字)设置为特定含义(值),其中值为字符串
  3. find-word: 根据单词拼写,从字典中查找对应单词
  4. remove-word-spell: 根据单词拼写,从字典中移除对应单词;这里考虑到,假设存在不可预知的意外情况(实际上按照设计,不可能由用户重复录入),导致字典中存在重复的单词。可以借助该命令移除所有拼写相同的单词
  5. clean-class-word: 将给定单词的词性(关键字)设置为给定含义(值)
  6. display-word: 打印单词的释义;其中,若某词性为nil,则说明该单词不具有该词性,所以不打印该词性
"本脚本用于实现单词本"
(defparameter *words-db* nil)

(defun add-word (word) (push word *words-db*))
(defun set-word (word key value)
  "设置特定单词的关键字值,value应为字符串"
  (setf (getf word key) value))
(defun find-word (spell)
  "从字典中查找单词,若无则返回nil"
  (car (remove-if-not
        (lambda (word) (eql spell (getf word :spell)))
        *words-db*)))

(defun remove-word-spell (spell)
  (setf *words-db* (remove-if
                    #'(lambda (word) (eq spell (getf word :spell)))
                    *words-db*)))
(defun clean-class-word (word key)
  (set-word word key nil))

(defun display-word (word)
  (flet ((display-class-word (word key)
           (if (getf word key)
               (format t "~% ~a.~7t~a" key (getf word key)))))
    (format t ">>> ~a" (getf word :spell))
    (display-class-word word :n)
    (display-class-word word :v)
    (display-class-word word :adj)
    (display-class-word word :adv)
    (display-class-word word :prep)
    (format t "~%")))

存档与加载

利用Common Lisp的文件读写功能保存存档与加载。存档文件的格式为纯文本。

这里,文件所在的路径为~/.config/lisp-dictionary,跟后续所介绍的改造为命令行工具有关。

;;;; 数据库的存档与加载
(defun save-db (data-base filename)
  (with-open-file (out filename
                       :direction :output
                       :if-exists :supersede
                       :if-does-not-exist :create)
    (with-standard-io-syntax
      (print data-base out))))
(defmacro load-db (data-base filename)
  `(let ((file-exists (probe-file ,filename)))
     (when file-exists
         (with-open-file (in ,filename
                        :if-does-not-exist :error)
     (with-standard-io-syntax
       (setf ,data-base (read in)))))))

(defun save-words ()
  (save-db *words-db* "~/.config/lisp-dictionary/dictionary-words.db"))
(defun load-words ()
  (load-db *words-db* "~/.config/lisp-dictionary/dictionary-words.db"))

用户交互功能

用户交互应具备一定功能,如下示意图所示

main-repl
├ note-down
│ ├ back
│ └ [edit]
├ look-up
│ └ back
├ edit
│ ├ back
│ └ change
├ erase
│ ├ back
│ ├ wipe
│ └ wipe-clean
├ restore
└ quit

该图用于表示功能之间的调用关系。其中,[]代表被调用的功能一般情况下不应看作处于次一级。

基础的读、执行功能

参考《Land of Lisp》,应当实现基础的读入、执行、输出函数。然而,本文略去了输出函数,将输出文本集成到具体函数的执行中。

user-eval*作为通用模板可根据需要生成执行函数。其参数allow-cmds规定了允许运行的命令,作为程序的一种简易保护措施。

(defun user-read ()
  "通用解析用户输入函数"
  (let ((cmd (read-from-string
              (concatenate 'string "(" (read-line) ")" ))))
    (flet ((quote-it (x)
             (list 'quote x)))
      (cons (car cmd) (mapcar #'quote-it (cdr cmd))))))

(defmacro user-eval* (allow-cmds)
  "模板,生成user-eval类型的函数,输入参数为允许的命令列表及允许词数
  allow-cmds: 应形如((command-1 3) (command-2 1))"
  `(lambda (sexp)
     (format t "~c[2J~c[H" #\escape #\escape)
     (let* ((allow-cmds ,allow-cmds)
            (find-cmd (assoc (car sexp) allow-cmds)))
       (if (and find-cmd
                (eq (length sexp) (cadr find-cmd)))
           (eval sexp)
           (format t "Not a valid command. (✿ ◕ __ ◕ )~%")))))

读取-求值-输出循环

根据《Land of Lisp》,应当实现读取-求值-输出循环。

其中,所谓子REPL,即为look-upediterase,因为根据设计,该三个功能仍然存在一定的用户交互能力。由于三者作为REPL具有一定的重复性,因此有必要利用宏对其进行抽象,作为模板,然后利用该模板来构造三个函数。

子REPL模板的构造

(defun user-cmd-description (cmd-desc)
  "依次打印命令的描述"
  (format t "~{~{- [~a~15t]: ~a~}~%~}" cmd-desc))

(defparameter *the-word* nil)
(defmacro user-repl* (cmd-desc u-eval)
  "子repl函数生成宏"
  `(lambda (spell)
     (setf *the-word* (find-word spell))
     (let ((word *the-word*))
       (labels
           ((repl (word)
              ; 此处显示查询单词的情况
              (if *the-word*
                  (progn
                    ;(format t "~c[2J~c[H" #\escape #\escape)
                    (format t "The target *~a* found. (˵u_u˵)~%~%" spell)
                    (display-word word))
                  (progn
                    ;(format t "~c[2J~c[H" #\escape #\escape)
                    (format t "The taget *~a* does not exist. (ノ ◕ ヮ ◕ )ノ~%~%" spell)))
              ; 反馈可用命令
              (user-cmd-description ,cmd-desc)
              ; 执行用户命令
              (let ((cmd (user-read)))
                (if (eq (car cmd) 'back)
                    (format t "~c[2J~c[H" #\escape #\escape)
                    (progn (funcall ,u-eval cmd)
                           (repl word))))))
         (repl word)))))

主REPL的命令

主REPL的命令,即为note-downlook-upediteraserestorequit数个函数。

根据设计,note-downrestorequit不应作为循环,因此,需要单独编写。

(defun note-down (spell)
  ;(format t "~c[2J~c[H" #\escape #\escape)
  (let ((word (find-word spell)))
                                        ; 此处显示查询单词的情况
    (if word
        (progn
          (format t "*~a* has already in our database.~%" spell)
          (read-line))
        (progn
          (add-word (create-word spell))
          (format t "The target *~a* has been add to our database.~%" spell)
          (read-line)
          (edit spell)))))

(defparameter look-up-call
  (user-repl*
   '(("back" "go back to the main menu."))
   (user-eval* '((back 1)))))
(defparameter edit-call
  (user-repl*
   '(("back" "go back to the main menu.")
     ("change :key new-meaning" "to change part of the speech of the target."))
   (user-eval* '((back 1) (change 3)))))
(defparameter erase-call
  (user-repl*
   '(("back" "go back to the main menu.")
     ("wipe :key" "to wipe off part of the speech of the target.")
     ("wipe-clean" "to wipe off the whole target clean."))
   (user-eval* '((back 1) (wipe 2) (wipe-clean 1)))))
(defmacro look-up (spell) `(funcall look-up-call ,spell))
(defmacro edit (spell) `(funcall edit-call ,spell))
(defmacro erase (spell) `(funcall erase-call ,spell))
(defun restore ()
  (save-words)
  (format t "Neatly done.~%")
  (read-line))
(defun quit-the-main-repl ()
  (save-words) ; 自动存档
  (format t "The dictionary closed. Goodbye. (⌐ ■ ᴗ ■ )~%"))

子REPL的命令

编写子REPL的命令。实际上只有changewipewipe-clean三个函数。均定义在全局范围内。因为根据通用模板user-eval*的实现原理,应当在源文件全局范围内定义函数。

(defun change (key value)
  (set-word *the-word* key (prin1-to-string value)))
(defun wipe (key)
  (clean-class-word *the-word* key))
(defun wipe-clean ()
  (if (not *the-word*)
      (progn
        (format t "Quite clean. Nothing to wipe off.")
        (read-line))
      (progn (format t "are you sure you want to wipe the hole target *~a* clean? (˵u_u˵)[y/n]"
                     (getf *the-word* :spell))
             (let* ((r-l (read-line))
                    (option (read-from-string
                             (if (eq (length r-l) 0)
                                 "default" r-l))))
               (cond ((eq 'y option)
                      (remove-word-spell (getf *the-word* :spell))
                      (setf *the-word* nil)
                      (format t "~c[2J~c[H" #\escape #\escape)
                      (format t "Neatly-done.~%")
                      (read-line))
                     ((eq 'n option))
                     (t (format t "yes or no?[y/n]~%")
                        (wipe-clean)))))))

主REPL

在主REPL中,上述关于读取和执行的模板仍然可用,但针对子REPL设计的模板不可用,所以,这里再次编写REPL的结构(可能存在减少重复代码的空间)。

(defun main-repl ()
  (format t "The dictionary opened. Wellcome back. ( ✿ ◕ ‿ ◕ )~%")
  (user-cmd-description              ; 反馈可用命令
   '(("note-down spell" "note-down a word.")
     ("look-up spell" "look up the dictionary for a word.")
     ("edit spell" "correct the fault.")
     ("erase spell" "give it a quick trim or eliminate it completely.")
     ("restore" "restore the data manually.")
     ("quit" "close the dictionary. data will be automatically restored by your little helper.(˵ ✿ ◕ ‿ ◕ ˵)")))
              ; 执行用户命令
  (let ((cmd (user-read)))
    (if (eq (car cmd) 'quit)
        (quit-the-main-repl)
        (progn
          (funcall (user-eval*
                    '((note-down 2)
                      (look-up 2)
                      (edit 2)
                      (erase 2)
                      (restore 1)
                      (quit 1))) cmd)
          (main-repl)))))

(load-words) ; 自动加载存档
(main-repl)
(sleep 0.1)(quit)

改造为命令行工具

Common Lisp程序可以编译为二进制可执行文件,具体编译方法因Common Lisp实现的不同而不同。具体方法参考《Common Lisp Recipes》。

利用ECL编译该程序,获得可执行文件,将其放置于/usr/local/bin目录下

sudo cp ./dictionary /usr/local/bin/lisp-dictionary

另外考虑存档文件的存放路径,设置在配置文件路径~/.configure/lisp-dictionary下。手动创建该路径即可。

关于lisp程序分发的问题

笔者曾经考虑将该程序代码分发至未安装Common Lisp实现的计算机上,但是发现存在困难。经过实践,笔者认为,在未安装Common Lisp实现的情况下,Common Lisp程序的分发确实存在困难。只有在Lisp程序员之间、以源文件的方式分享程序才是最方便的途径。Common Lisp本身并不存在版本的问题。事实证明,数十年前的Common Lisp代码在现在仍然可正常运行。

总结与展望

结论

本文针对单词本的实现开展讨论,主要解决了简易数据处理和简易命令行用户交互的问题。实现的单词本命令行工具具备简单的增、删、改、查功能,满足基本的英语学习需求。

展望

目前阶段,单词本命令行工具尚存在无法概览所有单词、无法反馈词汇总量的问题,对于用户可能的输入错误也未有良好的预防措施。未来可对单词本程序增加上述功能,并且考虑基于正则表达式实现较为高级的检索功能,允许根据单词局部来检索,可向用户反馈拼写相近的单词。