關於延展性
從 Clojure 的延展機制討論延展性的定義與應用
曾經有很長一段時間,我對 Clojure 有些誤解:我一直以為 Clojure 所提供的延展性就是 Macro 。不只如此,我讀過的一些關於 Lisp 的文章,這些文章一方面強調了 Lisp 的 Macro 遠比一般語言的 Macro 先進,另一方面又大大地宣揚了 Macro 的好處──可以延展的語言是多麼地有用。然而,讓我深深感到矛盾的地方是,Clojure 社群使用 Macro 採取保守的態度,所以,難道 Clojure 在延展方面比起其它的 Lisp 語言來得差嗎?
後來,我發現我的誤解有兩個層面:
首先,Clojure 至少有兩種延展機制:Macro 與 Protocol 。當我們使用 Clojure 語言時,很多時候,我們並不會詳細地去區分 core syntax 與 core library functions 的分別。換言之,如果延展的部分是 core library functions ,這跟延展 core syntax ,對於之後的使用者的感受是幾乎一樣的。更具體地來講,Clojure 的 core library 裡有許多部分是用 Protocol/Interface 語法來建構的, Protocol/Interface 語法就是 core library 裡預設留下的延展點,所以 core library 的功能也是可以被延展的。
第二,我過去一直把「延展」與「延展機制」混在一起。我總是把焦點放在,「喔,我又發現了什麼語言、什麼資料庫、什麼軟體,它可以加入 plugin ,太棒了,它有延展機制,所以可以延展。」然而,延展機制是達成延展的手段,那延展到底是什麼?它解決了什麼問題、帶來了什麼好處?我卻一直沒有思考清楚。
這邊提出一個延展性的定義
給定一個基於某些對外界假設而開發完畢的模塊或是區段程式碼,當外界假設變動時,在不做修改的前提下,透過既定機制增加或改變它的行為,使其適應新的需求。
依照這個定義,延展帶來的好處是:
節省成本。不做修改的話就不用擔心既有的功能又壞了、也不會有 regression 的問題。
降低複雜度。透過既定機制就可以增加或是改變模塊的行為,所以比複製整個模塊再做修改,節省了大量的程式碼。
賦能使用者。模塊已經開發完畢了卻依然還可以修改此一特性,在模塊的開發者與使用者屬於不同組織或是不同團隊時,提供了極大的彈性,因為使用者可以 self-serve。
接下來,我們來看一些實際的例子,感受一下實際的延展。
Macro
先看一些常見的內建 Macro :
->把線性排列的語法轉變成括號嵌套,可以視為提供了一種新的 DSL (domain specific language)。comment:將一段程式碼忽略。with-open:為一段程式碼賦予只有在區段內可以使用的資源,資源離開區段後會自動關閉。with-redefs:為一段程式碼賦予只有在區段內才重新定義的全域變數。with-in-str:為一段程式碼賦予只有在區段內才被綁定到特定StringReader的*in*。
這邊可以粗略地將 Macro 分成兩大類別:
non with-style Macro
with-style Macro
Non with-style Macro
這類型 Macro 的引數通常長成 & body 的形式,而它的內部會解析 body 內部的語句並且將其改成新的求值方式。
以 core.async/go 為例:
(go
(println "Before")
(<! (timeout 1000))
(println "After"))go Macro 會將 body 轉換成 state machine 以將其執行方式改成非同步運作。它不只是包裝一段程式碼,而是改寫了傳入的程式碼。
傳入這類 Macro 作為引數的程式碼,常常帶有新的語法或語意,也因此我們可以視為這種 Macro 延展了 Clojure 語言本身,因為它們為 Clojure 語言本身增加了新的 DSL 。
with-style Macro
相對地,有些 Macro 的引數通常長成 a b c & body 的形式,而它的內部會有一段 ~@body 的引用。它不拆解 body 內的語句,只是在 body 的執行之前或之後加入額外的處理,由於保持了 body 的原始結構,這讓它特別適合用來處理資源管理、上下文設定等情境。
在 embedkit 裡有一個 with-style Macro 相當有啟發性,因為它將認証的狀態視為一種上下文。
with-refresh-auth:當 API request 失敗且失敗原因是 401,就更新認証並且重新執行 API request。
(defmacro with-refresh-auth [conn & body]
`((fn retry# [conn# retries#]
(Thread/sleep (* 1000 retries# retries#)) ; backoff
(try
~@body
(catch clojure.lang.ExceptionInfo e#
(if (and (= 401 (:status (ex-data e#)))
(instance? clojure.lang.Atom conn#)
(< retries# 4))
(do
(reset! conn# (connect @conn#))
(retry# conn# (inc retries#)))
(throw e#)))))
~conn
0))
;; Use with-refresh-auth to wrap the do-request
(defn mb-request [verb conn path opts]
(with-refresh-auth conn
(do-request (request conn verb path opts))))Protocol
應該也有許多的 Clojurians 跟我一樣,總覺得 Protocol 到底該怎麼用,是個很抽象的難題,總是想不清楚具體的情境。這個問題,我覺得最好的答案是 ask.clojure.org 的答案。
protocol functions are better as SPI to hook in implementations than in API as functions consumers call directly.
如果你跟我一樣,對於什麼是 SPI (service provider interface) 是什麼,無法立刻清楚地想象,可以來研究一下 buddy-auth 這個函式庫。
buddy-auth 是一種 web 常用的 authentication 函式庫,並且它提供了常見數種 authentication 機制。而且使用者也延展它,在不修改它的程式碼的前提之下,加入新的 authentication 機制。
要定義一種 authentication 機制,就是要用 reify 來實現 IAuthentication 這個 Protocol
比方說,http-basic-backend 是一種基礎的 authentication 機制,它就實作了 IAuthentication
(defn http-basic-backend
[& [{:keys [realm authfn unauthorized-handler] :or {realm "Buddy Auth"}}]]
{:pre [(ifn? authfn)]}
(reify
proto/IAuthentication
(-parse [_ request]
(parse-header request))
(-authenticate [_ request data]
(authfn request data))
...
)使用 buddy-auth 的時候,需要在 ring-handler 加上 wrap-authenticationmiddleware ,而這個 middleware 最後就會去呼叫 proto/-parse 與 proto/-authenticate 。
看了這張圖,你可能會覺得,這不就是 Strategy Design Pattern 嗎?在這個 Pattern 裡的 Strategy 就是 Service Provider Interface ,它讓 Authentication 可以在 buddy-auth 這個模組完成之後,不修改任何程式碼就替換新的 Authentication 機制。
總結
如果為上述三種機制取個名稱的話:
Non with-style Macro 可以稱之為 syntax-rewriting extension
With-style Macro 可以稱之為 contextual extension
Protocol 可以稱之為 replace-based extension
replace-based extension 相對容易理解,也常見得多,在其它程式語言也有類似的機制。contextual extension 雖然已經是 meta-programming 等級的技巧了,依然比 syntax-rewriting extension 單純得多,算是一般人可以掌握的技術。至於 syntax-rewriting extension 則可視為是對 Clojure 語言的延展,這部分的技術進入門檻相對高,掌握這種技術的人,可以說是編譯技術的專家了。
Clojure 對延展的支援相當好──它提供了許多不同的延展機制,讓語言本身、區段程式碼、core library、使用者自訂模塊都可以延展。想要讓自己的程式設計水準更上一層樓的人,不妨認真地思考:「要怎麼設計軟體,讓它們可被延展。」這會讓你開發的軟體更加的 Clojurish。
註:
在這篇文章裡,你可以把模組和 Library 視為是同義詞,對我來講,Library 就是 published module 。Interface 與 Protocol 也可以視為同義詞,他們有微小的區別,但是在本篇文章裡的使用情境則沒有分別。




