绪论
背景
英语的学习给现代中国学生带来了极大的挑战。学习英语的一种常规做法是记录纸质笔记。然而,常规的纸质笔记具有书写慢、不易修改的特点……(编不下去了)。为了简化英语单词笔记记录、查看的操作,本文基于一种简单的数据管理方法,提出一种新型单词本,即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种词性。
列表数据结构与主要功能
本文选择简易的列表作为储存单词的数据结构,实现一种简易的数据管理。
主要构造的函数如下:
add-word
: 向字典中添加新构造的单词set-word
: 将特定单词的词性(关键字)设置为特定含义(值),其中值为字符串find-word
: 根据单词拼写,从字典中查找对应单词remove-word-spell
: 根据单词拼写,从字典中移除对应单词;这里考虑到,假设存在不可预知的意外情况(实际上按照设计,不可能由用户重复录入),导致字典中存在重复的单词。可以借助该命令移除所有拼写相同的单词clean-class-word
: 将给定单词的词性(关键字)设置为给定含义(值)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-up
、edit
、erase
,因为根据设计,该三个功能仍然存在一定的用户交互能力。由于三者作为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-down
、look-up
、edit
、erase
、restore
、quit
数个函数。
根据设计,note-down
、restore
、quit
不应作为循环,因此,需要单独编写。
(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的命令。实际上只有change
、wipe
、wipe-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代码在现在仍然可正常运行。
总结与展望
结论
本文针对单词本的实现开展讨论,主要解决了简易数据处理和简易命令行用户交互的问题。实现的单词本命令行工具具备简单的增、删、改、查功能,满足基本的英语学习需求。
展望
目前阶段,单词本命令行工具尚存在无法概览所有单词、无法反馈词汇总量的问题,对于用户可能的输入错误也未有良好的预防措施。未来可对单词本程序增加上述功能,并且考虑基于正则表达式实现较为高级的检索功能,允许根据单词局部来检索,可向用户反馈拼写相近的单词。