Lições de Quem Veio Antes — Effective Java, Código Limpo e Padrões

Lições de Quem Veio Antes — Effective Java, Código Limpo e Padrões

O que três livros clássicos ensinam sobre escrever Java bem: como criar objetos, por que preferir composição a herança, como nomes e funções pequenas tornam o código legível, e como os padrões de projeto ficaram mais simples com lambdas, records e enums.
Java

• • 29 min de leitura

O que três livros clássicos ensinam sobre escrever Java bem: como criar objetos, por que preferir composição a herança, como nomes e funções pequenas tornam o código legível, e como os padrões de projeto ficaram mais simples com lambdas, records e enums.

As primeiras sete fases deste curso ensinaram o que o Java oferece: a sintaxe, a orientação a objetos, as coleções, os streams, a JVM e a concorrência. Saber usar cada recurso, porém, não garante escrever um programa que outra pessoa, ou você mesmo daqui a seis meses, consiga entender e alterar sem medo. A Fase 8 trata do como: as práticas que separam um código que funciona de um código que continua funcionando depois de anos de mudanças. Esta aula reúne as lições de três livros que moldaram a forma de programar em Java: Effective Java, de Joshua Bloch, Clean Code, de Robert C. Martin, e Design Patterns, dos quatro autores conhecidos como Gang of Four. Nenhum deles é lido aqui como um livro de regras: cada lição vem com o motivo por trás dela, porque é o motivo que permite decidir quando ela não se aplica.

Três livros, três perspectivas

Design Patterns: Elements of Reusable Object-Oriented Software foi publicado em 1994 por Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides, a "Gangue dos Quatro" (Gang of Four, GoF). Um ano antes do Java, o livro catalogou 23 soluções recorrentes para problemas de desenho orientado a objetos, com exemplos em C++ e Smalltalk, e deu a elas nomes que se tornaram vocabulário comum: Strategy, Observer, Decorator, Factory, Singleton. O valor principal do livro não está nas soluções, mas nos nomes: dizer "isso é um Decorator" comunica em três palavras um desenho inteiro.

Effective Java, de Joshua Bloch, teve a primeira edição em 2001, a segunda em 2008 e a terceira, a atual, em 2018, cobrindo até o Java 9. Bloch projetou o Collections Framework e trabalhou anos na biblioteca padrão, e o livro reúne 90 itens curtos no formato "prefira X a Y, e eis por quê", sobre as armadilhas específicas do Java. Vários itens já apareceram neste curso: o contrato de equals e hashCode em "Iguais ou o Mesmo?", o uso de enums em "Dados sem Cerimônia", e a preferência por executores em "Não Crie Threads, Entregue Tarefas".

Clean Code, de Robert C. Martin, publicado em 2008, trata de legibilidade: nomes, tamanho de funções, comentários, formatação, tratamento de erros. É o mais famoso dos três e também o mais criticado, porque algumas de suas regras, levadas ao extremo, produzem código fragmentado em dezenas de funções minúsculas. A abordagem desta aula é aproveitar os princípios, que são sólidos, e desconfiar dos números exatos.

Criando objetos: fábricas estáticas

O primeiro item de Effective Java é "considere métodos de fábrica estáticos em vez de construtores". Um método de fábrica estático é um método static que devolve uma instância da classe:

// Fábrica estática: nome que explica, e liberdade para reaproveitar instâncias
record Cor(int r, int g, int b) {
    static final Cor PRETO = new Cor(0, 0, 0);
    static Cor deHex(String hex) {                  // "#1e90ff"
        int v = Integer.parseInt(hex.substring(1), 16);
        return new Cor((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff);
    }
    static Cor cinza(int nivel) {
        return nivel == 0 ? PRETO : new Cor(nivel, nivel, nivel);
    }
}
IO.println(Cor.deHex("#1e90ff") + " " + (Cor.cinza(0) == Cor.PRETO));
IO.println(Integer.valueOf(127) == Integer.valueOf(127));
IO.println(Integer.valueOf(128) == Integer.valueOf(128));
Cor[r=30, g=144, b=255] true
true
false

As vantagens sobre um construtor aparecem no exemplo. Primeiro, a fábrica tem nome: Cor.deHex("#1e90ff") diz o que o texto significa, enquanto dois construtores Cor(String) com sentidos diferentes nem poderiam coexistir. Segundo, ela não precisa criar um objeto novo: cinza(0) devolve a constante PRETO, e é exatamente o que o Integer.valueOf faz para os valores de -128 a 127, guardados num cache, daí o true e o false da saída, a armadilha de comparar objetos com == de "Iguais ou o Mesmo?". Terceiro, ela pode devolver qualquer subtipo do tipo declarado: List.of(...) devolve implementações internas diferentes conforme o número de elementos, e quem chama só conhece a interface List.

A biblioteca padrão segue convenções de nomes para essas fábricas, e seguir as mesmas convenções torna uma API previsível: of para agregar valores (List.of, EnumSet.of), from para converter de outro tipo (Date.from(instant)), valueOf como alternativa mais longa a ambos, parse para interpretar texto (LocalDate.parse), newInstance ou create quando cada chamada cria um objeto novo, e getInstance quando pode devolver sempre o mesmo.

Muitos parâmetros: o Builder

Quando um objeto tem muitos atributos opcionais, os construtores se multiplicam: um com o obrigatório, outro com o obrigatório e o assunto, outro com o obrigatório, o assunto e o corpo, e assim por diante. Esse padrão, chamado de construtores telescópicos, produz chamadas como new Mensagem("ana@ilha.dev", "Relatório", "", true, null), em que é impossível saber, sem abrir a classe, o que é cada valor. O item 2 de Effective Java recomenda o Builder:

import java.util.*;

static final class Mensagem {
    private final String para, assunto, corpo;
    private final boolean urgente;
    private final List<String> anexos;

    private Mensagem(Construtor c) {
        this.para = c.para;
        this.assunto = c.assunto;
        this.corpo = c.corpo;
        this.urgente = c.urgente;
        this.anexos = List.copyOf(c.anexos);
    }

    static Construtor para(String destinatario) { return new Construtor(destinatario); }

    static final class Construtor {
        private final String para;               // obrigatório: vem no início
        private String assunto = "(sem assunto)";
        private String corpo = "";
        private boolean urgente = false;
        private final List<String> anexos = new ArrayList<>();

        private Construtor(String para) { this.para = Objects.requireNonNull(para); }
        Construtor assunto(String a) { this.assunto = a; return this; }
        Construtor corpo(String c)   { this.corpo = c; return this; }
        Construtor urgente()         { this.urgente = true; return this; }
        Construtor anexo(String a)   { this.anexos.add(a); return this; }
        Mensagem criar() {
            if (urgente && corpo.isBlank())
                throw new IllegalStateException("mensagem urgente precisa de corpo");
            return new Mensagem(this);
        }
    }

    @Override public String toString() {
        return (urgente ? "[URGENTE] " : "") + "para " + para + ": " + assunto + " " + anexos;
    }
}
Mensagem m = Mensagem.para("ana@ilha.dev")
        .assunto("Relatório de setembro")
        .corpo("Segue o relatório.")
        .anexo("setembro.pdf")
        .urgente()
        .criar();
IO.println(m);
try {
    Mensagem.para("bruno@ilha.dev").urgente().criar();
} catch (IllegalStateException e) {
    IO.println("recusada: " + e.getMessage());
}
[URGENTE] para ana@ilha.dev: Relatório de setembro [setembro.pdf]
recusada: mensagem urgente precisa de corpo

Cada valor vem acompanhado do nome do que ele é, os opcionais podem ser omitidos em qualquer combinação, e o objeto final é imutável: o construtor de Mensagem é privado, só o Construtor o chama, e a lista de anexos é copiada com List.copyOf, como no record Turma de "Listas, Conjuntos e Mapas". O parâmetro obrigatório vem no método de entrada, para(...), e não pode ser esquecido. E o criar() é o lugar natural para validar combinações de atributos, como a regra da mensagem urgente, que nenhum método isolado conseguiria verificar. Como este exemplo está num arquivo compacto, as classes precisam ser static, pelo motivo visto em "O Programa que se Examina"; num projeto com arquivos separados, Mensagem seria uma classe de primeiro nível comum.

O Builder custa código. Para objetos com poucos atributos, um record com um construtor compacto que valida os dados, como em "Dados sem Cerimônia", é mais simples e igualmente seguro. A recomendação de Bloch é considerá-lo a partir de uns quatro ou cinco parâmetros, especialmente se vários forem opcionais. Ele volta na Trilha A: o HttpClient do Java e boa parte das bibliotecas modernas usam Builders exatamente assim.

Singleton, e por que evitá-lo

O Singleton é o padrão GoF que garante que uma classe tenha uma única instância. Em "O Cadeado e a Memória", você implementou um de forma preguiçosa e segura entre threads, com a classe portadora. O item 3 de Effective Java aponta uma forma ainda mais simples, quando a preguiça não é necessária:

// Singleton com enum: uma instância, garantida pela JVM
enum Relogio {
    INSTANCIA;
    private final long inicio = System.nanoTime();
    long milissegundos() { return (System.nanoTime() - inicio) / 1_000_000; }
}

A JVM garante que cada constante de um enum seja criada uma única vez, de forma segura entre threads, e que nem a reflexão de "O Programa que se Examina" nem a serialização consigam criar uma segunda. É, nas palavras de Bloch, "muitas vezes a melhor forma de implementar um singleton".

Mas a pergunta mais importante é se deveria haver um singleton. Um Singleton é, na prática, uma variável global: qualquer código pode acessá-lo com Relogio.INSTANCIA, e isso cria dependências invisíveis. Em "Dublês e Disciplina", o ServicoDeLembretes recebia um Clock no construtor, e por isso o teste podia passar um relógio fixo. Se ele usasse Relogio.INSTANCIA diretamente, o teste dependeria da hora real, e não haveria como substituí-lo. Por isso, a prática moderna é ter uma única instância criada no início do programa e passada a quem precisa, em vez de uma classe que impõe ser única. É exatamente o que os contêineres de injeção de dependências, como o Spring da Trilha A, fazem: os objetos são únicos por configuração, e não por construção.

Não crie objetos desnecessários

Criar objetos em Java é barato, como "Quem Recolhe o Lixo" mostrou, mas não é de graça, e alguns objetos são criados sem que o programador perceba. O caso clássico, do item 6 de Effective Java, é o empacotamento automático (autoboxing):

void main() {
    for (int r = 0; r < 3; r++) {
        long t0 = System.nanoTime();
        Long somaObjeto = 0L;                       // um L maiúsculo...
        for (long i = 0; i < 100_000_000L; i++) somaObjeto += i;
        long t1 = System.nanoTime();
        long somaPrimitiva = 0L;
        for (long i = 0; i < 100_000_000L; i++) somaPrimitiva += i;
        long t2 = System.nanoTime();
        IO.println("Long: " + (t1 - t0) / 1_000_000 + " ms | long: " + (t2 - t1) / 1_000_000 + " ms | iguais? " + (somaObjeto == somaPrimitiva));
    }
}
Long: 682 ms | long: 42 ms | iguais? true
Long: 442 ms | long: 31 ms | iguais? true
Long: 431 ms | long: 32 ms | iguais? true

A única diferença é uma letra: Long em vez de long. Com o tipo empacotado, cada somaObjeto += i desempacota o valor, soma e empacota o resultado num Long novo, porque os objetos Long são imutáveis. São cem milhões de objetos criados e descartados, e o laço fica mais de dez vezes mais lento. A regra, vista em "Cada Valor em sua Caixa", é preferir tipos primitivos, e usar os empacotados apenas onde a linguagem exige objetos, como nas coleções. Outros casos comuns são compilar a mesma expressão regular a cada chamada, em vez de guardar o Pattern numa constante, como em "Tempo, Dinheiro e Padrões", e concatenar Strings num laço, em vez de usar um StringBuilder.

O item tem um contrapeso importante, que Bloch faz questão de registrar: não crie caches e pools de objetos comuns para "economizar". A JVM moderna cria e recolhe objetos pequenos de vida curta de forma extremamente eficiente, e a análise de escape de "A Máquina por Trás da Máquina" muitas vezes nem os cria. Um pool feito à mão acrescenta complexidade, riscos de concorrência e, frequentemente, piora o desempenho. Como sempre, o critério é o de "Medir Antes de Otimizar".

Composição em vez de herança

O item 18 de Effective Java é um dos mais importantes: "prefira composição a herança". O exemplo clássico é um conjunto que conta quantos elementos já foram adicionados, feito de duas formas:

import java.util.*;

// Herança: conta quantos elementos já foram adicionados
static class ConjuntoContadoHeranca<E> extends HashSet<E> {
    private int adicionados = 0;
    @Override public boolean add(E e) { adicionados++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { adicionados += c.size(); return super.addAll(c); }
    int adicionados() { return adicionados; }
}

// Composição: embrulha qualquer Set e repassa as chamadas
static class ConjuntoContado<E> extends AbstractSet<E> {
    private final Set<E> interno;
    private int adicionados = 0;
    ConjuntoContado(Set<E> interno) { this.interno = interno; }
    @Override public boolean add(E e) { adicionados++; return interno.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { adicionados += c.size(); return interno.addAll(c); }
    @Override public Iterator<E> iterator() { return interno.iterator(); }
    @Override public int size() { return interno.size(); }
    int adicionados() { return adicionados; }
}

void main() {
    var h = new ConjuntoContadoHeranca<String>();
    h.addAll(List.of("java", "jvm", "jdk"));
    IO.println("herança:    " + h.adicionados());
    var c = new ConjuntoContado<String>(new HashSet<>());
    c.addAll(List.of("java", "jvm", "jdk"));
    IO.println("composição: " + c.adicionados());
    var ordenado = new ConjuntoContado<String>(new TreeSet<>());
    ordenado.addAll(List.of("jvm", "java", "jdk"));
    IO.println("composição sobre TreeSet: " + ordenado + " (" + ordenado.adicionados() + ")");
}
herança:    6
composição: 3
composição sobre TreeSet: [java, jdk, jvm] (3)

Três elementos foram adicionados, e a versão com herança diz seis. O motivo é um detalhe de implementação do HashSet: seu addAll percorre a coleção chamando add para cada elemento. Como add foi sobrescrito, cada elemento é contado duas vezes, uma no addAll e outra no add. A subclasse depende de como a superclasse foi escrita por dentro, algo que a documentação nem promete e que pode mudar numa versão futura. Esse problema tem nome: a fragilidade da classe base, e é a razão pela qual "Do Geral ao Específico" recomendou herança apenas para relações "é um" verdadeiras, entre classes projetadas para isso.

A versão com composição tem um conjunto em vez de ser um. Ela repassa cada chamada ao objeto interno e acrescenta o que precisa, sem depender de como o interno funciona: o addAll do HashSet interno chama o add dele, não o do embrulho. Há um bônus: como recebe qualquer Set, a mesma classe conta adições num TreeSet, ordenado, ou em qualquer outra implementação. A herança prendia a contagem ao HashSet. Este embrulho é o padrão Decorator, visto na próxima seção.

Padrões de projeto com Java moderno

Muitos padrões GoF foram criados para contornar limitações de linguagens de 1994, e alguns ficaram muito mais simples com os recursos que o Java ganhou desde então. Três exemplos, num programa só:

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.*;
import java.util.function.*;

record Pedido(String cliente, BigDecimal valor, double pesoKg) {}

// STRATEGY: a regra de frete é um valor que se troca
@FunctionalInterface
interface Frete { BigDecimal calcular(Pedido p); }

enum Modalidade implements Frete {
    ECONOMICO(p -> new BigDecimal("10.00")),
    POR_PESO(p -> BigDecimal.valueOf(p.pesoKg()).multiply(new BigDecimal("4.50")).setScale(2, RoundingMode.HALF_EVEN)),
    GRATIS_ACIMA_DE_200(p -> p.valor().compareTo(new BigDecimal("200")) >= 0
                              ? BigDecimal.ZERO : new BigDecimal("15.00"));

    private final Frete regra;
    Modalidade(Frete regra) { this.regra = regra; }
    public BigDecimal calcular(Pedido p) { return regra.calcular(p); }
}

// OBSERVER: quem se interessa por um evento se inscreve
static final class Loja {
    private final List<Consumer<Pedido>> ouvintes = new ArrayList<>();
    void aoFecharPedido(Consumer<Pedido> ouvinte) { ouvintes.add(ouvinte); }
    void fechar(Pedido p) { ouvintes.forEach(o -> o.accept(p)); }
}

// DECORATOR: acrescenta comportamento embrulhando, sem herdar
interface Notificador { void enviar(String texto); }

record ComPrefixo(String prefixo, Notificador interno) implements Notificador {
    public void enviar(String texto) { interno.enviar(prefixo + texto); }
}

record ComRegistro(List<String> registro, Notificador interno) implements Notificador {
    public void enviar(String texto) { registro.add(texto); interno.enviar(texto); }
}

void main() {
    Pedido p = new Pedido("Ana", new BigDecimal("180.00"), 2.0);
    for (Modalidade m : Modalidade.values())
        IO.println(m + ": R$ " + m.calcular(p));
    Frete promocional = pedido -> BigDecimal.ONE;   // uma estratégia nova, sem classe nova
    IO.println("promocional: R$ " + promocional.calcular(p));

    Loja loja = new Loja();
    List<String> estoque = new ArrayList<>();
    loja.aoFecharPedido(ped -> IO.println("e-mail para " + ped.cliente()));
    loja.aoFecharPedido(ped -> estoque.add("baixa de " + ped.pesoKg() + " kg"));
    loja.fechar(p);
    IO.println(estoque);

    List<String> registro = new ArrayList<>();
    Notificador console = texto -> IO.println("console: " + texto);
    Notificador completo = new ComRegistro(registro, new ComPrefixo("[loja] ", console));
    completo.enviar("pedido de Ana fechado");
    IO.println("registro: " + registro);
}
ECONOMICO: R$ 10.00
POR_PESO: R$ 9.00
GRATIS_ACIMA_DE_200: R$ 15.00
promocional: R$ 1
e-mail para Ana
[baixa de 2.0 kg]
console: [loja] pedido de Ana fechado
registro: [pedido de Ana fechado]

O Strategy encapsula um algoritmo intercambiável atrás de uma interface. No livro original, cada estratégia é uma classe; com uma interface funcional, como em "Comportamento como Valor", cada estratégia é uma lambda, e uma nova, como o frete promocional, é criada numa linha. O enum Modalidade reúne as estratégias conhecidas com nomes, e pode ser guardado num banco de dados ou escolhido numa tela, enquanto a interface continua aberta a estratégias novas. O Comparator de "Ordem e Critério" é o Strategy mais usado da biblioteca padrão.

O Observer permite que vários objetos reajam a um evento sem que quem o dispara os conheça. A Loja não sabe que existe um serviço de e-mail ou um controle de estoque: só mantém uma lista de ouvintes, que no Java moderno são simplesmente Consumers. Os ouvintes de eventos de interfaces gráficas, na Trilha B, seguem exatamente este desenho.

O Decorator acrescenta comportamento a um objeto embrulhando-o num outro com a mesma interface, como o conjunto contado da seção anterior. Os decoradores se empilham, e a ordem importa: o ComRegistro está por fora, então registra o texto antes de o ComPrefixo acrescentar o prefixo, e o registro guardou a mensagem sem "[loja] ". Com records, cada decorador é uma linha de declaração. Os fluxos de entrada e saída do Java são o Decorator clássico: um BufferedReader embrulha um InputStreamReader, que embrulha um InputStream.

Outros padrões mudaram tanto que quase desapareceram. O Template Method, em que uma superclasse define os passos de um algoritmo e as subclasses preenchem alguns deles, é hoje quase sempre um método que recebe uma função. O Visitor, usado para acrescentar operações a uma hierarquia de classes sem alterá-la, foi em grande parte substituído pelas hierarquias seladas com switch e pattern matching de "Hierarquias Fechadas": o compilador verifica que todos os casos foram tratados, que era justamente a garantia que o Visitor oferecia com muito mais código. E o Iterator está embutido na linguagem desde o for aprimorado do Java 5.

A lição geral é que padrões são nomes para ideias, não trechos de código para copiar. Reconhecer um Strategy numa lambda passada a um método, ou um Decorator num stream de E/S, é o que importa. Aplicar padrões por aplicar produz o estilo que virou piada na comunidade Java, com classes chamadas AbstractSingletonProxyFactoryBean, que existe de verdade no Spring. Um padrão se justifica quando resolve um problema que o código realmente tem.

Código limpo: nomes e funções

A lição mais duradoura de Clean Code é que código é lido muito mais vezes do que é escrito, e deve ser otimizado para quem lê. O método abaixo funciona, e é um exemplo de quase tudo o que o livro critica:

record Tarefa(int id, String titulo, LocalDate prazo, boolean concluida) {}

// Antes: um método que faz tudo, controlado por um número mágico
static String proc(List<Tarefa> l, int t, LocalDate d) {
    String r = "";
    int c = 0;
    for (int i = 0; i < l.size(); i++) {
        Tarefa x = l.get(i);
        // t == 1: atrasadas; t == 2: pendentes; outro: todas
        if ((t == 1 && !x.concluida() && x.prazo().isBefore(d)) || (t == 2 && !x.concluida()) || (t != 1 && t != 2)) {
            r = r + "- " + x.titulo() + (x.concluida() ? " (ok)" : "") + "\n";
            c++;
        }
    }
    if (c == 0) r = "nada\n";
    return r;
}

Os problemas, um por um: o nome proc não diz o que o método faz, e l, t, d, r, c e x obrigam o leitor a decifrar cada variável. O parâmetro t é um número mágico que muda o comportamento do método, e quem chama proc(tarefas, 1, hoje) não tem como saber o que o 1 significa sem ler o comentário dentro do método. A condição do if mistura três regras numa linha. E o método faz três coisas: seleciona as tarefas, formata cada uma e trata o caso vazio. A versão reescrita, com o mesmo resultado:

record Tarefa(int id, String titulo, LocalDate prazo, boolean concluida) {
    boolean pendente() { return !concluida; }
    boolean atrasada(LocalDate hoje) { return pendente() && prazo.isBefore(hoje); }
}

// Depois: o critério é um parâmetro com nome, e cada função faz uma coisa
static String listar(List<Tarefa> tarefas, Predicate<Tarefa> criterio) {
    List<Tarefa> selecionadas = tarefas.stream().filter(criterio).toList();
    if (selecionadas.isEmpty()) return "nada\n";
    return selecionadas.stream()
            .map(t -> "- " + t.titulo() + (t.concluida() ? " (ok)" : "") + "\n")
            .collect(Collectors.joining());
}
IO.print(listar(tarefas, t -> t.atrasada(hoje)));
IO.print(listar(tarefas, Tarefa::pendente));
- Pagar a conta de luz
- Pagar a conta de luz
- Revisar o artigo

As regras de negócio, "pendente" e "atrasada", foram para o record, com nomes, onde podem ser testadas e reutilizadas, como no projeto de tarefas de "Do Arquivo Solto ao Projeto". O número mágico virou um Predicate, e a chamada listar(tarefas, t -> t.atrasada(hoje)) se lê como uma frase. O comentário que explicava o t desapareceu, porque o código deixou de precisar dele. E a concatenação de String num laço deu lugar ao Collectors.joining de "Agrupar, Achatar, Reduzir".

Essa reescrita ilustra as principais recomendações do livro. Nomes revelam intenção: uma variável se chama pelo que ela significa, e um método, pelo que ele faz; nomes curtos só servem em escopos minúsculos, como o t de uma lambda de uma linha. Funções fazem uma coisa, num único nível de abstração. Parâmetros de controle, sejam booleans ou números que escolhem o comportamento, indicam que a função faz mais de uma coisa, e devem virar funções separadas ou um parâmetro com comportamento. Comentários explicam o porquê, não o quê: um comentário que descreve o que o código faz é, em geral, um sinal de que o código poderia ser mais claro, enquanto um que explica por que uma decisão estranha foi tomada ("o fornecedor devolve datas em UTC mesmo sem indicar o fuso") é valioso.

Onde o livro exagera é nos números. Martin defende funções de poucas linhas e o mínimo possível de parâmetros, e código escrito ao pé da letra dessas regras se espalha em dezenas de métodos de três linhas, que obrigam o leitor a pular de um para outro para entender qualquer coisa. John Ousterhout, em A Philosophy of Software Design (2018), argumenta o oposto: módulos profundos, com interfaces simples e bastante lógica por trás, são mais fáceis de usar do que muitos módulos rasos. O critério útil não é o tamanho, e sim se cada unidade tem um propósito claro e um nome que o descreve.

SOLID, em uma página

Robert Martin também popularizou cinco princípios de desenho orientado a objetos, conhecidos pela sigla SOLID, que aparecem em quase toda entrevista de emprego. Todos já apareceram neste curso, com outros nomes:

  • S, responsabilidade única (Single Responsibility): uma classe deve ter um só motivo para mudar. O ServicoDeLembretes de "Dublês e Disciplina" decidia quais lembretes enviar, e delegava como guardá-los ao repositório e como enviá-los ao notificador.
  • O, aberto/fechado (Open/Closed): o código deve estar aberto para extensão e fechado para modificação. O frete promocional desta aula foi acrescentado sem alterar nenhuma linha existente.
  • L, substituição de Liskov (Liskov Substitution): um subtipo deve funcionar em qualquer lugar onde o tipo base funciona. O conjunto contado por herança o viola: ele não se comporta como um HashSet do ponto de vista de quem conta.
  • I, segregação de interfaces (Interface Segregation): é melhor ter várias interfaces pequenas que uma grande. As interfaces funcionais de java.util.function são o extremo disso, com um método cada.
  • D, inversão de dependência (Dependency Inversion): o código de alto nível deve depender de abstrações, não de implementações. O ServicoDeLembretes recebia um Notificador e um Clock, e não um cliente de e-mail concreto e o relógio do sistema.

Esses princípios são úteis como perguntas a fazer sobre um desenho, e perigosos como leis a aplicar sempre. Um programa de cem linhas não precisa de uma interface para cada classe. A versão mais sensata de todos eles é o princípio YAGNI, da programação extrema, "você não vai precisar disso" (You Aren't Gonna Need It): não crie a abstração antes de ter o segundo caso concreto que a justifique.

O fio que atravessa os três livros é a preocupação com o que acontece depois que o código é escrito: quem vai lê-lo, quem vai alterá-lo, e o que pode quebrar quando isso acontecer. Três itens de Effective Java ficaram de fora desta aula porque merecem uma aula própria: "minimize a mutabilidade", "faça cópias defensivas quando necessário" e "verifique a validade dos parâmetros". Os três tratam do mesmo tema: como uma classe se protege de quem a usa, e como deixa claro, no próprio código, o que aceita e o que promete.

Fontes e leituras recomendadas

  • Joshua Bloch, Effective Java, 3ª edição (Addison-Wesley, 2018): os 90 itens, com destaque para o capítulo 2 (criação de objetos, itens 1 a 9) e o capítulo 4 (classes e interfaces, incluindo o item 18 sobre composição). É o livro de Java mais recomendado por programadores experientes.
  • Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994): o catálogo original dos 23 padrões. Os capítulos 1 e 2, que explicam a filosofia do livro, valem mais que o catálogo.
  • Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship (Prentice Hall, 2008): capítulos 2 (nomes), 3 (funções) e 4 (comentários) são os mais úteis.
  • John Ousterhout, A Philosophy of Software Design, 2ª edição (Yaknyam Press, 2021): um contraponto importante a Clean Code, com a ideia de módulos profundos e a crítica às funções excessivamente pequenas.
  • Refactoring.Guru, "Design Patterns": um catálogo ilustrado e gratuito dos padrões GoF, com exemplos em Java, útil como referência rápida. https://refactoring.guru/design-patterns

Exercícios

Exercício 1

A classe Relatorio tem os construtores Relatorio(String titulo), Relatorio(String titulo, String autor), Relatorio(String titulo, String autor, LocalDate data) e Relatorio(String titulo, String autor, LocalDate data, boolean confidencial, int copias). Identifique o problema, e decida entre um Builder e um record com fábricas estáticas, justificando.

Ver resposta

✓ Resposta: São construtores telescópicos. Uma chamada como new Relatorio("Vendas", null, null, true, 3) é ilegível: sem abrir a classe, não se sabe o que true e 3 significam, e é preciso passar null para os opcionais do meio só para chegar aos do fim. Além disso, dois parâmetros do mesmo tipo em sequência, como título e autor, podem ser trocados sem que o compilador perceba. Com cinco atributos, quatro opcionais, e combinações que não seguem uma ordem natural (um relatório confidencial sem autor, por exemplo), um Builder é a escolha adequada: Relatorio.comTitulo("Vendas").confidencial().copias(3).criar() diz o que cada valor é, permite qualquer combinação e concentra as validações no criar(), como "um relatório confidencial não pode ter mais de 5 cópias". Um record com fábricas estáticas funcionaria bem se houvesse poucos casos típicos com nomes, como Relatorio.rascunho(titulo) e Relatorio.publicado(titulo, autor, data), mas com quatro opcionais independentes o número de fábricas explodiria da mesma forma que os construtores.

Exercício 2

Explique por que Integer.valueOf(127) == Integer.valueOf(127) é true e Integer.valueOf(128) == Integer.valueOf(128) é false, e o que isso ensina sobre fábricas estáticas e sobre ==.

Ver resposta

✓ Resposta: O Integer.valueOf é uma fábrica estática que mantém um cache com os objetos Integer de -128 a 127, criados uma vez. Para valores nessa faixa, ele devolve sempre o mesmo objeto, e ==, que compara identidade, dá true. Para 128, cada chamada cria um objeto novo, e dois objetos diferentes com o mesmo valor dão false. Sobre fábricas estáticas, o exemplo mostra a vantagem que um construtor não tem: a liberdade de reaproveitar instâncias, economizando memória para os valores mais usados. O construtor new Integer(...) foi descontinuado justamente para que todo código passe pela fábrica. Sobre ==, reforça a lição de "Iguais ou o Mesmo?": objetos se comparam com equals. Um código que compara Integers com == funciona nos testes com números pequenos e falha em produção com números maiores, um dos defeitos mais traiçoeiros do Java, e o empacotamento automático, que cria esses objetos sem que apareçam no código, torna o erro ainda mais fácil de cometer.

Exercício 3

No exemplo da herança, a correção mais óbvia para a contagem dupla seria remover o addAll sobrescrito da subclasse, já que o addAll do HashSet chama add. Por que essa correção também é frágil?

Ver resposta

✓ Resposta: Ela funciona hoje porque depende de um detalhe de implementação do HashSet: o fato de o addAll herdado de AbstractCollection chamar add para cada elemento. Esse comportamento não faz parte do contrato documentado do HashSet. Se uma versão futura do Java otimizar o addAll, por exemplo inserindo os elementos diretamente na tabela interna sem passar por add, a subclasse deixa de contar os elementos adicionados em lote, sem nenhum erro de compilação e sem que nenhum código do projeto tenha mudado. A subclasse estaria correta ou errada conforme a versão da JVM. Esse é o problema central da herança de classes concretas: a subclasse depende não só do que a superclasse promete, mas de como ela cumpre a promessa. A composição elimina a dependência, porque o embrulho controla todas as entradas e só usa os métodos do objeto interno pelo que eles prometem. A alternativa, quando a herança é desejada, é a superclasse documentar explicitamente o uso interno de seus métodos sobrescrevíveis, como fazem as classes AbstractCollection e AbstractSet do próprio Java, projetadas para serem estendidas.

Exercício 4

Reescreva o método abaixo seguindo as recomendações da aula e explique cada mudança.

// calcula
static double calc(double v, boolean b, int tp) {
    double r;
    if (tp == 1) r = v * 0.1; else if (tp == 2) r = v * 0.15; else r = 0;
    if (b) r = r / 2;
    return r;
}
Ver resposta

✓ Resposta: O método calcula um imposto conforme uma categoria, com metade do valor para quem tem um benefício. Uma reescrita:

enum Categoria {
    SERVICO(new BigDecimal("0.10")),
    COMERCIO(new BigDecimal("0.15")),
    ISENTO(BigDecimal.ZERO);

    private final BigDecimal aliquota;
    Categoria(BigDecimal aliquota) { this.aliquota = aliquota; }

    BigDecimal impostoSobre(BigDecimal valor) {
        return valor.multiply(aliquota).setScale(2, RoundingMode.HALF_EVEN);
    }
}

static BigDecimal impostoComBeneficio(BigDecimal valor, Categoria categoria) {
    // A lei 1234/2025 concede 50% de desconto a microempresas
    return categoria.impostoSobre(valor).divide(BigDecimal.TWO, 2, RoundingMode.HALF_EVEN);
}

As mudanças: o número mágico tp virou o enum Categoria, e cada alíquota ficou junto do nome que a explica, como os enums com atributos de "Dados sem Cerimônia"; uma categoria nova é uma linha no enum, sem alterar nenhum if. O boolean b era um parâmetro de controle, e virou um método separado com nome, impostoComBeneficio, porque "com benefício" e "sem benefício" são duas operações. O double virou BigDecimal, porque é dinheiro, pelo motivo de "Tempo, Dinheiro e Padrões". O comentário inútil "calcula" desapareceu, e o novo comentário explica por que existe o desconto, algo que o código não tem como dizer. E os nomes calc, v, r foram substituídos por nomes que revelam a intenção. O resultado tem mais linhas, e mesmo assim é mais fácil de ler, testar e alterar.

Exercício 5

Um colega propõe criar, para o projeto de tarefas, uma interface RepositorioDeTarefas, uma FabricaDeRepositorios, uma EstrategiaDeOrdenacao e um GerenciadorDeEventosDeTarefa, "para seguir o SOLID". Hoje o projeto guarda tarefas só em memória, sempre ordenadas por prazo, e ninguém reage a eventos. Como você avaliaria a proposta?

Ver resposta

✓ Resposta: Cada abstração deve ser justificada por um problema concreto, não por um princípio. A interface RepositorioDeTarefas já se justifica: em "Dublês e Disciplina", ela permitiu testar o ServicoDeLembretes com um dublê, ao lado da implementação em memória do projeto: são dois usos reais, e um banco de dados seria o terceiro. A FabricaDeRepositorios não resolve problema nenhum: com uma única implementação em uso por vez, basta criá-la no main e passá-la adiante; uma fábrica só se justificaria se a escolha da implementação fosse complexa e acontecesse em vários lugares. A EstrategiaDeOrdenacao duplica o que já existe: um Comparator<Tarefa> é exatamente uma estratégia de ordenação, e pode ser introduzido como parâmetro quando surgir a segunda ordem. O GerenciadorDeEventosDeTarefa é especulação pura, porque ninguém reage a eventos; quando alguém precisar, uma lista de Consumer<Tarefa>, como a da Loja, resolve em três linhas. É o princípio YAGNI: cada abstração tem custo de leitura, de navegação e de manutenção, e abstrações criadas antes do segundo caso concreto geralmente acertam o formato errado. A resposta ao colega seria manter a interface do repositório e esperar os problemas reais para criar as outras.

Comentários

Mais em Java

Tempo, Dinheiro e Padrões — java.time, BigDecimal e Expressões Regulares
Tempo, Dinheiro e Padrões — java.time, BigDecimal e Expressões Regulares

Datas, horas e fusos com java.time, dinheiro exato com BigDecimal e padrões de…

Extensão — MongoDB: Documentos, Agregações e Quando Escolher Cada Modelo
Extensão — MongoDB: Documentos, Agregações e Quando Escolher Cada Modelo

Os relatos das Trilhas da Ilha no MongoDB, com ou sem Docker: documentos…

O Programa que Escolhe e Repete — if, switch e Laços
O Programa que Escolhe e Repete — if, switch e Laços

O if e a condição que precisa ser boolean, o switch clássico com fall-through…