0% encontró este documento útil (0 votos)
15 vistas31 páginas

Tema 3-3

Cargado por

vj5dmdcg6t
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
0% encontró este documento útil (0 votos)
15 vistas31 páginas

Tema 3-3

Cargado por

vj5dmdcg6t
Derechos de autor
© © All Rights Reserved
Nos tomamos en serio los derechos de los contenidos. Si sospechas que se trata de tu contenido, reclámalo aquí.
Formatos disponibles
Descarga como PDF, TXT o lee en línea desde Scribd
Está en la página 1/ 31

Herencia e interfaces

En este tema, se abordarán dos conceptos básicos y fundamentales de la programación


orientada a objetos: la herencia y las interfaces. Comenzaremos explicando qué es la
herencia a través de un ejemplo y dejaremos las interfaces para más adelante.
Imagina que deseamos desarrollar un programa que registre los animales del arca de
Noé.
Para ello, es esencial registrar tanto el nombre como la especie de cada animal. Además,
queremos que todos los animales cuenten con un método que muestre el sonido
característico que producen.
A simple vista, y basándonos en lo que hemos aprendido hasta ahora, podríamos
plantearnos implementar el programa con las siguientes clases:

Pero surge una inquietud: ¿cómo logramos que diferentes animales emitan sonidos
distintos sin necesidad de crear un método específico para cada uno? Aquí es donde
entra en juego el concepto de herencia.
La herencia es un pilar fundamental de la programación orientada a objetos que permite
a una clase, denominada subclase, heredar atributos y comportamientos de otra clase,
conocida como superclase. Este principio nos posibilita la reutilización de código y la
creación de estructuras jerárquicas en nuestras clases.
Dicho de otra manera, la herencia es un mecanismo que nos permite definir una nueva
clase basándonos en una ya existente. A esta clase existente se le denomina superclase
o clase padre, mientras que la nueva clase se conoce como subclase o clase hija. La
subclase no solo hereda atributos y comportamientos (métodos) de la superclase, sino
que también puede añadir o modificar atributos y comportamientos propios.
Un método práctico y sencillo para identificar si es apropiado aplicar herencia en
programación orientada a objetos es la regla "es un" (en inglés, "is-a"). Esta regla
sugiere que si puedes describir una relación entre dos clases usando la frase "es un",
entonces la herencia puede ser adecuada. Por ejemplo, considera las clases "Perro" y
"Animal". Si podemos afirmar que "un Perro es un Animal", entonces es lógico que
"Perro" herede de "Animal". De la misma manera, al preguntarnos: ¿Una Oveja es un
Animal? La respuesta es sí, por lo que "Oveja" también debería heredar de "Animal". Sin
embargo, cuando nos preguntamos: ¿Un Animal es un Perro? La respuesta es no, ya que
"Animal" es una categoría general y "Perro" es específica. Al analizar el caso del mastín,
vemos que "Un mastín es un Animal" y también "Un mastín es un Perro", lo que indica
que "Mastín" podría ser una subclase de "Perro". Al emplear esta regla, es más fácil
determinar cuándo una subcllasificación debe heredar de una clase superior,
garantizando una estructura de código más clara y lógica.
Para representar la herencia en un diagrama de clases, empleamos una flecha con punta
hueca:

Con la base de lo explicado anteriormente, y retomando nuestro ejemplo, podríamos


representarlo de la siguiente manera:
En este diagrama, hemos introducido tres nuevas clases: Perro , Oveja y León .
Cada una de estas clases tiene un método llamado sonido() que proporciona su
respectivo sonido característico:
En la clase Perro , el método sonido() retornará "guau guau".
En la clase Oveja , el método sonido() devolverá el sonido "beee".
Por su parte, en la clase León , el método sonido() emitirá el rugido "roar",
emulando el imponente sonido que estos felinos hacen en la naturaleza.
Cuando invoquemos el método sonido() en un objeto de cualquiera de estas clases,
obtendremos el sonido específico del animal correspondiente.
Si has estado siguiendo con atención, es posible que te surja una duda: si estamos
acostumbrados a que en una lista sólo se permitan elementos de un mismo tipo de dato
o clase, ¿cómo es posible que podamos almacenar distintos tipos de animales en la
misma lista y aún así acceder a su método sonido() sin problemas? Aquí es donde
entra en juego el polimorfismo.
El polimorfismo es un principio esencial en la programación orientada a objetos que nos
permite tratar a objetos de diferentes clases como si todos pertenecieran a una misma
superclase. En este caso, todos nuestros animales pueden ser tratados como instancias
de la clase Animal . Esta flexibilidad nos permite que distintos objetos proporcionen
implementaciones específicas de un método común, dependiendo de la subclase a la
que pertenecen.

1. Implementación de Herencia
Ahora es el momento de plasmar nuestro ejemplo en código Java. Comenzaremos con la
clase Animal .
// Clase Animal.
public class Animal {
private String nombre;
private String especie;

public Animal(String nombre, String especie) {


this.nombre = nombre;
this.especie = especie;
}

// Método sonido que imprime una cadena genérica. Las subclases


pueden sobrescribir este método.
public void sonido() {
System.out.println("Sonido del animal");
}

public String getNombre() {


return nombre;
}
}
En esta clase, hemos definido dos atributos, nombre y especie . Hemos
implementado un constructor para inicializar el objeto con valores específicos para estos
atributos. Además, proporcionamos dos métodos, sonido() y getNombre() . El
método sonido() imprime un mensaje genérico representando el sonido del animal,
mientras que getNombre() devuelve el nombre del animal.
Con la superclase lista, es momento de crear una subclase. Empezaremos con la
clase Perro , que heredará de Animal . En Java, la herencia se implementa mediante
la palabra clave extends , la declaración será:
public class Perro extends Animal

1.1 Definición de atributos


Una vez heredada la clase, seguimos con el proceso estándar de creación: definir
atributos, constructor y métodos. Si creamos una clase Perro que herede de
Animal y queremos añadir un atributo raza , tendríamos:

public class Perro extends Animal {


private String raza;
}
Como puedes observar, la forma de declarar atributos es exactamente la misma.
Entonces ¿de que sirve la herencia? Pues bien, la herencia en programación orientada a
objetos nos permite que una subclase, como Perro , herede automáticamente todos
los atributos de su superclase, en este caso Animal . Esto tiene varias ventajas:
1. Reutilización de atributos y métodos: Evitas repetir en Perro lo que ya está
definido en Animal . Un objeto de la clase Perro tendrá el atributo raza , pero
también los atributos nombre y especie heredados de Animal .
2. Consistencia y uniformidad: Asegura que todas las subclases hereden los mismos
atributos y métodos, proporcionando uniformidad.
3. Modularidad: Cambios realizados en la superclase afectarán automáticamente a
todas las subclases, facilitando las actualizaciones.
4. Estructura lógica: Agrupar atributos y métodos comunes en superclases da lugar a
un diseño estructurado y comprensible.

1.2 Herencia de métodos


Antes de abordar los constructores, exploremos cómo adaptar los métodos heredados.
Al heredar podemos modificar el comportamiento de los métodos de la superclase. Esta
modificación se realiza sobrescribiendo el método, usando la anotación @Override .
Al hacerlo, aportamos una implementación distinta en la subclase para un método
previamente definido en la superclase. Es esencial que el nombre del método
sobrescrito sea idéntico al de la superclase.
Veamos un ejemplo sencillo usando el método sonido() :
public class Perro extends Animal {
private String raza;

@Override
public void sonido() {
System.out.println("guau guau");
}
}
En el código anterior, hemos sobrescrito el método sonido() en la clase Perro para
que imprima "guau guau" en lugar del mensaje genérico definido en la clase Animal .
Al usar la anotación @Override , estamos indicando explícitamente que la intención es
sobrescribir un método de la superclase, y el compilador nos advertirá si cometemos
algún error.
Por lo tanto, al emplear la herencia de métodos, no solo se facilita la reutilización de
código, sino que también se brinda la posibilidad de personalizar comportamientos. Es
importante tener en cuenta que al heredar un método, se anula completamente el
comportamiento del método original de la clase padre. Además, el método en la clase
hija debe mantener la misma firma (mismo nombre y atributos) que su contraparte en la
superclase. Por último, es altamente recomendable el uso de la etiqueta @Override al
sobrescribir.
1.3 Herencia de constructores
Por último llega el turno de los constructores. Como ya sabemos, un constructor es
similar a un método pero posee características únicas. Cuando se trata de herencia, el
proceso de manejo de constructores difiere del que hemos visto para los métodos, para
empezar no se utiliza la anotacion @Override . Evidentemente, si hemos incluido
nuevos atributos será necesario crear un constructor que permita inicializar esos
atributos. Además, de alguna forma, debemos poder ejecutar el constructor de la
superclase para inicializar también los atributos heredados. Para ello, se utiliza la
palabra clave super , que es una referencia a la superclase inmediata. super nos
permite llamar al constructor de la superclase. La sintaxis general es usar super
seguido de paréntesis que contienen los argumentos que el constructor de la superclase
espera.
Nota: En un constructor de una subclase, es obligatorio que la llamada a
super() (si se incluye) sea la primera instrucción.
Sigamos completando el ejemplo:
public class Perro extends Animal {
private String raza;

public Perro(String nombre, String especie, String raza) {


super(nombre, especie); // Llamada al constructor de la
superclase Animal
this.raza = raza;
}

@Override
public void sonido() {
System.out.println("guau guau");
}
}
Como se puede observar en el código anterior, antes de inicializar el atributo raza
específico de la clase Perro , se realiza una llamada al constructor de la clase
Animal a través de super(nombre, especie) , asegurando que los atributos
heredados sean inicializados correctamente.
En muchas ocasiones, al trabajar con herencia, queremos que la subclase asigne un
valor por defecto específico para algunos atributos heredados, en lugar de recibirlo
como parámetro en el constructor. Esta técnica puede ser útil, por ejemplo, para
establecer valores que no cambian para una subclase en particular o para definir valores
predeterminados.
Para hacerlo, simplemente hacemos una llamada al constructor de la superclase usando
super() y, en lugar de pasar un parámetro, proporcionamos el valor predeterminado
directamente.
Por ejemplo, si deseamos que la especie del Perro siempre sea su nombre científico
"Canis lupus", podemos modificar el constructor de la siguiente manera:
public class Perro extends Animal {
private String raza;

public Perro(String nombre, String raza) {


super(nombre, "Canis lupus"); // Asignamos un valor por
defecto para la especie
this.raza = raza;
}

@Override
public void sonido() {
System.out.println("guau guau");
}
}
En este código, el constructor de Perro solo acepta dos parámetros: nombre y
raza . Sin embargo, al hacer la llamada super() , asignamos directamente el nombre
científico "Canis lupus" como el valor por defecto para la especie, garantizando que
todos los objetos de la clase Perro tengan ese valor específico para el atributo
especie .

1.4 Ejemplo completo


Hasta ahora, hemos abordado todos los conceptos esenciales relacionados con la
implementación de la herencia en Java. A continuación, veamos el ejemplo del Arca de
Noé completo:
// Clase Perro que hereda de Animal.
public class Perro extends Animal {

public Perro(String nombre) {


super(nombre, "Canis lupus"); // Asignamos un valor por
defecto para la especie
}

@Override
public void sonido() {
System.out.println("guau guau"); // Sonido específico de
Perro: "guau guau"
}
}

// Clase Oveja que hereda de Animal.


public class Oveja extends Animal {

public Oveja(String nombre) {


super(nombre, "Ovis aries"); // Asignamos un valor por
defecto para la especie
}

@Override
public void sonido() {
System.out.println("beee"); // Sonido específico de Oveja:
"beee"
}
}

// Clase León que hereda de Animal.


public class Leon extends Animal {

public Leon(String nombre) {


super(nombre, "Panthera leo"); // Asignamos un valor por
defecto para la especie
}
@Override
public void sonido() {
System.out.println("roar"); // Sonido específico de León:
"roar"
}
}

Nota: Cada clase debe ser implementada en un fichero independiente con


el nombre correspondiente (e.g., Perro.java , Oveja.java ).
Como se puede observar en las clases anteriores, hemos utilizado la herencia para
extender la funcionalidad de la clase Animal y personalizarla según las características
específicas de cada animal, como Perro , Oveja y León . Es notable que cada
subclase tiene un comportamiento sonoro distinto, lo que justifica el uso del método
sobrescrito sonido() . Además, se ha asignado un valor por defecto para la especie
en cada subclase mediante el constructor. Por ejemplo, en la clase Leon , se ha
establecido "Panthera leo" como el nombre científico por defecto.
Ahora, vamos a explorar cómo se instanciarian los objetos y se usaría la herencia. En
este caso, veremos cómo se aplica desde la clase Main :
import java.util.ArrayList;

// Clase principal para probar las clases que implementan herencia.


public class Main {
public static void main(String[] args) {
// Creamos una lista de animales y añadimos un perro, una
oveja y un león.
ArrayList<Animal> animales = new ArrayList<>();
animales.add(new Perro("Boby"));
animales.add(new Oveja("Dolly"));
animales.add(new Leon("Simba"));

// Recorremos la lista y hacemos que cada animal emita su


sonido característico.
for (Animal animal : animales) {
System.out.print(animal.getNombre() + " dice: ");
animal.sonido();
}
}
}
Con esta estructura, al ejecutar la clase
Main , obtendrías una salida similar a:
Boby dice: guau guau
Dolly dice: beee
Simba dice: roar

1.5 Uso de atributos protected en la herencia


Anteriormente, mencionamos que las subclases pueden acceder a los atributos de la
superclase. Una forma de hacerlo es mediante los métodos getters y setters. Sin
embargo, existe otra opción que consiste en modificar la visibilidad de los atributos de la
superclase. En Java, el modificador protected se utiliza para indicar que un miembro
de la clase (ya sea un atributo o un método) no sólo es accesible dentro de su propia
clase, sino también en sus subclases, incluso si se encuentran en paquetes diferentes.
Al cambiar el modificador de visibilidad del atributo nombre de private a
protected , las subclases obtendrán acceso directo a este atributo.

A continuación, te muestro cómo se realizaría este cambio:


1. Modifica el modificador de visibilidad del atributo nombre en la clase Animal :
// Clase Animal.
public class Animal {
protected String nombre; // Cambiamos el modificador a
'protected'
private String especie;

// ... el resto del código sigue igual ...


}

2. En las subclases, puedes hacer referencia directa al atributo nombre sin


necesidad de usar un método getter:
// Clase Perro que hereda de Animal.
public class Perro extends Animal {

// ... el resto del código sigue igual ...

@Override
public void sonido() {
System.out.println(nombre + " dice: guau guau"); //
Accedemos directamente al atributo 'nombre'
}
}
Al cambiar el modificador de visibilidad a
protected , le estamos dando a las
subclases un acceso directo al atributo. Esto puede ser útil si queremos que las
subclases modifiquen o lean directamente el valor del atributo sin necesidad de usar un
método getter o setter. Sin embargo, es importante tener cuidado al hacer esto porque
podría romper el principio de encapsulamiento, que es uno de los pilares de la
programación orientada a objetos. El encapsulamiento nos dice que deberíamos ocultar
los detalles internos de una clase y exponer sólo lo necesario a través de métodos
públicos.
Al hacer un atributo protected , lo estamos exponiendo a todas las subclases, lo que
podría llevar a modificaciones no deseadas o inesperadas. Por lo tanto, antes de cambiar
un modificador de visibilidad, siempre es bueno preguntarse si realmente es necesario y
si no hay otra forma de lograr el mismo objetivo sin romper el encapsulamiento.
El codigo completo quedaría así:
// Clase Animal.
public class Animal {
protected String nombre; // Modificador cambiado a 'protected'
private String especie;

public Animal(String nombre, String especie) {


this.nombre = nombre;
this.especie = especie;
}

// Método sonido que imprime una cadena genérica. Las subclases


pueden sobrescribir este método.
public void sonido() {
System.out.println(nombre + " hace un sonido");
}

public String getNombre() {


return nombre;
}
}

// Clase Perro que hereda de Animal.


public class Perro extends Animal {

public Perro(String nombre) {


super(nombre, "Canis lupus"); // Asignamos un valor por
defecto para la especie
}

@Override
public void sonido() {
System.out.println(nombre + " dice: guau guau"); //
Accedemos directamente al atributo 'nombre'
}
}

// Clase Oveja que hereda de Animal.


public class Oveja extends Animal {

public Oveja(String nombre) {


super(nombre, "Ovis aries"); // Asignamos un valor por
defecto para la especie
}

@Override
public void sonido() {
System.out.println(nombre + " dice: beee"); // Accedemos
directamente al atributo 'nombre'
}
}

// Clase León que hereda de Animal.


public class Leon extends Animal {

public Leon(String nombre) {


super(nombre, "Panthera leo"); // Asignamos un valor por
defecto para la especie
}

@Override
public void sonido() {
System.out.println(nombre + " dice: roar"); // Accedemos
directamente al atributo 'nombre'
}
}

1.6 Métodos Exclusivos de Subclases


Vamos a seguir ampliando el ejemplo para entender más mecanismos que se pueden
utilizar al emplear herencia. Al igual que ocurre con los atributos, también se pueden
añadir atributos y métodos únicos a una subclase. Estos atributos y métodos serán
específicos de la subclase y no estarán presentes en la superclase. Para ilustrar esto,
vamos a añadir un atributo raza y un método personalizado en la clase Perro
llamado darPatita() . Con estas inclusiones, la clase quedaría de la siguiente
manera:
// Clase Perro que hereda de Animal.
public class Perro extends Animal {

private String raza; // Atributo personalizado para almacenar


la raza del perro.

public Perro(String nombre, String raza) {


super(nombre, "Canis lupus");
this.raza = raza; // Inicializamos el atributo raza.
}

@Override
public void sonido() {
System.out.println(nombre + " dice: guau guau");
}

// Método personalizado que permite a un perro dar la patita.


public void darPatita() {
System.out.println(nombre + " da la patita");
}
}
Es importante destacar que el método darPatita() no lleva la etiqueta @Override
debido a que no está sobrescribiendo ningún método de la superclase Animal . En
cambio, es un método exclusivo de la subclase Perro .
Para profundizar en nuestro entendimiento de la herencia, vamos a crear dos subclases
derivadas de la clase Perro : Mastin y Chihuahua .

Con este nuevo diagrama, podemos apreciar una estructura jerárquica con tres niveles
de herencia. En el nivel superior, encontramos a la clase Animal , seguida por la clase
Perro en el segundo nivel. Finalmente, en el tercer nivel, están las subclases
específicas: Mastin y Chihuahua . A continuación, presentamos el código de estas
subclases:
// Clase Mastin que hereda de Perro.
public class Mastin extends Perro {

// Constructor que recibe el nombre y establece automáticamente


la raza a "Mastín".
public Mastin(String nombre) {
super(nombre, "Mastín");
}

@Override
public void sonido() {
System.out.println(nombre + " dice: rrr-guau!"); // Un
sonido más grave y potente para el Mastín
}
}

// Clase Chihuahua que hereda de Perro.


public class Chihuahua extends Perro {

// Constructor que recibe el nombre y establece automáticamente


la raza a "Chihuahua".
public Chihuahua(String nombre) {
super(nombre, "Chihuahua");
}

@Override
public void sonido() {
System.out.println(nombre + " dice: ¡yip yip!"); // Un
sonido más agudo para el Chihuahua
}
}
En este código tenemos que:
La clase Animal es la superclase que tiene atributos básicos, como el nombre y
la especie .
La clase Perro hereda de Animal y añade un método personalizado llamado
darPatita() . Este método es exclusivo para perros, lo que significa que
cualquier subclase que derive de Perro (como Mastin o Chihuahua ) también
heredará y podrá ejecutar este método.
Las clases Mastin y Chihuahua son subclases de Perro . Ambas heredan
todas las características de la clase Perro (incluido el método darPatita() ).
Sin embargo, cada una de estas subclases redefine el método sonido() para
personalizar el ladrido según las características típicas de cada raza. El Mastín tiene
un ladrido más grave y resonante ( rrr-guau! ), mientras que el Chihuahua tiene
un ladrido más agudo ( ¡yip yip! ).
Por lo tanto, con esta estructura, todos los perros pueden "dar la patita", pero cada raza
tiene su propio ladrido distintivo que se refleja al invocar el método sonido() .
1.7 Uso de instanceof
Para emplear atributos y, especialmente, métodos personalizados de una clase en Java,
se dispone del operador instanceof . Este operador es esencial para determinar si un
objeto es una instancia de una clase específica o si pertenece a una subclase de esta.
Podemos relacionarlo con la lógica de "es un/una" al identificar el tipo de objeto con el
que trabajamos. Veámoslo con un ejemplo.
Supongamos que, en nuestra jerarquía de clases de animales, deseamos identificar qué
animales pueden volar. Podríamos tener clases como Pájaro y Murciélago que
poseen un método volar() . Sin embargo, no todas las instancias de Animal tienen
esta capacidad. Aquí es donde instanceof se vuelve valioso.
if (animal instanceof Pajaro) {
((Pajaro) animal).volar();
}
En este fragmento de código, verificamos si animal es una instancia de la clase
Pajaro . Si es así, hacemos un casting a Pajaro y llamamos al método volar() .

Aplicando la misma lógica al ejemplo de los perros, utilizamos instanceof para


determinar si un Animal es, de hecho, un Perro . Si es así, le pedimos que dé la
patita:
if (animal instanceof Perro) {
((Perro) animal).darPatita();
}
Esta condición será verdadera no solo para instancias de la clase Perro , sino también
para cualquier subclase de Perro , como Mastin o Chihuahua .
Es crucial entender que instanceof verifica el tipo real del objeto, no el tipo de
referencia. Así, si un objeto Mastin se referencia a través de una variable de tipo
Animal , instanceof aún reconocerá que es una instancia de Mastin .

Si se compara un objeto con null usando instanceof , el resultado será


false .

Se debe emplear instanceof con prudencia, ya que un uso excesivo puede


conducir a un diseño de código que no explote plenamente el potencial del
polimorfismo y la programación orientada a objetos. Es preferible diseñar el código
de tal manera que no se requiera verificar constantemente el tipo real de un objeto.
Con este conocimiento, podemos revisar nuestro ejemplo y modificar la clase Main
para incorporar las subclases Mastin y Chihuahua . Además, aprovecharemos para
mostrar el uso del método darPatita() , exclusivo de la clase Perro y sus
subclases.
import java.util.ArrayList;

// Clase principal para probar las clases que implementan herencia.


public class Main {
public static void main(String[] args) {
// Creamos una lista de animales y añadimos distintos
tipos.
ArrayList<Animal> animales = new ArrayList<>();
animales.add(new Perro("Boby", "Desconocida")); // Añadido
un segundo argumento para la raza
animales.add(new Oveja("Dolly"));
animales.add(new Leon("Simba"));
// Añadimos los nuevos tipos de animales: Mastin y
Chihuahua.
animales.add(new Mastin("Brutus"));
animales.add(new Chihuahua("Tiny"));

// Recorremos la lista y hacemos que cada animal emita su


sonido característico.
for (Animal animal : animales) {
animal.sonido(); // Llamada directa al método sonido
que ya imprime el nombre del animal.

// Usamos 'instanceof' para verificar si el animal es


un perro o una subclase de Perro.
// Si es así, hacemos que dé la patita.
if (animal instanceof Perro) {
((Perro) animal).darPatita();
}
}
}
}
Con esta estructura, al ejecutar la clase Main , obtendrías una salida similar a:
Boby dice: Boby dice: guau guau
Boby da la patita
Dolly dice: Dolly dice: beee
Simba dice: Simba dice: roar
Brutus dice: Brutus dice: rrr-guau!
Brutus da la patita
Tiny dice: Tiny dice: ¡yip yip!
Tiny da la patita

Nótese que ahora, además de los sonidos, los perros (incluidos el Mastín y el
Chihuahua) también muestran la acción de "dar la patita".
1.8 Clases Abstractas
En el ejemplo anterior, nos hemos centrado en crear clases sin considerar un aspecto
crucial. Si queremos representar un animal que no sea un Perro, Oveja o León, ¿es
posible? La respuesta es sí; nuestro código actual no impide hacerlo. Sin embargo, esto
introduce ambigüedades: ¿Qué tipo de animal es? ¿Qué sonido emite? ¿Cómo se
comporta?
Observemos el siguiente código:
Animal animalGenerico = new Animal("Desconocido", "Especie
Desconocida");
Aunque esta línea es sintácticamente correcta, no tiene mucho sentido en el contexto de
nuestro programa. Representa un animal genérico, del cual no sabemos nada más.
También podríamos intentar especificar un animal indicando manualmente su nombre y
especie. Sin embargo, esta aproximación trae un problema: no garantiza que el nombre
de la especie sea correcto. Veamos:
Animal avestruz = new Animal("Gertrudis", "Avistruz"); // Nota el
error en "Avistruz".

Nota: Aunque podríamos añadir código para comprobar errores, pronto


veremos que hay soluciones más elegantes.
Siguiendo en la misma línea, si creamos a Boby simplemente como un Animal y no
como un Perro , nos limitamos a que Boby sólo pueda usar el método sonido()
genérico de la clase Animal . Esto significa que, en lugar de emitir un ladrido
específico, Boby simplemente producirá el mensaje genérico "hace un sonido".
Animal perro = new Animal("Boby", "Perro");
Visto esto, veamos cómo se vería un ejemplo completo pero primero vamos a recordar
como esta implementada la clase Animal:
// Clase Animal.
public class Animal {
protected String nombre; // Modificador cambiado a 'protected'
private String especie;

public Animal(String nombre, String especie) {


this.nombre = nombre;
this.especie = especie;
}

// Método sonido que imprime una cadena genérica. Las subclases


pueden sobrescribir este método.
public void sonido() {
System.out.println(nombre + " hace un sonido");
}

public String getNombre() {


return nombre;
}
}
La función main sería la siguiente:
// Clase principal para probar las clases que implementan herencia.
public class Main {
public static void main(String[] args) {
// Creamos una lista de animales y añadimos distintos
tipos.
ArrayList<Animal> animales = new ArrayList<>();
animales.add(new Animal("Desconocido", "Especie
Desconocida"));
animales.add(new Animal("Gertrudis", "Avistruz"));
animales.add(new Animal("Boby", "Perro"));
// Recorremos la lista y hacemos que cada animal emita su
sonido característico.
for (Animal animal : animales) {
animal.sonido();
// Verificamos si el animal es un perro o una subclase
de Perro.
if (animal instanceof Perro) {
((Perro) animal).darPatita();
}
}
}
}
El resultado en pantalla sería:
Desconocido hace un sonido
Gertrudis hace un sonido
Boby hace un sonido

Por lo tanto tenemos que:


Se crea una lista con tres animales: "Desconocido", "Gertrudis" y "Boby".
Al recorrer la lista, cada animal emite un sonido genérico basado en el método
sonido() de la clase Animal . Por ello, se imprime el nombre del animal seguido
de "hace un sonido".
Aunque uno de los animales tiene como especie "Perro", no es una instancia de la
clase Perro , así que no realiza ninguna acción adicional. Si hubiéramos creado a
Boby como una instancia de la clase Perro , entonces Boby habría tenido acceso a
métodos específicos para perros, como un método que permitiera emitir un ladrido.
Vemos que nos encontramos con una serie de ambiguedades y para resolverlas y
ofrecer una estructura más sólida para la herencia, Java presenta las clases
abstractas . Veamos a continuación que es una clase abstracta .

Imaginemos una clase abstracta como un molde, o un concepto general, que no tiene
sentido por sí solo pero define una estructura base para sus derivados. Por ejemplo, si
pensamos en el término "animal", sabemos que es un concepto amplio y que no nos
dice nada específico. No podemos definir cómo se ve o se comporta un "animal" sin más
detalles, pero sí podemos decir que todos los animales tienen ciertas características,
como un nombre o la capacidad de emitir un sonido.
En Java, una clase abstracta representa precisamente este concepto generalizado. No
puedes crear una instancia de una clase abstracta porque es demasiado vaga o general.
En su lugar, defines subclases concretas que heredan de la clase abstracta,
proporcionando las especificaciones necesarias. Así, en lugar de tener un genérico
"animal", tendrías clases específicas como Perro , Gato o Pájaro , cada una con
sus propios comportamientos y atributos definidos.
En resumen, las clases abstractas en Java son una forma de garantizar que ciertas
clases sirvan sólo como base para otras, sin que puedan ser instanciadas directamente.
Esto nos permite establecer un esquema claro y estructurado para la herencia,
asegurando que se respeten ciertas reglas y estructuras en el diseño de nuestro código.
Importante: Las clases abstractas nos permiten definir clases que no
pueden ser instanciadas, pero que pueden ser extendidas(heredadas).
Llega el momento de implementar y afianzar el concepto de clase abstracta. Para definir
una clase como abstracta, simplemente se utiliza la palabra clave abstract antes de
la palabra class .
abstract class NombreDeLaClaseAbstracta {
// cuerpo de la clase abstracta
}
Al igual que ocurre con una clase, también se pueden definir métodos Abstractos. Un
método abstracto es un método que se declara en la clase abstracta, pero no tiene una
implementación. Es decir, declaramos el método, pero no especificamos lo que hace
(misma idea que un prototipo en lenguaje C). Las clases concretas que heredan de la
clase abstracta deberán proporcionar una implementación para estos métodos
abstractos.
La sintaxis es la siguiente:
abstract TipoDeRetorno nombreDelMetodo(parametros);

Nota: No tiene llaves { } ya que no tiene una implementación. Termina


simplemente con un punto y coma.
En terminos formales un método abstracto actúa como un "placeholder" o "marcador de
posición" en una clase abstracta. Es una forma de decir: "Cualquier clase que herede de
esta clase abstracta deberá proporcionar una implementación específica para este
método". Es una herramienta poderosa para garantizar una estructura y un
comportamiento consistentes en todas las clases derivadas.
Vamos a verlo continuando con el ejemplo anterio, vamos a hacer que la clase Animal
sea abstracta y definir un método abstracto llamado movimiento() . Las subclases
deben implementar este método para proporcionar una descripción específica del
movimiento de ese animal.
// Clase Animal ahora es abstracta.
public abstract class Animal {
protected String nombre;
private String especie;

public Animal(String nombre, String especie) {


this.nombre = nombre;
this.especie = especie;
}

public void sonido() {


System.out.println(nombre + " hace un sonido");
}

public String getNombre() {


return nombre;
}

// Método abstracto que las subclases deberán implementar.


public abstract void movimiento();
}
Ahora, cada subclase de Animal deberá implementar el método movimiento() . El
propio IDE que estemos utilizando nos mostrará un mensaje de error indicando que falta
de implementar alguno de los métodos abstractos. Incluso, muchos IDEs modernos
ofrecen una funcionalidad de auto-completado que nos permitirá incorporar la definición
del método directamente.
public class Perro extends Animal {
private String raza;

public Perro(String nombre, String raza) {


super(nombre, "Canis lupus");
this.raza = raza;
}

@Override
public void sonido() {
System.out.println(nombre + " dice: guau guau");
}

public void darPatita() {


System.out.println(nombre + " da la patita");
}

// Implementación del método movimiento para la clase Perro.


@Override
public void movimiento() {
System.out.println(nombre + " corre alegremente.");
}
}
Este proceso se tendrá que repetir para cada subclase de Animal , y por ello, el IDE
nos lo recordará mostrando un error en cada clase que heredemos de la superclase
abstracta.
Como ya hemos definido la clase Animal como abstracta, si en la clase Main ,
intentamos hacer algo como esto:
Animal animalGenerico = new Animal("Genérico", "Desconocido"); //
Esto causará un error de compilación.
El código arrojará un error de compilación ya que no se puede instanciar un objeto de
una clase abstracta.
Recordemos nuestro ejemplo anterior donde teníamos a "Boby" definido simplemente
como un Animal . Si intentáramos hacer algo similar ahora, nos encontraríamos con el
problema de que no podríamos instanciar a Animal directamente debido a su
naturaleza abstracta. En cambio, Boby ahora necesariamente tendría que ser una
instancia de la clase Perro o de cualquier otra subclase concreta que derive de
Animal . Esto nos da la ventaja de que cuando Boby emita un sonido, específicamente
ladrará ("guau guau"), y no se limitará al comportamiento genérico previo. Además, con
la introducción del método abstracto movimiento() , nos aseguramos de que Boby,
como perro, tiene una manera específica de moverse: corriendo alegremente. Esto
refuerza la idea de que al hacer la clase Animal abstracta, no sólo estamos evitando
ambigüedades en la representación de animales, sino que también estamos
garantizando comportamientos específicos y consistentes para cada subclase de
Animal .

En conclusión, las clases y métodos abstractos en Java representan la esencia de la


programación orientada a objetos, proporcionando estructuras y patrones definidos que
las subclases deben seguir. Al restringir la instanciación y requerir que las subclases
implementen comportamientos específicos, estas herramientas refuerzan una
organización coherente y una especialización clara en el diseño del código. A través de
ellas, se garantiza que la herencia se maneje de forma estructurada y que cada entidad
en la jerarquía de clases tenga un propósito y comportamiento claramente definidos.

2. Interfaces
En la programación orientada a objetos con Java, hemos explorado extensamente el
concepto de herencia, donde una clase puede heredar atributos y métodos de otra. Sin
embargo, existen situaciones donde la herencia no es la herramienta más adecuada. Es
aquí donde entran en juego las interfaces.
Las interfaces en Java son fundamentales cuando necesitamos implementar
comportamiento específico en múltiples clases, sin la necesidad de heredar atributos.
Sería similar a disponer de una clase abstracta pero sin atributos; en este caso, es más
conveniente utilizar una interfaz en lugar de la herencia. Las interfaces se centran en
qué debe hacer una clase, más que en cómo debe hacerlo, lo que permite una mayor
flexibilidad y reutilización del código.
Un escenario común en el que las interfaces son particularmente útiles es cuando una
clase necesita incorporar comportamientos de múltiples fuentes. A diferencia de
algunos lenguajes de programación, Java no permite la herencia múltiple (es decir, una
clase no puede heredar directamente de más de una clase). Las interfaces ofrecen una
solución elegante a este límite, permitiendo que una clase implemente múltiples
interfaces y, por lo tanto, combine varios comportamientos de manera efectiva.
Por lo tanto, una interfaz en Java es una especie de contrato que define un conjunto de
métodos abstractos (sin cuerpo). Cuando una clase implementa una interfaz, está
obligada a proporcionar implementaciones concretas de todos los métodos abstractos
declarados en la interfaz.
Esto añade una capa adicional de flexibilidad y capacidad de extensión a las interfaces,
permitiendo que definan un comportamiento más sofisticado y establezcan una jerarquía
entre ellas, similar a la herencia de clases.
Es importante señalar que una interfaz no puede contener atributos de estado (excepto
constantes estáticas y finales) ni implementaciones de métodos (aunque Java 8 y
versiones posteriores introdujeron métodos por defecto y estáticos en interfaces). Las
diferencias clave entre herencia e interfaces son:
1. Naturaleza del Contrato: Mientras que la herencia establece una relación "es un
tipo de", las interfaces definen un contrato de "tiene capacidades de". Esto significa
que las clases que implementan una interfaz pueden tener diferentes formas, pero
todas comparten ciertas capacidades.
2. Herencia Múltiple: Las interfaces permiten que una clase implemente múltiples
interfaces, lo que promueve una mayor flexibilidad y reutilización del código.
3. Métodos y Atributos: Las clases abstractas pueden tener métodos con
implementación y atributos de estado, mientras que las interfaces tradicionalmente
solo tienen métodos abstractos y constantes estáticas finales.
4. Implementación versus Extensión: Al usar interfaces, las clases implementan
métodos definidos, en contraste con las clases heredadas que extienden y pueden
modificar el comportamiento heredado.
Al implementar interfaces, las clases asumen un compromiso más enfocado y flexible,
que les permite adoptar múltiples roles y responsabilidades definidas por sus interfaces
sin las restricciones de la herencia de una sola clase.
Además, una interfaz puede:
Definir métodos abstractos, lo que significa que puede establecer la firma de un
método sin proporcionar una implementación. Cualquier clase que implemente la
interfaz deberá proporcionar su propia implementación de estos métodos.
Definir constantes static , que son variables fijas que no cambiarán con una
instancia de una clase y que pueden ser compartidas entre todas las instancias.
Implementar métodos static , lo cual es una característica introducida en Java 8,
que permite a las interfaces proporcionar implementaciones completas que no
pueden ser sobrescritas por las clases implementadoras.
Heredar de otra interfaz, permitiendo la creación de una jerarquía de interfaces. Una
interfaz puede extender otra, heredando sus métodos, lo que permite una forma de
herencia múltiple que no está disponible con las clases.
Esta flexibilidad hace que las interfaces sean una herramienta poderosa en Java,
proporcionando una forma de abstracción que facilita la separación de la definición de
las operaciones de sus implementaciones concretas, permitiendo así un diseño de
software más limpio y modular.
2.1 Implementación de Interfaces
Antes de sumergirnos en un ejemplo práctico, es importante entender la sintaxis básica
para la creación de una interfaz en Java. Una interfaz se define utilizando la palabra
clave interface , seguida del nombre de la interfaz. Los métodos dentro de una
interfaz son implícitamente abstractos y públicos. La sintaxis general es la siguiente:
public interface NombreInterfaz {
// Métodos abstractos
tipoRetorno nombreMetodo(parametros);
// ...
}
Ahora, volvamos a nuestro ejemplo del Arca de Noé, pero esta vez vamos a abordarlo
utilizando interfaces. Recordemos que las interfaces son ideales para definir
comportamientos comunes entre clases que no están necesariamente relacionadas a
través de la herencia.
Para este ejemplo, definiremos una interfaz ComportamientoAnimal que especificará
ciertos comportamientos que diferentes animales pueden tener.
// Interfaz ComportamientoAnimal.
public interface ComportamientoAnimal {
void sonido();
void mover();
}
En esta interfaz, hemos definido dos métodos abstractos, sonido() y mover() , que
cualquier clase que implemente esta interfaz deberá definir. Al implementar una interfaz,
utilizamos la palabra clave implements . Veamos ahora como implementar la interfaz
ComportamientoAnimal en la clase Perro en código:

public class Perro extends Animal implements ComportamientoAnimal {


private String raza;

public Perro(String nombre, String especie, String raza) {


super(nombre, especie);
this.raza = raza;
}

@Override
public void sonido() {
System.out.println("Guau Guau");
}

@Override
public void mover() {
System.out.println("Corre rápidamente");
}
}
Al implementar la interfaz ComportamientoAnimal , la clase Perro ahora debe
proveer implementaciones concretas para los métodos sonido() y mover() . Esto
demuestra varios aspectos clave de las interfaces, como la especificación de
comportamiento, la flexibilidad y modularidad, la separación de responsabilidades, y el
contrato de implementación.
Nota: Al igual que en el caso de la herencia, cuando una clase en Java
implementa una interfaz pero no proporciona implementaciones concretas
para todos los métodos abstractos definidos en dicha interfaz, el entorno
de desarrollo integrado (IDE) marcará esto como un error de compilación.
El IDE puede incluso ofrecer asistencia para generar automáticamente los
cuerpos básicos de estos métodos. Es importante recordar que para evitar
errores de compilación, cada método abstracto de la interfaz debe ser
implementado en la clase que la implementa.
Sin embargo, esto provoca que hayamos perdido parte de la información que teníamos
en la clase abstracta Animal , ya que solo tenemos el comportamiento, no las
características de cada animal. Es decir, no podemos acceder a los atributos nombre ni
especie . Si queremos intentar mantenerlos, deberemos hacer un apaño creando
funcionalidades que obliguen a retornar estos valores. Como por ejemplo creando la
siguiente interfaz:
// Interfaz Animal.
public interface Animal {
String getEspecie();
String getNombre();
}
Ahora veamos cómo se implementa esta interfaz junto con ComportamientoAnimal
en la clase Perro :
public class Perro implements Animal, ComportamientoAnimal {
private String nombre;
private String especie;
private String raza;
public Perro(String nombre, String especie, String raza) {
this.nombre = nombre;
this.especie = especie;
this.raza = raza;
}

@Override
public void sonido() {
System.out.println("Guau Guau");
}

@Override
public String getEspecie() {
return especie;
}

@Override
public String getNombre() {
return nombre;
}

@Override
public void mover() {
System.out.println("Corre rápidamente");
}

// Método adicional para obtener la raza del perro


public String getRaza() {
return raza;
}
}
Con esta estructura, aseguramos que, aunque Perro ya no herede atributos
directamente como en la herencia tradicional, todavía se ve obligado a proporcionar esa
información a través de la implementación de los métodos definidos en la interfaz
Animal . Esto nos permite mantener una estructura coherente y una interfaz clara para
cualquier clase que represente a un animal, ya sea en el Arca de Noé o en cualquier otro
contexto de la programación orientada a objetos.
Es crucial observar en nuestro ejemplo que la clase Perro no solo implementa la
interfaz ComportamientoAnimal , sino que también implementa la interfaz Animal .
Esto demuestra la capacidad de Java para soportar la implementación de múltiples
interfaces, permitiendo que una clase herede comportamientos y contratos de
diferentes fuentes. Al implementar ambas interfaces, Perro se compromete a
proporcionar implementaciones concretas para todos los métodos abstractos definidos,
combinando así la información esencial y los comportamientos específicos en una única
clase. Esta característica subraya la potencia y versatilidad de las interfaces en Java,
ofreciendo un marco flexible para el diseño de software orientado a objetos, donde una
entidad puede asumir múltiples roles y responsabilidades.
Habiendo explorado la definición y aplicación de interfaces en Java, es importante
destacar que Java proporciona una serie de interfaces habituales o "estándar" que son
ampliamente utilizadas en el desarrollo de software. Estas interfaces, como
Comparable , Comparator , Cloneable , e Iterable , ofrecen funcionalidades
predefinidas que pueden ser implementadas en nuestras clases para enriquecerlas con
capacidades de ordenación, comparación, clonación y iteración. En las siguientes
secciones, examinaremos cómo utilizar estas interfaces estándar, proporcionando
ejemplos prácticos y discutiendo las mejores prácticas para su implementación. Esto
nos permitirá aprovechar al máximo las herramientas que Java nos ofrece para crear
código más eficiente, flexible y modular.
2.2. La Interfaz Comparable
La interface Comparable en Java es crucial para definir un "orden natural" para los
objetos de una clase. Esto es especialmente útil cuando necesitas ordenar colecciones
de objetos, como listas o arrays. La implementación de esta interfaz permite que los
objetos de una clase se comparen entre sí, lo cual es fundamental para operaciones de
ordenación y búsqueda.
Para implementar Comparable , una clase debe definir el método compareTo . Este
método devuelve un entero que indica la relación de este objeto con el objeto
especificado:
Un valor negativo indica que este objeto es menor que el objeto especificado.
Un valor cero indica que ambos objetos son iguales.
Un valor positivo indica que este objeto es mayor que el objeto especificado.
Veamos a continuación la sintaxis:
public class MiClase implements Comparable<MiClase> {
private int algunValor;

// Constructor, getters y setters

@Override
public int compareTo(MiClase otro) {
return Integer.compare(this.algunValor, otro.algunValor);
}
}
Supongamos ahora que tenemos una clase Estudiante con atributos como nombre
y puntuacion . Queremos ordenar los estudiantes por su puntuación.
public class Estudiante implements Comparable<Estudiante> {
private String nombre;
private int puntuacion;

// Constructor, getters y setters


@Override
public int compareTo(Estudiante otroEstudiante) {
return Integer.compare(this.puntuacion,
otroEstudiante.puntuacion);
}
}
A la hora de crear una lista de estudiantes, con la clase
Estudiante implementando
Comparable , podemos ordenar fácilmente una lista de ellos:

ArrayList<Estudiante> estudiantes = new ArrayList<>();


// Agregar estudiantes a la lista

Collections.sort(estudiantes);
La llamada a
Collections.sort ordenará la lista de estudiantes en base a su
puntuación.
Mientras que Comparable define un orden natural para los objetos, a veces
necesitamos una ordenación diferente para el mismo tipo de objetos. En estos casos,
podemos usar la interface Comparator , que veremos a continuación, que permite
definir un orden específico sin alterar la implementación de Comparable en la clase.
Continuando con nuestra exploración de interfaces en Java, pasamos ahora a otra
herramienta fundamental en el ámbito de la comparación de objetos: la interface
Comparator .

2.3. La Interfaz Comparator


La interface Comparator en Java es una herramienta esencial cuando necesitamos
definir una estrategia de comparación personalizada para los objetos de una clase,
particularmente en situaciones donde el "orden natural" establecido por Comparable
no es adecuado o cuando queremos ordenar objetos de clases que no implementan
Comparable .

Comparator es una interfaz funcional que define un método compare , que compara
dos objetos para determinar su orden. Este método devuelve:
Un valor negativo si el primer objeto es menor que el segundo.
Cero si ambos objetos son iguales.
Un valor positivo si el primer objeto es mayor que el segundo.
La sistansis de implementación de la interzar Comparator sería la siguiente:
import java.util.Comparator;

public class MiComparador implements Comparator<MiClase> {


@Override
public int compare(MiClase obj1, MiClase obj2) {
// Lógica de comparación personalizada
}
}
Consideremos nuevamente nuestra clase Estudiante , pero esta vez queremos
ordenarlos por su nombre en lugar de su puntuación.
import java.util.Comparator;

public class ComparadorNombreEstudiante implements


Comparator<Estudiante> {
@Override
public int compare(Estudiante est1, Estudiante est2) {
return est1.getNombre().compareTo(est2.getNombre());
}
}
Podemos usar nuestro
ComparadorNombreEstudiante para ordenar una lista de
estudiantes:
ArrayList<Estudiante> estudiantes = new ArrayList<>();
// Agregar estudiantes a la lista

estudiantes.sort(new ComparadorNombreEstudiante());
La principal ventaja de Comparator sobre Comparable es su flexibilidad. Podemos
definir múltiples estrategias de comparación para una misma clase sin modificar la clase
en sí. Esto es especialmente útil en bibliotecas y aplicaciones donde no tenemos control
sobre las clases de los objetos que necesitamos ordenar.
2.4. Combinando Comparable y Comparator
Java ofrece la posibilidad de combinar la interfaz Comparable , que establece un orden
natural para los objetos de una clase, con la interfaz Comparator , que permite definir
estrategias de comparación personalizadas. Este enfoque híbrido es especialmente
poderoso cuando queremos ordenar objetos basándonos en múltiples atributos.
Imagina que queremos ordenar una lista de estudiantes primero por su puntuación y, en
caso de empate, por su nombre. Podemos hacerlo implementando Comparable en
nuestra clase Estudiante y usando Comparator dentro del método compareTo
para definir una lógica de comparación compuesta. Empecemos definiendo los dos
comparadores:
import java.util.Comparator;

// Comparador por puntuación


class ComparadorPuntuacion implements Comparator<Estudiante> {
@Override
public int compare(Estudiante est1, Estudiante est2) {
return Integer.compare(est1.getPuntuacion(),
est2.getPuntuacion());
}
}

// Comparador por nombre


class ComparadorNombre implements Comparator<Estudiante> {
@Override
public int compare(Estudiante est1, Estudiante est2) {
return est1.getNombre().compareTo(est2.getNombre());
}
}
A continuación, veamos cómo podemos implementar la clase
Estudiante para utilizar
estos comparadores, aplicando la potente función thenComparing para combinarlos
en un orden compuesto.
import java.util.Comparator;

public class Estudiante implements Comparable<Estudiante> {


private String nombre;
private int puntuacion;

public Estudiante(String nombre, int puntuacion) {


this.nombre = nombre;
this.puntuacion = puntuacion;
}

// Getters y setters omitidos por brevedad

@Override
public int compareTo(Estudiante otroEstudiante) {
Comparator<Estudiante> comparador = new
ComparadorPuntuacion().thenComparing(new ComparadorNombre());
return comparador.compare(this, otroEstudiante);
}
}
El métodothenComparing es un mecanismo refinado que permite a
Comparator
encadenar múltiples criterios de comparación. Actuando cuando dos objetos son
considerados iguales por el comparador principal, thenComparing procede a utilizar
un comparador secundario para establecer un orden definitivo. Este encadenamiento
puede ampliarse a más comparadores mediante métodos adicionales que ofrecen
Comparator , como reversed() , thenComparingInt , thenComparingLong ,
thenComparingDouble , comparing , y comparing con un comparador clave,
cada uno adaptado para distintas necesidades y escenarios de comparación.
Estos métodos no solo permiten una ordenación robusta y eficiente sino que también
contribuyen a un código más limpio y expresivo. Su habilidad para combinar múltiples
comparadores facilita el diseño de clases y algoritmos de ordenación que son claros,
mantenibles y adaptados a la lógica de negocio.
Con la implementación de compareTo en la clase Estudiante , la ordenación de una
lista de estudiantes utilizando Collections.sort resultará en una lista ordenada
primero por puntuación y, en caso de empates, por nombre.
ArrayList<Estudiante> estudiantes = new ArrayList<>();
// Agregar estudiantes a la lista

Collections.sort(estudiantes);
Combinar Comparable con Comparator de esta manera proporciona una gran
flexibilidad, permitiéndonos reutilizar comparaciones definidas y construir lógicas de
ordenación complejas sin modificar la clase subyacente. Esto es útil para mantener el
código limpio y coherente, a la vez que se ofrecen poderosas capacidades de
ordenación.
2.5. La Interfaz Iterable
La interface Iterable en Java es una parte fundamental del framework de
colecciones, ya que permite recorrer una colección de objetos de manera sencilla y
concisa. Esta interfaz es lo que hace posible el uso del bucle for-each en Java, que
puede iterar sobre cualquier objeto que implemente Iterable .
La interfaz Iterable garantiza que una clase tiene el método iterator() , que
devuelve un Iterator . El Iterator es otro objeto que proporciona métodos para
navegar a través de una colección de objetos y para eliminar elementos de dicha
colección durante la iteración.
Para que una clase sea Iterable , debe implementar el método iterator() :
public class MiColeccion<T> implements Iterable<T> {
private List<T> lista = new ArrayList<>();

// Implementación de métodos de la colección omitida por


brevedad

@Override
public Iterator<T> iterator() {
return lista.iterator();
}
}
En este ejemplo, MiColeccion es una clase genérica que almacena sus elementos en
List<T> . Al implementar Iterable , permite a los usuarios de la clase utilizar el
bucle for-each para recorrer sus elementos.
Con Iterable , recorrer MiColeccion se convierte en una tarea sencilla:
MiColeccion<Estudiante> estudiantes = new MiColeccion<>();
// Agregar estudiantes a la colección
for (Estudiante est : estudiantes) {
// Procesar cada estudiante
}
Este bucle for-each es posible porque
MiColeccion implementa Iterable , y por
cada elemento de la colección, el bucle for-each recupera su Iterator y lo utiliza
para iterar a través de los elementos.
Además del método iterator() , Iterable incluye métodos por defecto como
forEach y spliterator :

forEach(Consumer<? super T> action) : Permite ejecutar una acción para


cada elemento de la colección.
spliterator() : Crea un Spliterator sobre los elementos de la colección,
que es útil para dividir la colección y realizar operaciones en paralelo.
La interface Iterable es esencial para trabajar con el framework de colecciones de
Java y para escribir clases que se integren de manera limpia y natural con los bucles for-
each. Su implementación permite que cualquier colección de objetos sea fácilmente
accesible a través de una sintaxis simplificada, favoreciendo la claridad y reduciendo la
posibilidad de errores.
2.6. La Interfaz Cloneable
Java ofrece la interfaz Cloneable como una forma de indicar que una clase permite la
clonación, es decir, la creación de copias de campo por campo de sus instancias.
Implementar Cloneable no añade nuevos métodos a una clase, pero sí afecta el
comportamiento del método clone() heredado de la clase Object . Las clases que
no implementan Cloneable y que invocan clone() resultarán en una
CloneNotSupportedException .

Para habilitar la clonación de objetos en una clase, esta debe implementar la interfaz
Cloneable y sobrescribir el método clone() . Por ejemplo, una clase MiClase que
implementa Cloneable podría sobrescribir clone() de la siguiente manera:
public class MiClase implements Cloneable {
private int dato;

public MiClase(int dato) {


this.dato = dato;
}

// Método clone sobrescrito


@Override
public MiClase clone() {
try {
return (MiClase) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // Can never happen
}
}
}
Esta implementación permite la clonación superficial de una instancia de MiClase ,
pero si la clase contiene referencias a otros objetos, estas referencias se copiarán en
lugar de los objetos a los que apuntan. En situaciones donde se requiere una clonación
más profunda, que copia los objetos referenciados también, se necesita una
implementación más compleja del método clone() .
La clonación superficial versus la clonación profunda es un aspecto importante a
considerar. La clonación superficial, realizada por defecto por el método clone() de
Object , simplemente copia los valores de los campos. Si un campo es un objeto,
copiará la referencia, no el objeto en sí. Por otro lado, la clonación profunda implica
copiar no solo el objeto sino también los objetos a los que se refiere, lo que puede
requerir una implementación manual del método clone() .
Crear una copia de una instancia de MiClase es sencillo con Cloneable . Se puede
hacer simplemente llamando al método clone() :
MiClase original = new MiClase(10);
MiClase copia = original.clone();
A pesar de la aparente sencillez, el uso de
Cloneable conlleva ciertos problemas. No
hay un método clone() en la interfaz Cloneable , por lo que no se garantiza que
cualquier tipo que implemente Cloneable tenga un método clone() accesible.
Además, el mecanismo de clonación predeterminado es superficial y podría no ser
adecuado para clases que contienen objetos complejos o colecciones. También está el
problema de la excepción CloneNotSupportedException , que debe ser manejada
aunque en realidad nunca se lanzará para clases que implementan Cloneable .
Debido a estos problemas, muchos desarrolladores prefieren utilizar constructores de
copia, fábricas o bibliotecas de terceros para la duplicación de objetos en lugar de
depender de la interfaz Cloneable . La correcta gestión de la clonación profunda y el
manejo adecuado de los recursos son fundamentales al considerar la implementación de
Cloneable en una clase.

© 2023 Rubén Martín García. Universidad Pontificia de Salamanca.

También podría gustarte