Skip to content

02. Functions and atoms

A function transforms input values into a result. An atom stores one value that a running program can replace.

Keep these jobs separate:

  • A function describes a change.
  • An atom records the current value.

By the end of this lesson, you can:

  1. Create anonymous and named functions.
  2. Read parameter vectors and return values.
  3. Use local bindings.
  4. Build pure state-transition functions.
  5. Create and read an atom.
  6. Replace atom state with reset! and swap!.
  7. Keep effects outside state-transition functions.

Use fn to create a function value:

(fn [line] (str/trim line))

The parameter vector names the inputs. The body returns its final value.

Call the function directly:

((fn [number] (+ number 1)) 41)
; => 42

The inner form creates the function. The outer form calls it.

Use defn to define a function in the current namespace:

(defn clean-line [line]
(str/trim line))
(clean-line " Hara ")
; => "Hara"

Read the definition in four parts:

  1. defn defines a function.
  2. clean-line is the function name.
  3. [line] is the parameter vector.
  4. (str/trim line) is the body.

A good function name states what the returned value means.

A function returns the value of its final body form:

(defn line-info [line]
(str/trim line)
{:line/text line
:line/length (count line)})

The map is the result. The earlier trim result is discarded.

Do not place forms in a function body unless their results or effects matter.

Use let to name intermediate values:

(defn line-record [line-number line]
(let [cleaned (str/trim line)]
{:line/number line-number
:line/text cleaned
:line/empty (empty? cleaned)}))

The local name cleaned exists only inside the let body.

Hara evaluates sibling let initializers against the enclosing lexical environment. One sibling initializer cannot depend on a new sibling binding.

Use a nested let when the second value needs the first:

(let [cleaned (str/trim line)]
(let [length (count cleaned)]
{:line/text cleaned
:line/length length}))

This rule makes the dependency visible.

A state-transition function receives an old state and returns a new state.

(defn mark-started [state]
(assoc state :run/status :status/running))

The function does not know where the state is stored.

Add progress with another pure function:

(defn record-line [state]
(update state :run/line-count inc))

Create the initial state:

(def initial-state
{:run/status :status/idle
:run/line-count 0
:run/error nil})

Test a transition with plain data:

(record-line initial-state)
; => {:run/status :status/idle
; :run/line-count 1
; :run/error nil}

The original state remains unchanged.

Use an atom when the program needs one replaceable current value:

(def run-state
(atom initial-state))

The atom is not the state map. The atom contains the current state map.

Read the atom with deref:

(deref run-state)

The reader shorthand is @:

@run-state

Both forms return the current value.

Use reset! when you already have the complete next value:

(reset! run-state initial-state)

reset! returns the value that it stores.

Use swap! when the next value depends on the current value:

(swap! run-state mark-started)

swap! reads the current value, calls the function, and stores the returned value.

Pass additional function arguments after the function:

(defn record-error [state message]
(assoc state
:run/status :status/failed
:run/error message))
(swap! run-state record-error "Input file is missing")

The function receives the current state first, then the extra arguments.

This is easy to test:

(record-error initial-state "bad input")

This version hides storage inside the transformation:

(defn fail-run! [message]
(swap! run-state record-error message))

The second function can be useful at an application boundary. The first function remains the reusable domain rule.

Use ! at the end of a name when the operation changes state or performs an effect.

Use compare-and-set! when the replacement must happen only if the atom still contains an expected value:

(compare-and-set!
run-state
initial-state
(mark-started initial-state))

The operation returns a boolean result.

This operation is identity-sensitive for the expected atom value. Prefer swap! for normal state transitions.

A common design stores one coherent immutable model in one atom:

(def app-state
(atom
{:run/status :status/idle
:run/line-count 0
:run/current nil
:run/messages []}))

A transition can update several related fields together:

(defn begin-line [state line-number]
(assoc state
:run/status :status/running
:run/current line-number))
(swap! app-state begin-line 1)

This is easier to reason about than several atoms that can drift into inconsistent combinations.

The atom changes which value it contains. It does not make the contained map mutable.

(def before @app-state)
(swap! app-state record-line)
(def after @app-state)

before and after are separate persistent values.

This property makes history, comparison, rollback, and UI rendering easier.

Add these definitions to your course project:

(def initial-run
{:run/status :status/idle
:run/line-count 0
:run/byte-count 0
:run/current nil
:run/error nil})
(def run
(atom initial-run))
(defn start-run [state]
(assoc state :run/status :status/running))
(defn add-line [state byte-count]
(-> state
(update :run/line-count inc)
(update :run/byte-count + byte-count)))
(defn finish-run [state]
(assoc state
:run/status :status/complete
:run/current nil))

Hara supports the threading macro used above. Read it as a sequence of updates to the state value.

Exercise the state:

(reset! run initial-run)
(swap! run start-run)
(swap! run add-line 12)
(swap! run add-line 8)
(swap! run finish-run)
@run

For each transition:

  1. Predict the returned state.
  2. Call the function with plain data.
  3. Call the same function through swap!.
  4. Compare the plain result with the atom value.
  5. Change one argument and explain the new state.

A function that accepts plain state is easier to test, reuse, and trace.

run

This returns the atom object. Use @run for its current value.

Ignoring the return value of a persistent update

Section titled “Ignoring the return value of a persistent update”
(assoc @run :run/status :status/complete)
@run

The atom did not change. Use swap! or reset! to store a replacement.

Do not make record-line also write a file. Let the function calculate state. Let an I/O boundary write bytes.

You are ready for the next lesson when you can answer these questions:

  1. What value does a function return?
  2. Why should a transition accept plain state?
  3. What does an atom contain?
  4. What is the difference between reset! and swap!?
  5. Why can old atom values remain useful?
  6. When should a function name end in !?
  7. Why might one atom be safer than several related atoms?

Continue with 03. Iterators and streaming.