Processamento de dados com streams do Java SE 8 - Parte 1
Por Raoul-Gabriel Urma,
Uso de operações de streams para expressar consultas de processamento de dados complexas
O que faríamos sem as coleções? Quase todas as aplicações do Java criam e processam coleções. Elas são essenciais para muitas tarefas de programação: permitem agrupar e processar dados. Por exemplo, o desenvolvedor poderia querer criar uma coleção de transações bancárias para representar o extrato de um cliente. Depois, talvez ele queira processar toda a coleção para saber quanto dinheiro o cliente gastou. Apesar de sua importância, o processamento de coleções em Java está longe de ser perfeito.
Em primeiro lugar, os padrões de processamento de coleções típicos são similares às operações usadas no SQL para "pesquisar" (por exemplo, pesquisar a transação de maior valor) ou "agrupar" (por exemplo, agrupar todas as transações relacionadas com compras de supermercado). A maioria dos bancos de dados permitem estabelecer operações como essas de forma declarativa. Por exemplo, a consulta de SQL abaixo permite pesquisar a identificação da transação de maior valor: "SELECT id, MAX(value) from transactions".
Como podemos observar, não é necessário programar como calcular o valor máximo (por exemplo, mediante laços e uma variável para acompanhar o valor mais elevado). Só expressamos o que esperamos. Assim, você precisa se preocupar menos com como codificar explicitamente as consultas; a linguagem vai fazer isso por você. Por que não é possível fazer o mesmo com as coleções? Quem não codificou alguma vez essas operações com laços uma e outra vez?
Em segundo lugar, como podemos fazer para processar coleções realmente grandes de maneira eficiente? Idealmente, para acelerar o processamento, é conveniente trabalhar com arquiteturas de núcleos múltiplos. Apesar disso, programar código paralelo é uma tarefa difícil e sujeita a erros.
Apresentamos uma ideia extraordinária: Essas duas operações podem gerar elementos "infinitamente".
E tudo isso por conta do Java SE 8. Os projetistas da interface API do Java incorporaram na atualização uma nova abstração chamada Stream, que permite processar dados de forma declarativa. Ainda mais, as streams permitem aproveitar as arquiteturas de núcleos múltiplos sem ter que programar linhas de código multiprocesso. Parece bom, não é? Isso é o vamos explorar nesta série de artigos.
Antes de entrar em detalhes sobre o que é possível fazer com streams, vamos ver um exemplo para conhecer o novo estilo de programação permitido pelas streams do Java SE 8. Vamos imaginar que precisamos encontrar todas as transações do tipo grocery e retornar uma lista de identificações de transações classificadas em ordem decrescente segundo o valor de transação. No Java SE 7 usaríamos o código da Listagem 1. No Java SE 8 usaremos o código da Listagem 2.
List<Transaction> groceryTransactions = new Arraylist<>();
for(Transaction t: transactions){
if(t.getType() == Transaction.GROCERY){
groceryTransactions.add(t);
}
}
Collections.sort(groceryTransactions, new Comparator(){
public int compare(Transaction t1, Transaction t2){
return t2.getValue().compareTo(t1.getValue());
}
});
List<Integer> transactionIds = new ArrayList<>();
for(Transaction t: groceryTransactions){
transactionsIds.add(t.getId());
}
Listagem 1
List<Integer> transactionsIds = transactions.stream()
.filter(t -> t.getType() == Transaction.GROCERY)
.sorted(comparing(Transaction::getValue).reversed())
.map(Transaction::getId)
.collect(toList());
Listagem 2
A Figura 1 mostra um exemplo do código do Java SE. Em primeiro lugar, obtemos uma stream da lista de transações (dados) com o método stream() disponível para List. Em seguida, várias operações (filter, sorted, map, collect) são encadeadas em um ///processo, que pode se ver como uma consulta dos dados.
Figura 1
E como se obtém o código paralelo? No Java SE 8 é fácil: só devemos trocar o comando stream() por parallel Stream(), como mostrado na Listagem 3, e a API de streams irá desagregar internamente a consulta para aproveitar os núcleos múltiplos da computador.
List<Integer> transactionsIds = transactions.parallelStream()
.filter(t -> t.getType() == Transaction.GROCERY)
.sorted(comparing(Transaction::getValue).reversed())
.map(Transaction::getId)
.collect(toList());
Listagem 3
A aparente complexidade do código não deve ser uma preocupação. Nas próximas seções, vamos ver em detalhes como funciona. Mesmo assim, cabe salientar o uso de expressões lambda (como t-> t.getCategory() == Transaction.GROCERY) e referências a métodos (por exemplo, Transaction::getId), com os quais o desenvolvedor certamente já esteja familiarizado. (Para lembrar como se usam as expressões lambda, recomendamos a leitura de artigos anteriores de Java Magazine e outros recursos elencados no final deste artigo.)
Por enquanto, podemos entender uma stream como uma abstração para expressar operações eficientes do estilo SQL em relação a uma coleção de dados. Além disso, essas operações podem ser parametrizadas sucintamente mediante expressões lambda.
Tendo lido a série completa de artigos sobre streams do Java SE 8, o desenvolvedor saberá usar a API de streams para programar código similar ao da Listagem 3 a fim de expressar consultas robustas.
Programação com streams: primeiros passos Vamos começar com um pouco de teoria. Qual a definição de stream? De forma sucinta, podemos dizer que é uma "sequência de elementos de uma fonte de dados que suporta operações de agregação". Vamos dividir a definição:
- Sequência de elementos: Uma stream oferece uma interface para um conjunto de valores sequenciais de um tipo de elemento particular. Apesar disso, as streams não armazenam elementos; estes são calculados sob demanda.
- Fonte de dados: As streams tomam seu insumo de uma fonte de dados, como coleções, matrizes ou recursos de E/S.
- Operações de agregação: As streams suportam operações do tipo SQL e operações comuns à maioria das linguagens de programação funcionais, como filter, map, reduce, find, match e sorted, entre outras.
Ainda mais, as operações das streams têm duas características fundamentais que as diferenciam das operações com coleções:
- Estrutura de processo: Muitas operações de stream retornam outra stream. Assim, é possível encadear operações para formar um processo mais abrangente. Isto, por sua vez, permite algumas otimizações, por exemplo mediante as noções de "preguiça" (laziness) e "corte de circuitos" (short-circuiting), que analisaremos mais a frente.
- Iteração interna: Diferentemente do trabalho com coleções, em que a iteração é explícita (iteração externa), as operações da stream realizam uma iteração por trás dos bastidores.
Figura 2
Em primeiro lugar, vamos obter uma stream da lista de transações chamando o método stream(). A fonte de dados é a lista de transações, que vai fornecer uma listagem de elementos à stream. A seguir, vamos aplicar uma série de operações agregadas à stream: filter (para filtrar elementos segundo um predicado particular), sorted (para ordenar os elementos segundo um comparador) e map (para obter informação). Todas as operações, com exceção de collect, retornam uma Stream, portanto, é possível encadeá-las e formar um processo, que podemos ver como consulta dos dados da fonte.
Na verdade, nenhuma tarefa é realizada até se chamar a operação collect. Esta última começará a abordar o processo para retornar um resultado (que não será uma Stream; neste caso, trata-se de uma lista List). Por enquanto, vamos esquecer de collect; essa operação será analisada em detalhes em outro artigo. Enquanto isso, podemos entendê-la como uma operação que como argumento usa diversas receitas para acumular os elementos de uma stream em um resultado resumido. Aqui, toList() descreve uma receita para transformar uma Stream em uma List.
Antes de analisar os diferentes métodos disponíveis para uma stream, é conveniente fazer uma pausa e refletir sobre a diferença conceitual entre uma stream e uma coleção.
Streams x coleções A noção de coleções já existente em Java e a nova noção de streams se referem a interfaces com sequências de elementos. Então, qual a diferença? Resumindo, as coleções se referem a dados enquanto as streams se referem a cálculos.
Vamos pensar em um filme armazenado em um DVD. Trata-se de uma coleção (de bytes ou fotogramas, isto não é importante para nosso exemplo) pois contém toda a estrutura de dados. Agora vamos imaginar o mesmo vídeo, mas, desta vez, reproduzimos na Internet. Neste caso, falamos de uma stream (de bytes ou fotogramas). O reprodutor de vídeo por sequências (streaming) precisa descarregar apenas uns poucos fotogramas além daqueles que o usuário está assistindo; assim, é possível começar a mostrar os valores do início da stream antes de que a maior parte da stream tenha sido calculada (a transmissão de sequências ou streaming pode ser pensada como um jogo de futebol ao vivo).
Em palavras mais simples, a diferença entre coleções e streams tem a ver com quando os cálculos são feitos. As coleções são estruturas de dados armazenados na memória, onde estão todos os valores que a estrutura de dados tem em um momento determinado; cada elemento da coleção deve ser calculado antes de se agregar à coleção. Já as streams são estruturas de dados fixas, cujos elementos são calculados sob demanda.
Quando a interface Collection é usada, o usuário deve se ocupar da iteração (por exemplo, mediante foreach, laço for melhorado); essa abordagem é chamada de iteração externa.
Em contrapartida, a biblioteca Streams recorre à iteração interna; ela se ocupa da iteração e de armazenar em algum lugar o valor da stream resultante; o usuário só fornece uma função dizendo o que deve ser feito. No código da Listagem 4 (iteração externa com uma coleção) e da Listagem 5 (iteração interna com uma stream) vemos essa diferença.
List<String> transactionIds = new ArrayList<>();
for(Transaction t: transactions){
transactionIds.add(t.getId());
}
Listagem 4
List<Integer> transactionIds = transactions.stream()
.map(Transaction::getId)
.collect(toList());
Listagem 5
Na Listagem 4, geramos a iteração explícita da lista de transações sequencialmente para extrair a identificação de cada transação e agregá-la ao acumulador. Já quando usamos uma stream, não há iteração explícita. Com o código da Listagem 5 criamos uma consulta na qual se parametrizou a operação map para extrair as identificações de transações, e a operação collect transforma a Stream resultante em uma lista List.
Nesse ponto, o desenvolvedor certamente já faz uma ideia do que são as streams e para que pode usá-las. Observemos agora as diferentes operações que as streams suportam para expressar as próprias consultas de processamento de dados.
Operações de streams: Como aproveitar as streams para processar dados A interface Stream de java.util .stream.Stream define várias operações que podem ser reunidas em duas categorias. No exemplo da Figura 1 identificamos as seguintes operações:
- filter, sorted e map, que podem se conectar para formar um processo;
- collect, que encerra o processo e retorna um resultado.
As operações de streams que podem se conectar entre si são chamadas de operações intermediárias. Elas podem se conectar porque a saída retornada é de tipo Stream. As operações que encerram um processo de stream são chamadas de operações terminais. A partir de um processo, elas produzem um resultado de tipo List, Integer ou até void (de tipos distintos de Stream).
Por que essa distinção é importante? Pois as operações intermediárias não realizam tarefas de processamento até uma operação terminal ser chamada no processo de stream; são "preguiçosas". Isso é porque, frequentemente, a operação terminal pode "fundir" e processar diversas operações intermediárias em uma única ação.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);
List<Integer> twoEvenSquares = numbers.stream()
.filter(n -> {
System.out.println("filtering " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("mapping " + n);
return n * n;
})
.limit(2)
.collect(toList());
Listagem 6
Por exemplo, vamos tomar o código da Listagem 6, que calcula duas potências pares a partir de uma lista de números. Talvez seja surpreendente que ela mostre o seguinte:
filtering 1
filtering 2
mapping 2
filtering 3
filtering 4
mapping 4
Isso é porque em limit(2) o circuito é encurtado; só é necessário processar parte da stream, e não toda, para retornar um resultado. Situação semelhante se apresenta ao avaliar uma expressão booleana extensa encadeada com o operador and: assim que a expressão retorna o valor false, é possível deduzir que toda a expressão é false sem avaliá-la completamente. Neste caso, limit retorna uma stream de tamanho 2.
A API de streams vai desagregar internamente a consulta para aproveitar os núcleos múltiplos do computador.
Além disso, as operações filter e map foram reunidas em uma única ação.
Resumindo o que aprendemos até agora, quando usamos streams, geralmente trabalhamos com três elementos:
- fonte de dados (p. ex., uma coleção) sobre a qual vai se fazer uma consulta;
- cadeia de operações intermediárias, que formam um processo;
- operação terminal, que executa o processo da stream e produz um resultado.
Agora, vamos ver algumas operações que podem ser usadas com streams. A lista completa pode ser consultada na interface java.util .stream.Stream ou outros exemplos nos recursos elencados no final deste artigo.
Filtragem. Diversas operações podem ser usadas para filtrar elementos de uma stream:
- filter(Predicate): Toma um predicado (java.util.function.Predicate) como argumento e retorna uma stream incluindo todos os elementos que coincidem com o predicado indicado.
- distinct: Retorna uma stream com elementos únicos (dependendo da implementação de equals para um elemento da stream).
- limit(n): Retorna uma stream cuja longitude máxima é n.
- skip(n): Retorna uma stream em que se descartaram os primeiros n números.
Pesquisas e identificação de coincidências. Um padrão comum no processamento de dados é determinar se alguns elementos se ajustam a uma propriedade determinada. É possível usar as operações anyMatch, allMatch e noneMatch para esse fim. Todas elas tomam como argumento um predicado e retornam um valor boolean (isto é, são operações terminais). Por exemplo, podemos usar allMatch para verificar que todos os elementos de uma stream de transações tenham valores superiores a 100, como mostrado na Listagem 7.
boolean expensive = transactions.stream()
.allMatch(t -> t.getValue() > 100);
Listagem 7
A interface de Stream também inclui operações como findFirst e findAny para recuperar elementos arbitrários de uma stream. Podem ser usadas juntamente com outras operações de stream, como filter. Tanto findFirst como findAny retornam um objeto Optional, como mostrado na Listagem 8.
Optional<Transaction> = transactions.stream()
.filter(t -> t.getType() == Transaction.GROCERY)
.findAny();
Listagem 8
Optional<T> (java.util .Optional) é uma classe invólucro que representa a existência ou ausência de um valor. Na Listagem 8, pode acontecer que findAny não encontre nenhuma transação do tipo grocery. A classe Optional contém diversos métodos para comprovar a existência de um elemento. Por exemplo, se uma transação está presente, podemos optar por aplicar uma operação sobre o objeto opcional usando o método ifPresent, como mostrado na Listagem 9 (onde acabamos de mostrar a transação).
transactions.stream()
.filter(t -> t.getType() == Transaction.GROCERY)
.findAny()
.ifPresent(System.out::println);
Listagem 9
Mapeamento. As streams suportam o método map, que usa uma função (java.util.function.Function) como argumento para projetar os elementos da stream em outro formato. A função é aplicada a cada elemento, que é "mapeado" ou associado a um novo elemento.
Por exemplo, poderia ser necessário usá-la para obter informações de cada elemento de uma stream. No exemplo da Listagem 10, retorna-se uma lista com longitudes de todas as palavras de uma lista.
Redução. As operações terminais que vimos até agora retornam objetos boolean (allMatch e similares), void (forEach) ou Optional (findAny e similares). Também usamos collect para combinar todo o conjunto de elementos de uma Stream em um objeto List.
List<String> words = Arrays.asList("Oracle", "Java", "Magazine");
List<Integer> wordLengths = words.stream()
.map(String::length)
.collect(toList());
Listagem 10
Outra possibilidade é combinar todos os elementos de uma stream para formular consultas de processos mais complexas, como "qual a transação com a identificação mais alta?" ou "calcular a soma dos valores de todas as transações". Para isso, podemos usar a operação reduce com streams; esta operação aplica reiteradamente uma operação (por exemplo, a soma de dois números) a cada elemento até gerar um resultado. No âmbito da programação funcional costuma ser chamada de operação fold porque é relacionada a ação de dobrar repetidamente um extenso papel (a stream) até restar um pequeno quadrado, o resultado da operação de dobrado.
É útil pensar primeiro como poderíamos calcular a soma dos elementos de uma lista com um laço for:
int sum = 0;
for (int x : numbers) {
sum += x;
}
Cada elemento da lista de números é combinado de maneira iterativa usando o operador de soma para gerar um resultado. Essencialmente, "reduzimos" a lista de números a um único número. O código inclui dois parâmetros: o valor inicial da variável sum, neste caso 0, e a operação que combina todos os elementos da lista, neste caso +.
Usando o método reduce com um stream, podemos somar todos os elementos de uma stream, como mostrado na Listagem 11. O método reduce inclui dois argumentos:
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
Listagem 11
- um valor inicial, aqui 0;
- um BinaryOperator<T> para combinar dois elementos e gerar um novo valor.
Essencialmente, o método reduce extrai o padrão que é repetidamente aplicado. Outras consultas, como “calcular o produto” ou “calcular o máximo” (ver Listagem 12) são casos especiais de uso do método reduce.
int product = numbers.stream().reduce(1, (a, b) -> a * b);
int product = numbers.stream().reduce(1, Integer::max);
Listagem 12
Streams numéricas Já vimos como usar o método reduce para calcular a soma de uma stream de números inteiros. Mesmo assim, essa abordagem tem uma desvantagem: muitas operações de boxing são realizadas para somar repetidamente objetos Integer. Não seria melhor poder chamar um método sum, como mostrado na Listagem 13, para ser mais explícitos sobre a intenção com que concebemos o nosso código?
int statement = transactions.stream()
.map(Transaction::getValue)
.sum(); // error since Stream has no sum method
Listagem 13
O Java SE 8 incorpora três interfaces que transformam streams primitivos em especiais para abordar esse problema: IntStream, DoubleStream e LongStream; cada uma delas transforma os elementos de uma stream de maneira especial para serem de tipo int, double ou long, respectivamente.
Os métodos mais frequentes para converter uma stream em uma versão especial são mapToInt, mapToDouble e mapToLong. Estes métodos funcionam da mesma forma que o método map que vimos acima, mas eles retornam uma stream especial ao invés de uma Stream<T>. Por exemplo, poderíamos melhorar o código da Listagem 13, como vemos na Listagem 14. Também é possível converter uma stream primitiva em uma stream de objetos mediante a operação boxed.int statementSum = transactions.stream()
.mapToInt(Transaction::getValue)
.sum(); // works!
Listagem 14
Por último, outro formato útil de streams numéricas é a de intervalos numéricos. Por exemplo, poderíamos querer obter todos os números entre 1 e 100. O Java SE 8 incorpora dois métodos estáticos para IntStream, DoubleStream e LongStream que ajudam a gerar esses intervalos: range e rangeClosed.
Os dois métodos tomam o valor inicial do intervalo como primeiro parâmetro e o valor final como segundo parâmetro. Porém, range é exclusivo, enquanto rangeClosed é inclusivo. A Listagem 15 é um exemplo do uso de rangeClosed para retornar uma stream de todos os números ímpares entre 10 e 30.
IntStream oddNumbers = IntStream.rangeClosed(10, 30)
.filter(n -> n % 2 == 1);
Listagem 15
Criando streams Existem diversas formas de criar streams. Vimos como obter uma stream a partir de uma coleção. Ainda mais, trabalhamos com streams numéricas. Também podemos criar streams a partir de valores, matrizes ou arquivos. Até é possível criar uma stream a partir de uma função para gerar streams infinitas.
Diferentemente do trabalho com coleções, em que a iteração é explícita (iteração externa), as operações da stream realizam uma iteração por trás dos bastidores.
Criar um stream a partir de valores ou de uma matriz é muito simples: só temos que usar os métodos estáticos Stream .of para valores e Arrays.stream para uma matriz, como vemos na Listagem 16.
Stream<Integer> numbersFromValues = Stream.of(1, 2, 3, 4);
int[] numbers = {1, 2, 3, 4};
IntStream numbersFromArray = Arrays.stream(numbers);
Listagem 16
Também podemos converter um arquivo em uma stream de linhas com o método estático Files.lines. Por exemplo, na Listagem 17 é contada a quantidade de linhas de um arquivo.
long numberOfLines = Files.lines(Paths.get(“yourFile.txt”), Charset.defaultCharset()) .count();
Listagem 17
Streams infinitas. Por último, antes de finalizar este primeiro artigo sobre streams, apresentamos uma possibilidade extraordinária. Nessa altura, com certeza, você á compreendeu que os elementos de uma stream são gerados sob demanda. Há dois métodos estáticos —Stream.iterate e Stream .generate— que permitem criar uma stream a partir de uma função. Porém, como esses elementos são calculados sob demanda, as duas operações podem gerar elementos "infinitamente". Chamamos isso de stream infinita: uma stream que não tem um tamanho fixo, diferente do que acontece quando a stream é criada a partir de uma coleção fixa.
A Listagem 18 é um exemplo do uso de iterate para criar uma stream de todos os múltiplos de 10. O método iterate tem um valor inicial (aqui, 0) e uma expressão lambda (de tipo UnaryOperator<T>) para a aplicação sucessiva a cada novo valor gerado.
Stream<Integer> numbers = Stream.iterate(0, n -> n + 10);
Listagem 18
É possível converter uma stream infinita em uma stream de tamanho fixo mediante a operação limit. Por exemplo, poderíamos limitar o tamanho da stream a 5, como vemos na Listagem 19.
numbers.limit(5).forEach(System.out::println); // 0, 10, 20, 30, 40
Listagem 19
Conclusão O Java SE 8 incorporou a API de streams, que permite expressar consultas sofisticadas de processamento de dados. Neste artigo, vimos que as streams suportam diversas operações, como filter, map, reduce e iterate que podem ser combinadas para gerar consultas de processamento de dados resumidas e expressivas. Esta nova forma de programar é muito diferente do modo em que as coleções eram processadas antes do Java SE 8, mas oferece muitas vantagens. Em primeiro lugar, a API de streams aproveita diversas técnicas, como a "preguiça" e o "corte de circuitos" para otimizar as consultas de processamento de dados. Em segundo lugar, as streams podem ser usadas automaticamente em paralelo para aproveitar as arquiteturas de núcleos múltiplos. No próximo artigo desta série, vamos explorar operações mais avançadas, como flatMap e collect.
Até já!
Raoul-Gabriel Urma é doutorando em Ciências da Computação na Universidade de Cambridge, onde desenvolve sua pesquisa em linguagens de programação. Ele é também coautor de Java 8 in Action: Lambdas, Streams, and Functional-style Programming (Manning, 2014).
Este artigo foi revisto pela equipe de produtos Oracle e está em conformidade com as normas e práticas para o uso de produtos Oracle.