Módulo 13: Polimorfismo, clases abstractas e interfaces

Objetivos del módulo

  • Comprender el polimorfismo de subtipo: una referencia de un supertipo puede apuntar a objetos de cualquiera de sus subtipos
  • Distinguir el tipo estático de una variable del tipo dinámico del objeto y entender el enlace dinámico
  • Realizar upcasting y downcasting de forma segura con instanceof y con pattern matching (Java 16)
  • Trabajar con arrays y listas polimórficas
  • Definir clases y métodos abstractos y saber cuándo usarlos
  • Definir e implementar interfaces, incluidas varias a la vez, y usar sus métodos default, static y private
  • Elegir entre interfaz y clase abstracta
  • Conocer las interfaces funcionales y escribir tus primeras lambdas
  • Implementar Comparable para ordenar objetos y reconocer otras interfaces de la biblioteca como Runnable
  • Aplicar el principio de diseño “programar contra la interfaz”

1. Polimorfismo de subtipo

1.1. Una referencia, muchas formas

En el módulo 12 viste que un ProductoPerecedero es un Producto. Java se toma esa frase al pie de la letra: allí donde se espera un Producto, puedes poner cualquier objeto de una subclase.

Producto p = new ProductoPerecedero("A01", "Yogur", 0.60, 1);   // ¡válido!

📘 Concepto: El polimorfismo (del griego “muchas formas”) es la capacidad de una referencia de un tipo general (supertipo) para apuntar a objetos de distintos tipos concretos (subtipos), de modo que una misma llamada a un método ejecute el comportamiento propio de cada objeto.

Para los ejemplos de este módulo usaremos la jerarquía de productos del módulo 12, simplificada: Producto con precioFinal() y toString(), ProductoPerecedero (30 % de descuento si caduca en 2 días o menos, y pasarDia()) y ProductoDigital (10 % de descuento siempre, y getTamanoMb()).

public class Producto {
    private final String codigo;
    private final String nombre;
    private final double precioBase;

    public Producto(String codigo, String nombre, double precioBase) {
        this.codigo = codigo;
        this.nombre = nombre;
        this.precioBase = precioBase;
    }

    public String getCodigo() { return codigo; }
    public String getNombre() { return nombre; }
    public double getPrecioBase() { return precioBase; }

    public double precioFinal() {
        return precioBase;
    }

    @Override
    public String toString() {
        return String.format("%-7s %6.2f €", nombre, precioFinal());
    }
}
// ===== ProductoPerecedero.java =====
public class ProductoPerecedero extends Producto {
    private int diasParaCaducar;

    public ProductoPerecedero(String codigo, String nombre, double precioBase, int dias) {
        super(codigo, nombre, precioBase);
        this.diasParaCaducar = dias;
    }

    public int getDiasParaCaducar() { return diasParaCaducar; }

    public void pasarDia() {
        if (diasParaCaducar > 0) diasParaCaducar--;
    }

    @Override
    public double precioFinal() {
        return diasParaCaducar <= 2 ? getPrecioBase() * 0.7 : getPrecioBase();
    }

    @Override
    public String toString() {
        return super.toString() + " (caduca en " + diasParaCaducar + " d)";
    }
}

// ===== ProductoDigital.java =====
public class ProductoDigital extends Producto {
    private final double tamanoMb;

    public ProductoDigital(String codigo, String nombre, double precioBase, double tamanoMb) {
        super(codigo, nombre, precioBase);
        this.tamanoMb = tamanoMb;
    }

    public double getTamanoMb() { return tamanoMb; }

    @Override
    public double precioFinal() {
        return getPrecioBase() * 0.9;          // los digitales tienen un 10 % de descuento
    }

    @Override
    public String toString() {
        return super.toString() + " [" + tamanoMb + " MB]";
    }
}

1.2. Tipo estático y tipo dinámico

public class PruebaSupertipo {
    public static void main(String[] args) {
        // Referencia de tipo Producto -> objeto de tipo ProductoPerecedero
        Producto p = new ProductoPerecedero("A01", "Yogur", 0.60, 1);

        System.out.println(p.getNombre());        // método heredado: se puede llamar
        System.out.println(p.precioFinal());      // ¿qué versión se ejecuta?
        System.out.println(p);                    // toString de ProductoPerecedero
        System.out.println("Clase real del objeto: " + p.getClass().getSimpleName());
    }
}
Yogur
0.42
Yogur     0.42 € (caduca en 1 d)
Clase real del objeto: ProductoPerecedero

En la línea Producto p = new ProductoPerecedero(...) intervienen dos tipos:

  • El tipo estático (o declarado) es el de la variable: Producto. Lo conoce el compilador y decide qué métodos puedes llamar.
  • El tipo dinámico (o real) es el del objeto creado con new: ProductoPerecedero. Se conoce en ejecución y decide qué versión del método se ejecuta.

La variable p de tipo Producto apunta a un objeto ProductoPerecedero; lo que se puede llamar lo decide Producto y lo que se ejecuta, ProductoPerecedero

Figura 1. El compilador solo deja llamar a lo que existe en Producto; la JVM ejecuta la versión de ProductoPerecedero.

Por eso p.precioFinal() devuelve 0,42 (versión del perecedero), pero p.pasarDia() ni siquiera compila:

public class PruebaError {
    public static void main(String[] args) {
        Producto p = new ProductoPerecedero("A01", "Yogur", 0.60, 1);
        p.pasarDia();       // pasarDia() no existe en Producto
    }
}
PruebaError.java:4: error: cannot find symbol
        p.pasarDia();       // pasarDia() no existe en Producto
         ^
  symbol:   method pasarDia()
  location: variable p of type Producto
1 error

El compilador razona así: “p es un Producto, y en Producto no hay ningún pasarDia()”. No le importa que en este caso concreto el objeto sea un perecedero: p podría apuntar a cualquier producto.


2. Enlace dinámico

2.1. Una línea de código, muchos comportamientos

La verdadera potencia aparece cuando tratas colecciones de objetos distintos de manera uniforme:

public class Caja {
    public static void main(String[] args) {
        Producto[] carrito = {
            new Producto("P01", "Teclado", 30),
            new ProductoPerecedero("A01", "Yogur", 0.60, 1),
            new ProductoDigital("D01", "eBook", 10, 2.4),
            new ProductoPerecedero("A02", "Leche", 1.00, 15)
        };

        double total = 0;
        for (Producto p : carrito) {
            // Una sola línea de código, cuatro comportamientos distintos
            System.out.println(p);
            total += p.precioFinal();
        }
        System.out.printf("TOTAL: %.2f €%n", total);
    }
}
Teclado  30.00 €
Yogur     0.42 € (caduca en 1 d)
eBook     9.00 € [2.4 MB]
Leche     1.00 € (caduca en 15 d)
TOTAL: 40.42 €

Array carrito con cuatro referencias a un Producto, dos ProductoPerecedero y un ProductoDigital; cada uno ejecuta su propia versión de precioFinal

Figura 2. El bucle no sabe ni le importa de qué tipo es cada producto: llama a precioFinal() y cada objeto responde a su manera.

📘 Concepto: El enlace dinámico (dynamic binding o late binding) es el mecanismo por el que la JVM decide en tiempo de ejecución, según el tipo real del objeto, qué implementación de un método sobrescrito se ejecuta. En Java todos los métodos de instancia no private, no final y no static se enlazan dinámicamente.

¿Por qué es tan importante? Porque si mañana la tienda añade ProductoReacondicionado con su propio precioFinal(), la clase Caja sigue funcionando sin cambiar una sola línea. El código que usa la jerarquía queda abierto a extensiones sin necesidad de modificarlo (es el principio abierto/cerrado del diseño orientado a objetos). Compáralo con la alternativa sin polimorfismo: un if o un switch por cada tipo de producto, repetido en cada lugar donde se calcule un precio.

2.2. La sobrecarga no es dinámica

Atención a esta trampa clásica de examen. La sobrecarga se resuelve en compilación con el tipo estático de los argumentos; solo la sobrescritura se resuelve en ejecución:

public class Etiquetas {
    // Sobrecarga: se elige en COMPILACIÓN según el tipo de la referencia
    static void imprimir(Producto p) {
        System.out.println("Etiqueta genérica: " + p.getNombre());
    }

    static void imprimir(ProductoDigital d) {
        System.out.println("Etiqueta digital: " + d.getNombre() + " (" + d.getTamanoMb() + " MB)");
    }

    public static void main(String[] args) {
        ProductoDigital ebook = new ProductoDigital("D01", "eBook", 10, 2.4);
        Producto mismoEbook = ebook;

        imprimir(ebook);          // tipo de la referencia: ProductoDigital
        imprimir(mismoEbook);     // tipo de la referencia: Producto (¡aunque el objeto sea digital!)
        System.out.println(mismoEbook.precioFinal());   // sobrescritura: decide el OBJETO
    }
}
Etiqueta digital: eBook (2.4 MB)
Etiqueta genérica: eBook
9.0

ebook y mismoEbook apuntan al mismo objeto, pero imprimir(mismoEbook) elige la versión de Producto porque el compilador solo ve el tipo de la variable. En cambio, mismoEbook.precioFinal() ejecuta la versión de ProductoDigital (9,0) porque es sobrescritura.

  Sobrecarga Sobrescritura
Se decide en… Compilación Ejecución
Según… El tipo estático de los argumentos El tipo dinámico del objeto receptor
Mecanismo Enlace estático Enlace dinámico

3. Upcasting y downcasting

3.1. Upcasting: siempre seguro

Convertir una referencia de un subtipo a un supertipo se llama upcasting (conversión hacia arriba). Es implícito y siempre seguro: todo perecedero es un producto.

ProductoPerecedero yogur = new ProductoPerecedero("A01", "Yogur", 0.60, 1);
Producto p = yogur;                  // upcasting implícito
Object o = yogur;                    // también: todo es un Object

3.2. Downcasting: explícito y con riesgo

El camino inverso, de supertipo a subtipo, es el downcasting. Hay que escribirlo explícitamente con el operador de conversión (Tipo), porque no siempre es cierto que un producto sea un perecedero. Si el objeto real no es del tipo indicado, la JVM lanza una ClassCastException:

public class PruebaDowncast {
    public static void main(String[] args) {
        Producto p = new ProductoPerecedero("A01", "Yogur", 0.60, 5);   // upcasting implícito

        // Downcasting explícito: le decimos al compilador "confía en mí, es un perecedero"
        ProductoPerecedero pp = (ProductoPerecedero) p;
        pp.pasarDia();
        System.out.println("Días: " + pp.getDiasParaCaducar());

        Producto q = new ProductoDigital("D01", "eBook", 10, 2.4);
        ProductoPerecedero error = (ProductoPerecedero) q;               // compila... pero
        System.out.println("No se llega aquí");
    }
}
Días: 4
Exception in thread "main" java.lang.ClassCastException: class ProductoDigital cannot be cast to class ProductoPerecedero (ProductoDigital and ProductoPerecedero are in unnamed module of loader 'app')
    at PruebaDowncast.main(PruebaDowncast.java:11)

El mensaje es muy claro: un ProductoDigital no se puede convertir en ProductoPerecedero. (La parte entre paréntesis sobre el unnamed module solo indica que las clases no pertenecen a ningún módulo con nombre; puedes ignorarla).

Y si los tipos no tienen ninguna relación de herencia que haga posible la conversión, el compilador ni siquiera lo permite:

public class PruebaCast {
    public static void main(String[] args) {
        ProductoDigital d = new ProductoDigital("D01", "eBook", 10, 2.4);
        ProductoPerecedero p = (ProductoPerecedero) d;   // tipos sin relación de herencia
    }
}
PruebaCast.java:4: error: incompatible types: ProductoDigital cannot be converted to ProductoPerecedero
        ProductoPerecedero p = (ProductoPerecedero) d;   // tipos sin relación de herencia
                                                    ^
1 error

Jerarquía Producto con ProductoPerecedero y ProductoDigital: upcasting hacia arriba en verde, downcasting hacia abajo en naranja y conversión entre hermanas tachada

Figura 3. Hacia arriba, siempre; hacia abajo, solo si el objeto es realmente de ese tipo; entre “hermanas”, nunca.

⚠️ Importante: Hacer downcasting no cambia el objeto en absoluto: solo cambia el tipo de la referencia a través de la cual lo miras. El objeto siempre es lo que se creó con new.


4. instanceof y pattern matching

4.1. Comprobar el tipo antes de convertir

El operador instanceof comprueba si un objeto es de un tipo (o de un subtipo suyo). Permite hacer downcasting sin riesgo de ClassCastException:

if (p instanceof ProductoPerecedero) {
    ProductoPerecedero pp = (ProductoPerecedero) p;    // ahora es seguro
    pp.pasarDia();
}

4.2. Pattern matching para instanceof (Java 16)

La forma anterior repite el tipo tres veces. Desde Java 16 existe el pattern matching para instanceof: si la comprobación tiene éxito, la variable indicada se declara y se inicializa automáticamente con la conversión ya hecha.

public class Inventario {
    public static void main(String[] args) {
        Producto[] almacen = {
            new Producto("P01", "Teclado", 30),
            new ProductoPerecedero("A01", "Yogur", 0.60, 3),
            new ProductoDigital("D01", "eBook", 10, 2.4),
            new ProductoPerecedero("A02", "Leche", 1.00, 1)
        };

        // Forma clásica: instanceof + conversión
        for (Producto p : almacen) {
            if (p instanceof ProductoPerecedero) {
                ProductoPerecedero pp = (ProductoPerecedero) p;
                pp.pasarDia();
            }
        }

        // Pattern matching para instanceof (Java 16): comprueba, convierte y declara a la vez
        double megas = 0;
        for (Producto p : almacen) {
            if (p instanceof ProductoPerecedero pp && pp.getDiasParaCaducar() <= 1) {
                System.out.println("¡Ofertar ya! " + pp);
            } else if (p instanceof ProductoDigital d) {
                megas += d.getTamanoMb();
            }
        }
        System.out.println("Megas a servir: " + megas);

        Producto nada = null;
        System.out.println("null instanceof Producto: " + (nada instanceof Producto));
    }
}
¡Ofertar ya! Leche     0.70 € (caduca en 0 d)
Megas a servir: 2.4
null instanceof Producto: false

Detalles importantes:

  • La variable del patrón (pp, d) solo existe donde el compilador sabe que la comprobación es cierta: dentro del if, o a la derecha de un && (como en pp.getDiasParaCaducar() <= 1). No se puede usar con ||, porque ahí no está garantizado.
  • null instanceof Cualquiercosa es siempre false: instanceof nunca lanza NullPointerException.
  • Con la forma negada se escriben comprobaciones muy limpias, como en equals:
@Override
public boolean equals(Object o) {
    if (!(o instanceof Producto otro)) return false;   // si no es Producto, fuera
    return codigo.equals(otro.codigo);                 // aquí 'otro' ya está disponible
}

⚠️ Importante: Si te encuentras escribiendo muchas cadenas de if (x instanceof A) ... else if (x instanceof B) ... para decidir qué hacer con cada tipo, probablemente ese comportamiento debería ser un método sobrescrito en cada clase. Usa instanceof para lo excepcional (como aquí, llamar a un método que solo tienen algunos tipos), no como sustituto del polimorfismo. En el módulo 14 verás el pattern matching en switch (Java 21), que combinado con las clases selladas sí hace muy elegantes estas decisiones.


5. Arrays y listas polimórficas

Ya has visto varios arrays de tipo Producto[] que contienen objetos de distintas subclases. Lo mismo ocurre con las listas del módulo 16, que adelantamos aquí en su forma más sencilla: una List<Producto> admite cualquier Producto.

import java.util.ArrayList;
import java.util.List;

public class Listas {
    public static void main(String[] args) {
        // Una lista de Producto admite objetos de cualquier subclase (módulo 16)
        List<Producto> pedido = new ArrayList<>();
        pedido.add(new Producto("P01", "Teclado", 30));
        pedido.add(new ProductoDigital("D01", "eBook", 10, 2.4));
        pedido.add(new ProductoPerecedero("A01", "Yogur", 0.60, 1));

        int digitales = 0;
        for (Producto p : pedido) {
            System.out.println(p.getClass().getSimpleName() + " -> " + p.precioFinal());
            if (p instanceof ProductoDigital) {
                digitales++;
            }
        }
        System.out.println(pedido.size() + " productos, " + digitales + " digital(es)");
    }
}
Producto -> 30.0
ProductoDigital -> 9.0
ProductoPerecedero -> 0.42
3 productos, 1 digital(es)

List<Producto> se lee “lista de productos”; los < > indican el tipo de los elementos (son los genéricos, que estudiarás en el módulo 16). add añade al final, size() devuelve el número de elementos y se recorre con el mismo for que un array.

💡 Consejo: Declara los arrays y listas con el tipo más general que tenga sentido (Producto[], List<Producto>): así el mismo código sirve para cualquier subtipo presente o futuro.


6. Clases abstractas

6.1. Conceptos demasiado generales para tener objetos

En el módulo 12, la clase Figura tenía un area() que devolvía 0 y se podía crear una “Figura” a secas, algo que no tiene sentido: toda figura real es un círculo, un rectángulo, un triángulo… Figura es un concepto abstracto.

📘 Concepto: Una clase abstracta (abstract class) es una clase que no se puede instanciar: solo sirve como superclase. Puede contener métodos abstractos, declarados con abstract y sin cuerpo, que todas las subclases concretas están obligadas a implementar.

// Clase ABSTRACTA: representa un concepto general del que no tiene sentido crear objetos
public abstract class Figura {
    private final String color;

    protected Figura(String color) {          // las clases abstractas SÍ tienen constructores
        this.color = color;
    }

    public String getColor() {
        return color;
    }

    // Métodos ABSTRACTOS: sin cuerpo; cada subclase concreta DEBE implementarlos
    public abstract double area();

    public abstract double perimetro();

    // Método concreto que usa los abstractos ("método plantilla")
    public String describir() {
        return String.format("%-10s %-6s área %7.2f  perímetro %6.2f",
                getClass().getSimpleName(), color, area(), perimetro());
    }
}
// ===== Circulo.java =====
public class Circulo extends Figura {
    private final double radio;

    public Circulo(String color, double radio) {
        super(color);
        this.radio = radio;
    }

    @Override
    public double area() {
        return Math.PI * radio * radio;
    }

    @Override
    public double perimetro() {
        return 2 * Math.PI * radio;
    }
}

// ===== Triangulo.java =====
public class Triangulo extends Figura {
    private final double a, b, c;     // lados

    public Triangulo(String color, double a, double b, double c) {
        super(color);
        this.a = a;
        this.b = b;
        this.c = c;
    }

    @Override
    public double area() {            // fórmula de Herón
        double s = perimetro() / 2;
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));
    }

    @Override
    public double perimetro() {
        return a + b + c;
    }
}
public class PruebaFiguras {
    public static void main(String[] args) {
        Figura[] figuras = {
            new Circulo("rojo", 1.5),
            new Rectangulo("azul", 4, 2.5),
            new Triangulo("verde", 3, 4, 5)
        };
        double total = 0;
        Figura mayor = figuras[0];
        for (Figura f : figuras) {
            System.out.println(f.describir());
            total += f.area();
            if (f.area() > mayor.area()) {
                mayor = f;
            }
        }
        System.out.printf("Área total: %.2f; la mayor es el %s%n", total, mayor.getClass().getSimpleName());
    }
}
Circulo    rojo   área    7.07  perímetro   9.42
Rectangulo azul   área   10.00  perímetro  13.00
Triangulo  verde  área    6.00  perímetro  12.00
Área total: 23.07; la mayor es el Rectangulo

(Rectangulo es análoga a Circulo). El array es de tipo Figura[] aunque no exista ningún objeto Figura “puro”: una clase abstracta sí se puede usar como tipo.

Diagrama UML: Figura en cursiva con el estereotipo abstract y los métodos area y perimetro en cursiva; Circulo, Rectangulo y Triangulo heredan e implementan esos métodos

Figura 4. En UML, las clases y métodos abstractos se escriben en cursiva. El símbolo # indica protected.

6.2. Lo que el compilador exige

// ===== Circulo.java =====
public class Circulo extends Figura {      // no implementa area()
    private double radio;
}

// ===== Prueba.java =====
public class Prueba {
    public static void main(String[] args) {
        Figura f = new Figura();           // no se puede instanciar una clase abstracta
    }
}
Circulo.java:1: error: Circulo is not abstract and does not override abstract method area() in Figura
public class Circulo extends Figura {      // no implementa area()
       ^
Prueba.java:3: error: Figura is abstract; cannot be instantiated
        Figura f = new Figura();           // no se puede instanciar una clase abstracta
                   ^
2 errors
Regla Detalle
No se puede instanciar new Figura(...) es un error, aunque tenga constructor
Sí tiene constructores Los usan las subclases con super(...); suelen ser protected
Puede tener atributos y métodos concretos Como cualquier clase (color, describir())
Un método abstracto obliga a que la clase sea abstracta Una clase concreta no puede tener métodos sin cuerpo
Una subclase concreta debe implementar todos los métodos abstractos O declararse también abstract
abstract y final son incompatibles Uno exige subclases; el otro las prohíbe

6.3. Cuándo usar una clase abstracta

Usa una clase abstracta cuando tengas una familia de clases muy relacionadas que:

  • comparten estado (atributos como color) y código (como describir()),
  • pero tienen alguna operación que cada una resuelve a su manera (area()),
  • y no tiene sentido crear objetos de la clase general.

El método describir() es un ejemplo del patrón método plantilla: la superclase define el algoritmo general y deja “huecos” abstractos que rellenan las subclases (ya lo viste en el ejercicio 4 del módulo 12 con puedeRetirar).


7. Interfaces

7.1. Un contrato

Piensa en un enchufe: no le importa si conectas una lámpara, un portátil o una tostadora; solo exige que el aparato tenga la clavija adecuada. Esa “clavija” es una interfaz.

📘 Concepto: Una interfaz (interface) define un contrato: un conjunto de métodos que una clase se compromete a ofrecer, sin decir (en principio) cómo. Una clase implementa la interfaz con implements y está obligada a proporcionar todos sus métodos abstractos. Una interfaz describe qué sabe hacer un objeto, no qué es.

El sistema de pagos de la tienda es un caso perfecto: tarjeta, transferencia y efectivo no tienen nada en común como “cosas”, pero todos saben cobrar un importe.

// Una interfaz define un CONTRATO: qué se puede hacer, no cómo
public interface MetodoPago {
    double IMPORTE_MAXIMO = 3000;          // constante: implícitamente public static final

    boolean pagar(double importe);         // implícitamente public abstract

    String descripcion();
}
// ===== Tarjeta.java =====
public class Tarjeta implements MetodoPago {
    private final String numero;
    private double credito;

    public Tarjeta(String numero, double credito) {
        this.numero = numero;
        this.credito = credito;
    }

    @Override
    public boolean pagar(double importe) {
        if (importe > IMPORTE_MAXIMO || importe > credito) {
            return false;
        }
        credito -= importe;
        return true;
    }

    @Override
    public String descripcion() {
        return "Tarjeta terminada en " + numero.substring(numero.length() - 4);
    }
}

// ===== Efectivo.java =====
public class Efectivo implements MetodoPago {
    private double entregado;

    public Efectivo(double entregado) {
        this.entregado = entregado;
    }

    @Override
    public boolean pagar(double importe) {
        if (importe > entregado || importe > 1000) {   // límite legal de pago en efectivo (ver nota)
            return false;
        }
        System.out.printf("  Cambio: %.2f €%n", entregado - importe);
        entregado = 0;
        return true;
    }

    @Override
    public String descripcion() {
        return "Efectivo";
    }
}
public class PruebaPagos {
    public static void main(String[] args) {
        MetodoPago[] metodos = {
            new Tarjeta("4111111111111111", 500),
            new Transferencia("ES9121000418450200051332"),
            new Efectivo(100)
        };
        double importe = 75.48;
        for (MetodoPago m : metodos) {
            System.out.println(m.descripcion() + " -> " + (m.pagar(importe) ? "PAGADO" : "RECHAZADO"));
        }
        System.out.println("Máximo por operación: " + MetodoPago.IMPORTE_MAXIMO + " €");
        System.out.println("¿Una Tarjeta es un MetodoPago? " + (metodos[0] instanceof MetodoPago));
    }
}
Tarjeta terminada en 1111 -> PAGADO
Transferencia desde ES91... -> PAGADO
  Cambio: 24.52 €
Efectivo -> PAGADO
Máximo por operación: 3000.0 €
¿Una Tarjeta es un MetodoPago? true

(Transferencia sigue el mismo esquema). En España, el pago en efectivo está limitado a 1.000 € cuando interviene un empresario o profesional; de ahí la comprobación de Efectivo.

Lo que debes saber de las interfaces:

  • Sus métodos son implícitamente public abstract (no hace falta escribirlo). Al implementarlos, la clase debe declararlos public.
  • Sus atributos son implícitamente public static final: solo pueden ser constantes (IMPORTE_MAXIMO).
  • No se pueden instanciar ni tienen constructores, pero sí se usan como tipo: MetodoPago[], parámetros MetodoPago m, instanceof MetodoPago.
  • Por convención, sus nombres son adjetivos o capacidades (Comparable, Runnable, Enviable) o nombres de un rol (MetodoPago, List).

7.2. Implementar varias interfaces

Una clase solo puede heredar de una clase, pero puede implementar todas las interfaces que quiera. Así se resuelve en Java lo que otros lenguajes hacen con la herencia múltiple:

// ===== Descontable.java =====
public interface Descontable {
    void aplicarDescuento(double porcentaje);
}

// ===== Enviable.java =====
public interface Enviable {
    double pesoKg();

    default double costeEnvio() {          // método default (Java 8): tiene cuerpo
        return pesoKg() <= 2 ? 3.95 : 3.95 + (pesoKg() - 2) * 1.10;
    }
}
// Una clase puede heredar de UNA clase e implementar VARIAS interfaces
public class Libro implements Descontable, Enviable {
    private final String titulo;
    private double precio;
    private final double peso;

    public Libro(String titulo, double precio, double peso) {
        this.titulo = titulo;
        this.precio = precio;
        this.peso = peso;
    }

    @Override
    public void aplicarDescuento(double porcentaje) {
        precio -= precio * porcentaje / 100;
    }

    @Override
    public double pesoKg() {
        return peso;
    }

    @Override
    public String toString() {
        return String.format("%s: %.2f € + envío %.2f €", titulo, precio, costeEnvio());
    }
}
// ===== Enciclopedia.java =====
public class Enciclopedia extends Libro {
    public Enciclopedia(String titulo, double precio) {
        super(titulo, precio, 6.5);
    }

    @Override
    public double costeEnvio() {           // un método default también se puede sobrescribir
        return 0;                          // envío gratis por campaña
    }
}

// ===== PruebaVarias.java =====
public class PruebaVarias {
    public static void main(String[] args) {
        Libro novela = new Libro("La Regenta", 22.50, 1.1);
        Libro atlas = new Libro("Atlas mundial", 45.00, 3.0);
        Enciclopedia enc = new Enciclopedia("Enciclopedia", 300);

        Descontable d = novela;            // el mismo objeto visto como Descontable...
        d.aplicarDescuento(10);
        Enviable e = novela;               // ...o como Enviable
        System.out.printf("Peso de la novela: %.1f kg%n", e.pesoKg());

        System.out.println(novela);
        System.out.println(atlas);
        System.out.println(enc);
    }
}
Peso de la novela: 1.1 kg
La Regenta: 20.25 € + envío 3.95 €
Atlas mundial: 45.00 € + envío 5.05 €
Enciclopedia: 300.00 € + envío 0.00 €

El mismo objeto novela puede manejarse como Libro, como Descontable o como Enviable, según lo que necesite cada parte del programa. Y Enciclopedia hereda de Libro y con él sus dos interfaces.

// Forma general: una superclase y cualquier número de interfaces
public class Pato extends Animal implements Nadador, Volador { ... }

8. Métodos default, static y private en interfaces

Originalmente las interfaces solo podían tener métodos abstractos y constantes. Java ha ido ampliándolas:

Tipo de método Desde Para qué
Abstracto Java 1.0 El contrato: lo que cada clase debe implementar
default Java 8 Comportamiento por defecto que las clases heredan (y pueden sobrescribir)
static Java 8 Utilidades relacionadas con la interfaz; se llaman con Interfaz.metodo()
private Java 9 Código auxiliar compartido por los métodos default
public interface Descuento {
    double aplicar(double precio);

    // default: comportamiento común que las implementaciones heredan (Java 8)
    default double aplicarConMinimo(double precio, double minimo) {
        return redondear(Math.max(aplicar(precio), minimo));
    }

    // static: utilidad de la interfaz; se llama con Descuento.ninguno() (Java 8)
    static Descuento ninguno() {
        return precio -> precio;           // una lambda (sección 10)
    }

    // private: ayuda interna para los métodos default (Java 9)
    private double redondear(double valor) {
        return Math.round(valor * 100) / 100.0;
    }
}
// ===== DescuentoPorcentaje.java =====
public class DescuentoPorcentaje implements Descuento {
    private final double porcentaje;

    public DescuentoPorcentaje(double porcentaje) {
        this.porcentaje = porcentaje;
    }

    @Override
    public double aplicar(double precio) {
        return precio * (1 - porcentaje / 100);
    }
}

// ===== PruebaDescuentos.java =====
public class PruebaDescuentos {
    public static void main(String[] args) {
        Descuento rebajas = new DescuentoPorcentaje(70);
        System.out.printf("%.2f%n", rebajas.aplicar(19.99));
        System.out.println(rebajas.aplicarConMinimo(19.99, 9.99));    // método default
        System.out.println(Descuento.ninguno().aplicar(19.99));       // método static
    }
}
6.00
9.99
19.99

Los métodos default nacieron para poder añadir métodos a interfaces existentes sin romper las clases que ya las implementaban (así se añadió, por ejemplo, forEach a las colecciones en Java 8). Ya viste uno en Enviable.costeEnvio(), que Enciclopedia sobrescribía.

8.1. Conflicto entre métodos default

Si una clase implementa dos interfaces con un método default de la misma firma, el compilador no elige por ti:

// ===== Saludador.java =====
public interface Saludador {
    default String saludar() { return "Hola"; }
}

// ===== Educado.java =====
public interface Educado {
    default String saludar() { return "Buenos días"; }
}

// ===== Recepcionista.java =====
public class Recepcionista implements Saludador, Educado {
    // No sobrescribe saludar(): ¿cuál de los dos hereda?
}
Recepcionista.java:1: error: types Saludador and Educado are incompatible;
public class Recepcionista implements Saludador, Educado {
       ^
  class Recepcionista inherits unrelated defaults for saludar() from types Saludador and Educado
1 error

La solución es sobrescribir el método y, si quieres, reutilizar cualquiera de las versiones con la sintaxis Interfaz.super.metodo():

public class Recepcionista implements Saludador, Educado {
    @Override
    public String saludar() {
        // Se resuelve el conflicto eligiendo (o combinando) con Interfaz.super.metodo()
        return Educado.super.saludar() + ", bienvenido. " + Saludador.super.saludar() + "!";
    }

    public static void main(String[] args) {
        System.out.println(new Recepcionista().saludar());
    }
}
Buenos días, bienvenido. Hola!

💡 Consejo: Como las interfaces no tienen estado (atributos de instancia), este conflicto es mucho más sencillo que el “problema del diamante” de la herencia múltiple de clases: solo hay que decidir qué código ejecutar, nunca cuántas copias de un atributo existen.


9. Interfaces frente a clases abstractas

  Clase abstracta Interfaz
Palabra clave abstract class / extends interface / implements
Cuántas puede usar una clase Una Todas las que quiera
Atributos de instancia (estado) ✔ ✘ (solo constantes static final)
Constructores ✔ ✘
Métodos con cuerpo ✔ (cualquier visibilidad) default, static y private
Visibilidad de los métodos abstractos Cualquiera menos private Siempre public
Relación que expresa “es un” (familia de clases con estado común) “sabe hacer” / “cumple el contrato”
Ejemplo Figura, Animal, Empleado MetodoPago, Comparable, Enviable

💡 Consejo: Empieza pensando en interfaces: son más flexibles porque no consumen la única superclase disponible. Recurre a una clase abstracta cuando varias clases relacionadas necesiten compartir estado y código. Y es muy habitual combinar ambas: una interfaz como contrato y una clase abstracta que la implementa parcialmente como base para las implementaciones concretas.


10. Interfaces funcionales y una primera lambda

10.1. Interfaces con un único método

📘 Concepto: Una interfaz funcional es una interfaz con exactamente un método abstracto (puede tener además métodos default y static). La anotación opcional @FunctionalInterface pide al compilador que lo compruebe. Representan “un comportamiento”, una función: un criterio, una acción, un cálculo.

Supón que quieres mostrar los productos del catálogo que cumplen un criterio, y que el criterio cambie según el caso: los baratos, los digitales, los que tienen una “e” en el nombre… Definimos el criterio como una interfaz funcional y un método que la recibe:

// ===== Filtro.java =====
@FunctionalInterface                     // interfaz con UN solo método abstracto
public interface Filtro {
    boolean acepta(Producto p);
}

// ===== Baratos.java =====
// Forma "tradicional": una clase que implementa la interfaz
public class Baratos implements Filtro {
    @Override
    public boolean acepta(Producto p) {
        return p.precioFinal() < 20;
    }
}
public class Catalogo {
    // Este método no sabe QUÉ criterio se usa: lo recibe como objeto Filtro
    static void mostrar(Producto[] productos, Filtro filtro) {
        for (Producto p : productos) {
            if (filtro.acepta(p)) {
                System.out.println("  " + p);
            }
        }
    }

    public static void main(String[] args) {
        Producto[] productos = {
            new Producto("P01", "Teclado", 30),
            new ProductoDigital("D01", "eBook", 10, 2.4),
            new ProductoPerecedero("A01", "Yogur", 0.60, 1),
            new ProductoDigital("D02", "Juego", 60, 4200)
        };
        System.out.println("Baratos (con una clase):");
        mostrar(productos, new Baratos());

        System.out.println("Digitales (con una lambda):");
        mostrar(productos, p -> p instanceof ProductoDigital);

        System.out.println("Nombre con 'e' y más de 5 € (lambda con bloque):");
        mostrar(productos, p -> {
            boolean tieneE = p.getNombre().toLowerCase().contains("e");
            return tieneE && p.precioFinal() > 5;
        });
    }
}
Baratos (con una clase):
  eBook     9.00 € [2.4 MB]
  Yogur     0.42 € (caduca en 1 d)
Digitales (con una lambda):
  eBook     9.00 € [2.4 MB]
  Juego    54.00 € [4200.0 MB]
Nombre con 'e' y más de 5 € (lambda con bloque):
  Teclado  30.00 €
  eBook     9.00 € [2.4 MB]
  Juego    54.00 € [4200.0 MB]

10.2. Qué es una lambda

Escribir una clase completa (Baratos) para una sola línea de lógica es mucho ceremonial. Desde Java 8, una expresión lambda permite escribir directamente la implementación del único método de una interfaz funcional:

p -> p instanceof ProductoDigital
//  parámetro(s)  ->  cuerpo (el valor devuelto)

El compilador sabe que mostrar espera un Filtro, que el único método de Filtro es boolean acepta(Producto p), y deduce que p es un Producto y que la expresión debe dar un boolean.

Forma Ejemplo
Un parámetro, una expresión p -> p.precioFinal() < 20
Varios parámetros (a, b) -> a + b
Sin parámetros () -> System.out.println("Hola")
Cuerpo con varias sentencias p -> { boolean x = ...; return x; }
Tipos explícitos (opcional) (Producto p) -> p.getNombre().isEmpty()

📘 Concepto: Las lambdas son la puerta de entrada a la programación funcional en Java: interfaces funcionales estándar de java.util.function (Predicate, Function, Consumer, Supplier), referencias a métodos (Producto::getNombre) y la API de streams. Todo ello se estudia a fondo en el Curso de Java Avanzado. Aquí basta con que reconozcas una lambda y entiendas que es un objeto que implementa una interfaz funcional.


11. Interfaces de la biblioteca estándar

La biblioteca de Java está llena de interfaces que tus clases pueden implementar para “encajar” con el resto de la plataforma.

11.1. Comparable: el orden natural de tus objetos

Arrays.sort sabe ordenar números y cadenas, pero ¿cómo va a saber ordenar productos? Tus objetos tienen que decirle cómo se comparan implementando Comparable<T>, cuyo único método es int compareTo(T otro):

a.compareTo(b) devuelve… Significa
Un número negativo a va antes que b
Cero a y b son equivalentes en el orden
Un número positivo a va después que b
public class Producto implements Comparable<Producto> {
    private final String nombre;
    private final double precio;

    public Producto(String nombre, double precio) {
        this.nombre = nombre;
        this.precio = precio;
    }

    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }

    // Orden NATURAL: por precio ascendente y, a igual precio, por nombre
    @Override
    public int compareTo(Producto otro) {
        int porPrecio = Double.compare(this.precio, otro.precio);
        if (porPrecio != 0) {
            return porPrecio;
        }
        return this.nombre.compareTo(otro.nombre);
    }

    @Override
    public String toString() {
        return nombre + " " + precio;
    }
}
import java.util.Arrays;
import java.util.Comparator;

public class Ordenar {
    public static void main(String[] args) {
        Producto[] productos = {
            new Producto("Teclado", 29.99),
            new Producto("Ratón", 15.50),
            new Producto("Monitor", 189.00),
            new Producto("Alfombrilla", 15.50)
        };
        Arrays.sort(productos);                     // usa compareTo
        System.out.println("Por precio:  " + Arrays.toString(productos));

        System.out.println("Ratón vs Teclado: " + productos[1].compareTo(productos[2]));

        // Otro criterio sin tocar la clase: un Comparator con lambda (módulo 16)
        Arrays.sort(productos, Comparator.comparing(Producto::getNombre));
        System.out.println("Por nombre:  " + Arrays.toString(productos));
    }
}
Por precio:  [Alfombrilla 15.5, Ratón 15.5, Teclado 29.99, Monitor 189.0]
Ratón vs Teclado: -1
Por nombre:  [Alfombrilla 15.5, Monitor 189.0, Ratón 15.5, Teclado 29.99]
  • Comparable<Producto> lleva el tipo entre < > para que compareTo reciba directamente un Producto (de nuevo, genéricos, módulo 16).
  • Usa Double.compare, Integer.compare y el compareTo de String en lugar de restar (this.edad - otro.edad), que puede desbordarse con números grandes y no funciona bien con double.
  • Para desempatar, compara por un segundo criterio cuando el primero da 0.
  • Es muy recomendable que el orden sea coherente con equals (que compareTo devuelva 0 solo cuando equals es true).
  • Si necesitas otros órdenes sin tocar la clase, existe la interfaz Comparator, que se suele escribir con lambdas: Comparator.comparing(Producto::getNombre). La estudiarás en el módulo 16.

11.2. Runnable y otras

Runnable (de java.lang) es una interfaz funcional con un único método void run(): representa “una tarea que se puede ejecutar”. Es la base de los hilos, que verás en el curso avanzado:

public class PruebaRunnable {
    public static void main(String[] args) throws InterruptedException {
        // Runnable es una interfaz funcional de java.lang: un único método run()
        Runnable tarea = () -> System.out.println("Ejecutando la tarea en el hilo " + Thread.currentThread().getName());
        tarea.run();                                // se ejecuta en el hilo actual

        Thread hilo = new Thread(tarea, "trabajador");
        hilo.start();                               // se ejecuta en otro hilo (curso avanzado)
        hilo.join();                                // esperamos a que termine
        System.out.println("Fin");
    }
}
Ejecutando la tarea en el hilo main
Ejecutando la tarea en el hilo trabajador
Fin
Interfaz Paquete Método principal Para qué
Comparable<T> java.lang compareTo(T) Orden natural (Arrays.sort, TreeSet)
Comparator<T> java.util compare(T, T) Órdenes alternativos
Runnable java.lang run() Tareas para hilos
CharSequence java.lang length(), charAt(int) Implementada por String y StringBuilder
Iterable<T> java.lang iterator() Permite usar el objeto en un for-each
AutoCloseable java.lang close() Recursos de try-with-resources (módulo 15)
List<E>, Set<E>, Map<K,V> java.util varios Colecciones (módulo 16)

12. Programar contra la interfaz

Este es uno de los principios de diseño más importantes que aprenderás:

📘 Concepto: Programa contra la interfaz, no contra la implementación. El código que usa un servicio debería depender del contrato (la interfaz), no de una clase concreta. Así puedes cambiar, añadir o simular implementaciones sin tocar el código que las usa.

En la tienda, Pedido no sabe nada de tarjetas ni de monederos: solo sabe que recibirá algo que cumple MetodoPago.

Pedido usa la interfaz MetodoPago, implementada por Tarjeta, Monedero, Transferencia y cualquier implementación futura

Figura 5. Pedido depende solo de MetodoPago. Mañana puede llegar un método de pago nuevo sin tocar Pedido.

public interface MetodoPago {
    boolean pagar(double importe);

    String descripcion();

    default double comision(double importe) {      // por defecto, sin comisión
        return 0;
    }
}
// ===== Monedero.java =====
public class Monedero implements MetodoPago {
    private double saldo;

    public Monedero(double saldo) {
        this.saldo = saldo;
    }

    @Override
    public boolean pagar(double importe) {
        double total = importe + comision(importe);
        if (total > saldo) return false;
        saldo -= total;
        return true;
    }

    @Override
    public double comision(double importe) {
        return Math.round(importe * 1.5) / 100.0;  // 1,5 % de comisión
    }

    @Override
    public String descripcion() {
        return String.format("Monedero virtual (saldo %.2f €)", saldo);
    }
}

// ===== Pedido.java =====
// Pedido depende de la INTERFAZ MetodoPago, no de ninguna clase concreta
public class Pedido {
    private final int numero;
    private double total;
    private boolean pagado;

    public Pedido(int numero) {
        this.numero = numero;
    }

    public void agregar(String producto, double precio, int cantidad) {
        total += precio * cantidad;
    }

    public boolean pagarCon(MetodoPago metodo) {
        if (pagado) return false;
        String desc = metodo.descripcion();
        pagado = metodo.pagar(total);
        System.out.printf("Pedido %d (%.2f €) con %s: %s", numero, total, desc,
                pagado ? "PAGADO" : "RECHAZADO");
        double comision = metodo.comision(total);
        System.out.println(pagado && comision > 0 ? String.format(" (comisión %.2f €)", comision) : "");
        return pagado;
    }
}
public class PruebaPedidos {
    public static void main(String[] args) {
        Pedido p1 = new Pedido(1001);
        p1.agregar("Teclado", 29.99, 2);
        p1.agregar("Ratón", 15.50, 1);

        MetodoPago tarjeta = new Tarjeta("4111111111111111", 50);
        MetodoPago monedero = new Monedero(200);

        p1.pagarCon(tarjeta);          // crédito insuficiente
        p1.pagarCon(monedero);         // funciona con otra implementación
        System.out.println(monedero.descripcion());

        // Una implementación "de mentira" para pruebas, escrita con una clase anónima
        Pedido p2 = new Pedido(1002);
        p2.agregar("Monitor", 189, 1);
        p2.pagarCon(new MetodoPago() {
            @Override
            public boolean pagar(double importe) { return true; }
            @Override
            public String descripcion() { return "Pago simulado"; }
        });
    }
}
Pedido 1001 (75.48 €) con Tarjeta ****1111: RECHAZADO
Pedido 1001 (75.48 €) con Monedero virtual (saldo 200.00 €): PAGADO (comisión 1.13 €)
Monedero virtual (saldo 123.39 €)
Pedido 1002 (189.00 €) con Pago simulado: PAGADO

(Tarjeta es como la de la sección 7, con validación del número). Observa:

  • Monedero sobrescribe el método default comision; Tarjeta usa el heredado (sin comisión).
  • El segundo pedido se paga con una clase anónima: una implementación de la interfaz escrita en el mismo lugar donde se usa, sin nombre. Es muy útil para pruebas (un pago que siempre funciona, sin tocar ningún banco). Como MetodoPago tiene dos métodos abstractos, no es funcional y no admite una lambda.
  • Las variables se declaran con el tipo de la interfaz (MetodoPago tarjeta = new Tarjeta(...)). Es la misma idea que verás constantemente con las colecciones: List<Producto> lista = new ArrayList<>();.

💡 Consejo: Este principio es la base de las arquitecturas profesionales: en Spring Boot (curso avanzado), los servicios y repositorios se definen como interfaces y el framework “inyecta” la implementación adecuada. Si te acostumbras ahora a pensar en contratos, ese salto te resultará natural.


13. Ejemplo completo: la granja

Los ejemplos de figuras con área (sección 6), sistema de pagos (sección 12) y ordenación con Comparable (sección 11) ya están completos. Este último combina todo el módulo: una clase abstracta con un método abstracto, que implementa Comparable, y dos interfaces de capacidades que solo implementan algunos animales.

Animal es abstracta y la heredan Perro, Gato y Pato; Perro implementa Nadador y Pato implementa Nadador y Volador

Figura 6. Herencia simple de clases y varias interfaces a la vez: un Pato es un Animal y sabe nadar y volar.

public abstract class Animal implements Comparable<Animal> {
    private final String nombre;
    private final int edad;

    protected Animal(String nombre, int edad) {
        this.nombre = nombre;
        this.edad = edad;
    }

    public String getNombre() { return nombre; }
    public int getEdad() { return edad; }

    public abstract String sonido();

    public String presentarse() {
        return getClass().getSimpleName() + " " + nombre + " (" + edad + " años) dice " + sonido();
    }

    @Override
    public int compareTo(Animal otro) {
        return Integer.compare(edad, otro.edad);   // orden natural: por edad
    }
}
// ===== Nadador.java =====
public interface Nadador {
    String nadar();
}

// ===== Volador.java =====
public interface Volador {
    int ALTITUD_MAXIMA_M = 10_000;

    String volar();
}
// ===== Perro.java =====
public class Perro extends Animal implements Nadador {
    public Perro(String nombre, int edad) { super(nombre, edad); }

    @Override
    public String sonido() { return "¡Guau!"; }

    @Override
    public String nadar() { return getNombre() + " nada a lo perrito"; }
}

// ===== Pato.java =====
public class Pato extends Animal implements Nadador, Volador {
    public Pato(String nombre, int edad) { super(nombre, edad); }

    @Override
    public String sonido() { return "¡Cuac!"; }

    @Override
    public String nadar() { return getNombre() + " flota y chapotea"; }

    @Override
    public String volar() { return getNombre() + " vuela bajo"; }
}
import java.util.Arrays;

public class Granja {
    public static void main(String[] args) {
        Animal[] animales = {
            new Perro("Toby", 5), new Gato("Misi", 2), new Pato("Lucas", 3), new Gato("Tom", 8)
        };
        Arrays.sort(animales);                         // por edad, gracias a Comparable
        for (Animal a : animales) {
            System.out.println(a.presentarse());
        }
        System.out.println("--- Al agua:");
        for (Animal a : animales) {
            if (a instanceof Nadador n) {              // ¿este animal cumple el contrato Nadador?
                System.out.println(n.nadar());
            }
        }
        System.out.println("--- Al aire:");
        for (Animal a : animales) {
            if (a instanceof Volador v) {
                System.out.println(v.volar() + " (máx. " + Volador.ALTITUD_MAXIMA_M + " m)");
            }
        }
    }
}
Gato Misi (2 años) dice ¡Miau!
Pato Lucas (3 años) dice ¡Cuac!
Perro Toby (5 años) dice ¡Guau!
Gato Tom (8 años) dice ¡Miau!
--- Al agua:
Lucas flota y chapotea
Toby nada a lo perrito
--- Al aire:
Lucas vuela bajo (máx. 10000 m)

(Gato es como Perro sin Nadador). Arrays.sort funciona porque Animal implementa Comparable<Animal>; presentarse() usa el sonido() de cada animal (enlace dinámico); y el pattern matching a instanceof Nadador n pregunta por una capacidad, no por una clase concreta: si mañana añades Pez implements Nadador, el bucle lo incluirá sin cambios.


14. Errores comunes

Error Síntoma Solución
Llamar a un método de la subclase con una referencia del supertipo cannot find symbol … location: variable p of type Producto instanceof con patrón y usar la variable del patrón
Downcasting sin comprobar ClassCastException en ejecución Comprobar antes con instanceof
Esperar que la sobrecarga sea dinámica Se ejecuta la versión del tipo estático Usar sobrescritura (un método en cada clase)
Cadenas de instanceof para decidir el comportamiento Código que hay que tocar con cada clase nueva Método sobrescrito en cada subclase
new de una clase abstracta o de una interfaz is abstract; cannot be instantiated Crear un objeto de una subclase o implementación concreta
No implementar todos los métodos abstractos is not abstract and does not override abstract method Implementarlos o declarar la clase abstract
Implementar un método de interfaz sin public attempting to assign weaker access privileges Los métodos de interfaz se implementan como public
Dos métodos default iguales inherits unrelated defaults Sobrescribir y elegir con Interfaz.super.metodo()
compareTo con resta Desbordamientos y errores con double Integer.compare, Double.compare
Declarar variables con la clase concreta Código rígido y difícil de probar Declarar con la interfaz
Lambda para una interfaz con dos métodos abstractos is not a functional interface Clase (anónima o normal) o rediseñar la interfaz

15. Ejercicios

Ejercicios resueltos

Ejercicio 1: Traza de polimorfismo

Sin ejecutarlo, escribe la salida del programa. Fíjate en qué se decide en compilación y qué en ejecución.

// ===== A.java =====
public class A {
    public String quien() { return "A"; }

    public String mensaje() { return "Soy " + quien(); }

    public static String tipo() { return "estático de A"; }
}

// ===== B.java =====
public class B extends A {
    @Override
    public String quien() { return "B"; }

    public String soloB() { return "solo en B"; }

    public static String tipo() { return "estático de B"; }   // oculta, no sobrescribe
}

// ===== Traza.java =====
public class Traza {
    static String ver(A a) { return "ver(A)"; }
    static String ver(B b) { return "ver(B)"; }

    public static void main(String[] args) {
        A x = new B();
        B y = new B();
        A z = new A();
        System.out.println(x.quien());
        System.out.println(x.mensaje());
        System.out.println(z.mensaje());
        System.out.println(ver(x) + " " + ver(y));
        System.out.println(((B) x).soloB());
        System.out.println(x instanceof B);
        System.out.println(z instanceof B);
        System.out.println(A.tipo() + " / " + B.tipo());
    }
}
Ver solución
B
Soy B
Soy A
ver(A) ver(B)
solo en B
true
false
estático de A / estático de B
  1. x.quien() → B: x es de tipo A, pero el objeto es un B (enlace dinámico).
  2. x.mensaje() → Soy B: mensaje() está en A, pero dentro llama a quien(), y para un objeto B se ejecuta la versión de B.
  3. z.mensaje() → Soy A: z apunta a un objeto A de verdad.
  4. ver(x) → ver(A) y ver(y) → ver(B): la sobrecarga se decide en compilación con el tipo estático (x es A).
  5. ((B) x).soloB() funciona: el downcasting es correcto porque el objeto es un B.
  6. x instanceof B → true (mira el objeto); z instanceof B → false.
  7. Los métodos static no se sobrescriben: B.tipo() oculta a A.tipo(); cada uno se llama con su clase.

Ejercicio 2: Orquesta con clase abstracta

Crea una clase abstracta Instrumento con un nombre, un estado afinado/desafinado, un método afinar() y un método abstracto protected String sonido(). El método tocar() debe ser final y devolver el sonido (en mayúsculas y con la advertencia “(desafinado)” si no está afinado). Crea Guitarra, Tambor (que no se puede afinar) y Flauta, y una orquesta que los afine y los toque.

Ver solución
public abstract class Instrumento {
    private final String nombre;
    private boolean afinado;

    protected Instrumento(String nombre) {
        this.nombre = nombre;
    }

    public String getNombre() { return nombre; }

    public void afinar() {
        afinado = true;
    }

    protected abstract String sonido();

    // Método plantilla: la secuencia es fija, el sonido lo pone cada subclase
    public final String tocar() {
        return nombre + ": " + (afinado ? sonido() : "(desafinado) " + sonido().toUpperCase());
    }
}
// ===== Guitarra.java =====
public class Guitarra extends Instrumento {
    private final int cuerdas;

    public Guitarra(int cuerdas) {
        super("Guitarra de " + cuerdas + " cuerdas");
        this.cuerdas = cuerdas;
    }

    @Override
    protected String sonido() { return "tlan tlan"; }
}

// ===== Tambor.java =====
public class Tambor extends Instrumento {
    public Tambor() { super("Tambor"); }

    @Override
    protected String sonido() { return "pom pom"; }

    @Override
    public void afinar() {
        // un tambor sencillo no se afina: no hace nada
    }
}

// ===== Flauta.java =====
public class Flauta extends Instrumento {
    public Flauta() { super("Flauta"); }

    @Override
    protected String sonido() { return "fiuuu"; }
}
public class Orquesta {
    public static void main(String[] args) {
        Instrumento[] orquesta = {new Guitarra(6), new Tambor(), new Flauta()};
        for (Instrumento i : orquesta) {
            i.afinar();
        }
        for (Instrumento i : orquesta) {
            System.out.println(i.tocar());
        }
    }
}
Guitarra de 6 cuerdas: tlan tlan
Tambor: (desafinado) POM POM
Flauta: fiuuu

tocar() es un método plantilla final: fija el formato de la salida y delega en el sonido() de cada subclase. Tambor sobrescribe afinar() con un cuerpo vacío, así que sigue “desafinado”. La orquesta trabaja solo con el tipo Instrumento: añadir un violín no requeriría cambiarla.

Ejercicio 3: Exportar a CSV con una interfaz

Crea una interfaz Exportable con dos métodos abstractos, campos() y valores() (arrays de String), y dos métodos default, cabeceraCsv() y filaCsv(), que unan los textos con ;. Haz que Cliente y Factura (dos clases sin ninguna relación) la implementen y escribe un método exportar(Exportable[] datos) que funcione con ambas.

Ver solución
public interface Exportable {
    String[] campos();          // nombres de las columnas
    String[] valores();         // valores de esta fila

    default String cabeceraCsv() {
        return String.join(";", campos());
    }

    default String filaCsv() {
        return String.join(";", valores());
    }
}
// ===== Cliente.java =====
public class Cliente implements Exportable {
    private final String dni, nombre, email;

    public Cliente(String dni, String nombre, String email) {
        this.dni = dni;
        this.nombre = nombre;
        this.email = email;
    }

    @Override
    public String[] campos() { return new String[]{"dni", "nombre", "email"}; }

    @Override
    public String[] valores() { return new String[]{dni, nombre, email}; }
}

// ===== Factura.java =====
public class Factura implements Exportable {
    private final int numero;
    private final double importe;

    public Factura(int numero, double importe) {
        this.numero = numero;
        this.importe = importe;
    }

    @Override
    public String[] campos() { return new String[]{"numero", "importe", "iva"}; }

    @Override
    public String[] valores() {
        return new String[]{String.valueOf(numero), String.format("%.2f", importe),
                String.format("%.2f", importe * 0.21)};
    }
}
public class Exportador {
    // Funciona con CUALQUIER array de objetos Exportable
    static void exportar(Exportable[] datos) {
        if (datos.length == 0) return;
        System.out.println(datos[0].cabeceraCsv());
        for (Exportable e : datos) {
            System.out.println(e.filaCsv());
        }
    }

    public static void main(String[] args) {
        exportar(new Exportable[]{
            new Cliente("12345678Z", "Ana López", "ana@correo.es"),
            new Cliente("87654321X", "Luis Pérez", "luis@correo.es")
        });
        System.out.println();
        exportar(new Exportable[]{new Factura(1, 120.5), new Factura(2, 89.99)});
    }
}
dni;nombre;email
12345678Z;Ana López;ana@correo.es
87654321X;Luis Pérez;luis@correo.es

numero;importe;iva
1;120.50;25.31
2;89.99;18.90

String.join(";", array) une los elementos con el separador. Cada clase solo dice qué columnas tiene; cómo se forma una línea CSV está escrito una sola vez en los métodos default. Cliente y Factura no comparten superclase (salvo Object), pero ambas cumplen el contrato Exportable, y eso es lo único que necesita exportar. En el módulo 17 escribirás estas líneas en un fichero.

Ejercicio 4: Ranking de alumnos con Comparable

Crea una clase Alumno (nombre y nota media) que implemente Comparable<Alumno> para que Arrays.sort ordene de mayor a menor media y, a igual media, por orden alfabético. Muestra el ranking numerado.

Ver solución
// ===== Alumno.java =====
public class Alumno implements Comparable<Alumno> {
    private final String nombre;
    private final double media;

    public Alumno(String nombre, double media) {
        this.nombre = nombre;
        this.media = media;
    }

    // Mayor media primero; a igual media, orden alfabético
    @Override
    public int compareTo(Alumno otro) {
        int c = Double.compare(otro.media, this.media);   // invertido: descendente
        return c != 0 ? c : this.nombre.compareTo(otro.nombre);
    }

    @Override
    public String toString() {
        return nombre + " (" + media + ")";
    }
}

// ===== Ranking.java =====
import java.util.Arrays;

public class Ranking {
    public static void main(String[] args) {
        Alumno[] clase = {
            new Alumno("Pablo", 6.5), new Alumno("Lucía", 8.75),
            new Alumno("Irene", 8.75), new Alumno("Hugo", 4.9)
        };
        Arrays.sort(clase);
        for (int i = 0; i < clase.length; i++) {
            System.out.println((i + 1) + ". " + clase[i]);
        }
    }
}
1. Irene (8.75)
2. Lucía (8.75)
3. Pablo (6.5)
4. Hugo (4.9)

Para ordenar de forma descendente basta con invertir los argumentos de Double.compare (otro.media, this.media). El desempate por nombre usa el orden natural de String. Irene y Lucía empatan a 8,75 y aparecen en orden alfabético.

Ejercicio 5: Inventario por tipos

Dado un array de Producto con objetos de las tres clases de la sección 1, cuenta cuántos hay de cada tipo, suma los megas de los digitales y haz que los perecederos pasen un día, todo en un único recorrido con pattern matching. Después muestra el inventario.

Ver solución
public class Resumen {
    public static void main(String[] args) {
        Producto[] almacen = {
            new Producto("P01", "Teclado", 30),
            new ProductoPerecedero("A01", "Yogur", 0.60, 3),
            new ProductoDigital("D01", "eBook", 10, 2.4),
            new ProductoPerecedero("A02", "Leche", 1.00, 2),
            new ProductoDigital("D02", "Juego", 60, 4200)
        };
        int perecederos = 0, digitales = 0, otros = 0;
        double megas = 0;
        for (Producto p : almacen) {
            if (p instanceof ProductoPerecedero pp) {
                perecederos++;
                pp.pasarDia();                              // solo los perecederos envejecen
            } else if (p instanceof ProductoDigital d) {
                digitales++;
                megas += d.getTamanoMb();
            } else {
                otros++;
            }
        }
        System.out.println("Perecederos: " + perecederos + ", digitales: " + digitales + ", otros: " + otros);
        System.out.println("Descarga total: " + megas + " MB");
        for (Producto p : almacen) {
            System.out.println("  " + p);
        }
    }
}
Perecederos: 2, digitales: 2, otros: 1
Descarga total: 4202.4 MB
  Teclado  30.00 €
  Yogur     0.42 € (caduca en 2 d)
  eBook     9.00 € [2.4 MB]
  Leche     0.70 € (caduca en 1 d)
  Juego    54.00 € [4200.0 MB]

El orden de las comprobaciones importa: como ProductoPerecedero y ProductoDigital son subclases de Producto, el else final recoge los productos “normales”. Si comprobaras primero p instanceof Producto, todos entrarían en esa rama. Al mostrar el inventario, la leche (que ha pasado a 1 día) ya aparece con el descuento del 30 %, gracias al enlace dinámico de precioFinal() dentro de toString().

Ejercicio 6: Calculadora con lambdas

Define una interfaz funcional Operacion con el método double aplicar(double a, double b) y crea un array de operaciones (suma, resta, multiplicación, división que devuelva NaN si el divisor es 0, potencia y máximo) escritas como lambdas. Aplica todas a los números 7 y 2.

Ver solución
// ===== Operacion.java =====
@FunctionalInterface
public interface Operacion {
    double aplicar(double a, double b);
}

// ===== Calculadora.java =====
public class Calculadora {
    public static void main(String[] args) {
        String[] simbolos = {"+", "-", "*", "/", "^", "máx"};
        Operacion[] operaciones = {
            (a, b) -> a + b,
            (a, b) -> a - b,
            (a, b) -> a * b,
            (a, b) -> b == 0 ? Double.NaN : a / b,
            (a, b) -> Math.pow(a, b),
            Math::max                              // referencia a método (curso avanzado)
        };
        double x = 7, y = 2;
        for (int i = 0; i < operaciones.length; i++) {
            System.out.println(x + " " + simbolos[i] + " " + y + " = " + operaciones[i].aplicar(x, y));
        }
    }
}
7.0 + 2.0 = 9.0
7.0 - 2.0 = 5.0
7.0 * 2.0 = 14.0
7.0 / 2.0 = 3.5
7.0 ^ 2.0 = 49.0
7.0 máx 2.0 = 7.0

Cada lambda es un objeto que implementa Operacion, por eso se pueden guardar en un array como cualquier otro objeto. La última, Math::max, es una referencia a método: una forma aún más corta de escribir (a, b) -> Math.max(a, b) que estudiarás en el curso avanzado. Double.NaN (Not a Number) es el valor especial de double para resultados indefinidos.

Ejercicios propuestos

Ejercicio 7: Nómina polimórfica

Convierte Empleado del módulo 12 en una clase abstracta con calcularSalario() abstracto, añade Becario y haz que Empleado implemente Comparable<Empleado> por salario. Calcula el total de la nómina y muestra los empleados ordenados con un único bucle sobre un Empleado[].

Ejercicio 8: Notificaciones de pedidos

Crea una interfaz Notificador con void enviar(String destinatario, String mensaje) e implementaciones NotificadorEmail y NotificadorSms (que solo admita mensajes de hasta 160 caracteres). Haz que Pedido reciba un Notificador en su constructor y avise al cliente al pagarse. Prueba también con una lambda, ya que la interfaz es funcional.

Ejercicio 9: Vehículos recargables

Crea una interfaz Recargable (int nivelBateria(), void recargar(int minutos) y un método default boolean necesitaCarga() que devuelva true por debajo del 20 %). Haz que CocheElectrico (subclase de Coche) y PatineteElectrico (sin relación con Coche) la implementen, y recorre un array de Recargable recargando los que lo necesiten.

Ejercicio 10: Biblioteca de préstamos

Define una interfaz Prestable (prestar(), devolver(), diasMaximoPrestamo()) y una clase abstracta Ejemplar (código, título). Crea Libro y Revista (prestables, 21 y 7 días) y Enciclopedia (un Ejemplar que no es prestable). En la Biblioteca, usa instanceof con patrón para prestar solo lo que se puede prestar.

Ejercicio 11: Figuras comparables y escalables

Haz que la Figura abstracta de la sección 6 implemente Comparable<Figura> por área y crea una interfaz Escalable con void escalar(double factor) que implementen Circulo y Rectangulo, pero no Triangulo. Ordena un array de figuras, escala al doble las que se puedan y vuelve a ordenarlo. ¿Cambia el orden?

Ejercicio 12: Filtros combinables

Añade a la interfaz Filtro de la sección 10 métodos default y(Filtro otro), o(Filtro otro) y no() que devuelvan nuevos filtros (con lambdas) combinando el actual con otro. Úsalos para mostrar “los productos digitales y baratos” o “los que no son perecederos” sin escribir clases nuevas. (Así es exactamente como está diseñada la interfaz Predicate de la biblioteca).


Resumen

Concepto Sintaxis / ejemplo Idea clave
Polimorfismo Producto p = new ProductoDigital(...) Referencia de supertipo, objeto de subtipo
Tipo estático / dinámico variable / objeto El primero decide qué se puede llamar; el segundo, qué se ejecuta
Enlace dinámico p.precioFinal() La versión se elige en ejecución
Sobrecarga imprimir(Producto) / imprimir(ProductoDigital) Se elige en compilación
Upcasting Producto p = yogur; Implícito y seguro
Downcasting (ProductoPerecedero) p Explícito; puede lanzar ClassCastException
instanceof p instanceof ProductoDigital Comprueba el tipo real; null da false
Pattern matching (Java 16) if (p instanceof ProductoDigital d) Comprueba, convierte y declara
Array / lista polimórfica Producto[], List<Producto> Un tipo general para objetos diversos
Clase abstracta public abstract class Figura No instanciable; estado y código común
Método abstracto public abstract double area(); Sin cuerpo; obligatorio en las subclases concretas
Interfaz public interface MetodoPago Contrato: “sabe hacer”
Implementar class Libro implements Descontable, Enviable Varias interfaces a la vez
Constantes en interfaces double IMPORTE_MAXIMO = 3000; Implícitamente public static final
default / static / private Java 8 / 8 / 9 Métodos con cuerpo en interfaces
Conflicto de default Educado.super.saludar() La clase debe resolverlo
Interfaz funcional @FunctionalInterface Un único método abstracto
Lambda p -> p.precioFinal() < 20 Implementación compacta de una interfaz funcional
Comparable<T> int compareTo(T otro) Orden natural para Arrays.sort
Programar contra la interfaz MetodoPago m = new Tarjeta(...) Depender del contrato, no de la implementación