Comparable and Cloneable in Java — practice with a ranking, backup copies and sorted trees (and the word that vanished from the TreeSet)
My first TreeSet with a Comparator by length turned out like this: I put in seven words and it gave me back three. I checked the code three times looking for the bug. I checked the word list. I even suspected my keyboard. Java hadn’t made any mistake: I had simply told it that two words of the same length were the same word, and it had taken my word for it.
That’s the beauty of this topic: Comparable, Comparator and Cloneable are three small tools that do exactly what you ask of them, even when it’s not what you meant. And that’s precisely why you learn them by practising: when something comes out odd, the cause is almost always a definition of “equal” or of “copy” that wasn’t the one you had in mind.
If you’ve already read the Comparable and Cloneable theory, this article is for putting it to work: a student ranking with several sorting criteria, a comparison between clone() and the copy constructor, and a walk through TreeSet and TreeMap with their own ordering. They are medium-difficulty programs. Each one comes with commented code, the output and the doubts that tend to come up.
Table of Contents
Before you start
It helps to be clear about:
- That
compareToreturns a negative number, zero or a positive number, and that it must compare without subtraction (Integer.compare,Double.compare) and with a tie-breaker when two different elements can tie. - That
Comparableis the natural order of the class andComparatoris an external criterion: you can have as many as you want and chain them withreversed()andthenComparing(). - That
clone()needsimplements Cloneable, must bepublicand must start withsuper.clone(), and that this copy is shallow: lists and mutable objects are still shared. - That
TreeSetandTreeMapdon’t useequals: if the comparator gives0, to them it’s the same element.
If any of these sounds shaky, go back over the theory before carrying on.
The three programs are independent. Create a folder for each one and compile them separately: programs 1 and 2 each have their own Student class, and they are not the same one.
Program 1: a student ranking
A class of six students with a name, a grade and a year. We want to see them in alphabetical order (the default order of a class list), then in a ranking by grade and, finally, grouped by year.
The idea
The student’s natural order will be alphabetical, because that’s what anyone would expect from a class list: that goes in compareTo. Everything else (ranking by grade, by year) are criteria that aren’t “the” order of the student, so they go outside, in Comparators that can be combined like building blocks.
And on top of that, a generic method greatest that only requires the type to be Comparable: proof that Comparable isn’t only for sorting, but for writing code that works for any “comparable” type.
Before programming, think about what changes from one criterion to another: only the field being compared. The Comparator.comparing... methods are made for exactly that.
The code
Student.java
public class Student implements Comparable<Student> {
private final String name;
private final double grade;
private final int year;
public Student(String name, double grade, int year) {
this.name = name;
this.grade = grade;
this.year = year;
}
public String getName() { return name; }
public double getGrade() { return grade; }
public int getYear() { return year; }
// Natural order: alphabetical by name
@Override
public int compareTo(Student other) {
return this.name.compareTo(other.name);
}
@Override
public String toString() {
return name + " (" + grade + ", year " + year + ")";
}
}
Ranking.java
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
public class Ranking {
// Works with any type that knows how to compare itself
static <T extends Comparable<T>> T greatest(List<T> list) {
T best = list.get(0);
for (T element : list) {
if (element.compareTo(best) > 0) {
best = element;
}
}
return best;
}
public static void main(String[] args) {
List<Student> classroom = new ArrayList<>(List.of(
new Student("Marta", 9.1, 2), new Student("Luis", 6.5, 1),
new Student("Ana", 8.0, 2), new Student("Pablo", 8.0, 1),
new Student("Eva", 9.1, 1), new Student("Sara", 4.8, 2)));
// 1. Natural order (compareTo): alphabetical
Collections.sort(classroom);
System.out.println("Alphabetical: " + classroom);
// 2. External criterion: grade descending and, on a tie, name
Comparator<Student> byGrade = Comparator
.comparingDouble(Student::getGrade).reversed()
.thenComparing(Student::getName);
classroom.sort(byGrade);
System.out.println("\nRanking:");
for (int i = 0; i < classroom.size(); i++) {
System.out.println((i + 1) + ". " + classroom.get(i));
}
// 3. Top 3 and extremes with the same Comparator
System.out.println("\nTop 3: " + classroom.subList(0, 3));
System.out.println("Best: " + Collections.min(classroom, byGrade));
System.out.println("Worst: " + Collections.max(classroom, byGrade));
// 4. Ranking by year and, within each year, by grade
classroom.sort(Comparator.comparingInt(Student::getYear).thenComparing(byGrade));
System.out.println("\nBy year: " + classroom);
// 5. A generic method that only requires Comparable
System.out.println("\nLast alphabetically: " + greatest(classroom));
System.out.println("Greatest number: " + greatest(List.of(3, 17, 8)));
System.out.println("Greatest word: " + greatest(List.of("sea", "sun", "moon")));
}
}
Output
Alphabetical: [Ana (8.0, year 2), Eva (9.1, year 1), Luis (6.5, year 1), Marta (9.1, year 2), Pablo (8.0, year 1), Sara (4.8, year 2)] Ranking: 1. Eva (9.1, year 1) 2. Marta (9.1, year 2) 3. Ana (8.0, year 2) 4. Pablo (8.0, year 1) 5. Luis (6.5, year 1) 6. Sara (4.8, year 2) Top 3: [Eva (9.1, year 1), Marta (9.1, year 2), Ana (8.0, year 2)] Best: Eva (9.1, year 1) Worst: Sara (4.8, year 2) By year: [Eva (9.1, year 1), Pablo (8.0, year 1), Luis (6.5, year 1), Marta (9.1, year 2), Ana (8.0, year 2), Sara (4.8, year 2)] Last alphabetically: Sara (4.8, year 2) Greatest number: 17 Greatest word: sun
Check it by hand:
- Alphabetical: Ana, Eva, Luis, Marta, Pablo, Sara. The grades had nothing to do with it:
compareToonly looks at the name. - Ranking: highest grades first. Eva and Marta have 9.1 and Eva goes first alphabetically; the same for Ana and Pablo with 8.0. Luis (6.5) and Sara (4.8) close the list.
- Top 3, best and worst: the top 3 are the first three of the ranking. Since our comparator puts the best one first, the minimum according to that criterion (
Collections.min) is the best student, Eva; and the maximum, the worst, Sara. - By year: first those in year 1 (Eva, Pablo, Luis, sorted by grade) and then those in year 2 (Marta, Ana, Sara).
- Last alphabetically: Sara. And the same
greatestmethod gives17with integers andsunwith words.
subList(0, 3) is a view of the list, not a copy: if you changed the original list, the view would be affected. For an independent list, new ArrayList<>(classroom.subList(0, 3)).
Questions you might ask
**Why is the ranking a Comparator and I don’t change compareTo?** Because compareTo is the class’s default order and there can only be one. If you changed it to “by grade”, the alphabetical listing would stop existing unless you wrote another criterion. With Comparator, they all coexist.
**Why does Collections.min(classroom, byGrade) return the best student?** Because min returns the element that comes first according to the comparator, and our ranking puts the one with the best grade first. “Minimum” doesn’t mean “worst”, it means “first in the order you give it”.
**What does <T extends Comparable<T>> mean?** That greatest accepts any type T that knows how to compare itself with others of its own class. That’s why the same function works for Student, Integer and String, without duplicating code.
How do you combine two comparators? comparingInt(Student::getYear).thenComparing(byGrade) sorts by year and, within each year, uses the byGrade comparator we already had. Comparators are reused as building blocks.
Calling greatest with an empty list. list.get(0) throws IndexOutOfBoundsException. Before taking the first element, check that the list isn’t empty.
Try it yourself
- Change the ranking so that, on a grade tie, the student in the higher year wins.
- Write a
Comparatorthat sorts by name length and, on a tie, by grade descending.
Program 2: clone() versus the copy constructor
A student with a list of grades and an address. We want to copy it in three ways (shallow copy, deep clone() and copy constructor) and see what each one keeps.
The idea
A copy made with super.clone() only duplicates the box: the list of grades and the address will still be the same ones. For the copy to be independent, you have to duplicate the mutable parts by hand. We’ll do it in two ways and compare them, and see what happens when there is inheritance (a scholarship student who is a Student with one more field).
The address is also Cloneable: that way Student can ask it to clone itself instead of knowing its fields. Depth is decided level by level.
Before reading the code, predict what will happen in each block of the output. In the first one, will the original change when you touch the shallow copy?
The code
Address.java
public class Address implements Cloneable {
private String city;
public Address(String city) { this.city = city; }
public void setCity(String city) { this.city = city; }
@Override
public Address clone() {
try {
return (Address) super.clone(); // there is only a String (immutable): this is enough
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
@Override
public String toString() { return city; }
}
Student.java
import java.util.ArrayList;
import java.util.List;
public class Student implements Cloneable {
private String name;
private List<Double> grades = new ArrayList<>();
private Address address;
public Student(String name, String city) {
this.name = name;
this.address = new Address(city);
}
// Copy constructor (deep copy)
public Student(Student other) {
this.name = other.name;
this.grades = new ArrayList<>(other.grades);
this.address = other.address.clone();
}
public void addGrade(double grade) { grades.add(grade); }
public Address getAddress() { return address; }
public List<Double> getGrades() { return grades; }
// Shallow clone(): just what super.clone() does
public Student shallowClone() {
try {
return (Student) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
}
}
// Deep clone(): super.clone() and then we duplicate whatever is mutable
@Override
public Student clone() {
Student copy = shallowClone();
copy.grades = new ArrayList<>(this.grades);
copy.address = this.address.clone();
return copy;
}
@Override
public String toString() {
return getClass().getSimpleName() + " " + name + " " + grades + " in " + address;
}
}
ScholarshipStudent.java
public class ScholarshipStudent extends Student {
private double scholarship;
public ScholarshipStudent(String name, String city, double scholarship) {
super(name, city);
this.scholarship = scholarship;
}
public ScholarshipStudent(ScholarshipStudent other) {
super(other);
this.scholarship = other.scholarship;
}
@Override
public String toString() {
return super.toString() + " with a scholarship of " + scholarship;
}
}
Copies.java
public class Copies {
public static void main(String[] args) {
Student ana = new Student("Ana", "Telde");
ana.addGrade(8.0);
// 1. Shallow copy: shares the list and the address
Student shallow = ana.shallowClone();
shallow.addGrade(5.0);
shallow.getAddress().setCity("Arucas");
System.out.println("Original: " + ana);
System.out.println("Shallow: " + shallow);
// 2. The two deep copies
Student byClone = ana.clone();
Student byConstructor = new Student(ana);
byClone.addGrade(10.0);
byConstructor.getAddress().setCity("Gáldar");
System.out.println("\nOriginal: " + ana);
System.out.println("With clone: " + byClone);
System.out.println("With ctor: " + byConstructor);
// 3. What type does the copy have when there is inheritance?
Student scholar = new ScholarshipStudent("Luis", "Agüimes", 1500);
Student copyByClone = scholar.clone();
Student copyByCtor = new Student(scholar);
System.out.println("\nclone(): " + copyByClone);
System.out.println("constructor: " + copyByCtor);
}
}
Output
Original: Student Ana [8.0, 5.0] in Arucas Shallow: Student Ana [8.0, 5.0] in Arucas Original: Student Ana [8.0, 5.0] in Arucas With clone: Student Ana [8.0, 5.0, 10.0] in Arucas With ctor: Student Ana [8.0, 5.0] in Gáldar clone(): ScholarshipStudent Luis [] in Agüimes with a scholarship of 1500.0 constructor: Student Luis [] in Agüimes
Check it by hand:
- Shallow:
shallowshares the list of grades and the address withana. When we add a 5.0 and move to Arucas, the original has them too. The damage is done for the rest of the program. - **Deep
clone():**byClonehas its own list. We add a 10.0 to it and the original ([8.0, 5.0]) doesn’t notice. - Copy constructor:
byConstructorhas its own address. It moves to Gáldar and the original stays in Arucas. - Inheritance:
scholar.clone()returns aScholarshipStudentwith its scholarship;new Student(scholar)creates a plainStudentand the scholarship disappears.
clone() | Copy constructor | |
|---|---|---|
Needs Cloneable and try/catch | Yes | No |
| Keeps the real type (inheritance) | Yes (if you use super.clone()) | No: it always creates the constructor’s class |
| Deep copy | By hand, after super.clone() | By hand, field by field |
| Readability | Lower | Higher |
final fields | Awkward (super.clone() copies them but you can’t reassign them) | Easy |
Questions you might ask
**Why doesn’t Address.clone() copy anything by hand?** Because it only has a String, and Strings are immutable: sharing them does no harm. With super.clone() it’s already a deep copy.
**Why does clone() call shallowClone() instead of super.clone() directly?** It could call it directly. I separated them so I could demonstrate the shallow and the deep copy with the same class in the same program. In real code you’d only have the deep clone().
Why does the copy constructor lose the scholarship? Because new Student(scholar) runs the Student constructor, which knows nothing about scholarships. The type of the created object is decided by the new, not by the type of the variable you pass in.
Which one do I use? There’s no absolute winner. With class hierarchies and copies that must be of the same type, clone() (or a copy() method that each subclass redefines). With simple classes and a taste for clarity, the copy constructor.
Copying a list with new ArrayList<>(other) and believing it’s already a deep copy. The list is new, but its elements are still the same objects. With Double (immutable) it doesn’t matter; with your own mutable objects, it does.
Try it yourself
- Remove
implements CloneablefromStudentand see which exception appears when you runCopies. - Give
Addressa list of phone numbers as well. What do you have to change so thatStudent.clone()is still deep?
Program 3: TreeSet and TreeMap with their own ordering
Seven words, a comparator by length and a lot of care with ties. This is the scene of the crime from my anecdote.
The idea
TreeSet and TreeMap keep their elements sorted using compareTo or a Comparator, and that changes everything: they stop using equals and hashCode, and look only at the result of the comparison. If the comparator gives 0, to them it’s the same element.
We’ll try three things: the natural order of String, a comparator that loses words and the same comparator fixed with a tie-breaker. Then we’ll navigate the set and use a TreeMap to group.
Before running it, count how many words there are of each length. That already tells you how many will survive with the “length only” comparator.
The code
Words.java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.TreeMap;
import java.util.TreeSet;
public class Words {
public static void main(String[] args) {
List<String> words = Arrays.asList("bank", "sun", "nest", "moon", "sea", "cloud", "tulip");
// 1. Natural order of String: alphabetical
TreeSet<String> alphabetical = new TreeSet<>(words);
System.out.println("Alphabetical: " + alphabetical);
// 2. Comparator by length only: a tie means "being the same"!
TreeSet<String> byLength = new TreeSet<>(Comparator.comparingInt(String::length));
byLength.addAll(words);
System.out.println("Length only: " + byLength.size() + " of " + words.size() + " " + byLength);
// 3. With a tie-breaker nothing is lost
TreeSet<String> full = new TreeSet<>(
Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
full.addAll(words);
System.out.println("Length + alphabetical: " + full.size() + " " + full);
// 4. Navigating a TreeSet
System.out.println("\nFirst: " + full.first() + ", last: " + full.last());
System.out.println("Before \"nest\": " + full.lower("nest"));
System.out.println("After \"nest\": " + full.higher("nest"));
System.out.println("Up to \"moon\" (inclusive): " + full.headSet("moon", true));
System.out.println("From \"nest\": " + full.tailSet("nest"));
// 5. TreeMap: words grouped by length, always sorted by key
TreeMap<Integer, List<String>> bySize = new TreeMap<>();
for (String p : words) {
bySize.computeIfAbsent(p.length(), k -> new ArrayList<>()).add(p);
}
System.out.println("\nGrouped: " + bySize);
System.out.println("Smallest and largest key: " + bySize.firstKey() + " and " + bySize.lastKey());
System.out.println("Largest key <= 4: " + bySize.floorKey(4));
System.out.println("Smallest key >= 5: " + bySize.ceilingKey(5));
System.out.println("Largest to smallest: " + bySize.descendingMap());
// 6. Keys compared ignoring case
TreeMap<String, Integer> votes = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
for (String vote : new String[]{"Sun", "moon", "sun", "SUN", "Moon"}) {
votes.merge(vote, 1, Integer::sum);
}
System.out.println("\nVotes: " + votes);
}
}
Output
Alphabetical: [bank, cloud, moon, nest, sea, sun, tulip]
Length only: 3 of 7 [sun, bank, cloud]
Length + alphabetical: 7 [sea, sun, bank, moon, nest, cloud, tulip]
First: sea, last: tulip
Before "nest": moon
After "nest": cloud
Up to "moon" (inclusive): [sea, sun, bank, moon]
From "nest": [nest, cloud, tulip]
Grouped: {3=[sun, sea], 4=[bank, nest, moon], 5=[cloud, tulip]}
Smallest and largest key: 3 and 5
Largest key <= 4: 4
Smallest key >= 5: 5
Largest to smallest: {5=[cloud, tulip], 4=[bank, nest, moon], 3=[sun, sea]}
Votes: {moon=2, Sun=3}
Check it by hand:
- Alphabetical: be careful, the natural order of
Stringcompares character codes, so uppercase letters, or accented letters (like theáin the Spanish wordárbol), end up afterz. It isn’t dictionary order. For real text with accents, the right tool is aCollator, which does know the language. - Length only: 3 of 7. There are words of 3, 4 and 5 letters: three lengths, three elements. The first word of each length that came in stays (
sun,bank,cloud). - Length + alphabetical: 7. With the tie-breaker nobody ties with anybody and no word is lost.
- Navigating:
lower("nest")ismoon(the previous one) andhigher("nest")iscloud(the next one).headSet("moon", true)goes up tomooninclusive;tailSet("nest")starts atnest. - **
TreeMap:** the keys 3, 4 and 5 always come out sorted.floorKey(4)is 4 andceilingKey(5)is 5. - Votes:
Sun,sunandSUNare the same key; the five votes are split intoSun(3) andmoon(2).
lower, higher, floorKey and ceilingKey work even if the key you ask for doesn’t exist: they return the closest one below or above (and null only if there is none).
Questions you might ask
**Why does the TreeSet discard nest and moon without warning?** Because the comparator says that compare("nest", "bank") == 0, and a TreeSet understands “zero” as “I already have it”. It throws no exception: adding returns false, and that’s it.
Why is nothing lost with the tie-breaker? Because thenComparing(Comparator.naturalOrder()) is only consulted when the length ties, and then the alphabetical order decides. Two different words never give 0 any more.
**And what about the key that’s kept in the TreeMap with CASE_INSENSITIVE_ORDER? The first** one that came in. The others count towards that same key, but they don’t replace it.
**When do I use TreeSet instead of HashSet? When you need the elements sorted** or to navigate them (first, lower, headSet…). If you only care whether something is there or not, HashSet is faster.
Using a Comparator that doesn’t tell different elements apart and expecting the TreeSet to keep both. A TreeSet doesn’t look at equals: if compare returns 0, the second one doesn’t get in.
Try it yourself
- Change the length comparator so it sorts from longest to shortest (
.reversed()) and check which of the four-letter words survives. - Create a
TreeMap<String, Integer>withString.CASE_INSENSITIVE_ORDERand count how many times each word in a text appears, ignoring case.
Common mistakes in this practice
- Subtracting to compare in
compareTo: it overflows with large values. UseInteger.compareorDouble.compare. - **An ordering criterion that gives
0for different elements**: in aTreeSetorTreeMap, data disappears without warning. - **Believing
reversed()reverses the whole chain: it only reverses what comes before** it. - **Calling
clone()withoutimplements Cloneable**:CloneNotSupportedExceptionat runtime. - Forgetting to duplicate the mutable parts after
super.clone(): the copy shares the list and changes the original. - Using the copy constructor with inheritance and losing the subclass’s fields.
Watch it step by step in Python Tutor
Python Tutor runs the code line by line and draws variables, objects and references. Choose Java. Here’s the TreeSet disaster reduced to the minimum, with the comparator written as a normal class (easier to follow than a lambda):
import java.util.Comparator;
import java.util.TreeSet;
public class Tutor {
static class ByLength implements Comparator<String> {
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
}
public static void main(String[] args) {
TreeSet<String> set = new TreeSet<>(new ByLength());
String[] words = {"bank", "sun", "nest", "moon"};
for (String p : words) {
boolean added = set.add(p);
System.out.println(p + " -> " + added + " " + set);
}
}
}
Expected output:
bank -> true [bank] sun -> true [sun, bank] nest -> false [sun, bank] moon -> false [sun, bank]
What to watch, step by step:
- On the first turn (
bank),addedistrueand the set has a single element. - On the second (
sun),trueagain: the length is different (3 versus 4) andsunis placed beforebank. - On the third (
nest),addedbecomesfalse. Notice the change: the set doesn’t grow, becausecompare("nest", "bank")gives0. - The fourth (
moon) repeats the same thing. When it ends, out of four words two remain.
Inside, a TreeSet is a tree, so Python Tutor may draw it with more boxes than you expect. Focus on the added variable and the output of each turn, which is what tells the story.
Summary
Comparabledefines the natural order of the class;Comparatorprovides as many external criteria as you want.- Comparators are chained and reused:
comparing,reversed,thenComparing. - An ordering criterion is a definition of “equal”, not just of “before and after”: if it gives
0,TreeSetandTreeMaptreat it as the same element. clone()is shallow: aftersuper.clone()you have to duplicate lists and mutable objects by hand.- The copy constructor is clearer, but it loses the real type of the subclass;
clone()keeps it. - Generic methods with
<T extends Comparable<T>>work for any comparable type.
Checklist: have you practised it?
- [ ] I’ve compiled and run the three programs.
- [ ] I’ve checked at least one output of each program by hand.
- [ ] I’ve added a new criterion to the ranking without touching
compareTo. - [ ] I’ve caused a shallow copy and seen the original change.
- [ ] I’ve lost a word in a
TreeSetbecause of a tie and recovered it with a tie-breaker. - [ ] I’ve looked at the Python Tutor program and seen
addreturnfalse.
And now, on to the exercises: Comparable and Cloneable exercises, with three levels, three fragments to debug and a cheat sheet at the end. See you there!
Next step: exercisesComparable and Cloneable in Java — exercises with books, players and a cloned squad (and the subtraction that put the richest account first)Did this article help you? Here is how you can support SergioLearns →
