美思 [Common Lisp] 程式設計教學:基礎概念

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

由於 Lisp 家族語言和主流語言差異較大,在本文中,我們會介紹 Lisp 和 Common Lisp 的基本概念,做為撰寫 Common Lisp 程式的準備。

Common Lisp 程式碼所用的副檔名

Common Lisp 原始碼使用 .lisp.lsp 為副檔名。而編譯後的位元組碼 (bytecode) 使用 .fasl (記為 fast load) 為副檔名。

有許多 Common Lisp 實作品可編譯出機械碼 (machine code),即原生執行檔 (native executable)。執行檔所用的副檔名依系統而異。Unix 的執行檔不使用副檔名,而 Windows 的執行檔使用 .exe 為副檔名。

Hello World 程式

由於 Common Lisp 沒有嚴格規定要有主函式,所以最精簡的 Hello World 程式只需一行:

(write-line "Hello World")

write-line 是內建函式,不需引入函式庫。

但在編譯 Common Lisp 程式碼時,則需要自訂主函式。詳見下文的說明。

編譯 (Compilation) 和直譯 (Interpretation)

根據生成電腦程式的方式,我們可以把程式語言 (的實作品) 區分為編譯和直譯。編譯所產生的程式會透過直譯執行程式來得有效率。此外,把原始碼編譯成機械碼後,無法還原成原始碼,只能恢復成等效的組語程式碼。所以,程式設計者可以藉由編譯保護原始碼。

在 Common Lisp 中,這樣的區分沒有意義。許多 Common Lisp 實作品兼具有編譯器和直譯器的功能。視實作可編譯出位元碼 (bytecode) 或機械碼 (machine code)。

S 表達式 (S-Expression)

S 表達式是 Lisp 家族語言的特色之一。寫慣 Algol 家族語言 (註) 的程式設計者看到 Lisp 程式碼往往不太習慣。其實,只要花一點時寫閱讀和寫 Lisp 程式碼,就會習慣這種表達方式。現在有許多編輯器可輔助撰寫 Lisp 程式碼的任務,寫起來不會太難。

(註) 即 C 家族語言。

Lisp 的 S 表達式的虛擬碼如下:

(sym a b c ...)

S 表達式本身是列表 (list)。在列表中的第一個項目 sym (記為 symbol) 代表對列表所做的指令 (command)。列表的第二個以後的項目是該指令的參數 (parameter)。參數的個數由該指令決定。

Lisp 程式碼並不完全等於 S 表達式,因 Lisp 程式碼結合 S 表達式和前綴表示法 (prefix notation) (註) 。我們不需要知道非 Lisp 的 S 表達式怎麼寫,只要知道 Lisp 程式碼的寫法即可。

(註) 又稱為波蘭表示法 (Polish notation)。

例如,以 Lisp 撰寫 4 + 3 如下:

(+ 4 3)

由於 Lisp 的 + 沒有限制參數數目,所以可以使用多個參數。以 Lisp 撰寫 1 + 2 + 3 + 4 + 5 如下:

(+ 1 2 3 4 5)

這裡可以看出 Lisp 家族語言和 Algol 家族語言的差異。

S 表達式可以嵌套。像是以 Lisp 寫 (4 + 3) * (5 - 1) 如下:

(* (+ 4 3) (- 5 1))

說實在的,這樣的程式碼剛開始會不太容易閱讀。只要持續寫一段時間 Lisp 程式後就能適應。

雖然 Lisp 程式碼乍看不易閱讀,但 Lisp 程式的好處是不用記憶指令的優先順序。當我們寫出 Lisp 程式碼時,指令的執行順序就已經決定了。因為 Lisp 程式碼和程式語言的抽象語法樹 (abstract syntax tree) 在概念上是相通的。

Lisp 程式的組成

Lisp 程式碼由 form (註) 組成。 Form 可能為下列三者之一:

(註) Form 是 Lisp 的專有名詞,代表 Lisp 程式的最小單位。

  • 符號 (symbol):可能是變數 (variable)、函式 (function)、巨集 (macro)、語法 (syntax 或 special form) 等
  • 列表 (conses 或 list):Lisp 的核心資料結構,相當於鏈結串列
  • 資料 (data):像是布林 (boolean)、數字 (number)、字元 (character)、字串 (string) 等

Common Lisp 的核心資料結構是列表,該結構可同時用來撰寫程式或資料結構。

例如,3.14159 是資料,不會轉換成其他的形式:

* 3.14159

(+ 4 3) 是列表。該列表包括符號 + 和資料 43

* (+ 4 3)

由於 + 是 Common Lisp 內建的函式,不需要使用者自行宣告即能使用。

大小寫敏感性 (Case Sensitivity)

Common Lisp 的程式碼不區分大小寫。按照 Common Lisp 社群的慣例,會使用小寫,不刻意使用縮寫,以 kebab-case 來撰寫識別字 (identifier)。

空白 (Space)、縮進 (Indentation)、換行 (End of Line)

在 Lisp 程式中,空白用來區隔列表的元素。元素可能為符號或資料,視程式碼而定。

但 Lisp 程式不嚴格規範縮進和換行。利用縮進和換行排列程式碼的目的是讓程式碼美觀易讀。容易閱讀的程式碼也會好維護。

註解 (Comment)

註解的用途是在程式碼中加入說明文字。由於註解會被編譯器或直譯器忽略,不需以程式語言來撰寫,以自然語言 (natural language) 來撰寫即可。

Common Lisp 的註解方式如下:

  • ;:單行註解
  • 一對 #||#:多行註解

在 Common Lisp 社群中,主流的方式是使用單行註解。只有需要跨行註解或行內註解時,才使用多行註解。

由於程式碼本身即可表達程式的行為,註解應用來說明程式設計者的意圖或想法。有時候註解會用來解釋罕見的演算法,或是標註特定的引用。對於教學用的程式碼來說,註解可用來補充說明程式的運作。

撰碼風格 (Coding Style)

Lisp 程式碼很容易寫得難以閱讀,保持良好的撰碼風格對整個團隊成員都好。可以參考這個頁面和這份 PDF 文件 來撰寫 Common Lisp 程式碼。

表達式 (Expression) 和敘述 (Statement)

表達式是指會回傳值的一段程式,該段程式由值、變數、運算子、函式呼叫等事物組成。而敘述代表獨立的一段指令。表達式也可以做為敘述使用,但反之則否。

Lisp 程式的 form 視為表達式,所以會自動取得值。例如,以下的 cond 指令會依 ab 的關係回傳 10-1 三者之一:

(defun cmp (a b)
  (cond ((> a b) 1)
        ((< a b) -1)
        (t 0)))

上述 Common Lisp 函式相似於以下 C 函式:

int cmp(int a, int b)
{
    if (a > b) {
        return 1;
    }
    else if (a < b) {
        return -1;
    }
    else {
        return 0;
    }
}

Lisp 的 cond 指令和 C 的 if 指令的差別在於前者是表達式,後者是敘述。

運算子 (Operator)、函式 (Function)、巨集 (Macro)

在 Algol 家族語言中,會明確地區分運算子、函式、巨集等指令。因不同指令可能會分別採用前綴表示法 (prefix notation)、中綴表示法 (infix notation) 或後綴表示法 (postfix notation) 三者之一。此外,還要考慮各種指令的優先順序。

但在 Lisp 家族語言中,運算子、函式、巨集的界限變得模糊,因為所有的指令都用前綴表示法,而且不需考慮指令的優先順序。所以,Lisp 程式乍看難寫,但寫起來反而是最單純的。

主函式 (Main Function)

許多程式語言具有主函式,做為應用程式的起始點。Common Lisp 不強調主函式的概念,大可把程式寫在命令稿頂層中。

但筆者會建議在每個 Common Lisp 應用程式中加入自訂的主函式,並將程式實作在主函式中。當我們把 Common Lisp 命令稿編譯成執行檔時,就會有設置主函式的項目,代表 Common Lisp 認定程式中存在著主函式。

以下是最精簡的範例程式:

;; Implement user-defined main function.
(defun main ()
  ;; Implement code here.

  ;; Run the script in batch mode.
  (quit))

(main) ;; Run the main function.

註:可能要為 quit 指令做額外設置,詳見下文。

defun (記為 declare function) 巨集用來宣告函式。本範例程式將主函式命名為 main。由於本範例程式僅是用來展示主函式的架構,其內部是空的 (dummy)。

接著,執行 main 指令,即呼叫主函式。在主函式的尾端會執行 quit 指令,以離開此程式。

Common Lisp 沒有規範主函式的名稱,筆者建議一律使用 main 來命名主函式。由於 main 恰巧是多種程式語言的主函式使用的名稱,易於閱讀和撰寫。

輸出到終端機

由於本系列文章不強調 REPL 環境的使用,必要時可將程式內的資料輸出到命令列環境,就可以在命令列環境中觀看資料。當我們使用主流語言寫終端機程式時,大部分也是用這種方式觀看程式輸出的資料。

我們建議用 write-line 函式搭配 format 函式將資料輸出到命令列環境。前者在輸出字串時會自動在資料尾端加上 (跨平台的) 換行符號,後者可將各種資料轉成字串。

原本 write-line 函式只能接收字串,我們撰寫兩個輔助函式來自動轉換資料型態並輸出:

(defun puts (obj)
  "Print obj to standard output with trailing newline."
  (if (stringp obj)
      (write-line obj)
      (write-line (princ-to-string obj))))

(defun perror (obj)
  "Print obj to standard error with trailing newline."
  (if (stringp obj)
      (write-line obj *error-output*)
      (write-line (princ-to-string obj) *error-output*)))

puts 函式接收一個變數 obj。使用 stringp (string predicate) 函式來判斷 obj 是否為字串型態。當 obj 為字串時,將 obj 直接餵給 write-line 函式;反之,用 princ-to-string 函式將 obj 轉為字串後再餵給 write-line 函式。

perror 函式的寫法和 puts 函式大抵上雷同,差別在於 perror ‵將 write-line 指令的輸出導向 *error-output*

由於 putsperror 這類小型工具函式時常會用到,不建議在每個程式中重覆複製貼上程式碼。可以自行將這些小型工具函式收集起來。在本範例中,我們將其收錄在 common.lisp 命令稿。讀者在寫一段時間的 Common Lisp 程式後,應該會有一套自己的工具函式,可以收在自己的 common.lisp 命令稿中,當成客製的基礎函式庫來用。

然後另外寫一個 main.lisp 命令稿,加入以下內容:

(load "common.lisp")

(defun main ()
  (puts (format nil "Hello World"))
  (puts (format nil "~D" (+ 1 2 3 4 5)))
  (quit-with-status 0))

(main)

一開始,我們用 load 函式載入 common.lisp 命令稿,就不需要重覆寫相同的函式。

在主函式中,此範例程式分別印出兩種不同型態的資料。"Hello World" 是字串型態。(+ 1 2 3 4 5) 的運算結果是整數型態,再以 format 函式轉為字串型態輸出。

用斷言 (Assertion) 取代命令列輪出

傳統的程式設計書籍,會將資料輸出到命令列環境,再以人工判讀資料。但我們建議用斷言取代命令列輸出,可以在程式碼中清楚地表達程式設計者的意圖。

斷言的工作原理就是在偵測到錯誤情境時引發錯誤並中止程式。以下是斷言的虛擬碼:

if (condition is false) do
  throw some error message;
  abort the program;
end

Common Lisp 已經為程式設計者實作好 assert 巨集了,就不需要自己重造輪子。以下簡短範例用到 assert 巨集:

(defun main ()
  (assert (> 4 3))
  (quit))

(main)

使用 assert 巨集時,目標是寫出正確的條件句,避免觸發 assert 而中止程式。當程式順利結束,代表資料是正確的。

離開狀態 (Exit Status)

離開狀態是程式終止時回傳給系統的代碼,該代碼為整數型態資料。

在程式設計的慣例中,回傳 0 代表程式正常結束,回傳 1 或其他非零數字代表程式異常結束。除了 01 以外,程式設計者對回傳值沒有共識。所以,不要設計複雜的回傳值,因為這類設計無法跨平台。

在 Common Lisp 中離開程式並回傳值的指令是 quit。但 quit 本身並非 Common Lisp 標準的一部分,每個 Common Lisp 實作品的 quit 指令的介面略有不同。為了寫出跨平台的 Common Lisp 指令,我們將 quit 封裝成以下的 wrapper 函式:

(defun quit-with-status (status)
  (declare (integer status))
  "Quit a program with exit status"
  #+sbcl   (sb-ext:quit :unix-status status)
  #+ccl    (if (string= "Microsoft Windows" (software-type))
               (external-call "exit" :int status)
               (ccl:quit status))
  #+clisp  (ext:quit status)
  #+ecl    (ext:quit status)
  #+abcl   (ext:quit :status status)
  ;; Fallback for uncommon CL implementation
  #-(or sbcl ccl clisp ecl abcl)
    (cl-user::quit status))

這個 quit-with-status 函式其實很簡單,只是把不同 Common Lisp 實作品的 quit 指令寫在一起而已。這個函式會根據不同 Common Lisp 實作品呼叫不同的 quit 函式。關鍵在於使用 #+ (sharpsign plus)#- (sharpsign minus) 來區隔不同的 Common Lisp 實作品。

這個函式針對 SBCL、Clozure CL、ClispECLABCL 五種 Common Lisp 實作品撰寫。這五種 Common Lisp 實作品可免費取得,而且使用者較多。

注意一下 Clozure CL 在 Windows 上執行 quit 指令時終端機會凍住 (hang) (參考此 issue),所以我們特地用了外部指令 exit 來離開程式。

將 Common Lisp 命令稿編譯成執行檔

Common Lisp 實作品可以編譯 Common Lisp 原始碼。但 Common Lisp 標準沒有明確規範編譯的目標格式,把這個部分留給各個實作品來決定。在常見的 Common Lisp 實作品中,編譯的目標可能為

  • fasl (fast load) 檔,為一種 bytecode
  • 轉成機械碼
  • 轉譯為 C

Common Lisp 實作品用來編譯機械碼的指令沒有統一,所以我們寫了個 wrapper 函式,把這個動作封裝起來。此 wrapper 的程式碼如下:

(defun compile-program (program main)
  (declare (string program)
           (function main))
  "Compile a program to an executable"
  #+sbcl  (sb-ext:save-lisp-and-die program
                                    :toplevel main
                                    :executable t)
  #+ccl   (ccl:save-application program
                                :toplevel-function main
                                :prepend-kernel t)
  #+clisp (ext:saveinitmem program
                           :init-function main
                           :executable t
                           :quiet t
                           :script nil)
  #-(or sbcl ccl clisp)
    (error "Unsupported Common Lisp implementation"))

這個工具函式適用於 SBCL、Clozure CL、Clisp。ECL 的編譯目標是 C 程式碼,不適用這個動作。ABCL 運行在 Java 平台上,未提供這項功能。

在 Windows 上使用以下指令來編譯 Common Lisp 命令稿:

(compile-program "program.exe" #'main)

Unix 的執行檔不需副檔名,故改用以下指令來編譯 Common Lisp 命令稿:

(compile-program "program" #'main)

更好的方式是自動偵測宿主系統,根據宿主系統自動命名程式。有關偵測宿主系統的部分,詳見下一節。

偵測系統

系統間總是有一些無法消除的歧異,能自動偵測宿主系統並採取相對應的動作是十分方便的功能。很遺憾地,Common Lisp 在這個部分沒有共識,委由各個 Common Lisp 實作品自行處理。

為了撰寫跨平台的 Common Lisp 程式碼,我們將偵測系統這項任務寫成 wrapper 函式如下:

(defun platform ()
  "Detect platform type"
  #+sbcl   (cond ((string= "Win32" (software-type)) :windows)
                 ((string= "Darwin" (software-type)) :macos)
                 ((string= "Linux" (software-type)) :linux)
                 ((not (not (find :unix *features*))) :unix)
                 (t (error "Unknown platform")))
  #+ccl    (cond ((string= "Microsoft Windows" (software-type)) :windows)
                 ((string= "Darwin" (software-type)) :macos)
                 ((string= "Linux" (software-type)) :linux)
                 ((not (not (find :unix *features*))) :unix)
                 (t (error "Unknown platform")))
  #+clisp  (cond ((not (not (find :win32 *features*))) :windows)
                 ((not (not (find :macos *features*))) :macos)
                 ((string= "Linux"
                           (let ((s (ext:run-program "uname"
                                                     :output :stream)))
                             (read-line s)))
                   :linux)
                 ((not (not (find :unix *features*))) :unix)
                 (t (error "Unknown platform")))
  #+ecl    (cond ((string= "NT" (software-type)) :windows)
                 ((string= "Darwin" (software-type)) :macos)
                 ((string= "Linux" (software-type)) :linux)
                 ((not (not (find :unix *features*))) :unix)
                 (t (error "Unknown platform")))
  #+abcl   (cond ((not (not (find :windows *features*))) :windows)
                 ((string= "Mac OS X" (software-type)) :macos)
                 ((string= "Linux" (software-type)) :linux)
                 ((not (not (find :unix *features*))) :unix)
                 (t (error "Unknown platform")))
  #-(or sbcl ccl clisp ecl abcl)
    (error "Unsupported Common Lisp implementation"))

在 Common Lisp 中偵測系統有兩種方式,一種是 software-type 指令,一種是 *features* 列表。很不幸地,這兩個指令的值沒有共識,所以我們得見招拆招。

在這個 wrapper 中,我們優先使用 software-type 指令。只有在該指令不適用時,才會改用 *features* 列表內的值。在 GNU/Linux 上的 Clisp 是一個特例,由於兩個方法都無法使用,故我們呼叫外部程式 uname(1),根據該外部程式的回傳值來偵測宿主系統。

結合這幾節的工具函式,將 Hello World 程式改寫如下:

(load "common.lisp")

(defun main ()
  (puts "Hello World")
  (quit-with-status 0))

(if (equal :windows (platform))
    (defvar *program* "program.exe")
    (defvar *program* "program"))

(compile-program *program* #'main)
(quit-with-status 0)

透過這些 wrapper 的協助,我們可以優雅地寫出跨平台的 Common Lisp 程式。讀者日後可以視自身的需求,參考本文撰寫自己的工具函式。

附記

本節所介紹的工具函式收錄在 cl-portablecl-yautils 專案。

關於作者

身為資訊領域碩士,美思認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

美思喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,美思將所學寫成文章,放在這個網站上和大家分享。