Stream API e Funções Lambda no Java 8

Rubens Yamasaki
Blog TecSinapse

--

Após o lançamento do Java 8 é possível fazermos uso da Stream API, que combinado com as funções lambda torna possível desenvolvermos códigos que manipulam listas de objetos de forma mais concisa, realizando mais operações escrevendo menos código.

Nesta postagem irei apresentar alguns conceitos básicos sobre as funções lambda e o seu uso em conjunto com a Stream API no Java.

1. Entendendo o funcionamento básico das funções lambda

As funções lambda utilizam técnicas de programação funcional, que é um paradigma de programação no qual o seu ponto central se baseia na aplicação de funções, ao contrário da programação imperativa, que tem o seu ponto focal na mudança de estados de um determinado programa.

Quando utilizamos as funções lambdas em Java é possível escrevermos por exemplo a seguinte fórmula:

(x) -> 3*x + 2

Nesta fórmula recebemos de parâmetro um determinado valor x que será multiplicado por 3 e posteriormente somaremos o valor 2.

A seguir é apresentado uma forma de executarmos esta fórmula no código Java:

Exemplo 1.1 - Executando uma função lambdaFunction<Integer, Integer> function = (x) -> x*3 + 2;
Integer resultado = function.apply(1);
System.out.print(resultado);

No código apresentado no Exemplo 1.1 utilizamos a interface funcional Function, ela recebe em sua definição a classe Integer, de modo que o primeiro Integer informado é o tipo de entrada da função e o segundo Integer será o resultado obtido.

Logo após a sua definição ela recebe a função (x) -> x*3 + 2. Na linha seguinte temos o comando function.apply(1), que executa a função (x) -> x*3 + 2, onde o valor de x será igual a 1. Após realizar estas operações, será apresentado o valor da variável resultado que será igual a 5.

2. Interfaces Funcionais

Com base no Exemplo 1.1 temos a interface Function que consiste de uma interface funcional que possibilita a execução da expressão lambda (x) -> x*3 + 2.

A linguagem Java é uma linguagem fortemente tipada, e a maioria dos valores em Java são objetos. Deste modo, a interface funcional é utilizada para representar as expressões lambda no código Java.

Uma interface funcional basicamente é uma interface que possui apenas um método abstrato. No Exemplo 1.1 podemos ver que que a expressão lambda (x) -> x*3 + 2 será um objeto que implementa Function, que é uma interface funcional que possui um único método abstrato chamado apply, que recebe um valor de parâmetro utilizado para executar a expressão lambda que ela representa.

3. Stream API

Agora que sabemos um pouco sobre o uso das funções lambda no Java, vamos aprender a combiná-las com a Stream API.

Podemos dizer que a Stream consiste de um recurso que permite construirmos diversas operações desde as mais simples até as de maior complexidade em estruturas de lista utilizando técnicas de programação funcional.

Para iniciarmos o entendimento sobre o uso da interface Stream, vamos considerar que temos um código que possua uma lista de diversos atores nacionais, e desejamos saber quantos são da cidade de São Paulo.

Exemplo 3.1: Estrutura de repetição que faz contagem da quantidade de atores da cidade de São Pauloint count = 0;
for (Actor actor: allActors) {
if (actor.isFrom(“São Paulo”)) {
count++;
}
}

Verificamos que este código utiliza a estrutura de repetição for, dentro desta estrutura validamos se um determinado ator é da cidade de São Paulo e caso esta condição seja verdadeira realizamos o incremento da variável count.

Este exemplo pode ser alterado para utilizar a Stream API conforme apresentado a seguir:

Exemplo 3.2: Realizando contagem de atores de São Paulo utilizando o recurso Streams APILong count = allActors.stream()
.filter(actor -> actor.isFrom(“São Paulo”))
.count();

Basicamente no Exemplo 3.2 convertemos a lista para uma Stream por meio da execução do método stream(). Após este passo chamamos o método filter que realiza a filtragem de dados por meio do recebimento de uma interface funcional, que no caso será actor -> actor.isFrom(“São Paulo”). Após isto é feita a contagem dos elementos filtrados por meio do método count().

4. Operações Intermediárias e Operações Terminais

Quando trabalhamos com a interface Stream devemos ter em mente que ela nos fornece dois tipos de operações, as operações intermediárias que retornam uma Stream e as operações terminais que retornam um valor ou objeto.

Para facilitar, vamos pensar na Stream como uma receita para gerar o objeto ou valor que desejamos. O método filter por exemplo é uma operação intermediária pois ela retorna uma Stream. No Exemplo 3.2 o método filter recebe uma função lambda de parâmetro para validar se um determinado ator é de São Paulo, após isto ela retorna uma Stream.

Podemos observar que até o momento esta receita tem a instrução de buscar todos os atores que são originados da cidade de São Paulo porém nenhuma ação de busca foi realizada para gerar algum resultado.

Para ficar mais claro vamos utilizar o seguinte exemplo para ilustrar a situação:

Exemplo 4.1: Criação de uma Stream para filtrar atores da cidade de São Paulo.allActors.stream()
.filter(actor -> {
System.out.println(actor.getName());
return actor.isFrom(“São Paulo”);
});

Se executarmos o código apresentado no Exemplo 4.1 perceberemos que nenhum valor será apresentado no console. Isto ocorre pelo fato de operações intermediárias não executarem nenhuma operação para percorrer a lista de atores.

Para que realmente ocorra uma iteração sobre a lista de atores é necessário realizar a chamada de uma operação terminal, como por exemplo a operação count.

Exemplo 4.2: Realizando a chamada do método count(), possibilitando a iteração sobre a lista allActors.allActors.stream()
.filter(actor -> {
System.out.println(actor.getName());
return actor.isFrom(“São Paulo”);
})
.count();

Ao executarmos o código do Exemplo 4.2 podemos verificar que serão impressos os nomes de todos os atores que estão contidos na lista allActors, isto ocorre porque diferente do código apresentado no Exemplo 4.1, realizamos a chamada da operação terminal count.

O fato da iteração sobre a lista ser executada somente após a chamada de uma operação terminal garante que a iteração ocorra executando todos os passos definidos por meio das operações intermediárias da forma mais eficiente possível.

5. Operações da interface Stream mais utilizadas

Nesta seção serão apresentadas algumas operações que a Stream API nos fornece.

5.1. collect(toList())

collect(toList()) é uma operação terminal que gera uma lista de valores por meio de uma Stream.

Exemplo 5.1: Gerando uma lista por meio de uma StreamList<String> list = Stream.of("a", "b", "c")
.collect(Collectors.toList());
assertEquals(Arrays.asList("a", "b", "c"), collected);

No Exemplo 5.1 geramos uma Stream por meio do comando Stream.of(“a”, “b”, “c”), posteriormente utilizamos o comando collect(Collectors.toList()) que vai transformar esta Stream em uma lista de objetos do tipo String.

5.2 map

O map é uma operação intermediária que permite transformar um objeto em algum outro tipo de objeto. Por exemplo, considere uma lista de objetos do tipo String conforme apresentado a seguir:

Exemplo 5.2.1: Lista de Strings que representam algum númerofinal List<String> numerosEmString = Arrays.asList(
"1.0", "2.0", "3.0");

Observando os elementos da lista numerosEmString vamos perceber que todos podem ser convertidos para um objeto do tipo BigDecimal, deste modo, podemos realizar a conversão de todos estes objetos por meio da operação intermediária map:

Exemplo 5.2.2: Transformando a lista de String em uma lista de BigDecimalList<BigDecimal> numeros = numberosEmString
.stream()
.map(s -> new BigDecimal(s))
.collect(Collectors.toList());

No Exemplo 5.2.2 passamos como parâmetro na operação map a expressão lambda que será responsável por realizar a conversão de um determinado objeto do tipo String em um objeto do tipo BigDecimal.

5.3 filter

O filter é uma operação intermediária que consiste em filtrar objetos com base em algum critério fornecido por parâmetro, esse critério é representado por uma função lambda. No exemplo a seguir poderemos verificar um exemplo do uso da operação filter.

Exemplo 5.3: Realizando filtragem de palavras que a primeira letra é um dígitoList<String> iniciaisComDigitos = Stream.of("a", "1abc", "abc1")
.filter(value -> isDigit(value.charAt(0)))
.collect(toList());
assertEquals(asList("1abc"), iniciaisComDigitos);

5.4 flatMap

Em alguns casos desejamos obter uma lista que é o resultado da junção de várias outras listas. O flatMap consegue obter este tipo de resultado concatenando várias listas, desde que elas sejam informadas no formato de uma Stream . Por exemplo, consideremos a classe GrupoNumerico que armazena uma lista de números inteiros conforme apresentado a seguir:

Exemplo 5.4.1 - Definição da classe GrupoNumericopublic class GrupoNumerico {

private List<Integer> numeros;
public List<Integer> getNumeros() {
return numeros;
}

public void setNumeros(List<Integer> numeros) {
this.numeros = numeros;
}
}

Com a classe GrupoNumerico definida, vamos considerar a definição de alguns objetos do tipo GrupoNumerico e a criação de uma lista contendo estes objetos:

Exemplo 5.4.2 - Criando uma lista de objetos do tipo GrupoNumericofinal GrupoNumerico multiplosDe2 = new GrupoNumerico();
multiplosDe2.setNumeros(Arrays.asList(0 ,2, 4, 6));

final GrupoNumerico multiplosDe3 = new GrupoNumerico();
multiplosDe3.setNumeros(Arrays.asList(0, 3, 6, 9, 12));

final GrupoNumerico multiplosDe5 = new GrupoNumerico();
multiplosDe5.setNumeros(Arrays.asList(0, 5 , 10, 15));
final List<GrupoNumerico> gruposNumericos = Arrays.asList(multiplosDe2, multiplosDe3, multiplosDe5);

No Exemplo 5.4.2 podemos ver que a variável gruposNumericos é uma lista de objetos do tipo GrupoNumerico. Vamos considerar que a partir da lista gruposNumericos desejamos obter todos os números contidos dentro de cada elemento do tipo GrupoNumerico da lista .

Podemos utilizar o flatMap para obter a lista de todos os números da seguinte forma:

Exemplo 5.4.2 - Obtendo todos os números informados em cada elemento da lista gruposNumericosList<Integer> todosOsNumeros = gruposNumericos
.stream()
.flatMap(grupo -> grupo.getNumeros()
.stream())
.collect(Collectors.toList());

No Exemplo 5.4.2 transformamos a lista gruposNumericos em uma Stream, logo em seguida invocamos o método flatMap informando em seu parâmetro uma função lambda que retorna uma Stream da lista de números de um determinado grupo.

Se executarmos o comando System.out.println(todosOsNumeros); vamos verificar que o console vai apresentar o seguinte valor: [0, 2, 4, 6, 0, 3, 6, 9, 12, 0, 5, 10, 15], ou seja, vamos ter uma lista com todos os números das listas multiplosDe2, multiplosDe3 e multiplosDe5.

Caso seja necessário obtermos somente os números pares presentes na lista gruposNumericos podemos ajustar a Stream informada no flatMap para filtrar somente os números pares conforme apresentado a seguir:

Exemplo 5.4.3 - Obtendo somente os números pares dos grupos numéricosList<Integer> todosOsNumeros = gruposNumericos
.stream()
.flatMap(grupo -> grupo.getNumeros()
.stream()
.filter(numero -> numero % 2 == 0))
.collect(Collectors.toList());

Neste momento se executarmos o comando System.out.println(todosOsNumeros); vamos verificar que o console vai apresentar uma lista somente com valores pares: [0, 2, 4, 6, 0, 6, 12, 0, 10].

5.5 Operações max e min

Em alguns casos desejamos encontrar um determinado objeto de uma lista que possua o maior ou menor valor de algum de seus atributos.

Vamos considerar novamente o objeto GrupoNumerico definido no Exemplo 5.4.1. No exemplo a seguir vamos definir uma lista de vários objetos do tipo GrupoNumerico e encontrar o objeto que possui a maior quantidade de números informados em sua lista conforme apresentado a seguir:

Exemplo 5.5.1 - Encontrado o GrupoNumerico com maior quantidade de elementos de sua lista de númerosfinal GrupoNumerico multiplosDe2 = new GrupoNumerico();
multiplosDe2.setNumeros(Arrays.asList(0 ,2, 4, 6));

final GrupoNumerico multiplosDe3 = new GrupoNumerico();
multiplosDe3.setNumeros(Arrays.asList(0, 3, 6, 9, 12));

final GrupoNumerico multiplosDe5 = new GrupoNumerico();
multiplosDe5.setNumeros(Arrays.asList(0, 10, 15));

final List<GrupoNumerico> gruposNumericos = Arrays.asList(multiplosDe2, multiplosDe3, multiplosDe5);
/* Realizando busca do GrupoNumerico com maior quantidade de elementos*/
GrupoNumerico grupoNumerico = gruposNumericos
.stream()
.max(Comparator.comparing(g -> g.getNumeros().size()))
.get();
/* O GrupoNumerico que possui maior quantidade de números neste caso será o multiplosDe3 */
assertEquals(multiplosDe3, grupoNumerico);

No Exemplo 5.5.1 utilizamos a função max para buscar o grupo numérico com maior quantidade de números. Definimos a sua forma de comparar o maior elemento com o auxílio do método comparing da classe Comparator. O método comparing recebe como parâmetro uma função lambda que retorna o valor do atributo que desejamos usar como referência para comparação.

Da mesma forma que o max consegue buscar o maior elemento, se utilizássemos a operação min, nós obteríamos o grupo numérico com menor quantidade de números que no caso seria a variável multiplosDe5.

5.6 Combinando operações intermediárias com operações terminais

Conforme verificamos nas seções anteriores, existem diversos tipos de operações intermediárias e operações terminais fornecidas na Stream API. Pensando novamente no conceito de uma Stream ser uma receita, podemos montar a nossa receita realizando a combinação de diversas operações intermediárias e obtermos o nosso resultado desejado por meio de uma operação terminal.

Considere por exemplo uma lista de objetos do tipo String, no qual representam algum número inteiro. Por meio desta lista desejamos obter uma lista de números inteiros pares já convertidos para o tipo Integer. Utilizando a combinação das operações intermediárias filter e map poderemos obter esta lista conforme apresentado no exemplo a seguir:

Exemplo 5.6.1 - Combinação das operações map e filter:List <String> numeros = Arrays.asList("1", "2", "3", "4", "5");
List<Integer> numerosPares = numeros
.stream()
.map(n -> new Integer(n))
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
assertEquals(asList(2, 4), numerosPares);

6. Referências para estudo

Existem muitas outras funcionalidades na Stream API que não foram abordadas nesta postagem. Deste modo, caso você deseje aprofundar os seus conhecimentos neste assunto, deixarei informado aqui o livro que utilizei como base para estudo.

O livro Java 8 Lambdas: Functional Programming For the Masses ensina desde os conceitos básicos referentes às funções lambdas e o seu uso combinado com a Stream API até os conceitos mais avançados que não foram abordados nesta postagem como por exemplo o uso da Stream API em funcionalidades que utilizam paralelismo e algumas técnicas de refatoração de código transformando por exemplo uma estrutura complexa de repetição em uma estrutura mais simples e concisa utilizando a Stream API.

Espero que esta postagem tenha ajudado a entender do funcionamento básico da Stream API.

Desde modo, finalizo aqui minha postagem. Muito obrigado pela atenção e até a próxima. =)

--

--