Sequence Operations
Clojure sequences are abstract. In this chapter, we'll make sense of what exactly we mean by that, and learn about common operations.
We have been using the word "sequence" (or "seq") without a concrete example. This is because seq is not concrete, but an abstraction. In the Syntax and Native Data Types
chapter, we learned that lists, vectors, sets, and maps are sequences, because they implement the ISeq
interface. But what exactly do we mean by that?
Many languages come bundled with functions like map
, reduce
, filter
etc. In ES6, these functions work on arrays. The map
function, for example, takes an array and a function and returns a new array where the element at index i is the result of the function applied to element i of the input array.
If the input array is [1, 2, 4, 5]
, and the function is i => i + 1
, map
code in JavaScript might look something like:
[1, 2, 4, 5].map(i => i+1);
If you execute this code in a JS console, you'll see an output array [2, 3, 5, 6]
.
But can you execute this code on a JS object? Do you think the following will work:
{"name": "John Doe", "age": 29}.map(console.log)
If you have ever written JS, your mind would immediately tell you that this is illegal. Why? Because the map
function is not designed to work with objects.
Programming on abstractions#
Clojure lets you run the same function, map
, on all native data structures. This is because map
and other sequence functions are not tied to a data type, like in JavaScript. Instead, they are tied to the abstract concept of sequence. All sequence functions can be called on any data structure that follows the rules of a sequence. For the sake of a mental model, sequences are lists.
The examples in this chapter are coded in the first-project.seq
namespace. We are not going to ask you its disk path but assume you know it!
To get the sequence representation of a data structure, we can use the seq
function.
(seq (list 1 2 3)) ;; => (1 2 3)
(seq [1 2 3]) ;; => (1 2 3)
(seq {:name "John Doe" :age 29}) ;; => ([:name "John Doe"] [:age 29])
(seq "Hello World") ;; => ("H" "e" "l" "l" "o" " " "W" "o" "r" "l" "d")
(seq :keyword) ;; => #object[Error Error: :keyword is not ISeqable]
The seq
function converts a data structure into sequence form, if possible. In cases where the input does not implement the ISeq
interface, an error is raised. Lists are not converted, vectors and strings are converted to lists, and maps are converted to a list of vectors.
Clojure sequence operations#
Clojure is excellent for slicing and dicing data. One of the reasons behind this is its rich set of sequence operations.
These are a subset of the operations that we may encounter.
map#
The map
function takes a sequence and transforms it by applying a function to each element. Its signature is: (map fn-to-apply & sequences)
. Notice how we used &
in the signature. If you recall from the chapter on Function definitions
, this means that map
is variadic. We'll get to that in a moment!
Let's try a simple map by inc
rementing all numbers from 0 to 9:
(range 10) ;; => (0 1 2 3 4 5 6 7 8 9)
(map inc (range 10)) ;; => (1 2 3 4 5 6 7 8 9 10)
The inc
function is a part of the Clojure core, but what if you want to do something else, say square all the numbers? Simple! You can define your own square
function and pass it to map
:
(defn square [x]
(* x x))
(map square (range 10)) ;; => (0 1 4 9 16 25 36 49 64 81)
We can make our expression terser by using an anonymous function instead of a namespaced definition:
(map (fn [x]
(* x x))
(range 10)) ;; => (0 1 4 9 16 25 36 49 64 81)
We can even go a step further by using the shorthand syntax for anonymous functions:
(map #(* % %) (range 10)) ;; => (0 1 4 9 16 25 36 49 64 81)
When I was a beginner, my team members guided me with terse forms of almost every function I wrote. I made it a personal rule that if a function feels verbose, there will be a better way to write it.
The map
function behaves slightly differently for Hash-Maps. The function being applied receives a vector as an input where the first element is the key and second is the value. The arguments to the function can be destructured:
(map (fn [[_key value]]
value)
{:name "John Doe" :age 29 :active? true :banned? false})
;; => ("John Doe" 29 true false)
Notice the use of _
prefix for the key argument, denoting that we don't really care about it. The use of _
is not a rule, only a convention.
In multi-threaded environments like the JVM, we can replace map
with pmap
, which is like map
but runs in parallel, leading to better performance.
map on multiple sequences#
The map
function is variadic - ie it can run on multiple sequences simultaneously:
(map + (range 10) (range 10)) ;; => (0 2 4 6 8 10 12 14 16 18)
In cases where multiple sequences are passed, the function is applied to the nth element of each sequence collectively.
In the example above, we passed two equal-sized sequences of integers from 0 to 9. The function +
was applied to the first element of both sequences to get the first element of the resulting sequence:
(apply + [0 0])
;; or
(+ 0 0)
Then it is applied again to the second element of both sequences, and so on. This leads to a question - what happens when sequences are of unequal length? A benefit of the REPL is that you can just evaluate code inline and check for yourself! You'll find out that the longer sequence is truncated at the end:
(map + (range 10) (range 5)) ;; => (0 2 4 6 8)
filter#
The filter
function removes elements of a sequence that don't match the defined condition. Its signature is: (filter predicate-fn sequence)
. The predicate function receives arguments in the same way as the map
function, ie a single element for lists/vectors and a two-element vector for Hash-Maps:
(filter even? (range 10)) ;; => (0 2 4 6 8))
(filter #(> % 4) (range 10)) ;; => (5 6 7 8 9)
reduce#
The reduce
function is the OG sequence operation. Its signature is: (reduce f init-val sequence)
or (reduce f sequence)
. The initial value of the accumulator is assumed to be nil
if not supplied. The input f
is a function that accepts two arguments - an accumulator and the next element:
(reduce + (range 10)) ;; => 45
In the simple example above, we reduce a list of integers from 0 to 9. We didn't pass an accumulator start value so it will be nil
.
In the first iteration, the function
+
is applied tonil
(the accumulator's initial value) and the first element of the list (0).(+ nil 0)
is 0 so our new accumulator becomes 0.In the next pass, the function
+
is applied to the last accumulator (0) and the next element (1). The resulting value (1) becomes the next accumulator.This process is continued until the sequence is exhausted.
All destructuring concepts we have studied so far are valid in conjunction with reduce
(and all other sequence operations). Combining concepts makes our expressions terse. For example, you can find the average karma points of a list of users like so:
(def karmas [{:id 1 :karma 40}
{:id 2 :karma 7}
{:id 3 :karma 80}])
This page is a preview of Tinycanva: Clojure for React Developers