Comparators and Sorting in Groovy
This blog post is inspired by the Comparator examples in the excellent Collections Refuelled talk and blog by Stuart Marks. That blog from 2017 highlights improvements in the Java collections library in Java 8 and 9 including numerous Comparator improvements. It is now 5 years old but still highly recommended for anyone using the Java collections library.
Rather than have a Student
class as per the original blog example, we'll have a Celebrity
class (and later record) which has the same first
and last
name fields and an additional age
field. We'll compare initially by last
name with nulls before non-nulls and then by first
name and lastly by age
.
As with the original blog, we'll cater for nulls, e.g. a celebrity known by a single name.
The Java comparator story recap
Our Celebrity
class if we wrote it in Java would look something like:
public class Celebrity { // Java
private String firstName;
private String lastName;
private int age;
public Celebrity(String firstName, int age) {
this(firstName, null, age);
}
public Celebrity(String firstName, String lastName, int age) {
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
@Override
public String toString() {
return "Celebrity{" +
"firstName='" + firstName +
(lastName == null ? "" : "', lastName='" + lastName) +
"', age=" + age +
'}';
}
}
It would look much nicer as a Java record (JDK16+) but we'll keep with the spirit of the original blog example for now. This is fairly standard boilerplate and in fact was mostly generated by IntelliJ IDEA. The only slightly interesting aspect is that we tweaked the toString
method to not display null last names.
On JDK 8 with the old-style comparator coding, a main application which created and sorted some celebrities might look like this:
import java.util.ArrayList; // Java
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Celebrity> celebrities = new ArrayList<>();
celebrities.add(new Celebrity("Cher", "Wang", 63));
celebrities.add(new Celebrity("Cher", "Lloyd", 28));
celebrities.add(new Celebrity("Alex", "Lloyd", 47));
celebrities.add(new Celebrity("Alex", "Lloyd", 37));
celebrities.add(new Celebrity("Cher", 76));
Collections.sort(celebrities, (c1, c2) -> {
String f1 = c1.getLastName();
String f2 = c2.getLastName();
int r1;
if (f1 == null) {
r1 = f2 == null ? 0 : -1;
} else {
r1 = f2 == null ? 1 : f1.compareTo(f2);
}
if (r1 != 0) {
return r1;
}
int r2 = c1.getFirstName().compareTo(c2.getFirstName());
if (r2 != 0) {
return r2;
}
return Integer.compare(c1.getAge(), c2.getAge());
});
System.out.println("Celebrities:");
celebrities.forEach(System.out::println);
}
}
When we run this example, the output looks like this:
Celebrities: Celebrity{firstName='Cher', age=76} Celebrity{firstName='Alex', lastName='Lloyd', age=37} Celebrity{firstName='Alex', lastName='Lloyd', age=47} Celebrity{firstName='Cher', lastName='Lloyd', age=28} Celebrity{firstName='Cher', lastName='Wang', age=63}
As pointed out in the original blog, this code is rather tedious and error-prone and can be improved greatly with comparator improvements in JDK8:
import java.util.Arrays; // Java
import java.util.List;
import static java.util.Comparator.comparing;
import static java.util.Comparator.naturalOrder;
import static java.util.Comparator.nullsFirst;
public class Main {
public static void main(String[] args) {
List<Celebrity> celebrities = Arrays.asList(
new Celebrity("Cher", "Wang", 63),
new Celebrity("Cher", "Lloyd", 28),
new Celebrity("Alex", "Lloyd", 47),
new Celebrity("Alex", "Lloyd", 37),
new Celebrity("Cher", 76));
celebrities.sort(comparing(Celebrity::getLastName, nullsFirst(naturalOrder())).
thenComparing(Celebrity::getFirstName).thenComparing(Celebrity::getAge));
System.out.println("Celebrities:");
celebrities.forEach(System.out::println);
}
}
The original blog also points out the convenience factory methods from JDK9 for list creation which you might be tempted to consider here. For our case, we will be sorting in place, so the immutable lists returned by those methods won't help us here but Arrays.asList
isn't much longer than List.of
and works well for this example.
As well as being much shorter, the comparing
and thenComparing
methods and built-in comparators like nullsFirst
and naturalOrdering
allow for far simpler composability. The sort within array list is also more efficient than the sort that would have been used with the Collections.sort
method on earlier JDKs. The output when running the example is the same as previously.
The Groovy comparator story
At about the same time that Java was evolving its comparator story Groovy added some complementary features to tackle many of the same problems. We'll look at some of those features and also how the JDK improvements we saw above features can be used instead if preferred.
First off, let's create a Groovy Celebrity
record:
@Sortable(includes = 'last,first,age')
@ToString(ignoreNulls = true, includeNames = true)
record Celebrity(String first, String last = null, int age) {}
And create our list of celebrities:
var celebrities = [
new Celebrity("Cher", "Wang", 63),
new Celebrity("Cher", "Lloyd", 28),
new Celebrity("Alex", "Lloyd", 47),
new Celebrity("Alex", "Lloyd", 37),
new Celebrity(first: "Cher", age: 76)
]
The record definition is nice and concise. It would look good in recent Java versions too. A nice aspect of the Groovy solution is that it will provide emulated records on earlier JDKs and it also has some nice declarative transforms to tweak the record definition. We could leave off the @ToString
annotation and we'd get a standard record-style toString
. Or we could add a toString
method to our record definition similar to what was done in the Java example. Using @ToString
allows us to remove null last names from the toString
in a declarative way. We'll cover the @Sortable
annotation a little later.
First off, Groovy's spaceship operator <=>
allows us to write a nice compact version of the "tedious" code in the first Java version. It looks like this:
celebrities.sort { c1, c2 ->
c1.last <=> c2.last ?: c1.first <=> c2.first ?: c1.age <=> c2.age
}
println 'Celebrities:\n' + celebrities.join('\n')
And the output looks like this:
Celebrities: Celebrity(first:Cher, age:76) Celebrity(first:Alex, last:Lloyd, age:37) Celebrity(first:Alex, last:Lloyd, age:47) Celebrity(first:Cher, last:Lloyd, age:28) Celebrity(first:Cher, last:Wang, age:63)
We'd have a tiny bit more work to do if we wanted nulls last but the defaults work well for the example at hand.
We can alternatively, make use of the "new in JDK8" methods mentioned earlier:
celebrities.sort(comparing(Celebrity::last, nullsFirst(naturalOrder())).
thenComparing(c -> c.first).thenComparing(c -> c.age))
But this is where we should come back and further explain the @Sortable
annotation. That annotation is associated with an Abstract Syntax Tree (AST) transformation, or just transform for short, which provides us with an automatic compareTo
method that takes into account the record's properties (and likewise if it was a class). Since we provided an includes
annotation attribute and provided a list of property names, the order of those names determines the priority of the properties used in the comparator. We could equally include just some of the names in that list or alternatively provide an excludes
annotation attribute and just mention that properties we don't want included.
It also adds Comparable
to the list of implemented interfaces for our record. So, what does all this mean? It means we can just write:
celebrities.sort()
The transform associated with the @Sortable
annotation also provides some additional comparators for us. To sort by age, we can use one of those comparators:
celebrities.sort(Celebrity.comparatorByAge())
Which gives this output:
Celebrities: Celebrity(first:Cher, last:Lloyd, age:28) Celebrity(first:Alex, last:Lloyd, age:37) Celebrity(first:Alex, last:Lloyd, age:47) Celebrity(first:Cher, last:Wang, age:63) Celebrity(first:Cher, age:76)
In addition to the sort
method, Groovy provides a toSorted
method which sorts a copy of the list, leaving the original unchanged. So, to create a list sorted by first name we can use this code:
var celebritiesByFirst = celebrities.toSorted(Celebrity.comparatorByFirst())
Which if output in a similar way to previous examples gives:
Celebrities: Celebrity(first:Alex, last:Lloyd, age:37) Celebrity(first:Alex, last:Lloyd, age:47) Celebrity(first:Cher, last:Lloyd, age:28) Celebrity(first:Cher, last:Wang, age:63) Celebrity(first:Cher, age:76)
If you are a fan of functional style programming, you might consider using List.of
to define the original list and then only toSorted
method calls in further processing.
Mixing in some language integrated queries
Groovy also has a GQuery (aka GINQ) capability which provides a SQL inspired DSL for working with collections. We can use GQueries to examine and order our collection. Here is an example:
println GQ {
from c in celebrities
select c.first, c.last, c.age
}
Which has this output:
+-------+-------+-----+ | first | last | age | +-------+-------+-----+ | Cher | | 76 | | Alex | Lloyd | 37 | | Alex | Lloyd | 47 | | Cher | Lloyd | 28 | | Cher | Wang | 63 | +-------+-------+-----+
In this case, it's using the natural ordering which @Sortable
gives us.
Or we can sort by age:
println GQ {
from c in celebrities
orderby c.age
select c.first, c.last, c.age
}
Which has this output:
+-------+-------+-----+ | first | last | age | +-------+-------+-----+ | Cher | Lloyd | 28 | | Alex | Lloyd | 37 | | Alex | Lloyd | 47 | | Cher | Wang | 63 | | Cher | | 76 | +-------+-------+-----+
Or we can sort by last name descending and then age:
println GQ {
from c in celebrities
orderby c.last in desc, c.age
select c.first, c.last, c.age
}
Which has this output:
+-------+-------+-----+ | first | last | age | +-------+-------+-----+ | Cher | Wang | 63 | | Cher | Lloyd | 28 | | Alex | Lloyd | 37 | | Alex | Lloyd | 47 | | Cher | | 76 | +-------+-------+-----+
Conclusion
We have seen a little example of using comparators in Groovy. All the great JDK capabilities are available as well as the spaceship operator, the sort
and toSorted
methods, and the @Sortable
AST transformation.