December 19, 2021

Advanced Features of Clojure Atoms

Most Clojure apps use atoms for managing state, changing state with the swap! or reset! functions:

Basic Atom Operations:
(def inventory (atom {:cheese 1 :bread 2}))
; Use swap! to update the atom with a function. In this example:
; Use the 'assoc' function to update the atom. The extra parameters are forwarded to 'assoc'
(swap! inventory assoc :cheese 3)
=> {:cheese 3, :bread 2}

; deref returns the current state. The @ prefix is 'syntactic' sugar to do the same
(str "use deref " (deref inventory) " or @ " @inventory " to get the value of the atom")
=> "use deref {:cheese 3, :bread 2} or @ {:cheese 3, :bread 2} to get the value of the atom"

; Use 'reset!' to reset the state to a specific value.
(reset! inventory {:cheese 0 :bread 0})
=> {:cheese 0, :bread 0}

In my Java/C# mind, an atom is an AtomicReference / Interlocked.CompareExchange. However, atoms do have more high-level features. Let’s take a look.

Clojure’s Atoms are fancier
Figure 1. Atom Models ;)

Validation Function

An atom can have a validation function, which ensures that the atom is kept in a valid state. Let’s define a function for a valid inventory: The inventory needs to be a map with non-negative integers values. Then we pass the function as a validator to the atom:

(defn valid-inventory? [state]
  (and
    (map? state)
    (every? nat-int? (vals state))))

(def inventory (atom {:cheese 1 :bread 2} :validator valid-inventory?))

If we try to 'swap!' or 'reset!' to an invalid state an exception is thrown and the old state is preserved.

(swap! inventory assoc :cheese 3)
=> {:cheese 3, :bread 2}
; Swapping to an invalid state will fail
(swap! inventory assoc :cheese -2)
=> Execution error (IllegalStateException) at info.gamlor.blog.scratch-pad/eval7907 (scratch_pad.clj:11).
=> Invalid reference state
@inventory
=> {:cheese 3, :bread 2}

; Resetting to invalid state will fail
(reset! inventory [])
=> Execution error (IllegalStateException) at info.gamlor.blog.scratch-pad/eval7911 (scratch_pad.clj:13).
=> Invalid reference state
@inventory
=> {:cheese 3, :bread 2}

To carry information about the issue, throw an exception with the issue instead.

(defn require-valid-inventory [state]
  (when-not (map? state)
    (throw (ex-info "Inventory needs to be map" {:invalid-state state})))
  (when-not (every? nat-int? (vals state))
    (throw (ex-info "Inventory requires non-negative inventory counts" {:invalid-state state})))
  true)

(swap! inventory assoc :cheese -1)
=> Execution error (ExceptionInfo) at info.gamlor.blog.scratch-pad/require-valid-inventory (scratch_pad.clj:5).
=> Inventory requires non-negative inventory counts

To summarize, the validator function is a great way to ensure your app state stays valid. If you use libraries like schema, specs, mali or similar, then you can use your schema-validation on the atom.

Atom Watchers

Atoms also support adding watchers, which is callback for every time an atom is updated. A watcher function needs 4 parameters:

  • The key, which identifies the watcher.

  • The ref/atom, which will be the atom that was changed

  • The old value before the change

  • the new value after the change

; A watcher which prints out the stock in the inventory of a particular food
(defn watch-food [key the-atom old-inventory new-inventory]
  (println "Watching " key " We've went from" (get old-inventory key 0) "to" (get new-inventory key 0)))

Once you have the watcher defined, you register it on an atom: [1]

(add-watch inventory :cheese watch-food)
(add-watch inventory :bread watch-food)

Then, when the atom is changed, the function is called:

(swap! inventory assoc :cheese 4)
=> Watching  :bread  We've went from 2 to 2
=> Watching  :cheese  We've went from 1 to 4
=> {:cheese 4, :bread 2}

A watch is removed by its identification key:

; Remove our :bread watch
(remove-watch inventory :bread)
; Calling add-watch with the same arguments is has no effects, it is idempotent
(add-watch inventory :cheese watch-food)
; Calling add watch with the same key but another function will replace the watch
(add-watch inventory :cheese watch-food2)

Watches Threading

Now you may want to know on what thread a watcher is called. And in what order the watchers are called.

For atoms, the watchers are called on the same thread that is updating the atom. Note: Other types that support watchers, like refs and agents, have a different threading model.

(def counter (atom 1))

; The watcher printing out the trhead it runs on.
(defn calling-thread-watch [key the-atom old-value new-value]
  (println "Watch called on thread" (.getId (Thread/currentThread)) "for" old-value "to" new-value))
(add-watch counter :thread calling-thread-watch)

; Swap the atom to see on what thread our watcher is called
(do
  (println "Swap atom on thread" (.getId (Thread/currentThread)))
  (dotimes [i 100]
    (swap! counter inc)))
=> Swap atom on thread 37
=> Watch called on thread 37 for 1 to 2
=> Watch called on thread 37 for 2 to 3
=> Watch called on thread 37 for 3 to 4
=> Watch called on thread 37 for 4 to 5
=> Watch called on thread 37 for 5 to 6
=> ...

Extended swap! and reset!

The core functions to update an atom are the swap! and reset!. They return the updated value after completing. However, often it is useful to know the old value as swell, so you can do an operation based on the difference. For that purpose there is a swap-vals! and reset-vals!, that returns the now and old value.

(def inventory (atom {:cheese 1 :bread 2}))

(let [[old new] (swap-vals! inventory assoc :cheese 2)]
  (println "Cheese when from" (:cheese old) "to" (:cheese new)))
=> Cheese when from 1 to 2
(let [[old new] (swap-vals! inventory assoc :cheese 2)]
  (println "Cheese when from" (:cheese old) "to" (:cheese new)))
=> Cheese when from 2 to 2

(let [[old new] (reset-vals! inventory {:cheese 4})]
  (println "Cheese when from" (:cheese old) "to" (:cheese new)))
=> Cheese when from 2 to 4

Summary

While Clojure atoms look like an AtomicReference / Interlocked.CompareExchange at first sight, they also provide higher-level features which can be handy in your application.


1. Watches work on agents and refs
Tags: Clojure Development