Tornando a manipulação de Collections eficiente com Stream API
- #Java
Breve introdução
As Collections representam um conjunto de classes e interfaces para armazenamento e gerenciamento de objetos em Java, a fim de tornar eficiente a manipulação de estruturas de dados. Com o crescimento da complexidade dos sistemas computacionais e as demandas por paradigmas mais apropriados a essa complexidade, eis que a partir do Java 8 surge a Stream API, um grupo de recursos para manipulação de coleções de modo eficaz e simplificado, utilizando conceitos do paradigma funcional e das expressões lambda. A motivação da Stream API é permitir aos desenvolvedores maneiras concisas e competentes de implementar e estruturar controles de fluxo, deixando tal tarefa a cargo da API, com práticas de mapeamento, filtragem, redução, particionamento, entre outras.
Exemplos práticos
1) Analisando dados de congestionamento nas cidades mais engarrafadas do Brasil
Conforme dito anteriormente, usaremos coleções de dados para fazer boa utilização da Stream API.
Uma classe "Cidade" com nome e quantidade de tempo gasto no trânsito em 2023.
public class Cidade {
private String nome;
private int tempoGasto;
public Cidade(String nome, int tempoGasto) {
this.nome = nome;
this.tempoGasto = tempoGasto;
}
public String getNome() {
return nome;
}
public void setNome(String nome) {
this.nome = nome;
}
public int getTempoGasto() {
return tempoGasto;
}
public void setTempoGasto(int tempoGasto) {
this.tempoGasto = tempoGasto;
}
}
Uma classe "Engarrafamento" que usa o paradigma funcional com Stream para iterar sobre a coleção de "Cidade".
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.text.*;
public class Engarrafamento {
private List<Cidade> cidades;
public Engarrafamento(List<Cidade> cidades) {
this.cidades = cidades;
}
private double calculaMediaTempoTotal() {
return cidades.stream()
.mapToDouble(Cidade::getTempoGasto)
.average()
.orElse(0.0);
}
public String retornaMedia() {
DecimalFormat decimalFormat = new DecimalFormat("0.00");
double media = calculaMediaTempoTotal();
return "Média das cidades analisadas : " + decimalFormat.format(media);
}
public List<String> determinaCidadePorTempo() {
double media = calculaMediaTempoTotal();
return cidades.stream()
.filter(cidade -> cidade.getTempoGasto() > media)
.map(cidade -> cidade.getNome() + " - " + cidade.getTempoGasto())
.collect(Collectors.toList());
}
}
Primeiro, gera-se uma instância da coleção List<Cidade>, e em um método funcional, converte a instância em uma stream:
cidades.stream()
A partir disso, realiza-se a chamada do método mapToDouble para mapear cada cidade e o tempo gasto e então determinar a média desse tempo, caso a coleção esteja vazia, retorna 0.0.
.mapToDouble(Cidade::getTempoGasto)
.average()
.orElse(0.0);
Após isso, o stream retorna novamente em outro método para realizar a filtragem das cidades com quantidade de tempo gasto no trânsito maior que a média de todas as cidades analisadas e ao final retorná-las no formato de String com o mapeamento de lista feito por Collectors.toList().
double media = calculaMediaTempoTotal();
return cidades.stream()
.filter(cidade -> cidade.getTempoGasto() > media)
.map(cidade -> cidade.getNome() + " " + cidade.getTempoGasto())
.collect(Collectors.toList());
Realizando-se a execução:
import java.util.*;
public class Principal {
public static void main(String[] args) {
var principal = new Principal();
List<Cidade> cidades = principal.inicializaDadosCidades();
var engarrafamento = new Engarrafamento(cidades);
List<String> congestionamentos = engarrafamento.determinaCidadePorTempo();
System.out.println("As cidades com tempo gasto na média maior que a média nacional são: \n");
System.out.println(" " + engarrafamento.retornaMedia());
congestionamentos.forEach(System.out::println);
}
public List<Cidade> inicializaDadosCidades() {
return new ArrayList<>(Arrays.asList(
new Cidade("São Paulo", 105),
new Cidade("Fortaleza", 92),
new Cidade("Curitiba", 94),
new Cidade("Recife", 116),
new Cidade("Belo Horizonte", 109),
new Cidade("Rio de Janeiro", 81)
));
}
}
/*
Saída esperada :
As cidades com quantidades de tempo gasto maiores que a média de todas as analisadas são:
Média das cidades analisadas : 99.50
São Paulo 105
Recife 116
Belo Horizonte 109
*/
2) Determinando alimentos por carga glicêmica
Com mais um exemplo, dessa vez sem tantos detalhamentos e fazendo uso de um record, recurso introduzido permanentemente na linguagem a partir do Java 16 e que reduz a verbosidade das classes com seus construtores e getters e setters.
O record "Nutriente" possui como atributos o nome, o peso, a massa de carboidratos (ambos em gramas) e o índice glicêmico.
public record Nutriente(
String nome,
double peso,
double massaCarboidratos,
double indiceGlicemico
) {}
A partir disso, introduzimos a classe "Glicemico" que usará o paradigma funcional para iterar sobre a stream gerada a partir da instância da coleção Set<Glicemico>.
import java.util.*;
import java.util.stream.Collectors;
public class Glicemico {
private Set<Nutriente> nutrientes;
public Glicemico(Set<Nutriente> nutrientes) {
this.nutrientes = nutrientes;
}
private double calcularCargaGlicemica(Nutriente nutriente) {
return (nutriente.massaCarboidratos() * nutriente.indiceGlicemico()) / 100.00; /*Fonte da fórmula logo abaixo.*/
}
public double calcularCargaTotal() {
return nutrientes.stream()
.mapToDouble(nutriente -> (nutriente.massaCarboidratos() * nutriente.indiceGlicemico()) / 100.00)
.sum();
}
public List<String> retornaAlimentosPorCG() {
return nutrientes.stream()
.sorted(Comparator.comparingDouble(this::calcularCargaGlicemica).reversed())
.map(nutriente -> {
double carga = calcularCargaGlicemica(nutriente);
return " " + nutriente.nome() + " - " + carga;
}
).collect(Collectors.toList());
}
}
A Stream é utilizada para retornar a soma total de carga glicêmica da coleção de alimentos e posteriormente, no último método, para ordenar de modo decrescente e retornar a lista em formato de String.
Executando:
import java.text.DecimalFormat;
import java.util.*;
public class Principal {
public static void main(String[] args) {
DecimalFormat decimalFormat = new DecimalFormat("0.00");
var principal = new Principal();
Set<Nutriente> nutrientes = principal.inicializaNutrientes();
var glicemico = new Glicemico(nutrientes);
List<String> glicemicos = glicemico.retornaAlimentosPorCG();
System.out.println("Alimentos por carga glicêmica.\n");
glicemicos.forEach(System.out::println);
System.out.println(("\nCarga glicêmica total : " + decimalFormat.format(glicemico.calcularCargaTotal())));
}
public Set<Nutriente> inicializaNutrientes() {
return new HashSet<>(Arrays.asList(
new Nutriente("Arroz", 1000.00, 28.00, 82.00),
new Nutriente("Pão integral", 100.00, 11.00, 70.00),
new Nutriente("Milho", 250.00, 25.00, 83.00),
new Nutriente("Feijão", 1000.00, 30.00, 39.00),
new Nutriente("Batata inglesa", 300.00, 35.00, 104.00)
));
}
}
/* Saída espera
Alimentos por carga glicêmica.
Batata inglesa - 36.4
Arroz - 22.96
Milho - 20.75
Feijão - 11.7
Pão integral - 7.7
Carga glicêmica total : 99.51
*/
Conclusão
Em resumo, a Stream API não só apresenta uma forma de escrever código de maneira mais concisa, bem como também simplifica muito a manipulação das Collections. Nos exemplos práticos, foram demonstrados suas utilizações em projetos simples, mas, além disso, em grandes projetos Backend e de APIs com o extraordinário framework Spring Boot e outros como Quarkus, a Stream fornece melhor legibilidade e facilidade de implementação de várias operações que são fundamentais em se trantando do controle apropriado de fluxo dos dados.
Fontes
Dados do trânsito : https://canalcomq.com.br/noticia/10292/9-cidades-com-o-maior-congestionamento-do-pais-voce-mora-em-uma-delas
Fórmula da carga glicêmica : https://endocrinoluperes.com.br/indice-glicemico-x-carga-glicemica