My personal study notes — sorting custom objects, choosing a comparison strategy, and validating it all with unit tests.
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<T> | Comparator<T> | |
|---|---|---|
| Package | java.lang | java.util |
| Method | compareTo(T o) | compare(T a, T b) |
| Args | 1 (this vs o) | 2 (a vs b) |
| Lives | inside the class | outside the class |
| Orderings | ONE (natural) | MANY |
| Edit class? | yes | no |
| Sort call | sort(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)
Primary learning material. Each concept: definition → intuition → why it matters → trade-offs/pitfalls → examples. + marks anything I added or corrected beyond the source.
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.
@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).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.
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).@BeforeAll → @BeforeEach → @Test → @AfterEach → @AfterAll. The "Each" pair
brackets every single test; the "All" pair brackets the entire class once.@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.
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.*;).
| Assertion | Passes 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 |
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().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.
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).
Collections.sort is stable:
equal elements keep their original relative order. This is what makes multi-key sorting by chaining
comparators reliable.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.
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.
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.
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.
| Want | Write (ints) |
|---|---|
| Ascending | Integer.compare(a, b) |
| Descending | Integer.compare(b, a) (swap) |
| By String asc | a.name.compareTo(b.name) |
| Descending (any) | negate / .reversed() |
Collections.reverseOrder() / .reversed()). Magnitude is
irrelevant: returning 1 vs 9999 behaves identically;
only the sign is read.a - b as a comparator?" → integer overflow on extreme values. Always answer with
Integer.compare / Long.compare.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.
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.
Comparator.comparing).See it run: Code §III · Comparator.
Same goal (define order), opposite placement: one inside the class for the single default, one outside for many flexible orders.
| Aspect | Comparable<T> | Comparator<T> |
|---|---|---|
| Package + | java.lang |
java.util |
| Method | compareTo(T o) |
compare(T a, T b) |
| # of objects compared | 1 (this vs param) | 2 (both params) |
| Where it lives | inside the class | separate class/lambda |
| How many orderings | exactly one (natural) | as many as you want |
| Modify the class? | required | not required |
| Sort call | Collections.sort(list) |
Collections.sort(list, cmp) |
| Good for | the obvious default order | alternate / multiple / 3rd-party |
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.Since Java 8 you rarely write a whole comparator class — you build one declaratively from method references.
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));
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.
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)
Collections.sort is stable, elements equal on all keys keep
their original order. This is exactly why chaining works predictably.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.
Debugging is a science: form a hypothesis, predict, test one change at a time, and let evidence (not guessing) drive the fix.
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.
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.
Studentpublic 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 + ")"; } }
Integer.compare not a - b: subtraction can overflow int range
and return the wrong sign for extreme values, silently corrupting the sort.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 } }
@Test methods by reflection. The
@DisplayName string replaces adds() in the report,
so failures read in English.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"); } }
static;
"Each" brackets every test so each gets a clean slate.assertThrows — Notes §3import 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()); } }
() -> xs.get(5) is an Executable; JUnit runs it and verifies the exception type, then hands it back
for further assertions.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); } }
sort falls back to the
element's Comparable implementation (rollNo). Wrap with new ArrayList<>(...) because List.of is
immutable and sort mutates in place.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); } }
Student. Descending is just swapped operands.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); } }
thenComparing(name) tie-break orders Asha before Ravi. comparingInt avoids boxing the int age.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); } }
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()); } }
compareTo regresses.Build it yourself. Predict before you run. Solutions are collapsed — open only after attempting.
java -version and javac -version.mkdir sorting-lab && cd sorting-lab.Student.java from Code §0.javac *.java && java ComparableDemo.mvn test) with junit-jupiter
on the classpath. Plain javac can't run JUnit without the jars on -cp.Goal: implement natural ordering so Collections.sort(list) orders by
rollNo ascending.
class Student implements Comparable<Student>.compareTo using Integer.compare.main, build a 3-student list out of order and call Collections.sort.return o.rollNo - this.rollNo instead, what order would you get? Run it and
confirm.// 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.
Goal: reverse the natural order at the call site only.
compareTo ascending.Collections.sort(list, Collections.reverseOrder()).reverseOrder() need
Student to be Comparable? What happens if it
weren't?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.
Goal: sort by age ascending using an external comparator, leaving Student untouched.
class AgeComparator implements Comparator<Student>.compare(a, b) with Integer.compare(a.getAge(), b.getAge()).Collections.sort(list, new AgeComparator()).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.
Goal: replace the whole class with a Comparator.comparing
lambda/method-reference.
AgeComparator class usage.list.sort(Comparator.comparingInt(Student::getAge)).comparingInt over
comparing here? What does comparing do extra to an
int?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.
Goal: sort by age asc → name asc → rollNo asc, in that priority.
comparingInt(...).thenComparing(...).thenComparingInt(...).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.
Goal: write a test that fails if natural ordering breaks, using @BeforeEach + assertEquals + assertThrows.
@BeforeEach, build a fresh unsorted list.Collections.sort, assert the first/last rollNo.List.of(...) throws (predict
the exception type).Collections.sort(List.of(...)) throw, and why? Confirm with assertThrows.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<>(...).
compareTo/compare return for greater / less / equal, and how to flip to descending.
Comparator class into a comparing(...).thenComparing(...) one-liner and back.a - b is risky and what to use instead.@BeforeEach, assertEquals, and assertThrows that guards your
sort.Collections.sort/list.sort (stable) do the work.
compareTo(o), sort(list).compare(a,b),
sort(list, cmp) — and it's the only option for types you can't modify.Integer.compare to dodge overflow.thenComparing; ties fall
through key by key, and stability holds equal elements in place.@Test/@BeforeEach + assertEquals/assertThrows turn "I think it sorts
right" into "it's verified, and stays verified."