1975:Scheme 誕生#
事件#
1975年,Gerald Sussman 與 Guy Steele 在 MIT 人工智慧實驗室創造了 Scheme——LISP 的方言,原名「Schemer」(因作業系統檔名長度限制去掉 r)。他們發表的論文《SCHEME: An Interpreter for Extended Lambda Calculus》(1975),以及 1975–1978 年間著名的 Lambda Papers(《Lambda: The Ultimate GOTO》等),重新定義了 LISP 與整個函數式程式設計。
語法/特性加入的理論與實用原因#
1. 詞法作用域(Lexical Scoping)#
- 理論原因:λ 演算本來就是詞法作用域——束縛變數的意義由 λ 定義處決定。Sussman 與 Steele 決定讓 Scheme 的實作忠實於理論,而非沿襲 LISP 1.5 的動態作用域。
- 實用原因:一級函數作為傳回值(funarg)時,必須捕捉定義時的環境,行為才可預測——這就是閉包(closure)。
- 彌補缺陷:LISP 的動態作用域使自由變數在執行期才綁定,FUNARG 問題讓函數的行為難以推理;詞法作用域讓變數綁定在編譯(讀取)期就完全確定。
動態作用域 vs 詞法作用域的行為差異實例:同一個函數,動態作用域下自由變數跟著呼叫者變動,詞法作用域下永遠鎖定定義處:
;; 動態作用域(LISP 1.5 風):自由變數 x 由「呼叫處」決定
(define (f) (* x x)) ; x 是自由變數
(define (test)
(let ((x 3)) (f))) ; 呼叫 f 時,test 的 x=3 進入作用域
(define x 100)
(test) ; => 9 (x 是 test 裡的 3!)
(f) ; => 10000(x 是全域的 100)
;; 同一個函數,行為隨呼叫環境改變——無法局部推理
;; 詞法作用域(Scheme):x 在定義處就綁定
(define x 100)
(define (f) (* x x))
(define (test)
(let ((x 3)) (f)))
(test) ; => 10000(永遠是定義處的 x=100)
;; 函數的行為與呼叫環境無關,可局部推理
2. 尾端呼叫優化(Proper Tail Calls)#
- 理論原因:
tail-call的語義等同於 goto——呼叫者已無事可做,直接跳轉即可。理論上「遞迴 = 迭代」,遞迴不需要消耗堆疊空間。 - 實用原因:若遞迴總是消耗堆疊,函數式風格在深遞迴時會崩潰;尾端呼叫優化讓迴圈可以用純遞迴寫出,且空間消耗為常數。
- 彌補缺陷:LISP 的遞迴在深度大時堆疊溢位;Lambda Paper《Lambda: The Ultimate GOTO》論證了尾端呼叫可消除這個問題。
(define (loop i)
(if (= i 0) 'done
(loop (- i 1)))) ; 尾端呼叫:常數空間,等同 while 迴圈
尾遞迴與非尾遞迴的對比:關鍵在於遞迴呼叫之後還有沒有「未完成的工作」(如乘法):
;; 非尾遞迴:乘法在遞迴傳回後才做 → 堆疊深度 O(n)
(define (fact n)
(if (= n 0) 1
(* n (fact (- n 1))))) ; (* n ...) 等遞迴結果——不是尾呼叫
;; 尾遞迴:用累加器把「結果」帶進遞迴,呼叫後無事可做 → 常數空間
(define (fact-iter n acc)
(if (= n 0) acc
(fact-iter (- n 1) (* acc n)))) ; 尾呼叫,直接跳轉
(fact-iter 100000 1) ; => 一個 456 位的大數,Scheme 不會堆疊溢位
(fact 100000) ; 沒有尾呼叫優化的語言在這裡會堆疊溢位
named let 是 Scheme 慣用的尾遞迴寫法,迴圈變數就地命名:
;; named let:迴圈與變數宣告合為一體
(let fact-iter ((n 5) (acc 1))
(if (= n 0) acc
(fact-iter (- n 1) (* acc n)))) ; => 120
;; 求列表長度:尾遞迴迴圈
(define (length l)
(let loop ((l l) (n 0))
(if (null? l) n
(loop (cdr l) (+ n 1)))))
3. 一級閉包(First-class Closure)#
- 理論原因:λ 演算中「函數 + 其環境」就是閉包的數學原型;函數是一級公民,閉包自然也是。
- 實用原因:閉包可用來實作物件、狀態機、延遲求值——Sussman 與 Steele 用閉包示範了各種控制結構的實作。
- 彌補缺陷:傳統 LISP 的 funarg 因動態作用域而殘缺;Scheme 的閉包是完整、可預測的一級值。
閉包可以捕捉並攜帶可變狀態——用閉包做出「物件」:
;; 閉包計數器:state 被閉包捕捉,外界無法直接碰觸
(define (make-counter)
(let ((count 0))
(lambda ()
(set! count (+ count 1))
count)))
(define c1 (make-counter))
(define c2 (make-counter))
(c1) ; => 1
(c1) ; => 2 c1 記得自己的 count
(c2) ; => 1 c2 有各自獨立的 count——兩個閉包、兩份狀態
閉包捕捉的是「環境」而非「當下的值」——這正是 funarg 問題的正解:
;; 三個加法器各自捕捉不同的 n
(define (make-adders)
(map (lambda (n) (lambda (x) (+ x n))) '(1 10 100)))
(define adders (make-adders))
((car adders) 5) ; => 6 捕捉 n=1
((cadr adders) 5) ; => 15 捕捉 n=10
((caddr adders) 5) ; => 105 捕捉 n=100
4. Continuation(續體)#
- 理論原因:
call/cc(call-with-current-continuation)把「接下來的計算」本身變成一級值,理論上等價於 λ 演算的 CPS(Continuation-Passing Style)變換。 - 實用原因:一個極簡原語就能實作例外處理、產生器、協程、非決定性計算等所有控制結構。
- 彌補缺陷:傳統語言的控制流(goto、return)是硬編在語言裡的;continuation 讓控制結構成為函式庫而非語言特性。
call/cc 最經典的簡例:「提早傳回」不必靠 return 敘述——續體本身可以逃出計算:
;; 找到第一個大於 100 的元素,立刻用續體跳脫整個遍歷
(define (find-big lst)
(call/cc
(lambda (return) ; return = 「目前的計算的其餘部分」
(for-each (lambda (x)
(if (> x 100) (return x))) ; 呼叫續體 = 提早離開
lst)
'not-found)))
(find-big '(3 7 250 9)) ; => 250
(find-big '(3 7 9)) ; => NOT-FOUND
;; 續體甚至可以在離開後再次被呼叫(可儲存、可重入)
(define saved #f)
(+ 1 (call/cc (lambda (k) (set! saved k) 1))) ; => 2
(saved 10) ; => 11(跳回 + 1 處,帶著 10)
5. 極簡核心#
- 理論原因:忠於 λ 演算——一切從函數應用出發,特殊形式越少越好。
- 實用原因:第一版 Scheme 的報告只有一頁,極小的核心使直譯器容易移植,也容易作為教學與研究載具。
- 彌補缺陷:LISP 1.5 累積了大量內建結構;Scheme 證明語言可以小而強大。
第一版 Scheme 直譯器的核心幾乎就是 λ 演算的直接翻譯——eval 只處理少數幾種形式,其餘一切交給函數應用:
;; 極簡 Scheme 核心示意(R5RS 風):eval 只有少數特殊形式
(define (eval expr env)
(cond ((number? expr) expr) ; 自我求值
((symbol? expr) (lookup expr env)) ; 變數查環境
((eq? (car expr) 'lambda)
(make-closure (cadr expr) (caddr expr) env)) ; 函數抽象
((eq? (car expr) 'quote) (cadr expr)) ; 引用
(else (apply (eval (car expr) env) ; 函數應用——核心引擎
(map (lambda (e) (eval e env))
(cdr expr))))))
;; 整個語言的語義一頁寫得完:特殊形式只有 lambda 與 quote
彌補了什麼缺陷#
Scheme 解決了自 LISP 1.5 以來懸宕二十年的 FUNARG 問題:詞法作用域讓理論與實作終於一致;尾端呼叫優化讓遞迴真正等同於迭代;一級閉包與 continuation 讓所有控制結構可以用函數表達。Scheme 是第一個「忠實實作 λ 演算」的 LISP 方言,其設計直接影響了 Common Lisp、Racket、JavaScript(閉包與一級函數)與 Rust 等後世語言。