Java

Tutorial Java: Interfaces

Java interfaces: definición y uso. Aprende a definir y usar interfaces en Java con ejemplos prácticos y detallados.

Aprende Java y certifícate

Definición e implementación básica de interfaces

Las interfaces proporcionan un mecanismo para definir contratos que las clases deben cumplir. A diferencia de las clases, las interfaces establecen qué debe hacer una clase sin especificar cómo debe hacerlo.

Una interfaz se define mediante la palabra clave interface y contiene métodos abstractos (sin implementación), constantes y, desde versiones más recientes de Java, también métodos con implementación por defecto. Las clases que implementan una interfaz se comprometen a proporcionar implementaciones para todos los métodos abstractos declarados en ella.

Para declarar una interfaz en Java, se utiliza la siguiente sintaxis:

public interface NombreInterfaz {
    // Constantes
    // Métodos abstractos
}

Por ejemplo, podemos crear una interfaz Dibujable que defina un contrato para objetos que pueden ser dibujados:

public interface Dibujable {
    void dibujar();
    String obtenerColor();
}

En este ejemplo, cualquier clase que implemente la interfaz Dibujable deberá proporcionar implementaciones para los métodos dibujar() y obtenerColor().

Para implementar una interfaz, una clase utiliza la palabra clave implements seguida del nombre de la interfaz:

public class Circulo implements Dibujable {
    private String color;
    private double radio;
    
    public Circulo(String color, double radio) {
        this.color = color;
        this.radio = radio;
    }
    
    @Override
    public void dibujar() {
        System.out.println("Dibujando un círculo de radio " + radio);
    }
    
    @Override
    public String obtenerColor() {
        return color;
    }
}

Al implementar una interfaz, se deben sobrescribir todos los métodos abstractos definidos en ella. Si una clase no implementa todos los métodos, debe declararse como abstracta.

Las interfaces en Java tienen varias características importantes:

  • No pueden ser instanciadas directamente. No se puede crear un objeto a partir de una interfaz usando el operador new.
  • Todos los métodos son implícitamente public y abstract (a menos que se especifique que son métodos default o static).
  • Todas las constantes son implícitamente public, static y final.
  • No pueden contener constructores.

Las constantes en interfaces se definen así:

public interface Configurable {
    int TIEMPO_ESPERA = 1000; // Implícitamente public, static y final
    String SERVIDOR_PREDETERMINADO = "localhost";
    
    void configurar(String servidor);
    boolean estaConfigurado();
}

Las interfaces también pueden extender otras interfaces utilizando la palabra clave extends:

public interface DibujableEnCanvas extends Dibujable {
    void establecerCanvas(String canvas);
    String obtenerCanvas();
}

En este caso, cualquier clase que implemente DibujableEnCanvas deberá proporcionar implementaciones para los métodos de ambas interfaces: dibujar() y obtenerColor() de Dibujable, y establecerCanvas() y obtenerCanvas() de DibujableEnCanvas.

Un aspecto de las interfaces es que permiten implementar un tipo de polimorfismo sin necesidad de herencia de clases. Por ejemplo:

public class GestorDibujo {
    public void procesarDibujable(Dibujable elemento) {
        System.out.println("Procesando elemento de color: " + elemento.obtenerColor());
        elemento.dibujar();
    }
}

Este método procesarDibujo puede recibir cualquier objeto que implemente la interfaz Dibujable, independientemente de su clase concreta:

public class EjemploInterfaces {
    public static void main(String[] args) {
        GestorDibujo gestor = new GestorDibujo();
        
        Dibujable circulo = new Circulo("rojo", 5.0);
        Dibujable rectangulo = new Rectangulo("azul", 10.0, 20.0);
        
        gestor.procesarDibujable(circulo);
        gestor.procesarDibujable(rectangulo);
    }
}

Las interfaces también se utilizan para implementar el patrón de diseño Callback. Por ejemplo, podemos definir una interfaz para manejar eventos:

public interface ManejadorEventos {
    void onEvento(String tipoEvento, Object datos);
}

Y luego implementarla en diferentes clases:

public class LoggerEventos implements ManejadorEventos {
    @Override
    public void onEvento(String tipoEvento, Object datos) {
        System.out.println("LOG: " + tipoEvento + " - " + datos);
    }
}

public class NotificadorEventos implements ManejadorEventos {
    @Override
    public void onEvento(String tipoEvento, Object datos) {
        System.out.println("NOTIFICACIÓN: Se ha producido un " + tipoEvento);
        // Código para enviar notificaciones
    }
}

Un caso de uso común para las interfaces es la implementación de comparadores personalizados. La interfaz Comparator de Java permite definir criterios de ordenación para colecciones:

import java.util.Comparator;

public class ComparadorPorPrecio implements Comparator<Producto> {
    @Override
    public int compare(Producto p1, Producto p2) {
        return Double.compare(p1.getPrecio(), p2.getPrecio());
    }
}

Este comparador se puede utilizar para ordenar una lista de productos por precio:

List<Producto> productos = new ArrayList<>();
// Añadir productos a la lista
productos.sort(new ComparadorPorPrecio());

Las interfaces en Java también pueden contener clases anidadas, que pueden ser útiles para agrupar tipos relacionados:

public interface Reproductor {
    void reproducir();
    void pausar();
    void detener();
    
    interface Controlable {
        void ajustarVolumen(int nivel);
        void silenciar(boolean activar);
    }
}

Una clase puede implementar ambas interfaces:

public class ReproductorMP3 implements Reproductor, Reproductor.Controlable {
    @Override
    public void reproducir() {
        System.out.println("Reproduciendo música");
    }
    
    @Override
    public void pausar() {
        System.out.println("Música en pausa");
    }
    
    @Override
    public void detener() {
        System.out.println("Reproducción detenida");
    }
    
    @Override
    public void ajustarVolumen(int nivel) {
        System.out.println("Volumen ajustado a: " + nivel);
    }
    
    @Override
    public void silenciar(boolean activar) {
        System.out.println("Silencio: " + (activar ? "activado" : "desactivado"));
    }
}

A diferencia de las clases, en Java una clase puede implementar múltiples interfaces, lo que proporciona una forma de conseguir algo similar a la herencia múltiple:

public class SmartTV implements Reproductor, Configurable, Dibujable {
    // Implementaciones de todos los métodos requeridos
}

Métodos default y static en interfaces

Las interfaces en Java evolucionaron mucho a partir de Java 8 con la introducción de los métodos default y métodos static. Estas características ampliaron las posibilidades de las interfaces, permitiéndoles proporcionar implementaciones concretas sin romper la compatibilidad con el código existente.

Métodos default

Los métodos default (o métodos por defecto) permiten añadir nuevas funcionalidades a las interfaces sin obligar a todas las clases que las implementan a proporcionar una implementación. Se declaran utilizando la palabra clave default y deben incluir un cuerpo de método:

public interface Notificador {
    void enviarNotificacion(String mensaje);
    
    default void enviarNotificacionUrgente(String mensaje) {
        System.out.println("URGENTE: " + mensaje);
        enviarNotificacion(mensaje);
    }
}

En este ejemplo, cualquier clase que implemente la interfaz Notificador debe proporcionar una implementación para enviarNotificacion(), pero puede utilizar la implementación predeterminada de enviarNotificacionUrgente() o sobrescribirla si necesita un comportamiento diferente:

public class NotificadorEmail implements Notificador {
    @Override
    public void enviarNotificacion(String mensaje) {
        System.out.println("Enviando email: " + mensaje);
    }
    
    // No es necesario implementar enviarNotificacionUrgente()
}

public class NotificadorSMS implements Notificador {
    @Override
    public void enviarNotificacion(String mensaje) {
        System.out.println("Enviando SMS: " + mensaje);
    }
    
    @Override
    public void enviarNotificacionUrgente(String mensaje) {
        System.out.println("Enviando SMS prioritario: " + mensaje);
        // Lógica específica para SMS urgentes
    }
}

Los métodos default son útiles para:

  • Evolucionar APIs sin romper la compatibilidad con implementaciones existentes
  • Proporcionar métodos de utilidad que operan sobre los métodos abstractos de la interfaz
  • Implementar el patrón Template Method donde se define un algoritmo con pasos personalizables

Dentro de un método default, se puede acceder a:

  • Otros métodos de la interfaz (default o abstractos)
  • Constantes definidas en la interfaz
  • Métodos estáticos de la interfaz o de otras clases
public interface Validador {
    boolean validar(String dato);
    
    default boolean validarNoVacio(String dato) {
        return dato != null && !dato.isEmpty() && validar(dato);
    }
    
    default String normalizarYValidar(String dato) {
        if (dato == null) {
            return null;
        }
        String normalizado = dato.trim().toLowerCase();
        return validar(normalizado) ? normalizado : null;
    }
}

Métodos static

Los métodos static en interfaces proporcionan funcionalidades de utilidad relacionadas con la interfaz sin requerir una instancia. Se declaran con la palabra clave static y, al igual que los métodos default, incluyen un cuerpo de implementación:

public interface Convertidor {
    double convertir(double valor);
    
    static Convertidor invertir(Convertidor convertidor) {
        return valor -> 1.0 / convertidor.convertir(valor);
    }
    
    static Convertidor componer(Convertidor primero, Convertidor segundo) {
        return valor -> segundo.convertir(primero.convertir(valor));
    }
}

Los métodos estáticos de una interfaz se invocan utilizando el nombre de la interfaz, no a través de una instancia:

public class EjemploConvertidores {
    public static void main(String[] args) {
        Convertidor celsiusAFahrenheit = celsius -> (celsius * 9/5) + 32;
        
        // Usando el método estático invertir
        Convertidor fahrenheitACelsius = Convertidor.invertir(
            fahrenheit -> (fahrenheit - 32) * 5/9
        );
        
        // Usando el método estático componer
        Convertidor celsiusAKelvin = Convertidor.componer(
            celsiusAFahrenheit,
            fahrenheit -> (fahrenheit + 459.67) * 5/9
        );
        
        double tempCelsius = 25.0;
        System.out.println(tempCelsius + "°C = " + 
                          celsiusAFahrenheit.convertir(tempCelsius) + "°F");
    }
}

Los métodos estáticos en interfaces son útiles para:

  • Proporcionar métodos de fábrica para crear instancias de implementaciones comunes
  • Ofrecer utilidades relacionadas con la funcionalidad de la interfaz
  • Agrupar operaciones relacionadas en un espacio de nombres lógico

Un ejemplo práctico de métodos estáticos es la interfaz Comparator de Java, que proporciona métodos como comparing(), naturalOrder() y reverseOrder():

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

public class EjemploComparadores {
    public static void main(String[] args) {
        List<Producto> productos = new ArrayList<>();
        productos.add(new Producto("Laptop", 1200.0));
        productos.add(new Producto("Teléfono", 800.0));
        productos.add(new Producto("Tablet", 500.0));
        
        // Usando método estático comparing
        productos.sort(Comparator.comparing(Producto::getPrecio));
        
        // Usando método estático reverseOrder
        Comparator<Producto> porPrecioDescendente = 
            Comparator.comparing(Producto::getPrecio, Comparator.reverseOrder());
        productos.sort(porPrecioDescendente);
    }
}

class Producto {
    private String nombre;
    private double precio;
    
    public Producto(String nombre, double precio) {
        this.nombre = nombre;
        this.precio = precio;
    }
    
    public String getNombre() { return nombre; }
    public double getPrecio() { return precio; }
}

Diferencias con clases abstractas

Aunque los métodos default hacen que las interfaces se parezcan más a las clases abstractas, existen diferencias:

  • Las interfaces no pueden tener estado (campos de instancia), mientras que las clases abstractas sí pueden.
  • Las interfaces no pueden tener constructores.
  • Los métodos default en interfaces no pueden acceder a estado mutable, ya que no hay campos de instancia.
  • Una clase puede implementar múltiples interfaces pero solo puede extender una clase abstracta.
public abstract class FiguraGeometrica {
    protected String color; // Estado
    
    public FiguraGeometrica(String color) { // Constructor
        this.color = color;
    }
    
    public abstract double calcularArea();
    
    public void mostrarInfo() {
        System.out.println("Figura de color " + color + 
                          " con área " + calcularArea());
    }
}

public interface Dibujable {
    void dibujar();
    
    default void dibujarConBorde() {
        System.out.println("Dibujando borde...");
        dibujar();
        System.out.println("Borde completado");
    }
    
    static boolean esVisible(int opacidad) {
        return opacidad > 0;
    }
}

Casos de uso prácticos

Los métodos default y static en interfaces se utilizan mucho en la API estándar de Java:

  • La interfaz Collection incluye métodos default como removeIf(), stream() y forEach().
  • La interfaz List proporciona métodos default como replaceAll() y sort().
  • La interfaz Comparator ofrece métodos static como comparing(), thenComparing() y nullsFirst().

Un ejemplo práctico de uso de métodos default es la implementación de un sistema de validación flexible:

public interface Validador<T> {
    boolean esValido(T valor);
    
    default Validador<T> and(Validador<T> otro) {
        return valor -> this.esValido(valor) && otro.esValido(valor);
    }
    
    default Validador<T> or(Validador<T> otro) {
        return valor -> this.esValido(valor) || otro.esValido(valor);
    }
    
    default Validador<T> not() {
        return valor -> !this.esValido(valor);
    }
    
    static <T> Validador<T> siempre(boolean resultado) {
        return valor -> resultado;
    }
}

Este diseño permite crear validadores complejos mediante composición:

public class ValidacionEjemplo {
    public static void main(String[] args) {
        Validador<String> noVacio = s -> s != null && !s.isEmpty();
        Validador<String> soloLetras = s -> s.matches("[a-zA-Z]+");
        Validador<String> longitudMinima = s -> s.length() >= 5;
        
        // Componiendo validadores con métodos default
        Validador<String> validadorNombre = noVacio
            .and(soloLetras)
            .and(longitudMinima);
        
        System.out.println(validadorNombre.esValido("Juan")); // false (longitud < 5)
        System.out.println(validadorNombre.esValido("Carlos")); // true
        System.out.println(validadorNombre.esValido("Ana123")); // false (tiene números)
        
        // Usando el método estático
        Validador<String> aceptarTodo = Validador.siempre(true);
        Validador<String> rechazarTodo = Validador.siempre(false);
    }
}

Implementación de múltiples interfaces

A diferencia de la herencia de clases, donde una clase solo puede extender una única superclase (herencia simple), Java permite que una clase implemente cualquier número de interfaces, lo que proporciona una forma de conseguir funcionalidad similar a la herencia múltiple sin sus complicaciones tradicionales.

La sintaxis para implementar múltiples interfaces es sencilla, utilizando la palabra clave implements seguida de los nombres de las interfaces separados por comas:

public class MiClase implements Interface1, Interface2, Interface3 {
    // Implementación de todos los métodos requeridos por las tres interfaces
}

Esta capacidad resulta útil cuando se desea que una clase adopte diferentes "roles" o "comportamientos" sin estar limitada por una jerarquía de herencia rígida. Veamos un ejemplo práctico:

public interface Nadador {
    void nadar();
    default void sumergirse() {
        System.out.println("Sumergiéndose bajo el agua");
    }
}

public interface Volador {
    void volar();
    double calcularAlturaMaxima();
}

public interface Cazador {
    void cazar();
    boolean estaEnBusqueda();
}

public class Pato implements Nadador, Volador, Cazador {
    private boolean buscandoComida;
    
    @Override
    public void nadar() {
        System.out.println("El pato nada en el lago");
    }
    
    @Override
    public void volar() {
        System.out.println("El pato vuela sobre el agua");
    }
    
    @Override
    public double calcularAlturaMaxima() {
        return 100.0; // metros
    }
    
    @Override
    public void cazar() {
        System.out.println("El pato caza pequeños peces e insectos");
        this.buscandoComida = true;
    }
    
    @Override
    public boolean estaEnBusqueda() {
        return buscandoComida;
    }
}

En este ejemplo, la clase Pato implementa tres interfaces diferentes, cada una representando un comportamiento distinto. Esto permite que un objeto Pato pueda ser tratado como un Nadador, un Volador o un Cazador según el contexto, lo que facilita un diseño más flexible y modular.

Ventajas de la implementación múltiple

La implementación de múltiples interfaces ofrece varias ventajas:

  • Flexibilidad en el diseño: Permite que una clase adopte diferentes comportamientos sin estar restringida por una única línea de herencia.
  • Separación de responsabilidades: Cada interfaz puede representar un aspecto específico del comportamiento de una clase.
  • Polimorfismo mejorado: Una clase puede ser tratada como cualquiera de las interfaces que implementa.
  • Reutilización de código: Se pueden combinar interfaces existentes para crear nuevos comportamientos.
public class SistemaEcologico {
    public void registrarComportamientoAcuatico(Nadador animal) {
        System.out.println("Registrando comportamiento acuático:");
        animal.nadar();
    }
    
    public void registrarVuelo(Volador animal) {
        System.out.println("Registrando vuelo a altura: " + 
                          animal.calcularAlturaMaxima() + " metros");
        animal.volar();
    }
    
    public void observarCaza(Cazador animal) {
        System.out.println("Observando comportamiento de caza:");
        animal.cazar();
        System.out.println("¿Sigue buscando presa? " + animal.estaEnBusqueda());
    }
}

Este sistema puede trabajar con cualquier objeto que implemente las interfaces correspondientes, sin importar su clase concreta:

public class EjemploSistema {
    public static void main(String[] args) {
        SistemaEcologico sistema = new SistemaEcologico();
        Pato pato = new Pato();
        
        sistema.registrarComportamientoAcuatico(pato);
        sistema.registrarVuelo(pato);
        sistema.observarCaza(pato);
        
        // También funcionaría con otras clases que implementen estas interfaces
        Aguila aguila = new Aguila();
        sistema.registrarVuelo(aguila);
        sistema.observarCaza(aguila);
    }
}

Resolución de conflictos entre interfaces

Cuando se implementan múltiples interfaces, pueden surgir conflictos si dos o más interfaces declaran métodos con la misma firma. Estos conflictos deben resolverse explícitamente en la clase que implementa las interfaces.

Existen varios escenarios de conflicto:

  • Métodos abstractos con la misma firma: La clase debe proporcionar una única implementación que satisfaga a todas las interfaces.
  • Métodos default con la misma firma: La clase debe sobrescribir el método y decidir qué implementación utilizar o proporcionar una nueva.
public interface Reproductor {
    void iniciar();
    
    default void detener() {
        System.out.println("Deteniendo reproducción estándar");
    }
}

public interface Dispositivo {
    void encender();
    void apagar();
    
    default void detener() {
        System.out.println("Deteniendo dispositivo");
    }
}

public class ReproductorMP3 implements Reproductor, Dispositivo {
    @Override
    public void iniciar() {
        System.out.println("Iniciando reproducción de música");
    }
    
    @Override
    public void encender() {
        System.out.println("Encendiendo reproductor MP3");
    }
    
    @Override
    public void apagar() {
        System.out.println("Apagando reproductor MP3");
    }
    
    @Override
    public void detener() {
        // Resolviendo el conflicto entre los métodos default
        Reproductor.super.detener(); // Usando la implementación de Reproductor
        // Dispositivo.super.detener(); // Alternativa: usar la implementación de Dispositivo
        
        // También podemos proporcionar una implementación completamente nueva
        System.out.println("Pausando reproducción y guardando estado");
    }
}

La sintaxis InterfaceNombre.super.metodo() permite acceder específicamente a la implementación default de una interfaz particular, lo que facilita la resolución de conflictos.

Interfaces como tipos

Una característica de las interfaces es que pueden utilizarse como tipos en Java. Esto significa que una variable puede declararse del tipo de una interfaz y referenciar cualquier objeto que implemente dicha interfaz:

public class GestorMultimedia {
    public void procesarContenido(List<Reproductor> reproductores) {
        for (Reproductor reproductor : reproductores) {
            reproductor.iniciar();
            // Procesar contenido
            reproductor.detener();
        }
    }
}

Este método puede recibir una lista de cualquier tipo de objeto que implemente la interfaz Reproductor, independientemente de qué otras interfaces pueda implementar o de su clase concreta.

Patrones de diseño con múltiples interfaces

La implementación de múltiples interfaces facilita varios patrones de diseño comunes:

  • Patrón Adaptador: Permite que una clase existente se adapte a una nueva interfaz sin modificar su código.
// Clase existente que no implementa nuestras interfaces
public class DispositivoExterno {
    public void play() {
        System.out.println("Reproduciendo en dispositivo externo");
    }
    
    public void stop() {
        System.out.println("Deteniendo dispositivo externo");
    }
}

// Adaptador que implementa nuestras interfaces
public class AdaptadorDispositivoExterno implements Reproductor, Dispositivo {
    private DispositivoExterno dispositivo;
    
    public AdaptadorDispositivoExterno(DispositivoExterno dispositivo) {
        this.dispositivo = dispositivo;
    }
    
    @Override
    public void iniciar() {
        dispositivo.play();
    }
    
    @Override
    public void detener() {
        dispositivo.stop();
    }
    
    @Override
    public void encender() {
        System.out.println("Encendiendo dispositivo externo");
    }
    
    @Override
    public void apagar() {
        System.out.println("Apagando dispositivo externo");
    }
}
  • Patrón Decorador: Permite añadir funcionalidades a objetos existentes dinámicamente.
public interface Mensaje {
    String getContenido();
}

public class MensajeTexto implements Mensaje {
    private String texto;
    
    public MensajeTexto(String texto) {
        this.texto = texto;
    }
    
    @Override
    public String getContenido() {
        return texto;
    }
}

// Decorador que implementa la misma interfaz
public class MensajeEncriptado implements Mensaje {
    private Mensaje mensajeOriginal;
    
    public MensajeEncriptado(Mensaje mensaje) {
        this.mensajeOriginal = mensaje;
    }
    
    @Override
    public String getContenido() {
        return encriptar(mensajeOriginal.getContenido());
    }
    
    private String encriptar(String texto) {
        // Lógica de encriptación simple
        return "ENCRIPTADO[" + texto + "]";
    }
}

Interfaces anidadas

Java también permite definir interfaces anidadas dentro de otras interfaces o clases. Cuando se implementan múltiples interfaces, también se pueden implementar estas interfaces anidadas:

public interface Dispositivo {
    void encender();
    void apagar();
    
    interface Configurable {
        void configurar(String parametro, String valor);
        Map<String, String> obtenerConfiguracion();
    }
}

public interface Reproductor {
    void iniciar();
    void detener();
    
    interface ControlVolumen {
        void subirVolumen();
        void bajarVolumen();
        int obtenerNivelVolumen();
    }
}

public class SmartTV implements Dispositivo, Reproductor, 
                              Dispositivo.Configurable, Reproductor.ControlVolumen {
    private boolean encendido;
    private int volumen = 10;
    private Map<String, String> configuracion = new HashMap<>();
    
    @Override
    public void encender() {
        encendido = true;
        System.out.println("TV encendida");
    }
    
    @Override
    public void apagar() {
        encendido = false;
        System.out.println("TV apagada");
    }
    
    @Override
    public void iniciar() {
        if (encendido) {
            System.out.println("Iniciando reproducción en TV");
        }
    }
    
    @Override
    public void detener() {
        System.out.println("Deteniendo reproducción en TV");
    }
    
    @Override
    public void configurar(String parametro, String valor) {
        configuracion.put(parametro, valor);
        System.out.println("Configurando " + parametro + " = " + valor);
    }
    
    @Override
    public Map<String, String> obtenerConfiguracion() {
        return new HashMap<>(configuracion);
    }
    
    @Override
    public void subirVolumen() {
        if (volumen < 100) volumen += 5;
        System.out.println("Volumen: " + volumen);
    }
    
    @Override
    public void bajarVolumen() {
        if (volumen > 0) volumen -= 5;
        System.out.println("Volumen: " + volumen);
    }
    
    @Override
    public int obtenerNivelVolumen() {
        return volumen;
    }
}

Interfaces y herencia

Es posible combinar la implementación de múltiples interfaces con la herencia de clases. Una clase puede extender otra clase y al mismo tiempo implementar varias interfaces:

public abstract class DispositivoElectronico {
    protected boolean encendido;
    
    public void encender() {
        encendido = true;
        System.out.println("Dispositivo encendido");
    }
    
    public void apagar() {
        encendido = false;
        System.out.println("Dispositivo apagado");
    }
    
    public boolean estaEncendido() {
        return encendido;
    }
}

public class TabletMultimedia extends DispositivoElectronico 
                             implements Reproductor, Dispositivo.Configurable {
    private Map<String, String> configuracion = new HashMap<>();
    
    @Override
    public void iniciar() {
        if (estaEncendido()) {
            System.out.println("Iniciando reproducción en tablet");
        }
    }
    
    @Override
    public void detener() {
        System.out.println("Deteniendo reproducción en tablet");
    }
    
    @Override
    public void configurar(String parametro, String valor) {
        configuracion.put(parametro, valor);
    }
    
    @Override
    public Map<String, String> obtenerConfiguracion() {
        return new HashMap<>(configuracion);
    }
    
    // Sobrescribimos un método heredado
    @Override
    public void encender() {
        super.encender(); // Llamamos al método de la superclase
        System.out.println("Iniciando sistema operativo de la tablet");
    }
}

En este ejemplo, TabletMultimedia hereda comportamiento de DispositivoElectronico y además implementa las interfaces Reproductor y Dispositivo.Configurable.

Consideraciones prácticas

Al implementar múltiples interfaces, se deben tener en cuenta algunas consideraciones importantes:

  • Principio de responsabilidad única: Aunque una clase puede implementar muchas interfaces, es recomendable que cada interfaz represente una responsabilidad o capacidad bien definida.
  • Cohesión: Las interfaces relacionadas pueden agruparse mediante herencia de interfaces.
  • Evitar interfaces demasiado grandes: Es preferible tener varias interfaces pequeñas y específicas que una interfaz grande con muchos métodos.
  • Documentación clara: Es importante documentar el propósito de cada interfaz y cómo se espera que interactúen entre sí.
// Ejemplo de interfaces cohesivas y específicas
public interface Autenticable {
    boolean autenticar(String usuario, String contraseña);
    void cerrarSesion();
}

public interface Autorizable {
    boolean tienePermiso(String accion);
    List<String> obtenerPermisos();
}

// Una clase puede implementar ambas si necesita ambas capacidades
public class Usuario implements Autenticable, Autorizable {
    private String nombre;
    private String contraseñaHash;
    private List<String> permisos;
    
    // Implementaciones de los métodos...
}

Interfaces funcionales y su papel en la programación funcional

Una interfaz funcional se define como aquella que contiene exactamente un método abstracto, aunque puede incluir cualquier número de métodos default o static. Este único método abstracto define la "firma funcional" que determina cómo se puede utilizar la interfaz en contextos de programación funcional.

Para identificar explícitamente una interfaz como funcional, se utiliza la anotación @FunctionalInterface:

@FunctionalInterface
public interface Calculadora {
    int calcular(int a, int b);
}

Esta anotación no es obligatoria, pero proporciona dos beneficios importantes: documenta la intención del diseño y permite que el compilador verifique que la interfaz cumpla con los requisitos (tener exactamente un método abstracto). Si se intenta añadir un segundo método abstracto a una interfaz anotada con @FunctionalInterface, el compilador generará un error.

Expresiones lambda y interfaces funcionales

Las expresiones lambda proporcionan una sintaxis concisa para implementar interfaces funcionales sin necesidad de crear clases anónimas explícitas:

// Implementación tradicional con clase anónima
Calculadora suma = new Calculadora() {
    @Override
    public int calcular(int a, int b) {
        return a + b;
    }
};

// Implementación equivalente con expresión lambda
Calculadora sumaLambda = (a, b) -> a + b;

// Uso de la interfaz funcional
int resultado = sumaLambda.calcular(5, 3);  // resultado = 8

La expresión lambda (a, b) -> a + b implementa implícitamente el método calcular de la interfaz Calculadora. El compilador infiere los tipos de parámetros basándose en el contexto.

Interfaces funcionales predefinidas

Java proporciona un conjunto de interfaces funcionales predefinidas en el paquete java.util.function que cubren los patrones más comunes. Estas interfaces están diseñadas para ser utilizadas como tipos para expresiones lambda y referencias a métodos:

  • Consumer<T>: Acepta un argumento de tipo T y no devuelve ningún resultado.
Consumer<String> impresor = mensaje -> System.out.println("Mensaje: " + mensaje);
impresor.accept("Hola mundo");  // Imprime: Mensaje: Hola mundo
  • Supplier<T>: No acepta argumentos pero produce un resultado de tipo T.
Supplier<LocalDateTime> obtenerFechaHora = () -> LocalDateTime.now();
LocalDateTime ahora = obtenerFechaHora.get();  // Obtiene la fecha y hora actual
  • Function<T, R>: Acepta un argumento de tipo T y produce un resultado de tipo R.
Function<String, Integer> longitud = texto -> texto.length();
int tamaño = longitud.apply("Programación funcional");  // tamaño = 22
  • Predicate<T>: Acepta un argumento de tipo T y devuelve un boolean.
Predicate<String> esMayorDeEdad = texto -> {
    try {
        return Integer.parseInt(texto) >= 18;
    } catch (NumberFormatException e) {
        return false;
    }
};
boolean resultado = esMayorDeEdad.test("20");  // resultado = true
  • BiFunction<T, U, R>: Acepta dos argumentos de tipos T y U, y produce un resultado de tipo R.
BiFunction<String, String, String> concatenar = (a, b) -> a + " " + b;
String nombreCompleto = concatenar.apply("Juan", "Pérez");  // nombreCompleto = "Juan Pérez"
  • UnaryOperator<T>: Caso especial de Function donde el tipo de entrada y salida es el mismo.
UnaryOperator<String> mayusculas = texto -> texto.toUpperCase();
String textoMayusculas = mayusculas.apply("java");  // textoMayusculas = "JAVA"
  • BinaryOperator<T>: Caso especial de BiFunction donde todos los tipos son iguales.
BinaryOperator<Integer> multiplicar = (a, b) -> a * b;
int producto = multiplicar.apply(4, 5);  // producto = 20

Composición de funciones

Una característica de las interfaces funcionales es la capacidad de componer operaciones mediante métodos default como andThen() y compose():

Function<String, String> eliminarEspacios = s -> s.replace(" ", "");
Function<String, Integer> contarCaracteres = s -> s.length();

// Composición de funciones
Function<String, Integer> contarSinEspacios = eliminarEspacios.andThen(contarCaracteres);

int resultado = contarSinEspacios.apply("Hola mundo");  // resultado = 9

En este ejemplo, andThen() crea una nueva función que primero aplica eliminarEspacios y luego aplica contarCaracteres al resultado. También existe el método compose() que aplica las funciones en orden inverso.

Referencias a métodos

Las referencias a métodos proporcionan una sintaxis aún más concisa para implementar interfaces funcionales cuando la implementación simplemente llama a un método existente:

// Diferentes tipos de referencias a métodos
Function<String, Integer> parser = Integer::parseInt;  // Referencia a método estático
Consumer<String> printer = System.out::println;  // Referencia a método de instancia de objeto particular
Function<String, String> toUpper = String::toUpperCase;  // Referencia a método de instancia de tipo
BiFunction<String, Integer, String> substring = String::substring;  // Referencia a método con parámetros
Supplier<ArrayList<String>> listFactory = ArrayList::new;  // Referencia a constructor

La sintaxis Clase::método o objeto::método permite referenciar métodos existentes sin necesidad de escribir una expresión lambda completa.

Creación de interfaces funcionales personalizadas

Aunque Java proporciona un amplio conjunto de interfaces funcionales predefinidas, a veces es necesario crear interfaces funcionales personalizadas para casos específicos:

@FunctionalInterface
public interface Validador<T> {
    boolean validar(T valor, String contexto);
    
    default Validador<T> and(Validador<T> otro) {
        return (valor, contexto) -> this.validar(valor, contexto) && 
                                   otro.validar(valor, contexto);
    }
    
    default Validador<T> or(Validador<T> otro) {
        return (valor, contexto) -> this.validar(valor, contexto) || 
                                   otro.validar(valor, contexto);
    }
}

Esta interfaz funcional personalizada incluye métodos default que permiten combinar validadores mediante operaciones lógicas:

Validador<String> noVacio = (s, ctx) -> {
    boolean valido = s != null && !s.isEmpty();
    if (!valido) System.out.println(ctx + ": El valor no puede estar vacío");
    return valido;
};

Validador<String> soloLetras = (s, ctx) -> {
    boolean valido = s.matches("[a-zA-Z]+");
    if (!valido) System.out.println(ctx + ": El valor debe contener solo letras");
    return valido;
};

// Combinando validadores
Validador<String> validadorNombre = noVacio.and(soloLetras);

// Uso del validador combinado
validadorNombre.validar("", "Nombre");  // Imprime: Nombre: El valor no puede estar vacío
validadorNombre.validar("Juan123", "Nombre");  // Imprime: Nombre: El valor debe contener solo letras
boolean esValido = validadorNombre.validar("Juan", "Nombre");  // esValido = true

Interfaces funcionales en colecciones y streams

Las interfaces funcionales son fundamentales para la API Stream de Java, que permite el procesamiento declarativo de colecciones:

List<String> nombres = List.of("Ana", "Juan", "María", "Pedro", "Lucía");

// Uso de Predicate para filtrar
List<String> nombresFiltrados = nombres.stream()
    .filter(nombre -> nombre.length() > 4)
    .collect(Collectors.toList());  // [María, Pedro, Lucía]

// Uso de Function para transformar
List<Integer> longitudes = nombres.stream()
    .map(String::length)
    .collect(Collectors.toList());  // [3, 4, 5, 5, 5]

// Uso de Consumer para procesar elementos
nombres.forEach(nombre -> System.out.println("Hola, " + nombre));

En este ejemplo, filter() utiliza un Predicate<String>, map() utiliza un Function<String, Integer>, y forEach() utiliza un Consumer<String>.

Closures y captura de variables

Las expresiones lambda en Java pueden capturar variables del contexto circundante, creando lo que se conoce como closures:

public class EjemploClosure {
    public static void main(String[] args) {
        String prefijo = "Usuario: ";
        
        // La lambda captura la variable prefijo
        Consumer<String> saludar = nombre -> System.out.println(prefijo + nombre);
        
        saludar.accept("María");  // Imprime: Usuario: María
    }
}

Sin embargo, las variables capturadas deben ser efectivamente finales (no modificadas después de su inicialización). Si se intenta modificar una variable capturada, el compilador generará un error:

String prefijo = "Usuario: ";
Consumer<String> saludar = nombre -> System.out.println(prefijo + nombre);
prefijo = "Cliente: ";  // Error de compilación: variable usada en lambda debe ser final o efectivamente final

Currying y aplicación parcial

El currying es una técnica de programación funcional que transforma una función con múltiples argumentos en una secuencia de funciones con un solo argumento. En Java, se puede implementar utilizando interfaces funcionales anidadas:

public class Currying {
    public static void main(String[] args) {
        // Función tradicional de dos parámetros
        BiFunction<Integer, Integer, Integer> suma = (a, b) -> a + b;
        
        // Versión currificada
        Function<Integer, Function<Integer, Integer>> sumaC = a -> b -> a + b;
        
        // Uso de la función currificada
        Function<Integer, Integer> sumar5 = sumaC.apply(5);  // Aplicación parcial
        int resultado = sumar5.apply(3);  // resultado = 8
        
        // También se puede usar directamente
        int resultado2 = sumaC.apply(2).apply(3);  // resultado2 = 5
    }
}

La aplicación parcial permite crear nuevas funciones especializadas a partir de funciones más generales, fijando algunos de sus parámetros.

Patrones de diseño funcionales

Las interfaces funcionales facilitan la implementación de varios patrones de diseño de manera más concisa:

  • Patrón Strategy: Permite definir una familia de algoritmos y hacerlos intercambiables.
// Definición de la estrategia como interfaz funcional
@FunctionalInterface
interface EstrategiaOrdenamiento<T> {
    List<T> ordenar(List<T> elementos);
}

class Ordenador<T> {
    private EstrategiaOrdenamiento<T> estrategia;
    
    public Ordenador(EstrategiaOrdenamiento<T> estrategia) {
        this.estrategia = estrategia;
    }
    
    public List<T> ejecutarOrdenamiento(List<T> elementos) {
        return estrategia.ordenar(elementos);
    }
}

// Uso con expresiones lambda
EstrategiaOrdenamiento<Integer> ascendente = lista -> {
    List<Integer> copia = new ArrayList<>(lista);
    Collections.sort(copia);
    return copia;
};

EstrategiaOrdenamiento<Integer> descendente = lista -> {
    List<Integer> copia = new ArrayList<>(lista);
    copia.sort(Collections.reverseOrder());
    return copia;
};

Ordenador<Integer> ordenador = new Ordenador<>(ascendente);
List<Integer> resultado = ordenador.ejecutarOrdenamiento(List.of(3, 1, 4, 1, 5, 9));
  • Patrón Command: Encapsula una solicitud como un objeto.
@FunctionalInterface
interface Comando {
    void ejecutar();
}

class ControlRemoto {
    private final Map<Integer, Comando> botones = new HashMap<>();
    
    public void asignarComando(int boton, Comando comando) {
        botones.put(boton, comando);
    }
    
    public void presionarBoton(int boton) {
        Comando comando = botones.get(boton);
        if (comando != null) {
            comando.ejecutar();
        }
    }
}

// Uso con expresiones lambda
ControlRemoto control = new ControlRemoto();
control.asignarComando(1, () -> System.out.println("Encendiendo TV"));
control.asignarComando(2, () -> System.out.println("Cambiando canal"));
control.presionarBoton(1);  // Imprime: Encendiendo TV

Integración con APIs tradicionales

Las interfaces funcionales permiten modernizar APIs tradicionales basadas en interfaces con un único método:

// API tradicional
public interface ActionListener {
    void actionPerformed(ActionEvent e);
}

// Uso tradicional
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        System.out.println("Botón presionado");
    }
});

// Uso moderno con lambda
button.addActionListener(e -> System.out.println("Botón presionado"));

Muchas APIs de Java que fueron diseñadas antes de Java 8 pueden beneficiarse de la sintaxis lambda si sus interfaces tienen un solo método abstracto.

Consideraciones de rendimiento

Las interfaces funcionales y las expresiones lambda están optimizadas en la JVM moderna, pero hay algunas consideraciones de rendimiento a tener en cuenta:

  • Las expresiones lambda se compilan a clases anónimas, pero con optimizaciones especiales.
  • La captura de variables puede tener un pequeño impacto en el rendimiento.
  • Para operaciones intensivas en bucles, las implementaciones tradicionales pueden ser ligeramente más rápidas.
// Ejemplo de benchmark simple
long inicio = System.nanoTime();

// Versión imperativa tradicional
int suma = 0;
for (int i = 0; i < 1000000; i++) {
    if (i % 2 == 0) {
        suma += i;
    }
}

// Versión funcional
int sumaFuncional = IntStream.range(0, 1000000)
    .filter(i -> i % 2 == 0)
    .sum();

long fin = System.nanoTime();

En la mayoría de los casos, la diferencia de rendimiento es insignificante comparada con los beneficios en legibilidad y mantenibilidad que ofrece el estilo funcional.

Aprende Java online

Otros ejercicios de programación de Java

Evalúa tus conocimientos de esta lección Interfaces con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Streams: match

Test

Gestión de errores y excepciones

Código

CRUD en Java de modelo Customer sobre un ArrayList

Proyecto

Clases abstractas

Test

Listas

Código

Métodos de la clase String

Código

Streams: reduce()

Test

API java.nio 2

Puzzle

Polimorfismo

Código

Pattern Matching

Código

Streams: flatMap()

Test

Llamada y sobrecarga de funciones

Puzzle

Métodos referenciados

Test

Métodos de la clase String

Código

Representación de Fecha

Puzzle

Operadores lógicos

Test

Inferencia de tipos con var

Código

Tipos de datos

Código

Estructuras de iteración

Puzzle

Streams: forEach()

Test

Objetos

Puzzle

Funciones lambda

Test

Uso de Scanner

Puzzle

Tipos de variables

Puzzle

Streams: collect()

Puzzle

Operadores aritméticos

Puzzle

Arrays y matrices

Código

Clases y objetos

Código

Interfaz funcional Consumer

Test

CRUD en Java de modelo Customer sobre un HashMap

Proyecto

Interfaces

Código

Enumeraciones Enums

Código

API Optional

Test

Interfaz funcional Function

Test

Encapsulación

Test

Interfaces

Código

Uso de API Optional

Puzzle

Representación de Hora

Test

Herencia básica

Test

Clases y objetos

Código

Interfaz funcional Supplier

Puzzle

HashMap

Puzzle

Sobrecarga de métodos

Test

Polimorfismo de tiempo de ejecución

Puzzle

OOP en Java

Proyecto

Sobrecarga de métodos

Código

CRUD de productos en Java

Proyecto

Clases sealed

Código

Creación de Streams

Test

Records

Código

Encapsulación

Código

Streams: min max

Puzzle

Herencia

Código

Métodos avanzados de la clase String

Puzzle

Funciones

Código

Polimorfismo de tiempo de compilación

Test

Reto sintaxis Java

Proyecto

Conjuntos

Código

Estructuras de control

Código

Recursión

Código

Excepciones

Puzzle

Herencia avanzada

Puzzle

Estructuras de selección

Test

Uso de interfaces

Test

Operadores

Código

Variables

Código

HashSet

Test

Objeto Scanner

Test

Streams: filter()

Puzzle

Operaciones de Streams

Puzzle

Interfaz funcional Predicate

Puzzle

Streams: sorted()

Test

Configuración de entorno

Test

Uso de variables

Test

Clases

Test

Streams: distinct()

Puzzle

Streams: count()

Test

ArrayList

Test

Mapas

Código

Datos de referencia

Test

Interfaces funcionales

Puzzle

Métodos básicos de la clase String

Test

Tipos de datos

Código

Clases abstractas

Código

Instalación

Test

Funciones

Código

Excepciones

Código

Estructuras de control

Código

Herencia de clases

Código

La clase Scanner

Código

Generics

Código

Streams: map()

Puzzle

Funciones y encapsulamiento

Test

Todas las lecciones de Java

Accede a todas las lecciones de Java y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Instalación De Java

Introducción Y Entorno

Configuración De Entorno Java

Introducción Y Entorno

Tipos De Datos

Sintaxis

Variables

Sintaxis

Operadores

Sintaxis

Estructuras De Control

Sintaxis

Funciones

Sintaxis

Recursión

Sintaxis

Arrays Y Matrices

Sintaxis

Excepciones

Programación Orientada A Objetos

Clases Y Objetos

Programación Orientada A Objetos

Encapsulación

Programación Orientada A Objetos

Herencia

Programación Orientada A Objetos

Clases Abstractas

Programación Orientada A Objetos

Interfaces

Programación Orientada A Objetos

Sobrecarga De Métodos

Programación Orientada A Objetos

Polimorfismo

Programación Orientada A Objetos

La Clase Scanner

Programación Orientada A Objetos

Métodos De La Clase String

Programación Orientada A Objetos

Excepciones

Programación Orientada A Objetos

Records

Programación Orientada A Objetos

Pattern Matching

Programación Orientada A Objetos

Inferencia De Tipos Con Var

Programación Orientada A Objetos

Enumeraciones Enums

Programación Orientada A Objetos

Generics

Programación Orientada A Objetos

Clases Sealed

Programación Orientada A Objetos

Listas

Framework Collections

Conjuntos

Framework Collections

Mapas

Framework Collections

Funciones Lambda

Programación Funcional

Interfaz Funcional Consumer

Programación Funcional

Interfaz Funcional Predicate

Programación Funcional

Interfaz Funcional Supplier

Programación Funcional

Interfaz Funcional Function

Programación Funcional

Métodos Referenciados

Programación Funcional

Creación De Streams

Programación Funcional

Operaciones Intermedias Con Streams: Map()

Programación Funcional

Operaciones Intermedias Con Streams: Filter()

Programación Funcional

Operaciones Intermedias Con Streams: Distinct()

Programación Funcional

Operaciones Finales Con Streams: Collect()

Programación Funcional

Operaciones Finales Con Streams: Min Max

Programación Funcional

Operaciones Intermedias Con Streams: Flatmap()

Programación Funcional

Operaciones Intermedias Con Streams: Sorted()

Programación Funcional

Operaciones Finales Con Streams: Reduce()

Programación Funcional

Operaciones Finales Con Streams: Foreach()

Programación Funcional

Operaciones Finales Con Streams: Count()

Programación Funcional

Operaciones Finales Con Streams: Match

Programación Funcional

Api Optional

Programación Funcional

Transformación

Programación Funcional

Reducción Y Acumulación

Programación Funcional

Mapeo

Programación Funcional

Streams Paralelos

Programación Funcional

Agrupación Y Partición

Programación Funcional

Filtrado Y Búsqueda

Programación Funcional

Api Java.nio 2

Entrada Y Salida Io

Fundamentos De Io

Entrada Y Salida Io

Leer Y Escribir Archivos

Entrada Y Salida Io

Httpclient Moderno

Entrada Y Salida Io

Clases De Nio2

Entrada Y Salida Io

Api Java.time

Api Java.time

Localtime

Api Java.time

Localdatetime

Api Java.time

Localdate

Api Java.time

Executorservice

Concurrencia

Virtual Threads (Project Loom)

Concurrencia

Future Y Completablefuture

Concurrencia

Spring Framework

Frameworks Para Java

Micronaut

Frameworks Para Java

Maven

Frameworks Para Java

Gradle

Frameworks Para Java

Lombok Para Java

Frameworks Para Java

Quarkus

Frameworks Para Java

Ecosistema Jakarta Ee De Java

Frameworks Para Java

Introducción A Junit 5

Testing

Accede GRATIS a Java y certifícate

Certificados de superación de Java

Supera todos los ejercicios de programación del curso de Java y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.

En esta lección

Objetivos de aprendizaje de esta lección

  • Entender qué es una interfaz y cómo se declara en Java
  • Aprender a implementar una interfaz en una clase
  • Comprender la importancia de las interfaces para la abstracción y la emulación de herencia múltiple
  • Entender los métodos predeterminados en interfaces, introducidos en Java 8
  • Aprender sobre las interfaces funcionales y cómo se utilizan para definir funciones lambda
  • Comprender cómo las interfaces contribuyen a la creación de código limpio, modular y reutilizable