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
instanceofy 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,staticyprivate - Elegir entre interfaz y clase abstracta
- Conocer las interfaces funcionales y escribir tus primeras lambdas
- Implementar
Comparablepara ordenar objetos y reconocer otras interfaces de la biblioteca comoRunnable - 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.
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 €
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, nofinaly nostaticse 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
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 delif, o a la derecha de un&&(como enpp.getDiasParaCaducar() <= 1). No se puede usar con||, porque ahí no está garantizado. null instanceof Cualquiercosaes siemprefalse:instanceofnunca lanzaNullPointerException.- 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. Usainstanceofpara 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 enswitch(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 conabstracty 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.
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 (comodescribir()), - 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 conimplementsy 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 declararlospublic. - 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ámetrosMetodoPago 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
defaultystatic). La anotación opcional@FunctionalInterfacepide 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 quecompareToreciba directamente unProducto(de nuevo, genéricos, módulo 16).- Usa
Double.compare,Integer.comparey elcompareTodeStringen lugar de restar (this.edad - otro.edad), que puede desbordarse con números grandes y no funciona bien condouble. - Para desempatar, compara por un segundo criterio cuando el primero da 0.
- Es muy recomendable que el orden sea coherente con
equals(quecompareTodevuelva 0 solo cuandoequalsestrue). - 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.
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:
Monederosobrescribe el métododefaultcomision;Tarjetausa 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
MetodoPagotiene 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.
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
x.quien()→B:xes de tipoA, pero el objeto es unB(enlace dinámico).x.mensaje()→Soy B:mensaje()está enA, pero dentro llama aquien(), y para un objetoBse ejecuta la versión deB.z.mensaje()→Soy A:zapunta a un objetoAde verdad.ver(x)→ver(A)yver(y)→ver(B): la sobrecarga se decide en compilación con el tipo estático (xesA).((B) x).soloB()funciona: el downcasting es correcto porque el objeto es unB.x instanceof B→true(mira el objeto);z instanceof B→false.- Los métodos
staticno se sobrescriben:B.tipo()oculta aA.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 |