Why One Function Isn't Enough for All Collections
Most Clojure functions we’ve seen so far are written for a single kind of input. For example, consider a function that appends to a vector:
(defn append
"Adds an element x to the end of a vector v."
[v x]
(conj v x))
scratch.polymorphism=> (append [1 2] 3)
[1 2 3]
But what happens when you want to append to a list? The usual conj function prepends to lists, not appends:
scratch.polymorphism=> (append '(1 2) 3)
(3 1 2)
One workaround is to use concat for both cases:
(defn append-concat
"Adds an element x to the end of a collection coll by concatenating a
single-element list (x) to the end of coll."
[coll x]
(concat coll (list x)))
This works, but it creates an unnecessary wrapper object on every call, even for vectors where a plain conj would be cheaper. The real answer is polymorphism—a function that behaves differently based on the type of its argument.
Runtime Type Dispatch
A naive approach is to inspect the type of the collection at runtime. The type function returns the exact class of a value:
(type [1 2])
clojure.lang.PersistentVector
(type '(1 2))
clojure.lang.PersistentList
Given that, we could write a branch that checks whether the collection is a PersistentVector and uses conj; otherwise, we fall back to concat:
(defn append
"Adds an element x to the end of a collection coll. Coll may be either a
vector or a list."
[coll x]
(condp = (type coll)
clojure.lang.PersistentVector
(conj coll x)
clojure.lang.PersistentList
(concat coll (list x))))
Testing it on a vector and a list shows this works:
scratch.polymorphism=> (append [1 2] 3)
[1 2 3]
scratch.polymorphism=> (append '(1 2) 3)
(1 2 3)
But look what happens with an empty list:
scratch.polymorphism=> (append '() 3)
IllegalArgumentException No matching clause: class clojure.lang.PersistentList$EmptyList scratch.polymorphism/append (polymorphism.clj:7)
The problem is that empty lists are a distinct class in Clojure:
scratch.polymorphism=> (type '())
clojure.lang.PersistentList$EmptyList
An empty list is clojure.lang.PersistentList$EmptyList, not clojure.lang.PersistentList. Yet they behave almost identically everywhere else. The reason lies in Clojure’s type system—specifically, in subtypes and interfaces.
Classes, Interfaces, and Supertypes
On the JVM, every value has a class, such as java.lang.String or clojure.lang.PersistentVector. Each class is a subtype of exactly one other class, ultimately reaching java.lang.Object. The JVM also has interfaces, which define behaviors—a collection of functions that instances of that interface must support. Unlike classes, a class can implement many interfaces. Clojure leverages this heavily: a list, for instance, implements clojure.lang.IPersistentList, which defines the core list behavior, as well as other interfaces that describe general collection properties like being countable or sequenceable.
You can see the full set of a type’s supertypes—both classes and interfaces—with the supers function:
scratch.polymorphism=> (supers clojure.lang.PersistentList$EmptyList)
#{clojure.lang.Obj clojure.lang.IPersistentCollection clojure.lang.IMeta clojure.lang.IObj clojure.lang.Sequential java.lang.Iterable java.io.Serializable clojure.lang.IPersistentStack java.lang.Object clojure.lang.IHashEq clojure.lang.IPersistentList clojure.lang.Seqable clojure.lang.ISeq clojure.lang.Counted java.util.List java.util.Collection}
scratch.polymorphism=> (supers clojure.lang.PersistentList)
#{clojure.lang.Obj clojure.lang.IPersistentCollection clojure.lang.IReduce clojure.lang.IMeta clojure.lang.IObj clojure.lang.Sequential java.lang.Iterable java.io.Serializable clojure.lang.IPersistentStack java.lang.Object clojure.lang.IHashEq clojure.lang.IPersistentList clojure.lang.Seqable clojure.lang.ISeq clojure.lang.ASeq clojure.lang.Counted java.util.List java.util.Collection clojure.lang.IReduceInit}
Notice that empty and non-empty lists share nearly all of their supertypes. Both are subtypes of clojure.lang.IPersistentList, which is what lets Clojure treat them uniformly even though they are different classes.
Our earlier type check compared equality of types, which fails for empty lists. What we really want is to check whether the collection’s type is a subtype of some target. We can do that by checking the type and all its supertypes:
(defn append
"Adds an element x to the end of a collection coll. Coll may be either a
vector or a list."
[coll x]
(let [t (type coll)
types (conj (supers t) t)]
(cond (types clojure.lang.PersistentVector)
(conj coll x)
(types clojure.lang.IPersistentList)
(concat coll (list x))
true (str "Sorry, I don't know how to append to a "
(type coll) ", which has supertypes " types))))
scratch.polymorphism=> (append '() 1)
(1)
That fixes the empty list case. But what about lazy sequences, the kind produced by map?
scratch.polymorphism=> (append (map inc [1 2 3]) 5)
"Sorry, I don't know how to append to a class clojure.lang.LazySeq, which has supertypes #{java.util.List clojure.lang.IHashEq java.io.Serializable clojure.lang.IObj clojure.lang.IPersistentCollection clojure.lang.ISeq java.util.Collection java.lang.Iterable clojure.lang.Seqable clojure.lang.IPending clojure.lang.Sequential java.lang.Object clojure.lang.IMeta clojure.lang.Obj}"
A lazy sequence is neither a list nor a vector. We could add another clause for LazySeq, but is there a more general type that both lists and lazy sequences implement?
(require '[clojure.set :as set])
scratch.polymorphism=> (set/intersection (supers clojure.lang.IPersistentList) (supers clojure.lang.LazySeq))
#{clojure.lang.IPersistentCollection clojure.lang.Seqable clojure.lang.Sequential}
Among the shared supertypes are IPersistentCollection (any Clojure collection), Seqable (anything that can be treated as a sequence), and Sequential (collections with a definite order, like lists, vectors, and lazy sequences—but not sets or maps). If the behavior we want applies to any ordered collection, Sequential is the right abstraction:
(defn append
"Adds an element x to the end of any sequential collection--faster for vectors."
[coll x]
(let [t (type coll)
types (conj (supers t) t)]
(cond (types clojure.lang.PersistentVector)
(conj coll x)
(types clojure.lang.Seqable)
(concat coll (list x))
true (str "Sorry, I don't know how to append to a "
(type coll) ", which has supertypes " types))))
scratch.polymorphism=> (append (map inc [1 2 3]) 5)
(2 3 4 5)
This version is both general and efficient: it appends to vectors with conj and falls back to concat for all other sequential types.
The instance? Function
Rather than manually computing the union of a type and its supertypes, Clojure provides instance?. A value v is an instance of type T if T is the value’s class or any of its supertypes:
scratch.polymorphism=> (instance? clojure.lang.PersistentVector [])
true
scratch.polymorphism=> (instance? clojure.lang.PersistentVector (list))
false
Using instance? makes the code cleaner:
(defn append
"Adds an element x to the end of any sequential collection--faster for
vectors."
[coll x]
(cond (instance? clojure.lang.PersistentVector coll)
(conj coll x)
(instance? clojure.lang.IPersistentList coll)
(concat coll (list x))
true (str "Sorry, I don't know how to append to a "
(type coll))))
This is a legitimate style of polymorphic programming, but it has a serious limitation. Adding support for a new collection type means editing the append function itself—you cannot extend it from the outside. This is one side of what programmers call the expression problem: the difficulty of adding new behaviors to existing types without modifying them, and adding new types without modifying existing behaviors. Clojure addresses this with a feature we’ll cover next: multimethods.
Dispatch by type
A multimethod is a function that, instead of a body, has a dispatch function. The dispatch function receives the arguments and returns a value that Clojure uses to select the appropriate implementation. You declare a multimethod with defmulti, then define its implementations separately with defmethod.
(defmulti append
"Appends an x to collection coll."
(fn [coll x] (type coll)))
Here, append is a multimethod whose dispatch function returns the type of its first argument. The docstring and the dispatch function follow the name, so the dispatch function always receives the same arguments as the multimethod itself. Because dispatch is decoupled from implementation, you can add new cases without touching existing ones.
(defmethod append clojure.lang.PersistentVector
[coll x]
(conj coll x))
(defmethod append clojure.lang.Sequential
[coll x]
(concat coll (list x)))
The second implementation handles any clojure.lang.Sequential by using concat. This works even for values whose exact type you never registered, because multimethods dispatch with isa?, not =. Along with plain equality, isa? recognizes Java type relationships and any hierarchies you establish with derive.
scratch.polymorphism=> (doc isa?)
-------------------------
clojure.core/isa?
([child parent] [h child parent])
Returns true if (= child parent), or child is directly or indirectly derived from
parent, either via a Java type inheritance relationship or a
relationship established via derive. h must be a hierarchy obtained
from make-hierarchy, if not supplied defaults to the global
hierarchy
Why does that matter? You can dispatch on a broad category like Sequential instead of listing every concrete class. Better still, derive lets you build hierarchies among keywords and symbols. Classes may only be children of other classes or keywords, but keywords can be as flexible as you need.
scratch.polymorphism=> (doc derive)
-------------------------
clojure.core/derive
([tag parent] [h tag parent])
Establishes a parent/child relationship between parent and
tag. Parent must be a namespace-qualified symbol or keyword and
child can be either a namespace-qualified symbol or keyword or a
class. h must be a hierarchy obtained from make-hierarchy, if not
supplied defaults to, and modifies, the global hierarchy.
(derive ::milk ::dairy)
(derive ::dairy ::grocery)
scratch.polymorphism=> (isa? ::milk ::milk)
true
scratch.polymorphism=> (isa? ::milk ::furniture)
false
scratch.polymorphism=> (isa? ::milk ::dairy)
true
scratch.polymorphism=> (isa? ::milk ::grocery)
true
With these relationships, a qualified keyword like ::milk is considered a kind of ::dairy, and, transitively, a ::grocery. You can also express multiple inheritance: milk can be both a grocery and refrigerated, while apples are simply groceries.
(derive ::milk ::refrigerated)
(derive ::apples ::grocery)
scratch.polymorphism=> (isa? ::milk ::grocery)
true
scratch.polymorphism=> (isa? ::milk ::refrigerated)
true
scratch.polymorphism=> (isa? ::apples ::grocery)
true
The parents function returns the direct supertypes, and descendents returns everything below a given node:
scratch.polymorphism=> (parents ::milk)
#{:scratch.polymorphism/refrigerated :scratch.polymorphism/dairy}
scratch.polymorphism=> (descendants ::grocery)
#{:scratch.polymorphism/milk :scratch.polymorphism/apples :scratch.polymorphism/dairy}
Now imagine representing groceries as maps like {:item-type ::milk, :size :gallon}. Since keywords act as functions, you can dispatch on the value they return:
(defmulti put-away
"Stores an item when we get home."
:item-type)
(defmethod put-away ::grocery
[item]
(println "Putting a" (name (:size item)) "of" (name (:item-type item))
"in the pantry"))
(defmethod put-away ::refrigerated
[item]
(println "Storing a" (name (:size item)) "of" (name (:item-type item))
"in the fridge"))
Apples go to the pantry because ::apples is only a grocery. Milk, however, is both grocery and refrigerated, so Clojure needs a rule to break the tie:
(prefer-method put-away ::refrigerated ::grocery)
scratch.polymorphism=> (put-away {:item-type ::milk, :size :gallon})
Storing a gallon of milk in the fridge
What you have built is a small type system of your own, with the same core idea as Clojure's own: code written against general classes automatically covers more specific ones. Someone else could invent a new category, like ::medication, and extend put-away to store it somewhere appropriate, without ever touching your original code.
Multimethods are flexible, but the dispatch function runs on every call, which makes them slower than an ordinary function. When performance matters, Clojure offers a lower-level form of type dispatch.
Interfaces and their methods
Interfaces are the JVM's built-in mechanism for type dispatch: the runtime picks an implementation from the type of a value, without any intervening dispatch code. Clojure does not restrict you to the interfaces it provides; you can define your own with definterface.
(definterface IAppend
(append [x]))
This declares a type named IAppend. Any value that is an instance of IAppend must have a method called append. These methods are JVM methods, not Clojure functions: they cannot be passed as arguments to map or filter, and they have no docstring. They are nonetheless first-class to the JVM's dispatch machinery.
Note that the append method declaration lists only one argument, x, even though a two-argument call like (append [1 2] 3) is the target. This is because interfaces always operate on their first argument implicitly: the receiver, which must be an instance of IAppend, is not listed in the method signature. That quirk is inherited from the JVM's design. There is no body and no dispatch function here, because the runtime will always select append based on the first argument's type. Clojure knows how to call the method on any IAppend — but how do you create a value that satisfied the interface in the first place?
Beyond Hand-Rolled Dispatch
With IAppend defined as an interface, the next step is creating values that actually implement it. Clojure’s reify macro is the tool for this: it generates an anonymous class at compile time and returns instances of that class, letting you supply concrete implementations for the interface’s methods. A simple use case is a grocery list that can accept new items:
(defn grocery-list
"Creates an appendable grocery list. Takes a vector of
groceries to buy."
[to-buy]
(reify IAppend
(append [this x]
(grocery-list (conj to-buy x)))))
The function grocery-list wraps the reify call. Each invocation of grocery-list produces a new instance of the anonymous IAppend class. When the append method is invoked with the object and a new item, the implementation uses conj to add x to the internal to-buy vector, then calls grocery-list again with the updated vector. Note that reify, like fn, is a closure: it captures the surrounding to-buy binding, so the resulting object carries its state with it.
Calling append on this object works, but the default toString output is unhelpful — it shows the generated class name and object identity, not the list contents.
scratch.polymorphism=> (supers (type (grocery-list [:eggs])))
#{clojure.lang.IObj scratch.polymorphism.IAppend java.lang.Object clojure.lang.IMeta}
In contrast to built-in types like PersistentVector, a reified object’s type hierarchy is minimal. The supers call confirms this instance is an IAppend, an Object, and also implements IObj and IMeta automatically. Checking its metadata reveals the source location where the reify expression was written.
scratch.polymorphism=> (meta (grocery-list [:eggs]))
{:line 12, :column 3}
Calling the append method on this object does work, but note the distinction: methods and functions are not the same in Clojure. The method call requires a leading dot:
scratch.polymorphism=> (.append (grocery-list [:eggs]) :tofu)
#object[scratch.polymorphism$grocery_list$reify__1950 0x40eb00f0 "scratch.polymorphism$grocery_list$reify__1950@40eb00f0"]
If you prefer a functional interface, you can write a wrapper that delegates to the method, making it usable with higher-order functions like reduce.
(defn append
"Appends x to the end of coll."
[coll x]
(.append coll x))
To make the grocery list printable in a useful way, you can override toString within the same reify. The macro accepts multiple interfaces (or classes like Object) followed by their respective method bodies.
(defn grocery-list
"Creates an appendable (via IAppend) grocery list. Takes a vector of
groceries to buy."
[to-buy]
(reify
IAppend
(append [this x]
(grocery-list (conj to-buy x)))
Object
(toString [this]
(str "To buy: " to-buy))))
Now the printed output is readable — a significant improvement. More importantly, this is polymorphism in action without touching the core toString or str implementations. The dispatch on type is handled by the runtime, so you can add new behaviors for new types without modifying existing code paths.
scratch.polymorphism=> (str (.append (grocery-list [:eggs]) :tomatoes))
"To buy: [:eggs :tomatoes]"
One obvious limitation remains: this works only for types you define yourself. Trying to call .append on a vector fails because no IAppend method exists for PersistentVector. Interfaces on the JVM are a one-way street — you cannot retroactively make an existing class implement a new interface. This is the other half of the expression problem: while multimethods let you extend functions to new types, interfaces don’t let you extend types to new interfaces.
Protocols as Extensible Interfaces
Clojure’s answer is the protocol — a construct that behaves like an interface but can be extended to existing types. Protocols define named functions whose first argument is the dispatcher, and they generate real functions rather than JVM methods. defprotocol sets one up:
(defprotocol Append
"This protocol lets us add things to the end of a collection."
(append [coll x]
"Appends x to the end of collection coll."))
Calling this will overwrite any earlier append function with a warning. The protocol declares an append function taking coll and x, with its own docstring. Unlike interface methods, protocol functions take explicit first arguments, can be documented, and are called without a dot — they’re first-class citizens that can be passed around or passed to reduce.
Protocols are introspectable like regular definitions:
scratch.polymorphism=> (doc Append)
-------------------------
scratch.polymorphism/Append
This protocol lets us add things to the end of a collection.
Marking a grocery list with IAppend won’t satisfy the new protocol — the $
Named types and why they matter
The reify forms we’ve been using create objects that satisfy interfaces or protocols, much like an anonymous function (fn [x] ...) creates an anonymous type. Because the resulting type has no predictable name, we can’t later extend protocols to it. That’s a real limitation when we want a named, reusable type such as clojure.lang.PersistentVector.
Two macros build named types in Clojure. deftype is the more basic tool, producing a minimal datatype. Here’s a GroceryList type with a single field, plus an implementation of an Append protocol:
(deftype GroceryList [to-buy]
Append
(append [this x]
(GroceryList. (conj to-buy x)))
Object
(toString [this]
(str "To buy: " to-buy)))
Creating instances uses the class name followed by a period — (GroceryList. to-buy). In the protocol method above, constructing a new list with the updated field is done the same way. Instances print with their fully qualified name and a memory address:
scratch.polymorphism=> (GroceryList. [:eggs])
#object[scratch.polymorphism.GroceryList 0x370dbd33 "To buy: [:eggs]"]
Besides the Append protocol, a deftype instance’s own type hierarchy is sparse. The compiler adds clojure.lang.IType as a marker, and everything inherits from java.lang.Object. Beyond implementing what you specify, not much is provided for free.
Two affordances do come built in. Fields can be read directly with .to-buy, and the ->GroceryList function wraps the constructor. That function is handy because GroceryList., like a method call, isn’t a first-class function—you can’t pass it to map or apply, but you can pass ->GroceryList instead.
scratch.polymorphism=> (.to-buy (GroceryList. [:eggs]))
[:eggs]
scratch.polymorphism=> (->GroceryList [:strawberries])
#object[scratch.polymorphism.GroceryList 0x44cc69b3 "To buy: [:strawberries]"]
Those basic utilities aside, deftype is conservative by design. Equality, for instance, is identity-based:
scratch.polymorphism=> (= (GroceryList. [:cheese]) (GroceryList. [:cheese]))
false
The only object a GroceryList is equal to is itself. Clojure won’t assume two lists with identical fields are equivalent—that determination is left to us. Implementing the equals method from Object explicitly opts in to value-based equality. You could even make every grocery list equal if that were genuinely useful.
(deftype GroceryList [to-buy]
Append
(append [this x]
(GroceryList. (conj to-buy x)))
Object
(toString [this]
(str "To buy: " to-buy))
(equals [this other]
(and (= (type this) (type other))
(= to-buy (.to-buy other)))))
scratch.polymorphism=> (= (GroceryList. [:cheese]) (GroceryList. [:cheese]))
true
In most cases, we don’t want this level of raw control. Plain maps already provide convenient printing, equality semantics, and manipulation via core functions. What would be ideal is a type that participates in protocols, yet still behaves like a map. A defrecord is exactly that — a named type that supports protocol dispatch while offering map-like operations out of the box.
Records behave like maps
The syntax of defrecord closely mirrors deftype: a name, field names, and protocol/interface implementations. Construction works the same way with either GroceryList. or ->GroceryList.
(defrecord GroceryList [to-buy]
Append
(append [this x]
(GroceryList. (conj to-buy x))))
Instance printing is the immediate payoff:
scratch.polymorphism=> (GroceryList. [:beans])
#scratch.polymorphism.GroceryList{:to-buy [:beans]}
Printing shows the type name followed by a map-like view of the fields. Equality is likewise value-based — two records are equal when they’re the same type and their fields match. They are not, however, considered equal to plain maps with the same keys and values.
scratch.polymorphism=> (= (GroceryList. [:beans]) (GroceryList. [:beans]))
true
scratch.polymorphism=> (= (GroceryList. [:beans]) {:to-buy [:beans]})
false
Fields are accessible in several ways. Direct access with .to-buy works, as do the map-oriented get and keyword-as-function lookups:
scratch.polymorphism=> (.to-buy (GroceryList. [:bread]))
[:bread]
scratch.polymorphism=> (get (GroceryList. [:bread]) :to-buy)
[:bread]
scratch.polymorphism=> (:to-buy (GroceryList. [:bread]))
[:bread]
Mutation is also map-like. assoc and update create immutable copies with the requested changes rather than mutating in place:
scratch.polymorphism=> (-> (GroceryList. [:chicken])
(assoc :to-buy [:onion]))
#scratch.polymorphism.GroceryList{:to-buy [:onion]}
scratch.polymorphism=> (-> (GroceryList. [:chicken])
(assoc :to-buy [:onion])
(update :to-buy conj :beets))
#scratch.polymorphism.GroceryList{:to-buy [:onion :beets]}
Records also keep an internal supplementary map for fields not declared up front. Adding a :note key falls back to that extra map when no matching field exists:
scratch.polymorphism=> (assoc (GroceryList. [:cherries]) :note "Tart cherries if possible!")
#scratch.polymorphism.GroceryList{:to-buy [:cherries], :note "Tart cherries if possible!"}
Polymorphism with records in practice
Because defrecord produces a named type, we can extend protocols to it after the fact. Consider defining a protocol for console output and extending it to both a general implementation for any object and a specific one for GroceryList:
(defprotocol Printable
(print-out [x] "Print out the given object, nicely formatted."))
With protocol dispatch in place, rendering a grocery list using the generic Object implementation for each item gives us cleanly formatted output:
(extend-protocol Printable
GroceryList
(print-out [gl]
(println "GROCERIES")
(println "---------")
(doseq [item (:to-buy gl)]
(print "[ ] ")
(print-out item)
(println)))
Object
(print-out [x]
(print x)))
scratch.polymorphism=> (print-out (GroceryList. [:cilantro :carrots :pork :baguette]))
GROCERIES
---------
[ ] :cilantro
[ ] :carrots
[ ] :pork
[ ] :baguette
This pattern extends naturally. If we later want to represent quantities, we define a CountedItem record with its own printing rules:
(defrecord CountedItem [thing quantity]
Printable
(print-out [this]
(print-out thing)
(print (str " (" quantity "x)"))))
No changes to GroceryList are needed. The accumulated CountedItems print correctly because print-out dispatches polymorphically:
scratch.polymorphism=> (print-out (GroceryList. [:cilantro (CountedItem. :carrots 2) :pork :baguette]))
GROCERIES
---------
[ ] :cilantro
[ ] :carrots (2x)
[ ] :pork
[ ] :baguette
Choosing between bare maps, deftype, and defrecord
Coming from Java-style OOP or Haskell-style algebraic data types can make defprotocol/defrecord feel like the obviously correct modeling tools. At first glance it’s tempting to write (defrecord Person [name pronouns age]). But before reaching for records, it’s worth asking whether polymorphism is actually needed. If data simply needs to be grouped and passed around, plain maps are often the better default:
{:name "Morgan"
:pronouns [:they :them]
:age 56}
Maps flow through every core Clojure function, persist to disk trivially, serialize well for network transport, and print concisely in the REPL. That combination makes for easy debugging and easy interoperation with other Clojure code and libraries. Records earn their place at specific moments: when protocol dispatch matters, when methods underpin system boundaries, or when multicall performance justifies the extra structure. Records may also beat maps on speed and memory footprint, but that trade-off requires measurement — assuming the overhead is worth it without profiling is risky.
And if the motivation is type safety, records won’t deliver the guardrails you might expect. assoc works across all record types and lacks compile-time checks against misspelled or wrong-type keywords. The compiler won’t flag a mismatched field name any more than it would for a map. Clojure leans on tests and contracts rather than static typing. Tools like core.typed provide optional static checking, but they’re an addition to standard Clojure practice, not a feature of records themselves.
Bringing the dispatch story together
Polymorphism in Clojure spans several abstraction levels. Just as core functions like conj and reduce behave differently for each collection type, our own functions can become implicitly polymorphic by composing with them. Explicit type-dependent logic can also be handled by simple forms like cond or case, using instance?, type, or supers to discriminate.
When dispatch needs to remain open — where new types can meaningfully participate — multimethods are the most flexible answer. They incur a performance cost in exchange for arbitrary dispatch vectors and derive-based relationships between non-type values, with fine-grained control over ambiguity during dispatch.
For type-of-first-argument dispatch, protocols are the polished interface. They define regular functions rather than Java-style methods, and the fact that they can be extended to previously defined types makes them more useful than bare interfaces. Interfaces carry slightly less overhead and remain reasonable when performance is the overriding concern; definterface+ can synthesize wrapper functions for method-centric interfaces when wrapper ergonomics matter.
For instantiating types, scope matters. reify is our ephemeral tool: the type lives and dies anonymously at the point of use. When other code needs to extend something meaningfully, defrecord is typically the better default — regular map behavior and value equality cost little. deftype remains there for cases where none of those conveniences are acceptable and the implementation must be fully intentional. Java-style inheritance and class internals sit largely outside the model — critical to know for interop, but rarely central to day-to-day Clojure design work.
Polymorphism Exercises
Sorting and Multimethods
Start by writing a sorted function backed by cond and instance?. For lists, return a sorted list with (sort ...); for sets, convert to a sorted set with (into (sorted-set) ...). Once that works, refactor sorted into a multimethod. Use defmethod to extend it to handle maps as well.
Before moving on, run the loop from one to ten, setting *print-length* to three. Then, creating larger sets—ten, a thousand, and a hundred thousand elements—use (time (has-element? some-set 123)) to observe the performance difference and consider why it scales that way.
Building a Set Protocol
Imagine Clojure ships with no sets. Define a Set protocol exposing basic operations like add-element, has-element?, and remove-element. First, back that protocol with a vector or list-based store. Confirm idempotence: adding the same item twice must not create duplicates. Compare this effort against a second implementation that stores elements in a map, and time both versions on the sizes above to contrast their characteristics.
Extending Grocery Lists
Add a checked-off field to the GroceryList type, storing the set of items already placed in the cart. Write a check-off function that takes a grocery list and an item (e.g., (check-off my-list eggs)), returning a list whose checked-off set now also contains that item. Then implement a remaining function that returns only the items not yet checked off.
Modify the print-out implementation for GroceryList to honor the checked-off set, such that each checked item is printed with an [x] prefix.
Custom Containers and Deref
Using deftype, define your own container type. The deref function faces this through the clojure.lang.IDeref interface, so your container must conform to that contract to return its current value. Test with @(MyContainer. :hi), which should return :hi.
For a mutable twist, tag a field in deftype as ^:unsynchronized-mutable, as in (deftype DangerBox [^:unsynchronized-mutable value] ...). Design a Mutable protocol whose (write! box value) overwrites the stored value via (set! field value). Build your own mutable container honoring both Mutable and IDeref, so you can write with write! and read back with @.
Use that container as a counter: read its state, increment, and write back—as in (write! box (inc @box)). Wrap this increment in dotimes for many consecutive updates, then check that the final value equals the number passed to dotimes. Finally, duplicate the loop across two threads with future. Compare the resulting counter to the input count, and reconcile that difference against the behavior of an (atom) driven by swap!.



