← Skill tree CS Skill Tree 0 CSCD211

Multi-key ordering with thenComparing

Textbook: BJP (Reges and Stepp)

Before You Start

Check each box you can do from memory. A box you cannot check yet is not a problem; it points you to a quick refresher, not a grade.

Not sure? Take the 60-second self-check.

1. What does Comparator.comparing(Person::getLastName) return, and what is its type?

Check

It returns a Comparator<Person> that compares two Person objects by calling getLastName() on each and comparing the resulting String values using their natural ordering.

2. A method reference like Player::getTeam extracts a key. What must that key’s type guarantee for the single-argument Comparator.comparing overload to compile?

Check

The key type must implement Comparable (for example, String or Integer). If the key does not implement Comparable, you must supply an explicit Comparator for it using the two-argument overload Comparator.comparing(keyExtractor, keyComparator).

3. In the legacy two-pass stable-sort idiom, you sort by the less important key first. Why?

Check

A stable sort preserves the relative order of equal elements. Sorting by the minor key first means that when you sort by the major key, ties in the major key still fall in minor-key order. The two-pass idiom achieves multi-key ordering indirectly through stability. The thenComparing chain achieves the same result in a single sort pass.


What You Need To Walk In With

Reading a Comparator.comparing(...).thenComparing(...) chain is not new syntax on top of what you already know: each .thenComparing(...) is a method call on the result of the previous call, and you can read the chain line by line, each line stating “and if that is a tie, use this next rule.” You do not need any new mental machinery to follow a chain; you need confidence in method calls and method references. You can: build a two-key comparator from a Comparator.comparing and one thenComparing; trace a chain step by step and identify at which key the final result is produced; select the right overload for a given field type; and translate a manual cascade (if-return block) into a single chained expression.


Try this first

Here are two Player objects:

playerA: team = "BUF", name = "Allen"
playerB: team = "BUF", name = "Diggs"

You build this comparator:

Comparator<Player> byTeamThenName =
    Comparator.comparing(Player::getTeam)
              .thenComparing(Player::getName);

Before reading further: predict what byTeamThenName.compare(playerA, playerB) returns. Is it negative, zero, or positive? And which line of code is responsible for that result?

Hold your prediction. The explanation below will let you check it.


How it works

The short-circuit cascade

Comparator<T>.thenComparing(Comparator<? super T> other) returns a new comparator. When that comparator’s compare(a, b) is called:

  1. Call the original comparator on a and b.
  2. If the result is non-zero, return it immediately. The other comparator is never called.
  3. If the result is zero, delegate to other.

Chaining three keys:

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

The cascade reads left to right: last name is most significant, age is least significant. If two people share a last name and a first name, age breaks the tie. If all three match, the result is 0.

Picture: a priority ladder

compare(a, b)
  │
  ├─ getLastName(a) vs getLastName(b)
  │     non-zero? ──► return that result  (done)
  │     zero?
  │       │
  │       ├─ getFirstName(a) vs getFirstName(b)
  │       │     non-zero? ──► return that result  (done)
  │       │     zero?
  │       │       │
  │       │       └─ getAge(a) vs getAge(b)
  │       │             return that result
  │

Each rung is consulted only if the rung above it returned zero.

The overloads

Overload When to use
thenComparing(Comparator<? super T>) The next key needs its own comparator (for example, reversed())
thenComparing(Function<T,U>) The key implements Comparable
thenComparing(Function<T,U>, Comparator<? super U>) The key does not implement Comparable, or you want a non-natural ordering for the key
thenComparingInt(ToIntFunction<T>) The key is int; avoids boxing
thenComparingLong(ToLongFunction<T>) The key is long
thenComparingDouble(ToDoubleFunction<T>) The key is double

Reversing one key in the middle of a chain

To order primarily by team (ascending) and secondarily by games played (descending):

Comparator<Player> byTeamThenGamesDesc =
    Comparator.comparing(Player::getTeam)
              .thenComparing(
                  Comparator.comparingInt(Player::getGamesPlayed).reversed());

reversed() returns a new comparator, which is then passed to thenComparing(Comparator<? super T>).


Worked example: predict, then check

Setup. A roster has three players:

name team gamesPlayed
Allen BUF 16
Diggs BUF 14
Hill MIA 17

Comparator:

Comparator<Player> c =
    Comparator.comparing(Player::getTeam)
              .thenComparingInt(Player::getGamesPlayed);

Step 1 (predict): Before tracing, guess the sorted order.

Show trace and answer

Trace c.compare(Allen, Diggs):

  • Key 1: "BUF".compareTo("BUF") = 0. Fall through.
  • Key 2: 16 - 14 = 2 (positive). Return 2.
  • Result: Allen comes after Diggs in this ordering (Diggs has fewer games, so it sorts first with ascending int comparison).

Trace c.compare(Diggs, Hill):

  • Key 1: "BUF".compareTo("MIA") = negative (B < M). Return negative.
  • Result: Diggs comes before Hill. Key 2 is never consulted.

Sorted order: Diggs (BUF/14), Allen (BUF/16), Hill (MIA/17).

BUF players appear first (alphabetically before MIA), and within BUF, fewer games comes first.

Checking your prediction from the intro section:

byTeamThenName.compare(playerA, playerB) where both have team "BUF":

Result: negative. thenComparing(Player::getName) is responsible. The team key produced 0, so the name key broke the tie.

Algorithm for building a multi-key chain

  1. Identify the most significant field. Use Comparator.comparing(Type::getField) or Comparator.comparingInt(...) for that field.
  2. For each additional field in decreasing significance, call .thenComparing(Type::getField) or the appropriate primitive variant.
  3. If any field should sort in reverse, wrap that step: .thenComparing(Comparator.comparingInt(Type::getField).reversed()).
  4. Assign the full chain to a named variable; pass that variable to Collections.sort, List.sort, or a stream’s .sorted(...).

Quick check

Check your understanding

Two Employee objects share the same department but differ in salary. A comparator is built as Comparator.comparing(Employee::getDepartment).thenComparingInt(Employee::getSalary). Which comparator is responsible for the non-zero result when these two employees are compared?

Tier 2 · BJP (Reges and Stepp), Ch 10


Common misconceptions

Common misconception

the order of fields in the chain does not matter.

Wrong mental model: “I listed all the fields I care about, so the result is the same regardless of order.”

Why it breaks: comparing(byA).thenComparing(byB) sorts primarily by A and uses B only when A ties. comparing(byB).thenComparing(byA) does the opposite. Given two objects that differ in both A and B, these comparators produce opposite results.

How to correct: read the chain left to right as primary to tertiary key. The leftmost comparing call is the most significant sort key. Source: Comparator.thenComparing javadoc.

Common misconception

lambda expressions work as well as method references in the first position of a chain.

Wrong mental model: Comparator.comparing(p -> p.getTeam()).thenComparing(p -> p.getGamesPlayed()) compiles fine.

Why it breaks: Java’s type inference can fail to infer T from a bare lambda in Comparator.comparing(...). The compiler reports a confusing error pointing at thenComparing, not at the lambda. The fix is to use a method reference (Player::getTeam) or to annotate the lambda’s parameter type explicitly ((Player p) -> p.getTeam()).

How to correct: prefer method references in comparator chains. They carry unambiguous type information and satisfy the intent of Bloch EJ3 Item 43. Source: Bloch.EJ3.Item-43.

Quick check

Check your understanding

A programmer writes Comparator.comparing(Person::getLastName).thenComparing(Person::getFirstName) and wants the result to match Comparator.comparing(Person::getFirstName).thenComparing(Person::getLastName). For which pair of Person objects would the two comparators produce opposite signs?

Tier 2 · BJP (Reges and Stepp), Ch 10


Formal definition and interface contract

Comparator<T>.thenComparing is a default method in java.util.Comparator. Its specification (Java SE 25 API):

Returns a lexicographic-order comparator with another comparator. If this comparator considers two elements equal, i.e., compare(a, b) == 0, the other comparator is used.

Three overloads at docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/Comparator.html:

Bloch EJ3 Item 14 establishes the general contract for Comparator: the value returned by compare is only meaningful as negative, zero, or positive; callers must not rely on its magnitude. That contract holds for the chained comparator thenComparing returns.


Mental model

Think of ordering people in a line at a sorted table: first everyone sorts by last name, and within the same last name they sort by first name, and within the same full name they sort by age. The person checking last names only hands you to the person checking first names when last names match. The person checking first names only hands you to the age-checker when first names match too. thenComparing encodes exactly that handoff structure in one readable expression, making the priority order visible at the call site instead of buried in a multi-step sort procedure.


Connections

Within CSCD 211: This comparator feeds directly into List.sort(Comparator), Collections.sort, and Stream.sorted(Comparator). Any time a sorted view of data is needed with more than one priority rule, this is the tool.

Looking back: Comparator.comparing builds the first comparator in the chain. The legacy two-pass stable-sort idiom achieves the same ordering; thenComparing replaces it with a single-pass, single-named expression.

Looking ahead: reversed() and nullsFirst/nullsLast extend the chain pattern to handle descending keys and null-safe ordering. Stream pipelines pick up the same Comparator<T> directly in .sorted(...), so this skill transfers without modification.


Practice

Level 1

Problem. Write a Comparator<Book> that orders books primarily by genre (alphabetical) and secondarily by publication year (ascending). Book has getGenre() returning String and getYear() returning int.

Thought process

Genre is the primary key. It returns String, which is Comparable, so use the single-argument Comparator.comparing. Year is the secondary key. It is int; use thenComparingInt to avoid boxing. Secondary means it appears after the first key in the chain.

Show answer
Comparator<Book> byGenreThenYear =
    Comparator.comparing(Book::getGenre)
              .thenComparingInt(Book::getYear);

getGenre() returning String satisfies the Comparable requirement for the single-argument overload. thenComparingInt accepts a ToIntFunction<Book> and avoids boxing int to Integer.


Level 2

Problem. Translate the following legacy block to a single thenComparing chain.

// Two pre-built comparators
Comparator<Order> byStatus = Comparator.comparing(Order::getStatus);
Comparator<Order> byDate   = Comparator.comparing(Order::getDate);

// Legacy lambda
Comparator<Order> combined = (a, b) -> {
    int r = byStatus.compare(a, b);
    if (r != 0) return r;
    return byDate.compare(a, b);
};
Thought process

The lambda checks byStatus first. If that is non-zero it returns. Otherwise it falls through to byDate. That is exactly the behavior of byStatus.thenComparing(byDate): call byStatus, return the result if non-zero, otherwise delegate to byDate. No new method references are needed because byStatus and byDate are already Comparator<Order> values.

Show answer
Comparator<Order> combined = byStatus.thenComparing(byDate);

This passes byDate to thenComparing(Comparator<? super Order>). The behavior is identical to the lambda: byStatus runs first; byDate runs only when byStatus returns 0. Source for this translation pattern: Bloch.EJ3.Item-14.


Level 3

Problem. Build a Comparator<Player> that sorts by team (ascending), then by position (ascending), then by games played (descending). Assign it to a variable, then sort a List<Player> roster in place using it.

Thought process

Three keys: team, position, gamesPlayed. Team and position are ascending, so use Comparator.comparing and thenComparing with method references. Games played is descending: wrap a Comparator.comparingInt(Player::getGamesPlayed) in .reversed() and pass it to thenComparing(Comparator<? super Player>). To sort in place, call roster.sort(comparator).

Show answer
Comparator<Player> byTeamPositionGamesDesc =
    Comparator.comparing(Player::getTeam)
              .thenComparing(Player::getPosition)
              .thenComparing(
                  Comparator.comparingInt(Player::getGamesPlayed).reversed());

roster.sort(byTeamPositionGamesDesc);

The first two keys use the key-extractor overload because String is Comparable. The third key wraps a new Comparator<Player> produced by comparingInt(...).reversed() and passes it to the comparator overload of thenComparing. This matches the expected form from the assessment stem in objective.md.


Go Deeper (optional)

Nothing here is required to write correct code with thenComparing. Read on when you are curious about where the idea comes from, or when you want to recognize it in a professional codebase.

The thenComparing chain is an instance of the Decorator pattern (Bloch EJ3 Item 18): each call wraps the previous Comparator in a new object that delegates to it first and falls through only when the wrapped result is zero. You can trace the delegation graph by hand on any two objects, following the chain of wrapper objects until one of them returns non-zero. Recognizing Decorator in action here makes the pattern easier to spot elsewhere in the Java standard library.

There is also a connection to order theory worth knowing. A Comparator is a binary relation that, when its contract is satisfied, imposes a strict total order on a set: it is irreflexive, asymmetric, and transitive. The thenComparing chain constructs a lexicographic order on the product space of the key types, which is the same structure used in B-tree composite index design in databases (where a two-column index on (lastname, firstname) uses exactly this left-to-right tie-breaking rule) and in graded lexicographic monomial orders in computer algebra. Encountering the chain in a production codebase and recognizing it as a familiar mathematical structure is one of those moments where theory and practice meet cleanly.

A Comparator that takes a function argument and returns a new Comparator is also the simplest real example of a first-class function: the function object idea here traces to the Strategy pattern (the Comparator itself) and to the concept that a function is a value that can be stored, passed, and composed. The key-extractor overloads of comparing and thenComparing take a Function<T, U> and return a new Comparator<T>, which is higher-order function composition. In production code, sorting orders by business rules (sort orders by status then date, sort employees by department then seniority) appear constantly in service layers, report generation, and data pipelines; a named Comparator chain reads the rule aloud, which makes code review and maintenance easier than an equivalent manual cascade.


Check Yourself

Close the notes and answer each one from memory, then reveal it. Pulling an idea back from memory is one of the strongest ways to make it stick.

Check your understanding

When does a thenComparing chain consult the secondary key?

Tier 1 · BJP (Reges and Stepp), Ch 10

A Book has getGenre() returning String and getYear() returning int. Which declaration correctly builds a Comparator<Book> ordered by genre then by year (ascending)?

Tier 1 · BJP (Reges and Stepp), Ch 10

Given three Player objects: Allen (team=BUF, gamesPlayed=16), Diggs (team=BUF, gamesPlayed=14), Hill (team=MIA, gamesPlayed=17). The comparator is: Comparator.comparing(Player::getTeam).thenComparingInt(Player::getGamesPlayed). What is the sorted order after Collections.sort(roster, c)?

Tier 2 · BJP (Reges and Stepp), Ch 10

You need a Comparator<Player> that sorts by team ascending, then by gamesPlayed descending. Which chain is correct?

Tier 2 · BJP (Reges and Stepp), Ch 10

Why do the misconceptions in this material recommend method references over bare lambda expressions in the first position of a Comparator.comparing call?

Tier 3 · BJP (Reges and Stepp), Ch 10