Desde o lançamento da primeira versão do Java, mais de 20 anos se passaram. Nesse período, mudanças significativas na linguagem foram ocorrer apenas em meados de 2004, com a chegada do Java 5. A inclusão de Generics, Enums e Annotations foram algumas das novidades.
Após essa versão, novas mudanças de impacto voltaram a acontecer na linguagem mais de uma década depois com o lançamento da versão 8, feito pela Oracle em 2014. Ao todo são mais de 80 novas funcionalidades listadas, que vão além de apenas mudanças singelas na máquina virtual (JVM), causando impactos naquilo que mais afeta o desenvolvedor: a forma como ele escreve o código.
Na sintaxe, por exemplo, houveram alterações significativas com a incorporação de conceitos originários de linguagens que seguem o paradigma funcional, oferecendo assim mais facilidade em tarefas que antes demandavam maior complexidade e muitas linhas de código.
Uma das novas features recebe o nome de Expressões Lambda (EL). Essa novidade proporcionou ao Java a adição de recursos muito disseminados em linguagens funcionais, facilitando muito a vida dos desenvolvedores. Para aqueles que já se arriscaram em linguagens como Groovy, Scala e Clojure e que estão acostumados, por exemplo, a passar funções como argumentos em uma chamada de método, certamente essas mudanças causarão menos impacto do que naqueles que nunca tiveram esse contato e cujo costume é escrever métodos que recebem apenas objetos ou tipos primitivos como parâmetro.
Outro recurso adicionado à nova versão da linguagem é a API para lidar com datas, a Date and Time, baseada na famosa biblioteca JodaTime. O novo pacote java.time, possui várias classes para trabalhar com objetos que armazenam apenas datas, horas ou mesmo ambos de forma simultânea.
Essa versão trouxe também o recurso denominado Default Methods, que foi introduzido para permitir que interfaces já existentes ofereçam métodos novos sem que os códigos que às utilizem também tenham que fornecer uma implementação para esses métodos. Diante disso, note que um dos focos dessa nova versão continua sendo manter a compatibilidade com códigos legados e ser o menos intrusivo possível, ou seja, afetar o menos possível as antigas APIs.
Essa nova funcionalidade foi usada para incluir o método stream() na API de Collections, por exemplo, e possibilitar que todas as coleções sejam fontes de dados para streams. Além deste, outros métodos padrão também foram incorporados à API de coleções, como o removeIf() na interface Collection, e em Comparator, o método reversed(), que retorna um novo comparador que realiza a ordenação inversa.
A Streams API traz uma nova opção para a manipulação de coleções em Java seguindo os princípios da programação funcional. Combinada com as expressões lambda, ela proporciona uma forma diferente de lidar com conjuntos de elementos, oferecendo ao desenvolvedor uma maneira simples e concisa de escrever código que resulta em facilidade de manutenção e paralelização sem efeitos indesejados em tempo de execução.
A proposta em torno da Streams API é fazer com que o desenvolvedor não se preocupe mais com a forma de se programar o comportamento, deixando a parte relacionada ao controle de fluxo e loop a cargo da API. É algo muito parecido com o que é feito com Threads, onde os aspectos mais complexos ficam encapsulados em APIs e as regras de negócio passam a ser a única responsabilidade do desenvolvedor.
Outro ponto a se destacar sobre a Streams API diz respeito à eficiência do processamento. Com o aperfeiçoamento constante do hardware, sobretudo a proliferação das CPUs multicore, a API levou isso em consideração e com o apoio do paradigma funcional, suporta a paralelização de operações para processar os dados - abstraindo a lógica de baixo nível para se ter um código multithreading - e permitindo que o desenvolvedor concentre-se totalmente nas regras existentes.
A paralelização de operações consiste basicamente em dividir uma tarefa maior em subtarefas menores, processar essas subtarefas em paralelo e, em seguida, combinar os resultados para obter o resultado final. Em sua estrutura, a Streams API fornece um mecanismo similar para trabalhar com a Java Collections, convertendo a coleção em uma stream, em um primeiro momento, processando os vários elementos em paralelo em seguida e, por fim, reunindo os elementos resultantes em uma coleção.
Com base no que foi visto até aqui, o objetivo desse artigo é ensinar, de forma prática e objetiva, como trabalhar com streams e como usar os diferentes tipos de operações disponibilidades por esse novo recurso.
Collections e Streams
Hoje em dia, quase todos os aplicativos Java fazem uso e processam recursos relacionados à interface Collection. Por exemplo, o desenvolvedor pode ter a necessidade de criar uma coleção para armazenar as vendas realizadas em uma loja e em seguida processar esses dados com o intuito de descobrir em que momento do dia ocorre o maior volume de vendas. Com base nessas informações, ações podem ser tomadas, como colocar um número maior de funcionários nesse período ou mesmo alocar os funcionários mais qualificados.
O framework de coleções do Java possui mais de 18 anos, tendo surgido com a versão 2 da linguagem, mas no decorrer desse tempo sofreu apenas algumas evoluções. Até o Java 8, a principal evolução havia sido a adição de recursos devido à criação de Generics, no Java 5, o que permitiu a criação de coleções "tipadas".
A sua estabilidade se justifica, sobretudo, devido à realidade existente até alguns anos atrás, quando a evolução dos processadores era focada no aumento do poder de processamento de um núcleo único (core). No entanto, avanços ocorreram e o foco da indústria mudou, concentrando-se na capacidade de vários núcleos de processamento em um mesmo processador.
Para que esse novo cenário possa trazer ganhos ao usuário, no entanto, não apenas a evolução em nível de hardware se faz necessária. Os softwares também precisam suportar esse novo paradigma, que realmente possibilita a execução de vários processos simultaneamente.
Com isso em mente e considerando as mudanças realizadas para a implementação das Expressões Lambda, o framework de coleções do Java passou por muitas alterações e melhorias. Isso ocorreu principalmente em função da disponibilização dos Default Methods que, como mencionado, proporcionam a evolução de interfaces já muito conhecidas pelos desenvolvedores.
A nova opção das interfaces terem Default Methods possibilitou ao Java 8 criar uma grande quantidade de métodos na Collections API. Esses métodos são "herdados" por aqueles que implementarem a interface e construções mais eficientes podem ser adicionadas às classes quando apropriado.
Com o auxílio desses novos recursos os projetistas da Oracle disponibilizaram uma nova abstração no Java 8 para trabalhar com coleções. De nome Stream, trata-se de uma poderosa solução para processar coleções de maneira declarativa, ao invés da tradicional e burocrática forma imperativa. Na forma imperativa, para realizar uma iteração simples, por exemplo, o desenvolvedor tem que se preocupar não apenas com o que deve ser feito em cada elemento, isto é, com as regras associadas ao processamento dos elementos da lista, mas também com a maneira de realizar essa iteração. Assim, esse procedimento possibilita que a mesma iteração tenha resultados diferentes em função de alterações imprevistas em variáveis de controle ou mesmo mutações no decorrer da repetição.
O trecho de código apresentado na Listagem 1 mostra um exemplo de uso da forma imperativa. Nele deseja-se percorrer uma lista finita de números inteiros e calcular a soma de todos os números pares. Nesse caso três dificuldades podem ser encontradas em virtude dessa abordagem.
Listagem 1. Cálculo da soma de valores inteiros pares antes do Java 5.
private static int somaIterator(List list) { Iterator it = list.iterator(); int soma = 0; while (it.hasNext()) { int num = it.next(); if (num % 2 == 0) { soma += num; } } return soma; }
A primeira dificuldade diz respeito à forma como a lista de valores será iterada, isto é, o algoritmo utilizado para varrer a lista. A segunda dificuldade está relacionada à eficiência do processamento, devido a dificuldade em viabilizar uma maneira eficiente e paralela para percorrer a lista, sobretudo em casos em que a quantidade de dados é extensa. Por fim, o terceiro entrave é a verbosidade do código, ou seja, a grande quantidade de código para realizar tarefas tidas como simples.
Com o advento do Java 5, uma nova maneira de percorrer um array ou coleção (ou qualquer tipo de objeto que implemente a interface java.lang.Iterable) foi criada, deixando o código-fonte menor, como mostra a Listagem 2. No entanto, note que mesmo com esse novo recurso, implementar o código para percorrer uma lista é algo que acaba se tornando tedioso e cansativo.
Listagem 2. Cálculo da soma de valores inteiros pares utilizando o laço for melhorado do Java 5.
private static int somaIterator(Listlist) { int soma = 0; for (int num : list) { if (num % 2 == 0) { soma += num; } } return soma; }
Pensando em solucionar esse problema, a programação funcional oferece uma nova maneira de pensar no fluxo do algoritmo, introduzindo o conceito de programação declarativa. Esta possibilita trabalhar com fluxos a partir do encadeamento de métodos e da passagem de comportamentos via expressões lambda.
Dentre os principais benefícios dessa abordagem pode-se citar a obtenção de códigos mais concisos, a imutabilidade de variáveis e o fim dos side-effects, uma vez que a garantia de que uma variável não vai mudar assegura, dentro de um fluxo, que, quando o código sofrer paralelização não existirão efeitos indesejáveis como, por exemplo, modificar o objeto original ao invés de se criar um novo.
Um código conciso significa um código breve, preciso e claro. Em suma, expressa muito com poucas palavras. Com todas essas características reunidas, o resultado será um código muito mais simples com relação à sua manutenção, esteticamente mais agradável e menos suscetível a erros.
Agora, com as novas features da linguagem, tem-se uma separação do "que fazer" e do "como fazer", ou seja, a partir da versão 8 o desenvolvedor precisa apenas focar em "o que" fazer com os elementos, deixando de lado o "como" percorrer a lista. Além disso, tem-se, em nível de API, o processamento paralelo, que permite aproveitar as arquiteturas de núcleos múltiplos sem ter que programar linhas de código multiprocesso.
Antes de explorar em detalhes o que pode ser feito com Streams, é apresentado um exemplo do novo estilo de programação com Java 8. O propósito desse exemplo é encontrar, em uma lista de ordens de serviços, todas aquelas que estão relacionadas à ativação, por exemplo, de um serviço de telefonia, e por fim retornar todos os identificadores dessas ordens, classificadas de maneira decrescente segundo o valor cobrado pelo serviço. No Java 7, o código pode ser escrito conforme a Listagem 3. Já no Java 8, pode ser empregado o código mostrado na Listagem 4.
Listagem 3. Abordagem no Java 7 ou anterior para trabalhar com coleções.
ListOrdensAtivacao = new Arraylist<>(); for(Ordem o: ordens){ if(o.getType() == Ordem.ATIVACAO){ OrdensAtivacao.add(t); } } Collections.sort(OrdensAtivacao, new Comparator(){ public int compare(Ordem t1, Ordem t2){ return t2.getValue().compareTo(t1.getValue()); } }); List ordensIDs = new ArrayList<>(); for(Ordem o: OrdensAtivacao){ ordensIDs.add(t.getId()); }
Listagem 4. Abordagem no Java 8 utilizando Streams para trabalhar com coleções.
ListordensIDs = Ordem.stream() .filter(o -> o.getType() == Ordem.ATIVACAO) .sorted(comparing(Ordem::getValue).reversed()) .map(Ordem::getId) .collect(toList());
Percebe-se que o acesso aos elementos de uma stream é diferente de como é feito em coleções. Em uma coleção é possível navegar entre os elementos de diferentes maneiras, tanto de forma sequencial quanto por meio de índices. Já em uma stream, o acesso aos elementos é sequencial, não sendo possível alcançá-los através de índices, pois inexiste uma estrutura de dados para armazenar os elementos que, por sua vez, são processados sob demanda.
A Figura 1 mostra o fluxo de operações que acontece durante a execução do código da Listagem 4. Primeiramente, obtem-se uma stream da lista de dados usando o método stream(), da interface Collection. Depois, uma série de operações são aplicadas. O método filter(), por exemplo, retorna apenas as ordens que são do tipo ATIVACAO. Sua saída é processada pela operação sorted(), que ordenará as operações de forma decrescente levando em consideração o valor da operação. Em seguida, o resultado de sorted() será manipulado pelo método map(), que obterá todas as informações desejadas, ou seja, todos os identificadores das ordens da lista de operações. Por fim, o método collect() devolve uma lista de inteiros, em oposição aos demais, que sempre retornam uma nova stream como resultado do processamento.
A aparente complexidade do código não deve ser uma preocupação. O importante neste momento é entender como ocorre o processamento de uma stream. Nas próximas seções serão analisados em detalhes seu funcionamento.
Figura 1. Fluxo de processamento de uma stream.
Outro diferencial é verificado quando se tem a necessidade de manipular grandes quantidades de dados. Nesses casos a Streams API oferece a possibilidade de trabalhar com esses dados de forma paralela, viabilizando uma melhora de desempenho ao tirar proveito do poder de processamento dos computadores modernos.
Tomando como exemplo o código da Listagem 4, para que o desenvolvedor consiga fazer uso da paralelização, basta trocar o método stream() por parallelStream(). Dessa forma, a Streams API irá decompor as ações em várias subtarefas, e as operações serão processadas em paralelo, explorando os recursos oferecidos pelos diversos núcleos do processador.
Características de uma Stream
Uma stream pode ser definida como uma "sequência de elementos de uma fonte de dados que oferece suporte a diferentes tipos de operações de agregação". A seguir, cada um dos componentes dessa definição serão explicados:
- Sequência de elementos: Uma stream provê uma interface para um conjunto sequencial de valores de um determinado tipo. Contudo, streams não armazenam elementos. Elas são processadas sob demanda;
- Fonte de dados: Streams consomem dados de uma fonte, como coleções, arrays ou mesmo recursos de E/S (entrada e saída);
- Operações de agregação: Streams suportam operações comuns a linguagens de programação funcionais, como filtrar, modificar, transformar o elemento em outro e assim por diante. Essas operações podem ser realizadas em série ou em paralelo.
Além disso, as operações relacionadas a streams têm duas características fundamentais que as tornam muito diferentes das operações sobre coleções. São elas:
- Pipeline: Streams são projetadas de tal maneira que a maior parte de suas operações retornam novas streams. Dessa forma, é possível criar uma cadeia de operações que formam um fluxo de processamento. A isso dá-se o nome de pipeline;
- Iteração interna: Tradicionalmente as coleções usam loops ou iteradores para percorrer seus elementos. Esse tipo de operação é conhecida como iteração externa e é claramente perceptível no código. A Listagem 1 demonstra esse tipo de abordagem. Já a partir do Java 8, através da Streams API, é possível encontrar métodos como map(), forEach(), filter(), entre outros, que percorrem uma sequência de elementos internamente focando apenas no que "fazer" com os elementos, livrando o desenvolvedor de ter que se preocupar com a forma de iterar sobre a lista e como manipular cada um dos seus elementos. Ou seja, o modo como ocorre a iteração/loop agora fica encapsulado na API.
Como mencionado, as operações realizadas sobre streams podem ser classificadas como intermediárias ou terminais e quando combinadas formam uma estrutura de processo chamada pipeline, que é composta por uma fonte de dados, seguida por zero ou mais operações intermediárias e uma operação terminal. Essa distinção é importante porque as operações intermediárias servem como entrada para outras operações intermediárias ou para a operação terminal, e as intermediárias não realizam qualquer ação até o momento em que uma operação terminal seja invocada. Esse mecanismo é conhecido como lazy evaluation (avaliação tardia ou "preguiçosa") e possibilita que o processamento da cadeia de operações seja executado de forma mais performática, postergando a computação até um ponto em que o resultado seja, de fato, importante, evitando assim cálculos desnecessários.
Outra otimização que a Stream API oferece ocorre com a ajuda das operações de curto-circuito (short-circuiting). Essas operações possibilitam que o resultado final seja obtido antes que todos os elementos da stream sejam processados. Um exemplo é a chamada ao método limit(n) onde apenas os n primeiros elementos da stream serão processados.
Saiba que as operações intermediárias sempre retornam uma nova stream, de modo que seja possível realizar o encadeamento de múltiplas operações intermediárias. Já as operações terminais, como o próprio nome sugere, residem no final da cadeia de operações e seu objetivo é fechar o processo. Elas retornam um resultado diferente de uma stream, que pode ser um valor ou um objeto.
Em suma, a Streams API trabalha convertendo uma fonte de dados em uma stream. Em seguida, realiza o processamento dos dados através das operações intermediárias e, por fim, retorna uma nova coleção ou valor reduzido (map-reduce) com a chamada a uma operação terminal.
Tomando como base a Listagem 4, filter(), sorted() e map() são tidas como operações intermediárias. Já collect() é classificada como terminal, pois encerra a stream e retorna uma lista.
As operações intermediárias ainda podem ser divididas em dois grupos: stateless e stateful. As operações stateless, como filter() e map(), não armazenam o estado do elemento manipulado anteriormente ao processar um novo elemento. Dessa forma, cada elemento pode ser processado independentemente das operações dos outros elementos. Já as operações stateful, como distinct() e sorted(), podem incorporar o estado do elemento processado anteriormente no processamento de novos elementos.
Após conhecer as principais características de uma stream, o foco será dado no estudo dos principais recursos que compõem a API. Esses recursos serão analisados de acordo com o fluxo de processamento apresentado na Figura 1. Sendo assim, primeiro será mostrado como criar e obter uma stream. Logo após, serão abordadas as operações intermediárias e, em seguida, algumas das mais difundidas operações terminais. Por fim, outros recursos da Streams API serão trabalhados através do desenvolvimento de uma aplicação exemplo.
Trabalhando com a Streams API
A Streams API foi desenvolvida sob o pacote java.util.stream. Deste modo, este disponibiliza classes e interfaces que suportam as mais variadas operações funcionais que podemos aplicar sobre os dados com o Java 8. O tipo mais importante desse pacote é a interface Stream. Dito isso, será dado início ao estudo prático sobre streams.
Como criar streams
O primeiro passo para se trabalhar com streams é saber como criá-las A forma mais comum é através de uma coleção de dados, tendo em vista que o principal propósito dessa API é tornar mais flexível e eficiente o processamento de coleções. A Listagem 5 mostra como criar uma stream ao invocar o método stream() a partir da interface java.util.Collection.
Nesse trecho de código, primeiramente uma lista de strings é definida e três objetos são adicionados a ela. Em seguida, uma stream de strings é obtida ao chamar o método items.stream(), na linha 7. Outra forma de criar streams é invocando o método parallelStream(), que possibilitará paralelizar o seu processamento.
Listagem 5. Criação de uma stream a partir de um List.
Listitems = new ArrayList (); items.add("um"); items.add("dois"); items.add("três"); Stream stream = items.stream();
O método stream() também foi adicionado à interface java.util.map. A Listagem 6 mostra um exemplo de como criar uma stream a partir dessa interface.
Listagem 6. Criação de Stream a partir de um Map
Mapmap = new HashMap (); map.put("key1", "abacate"); map.put("key2", "melancia"); map.put("key3", "manga"); Stream stream = map.values().stream();
Além disso, uma stream pode ser gerada a partir de I/O, arrays e valores. Para obter uma stream a partir de valores ou arrays é muito simples: basta chamar os métodos estáticos Stream.of() ou Arrays.stream(), como mostra o código a seguir:
StreamnumbersFromValues = Stream.of(1, 2, 3, 4, 5); IntStream numbersFromArray = Arrays.stream(new int[] {1, 2, 3, 4, 5});
Por sua vez, para criar uma stream de linhas a partir do conteúdo de um arquivo texto (I/O), pode-se chamar o método estático Files.lines(Path path). No código a seguir, por exemplo, é possível descobrir a quantidade de linhas que um arquivo possui:
Streamlines= Files.lines(Paths.get(“myFile.txt”), Charset.defaultCharset()); long numbersLines = lines.count();
Após conhecer alguns dos diferentes modos para criar e obter streams, o foco agora será em como processá-las. Para isso, será mostrado nos próximos tópicos a transformação e o processamento de streams fazendo uso de diferentes operações da interface Stream.
Para ilustrar cada uma dessas operações, primeiro criá-se a classe Pessoa com quatro atributos básicos: id, nome, nacionalidade e idade. A Listagem 7 mostra a implementação dessa classe, que traz, também, o método populaPessoas(), criado para preencher uma lista com alguns objetos.
Listagem 7. Implementação da classe Pessoa.
public class Pessoa { String id; String nome; String nacionalidade; int idade; //gets e sets omitidos public Pessoa(){} public Pessoa (String id, String nome, String nacionalidade, int idade){ this.id = id; this.nome = nome; this.nacionalidade = nacionalidade; this.idade = idade; } public ListpopulaPessoas(){ Pessoa pessoa1 = new Pessoa("p1" , "Matheus Henrique", "Brasil", 18); Pessoa pessoa2 = new Pessoa("p2" , "Hernandez Roja", "Mexico", 21); Pessoa pessoa3 = new Pessoa("p3" , "Mario Fernandes"¸"Canada", 22); Pessoa pessoa4 = new Pessoa("p4" , "Neymar Junior", "Brasil", 22); List list = new ArrayList (); list.add(pessoa1); list.add(pessoa2); list.add(pessoa3); list.add(pessoa4); return list; } @Override public String toString() { return this.nome; } }
Operações intermediárias
Algumas das operações intermediárias mais utilizadas são: filter(), map(), sorted(), limit() e distinct(). Portanto, os primeiros passos nessa nova API demonstrarão como utilizar essas operações.
Filter
O método filter() é usado para filtrar elementos de uma stream de acordo com uma condição (predicado). Para isso, ele recebe como parâmetro um objeto que implementa a interface Predicate<T> (interface funcional que define uma função com valor de retorno igual a um boolean) e retorna uma nova stream contendo apenas os elementos que satisfazem à condição.
O código a seguir mostra um exemplo de uso dessa operação. Primeiramente é criada uma lista com alguns objetos do tipo Pessoa. Em seguida, com a chamada ao método stream() é criada a stream. Logo após, o método filter() recebe como parâmetro uma condição, representada por uma expressão lambda, que tem por objetivo buscar todas as pessoas que nasceram no Brasil.
Listpessoas = new Pessoa().populaPessoas(); Stream stream = pessoas.stream().filter(pessoa -> pessoa.getNacionalidade().equals("Brasil");
Diante de algumas situações se faz necessário realizar transformações em uma lista de dados. O método map() permite realizar essas mudanças sem a necessidade de variáveis intermediárias, apenas utilizando como argumento uma função do tipo java.util.function.Function, que, assim como Predicate<T>, também é uma interface funcional. Essa função toma cada elemento de uma stream como parâmetro e retorna o elemento processado como resposta. O resultado será uma nova stream contendo os elementos mapeados a partir da stream original.
A Listagem 8 mostra um exemplo desse tipo de operação. Na linha 4, na nova stream obtida a partir da operação de filtragem, é realizado um mapeamento com o intuito de obter apenas a idade das pessoas presentes no fluxo de dados.
Listagem 8. Exemplo de uso do método map().
Listpessoas = new Pessoa().populaPessoas(); Stream stream = pessoas.stream(). filter(pessoa -> pessoa.getNacionalidade().equals("Brasil"). map(Pessoa::getIdade);
Porém, nesse trecho de código pode-se ter um problema com a utilização do método map(), haja vista que seu retorno é do tipo Stream<Integer>. Esse fato gera o boxing dos valores inteiros, isto é, a necessidade de converter o tipo primitivo retornado pelo método getIdade() em seu correspondente objeto wrapper. Sendo assim, ocorrerá um overhead indesejado, sobretudo quando se tratar de listas grandes.
Pensando nisso, a Streams API oferece implementações para os principais tipos primitivos, a saber: IntStream, DoubleStream e LongStream. Neste exemplo, portanto, pode-se usar o IntStream para evitar o autoboxing e chamar o método mapToInt() ao invés do map().
Outro ponto a observar nesse exemplo é a possibilidade de tirar proveito da sintaxe de method reference, simplificando ainda mais o código. Para verificar isso, note como, na linha 4, é passado o método getIdade() da classe Pessoa como parâmetro.
Nota: Method Reference é um novo recurso do Java 8 que permite fazer referência a um método ou construtor de uma classe (de forma funcional) e assim indicar que ele deve ser utilizado num ponto específico do código, deixando-o mais simples e legível. Para utilizá-lo, basta informar uma classe ou referência seguida do símbolo "::" e o nome do método sem os parênteses no final.
Sorted
A ordenação de elementos em coleções é uma tarefa recorrente no dia a dia de todo desenvolvedor. No Java 8, felizmente, isso foi bastante facilitado, eliminando a necessidade de implementar o verboso Comparator, assim como as classes internas anônimas, proporcionando ao código clareza e simplicidade. Para isso, a Streams API oferece a operação sorted(). Esse método retorna uma nova stream contendo os elementos da stream original ordenados de acordo com algum critério.
A Listagem 9 mostra um exemplo que faz uso desse método. Após a filtragem, já explicada, na linha 4 a ordenação é realizada utilizando o método comparing() da interface Comparator. Esse método recebe uma Function como parâmetro e devolve um valor chave que será utilizado na ordenação. Nesse caso, a classificação das informações é feita com base no nome da pessoa, utilizando a ordem natural (alfabética) definida na interface Comparator para classificar Strings.
Note que nesse caso também é possível tirar proveito da sintaxe de method reference, uma vez que é passado uma referência do método getNome() da classe Pessoa como parâmetro para a operação Comparator.comparing().
Listagem 9. Exemplo de uso do método sorted().
Listpessoas = new Pessoa().populaPessoas(); Stream stream = pessoas.stream(). filter(pessoa -> pessoa.getNacionalidade().equals("Brasil"). sorted(Comparator.comparing(Pessoa::getNome));
Distinct
A operação distinct() retorna uma stream contendo apenas elementos que são exclusivos, isto é, que não se repetem, de acordo com a implementação do método equals(). O código a seguir mostra um exemplo de uso:
Listpessoas = new Pessoa().populaPessoas(); Stream stream = pessoas.stream().distinct();
Limit
O método limit() é utilizado para limitar o número de elementos em uma stream. É uma operação conhecida como curto-circuito devido ao fato de não precisar processar todos os elementos. Como exemplo, o código a seguir demonstra como retornar uma stream com apenas os dois primeiros elementos:
Listpessoas = new Pessoa().populaPessoas(); Stream stream = pessoas.stream().limit(2);
Operações terminais
Esse tipo de operação pode ser identificada pelo tipo de retorno do método, uma vez que uma operação terminal nunca retorna uma interface Stream, mas sim um resultado (List, String, Long, Integer, etc.) ou void. A seguir, é visto em detalhes alguns dos métodos mais importantes e em quais situações eles podem ser empregados.
ForEach
Através do método forEach() é possível realizar um loop sobre todos os elementos de uma stream e executar algum tipo de processamento. No exemplo a seguir, o parâmetro que o método forEach() recebe é uma expressão lambda que invoca o método getNome() do objeto pessoa e imprime o seu retorno no console. Assim, serão exibidos os nomes de todas as pessoas presentes na coleção.
Listpessoas = new Pessoa().populaPessoas(); pessoas.stream().forEach(pessoa -> System.out.println(pessoa.getNome()));
Average
Com o objetivo de auxiliar sua utilização, as implementações de Stream para tipos primitivos (IntStream, DoubleStream e LongStream) oferecem vários métodos. Um deles é o average(), que permite calcular a média dos valores dos elementos. A Listagem 11 mostra um exemplo de uso dessa operação, no qual é calculada a média de idade de todas as pessoas que nasceram no Brasil.
Na linha 4, a operação mapToInt() é empregada para que se possa obter uma nova stream, composta apenas por valores inteiros. A partir dessa stream, a operação average() irá realizar o cálculo da média. Por fim, na linha 6, o método getAsDouble() converte o valor retornado por average() para o tipo numérico double.
A operação getAsDouble() é utilizada porque average() não retorna um valor numérico e sim um objeto da classe java.util.Optional, introduzida no Java 8. Essa classe permite que algumas situações excepcionais sejam tratadas de forma simples como, por exemplo, quando o número de elementos da stream for igual a zero. Nesse caso, o resultado da média será um positivo infinito (qualquer número dividido por 0 é igual a infinito) e certamente não é um valor que o desenvolvedor deseja manipular.
Nota: Observe que o método average() utiliza todos os elementos da stream para retornar um único valor. Operações desse tipo são conhecidas como operações de redução (reduction).
Listagem 11. Exemplo de uso do método average().
Listpessoas = new Pessoa().populaPessoas(); double media = pessoas.stream(). filter(pessoa -> pessoa.getNacionalidade().equals("Brasil"). mapToInt(pessoa -> pessoa.getIdade()). average(). getAsDouble();
Collect
O método collect() possibilita coletar os elementos de uma stream na forma de coleções, convertendo uma stream para os tipos List, Set ou Map. Um exemplo de uso dessa operação pode ser visto no trecho de código a seguir:
Listpessoas = new Pessoa().populaPessoas(); List pessoasComM = pessoas.stream().filter(pessoa -> pessoa.startsWith("M")).collect(Collectors.toList());
Note que após gerar a stream e aplicar um filtro sobre ela, é chamado o método collect(), o qual recebe como argumento Collector.toList(), que reuni o resultado da stream e os retorna na forma de uma lista.
Count
O método count() retorna a quantidade de elementos presentes em uma stream. Portanto, assim como average(), também é classificada como uma operação de redução (reduction). Como exemplo, o trecho de código a seguir mostra como obter o número de pessoas em uma lista cujo nome começa com a letra "N":
Listpessoas = new Pessoa().populaPessoas(); long qt = pessoas.stream().filter(pessoa -> pessoa.startsWith("N")).count());
AllMatch
Um padrão de processamento comum em aplicações consiste em verificar se os elementos de uma coleção correspondem a um determinado predicado, isto é, a uma característica (propriedades de um objeto).
Com esse objetivo, o método allMatch() verifica se todos os elementos de uma stream atendem a um critério passado como parâmetro, através de um Predicate, e retorna um valor booleano.
O exemplo a seguir apresenta essa situação. Nesse código, cada elemento da stream é submetido a uma condição, que nesse caso é verificar se a pessoa nasceu no México. Se todos os elementos obedecem a essa condição, será retornado true. Caso algum dos elementos não satisfaça ao predicado, será retornado false.
Listpessoas = new Pessoa().populaPessoas(); boolean todosMexicanos = pessoas.stream().allMatch(pessoa -> pessoa.getNacionalidade().equals("Mexico"));
Desenvolvendo uma aplicação
Para apresentar a Streams API na prática, será implementado uma pequena aplicação que manipulará dados referentes ao campeonato brasileiro de futebol do ano de 2016. O enfoque será dado às informações referentes aos jogadores, a saber: nome, posição em que atua, idade, time em que disputou o certame e o número de gols marcados.
Criando a aplicação
Com o ambiente de desenvolvimento instalado e o Java 8 configurado, crie um projeto Java do tipo Desktop.
A classe Jogador
Com a estrutura do projeto pronta, crie a classe Jogador que representará o domínio do sistema, ou seja, a entidade que a aplicação manipulará. Sua implementação pode ser vista através da Listagem 12.
Listagem 12. Implementação da classe Jogador.
public class Jogador { private String nome; private String posicao; private int idade; private String timeAtual; private int golsMarcados; //métodos gets e sets omitidos... @Override public String toString() { return this.nome + " " + this.posicao + " " + this.getTimeAtual(); } }
Nessa classe estão os atributos do jogador e seus respectivos métodos de acesso. Além disso, tem o método sobrescrito toString(), que retorna uma representação mais informativa do objeto.
Classe com as operações a serem disponibilizadas
O passo seguinte é implementar a classe JogadorImpl com as operações que serão realizadas sobre o cadastro. O código desta classe é visto na Listagem 13.
Para simplificar o exemplo, as informações referentes aos jogadores serão obtidas de um arquivo texto, apresentado na Figura 2. Seu formato respeita a seguinte disposição: NomeJogador,Posição,Idade,TimeAtual,GolsMarcados.
Figura 2 - Conteúdo do arquivo jogadores.txt.
A primeira operação, declarada na linha 7, tem o objetivo de verificar se o arquivo jogadores.txt existe, e para isso, será utilizado recursos da Streams API.
No Java 8, a classe java.nio.file.Files sofreu algumas alterações para possibilitar trabalhar com streams. Uma das operações adicionadas foi a Files.list(), chamado na linha 10, que retorna todos os arquivos de uma pasta através de um Stream<Path>. Nesse caso, cada elemento dessa stream representará um objeto do tipo Path, possibilitando assim manipular o conteúdo do sistema de arquivos.
Com a stream em mãos, pode ser aplicado um filtro para verificar se o nome de algum dos arquivos da pasta equivale ao valor passado como parâmetro, o que é feito na linha 11, com o predicado p -> p.toString().endsWith("jogadores.txt"). Por sua vez, o método findAny() checa se alguma ocorrência da característica representada pelo predicado está presente na nova stream.
Dando sequência ao processamento, na linha 19 é declarado o método getListaDeJogadores(), que lê os dados do arquivo e constrói uma lista com todos os jogadores. Essa lista será recebida pelos demais métodos como parâmetro e servirá de fonte de dados para a criação das streams. Primeiramente, na linha 20, a operação Files.lines() cria uma stream com todas as linhas do arquivo informado. Como esse método considera o encoding UTF-8 como padrão e o arquivo que utilizamos está com o tipo ISO-8859-1, existe a necessidade de informar ao método o encoding correto.
A partir dessa stream é possível obter uma lista com todas as linhas do arquivo, onde cada elemento da lista representa uma linha. Para isso, o método collect() deve ser chamado, como mostra a linha 21. Então, a lista será percorrida para obter as características (atributos) de cada atleta e criar objetos do tipo Jogador. Como visto na Figura 2, cada linha contém as informações referentes a um jogador separadas por vírgula.
Obtida a lista de jogadores, que representa a fonte de dados, o foco será na construção de métodos que ilustram o processamento de dados com streams.
Listagem 13. Implementação da classe JogadorImpl.
//imports omitidos public class JogadorImpl { public boolean verificarArquivoExiste(Path path) { boolean ret = false; try { Stream < Path > stream = Files.list(path); Optional < Path > arq = stream.filter(p -> p.toString().endsWith("jogadores.txt")).findAny(); ret = arq.isPresent(); } catch (IOException ex) { ex.printStackTrace(); } return ret; } public List < Jogador > getListaDeJogadores(Path path) throws IOException { Stream < String > linhas = Files.lines(path, StandardCharsets.ISO_8859_1); List < String > listaDeLinhas = linhas.collect(Collectors.toList()); List < Jogador > listaDeJogadores = new ArrayList < > (); Jogador jogador; Iterator it = listaDeLinhas.listIterator(); String str = null; while (it.hasNext()) { str = (String) it.next(); String info[] = str.split(","); jogador = new Jogador(); jogador.setNome(info[0]); jogador.setPosicao(info[1]); jogador.setIdade(Integer.parseInt(info[2])); jogador.setTimeAtual(info[3]); jogador.setGolsMarcados(Integer.parseInt(info[4])); listaDeJogadores.add(jogador); } return listaDeJogadores; } public void imprimirJogadores(List < Jogador > jogadores) { jogadores.stream().forEach(System.out::println); } public void imprimirJogadoresTime(List < Jogador > jogadores, String time) { jogadores.stream().filter(jogador -> jogador.getTimeAtual().equals(time)).forEach(System.out::println); } public void imprimirJogadoresTimeGols(List < Jogador > jogadores, String time) { jogadores.stream().filter(jogador -> jogador.getTimeAtual().equals(time) && jogador.getGolsMarcados() > 10).forEach(System.out::println); } public void agruparJogadoresPorTime(List < Jogador > jogadores) { jogadores.stream().sorted(Comparator.comparing(Jogador::getTimeAtual)).forEach(System.out::println); } public double calcularMediaIdade(List < Jogador > jogadores) { return jogadores.stream().mapToInt(Jogador::getIdade).average().getAsDouble(); } public void imprimirJogadorMaisVelho(List < Jogador > jogadores) { Jogador jogador = jogadores.stream().max(Comparator.comparingInt(Jogador::getIdade)).get(); System.out.println("Jogador mais velho: " + jogador.getNome()); } public void imprimirJogadorMaisNovo(List < Jogador > jogadores) { Jogador jogador = jogadores.stream().min(Comparator.comparingInt(Jogador::getIdade)).get(); // .min((p1, p2) -> Integer.compare(p1.getAge(), p2.getAge())) System.out.println("Jogador mais novo: " + jogador.getNome()); } public void imprimirJogadorArtilheiro(List < Jogador > jogadores) { Jogador jogador = jogadores.stream().max(Comparator.comparingInt(Jogador::getGolsMarcados)).get(); System.out.println("Jogador Artilheiro: " + jogador.getNome()); } public int imprimirSomatorioGols(List < Jogador > jogadores) { int soma = jogadores.stream().mapToInt(jogador -> jogador.getGolsMarcados()).sum(); return soma; } public void agruparJogadoresPeloTime(List < Jogador > jogadores) { Map < String, List < Jogador >> groupByTime = jogadores.stream().collect( Collectors.groupingBy(Jogador::getTimeAtual)); System.out.println(groupByTime); } public void ordenarJogadoresGols(List < Jogador > jogadores) { jogadores.stream().sorted(Comparator.comparingInt(Jogador::getGolsMarcados).reversed()).forEach(System.out::println); } }
O método imprimirJogadores()
O primeiro desses métodos é o imprimirJogadores(), declarado na linha 43 que cria uma stream e imprime todos os elementos utilizando a operação forEach(), que recebe como parâmetro o método println(). Dessa forma, o compilador sabe que ao iterar internamente pela stream, a cada passo sempre um elemento do tipo Jogador será obtido, pois inicialmente foi criada uma stream do tipo Stream<Jogador>. A passagem do método println() indica a ação, ou seja, o que será feito sobre cada um dos elementos da lista.
O método imprimirJogadoresTime()
Na linha 47, o método imprimirJogadoresTime() recebe como parâmetro, além da lista de jogadores, o nome de um time, e imprime apenas os jogadores associados a ele. Para isso, o filtro realizado sobre a stream inicial permite recuperar apenas os jogadores do time desejado, graças à condição indicada pela expressão lambda jogador -> jogador.getTimeAtual().equals(time), passada como parâmetro para o método filter(). A partir da nova stream é realizada uma iteração sobre os elementos com o objetivo de imprimir uma representação textual de cada objeto.
Com proposta semelhante, o método imprimirJogadoresTimeGol(), declarado na linha 51, imprime os nomes dos jogadores de um dado time, porém, considerando somente aqueles que marcaram mais de dez gols.
O método calcularMediaIdade()
Na linha 60, o método calcularMediaIdade() calcula a média de idade de todos os jogadores cadastrados. Com isso em mente, no fluxo de processamento da linha 61, primeiramente é criada uma stream a partir da lista de jogadores. Em seguida, é chamado o método mapToInt(), que produz uma stream de inteiros cujos elementos correspondem à idade dos atletas. Por fim, é invocado o método average(). Como essa operação não retorna um valor numérico, e sim um objeto da classe Optional, é preciso chamar o método getAsDouble() para recuperar o resultado.
O método imprimirJogadorMaisVelho()
Outro método implementado foi o imprimirJogadorMaisVelho() - veja a linha 64. Para alcançar esse requisito é utilizado a operação max(), que para saber o que avaliar recebe Comparator.comparingInt() como parâmetro. Esse método, por sua vez, aceita como parâmetro uma função, Jogador::getIdade, que extrai um valor chave, nesse caso a idade dos jogadores, e o utiliza como critério de classificação para comparar os elementos da stream.
Aproveitando essa lógica, foi criado o método imprimirJogadorMaisNovo() - veja a linha 69. A diferença em relação ao método anterior está na substituição do método max() pelo min(), para retornar o menor valor.
O método imprimirSomatorioGols()
Para calcular a quantidade de gols marcados, é construído o método imprimirSomatorioGols(), iniciado na linha 80. Com esse objetivo, após obter a stream inicial, é realizada uma chamada a mapInt() para produzir uma nova stream contendo apenas os valores inteiros que representam os gols. Em seguida, é chamado o método sum() para contabilizar os valores e retornar o resultado.
O método agruparJogadoresPeloTime()
Outra operação implementada pode ser verificada a partir da linha 85. Trata-se do método agruparJogadoresPeloTime() cuja função é agrupar os jogadores levando em conta o time em que eles atuam. Isso é feito utilizando Collector.groupingBy(), factory de Collectors que agrupa os elementos de uma stream de acordo com uma função classificadora. Nesse caso a função empregada foi Jogador::getTimeAtual(). O retorno é uma instância da interface Map.
O método ordenarJogadoresGols()
Por fim, a partir da linha 91 pode ser visto o método ordenarJogadoresGols(), que organiza a stream adotando como critério o número de gols marcados, em ordem decrescente (observe o método reverse()). Para tanto, da stream inicial é chamado o método sorted(), que recebe um Comparator com o critério de classificação para realizar a operação.
Codificando a classe cliente
Neste momento, é criada a classe Principal para executar e testar os vários métodos que foram implementados. A Listagem 14 mostra a implementação desta classe.
Listagem 14. Implementação da classe Principal.
//imports omitidos public class Principal { public static void main(String[] args) { Principal p = new Principal(); JogadorImpl jogImpl = new JogadorImpl(); String enderecoDir = "C:\\Users\\carlosalberto\\Desktop\\stream"; String nomeArquivo = "jogadores.txt"; ListlistaDeJogadores = jogImpl.getListaDeJogadores(Paths.get(enderecoDir + "\\" + nomeArquivo)); if (!jogImpl.verificarArquivoExiste(Paths.get(enderecoDir))){ System.out.println("Arquivo não encontrado"); } else { jogImpl.imprimirJogadorArtilheiro(listaDeJogadores); jogImpl.imprimirJogadorMaisVelho(listaDeJogadores); jogImpl.imprimirJogadorMaisNovo(listaDeJogadores); } } }
No método main(), linhas 9 e 10, é definido, respectivamente, o diretório onde o arquivo com os dados se encontra e o nome desse arquivo. Em seguida, após recuperar a lista de jogadores, alguns métodos da classe JogadorImpl são invocados, a saber: imprimirJogadorArtilheiro(), imprimirJogadorMaisVelho() e imprimirJogadorMaisNovo(). A Figura 3 mostra o resultado da execução dessa classe.
Figura 3 - Resultado da execução da classe Principal.
Nessa aplicação foi utilizada apenas uma pequena amostra da grande quantidade de informações que é produzida ao longo de um campeonato. Caso a situação fosse diferente, mais processamento seria necessário, levando o desenvolvedor a se preocupar com fatores relacionados à performance. Nesse momento, uma boa opção é o emprego do paralelismo, lançando mão dos múltiplos núcleos que os processadores modernos oferecem.
Pensando nisso, a Streams API também foi projetada para tirar proveito do processamento paralelo. Assim, de forma simples, basta o desenvolvedor, ao invés de criar uma stream por meio da chamada ao método stream(), solicitar a criação da mesma por meio da chamada ao método parallelStream(). Isso possibilitará executar as operações de forma concorrente.
Apesar dessa facilidade, no entanto, é preciso ter cautela e não começar a solucionar todos os problemas de forma paralela, pois isso tem um custo (overhead), decorrente do processamento de tarefas adicionais geradas pela paralelização. Portanto, se não houver uma grande quantidade de elementos a serem manipulados, o overhead do processamento natural do paralelismo provavelmente não irá compensar sua adoção.
Note que essa nova forma de escrever código é bem diferente da maneira de processar coleções tradicional, sobretudo devido à incorporação de conceitos oriundos do paradigma funcional, trazendo facilidade em tarefas antes complexas e possibilitando um número menor de linhas de código. Além disso, algumas técnicas ajudam a otimizar o processamento dos dados, como o "processamento preguiçoso", que faz com que a execução das operações seja realizada apenas quando existir a real necessidade de se obter o resultado, e as operações de curto-circuito (short-circuiting), que encurtam o processamento da stream ao trabalhar apenas com a parte necessária para obtenção do resultado.
Por fim, saiba que a introdução de streams na API do Java é um grande avanço e dominar essa nova solução é tão importante quanto conhecer e dominar os conceitos por trás da infraestrutura de Collections.
Referências
- Processamento de dados com streams do Java SE 8 - Parte 1
- Java 8 Stream Tutorial
- Novidades do Java 8: Lambda Expressions
- Documentação da Streams API - Package java.util.stream
- Java 8 Prático: Lambdas, Streams e os novos recursos da linguagem
Artigo originalmente publicado em Oracle Technology Network.
Carlos Alberto Silva (casilvamg@hotmail.com) é Formado em Ciência da Computação pelo Universidade Federal de Uberlândia (UFU), com especialização em Desenvolvimento Java pelo Centro Universitário do Triângulo (UNITRI). e em Análise e Desenvolvimento de Sistemas Aplicados a Gestão Empresarial no Instituto Federal do Triângulo Mineiro (IFTM). Trabalha na empresa Algar Telecom como Analista de Sistemas. Possui as seguintes certificações: OCJP, OCWCD e ITIL.