Skip to content

Latest commit

 

History

History
291 lines (213 loc) · 11.4 KB

06.md

File metadata and controls

291 lines (213 loc) · 11.4 KB

資料型別與協定

計算機科學有兩大難題:快取失效,爲事物命名以及差一錯誤。

— 菲爾•卡爾頓

我們以程式語言中的物件,數值與函式形塑真實世界,雖然 Clojure 提供了列表、向量、映射與集合等基礎型態供我們使用,還是會有力有未逮的時候。

Clojure 除了基本型別之外,還提供了自行建立型別,以及擴充現有型態的功能,或許是因爲它知道,我們體內的物件導向火花尚未熄滅,仍然等待發光的時刻。

這次會介紹在 Clojure 當中如何自定型別,如何擴充已經存在的型別,還有如何使用更優雅的方式做到物件導向程式設計。

自定型別

defrecord

使用 defrecord 巨集可以建立自己的型別,第一個參數是型別的名字,接着在向量中分別寫上此型別內各個屬性的名稱:

(defrecord User [name age])
;; => user.User

defrecord函式會根據名稱,動態地建立對應的 Java 類別,稱爲記錄類型 (Record),內部以類似映射的方式實作。你在參數向量中指定的屬性都可以公開取得,命名習慣則是跟 Java 中一樣,採取 CamelCase 的命名規則。以 defrecord 建立自定型別之後,以型別名稱後加上點 (.) 並接上各屬性的值,即可創建新型別:

(User. "Catherine" 40)
;; => #user.User{:name "Catherine", :age 40}

取得以 defrecord 建立的新型別中屬性的方法爲,在屬性名稱前加上點 (.),再帶入型別的執行個體 (Instance) 即可:

(.name (User. "Catherine" 40))
;; => "Catherine"
(.age (User. "Catherine" 40))
;; => 40

由於記錄類型實作了映射所屬的關聯類型 (Associative type),因此可以在映射上使用的函式,也可以套用在記錄類型上。

(assoc (User. "Catherine" 40) :city "Taipei")
;; => #user.User{:name "Catherine", :age 40, :city "Taipei"}
(dissoc (User. "Catherine" 40) :age)
;; => {:name "Catherine"}

以上的範例首先使用 assoc 爲記錄類型添加新的屬性,再使用 dissoc 將屬性之一刪除。由於在執行階段,動態地將原有類型的屬性刪除,因此返回值不再是記錄類型,而是映射。

記錄類型除了自動生成建構子函式(就是類別名稱加上點符號的函式)之外,還自動生成了工廠函式 (Factory function),用以建構記錄類型:

(->User "Catherine" 40)
;; => #user.User{:name "Catherine", :age 40}

另外還生成了可以將映射轉換成記錄類型的工廠函式,它接受一個映射,映射中包含了用於建構記錄類型的資訊:

(map->User {:name "Allen" :age 42})
;; => #user.User{:name "Allen", :age 42}

deftype

deftype 函式類似於 defrecord,可以創建一個 Java 物件類型,並具有建構子函式:

(deftype Point [x y])
;; => user.Point
(.x (Point. 2 5))
;; => 2
(.y (Point. 2 5))
;; => 5

除此之外,便沒有與記錄類型相似的地方了。用 deftype 建立的類型既不能以映射的方式操作,也沒有工廠方法可供使用。

reify

使用 defrecord 或是 deftype 創建具名的類別,如果臨時想要繼承某些類型,又因爲使用場所只在當前的環境,不需要大費周章創立具名類別,可以使用 reify 來建立匿名類型 (Anonymous type)。

reify 接受協定或是介面的名字爲參數,當作欲實作的類型,接下來是該類型中打算實作的方法,reify 可以同時繼承多個協定或是介面。

例如在 AWT/Swing 中,如果要接收來自各方的資訊,必須實作各種傾聽者 (Listener) 方法。可以使用 reify 來完成這個要求:

(reify
  java.awt.event.MouseListener
  (mousePressed [this e]
    (println "Mouse pressed")))

以上範例實作了滑鼠傾聽者介面中的 mousePressed 方法。mousePressed 方法共有兩個參數,第一個參數爲發出此事件的執行實體,第二個參數則爲代表該滑鼠事件的執行實體。

協定

Java 提供介面 (Interface) 用來定義共通的函式,由各型別實作共通的函式,根據各別的實作而有不同的功能。程式便只看見抽象的函式,而不依賴於實體類別。

在 Clojure 中可以使用 definterface 定義介面,與 Java 一樣:

(definterface IAnimal
  (eat [food])
  (sleep []))
;; => user.IAnimal

Clojure 還提供了類似介面的概念:協定。與介面類似,但是協定沒有實作部分,只有一組函式規則。介面一旦定義之後,便很難改動;協定則可以動態擴充,不必擔心牽一髮而動全身:

(defprotocol StackOps
  (stack-push [this thing])
  (stack-pop [this]))
;; => StackOps

以上範例使用 defprotocol 定義了一組堆疊的函式操作:可以往堆疊推入東西,也可以從堆疊中取出東西。

你可以使用 deftypedefrecordreify 實作協定:

(deftype TypeStack [coll] 
  StackOps
  (stack-push [_ thing] (println "Type push"))
  (stack-pop [_] (println "Type pop")))
;; => user.TypeStack
(defrecord RecStack [coll] 
  StackOps
  (stack-push [_ thing] (println "Record push"))
  (stack-pop [_] (println "Record pop")))
;; => user.RecordStack
(reify StackOps
  (stack-push [_ thing] (println "Reify push"))
  (stack-pop [_] (println "Reify pop")))
;; => #object[user$eval10525$reify__10526 0x43ca678f "user$eval10525$reify__10526@43ca678f"]

擴充

雖然可以使用 deftypedefrecordreify 實作介面或協定,但是缺點是必須在定義型別時就確認,Clojure 提供了在建立型別之後,仍然可以將型別或協定擴充的方式。

extend

我們可以使用 extend 函式擴充已經定義好的型別:

(defrecord Rectangle [x y])
;; => user.Rectangle
(def rect (Rectangle. 5 5))
;; => #'user/rect
(defprotocol Shape
  (draw [this]))
;; => Shape
(extend Rectangle
  Shape
  {:draw (fn [this] (println "Draw Rectangle:" (.x this) (.y this)))})
(draw rect)
;; => Draw Rectangle: 5 5

範例中先創建 Rectangle 記錄類型,並以此記錄類型建立執行實體 (Instance) 後,定義了名爲 Shape 的協定,再讓 Rectangle 型態實作 Shape 協定。先在協定之前建立好的 Rectangle 記錄類型,便有了 Shape 協定的實作。

可以看到 extend 方法的寫法是,先寫上欲實作介面的類型名稱,再寫上欲實作的協定名稱,最後是加上映射,內容爲關鍵字與匿名函式,關鍵字名稱即是協定中函式的名稱。

extend-type

extend 雖然很神奇很方便,但是定義實作函式的地方太繁瑣了,你可以利用 extend-type 簡化定義方式:

(extend-type Rectangle
  Shape
  (draw [this] (println "Draw Rectangle still using extend-type:"
                        (.x this)
                        (.y this))))
(draw rect)
;; => Draw Rectangle using extend-type: 5 5

extend-protocol

還有 extend-protocol,可以讓多個型別同時實作某個協定,而 extend-type 則是讓某個型別同時實作多個協定:

(extend-protocol AProtocol
  AType
  (method-from-AProtocol [this x]
    (;.. implementation of AType
     ))
  BType
  (method-from-AProtocol [this x]
    (;.. implementation of BType
     ))
  CType
  (method-from-AProtocol [this x]
    (;.. implementation of CType
     )))

以上只是示例,並無法實際在 REPL 執行

多重方法

defrecorddefprotocol 的介紹,我們已經看到了主流物件導向語言如 Java/C++ 支持多型 (Polymorphism) 的方式,就是根據型態的不同,決定該執行的函式。

主流的物件導向語言使用繼承建立階層,以繼承階層實現多型。Clojure 的多型並不一定要綁定在型別上。除了協定和記錄類型之外,Clojure 還提供了更靈活的多型設計方法,稱爲多重方法 (Multi-method)。

要使用多重方法達到多型,首先必須先使用 defmulti 巨集。defmulti 包括函式名稱和一個分派函式 (Dispatch function),分派函式被調用後,返回值用來決定該使用哪個函式。

接下來使用 defmethod 定義多重函式。參數接受函式名稱、代表該函式應該被呼叫的分派值、和函式參數與函式本體。

以下範例使用多重方法,計算不同計酬方式員工的薪水。正職員工以月薪給付薪水,不管超過月平均工作時數與否,都是領月薪;而派遣員工則是以小時計酬,如果工作時數不滿 40 小時,便以時薪乘以工作時數給薪,如果超過 40 小時,則給薪方式爲 40 小時時薪,再加上超過 40 小時的工作時數乘以時薪再乘以 1.5 倍:

(defrecord Employee [type hours salary])

(defmulti earnings
  (fn [employee] (.type employee)))          ; 1

(defmethod earnings
  :salaried                                  ; 2
  [employee] (.salary employee))

(defmethod earnings
  :hourly
  [employee]
  (let [hours (.hours employee)
        salary (.salary employee)]
    (if (< hours 40)
      (* hours salary)
      (+ (* 40 hours)
         (* (- hours 40) salary 1.5)))))

(earnings (Employee. :salaried 70 30000))    ; 3
;; => 30000
(earnings (Employee. :hourly 50 200))
;; => 11800.0

以上範例在步驟 1 的地方爲此多重方法的分派函式,此函式的返回值決定該執行哪個函式;步驟 2 則是分派值,當分派函式的返回值與這個值一樣,便執行此處的函式。第一個計算的是正職員工的薪水,再來是派遣員工的薪水。步驟 3 呼叫 earnings 函式的參數會先丟給分派函式求得分派值,再根據分派值選擇適當的函式。

由於多重方法中的分派函式可以是任何函式,因此可以有各種變化,不像主流物件導向語言只能依據類型與階層完成單一分派 (Single dispatch)。

反射

Clojure 提供一些具有反射 (Reflective) 能力的函式,用來檢查或驗證型態與協定之間的關係。首先介紹的是 extends? 函式,參數接受協定和型別,結果爲該型別是否擴充提供的協定:

(defprotocol Vehicle (go [this]))

(defrecord Car []
  Vehicle
  (go [this] "Go car"))

(extends? Vehicle Car)
;; => true

extenders 則是列出有哪些型別以 extend 相關的函式實作當作參數的協定:

(defrecord Motorcycle [color])
;; => user.Motorcycle
(defrecord Truck [color])
;; => user.Truck
(extend-protocol Vehicle
  Motorcycle
  (go [this] "Go motorcycle")
  Truck
  (go [this] "Go truck"))
;; => (user.Motorcycle user.Truck)
(extenders Vehicle)
;; => (user.Motorcycle user.Truck)

satisfies? 接受協定與執行實體爲參數,如果執行實體以 extend 相關函式實作此協定則返回真,反之則否:

(satisfies? Vehicle (Truck. "red"))
;; => true
(satisfies? Vehicle 123)
;; => false

回顧

經由本篇文章你了解到如何在 Clojure 中自定型別,和建立相似於 Java 中介面的協定;也知道了擴充協定的方法,更了解了比物件導向的繼承式多型還強大的多重方法,並知道了一些反射方法,取得型別與協定之間的關係。

還不賴吧?今天就先到這裡,下一篇文章再見囉!