[Pesquisar este blog]

quinta-feira, 22 de dezembro de 2016

Coleções e Streams outra vez::Parte IV

A nova API Stream foi adicionada ao Java 8 permitir a realização de operações de filtragem, mapeamento e redução sobre coleções. Grande parte de seus métodos são encadeáveis a outros, como na programação funcional, permitindo a criação de pipelines de operações. Assim é possível tratar os elementos de uma coleção sem a necessidade de construção explícita de laços de repetição e, com isso, especificando a realização de operações em massa sobre as coleções.

Segundo a Oracle:
Classes do novo pacote java.util.stream compõem a API Stream para suportar operações no estilo funcional em streams de elementos. A API Stream á integrada a API de Coleções, o que possibilita operações em massa sobre as coleções, tal como transformações de mapeamento/redução sequenciais ou paralelas. (Oracle, 2015, http://www.oracle.com/technetwork/java/javase/8-whats-new-2157071.html )


Neste último artigo desta série sobre coleções e streams serão comentados outros exemplos para mostrar outras aplicações desta API. Esta série foi dividida em  quatro artigos:

Nos fragmentos de código que seguem, o comentário inicial indica o nome arquivo-fonte Java onde estão contidos, permitindo que sejam testados com maior facilidade. Todos exemplos estão disponíveis no GitHub (projeto pjandl/stream_again).

Mind Map

Na figura abaixo temos um mapa mental para auxiliar no uso da API Stream. Do lado esquerdo é possível visualizar Stream<T>, que representa um stream de qualquer tipo T. A partir de um stream deste tipo, são possíveis operações terminais, operações terminais de mapeamento e outras operações intermediárias.

Mapa mental para operações terminais e intermediárias de Stream<T>.
As operações terminais estão agrupadas na parte superior da figura e sua execução resulta em tipos primitivos (boolean e long) ou tipos objeto (T, Optional<T>, Object e arrays). Muitas destas operações são de redução, pois processam um stream produzindo um resultado final de tipo específico.

As operações terminais de mapeamento resultam em streams de tipo R ou de outros tipos diferentes de Stream<T>. Com estes streams é possível encadear novas operações, criando pipelines do resultado do mapeamento.

Na parte inferior da figura estão ilustradas operações intermediárias, que a partir do stream de entrada Stream<T> resultam em outro stream do mesmo tipo Stream<T>, permitindo o encadeamento de operações e a construção de pipelines de operações.

Uma aplicação

A Enviromental Protection Agency (EPA) é uma agência norte-americana que divulga anualmente informações sobre os automóveis à venda nos EUA sob o nome de Fuel Eficiency Guide. Para cada modelo temos dados: do fabricante, do motor, da transmissão, do combustível usado e do consumo.

Com a planilha de dados de 2015, foi gerado um arquivo CSV (Comma Separated Values), ilustrado abaixo.

Fragmento de arquivo CSV originado do Fuel Eficiency Guide 2015/EPA.
O programa que segue, ProcessCSVtoList lê o arquivo CSV denominado "2015 FEGuide.xlsx", criando um objeto Car com os dados de cada modelo encontrado no arquivo. A lista criada, na verdade uma instância de ArrayList, é serializada, permitindo que os dados dos modelos sejam persistidos e usados por outras aplicações sem a necessidade de bancos de dados ou da repetição deste processamento.

// ProcessCSVtoList.java
package jandl.streamAgain.app;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.StringTokenizer;

public class ProcessCSVtoList {

public static void main(String[] args) throws Exception {
List<Car> list = new ArrayList<>();
BufferedReader br = new BufferedReader(
  new FileReader("2015 FEGuide.csv"));
String line = br.readLine();
while ((line = br.readLine()) != null) {
StringTokenizer st = new StringTokenizer(line, ";");
if (st.countTokens() == 22) {
String[] data = new String[22];
int i = 0;
while (st.hasMoreTokens()) {
data[i] = st.nextToken();
i++;
}
list.add(dataToCar(data));
}
}
System.out.println("cars: " + list.size());
br.close();
serializeList(list, "2015carlist.ser");
}

public static Car dataToCar(String[] data)
  throws ParseException {
Car car = new Car();
car.modelYear = Integer.parseInt(data[0]);
car.manufacturerName = data[1];
car.manufacturerDivision = data[2];
car.carline = data[3];
NumberFormat nf = NumberFormat.getNumberInstance();
car.engineVolume = nf.parse((data[4])).doubleValue();
car.numberOfCylinders = Integer.parseInt(data[5]);
car.transmission = data[6];
car.fuelEficiencyCity = Integer.parseInt(data[7]);
car.fuelEficiencyHighway = Integer.parseInt(data[8]);
car.fuelEficiencyCombined = Integer.parseInt(data[9]);
car.airAspirationMethod = data[10];
car.transmissionDescription = data[11];
car.numberOfGears = Integer.parseInt(data[12]);
car.driveDescription = data[13];
car.maximumEthanolPercentage = Integer.parseInt(data[14]);
car.maximumBiodieselPercentage = Integer.parseInt(data[15]);
car.fuelUsage = data[16];
car.numberOfIntakeValvesPerCyl = Integer.parseInt(data[17]);
car.numberOfExhaustValvesPerCyl = Integer.parseInt(data[18]);
car.varValveTimingDescription = data[19];
car.fuelMeteringSysDescription = data[20];
car.oilViscosity = data[21];
return car;
}

public static void serializeList(List<Car> list, String fileName)
     throws Exception {

ObjectOutputStream oos = new ObjectOutputStream(
  new FileOutputStream(fileName));
oos.writeObject(list);
oos.close();
}

public static List<Car> deserializeList(String fileName)
  throws Exception {
ObjectInputStream ois = new ObjectInputStream(
  new FileInputStream(fileName));
@SuppressWarnings("unchecked")
List<Car> list = (List<Car>) ois.readObject();
ois.close();
return list;
}
}

A classe  ProcessCSVtoList contém quatro métodos estáticos: main(String[]), dataToCar(String[]), serializeList(List<Car>, String) e serializeList(String). O método main(String[], que constitui o início da aplicação, lê o arquivo CSV e, com o auxílio de um StringTokenizer, separa os dados de cada campo dos automóveis, armazenando-os em um array de String. Este array é transformados em um objeto Car por meio do  uso do método dataToCar(String[]), cuja implementação é pouco elegante, mas adequada.

O método serializeList(List<Car>, String) realiza a serialização do objeto List<Car> recebido, salvando-o num arquivo cujo nome é dado pelo segundo parâmetro. Já o método deserializeList(String) efetua a operação inversa, ou seja, recupera um ArrayList de objetos Car do arquivo denominado pelo parâmetro recebido.

A classe Car é muito simples, constituindo-se apenas de um conjunto de campos para armazenar os dados de cada modelo. O único método disponível é toString(), implementado para facilitar a exibição de tais dados.

// Car.java
package jandl.streamAgain.app;

import java.io.Serializable;

public class Car implements Serializable {
private static final long serialVersionUID = 1L;

int modelYear;
String manufacturerName;
String manufacturerDivision;
String carline;
double engineVolume;
int numberOfCylinders;
String transmission;
int fuelEficiencyCity;
int fuelEficiencyHighway;
int fuelEficiencyCombined;
String airAspirationMethod;
String transmissionDescription;
int numberOfGears;
String driveDescription;
int maximumEthanolPercentage;
int maximumBiodieselPercentage;
String fuelUsage;
int numberOfIntakeValvesPerCyl;
int numberOfExhaustValvesPerCyl;
String varValveTimingDescription;
String fuelMeteringSysDescription;
String oilViscosity;

public String toString() {
return manufacturerName + " "+ carline + " " + engineVolume + " " + transmission;
}
}

Considerando que existem mais de 1000 modelos diferentes na lista que dispomos, para encontrarmos aqueles cujos motores possuem, por exemplo, 10 cilindros, seria necessário código como:

List<Car> lista = ProcessCSVtoList.deserializeList("2015carlist.ser");
int iCylinders = 10;
// código convencional
for(Car c: lista) {
if (c.numberOfCylinders==iCylinders) {
System.out.println(c);
}
}

Com o uso da API Stream e de expressões lambda, tal construção se reduz para:

// StreamFrag401.java
lista.stream()
     .filter(car -> car.numberOfCylinders==cylinders)
     .forEach(c-> System.out.println(c));

Observe que a variável cylinder, definida fora da expressão lambda, pode ser utilizada sem qualquer problema, desde que para leitura. Isto é chamado de captura de variáveis.

Caso seja necessário a contagem dos elementos filtrados pelo pipeline, o uso de variáveis simples não é adequado, pois não podem ser alteradas (as expressões lambdas se comportam como closures e não produzem efeitos colaterais) requerendo um pequeno expediente: o uso de um objeto para realizar a contagem.

O código convencional que segue filtra e conta veículos que podem utilizar um percentual mínimo de etanol indicado pela variável ethanol.

List<Car> lista = ProcessCSVtoList.deserializeList("2015carlist.ser");
int ethanol = 85;

// código convencional
int count = 0;
for(Car c: lista) {
if (c.maximumEthanolPercentage>=ethanol) {
System.out.println(c);
count++
}
}
System.out.println("count = " + count);

Como antes, o uso da API Stream e expressões lambda simplifica bastante o código necessário. Observe também o uso de uma instância da classe Counter, dada a seguir, que implementa um conveniente contador.

Counter counter = new Counter();
lista.stream()
     .filter(car -> car.maximumEthanolPercentage>=ethanol)
     .forEach(c-> { System.out.println(c); counter.inc(); });
System.out.println("count = " + counter.count);

A classe Counter é simples, direta e útil!

package jandl.streamAgain.app;

public class Counter {
public int count;
public Counter() { this(0); }
public Counter(int count) { count = 0; }
public void inc() { count++; }
public void preset(int count) { this.count = count; }
public void reset() { count = 0; }
}

Composição de Predicados

A filtragem de elementos de um stream utiliza-se de predicados, isto é, de objetos que implementam a interface funcional Predicate<T>, convenientemente substituíveis por expressões lambda. Quando o critério de filtragem é simples, tal como mostrado nos fragmentos anteriores, a expressão lambda obtida é igualmente simples.

Mas existem situações onde o critério de filtragem é composto e, portanto, mais complexo. Como por exemplo, filtrar modelos que aceitem mais de 50% etanol, que tenham motores de 6 cilindros e transmissão com 7 ou mais velocidades. Para efetuar tal filtragem é possível escrever:

lista.stream()
.filter(car -> car.maximumEthanolPercentage >= ethanol &&
car.numberOfCylinders==iCylinders &&
car.numberOfGears>=gears)
.forEach(c ->System.out.println(c));

É fácil perceber que predicados compostos tem legibilidade comprometida. Para contornar isso, mantendo o código legível e mais fácil de manter, podemos fazer como no fragmento que segue:

// StreamFrag403.java
List<Car> lista =
    ProcessCSVtoList.deserializeList("2015carlist.ser");


int ethanol = 50;
int iCylinders = 6;
int gears = 7;

Predicate<Car> ethanolPredicate =
    car -> car.maximumEthanolPercentage >= ethanol;

Predicate<Car> cylinderPredicate =
    car -> car.numberOfCylinders==iCylinders;

Predicate<Car> gearPredicate =
    car -> car.numberOfGears>=gears;


lista.stream()     .filter(ethanolPredicate
             .and(cylinderPredicate)
             .and(gearPredicate))
     .forEach(c -> System.out.println(c));

Assim, cada predicado é definido separadamente e composto por meio de operações disponíveis na interface Predicate<T>, melhorando a legibilidade, manutenabilidade e reusabilidade do código.

Execução sequencial ou paralela

A API Stream possibilita a execução sequencial ou paralela de suas operações, o que é determinado pela maneira com que o stream é criado a partir de sua fonte.

Enquanto o método default stream() retorna um stream sequencial de uma coleção, seu correspondente parallelStream() retorna um stream cuja execução acontecerá em paralelo. Num stream sequencial as operações acontecerão na ordem em que os elementos do stream são encontrados, ou seja, sequencialmente, sendo executadas em uma thread (e portanto ocupando apenas um processador ou núcleo do sistema). Já num stream paralelo, várias operações serão executadas concorrentemente em múltiplas threads, utilizando os elementos do stream em ordem diversa da sequencial, sendo possivelmente executados por vários processadores (ou núcleos) do sistema. Isto permite explorar melhor os recursos do sistema.

No fragmento que segue, temos uma grande lista com 2 milhões de elementos inteiros gerados aleatoriamente.

// StreamFrag404.java
int MAXIMO = 2000000;
List<String> valores = new ArrayList<>(MAXIMO);
for (int i = 0; i < MAXIMO; i++) {
UUID uuid = UUID.randomUUID();
valores.add(uuid.toString());
}

Os elementos desta lista são ordenados com uso de uma stream sequencial e de uma stream paralela, exibindo diferentes tempos de processamento. Observe que os trechos de código são idênticos, exceto pela forma de obtenção do stream.

// StreamFrag404.java
long tIni, tFim, num, tDecorrido;

// ordenação sequencial
tIni = System.nanoTime();
num = valores.stream().sorted().count();
System.out.println(num);
tFim = System.nanoTime();
tDecorrido = TimeUnit.NANOSECONDS.toMillis(tFim - tIni);
System.out.printf("Ordenacao sequencial: %d ms\n", tDecorrido);
// ordenação paralela
tIni = System.nanoTime();
num = valores.parallelStream().sorted().count();
System.out.println(num);
tFim = System.nanoTime();
tDecorrido = TimeUnit.NANOSECONDS.toMillis(tFim - tIni);
System.out.printf("Ordenacao paralela  : %d ms\n", tDecorrido);

Como esperado, o tempo de processamento com uso de um stream paralelo é menor do que com o uso do stream sequencial.

No próximo fragmento, a lista de modelos de veículos é filtrada para exibição daqueles com tranmissão com 9 velocidades.

// StreamFrag405.java
List<Car> lista = ProcessCSVtoList.deserializeList("2015carlist.ser");
int gears = 9;

System.out.println("==========");
Counter counter = new Counter();
lista.stream()
.filter(car -> car.numberOfGears==gears)
.forEach(s-> { System.out.println(s); counter.inc(); });
System.out.println("count = " + counter.count);
System.out.println("==========");
counter.reset();
lista.parallelStream()
.filter(car -> car.numberOfGears==gears)
.forEach(s-> { System.out.println(s); counter.inc(); });
System.out.println("count = " + counter.count);
System.out.println("==========");

Enquanto que com uso do stream sequencial os modelos são exibidos na mesma ordem em figuram na lista completa, a utilização do stream paralelo produz um resultado com ordenação diversa, pois threads diferentes processaram porções distintas da lista. Mas o mesmo número de registros é exibido pelas duas estratégias.

Conclusões

A API Stream adiciona um conjunto extremamente flexível e poderoso de características às Coleções, facilitando muito a produção de código mais simples, de melhor legibilidade, de manutenção mais fácil e, possivelmente de reuso mais fácil.
No entanto, é bastante provável que não existam ganhos de desempenho por parte da aplicação, pois tais faciilidades oneram os tempos de execução. Ainda assim não devem ser desprezadas, pois em geral, a perda de perfomance não será perceptível para aplicações comuns, tornando substancial o ganho de produtividade do programador. Já aplicações massivas e de alto desempenho terão que utilizar mais cautelosamente esta API, balanceando os ganhos possíveis decorrentes do desenvolvimento simplificado com os requisitos de performance exigidos pela aplicação.
Conhecer e explorar esta API é, desta maneira, mandatório, para que possa ser empregada com sucesso conforme as demandas de cada projeto!

Este artigo faz parte de uma pequena série Coleções e Streams outra vez:

Para saber mais