Scheme 初步

之前定了每年學習一門語言的目標,自然不能輕言放棄。今年目標:簡單掌握 Scheme。

因爲自己接觸這門語言也不過寥寥數天,所以更多的會以引導的方式簡單介紹語法,而不會 (也沒有能力) 去探討什麼深入的東西。本文很多例程和圖示參考了紫藤貴文的《もうひとつの Scheme 入門》這篇深入淺出的教程,這篇教程現在也有英譯版中譯版。我自己是參照這篇教程入門的,一方面這篇教程可以說是能找到合適初學者學習的很好的材料,另一方面也希望能挑戰一下自己的日文閱讀能力 (結果對比日文和英文看下來發現果然還是日文版寫的比較有趣,英文翻譯版本就太嚴肅了,而中文版感覺是照着英文譯版二次翻譯的,有不少內容上的缺失和翻譯生硬的問題,蠻可惜的)。因爲中文的 Scheme 的資料其實很少,所以順便把自己學習的過程和一些體會整理記錄下來,算是作爲備忘。本文只涉及 Scheme 裏最基礎的一些語法部分,要是恰好能夠幫助到後來的學習者入門 Scheme,那更是再好不過。

爲什麼選擇學 Scheme

三方面的原因。

首先是自己基本上對函數式語言的接觸爲零。平時工作和自己的開發中基本不使用函數式編程,大腦已經被指令式程序佔滿,有時候總顯得不很靈光。而像 Swift 這樣的語言其實引入了一些函數式編程的可能性。多接觸一些函數式的語言,可能會對實際工作中解決某些問題有所幫助。而 Scheme 比起另一門常用 Lisp 方言 Common Lisp 來說,要簡單不少。比較適合像我這樣非科班出身,CS 功力不足的開發者。

其次,Structure and Interpretation of Computer Programs (SICP) 裏的例程都是使用 Scheme 寫的。雖然不太有可能有時間補習這本經典,但是如果不會一點 Scheme 的話,那就完全沒有機會去讀這本書了。

最後,Scheme 很酷也很好玩,雖然在實際中可能並沒有鳥用,但是和別人說起來自己會一點 Scheme 的話,那種感覺還是很棒的。

其實還有一點對 hacker 們很重要,如果你喜歡使用像 Emacs 這樣的基於 Lisp 的編輯器的話,使用 Scheme 就可以與它進行交互或者是擴展它的功能了。

那讓我們儘快開始吧。

成爲 Scheme Hacker 的第一步

成爲 Scheme Hacker 的第一步,當然是安裝和配置運行環境,同時這也是最難跨過去的一步。想想有多少次你決心學習一門新語言的時候,在配置好開發環境後就再也沒有碰過吧。所以我們需要儘快跨過這個步驟。

最簡單的開發環境點擊這個鏈接,然後你就可以開始用 Scheme 編程了。如果你更喜歡在本地環境和終端裏操作的話,可以下載 MIT/GUN Scheme。在 OS X 上解包後是一個 .app 文件,運行 .app 包裏的 /Contents/Resources/mit-scheme 就可以打開一個解釋器了。

Hello 1 + 1

雖然大部分語言都是從 Hello World 開始的,但是對於 Scheme 來說,計算纔是它的強項。所以我們從 1 + 1 開始。計算 1 + 1 程序在 Scheme 中是這樣的:

1 ]=> (+ 1 1)

;Value: 2

1 ]=>

1 ]=> 是輸入提示符,我們輸入的內容是 (+ 1 1),得到的結果是 2。雖然語句很簡單,但是這裏包含了 Scheme 的最基本的語素,有三個地方值得特別注意。

  1. 成對的括號。一對括號表示的是一步計算,這裏 (+ 1 1) 表示的就是 1 + 1 這一步運算。
  2. 緊接括號的是函數名字,再然後是函數的參數。在這裏,函數名字就是 “+”,兩個 1 是它的參數。Scheme 中大部分的運算符其實都是函數。
  3. 使用空格,tab 或是換行符來分割函數名以及參數。

和別的很多語言一樣,Scheme 在函數調用時也有計算優先級,會先對輸入的參數進行計算,然後再進行函數調用。還是以上面的 1 + 1 爲例。首先解釋器看到加號,但是此時運算並沒有開始。解釋器會先計算第一個參數 1 的值 (其實就是 1),然後計算第二個參數 1 的值 (其實還是 1)。然後再用兩個計算得到的值來進行加法運算。

另外,”+” 這個函數不僅可以接受兩個參數,其實它是可以接受任意多個參數的。比如 (+) 的結果是 0,(+ 1 2 3 4) 的結果是 10。

學會加法以後,乘法自然也不在話下了。

1 ]=> (* 2 3)

;Value: 6

1 ]=>

減法和除法稍微不同一些,因爲它們並不滿足交換律,所以可能會有疑問。但是隻要記住參數是平等的,它們會順次計算就可以了。舉個例子:

1 ]=> (- 10 5 3)

;Value: 2

1 ]=> (/ 20 2 2)

;Value: 5

對於除法,有兩個需要注意的地方。首先和我們熟悉的很多語言不同,Scheme 是默認有分數的概念的。比如在 C 系語言中,如果只是在整數範圍的話,我們計算 10 / 3 的結果會是 3;如果是浮點型的話結果爲 3.33333。而在 Scheme 中,結果是這樣的:

1 ]=> (/ 10 3)

;Value: 10/3

這是一個分數,就是三分之十,絕對精確!

另一個需要注意的是,如果 / 只有一個輸入的話,它的意思是取倒數。

1 ]=> (/ 2)

;Value: 1/2

如果你需要一個浮點數而不是分數的話,可以使用 exact->inexact 方法,將精確值轉爲非精確值:

1 ]=> (exact->inexact (/ 10 3))

;Value: 3.3333333333333335

Scheme 也內建定義了一些其他的數學運算符號,如果你感興趣,可以查看 R6RS 的相關章節

R6RS (Revisedn Report on the Algorithmic Language Scheme, Version 6) 是當前的 Scheme 標準。

定義變量和方法,Hello World

通過簡單的 1 + 1 運算我們可以大概知道 Scheme 中的奇怪的括號開頭的意思了。有了這個作爲基礎,我們可以來看看如何定義變量和方法了。

Scheme 中通過 define 來定義變量和方法:

; s 是一個變量,值爲 "Hello World"
(define s "Hello World")

; f 是一個函數,它不接受參數,調用時返回 "Hello World"
(define f (lambda () "Hello World"))

上面的 lambda 可以生成一個閉包,它接受兩個參數,第一個是一個空的列表 (),表示這個閉包不接受參數;第二個是 “Hello World” 這個字符串。在解釋器中定義好兩者之後,就可以進行調用了:

1 ]=> (define s "Hello World")

;Value: s

1 ]=> (define f (lambda () "Hello World"))

;Value: f

1 ]=> s

;Value 24: "Hello World"

1 ]=> f

;Value 25: #[compound-procedure 25 f]

1 ]=> (f)

;Value 26: "Hello World"

既然我們已經知道了 lambda 的意義和用法,那麼定義一個接受參數的函數也就不是什麼難事了。比如上面的 f,我們想要定義一個接受名字的函數的話:

1 ]=> (define hello
        (lambda (name)
            (string-append "Hello " name "!")
        )
      )

;Value: hello

1 ]=> (hello "onevcat")

;Value 27: "Hello onevcat!"

很簡單,對吧?其實甚至可以更簡單,define 的第一個參數可以是一個列表,其中第一個元素是函數名名字,之後的是參數列表。

用專業一點的術語來說的話,就是 define 的第一個參數是一個 cons cell 的話,它的 car 是函數名,cdr 是參數。關於這些概念我們稍後再仔細說說。

於是上面的方法可以簡單地寫作:

1 ]=> (define (hello name)
        (string-append "Hello " name "!"))

;Value: hello

1 ]=> (hello "onevcat")

;Value 28: "Hello onevcat!"

光說不練假把式,所以留個小練習給大家吧,用 define 來定義一個函數,讓其爲輸入的數字 +1。如果你無壓力地搞定了的話,我們就可以繼續看看 Scheme 裏的條件語句怎麼寫了。

條件分支和布爾邏輯

不論是什麼編程語言,條件分支或者類似的概念應該都是不可缺少的部分。在 Scheme 中,使用 if 可以進行條件分支的處理。和其他很多語言不一樣的地方在於,函數式語言中函數纔是一等公民,if 的行爲也和一個其他的普通函數很相似,是作爲一個函數來使用的。它的語法是:

(if condition ture_action false_action)

與普通函數先進行輸入的取值不同,if 將會先對 condition 運算式進行取值判斷。如果結果是 true (在 Scheme 中用 #t 代表 true,#f 代表 false),則再對 ture_action 進行取值,否則就執行 false_action。比如我們可以實現一個 abs 函數來返回輸入的絕對值:

1 ]=> (define (abs input)
        (if (< input 0) (- input) input))

;Value: abs

1 ]=> (abs 100)

;Value: 100

1 ]=> (abs -100)

;Value: 100

也許你已經猜到了,Scheme 的布爾邏輯也是遵循函數式的,最常用的就是 and 和 or 兩種了。和常見 C 系語言類似的是,and 和 or 都會將參數從左到右取值,一旦遇到滿足停止條件的值就會停止。但是和傳統 C 系語言不同,布爾邏輯的函數返回的不一定就是 #t 或者 #f,而有可能是輸入值,這和很多腳本語言的行爲是比較一致的:and 會返回最後一個非 #f 的值,而 or 則返回第一個非 #f 的值:

1 ]=> (and #f 0)

;Value: #f

1 ]=> (and 1 2 "Hello")

;Value 13: "Hello"

1 ]=> (or #f 0)

;Value: 0

1 ]=> (or 1 2 "Hello")

;Value: 1

1 ]=> (or #f #f #f)

;Value: #f

在很多時候,Scheme 中的 and 和 or 並不全是用來做條件的組合,而是用來簡化一些代碼的寫法,以及爲了順次執行一些代碼的。比如說下面的函數在三個輸入都爲正數的情況下返回它們的乘積,可以想象和對比一下在指令式編程中同樣功能的實現。

(define (pro3and a b c)
    (and (positive? a)
        (positive? b)
        (positive? c)
        (* a b c)
    )
)

除了 if 之外,在 C 系語言裏另一種常見的條件分支語句是 switch。Scheme 裏對應的函數是 condcond 接受多個二元列表作爲輸入,從上至下依次判斷列表的第一項是否滿足,如果滿足則返回第二項的求值結果並結束,否則一直繼續到最後的 else

(cond
  (predicate_1 clauses_1)
  (predicate_2 clauses_2)
    ......
  (predicate_n clauses_n)
  (else        clauses_else))

在新版的 Scheme 中,標準里加入了更多的流程控制的函數,它們包括 beginwhen 和 unless 等。

begin 將順次執行一系列語句:

(define (foo)
  (begin
    (display "hello")
    (newline)
    (display "world")
  )
)

when 當條件滿足時執行一系列代碼,而 unless 在條件不滿足時執行一系列代碼。這些改動可以看出一些現代腳本語言的特色,但是新的標準據說也在 Scheme 社區造成了不小爭論。雖然結合使用 ifand 和 or 肯定是可以寫出等效的代碼的,但是這些額外的分支控制語句確實增加了語言的便利性。

循環

一門完備的編程語言必須的三個要素就是賦值,分支和循環。前兩個我們已經看到了,現在來看看循環吧:

do

1 ]=> (do ((i 0 (+ i 1))) ; 初始值和 step 條件
          ((> i 4))       ; 停止條件,取值爲 #f 時停止
        (display i)       ; 循環主體 (命令)
      )
01234
;Value: #t

唯一要解釋的是這裏的條件是停止條件,而不是我們習慣的進入循環主體的條件。

遞歸

可以看出其實 do 寫起來還是比較繁瑣的。在 Scheme 中,一種更貼合語言特點的寫法是使用遞歸的方式來完成循環:

1 ]=> (define (count n)
          (and (display (- 4 n))
               (if (= n 0) #t (count (- n 1)))
          )
      )

;Value: count

1 ]=> (count 4)
01234
;Value: #t

列表和遞歸

也許你會說,用遞歸的方式看起來一點也不簡單,甚至代碼要比上面的 do 的版本更難理解。現在看來確實是這樣的,那是因爲我們還沒有接觸 Scheme 裏一些很獨特的概念,cons cell 和 list。我們在上面介紹 define 的時候曾經提到過,cons cell 的 car 和 cdr。結合這個數據結構,Scheme 裏的遞歸就會變得非常好用。

那麼什麼是 cons cell 呢?其實沒有什麼特別的,cons cell 就是一種數據結構,它對應了內存的兩個地址,每個地址指向一個值。

要初始化一個上面圖示的 cons cell,可以使用 cons 函數:

1 ]=> (cons 1 2)

;Value 13: (1 . 2)

我們可以使用 car 和 cdr 來取得一個 cons cell 的兩部分內容 (car 是 “Contents of Address part of Register” 的縮寫,cdr 是 “Contents of Decrement part of Register”):

1 ]=> (car (cons 1 2))

;Value: 1

1 ]=> (cdr (cons 1 2))

;Value: 2

cons cell 每個節點的內容可以是任意的數據類型。一種最常見的結構是 car 中是數據,而 cdr 指向另一個 cons cell:

上面這樣的數據結構對應的生成代碼爲:

1 ]=> (cons 3 (cons 1 2))

;Value 14: (3 1 . 2)

有一種特殊的 cons cell 鏈,其最後一個 cons cell 的 cdr 爲空列表 '(),這類數據結構就是 Scheme 中的列表。

對於列表,我們有一種更簡單的創建方式,就是類似 '(1 2 3) 這樣。對於列表來說,它的 cdr 值是一個子列表:

1 ]=>  '(1 2 3)

;Value 15: (1 2 3)

1 ]=> (car '(1 2 3))

;Value: 1

1 ]=> (cdr '(1 2 3))

;Value 16: (2 3)

而循環其實質就是對一列數據進行處理的過程,結合 Scheme 列表的特性,我們意識到如果把列表運用在遞歸中的話,car 就是遍歷的當前項,而 cdr 就是下一次遞歸的輸入。Scheme 和遞歸調用可以說能配合得天衣無縫。

比如我們定義一個將列表中的所有數都加上 1 的函數的話,可以這麼處理:

(define (ins_ls ls)
    (if (null? ls)
      '()
      (cons (+ (car ls) 1) (ins_ls (cdr ls)))
    )
)

(ins_ls '(1 2 3 4 5))

;=> (2 3 4 5 6)

尾遞歸

遞歸存在性能上的問題,因爲遞歸的調用需要在棧上保持,然後再層層返回,這會造成很多額外的開銷。對於小型的遞歸來說還勉強可以接受,但是對於遞歸調用太深的情況來說,這顯然是不可擴展的做法。於是在 Scheme 中對於大型的遞歸我們一般會傾向於將它寫爲尾遞歸的方式。比如上面的加 1 函數,用尾遞歸重寫的話:

(define (ins_ls ls)
    (ins_ls_interal ls '()))

(define (ins_ls_interal ls ls0)
    (if (null? ls)
        ls0
        (ins_ls_interal (cdr ls) (cons ( + (car ls) 1) ls0))))

(define (rev_ls ls)
  (rev_ls_internal ls '()))

(define (rev_ls_internal ls ls0)
  (if (null? ls)
      ls0
      (rev_ls_internal (cdr ls) (cons (car ls) ls0))))

(rev_ls (ins_ls '(1 2 3 4 5)))

;=> (2 3 4 5 6)

函數式

上面介紹了 Scheme 的最基本的賦值,分支和循環。可以說用這些東西就能夠寫出一些基本的程序了。一開始會比較難理解 (特別是遞歸),但是相信隨着深入下去和習慣以後就會好很多。到現在爲止,除了在定義函數時,其實我們還沒有直接觸碰到 Scheme 的函數式特性。在 Scheme 裏函數是一等公民,我們可以將一個函數作爲參數傳給另外的函數並進行調用,這就是高階函數。

一個最簡單的例子是排序的時候我們可以將一個返回布爾值的函數作爲排序規則:

1 ]=> (sort '(7883 9099 6729 2828 7754 4179 5340 2644 2958 2239) <)

;Value 13: (2239 2644 2828 2958 4179 5340 6729 7754 7883 9099)

更甚於我們可以使用一個匿名函數來控制這個排序,比如按照模 100 之後的大小 (也就是數字的後兩位) 進行排序:

1 ]=> (sort '(7883 9099 6729 2828 7754 4179 5340 2644 2958 2239)
      (lambda (x y) (< (modulo x 100) (modulo y 100))))

;Value 14: (2828 6729 2239 5340 2644 7754 2958 4179 7883 9099)

類似這樣的特性在一些 modern 的語言裏並不算罕見,但是要知道 Scheme 可是有些年頭的東西了。類似的還有 mapfilter 等。比如上面的 list 加 1 的例子,用 map 函數就可以非常簡單地實現:

(map (lambda (x) (+ x 1)) '(1 2 3 4 5))

;=> (2 3 4 5 6)

接下來…

篇幅有限,再往長寫的話估計沒什麼人會想看完了。到這裏爲止關於 Scheme 的一些基礎內容也算差不多了,大概閱讀最簡單的 Scheme 程序應該也沒有太大問題了。在進一步的學習中,如果出現不認識的函數或者語法的話,可以求助 SRFI 下對應的文檔或是在 MIT/GNU Scheme 文檔中尋找。

本文一開始提到的教程很適合入門,之後的話可以開始參看 SICP,可以對程序設計和 Scheme 的思想有更深的瞭解 (雖然閱讀 SICP 的目的不應該是學 Scheme,Scheme 只是幫助你進行閱讀和練習的工具)。因爲我自己也就是個愣頭青的初學者,所以無法再給出其他建議了。如果您有什麼好的資源或者建議,非常歡迎在評論中提出。

另外,相比起 Scheme,如果你想要在實際的工程中使用 Lisp 家族的語言的話,Racket 也許會是更好的選擇。相比於面向數學和科學計算來說,Racket 支持對象類型等概念,更注重在項目實踐方面的運用。

就這樣吧,我要繼續去和 Scheme 過週末了。

發佈了9 篇原創文章 · 獲贊 13 · 訪問量 7萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章