在我的 Gaiwan 工作之中,有一段程式碼的品質有點糟,我一直覺得它是我的心中的痛。而且,有很長一段時間,我都想不出什麼比較好的方法去處理它們。
這段程式碼是 Nested If 。每一個 step-* 是一個 side effect 操作,而每一個 handler 則會記錄 log 。
(if (step-a ctx)
  (if (step-b ctx)
    (if (step-c ctx)
        (do-something)
      (handle-c ctx))
    (handle-b ctx))
  (handle-a ctx))寫這種程式碼真的滿痛苦的,它痛苦的來源主要有幾個:
這些
if彼此之間緊密地靠在一起,用這種寫法,如果(handle...)稍微多個幾行,整個區塊就會非常地長。很難閱讀。容易漏寫 handler branch 。
日後也不容易修改。
一寫完,我馬上拉著同事討論:這段一定有更好的寫法吧?那時候,我第一個想到的選項是 Monad 。或許 Monad 可以讓這段程式碼稍微容易讀一點,但是,考量到日後維護成本,我們擔心 Monad 的抽象可能成為理解障礙,因此暫不採用。
some->
其實 Clojure 有與 Monad 近似的語法可以處理這類問題的函數,於是我想到了 some->。在下方的範例裡,如果任何一個 step 傳回 nil ,它就會提早結束。
(some-> context
        step1
        step2
        step3)但是,這依然不是我要的,因為每一個 step 如果失敗,我需要用獨立的 handler 來加以記錄。日後當這段程式在生產環境運行的時候,我才能快速地定位,到底是出錯在哪一個 step?
ok->
我在 Dave Liepmann 的一篇文章找到了我覺得可行的解法。該篇文章的連結裡,提供了一個 macro: ok->,它可以算是 some-> 的改進版。
每個 step 若出錯,只要寫入 :error,流程就會直接結束,錯誤資訊也保留在 context。換言之,我可以再根據 :error 對應的值來選擇 handler 來處理錯誤。
ok-> 已經算是可以用了。如果說,它還有什麼令人不滿意的地方,那就是 Locality of Behavior 方面有點不太理想,因為我必須將 step 與 handler 寫在不同的位置。
(defmacro ok->
  "Like `some->` but for `:error` keys instead of `nil` values:
  When expr does not contain an `:error` key, threads it into the first
  form (via ->), and when that result does not contain an `:error` key, through
  the next etc"
  [expr & forms]
  (let [g (gensym)
        steps (map (fn [step] `(if (contains? ~g :error)
                                ~g
                                (-> ~g ~step)))
                   forms)]
    `(let [~g ~expr
           ~@(interleave (repeat g) (butlast steps))]
       ~(if (empty? steps)
          g
          (last steps)))))ensure->
受到 ok-> 的啟發,我開發了 ensure->
(defmacro ensure->
  "Threaded validation pipeline with short-circuiting and error handling.
  Takes a context and a series of condition/handler pairs followed by a final
  expression to evaluate if all conditions pass.
  Each condition is evaluated in order. If the condition returns truthy, the
  pipeline proceeds. If it returns falsey, the corresponding handler is invoked
  with the original context, and the pipeline terminates immediately.
  This is useful when:
  - Each step has a specific failure case to handle
  - You want linear syntax instead of deeply nested `if`
  - You want to maintain clear, stepwise semantics in validations or workflows"
  [context & steps]
  (let [pairs (partition 2 (butlast steps))
        final-fn (last steps)]
    (reduce
     (fn [acc [step-fn handler-fn]]
       `(let [ctx# ~context]
          (if (~step-fn ctx#)
            ~acc
            (~handler-fn ctx#))))
     `(~final-fn ~context)
     (reverse pairs))))用法:
(ensure-> ctx
          step-a handle-a
          step-b handle-b
          step-c handle-c
          do-something)上述的這一段會展開成:
(if (step-a ctx)
  (if (step-b ctx)
    (if (step-c ctx)
      (do-something ctx)
      (handle-c ctx))
    (handle-b ctx))
  (handle-a ctx))ensure-> 的語意是:「驗證一系列的條件,並且在失敗點直接結束並做對應處理。」由於這個模式算是普遍出現,所以將這個模式抽象為一個通用的高階語法,開發者也很容易理解。更進一步來講,它的重點並不是為了少寫幾行 if,而是讓語意從『控制流程』提升到『驗證與錯誤處理』。
最初的 nested if 如果改成用 ensure-> 這樣子的 macro 來改寫的話,一方面程式碼會比較容易閱讀、容易修改,因為將嵌套的語法改成了線性的語法;另一方面不需額外引入複雜的抽象,只是簡單的語法糖;此外,ensure->命名本身就對應了一個模式,這可以讓程式碼呈現的語意更加清楚地表現開發者的意圖。
cond not
使用 ensure-> 這樣子的 macro 雖然可以讓語意清晰,但是有沒有什麼可以不需要定義 macro 的解決方案呢? 其實 ensure-> 可以被 cond not 這個 pattern 取代。
(def ctx ...)
(cond
  (not (step-a ctx)) (handle-a ctx)
  (not (step-b ctx)) (handle-b ctx)
  (not (step-c ctx)) (handle-c ctx)
  :else
  (do-something ctx))if pattern 是一種 code smell
仔細想想,類似 nested if 這樣子的 if pattern 其實滿多的,這些 if pattern 組合之後,會產生更高階的語意。然而,如果只能用 if 來表達的話,語意的表達層級就會停留在分支的控制結構,因而讓程式碼難以理解又不易修改。
以下是幾個相關的 idiomatic 寫法,展示如何用更語意化的方式避免冗長的 if 結構。
嵌套的「滿足條件後修改」
問題:對於某個 HashMap hm ,滿足了某個條件,才修改它,否則不修改。
(if c
    (assoc hm :x y)
    hm)解法:
(cond-> hm
   c (assoc :x y))當有很多個條件時,改成 cond-> 的話,可讀性會大幅提昇,因為同樣也是將嵌套的語法改成了線性的語法。
讓 update-in 不被容器為 nil 妨礙
問題:
我們需要對一個 HashMap hm 裡的某個 path 做操作。
如果該 path 為空,就插入空的 vector,並且在該 vector 裡插入 val
如果該 path 已經有一個 vector ,就把 val 插入 vector 的尾端
直覺的寫法可能是:
(if (nil? (get-in hm path)
    (assoc-in hm path [val])
    (update-in hm path conj val))解法:
(update-in hm path (fnil conj []) val)即使多次修改,也要保持 Atom 的值滿足特定條件
問題:對於某個 Atom ,必須確保它的值永遠是正數。
(def a
  (atom 3))
(swap! a (fn [n]
           (if (pos? (dec n))
             (dec n)
             (throw (ex-info "content must be positive"
                             {:a 1})))))解法:
(def a
  (atom 3 :validator pos?))
(swap! a dec)結論
當我們一層層寫下 nested if,其實是讓程式語言的語法牽著我們走。我們想表達「這是一連串條件檢查,遇錯就停止,否則繼續做事」,但語言本身沒給我們太好的工具。
這也是為什麼 Clojure 社群長年發展出許多 idioms——像是 cond not、cond->、fnil、:validator、,它們不是單純省略程式碼的 macro,而是讓你的意圖更清晰。
程式設計的終極目標,從來不只是控制流程,而是設計語意。
當你再一次遇到混亂的 if 叢林,不妨停下來想一想:你真正想表達的是什麼?然後,讓語法為你的語意服務,而不是反過來。


