Por que eu devo ler este artigo:Este artigo mostra em detalhes como funcionam as Expressões Lambda (EL), a nova feature que entrará em vigor na próxima versão do JDK e que mudará substancialmente a maneira de desenvolver software escrito em Java.

O conteúdo do artigo é bastante conceitual e tem por objetivo primário demonstrar todo alicerce que suporta uma das maiores atualizações da história do Java e também como as novas features do Java 8 estão posicionadas frente a implementações em outras linguagens, como C# e Scala, que há anos possuem bibliotecas maduras e consolidadas para maximizar a usabilidade das Expressões Lambda.

Após anos esperando, finalmente os desenvolvedores Java terão oportunidade de codificar e manipular estruturas de dados com uma sintaxe muito mais simples e expressiva, utilizando as expressões lambda (EL).

A nova API, proveniente da JSR 335, inicialmente estava prometida para o JDK 7, porém muito antes da especificação ser concebida, já havia diversas outras propostas para implementação de EL desde o ano de 2006, como BGGA e CICE (ver seção Links), mas não se chegava a um consenso de qual direção seguir.

Ao mesmo tempo em que se discutiam as mudanças necessárias no JDK e na JVM para suportar EL, havia pressão para o lançamento do Java 7, pois após quatro anos sem uma nova versão “major”, o Java 6 demonstrava sinais de fadiga e muitas outras novas JSRs como o Project Coin e NIO2 já estavam entregues.

No final de 2010, Mark Reinhold anunciou em seu blog, o “plano B”, que consistia em entregar o Java 7 sem EL e sem o sistema de módulos (Jigsaw), provavelmente as duas features mais aguardadas pela comunidade Java.

Embora sem muitas evoluções que pudessem ser desfrutadas pelos programadores, a versão 7 da JVM veio acompanhada da nova instrução invokedynamic, a qual gerou enormes alterações na JVM e, como veremos, era o ingrediente que faltava para a implementação de EL.

Conheça nosso curso de Lambda Expressions

A implementação da JSR 335 provocou uma das maiores atualizações em termos de código fonte, tanto na JVM como no JDK (compilador e bibliotecas). Para se ter uma ideia, ao enumerarmos as novas classes dos pacotes java.util e java.util.concurrent que dão suporte à JSR 335 no JDK 8, chegamos ao expressivo número de 283 classes. Se somarmos também as classes dos novos pacotes java.util.function e java.util.stream, chegamos a um total de 896 classes a mais no JDK 8 em relação ao JDK 7, como pode ser visto na Tabela 1.

Comparação do número de classes entre o Java 7 e o Java 8
Tabela 1. Comparação do número de classes entre o Java 7 e o Java 8.

Nessa tabela estamos contando cada EL utilizada no código fonte do próprio JDK 8 (build 1.8.0-ea-b113) como uma classe, pois como veremos, efetivamente uma EL substitui uma classe. Note que do total das 896 novas classes, 284 são representadas como EL, ou seja, praticamente um terço do total de classes nesses pacotes já é baseado em EL.

Neste artigo vamos explorar algumas das novas funcionalidades proporcionadas pela JSR 335 e comparar alguns detalhes da implementação com relação a outras linguagens que já fazem uso de técnicas funcionais proporcionadas pelas Expressões Lambda.

A JSR 335

A JSR 335 é uma especificação ampla e ambiciosa. Embora o objetivo central da JSR seja proporcionar a presença sintática de EL em código fonte Java, ao oferecer essa possibilidade para os programadores automaticamente é criado um novo (e imenso) desafio: apresentar uma biblioteca de nível similar às que são encontradas em “produtos concorrentes”, como C# e Scala, que por sua vez possuem classes e estruturas especificamente criadas para se beneficiar de EL.

Para que isto fosse possível, diversas JEPs (JDK Enhancement Proposals) foram criadas para alavancar a JSR 335 e dentre essas, chamam a atenção a JEP 108, que tem por objetivo evoluir o framework de coleções, e a JEP 107, que consiste em oferecer uma linguagem fluente para processamento em lote de estruturas de dados.

Para compreender e fazer melhor proveito de todas as novas features proporcionadas por EL, é necessário examinar o seguinte conjunto de implementações e APIs que orbitam em torno da JSR 335:

  • A sintaxe de Expressões Lambda;
  • SAM Types;
  • Defender Methods;
  • API do pacote java.util.function;
  • Avaliação Lazy, Processamento em Lote e o pacote java.util.stream;
  • Estratégias do Compilador, escopo de Expressões Lambda e a Instrução invokedynamic.

Neste artigo vamos abordar os quatro primeiros tópicos desta lista e na segunda parte mostraremos a engenharia por trás da sintaxe de EL, bem como a API de Streams para o processamento de coleções.

A sintaxe de Expressões Lambda

Em Java, uma EL pode ser utilizada para representar a execução de um bloco de código proveniente de métodos de classes anônimas, que atualmente (até o Java 7) é construído através de classes internas anônimas, como mostrado na Listagem 1.

Listagem 1. Representação de blocos de código via classes anônimas.
 
   public void codeJava7() throws Exception{
    
    ExecutorService pool = //...;
    
    Future<Integer> f = pool.submit(new Callable<Integer>() {
   
     @Override
     public Integer call() throws Exception {
      return 1;
     }
    });
   
    JButton jb = new Jbutton();
    
    //registra Action Listener
    jb.addActionListener(new ActionListener() {
      
     @Override
     public void actionPerformed(ActionEvent e) {
      System.out.println("Perform Action...");
      //...impl
     }
    });
   
   }

Em Java 8, com a nova sintaxe de EL, esse código pode ser rescrito de forma muito mais compacta e elegante, como pode ser visto na Listagem 2.

Listagem 2. Representação de blocos de código via EL.
 
   public void codeJava8() throws Exception{
    
    ExecutorService pool = //...;
   
    Future<Integer> f = pool.submit(() -> 1));
   
    JButton jb = new Jbutton();
   
    //registra Action Listener
    jb.addActionListener((e) -> { 
       System.out.println("Perform Action...");
       //...impl
    });
   }

Esta listagem sumariza, a grosso modo, o que a sintaxe com EL é capaz de omitir do código:

  • Declaração do tipo a ser passado como argumento de um método (Callabe e ActionListener);
  • Declaração dos tipos de argumentos (ActionEvent);
  • Declaração de métodos (call, actionPerformed);
  • Instrução return.

De fato, na maioria das vezes a informação de tipo é redundante e declará-la explicitamente serve apenas para aumentar o volume de código fonte. Como EL eliminam essa verbosidade do código, eventualmente elas são chamadas de “métodos anônimos”.

No geral, uma EL pode ser pensada como a representação da assinatura de um método, que pode possuir N parâmetros de quaisquer tipos e deve retornar um objeto, um tipo primitivo ou nada (void). Em termos de sintaxe, o compilador do Java é capaz de aceitar esta representação em uma das quatro formas apresentadas a seguir:

  • Forma Inline: Esta forma é a mais compacta, que elimina a necessidade de chaves e inclusive a necessidade da instrução return, como mostrado no trecho pool.submit(()-> 1)) da Listagem 2.

    Nesta representação é possível executar diversas instruções desde que o retorno de uma instrução seja diretamente consumido pela próxima instrução, e.g.,() -> Integer.valueOf(“100”).toString().length(). Em resumo, nesta forma não é permitido criar variáveis locais;

  • Forma em Bloco: Esta forma é idêntica ao código de um método, no qual a implementação deve obrigatoriamente estar contida entre { } e é necessário declarar a instrução return, quando a EL estiver representando um método com retorno não void. Toda forma inline pode ser convertida para a forma em bloco, e.g., () -> 1 e () -> {return 1;} são equivalentes
  • Forma de Method Reference: Essa forma consiste em transformar um método já existente em uma expressão lambda e será discutida na seção “Estratégias do Compilador”, na segunda parte do artigo;
  • Forma Tipada: Em casos raros onde há ambiguidade no tipo que uma EL deve representar ao ser passada como argumento para métodos que possuem sobrecargas, o compilador não é capaz de decidir qual tipo de objeto a EL deve substituir.

    Nessas situações é necessário utilizar esta forma para que seja possível realizar a resolução da sobrecarga (Overload Resolution), removendo as ambiguidades do código fonte. A Forma Tipada refere-se ao lado esquerdo (argumentos) de uma EL e deve ser utilizada em conjunto com o lado direito, construído a partir da Forma Inline ou em Bloco.

Como exemplo da Forma Tipada, consideremos a Listagem 3.

Listagem 3. Lambda e sobrecarga de métodos.
 
  interface EventOne {
   long event(int a,String b);
  }
   
  interface EventTwo {
   long event(long a,String b);
  }
   
  class EventConsumer {
   int i;
   long j;
   String k;
   
   public void exec(EventOne e) {
    System.out.println(e.event(i, k));
   }
   
   public void exec(EventTwo e) {
    System.out.println(e.event(j, k));
   }
  }

Na Listagem 3 a classe EventConsumer declara dois métodos com o mesmo nome, isto é, há uma sobrecarga do método exec(). Ao tentar aplicar EL, conforme a Listagem 4, ocorrerá um erro de compilação.

Listagem 4. Ambiguidade na resolução de tipos.
 
   public void runEventConsumer(){
    EventConsumer ec = new EventConsumer();
   
    //Erro de compilação: Qual o tipo de i?
    ec.exec((i,s) -> i*s.length());       
   }

Para corrigir o código da Listagem 4 é necessário prover mais informações a respeito do tipo das variáveis para o compilador decidir se a EL representará uma instância de EventOne ou EventTwo, como na Listagem 5.

Listagem 5. Ambiguidade na resolução de tipos.
 
   public void runEventConsumer(){
    EventConsumer ec = new EventConsumer();
   
    //Forma tipada: dica para que o compilador decida por EventOne
    ec.exec((int i,String s) -> i);              
   
    //Ou executar uma operação de cast
    ec.exec((EventOne)(i,s) -> i);        
   }

Como também mostrado na Listagem 5, além da Forma Tipada de lambdas para resolução de ambiguidades, é possível declarar uma EL prefixada pelo tipo exato que se deseja representar, de modo similar a uma operação de type cast.

A sintaxe com cast deve ser vista como um último recurso, afinal a ideia de EL é eliminar a declaração de tipos! Por exemplo, caso houvesse uma nova sobrecarga como na Listagem 6, a única opção seria declarar explicitamente qual interface está sendo representada através de uma EL.

Listagem 6. Ambiguidade devido à sobrecarga de tipos equivalentes.
 
  //Equivalente a EventOne
  interface EventThree {
   
   long event(int a, String b);
  }
   
  class EventConsumer {
   
   int i;
   long j;
   String k;
   
   public void exec(EventOne e) {
    System.out.println(e.event(i, k));
   }
   
   public void exec(EventTwo e) {
    System.out.println(e.event(j, k));
   }
   
   public void exec(EventThree e) {
    System.out.println(e.event(i, k)* e.event(i, k));
   }
  }
   
   public void runEventConsumer(){
    EventConsumer ec = new EventConsumer();
    
    //Como as assinaturas do método de EventOne e EventThree 
    //são idênticas, é necessário informar ao compilador o tipo 
    //exato que se deseja representar como EL
    ec.exec((EventThree)(i,s) -> i*s.length());         
   }

Para não termos que recorrer às Formas Tipadas ou casts, basta evitar a sobrecarga de métodos. Este recurso deve ser visto como uma funcionalidade disponível para que seja possível aplicar EL em código legado.

Em relação a outras linguagens que já permitem código com EL há algum tempo, como Scala e C#, a sintaxe adotada em Java é muito similar, exceto pela escolha da seta com traço simples (->) ao invés da seta com traço duplo (=>). O que realmente muda e é inovador em Java é o conjunto de tipos que podem ser representados por EL, conhecidos como SAM Types, como veremos a seguir.

SAM Types

Nem tudo pode ser representado por uma EL. Se observarmos com atenção os exemplos das listagens até agora, notaremos que o código que utiliza EL possui duas coisas em comum:

  • As EL tomam o lugar de classes anônimas associadas a interfaces;
  • Essas interfaces declaram apenas um único método (call(), actionPerformed(), etc.).

Essencialmente é este tipo de objeto que pode ser escrito como uma EL e foi rotulado de SAM type. A sigla SAM é uma abreviação de “Single Abstract Method” e um SAM type é qualquer interface que defina apenas um único método abstrato, como por exemplo, a interface Callable. Efetivamente todo código que utiliza classes anônimas oriundas de SAM types pode ser reescrito com a sintaxe de EL.

Atualmente uma classe abstrata, mesmo que defina um único método abstrato, não é considerada um SAM type. Este privilégio é reservado para interfaces apenas e a tendência é que esta restrição seja mantida.

Outra maneira de se referir a um SAM type é o termo Functional Interface (Interface Funcional) e inclusive há uma nova anotação (@FunctionalInterface) para decorar este tipo de interface. O uso da anotação é apenas informativo, isto é, uma EL pode ser utilizada para representar uma implementação de qualquer interface funcional, estando ou não anotada.

A anotação @FunctionalInterface tem uso exclusivo para o compilador gerar mensagens de erro caso seja adicionado um novo método na interface, que deixaria de ser um SAM type.

Na build 1.8.0-ea-b113 do JDK essa anotação é utilizada em 60 interfaces que fazem parte do JDK, porém poderia ser aplicada em um total de 319 interfaces.

Ao contrário do que pode ser feito em Java, algumas linguagens têm um conjunto finito de tipos que podem ser atribuídos à EL, como é o caso de C# e Scala, que definem Function types.

Em Java, a decisão por suportar a conversão de quaisquer SAM types em lambdas foi tomada para que código legado, tipicamente usando classes internas, pudesse ser reescrito sem a necessidade de usar novos tipos.

A desvantagem dessa abordagem, e.g., em comparação com Scala, é que atualmente não é possível declarar métodos utilizando a sintaxe de EL, apenas é possível invocá-los. A Listagem 7 mostra como um método que recebe uma função como parâmetro pode ser declarado em Scala.

Listagem 7. Método em Scala que recebe uma função como parâmetro.
 
  class LambdaAsParam[T] {
   
    def count(p: T => Boolean) : Int = {
      //...impl
    }
  }

Em Java, o tipo a ser passado como parâmetro deve ser escrito explicitamente, como na Listagem 8.

Listagem 8. Método em Java que recebe uma função como parâmetro.
 
    interface SomeSamType<T,U>{
   
    public U nomeDeMetodoQualquer(T param);
     
  }
   
  class LambdaAsParam<T> {
    public int count(SomeSamType<T,Boolean> p) {
      //...impl
    }
  }

Como qualquer SAM Type em Java pode ser representado como uma EL, para que um dia o compilador suporte sintaxe similar à encontrada em Scala, será necessário estabelecer um conjunto padrão de interfaces para assumir o papel de EL na declaração de argumentos de métodos. Em Scala, o compilador “sabe” que o argumento do método count da Listagem 7 refere-se a um objeto do tipo scala.Function1.

Default Methods

Default methods (métodos padrão) ou Extension Methods fazem parte do pacote de novas features do Java 8 e sua função é estender a funcionalidade de interfaces sem quebrar a compatibilidade do código. Qualquer interface agora pode declarar um método com “corpo” (implementação) ao utilizar a palavra reservada default, como mostrado na Listagem 9.

Listagem 9. Interface Funcional com método padrão.
 
public interface Point {

  double x();

  double y();

  default double distanceFrom(Point other) {
    return Math.sqrt(square(x() - other.x()) + square((y() - other.y())));
  }

  //Também é possível declarar métodos estáticos!
  static double square(double a) {
    return a * a;
  }
}

Atualmente o JDK declara um total de 367 métodos padrão, espalhados em 85 interfaces. Deste montante, cerca de 200 métodos foram criados para suporte a coleções, o que é um número razoável se compararmos, por exemplo, com a biblioteca Linq do .NET, que provê 377 extension methods de suporte.

Os métodos padrão obedecem às mesmas regras de métodos implementados em classes e podem ser sobrescritos, por exemplo, caso determinada implementação da interface possa prover um código mais otimizado.

A única restrição associada a métodos padrão é que os mesmos devem ser públicos e virtuais (não podem ser final). Outro detalhe é que métodos padrão não contam para determinar se uma interface é um SAM Type, pois não são abstratos, ou seja, um SAM Type pode conter dezenas de métodos padrão.

A adição de métodos padrão tem como principal motivação revitalizar as bibliotecas do Java, que já têm quase 20 anos de existência, fazendo com que seja possível que interfaces apresentem novos métodos sem quebrar a compatibilidade binária.

Por exemplo, a interface Iterable, base para todas as coleções do Java, agora implementa o método forEach(), conforme a Listagem 10.

Listagem 10. Implementação padrão do método forEach().
 
public interface Iterable<T> {

  Iterator<T> iterator();

  default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
      action.accept(t);
    }
  }
}

Como a interface Collection estende a interface Iterable, automaticamente todas as coleções do JDK podem utilizar o método forEach(), como na Listagem 11.

Listagem 11. Lista utilizando forEach com EL.
 
  void runForEach(){
    List<String> l = Arrays.asList("Joao", "Pedro", "Maria");
    
    l.forEach(n -> System.out.println(n));
  }

Ao comparar os default methods em Java com features similares em outras linguagens, encontramos contrastes interessantes nas implementações. O framework .Net, por exemplo, utilizou uma estratégia bem diferente para adicionar novos métodos em suas interfaces, na qual os extension methods são implementados externamente às interfaces. A Listagem 12 mostra a implementação do método First(), na interface IEnumerable (equivalente ao Iterable do Java) em C#.

Listagem 12. Extension Methods em C#.
 
//Retorna o primeiro elemento de uma “Coleção”
public static T First<T>(this IEnumerable<T> source)
{
  if (source == null)
  throw Error.ArgumentNull("source");

  //Especialização otimizada para listas
  IList<T> list = source as IList<T>;
  if (list != null)
  {
     if (list.Count > 0)
       return list[0];
  }
  else
  {
    using (IEnumerator<T> enumerator = source.GetEnumerator())
    {
      if (enumerator.MoveNext())
        return enumerator.Current;
    }
  }
  throw Error.NoElements();
}

A sintaxe do código em C# da Listagem 12 é muito similar à encontrada em código Java, exceto pela palavra reservada this prefixando o argumento de um método. Isso indica ao compilador do C# que First() deve ser interpretado como um Extension Method e pode ser invocado como se estivesse embutido na própria implementação da interface IEnumerable.

Em C#, entretanto, é necessário “Importar” os Extension Methods a serem utilizados, similar a static imports do Java. Assim, o método First() pode ser invocado como na Listagem 13.

Listagem 13. Extension Methods em C#.
 
  //"Importação" de Extension Methods
  using System.Linq;
   
  namespace JM.Lambda
  {
    class FirstTest
    {
   
      void RunFirst()
     {
        var list = new List<String> { "a", "b" };
   
        //extension method   
        var fst = list.First();
        
        //imprime "a"
        Console.WriteLine(fst);
      }
    }
  }

O código anterior é uma facilidade oferecida pelo compilador do .Net (sugar syntax) e é traduzido efetivamente para uma invocação estática, como exibe a Listagem 14.

Listagem 14. Tradução de Extension Methods para Invocações Estáticas.
 
  private void RunFirst()
  {
    List<string> list = new List<string>();
    list.Add("a");
    list.Add("b");
   
    //Implementação do Extension Method First está contido na classe Enumerable
    string fst = Enumerable.First<string>((IEnumerable<string>) list);
     
    Console.WriteLine(fst);
  }

A vantagem do modelo estático utilizado no .NET é que “qualquer um” pode estender as classes core do Framework, criando sua própria biblioteca de Extension Methods. Em Java, como os métodos estão realmente vinculados às interfaces, não é possível adicionar novos métodos.

Assim, ficamos limitados a trabalhar com o que é oferecido e o máximo que se pode fazer é aguardar (e torcer!) por uma nova versão com os métodos que gostaríamos que estivessem implementados, mas não estão.

Existem duas razões pelas quais o Java tomou uma direção diferente em relação ao C#, ambas ligadas a princípios. A primeira razão para não prover Extension Methods como em .Net é uma imposição do expert group da JSR335, pois segundo o mesmo, a funcionalidade extension methods é um instrumento para que o desenvolvedor de uma determinada biblioteca possa adicionar novos métodos sem quebrar a compatibilidade binária, ou seja, o usuário da biblioteca deve trabalhar em cima das funcionalidades que lhe são providas sem “emular” novos métodos nas interfaces.

A segunda razão está associada a princípios de programação OO. Como default methods têm o mesmo comportamento de métodos virtuais comuns, ou seja, a resolução de qual implementação será invocada é deferida para o tempo de execução, é garantido que a “melhor” implementação sempre será invocada de forma transparente pela JVM.

Com uma abordagem estática, uma implementação melhorada deve ser implementada diretamente no extension method e o argumento deve ser testado para os tipos otimizados, como é feito na Listagem 12.

Em Java, por exemplo, a classe ArrayList sobrescreve o método forEach() para que a iteração seja feita diretamente sobre o Array, sem a necessidade de criar Iterators, como mostra o trecho de código da Listagem 15.

Em uma abordagem cuja otimização é realizada externamente à classe, tal código seria impossível sem quebrar o encapsulamento do campo elementData.

Listagem 15. ForEach() otimizado para a classe ArrayList.
 
  @Override
  public void forEach(Consumer<? super E> action) {
    Objects.requireNonNull(action);
    final int expectedModCount = modCount;
    final E[] elementData = (E[]) this.elementData;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
      action.accept(elementData[i]);
    }
    if (modCount != expectedModCount) {
      throw new ConcurrentModificationException();
    }
  }

Nessa história, quem definitivamente leva vantagem é Scala, pois oferecem as duas possibilidades aos desenvolvedores! Em Scala, default methods com a mesma semântica dos utilizados em Java, podem ser declarados nas estruturas conhecidas como Traits. E extension methods, como utilizados em .NET, podem ser criados a partir da funcionalidade implicit. A Listagem 16 mostra como Scala pode combinar os dois “paradigmas” para evolução de bibliotecas.

Listagem 16. Scala e combinação de Extension/Default Methods.
 
  //"Interface" com método padrão
  trait Printer {
   
    def print(s: String): Any;
   
    //método padrão em Scala, não há necessidade de palavras reservadas
    def printToUpper(s: String) = print(s.toUpperCase());
   
  }
   
  //object em Scala é um atalho para declaração de Singletons
  object PrintUtilities {
   
    implicit def stringExtensions(s: String) = new {
   
      //embaralha os caracteres de uma string
      def shuffle = {
        val c = s.toList;
        var r = Random.shuffle(c);
        val arr = r.toArray;
        //scala não usa return 
        new String(arr)
      };
   
    }
  }
   
  //Referência para a classe que possui o Extension method shuffle
  import PrintUtilities._
   
  //Implementação do Trait Printer
  class PrinterImpl extends Printer {     
   
    //impl de print com shuffle
    def print(s: String) {
      println(s.shuffle);
    }
  }
   
  //classe com método main
  object TestCombinedExtensions extends App {
   
    override def main(args: Array[String]) {
   
      var p = new PrinterImpl()
   
      //Imprime EWOLR ou outra combinação possível 
      p.printToUpper("lower")
    }
   
  }

Como alternativa para desfrutar de extension methods à la C# em Java, existe a ferramenta Project Lombok, que se integra com IDEs e outras ferramentas de build como o Maven, provendo diversas facilidades sintáticas interessantes.

A versão atual do Project Lombok ainda não está integrada com as novas funcionalidades do Java 8, porém o suporte está prometido para uma versão futura.

API do pacote java.util.function

As classes e interfaces do novo pacote java.util.function foram criadas especificamente para dar suporte a outras classes do Java, com total foco para alavancagem de EL. O pacote java.util.function apresenta um total de 41 interfaces funcionais (SAM types), praticamente o quádruplo do número de interfaces contidas no pacote java.awt, que ocupa o segundo lugar com um total de apenas 11 SAM types.

O nome function remete ao paradigma de programação funcional, que tem por objetivo descrever programas através de funções matemáticas, que podem ser representadas, em Java, como métodos que recebem N argumentos (N<=255) e retorna um único resultado.

Ao contrário da programação imperativa, em que blocos de código são executados juntamente com as estruturas de dados pertinentes a determinado ponto do programa, na programação funcional esses blocos são encapsulados em funções.

Como exemplo simples do contraste entre os paradigmas imperativo e funcional, podemos citar a Listagem 11 e sua versão imperativa, na Listagem 17.

Listagem 17. Programação imperativa.
 
  void runForEach(){
    List<String> l = Arrays.asList("Joao", "Pedro", "Maria");
    
   for (String n : l){
     System.out.println(n);
   }
  }

As interfaces do pacote java.util.function e as inovações do Java 8 não têm a pretensão de dar todas as características encontradas em linguagens funcionais “puras” como Clojure e até mesmo Scala, mas extrair do paradigma funcional os padrões mais interessantes e com aplicação imediata para os desenvolvedores.

Dentre os padrões de programação funcional, sem dúvida o que mais se destaca é a capacidade de manipulação e extração de dados de coleções através do trio Filter-Map-Reduce (FMR: Filtrar-Transformar-Sumarizar), que consiste em selecionar um subconjunto de interesse em determinada massa de dados, transformar individualmente cada um dos objetos selecionados e finalmente agregar os valores. Como exemplo desse padrão, consideremos a Listagem 18.

Listagem 18. Exemplo de Filter-Map-Reduce.
 
  public class UnidadeDeNegocio {
   
   String nome;
   boolean ativa;
   float dinheiroEmCaixa;
   
   //gets/sets
  }
   
  public class Organizacao {
   
   List<UnidadeDeNegocio> bus = new ArrayList<UnidadeDeNegocio>();
   
   
   public float totalDinheiroEmCaixa(){
    final Holder<Float> total = new Holder<Float>(0);
    
    //código imperativo misturado com funcional!
    bus.forEach(u-> { 
     
     //filter
     if(u.isAtiva()){
      //map: UnidadeDeNegocio → float
      total.value += u.getDinheiroEmCaixa();
     }
     
    });
    
    //reduce
    return total.value;
    
    //Código funcional com a nova API de streams
    //return (float)bus.stream().filter(c->c.isAtiva()).
    //       mapToDouble(u -> u.getDinheiroEmCaixa()).sum();
   }
  }

O método totalDinheiroEmCaixa() da classe Organizacao tem por objetivo somar o dinheiro em caixa de todas as unidades de negócio pertencentes a uma determinada organização.

Da forma que está implementado, ele faz uso de EL misturado com código de forma imperativa, o que certamente não é o padrão de código que os desenvolvedores da JSR 335 vislumbraram.

O código comentado na última linha do método totalDinheiroEmCaixa() mostra como realmente o trio FMR é no Java 8 e será explorado em mais detalhes na segunda parte do artigo. O que deve ser observado neste método é a parte em que ocorre a transformação, mapToDouble(), na qual uma EL é utilizada para criar um SAM type do tipo ToDoubleFunction, Listagem 19, e que transforma objetos em doubles e não em floats.

Listagem 19. Interface funcional para transformação de objetos em doubles.
 
  package java.util.function;
   
  @FunctionalInterface
  public interface ToDoubleFunction<T> {
    
    double applyAsDouble(T value);
   
  }

O alicerce de tipos funcionais para suportar Lambda Expressions poderia ser baseado em um número pequeno de classes, e de fato, C# baseia-se apenas em 10 “interfaces” para promover a API de EL. Nesta listagem têm-se apenas a “interface” Action<T>, equivalente à interface Consumer<T> do Java e as oito “interfaces” Func<T1,R> à Func<T1,...,T8,R>, que representam funções com até oito argumentos de tipos [T1,...,T8] e retornam um objeto do tipo R.

Em contraste, o Java define apenas funções com até dois argumentos (Function<T,R> e BiFunction<T1,T2,R>) e especializa essas funções para os tipos primitivos int, long e double, com o objetivo de evitar o uso de wrapper types (Integer, Long, Double) que podem comprometer o desempenho devido a operações de “boxing” e “unboxing”.

A escolha por limitar a especialização de funções a apenas estes três tipos se deve ao fato de operações com outros tipos primitivos poderem ser representadas apenas por esses três, como na Listagem 19, onde double é utilizado ao invés de float.

Essa limitação evita, em partes, a explosão do número de interfaces (seriam necessárias mais 35 para especializar todos os tipos!) e também o número de métodos que consomem essas interfaces, no pacote java.util.stream.

Essa especialização de tipos não é necessária em C#, pois a VM e o compilador do framework .Net foram modificados para aceitar qualquer tipo de dados (objetos ou “primitivos”), de forma que uma função de mapeamento T => float possa ser declarada como Func<T,float>. Parte do sucesso da implementação de generics no framework .Net se deve à separação das bibliotecas com e sem código genérico, o que não ocorre em Java, que provavelmente tomará um rumo completamente diferente caso um dia suporte a construção genérica com primitivos.

Scala, apesar de executar sobre a JVM, criou uma maneira bem interessante de especializar código genérico na versão 2.8, através de modificações no compilador (scalac) e da anotação @specialized. Quando um tipo em Scala é especializado, o compilador do Scala cria uma nova versão da classe para cada tipo que é declarado como especializado.

Por exemplo, se criarmos o tipo genérico Holder[T], especializando T para o tipo Float, como na Listagem 20, o código que utilizar instâncias de Holder[Float] trabalhará diretamente com tipos primitivos, ficando livre de operações de boxing e alocação de objetos.

Listagem 20. Especialização de Generics em Scala.
 
  class Holder[@specialized(Float) T] (var value: T){
    
  }
   
  object TestGenerics extends App {
   
    override def main(args: Array[String]) {
      
      //Impl não-especializada (haverá boxing)
      val g = new Holder(1);
   
      println(g.value);
      
      //Impl Especializada
      val h = new Holder(1f);
   
      println(h.value);
    }
  }

Devido à anotação @specialized, o compilador do Scala gera duas classes, uma para quaisquer tipos - primitivos e objetos - e outra especializada para o tipo primitivo float. Em sintaxe Java, o código fonte dessas classes seria representado como mostrado na Listagem 21, onde o tipo especializado herda o tipo genérico, adicionando um novo campo do tipo float, e sobrescreve os métodos declarados na classe pai.

Listagem 21. Código fonte de classe especializada gerado pelo compilador do Scala.

//A classe genérica, “pai”
public class Holder<T> {
  public T value;

  //Método "get" genérico
  public T value() {
    return this.value;
  }

  //Método "set" genérico
  public void value_$eq(T v) {
    this.value = v;
  }

  //Método "get" especializado para floats, gerado pelo compilador.
  //Será sobrescrito na classe especializada
  public float value$mcF$sp() {
    return BoxesRunTime.unboxToFloat(value());
  }

  //Método "set", análogo a get
  public void value$mcF$sp_$eq(float v) {
    value_$eq(BoxesRunTime.<T> boxToFloat(v));
  }
}

//A classe “filha”, especializada para floats
public class Holder$mcF$sp extends Holder<Object> {
    
  //campo gerado pelo compilador
  public float value$mcF$sp;

  //método "get" sobrescrito
  @Override
  public float value$mcF$sp() {
    return this.value$mcF$sp;
  }

  //método "set" sobrescrito
  @Override
  public void value$mcF$sp_$eq(float x$1) {
    this.value$mcF$sp = x$1;
  }
} 

Além de gerar as classes especializadas, o compilador do Scala é capaz de reconhecer a utilização do tipo especializado. Por exemplo, o código Scala da Listagem 22 será efetivamente traduzido para o código Java mostrado na Listagem 23.

Listagem 22. Código Scala com especialização.
 
  class TestGenerics {
   
    def runGenerics() {
      //Versão não-especializada (haverá boxing para int)
      val g = new Holder(1);
   
      println(g.value);
   
      //metodo “set” com boxing
      g.value = 2;
   
      //Impl Especializada para floats
      val h = new Holder(1f);
   
      println(h.value);
   
      //metodo “set” sem boxing
      h.value = 2f;
   
      println(h.value);
    }
  }
Listagem 23. Tradução efetuada pelo compilador do Scala.
 
  public class TestGenerics {
   
   public void runGenerics() {
    // Versão sem especialização
    Holder g = new Holder(BoxesRunTime.boxToInteger(1));
   
    //get retornando object
    System.out.println(g.value());
   
    g.value_$eq(BoxesRunTime.boxToInteger(2));
    
    // Versão especializada para Floats
    Holder h = new Holder$mcF$sp(1.0F);
   
    // get especializado
    System.out.println(h.value$mcF$sp());
   
    // set especializado
    h.value$mcF$sp_$eq(2.0F);
   
    //...
   }
  }

Antes de finalizarmos esta primeira parte do artigo, vamos comentar sobre outros instrumentos utilizados em linguagens funcionais, conhecidos como Aplicação Parcial e Curry, para compararmos como os mesmos podem ser implementados no Java 8. Como mencionamos, o intuito desta versão do Java é disponibilizar “apenas” as facilidades mais interessantes da programação funcional, porém é interessante verificar como construções mais sofisticadas encontradas em outras linguagens podem ser adaptadas para código fonte Java.

Aplicação Parcial e Curry

O conceito de Curry (em homenagem ao matemático Haskel Brooks Curry) consiste em representar a aplicação de uma função de N argumentos através de N aplicações de funções de 1 argumento. No geral, curry, em programação funcional, permite que combinações sejam feitas com os argumentos e promove o reuso de funções.

Infelizmente a API que vem com o JDK 8 define apenas funções que recebem até dois argumentos (BiFunction) e não oferece um método curry padrão. Porém, dada uma BiFunction, é relativamente trivial criar sua versão curried, como mostra a Listagem 24.

Listagem 24. Versões curried de uma BiFunction.
 
  public void testCurry() {
   
    BiFunction<Integer, Integer, Integer> mult = (i, j) -> i * j;
   
    Function<Integer, Function<Integer, Integer>> curried = i -> j -> mult
      .apply(i, j);
   
    List<Integer> numeros = Arrays.asList(1,2,3,4,5);
   
    Function<Integer, Integer> vezes2 = curried.apply(2);
    
    //(1,4,6,8,10) – map aceita como argumento uma Function apenas, não uma BiFunction
    List<Integer> numerosVezes2 = numeros.stream().map(curried).collect(toList());
  }

Mas para que isto serve na prática? No geral, uma biblioteca com EL é construída com funções de ordem superior, isto é, funções que recebem uma função como argumento, como por exemplo, o método map() da classe Stream, que recebe uma Function como parâmetro.

Assim, se tivermos um mecanismo simples para adaptar uma interface funcional em formas compatíveis com as aceitas por funções de ordem superior, podemos reutilizar estas funções sem ter que reescrever código.

No exemplo da Listagem 24, se já possuímos a função de dois argumentos mult, ao utilizar sua representação curried e fixar um dos argumentos, obtemos uma nova Function que pode ser utilizada para transformar uma lista em seus múltiplos de dois, que aceita apenas funções de 1 argumento.

Essa técnica é conhecida como Aplicação Parcial, que consiste em fixar K argumentos de uma função de ordem N (N>=K), produzindo uma função de ordem N-K. Na Listagem 24 temos N=2 e K=1, portanto o resultado da aplicação parcial foi uma função com um argumento.

Uma das dificuldades em oferecer currying e Aplicação Parcial de forma “nativa” é o número de combinações que podem ser criadas a partir de uma função.

No exemplo da Listagem 25, tanto faz a ordem dos parâmetros na representação curried, isto é, ao invés de i -> j -> mult.apply(i, j) poderíamos ter escrito j -> i -> mult.apply(i, j), pois multiplicação é uma operação comutativa.

Se fosse uma divisão, entretanto, a ordem seria relevante. Em Java só é possível criar uma Aplicação Parcial “da esquerda para a direita”, isto é, fixando-se apenas os primeiros argumentos de uma função.

Assim, se temos uma função de divisão (i, j) -> i / j, e desejamos criar uma Aplicação Parcial para o divisor, devemos escrever a versão curried da função como mostrado na Listagem 25.

Listagem 25. Curry: Ordem importa para funções não comutativas.
 
  public void testCurryDiv() {
   
     BiFunction<Float, Float, Float> div = (i, j) -> i / j;
   
    Function<Float, Function<Float, Float>> curryDivisor = j -> i -> div
      .apply(i, j);
   
    Function<Float, Float> percent = curryDivisor.apply(100f);
   
    System.out.println(percent.apply(10f)); //0.1
   
  }

Scala já trata “nativamente” o problema da ordem dos argumentos e permite criar Aplicações Parciais arbitrárias, com e sem currying, possibilitando também a substituição de argumentos por tipos menos abrangentes, como mostra o trecho da Listagem 26.

Listagem 26. Código em Scala com Aplicações Parciais e Curry.
 
   class Divisions {
   
    //Função de dois argumentos: (Float,Float) => Float
    def div(i: Float, j: Float): Float = i / j;
   
    //Suporte nativo para currying
    //cria uma 'função de uma função' Float => (Float → Float) 
    def divCurried = (div _).curried;
   
    //Cria uma função de 1 argumento: Float => Float
    def percentFromCurried = divCurried(_: Float)(100);
   
    //Cria uma função de um argumento: Float => Float
    def percentFromOriginal = div(_: Float, 100);
    
    //Substituição por um tipo menos abrangente (Int < Float): OK!
    //Cria uma função Int → Float
    def percentInt = div(_: Int, 100);
    
    //Erro de compilação Double > Float
    //def percentDouble = div(_: Double, 100);
   
  }

A Listagem 27 mostra como poderiam ter sido implementadas as operações de curry, para funções com dois ou três argumentos, em Java. Como mencionamos, essa implementação deve levar em conta a ordem dos argumentos, ou seja, para oferecer todas as possibilidades de currying de uma função, precisaríamos de N! (N fatorial) métodos. Para uma função de cinco argumentos teríamos 120 possibilidades de curry! Certamente essa é uma das razões pelas quais curry não foi disponibilizado como um método default.

Listagem 27. Curry e Uncurry em Java.

   public interface BiFunction<T, U, R> {
   
   default Function<T, Function<U, R>> curry() {
    return t -> u -> apply(t, u);
   }
   
   static <T, U, R> BiFunction<T, U, R> uncurry(
     Function<T, Function<U, R>> curried) {
    return (t, u) -> curried.apply(t).apply(u);
   }
  }
   
  //Não Existe esta interface funcional no JDK
  public interface Function3<T, U, V, R> {
   
   //Função com 3 argumentos 
   R apply(T t, U u, V v);
   
   //curry para 'ordem' (t,u,v). Seriam necessários também
   //métodos para ordem de aplicação (t,v,u), (v,t,u), 
   //(v,u,t), (u,t,v) e (u,v,t)
   default Function<T, Function<U, Function<V, R>>> curry() {
    return t -> u -> v -> apply(t, u, v);
   }
   
   static <T, U, V, R> Function3<T, U, V, R> uncurry(
     Function<T, Function<U, Function<V, R>>> curried) {
    return (t, u, v) -> curried.apply(t).apply(u).apply(v);
   }
  }

Neste artigo vimos boa parte da fundação que foi necessária criar para que a feature de Expressões Lambda pudesse ser disponibilizada para o Java 8 e comparamos de que forma a sintaxe e também alguns detalhes de implementação, como extension methods, são representados em outras linguagens que já oferecem suporte à EL.

Embora EL aparentem ser “apenas” um poderoso recurso sintático, capaz de reduzir consideravelmente a quantidade do código fonte que deve ser escrita, a sua introdução abre novas portas para que o Java possa passar a ser desenvolvido com toques de programação funcional. Todo trabalho realizado ao redor da JSR 335 definitivamente revitaliza a linguagem, aproximando-a de outras tanto em funcionalidades como em facilidades para os desenvolvedores.

Continuando a análise de Expressões Lambda (EL) em Java, nesta segunda parte do artigo vamos verificar como as mesmas podem auxiliar no processamento de coleções de dados com a expressividade e simplicidade da programação funcional.

Vamos explorar alguns métodos da nova API de Streams e apresentaremos uma visão de alto nível das técnicas envolvidas para realização de processamento paralelo, as quais utilizam o estado da arte de programação concorrente baseadas nos artefatos do framework Fork/Join.

Deste modo, este artigo é útil para desenvolvedores que desejam entender como utilizar as EL juntamente com a nova API de Streams com o objetivo de processar massas de dados empregando um código mais conciso e elegante, assim como conferir como a nova biblioteca torna trivial a tarefa de paralelizar um processamento sequencial

Na primeira parte do artigo avaliamos os componentes que foram criados para suportar a sintaxe e criação de EL no Java, as quais permitirão um estilo muito mais compacto e elegante de codificação, com nuances de programação funcional.

Nesta segunda parte vamos focar na API de Streams, criada especificamente para alavancar o uso de EL e que modificará completamente a maneira que estamos acostumados a lidar com coleções de dados.

A biblioteca de Streams chegou para suprir, definitivamente, a antiga necessidade dos desenvolvedores Java de possuírem em seu arsenal uma API de linguagem fluente e “universal” para processamento de dados.

Esta API concentra-se em disponibilizar o trio de operações Filter, Map e Reduce (FMR) através de funções de ordem superior, que nada mais são que métodos que recebem uma interface, tipicamente do pacote java.util.function, como parâmetro.

Além disso, veremos como a biblioteca de Streams promove a utilização do framework de processamento paralelo Fork/Join, entregue no JDK 7, porém ainda não tão popular quanto os ThreadPools do framework Executors, disponível desde o JDK 5.

Avaliação lazy, processamento em lote e o pacote java.util.stream

Enquanto o pacote java.util.function provê os insumos para construir uma biblioteca de programação funcional, o pacote java.util.stream faz uso desses ingredientes para criar de fato as receitas que os agregam para produzir algo muito maior que a soma de cada parte.

Como o próprio nome diz, este pacote é dedicado ao tratamento do fluxo de dados, que usualmente provém de coleções, porém as abstrações construídas neste pacote não assumem nada em relação à fonte dos dados, as quais podem ter como origem um arquivo ou tabela no banco de dados.

Embora as ideias para implementação do trio FMR já estivessem relativamente amadurecidas no início de sua implementação no JDK, o código que provia estas funcionalidades habitava o próprio pacote java.util e boa parte da implementação estava consolidada com métodos padrão na interface Iterable e suas extensões como List e Set.

Ao perceberem que as antigas interfaces estavam ficando extremamente poluídas devido ao abuso de métodos padrão, os desenvolvedores da JSR 335 decidiram criar a abstração contida no pacote java.util.stream, separando “fisicamente” as centenas de novas classes e criando alguns poucos métodos padrão nas classes do pacote java.util que servem como ponte do antigo mundo das coleções para o novo mundo dos Streams.

Na versão atual, a interface Iterable define apenas um método padrão utilizado para suportar as funcionalidades de FMR, spliterator(), cujo significado veremos logo adiante, e a interface Collection define os novos métodos stream() e parallelStream(), que permitem que qualquer coleção seja adaptada para um Stream.

A implementação de Filter-Map-Reduce na biblioteca de Streams segue três princípios básicos:

  • As operações devem ser Lazy, sempre que possível;
  • As operações devem poder ser encadeadas “à vontade”;
  • Deve ser possível converter processamento sequencial para processamento paralelo de forma transparente.

O primeiro princípio, de avaliação Lazy, está relacionado a minimizar o footprint de memória ocasionado por operações do tipo Filter e Map. Isto quer dizer que dada uma massa de dados, contendo, por exemplo, 100.000 objetos, e a operação Filter selecionar apenas 20.000 objetos para serem posteriormente transformados por uma operação Map, nenhum objeto deverá ser criado em função da quantidade de objetos filtrados e/ou mapeados, se as funções de Filter e Map não provocarem a criação de novos objetos.

Para entender este conceito, consideremos, por exemplo, uma coleção de objetos do tipo Pessoa (Listagem 1). Em determinado momento, poderíamos ter de implementar a funcionalidade de selecionar apenas pessoas entre 20 e 30 anos, cujo nome inicia com a letra 'A' e calcular a soma, a média, o maior e o menor salário das mesmas.

Existem muitas maneiras de realizar esta filtragem e calcular esses valores atrelados ao salário. Talvez a mais intuitiva, como faríamos sem ter experiência em desenvolvimento, seria como mostrado na Listagem 2.

Listagem 1. Código da classe Pessoa.

  public class Pessoa {
   
   String nome;
   int idade;
   int salario;
   List<Imovel> imoveis;
   
   //gets e sets
  }
   
  public class Imovel {
   
    int valor;
    int area;
   
   //gets e sets
  }
Listagem 2. Código para filtrar uma coleção de Pessoas.

    public class TestFiltro {
   
   public void testFiltroComCopia() {
    Collection<Pessoa> pessoas = Arrays.asList(
        new Pessoa("Andre", 42,18000),
        new Pessoa("Joao", 20,2000),  
        new Pessoa("Alessandra", 21,500), 
        new Pessoa("Antônio", 28),11500)
    );
   
    Collection<Pessoa> filtrada = filtrarPorInicialEIdade(pessoas, 'A', 20,30);
   
    int numFiltrada = filtrada.size();
    int somaSalario = 0;
    int maxSalario = 0;
    int minSalario = 0;
    double mediaSalario=0;
   
    if(numFiltrada > 0){
     //consome a coleção filtrada: {[Alessandra,21,500],[Antônio,28,11500]}              
     for(Pessoa p : filtrada) {
      int sal = p.getSalario();
      
      if(sal > maxSalario){
       maxSalario = sal;
      }
   
      if(minSalario == 0 || sal < minSalario){
       minSalario = sal;
      }
      somaSalario += p.getSalario();
     }
   
     mediaSalario = somaSalario/numFiltrada;
    }    
    
   }
   
   private Collection<Pessoa> filtrarPorInicialEIdade(
     Collection<Pessoa> pessoas, char inicial, int idadeMin, int idadeMax) {
   
    Collection<Pessoa> result = new ArrayList<>();
   
    for (Pessoa p : pessoas) {
     if (aceita(p, inicial, idadeMin, idadeMax)) {
      result.add(p);
     }
    }
   
    return result;
   }
   
   private static boolean aceita(Pessoa p, char inicial, int idadeMin,
     int idadeMax) {
    return p.getNome().charAt(0) == inicial && //
      p.getIdade() >= idadeMin && //
      p.getIdade() <= idadeMax;
   }
  }

O código da Listagem 2 apresenta dois problemas:

  1. Os objetos são copiados da coleção original para uma nova coleção, possivelmente sem necessidade. Se todos forem selecionados, teremos uma réplica da coleção original;
  2. O código que realiza a filtragem deve decidir a implementação da coleção que irá retornar (ArrayList, no caso).

Há anos nos deparamos com esse tipo de situação e até recentemente uma alternativa 'viável' era utilizar a biblioteca Guava, que define um idioma funcional e uma API de avaliação 'lazy' de elementos de uma coleção, para evitar cópias como na Listagem 2.

O mesmo código, com a API disponibilizada pelo Guava, poderia ser escrito como expõe a Listagem 3.

Listagem 3. Avaliação Lazy com Guava.

  public void testFiltroLazyGuava() {
   Collection<Pessoa> pessoas = ...//[Mesma coleção da Listagem 2]
   
   //funcional
   Iterable<Pessoa> filtrada = Iterables.filter(pessoas,
     new Predicate<Pessoa>() {
      @Override
      public boolean apply(Pessoa p) {
       return aceita(p, 'A', 20, 30);
      }
     }); 
   
   Iterable<Integer> salarios = Iterables.transform(filtrada,new Function<Pessoa,Integer>() {
      @Override
      public Integer apply(Pessoa p) {
       //note que há boxing!
       return p.getSalario();
      }
     }
   //Imperativo!
   for(Integer salario : salarios) {
    //impl similar à listagem 2
   }
  }

A classe Iterables do Guava define alguns métodos utilitários para lidar com coleções de forma lazy. O código da Listagem 3 só irá invocar o método apply() da interface Predicate à medida que consumirmos os objetos Pessoa filtrados, os quais serão subsequentemente transformados em números sob demanda, isto é, o método apply() da interface Function só será invocado à medida que a iteração sobre salarios for realizada. Essencialmente, filter() terá o efeito de 'pular' os objetos que não estejam de acordo com o critério de seleção, no caso André e João.

A vantagem nesse caso é que não será feita nenhuma cópia dos objetos pessoa da coleção original. Porém, ao avaliarmos com detalhe, o código ainda tem seus 'defeitos'. Um deles está associado ao uso de inner classes, que será discutido na próxima seção e o outro à inabilidade de se encadear novos filtros e executar a operação Reduce, possivelmente em paralelo, o que provoca a mistura do código funcional com código imperativo.

Nota: A API do Guava oferece a possibilidade de trabalhar com estruturas fluentes, ou seja, estruturas que permitem, por exemplo, aplicar diversos filtros de maneira encadeada na forma itr.filter(pred1).filter(pred2)..., porém não há suporte para paralelismo e é necessário referenciar a interface FluentIterable, o que deixa o código “preso” à biblioteca.

Ok, mas afinal, como fica o código com a biblioteca de Streams e EL? Embora a biblioteca disponibilize métodos como sum() (soma) e average() (média) para Streams, a forma mais simples de obter todos os valores desejados “de uma vez” é utilizar a classe IntSummaryStatistics, como mostra a Listagem 4.

Listagem 4. Refatoração de código com a API de Streams.

public void testFilterMapReduce() {

   List<Pessoa> pessoas = Arrays.asList(
         new Pessoa("Andre", 42, 18000), 
         new Pessoa("Joao", 20, 20000),
         new Pessoa("Alessandra", 21, 500),
         new Pessoa("Antônio", 28, 11500)
      );

   IntSummaryStatistics stats = pessoas
            .stream() //Converte uma coleção para um Stream
            .filter(p -> p.getNome().charAt(0) == 'A' && p.getIdade() >= 20
                  && p.getIdade() <= 30)//EL “inline” cria o Filtro
            .mapToInt(p -> p.getSalario())//Mapeamento Pessoa→int (não há boxing!)
            .summaryStatistics();

   Assert.assertEquals(stats.getMin(), 500);
   Assert.assertEquals(stats.getMax(), 11500);
   Assert.assertEquals(stats.getSum(), 12000);
   Assert.assertEquals(stats.getAverage(), 6000d, 0);
}

Mas qual o motivo da existência da classe IntSummaryStatistics? Esta classe serve para otimizar especificamente o cenário em que se deseja obter múltiplos resultados após filtrar e mapear um Stream. E se desejarmos reutilizar um Stream, não poderíamos escrever o código como na Listagem 5?

Listagem 5. Consumindo um stream mais de uma vez.


@Test(expected = IllegalStateException.class)
public void testFilterMapReduce() {

   List<Pessoa> pessoas = asList(
         new Pessoa("Andre", 42, 18000), 
         new Pessoa("Joao", 20, 20000),
         new Pessoa("Alessandra", 21, 500),
         new Pessoa("Antônio", 28, 11500)
         );

   IntStream stream = pessoas
            .stream()
            .filter(p -> p.getNome().charAt(0) == 'A' && p.getIdade() >= 20
                  && p.getIdade() <= 30)//
            .mapToInt(p -> p.getSalario());

   //OK! Primeira Operação Terminal
   Assert.assertEquals(stream.min().getAsInt(), 500);

   //Erro! Stream já foi consumido, deve lançar IllegalStateException!
   Assert.assertEquals(stream.max().getAsInt(), 11500);
      
   throw new RuntimeException("Não deveria chegar aqui!!!");
}

A resposta é “Não!”. Um Stream é composto por dois grupos de operações, rotuladas intermediárias e terminais. Operações intermediárias são métodos que têm como característica retornar um novo Stream para que novas operações intermediárias (e.g. filter e mapToInt) possam ser encadeadas de maneira fluente. Uma operação terminal (e.g. min e max) agrega de alguma maneira os resultados de um Stream para produzir um objeto ou valor.

Uma vez invocada uma operação terminal, o Stream não poderá mais ser modificado por novas operações intermediárias ou consumido por novas operações terminais.

Formalmente, na biblioteca de Streams, Filter e Map são consideradas casos particulares de operações intermediárias e Reduce é um caso particular de operação terminal, mas quando falamos em FMR estamos efetivamente pensando em operações intermediárias e terminais, que abrangem o escopo de operações de FMR e acrescentam outros tipos de operações.

Como veremos adiante, por exemplo, a operação forEach é também um exemplo de operação terminal que não é uma redução.

A API de Streams provê muitos métodos e especializações de suas interfaces. Deste modo, antes de tentar entender como funcionam as novas operações e prosseguirmos para os exemplos práticos da biblioteca, convém analisar a hierarquia dos componentes de alto nível que constituem o coração da API, exibida na Figura 1.

Hierarquia e operações fundamentais
de Streams
Figura 1. Hierarquia e operações fundamentais de Streams.

Nesta hierarquia, quem oferece as operações de FMR é a interface Stream. Como mencionamos na primeira parte do artigo, a API do pacote java.util.function especializou algumas interfaces funcionais para os tipos primitivos int, long e double e isto é refletido no pacote java.util.stream com a criação de Streams especializados para consumir estes tipos de dados em conjunto com as interfaces especializadas.

Estes Streams “numéricos” embora herdem as operações de Stream, criam sobrecargas das operações de FMR para evitar boxing sempre que possível, como mostra o trecho da Listagem 6. Além disso, estas interfaces definem métodos específicos para tipos numéricos, como sum() e average() e o método boxed(), que transforma, e.g. um IntStream em um Stream e cria, em conjunto com o método mapToInt() de Stream, uma transformação bidirecional.

Listagem 6. Sobrecarga para tipos especializados.

public interface IntStream extends BaseStream<Integer, IntStream> {

    //sobrecarga de filter para ints, para evitar
    //boxing com o tipo genérico Predicate<Integer>   
    IntStream filter(IntPredicate predicate);

    //Outras sobrecargas e métodos para Números...
}
 

O diagrama da Figura 1 exibe também a interface Spliterator. Esta interface é quem de fato será responsável por consumir os elementos do Stream com uma operação terminal, que será passada como parâmetro para o método forEachRemaining.

Este parâmetro consiste de uma implementação da interface Consumer, que é provida pela API e é aplicada sobre todos os elementos do Stream “gerenciados” pelo Spliterator. Quando o Stream é sequencial, e.g., quando o mesmo é criado a partir de uma coleção com a invocação do método stream(), um único Spliterator será utilizado para consumir todos os dados da coleção.

Em contrapartida, veremos que quando o processamento é paralelo, um Spliterator ganha outra responsabilidade, a qual consiste em realizar o particionamento do Stream através do método trySplit(), que é o alicerce que sustenta o processamento dos dados de um Stream por múltiplas Threads.

A seguir vamos apresentar alguns exemplos da utilização da nova API de Streams. No total, a interface Stream define 34 operações de suporte à FMR, sendo 17 intermediárias e 17 terminais e operações equivalentes para tipos numéricos específicos. Como são muitas possibilidades, mostraremos exemplos com as combinações mais interessantes.

Operações intermediárias

As operações intermediárias caracterizam-se por permitir operações encadeadas através da transformação de um Stream em outro. O Stream é uma interface que apresenta, além dos métodos básicos filter() e map(), diversos outros métodos para transformação de objetos.

Dentre os métodos de mapeamento podemos destacar os flatMappers, que nos permitem percorrer “coleções de coleções” ou grafos de coleções como se fossem uma única coleção. Tomando como exemplo as classes Pessoa e Imovel, da Listagem 1, como poderíamos fazer para calcular a soma do valor de todos os imóveis de determinado grupo de pessoas?

A Listagem 7 mostra o contraste das implementações imperativa e com as facilidades da API de Streams.

Listagem 7. Iterando em Grafos e Coleções com Flat Mappings.

public void testFlatMapping() {

   List<Pessoa> pessoas = Arrays.asList(
         new Pessoa("Andre", 42, 18000), 
         new Pessoa("Joao", 20, 20000),                           new Pessoa("Alessandra", 21, 500),
         new Pessoa("Antônio", 28, 11500)
      );

   int defaultVal = 1000 * 1000;
   //Todos com 1 imóvel no valor de 1000000
   pessoas.forEach(p -> p.add(new Imovel(defaultVal, 20)));

   int sum=0;

   //imperativo   
   for (Pessoa pessoa : pessoas) {
      List<Imovel> imoveis = pessoa.getImoveis();
      
      for (Imovel imovel : imoveis) {
         sum += imovel.getValor();
      }
   }

   Assert.assertEquals(pessoas.size()*defaultVal, sum);
      
   //Alternativa 1: Obter um Stream<Imovel> com flatMap e em seguida obter os valores
   //com um segundo mapeamento. Note que o compilador javac não é capaz de inferir o   //tipo Imovel após a invocação de flatMap, o que faz necessária a inclusão da “dica” <Imovel>   //para que ele (o compilador) entenda que a variável i em mapToInt é do tipo Imovel
   sum = pessoas.stream()
            .<Imovel>flatMap(p -> p.getImoveis().stream())
            .mapToInt(i -> i.getValor())
            .sum();
   Assert.assertEquals(pessoas.size() * defaultVal, sum);

   //Alternativa 2: Obter diretamente um IntStream com flatMapToInt, mapeando 
   //os imoveis para os seus respectivos valores
   sum = pessoas.stream()
             .flatMapToInt(p -> p.getImoveis().stream().mapToInt(i -> i.getValor()))
                .sum();
   Assert.assertEquals(pessoas.size() * defaultVal, sum);
}

public void testTwoFlatMaps() {
   List<List<List<String>>> l = new ArrayList<>();

   l.add(Arrays.asList(Arrays.asList("a", "b", "c"),Arrays.asList("d", "e", "f")));

   String concat = l.stream() //List<List<List<String>>> → Stream<List<List<String>>>
            .flatMap(i -> i.stream() //List<List<String>> → Stream<List<String>>
                                                .flatMap(e -> e.stream()) //List<String> → Stream<String>
               ) 
           .collect(Collectors.joining()); //Concatena as Strings 

   Assert.assertEquals("abcdef", concat);
}

Operações Terminais

Uma operação terminal demarca o consumo de um Stream e, uma vez invocada, inviabiliza a realização de futuras manipulações sobre o mesmo. Todas as operações terminais são baseadas na interface não-pública TerminalOp, a qual define como é realizado o processamento, de forma sequencial ou paralela. Essas operações são subdividas nos seguintes grupos:

  • ForEach: aplica uma ação para cada elemento do Stream. A operação ForEach caracteriza-se por não apresentar retorno (void);
  • Match: operações que determinam se todos (allMatch), qualquer (anyMatch) ou nenhum (noneMatch) elemento do stream satisfazem um predicado. Todas tem retorno boolean;
  • Find: operações que consultam se um elemento do Stream satisfaz determinado predicado. Estas operações retornam objetos do tipo Optional, que serão discutidos a seguir;
  • Reduce: operações que executam processamento de forma acumulativa, partindo de um valor inicial que é sucessivamente transformado através de uma função que opera sobre cada um dos elementos do Stream. No geral uma operação de redução pode ser pensada como o trecho da Listagem 8.

    Embora a operação Reduce aparente ser uma sofisticação desnecessária para operações simples como soma e produto, ela cria um idioma natural para processamento paralelo de Streams, que é utilizado internamente pela API.

Listagem 8. Operações de Soma e Produto expressas como Reduções.

//BinaryOperator<T> é uma função do tipo (T a,T b)->T
public static <T> T reduce(T seed, BinaryOperator<T> accumulator, Iterable<T> source) {
   T result = seed;

   for (T t : source) {
      result = accumulator.apply(result, t);
   }

   return result;
}

public static Integer sum(Iterable<Integer> source) {
   return reduce(0, (a, b) -> a + b, source);
}

public static Integer product(Iterable<Integer> source) {
   return reduce(1, (a, b) -> a * b, source);
}

As operações de Find introduzem o tipo Optional no JDK, que é conhecido como nullable type em C# e Option em Scala. Um Optional é uma classe que pode guardar um valor e define métodos para lidar com a possível ausência deste. Como mostra o código da Listagem 9, um Optional tem a capacidade de adiar a decisão sobre o que fazer caso nenhum objeto com o critério especificado seja encontrado.

Optionals implementam e estendem o design pattern Null Object, o qual é conhecido por evitar expressões condicionais envolvendo null e, particularmente na implementação do JDK, define mecanismos para expressar a ausência de estado baseados em EL.

Ao utilizar Optional como retorno de métodos, passamos para os clientes dos mesmos a responsabilidade de controle de fluxo, o que pode ser útil, por exemplo, em situações nas quais ora a ausência de resultado deva ser expressa como null (ou um valor padrão), ora uma exceção deva ser propagada para ser tratada por algum componente ou framework.

Listagem 9. Optionals e controle de fluxo pós-execução.

@Test(expected = IllegalStateException.class)
public void testFind() throws Throwable {
   List<Pessoa> pessoas = ...//mesma lista de pessoas
   int defaultVal = 1000 * 1000; 
   pessoas.forEach(p -> p.add(new Imovel(defaultVal, 20)));

   // Encontre o primeiro imóvel com valor superior a 2000000
   Optional<Imovel> first = pessoas
               .stream()
               .<Imovel> flatMap(p -> p.getImoveis().stream())
               .filter(i -> i.getValor() > defaultVal * 2)
               .findFirst();

   //Caso não encontre nada, assuma null
   Assert.assertNull(first.orElse(null));

   //Executa ação somente se houver um objeto
   first.ifPresent(i -> System.out
            .println("Primeiro imóvel com valor superior a 2.000.000: " + i));   

   //Caso não encontre nada, lance uma exceção
   first.orElseThrow(() -> new IllegalStateException());
}
  

Outro tipo de redução são as operações collect() (coleta), que podem ser pensadas como uma generalização de group by (agrupamento). O método collect() pode ser utilizado em conjunto com a classe Collectors, que provê diversos métodos de fábrica para criação de coletores. Na Listagem 10

Mostramos os métodos toList(), que converte um Stream em uma lista e groupingBy(), que agrupa os elementos do Stream em um mapa, de acordo com uma função para gerar as respectivas chaves a partir de um objeto Pessoa.

Listagem 10. Exemplo de Collectors e agrupamento de dados.

@Test
public void testCollectors() {
   List<Pessoa> pessoas = ...//[Mesma coleção da Listagem 2]

   //Conversão Stream → List
   List<Pessoa> listaFiltrada = pessoas
                  .stream()
                  .filter(p -> ...)//[Mesmo filtro da Listagem 5]
                  .collect(Collectors.toList());
   //[Antônio,Alessandra]  
   Assert.assertEquals(2,listaFiltrada.size());

   //Agrupamento pelas iniciais dos nomes: Stream → Map        
   Map<Character, List<Pessoa>> agrupadasPorInicial = pessoas
            .stream()
            .collect(
               Collectors.groupingBy(p -> p.getNome().charAt(0))
             );
   
   Assert.assertEquals(2,agrupadasPorInicial.size());
   
   //[André,Antônio,Alessandra]     
   Assert.assertEquals(3,agrupadasPorInicial.get('A').size());
   //[Joao]
   Assert.assertEquals(1,agrupadasPorInicial.get('J').size());
}
 

Processamento Paralelo

Além da avaliação Lazy e do encadeamento de funções, mencionamos que um dos objetivos da API de Streams é possibilitar a conversão do processamento sequencial para paralelo de forma transparente. De fato, a mudança no código fonte que habilita o paralelismo é tão sutil que pode passar despercebida!

Caso desejemos processar uma coleção em paralelo, basta substituir a chamada ao método stream() por parallelStream() e caso já se tenha posse de um stream, basta convertê-lo para sua versão paralela com o método parallel().

Para entender os conceitos de paralelismo utilizados na API de Streams, consideremos a Listagem 11, que realiza a contagem de números primos em um intervalo de números.

O que é importante notar nesta lista é que o algoritmo que determina se um número é primo tem desempenho O(N), ou seja, quanto maior o número, maior o tempo de cálculo de isPrime().

Listagem 11. Contagem de números primos.

public final class ParallelCount {
   
   static int N_CPUS = Runtime.getRuntime().availableProcessors();
   static ExecutorService POOL = Executors.newFixedThreadPool(N_CPUS);
   
   static boolean isPrime(int l) {
      if (l <= 2) {
         return true;
      }

      if (l%2 == 0) {
         return false;
      }

      for (int i = 3; i < l / 2; i += 2) {
         if (l % i == 0) {
            return false;
         }
      }

      return true;
   }

   @Test
   public void countPrimeNumbers() throws Exception {

      int max = 1000000;
      
      //~40.3s
      int primesSeq = countSequential(max);
      
      //~21.4s – particionamento “automático” da API de Streams
      int primesParallel = countParallel(max);

      //~31.6s – particionamento com segmentos de tamanho max/NCPU
      int primesExecutors = countExecutors(max, null);

      //~21.1s – particionamento com segmentos de tamanho 100
      int primesExecutorsOptimized = countExecutors(max, 100);
      
      Assert.assertEquals(primesSeq, primesParallel);
      Assert.assertEquals(primesParallel, primesExecutors);
      Assert.assertEquals(primesExecutors, primesExecutorsOptimized);
   }

   static int countSequential(int max) {
      //Range representa um intervalo de números
      return (int) IntStream.range(1, max).filter(l -> isPrime(l)).count();
   }

   static int countParallel(int max) {
      return (int) IntStream.range(1, max)
               .parallel() //converte o Stream sequencial para paralelo
               .filter(l -> isPrime(l)).count();
   }
   
   static int countExecutors(int max, Integer partSize)
         throws InterruptedException, ExecutionException {

      int loops = partSize == null ? N_CPUS : max / partSize + (max%partSize == 0?0:1);

      int partition = partSize == null ? max / procs : partSize;
      int rem = max % loops;

      List<Future<Integer>> futures = new ArrayList<Future<Integer>>(loops);

      for (int i = 0; i < loops; i++) {
         int start = i * partition;
         //Caso a divisão não seja exata, adiciona o resto na última partição
         int end = (i + 1) * partition + (i == (loops - 1) ? rem : 0);

         futures.add(POOL.submit(() -> calcPrimesInRange(start,end)));
      }

      int total = 0;

      for (Future<Integer> f : futures) {
         total += f.get();
      }

      return total;
   }

   static int calcPrimesInRange(int start, int end) {
      int primes = 0;

      for (int j = start; j < end; j++) {
         if (isPrime(j)) {
            primes++;
         }
      }

      return primes;
   }
}
 

Caso tenhamos que calcular o total de primos no intervalo [1,1000000], para nos beneficiarmos de múltiplos processadores, precisaríamos particionar este intervalo em intervalos menores, distribuir o processamento para diversas Threads e agregar os resultados parciais. Uma estratégia simples seria quebrar o intervalo em partições igual ao número de CPUs.

Nesse caso, se tivermos duas CPUs, uma processaria o intervalo [1,500000] e outra [500001,1000000]. Esta estratégia, porém, não é adequada neste caso, pois a primeira Thread finalizaria o processamento muito antes da segunda, pois o cálculo de números primos é mais custoso para números maiores!

No teste realizado, com N_CPU=4, percebeu-se que o melhor ganho de desempenho sobre o método countSequential() devido a paralelismo ocorre com os métodos countParallel(), baseado na nova API de Streams, e countExecutors(), baseado no framework Executors.

A principal diferença entre essas duas maneiras de realizar o cálculo é o critério de particionamento, que na API de Streams é realizado internamente de forma “automática” e explicitamente no caso do método countExecutors(), que foi desenvolvido para o teste de desempenho.

O método countExecutors() pode particionar um intervalo de números [1,max] de duas maneiras: uma baseada em blocos de tamanho fixo, atrelado ao valor do parâmetro partSize, e outra utilizando a divisão do tamanho do intervalo pelo número de CPUs disponíveis, quando partSize é nulo. Verificou-se que ao processar o intervalo [1,1000000] e fixando partSize para gerar quebras com 100 elementos, o tempo de processamento utilizando o framework Executors foi ligeiramente superior ao processamento realizado pela API de Streams, ambos da ordem de 21 segundos. Esta medida representa um ganho de praticamente 50% em relação ao processamento sequencial (que levou por volta de 40 segundos), o que é impressionante, considerando que a API “não conhece” o problema que está sendo paralelizado.

Por outro lado, se o tamanho da partição não é informado (partSize==null), o ganho proporcionado por countExecutors() é mais tímido (ao redor de 25%), o que mostra como uma má estratégia de particionamento pode impactar no desempenho do processamento.

Os Streams disponibilizados na biblioteca do JDK são criados para executar processamento sequencial, a menos que haja uma “dica” indicando o contrário, como o método parallelStream() das coleções. Entretanto, todo Stream sequencial pode ser convertido para execução paralela a partir do método parallel(), como em countParallel() na Listagem 11. Streams paralelos executam suas operações em múltiplas Threads, seguindo estratégias de particionamento que podem variar de acordo com a complexidade das características internas e individuais de cada estrutura de dados.

Quem determina como um Stream será particionado é seu Spliterator (ver Figura 1).

A API de Streams oferece uma implementação “universal” de Spliterator, que adapta uma instância de um Iterator, porém no geral as coleções proveem implementações otimizadas que levam em conta o formato das estruturas de dados que encapsulam.

A maneira como é realizada a segmentação de dados por um Spliterator é determinada pela implementação do método trySplit(), que deve retornar um novo Spliterator, se ainda for possível particionar um conjunto de dados, e nulo caso não seja mais possível realizar o particionamento ou caso o particionamento já se encontre no tamanho ideal, que às vezes pode ser obtido se o Stream associado provém de uma coleção ou estrutura de dados com tamanho determinado.

Na implementação atual, se o Stream tem tamanho S conhecido, o particionamento considerado como “ideal” tem o tamanho P=S/(4×NPar), onde, por padrão, o nível de paralelismo NPar é inicializado na classe ForkJoinPool com o valor [número de CPUs] - 1 (N_CPU-1), quantidade esta que é deduzida a partir do método availableProcessors() da classe Runtime (ver Listagem 11).

Assim, se S=1000 e NPar=2, o valor ideal da partição seria 125 e o particionamento do Spliterator seria realizado como em uma estrutura de árvore, onde cada nível contém partições de determinado tamanho, que podem ser subdivididas em duas até atingir a quantidade ideal de elementos, como mostrado na Figura 2.

Particionamento com Spliterator
Figura 2. Particionamento com Spliterator.

Nesta figura, está ilustrado o particionamento de um conjunto de dados de 1.000 objetos em oito partições de 125 elementos cada uma. O Spliterator raiz faz duas subdivisões, uma para o intervalo [0,499] e outra para o intervalo [500,999].

Caso novas partições fossem solicitadas ao Spliterator raiz, as próximas chamadas à trySplit() retornariam nulo, pois não há mais itens para realizar a subdivisão.

A decomposição prossegue de forma similar com os novos Spliterators gerados por trySplit(), até que se chega às folhas da árvore, que indicam que não é mais necessário criar novas partições e todos os elementos do intervalo que cada uma referencia devem ser consumidos pelo método forEachRemaining().

É possível forçar que o número de partições geradas seja maior aumentando-se o nível de paralelismo NPar, o qual pode ser manipulado pela System Property java.util.concurrent.ForkJoinPool.common.parallelism, que assume o valor de NCPU-1, se não especificada. No projeto que acompanha o artigo apresentamos um Spliterator que reporta o número de partições geradas conforme NPar é modificado.

Por exemplo, conseguimos verificar que se atribuirmos o valor 3 para NPar, teríamos P=83, o que provocaria uma nova subdivisão (pois 125>83) e as folhas da Figura 1 ficariam num outro nível na árvore, reportando alternadamente tamanhos 62 e 63, o que corresponde à subdivisão da partição de tamanho 125 por dois.

Como vimos no exemplo da contagem de números primos, criar mais partições provoca uma melhoria no desempenho, no caso do cálculo feito com o framework Executors, porém há uma penalidade no consumo de memória, pois mais tarefas devem ser criadas. No caso de Streams, também há esta penalidade, porém o framework de concorrência Fork/Join tenta mitigá-la, através de uma técnica conhecida como Work Stealing (roubo de trabalho), que visa manter o nível de paralelismo elevado sem a necessidade de criação de muitas partições.

A principal diferença entre o framework Executors e o Fork/Join é como as tarefas são distribuídas entre as Threads do Pool. A Figura 3 mostra esquematicamente a diferença entre os ThreadPools dos frameworks.

ThreadPool vs ForkJoinPool
Figura 3. ThreadPool vs ForkJoinPool.

Enquanto no framework Executors há uma única fila que armazena as tarefas pendentes, no framework Fork/Join cada Thread possui uma fila. A partir do momento que a fila do ForkJoinPool for totalmente drenada, a Thread correspondente passará a examinar as outras filas para não ficar “parada” e começará a “roubar” os itens dessas filas, como ilustrado na Figura 4.

Processamento paralelo com
WorkStealing
Figura 4. Processamento paralelo com WorkStealing.

Na Figura 4 os pontos em vermelho e azul representam as tarefas pendentes de execução. Em cada instante T, um item é removido da fila e processado. Em T=2, o Worker em azul já exauriu todas as tarefas de sua fila e passa a roubar os itens de outras filas.

Em T=3 não há mais nada a ser processado e as Threads passam a examinar as outras filas em busca de trabalho e são estacionadas caso não encontrem novas tarefas.

Embora a quantidade de folhas geradas e o algoritmo de particionamento utilizado por Spliterators tenham influência no desempenho do processamento paralelo de Streams, normalmente não há muito com o que se preocupar, pois as implementações que acompanham o JDK já vêm tunadas para oferecer um bom custo-benefício em termos de desempenho/footprint de memória e felizmente na maioria das vezes o desenvolvedor não precisará se preocupar em implementar um Spliterator, a menos que precise realmente otimizar a lógica de particionamento, por exemplo, para realizar leituras de dados a partir do banco ou de um arquivo.

O “trabalho sujo” de implementações otimizadas para as coleções do Java já foi feito e o exemplo da Figura 1 reflete a implementação do Spliterator da classe ArrayList.

No geral, estruturas de dados baseadas em arrays tendem a apresentar desempenho praticamente constante quando se mede o tempo de particionamento contra o número de elementos, porém o mesmo não ocorre com estruturas de dados como listas ligadas (LinkedList).

Na Figura 5 apresentamos um comparativo do custo para realização de particionamento e iteração em função da quantidade de elementos de diversas coleções do JDK.

As medições realizadas foram feitas em paralelo e utilizam um filtro que seleciona apenas números pares em coleções de números inteiros seguido por uma operação terminal, que conta os elementos que satisfazem a condição do filtro. No caso, escolhemos a operação de contagem porque esta é a menos onerosa e o que queremos é verificar o “custo” do particionamento em si e não da operação terminal.

Já a filtragem é empregada para evitar possíveis otimizações com relação à operação de count(), que poderia simplesmente retornar o tamanho da coleção sem fazer nenhum processamento. Para mais detalhes, veja o projeto que acompanha o artigo.

Relação entre número de elementos e
tempo de particionamento
Figura 5. Relação entre número de elementos e tempo de particionamento.

Na Figura 5 chama a atenção a discrepância do tempo medido para ArrayBlockingQueue, muito superior em relação às outras coleções. Isto ocorre porque esta classe suporta modificações na coleção no momento em que uma operação em paralelo está ocorrendo, porém esta consistência é mantida através de Locks.

Por padrão, as coleções do pacote java.util lançarão uma ConcurrentModificationException se forem alteradas durante o processamento do Stream. Já as coleções de java.util.concurrent suportam modificações.

A API de Streams provê centenas de funcionalidades novas e constitui uma das maiores adições já vistas no JDK, contando com mais de 200 classes para promover o uso de expressões lambda e programação funcional, e para explorá-las recomendamos o uso do Eclipse com suporte ao JDK 8, cujo endereço para download é indicado na seção Links.

As novas features ainda não estão estáveis na IDE, mas mesmo assim é possível desfrutar dos benefícios que estão por vir no Java 8.

Estratégias do compilador, o escopo de EL e invokedynamic

Após analisarmos algumas das novas features disponíveis no Java 8, nos resta a pergunta: como as EL se materializam em objetos?

A adição da nova sintaxe de EL necessariamente provocou diversas mudanças no compilador (javac) do Java para que o mesmo pudesse interpretar as expressões e produzir corretamente o código que constrói as implementações dos SAM types.

Ao contrário de outras linguagens como Scala e C#, em que o compilador gera estaticamente as classes dos objetos representados por EL, em Java essa geração é feita em tempo de execução pela classe java.lang.invoke.LambdaMetafactory e o compilador só tem de se preocupar em mandar a “receita” para a geração do código.

Toda vez que o compilador encontra uma EL, é emitida uma instrução invokedynamic sobre o CallSite construído pelo método metafactory() de LambdaMetafactory.

Um CallSite é uma estrutura que serve como ligação de uma instrução invokedynamic e um método/instrução alvo representado por um MethodHandle. A abstração de CallSites permite, entre outras coisas, que a JVM realize determinadas otimizações sobre invokedynamic de acordo com o tipo do CallSite.

No caso particular de EL, todos os CallSites produzidos por LambdaMetafactory são do tipo Constant, o que efetivamente quer dizer que o MethodHandle referenciado pelo CallSite não muda e a instrução invokedynamic pode ser otimizada para instruções mais simples e diretas, como a invocação de um construtor.

Esquematicamente a “invocação” de um CallSite via invokedynamic que provocará a construção de um SAM type a partir de uma EL pode ser representada como o diagrama da Figura 6.

Criação de uma EL via InvokeDynamic
Figura 6. Criação de uma EL via InvokeDynamic.

No caso, a “receita” que é passada para LambdaMetafactory para a construção do CallSite contém o SAM type e os argumentos necessários para construí-lo. O MethodHandle encapsulado pelo CallSite representa a invocação de um construtor de uma classe que será posteriormente construída em tempo de execução. Em outras palavras, LambdaMetafactory proporciona um mecanismo de indireção para instanciar um objeto de uma classe que não está presente no classpath no momento da compilação. Em pseudocódigo, o código fonte com as EL da Listagem 2 publicada na primeira parte desse artigo poderia ser apresentado na forma da Listagem 12.

Listagem 12. Código emitido pelo compilador.

  public class TestJDK8 {
   
   public void codeJava8() throws Exception{
    
    ExecutorService pool = //...;
    
    //tradução da EL () -> 1 para uma instrução com invokedynamic
    MethodHandle mh = lookup lambda$0
    //invokedynamic opera sobre o CallSite que  
    //referencia construtor da classe TestJDK8$Lambda$1 [ver Listagem 14]
    Callable<Integer> c = invokedynamic java/lang/invoke/LambdaMetafactory.metafactory: (...,mh) 
    Future<Integer> f = pool.submit(c);
   
    //...
   }
   
   //Método gerado pelo compilador para representar o bloco de código da expressão Lambda
   private static Integer lambda$0() throws java.lang.Exception{
    return 1;
   }
  }

O compilador javac é responsável por gerar estaticamente o método lambda$0 que, além da interface funcional e argumentos, é o último ingrediente necessário para LambdaMetafactory produzir, em tempo de execução, uma implementação do SAM type.

Para ver realmente como é a forma de uma classe gerada por LambdaMetafactory, basta ativar a propriedade mostrada na Listagem 13 antes de executar um programa contendo EL.

Listagem 13. Propriedade para salvar as classes geradas por LambdaMetafactory.
System.setProperty("jdk.internal.lambda.dumpProxyClasses","/Meu/Diretorio/Lambdas"); 

Utilizando esse recurso, podemos checar que a instância de Callable gerada é como mostrada na Listagem 14.

Listagem 14. Classe gerada por LambdaMetafactory.

  final class TestJDK8$Lambda$1 extends MagicLambdaImpl implements Callable<Integer> {
   
   public Integer call() {
    //Delega para o método lambda$0
    return  TestJDK8.lambda$0();
   }
  }

A classe da Listagem 14 implementa o método call() de Callable simplesmente delegando a invocação para o método lambda$0() gerado estaticamente pelo compilador.

Essa classe comporta-se como uma classe interna, mesmo sem estar declarada explicitamente “dentro” da classe TestJDK8. O que garante acesso ao método privado lambda$0() é a superclasse java.lang.invoke.MagicLambdaImpl, que é tratada de forma especial pela JVM, pois instâncias dessa classe tem o privilégio de acesso sem restrições a campos e métodos privados de outras classes.

Mas, qual o motivo de utilizar um mecanismo tão complicado para as implementações de SAM types criados via EL quando todo trabalho poderia ser deixado para o compilador? Uma das razões é que o código com invokedynamic provê nativamente um idioma para inicialização lazy de objetos. Tomando como exemplo o código em C#, da Listagem 15, e observando a versão gerada pelo compilador na Listagem 16, percebe-se que além de emitir o método com a lógica contida na EL, o compilador torna-se responsável por realizar otimizações com respeito à instanciação dos objetos que serão utilizados no fluxo de um programa. No caso, a instância de Action criada pelo compilador pode ser tratada como um Singleton, pois sua lógica independe de qualquer estado de variáveis locais do método, bem como variáveis de instâncias da classe.

Listagem 15. Código fonte com EL em C#.

  public class LambdaTest
  {
          
    public void M0()
   {
     var list = new List<String> { "a" };
   
     list.ForEach(t=> { Console.WriteLine(t); });
   }
  }
Listagem 16. Código em C# transformado pelo Compilador.

public class LambdaTest
{
  //Cache de um Lambda “constante” - Singleton
  private static Action<string> CS$<>E9__CachedAnonymousMethodDelegate2;

 public void M0()
 {
   var list = new List<String> { "a" };

   //Instanciação Lazy
   if (CS$<>E9__CachedAnonymousMethodDelegate2 == null)
   {
     CS$<>E9__CachedAnonymousMethodDelegate2 = new Action<string>(null,__methodptr(<M0>b__1));
   }

   Action<string> action = CS$<>E9__CachedAnonymousMethodDelegate2;
   list2.ForEach(action);     

   list.ForEach(t=> { });
 }

  //Método gerado pelo compilador, como em Java
  private static void <M0>b__1(string t)
  {
    Console.WriteLine(t);
  }
}

Em Java, com a instrução invokedynamic otimizações como a da Listagem 16 tornam-se supérfluas e desnecessárias, pois a própria JVM é capaz de otimizar a criação de objetos baseando-se no tipo de CallSite. Ou seja, o uso de invokedynamic promove o uso da JVM e reduz a dependência no compilador.

Mas e se a EL utilizar variáveis locais ou variáveis de instância, como são geradas as classes? As expressões lambda são subdivididas em três categorias:

  1. Non-Capturing Lambdas (Lambdas sem captura): Expressões cujo código utiliza apenas os parâmetros do método do SAM type para realizar a lógica;
  2. Local-Capturing Lambdas (Lambdas com captura Local): Expressões cujo código utiliza variáveis locais para realizar a lógica;
  3. Instance-Capturing Lambdas (Lambdas com captura de Instância): Expressões cujo código utiliza variáveis de instância e/ou métodos não-estáticos para realizar a lógica.

A Listagem 17 ilustra as três formas de captura no método execute().

Listagem 17. Exemplo de captura de argumentos.

  public class Captures {
   
   int state;
   
   public void execute() throws Exception {
   
    Callable<Integer> cNonCapturing = () -> 1;
    cNonCapturing.call();
   
    int localState = 10;
   
    Callable<Integer> cLocalCapture = () -> localState;
    cLocalCapture.call();
   
    Callable<Integer> cInstanceCapture = () -> state;
    cInstanceCapture.call();
   }
   
   // Lambdas criados com referências para métodos
   public void executeWithMethodRefs() throws Exception {
   
    Callable<Integer> cNonCapturing = Captures::one;
    cNonCapturing.call();
   
    Callable<Integer> cInstanceCapture = this::getState;
    cInstanceCapture.call();
   }
   
   public static int one() {
    return 1;
   }
   
   public int getState() {
    return state;
   }
  }

No primeiro caso, a geração do SAM type cNonCapturing é feita como vimos anteriormente. No segundo e terceiro casos, a geração de cLocalCapture e cInstanceCapture segue um padrão similar, o que muda são os argumentos que invokedynamic recebe.

Para esses casos, o compilador emite os métodos descritos na Listagem 18 e LambdaMetafactory produz classes como apresentadas na Listagem 19.

Listagem 18. Código emitido pelo compilador.

  public class Captures {
   
   //Método gerado pelo compilador para captura local
   private static Integer lambda$1(int arg) throws java.lang.Exception{
    return arg;
   }
   
   //Método gerado pelo compilador para captura de instância
   private Integer lambda$2() throws java.lang.Exception{
    return state;
   }
  }
Listagem 19. Classes para captura local e de instância.

  final class Captures$Lambda$1 extends MagicLambdaImpl implements Callable<Integer> {
   
   final int arg$0;
   
   Captures$Lambda$1(final int arg$0){
    this.arg$0= arg$0;
   }
   
   public Integer call() {
    return Captures.lambda$1(arg$0);
   }
  }
   
  final class Captures$Lambda$2 extends MagicLambdaImpl implements Callable<Integer> {
   
   final Captures arg$0;
   
   Captures$Lambda$1(final Captures arg$0){
    this.arg$0= arg$0;
   }
   
   public Integer call() {
    return arg$0.lambda$2();
   }
  <p align="left">} 

Note que apenas no caso de capturas com instância será gerada uma classe que faz referência a um objeto da classe que declara a EL. Essa lógica não existe quando o código utiliza inner-classes, por exemplo, como na Listagem 3.

A declaração de classes anônimas sempre provocará a captura da instância da classe “externa” que as declara e sintaticamente elas possuem um problema de escopo com relação à variável this. Para entender este conceito, consideremos o trecho de código da Listagem 20.

Listagem 20. Escopo de uma EL.

  public class Captures {
   
   int state;
   
   public void lambdaVsInnerClass() throws Exception {
    Runnable rLambda = () -> System.out.println(this.state);
    rLambda.run();
    
    Runnable rInner = new Runnable() {
     
     @Override
     public void run() {
      //Erro de compilação! 
      //this é a referência para o objeto da classe anônima! 
      //System.out.println(this.state);
      
      //OK!
      System.out.println(Captures.this.state);
     }
    };
    
    rInner.run();
   }
  }

Na EL atribuída à variável rLambda, a referência this aponta para a própria instância da classe Captures que declara a EL, ou seja, uma EL funciona legitimamente como um bloco “inline” de código.

O mesmo não é verdade quando se fala de classes anônimas, que devem prefixar a referência this com o nome da classe externa se desejarem acessar campos ou métodos da mesma.

Por fim, há ainda uma sintaxe alternativa para representação de EL, conhecida como Method References (referências para métodos). Essa estratégia é a preferida quando já existe um método codificado que executa a lógica desejada e queremos simplesmente adaptar o método para um SAM type

. A Listagem 21 mostra alguns exemplos de Method References, as quais são criadas com o operador ::. Quando se utiliza a sintaxe de Method References, a estratégia para geração de EL é a mesma, com a exceção que o compilador não precisa criar os métodos como nas Listagens 14 e 19, pois os mesmos já existem.

Listagem 21. Geração de EL através de Method References.

  public class Captures {
   
   int state;
   
   // Lambdas criados com referências para métodos
   public void executeWithMethodRefs() throws Exception {
    
    //referência para método estático
    Callable<Integer> cNonCapturing = Captures::one;
    cNonCapturing.call();
   
    //referência para método de instância
    Callable<Integer> cInstanceCapture = this::getState;
    cInstanceCapture.call();
    
    //referência para método estático com parâmetros
    Predicate<Captures> isPositive=Captures::isPositive;
   
    //referência para o construtor
    Supplier<Captures> s = Captures::new;
    Captures c = s.get();
   }
   
   public static int one() {
    return 1;
   }
   
   public int getState() {
    return state;
   }
   
   public static boolean isPositive(Captures c){
    return c.state > 0;
   }
  }

Outro motivo, pelo menos no momento, para utilização de Method References é que ainda não é possível debugar EL nas formas bloco e inline.

Como os SAM types delegam a execução do código da EL para um método, é possível ver o que está acontecendo dentro do mesmo e acompanhar o fluxo de execução no debugger.

Enfim, as EL custaram a chegar para os desenvolvedores Java, mas sem dúvida a nova sintaxe, em conjunto com as novas APIs, trazem uma nova vida ao JDK e mudarão para melhor a maneira como codificamos.