Java

Tutorial Java: Interfaz funcional Consumer

Aprende la interfaz funcional Consumer en Java 8, su método accept(), uso con genéricos y encadenamiento andThen() para programación funcional.

Aprende Java y certifícate

¿Qué es la interfaz Consumer?

La interfaz Consumer<T> es una de las interfaces funcionales fundamentales introducidas en Java 8 como parte del paquete java.util.function. Esta interfaz está diseñada para representar operaciones que aceptan un único argumento de entrada y no producen ningún resultado como salida. Como su nombre sugiere, un Consumer simplemente "consume" o procesa un valor sin devolver nada.

En el paradigma de la programación funcional, los consumidores son útiles cuando necesitamos realizar alguna acción o efecto secundario con los datos, pero no necesitamos un valor de retorno. Algunos ejemplos típicos incluyen mostrar información en consola, escribir datos en un archivo, o modificar el estado de un objeto.

La definición básica de la interfaz Consumer es:

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    
    // Método default para composición
    default Consumer<T> andThen(Consumer<? super T> after) {
        // Implementación omitida
    }
}

Siendo una interfaz funcional, Consumer contiene exactamente un método abstracto: accept(). Esto significa que puede ser implementada mediante expresiones lambda o referencias a métodos, lo que la hace ideal para el estilo de programación funcional en Java.

Veamos un ejemplo sencillo de cómo podemos utilizar un Consumer:

import java.util.function.Consumer;

public class ConsumerExample {
    public static void main(String[] args) {
        // Crear un Consumer usando una expresión lambda
        Consumer<String> printUpperCase = text -> System.out.println(text.toUpperCase());
        
        // Usar el Consumer
        printUpperCase.accept("hola mundo"); // Imprime: HOLA MUNDO
    }
}

Los Consumers son especialmente útiles cuando trabajamos con colecciones y queremos realizar alguna operación con cada elemento. Por ejemplo, el método forEach() de las colecciones en Java acepta un Consumer como parámetro:

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class ConsumerWithCollections {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Ana", "Juan", "Carlos", "María");
        
        // Usando un Consumer con forEach
        Consumer<String> printName = name -> System.out.println("Nombre: " + name);
        names.forEach(printName);
        
        // O de forma más concisa
        names.forEach(name -> System.out.println("Nombre: " + name));
    }
}

También podemos usar referencias a métodos para crear Consumers de manera más concisa:

import java.util.Arrays;
import java.util.List;

public class MethodReferenceConsumer {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Ana", "Juan", "Carlos", "María");
        
        // Usando una referencia a método
        names.forEach(System.out::println);
    }
}

Una característica importante de los Consumers es que pueden ser utilizados para efectos secundarios controlados dentro de un flujo de programación funcional. Por ejemplo, podemos usar un Consumer para registrar información de depuración en medio de un pipeline de Stream:

import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;

public class DebugWithConsumer {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        
        Consumer<Integer> debugConsumer = n -> System.out.println("Procesando: " + n);
        
        // Usar el Consumer para depuración en un pipeline de Stream
        numbers.stream()
               .peek(debugConsumer)
               .map(n -> n * 2)
               .peek(n -> System.out.println("Después de multiplicar: " + n))
               .filter(n -> n > 5)
               .forEach(System.out::println);
    }
}

En este ejemplo, utilizamos el método peek() que acepta un Consumer para observar los elementos a medida que fluyen a través del Stream, sin modificar el flujo en sí.

Los Consumers también son útiles para actualizar el estado de objetos existentes:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

class Person {
    private String name;
    private int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public void setName(String name) { this.name = name; }
    public void setAge(int age) { this.age = age; }
    
    @Override
    public String toString() {
        return "Person{name='" + name + "', age=" + age + "}";
    }
}

public class ConsumerForUpdates {
    public static void main(String[] args) {
        List<Person> people = new ArrayList<>();
        people.add(new Person("Ana", 25));
        people.add(new Person("Juan", 30));
        
        // Consumer para actualizar la edad
        Consumer<Person> incrementAge = person -> person.setAge(person.age + 1);
        
        // Aplicar el Consumer a cada persona
        people.forEach(incrementAge);
        
        // Mostrar resultados
        people.forEach(System.out::println);
    }
}

En resumen, la interfaz Consumer es una herramienta fundamental en la programación funcional en Java que nos permite:

  • Realizar operaciones que no requieren un valor de retorno
  • Trabajar con colecciones de manera más expresiva
  • Implementar efectos secundarios controlados
  • Actualizar el estado de objetos de manera concisa

Su simplicidad y flexibilidad la convierten en un componente esencial para escribir código más limpio y expresivo en el estilo funcional.

El método accept()

El método accept() es el núcleo funcional de la interfaz Consumer. Como único método abstracto de esta interfaz funcional, define la operación que se realizará sobre el elemento proporcionado. Su firma es simple pero poderosa:

void accept(T t);

Esta firma nos indica dos características fundamentales:

  • Recibe un parámetro de tipo T (el tipo genérico definido por el Consumer)
  • No devuelve ningún valor (void)

El método accept() está diseñado para realizar alguna acción o procesamiento sobre el elemento que recibe, sin producir un resultado que deba ser devuelto. Esta característica lo hace ideal para operaciones que generan efectos secundarios controlados, como:

import java.util.function.Consumer;

public class AcceptMethodExample {
    public static void main(String[] args) {
        // Consumer que imprime un mensaje con el valor recibido
        Consumer<Integer> printSquare = number -> {
            int result = number * number;
            System.out.println("El cuadrado de " + number + " es: " + result);
        };
        
        // Invocamos el método accept() directamente
        printSquare.accept(5);  // Imprime: El cuadrado de 5 es: 25
        printSquare.accept(8);  // Imprime: El cuadrado de 8 es: 64
    }
}

Aunque normalmente usamos Consumers a través de métodos como forEach(), es importante entender que estos métodos internamente invocan accept() por nosotros. Podemos llamar a este método directamente cuando necesitamos aplicar la operación a un elemento específico.

Implementaciones personalizadas

Podemos implementar el método accept() de varias formas:

  • Clase anónima tradicional:
Consumer<String> printer = new Consumer<String>() {
    @Override
    public void accept(String text) {
        System.out.println("Mensaje: " + text);
    }
};
printer.accept("Hola mundo");  // Imprime: Mensaje: Hola mundo
  • Expresión lambda (más concisa):
Consumer<String> printer = text -> System.out.println("Mensaje: " + text);
printer.accept("Hola mundo");  // Imprime: Mensaje: Hola mundo
  • Referencia a método:
Consumer<String> printer = System.out::println;
printer.accept("Hola mundo");  // Imprime: Hola mundo

Casos de uso prácticos

El método accept() es especialmente útil en escenarios donde necesitamos:

  • Modificar objetos existentes:
import java.util.function.Consumer;

class Product {
    private String name;
    private double price;
    
    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }
    
    public void setPrice(double price) { this.price = price; }
    
    @Override
    public String toString() {
        return "Product{name='" + name + "', price=" + price + "}";
    }
}

public class ModifyObjectExample {
    public static void main(String[] args) {
        Product laptop = new Product("Laptop", 1000.0);
        
        // Consumer para aplicar descuento
        Consumer<Product> applyDiscount = product -> product.setPrice(product.price * 0.9);
        
        System.out.println("Antes: " + laptop);
        applyDiscount.accept(laptop);
        System.out.println("Después: " + laptop);
    }
}
  • Realizar validaciones con efectos secundarios:
import java.util.function.Consumer;

public class ValidationExample {
    public static void main(String[] args) {
        Consumer<String> validateEmail = email -> {
            if (!email.contains("@")) {
                System.err.println("Email inválido: " + email);
                // Podríamos lanzar una excepción o registrar el error
            }
        };
        
        validateEmail.accept("usuario@dominio.com");  // No imprime nada (email válido)
        validateEmail.accept("usuario.dominio.com");  // Imprime: Email inválido: usuario.dominio.com
    }
}
  • Ejecutar operaciones en cascada:
import java.util.function.Consumer;

public class ProcessingStepsExample {
    public static void main(String[] args) {
        // Definimos varios pasos de procesamiento
        Consumer<String> logInput = input -> System.out.println("Procesando: " + input);
        Consumer<String> convertToUpperCase = input -> System.out.println("En mayúsculas: " + input.toUpperCase());
        Consumer<String> countChars = input -> System.out.println("Longitud: " + input.length());
        
        // Procesamos un texto aplicando cada paso
        String text = "Java Consumer";
        logInput.accept(text);
        convertToUpperCase.accept(text);
        countChars.accept(text);
    }
}

Comportamiento con valores nulos

Es importante tener en cuenta que el método accept() puede lanzar una NullPointerException si la implementación intenta realizar operaciones sobre un valor nulo:

Consumer<String> lengthPrinter = str -> System.out.println("Longitud: " + str.length());

lengthPrinter.accept("Hola");     // Funciona correctamente
lengthPrinter.accept(null);       // Lanza NullPointerException

Para manejar valores nulos de forma segura, podemos implementar una verificación dentro del Consumer:

Consumer<String> safeLengthPrinter = str -> {
    if (str != null) {
        System.out.println("Longitud: " + str.length());
    } else {
        System.out.println("El texto es nulo");
    }
};

safeLengthPrinter.accept(null);  // Imprime: El texto es nulo

Rendimiento y consideraciones

El método accept() está diseñado para ser eficiente y ligero. Al no devolver ningún valor, evita la creación de objetos innecesarios. Sin embargo, hay algunas consideraciones importantes:

  • Las implementaciones de accept() deberían ser puras en la medida de lo posible, evitando efectos secundarios no controlados.
  • Para operaciones intensivas, considere la posibilidad de procesar por lotes en lugar de elementos individuales.
  • Si necesita encadenar múltiples operaciones, considere usar el método andThen() en lugar de llamadas separadas a accept().

El método accept() es la esencia de la interfaz Consumer, proporcionando un mecanismo simple pero potente para procesar datos sin necesidad de devolver resultados, lo que lo hace ideal para operaciones de transformación, validación y efectos secundarios controlados en programación funcional.

Consumer con tipos genéricos

La interfaz Consumer<T> aprovecha el sistema de tipos genéricos de Java para proporcionar flexibilidad y seguridad de tipos al trabajar con diferentes clases de datos. El parámetro de tipo T permite que un Consumer opere sobre cualquier tipo de objeto que especifiquemos al momento de crear la instancia.

Esta característica nos permite crear consumidores especializados para diferentes tipos de datos sin necesidad de duplicar código. Veamos cómo funciona:

import java.util.function.Consumer;

public class GenericConsumerExample {
    public static void main(String[] args) {
        // Consumer para Strings
        Consumer<String> stringConsumer = s -> System.out.println("Procesando string: " + s);
        
        // Consumer para Integer
        Consumer<Integer> intConsumer = i -> System.out.println("Procesando entero: " + i * 2);
        
        // Consumer para objetos personalizados
        Consumer<Person> personConsumer = p -> System.out.println("Persona: " + p.getName() + ", " + p.getAge() + " años");
        
        // Aplicando cada consumer
        stringConsumer.accept("Hola mundo");
        intConsumer.accept(42);
        personConsumer.accept(new Person("Ana", 28));
    }
    
    static class Person {
        private String name;
        private int age;
        
        Person(String name, int age) {
            this.name = name;
            this.age = age;
        }
        
        String getName() { return name; }
        int getAge() { return age; }
    }
}

Consumidores especializados

Java proporciona algunas interfaces especializadas derivadas de Consumer para tipos primitivos, evitando el costo de autoboxing/unboxing:

import java.util.function.IntConsumer;
import java.util.function.LongConsumer;
import java.util.function.DoubleConsumer;

public class SpecializedConsumers {
    public static void main(String[] args) {
        // Consumidor para primitivos int
        IntConsumer intConsumer = value -> System.out.println("Cuadrado: " + value * value);
        intConsumer.accept(5);  // Imprime: Cuadrado: 25
        
        // Consumidor para primitivos long
        LongConsumer longConsumer = value -> System.out.println("Valor long: " + value);
        longConsumer.accept(1000000000000L);
        
        // Consumidor para primitivos double
        DoubleConsumer doubleConsumer = value -> System.out.println("Mitad: " + value / 2);
        doubleConsumer.accept(9.8);  // Imprime: Mitad: 4.9
    }
}

Estas interfaces especializadas mejoran el rendimiento al evitar la conversión automática entre tipos primitivos y sus equivalentes en objetos.

Consumidores con tipos genéricos acotados

Podemos utilizar comodines con límites para crear consumidores que trabajen con jerarquías de clases:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class BoundedGenericConsumers {
    public static void main(String[] args) {
        // Jerarquía de clases
        class Animal { 
            void makeSound() { System.out.println("Animal sound"); } 
        }
        class Dog extends Animal { 
            @Override void makeSound() { System.out.println("Woof!"); }
            void fetch() { System.out.println("Fetching..."); }
        }
        class Cat extends Animal { 
            @Override void makeSound() { System.out.println("Meow!"); }
            void purr() { System.out.println("Purring..."); }
        }
        
        // Consumer que acepta cualquier Animal
        Consumer<Animal> soundMaker = Animal::makeSound;
        
        // Lista de diferentes animales
        List<Animal> animals = new ArrayList<>();
        animals.add(new Dog());
        animals.add(new Cat());
        
        // Aplicar el consumer a todos los animales
        animals.forEach(soundMaker);  // Imprime: Woof! y Meow!
        
        // Método que acepta un Consumer de cualquier supertipo de Dog
        processDog(new Dog(), soundMaker);  // Funciona porque Animal es supertipo de Dog
    }
    
    // Método que utiliza comodín con límite superior
    static <T extends Animal> void processDog(T animal, Consumer<? super T> consumer) {
        consumer.accept(animal);
    }
}

El parámetro Consumer<? super T> permite pasar un consumidor que acepte cualquier supertipo de T, lo que facilita la reutilización de consumidores en jerarquías de clases.

Consumidores genéricos en estructuras de datos

Los tipos genéricos son especialmente útiles cuando trabajamos con colecciones heterogéneas:

import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;

public class GenericConsumerWithCollections {
    public static void main(String[] args) {
        // Mapa que asocia tipos de datos con sus consumidores correspondientes
        Map<Class<?>, Consumer<?>> processors = new HashMap<>();
        
        // Registramos consumidores para diferentes tipos
        processors.put(String.class, (Consumer<String>) s -> System.out.println("String: " + s.toUpperCase()));
        processors.put(Integer.class, (Consumer<Integer>) i -> System.out.println("Integer: " + (i * 10)));
        processors.put(Double.class, (Consumer<Double>) d -> System.out.println("Double: " + Math.round(d)));
        
        // Método para procesar un objeto con el consumidor adecuado
        processObject("hello", processors);
        processObject(42, processors);
        processObject(3.14, processors);
    }
    
    // Método genérico para procesar objetos según su tipo
    @SuppressWarnings("unchecked")
    static <T> void processObject(T object, Map<Class<?>, Consumer<?>> processors) {
        // Obtenemos el consumidor para el tipo del objeto
        Consumer<T> consumer = (Consumer<T>) processors.get(object.getClass());
        if (consumer != null) {
            consumer.accept(object);
        } else {
            System.out.println("No hay procesador para " + object.getClass().getSimpleName());
        }
    }
}

Este patrón permite implementar un sistema flexible de procesadores por tipo utilizando consumidores genéricos.

Creación de fábricas de consumidores genéricos

Podemos crear métodos de fábrica que generen consumidores especializados:

import java.util.function.Consumer;
import java.io.PrintStream;

public class ConsumerFactory {
    public static void main(String[] args) {
        // Crear consumidores usando nuestras fábricas
        Consumer<String> infoLogger = createLogger("INFO");
        Consumer<String> errorLogger = createLogger("ERROR");
        
        infoLogger.accept("Operación completada");  // Imprime: [INFO] Operación completada
        errorLogger.accept("Conexión fallida");     // Imprime: [ERROR] Conexión fallida
        
        // Consumidor para depuración que muestra el tipo y valor
        Consumer<Integer> debugInt = createDebugConsumer();
        Consumer<String> debugString = createDebugConsumer();
        
        debugInt.accept(100);      // Imprime: [DEBUG] Integer: 100
        debugString.accept("test"); // Imprime: [DEBUG] String: test
    }
    
    // Fábrica que crea un logger con un nivel específico
    static Consumer<String> createLogger(String level) {
        return message -> System.out.println("[" + level + "] " + message);
    }
    
    // Fábrica genérica que crea un consumidor de depuración
    static <T> Consumer<T> createDebugConsumer() {
        return value -> System.out.println("[DEBUG] " + value.getClass().getSimpleName() + ": " + value);
    }
    
    // Fábrica que crea un consumidor que escribe en un stream específico
    static <T> Consumer<T> createStreamConsumer(PrintStream stream) {
        return value -> stream.println(value);
    }
}

Estas fábricas genéricas nos permiten reutilizar la lógica de creación de consumidores para diferentes tipos y contextos.

Consumidores genéricos con estado

Podemos crear consumidores genéricos que mantengan un estado interno:

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

public class StatefulGenericConsumers {
    public static void main(String[] args) {
        // Consumidor que recolecta elementos en una lista
        Collector<String> stringCollector = new Collector<>();
        stringCollector.accept("uno");
        stringCollector.accept("dos");
        stringCollector.accept("tres");
        
        System.out.println("Elementos recolectados: " + stringCollector.getCollected());
        
        // Consumidor que cuenta elementos por tipo
        Counter counter = new Counter();
        counter.accept("texto");
        counter.accept(42);
        counter.accept("otro texto");
        counter.accept(7.5);
        
        System.out.println("Conteo: " + counter.getCounts());
    }
    
    // Consumidor genérico que recolecta elementos
    static class Collector<T> implements Consumer<T> {
        private final List<T> collected = new ArrayList<>();
        
        @Override
        public void accept(T item) {
            collected.add(item);
        }
        
        public List<T> getCollected() {
            return new ArrayList<>(collected);
        }
    }
    
    // Consumidor que cuenta elementos por tipo
    static class Counter implements Consumer<Object> {
        private final Map<String, Integer> counts = new HashMap<>();
        
        @Override
        public void accept(Object item) {
            String type = item.getClass().getSimpleName();
            counts.put(type, counts.getOrDefault(type, 0) + 1);
        }
        
        public Map<String, Integer> getCounts() {
            return new HashMap<>(counts);
        }
    }
}

Estos consumidores con estado son útiles para acumular información durante el procesamiento de datos.

Los consumidores genéricos proporcionan una forma flexible y segura de trabajar con diferentes tipos de datos en Java, permitiendo crear código reutilizable y manteniendo la seguridad de tipos en tiempo de compilación.

Encadenamiento de Consumers con andThen()

La interfaz Consumer proporciona un método default llamado andThen() que permite encadenar múltiples consumidores para crear una secuencia de operaciones. Este método facilita la composición de comportamientos sin necesidad de invocar accept() múltiples veces.

La firma del método es:

default Consumer<T> andThen(Consumer<? super T> after)

Este método devuelve un nuevo Consumer que ejecuta primero la operación del Consumer actual y luego la operación del Consumer proporcionado como parámetro. Veamos cómo funciona:

import java.util.function.Consumer;

public class ConsumerChaining {
    public static void main(String[] args) {
        Consumer<String> printUpperCase = s -> System.out.println("Mayúsculas: " + s.toUpperCase());
        Consumer<String> printLength = s -> System.out.println("Longitud: " + s.length());
        
        // Encadenamos los dos consumidores
        Consumer<String> printBoth = printUpperCase.andThen(printLength);
        
        // Al ejecutar accept(), se ejecutan ambas operaciones en secuencia
        printBoth.accept("Programación funcional");
        // Imprime:
        // Mayúsculas: PROGRAMACIÓN FUNCIONAL
        // Longitud: 22
    }
}

Encadenamiento múltiple

Podemos encadenar múltiples consumidores para crear flujos de procesamiento complejos:

import java.util.function.Consumer;

public class MultipleChaining {
    public static void main(String[] args) {
        Consumer<String> step1 = s -> System.out.println("Paso 1: Recibido '" + s + "'");
        Consumer<String> step2 = s -> System.out.println("Paso 2: Procesando...");
        Consumer<String> step3 = s -> System.out.println("Paso 3: " + s.toUpperCase());
        Consumer<String> step4 = s -> System.out.println("Paso 4: Completado!");
        
        // Encadenamos todos los pasos
        Consumer<String> pipeline = step1
                                    .andThen(step2)
                                    .andThen(step3)
                                    .andThen(step4);
        
        // Ejecutamos el pipeline completo
        pipeline.accept("datos");
        // Imprime:
        // Paso 1: Recibido 'datos'
        // Paso 2: Procesando...
        // Paso 3: DATOS
        // Paso 4: Completado!
    }
}

Este enfoque permite crear pipelines de procesamiento claros y mantenibles.

Manejo de excepciones en cadenas

Cuando trabajamos con cadenas de consumidores, es importante considerar cómo manejar las excepciones:

import java.util.function.Consumer;

public class ExceptionHandlingInChains {
    public static void main(String[] args) {
        // Consumidor que podría lanzar una excepción
        Consumer<String> parser = s -> {
            try {
                int value = Integer.parseInt(s);
                System.out.println("Valor numérico: " + value);
            } catch (NumberFormatException e) {
                System.err.println("Error al parsear '" + s + "': " + e.getMessage());
            }
        };
        
        // Consumidor que registra la operación
        Consumer<String> logger = s -> System.out.println("Procesando: " + s);
        
        // Encadenamos los consumidores
        Consumer<String> process = logger.andThen(parser);
        
        // Probamos con diferentes entradas
        process.accept("123");    // Funciona correctamente
        process.accept("abc");    // Maneja la excepción
    }
}

Si un consumidor en la cadena lanza una excepción, los consumidores posteriores no se ejecutarán. Para evitar esto, cada consumidor debe manejar sus propias excepciones.

Creación de consumidores condicionales

Podemos combinar el encadenamiento con lógica condicional:

import java.util.function.Consumer;
import java.util.function.Predicate;

public class ConditionalConsumers {
    public static void main(String[] args) {
        // Consumidores para diferentes tipos de procesamiento
        Consumer<Integer> processEven = n -> System.out.println(n + " es par: " + n * 2);
        Consumer<Integer> processOdd = n -> System.out.println(n + " es impar: " + (n * 3 + 1));
        
        // Predicados para determinar el tipo de número
        Predicate<Integer> isEven = n -> n % 2 == 0;
        
        // Procesar una lista de números
        Integer[] numbers = {1, 2, 3, 4, 5, 6};
        for (Integer n : numbers) {
            // Elegimos el consumidor adecuado según la condición
            Consumer<Integer> processor = isEven.test(n) ? processEven : processOdd;
            processor.accept(n);
        }
    }
    
    // Método genérico para aplicar un consumidor condicionalmente
    static <T> Consumer<T> conditionalConsumer(Predicate<T> condition, 
                                              Consumer<T> ifTrue, 
                                              Consumer<T> ifFalse) {
        return item -> {
            if (condition.test(item)) {
                ifTrue.accept(item);
            } else {
                ifFalse.accept(item);
            }
        };
    }
}

Este patrón permite crear flujos de procesamiento dinámicos basados en condiciones.

Aplicaciones prácticas del encadenamiento

El encadenamiento de consumidores es especialmente útil en escenarios como:

  • Validación de datos en múltiples pasos:
import java.util.function.Consumer;

public class ValidationChain {
    public static void main(String[] args) {
        // Consumidores para diferentes validaciones
        Consumer<String> checkLength = email -> {
            if (email.length() < 5) {
                throw new IllegalArgumentException("Email demasiado corto");
            }
        };
        
        Consumer<String> checkAtSymbol = email -> {
            if (!email.contains("@")) {
                throw new IllegalArgumentException("Email debe contener @");
            }
        };
        
        Consumer<String> checkDomain = email -> {
            if (!email.contains(".")) {
                throw new IllegalArgumentException("Email debe contener un dominio válido");
            }
        };
        
        // Encadenamos todas las validaciones
        Consumer<String> validateEmail = checkLength
                                        .andThen(checkAtSymbol)
                                        .andThen(checkDomain);
        
        // Validamos diferentes emails
        try {
            validateEmail.accept("usuario@dominio.com");
            System.out.println("Email válido");
        } catch (IllegalArgumentException e) {
            System.err.println("Error: " + e.getMessage());
        }
        
        try {
            validateEmail.accept("abc");
            System.out.println("Email válido");
        } catch (IllegalArgumentException e) {
            System.err.println("Error: " + e.getMessage());
        }
    }
}
  • Procesamiento de eventos en capas:
import java.util.function.Consumer;

class Event {
    private String name;
    private String data;
    
    public Event(String name, String data) {
        this.name = name;
        this.data = data;
    }
    
    public String getName() { return name; }
    public String getData() { return data; }
}

public class EventProcessing {
    public static void main(String[] args) {
        // Diferentes capas de procesamiento
        Consumer<Event> logger = event -> 
            System.out.println("Evento recibido: " + event.getName());
        
        Consumer<Event> security = event -> 
            System.out.println("Verificando permisos para: " + event.getName());
        
        Consumer<Event> processor = event -> 
            System.out.println("Procesando datos: " + event.getData());
        
        Consumer<Event> notifier = event -> 
            System.out.println("Notificando completado: " + event.getName());
        
        // Creamos el pipeline de procesamiento
        Consumer<Event> eventPipeline = logger
                                       .andThen(security)
                                       .andThen(processor)
                                       .andThen(notifier);
        
        // Procesamos un evento
        Event loginEvent = new Event("LOGIN", "usuario=admin");
        eventPipeline.accept(loginEvent);
    }
}

El método andThen() es una herramienta poderosa que permite construir flujos de procesamiento complejos de manera declarativa y legible, facilitando la creación de código modular y mantenible en el estilo de programación funcional.

Encadenamiento de Consumers con andThen()

El método andThen() es una de las características más potentes de la interfaz Consumer, ya que permite combinar múltiples operaciones de consumo en una secuencia fluida. Este método facilita la creación de pipelines de procesamiento donde cada Consumer realiza una tarea específica sobre los datos.

La firma del método andThen() es:

default Consumer<T> andThen(Consumer<? super T> after)

Este método devuelve un nuevo Consumer compuesto que ejecuta en secuencia: primero la operación del Consumer actual y luego la operación del Consumer proporcionado como argumento. Veamos un ejemplo práctico:

import java.util.function.Consumer;

public class AndThenExample {
    public static void main(String[] args) {
        // Primer Consumer: convierte a minúsculas
        Consumer<String> toLowerCase = s -> System.out.println("Minúsculas: " + s.toLowerCase());
        
        // Segundo Consumer: cuenta caracteres
        Consumer<String> countChars = s -> System.out.println("Número de caracteres: " + s.length());
        
        // Combinamos ambos usando andThen()
        Consumer<String> processText = toLowerCase.andThen(countChars);
        
        // Aplicamos el Consumer compuesto
        processText.accept("JAVA FUNCIONAL");
        // Salida:
        // Minúsculas: java funcional
        // Número de caracteres: 14
    }
}

Creación de flujos de transformación

El método andThen() es particularmente útil para crear flujos de transformación donde cada paso modifica o enriquece un objeto:

import java.util.function.Consumer;

class User {
    private String username;
    private boolean active = false;
    private int loginAttempts = 0;
    
    public User(String username) {
        this.username = username;
    }
    
    public void setActive(boolean active) { this.active = active; }
    public void incrementLoginAttempts() { this.loginAttempts++; }
    
    @Override
    public String toString() {
        return "User{username='" + username + "', active=" + active + 
               ", loginAttempts=" + loginAttempts + "}";
    }
}

public class TransformationFlow {
    public static void main(String[] args) {
        // Definimos transformaciones individuales
        Consumer<User> activateUser = user -> user.setActive(true);
        Consumer<User> recordLogin = user -> user.incrementLoginAttempts();
        Consumer<User> logUserState = user -> System.out.println("Estado actual: " + user);
        
        // Creamos un flujo de procesamiento para el login
        Consumer<User> loginProcess = activateUser
                                     .andThen(recordLogin)
                                     .andThen(logUserState);
        
        // Procesamos un usuario
        User newUser = new User("alice123");
        System.out.println("Usuario inicial: " + newUser);
        loginProcess.accept(newUser);
    }
}

Este enfoque permite descomponer procesos complejos en pasos más pequeños y manejables, mejorando la legibilidad y mantenibilidad del código.

Implementación de patrones de diseño

El encadenamiento de Consumers facilita la implementación de patrones de diseño como el Chain of Responsibility:

import java.util.function.Consumer;

class Request {
    private String type;
    private int priority;
    private String content;
    
    public Request(String type, int priority, String content) {
        this.type = type;
        this.priority = priority;
        this.content = content;
    }
    
    public String getType() { return type; }
    public int getPriority() { return priority; }
    public String getContent() { return content; }
}

public class ChainOfResponsibility {
    public static void main(String[] args) {
        // Handlers para diferentes aspectos de la solicitud
        Consumer<Request> logHandler = request -> 
            System.out.println("Registrando solicitud: " + request.getType());
        
        Consumer<Request> authHandler = request -> {
            if (request.getPriority() > 5) {
                System.out.println("Solicitud de alta prioridad: requiere autorización especial");
            }
        };
        
        Consumer<Request> processHandler = request -> 
            System.out.println("Procesando contenido: " + request.getContent());
        
        // Construimos la cadena de responsabilidad
        Consumer<Request> requestPipeline = logHandler
                                          .andThen(authHandler)
                                          .andThen(processHandler);
        
        // Procesamos diferentes solicitudes
        Request normalRequest = new Request("QUERY", 3, "select * from users");
        Request criticalRequest = new Request("UPDATE", 8, "update users set status='inactive'");
        
        System.out.println("--- Procesando solicitud normal ---");
        requestPipeline.accept(normalRequest);
        
        System.out.println("\n--- Procesando solicitud crítica ---");
        requestPipeline.accept(criticalRequest);
    }
}

Este patrón permite separar responsabilidades y crear flujos de procesamiento flexibles y extensibles.

Construcción de validadores complejos

El encadenamiento es ideal para implementar validaciones en múltiples etapas:

import java.util.function.Consumer;

public class ValidationChaining {
    public static void main(String[] args) {
        // Validadores individuales
        Consumer<String> notEmptyValidator = input -> {
            if (input == null || input.trim().isEmpty()) {
                throw new IllegalArgumentException("El valor no puede estar vacío");
            }
        };
        
        Consumer<String> lengthValidator = input -> {
            if (input.length() < 8) {
                throw new IllegalArgumentException("El valor debe tener al menos 8 caracteres");
            }
        };
        
        Consumer<String> formatValidator = input -> {
            if (!input.matches(".*[A-Z].*") || !input.matches(".*[0-9].*")) {
                throw new IllegalArgumentException("El valor debe contener al menos una mayúscula y un número");
            }
        };
        
        // Combinamos todos los validadores
        Consumer<String> passwordValidator = notEmptyValidator
                                           .andThen(lengthValidator)
                                           .andThen(formatValidator);
        
        // Validamos diferentes contraseñas
        validatePassword(passwordValidator, "");  // Falla: vacío
        validatePassword(passwordValidator, "abc123");  // Falla: muy corto
        validatePassword(passwordValidator, "abcdefgh");  // Falla: sin mayúscula ni número
        validatePassword(passwordValidator, "Abcdefg1");  // Válido
    }
    
    private static void validatePassword(Consumer<String> validator, String password) {
        try {
            validator.accept(password);
            System.out.println("Contraseña válida: " + password);
        } catch (IllegalArgumentException e) {
            System.out.println("Contraseña inválida '" + password + "': " + e.getMessage());
        }
    }
}

Este enfoque permite crear validadores modulares que pueden combinarse de diferentes formas según las necesidades.

Optimización del encadenamiento

Cuando trabajamos con cadenas de Consumers, es importante considerar algunas optimizaciones:

import java.util.function.Consumer;
import java.util.ArrayList;
import java.util.List;

public class OptimizedChaining {
    public static void main(String[] args) {
        List<String> processed = new ArrayList<>();
        
        // Versión ineficiente: crea múltiples objetos Consumer intermedios
        Consumer<String> inefficient = 
            s -> System.out.println("Paso 1: " + s)
            .andThen(s -> System.out.println("Paso 2: " + s))
            .andThen(s -> System.out.println("Paso 3: " + s))
            .andThen(processed::add);
        
        // Versión optimizada: define los Consumers por separado
        Consumer<String> step1 = s -> System.out.println("Paso 1: " + s);
        Consumer<String> step2 = s -> System.out.println("Paso 2: " + s);
        Consumer<String> step3 = s -> System.out.println("Paso 3: " + s);
        Consumer<String> step4 = processed::add;
        
        // Encadena los Consumers reutilizables
        Consumer<String> efficient = step1.andThen(step2).andThen(step3).andThen(step4);
        
        // Procesa múltiples elementos
        efficient.accept("dato1");
        efficient.accept("dato2");
        
        System.out.println("Elementos procesados: " + processed);
    }
}

La versión optimizada permite reutilizar los Consumers individuales y reduce la creación de objetos intermedios.

Manejo de errores en cadenas de Consumers

El encadenamiento de Consumers requiere un manejo cuidadoso de errores para evitar que una excepción interrumpa toda la cadena:

import java.util.function.Consumer;

public class ErrorHandlingInChains {
    public static void main(String[] args) {
        // Consumidor que podría fallar
        Consumer<String> riskyOperation = s -> {
            if (Math.random() < 0.5) {
                throw new RuntimeException("Error aleatorio en el procesamiento");
            }
            System.out.println("Operación completada para: " + s);
        };
        
        // Envolvemos el consumidor con manejo de errores
        Consumer<String> safeOperation = createSafeConsumer(riskyOperation);
        
        // Creamos una cadena con el consumidor seguro
        Consumer<String> process = 
            s -> System.out.println("Iniciando procesamiento de: " + s)
            .andThen(safeOperation)
            .andThen(s -> System.out.println("Finalizando procesamiento de: " + s));
        
        // Procesamos varios elementos
        for (int i = 0; i < 5; i++) {
            process.accept("Item-" + i);
            System.out.println("---");
        }
    }
    
    // Método para crear un Consumer que maneja sus propias excepciones
    private static <T> Consumer<T> createSafeConsumer(Consumer<T> consumer) {
        return item -> {
            try {
                consumer.accept(item);
            } catch (Exception e) {
                System.err.println("Error capturado: " + e.getMessage());
                // Aquí podríamos registrar el error, notificar, etc.
            }
        };
    }
}

Este patrón permite crear cadenas robustas que continúan funcionando incluso cuando ocurren errores en pasos intermedios.

Creación de Consumers condicionales encadenados

Podemos combinar el encadenamiento con lógica condicional para crear flujos de procesamiento dinámicos:

import java.util.function.Consumer;
import java.util.function.Predicate;

public class ConditionalChaining {
    public static void main(String[] args) {
        // Procesadores para diferentes tipos de datos
        Consumer<Object> processNumber = obj -> {
            if (obj instanceof Number) {
                Number num = (Number) obj;
                System.out.println("Número procesado: " + (num.doubleValue() * 2));
            }
        };
        
        Consumer<Object> processString = obj -> {
            if (obj instanceof String) {
                String str = (String) obj;
                System.out.println("Texto procesado: " + str.toUpperCase());
            }
        };
        
        // Combinamos los procesadores
        Consumer<Object> processor = processNumber.andThen(processString);
        
        // Procesamos diferentes tipos de datos
        Object[] data = {42, "hello", 3.14, "world"};
        for (Object item : data) {
            processor.accept(item);
        }
        
        // Creamos un Consumer condicional
        Consumer<Integer> processEven = createConditionalConsumer(
            n -> n % 2 == 0,
            n -> System.out.println(n + " es par"),
            n -> System.out.println(n + " es impar")
        );
        
        // Procesamos números
        for (int i = 1; i <= 5; i++) {
            processEven.accept(i);
        }
    }
    
    // Método para crear un Consumer condicional
    private static <T> Consumer<T> createConditionalConsumer(
            Predicate<T> condition,
            Consumer<T> whenTrue,
            Consumer<T> whenFalse) {
        return item -> {
            if (condition.test(item)) {
                whenTrue.accept(item);
            } else {
                whenFalse.accept(item);
            }
        };
    }
}

Este enfoque permite crear flujos de decisión complejos que se adaptan dinámicamente a los datos procesados.

El método andThen() es una herramienta fundamental para la composición funcional en Java, permitiendo construir operaciones complejas a partir de componentes simples y reutilizables. Esta capacidad de encadenamiento facilita la creación de código más modular, legible y mantenible, siguiendo los principios de la programación funcional.

Aprende Java online

Otros ejercicios de programación de Java

Evalúa tus conocimientos de esta lección Interfaz funcional Consumer 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

  • Comprender la definición y propósito de la interfaz funcional Consumer en Java.
  • Aprender a implementar y utilizar el método accept() para realizar operaciones con efectos secundarios.
  • Explorar el uso de Consumers con tipos genéricos y especializaciones para tipos primitivos.
  • Entender cómo encadenar múltiples Consumers usando el método andThen() para crear flujos de procesamiento.
  • Aplicar patrones de diseño y manejo de errores en cadenas de Consumers para código modular y robusto.