1962:LISP 1.5——GC 與巨集正式化#
事件#
1962年,MIT 出版 《LISP 1.5 Programmer's Manual》(McCarthy、Abrahams、Edwards、Levin、Levin 著),這是第一個正式且廣泛使用的 LISP 版本,被安裝在數十台電腦上。LISP 1.5 將垃圾回收(GC)、巨集系統、一級函數與高階函數正式化,奠定了 LISP 家族的標準形態。
語法/特性加入的理論與實用原因#
1. 垃圾回收(Garbage Collection)#
- 理論原因:列表是動態配置的遞迴結構,函數式風格大量產生中間列表;若無法自動回收,純函數式運算在記憶體上不可行。
- 實用原因:McCarthy 在 1960 年的論文中已提出想法,1962 年 LISP 1.5 手冊正式化 標記-清除(mark-sweep) 演算法——從根集合標記所有可達的 cons cell,再清除未標記者。 GC 的核心概念:CONS 不斷產生新 cell,用不到的舊 cell 由標記-清除自動回收——程式設計師完全不必(也不能)插手:
;; 標記-清除(mark-sweep)概念示意
;; 假設記憶體是一排 cons cell:
[a|•]→[b|/] [c|•]→[d|/] [e|/]
↑根集合可達 ↑沒有任何變數指向 c、d、e
;; 1. 標記階段:從根集合(如變數 x 的值)出發,標記所有可達 cell
;; → a、b 被標記
;; 2. 清除階段:掃過整個記憶體,把未標記的 c、d、e 放回自由串列
;; → c、d、e 的空間自動回收,可供之後的 CONS 重複使用
;; 程式面:中間列表用完即丟,GC 在背後默默善後
(setq x (mapcar (function (lambda (n) (* n n))) '(1 2 3 4)))
;; 運算過程產生的 4 個中間 cell 與臨時結構,
;; 全都不需手動釋放——這是史上第一個自動記憶體管理
對照組:在沒有 GC 的語言(如 C)裡,同樣的結構要手動配置與釋放,漏掉 free 就是記憶體洩漏:
/* C(1972,為對照而引用):手動記憶體管理 */
struct node *p = malloc(sizeof(struct node));
/* ...使用 p... */
free(p); /* 忘了這行 → 記憶體洩漏;提早這行 → 懸吊指標 */
2. 巨集(Macro)系統#
- 理論原因:程式即資料(S-表達式同構性)意味著程式可以被程式轉換——巨集就是「編譯期對程式做 β-歸約式的改寫」。
- 實用原因:LISP 1.5 提供巨集機制讓使用者以 LISP 本身擴充語言,新增控制結構與語法糖,而不需修改直譯器。
- 彌補缺陷:傳統語言的語法是固定的,使用者無法新增語言結構;巨集讓語言成為「可自我擴充的語言」,這是 LISP 至今的招牌特性。
(defmacro when (test &rest body)
`(cond (,test ,@body)))
巨集在編譯期對程式做改寫:呼叫 (when ...) 時,巨集函數先算出展開式,再求值展開後的程式——語言就這樣被使用者擴充:
;; unless 巨集:與 when 相反的測試
(defmacro unless (test &rest body)
`(cond ((not ,test) ,@body)))
;; 展開過程(macroexpand 所見):
;; (unless (= x 0) (print 'not-zero))
;; ⇒ (cond ((not (= x 0)) (print 'not-zero)))
;; ——本來不存在的語法,被使用者「發明」出來
(when (> x 0) (print 'positive))
(unless (null l) (print (car l)))
LISP 1.5 時代巨集甚至可以自己動手組 S-表達式(不用反引號語法,當年正是這樣寫的):
;; LISP 1.5 風格:手動用 list/cons 組出展開式
(defmacro while (test &rest body)
(list 'cond
(list (list 'not test) nil)
(cons 'progn (append body
(list (cons 'while (cons test body)))))))
;; 展開成遞迴呼叫自身,模擬出迴圈——控制結構由函式庫供應
3. 函數作為一級值與高階函數#
- 理論原因:λ 演算中函數本來就是一級公民——可以傳遞、傳回、組合。LISP 1.5 讓這點真正落地。
- 實用原因:列表處理天然需要「把函數套用到每個元素」——
mapcar等高階函數成為標準庫的一部分。 - 彌補缺陷:FORTRAN 的副程式不能作為資料傳遞;一級函數讓控制抽象(如 map、filter)成為可能。
;; 高階函數:把函數當資料傳遞
(mapcar (function (lambda (x) (* x x))) '(1 2 3 4))
; => (1 4 9 16)
;; filter 風格:選出偶數
(defun evens (l)
(cond ((null l) nil)
((= (rem (car l) 2) 0) (cons (car l) (evens (cdr l))))
(t (evens (cdr l)))))
(evens '(1 2 3 4 5 6)) ; => (2 4 6)
;; 函數作為引數傳入另一個高階函數
(defun apply-twice (f x)
(funcall f (funcall f x)))
(apply-twice (function (lambda (n) (* 10 n))) 3) ; => 300
對照組:FORTRAN 的副程式是固定的位址,無法作為值傳遞——「把平方函數套到每個元素」只能為每種運算各寫一個迴圈:
C FORTRAN:每種運算都要一個專屬副程式與迴圈
SUBROUTINE SQUARE (A, N)
DIMENSION A(100)
DO 10 I = 1, N
A(I) = A(I) * A(I)
10 CONTINUE
RETURN
END
(mapcar (function (lambda (x) (* x x))) '(1 2 3 4))
; => (1 4 9 16)
4. FUNARG 問題——作用域之爭的起點#
- 理論原因:λ 演算採詞法作用域(變數的意義由定義處決定),但 LISP 1.5 的實作採動態作用域(由呼叫處決定),兩者理論上不一致。
- 實用原因:當函數可作為傳回值(funarg),動態作用域會使自由變數指向呼叫時的環境而非定義時的環境,產生難以預測的行為。
- 彌補缺陷:FUNARG 問題暴露了動態作用域的缺陷,直接引發了長達二十年的詞法 vs 動態作用域之爭,直到 1975 年 Scheme 才徹底解決。
動態作用域的陷阱實例:同一個函數、不同的呼叫環境,自由變數的意義竟不相同——函數的行為無法脫離呼叫處預測:
;; LISP 1.5 動態作用域:自由變數 z 在「呼叫時」才綁定
(defun add-z (x) (+ x z)) ; z 是自由變數
;; 呼叫環境一:恰好有 z
(setq z 100)
(add-z 1) ; => 101 (z 來自呼叫處)
;; 呼叫環境二:換了呼叫者,行為就變了
(defun caller-without-z (f) (funcall f 1))
(caller-without-z (function add-z)) ; z 未定義 → 錯誤或得到不可預測的值
對照組:λ 演算(及後來的 Scheme)採詞法作用域,同一個閉包不管從哪裡呼叫,結果都一樣:
;; Scheme 詞法作用域:z 在定義處綁定
(define z 100)
(define (add-z x) (+ x z))
(add-z 1) ; => 101
((lambda (f) (f 1)) add-z) ; => 101 行為永遠可預測
彌補了什麼缺陷#
LISP 1.5 彌補了三個實用化缺口:GC 解決了函數式風格的記憶體管理問題、巨集解決了語言無法擴充的問題、一級函數與高階函數讓 λ 演算的表達力真正可用。同時 FUNARG 問題誠實地暴露了實作與理論的落差,為後繼者(Scheme)留下了明確的待解難題。