Groovy from the ground up

Sorting, grouping and a report

The last two closure shapes — a comparator that decides order and groupBy that buckets a table — brought together in a report built from records.

Part 12 of 12

Advanced22 min readBack to Courses

A comparator decides the order

list.sort() puts strings in alphabetical order. For anything else you pass a block with two parameters: it is handed a pair of items and answers which comes first, using <=> — the spaceship, which answers -1, 0 or 1. sort { a, b -> a.name <=> b.name } is alphabetical by name, and sort { a, b -> b.hours <=> a.hours } is biggest first, because the sides are swapped.

Two things to keep in mind. The block is called with pairs, not with the whole list, so it must be a quick, honest comparison. And sort changes the list it is called on; sort(false) { ... } answers a new one and leaves the original alone. The spaceship answers an integer rather than a boolean because a sort needs three answers — less, equal, greater — and a comparator that only ever says -1 or 1 cannot recognise a tie for the next key to break.

groupBy makes buckets

rows.groupBy { it.team } answers a map: each distinct answer from the block becomes a key, and the values are lists of the rows that answered it. One line turns a flat table into teams, months, categories — whatever the block returns.

From there collect reduces each bucket to one row: { team, members -> [team: team, people: members.size(), hours: members.sum { it.hours }] }. A map with sizes and totals is the summary table, and sum with a block is how the column is added up without writing the loop. The block runs once per row and its answer becomes the key, so returning a list or a map from it keys the result by that whole value — occasionally useful, and easy to do by accident.

A report is a header, a rule, and one line per row

Printed output is a table people read, so it needs a header, a separator and columns that line up. sprintf('%-9s %6s', [label, value]) pads the text to the left and the number to the right, and the same pattern repeated for every row is what makes the columns straight. A rule of 23 dashes has to match the width of the pattern above it, so when a column changes, the pattern and the rule change together.

The whole capstone is four moves you already know: findAll to keep the rows that matter, sort with a comparator to order them, groupBy plus collect to summarise, and sprintf to print. The interesting part is not the syntax any more — it is deciding which four moves the question needs.

Sort both ways, then group

def people = [
    [name: 'Ada', team: 'core', hours: 12],
    [name: 'Grace', team: 'web', hours: 7],
    [name: 'Linus', team: 'core', hours: 9],
]

println people.sort { a, b -> a.name <=> b.name }.collect { it.name }
println people.sort { a, b -> b.hours <=> a.hours }.collect { it.name }

def teams = people.groupBy { it.team }
println teams.keySet()
println teams.collect { team, members -> team + '=' + members.size() }

println people.sum { it.hours }
println people.max { it.hours }.name

Two forms of sort

sort accepts either a one-parameter closure or a two-parameter one. With one parameter, the closure is a key extractor: rows.sort { it.hours } orders by that value. With two, it is a comparator: the block is handed a pair and answers negative, zero or positive, usually via <=>.

The comparator form is what you need for a tie-break. (a.team <=> b.team) ?: (b.hours <=> a.hours) orders by team, and when the teams are equal the Elvis falls through to hours, biggest first. The sort is stable, so items that still compare equal keep the order they arrived in.

Group, reduce, then order

groupBy answers a map whose values are lists, and a map keeps insertion order — the order in which keys were first seen. That is convenient, but it is not sorted, so a report that needs alphabetical or biggest-first teams sorts the summary rows afterwards.

collect on that map takes two parameters, key and value, and answers one row per bucket. From there the report is data: sort it, format it, print it. Separating the summary from the printing is what lets you change the layout without touching the arithmetic.

Three report mistakes: a bare sort changes the list in place, so use sort(false) when the caller's list must survive; groupBy keeps first-seen order rather than sorted order, so sort the summary explicitly; and [].sum() answers null, not 0, so give sum a seed when an empty group is possible.

Try these next

  • Sort the records by hours descending, then by name ascending as a tie-break.
  • Group by team and print only the teams with more than one person.
  • Change the report column widths and keep the header and rule aligned.

A team report from five records

Run it and check the totals by hand. Then add a sixth record of your own, and change the report to sort the teams by name instead of by hours.

Output
Click "Run" to execute your code

A sales report by region

Run it and check the grand total against the five records. Then add a sixth sale in the east, and change the summary sort to order by region name instead of total.

Output
Click "Run" to execute your code

Order the summary rows after grouping, never before: groupBy answers a fresh map, so it is the buckets you want to sort, not the rows that went into them.

Check what you learned

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

Part 1 of 5

What does the block in sort { a, b -> b.hours <=> a.hours } decide?