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:

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.

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.

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

2. Segundo Parâmetro: A Expressão Lambda

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 }

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>

2. Function<T, R>

3. Consumer<T>

4. Supplier<T>

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);
}

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:

  1. Fonte (Source): Obter um stream a partir de uma coleção.
  2. 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.
  3. 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:

Dentre as principais operações da Stream API temos:

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)

2. Optional.ofNullable(valor)

3. Optional.empty()

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:

4.3. Cuidado: orElse() vs. orElseGet()

Existe uma diferença de performance sutil, mas importante, entre orElse() e orElseGet().

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:

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("@");
    }
}

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:

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:

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:

2) Users e Subscription Plans Os usuários da plataforma podem pertencer a dois perfis:

Os planos de assinatura (SubscriptionPlan) disponíveis para alunos são:

3) Sistema de Enrollments e Progress

4) Fila de Suporte ao User

5) Relatórios e Análises da Plataforma O sistema deve ser capaz de gerar as seguintes informações analíticas:

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:

  1. 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.
  2. 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).
  3. 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)

Fila de Suporte

Relatórios e Análises O sistema deve gerar as seguintes informações utilizando Stream API:

Requisitos de Implementação e Ferramentas

Para este protótipo, as seguintes ferramentas e abordagens devem ser utilizadas:

  1. 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.
  2. Estruturas de Dados Específicas:
    • Para garantir a unicidade e a busca eficiente de Courses por title e Users por email, utilize a interface Map.
    • Para a listagem de instructors únicos no relatório, utilize a interface Set.
    • Para a fila de Support Tickets, utilize uma implementação da interface Queue (como LinkedList ou ArrayDeque) para garantir o comportamento FIFO.
  3. 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.
  4. 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.
  5. Tratamento de exceções:
    • Operações que violem regras de negócio (por exemplo, a tentativa de matricular um Student com BasicPlan no quarto curso, ou em um curso INACTIVE) 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.
  6. Modelagem implícita:
    • Você notará que o conceito de "matrícula" (Enrollment) é central para o sistema, pois conecta Student e Course e armazena o progress. 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.

Modelagem da Solução

Para implementar a aplicação, utilize conceitos da Programação Orientada a Objetos (POO) com Java, incluindo:

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.

Mãos à obra! ⚒️