7

I'm having a hard time deciding when using defrecord is the right choice and more broadly if my use of protocols on my records is semantic clojure and functional.

In my current project I'm building a game that has different types of enemies that all have the same set of actions, where those actions might be implemented differently.

Coming from an OOP background, I'm tempted to do something like:

(defprotocol Enemy
  "Defines base function of an Enemy"
  (attack [this] "attack function"))

(extend-protocol Enemy
  Orc
  (attack [_] "Handles an orc attack")  
  Troll
  (attack [_] "Handles a Troll attack"))



(defrecord Orc [health attackPower defense])
(defrecord Troll [health attackPower defense])

(def enemy (Orc. 1 20 3))
(def enemy2 (Troll. 1 20 3))

(println (attack enemy))
; handles an orc attack

(println (attack enemy2))
;handles a troll attack

This looks like it makes sense on the surface. I want every enemy to always have an attack method, but the actual implementation of that should be able to vary on the particular enemy. Using the extend-protocol I'm able to create efficient dispatch of the methods that vary on my enemies, and I can easily add new enemy types as well as change the functionally on those types.

The problem I'm having is why should I use a record over a generic map? The above feels a bit to OOP to me, and seems like I'm going against a more functional style. So, my question is broken into two:

  1. Is my above implementation of records and protocols a sound use case?
  2. More generically, when is a record preferred over a map? I've read you should favor records when you're re-building the same map multiple times (as I would be in this case). Is that logic sound?
akond
  • 15,865
  • 4
  • 35
  • 55
newBieDev
  • 484
  • 3
  • 11
  • what makes an Orc attack fundamentally different to a Troll attack? – Jochen Bedersdorfer Jul 01 '20 at 19:57
  • Orc V. Troll might not be the best example, but there will be instances where the attack method for a particular entity will be different enough that it will require its own unique calculation. Attack is just one example also, there will be a number of shared methods that differ in implementation. – newBieDev Jul 01 '20 at 20:05
  • yeah, but would it be attached to a 'type' or the existence of certain keys? – Jochen Bedersdorfer Jul 01 '20 at 20:07
  • I think by type, an Orc will always perform perform an attack the same way and that will hold true for any other common "Enemy" methods. – newBieDev Jul 01 '20 at 20:12
  • then record seems to be fine. If you have other criteria for dispatching, you might want to look into multimethods – Jochen Bedersdorfer Jul 01 '20 at 20:15
  • the only "downside" I find in records is that you are committing to a name (i.e. a type name). Later on that might not turn out to be the best decision as you might have other identifying data, but by then you have plenty of code that is stuck with that name. – Jochen Bedersdorfer Jul 01 '20 at 20:19

2 Answers2

13

This flowchart is still good advice, nine years later: https://cemerick.com/2011/07/05/flowchart-for-choosing-the-right-clojure-type-definition-form/ -- seems to have moved to https://cemerick.com/blog/2011/07/05/flowchart-for-choosing-the-right-clojure-type-definition-form.html

My rule of thumb is: always use plain hash maps until you really need polymorphism and then decide whether you want multi-methods (dispatch on one or more arguments/attributes) or protocols (dispatch on just the type).

Sean Corfield
  • 6,297
  • 22
  • 31
  • FYI, you're link to the flowchart is broken. – jmrah Jan 17 '21 at 15:59
  • Looks like Chas Emerick has completely rewritten his blog -- and a lot of useful links to content there are now gone (including several years of State of Clojure survey results). The link was valid when I posted it... – Sean Corfield Jan 17 '21 at 23:56
  • 4
    I found an updated link and have added that to the original answer. – Sean Corfield Jan 17 '21 at 23:59
  • While i usually use plain maps, this from Clojure Applied: “Given that records give you well-known fields, a type, factory functions, and better performance, they should be your first choice for domain entities. Why might we use maps instead? One specific case for which you should strongly consider maps is in public-facing APIs…” – Akiz Aug 01 '23 at 21:09
  • The author of Clojure Applied has said on several occasions that one thing he would change about the book is that "first choice for domain entities" advice, and he would instead encourage people to use plain hash maps as their first choice. – Sean Corfield Aug 02 '23 at 22:14
3

To Sean's excellent answer, I would only add that records can slow down iterative development, especially using a tool like lein-test-refresh or similar.

Records form a separate Java class, and must be recompiled upon every change, which can slow down the iteration cycle.

In addition, recompilation breaks comparison with still-existing record objects, since the recompiled object (even if there are no changes!) will not be = to the original since it has a different class file. As an example, suppose you have a Point record:

(defrecord Point [x y])

(def p (->Point 1 2)) ; in file ppp.clj
(def q (->Point 1 2)) ; in file qqq.clj

(is (= p q)) ; in a unit test

If file ppp.clj gets recompiled, it generates a new Point class with a different "ID" value than before. Since records must have the same type AND values to be considered equal, the unit test will fail even though both are of type Point and both have values [1 2]. This is an unintended pain point when using records.

Alan Thompson
  • 29,276
  • 6
  • 41
  • 48