Aula 02 - Aprofundando em Java: Lambdas, Streams e Optionals
Na Aula 01, revisamos a base da Programação Orientada a Objetos em Java. Agora, vamos explorar um conjunto de recursos introduzidos a partir do Java 8 que transformaram a maneira como escrevemos código: Expressões Lambda, a Stream API e a classe Optional
.
Essas ferramentas são o coração do estilo de programação funcional em Java e são usadas extensivamente em frameworks como o Spring Boot. Compreendê-las não é apenas um diferencial, mas um requisito para escrever código limpo e expressivo.
Não se preocupe se alguns conceitos ainda ficarem "nublados" nesse primeiro momento. Os pontos se conectarão mais adiante quando os utilizarmos para construção de nossas APIs Rest. 🧑💻
1. Mudando a Perspectiva: Uma Introdução à Programação Funcional
Até agora, nossa visão sobre Java foi moldada pela Programação Orientada a Objetos (POO). Na POO, modelamos o mundo como um conjunto de objetos que possuem estado (atributos) e comportamento (métodos). O foco está nos substantivos: um Carro
, um Cliente
, uma NotaFiscal
. Nosso código opera modificando o estado desses objetos ao longo do tempo.
Agora, vamos introduzir uma perspectiva diferente, mas complementar: a Programação Funcional (PF).
Na Programação Funcional, em vez de focar em objetos que mudam de estado, pensamos em termos de transformação de dados. O software é visto como uma série de funções matemáticas, onde cada função recebe uma entrada, processa-a e produz uma saída, sem alterar nada fora de seu escopo. O foco aqui está nos verbos: calcular
, filtrar
, transformar
.
1.1 Os Pilares da Programação Funcional
Para entender essa abordagem, precisamos conhecer três conceitos-chave que a sustentam:
1. Funções como Cidadãos de Primeira Classe (First-Class Citizens)
Este é o pilar central. Significa que as funções são tratadas como qualquer outro valor no programa. Em Java, isso é alcançado através das Interfaces Funcionais e Expressões Lambda. Nesse paradigma uma função pode ser:
Atribuída a uma variável:
// A função (x -> x * 2) é atribuída à variável 'dobrar'. // A variável 'dobrar' é do tipo Function<Integer, Integer>, uma interface funcional. Function<Integer, Integer> dobrar = x -> x * 2; // Agora, podemos usar a variável para executar a função. int resultado = dobrar.apply(5); // resultado = 10
Passada como argumento para outra função:
// Este método recebe uma lista e uma função como argumentos. public static List<Integer> aplicarFuncaoNaLista(List<Integer> lista, Function<Integer, Integer> funcao) { List<Integer> novaLista = new ArrayList<>(); for (Integer item : lista) { novaLista.add(funcao.apply(item)); } return novaLista; } // No código principal, passamos a função 'dobrar' como argumento. List<Integer> numeros = Arrays.asList(1, 2, 3); List<Integer> numerosDobrados = aplicarFuncaoNaLista(numeros, dobrar); // [2, 4, 6]
Retornada como resultado de outra função:
// Este método retorna uma função que multiplica por um valor específico. public static Function<Integer, Integer> criarMultiplicador(int multiplicador) { return numero -> numero * multiplicador; } // Criamos e armazenamos novas funções dinamicamente. Function<Integer, Integer> triplicar = criarMultiplicador(3); Function<Integer, Integer> quintuplicar = criarMultiplicador(5); int resultadoTriplo = triplicar.apply(10); // resultadoTriplo = 30 int resultadoQuintuplo = quintuplicar.apply(10); // resultadoQuintuplo = 50
Essa capacidade é o que abre as portas para as Expressões Lambda, que nada mais são do que uma forma de escrever essas funções "portáteis".
2. Imutabilidade e a Ausência de Efeitos Colaterais (Side Effects)
Este é talvez o maior contraste com a POO tradicional.
- Efeito Colateral (Side Effect): É qualquer alteração de estado fora do escopo da função. Modificar um atributo de um objeto que foi passado como parâmetro, alterar uma variável global, escrever em um arquivo ou no console são todos exemplos de efeitos colaterais.
- Imutabilidade: Significa que os dados, uma vez criados, não podem ser alterados. Em vez de modificar uma lista existente, uma função funcional cria e retorna uma nova lista com as mudanças desejadas.
Vamos ver a diferença na prática.
Exemplo com Efeito Colateral (abordagem mutável):
public void dobrarValores(List<Integer> numeros) {
// EFEITO COLATERAL: A lista original passada como parâmetro é modificada.
for (int i = 0; i < numeros.size(); i++) {
numeros.set(i, numeros.get(i) * 2);
}
}
List<Integer> minhaLista = new ArrayList<>(Arrays.asList(1, 2, 3));
dobrarValores(minhaLista);
// Agora 'minhaLista' foi alterada e contém [2, 4, 6].
// Isso pode ser inesperado e causar bugs em outras partes do código que usam 'minhaLista'.
Exemplo Sem Efeitos Colaterais (abordagem imutável e funcional):
public List<Integer> dobrarValores(List<Integer> numeros) {
// SEM EFEITO COLATERAL: A lista original não é modificada.
// Uma nova lista é criada e retornada.
return numeros.stream()
.map(n -> n * 2)
.collect(Collectors.toList());
}
List<Integer> minhaLista = Arrays.asList(1, 2, 3);
List<Integer> listaDobrada = dobrarValores(minhaLista);
// 'minhaLista' permanece intacta: [1, 2, 3]
// 'listaDobrada' contém o novo resultado: [2, 4, 6]
E isso é valioso porque código sem efeitos colaterais é mais previsível. Uma função, dadas as mesmas entradas, sempre produzirá as mesmas saídas. Isso torna o código mais fácil de testar, depurar e, especialmente, de paralelizar, pois elimina as condições de corrida (quando múltiplas threads tentam modificar o mesmo dado ao mesmo tempo).
3. Programação Declarativa vs. Imperativa
Este pilar define o estilo de escrita do código.
- Estilo Imperativo (o "como"): Você descreve passo a passo como o computador deve executar uma tarefa. Laços
for
ewhile
são a marca registrada desse estilo. Você controla cada detalhe do fluxo. - Estilo Declarativo (o "o quê"): Você descreve o que você quer como resultado, e a linguagem ou API se encarrega dos detalhes da execução.
Vejamos um exemplo simples: obter os nomes dos produtos com preço acima de R$100.
Abordagem Imperativa (foco no "COMO"):
List<String> nomesProdutosCaros = new ArrayList<>();
for (Produto produto : listaProdutos) {
if (produto.getPreco() > 100.0) {
nomesProdutosCaros.add(produto.getNome());
}
}
Aqui, nós instruímos cada passo: crie uma lista vazia, itere sobre a lista original, verifique a condição, adicione à nova lista.
Abordagem Declarativa (foco no "O QUÊ"):
List<String> nomesProdutosCaros = listaProdutos.stream()
.filter(p -> p.getPreco() > 100.0)
.map(p -> p.getNome())
.collect(Collectors.toList());
Aqui, nós simplesmente declaramos o que queremos: "A partir da lista de produtos, filtre aqueles com preço maior que 100, mapeie o resultado para seus nomes e colete em uma nova lista". A Stream API cuida do "como" por baixo dos panos.
1.2 Conectando com o que Você Já Sabe: Recursão
Se você já usou recursão, já teve um contato com o pensamento funcional. A recursão é uma técnica clássica da Programação Funcional para realizar repetições sem usar laços que dependem de variáveis de controle mutáveis (como o int i = 0
do for
).
Por que Estamos Aprendendo Isso?
O Java, a partir da versão 8, abraçou fortemente os conceitos da programação funcional. O motivo é prático: esse paradigma ajuda a escrever código mais conciso, expressivo e seguro, especialmente ao lidar com coleções de dados e programação concorrente. No desenvolvimento de APIs com Spring Boot, você usará esses conceitos o tempo todo para manipular dados, tratar respostas assíncronas e escrever lógicas de negócio de forma mais limpa.
Agora que entendemos a filosofia da programação funcional, vamos analisar as ferramentas que o Java nos oferece para aplicá-la na prática. Começaremos pelas Expressões Lambda.
2. Expressões Lambda: A Revolução da Concisão
Antes do Java 8, tarefas simples como ordenar uma lista com um critério customizado ou definir um evento de clique exigiam a criação de classes anônimas internas, resultando em um código verboso.
O problema:
// Ordenando uma lista de Strings por tamanho (antes do Java 8)
Collections.sort(nomes, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
Toda essa estrutura (new Comparator...
) existe apenas para passar um único método (compare
) como lógica.
As Expressões Lambda resolvem isso, permitindo que você trate funcionalidades como um argumento de método, ou código como dados.
A solução com Lambda:
// A mesma ordenação, agora com uma expressão lambda
Collections.sort(nomes, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
O código faz exatamente a mesma coisa, mas de forma muito mais direta e legível. A seguir, vamos detalhar cada parte desse código.
Ele utiliza o método Collections.sort()
, que aceita dois parâmetros:
1. Primeiro Parâmetro: nomes
- O que é: É a
List<String>
que você deseja ordenar. - Função: É o alvo da operação de ordenação.
2. Segundo Parâmetro: A Expressão Lambda
O que é: É a lógica de comparação, fornecida como uma função. Ela diz ao método
sort
como ele deve decidir qual dos dois elementos vem primeiro.Função: A lambda
(s1, s2) -> Integer.compare(s1.length(), s2.length())
é uma implementação concisa da interface funcionalComparator<String>
. Vamos quebrar a lambda em partes:(s1, s2)
: Estes são os parâmetros da função. Em qualquer momento da ordenação, o algoritmo pegará dois elementos da listanomes
para comparar, que serão representados pors1
es2
(ambos do tipoString
).->
: É o operador que separa os parâmetros do corpo (a lógica) da função.Integer.compare(s1.length(), s2.length())
: Esta é a implementação da lógica de comparação.s1.length()
: Pega o comprimento (número de caracteres) da primeira String.s2.length()
: Pega o comprimento da segunda String.Integer.compare(a, b)
: Este método compara dois inteiros (a
eb
) e retorna:- Um número negativo se
a
for menor queb
. - Zero se
a
for igual ab
. - Um número positivo se
a
for maior queb
.
- Um número negativo se
O algoritmo de ordenação do Collections.sort
usa esse retorno para organizar a lista, resultando em uma ordenação baseada no comprimento das strings, da menor para a maior.
Bem prático, né? 👽
2.1. Anatomia de uma Expressão Lambda
Uma lambda é, basicamente, uma função anônima. Como vimos no exemplo acima, sua sintaxe é:
(parâmetros) -> { corpo da função }
- Parâmetros: Uma lista de parâmetros de entrada (pode ser vazia). O tipo pode ser omitido se o compilador conseguir inferi-lo.
- Seta
->
: Separa os parâmetros do corpo. - Corpo: Uma única expressão ou um bloco de código. Se for uma única expressão, o
return
é implícito e as chaves{}
são opcionais.
2.2. Interfaces Funcionais: A Base das Lambdas
Uma expressão lambda só pode ser usada em um contexto onde um tipo é esperado. Pense nesse tipo como um contrato ou um molde para a lambda. Em Java, esse molde é sempre uma Interface Funcional.
Uma Interface Funcional é definida por uma regra simples: ela deve conter apenas um método abstrato. É a assinatura desse único método que define o formato que a lambda deve seguir (quais parâmetros ela recebe e o que ela retorna).
O Java já nos fornece um conjunto de interfaces funcionais prontas no pacote java.util.function
. Dentre as mais comuns temos:
1. Predicate<T>
- Propósito: Testar uma condição.
- Método abstrato:
boolean test(T t)
- Contrato: Recebe um objeto do tipo
T
e retornatrue
oufalse
. - Exemplo de Lambda:
// Testa se um número é par Predicate<Integer> isEven = numero -> numero % 2 == 0; System.out.println(isEven.test(10)); // Saída: true
- Uso comum: Ideal para filtros, como em
stream().filter()
.
2. Function<T, R>
- Propósito: Transformar um valor de um tipo em outro.
- Método abstrato:
R apply(T t)
- Contrato: Recebe um objeto do tipo
T
e retorna um objeto do tipoR
. - Exemplo de Lambda:
// Transforma uma String em seu comprimento (Integer) Function<String, Integer> getLength = texto -> texto.length(); System.out.println(getLength.apply("Java")); // Saída: 4
- Uso comum: Perfeita para mapeamentos, como em
stream().map()
.
3. Consumer<T>
- Propósito: "Consumir" um valor para executar uma ação, sem retornar nada.
- Método abstrato:
void accept(T t)
- Contrato: Recebe um objeto do tipo
T
e retornavoid
. - Exemplo de Lambda:
// Ação de imprimir uma String no console Consumer<String> printText = texto -> System.out.println(texto); printText.accept("Olá, Mundo!"); // Imprime "Olá, Mundo!"
- Uso comum: Utilizada em operações terminais que executam algo, como
list.forEach()
.
4. Supplier<T>
- Propósito: Fornecer/criar um valor sem receber nenhuma entrada.
- Método abstrato:
T get()
- Contrato: Não recebe parâmetros e retorna um objeto do tipo
T
. - Exemplo de Lambda:
// Fornece a data e hora atuais Supplier<LocalDateTime> now = () -> LocalDateTime.now(); System.out.println(now.get());
- Uso comum: Para criação de objetos ou fornecimento de valores padrão, como em
Optional.orElseGet()
.
A anotação @FunctionalInterface
pode ser usada para garantir que uma interface cumpra o requisito de ter apenas um método abstrato. Ela é opcional, mas funciona como uma verificação de segurança para o compilador e uma dica clara para outros desenvolvedores de que a interface foi projetada para ser usada com lambdas. Vejamos como isso funciona.
Criando sua própria Interface Funcional
Você pode facilmente criar suas próprias interfaces funcionais para representar contratos específicos do seu negócio.
Exemplo: Criando um formatador de texto
Vamos supor que precisamos de diferentes formas de formatar uma String
. Podemos criar uma interface para isso:
@FunctionalInterface
public interface TextFormatter {
String format(String text);
}
- A Regra: A interface
TextFormatter
tem apenas um método abstrato,format(String text)
. - A Anotação:
@FunctionalInterface
garante que, se alguém tentar adicionar outro método abstrato a esta interface, o código não compilará.
Como usar:
Agora, podemos criar diferentes implementações para este contrato usando lambdas:
// Implementação 1: Converte para maiúsculas
TextFormatter toUpperCase = texto -> texto.toUpperCase();
// Implementação 2: Adiciona "!!!" no final
TextFormatter addExclamation = texto -> texto + "!!!";
System.out.println(toUpperCase.format("java")); // Saída: JAVA
System.out.println(addExclamation.format("Atenção")); // Saída: Atenção!!!
Lembrando: o mais importante aqui é o conceito!
@FunctionalInterface
formaliza a criação de "moldes" para as suas lambdas. Não se preocupe em decorar todas as interfaces prontas do Java agora. O fundamental é entender que, por trás de toda expressão lambda, existe uma interface funcional definindo o contrato que ela cumpre.
3. Stream API: Processando Coleções de Forma Declarativa
A Stream API é talvez a adição mais impactante do Java 8. Pense nela como uma linha de montagem para dados: você coloca uma coleção de itens em uma ponta, e eles passam por várias estações (filtragem, transformação, ordenação) até saírem do outro lado como um resultado final. Ela introduz uma nova forma de processar coleções de dados, focando no "o que fazer" em vez de "como fazer".
Uma Stream não é uma estrutura de dados, mas sim uma sequência de elementos de uma fonte (como uma List
ou Set
) que suporta operações agregadas.
O trabalho com streams geralmente segue um padrão de três etapas:
- Fonte (Source): Obter um stream a partir de uma coleção.
- Operações Intermediárias (Intermediate Operations): Transformar o stream (filtrar, mapear, etc.). Essas operações são lazy (preguiçosas), ou seja, nada acontece até que uma operação terminal seja chamada.
- Operação Terminal (Terminal Operation): Produzir um resultado ou um efeito colateral a partir do stream (coletar em uma lista, calcular uma soma, etc.).
Contexto Prático: Onde a Stream API Brilha?
Imagine que você está trabalhando em uma API para um e-commerce. A equipe de marketing pediu um novo endpoint: GET /api/produtos/destaques
.
A Regra de Negócio: Este endpoint deve retornar uma lista contendo apenas os nomes dos produtos considerados "premium" (com preço acima de R$ 500), e esses nomes devem estar em letras maiúsculas para serem exibidos em um banner promocional no site.
Essa lógica de negócio pertence à camada de Serviço (Service
). O Controller
apenas receberá a requisição e chamará o método do serviço.
Vamos ver como as duas abordagens se sairiam dentro de um ProductService
.
1. Abordagem Imperativa (Com for
)
No seu ProductService
, o método ficaria assim:
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public List<String> findFeaturedProductNamesImperative() {
// 1. Busca todos os produtos do banco de dados.
List<Product> allProducts = productRepository.findAll();
// 2. Prepara uma lista vazia para armazenar os resultados.
List<String> featuredNames = new ArrayList<>();
// 3. Itera sobre cada produto para aplicar a regra de negócio (o "COMO").
for (Product product : allProducts) {
if (product.getPrice() > 500.0) {
String upperCaseName = product.getName().toUpperCase();
featuredNames.add(upperCaseName);
}
}
// 4. Retorna a lista preenchida.
return featuredNames;
}
}
Este código funciona, mas ele detalha cada passo mecânico necessário para chegar ao resultado.
2. Abordagem Declarativa (Com Stream API)
Usando a Stream API, o mesmo método fica muito mais direto:
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public List<String> findFeaturedProductNamesFunctional() {
// 1. Busca todos os produtos do banco.
List<Product> allProducts = productRepository.findAll();
// 2. Descreve a transformação desejada (o "O QUÊ").
return allProducts.stream()
.filter(product -> product.getPrice() > 500.0) // Filtra produtos 'premium'
.map(product -> product.getName().toUpperCase()) // Mapeia para o nome em maiúsculas
.collect(Collectors.toList()); // Coleta os resultados em uma nova lista
}
}
Ou seja, ambos os métodos entregam o mesmo resultado. No entanto, em um serviço de uma aplicação real, a abordagem funcional (declarativa) é geralmente preferida porque:
Expressa melhor a intenção: O código lê como uma descrição da regra de negócio ("pegue os produtos, filtre por preço, transforme em nome maiúsculo"), não como uma série de instruções de baixo nível.
É mais concisa e menos propensa a erros: Menos código "boilerplate" (como criar e adicionar a uma lista manualmente) significa menos lugares para bugs se esconderem.
Facilita a manutenção: Se a regra mudar (ex: "agora também ordene por nome"), adicionar uma nova operação ao stream (
.sorted()
) é trivial.
Dentre as principais operações da Stream API temos:
filter(Predicate<T>)
: Retorna um stream com elementos que correspondem a uma condição.map(Function<T, R>)
: Transforma cada elemento de um tipoT
para um tipoR
.sorted()
: Ordena os elementos (usando a ordem natural ou umComparator
).distinct()
: Remove elementos duplicados.forEach(Consumer<T>)
: Executa uma ação para cada elemento (operação terminal).collect(Collector)
: Agrupa os resultados em uma coleção, comoList
,Set
ouMap
(operação terminal).findFirst()
/findAny()
: Retorna umOptional
com o primeiro elemento encontrado (operação terminal).
Veremos algumas dessas ao longo do semestre (e nos exercícios!).
4. A Classe Optional
: Tratando a Ausência de Valor com Elegância
Um dos erros mais comuns em Java é o NullPointerException
. Ele ocorre quando tentamos usar um membro de uma referência de objeto que é null
. A classe Optional<T>
foi introduzida para fornecer uma solução de tipo seguro para representar a presença ou ausência de um valor.
Optional
é um container: um objeto que pode ou não conter um valor não nulo. Ele nos força a pensar sobre o caso em que o valor não está presente.
4.1. Criando um Optional
Existem três maneiras principais de criar uma instância de Optional
, cada uma adequada a um cenário diferente. Entender qual usar é o primeiro passo para escrever um código mais seguro.
1. Optional.of(valor)
Quando usar: Quando você tem um valor que, por contrato, você tem certeza de que não é nulo.
Comportamento: Cria um
Optional
que "envolve" o seu objeto. Se você tentar passarnull
paraOptional.of()
, ele falhará imediatamente (fail-fast) com umNullPointerException
. Isso é útil para validar suas próprias premissas no código.// Cenário: Você acabou de criar um novo objeto. Ele NUNCA será nulo. User newUser = new User("Ana"); Optional<User> userOptional = Optional.of(newUser); // Correto e seguro. User userNulo = null; // A linha abaixo lançaria um NullPointerException, protegendo o resto do código. // Optional<User> userNuloOptional = Optional.of(userNulo);
2. Optional.ofNullable(valor)
Quando usar: Este é o método mais seguro e comum. Use-o quando você está lidando com um valor que pode ser nulo, como o retorno de um método de busca ou de um mapa.
Comportamento: Se o valor fornecido não for nulo, ele o envolve em um
Optional
. Se o valor fornull
, ele cria umOptional
vazio (Optional.empty()
). Este método nunca lançaNullPointerException
.// Cenário: Buscando um usuário que pode ou não existir em um repositório. // O método findById pode retornar um User ou null. User foundUser = userRepository.findById(1L); Optional<User> userOptional = Optional.ofNullable(foundUser); // Forma segura de lidar com o resultado.
3. Optional.empty()
Quando usar: Quando você precisa representar explicitamente a ausência de um valor. É frequentemente usado como valor de retorno em métodos que podem não encontrar o que procuram.
Comportamento: Retorna uma instância de
Optional
que está garantidamente vazia.// Cenário: Um método que retorna um Optional<User>, mas uma validação inicial falha. public Optional<User> findUser(Long id) { if (id <= 0) { return Optional.empty(); // Retorna "nada" de forma segura e explícita. } // ... continua a lógica de busca ... return Optional.ofNullable(userRepository.findById(id)); }
Método | Quando Usar | Comportamento com null |
---|---|---|
Optional.of(obj) |
Quando obj não pode ser nulo. |
Lança NullPointerException . |
Optional.ofNullable(obj) |
Quando obj pode ser nulo. |
Retorna Optional.empty() . |
Optional.empty() |
Para retornar explicitamente um valor ausente. | N/A (já retorna um Optional vazio). |
4.2. Usando Optional
de Forma Segura
A maneira errada de usar Optional
é simplesmente chamar .get()
sem verificar se o valor está presente, pois isso pode lançar uma NoSuchElementException
.
Para entender o motivo, precisamos pensar na proposta do Optional
: ele é um contrato que diz "este container pode ou não ter um valor". O método .get()
foi projetado para ser a forma mais direta de extrair o valor, mas ele opera sob uma premissa perigosa: ele assume que o valor existe.
Quando o Optional
está vazio, o contrato do método .get()
é lançar a exceção NoSuchElementException
. Este é o seu comportamento esperado.
O problema: NoSuchElementException
é uma RuntimeException
(uma exceção não checada). Isso significa que o compilador Java não obriga você a tratá-la com um bloco try-catch
. Se você chamar .get()
em um Optional
vazio sem uma verificação prévia, e não houver um tratamento de exceção, sua aplicação irá quebrar em tempo de execução.
Exemplo prático do erro:
// Cenário: Buscamos um produto que não existe no banco de dados.
Optional<Product> productOptional = productRepository.findById(999L); // Retorna Optional.empty()
// Tentativa errada de extrair o valor
try {
Product product = productOptional.get(); // <<-- ISTO VAI LANÇAR A EXCEÇÃO
System.out.println("Produto encontrado: " + product.getName());
} catch (NoSuchElementException e) {
System.err.println("ERRO: O produto não foi encontrado. A aplicação quebrou aqui!");
// Em uma API REST, isso resultaria em um erro 500 (Internal Server Error)
// se não fosse tratado, o que é uma péssima experiência para o usuário.
}
Essencialmente, chamar .get()
sem uma verificação (.isPresent()
) é trocar um risco (NullPointerException
) por outro (NoSuchElementException
), o que não resolve o problema fundamental de lidar com a ausência de valor.
É por isso que as formas idiomáticas (ifPresent
, orElse
, orElseThrow
, etc.) são preferíveis: elas forçam o desenvolvedor a lidar explicitamente com o caso em que o Optional
está vazio, resultando em um código mais seguro, robusto e previsível.
As formas seguras e idiomáticas de usar Optional
são as seguintes:
ifPresent(Consumer<T>)
: Executa uma ação somente se o valor estiver presente.Optional<User> user = userRepository.findById(1L); user.ifPresent(u -> System.out.println("Usuário encontrado: " + u.getName()));
orElse(T other)
: Retorna o valor se presente, senão retorna um valor padrão.String username = user.map(User::getName).orElse("Usuário Padrão");
orElseThrow(Supplier<X> exceptionSupplier)
: Retorna o valor se presente, senão lança uma exceção. Esta é a forma mais comum em APIs REST.Contact contact = contactRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Contato não encontrado: " + id));
4.3. Cuidado: orElse()
vs. orElseGet()
Existe uma diferença de performance sutil, mas importante, entre orElse()
e orElseGet()
.
orElse(valorPadrao)
: OvalorPadrao
é sempre calculado, mesmo que oOptional
contenha um valor.orElseGet(supplier)
: Osupplier
(uma lambda) só é executado se oOptional
estiver vazio.
Exemplo:
// Método custoso que busca um nome padrão no banco de dados
public String getNomePadraoSistema() {
System.out.println("Executando método custoso...");
// ...lógica de banco...
return "Admin";
}
Optional<String> nomeUsuario = Optional.of("Ana");
// Mau uso: o método custoso será executado desnecessariamente.
String nome1 = nomeUsuario.orElse(getNomePadraoSistema());
// Console imprime: "Executando método custoso..."
// Bom uso: o método custoso NÃO será executado.
String nome2 = nomeUsuario.orElseGet(this::getNomePadraoSistema);
// Console não imprime nada.
Regra geral: Se o valor padrão for uma constante simples, use orElse()
. Se a criação do valor padrão for custosa (envolve I/O, cálculo, etc.), use orElseGet()
. Vejamos abaixo uma tabela comparativa entre esses dois método.
Tabela Comparativa: orElse()
vs. orElseGet()
Característica | orElse(T valor) |
orElseGet(Supplier<T> supplier) |
---|---|---|
Argumento que Aceita | Um valor (T ) já criado ou uma constante. |
Uma função/lambda (Supplier<T> ) que sabe como criar o valor. |
Custo da Operação Padrão | O valor padrão é calculado sempre, mesmo se o Optional contiver um valor. |
A função para criar o valor padrão é executada apenas se o Optional estiver vazio. |
Cenário Ideal | Usar com valores constantes ou objetos que já existem e são baratos de obter. | Usar quando a criação do valor padrão exige cálculo, I/O ou chamadas a outros métodos custosos. |
O exemplo abaixo exemplifica novamente os conceitos, para não deixarmos nenhuma dúvida sobre quando usar um ou outro. 🤓
Mais um exemplo de Código Prático
Imagine que estamos buscando uma configuração em um mapa. Se a configuração não existir, precisamos obter um valor padrão de um método que simula uma operação lenta (como uma consulta ao banco).
import java.util.Map;
import java.util.Optional;
public class OptionalExample {
// Método que simula uma operação custosa (ex: acesso a banco, chamada de API)
public static String getExpensiveDefaultValue() {
System.out.println(">>> Executando método custoso para obter valor padrão...");
// Simula um delay
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "default_value";
}
public static void main(String[] args) {
Map<String, String> configs = Map.of("key1", "value1");
System.out.println("--- Teste com um Optional que contém valor ---");
Optional<String> optionalWithValue = Optional.of("value1");
// Usando orElse(): O método custoso SERÁ chamado desnecessariamente.
System.out.println("Usando orElse():");
String value1 = optionalWithValue.orElse(getExpensiveDefaultValue());
System.out.println("Resultado: " + value1);
System.out.println("\n"); // Separador
// Usando orElseGet(): O método custoso NÃO será chamado.
System.out.println("Usando orElseGet():");
String value2 = optionalWithValue.orElseGet(() -> getExpensiveDefaultValue()); // ou OptionalExample::getExpensiveDefaultValue
System.out.println("Resultado: " + value2);
System.out.println("\n--- Teste com um Optional vazio ---");
Optional<String> emptyOptional = Optional.empty();
// Com Optional vazio, ambos chamarão o método, mas orElseGet() continua sendo a prática mais segura.
System.out.println("Usando orElse() com Optional vazio:");
String value3 = emptyOptional.orElse(getExpensiveDefaultValue());
System.out.println("Resultado: " + value3);
}
}
Saída do Código:
--- Teste com um Optional que contém valor ---
Usando orElse():
>>> Executando método custoso para obter valor padrão...
Resultado: value1
Usando orElseGet():
Resultado: value1
--- Teste com um Optional vazio ---
Usando orElse() com Optional vazio:
>>> Executando método custoso para obter valor padrão...
Resultado: default_value
Como o resultado demonstra, orElseGet()
evitou a execução desnecessária do método custoso quando o Optional
já continha um valor, otimizando a performance da aplicação.
Passemos agora a um uso prático do Optional
.
4.4. Exemplo Prático: Usando Optional
para Conectar Camadas
Agora que conhecemos as ferramentas, vamos aplicá-las em um cenário realista. Imagine a estrutura de uma aplicação comercial dividida em camadas:
- Repository: Responsável por acessar os dados (simularemos um banco em memória).
- Service: Onde mora a lógica de negócio.
- Controller: A porta de entrada que recebe as requisições (simularemos com uma classe
main
).
Nossa tarefa é criar um método que atualiza o e-mail de um usuário, mas apenas se o usuário existir e se o novo e-mail for válido. Veja como o Optional
cria um contrato seguro e elegante entre essas camadas (lembrando que o código-fonte abaixo é simplificado para fins didáticos!).
Camada 1: O Repositório (Simulando o acesso a dados)
Primeiro, nosso UserRepository
simula a busca no banco. Note como ele já retorna um Optional
, protegendo o resto da aplicação desde a fonte dos dados.
// Simula uma entidade do banco de dados
class User {
private Long id;
private String name;
private String email;
// Construtores, Getters e Setters...
public User(Long id, String name, String email) { this.id = id; this.name = name; this.email = email; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
@Override public String toString() { return "User{id=" + id + ", name='" + name + "', email='" + email + "'}"; }
}
// Simula a classe que acessa o banco de dados
class UserRepository {
// Um mapa para simular nossa tabela de usuários no banco
private final Map<Long, User> database = new HashMap<>();
public UserRepository() {
// Populando o "banco" com dados iniciais
database.put(1L, new User(1L, "Ana", "ana@email.com"));
database.put(2L, new User(2L, "Carlos", "carlos@email.com"));
}
// O método de busca já retorna um Optional, como faz o Spring Data JPA
public Optional<User> findById(Long id) {
return Optional.ofNullable(database.get(id));
}
public User save(User user) {
database.put(user.id, user);
return user;
}
}
Camada 2: O Serviço (Onde a lógica de negócio acontece)
O UserService
recebe o UserRepository
e usa o Optional
para criar um fluxo seguro de validação e atualização.
// Simula a camada de serviço com a lógica de negócio
class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Optional<User> updateUserEmail(Long userId, String newEmail) {
// 1. Busca o usuário. O retorno já é um Optional<User>.
return userRepository.findById(userId)
.filter(user -> isEmailValid(newEmail)) // 2. Continua apenas se o email for válido.
.map(user -> { // 3. Se tudo estiver ok, transforma o objeto.
user.setEmail(newEmail);
return userRepository.save(user); // 4. Salva e retorna o usuário atualizado.
});
}
private boolean isEmailValid(String email) {
return email != null && email.contains("@");
}
}
- Aqui buscamos o
Optional
, filtramos, e mapeamos para a nova versão salva. Se em qualquer ponto oOptional
ficar vazio (usuário não encontrado ou e-mail inválido), as operações seguintes são simplesmente ignoradas.
Camada 3: O "Controller" e a Execução (main
)
Finalmente, nossa classe main
simula a "requisição" do cliente e mostra como o Controller
interpretaria a resposta segura vinda do Service
.
// Simula a camada que recebe as "requisições"
class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
public String handleUpdateEmailRequest(Long id, String newEmail) {
// O "Controller" chama o serviço e lida com o Optional retornado.
return userService.updateUserEmail(id, newEmail)
.map(user -> "200 OK - Usuário atualizado: " + user.toString()) // Se sucesso, formata a resposta OK.
.orElse("404 Not Found - Usuário não encontrado ou email inválido."); // Se vazio, formata a resposta de erro.
}
}
// Ponto de entrada da nossa aplicação simulada
public class MainApplication {
public static void main(String[] args) {
// Injeção de dependência manual: criamos e conectamos as camadas.
UserRepository userRepository = new UserRepository();
UserService userService = new UserService(userRepository);
UserController userController = new UserController(userService);
// --- Simulação das Requisições ---
// Teste 1: Caso de sucesso
System.out.println("Tentativa 1: Atualizando usuário com ID 1...");
String response1 = userController.handleUpdateEmailRequest(1L, "ana.nova@email.com");
System.out.println("Resposta: " + response1);
System.out.println("--------------------");
// Teste 2: Caso de falha (usuário não existe)
System.out.println("Tentativa 2: Atualizando usuário com ID 99...");
String response2 = userController.handleUpdateEmailRequest(99L, "fantasma@email.com");
System.out.println("Resposta: " + response2);
}
}
Este exemplo de ponta a ponta, mesmo sem uso de framework, demonstra o poder do Optional
: ele atua como um contrato seguro entre as camadas, eliminando a necessidade de verificações de nulo e permitindo a criação de fluxos de dados robustos e declarativos, que são fáceis de ler e manter.
5. Method References: "Sintaxe Açucarada" para Lambdas
Em muitos casos, uma expressão lambda apenas chama um método existente. As referências de método (Method References) são uma sintaxe compacta para esses casos.
Tipo de Referência | Exemplo Lambda | Exemplo Method Reference |
---|---|---|
Método Estático | str -> Integer.parseInt(str) |
Integer::parseInt |
Método de Instância (objeto específico) | () -> expensiveObject.get() |
expensiveObject::get |
Método de Instância (objeto arbitrário) | s -> s.toUpperCase() |
String::toUpperCase |
Construtor | () -> new ArrayList<>() |
ArrayList::new |
Exemplo Prático:
A atualização parcial de um contato usava .ifPresent()
:
// Com lambda
dto.getNome().ifPresent(nome -> existingContact.setNome(nome));
// Com method reference (mais limpo e preferível)
dto.getNome().ifPresent(existingContact::setNome);
Ambas as linhas fazem a mesma coisa, mas a referência de método é mais expressiva e fácil de ler.
Concluindo...
Entender Lambdas, Streams e Optionals eleva seu código Java a um novo patamar de clareza e eficiência. Essas ferramentas representam uma mudança de paradigma para um estilo mais declarativo e funcional.
Com essa base sólida, estamos agora mais preparados para construir os componentes de nossas APIS com Spring Boot. Você verá como esses recursos tornam o código em Controllers, Services e Repositories muito mais enxuto e expressivo.
Mas antes, vamos exercitar esse conceitos com...
Exercícios ⚒️
Elabore os exercícios e o desafio abaixo.
A entrega deverá ser feita individualmente via Moodle.
Crie uma classe Produto
com os atributos: nome
(String), preco
(double) e categoria
(String). As categorias devem ser, pelo menos, "Eletrônicos" e "Livros". Em uma classe de teste, crie uma List<Produto>
com pelo menos 8 produtos de diferentes categorias e preços, incluindo alguns com a mesma categoria. Após isso:
a. Use
forEach
e uma estruturaif
tradicional para imprimir o nome de todos os produtos da categoria "Eletrônicos". Em seguida, refaça o mesmo exercício usandostream()
e a operaçãofilter()
.b. Crie uma nova lista contendo apenas os preços de todos os produtos cujo preço seja maior que 500.0. Use as operações
filter()
emap()
. Imprima a lista de preços.c. Calcule o valor total do estoque de produtos da categoria "Livros". Use
filter()
para selecionar os produtos da categoria correta e, em seguida, usemapToDouble()
esum()
para calcular o total.d. Escreva um método
buscarProdutoPorNome(List<Produto> produtos, String nome)
que retorna umOptional<Produto>
. Use a Stream API (filter
efindFirst
).e. No seu método
main
, chame obuscarProdutoPorNome
: Primeiro, com um nome de produto que existe. UseifPresent()
para imprimir os detalhes do produto; Depois, com um nome que não existe. UseorElseThrow()
para lançar umaRuntimeException
com a mensagem "Produto não encontrado!".f. Crie um
stream
a partir da sua lista de produtos e use.map()
para obter umaList<String>
contendo apenas os nomes dos produtos. Primeiro, faça isso com uma expressão lambda (p -> p.getNome()
) e depois refatore para usar uma referência de método (Produto::getNome
).
Desafio - Plataforma de Cursos Online "AcademiaDev"
A startup de tecnologia educacional AcademiaDev está lançando sua nova plataforma de cursos online. Seu modelo de negócio é baseado em um sistema de assinaturas que dá aos alunos acesso a um catálogo de cursos de alta qualidade, focados no desenvolvimento de software.
Para validar sua proposta de negócio, a AcademiaDev contratou sua equipe para desenvolver um protótipo inicial da aplicação. Por um infortúnio do destino, parte de sua equipe foi hospitalizada após a ingestão de dezenas de torresmos no Bar do Bigode ao comemorar mais uma vitória do Corinthians sobre o Palmeiras.
Dessa forma, cabe a você, o(a) único(a) desenvolvedor(a) geração saúde da equipe, trabalhar na implementação desse protótipo inicial utilizando todos os conceitos que foram relembrados na Aula 01 e vistos na Aula 02. Nesse protótipo os requisitos são focados na implementação da lógica de negócio principal, utilizando um conjunto de dados já existente.
Para focar na lógica principal da aplicação, não será necessário implementar as funcionalidades de CRUD completas. Em vez disso, sua aplicação deve iniciar com um conjunto de dados pré-cadastrado. Crie uma classe utilitária (ex: InitialData
) que popule suas estruturas de dados em memória assim que a aplicação iniciar. Ou seja, não é necessário criar um CRUD completo de Courses
ou Users
- apenas o suficiente para validar a lógica de negócio.
Nesse sentido, o protótipo deverá implementar funcionalidades para:
- Gerenciamento do catálogo de
Courses
(cursos); - Gerenciamento de
Users
(usuários) e seus respectivos planos de assinatura; - Sistema de
Enrollments
(matrículas) e acompanhamento de progresso dos alunos; - Um sistema de fila para atendimento de
Support Tickets
; - Geração de relatórios e exportação de dados da plataforma.
A equipe de analistas da sua empresa, a partir de reuniões com os fundadores da AcademiaDev, já havia determinado os requisitos a seguir.
Requisitos Funcionais
1) Gerenciamento do Catálogo de Courses
Os cursos da plataforma devem possuir as seguintes características:
title
edescription
. Otitle
de cada curso deve ser único na plataforma.instructorName
.durationInHours
(carga horária).difficultyLevel
, que pode serBEGINNER
,INTERMEDIATE
ouADVANCED
.status
, que pode serACTIVE
ouINACTIVE
. Um curso com statusINACTIVE
não pode receber novas matrículas.
2) Users
e Subscription Plans
Os usuários da plataforma podem pertencer a dois perfis:
Admin
: Possuiname
eemail
. Tem permissão para gerenciar o catálogo de cursos e usuários.Student
: Possuiname
,email
e umsubscriptionPlan
.- O
email
de cada usuário (sejaStudent
ouAdmin
) deve ser único.
Os planos de assinatura (SubscriptionPlan
) disponíveis para alunos são:
BasicPlan
: Permite que o aluno esteja matriculado em, no máximo, 3 cursos ativos simultaneamente.PremiumPlan
: Permite que o aluno se matricule em um número ilimitado de cursos.
3) Sistema de Enrollments
e Progress
- Um aluno só pode se matricular em um curso (
Course
) se o seu plano de assinatura permitir e se o curso estiver com o statusACTIVE
. - Ao se matricular em um curso, o progresso (
progress
) do aluno é iniciado em 0%. - O sistema deve permitir que um aluno atualize o seu percentual de progresso (0 a 100) em qualquer curso no qual esteja matriculado.
4) Fila de Suporte ao User
- Qualquer usuário da plataforma pode abrir um
SupportTicket
, contendo umtitle
e umamessage
. - Os tickets devem ser armazenados em uma fila de atendimento para serem processados pela equipe de administradores. O atendimento deve seguir rigorosamente a ordem de chegada (FIFO - First-In, First-Out).
5) Relatórios e Análises da Plataforma O sistema deve ser capaz de gerar as seguintes informações analíticas:
- Uma lista de cursos pertencentes a um determinado
difficultyLevel
, ordenada alfabeticamente pelotitle
do curso. - Uma relação de todos os instrutores únicos que ministram cursos ativos na plataforma, sem nomes repetidos.
- Um relatório que agrupe os alunos de acordo com seu
subscriptionPlan
. - O cálculo da média geral de
progress
, considerando todas as matrículas de todos os alunos. - A identificação do aluno com o maior número de matrículas ativas.
- Exportação de Dados para CSV: A plataforma precisa de uma funcionalidade de exportação que permita a um administrador gerar um arquivo CSV a partir de qualquer lista de dados (seja de
Courses
,Students
, etc.). O administrador deve poder escolher dinamicamente quais colunas (campos) quer no arquivo no momento da exportação. Nesse momento, não é necessário gerar um arquivo.csv
físico: a função deve apenas retornar e exibir a estrutura do CSV formatada como umaString
no console.
Funcionalidades da Aplicação (Interface de Linha de Comando)
A aplicação deve ser desenvolvida como um sistema de linha de comando, com um menu que ofereça, no mínimo, as seguintes funcionalidades:
Operações de Administrador (
Admin
)- Gerenciar Status de Cursos: Ativar/inativar cursos existentes (não precisa implementar CRUD completo).
- Gerenciar Planos de Alunos: Alterar o plano de assinatura de um aluno existente.
- Atender Tickets de Suporte: Processar tickets da fila em ordem FIFO.
- Gerar Relatórios e Análises: Acessar todos os relatórios da plataforma.
- Exportar Dados: Gerar a
String
CSV com colunas selecionáveis dinamicamente.
Operações do Aluno (
Student
)- Matricular-se em Curso: Desde que o plano permita e o curso esteja
ACTIVE
. - Consultar Matrículas: Ver todos os cursos em que está matriculado e seu progresso.
- Atualizar Progresso: Modificar o percentual de conclusão de um curso.
- Cancelar Matrícula: Remover-se de um curso (libera vaga para planos básicos).
- Matricular-se em Curso: Desde que o plano permita e o curso esteja
Operações Gerais (Qualquer Usuário):
- Consultar Catálogo de Cursos: Listar cursos ativos disponíveis.
- Abrir Ticket de Suporte: Criar um novo ticket para a fila de atendimento.
- Autenticação Simples: Sistema básico de login por email para distinguir entre Admin e Student. Não é necessário que o usuário tenha senha, apenas o e-mail é suficiente para autenticação. Você poderá elaborar duas telas distintas ou simplesmente atribuir papeis aos usuários e fazer a verificação de permissão.
Lógica de Negócio e Regras
A partir dos requisitos acima, foram destacadas as seguintes regras de negócio que devem ser consideradas no processo de implementação.
Sistema de Matrículas (Enrollments
)
- Um
Student
só pode se matricular em umCourse
se:- Seu plano de assinatura permitir (BasicPlan: máximo 3 matrículas ativas).
- O curso estiver com status
ACTIVE
. - Não estiver já matriculado no mesmo curso.
- O progresso inicia em 0% e pode ser atualizado de 0 a 100%.
Fila de Suporte
- Tickets são processados em ordem FIFO (First-In, First-Out).
- Qualquer usuário pode abrir tickets, mas apenas
Admin
pode processá-los.
Relatórios e Análises O sistema deve gerar as seguintes informações utilizando Stream API:
- Lista de cursos por
difficultyLevel
, ordenada alfabeticamente. - Relação de instrutores únicos que ministram cursos
ACTIVE
. - Agrupamento de alunos por
subscriptionPlan
. - Média geral de progresso de todas as matrículas.
- Aluno com maior número de matrículas ativas (retorno:
Optional<Student>
).
Requisitos de Implementação e Ferramentas
Para este protótipo, as seguintes ferramentas e abordagens devem ser utilizadas:
- Persistência em Memória: Toda a persistência de dados deve ser simulada em memória utilizando as Collections do Java. Não é necessário usar um banco de dados real.
- Estruturas de Dados Específicas:
- Para garantir a unicidade e a busca eficiente de
Courses
portitle
eUsers
poremail
, utilize a interfaceMap
. - Para a listagem de
instructors
únicos no relatório, utilize a interfaceSet
. - Para a fila de
Support Tickets
, utilize uma implementação da interfaceQueue
(comoLinkedList
ouArrayDeque
) para garantir o comportamento FIFO.
- Para garantir a unicidade e a busca eficiente de
- Programação Funcional com Java 8+:
- Todos os relatórios e análises descritos devem ser implementados utilizando a Stream API e Expressões Lambda.
- Reforçando: a função que busca o aluno com mais matrículas deve obrigatoriamente retornar um
Optional<Student>
para tratar o caso de não haver alunos.
- Reflection e Anotações:
- A funcionalidade de Exportação de Dados para CSV deve ser implementada de forma genérica. Crie uma classe utilitária
GenericCsvExporter
que utilize Reflection para ler os campos de qualquer lista de objetos e gerar uma String no formato CSV.
- A funcionalidade de Exportação de Dados para CSV deve ser implementada de forma genérica. Crie uma classe utilitária
- Tratamento de exceções:
- Operações que violem regras de negócio (por exemplo, a tentativa de matricular um
Student
comBasicPlan
no quarto curso, ou em um cursoINACTIVE
) devem lançar exceções customizadas (ex:EnrollmentException
). A interface com o usuário deve capturar essas exceções e exibir uma mensagem de erro amigável.
- Operações que violem regras de negócio (por exemplo, a tentativa de matricular um
- Modelagem implícita:
- Você notará que o conceito de "matrícula" (
Enrollment
) é central para o sistema, pois conectaStudent
eCourse
e armazena oprogress
. No entanto, os atributos e a estrutura exata dessa classe não foram detalhados pela sua equipe. Você deverá modelar essa classe de associação, definindo os campos e métodos necessários para que todas as regras de negócio de matrícula, cancelamento e progresso funcionem corretamente. Sinta-se à vontade para criar outras classes de suporte que julgar necessárias para uma boa organização e para cumprir os requisitos. Caso perceba alguma lacuna, você poderá completá-la como julgar melhor, já que o restante de sua equipe está hospitalizada.
- Você notará que o conceito de "matrícula" (
Modelagem da Solução
Para implementar a aplicação, utilize conceitos da Programação Orientada a Objetos (POO) com Java, incluindo:
- Encapsulamento - Para garantir que os dados dos objetos sejam acessados de forma segura e controlada.
- Herança - Para modelar os diferentes tipos de usuários, se julgar apropriado.
- Polimorfismo - Para permitir o tratamento genérico de diferentes entidades, como os planos de assinatura.
- Classes Abstratas e Interfaces - Para estruturar a hierarquia de suas classes de forma coesa e flexível.
Além da implementação do código, elabore um Diagrama de Classes UML que represente a estrutura do sistema, demonstrando as relações entre as classes (User
, Student
, Admin
, Course
, SubscriptionPlan
, Enrollment
, etc.). Entregue juntamente com o diagrama uma justificativa para suas escolhas de design do protótipo.
Esse exercício poderá ser feito em duplas.