二十多年前,那時的電腦書書名很喜歡命名為《24 小時 C++》。當然,正如太陽餅裡不會有太陽、老婆餅裡不會有老婆,《24 小時 C++》很可能 24 小時都還讀不完。
由於 C++ 是屬一屬二複雜的語言,想學程式語言、想 24 小時入門,一旦選了 C++ ,第一步就錯了。另一方面,Lua 則是生產環境中廣泛使用的高階語言裡最簡單的,換言之,想快速掌握一門可用於生產環境的程式語言,首選 Lua 。
以下這張圖是對 Fennel 的概括:Lisp, Hosted on Lua VM, Minimalism。其中,極簡 (Minimalism) 可以說是它的精髓與特色。像 Clojure 也是跑在其它語言的運行環境 (JVM) 上的 Lisp ,然而,Clojure 有自己的標準函式庫、也有自己的語意 (semantics)。而 Fennel 不同,它只有語法使用 Lisp 語法,連語意都是大量借用 Lua 的語意。
由於 Fennel 借用了 Lua 的運行環境 (runtime)、函式庫 (library)、絕大部分的語意 (semantics),只是加上了 Lisp 的語法 (syntax),要掌握 Fennel ,難度基本上跟要掌握 Lua 相去不遠,真的可以速成!
前面談到語法 (syntax) 與語意 (semantics) 兩個詞,並且明確地指出了它們的不同。這邊舉個例子來解釋清楚一點:
(print "hellow world")
print("hellow world")
上面兩段程式碼都是 Hello World。在表現形式 (語法) 方面,Fennel 因為採用了 Lisp 語法,所以括號擺放的位置比較特殊,但是,在運作行為 (語意) 方面,都是去呼叫同一個 print 函數,所以會一模一樣。
再來一個更複雜的例子,例如要計算 1 + 2 * 3。
(+ 1 (* 2 3))
1 + 2 * 3
相較於一般多數的程式語言使用的中序表示法 (infix notation),運算子的位置在中間; Lisp 語法採取了前序表示法 (prefix notation),運算子的位置在最前面,且多加上了很多的括弧。
你可能會想:「如果我已經會寫 Lua 了,寫完 Lua 之後,把括弧和運算子的位置改一改,就變成會寫 Fennel 了?」
倒也沒有這麼單純,差異還有兩個層次,第一個層次是:「Fennel 做得到,Lua 做不到的事。」
第二個層次則是:「Fennel 承習 Clojure/Lisp 社群而來的開發風格。」
在命令式 (imperative) 語言裡,常常使用行數來做為量測程式碼數量的一種單位,且通常我們會認為我們是一行又一行地在寫程式碼。每一行的程式碼都是一個陳述 (statement),而陳述可以是呼叫函數、為變數賦值等等。上述的這個思維模型,可以稱之為「陳述導向」。陳述導向的思維模型在除錯時,特別是利用 Debugger 來除錯時,特別有道理。因為除錯時,我們會叫 Debugger 單步執行:「執行一行程式、立刻停下來。」
而 Lisp programmer 用不同的思維模型在寫程式:他們認為自己是透過一個又一個的表達式在寫程式。表達式與表達式之間以樹狀的方式相連,構成了語法樹。同時,一個模組 (通常就是一個 source code file),往往由多顆語法樹所構成。每一個表達式求值之後,都會有傳回值。就算是像 (print xxx)
這種只有副作用的函數呼叫表達式,也會有 nil
值的傳回值。
此外,由於採用互動式開發的關系,Lisp programmer 幾乎是隨時開著 Debugger 。所謂的互動式開發,它的運作原理是:當使用者下達 Evaluate Code
之類的編輯器指令時,編輯器會自動抓取一個表達式,送到一個正在執行中的直譯器進行求值,再將結果送回編輯器。換言之,Lisp programmer 在開發軟體時,他隨時有一個正在執行中的軟體,而該軟體的內部狀態一直照著他的意思在改變。這點就跟 non Lisp programmer 在操作 Debugger 時,得到的效果算是幾乎等價的:「有一個運行中的程式,而工程師可以控制它執行到哪一行,同時修改、監看它的內部狀態。」
我 2019 年去一家公司 L 社開發軟體,那時候因為是約聘雇的身分,L 社分配給我一台文書用的 Macbook Air 。我照著平常的方式開發軟體 (Clojure on JVM),然後,大約每個月會有一回,寫一寫程式, Macbook Air 要強制關機重開,因為記憶體被互動式開發給用光了。
想象一下,如果你只能在 1975 年的 MITS Altair 8800 上寫程式,你要選 C 還是 Lisp ?你不用選,因為先進的 Lisp 根本跑不動。
前面提到了 Lisp programmer 不是一行一行地寫,而是一個一個表達式地寫。那他們怎麼閱讀程式碼呢?
閱讀程式碼時,勢必要做一些「非正式推理」(informal reasoning) 。部分的程式碼,如果不能直觀地理解,就得先在腦海裡運作 (run) 它,再設法推理出它的行為。換言之,你就是一個活生生的直譯器。
Fennel 程式碼求值順序規則只有三條:
這個規則是指,當我們看到一個巢狀結構的表達式時,必須先從最內層的表達式開始求值,然後再向外層遞迴。這就像俄羅斯娃娃,必須一層一層地拆開,才能看到最裡面的那一個。
舉個例子:
(+ 1 (* 2 3))
在閱讀這段程式碼時,我們不能直接計算 (+ 1 ...)
,因為我們不知道第二個參數是什麼。我們必須先進入最內層的表達式 (* 2 3)
。在我們腦海中,首先求值 (* 2 3)
,得到 6
。接著,我們用這個值來替換掉原本的表達式,整段程式碼就變成了 (+ 1 6)
。最後,再求值這個外層表達式,得到 7
。
這個規則適用於任何包含多個表達式但是彼此並不嵌套的情境,無論是在一個檔案內,還是在一個函數內部。當程式碼裡有多個表達式時,求值的順序會從最上面、最前面的表達式開始,依序往下。
凡是不能用前兩條規則解釋執行順序的,都是 Macro ,而我們之後會在後續的章節詳細討論 Macro 。在初學來講,你只需要先知道 let
和 if
都是 Macro ,因為它們最常用。
if
的執行順序先對 A-expression
求值,如果為真,則求值 B-expression
;如果為假,則求值 C-expression
。
(if A-expression
B-exression
C-expression)
let
的執行順序先對 B-expression
求值,然後將其結果暫時綁定到 A-symbol
上,接著,再求值 D-expression
,然後將其結果暫時綁定到 C-symbol
上,最後,再求值 E-expression
與 F-expression
。
(let [A-symbol B-expression
C-symbol D-expression]
E-expression
F-expression)
剛才我們看到到 let
這種賦值的語法之後,這邊出現了一個問題:「let
表達式有傳回值嗎?」
有的,它的傳回值就是它裡頭的最後一個表達式的回傳值,也就是 F-expression
的傳回值。
還有另一個與 let
很類似的就是 fn
,它是用來定義函數的語法。下方是 print-and-multiply
是一個函數,當它被呼叫時,會去執行函數定義內的表達式,並將最後一個表達式的傳回值作為整個函數的傳回值。
(fn print-and-multiply [a b]
(print (+ a b))
(print (- a b))
(* a b))
以上面這段程式碼為例,print-and-multiply
的傳回值就是 (* a b)
這個表達式的結果,因為它也是整個函數定義裡最後一個被求值的表達式。
這點和多數命令式語言的函數傳回值有很大的不同。在許多命令式語言中,你必須明確使用 return
關鍵字來指定傳回值,否則函數可能沒有傳回值或是傳回 nil
。而在 Fennel 裡,最後一個表達式的傳回值會被自動當作整個函數的傳回值。
本篇介紹了 Fennel 為什麼可以速成、它的關鍵三個面向:Lisp, Hosted on Lua VM, Minimalism。其中,Fennel 的 Lisp 語法部分,也做了詳細地討論。
之後,我們將來再繼續討論 Fennel 的語意部分。
註:之後的章節,才會詳細討論「後設編程」與「函數式編程」。