O estudo de um novo paradigma sempre envolve um esforço maior no início da jornada, pois nos são apresentados muitos conceitos não familiares. A Programação Funcional não foge à regra, porém, quando aprendemos algo novo a partir de exemplos que já conhecemos, a tarefa se torna muito mais fácil. Nesse artigo iremos usar conceitos de código limpo e padrões de projeto que aplicamos na programação diária com Java para entender como o pensamento funcional é muito mais simples do que aparenta ser, e que pode ser visto como uma evolução natural das boas práticas de programação que conhecemos na Orientação a Objetos.

Comportamento “injetado”

Vejamos o código da Listagem 1.

Listagem 1. Loop


  public class IteracaoExemplo1 {
   
      public static void main(String[] args) {
          
          List<String> valores = Arrays.asList("1", "2", "3");
          Iterator<String> it = valores.iterator();        
          int soma = 0;
          while(it.hasNext()) {
              soma += Integer.parseInt(it.next());
          }        
          System.out.println("Soma = " + soma);
      }    
  }

No código vemos a iteração de uma lista de números (representados como Strings), onde a cada iteração convertemos um item String para int e o acumulamos na variável soma.

Ao longo da nossa carreira, escrevemos centenas de códigos similares a este, onde temos que iterar sobre uma coleção, efetuar um certo processamento sobre os itens e retornar um resultado. A operação de iteração é universal e o que varia de uma implementação para outra é o tipo de dado tratado e o comportamento a ser processado para cada item.

Mas, por ser universal, a presença repetida desta operação no código que escrevemos fere o princípio DRY (Dont Repeat Yourself). O Java, a partir da sua versão 5, disponibilizou o recurso de for-each, apresentado na Listagem 2.

Listagem 2. Usando for-each


  import java.util.Arrays;
  import java.util.List;
   
  public class IteracaoExemplo2 {
      public static void main(String[] args) {        
          List<String> valores = Arrays.asList("1", "2", "3");
          int soma = 0;
          for(String item: valores) {
              soma += Integer.parseInt(item);
          }        
          System.out.println("Soma = " + soma);
      }    
  }

O código acima é bem mais elegante que o código da Listagem 1, pois agora a própria linguagem trata o Iterator, permitindo focarmos no comportamento que devemos aplicar a cada item para obter o resultado desejado, mas, mesmo assim, isso não nos livra de escrever o laço for.

Mas a partir do Java 8, podemos fazer ainda melhor, como mostra a Listagem 3.

Listagem 3. Usando stream


  import java.util.Arrays;
  import java.util.List;
   
  public class IteracaoExemplo3 {
      public static void main(String[] args) {        
          List<String> valores = Arrays.asList("1", "2", "3");
          final int soma = valores.stream()
                  .map(item -> Integer.parseInt(item))
                  .reduce(0, (a,b) -> a+b);
          System.out.println("Soma = " + soma);
      }    
  }

O código da Listagem 3 faz a mesma coisa que o da Listagem 2, porém ele é escrito de uma forma muito diferente da qual estamos acostumados.

Onde está a operação de iteração e loop? Ela agora está encapsulada na API do Java, não sendo mais necessário escrever essa lógica no nosso programa. Além de eliminarmos a iteração, passamos o “comportamento” que queremos efetuar na lista como parâmetro para dois métodos, no caso:

  • Converter um valor inteiro para String para o método map;
  • Somar os itens da lista para o método reduce.

Acabamos de escrever a versão “declarativa” do programa anterior nessa nova versão. Qual a diferença entre esse estilo e o anterior (o estilo imperativo)?

Na versão imperativa adotamos uma abordagem sequencial, onde definimos passo a passo tudo o que o programa deve fazer, como a codificação do loop e as regras associadas ao processamento dos itens da lista.

Já na programação declarativa trabalhamos com fluxos a partir de composição de métodos e passagem de comportamento via lambdas, como mostra o esquema da Figura 1.

Composição
de métodos

Figura 1. Composição de métodos

Temos o seguinte cenário: o método map irá atuar na Lista de Strings, transformando-a numa Lista de Inteiros (sendo que essa transformação foi “injetada” via parâmetro). A seguir, a saída de map é processada pelo método reduce, que irá aplicar operações de soma (e acumulação) nos elementos da lista e retornará o resultado final, sendo que a operação de soma também foi parametrizada. Podemos então concluir que:

1) O método map sempre aplica uma transformação numa coleção, mantendo a mesma quantidade de elementos, como mostra o esquema da Figura 2.

método map

Figura 2. Método map

2) Já o método reduce “reduz” uma coleção a um único valor.

Observem os ganhos na questão de código limpo da versão declarativa:

  • DRY: Ao abstrair o loop, eliminamos a duplicação dessa operação em nossos códigos.
  • Separação de conceitos: Toda a lógica de “infraestrutura”, ou seja, a criação do loop e sua iteração ficam a cargo da API, enquanto o programador se foca mais no comportamento a ser aplicado nos itens da coleção.
  • Inversão de controle: Injetamos o comportamento que queremos nos métodos funcionais de Stream (map, reduce, filter, etc.) via parâmetro, tornando esses métodos altamente maleáveis.
  • Composição e encadeamento: definimos o comportamento geral do programa a partir da combinação de métodos e lambdas.

Nós já conhecemos todos esses padrões de qualidade e tentamos aplicá-los no nosso dia a dia, sendo que a vantagem da Programação Funcional é que tudo isso já vem embutido. Se pararmos para pensar, em algum momento do processamento do código interno de map via Stream, a API do Java vai ter que fazer uma iteração, afinal os nossos computadores são construídos a partir da arquitetura de Von Neumann, onde uma instrução é processada por vez, ou seja, é imperativa.

O Paradigma Funcional pode ser visto como uma abstração de alto nível sobre a programação imperativa, sendo um conjunto de técnicas e conceitos criados para restringir ao máximo estados mutáveis e side-effects dentro do programa (Programação Restritiva) e facilitar assim a criação de sistemas concorrentes e reativos.

O cenário atual da tecnologia caminha para processadores de vários núcleos, clusters, programação reativa, distribuída, nas nuvens e de Big Data, sendo que a escrita de aplicações concorrentes que tirem o máximo proveito dos recursos de hardware é menos sujeita a erros adotando a Programação Funcional.

Padrões e Paradigma Funcional

A “injeção de comportamento” parece ser uma ideia nova, que só existe nas Linguagens Funcionais, mas isso não é verdade, pois mesmo sem o suporte a Lambdas podemos passar comportamento variável para um método ou classe a partir de interfaces por exemplo. Um caso bem familiar é a interface Runnable, presente na Listagem 4.

Listagem 4. Runnable


  new Thread(new Runnable() {
        @Override
         public void run() {}
  }); 

Já parou para pensar na complexidade de se criar uma thread dentro da máquina virtual Java? Provavelmente não, e a responsável por isso é a classe Thread, que abstrai essa questão criando magicamente uma thread que irá executar nosso código. Só temos que passar no seu construtor um objeto que herda a interface Runnable e implementa seu único método run, conforme a Listagem 4. Ou seja, uma classe Thread pode executar qualquer tipo de comportamento que definirmos, de forma paralela, sem que nós nos preocupemos em como isso é feito de verdade (sendo bem semelhante ao uso dos métodos de Stream visto anteriormente).

Além disso, para implementar comportamento variável podemos usar Design Patterns, como o Strategy, State, Template Method, Visitor ou outro padrão comportamental.

Vejamos uma simples aplicação do Padrão Strategy na Listagem 5.

Listagem 5. Implementando Strategy


  interface Strategy {
      double apply(double a, double b);
  };
    
  class Maximo implements Strategy {
      @Override
      public double apply(double a, double b) {
          return a > b ? a : b;
      }
  };
    
  class Minimo implements Strategy {
      @Override
      public double apply(double a, double b) {
          return a < b ? a : b;
      }
  };
    
  class Media implements Strategy {
      @Override
      public double apply(double a, double b) {
          return (a + b) / 2;
      }   
  };
    
  class Context {
      private final Strategy strategy;
    
      public Context(Strategy strategy) {
          this.strategy = strategy;
      }
    
      public double execute(double a, double b) {
          return this.strategy.apply(a, b);
      }
  };
    
  public class StrategyExemplo {    
      public static void main(String[] args) {        
          Context context = new Context(new Maximo());
          System.out.println(context.execute(3,4));
    
          context = new Context(new Minimo());
          System.out.println(context.execute(3,4));
    
          context = new Context(new Media());
          System.out.println(context.execute(3,4));  
      }    
  }

Veja que definimos uma classe Context e três comportamentos que ela pode adotar, no caso Máximo, Mínimo ou Média de dois valores, e no método main vamos aplicar todos esses comportamentos.

Esse código segue o Open/Closed Principle (princípio de Aberto/Fechado) do SOLID, pois podemos definir vários outros tipos de comportamentos (soma, subtração, divisão, etc.) para a classe Context sem ter que alterar internamente seu código (aberta para extensões e fechada para modificações).

Agora vamos comparar o mesmo exemplo, usando Lambdas, presente na Listagem 6.

Listagem 6. Strategy com Lambdas


  @FunctionalInterface
  interface Strategy {
      double apply(double a, double b);
  };
    
  public class StrategyExemploFuncional {    
      
      public static double execute(double a, double b, Strategy strategy) {
          return strategy.apply(a, b);
      }
      
      public static void main(String[] args) {        
          System.out.println(execute(3, 4, (a,b) -> a > b ? a : b ));
          System.out.println(execute(3, 4, (a,b) -> a < b ? a : b ));
          System.out.println(execute(3, 4, (a,b) -> (a + b) / 2 ));
      }    
  }

O código deixa visível a quantidade de verbosidade que eliminamos ao usar o suporte a Lambdas e Interfaces Funcionais do Java 8, gerando código muito mais limpo e conciso.

Um dos maiores obstáculos das versões anteriores é a verbosidade da linguagem, que muitas vezes até desencoraja o uso de padrões por parte de alguns programadores, mas mesmo assim, é possível programar “comportamento parametrizado” e injetá-lo através de interfaces e Padrões de Projeto.

O suporte a Lambdas do Java 8, portanto, não inaugurou a técnica de “comportamento injetável”, mas possibilita a aplicação de padrões e princípios SOLID de uma forma muito mais fácil do que nas versões anteriores, sendo que o padrão Strategy (e também o Template Method) é um importante item do Paradigma Funcional, pois métodos como map, filter, reduce, flatMap, sorted, collect usam o conceito de receber comportamento parametrizado, sem ferir o princípio de Aberto/Fechado.

Todos esses métodos citados anteriormente são conhecidos no meio funcional como sendo higher-order functions. Apesar do nome pomposo, ele apenas se refere a métodos que recebem expressões lambdas como parâmetros ou retornam lambdas. Mas, “por baixo dos panos”, o Java transforma todas as expressões lambdas em classes concretas para manter a compatibilidade com as versões anteriores, sendo, portanto, apenas “açúcar sintático” para reduzir a verbosidade da linguagem.

Imutabilidade e Side-Effects

Se na programação Orientada a Objetos a imutabilidade já é recomendada, na Programação Funcional ela é fundamental.

Na Programação Funcional, como podemos deduzir pelo nome, os blocos de construção fundamentais são as funções, que idealmente devem ter o mesmo comportamento das funções matemáticas, ou seja, o resultado de uma função é determinado unicamente pela sua(s) entrada(s), sem outros fatores externos que possam alterar o resultado. Além disso, ela não deve produzir efeitos colaterais, como por exemplo:

  • Alterar o estado de uma variável de entrada (por exemplo: objetos mutáveis);
  • Alterar o valor de uma variável externa a ela;
  • Produzir algum efeito de I/O (escrever em arquivo, etc.).

Funções que seguem essas premissas são conhecidas como funções puras e são o objetivo principal das linguagens funcionais. Percebam o quanto as funções puras estão interligadas com as melhores práticas de programação:

  • Uma função deve realizar apenas uma tarefa (Princípio da Responsabilidade Única);
  • Novos comportamentos são criados a partir da composição de funções mais básicas (reaproveitamento de código);
  • Funções puras são thread-safety, pois não geram efeitos colaterais e, portanto, podem ser usadas seguramente em programação concorrentes e dependendo da situação, até paralelizadas;
  • Possuem baixo acoplamento e alta coesão.

Além disso, as funções puras são bem modulares. Vejamos um exemplo real com os comandos Unix.

Existem diversos tipos de ferramentas de shell no Unix: cat, find, sed, awk, grep, tail, head, etc. Cada um desses programas desempenha um papel bem definido, e podemos fazer vários tipos de combinação entre eles, gerando diversos tipos de funcionalidade jamais previstas pelos criados dessas ferramentas, como mostra a Figura 3.

Pipeline
de comandos Unix

Figura 3. Pipeline de comandos Unix

No modelo dos comandos Unix apresentado, a saída de um aplicativo torna-se a entrada do outro. Isso é bem similar ao diagrama da Figura 1, e é exatamente isso. No Java 8 podemos fazer esse “pipeline” através de Stream, como mostra a Listagem 7.

Listagem 7. “Pipeline”


  import java.util.ArrayList;
  import java.util.List;
   
  public class Pipeline {    
      
      public static void main(String[] args) {        
          
          List<Integer> list = new ArrayList<>();
          for(int i = 0; i < 100; i++) list.add(i);
          
          // Pipeline
          int soma = list.stream()
              .filter(i -> i % 2 == 1)
              .map(i -> i * 2)
              .reduce(0, (a,b) -> a + b);
                  
          System.out.println("Soma = " + soma);
      }    
  }

Pipeline

Figura 4. Pipeline

A Figura 4 traduz nitidamente o comportamento do programa da Listagem 7:

  • O método filter atua sobre a lista, aplicando o comportamento de só permitir valores ímpares;
  • O método map multiplica cada elemento da lista de ímpares, gerados pelo filter, por 2;
  • O método reduce soma todos os elementos da lista gerada por map.

O comportamento do programa Java é idêntico ao pipeline dos comandos Unix, pois a partir da composição de métodos e lambdas, foi possível montar a lógica declarativa do programa.

A composição de funções nos lembra o modelo “lego” (ver seção Links), onde a criação de um programa é vista como a montagem de blocos básicos para gerar estruturas mais complexas.

Para evitarmos ao máximo criar funções impuras, trabalhar com objetos imutáveis no Java é fundamental pois, conforme visto no artigo de Programação Restritiva (ver seção Links), a imposição é verificada pelo compilador, garantindo que o programador não cometa o erro de alterar o estado de um objeto passado como argumento para o método.

A classe Stream é uma mônada (ver seção Links), assim como Optional e CompletableFuture, sendo que o aspecto mais interessante disso em Java é a questão da composição, pois as chamadas aos métodos filter e map no exemplo anterior retornam um Stream, ou seja, a partir da mônada inicial list.stream vamos aplicando métodos e gerando outras mônadas Stream até que a operação de reduce a converta num valor inteiro. No caso de Optional e CompletableFuture, as operações envolvendo flatMap e map permitem escrever código declarativo bem mais conciso para encadear vários tipos de operações sobre elas.

Ocultando complexidade

Ao trabalharmos com Stream nos preocupamos mais em como programar o comportamento, deixando toda a parte do loop e controle para a API do Java, sendo isso muito similar ao que fazemos com a Thread: deixamos que a API cuide dos aspectos mais complexos e nos deixa focar mais nas regras de negócio.

Deixar esses detalhes a cargo da API do Java e dos engenheiros da Oracle permite que eles otimizem ao máximo esses códigos, aplicando diversas técnicas complexas para melhorar performance, consumo de memória, paralelismo, etc. Vejamos o exemplo da Listagem 8.

Listagem 8. O Conjunto dos números naturais


  import java.util.stream.IntStream;
   
  public class InfiniteExample {
   
      public static void main(String[] args) {
          long sum = IntStream.iterate(0, n-> n+1).sum();
          System.out.println("sum = " + sum);
      }
  }

Veja que estamos utilizando o método iterate de IntStream para gerar a representação dos números naturais (0, 1, 2, 3, 4...) através da função lambda n - > n + 1 (sendo 0 o elemento inicial, passado como primeiro parâmetro), e somando todo o conjunto via método sum. Como o conjunto de números naturais é infinito, esse programa nunca encerrará.

Agora vamos aplicar o método limit, como mostra a Listagem 9.

Listagem 9. Usando limit


  import java.util.stream.IntStream;
   
  public class FiniteExample {
   
      public static void main(String[] args) {
          long sum = IntStream.iterate(0, n-> n+1)
                          .limit(4)                        
                          .sum();
          System.out.println("sum = " + sum);        
      }
  }

Veja que estamos limitando o conjunto a quatro elementos, no caso 0|1|2|3, e somando-os a partir de sum, obtendo o resultado 6. Observe que o código do Stream leva em consideração não só o método iterate, mas também o limit, ou seja, a avaliação da sentença não é linear, sequencial, como no modo imperativo. O código de Stream lida de forma tardia com o encadeamento de métodos, escondendo os detalhes mais complexos dessa técnica dos desenvolvedores.

Ou seja, ao adotarmos o estilo declarativo de programação temos a certeza que o código de infraestrutura será o mais performático possível, sem que possíveis alterações futuras na JVM ou na linguagem impactem na reescrita do nosso código, principalmente no que tange a melhorias no suporte ao paralelismo do Stream. O mesmo vale para as mônadas citadas anteriormente, principalmente CompletableFuture, que tem a premissa de garantir que as higher-order functions combinadas não afetem o aspecto assíncrono de suas operações.

Recursão

Na Programação Funcional é utilizado recursão para escrever métodos de natureza iterativa de uma forma mais declarativa. Porém, em Java, usar recursividade pode gerar estouro de pilha.

Listagem 10. Fatorial em Java


  public class Fatorial {
      
      public static void main(String[] args) {
          long fatorial = 10;
          int count = 1;
          long resultado = 1;
          
          while (count <= fatorial) {  
              resultado = resultado * count;  
              count++;  
          }  
          
          System.out.println("Fatorial = " + resultado);
      }
  }

Escrevemos na Listagem 10 um programa para calcular o fatorial de um número positivo (no caso 10), usando o estilo imperativo. Notem que as variáveis resultado e count estão sendo alteradas a cada iteração, ou seja, temos estados mutáveis nas variáveis locais. Programadores funcionais não gostam de variáveis que mudam de estado, logo reescreveriam esse código de forma recursiva, de acordo com a Listagem 11.

Listagem 11. Versão recursiva de fatorial


  public class FatorialRecursivo {
      
      public static long fatorial(final long num) {
          if (num <= 1) {
              return 1;
          } else {
              return fatorial(num - 1) * num;
          }
      }    
      
      public static void main(String[] args) {
          System.out.println("Fatorial = " + fatorial(10));
      }
  }

Agora temos a versão recursiva no código da Listagem 11, que não usa variáveis de controle cujos valores são alterados, além de parecer mais com a definição matemática de fatorial:

fatorial(n) => 1 se n == 1
     n * fatorial(n-1) se n > 1

Essa versão mais declarativa tem um problema: experimente usar o valor 100000 no código imperativo e no recursivo. No método recursivo, teremos um StackOverFlow, enquanto na versão iterativa não, pois funções de natureza recursiva implementadas via iteração são muito mais eficientes em termos de performance e uso de memória do que seus equivalentes recursivos.

No Scala temos o recurso de tail-recursion, que permite escrever um método recursivo anotando-a com @tailrec, assim o compilador irá transformar a versão recursiva em iterativa de forma automática quando for gerar o código compilado. Infelizmente a linguagem Java não tem suporte para esse recurso.

A título de curiosidade, na Listagem 12 temos a implementação de fatorial em Scala com tail-recursion.

Listagem 12. Tail-Recursion no Scala


  import scala.annotation.tailrec 
   
  object Fatorial extends App { 
   
    def fatorial(n: Long): Long = { 
      @tailrec 
      def fatorialAcc(acumulador: Long, n: Long): Long = { 
        if (n == 0) acumulador 
        else fatorialAcc(n*acumulador, n-1) 
      } 
      fatorialAcc(1, n) 
    } 
   
    println(fatorial(10)) 
    println(fatorial(1000000)) 
  }

O código evita o uso de variáveis que mudam de estado e tira proveito do suporte a tail-recursion do Scala para permitir a escrita da versão recursiva que será transformada em iterativa pelo compilador. Porém, não é qualquer função recursiva que pode ser marcada com @tailrec e que será otimizada pelo compilador, mas apenas aquelas onde a chamada recursiva é a última instrução, daí o nome “tail”-recursion.

Veremos na Listagem 13 a mesma implementação de fatorial, mas usando Stream do Java.

Listagem 13. Usando Stream para implementar fatorial


  import java.util.stream.LongStream;
   
  public class FatorialStream {
   
      public static void main(String[] args) {
          
          final long iteracoes = 100000;
          
          final long result = LongStream.iterate(1, n-> n+1)
                          .limit(iteracoes)                        
                          .reduce(1, (a,b) -> a * b);
          
          System.out.println("fatorial =  " + result);
      }    
  }

Essa versão tem um comportamento interessante: ela não gera StackOverflow como a versão recursiva que escrevemos anteriormente em Java. Aqui ressalta-se novamente a vantagem de se usar a API do Java, pois os engenheiros da Oracle podem implementar técnicas complexas para evitar o estouro de pilha de forma transparente para os usuários (se você rodar o programa o resultado será 0, pois fatorial de 100000 é um número muito grande, que estoura a capacidade de um long). Com a classe Stream, podemos implementar muitos algoritmos iterativos de uma forma muito mais elegante e declarativa, portanto, vale a pena investir tempo estudando seus métodos e aplicando refatorações em códigos imperativos que permitam seu uso.

Existe uma forma de implementar o fatorial de uma forma declarativa sem usar Stream e que não gere StackOverFlow no Java? Sim, existe uma técnica chamada Trampoline, que permite escrever funções num estilo declarativo com aspecto recursivo, mas cuja execução será feita de modo iterativo, evitando o estouro de pilha.

No Github do usuário Piotr Kukiełka (ver seção Links) há uma implementação bem interessante de Trampoline envolvendo fatoriais com BigInteger.

Listagem 14. Fatorial com Trampoline de Piotr Kukielka


  import java.math.BigInteger;
   
  class Trampoline<T> {
   
      public T get() {
          return null;
      }
   
      public Trampoline<T> run() {
          return null;
      }
   
      T execute() {
          Trampoline<T> trampoline = this;
   
          while (trampoline.get() == null) {
              trampoline = trampoline.run();
          }
   
          return trampoline.get();
      }
  }
   
  public class FatorialTrampoline {
   
      public static Trampoline<BigInteger> factorial(final int n, final BigInteger sum) {
          if (n <= 1) {
              return new Trampoline<BigInteger>() {
                  public BigInteger get() {
                      return sum;
                  }
              };
          } else {
              return new Trampoline<BigInteger>() {
                  public Trampoline<BigInteger> run() {
                      return factorial(n - 1, sum.multiply(BigInteger.valueOf(n)));
                  }
              };
          }
      }
   
      public static void main(String[] args) {
          System.out.println(factorial(100000, BigInteger.ONE).execute());
      }
  }

O método factorial da Listagem 14 não usa variáveis que mudam de estado, tendo um estilo mais declarativo e falsamente recursivo. O segredo aqui é que, a cada iteração o resultado acumulado e a variável de controle são atualizadas e envolvidas num novo objeto Trampoline, que será novamente criado e atualizado nas próximas iterações do laço até chegar na condição sentinela (n <= 1). Toda a iteração é controlada pela classe Trampoline, através do laço while, ou seja, esta classe oculta toda a lógica imperativa, permitindo a escrita de funções num estilo mais declarativo.

Para ficar mais claro, na Figura 5 segue um diagrama de fluxo para o caso de fatorial(3).

Fluxo
de Trampolines

Figura 5. Fluxo de Trampolines

Vimos nesse artigo que a Programação Funcional aplica (e impõe) muitas técnicas consagradas de boas práticas e código limpo como DRY, Separation of Concerns, SOLID, Design Patterns, composição, pipelines, imutabilidade, funções sem efeitos colaterais, thread safety, programação declarativa, ocultação de complexidade, etc.

Portanto, não se trata de um Paradigma com conceitos estranhos a nossa prática diária, muito pelo contrário, ela apenas incorpora todas essas práticas de uma maneira sólida dentro das linguagens funcionais, impondo restrições em nível de linguagem ou conceitualmente, fazendo com que os programadores sigam mais fortemente esses princípios.

O suporte do Java 8 a alguns desses recursos funcionais simplesmente atesta o compromisso da Oracle em não deixar a linguagem ficar obsoleta, permitindo que a enorme gama de desenvolvedores Java tire proveito das vantagens desse novo paradigma, sendo que o fato do Java ser uma linguagem Orientada a Objetos permite combinações interessantes de conceitos entre esses dois paradigmas.

Obrigado e até a próxima.

Links

Trampoline
https://gist.github.com/pkukielka

Modelo “lego” e boas práticas OO (SOLID, GRASP, etc)
//www.devmedia.com.br/as-leis-do-mundo-dos-objetos-melhores-praticas-em-orientacao-a-objetos/26588

Programação Restritiva
//www.devmedia.com.br/programacao-restritiva-usando-boas-praticas-em-java/32651

Mônadas no Java 8
//www.devmedia.com.br/conheca-o-conceito-monada-no-java/32838