A minha primeira pergunta sobre streams no Stackoverflow foi há exatos dois anos e seis meses atrás. O IntelliJ possui um recurso que sugere trechos de código que podem ser convertidos para streams e lambdas e os converte sozinho.
Lembro que na época fiquei muito confusa sobre um trecho específico (como usar lambdas e streams?). O recurso chegou em Java há anos atrás em Java 8 e a sintaxe ainda causa estranheza. Eis alguns exemplos de como utilizar!
Filters
Imagine que temos uma lista de nomes e queremos encontrar todos aqueles que começam com a letra J.
List<String> names = Arrays.asList("John", "Jack", "Hamilton", "George");
for (String name : names) {
if (name.startsWith("J")) {
System.out.println(name);
}
}
Os filters são exatamente a regra da busca.
List<String> result = names.stream()
.filter(name -> name.startsWith("J"))
.collect(Collectors.toList());
result.forEach(element -> System.out.println(element));
Note que, como filters retornam streams, consequentemente podemos ter filter logo depois de outro filter. Veja também que com Collectors conseguimos transformar a stream em uma lista de Strings que obedecem a regra do nome começar com J. Ou seja, a saída é:
John
Jack
Podemos também alterar a saída com Collectors:
String result = names.stream()
.filter(name -> name.startsWith("J"))
.collect(Collectors.joining(", "));
System.out.println(result);
Saída
John, Jack
Veja mais exemplos de Collectors no Javadoc.
Method reference
Na lista de strings que tivemos como resultado, temos uma expressão lambda apenas para chamar o método:
result.forEach(element -> System.out.println(element));
Agora Java já deduz que obviamente queremos repassar cada elemento para o método println().
result.forEach(System.out::println);
Optionals
Optionals surgiram para evitar nullPointerExceptions e antes de tentar obter algo, podemos validar se realmente existe.
Ainda para a lista de nomes, vamos tentar encontrar qualquer nome que comece com W.
List<String> names = Arrays.asList("John", "Jack", "Hamilton", "George");
Optional<String> result = names.stream()
.filter(name -> name.startsWith("W"))
.findAny();
if (!result.isPresent()) {
System.out.println("Not found");
}
Podemos reescrever esse trecho também lançando direto uma exceção, caso o nome não exista:
names.stream()
.filter(name -> name.startsWith("W"))
.findAny()
.orElseThrow(IllegalStateException::new);
Map e reduce
Note que cada “operação executada” na stream é sequencial, e a ordem importa. Isto é, por exemplo, colocar um filter antes de um findAny é diferente de um findAny primeiro e depois o filter.
Um exemplo disso é como obter todas as pessoas que possuem mais de 20 anos e para estas alterar o nome para upperCase (caixa alta):
List<Person> people = Arrays.asList(new Person("John", 26),
new Person("Jack", 40),
new Person("Hamilton", 14),
new Person("George", 63));
people.stream()
.filter(p -> p.getAge() > 20)
.map(p -> p.getName().toUpperCase())
.forEach(System.out::println);
A função map é a responsável por “transformar” a saída.
Saída
JOHN
JACK
GEORGE
Para todos aqueles que possuem idade maior que 20, podemos obter essas idades através do map:
people.stream()
.filter(p -> p.getAge() > 20)
.mapToInt(Person::getAge)
.forEach(System.out::println);
Note que o processo consiste nas fases:
- Buscar pessoas com mais de 20 anos
- Para cada uma pessoa encontrada na busca, obter a idade
Através do reduce podemos fazer operações com cada resultado da stream. Por exemplo, somar todas as idades maiores que 20.
OptionalInt result = people.stream()
.filter(p -> p.getAge() > 20)
.mapToInt(Person::getAge)
.reduce((a, b) -> a + b);
if (result.isPresent()) {
System.out.println(result.getAsInt());
}
O método reduce obtém a idade de John, soma com a idade de Jack, e por fim, soma com a idade de George.
Note que a maioria das funções mais comuns já estão implementadas como obter a média, obter o valor mínimo/máximo, etc.
Por exemplo, temos que o trecho anterior é equivalente a:
int result = people.stream()
.filter(p -> p.getAge() > 20)
.mapToInt(Person::getAge)
.sum();
System.out.println(result);
Saída
129
Boas práticas
Esses conceitos implementados em Java surgiram na programação funcional há muito tempo. As ideias centrais da programação funcional são os conceitos de pureza, idempotência e side effect.
Resumidamente, temos que:
- A operação executada para determinar o valor retornado pelo método deve ser baseada apenas nas entradas e não em atributos/variáveis externos.
- Além disso, o método não deve alterar o valor de atributos (atribuindo algum outro valor)
- Para cada chamada ao método (repassando os mesmos parâmetros) devemos obter sempre o mesmo valor.
Para entender mais sobre conceitos de programação funcional com exemplos, veja aqui.
Note que Java nos obriga escrever expressões entre { } somente em casos em que há mais de uma linha. Exemplo:
people.stream()
.filter(p -> p.getAge() > 20)
.forEach(p -> {
System.out.println(p.getAge());
System.out.println(p.getName());
});
Trechos de código que usam lambdas e possuem várias linhas dificilmente não estão violando os conceitos de programação funcional. Portanto, evite.
Além disso, uma boa prática para facilitar a leitura é sempre quebrar em uma nova linha o pipeline das streams, ao invés de tudo em uma só linha.
Portanto, ao invés de:
people.stream().filter(p -> p.getAge() > 20).forEach(System.out::println);
Escreva:
people.stream()
.filter(p -> p.getAge() > 20)
.forEach(System.out::println);
Off topic
Apesar de Java ser uma linguagem estática, lambdas em Java foi possível graças a Duck Typing. Isto é, em tempo de execução é inferido qual é sua classe através de InvokeDynamic.