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)