Specifying default slot values for defrecord classes in Clojure

Clojure’s datatypes are mana in a variety of ways.  In particular, for application-level programming, defrecord is the near-ideal construct — baked-in protocol support, Java interface interop, direct field storage of slots, fast keyword “accessors”, fast-as-Java method implementations…the list of goodness goes on and on.

Of course, there’s always room for improvement.  One big pain point for me was that I have a couple of records that, due to the domain, have a lot of slots, each of which needs to have a specific default value.  defrecord‘s generated constructor doesn’t provide for any kind of default value for slots.  The naive solution would be to write a factory function that aligned its arguments with some defined set of defaults, but that sounded painful from the start: slot definitions are then functionally spread over multiple forms and the handling of arguments into that factory function would either be a maintenance or performance nightmare.

It’s good to avoid macros as much as you can, but cases like this is where they shine:

(defmacro defrecord+defaults
  "Defines a new record, along with a new-RecordName factory function that
   returns an instance of the record initialized with the default values
   provided as part of the record's slot declarations.  e.g.
   (defrecord+ Foo [a 5 b \"hi\"])
   (new-Foo)
   => #user.Foo{:a 5, :b \"hi\"}"
  [name slots & etc]
  (let [fields (->> slots (partition 2) (map first) vec)
        defaults (->> slots (partition 2) (map second))]
    `(do
      (defrecord ~name
         ~fields
         ~@etc)
       (defn ~(symbol (str "new-" name))
         ~(str "A factory function returning a new instance of " name
            " initialized with the defaults specified in the corresponding defrecord+ form.")
         []
         (~(symbol (str name \.)) ~@defaults))
       ~name)))

This macro is breaking my general rules of thumb that no def form should define more than one entity (here, a class and a factory function), and that def forms shouldn’t create vars with generated names.  Unfortunately, I don’t think there’s any other better approach to be had given what I’m aiming to do.

There’s an example in the docstring for defrecord+defaults, but let’s take a look at some possible usage in a REPL interaction:

user=> (defrecord+defaults SomeClass
         [size 0
          root nil
          color java.awt.Color/black])
user.SomeClass
user=> (doc new-SomeClass)
-------------------------
user/new-SomeClass
([])
  A factory function returning a new instance of SomeClass initialized with the defaults specified in the corresponding defrecord+ form.
nil
user=> (new-SomeClass)
{:size 0,
 :root nil,
 :color
 #<Color java.awt.Color[r=0,g=0,b=0]>}
user=> (assoc (new-SomeClass)
         :size 1
         :root "foo")
{:size 1,
 :root "foo",
 :color
 #<Color java.awt.Color[r=0,g=0,b=0]>}

Yup, macros to the rescue (yet again!). What’s particularly handy about this is that the defined defaults can be any Clojure expression, which is evaluated each time the factory function is invoked. A neat future enhancement would be for the factory function to have a keyword-argument override that takes slot names and values that supersede the corresponding defaults provided in the defrecord specification.

I wouldn’t be surprised if something like this ends up in Clojure itself eventually, as a specialization of a future defrecord factory function facility.

About these ads
This entry was posted in Clojure. Bookmark the permalink.

9 Responses to Specifying default slot values for defrecord classes in Clojure

  1. Alex Miller says:

    Might want to check out some other ideas on enhanced clojure constructors here if you haven’t seen it:

    http://david-mcneil.com/post/765563763/enhanced-clojure-records

  2. Hugo Duncan says:

    Another take on this, that creates factory functions for existing defrecords:

    http://github.com/hugoduncan/atticus/blob/master/src/main/clojure/atticus/factory.clj

    • Chas Emerick says:

      That’s handy too, especially if you’re working with existing libraries that have records defined. For my own stuff, not having those default values co-located makes me cringe though — DRY, and all that, at least when possible.

      • Hugo Duncan says:

        Being able to create multiple factory functions with different defaults can be useful for testing. Having fields computed on other field values is also useful. Atticus has overrides of field values, which flow into the computed field values.

  3. Fogus says:

    Per our chat in the IRCs, I added rudimentary kwargs on your factory fn.

    http://gist.github.com/504861

    (defrecord+defaults Foo [a 1 b 2])
    (new-Foo :a 42)
    ;=> #:user.Foo{:a 42, :b 2}
    

  4. Pingback: -= Monkeying with Clojure’s defmethod Macro =-

  5. Anthony Gallagher says:

    I found this macro very handy, and I wanted to modify it a bit so that I could do the following:

    (defrecord+ foo
      [(x 1) y])
    

    which would generate a (defrecord foo [x y]) and a make-foo function. The default for x would be 1, and for nil.

    Since I am a Clojure noob, this is the best I could come up with. I realize it is very crude, and I would appreciate advice to make it nice. Thanks in advance for any feedback.

    (defn- seq2map
      "Convert a sequence to a map"
      [key-vals]
      (loop [dmap {}
    	 key-vals key-vals]
        (if (empty? key-vals)
          dmap
          (let [elem (first key-vals)]
    	(recur (assoc dmap (keyword (first elem)) (second elem))
    	       (rest key-vals))))))
    
    (defn- make-instance
      "Make an instance of a class with the given parameters"
      [cname params]
      (eval (conj (list* params) cname 'new)))
    
    (defmacro defrecord+
      "Defines a new record, along with a make-RecordName factory function that
       returns an instance of the record initialized with the default values
       provided as part of the record's slot declarations.  e.g.
       (defrecord+ foo
         [(a 5) b])
       (new-foo)
       =&gt; #user.foo{:a 5, :b nil}"
      [name slots &amp; etc]
      (let [slots+ (for [slot slots]
    		 (if (list? slot)
    		   slot
    		   (list slot nil)))
    	fields (-&gt;&gt; slots+ (map first) vec)
            default-map (seq2map slots+)]
        `(do
           (defrecord ~name
             ~fields
             ~@etc)
           (defn ~(symbol (str "make-" name))
             ~(str "A factory function returning a new instance of " name
    	       " initialized with the defaults specified in the corresponding defrecord+ form.")
             [&amp; body#]
    	 (let [user-map# (seq2map (partition 2 body#))
    	       record-vals# (for [rawkey# '~fields]
    			      (let [key# (keyword rawkey#)]
    				(get user-map# key# (~default-map key#))))]
    	    (make-instance ~name record-vals#)))
           ~name)))
    
    • Anthony Gallagher says:

      I forgot to include that the call to make-foo is:
      (make-foo :y 2) which gives a foo record with {:x 1, :y 2}

      • Chas Emerick says:

        I don’t think all that is necessary. Unless I’ve missed something, the equivalent using the defrecord+defaults macro I’ve provided is:

        (defrecord+defaults foo
          [x 1 y nil])
        

        I don’t think having to put default values in parens just to get default-default values of nil is worthwhile.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s