Programación Avanzada

Programación Orientada a Objetos

Interfaces

Introducción

Eres capaz definir clases, y sabes que estas clases definen nuevos tipos de datos.

Eres capaz de crear nuevos objetos a partir de clases, y utilizar sus métodos para trabajar con ellos.

En este capítulo vamos a ver una nueva construcción del lenguaje de programación Java, las interface.

Con las interface creamos nuevos tipos de datos, pero esta vez los tipos son abstractos, no poseen ninguna implementación.

La potencia de este mecanismo es increíble, una referencia a un objeto que implemente una interface lo podemos ver como de este tipo abstracto. Y podemos manejar clases que no están relacionadas entre sí a través de este tipo abstracto.

Bibliografía

Contenidos

  1. Ejemplo introductorio.
  2. La anotación @Override.
  3. Principio DRY: Don't Repeat Yourself.
  4. Las interfaces son tipos de datos.
  5. Implementación de varias interfaces.
  6. Polimorfismo utilizando interface.
  7. Vinculación Dinámica.
  8. Reescritura de interfaces.
  9. Novedades en Java 8.
  10. Resumen.

Ejemplo introductorio

Me gusta cocinar.

Preparemos una paella.

public class Paella {
    public void preparaPaella() {
        System.out.println("Lava y trocea la verdura.");
        System.out.println("Corta el pollo.");
        System.out.println("Fríe el pollo.");
        System.out.println("Añade la verdura");
        System.out.println("Añade agua.");
        System.out.println("Deja hervir");
        System.out.println("Añade el arroz.");
        System.out.println("Espera 20 minutos.");
        System.out.println("Sirve en un plato.");
    }
}

Ejemplo introductorio

Ahora vamos a cocinar la paella.

private void preparaReceta(Paella paella) {
    paella.preparaPaella();
}

private void aCocinar() {
   Paella paella = new Paella();
   preparaReceta(paella);
}

Fíjate que la referencia paella en el método aCocinar es del mismo tipo que el objeto que estamos creando.

Fíjate, que la referencia del método preparaReceta(Paella paella) también es de tipo Paella.

Ejemplo introductorio

Me está entrando hambre. Cocinemos una lubina al horno.

public class LubinaAlHorno {
    public void preparaLubina() {
        System.out.println("Extrae las vísceras.");
        System.out.println("Desescama.");
        System.out.println("Lava y trocea en gajos un limón.");
        System.out.println("Haz varios cortes en el lomo.");
        System.out.println("Introduce en cada corte un gajo de limón.");
        System.out.println("Mete la lubina en el horno.");
        System.out.println("Espera 20 minutos.");
        System.out.println("Sirve en un plato.");
        System.out.println("Acompáñala con ensalada.");
    }
}

Ejemplo introductorio

Cocinemos ahora la lubina.

    private void preparaReceta(Paella paella) {
        paella.preparaPaella();
    }
    private void preparaReceta(LubinaAlHorno lubinaAlHorno) {
        lubinaAlHorno.preparaLubina();
    }

    private void aCocinar() {
        Paella paella = new Paella();
        preparaReceta(paella);
        LubinaAlHorno lubinaAlHorno = new LubinaAlHorno();
        preparaReceta(lubinaAlHorno);
    }

Hemos utilizado la sobrecarga para escribir un método con el mismo nombre pero argumentos de distinto tipo.

Ejemplo introductorio

Espera, espera, ¿me quieres decir que cada vez que quiera preparar una nueva receta tengo que añadir un nuevo método sobrecargado? ¿Seguro que no hay una forma mejor de resolverlo?

Yo sé preparar recetas, tanto si es paella como si es lubina al horno, tan sólo sigo los pasos de la receta, me da igual que receta concreta estoy preparando.

Ejemplo introductorio

Empecemos por unificar el nombre de los métodos en los objetos Paella y LubinaAlHorno.

public class Paella {
    public void preparaReceta() {
        System.out.println("Lava y trocea la verdura.");
        ....
}
public class LubinaAlHorno {
    public void preparaReceta() {
        System.out.println("Extrae las vísceras.");
        ....
}

Ejemplo introductorio

¿Cómo nos queda la elaboración de las recetas ahora?

    private void preparaReceta(Paella receta) {
        receta.preparaReceta();
    }
    private void preparaReceta(LubinaAlHorno receta) {
        receta.preparaReceta();
    }

    private void aCocinar() {
        Paella paella = new Paella();
        preparaReceta(paella);
        LubinaAlHorno lubinaAlHorno = new LubinaAlHorno();
        preparaReceta(lubinaAlHorno);
    }

Se parecen mucho solo cambia el tipo de los argumentos.

Ejemplo introductorio

Sería fantástico si de algún modo pudiésemos indicar que la Paella y la LubinaAlHorno pueden verse como la misma cosa abstracta a efectos de uso de referencias, y utilizar el tipo concreto cuando llamamos a los métodos.

Este es precisamente el cometido de las interfaces.

Las interfaces son tipos de datos abstractos donde se declaran métodos que las clases deben implementar.

Veamoslo con el ejemplo de las recetas.

Ejemplo introductorio

Creemos un interface con el comportamiento común a las dos clases:

public interface Receta {
    void preparaReceta();
}

Ahora indicamos que la Paella implementa este interface.

public class Paella implements Receta {
    @Override
    public void preparaReceta() {
        System.out.println("Lava y trocea la verdura.");
        ...

Fíjate en el uso de la anotación @Override.

Ejemplo introductorio

public class Paella implements Receta {
    @Override
    public void preparaReceta() {
        System.out.println("Lava y trocea la verdura.");
        ...

Hagamos que LubinaAlHorno también implemente el interface.

public class LubinaAlHorno implements Receta {
    @Override
    public void preparaReceta() {
        System.out.println("Extrae las vísceras.");
        ...

Fíjate de nuevo en el uso de la anotación @Override.

Ejemplo introductorio

¿Cómo cocinamos ahora?

private void preparaReceta(Receta receta) {
    receta.preparaReceta();
}

private void aCocinar() {
    Receta receta = new Paella();
    preparaReceta(receta);
    receta = new LubinaAlHorno();
    preparaReceta(receta);
}

Fíjate que ahora sólo necesitamos un método para cocinar todas las recetas.

Ejemplo introductorio

O de una manera más concisa:

private void preparaReceta(Receta receta) {
    receta.preparaReceta();
}

private void aCocinar() {
    preparaReceta(new Paella());
    preparaReceta(new LubinaAlHorno());
}

Dos puntos importantes:

  1. Da igual la receta, el método que utilizo es preparaReceta(Receta receta)
  2. Cada clase implementa de modo distinto el método preparaReceta() definido en el interface Receta.

La anotación @Override

Hemos visto cómo marcar en la definición de las clases los métodos que se definen y que están declarados en el interface, usando la anotación @Override.

Veamos ahora por qué es tan útil.

Supón que nos despistamos y el método preparaLaReceta no está declarado en la interfaz. Si escribimos el método en la clase, podemos pensar qued estamos definiendo el método declarado en la interfaz, pero no sería así.

Si no utilizamos la anotación @Override no hubiésemos obtenido ningún error en la clase Paella.

Si utilizamos la anotación @Override le estamos indicando al compilador que queremos sobrescribir un método que debe estar declarado en alguna interface de los que implementa la clase. Y de no ser así debemos obtener un error.

Principio DRY: Don't Repeat Yourself

Lo que hemos hecho en el ejemplo anterior es utilizar, sin conocerlo, el principio DRY: Don't Repeat Yourself. Cuando aparece código repetido, hay que utilizar alguna técnica para eliminarlo.

En nuestro caso teníamos dos método que sólo se diferenciaban en el tipo de dato de su único argumento. El modo de fusionar ambos métodos ha sido definir una interface común a ambos tipos, y utilizarlo como argumento del método.

Sencillo. Muy potente.

Principio DRY: Don't Repeat Yourself

Nuestro punto de partida (utilizando sobrecarga):

private void preparaReceta(Paella paella) {
    paella.preparaReceta();
}
private void preparaReceta(LubinaAlHorno lubinaAlHorno) {
    lubinaAlHorno.preparaReceta();
}

Hemos llegado a (utilizando polimorfismo):

private void preparaReceta(Receta receta) {
    receta.preparaReceta();
}
            

Las interfaces son tipos de datos

Al igual que las clases definen tipos concretos, las interfaces definen tipos abstractos de datos.

Como ya hemos visto, en Java una interface se define del siguiente modo:

[modificador acceso] interface nombreDelInterface {
    [public static final] [tipo] constante = valor;
    [public abstract] [tipo] nombreDelMetodo(argumentos);
}

Pueden formar parte de una interface:

  • Definición de constantes.
  • Declaración de métodos.

Las interfaces son tipos de datos

Las clases implementan (implements) interface.

La clase que implementa una interface debe definir todos los métodos declarados en la interface.

Por defecto, los métodos declarados en una interface son public abstract.

Por defecto, todas las constantes definidas en una interface son public static final.

Para asegurarnos que un método de la clase está definiendo un método declarado en la interface utilizamos la anotación @Override sobre el método.

Las interfaces son tipos de datos

Veamos un ejemplo:

public interface Calificador {
    [public final static] float APROBADO = 5;
    [public final static] float NOTABLE = 7;
    [public final static] float SOBRESALIENTE = 9;
    [public final static] float MATRICULA_HONOR = 10;
    [public abstract] float califica();
    [public abstract] void addNota(float nuevaNota);
}

Los métodos califica() y addNota(float nuevaNota) los debe definir cualquier clase que implemente este interface.

Las interfaces son tipos de datos

Una clase que lo implementa:

public class CalificadorUniforme implements Calificador {
    public final static int NUMERO_MAXIMO_NOTAS = 10;
    private float[] notas = new float[NUMERO_MAXIMO_NOTAS];
    private int numeroNotas = 0;
    @Override
    public float califica() {
        float suma = 0;
        for(int i = 0; i < numeroNotas; i++) suma += notas[i];
        return suma/numeroNotas;
    }
    @Override
    public void addNota(float nuevaNota) {
        if(numeroNotas < NUMERO_MAXIMO_NOTAS) notas[numeroNotas++] = nuevaNota;
    }
}

Las interfaces son tipos de datos

Utilicemos un test unitario para probar la clase:

public class CalificadorUniformeTest {
    private Calificador calificador;

    @Before
    public void init() {
        calificador = new CalificadorUniforme();
    }

    @Test
    public void testCalificaConMaximoNotas() {
        for(int i = 0; i < CalificadorUniforme.NUM_MAX_NOTAS; i++)
            calificador.addNota(i);
        assertThat(calificador.califica(), is(4.5f));
    }...

Las interfaces son tipos de datos

Continuación del test

...
    @Test
    public void testCalificaConCincoNotas() {
        for(int i = 0; i < 5; i++)
            calificador.addNota(i);
        assertThat(calificador.califica(), is(2.0f));
    }

    @Test
    public void testCalificaMatriculaHonor() {
        calificador.addNota(10);
        assertThat(calificador.califica(), is(10.0f));
    }
}

Estupendo, los tests pasan.

Las interfaces son tipos de datos

Definamos otra clase que implemente la misma interface.

public class CalificadorLineal implements Calificador {
    public final static int NUMERO_MAXIMO_NOTAS = 10;
    private float[] notas = new float[NUM_MAX_NOTAS];
    private int numeroNotas = 0;
    @Override
    public float califica() {
        float suma = 0;
        for(int i = 0; i < numeroNotas; i++)
            suma += notas[i]*(i+1);
        return suma/sumaPesos();
    }
    @Override
    public void addNota(float nuevaNota) {
        if(numeroNotas < NUM_MAX_NOTAS)
            notas[numeroNotas++] = nuevaNota;
    } ...

Las interfaces son tipos de datos

...
    private float sumaPesos() {
        float suma = 0;
        for(int i = 0; i < numeroNotas; i++)
            suma += i+1;
        return suma;
    }
}

Esta clase, da un peso a cada nota, de modo que, a medida que avanza el curso las notas tienen mayor peso.

Las interfaces son tipos de datos

Y los tests:

public class CalificadorLinealTest {
    private Calificador calificador;

    @Before
    public void init() {
        calificador = new CalificadorLineal();
    }

    @Test
    public void testCalificaConMaximoNotas() {
        for(int i = 0; i < CalificadorLineal.NUM_MAX_NOTAS; i++)
            calificador.addNota(i);
        assertThat(calificador.califica(), is(6.0f));
    }...

Las interfaces son tipos de datos

...
    @Test
    public void testCalificaConCincoNotasIguales() {
        for(int i = 0; i < 5; i++)
            calificador.addNota(5);
        assertThat(calificador.califica(), is(5.0f));
    }

    @Test
    public void testCalificaMatriculaHonor() {
        calificador.addNota(10);
        assertThat(calificador.califica(), is(10.0f));
    }
}

Estos test también pasan.

Las interfaces son tipos de datos

Ahora ya podemos utilizar nuestra interface.

private void ponNotas(Calificador calificador) {
    calificador.addNota(5); // Primer ejercicio
    calificador.addNota(6); // Segundo ejercicio
    calificador.addNota(10); // Último ejercicio
}
private void califica() {
    Calificador calificador = new CalificadorUniforme();
    ponNotas(calificador);
    // Lo siguiente muestra --> Nota final: 7.0
    System.out.println("Nota final: " + calificador.califica());
    calificador = new CalificadorLineal();
    ponNotas(calificador);
    // Lo siguiente muestra --> Nota final: 7.8333335
    System.out.println("Nota final: " + calificador.califica());
}

Las interfaces son tipos de datos

A una referencia cuyo tipo es una interface le podemos asignar cualquier objeto que implements esa interface. Se dice que los tipos son compatibles.

Calificador calificadorUniforme = new CalificadorUniforme();
Calificador calificadorLineal = new CalificadorLineal();
Calificador calificador = calificadorUniforme = calificadorLineal;

Cuidado, si dos clases implementan el misma interface no existe compatibilidad entre ellas.

CalificadorUniforme calificadorUniforme = new CalificadorUniforme();
CalificadorLineal calificadorLineal = new CalificadorLineal();
calificadorUniforme = calificadorLineal; // Error, los tipos no son compatibles.

Implementación de varias interfaces

Una clase puede implementar más de una interface.

Supongamos que tenemos la siguiente clase que describe una Persona.

public class Persona {
    private String nombre;
    private String domicilio;
    private int curso;
    private float notaExpediente;
    ...

Implementación de varias interfaces

Por otro lado, hay veces que nos gustaría trabajar con objetos de esta clase pero verlos como Ciudadano de quien lo único que nos interesa es su nombre y dirección.

Y otras veces nos interesa ver a una Persona como un Estudiante de quien lo único que nos interesa saber es el curso en el que está matriculado y su nota de expediente.

Implementación de varias interfaces

Definamos una interface que nos permita acceder sólo a los métodos que nos interesa de una Persona como Ciudadano.

public interface Ciudadano {
    String getDomicilio();
    String getNombre();
}

Y definamos una interface que nos permita acceder sólo a los métodos de una Persona como un Estudiante.

public interface Estudiante {
    float getNotaExpediente();
    int getCurso();
}

Implementación de varias interfaces

Ahora Persona implementa ambas interface.

public class Persona implements Ciudadano, Estudiante {
    ...
    @Override
    public String getNombre() {
        return nombre;
    }
    @Override
    public String getDomicilio() {
        return domicilio;
    }
    @Override
    public int getCurso() {
        return curso;
    }
    @Override
    public float getNotaExpediente() {
        return notaExpediente;
    }...

Implementación de varias interfaces

De tal manera que cuando nos interese podemos ver a la Persona como un Ciudadano o como un Estudiante.

private void personaComoCiudadano(Ciudadano ciudadano) {
    System.out.println("Nombre:" + ciudadano.getNombre());
    System.out.println("Domicilio: " + ciudadano.getDomicilio());
}
private void personaComoEstudiante(Estudiante estudiante) {
    System.out.println("Curso: " + estudiante.getCurso());
    System.out.println("Nota expediente: " +
        estudiante.getNotaExpediente());
}
private void juega() {
    Persona persona = new Persona("Oscar", "UJI", 4, 10.0f);
    personaComoCiudadano(persona);
    personaComoEstudiante(persona);
}

Implementación de varias interfaces

Pero cuidado, si la referencia es de un tipo sólo podremos utilizar los métodos declarados en ese tipo.

private void personaComoCiudadano(Ciudadano ciudadano) {
    System.out.println("Nombre:" + ciudadano.getNombre());
    System.out.println("Domicilio: " + ciudadano.getDomicilio());
    // La siguiente línea da un error
    //System.out.println("Curso: " + ciudadano.getCurso());
}

private void personaComoEstudiante(Estudiante estudiante) {
    System.out.println("Curso: " + estudiante.getCurso());
    System.out.println("Nota expediente: " +
        estudiante.getNotaExpediente());
    // La siguiente línea da un error
    //System.out.println("Nombre:" + estudiante.getNombre());
}

Clases distintas implementando la misma interface

Aunque el ejemplo de introducción mostraba este mismo caso, veamos otro ejemplo.

Estamos desarrollado una aplicación para Hacienda. En la aplicación gestionamos tanto Personas como Empresas, pero a efectos de transacciones comerciales a ambos los podemos ver como Pagadores.

Clases distintas implementando la misma interface

Aquí tenemos la interface común.

public interface Pagador {
    String getNombre();
    String getDireccion();
    String getNif();
}

Y las dos implementaciones de esta interface.

Clases distintas implementando la misma interface

public class Persona implements Pagador {
    private String nombre;
    private String direccion;
    private String nif;
    private float estatura;
    private float peso;
    @Override
    public String getNombre() {
        return "Sr./Sra. " + nombre;
    }
    @Override
    public String getDireccion() {
        return direccion;
    }
    @Override
    public String getNif() {
        return nif;
    }...

Clases distintas implementando la misma interface

public class Empresa implements Pagador {
    private String nombre;
    private String direccion;
    private String nif;
    private int numeroEmpleados;
    @Override
    public String getNombre() {
        return nombre;
    }
    @Override
    public String getDireccion() {
        return direccion;
    }
    @Override
    public String getNif() {
        return nif;
    }...

Clases distintas implementando la misma interface

Probemos todo ello.

Pagador pagadores[] = new Pagador[2];
pagadores[0] = new Persona("Oscar", "UJI", "123d", 1.7f, 63);
pagadores[1] = new Empresa("Indra", "Madrid", "321q", 500);

for(Pagador pagador: pagadores){
    System.out.println("Nombre: " + pagador.getNombre());
    System.out.println("Dirección: " + pagador.getDireccion());
    System.out.println("NIF: " + pagador.getNif());
}

La interface Pagador es un tipo de datos, y podemos crear un array de referencias de este tipo.

Da igual que el objeto sea de tipo Persona o Empresa, ambos se pueden ver como Pagador.

Clases distintas implementando la misma interface

La salida es la siguiete:

Nombre: Sr./Sra. Oscar
Dirección: UJI
NIF: 123d
Nombre: Indra
Dirección: Madrid
NIF: 321q

Perfecto, ningún problema.

Polimorfismo utilizando interfaces

Al utilizar interfaces polimorfismo significa que diferentes clases que implementan la misma interface son sustituibles, es decir, que allí donde utilizamos una también podremos utilizar la otra.

La diferencia entre ambas radicará en cómo cada una implementa los métodos declarados en interface.

Dicho de otro modo, mismo método diferentes implementaciones.

Vinculación Dinámica

Las referencias son la puerta de entrada a los objetos.

En Java, nunca accedemos directamente a los objetos, siempre lo hacemos a través de referencias.

Ahora bien, ¿cómo sabe Java el método correcto a invocar si accedemos a una referencia de tipo interface para la que tenemos más de una implementación en diferentes objetos?

Java utiliza el mecanismo de Vinculación Dinámica: en tiempo de ejecución, la máquina virtual de Java determina el tipo del objeto que es referenciado para llamar el método correcto.

Vinculación Dinámica

Pagador pagador = new Persona("Óscar", "Castellón", "123");
System.out.println(pagador.getNombre());
pagador = new Empresa("UJI", "Castellón", "321");
System.out.println(pagador.getNombre());

En este ejemplo, la referencia que usamos es de tipo Pagador, que no tiene ningún método implementado, sólo declarado.

Creamos un objeto de tipo Persona que asignamos a una referencia de tipo Pagador y, a través de la referencia pagador, llamamos al método getNombre(), en este caso, el método que se llama es el que implementa la clase Persona.

A continuación, creamos un objeto de tipo Empresa lo asignamos a pagador y llamamos a getNombre(), en este segundo caso, el método que se llama es el que está definido en la clase Empresa.

Reescritura de interfaces

Una vez que se ha definido una interface, no es conveniente añadirle nuevo métodos. Al hacerlo, se invalidarían todas las clases que la hubieran implementado por no sobrecargar el nuevo método.

public interface HaceAlgo {
	void hazAlgo(int i, double x);
	int hazOtraCosa(String s);
	void hazAlgoNuevo(double x); // Nuevo. Fallan implementaciones previas.
}

Reescritura de interfaces

Una solución es crear una nueva interfaz extendiendo la antigua (veremos este concepto en el tema de herencia). El programador puede usar la nueva interfaz o seguir con la versión antigua.

public interface HaceAlgoMas extends HaceAlgo {
	void hazAlgoNuevo(double x);
}

Novedades en Java 8

Otra solución al problema de añadir nuevos métodos a interfaces nos lo resuelve una nueva característica de Java 8: los métodos default.

Los métodos default de las interfaces van precedidos por la palabra default e incluyen la implementación del método, que heredan las clases que implementen la interface.

Estas clases que implementan la interfaz pueden, si es necesario, sobreescribir el metodo.

Novedades en Java 8

Ahora podríamos ampliar nuestra interfaz HaceAlgo con un método default y las clases que ya implementan esta interfaz no se verían afectadas.

public interface HaceAlgo {
    void hazAlgo(int i, double x);
    int hazOtraCosa(String s);
    default void hazAlgoNuevo(double x) {
        System.out.println("Valor de x: " + x);
    }
}

Advertencia: no utilices métodos default en tus aplicaciones, a no ser que tengas problemas de compatibilidad en tu API.

Novedades en Java 8

¿Pero qué pasa si una clase implementa dos interfaces con métodos default que tienen la misma signatura?

En ese caso, la clase ignora ambos métodos.

Para evitar problemas como este, un método default no puede sobrecargar ningún metodo de java.lang.Object.

Novedades en Java 8

Otra novedad de Java 8 que afecta a las interfaces es la posibilidad de añadirles métodos estáticos o de interfaz.

Cuando una clase implementa una interfaz con un método estático, este no forma parte de la clase sino de la interfaz. Por ello hemos de invocarlo con el tipo de la interfaz, y no el de la clase.

Novedades en Java 8

public interface HaceAlgo {
    static void hazAlgoNuevo(double x) {
        System.out.println("Valor de x: " + x);
    }
}

public class UnaClase implements HaceAlgo {}

public class Principal {
    public static void main (String[] args) {
        HaceAlgo.hazAlgoNuevo(2.0);
        UnaClase.hazAlgoNuevo(2.0); // No compila
    }
}

Advertencia: evita los métodos static en tus propias interface. Hay que pensar mucho cuando utilizar métodos static en general.

Resumen

Las interfaces definen tipos de datos abstractos.

Una clase puede implementar todos las interface que necesite.

Una clase que implementa una interface puede ser vista como esa interface.

Java 8 permite la usar métodos default y static en las interface para poder modificarlos sin romper la compatibilidad hacia atrás.

Patrón de diseño: Programa orientado al interfaz no a la implementación.

Antipatrón: Una clase no debe implementar una interface sólo porque necesita un método declarado en él.

Referencias on-line