conditions - java 8 stream
Cómo hacer dinámicamente el filtrado en Java 8? (2)
Sé que en Java 8, puedo filtrar de esta manera:
List<User> olderUsers = users.stream().filter(u -> u.age > 30).collect(Collectors.toList());
Pero, ¿y si tengo una colección y media docena de criterios de filtrado, y quiero probar la combinación de los criterios?
Por ejemplo, tengo una colección de objetos y los siguientes criterios:
<1> Size
<2> Weight
<3> Length
<4> Top 50% by a certain order
<5> Top 20% by a another certain ratio
<6> True or false by yet another criteria
Y quiero probar la combinación de los criterios anteriores, algo así como:
<1> -> <2> -> <3> -> <4> -> <5>
<1> -> <2> -> <3> -> <5> -> <4>
<1> -> <2> -> <5> -> <4> -> <3>
...
<1> -> <5> -> <3> -> <4> -> <2>
<3> -> <2> -> <1> -> <4> -> <5>
...
<5> -> <4> -> <3> -> <3> -> <1>
Si cada orden de prueba puede darme resultados diferentes, ¿cómo escribir un ciclo para filtrar automáticamente a través de todas las combinaciones?
Lo que puedo pensar es usar otro método que genere el orden de prueba de la siguiente manera:
int[][] getTestOrder(int criteriaCount)
{
...
}
So if the criteriaCount is 2, it will return : {{1,2},{2,1}}
If the criteriaCount is 3, it will return : {{1,2,3},{1,3,2},{2,1,3},{2,3,1},{3,1,2},{3,2,1}}
...
¿Pero cómo implementarlo más eficientemente con el mecanismo de filtrado en expresiones concisas que viene con Java 8?
Podríamos agregar un contador con un mapa para saber cuántos elementos tenemos después de los filtros. Creé una clase auxiliar que tiene un método que cuenta y devuelve el mismo objeto pasado:
class DoNothingButCount<T> {
AtomicInteger i;
public DoNothingButCount() {
i = new AtomicInteger(0);
}
public T pass(T p) {
i.incrementAndGet();
return p;
}
}
public void runDemo() {
List<Person>persons = create(100);
DoNothingButCount<Person> counter = new DoNothingButCount<>();
persons.stream().filter(u -> u.size > 12).filter(u -> u.weitght > 12).
map((p) -> counter.pass(p)).
sorted((p1, p2) -> p1.age - p2.age).
collect(Collectors.toList()).stream().
limit((int) (counter.i.intValue() * 0.5)).
sorted((p1, p2) -> p2.length - p1.length).
limit((int) (counter.i.intValue() * 0.5 * 0.2)).forEach((p) -> System.out.println(p));
}
Tuve que convertir el flujo a la lista y volver al flujo en el medio porque el límite usaría la cuenta inicial de lo contrario. Es todo un "hackish" pero es todo lo que pude pensar.
Podría hacerlo de una manera un poco diferente usando una función para mi clase mapeada:
class DoNothingButCount<T > implements Function<T, T> {
AtomicInteger i;
public DoNothingButCount() {
i = new AtomicInteger(0);
}
public T apply(T p) {
i.incrementAndGet();
return p;
}
}
Lo único que cambiará en la transmisión es:
map((p) -> counter.pass(p)).
se convertirá:
map(counter).
Mi clase de prueba completa que incluye los dos ejemplos:
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.stream.Collectors;
public class Demo2 {
Random r = new Random();
class Person {
public int size, weitght,length, age;
public Person(int s, int w, int l, int a){
this.size = s;
this.weitght = w;
this.length = l;
this.age = a;
}
public String toString() {
return "P: "+this.size+", "+this.weitght+", "+this.length+", "+this.age+".";
}
}
public List<Person>create(int size) {
List<Person>persons = new ArrayList<>();
while(persons.size()<size) {
persons.add(new Person(r.nextInt(10)+10, r.nextInt(10)+10, r.nextInt(10)+10,r.nextInt(20)+14));
}
return persons;
}
class DoNothingButCount<T> {
AtomicInteger i;
public DoNothingButCount() {
i = new AtomicInteger(0);
}
public T pass(T p) {
i.incrementAndGet();
return p;
}
}
class PDoNothingButCount<T > implements Function<T, T> {
AtomicInteger i;
public PDoNothingButCount() {
i = new AtomicInteger(0);
}
public T apply(T p) {
i.incrementAndGet();
return p;
}
}
public void runDemo() {
List<Person>persons = create(100);
PDoNothingButCount<Person> counter = new PDoNothingButCount<>();
persons.stream().filter(u -> u.size > 12).filter(u -> u.weitght > 12).
map(counter).
sorted((p1, p2) -> p1.age - p2.age).
collect(Collectors.toList()).stream().
limit((int) (counter.i.intValue() * 0.5)).
sorted((p1, p2) -> p2.length - p1.length).
limit((int) (counter.i.intValue() * 0.5 * 0.2)).forEach((p) -> System.out.println(p));
}
public void runDemo2() {
List<Person>persons = create(100);
DoNothingButCount<Person> counter = new DoNothingButCount<>();
persons.stream().filter(u -> u.size > 12).filter(u -> u.weitght > 12).
map((p) -> counter.pass(p)).
sorted((p1, p2) -> p1.age - p2.age).
collect(Collectors.toList()).stream().
limit((int) (counter.i.intValue() * 0.5)).
sorted((p1, p2) -> p2.length - p1.length).
limit((int) (counter.i.intValue() * 0.5 * 0.2)).forEach((p) -> System.out.println(p));
}
public static void main(String str[]) {
Demo2 demo = new Demo2();
System.out.println("Demo 2:");
demo.runDemo2();
System.out.println("Demo 1:");
demo.runDemo();
}
}
Problema interesante. Hay varias cosas pasando aquí. Sin duda esto podría resolverse en menos de media página de Haskell o Lisp, pero esto es Java, así que aquí vamos ...
Un problema es que tenemos una cantidad variable de filtros, mientras que la mayoría de los ejemplos que se han mostrado ilustran las tuberías fijas.
Otro problema es que algunos de los "filtros" de OP son contextuales, como "50% superior en un orden determinado". Esto no se puede hacer con un simple constructo de filter(predicate)
en una secuencia.
La clave es darse cuenta de que, si bien las lambdas permiten que las funciones se pasen como argumentos (con buenos resultados), también significa que se pueden almacenar en estructuras de datos y se pueden realizar cálculos en ellas. El cálculo más común es tomar múltiples funciones y componerlas.
Supongamos que los valores que se operan son instancias de Widget, que es un POJO que tiene algunos getters obvios:
class Widget {
String name() { ... }
int length() { ... }
double weight() { ... }
// constructors, fields, toString(), etc.
}
Comencemos con el primer número y descubramos cómo operar con una cantidad variable de predicados simples. Podemos crear una lista de predicados como este:
List<Predicate<Widget>> allPredicates = Arrays.asList(
w -> w.length() >= 10,
w -> w.weight() > 40.0,
w -> w.name().compareTo("c") > 0);
Dada esta lista, podemos permutarlos (probablemente no sean útiles, ya que son independientes de la orden) o seleccionar cualquier subconjunto que queramos. Digamos que solo queremos aplicarlos todos. ¿Cómo aplicamos una cantidad variable de predicados a una secuencia? Hay un método Predicate.and()
que tomará dos predicados y los combinará usando un predicado lógico y regresivo. Entonces, podríamos tomar el primer predicado y escribir un bucle que lo combine con los predicados sucesivos para construir un único predicado que sea un compuesto y de todos ellos:
Predicate<Widget> compositePredicate = allPredicates.get(0);
for (int i = 1; i < allPredicates.size(); i++) {
compositePredicate = compositePredicate.and(allPredicates.get(i));
}
Esto funciona, pero falla si la lista está vacía, y como estamos haciendo programación funcional ahora, la mutación de una variable en un ciclo es declassé. Pero lo! Esto es una reducción! Podemos reducir todos los predicados sobre el y el operador obtener un único predicado compuesto, como este:
Predicate<Widget> compositePredicate =
allPredicates.stream()
.reduce(w -> true, Predicate::and);
(Crédito: aprendí esta técnica de @venkat_s . Si alguna vez tienes la oportunidad, ve a verlo hablar en una conferencia. Está bien.)
Tenga en cuenta el uso de w -> true
como el valor de identidad de la reducción. (Esto también podría usarse como el valor inicial de compositePredicate
para el bucle, que arreglaría el caso de lista de longitud cero).
Ahora que tenemos nuestro predicado compuesto, podemos escribir una tubería corta que simplemente aplica el predicado compuesto a los widgets:
widgetList.stream()
.filter(compositePredicate)
.forEach(System.out::println);
Contexto de filtros sensibles
Ahora consideremos lo que me refería como un filtro "sensible al contexto", que se representa mediante el ejemplo como "el 50% superior en un orden determinado", es decir, el 50% superior de los widgets en peso. "Sensible al contexto" no es el mejor término para esto, pero es lo que tengo en este momento, y es algo descriptivo en cuanto a la cantidad de elementos en la transmisión hasta este punto.
¿Cómo implementaríamos algo como esto usando streams? A menos que a alguien se le ocurra algo realmente inteligente, creo que tenemos que recopilar los elementos en algún lugar primero (por ejemplo, en una lista) antes de poder emitir el primer elemento a la salida. Es algo así como sorted()
en una canalización que no puede decir cuál es el primer elemento en salir hasta que haya leído todos los elementos de entrada y los haya ordenado.
El enfoque directo para encontrar el 50% superior de widgets en peso, usando transmisiones, se vería así:
List<Widget> temp =
list.stream()
.sorted(comparing(Widget::weight).reversed())
.collect(toList());
temp.stream()
.limit((long)(temp.size() * 0.5))
.forEach(System.out::println);
Esto no es complicado, pero es un poco engorroso ya que tenemos que reunir los elementos en una lista y asignarlos a una variable, para usar el tamaño de la lista en el 50% de computación.
Sin embargo, esto es limitante, ya que es una representación "estática" de este tipo de filtrado. ¿Cómo encadenaríamos esto en una secuencia con una cantidad variable de elementos (otros filtros o criterios) como hicimos con los predicados?
Una observación importante es que este código realiza su trabajo real entre el consumo de una secuencia y la emisión de una secuencia. Tiene un colector en el medio, pero si encadenas una secuencia a su frente y cadenas de su parte trasera, nadie es más sabio. De hecho, las operaciones de canalización de flujo estándar, como el map
y el filter
toman cada una una corriente como entrada y emiten una secuencia como salida. Entonces podemos escribir una función como esta:
Stream<Widget> top50PercentByWeight(Stream<Widget> stream) {
List<Widget> temp =
stream.sorted(comparing(Widget::weight).reversed())
.collect(toList());
return temp.stream()
.limit((long)(temp.size() * 0.5));
}
Un ejemplo similar podría ser encontrar los tres widgets más cortos:
Stream<Widget> shortestThree(Stream<Widget> stream) {
return stream.sorted(comparing(Widget::length))
.limit(3);
}
Ahora podemos escribir algo que combine estos filtros con estado con operaciones de flujo ordinarias:
shortestThree(
top50PercentByWeight(
widgetList.stream()
.filter(w -> w.length() >= 10)))
.forEach(System.out::println);
Esto funciona, pero es un poco pésimo porque dice "de adentro hacia afuera" y hacia atrás. La fuente de la secuencia es widgetList
que se transmite por widgetList
y se filtra a través de un predicado ordinario. Ahora, yendo hacia atrás, se aplica el filtro superior del 50%, luego se aplica el filtro más corto-tres y, finalmente, se aplica la operación de flujo para cada forEach
al final. Esto funciona, pero es bastante confuso de leer. Y sigue siendo estático. Lo que realmente queremos es tener una forma de poner estos nuevos filtros dentro de una estructura de datos que podamos manipular, por ejemplo, para ejecutar todas las permutaciones, como en la pregunta original.
Una idea clave en este punto es que estos nuevos tipos de filtros son en realidad funciones justas, y tenemos tipos de interfaces funcionales en Java que nos permiten representar funciones como objetos, manipularlas, almacenarlas en estructuras de datos, componerlas, etc. El tipo de interfaz funcional que toma un argumento de algún tipo y devuelve un valor del mismo tipo es UnaryOperator
. El tipo de argumento y retorno en este caso es Stream<Widget>
. Si tuviéramos que tomar referencias de métodos como this::top50PercentByWeight
o this::top50PercentByWeight
, los tipos de los objetos resultantes serían
UnaryOperator<Stream<Widget>>
Si tuviéramos que ponerlos en una lista, el tipo de esa lista sería
List<UnaryOperator<Stream<Widget>>>
¡Uf! Tres niveles de genéricos anidados son demasiado para mí. (Pero Aleksey Shipilev una vez me mostró un código que usaba cuatro niveles de genéricos anidados.) La solución para demasiados genéricos es definir nuestro propio tipo. Llamemos a una de nuestras cosas nuevas un Criterio. Resulta que hay poco valor que ganar haciendo que nuestro nuevo tipo de interfaz funcional se relacione con UnaryOperator
, por lo que nuestra definición puede ser simplemente:
@FunctionalInterface
public interface Criterion {
Stream<Widget> apply(Stream<Widget> s);
}
Ahora podemos crear una lista de criterios como este:
List<Criterion> criteria = Arrays.asList(
this::shortestThree,
this::lengthGreaterThan20
);
(Vamos a descubrir cómo usar esta lista a continuación.) Este es un paso adelante, ya que ahora podemos manipular la lista de forma dinámica, pero aún así es algo limitante. Primero, no se puede combinar con predicados ordinarios. En segundo lugar, hay muchos valores codificados aquí, como los tres más cortos: ¿qué tal dos o cuatro? ¿Qué tal un criterio diferente a la duración? Lo que realmente queremos es una función que cree estos objetos Criterion para nosotros. Esto es fácil con lambdas.
Esto crea un criterio que selecciona los mejores N widgets, dado un comparador:
Criterion topN(Comparator<Widget> cmp, long n) {
return stream -> stream.sorted(cmp).limit(n);
}
Esto crea un criterio que selecciona el p por ciento superior de widgets, dado un comparador:
Criterion topPercent(Comparator<Widget> cmp, double pct) {
return stream -> {
List<Widget> temp =
stream.sorted(cmp).collect(toList());
return temp.stream()
.limit((long)(temp.size() * pct));
};
}
Y esto crea un criterio a partir de un predicado ordinario:
Criterion fromPredicate(Predicate<Widget> pred) {
return stream -> stream.filter(pred);
}
Ahora tenemos una forma muy flexible de crear criterios y ponerlos en una lista, donde se pueden subconjuntar o permutar o lo que sea:
List<Criterion> criteria = Arrays.asList(
fromPredicate(w -> w.length() > 10), // longer than 10
topN(comparing(Widget::length), 4L), // longest 4
topPercent(comparing(Widget::weight).reversed(), 0.50) // heaviest 50%
);
Una vez que tenemos una lista de objetos Criterion, necesitamos encontrar una manera de aplicarlos todos. Una vez más, podemos usar nuestro amigo reduce
para combinar todos ellos en un solo objeto Criterion:
Criterion allCriteria =
criteria.stream()
.reduce(c -> c, (c1, c2) -> (s -> c2.apply(c1.apply(s))));
La función de identidad c -> c
es clara, pero la segunda arg es un poco complicada. Dada una secuencia s
primero aplicamos el Criterio c1, luego el Criterio c2, y esto se envuelve en una lambda que toma dos objetos de Criterio c1 y c2 y devuelve una lambda que aplica la composición de c1 y c2 a una secuencia y devuelve la secuencia resultante.
Ahora que hemos compuesto todos los criterios, podemos aplicarlo a una secuencia de widgets como esta:
allCriteria.apply(widgetList.stream())
.forEach(System.out::println);
Esto todavía está un poco al revés, pero está bastante bien controlado. Lo más importante es que aborda la pregunta original, que es cómo combinar los criterios dinámicamente. Una vez que los objetos Criterion se encuentran en una estructura de datos, se pueden seleccionar, subconjuntar, permutar, o lo que sea necesario, y se pueden combinar en un solo criterio y aplicar a una secuencia utilizando las técnicas anteriores.
Los gurús de la programación funcional probablemente digan "¡Él acaba de reinventar ...!" que es probablemente cierto. Estoy seguro de que esto probablemente ya se haya inventado en algún lugar, pero es nuevo en Java, porque antes de lambda, simplemente no era factible escribir código Java que utilizara estas técnicas.
Actualización 2014-04-07
He limpiado y publicado el código de muestra completo en una esencia.