12 - POO V: Clases y Métodos abstractos. Interfaces
Métodos abstractos
Vamos a seguir con nuestro ejemplo de los vehículos con herencia. Tenemos la clase Vehiculo, a la que vamos a añadir una nueva propiedad: matricula.
public class Vehiculo { private String marca, modelo; private int velocidad; private float[] presionRuedas; private String matricula; ...
Supongamos que el proceso de matriculación es diferente para los distintos vehículos (las motos de menos de 250cc no pagan impuesto de matriculación, por ejemplo), pero queremos que todos los vehículos implementen ese método obligatoriamente. Si dejamos las clases como están, es el programador el que tiene que acordarse de implementar ese método. Podría darse el caso de crear un vehículo sin la opción de matricular.
Existe una forma de obligar a todas las clases descendientes a implementar un método: los métodos abstractos. Un método abstracto es aquel que tiene solo la definición de la cabecera, pero no su implementación. Por ejemplo, podemos crear el método abstracto matricular() en la clase Vehiculo:
public class Vehiculo { private String marca, modelo; private int velocidad; private float[] presionRuedas; private String matricula; ... public abstract void matricular(String matricula);
Fíjate que el método matricular no tiene implementación. Tan solo hemos definido su cabecera (es un método público que no devuelve nada y recibe un parámetro de tipo String). Además, para indicar que es un método abstracto debemos usar la palabra abstract.
Lo primero que pasa al crear un método abstracto es que Java nos da un error. ¿Por qué? Simplemente, porque si una clase tiene uno o más métodos abstractos, debemos declarar esa clase también como abstracta. Esto indica que no podemos crear objetos de dicha clase, cosa lógica, ya que no tiene implementado el método recién creado (matricular). Para declarar la clase como abstracta, lo único que tenemos que hacer es añadir abstract a la definición de la clase:
public abstract class Vehiculo { private String marca, modelo; private int velocidad; private float[] presionRuedas; private String matricula; ... public abstract void matricular(String matricula); ...
Vamos a aprovechar y a crear otro método que nos será útil más adelante. Será de tipo protected para que lo puedan usar solo las clases descendientes (aunque acuérdate que en Java los método protegidos también lo pueden usar las clases definidas en el mismo paquete, con lo que para hacerlo bien deberíamos cambiar el package de la clase Vehiculo y sus descendientes. Por simplificar, vamos a dejarlo como está) y será el setter del atributo matricula:
public abstract class Vehiculo { private String marca, modelo; private int velocidad; private float[] presionRuedas; private String matricula; ... public abstract void matricular(String matricula); protected void setMatricula(String matricula){ this.matricula = matricula; } ...
El método setMatricula() lo necesitaremos más adelante, ya que el atributo matricula es privado y no se puede acceder a él desde las clases descendientes. Además, lo hacemos protected para que no se pueda cambiar la matrícula de un vehículo desde fuera de las clases descendientes de Vehiculo.
Al definir la clase como abstracta, vemos que nos desaparece el error anterior, pero ahora nos muestra errores en las clases Coche y Moto. Esos errores nos están indicando que ambas clases tienen que implementar obligatoriamente los métodos abstractos de sus clases antecesoras. Si creamos el método en la clase Coche vemos como desaparece el error:
public class Coche extends Vehiculo{ private boolean ruedaRepuesto; ... @Override public void matricular(String matricula) { this.setMatricula(matricula); System.out.println("Pagar impuesto de matriculación"); }
Lo único que hace el método es mostrar la frase “Pagar impuesto de matriculación”. Para implementar el método en la clase Moto, primero debemos crear el atributo cilindrada y pasarlo al constructor. El método matricular() comprobará la cilindrada de la moto y mostrará por pantalla si se debe pagar matrícula o no:
public class Moto extends Vehiculo{ private int cilindrada; public Moto(String marca, String modelo, int velocidad, float[] presionRuedas, int cilindrada) { super(marca, modelo, velocidad, presionRuedas); this.cilindrada = cilindrada; } @Override public void matricular(String matricula) { if(this.cilindrada<250) { System.out.println("No hace falta pagar impuesto matriculación"); } else { System.out.println("Pagar impuesto matriculación"); } } }
Vamos a crear un coche y un par de motos en nuestra clase principal para comprobar que todo funciona:
public class Main { public static void main(String[] args) { Coche coche1 = new Coche( "Kia", "Niro", 0, new float[] {2.4f, 2.4f, 2.4f, 2.4f}, true ); Moto moto1 = new Moto( "Honda", "NC 750", 0, new float[] {2.5f, 2.5f}, 750 ); Moto moto2 = new Moto( "Yamaha", "XMAX", 0, new float[] {2.2f, 2.2f}, 125 ); coche1.matricular("1111AAA"); moto1.matricular("2222BBB"); moto2.matricular("3333CCCC"); } }
Pagar impuesto de matriculación Pagar impuesto matriculación No hace falta pagar impuesto matriculación
Propiedades de los métodos abstractos
- Podemos declarar clases abstractas sin necesidad de tener algún método abstracto. Aunque no es lo habitual, puede ser útil si queremos definir métodos comunes de las clases descendientes pero no queremos que se puedan crear objetos de dicha clase.
- Un método abstracto solo se puede definir como protected o public. Esto es lógico, ya que si es private no puede ser accesible desde las clases descendientes y no podrían implementarlo.
- No podemos reducir la visibilidad de un método abstracto en las clases hijas. Es decir, si en la clase padre hemos declarado el método abstracto como public, en las clases hijas que implementen ese método no podemos definirlas con una accesibilidad más restrictiva (private o protected). Sin embargo, si declaramos el método abstracto como protected si que podemos declararlo en las clases hijas como public.</note>
Interfaces
Podríamos definir las interfaces como clases donde todos sus métodos son abstractos (no todos, como veremos a continuación). En realidad, tienen algunas características más:
- Las interfaces pueden ser públicas o sin modificador de acceso (accesible desde el paquete donde está definida).
- Una interface pueden definir métodos abstractos o métodos estáticos y ambos serán públicos (aunque no se indique).
- Podemos definir atributos en la interface, pero tienen que ser públicos, estáticos y finales (constantes), con lo que debemos darle un valor. No hace falta añadirles los modificadores (public, static y final), pero sí darles un valor.
- Una clase puede heredar (implementar) de una o varias interfaces.
Para declarar una interface en Java, debemos usar la palabra reservada interface:
public interface nombreInterface { ... }
Si queremos que una clase implemente una interface, debemos usar la palabra reservada implements, en lugar de extends como en la herencia:
public class miClase implements nombreInterface { ... }
Utilidad de las interfaces
Aunque en un principio pueda parecer que las interfaces son poco útiles, son uno de los mecanismos más importantes para crear una buena arquitectura de nuestra aplicación.
Por ejemplo, supongamos que estamos haciendo un programa que permite leer documentos pdf donde tenemos almacenados nuestros clientes. Para eso, creamos una clase Cliente y una clase DocumentoPdf un método para encontrar un cliente en el pdf con el id que le pasemos:
public class Cliente { int id; String nombre, CIF; ... }
public class DocumentoPdf { String path; ... public Cliente getClienteFromPdf(int id){ Cliente cliente = new Cliente(); ... return cliente; } ... }
En nuestra clase principal, usamos esas clases para buscar el cliente con id = 2:
public class Main { public static void main(String[] args) { DocumentoPdf documento = new DocumentoPdf(); Cliente cliente = documento.getClienteFromPdf(2); } }
La aplicación funciona como toca y nos busca clientes en los pdf sin ningún problema. Al cabo del tiempo, sustituimos los documentos pdf por json. Tenemos que crear una nueva clase (DocumentoJson) con el método getClienteFromJson():
public class DocumentoJson { String path; public Cliente getClienteFromJson(int id){ Cliente cliente = new Cliente(); ... return cliente; } }
Además, tenemos que sustituir la llamada al método desde todas las clases donde lo hemos utilizado (en nuestro caso, solo está en nuestra clase Main):
public class Main { public static void main(String[] args) { DocumentoJson documento = new DocumentoJson(); Cliente cliente = documento.getClienteFromJson(2); } }
Imagínate si tuviésemos varias referencias al método. Nos tocaría buscar donde lo hemos utilizado y sustituirlos. Si después cambiamos a otro tipo de documentos o base de datos o cualquier otro tipo de persistencia, nos tocaría volver a hacer lo mismo, con el costo que conlleva.
Vamos a hacer lo mismo, pero usando interfaces. Para eso, creamos una interface Documento con el método getCliente():
public interface Documento { public Cliente getCliente(int id); }
En principio (igual que antes) leemos los clientes de documentos pdf. Para eso, creamos la clase DocumentoPdf que implementará la interface Documento (y su método getCliente():
public class DocumentoPdf implements Documento{ String path; public Cliente getCliente(int id){ Cliente cliente = new Cliente(); //... return cliente; } }
En nuestra clase principal, usamos esas clases y métodos:
public class Main { public static void main(String[] args) { Documento documento = new DocumentoPdf(); Cliente cliente = documento.getCliente(2); } }
Date cuenta que estamos usando polimorfismo para definir el tipo de la variable documento. Lo definimos del tipo Documento (interface), no como una clase concreta.
Si cambiamos y queremos usar documentos Json, creamos la nueva clase implementando de nuevo el interface:
public class DocumentoJson implements Documento{ String path; public Cliente getCliente(int id){ Cliente cliente = new Cliente(); //... return cliente; } }
Ahora, lo único que tenemos que cambiar es el tipo de documento en nuestra clase principal, pero el método no hace falta cambiarlo:
public class Main { public static void main(String[] args) { Documento documento = new DocumentoJson(); Cliente cliente = documento.getCliente(2); } }
Con este enfoque, desacoplamos las clases entre sí, permitiendo hacer cambios en unas sin que afecten a las otras.
Ejercicios
Ejercicio 1
Crea una estructura de paquetes similar a la del tema anterior en el back:
/controller //Capa de presentación de nuestra aplicación (controladores) BookController //Controlador de libros /business //Capa de negocio de nuestra aplicación (entidades y servicios) /entity //Entidades de nuestra aplicación Book //Modelo de negocio //Book// /service //Servicios de nuestra aplicación <<BooksService>> //Interfaz del caso de uso "Listar todos los libros" /impl //Implementaciones de nuestros servicios BooksServiceImpl //Implementación de la interface //BooksService// /persistence //Capa de persistencia de nuestra aplicación (repositorios) <<BookRepository>> //Interfaz de acceso a datos de los libros /impl //Implementaciones de nuestros repositorios StaticBookRepositoryImpl //Implementación estática de la //interface// //BookRespository// App //Clase principal de nuestra aplicación
Ejercicio 2
Crea la clase Book (será nuestro modelo de negocio) con los atributos id, title y author. La clase tendrá el constructor donde le pasaremos las propiedades del objeto, los getters correspondientes y el método toString() para mostrar el libro por pantalla.
Ejercicio 3
Crea un método en «BookRespository» (ten en cuenta que es una interface, con lo que no tendrás que implementarlo) llamado all() que devuelva una lista de libros (clase Book). Haz que la clase StaticBookRepositoryImpl implemente la interface «BookRepository» y devuelva un listado de libros que estarán escritos en el código. La clase podría quedar más o menos así:
public class StaticBookRepositoryImpl implements BookRepository{ List<Book> books = List.of( new Book(1, "El nombre de la rosa", "Umberto Eco"), new Book(2, "La insoportable levedad del ser", "Milan Kundera"), new Book(3, "1Q84", "Haruki Murakami") ); public List<Book> all() { return this.books; } }
Ejercicio 4
Crea la interface «BookService» con un sólo método llamado getAll(), que devolverá un listado de libros y su implementación en la clase BooksServiceImpl, que creará un objeto de tipo BookRepository (fíjate que el tipo del objeto es la interface «BookRepository») y devolverá lo retornado por el método all(). La clase BooksServiceImpl tendrá una propiedad privada que instanciará un objeto de tipo BookRepository con la implementación correspondiente (StaticBookRepositoryImpl).
public class BooksServiceImpl implements BookService{ private BookRepository repository = new StaticBookRepositoryImpl(); public List<Book> getAll() { return this.repository.all(); } }
Ejercicio 5
Crea en la clase BookController el método getAll(). Este método tendrá una propiedad de tipo BookService y devolverá lo retornado por su método getAll():
public class BookController { private BookService service = new BooksServiceImpl(); public List<Book> getAll() { return this.service.getAll(); } }
Ejercicio 6
Por último, crea en la clase principal (App) el método main(), donde se creará el controlador BookController y se mostrará por pantalla el listado de libros:
public class App { private static BookController controller = new BookController(); public static void main(String[] args) { List<Book> books = App.controller.getAll(); System.out.println(books); } }
Ejercicio 7
Crea un enum con 3 libros. Tendrá las propiedades id, title y author, con su constructor correspondiente.
Crea además una clase EnumBookRepositoryImpl que implementará la interface BookRepository y recogerá los datos del enum recién creado. Cambia en el servicio el Repository que utilizamos para leer los datos de los libros y comprueba que funciona todo correctamente.