Você já deve ter ouvido dizer que a otimização prematura é a raiz de todos os males. Esta ideia se deve a uma má citação do texto do Donald Knuth. A partir dai se criou a ideia que você poderia criar um código qualquer e que depois, no fim, iria ser otimizado. O famoso: “primeiro faz funcionar, depois faz funcionar bem, depois faz funcionar depressa”. Ora isto é simplesmente uma imbecilidade.O código tem que funcionar, bem e depressa à primeira.
A critica do Donald Knuth era sobre a otimização de pequenas coisas, como por exemplo iterar ao contrário porque em tese o compilador ( do C++) trabalha mais depressa com i– do que com i++. É este tipo de otimização do tipo “eu sei o que o compilador fez no verão passado” que é de evitar. Isto complica a manutenção e entendimento do código que são as duas coisas mais importantes. Mais ainda do que funcionar. Então toda a otimização “espertinha” não deve ser feita até que saibamos que merece a pena.
Por outro lado, existem muitas otimização que podem e devem ser feitas. Algumas são bem triviais, outras exigem um pouco de conhecimento e outras exigem mais conhecimento de algoritmos (especialmente de notação O-grande). Se você tem uma algoritmo que trabalha em O(N2) e troca por um outro O(N) você está otimizando. Mesmo em orientação a objetos ter a noção do peso operacional do seu algoritmo é muito importante. Imagine que você tem uma lista de objetos e você quer procurar um em particular. E imagine que esta pesquisa é usada muito frequentemente por outros algoritmos. Você pode implementar um algoritmo que itere a lista, faça uma verificação a cada passo do laço e termine o laço quando encontrar o objeto. Se a lista tiver N itens, no pior caso o item é o ultimo o que significa que você precisa de N passos para encontrar o objeto. Mas se você usar um mapa de chave-valor onde a chave tem um código de hash correspondente, para os mesmos N itens, você precisa de 1 passo. Não ha laço necessário. Veja que não estamos preocupados quanto o tempo que demora de fato a operação em termos de segundos, estamos preocupados em quantos passos podemos resolver o mesmo problema. O truque que usamos aqui foi utilizar uma estrutura de dados mais apropriada e isso nos libertou de usar laços e verificações.
A primeira otimização , mais simples e trivial é usar o objeto do tipo certo. Em programação não-OO seria “usar a variável do tipo certo”. Parece estupido dizer isto mas muita gente ainda não abraçou esta prática. Quem nunca viu o código usar String onde deveria usar um numero ou uma data ? O uso de String como “Objeto que serve para tudo ” é o sintoma da Programação Orientada a String (PoS), que é uma péssima prática. Se você usa String para tudo, você não está programado direito. Em java de vez em quando aparece alguém usando Calendar em vez de Date. Date é um objeto que simplesmente encapsula um long. É um Value Object muito simples. Tudo bem que os seus métodos são desatualizados e tudo o mais, mas o ponto aqui é que se você quer guarda um tempo, use um objeto que é um tempo. Calendar é uma calculadora. O equivalente de usar um Calendar em vez de um Date é anexar uma calculadora ao arquivo em vez de escrever a data no papel do arquivo. O Calendar é mutável e é mais pesado em termos de memoria e mais pesado em significado (ele trabalha com fuso horário e localização o que normalmente não é necessário e pode levar a erros). Só o fato de ser mutável já deveria ser um alerta. Sim, o Date também é mutável, mas todos esses métodos foram descontinuados , exatamente quando foi criado o Calendar. Outro síndrome é usar inteiros em vez de valores lógicos (boolean). Isso é um síndrome originado na linguagem C e C++ ( o que demonstra como a falta de seguir regras simples como esta complicam a linguagem). Este síndrome de usar inteiros como valor lógico leva as pessoas a escrevem códigos como:
boolean isHold =... if (isHold == true){ // faça algo }
A vraiável isHold já é um boolean não precisamos comparar ela para obter outro boolean. Ficaria assim:
boolean isHold =... if (isHold){ // faça algo }
Simples. O código faz uma operação a menos que é totalmente desnecessária ( não que o compilador não pudesse otimizar isso, mas é que se trata de um problema de escrita).
Na categoria de uso de tipos errados ha muito o que dizer no campo da API de coleções. A API de coleções atualmente é bem extensa e realmente pode ser um pouco confuso para quem está começando, mas o básico de Estruturas de Dados tem que se saber. É necessário saber quando usar um Set em vez de um List. Saber se vale a pena copiar um List para um Set para realizar um certo algoritmo. Quando usar Map, quando usar Queue, BlockingQueue. Além disso saber quando usar LinkedList e quando usar ArrayList. ArrayList é usado sempre que souber exactamente quantos elementos irá ter na lista. É o mesmo que criar um array. Você precisa saber o tamanho primeiro. Se você não sabe, ou não pode calcular, use LinkedList. O código a seguir sofre de um problema grave de otimização
public List<String> copyToList(Collection<String> collection) { List<String> nomes = new ArrayList<String>(0); for (String s : collection) { nomes.add(s); } return nomes;
Primeiro, está-se criando um ArrayList sem especificar o tamanho certo, pior, está-se especificando zero. A ideia do programador é criar um ArrayList com um array vazio internamente para que se a coleção de nomes for vazia o retorno ocupar menos espaço na memoria. Este é o tipo de raciocínio que o Kunth se referia. É uma “otimização” errada porque a maior parte das vezes a lista não será vazia e portanto a cada Add o ArrayList vai começar a criar e descartar vários arrays pois o tamanho está sempre aumentando. Segundo, os construtores servem para ser usados. Existe um construtor que já faz a cópia, então use-o. O código final seria assim:
public List<String> copyToList(Collection<String> collection) { return new ArrayList<String>(collection); }
Se você precisar fazer o for ( porque vai aplicar uma lógica qualquer, sei lá, por exemplo dar UpperCase), então seria assim:
public List<String> copyToList(Collection<String> collection) { List<String> nomes = new ArrayList<String>(collection.size()); for (String s : collection) { nomes.add(s.UpperCase()); } return nomes; }
Repare que o tamanho da coleção original é sabido e pode ser usado. É muito raro que você não saiba ou não consiga computar o tamanho do array necessário. Caso não saiba você teria que usar LinkedList.
Mas imagine que o método seguinte vai pegar aquela lista e executar um sort. O seu código vai ter problemas, porque as chamadas a LinkedList.get(index) são muito custosas na ordem de O(index) se index não for zero ou o ultimo índex ( caso em que é O(1)). Então talvez usar LinkedList não seja a melhor opção se no final de contas você não sabe se o chamador do método vai utilizar sort ou não. Então, se realmente não soubermos o tamanho do array, mas não queremos usar LinkedList, podemos utilizar o método trimToSize() de ArrayList que redimensiona o array interno ao numero de elementos que existem nele. Assim o array não está ocupando memoria à toa. Mas ao fazermos isto executamos mais uma criação e cópia de array, o que pesa na performance. É por isso que o uso de trimToSize(), na prática, é raro. Cai no tipo de otimização que o Kunth fala. O array já está criado, ok, tem um bocado de elementos vazios, tudo bem , vamos assim. Se, no futuro, você sentir falta de memória ai sim cogitaria usar o trimToSize(). Nos tempos da JME isto poderia ser necessário, porque a memória ela limitada. Isto não significa que você deve esbanjar memória ( é um recurso escasso), e deve sempre inicializar o ArrayList com o tamanho correto. Este truque é apenas quando você realmente não sabe o tamanho e precisa ordenar depois ( ou usar alguma operação que use get(index) onde o ArrayList é mais eficiente porque implementa RandomAccess)
Uma das coisas que as pessoas esquecem facilmente é do encapsulamento. Tanto de usá-lo, como das suas consequências.
Imagine que tínhamos um método que faz uma pesquisa e retorna uma lista de elementos. Só que essa pesquisas é extremamente custosa porque é remota e a rede é muito ruim. Só que devido ao encapsulamento o programador que usa o método não sabe que isso é tão demorado. Vejamos um código que ele faria:
public boolean fazalgo () { if ( retornaLista().isEmpty()){ return false; } for ( Object obj : retornaLista() ){ if (obj == null ){ return false; } } return true; }
O que ele está tentando fazer com este código não importa. O ponto é que ele invoca retornaList() duas vezes. Isto significa que o pobre do programa tem que executar a mesma coisa outra vez. A sorte é que o compilador otimiza o for para só executar a pesquisa uma vez, pois senão iria ser o caos, fazendo a mesma pesquisa custosa a cada passo do laço.
A regra é, se você invoca algum outro método dentro do seu, invoque-o apenas uma vez. Isto elimina o problema do encapsulamento surpresa. Pode ser que o método seja muito rápido e chamar mais do que uma vez não faz diferença. Mas pode ser que não. Ou pior, pode ser que ele é rápido porque está mal implementado e quando o outro programador corrigir o problema ele passe a ser lento.
Um código melhor seria:
public boolean fazalgo () { List<Object> lista = retornaLista(); if (lista.isEmpty()){ return false; } for ( Object obj : lista ){ if (obj == null ){ return false; } } return true; }
É algo simples, mas que muitas vezes passa desapercebido. Existe um outro problema ao invocar o método mais do que uma vez. Pode ser que o retorno não seja o mesmo quando você executa a segunda chamada e subsequentes. Afinal você não sabe como o método é implementado nem que garantias são verdadeiras para ele. Não arrisque. Além de tudo o código fica mais simples de ler porque você está explicitando onde a invocação é feita. Se houver um erro no método chamado, será mais fácil identificar onde foi feita a chamada e qual a causa.
Existem pessoas que têm preconceito em usar break, continue e labels. Labels, para quem não sabe é uma característica da linguagem java que permite atribuir nomes a laços. O break é uma instrução que termina o laço e o continue é uma instrução que pula o resto do código do laço e passa ao próximo passo do laço.
Estas instruções são muito úteis. Se você foi obrigado a usar um laço para fazer uma pesquisa quando você encontra o que quer, porquê continuar o laço ? Nesse caso simplesmente aborte a pesquisa. Um código que não usa break é mais ou menos assim :
public Pessoa encontraComNome(List<Pessoa> pessoas, String nome){ Pessoa encontrado = null; for (Pessoa pessoa : pessoas){ if (pessoa.getName().equals(nome)){ encontrado = pessoa; } } return pessoa; }
Veja que mesmo depois do código encontrar a pessoa certa ele ainda continua iterando. O que o código espera encontrar ? um clone ? Se já foi encontrada a pessoa, ha que abortar a pesquisa. Usando um break seria:
public Pessoa encontraComNome(List<Pessoa> pessoas, String nome){ Pessoa encontrado = null; for (Pessoa pessoa : pessoas){ if (pessoa.getName().equals(nome)){ encontrado = pessoa; break; // termina o for } } return pessoa; }
Uma simples palavra que faz toda a diferença. Pense assim, se a lista tiver N itens, e a pessoa certa estiver no item K (onde K <= N) no primeiro caso o laço itera N elementos, no segundo caso itera K. Apenas se o item escolhido for o último é que os dois algoritmos demoram o mesmo, caso contrário, com break, ele dá menos passos. Apenas os necessários para encontrar a pessoa. Sim, este é o tipo de código que falamos antes que poderíamos modificar para usar um mapa. Mas essa otimização só funciona se a lista de pessoas estiver sob o nosso controle. Neste caso imaginamos que a lista nos foi passada por outro sistema. Incluir a lista num mapa para encontra a pessoa certa iria precisa de N passos porque todos os itens teriam que ser incluídos na lista. O que não nos trás vantagem em relação ao método com break.
Neste caso o retorno do método é exatamente o objeto que iremos encontrar no laço. Para estes casos existe algo ainda melhor que o break. Simplesmente use o return dentro do laço, assim :
public Pessoa encontraComNome(List<Pessoa> pessoas, String nome){ for (Pessoa pessoa : pessoas){ if (pessoa.getName().equals(nome)){ return pessoa; } } return null; }
Veja que eliminamos a necessidade de uma variável a mais. Isto é bom, tanto porque poupamos memória, mas principalmente porque deixamos o código mais claro.
O uso de labels só faz sentido quando usamos laços aninhados, ou seja, quando fazemos um laço dentro do outro. Para casos de um laço apenas, o uso de labels apenas complica a escrita pois podemos usar break ou continue normalmente. O label é um nome, uma etiqueta para o laço e usamos esse nome no break ou no continue para explicitar qual laço queremos interromper ou continuar.
Imagine que temos uma matriz de números (um array de arrays de números para ser mais exato) , e queremos saber se um determinado numero está presente na matriz. O código que fariamos seria assim:
int[][] matriz= { { 32, 87, 3, 589 }, { 12, 1076, 2000, 8 }, { 622, 127, 77, 955 } }; int numeroQueQueremos = 12; int i; int j = 0; boolean encontrado = false; procura: for (i = 0; i < matriz.length; i++) { for (j = 0; j < matriz[i].length; j++) { if (matriz[i][j] == numeroQueQueremos) { encontrado = true; break procura; } } }
Repare que usamos dois laço aninhados para iterar pela matriz. Um laço itera as colunas enquanto o outro as linhas. Mas quando encontramos o numero, tanto faz onde cada laço estiver, queremos simplesmente aborta toda a pesquisa. Se usarmos apenas break, isso significaria terminar o laço que envolver o break, que seria o laço interior. Mas o laço exterior continuaria à toa. O que queremos é interromper o laço exterior de dentro do interior. Para isso usamos um label e etiquetamos o laço exterior com o nome ‘procura’. Assim podemos dar a instrução de break para parar esse laço.
Claro que as mesmas regras de antes se aplicam. Se o método só fizesse isto, o uso de return seria mais indicado. E seria até indicado criar um método apenas para fazer esta pesquisa de forma a podermos tirar vantagem do return. É que o return não precisa saber de onde está sendo chamado ou qual laço terminar. Ele simplesmente termina o método inteiro, o que é ainda melhor. Mas caso, por alguma razão – afinal este é apenas um exemplo muito simples – você não possa usar return, tenha em atenção o uso de labels para que os seus laço não fiquem iterando à toa. Lembre-se que quanto antes terminar um laço, melhor.
Melhor que terminar um laço o mais cedo possível, é não usar laço nenhum. Então, antes de usar um laço, certifique-se não não outra forma de resolver o problema. Normalmente isso passar pelo uso de uma estrutura de dados mais apropriada ou um algoritmo mais astuto.
Os programas funcionam melhor e são mais fáceis de ler quando apenas existem instruções em sequência. Decisões (ifs) e laços (for, while, etc.. ) são considerados males necessários em uma linguagem imperativa. Contudo, usando de orientação a objeto é possível eliminar grande parte desses ifs e fors. Para isso, você precisaria recorrer a um estilo de linguagens mais funcional e menos imperativo, deixando para as estruturas de dados o trabalho de executarem funções em cima dos seus itens, em vez de você fazer isso explicitamente.
Existem alguns truques para isto. O principal passa por encapsular o ciclo em algum objeto e prover ao objeto o miolo que deve ser executado a cada passo. Assim o objeto é livre de executar o miolo do ciclo como quiser. Num for, num while, em sequencia, em paralelo, como ele quiser. Encapsular o ciclo é portanto a forma mais eficiente de evitar o uso de for.
Outro problema que o for trás é que muitas vezes fazemos mais do que um ciclo para chegar em alguma conclusão ou encontrar algum objeto. É comum ficar fazendo ciclos para copiar ou transformar objetos de uma lista em objeto de outra. Ao usar o for explicitamente precisamos de várias listas que guardam os resultados intermédios. Ao encapsular o ciclo, podemos criar um objeto composto (usado o padrão Composite Object) que executa todas as ações de uma vez só para cada elemento na lista original e portanto não usando listas intermédias. Este tipo de objeto e mecanismo ainda é novidade em java e pouco usado em API. Algumas API como a Google Guava fazem uso deste conceito ( A classe Splitter por exemplo). O conceito é implementado recorrendo a padrões conhecidos como Static Factory Method e Method Chaining o que não apenas dá uma fluidez à escrita dos comandos, mas também à sua execução, onde os comandos vão sendo encadeados para depois serem usados uma só vez no final. O segredo disto tudo está no padrão Iterator e no padrão Decorator. O iterador da lista original é decorado com outras formas de iterador. Quando o laço for executado sobre o iterador final cada objeto do iterador original é apenas obtido uma vez, passado pelo conjunto de filtros e transformações no iterador decorador e finalmente apresentado ao laço. Pode ser que nem exista nenhum objeto a apresentar (porque todos foram filtrados fora) ou que o objeto apresentado não é o objeto da lista original porque foi transformado. É possivel ainda transforma cada item numa lista de itens e operar em cada item destas listas , tudo sem precisar de outros laços.
Este reaproveitamento de laços pode se comparar ao reaproveitamento de threads ou de conexões em um pool. Sabemos que utilizar laços é um recurso caro, e portanto queremos usar o mesmo possível e mais que isso, minimizar a repetição.
Esta otimização é sutil, porque se analizarmos um algoritmo que usa 8 laços e um algoritmo que usa as tecnicas de iteradores decorados, vemos que ambos continuam sendo O(N) onde N é o numero de elementos a iterar. Mas o primeiro é 8N e o segundo N. Existe um fator de 8 que pode ser muito relevante e removê-lo é uma otimização. Além disto , ao utilizar objetos que encapsulam o laço em vez de usar o laço diretamente criamos um nível de indireção que nos permite modificar o objeto que faz o laço, para o realizar em paralelo em vez de em sequencia. Isto só é possivel quando um passo do laço não depende do próximo, mas isto é verdade na maioria dos cenários que usam laços. Ao realizar laços em paralelo estamos criando uma outra otimização que poderá ter ganho de performance, mas ainda podemos usar o algoritmo sequencial se o paralelismo não estiver disponível, ou não fizer sentido para aquele laço em particular.
Um exemplo deste tipo de API chegará com uma nova API Stream do java 8 (não é um stream de I/O mas o conceito é parecido) que permite aplicar regras aos itens de uma estrutura de dados sem nunca usar for ou if. Claro que em algum ponto o programador dessa estrutura teve que usar laços e decisões, mas ao usar API como esta, o seu código não tem que usar, e é ai que está a vantagem. No encapsulamento das instruções de laço e de decisão.
É comum em muitas aplicações que algum processo tenha que ser executado em lote, ou seja, em pacotes de subconjuntos para grandes conjuntos de instancias. Estes cenários são normalmente complexos de gerenciar quando ao problema de carregar muitos objetos na memória de uma só vez. A JVM pode engasgar ou até explodir se você tentar instanciar milhões de objetos, apenas porque seu banco de dados têm milhões de linhas. Estratégias para dividir esses dados são necessárias. E elas não são tão triviais como parecem.
Se você poder usar um framework de mercado para isto ( como o Spring Batch) melhor para você. Use. Vai poupar muita dor de cabeça. Mas se não pode, então tenha cuidado com as “otimizações” que faz.
Uma que você pode querer fazer é carregar todas as instancias necessárias antes de mais nada. Isto não incluir apenas as instancias que serão o alvo do trabalho, mas quaisquer instancias auxiliares que serão necessárias durantes o processo. Depois de tudo carregado, execute o processo. Isto lhe dá duas vantagens. Primeiro já o informa se cabe tudo na memória logo no inicio. Se não couber você já sabe que tem que aumentar a memória ou diminuir o lote. Segundo, o processo executará muito rapidamente e sem interrupções porque todos os recursos que ele precisa já estão carregados em memória e não ha necessidade de I/O durante o processamento.
Se o numero de instancias com que tem que trabalhar é imenso, não adianta rezar por um computador com super memória. Já pense numa estratégia distribuída logo de inicio. Primeiro uma distribuída entre várias threads, e depois uma distribuída em várias threads em várias máquinas. Esta é a estratégia que mais funciona. Obviamente não é a mais trivial, mas é que a produz melhores resultados. Se o seu design levar isto em conta, mesmo que numa primeira implementação tudo rode sequencialmente em uma mesma máquina, você abre a porta para melhorar a implementação ate onde for necessário para processar essa quantidade de informação. A otimização preventiva, neste caso é desenhar o sistema para que ele compreenda que o processamento dos lotes pode ocorrer em diferentes máquinas e diferentes threads, mesmo que isso não seja verdade logo de inicio. Aqui a otimização é para diminuir a manutenção futura a qual trará mais velocidade e não para obter velocidade logo num primeiro momento.
Cache é um elemento que sempre aparece nos projetos depois que se descobre que o sistema não é rápido o suficiente. Ele aparece como um pensamento “à posteriori”. Ora isto traz surpresas. Devido ao fato do cache ser normalmente implementado como algo escondido ( a palavra Cache vem do francês cacher que significa esconder) pode trazer consequências inesperadas.
Então não assuma que os métodos que está invocando sempre obtêm a sua informação “fresca”. Alguns podem bem ter um cache sendo usado. Se você precisa da informação “fresca” certifique-se que o método sabe disso de forma que , quando, e se, for usado um cache o método saiba não consultá-lo e pesquisa direto na fonte da informação.
A otimização prematura é aquela em que você torna o código mais complexo com base em uma (pre)suposição de que isso irá melhorar a performance, mas você não sabe verdadeiramente que vai afetar. Este tipo de otimização é de evitar e realmente não deve ser feita. Ainda para mais numa linguagem de máquina virtual onde quanto mais você seguir o padrão mais a VM pode otimizar o código dinamicamente; e isso sim é otimização de verdade. Mas isto não significa que deve fazer o código mais lento e os algoritmos mais estúpidos que você conseguir. Não. A Otimização preventiva é muito importante e representa uma forma de pensar e conduzir a escrita do seu código de uma forma limpa e que os outros programadores podem entender mais fácilmente. A otimização preventiva passa por coisas simples e é para os algoritmos o que a abstração é a para a orientação a objetos. Requer alguma prática e requer que você saiba o que está fazendo, mas não deve ser evitada.
Otimização preventiva não é o mesmo que otimização permutara e saber a diferença pode ajudá-lo a ter menos dores de cabeça e a ser um melhor programador.
Muito bom o artigo só considero que alguns pontos colocados por você não são otimizações! Como falar sobre isso ocuparia muito espaço aqui escrive um post sobre isso
http://tekhton.blogspot.com.br/2013/01/otimizacao-e-boas-praticas-de.html
Primeiro obrigado pelo comentário, é bom ver que alguém está com atenção 🙂
A parte do uso correto de boolean no if tem que ver com usar os objetos dos tipos certos. a instrução que compara com true ou false demonstra que a pessoa não compreende o tipo que está usando e por isso sente necessidade em fazer a comparação como se fosse um inteiro. A parte do for , é apenas para terminar o quanto antes o algoritmo sendo que o resultado já foi obtido. O exemplo é muito simples, mas imagine calcular o factorial de 3 e continuar até calcular fatorial de 100. Não é bom. Talvez o exemplo foi simples de mais, mas o ponto é que o algoritmo, e em especial os laços, devem terminar o mais depressa possível. Você considera isto uma boa prática. Eu também. Você não considera que é uma otimização preventiva. O que eu considero é que é uma boa prática porque é uma otimização preventiva. Caso contrario seria aceitável deixar o laço correr à toa. Não é aceitável e portanto é má prática, exatamente por que está ofendendo a performance do algoritmo.
A meu ver os exemplos estão claros e muito bons. A meu ver a questão do laço não é uma otimização, porque está errado! Não é porque roda e retorna o resultado correto que a lógica esteja correta. Na minha opinião, um algoritmo deve executar somente o número necessário de passos para sua solução! No caso do laço, ele encontrou a resposta mas continua executando! Funciona e retorna a resposta correta mas não é o correto a ser feito.
O problema de se alegar que isso é uma otimização, é que eu não necessito otimizar tudo! Se for um caso simples de busca em um array pequeno, pré definido e que não vai mudar, para que otimizar? Digo isso porque já passei por essa situação com um estagiário! No momento que isso é uma má prática, deve-se refatorar o código para o modo correto independente se funciona! E isso deve ser feito sempre e o contrário deve ser combatido!
Compreendo que isso acaba por otimizar o resultado, então posso dizer que isso é uma otimização! Mas devo antes alegar que é uma má prática, que isso acarreta em perda de desempenho e por isso não deve ser feito.
Não quero fazer disso um bate boca, de quem está certo ou errado. Só quero colocar meu ponto de vista! Então encerro por aqui. Obrigado!
Não se trata de um bate-boca. É simples lógica. Vc mesmos falou que “uma má prática, que isso acarreta em perda de desempenho e por isso não deve ser feito”.
Ora, se acarreta perda de desempenho e você resolve, o que vc está fazendo ? otimização. Vc está tornando o algoritmo mais rápido. Otimização preventiva é feita na simples base lógica e matemática e é uma boa prática porque funciona e ajuda a ter um código que funciona em menos passos. Ser boa prática é consequência de melhorar a performance e não, como vc parece entender, que a otimização é um efeito secundário de ser boa prática.
Muito bom o artigo, meus parabéns.
[…] 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 […]