使用 call/cc 實現計數迴圈

使用者bPGfS發表於2023-05-07

什麼是計數迴圈

計數迴圈就是從一個數字$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也視為ifgoto的語法糖的話,可以進一步將計數迴圈寫成更原始的形式

#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,那就是tagbodygo。使用它們可以將 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。藉助這個運算子,即便沒有tagbodygo,也可以實現計數迴圈。

用callcc模擬計數迴圈

在上面的程式碼中,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-resultnext分別表示在宏展開後的程式碼中的計數上限和被捕捉的續延。之所以讓它們以(gensym)的方式來命名,是為了避免多次求值count-form表示式,以及避免儲存續延的變數名恰好出乎意料地與statements中的變數名衝突了,這也算是編寫 Common Lisp 的宏時的最佳實踐了。

後記

直接用call/cc來一個個實現 Common Lisp 中的各種控制流還是太繁瑣了,更好的方案是用call/cc先實現tagbodygo,然後再用後兩者繼續實現do,最後用do分別實現dolistdotimes。當然了,這些都是後話了。

閱讀原文

相關文章