Skip to content
Part IA Michaelmas Term

Comparable versus Comparator

Two Interfaces, One Purpose

Java provides two separate interfaces for defining ordering:

Comparable<T>

Implemented by the class itself. Defines the class’s single “natural ordering”.

public interface Comparable<T> {
    int compareTo(T o);
}

The method returns:

  • A negative integer if this is less than o
  • Zero if this is equal to o
  • A positive integer if this is greater than o
class Person implements Comparable<Person> {
    private String name;
    private int age;

    Person(String name, int age) { this.name = name; this.age = age; }

    @Override
    public int compareTo(Person other) {
        return name.compareTo(other.name);  // natural order: by name
    }
}

Usage: Collections.sort(list) — no comparator needed; the list elements’ natural ordering is used.

Comparator<T>

A separate object that implements comparison logic. Allows any number of orderings external to the class.

public interface Comparator<T> {
    int compare(T a, T b);
}
class PersonAgeComparator implements Comparator<Person> {
    @Override
    public int compare(Person a, Person b) {
        return Integer.compare(a.getAge(), b.getAge());
    }
}

Usage: list.sort(new PersonAgeComparator()) or Collections.sort(list, new PersonAgeComparator()).

When to Use Which

Use ComparableUse Comparator
The class has one obvious natural orderingYou need multiple orderings (by name, by age, by postcode)
You control the class’s source codeYou cannot modify the class (library class, third-party)
The ordering is intrinsic to the class’s identityThe ordering is context-dependent

Examples of natural ordering:

  • Integer by numeric value
  • String lexicographically (dictionary order)
  • LocalDate chronologically
  • BigDecimal by numeric value

Comparator Static Factory Methods

Since Java 8, Comparator provides static methods for building comparators declaratively, avoiding verbose anonymous classes:

Comparator<Person> byName = Comparator.comparing(Person::getName);
Comparator<Person> byAge = Comparator.comparingInt(Person::getAge);

list.sort(byName);                        // sort by name
list.sort(byAge);                         // sort by age
list.sort(byName.thenComparing(byAge));   // sort by name, then by age for ties
list.sort(byAge.reversed());              // sort by age descending

Chaining comparators:

Comparator<Person> byNameThenAge = Comparator
    .comparing(Person::getLastName)
    .thenComparing(Person::getFirstName)
    .thenComparingInt(Person::getAge);

Null handling:

Comparator<String> nullSafe = Comparator.nullsFirst(String::compareTo);
// null values come before all non-null values

Comparator<String> nullSafeLast = Comparator.nullsLast(String::compareTo);
// null values come after all non-null values

The Consistency Rule

The compareTo() / compare() results should be consistent with equals(): a.equals(b)    a.compareTo(b)=0a.\text{equals}(b) \implies a.\text{compareTo}(b) = 0

This is a recommendation, not enforced by the compiler. Violating it causes subtle bugs:

class BadDecimal {
    BigDecimal value;

    @Override
    public boolean equals(Object o) {
        return o instanceof BadDecimal
            && value.compareTo(((BadDecimal) o).value) == 0;
    }

    @Override
    public int compareTo(BadDecimal o) {
        return value.compareTo(o.value);
    }
}

BigDecimal is the canonical example: new BigDecimal("1.0") and new BigDecimal("1.00") are .equals()-unequal (different precision) but .compareTo()-equal (same numeric value). If you use a TreeSet (which relies on compareTo), only one of them can be stored — which one survives depends on insertion order. A HashSet (which relies on equals) stores both.

The SortedSet and SortedMap contracts explicitly state that ordering must be consistent with equals unless the implementation’s documentation says otherwise.

Comparison Contract

Both compareTo() and compare() must satisfy:

  1. Sign consistency: sign(x.compareTo(y)) == -sign(y.compareTo(x)) — antisymmetry.
  2. Transitivity: if x.compareTo(y) > 0 and y.compareTo(z) > 0, then x.compareTo(z) > 0.
  3. Equivalence: if x.compareTo(y) == 0, then sign(x.compareTo(z)) == sign(y.compareTo(z)) for all z.
  4. Recommended but not required: x.compareTo(y) == 0 if and only if x.equals(y).

TreeSet and TreeMap Behaviour

TreeSet and TreeMap use compareTo() (or the supplied Comparator) for ALL ordering — they never call equals():

Set<BigDecimal> set = new TreeSet<>();
set.add(new BigDecimal("1.0"));
set.add(new BigDecimal("1.00"));
System.out.println(set.size());  // 1 — compareTo considers them equal

This is correct behaviour per the SortedSet contract. If your comparator is inconsistent with equals, the set may not obey the general Set contract (which is defined in terms of equals), but it obeys the SortedSet contract.

Implementing compareTo with Primitives

Use the static wrapper methods rather than subtraction (which can overflow):

@Override
public int compareTo(Person other) {
    int nameCmp = name.compareTo(other.name);
    if (nameCmp != 0) return nameCmp;

    return Integer.compare(age, other.age);  // safe — no overflow
}

Subtraction (return age - other.age) can overflow for large magnitude differences, producing incorrect sign results. Integer.compare(a, b) handles this correctly under all values.