Trabalhando com Stream API e Funções Lambda no Java

Trabalhando com Stream API e Funções Lambda no Java

O Java 8 foi lançado em 2014, e uma das principais novidades foi a Stream API e o uso de Funções Lambda, que usados em conjunto podem deixar o seu código mais conciso. Muitos desenvolvedores ainda não fazem o uso de Streams, para quem não conhece, em um primeiro momento a sintaxe pode parecer estranha e confusa, mas a medida que se vai usando, você percebe que seu código realmente fica mais limpo, e você consegue economizar bastante linhas na sua aplicação. Neste artigo vou mostrar alguns exemplos para que você se habitue a sintaxe e ao uso de Streams e Lambdas.

Interface Funcional

Primeiramente vamos criar uma Interface Funcional, para visualizar como a sintaxe funciona:

@FunctionalInterface
public interface Calculo {
    double executar(double a, double b);
}

No trecho de código acima, criamos uma interface com um único método, e anotamos com @FunctionalInterface, isso deixa explicito que essa interface só pode receber um único método, para seguir o conceito de Single Abstract Method, dessa forma podemos chamar o método como uma Função Lambda.

public static void main(String[] args) {

    Executar soma = (x, y) -> {
        return x + y;
    };

    Double resultado = soma.executar(10, 10);
    System.out.println(resultado); // 20.0
}

No exemplo acima estamos fazendo o uso da Interface Funcional que criamos anteriormente, como não estamos fazendo nada dentro dela além da operação de soma, podemos simplificar mais ainda a sintaxe e omitir as chaves "{}" e o "return":

Executar soma = (x, y) -> x + y;

Method Reference

Com Method Reference, podemos simplificar a chamada de métodos utilizando a sintaxe "::" entre a classe e o nome do método, o forEach é o exemplo mais básico para demostrar a utilização, vamos observar a diferença utilizando a forma tradicional, usando Lambda, e por último utilizando Method Reference:

List<String> lista = Arrays.asList("Ana", "Bia", "Maria", "João");

// forma tradicional (for)
for (String nome : lista) {
    System.out.println(nome);
}

// Lambda
lista.forEach(nome -> System.out.println(nome));

// Method Reference
lista.forEach(System.out::println);

Podemos demostrar outro uso do Method Reference no primeiro exemplo que mostramos no artigo, onde mostramos como criar e usar uma Interface Funcional como Lambda, poderíamos declarar ela da seguinte forma, usando a função de soma do Double:

// Interface Funcional criada no começo do artigo, utilizando Method Reference
Executar soma = Double::sum;
Double resultado = soma.executar(10, 10);
System.out.println(resultado); // 20.0

Não vamos conseguir utilizar Method Reference em todos os casos, pois a saída da expressão anterior precisa corresponder aos parâmetros de entrada da assinatura do método referenciado, então é preciso ficar atento ao seu uso.

Algumas Interfaces da própria API do Java

O próprio Java já implementa algumas Interfaces que podem ser úteis para se utilizar no dia a dia, vamos ver o uso de algumas:

Predicate

Recebe um parâmetro, e de acordo com condição implementada retorna true ou false.

Predicate<Produto> corteDesconto = prod -> (prod.getPreco() * (1 - prod.getDesconto())) >= 5021.0;

Produto produto = new Produto("Iphone 12", 5579.00, 0.10);

System.out.println(corteDesconto.test(produto)); // true

Ainda com o Predicate, podemos fazer composição usando operadores:

Predicate<Integer> numeroPar = num -> num % 2 == 0;
Predicate<Integer> numeroTresDigitos = num -> num >= 100 && num <= 999;

// Foram usadas as funções "and", "or" e "negate", que fazem parte da interface Predicate.
boolean teste1 = numeroPar.and(numeroTresDigitos).negate().test(123);
boolean teste2 = numeroPar.or(numeroTresDigitos).test(123);

System.out.println(teste1); // true
System.out.println(teste2); // true

Function

Interface que recebe dois parâmetros, um de entrada e outro de saída, podemos estender múltiplas funções por meio do andThen.

Function<Integer, String> testaNumero = numero -> numero % 2 == 0 ? "Par" : "Impar";
Function<String, String> resultado = valor -> "O resultado é: " + valor;
Function<String, String> addExclamacao = valor -> valor + "!!";

String resultadoTesteNum = testaNumero
        .andThen(resultado)
        .andThen(addExclamacao)
        .apply(2);

System.out.println(resultadoTesteNum); // O resultado é: Par!!

BinaryOperator

Recebe dois parâmetros do mesmo tipo, e retorna um valor daquele mesmo tipo.

BinaryOperator<Double> media = (n1, n2) -> (n1 + n2) / 2;
Double resultado = media.apply(9.8, 5.7);
System.out.println(resultado); // 7.75

Mostramos alguns exemplos do que podemos fazer com as interfaces já existentes no Java, existem diferentes interfaces como a UnaryOperator, Supplier, Consumer, entre outras, você pode explorar todas elas olhando a documentação em docs.oracle.com/javase/8/docs/api/java/util..

Stream API

Como já dissemos, Stream API já é uma coisa antiga, que chegou no Java 8, mas muitos desenvolvedores ainda não utilizam seu potencial, principalmente em código legado. Ela é usada para manipular coleções (Collections) de uma maneira mais eficiente, utilizando interfaces funcionais e lambda, tópicos que vimos anteriormente. Nesse tópico vamos ver seus tipos, e alguns exemplos de como utilizá-las.

Podemos ter 3 categorias de operações nas Streams:

  • Operações de construção: onde iniciamos nossa coleção;
  • Operações intermediárias: podemos encadear essas operações, pois seu retorno ainda é uma Stream;
  • Operações terminais: encerra o processo de Stream, retornando a coleção desejada.

Ainda temos outros tipos de Streams:

  • Ordenadas;
  • Não Ordenadas;
  • Síncronas e Assíncronas (ParallelStream).

Primeiramente vamos ver algumas formas simples de criar Streams:

Consumer<String> print = System.out::println;

Stream<String> clouds = Stream.of("AWS ", "Google Cloud ", "Azure\n");
clouds.forEach(print);

String[] clouds2 = {"IBM Cloud ", "Alibaba Cloud", "Oracle ", "Salesforce\n"};

Stream.of(clouds2).forEach(print);
Arrays.stream(clouds2).forEach(print);
Arrays.stream(clouds2, 1, 4).forEach(print);

List<String> clouds3 = Arrays.asList("DigitalOcean ", "Heroku ", "MaxiHost\n");

// Sincrono
clouds3.stream().forEach(print);

// Assincrono
clouds3.parallelStream().forEach(print);

Map

Agora vamos ver as principais formas de manipular as coleções com Streams, o primeiro método que vamos trabalhar é o Map. Ele retorna uma nova Stream com a mesma quantidade de posições, transformando os elementos conforme a lógica implementada, ou seja, como exemplo podemos iniciar a Stream com uma coleção de Produtos, e retornar uma lista de Strings com o nome dos produtos.

List<Produto> produtos = dao.getProdutos();

List<String> nomeProdutos = produtos
        .stream()
        .map(produto -> produto.getNome())
        .collect(Collectors.toList());

System.out.println(nomeProdutos); // [Nome P1, Nome P2, Nome P3]

Outro exemplo com Map, dessa vez utilizando composição:

List<String> marcas = Arrays.asList("Ford", "Fiat", "Renault");

marcas.stream()
        .map(name -> name.toUpperCase())
        .forEach(System.out::println); // FORD, FIAT, RENAULT

UnaryOperator<String> maiuscula = name -> name.toUpperCase();
UnaryOperator<String> primeiraLetra = name -> name.charAt(0) + "";
UnaryOperator<String> exclamacao = name -> name + "!!!";

// Composição com Map
marcas.stream()
        .map(maiuscula)
        .map(primeiraLetra)
        .map(exclamacao)
        .forEach(System.out::println); // F!!!, F!!!, R!!!

Filter

Como o nome já diz, podemos filtrar nossa coleção conforme a lógica passada, inclusive podemos fazer composição já utilizando o Map na mesma operação, vamos aos exemplos:

List<Produto> produtos = dao.getProdutos();

// Lista dos produtos selecionados de acordo com desconto;
List<Produto> produtosSelecionados = produtos.stream()
        .filter(produto -> produto.getDesconto() >= 12)
        .collect(Collectors.toList());

// Lista do nome dos produtos selecionados, usando composição com Map.
List<String> nomeProdutosSelecionados = produtos.stream()
        .filter(produto -> produto.getDesconto() >= 12)
        .map(produto -> "O nome do produto é: " + produto.getNome())
        .collect(Collectors.toList());

Reduce

O Reduce é mais flexível que o Map e o Filter, o exemplo mais simples para entendimento que podemos usar com ele é a soma de valores. No Reduce podemos passar dois parâmetros, o primeiro é o identificador, é o ponto de partida para aquela operação, caso não passe o identificador, ele retornará um Optional. Também podemos fazer composição usando Reduce, mas como ele é uma operação terminal, então não podemos usar outro operador após ele.

List<Produto> produtos = dao.getProdutos;

Double somaValorProdutos = produtos.stream()
        .map(produto -> produto.getPreco())
        .reduce(0.0, (subtotal, preco) -> subtotal + preco);

Match

Com as operações de Match você consegue testar se determinada regra está aplicada a uma Stream, fazendo um papel semelhante aos dos operadores condicionais, ele é terminal, então não gera outra Stream.

List<Aluno> alunos = dao.getAlunos; // Notas: 3.8, 3.8, 3.8, 3.8, 7.1, 3.8

Predicate <Aluno> aprovado = a -> a.getNota() >= 7;

System.out.println(alunos.stream().allMatch(aprovado)); // false
System.out.println(alunos.stream().anyMatch(aprovado)); // true
System.out.println(alunos.stream().noneMatch(aprovado.negate())); // false

Essas são as principais operações que vamos utilizar no dia a dia trabalhando com Streams, mas a API não se limita a isso, e existem muitas outras possibilidades, você pode explorar mais coisas através do link: docs.oracle.com/javase/8/docs/api/java/util.., a sua IDE também pode ajudar bastante, o IntelliJ, por exemplo, sendo a IDE que uso no momento, me da várias dicas do que utilizar a medida que vou escrevendo meu código. Espero que esse artigo possa ter te ajudado de alguma forma, e caso tenha alguma dúvida, sugestão, ou crítica construtiva, deixe seu comentário.