[Pesquisar este blog]

terça-feira, 22 de setembro de 2015

Streams e operações em massa para coleções

A cada nova versão a API do Java evolui e se torna mais rica. No Java 8, entre adições e correções, destaca-se um novo “elemento” que são os streams para as coleções e todo um novo conjunto de possibilidades baseadas nesta novidade que são as operações de filtragem, mapeamento e redução (fiter/map/reduce) do conteúdo de coleções, conhecidas também como operações em massa.

Um stream pode ser entendido como um duto abstrato que é capaz de realizar a transmissão de dados de uma fonte para um destino. Um array, uma string, um arquivo ou um dispositivo podem funcionar tanto como fonte, provendo dos dados que serão transportados pelo duto, como destino, quando armazenarão os elementos recebidos. Praticamente todo o tratamento de arquivos e dispositivos se baseia no uso de streams e de suas operações de leitura (da fonte) e escrita (no destino).

O novo pacote java.util.stream permite associar um stream a uma coleção, de maneira que todo o seu conteúdo seja percorrido em sequência e sem a repetição de qualquer um de seus elementos. Assim torna-se possível aplicar homogeneamente uma transformação específica a todo o conteúdo de uma coleção. Operações possíveis de transformação são a filtragem, o mapeamento ou a redução.

A filtragem é uma operação de separação de elementos particulares de uma coleção que atendem um critério de seleção específico, o que significa que cada elemento satisfaz um predicado pré-estabelecido. Considerando uma coleção de objetos Automovel (um exemplo é definido posteriormente), que descrevem veículos por meio de sua marca, modelo, ano de fabricação, placa, cidade e cor, entre outros atributos possíveis, uma operação de filtragem poderia ser a determinação do conjunto de veículos de uma determinada marca, ou de uma cidade, ou de uma combinação destes atributos.

Já o mapeamento consiste na associação de cada elemento a uma outra informação, ou até mesmo sua substituição por outro. Voltando a pensar na coleção de objetos Automovel, uma operação de mapeamento poderia associar as informações técnicas dos modelos com seus elementos ou substituir a placa por outro padrão de identificação.

As operações de redução geralmente agregam a informação contida pelo elemento ou por um de seus atributos produzindo um resultado final. Ainda considerando a coleção de objetos Automovel, uma operação de redução poderia determinar a idade média dos veículos ou a contagem daqueles com uma cor particular.

Como as operações de filtragem, mapeamento e redução podem ser combinadas entre si, é enorme o número de problemas onde poderiam ser empregadas.

Coleções

As coleções são um conjunto de estruturas de dados de propósito geral, organizada como um conjunto de interfaces e classes contidas no pacote java.util. Para que seu uso seja flexível e robusto, utilizam extensivamente os genéricos, o que permite sua adaptação aos tipos específicos de dados de cada problema.

A interface Collection<E> é a raiz da hierarquia das coleções, ou seja, define o conjunto de operações básico que deve ser oferecido por todas as suas subinterfaces e classes que as realizam. Mas a interface Collection<E> é uma subinterface de Iterable<T>, que prevê um método denominado iterator(), cujo objetivo é retornar um objeto do tipo Iterator<E> responsável por prover um mecanismo de navegação comum a todas as coleções.

Um objeto Iterator<E> provê o acesso sequencial e individual a todos os elementos da coleção que o forneceu, sem repetição e independente da organização interna dos elementos da coleção. Isto significa que, sem conhecer detalhes da implementação da coleção, é possível percorrer todos os seus elementos, o que constitui uma operação muito útil.

Observe o código que segue:
// obtém Iterator a partir da coleção
Iterator it<?> = colecao.iterator();
// laço percorre e exibe elementos da coleção
while (it.hasNext()) {
    // acesso ao elemento é provido por next()
    System.out.println("Elemento: " + it.next());
}

Cada coleção pode fornecer um objeto Iterator<E> adequado à sua navegação por meio do método iterator(). O método hasNext() do Iterator<E> permite verificar se existe ao menos um elemento que não foi acessado na coleção. O método next() retorna o próximo elemento a ser navegado, mas não verifica sua disponibilidade prévia. Assim, um laço pode, com uso de hasNext(), verifica se existem elementos a serem navegados, os quais serão obtidos por meio de next().

Assim a navegação por meio de um iterator permite processar todos os elementos de uma coleção, ou seja, possibilita encontrar elementos que atendam um critério específico, ou executar uma transformação nos elementos da coleção, ou ainda contar, totalizar e realizar outras operações de agregação ou redução.

Porém existem algumas limitações no uso dos iterators: não permitem o acesso direto a um elemento qualquer (ou seja, a coleção deve ser percorrida até que o elemento desejado seja encontrado); e não são adequados para remoção de elementos da coleção. Além disso, cada elemento da coleção deve ser processado um-a-um, ou seja, individualmente.

Streams para coleções

A partir do Java 8, qualquer objeto do tipo Collection<E>, ou seja, toda e qualquer implementação disponível na API Collections, pode retornar um objeto do tipo Stream<E> por meio de seu método stream(), ou seja, retorna um stream que permite o acesso sequencial a todo o conteúdo desta coleção. 

Um stream é um duto abstrato capaz de realizar a transmissão de dados de uma fonte para um destino. Arrays, strings, arquivos ou dispositivos podem funcionar tanto como fonte dos dados que serão transportados pelo duto, como destino para armazenado dos dados recebidos. Praticamente todo o tratamento de arquivos e dispositivos se baseia no uso de streams e de suas operações de leitura (da fonte) e escrita (no destino).

Os streams retornados pelo método stream() das coleções estão definidos no novo pacote java.util.stream. Por meio destes streams, o conteúdo da coleção pode ser percorrido em sequência e sem a repetição de qualquer um de seus elementos, de maneira análoga ao serviço provido pelos iterators. A grande diferença é que por meio dos streams foram implementadas novas operações que tornam possível aplicar homogeneamente uma operação específica a todo o conteúdo de uma coleção, sem necessidade de sua navegação um-a-um.

Por isso essas operações são conhecidas como operações em massa (bulk operations) para coleções, que permitem realizar a filtragem, o mapeamento ou a redução do conteúdo de integral de uma coleção, como será exemplificado nas próximas seções deste artigo.

A interface Iterable<T> foi modificada no Java 8 para incluir o método forEach(Consumer<T>), destinado a simplificar o processamento do conteúdo de coleções. Este método permite indicar uma função com assinatura Consumer<T>, uma interface funcional, que será aplicada a todos os elementos da coleção, ou seja, aceita um objeto deste tipo, uma referência para método compatível ou uma expressão lambda.

Assim, para exibir todos os elementos de uma coleção é possível escrever:
// exibe todos os elementos da coleção
colecao.forEach(e -> System.out.println(e));

A expressão lambda usada indica que argumento recebido será exibido no console, sendo que o método forEach provê a aplicação desta função para cada elemento da coleção. Os streams também possuem um método forEach(Consumer<T>) idêntico, ou seja:
// obtém stream a partir de coleção
Stream<?> stream = colecao.stream();
// exibe todos os elementos da coleção
stream.forEach(e -> System.out.println(e));

O uso do método forEach(Consumer<T> é tão ou mais simples que o uso de um Iterator<E>.

Segue a definição de uma classe Automovel que permite armazenar informações de marca, modelo, ano de fabricação, placa, cidade e cor de um automóvel.

/* Arquivo: Automovel.java
 */
package jandl.j8s;

import java.awt.Color;

public class Automovel {
    private String marca;
    private String modelo;
    private int anoFabricacao;
    private String placa;
    private String cidade;
    private Color cor;
    
    public Automovel(String marca, String modelo,
               int anoFabricacao, String placa,
               String cidade, Color cor) {
          setMarca(marca);
          setModelo(modelo);
          setAnoFabricacao(anoFabricacao);
          setPlaca(placa);
          setCidade(cidade);
          setCor(cor);
     }
     public String getMarca() {
          return marca;
     }
     public void setMarca(String marca) {
          this.marca = marca;
     }
     public String getModelo() {
          return modelo;
     }
     public void setModelo(String modelo) {
          this.modelo = modelo;
     }
     public int getAnoFabricacao() {
          return anoFabricacao;
     }
     public void setAnoFabricacao(int anoFabricacao) {
          this.anoFabricacao = anoFabricacao;
     }
     public String getPlaca() {
          return placa;
     }
     public void setPlaca(String placa) {
          this.placa = placa;
     }
     public String getCidade() {
          return cidade;
     }
     public void setCidade(String cidade) {
          this.cidade = cidade;
     }
     public Color getCor() {
          return cor;
     }
     public void setCor(Color cor) {
          this.cor = cor;
     }
     public String toString() {
          return String.format("%s:%s:%d",
                  marca, modelo, anoFabricacao);
     }
}

Para tornar os exemplos que seguem um pouco mais simples, a classe AutomovelFactory define um único método estático capaz de criar e retornar um objeto List<Automovel>, na verdade um ArrayList contendo vários objetos Automovel.

/* Arquivo: AutomovelFactory.java
 */
package jandl.j8s;

import java.awt.Color;
import java.util.ArrayList;
import java.util.List;

public class AutomovelFactory {
    public static List<Automovel> createList() {
        // cria lista de Automovel com ArrayList
        List<Automovel> lista = new ArrayList<>();

        // Insere objetos Automovel na lista
        lista.add(new Automovel("Fiat", "Novo Uno",
                2015, "ABC-1234", "Campinas-SP", Color.WHITE));
        lista.add(new Automovel("Fiat", "Punto",
                2014, "BCD-2345", "Cajamar-SP", Color.BLACK));
        lista.add(new Automovel("Chevrolet", "Celta",
                2013, "CDE-3456", "Campinas-SP", Color.RED));
        lista.add(new Automovel("Chevrolet", "Spin",
                2014, "DEF-4567", "Atibaia-SP", Color.BLUE));
        lista.add(new Automovel("Volkswagen", "Gol",
                2013, "EFG-5678", "Jundiaí­-SP", Color.GRAY));
        lista.add(new Automovel("Volkswagen", "Jetta",
                2015, "FGH-6789", "Itu-SP", Color.WHITE));
        lista.add(new Automovel("Honda", "Civic",
                2012, "ABC-7890", "Atibaia-SP", Color.BLACK));
        lista.add(new Automovel("Renault", "Duster",
                2013, "BCD-8901", "Cajamar-SP", Color.BLUE));
        lista.add(new Automovel("Ford", "Fiesta",
                2014, "DEF-9012", "Atibaia-SP", Color.RED));
        lista.add(new Automovel("Ford", "Ka",
                2011, "EFG-0123", "Jundiaí-SP", Color.LIGHT_GRAY));

        // retorna lista
        return lista;
    }
}
Utilizando a lista de objetos Automovel, serão exemplificadas as operações de navegação, filtragem, mapeamento e redução de coleções.

Navegação de coleções com streams

O programa Teste01 que segue é composto de três blocos: no primeiro é obtida uma lista de objetos Automovel por meio do método estático createList() da classe AutomovelFactory; no segundo esta lista é navegada (percorrida) com um Iterator; e no bloco final a navegação é realizada por meio de um forEach e de uma expressão lambda.

/* Arquivo: Test01.java
 */
import jandl.j8s.Automovel;
import jandl.j8s.AutomovelFactory;

import java.util.Iterator;
import java.util.List;
import java.util.stream.Stream;

public class Teste01 {

    public static void main(String[] args) {
        // Obtencao de lista de objetos Automovel
        List<Automovel> lista = AutomovelFactory.createList();

        // Navegacao na lista com iterator
        System.out.println("Navegacao com iterator");
        Iterator<Automovel> it = lista.iterator();
        while (it.hasNext()) {
            System.out.println(it.next());
        }
        System.out.println();

        // Navegacao na lista com stream
        System.out.println("Navegacao com stream");
        Stream<Automovel> stream = lista.stream();
        stream.forEach(auto -> System.out.println(auto));
    }
}

A execução deste exemplo produz:

Navegacao com iterator
Fiat:Novo Uno:2015
Fiat:Punto:2014
Chevrolet:Celta:2013
Chevrolet:Spin:2014
Volkswagen:Gol:2013
Volkswagen:Jetta:2015
Honda:Civic:2012
Renault:Duster:2013
Ford:Fiesta:2014
Ford:Ka:2011

Navegacao com stream
Fiat:Novo Uno:2015
Fiat:Punto:2014
Chevrolet:Celta:2013
Chevrolet:Spin:2014
Volkswagen:Gol:2013
Volkswagen:Jetta:2015
Honda:Civic:2012
Renault:Duster:2013
Ford:Fiesta:2014
Ford:Ka:2011

Ou seja, tanto a navegação por meio do Iterator<Automovel>, destacada abaixo:
Iterator<Automovel> it = lista.iterator();
while (it.hasNext()) {
    System.out.println(it.next());
}

Como a navegação provida por forEach() e a expressão lambda:
Stream<Automovel> stream = lista.stream();
stream.forEach(automovel -> System.out.println(automovel));

Produzem o mesmo resultado, embora a segunda forma seja um pouco mais simples e direta.

Filtragem de coleções com streams

A filtragem consiste na seleção de um subconjunto de elementos de uma estrutura de dados ou coleção que atende a um critério ou predicado específico. 

A filtragem convencional de uma coleção para criação de uma sublista com os elementos desejados empregaria código como segue:
List<Automovel> sublista1 = new ArrayList<>();
Iterator<Automovel> it = lista.iterator();
while (it.hasNext()) {
    Automovel a = it.next();
    if (a.getAnoFabricacao()==2013) { // critério de filtragem
        sublista1.add(a);
    }
}

Com uso das operações das operações em massa a criação de uma sublista filtrada se reduz à:
List<Automovel> sublista2 = lista.stream()
        .filter((a) -> a.getAnoFabricacao() == 2013)
        .collect(Collectors.toList());

Ou seja: a partir da coleção obtém-se sua stream associada com stream(); por meio desta é realizada a filtragem com uso do método filter(Predicate<T>), que é uma interface funcional e possibilita o uso de uma expressão lambda para indicar o critério de filtragem; e finalmente collect(Collector<T,A,R>) que transfere (coleta) os elementos obtidos da stream retornada por filter para uma lista criada com a classe Collectors<T>. Aqui existe substancial redução do código utilizado para filtragem. Além disso, toda parametrização de tipos é inferida automaticamente pelo compilador.

O programa Teste02 mostra a filtragem de uma lista de objetos Automovel por meio do um Iterator e também com uso das  streams e das operações em massa.

/* Arquivo: Test02.java
 */
import jandl.j8s.Automovel;
import jandl.j8s.AutomovelFactory;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.stream.Collectors;

public class Teste02 {

    public static void main(String[] args) {
        // Obtencao de lista de objetos Automovel
        List<Automovel> lista = AutomovelFactory.createList();

        // Obtencao de sublista de automoveis de 2013
        // com uso de iterator
        System.out.println("Filtragem com iterator");
        List<Automovel> sublista1 = new ArrayList<>();
        Iterator<Automovel> it = lista.iterator();
        while (it.hasNext()) {
            Automovel a = it.next();
            if (a.getAnoFabricacao()==2013) {
                sublista1.add(a);
            }
        }
        System.out.println(sublista1);
        System.out.println();

        // Obtencao de sublista de automoveis de 2013
        // com stream/operações em massa
        System.out.println("Filtragem com stream");
        List<Automovel> sublista2 = lista.stream()
                .filter((a) -> a.getAnoFabricacao() == 2013)
                .collect(Collectors.toList());
        System.out.println(sublista2);
    }
}

As duas estratégias de filtragem produzem o mesmo resultado, como visto a seguir:
Filtragem com iterator
[Chevrolet:Celta:2013, Volkswagen:Gol:2013, Renault:Duster:2013]

Filtragem com stream
[Chevrolet:Celta:2013, Volkswagen:Gol:2013, Renault:Duster:2013]

É clara a vantagem proporcionada pelo uso das operações em massa que, com menos código, permitem indicar com clareza e simplicidade a natureza das operações realizadas, no caso visto, a filtragem e a coleta de elementos de coleções.

Mapeamento de coleções com streams

O mapeamento é a operação de transformação dos elementos existentes em uma coleção, segundo uma função, dita de mapeamento ou transformação, que permite obter um novo conjunto associado de valores. O mapeamento pode modificar os elementos das coleções ou eventualmente associá-los a outros elementos.

Os métodos existentes para isto são:
  • Stream<R> map(Function<T, R>);
  • DoubleStream mapToDouble(ToDoubleFuntion<T>);
  • IntStream mapToInt(ToIntFunction<T>); e
  • LongStream mapToLong(ToLongFunction<T>).
Segue um fragmento de código onde uma Stream<Double> é criada a partir de valores compatíveis com o tipo, funcionando como uma lista de elementos Double. Esta lista é transformada de modo a gerar uma nova lista de resultados contendo a a metade dos valores originais. 
// Cria Stream a partir de valores reais
Stream<Double> streamD = Stream.of(0.55, 0.66, 0.77, 0.88, 0.99);
// Valores do stream são mapeados em suas metades
DoubleStream resultado = streamD.mapToDouble(e -> e/2.0);
// Stream com resultado é exibida
resultado.forEach(e -> System.out.println(e));

No exemplo que segue, a classe utilitária MassaAltura pretende apenas armazenar pares de valores reais correspondentes a massa e altura de uma pessoa. (Os campos foram declarados públicos e sem qualquer validação para manter a classe a mais simples possível). Na classe Teste03 é criada uma lista contendo quatro objetos MassaAltura correspondentes a diferentes indivíduos. A lista é exibida por meio de um stream. Outro stream é obtido para permitir o mapeamento dos objetos MassaAltura no valor correspondente de seu índice de massa corpórea, onde imc = massa/(altura*altura). Aqui a função utilizada para o mapeamento é mapToDouble(ToDoubleFunction<T>) e o argumento fornecido é uma expressão lambda de mesmo target-type. A stream resultante do mapeamento é exibida no final do programa.

/* Arquivo: Teste03.java
 */
import java.util.ArrayList;
import java.util.List;
import java.util.stream.DoubleStream;

public class Teste03 {

    public static void main(String[] args) {
        // Cria lista com valores double
        List<MassaAltura> lista = new ArrayList<>();
        lista.add(new MassaAltura(83.5, 1.69));
        lista.add(new MassaAltura(95.3, 1.83));
        lista.add(new MassaAltura(60.0, 1.64));
        lista.add(new MassaAltura(58.5, 1.71));
        // Exibe lista
        System.out.println("MassaxAltura");
        lista.stream()
                .forEach(e -> System.out.printf(
                        "%5.1f x %4.2f\n", e.massa, e.altura));

        // Efetua mapeamento (transformação) da lista
        DoubleStream imc = lista.stream()
                .mapToDouble(e -> e.massa / Math.pow(e.altura, 2));
        // Exibe lista imc
        System.out.println("IMC");
        imc.forEach(e -> System.out.printf("%5.2f\n", e));
    }
}

// classe utilitária
class MassaAltura {
    // campos públicos
    public double massa, altura;

    // construtor
    public MassaAltura(double massa, double altura) {
            this.massa = massa;
            this.altura = altura;
    }
}

Este exemplo produz resultados como:
MassaxAltura
 83.5 x 1.69
 95.3 x 1.83
 60.0 x 1.64
 58.5 x 1.71
IMC
29.24
28.46
22.31
20.01
Um outro exemplo seria a obtenção da lista das idades em anos completos dos automóveis brancos presentes em uma lista, como feito em Teste04. Aqui a função de filtragem é filter(Predicate<T>), combinada com a função de mapeamento mapToInt(ToIntFunction<T>). Ambas recebem como argumento uma expressão lambda equivalente a interface funcional necessária.

/* Arquivo: Teste04.java
 */
import jandl.j8s.Automovel;
import jandl.j8s.AutomovelFactory;

import java.util.List;
import java.util.stream.IntStream;

public class Test04 {

    public static void main(String[] args) {
        // Obtencao de lista de objetos Automovel
        List<Automovel> lista = AutomovelFactory.createList();
        // Efetua mapeamento (transformação) da lista de automóveis
        IntStream resultado = lista.stream()
                .filter(e -> e.getCor() == Color.WHITE)
                .mapToInt(e -> 2015 - e.getAnoFabricacao());
        // Exibe lista resultado
        resultado.forEach(e -> System.out.println(e));
        System.out.println();
    }
}

O resultado produzido aqui é uma lista de inteiros com dois valores zero correspondente às idades dos dois automóveis brancos existentes na lista, cujo ano de fabricação é 2015 (portanto idade em anos completos é zero).

Novamente é bastante clara a vantagem proporcionada pelo uso das operações em massa que, com menos código, permitem indicar com clareza e simplicidade a natureza das operações realizadas. Outro aspecto importante é que as operações podem ser combinadas, como visto para filtragem e mapeamento.

Redução de coleções com streams

A redução é a operação que toma uma sequência de elementos de entrada aplicando uma função de combinação em cada um, produzindo um resultado final. Ou seja, dados relacionados com conjuntos de elementos podem ser totalizados, contados ou agregados, resultando num único valor final que corresponde a redução do conjunto.

Os métodos existentes para isto, disponíveis nas classes Stream<T>, IntStream, LongStream e DoubleStream são:
  • long count();
  • Optional<T> max (Comparator<T>); e
  • Optional<T> min (Comparator<T>).
Adicionalmente as classes IntStream, LongStream e DoubleStream oferecem métodos adicionais:
  • OptionalDouble average();
  • <tipo> sum(); e
  • <tipo>SummaryStatistics summaryStatistics().
O programa Teste05 que segue obtém a lista de automóveis por meio da classe AutomovelFactory e, com o uso de streams, efetua várias operações. A contagem de automóveis (elementos) com count() é uma operação de redução direta. A contagem de automóveis de cor preta combina operações de filtragem filter(Predicate<T>) com redução count(). A determinação da idade média dos automóveis associa operações de mapeamento mapToInt(ToIntFunction<T>) e redução average().

/* Arquivo: Teste05.java
 */
import jandl.j8s.Automovel;
import jandl.j8s.AutomovelFactory;

import java.awt.Color;
import java.util.List;
import java.util.OptionalDouble;

public class Teste05 {

    public static void main(String[] args) {
        // Obtencao de lista de objetos Automovel
        List<Automovel> lista = AutomovelFactory.createList();

        System.out.println("Automoveis: ");
        System.out.println("Total: " + lista.stream().count());
        System.out.println("Pretos: " + lista.stream()
                .filter(e -> e.getCor() == Color.BLACK)
                .count());
        OptionalDouble idadeMedia = lista.stream()
                .mapToInt(e -> 2015 - e.getAnoFabricacao())
                .average();
        System.out.println("Idade Média: " +
                idadeMedia.getAsDouble());
    }
}

Este programa produz resultados como os que seguem:
Automoveis: 
Total: 10
Pretos: 2
Idade Média: 1.6

Outra vez as operações de redução disponíveis nesta API se mostram conveniente, principalmente por permitir que sejam combinadas com operações de filtragem e mapeamento.

Considerações Finais

As coleções sempre constituíram uma importante API do Java, tanto pelo seu papel fundamental na construção de programas, quanto pela qualidade, versatilidade e eficiência de sua implementação. Avaliar sua utilização é imprescindível.

Com a adição das streams para coleções tornou-se possível uma simplificação bastante considerável do código necessário para realização de operações de filtragem, mapeamento e redução. Além disso, o encadeamento destas operações, bastante semelhante ao encontrado na programação funcional, permite a combinação destas operações, possibilitando a obtenção de resultados sofisticados com muita elegância e clareza.

Esta é, sem dúvida, uma das melhores novidades da versão 8 do Java.

Referências


Este artigo faz parte de uma pequena série: