JAVA / COLLECTIONS
Comparable natural ordering versus Comparator rules
Decide when to implement Comparable, when to pass a Comparator, and how to compose multi-key rules without breaking the ordering contract.
What you will learn
- Implement Comparable when a type has one obvious order; use Comparators for the rest.
- Return only a sign from compare and compareTo; use Integer.compare, never a - b.
- Compose multi-key rules with comparing, thenComparing and a correctly scoped reversed().
- Tell apart compareTo == 0 from equals, and know which API consults which.
Understanding Comparable natural ordering versus Comparator rules
Comparable puts one ordering inside the type: compareTo(other) is an instance method, so the object answers the question about itself, and a class can give exactly one answer. Comparator moves the ordering outside into a separate object that holds a rule and is chosen where the sort happens, so a Track can be ordered by title in one place and by length in another without editing Track. The mental model worth keeping is that natural ordering is a property of the type, like a field, while a comparator is an argument you pass. Both return an int whose sign is the entire answer; the magnitude carries no meaning, which is why -17 and -1 say the same thing.
The contract is what makes sorting and binary search meaningful: the sign of a.compareTo(b) must be the opposite of b.compareTo(a), the relation must be transitive, and two elements that compare as 0 must compare the same way against every third element. That is why writing a.value - b.value is a bug rather than a shortcut. With large or negative values the subtraction overflows and wraps around, so a genuinely smaller object reports as greater and antisymmetry is lost. Java does not always tell you loudly: sometimes you just get a misordered list, and sometimes TimSort notices the inconsistency mid-merge and throws IllegalArgumentException: Comparison method violates its general contract!
Every ordering-sensitive API comes in two flavours, and the selection rule is simple: if a comparator was supplied it is used, otherwise the elements must be Comparable and compareTo is used. Collections.sort(list), Arrays.sort(Object[]) and list.sort(null) take the natural ordering, while the overloads that accept a comparator ignore compareTo completely, so natural order as a tie-break only happens if you chain thenComparing(Comparator.naturalOrder()) yourself. The composition helpers each return a new comparator and never mutate the receiver, which is exactly why reversed() applies to the whole chain built so far rather than to the last key alone. One asymmetry remains deliberate: compareTo returning 0 need not agree with equals, so a type may call two objects tied for ordering while equals calls them different.
placeholder
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
class Track implements Comparable<Track> {
final String title;
final int seconds;
Track(String title, int seconds) {
this.title = title;
this.seconds = seconds;
}
// The one ordering that belongs to the type: alphabetical by title.
@Override
public int compareTo(Track other) {
return title.compareTo(other.title);
}
@Override
public String toString() {
return title + "/" + seconds;
}
}
public class Main {
public static void main(String[] args) {
Track rain = new Track("Rain", 245);
Track ash = new Track("Ash", 198);
Track moss = new Track("Moss", 312);
List<Track> tracks = new ArrayList<>(List.of(rain, ash, moss));
tracks.sort(Comparator.naturalOrder());
System.out.println("natural " + tracks);
Comparator<Track> byLength = Comparator.comparingInt(t -> t.seconds);
tracks.sort(byLength);
System.out.println("byLength " + tracks);
tracks.sort(byLength.reversed());
System.out.println("longest " + tracks);
System.out.println("rain vs moss by compareTo " + Integer.signum(rain.compareTo(moss)));
System.out.println("rain vs moss by byLength " + Integer.signum(byLength.compare(rain, moss)));
}
}A type carries at most one natural ordering through compareTo, while a Comparator is a swappable ordering passed at the call site, and the comparator you pass replaces the natural ordering entirely.
Worked examples
Why a - b is not a comparator
Shows how subtraction-based comparison overflows and reports the wrong sign while still looking correct on small numbers.
import java.util.Comparator;
public class Main {
public static void main(String[] args) {
Comparator<Integer> subtracting = (a, b) -> a - b;
Comparator<Integer> safe = Comparator.naturalOrder();
int small = Integer.MIN_VALUE + 5;
int big = 10;
System.out.println("subtracting " + subtracting.compare(small, big));
System.out.println("safe " + safe.compare(small, big));
System.out.println("ordinary values " + subtracting.compare(3, 7));
}
}Example explained
Line 1small - big is -2147483653, which does not fit in an int, so it wraps to a positive value and the comparator claims the smaller number is greater.
Line 2Comparator.naturalOrder() delegates to Integer.compareTo, which decides by comparison rather than arithmetic and cannot overflow.
Line 3The last line is why this bug hides: for values close together the subtraction still has the correct sign, so small test data passes.
Line 4-4 and -1 are equally valid answers for the same question, because only the sign of the returned int is defined.
Two keys, and where reversed() applies
Demonstrates that reversed() flips whatever comparator it is attached to, so its position changes the result.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
record City(String country, String name, int millions) {
@Override
public String toString() {
return country + "-" + name + "-" + millions;
}
}
public class Main {
public static void main(String[] args) {
List<City> cities = new ArrayList<>(List.of(
new City("JP", "Osaka", 19),
new City("BR", "SaoPaulo", 22),
new City("JP", "Tokyo", 37),
new City("BR", "Rio", 13)));
Comparator<City> rule = Comparator.comparing(City::country)
.thenComparing(Comparator.comparingInt(City::millions).reversed());
cities.sort(rule);
System.out.println(cities);
cities.sort(rule.reversed());
System.out.println(cities);
}
}Example explained
Line 1comparing(City::country) is the primary key, and thenComparing is consulted only when that comparison returns 0.
Line 2reversed() sits on comparingInt(City::millions), so only the size key is flipped and countries still ascend: BR before JP.
Line 3rule.reversed() wraps the finished two-key comparator, so both keys flip at once: countries descend and sizes ascend.
Line 4Neither call changes rule itself; every helper returns a new comparator object.
When compareTo 0 and equals disagree
Uses BigDecimal to show a standard library type whose natural ordering is deliberately inconsistent with equals.
import java.math.BigDecimal;
import java.util.List;
public class Main {
public static void main(String[] args) {
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("1.000");
List<BigDecimal> prices = List.of(a);
System.out.println("equals " + a.equals(b));
System.out.println("compareTo " + a.compareTo(b));
System.out.println("contains " + prices.contains(b));
System.out.println("cmp scan " + prices.stream().anyMatch(p -> p.compareTo(b) == 0));
}
}Example explained
Line 1BigDecimal.equals compares unscaled value and scale, so 1.0 and 1.000 are different values by equals.
Line 2compareTo compares numeric value only and returns 0, so a sort treats the pair as tied.
Line 3contains is defined in terms of equals, so it misses b even though the list holds a numerically equal number.
Line 4Any lookup that should ignore scale has to be written with compareTo explicitly, as the last line does.
Important notes
Inside a thenComparing chain an implicit lambda such as t -> t.title is ambiguous between the Comparator and key-extractor overloads and fails to compile; use a method reference or type the parameter as (Track t) -> t.title.
list.sort(null) and Arrays.sort(Object[]) are checked at runtime, not compile time: elements that are not mutually Comparable, such as a String next to an Integer, fail with ClassCastException.
Common mistakes
Writing return this.seconds - other.seconds: for values far apart the int subtraction overflows, producing a silently wrong order or IllegalArgumentException: Comparison method violates its general contract!
Assuming a supplied comparator falls back to compareTo for ties: it never does, so elements with equal keys simply keep their input order instead of coming out alphabetically.
Putting reversed() at the end of a comparing/thenComparing chain to flip the last key only; it reverses every key in the chain, so the primary grouping flips too.
Try it yourself
Change, predict, then run
Write a Semver class with int major and int minor whose compareTo orders by major then minor, then print one list of four versions sorted twice: once with Comparator.naturalOrder() and once with a comparator that orders by minor descending and uses Comparator.naturalOrder() as the tie-break.
Open the Java workspaceCheck your understanding
A Track class implements Comparable by title. You run tracks.sort(Comparator.comparingInt(Track::seconds)) on a list where two tracks have exactly the same seconds but different titles. What decides their relative order?
- Their order before the sort, because the comparator reports a tie and List.sort is stable.
- Their titles, because compareTo is consulted whenever the supplied comparator returns 0.
- Nothing predictable, because a comparator that returns 0 for objects that are not equals breaks the contract.
- Nothing: sort throws IllegalArgumentException as soon as two elements compare as equal.
Show answer
A comparator returning 0 only says it has no preference, and the merge sort behind List.sort preserves the input order of tied elements, so the earlier track stays earlier. Option 1 is tempting because the type really does have a natural ordering, but a supplied comparator replaces compareTo completely; you would have to write thenComparing(Comparator.naturalOrder()) to get title as a tie-break. Returning 0 for objects that are not equals is permitted and is not a contract violation, so option 2 is wrong as well.