有一個對 Clojure 評論是這樣子:「Clojure 是一種 Lisp ,但是因為有獨特的品味,難以歸類於傳統的 Lisp 。」
基本上,程式語言只要語法使用了 S 表達式,我們就會稱它為一種 Lisp 。這個定義很有道理,因為一旦用了 S 表達式,就意謂著三件事成為可能:
那 Clojure 的獨特品味長成什麼樣子呢?先來看一些 Clojure 特有的語法吧:
[] -> 向量
{} -> 字典
#{} -> 集合
#() -> 匿名函數
在學習 Lisp 之前,我已經寫了程式十年以上了。但是,Design Pattern 一書裡提到的 Interpreter Pattern ,我從來沒有用過。主要的原因是,我覺得要做樹走訪,很麻煩。
很多的程式語言都有處理樹狀結構的函式庫,但是,不僅不同程式語言的樹走訪函式庫不同,就連同一種程式語言的樹走訪函式庫常常都有極大的差異。無論是樹的結構、走訪樹的 API、全都沒有一致的。
也因止,我總是記不住任何一套樹走訪的函式庫,而 Interpreter Pattern 也因此從來沒有進入我的工具箱裡。
在 Lisp 社群,樹走訪卻是小菜一碟,幾乎人人都會,因為程式碼本身就是 S 表達式,Lisp 天生就非常適合處理樹狀結構。
用 Lisp 的話,會用什麼資料結構來呈現下方的樹呢?
祖父
/ \
父親 伯父
/ \
我 妹妹
當然也是 S 表達式啊。
(祖父 (父親 (我)
(妹妹))
(伯父))
Lisp 的 S 表達式,容許工程師可以輕易地表達『資料結構』(此處的資料結構一詞,不是演算法的資料結構,而是指類似 JSON, XML 這種形式的表示法。) 也正因如此,Lisp programmer 可以說是最早發現 JSON 表示法的價值的一群人。
在前面的例子裡,相信讀者已經可以感受到了「呈現形式」的重要性。如果資料可以有一個簡單、可讀、易理解的呈現方式,將大幅地提昇程式碼的可讀性。
然而,Clojure 還再多走一步,它企圖達成兩個目的:
如果只用 S 表達式來建構向量和集合,應該是這樣子寫。
(vector 1 2 3)
(set 1 2 3)
但是,Clojure 提供了更簡潔的語法,讓資料結構看起來更像資料:
[1 2 3] ;=> 表示一個向量內含 1, 2, 3 。
#{1 2 3} ;=> 表示一個集合內含 1, 2, 3 。集合之內的元素必是唯一的。
適度地用符號來加以修飾之後,是不是語法更加精簡,卻又還容易理解了呢?
傳統的程式語言通常將資料和邏輯分開處理。資料是資料,函數是函數,它們彼此獨立。然而,在 Clojure 中,邏輯常常可以透過某種資料結構來表達,而且還更容易理解。
什麼意思呢?讓我們來看一個簡單的例子。
在 Clojure 裡,你可以用一個向量(vector)來代表一個「路徑」。例如,[:a :b :c]
這個向量可以表示一個循序的邏輯操作:先進入 :a
,再進入 :b
,最後到達 :c
。
當你將這個向量傳給 get-in
這個函數時,這個向量就不再只是一個單純的資料,它搖身一變,成為了「告訴函數該如何執行」的邏輯。
(def data {:a {:b {:c 100}}})
(get-in data [:a :b :c])
;; => 100
在這裡,[:a :b :c]
這個向量清楚地表達了「從 data
這個字典中,依序取出 :a
, :b
, :c
所對應的值」的這個邏輯。
同樣地,集合(set)也可以用來表達邏輯。想像你有一個數據序列 data-seq
,你想要篩選出其中所有包含 :a
或 :b
的元素。你不需要寫一個冗長的匿名函數,直接將集合 #{:a :b}
傳給 filter
函數即可。
(def data-seq [:a :c :b :d])
(filter #{:a :b} data-seq)
;; => (:a :b)
在這個例子中, filter
函數會依序把 data-seq
中的每個元素 (:a、:c、:b、:d)
傳給作為函數的 #{:a :b}
。這個集合不僅是資料,也定義了篩選的規則。
這就是 Clojure 的核心思想之一:將邏輯透過為簡單、可讀的資料結構來表達,如此可以讓程式碼更簡潔、更易於理解。也可以說,在這些例子裡,資料結構就是一種 DSL (Domain-Specific Language),因為它被賦予了新的語意。
這種資料導向的思維模式,讓 Clojure programmer 一方面繼承了 Lisp programmer 的傳統:「為特定問題定義新語言 (DSL),最後讓程式碼成為更能表達其意圖的絕佳工具。」另一方面,又巧妙地迴避了使用過多的 Macro 會讓程式難以除錯的缺點。
讀者可能會想問:「那要是我想在資料結構上賦予很複雜的語意的話,該怎麼設計?」
這就是你可以來寫樹走訪直譯器 (treewalk interpreter) 的時刻了。
傳統 Lisp 的經典說法之一 Code is Data ,可以這樣子理解:因為程式碼是 S 表達式,所以 Code 也是 Abstract Syntax Tree,而 Abstract Syntax Tree 是一種資料結構 (Data) ,即程式碼就是一種資料結構。而 Lisp Macro 可以轉換 S 表達式,因此可以添加新的語意,換言之,Code is Data 這句是在談 Lisp Macro 。
另一個經典說法則是 Data is Code,則可以理解成:在 Lisp 程式碼之中,有一些 S 表達式,它們不是函數、不是巨集、而是資料結構 (Data)。而這些資料結構往往可以被賦予一些新的語意,所以其實它們也是 DSL,也就是另一種形式的 Code。如果要賦予比較複雜的語意時,就需要使用樹走訪直譯器,換言之,Data is Code 這句是在談資料結構做為 DSL。
相對於傳統的 Lisp 偏重於 Code is Data 哲學;Clojure 除了使用 Code is Data 哲學之外,還積極地使用 Data is Code 哲學。
本篇介紹了傳統 Lisp 與 Clojure 的關鍵區別:資料導向編程。
綜合上述,Lisp 可以帶來四種強大的特性:
有趣的事情是,儘管在 non-Lisp 語言中很少同時具備這四種特性,這些特性卻又往往會在 Modern Data Stack 裡、某些用 JSON/YAML 做為 scripting language 的 API 設計裡找到。
Lisp 的哲學,總是會被一代又一代的工程師重新發現,對吧?