Clojure from the ground up

Sorting, counting and grouping

Turning a pile of rows into a report: sort-by on a key, group-by into buckets, frequencies to count, and doseq to print one line at a time.

Part 11 of 12

Advanced20 min readBack to Courses

sort-by asks for the key

sort needs the values themselves to be comparable. sort-by takes a function first and sorts on whatever that function returns, so (sort-by :age people) sorts a vector of records youngest first. The key function can be anything, including a small expression that pulls one field out of each row. It is called once per row, and its answers are compared, so it should be cheap and should return values of one kind.

Sorted upward is the default. To go the other way, put a comparison in the middle: (sort-by :amount > orders) puts the biggest order first, because > is the comparison the sorter uses to decide which of two values comes later. The comparator takes two keys and answers true when the first should come before the second; > says the larger one goes first, which is why it reverses the usual order.

group-by and frequencies

group-by gathers rows into buckets: (group-by :city people) answers a map from each city to the vector of people who live there. frequencies is the cousin for plain values rather than rows, and it answers a map from each value to the number of times it appeared. Both leave the original rows alone and answer a new map, and both are the same reduce you met earlier: walk the rows, update the bucket for the key.

Both answer a map, and a map promises nothing about order, so sort before you print. (sort (keys m)) puts the keys in a fixed order, and (sort-by first m) sorts the pairs themselves by their key. A pair is a two-item vector, and first is the key, so sort-by first is the shortest way to say "in key order".

Printing one line at a time

doseq walks a sequence for its side effect: it runs the body once per value and answers nil. That is the right tool at the end of a report, where the lines are already built and each one just needs printing.

It can also take a pair apart on the way in: (doseq [[k v] pairs] ...) gives each key and each value its own name, which is exactly the shape a map hands you.

Ties need a second key

Sorting on one field leaves the order of equal values to the sorter. sort-by is stable, so equal rows keep the order they arrived in, which is fine when the input order is fixed and fragile when it is not.

When the output must be identical every run, add a tiebreaker: make the key function return a vector, such as (fn [row] [(:city row) (:who row)]), because vectors compare element by element. Two rows that share a city are then still ordered by name. A vector key compares slot by slot, so the first slot decides most rows and the second only breaks ties; that is exactly the behaviour a report wants.

A summary per bucket

A bucket from group-by is a vector of rows, so a summary per group is a second pass: (reduce + (map :amount (get by-city city))). The outer loop walks the sorted keys, and the inner pipeline reduces one bucket. The outer loop is about which group comes next; the inner one is about what that group is worth.

That two-level shape is how a report gets a line per group instead of a line per row. Keep the outer loop about names and the inner one about numbers, and each line is easy to check. If the inner pipeline grows past two steps, give it a name; the report then reads as a list of groups and a function that summarises one.

Three ways to summarise rows

(def people [{:who "Ada" :age 36}
             {:who "Alan" :age 41}
             {:who "Grace" :age 45}])

; sort-by takes the key to sort on.
(doseq [p (sort-by :age people)]
  (println (str (:who p) " is " (:age p))))

; Oldest first: the comparison says which way.
(doseq [p (sort-by :age > people)]
  (println (:who p)))

; frequencies counts plain values.
(def words ["the" "a" "the" "and"])
(doseq [[w n] (sort-by first (frequencies words))]
  (println (str w ": " n)))

Rows turned into a summary

Run it and read the seven lines. Then add a fourth person, group the people by city, and print each city with the number of people who live in it.

Output
Click "Run" to execute your code

A map answers no question about order, and printing one can come out in a different order from the way it was built. Sort the keys first, always: the output is then the same on every run, which is what a report has to be.

Three report bugs. Sorting a map’s keys but not the values inside a bucket leaves the inner order to chance. Comparing values of different types — a number with a string — throws rather than choosing an order, so clean the column before sorting. And forgetting that doseq answers nil means a pipeline that ends in doseq cannot be reused as data; build the lines first if anything else needs them.

Try these next

  • Sort a vector of maps by one field, then by the same field in reverse.
  • Group a vector of words by their first letter and print the count of each bucket.
  • Use frequencies on a vector with repeats, then sort the pairs before printing.

A shelf report by year and author

Run it and read the fourteen lines. Then add a fifth book and print how many books fall in each decade, using `#(quot (:year %) 10)` as the key function.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does (sort-by :age people) sort on?