什麼是計數迴圈
計數迴圈就是從一個數字$i$開始一直遍歷到另一個數字$j$為止的迴圈過程。例如,下面的 Python 程式碼就會遍歷從 0 到 9 這 10 個整數並逐個列印它們
for i in range(10):
print(i)
如果是在 C 語言中實現同樣的功能,程式碼會更顯著一些
#include <stdio.h>
int main(int argc, char *argv[])
{
for (int i = 0; i < 10; i++) {
printf("%d\n", i);
}
return 0;
}
在 C 語言的例子中,顯式地指定了計數器變數i
從 0 開始並且在等於 10 的時候結束迴圈,比之 Python 版本更有迴圈的味道。
拆開迴圈計數的語法糖
使用 C 語言的while
語句同樣可以實現計數迴圈,示例程式碼如下
#include <stdio.h>
int main(int argc, char *argv[])
{
int i = 0;
while (i < 10) {
printf("%d\n", i);
i++;
}
return 0;
}
如果將while
也視為if
和goto
的語法糖的話,可以進一步將計數迴圈寫成更原始的形式
#include <stdio.h>
int main(int argc, char *argv[])
{
int i = 0;
label0:
if (i >= 10) {
goto label1;
}
printf("%d\n", i);
i++;
goto label0;
label1:
return 0;
}
Common Lisp 中的 go 與續延
在 Common Lisp 中也有與 C 語言的goto
特性相近的 special form,那就是tagbody
和go
。使用它們可以將 C 程式碼直白地翻譯為對應的 Common Lisp 版本
(let ((i 0))
(tagbody
label0
(when (>= i 10)
(go label1))
(format t "~D~%" i)
(incf i)
(go label0)
label1))
聰明的你一定已經發現了,此處的第二個符號label1
其實是絲毫不必要的,只要寫成下面的形式即可
(let ((i 0))
(tagbody
label0
(when (< i 10)
(format t "~D~%" i)
(incf i)
(go label0))))
這個形式不僅僅是更簡單了,而且它暴露出了一個事實:label0
所表示的,其實就是在將變數i
繫結為 0之後要執行的程式碼的位置。換句話說,它標識了一個續延(continuation)。
用 call/cc 重新實現計數迴圈
如果你用的語言中支援 first-class 的續延,那麼便可以用來實現計數迴圈,例如233-lisp。在 233-lisp 中,提供了特殊運算子call/cc
來捕捉當前續延物件,這個名字借鑑自 Scheme。藉助這個運算子,即便沒有tagbody
和go
,也可以實現計數迴圈。
在上面的程式碼中,call/cc
捕捉到的續延就是“賦值給區域性變數i
”。在將這個續延k
儲存到變數next
之後,用 0 初始化變數i
。之後只要i
還小於 10,就將它列印到標準輸出,並啟動儲存在了變數next
中的續延,回到給變數i
賦值的地方。此時傳遞給續延的引數為(+ i 1)
,就實現了變數i
的自增操作。當(< i 10)
不再成立時,也就不會啟動續延“回到過去”了,至此,程式結束。
在 233-lisp 中,將dotimes
作為一個內建的宏用call/cc
實現了一遍,參見這裡,其程式碼如下
(defun expand-dotimes-to-call/cc (expr)
"將 DOTIMES 語句 EXPR 編譯為等價的 CALL/CC 語句。"
(assert (eq (first expr) 'dotimes))
(destructuring-bind ((var count-form) &rest statements)
(rest expr)
(let ((a (gensym))
(count-form-result (gensym))
(next (gensym)))
`(let ((,count-form-result ,count-form)) ; 由於目前 LET 只支援一個繫結,因此這裡要寫多個 LET。
(let ((,next 0)) ; 由於 233-lisp 中尚未支援 NIL,因此這裡填個 0。
(let ((,var (call/cc (k)
(progn
(setf ,next k)
0)))) ; 計數迴圈從 0 開始。
(if (< ,var ,count-form-result)
(progn
,@statements
(,next (+ ,var 1)))
0))))))) ; 由於目前沒有 NIL,因此返回一個數字 0 來代替。
變數count-form-result
和next
分別表示在宏展開後的程式碼中的計數上限和被捕捉的續延。之所以讓它們以(gensym)
的方式來命名,是為了避免多次求值count-form
表示式,以及避免儲存續延的變數名恰好出乎意料地與statements
中的變數名衝突了,這也算是編寫 Common Lisp 的宏時的最佳實踐了。
後記
直接用call/cc
來一個個實現 Common Lisp 中的各種控制流還是太繁瑣了,更好的方案是用call/cc
先實現tagbody
和go
,然後再用後兩者繼續實現do
,最後用do
分別實現dolist
和dotimes
。當然了,這些都是後話了。