Teaching Clojure II

2023 06 02 head

Functional programming is hype for the last ten years.

All popular programming languages have added support for functional concepts for years.

Not all developers are embracing the new approach and using the provided constructs.

I often have to read source code which uses only idioms defined in the last millennium. I cringed and asked myself why developers are writing such awkward code?

What does it mean to code with functional paradigms?

I have an interesting test to decide if your code is embracing functional programming approaches:

  1. You are using library methods with formal parameters being functions or lambda expressions.

  2. You use the map, filter, reduce and apply operations instead of conditional control blocks. Loops and conditional statements are almost disappearing in your code.

  3. You declared in your own methods functions as formal parameters.

  4. You declared in at least one of your methods a return type being a function.

Clojure is a very nice language to learn functional programming idioms [1].

Functional Thinking

The key functional concepts of Clojure [1] are:

  • All data structures are per default immutable. Lists, vectors, maps, sets, records are all immutable.

  • Functions are first-class functions. You can also state that functions are first-class citizens. This means that functions have the same functionality as objects. For example, they can be assigned to variables, passed in as arguments, or returned from a function.

(defrecord Person [^String firstname ^String lastname])       (1)

(def aPerson (->Person "John" "Doe"))                         (2)
;; or (def aPerson (Person. "John" "Doe"))

(:firstname aPerson)                                          ;; => "John"
(:lastname aPerson)                                           ;; => "Doe

(def anotherPerson (assoc aPerson :firstname "Jane"))         (3)
;; => Jane doe
1 Define a new record Person with two string properties firstname and lastname. The string type is only a hint and not enforced.
2 Create a new record instance with the firstname John and lastname Doe and associate it with reference to aPerson.
3 Create a new record instance with the values of aPerson and the new value Jane for the firstname.
(defn factorial-loop [^BigDecimal x]
  (loop [n x prod 1]
    (if (= 1 n)
      prod
      (recur (dec n) (* prod n)))))                           (1)

(defn factorial-tail-recursion [^BigDecimal x]
  (letfn [(f [result n]
    (if (= n 1)
        result
        (recur (* result n) (dec n))))]                       (2)
  (f 1 x)))
1 The recur expression evaluates the new values for all loop variables and restarts the loop with these values.
2 The recur expression evaluates the new values for all function parameters and restarts the function without adding a new stack frame. This mechanism provides tail-recursion call optimization.
(defn greet                                                   (1)
     ([] (greet "you"))
     ([name] (print "Hello" name)))

(greet)                                                       ;; => Hello you

(greet "World")                                               ;; => Hello World

(defn greet [name & rest] (print "Hello" name rest))          (2)

(greet "Yan" "Darryl" "John" "Tom")                          ;; => Hello Yan (Darryl John Tom)
1 Define a multi-arity function. The first declaration is without a parameter, the second one has one parameter.
2 Define a variadic function with a variable number of parameters as the last formal parameter.

Advanced Functional Constructs

The regular mapping, filtering, reducing and applying functions are fully supported in Clojure.

Partial application to reduce the arity of a function is supported with the apply construct.

Memoization is also provided. Beware of the tradeoff between processing improvement versus memory consumption.

(map inc [1 2 3 4 5])                                         ;;=> (2 3 4 5 6)

(filter even? (range 10))                                     ;;=> (0 2 4 6 8)

(reduce + [1 2 3 4 5])                                        ;;=> 15

(apply str ["str1" "str2" "str3"])                            ;;=> "str1str2str3"

;; partial application on the add function
(defn add [x y] (+ x y))
(def adder (partial add 5))
(adder 1)                                                     ;; => 6

;; memoization of function calls
 (def memoized-fibonacci
  (memoize (fn [n]
             (condp = n
               0 1
               1 1
               (+ (memoized-fibonacci(dec n)) (memoized-fibonacci (- n 2)))))))

(time (memoized-fibonacci 80))
;; "Elapsed time: 0.593208 msecs"  => 37889062373143906
(time (memoized-fibonacci 80))
;; "Elapsed time: 0.022459 msecs"  => 37889062373143906

Synchronization Concepts

Concurrency is built into the language. Rich Hickey designed Clojure to specifically address the problems that develop from shared access to mutable state. Clojure embodies a very clear conception of state that makes it inherently safer for concurrency than most popular programming languages.

Three concepts shall cover all your concurrency needs.

Atoms

Atoms provide a way to manage shared, synchronous, independent state. Atom allows you to endow a succession of related values with an identity. Atoms are an efficient way to represent some state that will never need to be coordinated with any other, and for which you wish to make synchronous changes

Refs and Transactions

Refs allow you to update the state of multiple identities using transaction semantics. These transactions have three features:

  • They are atomic, meaning that all refs are updated or none of them are.

  • They are consistent, meaning that the refs always appear to have valid states. A sock will always belong to a dryer or a gnome, but never both or neither.

  • They are isolated, meaning that transactions behave as if they executed serially. If two threads are simultaneously running transactions that alter the same ref, one transaction will retry. This is similar to the compare-and-set semantics of atoms.

    You might recognize these as the A, C, and I in the ACID properties of database transactions. You can think of refs as giving you the same concurrency safety as database transactions, only with in-memory data. Clojure uses software transactional memory STM to implement this behavior.

Agents

Agents are a mechanism for sequencing operations on a particular instance of a data structure. Agents provide independent, asynchronous change of individual locations. Agents are bound to a single storage location for their lifetime and only allow mutation of that location (to a new state) to occur as a result of an action.

;; Atoms

(def state (atom {}))                                         (1)
(swap! state assoc :x 42)                                     (2)

(println @state)                                              (3)
;; @state is equivalent to (deref state) => {:x 42}

;; References

(def account-a (ref 100))                                     (4)
(def account-b (ref 100))

(defn transfer! [amount from to]
  (dosync                                                     (5)
   (if (>= (- @from amount) 0)
     (do
       (alter from - amount)                                  (6)
       (alter to + amount)))))

(transfer! 20 account-a account-b)
(println @account-a @account-b)                               ;; 80 120

;; Agents

(def x (agent 0))                                             (7)
(defn increment [c n] (+ c n))
(send x increment 5)                                          (8)
;; @x -> 5
(send x increment 10)
;; @x -> 15
1 Declare an atom.
2 Update the value of the atom. The expression passed as a parameter to swap! is applied to the current value of the atom.
3 Gets the current value of the atom.
4 Declare a reference.
5 Define the transactional parenthesis by grouping multiple modifications of references.
6 The behavior of alter is:
  1. Reach outside the transaction and read the reference’s current state.

  2. Compare the current state to the state the ref started with within the transaction.

  3. If the two differ, make the transaction retry. Otherwise, commit the altered ref state.

7 Declare an agent.
8 Send the expression to the agent. The expression is executed asynchronously using a thread pool. The first parameter of the expression will be the agent value.

Atoms allow multiple threads to apply transformations to a single value and guarantee the transformations are atomic. swap! takes the atom and a function expecting the current value of the atom. The result of calling that function with the current value is stored in the atom. multiple calls to swap! may interleave, but each call will run in isolation.

Refs allow multiple threads to update multiple values in a co-ordinated way. All updates to all refs inside a sync will complete or none will. You must write your code such that transaction retries are catered for. There are a few potential performance tweaks if you can relax the ordering of operations, which may reduce the chance of transaction retry.

Embrace Modern Java

Modern Java adds functional approaches [2] to the Java language. I will certainly not pretend that Java is a functional language. You still can go a long way and write more functional and legible code using the provided mechanisms.

The major constructs are:

  • Lambda functions and Java functional Idioms

  • Streams and monoids

  • Algebraic data types and pattern matching

  • Structured concurrency and virtual threads

Functional Java means no more for, while, and do loops [1].

Functional Java means no more checks if a value has the value null.

Virtual threads means no more asynchronous programming.

Java still does not support tail optimization. This constraint limits the use of recursive constructs in your solution.

Partial application is painful in Java due to the type declarations implied with the single abstract method interface approach for lambdas. Try using var as much as possible. Otherwise, you need to type very lengthy type declarations.

Lessons Learnt

Clojure is an ideal language to learn and better understand functional programming approaches [3].

You will probably not use it in commercial product development. None of the functional languages such as Clojure, List, F# has taken over the world of programmers.

The principles you learnt shall often be applicable to your technology stack. Your code will certainly be simpler, more legible and maintainable.

Some advanced concepts exist to better integrate Clojure with Java and provide object-oriented features to the language. I would recommend using Java to teach these concepts and restrict Clojure teaching to functional programming aspects.

I wish you happy coding in the functional world of Clojure.

References

[1] S. D. Halloway, Programming Clojure. 2012 [Online]. Available: https://www.amazon.com/dp/B07BN4C92X

[2] V. Subramaniam, Functional Programming In Java Harnessing The Power Of Java 8 Lambda Expressions. The Pragmatic Programmers, 2014 [Online]. Available: https://www.amazon.com/dp/B0CJL7VKFL

[3] N. Ford, Functional Thinking: Paradigm Over Syntax. O’Reilly Media [Online]. Available: https://www.amazon.com/dp/B00LEX6SP8


1. Recursion is sufficient to have a Turing complete language. Loops are not required.