PCL -> Clojure, Chapter 16

This article is part of a series describing a port of the samples from Practical Common Lisp (PCL) to Clojure. You will probably want to read the intro first.

This article covers Chapter 16, Object Reorientation: Generic Functions.

defmulti

In Common Lisp, a generic function defines an abstract operation and a parameter list. In Clojure, a multimethod takes a similar role:

  (defmulti draw :shape)

The multimethod's name is multi, and :shape is a dispatch function used to select the actual concrete implementation. (Remember that keywords like :shape are also lookup functions.) Now, I can create one or more methods:

  (defmethod draw :square [shape] "TBD: draw a sqaure")
  (defmethod draw :circle [shape] "TBD: draw a circle")

The first method will draw things with a :shape of :square, and the second method will draw things with a :shape of :circle:

  user=> (draw {:shape :square, :length 10})
  "TBD: draw a square"
  user=> (draw {:shape :circle, :radius 8})
  "TBD: draw a circle"  

The draw multimethod is emulating single inheritance, if you think of an object's :shape value as its type. But the multimethod mechanism is more general.

A more complete example

Let's say that I need to implement account withdrawals. Different kinds of accounts will have different rules:

  • Bank accounts are simple accounts. Withdrawals will work if there is enough money available.
  • Checking accounts attach an overdraft account which can be used to cover large withdrawals.

The multimethod for withdraw could look like this:

  (defmulti withdraw :account-type)

The bank account implementation will do a simple withdraw.

  (defmethod withdraw :bank [account amount]
    (raw-withdraw account amount))

PCL uses Common Lisp's method combination to share implementation code between the different account types. Clojure's dispatch is much more general, so a general method combination mechanism is not appropriate. I am taking a different approach, pulling the shared code into a helper function raw-withdraw:

  (defn raw-withdraw [account amount]
    (when (< (:balance account) amount)
      (throw (IllegalArgumentException. "Account overdrawn")))
    (assoc account :balance (- (:balance account) amount)))

The withdrawal differs from the original PCL implementation in one other way. The original code mutated the account. Since mutation is a no-no, I am instead returning a new account object, associng in the changed balance. In the example below, I am using a let just to show that the original account is unchanged.

  (let [original-state {:account-type :bank :balance 100}
        updated-state (withdraw original-state 50)]
    (println original-state updated-state)) 

  {:balance 100, :account-type :bank} {:balance 50, :account-type :bank}

The checking account is a little more complex. First, I have to shuttle money in from the overdraft account (if necessary), then raw-withdraw as before:

  (defmethod withdraw :checking [account amount]
    (let [over-account (account :overdraft-account)
    over-amount (- amount (:balance account))
    withdrawal-account 
    (if (> over-amount 0)
      (merge account
         {:overdraft-account (withdraw over-account over-amount)
          :balance amount})
      account)]
      (raw-withdraw withdrawal-account amount)))

Again, all the objects are immutable. The merge function returns a new account object (possibly with an overdraft), and the raw-withdraw returns another object:

  (let [overdraft {:account-type :checking, :balance 1000}
        original-state {:account-type :checking
              :balance 100
              :overdraft-account overdraft}
        updated-state (withdraw original-state 500)]
    (println original-state)
    (println updated-state))

  {:overdraft-account {:balance 1000, :account-type :checking}, 
   :balance 100, 
   :account-type :checking}
  {:overdraft-account {:balance 600, :account-type :checking}, 
   :balance 0, 
   :account-type :checking}

Dispatching on more than one parameter

In languages like Java, methods are polymorphic on their first (implicit) parameter. Because multimethods dispatch on arbitrary functions, they can be polymorphic on all of their parameters.

For example, a music library might implement a beat method that is polymorphic on both the drum and the stick:

  (defmulti beat (fn [d s] [(:drum d)(:stick s)]))
  (defmethod beat [:snare-drum :brush] [drum stick] "snare drum and brush")
  (defmethod beat [:snare-drum :soft-mallet] [drum stick] "snare drum and soft mallet")

The first beat method matches only snare drum + brush, etc.:

  user=> (beat {:drum :snare-drum} {:stick :brush})
  "snare drum and brush"
  user=> (beat {:drum :snare-drum} {:stick :soft-mallet})
  "snare drum and soft mallet"

If no methods match the dispatch value, Clojure throws an exception:

  user=> (beat {:drum :bongo} {:stick :none})

    java.lang.IllegalArgumentException: No method for dispatch value
    ... stack trace elided ...

Or, you can define a :default that will match if no other dispatch value matches:

  (defmethod beat :default [drum stick] "default value, if you want one")

  user=> (beat {:drum :bongo} {:stick :none})
    "default value, if you want one"

Wrapping up

The PCL chapter demonstrates dispatch based on one or more arguments to a function, and those examples are duplicated above. There are many other things you might do with defmulti, but since they are not covered in PCL I will declare them out of scope here, and point you to some other reading:

  • Clojure objects have metadata, so you could dispatch based on metadata values instead of data values. See mac's post on the mailing list for an example.
  • Dispatch can be based on the state of an object, rather than on some kind of type tag. This lets you treat a rectangle with equal width and height as a square, even if it was created as a rectangle. See my article on dispatch in the Java.next series for an example.
  • Clojure's defmulti allows you to create multiple taxonomies dynamically, and trivially dispatch based on isa relationships in a taxonomy. See Rich's mailing list post introducing this feature.

Notes

Revision history

  • 2008/09/25: initial version
  • 2008/12/09: fixed withdraw erratum. Thanks Dean Ferreyra.