9

format,不只是格式化

 3 years ago
source link: https://liutos.github.io/2021/01/29/format,不只是格式化/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

《实战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<~B~;~>|" 5)

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

(format t "|~8,,,'-<~;hello~>|" 5)

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

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

(format t "|~24<left~;middle~;right~>|") ; 打印出|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 )打印成单词 truefalse

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK