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

0%

“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。

Emacs的org-mode用的是一门叫Org的标记语言,正如大部分的标记语言那样,它也支持无序列表和检查清单——前者以-(一个连字符、一个空格)为前缀,后者以- [ ]- [x]为前缀(比无序列表多了一对方括号及中间的字母x

此外,org-mode还为编辑这两种列表提供了快速插入新一行的快捷键M-RET(即按住alt键并按下回车键)。如果光标位于无序列表中,那么新的一行将会自动插入-前缀。遗憾的是,如果光标位于检查清单中,那么新一行并没有自动插入一对方括号

每次都要手动敲入[ ]还挺繁琐的。好在这是Emacs,它是可扩展的、可定制的。只需敲几行代码,就可以让Emacs代劳输入方括号了。

Emacs的AOP特性——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
2
3
4
5
6
7
8
9
10
11
12
13
14
(cl-defun lt-around-org-insert-item (oldfunction &rest args)
"在调用了org-insert-item后识时务地追加 [ ]这样的内容。"
(let ((is-checkbox nil)
(line (buffer-substring-no-properties (line-beginning-position) (line-end-position))))
;; 检查当前行是否为checkbox
(when (string-match-p "- \\[.\\]" line)
(setf is-checkbox t))
;; 继续使用原来的org-insert-item插入文本
(apply oldfunction args)
;; 决定要不要追加“ [ ]”字符串
(when is-checkbox
(insert "[ ] "))))

(advice-add 'org-insert-item :around #'lt-around-org-insert-item)

这下子,M-RET对检查清单也一视同仁了

Common Lisp的method combination

advice-add:after:around,以及:before在Common Lisp中有着完全同名的等价物,只不过不是用一个叫advice-add的函数,而是喂给一个叫defmethod的宏。举个例子,用defmethod可以定义出一个多态的len函数,对不同类型的入参执行不同的逻辑

1
2
3
4
5
6
7
(defgeneric len (x))

(defmethod len ((x string))
(length x))

(defmethod len ((x hash-table))
(hash-table-count x))

然后为其中参数类型为字符串的特化版本定义对应的:after:around,以及:before修饰过的方法

1
2
3
4
5
6
7
8
9
10
11
(defmethod len :after ((x string))
(format t "after len~%"))

(defmethod len :around ((x string))
(format t "around中调用len前~%")
(prog1
(call-next-method)
(format t "around中调用len后~%")))

(defmethod len :before ((x string))
(format t "before len~%"))

这一系列方法的调用规则为:

  1. 先调用:around修饰的方法;
  2. 由于上述方法中调用了call-next-method,因此再调用:before修饰的方法;
  3. 调用不加修饰的方法(在CL中这称为primary方法);
  4. 再调用:after修饰的方法;
  5. 最后,又回到了:around中调用call-next-method的位置。

咋看之下,Emacs的advice-add支持的修饰符要多得多,实则不然。在CL中,:after:around,以及:before同属于一个名为standardmethod combination,而CL还内置了其它的method combination。在《Other method combinations》一节中,作者演示了prognlist的例子。

如果想要模拟Emacs的advice-add所支持的其它修饰符,那么就必须定义新的method combination了。

可编程的编程语言——define-method-combination

曾经我以为,defmethod只能接受:after:around,以及:before,认为这三个修饰符是必须在语言一级支持的特性。直到有一天我闯入了LispWorks的define-method-combination词条中,才发现它们也是三个平凡的修饰符而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(define-method-combination standard ()
((around (:around))
(before (:before))
(primary () :required t)
(after (:after)))
(flet ((call-methods (methods)
(mapcar #'(lambda (method)
`(call-method ,method))
methods)))
(let ((form (if (or before after (rest primary))
`(multiple-value-prog1
(progn ,@(call-methods before)
(call-method ,(first primary)
,(rest primary)))
,@(call-methods (reverse after)))
`(call-method ,(first primary)))))
(if around
`(call-method ,(first around)
(,@(rest around)
(make-method ,form)))
form))))

秉持“柿子要挑软的捏”的原则,让我来尝试模拟出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-whilebefore-while变量指向的是多个“可调用”的方法,但这里只调用“最具体”的一个。

给这个新的method combination取名为emacs-advice,其具体实现已是水到渠成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(define-method-combination emacs-advice ()
((after-while (:after-while))
(before-while (:before-while))
(primary () :required t))
(let ((after-while-fn (first after-while))
(before-while-fn (first before-while))
(result (gensym)))
`(let ((,result (when ,before-while-fn
(call-method ,before-while-fn))))
(when (or (null ,before-while-fn)
,result)
(let ((,result (call-method ,(first primary))))
(when (and ,result ,after-while-fn)
(call-method ,after-while-fn))
,result)))))

call-method(以及它的搭档make-method)是专门用于在define-method-combination中调用传入的方法的宏。

用一系列foobar方法来验证一番

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(defgeneric foobar (x)
(:method-combination emacs-advice))

(defmethod foobar (x)
'hello)

(defmethod foobar :after-while (x)
(declare (ignorable x))
(format t "for side effect~%"))

(defmethod foobar :before-while (x)
(evenp x))

(foobar 1) ;; 返回NIL
(foobar 2) ;; 打印“fo side effect”,并返回HELLO

后记

尽管我对CL赏识有加,但越是琢磨define-method-combination,就越会发现编程语言的能力是有极限的,除非超越编程语言。比如Emacs的advice-add所支持的:filter-args:filter-return就无法用define-method-combination优雅地实现出来——并不是完全不行,只不过需要将它们合并在由:around修饰的方法之中。

准备过互联网公司的服务端岗位面试的人,对于二叉树的三种遍历方式想必是如数家珍。假设以类BinaryTree定义一棵二叉树

1
2
3
4
5
class BinaryTree:
def __init__(self, left, right, value):
self.left = left
self.right = right
self.value = value

实现一个前序遍历的算法便是信手拈来的事情

1
2
3
4
5
6
7
def preorder_traversal(tree, func):
"""前序遍历二叉树的每个节点。"""
if tree is None:
return
func(tree.value)
preorder_traversal(tree.left, func)
preorder_traversal(tree.right, func)

随着行业曲率的增大,要求写出不使用递归的版本也没什么过分的

1
2
3
4
5
6
7
8
9
def iterative_preorder_traversal(tree, func):
nodes = [tree]
while len(nodes) > 0:
node = nodes.pop()
func(node)
if node.left is not None:
nodes.append(node.right)
if node.left is not None:
nodes.append(node.left)

一直以来,我觉得这种用一个显式的栈来代替递归过程中隐式的栈的做法就是镜花水月。但最近却找到了它的一个用武之地——用于实现iterator

iterator是个啥?

这年头,iterator已经不是什么新鲜事物了,许多语言中都有支持,维基百科上有一份清单列出了比较知名的语言的iterator特性。按照Python官方的术语表中的定义iterator表示一个数据流,反复调用其__next__方法可以一个接一个地返回流中的下一项数据。将内置函数iter作用于liststrtuple类型的对象,可以获得相应的迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat get_iter.py
# -*- coding: utf8 -*-
if __name__ == '__main__':
values = [
[1, 2, 3],
'Hello, world!',
(True, None),
]
for v in values:
print('type of iter({}) is {}'.format(v, type(iter(v))))
$ python get_iter.py
type of iter([1, 2, 3]) is <class 'list_iterator'>
type of iter(Hello, world!) is <class 'str_iterator'>
type of iter((True, None)) is <class 'tuple_iterator'>

写一个前序遍历的iterator

一个iterator对象必须要实现__iter____next__方法:

  • __iter__只需要返回iterator对象自身即可;
  • 顾名思义,__next__负责返回下一个元素。

仔细观察一下前文中的iterative_preorder_traversal函数可以看出:

  • nodes = [tree]属于初始化逻辑;
  • len(nodes) > 0用于判断是应当抛出StopIteration,还是应当继续返回下一个值(nodes.pop());
  • 最后四行就是负责填充nodes,好让它可以在下一次调用__next__的时候有值可以返回的。

到这里,iterator的具体实现代码已经呼之欲出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BinaryTreePreorderIterator:
def __init__(self, root):
nodes = []
if root is not None:
nodes.append(root)
self.nodes = nodes

def __iter__(self):
return self

def __next__(self):
if len(self.nodes) == 0:
raise StopIteration
node = self.nodes.pop()
if node.right is not None:
self.nodes.append(node.right)
if node.left is not None:
self.nodes.append(node.left)
return node.value

构造一棵这样的满二叉树

BinaryTreePreorderIterator可以正确地打印出每一个节点的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if __name__ == '__main__':
tree = BinaryTree(
BinaryTree(
BinaryTree(None, None, 1),
BinaryTree(None, None, 3),
2,
),
BinaryTree(
BinaryTree(None, None, 5),
BinaryTree(None, None, 7),
6,
),
4,
)
for n in BinaryTreePreorderIterator(tree):
print('{}\t'.format(n), end='')
# 打印内容为:4 2 1 3 6 5 7

iterator的优势

显然,iterator比起preorder_traversal更为灵活——很容易在for-in循环内添加各种各样的控制逻辑:用continue跳过一些值,或者用break提前结束遍历过程。这些在函数preorder_traversal中做起来会比较别扭。

聪明的你应该已经发现了,大可不必将preorder_traversal拆解到一个构造方法和一个__next__方法中。用generator写起来明明更加直观

1
2
3
4
5
6
7
8
9
10
def preorder_generator(tree):
"""返回一个能够以前序遍历的次序遍历二叉树节点的generator。"""
nodes = [tree]
while len(nodes) > 0:
node = nodes.pop()
yield node.value
if node.left is not None:
nodes.append(node.right)
if node.left is not None:
nodes.append(node.left)

但是,很多语言并不支持generator。与之相比,iterator要亲民得多,更容易移植。例如,即使是Common Lisp这种一穷二白的语言,也可以实现和Python的iterator以及for类似的效果

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
(in-package #:cl-user)

(defpackage #:com.liutos.binary-tree
(:use #:cl))

(in-package #:com.liutos.binary-tree)

(defclass preorder-iterator ()
((nodes
:initform nil)
(tree
:initarg :tree))
(:documentation "前序遍历二叉树的迭代器"))

(defmethod initialize-instance :after ((instance preorder-iterator) &key)
(with-slots (nodes tree)
instance
(when tree
(push tree nodes))))

(defgeneric next (iterator)
(:documentation "返回迭代器的下一个值。"))

(define-condition stop-iteration (error)
()
(:documentation "Python中StopIteration异常的等价物。"))

(defmethod next ((iterator preorder-iterator))
(with-slots (nodes) iterator
(when (null nodes)
(error 'stop-iteration))

(let ((node (pop nodes)))
;; 一个节点的结构为:(值 左子树 右子树)
(when (third node)
(push (third node) nodes))
(when (second node)
(push (second node) nodes))
(first node))))

(defmacro for-in (var iterator &body forms)
"将iterator中的值逐个绑定到变量var上,并执行forms中的表达式。"
(let ((iter (gensym)))
`(let ((,iter ,iterator))
(handler-case
(loop
(let ((,var (next ,iter)))
,@forms))
(stop-iteration (c)
(declare (ignorable c)))))))

(defparameter *tree*
'(4 (2 (1 nil nil) (3 nil nil)) (6 (5 nil nil) (7 nil nil))))

(defun test-preorder-iterator ()
"测试前序遍历迭代器。"
(for-in n (make-instance 'preorder-iterator
:tree *tree*)
(format t "~D~C" n #\Tab)))

后记

中序遍历和后序遍历也可以写成迭代器,证明略。

准备过互联网公司的服务端岗位面试的人,对Redis中的5种数据类型想必是如数家珍。而网上很多面试题里也会出现这道题目

来自https://blog.csdn.net/ThinkWon/article/details/103522351

来自https://juejin.cn/post/6844903982066827277

来自https://mikechen.cc/3313.html

随着行业曲率的增大,光是知道有这些数据类型已经不够了,还得知道同一个类型也有不同的底层数据结构。例如同样是string类型,不同内容或不同长度会采用不同的编码方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> SET key1 "1"
OK
127.0.0.1:6379> SET key2 "value"
OK
127.0.0.1:6379> SET key3 "Any sufficiently complicated C or Fortran program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp."
OK
127.0.0.1:6379> TYPE key1
string
127.0.0.1:6379> TYPE key2
string
127.0.0.1:6379> TYPE key3
string
127.0.0.1:6379> OBJECT ENCODING key1
"int"
127.0.0.1:6379> OBJECT ENCODING key2
"embstr"
127.0.0.1:6379> OBJECT ENCODING key3
"raw"

hash类型也有两种底层实现

1
2
3
4
5
6
7
8
127.0.0.1:6379>  HSET myhash field1 "Hello"
(integer) 1
127.0.0.1:6379> HSET myhash2 field1 "Any sufficiently complicated C or Fortran program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp."
(integer) 1
127.0.0.1:6379> OBJECT ENCODING myhash
"ziplist"
127.0.0.1:6379> OBJECT ENCODING myhash2
"hashtable"

不知道你是否曾经好奇过,上文中的key1key2key3myhash,以及myhash2这些键,与它们各自的值(前三个为string,后两个为hash)之间的关系又是存储在什么数据结构中的呢?

答案在意料之外,情理之中:键与值的关系,也是存储在一张哈希表中的,并且正是上文中的hashtable

求证的办法当然是阅读Redis的源代码。

Redis命令的派发逻辑

阅读Redis的源码是比较轻松愉快的,一是因为其源码由简单易懂的C语言编写,二是因为源码仓库的README.md中对内部实现做了一番高屋建瓴的介绍。在README.mdserver.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
2
3
{"get",getCommand,2,
"read-only fast @string",
0,NULL,1,1,1,0,0,0},

身为一名老解释器爱好者,对这种套路的代码当然是不会陌生的。我也曾在写过的、跑不起来的玩具解释器上用过类似的手法

Redis收到一道需要执行的命令后,根据命令的名字用lookupCommand找到一个命令(是个struct redisCommand类型的结构体),然后call函数做的事情就是调用它的proc成员所指向的函数而已

1
c->cmd->proc(c);

那么接下来,就要看看SET命令对应的C函数究竟做了些什么了。

SET命令的实现

redisCommonTable中下标为2的元素正是SET命令的定义

1
2
3
4
5
/* Note that we can't flag set as fast, since it may perform an
* implicit DEL of a large key. */
{"set",setCommand,-3,
"write use-memory @string",
0,NULL,1,1,1,0,0,0},

其中函数setCommand定义在文件t_string.c中,它根据参数中是否有传入NXXXEX等选项计算出一个flags后,便调用setGenericCommand——顾名思义,这是一个通用的SET命令,它同时被SETSETNXSETEX,以及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会调用dbAdddbOverwrite。而在dbAdd中,最终调用了dictAdd将键与值存入数据库中。

1
2
3
4
5
6
7
8
9
/* Add an element to the target hash table */
int dictAdd(dict *d, void *key, void *val)
{
dictEntry *entry = dictAddRaw(d,key,NULL);

if (!entry) return DICT_ERR;
dictSetVal(d, entry, val);
return DICT_OK;
}

现在我们知道了,使用SET命令时传入的keyvalue,是存储在一个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转换为hashtableserver.hash_max_ziplist_value的值在文件config.c中通过宏设置,默认值为64——这正是上文中myhash2所对应的值的编码为hashtable的原因。

将思绪拉回到函数hsetCommand中。做完编码的转换后,它调用函数hashTypeSet,在编码为hashtable的世界线中,同样调用了dictAdd实现往哈希表中写入键值对。

殊途同归

结论

因此,在Redis中用以维持每一个键与其对应的值——这些值也许是string,也许是list,也许是hash——的关系的数据结构,与Redis中的一系列操作哈希表的命令——也许是HSET、也许HGET,也许是HDEL——所用的数据结构,不能说是毫不相关,起码是一模一样。

通常在糊业务代码的时候,不管是函数、方法,还是宏,都只会有一个返回值。比如在C语言用于检查一个字符是否为阿拉伯数字的isdigit函数就只会返回是(1)或否(0)

1
2
3
4
5
6
7
8
9
10
#include <ctype.h>
#include <stdio.h>

int
main(int argc, char *argv[])
{
char c = 'a';
printf("isdigit('%c') is %d\n", c, isdigit(c));
return 0;
}

但有时候如果一个函数、方法,或宏可以返回多个值的话会更加方便。例如,在Python中dict类型有一个实例方法get,它可以取得dict实例中与给定的键对应的值。但如果有一个键在字典中的值为None,那么光凭get的返回值无法准确判断这个键是否存在——除非你给它一个非None的默认值

1
2
3
4
5
6
7
8
9
10
11
# -*- coding: utf8 -*-
def test(d, key):
print("d.get('{0}') is {1}\t'{0}' in d is {2}".format(key, d.get(key), key in d))

if __name__ == '__main__':
d = {
'foo': 'bar',
'baz': None,
}
test(d, 'foo')
test(d, 'baz')

发展了这么多年的编程语言,又怎么会连一次调用、多值返回这么简单的事情都做不到呢。事实上,有各种各样、各显神通的返回多个值的方法,我给其中的一些做了个分类

Lisp的multiple-value-bind

Common Lisp(简称为CL)的多重返回值当之无愧是其中最正统、最好用的实现方式。以它的内置函数truncate为例,它的第一个返回值为第一个参数除以第二个参数的商,第二个返回值为对应的余数

1
2
3
CL-USER> (truncate 10 3)
3
1

如果不加修饰地调用truncate,就像其它只返回一个值的函数一样,也只会拿到一个返回值

1
2
3
CL-USER> (let ((q (truncate 10 3)))
(format t "q = ~D~%" q))
q = 3

除非用multiple-value-bind来捕获一个函数产生的所有返回值

1
2
3
4
CL-USER> (multiple-value-bind (q r)
(truncate 10 3)
(format t "q = ~D~8Tr = ~D~%" q r))
q = 3 r = 1

CL的方案的优点在于它十分灵活。即使将一个函数从返回单个值改为返回多个值,也不会导致原本调用该函数的位置要全部修改一遍——对修改封闭,对扩展开放(误)。

Go的多重返回值

踩在C语言肩膀上的Go也能够从函数中返回多个值。在io/ioutil包的官方文档中有大量的例子,比如用ReadAll方法从字符串衍生的流中读取全部内容,就会返回两个值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"io/ioutil"
"log"
"strings"
)

func main() {
s := "Hello, world!"
reader := strings.NewReader(s)
bytes, err := ioutil.ReadAll(reader)
if err != nil {
log.Fatal(err)
}
fmt.Printf("bytes is %s", bytes)
}

Go以这种方式取代了C语言中用返回值表达成功与否、再通过指针传出读到的数据的风格。由于这个模式在有用的Go程序中到处出现,因此Gopher们用的都是定制的键盘(误)

不同于前文的multiple-value-bind,如果一个函数或方法返回多个值,那么调用者必须捕获每一个值,否则编译无法通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➜  try cat try_read_all_ignore_err.go
package main

import (
"fmt"
"io/ioutil"
"strings"
)

func main() {
s := "Hello, world!"
reader := strings.NewReader(s)
bytes := ioutil.ReadAll(reader)
fmt.Printf("bytes is %s", bytes)
}
➜ try go build try_read_all_ignore_err.go
# command-line-arguments
./try_read_all_ignore_err.go:12:8: assignment mismatch: 1 variable but ioutil.ReadAll returns 2 values

这一要求也是合理的,毕竟多重返回值机制主要用于向调用者传递出错原因——既然可能出错,那么就必须要检查一番。

Python和Rust的解构

就像CL的truncate函数一样,Python中的函数divmod也可以同时返回两个数相除的商和余数,并且咋看之下也是返回多个值的形式

1
2
3
4
# -*- coding: utf8 -*-
if __name__ == '__main__':
q, r = divmod(10, 3)
print('q = {}\tr = {}'.format(q, r))

但本质上,这是因为Python支持解构,同时divmod返回的是一个由商和余数组成的元组。这样的做法与CL的真·奥义·多重返回值的差异在于,如果只想要divmod的第一个值,那么等号左侧也要写成对应的结构

1
2
3
4
# -*- coding: utf8 -*-
if __name__ == '__main__':
q, _ = divmod(10, 3)
print('q = {}'.format(q))

在支持解构的语言中都可以模仿出多重返回值,例如Rust

1
2
3
4
5
6
7
8
fn divmod(a: u32, b: u32) -> (u32, u32) {
(a / b, a % b)
}

fn main() {
let (q, r) = divmod(10, 3);
println!("q = {}\tr = {}", q, r);
}

Prolog的归一

到了Prolog这里,画风就有点不一样了。首先Prolog既没有函数,也没有方法,更没有宏。在Prolog中,像length/2member/2这样的东西叫做functor,它们之于Prolog中的列表,就犹如CL的lengthmember之于列表、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
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <sys/stat.h>

int
main(int argc, char *argv[])
{
char *path = "./try_stat.c";
struct stat buf;
stat(path, &buf);
printf("inode's number of %s is %llu\n", path, buf.st_ino);
return 0;
}

查看man 2 stat可以知道struct stat类型中有非常多的内容,这显然也是一种多重返回值。同样的手法,在Go中也可以运用,例如用于把从数据库中读取出来的行的数据写入目标数据结构的Scan方法

最后,如果只要能让调用者感知就行,那么全局变量未尝不是一种通用的多重返回值机制。例如在C语言中的strtol函数,就会在无法转换出任何数字的时候返回0并设置errno,因此检查errno是必须的步骤

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/errno.h>

void try_conversion(const char *str)
{
long num = strtol(str, NULL, 10);
if (errno == EINVAL || errno == ERANGE)
{
char message[64];
snprintf(message, sizeof(message), "strtol(\"%s\")", str);
perror(message);
return;
}
printf("strtol(\"%s\") is %ld\n", str, num);
}

int
main(int argc, char *argv[])
{
try_conversion("233");
try_conversion("0");
try_conversion("lisp");
return 0;
}

鉴于errno是一个全局变量,strtol的使用者完全有可能忘记要检查。相比之下,Go的strconv包的函数都将转换过程中的错误以第二个参数的形式返回给调用者,用起来更安全。

后记

按照《代码写得不好,不要总觉得是自己抽象得不好》这篇文章的说法,代码写成什么样子完全是由产品经理决定的。但产品经理又怎么会在意你用的技术是怎么实现多重返回值的呢。综上所述,这个特性没用(误)。

全文完。

序言

在旧文《如何写一个命令行的秒表》中,借助命令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)

但效果却很显著

全文完