Creating Ad Hoc Taxonomies

Multimethods let you create ad hoc taxonomies, which can be helpful when you discover type relationships that are not explicitly declared as such.

For example, consider a financial application that deals with checking and savings accounts. Define a Clojure map for an account, using a tag to distinguish the two:

 (ns examples.multimethods.account)

Now, you’re going to create two different checking accounts, tagged as ::checking and ::savings. The doubled :: causes the keywords to resolve in the current namespace. To see the namespace resolution happen, compare entering :checking and ::checking at the REPL:

 :checking
 -> :checking
 
 ::checking
 -> :user/checking

Placing keywords in a namespace helps prevent name collisions with other people’s code. When you want to use ::savings or ::checking from another namespace, you’ll need to fully qualify them:

 {:id 1, :tag :examples.multimethods.account/savings, :balance 100M}

Full names get tedious quickly, so you can use alias to specify a shorter alias for a long namespace name:

 (alias short-name-symbol namespace-symbol)

Use alias to create the short name acc:

 (alias ​'acc​ ​'examples.multimethods.account​)
 -> nil

Now that the acc alias is available, create two top-level test objects, a savings account and a checking account:

 (​def​ test-savings {:id 1, :tag ::acc/savings, ::balance 100M})
 -> #​'user/test-savings
 
 (​def​ test-checking {:id 2, :tag ::acc/checking, ::balance 250M})
 -> #​'user/test-checking

Note that the trailing M creates a BigDecimal literal and does not mean you have millions of dollars.

The interest rate is 0% for checking accounts and 5% for savings accounts. Create a multimethod interest-rate that dispatches based on :tag, like so:

 (​defmulti​ interest-rate :tag)
 (​defmethod​ interest-rate ::acc/checking [_] 0M)
 (​defmethod​ interest-rate ::acc/savings [_] 0.05M)

Check your test-savings and test-checking to make sure that interest-rate works as expected.

 (interest-rate test-savings)
 -> 0.05M
 
 (interest-rate test-checking)
 -> 0M

Accounts have an annual service charge, with rules as follows:

  • Normal checking accounts incur a $25 fee.
  • Normal savings accounts incur a $10 fee.
  • Premium accounts have no fee.
  • Checking accounts with a balance of $5,000 or more are premium.
  • Savings accounts with a balance of $1,000 or more are premium.

In a realistic example, the rules would be more complex. Premium status would be driven by average balance over time, and there would probably be other ways to qualify. But the previous rules are complex enough to demonstrate the point.

You could implement service-charge with a bunch of conditional logic, but premium feels like a type, although there’s no explicit premium tag on an account. Create an account-level multimethod that returns ::premium or ::basic:

 (​defmulti​ account-level :tag)
 (​defmethod​ account-level ::acc/checking [acct]
  (​if​ (>= (:balance acct) 5000) ::acc/premium ::acc/basic))
 (​defmethod​ account-level ::acc/savings [acct]
  (​if​ (>= (:balance acct) 1000) ::acc/premium ::acc/basic))

Test account-level to make sure that checking and savings accounts require different balance levels to reach ::premium status:

 (account-level {:id 1, :tag ::acc/savings, :balance 2000M})
 -> :examples.multimethods.account/premium
 (account-level {:id 1, :tag ::acc/checking, :balance 2000M})
 -> :examples.multimethods.account/basic

Now you might be tempted to implement service-charge using account-level as a dispatch function:

 ; bad approach
 (​defmulti​ service-charge account-level)
 (​defmethod​ service-charge ::basic [acct]
  (​if​ (= (:tag acct) ::checking) 25 10))
 (​defmethod​ service-charge ::premium [_] 0)

The conditional logic in service-charge for ::basic is exactly the kind of type-driven conditional that multimethods should help us avoid. The problem here is that you’re already dispatching by account-level, and now you need to be dispatching by :tag as well. No problem—you can dispatch on both. Write a service-charge whose dispatch function calls both account-level and :tag, returning the results in a vector:

 (​defmulti​ service-charge (​fn​ [acct] [(account-level acct) (:tag acct)]))
 (​defmethod​ service-charge [::acc/basic ::acc/checking] [_] 25)
 (​defmethod​ service-charge [::acc/basic ::acc/savings] [_] 10)
 (​defmethod​ service-charge [::acc/premium ::acc/checking] [_] 0)
 (​defmethod​ service-charge [::acc/premium ::acc/savings] [_] 0)

This version of service-charge dispatches against two different taxonomies: the :tag intrinsic to an account and the externally defined account-level. Try a few accounts to verify that service-charge works as expected:

 (service-charge {:tag ::acc/checking :balance 1000})
 -> 25
 
 (service-charge {:tag ::acc/savings :balance 1000})
 -> 0

There’s one further improvement you can make to service-charge. Since all premium accounts have the same service charge, it seems redundant to have to define two separate service-charge methods for ::savings and ::checking accounts. It would be nice to have a parent type ::account so you could define a multimethod that matches ::premium for any kind of ::account. Clojure lets you define arbitrary parent-child relationships with derive:

 (derive child parent)

Using derive, you can specify that both ::savings and ::checking are kinds of ::account:

 (derive ::acc/savings ::acc/account)
 (derive ::acc/checking ::acc/account)

When you start to use derive, isa? comes into its own. In addition to understanding Java inheritance, isa? knows all about derived relationships:

 (isa? ::acc/savings ::acc/account)
 -> true

Now that Clojure knows that savings and checking are accounts, you can define a service-charge using a single method to handle ::premium:

 (​defmulti​ service-charge (​fn​ [acct] [(account-level acct) (:tag acct)]))
 (​defmethod​ service-charge [::acc/basic ::acc/checking] [_] 25)
 (​defmethod​ service-charge [::acc/basic ::acc/savings] [_] 10)
 (​defmethod​ service-charge [::acc/premium ::acc/account] [_] 0)

At first glance, you may think that derive and isa? duplicate functionality that’s already available to Clojure via Java inheritance. This is not the case. Java inheritance relationships are forever fixed at the moment you define a class. derived relationships can be created when you need them and can be applied to existing objects without their knowledge or consent. So when you discover a useful relationship between existing objects, you can derive that relationship without touching the original objects’ source code and without creating tiresome “wrapper” classes.

If the number of different ways you might define a multimethod has your head spinning, don’t worry. In practice, most Clojure code uses multimethods sparingly. Let’s take a look at some open source Clojure code to get a better idea of how multimethods are used.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.139.107.210