*Esse texto é uma adaptação da palestra Lambdas & Streams de Simon Ritter, filmada no QCon Londres 2014
Se perguntarmos aos desenvolvedores Java sobre o Java 8, teremos diversas respostas bem animadas, especialmente sobre o uso das expressões lambda.
Mas após uma conversa mais honesta, encontramos um fervor misturado com um pouco de receio em relação às novas e misteriosas APIs disponíveis na Web. Simon Ritter revelou alguns dos mistérios na apresentação sobre lambdas na conferência QCon London 2014.
A seguir, temos um trecho de código Java que ilustra um padrão comum:
Nota: As partes em vermelho são aquelas que estamos interessados; e as partes em azul representam código repetitivo.
Neste problema, queremos encontrar a maior nota em uma coleção de estudantes. Usamos um idioma comum de iteração externa para percorrer e comparar cada elemento da coleção.
Mas há algumas desvantagens no código. Em primeiro lugar, a iteração externa significa que os desenvolvedores são responsáveis pela implementação (programação imperativa), e como é usado um único loop, definimos que a execução do código será de modo sequencial. Se quisermos otimizá-lo, não poderíamos facilmente segmentar em um conjunto de execução de instruções em paralelo.
Em segundo lugar, a variável highestScore é mutável e não é thread-safe. Então, mesmo que quiséssemos quebrá-lo em múltiplas threads, precisaríamos adicionar um lock (bloqueio) para prevenir race conditions (condições de concorrência), que por sua vez podem introduzir problemas de desempenho.
Agora, se quisermos agir de modo mais inteligente, podemos mudar um pouco mais a implementação em direção ao estilo funcional utilizando uma classe interna anônima:
Nessa implementação, eliminamos o estado mutável e passamos o trabalho da interação para a biblioteca. Estamos encadeando uma sequência de chamadas de métodos para aplicar a operação em uma expressão "Olhe para todos os meus estudantes e filtre apenas aqueles que se graduaram em 2011".
A classe interna anônima implementa a interface Predicate (ela contém um método, aceita um parâmetro e retorna um valor booleano) com o método chamado "op", que simplesmente compara o ano de graduação do estudante com 2011 e retorna o resultado.
Enviaremos o resultado (todos os estudantes graduados em 2011) para o método "map", que usará outra classe interna anônima para chamar um método da interface de mapeamento com seu único método "extract" para extrair o dado que queremos (chamando o método getScore). Então passaremos esse resultado, que é um conjunto de notas de todos estudantes graduados em 2011, para o método "max", que enfim entregará o maior valor a partir do conjunto de resultados.
Usando essa abordagem, tratamos toda a interação, filtro e acúmulo com o uso da biblioteca, sem precisar fazer isso de forma explícita. Isso não somente simplifica a implementação, como também elimina o compartilhamento de estado, deixando mais fácil pedir ao código da biblioteca para decompor a implementação em diversas sub tarefas e alocá-lo em diferentes threads para serem executadas em paralelo. Em muitos casos, também executamos uma avaliação posterior, economizando ainda mais tempo.
Então, uma abordagem que usa classe interna anônima é rápida e thread-safe, mas atente-se para as cores do código fonte. Note que a quantidade em azul é maior do que em vermelho, indicando código repetitivo.
Então, entram em cena as expressões lambda!
Pense nas expressões lambda como um método, no sentido de que ele aceita parâmetros, tem corpo e retorna um tipo estático.
Nesse exemplo, usamos as expressões lambda para obter o mesmo algoritmo que determina qual a maior nota, como nos exemplos anteriores. Vejamos em detalhes.
Primeiro, criamos um Stream a partir de uma Collection. O método stream é novo na interface Collection e funciona de forma parecida com um Iterator embutido (veremos em mais detalhes logo adiante). O stream prepara os resultados da coleção, que então passa para o método filter, descrevendo "como" as partes serão filtradas usando a expressão lambda que compara o ano de graduação dos estudantes com 2011.
Note que não há um retorno explicito. Simplesmente dizemos "Compare o ano de graduação com 2011" e o compilador deduz que destina-se a interface Predicate (que tem um único método que necessita de uma assinatura com um retorno do tipo booleano). O método map é processado de forma similar, usando uma expressão lambda, passando um estudante S como parâmetro e mapeando (como um tradutor) seu valor de retorno, que é a nota (score) do estudante. O método map não deve ser confundido com o java.util.Map que usa pares de chave-valor. O método map da classe Stream retorna uma nova instância do Stream contendo os resultados da operação aplicada para todos os elementos da Stream de entrada, produzindo, nesse caso, um Stream com todas as notas.
Usando lambdas implementamos o mesmo algoritmo com muito menos código. É mais compreensível, portanto menos propenso a erros, e como visto, ele pode ser alterado para um algoritmo paralelo uma vez que não há compartilhamento de estado.
Como Ritter disse em sua apresentação:
"As expressões Lambda representam uma função anônima. Então, elas são como métodos, mas não são realmente um método. Elas são como funções anônimas no sentido de que elas tem as mesmas funcionalidades de um método, mas elas não são métodos porque não estão associadas com uma classe. Se pensar no Java como programamos hoje, criamos classes e classes têm métodos. Portanto o método tem uma classe associada a ele. No caso das expressões Lambda, não há uma classe associada a ela!
É como uma estrutura de método: tem uma lista de tipos de argumentos; tem um tipo de retorno que pode ser inferido; mas também pode ter um retorno explícito. Tem potencialmente um conjunto de lançamento de exceções, então é possível lançar uma exceção dentro de uma expressão Lambda e ela tem um corpo, que defini o que ela fará. Também é possível fazer as mesmas coisas que um método: pode agrupar instruções; pode usar chaves e ter múltiplas instruções sem nenhum problema. A coisa mais importante sobre isso é que agora permite usar uma maneira simples de ter um procedimento parametrizado, não apenas valores parametrizados."
Ritter ampliou esse conceito apontando que uma vez que uma lambda é uma função sem uma classe, então a palavra-chave "this" não se refere a própria lambda, mas sim a classe na qual foi declarada. Isso distingue de uma classe interna anônima, na qual "this" refere-se a própria classe interna.
É útil olhar para as decisões de implementações que os designers da linguagem fizeram para desenvolver os lambdas.
Olhando para o Java como um todo, há muitas interfaces que possuem apenas um método.
Vamos definir uma interface funcional como uma interface com exatamente um método abstrato, por exemplo:
interface Comparator<T> { boolean compare(T x, T y); } interface FileFilter { boolean accept(File x); } interface Runnable { void run(); } interface ActionListener { void actionPerformed(…); } interface Callable<T> { T call(); }
Uma expressão Lambda permite definir uma interface funcional (novamente, um método abstrato) que o compilador identifica pela estrutura. O compilador pode determinar a interface funcional representada a partir de sua posição. O tipo de uma expressão lambda é o da interface funcional associada.
Como o compilador conhece os locais que são usados uma expressão lambda, é possível determinar muito sobre essa expressão. Portanto como o compilador sabe o tipo da interface funcional, então pode inferir os outros tipos necessários.
Mas, Ritter avisa:
"Um ponto de atenção é que embora não tenhamos explicitamente colocado o tipo da informação lá, isso não significa que está usando uma tipagem dinâmica no Java. Nunca faríamos isso, é desagradável e ruim. Então, o que fazemos é dizer que isso ainda é uma tipagem muito estática, mas com menos digitação."
Ainda segundo Ritter, uma coisa que diferencia as expressões lambda dos closures, é que ao contrário dos closures, as lambdas não podem acessar as variáveis que estão fora da expressão lambda, exceto as variáveis que são efetivamente final, isso significa que embora a variável não precisa da palavra-chave final (ao contrário de uma classe interna), no entanto o seu valor não pode ser reatribuído.
Referência de métodos
A sintaxe da referência de método é outra nova funcionalidade da expressão lambda. É um atalho que permite reutilizar um método basicamente como uma expressão Lambda. Podemos fazer coisas como:
FileFilter x = f -> f.canRead();
Essa sintaxe diz para o programa criar um FileFilter que filtra os arquivos com base em uma propriedade comum - nesse caso, se o arquivo pode ser lido. Note que nesse exemplo, nunca mencionamos que f é um arquivo; o compilador infere através da assinatura do único método no FileFilter:
boolean accept(File pathname);
Podendo ser simplificado ainda mais usando a nova notação do Java 8 "::".
FileFilter x = File::canRead;
Essas sintaxes são completamente equivalentes.
Para chamar um construtor de forma análoga, pode ser utilizado a sintaxe "::new". Por exemplo, se tivermos uma interface funcional como:
interface Factory<T> { T make(); }
Então, podemos dizer:
Factory<List<String>> f = ArrayList<String>::new;
Isso é equivalente a:
Factory<List<String>> f = () -> return new ArrayList<String>();
E agora, quando f.make() é chamada, será retornado um novo ArrayList<String>.
Usando as interfaces funcionais, o compilador pode deduzir muitas coisas sobre a tipagem e intenção, como demonstrado nesses exemplos.
Evolução da biblioteca
Uma das vantagens das lambdas e expressão de códigos como dados é que, como visto, as bibliotecas existentes foram atualizadas para aceitar lambdas como parâmetros. Isso introduz alguma complexidade: como introduzir métodos na interface sem quebrar as implementações das interfaces que já existem?
Para fazer isso, o Java introduz o conceito de métodos de extensão, também conhecido como defender métodos ou métodos default (padrão).
Vamos explicar usando um exemplo. O método stream foi adicionado na interface Collection para fornecer um suporte básico ao lambda. Para adicionar o método stream na interface sem quebrar as implementações existentes da Collection de todo mundo, o Java adicionou o stream como um método da interface, fornecendo uma implementação padrão:
interface Collection<E> {
default Stream<E> stream() { return StreamSupport.stream(spliterator()); } }
Então, agora temos a opção de implementar o método stream ou se preferir usar a implementação padrão fornecida pelo Java.
Operações de agregação
Operações de negócios frequentemente envolvem agregações como: encontrar a soma, máximo, ou média de um conjunto de dados, ou grupo de alguma coisa. Até agora, tais operações são normalmente executadas com loops de interação externa, como dissemos, nos restringindo das otimizações e adicionando boilerplate ao código fonte.
As Streams do Java SE 8 tem como objetivo resolver estes problemas. Nas palavras de Ritter:
Um stream é a maneira de abstrair e especificar como processar uma agregação. Ele não é uma estrutura de dados. Na realidade é uma maneira de tratar os dados, mas define uma nova estrutura de dados, e interessantemente pode tanto finito quanto infinito. Então, é possível criar um stream de, digamos, números aleatórios e não precisa mais ter um limite. É aqui que, algumas vezes, as coisas ficam um pouco confusas. Reflita sobre a seguinte questão: "Se tenho um stream infinito, posso continuar processando os dados para sempre. Como faço para parar o que estou fazendo com os dados?"
A resposta é que potencialmente não será finalizado. É possível fazer facilmente um trecho de código usando streams que continua para sempre, como se fosse um loop "while(true);" infinito. Ele é como um Stream: se usar um Stream infinito, ele pode nunca terminar. Mas também é possível fazer um Stream parar - digamos, para fornecer um Stream infinito de números aleatórios, mas com um ponto de parada. Assim o Stream vai parar e o programa pode continuar sua execução.
O Stream fornece um pipeline (sequência) de dados com três componentes importantes:
1. Uma fonte de dados;
2. Zero ou mais operações intermediarias, fornecendo um pipeline de Streams;
3. Uma operação terminal(final) que pode realizar duas funções: criar um resultado ou um efeito colateral. (Um efeito colateral significa que talvez não consiga retornar um resultado, mas ao invés disso, consiga imprimir o valor.)
Nesse exemplo, iniciamos com uma Collection de "transações" e queremos determinar o preço total de todas as transações que foram realizadas por compradores de Londres. Obtemos um stream a partir da Collection de transações.
Depois obtemos um stream a partir da Collection de transações. Então, aplicamos uma operação de filtro para produzir um novo Stream com os compradores de Londres.
A seguir, aplicamos a operação intermediaria mapToInt para extrair os preços. E finalmente, aplicamos a operação final de soma para obter o resultado.
Do ponto de vista da execução, o que aconteceu aqui é que o filtro e o método de map (a operação intermediaria) não realizaram um trabalho computacional. Foram apenas responsáveis por configurar o conjunto das operações, e a computação real é executada posteriormente, postergado até chamar a operação final - nesse caso a soma (sum) - que faz todo trabalho acontecer.
Fontes de stream
Há diversas formas de obter um Stream. Muitos métodos estão sendo adicionados a API de Collection (usando a extensão de métodos nas interfaces).
Através de uma List, Set, ou Map.Entry é possível chamar um método de Stream que retorna uma Stream com o conteúdo da coleção.
Um exemplo é o método stream(), ou parallelStream(), que internamente a biblioteca usa o framework de fork/join para decompor as ações em diversas subtarefas.
Há outras maneiras de obter um stream:
- Fornecer um array para o método stream() da classe Arrays;
- Chamar o método Stream.of(), um método estático da classe Stream;
- Chamar um dos novos métodos estáticos para retornar um stream em particular, por exemplo:
- IntStream.range(), fornecendo um índice de inicio e fim. Por exemplo, IntStream.range(1, 10) gera um stream de 1 a 9 com incremento de 1 em 1 (IntRange.rangeClosed(1, 10) gera um stream de 1 a 10);
- Files.walk() passando um caminho e algum parâmetro de controle opcional que retorna um stream de arquivos individuais ou subdiretórios;
- Implementar a interface java.util.Spliterator para definir sua própria maneira de criar um Stream. Para mais informações sobre o Spliterator consulte o Javadoc do SE 8 fornecido pela Oracle.
Operações de finalização de Stream
Depois de encadearmos todos esses streams, podemos especificar uma operação de finalização para executar as pipeline e todas as operações (sequencialmente ou em paralelo) e produzir os resultados finais (ou efeitos colaterais).
int sum = transactions.stream(). filter(t -> t.getBuyer().getCity().equals("London")). //Lazy mapToInt(Transaction::getPrice). //Lazy sum(); //Executa o pipeline
Inteface Iterable
Essa é uma velha conhecida dos dias do Java 1.5, exceto que agora tem um método forEach() que aceita um Consumer, que aceita um simples argumento e não retorna valor e produz um efeito colateral. Mas continua sendo uma interação externa e a melhor forma de obter um lambda é o método map().
Exemplos
Ritter conclui a apresentação com alguns exemplos uteis, que estão listados a seguir com comentários explicativos. (As linhas em negrito indicam o especial uso demonstrado em cada exemplo).
Exemplo 1. Converter palavras para maiúsculo:
List<String> output = wordList.stream(). // Mapa de toda a lista de String em maiúsculo. map(String::toUpperCase). // Converte o stream para uma lista. collect(Collectors.toList());
Exemplo 2. Procurar as palavras com tamanho par na lista:
List<String> output = wordList.stream(). //Seleciona somente as palavras com tamanho par. filter(w -> (w.length() & 1 == 0). collect(Collectors.toList());
Exemplo 3. Contar as linhas de um arquivo:
long count = bufferedReader. // Recebe um stream com linhas individuais. Esse é o novo método do // bufferedReader que retorna um stream<string>. lines(). // Conta os elementos do stream de entrada. count();
Exemplo 4. Juntar as linhas 3 e 4 em uma única String:
String output = bufferedReader.lines(). // Pula as duas primeiras linhas. skip(2). // limita a stream a apenas as próximas duas linhas. limit(2). // Concatena as linhas. collect(Collectors.joining());
Exemplo 5. Encontrar o tamanho da linha mais longa em um arquivo:
int longest = reader.lines(). mapToInt(String::length). // Cria uma novo Stream com o tamanhos das Strings mapeando // a atual String ao tamanho correspondente. max(). // Coleta o maior elemento do stream de tamanho (como uma OptionalInt). getAsInt(); // Atualiza o OptionalInt com um int.
Exemplo 6. Coleção de todas as palavras do arquivo em uma lista:
List<String> output = reader.lines(). flatMap(line -> Stream.of(line.split(REGEXP))). // Recebe um stream de palavras de // todas as linhas. filter(word -> word.length() > 0). // Filtra as Strings vazias. collect(Collectors.toList()); // Cria a lista de retorno.
Exemplo 7. Retorna a lista de palavras minúscula em ordem alfabética:
List<String> output = reader.lines(). flatMap(line -> Stream.of(line.split(REGEXP))). filter(word -> word.length() > 0). map(String::toLowerCase). // Atualiza o Stream da fonte com o Stream de // letras minúsculas. sorted(). // Atualiza o stream com a versão ordenada. collect(Collectors.toList()); // Cria e retorna uma Lista
Conclusão
Simon Ritter conclui a apresentação declarando:
"O Java precisa das expressões lambda para facilitar a vida do desenvolvedor. As expressões lambdas eram necessárias para a criação dos Streams e também para implementar a ideia de passagem de comportamento como a passagem de valor. Também precisávamos ampliar as interfaces existentes, com o uso das extensões de métodos do Java SE 8, e que resolve o problema da retro compatibilidade. Isso permite fornecer a ideia de operações em lote na Collections e permite fazer coisas que são mais simples, e de um modo mais legível. O Java SE 8 está basicamente evoluindo a linguagem; evoluindo as bibliotecas de classes e também as maquinas virtuais ao mesmo tempo."
O Java 8 está disponível para download e há um bom suporte a lambda em todos as principais IDEs. Sugiro que todos os desenvolvedores Java façam o download e usem o Projeto Lambda.
Sobre o palestrante
Simon Ritter é o diretor do Evangelismo da tecnologia Java na Oracle Corporation. Ritter trabalha com negócios de TI desde 1984 e é bacharel de Ciência graduado em Física pela universidade de Brunel no Reino Unido.