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.
Learning goals¶
By the end of this lesson, you can:
- Create anonymous and named functions.
- Read parameter vectors and return values.
- Use local bindings.
- Build pure state-transition functions.
- Create and read an atom.
- Replace atom state with
reset!andswap!. - Keep effects outside state-transition functions.
Anonymous 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.
Named functions¶
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:
defndefines a function.clean-lineis the function name.[line]is the parameter vector.(str/trim line)is the body.
A good function name states what the returned value means.
Functions return values¶
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.
Local bindings¶
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.
Dependent local values¶
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.
Pure state transitions¶
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.
Create an atom¶
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.
Replace atom state¶
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.
Keep the transition reusable¶
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.
Compare and set¶
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.
One atom can hold a complete model¶
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.
Atom state is still immutable data¶
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.
Build the course state model¶
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
Practice loop¶
For each transition:
- Predict the returned state.
- Call the function with plain data.
- Call the same function through
swap!. - Compare the plain result with the atom value.
- Change one argument and explain the new state.
Common mistakes¶
Putting an atom inside every function¶
A function that accepts plain state is easier to test, reuse, and trace.
Forgetting to dereference¶
run
This returns the atom object. Use @run for its current value.
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.
Mixing transformation and I/O¶
Do not make record-line also write a file. Let the function calculate state. Let an I/O boundary write bytes.
Check yourself¶
You are ready for the next lesson when you can answer these questions:
- What value does a function return?
- Why should a transition accept plain state?
- What does an atom contain?
- What is the difference between
reset!andswap!? - Why can old atom values remain useful?
- When should a function name end in
!? - Why might one atom be safer than several related atoms?
Continue with 03. Iterators and streaming.