Lesson 6. Comparable and Comparator
Mission link: Ordering is where a contract violation stops being theoretical: an inconsistent comparator makes a TreeSet drop elements and makes sort throw, and both failures name something other than the comparator.
Primary source: Comparator, Java SE 25 API
Prerequisites: Lesson 3, Lesson 5
Warm-up
- ▢ What is the difference between
Collections.unmodifiableList(source)andList.copyOf(source)?
Check
The first is a view: later mutations of source show through. The second is a snapshot and is unaffected.
- ▢ Why must equal objects have equal hash codes?
Check
A hash collection locates a key by its hash first. Equal keys hashing differently land in different buckets, so they are never compared and the lookup misses.
Know this
There are two ways to order things, and they answer different questions.
Comparable is implemented by the type itself and defines its natural ordering. String, the numeric wrappers and LocalDate all have one.
Comparator is a separate object that imposes an ordering from outside. Use it when the type has no natural order, when you need a second order, or when the type is not yours to change.
Both return a negative number, zero, or a positive number. Only the sign matters.
records.sort(Comparator.comparing(Order::createdAt));
records.sort(Comparator.comparing(Order::customer)
.thenComparing(Order::createdAt)
.reversed());
comparing, thenComparing, reversed, nullsFirst and nullsLast compose, which is how a multi-key ordering stays readable. comparingInt, comparingLong and comparingDouble avoid boxing in the key extractor.
The contract
For any x, y, z:
- Antisymmetric in sign:
sgn(compare(x, y)) == -sgn(compare(y, x)). - Transitive: if
compare(x, y) > 0andcompare(y, z) > 0, thencompare(x, z) > 0. - Consistent on ties: if
compare(x, y) == 0, thenxandycompare the same way against everyz. - Deterministic: the same pair gives the same answer while nothing relevant changes.
And a fifth, which is strongly recommended rather than required: consistency with equals, meaning compare(x, y) == 0 exactly when x.equals(y).
Why that last one matters more than "recommended" suggests
A sorted collection uses the ordering and never calls equals:
record Task(String name, int priority) {}
Set<Task> set = new TreeSet<>(Comparator.comparingInt(Task::priority));
set.add(new Task("write", 1));
set.add(new Task("review", 1));
System.out.println(set.size()); // 1
Two answers exist, and only one of them goes anywhere. The equality answer is drawn with nothing leaving it because nothing consults it.
Two tasks that are not equals compared as zero, so the TreeSet treated the second as a duplicate and discarded it. Nothing was thrown, and size() is the only evidence.
The fix is to make the ordering a total order over distinct elements, by adding a tie-breaker that ends in something unique:
Comparator.comparingInt(Task::priority).thenComparing(Task::name)
TreeMap behaves the same way with keys, and binarySearch gives meaningless results on a list sorted by a different comparator than the one it is passed.
The subtraction bug
Comparator<Item> byId = (a, b) -> a.id() - b.id(); // BROKEN
Comparator<Item> ok = Comparator.comparingInt(Item::id);
Subtraction overflows. With a.id() at Integer.MIN_VALUE and b.id() positive, the difference wraps to a positive number and the comparator claims the smaller value is larger. It also breaks transitivity, which is what actually corrupts a sort.
Use Integer.compare, Long.compare, Double.compare, or the comparingInt family. Never subtract. For double, Double.compare also handles NaN and the two zeros consistently, which a subtraction cannot.
What a broken comparator does to sort
List.sort and Collections.sort use a merge sort that detects some contract violations and throws:
java.lang.IllegalArgumentException: Comparison method violates its general contract!
That message names the sort, not the comparator, and it appears only for some violations and some inputs, which is why it usually arrives long after the comparator was written.
Two ordering properties worth knowing while you are here: sorting a List or an object array is stable, so equal elements keep their relative order, and sorting a primitive array is not, because a different algorithm is used and stability is unobservable for primitives anyway.
Practice
-
▢ Predict the size, and explain it.
record Task(String name, int priority) {} Set<Task> set = new TreeSet<>(Comparator.comparingInt(Task::priority)); set.add(new Task("a", 1)); set.add(new Task("b", 1)); set.add(new Task("c", 2)); System.out.println(set.size());
Check
2.
TreeSet decides membership with the comparator, so "b" compared as zero against "a" and was rejected as a duplicate. The two records are not equals, and nothing ever asked them.
Add .thenComparing(Task::name) and the size is 3.
-
▢ This comparator passes its tests. Name the input that breaks it and the property it violates.
Comparator<Item> byId = (a, b) -> a.id() - b.id();
Hint
The tests almost certainly use small positive ids. Ask what the subtraction produces near the extremes of int.
Check
Any pair whose difference overflows int, for example a.id() == Integer.MIN_VALUE and b.id() == 1: the difference wraps to a large positive number, so the comparator reports that MIN_VALUE is greater than 1.
It violates antisymmetry in sign and transitivity. Transitivity is the damaging one, because it is what a sort relies on, and the visible symptom is either a wrongly ordered result or IllegalArgumentException: Comparison method violates its general contract!.
Comparator.comparingInt(Item::id) is the fix.
-
▢ Which comparator is safe to hand to a
TreeSet<Person>wherePersonisrecord Person(String last, String first)?- a)
Comparator.comparing(Person::last) - b)
Comparator.comparing(Person::last).thenComparing(Person::first) - c)
(a, b) -> a.last().length() - b.last().length() - d)
Comparator.comparing(Person::last).reversed()
- a)
Check
b) only.
Options a and d order by surname alone, so two people sharing a surname compare as zero and one is dropped. Option c compares lengths, which collapses every equal-length surname together and also subtracts, so it carries the overflow bug for good measure.
Only b is a total order over distinct Person values, since together the two components identify the record.
- ▢ You need to sort orders by customer name, then by date descending, with orders that have no date last. Write it.
Check
orders.sort(Comparator.comparing(Order::customer)
.thenComparing(Order::createdAt,
Comparator.nullsLast(Comparator.reverseOrder())));
The two-argument thenComparing takes a key extractor and a comparator for that key, which is what lets the null handling and the reversal apply to the date alone. Writing .reversed() at the end instead would reverse the customer ordering too, which is the most common mistake in composed comparators.
- ▢ A class implements
Comparablewith an ordering that is deliberately inconsistent withequals, and a reviewer says that is acceptable. When are they right, and what must the class do about it?
Check
They can be right, since the consistency rule is a recommendation and BigDecimal is the standard example: new BigDecimal("1.0") and new BigDecimal("1.00") compare as zero and are not equals, because scale is part of the value and not part of the numeric comparison.
What the class must do is say so in its documentation, in the sentence the JDK uses: "this class has a natural ordering that is inconsistent with equals". Then callers know that sorted collections will treat some distinct instances as duplicates, and that a TreeSet of these is a different collection from a HashSet of them.
Real-world reps
- [ ] Build the
TreeSetfrom practice 1, watch the size come out at 2, then add the tie-breaker and watch it become 3. - [ ] Write the subtraction comparator, sort a list containing
Integer.MIN_VALUE, and see what comes out. Then swap incomparingInt. - [ ] Tomorrow: find a
Comparatorin code you know. Check whether it is a total order over distinct elements, and whether anything sorted by it ever lands in aTreeSetorTreeMap.
Going further
Comparator: the contract, and every composing factory methodComparable: natural ordering, and the wording for documenting an inconsistent oneArrays: which sorts are stable, stated per overload- Equality, hashing and ordering: the reference sheet for this stage
- Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.