Java 8 Streams: Guía definitiva del método filter()

En esta guía detallada y extensa del método filter() en Java 8, comprenderá cómo funciona el método, así como varios casos de uso y cómo obtener el mejor rendimiento de grandes conjuntos de datos.

Introducción

La API de Java Streams simplifica el trabajo con una colección de elementos. Debido a que las secuencias convierten estos elementos en una canalización, puede probarlos usando un conjunto de condiciones (conocidos como predicados), antes de finalmente actuar sobre aquellos que cumplan con sus criterios.

El método filter() es una de esas operaciones que prueba los elementos en un flujo. Y, como puede adivinar, requiere un predicado para que funcione.

La documentación oficial define el método filter() como uno que:

Devuelve un flujo que consta de los elementos de [un flujo dado] que coinciden con el predicado dado.

Por lo que, la documentación define un predicado como:

[una función de valor booleano] de un argumento

El método filter() tiene la firma:

1
Stream<T> filter(Predicate<? super T> predicate)

Y toma un predicado (que es una implementación de una interfaz funcional) con un método:

1
boolean test(T t)

{.icon aria-hidden=“true”}

Nota: El método filter() es una [operación intermedia](https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html# StreamOps). Por lo tanto, es importante que pase un predicado al método filter() que [no modifique](https://docs.oracle.com/javase/8/docs/api/java/util/stream/package- summary.html#NonInterference) los elementos en prueba. Además, el predicado debería no producir resultados diferentes cuando lo somete a condiciones similares. operaciones.

Cuando los predicados cumplen estos dos requisitos, hacen posible ejecutar flujos en paralelo. Esto se debe a que está seguro de que no surgirá ningún comportamiento inesperado de dicho proceso.

En la práctica, no hay límite para el número de llamadas al método filter() que puede realizar en una secuencia. Por ejemplo:

1
2
3
4
5
6
list.stream()
    .filter(predicate1)
    .filter(predicate2)
    .filter(predicate3)
    .filter(predicate4)
    .count();

También puede apilar múltiples predicados a través del operador &&:

1
2
3
4
5
6
list.stream()
    .filter(predicate1
            && predicate2
            && predicate3
            && predicate4)
    .count();

Sin embargo, el bucle for clásico puede hacer exactamente las mismas cosas que usted puede hacer con los métodos filter(). Así, por ejemplo:

1
2
3
4
5
6
7
8
9
long count = 0;
for (int i = 0; i < list().size(); i++) {
    if (predicate1
            && predicate2
            && predicate3
            && predicate4) {
        count = count + 1;
    }
}

Entonces, ¿con qué enfoque deberías conformarte entre estos tres? ¿Hay alguna diferencia en la eficiencia de los recursos entre los tres? Es decir, ¿hay un enfoque que funcione más rápido que el otro?

Esta guía responderá estas preguntas y le brindará una comprensión más profunda del método filter() y cómo puede emplearlo en sus aplicaciones Java hoy.

Además, pondremos en práctica lo que se ha concluido de esas respuestas para crear un código interesante. Uno que filtra todo un diccionario de palabras para armar grupos de anagramas. Y, si has jugado "Escarbar" antes (o incluso has completado un crucigrama), apreciarás por qué los anagramas son una característica tan importante de las palabras para llegar a saber.

Comprender el método filter()

Digamos que tienes una lista de cuatro palabras:

1
2
3
4
yearly
years
yeast
yellow

Y supongamos que desea saber cuántas son palabras de cinco letras: cuántas de esas palabras tienen una “longitud” de cadena de “5”.

Dado que utilizaremos la API Stream para procesar estos datos, vamos a crear un Stream a partir de la lista de palabras, y filtrarlos() con un Predicado, y luego contar() los elementos restantes:

1
2
3
4
List<String> list = List.of("yearly", "years", "yeast", "yellow");

long count = list.stream().filter(s -> s.length() == 5).count();
System.out.println(String.format("There are %s words of length 5", count));

Esto resulta en:

1
There are 2 words of length 5

Después de que se activa el método filter(), dado este predicado, solo hay dos elementos disponibles en la secuencia, que también se pueden recopilar en otra colección:

1
2
List filteredList = list.stream().filter(s -> s.length() == 5).collect(Collectors.toList());
System.out.println(filteredList);

Esto resulta en:

1
[years, yeast]

El método filter() devuelve un nuevo flujo, por lo que podemos optar por realizar otras operaciones de flujo o recopilarlo en una colección más tangible. Por ejemplo, puede apilar varios métodos filter() de forma consecutiva:

1
2
3
4
5
6
7
8
List<String> list = List.of("yearly", "years", "yeast", "yellow", "blues", "astra");

List filteredList = list.stream()
            .filter(s -> s.length() == 5)
            .filter(s -> !s.startsWith("y"))
            .filter(s -> s.contains("str"))
            .collect(Collectors.toList());
System.out.println(filteredList);

Aquí, filtramos la lista tres veces, creando tres flujos:

1
2
3
First  filter() results in: [years, yeast, blues, astra]
Second filter() results in: [blues, astra]
Third  filter() results in: [astra]

Así que finalmente nos quedamos con:

1
[astra]

Entonces, ¿qué está pasando realmente aquí?

Si eres nuevo en el funcionamiento de los predicados, el código anterior podría tener sentido, pero podría haber una barrera entre la verdadera comprensión de lo que está pasando, así que analicémoslo.

Comencemos creando un Stream de las palabras:

1
Stream<String> words = Stream.of("yearly", "years", "yeast", "yellow");

No hay diferencia entre crear un Stream explícitamente como este, o crear uno a partir de una colección a través del método stream() de forma anónima:

1
2
3
4
List<String> list = List.of("yearly", "years", "yeast", "yellow");

// Create Stream and return result
List result = list.stream()...

Ambos construyen una secuencia, pero el último caso es más común, ya que normalmente tendrá una colección subyacente con la que trabajar.

Luego, podemos definir un predicado para hacer coincidir nuestros elementos:

1
2
3
4
5
6
Predicate<String> predicate = new Predicate<String>() {
    @Override
    public boolean test(String word) {
        return word.length() == 5;
    }
};

El predicado ejecuta el método test() contra todos los elementos, y se devuelve un valor booleano basado en el resultado de este método. Si es verdadero, el elemento no se filtra y permanecerá en el flujo después del método filter(). Si es falso, se elimina del Stream, pero por supuesto, no de la colección subyacente.

También podría declarar este predicado usando una lambda, como una versión abreviada:

1
Predicate<String> predicate = (String word) -> word.length() == 5;

O, incluso de una manera aún más concisa:

1
Predicate<String> predicate = word -> word.length() == 5;

El último paso es adjuntar el predicado a un método filter() en el flujo de palabras antes de pedirle que cuente el número de elementos que han pasado la prueba:

1
2
3
4
5
6
// Put the collection of words into a stream
Stream<String> words = Stream.of("yearly", "years", "yeast", "yellow");
// Declare a predicate that allows only those words that have a length of 5
Predicate<String> predicate = word -> word.length() == 5;
// Attach the predicate to filter method and count how many words have passed the test
long count = words.filter(predicate).count();

Con un ojo agudo, puede ver que esta es de hecho la misma versión explícita del código que escribimos primero.

1
long count = list.stream().filter(s -> s.length() == 5).count();

En esta versión, simplemente creamos un Stream a través del método stream() y llamamos al predicado de forma anónima dentro de la llamada al método filter().

¿Existe una forma 'correcta' de utilizar el método filter()?

El ejemplo anterior hizo un buen uso del método filter(). Aún así, podemos llevar las cosas un poco más arriba. Entonces, exploremos un caso de uso aún más envolvente.

Desea generar muchas cifras decimales entre E y PI. Y esas cifras deben excluir E, PI, 2.0 y 3.0. Eso significa que una cifra (f) debe cumplir con los siguientes criterios:

1
f > Math.Ef < Math.PIf != 2f != 3

Aquí, PI y E provienen de la API matemática de Java. Donde PI es:

El valor doble que está más cerca que cualquier otro de pi, la relación entre la circunferencia de un círculo y su diámetro.

Por eso:

1
PI = 3.14159265358979323846;

Y E es:

El valor doble que está más cerca que ningún otro de e, la base de los logaritmos naturales.

De este modo:

1
E = 2.7182818284590452354;

Creación de figuras aleatorias

Todas las estrategias de filtrado que crearemos necesitan figuras con las que trabajar. Entonces, comencemos creando muchas figuras aleatorias que sean todas mayores que ‘1’ y menores que ‘4’.

Y, para lograr eso, usaremos la clase abstracta FilterFigures:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public abstract class FilterFigures {
    // Generate random figures in increasing exponents of base 10    
    // Thus, with an exponent of one: 10^1 = 10  
    // two: 10^2 = 100   
    // three: 10^3 = 1,000   
    // four: 10^4 = 10,000   
    // five: 10^5 = 100,000  
    // six: 10^6 = 1,000,000 
    // and so on 
    private final double exponent;
        
    FilterFigures(double exponent) {
        this.exponent = exponent;
    }
    
    // Child classes must do their filtering here when this method is called by client code   
    public abstract void doFilter();
    // A list of random doubles are automatically generated by this method    
    protected List<Double> getRandomFigures() {
        return ThreadLocalRandom
                .current()
                .doubles((long) Math.pow(10, exponent), 1, 4)
                .boxed()
                .collect(Collectors
                        .collectingAndThen(Collectors.toList(), 
                                           Collections::unmodifiableList));
    }
}

Con esta clase, usaremos un exponente de ‘10’ para generar números aleatorios.

Por lo tanto, tenga en cuenta el método getRandomFigures():

  • (1) Creamos un generador de números aleatorios usando ThreadLocalRandom.current(). Debería preferir esta forma de crear una instancia Random porque como los comentarios de la documentación oficial:

Cuando corresponda, el uso de ThreadLocalRandom en lugar de objetos Random compartidos en programas simultáneos generalmente encontrará mucha menos sobrecarga y contención.

  • (2) Llamamos al generador para producir valores dobles aleatorios. Aquí, pasamos tres argumentos. Primero, el número de cifras aleatorias que queremos que produzca el generador usando Math.pow(10, exponent). Lo que significa que la API Math devolverá un valor que es igual a 10 elevado a la potencia del exponente pasado. En segundo lugar, dictamos la cifra aleatoria más baja que puede incluirse en la colección de cifras aleatorias. Aquí ese valor es 1. También sugerimos el límite más alto (aunque exclusivo) (4).

  • (3) Le indicamos al generador de números aleatorios que encuadre los valores ‘dobles’ primitivos con la clase contenedora ‘Doble’. ¿Y por qué eso es importante? Porque queremos recopilar los valores en List. Sin embargo, las implementaciones List de Java, como la clase ArrayList, no pueden contener valores primitivos como doble. Sin embargo, puede contener Doble.

  • (4) Finalmente terminamos el flujo de valores Double usando un Collector y un finalizador.

Con la clase FilterFigures a la mano, podemos crear subclases concretas para ella que usen varias tácticas para filtrar los números aleatorios.

Usar muchos métodos secuenciales de filtro()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class ManySequentialFilters extends FilterFigures {    
    public ManySequentialFilters(double exponent) {        
        super(exponent);    
    }   
    // This method filters the random figures and only permits those figures that are less than pi   
    // (i.e., 3.14159265358979323846)
    // It permits those that are greater than the base of a natural algorithm    
    // (i.e., 2.7182818284590452354) 
    // It does not permit the figure 3
    // It does not permit the figure 2    
    @Override
    public long doFilter() {
        return super.getRandomFigures().stream()
                .filter(figure -> figure < Math.PI)
                .filter(figure -> figure > Math.E)
                .filter(figure -> figure != 3)
                .filter(figure -> figure != 2)
                .count();
    }
}

Esta clase aplica cuatro filtros para cumplir con los requisitos que establecimos anteriormente. Como antes, un filtro() da como resultado una nueva secuencia, con ciertos elementos filtrados, según el predicado. Esto significa que podemos volver a llamar a filter() en ese flujo, y así sucesivamente.

Aquí, se crean cuatro flujos nuevos, y cada vez, algunos elementos se filtran:

1
2
3
4
FilterFigures ff = new ManySequentialFilters(5);

long count = ff.doFilter();
System.out.println(count);

Con un exponente de ‘5’, hay bastantes números, y el recuento de números que se ajustan a nuestros cuatro filtros es algo así como:

1
14248

Dado el factor de aleatoriedad, cada ejecución dará como resultado un recuento diferente, pero debería estar aproximadamente en el mismo estadio.

Si estás interesado en la figura creada por la clase, puedes echarle un vistazo fácilmente con:

1
System.out.println(ff.getRandomFigures());

Lo que resultará en una lista potencialmente larga - con un exponente de 5, esta lista tiene 100000 elementos:

1
2
3
4
5
2.061505905989455, 2.1559549378375986, 2.785542981180915, 3.0510231495547373, 
3.449422675836848, 3.225190770912789, 3.100194060442495, 2.4322353023765593, 
2.007779315680971, 2.8776634991278796, 1.9027959105246701, 3.763408883116875, 
3.670863706271426, 1.5414358709610365, 3.474927271813806, 1.8701468250626507, 
2.546568871253891...

{.icon aria-hidden=“true”}

Nota: Con números más grandes, como 10, se quedará sin espacio de almacenamiento dinámico si no lo cambia manualmente.

Uso de métodos de filtro() secuenciales combinados {#uso de métodos de filtro secuencial combinados}

Crear una nueva secuencia para cada filter() es un poco inútil, y si tiene una lista arbitraria de predicados, la creación de una gran cantidad de secuencias puede afectar el rendimiento de su aplicación.

Puede combinar múltiples predicados y filter() usándolos de una sola vez:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class CombinedSequentialFilters extends FilterFigures {

    public CombinedSequentialFilters(double exponent) {
        super(exponent);
    }
    
    // This method filters random figures  using a 
    // predicate testing all the conditions in one go
    @Override
    public long doFilter() {
        return super.getRandomFigures()
            .stream()
            .filter(
                figure - > figure < Math.PI 
                && figure > Math.E 
                && figure != 3 
                && figure != 2
            )
            .count();
    }
}

Entonces, ¿qué efecto tiene este enfoque en el rendimiento? El rendimiento se compara en una sección posterior.

Uso de muchos métodos filter() paralelos

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class ManyParallelFilters extends FilterFigures {

    public ManyParallelFilters(double exponent) {
        super(exponent);
    }

    @Override
    public long doFilter() {
        return super.getRandomFigures()
            .stream()
            .parallel()
            .filter(figure - > figure < Math.PI)
            .filter(figure - > figure > Math.E)
            .filter(figure - > figure != 3)
            .filter(figure - > figure != 2)
            .count();
    }
}

Nuevamente, el resultado esperado de esta clase es similar a los dos que hemos visto anteriormente. Pero, la diferencia aquí es que hemos comenzado a usar la función parallel(). Esta es una característica intermedia de la API de Streams.

Con la adición del método parallel(), el código hará uso de todos los núcleos que tiene su máquina. También podríamos paralelizar la táctica de filtrado de usar un predicado combinado.

Uso de métodos combinados de filtros paralelos () {#uso de métodos combinados de filtros paralelos}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class CombinedParallelFilters extends FilterFigures {
    public CombinedParallelFilters(double exponent) {
        super(exponent);
    }
    @Override public long doFilter() {
        return super.getRandomFigures()
                .stream()
                .parallel()
                .filter(figure -> figure < Math.PI 
                        && figure > Math.E
                        && figure != 3
                        && figure != 2)
                .count();
    }
}

Con esta clase simplemente hemos agregado la operación parallel() al predicado complejo que encontramos anteriormente. La salida debe permanecer en la misma clase.

Sin embargo, vale la pena probar si obtenemos ganancias en velocidad al diseñar los métodos filter() de diferentes maneras. ¿Cuál es preferible de este grupo?

Elegir la forma más rápida de usar métodos filter()

Una forma sencilla de medir cómo funcionan los diversos estilos de uso de filter() es cronometrarlos. Entonces, en la clase FiltersTest hemos ejecutado todas las clases usando el filtro con un exponente de 7. Lo que significa que queremos que cada una de estas clases filtre 10,000,000 dobles aleatorios.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
long startTime = System.currentTimeMillis();
// With an exponent of 7, the random generator will produce 10^7 random doubles - 10,000,000 figures!
int exponent = 7;
new ManySequentialFilters(exponent).doFilter();
long endTime = System.currentTimeMillis();
System.out.printf(
    "Time taken by many sequential filters = %d ms\n",
    (endTime - startTime)
);
startTime = System.currentTimeMillis();
new ManyParallelFilters(exponent).doFilter();
endTime = System.currentTimeMillis();
System.out.printf(
    "Time taken by many parallel filters = %d ms\n",
    (endTime - startTime)
);
startTime = System.currentTimeMillis();
new CombinedSequentialFilters(exponent).doFilter();
endTime = System.currentTimeMillis();
System.out.printf(
    "Time taken by combined sequential filters = %d ms\n",
    (endTime - startTime)
);
startTime = System.currentTimeMillis();
new CombinedParallelFilters(exponent).doFilter();
endTime = System.currentTimeMillis();
System.out.printf(
    "Time taken by combined parallel filters = %d ms\n",
    (endTime - startTime)
);

Cuando ejecute esta prueba, obtendrá resultados similares a estos:

1
2
3
4
Time taken by many sequential filters = 2879 ms
Time taken by many parallel filters = 2227 ms
Time taken by combined sequential filters = 2665 ms
Time taken by combined parallel filters = 415 ms

Tenga en cuenta que estos resultados son de una computadora que ejecuta ArchLinux, Java 8, con 8GiB de RAM y una CPU Intel i5-4579T @ 2.90GHz.

Se logra un resultado muy diferente cuando se ejecuta en una máquina diferente, con Windows 10, Java 14, con 32GiB de RAM y un AMD Ryzen 7 3800X 8-Core @ 3.9GHz:

1
2
3
4
Time taken by many sequential filters = 389 ms
Time taken by many parallel filters = 295 ms
Time taken by combined sequential filters = 303 ms
Time taken by combined parallel filters = 287 ms

Por lo tanto, dependiendo de las capacidades y la arquitectura de su máquina, sus resultados pueden ser más rápidos o más lentos.

Por ejemplo, el procesador Intel i5 obtuvo un impulso evidente con la paralelización, mientras que el procesador AMD Ryzen 7 no parece haber ganado mucho.

Método filter() frente a for Loop

El bucle for era el rey antes de que apareciera el filtrado, y el método filter() fue aceptado con gratitud por la comunidad de desarrolladores. Es una forma mucho más concisa y menos detallada de filtrar elementos de las colecciones.

Usando el ciclo clásico for de Java, aún puede filtrar elementos para satisfacer las condiciones dadas. Entonces, para nuestro caso, podríamos filtrar los ‘dobles’ aleatorios usando esta clase ‘ClassicForLoop’:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ClassicForLoop extends FilterFigures {
    
    public ClassicForLoop(double exponent) {
        super(exponent);
    }
    
    @Override
    public long doFilter() {
        List<Double> randomFigures = super.getRandomFigures();
        long count = 0;
        for (int i = 0; i < randomFigures.size(); i++) {
            Double figure = randomFigures.get(i);
            if (figure < Math.PI
                    && figure > Math.E
                    && figure != 3
                    && figure != 2) {
                count = count + 1;
            }
        }
        return count;
    }
}

Pero, ¿por qué molestarse con este estilo de bucle? Hasta ahora hemos visto que los filtros paralelos combinados funcionan más rápido en ciertas máquinas. Entonces, deberíamos comparar este último con el ciclo for para ver si hay una diferencia sustancial en las velocidades, al menos.

Y, para eso, usaremos un fragmento de código en la clase FiltersTest para medir la velocidad del bucle for junto con los filtros paralelos combinados. Así:

1
2
3
4
5
6
startTime = System.currentTimeMillis();
new ClassicForLoop(exponent).doFilter();
endTime = System.currentTimeMillis();
System.out.printf(
        "Time taken by filtering using classic for loop = %d ms\n",
                (endTime - startTime));

Los resultados, nuevamente, variarán dependiendo de su máquina local:

En términos generales - el bucle for() debería superar al método filter() en conjuntos pequeños, como con exponentes de hasta 4, aunque esto normalmente se mide en milisegundos, por lo que prácticamente no notarás la diferencia.

Con más de ~10k dobles, los bucles for generalmente comienzan a tener un rendimiento inferior al del método filter().

Sin embargo, todavía deberías optar por el método filter() debido a su legibilidad. El estilo de bucles adolece de ser demasiado abstracto. Y dado que el código se escribe para que los humanos lo lean y no para que las computadoras lo compilen solos, la legibilidad se convierte en un factor crucial.

Además, si su conjunto de datos comienza a aumentar, con un bucle for, no tendrá suerte. Mientras que para el método filter(), el rendimiento relativo al bucle for empieza a mejorar.

Conclusión

El método filter() es una de las formas que podría usar para hacer que su código Java sea más funcional por naturaleza. A diferencia de imperativo o procedimental. Sin embargo, hay consideraciones que se deben implementar con el método filter().

Encadenar muchos métodos de filtro corre el riesgo de ralentizar su código cuando se ejecuta, por ejemplo. Esto se debe a que, como operación intermedia, crea una nueva secuencia con los elementos que pasan la condición de un predicado. Por lo tanto, el truco sigue siendo combinar predicados en una declaración para reducir el número de llamadas a filter() que realiza.

Puede encontrar el código utilizado en este artículo en GitHub.

Licensed under CC BY-NC-SA 4.0