Uma das novidades dos Java 8 é o conceito de Stream de objetos e sua conceptualização na interface Stream.

O conceito de Stream é bastante simples. A ideia é iterar um conjunto de objetos que estão presentes em alguma fonte e enquanto o elemento é iterado várias operações podem ser realizadas, como filtrar o elemento, modificar o elemento, transformar o elemento em outro elemento, etc. O conceito de Stream é uma extensão do conceito de Iterator, misturada com o conceito de Builder. A ideia é que ao invocar os métodos o objeto vai memorizando o que precisa ser feito, como faria um builder, e apenas quando os elementos são iterados é que realmente olhamos os elementos na fonte e os passamos pelo fluxo de operações. Isto tem duas vantagens simples a) encapsula a invocação da diretiva for-each e b) assegura que a fonte só é iterada uma vez (for is evil).

O conceito de Stream não é novo. Ele advém do conceito de Monad e está presente em linguagens como Lisp, Scala e C# ( o famoso Linq, é baseado neste conceito).

A fonte de elementos é normalmente uma coleção da API de coleções do java, mas não necessariamente. A classe Random,por exemplo, permite acessar diferentes Streans de números aleatórios sem partir de uma coleção original. Este exemplo, mostra também, que uma Stream não precisa ser finita. A iteração dos elementos pode ser infinita.Para fontes finitas, a importância do Stream  está em que todas as transformações são feitas em uma única iteração e portanto todo o conjunto de operações executa em tempo proporcional ao numero de elementos na fonte.

O fato do stream esconder a invocação à diretiva for-each tem outra vantagem que à partida pode não ser óbvia. Isto permite que a execução dos comandos não seja sequencial o que abre as portas para introduzir mais performance quando a stream tem muitos elementos.  Estas operações paralelas já eram possíveis desde o java 7 com o framework de fork-join, mas ele é demasiado complexo para coisas simples. Logo, a ideia era prover essa funcionalidade encapsulada em algo mais intuitivo e “hands-off” de forma que a própria API faça uso do framework automaticamente, seguindo o principio de design que uma API não deve força o programador a fazer algo que ela sabe fazer sozinha.

A ideia de Stream é muito interessante, mas havia um problema. Para que este conceito vingasse em java era necessário que fosse possível obter uma stream de fontes comuns como listas e mapas. Seria possível incluir um conjunto de métodos estáticos do tipo que existem na classe Collections, mas isto pareceria estranho ao usar. Por outro lado, como a API é baseada no conceito de builder que vai concatenando comandos seria necessário criar classes para estes comandos. Isto poderia ser resolvido com classes internas (inner classes) , mas seria muito verboso. Uma das experiencias que fiz no MiddleHeaven foi exatamente esta de incluir uma API de stream, e a coisa mais chata era o uso de inner classes toda a hora. Funciona, mas não é bonito. Depois de um tempo descobri que isto gerava memory leak pois as inner classes não são estáticas e acabam guardando referencias aos objetos pai o que baralha o Garbage Collector.

Para resolver estes problemas (não só, mas também) duas novas capacidades da plataforma foram adicionadas : Default Methods ( Métodos Padrão), e lambda-expressions.

Expressões lambda permitem escrever literais para Functors (Objetos que são apenas funções, normalmente apenas uma função. Estes objetos são normalmente concebidos como interfaces que só têm um método). O pessoal da Oracle envolvido com lambdas gosta de chamar os Functors de tipos SAM (Single Abstrat Method Types). Não apenas temos uma simplificação na sintaxa, mas a maquinaria interna da JVM e do compilador fazem com que o peso na performance destes tipos seja quase inexistente o que torna todo o mecanismo mais eficiente. Isto se deve ao mecanismo de lambdas usar o novo bytecode InvokeDynamic introduzido no Java 7.

Default Methods ( Métodos Padrão) são um novo mecanismo para interfaces que permite definir métodos na interface e ao mesmo tempo definir uma implementação padrão para eles. Isto efetivamente transforma as interfaces  java em traits que são usados em outras linguagens, como Scala. Um trait é como uma interface e é usado para estabelecer um contrato , mas além disso, permite que já sejam definidos outros métodos não abstratos com um certa implementação padrão. É uma fusão entre o conceito de interface e de classe abstrata no que diz respeito a abstrações. Normalmente a implementação destes métodos padrão é baseada em outros métodos da interface que são completamente abstratos. Por exemplo, se houvesse uma interface Countable como a seguir :


public interface Countable {

public int size();

public boolean isEmpty();

}

Em que a regra é que isEmpty retorna true se size retornar zero. Podemos , em java 8, escrever isto facilmente, e poupamos o implementador da interface de se preocupar com esse método


public interface Countable {

public int size();

public default boolean isEmpty() { // note a palavra default

return size() == 0;

}

}

Isto ainda permite ao implementador da classe sobreescrever o método como achar melhor, mas torna a sua implementação opcional na maioria dos casos. A API para o Java 8 recebeu bastantes novos métodos desenhados com esta funcionalidade e inclusive alguns métodos antigos como Iterator.remove que lança NotSupportedOperation por padrão…Uma nota sobre novas coisas para interfaces é que agora também é possivel escrever métodos estáticos em interfaces. Isto é muito útil para desenhar melhores APIs.

Esta nova tecnologia de default methods foi usada para incluir o método stream() em Collection e permitir que todas as coleções sejam fontes para streams.

Como disse antes, o objetivo principal do Stream não é melhorar o Iterator, mas sim permitir operações paralelas visando aumentar a performance. Isto não é conseguido apenas com o uso do conceito de Stream. Por ajuda nisto, o Java define um novo tipo de iterador : o Spliterator, que é um Iterator que permite fazer split, ou seja produzir duas partes para iterar a partir de uma única fonte. Ele também pode ser usado como um iterador normal caso tudo aconteça em sequencia. Todas as Coleções ganharam um método (um outro default method) chamado spliterator(). Em conjunto o spliterator e o stream permitem realizar tarefas antes chatas e repetitivas com pouco código.

Se já programou em java antes do java 8 então provavelmente já teve que produzir código que filtra a transforma objetos de uma coleção em outros.  Eis um exemplo que pega um conjunto de clientes e produz objetos ClientView para enviar para a tela, mas apenas se o cliente está ativo.


List<Client> clients = ... // obtido de alguma forma

List<ClientView> views = new ArrayList<>(clients.size());

for (Client c : clients){

if (c.isActive()){

Clientview v = new  ClientView();

v.setName(c.getName());

v.setId(c.getId());

views.add(v);

}

}

Aqui eu coloquei tudo em um único for, para ser mais curto e eficiente, mas é muito comum ver código em que cada parte tem seu próprio for. Um para filtrar e um outro para converter. Em aplicações reais as regras são mais complexas e muitas mais transformações são necessárias.

Com a nova API de stream o mesmo resultado pode ser alcançado com :


List<Client> clients = ... // obtido de alguma forma

List<ClientView> views = clients.stream().filter ( c -> c.isActive())
.map( c -> new  ClientView(c) ).collect(Collectors.toList());

Bem mais curto e fácil de entender. Agora é claro que existe um filtro pelo estado de ativo porque o método filter está sendo usado, e é claro que existe uma conversão porque o método map está sendo usado.  Obter uma versão de execução fork-join para o primeiro código seria complexo, mas para a nova versão com stream, basta apenas chamar um método diferente:


List<Client> clients = ... // obtido de alguma forma

List<ClientView> views = clients.parallelStream().filter ( c -> c.isActive())
.map( c -> new  ClientView(c) ).collect(Collectors.toList());

A API de paralelismo tenta ser o mais inteligente possível considerando , quando possível, o número de elementos na fonte, e pode, inclusive decidir executar tudo em sequencia se isso for mais vantajoso.

Embora a API de Stream seja realmente artilhada para fornecer paralelismo de forma simples, isso implica que as operações da Stream em si, são limitadas. Se compararmos a interface Stream do java com suas contrapartes em outras linguagens veremos que faltam algumas coisas. Por exemplo, é possível obter o primeiro item do stream (findFirst()) , mas não o último. Para reverter a stream ou para fazer uma operação de groupBy temos que usar um Collector, que quebra um pouco a fluidez da api. Em .NET , por exemplo, estas operações são nativas da interface IEnumerable que tem o mesmo papel que Stream. Podemos facilmente invocar GroupBy ou Last. Esperemos que isto evolua em próximas versões da API, pois como está hoje realmente é muito orientado a paralelismo, mas sem considerar operações mais comuns do dia a dia.

Claro que podemos argumentar que operações especiais como last e reverse têm que ser obtidas pelo correto uso da API de coleções original (iterar em reverso, por exemplo, pode ser feito usando um ListIterator, mas apenas para lista onde essa operação é eficiente).

Embora os métodos presentes em Stream sejam limitados a introdução do conceito representa um salto gigante para a linguagem em termos de escrita e leitura de operações sobre conjuntos. Além disso, trás a linguagem para o mundo moderno a par com Scala e C#.

Criar uma API de Stream não é assim tão complexo –  e já me referi a isto outras vezes (sim , eu acho que você deveria ter sua própria API de Stream) –  o complexo é torná-la paralelizável e performática. Queremos que as operações sejam realizadas com uma só iteração no elementos da fonte tanto quanto possível e isto requer, algumas vezes, um pouco de imaginação.

A introdução de Stream na API Java é realmente um grande avanço e dominar esta API é tão importante hoje, quanto ontem era dominar a própria API de coleções. Espero que ela evolua em funcionalidade ao nível da API presente em outras linguagens mas sempre será uma boa alternativa para processamento paralelo de forma simples ( que já não é tão comum em outras linguagens). É realmente uma API que uma vez que você se habitua você não quer fazer de jeito antigo.

 

3 comentários para “Streams no Java 8 e em outras Linguagens”

  1. Ótimo post. Você já utilizou a API de Collection Goldman Sachs?

    https://github.com/goldmansachs/gs-collections

  2. Não conhecia. Mas é bem curioso que você tenha referido isso, porque o assunto de fazer uma melhor api de collections é o meu próximo post 🙂

    Vou analisar a lib e comento no próximo post que vem bem a propósito.

  3. […] Stream que permite realizar várias operações enquanto a iteração acontece. Falei disto no post passado. Uma nova API de coleções tem que levar este conceito a sério. Em Scala, por exemplo, não […]

Comente

Scroll to Top