Reader macro 是 Common Lisp 提供的众多有趣特性之一,它让语言的使用者能够自定义词法分析的逻辑,使其在读取源代码时,如果遇到了特定的一两个字符,可以调用相应的函数来个性化处理。此处所说的“特定的一两个字符”,被称为 macro character,而“相应的函数”则被称为 reader macro function。举个例子,单引号'
就是一个 macro character,可以用函数get-macro-character
来获取它对应的 reader macro function。
1 | CL-USER> (get-macro-character #\') |
借助单引号,可以简化一些代码的写法,例如表达一个符号HELLO
本身可以写成这样。
1 | CL-USER> 'hello |
而不是下面这种等价但更繁琐的形式。
1 | CL-USER> (quote hello) |
Common Lisp 中还定义了由两个字符构成的 reader macro,例如用于书写simple-vector
字面量的#(
。借助它,如果想要表达一个依次由数字 1、2、3 构成的simple-vector
类型的对象,不需要显式地调用函数vector
并传给它 1、2、3,而是可以写成#(1 2 3)
。
合法的 JSON 文本不一定是合法的 Common Lisp 源代码。例如,[1, 2, 3]
在 JSON 标准看来是一个由数字 1、2、3 组成的数组,但在 Common Lisp 中,这段代码会触发 condition。(condition 就是 Common Lisp 中的“异常”、“出状况”了)
1 | CL-USER> (let ((eof-value (gensym))) |
这是因为按照 Common Lisp 的读取算法,左方括号[
和数字 1 都是标准中所指的 constituent character,它们可以组成一个 token,并且最终被解析为一个符号类型的对象。而紧接着的字符是逗号,
,它是一个 terminating macro char,按照标准,如果不是在一个反引号表达式中使用它将会是无效的,因此触发了 condition。
假如存在一个由两个字符#J
定义的 reader macro、允许开发者使用 JSON 语法来描述紧接着的对象的话,那么就可以写出下面这样的代码。
1 | CL-USER> (progn |
显然,用上述语法表示一个哈希表,要比下面这样的代码简单得多
1 | CL-USER> (let ((obj (make-hash-table :test #'equal))) |
Common Lisp 并没有预置#J
这个 reader macro,但这门语言允许使用者定义自己的 macro character,因此前面的示例代码是可以实现的。要自定义出#J
这个读取器宏,需要使用函数set-dispatch-macro-character
。它的前两个参数分别为构成 macro character 的前两个字符,即#
和J
——其中J
即便是写成了小写,也会被转换为大写后再使用。第三个参数则是 Lisp 的词法解析器在遇到了#J
时将会调用的参数。set-dispatch-macro-character
会传给这个函数三个参数:
J
);#
和J
之间的数字。百闻不如一见,一段能够实现上一个章节中的示例代码的set-dispatch-macro-character
用法如下
1 | (set-dispatch-macro-character |
在set-dispatch-macro-character
的回调函数中,我是用了开源的第三方库yason
提供的函数parse
,从输入流stream
中按照 JSON 语法解析出一个值。函数parse
的三个关键字参数的含义参见这里,此处不再赘述。由于 reader macro 的结果会被用于构造源代码的表达式,因此如果函数parse
返回了符号或者cons
类型,为了避免被编译器求值,需要将它们“引用”起来,因此将它们放到第一元素为quote
的列表中。其它情况下,直接返回parse
的返回值即可,因此它们是“自求值”的,求值结果是它们自身。
本文我借助了现成的库yason
来解析 JSON 格式的字符串,如果你对如何从零开始实现这样的 reader macro 感兴趣的话,可以参考这篇文章。
全文完。
]]>计数循环就是从一个数字$i$开始一直遍历到另一个数字$j$为止的循环过程。例如,下面的 Python 代码就会遍历从 0 到 9 这 10 个整数并逐个打印它们
1 | for i in range(10): |
如果是在 C 语言中实现同样的功能,代码会更显著一些
1 |
|
在 C 语言的例子中,显式地指定了计数器变量i
从 0 开始并且在等于 10 的时候结束循环,比之 Python 版本更有循环的味道。
使用 C 语言的while
语句同样可以实现计数循环,示例代码如下
1 |
|
如果将while
也视为if
和goto
的语法糖的话,可以进一步将计数循环写成更原始的形式
1 |
|
在 Common Lisp 中也有与 C 语言的goto
特性相近的 special form,那就是tagbody
和go
。使用它们可以将 C 代码直白地翻译为对应的 Common Lisp 版本
1 | (let ((i 0)) |
聪明的你一定已经发现了,此处的第二个符号label1
其实是丝毫不必要的,只要写成下面的形式即可
1 | (let ((i 0)) |
这个形式不仅仅是更简单了,而且它暴露出了一个事实:label0
所表示的,其实就是在将变量i
绑定为 0之后要执行的代码的位置。换句话说,它标识了一个续延(continuation)。
如果你用的语言中支持 first-class 的续延,那么便可以用来实现计数循环,例如233-lisp。在 233-lisp 中,提供了特殊操作符call/cc
来捕捉当前续延对象,这个名字借鉴自 Scheme。借助这个操作符,即便没有tagbody
和go
,也可以实现计数循环。
在上面的代码中,call/cc
捕捉到的续延就是“赋值给局部变量i
”。在将这个续延k
保存到变量next
之后,用 0 初始化变量i
。之后只要i
还小于 10,就将它打印到标准输出,并启动保存在了变量next
中的续延,回到给变量i
赋值的地方。此时传递给续延的参数为(+ i 1)
,就实现了变量i
的自增操作。当(< i 10)
不再成立时,也就不会启动续延“回到过去”了,至此,进程结束。
在 233-lisp 中,将dotimes
作为一个内置的宏用call/cc
实现了一遍,参见这里,其代码如下
1 | (defun expand-dotimes-to-call/cc (expr) |
变量count-form-result
和next
分别表示在宏展开后的代码中的计数上限和被捕捉的续延。之所以让它们以(gensym)
的方式来命名,是为了避免多次求值count-form
表达式,以及避免存储续延的变量名恰好出乎意料地与statements
中的变量名冲突了,这也算是编写 Common Lisp 的宏时的最佳实践了。
直接用call/cc
来一个个实现 Common Lisp 中的各种控制流还是太繁琐了,更好的方案是用call/cc
先实现tagbody
和go
,然后再用后两者继续实现do
,最后用do
分别实现dolist
和dotimes
。当然了,这些都是后话了。
clingon 是一个 Common Lisp 的命令行选项的解析器,它可以轻松地解析具有复杂格式的命令行选项。例如,下面的代码可以打印给定次数的打招呼信息
1 |
|
稍微做一些解释。首先执行命令ros init hello
生成上面的代码的雏形——加载依赖、包定义,以及空的函数main
。为了加载 clingon,将其作为函数ql:quickload
的参数。然后分别定义一个command
、handler
,以及option
。
在 clingon 中,类clingon:command
的实例对象表示一个可以在 shell 中被触发的命令,它们由函数clingon:make-command
创建。每一个命令起码要有三个要素:
:handler
,负责使用命令行选项、实现业务逻辑的函数;:name
,命令的名字,一般会被展示在命令的用法说明中;:options
,该命令所接受的选项。此处的:handler
就是函数top-level/handler
,它会被函数clingon:run
调用(依赖注入的味道),并将一个合适的clingon:command
对象传入。:options
目前只承载了一个选项的定义,即
1 | (clingon:make-option |
它定义了一个值为整数的选项,在命令行中通过--count
指定。如果没有传入该选项,那么在使用函数clingon:getopt
取值时,会获得默认值 1。如果要从一个命令对象中取出这个选项的值,需要以它的:key
参数的值作为参数来调用函数clingon:getopt
,正如上面的函数top-level/handler
所示。
clingon 也可以实现诸如git add
、git branch
这样的子命令特性。像add
、branch
这样的子命令,对于 clingon 而言仍然是类clingon:command
的实例对象,只不过它们不会传递给函数clingon:run
调度,而是传递给函数clingon:make-command
的参数:sub-command
,如下列代码所示
1 | (defun top-level/handler (cmd) |
在 clingon 中通过命令行传递给进程的信息分为选项和参数两种形态,选项是通过名字来引用,而参数则通过它们的下标来引用。例如在第一个例子中,就定义了一个名为--count
的选项,它在解析结果中被赋予了:count
这个关键字,可以通过函数clingon:getopt
来引用它的值;与之相反,变量name
是从命令行中解析了选项后、剩余的参数中的第一个,它是以位置来标识的。clingon 通过函数clingon:make-option
来定义选项,它提供了丰富的控制能力。
选项有好几种名字,一种叫做:key
,是在程序内部使用的名字,用作函数clingon:getopt
的参数之一;一种叫做:long-name
,一般为多于一个字符的字符串,如"count"
,在命令行该名称需要带上两个连字符的前缀来使用,如--count 3
;最后一种叫做:short-name
,为一个单独的字符,如#\v
,在命令行中带上一个连字符前缀来使用,如-v
。
通过传入参数:required t
给函数clingon:make-option
,可以要求一个选项为必传的。例如下面的命令的选项--n
就是必传的
1 | (defun top-level/handler (cmd) |
如果不希望在一些最简单的情况下也要繁琐地编写--n 1
这样的命令行参数,可以用:initial-value 1
来指定。除此之外,也可以让选项默认读取指定的环境变量中的值,使用:env-vars
指定环境变量名即可
1 | (defun top-level/handler (cmd) |
像curl
中的选项-H
就是可以多次使用的,每指定一次就可以在请求中添加一个 HTTP 头部,如下图所示
在 clingon 中可以通过往函数clingon:make-option
传入:list
来实现。当用clingon:getopt
取出类型为:list
的选项的值时,得到的是一个列表,其中依次存放着输入的值的字符串。
1 | (defun top-level/handler (cmd) |
另一种情况是尽管没有值,但仍然多次使用同一个选项。例如命令ssh
的选项-v
,使用的次数越多(最多为 3 次),则ssh
打印的调试信息也就越详细。这种类型的选项在 clingon 中称为:counter
。
1 | (defun top-level/handler (cmd) |
有一些选项只需要区分【有】和【没有】两种情况就可以了,而不需要在意这个选项的值——或者这类选项本身就不允许有值,例如docker run
命令的选项-d
和--detach
。这种选项的类型为:boolean/true
,如果指定了这个选项,那么取出来的值始终为t
。与之相反,类型:boolean/false
取出来的值始终为nil
。
1 | (defun top-level/handler (cmd) |
如果一个选项尽管接受的是字符串,但并非所有输入都是有意义的,例如命令dot
的选项-T
。从dot
的 man 文档可以看到,它所支持的图片类型是有限的,如ps
、pdf
、png
等。比起声明一个:string
类型的选项,让 clingon 代劳输入值的有效性检查来得更轻松,这里可以使用:choice
类型
1 | (defun top-level/handler (cmd) |
format
。例如,上面的代码会往标准输出中打印出233这个数字:1 | (format t "~D" 233) |
除此之外,format
还可以控制打印内容的宽度、填充字符、是否打印正负号等方面。例如,要控制打印的内容至少占据6列的话,可以用如下代码
1 | (format t "~6D" 233) |
如果不使用字符串形式的 DSL,而是以关键字参数的方式来实现一个能够达到同样效果的函数format-decimal
,代码可能如下:
1 | (defun format-decimal (n |
如果要求用数字0而不是空格来填充左侧的列,用format
的写法如下:
1 | (format t "~6,'0D" 233) |
format-decimal
想要做到同样的事情,可以这么写:
1 | (defun format-decimal (n |
-D
默认是不会打印非负整数的符号的,可以用修饰符@
来修改这个行为。例如,(format t "~6,'0@D" 233)
会打印出00+233
。稍微修改一下就可以在format-decimal
中实现同样的功能
1 | (defun format-decimal (n |
除了@
之外,:
也是一个~D
的修饰符,它可以让format
每隔3个数字就打印出一个逗号,方便阅读比较长的数字。例如,下列代码会打印出00+23,333
:
1 | (format t "~9,'0@:D" 23333) |
为此,给format-decimal
新增一个关键字参数comma-separated
来控制这一行为。
1 | (defun format-decimal (n |
事实上,打印分隔符的步长,以及作为分隔符的逗号都是可以定制的。例如,可以改为每隔4个数字打印一个连字符
1 | (format t "~9,'0,'-,4@:D" 23333) |
对于format-decimal
来说这个修改现在很简单了
1 | (defun format-decimal (n |
全文完。
]]>Animal
及其两个子类的例子(代码经过我微微调整)1 | abstract class Animal { |
基于子类型的多态要求在程序的运行期根据参数的类型,选择不同的具体方法——例如在上述例子中,当方法letsHear
中调用了参数a
的方法talk
时,是依照变量a
在运行期的类型(第一次为Cat
,第二次为Dog
)来选择对应的talk
方法的实例的,而不是依照编译期的类型Animal
。
但在不同的语言中,在运行期查找方法时,所选择的参数的个数是不同的。对于 Java 而言,它只取方法的第一个参数(即接收者),这个策略被称为 single dispatch。
要演示为什么 Java 是 single dispatch 的,必须让示例代码中的方法接收两个参数(除了方法的接收者之外再来一个参数)
1 | // 演示 Java 是 single dispatch 的。 |
显然,类Resizer
的实例方法resize
就是接收两个参数的——第一个为Resizer
类的实例对象,第二个则可能是Shape
及其三个子类中的一种类的实例对象。假如 Java 的多态策略是 multiple dispatch 的,那么应当分别调用不同的三个版本的resize
方法,但实际上并不是
通过 JDK 中提供的程序javap
可以看到在main
方法中调用resize
方法时究竟用的是类Resizer
中的哪一个版本,运行命令javap -c -l -s -v Trial1
,可以看到调用resize
方法对应的 JVM 字节码为invokevirtual
翻阅 JVM 规格文档可以找到对invokevirtual 指令的解释
显然,由于在 JVM 的字节码中,invokevirtual
所调用的方法的参数类型已经解析完毕——LShape
表示是一个叫做Shape
的类,因此在方法接收者,即类Resizer
中查找的时候,也只会命中resize(Shape s)
这个版本的方法。变量s
的运行期类型在查找方法的时候,丝毫没有派上用场,因此 Java 的多态是 single dispatch 的。
想要依据参数的运行期类型来打印不同内容也不难,简单粗暴的办法可以选择instanceOf
1 | abstract class AbstractResizer |
或者动用 Visitor 模式。
我第一次知道 multiple dispatch 这个词语,其实就是在偶然间查找 CLOS 的相关资料时看到的。在 Common Lisp 中,定义类和方法的语法与常见的语言画风不太一样。例如,下列代码跟 Java 一样定义了四个类
1 | (defclass shape () |
执行上述代码会调用不同版本的resize
方法来打印内容
由于defmethod
支持给每一个参数都声明对应的类这一做法是在太符合直觉了,以至于我丝毫没有意识到它有一个专门的名字叫做 multiple dispatch,并且在大多数语言中是不支持的。
聪明的你应该已经发现了,在上面的 Common Lisp 代码中,其实与 Java 中的抽象类AbstractResizer
对应的类abstract-resizer
是完全没有必要的,defgeneric
本身就是一种用来定义抽象接口的手段。
此外,在第三个版本的resize
方法中,可以看到标识符shape
同时作为了参数的名字和该参数所属的类的名字——没错,在 Common Lisp 中,一个符号不仅仅可以同时代表一个变量和一个函数,同时还可以兼任一个类型,它不仅仅是一门通常所说的 Lisp-2 的语言。
Emacs
的ledger-mode
来记账(参见以前的文章《程序员的记账工具——ledger与ledger-mode》)。作为一个出色的命令行报表工具,ledger
的命令balance
和register
足以涵盖大部分的使用场景:balance
可以生成所有帐号的余额的报表,用于每天与各个账户中的真实余额进行比较;register
可以生成给定帐号的交易明细,用于在余额不一致时与真实账户的流水一条条核对;美中不足的是,ledger
的报表不够直观,因为它们是冷冰冰的文字信息,而不是振奋人心的统计图形。好在,正如ledger
不存储数据,而只是一份份.ledger
文件中的交易记录的搬运工一样,gnuplot
也是这样的工具——它不存储数据,它只负责将存储在文本文件的数据以图形的形态呈现出来。
gnuplot
gnuplot
是很容易使用的。以最简单的情况为例,首先将如下内容保存到文件/tmp/data.csv
中
1 | -1 -1 |
然后在命令行中启动gnuplot
,进入它的 REPL 中,并执行如下命令
1 | plot "/tmp/data.csv" |
即可得到这三组数据的展示
三组数据分别是坐标为(-1, -1)
、(0, 0)
,以及(1, 1)
的点。
因此要让gnuplot
绘制开销的图形,首先就是从账本中提取出要绘制的数据,再决定如何用gnuplot
绘制即可。
ledger
提取开销记录尽管ledger
的子命令register
可以打印出给定帐号的交易明细,但此处更适合使用csv
子命令。例如,下列的命令可以将最早的10条、吃的方面的支出记录,都以 CSV 格式打印出来
1 | ➜ Accounting ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' |
--anon
选项可以将交易明细中的敏感信息(如收款方、帐号)等匿名处理。
尽管ledger
打印出的内容有很多列,但只有第一列的日期,以及第六列的金额是我所需要的。同时,由于一天中可能会有多次吃的方面的开销,因此同一天的交易也会有多笔,在绘图之前,需要将同一天之中的开销累加起来,只留下一个数字。这两个需求,都可以用csvsql
来满足。
csvsql
聚合数据以前文中的10条记录为例,用如下的命令可以将它们按天聚合在一起
1 | ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT `a`, SUM(`f`) FROM `expense` GROUP BY `a` ORDER BY `a` ASC' --tables 'expense' |
其中:
-H
让csvsql
知道从管道中输入的数据没有标题行。后续处理时,csvsql
会默认使用a
、b
、c
等作为列名;--query
用于提交要执行的 SQL 语句;--tables
用于指定表的名字,这样在--query
中才能用 SQL 对其进行处理;结果如下
1 | ➜ Accounting ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT `a`, SUM(`f`) FROM `expense` GROUP BY `a` ORDER BY `a` ASC' --tables 'expense' |
gnuplot
读取数据并绘图用重定向将csvsql
的输出结果保存到文件/tmp/data.csv
中,然后就可以用gnuplot
将它们画出来
1 | ➜ Accounting ledger --anon --head 10 -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT `a`, SUM(`f`) FROM `expense` GROUP BY `a` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/data.csv |
生成的图片文件/tmp/xyz.png
如下
在脚本文件/tmp/plot_expense.gplot
中用到的命令都可以通过gnuplot
的在线手册查阅到:
set format
命令用于设置坐标轴的刻度的格式。set format x "%y-%m-%d"
意味着设置 X 轴的刻度为形如19-09-10
的格式;set style data
命令设置数据的绘制风格。set style data box
表示采用空心柱状图;set terminal
命令用于告诉gnuplot
该生成什么样的输出。set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc'
表示输出结果为 PNG 格式的图片,并且采用给定的字体;set title
命令控制输出结果顶部中间位置的标题文案;set output
命令用于将原本输出到屏幕上的内容重定向到文件中;set timefmt
命令用于指定输入的日期时间数据的格式。set timefmt '%Y-%m-%d'
意味着输入的日期时间数据的为形如2019-09-10
的格式;set xdata
命令控制gnuplot
如何理解属于 X 轴的数据。set xdata time
表示 X 轴上的均为时间型数据;set xlabel
命令控制 X 轴的含义的文案。set ylabel
与其类似,只是作用在 Y 轴上;set xrange
命令控制gnuplot
所绘制的图形中 X 轴上的展示范围;set datafile separator
命令控制gnuplot
读取数据文件时各列间的分隔符,comma
表示分隔符为逗号。假设我要查看的是2021年每一周在吃的方面的总开支,那么需要在csvsql
中将数据按所处的是第几周进行聚合
1 | ➜ Accounting ledger -b '2021-01-01' -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT strftime("%W", `a`) AS `week`, SUM(`f`) FROM `expense` GROUP BY `week` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/expense_dow.csv |
同时也需要调整gnuplot
的脚本
1 | set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc' |
结果如下
gnuplot
支持同时绘制多条曲线,只要使用数据文件中不同的列作为纵坐标即可。假设我要对比的是2020年和2021年,那么先分别统计两年的开支到不同的文件中
1 | ➜ Accounting ledger -b '2020-01-01' -e '2021-01-01' -f 2021.ledger csv 'Expense:Food' | csvsql -H --query 'SELECT strftime("%W", `a`) AS `week`, SUM(`f`) FROM `expense` GROUP BY `week` ORDER BY `a` ASC' --tables 'expense' | tail -n '+2' > /tmp/expense_2020.csv |
再将处于同一周的数据合并在一起
1 | ➜ Accounting csvjoin -H -c a /tmp/expense_2020.csv /tmp/expense_2021.csv | tail -n '+2' > /tmp/expense_2years.csv |
最后,再让gnuplot
一次性绘制两条折线
1 | set terminal png font '/System/Library/Fonts/Hiragino Sans GB.ttc' |
结果如下
其实仍然是非常不直观的,因为最终生成的是一张静态的图片,并不能做到将鼠标挪到曲线上时就给出所在位置的纵坐标的效果。
]]>作为一个天天都在用的工具,各位同行想必都非常熟悉 Git 的基本用法,例如:
git-blame
找出某一行 bug 是哪一位同事引入的,由他背锅;git-merge
把别人的代码合进自己完美无瑕的分支中,然后发现单元测试无法跑通;git-push -f
把团队里其他人的提交通通覆盖掉。除此之外,Git 其实还是一个带版本功能的键值数据库:
.git/objects/
下;blob
对象、存储文件元数据的tree
对象,还有存储提交记录的commit
对象等;git-cat-file
和git-hash-object
。读过我以前的文章《当我们git merge的时候到底在merge什么》的朋友们应该都知道,如果一次合并不是fast-forward
的,那么会产生一个新的commit
类型的对象,并且它有两个父级commit
对象。以知名的 Go 语言 Web 框架gin
的仓库为例,它的哈希值为e38955615a14e567811e390c87afe705df957f3a
的提交是一次合并产生的,这个提交的内容中有两行parent
1 | ➜ gin git:(master) git cat-file -p 'e38955615a14e567811e390c87afe705df957f3a' |
通过一个提交的parent
属性,所有的提交对象组成了一个有向无环图。但聪明的你应该发现了,git-log
的输出结果是线性的,所以 Git 用到了某种图的遍历算法。
查阅man git-log
,可以在Commit Ordering
一节中看到
By default, the commits are shown in reverse chronological order.
聪明的你想必已经知道该如何实现这个图的遍历算法了。
git-log
commit
对象要想以正确的顺序打印commit
对象的信息,得先解析它。我们不需要从零开始自己打开文件、读取字节流,以及解压文件内容,只需要像上文那样调用git-cat-file
即可。git-cat-file
打印的内容中,有一些是需要提取备用的:
parent
开头的行。这一行的哈希值要用于定位到有向无环图中的一个节点;committer
开头的行。这一行的 UNIX 时间戳将会作为决定谁是“下一个节点”的排序依据。可以随手写一个 Python 中的类来解析一个commit
对象
1 | class CommitObject: |
commit
组成的有向无环图——大根堆恭喜你,你学过的数据结构可以派上用场了。
假设用上面的类CommitObject
解析了gin
中哈希值为e38955615a14e567811e390c87afe705df957f3a
的提交,那么它的parents
属性中会有两个字符串:
ad087650e9881c93a19fd8db75a86968aa998cac
;ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c
。其中:
ad087650e9881c93a19fd8db75a86968aa998cac
的提交的时间为Sat Jul 8 12:31:44
;ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c
的提交时间为Jan 28 02:32:44
。显然,按照反转的时间先后顺序(reverse chronological
)打印日志的话,下一个打印的节点应当是是ad087650e9881c93a19fd8db75a86968aa998cac
——用git-log
命令可以确认这一点。
打印完ad087650e9881c93a19fd8db75a86968aa998cac
之后,又要从它的父级提交和ce26751a5a3ed13e9a6aa010d9a7fa767de91b8c
中,挑选出下一个要打印的提交对象。显然,这是一个循环往复的过程:
commit
对象中,找出提交时间戳最大的一个;commit
的所有父级提交加入到待打印的对象池中,回到第1个步骤;这个过程一直持续到没有待打印的commit
对象为止,而所有待打印的commit
对象组成了一个优先级队列——可以用一个大根堆来实现。
然而,我并不打算在这短短的演示当中真的去实现一个堆数据结构——我用插入排序来代替它。
1 | class MyGitLogPrinter(): |
最后再提供一个启动函数就可以体验一番了
1 |
|
为了看看上面的代码所打印出来的commit
对象的顺序是否正确,我先将它的输出内容重定向到一个文件中
1 | ➜ gin git:(master) python3 ~/SourceCode/python/my_git_log/my_git_log.py --commit-id 'e38955615a14e567811e390c87afe705df957f3a' -n 20 > /tmp/my_git_log.txt |
再用git-log
以同样的格式打印出来
1 | ➜ gin git:(master) git log --pretty='format:%H %ct' 'e38955615a14e567811e390c87afe705df957f3a' -n 20 > /tmp/git_log.txt |
最后让diff
命令告诉我们这两个文件是否有差异
1 | ➜ gin git:(master) diff /tmp/git_log.txt /tmp/my_git_log.txt |
可以说是一模一样了。
]]>众所周知,Python 支持向函数传递关键字参数。比如 Python 的内置函数max
就接受名为key
的关键字参数,以决定如何获取比较两个参数时的依据
1 | max({'v': 1}, {'v': 3}, {'v': 2}, key=lambda o: o['v']) # 返回值为{'v': 3} |
自定义一个运用了关键字参数特性的函数当然也不在话下。例如模仿一下 Common Lisp 中的函数string-equal
1 | def string_equal(string1, string2, *, start1=None, end1=None, start2=None, end2=None): |
再以关键字参数的形式向它传参
1 | string_equal("Hello, world!", "ello", start1=1, end1=4) # 返回值为True |
秉承 Python 之禅中的 我甚至可以花里胡哨地、用关键字参数的语法向There should be one-- and preferably only one --obvious way to do it.
理念,string1
和string2
传参
1 | string_equal(string1='Goodbye, world!', string2='ello') # 返回值为False |
但瑜不掩瑕,Python 的关键字参数也有其不足。
Python 的关键字参数特性的缺点在于,同一个参数无法同时以:
**kwargs
中取得,两种形态存在于参数列表中。
举个例子,我们都知道 Python 有一个知名的第三方库叫做 requests,提供了用于开发爬虫牢底坐穿的发起 HTTP 请求的功能。它的类requests.Session
的实例方法request
有着让人忍不住运用 Long Parameter List 对其重构的、长达 16 个参数的参数列表。(你可以移步request
方法的文档观摩)
为了便于使用,requests 的作者贴心地提供了requests.request
,这样只需要一次简单的函数调用即可
1 | requests.request('GET', 'http://example.com') |
requests.request
函数支持与requests.Session#request
(请允许我借用 Ruby 对于实例方法的写法)相同的参数列表,这一切都是通过在参数列表中声明**kwargs
变量,并在函数体中用相同的语法向后者传参来实现的。(你可以移步request 函数的源代码观摩)
这样的缺陷在于,requests.request
函数的参数列表丢失了大量的信息。要想知道使用者能往kwargs
中传入什么参数,必须:
requests.request
是如何往requests.Session#request
中传参的——将kwargs
完全展开传入是最简单的情况;requests.Session#request
的参数列表中排除掉method
和url
的部分剩下哪些参数。如果想在requests.request
的参数列表中使用参数自身的名字(例如params
、data
、json
等),那么调用requests.Session#request
则变得繁琐起来,不得不写成
1 | with sessions.Session() as session: |
的形式——果然人类的本质是复读机。
一个优雅的解决方案,可以参考隔壁的 Common Lisp。
Common Lisp 第一次面世是在1984年,比 Python 的1991年要足足早了7年。但据悉,Python 的关键字参数特性借鉴自 Modula-3,而不是万物起源的 Lisp。Common Lisp 中的关键字参数特性与 Python 有诸多不同。例如,根据 Python 官方手册中的说法,**kwargs
中只有多出来的关键字参数
If the form “**identifier” is present, it is initialized to a new ordered mapping receiving any excess keyword arguments
而在 Common Lisp 中,与**kwargs
对应的是&rest args
,它必须放置在关键字参数之前(即左边),并且根据 CLHS 中《A specifier for a rest parameter》的说法,args
中含有所有未经处理的参数——也包含了位于其后的关键字参数
1 | (defun foobar (&rest args &key k1 k2) |
如果我还有另一个函数与foobar
有着相似的参数列表,那么也可以轻松将所有参数传递给它
1 | (defun foobaz (a &rest args &key k1 k2) |
甚至于,即使在foobaz
中支持的关键字参数比foobar
要多,也能轻松地处理,因为 Common Lisp 支持向被调用的函数传入一个特殊的关键字参数:allow-other-keys
即可
1 | (defun foobaz (a &rest args &key k1 k2 my-key) |
回到 HTTP 客户端的例子。在 Common Lisp 中我一般用drakma这个第三方库来发起 HTTP 请求,它导出了一个http-request
函数,用法与requests.request
差不多
1 | (drakma:http-request "http://example.com" :method :get) |
如果我想要基于它来封装一个便捷地发出 GET 请求的函数http-get
的话,可以这样写
1 | (defun http-get (uri &rest args) |
如果我希望在http-get
的参数列表中直接暴露出一部分http-request
支持的关键字参数的话,可以这样写
1 | (defun http-get (uri &rest args &key content) |
更进一步,如果我想在http-get
中支持解析Content-Type
为application/json
的响应结果的话,还可以这样写
1 | (ql:quickload 'jonathan) |
不愧是Dio Common Lisp,轻易就做到了我们做不到的事情。
曾几何时,Python 程序员还会津津乐道于 Python 之禅中的There should be one-- and preferably only one --obvious way to do it.
,但其实 Python 光是在定义一个函数的参数方面就有五花八门的写法了。甚至在写这篇文章的过程中,我才知道原来 Python 的参数列表中可以通过写上/
来使其左侧的参数都成为 positional-only 的参数。
1 | def foo1(a, b): pass |
或许是为了显摆,也或许是虚心学习,总之我在去年年初花了大约两个月读完了《架构整洁之道》。但读过后也仅仅就是读了而已,尽管书中描绘了一个名为整洁架构的软件架构,但我并没有理解并应用到实际的开发中去。书中的诸多理念最终都蛰伏在了我的脑海深处。
今年年初的时候我换了工作。新的单位给每人都配备了办公用的电脑,从此我也不用背着2公斤重的MacBook Pro通勤了。美中不足的地方是,我和cuckoo之间的联系被斩断了,因为cuckoo
是个单机程序,要在私人电脑和办公电脑上各装一份太不方便了。于是乎,我决定开两个新的项目,将cuckoo
拆分为客户端和服务端两部分。
正好,这给了我在实际的项目中践行整洁架构的机会。
不像数学领域的概念往往有一个精确的定义,书中甚至没有道出整洁架构是什么。相对的,只有一副引人入胜的架构示意图(图片摘自作者博客的这篇文章)
在作者的文章中,对图中的四个层次给出了响应的解释:
Frameworks & Drivers
,顾名思义,这一层包含了与框架相关的代码,或者像C语言中的main
函数这样的入口函数代码;前文提到,为了满足新需求,我需要将cuckoo改造为C/S模型。但比起缓缓地将cuckoo拆解为两部分,我更乐于大刀阔斧地从头开发开发这两个程序,于是便诞生了:
它们都是我依照自己对整洁架构的理解来编写的。
正如REST仅仅是一种软件结构风格而不是具体的设计指南一样,整洁架构也并没有规定示意图中的分层结构该如何运用一门语言的特性来实现,这需要开发者自己去摸索。下文我给出自己在nest
和fledgling
项目中的做法。
在程序的代码结构中,最接近于架构示意图的分层架构的,当属代码仓库的目录结构了。模仿整洁架构中的四层结构,我在nest
中也安排了相似的目录结构
1 | (venv) ➜ nest git:(master) tree -I '__pycache__' -d ./nest |
nest/app/entity/
目录nest/app/entity/
目录下的各个文件分别定义了系统中的各个实体类型
1 | (venv) ➜ nest git:(master) ls nest/app/entity |
例如:
task.py
中定义了类Task
,表示一个任务;plan.py
中定义了类Plan
,表示任务的一次触发计划,等等。entity/
目录下的各个文件中,还定义了管理各种实体对象生命期的仓库对象,例如:
task.py
中定义了类ITaskRepository
,它负责增(add
方法)删(clear
、remove
方法)查(find
、find_by_id
方法)改(同样是add
方法)任务对象;plan.py
中定义了类IPlanRepository
,同样能够增(add
方法)删(clear
、remove
方法)查(find_as_queue
、find_by_id
、find_by_task_id
方法)改(同样是add
方法)计划对象,等等。实体类型都是充血模型,它们实现了系统核心的业务规则,例如:
Plan
有方法is_repeated
用于检查是否为重复性任务;is_visible
用于检查该计划在当前时间是否可见;rebirth
用于生成一个新的、下一次触发的计划,等等。这个目录下的内容相当于整洁架构中的Entities
层。
nest/app/use_case/
目录nest/app/use_case/
目录下的各个文件分别定义了系统所提供的功能
1 | (venv) ➜ nest git:(master) ls nest/app/use_case |
例如:
authenticate.py
定义了系统如何认证发送当前请求的用户;change_task.py
定义了系统如何修改一个任务对象,等等。每一个处于该目录下的文件,只会依赖nest/app/entity/
中的代码,并且它们都是抽象的。例如,authenticate.py
中的类AuthenticateUseCase
的构造方法中,要求其:
certificate_repository
必须是类ICertificateRepository
或其子类的实例;params
必须是类IParams
或其子类的实例。然而ICertificateRepository
和IParams
其实都是抽象基类ABC
的子类,并且它们都有被装饰器abstractmethod
装饰的抽象方法,因此并不能直接实例化。
该目录相当于整洁架构中的Use Cases
层。
顾名思义,cli
和web
目录分别是与命令行程序、基于HTTP的API相关的代码,它们实现了处理来自命令行和HTTP协议的输入,以及打印到终端和返回HTTP响应的功能。repository
目录下的各个文件实现了entity
目录中各个抽象的仓库类的具体子类
1 | (venv) ➜ nest git:(master) ls nest/repository |
例如:
certificate.py
中实现了entity/
目录下的同名文件中的抽象类ICertificateRepository
——一个基于内存的子类MemoryCertificateRepository
,以及一个基于Redis的子类RedisCertificateRepository
;location.py
中实现了entity/
目录下的同名文件中的抽象类ILocationRepository
——基于MySQL的子类DatabaseLocationRepository
,等等。需要注意的是,除了app
外的这些目录,并不能与整洁架构示意图中的外面两层严格对应起来。例如,尽管cli
和web
的名字一下子就让人认为它们处于Frameworks & Drivers
层,但web/presenter/
目录下的内容其实与框架并无联系。反倒是从命名上看处于Interface Adapters
层的web/controller/
目录,其中的代码依赖于Flask
框架。
Use Cases
层传入数据在鲍勃大叔的文章中,提到了关于如何在层之间传递数据的原则
Typically the data that crosses the boundaries is simple data structures. You can use basic structs or simple Data Transfer objects if you like. Or the data can simply be arguments in function calls. Or you can pack it into a hashmap, or construct it into an object.
在nest/app/use_case/
目录下的所有用例采用的都是这里提到的construct it into an object
的方式。以create_task.py
为例:
1 | class IParams(ABC): |
abc
中的抽象基类ABC
、装饰器abstractmethod
,以及类CreateTaskUseCase
中的assert
一起模拟类似Java中的interface
的效果;get_brief
获取任务的简述;get_keywords
获取关键字列表;get_user_id
获取创建该任务的用户的ID。聪明的盲生已经发现了华点:明明只需要在类CreateTaskUseCase
的构造方法中定义brief
、keywords
,以及user_id
三个参数即可,为什么要用方法这么麻烦呢?答案是因为方法更灵活。
当你采用构造方法参数的方案时,本质上是立了一个假设:
如果是一个基于HTTP协议的API,那么这个假设是成立的——用户在客户端发送的HTTP请求到达服务端后,便无法再补充参数了。但有一种场景,用户能够在用例执行业务逻辑的过程中,持续地与应用交互,那便是命令行程序。
我在fledgling
项目中给了一个用户在用例执行过程中,交互式地输入的例子。在文件fledgling/app/use_case/delete_task.py
中,实现了删除指定任务的用例。它要求输入两个参数
1 | class IParams(ABC): |
在文件fledgling/cli/command/delete_task.py
中实现了IParams
类的命令行形态。当没有从命令行参数中获取到任务的ID时,便会使用第三方库PyInquirer
询问用户输入任务ID,并进一步确认
1 | class Params(IParams): |
而这一切煮不在乎DeleteTaskUseCase
并不会感知到,它独立于用户界面。
在《架构整洁之道》第20章中,鲍勃大叔给出了业务规则的定义
Strictly speaking, business rules are rules or procedures that make or save
the business money. Very strictly speaking, these rules would make or save the business money, irrespective of whether they were implemented on a computer. They would make or save money even if they were executed manually.
业务规则往往不是独立存在的,它们需要作用在一些数据上
Critical Business Rules usually require some data to work with. For example, our loan requires a loan balance, an interest rate, and a payment schedule.
而整洁架构中的实体就是包含了一部分业务规则及其操作的数据的对象。以nest
中的计划实体为例,在类Plan
中包含了几种业务规则——尽管这些规则不能为我赚钱或者省钱:
duration
的setter保障;new
方法维护;is_repeated
方法维护;weekday
——由is_visible
方法维护。但在整洁架构的示意图中,Use Cases
层也是有维护规则的,它维护的是应用的业务规则(Application Business Rules
)。与Entities
层所维护的业务规则不同,Use Cases
层的业务规则取决于应用提供的功能。例如,在nest
项目修改一个计划的用例ChangePlanUseCase
类的方法run
中,会:
Plan
对象不会去检查Location
存在与否;1 | # 文件nest/app/use_case/change_plan.py |
聪明的你一定发现了:is_changeable
为什么不作为Enterpries Business Rules
,在Plan
对象内自行检查呢?答案是因为这样写更简单。
试想一下,如果要让Plan
自己禁止在is_changeable
为False
时被修改,那么必须:
is_changeable
进行检查。之所以要这么做,是因为一个实体对象(在这里是指Plan
的实例对象)是外部的时间流动是无感知的。它不知道外层(此处是Use Cases
层)会先调用哪一个方法,后调用哪一个方法。因此,要想保持“终止状态的计划不能修改”,就必须在每一处setter都检查。
与之相反,在用例中有编排,因此它可以感知时间的流动。用例可以让Plan
的is_changeable
方法在其它任何方法之前被调用,因此免除了繁琐地在每一个setter中检查is_changeable
的必要。
Use Cases
层的处理结果正如往Use Cases
层中输入参数可以采用:
__init__
中传入对应类型的参数,或;__init__
中传入一个能根据方法提取参数的对象。两种方案一样,获取Use Cases
层的计算结果同样有两种方案:
run
方法的返回值,捕捉它的异常,或;__init__
中传入一个能够接受不同结果并处理的对象。在nest
这样的仅仅提供HTTP API的应用中,第1种方案便已经足够了。例如,在文件nest/web/controller/create_plan.py
中,类CreatePlanUseCase
的run
方法的返回值为创建的计划对象,如果run
调用成功,这个controller会借助于PlanPresenter
,将计划对象转换为JSON对象格式的字符串,返回给调用方;如果调用失败,那么controller中也会捕捉异常(如InvalidRepeatTypeError
)并以另一种格式返回给调用方。
1 | def create_plan(certificate_repository, repository_factory): |
如果想要更高的灵活性并且也有施展的空间,那么可以考虑第2种方案。例如fledgling
项目中文件fledgling/app/use_case/list_plan.py
中,就定义了一个接口IPresenter
1 | class IPresenter(ABC): |
并且在用例的执行过程中,会多次向self.presenter
传递数据
1 | class ListPlanUseCase: |
在构造方法中注入presenter
的缺点在于用例的run
方法中需要显式地return
,否则用例会继续执行下去。
abstractmethod
v.s.NotImplementedError
整洁架构的每一层都只会依赖于内层,而内层又对外层一无所知,负责解耦两者的便是编程语言的接口特性。但Python并不像Java那般有interface
关键字,因此我利用它的其它一系列特性来模拟出接口:
class
代替interface
,这些类继承自内置模块abc
的抽象基类ABC
;abstractmethod
装饰,使它们必须由该类的子类全部定义;Use Cases
层)用断言assert
约束输入参数的类型。nest
中的大部分需要接口的位置我都是用这种手法来做的,但这种方式会给编写单元测试用例带来一些不便:
assert
来检查参数类型,导致传入的参数只能是这个接口或其子类的实例;ABC
,所以必须定义所有被abstractmethod
装饰的方法,否则在实例化时就会抛出异常。例如,在nest
项目的文件tests/use_case/task/test_list.py
中,作为白盒测试的人员,我确切地知道类ListTaskUseCase
的run
方法只会调用它的task_repository
的find
方法,但在类MockTaskRepository
中依然不得不定义基类的每一个方法——尽管它们只有一行pass
语句。
如果愿意放弃一点点的严谨性,那么可以弱化一下上面的接口方案:
abstractmethod
,而是在本应为抽象方法的方法中只留下一句raise NotImplementedError
;assert
检查类型,而是在参数中写上type hint。有了第1点,那么在测试用例中就不需要为测试路径上不会调用的方法写多余的定义了。而有了第2点,也就不需要为测试路径上不会引用的属性创建对象了,大可直接传入一个None
。选择哪一种都无妨,取决于开发者或团队的口味。
在《架构整洁之道》的第20章,作者给出了整洁架构的五种优秀特性:
nest
从Flask迁移到Bottle上,尽管并不会无缘无故或频繁地这么做;nest
项目的目录tests/use_case
下的测试用例不需要有任何外部系统的依赖就可以编写并运行;nest
项目中同一个用例RegistrationUseCase
就有HTTP API和命令行两种用户界面:nest/web/controller/registration.py
中是HTTP API形态;nest/cli/command/register.py
中则是命令行形态。Entities
和Use Cases
层的代码而言别无二致;fledgling
项目中,尽管也定义了一个接口ITaskRepository
,但不同于nest
中基于数据库的实现子类DatabaseTaskRepository
,在fledgling
中实现的是基于网络传输的类TaskRepository
。但究竟是基于单机数据库,还是身处一个分布式系统(C/S模型)中,Entities
和Use Cases
层对此是无感知的。Also unlike C, expressions like a < b < c have the interpretation that is conventional in mathematics
也就是说,在C语言中要写成a < b && b < c
的表达式,在Python中可以写成a < b < c
。并且,标准中还提到
Comparisons can be chained arbitrarily, e.g., x < y <= z is equivalent to x < y and y <= z, except that y is evaluated only once (but in both cases z is not evaluated at all when x < y is found to be false).
一般将这种性质成为短路。因此,像2 < 1 < (1 / 0)
这样的表达式在Python中不会引发异常,而是返回False
。
Python的小于号能拥有短路特性,是因为它并非一个普通函数,而是有语言层面加持的操作符。而在Common Lisp(下称CL)中,小于号仅仅是一个普通函数,就像Haskell中的小于号也是一个函数一般。不同的是,CL的小于号能接受多于两个的参数
1 | (< 1 2 3 -1) ; 结果为NIL |
但它并没有短路特性
1 | (< 1 2 3 -1 (/ 1 0)) ; 引发名为DIVISION-BY-ZERO的错误 |
要想模拟出具有短路特性的小于号,必须借助于宏的力量。
要想写出一个宏,必须先设想出它的语法,以及它会展开成什么样的代码。姑且为这个宏起名为less-than
,它的语法应当为
1 | (defmacro less-than (form &rest more-forms) |
至于它的展开结果可以有多种选择。例如,可以(less-than 2 1 (/ 1 0))
展开为自身具有短路特性的and
形式
1 | (and (< 2 1) (< 1 (/ 1 0))) |
但就像在C语言中用宏朴素地实现计算二者最大值的MAX
宏一样,上面的展开方式在一些情况下会招致重复求值
1 | (less-than 1 (progn (print 'hello) 2) 3) |
因此,起码要展开为and
和let
的搭配
1 | (let ((g917 1) |
要想展开为这种结构,可以如这般实现less-than
1 | (defmacro less-than (form &rest more-forms) |
用上面的输入验证一下是否会导致重复求值
1 | CL-USER> (macroexpand-1 '(less-than 1 (progn (print 'hello) 2) 3)) |
显然less-than
可以优化,只需要简单地运用递归的技巧即可
1 | (defmacro less-than (form &rest more-forms) |
展开后的代码简短得多
1 | CL-USER> (macroexpand-1 '(less-than 1 (progn (print 'hello) 2) 3)) |
“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。
Emacs的org-mode用的是一门叫Org的标记语言,正如大部分的标记语言那样,它也支持无序列表和检查清单——前者以-
(一个连字符、一个空格)为前缀,后者以- [ ]
或- [x]
为前缀(比无序列表多了一对方括号及中间的字母x
)
此外,org-mode还为编辑这两种列表提供了快速插入新一行的快捷键M-RET
(即按住alt
键并按下回车键)。如果光标位于无序列表中,那么新的一行将会自动插入-
前缀。遗憾的是,如果光标位于检查清单中,那么新一行并没有自动插入一对方括号
每次都要手动敲入[ ]
还挺繁琐的。好在这是Emacs,它是可扩展的、可定制的。只需敲几行代码,就可以让Emacs代劳输入方括号了。
advice-add
借助Emacs的describe-key
功能,可以知道在一个org-mode
的文件中按下M-RET
时,Emacs会调用到函数org-insert-item
上。要想让M-RET
实现自动追加方括号的效果,马上可以想到简单粗暴的办法:
M-RET
绑定到它身上;org-insert-item
函数,使其追加方括号;但不管是上述的哪一种,都需要连带着重新实现插入连字符、空格前缀的已有功能。有一种更温和的办法可以在现有的org-insert-item
的基础上扩展它的行为,那就是Emacs的advice
特性。
advice
是面向切面编程范式的一种,使用Emacs的advice-add
函数,可以在一个普通的函数被调用前或被调用后捎带做一些事情——比如追加一对方括号。对于这两个时机,分别可以直接用advice-add
的:before
和:after
来实现,但用在这里都不合适,因为:
org-insert-item
前做;org-insert-item
之后做。因此,正确的做法是使用:around
来修饰原始的org-insert-item
函数
1 | (cl-defun lt-around-org-insert-item (oldfunction &rest args) |
这下子,M-RET
对检查清单也一视同仁了
method combination
advice-add
的:after
、:around
,以及:before
在Common Lisp中有着完全同名的等价物,只不过不是用一个叫advice-add
的函数,而是喂给一个叫defmethod
的宏。举个例子,用defmethod
可以定义出一个多态的len
函数,对不同类型的入参执行不同的逻辑
1 | (defgeneric len (x)) |
然后为其中参数类型为字符串的特化版本定义对应的:after
、:around
,以及:before
修饰过的方法
1 | (defmethod len :after ((x string)) |
这一系列方法的调用规则为:
:around
修饰的方法;call-next-method
,因此再调用:before
修饰的方法;primary
方法);:after
修饰的方法;:around
中调用call-next-method
的位置。咋看之下,Emacs的advice-add
支持的修饰符要多得多,实则不然。在CL中,:after
、:around
,以及:before
同属于一个名为standard
的method combination
,而CL还内置了其它的method combination
。在《Other method combinations》一节中,作者演示了progn
和list
的例子。
如果想要模拟Emacs的advice-add
所支持的其它修饰符,那么就必须定义新的method combination
了。
define-method-combination
曾经我以为,defmethod
只能接受:after
、:around
,以及:before
,认为这三个修饰符是必须在语言一级支持的特性。直到有一天我闯入了LispWorks的define-method-combination词条中,才发现它们也是三个平凡的修饰符而已。
1 | (define-method-combination standard () |
秉持“柿子要挑软的捏”的原则,让我来尝试模拟出advice-add
的:after-while
和:before-while
的效果吧。
:after-while
和:before-while
的效果还是很容易理解的
Call function after the old function and only if the old function returned non-
nil
.Call function before the old function and don’t call the old function if function returns
nil
.
因此,由define-method-combination
生成的form
中(犹记得伞哥在《PCL》中将它翻译为形式),势必要:
:before-while
修饰的方法;:before-while
修饰的方法后的返回值是否为NIL
;:before-while
修饰的方法的返回值为非NIL
,便调用primary
方法;:after-while
修饰的方法,并且primary
方法的返回值不为NIL
,就调用这些方法;primary
方法的返回值。为了简单起见,尽管after-while
和before-while
变量指向的是多个“可调用”的方法,但这里只调用“最具体”的一个。
给这个新的method combination
取名为emacs-advice
,其具体实现已是水到渠成
1 | (define-method-combination emacs-advice () |
call-method
(以及它的搭档make-method
)是专门用于在define-method-combination
中调用传入的方法的宏。
用一系列foobar
方法来验证一番
1 | (defgeneric foobar (x) |
尽管我对CL赏识有加,但越是琢磨define-method-combination
,就越会发现编程语言的能力是有极限的,除非超越编程语言。比如Emacs的advice-add
所支持的:filter-args
和:filter-return
就无法用define-method-combination
优雅地实现出来——并不是完全不行,只不过需要将它们合并在由:around
修饰的方法之中。
BinaryTree
定义一棵二叉树1 | class BinaryTree: |
实现一个前序遍历的算法便是信手拈来的事情
1 | def preorder_traversal(tree, func): |
随着行业曲率的增大,要求写出不使用递归的版本也没什么过分的
1 | def iterative_preorder_traversal(tree, func): |
一直以来,我觉得这种用一个显式的栈来代替递归过程中隐式的栈的做法就是镜花水月。但最近却找到了它的一个用武之地——用于实现iterator
。
iterator
是个啥?这年头,iterator
已经不是什么新鲜事物了,许多语言中都有支持,维基百科上有一份清单列出了比较知名的语言的iterator
特性。按照Python官方的术语表中的定义,iterator
表示一个数据流,反复调用其__next__
方法可以一个接一个地返回流中的下一项数据。将内置函数iter
作用于list
、str
、tuple
类型的对象,可以获得相应的迭代器
1 | cat get_iter.py |
iterator
一个iterator
对象必须要实现__iter__
和__next__
方法:
__iter__
只需要返回iterator
对象自身即可;__next__
负责返回下一个元素。仔细观察一下前文中的iterative_preorder_traversal
函数可以看出:
nodes = [tree]
属于初始化逻辑;len(nodes) > 0
用于判断是应当抛出StopIteration
,还是应当继续返回下一个值(nodes.pop()
);nodes
,好让它可以在下一次调用__next__
的时候有值可以返回的。到这里,iterator
的具体实现代码已经呼之欲出了
1 | class BinaryTreePreorderIterator: |
构造一棵这样的满二叉树
用BinaryTreePreorderIterator
可以正确地打印出每一个节点的值
1 | if __name__ == '__main__': |
iterator
的优势显然,iterator
比起preorder_traversal
更为灵活——很容易在for-in
循环内添加各种各样的控制逻辑:用continue
跳过一些值,或者用break
提前结束遍历过程。这些在函数preorder_traversal
中做起来会比较别扭。
聪明的你应该已经发现了,大可不必将preorder_traversal
拆解到一个构造方法和一个__next__
方法中。用generator
写起来明明更加直观
1 | def preorder_generator(tree): |
但是,很多语言并不支持generator
。与之相比,iterator
要亲民得多,更容易移植。例如,即使是Common Lisp这种一穷二白的语言,也可以实现和Python的iterator
以及for
类似的效果
1 | (in-package #:cl-user) |
中序遍历和后序遍历也可以写成迭代器,证明略。
]]>随着行业曲率的增大,光是知道有这些数据类型已经不够了,还得知道同一个类型也有不同的底层数据结构。例如同样是string
类型,不同内容或不同长度会采用不同的编码方式:
1 | 127.0.0.1:6379> SET key1 "1" |
而hash
类型也有两种底层实现
1 | 127.0.0.1:6379> HSET myhash field1 "Hello" |
不知道你是否曾经好奇过,上文中的key1
、key2
、key3
、myhash
,以及myhash2
这些键,与它们各自的值(前三个为string
,后两个为hash
)之间的关系又是存储在什么数据结构中的呢?
答案在意料之外,情理之中:键与值的关系,也是存储在一张哈希表中的,并且正是上文中的hashtable
。
求证的办法当然是阅读Redis的源代码。
阅读Redis的源码是比较轻松愉快的,一是因为其源码由简单易懂的C语言编写,二是因为源码仓库的README.md
中对内部实现做了一番高屋建瓴的介绍。在README.md
的server.c一节中,道出了有关命令派发的两个关键点
call()
is used in order to call a given command in the context of a given client.
The global variable
redisCommandTable
defines all the Redis commands, specifying the name of the command, the function implementing the command, the number of arguments required, and other properties of each command.
位于文件src/server.c
中的变量redisCommandTable
定义了所有可以在Redis中使用的命令——为什么一个C语言项目里要用camelCase
这种格格不入的命名风格呢——它的元素的类型为struct redisCommand
,其中:
name
存放命令的名字;proc
存放实现命令的C函数的指针;比如高频使用的GET
命令在redisCommandTable
中就是这样定义的
1 | {"get",getCommand,2, |
身为一名老解释器爱好者,对这种套路的代码当然是不会陌生的。我也曾在写过的、跑不起来的玩具解释器上用过类似的手法
Redis收到一道需要执行的命令后,根据命令的名字用lookupCommand
找到一个命令(是个struct redisCommand
类型的结构体),然后call
函数做的事情就是调用它的proc
成员所指向的函数而已
1 | c->cmd->proc(c); |
那么接下来,就要看看SET
命令对应的C函数究竟做了些什么了。
SET
命令的实现redisCommonTable
中下标为2的元素正是SET
命令的定义
1 | /* Note that we can't flag set as fast, since it may perform an |
其中函数setCommand
定义在文件t_string.c
中,它根据参数中是否有传入NX
、XX
、EX
等选项计算出一个flags
后,便调用setGenericCommand
——顾名思义,这是一个通用的SET
命令,它同时被SET
、SETNX
、SETEX
,以及PSETEX
四个Redis命令的实现函数所共用。
setGenericCommand
调用了genericSetKey
,后者定义在文件db.c
中。尽管该函数上方的注释写着
All the new keys in the database should be created via this interface.
但人生不如意事十之八九事实并非如此。例如在命令RPUSH
的实现函数rpushCommand
中,调用了pushGenericCommand
,后者直接调用了dbAdd
往Redis中存入键和列表对象的关系。
言归正传。根据键存在与否,genericSetKey
会调用dbAdd
或dbOverwrite
。而在dbAdd
中,最终调用了dictAdd
将键与值存入数据库中。
1 | /* Add an element to the target hash table */ |
现在我们知道了,使用SET
命令时传入的key
和value
,是存储在一个dict
类型的数据结构中。
HSET
命令的实现依葫芦画瓢,Redis的HSET
命令由位于文件t_hash.c
中的函数hsetCommand
实现,它会尝试转换要操作的hash
值的编码方式。
1 | hashTypeTryConversion(o,c->argv,2,c->argc-1); |
如果hashTypeTryConversion
发现要写入哈希表的任何一个键或者值的长度超过了server.hash_max_ziplist_value
所规定的值,就会将hash
类型的编码从ziplist
转换为hashtable
。server.hash_max_ziplist_value
的值在文件config.c
中通过宏设置,默认值为64——这正是上文中myhash2
所对应的值的编码为hashtable
的原因。
将思绪拉回到函数hsetCommand
中。做完编码的转换后,它调用函数hashTypeSet
,在编码为hashtable
的世界线中,同样调用了dictAdd
实现往哈希表中写入键值对。
殊途同归
因此,在Redis中用以维持每一个键与其对应的值——这些值也许是string
,也许是list
,也许是hash
——的关系的数据结构,与Redis中的一系列操作哈希表的命令——也许是HSET
、也许HGET
,也许是HDEL
——所用的数据结构,不能说是毫不相关,起码是一模一样。
isdigit
函数就只会返回是(1)或否(0)1 |
|
但有时候如果一个函数、方法,或宏可以返回多个值的话会更加方便。例如,在Python中dict
类型有一个实例方法get
,它可以取得dict
实例中与给定的键对应的值。但如果有一个键在字典中的值为None
,那么光凭get
的返回值无法准确判断这个键是否存在——除非你给它一个非None
的默认值
1 | # -*- coding: utf8 -*- |
发展了这么多年的编程语言,又怎么会连一次调用、多值返回这么简单的事情都做不到呢。事实上,有各种各样、各显神通的返回多个值的方法,我给其中的一些做了个分类
multiple-value-bind
Common Lisp(简称为CL)的多重返回值当之无愧是其中最正统、最好用的实现方式。以它的内置函数truncate
为例,它的第一个返回值为第一个参数除以第二个参数的商,第二个返回值为对应的余数
1 | CL-USER> (truncate 10 3) |
如果不加修饰地调用truncate
,就像其它只返回一个值的函数一样,也只会拿到一个返回值
1 | CL-USER> (let ((q (truncate 10 3))) |
除非用multiple-value-bind
来捕获一个函数产生的所有返回值
1 | CL-USER> (multiple-value-bind (q r) |
CL的方案的优点在于它十分灵活。即使将一个函数从返回单个值改为返回多个值,也不会导致原本调用该函数的位置要全部修改一遍——对修改封闭,对扩展开放(误)。
踩在C语言肩膀上的Go也能够从函数中返回多个值。在io/ioutil
包的官方文档中有大量的例子,比如用ReadAll
方法从字符串衍生的流中读取全部内容,就会返回两个值
1 | package main |
Go以这种方式取代了C语言中用返回值表达成功与否、再通过指针传出读到的数据的风格。由于这个模式在有用的Go程序中到处出现,因此Gopher们用的都是定制的键盘(误)
不同于前文的multiple-value-bind
,如果一个函数或方法返回多个值,那么调用者必须捕获每一个值,否则编译无法通过
1 | ➜ try cat try_read_all_ignore_err.go |
这一要求也是合理的,毕竟多重返回值机制主要用于向调用者传递出错原因——既然可能出错,那么就必须要检查一番。
就像CL的truncate
函数一样,Python中的函数divmod
也可以同时返回两个数相除的商和余数,并且咋看之下也是返回多个值的形式
1 | # -*- coding: utf8 -*- |
但本质上,这是因为Python支持解构,同时divmod
返回的是一个由商和余数组成的元组。这样的做法与CL的真·奥义·多重返回值的差异在于,如果只想要divmod
的第一个值,那么等号左侧也要写成对应的结构
1 | # -*- coding: utf8 -*- |
在支持解构的语言中都可以模仿出多重返回值,例如Rust
1 | fn divmod(a: u32, b: u32) -> (u32, u32) { |
到了Prolog这里,画风就有点不一样了。首先Prolog既没有函数,也没有方法,更没有宏。在Prolog中,像length/2
和member/2
这样的东西叫做functor
,它们之于Prolog中的列表,就犹如CL的length
和member
之于列表、Python的len
函数和in
操作符之于列表,JavaScript的length
属性和indexOf
方法之于数组……
其次,Prolog并不“返回”一个functor
的“调用结果”,它只是判断输入的查询是否成立,以及给出使查询成立的变量值。在第一个查询中,length/2
的第二个参数为变量L
,因此Prolog给出了使这个查询成立的L
的值4;第二个查询中没有变量,Prolog只是简单地给出查询是否成立;第三个查询中,Prolog给出了四个能够使查询成立的变量X
的值。
由于Prolog会给出查询中每一个变量的值,可以用这个特性来模拟多重返回值。例如,可以让Prolog一次性给出两个数字的和、差、积,和商
麻烦之处在于就算只想要得到两数之和,也必须用占位符填在后三个参数上:jjcc(10, 3, S, _, _, _)
。
尽管在开篇的时候提到了C语言中的函数无法返回多个值,但如果像上文的Prolog那般允许修改参数的话,C语言也是可以做到的,谁让它有指针这个强力特性呢。例如,stat(2)
函数就会将关于一个文件的信息填充到参数中所指向的结构体的内存中
1 |
|
查看man 2 stat
可以知道struct stat
类型中有非常多的内容,这显然也是一种多重返回值。同样的手法,在Go中也可以运用,例如用于把从数据库中读取出来的行的数据写入目标数据结构的Scan
方法。
最后,如果只要能让调用者感知就行,那么全局变量未尝不是一种通用的多重返回值机制。例如在C语言中的strtol
函数,就会在无法转换出任何数字的时候返回0并设置errno
,因此检查errno
是必须的步骤
1 |
|
鉴于errno
是一个全局变量,strtol
的使用者完全有可能忘记要检查。相比之下,Go的strconv包的函数都将转换过程中的错误以第二个参数的形式返回给调用者,用起来更安全。
按照《代码写得不好,不要总觉得是自己抽象得不好》这篇文章的说法,代码写成什么样子完全是由产品经理决定的。但产品经理又怎么会在意你用的技术是怎么实现多重返回值的呢。综上所述,这个特性没用(误)。
全文完。
]]>在旧文《如何写一个命令行的秒表》中,借助命令tput
,我实现了“原地更新”所输出的时分秒的效果
其中用到的是ASCII转义序列\x1b[8D
和\x1b[0K
。除此之外,ASCII转义序列还有许多其它功能。例如,可以用来定制输出内容的前景色
将转义序列中的参数38
改为48
,可以定制输出内容的背景色
将打印内容改为两个空格,看起来就像是在一块黑色的画布上涂了一个红色的方块
既然如此,只要尺寸合适,就可以在终端打印出一张图片,只需要将每一个像素的颜色作为背景色,在坐标对应的行列上输出两个空格即可。如果能抹掉输出的内容并在同样的位置上打印一张不同的图片,甚至可以实现动画的效果。
百闻不如一见,下面我用Python演示一番。
要想用前文的思路在终端中显示一张GIF图片,必须先得到GIF图片每一帧的每个像素的颜色才行。在Python中使用名为Pillow的库可以轻松地解析GIF文件,先安装这个库
1 | ➜ /tmp rmdir show_gif |
接着便可以让它读入并解析一张GIF图片
1 | import sys |
然后将每一帧都转换为RGB
模式再遍历其每一个像素
1 | import sys |
调用Image
类的实例方法load
得到的是一个PixelAccess
类的实例,它可以像二维数组一般用坐标获取每一个像素的颜色值,颜色值则是一个长度为3的tuple
类型的值,其中依次是像素的三原色的分量。
从ANSI escape code词条的24-bit小节中得知,使用参数为48;2;
的转义序列,再接上以分号分隔的三原色分量即可设置24位的背景色
1 | import sys |
在每次二重循环遍历了所有像素后,还必须清除输出的内容,并将光标重置到左上角才能再次打印,这可以用ASCII转义序列来实现。查阅VT100 User Guide可以知道,用ED命令可以擦除显示的字符,对应的转义序列为\x1b[2J
;用CUP命令可以移动光标的位置到左上角,对应的转义序列为\x1b[0;0H
。在每次开始打印一帧图像前输出这两个转义序列即可
1 | import sys |
最后,只需要在每次打印完一帧后,按GIF文件的要求睡眠一段时间即可。每一帧的展示时长可以从info
属性的键duration
中得到,单位是毫秒
1 | import sys |
现在可以看看效果了。我准备了一张测试用的GIF图片,宽度和高度均为47像素,共34帧
让它在终端中显示出来吧
你可能留意到了,前文的演示效果中有明显的闪烁,这是因为打印ASCII转义序列的速度不够快导致的。既然如此,可以将一整行的转义序列先生成出来,再一次性输出到终端。改动不复杂
1 | import sys |
但效果却很显著
全文完
]]>仅以此文膜拜八年前的自己
欧拉计划(Project Euler)就像LeetCode,是一个编程答题的网站。不同于LeetCode的是,欧拉计划只要求用户提交最终答案即可(一般是一个数字),而不需要完整代码。因此,可以尽情地使用自己喜欢的编程语言——不少题目甚至光靠笔和纸便能解决。
欧拉计划的第66题非常有意思,它的题目很简单,就是要求找出在不大于1000的整数中,以哪一个数字为丢番图方程的系数,可以得到所有最小解中的最大值。
可以很容易地看出方程有一个直观的暴力算法:让y从1开始递增,对于每一个y,计算公式Dy^2+1
的值。如果该值为平方数,那么它的平方根就是最小的x解。再依照这个算法求解所有D不大于1000的方程,便可以求出题目的答案。很容易用Python写出这个算法
1 | # -*- coding: utf8 -*- |
但如果将上限limit
提升为1000,这个算法在有生之年是算不出结果的。
要想解决这一题,需要借助数学的力量。
八年前第一次做这一题的时候,经过一番搜索,我从这篇文章中知道了题目中的方程叫做佩尔方程。它有标准的解法,但需要用到连分数。那么什么是连分数呢?
连分数不是一种新的数系,只是小数的另一种写法。例如可以把分数45除以16写成下面的形式
就像定义递归的数据结构一样,可以给连分数一个递归的定义。连分数要么是一个整数,要么是一个整数加上另一个连分数的倒数。除了上面的形式,连分数也可以写成更节省篇幅的样子。比如把45除以16写成[2;1,4,3]
,即把原本的式子中所有的整数部分按顺序写在一对方括号之间。这种记法,看起来就像是编程语言中的数组一般。
如果用数组[2;1,4,3]
的不同前缀来构造分式,那么结果依次为2/1
、3/1
、14/5
。它们是这个连分数的渐进连分数,而佩尔方程的一组解,就来自于渐进连分数的分子和分母。
以系数为7的佩尔方程为例,先计算出根号7的连分数,然后依次尝试它的渐进连分数。前三个分别为2/1
、3/1
、5/2
,都不是方程的解。第四个渐进连分数8/3
才是方程的解。如果继续提高连分数的精度,还会找到第二个解127/48
。继续找,还有更多,而8则是其中最小的x。
所以,想要快速算出佩尔方程的解,最重要的是找到计算一个数的平方根的连分数的算法。
要计算一个数字的连分数,最重要的便是要算出所有的整数部分(a0
、a2
、a2
等)。它们都可以依据定义直接计算
推广到一半情况,如果用变量n
存储开平方的数字,用numbers
存储所有已知的整数,那么用Python可以写出下面的算法来计算出下一个整数
1 | # 计算连分数数列的下一个数字 |
遗憾的是,这个算法算出来的数字会因为计算上的精度误差而导致失之毫厘谬以千里。
要想计算出正确的结果,就需要尽可能地消除在计算1 / (v - a)
的时候引入的误差,因此必须把浮点数从分母中除去。
在这个网站中,作者以计算根号14的连分数为例,列出了一个表格
可以看到x1
、x2
,以及x3
都是形如(sqrt(n)+a)/b
这样的格式,这样的式子更利于控制误差。那么是否每一个待计算的x
都符合这种格式呢?答案是肯定的,可以用数学归纳法予以证明(为了方便写公式,用LaTeX写好后截了图)
在这个证明过程中,还得到了分子中的a
以及分母中的b
的递推公式,现在可以写出正确的计算连分数整数部分的代码了。
为了在实现这个算法的同时还要写出优雅的代码,我会用上Common Lisp的面向对象特性。首先是定义一个类来表示一个可以不断提高精度的连分数
1 | (defpackage #:com.liutos.cf |
接着再定义这个类需要实现的“接口”
1 | (defgeneric advance (cf) |
最后来实现上述两个接口
1 | (defmethod advance ((cf <cf>)) |
在实现into-rational
方法上,Common Lisp的有理数数值类型给我带来了极大的便利,它使我不必担心计算(/ 1 v)
的时候会引入误差,代码写起来简单直白。
乘胜追击,用Common Lisp解答第66题
1 | (defun find-min-x (D) |
答案的D是多少就不说了,不过作为答案的x是16421658242965910275055840472270471049。有兴趣的读者可以试一下暴力解法要花多久才能算到这个数字。
全文完。
]]>《实战Common Lisp》系列主要讲述在使用Common Lisp时能派上用场的小函数,希望能为Common Lisp的复兴做一些微小的贡献。MAKE COMMON LISP GREAT AGAIN。
写了一段时间的Python后,总觉得它跟Common Lisp(下文简称CL)有亿点点像。例如,Python和CL都支持可变数量的函数参数。在Python中写作
1 | def foo(* args): |
而在CL中则写成
1 | (defun foo (&rest args) |
Python的语法更紧凑,而CL的语法表意更清晰。此外,它们也都支持关键字参数。在Python中写成
1 | def bar(*, a=None, b=None): |
而在CL中则是
1 | (defun bar (&key (a nil) (b nil)) |
尽管CL的&key
仍然更清晰,但声明参数默认值的语法确实是Python更胜一筹。
细心的读者可能发现了,在Python中有一个叫做format
的方法(属于字符串类),而在CL则有一个叫做format
的函数。并且,从上面的例子来看,它们都负责生成格式化的字符串,那么它们有相似之处吗?
答案是否定的,CL的format
简直就是格式化打印界的一股泥石流。
format
的基本用法不妨从上面的示例代码入手介绍CL中的format
(下文在不引起歧义的情况下,简称为format
)的基本用法。首先,它需要至少两个参数:
format
将会把格式化后的字符串打印到什么地方。t
表示打印到标准输出;format
如何格式化。听起来很神秘,但其实跟C语言的fprintf
也没什么差别。
在控制字符串中,一般会有许多像占位符一般的命令(directive)。正如Python的format
方法中,有各式各样的format_spec能够格式化对应类型的数据,控制字符串中的命令也有很多种,常见的有:
~B
,例如(format t "~B" 5)
会打印出101;~O
,例如(format t "~O" 8)
会打印出10;~D
;~X
,例如(format t "~X" 161)
会打印出A1;~A
,一般打印字符串的时候会用到。另外,format
的命令也支持参数。在Python中,可以用下列代码打印右对齐的、左侧填充字符0的、二进制形式的数字5
1 | print('{:0>8b}'.format(5)) |
format
函数也可以做到同样的事情
1 | (format t "~8,'0B" 5) |
到这里为止,你可能会觉得format
的控制字符串,不过就是将花括号去掉、冒号换成波浪线,以及参数语法不一样的format
方法的翻版罢了。
接下来,让我们进入format
的黑科技领域。
format
的高级用法前面列举了打印二、八、十,以及十六进制的命令,但format
还支持其它的进制。使用命令~R
搭配参数,format
可以打印数字从2到36进制的所有形态。
1 | (format t "~3R~%" 36) ; 以 3进制打印数字36,结果为1100 |
之所以最大为36进制,是因为十个阿拉伯数字,加上二十六个英文字母正好是三十六个。那如果不给~R
加任何参数,会使用0进制吗?非也,format
会把数字打印成英文单词
1 | (format t "~R~%" 123) ; 打印出one hundred twenty-three |
甚至可以让format
打印罗马数字,只要加上@
这个修饰符即可
1 | (format t "~@R~%" 123) ; 打印出CXXIII |
天晓得为什么要内置这么冷门的功能。
你,作为一名细心的读者,可能留意到了,format
的~X
只能打印出大写字母,而在Python的format
方法中,{:x}
可以输出小写字母的十六进制数字。即使你在format
函数中使用~x
也是无效的,因为命令是大小写不敏感的(case insensitive)。
那要怎么实现打印小写字母的十六进制数字呢?答案是使用新的命令~(
,以及它配套的命令~)
1 | (format t "~(~X~)~%" 26) ; 打印1a |
配合:
和@
修饰符,一共可以实现四种大小写风格
1 | (format t "~(hello world~)~%") ; 打印hello world |
在Python的format
方法中,可以控制打印出的内容的宽度,这一点在“format
的基本用法”中已经演示过了。如果设置的最小宽度(在上面的例子中,是8)超过了打印的内容所占据的宽度(在上面的例子中,是3),那么还可以控制其采用左对齐、右对齐,还是居中对齐。
在CL的format
函数中,不管是~B
、~D
、~O
,还是~X
,都没有控制对齐方式的选项,数字总是右对齐。要控制对齐方式,需要用到~<
和它配套的~>
。例如,下面的CL代码可以让数字在八个宽度中左对齐
1 | (format t "|~8<~B~;~>|" 5) |
打印内容为|101 |
。~<
跟前面提到的其它命令不一样,它不消耗控制字符串之后的参数,它只控制~<
和~>
之间的字符串的布局。这意味着,即使~<
和~>
之间是字符串常量,它也可以起作用。
1 | (format t "|~8,,,'-<~;hello~>|" 5) |
上面的代码运行后会打印出|---hello|
:8表示用于打印的最小宽度;三个逗号(,
)之间为空,表示忽略~<
的第二和第三个参数;第四个参数控制着打印结果中用于填充的字符,由于-
不是数字,因此需要加上单引号前缀;~;
是内部的分隔符,由于它的存在,hello
成了最右侧的字符串,因此会被右对齐。
如果~<
和~>
之间的内容被~;
分隔成了三部分,还可以实现左对齐、居中对齐,以及右对齐的效果
1 | (format t "|~24<left~;middle~;right~>|") ; 打印出|left middle right| |
通常情况下,控制字符串中的命令会消耗参数,比如~B
和~D
等命令。也有像~<
这样不消耗参数的命令。但有的命令甚至可以做到“一参多用”,那就是~*
。比如,给~*
加上冒号修饰,就可以让上一个被消耗的参数重新被消耗一遍
1 | (format t "~8D~:*~8D~8D~%" 1 2) ; 打印出 1 1 2 |
在~8D
消耗了参数1之后,~:*
让下一个被消耗的参数重新指向了1,因此第二个~8D
拿到的参数仍然是1,最后一个拿到了2。尽管控制字符串中看起来有三个~D
命令而参数只有两个,却依然可以正常打印。
在format
的文档中一个不错的例子,就是让~*
和~P
搭配使用。~P
可以根据它对应的参数是否大于1,来打印出字母s
或者什么都不打印。配合~:*
就可以实现根据参数打印出单词的单数或复数形式的功能
1 | (format t "~D dog~:*~P~%" 1) ; 打印出1 dog |
甚至你可以组合一下前面的毕生所学
1 | (format t "~@(~R dog~:*~P~)~%" 2) ; 打印出Two dogs |
命令~[
和~]
也是成对出现的,它们的作用是选择性打印,不过比起编程语言中的if
,更像是取数组某个下标的元素
1 | (format t "~[~;one~;two~;three~]~%" 1) ; 打印one |
但这个特性还挺鸡肋的。想想,你肯定不会无缘无故传入一个数字来作为下标,而这个作为下标的数字很可能本身就是通过position
之类的函数计算出来的,而position
就要求传入待查找的item
和整个列表sequence
,而为了用上~[
你还得把列表中的每个元素硬编码到控制字符串中,颇有南辕北辙的味道。
给它加上冒号修饰符之后倒是有点用处,比如可以将CL中的真(NIL
以外的所有对象)和假(NIL
)打印成单词true
和false
1 | (format t "~:[false~;true~]" nil) ; 打印false |
圆括号和方括号都用了,又怎么能少了花括号呢。没错,~{
也是一个命令,它的作用是遍历列表。例如,想要打印出一个列表中的每个元素,并且两两之间用逗号和空格分开的话,可以用下列代码
1 | (format t "~{~D~^, ~}" '(1 2 3)) ; 打印出1, 2, 3 |
~{
和~}
之间也可以有不止一个命令,例如下列代码中每次会消耗列表中的两个元素
1 | (format t "{~{\"~A\": ~D~^, ~}}" '(:a 3 :b 2 :c 1)) |
打印结果为{"A": 3, "B": 2, "C": 1}
。如果把这两个format
表达式拆成用循环写的、不使用format
的等价形式,大约是下面这样子
1 | ; 与(format t "~{~D~^, ~}" '(1 2 3))等价 |
这么看来,~{
确实可以让使用者写出更紧凑的代码。
在前面的例子中,尽管用~R
搭配不同的参数可以将数字打印成不同进制的形式,但毕竟这个参数是固化在控制字符串中的,局限性很大。例如,如果我想要定义一个函数print-x-in-base-y
,使得参数x
可以打印为y
进程的形式,那么也许会这么写
1 | (defun print-x-in-base-y (x y) |
但format
的灵活性,允许使用者将命令的前缀参数也放到控制字符串之后的列表中,因此可以写成如下更简练的实现
1 | (defun print-x-in-base-y (x y) |
而且不只一个,你可以把所有参数都写成参数的形式
1 | (defun print-x-in-base-y (x |
恭喜你重新发明了~R
,而且还不支持:
和@
修饰符。
要在CL中打印形如2021-01-29 22:43
这样的日期和时间字符串,是一件比较麻烦的事情
1 | (multiple-value-bind (sec min hour date mon year) |
谁让CL没有内置像Python的datetime
模块这般完善的功能呢。不过,借助format
的~/
命令,我们可以在控制字符串中写上要调用的自定义函数,来深度定制打印出来的内容。以打印上述格式的日期和时间为例,首先定义一个后续要用的自定义函数
1 | (defun yyyy-mm-dd-HH-MM (dest arg is-colon-p is-at-p &rest args) |
然后便可以直接在控制字符串中使用它的名字
1 | (format t "~/yyyy-mm-dd-HH-MM/" (get-universal-time)) |
在我的机器上运行的时候,打印内容为2021-01-29 22:51
。
format
可以做的事情还有很多,CL的HyperSpec中有关于format
函数的详细介绍,CL爱好者一定不容错过。
最后,其实Python跟CL并不怎么像。每每看到Python中的__eq__
、__ge__
,以及__len__
等方法的巧妙运用时,身为一名Common Lisp爱好者,我都会流露出羡慕的神情。纵然CL被称为可扩展的编程语言,这些平凡的功能却依旧无法方便地做到呢。
入坑VS Code
前,我已经是一名久经考验的Emacs
老用户了,因此开始正式使用VS Code
后,我第一时间启用了它的Emacs Keymap
。但不久我便发现,这套键映射缺少一个重要的快捷键——ctrl-l
。
在Emacs
中,ctrl-l
对应的命令是recenter-top-bottom
,它用于将光标所在的行轮替地滚动到可视区域(即Emacs
中的window
)的中间、顶部,以及底部(如下图所示)
这是我高频使用的一个功能,尤其是跳转到函数的定义的首行后,我习惯于连按两次,将其滚动到window
的顶部以便在一屏中看到尽量多的内容。
为了避免重复发明轮子,我先搜索了一番,找到了一个宣称实现了该功能的扩展Recenter Top Bottom
。可惜的是,安装后并不生效。
难道只能委屈自己用鼠标小心翼翼地将光标所在行滚到顶部了吗?当然不是。既然没有开箱即用的,那便自己写一个VS Code
的扩展实现这个功能吧。
要想入门VS Code
扩展的开发,官方便提供了一份不错的教程。一个扩展有许多的“八股文”代码,可以用yo
和generator-code
来快速生成
1 | npm install -g yo generator-code |
到这里,便得到了一个名为helloworld
的目录了。用VS Code
打开它,接下来要在其中大展身手。
VS Code
扩展的核心逻辑定义在文件src/extension.ts
中。在yo
生成的示例代码中,用registerCommand
注册了一个名为helloworld.helloWorld
的命令,其逻辑是简单地在右下角弹出一句Hello VS Code from HelloWorld!
。这个回调函数,便是业务逻辑的落脚点。
要想实现将光标所在行滚动到中间的功能,首先要知道VS Code
为开发者提供了哪些支持。在摸索了一通从VS Code
的API文档
后,我有了以下的线索:
vscode.window.activeTextEditor
可以取得当前聚焦的编辑器——其值可能为空(undefined
);TextEditor
实例的属性.selection.active
可以取得当前光标的位置;TextEditor
实例有一个方法revealRange
可以滚动文本来改变展示的范围,它需要一个vscode.Range
类的实例,以及一个vscode.TextEditorRevealType
类型的枚举值;vscode.TextEditorRevealType.InCenter
的效果是将所给定的范围展示在中间,vscode.TextEditorRevealType.AtTop
则是置顶。有了这些知识储备,实现这样的一个回调函数便是信手拈来的事情了
1 | function recenterTop() { |
由于暂时没有配置该命令的快捷键,只能用VS Code
的命令面板来调用
接下来我将实现连续调用两次helloworld.helloWorld
命令,把光标所在行滚动到顶部的效果。在Emacs
中,可以很轻松地知道一个命令是否被连续运行——Emacs
有一个名为last-command
的变量存储着上一个命令的名称,只需要检查其是否等于recenter-top-bottom
即可。但VS Code
没有暴露这么强大的功能,只能另辟蹊径。
我的策略是,如果调用helloworld.helloWorld
时光标的位置,与上一次调用该命令时的位置相同,就认为是连续调用。为此,需要两个在函数recenterTop
之外定义的变量:
previousPosition
负责记录上一次调用recenterTop
时光标的位置,它的初始值为null
;revealType
存储着上一次调整展示范围时传递给TextEditor
实例的revealRange
方法的第二个参数的值,它的初始值也为null
。我的目标是尽量模拟Emacs
中的recenter-top-bottom
所具备的、交替使用居中、置顶效果的特点,因此:
revealType
为null
,意味着这是第一次调用recenterTop
,那么效果便是居中。否则;recenterTop
后调用过其它命令,效果依然是居中。否则;revealType
已经是居中了,就改为置顶。否则;revealType
改为居中。Talk is cheap. Show me the code.
1 | let previousPosition: null|vscode.Position = null; |
通过命令面板来使用不是我的最终目标,通过快捷键才是。根据VS Code
的文档可以知道,只要在package.json
的contributes
对象中,新增名为keybindings
的属性,并定义命令及按键序列即可。
1 | { |
如果看过我之前的文章《手指疼,写点代码缓解一下》的读者应当会记得,我已经从Emacs Keymap
“叛逃”到了Vim Keymap
了。所以,我并没有真正用上上述的VS Code
扩展。相反,目前高频使用的是Vim Keymap
内置的z-.
以及z-↵
了——前者用于垂直居中,后者用于置顶。
爱护手指,从使用Vim Keymap
做起。
Homebrew
安装了一些软件——因为已经是一个月前的事情了,所以已经记不清是安装了什么。安装后并没有立即出现什么问题,只是又过了两天我重新启动电脑后,发现同样是由Homebrew
安装的Emacs
不由分说地无法启动了。这下可麻烦了,毕竟我是org-mode
的重度使用者,还需要偶尔用SLIME
写点Common Lisp的代码,而它们都运行在Emacs
中。直觉告诉我,也许重新安装一下Emacs
,一切就可以恢复正常。重装了Emacs
后,又遇到了别的问题——用BetterTouchTools
在Touch Bar中添加的按钮,无法在Emacs
已经启动的情况下,切换到它的窗口上。
非要说,问题其实也不大,毕竟很多时候是将MacBook Pro合上盖子当主机用的,Touch Bar在工作时的使用频率并不高。此外,糊Node.js
等语言的代码时也用不到Emacs
——还是VSCode
更合适。
但这就是令人不爽,因此我决定要解决它——用Hammerspoon
。
Hammerspoon
的官网很好地说明了这款工具的定位和原理
This is a tool for powerful automation of OS X. At its core, Hammerspoon is just a bridge between the operating system and a Lua scripting engine. What gives Hammerspoon its power is a set of extensions that expose specific pieces of system functionality, to the user.
Automator
或第三方的Alfred Workflow
那样;Lua
代码调用的模块;例如下面的代码
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "E", function() |
就可以让使用者在按下组合键⌘⌥⌃e
的时候,在屏幕正中间显示Hello World!
这段文本
Hammerspoon
正好可以解决我的问题,它的hs.window
模块既可以让使用者遍历所有打开的窗口(用hs.window.allWindows
函数),也可以聚焦到指定的窗口上(用focus
方法)。有了它们,将Emacs
调到最前面(front-most)来也就是水到渠成的事情了:
hs.window.allWindows
函数,获得所有窗口的列表;Emacs
的,就调用窗口的方法focus
,并跳出循环。剩下的两个问题便是:
Emacs
的bundle ID
是什么;bundle ID
。Bundle ID
可以在macOS中独一无二地标识一个应用。要想知道Emacs
的bundle ID
是什么,只需要打开文件/Applications/Emacs.app/Contents/Info.plist
,看看其中键为CFBundleIdentifier
的值即可。
1 | ➜ Contents grep -A 1 'CFBundleIdentifier' Info.plist |
可以看到,Emacs
的bundle ID
是org.gnu.Emacs
。
有了Emacs
的bundle ID
,接下来就可以在Hammerspoon
中定义快捷键了。由于最后会通过Touch Bar上的按钮来触发这组快捷键,复杂点也不要紧,因此我直接沿用了Hammerspoon
的入门指引中作为例子的⌘⌥⌃w
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() |
为了在一个循环中逐个遍历窗口对象,将hs.window.allWindows
的返回值保存到一个局部变量中
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() |
照着简书上的这篇文章,依葫芦画瓢地用for
和pairs
来遍历变量windows
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() |
窗口自身没有bundle ID
,为此需要先获取窗口所属的应用。查看文档可以知道,有一个application
方法正是用来获取应用对象的
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() |
调用allWindows
时使用的是英文句号(.
),调用application
则是用冒号(:
),这正是Lua
中调用函数与方法时语法上的差异。
再用应用的bundleID
方法获得它的bundle ID
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() |
现在,只要变量bundleID
等于Emacs
的bundle ID
就可以聚焦到当前遍历的窗口上了
1 | hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function() |
只需要在BetterTouchTools
中配置一下即可
这个方法比此前唤起/Applications/Emacs.app
的方式更好,因为它只依赖于Emacs
逻辑上亘古不变的东西——bundle ID
,而不依赖于其物理上的安装位置。
“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。
在org-mode
中,一个条目(entry)可以设置多个属性(Properties)。有的属性是org-mode
内置的,有它们的特殊用途。有的属性是自定义的,用在一些插件或仅仅用于记录信息。CUSTOM_ID
属于前者,而ID
属性后者。
CUSTOM_ID
用于跳转。org-mode
支持丰富的外部链接格式,其中之一便是链接到指定.org
文件的指定CUSTOM_ID
的条目。
比如在一个.org
文件中有file:/Users/liutos/Dropbox/gtd/roles/writer.org::#d1bdc978-a8ce-4266-9ffa-b6041f818431
这么一段文本,那么当光标置于这个文本中时,按下快捷键C-c C-o
,Emacs便会打开文件/Users/liutos/Dropbox/gtd/roles/writer.org
,并将光标对应的条目上。
ID
用于联系两个条目。一个名叫org-edna
的第三方插件能够实现两个条目间的依赖,其中一个要素便是条目的ID
属性。
比如我有一个讲解Ada语言
的任务(以一个条目的形式存在),同时也有一个学习Ada语言
的任务(另一个条目)。显然,必须先学习一番才能讲给他人听,所以第一个条目依赖于第二个条目,于是我先给学习Ada语言
的条目设置一个ID
属性,值为905fc2f4-4e28-4966-84fa-84c9e6bae96c
,然后再为讲解Ada语言
的条目中设置一个BLOCKER
属性,值为ids(905fc2f4-4e28-4966-84fa-84c9e6bae96c)
。如此一来,当讲解Ada语言
的条目出现在*Org Agenda*
中时,Emacs会将其置灰显示,代表它处于阻塞的状态,必须先处理它的依赖才行。
建立依赖和跳转都是很常用的功能,因此我会给每一个条目都设置CUSTOM_ID
和ID
属性。为了免除每次都手动设置的麻烦,我用org-mode
的capture-template特性来实现自动填充。
capture-template是org-mode
的又一项利器,用于生成条目间共性的内容,比如行首的星号、关键字,以及写入到哪一个文件的哪一个层级中。org-mode
的官网便有一个例子
1 | (setq org-capture-templates |
在capture-template中除了可以用预置的占位符(比如上文的%U
、%i
,以及%a
),还可以调用任意的Elisp函数——这正适合填充ID
和CUSTOM_ID
这类不重复,并且有一定的格式要求的属性。ID
属性的值可以用来自于第三方插件uuidgen
的uuidgen-4
函数来生成
1 | (setq org-capture-templates |
美中不足的是,CUSTOM_ID
和ID
的值是不同的,因为uuidgen-4
每次都会返回不同的字符串。有没有什么办法能够让它们一样的呢?答案是肯定的。
既然两次调用uuidgen-4
的结果不同,那么就将第一次调用后的结果保存起来,然后重复使用即可。思路很简单,实现代码也很直白
1 | (let (lt-org-capture--uuid) |
capture-template也是水到渠成的
1 | (setq org-capture-templates |
在上面的函数定义中,我试图利用词法作用域特性,使得lt-org-capture--uuid
只能被lt-org-capture-uuidgen
和lt-org-capture-uuidclr
读写。遗憾的是,Elisp并不支持词法作用域,lt-org-capture--uuid
实际上是一个全局变量——完全可以用C-h v
来审视它。
全文完。
]]>