Clojure

執行緒巨集指南

執行緒巨集,也稱為箭頭巨集,會將巢狀函式呼叫轉換為線性函式呼叫流程,以提升可讀性。

執行緒優先巨集 (->)

在慣用的 Clojure 中,純函式會將不可變資料結構轉換為所需的輸出格式。考慮一個函式,它對一個映射套用兩個轉換

(defn transform [person]
   (update (assoc person :hair-color :gray) :age inc))

(transform {:name "Socrates", :age 39})
;; => {:name "Socrates", :age 40, :hair-color :gray}

transform 是常見模式的一個範例:它會取一個值並套用多個轉換,而管道中的每個步驟會將前一步驟的結果作為其輸入。通常可以透過改寫程式碼來使用執行緒優先巨集 -> 來改善此類型的程式碼

(defn transform* [person]
   (-> person
      (assoc :hair-color :gray)
      (update :age inc)))

將初始值作為其第一個引數,-> 會將其執行緒化到一個或多個表達式中。

注意:此處「執行緒」一詞(意指將值傳遞到函式管道中)與並發執行緒執行概念無關。

從第二個表單開始,巨集將第一個值插入為其第一個參數。這會在每個後續步驟中重複,其中前一個運算的結果插入為下一個表單的第一個參數。看起來像具有兩個參數的函數呼叫實際上是一個具有三個參數的呼叫,因為執行緒值插入在函數名稱之後。標記插入點三個逗號可能有助於說明

(defn transform* [person]
   (-> person
      (assoc ,,, :hair-color :gray)
      (update ,,, :age inc)))

儘管在實務中並不常見,但這個視覺輔助是有效的 Clojure 語法,因為逗號在 Clojure 中是空白。

語義上,transform* 等同於 transform:箭頭巨集在編譯時展開為原始程式碼。在每種情況下,函數的傳回值都是最後一個運算的結果,也就是呼叫 update。重新編寫的函數讀起來就像轉換的描述:「取一個人,讓他們長出白髮,增加他們的年齡,並傳回結果」。當然,在不可變值的背景下,不會發生實際的變異。相反地,函數只會傳回一個具有更新屬性的新值。

語法上,執行緒巨集也允許讀者從左到右的順序來讀取函數的應用,而不是從最內層的表達式讀取。

thread-last (->>) 和 thread-as (as->) 巨集

-> 巨集遵循純粹的語法轉換規則:對於每個表達式,在函數名稱和第一個參數之間插入執行緒值。請注意,執行緒表達式是 (f arg1 arg2 …​) 形式的函數呼叫。沒有括號的單獨符號或關鍵字會被解釋為具有單一參數的簡單函數呼叫。這允許簡潔的單元函數鏈

(-> person :hair-color name clojure.string/upper-case)

;; equivalent to

(-> person (:hair-color) (name) (clojure.string/upper-case))

然而,-> 並不普遍適用,因為我們並不總是希望在初始位置插入執行緒參數。考慮一個計算小於 10 的所有奇數正整數平方和的函數

(defn calculate []
   (reduce + (map #(* % %) (filter odd? (range 10)))))

transform 一樣,calculate 是轉換的管線,但與前者不同,執行緒值出現在參數清單中最後一個位置的每個函數呼叫中。我們需要使用 thread-last 巨集 ->>,而不是 thread-first 巨集

(defn calculate* []
   (->> (range 10)
        (filter odd? ,,,)
        (map #(* % %) ,,,)
        (reduce + ,,,)))

同樣地,儘管通常會省略,但三個逗號標記參數將被插入的位置。如你所見,在使用 ->> 執行緒的表單中,執行緒值會插入在參數清單的結尾而不是開頭。

Thread-first 和 thread-last 會在不同的情況下使用。哪一個合適取決於轉換函數的簽章。最終,你將需要查閱所用函數的文件,但有一些經驗法則

  • 依慣例,對序列運作的核心函式會將序列當作最後一個參數。因此,包含 mapfilterremovereduceinto 等的管線通常會呼叫 ->> 巨集。

  • 另一方面,對資料結構運作的核心函式會將其運作的值當作第一個參數。這些函式包括 assocupdatedissocget 及其 -in 變體。使用這些函式轉換映射的管線通常需要 -> 巨集。

  • 透過 Java 互操作 呼叫方法時,Java 物件會傳入作為第一個參數。在這種情況下,-> 很實用,例如用來檢查字串是否有前綴

    (-> a-string clojure.string/lower-case (.startsWith "prefix"))

    另請注意更專業的互操作巨集 ..doto

最後,有些情況既不適用 -> 也不適用 ->>。管線可能包含插入點不同的函式呼叫。在這些情況下,您需要使用 as->,這是更靈活的替代方案。as-> 需要兩個固定參數和可變數量的表達式。與 -> 一樣,第一個參數是要透過以下形式串接的值。第二個參數是繫結的名稱。在每個後續形式中,繫結的名稱可用於前一個表達式的結果。這允許值串接至任何參數位置,不只是第一個或最後一個。

(as-> [:foo :bar] v
  (map name v)
  (first v)
  (.substring v 1))

;; => "oo"

some->、some->> 和 cond->

Clojure 的兩個更專業的串接巨集 some->some->> 在與 Java 方法介面時最常使用。some-> 類似於 ->,它會透過許多表達式串接一個值。然而,當鏈中的任何一點有表達式評估為 nil 時,它也會短路執行。在 Java 互操作 的背景下,箭頭巨集的一個常見問題是 Java 方法不希望傳入 nil (null)。在這些情況下,避免 NullPointerException 的一種方法是加入明確的防護措施

(when-let [counter (:counter a-map)]
  (inc (Long/parseLong counter)))

some-> 以更簡潔的方式達成相同效果

(some-> a-map :counter Long/parseLong inc)

如果 a-map 缺少 :counter 鍵,整個表達式將評估為 nil,而不是引發例外。事實上,此行為非常有用,因此即使不需要執行緒處理,也經常看到 some-> 被使用

(some-> (compute) Long/parseLong)

;; equivalent to

(when-let [a-str (compute)]
  (Long/parseLong a-str))

-> 相似,巨集 cond-> 會取得一個初始值,但與前者不同的是,它會將其引數清單解釋為一系列 test, expr 對。cond-> 會將一個值執行緒處理到表達式中,但會略過測試失敗的表達式。對於每個對,test 會被評估。如果結果為真,則會使用執行緒處理的值作為其第一個引數來評估表達式;否則,評估會繼續進行下一個 test, expr 對。請注意,與其相關的 some->cond 不同,cond-> 永遠不會短路評估,即使測試評估為 falsenil

(defn describe-number [n]
  (cond-> []
    (odd? n) (conj "odd")
    (even? n) (conj "even")
    (zero? n) (conj "zero")
    (pos? n) (conj "positive")))

(describe-number 3) ;; => ["odd" "positive"]
(describe-number 4) ;; => ["even" "positive"]

cond->> 會將執行緒處理的值插入為每個形式的最後一個引數,但在其他方面的工作方式類似。

原始作者:Paulus Esterhazy