Se você está aprendendo Java agora, ou vindo de outra linguagem, acredito que esse texto possa te ajudar. A Stream API do Java é uma ferramenta muito poderosa e inversamente a esse fato, ela é muito simples.
Com pouquíssimas linhas de código, podemos realizar operações que em outras linguagens precisaríamos de, talvez, o dobro.
Stream API
A Stream API, apresentada no Java 8, consiste em uma sequencia/coleção de elementos agregados que suportam operações sequenciais e/ou paralelas:
- stream()
- parallelStream()
Com isso, a ideia é abstrair parte do processo de desenvolvimento que antes seria utilizado para "comportamentos", deixando o controle de fluxo e loop's para a Stream API. Similar as Threads, onde aspectos mais complexos são encapsulados e o desenvolvedor fica responsável apenas pela implementação das regras de negócio.
Existem variações para suportar/facilitar a manipulação de tipos primitivos como:
- IntStream()
- LongStream()
- DoubleStream()
Dito isso, a Stream API oferece dois tipos de operações: Operações intermediárias e Operações terminais.
Operações intermediárias
Basicamente, operações intermediárias retornam novas stream's e permitem que o programador continue a concatenar/utilizar novas funções.
Filter
A operação filter() é usado para filtrar elementos de uma stream de acordo com uma condição, um predicado, 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 Usuario:
public class Usuario {
private String nome;
private int idade;
private String email;
private String senha;;
public Usuario(String nome, int idade, String email, String senha) {
this.email = email;
this.nome = nome;
this.idade = idade;
this.senha = senha;
}
@Override
public String toString() {
return "Usuario [nome=" + nome +
", idade=" + idade +
", email=" + email +
", senha=" + senha + "]";
}
}
private static List<Usuario> populaUsuarios(){
return List.of(new Usuario("Ana", 25, "<EMAIL>", "123456"),
new Usuario("Bruno", 21, "<EMAIL>", "123456"),
new Usuario("Caio", 23, "<EMAIL>", "123456"),
new Usuario("Daniel", 26, "<EMAIL>", "123456"),
new Usuario("Simão", 27, "<EMAIL>", "123456"),
new Usuario("Pedro", 28, "<EMAIL>", "123456"),
new Usuario("Maria", 25, "<EMAIL>", "123456"),
new Usuario("João", 25, "<EMAIL>", "123456"),
new Usuario("Marcos", 22, "<EMAIL>", "123456"),
new Usuario("Paulo", 23, "<EMAIL>", "123456"));
}
Agora, vamos montar uma nova lista apenas com usuários com 25 anos ou mais:
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaUsuariosComVinteCincoAnos =
listaUsuarios
.stream()
.filter(usuario -> usuario.idade >= 25);
listaUsuariosComVinteCincoAnos.forEach(System.out::println);
}
Chegando ao resultado:
Map
Em algumas situações nos deparamos com a necessidade de transformarmos/modificarmos os objetes da nossa lista. E ai entra a operação map(), que nos permite realizar essas mudanças sem a necessidade de variáveis intermediárias.
Usando uma função como argumento que, assim como o predicado na operação filter(), também é uma interface funcional. Tornando cada elemento da Stream em um parâmetro e retornando o elemento processado como resposta.
Dito isso, imagine que queremos transformar nossa lista de usuários em uma lista de pessoas, para ocultar algumas informações. Para isso vamos criar uma nova classe: Pessoa
public class Pessoa {
private String nome;
private int idade;
public Pessoa(Usuario usuario) {
this.nome = usuario.getNome();
this.idade = usuario.getIdade();
}
@Override
public String toString() {
return "Pessoa [nome=" + nome + ", idade=" + idade + "]";
}
}
Aplicando a operação map(), teremos os seguinte código:
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoas =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);});
listaPessoas.forEach(System.out::println);
}
}
Chegando ao resultado:
Lembra que as Streams possuem abstrações para otimizar a manipulação de tipos primitivos (IntStream, LongStream, DoubleStream) e assim evitar overhead? Então, vou demonstrar isso com a operação map().
Utilizando nossa lista de pessoas, vamos criar uma lista contendo apenas a idade das pessoas que estão na lista "original", para isso é preciso implementar os getters da classe Pessoa:
public class Pessoa {
private String nome;
private int idade;
public Pessoa(Usuario usuario) {
this.nome = usuario.getNome();
this.idade = usuario.getIdade();
}
@Override
public String toString() {
return "Pessoa [nome=" + nome + ", idade=" + idade + "]";
}
public String getNome() {
return nome;
}
public int getIdade() {
return idade;
}
}
Agora vamos montar nossa lista de idades, que por baixo dos panos é uma Stream< int >:
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoas =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);});
var listaPessoaIdade =
listaPessoas
.stream()
.mapToInt(Pessoa::getIdade)
.boxed()
.toList();
listaPessoaIdade.forEach(System.out::println);
}
}
Chegando ao resultado:
É importante reduzir o overhead pois nem existem situações onde será necessário operar sobre Streams de tamanho indeterminado. Para contextos controlados, talvez não faça tanto sentindo se preocupar com o custo computacional das operações.
Sorted
Ordenar elementos em uma coleção é uma tarefa recorrente para todo desenvolvedor. E no Java isso é bastante simples, as Stream API oferece a operação sorted(). Retornando uma nova stream contendo os elementos da stream original ordenados de acordo com o critério fornecido.
Utilizando o método comparing() da interface Comparator, é fornecida uma Function como parâmetro e assim obtemos um valor chave que será utilizado na ordenação.
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoa = listaUsuarios
.stream()
.map(usuario -> {
return new Pessoa(usuario);
});
var listaPessoaOrdenadoPorIdade = listaPessoa
.stream()
.sorted(Comparator.comparing(Pessoa::getIdade));
listaPessoaOrdenadoPorIdade.forEach(System.out::println);
var listaPessoaOrdenadoPorNome = listaPessoa
.stream()
.sorted(Comparator.comparing(Pessoa::getNome));
listaPessoaOrdenadoPorNome.forEach(System.out::println);
}
}
Nesse caso, a classificação das informações foi feita primeiro por idade:
E depois de acordo com o nome da pessoa, utilizando a ordem natural (alfabética) definida na interface Comparator para classificar Strings:
Como pôde ver, através de method reference conseguirmos usar a referência dos métodos getIdade() e getNome() da classe Pessoa como parâmetro para a operação Comparator.comparing().
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() e hashCode().
Ou seja, é necessário sobrescrever esses métodos dentro na nossa classe Pessoa:
public class Pessoa {
private String nome;
private int idade;
public Pessoa(Usuario usuario) {
this.nome = usuario.getNome();
this.idade = usuario.getIdade();
}
@Override
public String toString() {
return "Pessoa [nome=" + nome + ", idade=" + idade + "]";
}
public String getNome() {
return nome;
}
public int getIdade() {
return idade;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((nome == null) ? 0 : nome.hashCode());
result = prime * result + idade;
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Pessoa other = (Pessoa) obj;
if (nome == null) {
if (other.nome != null)
return false;
} else if (!nome.equals(other.nome))
return false;
if (idade != other.idade)
return false;
return true;
}
}
Após sobrescrever os métodos equals() e hashCode(), vamos adicionar usuários repetidos a nossa lista e utilizar a operação distinct():
private static List<Usuario> populaUsuarios(){
return List.of(new Usuario("Ana", 25, "<EMAIL>", "123456"),
new Usuario("Bruno", 21, "<EMAIL>", "123456"),
new Usuario("Caio", 23, "<EMAIL>", "123456"),
new Usuario("Daniel", 26, "<EMAIL>", "123456"),
new Usuario("Simão", 27, "<EMAIL>", "123456"),
new Usuario("Pedro", 28, "<EMAIL>", "123456"),
new Usuario("Maria", 25, "<EMAIL>", "123456"),
new Usuario("João", 25, "<EMAIL>", "123456"),
new Usuario("Marcos", 22, "<EMAIL>", "123456"),
new Usuario("Paulo", 23, "<EMAIL>", "123456"),
//Nomes Repetidos
new Usuario("Ana", 25, "<EMAIL>", "123456"),
new Usuario("Bruno", 21, "<EMAIL>", "123456"));
}
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoa = listaUsuarios
.stream()
.map(usuario -> {
return new Pessoa(usuario);
})
.distinct();
listaPessoa.forEach(System.out::println);
}
}
Chegando ao resultado:
Limit
A operação limit() retorna uma nova Stream com apenas a quantidade de elementos passada por parâmetro. Também é conhecida como curto-circuito pois não precisa processar todos os elementos da Stream original.
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoa = listaUsuarios
.stream()
.map(usuario -> {
return new Pessoa(usuario);
})
.limit(3);
listaPessoa.forEach(System.out::println);
}
}
Chegando ao resultado:
Operações terminais
Enquanto operações terminais retornam objetos e/ou resultados que, diferente das operações intermediárias, não são do tipo Stream. São consideradas operações terminais as operações:
ForEach
Através da operação forEach() é possível realizar um loop sobre todos os elementos de uma Stream e executar algum tipo de processamento.
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
listaUsuarios
.stream()
.map(usuario -> {
return new Pessoa(usuario);
})
.forEach(pessoa -> {
System.out.println(pessoa.getNome());
});
}
}
No exemplo, utilizamos lambda para invocar o método getNome() do objeto pessoa e passa-lo como parâmetro para a operação forEach(). Desta forma será exibidos apenas os nomes de todas as pessoas presentes na Stream.
Average
Para tipos primitivos, a operação average() permite calcular a média dos valores de uma Stream. Como exemplo, para calcular a média de idade das pessoas, teremos um código similar ao seguinte:
public class App {
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoas = listaUsuarios
.stream()
.map(usuario -> {
return new Pessoa(usuario);
});
var value = listaUsuarios
.stream()
.mapToInt(pessoa -> pessoa.getIdade())
.average()
.getAsDouble();
System.out.println("A média de idade das pessoas é: " + value);
}
}
Tendo como resultado:
Repare que no exemplo acima foi utilizada a operação getAsDouble(), isso ocorre pois a operação average() não retorna um valor numérico mas sim um objeto da classe java.util.Optional. Objetos do tipo Optinonal nos permitem lidar com algumas situações de forma simples.
Collect
A operação collect() permite a conversão de uma Stream em uma Collection, convertendo para os tipos:
- List
- Set
- Map
Ao chamarmos a operação collect(), determinando via argumento qual Collection será retornada através da classe Collectors. Como por exemplo:
- Collectors.toList()
- Collectors.toMap()
- Collectiors.toSet()
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoasList =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);})
.collect(Collectors.toList());
System.out.println("List: ");
System.out.println(listaPessoasList.toString());
System.out.println();
var listaPessoasMap =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);})
.collect(Collectors.toMap(Pessoa::getNome, Pessoa::getIdade));
System.out.println("Map: ");
System.out.println(listaPessoasMap.get("Ana"));
System.out.println();
var listaPessoasSet =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);})
.collect(Collectors.toSet());
System.out.println("Set: ");
System.out.println(listaPessoasSet);
System.out.println();
}
Chegando ao resultado:
Count
A operação count() retorna a quantidade de elementos presentes em uma Stream. Assim como average(), também é classificado como uma operação de redução (reduction).
Por exemplo, se quisermos retornar apenas as pessoas cujo o nome se inicia com a letra 'M':
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var listaPessoas =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);})
.filter(pessoa -> pessoa.getNome().toUpperCase().startsWith("M"))
.count();
System.out.println("Quantidade de pessoas com a letra inicial do nome M: " + listaPessoas);
}
Teremos como resultado:
AllMatch
A operação 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.
Imagine que queremos saber se todas as pessoas da nossa lista são maiores de idade, com a operação allMatch() teríamos um código similar a este:
public static void main(String[] args) throws Exception {
var listaUsuarios = populaUsuarios();
var todasAsPessoasSaoMaioresDeIdade =
listaUsuarios
.stream()
.map(usuario -> {return new Pessoa(usuario);})
.allMatch(pessoa -> {return pessoa.getIdade() >= 18;});
System.out.println(todasAsPessoasSaoMaioresDeIdade);
}
Resultado: true.
Collections e Streams
Apesar da semelhança entre Collections e Streams, os objetivos são bem diferentes. As Collections tem como principal objetiva a facilidade e eficiência na gestão de seu elementos, fornecendo meios simples intuitivos de armazenamento e acesso de objetos armazenados.
Enquanto as Streams, apesar de fornecer meios de acessar e manipular seus elementos, não permite que você os altere diretamente. Tendo como objetivo maior descrever declarativamente sua fonte e as operações computacionais que serão executadas sobre essa fonte.
Conclusão
Se você é um dev mais "cascudo" (veio), esse artigo pode parecer simplório e até defasado, afinal tudo isso foi introduzido ao Java na versão 8. Mas para novos programadores e/ou pessoas que estão chegando agora na linguagem, espero que esse artigo possa ajudar a entender um pouco do poder do Java e da funcionalidades/facilidades que a linguagem nos oferece.
Um grande abraço e até a próxima!
Top comments (0)