Comparable y Cloneable en Java — teoría con alumnos, notas y copias (y la copia que cambió al original)
Tenía tres alumnos en un array, una nota para cada uno y una idea muy simple: Arrays.sort(clase) y a otra cosa. Compilé, ejecuté y Java me contestó esto:
Exception in thread "main" java.lang.ClassCastException: class Alumno cannot be cast to class java.lang.Comparable (Alumno is in unnamed module of loader 'app'; java.lang.Comparable is in module java.base of loader 'bootstrap')
«¿Cannot be cast to Comparable? Yo no he hecho ningún cast.» Y sin embargo Java tenía razón: para ordenar objetos hay que saber cuál va antes, y mi clase Alumno no le había dicho cómo se comparan dos alumnos. Esa es la mitad de la historia de hoy: la interfaz Comparable, que le enseña a una clase a ordenarse. La otra mitad es Cloneable, que le enseña a copiarse… con una letra pequeña que ya te adelanto: copiar un objeto no siempre significa copiar lo que lleva dentro.
Si ya has leído la teoría de interfaces, esto te va a sonar: Comparable es una interfaz con un único método, el contrato más pequeño que existe. Y Cloneable es la interfaz más rara de todo Java, porque no tiene ningún método. Verás por qué. También vamos a tirar de lo que sabes de herencia y de excepciones, porque clone() lanza una excepción comprobada. Al final, la práctica de Comparable y Cloneable te espera con tres programas completos.
Tabla de Contenidos
Comparable: el orden natural de una clase
Los números saben ordenarse solos (el 3 va antes que el 8) y los String también (por orden alfabético). Pero un Alumno… ¿va antes el que tiene mejor nota o el que se llama antes en el diccionario? Java no puede adivinarlo. Hay que decírselo, y la forma de hacerlo es implementar Comparable:
public interface Comparable<T> {
int compareTo(T otro);
}
Un solo método. Lo que devuelve a.compareTo(b) es la respuesta a «¿a va antes que b?»:
| Resultado | Significa |
|---|---|
| Un número negativo | a va antes que b |
| Cero | a y b son equivalentes para el orden |
| Un número positivo | a va después que b |
Con eso, mi clase de alumnos queda así (ordenados de menor a mayor nota):
public class Alumno implements Comparable<Alumno> {
private final String nombre;
private final double nota;
public Alumno(String nombre, double nota) {
this.nombre = nombre;
this.nota = nota;
}
public String getNombre() { return nombre; }
public double getNota() { return nota; }
// Orden natural: de menor a mayor nota
@Override
public int compareTo(Alumno otro) {
return Double.compare(this.nota, otro.nota);
}
@Override
public String toString() {
return nombre + " (" + nota + ")";
}
}
Fíjate en tres detalles:
Comparable<Alumno>lleva el tipo entre<>: asícompareTorecibe unAlumnoy no unObject, y te ahorras un cast.- Comparo con
Double.compare, que sabe comparardoublesin trampas. Más abajo verás por qué no uso una resta. - El orden natural es uno solo. Lo elige quien escribe la clase y se usa siempre que alguien ordene sin dar más datos.
Y ahora sí, el mismo programa que antes explotaba:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
public class Ordenar2 {
public static void main(String[] args) {
Alumno[] clase = {
new Alumno("Marta", 9.1),
new Alumno("Luis", 6.5),
new Alumno("Ana", 8.0)
};
Arrays.sort(clase);
System.out.println("Array ordenado: " + Arrays.toString(clase));
List<Alumno> lista = new ArrayList<>(Arrays.asList(clase));
Collections.reverse(lista);
System.out.println("Al revés: " + lista);
Collections.sort(lista);
System.out.println("Con sort: " + lista);
System.out.println("Mejor nota: " + Collections.max(lista));
System.out.println("Peor nota: " + Collections.min(lista));
}
}
Array ordenado: [Luis (6.5), Ana (8.0), Marta (9.1)] Al revés: [Marta (9.1), Ana (8.0), Luis (6.5)] Con sort: [Luis (6.5), Ana (8.0), Marta (9.1)] Mejor nota: Marta (9.1) Peor nota: Luis (6.5)
Lo importante: yo no he programado ningún algoritmo de ordenación. Arrays.sort, Collections.sort, Collections.max y Collections.min llaman por dentro a compareTo y ya está. Eso mismo hacen TreeSet y TreeMap para mantener sus elementos ordenados, como verás en la práctica.
El ClassCastException del principio era el aviso de que Arrays.sort intentó ver a mis alumnos como Comparable y no lo eran. Es un error en ejecución, no de compilación, porque Arrays.sort acepta cualquier Object[]. Por eso conviene implementar la interfaz antes de pasar la clase por un ordenador.
Y si declaras implements Comparable<Alumno> pero se te olvida escribir el método, ahí sí protesta el compilador:
Alumno.java:1: error: Alumno is not abstract and does not override abstract method compareTo(Alumno) in Comparable
public class Alumno implements Comparable<Alumno> {
^
1 error
Escribir implements Comparable<Alumno> y olvidar compareTo. Es el mismo mensaje que con cualquier interfaz: te falta cumplir el contrato. Si usas un IDE, te ofrecerá generar el método.
El contrato de compareTo (las letras pequeñas)
Escribir compareTo es fácil. Escribirlo bien exige respetar un contrato que Java no comprueba, pero del que dependen todos los algoritmos de ordenación:
- Simetría: si
a.compareTo(b)es positivo,b.compareTo(a)tiene que ser negativo (y al revés). Solo importa el signo, no el valor exacto. - Transitividad: si
ava antes quebybantes quec, entoncesava antes quec. - **Coherencia con
equals** (recomendada):a.compareTo(b) == 0debería dar el mismo resultado quea.equals(b).
La tercera es la que más gente se salta, y la que más disgustos da. Vamos a verlo. Ahora mi Alumno compara primero por nota y, si empatan, por nombre, y tiene un equals coherente con eso:
@Override
public int compareTo(Alumno otro) {
int porNota = Double.compare(this.nota, otro.nota);
if (porNota != 0) {
return porNota;
}
return this.nombre.compareTo(otro.nombre);
}
// Coherente con compareTo: iguales si y solo si compareTo da 0
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Alumno)) return false;
Alumno otro = (Alumno) o;
return nombre.equals(otro.nombre) && Double.compare(nota, otro.nota) == 0;
}
@Override
public int hashCode() {
return 31 * nombre.hashCode() + Double.hashCode(nota);
}
El desempate por nombre no es un adorno. Sin él, dos alumnos con la misma nota darían compareTo == 0 aunque sean personas distintas, y las colecciones ordenadas los tratarían como si fueran el mismo.
Mira qué pasa con un TreeSet (un conjunto que se mantiene ordenado usando compareTo o un Comparator) cuando el criterio de orden no distingue a dos alumnos distintos:
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
public class Contrato3 {
public static void main(String[] args) {
Alumno ana = new Alumno("Ana", 8.5);
Alumno luis = new Alumno("Luis", 7.0);
Alumno marta = new Alumno("Marta", 8.5);
// Simetría: el signo se invierte al cambiar el orden
System.out.println("ana vs luis: " + Integer.signum(ana.compareTo(luis)));
System.out.println("luis vs ana: " + Integer.signum(luis.compareTo(ana)));
TreeSet<Alumno> natural = new TreeSet<>();
TreeSet<Alumno> soloNota = new TreeSet<>(Comparator.comparingDouble(Alumno::getNota));
for (Alumno a : List.of(ana, luis, marta)) {
natural.add(a);
soloNota.add(a);
}
System.out.println("Natural: " + natural.size() + " " + natural);
System.out.println("Solo nota: " + soloNota.size() + " " + soloNota);
// La trampa de la resta
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)] Solo nota: 2 [Luis (7.0), Ana (8.5)] a - b = -2147483648 Integer.compare(a, b) = 1
Entre líneas:
- Las dos primeras confirman la simetría:
+1en un sentido y-1en el otro. - El
TreeSetnatural (nota y nombre) guarda los tres alumnos. El que ordena solo por nota pierde a Marta: como empata con Ana (8,5 las dos), para elTreeSetson «el mismo elemento» y la segunda inserción se descarta en silencio. Sin error, sin aviso.
Un Comparator o un compareTo que da 0 para objetos que no son iguales. En un TreeSet/TreeMap significa datos que desaparecen sin ninguna excepción. Si dos cosas pueden ser distintas, el criterio de orden tiene que distinguirlas (con un desempate).
Y las dos últimas líneas son la otra trampa clásica. Mucha gente escribe return this.nota - otro.nota pensando «negativo, cero o positivo, ¡justo lo que necesito!». Con double hay un problema de decimales, y con int hay uno peor: la resta puede desbordar. Integer.MAX_VALUE - (-1) no da un número enorme, da -2147483648, es decir, un negativo, y el orden se estropea.
No restes nunca para comparar. Usa Integer.compare(a, b), Double.compare(a, b) o a.compareTo(b) si ya son objetos (String, Integer…). Hacen lo mismo, sin trampas.
Comparator: otros criterios sin tocar la clase
Comparable da un orden natural, único y dentro de la clase. Pero a veces quieres ordenar los alumnos por nombre, o por nota de mayor a menor, o por longitud del nombre… sin cambiar la clase Alumno. Para eso existe Comparator, un criterio de orden externo:
Comparable<T> | Comparator<T> | |
|---|---|---|
| Dónde vive | Dentro de la clase | Fuera, en su propio objeto |
| Método | compareTo(T otro) | compare(T a, T b) |
| ¿Cuántos puedo tener? | Uno (el natural) | Todos los que quiera |
| Se usa con | sort(lista), TreeSet<T>() | sort(lista, comparator), new TreeSet<>(comparator) |
| ¿Hay que tocar la clase? | Sí | No |
Desde Java 8 no hace falta escribir clases enteras: hay métodos estáticos que fabrican comparadores a partir de un «getter», y se pueden encadenar con reversed() y thenComparing():
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Ordenar4 {
public static void main(String[] args) {
List<Alumno> clase = new ArrayList<>(List.of(
new Alumno("Marta", 9.1), new Alumno("Luis", 6.5),
new Alumno("Ana", 8.0), new Alumno("Pablo", 8.0), new Alumno("Eva", 9.1)));
clase.sort(null); // null = orden natural (compareTo)
System.out.println("Natural: " + clase);
clase.sort(Comparator.comparing(Alumno::getNombre));
System.out.println("Por nombre: " + clase);
clase.sort(Comparator.comparingDouble(Alumno::getNota).reversed()
.thenComparing(Alumno::getNombre));
System.out.println("Nota desc.: " + clase);
// Un Comparator "a mano": por longitud del nombre
clase.sort((x, y) -> Integer.compare(x.getNombre().length(), y.getNombre().length()));
System.out.println("Por longitud: " + clase);
}
}
Natural: [Luis (6.5), Ana (8.0), Pablo (8.0), Eva (9.1), Marta (9.1)] Por nombre: [Ana (8.0), Eva (9.1), Luis (6.5), Marta (9.1), Pablo (8.0)] Nota desc.: [Eva (9.1), Marta (9.1), Ana (8.0), Pablo (8.0), Luis (6.5)] Por longitud: [Eva (9.1), Ana (8.0), Luis (6.5), Marta (9.1), Pablo (8.0)]
Tres cosas que mirar en la salida:
sort(null)usa el orden natural (compareTo): de menor a mayor nota, y los empates, por nombre.- En «Nota desc.», Eva y Marta (9,1) van primero y entre ellas manda el nombre; lo mismo con Ana y Pablo (8,0). Ese
thenComparinges el desempate del que hablábamos antes. - El último criterio se escribe «a mano» con una lambda. Los dos nombres con la misma longitud conservan su orden relativo porque
sortes estable: no reordena lo que ya está empatado.
Si solo recuerdas una cosa de esta sección: Comparable es cómo se ordena esta clase por defecto; Comparator es cómo quiero ordenarla ahora mismo.
Cloneable: cómo se copia un objeto
Cambiamos de tema (aunque vuelve a ser una interfaz). Imagina que quieres una copia de seguridad de un alumno antes de tocarle la nota. Si escribes Alumno copia = original; no has copiado nada: has creado una segunda referencia al mismo objeto, y lo que cambies por una lo verás por la otra. Para tener un objeto distinto con los mismos datos, Java ofrece clone(), heredado de Object, con tres peculiaridades:
clone()es **protected** enObject: desde fuera no se puede llamar si tu clase no lo redefine comopublic.- Solo funciona si tu clase implementa **
Cloneable, una interfaz sin métodos** (de las llamadas interfaces marcadoras): es una etiqueta que dice «a este objeto se le permite copiarse». super.clone()lanzaCloneNotSupportedException, una excepción comprobada: tienes que capturarla o declararla.
La receta completa, para un Alumno con nombre, nota y lista de asignaturas:
import java.util.ArrayList;
import java.util.List;
public class Alumno implements Cloneable {
private String nombre;
private double nota;
private List<String> asignaturas = new ArrayList<>();
public Alumno(String nombre, double nota) {
this.nombre = nombre;
this.nota = nota;
}
public void setNota(double nota) { this.nota = nota; }
public void matricular(String asignatura) { asignaturas.add(asignatura); }
public List<String> getAsignaturas() { return asignaturas; }
@Override
public Alumno clone() {
try {
return (Alumno) super.clone(); // copia campo a campo
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // no puede pasar: implementamos Cloneable
}
}
@Override
public String toString() {
return nombre + " (" + nota + ") " + asignaturas;
}
}
Detalles de la receta:
implements Cloneable: el permiso.public Alumno clone(): redefino el método con accesopublic, y con **tipo de retornoAlumno** (se permite devolver un tipo más concreto queObject; se llama retorno covariante) para no tener que hacer cast fuera.super.clone()hace la copia real: crea un objeto nuevo y copia campo a campo.- El
try/catchexiste solo porquesuper.clone()declara la excepción. Como hemos implementadoCloneable, no puede saltar; por eso la convertimos en unAssertionError(«esto es imposible»).
Si te saltas alguno de los permisos, Java protesta de dos maneras distintas. Sin implements Cloneable, falla en ejecución:
public class Alumno { // ¡sin "implements Cloneable"!
private String nombre;
public Alumno(String nombre) {
this.nombre = nombre;
}
@Override
public Alumno clone() throws CloneNotSupportedException {
return (Alumno) super.clone();
}
}
Exception in thread "main" java.lang.CloneNotSupportedException: Alumno at java.base/java.lang.Object.clone(Native Method) at Alumno.clone(Alumno.java:10)
Olvidar implements Cloneable. super.clone() mira si la clase lleva la etiqueta y, si no la lleva, lanza CloneNotSupportedException con el nombre de la clase. Compila sin quejarse y falla al ejecutar.
Y si llamas a clone() desde fuera sin haberlo hecho public, falla al compilar:
Protegido.java:4: error: clone() has protected access in Object
Alumno copia = (Alumno) ana.clone();
^
1 error
Llamar a clone() sin haberlo redefinido como public. Recuerda: en Object es protected. No basta con implementar Cloneable; hay que sobrescribir también el método.
Copia superficial vs copia profunda
La primera vez que hice una «copia de seguridad» con clone(), cambié la nota de la copia y respiré tranquilo: el original seguía igual. Después matriculé a la copia en otra asignatura… y cuando imprimí el original, también estaba matriculado. Mi copia había cambiado al original. Esto es lo que ve Java:
public class Copia5 {
public static void main(String[] args) {
Alumno original = new Alumno("Ana", 8.0);
original.matricular("FP2");
Alumno copia = original.clone();
copia.setNota(5.0);
copia.matricular("IC2");
System.out.println("Original: " + original);
System.out.println("Copia: " + copia);
System.out.println("¿Mismo objeto? " + (original == copia));
System.out.println("¿Misma lista? " + (original.getAsignaturas() == copia.getAsignaturas()));
}
}
Original: Ana (8.0) [FP2, IC2] Copia: Ana (5.0) [FP2, IC2] ¿Mismo objeto? false ¿Misma lista? true
Léelo con calma, porque es la idea del tema:
- La nota (un
double) se ha copiado de verdad: la copia tiene 5,0 y el original conserva 8,0. - La lista de asignaturas no: original y copia apuntan a la misma lista (
trueen la última línea). Por eso «IC2» aparece en los dos.
Es lo que se llama copia superficial (shallow copy): super.clone() copia los campos tal cual, y en los campos que son referencias copia la referencia, no el objeto al que apunta. Para los tipos primitivos y los String (inmutables) eso es suficiente. Para cualquier objeto mutable (listas, arrays, otros objetos tuyos) no.
La solución es la copia profunda (deep copy): después de super.clone(), duplicas a mano lo mutable.
@Override
public Alumno clone() {
try {
Alumno copia = (Alumno) super.clone(); // copia campo a campo
copia.asignaturas = new ArrayList<>(this.asignaturas); // y una lista nueva solo para la copia
return copia;
} catch (CloneNotSupportedException e) {
throw new AssertionError(e); // no puede pasar: implementamos Cloneable
}
}
Original: Ana (8.0) [FP2] Copia: Ana (5.0) [FP2, IC2] ¿Mismo objeto? false ¿Misma lista? false
Ahora la copia lleva su propia lista: el original sigue en [FP2], y ¿Misma lista? da false.
| Campo del objeto | super.clone() | ¿Hay que hacer algo más? |
|---|---|---|
int, double, boolean… | Copia el valor | No |
String (inmutable) | Copia la referencia | No: nadie puede modificarlo |
List, Map, arrays | Copia la referencia | Sí: crear uno nuevo |
| Otro objeto tuyo mutable | Copia la referencia | Sí: clonarlo también (otro.clone()) |
Esto se encadena. Si tu Alumno tiene una Direccion mutable y quieres copia profunda, Direccion tiene que ser Cloneable a su vez, y Alumno.clone() debe llamar a direccion.clone(). La profundidad la decides tú, nivel a nivel.
Y un último detalle que te ahorrará un ClassCastException: dentro de clone() usa siempre super.clone() y nunca new Alumno(...). super.clone() crea un objeto del tipo real del que se está clonando (si es un subtipo, sigue siendo el subtipo); con new lo fuerzas a ser siempre la clase padre. En los ejercicios te encontrarás con ese fallo.
Y si clone() no te convence
Mucha gente prefiere evitar clone(): es incómodo (excepción comprobada, interfaz sin métodos, casts) y no hay forma de hacer copias profundas sin escribirlas a mano. La alternativa habitual es un constructor de copia: new Alumno(otro). Es más claro y no necesita Cloneable, aunque pierde el tipo real si hay herencia. En la práctica compararemos las dos formas lado a lado con un programa.
Errores típicos, de un vistazo
ClassCastException al ordenar. La clase no implementa Comparable (o le pasas un Comparator y se te olvidó).
compareTo con una resta. Funciona hasta que los valores son muy grandes o muy negativos; después el orden se rompe sin avisar.
Orden que da 0 para elementos distintos. En un TreeSet/TreeMap se pierden datos.
Creer que clone() copia «todo lo de dentro». Copia superficial: los objetos mutables siguen compartidos.
CloneNotSupportedException (falta Cloneable) o clone() has protected access (falta public).
Míralo paso a paso en Python Tutor
Con las referencias es muy fácil perderse, y un dibujo vale más que mil palabras. Python Tutor ejecuta el código línea a línea y dibuja las variables, los objetos y las flechas de las referencias. Elige Java, pega el programa y pulsa Visualize Execution. Esta es una versión corta del problema de la copia: en lugar de clone() usa dos métodos que hacen a mano lo que haría super.clone() (superficial) y lo que hace nuestra copia profunda:
import java.util.ArrayList;
public class Tutor {
static class Alumno {
String nombre;
ArrayList<String> asignaturas;
Alumno(String nombre) {
this.nombre = nombre;
this.asignaturas = new ArrayList<>();
}
Alumno copiaSuperficial() {
Alumno c = new Alumno(this.nombre);
c.asignaturas = this.asignaturas; // misma lista
return c;
}
Alumno copiaProfunda() {
Alumno c = new Alumno(this.nombre);
c.asignaturas = new ArrayList<>(this.asignaturas); // lista nueva
return c;
}
}
public static void main(String[] args) {
Alumno ana = new Alumno("Ana");
ana.asignaturas.add("FP2");
Alumno sup = ana.copiaSuperficial();
Alumno prof = ana.copiaProfunda();
sup.asignaturas.add("IC2");
System.out.println("Original: " + ana.asignaturas);
System.out.println("Profunda: " + prof.asignaturas);
}
}
Original: [FP2, IC2] Profunda: [FP2]
Qué observar, paso a paso:
- Tras crear
anay añadir"FP2", verás un objetoAlumnocon una flecha hacia una lista. - Al ejecutar
copiaSuperficial(), aparece un segundoAlumno… pero **su flecha deasignaturasapunta a la misma lista** que la deana. Dos objetos, una lista. - Con
copiaProfunda(), el tercerAlumnotrae una lista nueva con el mismo contenido: otra caja distinta en el dibujo. - En la línea
sup.asignaturas.add("IC2"), fíjate en que la lista que crece es la compartida: por esoanatambién cambia, yprofno.
Resumen y tabla rápida
| Quiero… | Escribo | Detalle |
|---|---|---|
| Dar un orden natural a mi clase | implements Comparable<Alumno> | compareTo devuelve negativo, 0 o positivo |
| Comparar números sin trampas | Integer.compare(a, b) / Double.compare(a, b) | Nunca una resta |
| Desempatar | if (c != 0) return c; return … | Evita que dos distintos den 0 |
| Ordenar con un criterio externo | lista.sort(comparador) | No hace falta Comparable |
| Encadenar criterios | comparing(…).reversed().thenComparing(…) | reversed() invierte lo anterior |
| Poder copiar un objeto | implements Cloneable | Interfaz marcadora, sin métodos |
Escribir clone() | public Alumno clone() { … super.clone() … } | public, super.clone() y try/catch |
| Copia profunda | copia.lista = new ArrayList<>(this.lista); | Tras super.clone(), duplica lo mutable |
Copiar sin clone() | new Alumno(otro) | Constructor de copia |
Tres ideas para quedarte:
- Para ordenar necesitas un criterio:
Comparablesi es el orden de la clase,Comparatorsi es uno más. - Un orden que da 0 para cosas distintas hace desaparecer datos en las colecciones ordenadas. Desempata siempre.
- **
clone()copia la caja, no lo que hay dentro de las cajas** que apuntan a otros sitios. Lo mutable, duplícalo tú.
Y ahora, a practicar
Con esto tienes la teoría completa. En la práctica de Comparable y Cloneable lo aplicamos con un ranking de alumnos, una comparación entre clone() y el constructor de copia, y un paseo por TreeSet y TreeMap. Y si quieres repasar antes la base, vuelve a la teoría de interfaces.
¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Un comentario