Clojure 函数式设计模式:组合子、解释器与 Monad 实战

深入 Clojure 的函数式设计模式:用组合子模式替代策略/模板方法、用解释器模式实现 DSL、用命令模式封装副作用、用 Either/Maybe 实现类型化错误处理,以及 Monad 风格数据流组合,帮助你把面向对象的设计模式翻译成函数式语言习惯。

设计模式是应对「反复出现的软件结构问题」的命名解法。GoF 的经典模式大多面向对象语言,很多模式依赖继承、可变状态和类——在 Clojure 里它们要么被语言特性直接取代(多方法取代策略、高阶函数取代模板方法),要么需要换一种函数式表述(副作用用命令/组合子封装、错误用 Either 传递)。本文把 GoF 中最常被问到的模式逐一「翻译」成 Clojure 习惯用法,并重点讲透组合子与 Monad 两种函数式原生模式。

1. 设计模式的函数式视角

1.1 哪些模式「消失了」

GoF 模式OO 的实现方式Clojure 的直接替代
Strategy定义策略接口 + 多态实现高阶函数(把算法作为参数)
Template Method抽象类 + 子类覆写钩子高阶函数 + 默认参数
Observer观察者接口 + 事件注册add-watch / core.async 通道
IteratorIterator 接口 + next/hasNextseq 抽象(map/reduce/lazy-seq)
Factory工厂类层次普通函数 + defmulti 分派
Singleton私有构造 + 静态实例原子/单例 var + 不可变引用

核心洞察:函数式语言里,很多模式是「语言本身就替你做了」。模式的价值在于识别结构,而不是照搬类图。

1.2 剩下的模式如何翻译

策略 → 函数、观察者 → watch、迭代器 → seq……这些是「直译」。真正需要费心的是三类:行为封装(命令)、算法可配置(解释器)、错误与组合(Monad)。下面逐一展开。

2. 命令模式:把副作用封装成值

2.1 为什么需要命令

在函数式代码里,「执行一个操作」应该被建模成可传递、可延迟、可回放的值。命令模式把「做什么」与「何时做/怎么做」分离:

;; 命令 = 描述操作的数据
(defn create-command [op payload]
  {:op op :payload payload :ts (System/currentTimeMillis)})

;; 命令执行器:集中分发
(defmulti execute (fn [cmd] (:op cmd)))

(defmethod execute :user-create [{:keys [payload] :as cmd}]
  (let [user (jdbc/insert! :users payload)]
    (log/info "user created" (:id user))
    user))

(defmethod execute :user-delete [{:keys [payload]}]
  (jdbc/delete! :users {:id (:id payload)})
  nil)

;; 命令队列:可持久化、可重放、可审计
(defn enqueue! [db cmd]
  (jdbc/insert! db :command_queue {:payload (pr-str cmd)}))

2.2 命令的审计与重放

命令模式的工程价值在于可追溯:把命令序列持久化,就能重建任意时刻的状态(事件溯源的雏形):

;; 回放全部命令,重建最终状态
(defn replay [db user-id]
  (reduce
    (fn [state cmd] (execute cmd))
    initial-state
    (jdbc/query db ["select payload from command_queue
                     where user_id = ? order by ts" user-id])))

与 /clojure-event-sourcing-cqrs/ 中的事件溯源一脉相承——命令是「意图」,事件是「事实」。

3. 解释器模式:用数据定义行为

3.1 表达式即数据

解释器模式把「语言的语法树」建成数据,再用一个求值函数解释它。Clojure 的「代码即数据」让这几乎是原生的:

;; 一个简单的规则 DSL:用嵌套 vector 描述规则
(def rule
  [:and
   [:> [:field :order_amount] 100]
   [:or
    [:= [:field :payment_method] "wxpay"]
    [:contains? [:field :tags] :promo]]])

;; 规则求值器:解释器模式
(defmulti eval-rule (fn [node _ctx] (first node)))

(defmethod eval-rule :and [[_ & clauses] ctx]
  (every? #(eval-rule % ctx) clauses))

(defmethod eval-rule :or [[_ & clauses] ctx]
  (some #(eval-rule % ctx) clauses))

(defmethod eval-rule :> [[_ lhs rhs] ctx]
  (> (eval-rule lhs ctx) (eval-rule rhs ctx)))

(defmethod eval-rule :field [[_ k] ctx]
  (get-in ctx k))

;; 使用:把 DSL 从「字符串规则」变成「可执行判定」
(eval-rule rule {:order_amount 150 :payment_method "wxpay" :tags [:promo]})
;; => true

3.2 解释器 vs 硬编码

解释器模式把业务规则从代码里抽离到数据:非技术人员可以维护规则,规则可以热更新、可以版本化、可以跨端复用。参见 /clojure-macro-programming/ 中宏构建 DSL 的对比——宏在编译期展开,解释器在运行期求值,各有适用边界。

4. 策略与模板方法的函数式表达

4.1 策略 = 高阶函数

;; 排序/校验/定价策略都只是函数
(defn price-rule:standard [base] base)
(defn price-rule:vip [[discount _] base] (* base discount))
(defn price-rule:promo [{:keys [promo]} base] (* base (- 1 promo)))

;; 把策略作为参数传入
(defn checkout
  [{:keys [price-strategy] :as config} cart user]
  (let [base (reduce + (map :price cart))]
    (price-strategy user base)))

4.2 模板方法 = 参数化钩子

模板方法定义算法骨架,让子类覆写某几步。函数式写法把「钩子」作为函数参数,或提供默认实现:

;; 通用「重试」骨架,让调用方注入每一步的策略
(defn retry*
  [{:keys [attempts delay should-retry on-exhausted]} f]
  (loop [n attempts]
    (let [res (try {:ok (f)}
                   (catch Exception e {:err e}))]
      (cond
        (:ok res) (:ok res)
        (and (> n 1) (should-retry res)) (do (Thread/sleep delay)
                                             (recur (dec n)))
        :else (on-exhausted res)))))

;; 用法:注入超时与退避策略
(retry* {:attempts 3
         :delay 200
         :should-retry #(= 429 (:status (:err %)))
         :on-exhausted (fn [{:keys [err]}] (throw err))}
        #(http/post "https://api.example.com/order" {:body order}))

5. Either / Maybe:类型化错误处理

5.1 用值表示「可能失败」

Go 用多返回值、Java 用异常、Clojure 的惯用做法是用数据表示成功或失败,让错误参与组合而非中断流程:

;; 极简 Either:[:ok v] / [:err e]
(defn safe-divide [a b]
  (if (zero? b) [:err {:msg "division by zero"}]
      [:ok (/ a b)]))

;; 组合:任何一个环节失败,整条链短路
(defn pipeline [a b]
  (let [[s1 v1] (safe-divide a b)
        [s2 v2] (if (= s1 :err) [:err v1] (safe-divide 100 v1))]
    (if (= s2 :err) [:err v2] [:ok v2])))

(pipeline 10 2)  ; => [:ok 50]
(pipeline 10 0)  ; => [:err {:msg "division by zero"}]

5.2 用 some-> / some-fn 处理 Maybe

Clojure 标准库自带 some->(nil 短路线程宏),处理「中间任一步可能返回 nil」的场景非常顺手:

(some-> (get-user id)          ; 用户可能不存在
        :profile                ; profile 可能为空
        :settings
        (get :notify)
        (update :email-count inc))
;; 任一步 nil → 整体 nil

6. Monad 风格的数据流组合

6.1 什么是 Monad(在 Clojure 语境)

Monad 本质是把「带上下文的值」的组合规则抽象出来。最常见的三个 Monad 是:

Monad上下文组合效果
Maybe可能为空短路:任一环节空 → 整体空
Either可能失败短路:任一环节错 → 整体错
State隐式状态线程化状态,无需手动传递

6.2 用 core.async 通道模拟 bind

Clojure 不强制用范畴论术语,->> 和 some-> 已经覆盖 90% 场景。真正的 Monad 组合器可用 let 表达:

;; 用 let 表达 Maybe 的组合语义
(defn get-order-email [order-id]
  (let [order   (find-order order-id)          ; Maybe Order
        user-id (some-> order :user-id)        ; 短路
        user    (find-user user-id)
        email   (some-> user :email)]
    email))

6.3 现实建议

工程判断:不要为了「上 Monad」而上 Monad。Clojure 的 some->、cond->、reduce 已内建大部分组合能力;只有在跨语言团队、或确实需要统一错误/状态语义时才引入完整 Monad 库(如 cats)。过度抽象是函数式项目最常见的失败原因之一。

7. 组合子模式:函数式语言的「设计模式之王」

7.1 组合子 = 高阶函数的积木

组合子模式是函数式编程的元模式:用若干基础函数组合出领域词汇表,再让领域专家用词汇表描述解决方案:

;; 基础组合子
(defn between? [lo hi x] (and (>= x lo) (<= x hi)))
(defn either [a b] (fn [x] (or (a x) (b x))))
(defn all [& preds] (fn [x] (every? #(% x) preds)))
(defn negate [pred] (fn [x] (not (pred x))))

;; 领域词汇表
(def adult?    (between? 18 120))
(def teen?     (between? 13 17))
(def senior?   (between? 60 120))
(def valid-age (either teen? senior?))

;; 组合出复杂校验
(def ok? (all adult? (negate senior?) #(even? (:order-count %))))

7.2 组合子的工程价值

  • 可测试:每个组合子独立可测,组合逻辑就是等式推理
  • 可读:领域词汇表让代码读起来像业务规则
  • 可扩展:新增规则 = 新增组合子,不动既有组合

8. 模式选型速查

要解决的问题OO 时代的模式Clojure 的做法
算法可替换Strategy高阶函数参数
行为序列封装Command数据化命令 + dispatch
规则可配置InterpreterDSL 数据 + 求值器
嵌套条件分派Visitordefmulti/defmethod 多方法
可能失败try/catchEither / some-> 短路
对象创建Factory普通函数 + 分派
通知联动Observeradd-watch / 通道

9. 总结

Clojure 里设计模式不是「照搬类图」,而是识别问题结构、用函数式手段重新表达:策略与模板方法退化为高阶函数,命令与解释器退化为「数据 + 求值器」,观察者退化为 watch 与通道,错误处理退化为 Either/Maybe 的组合。真正的函数式模式王者是组合子——用基础函数搭出领域词汇表,让复杂逻辑变得可测试、可读、可扩展。多方法(/clojure-multimethods-protocols/)负责多态,组合子负责结构,二者合起来几乎覆盖了 OO 模式要解决的全部问题。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 函数式错误处理:Result、异常与结构化错误
  2. Clojure GraphQL API 实战:lacinia、Schema、Resolver 与权限
  3. Clojure REPL 驱动开发:nREPL、热重载与交互式工作流