Clojure 面向对象:分派、协议和实现
Clojure 是一门动态 LISP 家族、纯函数式编程语言,它提供了与传统面向对象编程(OOP)不同的方法来处理数据和逻辑。尽管 Clojure 并非传统意义上的“面向对象”语言,但它通过一系列强大的抽象机制支持面向对象编程模式,包括完整且灵活的 封装、继承和多态。
具体而言,Clojure 允许通过 defrecord、deftype 封装数据,通过 defprotocol 约定协议,通过 extend-type、extend 提供具名实现,reify 提供匿名实现。这套协议与实现机制很好的实现了封装和多态。此外,Clojure 还提供了 defmethod 方法分派,这种机制通过另一种途径实现了更强大的多态能力,包括对行为的继承。
多重方法
由 Douglas R. Hofstadter 提出的原型原则认为,我们倾向于以特定事件为模型来处理相似但有差异的事情。Steve Yegge (程序员)因此提出 UDP 术语,使用原型继承实现了侯世达的思想(Ungar 1987)。UDP(通用设计模式 Universal Design Pattern) 基于 map 作为类对象的概念,其中每个 map 包含一个原型 map 的引用作为父链接,得到其父继承的字段:典型的 UDP 语言为 JavaScript。
下面提供了一个其在 Clojure 中的实现,使用到 beget、get 和 post 三个函数,非常好理解,就是一个层次化的 map 构建和查找:
(ns hello-clj.core2
(:refer-clojure :exclude [get]))
(defn beget [this proto]
(assoc this ::prototype proto))
(defn get [m k]
(when m
(if-let [[_ v] (find m k)]
v (recur (::prototype m) k))))
(def put assoc)
其中 beget 用于使用 map 构建包含继承链的对象,get 用于从其本身或者继承链访问对象,put 用于插入属性,下面是一个简单的例子,一个本来喜欢狗的猫 ,一个特别喜欢 9lives 猫粮的猫 morris,其有一天被狗欺负了,于是之后就不喜欢狗了:
(def cat {:likes-dogs true
:ocd-bathing true})
(def morris (beget {:likes-9lives true}
cat))
(def post-traumatic-morris
(beget {:likes-dogs nil}
morris))
(get cat :likes-dogs) ;true
(get morris :likes-dogs) ;true
(get post-traumatic-morris :likes-dogs) ;nil
(get post-traumatic-morris :likes-9lives) ;true
现在有了继承,那么如何实现多态呢?可以使用 Clojure 的 defmulti 宏,此宏用于根据某个字段执行不同的分发,注意这里的 :os 是函数,也可以传入任何自定义函数,为输入的对象指定其需要分派的方法上,比如 (defmulti compiler (fn [m] (if (?? m) ::unix ::osx)))
,(defmethod compiler ::unix)
之后 (compiler m)
调用时就会先执行此函数获取实现的方法然后分派执行,注意 :default 可以匹配到任何意料之外的情况,此外匹配可基于单个值或 vector(见下文)。
(defmulti compiler :os)
(defmethod compiler ::unix [m]
(get m :c-compiler))
(defmethod compiler ::osx [m]
(get m :llvm-compiler))
(def clone (partial beget {}))
(def unix {:os ::unix
:c-compiler "cc"
:home "/home"
:dev "/dev"})
(def osx (-> (clone unix)
(put :os ::osx)
(put :llvm-compiler "clang")
(put :home "/Users")))
(compiler unix) ;cc
(compiler osx) ;clang
但是这里的问题在于,我们没有办法很好的将继承和多态结合起来,系统不知道 :osx 是一种 :unix,因此这里对 osx 的 home 多重分派失败了(这里的问题和 SICP 中层次化数据的数据导向问题类似,在那里我们花了很长的时间处理类型提升,以试图找到合适的方法,在 Clojure 中,可以简单的通过 derive 来声明分派键的 isa 关系,通过 parents、ancestors、descendants、isa?函数可以对声明过的关系进行判断,之后对于 ::osx 的分派就会在找不到时提升为 ::unix):
(defmulti home :os)
(defmethod home ::unix [m] (get m :home))
(home unix) ;/home
;(home osx) ;Exception no method
(derive ::osx ::unix)
(home osx) ;/Users
(parents ::osx) ;{:unix}
(ancestors ::osx) ;{unix}
(descendants ::unix) ;{osx}
(isa? ::osx ::unix) ;true
(isa? ::unix ::osx) ;false
现在仅仅是解决了父子继承问题而已,对于层次化结构,这还远远不够,对于多重继承:osx 来自 unix 和 bsd,多重分派还是会失败,因此可以通过指定 prefer-method 来确定在某个多重分派方法中两个标签的优先级,通过 remove-method 来删除某个多重分派。但更好的做法,则是使用 derive (make-hierarchy) 来建立继承链结构:
(derive ::osx ::bsd)
(defmethod home ::bsd [m] "/home")
;(home osx) ;multiple method in home
(prefer-method home ::unix ::bsd)
(home osx) ;/Users
(remove-method home ::bsd)
(derive (make-hierarchy) ::osx ::unix)
;{:parents {:osx #{unix}}
; :ancestors {:osx #{unix}}
; :descendants {:unix #{osx}}
juxt 接受函数参数,返回一个函数,其简单的将给自己的参数逐个作用于每个函数参数,然后将每个函数执行的结果放在一个 vector 中返回:
((juxt + - * /) 2 3) => [5 -1 6 2/3]
((juxt take drop) 3 (range 9)) => [(0 1 2) (3 4 5 6 7 8)]
可以利用 juxt 进行任意分发 —— 换言之,分发函数可以返回一个 vector,然后可以利用解构对每个 vector 中的值进行详细匹配,类似于 Scala 的 List 的 pattern matching,注意,使用单独的 :default Key 来设置默认匹配。注意这里的 compile-cmd 多重分派组合了 compiler 多重分派方法,构建了层次化的分派结构。
(defmulti compile-cmd (juxt :os compiler))
(defmethod compile-cmd [::osx "gcc"] [m]
(str "/usr/bin/" (get m :c-compiler)))
(defmethod compile-cmd :default [m]
(str "Unsure where to locate " (get m :c-compiler)))
(compile-cmd osx) ;/usr/bin/gcc
(compile-cmd unix) ;Unsure where to..
协议与实现
基于 UDP 和多重分派实现的运行时继承与多态很有用处,不过也面临着不小的问题,比如 ① map 缺乏类型,难以辨别。②多态机制中基于任意函数的分发并有些大材小用,且对追求原生速度无益。
defprototol
协议是一种对某个 Java 对象可执行动作的抽象。协议的语法很简单,第一个参数为协议名,之后每个 form 为一个必须实现的函数定义,函数第一个参数一般是函数名this ,之后是参数。实际使用时,将此对象作为函数的第一个参数传入即可,其编译生成 Java 接口。
(defprotocol FIXO
(f-push [f value])
(f-pop [f])
(f-peek [f]))
defrecord
Clojure 的 Record 记录类型就相当于 Kotlin 的 data class 或 Java record class。defrecord 会生成一个当前命名空间路径为 package 的包含多个字段的具名 Java 类,其中各个字段没有类型限制,且可以使用 :key 来获取其值,通过 assoc 任意扩充和修改字段(但 dissoc 却不能删除字段,只会返回删除后的 map),但注意 = 现在并不相等。因为是 Java 类,所以导入时也必须使用 :import 而不能是 :require,但也正因为是 Java 类,相比较 map 而言查找键的速度更快,也没有 Integer、Long 等拆装箱的内存和效率影响。
{:val 3 :l nil :r nil}
(defrecord TreeNode [val l r]) ;=> joy.udp.TreeNode
(TreeNode. 3 nil nil) ;=> #joy.udp.TreeNode{:val 3, :l nil, :r nil}
(def a (TreeNode. 3 nil nil)) ;=> #'joy.udp/a
(class a) ;=> joy.udp.TreeNode
(:val a) ;=> 3
(:l a) ;=> nil
(defn xconj [t v]
(cond
(nil? t) (TreeNode. v nil nil)
(< v (:val t)) (TreeNode. (:val t) (xconj (:l t) v) (:r t))
:else (TreeNode. (:val t) (:l t) (xconj (:r t) v))))
(defn xseq [t]
(when t
(concat (xseq (:l t)) [(:val t)] (xseq (:r t)))))
(def sample-tree (reduce xconj nil [3 5 2 4 6]))
(xseq sample-tree) ;=> (2 3 4 5 6)
extend-type
考虑为堆栈 FILO 和队列 FIFO 建模,可通过 extend-type 为 Java 类(任意 Java 类型,包括 Record 记录、其他接口等)实现接口,实现接口的语法和定义接口语法类似。
(defprotocol FIXO
(f-push [f value])
(f-pop [f])
(f-peek [f]))
(extend-type TreeNode FIXO
(f-push [node value]
(conj node value)))
(seq (f-push sample-tree 5/2))
=> (2 5/2 3 4 5 6)
可以对现有协议实现协议扩展,这样实现了协议的那些类都可以使用此协议的方法(多重协议):
(extend-type clojure.lang.IPersistentVector FIXO
(f-push [vector value]
(conj vector value)))
(f-push [2 3 4 5 6] 5/2)
=> [2 3 4 5 6 5/2]
对于记录而言,可以直接在声明式即表示其实现的接口,这种模式类似于创建富血对象(这种方式不仅可以实现 defprototol 定义的接口,还可以实现任意 Java 接口):
(defrecord TreeNode [val l r]
FIXO
(f-push [t v]
(if (< v val)
(TreeNode. val (f-push l v) r)
(TreeNode. val l (f-push r v))))
(f-peek [t]
(if l (f-peek l) val))
(f-pop [t]
(if l (TreeNode. val (f-pop l) r) r)))
=> joy.udp.TreeNode
reify
reify 可用于创建实现某个协议的匿名类实例(工厂方法),其闭包可以用来模拟对象的私有字段,其语法类似于 extend-type。虽然可以单独的为某个 record 定义协议实现的细节,但是,当你想要为 String 定义 100 个不同的 StringOps 行为以让其在不同的环境下满足不同的需求,就难办了,而利用 reify,你可以复用数据结构,表面看起来一样的数据结构,实际上由不同的工厂产出,其行为大不相同:
(defn fixed-fixo
([limit] (fixed-fixo limit []))
([limit vector]
(reify FIXO
(f-push [this value]
(if (< (count vector) limit)
(fixed-fixo limit (conj vector value)
this))
(f-peek [_]
(peek vector))
(f-pop [_]
(pop vector)))))
需要注意,在上述 extend-type、extend 的实现中,基于 recur 和方法名的行为可能不同,后者带有多态性质。
最佳实践
协议和分派都可用于实现多态,但用途不同:协议实现的是一组动作,而分派则着重对某个特定动作实现更深层次的多态,包括动作的继承。大部分场景下,协议的适用范围更广,当然分派也有用武之地,比如构造对象工厂函数中基于配置的分派(比 if、case 之类来的优雅,可以在任意命名空间实现分派,不用修改单一位置)。
对于协议而言,可以选择基于现有类型或 Record 的具名实现,或 reify 匿名实现,具名实现可后续利用类型信息进行进一步操作,包括基于类型实现新协议、和 Java 的互操作性等。而匿名实现则更加简洁,其所需数据可包裹在构造函数的闭包中,以实现封装特性。
(defprotocol IExpressHandler
(check! [_])
(is-ok? [_])
(get-msg [_])
(get-data [_])
(is-assign? [_]))
(defrecord ExpressImpl [^String url ^String code ^Atom data]
IExpressHandler
(check!
[_]
(let [req @(client/request {:url (format url (:no @data))
:method :get
:headers {"Authorization" (str "APPCODE " code)}})
resp (-> req :body (json/parse-string true))]
(swap! data assoc :resp resp)))
(is-ok?
[_]
(-> data deref :resp :status (= "0")))
(get-msg
[this]
(if-not (is-ok? this)
(-> @data :resp :msg (or "调用接口错误。"))
"查询成功。"))
(get-data
[_]
(let [{:keys [number type] :as result} (-> @data :resp :result)]
(-> result (dissoc :number :type) (assoc :no number :kind type))))
(is-assign?
[this]
(let [data (get-data this)]
(or ((comp not nil? #{"3" "4" "5" "6"}) (:deliverystatus data))
(and (not= "JD" (:kind data)) (= "1" (:issign data)))))))
(defmulti make-express :type)
(defmethod make-express :record [args]
(let [code (edn-in [:express :code])
url "https://wuliu.market.alicloudapi.com/kdi?no=%s"]
(->ExpressImpl code url (atom args))))
(defmethod make-express :reify [args]
(let [code (edn-in [:express :code])
url "https://wuliu.market.alicloudapi.com/kdi?no=%s"
data (atom {})]
(reify IExpressHandler
(check!
[_]
(let [req @(client/request {:url (format url (:no args))
:method :get
:headers {"Authorization" (str "APPCODE " code)}})
resp (-> req :body (json/parse-string true))]
(swap! data assoc :resp resp)))
(is-ok?
[_]
(-> data deref :resp :status (= "0")))
(get-msg
[this]
(if-not (is-ok? this)
(-> @data :resp :msg (or "调用接口错误。"))
"查询成功。"))
(get-data
[_]
(let [{:keys [number type] :as result} (-> @data :resp :result)]
(-> result (dissoc :number :type) (assoc :no number :kind type))))
(is-assign?
[this]
(let [data (get-data this)]
(or ((comp not nil? #{"3" "4" "5" "6"}) (:deliverystatus data))
(and (not= "JD" (:kind data)) (= "1" (:issign data)))))))))
(let [express (make-express {:type :record
:no "1234567890123"})]
(-> express check!)
(if (is-ok? express)
(let [data (get-data express)]
(if (is-assign? express)
(println "assign!")
(println "saving to db" data)))
(println "error!" (get-msg express))))