Interfaces y clases abstractas en Java: cuándo usar cada una

Sigue directamente del post de POO: ahora toca resolver el instanceof que se repite por todo el código

Si has seguido el tutorial de POO con el sistema bancario, en un momento del código apareció esto: if (cuenta2 instanceof CuentaAhorro), para poder llamar a un método que solo tenía CuentaAhorro. Funciona, pero en cuanto añades un tercer o cuarto tipo de cuenta, ese if se convierte en una cadena de instanceof que hay que actualizar cada vez que creas una clase nueva.

Vamos a ver el mismo problema con vehículos, que es más visual: un Coche y una Bicicleta pueden moverse, pero uno tiene motor y la otra no. Si intentas meter los dos en la misma jerarquía de herencia con extends, acabas con un método arrancarMotor() que no tiene sentido en la bicicleta, o con trucos raros para esquivarlo. Ahí es donde entran las interfaces y las clases abstractas.

El problema que tiene solo herencia

Imagina que quieres modelar un Coche, una Moto y una Bicicleta. Los tres se pueden mover, así que la tentación es crear una clase padre Vehiculo con un método mover() y que las tres hereden de ella con extends.

El problema aparece en cuanto añades algo que no todos comparten. El coche y la moto tienen motor y se pueden repostar; la bicicleta no. Si pones repostar() en Vehiculo, la bicicleta hereda un método que no le sirve para nada. Y en Java, una clase solo puede heredar de una única clase padre con extends, así que si más adelante necesitas que el Coche también sea, por ejemplo, Alquilable y Asegurable, no puedes simplemente heredar de dos sitios a la vez.

Interfaces: un contrato, no una herencia

Una interfaz no describe qué es algo, describe qué puede hacer. No te dice "esto es un vehículo", te dice "esto es capaz de moverse". Es un contrato: cualquier clase que la implemente se compromete a tener esos métodos, pero la interfaz no dice cómo funcionan por dentro, cada clase lo decide.

Crea Movible.java en tu proyecto de IntelliJ (o el nombre de paquete que ya tengas del tutorial de POO):

Java: Movible.java 📋
public interface Movible {
    void mover(int distancia);
    void detener();
}

Fíjate en que los métodos no tienen cuerpo, solo la firma terminada en punto y coma. Una interfaz no implementa nada, solo obliga a que quien la use tenga esos métodos. Ahora la Bicicleta puede implementarla:

Java: Bicicleta.java 📋
public class Bicicleta implements Movible {
    private int velocidad;

    @Override
    public void mover(int distancia) {
        velocidad = 15;
        System.out.println("Pedaleando " + distancia + " metros");
    }

    @Override
    public void detener() {
        velocidad = 0;
        System.out.println("Bicicleta detenida");
    }
}

❌ Error común: olvidar implementar un método de la interfaz

Si Bicicleta pusiera implements Movible pero se dejara detener() sin escribir, el código no compila. IntelliJ te lo marca en rojo antes de que llegues a ejecutar nada: Bicicleta is not abstract and does not override abstract method detener() in Movible. El compilador te obliga a cumplir el contrato entero, no puedes implementar solo la mitad.

Clases abstractas: cuando sí quieres compartir código

Una interfaz no te deja compartir código entre clases, solo la firma de los métodos. Pero el Coche y la Moto sí que comparten comportamiento de verdad: los dos tienen motor, los dos gastan combustible al moverse, y ese código sería exactamente igual si lo escribieras dos veces. Ahí es donde una clase abstracta encaja mejor que una interfaz.

Una clase abstracta es una clase normal, con propiedades y métodos con código real, pero que no se puede instanciar directamente con new. Sirve como base para que otras clases hereden de ella y completen lo que le falte:

Java: VehiculoMotor.java 📋
public abstract class VehiculoMotor implements Movible {
    protected String marca;
    protected double combustible;

    public VehiculoMotor(String marca) {
        this.marca = marca;
        this.combustible = 100.0;
    }

    // Método normal, con código: lo heredan Coche y Moto tal cual
    public void repostar() {
        combustible = 100.0;
        System.out.println(marca + " repostado al 100%");
    }

    // Método abstracto: cada vehículo lo implementa a su manera
    public abstract void arrancarMotor();
}

repostar() tiene código completo porque es idéntico para cualquier vehículo con motor, así que se escribe una sola vez y todos lo heredan. arrancarMotor() está marcado como abstract y sin cuerpo, porque un coche y una moto arrancan de forma distinta y cada clase hija tiene que decidir cómo. Fíjate también en que VehiculoMotor ya implementa Movible: una clase abstracta puede implementar interfaces igual que cualquier otra clase, y dejar que sean las clases hijas las que terminen de rellenar los métodos que falten.

Ahora Coche hereda de VehiculoMotor con extends, como cualquier herencia normal, pero solo tiene que escribir lo que de verdad es distinto:

Java: Coche.java 📋
public class Coche extends VehiculoMotor {
    public Coche(String marca) {
        super(marca);
    }

    @Override
    public void arrancarMotor() {
        System.out.println(marca + ": motor de 4 cilindros arrancado");
    }

    @Override
    public void mover(int distancia) {
        combustible -= distancia * 0.05;
        System.out.println(marca + " avanza " + distancia + " metros");
    }

    @Override
    public void detener() {
        System.out.println(marca + " detenido");
    }
}

Moto.java sería prácticamente igual, cambiando el texto de arrancarMotor() y el consumo de combustible en mover(). Lo importante es que repostar() no hace falta escribirlo en ninguna de las dos, ya viene heredado de VehiculoMotor tal cual.

❌ Error común: intentar instanciar la clase abstracta

Si escribes VehiculoMotor v = new VehiculoMotor("Genérico");, Java no compila y da el error VehiculoMotor is abstract; cannot be instantiated. Tiene sentido: VehiculoMotor tiene un método (arrancarMotor) sin implementar, así que un objeto de esa clase estaría incompleto. Solo puedes crear objetos de las clases hijas que sí completan todos los métodos abstractos.

El instanceof que ya no hace falta

Con Movible ya no necesitas comprobar de qué tipo es cada vehículo para moverlo. Puedes guardar cualquier objeto que implemente Movible en la misma lista, y llamar a mover() sobre todos sin saber si por dentro es un Coche o una Bicicleta:

Java: Main.java 📋
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Movible> vehiculos = new ArrayList<>();
        vehiculos.add(new Coche("Toyota"));
        vehiculos.add(new Bicicleta());

        for (Movible v : vehiculos) {
            v.mover(100);
        }
    }
}

El bucle for no sabe ni le importa si está moviendo un coche o una bicicleta. Solo sabe que cada elemento de la lista es Movible, así que tiene garantizado que mover() existe. Esto es lo que evita la cadena de instanceof que crecía sin control en el post de POO: en vez de preguntar "¿de qué tipo eres?" antes de actuar, defines de antemano qué pueden hacer todos, y cada uno decide cómo.

Cuándo usar cada una

La pregunta que de verdad importa es si hay código que compartir. Si dos clases necesitan tener los mismos métodos pero su implementación no tiene nada en común (un Coche y un Formulario pueden ser los dos Validable, sin compartir ni una línea de código), usa una interfaz. Si en cambio las clases son variaciones de lo mismo y hay lógica real que se repetiría igual en cada una (como repostar() en todos los vehículos con motor), usa una clase abstracta.

Y no son excluyentes, como has visto arriba: una clase abstracta puede implementar una o varias interfaces, y sus clases hijas heredan tanto el código compartido como el contrato de la interfaz. Es la combinación que vas a ver más en proyectos reales.

Ejercicio: añade un tercer vehículo

Crea una clase Patinete que implemente Movible directamente (no hereda de VehiculoMotor, porque no tiene motor ni combustible que gastar). En mover(), controla una batería que empieza en 100 y baja 2 puntos por cada metro que avanza; si la batería llega a 0, que mover() avise de que hay que cargarlo en vez de moverse.

Ver la solución (inténtalo tú primero)
Java: Patinete.java 📋
public class Patinete implements Movible {
    private int bateria = 100;

    @Override
    public void mover(int distancia) {
        int consumo = distancia * 2;
        if (consumo > bateria) {
            System.out.println("Batería insuficiente, hay que cargarlo");
            return;
        }
        bateria -= consumo;
        System.out.println("Patinete avanza " + distancia + " metros. Batería: " + bateria + "%");
    }

    @Override
    public void detener() {
        System.out.println("Patinete detenido");
    }
}

Añádelo a la lista vehiculos del Main de antes y comprueba que el bucle for lo mueve exactamente igual que al coche o la bicicleta, sin tocar ni una línea de ese bucle. Esa es la prueba de que la interfaz está haciendo su trabajo.

Qué toca ahora

Con interfaces y clases abstractas ya puedes evitar la mayoría de cadenas de instanceof que aparecen cuando un proyecto crece. El siguiente problema natural es qué pasa cuando algo falla de verdad dentro de esos métodos (una batería que da un valor negativo, un archivo que no existe): ahí es donde entra el manejo de excepciones con try, catch y excepciones propias, que no hemos tocado todavía en ningún mover() de este post.

Si quieres repasar cómo funcionan las colecciones que estás usando para guardar listas de vehículos, Java Colecciones: ArrayList y HashMap explicados desde cero cubre justo eso.