《實戰 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[1]能夠格式化對應類型的數據,控制字符串中的命令也有很多種,常見的有:
列印二進位數字的~B,例如(format t "~B" 5)會列印出 101;列印八進位數字的~O,例如(format t "~O" 8)會列印出 10;列印十六進位數字的~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)列印成單詞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函數的詳細介紹[2],CL 愛好者一定不容錯過。
最後,其實 Python 跟 CL 並不怎麼像。每每看到 Python 中的__eq__、__ge__,以及__len__等方法的巧妙運用時,身為一名 Common Lisp 愛好者,我都會流露出羨慕的神情。縱然 CL 被稱為可擴展的程式語言,這些平凡的功能卻依舊無法方便地做到呢。
如果你想要和我交流,歡迎點擊閱讀原文到我的博客上發表評論。
參考資料[1]format_spec: https://docs.python.org/3/library/string.html#formatspec
[2]詳細介紹: http://www.ai.mit.edu/projects/iiip/doc/CommonLISP/HyperSpec/Body/sec_22-3.html