format,不只是格式化

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

序言

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

def foo(* args):
    print(args)

而在CL中则写成

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

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

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

而在CL中则是

(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

print('{:0>8b}'.format(5))

format
函数也可以做到同样的事情

(format t "~8,'0B" 5)

到这里为止,你可能会觉得 format
的控制字符串,不过就是将花括号去掉、冒号换成波浪线,以及参数语法不一样的 format
方法的翻版罢了。

接下来,让我们进入 format
的黑科技领域。

format
的高级用法

进制转换

前面列举了打印二、八、十,以及十六进制的命令,但 format
还支持其它的进制。使用命令 ~R
搭配参数, format
可以打印数字从2到36进制的所有形态。

(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
会把数字打印成英文单词

(format t "~R~%" 123) ; 打印出one hundred twenty-three

甚至可以让 format
打印罗马数字,只要加上 @
这个修饰符即可

(format t "~@R~%" 123) ; 打印出CXXIII

天晓得为什么要内置这么冷门的功能。

大小写转换

你,作为一名细心的读者,可能留意到了, format
~X
只能打印出大写字母,而在Python的 format
方法中, {:x}
可以输出小写字母的十六进制数字。即使你在 format
函数中使用 ~x
也是无效的,因为命令是大小写不敏感的(case insensitive)。

那要怎么实现打印小写字母的十六进制数字呢?答案是使用新的命令 ~(
,以及它配套的命令 ~)

(format t "~(~X~)~%" 26) ; 打印1a

配合 :
@
修饰符,一共可以实现四种大小写风格

(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代码可以让数字在八个宽度中左对齐

(format t "|~8|" 5)

打印内容为 |101 |
~<
跟前面提到的其它命令不一样,它不消耗控制字符串之后的参数,它只控制 ~<
~>
之间的字符串的布局。这意味着,即使 ~<
~>
之间是字符串常量,它也可以起作用。

(format t "|~8,,,'-|" 5)

上面的代码运行后会打印出 |---hello|
:8表示用于打印的最小宽度;三个逗号( ,
)之间为空,表示忽略 ~<
的第二和第三个参数;第四个参数控制着打印结果中用于填充的字符,由于 -
不是数字,因此需要加上单引号前缀; ~;
是内部的分隔符,由于它的存在, hello
成了最右侧的字符串,因此会被右对齐。

如果 ~<
~>
之间的内容被 ~;
分隔成了三部分,还可以实现左对齐、居中对齐,以及右对齐的效果

(format t "|~24|") ; 打印出|left    middle     right|

跳转

通常情况下,控制字符串中的命令会消耗参数,比如 ~B
~D
等命令。也有像 ~<
这样不消耗参数的命令。但有的命令甚至可以做到“一参多用”,那就是 ~*
。比如,给 ~*
加上冒号修饰,就可以让上一个被消耗的参数重新被消耗一遍

(format t "~8D~:*~8D~8D~%" 1 2) ; 打印出       1       1       2

~8D
消耗了参数1之后, ~:*
让下一个被消耗的参数重新指向了1,因此第二个 ~8D
拿到的参数仍然是1,最后一个拿到了2。尽管控制字符串中看起来有三个 ~D
命令而参数只有两个,却依然可以正常打印。

format
的文档中一个不错的例子,就是让 ~*
~P
搭配使用。 ~P
可以根据它对应的参数是否大于1,来打印出字母 s
或者什么都不打印。配合 ~:*
就可以实现根据参数打印出单词的单数或复数形式的功能

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

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

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

条件打印

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

(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
)打印成单词 true
false

(format t "~:[false~;true~]" nil) ; 打印false

循环打印

圆括号和方括号都用了,又怎么能少了花括号呢。没错, ~{
也是一个命令,它的作用是遍历列表。例如,想要打印出一个列表中的每个元素,并且两两之间用逗号和空格分开的话,可以用下列代码

(format t "~{~D~^, ~}" '(1 2 3)) ; 打印出1, 2, 3

~{
~}
之间也可以有不止一个命令,例如下列代码中每次会消耗列表中的两个元素

(format t "{~{\"~A\": ~D~^, ~}}" '(:a 3 :b 2 :c 1))

打印结果为 {"A": 3, "B": 2, "C": 1}
。如果把这两个 format
表达式拆成用循环写的、不使用 format
的等价形式,大约是下面这样子

; 与(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
进程的形式,那么也许会这么写

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

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

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

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

(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
这样的日期和时间字符串,是一件比较麻烦的事情

(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
~/
命令,我们可以在控制字符串中写上要调用的自定义函数,来深度定制打印出来的内容。以打印上述格式的日期和时间为例,首先定义一个后续要用的自定义函数

(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)))

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

(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被称为可扩展的编程语言,这些平凡的功能却依旧无法方便地做到呢。