In this lesson, we’re going to learn about the Comparable
and Comparator
interfaces in Java.
These interfaces help us to, well, compare pairs of objects to determine and order between them.
The sorting problem
To begin with, let’s consider as a motivating example the problem of sorting a list of objects.
You’ve probably studied a number of sorting algorithms like insertion sort, merge sort, quicksort, etc. They all work slightly differently, but ultimately the outcome is the same: given a collection of data, they each give you back that same collection with the items arranged in order.
The “in order” in the sentence above is actually doing a lot of work.
Every sort function needs to, at some point, do a pairwise comparison of objects in the collection that’s being sorted. That is, regardless of how the sorting algorithm works, at some point two items in the collection need to be compared to each other to determine how they should be ordered relative to each other.
Consider the following sort
function that implements insertion sort.1
How we perform that pairwise comparison is going to depend on what is being sorted.
public static void sort(Album[] arr) {
for (int i = 1; i < arr.length; i++) {
for (int j = i; j > 0; j--) {
if (______________________) { // Compare arr[j] and arr[j - 1]
// Swap arr[j] and arr[j-1]
Album temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
} else {
break;
}
}
}
}
The blank in the if
statement in the code above is where the comparison should take place.
That is, we need to check if arr[j]
is “less than” arr[j - 1]
, whatever that means for the particular data being sorted.
How should that comparison take place?
When we think about sorting a list of numbers, the comparison is clear: we often mean to order the numbers in ascending order, i.e., smallest-to-largest.
That is, for any pair of numbers, we know the smaller one should come before the larger one.
If we needed to order them in descending order (largest-to-smallest), that’s still easy—given the numbers 8
and 5
, we can easily say what their relative order should be using operators like >
, <
, and ==
.
Suppose, instead, you have a custom object, based on a class you have just created.
For example, an Album
object that contains a number of fields (or instance variables):
String title
String artist
int year
double price
How should a list of Album
s be sorted? By title
, artist
, year
? Some combination of fields?
We cannot use comparison operators like >
or <
on our Album
object because those operators are reserved for numerical (and char
) types in Java.
At the same time, we don’t want to have to re-write our sort
function for our Album
class, because pretty soon we will have an Artist
class and then a Song
class, and we definitely don’t want to keep re-writing a sorting algorithm when the only thing that’s changing is the type of data that’s being sorted (and therefore, the pairwise comparison).
So, how should we compare Album
s? We can write custom code to compare any two Album
s using whatever criterion we think is a good “natural ordering” for Album
s.
Observe that, no matter how we decide to order Album
s, the rest of that sort
function will stay the same.
The only part of the function that needs to change is the comparison in the if
statement.
Can we abstract out that comparison so that the sort
doesn’t need to know how it’s being done?
Comparable
That’s where the Comparable
interface comes in.
It is used when we create a class and we want to define a “natural” ordering between any pair of objects created from that class.
In essence, the Comparable
interface’s job is to let us define >
, <
, and ==
relationships for classes that we create.
The Comparable
interface contains a single abstract method that must be implemented by implementing subclasses:
int compareTo(T other)
It defines how this
object compares to the other
object.
Two things are worth discussing about the method signature.
- The parameter
T other
: TheT
here is a placeholder type. TheComparable
interface doesn’t know what type of data is going to be compared, and doesn’t care. In ourAlbum
class, the signature would beint compareTo(Album other)
. - The
int
return type. The comparison is not a binary decision: there are three possible outcomes (<
,>
, or==
). So we cannot use aboolean
as the return type.
The “contract” for the compareTo
function is:
- If
this
is “less than”other
, i.e., it should come beforeother
in a sorted list, return a negative number. - If
this
is “greater than”other
, i.e., should come afterother
in a sorted list, return a positive number. - If
this
andother
are equal, return0
. In general, it’s recommended that ifthis.equals(other)
istrue
, thenthis.compareTo(other)
should return0
.
Consider the code below.
We have an Album
class that is declared to be Comparable
.
We are saying Album
objects are comparable to other Album
objects.
This means the Album
must define a compareTo
method.
In the example below, we are saying that Album
ordering is determined based on their title
s.
Notice that we are not ourselves writing a lexicographic comparison of this.title
and other.title
: title
is a String
, which itself implements the Comparable
interface.
We can use that.
public class Album implements Comparable<Album> {
private final String title;
private final String artist;
private final int year;
private double price;
// ... Assume we have written a constructor, getters, setters etc.
@Override
public int compareTo(Album other) {
return this.title.compareTo(other.title);
}
}
What does this get us?
Consider our sort
function example from above.
If, instead of using Album[]
as our parameter type, we used a Comparable[]
as our parameter type, we can now use the same sort
function for any data type, as long as that data type implements the Comparable
interface.
See the if
statement in the updated sort
function below.
public static void sort(Comparable[] arr) {
for (int i = 1; i < arr.length; i++) {
for (int j = i; j > 0; j--) {
if (arr[j].compareTo(arr[j - 1]) < 0) { // If arr[j] is "less than" arr[j - 1]
// Swap arr[j] and arr[j-1]
Album temp = arr[j];
arr[j] = arr[j - 1];
arr[j - 1] = temp;
} else {
break;
}
}
}
}
This is an example of using abstraction — we are ignoring or abstracting away the details of the specific object being sorted, and only focusing on the salient detail, i.e., the fact that it can be compared to other objects of its own type.
Because we can use compareTo
, we don’t need to know or care what specific type of object is stored in arr
.
And this is exactly what is done in sort functions already available in the Java standard library.
The Collections
class provides a number of helpful static functions; among them is Collections.sort
.
If you have a list of objects, and those objects are Comparable
, you can call Collections.sort
on that list to sort it according to the object’s “natural ordering”, i.e., according to its compareTo
method.
Note that Collections.sort
sorts the list in place, meaning it mutates the underlying list, instead of returning a new sorted list.
List<Album> albums = Arrays.asList(
new Album("Rubber Soul", "The Beatles", 1965, 18.99),
new Album("1989 (Taylor's Version)", "Taylor Swift", 2023, 18.99),
new Album("1989", "Taylor Swift", 2014, 18.99),
new Album("Leaving Eden", "The Carolina Chocolate Drops", 2012, 18.99)
);
// If Album does not implement Comparable, this line won't compile.
Collections.sort(albums);
for (Album current : albums) {
System.out.println(current);
}
The code above would print:
1989
1989 (Taylor's Version)
Leaving Eden
Rubber Soul
Suppose you were asked to handle tie-breakers. E.g., for albums with the same title, break ties by artist name.
How would you handle this in the compareTo
function?
Can you change Album
’s compareTo
to induce a reversed ordering, i.e., in descending order?
Comparator
The ComparABLE
interface is used to define a “natural ordering” for an object.
What exactly does that mean?
You should use Comparable
when there is an argument to made that there is an obvious way to compare two objects of a given type.
For example, the String
class in Java implements the Comparable
interface.
It defines what many would naturally expect when they compare two String
objects, say, for the purpose of sorting.
It compares String
s using their lexicographic ordering, i.e., their alphabetic order.
However, sometimes you need to order a collection of objects using something other than its natural order. Or you need to order a collection of objects that cannot be reasonably considered to have a “natural” ordering for all circumstances. These are cases in which you need to define, on an as-needed basis, a custom comparison between two objects.
That’s where the ComparATOR
interface comes in.
These two interfaces are annoyingly similarly named, I know.
Example
So, for example, suppose we need to compare albums by their price
, and not by their “natural” ordering based on title
.
The Comparator
interface defines one abstract method that must be implemented by subclasses:
public int compare(T o1, T o2)
This is very similar to the compareTo
method for the Comparable
. The only difference is now we take two parameters instead of one, because both items to be compared are being passed to the method.
That is, the “calling object” is not the one being compared, so this
is not really relevant here.
To compare Album
s by price
, we would create a new class that implements the Comparator
interface, and implement the required compare
function in that class.
public class AlbumPriceComparator implements Comparator<Album> {
public int compare(Album o1, Album o2) {
if (o1.getPrice() > o2.getPrice()) {
return 1; // Can return any positive integer
} else if (o1.getPrice() < o2.getPrice()) {
return -1; // Can return any negative integer
} else {
return 0;
}
}
}
This comparator object can then be used to impose “custom” orderings on Album
s.
How does this help us? The Collections.sort
function has an overloaded version that takes two parameters:
- A collection of objects
- A comparator to use for pairwise comparisons
If you use this version of the Collections.sort
function, you don’t need the objects being sorted to be ComparABLE
. This is because the second parameter, the ComparATOR
, knows how to compare those objects.
List<Album> albums = ...; // Same list as before
Comparator<Album> priceComp = new AlbumPriceComparator();
// Sort the albums in ascending order of price
// Doesn't matter here whether or not Album implements Comparable
Collections.sort(albums, priceComp);
In the next lesson…
We can also dynamically create Comparator
s on an as-needed basis.
Comparators are useful when you don’t know upfront how a collection of objects is going to be compared or sorted.
Continuing with the Album example, consider your music library in whatever application you use to manage and listen to your music. Chances are you’ve seen a “table view” that lists all the songs in your library, and you can click on the columns in that table to change how the songs are sorted. E.g., if you click on “Title” the songs will be sorted by title. If you click on “Artist” the order will change. If you click again, it’ll reverse it.
These are dynamic changes in the current sort order, i.e., they are happening while the program (the application, Spotify or whatever) is running.
Can we programmatically spin up new Comparator
s to support these changes in desired sort orders?
Doesn’t this seem like a lot of work to just write one compare
function?
All we really care about is that compare
function, but because we need to “pass” the compare
function to the sort
function, we went through the rigmarole of wrapping it in a class and creating an object.
In the next lesson, we’ll learn about using lambdas in Java to concisely create new Comparator
s.
Lambdas allow us to treat functions as values that can be stored and passed around, e.g., as parameters to other functions.
We’ll use that as a springboard to learn about lambdas and functional programming more generally.
-
From the OpenDSA chapter on Insertion Sort. ↩