DEV Community

Gabriel Suaki
Gabriel Suaki

Posted on

Programação Funcional em Java #2 - Descomplicando o Vavr

Alt Text

Descomplicando o Vavr

Se você está se perguntando "Qual a relação entre Programação Funcional e Vavr ?" não se preocupe, vou te explicar. A biblioteca Vavr é um superset de construções básicas que linguagens funcionais oferecem por padrão, com exceção do Java - o que dificulta a utilização do paradigma funcional no dia-a-dia.

A biblioteca conta com várias construções e aqui focaremos nas mais importantes: Functions, Monads, Collections e Pattern Matching.

1. Functions

Functions enriquece as funções de uma maneira jamais vista antes no Java. É considerado um dos pilares mais importantes, pois Programação Funcional é sobre valores e como manipulá-los através de funções. Esse módulo sobre funções é divido em cinco conceitos: Composition, Lifting, Partial application, Currying e Memoization.

1.1. Composition

Esse é o conceito mais simples (também encontramos nas interfaces do Java). É basicamente sobre compor as funções a fim de obter maior robustez e significância. Veja o exemplo:

final Function1<Integer, Integer> tributoIRRF = Function1.of(n -> n - 1000);

final Function1<Integer, Integer> contribuicaoINSS = Function1.of(n -> n - 100);

final Function1<Integer, Integer> calculaSalarioLiquido = tributoIRRF.andThen(contribuicaoINSS);

final Integer salarioLiquido = calculaSalarioLiquido.apply(2000); // Líquido de 900
Enter fullscreen mode Exit fullscreen mode

Nesse exemplo acima definimos três funções. A primeira tributoIRRF recebe um inteiro e subtrai 1000. A segunda contribuicaoINSS também recebe um inteiro mas subtrai apenas 100. Na terceira função calculaSalarioLiquido não implementamos nenhuma lógica, ao invés disso foi feita uma composição entre as duas primeiras funções. Ao executar calculaSalarioLiquido seguimos a lógica em invocar tributoIRRF e, então, invocar contribuicaoINSS. Convertendo para funções matemáticas, podemos ler f(x): ((x - 1000) - 100).

Além do andThen, também é possível alterar a ordem da execução com o compose:

final Function1<Integer, Integer> calculaSalarioLiquido = tributoIRRF.compose(contribuicaoINSS);

final Integer salarioLiquido = calculaSalarioLiquido.apply(2000); // Líquido de 900
Enter fullscreen mode Exit fullscreen mode

Dessa forma, o resultado obtido ainda é o mesmo, mas agora lê-se f(x): ((x - 100) - 1000) .

1.2. Lifting

Esse conceito consiste em envolver uma função que consideramos parcial gerando uma função total. Para entender melhor, veja este exemplo:

public Integer calculaImposto(final Integer tributoIRRF, final Integer contribuicaoINSS) {

  if (tributoIRRF < 0 || contribuicaoINSS < 0) {
    throw new IllegalArgumentException("Imposto não pode ser menor que 0");
  }

  return tributoIRRF + contribuicaoINSS;
}
Enter fullscreen mode Exit fullscreen mode

Esse método calculaImposto retorna a soma entre os dois impostos da entrada, caso sejam maiores que 0, do contrário, lança uma exceção. O fato de lançar tal exceção o torna uma função parcial. Podemos tornar a mesma completa utilizando o Function2.lift.

Function2<Integer, Integer, Option<Integer>> calculadorDeImposto = 
  Function2.lift(this::calculaImposto);
Enter fullscreen mode Exit fullscreen mode

Epa! Option, what!? Calma, por ora, o que você precisa saber é que este Option tem a semântica do Optional do Java e tem duas representações: None() quando é vazio e Some(n) quando há um valor.

Voltando ao exemplo, na nova função calculadorDeImposto, agora indicamos ao consumidor que a função pode ou não produzir um resultado. No caso, se o valor de um dos impostos for menor que zero e o método principal calculaImposto lançar uma exceção, o resultado da operação é None(). Por outro lado, caso os parâmetros estiverem OK, o resultado é Some(n).

1.3. Partial application

Partial application consiste em reduzir gradativamente a quantidade de parâmetros de uma função aplicando aqueles que estão disponíveis. É parecido com o conceito Currying, mas não se engane, pois eles são diferentes. Vamos ao exemplo:

Exemplo 1. Reduzindo parâmetros até o último

final Function3<Integer, Integer, Integer, Integer> calculaSalarioLiquido = 
  (salario, tributoIRRF, contribuicaoINSS) -> salario - (contribuicaoINSS + tributoIRRF);

final Function2<Integer, Integer, Integer> salarioBruto = calculaSalarioLiquido.apply(2000);

final Function1<Integer, Integer> salarioMenosIRRF = salarioBruto.apply(1000);

final Integer salarioLiquido = salarioMenosIRRF.apply(100);
Enter fullscreen mode Exit fullscreen mode

Nesse exemplo 1, a função calculaSalarioLiquido é de aridade três, ou seja, demanda três parâmetros de entrada. Supondo que no momento da execução não temos todos os três valores, como proceder? Felizmente, o Vavr está preparado para receber os valores individualmente e ir reduzindo a aridade da função, exatamente como no exemplo 1.

Primeiramente aplicamos o valor do salário (2000) e a função calculaSalarioLiquido nos retornou uma nova função de aridade dois, a qual chamamos de salarioBruto. Em seguida, aplicamos o próximo valor (1000) referente ao tributoIRRF e o resultado foi uma nova função de aridade um, a qual chamamos de salarioMenosIRRF. Por fim, aplicamos o valor de 100 referente a contribuicaoINSS e obtivemos o resultado final que é o salário líquido no valor de 900.

O Vavr também possibilita fazer de outras formas. No próximo exemplo utilizamos os métodos reversed() e .apply(p1, p2) de aridade dois, veja:

Exemplo 2. Invertendo a ordem e aplicando dois parâmetros de uma só vez

final Function3<Integer, Integer, Integer, Integer> calculaSalarioLiquido =
  (salario, tributoIRRF, contribuicaoINSS) -> salario - (contribuicaoINSS + tributoIRRF);

final Function1<Integer, Integer> salarioMenosIRRFeINSS = calculaSalarioLiquido.reversed().apply(100, 1000);

final Integer salarioLiquido = salarioMenosIRRFeINSS.apply(100);
Enter fullscreen mode Exit fullscreen mode

Nesse segundo cenário fizemos basicamente a inversão dos parâmetros de entrada para que pudéssemos aplicar os dois impostos de uma só vez e, então, gerar função salarioMenosIRRFeINSS cuja aridade caiu de três para um. Dessa forma, para o cálculo da aridade da função resultante utilizamos a fórmula: aridade original - parâmetros aplicados.

1.4. Currying

Currying é a técnica de quebrar a aridade de uma função em funções de aridade única. Ficou confuso? Vamos ao exemplo:

final Function3<Integer, Integer, Integer, Integer> calculaSalarioLiquido =
  (salario, tributoIRRF, contribuicaoINSS) -> salario - (contribuicaoINSS + tributoIRRF);

final Function1<Integer, Function1<Integer, Function1<Integer, Integer>>> calculoSeparado =
  calculaSalarioLiquido.curried();
Enter fullscreen mode Exit fullscreen mode

Aqui, podemos ver nossa conhecida função, de aridade três e bastante complexa, calculaSalarioLiquido. Com o uso do .curried() possibilitamos desmembrá-la em três funções encadeadas de um único parâmetro.

Para invocar a função resultante de .curried(), faremos desta forma:

final Integer salarioLiquido = calculoSeparado.apply(2000).apply(1000).apply(100);
Enter fullscreen mode Exit fullscreen mode

Resumindo, a diferença entre Currying e Partial application é que Currying quebra a função em funções de aridade única encadeadas, enquanto o Partial application é flexível, pois resulta sempre em uma função com o restante dos parâmetros não aplicados.

1.5. Memoization

Memoization consiste em cachear o resultado de uma função com base nos parâmetros.

final Function3<Integer, Integer, Integer, Integer> calculaSalarioLiquido =
  (salario, tributoIRRF, contribuicaoINSS) -> salario - (contribuicaoINSS + tributoIRRF);

final Function1<Integer, Integer> salarioMenosDescontos =
  calculaSalarioLiquido.reversed().apply(100, 1000).memoized();

final Integer salarioLiquido = salarioMenosDescontos.apply(2000); // Calcula e cacheia

final Integer salarioLiquido2 = salarioMenosDescontos.apply(2000); // Utiliza valor do cache

final Integer salarioLiquido3 = salarioMenosDescontos.apply(3000); // Calcula e cacheia
Enter fullscreen mode Exit fullscreen mode

Supondo que calculaSalarioLiquido demande uma computação enorme e que os parâmetros tributoIRRF e contribuicaoINSS não se alterem com muita frequência, podemos aplicar os parâmetros de impostos e marcar a função resultante como .memoized(), assim, a nova função salarioMenosDescontos terá o comportamento de cachear o resultado em memória, sendo a chave o parâmetro de entrada. Ou seja, ao aplicar o valor 2000, toda computação é feita e ao final armazena-se o resultado. Se o valor for inputado novamente o cache estará populado, retornando o resultado computado previamente.

2.1. Monads

Monads são typeclasses que encapsulam um valor a um comportamento específico. Você conhece o java.util.Optional ? É um Monad. Ele encapsula um valor que pode, ou não, estar presente.

2.1. Option

Option é um container que representa um valor opcional. Por baixo dos panos, a interface Option tem duas implementações: Some(value) quando há valor dentro do Option e None() para quando não há.

Option<String> maybeName = Option.of("Gabriel"); // Some(string)

Option<String> maybeName = Option.of(null); // None()
Enter fullscreen mode Exit fullscreen mode

Veja que, diferentemente do Optional, o factory method padrão do Option contém a inteligência de fazer a triagem do valor null, sem precisar de um outro método como Optional.ofNullable. Além disso, existe outra diferença muito importante no comportamento do .map que ,se você não se atentar, pode resultar em NPEs.

Exemplo 1. Optional padrão do Java

final Optional<String> name = Optional.of("gabriel") // (1)
  .map(value -> (String) null) // (2)
  .map(value -> value.toLowerCase()); // (3) Não acontece

assertFalse(name.isPresent());
Enter fullscreen mode Exit fullscreen mode
  1. Optional com valor interno "gabriel".
  2. Optional se torna vazio pois null é retornado.
  3. Não é invocado pois o Optional está vazio.

No exemplo acima, ao retornar null no primeiro .map, podemos observar que o comportamento é de desconsiderar os próximos .map, pois o valor interno tornou-se vazio. Caso prosseguisse para o próximo .map(value -> value.toLowerCase()) o resultado seria, inevitavelmete, NPE, já que value passou a ser null.

Exemplo 2. Option do *Vavr*

final Option<String> name = Option.of("gabriel") // (1)
  .map(value -> (String) null) // (1)
  .map(value -> value.toLowerCase()); // (3) throws NullPointerException
Enter fullscreen mode Exit fullscreen mode
  1. Option com valor interno Some("gabriel").
  2. Option se torna Some(null).
  3. .map é invocado com valor null e um NPE é lançado.

Simulando o mesmo cenário, mas com Option do Vavr, observamos que NullPointerException é lançado ao tentar invocar .toLowerCase em uma variável null. Isso acontece porque a semântica do .map do Vavr preserva o conceito matemático de que invocar .map em um Some resulta em outro Some, e invocar .map em um None resulta em outro None. No Optional do Java, o resultado null ao invocar o .map altera o contexto interno de Some para None.

Sabendo disso, o que fazer para voltar a estar seguro contra null ? É simples. Ao invés de .map, flatMap that shit!.

final Option<String> name = Option.of("gabriel")
  .flatMap(value -> Option.of((String) null))
  .map(value -> value.toLowerCase()); // we are safe

assertFalse(name.isPresent());
Enter fullscreen mode Exit fullscreen mode

Para conhecer mais sobre o assunto, recomendo este post The agonizing death of an astronaut.

2.2. Try

Try é um container que representa uma execução que pode resultar em sucesso ou em uma exceção.

// Simples try com valor padrão em caso de erro.
Try.of(() -> calculaImpostos())
   .getOrElse(valorDefault);

// Exemplo um pouco mais avançado utilizando Try with resources para ler um InputStream
Try.withResources(() -> readFile())
   .of(stream -> IOUtils.toString(stream))
   .getOrElse("");
Enter fullscreen mode Exit fullscreen mode

Em caso de erro, esse mônada fornece métodos utilitários que nos permite fazer tratamentos, tais como:

Try.of(this::calculaImposto)
   .recover(AException.class, t -> recoverFromA(t))
   .recover(BException.class, t -> recoverFromB(t))
   .recoverWith(CException.class, t -> Try.of(() -> recoverFromC(t)))
Enter fullscreen mode Exit fullscreen mode

Ou, podemos utilizar Pattern Matching - conceito que veremos mais à frente - e unificar os .recover com rebuscado fancy switch-case funcional Match, fornecido pelo Vavr:

Try.of(this::calculaImposto)
   .recover(error -> Match(error).of(
     Case($(instanceOf(AException.class)), t -> recoverFromA(t)),
     Case($(instanceOf(BException.class)), t -> recoverFromB(t)),
     Case($(instanceOf(CException.class)), t -> recoverFromC(t)),
     Case($(), t -> recoverFromUnknown(t))
   ))
   .getOrElse(impostoPadrao);
Enter fullscreen mode Exit fullscreen mode

No exemplo acima, fica notável como Vavr nos permite desenvolver códigos funcionais e expressivos. Além dos métodos exemplificados, Try conta com hooks como .onFailure, .onSuccess, .andFinally, .andThen, entre outros.

2.3. Lazy

Lazy é um container que representa um valor que não precisa ser computado no momento em que é criado, apenas quando solicitado. Diferentemente do Supplier, cuja semântica é parecida, no Lazy o resultado é cacheado ao ser evaluado.

final Lazy<UUID> id = Lazy.of(UUID::randomUUID); // (1)
id.isEvaluated(); // (2)

final UUID newID = id.get(); // (3)
id.isEvaluated(); // (4)

final UUID cachedID = id.get(); // (5)
assertEquals(newID, cachedID);
Enter fullscreen mode Exit fullscreen mode
  1. Cria-se instância do Lazy a partir de um supplier de UUID.
  2. isEvaluated é false por não ter sido invocado o .get.
  3. Invoca-se o .get que invoca o supplier UUID.randomUUID gerando o UUID aleatório.
  4. isEvaluated passa a ser true.
  5. Invoca-se o .get e retorna o UUID cacheado do passo 3.

Outra funcionalidade muito interessante é o suporte a interfaces, onde a instância da implementação é feita via Proxy na primeira chamada de qualquer método da interface. Não entendeu ? Vamos ao exemplo:

final CalculadoraImposto inss = Lazy.val(CalculadoraINSS::new, CalculadoraImposto.class);
inss.calcular(); // (1)
inss.calcular(); // (2)

public interface CalculadoraImposto {
  Integer calcular();
}

public class CalculadoraINSS implements CalculadoraImposto {
  public CalculadoraINSS() {
    System.out.println("Instanciando calculadora INSS");
    // Computação custosa e demorada
  }

  @Override
  public Integer calcula() {
      return 1000;
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. Invoca-se o método calcular, o qual passa pelo proxy e invoca o supplier CalculadoraINSS::new, cacheando o resultado e depois invocando o método.

  2. Invoca-se o método calcular, o qual também passa pelo proxy, mas desta vez, o resultado do supplier está cacheado e não passa pelo CalculadoraINSS::new, indo direto ao método.

Nesse exemplo, temos a interface CalculadoraImposto e a implementação CalculadoraINSS. Supondo que instanciar CalculadoraINSS demande muito poder computacional e tempo, obviamente não gostaríamos de instanciar sem realmente precisar. Utilizando factory method Lazy.val, passamos como primeiro parâmetro o Supplier, responsável por construir o objeto, e como segundo parâmetro o .class da interface, a qual a implementação pertence (CalculadoraImposto). Repare que o retorno do Lazy.val é a própria interface e não Lazy<CalculadoraImposto>, como no exemplo anterior com Lazy.of. O truque aqui é que o Vavr cria um Proxy dinâmico da interface envolvendo o Supplier em um Lazy. Quando qualquer invocação é feita, nos métodos da interface, primeiro passa-se pelo Proxy que invoca o Lazy.get e depois invoca-se a implementação real do método, caso necessário. Como vimos acima, Lazy.get faz cache do resultado, ou seja, só invocamos o construtor CalculadoraINSS uma vez e a instância fica em memória para ser reutilizada.

2.4. Either

Either é um container que representa um valor que pode se apresentar de duas maneiras diferentes. Por debaixo dos panos, a interface Either tem duas implementações: Left() e Right() .
Este mônada é bastante utilizada para representar um valor que deu certo (right) ou que falhou (left). Imagino que você esteja se perguntando "qual a diferença entre Either e Try" ? Bom, assim como o Either, a semântica do Try também representa um valor que pode ser um sucesso ou um erro. A grosso modo, é possível visualizar o Try sendo um Either<Throwable, R>, todavia, seu contexto é inteiro voltado para isso e apenas isso. Por outro lado, o Either é flexível e não representa, necessariamente, sucesso ou falha.

final Either<String, Integer> impostoINSS = calculaINSS();

final Integer inss = impostoINSS
  .map(imposto -> salario - imposto) // (1)
  .getOrElseThrow(left -> handleError(left)); // (2)
Enter fullscreen mode Exit fullscreen mode
  1. .map só será executado caso o Either seja Right, ou seja, sucesso.

  2. Caso seja Left, lidamos com erro invocando handleError.

No exemplo acima, Either foi utilizado para indicar cálculo do INSS com sucesso (right) ou falha (left). calculaINSS poderia retornar Try ao invés de Either ? Sim, mas repare que a assinatura é Either<String, Integer>, se fosse Try lidaríamos com Throwable ao invés de String, o que pode fazer toda diferença para o contexto da aplicação e a maneira como lidamos com a falha.
Existem outras formas de trabalhar com o Either, conheça o .fold:

final Integer inss = impostoINSS.fold(
  left -> 1000, // (1) 
  imposto -> salario - imposto // (2)
);

Enter fullscreen mode Exit fullscreen mode
  1. Ignora-se a mensagem de erro left e retorna o valor padrão 1000. Poderíamos obter o mesmo resultado alterando a implementação do leftMapper por left -> { throw handleError(left); }.

  2. Mantém o mesmo comportamento que o exemplo anterior para caso de sucesso.

Diferentemente do .map e .getOrElseThrow encadeados, o .fold recebe ambos os mappers (leftMapper e rightMapper) e utiliza um deles dependendo de seu estado interno (left ou right).

3 Collections

As coleções do Vavr são uma evolução das que encontramos no Java. Todas são imutáveis e cada tentativa de mutação gera uma nova lista, ou seja, são estruturas persistentes.

Exemplo 1. java.util.List vs io.vavr.collection.List

// Java 8
final java.util.List<Integer> list = Arrays.asList(1, 2, 3);
list.add(4);
list.add(5);
list.add(0, 9);
list.set(1, 6);

list
  .stream()
  .max(Integer::compareTo)
  .ifPresent(System.out::print);

// Vavr - io.vavr.collection.List
List.of(1, 2, 3) // List(1, 2, 3) 
    .append(4) // List(1, 2, 3, 4)
    .append(5) // List(1, 2, 3, 4, 5)
    .insert(0, 9) // List(9, 1, 2, 3, 4, 5)
    .pop()
    .update(1, 6) // List(9, 6, 2, 3, 4, 5)
    .max() // Option(9)
    .peek(System.out::print); // 9
Enter fullscreen mode Exit fullscreen mode

Parece complexo, mas na verdade é bem simples. No exemplo acima, ambas collections iniciam-se com valores de 1, 2 e 3. Em seguida, adicionam-se os inteiros 4 e 5. Depois disso, insere-se o número 9 como primeiro elemento da lista e atualiza-se o valor do elemento de índice 1 para 6. Na próxima etapa devemos ir à procura do maior valor da lista e imprimir no console.

Podemos observar que a lista do Vavr é bem mais simples de se manipular. Cada manipulação gera uma nova lista e não corremos o risco de algum side-effect indesejado.

Exemplo 2. io.vavr.collection.HashMap

final HashSet<String> setOfNames = HashSet.of("Gabriel", "Joao", "Caio", "Pedro", "Fernando");

final Map<String, Integer> nameWithSize = setOfNames.toMap(Function.identity(), String::length);

nameWithSize.filterValues(size -> size > 4)
            .computeIfAbsent("Priscila", String::length)
            ._2()
            .computeIfPresent("Gabriel", (name, size) -> name.length())
            ._2()
            .maxBy(entry -> entry._2())
            .peek(System.out::println);
Enter fullscreen mode Exit fullscreen mode

Todas as coleções são otimizadas no quesito Time Complexity, para saber mais: Performance Characteristics.

4. Pattern Matching

Finalmente chegamos na parte mais poderosa do Vavr - e aquela que eu mais utilizo!

Pattern Matching é a habilidade de comparar um valor aos padrões estipulados. Caso surja algum match, aplica-se a função correspondente ao valor. Na prática é um hiper switch-case, veja o exemplo:

import static io.vavr.API.*;
// io.vavr.API.Match, io.vavr.API.Case e io.vavr.API.$
final String value = "Gabriel";
final Number number = Match(value).of(
  Case($("gab"), name -> 3),
  Case($(isNull()), name -> 0),
  Case($(isIn("Monica", "Cebolinha", "Joao")), name -> 0),
  Case($(instanceOf(CharSequence.class)), name -> name.length()),
  Case($(name -> name.length() > 10), name -> -1),
  Case($(), name -> -1)
);
Enter fullscreen mode Exit fullscreen mode

O exemplo acima é bastante simples, mas exibe as possibilidades que Pattern Matching nos dá. A estrutura apresenta-se como:

  1. Match(valor) entrando com valor que será comparado aos padrões.
  2. Case(padrão, função) definindo o padrão que será comparado e, se servir, a função na qual será aplicado o valor.

Os possíveis padrões, dentro do Case, podem ser:

  • $() - Padrão genérico/default
  • $(value) - Padrão equals
  • $(predicate) - Padrão condicional

Caso não queira usar o padrão genérico $(), é necessário utilizar o Match().option ao invés de Match().of e tratar manualmente.

final Option<Number> plusOne = Match(number).option(
  Case($(instanceOf(Integer.class)), i -> i + 1),
  Case($(instanceOf(Double.class)), d -> d + 1)
);
Enter fullscreen mode Exit fullscreen mode

5. What’s next?

A biblioteca Vavr ainda tem muito mais a ser explorada, não hesite em ler a documentação completa.

6. Referências

Discussion (3)

Collapse
ljtfreitas profile image
Tiago de Freitas Lima

O vavr realmente é bem massa, uma biblioteca bem legal 😄. Há algum tempo quando trabalhava com java eu implementei uma biblioteca HTTP onde trabalhava, parecida com feign ou retrofit (que imagino que você conheça), mas que, entre outras coisas, também suporta o vavr como retorno de método. Caso queira dar uma olhada: github.com/ljtfreitas/java-restify...

Obrigado pelo artigo!

Collapse
gsuaki profile image
Gabriel Suaki Author

Fala Tiago.

Dei uma olhada no java-restify e fez um ótimo trabalho! A documentação ficou excelente, baste integrações e APIs bem desenhadas. Ainda está em WIP ? (Reparei que algumas páginas da documentação estavam faltando hehe).

Obrigado pelo comentário e por compartilhar a lib 😄

Collapse
ljtfreitas profile image
Tiago de Freitas Lima

Opa Gabriel, legal que gostou :). Na verdade acho que está em um eterno WIP hehehe, porque eu comecei a implementar essa lib pensando algumas necessidades de uma empresa que trabalhava (hoje é a principal lib http que eles usam), mas depois mudei de trabalho, fui trabalhando com outras coisas, e acabei nem mesmo concluindo algumas partes da documentação 🤣, ainda falta até o trecho do vavr (vergonha 🤦🏽‍♂️, pelo menos tinha um código de exemplo com o vavr pra mostrar hehe). Então acabo fazendo devagar mas espero terminar a doc algum dia hehe. Mas a lib está production ready sim e usada em produção :) Se quiser experimentar pra alguma coisa e dar algum xabu só dar um toque :) (no momento quando sobra um tempinho estou mexendo também em uma nova versão porque queria usar internamente o client http que entrou no java 11. talvez devesse terminar os docs primeiro hehe)

Mas sobre o vavr, foi bem legal implementar o suporte pra essas coleções e pras monads que você mostrou, tipo o Try, Option e o Either (acho que nao tinha exemplo com o Either mas funciona tambem, se o Left for um Throwable ou String). Essa lib é bem massa. Valeu pelo artigo!