乍听之下,不无道理;仔细揣摩,胡说八道

0%

序言

在旧文《如何写一个命令行的秒表》中,借助命令tput,我实现了“原地更新”所输出的时分秒的效果

其中用到的是ASCII转义序列\x1b[8D\x1b[0K。除此之外,ASCII转义序列还有许多其它功能。例如,可以用来定制输出内容的前景色

将转义序列中的参数38改为48,可以定制输出内容的背景色

将打印内容改为两个空格,看起来就像是在一块黑色的画布上涂了一个红色的方块

既然如此,只要尺寸合适,就可以在终端打印出一张图片,只需要将每一个像素的颜色作为背景色,在坐标对应的行列上输出两个空格即可。如果能抹掉输出的内容并在同样的位置上打印一张不同的图片,甚至可以实现动画的效果。

百闻不如一见,下面我用Python演示一番。

把GIF装进终端

要想用前文的思路在终端中显示一张GIF图片,必须先得到GIF图片每一帧的每个像素的颜色才行。在Python中使用名为Pillow的库可以轻松地解析GIF文件,先安装这个库

1
2
3
4
5
6
7
8
9
10
11
12
➜  /tmp rmdir show_gif
➜ /tmp mkdir show_gif
➜ /tmp cd show_gif
➜ show_gif python3 -m venv ./venv
➜ show_gif . ./venv/bin/activate
(venv) ➜ show_gif pip install Pillow
Collecting Pillow
Using cached Pillow-8.1.0-cp39-cp39-macosx_10_10_x86_64.whl (2.2 MB)
Installing collected packages: Pillow
Successfully installed Pillow-8.1.0
WARNING: You are using pip version 20.2.3; however, version 21.0.1 is available.
You should consider upgrading via the '/private/tmp/show_gif/venv/bin/python3 -m pip install --upgrade pip' command.

接着便可以让它读入并解析一张GIF图片

1
2
3
4
5
6
7
8
9
import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
path = sys.argv[1]
im = Image.open(path)
for frame in ImageSequence.Iterator(im):
pass

然后将每一帧都转换为RGB模式再遍历其每一个像素

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
path = sys.argv[1]
im = Image.open(path)
for frame in ImageSequence.Iterator(im):
rgb_frame = frame.convert('RGB')
pixels = rgb_frame.load()
for y in range(0, rgb_frame.height):
for x in range(0, rgb_frame.width):
pass

调用Image类的实例方法load得到的是一个PixelAccess类的实例,它可以像二维数组一般用坐标获取每一个像素的颜色值,颜色值则是一个长度为3的tuple类型的值,其中依次是像素的三原色的分量。

ANSI escape code词条的24-bit小节中得知,使用参数为48;2;的转义序列,再接上以分号分隔的三原色分量即可设置24位的背景色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
path = sys.argv[1]
im = Image.open(path)
for frame in ImageSequence.Iterator(im):
rgb_frame = frame.convert('RGB')
pixels = rgb_frame.load()
for y in range(0, rgb_frame.height):
for x in range(0, rgb_frame.width):
colors = pixels[x, y]
print('\x1b[48;2;{};{};{}m \x1b[0m'.format(*colors), end='')
print('')

在每次二重循环遍历了所有像素后,还必须清除输出的内容,并将光标重置到左上角才能再次打印,这可以用ASCII转义序列来实现。查阅VT100 User Guide可以知道,用ED命令可以擦除显示的字符,对应的转义序列为\x1b[2J;用CUP命令可以移动光标的位置到左上角,对应的转义序列为\x1b[0;0H。在每次开始打印一帧图像前输出这两个转义序列即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys

from PIL import Image, ImageSequence

if __name__ == '__main__':
path = sys.argv[1]
im = Image.open(path)
for frame in ImageSequence.Iterator(im):
rgb_frame = frame.convert('RGB')
pixels = rgb_frame.load()
print('\x1b[2J\x1b[0;0H', end='')
for y in range(0, rgb_frame.height):
for x in range(0, rgb_frame.width):
colors = pixels[x, y]
print('\x1b[48;2;{};{};{}m \x1b[0m'.format(*colors), end='')
print('')

最后,只需要在每次打印完一帧后,按GIF文件的要求睡眠一段时间即可。每一帧的展示时长可以从info属性的键duration中得到,单位是毫秒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import sys
import time

from PIL import Image, ImageSequence

if __name__ == '__main__':
path = sys.argv[1]
im = Image.open(path)
for frame in ImageSequence.Iterator(im):
rgb_frame = frame.convert('RGB')
pixels = rgb_frame.load()
print('\x1b[2J\x1b[0;0H', end='')
for y in range(0, rgb_frame.height):
for x in range(0, rgb_frame.width):
colors = pixels[x, y]
print('\x1b[48;2;{};{};{}m \x1b[0m'.format(*colors), end='')
print('')
time.sleep(rgb_frame.info['duration'] / 1000)

现在可以看看效果了。我准备了一张测试用的GIF图片,宽度和高度均为47像素,共34帧

让它在终端中显示出来吧

一点微小的改进

你可能留意到了,前文的演示效果中有明显的闪烁,这是因为打印ASCII转义序列的速度不够快导致的。既然如此,可以将一整行的转义序列先生成出来,再一次性输出到终端。改动不复杂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import sys
import time

from PIL import Image, ImageSequence

if __name__ == '__main__':
path = sys.argv[1]
im = Image.open(path)
for frame in ImageSequence.Iterator(im):
rgb_frame = frame.convert('RGB')
pixels = rgb_frame.load()
print('\x1b[2J\x1b[0;0H', end='')
for y in range(0, rgb_frame.height):
last_colors = None
line = ''
for x in range(0, rgb_frame.width):
colors = pixels[x, y]
if colors != last_colors:
line += '\x1b[0m\x1b[48;2;{};{};{}m '.format(*colors)
else:
line += ' '
last_colors = colors
print('{}\x1b[0m'.format(line))
time.sleep(rgb_frame.info['duration'] / 1000)

但效果却很显著

全文完

仅以此文膜拜八年前的自己

序言

欧拉计划(Project Euler)就像LeetCode,是一个编程答题的网站。不同于LeetCode的是,欧拉计划只要求用户提交最终答案即可(一般是一个数字),而不需要完整代码。因此,可以尽情地使用自己喜欢的编程语言——不少题目甚至光靠笔和纸便能解决。

欧拉计划的第66题非常有意思,它的题目很简单,就是要求找出在不大于1000的整数中,以哪一个数字为丢番图方程的系数,可以得到所有最小解中的最大值。

可以很容易地看出方程有一个直观的暴力算法:让y从1开始递增,对于每一个y,计算公式Dy^2+1的值。如果该值为平方数,那么它的平方根就是最小的x解。再依照这个算法求解所有D不大于1000的方程,便可以求出题目的答案。很容易用Python写出这个算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# -*- coding: utf8 -*-
import math

def is_square(num: int) -> bool:
return math.isqrt(num) ** 2 == num

def find_x(D: int) -> int:
"""
求出给定D时,满足题目所给的丢番图方程的最小的x。
"""
assert not is_square(D)
y = 1
while True:
candidate = D * y * y + 1
if is_square(candidate):
return math.isqrt(candidate)
y += 1

def solve_66(limit):
"""
找出不大于limi的D中,使find_x的返回值最大的那一个数字。
"""
max_D = None
max_x = None
D = 2
while D <= limit:
if is_square(D):
D += 1
continue
x = find_x(D)
if max_x is None or x > max_x:
max_D = D
max_x = x
D += 1
return max_D, max_x

if __name__ == '__main__':
D, x = solve_66(7)
print('D is {} and x is {}'.format(D, x))

但如果将上限limit提升为1000,这个算法在有生之年是算不出结果的。

要想解决这一题,需要借助数学的力量。

佩尔方程

八年前第一次做这一题的时候,经过一番搜索,我从这篇文章中知道了题目中的方程叫做佩尔方程。它有标准的解法,但需要用到连分数。那么什么是连分数呢?

连分数不是一种新的数系,只是小数的另一种写法。例如可以把分数45除以16写成下面的形式

就像定义递归的数据结构一样,可以给连分数一个递归的定义。连分数要么是一个整数,要么是一个整数加上另一个连分数的倒数。除了上面的形式,连分数也可以写成更节省篇幅的样子。比如把45除以16写成[2;1,4,3],即把原本的式子中所有的整数部分按顺序写在一对方括号之间。这种记法,看起来就像是编程语言中的数组一般。

如果用数组[2;1,4,3]的不同前缀来构造分式,那么结果依次为2/13/114/5。它们是这个连分数的渐进连分数,而佩尔方程的一组解,就来自于渐进连分数的分子和分母。

以系数为7的佩尔方程为例,先计算出根号7的连分数,然后依次尝试它的渐进连分数。前三个分别为2/13/15/2,都不是方程的解。第四个渐进连分数8/3才是方程的解。如果继续提高连分数的精度,还会找到第二个解127/48。继续找,还有更多,而8则是其中最小的x。

所以,想要快速算出佩尔方程的解,最重要的是找到计算一个数的平方根的连分数的算法。

计算平方根的连分数的错误方法

要计算一个数字的连分数,最重要的便是要算出所有的整数部分(a0a2a2等)。它们都可以依据定义直接计算

推广到一半情况,如果用变量n存储开平方的数字,用numbers存储所有已知的整数,那么用Python可以写出下面的算法来计算出下一个整数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 计算连分数数列的下一个数字
import math

def compute_next_integer_part(n, numbers):
v = math.sqrt(n)
for a in numbers:
v = 1 / (v - a)
return int(v)

if __name__ == '__main__':
n = 14
numbers = [3, 1, 2, 1]
v = compute_next_integer_part(n, numbers)
print('下一个数字为{}'.format(v))

遗憾的是,这个算法算出来的数字会因为计算上的精度误差而导致失之毫厘谬以千里。

计算平方根的连分数的正确方法

要想计算出正确的结果,就需要尽可能地消除在计算1 / (v - a)的时候引入的误差,因此必须把浮点数从分母中除去。

这个网站中,作者以计算根号14的连分数为例,列出了一个表格

可以看到x1x2,以及x3都是形如(sqrt(n)+a)/b这样的格式,这样的式子更利于控制误差。那么是否每一个待计算的x都符合这种格式呢?答案是肯定的,可以用数学归纳法予以证明(为了方便写公式,用LaTeX写好后截了图)

在这个证明过程中,还得到了分子中的a以及分母中的b的递推公式,现在可以写出正确的计算连分数整数部分的代码了。

用Common Lisp实现上述算法

为了在实现这个算法的同时还要写出优雅的代码,我会用上Common Lisp的面向对象特性。首先是定义一个类来表示一个可以不断提高精度的连分数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(defpackage #:com.liutos.cf
(:use #:cl))

(in-package #:com.liutos.cf)

(defclass <cf> ()
((a
:documentation "数学归纳法中、分子中与平方根相加的数"
:initform 0)
(b
:documentation "数学归纳法中的分母"
:initform 1)
(numbers
:documentation "连分数中的整数部分依次组成的数组。"
:initform nil)
(origin
:documentation "被开平方的数字"
:initarg :origin))
(:documentation "表示整数ORIGIN的平方根的连分数。"))

接着再定义这个类需要实现的“接口”

1
2
3
4
5
(defgeneric advance (cf)
(:documentation "让连分数CF提高到下一个精度。"))

(defgeneric into-rational (cf)
(:documentation "将连分数CF转换为有理数类型的值。"))

最后来实现上述两个接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(defmethod advance ((cf <cf>))
"根据递推公式计算出下一批a、b,以及连分数的整数部分。"
(let* ((a (slot-value cf 'a))
(b (slot-value cf 'b))
(n (slot-value cf 'origin))
(m (truncate (+ (sqrt n) a) b)))
(let ((a (- (* b m) a))
(b (/ (- n (expt (- a (* b m)) 2)) b)))
(setf (slot-value cf 'a) a
(slot-value cf 'b) b
(slot-value cf 'numbers) (append (slot-value cf 'numbers) (list m))))
(values)))

(defmethod into-rational ((cf <cf>))
(let* ((numbers (reverse (slot-value cf 'numbers)))
(v (first numbers)))
(dolist (n (rest numbers))
(setf v
(+ n (/ 1 v))))
v))

在实现into-rational方法上,Common Lisp的有理数数值类型给我带来了极大的便利,它使我不必担心计算(/ 1 v)的时候会引入误差,代码写起来简单直白。

解题

乘胜追击,用Common Lisp解答第66题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(defun find-min-x (D)
(let ((cf (make-instance '<cf> :origin D)))
(loop
(advance cf)
(let* ((ratio (into-rational cf))
(x (numerator ratio))
(y (denominator ratio)))
(when (= (- (* x x) (* D y y)) 1)
(return-from find-min-x x))))))

(defun square-p (n)
(let ((rt (sqrt n)))
(= rt (truncate rt))))

(defun pro66 (&optional (bnd 1000))
(let ((target-d)
(max-x 0))
(loop :for i :from 2 :to bnd
:when (not (square-p i))
:do (let ((x (find-min-x i)))
(if (> x max-x)
(setf target-d i
max-x x))))
(values target-d max-x)))

答案的D是多少就不说了,不过作为答案的x是16421658242965910275055840472270471049。有兴趣的读者可以试一下暴力解法要花多久才能算到这个数字。

全文完。

《实战Common Lisp》系列主要讲述在使用Common Lisp时能派上用场的小函数,希望能为Common Lisp的复兴做一些微小的贡献。MAKE COMMON LISP GREAT AGAIN。

序言

写了一段时间的Python后,总觉得它跟Common Lisp(下文简称CL)有亿点点像。例如,Python和CL都支持可变数量的函数参数。在Python中写作

1
2
def foo(* args):
print(args)

而在CL中则写成

1
2
(defun foo (&rest args)
(print args))

Python的语法更紧凑,而CL的语法表意更清晰。此外,它们也都支持关键字参数。在Python中写成

1
2
def bar(*, a=None, b=None):
print('a={}\tb={}'.format(a, b))

而在CL中则是

1
2
(defun bar (&key (a nil) (b nil))
(format t "a=~A~8Tb=~A~%" a b))

尽管CL的&key仍然更清晰,但声明参数默认值的语法确实是Python更胜一筹。

细心的读者可能发现了,在Python中有一个叫做format的方法(属于字符串类),而在CL则有一个叫做format的函数。并且,从上面的例子来看,它们都负责生成格式化的字符串,那么它们有相似之处吗?

答案是否定的,CL的format简直就是格式化打印界的一股泥石流。

format的基本用法

不妨从上面的示例代码入手介绍CL中的format(下文在不引起歧义的情况下,简称为format)的基本用法。首先,它需要至少两个参数:

  • 第一个参数控制了format将会把格式化后的字符串打印到什么地方。t表示打印到标准输出;
  • 第二个参数则是本文的主角,名为控制字符串(control-string)。它指导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
2
3
4
5
6
7
8
9
10
(format t "~3R~%" 36)   ; 以 3进制打印数字36,结果为1100
(format t "~5R~%" 36) ; 以 5进制打印数字36,结果为 121
(format t "~7R~%" 36) ; 以 7进制打印数字36,结果为 51
(format t "~11R~%" 36) ; 以11进制打印数字36,结果为 33
(format t "~13R~%" 36) ; 以13进制打印数字36,结果为 2A
(format t "~17R~%" 36) ; 以17进制打印数字36,结果为 22
(format t "~19R~%" 36) ; 以19进制打印数字36,结果为 1H
(format t "~23R~%" 36) ; 以23进制打印数字36,结果为 1D
(format t "~29R~%" 36) ; 以29进制打印数字36,结果为 17
(format t "~31R~%" 36) ; 以31进制打印数字36,结果为 15

之所以最大为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
2
3
4
(format t "~(hello world~)~%")   ; 打印hello world
(format t "~:(hello world~)~%") ; 打印Hello World
(format t "~@(hello world~)~%") ; 打印Hello world
(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
2
(format t "~D dog~:*~P~%" 1) ; 打印出1 dog
(format t "~D dog~:*~P~%" 2) ; 打印出2 dogs

甚至你可以组合一下前面的毕生所学

1
(format t "~@(~R dog~:*~P~)~%" 2) ; 打印出Two dogs

条件打印

命令~[~]也是成对出现的,它们的作用是选择性打印,不过比起编程语言中的if,更像是取数组某个下标的元素

1
2
3
(format t "~[~;one~;two~;three~]~%" 1) ; 打印one
(format t "~[~;one~;two~;three~]~%" 2) ; 打印two
(format t "~[~;one~;two~;three~]~%" 3) ; 打印three

但这个特性还挺鸡肋的。想想,你肯定不会无缘无故传入一个数字来作为下标,而这个作为下标的数字很可能本身就是通过position之类的函数计算出来的,而position就要求传入待查找的item和整个列表sequence,而为了用上~[你还得把列表中的每个元素硬编码到控制字符串中,颇有南辕北辙的味道。

给它加上冒号修饰符之后倒是有点用处,比如可以将CL中的真(NIL以外的所有对象)和假(NIL)打印成单词truefalse

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
; 与(format t "~{~D~^, ~}" '(1 2 3))等价
(progn
(do ((lst '(1 2 3) (cdr lst)))
((null lst))
(let ((e (car lst)))
(princ e)
(when (cdr lst)
(princ ", "))))
(princ #\Newline))

; 与(format t "{~{\"~A\": ~D~^, ~}}" '(:a 3 :b 2 :c 1))等价
(progn
(princ "{")
(do ((lst '(:c 3 :b 2 :a 1) (cddr lst)))
((null lst))
(let ((key (car lst))
(val (cadr lst)))
(princ "\"")
(princ key)
(princ "\": ")
(princ val)
(when (cddr lst)
(princ ", "))))
(princ "}")
(princ #\Newline))

这么看来,~{确实可以让使用者写出更紧凑的代码。

参数化参数

在前面的例子中,尽管用~R搭配不同的参数可以将数字打印成不同进制的形式,但毕竟这个参数是固化在控制字符串中的,局限性很大。例如,如果我想要定义一个函数print-x-in-base-y,使得参数x可以打印为y进程的形式,那么也许会这么写

1
2
3
(defun print-x-in-base-y (x y)
(let ((control-string (format nil "~~~DR" y)))
(format t control-string x)))

format的灵活性,允许使用者将命令的前缀参数也放到控制字符串之后的列表中,因此可以写成如下更简练的实现

1
2
(defun print-x-in-base-y (x y)
(format t "~VR" y x))

而且不只一个,你可以把所有参数都写成参数的形式

1
2
3
4
5
6
7
(defun print-x-in-base-y (x
&optional y
&rest args
&key mincol padchar commachar commainterval)
(declare (ignorable args))
(format t "~V,V,V,V,VR"
y mincol padchar commachar commainterval x))

恭喜你重新发明了~R,而且还不支持:@修饰符。

自定义命令

要在CL中打印形如2021-01-29 22:43这样的日期和时间字符串,是一件比较麻烦的事情

1
2
3
4
5
(multiple-value-bind (sec min hour date mon year)
(decode-universal-time (get-universal-time))
(declare (ignorable sec))
(format t "~4D-~2,'0D-~2,'0D ~2,'0D:~2,'0D~%"
year mon date hour min))

谁让CL没有内置像Python的datetime模块这般完善的功能呢。不过,借助format~/命令,我们可以在控制字符串中写上要调用的自定义函数,来深度定制打印出来的内容。以打印上述格式的日期和时间为例,首先定义一个后续要用的自定义函数

1
2
3
4
5
6
7
(defun yyyy-mm-dd-HH-MM (dest arg is-colon-p is-at-p &rest args)
(declare (ignorable args is-at-p is-colon-p))
(multiple-value-bind (sec min hour date mon year)
(decode-universal-time arg)
(declare (ignorable sec))
(format dest "~4D-~2,'0D-~2,'0D ~2,'0D:~2,'0D~%"
year mon date hour min)))

然后便可以直接在控制字符串中使用它的名字

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扩展

创建VS Code扩展的项目

要想入门VS Code扩展的开发,官方便提供了一份不错的教程。一个扩展有许多的“八股文”代码,可以用yogenerator-code来快速生成

1
2
npm install -g yo generator-code
yo code

到这里,便得到了一个名为helloworld的目录了。用VS Code打开它,接下来要在其中大展身手。

实现将光标所在行垂直居中的功能

VS Code扩展的核心逻辑定义在文件src/extension.ts中。在yo生成的示例代码中,用registerCommand注册了一个名为helloworld.helloWorld的命令,其逻辑是简单地在右下角弹出一句Hello VS Code from HelloWorld!。这个回调函数,便是业务逻辑的落脚点。

要想实现将光标所在行滚动到中间的功能,首先要知道VS Code为开发者提供了哪些支持。在摸索了一通从VS CodeAPI文档后,我有了以下的线索:

  1. 通过vscode.window.activeTextEditor可以取得当前聚焦的编辑器——其值可能为空(undefined);
  2. TextEditor实例的属性.selection.active可以取得当前光标的位置;
  3. TextEditor实例有一个方法revealRange可以滚动文本来改变展示的范围,它需要一个vscode.Range类的实例,以及一个vscode.TextEditorRevealType类型的枚举值;
  4. vscode.TextEditorRevealType.InCenter的效果是将所给定的范围展示在中间,vscode.TextEditorRevealType.AtTop则是置顶。

有了这些知识储备,实现这样的一个回调函数便是信手拈来的事情了

1
2
3
4
5
6
7
8
function recenterTop() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const cursorPosition = editor.selection.active;
editor.revealRange(new vscode.Range(cursorPosition, cursorPosition), vscode.TextEditorRevealType.InCenter);
}

由于暂时没有配置该命令的快捷键,只能用VS Code的命令面板来调用

实现将光标所在行置顶的功能

接下来我将实现连续调用两次helloworld.helloWorld命令,把光标所在行滚动到顶部的效果。在Emacs中,可以很轻松地知道一个命令是否被连续运行——Emacs有一个名为last-command的变量存储着上一个命令的名称,只需要检查其是否等于recenter-top-bottom即可。但VS Code没有暴露这么强大的功能,只能另辟蹊径。

我的策略是,如果调用helloworld.helloWorld时光标的位置,与上一次调用该命令时的位置相同,就认为是连续调用。为此,需要两个在函数recenterTop之外定义的变量:

  1. previousPosition负责记录上一次调用recenterTop时光标的位置,它的初始值为null
  2. revealType存储着上一次调整展示范围时传递给TextEditor实例的revealRange方法的第二个参数的值,它的初始值也为null

我的目标是尽量模拟Emacs中的recenter-top-bottom所具备的、交替使用居中、置顶效果的特点,因此:

  1. 如果revealTypenull,意味着这是第一次调用recenterTop,那么效果便是居中。否则;
  2. 如果这一次与上一次的光标位置不同,意味着在上一次调用recenterTop后调用过其它命令,效果依然是居中。否则;
  3. 如果revealType已经是居中了,就改为置顶。否则;
  4. revealType改为居中。

Talk is cheap. Show me the code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let previousPosition: null|vscode.Position = null;
let revealType: null|vscode.TextEditorRevealType = null;

function recenterTop() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
return;
}
const cursorPosition = editor.selection.active;
if (!revealType) {
revealType = vscode.TextEditorRevealType.InCenter;
} else if (previousPosition && !cursorPosition.isEqual(previousPosition)) {
revealType = vscode.TextEditorRevealType.InCenter;
} else if (revealType === vscode.TextEditorRevealType.InCenter) {
revealType = vscode.TextEditorRevealType.AtTop;
} else {
revealType = vscode.TextEditorRevealType.InCenter;
}
previousPosition = cursorPosition;
editor.revealRange(new vscode.Range(cursorPosition, cursorPosition), revealType);
}

定义快捷键

通过命令面板来使用不是我的最终目标,通过快捷键才是。根据VS Code文档可以知道,只要在package.jsoncontributes对象中,新增名为keybindings的属性,并定义命令及按键序列即可。

1
2
3
4
5
6
7
8
9
{
// 此处省略其它不必要的属性
"contributes": {
"keybindings":{ // 新增属性
"command": "helloworld.helloWorld",
"key": "ctrl+l"
}
}
}

后记

如果看过我之前的文章《手指疼,写点代码缓解一下》的读者应当会记得,我已经从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是什么?

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.

  1. 它运行在OS X上——现在应该叫macOS;
  2. 它是用来自动化操作的——就像系统内置的Automator或第三方的Alfred Workflow那样;
  3. 它的原理是将操作系统的功能封装成了可以用Lua代码调用的模块;

例如下面的代码

1
2
3
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "E", function()
hs.alert.show("Hello World!")
end)

就可以让使用者在按下组合键⌘⌥⌃e的时候,在屏幕正中间显示Hello World!这段文本

为什么用Hammerspoon?

Hammerspoon正好可以解决我的问题,它的hs.window模块既可以让使用者遍历所有打开的窗口(用hs.window.allWindows函数),也可以聚焦到指定的窗口上(用focus方法)。有了它们,将Emacs调到最前面(front-most)来也就是水到渠成的事情了:

  1. 调用函数hs.window.allWindows函数,获得所有窗口的列表;
  2. 逐个检查列表中的窗口对象,如果属于Emacs的,就调用窗口的方法focus,并跳出循环。

剩下的两个问题便是:

  1. Emacsbundle ID是什么;
  2. 如何知道一个窗口对象的bundle ID

Emacs的bundle ID

Bundle ID可以在macOS中独一无二地标识一个应用。要想知道Emacsbundle ID是什么,只需要打开文件/Applications/Emacs.app/Contents/Info.plist,看看其中键为CFBundleIdentifier的值即可。

1
2
3
4
➜  Contents grep -A 1 'CFBundleIdentifier' Info.plist
<key>CFBundleIdentifier</key>
<string>org.gnu.Emacs</string>
➜ Contents

可以看到,Emacsbundle IDorg.gnu.Emacs

来点Lua代码吧

有了Emacsbundle ID,接下来就可以在Hammerspoon中定义快捷键了。由于最后会通过Touch Bar上的按钮来触发这组快捷键,复杂点也不要紧,因此我直接沿用了Hammerspoon的入门指引中作为例子的⌘⌥⌃w

1
2
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
end)

为了在一个循环中逐个遍历窗口对象,将hs.window.allWindows的返回值保存到一个局部变量中

1
2
3
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
end)

照着简书上的这篇文章,依葫芦画瓢地用forpairs来遍历变量windows

1
2
3
4
5
6
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
end
end)

窗口自身没有bundle ID,为此需要先获取窗口所属的应用。查看文档可以知道,有一个application方法正是用来获取应用对象的

1
2
3
4
5
6
7
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
local app = win:application()
end
end)

调用allWindows时使用的是英文句号(.),调用application则是用冒号(:),这正是Lua中调用函数与方法时语法上的差异。

再用应用的bundleID方法获得它的bundle ID

1
2
3
4
5
6
7
8
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
local app = win:application()
local bundleID = app:bundleID()
end
end)

现在,只要变量bundleID等于Emacsbundle ID就可以聚焦到当前遍历的窗口上了

1
2
3
4
5
6
7
8
9
10
11
hs.hotkey.bind({"cmd", "alt", "ctrl"}, "W", function()
local windows = hs.window.allWindows()
-- 在Lua中遍历表的方法:https://www.jianshu.com/p/de5a4b132918
for _, win in pairs(windows) do
local app = win:application()
local bundleID = app:bundleID()
if bundleID == "org.gnu.Emacs" then
win:focus()
end
end
end)

让Touch Bar按钮触发这一切

只需要在BetterTouchTools中配置一下即可

这个方法比此前唤起/Applications/Emacs.app的方式更好,因为它只依赖于Emacs逻辑上亘古不变的东西——bundle ID,而不依赖于其物理上的安装位置。