Study Notes
Design & Patternsfoundation

Sorting — Comparable & Comparator

Natural ordering with Comparable, custom ordering with Comparator, Collections.sort(), lambda comparators, and multi-field sorting strategies.

30 min read·verified Java 21·reviewed 2026-08-30

After this you should be able to

  • Choose natural or custom ordering
  • Build a stable multi-field comparator
ComparableComparatorCollections.sort()

My personal study notes — sorting custom objects, choosing a comparison strategy, and validating it all with unit tests.

JUnit 5Comparable<T>Comparator<T> Collections.sortScientific Debugging

I. Notebook Summary#

Copy-by-hand cheat sheet. Telegraphic. Lead with the term, then the rule. Everything here is derivable from Section II.

=== UNIT TESTING / JUnit ===
Unit test  = test 1 small unit (method/class) in isolation
           → catch bugs early, before integration
JUnit      = Java unit-testing framework; auto run tests fast
JUnit 5    = Platform + Jupiter + Vintage (Vintage runs JUnit 4) [+]

Annotations (Jupiter, org.junit.jupiter.api):
  @Test        → marks a test method
  @DisplayName → readable test label
  @BeforeEach  → runs before EVERY test  (fresh setup)
  @AfterEach   → after every test        (cleanup)
  @BeforeAll   → once, before all  (must be static) [+]
  @AfterAll    → once, after all   (must be static) [+]
  @ParameterizedTest → same test, many inputs
  @RepeatedTest(n)   → run test n times

Assertions (org.junit.jupiter.api.Assertions, static):
  assertEquals(exp, act)   exp FIRST [+]
  assertTrue / assertFalse condition
  assertNull / assertNotNull
  assertThrows(Ex.class, () -> code())  → returns the ex [+]
  NOTE: == checks reference; assertEquals uses .equals() [+]
=== THE PROBLEM ===
Sort List<Student> by rollNo (asc).
  naive  → write a custom sort method by hand   (don't)
  better → Comparable (natural order)
  best+  → Comparator (many orders, no class edits)

Collections.sort(list)            → needs Comparable
Collections.sort(list, cmp)       → uses a Comparator
list.sort(cmp)                     → same, since Java 8 [+]

Comparable vs Comparator — the core table#

 Comparable<T>Comparator<T>
Packagejava.langjava.util
MethodcompareTo(T o)compare(T a, T b)
Args1 (this vs o)2 (a vs b)
Livesinside the classoutside the class
OrderingsONE (natural)MANY
Edit class?yesno
Sort callsort(list)sort(list, cmp)
=== compareTo / compare RETURN CONTRACT ===
return > 0  → this/first  is GREATER → comes AFTER
return < 0  → this/first  is LESS    → comes BEFORE
return = 0  → equal                  → order unchanged

ASC  trick:  this.field - other.field
DESC trick:  other.field - this.field    (flip operands)
SAFE (no overflow): Integer.compare(a, b)   [+]
doodle: number line ──(-)──0──(+)──> ; "-" = before 0, "+" = after
=== COMPARABLE — recipe ===
class Student implements Comparable<Student> {
  int rollNo;
  public int compareTo(Student o){
    return Integer.compare(this.rollNo, o.rollNo); // asc
  }
}
Collections.sort(students);     // uses compareTo
doodle: one box "Student" with a built-in ruler inside it
=== COMPARATOR — recipe ===
class AgeComparator implements Comparator<Student> {
  public int compare(Student a, Student b){
    return Integer.compare(a.age, b.age);
  }
}
Collections.sort(students, new AgeComparator());
Collections.sort(students, new RollNoComparator());
doodle: box "Student" + several external rulers (age, roll, name)
=== COMPARATOR — modern (Java 8+) [+] ===
comparing(Student::getAge)                 // asc by age
  .reversed()                              // desc
  .thenComparing(Student::getName)         // tie-break
comparingInt(Student::getRollNo)           // no boxing
nullsFirst(...) / nullsLast(...)           // null-safe
doodle: chain of links: [age]→[name]→[roll] = thenComparing
=== WHEN TO USE WHICH ===
Comparable  → you own class + ONE natural order (e.g. by id)
Comparator  → can't edit class / want MANY orders / 3rd-party type
Both can coexist: Comparator overrides natural order at call site [+]
=== TRIPLE SORTING (Codewars idea) ===
Sort by 3 attributes in priority order:
  comparingInt(a).thenComparingInt(b).thenComparing(c)
First key decides; ties fall through to next key. [+]
doodle: 3 sieves stacked; item drops to next sieve only on a tie
=== SCIENTIFIC DEBUGGING (be independent) ===
1 OBSERVE   reproduce the bug reliably
2 HYPOTHE   "I think X causes it"
3 PREDICT   "if true, then Y happens"
4 EXPERIMENT log / breakpoint / change one thing
5 CONCLUDE  confirm or reject → repeat
RULE: change ONE variable at a time. [+]
doodle: loop arrow: observe→guess→test→learn→(back to top)
Memory hooks Comparable = comparable-by-itself (one method, lives inside, one order). Comparator = an external comparator device (two args, lives outside, many orders).   Return sign = "where do I sit on the number line relative to you."

II. Detailed Study Notes#

Primary learning material. Each concept: definition → intuition → why it matters → trade-offs/pitfalls → examples. + marks anything I added or corrected beyond the source.

1.Unit Testing & JUnit#

A unit test checks one small piece of code in isolation so bugs surface early, and JUnit is the framework that makes those tests easy to write and run in Java.

Unit testing validates individual units of source code — usually a single method or class — independently of everything around it. The payoff is timing: if each piece is proven correct on its own, the errors that show up when you later wire pieces together drop sharply, because you've already ruled out the small parts.

JUnit is the de-facto unit-testing framework for Java. It lets you write small test methods and run them automatically and quickly, getting a green/red result instead of eyeballing System.out.println output.

+ The current generation is JUnit 5, made of three parts: the Platform (launches tests), Jupiter (the new programming + extension model — where annotations like @Test live, in package org.junit.jupiter.api), and Vintage (a backward-compat engine that runs old JUnit 3/4 tests). Knowing this explains why imports changed from org.junit (JUnit 4) to org.junit.jupiter.api (JUnit 5).
Why it mattersTests are a safety net for change. When you refactor sorting logic (exactly what this guide does), a passing test suite tells you instantly whether you broke behavior — no manual re-checking.

Real-world example: a payments method applyDiscount() gets a unit test feeding it $100 + 10% and asserting $90. Later someone "optimizes" the math; if they introduce a rounding bug, the test goes red before the code ever reaches production.

See it run: Code §III · JUnit example.

2.JUnit Annotations#

Annotations are tags that tell JUnit what a method is and when to run it.

  • @Test — marks a method as a test. JUnit discovers and runs it.
  • @DisplayName("...") — gives the test a human-readable name in reports instead of the raw method name.
  • @BeforeEach — runs before each test method; use it to build fresh objects so tests don't leak state into one another.
  • + @AfterEach — runs after each test (cleanup, e.g. close a file/connection).
  • + @BeforeAll / @AfterAll — run once for the whole class, before/after all tests. In JUnit 5 these must be static (unless you change the test instance lifecycle).
  • @ParameterizedTest — runs the same test body against many input values.
  • @RepeatedTest(n) — runs a test n times (useful for flaky/timing checks).
+ Lifecycle order for one test: @BeforeAll → @BeforeEach → @Test → @AfterEach → @AfterAll. The "Each" pair brackets every single test; the "All" pair brackets the entire class once.
PitfallForgetting @BeforeEach and instead doing setup in a constructor or sharing a field across tests leads to order-dependent tests — one test mutates shared state and a later test mysteriously fails. Fresh setup per test avoids this.

See it run: Code §III · Lifecycle demo.

3.JUnit Assertions#

Assertions are the checks that decide pass or fail; if an assertion is false, the test fails.

All of these are static methods on Assertions (commonly import static org.junit.jupiter.api.Assertions.*;).

AssertionPasses when…
assertEquals(expected, actual)the two are equal (uses .equals())
assertTrue(cond) / assertFalse(cond)the condition is true / false
assertNull(o) / assertNotNull(o)object is null / not null
assertThrows(Ex.class, executable)the code throws that exception type
+ Two precision points worth memorizing: (1) argument order is expected first, actual second — swapping them flips the "expected vs actual" message and misleads you while debugging. (2) assertThrows returns the thrown exception, so you can chain a follow-up assertion on its message: Exception e = assertThrows(...); assertEquals("bad", e.getMessage());. (3) assertEquals compares with .equals(), not ==, so two different objects that are logically equal still pass — provided the class overrides equals().
Interview angle"Why does assertEquals on two distinct new Student(1) objects fail unless I override equals()?" Because the default Object.equals is reference identity. This is the same reason equals()/hashCode() matter for collections.

See it run: Code §III · Assertions demo.

4.The Sorting Problem (why we need an ordering)#

Java can sort numbers and strings out of the box, but to sort your own objects it must be told what "less than" means for them.

The driving task: given a List<Student>, sort it by roll number ascending. The naive instinct is to hand-write a sorting method (loop, compare fields, swap). That works but is the wrong layer of abstraction — you'd re-implement sorting logic the JDK already ships, and you'd repeat it for every new sort key.

The better path is to keep using the JDK's tuned sort (Collections.sort, a stable merge-sort variant) and just supply the comparison rule. Two mechanisms exist for that: Comparable (built into the class) and Comparator (supplied separately).

Why it mattersSeparating "how to sort" (the algorithm, already solved) from "what counts as smaller" (your business rule) is a clean example of the strategy pattern. You write less code, and it's less buggy.
+ Collections.sort is stable: equal elements keep their original relative order. This is what makes multi-key sorting by chaining comparators reliable.

5.The Comparable Interface (natural ordering)#

Comparable bakes a single, default "natural" ordering directly into the class via one method, compareTo.

Comparable<T> is used to make a class's objects sortable. It declares exactly one method:

public int compareTo(T o)

It returns a positive int if this is greater than o, negative if this is less than o, and zero if they're equal. Once a class implements Comparable<Student> and overrides compareTo, calling Collections.sort(list) sorts by that rule with no extra arguments.

+ All the wrapper classes (Integer, Double, Character, etc.) and String already implement Comparable — that's exactly why Collections.sort on a List<Integer> "just works." String's natural order is lexicographic (dictionary-like, case-sensitive: uppercase before lowercase).

Recipe: (1) class Student implements Comparable<Student>, (2) override compareTo(), (3) call Collections.sort(students).

Analogy: Comparable is a built-in ruler glued inside each object. Hand the object to a sorter and it can measure itself against another of its kind. But there's only one ruler, so only one notion of order.

Trade-offsPros: simplest setup; one obvious default order; no extra object at the sort call. Cons: only one ordering per class; requires editing the class (impossible for third-party/final types); changing the rule later means touching the class again.
PitfallWriting return this.rollNo - o.rollNo can overflow for large/negative values (e.g. Integer.MIN_VALUE), returning a wrong sign. Prefer Integer.compare(this.rollNo, o.rollNo). +

Best practice: keep compareTo consistent with equals — if a.compareTo(b)==0 you generally want a.equals(b) too, otherwise TreeSet/TreeMap behave surprisingly (they use compareTo, not equals). +

See it run: Code §III · Comparable.

6.The Return-Value Contract (asc, desc, tie-break)#

The sign of the returned int — not its magnitude — decides order; flip the operands to reverse it.

Picture a number line. A return value places one element relative to the other: a positive number means "I sit to the right (after) you," negative means "to the left (before)," zero means "same spot." Both compareTo and compare obey the identical contract.

WantWrite (ints)
AscendingInteger.compare(a, b)
DescendingInteger.compare(b, a)  (swap)
By String asca.name.compareTo(b.name)
Descending (any)negate / .reversed()
+ To sort descending you don't rewrite logic — just swap the two operands (or wrap an ascending comparator with Collections.reverseOrder() / .reversed()). Magnitude is irrelevant: returning 1 vs 9999 behaves identically; only the sign is read.
Interview angleClassic gotcha: "What's wrong with a - b as a comparator?" → integer overflow on extreme values. Always answer with Integer.compare / Long.compare.

7.The Comparator Interface (external, multiple orderings)#

Comparator defines an ordering outside the class, so you can have many strategies without touching — or even owning — the type.

Comparator<T> is also used to sort collection elements, but the comparison lives in a separate object. Its primary method takes two arguments:

public int compare(T a, T b)   // same +/-/0 contract
public boolean equals(Object o) // compares two comparators (rarely overridden)

compare returns positive if the first argument is greater than the second, negative if less, zero if equal. To use it, pass an instance as the second argument: Collections.sort(students, new AgeComparator()). You can define as many comparators as you have orderings — AgeComparator, RollNoComparator, NameComparator — and pick one per call.

+ The equals(Object) in the interface is special: it compares two comparators (whether they impose the same ordering), not two students. You almost never override it; Object.equals suffices. It's in the contract mainly so collections can reason about comparator equality.

Analogy: if Comparable is a ruler glued inside the object, Comparator is a drawer full of external measuring tools — pick "the age ruler" today, "the name ruler" tomorrow, and the Student class never changes.

Why it matters — the four reasons to choose Comparator
  1. You can't modify the source of the class (third-party / final) → Comparable is impossible, Comparator still works.
  2. It avoids piling extra code into your domain class.
  3. You can define multiple comparison strategies — not possible with a single Comparable.
  4. Comparable remains the right choice for the one default/natural ordering; Comparator handles everything else.
Trade-offsPros: unlimited orderings; no class edits; works on types you don't own; composable. Cons: the caller must remember to pass it; slightly more boilerplate than the single natural order (mitigated by lambdas / Comparator.comparing).

See it run: Code §III · Comparator.

8.Comparable vs Comparator — side by side#

Same goal (define order), opposite placement: one inside the class for the single default, one outside for many flexible orders.

AspectComparable<T>Comparator<T>
Package +java.langjava.util
MethodcompareTo(T o)compare(T a, T b)
# of objects compared1 (this vs param)2 (both params)
Where it livesinside the classseparate class/lambda
How many orderingsexactly one (natural)as many as you want
Modify the class?requirednot required
Sort callCollections.sort(list)Collections.sort(list, cmp)
Good forthe obvious default orderalternate / multiple / 3rd-party
+ They coexist happily: a class can implement Comparable for its natural order and you can still pass a Comparator at a specific sort call to override it just there. Collections.sort(list) uses natural order; Collections.sort(list, byName) uses your comparator for that one call.

9.Modern Comparators (Java 8+ factory methods)#

Since Java 8 you rarely write a whole comparator class — you build one declaratively from method references.

+ This whole subsection is enrichment, but it's the way comparators are written in real code today and it shows up constantly in interviews and reviews:
  • Comparator.comparing(Student::getAge) — ascending by age.
  • .reversed() — flip to descending.
  • .thenComparing(Student::getName) — tie-break when the first key is equal.
  • comparingInt / comparingLong / comparingDouble — primitive-specialized, avoid autoboxing.
  • Comparator.nullsFirst(...) / nullsLast(...) — handle null elements safely.
  • list.sort(cmp) — instance method on List (Java 8+), tidier than Collections.sort(list, cmp).

Example: sort by age ascending, then name ascending for ties: list.sort(comparingInt(Student::getAge).thenComparing(Student::getName));

Interview angleBe ready to convert a verbose anonymous Comparator into a one-liner with Comparator.comparing(...).thenComparing(...), and to explain why comparingInt beats comparing for an int key (no boxing).

See it run: Code §III · Modern comparators.

10.Triple Sorting (sort by three attributes)#

Multi-key sorting is just a priority chain: the first key decides, and only ties fall through to the next key.

The practice exercise is to sort and stringify a list by three attributes at once. The clean solution chains comparators in priority order:

comparingInt(X::getA)
  .thenComparingInt(X::getB)
  .thenComparing(X::getC)
+ Mental model: imagine three stacked sieves. An element is placed by key A; if two elements tie on A, they drop to the B sieve; if they still tie, they drop to C. Because Collections.sort is stable, elements equal on all keys keep their original order. This is exactly why chaining works predictably.
PitfallOrder of thenComparing calls is the priority order. Putting the wrong key first silently produces a "correct-looking but wrong" sort — easy to miss without a test.

See it run: Code §III · Triple sort.

11.Becoming an Independent Developer: Scientific Debugging#

Debugging is a science: form a hypothesis, predict, test one change at a time, and let evidence (not guessing) drive the fix.

+ The source frames "how to become an independent developer" around scientific debugging but leaves the method implicit. The standard loop (from Why Programs Fail, Zeller) is concrete and worth memorizing:
  1. Observe / Reproduce — make the bug happen reliably; capture exact inputs.
  2. Hypothesize — propose a specific cause ("the comparator returns 0 for unequal ages").
  3. Predict — state what you'd see if the hypothesis is true.
  4. Experiment — test it by changing one thing (a log line, a breakpoint, one input).
  5. Conclude — confirm or reject, then loop with a refined hypothesis until found.
Why it matters"Independent" means you can unblock yourself without always asking a senior. The discipline — reproduce, isolate, change one variable — is what separates fast debuggers from people who randomly edit code until it works.
PitfallChanging several things at once. If the bug disappears you won't know which change fixed it, and you may have introduced a new one. One variable at a time.

Connection: unit tests (§1–3) are the fastest "experiment" tool here — a failing test reproduces the bug on demand and a passing one confirms the fix, closing the loop quickly.

III. Code Samples#

Runnable Java (standard library only, Java 17 LTS syntax). Each block has a filename, run command, inline comments, and expected output. JUnit blocks assume JUnit 5 on the classpath.

0.Shared model: Student#

Student.javaused by all samples below
public class Student implements Comparable<Student> {  // natural order = rollNo
    private final int rollNo;
    private final String name;
    private final int age;

    public Student(int rollNo, String name, int age) {
        this.rollNo = rollNo; this.name = name; this.age = age;
    }
    public int getRollNo() { return rollNo; }
    public String getName()  { return name; }
    public int getAge()    { return age; }

    @Override                                // natural ordering: rollNo ascending
    public int compareTo(Student o) {
        return Integer.compare(this.rollNo, o.rollNo); // overflow-safe, not a-b
    }
    @Override
    public String toString() {
        return "#" + rollNo + " " + name + "(" + age + ")";
    }
}
Why Integer.compare not a - b: subtraction can overflow int range and return the wrong sign for extreme values, silently corrupting the sort.

1.JUnit basics — Notes §1#

CalcTest.javamvn test  |  run via IDE / JUnit Platform
import static org.junit.jupiter.api.Assertions.*;  // static import: assertEquals(...)
import org.junit.jupiter.api.*;

class CalcTest {
    @Test
    @DisplayName("2 + 3 = 5")              // readable label in report
    void adds() {
        int sum = 2 + 3;
        assertEquals(5, sum);            // expected FIRST, actual SECOND
    }
}
Expected outputCalcTest > 2 + 3 = 5 ✔ (1 test, 0 failures)
Why: JUnit discovers @Test methods by reflection. The @DisplayName string replaces adds() in the report, so failures read in English.

2.Lifecycle annotations — Notes §2#

LifecycleTest.javamvn test
import org.junit.jupiter.api.*;

class LifecycleTest {
    @BeforeAll static void all()  { System.out.println("BeforeAll (once)"); }
    @BeforeEach      void each() { System.out.println("  BeforeEach"); }
    @AfterEach       void aEach(){ System.out.println("  AfterEach"); }
    @AfterAll  static void aAll() { System.out.println("AfterAll (once)"); }

    @Test void t1() { System.out.println("    test1"); }
    @Test void t2() { System.out.println("    test2"); }
}
Expected outputBeforeAll (once) BeforeEach test1 AfterEach BeforeEach test2 AfterEach AfterAll (once)
Why: "All" brackets the class once and must be static; "Each" brackets every test so each gets a clean slate.

3.Assertions incl. assertThrows — Notes §3#

AssertionsTest.javamvn test
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
import java.util.*;

class AssertionsTest {
    @Test void basics() {
        assertEquals(4, 2 + 2);             // equality via .equals()
        assertTrue("hi".startsWith("h"));      // boolean condition
        assertNotNull(new Object());           // not null
    }
    @Test void throwsOnBadIndex() {
        List<Integer> xs = List.of(1, 2);
        // assertThrows RETURNS the exception so we can inspect it
        Exception e = assertThrows(IndexOutOfBoundsException.class,() -> xs.get(5));            // the code expected to throw
        assertNotNull(e.getMessage());
    }
}
Expected outputAssertionsTest > basics ✔ AssertionsTest > throwsOnBadIndex ✔ (2 tests, 0 failures)
Why: the lambda () -> xs.get(5) is an Executable; JUnit runs it and verifies the exception type, then hands it back for further assertions.

4.Comparable — natural order — Notes §5#

ComparableDemo.javajavac *.java && java ComparableDemo
import java.util.*;

public class ComparableDemo {
    public static void main(String[] args) {
        List<Student> students = new ArrayList<>(List.of(new Student(3, "Asha", 21), new Student(1, "Ravi", 23), new Student(2, "Meera", 20)));

        Collections.sort(students);   // uses Student.compareTo -> rollNo asc
        System.out.println(students);
    }
}
Expected output[#1 Ravi(23), #2 Meera(20), #3 Asha(21)]
Why: no comparator passed → sort falls back to the element's Comparable implementation (rollNo). Wrap with new ArrayList<>(...) because List.of is immutable and sort mutates in place.

5.Comparator — multiple orders — Notes §7#

ComparatorDemo.javajavac *.java && java ComparatorDemo
import java.util.*;

// External ordering #1: by age ascending
class AgeComparator implements Comparator<Student> {
    @Override public int compare(Student a, Student b) {
        return Integer.compare(a.getAge(), b.getAge());
    }
}
// External ordering #2: by rollNo DESCENDING (operands flipped)
class RollNoDescComparator implements Comparator<Student> {
    @Override public int compare(Student a, Student b) {
        return Integer.compare(b.getRollNo(), a.getRollNo()); // b,a = desc
    }
}

public class ComparatorDemo {
    public static void main(String[] args) {
        List<Student> s = new ArrayList<>(List.of(new Student(3, "Asha", 21), new Student(1, "Ravi", 23), new Student(2, "Meera", 20)));

        Collections.sort(s, new AgeComparator());        // pass the strategy
        System.out.println("by age  : " + s);
        Collections.sort(s, new RollNoDescComparator());
        System.out.println("roll desc: " + s);
    }
}
Expected outputby age : [#2 Meera(20), #3 Asha(21), #1 Ravi(23)] roll desc: [#3 Asha(21), #2 Meera(20), #1 Ravi(23)]
Why: the same list is re-sorted with different strategies, none of which touched Student. Descending is just swapped operands.

6.Modern comparators (Java 8+) — Notes §9#

ModernComparatorDemo.javajavac *.java && java ModernComparatorDemo
import java.util.*;
import static java.util.Comparator.*;   // comparing, comparingInt, ...

public class ModernComparatorDemo {
    public static void main(String[] args) {
        List<Student> s = new ArrayList<>(List.of(new Student(3, "Asha", 21), new Student(1, "Ravi", 21), new Student(2, "Meera", 20)));

        // age asc, then name asc on ties; .sort is a List method
        s.sort(comparingInt(Student::getAge).thenComparing(Student::getName));
        System.out.println(s);

        s.sort(comparing(Student::getName).reversed()); // name desc
        System.out.println(s);
    }
}
Expected output[#2 Meera(20), #3 Asha(21), #1 Ravi(21)] [#1 Ravi(21), #2 Meera(20), #3 Asha(21)]
Why: Ravi and Asha both have age 21, so the thenComparing(name) tie-break orders Asha before Ravi. comparingInt avoids boxing the int age.

7.Triple sorting — Notes §10 #

TripleSortDemo.javajavac *.java && java TripleSortDemo
import java.util.*;
import static java.util.Comparator.*;

public class TripleSortDemo {
    public static void main(String[] args) {
        List<Student> s = new ArrayList<>(List.of(new Student(5, "Asha", 21), new Student(2, "Asha", 21), new Student(9, "Asha", 19), new Student(1, "Bina", 19)));

        // priority: age asc -> name asc -> rollNo asc
        s.sort(comparingInt(Student::getAge).thenComparing(Student::getName).thenComparingInt(Student::getRollNo));
        s.forEach(System.out::println);
    }
}
Expected output#9 Asha(19) #1 Bina(19) #2 Asha(21) #5 Asha(21)
Why: age 19 group first; within it Asha<Bina by name; the two age-21 Ashas tie on name, so rollNo (2 before 5) breaks it. Each key only matters when the previous ones tie.

8.Unit-testing a sort (ties it all together)#

SortTest.javamvn test
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
import java.util.*;

class SortTest {
    private List<Student> students;

    @BeforeEach void setUp() {        // fresh list per test
        students = new ArrayList<>(List.of(new Student(3, "Asha", 21), new Student(1, "Ravi", 23)));
    }
    @Test @DisplayName("natural order sorts by rollNo asc")
    void naturalOrder() {
        Collections.sort(students);
        assertEquals(1, students.get(0).getRollNo());
        assertEquals(3, students.get(1).getRollNo());
    }
}
Expected outputSortTest > natural order sorts by rollNo asc ✔ (1 test, 0 failures)
Why: this is scientific debugging operationalized — the test reproduces the expected behavior and will turn red the instant compareTo regresses.

IV. Hands-On Lab#

Build it yourself. Predict before you run. Solutions are collapsed — open only after attempting.

One-time Setup#

  1. Check Java (need 17+): java -version and javac -version.
  2. Make a folder: mkdir sorting-lab && cd sorting-lab.
  3. Add the model: create Student.java from Code §0.
  4. Compile & run a file: javac *.java && java ComparableDemo.
  5. (For test exercises) easiest path: run them inside an IDE (IntelliJ/VS Code) with JUnit 5, or via Maven (mvn test) with junit-jupiter on the classpath. Plain javac can't run JUnit without the jars on -cp.
Warm-up

1 · Make Student sortable by roll number#

Goal: implement natural ordering so Collections.sort(list) orders by rollNo ascending.

  1. Declare class Student implements Comparable<Student>.
  2. Override compareTo using Integer.compare.
  3. In a main, build a 3-student list out of order and call Collections.sort.
Predict, then verify: if you wrote return o.rollNo - this.rollNo instead, what order would you get? Run it and confirm.
▶ Show solution
// see Code §0 (Student) and §4 (ComparableDemo)
@Override public int compareTo(Student o) {
    return Integer.compare(this.rollNo, o.rollNo); // asc
}

Answer to prediction: o.rollNo - this.rollNo flips the sign → descending order. Same logic, swapped operands.

Warm-up

2 · Sort descending without editing compareTo#

Goal: reverse the natural order at the call site only.

  1. Keep compareTo ascending.
  2. Sort with Collections.sort(list, Collections.reverseOrder()).
  3. Print and compare to the ascending result.
Predict, then verify: does reverseOrder() need Student to be Comparable? What happens if it weren't?
▶ Show solution
Collections.sort(list, Collections.reverseOrder()); // reverses NATURAL order
// equivalent modern form:
list.sort(Comparator.<Student>naturalOrder().reversed());

Answer: yes — reverseOrder() reverses the natural ordering, so the type must be Comparable. Without it you'd get a ClassCastException at runtime.

Core

3 · Write an AgeComparator#

Goal: sort by age ascending using an external comparator, leaving Student untouched.

  1. Create class AgeComparator implements Comparator<Student>.
  2. Override compare(a, b) with Integer.compare(a.getAge(), b.getAge()).
  3. Sort: Collections.sort(list, new AgeComparator()).
Predict, then verify: two students share an age. Which comes first after sorting, and why? (Hint: stability.)
▶ Show solution
class AgeComparator implements Comparator<Student> {
    @Override public int compare(Student a, Student b) {
        return Integer.compare(a.getAge(), b.getAge());
    }
}

Answer: the one that appeared earlier in the original list stays first — Collections.sort is stable, so equal keys preserve input order.

Core

4 · Rewrite AgeComparator as a one-liner#

Goal: replace the whole class with a Comparator.comparing lambda/method-reference.

  1. Delete the AgeComparator class usage.
  2. Sort with list.sort(Comparator.comparingInt(Student::getAge)).
  3. Confirm output matches Exercise 3 exactly.
Predict, then verify: why use comparingInt over comparing here? What does comparing do extra to an int?
▶ Show solution
import static java.util.Comparator.*;
list.sort(comparingInt(Student::getAge)); // no boxing

Answer: comparing would autobox each int age into an Integer; comparingInt uses the primitive directly — same result, less allocation.

Challenge

5 · Triple sort with a tie-break chain#

Goal: sort by age asc → name asc → rollNo asc, in that priority.

  1. Build a list where some students tie on age, and some tie on age and name.
  2. Chain comparingInt(...).thenComparing(...).thenComparingInt(...).
  3. Print and trace which key broke each tie.
Predict, then verify: write the expected final order on paper before running. Then swap the first two keys — how does the result change?
▶ Show solution
import static java.util.Comparator.*;
list.sort(comparingInt(Student::getAge)
        .thenComparing(Student::getName)
        .thenComparingInt(Student::getRollNo));

Answer: see Code §7 for full output. Swapping age and name first makes name the primary key, so the whole grouping changes — proof that thenComparing order = priority order.

Challenge

6 · Prove your sort with a JUnit test#

Goal: write a test that fails if natural ordering breaks, using @BeforeEach + assertEquals + assertThrows.

  1. In @BeforeEach, build a fresh unsorted list.
  2. Test 1: after Collections.sort, assert the first/last rollNo.
  3. Test 2: assert that sorting an immutable List.of(...) throws (predict the exception type).
Predict, then verify: what exception does Collections.sort(List.of(...)) throw, and why? Confirm with assertThrows.
▶ Show solution
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
import java.util.*;

class SortChallengeTest {
    List<Student> xs;
    @BeforeEach void init() {
        xs = new ArrayList<>(List.of(new Student(3,"A",21), new Student(1,"B",22)));
    }
    @Test void sortsAsc() {
        Collections.sort(xs);
        assertEquals(1, xs.get(0).getRollNo());
        assertEquals(3, xs.get(1).getRollNo());
    }
    @Test void immutableThrows() {
        List<Student> fixed = List.of(new Student(2,"C",20), new Student(1,"D",19));
        assertThrows(UnsupportedOperationException.class,() -> Collections.sort(fixed));
    }
}

Answer: List.of(...) is immutable; sort mutates in place, so it throws UnsupportedOperationException. That's why all demos wrap with new ArrayList<>(...).

Self-check rubric — you've got it when…#

  • You can state, without looking, what compareTo/compare return for greater / less / equal, and how to flip to descending.
  • You can choose Comparable vs Comparator from a one-sentence requirement and justify why.
  • You can turn a full Comparator class into a comparing(...).thenComparing(...) one-liner and back.
  • You can explain why a - b is risky and what to use instead.
  • You can predict a triple-sort result on paper and explain which key broke each tie (stability included).
  • You can write a JUnit 5 test with @BeforeEach, assertEquals, and assertThrows that guards your sort.
  • You can run the scientific-debugging loop out loud: observe → hypothesize → predict → experiment (one change) → conclude.

Key Takeaways#

  • Don't hand-write sorting — supply a comparison rule and let Collections.sort/list.sort (stable) do the work.
  • Comparable = one natural order, lives inside the class, compareTo(o), sort(list).
  • Comparator = many orders, lives outside the class, compare(a,b), sort(list, cmp) — and it's the only option for types you can't modify.
  • The contract is sign-only: +after, −before, 0 equal. Flip operands for descending. Use Integer.compare to dodge overflow.
  • Multi-key sorting is a priority chain via thenComparing; ties fall through key by key, and stability holds equal elements in place.
  • JUnit proves it: @Test/@BeforeEach + assertEquals/assertThrows turn "I think it sorts right" into "it's verified, and stays verified."
  • Independence = scientific debugging: reproduce, hypothesize, predict, change one thing, conclude — with a failing test as your fastest experiment.
Personal study notes · Java 17 LTS · JUnit 5 (Jupiter). [+] marks my own additions & corrections beyond the source.