Comparable and Cloneable in Java — theory with students, grades and copies (and the copy that changed the original)
I had three students in an array, a grade for each one and a very simple idea: Arrays.sort(classroom) and move on. I compiled, ran it and Java answered with this:
Exception in thread "main" java.lang.ClassCastException: class Student cannot be cast to class java.lang.Comparable (Student is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')
“Cannot be cast to Comparable? I haven’t done any cast.” And yet Java was right: to sort objects you have to know which one goes first, and my Student class had never told Java how two students compare. That’s half of today’s story: the Comparable interface, which teaches a class how to sort itself. The other half is Cloneable, which teaches it how to copy itself… with a small print I’ll warn you about right now: copying an object doesn’t always mean copying what it carries inside.
If you’ve already read the interfaces theory, this will ring a bell: Comparable is an interface with a single method, the smallest contract there is. And Cloneable is the strangest interface in all of Java, because it has no methods at all. You’ll see why. We’ll also lean on what you know about inheritance and exceptions, because clone() throws a checked exception. At the end, the Comparable and Cloneable practice is waiting with three complete programs.
Table of Contents
Comparable: the natural order of a class
Numbers know how to sort themselves (3 goes before 8) and so do Strings (alphabetically). But a Student… does the one with the best grade go first, or the one whose name comes first in the dictionary? Java can’t guess. You have to tell it, and the way to do that is to implement Comparable:
public interface Comparable<T> {
int compareTo(T other);
}
One method. What a.compareTo(b) returns answers the question “does a go before b?”:
| Result | Means |
|---|---|
| A negative number | a goes before b |
| Zero | a and b are equivalent for ordering |
| A positive number | a goes after b |
With that, my student class looks like this (sorted from lowest to highest grade):
public class Student implements Comparable<Student> {
private final String name;
private final double grade;
public Student(String name, double grade) {
this.name = name;
this.grade = grade;
}
public String getName() { return name; }
public double getGrade() { return grade; }
// Natural order: from lowest to highest grade
@Override
public int compareTo(Student other) {
return Double.compare(this.grade, other.grade);
}
@Override
public String toString() {
return name + " (" + grade + ")";
}
}
Notice three details:
Comparable<Student>carries the type between<>: that waycompareToreceives aStudentand not anObject, and you save yourself a cast.- I compare with
Double.compare, which knows how to comparedoublevalues without tricks. Further down you’ll see why I don’t use a subtraction. - The natural order is just one. Whoever writes the class chooses it, and it’s used whenever someone sorts without giving more information.
And now, the same program that blew up before:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class SortStudents2 {
public static void main(String[] args) {
Student[] classroom = {
new Student("Marta", 9.1),
new Student("Luis", 6.5),
new Student("Ana", 8.0)
};
Arrays.sort(classroom);
System.out.println("Sorted array: " + Arrays.toString(classroom));
List<Student> list = new ArrayList<>(Arrays.asList(classroom));
Collections.reverse(list);
System.out.println("Reversed: " + list);
Collections.sort(list);
System.out.println("With sort: " + list);
System.out.println("Best grade: " + Collections.max(list));
System.out.println("Worst grade: " + Collections.min(list));
}
}
Sorted array: [Luis (6.5), Ana (8.0), Marta (9.1)] Reversed: [Marta (9.1), Ana (8.0), Luis (6.5)] With sort: [Luis (6.5), Ana (8.0), Marta (9.1)] Best grade: Marta (9.1) Worst grade: Luis (6.5)
The important part: I haven’t programmed any sorting algorithm. Arrays.sort, Collections.sort, Collections.max and Collections.min call compareTo internally and that’s it. TreeSet and TreeMap do the same thing to keep their elements sorted, as you’ll see in the practice.
The ClassCastException at the start was the warning that Arrays.sort tried to see my students as Comparable and they weren’t. It’s a runtime error, not a compile-time one, because Arrays.sort accepts any Object[]. That’s why it’s a good idea to implement the interface before you hand the class to a sorter.
And if you declare implements Comparable<Student> but forget to write the method, then the compiler does complain:
Student.java:1: error: Student is not abstract and does not override abstract method compareTo(Student) in Comparable
public class Student implements Comparable<Student> {
^
1 error
Writing implements Comparable<Student> and forgetting compareTo. It’s the same message as with any interface: you haven’t fulfilled the contract. If you use an IDE, it will offer to generate the method.
The compareTo contract (the small print)
Writing compareTo is easy. Writing it well means respecting a contract that Java doesn’t check, but that every sorting algorithm depends on:
- Symmetry: if
a.compareTo(b)is positive,b.compareTo(a)has to be negative (and vice versa). Only the sign matters, not the exact value. - Transitivity: if
agoes beforebandbbeforec, thenagoes beforec. - **Consistency with
equals** (recommended):a.compareTo(b) == 0should give the same result asa.equals(b).
The third one is the one most people skip, and the one that causes the most grief. Let’s see it. Now my Student compares by grade first and, on a tie, by name, and it has an equals consistent with that:
@Override
public int compareTo(Student other) {
int byGrade = Double.compare(this.grade, other.grade);
if (byGrade != 0) {
return byGrade;
}
return this.name.compareTo(other.name);
}
// Consistent with compareTo: equal if and only if compareTo gives 0
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student)) return false;
Student other = (Student) o;
return name.equals(other.name) && Double.compare(grade, other.grade) == 0;
}
@Override
public int hashCode() {
return 31 * name.hashCode() + Double.hashCode(grade);
}
The tie-break by name isn’t decoration. Without it, two students with the same grade would give compareTo == 0 even though they’re different people, and sorted collections would treat them as if they were the same one.
See what happens with a TreeSet (a set that stays sorted using compareTo or a Comparator) when the ordering criterion doesn’t tell two different students apart:
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
public class Contract3 {
public static void main(String[] args) {
Student ana = new Student("Ana", 8.5);
Student luis = new Student("Luis", 7.0);
Student marta = new Student("Marta", 8.5);
// Symmetry: the sign flips when the order is swapped
System.out.println("ana vs luis: " + Integer.signum(ana.compareTo(luis)));
System.out.println("luis vs ana: " + Integer.signum(luis.compareTo(ana)));
TreeSet<Student> natural = new TreeSet<>();
TreeSet<Student> gradeOnly = new TreeSet<>(Comparator.comparingDouble(Student::getGrade));
for (Student a : List.of(ana, luis, marta)) {
natural.add(a);
gradeOnly.add(a);
}
System.out.println("Natural: " + natural.size() + " " + natural);
System.out.println("Grade only: " + gradeOnly.size() + " " + gradeOnly);
// The subtraction trap
int a = Integer.MAX_VALUE, b = -1;
System.out.println("a - b = " + (a - b));
System.out.println("Integer.compare(a, b) = " + Integer.compare(a, b));
}
}
ana vs luis: 1 luis vs ana: -1 Natural: 3 [Luis (7.0), Ana (8.5), Marta (8.5)] Grade only: 2 [Luis (7.0), Ana (8.5)] a - b = -2147483648 Integer.compare(a, b) = 1
Between the lines:
- The first two lines confirm symmetry:
+1one way and-1the other. - The natural
TreeSet(grade and name) keeps all three students. The one that sorts only by grade loses Marta: since she ties with Ana (8.5 both), to theTreeSetthey’re “the same element” and the second insertion is silently discarded. No error, no warning.
A Comparator or a compareTo that returns 0 for objects that aren’t equal. In a TreeSet/TreeMap it means data that vanishes without any exception. If two things can be different, the ordering criterion has to tell them apart (with a tie-breaker).
And the last two lines are the other classic trap. Many people write return this.grade - other.grade thinking “negative, zero or positive, just what I need!”. With double there’s a decimals problem, and with int there’s a worse one: the subtraction can overflow. Integer.MAX_VALUE - (-1) doesn’t give a huge number, it gives -2147483648, that is, a negative, and the order breaks.
Never subtract to compare. Use Integer.compare(a, b), Double.compare(a, b) or a.compareTo(b) if they’re already objects (String, Integer…). They do the same thing, without the traps.
Comparator: other criteria without touching the class
Comparable gives a natural order, a single one, inside the class. But sometimes you want to sort students by name, or by grade from highest to lowest, or by name length… without changing the Student class. That’s what Comparator is for, an external ordering criterion:
Comparable<T> | Comparator<T> | |
|---|---|---|
| Where it lives | Inside the class | Outside, in its own object |
| Method | compareTo(T other) | compare(T a, T b) |
| How many can I have? | One (the natural one) | As many as I want |
| Used with | sort(list), TreeSet<T>() | sort(list, comparator), new TreeSet<>(comparator) |
| Do I have to touch the class? | Yes | No |
Since Java 8 you don’t need to write whole classes: there are static methods that build comparators from a “getter”, and they can be chained with reversed() and thenComparing():
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class SortStudents4 {
public static void main(String[] args) {
List<Student> classroom = new ArrayList<>(List.of(
new Student("Marta", 9.1), new Student("Luis", 6.5),
new Student("Ana", 8.0), new Student("Pablo", 8.0), new Student("Eva", 9.1)));
classroom.sort(null); // null = natural order (compareTo)
System.out.println("Natural: " + classroom);
classroom.sort(Comparator.comparing(Student::getName));
System.out.println("By name: " + classroom);
classroom.sort(Comparator.comparingDouble(Student::getGrade).reversed()
.thenComparing(Student::getName));
System.out.println("Grade desc.: " + classroom);
// A hand-written Comparator: by name length
classroom.sort((x, y) -> Integer.compare(x.getName().length(), y.getName().length()));
System.out.println("By length: " + classroom);
}
}
Natural: [Luis (6.5), Ana (8.0), Pablo (8.0), Eva (9.1), Marta (9.1)] By name: [Ana (8.0), Eva (9.1), Luis (6.5), Marta (9.1), Pablo (8.0)] Grade desc.: [Eva (9.1), Marta (9.1), Ana (8.0), Pablo (8.0), Luis (6.5)] By length: [Eva (9.1), Ana (8.0), Luis (6.5), Marta (9.1), Pablo (8.0)]
Three things to look at in the output:
sort(null)uses the natural order (compareTo): from lowest to highest grade, and ties by name.- In “Grade desc.”, Eva and Marta (9.1) come first and between them the name decides; the same goes for Ana and Pablo (8.0). That
thenComparingis the tie-breaker we were talking about earlier. - The last criterion is written “by hand” with a lambda. The names with the same length keep their relative order because
sortis stable: it doesn’t reorder what’s already tied.
If you remember only one thing from this section: Comparable is how this class sorts by default; Comparator is how I want to sort it right now.
Cloneable: how an object gets copied
Let’s change topic (although it’s an interface again). Imagine you want a backup copy of a student before touching their grade. If you write Student copy = original; you haven’t copied anything: you’ve created a second reference to the same object, and whatever you change through one you’ll see through the other. To get a different object with the same data, Java offers clone(), inherited from Object, with three peculiarities:
clone()is **protected** inObject: you can’t call it from outside unless your class redefines it aspublic.- It only works if your class implements **
Cloneable, an interface with no methods** (one of the so-called marker interfaces): it’s a tag that says “this object is allowed to be copied”. super.clone()throwsCloneNotSupportedException, a checked exception: you have to catch it or declare it.
The complete recipe, for a Student with a name, a grade and a list of subjects:
import java.util.ArrayList;
import java.util.List;
public class Student implements Cloneable {
private String name;
private double grade;
private List<String> subjects = new ArrayList<>();
public Student(String name, double grade) {
this.name = name;
this.grade = grade;
}
public void setGrade(double grade) { this.grade = grade; }
public void enrol(String subject) { subjects.add(subject); }
public List<String> getSubjects() { return subjects; }
@Override
public Student clone() {
try {
return (Student) super.clone(); // field-by-field copy
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // can't happen: we implement Cloneable
}
}
@Override
public String toString() {
return name + " (" + grade + ") " + subjects;
}
}
Details of the recipe:
implements Cloneable: the permission.public Student clone(): I redefine the method withpublicaccess, and with **return typeStudent** (you’re allowed to return a more specific type thanObject; it’s called a covariant return) so you don’t need a cast outside.super.clone()does the actual copy: it creates a new object and copies field by field.- The
try/catchexists only becausesuper.clone()declares the exception. Since we implementedCloneable, it can’t be thrown; that’s why we turn it into anAssertionError(“this is impossible”).
If you skip one of the permissions, Java complains in two different ways. Without implements Cloneable, it fails at runtime:
public class Student { // no "implements Cloneable"!
private String name;
public Student(String name) {
this.name = name;
}
@Override
public Student clone() throws CloneNotSupportedException {
return (Student) super.clone();
}
}
Exception in thread "main" java.lang.CloneNotSupportedException: Student at java.base/java.lang.Object.clone(Native Method) at Student.clone(Student.java:10)
Forgetting implements Cloneable. super.clone() checks whether the class carries the tag and, if it doesn’t, throws CloneNotSupportedException with the class name. It compiles without complaint and fails when you run it.
And if you call clone() from outside without having made it public, it fails at compile time:
ProtectedAccess.java:4: error: clone() has protected access in Object
Student copy = (Student) ana.clone();
^
1 error
Calling clone() without having redefined it as public. Remember: in Object it’s protected. Implementing Cloneable isn’t enough; you also have to override the method.
Shallow copy vs deep copy
The first time I made a “backup copy” with clone(), I changed the copy’s grade and breathed easy: the original was still the same. Then I enrolled the copy in another subject… and when I printed the original, it was enrolled too. My copy had changed the original. This is what Java sees:
public class Copy5 {
public static void main(String[] args) {
Student original = new Student("Ana", 8.0);
original.enrol("FP2");
Student copy = original.clone();
copy.setGrade(5.0);
copy.enrol("IC2");
System.out.println("Original: " + original);
System.out.println("Copy: " + copy);
System.out.println("Same object? " + (original == copy));
System.out.println("Same list? " + (original.getSubjects() == copy.getSubjects()));
}
}
Original: Ana (8.0) [FP2, IC2] Copy: Ana (5.0) [FP2, IC2] Same object? false Same list? true
Read it slowly, because this is the idea of the topic:
- The grade (a
double) was really copied: the copy has 5.0 and the original keeps 8.0. - The list of subjects wasn’t: original and copy point to the same list (
trueon the last line). That’s why “IC2” shows up in both.
This is what’s called a shallow copy: super.clone() copies the fields as they are, and in the fields that are references it copies the reference, not the object it points to. For primitive types and Strings (immutable) that’s enough. For any mutable object (lists, arrays, other classes of yours) it isn’t.
The solution is the deep copy: after super.clone(), you duplicate the mutable parts by hand.
@Override
public Student clone() {
try {
Student copy = (Student) super.clone(); // field-by-field copy
copy.subjects = new ArrayList<>(this.subjects); // and a new list just for the copy
return copy;
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // can't happen: we implement Cloneable
}
}
Original: Ana (8.0) [FP2] Copy: Ana (5.0) [FP2, IC2] Same object? false Same list? false
Now the copy carries its own list: the original stays at [FP2], and Same list? gives false.
| Object field | super.clone() | Anything else to do? |
|---|---|---|
int, double, boolean… | Copies the value | No |
String (immutable) | Copies the reference | No: nobody can modify it |
List, Map, arrays | Copies the reference | Yes: create a new one |
| Another mutable object of yours | Copies the reference | Yes: clone it too (other.clone()) |
This chains. If your Student has a mutable Address and you want a deep copy, Address has to be Cloneable in turn, and Student.clone() must call address.clone(). You decide the depth, level by level.
And one last detail that will save you a ClassCastException: inside clone() always use super.clone() and never new Student(...). super.clone() creates an object of the real type of the one being cloned (if it’s a subtype, it’s still the subtype); with new you force it to always be the parent class. In the exercises you’ll run into that failure.
And if clone() doesn’t convince you
Many people prefer to avoid clone(): it’s awkward (checked exception, an interface with no methods, casts) and there’s no way to make deep copies without writing them by hand. The usual alternative is a copy constructor: new Student(other). It’s clearer and doesn’t need Cloneable, although it loses the real type if there’s inheritance. In the practice we’ll compare both ways side by side with a program.
Typical mistakes, at a glance
ClassCastException when sorting. The class doesn’t implement Comparable (or you pass a Comparator and forgot it).
compareTo with a subtraction. It works until the values are very large or very negative; after that the order breaks without warning.
An order that gives 0 for different elements. In a TreeSet/TreeMap data gets lost.
Believing clone() copies “everything inside”. It’s a shallow copy: mutable objects are still shared.
CloneNotSupportedException (missing Cloneable) or clone() has protected access (missing public).
Watch it step by step in Python Tutor
References are very easy to get lost in, and a picture is worth a thousand words. Python Tutor runs the code line by line and draws the variables, the objects and the arrows of the references. Choose Java, paste the program and press Visualize Execution. This is a short version of the copy problem: instead of clone() it uses two methods that do by hand what super.clone() would do (shallow) and what our deep copy does:
import java.util.ArrayList;
public class Tutor {
static class Student {
String name;
ArrayList<String> subjects;
Student(String name) {
this.name = name;
this.subjects = new ArrayList<>();
}
Student shallowCopy() {
Student c = new Student(this.name);
c.subjects = this.subjects; // same list
return c;
}
Student deepCopy() {
Student c = new Student(this.name);
c.subjects = new ArrayList<>(this.subjects); // new list
return c;
}
}
public static void main(String[] args) {
Student ana = new Student("Ana");
ana.subjects.add("FP2");
Student shallow = ana.shallowCopy();
Student deep = ana.deepCopy();
shallow.subjects.add("IC2");
System.out.println("Original: " + ana.subjects);
System.out.println("Deep: " + deep.subjects);
}
}
Original: [FP2, IC2] Deep: [FP2]
What to watch, step by step:
- After creating
anaand adding"FP2", you’ll see oneStudentobject with an arrow to one list. - When
shallowCopy()runs, a secondStudentappears… but **itssubjectsarrow points to the same list** asana‘s. Two objects, one list. - With
deepCopy(), the thirdStudentcomes with a new list with the same content: another separate box in the drawing. - On the line
shallow.subjects.add("IC2"), notice that the list that grows is the shared one: that’s whyanachanges too, anddeepdoesn’t.
Summary and quick table
| I want to… | I write | Detail |
|---|---|---|
| Give my class a natural order | implements Comparable<Student> | compareTo returns negative, 0 or positive |
| Compare numbers without traps | Integer.compare(a, b) / Double.compare(a, b) | Never a subtraction |
| Break ties | if (c != 0) return c; return … | Stops two different ones from giving 0 |
| Sort with an external criterion | list.sort(comparator) | Comparable not needed |
| Chain criteria | comparing(…).reversed().thenComparing(…) | reversed() reverses what came before |
| Be able to copy an object | implements Cloneable | Marker interface, no methods |
Write clone() | public Student clone() { … super.clone() … } | public, super.clone() and try/catch |
| Deep copy | copy.list = new ArrayList<>(this.list); | After super.clone(), duplicate the mutable parts |
Copy without clone() | new Student(other) | Copy constructor |
Three ideas to keep:
- To sort you need a criterion:
Comparableif it’s the order of the class,Comparatorif it’s one more. - An order that gives 0 for different things makes data vanish in sorted collections. Always break ties.
- **
clone()copies the box, not what’s inside the boxes** that point elsewhere. Whatever is mutable, you duplicate it yourself.
And now, let’s practise
That’s the complete theory. In the Comparable and Cloneable practice we apply it with a student ranking, a comparison between clone() and the copy constructor, and a walk through TreeSet and TreeMap. And if you want to review the basics first, go back to the interfaces theory.
Did this article help you? Here is how you can support SergioLearns →

2 Comments