设计模式是应对「反复出现的软件结构问题」的命名解法。GoF 的经典模式大多面向对象语言,很多模式依赖继承、可变状态和类——在 Clojure 里它们要么被语言特性直接取代(多方法取代策略、高阶函数取代模板方法),要么需要换一种函数式表述(副作用用命令/组合子封装、错误用 Either 传递)。本文把 GoF 中最常被问到的模式逐一「翻译」成 Clojure 习惯用法,并重点讲透组合子与 Monad 两种函数式原生模式。
1. 设计模式的函数式视角
1.1 哪些模式「消失了」
| GoF 模式 | OO 的实现方式 | Clojure 的直接替代 |
|---|---|---|
| Strategy | 定义策略接口 + 多态实现 | 高阶函数(把算法作为参数) |
| Template Method | 抽象类 + 子类覆写钩子 | 高阶函数 + 默认参数 |
| Observer | 观察者接口 + 事件注册 | add-watch / core.async 通道 |
| Iterator | Iterator 接口 + next/hasNext | seq 抽象(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 |
| 规则可配置 | Interpreter | DSL 数据 + 求值器 |
| 嵌套条件分派 | Visitor | defmulti/defmethod 多方法 |
| 可能失败 | try/catch | Either / some-> 短路 |
| 对象创建 | Factory | 普通函数 + 分派 |
| 通知联动 | Observer | add-watch / 通道 |
9. 总结
Clojure 里设计模式不是「照搬类图」,而是识别问题结构、用函数式手段重新表达:策略与模板方法退化为高阶函数,命令与解释器退化为「数据 + 求值器」,观察者退化为 watch 与通道,错误处理退化为 Either/Maybe 的组合。真正的函数式模式王者是组合子——用基础函数搭出领域词汇表,让复杂逻辑变得可测试、可读、可扩展。多方法(/clojure-multimethods-protocols/)负责多态,组合子负责结构,二者合起来几乎覆盖了 OO 模式要解决的全部问题。
延伸阅读
- Clojure 宏编程深度解析 — 宏构建 DSL 与解释器的区别
- Clojure 领域建模与事件溯源 — 命令模式与事件溯源的关系
- Clojure 多方法与协议 — defmulti 替代 Visitor/Factory
- Java 专题 — GoF 模式的 OO 原版对照
- 分布式系统 — 命令重放与最终一致
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。