Clases Abstractas en Java

Clases Abstractas en Java

1. Introducción

Existen muchos casos en los que, al implementar un contrato, deseamos posponer algunas partes de la implementación para completarlas más tarde. Esto se puede lograr fácilmente en Java a través de las clases abstractas.

En este tutorial, aprenderemos los conceptos básicos de las clases abstractas en Java y en qué casos pueden ser útiles.

2. Conceptos Clave para Clases Abstractas

Antes de profundizar en cuándo usar una clase abstracta, veamos sus características más relevantes:

  • Definimos una clase abstracta con el modificador abstract que precede a la palabra clave class.
  • Se puede heredar de una clase abstracta, pero no se puede instanciar.
  • Si una clase define uno o más métodos abstract, entonces la clase en sí debe declararse como abstract.
  • Una clase abstract puede declarar métodos tanto abstractos como concretos.
  • Una subclase derivada de una clase abstract debe implementar todos los métodos abstract de la clase base o ser ella misma abstract.

Para comprender mejor estos conceptos, crearemos un ejemplo simple.

Supongamos que nuestra clase abstracta base define la API abstracta de un juego de mesa:

public abstract class JuegoDeMesa {

    //... declaraciones de campos, constructores

    public abstract void jugar();

    //... métodos concretos
}

Luego, podemos crear una subclase que implemente el método jugar:

public class Damas extends JuegoDeMesa {

    public void jugar() {
        //... implementación
    }
}

3. Cuándo Usar Clases Abstractas

Ahora, analicemos algunos escenarios típicos en los que deberíamos preferir las clases abstractas sobre interfaces y clases concretas:

  • Queremos encapsular alguna funcionalidad común en un solo lugar (reutilización de código) que múltiples subclases relacionadas compartirán.
  • Necesitamos definir parcialmente una API que nuestras subclases puedan ampliar y refinar fácilmente.
  • Las subclases necesitan heredar uno o más métodos o campos comunes con modificadores de acceso protegidos.

Es importante tener en cuenta que todos estos escenarios son buenos ejemplos de adherencia completa basada en herencia al principio de Abierto/Cerrado.

Además, dado que el uso de clases abstractas trata implícitamente con tipos base y subtipos, también estamos aprovechando el polimorfismo.

Ten en cuenta que la reutilización de código es una razón muy convincente para usar clases abstractas, siempre y cuando se preserve la relación "es un" dentro de la jerarquía de clases.

Desde Java 8 se agregó otro matiz con los métodos por defecto (default), que a veces pueden reemplazar la necesidad de crear una clase abstracta por completo.

4. Ejemplo de Jerarquía de Lectores de Archivos

Para comprender con mayor claridad la funcionalidad que aportan las clases abstractas, veamos otro ejemplo.

4.1 Definir una Clase Abstracta Base

Entonces, si quisiéramos tener varios tipos de lectores de archivos, podríamos crear una clase abstracta que encapsule lo que es común en la lectura de archivos:

public abstract class LectorDeArchivoBase {
    
    protected Path rutaArchivo;
    
    protected LectorDeArchivoBase(Path rutaArchivo) {
        this.rutaArchivo = rutaArchivo;
    }
    
    public Path obtenerRutaArchivo() {
        return rutaArchivo;
    }
    
    public List<String> leerArchivo() throws IOException {
        return Files.lines(rutaArchivo)
          .map(this::mapearLineaArchivo).collect(Collectors.toList());
    }
    
    protected abstract String mapearLineaArchivo(String linea);
}

Observa que hemos declarado rutaArchivo como protegida para que las subclases puedan acceder a ella si es necesario. Más importante aún, hemos dejado algo sin hacer: cómo analizar realmente una línea de texto del contenido del archivo.

Nuestro plan es simple: si nuestras clases concretas no tienen una forma especial de almacenar la ruta del archivo o de recorrer el archivo, cada una tendrá una forma especial de transformar cada línea.

A simple vista, LectorDeArchivoBase puede parecer innecesario. Sin embargo, es la base de un diseño limpio y fácilmente ampliable. A partir de él, podemos implementar fácilmente diferentes versiones de un lector de archivos que pueden centrarse en su lógica de negocio única.

4.2 Definir Subclases

Una implementación natural es probablemente una que convierte el contenido del archivo a minúsculas:

public class LectorDeArchivoMinusculas extends LectorDeArchivoBase {

    public LectorDeArchivoMinusculas(Path rutaArchivo) {
        super(rutaArchivo);
    }

    @Override
    protected String mapearLineaArchivo(String linea) {
        return linea.toLowerCase();
    }   
}

Otra podría ser una que convierte el contenido del archivo a mayúsculas:

public class LectorDeArchivoMayusculas extends LectorDeArchivoBase {

    public LectorDeArchivoMayusculas(Path rutaArchivo) {
        super(rutaArchivo);
    }

    @Override
    protected String mapearLineaArchivo(String linea) {
        return linea.toUpperCase();
    }
}

Como podemos ver en este ejemplo simple, cada subclase puede centrarse en su comportamiento único sin necesidad de especificar otros aspectos de la lectura de archivos.

4.3 Uso de una Subclase

Finalmente, usar una clase que hereda de una clase abstracta no es diferente a cualquier otra clase concreta:

@Test
public void dadoUnainstanciaDeLectorDeArchivoMinusculas_cuandoSeLlamaLeerArchivo_entoncesEsCorrecto() throws Exception {
    URL ubicacion = getClass().getClassLoader().getResource("files/test.txt")
    Path ruta = Paths.get(ubicación.toURI());
    LectorDeArchivoBase lectorMinusculas = new LectorDeArchivoMinusculas(ruta);
        
    assertThat(lectorMinusculas.leerArchivo()).isInstanceOf(List.class);
}

Por simplificación, el archivo de destino se encuentra en la carpeta src/main/resources/files. Por lo tanto, utilizamos un cargador de clases de aplicación para obtener la ruta del archivo de ejemplo. Profundizaremos más en los cargadores de clases en futuras lecciones.

5. Conclusión

En este artículo rápido, aprendimos los conceptos básicos de las clases abstractas en Java y cuándo usarlas para lograr la abstracción y encapsular la implementación común en un solo lugar.