Groovy Apache Groovy

Comparators and Sorting in Groovy

by paulk


Posted on Thursday July 21, 2022 at 03:51PM in Technology


2022-07-22 01_05_29-s-l300.webp (300×291).pngThis 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

JavaTransparent.pngOur 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

groovy.pngAt 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<Celebrity> 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.



No one has commented yet.
Comments are closed for this entry.