iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0

二十多年前,那時的電腦書書名很喜歡命名為《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 的語意。

https://ithelp.ithome.com.tw/upload/images/20250903/20161869mmuzYCvfl9.png

由於 Fennel 借用了 Lua 的運行環境 (runtime)、函式庫 (library)、絕大部分的語意 (semantics),只是加上了 Lisp 的語法 (syntax),要掌握 Fennel ,難度基本上跟要掌握 Lua 相去不遠,真的可以速成!

先談談語法

前面談到語法 (syntax) 與語意 (semantics) 兩個詞,並且明確地指出了它們的不同。這邊舉個例子來解釋清楚一點:

  • Fennel 的 Hello World。
(print "hellow world")
  • Lua 的 Hello World。
print("hellow world") 

上面兩段程式碼都是 Hello World。在表現形式 (語法) 方面,Fennel 因為採用了 Lisp 語法,所以括號擺放的位置比較特殊,但是,在運作行為 (語意) 方面,都是去呼叫同一個 print 函數,所以會一模一樣。

再來一個更複雜的例子,例如要計算 1 + 2 * 3。

  • Fennel
(+ 1 (* 2 3))
  • Lua
1 + 2 * 3

相較於一般多數的程式語言使用的中序表示法 (infix notation),運算子的位置在中間; Lisp 語法採取了前序表示法 (prefix notation),運算子的位置在最前面,且多加上了很多的括弧。

你可能會想:「如果我已經會寫 Lua 了,寫完 Lua 之後,把括弧運算子的位置改一改,就變成會寫 Fennel 了?」

倒也沒有這麼單純,差異還有兩個層次,第一個層次是:「Fennel 做得到,Lua 做不到的事。」

  1. 運算子優先順序:Fennel 開發者不需要記憶運算子優先順序 (運算順序完全由括弧決定),而 Lua 開發者還是必須搞懂。
  2. 開發方式:Fennel 可以支援互動式開發、S 表達式編輯,而 Lua 不行。
  3. 後設編程(註):Fennel 可以寫 Lisp Macro ,而 Lua 不行。

第二個層次則是:「Fennel 承習 Clojure/Lisp 社群而來的開發風格。」

  • 表達式導向 (expression-oriented) 。
  • 函數式編程(註)。

表達式導向

在命令式 (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 根本跑不動。

如何閱讀 Fennel 程式碼

前面提到了 Lisp programmer 不是一行一行地寫,而是一個一個表達式地寫。那他們怎麼閱讀程式碼呢?

閱讀程式碼時,勢必要做一些「非正式推理」(informal reasoning) 。部分的程式碼,如果不能直觀地理解,就得先在腦海裡運作 (run) 它,再設法推理出它的行為。換言之,你就是一個活生生的直譯器。

Fennel 程式碼求值順序規則只有三條:

  1. 由內而外
  2. 由上而下
  3. 照 Macro 的實作

由內而外

這個規則是指,當我們看到一個巢狀結構的表達式時,必須先從最內層的表達式開始求值,然後再向外層遞迴。這就像俄羅斯娃娃,必須一層一層地拆開,才能看到最裡面的那一個。

舉個例子:

(+ 1 (* 2 3))

在閱讀這段程式碼時,我們不能直接計算 (+ 1 ...),因為我們不知道第二個參數是什麼。我們必須先進入最內層的表達式 (* 2 3)。在我們腦海中,首先求值 (* 2 3),得到 6。接著,我們用這個值來替換掉原本的表達式,整段程式碼就變成了 (+ 1 6)。最後,再求值這個外層表達式,得到 7

由上而下

這個規則適用於任何包含多個表達式但是彼此並不嵌套的情境,無論是在一個檔案內,還是在一個函數內部。當程式碼裡有多個表達式時,求值的順序會從最上面、最前面的表達式開始,依序往下。

照 Macro 的實作

凡是不能用前兩條規則解釋執行順序的,都是 Macro ,而我們之後會在後續的章節詳細討論 Macro 。在初學來講,你只需要先知道 letif 都是 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-expressionF-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 的語意部分。


註:之後的章節,才會詳細討論「後設編程」與「函數式編程」。


上一篇
Fennel 簡史與開發環境
下一篇
Fennel 語言速成 -- 核心語法
系列文
在 Neovim 中探索 Fennel 與函數式編程5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言