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))))