DEV Community

Cover image for Entendendo Java Stream API na prática
Gabriel Ferreira Mendes
Gabriel Ferreira Mendes

Posted on

Entendendo Java Stream API na prática

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 + "]";
    }
}
Enter fullscreen mode Exit fullscreen mode
    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"));                  
    }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Chegando ao resultado:

Lista de usuários com mais de 25 anos

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 + "]";
    }

}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Chegando ao resultado:

Lista de usuários convertidos em pessoas

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;
    }

}
Enter fullscreen mode Exit fullscreen mode

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); 
    }
}
Enter fullscreen mode Exit fullscreen mode

Chegando ao resultado:

Lista com a idade das pessoas

É 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);
    }
}

Enter fullscreen mode Exit fullscreen mode

Nesse caso, a classificação das informações foi feita primeiro por idade:

Lista de pessoas ordenadas por idade

E depois de acordo com o nome da pessoa, utilizando a ordem natural (alfabética) definida na interface Comparator para classificar Strings:

Lista de pessoas ordenadas por nome

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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"));                  
    }
Enter fullscreen mode Exit fullscreen mode
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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Chegando ao resultado:

Lista de usuários sem redundâncias

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Chegando ao resultado:

Lista de pessoas limitando a 3 resultados

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());
                                   });               
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Tendo como resultado:

Média de idade dos usuários presentes na lista

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();

    }
Enter fullscreen mode Exit fullscreen mode

Chegando ao resultado:

Resultado da conversão para: List, Map e Set

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);
    }
Enter fullscreen mode Exit fullscreen mode

Teremos como resultado:

Quantidade de pessoas cujo nome começa com a letra 'M'

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);
    }
Enter fullscreen mode Exit fullscreen mode

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)